Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1db7ce877e | |||
| 2640821a05 | |||
| d188bba16e | |||
| d584340f01 | |||
| c22787792d | |||
| 81a9ec0c50 | |||
| 0ab777d898 | |||
| 66cbd23013 | |||
| 9d1261b74d | |||
| 46af205aef | |||
| cc916c06ce | |||
| 60aa5fcb34 | |||
| 787118309a | |||
| aec4ef8339 |
32 changed files with 2653 additions and 480 deletions
1153
Cargo.lock
generated
1153
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,9 +2,10 @@
|
||||||
members = ["cli", "discord"]
|
members = ["cli", "discord"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.9.0"
|
version = "0.12.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest"
|
repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest"
|
||||||
|
homepage = "https://2ndbeam.ru/git/2ndbeam/squad-quest"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
|
|
@ -13,6 +14,7 @@ edition.workspace = true
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
|
|
||||||
2
LICENSE
2
LICENSE
|
|
@ -1,4 +1,4 @@
|
||||||
Copyright 2025 (c) 2ndbeam
|
Copyright 2025 (c) Alexey Mirenkov <2ndbeam@disroot.org>
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
|
|
||||||
23
build-deb.sh
Executable file
23
build-deb.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
||||||
|
#!/bin/sh
|
||||||
|
cargo build --workspace --release
|
||||||
|
|
||||||
|
install -dvm755 target/release/dpkg/etc/squad_quest target/release/dpkg/usr/bin target/release/dpkg/DEBIAN target/release/dpkg/usr/share/doc/squad-quest
|
||||||
|
|
||||||
|
strip target/release/squad-quest-cli
|
||||||
|
strip target/release/squad-quest-discord
|
||||||
|
install -vm755 target/release/squad-quest-cli target/release/squad-quest-discord target/release/dpkg/usr/bin
|
||||||
|
|
||||||
|
install -vm 644 LICENSE target/release/dpkg/usr/share/doc/squad-quest/copyright
|
||||||
|
|
||||||
|
target/release/squad-quest-cli -qc nil init -i discord.toml -p target/release/dpkg/etc/squad_quest
|
||||||
|
cargo build --bin deb --release
|
||||||
|
target/release/deb > target/release/dpkg/DEBIAN/control
|
||||||
|
|
||||||
|
echo -n "" > target/release/dpkg/DEBIAN/conffiles
|
||||||
|
for file in $(ls target/release/dpkg/etc/squad_quest); do
|
||||||
|
if [ -f target/release/dpkg/etc/squad_quest/$file ]; then
|
||||||
|
echo "/etc/squad_quest/$file" >> target/release/dpkg/DEBIAN/conffiles
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
dpkg-deb --root-owner-group --build target/release/dpkg target/release/squad-quest.deb
|
||||||
|
|
@ -9,5 +9,5 @@ license.workspace = true
|
||||||
chrono = "0.4.42"
|
chrono = "0.4.42"
|
||||||
clap = { version = "4.5.53", features = ["derive"] }
|
clap = { version = "4.5.53", features = ["derive"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
squad-quest = { version = "0.9.0", path = ".." }
|
squad-quest = { version = "0.12.0", path = ".." }
|
||||||
toml = "0.9.8"
|
toml = "0.9.8"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ pub enum MapCommands {
|
||||||
Delete(MapDeleteArgs),
|
Delete(MapDeleteArgs),
|
||||||
/// Update room data
|
/// Update room data
|
||||||
Update(MapUpdateArgs),
|
Update(MapUpdateArgs),
|
||||||
|
/// Get room implementation data
|
||||||
|
Data(MapDataArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
|
|
@ -55,3 +57,9 @@ pub struct MapUpdateArgs {
|
||||||
#[arg(short,long)]
|
#[arg(short,long)]
|
||||||
pub value: Option<u32>,
|
pub value: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct MapDataArgs {
|
||||||
|
/// Room ID
|
||||||
|
pub id: u16,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,4 +40,6 @@ pub enum Objects {
|
||||||
pub struct InitArgs {
|
pub struct InitArgs {
|
||||||
#[arg(long,short)]
|
#[arg(long,short)]
|
||||||
pub path: Option<PathBuf>,
|
pub path: Option<PathBuf>,
|
||||||
|
#[arg(long,short)]
|
||||||
|
pub implpath: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,9 @@ pub struct QuestCreateArgs {
|
||||||
/// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24)
|
/// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24)
|
||||||
#[arg(short,long,value_parser = parse_date)]
|
#[arg(short,long,value_parser = parse_date)]
|
||||||
pub deadline: Option<Date>,
|
pub deadline: Option<Date>,
|
||||||
|
/// Limit on how many users can solve the quest (0 = no limit)
|
||||||
|
#[arg(short,long)]
|
||||||
|
pub limit: Option<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
|
|
@ -113,6 +116,9 @@ pub struct QuestUpdateArgs {
|
||||||
/// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24)
|
/// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24)
|
||||||
#[arg(long,value_parser = parse_date)]
|
#[arg(long,value_parser = parse_date)]
|
||||||
pub deadline: Option<Date>,
|
pub deadline: Option<Date>,
|
||||||
|
/// Limit on how many users can solve the quest (0 = no limit)
|
||||||
|
#[arg(long)]
|
||||||
|
pub limit: Option<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ fn main() {
|
||||||
|
|
||||||
let config = Config {
|
let config = Config {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
|
impl_path: args.implpath.clone(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -146,7 +147,9 @@ fn main() {
|
||||||
answer: args.answer.clone(),
|
answer: args.answer.clone(),
|
||||||
public: args.public,
|
public: args.public,
|
||||||
available_on: args.available.clone(),
|
available_on: args.available.clone(),
|
||||||
deadline: args.deadline.clone()
|
deadline: args.deadline.clone(),
|
||||||
|
limit: args.limit.unwrap_or_default(),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
do_and_log(quest.save(path), !cli.quiet, format!("Created quest #{}.", quest.id));
|
do_and_log(quest.save(path), !cli.quiet, format!("Created quest #{}.", quest.id));
|
||||||
|
|
@ -168,7 +171,9 @@ fn main() {
|
||||||
answer: args.answer.clone().unwrap_or(quest.answer.clone()),
|
answer: args.answer.clone().unwrap_or(quest.answer.clone()),
|
||||||
public: args.public.unwrap_or(quest.public),
|
public: args.public.unwrap_or(quest.public),
|
||||||
available_on: args.available.clone().or(quest.available_on.clone()),
|
available_on: args.available.clone().or(quest.available_on.clone()),
|
||||||
deadline: args.deadline.clone().or(quest.deadline.clone())
|
deadline: args.deadline.clone().or(quest.deadline.clone()),
|
||||||
|
limit: args.limit.unwrap_or_default(),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
do_and_log(quest.save(path), !cli.quiet, format!("Updated quest #{}.", quest.id));
|
do_and_log(quest.save(path), !cli.quiet, format!("Updated quest #{}.", quest.id));
|
||||||
|
|
@ -281,10 +286,6 @@ fn main() {
|
||||||
do_and_log(account.save(path), !cli.quiet, format!("Updated balance of account \"{}\".", account.id));
|
do_and_log(account.save(path), !cli.quiet, format!("Updated balance of account \"{}\".", account.id));
|
||||||
},
|
},
|
||||||
AccountCommands::Complete(args) => {
|
AccountCommands::Complete(args) => {
|
||||||
let Some(account) = accounts.iter_mut().find(|a| a.id == args.account) else {
|
|
||||||
if !cli.quiet { eprintln!("Error: account \"{}\" not found.", args.account); }
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let quests = config.load_quests();
|
let quests = config.load_quests();
|
||||||
|
|
||||||
|
|
@ -296,9 +297,17 @@ fn main() {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
match quest.complete_for_account(account) {
|
let result = quest.complete_for_account(&args.account, &mut accounts);
|
||||||
|
|
||||||
|
match result {
|
||||||
Err(error) if !cli.quiet => println!("Error: {error}"),
|
Err(error) if !cli.quiet => println!("Error: {error}"),
|
||||||
Ok(_) => do_and_log(account.save(path), !cli.quiet, format!("Completed quest #{} on account \"{}\".", args.quest, account.id)),
|
Ok(_) => {
|
||||||
|
let Some(account) = accounts.iter_mut().find(|a| a.id == args.account) else {
|
||||||
|
if !cli.quiet { eprintln!("Error: account \"{}\" not found.", args.account); }
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
do_and_log(account.save(path), !cli.quiet, format!("Completed quest #{} on account \"{}\".", args.quest, account.id));
|
||||||
|
},
|
||||||
_ => {},
|
_ => {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -444,6 +453,15 @@ fn main() {
|
||||||
let connected = if connect { "Connected" } else { "Disconnected" };
|
let connected = if connect { "Connected" } else { "Disconnected" };
|
||||||
do_and_log(map_save(map, map_path), !cli.quiet, format!("{connected} rooms #{} <-> #{}.", args.first, args.second));
|
do_and_log(map_save(map, map_path), !cli.quiet, format!("{connected} rooms #{} <-> #{}.", args.first, args.second));
|
||||||
},
|
},
|
||||||
|
MapCommands::Data(args) => {
|
||||||
|
if let Some(room) = map.room.iter().find(|r| r.id == args.id) {
|
||||||
|
if let Some(data) = &room.data {
|
||||||
|
for (key, value) in data {
|
||||||
|
println!("{key} = {value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,13 @@ repository.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = "0.4.42"
|
||||||
clap = { version = "4.5.53", features = ["derive"] }
|
clap = { version = "4.5.53", features = ["derive"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
poise = "0.6.1"
|
poise = "0.6.1"
|
||||||
|
rocket = { version = "0.5.1", features = ["json"] }
|
||||||
serde = "1.0.228"
|
serde = "1.0.228"
|
||||||
squad-quest = { version = "0.9.0", path = ".." }
|
serde_json = "1.0.146"
|
||||||
|
squad-quest = { version = "0.12.0", path = ".." }
|
||||||
tokio = { version = "1.48.0", features = ["rt-multi-thread"] }
|
tokio = { version = "1.48.0", features = ["rt-multi-thread"] }
|
||||||
toml = "0.9.8"
|
toml = "0.9.8"
|
||||||
|
|
|
||||||
8
discord/Rocket.toml
Normal file
8
discord/Rocket.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[default]
|
||||||
|
address = "127.0.0.1" # should be local only because frontend runs on the same machine
|
||||||
|
port = 2526
|
||||||
|
log_level = "critical"
|
||||||
|
|
||||||
|
[default.shutdown]
|
||||||
|
ctrlc = false
|
||||||
|
|
||||||
|
|
@ -1,12 +1,50 @@
|
||||||
use squad_quest::{account::Account, config::Config};
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub fn fetch_or_init_account(conf: &Config, id: String) -> Account {
|
use poise::serenity_prelude::{User, UserId};
|
||||||
|
use squad_quest::{account::Account, config::Config, map::Map};
|
||||||
|
|
||||||
|
/// Returns Ok(account) if account was found or Err(new_account) if not
|
||||||
|
pub fn fetch_or_init_account(conf: &Config, id: &str, user: Option<&User>) -> Result<Account, Account> {
|
||||||
let accounts = conf.load_accounts();
|
let accounts = conf.load_accounts();
|
||||||
match accounts.iter().find(|a| a.id == id) {
|
|
||||||
Some(a) => a.clone(),
|
if let Some(account) = accounts.iter().find(|a| a.id == id) {
|
||||||
None => Account {
|
return Ok(account.clone());
|
||||||
id,
|
}
|
||||||
|
|
||||||
|
let mut data: HashMap<String, String> = HashMap::new();
|
||||||
|
|
||||||
|
if let Some(user) = user {
|
||||||
|
let avatar = user.avatar_url().unwrap_or("null".to_string());
|
||||||
|
let name = user.display_name().to_string();
|
||||||
|
data.insert("avatar".to_string(), avatar);
|
||||||
|
data.insert("name".to_string(), name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_account = Account {
|
||||||
|
id: id.to_string(),
|
||||||
|
data: Some(data),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
};
|
||||||
|
|
||||||
|
Err(new_account)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn account_rooms_value(account: &Account, map: &Map) -> u32 {
|
||||||
|
map.room.iter().filter_map(|r| {
|
||||||
|
if account.rooms_unlocked.contains(&r.id) {
|
||||||
|
Some(r.value)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn account_full_balance(account: &Account, map: &Map) -> u32 {
|
||||||
|
let rooms_value = account_rooms_value(account, map);
|
||||||
|
account.balance + rooms_value
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn account_user_id(account: &Account) -> UserId {
|
||||||
|
UserId::new(account.id.clone().parse::<u64>().expect("automatically inserted"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
119
discord/src/api.rs
Normal file
119
discord/src/api.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
use rocket::{Build, Response, Rocket, State, http::{Header, hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN}, response::Responder, serde::json::Json};
|
||||||
|
use serde::Serialize;
|
||||||
|
use squad_quest::{SquadObject, account::Account, config::Config, map::{Map, Room}};
|
||||||
|
|
||||||
|
struct RocketData {
|
||||||
|
pub config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct UserData {
|
||||||
|
pub id: String,
|
||||||
|
pub avatar: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RoomData {
|
||||||
|
pub id: u16,
|
||||||
|
pub value: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub w: f32,
|
||||||
|
pub h: f32,
|
||||||
|
pub markers: Vec<UserData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RoomDataResponse {
|
||||||
|
pub data: Vec<RoomData>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Vec<RoomData>> for RoomDataResponse {
|
||||||
|
fn from(value: Vec<RoomData>) -> Self {
|
||||||
|
Self {
|
||||||
|
data: value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'r> Responder<'r, 'static> for RoomDataResponse {
|
||||||
|
fn respond_to(self, request: &'r rocket::Request<'_>) -> rocket::response::Result<'static> {
|
||||||
|
Response::build_from(Json(&self.data).respond_to(request)?)
|
||||||
|
.header(Header::new(ACCESS_CONTROL_ALLOW_ORIGIN.as_str(), "http://localhost:5173"))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Room> for RoomData {
|
||||||
|
fn from(value: &Room) -> Self {
|
||||||
|
let data = value.data.clone().unwrap_or_default();
|
||||||
|
let keys = [ "x", "y", "w", "h" ];
|
||||||
|
let mut values = [ 0f32, 0f32, 0f32, 0f32 ];
|
||||||
|
let mut counter = 0usize;
|
||||||
|
for key in keys {
|
||||||
|
values[counter] = data.get(key).map_or(0f32, |v| v.parse::<f32>().unwrap_or_default());
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
RoomData {
|
||||||
|
id: value.id,
|
||||||
|
value: value.value,
|
||||||
|
name: value.name.clone(),
|
||||||
|
description: value.description.clone().unwrap_or(String::new()),
|
||||||
|
x: values[0],
|
||||||
|
y: values[1],
|
||||||
|
w: values[2],
|
||||||
|
h: values[3],
|
||||||
|
markers: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn acc_filt_map(account: &Account, room_id: u16) -> Option<UserData> {
|
||||||
|
if account.location == room_id {
|
||||||
|
let data = account.data.clone().unwrap_or_default();
|
||||||
|
let keys = [ "avatar", "name" ];
|
||||||
|
let empty = String::new();
|
||||||
|
let mut values = [ &String::new(), &String::new() ];
|
||||||
|
let mut counter = 0usize;
|
||||||
|
for key in keys {
|
||||||
|
values[counter] = data.get(key).unwrap_or(&empty);
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
Some(UserData {
|
||||||
|
id: account.id.clone(),
|
||||||
|
avatar: values[0].clone(),
|
||||||
|
name: values[1].clone(),
|
||||||
|
})
|
||||||
|
} else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/")]
|
||||||
|
fn index(rd: &State<RocketData>) -> RoomDataResponse {
|
||||||
|
let map_path = rd.config.full_map_path();
|
||||||
|
let Ok(map) = Map::load(map_path) else {
|
||||||
|
return Vec::new().into();
|
||||||
|
};
|
||||||
|
let accounts = rd.config.load_accounts();
|
||||||
|
|
||||||
|
let rooms_vec: Vec<RoomData> = map.room.iter()
|
||||||
|
.map(|r| {
|
||||||
|
let mut rd = RoomData::from(r);
|
||||||
|
let markers = accounts.iter()
|
||||||
|
.filter_map(|a| acc_filt_map(a, r.id))
|
||||||
|
.collect::<Vec<UserData>>();
|
||||||
|
rd.markers = markers;
|
||||||
|
rd
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
rooms_vec.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rocket(config: Config) -> Rocket<Build> {
|
||||||
|
rocket::build()
|
||||||
|
.mount("/", routes![index])
|
||||||
|
.manage(RocketData{config})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,58 +1,46 @@
|
||||||
use poise::serenity_prelude::UserId;
|
use poise::serenity_prelude::User;
|
||||||
use squad_quest::{SquadObject, account::Account, map::Map};
|
use squad_quest::{SquadObject, account::Account, map::Map};
|
||||||
|
|
||||||
use crate::{Context, Error, account::fetch_or_init_account};
|
use crate::{Context, Error, account::{account_full_balance, account_user_id}, strings::StringFormatter, commands::guild};
|
||||||
|
|
||||||
async fn account_balance_string(ctx: &Context<'_>, account: &Account, map: &Map) -> String {
|
async fn account_balance_string(
|
||||||
let rooms_value = account_rooms_value(account, map);
|
ctx: &Context<'_>,
|
||||||
let full_balance = account_full_balance(account, map);
|
account: &Account,
|
||||||
|
map: &Map,
|
||||||
|
mut formatter: StringFormatter
|
||||||
|
) -> String {
|
||||||
let account_id = account_user_id(&account);
|
let account_id = account_user_id(&account);
|
||||||
|
|
||||||
let Ok(user) = account_id
|
let Ok(user) = account_id.to_user(ctx).await else {
|
||||||
.to_user(ctx)
|
|
||||||
.await else {
|
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
let name = user.display_name();
|
|
||||||
format!("\n{name}: **{full_balance}** points (**{balance}** on balance \
|
let strings = &ctx.data().strings;
|
||||||
+ **{rooms_value}** unlocked rooms networth)",
|
formatter = formatter.user(&user).balance(account, map);
|
||||||
balance = account.balance,
|
|
||||||
)
|
formatter.fmt(&strings.scoreboard.line_format)
|
||||||
}
|
|
||||||
|
|
||||||
fn account_rooms_value(account: &Account, map: &Map) -> u32 {
|
|
||||||
map.room.iter().filter_map(|r| {
|
|
||||||
if account.rooms_unlocked.contains(&r.id) {
|
|
||||||
Some(r.value)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.sum()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn account_full_balance(account: &Account, map: &Map) -> u32 {
|
|
||||||
let rooms_value = account_rooms_value(account, map);
|
|
||||||
account.balance + rooms_value
|
|
||||||
}
|
|
||||||
|
|
||||||
fn account_user_id(account: &Account) -> UserId {
|
|
||||||
UserId::new(account.id.clone().parse::<u64>().expect("automatically inserted"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reset user account, including balance, unlocked rooms and completed quests
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
required_permissions = "ADMINISTRATOR",
|
required_permissions = "ADMINISTRATOR",
|
||||||
|
name_localized("ru", "сбросить"),
|
||||||
|
description_localized("ru", "Сбросить аккаунт пользователя, вкл. баланс, открытые комнаты и пройденные квесты"),
|
||||||
)]
|
)]
|
||||||
pub async fn reset(
|
pub async fn reset(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
who: UserId,
|
#[description = "The user to reset"]
|
||||||
|
#[name_localized("ru", "кого")]
|
||||||
|
#[description_localized("ru", "Сбрасываемый пользователь")]
|
||||||
|
who: User,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let accounts = ctx.data().config.load_accounts();
|
let accounts = ctx.data().config.load_accounts();
|
||||||
|
|
||||||
let acc_id = format!("{}", who.get());
|
let acc_id = format!("{}", who.id.get());
|
||||||
|
|
||||||
if let None = accounts.iter().find(|a| a.id == acc_id) {
|
if let None = accounts.iter().find(|a| a.id == acc_id) {
|
||||||
return Err(Error::AccountNotFound);
|
return Err(Error::AccountNotFound);
|
||||||
|
|
@ -62,16 +50,23 @@ pub async fn reset(
|
||||||
path.push(format!("{acc_id}.toml"));
|
path.push(format!("{acc_id}.toml"));
|
||||||
Account::delete(path)?;
|
Account::delete(path)?;
|
||||||
|
|
||||||
let reply_string = "User was successfully reset.".to_string();
|
let strings = &ctx.data().strings;
|
||||||
|
let formatter = strings.formatter().user(&who);
|
||||||
|
|
||||||
|
let reply_string = formatter.fmt(&strings.account.reset);
|
||||||
ctx.reply(reply_string).await?;
|
ctx.reply(reply_string).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show scoreboard
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "счет"),
|
||||||
|
description_localized("ru", "Отобразить таблицу лидеров"),
|
||||||
)]
|
)]
|
||||||
pub async fn scoreboard(
|
pub async fn scoreboard(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
|
|
@ -79,6 +74,9 @@ pub async fn scoreboard(
|
||||||
let map_path = ctx.data().config.full_map_path();
|
let map_path = ctx.data().config.full_map_path();
|
||||||
let map = Map::load(map_path).expect("map.toml should exist");
|
let map = Map::load(map_path).expect("map.toml should exist");
|
||||||
|
|
||||||
|
let strings = &ctx.data().strings;
|
||||||
|
let mut formatter = strings.formatter();
|
||||||
|
|
||||||
let mut accounts = ctx.data().config.load_accounts();
|
let mut accounts = ctx.data().config.load_accounts();
|
||||||
accounts.sort_by(|a,b| {
|
accounts.sort_by(|a,b| {
|
||||||
let a_balance = account_full_balance(a, &map);
|
let a_balance = account_full_balance(a, &map);
|
||||||
|
|
@ -88,13 +86,14 @@ pub async fn scoreboard(
|
||||||
|
|
||||||
let this_user = ctx.author().id;
|
let this_user = ctx.author().id;
|
||||||
|
|
||||||
let mut reply_string = "Current scoreboard:".to_string();
|
let mut reply_string = formatter.fmt(&strings.scoreboard.header);
|
||||||
for account in accounts {
|
for account in accounts {
|
||||||
let user_id = account_user_id(&account);
|
let user_id = account_user_id(&account);
|
||||||
|
|
||||||
let mut str = account_balance_string(&ctx, &account, &map).await;
|
let mut str = account_balance_string(&ctx, &account, &map, formatter.clone()).await;
|
||||||
if user_id == this_user {
|
if user_id == this_user {
|
||||||
str = format!("__{str}__ << You");
|
formatter = formatter.text(&str);
|
||||||
|
str = formatter.fmt(&strings.scoreboard.you_format);
|
||||||
}
|
}
|
||||||
|
|
||||||
reply_string.push_str(&str);
|
reply_string.push_str(&str);
|
||||||
|
|
@ -109,7 +108,9 @@ pub async fn scoreboard(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
subcommands("give", "set"),
|
subcommands("give", "set"),
|
||||||
|
name_localized("ru", "баланс"),
|
||||||
)]
|
)]
|
||||||
pub async fn balance(
|
pub async fn balance(
|
||||||
_ctx: Context<'_>,
|
_ctx: Context<'_>,
|
||||||
|
|
@ -117,24 +118,46 @@ pub async fn balance(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Give points to another user
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "передать"),
|
||||||
|
description_localized("ru", "Передать очки другому пользователю"),
|
||||||
)]
|
)]
|
||||||
pub async fn give(
|
pub async fn give(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
who: UserId,
|
#[description = "Recipient"]
|
||||||
|
#[name_localized("ru", "кому")]
|
||||||
|
#[description_localized("ru", "Получатель")]
|
||||||
|
who: User,
|
||||||
|
#[description = "Amount of the points to give"]
|
||||||
|
#[name_localized("ru", "количество")]
|
||||||
|
#[description_localized("ru", "Количество очков для передачи")]
|
||||||
amount: u32,
|
amount: u32,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
|
if ctx.author() == &who {
|
||||||
|
return Err(Error::AccountIsSelf);
|
||||||
|
}
|
||||||
|
|
||||||
let config = &ctx.data().config;
|
let config = &ctx.data().config;
|
||||||
let mut accounts = config.load_accounts();
|
let mut accounts = config.load_accounts();
|
||||||
|
|
||||||
let user_id = format!("{}", ctx.author().id.get());
|
let user_id = format!("{}", ctx.author().id.get());
|
||||||
let mut user_account = fetch_or_init_account(config, user_id);
|
let strings = &ctx.data().strings;
|
||||||
|
let formatter: StringFormatter;
|
||||||
|
let accounts_path = config.full_accounts_path();
|
||||||
|
|
||||||
let who_id = format!("{}", who.get());
|
let who_id = format!("{}", who.id.get());
|
||||||
let Some(other_account) = accounts.iter_mut().find(|a| a.id == who_id ) else {
|
|
||||||
|
if let None = accounts.iter().find(|a| a.id == who_id ) {
|
||||||
|
return Err(Error::AccountNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let Some(user_account) = accounts.iter_mut().find(|a| a.id == user_id) else {
|
||||||
return Err(Error::AccountNotFound);
|
return Err(Error::AccountNotFound);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -143,33 +166,51 @@ pub async fn give(
|
||||||
}
|
}
|
||||||
|
|
||||||
user_account.balance -= amount;
|
user_account.balance -= amount;
|
||||||
|
user_account.save(accounts_path.clone())?;
|
||||||
|
|
||||||
|
formatter = strings.formatter()
|
||||||
|
.value(amount)
|
||||||
|
.user(&who)
|
||||||
|
.current_balance(&user_account);
|
||||||
|
}
|
||||||
|
|
||||||
|
let other_account = accounts.iter_mut().find(|a| a.id == who_id ).expect("We already checked its existence earlier");
|
||||||
|
|
||||||
other_account.balance += amount;
|
other_account.balance += amount;
|
||||||
|
|
||||||
let accounts_path = config.full_accounts_path();
|
|
||||||
user_account.save(accounts_path.clone())?;
|
|
||||||
other_account.save(accounts_path)?;
|
other_account.save(accounts_path)?;
|
||||||
|
|
||||||
let reply_string = format!("Given money to user.\n\
|
|
||||||
Your new balance: {} points.", user_account.balance);
|
let reply_string = formatter.fmt(&strings.account.give_pt);
|
||||||
ctx.reply(reply_string).await?;
|
ctx.reply(reply_string).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set current user balance
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
required_permissions = "ADMINISTRATOR",
|
required_permissions = "ADMINISTRATOR",
|
||||||
|
name_localized("ru", "установить"),
|
||||||
|
description_localized("ru", "Устанавливает текущий баланс пользователя"),
|
||||||
)]
|
)]
|
||||||
pub async fn set(
|
pub async fn set(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
who: UserId,
|
#[description = "User, whose balance will be modified"]
|
||||||
|
#[name_localized("ru", "чей")]
|
||||||
|
#[description_localized("ru", "Пользователь, чей баланс будет изменён")]
|
||||||
|
who: User,
|
||||||
|
#[description = "New balance of the user"]
|
||||||
|
#[name_localized("ru", "количество")]
|
||||||
|
#[description_localized("ru", "Новый баланс пользователя")]
|
||||||
amount: u32,
|
amount: u32,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let mut accounts = ctx.data().config.load_accounts();
|
let mut accounts = ctx.data().config.load_accounts();
|
||||||
|
|
||||||
let who_id = format!("{}", who.get());
|
let who_id = format!("{}", who.id.get());
|
||||||
let Some(account) = accounts.iter_mut().find(|a| a.id == who_id ) else {
|
let Some(account) = accounts.iter_mut().find(|a| a.id == who_id ) else {
|
||||||
return Err(Error::AccountNotFound);
|
return Err(Error::AccountNotFound);
|
||||||
};
|
};
|
||||||
|
|
@ -178,7 +219,12 @@ pub async fn set(
|
||||||
let accounts_path = ctx.data().config.full_accounts_path();
|
let accounts_path = ctx.data().config.full_accounts_path();
|
||||||
account.save(accounts_path)?;
|
account.save(accounts_path)?;
|
||||||
|
|
||||||
let reply_string = format!("Set user balance to {amount}.");
|
let strings = &ctx.data().strings;
|
||||||
|
let formatter = strings.formatter()
|
||||||
|
.user(&who)
|
||||||
|
.current_balance(&account);
|
||||||
|
|
||||||
|
let reply_string = formatter.fmt(&strings.account.set_pt);
|
||||||
ctx.reply(reply_string).await?;
|
ctx.reply(reply_string).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,67 @@
|
||||||
use poise::serenity_prelude::{Attachment, ComponentInteractionCollector, CreateActionRow, CreateAttachment, CreateButton, CreateMessage, EditMessage, Mentionable};
|
use poise::serenity_prelude::{Attachment, ComponentInteractionCollector, CreateActionRow, CreateAttachment, CreateButton, CreateMessage, EditMessage};
|
||||||
use squad_quest::SquadObject;
|
use squad_quest::SquadObject;
|
||||||
|
|
||||||
use crate::{Context, Error, account::fetch_or_init_account};
|
use crate::{account::fetch_or_init_account, commands::{guild, quest::update_quest_message}, Context, Error};
|
||||||
|
|
||||||
|
/// Send an answer to the quest for review
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "ответить"),
|
||||||
|
description_localized("ru", "Отправить ответ на квест на проверку"),
|
||||||
)]
|
)]
|
||||||
pub async fn answer(
|
pub async fn answer(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
#[description = "Identifier of the quest to answer to"]
|
#[description = "Identifier of the quest to answer to"]
|
||||||
|
#[name_localized("ru", "ид_квеста")]
|
||||||
|
#[description_localized("ru", "Идентификатор квеста для ответа")]
|
||||||
quest_id: u16,
|
quest_id: u16,
|
||||||
#[description = "Text answer to the quest"]
|
#[description = "Text answer to the quest"]
|
||||||
|
#[name_localized("ru", "текст")]
|
||||||
|
#[description_localized("ru", "Текст ответа на квест")]
|
||||||
text: Option<String>,
|
text: Option<String>,
|
||||||
#[description = "Attachment answer to the quest"]
|
#[description = "Attachment answer to the quest"]
|
||||||
|
#[name_localized("ru", "файл1")]
|
||||||
|
#[description_localized("ru", "Вложение к ответу на квест")]
|
||||||
file1: Option<Attachment>,
|
file1: Option<Attachment>,
|
||||||
#[description = "Attachment answer to the quest"]
|
#[description = "Attachment answer to the quest"]
|
||||||
|
#[name_localized("ru", "файл2")]
|
||||||
|
#[description_localized("ru", "Вложение к ответу на квест")]
|
||||||
file2: Option<Attachment>,
|
file2: Option<Attachment>,
|
||||||
#[description = "Attachment answer to the quest"]
|
#[description = "Attachment answer to the quest"]
|
||||||
|
#[name_localized("ru", "файл3")]
|
||||||
|
#[description_localized("ru", "Вложение к ответу на квест")]
|
||||||
file3: Option<Attachment>,
|
file3: Option<Attachment>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let mut account = fetch_or_init_account(&ctx.data().config, ctx.author().id.to_string());
|
|
||||||
|
|
||||||
if let Some(_) = account.quests_completed.iter().find(|qid| **qid == quest_id) {
|
|
||||||
return Err(Error::QuestIsCompleted(quest_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
let quests = ctx.data().config.load_quests();
|
let quests = ctx.data().config.load_quests();
|
||||||
let Some(quest) = quests.iter()
|
let Some(quest) = quests.iter()
|
||||||
.filter(|q| q.public)
|
.filter(|q| q.public)
|
||||||
.find(|q| q.id == quest_id) else {
|
.find(|q| q.id == quest_id) else {
|
||||||
return Err(Error::QuestNotFound(quest_id));
|
return Err(Error::QuestNotFound(quest_id));
|
||||||
};
|
};
|
||||||
|
{
|
||||||
|
let accounts = ctx.data().config.load_accounts();
|
||||||
|
let completed_times = accounts.iter().filter(|a| a.has_completed_quest(&quest)).count() as u8;
|
||||||
|
if quest.limit > 0 && completed_times >= quest.limit {
|
||||||
|
return Err(Error::QuestLimitExceeded(quest.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id = ctx.author().id.to_string();
|
||||||
|
match fetch_or_init_account(&ctx.data().config, &user_id, Some(ctx.author())) {
|
||||||
|
Ok(account) => {
|
||||||
|
if let Some(_) = account.quests_completed.iter().find(|qid| **qid == quest_id) {
|
||||||
|
return Err(Error::QuestIsCompleted(quest_id));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(new_account) => {
|
||||||
|
let path = ctx.data().config.full_accounts_path();
|
||||||
|
new_account.save(path)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
let mut files: Vec<Attachment> = Vec::new();
|
let mut files: Vec<Attachment> = Vec::new();
|
||||||
for file in [file1, file2, file3] {
|
for file in [file1, file2, file3] {
|
||||||
|
|
@ -45,23 +74,30 @@ pub async fn answer(
|
||||||
return Err(Error::NoContent);
|
return Err(Error::NoContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let strings = &ctx.data().strings;
|
||||||
|
let mut formatter = strings.formatter()
|
||||||
|
.user(ctx.author())
|
||||||
|
.quest(quest);
|
||||||
|
|
||||||
let text_ans = match text {
|
let text_ans = match text {
|
||||||
Some(text) => format!("\n### Passed answer:\n{text}"),
|
Some(text) => {
|
||||||
|
formatter = formatter.text(text);
|
||||||
|
formatter.fmt(&strings.answer.text)
|
||||||
|
},
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let attachment_notice = if files.len() == 0 { String::new() } else {
|
let attachment_notice = if files.len() == 0 { String::new() } else {
|
||||||
"\nPassed answer has attachments.".to_string()
|
formatter.fmt(&strings.answer.attachment_notice)
|
||||||
};
|
};
|
||||||
|
|
||||||
let content = format!("## From: {user}\n\
|
let content = [
|
||||||
### Quest #{quest_id}: {quest_name}\n\
|
formatter.fmt(&strings.answer.from),
|
||||||
### Expected answer:\n\
|
formatter.fmt(&strings.answer.quest),
|
||||||
||{quest_answer}||{text_ans}{attachment_notice}",
|
formatter.fmt(&strings.answer.expected),
|
||||||
user = ctx.author().mention(),
|
text_ans,
|
||||||
quest_name = quest.name,
|
attachment_notice,
|
||||||
quest_answer = quest.answer,
|
].join("");
|
||||||
);
|
|
||||||
|
|
||||||
let mut attachments: Vec<CreateAttachment> = Vec::new();
|
let mut attachments: Vec<CreateAttachment> = Vec::new();
|
||||||
|
|
||||||
|
|
@ -91,19 +127,21 @@ pub async fn answer(
|
||||||
|
|
||||||
let mut message = ans_channel.send_message(ctx, builder).await?;
|
let mut message = ans_channel.send_message(ctx, builder).await?;
|
||||||
|
|
||||||
let reply_string = "Your answer has been posted.".to_string();
|
let reply_string = formatter.fmt(&strings.answer.reply.initial);
|
||||||
ctx.reply(reply_string).await?;
|
ctx.reply(reply_string).await?;
|
||||||
|
|
||||||
if let Some(press) = ComponentInteractionCollector::new(ctx)
|
if let Some(press) = ComponentInteractionCollector::new(ctx)
|
||||||
.filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string()))
|
.filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string()))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
let admin = press.user.mention();
|
let admin = press.user;
|
||||||
|
formatter = formatter.user(&admin).text(&content);
|
||||||
|
|
||||||
let is_approved = press.data.custom_id == approve_id;
|
let is_approved = press.data.custom_id == approve_id;
|
||||||
let content = if is_approved {
|
let content = if is_approved {
|
||||||
format!("{content}\nApproved by: {admin}")
|
formatter.fmt(&strings.answer.accepted_by)
|
||||||
} else {
|
} else {
|
||||||
format!("~~{content}~~\nRejected by: {admin}")
|
formatter.fmt(&strings.answer.rejected_by)
|
||||||
};
|
};
|
||||||
|
|
||||||
let builder = EditMessage::new().content(content).components(Vec::new());
|
let builder = EditMessage::new().content(content).components(Vec::new());
|
||||||
|
|
@ -112,31 +150,29 @@ pub async fn answer(
|
||||||
let content: String;
|
let content: String;
|
||||||
if is_approved {
|
if is_approved {
|
||||||
let mut no_errors = true;
|
let mut no_errors = true;
|
||||||
if let Err(error) = quest.complete_for_account(&mut account) {
|
let mut accounts = ctx.data().config.load_accounts();
|
||||||
|
if let Err(error) = quest.complete_for_account(&ctx.author().id.to_string(), &mut accounts) {
|
||||||
eprintln!("{error}");
|
eprintln!("{error}");
|
||||||
no_errors = false;
|
no_errors = false;
|
||||||
};
|
};
|
||||||
|
let account = accounts.iter_mut().find(|a| a.id == user_id).expect("we done fetch_or_init earlier");
|
||||||
let path = ctx.data().config.full_accounts_path();
|
let path = ctx.data().config.full_accounts_path();
|
||||||
if let Err(error) = account.save(path) {
|
if let Err(error) = account.save(path) {
|
||||||
eprintln!("{error}");
|
eprintln!("{error}");
|
||||||
no_errors = false;
|
no_errors = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
update_quest_message(ctx, &quest).await?;
|
||||||
|
|
||||||
|
formatter = formatter.current_balance(&account);
|
||||||
|
|
||||||
if no_errors {
|
if no_errors {
|
||||||
content = format!("Your answer to the quest #{quest_id} has been approved.\n\
|
content = formatter.fmt(&strings.answer.reply.accepted);
|
||||||
You gained {reward} points.\n\
|
|
||||||
Your balance is now {balance} points",
|
|
||||||
reward = quest.reward,
|
|
||||||
balance = account.balance
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
content = format!("Your answer to the quest #{quest_id} has been approved, \
|
content = formatter.fmt(&strings.answer.reply.error);
|
||||||
but some server error happened. \
|
|
||||||
Please contact administrator for details."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
content = format!("Your answer to the quest #{quest_id} has been rejected.");
|
content = formatter.fmt(&strings.answer.reply.rejected);
|
||||||
};
|
};
|
||||||
let dm_builder = CreateMessage::new().content(content);
|
let dm_builder = CreateMessage::new().content(content);
|
||||||
ctx.author().dm(ctx, dm_builder).await?;
|
ctx.author().dm(ctx, dm_builder).await?;
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,30 @@
|
||||||
use std::path::Path;
|
use std::{path::Path, str::FromStr};
|
||||||
|
|
||||||
use poise::serenity_prelude::{ChannelId};
|
use poise::{CreateReply, serenity_prelude::ChannelId};
|
||||||
use squad_quest::SquadObject;
|
use squad_quest::SquadObject;
|
||||||
|
use toml::value::Time;
|
||||||
|
|
||||||
use crate::{Context, Error};
|
use crate::{Context, Error, timer::DailyTimer, commands::guild};
|
||||||
|
|
||||||
|
|
||||||
|
/// Set channels to post quests and answers to
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
required_permissions = "ADMINISTRATOR",
|
required_permissions = "ADMINISTRATOR",
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "инит"),
|
||||||
|
description_localized("ru", "Установить каналы для публикации квестов и ответов"),
|
||||||
)]
|
)]
|
||||||
pub async fn init(
|
pub async fn init(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
#[description = "Channel to post quests to"]
|
#[description = "Channel to post quests to"]
|
||||||
|
#[name_localized("ru", "канал_квестов")]
|
||||||
|
#[description_localized("ru", "Канал для публикации квестов")]
|
||||||
quests_channel: ChannelId,
|
quests_channel: ChannelId,
|
||||||
#[description = "Channel to post answers to check"]
|
#[description = "Channel to post answers for review"]
|
||||||
|
#[name_localized("ru", "канал_ответов")]
|
||||||
|
#[description_localized("ru", "Канал для публикации ответов на проверку")]
|
||||||
answers_channel: ChannelId,
|
answers_channel: ChannelId,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let dc = ctx.data().discord.clone();
|
let dc = ctx.data().discord.clone();
|
||||||
|
|
@ -30,8 +38,72 @@ pub async fn init(
|
||||||
let path = &ctx.data().config.full_impl_path().unwrap();
|
let path = &ctx.data().config.full_impl_path().unwrap();
|
||||||
guard.save(path.parent().unwrap_or(Path::new("")).to_owned())?
|
guard.save(path.parent().unwrap_or(Path::new("")).to_owned())?
|
||||||
};
|
};
|
||||||
let reply_string = "Settings updated.".to_string();
|
let strings = &ctx.data().strings;
|
||||||
|
let formatter = strings.formatter();
|
||||||
|
let reply_string = formatter.fmt(&strings.init_reply);
|
||||||
ctx.reply(reply_string).await?;
|
ctx.reply(reply_string).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TimeWrapper {
|
||||||
|
time: Time,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for TimeWrapper {
|
||||||
|
type Err = toml::de::Error;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
let toml_str = format!("time = {s}");
|
||||||
|
let wrapper: Self = toml::from_str(&toml_str)?;
|
||||||
|
Ok(wrapper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TimeWrapper> for Time {
|
||||||
|
fn from(value: TimeWrapper) -> Self {
|
||||||
|
value.time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seconds(time: Time) -> u64 {
|
||||||
|
time.hour as u64 * 3600 + time.minute as u64 * 60 + time.second as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable publication timer on given UTC time
|
||||||
|
#[poise::command(
|
||||||
|
prefix_command,
|
||||||
|
slash_command,
|
||||||
|
required_permissions = "ADMINISTRATOR",
|
||||||
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "таймер"),
|
||||||
|
description_localized("ru", "Включить таймер публикации по заданной временной метке UTC (МСК -3)"),
|
||||||
|
)]
|
||||||
|
pub async fn timer(
|
||||||
|
ctx: Context<'_>,
|
||||||
|
#[description = "UTC time (in format HH:MM:SS, e.g. 9:00:00)"]
|
||||||
|
#[name_localized("ru", "время")]
|
||||||
|
#[description_localized("ru", "Время по UTC (МСК -3) в формате ЧЧ:ММ:СС, напр. 9:00:00")]
|
||||||
|
time: TimeWrapper,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
if ctx.data().has_timer() {
|
||||||
|
return Err(Error::TimerSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
let time = Time::from(time);
|
||||||
|
let start_time = seconds(time);
|
||||||
|
let timer = DailyTimer::new(start_time);
|
||||||
|
|
||||||
|
let strings = &ctx.data().strings;
|
||||||
|
let formatter = strings.formatter().value(time);
|
||||||
|
|
||||||
|
let content = formatter.fmt(&strings.timer_reply);
|
||||||
|
let builder = CreateReply::default().ephemeral(true).content(content);
|
||||||
|
ctx.send(builder).await?;
|
||||||
|
|
||||||
|
ctx.data().timer();
|
||||||
|
|
||||||
|
timer.start(ctx).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,22 @@
|
||||||
|
use poise::{serenity_prelude::{Attachment, CreateAttachment, CreateMessage}, CreateReply};
|
||||||
use squad_quest::{SquadObject, map::Map};
|
use squad_quest::{SquadObject, map::Map};
|
||||||
|
|
||||||
use crate::{Context, account::fetch_or_init_account, error::Error};
|
use crate::{Context, account::fetch_or_init_account, error::Error, commands::guild};
|
||||||
|
|
||||||
|
/// Unlock specified room if it is reachable and you have required amount of points
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "открыть"),
|
||||||
|
description_localized("ru", "Открывает указанную комнату, если хватает очков и до нее можно добраться"),
|
||||||
)]
|
)]
|
||||||
pub async fn unlock(
|
pub async fn unlock(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
|
#[description = "Room identifier"]
|
||||||
|
#[name_localized("ru", "идентификатор")]
|
||||||
|
#[description_localized("ru", "Идентификатор комнаты")]
|
||||||
id: u16,
|
id: u16,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let conf = &ctx.data().config;
|
let conf = &ctx.data().config;
|
||||||
|
|
@ -21,7 +29,10 @@ pub async fn unlock(
|
||||||
};
|
};
|
||||||
|
|
||||||
let acc_id = format!("{}", ctx.author().id.get());
|
let acc_id = format!("{}", ctx.author().id.get());
|
||||||
let mut account = fetch_or_init_account(conf, acc_id);
|
let mut account = match fetch_or_init_account(conf, &acc_id, Some(ctx.author())) {
|
||||||
|
Ok(account) => account,
|
||||||
|
Err(account) => account,
|
||||||
|
};
|
||||||
|
|
||||||
if account.balance < room.value {
|
if account.balance < room.value {
|
||||||
return Err(Error::InsufficientFunds(room.value));
|
return Err(Error::InsufficientFunds(room.value));
|
||||||
|
|
@ -32,36 +43,153 @@ pub async fn unlock(
|
||||||
let account_path = conf.full_accounts_path();
|
let account_path = conf.full_accounts_path();
|
||||||
account.save(account_path)?;
|
account.save(account_path)?;
|
||||||
|
|
||||||
let reply_string = format!("Unlocked room #{id}. Your balance: {} points", account.balance);
|
let strings = &ctx.data().strings;
|
||||||
|
let formatter = strings.formatter()
|
||||||
|
.user(ctx.author())
|
||||||
|
.balance(&account, &map)
|
||||||
|
.value(id);
|
||||||
|
|
||||||
|
let reply_string = formatter.fmt(&strings.map.room_unlocked);
|
||||||
ctx.reply(reply_string).await?;
|
ctx.reply(reply_string).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Move to another unlocked room
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "пойти"),
|
||||||
|
description_localized("ru", "Переместиться в другую разблокированную комнату"),
|
||||||
)]
|
)]
|
||||||
pub async fn r#move(
|
pub async fn r#move(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
|
#[description = "Identifier of the room to move to"]
|
||||||
|
#[name_localized("ru", "идентификатор")]
|
||||||
|
#[description_localized("ru", "Идентификатор комнаты, куда переместиться")]
|
||||||
id: u16,
|
id: u16,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let conf = &ctx.data().config;
|
let conf = &ctx.data().config;
|
||||||
|
|
||||||
let acc_id = format!("{}", ctx.author().id.get());
|
let acc_id = format!("{}", ctx.author().id.get());
|
||||||
let mut account = fetch_or_init_account(conf, acc_id);
|
let mut account = match fetch_or_init_account(conf, &acc_id, Some(ctx.author())) {
|
||||||
|
Ok(account) => account,
|
||||||
|
Err(account) => account,
|
||||||
|
};
|
||||||
|
|
||||||
if let None = account.rooms_unlocked.iter().find(|rid| **rid == id) {
|
if let None = account.rooms_unlocked.iter().find(|rid| **rid == id) {
|
||||||
return Err(Error::RoomNotFound(id));
|
return Err(Error::CannotReach(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
account.location = id;
|
account.location = id;
|
||||||
let account_path = conf.full_accounts_path();
|
let account_path = conf.full_accounts_path();
|
||||||
account.save(account_path)?;
|
account.save(account_path)?;
|
||||||
|
|
||||||
let reply_string = format!("Moved to room #{id}.");
|
let strings = &ctx.data().strings;
|
||||||
|
let formatter = strings.formatter()
|
||||||
|
.user(ctx.author())
|
||||||
|
.value(id);
|
||||||
|
|
||||||
|
let reply_string = formatter.fmt(&strings.map.moved_to_room);
|
||||||
ctx.reply(reply_string).await?;
|
ctx.reply(reply_string).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Change avatar on web map
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
prefix_command,
|
||||||
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "аватар"),
|
||||||
|
description_localized("ru", "Сменить аватар на веб карте"),
|
||||||
|
)]
|
||||||
|
pub async fn avatar(
|
||||||
|
ctx: Context<'_>,
|
||||||
|
#[description = "URL to the avatar"]
|
||||||
|
#[name_localized("ru", "ссылка")]
|
||||||
|
#[description_localized("ru", "Ссылка на аватар")]
|
||||||
|
url: Option<String>,
|
||||||
|
#[description = "Attachment to use as avatar"]
|
||||||
|
#[name_localized("ru", "вложение")]
|
||||||
|
#[description_localized("ru", "Вложение, используемое как аватар")]
|
||||||
|
attachment: Option<Attachment>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let user_id = ctx.author().id.to_string();
|
||||||
|
let mut accounts = ctx.data().config.load_accounts();
|
||||||
|
let Some(account) = accounts.iter_mut().find(|a| a.id == user_id) else {
|
||||||
|
return Err(Error::AccountNotFound);
|
||||||
|
};
|
||||||
|
|
||||||
|
if url.is_none() && attachment.is_none() {
|
||||||
|
return Err(Error::NoUrlOrAttachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if url.is_some() && attachment.is_some() {
|
||||||
|
return Err(Error::BothUrlAndAttachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
let strings = &ctx.data().strings;
|
||||||
|
let formatter = strings.formatter();
|
||||||
|
|
||||||
|
if let Some(url) = url {
|
||||||
|
let attachment = CreateAttachment::url(ctx, &url).await?;
|
||||||
|
let reply_string = formatter.fmt(&strings.map.processing_url);
|
||||||
|
let builder = CreateMessage::new()
|
||||||
|
.content(reply_string)
|
||||||
|
.add_file(attachment.clone());
|
||||||
|
let message = ctx.channel_id().send_message(ctx, builder).await?;
|
||||||
|
|
||||||
|
let attachment_check = message.attachments.first().expect("we just sent it");
|
||||||
|
if attachment_check.width.is_none()
|
||||||
|
|| !attachment_check.content_type
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|t| t.starts_with("image/")) {
|
||||||
|
message.delete(ctx).await?;
|
||||||
|
return Err(Error::NonImageAttachment);
|
||||||
|
}
|
||||||
|
let data = account.data.as_mut().expect("automatically created");
|
||||||
|
data.insert("avatar".to_string(), url);
|
||||||
|
|
||||||
|
message.delete(ctx).await?;
|
||||||
|
|
||||||
|
let reply_string = formatter.fmt(&strings.map.updated_avatar);
|
||||||
|
let builder = CreateReply::default()
|
||||||
|
.content(reply_string)
|
||||||
|
.attachment(attachment)
|
||||||
|
.reply(true);
|
||||||
|
ctx.send(builder).await?;
|
||||||
|
|
||||||
|
} else if let Some(attachment) = attachment {
|
||||||
|
if attachment.width.is_none()
|
||||||
|
|| !attachment.content_type
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|t| t.starts_with("image/")) {
|
||||||
|
return Err(Error::NonImageAttachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
let reply_string = formatter.fmt(&strings.map.updated_avatar);
|
||||||
|
let copied_attachment = CreateAttachment::url(ctx, &attachment.url).await?;
|
||||||
|
|
||||||
|
let data = account.data.as_mut().expect("automatically created");
|
||||||
|
data.insert("avatar".to_string(), attachment.url);
|
||||||
|
|
||||||
|
let path = ctx.data().config.full_accounts_path();
|
||||||
|
account.save(path)?;
|
||||||
|
|
||||||
|
let builder = CreateReply::default()
|
||||||
|
.content(reply_string)
|
||||||
|
.attachment(copied_attachment)
|
||||||
|
.reply(true);
|
||||||
|
|
||||||
|
ctx.send(builder).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = ctx.data().config.full_accounts_path();
|
||||||
|
account.save(path)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
use std::error::Error as StdError;
|
use std::error::Error as StdError;
|
||||||
use poise::CreateReply;
|
use poise::{CreateReply, serenity_prelude::GuildId};
|
||||||
|
use squad_quest::quest::Quest;
|
||||||
|
|
||||||
use crate::{Context, Data, Error};
|
use crate::{error, Context, Data, Error};
|
||||||
|
|
||||||
pub mod quest;
|
pub mod quest;
|
||||||
pub mod init;
|
pub mod init;
|
||||||
|
|
@ -10,23 +11,36 @@ pub mod social;
|
||||||
pub mod account;
|
pub mod account;
|
||||||
pub mod map;
|
pub mod map;
|
||||||
|
|
||||||
|
pub async fn guild(ctx: Context<'_>) -> Result<bool, Error> {
|
||||||
|
let id = ctx.guild_id().expect("guild-only command");
|
||||||
|
let guard = ctx.data().discord.lock().expect("shouldn't be locked");
|
||||||
|
let expected_id = guard.guild;
|
||||||
|
|
||||||
|
if expected_id != GuildId::default() && id != expected_id {
|
||||||
|
return Err(Error::NotThisGuild);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
#[poise::command(prefix_command)]
|
#[poise::command(prefix_command)]
|
||||||
pub async fn register(ctx: Context<'_>) -> Result<(), Error> {
|
pub async fn register(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
poise::builtins::register_application_commands_buttons(ctx).await?;
|
poise::builtins::register_application_commands_buttons(ctx).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show bot info, such as version and link to web map
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
|
name_localized("ru", "инфо"),
|
||||||
|
description_localized("ru", "Получить информацию о боте и ссылку на веб карту"),
|
||||||
)]
|
)]
|
||||||
pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
|
pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
let reply_string = format!("\
|
let strings = &ctx.data().strings;
|
||||||
SquadQuest version {ver}\n\
|
let formatter = strings.formatter();
|
||||||
Find the map here: {url}",
|
let reply_string = formatter.fmt(&strings.info);
|
||||||
ver = env!("CARGO_PKG_VERSION"),
|
|
||||||
url = "not implemented yet!",
|
|
||||||
);
|
|
||||||
ctx.say(reply_string).await?;
|
ctx.say(reply_string).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -35,17 +49,52 @@ pub async fn error_handler(error: poise::FrameworkError<'_, Data, Error>) {
|
||||||
eprintln!("ERROR:");
|
eprintln!("ERROR:");
|
||||||
print_error_recursively(&error);
|
print_error_recursively(&error);
|
||||||
if let Some(ctx) = error.ctx() {
|
if let Some(ctx) = error.ctx() {
|
||||||
let response = match error {
|
let user = ctx.author().display_name();
|
||||||
poise::FrameworkError::Command { error, .. } => format!("Internal server error: {error}"),
|
|
||||||
_ => format!("Internal server error: {error}"),
|
eprintln!("User: {user} ({id})", id = ctx.author().id);
|
||||||
|
eprintln!("Invokation string: {}", ctx.invocation_string());
|
||||||
|
|
||||||
|
let strings = &ctx.data().strings;
|
||||||
|
|
||||||
|
let response = if let poise::FrameworkError::Command { error, .. } = error {
|
||||||
|
let formatter = match error {
|
||||||
|
error::Error::QuestNotFound(id) |
|
||||||
|
error::Error::QuestIsPublic(id) |
|
||||||
|
error::Error::QuestIsCompleted(id) |
|
||||||
|
error::Error::QuestLimitExceeded(id) =>
|
||||||
|
strings.formatter().quest(&Quest { id, ..Default::default() }),
|
||||||
|
|
||||||
|
error::Error::InsufficientFunds(amount) =>
|
||||||
|
strings.formatter().value(amount),
|
||||||
|
|
||||||
|
error::Error::RoomNotFound(value) |
|
||||||
|
error::Error::RoomAlreadyUnlocked(value) |
|
||||||
|
error::Error::CannotReach(value) =>
|
||||||
|
strings.formatter().value(value),
|
||||||
|
|
||||||
|
error::Error::SerenityError(ref error) =>
|
||||||
|
strings.formatter().text(error),
|
||||||
|
|
||||||
|
error::Error::SquadQuestError(ref error) =>
|
||||||
|
strings.formatter().text(error),
|
||||||
|
|
||||||
|
_ => strings.formatter(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let error_string = error.formattable_string(&strings.error);
|
||||||
|
formatter.fmt(error_string)
|
||||||
|
} else {
|
||||||
|
let formatter = strings.formatter().text(&error);
|
||||||
|
formatter.fmt(&strings.error.non_command_error)
|
||||||
|
};
|
||||||
|
|
||||||
if let Err(error) = ctx.send(CreateReply::default().content(response).ephemeral(true)).await {
|
if let Err(error) = ctx.send(CreateReply::default().content(response).ephemeral(true)).await {
|
||||||
eprintln!("Couldn't send error message: {error}");
|
eprintln!("Couldn't send error message: {error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_error_recursively(error: &impl StdError) {
|
pub fn print_error_recursively(error: &impl StdError) {
|
||||||
eprintln!("{error}");
|
eprintln!("{error}");
|
||||||
if let Some(source) = error.source() {
|
if let Some(source) = error.source() {
|
||||||
eprintln!("source:");
|
eprintln!("source:");
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
use std::{future, path::Path, str::FromStr};
|
use std::{future, str::FromStr};
|
||||||
|
|
||||||
use poise::serenity_prelude::{CreateMessage, EditMessage, Message, futures::StreamExt};
|
use poise::serenity_prelude as serenity;
|
||||||
use squad_quest::{SquadObject, quest::{Quest, QuestDifficulty}};
|
use serenity::{CreateMessage, EditMessage, Message, futures::StreamExt};
|
||||||
|
use squad_quest::{account::Account, quest::{Quest, QuestDifficulty}, SquadObject};
|
||||||
use toml::value::Date;
|
use toml::value::Date;
|
||||||
use crate::{Context, Error};
|
use crate::{Context, Error,commands::guild};
|
||||||
|
|
||||||
async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result<Option<Message>, Error>{
|
async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result<Option<Message>, Error>{
|
||||||
ctx.defer().await?;
|
ctx.defer().await?;
|
||||||
|
|
@ -24,24 +25,45 @@ async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result<Option<Message>
|
||||||
Ok(messages.first().cloned())
|
Ok(messages.first().cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_quest_message_content(quest: &Quest) -> String {
|
fn make_quest_message_content(ctx: Context<'_>, quest: &Quest, accounts: &Option<Vec<Account>>) -> String {
|
||||||
format!("### `#{id}` {name} (+{reward})\n\
|
let strings = &ctx.data().strings;
|
||||||
Difficulty: *{difficulty:?}*\n\
|
let formatter = match accounts {
|
||||||
{description}",
|
Some(accounts) => strings.formatter().quest_full(quest, accounts),
|
||||||
id = quest.id,
|
None => strings.formatter().quest(quest),
|
||||||
name = quest.name,
|
};
|
||||||
reward = quest.reward,
|
formatter.fmt(&strings.quest.message_format)
|
||||||
difficulty = quest.difficulty,
|
}
|
||||||
description = quest.description,
|
|
||||||
)
|
pub async fn update_quest_message(ctx: Context<'_>, quest: &Quest) -> Result<(), Error> {
|
||||||
|
let strings = &ctx.data().strings;
|
||||||
|
let formatter = strings.formatter().quest(&quest);
|
||||||
|
let accounts = ctx.data().config.load_accounts();
|
||||||
|
let content = make_quest_message_content(ctx, &quest, &Some(accounts));
|
||||||
|
let builder = EditMessage::new().content(content);
|
||||||
|
|
||||||
|
let message = find_quest_message(ctx, quest.id).await?;
|
||||||
|
if let Some(mut message) = message {
|
||||||
|
return match message.edit(ctx, builder).await {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(error) => Err(error.into()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let reply_string = formatter.fmt(&strings.quest.message_not_found);
|
||||||
|
match ctx.reply(reply_string).await {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(error) => Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
subcommands("list", "create", "update", "publish", "delete"),
|
subcommands("list", "create", "update", "publish", "delete"),
|
||||||
required_permissions = "ADMINISTRATOR",
|
required_permissions = "ADMINISTRATOR",
|
||||||
|
name_localized("ru", "квест"),
|
||||||
)]
|
)]
|
||||||
pub async fn quest(
|
pub async fn quest(
|
||||||
_ctx: Context<'_>,
|
_ctx: Context<'_>,
|
||||||
|
|
@ -49,24 +71,27 @@ pub async fn quest(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all quests
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
required_permissions = "ADMINISTRATOR",
|
required_permissions = "ADMINISTRATOR",
|
||||||
|
name_localized("ru", "список"),
|
||||||
|
description_localized("ru", "Вывести все квесты")
|
||||||
)]
|
)]
|
||||||
pub async fn list(
|
pub async fn list(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let conf = &ctx.data().config;
|
let conf = &ctx.data().config;
|
||||||
let quests = conf.load_quests();
|
let quests = conf.load_quests();
|
||||||
let mut reply_string = format!("Listing {} quests:", quests.len());
|
let strings = &ctx.data().strings;
|
||||||
|
let mut formatter = strings.formatter().value(quests.len());
|
||||||
|
let mut reply_string = formatter.fmt(&strings.quest.list);
|
||||||
for quest in quests {
|
for quest in quests {
|
||||||
reply_string.push_str(format!("\n#{}: {}\n\tDescription: {}",
|
formatter = formatter.quest(&quest);
|
||||||
quest.id,
|
reply_string.push_str(formatter.fmt(&strings.quest.list_item).as_str());
|
||||||
quest.name,
|
|
||||||
quest.description,
|
|
||||||
).as_str());
|
|
||||||
}
|
}
|
||||||
ctx.reply(reply_string).await?;
|
ctx.reply(reply_string).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -80,7 +105,6 @@ pub enum DifficultyWrapper {
|
||||||
Secret,
|
Secret,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl From<DifficultyWrapper> for QuestDifficulty {
|
impl From<DifficultyWrapper> for QuestDifficulty {
|
||||||
fn from(value: DifficultyWrapper) -> Self {
|
fn from(value: DifficultyWrapper) -> Self {
|
||||||
match &value {
|
match &value {
|
||||||
|
|
@ -112,28 +136,52 @@ impl From<DateWrapper> for Date {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create quest and print its identifier
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
required_permissions = "ADMINISTRATOR",
|
required_permissions = "ADMINISTRATOR",
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "создать"),
|
||||||
|
description_localized("ru", "Создать квест и получить его идентификатор"),
|
||||||
)]
|
)]
|
||||||
pub async fn create(
|
pub async fn create(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
#[description = "Quest difficulty"]
|
#[description = "Quest difficulty"]
|
||||||
|
#[name_localized("ru", "сложность")]
|
||||||
|
#[description_localized("ru", "Сложность квеста")]
|
||||||
difficulty: DifficultyWrapper,
|
difficulty: DifficultyWrapper,
|
||||||
#[description = "Reward for the quest"]
|
#[description = "Reward for the quest"]
|
||||||
|
#[name_localized("ru", "награда")]
|
||||||
|
#[description_localized("ru", "Награда за квест")]
|
||||||
reward: u32,
|
reward: u32,
|
||||||
#[description = "Quest name"]
|
#[description = "Quest name"]
|
||||||
|
#[name_localized("ru", "название")]
|
||||||
|
#[description_localized("ru", "Название квеста")]
|
||||||
name: String,
|
name: String,
|
||||||
#[description = "Quest description"]
|
#[description = "Quest description"]
|
||||||
|
#[name_localized("ru", "описание")]
|
||||||
|
#[description_localized("ru", "Описание квеста")]
|
||||||
description: String,
|
description: String,
|
||||||
#[description = "Quest answer, visible to admins"]
|
#[description = "Expected answer, visible when user posts their answer for review"]
|
||||||
|
#[name_localized("ru", "ответ")]
|
||||||
|
#[description_localized("ru", "Ожидаемый результат, отображаемый при проверке ответа игрока")]
|
||||||
answer: String,
|
answer: String,
|
||||||
#[description = "Optional date of publication (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
|
#[description = "Date of publication (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
|
||||||
|
#[name_localized("ru", "доступен")]
|
||||||
|
#[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24)")]
|
||||||
available: Option<DateWrapper>,
|
available: Option<DateWrapper>,
|
||||||
#[description = "Optional deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
|
#[description = "Limit how many users are allowed to complete the quest"]
|
||||||
|
#[name_localized("ru", "лимит")]
|
||||||
|
#[description_localized("ru", "Ограничение количества участников, которые могут выполнить квест")]
|
||||||
|
limit: Option<u8>,
|
||||||
|
/*
|
||||||
|
#[description = "Deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
|
||||||
|
#[name_localized("ru", "дедлайн")]
|
||||||
|
#[description_localized("ru", "Дедлайн (в формате ГГГГ-ММ-ДД), напр. 2025-12-24")]
|
||||||
deadline: Option<DateWrapper>,
|
deadline: Option<DateWrapper>,
|
||||||
|
*/
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let conf = &ctx.data().config;
|
let conf = &ctx.data().config;
|
||||||
let mut quests = conf.load_quests();
|
let mut quests = conf.load_quests();
|
||||||
|
|
@ -148,10 +196,12 @@ pub async fn create(
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
let deadline = match deadline {
|
let deadline = match deadline {
|
||||||
Some(dl) => Some(dl.into()),
|
Some(dl) => Some(dl.into()),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
*/
|
||||||
|
|
||||||
let quest = Quest {
|
let quest = Quest {
|
||||||
id: next_id,
|
id: next_id,
|
||||||
|
|
@ -162,46 +212,80 @@ pub async fn create(
|
||||||
answer,
|
answer,
|
||||||
public: false,
|
public: false,
|
||||||
available_on,
|
available_on,
|
||||||
deadline,
|
limit: limit.unwrap_or_default(),
|
||||||
|
//deadline,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let path = conf.full_quests_path();
|
let path = conf.full_quests_path();
|
||||||
|
|
||||||
quest.save(path)?;
|
quest.save(path)?;
|
||||||
let reply_string = format!("Created quest #{}", quest.id);
|
|
||||||
|
let strings = &ctx.data().strings;
|
||||||
|
let formatter = strings.formatter().quest(&quest);
|
||||||
|
let reply_string = formatter.fmt(&strings.quest.create);
|
||||||
|
|
||||||
ctx.reply(reply_string).await?;
|
ctx.reply(reply_string).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update quest values by its identifier and new given values
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
required_permissions = "ADMINISTRATOR",
|
required_permissions = "ADMINISTRATOR",
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "обновить"),
|
||||||
|
description_localized("ru", "Обновить выбранные значения указанного квеста"),
|
||||||
)]
|
)]
|
||||||
pub async fn update(
|
pub async fn update(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
#[description = "Quest identifier"]
|
#[description = "Quest identifier"]
|
||||||
|
#[name_localized("ru", "идентификатор")]
|
||||||
|
#[description_localized("ru", "Идентификатор квеста")]
|
||||||
id: u16,
|
id: u16,
|
||||||
#[description = "Quest difficulty"]
|
#[description = "Quest difficulty"]
|
||||||
|
#[name_localized("ru", "сложность")]
|
||||||
|
#[description_localized("ru", "Сложность квеста")]
|
||||||
difficulty: Option<DifficultyWrapper>,
|
difficulty: Option<DifficultyWrapper>,
|
||||||
#[description = "Reward for the quest"]
|
#[description = "Reward for the quest"]
|
||||||
|
#[name_localized("ru", "награда")]
|
||||||
|
#[description_localized("ru", "Награда за квест")]
|
||||||
reward: Option<u32>,
|
reward: Option<u32>,
|
||||||
#[description = "Quest name"]
|
#[description = "Quest name"]
|
||||||
|
#[name_localized("ru", "название")]
|
||||||
|
#[description_localized("ru", "Название квеста")]
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
#[description = "Quest description"]
|
#[description = "Quest description"]
|
||||||
|
#[name_localized("ru", "описание")]
|
||||||
|
#[description_localized("ru", "Описание квеста")]
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
#[description = "Quest answer, visible to admins"]
|
#[description = "Expected answer, visible when user posts their answer for review"]
|
||||||
|
#[name_localized("ru", "ответ")]
|
||||||
|
#[description_localized("ru", "Ожидаемый результат, отображаемый при проверке ответа игрока")]
|
||||||
answer: Option<String>,
|
answer: Option<String>,
|
||||||
#[description = "Date of publication (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
|
#[description = "Date of publication (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
|
||||||
|
#[name_localized("ru", "доступен")]
|
||||||
|
#[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24")]
|
||||||
available: Option<DateWrapper>,
|
available: Option<DateWrapper>,
|
||||||
|
#[description = "Limit how many users are allowed to complete the quest"]
|
||||||
|
#[name_localized("ru", "лимит")]
|
||||||
|
#[description_localized("ru", "Ограничение количества участников, которые могут выполнить квест")]
|
||||||
|
limit: Option<u8>,
|
||||||
|
/*
|
||||||
#[description = "Quest deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
|
#[description = "Quest deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
|
||||||
|
#[name_localized("ru", "дедлайн")]
|
||||||
|
#[description_localized("ru", "Дедлайн (в формате ГГГГ-ММ-ДД, напр. 2025-12-24)")]
|
||||||
deadline: Option<DateWrapper>,
|
deadline: Option<DateWrapper>,
|
||||||
#[description = "Clear availability and deadline if checked"]
|
#[description = "Reset availability and deadline if checked"]
|
||||||
#[rename = "override"]
|
#[description_localized("ru", "Если выбрано, сбросить доступность и дедлайн")]
|
||||||
should_override: Option<bool>,
|
*/
|
||||||
|
#[description = "Reset availability if checked"]
|
||||||
|
#[description_localized("ru", "Если выбрано, сбросить доступность")]
|
||||||
|
#[name_localized("ru", "сброс")]
|
||||||
|
reset: Option<bool>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let conf = &ctx.data().config;
|
let conf = &ctx.data().config;
|
||||||
let quests = conf.load_quests();
|
let quests = conf.load_quests();
|
||||||
|
|
@ -215,20 +299,22 @@ pub async fn update(
|
||||||
};
|
};
|
||||||
|
|
||||||
let available_on: Option<Date>;
|
let available_on: Option<Date>;
|
||||||
let dead_line: Option<Date>;
|
let new_limit: u8;
|
||||||
|
//let dead_line: Option<Date>;
|
||||||
|
|
||||||
match should_override.unwrap_or(false) {
|
match reset.unwrap_or(false) {
|
||||||
true => {
|
true => {
|
||||||
available_on = available.map(|v| v.into());
|
available_on = None;
|
||||||
dead_line = deadline.map(|v| v.into());
|
new_limit = limit.unwrap_or_default();
|
||||||
|
//dead_line = None;
|
||||||
},
|
},
|
||||||
false => {
|
false => {
|
||||||
available_on = available.map_or_else(|| quest.available_on.clone(), |v| Some(v.into()));
|
available_on = available.map_or_else(|| quest.available_on.clone(), |v| Some(v.into()));
|
||||||
dead_line = deadline.map_or_else(|| quest.deadline.clone(), |v| Some(v.into()));
|
new_limit = limit.unwrap_or(quest.limit);
|
||||||
|
//dead_line = deadline.map_or_else(|| quest.deadline.clone(), |v| Some(v.into()));
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let new_quest = Quest {
|
let new_quest = Quest {
|
||||||
id,
|
id,
|
||||||
difficulty,
|
difficulty,
|
||||||
|
|
@ -238,40 +324,65 @@ pub async fn update(
|
||||||
answer: answer.unwrap_or(quest.answer.clone()),
|
answer: answer.unwrap_or(quest.answer.clone()),
|
||||||
public: quest.public,
|
public: quest.public,
|
||||||
available_on,
|
available_on,
|
||||||
deadline: dead_line,
|
limit: new_limit,
|
||||||
|
//deadline: dead_line,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
if new_quest.public {
|
let strings = &ctx.data().strings;
|
||||||
let content = make_quest_message_content(&new_quest);
|
let formatter = strings.formatter().quest(&new_quest);
|
||||||
let builder = EditMessage::new().content(content);
|
|
||||||
|
|
||||||
let message = find_quest_message(ctx, id).await?;
|
if new_quest.public {
|
||||||
if let Some(mut message) = message {
|
update_quest_message(ctx, &new_quest).await?;
|
||||||
message.edit(ctx, builder).await?;
|
|
||||||
} else {
|
|
||||||
let reply_string = format!("Quest #{id} is public, but its message was not found in the quest channel",
|
|
||||||
);
|
|
||||||
ctx.reply(reply_string).await?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = conf.full_quests_path();
|
let path = conf.full_quests_path();
|
||||||
new_quest.save(path)?;
|
new_quest.save(path)?;
|
||||||
let reply_string = format!("Updated quest #{id}");
|
let reply_string = formatter.fmt(&strings.quest.update);
|
||||||
ctx.reply(reply_string).await?;
|
ctx.reply(reply_string).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<Message, Error> {
|
||||||
|
quest.public = true;
|
||||||
|
|
||||||
|
let quests_path = ctx.data().config.full_quests_path();
|
||||||
|
quest.save(quests_path)?;
|
||||||
|
|
||||||
|
let accounts = ctx.data().config.load_accounts();
|
||||||
|
let content = make_quest_message_content(ctx, &quest, &Some(accounts));
|
||||||
|
|
||||||
|
let builder = CreateMessage::new()
|
||||||
|
.content(content);
|
||||||
|
|
||||||
|
let dc = ctx.data().discord.clone();
|
||||||
|
let channel = {
|
||||||
|
let guard = dc.lock().expect("shouldn't be locked");
|
||||||
|
guard.quests_channel
|
||||||
|
};
|
||||||
|
|
||||||
|
match channel.send_message(ctx, builder).await {
|
||||||
|
Ok(m) => Ok(m),
|
||||||
|
Err(error) => Err(Error::SerenityError(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark quest as public and send its message in quests channel
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
required_permissions = "ADMINISTRATOR",
|
required_permissions = "ADMINISTRATOR",
|
||||||
guild_only
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "опубликовать"),
|
||||||
|
description_localized("ru", "Отметить квест как публичный и отправить его сообщение в канал квестов"),
|
||||||
)]
|
)]
|
||||||
pub async fn publish(
|
pub async fn publish(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
#[description = "Identifier of the quest to publish"]
|
#[description = "Quest identifier"]
|
||||||
|
#[name_localized("ru", "идентификатор")]
|
||||||
|
#[description_localized("ru", "Идентификатор квеста")]
|
||||||
id: u16,
|
id: u16,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let mut quests = ctx.data().config.load_quests();
|
let mut quests = ctx.data().config.load_quests();
|
||||||
|
|
@ -284,45 +395,34 @@ pub async fn publish(
|
||||||
return Err(Error::QuestIsPublic(id));
|
return Err(Error::QuestIsPublic(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
quest.public = true;
|
let message = publish_inner(ctx, quest).await?;
|
||||||
|
|
||||||
let content = make_quest_message_content(&quest);
|
let strings = &ctx.data().strings;
|
||||||
|
let formatter = strings.formatter()
|
||||||
|
.quest(&quest)
|
||||||
|
.message(&message);
|
||||||
|
|
||||||
let builder = CreateMessage::new()
|
let reply_string = formatter.fmt(&strings.quest.publish);
|
||||||
.content(content);
|
|
||||||
|
|
||||||
let dc = ctx.data().discord.clone();
|
|
||||||
let channel = {
|
|
||||||
let guard = dc.lock().expect("shouldn't be locked");
|
|
||||||
guard.quests_channel
|
|
||||||
};
|
|
||||||
|
|
||||||
let message = channel.send_message(ctx, builder).await?;
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut guard = dc.lock().expect("shouldn't be locked");
|
|
||||||
guard.quests_messages.push(message.id);
|
|
||||||
let path = ctx.data().config.full_impl_path().unwrap();
|
|
||||||
guard.save(path.parent().unwrap_or(Path::new("")).to_owned())?
|
|
||||||
};
|
|
||||||
|
|
||||||
let quests_path = ctx.data().config.full_quests_path();
|
|
||||||
quest.save(quests_path)?;
|
|
||||||
|
|
||||||
let reply_string = format!("Published quest #{id}");
|
|
||||||
ctx.reply(reply_string).await?;
|
ctx.reply(reply_string).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete quest (and its message, if published)
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
required_permissions = "ADMINISTRATOR",
|
required_permissions = "ADMINISTRATOR",
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "удалить"),
|
||||||
|
description_localized("ru", "Удалить квест (и его сообщение, если он был опубликован)"),
|
||||||
)]
|
)]
|
||||||
pub async fn delete(
|
pub async fn delete(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
|
#[description = "Quest identifier"]
|
||||||
|
#[name_localized("ru", "идентификатор")]
|
||||||
|
#[description_localized("ru", "Идентификатор квеста")]
|
||||||
id: u16,
|
id: u16,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
if let Some(msg) = find_quest_message(ctx, id).await? {
|
if let Some(msg) = find_quest_message(ctx, id).await? {
|
||||||
|
|
@ -342,7 +442,15 @@ pub async fn delete(
|
||||||
account.save(accounts_path.clone())?;
|
account.save(accounts_path.clone())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let reply_string = format!("Successfully deleted quest #{id}");
|
let mock_quest = Quest {
|
||||||
|
id,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let strings = &ctx.data().strings;
|
||||||
|
let formatter = strings.formatter().quest(&mock_quest);
|
||||||
|
|
||||||
|
let reply_string = formatter.fmt(&strings.quest.delete);
|
||||||
ctx.reply(reply_string).await?;
|
ctx.reply(reply_string).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,47 @@
|
||||||
use poise::serenity_prelude::{Attachment, ChannelId, CreateAttachment, CreateMessage, EditMessage, Mentionable, MessageId, UserId};
|
use poise::serenity_prelude::{Attachment, ChannelId, CreateAttachment, CreateMessage, EditMessage, MessageId, UserId};
|
||||||
|
|
||||||
use crate::{Context, Error};
|
use crate::{Context, Error, commands::guild};
|
||||||
|
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
required_permissions = "ADMINISTRATOR",
|
required_permissions = "ADMINISTRATOR",
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
subcommands("msg", "edit", "undo"),
|
subcommands("msg", "edit", "undo"),
|
||||||
|
name_localized("ru", "сообщение"),
|
||||||
)]
|
)]
|
||||||
pub async fn social( _ctx: Context<'_> ) -> Result<(), Error> {
|
pub async fn social( _ctx: Context<'_> ) -> Result<(), Error> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send message to channel or user
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
required_permissions = "ADMINISTRATOR",
|
required_permissions = "ADMINISTRATOR",
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "написать"),
|
||||||
|
description_localized("ru", "Отправить сообщение пользователю или в канал"),
|
||||||
)]
|
)]
|
||||||
pub async fn msg (
|
pub async fn msg (
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
|
#[description = "Channel to message to"]
|
||||||
|
#[name_localized("ru", "канал")]
|
||||||
|
#[description_localized("ru", "Канал, в который отправится сообщение")]
|
||||||
channel: Option<ChannelId>,
|
channel: Option<ChannelId>,
|
||||||
|
#[description = "User to message to"]
|
||||||
|
#[name_localized("ru", "пользователь")]
|
||||||
|
#[description_localized("ru", "Пользователь, которому отправится сообщение")]
|
||||||
user: Option<UserId>,
|
user: Option<UserId>,
|
||||||
|
#[description = "Message text"]
|
||||||
|
#[name_localized("ru", "содержание")]
|
||||||
|
#[description_localized("ru", "Текст сообщения")]
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
|
#[description = "Message attachment"]
|
||||||
|
#[name_localized("ru", "файл")]
|
||||||
|
#[description_localized("ru", "Вложение к сообщению")]
|
||||||
file: Option<Attachment>,
|
file: Option<Attachment>,
|
||||||
|
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
|
|
@ -51,20 +69,22 @@ pub async fn msg (
|
||||||
builder = builder.add_file(attachment);
|
builder = builder.add_file(attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let strings = &ctx.data().strings;
|
||||||
|
|
||||||
let reply_string = if let Some(channel) = channel {
|
let reply_string = if let Some(channel) = channel {
|
||||||
let message = channel.send_message(ctx, builder).await?;
|
let message = channel.send_message(ctx, builder).await?;
|
||||||
format!("Sent {message} ({message_id}) to {channel}!",
|
let formatter = strings.formatter()
|
||||||
message = message.link(),
|
.message(&message);
|
||||||
message_id = message.id,
|
|
||||||
channel = channel.mention(),
|
formatter.fmt(&strings.social.sent_channel)
|
||||||
)
|
} else if let Some(user_id) = user {
|
||||||
} else if let Some(user) = user {
|
let message = user_id.dm(ctx, builder).await?;
|
||||||
let message = user.dm(ctx, builder).await?;
|
let user = user_id.to_user(ctx).await?;
|
||||||
format!("Sent {message} ({message_id}) to {user}",
|
let formatter = strings.formatter()
|
||||||
message = message.link(),
|
.message(&message)
|
||||||
message_id = message.id,
|
.user(&user);
|
||||||
user = user.mention(),
|
|
||||||
)
|
formatter.fmt(&strings.social.sent_dm)
|
||||||
} else {
|
} else {
|
||||||
unreachable!();
|
unreachable!();
|
||||||
};
|
};
|
||||||
|
|
@ -74,19 +94,38 @@ pub async fn msg (
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Edit sent channel or DM message
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
required_permissions = "ADMINISTRATOR",
|
required_permissions = "ADMINISTRATOR",
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "редактировать"),
|
||||||
|
description_localized("ru", "Редактировать сообщение в канале или в ЛС"),
|
||||||
)]
|
)]
|
||||||
pub async fn edit (
|
pub async fn edit (
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
|
#[description = "Identifier of the message to edit"]
|
||||||
|
#[name_localized("ru", "сообщение")]
|
||||||
|
#[description_localized("ru", "Идентификатор редактируемого сообщения")]
|
||||||
#[rename = "message"]
|
#[rename = "message"]
|
||||||
message_id: MessageId,
|
message_id: MessageId,
|
||||||
|
#[description = "Channel where the message is"]
|
||||||
|
#[name_localized("ru", "канал")]
|
||||||
|
#[description_localized("ru", "Канал, где находится сообщение")]
|
||||||
channel: Option<ChannelId>,
|
channel: Option<ChannelId>,
|
||||||
|
#[description = "User, who received DM"]
|
||||||
|
#[name_localized("ru", "пользователь")]
|
||||||
|
#[description_localized("ru", "Пользователь, получивший ЛС")]
|
||||||
user: Option<UserId>,
|
user: Option<UserId>,
|
||||||
|
#[description = "New message text"]
|
||||||
|
#[name_localized("ru", "содержание")]
|
||||||
|
#[description_localized("ru", "Новый текст сообщения")]
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
|
#[description = "New file (overrides existing if specified)"]
|
||||||
|
#[name_localized("ru", "файл")]
|
||||||
|
#[description_localized("ru", "Новое вложение (заменит предыдущее если указано)")]
|
||||||
file: Option<Attachment>,
|
file: Option<Attachment>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
if channel.is_none() && user.is_none() {
|
if channel.is_none() && user.is_none() {
|
||||||
|
|
@ -113,32 +152,52 @@ pub async fn edit (
|
||||||
builder = builder.new_attachment(attachment);
|
builder = builder.new_attachment(attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut message;
|
||||||
if let Some(channel) = channel {
|
if let Some(channel) = channel {
|
||||||
let mut message = channel.message(ctx, message_id).await?;
|
message = channel.message(ctx, message_id).await?;
|
||||||
message.edit(ctx, builder).await?;
|
message.edit(ctx, builder).await?;
|
||||||
} else if let Some(user) = user {
|
} else if let Some(user) = user {
|
||||||
let channel = user.create_dm_channel(ctx).await?;
|
let channel = user.create_dm_channel(ctx).await?;
|
||||||
let mut message = channel.message(ctx, message_id).await?;
|
message = channel.message(ctx, message_id).await?;
|
||||||
message.edit(ctx, builder).await?;
|
message.edit(ctx, builder).await?;
|
||||||
|
} else {
|
||||||
|
unreachable!()
|
||||||
}
|
}
|
||||||
|
|
||||||
let reply_string = "Successfully edited message.".to_string();
|
let strings = &ctx.data().strings;
|
||||||
|
let formatter = strings.formatter()
|
||||||
|
.message(&message);
|
||||||
|
|
||||||
|
let reply_string = formatter.fmt(&strings.social.edited);
|
||||||
ctx.reply(reply_string).await?;
|
ctx.reply(reply_string).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete message in channel or DM
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
required_permissions = "ADMINISTRATOR",
|
required_permissions = "ADMINISTRATOR",
|
||||||
guild_only,
|
guild_only,
|
||||||
|
check = "guild",
|
||||||
|
name_localized("ru", "удалить"),
|
||||||
|
description_localized("ru", "Удалить сообщение в канале или в ЛС"),
|
||||||
)]
|
)]
|
||||||
pub async fn undo(
|
pub async fn undo(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
|
#[description = "Identifier of the message to delete"]
|
||||||
|
#[name_localized("ru", "сообщение")]
|
||||||
|
#[description_localized("ru", "Идентификатор удаляемого сообщения")]
|
||||||
#[rename = "message"]
|
#[rename = "message"]
|
||||||
message_id: MessageId,
|
message_id: MessageId,
|
||||||
|
#[description = "Channel where the message is"]
|
||||||
|
#[name_localized("ru", "канал")]
|
||||||
|
#[description_localized("ru", "Канал, где находится сообщение")]
|
||||||
channel: Option<ChannelId>,
|
channel: Option<ChannelId>,
|
||||||
|
#[description = "User, who received DM"]
|
||||||
|
#[name_localized("ru", "пользователь")]
|
||||||
|
#[description_localized("ru", "Пользователь, получивший ЛС")]
|
||||||
user: Option<UserId>,
|
user: Option<UserId>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
|
|
||||||
|
|
@ -150,16 +209,23 @@ pub async fn undo(
|
||||||
return Err(Error::BothChannelAndUser);
|
return Err(Error::BothChannelAndUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let message;
|
||||||
if let Some(channel) = channel {
|
if let Some(channel) = channel {
|
||||||
let message = channel.message(ctx, message_id).await?;
|
message = channel.message(ctx, message_id).await?;
|
||||||
message.delete(ctx).await?;
|
message.delete(ctx).await?;
|
||||||
} else if let Some(user) = user {
|
} else if let Some(user) = user {
|
||||||
let channel = user.create_dm_channel(ctx).await?;
|
let channel = user.create_dm_channel(ctx).await?;
|
||||||
let message = channel.message(ctx, message_id).await?;
|
message = channel.message(ctx, message_id).await?;
|
||||||
message.delete(ctx).await?;
|
message.delete(ctx).await?;
|
||||||
|
} else {
|
||||||
|
unreachable!()
|
||||||
}
|
}
|
||||||
|
|
||||||
let reply_string = "Successfully deleted message".to_string();
|
let strings = &ctx.data().strings;
|
||||||
|
let formatter = strings.formatter()
|
||||||
|
.message(&message);
|
||||||
|
|
||||||
|
let reply_string = formatter.fmt(&strings.social.deleted);
|
||||||
ctx.reply(reply_string).await?;
|
ctx.reply(reply_string).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,55 @@
|
||||||
use std::{io::Write, path::{Path, PathBuf}};
|
use std::{io::Write, path::{Path, PathBuf}};
|
||||||
|
|
||||||
use poise::serenity_prelude::{ChannelId, GuildId, MessageId};
|
use poise::serenity_prelude::{ChannelId, GuildId};
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
use squad_quest::{SquadObject, config::Config, error::Error};
|
use squad_quest::{SquadObject, config::Config, error::Error};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
|
use crate::strings::Strings;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct DiscordConfig {
|
pub struct DiscordConfig {
|
||||||
pub guild: GuildId,
|
pub guild: GuildId,
|
||||||
pub quests_channel: ChannelId,
|
pub quests_channel: ChannelId,
|
||||||
pub answers_channel: ChannelId,
|
pub answers_channel: ChannelId,
|
||||||
pub quests_messages: Vec<MessageId>,
|
pub strings_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DiscordConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
guild: GuildId::default(),
|
||||||
|
quests_channel: ChannelId::default(),
|
||||||
|
answers_channel: ChannelId::default(),
|
||||||
|
strings_path: "strings.toml".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait ConfigImpl {
|
pub trait ConfigImpl {
|
||||||
fn discord_impl(&self) -> Result<DiscordConfig, Error>;
|
fn discord_impl(&self) -> Result<(DiscordConfig, Strings), Error>;
|
||||||
fn init_impl(&self) -> Result<(), Error>;
|
fn init_impl(&self) -> Result<(), Error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConfigImpl for Config {
|
impl ConfigImpl for Config {
|
||||||
fn discord_impl(&self) -> Result<DiscordConfig, Error> {
|
fn discord_impl(&self) -> Result<(DiscordConfig, Strings), Error> {
|
||||||
let Some(path) = &self.full_impl_path() else {
|
let Some(path) = self.full_impl_path() else {
|
||||||
return Err(Error::IsNotImplemented);
|
return Err(Error::IsNotImplemented);
|
||||||
};
|
};
|
||||||
DiscordConfig::load(path.clone())
|
let discord = DiscordConfig::load(path.clone())?;
|
||||||
|
let mut strings_path: PathBuf = path.parent().unwrap_or(Path::new("")).to_owned();
|
||||||
|
strings_path.push(discord.strings_path.clone());
|
||||||
|
let strings = Strings::load(strings_path)?;
|
||||||
|
Ok((discord, strings))
|
||||||
}
|
}
|
||||||
fn init_impl(&self) -> Result<(), Error> {
|
fn init_impl(&self) -> Result<(), Error> {
|
||||||
let Some(path) = self.full_impl_path() else {
|
let Some(path) = self.full_impl_path() else {
|
||||||
return Err(Error::IsNotImplemented);
|
return Err(Error::IsNotImplemented);
|
||||||
};
|
};
|
||||||
|
let folder = path.parent().unwrap_or(Path::new("")).to_owned();
|
||||||
let dc = DiscordConfig::default();
|
let dc = DiscordConfig::default();
|
||||||
dc.save(path.parent().unwrap_or(Path::new("")).to_owned())
|
dc.save(folder.clone())?;
|
||||||
|
let strings = Strings::default();
|
||||||
|
strings.save(folder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,17 +66,8 @@ impl SquadObject for DiscordConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete(path: PathBuf) -> Result<(), Error> {
|
fn delete(_path: PathBuf) -> Result<(), Error> {
|
||||||
match Self::load(path.clone()) {
|
unimplemented!()
|
||||||
Ok(_) => {
|
|
||||||
if let Err(error) = std::fs::remove_file(path) {
|
|
||||||
return Err(Error::IoError(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
Err(error) => Err(error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save(&self, path: PathBuf) -> Result<(), Error> {
|
fn save(&self, path: PathBuf) -> Result<(), Error> {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ use std::fmt::Display;
|
||||||
use poise::serenity_prelude as serenity;
|
use poise::serenity_prelude as serenity;
|
||||||
use squad_quest::error::MapError;
|
use squad_quest::error::MapError;
|
||||||
|
|
||||||
|
use crate::strings::ErrorStrings;
|
||||||
|
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
|
|
@ -15,10 +17,17 @@ pub enum Error {
|
||||||
SerenityError(serenity::Error),
|
SerenityError(serenity::Error),
|
||||||
SquadQuestError(squad_quest::error::Error),
|
SquadQuestError(squad_quest::error::Error),
|
||||||
AccountNotFound,
|
AccountNotFound,
|
||||||
|
AccountIsSelf,
|
||||||
InsufficientFunds(u32),
|
InsufficientFunds(u32),
|
||||||
RoomNotFound(u16),
|
RoomNotFound(u16),
|
||||||
RoomAlreadyUnlocked(u16),
|
RoomAlreadyUnlocked(u16),
|
||||||
CannotReach(u16),
|
CannotReach(u16),
|
||||||
|
TimerSet,
|
||||||
|
NotThisGuild,
|
||||||
|
QuestLimitExceeded(u16),
|
||||||
|
BothUrlAndAttachment,
|
||||||
|
NoUrlOrAttachment,
|
||||||
|
NonImageAttachment,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<serenity::Error> for Error {
|
impl From<serenity::Error> for Error {
|
||||||
|
|
@ -51,16 +60,23 @@ impl Display for Error {
|
||||||
Self::QuestNotFound(u16) => write!(f, "quest #{u16} not found"),
|
Self::QuestNotFound(u16) => write!(f, "quest #{u16} not found"),
|
||||||
Self::QuestIsPublic(u16) => write!(f, "quest #{u16} is already public"),
|
Self::QuestIsPublic(u16) => write!(f, "quest #{u16} is already public"),
|
||||||
Self::QuestIsCompleted(u16) => write!(f, "quest #{u16} is already completed for this user"),
|
Self::QuestIsCompleted(u16) => write!(f, "quest #{u16} is already completed for this user"),
|
||||||
Self::NoContent => write!(f, "no text or attachment was specified"),
|
Self::NoContent => write!(f, "no text or attachment were specified"),
|
||||||
Self::NoChannelOrUser => write!(f, "no channel or user was specified"),
|
Self::NoChannelOrUser => write!(f, "no channel or user were specified"),
|
||||||
Self::BothChannelAndUser => write!(f, "both channel and user was specified"),
|
Self::BothChannelAndUser => write!(f, "both channel and user were specified"),
|
||||||
Self::SerenityError(_) => write!(f, "discord interaction error"),
|
Self::SerenityError(_) => write!(f, "discord interaction error"),
|
||||||
Self::SquadQuestError(_) => write!(f, "internal logic error"),
|
Self::SquadQuestError(_) => write!(f, "internal logic error"),
|
||||||
Self::AccountNotFound => write!(f, "account not found"),
|
Self::AccountNotFound => write!(f, "account not found"),
|
||||||
|
Self::AccountIsSelf => write!(f, "given account is the same as command user"),
|
||||||
Self::InsufficientFunds(amount) => write!(f, "user does not have {amount} points"),
|
Self::InsufficientFunds(amount) => write!(f, "user does not have {amount} points"),
|
||||||
Self::RoomNotFound(id) => write!(f, "room #{id} not found"),
|
Self::RoomNotFound(id) => write!(f, "room #{id} not found"),
|
||||||
Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"),
|
Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"),
|
||||||
Self::CannotReach(id) => write!(f, "user cannot reach room #{id}"),
|
Self::CannotReach(id) => write!(f, "user cannot reach room #{id}"),
|
||||||
|
Self::TimerSet => write!(f, "timer is already set"),
|
||||||
|
Self::NotThisGuild => write!(f, "cannot be used in this guild"),
|
||||||
|
Self::QuestLimitExceeded(id) => write!(f, "exceeded limit for quest #{id}"),
|
||||||
|
Self::BothUrlAndAttachment => write!(f, "both url and attachment were specified"),
|
||||||
|
Self::NoUrlOrAttachment => write!(f, "no url or attachment were specified"),
|
||||||
|
Self::NonImageAttachment => write!(f, "attachment is not an image"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,14 +91,46 @@ impl std::error::Error for Error {
|
||||||
Self::NoChannelOrUser |
|
Self::NoChannelOrUser |
|
||||||
Self::BothChannelAndUser |
|
Self::BothChannelAndUser |
|
||||||
Self::AccountNotFound |
|
Self::AccountNotFound |
|
||||||
|
Self::AccountIsSelf |
|
||||||
Self::InsufficientFunds(_) |
|
Self::InsufficientFunds(_) |
|
||||||
Self::RoomNotFound(_) |
|
Self::RoomNotFound(_) |
|
||||||
Self::RoomAlreadyUnlocked(_) |
|
Self::RoomAlreadyUnlocked(_) |
|
||||||
Self::CannotReach(_) => None,
|
Self::CannotReach(_) |
|
||||||
|
Self::TimerSet |
|
||||||
|
Self::NotThisGuild |
|
||||||
|
Self::QuestLimitExceeded(_) |
|
||||||
|
Self::BothUrlAndAttachment |
|
||||||
|
Self::NoUrlOrAttachment |
|
||||||
|
Self::NonImageAttachment => None,
|
||||||
Self::SerenityError(error) => Some(error),
|
Self::SerenityError(error) => Some(error),
|
||||||
Self::SquadQuestError(error) => Some(error),
|
Self::SquadQuestError(error) => Some(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'a> Error {
|
||||||
|
pub fn formattable_string(&self, errors: &'a ErrorStrings) -> &'a str {
|
||||||
|
match self {
|
||||||
|
Self::QuestNotFound(_) => &errors.quest_not_found,
|
||||||
|
Self::QuestIsPublic(_) => &errors.quest_is_public,
|
||||||
|
Self::QuestIsCompleted(_) => &errors.quest_is_completed,
|
||||||
|
Self::NoContent => &errors.no_content,
|
||||||
|
Self::NoChannelOrUser => &errors.no_channel_or_user,
|
||||||
|
Self::BothChannelAndUser => &errors.both_channel_and_user,
|
||||||
|
Self::SerenityError(_) => &errors.discord_error,
|
||||||
|
Self::SquadQuestError(_) => &errors.library_error,
|
||||||
|
Self::AccountNotFound => &errors.account_not_found,
|
||||||
|
Self::AccountIsSelf => &errors.account_is_self,
|
||||||
|
Self::InsufficientFunds(_) => &errors.insufficient_funds,
|
||||||
|
Self::RoomNotFound(_) => &errors.room_not_found,
|
||||||
|
Self::RoomAlreadyUnlocked(_) => &errors.room_already_unlocked,
|
||||||
|
Self::CannotReach(_) => &errors.cannot_reach,
|
||||||
|
Self::TimerSet => &errors.timer_set,
|
||||||
|
Self::NotThisGuild => &errors.not_this_guild,
|
||||||
|
Self::QuestLimitExceeded(_) => &errors.quest_limit_exceeded,
|
||||||
|
Self::BothUrlAndAttachment => &errors.both_url_and_attachment,
|
||||||
|
Self::NoUrlOrAttachment => &errors.no_url_or_attachment,
|
||||||
|
Self::NonImageAttachment => &errors.non_image_attachment,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,57 @@
|
||||||
use std::sync::{Arc, Mutex};
|
#[macro_use] extern crate rocket;
|
||||||
|
|
||||||
|
use std::{sync::{Arc, Mutex}};
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use poise::serenity_prelude as serenity;
|
use poise::serenity_prelude as serenity;
|
||||||
use squad_quest::config::Config;
|
use squad_quest::config::Config;
|
||||||
|
|
||||||
use crate::{commands::error_handler, config::{ConfigImpl, DiscordConfig}, error::Error};
|
use crate::{commands::{error_handler, print_error_recursively}, config::{ConfigImpl, DiscordConfig}, error::Error, strings::Strings};
|
||||||
|
|
||||||
|
mod api;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod cli;
|
mod cli;
|
||||||
mod config;
|
mod config;
|
||||||
mod account;
|
mod account;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod strings;
|
||||||
|
mod timer;
|
||||||
|
|
||||||
const CONFIG_PATH: &str = "cfg/config.toml";
|
const CONFIG_PATH: &str = "cfg/config.toml";
|
||||||
|
const DISCORD_TOKEN: &str = "DISCORD_TOKEN";
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct InnerBool {
|
||||||
|
pub value: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct Data {
|
struct Data {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub discord: Arc<Mutex<DiscordConfig>>,
|
pub discord: Arc<Mutex<DiscordConfig>>,
|
||||||
|
pub strings: Strings,
|
||||||
|
pub timer_set: Arc<Mutex<InnerBool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Data {
|
||||||
|
pub fn timer(&self) {
|
||||||
|
let tm = self.timer_set.clone();
|
||||||
|
{
|
||||||
|
let mut guard = tm.lock().unwrap();
|
||||||
|
guard.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_timer(&self) -> bool {
|
||||||
|
let tm = self.timer_set.clone();
|
||||||
|
{
|
||||||
|
let guard = tm.lock().unwrap();
|
||||||
|
guard.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Context<'a> = poise::Context<'a, Data, Error>;
|
type Context<'a> = poise::Context<'a, Data, Error>;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -28,22 +60,31 @@ async fn main() {
|
||||||
|
|
||||||
let cli = cli::Cli::parse();
|
let cli = cli::Cli::parse();
|
||||||
let config = Config::load(cli.config.clone().unwrap_or(CONFIG_PATH.into()));
|
let config = Config::load(cli.config.clone().unwrap_or(CONFIG_PATH.into()));
|
||||||
let discord = config.discord_impl().unwrap_or_else(|_| {
|
let (discord, strings) = config.discord_impl().unwrap_or_else(|_| {
|
||||||
config.init_impl().unwrap();
|
config.init_impl().unwrap();
|
||||||
config.discord_impl().unwrap()
|
config.discord_impl().unwrap()
|
||||||
});
|
});
|
||||||
|
|
||||||
let token = std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN");
|
let token = std::env::var(DISCORD_TOKEN).expect("missing DISCORD_TOKEN");
|
||||||
let intents = serenity::GatewayIntents::non_privileged();
|
let intents = serenity::GatewayIntents::non_privileged();
|
||||||
|
|
||||||
|
let conf1 = config.clone();
|
||||||
|
tokio::spawn(async {
|
||||||
|
if let Err(error) = api::rocket(conf1).launch().await {
|
||||||
|
eprintln!("ERROR ON API LAUNCH");
|
||||||
|
print_error_recursively(&error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let framework = poise::Framework::builder()
|
let framework = poise::Framework::builder()
|
||||||
.options(poise::FrameworkOptions {
|
.options(poise::FrameworkOptions {
|
||||||
on_error: |err| Box::pin(error_handler(err)),
|
on_error: |err| Box::pin(error_handler(err)),
|
||||||
commands: vec![
|
commands: vec![
|
||||||
commands::quest::quest(),
|
|
||||||
commands::register(),
|
commands::register(),
|
||||||
|
commands::quest::quest(),
|
||||||
commands::info(),
|
commands::info(),
|
||||||
commands::init::init(),
|
commands::init::init(),
|
||||||
|
commands::init::timer(),
|
||||||
commands::answer::answer(),
|
commands::answer::answer(),
|
||||||
commands::social::social(),
|
commands::social::social(),
|
||||||
commands::account::scoreboard(),
|
commands::account::scoreboard(),
|
||||||
|
|
@ -51,15 +92,20 @@ async fn main() {
|
||||||
commands::account::reset(),
|
commands::account::reset(),
|
||||||
commands::map::unlock(),
|
commands::map::unlock(),
|
||||||
commands::map::r#move(),
|
commands::map::r#move(),
|
||||||
|
commands::map::avatar(),
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.setup(|ctx, _ready, framework| {
|
.setup(|_ctx, _ready, _framework| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
|
//poise::builtins::register_globally(ctx, &framework.options().commands).await?;
|
||||||
|
|
||||||
|
|
||||||
Ok(Data {
|
Ok(Data {
|
||||||
config,
|
config,
|
||||||
discord: Arc::new(Mutex::new(discord)),
|
discord: Arc::new(Mutex::new(discord)),
|
||||||
|
timer_set: Arc::new(Mutex::new(InnerBool { value: false })),
|
||||||
|
strings,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
463
discord/src/strings.rs
Normal file
463
discord/src/strings.rs
Normal file
|
|
@ -0,0 +1,463 @@
|
||||||
|
use std::{collections::HashMap, fmt::Display, io::Write, path::PathBuf};
|
||||||
|
|
||||||
|
use poise::serenity_prelude::{Mentionable, Message, User};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use squad_quest::{SquadObject, account::Account, map::Map, quest::{Quest, QuestDifficulty}, error::Error};
|
||||||
|
|
||||||
|
use crate::account::{account_full_balance, account_rooms_value};
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone)]
|
||||||
|
pub struct StringFormatter {
|
||||||
|
tags: HashMap<String, String>,
|
||||||
|
difficulty: Difficulty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StringFormatter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let newline = ("{n}".to_string(), '\n'.to_string());
|
||||||
|
let version = ("{v}".to_string(), env!("CARGO_PKG_VERSION").to_string());
|
||||||
|
let new_tags = vec![ newline, version ];
|
||||||
|
|
||||||
|
Self::default().with_tags(new_tags).to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_tags(mut self, tags: Vec<(String, String)>) -> Self {
|
||||||
|
for tag in tags {
|
||||||
|
self.tags.insert(tag.0, tag.1);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn strings(mut self, strings: &Strings) -> Self {
|
||||||
|
self.difficulty = strings.difficulty.clone();
|
||||||
|
|
||||||
|
let url = ("{url}".to_string(), strings.url.clone());
|
||||||
|
let points = ("{pt}".to_string(), strings.points.clone());
|
||||||
|
let new_tags = vec![ url, points ];
|
||||||
|
|
||||||
|
self.with_tags(new_tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn quest(self, quest: &Quest) -> Self {
|
||||||
|
let id = ("{q.id}".to_string(), id(quest.id));
|
||||||
|
let difficulty = ("{q.difficulty}".to_string(), self.difficulty.as_string(&quest.difficulty));
|
||||||
|
let reward = ("{q.reward}".to_string(), self.points(quest.reward.to_string()));
|
||||||
|
let name = ("{q.name}".to_string(), quest.name.clone());
|
||||||
|
let description = ("{q.description}".to_string(), quest.description.clone());
|
||||||
|
let answer = ("{q.answer}".to_string(), quest.answer.clone());
|
||||||
|
let limit = ("{q.limit}".to_string(), quest.limit.to_string());
|
||||||
|
let new_tags = vec![ id, difficulty, reward, name, description, answer, limit ];
|
||||||
|
|
||||||
|
self.with_tags(new_tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn quest_full(mut self, quest: &Quest, accounts: &Vec<Account>) -> Self {
|
||||||
|
self = self.quest(&quest);
|
||||||
|
let completed_times = accounts.iter().filter(|a| a.has_completed_quest(&quest)).count();
|
||||||
|
let completions = ("{q.completions}".to_string(), completed_times.to_string());
|
||||||
|
|
||||||
|
self.with_tags(vec![completions])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user(self, user: &User) -> Self {
|
||||||
|
let mention = ("{u.mention}".to_string(), user.mention().to_string());
|
||||||
|
let name = ("{u.name}".to_string(), user.display_name().to_string());
|
||||||
|
let new_tags = vec![ mention, name ];
|
||||||
|
|
||||||
|
self.with_tags(new_tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn balance(mut self, account: &Account, map: &Map) -> Self {
|
||||||
|
self = self.current_balance(account);
|
||||||
|
let full_balance = (
|
||||||
|
"{b.full}".to_string(),
|
||||||
|
self.points(account_full_balance(account, map)),
|
||||||
|
);
|
||||||
|
let rooms_balance = (
|
||||||
|
"{b.rooms}".to_string(),
|
||||||
|
self.points(account_rooms_value(account, map)),
|
||||||
|
);
|
||||||
|
let new_tags = vec![ full_balance, rooms_balance ];
|
||||||
|
|
||||||
|
self.with_tags(new_tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_balance(self, account: &Account) -> Self {
|
||||||
|
let balance = ("{b.current}".to_string(), self.points(account.balance));
|
||||||
|
self.with_tags(vec![balance])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn message(self, message: &Message) -> Self {
|
||||||
|
let link = ("{m.link}".to_string(), message.link());
|
||||||
|
let id = ("{m.id}".to_string(), message.id.to_string());
|
||||||
|
let channel = ("{m.channel}".to_string(), message.channel_id.mention().to_string());
|
||||||
|
let new_tags = vec![ link, id, channel ];
|
||||||
|
|
||||||
|
self.with_tags(new_tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text(self, text: impl ToString) -> Self {
|
||||||
|
let text = ("{text}".to_string(), text.to_string());
|
||||||
|
|
||||||
|
self.with_tags(vec![text])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(self, value: impl ToString) -> Self {
|
||||||
|
let value = ("{value}".to_string(), value.to_string());
|
||||||
|
|
||||||
|
self.with_tags(vec![value])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn points(&self, str: impl Display) -> String {
|
||||||
|
let template = format!("{str} {pt}", pt = "{pt}");
|
||||||
|
self.fmt(&template)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fmt(&self, string: &str) -> String {
|
||||||
|
let mut formatted = string.to_string();
|
||||||
|
for (tag, replacement) in self.tags.iter() {
|
||||||
|
formatted = formatted.replace(tag, replacement);
|
||||||
|
}
|
||||||
|
formatted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id(str: impl Display) -> String {
|
||||||
|
format!("#{str}")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
pub struct Strings {
|
||||||
|
pub url: String,
|
||||||
|
pub points: String,
|
||||||
|
pub info: String,
|
||||||
|
pub init_reply: String,
|
||||||
|
pub timer_reply: String,
|
||||||
|
pub account: AccountReplies,
|
||||||
|
pub answer: Answer,
|
||||||
|
pub difficulty: Difficulty,
|
||||||
|
pub map: MapReplies,
|
||||||
|
pub scoreboard: Scoreboard,
|
||||||
|
pub social: Social,
|
||||||
|
pub quest: QuestStrings,
|
||||||
|
pub error: ErrorStrings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Strings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
url: "not implemented!".to_string(),
|
||||||
|
points: "points".to_string(),
|
||||||
|
info: "SquadQuest version {v}\
|
||||||
|
{n}Find the map here: {url}".to_string(),
|
||||||
|
init_reply: "Updated linked channels and guild.".to_string(),
|
||||||
|
timer_reply: "Set daily timer on {value}.".to_string(),
|
||||||
|
answer: Answer::default(),
|
||||||
|
difficulty: Difficulty::default(),
|
||||||
|
scoreboard: Scoreboard::default(),
|
||||||
|
quest: QuestStrings::default(),
|
||||||
|
social: Social::default(),
|
||||||
|
account: AccountReplies::default(),
|
||||||
|
map: MapReplies::default(),
|
||||||
|
error: ErrorStrings::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SquadObject for Strings {
|
||||||
|
fn load(path: PathBuf) -> Result<Self, Error> {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(string) => {
|
||||||
|
match toml::from_str::<Self>(&string) {
|
||||||
|
Ok(object) => Ok(object),
|
||||||
|
Err(error) => Err(Error::TomlDeserializeError(error))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(error) => Err(Error::IoError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(_path: PathBuf) -> Result<(), Error> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&self, path: PathBuf) -> Result<(), Error> {
|
||||||
|
let filename = "strings.toml".to_string();
|
||||||
|
let mut full_path = path;
|
||||||
|
full_path.push(filename);
|
||||||
|
|
||||||
|
let str = match toml::to_string_pretty(&self) {
|
||||||
|
Ok(string) => string,
|
||||||
|
Err(error) => {
|
||||||
|
return Err(Error::TomlSerializeError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut file = match std::fs::File::create(full_path) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(error) => {
|
||||||
|
return Err(Error::IoError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = file.write_all(str.as_bytes()) {
|
||||||
|
return Err(Error::IoError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Strings {
|
||||||
|
pub fn formatter(&self) -> StringFormatter {
|
||||||
|
StringFormatter::new().strings(self).to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Answer {
|
||||||
|
pub from: String,
|
||||||
|
pub quest: String,
|
||||||
|
pub expected: String,
|
||||||
|
pub text: String,
|
||||||
|
pub attachment_notice: String,
|
||||||
|
pub accepted_by: String,
|
||||||
|
pub rejected_by: String,
|
||||||
|
pub reply: AnswerReplies,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Answer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
from: "## From: {u.mention}{n}".to_string(),
|
||||||
|
quest: "### Quest {q.id}: {q.name}{n}".to_string(),
|
||||||
|
expected: "### Expected answer:{n}||{q.answer}||".to_string(),
|
||||||
|
text: "{n}### Passed answer:{n}{text}".to_string(),
|
||||||
|
attachment_notice: "{n}Passed answer has attachments.".to_string(),
|
||||||
|
accepted_by: "{text}{n}Accepted by: {u.mention}".to_string(),
|
||||||
|
rejected_by: "~~{text}~~{n}Rejected by: {u.mention}".to_string(),
|
||||||
|
reply: AnswerReplies::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AnswerReplies {
|
||||||
|
pub initial: String,
|
||||||
|
pub accepted: String,
|
||||||
|
pub rejected: String,
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AnswerReplies {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
initial: "Your answer has been posted.".to_string(),
|
||||||
|
accepted: "Your answer to the quest {q.id} has been approved.{n}\
|
||||||
|
You gained: {q.reward}{n}\
|
||||||
|
Your current balance is {b.current}.".to_string(),
|
||||||
|
rejected: "Your answer to the quest {q.id} has been rejected.".to_string(),
|
||||||
|
error: "Your answer to the quest {q.id} has been approved, \
|
||||||
|
but some server error happened. \
|
||||||
|
Please contact administator for details.".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Difficulty {
|
||||||
|
pub easy: String,
|
||||||
|
pub normal: String,
|
||||||
|
pub hard: String,
|
||||||
|
pub secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Difficulty {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
easy: "Easy".to_string(),
|
||||||
|
normal: "Normal".to_string(),
|
||||||
|
hard: "Hard".to_string(),
|
||||||
|
secret: "Secret".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Difficulty {
|
||||||
|
pub fn as_string(&self, difficulty: &QuestDifficulty) -> String {
|
||||||
|
match difficulty {
|
||||||
|
QuestDifficulty::Easy => self.easy.clone(),
|
||||||
|
QuestDifficulty::Normal => self.normal.clone(),
|
||||||
|
QuestDifficulty::Hard => self.hard.clone(),
|
||||||
|
QuestDifficulty::Secret => self.secret.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Scoreboard {
|
||||||
|
pub header: String,
|
||||||
|
pub line_format: String,
|
||||||
|
pub you_format: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Scoreboard {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
header: "Current scoreboard:".to_string(),
|
||||||
|
line_format: "{n}{u.name}: **{b.full}** (**{b.current}** on balance\
|
||||||
|
+ **{b.rooms}** unlocked rooms networth)".to_string(),
|
||||||
|
you_format: "__{text}__ << You".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct QuestStrings {
|
||||||
|
pub list: String,
|
||||||
|
pub list_item: String,
|
||||||
|
pub create: String,
|
||||||
|
pub update: String,
|
||||||
|
pub publish: String,
|
||||||
|
pub delete: String,
|
||||||
|
pub message_format: String,
|
||||||
|
pub message_not_found: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for QuestStrings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
list: "Listing {value} quests:".to_string(),
|
||||||
|
list_item: "{n}{q.id}: {q.name}{n} Description: {q.description}".to_string(),
|
||||||
|
create: "Created quest {q.id}".to_string(),
|
||||||
|
update: "Updated quest {q.id}".to_string(),
|
||||||
|
publish: "Published quest {q.id}: {m.link}".to_string(),
|
||||||
|
delete: "Deleted quest {q.id}".to_string(),
|
||||||
|
message_format: "### `{q.id}` {q.name} (+{q.reward}){n}\
|
||||||
|
Difficulty: *{q.difficulty}*{n}\
|
||||||
|
{q.description}".to_string(),
|
||||||
|
message_not_found: "Warning: quest {q.id} message not found".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Social {
|
||||||
|
pub sent_channel: String,
|
||||||
|
pub sent_dm: String,
|
||||||
|
pub edited: String,
|
||||||
|
pub deleted: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Social {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
sent_channel: "Sent {m.link} ({m.id}) to {m.channel}".to_string(),
|
||||||
|
sent_dm: "Sent {m.link} ({m.id}) to {u.mention}".to_string(),
|
||||||
|
edited: "Edited message {m.id}".to_string(),
|
||||||
|
deleted: "Deleted message {m.id}".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AccountReplies {
|
||||||
|
pub reset: String,
|
||||||
|
pub give_pt: String,
|
||||||
|
pub set_pt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AccountReplies {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
reset: "Reset {u.name} account".to_string(),
|
||||||
|
give_pt: "Given {value} {pt} to {u.name}{n}\
|
||||||
|
Your current balance: {b.current}".to_string(),
|
||||||
|
set_pt: "Set {u.name} balance to {b.current}".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct MapReplies {
|
||||||
|
pub room_unlocked: String,
|
||||||
|
pub moved_to_room: String,
|
||||||
|
pub updated_avatar: String,
|
||||||
|
pub processing_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MapReplies {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
room_unlocked: "Unlocked room #{value}. Your balance: {b.current}".to_string(),
|
||||||
|
moved_to_room: "Moved to room #{value}".to_string(),
|
||||||
|
updated_avatar: "Successfully changed avatar".to_string(),
|
||||||
|
processing_url: "Processing URL...".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ErrorStrings {
|
||||||
|
pub non_command_error: String,
|
||||||
|
pub quest_not_found: String,
|
||||||
|
pub quest_is_public: String,
|
||||||
|
pub quest_is_completed: String,
|
||||||
|
pub no_content: String,
|
||||||
|
pub no_channel_or_user: String,
|
||||||
|
pub both_channel_and_user: String,
|
||||||
|
pub discord_error: String,
|
||||||
|
pub library_error: String,
|
||||||
|
pub account_not_found: String,
|
||||||
|
pub account_is_self: String,
|
||||||
|
pub insufficient_funds: String,
|
||||||
|
pub room_not_found: String,
|
||||||
|
pub room_already_unlocked: String,
|
||||||
|
pub cannot_reach: String,
|
||||||
|
pub timer_set: String,
|
||||||
|
pub not_this_guild: String,
|
||||||
|
pub quest_limit_exceeded: String,
|
||||||
|
pub both_url_and_attachment: String,
|
||||||
|
pub no_url_or_attachment: String,
|
||||||
|
pub non_image_attachment: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ErrorStrings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
non_command_error: "Internal server error: {text}".to_string(),
|
||||||
|
quest_not_found: "Quest {q.id} not found".to_string(),
|
||||||
|
quest_is_public: "Quest {q.id} is already public".to_string(),
|
||||||
|
quest_is_completed: "Quest {q.id} is already completed for this user".to_string(),
|
||||||
|
no_content: "No text or attachment were specified".to_string(),
|
||||||
|
no_channel_or_user: "No channel or user were specified".to_string(),
|
||||||
|
both_channel_and_user: "Both channel and user were specified".to_string(),
|
||||||
|
discord_error: "Discord interaction error: {text}".to_string(),
|
||||||
|
library_error: "Some internal logic error: {text}".to_string(),
|
||||||
|
account_not_found: "Given account was not found".to_string(),
|
||||||
|
account_is_self: "Given account is the same as command invoker".to_string(),
|
||||||
|
insufficient_funds: "You don't have {value} points".to_string(),
|
||||||
|
room_not_found: "Room #{value} not found".to_string(),
|
||||||
|
room_already_unlocked: "Room #{value} is already unlocked for this account".to_string(),
|
||||||
|
cannot_reach: "You cannot reach room #{value}".to_string(),
|
||||||
|
timer_set: "Timer is already set".to_string(),
|
||||||
|
not_this_guild: "Bot cannot be used in this guild".to_string(),
|
||||||
|
quest_limit_exceeded: "Exceeded limit for quest {q.id}".to_string(),
|
||||||
|
both_url_and_attachment: "Both URL and attachment were specified".to_string(),
|
||||||
|
no_url_or_attachment: "No URL or attachment were specified".to_string(),
|
||||||
|
non_image_attachment: "Given attachment is not an image".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
discord/src/timer.rs
Normal file
56
discord/src/timer.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::{Datelike, Timelike, Utc};
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use toml::value::Date as TomlDate;
|
||||||
|
|
||||||
|
use crate::{Context, commands::{print_error_recursively, quest::publish_inner}};
|
||||||
|
|
||||||
|
const DAY_IN_SECONDS: u64 = 24 * 60 * 60;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DailyTimer {
|
||||||
|
start_time: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DailyTimer {
|
||||||
|
pub fn new(start_time: u64) -> Self {
|
||||||
|
Self { start_time }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_countdown(&self) -> u64 {
|
||||||
|
let current_time = Utc::now().time();
|
||||||
|
let seconds = current_time.num_seconds_from_midnight() as u64;
|
||||||
|
let result = if seconds > self.start_time {
|
||||||
|
DAY_IN_SECONDS + self.start_time - seconds
|
||||||
|
} else {
|
||||||
|
self.start_time - seconds
|
||||||
|
};
|
||||||
|
if result == 0 {
|
||||||
|
return DAY_IN_SECONDS - 1;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(&self, ctx: Context<'_>) {
|
||||||
|
loop {
|
||||||
|
let countdown = self.get_countdown();
|
||||||
|
println!("Daily timer: sleeping for {countdown} seconds.");
|
||||||
|
sleep(Duration::from_secs(countdown)).await;
|
||||||
|
let now = Utc::now().date_naive();
|
||||||
|
let date = TomlDate {
|
||||||
|
year: now.year() as u16,
|
||||||
|
month: now.month() as u8,
|
||||||
|
day: now.day() as u8,
|
||||||
|
};
|
||||||
|
let conf = &ctx.data().config;
|
||||||
|
let quests = conf.load_quests().into_iter().filter(|q| !q.public && q.available_on.is_some_and(|d| d <= date));
|
||||||
|
for mut quest in quests {
|
||||||
|
if let Err(error) = publish_inner(ctx, &mut quest).await {
|
||||||
|
eprintln!("ERROR in timer:");
|
||||||
|
print_error_recursively(&error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
//! User accounts
|
//! User accounts
|
||||||
|
|
||||||
use std::{fs, io::Write, path::PathBuf};
|
use std::{collections::HashMap, fs, io::Write, path::PathBuf};
|
||||||
|
|
||||||
use serde::{ Serialize, Deserialize };
|
use serde::{ Serialize, Deserialize };
|
||||||
|
|
||||||
use crate::{SquadObject, error::Error};
|
use crate::{error::Error, quest::Quest, SquadObject};
|
||||||
|
|
||||||
fn default_id() -> String {
|
fn default_id() -> String {
|
||||||
"none".to_string()
|
"none".to_string()
|
||||||
|
|
@ -29,6 +29,9 @@ pub struct Account {
|
||||||
|
|
||||||
/// Vec of rooms unlocked by this user
|
/// Vec of rooms unlocked by this user
|
||||||
pub rooms_unlocked: Vec<u16>,
|
pub rooms_unlocked: Vec<u16>,
|
||||||
|
|
||||||
|
/// Additional implementation-defined data
|
||||||
|
pub data: Option<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Account {
|
impl Default for Account {
|
||||||
|
|
@ -39,6 +42,7 @@ impl Default for Account {
|
||||||
location: u16::default(),
|
location: u16::default(),
|
||||||
quests_completed: Vec::new(),
|
quests_completed: Vec::new(),
|
||||||
rooms_unlocked: Vec::new(),
|
rooms_unlocked: Vec::new(),
|
||||||
|
data: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -95,3 +99,10 @@ impl SquadObject for Account {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
/// Returns true if given quest is completed on this account
|
||||||
|
pub fn has_completed_quest(&self, quest: &Quest) -> bool {
|
||||||
|
self.quests_completed.contains(&quest.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
25
src/bin/deb.rs
Normal file
25
src/bin/deb.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
//! This binary generates DEBIAN/control text for use in debian package
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
let homepage = env!("CARGO_PKG_HOMEPAGE");
|
||||||
|
let dpkg_arch = {
|
||||||
|
let output = match Command::new("dpkg")
|
||||||
|
.arg("--print-architecture")
|
||||||
|
.output() {
|
||||||
|
Ok(out) => out,
|
||||||
|
Err(error) => panic!("error running dpkg: {error}"),
|
||||||
|
};
|
||||||
|
String::from_utf8(output.stdout).expect("dpkg returned ill UTF-8")
|
||||||
|
};
|
||||||
|
println!("Package: squad-quest\n\
|
||||||
|
Version: {version}-1\n\
|
||||||
|
Architecture: {dpkg_arch}\
|
||||||
|
Section: misc\n\
|
||||||
|
Priority: optional\n\
|
||||||
|
Homepage: {homepage}\n\
|
||||||
|
Description: Simple RPG-like system for hosting events\n \
|
||||||
|
Includes discord bot and CLI\n\
|
||||||
|
Maintainer: Alexey Mirenkov <2ndbeam@disroot.org>");
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use crate::{SquadObject, account::Account, error::Error, quest::Quest};
|
use crate::{SquadObject, account::Account, error::Error, quest::Quest};
|
||||||
|
|
||||||
/// Struct for containing paths to other (de-)serializable things
|
/// Struct for containing paths to other (de-)serializable things
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
/// Path to config directory
|
/// Path to config directory
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,18 @@ impl std::error::Error for Error {
|
||||||
pub enum QuestError {
|
pub enum QuestError {
|
||||||
/// Quest (self.0) is already completed for given account (self.1)
|
/// Quest (self.0) is already completed for given account (self.1)
|
||||||
AlreadyCompleted(u16, String),
|
AlreadyCompleted(u16, String),
|
||||||
|
/// Account (self.0) not found
|
||||||
|
AccountNotFound(String),
|
||||||
|
/// Limit for quest (self.0) exceeded
|
||||||
|
LimitExceeded(u16),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for QuestError {
|
impl fmt::Display for QuestError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::AlreadyCompleted(quest_id, account_id) => write!(f, "quest #{quest_id} is already completed for account \"{account_id}\""),
|
Self::AlreadyCompleted(quest_id, account_id) => write!(f, "quest #{quest_id} is already completed for account \"{account_id}\""),
|
||||||
|
Self::AccountNotFound(account_id) => write!(f, "account \"{account_id}\""),
|
||||||
|
Self::LimitExceeded(quest_id) => write!(f, "exceeded limit for quest #{quest_id}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Map, a.k.a. a graph of rooms
|
//! Map, a.k.a. a graph of rooms
|
||||||
|
|
||||||
use std::{fs, io::Write, path::PathBuf};
|
use std::{collections::HashMap, fs, io::Write, path::PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
@ -11,7 +11,7 @@ use crate::{SquadObject, account::Account, error::{Error, MapError}};
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Map {
|
pub struct Map {
|
||||||
/// Rooms go here
|
/// Rooms go here
|
||||||
pub room: Vec<Room>
|
pub room: Vec<Room>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Map {
|
impl Default for Map {
|
||||||
|
|
@ -131,6 +131,8 @@ pub struct Room {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Room description
|
/// Room description
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
/// Additional implementation-based data
|
||||||
|
pub data: Option<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_name() -> String {
|
fn default_name() -> String {
|
||||||
|
|
@ -145,6 +147,7 @@ impl Default for Room {
|
||||||
value: u32::default(),
|
value: u32::default(),
|
||||||
name: default_name(),
|
name: default_name(),
|
||||||
description: None,
|
description: None,
|
||||||
|
data: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Text-based quests and user solutions for them
|
//! Text-based quests and user solutions for them
|
||||||
|
|
||||||
use std::{fs, io::Write, path::PathBuf};
|
use std::{collections::HashMap, fs, io::Write, path::PathBuf};
|
||||||
|
|
||||||
use serde::{ Serialize, Deserialize };
|
use serde::{ Serialize, Deserialize };
|
||||||
use crate::{SquadObject, account::Account, error::{Error, QuestError}};
|
use crate::{SquadObject, account::Account, error::{Error, QuestError}};
|
||||||
|
|
@ -66,7 +66,14 @@ pub struct Quest {
|
||||||
pub available_on: Option<Date>,
|
pub available_on: Option<Date>,
|
||||||
|
|
||||||
/// When quest expires
|
/// When quest expires
|
||||||
pub deadline: Option<Date>
|
pub deadline: Option<Date>,
|
||||||
|
|
||||||
|
/// Additional implementation-defined data
|
||||||
|
pub data: Option<HashMap<String, String>>,
|
||||||
|
|
||||||
|
/// Limit how many users can complete the quest.
|
||||||
|
/// If set to 0, quest is not limited.
|
||||||
|
pub limit: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Quest {
|
impl Default for Quest {
|
||||||
|
|
@ -80,7 +87,9 @@ impl Default for Quest {
|
||||||
answer: default_answer(),
|
answer: default_answer(),
|
||||||
public: false,
|
public: false,
|
||||||
available_on: None,
|
available_on: None,
|
||||||
deadline: None
|
deadline: None,
|
||||||
|
data: None,
|
||||||
|
limit: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +164,17 @@ impl Quest {
|
||||||
/// // handle error
|
/// // handle error
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
pub fn complete_for_account(&self, account: &mut Account) -> Result<(),QuestError> {
|
pub fn complete_for_account(&self, id: &str, accounts: &mut Vec<Account>) -> Result<(),QuestError> {
|
||||||
|
let completed_times = accounts.iter().filter(|a| a.has_completed_quest(self)).count();
|
||||||
|
|
||||||
|
if self.limit > 0 && completed_times as u8 >= self.limit {
|
||||||
|
return Err(QuestError::LimitExceeded(self.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(account) = accounts.iter_mut().find(|a| a.id == id) else {
|
||||||
|
return Err(QuestError::AccountNotFound(id.to_string()));
|
||||||
|
};
|
||||||
|
|
||||||
match account.quests_completed.iter().find(|qid| **qid == self.id) {
|
match account.quests_completed.iter().find(|qid| **qid == self.id) {
|
||||||
Some(_) => Err(QuestError::AlreadyCompleted(self.id, account.id.clone())),
|
Some(_) => Err(QuestError::AlreadyCompleted(self.id, account.id.clone())),
|
||||||
None => {
|
None => {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@ fn quest_one() {
|
||||||
answer: "Accept the answer if it has no attachments and an empty comment".to_owned(),
|
answer: "Accept the answer if it has no attachments and an empty comment".to_owned(),
|
||||||
public: false,
|
public: false,
|
||||||
available_on: None,
|
available_on: None,
|
||||||
deadline: None
|
deadline: None,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(*quest, expected);
|
assert_eq!(*quest, expected);
|
||||||
|
|
@ -73,7 +74,8 @@ fn account_test() {
|
||||||
balance: 150,
|
balance: 150,
|
||||||
location: 0,
|
location: 0,
|
||||||
quests_completed: vec![0],
|
quests_completed: vec![0],
|
||||||
rooms_unlocked: Vec::new()
|
rooms_unlocked: Vec::new(),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let accounts = config.load_accounts();
|
let accounts = config.load_accounts();
|
||||||
|
|
@ -92,6 +94,7 @@ fn load_map() {
|
||||||
value: 0,
|
value: 0,
|
||||||
name: "Entrance".to_string(),
|
name: "Entrance".to_string(),
|
||||||
description: Some("Enter the dungeon".to_string()),
|
description: Some("Enter the dungeon".to_string()),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let room1 = Room {
|
let room1 = Room {
|
||||||
|
|
@ -100,6 +103,7 @@ fn load_map() {
|
||||||
value: 100,
|
value: 100,
|
||||||
name: "Kitchen hall".to_string(),
|
name: "Kitchen hall".to_string(),
|
||||||
description: None,
|
description: None,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let room2 = Room {
|
let room2 = Room {
|
||||||
|
|
@ -108,6 +112,7 @@ fn load_map() {
|
||||||
value: 250,
|
value: 250,
|
||||||
name: "Room".to_string(),
|
name: "Room".to_string(),
|
||||||
description: Some("Simple room with no furniture".to_string()),
|
description: Some("Simple room with no furniture".to_string()),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let room3 = Room {
|
let room3 = Room {
|
||||||
|
|
@ -116,6 +121,7 @@ fn load_map() {
|
||||||
value: 175,
|
value: 175,
|
||||||
name: "Kitchen".to_string(),
|
name: "Kitchen".to_string(),
|
||||||
description: Some("Knives are stored here".to_string()),
|
description: Some("Knives are stored here".to_string()),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let expected = Map {
|
let expected = Map {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue