From aec4ef8339ad261665d264a14498e4db943c1a89 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Tue, 16 Dec 2025 16:42:18 +0300 Subject: [PATCH 01/13] feat(discord)!: Added string formatter - Added string formatter - Added Strings struct for passing strings from file - Refactored /info and /quest * to use formatter BREAKING CHANGE: Changed DiscordConfig fields --- discord/src/account.rs | 23 ++- discord/src/commands/account.rs | 22 +-- discord/src/commands/mod.rs | 10 +- discord/src/commands/quest.rs | 69 ++++---- discord/src/config.rs | 49 +++--- discord/src/main.rs | 10 +- discord/src/strings.rs | 287 ++++++++++++++++++++++++++++++++ 7 files changed, 386 insertions(+), 84 deletions(-) create mode 100644 discord/src/strings.rs diff --git a/discord/src/account.rs b/discord/src/account.rs index 50ebb03..bc433e3 100644 --- a/discord/src/account.rs +++ b/discord/src/account.rs @@ -1,4 +1,5 @@ -use squad_quest::{account::Account, config::Config}; +use poise::serenity_prelude::UserId; +use squad_quest::{account::Account, config::Config, map::Map}; pub fn fetch_or_init_account(conf: &Config, id: String) -> Account { let accounts = conf.load_accounts(); @@ -10,3 +11,23 @@ pub fn fetch_or_init_account(conf: &Config, id: String) -> 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::().expect("automatically inserted")) +} diff --git a/discord/src/commands/account.rs b/discord/src/commands/account.rs index 5027f79..ebaede1 100644 --- a/discord/src/commands/account.rs +++ b/discord/src/commands/account.rs @@ -1,7 +1,7 @@ use poise::serenity_prelude::UserId; 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_rooms_value, account_user_id, fetch_or_init_account}}; async fn account_balance_string(ctx: &Context<'_>, account: &Account, map: &Map) -> String { let rooms_value = account_rooms_value(account, map); @@ -20,26 +20,6 @@ async fn account_balance_string(ctx: &Context<'_>, account: &Account, map: &Map) ) } -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::().expect("automatically inserted")) -} - #[poise::command( prefix_command, slash_command, diff --git a/discord/src/commands/mod.rs b/discord/src/commands/mod.rs index 608ee48..bc59244 100644 --- a/discord/src/commands/mod.rs +++ b/discord/src/commands/mod.rs @@ -21,12 +21,10 @@ pub async fn register(ctx: Context<'_>) -> Result<(), Error> { slash_command, )] pub async fn info(ctx: Context<'_>) -> Result<(), Error> { - let reply_string = format!("\ - SquadQuest version {ver}\n\ - Find the map here: {url}", - ver = env!("CARGO_PKG_VERSION"), - url = "not implemented yet!", - ); + let strings = &ctx.data().strings; + let formatter = strings.formatter(); + let reply_string = formatter.fmt(&strings.info); + ctx.say(reply_string).await?; Ok(()) } diff --git a/discord/src/commands/quest.rs b/discord/src/commands/quest.rs index af3af5a..fd6c8b6 100644 --- a/discord/src/commands/quest.rs +++ b/discord/src/commands/quest.rs @@ -1,4 +1,4 @@ -use std::{future, path::Path, str::FromStr}; +use std::{future, str::FromStr}; use poise::serenity_prelude::{CreateMessage, EditMessage, Message, futures::StreamExt}; use squad_quest::{SquadObject, quest::{Quest, QuestDifficulty}}; @@ -24,16 +24,10 @@ async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result Ok(messages.first().cloned()) } -fn make_quest_message_content(quest: &Quest) -> String { - format!("### `#{id}` {name} (+{reward})\n\ - Difficulty: *{difficulty:?}*\n\ - {description}", - id = quest.id, - name = quest.name, - reward = quest.reward, - difficulty = quest.difficulty, - description = quest.description, - ) +fn make_quest_message_content(ctx: Context<'_>, quest: &Quest) -> String { + let strings = &ctx.data().strings; + let formatter = strings.formatter().quest(quest); + formatter.fmt(&strings.quest.message_format) } #[poise::command( @@ -60,13 +54,12 @@ pub async fn list( ) -> Result<(), Error> { let conf = &ctx.data().config; 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 { - reply_string.push_str(format!("\n#{}: {}\n\tDescription: {}", - quest.id, - quest.name, - quest.description, - ).as_str()); + formatter = formatter.quest(&quest); + reply_string.push_str(formatter.fmt(&strings.quest.list_item).as_str()); } ctx.reply(reply_string).await?; Ok(()) @@ -168,7 +161,10 @@ pub async fn create( let path = conf.full_quests_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?; @@ -228,7 +224,6 @@ pub async fn update( }, } - let new_quest = Quest { id, difficulty, @@ -241,23 +236,25 @@ pub async fn update( deadline: dead_line, }; + let strings = &ctx.data().strings; + let formatter = strings.formatter().quest(&new_quest); + if new_quest.public { - let content = make_quest_message_content(&new_quest); + let content = make_quest_message_content(ctx, &new_quest); let builder = EditMessage::new().content(content); let message = find_quest_message(ctx, id).await?; if let Some(mut message) = message { message.edit(ctx, builder).await?; } else { - let reply_string = format!("Quest #{id} is public, but its message was not found in the quest channel", - ); + let reply_string = formatter.fmt(&strings.quest.message_not_found); ctx.reply(reply_string).await?; } } let path = conf.full_quests_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?; Ok(()) @@ -286,7 +283,7 @@ pub async fn publish( quest.public = true; - let content = make_quest_message_content(&quest); + let content = make_quest_message_content(ctx, &quest); let builder = CreateMessage::new() .content(content); @@ -297,19 +294,15 @@ pub async fn publish( 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())? - }; + channel.send_message(ctx, builder).await?; let quests_path = ctx.data().config.full_quests_path(); quest.save(quests_path)?; - let reply_string = format!("Published quest #{id}"); + let strings = &ctx.data().strings; + let formatter = strings.formatter().quest(&quest); + + let reply_string = formatter.fmt(&strings.quest.publish); ctx.reply(reply_string).await?; Ok(()) @@ -342,7 +335,15 @@ pub async fn delete( 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?; Ok(()) diff --git a/discord/src/config.rs b/discord/src/config.rs index fee6498..cb82169 100644 --- a/discord/src/config.rs +++ b/discord/src/config.rs @@ -1,35 +1,55 @@ 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 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 guild: GuildId, pub quests_channel: ChannelId, pub answers_channel: ChannelId, - pub quests_messages: Vec, + 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 { - fn discord_impl(&self) -> Result; + fn discord_impl(&self) -> Result<(DiscordConfig, Strings), Error>; fn init_impl(&self) -> Result<(), Error>; } impl ConfigImpl for Config { - fn discord_impl(&self) -> Result { - let Some(path) = &self.full_impl_path() else { + fn discord_impl(&self) -> Result<(DiscordConfig, Strings), Error> { + let Some(path) = self.full_impl_path() else { 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> { let Some(path) = self.full_impl_path() else { return Err(Error::IsNotImplemented); }; + let folder = path.parent().unwrap_or(Path::new("")).to_owned(); 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> { - match Self::load(path.clone()) { - Ok(_) => { - if let Err(error) = std::fs::remove_file(path) { - return Err(Error::IoError(error)); - } - - Ok(()) - }, - Err(error) => Err(error) - } + fn delete(_path: PathBuf) -> Result<(), Error> { + unimplemented!() } fn save(&self, path: PathBuf) -> Result<(), Error> { diff --git a/discord/src/main.rs b/discord/src/main.rs index d37aa0d..70b6625 100644 --- a/discord/src/main.rs +++ b/discord/src/main.rs @@ -5,20 +5,23 @@ use dotenvy::dotenv; use poise::serenity_prelude as serenity; use squad_quest::config::Config; -use crate::{commands::error_handler, config::{ConfigImpl, DiscordConfig}, error::Error}; +use crate::{commands::error_handler, config::{ConfigImpl, DiscordConfig}, error::Error, strings::Strings}; mod commands; mod cli; mod config; mod account; mod error; +mod strings; const CONFIG_PATH: &str = "cfg/config.toml"; +const DISCORD_TOKEN: &str = "DISCORD_TOKEN"; #[derive(Debug)] struct Data { pub config: Config, pub discord: Arc>, + pub strings: Strings, } type Context<'a> = poise::Context<'a, Data, Error>; @@ -28,12 +31,12 @@ async fn main() { let cli = cli::Cli::parse(); 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.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 framework = poise::Framework::builder() @@ -60,6 +63,7 @@ async fn main() { Ok(Data { config, discord: Arc::new(Mutex::new(discord)), + strings, }) }) }) diff --git a/discord/src/strings.rs b/discord/src/strings.rs new file mode 100644 index 0000000..d31c6e0 --- /dev/null +++ b/discord/src/strings.rs @@ -0,0 +1,287 @@ +use std::{collections::HashMap, fmt::Display, io::Write, path::PathBuf}; + +use poise::serenity_prelude::{Mentionable, 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, + 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 new_tags = vec![ id, difficulty, reward, name, description, answer ]; + + self.with_tags(new_tags) + } + + 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(self, account: &Account, map: &Map) -> Self { + let balance = ("{b.current}".to_string(), self.points(account.balance)); + 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![ balance, full_balance, rooms_balance ]; + + 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 answer: Answer, + pub difficulty: Difficulty, + pub scoreboard: Scoreboard, + pub quest: QuestStrings, +} + +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(), + answer: Answer::default(), + difficulty: Difficulty::default(), + scoreboard: Scoreboard::default(), + quest: QuestStrings::default(), + } + } +} + +impl SquadObject for Strings { + fn load(path: PathBuf) -> Result { + match std::fs::read_to_string(path) { + Ok(string) => { + match toml::from_str::(&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, +} + +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: "### Passed answer:{n}{text}".to_string(), + attachment_notice: "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(), + } + } +} + +#[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)] +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)] +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}: {text}".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(), + } + } +} From 787118309accdd75f62e8daf5cc158b901dd075e Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Wed, 17 Dec 2025 14:43:40 +0300 Subject: [PATCH 02/13] feat(discord): Moved most strings to Strings - Added Error::AccountIsSelf variant - /balance give to self now returns error --- discord/src/commands/account.rs | 76 ++++++++++++-------- discord/src/commands/answer.rs | 59 ++++++++-------- discord/src/commands/init.rs | 4 +- discord/src/commands/map.rs | 15 +++- discord/src/commands/social.rs | 56 +++++++++------ discord/src/error.rs | 3 + discord/src/strings.rs | 119 ++++++++++++++++++++++++++++++-- 7 files changed, 247 insertions(+), 85 deletions(-) diff --git a/discord/src/commands/account.rs b/discord/src/commands/account.rs index ebaede1..7dc5cb2 100644 --- a/discord/src/commands/account.rs +++ b/discord/src/commands/account.rs @@ -1,23 +1,24 @@ -use poise::serenity_prelude::UserId; +use poise::serenity_prelude::User; use squad_quest::{SquadObject, account::Account, map::Map}; -use crate::{Context, Error, account::{account_full_balance, account_rooms_value, account_user_id, fetch_or_init_account}}; +use crate::{Context, Error, account::{account_full_balance, account_user_id, fetch_or_init_account}, strings::StringFormatter}; -async fn account_balance_string(ctx: &Context<'_>, account: &Account, map: &Map) -> String { - let rooms_value = account_rooms_value(account, map); - let full_balance = account_full_balance(account, map); +async fn account_balance_string( + ctx: &Context<'_>, + account: &Account, + map: &Map, + mut formatter: StringFormatter +) -> String { let account_id = account_user_id(&account); - - let Ok(user) = account_id - .to_user(ctx) - .await else { + + let Ok(user) = account_id.to_user(ctx).await else { return String::new(); }; - let name = user.display_name(); - format!("\n{name}: **{full_balance}** points (**{balance}** on balance \ - + **{rooms_value}** unlocked rooms networth)", - balance = account.balance, - ) + + let strings = &ctx.data().strings; + formatter = formatter.user(&user).balance(account, map); + + formatter.fmt(&strings.scoreboard.line_format) } #[poise::command( @@ -28,11 +29,11 @@ async fn account_balance_string(ctx: &Context<'_>, account: &Account, map: &Map) )] pub async fn reset( ctx: Context<'_>, - who: UserId, + who: User, ) -> Result<(), Error> { 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) { return Err(Error::AccountNotFound); @@ -42,7 +43,10 @@ pub async fn reset( path.push(format!("{acc_id}.toml")); 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?; Ok(()) @@ -59,6 +63,9 @@ pub async fn scoreboard( let map_path = ctx.data().config.full_map_path(); 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(); accounts.sort_by(|a,b| { let a_balance = account_full_balance(a, &map); @@ -68,13 +75,14 @@ pub async fn scoreboard( 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 { 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 { - str = format!("__{str}__ << You"); + formatter = formatter.text(&str); + str = formatter.fmt(&strings.scoreboard.you_format); } reply_string.push_str(&str); @@ -104,16 +112,20 @@ pub async fn balance( )] pub async fn give( ctx: Context<'_>, - who: UserId, + who: User, amount: u32, ) -> Result<(), Error> { + if ctx.author() == &who { + return Err(Error::AccountIsSelf); + } + let config = &ctx.data().config; let mut accounts = config.load_accounts(); let user_id = format!("{}", ctx.author().id.get()); let mut user_account = fetch_or_init_account(config, user_id); - 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 { return Err(Error::AccountNotFound); }; @@ -129,8 +141,13 @@ pub async fn give( user_account.save(accounts_path.clone())?; other_account.save(accounts_path)?; - let reply_string = format!("Given money to user.\n\ - Your new balance: {} points.", user_account.balance); + let strings = &ctx.data().strings; + let formatter = strings.formatter() + .user(&who) + .value(amount) + .current_balance(&user_account); + + let reply_string = formatter.fmt(&strings.account.give_pt); ctx.reply(reply_string).await?; Ok(()) @@ -144,12 +161,12 @@ pub async fn give( )] pub async fn set( ctx: Context<'_>, - who: UserId, + who: User, amount: u32, ) -> Result<(), Error> { 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 { return Err(Error::AccountNotFound); }; @@ -157,8 +174,13 @@ pub async fn set( account.balance = amount; let accounts_path = ctx.data().config.full_accounts_path(); account.save(accounts_path)?; + + let strings = &ctx.data().strings; + let formatter = strings.formatter() + .user(&who) + .current_balance(&account); - let reply_string = format!("Set user balance to {amount}."); + let reply_string = formatter.fmt(&strings.account.set_pt); ctx.reply(reply_string).await?; Ok(()) diff --git a/discord/src/commands/answer.rs b/discord/src/commands/answer.rs index fd55666..f01b9c7 100644 --- a/discord/src/commands/answer.rs +++ b/discord/src/commands/answer.rs @@ -1,4 +1,4 @@ -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 crate::{Context, Error, account::fetch_or_init_account}; @@ -33,7 +33,7 @@ pub async fn answer( .find(|q| q.id == quest_id) else { return Err(Error::QuestNotFound(quest_id)); }; - + let mut files: Vec = Vec::new(); for file in [file1, file2, file3] { if let Some(f) = file { @@ -45,23 +45,30 @@ pub async fn answer( return Err(Error::NoContent); } - let text_ans = match text { - Some(text) => format!("\n### Passed answer:\n{text}"), + let strings = &ctx.data().strings; + let mut formatter = strings.formatter() + .user(ctx.author()) + .quest(quest); + + let text_ans = match text { + Some(text) => { + formatter = formatter.text(text); + formatter.fmt(&strings.answer.text) + }, None => String::new(), }; 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\ - ### Quest #{quest_id}: {quest_name}\n\ - ### Expected answer:\n\ - ||{quest_answer}||{text_ans}{attachment_notice}", - user = ctx.author().mention(), - quest_name = quest.name, - quest_answer = quest.answer, - ); + let content = [ + formatter.fmt(&strings.answer.from), + formatter.fmt(&strings.answer.quest), + formatter.fmt(&strings.answer.expected), + text_ans, + attachment_notice, + ].join(""); let mut attachments: Vec = Vec::new(); @@ -91,19 +98,21 @@ pub async fn answer( 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?; if let Some(press) = ComponentInteractionCollector::new(ctx) .filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string())) .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 content = if is_approved { - format!("{content}\nApproved by: {admin}") + formatter.fmt(&strings.answer.accepted_by) } else { - format!("~~{content}~~\nRejected by: {admin}") + formatter.fmt(&strings.answer.rejected_by) }; let builder = EditMessage::new().content(content).components(Vec::new()); @@ -122,21 +131,15 @@ pub async fn answer( no_errors = false; }; + formatter = formatter.current_balance(&account); + if no_errors { - content = format!("Your answer to the quest #{quest_id} has been approved.\n\ - You gained {reward} points.\n\ - Your balance is now {balance} points", - reward = quest.reward, - balance = account.balance - ); + content = formatter.fmt(&strings.answer.reply.accepted); } else { - content = format!("Your answer to the quest #{quest_id} has been approved, \ - but some server error happened. \ - Please contact administrator for details." - ); + content = formatter.fmt(&strings.answer.reply.error); } } 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); ctx.author().dm(ctx, dm_builder).await?; diff --git a/discord/src/commands/init.rs b/discord/src/commands/init.rs index e642921..aac39a2 100644 --- a/discord/src/commands/init.rs +++ b/discord/src/commands/init.rs @@ -30,7 +30,9 @@ pub async fn init( let path = &ctx.data().config.full_impl_path().unwrap(); 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?; Ok(()) diff --git a/discord/src/commands/map.rs b/discord/src/commands/map.rs index efa58f7..38c6a0c 100644 --- a/discord/src/commands/map.rs +++ b/discord/src/commands/map.rs @@ -31,8 +31,14 @@ pub async fn unlock( let account_path = conf.full_accounts_path(); account.save(account_path)?; + + let strings = &ctx.data().strings; + let formatter = strings.formatter() + .user(ctx.author()) + .balance(&account, &map) + .value(id); - let reply_string = format!("Unlocked room #{id}. Your balance: {} points", account.balance); + let reply_string = formatter.fmt(&strings.map.room_unlocked); ctx.reply(reply_string).await?; Ok(()) @@ -59,8 +65,13 @@ pub async fn r#move( account.location = id; let account_path = conf.full_accounts_path(); account.save(account_path)?; + + let strings = &ctx.data().strings; + let formatter = strings.formatter() + .user(ctx.author()) + .value(id); - let reply_string = format!("Moved to room #{id}."); + let reply_string = formatter.fmt(&strings.map.moved_to_room); ctx.reply(reply_string).await?; Ok(()) diff --git a/discord/src/commands/social.rs b/discord/src/commands/social.rs index 118b593..33cb2d5 100644 --- a/discord/src/commands/social.rs +++ b/discord/src/commands/social.rs @@ -1,4 +1,4 @@ -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}; @@ -51,20 +51,22 @@ pub async fn msg ( builder = builder.add_file(attachment); } + let strings = &ctx.data().strings; + let reply_string = if let Some(channel) = channel { let message = channel.send_message(ctx, builder).await?; - format!("Sent {message} ({message_id}) to {channel}!", - message = message.link(), - message_id = message.id, - channel = channel.mention(), - ) - } else if let Some(user) = user { - let message = user.dm(ctx, builder).await?; - format!("Sent {message} ({message_id}) to {user}", - message = message.link(), - message_id = message.id, - user = user.mention(), - ) + let formatter = strings.formatter() + .message(&message); + + formatter.fmt(&strings.social.sent_channel) + } else if let Some(user_id) = user { + let message = user_id.dm(ctx, builder).await?; + let user = user_id.to_user(ctx).await?; + let formatter = strings.formatter() + .message(&message) + .user(&user); + + formatter.fmt(&strings.social.sent_dm) } else { unreachable!(); }; @@ -113,16 +115,23 @@ pub async fn edit ( builder = builder.new_attachment(attachment); } + let mut message; 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?; } else if let Some(user) = user { 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?; + } 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?; Ok(()) @@ -149,17 +158,24 @@ pub async fn undo( if channel.is_some() && user.is_some() { return Err(Error::BothChannelAndUser); } - + + let message; if let Some(channel) = channel { - let message = channel.message(ctx, message_id).await?; + message = channel.message(ctx, message_id).await?; message.delete(ctx).await?; } else if let Some(user) = user { 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?; + } else { + unreachable!() } + + let strings = &ctx.data().strings; + let formatter = strings.formatter() + .message(&message); - let reply_string = "Successfully deleted message".to_string(); + let reply_string = formatter.fmt(&strings.social.deleted); ctx.reply(reply_string).await?; Ok(()) diff --git a/discord/src/error.rs b/discord/src/error.rs index 1cb3927..e5f57c8 100644 --- a/discord/src/error.rs +++ b/discord/src/error.rs @@ -15,6 +15,7 @@ pub enum Error { SerenityError(serenity::Error), SquadQuestError(squad_quest::error::Error), AccountNotFound, + AccountIsSelf, InsufficientFunds(u32), RoomNotFound(u16), RoomAlreadyUnlocked(u16), @@ -57,6 +58,7 @@ impl Display for Error { Self::SerenityError(_) => write!(f, "discord interaction error"), Self::SquadQuestError(_) => write!(f, "internal logic error"), 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::RoomNotFound(id) => write!(f, "room #{id} not found"), Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"), @@ -75,6 +77,7 @@ impl std::error::Error for Error { Self::NoChannelOrUser | Self::BothChannelAndUser | Self::AccountNotFound | + Self::AccountIsSelf | Self::InsufficientFunds(_) | Self::RoomNotFound(_) | Self::RoomAlreadyUnlocked(_) | diff --git a/discord/src/strings.rs b/discord/src/strings.rs index d31c6e0..711afc9 100644 --- a/discord/src/strings.rs +++ b/discord/src/strings.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, fmt::Display, io::Write, path::PathBuf}; -use poise::serenity_prelude::{Mentionable, User}; +use poise::serenity_prelude::{Mentionable, Message, User}; use serde::{Deserialize, Serialize}; use squad_quest::{SquadObject, account::Account, map::Map, quest::{Quest, QuestDifficulty}, error::Error}; @@ -58,8 +58,8 @@ impl StringFormatter { self.with_tags(new_tags) } - pub fn balance(self, account: &Account, map: &Map) -> Self { - let balance = ("{b.current}".to_string(), self.points(account.balance)); + 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)), @@ -68,7 +68,21 @@ impl StringFormatter { "{b.rooms}".to_string(), self.points(account_rooms_value(account, map)), ); - let new_tags = vec![ balance, full_balance, rooms_balance ]; + 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) } @@ -76,7 +90,7 @@ impl StringFormatter { pub fn text(self, text: impl ToString) -> Self { let text = ("{text}".to_string(), text.to_string()); - self.with_tags(vec![text]) +self.with_tags(vec![text]) } pub fn value(self, value: impl ToString) -> Self { @@ -110,9 +124,13 @@ pub struct Strings { pub url: String, pub points: String, pub info: String, + pub init_reply: String, + pub account: AccountReplies, pub answer: Answer, pub difficulty: Difficulty, + pub map: MapReplies, pub scoreboard: Scoreboard, + pub social: Social, pub quest: QuestStrings, } @@ -123,10 +141,14 @@ impl Default for Strings { 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(), answer: Answer::default(), difficulty: Difficulty::default(), scoreboard: Scoreboard::default(), quest: QuestStrings::default(), + social: Social::default(), + account: AccountReplies::default(), + map: MapReplies::default(), } } } @@ -192,6 +214,7 @@ pub struct Answer { pub attachment_notice: String, pub accepted_by: String, pub rejected_by: String, + pub reply: AnswerReplies, } impl Default for Answer { @@ -200,10 +223,35 @@ impl Default for Answer { 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: "### Passed answer:{n}{text}".to_string(), - attachment_notice: "Passed answer has attachments.".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(), } } } @@ -240,6 +288,7 @@ match difficulty { } #[derive(Deserialize, Serialize, Debug)] +#[serde(default)] pub struct Scoreboard { pub header: String, pub line_format: String, @@ -258,6 +307,7 @@ impl Default for Scoreboard { } #[derive(Deserialize, Serialize, Debug)] +#[serde(default)] pub struct QuestStrings { pub list: String, pub list_item: String, @@ -285,3 +335,58 @@ impl Default for QuestStrings { } } } + +#[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, +} + +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(), + } + } +} From 60aa5fcb3484dd83baa326fe0397f55d7e206107 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Thu, 18 Dec 2025 13:33:42 +0300 Subject: [PATCH 03/13] feat(discord): Commands description - Added english commands description - Added russian commands description - Changed override option on /quest update to reset dates - Commented out all deadline functionality --- discord/src/commands/account.rs | 28 ++++++++++ discord/src/commands/answer.rs | 13 +++++ discord/src/commands/init.rs | 10 +++- discord/src/commands/map.rs | 12 +++++ discord/src/commands/mod.rs | 3 ++ discord/src/commands/quest.rs | 95 +++++++++++++++++++++++++++------ discord/src/commands/social.rs | 46 ++++++++++++++++ discord/src/main.rs | 6 +-- 8 files changed, 191 insertions(+), 22 deletions(-) diff --git a/discord/src/commands/account.rs b/discord/src/commands/account.rs index 7dc5cb2..5f2896e 100644 --- a/discord/src/commands/account.rs +++ b/discord/src/commands/account.rs @@ -21,14 +21,20 @@ async fn account_balance_string( formatter.fmt(&strings.scoreboard.line_format) } +/// Reset user account, including balance, unlocked rooms and completed quests #[poise::command( prefix_command, slash_command, guild_only, required_permissions = "ADMINISTRATOR", + name_localized("ru", "сбросить"), + description_localized("ru", "Сбросить аккаунт пользователя, вкл. баланс, открытые комнаты и пройденные квесты"), )] pub async fn reset( ctx: Context<'_>, + #[description = "The user to reset"] + #[name_localized("ru", "кого")] + #[description_localized("ru", "Сбрасываемый пользователь")] who: User, ) -> Result<(), Error> { let accounts = ctx.data().config.load_accounts(); @@ -52,10 +58,13 @@ pub async fn reset( Ok(()) } +/// Show scoreboard #[poise::command( prefix_command, slash_command, guild_only, + name_localized("ru", "счёт"), + description_localized("ru", "Отобразить таблицу лидеров"), )] pub async fn scoreboard( ctx: Context<'_>, @@ -98,6 +107,7 @@ pub async fn scoreboard( slash_command, guild_only, subcommands("give", "set"), + name_localized("ru", "баланс"), )] pub async fn balance( _ctx: Context<'_>, @@ -105,14 +115,23 @@ pub async fn balance( Ok(()) } +/// Give points to another user #[poise::command( prefix_command, slash_command, guild_only, + name_localized("ru", "передать"), + description_localized("ru", "Передать очки другому пользователю"), )] pub async fn give( ctx: Context<'_>, + #[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, ) -> Result<(), Error> { if ctx.author() == &who { @@ -153,15 +172,24 @@ pub async fn give( Ok(()) } +/// Set current user balance #[poise::command( prefix_command, slash_command, guild_only, required_permissions = "ADMINISTRATOR", + name_localized("ru", "установить"), + description_localized("ru", "Устанавливает текущий баланс пользователя"), )] pub async fn set( ctx: Context<'_>, + #[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, ) -> Result<(), Error> { let mut accounts = ctx.data().config.load_accounts(); diff --git a/discord/src/commands/answer.rs b/discord/src/commands/answer.rs index f01b9c7..1f6445e 100644 --- a/discord/src/commands/answer.rs +++ b/discord/src/commands/answer.rs @@ -3,22 +3,35 @@ use squad_quest::SquadObject; use crate::{Context, Error, account::fetch_or_init_account}; +/// Send an answer to the quest for review #[poise::command( prefix_command, slash_command, guild_only, + name_localized("ru", "ответить"), + description_localized("ru", "Отправить ответ на квест на проверку"), )] pub async fn answer( ctx: Context<'_>, #[description = "Identifier of the quest to answer to"] + #[name_localized("ru", "ид_квеста")] + #[description_localized("ru", "Идентификатор квеста для ответа")] quest_id: u16, #[description = "Text answer to the quest"] + #[name_localized("ru", "текст")] + #[description_localized("ru", "Текст ответа на квест")] text: Option, #[description = "Attachment answer to the quest"] + #[name_localized("ru", "файл1")] + #[description_localized("ru", "Вложение к ответу на квест")] file1: Option, #[description = "Attachment answer to the quest"] + #[name_localized("ru", "файл2")] + #[description_localized("ru", "Вложение к ответу на квест")] file2: Option, #[description = "Attachment answer to the quest"] + #[name_localized("ru", "файл3")] + #[description_localized("ru", "Вложение к ответу на квест")] file3: Option, ) -> Result<(), Error> { let mut account = fetch_or_init_account(&ctx.data().config, ctx.author().id.to_string()); diff --git a/discord/src/commands/init.rs b/discord/src/commands/init.rs index aac39a2..bfd7998 100644 --- a/discord/src/commands/init.rs +++ b/discord/src/commands/init.rs @@ -5,18 +5,24 @@ use squad_quest::SquadObject; use crate::{Context, Error}; - +/// Set channels to post quests and answers to #[poise::command( prefix_command, slash_command, required_permissions = "ADMINISTRATOR", guild_only, + name_localized("ru", "инит"), + description_localized("ru", "Установить каналы для публикации квестов и ответов"), )] pub async fn init( ctx: Context<'_>, #[description = "Channel to post quests to"] + #[name_localized("ru", "канал_квестов")] + #[description_localized("ru", "Канал для публикации квестов")] 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, ) -> Result<(), Error> { let dc = ctx.data().discord.clone(); diff --git a/discord/src/commands/map.rs b/discord/src/commands/map.rs index 38c6a0c..4313132 100644 --- a/discord/src/commands/map.rs +++ b/discord/src/commands/map.rs @@ -2,13 +2,19 @@ use squad_quest::{SquadObject, map::Map}; use crate::{Context, account::fetch_or_init_account, error::Error}; +/// Unlock specified room if it is reachable and you have required amount of points #[poise::command( prefix_command, slash_command, guild_only, + name_localized("ru", "открыть"), + description_localized("ru", "Открывает указанную комнату, если хватает очков и до нее можно добраться"), )] pub async fn unlock( ctx: Context<'_>, + #[description = "Room identifier"] + #[name_localized("ru", "идентификатор")] + #[description_localized("ru", "Идентификатор комнаты")] id: u16, ) -> Result<(), Error> { let conf = &ctx.data().config; @@ -44,13 +50,19 @@ pub async fn unlock( Ok(()) } +/// Move to another unlocked room #[poise::command( prefix_command, slash_command, guild_only, + name_localized("ru", "пойти"), + description_localized("ru", "Переместиться в другую разблокированную комнату"), )] pub async fn r#move( ctx: Context<'_>, + #[description = "Identifier of the room to move to"] + #[name_localized("ru", "идентификатор")] + #[description_localized("ru", "Идентификатор комнаты, куда переместиться")] id: u16, ) -> Result<(), Error> { let conf = &ctx.data().config; diff --git a/discord/src/commands/mod.rs b/discord/src/commands/mod.rs index bc59244..4a0ba13 100644 --- a/discord/src/commands/mod.rs +++ b/discord/src/commands/mod.rs @@ -16,9 +16,12 @@ pub async fn register(ctx: Context<'_>) -> Result<(), Error> { Ok(()) } +/// Show bot info, such as version and link to web map #[poise::command( prefix_command, slash_command, + name_localized("ru", "инфо"), + description_localized("ru", "Получить информацию о боте и ссылку на веб карту"), )] pub async fn info(ctx: Context<'_>) -> Result<(), Error> { let strings = &ctx.data().strings; diff --git a/discord/src/commands/quest.rs b/discord/src/commands/quest.rs index fd6c8b6..ad61e1a 100644 --- a/discord/src/commands/quest.rs +++ b/discord/src/commands/quest.rs @@ -36,6 +36,7 @@ fn make_quest_message_content(ctx: Context<'_>, quest: &Quest) -> String { guild_only, subcommands("list", "create", "update", "publish", "delete"), required_permissions = "ADMINISTRATOR", + name_localized("ru", "квесты"), )] pub async fn quest( _ctx: Context<'_>, @@ -43,11 +44,14 @@ pub async fn quest( Ok(()) } +/// List all quests #[poise::command( prefix_command, slash_command, guild_only, required_permissions = "ADMINISTRATOR", + name_localized("ru", "список"), + description_localized("ru", "Вывести все квесты") )] pub async fn list( ctx: Context<'_>, @@ -73,7 +77,6 @@ pub enum DifficultyWrapper { Secret, } - impl From for QuestDifficulty { fn from(value: DifficultyWrapper) -> Self { match &value { @@ -105,28 +108,47 @@ impl From for Date { } } +/// Create quest and print its identifier #[poise::command( prefix_command, slash_command, required_permissions = "ADMINISTRATOR", guild_only, + name_localized("ru", "создать"), + description_localized("ru", "Создать квест и получить его идентификатор"), )] pub async fn create( ctx: Context<'_>, #[description = "Quest difficulty"] + #[name_localized("ru", "сложность")] + #[description_localized("ru", "Сложность квеста")] difficulty: DifficultyWrapper, #[description = "Reward for the quest"] + #[name_localized("ru", "награда")] + #[description_localized("ru", "Награда за квест")] reward: u32, #[description = "Quest name"] + #[name_localized("ru", "название")] + #[description_localized("ru", "Название квеста")] name: String, #[description = "Quest description"] + #[name_localized("ru", "описание")] + #[description_localized("ru", "Описание квеста")] 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, - #[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, - #[description = "Optional deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"] + /* + #[description = "Deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"] + #[name_localized("ru", "дедлайн")] + #[description_localized("ru", "Дедлайн (в формате ГГГГ-ММ-ДД), напр. 2025-12-24")] deadline: Option, + */ ) -> Result<(), Error> { let conf = &ctx.data().config; let mut quests = conf.load_quests(); @@ -141,10 +163,12 @@ pub async fn create( None => None, }; + /* let deadline = match deadline { Some(dl) => Some(dl.into()), None => None, }; + */ let quest = Quest { id: next_id, @@ -155,7 +179,8 @@ pub async fn create( answer, public: false, available_on, - deadline, + //deadline, + ..Default::default() }; let path = conf.full_quests_path(); @@ -171,33 +196,57 @@ pub async fn create( Ok(()) } +/// Update quest values by its identifier and new given values #[poise::command( prefix_command, slash_command, required_permissions = "ADMINISTRATOR", guild_only, + name_localized("ru", "обновить"), + description_localized("ru", "Обновить выбранные значения указанного квеста"), )] pub async fn update( ctx: Context<'_>, #[description = "Quest identifier"] + #[name_localized("ru", "идентификатор")] + #[description_localized("ru", "Идентификатор квеста")] id: u16, #[description = "Quest difficulty"] + #[name_localized("ru", "сложность")] + #[description_localized("ru", "Сложность квеста")] difficulty: Option, #[description = "Reward for the quest"] + #[name_localized("ru", "награда")] + #[description_localized("ru", "Награда за квест")] reward: Option, #[description = "Quest name"] + #[name_localized("ru", "название")] + #[description_localized("ru", "Название квеста")] name: Option, #[description = "Quest description"] + #[name_localized("ru", "описание")] + #[description_localized("ru", "Описание квеста")] description: Option, - #[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, #[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, + /* #[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, - #[description = "Clear availability and deadline if checked"] - #[rename = "override"] - should_override: Option, + #[description = "Reset availability and deadline if checked"] + #[description_localized("ru", "Если выбрано, сбросить доступность и дедлайн")] + */ + #[description = "Reset availability if checked"] + #[description_localized("ru", "Если выбрано, сбросить доступность")] + #[name_localized("ru", "сброс")] + reset: Option, ) -> Result<(), Error> { let conf = &ctx.data().config; let quests = conf.load_quests(); @@ -211,16 +260,16 @@ pub async fn update( }; let available_on: Option; - let dead_line: Option; + //let dead_line: Option; - match should_override.unwrap_or(false) { + match reset.unwrap_or(false) { true => { - available_on = available.map(|v| v.into()); - dead_line = deadline.map(|v| v.into()); + available_on = None; + //dead_line = None; }, false => { 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())); + //dead_line = deadline.map_or_else(|| quest.deadline.clone(), |v| Some(v.into())); }, } @@ -233,7 +282,8 @@ pub async fn update( answer: answer.unwrap_or(quest.answer.clone()), public: quest.public, available_on, - deadline: dead_line, + //deadline: dead_line, + ..Default::default() }; let strings = &ctx.data().strings; @@ -260,15 +310,20 @@ pub async fn update( Ok(()) } +/// Mark quest as public and send its message in quests channel #[poise::command( prefix_command, slash_command, required_permissions = "ADMINISTRATOR", - guild_only + guild_only, + name_localized("ru", "опубликовать"), + description_localized("ru", "Отметить квест как публичный и отправить его сообщение в канал квестов"), )] pub async fn publish( ctx: Context<'_>, - #[description = "Identifier of the quest to publish"] + #[description = "Quest identifier"] + #[name_localized("ru", "идентификатор")] + #[description_localized("ru", "Идентификатор квеста")] id: u16, ) -> Result<(), Error> { let mut quests = ctx.data().config.load_quests(); @@ -308,14 +363,20 @@ pub async fn publish( Ok(()) } +/// Delete quest (and its message, if published) #[poise::command( prefix_command, slash_command, required_permissions = "ADMINISTRATOR", guild_only, + name_localized("ru", "удалить"), + description_localized("ru", "Удалить квест (и его сообщение, если он был опубликован)"), )] pub async fn delete( ctx: Context<'_>, + #[description = "Quest identifier"] + #[name_localized("ru", "идентификатор")] + #[description_localized("ru", "Идентификатор квеста")] id: u16, ) -> Result<(), Error> { if let Some(msg) = find_quest_message(ctx, id).await? { diff --git a/discord/src/commands/social.rs b/discord/src/commands/social.rs index 33cb2d5..d2f22cb 100644 --- a/discord/src/commands/social.rs +++ b/discord/src/commands/social.rs @@ -8,22 +8,38 @@ use crate::{Context, Error}; required_permissions = "ADMINISTRATOR", guild_only, subcommands("msg", "edit", "undo"), + name_localized("ru", "сообщение"), )] pub async fn social( _ctx: Context<'_> ) -> Result<(), Error> { Ok(()) } +/// Send message to channel or user #[poise::command( prefix_command, slash_command, required_permissions = "ADMINISTRATOR", guild_only, + name_localized("ru", "написать"), + description_localized("ru", "Отправить сообщение пользователю или в канал"), )] pub async fn msg ( ctx: Context<'_>, + #[description = "Channel to message to"] + #[name_localized("ru", "канал")] + #[description_localized("ru", "Канал, в который отправится сообщение")] channel: Option, + #[description = "User to message to"] + #[name_localized("ru", "пользователь")] + #[description_localized("ru", "Пользователь, которому отправится сообщение")] user: Option, + #[description = "Message text"] + #[name_localized("ru", "содержание")] + #[description_localized("ru", "Текст сообщения")] content: Option, + #[description = "Message attachment"] + #[name_localized("ru", "файл")] + #[description_localized("ru", "Вложение к сообщению")] file: Option, ) -> Result<(), Error> { @@ -76,19 +92,37 @@ pub async fn msg ( Ok(()) } +/// Edit sent channel or DM message #[poise::command( prefix_command, slash_command, required_permissions = "ADMINISTRATOR", guild_only, + name_localized("ru", "редактировать"), + description_localized("ru", "Редактировать сообщение в канале или в ЛС"), )] pub async fn edit ( ctx: Context<'_>, + #[description = "Identifier of the message to edit"] + #[name_localized("ru", "сообщение")] + #[description_localized("ru", "Идентификатор редактируемого сообщения")] #[rename = "message"] message_id: MessageId, + #[description = "Channel where the message is"] + #[name_localized("ru", "канал")] + #[description_localized("ru", "Канал, где находится сообщение")] channel: Option, + #[description = "User, who received DM"] + #[name_localized("ru", "пользователь")] + #[description_localized("ru", "Пользователь, получивший ЛС")] user: Option, + #[description = "New message text"] + #[name_localized("ru", "содержание")] + #[description_localized("ru", "Новый текст сообщения")] content: Option, + #[description = "New file (overrides existing if specified)"] + #[name_localized("ru", "файл")] + #[description_localized("ru", "Новое вложение (заменит предыдущее если указано)")] file: Option, ) -> Result<(), Error> { if channel.is_none() && user.is_none() { @@ -137,17 +171,29 @@ pub async fn edit ( Ok(()) } +/// Delete message in channel or DM #[poise::command( prefix_command, slash_command, required_permissions = "ADMINISTRATOR", guild_only, + name_localized("ru", "удалить"), + description_localized("ru", "Удалить сообщение в канале или в ЛС"), )] pub async fn undo( ctx: Context<'_>, + #[description = "Identifier of the message to delete"] + #[name_localized("ru", "сообщение")] + #[description_localized("ru", "Идентификатор удаляемого сообщения")] #[rename = "message"] message_id: MessageId, + #[description = "Channel where the message is"] + #[name_localized("ru", "канал")] + #[description_localized("ru", "Канал, где находится сообщение")] channel: Option, + #[description = "User, who received DM"] + #[name_localized("ru", "пользователь")] + #[description_localized("ru", "Пользователь, получивший ЛС")] user: Option, ) -> Result<(), Error> { diff --git a/discord/src/main.rs b/discord/src/main.rs index 70b6625..f65b4fc 100644 --- a/discord/src/main.rs +++ b/discord/src/main.rs @@ -43,8 +43,8 @@ async fn main() { .options(poise::FrameworkOptions { on_error: |err| Box::pin(error_handler(err)), commands: vec![ + //commands::register(), commands::quest::quest(), - commands::register(), commands::info(), commands::init::init(), commands::answer::answer(), @@ -57,9 +57,9 @@ async fn main() { ], ..Default::default() }) - .setup(|ctx, _ready, framework| { + .setup(|ctx, _ready, _framework| { Box::pin(async move { - poise::builtins::register_globally(ctx, &framework.options().commands).await?; + //poise::builtins::register_globally(ctx, &framework.options().commands).await?; Ok(Data { config, discord: Arc::new(Mutex::new(discord)), From cc916c06ce5ae2a1b4350045000db759e2ad38f6 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Thu, 18 Dec 2025 15:58:18 +0300 Subject: [PATCH 04/13] feat: Implemented daily timer - Bump version to 0.10.0 - Added /timer command --- Cargo.lock | 7 ++-- Cargo.toml | 2 +- cli/Cargo.toml | 2 +- discord/Cargo.toml | 3 +- discord/src/commands/init.rs | 67 ++++++++++++++++++++++++++++++++--- discord/src/commands/mod.rs | 2 +- discord/src/commands/quest.rs | 39 +++++++++++--------- discord/src/error.rs | 5 ++- discord/src/main.rs | 34 ++++++++++++++++-- discord/src/strings.rs | 2 ++ discord/src/timer.rs | 56 +++++++++++++++++++++++++++++ 11 files changed, 187 insertions(+), 32 deletions(-) create mode 100644 discord/src/timer.rs diff --git a/Cargo.lock b/Cargo.lock index aea1abb..b846c1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1599,7 +1599,7 @@ dependencies = [ [[package]] name = "squad-quest" -version = "0.9.0" +version = "0.10.0" dependencies = [ "serde", "toml", @@ -1607,7 +1607,7 @@ dependencies = [ [[package]] name = "squad-quest-cli" -version = "0.9.0" +version = "0.10.0" dependencies = [ "chrono", "clap", @@ -1618,8 +1618,9 @@ dependencies = [ [[package]] name = "squad-quest-discord" -version = "0.9.0" +version = "0.10.0" dependencies = [ + "chrono", "clap", "dotenvy", "poise", diff --git a/Cargo.toml b/Cargo.toml index bb709d8..5f06182 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = ["cli", "discord"] [workspace.package] -version = "0.9.0" +version = "0.10.0" edition = "2024" repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest" license = "MIT" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 2006986..1db73ec 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -9,5 +9,5 @@ license.workspace = true chrono = "0.4.42" clap = { version = "4.5.53", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] } -squad-quest = { version = "0.9.0", path = ".." } +squad-quest = { version = "0.10.0", path = ".." } toml = "0.9.8" diff --git a/discord/Cargo.toml b/discord/Cargo.toml index 399606a..6853c25 100644 --- a/discord/Cargo.toml +++ b/discord/Cargo.toml @@ -6,10 +6,11 @@ repository.workspace = true license.workspace = true [dependencies] +chrono = "0.4.42" clap = { version = "4.5.53", features = ["derive"] } dotenvy = "0.15.7" poise = "0.6.1" serde = "1.0.228" -squad-quest = { version = "0.9.0", path = ".." } +squad-quest = { version = "0.10.0", path = ".." } tokio = { version = "1.48.0", features = ["rt-multi-thread"] } toml = "0.9.8" diff --git a/discord/src/commands/init.rs b/discord/src/commands/init.rs index bfd7998..e85d79a 100644 --- a/discord/src/commands/init.rs +++ b/discord/src/commands/init.rs @@ -1,9 +1,10 @@ -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 toml::value::Time; -use crate::{Context, Error}; +use crate::{Context, Error, timer::DailyTimer}; /// Set channels to post quests and answers to #[poise::command( @@ -25,7 +26,7 @@ pub async fn init( #[description_localized("ru", "Канал для публикации ответов на проверку")] answers_channel: ChannelId, ) -> Result<(), Error> { - let dc = ctx.data().discord.clone(); +let dc = ctx.data().discord.clone(); { let mut guard = dc.lock().expect("shouldn't be locked"); let guild = ctx.guild_id().unwrap(); @@ -43,3 +44,61 @@ pub async fn init( Ok(()) } + +#[derive(serde::Deserialize)] +struct TimeWrapper { + time: Time, +} + +impl FromStr for TimeWrapper { + type Err = toml::de::Error; + fn from_str(s: &str) -> Result { + let toml_str = format!("time = {s}"); + let wrapper: Self = toml::from_str(&toml_str)?; + Ok(wrapper) + } +} + +impl From 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, + name_localized("ru", "таймер"), + description_localized("ru", "Включить таймер публикации по заданной временной метке UTC (МСК-3)"), +)] +pub async fn timer( + ctx: Context<'_>, + 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(()) +} diff --git a/discord/src/commands/mod.rs b/discord/src/commands/mod.rs index 4a0ba13..85d3ee8 100644 --- a/discord/src/commands/mod.rs +++ b/discord/src/commands/mod.rs @@ -46,7 +46,7 @@ pub async fn error_handler(error: poise::FrameworkError<'_, Data, Error>) { } } -fn print_error_recursively(error: &impl StdError) { +pub fn print_error_recursively(error: &impl StdError) { eprintln!("{error}"); if let Some(source) = error.source() { eprintln!("source:"); diff --git a/discord/src/commands/quest.rs b/discord/src/commands/quest.rs index ad61e1a..bf90fdd 100644 --- a/discord/src/commands/quest.rs +++ b/discord/src/commands/quest.rs @@ -310,6 +310,27 @@ pub async fn update( Ok(()) } +pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<(), Error> { + quest.public = true; + + let quests_path = ctx.data().config.full_quests_path(); + quest.save(quests_path)?; + + let content = make_quest_message_content(ctx, &quest); + + 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 + }; + + channel.send_message(ctx, builder).await?; + Ok(()) +} + /// Mark quest as public and send its message in quests channel #[poise::command( prefix_command, @@ -335,24 +356,8 @@ pub async fn publish( if quest.public { return Err(Error::QuestIsPublic(id)); } - - quest.public = true; - let content = make_quest_message_content(ctx, &quest); - - 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 - }; - - channel.send_message(ctx, builder).await?; - - let quests_path = ctx.data().config.full_quests_path(); - quest.save(quests_path)?; + publish_inner(ctx, quest).await?; let strings = &ctx.data().strings; let formatter = strings.formatter().quest(&quest); diff --git a/discord/src/error.rs b/discord/src/error.rs index e5f57c8..8d8b442 100644 --- a/discord/src/error.rs +++ b/discord/src/error.rs @@ -20,6 +20,7 @@ pub enum Error { RoomNotFound(u16), RoomAlreadyUnlocked(u16), CannotReach(u16), + TimerSet, } impl From for Error { @@ -63,6 +64,7 @@ impl Display for Error { Self::RoomNotFound(id) => write!(f, "room #{id} not found"), Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"), Self::CannotReach(id) => write!(f, "user cannot reach room #{id}"), + Self::TimerSet => write!(f, "timer is already set"), } } } @@ -81,7 +83,8 @@ impl std::error::Error for Error { Self::InsufficientFunds(_) | Self::RoomNotFound(_) | Self::RoomAlreadyUnlocked(_) | - Self::CannotReach(_) => None, + Self::CannotReach(_) | + Self::TimerSet => None, Self::SerenityError(error) => Some(error), Self::SquadQuestError(error) => Some(error), } diff --git a/discord/src/main.rs b/discord/src/main.rs index f65b4fc..cfb68c7 100644 --- a/discord/src/main.rs +++ b/discord/src/main.rs @@ -1,4 +1,4 @@ -use std::sync::{Arc, Mutex}; +use std::{sync::{Arc, Mutex}}; use clap::Parser; use dotenvy::dotenv; @@ -13,16 +13,42 @@ mod config; mod account; mod error; mod strings; +mod timer; const CONFIG_PATH: &str = "cfg/config.toml"; const DISCORD_TOKEN: &str = "DISCORD_TOKEN"; +#[derive(Debug)] +struct InnerBool { + pub value: bool, +} + #[derive(Debug)] struct Data { pub config: Config, pub discord: Arc>, pub strings: Strings, + pub timer_set: Arc>, } + +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>; #[tokio::main] @@ -43,10 +69,11 @@ async fn main() { .options(poise::FrameworkOptions { on_error: |err| Box::pin(error_handler(err)), commands: vec![ - //commands::register(), + commands::register(), commands::quest::quest(), commands::info(), commands::init::init(), + commands::init::timer(), commands::answer::answer(), commands::social::social(), commands::account::scoreboard(), @@ -57,12 +84,13 @@ async fn main() { ], ..Default::default() }) - .setup(|ctx, _ready, _framework| { + .setup(|_ctx, _ready, _framework| { Box::pin(async move { //poise::builtins::register_globally(ctx, &framework.options().commands).await?; Ok(Data { config, discord: Arc::new(Mutex::new(discord)), + timer_set: Arc::new(Mutex::new(InnerBool { value: false })), strings, }) }) diff --git a/discord/src/strings.rs b/discord/src/strings.rs index 711afc9..a3d485f 100644 --- a/discord/src/strings.rs +++ b/discord/src/strings.rs @@ -125,6 +125,7 @@ pub struct Strings { pub points: String, pub info: String, pub init_reply: String, + pub timer_reply: String, pub account: AccountReplies, pub answer: Answer, pub difficulty: Difficulty, @@ -142,6 +143,7 @@ impl Default for Strings { 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(), diff --git a/discord/src/timer.rs b/discord/src/timer.rs new file mode 100644 index 0000000..3930492 --- /dev/null +++ b/discord/src/timer.rs @@ -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); + } + } + } + } +} From 46af205aefbf706cfd1b6aef40c617f28a2ecd54 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Fri, 19 Dec 2025 14:17:02 +0300 Subject: [PATCH 05/13] style: Fixed several minor things in text - Changed error in /move to CannotReach instead of RoomNotFound --- discord/src/commands/account.rs | 2 +- discord/src/commands/init.rs | 5 ++++- discord/src/commands/map.rs | 2 +- discord/src/commands/quest.rs | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/discord/src/commands/account.rs b/discord/src/commands/account.rs index 5f2896e..d47b906 100644 --- a/discord/src/commands/account.rs +++ b/discord/src/commands/account.rs @@ -63,7 +63,7 @@ pub async fn reset( prefix_command, slash_command, guild_only, - name_localized("ru", "счёт"), + name_localized("ru", "счет"), description_localized("ru", "Отобразить таблицу лидеров"), )] pub async fn scoreboard( diff --git a/discord/src/commands/init.rs b/discord/src/commands/init.rs index e85d79a..fb6e6c3 100644 --- a/discord/src/commands/init.rs +++ b/discord/src/commands/init.rs @@ -76,10 +76,13 @@ fn seconds(time: Time) -> u64 { required_permissions = "ADMINISTRATOR", guild_only, name_localized("ru", "таймер"), - description_localized("ru", "Включить таймер публикации по заданной временной метке UTC (МСК-3)"), + 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() { diff --git a/discord/src/commands/map.rs b/discord/src/commands/map.rs index 4313132..96bb30d 100644 --- a/discord/src/commands/map.rs +++ b/discord/src/commands/map.rs @@ -71,7 +71,7 @@ pub async fn r#move( let mut account = fetch_or_init_account(conf, acc_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; diff --git a/discord/src/commands/quest.rs b/discord/src/commands/quest.rs index bf90fdd..ee6089d 100644 --- a/discord/src/commands/quest.rs +++ b/discord/src/commands/quest.rs @@ -36,7 +36,7 @@ fn make_quest_message_content(ctx: Context<'_>, quest: &Quest) -> String { guild_only, subcommands("list", "create", "update", "publish", "delete"), required_permissions = "ADMINISTRATOR", - name_localized("ru", "квесты"), + name_localized("ru", "квест"), )] pub async fn quest( _ctx: Context<'_>, From 9d1261b74d2ddaa442d33b4a90b4b2c745f195f5 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Fri, 19 Dec 2025 16:22:02 +0300 Subject: [PATCH 06/13] build: Preparing stuff to create debian package - Added deb binary target to generate incomplete control file - Added CLI init option to insert impl_path in config --- Cargo.toml | 2 ++ cli/src/cli/mod.rs | 2 ++ cli/src/main.rs | 1 + src/bin/deb.rs | 24 ++++++++++++++++++++++++ 4 files changed, 29 insertions(+) create mode 100644 src/bin/deb.rs diff --git a/Cargo.toml b/Cargo.toml index 5f06182..e55b3ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = ["cli", "discord"] version = "0.10.0" edition = "2024" repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest" +homepage = "https://2ndbeam.ru/git/2ndbeam/squad-quest" license = "MIT" [package] @@ -13,6 +14,7 @@ edition.workspace = true version.workspace = true repository.workspace = true license.workspace = true +homepage.workspace = true [dependencies] serde = { version = "1.0.228", features = ["derive"] } diff --git a/cli/src/cli/mod.rs b/cli/src/cli/mod.rs index db78d60..5ff05fa 100644 --- a/cli/src/cli/mod.rs +++ b/cli/src/cli/mod.rs @@ -40,4 +40,6 @@ pub enum Objects { pub struct InitArgs { #[arg(long,short)] pub path: Option, + #[arg(long,short)] + pub implpath: Option, } diff --git a/cli/src/main.rs b/cli/src/main.rs index d120a9d..c1c75d5 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -71,6 +71,7 @@ fn main() { let config = Config { path: path.clone(), + impl_path: args.implpath.clone(), ..Default::default() }; diff --git a/src/bin/deb.rs b/src/bin/deb.rs new file mode 100644 index 0000000..b4938d7 --- /dev/null +++ b/src/bin/deb.rs @@ -0,0 +1,24 @@ +//! 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\ + Maintainer: Alexey Mirenkov <2ndbeam@disroot.org>"); +} From 66cbd2301369ae698e0b2c7128e52c5d8562e784 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Fri, 19 Dec 2025 16:58:50 +0300 Subject: [PATCH 07/13] style: Changed name in license --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 4eee171..4e5488f 100644 --- a/LICENSE +++ b/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: From 0ab777d898edb986f6a9d68c9cbeca989f18bb4d Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Fri, 19 Dec 2025 17:15:13 +0300 Subject: [PATCH 08/13] build: Added unfinished build-deb.sh --- build-deb.sh | 23 +++++++++++++++++++++++ src/bin/deb.rs | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100755 build-deb.sh diff --git a/build-deb.sh b/build-deb.sh new file mode 100755 index 0000000..2c5b7a9 --- /dev/null +++ b/build-deb.sh @@ -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 diff --git a/src/bin/deb.rs b/src/bin/deb.rs index b4938d7..281d003 100644 --- a/src/bin/deb.rs +++ b/src/bin/deb.rs @@ -19,6 +19,7 @@ fn main() { Section: misc\n\ Priority: optional\n\ Homepage: {homepage}\n\ - Description: Simple RPG-like system for hosting events\n\ + Description: Simple RPG-like system for hosting events\n \ + Includes discord bot and CLI\n\ Maintainer: Alexey Mirenkov <2ndbeam@disroot.org>"); } From 81a9ec0c50468cd6cdd4192323b6303bf7dd32d6 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Sun, 21 Dec 2025 11:08:08 +0300 Subject: [PATCH 09/13] feat: Added message context to strings.quest.publish --- discord/src/commands/quest.rs | 14 +++++++++----- discord/src/strings.rs | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/discord/src/commands/quest.rs b/discord/src/commands/quest.rs index ee6089d..1d73544 100644 --- a/discord/src/commands/quest.rs +++ b/discord/src/commands/quest.rs @@ -310,7 +310,7 @@ pub async fn update( Ok(()) } -pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<(), Error> { +pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result { quest.public = true; let quests_path = ctx.data().config.full_quests_path(); @@ -327,8 +327,10 @@ pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<(), Er guard.quests_channel }; - channel.send_message(ctx, builder).await?; - Ok(()) + 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 @@ -357,10 +359,12 @@ pub async fn publish( return Err(Error::QuestIsPublic(id)); } - publish_inner(ctx, quest).await?; + let message = publish_inner(ctx, quest).await?; let strings = &ctx.data().strings; - let formatter = strings.formatter().quest(&quest); + let formatter = strings.formatter() + .quest(&quest) + .message(&message); let reply_string = formatter.fmt(&strings.quest.publish); ctx.reply(reply_string).await?; diff --git a/discord/src/strings.rs b/discord/src/strings.rs index a3d485f..afa2ac8 100644 --- a/discord/src/strings.rs +++ b/discord/src/strings.rs @@ -328,7 +328,7 @@ impl Default for QuestStrings { 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}: {text}".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}\ From c22787792d6dc59307f1e7c0117a06808d57fff4 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Wed, 24 Dec 2025 14:30:40 +0300 Subject: [PATCH 10/13] feat: Added API for web map in discord bot - Bump version to 0.11.0 - Added data table to quests, accounts and rooms - Discord bot now adds "avatar" and "name" data to accounts on init - Added CLI "map data" command --- Cargo.lock | 1152 +++++++++++++++++++++++++------ Cargo.toml | 2 +- cli/Cargo.toml | 2 +- cli/src/cli/map.rs | 8 + cli/src/main.rs | 15 +- discord/Cargo.toml | 4 +- discord/Rocket.toml | 8 + discord/src/account.rs | 16 +- discord/src/api.rs | 119 ++++ discord/src/commands/account.rs | 3 +- discord/src/commands/answer.rs | 2 +- discord/src/commands/map.rs | 4 +- discord/src/main.rs | 15 +- src/account/mod.rs | 6 +- src/config/mod.rs | 2 +- src/map/mod.rs | 7 +- src/quest/mod.rs | 10 +- tests/main.rs | 10 +- 18 files changed, 1161 insertions(+), 224 deletions(-) create mode 100644 discord/Rocket.toml create mode 100644 discord/src/api.rs diff --git a/Cargo.lock b/Cargo.lock index b846c1b..388a656 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,28 @@ dependencies = [ "serde", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -96,18 +118,33 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -115,10 +152,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "bitflags" -version = "1.3.2" +name = "binascii" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" [[package]] name = "bitflags" @@ -137,9 +174,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytecount" @@ -147,6 +184,12 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + [[package]] name = "byteorder" version = "1.5.0" @@ -161,9 +204,9 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "camino" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ "serde_core", ] @@ -192,9 +235,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.48" +version = "1.2.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" dependencies = [ "find-msvc-tools", "shlex", @@ -206,6 +249,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -267,13 +316,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] -name = "core-foundation" -version = "0.9.4" +name = "cookie" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ - "core-foundation-sys", - "libc", + "percent-encoding", + "time", + "version_check", ] [[package]] @@ -401,6 +451,39 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "devise" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d90b0c4c777a2cad215e3c7be59ac7c15adf45cf76317009b7d096d46f651d" +dependencies = [ + "devise_codegen", + "devise_core", +] + +[[package]] +name = "devise_codegen" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71b28680d8be17a570a2334922518be6adc3f58ecc880cbb404eaeb8624fd867" +dependencies = [ + "devise_core", + "quote", +] + +[[package]] +name = "devise_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" +dependencies = [ + "bitflags", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.111", +] + [[package]] name = "digest" version = "0.10.7" @@ -428,6 +511,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -468,6 +557,20 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic 0.6.1", + "pear", + "serde", + "toml 0.8.23", + "uncased", + "version_check", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -577,12 +680,16 @@ dependencies = [ ] [[package]] -name = "fxhash" -version = "0.2.1" +name = "generator" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" dependencies = [ - "byteorder", + "cc", + "libc", + "log", + "rustversion", + "windows", ] [[package]] @@ -602,8 +709,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -613,9 +722,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -661,6 +772,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "http" version = "0.2.12" @@ -693,6 +810,29 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" @@ -717,7 +857,7 @@ dependencies = [ "futures-util", "h2", "http 0.2.12", - "http-body", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -730,17 +870,65 @@ dependencies = [ ] [[package]] -name = "hyper-rustls" -version = "0.24.2" +name = "hyper" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ - "futures-util", - "http 0.2.12", - "hyper", - "rustls 0.21.12", + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", "tokio", - "tokio-rustls 0.24.1", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls 0.23.35", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots 1.0.4", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.1", + "tokio", + "tower-service", + "tracing", ] [[package]] @@ -815,9 +1003,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -829,9 +1017,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -883,14 +1071,43 @@ checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + [[package]] name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -899,9 +1116,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" [[package]] name = "js-sys" @@ -914,10 +1131,16 @@ dependencies = [ ] [[package]] -name = "libc" -version = "0.2.177" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "linux-raw-sys" @@ -942,9 +1165,39 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] [[package]] name = "memchr" @@ -995,15 +1248,43 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.4.0", + "httparse", + "memchr", + "mime", + "spin", + "tokio", + "tokio-util", + "version_check", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1019,6 +1300,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1054,6 +1345,29 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.111", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1134,17 +1448,85 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", + "version_check", + "yansi", +] + [[package]] name = "pulldown-cmark" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" dependencies = [ - "bitflags 2.10.0", + "bitflags", "memchr", "unicase", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.35", + "socket2 0.6.1", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.35", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.1", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.42" @@ -1167,8 +1549,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -1178,7 +1570,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -1190,13 +1592,42 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", ] [[package]] @@ -1230,46 +1661,44 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.11.27" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.21.7", + "base64", "bytes", - "encoding_rs", "futures-core", "futures-util", - "h2", - "http 0.2.12", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", "hyper-rustls", - "ipnet", + "hyper-util", "js-sys", "log", - "mime", "mime_guess", - "once_cell", "percent-encoding", "pin-project-lite", - "rustls 0.21.12", - "rustls-pemfile", + "quinn", + "rustls 0.23.35", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", - "system-configuration", "tokio", - "tokio-rustls 0.24.1", + "tokio-rustls 0.26.4", "tokio-util", + "tower", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 0.25.4", - "winreg", + "webpki-roots 1.0.4", ] [[package]] @@ -1287,30 +1716,106 @@ dependencies = [ ] [[package]] -name = "rustix" -version = "1.1.2" +name = "rocket" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "a516907296a31df7dc04310e7043b61d71954d703b603cc6867a026d7e72d73f" dependencies = [ - "bitflags 2.10.0", + "async-stream", + "async-trait", + "atomic 0.5.3", + "binascii", + "bytes", + "either", + "figment", + "futures", + "indexmap", + "log", + "memchr", + "multer", + "num_cpus", + "parking_lot", + "pin-project-lite", + "rand 0.8.5", + "ref-cast", + "rocket_codegen", + "rocket_http", + "serde", + "serde_json", + "state", + "tempfile", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "ubyte", + "version_check", + "yansi", +] + +[[package]] +name = "rocket_codegen" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" +dependencies = [ + "devise", + "glob", + "indexmap", + "proc-macro2", + "quote", + "rocket_http", + "syn 2.0.111", + "unicode-xid", + "version_check", +] + +[[package]] +name = "rocket_http" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e274915a20ee3065f611c044bd63c40757396b6dbc057d6046aec27f14f882b9" +dependencies = [ + "cookie", + "either", + "futures", + "http 0.2.12", + "hyper 0.14.32", + "indexmap", + "log", + "memchr", + "pear", + "percent-encoding", + "pin-project-lite", + "ref-cast", + "serde", + "smallvec", + "stable-pattern", + "state", + "time", + "tokio", + "uncased", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", "errno", "libc", "linux-raw-sys", "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.22.4" @@ -1326,31 +1831,27 @@ dependencies = [ ] [[package]] -name = "rustls-pemfile" -version = "1.0.4" +name = "rustls" +version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - -[[package]] -name = "rustls-pki-types" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.8", + "subtle", "zeroize", ] [[package]] -name = "rustls-webpki" -version = "0.101.7" +name = "rustls-pki-types" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ - "ring", - "untrusted", + "web-time", + "zeroize", ] [[package]] @@ -1364,6 +1865,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1372,9 +1884,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" [[package]] name = "same-file" @@ -1385,22 +1897,18 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "secrecy" version = "0.8.0" @@ -1462,9 +1970,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.146" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "217ca874ae0207aac254aa02c957ded05585a90892cc8d87f9e5fa49669dadd8" dependencies = [ "itoa", "memchr", @@ -1475,9 +1983,18 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.3" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] @@ -1496,24 +2013,24 @@ dependencies = [ [[package]] name = "serenity" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d72ec4323681bf9a3cabe40fd080abc2435859b502a1b5aa9bf693f125bfa76" +checksum = "9bde37f42765dfdc34e2a039e0c84afbf79a3101c1941763b0beb816c2f17541" dependencies = [ "arrayvec", "async-trait", - "base64 0.22.1", - "bitflags 2.10.0", + "base64", + "bitflags", "bytes", "chrono", "dashmap", "flate2", "futures", - "fxhash", "mime_guess", "parking_lot", "percent-encoding", "reqwest", + "rustc-hash", "secrecy", "serde", "serde_cow", @@ -1538,6 +2055,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1545,10 +2071,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "simd-adler32" -version = "0.3.7" +name = "signal-hook-registry" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "skeptic" @@ -1597,37 +2132,54 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "squad-quest" -version = "0.10.0" +version = "0.11.0" dependencies = [ "serde", - "toml", + "toml 0.9.10+spec-1.1.0", ] [[package]] name = "squad-quest-cli" -version = "0.10.0" +version = "0.11.0" dependencies = [ "chrono", "clap", "serde", "squad-quest", - "toml", + "toml 0.9.10+spec-1.1.0", ] [[package]] name = "squad-quest-discord" -version = "0.10.0" +version = "0.11.0" dependencies = [ "chrono", "clap", "dotenvy", "poise", + "rocket", "serde", + "serde_json", "squad-quest", "tokio", - "toml", + "toml 0.9.10+spec-1.1.0", +] + +[[package]] +name = "stable-pattern" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4564168c00635f88eaed410d5efa8131afa8d8699a612c80c455a0ba05c21045" +dependencies = [ + "memchr", ] [[package]] @@ -1636,6 +2188,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "state" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8c4a4445d81357df8b1a650d0d0d6fbbbfe99d064aa5e02f3e4022061476d8" +dependencies = [ + "loom", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1672,9 +2233,12 @@ dependencies = [ [[package]] name = "sync_wrapper" -version = "0.1.2" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1687,27 +2251,6 @@ dependencies = [ "syn 2.0.111", ] -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tagptr" version = "0.2.0" @@ -1733,7 +2276,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", ] [[package]] @@ -1747,6 +2299,26 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.44" @@ -1788,6 +2360,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.48.0" @@ -1798,6 +2385,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2 0.6.1", "tokio-macros", "windows-sys 0.61.2", @@ -1814,16 +2402,6 @@ dependencies = [ "syn 2.0.111", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.25.0" @@ -1835,6 +2413,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.35", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.21.0" @@ -1866,14 +2465,26 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.8" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + +[[package]] +name = "toml" +version = "0.9.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow", @@ -1881,27 +2492,95 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] [[package]] -name = "toml_parser" -version = "1.0.4" +name = "toml_edit" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow", ] [[package]] -name = "toml_writer" -version = "1.0.4" +name = "toml_write" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" @@ -1911,9 +2590,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -1934,11 +2613,41 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1965,11 +2674,11 @@ dependencies = [ "http 1.4.0", "httparse", "log", - "rand", + "rand 0.8.5", "rustls 0.22.4", "rustls-pki-types", "sha1", - "thiserror", + "thiserror 1.0.69", "url", "utf-8", ] @@ -2015,6 +2724,25 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "ubyte" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" +dependencies = [ + "serde", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "serde", + "version_check", +] + [[package]] name = "unicase" version = "2.8.1" @@ -2027,6 +2755,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -2063,6 +2797,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -2185,10 +2925,14 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "0.25.4" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] name = "webpki-roots" @@ -2217,6 +2961,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2276,15 +3029,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -2503,15 +3247,8 @@ name = "winnow" version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" - -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" dependencies = [ - "cfg-if", - "windows-sys 0.48.0", + "memchr", ] [[package]] @@ -2526,6 +3263,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +dependencies = [ + "is-terminal", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index e55b3ab..bffc69b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = ["cli", "discord"] [workspace.package] -version = "0.10.0" +version = "0.11.0" edition = "2024" repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest" homepage = "https://2ndbeam.ru/git/2ndbeam/squad-quest" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 1db73ec..ac8581c 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -9,5 +9,5 @@ license.workspace = true chrono = "0.4.42" clap = { version = "4.5.53", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] } -squad-quest = { version = "0.10.0", path = ".." } +squad-quest = { version = "0.11.0", path = ".." } toml = "0.9.8" diff --git a/cli/src/cli/map.rs b/cli/src/cli/map.rs index c98a158..539b4c8 100644 --- a/cli/src/cli/map.rs +++ b/cli/src/cli/map.rs @@ -14,6 +14,8 @@ pub enum MapCommands { Delete(MapDeleteArgs), /// Update room data Update(MapUpdateArgs), + /// Get room implementation data + Data(MapDataArgs), } #[derive(Args)] @@ -55,3 +57,9 @@ pub struct MapUpdateArgs { #[arg(short,long)] pub value: Option, } + +#[derive(Args)] +pub struct MapDataArgs { + /// Room ID + pub id: u16, +} diff --git a/cli/src/main.rs b/cli/src/main.rs index c1c75d5..eb6dceb 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -147,7 +147,8 @@ fn main() { answer: args.answer.clone(), public: args.public, available_on: args.available.clone(), - deadline: args.deadline.clone() + deadline: args.deadline.clone(), + ..Default::default() }; do_and_log(quest.save(path), !cli.quiet, format!("Created quest #{}.", quest.id)); @@ -169,7 +170,8 @@ fn main() { answer: args.answer.clone().unwrap_or(quest.answer.clone()), public: args.public.unwrap_or(quest.public), 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()), + ..Default::default() }; do_and_log(quest.save(path), !cli.quiet, format!("Updated quest #{}.", quest.id)); @@ -445,6 +447,15 @@ fn main() { let connected = if connect { "Connected" } else { "Disconnected" }; 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}"); + } + } + } + }, } } } diff --git a/discord/Cargo.toml b/discord/Cargo.toml index 6853c25..510a366 100644 --- a/discord/Cargo.toml +++ b/discord/Cargo.toml @@ -10,7 +10,9 @@ chrono = "0.4.42" clap = { version = "4.5.53", features = ["derive"] } dotenvy = "0.15.7" poise = "0.6.1" +rocket = { version = "0.5.1", features = ["json"] } serde = "1.0.228" -squad-quest = { version = "0.10.0", path = ".." } +serde_json = "1.0.146" +squad-quest = { version = "0.11.0", path = ".." } tokio = { version = "1.48.0", features = ["rt-multi-thread"] } toml = "0.9.8" diff --git a/discord/Rocket.toml b/discord/Rocket.toml new file mode 100644 index 0000000..034039b --- /dev/null +++ b/discord/Rocket.toml @@ -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 + diff --git a/discord/src/account.rs b/discord/src/account.rs index bc433e3..0551ec6 100644 --- a/discord/src/account.rs +++ b/discord/src/account.rs @@ -1,12 +1,24 @@ -use poise::serenity_prelude::UserId; +use std::collections::HashMap; + +use poise::serenity_prelude::{User, UserId}; use squad_quest::{account::Account, config::Config, map::Map}; -pub fn fetch_or_init_account(conf: &Config, id: String) -> Account { +pub fn fetch_or_init_account(conf: &Config, id: String, user: Option<&User>) -> Account { let accounts = conf.load_accounts(); + let mut data: HashMap = 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); + } + match accounts.iter().find(|a| a.id == id) { Some(a) => a.clone(), None => Account { id, + data: Some(data), ..Default::default() }, } diff --git a/discord/src/api.rs b/discord/src/api.rs new file mode 100644 index 0000000..1594240 --- /dev/null +++ b/discord/src/api.rs @@ -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, +} + +struct RoomDataResponse { + pub data: Vec +} + +impl From> for RoomDataResponse { + fn from(value: Vec) -> 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::().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 { + 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) -> 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 = 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::>(); + rd.markers = markers; + rd + }) + .collect(); + rooms_vec.into() +} + +pub fn rocket(config: Config) -> Rocket { + rocket::build() + .mount("/", routes![index]) + .manage(RocketData{config}) +} + + diff --git a/discord/src/commands/account.rs b/discord/src/commands/account.rs index d47b906..2d5d6f3 100644 --- a/discord/src/commands/account.rs +++ b/discord/src/commands/account.rs @@ -142,7 +142,8 @@ pub async fn give( let mut accounts = config.load_accounts(); let user_id = format!("{}", ctx.author().id.get()); - let mut user_account = fetch_or_init_account(config, user_id); + + let mut user_account = fetch_or_init_account(config, user_id, Some(ctx.author())); let who_id = format!("{}", who.id.get()); let Some(other_account) = accounts.iter_mut().find(|a| a.id == who_id ) else { diff --git a/discord/src/commands/answer.rs b/discord/src/commands/answer.rs index 1f6445e..523db8f 100644 --- a/discord/src/commands/answer.rs +++ b/discord/src/commands/answer.rs @@ -34,7 +34,7 @@ pub async fn answer( #[description_localized("ru", "Вложение к ответу на квест")] file3: Option, ) -> Result<(), Error> { - let mut account = fetch_or_init_account(&ctx.data().config, ctx.author().id.to_string()); + let mut account = fetch_or_init_account(&ctx.data().config, ctx.author().id.to_string(), Some(ctx.author())); if let Some(_) = account.quests_completed.iter().find(|qid| **qid == quest_id) { return Err(Error::QuestIsCompleted(quest_id)); diff --git a/discord/src/commands/map.rs b/discord/src/commands/map.rs index 96bb30d..064595d 100644 --- a/discord/src/commands/map.rs +++ b/discord/src/commands/map.rs @@ -27,7 +27,7 @@ pub async fn unlock( }; let acc_id = format!("{}", ctx.author().id.get()); - let mut account = fetch_or_init_account(conf, acc_id); + let mut account = fetch_or_init_account(conf, acc_id, Some(ctx.author())); if account.balance < room.value { return Err(Error::InsufficientFunds(room.value)); @@ -68,7 +68,7 @@ pub async fn r#move( let conf = &ctx.data().config; let acc_id = format!("{}", ctx.author().id.get()); - let mut account = fetch_or_init_account(conf, acc_id); + let mut account = fetch_or_init_account(conf, acc_id, Some(ctx.author())); if let None = account.rooms_unlocked.iter().find(|rid| **rid == id) { return Err(Error::CannotReach(id)); diff --git a/discord/src/main.rs b/discord/src/main.rs index cfb68c7..ffe6922 100644 --- a/discord/src/main.rs +++ b/discord/src/main.rs @@ -1,3 +1,5 @@ +#[macro_use] extern crate rocket; + use std::{sync::{Arc, Mutex}}; use clap::Parser; @@ -5,8 +7,9 @@ use dotenvy::dotenv; use poise::serenity_prelude as serenity; use squad_quest::config::Config; -use crate::{commands::error_handler, config::{ConfigImpl, DiscordConfig}, error::Error, strings::Strings}; +use crate::{commands::{error_handler, print_error_recursively}, config::{ConfigImpl, DiscordConfig}, error::Error, strings::Strings}; +mod api; mod commands; mod cli; mod config; @@ -65,6 +68,14 @@ async fn main() { let token = std::env::var(DISCORD_TOKEN).expect("missing DISCORD_TOKEN"); 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() .options(poise::FrameworkOptions { on_error: |err| Box::pin(error_handler(err)), @@ -87,6 +98,8 @@ async fn main() { .setup(|_ctx, _ready, _framework| { Box::pin(async move { //poise::builtins::register_globally(ctx, &framework.options().commands).await?; + + Ok(Data { config, discord: Arc::new(Mutex::new(discord)), diff --git a/src/account/mod.rs b/src/account/mod.rs index d816548..bfb6157 100644 --- a/src/account/mod.rs +++ b/src/account/mod.rs @@ -1,6 +1,6 @@ //! User accounts -use std::{fs, io::Write, path::PathBuf}; +use std::{collections::HashMap, fs, io::Write, path::PathBuf}; use serde::{ Serialize, Deserialize }; @@ -29,6 +29,9 @@ pub struct Account { /// Vec of rooms unlocked by this user pub rooms_unlocked: Vec, + + /// Additional implementation-defined data + pub data: Option>, } impl Default for Account { @@ -39,6 +42,7 @@ impl Default for Account { location: u16::default(), quests_completed: Vec::new(), rooms_unlocked: Vec::new(), + data: None, } } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 07aa1f1..97a2634 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::{SquadObject, account::Account, error::Error, quest::Quest}; /// Struct for containing paths to other (de-)serializable things -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(default)] pub struct Config { /// Path to config directory diff --git a/src/map/mod.rs b/src/map/mod.rs index 1ed9aaf..819962a 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -1,6 +1,6 @@ //! 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}; @@ -11,7 +11,7 @@ use crate::{SquadObject, account::Account, error::{Error, MapError}}; #[serde(default)] pub struct Map { /// Rooms go here - pub room: Vec + pub room: Vec, } impl Default for Map { @@ -131,6 +131,8 @@ pub struct Room { pub name: String, /// Room description pub description: Option, + /// Additional implementation-based data + pub data: Option>, } fn default_name() -> String { @@ -145,6 +147,7 @@ impl Default for Room { value: u32::default(), name: default_name(), description: None, + data: None, } } } diff --git a/src/quest/mod.rs b/src/quest/mod.rs index 669d061..da28415 100644 --- a/src/quest/mod.rs +++ b/src/quest/mod.rs @@ -1,6 +1,6 @@ //! 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 crate::{SquadObject, account::Account, error::{Error, QuestError}}; @@ -66,7 +66,10 @@ pub struct Quest { pub available_on: Option, /// When quest expires - pub deadline: Option + pub deadline: Option, + + /// Additional implementation-defined data + pub data: Option>, } impl Default for Quest { @@ -80,7 +83,8 @@ impl Default for Quest { answer: default_answer(), public: false, available_on: None, - deadline: None + deadline: None, + data: None, } } } diff --git a/tests/main.rs b/tests/main.rs index b2d054d..73a63f5 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -38,7 +38,8 @@ fn quest_one() { answer: "Accept the answer if it has no attachments and an empty comment".to_owned(), public: false, available_on: None, - deadline: None + deadline: None, + ..Default::default() }; assert_eq!(*quest, expected); @@ -73,7 +74,8 @@ fn account_test() { balance: 150, location: 0, quests_completed: vec![0], - rooms_unlocked: Vec::new() + rooms_unlocked: Vec::new(), + ..Default::default() }; let accounts = config.load_accounts(); @@ -92,6 +94,7 @@ fn load_map() { value: 0, name: "Entrance".to_string(), description: Some("Enter the dungeon".to_string()), + ..Default::default() }; let room1 = Room { @@ -100,6 +103,7 @@ fn load_map() { value: 100, name: "Kitchen hall".to_string(), description: None, + ..Default::default() }; let room2 = Room { @@ -108,6 +112,7 @@ fn load_map() { value: 250, name: "Room".to_string(), description: Some("Simple room with no furniture".to_string()), + ..Default::default() }; let room3 = Room { @@ -116,6 +121,7 @@ fn load_map() { value: 175, name: "Kitchen".to_string(), description: Some("Knives are stored here".to_string()), + ..Default::default() }; let expected = Map { From d188bba16e581bb9dfc6b60c6e6bdd67f08b6c0f Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Wed, 24 Dec 2025 17:46:22 +0300 Subject: [PATCH 11/13] feat: Implemented guild check - Also added more error logging --- discord/src/commands/account.rs | 7 ++++++- discord/src/commands/answer.rs | 3 ++- discord/src/commands/init.rs | 4 +++- discord/src/commands/map.rs | 4 +++- discord/src/commands/mod.rs | 23 +++++++++++++++++++++-- discord/src/commands/quest.rs | 8 +++++++- discord/src/commands/social.rs | 6 +++++- discord/src/error.rs | 5 ++++- 8 files changed, 51 insertions(+), 9 deletions(-) diff --git a/discord/src/commands/account.rs b/discord/src/commands/account.rs index 2d5d6f3..2b81750 100644 --- a/discord/src/commands/account.rs +++ b/discord/src/commands/account.rs @@ -1,7 +1,7 @@ use poise::serenity_prelude::User; use squad_quest::{SquadObject, account::Account, map::Map}; -use crate::{Context, Error, account::{account_full_balance, account_user_id, fetch_or_init_account}, strings::StringFormatter}; +use crate::{Context, Error, account::{account_full_balance, account_user_id, fetch_or_init_account}, strings::StringFormatter, commands::guild}; async fn account_balance_string( ctx: &Context<'_>, @@ -26,6 +26,7 @@ async fn account_balance_string( prefix_command, slash_command, guild_only, + check = "guild", required_permissions = "ADMINISTRATOR", name_localized("ru", "сбросить"), description_localized("ru", "Сбросить аккаунт пользователя, вкл. баланс, открытые комнаты и пройденные квесты"), @@ -63,6 +64,7 @@ pub async fn reset( prefix_command, slash_command, guild_only, + check = "guild", name_localized("ru", "счет"), description_localized("ru", "Отобразить таблицу лидеров"), )] @@ -106,6 +108,7 @@ pub async fn scoreboard( prefix_command, slash_command, guild_only, + check = "guild", subcommands("give", "set"), name_localized("ru", "баланс"), )] @@ -120,6 +123,7 @@ pub async fn balance( prefix_command, slash_command, guild_only, + check = "guild", name_localized("ru", "передать"), description_localized("ru", "Передать очки другому пользователю"), )] @@ -178,6 +182,7 @@ pub async fn give( prefix_command, slash_command, guild_only, + check = "guild", required_permissions = "ADMINISTRATOR", name_localized("ru", "установить"), description_localized("ru", "Устанавливает текущий баланс пользователя"), diff --git a/discord/src/commands/answer.rs b/discord/src/commands/answer.rs index 523db8f..2dd280a 100644 --- a/discord/src/commands/answer.rs +++ b/discord/src/commands/answer.rs @@ -1,13 +1,14 @@ use poise::serenity_prelude::{Attachment, ComponentInteractionCollector, CreateActionRow, CreateAttachment, CreateButton, CreateMessage, EditMessage}; use squad_quest::SquadObject; -use crate::{Context, Error, account::fetch_or_init_account}; +use crate::{Context, Error, account::fetch_or_init_account, commands::guild}; /// Send an answer to the quest for review #[poise::command( prefix_command, slash_command, guild_only, + check = "guild", name_localized("ru", "ответить"), description_localized("ru", "Отправить ответ на квест на проверку"), )] diff --git a/discord/src/commands/init.rs b/discord/src/commands/init.rs index fb6e6c3..b4fd02e 100644 --- a/discord/src/commands/init.rs +++ b/discord/src/commands/init.rs @@ -4,7 +4,7 @@ use poise::{CreateReply, serenity_prelude::ChannelId}; use squad_quest::SquadObject; use toml::value::Time; -use crate::{Context, Error, timer::DailyTimer}; +use crate::{Context, Error, timer::DailyTimer, commands::guild}; /// Set channels to post quests and answers to #[poise::command( @@ -12,6 +12,7 @@ use crate::{Context, Error, timer::DailyTimer}; slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "инит"), description_localized("ru", "Установить каналы для публикации квестов и ответов"), )] @@ -75,6 +76,7 @@ fn seconds(time: Time) -> u64 { slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "таймер"), description_localized("ru", "Включить таймер публикации по заданной временной метке UTC (МСК -3)"), )] diff --git a/discord/src/commands/map.rs b/discord/src/commands/map.rs index 064595d..e22f2e4 100644 --- a/discord/src/commands/map.rs +++ b/discord/src/commands/map.rs @@ -1,12 +1,13 @@ 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( prefix_command, slash_command, guild_only, + check = "guild", name_localized("ru", "открыть"), description_localized("ru", "Открывает указанную комнату, если хватает очков и до нее можно добраться"), )] @@ -55,6 +56,7 @@ pub async fn unlock( prefix_command, slash_command, guild_only, + check = "guild", name_localized("ru", "пойти"), description_localized("ru", "Переместиться в другую разблокированную комнату"), )] diff --git a/discord/src/commands/mod.rs b/discord/src/commands/mod.rs index 85d3ee8..b274168 100644 --- a/discord/src/commands/mod.rs +++ b/discord/src/commands/mod.rs @@ -1,5 +1,5 @@ use std::error::Error as StdError; -use poise::CreateReply; +use poise::{CreateReply, serenity_prelude::GuildId}; use crate::{Context, Data, Error}; @@ -10,6 +10,18 @@ pub mod social; pub mod account; pub mod map; +pub async fn guild(ctx: Context<'_>) -> Result { + 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)] pub async fn register(ctx: Context<'_>) -> Result<(), Error> { poise::builtins::register_application_commands_buttons(ctx).await?; @@ -36,10 +48,17 @@ pub async fn error_handler(error: poise::FrameworkError<'_, Data, Error>) { eprintln!("ERROR:"); print_error_recursively(&error); if let Some(ctx) = error.ctx() { + let user = ctx.author().display_name(); + eprintln!("User: {user} ({id})", id = ctx.author().id); + let response = match error { - poise::FrameworkError::Command { error, .. } => format!("Internal server error: {error}"), + poise::FrameworkError::Command { error, .. } => { + eprintln!("Invokation string: {}", ctx.invocation_string()); + format!("Internal server error: {error}") + }, _ => format!("Internal server error: {error}"), }; + if let Err(error) = ctx.send(CreateReply::default().content(response).ephemeral(true)).await { eprintln!("Couldn't send error message: {error}"); } diff --git a/discord/src/commands/quest.rs b/discord/src/commands/quest.rs index 1d73544..489d70c 100644 --- a/discord/src/commands/quest.rs +++ b/discord/src/commands/quest.rs @@ -3,7 +3,7 @@ use std::{future, str::FromStr}; use poise::serenity_prelude::{CreateMessage, EditMessage, Message, futures::StreamExt}; use squad_quest::{SquadObject, quest::{Quest, QuestDifficulty}}; use toml::value::Date; -use crate::{Context, Error}; +use crate::{Context, Error,commands::guild}; async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result, Error>{ ctx.defer().await?; @@ -34,6 +34,7 @@ fn make_quest_message_content(ctx: Context<'_>, quest: &Quest) -> String { prefix_command, slash_command, guild_only, + check = "guild", subcommands("list", "create", "update", "publish", "delete"), required_permissions = "ADMINISTRATOR", name_localized("ru", "квест"), @@ -49,6 +50,7 @@ pub async fn quest( prefix_command, slash_command, guild_only, + check = "guild", required_permissions = "ADMINISTRATOR", name_localized("ru", "список"), description_localized("ru", "Вывести все квесты") @@ -114,6 +116,7 @@ impl From for Date { slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "создать"), description_localized("ru", "Создать квест и получить его идентификатор"), )] @@ -202,6 +205,7 @@ pub async fn create( slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "обновить"), description_localized("ru", "Обновить выбранные значения указанного квеста"), )] @@ -339,6 +343,7 @@ pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result ) -> Result<(), Error> { slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "написать"), description_localized("ru", "Отправить сообщение пользователю или в канал"), )] @@ -98,6 +100,7 @@ pub async fn msg ( slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "редактировать"), description_localized("ru", "Редактировать сообщение в канале или в ЛС"), )] @@ -177,6 +180,7 @@ pub async fn edit ( slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "удалить"), description_localized("ru", "Удалить сообщение в канале или в ЛС"), )] diff --git a/discord/src/error.rs b/discord/src/error.rs index 8d8b442..fe80e0d 100644 --- a/discord/src/error.rs +++ b/discord/src/error.rs @@ -21,6 +21,7 @@ pub enum Error { RoomAlreadyUnlocked(u16), CannotReach(u16), TimerSet, + NotThisGuild, } impl From for Error { @@ -65,6 +66,7 @@ impl Display for Error { Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"), 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"), } } } @@ -84,7 +86,8 @@ impl std::error::Error for Error { Self::RoomNotFound(_) | Self::RoomAlreadyUnlocked(_) | Self::CannotReach(_) | - Self::TimerSet => None, + Self::TimerSet | + Self::NotThisGuild => None, Self::SerenityError(error) => Some(error), Self::SquadQuestError(error) => Some(error), } From 2640821a05f5b6efd0af9ebb9de1e8c3832b8a40 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Tue, 30 Dec 2025 15:44:23 +0300 Subject: [PATCH 12/13] feat!: Added limit field to quests - Bump version to 0.12.0 - lib: Changed Quest::complete_for_account behavior - cli: Added limit field for quest create and quest update - discord: Quests are checked for limit on /answer - discord: Added limit field for /quest create and /quest update - discord: Changed behavior of fetch_or_init_account --- Cargo.lock | 6 ++-- Cargo.toml | 2 +- cli/Cargo.toml | 2 +- cli/src/cli/quest.rs | 6 ++++ cli/src/main.rs | 18 ++++++---- discord/Cargo.toml | 2 +- discord/src/account.rs | 23 +++++++----- discord/src/commands/account.rs | 46 ++++++++++++++---------- discord/src/commands/answer.rs | 35 +++++++++++++----- discord/src/commands/map.rs | 10 ++++-- discord/src/commands/quest.rs | 63 ++++++++++++++++++++++++--------- discord/src/error.rs | 5 ++- discord/src/strings.rs | 11 +++++- src/account/mod.rs | 9 ++++- src/error.rs | 6 ++++ src/quest/mod.rs | 17 ++++++++- 16 files changed, 192 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 388a656..1be0a5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2140,7 +2140,7 @@ checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "squad-quest" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde", "toml 0.9.10+spec-1.1.0", @@ -2148,7 +2148,7 @@ dependencies = [ [[package]] name = "squad-quest-cli" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "clap", @@ -2159,7 +2159,7 @@ dependencies = [ [[package]] name = "squad-quest-discord" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index bffc69b..7cc9deb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = ["cli", "discord"] [workspace.package] -version = "0.11.0" +version = "0.12.0" edition = "2024" repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest" homepage = "https://2ndbeam.ru/git/2ndbeam/squad-quest" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index ac8581c..9585de5 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -9,5 +9,5 @@ license.workspace = true chrono = "0.4.42" clap = { version = "4.5.53", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] } -squad-quest = { version = "0.11.0", path = ".." } +squad-quest = { version = "0.12.0", path = ".." } toml = "0.9.8" diff --git a/cli/src/cli/quest.rs b/cli/src/cli/quest.rs index 1ed541c..41c5a7e 100644 --- a/cli/src/cli/quest.rs +++ b/cli/src/cli/quest.rs @@ -83,6 +83,9 @@ pub struct QuestCreateArgs { /// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24) #[arg(short,long,value_parser = parse_date)] pub deadline: Option, + /// Limit on how many users can solve the quest (0 = no limit) + #[arg(short,long)] + pub limit: Option, } #[derive(Args)] @@ -113,6 +116,9 @@ pub struct QuestUpdateArgs { /// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24) #[arg(long,value_parser = parse_date)] pub deadline: Option, + /// Limit on how many users can solve the quest (0 = no limit) + #[arg(long)] + pub limit: Option, } #[derive(Args)] diff --git a/cli/src/main.rs b/cli/src/main.rs index eb6dceb..1a022e9 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -148,6 +148,7 @@ fn main() { public: args.public, available_on: args.available.clone(), deadline: args.deadline.clone(), + limit: args.limit.unwrap_or_default(), ..Default::default() }; @@ -171,6 +172,7 @@ fn main() { public: args.public.unwrap_or(quest.public), available_on: args.available.clone().or(quest.available_on.clone()), deadline: args.deadline.clone().or(quest.deadline.clone()), + limit: args.limit.unwrap_or_default(), ..Default::default() }; @@ -284,10 +286,6 @@ fn main() { do_and_log(account.save(path), !cli.quiet, format!("Updated balance of account \"{}\".", account.id)); }, 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(); @@ -299,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}"), - 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)); + }, _ => {}, } }, diff --git a/discord/Cargo.toml b/discord/Cargo.toml index 510a366..384d709 100644 --- a/discord/Cargo.toml +++ b/discord/Cargo.toml @@ -13,6 +13,6 @@ poise = "0.6.1" rocket = { version = "0.5.1", features = ["json"] } serde = "1.0.228" serde_json = "1.0.146" -squad-quest = { version = "0.11.0", path = ".." } +squad-quest = { version = "0.12.0", path = ".." } tokio = { version = "1.48.0", features = ["rt-multi-thread"] } toml = "0.9.8" diff --git a/discord/src/account.rs b/discord/src/account.rs index 0551ec6..8950210 100644 --- a/discord/src/account.rs +++ b/discord/src/account.rs @@ -3,8 +3,14 @@ use std::collections::HashMap; use poise::serenity_prelude::{User, UserId}; use squad_quest::{account::Account, config::Config, map::Map}; -pub fn fetch_or_init_account(conf: &Config, id: String, user: Option<&User>) -> Account { +/// 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 { let accounts = conf.load_accounts(); + + if let Some(account) = accounts.iter().find(|a| a.id == id) { + return Ok(account.clone()); + } + let mut data: HashMap = HashMap::new(); if let Some(user) = user { @@ -14,14 +20,13 @@ pub fn fetch_or_init_account(conf: &Config, id: String, user: Option<&User>) -> data.insert("name".to_string(), name); } - match accounts.iter().find(|a| a.id == id) { - Some(a) => a.clone(), - None => Account { - id, - data: Some(data), - ..Default::default() - }, - } + let new_account = Account { + id: id.to_string(), + data: Some(data), + ..Default::default() + }; + + Err(new_account) } pub fn account_rooms_value(account: &Account, map: &Map) -> u32 { diff --git a/discord/src/commands/account.rs b/discord/src/commands/account.rs index 2b81750..91ec72d 100644 --- a/discord/src/commands/account.rs +++ b/discord/src/commands/account.rs @@ -1,7 +1,7 @@ use poise::serenity_prelude::User; use squad_quest::{SquadObject, account::Account, map::Map}; -use crate::{Context, Error, account::{account_full_balance, account_user_id, fetch_or_init_account}, strings::StringFormatter, commands::guild}; +use crate::{Context, Error, account::{account_full_balance, account_user_id}, strings::StringFormatter, commands::guild}; async fn account_balance_string( ctx: &Context<'_>, @@ -146,31 +146,41 @@ pub async fn give( let mut accounts = config.load_accounts(); let user_id = format!("{}", ctx.author().id.get()); - - let mut user_account = fetch_or_init_account(config, user_id, Some(ctx.author())); - + let strings = &ctx.data().strings; + let formatter: StringFormatter; + let accounts_path = config.full_accounts_path(); + let who_id = format!("{}", who.id.get()); - let Some(other_account) = accounts.iter_mut().find(|a| a.id == who_id ) else { - return Err(Error::AccountNotFound); - }; - if user_account.balance < amount { - return Err(Error::InsufficientFunds(amount)); + if let None = accounts.iter().find(|a| a.id == who_id ) { + return Err(Error::AccountNotFound); } - user_account.balance -= amount; + { + let Some(user_account) = accounts.iter_mut().find(|a| a.id == user_id) else { + return Err(Error::AccountNotFound); + }; + + if user_account.balance < amount { + return Err(Error::InsufficientFunds(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; - let accounts_path = config.full_accounts_path(); - user_account.save(accounts_path.clone())?; other_account.save(accounts_path)?; - let strings = &ctx.data().strings; - let formatter = strings.formatter() - .user(&who) - .value(amount) - .current_balance(&user_account); - + let reply_string = formatter.fmt(&strings.account.give_pt); ctx.reply(reply_string).await?; diff --git a/discord/src/commands/answer.rs b/discord/src/commands/answer.rs index 2dd280a..964ea6a 100644 --- a/discord/src/commands/answer.rs +++ b/discord/src/commands/answer.rs @@ -1,7 +1,7 @@ use poise::serenity_prelude::{Attachment, ComponentInteractionCollector, CreateActionRow, CreateAttachment, CreateButton, CreateMessage, EditMessage}; use squad_quest::SquadObject; -use crate::{Context, Error, account::fetch_or_init_account, commands::guild}; +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( @@ -35,19 +35,34 @@ pub async fn answer( #[description_localized("ru", "Вложение к ответу на квест")] file3: Option, ) -> Result<(), Error> { - let mut account = fetch_or_init_account(&ctx.data().config, ctx.author().id.to_string(), Some(ctx.author())); - - 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 Some(quest) = quests.iter() .filter(|q| q.public) .find(|q| q.id == quest_id) else { 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 = Vec::new(); for file in [file1, file2, file3] { if let Some(f) = file { @@ -135,16 +150,20 @@ pub async fn answer( let content: String; if is_approved { 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}"); 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(); if let Err(error) = account.save(path) { eprintln!("{error}"); no_errors = false; }; + update_quest_message(ctx, &quest).await?; + formatter = formatter.current_balance(&account); if no_errors { diff --git a/discord/src/commands/map.rs b/discord/src/commands/map.rs index e22f2e4..e23134d 100644 --- a/discord/src/commands/map.rs +++ b/discord/src/commands/map.rs @@ -28,7 +28,10 @@ pub async fn unlock( }; let acc_id = format!("{}", ctx.author().id.get()); - let mut account = fetch_or_init_account(conf, acc_id, Some(ctx.author())); + 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 { return Err(Error::InsufficientFunds(room.value)); @@ -70,7 +73,10 @@ pub async fn r#move( let conf = &ctx.data().config; let acc_id = format!("{}", ctx.author().id.get()); - let mut account = fetch_or_init_account(conf, acc_id, Some(ctx.author())); + 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) { return Err(Error::CannotReach(id)); diff --git a/discord/src/commands/quest.rs b/discord/src/commands/quest.rs index 489d70c..a04b0ef 100644 --- a/discord/src/commands/quest.rs +++ b/discord/src/commands/quest.rs @@ -1,7 +1,8 @@ use std::{future, str::FromStr}; -use poise::serenity_prelude::{CreateMessage, EditMessage, Message, futures::StreamExt}; -use squad_quest::{SquadObject, quest::{Quest, QuestDifficulty}}; +use poise::serenity_prelude as serenity; +use serenity::{CreateMessage, EditMessage, Message, futures::StreamExt}; +use squad_quest::{account::Account, quest::{Quest, QuestDifficulty}, SquadObject}; use toml::value::Date; use crate::{Context, Error,commands::guild}; @@ -24,12 +25,37 @@ async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result Ok(messages.first().cloned()) } -fn make_quest_message_content(ctx: Context<'_>, quest: &Quest) -> String { +fn make_quest_message_content(ctx: Context<'_>, quest: &Quest, accounts: &Option>) -> String { let strings = &ctx.data().strings; - let formatter = strings.formatter().quest(quest); + let formatter = match accounts { + Some(accounts) => strings.formatter().quest_full(quest, accounts), + None => strings.formatter().quest(quest), + }; formatter.fmt(&strings.quest.message_format) } +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( prefix_command, slash_command, @@ -146,6 +172,10 @@ pub async fn create( #[name_localized("ru", "доступен")] #[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24)")] available: Option, + #[description = "Limit how many users are allowed to complete the quest"] + #[name_localized("ru", "лимит")] + #[description_localized("ru", "Ограничение количества участников, которые могут выполнить квест")] + limit: Option, /* #[description = "Deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"] #[name_localized("ru", "дедлайн")] @@ -182,6 +212,7 @@ pub async fn create( answer, public: false, available_on, + limit: limit.unwrap_or_default(), //deadline, ..Default::default() }; @@ -239,6 +270,10 @@ pub async fn update( #[name_localized("ru", "доступен")] #[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24")] available: Option, + #[description = "Limit how many users are allowed to complete the quest"] + #[name_localized("ru", "лимит")] + #[description_localized("ru", "Ограничение количества участников, которые могут выполнить квест")] + limit: Option, /* #[description = "Quest deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"] #[name_localized("ru", "дедлайн")] @@ -264,15 +299,18 @@ pub async fn update( }; let available_on: Option; + let new_limit: u8; //let dead_line: Option; match reset.unwrap_or(false) { true => { available_on = None; + new_limit = limit.unwrap_or_default(); //dead_line = None; }, false => { available_on = available.map_or_else(|| quest.available_on.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())); }, } @@ -286,6 +324,7 @@ pub async fn update( answer: answer.unwrap_or(quest.answer.clone()), public: quest.public, available_on, + limit: new_limit, //deadline: dead_line, ..Default::default() }; @@ -294,16 +333,7 @@ pub async fn update( let formatter = strings.formatter().quest(&new_quest); if new_quest.public { - let content = make_quest_message_content(ctx, &new_quest); - let builder = EditMessage::new().content(content); - - let message = find_quest_message(ctx, id).await?; - if let Some(mut message) = message { - message.edit(ctx, builder).await?; - } else { - let reply_string = formatter.fmt(&strings.quest.message_not_found); - ctx.reply(reply_string).await?; - } + update_quest_message(ctx, &new_quest).await?; } let path = conf.full_quests_path(); @@ -319,8 +349,9 @@ pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result for Error { @@ -67,6 +68,7 @@ impl Display for Error { 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}"), } } } @@ -87,7 +89,8 @@ impl std::error::Error for Error { Self::RoomAlreadyUnlocked(_) | Self::CannotReach(_) | Self::TimerSet | - Self::NotThisGuild => None, + Self::NotThisGuild | + Self::QuestLimitExceeded(_) => None, Self::SerenityError(error) => Some(error), Self::SquadQuestError(error) => Some(error), } diff --git a/discord/src/strings.rs b/discord/src/strings.rs index afa2ac8..43505d4 100644 --- a/discord/src/strings.rs +++ b/discord/src/strings.rs @@ -45,11 +45,20 @@ impl StringFormatter { 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 new_tags = vec![ id, difficulty, reward, name, description, answer ]; + 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) -> 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()); diff --git a/src/account/mod.rs b/src/account/mod.rs index bfb6157..2d8b296 100644 --- a/src/account/mod.rs +++ b/src/account/mod.rs @@ -4,7 +4,7 @@ use std::{collections::HashMap, fs, io::Write, path::PathBuf}; use serde::{ Serialize, Deserialize }; -use crate::{SquadObject, error::Error}; +use crate::{error::Error, quest::Quest, SquadObject}; fn default_id() -> String { "none".to_string() @@ -99,3 +99,10 @@ impl SquadObject for Account { 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) + } +} diff --git a/src/error.rs b/src/error.rs index f4f1bc3..6bf532a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -49,12 +49,18 @@ impl std::error::Error for Error { pub enum QuestError { /// Quest (self.0) is already completed for given account (self.1) AlreadyCompleted(u16, String), + /// Account (self.0) not found + AccountNotFound(String), + /// Limit for quest (self.0) exceeded + LimitExceeded(u16), } impl fmt::Display for QuestError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { 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}"), } } } diff --git a/src/quest/mod.rs b/src/quest/mod.rs index da28415..dc6c7ce 100644 --- a/src/quest/mod.rs +++ b/src/quest/mod.rs @@ -70,6 +70,10 @@ pub struct Quest { /// Additional implementation-defined data pub data: Option>, + + /// Limit how many users can complete the quest. + /// If set to 0, quest is not limited. + pub limit: u8, } impl Default for Quest { @@ -85,6 +89,7 @@ impl Default for Quest { available_on: None, deadline: None, data: None, + limit: 0, } } } @@ -159,7 +164,17 @@ impl Quest { /// // handle error /// } /// ``` - pub fn complete_for_account(&self, account: &mut Account) -> Result<(),QuestError> { + pub fn complete_for_account(&self, id: &str, accounts: &mut Vec) -> 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) { Some(_) => Err(QuestError::AlreadyCompleted(self.id, account.id.clone())), None => { From 1db7ce877ef99dac0be3ea6c7f1f63f7afb89dbd Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Wed, 31 Dec 2025 23:31:41 +0300 Subject: [PATCH 13/13] feat(discord): Added /avatar command - Added formattable error strings --- discord/src/commands/map.rs | 97 +++++++++++++++++++++++++++++++++++++ discord/src/commands/mod.rs | 45 ++++++++++++++--- discord/src/error.rs | 46 ++++++++++++++++-- discord/src/main.rs | 1 + discord/src/strings.rs | 60 +++++++++++++++++++++++ 5 files changed, 236 insertions(+), 13 deletions(-) diff --git a/discord/src/commands/map.rs b/discord/src/commands/map.rs index e23134d..0b6dfb5 100644 --- a/discord/src/commands/map.rs +++ b/discord/src/commands/map.rs @@ -1,3 +1,4 @@ +use poise::{serenity_prelude::{Attachment, CreateAttachment, CreateMessage}, CreateReply}; use squad_quest::{SquadObject, map::Map}; use crate::{Context, account::fetch_or_init_account, error::Error, commands::guild}; @@ -96,3 +97,99 @@ pub async fn r#move( 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, + #[description = "Attachment to use as avatar"] + #[name_localized("ru", "вложение")] + #[description_localized("ru", "Вложение, используемое как аватар")] + attachment: Option, +) -> 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(()) +} diff --git a/discord/src/commands/mod.rs b/discord/src/commands/mod.rs index b274168..bba7684 100644 --- a/discord/src/commands/mod.rs +++ b/discord/src/commands/mod.rs @@ -1,7 +1,8 @@ use std::error::Error as StdError; 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 init; @@ -49,14 +50,42 @@ pub async fn error_handler(error: poise::FrameworkError<'_, Data, Error>) { print_error_recursively(&error); if let Some(ctx) = error.ctx() { let user = ctx.author().display_name(); - eprintln!("User: {user} ({id})", id = ctx.author().id); - let response = match error { - poise::FrameworkError::Command { error, .. } => { - eprintln!("Invokation string: {}", ctx.invocation_string()); - 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 { diff --git a/discord/src/error.rs b/discord/src/error.rs index 258b374..df5fb7f 100644 --- a/discord/src/error.rs +++ b/discord/src/error.rs @@ -3,6 +3,8 @@ use std::fmt::Display; use poise::serenity_prelude as serenity; use squad_quest::error::MapError; +use crate::strings::ErrorStrings; + #[non_exhaustive] #[derive(Debug)] pub enum Error { @@ -23,6 +25,9 @@ pub enum Error { TimerSet, NotThisGuild, QuestLimitExceeded(u16), + BothUrlAndAttachment, + NoUrlOrAttachment, + NonImageAttachment, } impl From for Error { @@ -55,9 +60,9 @@ impl Display for Error { Self::QuestNotFound(u16) => write!(f, "quest #{u16} not found"), Self::QuestIsPublic(u16) => write!(f, "quest #{u16} is already public"), Self::QuestIsCompleted(u16) => write!(f, "quest #{u16} is already completed for this user"), - Self::NoContent => write!(f, "no text or attachment was specified"), - Self::NoChannelOrUser => write!(f, "no channel or user was specified"), - Self::BothChannelAndUser => write!(f, "both channel and user was specified"), + Self::NoContent => write!(f, "no text or attachment were specified"), + Self::NoChannelOrUser => write!(f, "no channel or user were specified"), + Self::BothChannelAndUser => write!(f, "both channel and user were specified"), Self::SerenityError(_) => write!(f, "discord interaction error"), Self::SquadQuestError(_) => write!(f, "internal logic error"), Self::AccountNotFound => write!(f, "account not found"), @@ -69,6 +74,9 @@ impl Display for Error { 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"), } } } @@ -90,11 +98,39 @@ impl std::error::Error for Error { Self::CannotReach(_) | Self::TimerSet | Self::NotThisGuild | - Self::QuestLimitExceeded(_) => None, + Self::QuestLimitExceeded(_) | + Self::BothUrlAndAttachment | + Self::NoUrlOrAttachment | + Self::NonImageAttachment => None, Self::SerenityError(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, + } + } +} diff --git a/discord/src/main.rs b/discord/src/main.rs index ffe6922..acb3fea 100644 --- a/discord/src/main.rs +++ b/discord/src/main.rs @@ -92,6 +92,7 @@ async fn main() { commands::account::reset(), commands::map::unlock(), commands::map::r#move(), + commands::map::avatar(), ], ..Default::default() }) diff --git a/discord/src/strings.rs b/discord/src/strings.rs index 43505d4..c965f8c 100644 --- a/discord/src/strings.rs +++ b/discord/src/strings.rs @@ -142,6 +142,7 @@ pub struct Strings { pub scoreboard: Scoreboard, pub social: Social, pub quest: QuestStrings, + pub error: ErrorStrings, } impl Default for Strings { @@ -160,6 +161,7 @@ impl Default for Strings { social: Social::default(), account: AccountReplies::default(), map: MapReplies::default(), + error: ErrorStrings::default(), } } } @@ -391,6 +393,8 @@ impl Default for AccountReplies { pub struct MapReplies { pub room_unlocked: String, pub moved_to_room: String, + pub updated_avatar: String, + pub processing_url: String, } impl Default for MapReplies { @@ -398,6 +402,62 @@ impl Default for MapReplies { 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(), } } }