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] 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(), } } }