feat(discord): Added /avatar command

- Added formattable error strings
This commit is contained in:
Alexey 2025-12-31 23:31:41 +03:00
commit 1db7ce877e
5 changed files with 236 additions and 13 deletions

View file

@ -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<String>,
#[description = "Attachment to use as avatar"]
#[name_localized("ru", "вложение")]
#[description_localized("ru", "Вложение, используемое как аватар")]
attachment: Option<Attachment>,
) -> Result<(), Error> {
let user_id = ctx.author().id.to_string();
let mut accounts = ctx.data().config.load_accounts();
let Some(account) = accounts.iter_mut().find(|a| a.id == user_id) else {
return Err(Error::AccountNotFound);
};
if url.is_none() && attachment.is_none() {
return Err(Error::NoUrlOrAttachment);
}
if url.is_some() && attachment.is_some() {
return Err(Error::BothUrlAndAttachment);
}
let strings = &ctx.data().strings;
let formatter = strings.formatter();
if let Some(url) = url {
let attachment = CreateAttachment::url(ctx, &url).await?;
let reply_string = formatter.fmt(&strings.map.processing_url);
let builder = CreateMessage::new()
.content(reply_string)
.add_file(attachment.clone());
let message = ctx.channel_id().send_message(ctx, builder).await?;
let attachment_check = message.attachments.first().expect("we just sent it");
if attachment_check.width.is_none()
|| !attachment_check.content_type
.as_ref()
.is_some_and(|t| t.starts_with("image/")) {
message.delete(ctx).await?;
return Err(Error::NonImageAttachment);
}
let data = account.data.as_mut().expect("automatically created");
data.insert("avatar".to_string(), url);
message.delete(ctx).await?;
let reply_string = formatter.fmt(&strings.map.updated_avatar);
let builder = CreateReply::default()
.content(reply_string)
.attachment(attachment)
.reply(true);
ctx.send(builder).await?;
} else if let Some(attachment) = attachment {
if attachment.width.is_none()
|| !attachment.content_type
.as_ref()
.is_some_and(|t| t.starts_with("image/")) {
return Err(Error::NonImageAttachment);
}
let reply_string = formatter.fmt(&strings.map.updated_avatar);
let copied_attachment = CreateAttachment::url(ctx, &attachment.url).await?;
let data = account.data.as_mut().expect("automatically created");
data.insert("avatar".to_string(), attachment.url);
let path = ctx.data().config.full_accounts_path();
account.save(path)?;
let builder = CreateReply::default()
.content(reply_string)
.attachment(copied_attachment)
.reply(true);
ctx.send(builder).await?;
}
let path = ctx.data().config.full_accounts_path();
account.save(path)?;
Ok(())
}

View file

@ -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!("User: {user} ({id})", id = ctx.author().id);
eprintln!("Invokation string: {}", ctx.invocation_string());
format!("Internal server error: {error}")
},
_ => format!("Internal server error: {error}"),
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 {

View file

@ -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<serenity::Error> 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,
}
}
}

View file

@ -92,6 +92,7 @@ async fn main() {
commands::account::reset(),
commands::map::unlock(),
commands::map::r#move(),
commands::map::avatar(),
],
..Default::default()
})

View file

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