From debb2e088458e28679e74f87b0d5c5828679acd7 Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Tue, 25 Nov 2025 23:00:48 +0100 Subject: [PATCH 1/9] feat(snippet): add base for snippet command --- migrations/20251125000000_create_snippets.sql | 11 + rustmail/src/bot.rs | 1 + .../anonreply/text_command/anonreply.rs | 19 +- rustmail/src/commands/mod.rs | 2 + .../src/commands/reply/slash_command/reply.rs | 27 +- .../src/commands/reply/text_command/reply.rs | 19 +- rustmail/src/commands/snippet/mod.rs | 5 + .../src/commands/snippet/slash_command/mod.rs | 3 + .../commands/snippet/slash_command/snippet.rs | 678 ++++++++++++++++++ .../src/commands/snippet/text_command/mod.rs | 3 + .../commands/snippet/text_command/snippet.rs | 233 ++++++ rustmail/src/db/operations/mod.rs | 2 + .../src/handlers/guild_messages_handler.rs | 1 + rustmail/src/i18n/language/en.rs | 127 ++++ rustmail/src/i18n/language/fr.rs | 127 ++++ rustmail/src/types/mod.rs | 2 + rustmail/src/types/snippet.rs | 12 + rustmail_types/src/api/mod.rs | 10 + 18 files changed, 1279 insertions(+), 3 deletions(-) create mode 100644 migrations/20251125000000_create_snippets.sql create mode 100644 rustmail/src/commands/snippet/mod.rs create mode 100644 rustmail/src/commands/snippet/slash_command/mod.rs create mode 100644 rustmail/src/commands/snippet/slash_command/snippet.rs create mode 100644 rustmail/src/commands/snippet/text_command/mod.rs create mode 100644 rustmail/src/commands/snippet/text_command/snippet.rs create mode 100644 rustmail/src/types/snippet.rs diff --git a/migrations/20251125000000_create_snippets.sql b/migrations/20251125000000_create_snippets.sql new file mode 100644 index 00000000..877a4149 --- /dev/null +++ b/migrations/20251125000000_create_snippets.sql @@ -0,0 +1,11 @@ +-- Add migration script here +CREATE TABLE IF NOT EXISTS "snippets" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "key" TEXT NOT NULL UNIQUE, + "content" TEXT NOT NULL, + "created_by" TEXT NOT NULL, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS "idx_snippets_key" ON "snippets"("key"); diff --git a/rustmail/src/bot.rs b/rustmail/src/bot.rs index 050a2ebe..b628e681 100644 --- a/rustmail/src/bot.rs +++ b/rustmail/src/bot.rs @@ -144,6 +144,7 @@ pub async fn run_bot( registry.register_command(TakeCommand); registry.register_command(ReleaseCommand); registry.register_command(PingCommand); + registry.register_command(SnippetCommand); let registry = Arc::new(registry); diff --git a/rustmail/src/commands/anonreply/text_command/anonreply.rs b/rustmail/src/commands/anonreply/text_command/anonreply.rs index 1173b063..fede788a 100644 --- a/rustmail/src/commands/anonreply/text_command/anonreply.rs +++ b/rustmail/src/commands/anonreply/text_command/anonreply.rs @@ -21,7 +21,24 @@ pub async fn anonreply( .as_ref() .ok_or_else(database_connection_failed)?; - let content = extract_reply_content(&msg.content, &config.command.prefix, &["anonreply", "ar"]); + let mut content = extract_reply_content(&msg.content, &config.command.prefix, &["anonreply", "ar"]); + + if let Some(text) = &content { + if let Some(stripped) = text.strip_prefix("{{").and_then(|s| s.strip_suffix("}}")) { + let snippet_key = stripped.trim(); + match get_snippet_by_key(snippet_key, db_pool).await? { + Some(snippet) => { + content = Some(snippet.content); + } + None => { + msg.reply(&ctx.http, format!("❌ Snippet `{}` not found.", snippet_key)) + .await?; + return Ok(()); + } + } + } + } + let intent = extract_intent(content, &msg.attachments).await; let Some(intent) = intent else { diff --git a/rustmail/src/commands/mod.rs b/rustmail/src/commands/mod.rs index c55f34b8..b68102b5 100644 --- a/rustmail/src/commands/mod.rs +++ b/rustmail/src/commands/mod.rs @@ -28,6 +28,7 @@ pub mod release; pub mod remove_reminder; pub mod remove_staff; pub mod reply; +pub mod snippet; pub mod take; pub use add_reminder::*; @@ -49,6 +50,7 @@ pub use release::*; pub use remove_reminder::*; pub use remove_staff::*; pub use reply::*; +pub use snippet::*; pub use take::*; pub type BoxFuture<'a, T> = Pin + Send + 'a>>; diff --git a/rustmail/src/commands/reply/slash_command/reply.rs b/rustmail/src/commands/reply/slash_command/reply.rs index 1c9bbee7..510aa38a 100644 --- a/rustmail/src/commands/reply/slash_command/reply.rs +++ b/rustmail/src/commands/reply/slash_command/reply.rs @@ -79,7 +79,15 @@ impl RegistrableCommand for ReplyCommand { "message", message_desc, ) - .required(true), + .required(false), + ) + .add_option( + CreateCommandOption::new( + CommandOptionType::String, + "snippet", + "Use a snippet instead of typing a message", + ) + .required(false), ) .add_option( CreateCommandOption::new( @@ -119,6 +127,7 @@ impl RegistrableCommand for ReplyCommand { defer_response(&ctx, &command).await?; let mut content: Option = None; + let mut snippet_key: Option = None; let mut attachments: Vec = Vec::new(); let mut anonymous: bool = false; @@ -127,6 +136,9 @@ impl RegistrableCommand for ReplyCommand { CommandDataOptionValue::String(val) if option.name == "message" => { content = Some(val.clone()); } + CommandDataOptionValue::String(val) if option.name == "snippet" => { + snippet_key = Some(val.clone()); + } CommandDataOptionValue::Attachment(att_id) if option.name == "attachment" => { if let Some(att) = command.data.resolved.attachments.get(att_id) { attachments.push(att.clone()); @@ -138,6 +150,19 @@ impl RegistrableCommand for ReplyCommand { _ => {} } } + + if let Some(key) = snippet_key { + match get_snippet_by_key(&key, db_pool).await? { + Some(snippet) => { + content = Some(snippet.content); + } + None => { + return Err(ModmailError::Command(CommandError::CommandFailed( + format!("Snippet '{}' not found", key), + ))); + } + } + } let intent = extract_intent(content, &attachments).await; diff --git a/rustmail/src/commands/reply/text_command/reply.rs b/rustmail/src/commands/reply/text_command/reply.rs index 20b94369..5f7483ff 100644 --- a/rustmail/src/commands/reply/text_command/reply.rs +++ b/rustmail/src/commands/reply/text_command/reply.rs @@ -21,7 +21,24 @@ pub async fn reply( .as_ref() .ok_or_else(database_connection_failed)?; - let content = extract_reply_content(&msg.content, &config.command.prefix, &["reply", "r"]); + let mut content = extract_reply_content(&msg.content, &config.command.prefix, &["reply", "r"]); + + if let Some(text) = &content { + if let Some(stripped) = text.strip_prefix("{{").and_then(|s| s.strip_suffix("}}")) { + let snippet_key = stripped.trim(); + match get_snippet_by_key(snippet_key, db_pool).await? { + Some(snippet) => { + content = Some(snippet.content); + } + None => { + msg.reply(&ctx.http, format!("❌ Snippet `{}` not found.", snippet_key)) + .await?; + return Ok(()); + } + } + } + } + let intent = extract_intent(content, &msg.attachments).await; let Some(intent) = intent else { diff --git a/rustmail/src/commands/snippet/mod.rs b/rustmail/src/commands/snippet/mod.rs new file mode 100644 index 00000000..b8524198 --- /dev/null +++ b/rustmail/src/commands/snippet/mod.rs @@ -0,0 +1,5 @@ +pub mod slash_command; +pub mod text_command; + +pub use slash_command::*; +pub use text_command::*; diff --git a/rustmail/src/commands/snippet/slash_command/mod.rs b/rustmail/src/commands/snippet/slash_command/mod.rs new file mode 100644 index 00000000..3840f0e3 --- /dev/null +++ b/rustmail/src/commands/snippet/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod snippet; + +pub use snippet::*; diff --git a/rustmail/src/commands/snippet/slash_command/snippet.rs b/rustmail/src/commands/snippet/slash_command/snippet.rs new file mode 100644 index 00000000..387b0f25 --- /dev/null +++ b/rustmail/src/commands/snippet/slash_command/snippet.rs @@ -0,0 +1,678 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use regex::Regex; +use std::collections::HashMap; +use serenity::all::{ + CommandDataOption, CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, + CreateCommand, CreateCommandOption, CreateEmbed, CreateInteractionResponse, + CreateInteractionResponseMessage, ResolvedOption, +}; +use serenity::FutureExt; +use std::sync::Arc; + +pub struct SnippetCommand; + +#[async_trait::async_trait] +impl RegistrableCommand for SnippetCommand { + fn name(&self) -> &'static str { + "snippet" + } + + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { + get_translated_message(config, "slash_command.snippet_command_description", None, None, None, None).await + }.boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { + let name = self.name(); + let config = config.clone(); + + Box::pin(async move { + let cmd_desc = get_translated_message( + &config, + "slash_command.snippet_command_description", + None, + None, + None, + None, + ) + .await; + let create_desc = get_translated_message( + &config, + "slash_command.snippet_create_description", + None, + None, + None, + None, + ) + .await; + let list_desc = get_translated_message( + &config, + "slash_command.snippet_list_description", + None, + None, + None, + None, + ) + .await; + let show_desc = get_translated_message( + &config, + "slash_command.snippet_show_description", + None, + None, + None, + None, + ) + .await; + let edit_desc = get_translated_message( + &config, + "slash_command.snippet_edit_description", + None, + None, + None, + None, + ) + .await; + let delete_desc = get_translated_message( + &config, + "slash_command.snippet_delete_description", + None, + None, + None, + None, + ) + .await; + let key_desc = get_translated_message( + &config, + "slash_command.snippet_key_argument", + None, + None, + None, + None, + ) + .await; + let content_desc = get_translated_message( + &config, + "slash_command.snippet_content_argument", + None, + None, + None, + None, + ) + .await; + + vec![CreateCommand::new(name) + .description(cmd_desc) + .add_option( + CreateCommandOption::new( + CommandOptionType::SubCommand, + "create", + create_desc.clone(), + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "key", + key_desc.clone(), + ) + .required(true), + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "content", + content_desc.clone(), + ) + .required(true), + ), + ) + .add_option( + CreateCommandOption::new( + CommandOptionType::SubCommand, + "list", + list_desc, + ), + ) + .add_option( + CreateCommandOption::new( + CommandOptionType::SubCommand, + "show", + show_desc, + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "key", + key_desc.clone(), + ) + .required(true), + ), + ) + .add_option( + CreateCommandOption::new( + CommandOptionType::SubCommand, + "edit", + edit_desc, + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "key", + key_desc.clone(), + ) + .required(true), + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "content", + content_desc, + ) + .required(true), + ), + ) + .add_option( + CreateCommandOption::new( + CommandOptionType::SubCommand, + "delete", + delete_desc, + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "key", + key_desc, + ) + .required(true), + ), + )] + }) + } + + fn run( + &self, + ctx: &Context, + command: &CommandInteraction, + _options: &[ResolvedOption<'_>], + config: &Config, + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { + let ctx = ctx.clone(); + let command = command.clone(); + let config = config.clone(); + + Box::pin(async move { + let pool = config + .db_pool + .as_ref() + .ok_or_else(database_connection_failed)?; + + let subcommand = &command.data.options[0]; + let subcommand_name = &subcommand.name; + + match subcommand_name.as_str() { + "create" => handle_create(&ctx, &command, &command.data.options, pool, &config).await, + "list" => handle_list(&ctx, &command, pool, &config).await, + "show" => handle_show(&ctx, &command, &command.data.options, pool, &config).await, + "edit" => handle_edit(&ctx, &command, &command.data.options, pool, &config).await, + "delete" => handle_delete(&ctx, &command, &command.data.options, pool, &config).await, + _ => { + let error_msg = get_translated_message( + &config, + "snippet.unknown_subcommand", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await; + + command + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("❌ {}", error_msg)) + .ephemeral(true), + ), + ) + .await?; + Ok(()) + } + } + }) + } +} + +async fn handle_create( + ctx: &Context, + command: &CommandInteraction, + options: &Vec, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let mut key = String::new(); + let mut content = String::new(); + + if let Some(subcommand) = options.first() { + if let CommandDataOptionValue::SubCommand(sub_options) = &subcommand.value { + for option in sub_options { + match option.name.as_str() { + "key" => { + if let CommandDataOptionValue::String(val) = &option.value { + key = val.to_string(); + } + } + "content" => { + if let CommandDataOptionValue::String(val) = &option.value { + content = val.to_string(); + } + } + _ => {} + } + } + } + } + + let key_regex = Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap(); + if !key_regex.is_match(&key) { + let response = MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.invalid_key_format", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message() + .await + .ephemeral(true); + + command.create_response(&ctx.http, response).await?; + return Ok(()); + } + + if content.len() > 4000 { + let response = MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.content_too_long", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message() + .await + .ephemeral(true); + + command.create_response(&ctx.http, response).await?; + return Ok(()); + } + + let created_by = command.user.id.to_string(); + match create_snippet(&key, &content, &created_by, pool).await { + Ok(_) => { + let mut params = HashMap::new(); + params.insert("key".to_string(), key.clone()); + + let response = MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.created", + Some(¶ms), + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message() + .await + .ephemeral(true); + + command.create_response(&ctx.http, response).await?; + } + Err(e) => { + let mut params = HashMap::new(); + params.insert("error".to_string(), e.to_string()); + + let response = MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.creation_failed", + Some(¶ms), + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message() + .await + .ephemeral(true); + + command.create_response(&ctx.http, response).await?; + } + } + + Ok(()) +} + +async fn handle_list( + ctx: &Context, + command: &CommandInteraction, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let snippets = get_all_snippets(pool).await?; + + if snippets.is_empty() { + let response = MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.no_snippets_found", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message() + .await + .ephemeral(true); + + command.create_response(&ctx.http, response).await?; + return Ok(()); + } + + let mut description = String::new(); + for snippet in snippets.iter().take(25) { + let preview = if snippet.content.len() > 50 { + format!("{}...", &snippet.content[..50]) + } else { + snippet.content.clone() + }; + description.push_str(&format!("**`{}`** - {}\n", snippet.key, preview)); + } + + if snippets.len() > 25 { + description.push_str(&format!("\n*...and {} more*", snippets.len() - 25)); + } + + let title = get_translated_message( + config, + "snippet.list_title", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await; + + let embed = CreateEmbed::new() + .title(title) + .description(description) + .color(0x5865F2); + + command + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .embed(embed) + .ephemeral(true), + ), + ) + .await?; + + Ok(()) +} + +async fn handle_show( + ctx: &Context, + command: &CommandInteraction, + options: &Vec, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let mut key = String::new(); + + if let Some(subcommand) = options.first() { + if let CommandDataOptionValue::SubCommand(sub_options) = &subcommand.value { + for option in sub_options { + if option.name == "key" { + if let CommandDataOptionValue::String(val) = &option.value { + key = val.to_string(); + } + } + } + } + } + + match get_snippet_by_key(&key, pool).await? { + Some(snippet) => { + let title = get_translated_message( + config, + "snippet.show_title", + Some(&HashMap::from([("key".to_string(), snippet.key.clone())])), + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await; + + let created_by_label = get_translated_message( + config, + "snippet.created_by", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await; + + let created_at_label = get_translated_message( + config, + "snippet.created_at", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await; + + let embed = CreateEmbed::new() + .title(title) + .description(&snippet.content) + .field(created_by_label, format!("<@{}>", snippet.created_by), true) + .field(created_at_label, &snippet.created_at, true) + .color(0x5865F2); + + command + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .embed(embed) + .ephemeral(true), + ), + ) + .await?; + } + None => { + let mut params = HashMap::new(); + params.insert("key".to_string(), key.clone()); + + let response = MessageBuilder::error_message(ctx, config) + .translated_content( + "snippet.not_found", + Some(¶ms), + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message() + .await + .ephemeral(true); + + command.create_response(&ctx.http, response).await?; + } + } + + Ok(()) +} + +async fn handle_edit( + ctx: &Context, + command: &CommandInteraction, + options: &Vec, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let mut key = String::new(); + let mut content = String::new(); + + if let Some(subcommand) = options.first() { + if let CommandDataOptionValue::SubCommand(sub_options) = &subcommand.value { + for option in sub_options { + match option.name.as_str() { + "key" => { + if let CommandDataOptionValue::String(val) = &option.value { + key = val.to_string(); + } + } + "content" => { + if let CommandDataOptionValue::String(val) = &option.value { + content = val.to_string(); + } + } + _ => {} + } + } + } + } + + if content.len() > 4000 { + let response = MessageBuilder::error_message(ctx, config) + .translated_content( + "snippet.content_too_long", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message() + .await + .ephemeral(true); + + command.create_response(&ctx.http, response).await?; + return Ok(()); + } + + match update_snippet(&key, &content, pool).await { + Ok(_) => { + let mut params = HashMap::new(); + params.insert("key".to_string(), key.clone()); + + let response = MessageBuilder::success_message(ctx, config) + .translated_content( + "snippet.updated", + Some(¶ms), + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message() + .await + .ephemeral(true); + + command.create_response(&ctx.http, response).await?; + } + Err(e) => { + let mut params = HashMap::new(); + params.insert("error".to_string(), e.to_string()); + + let response = MessageBuilder::error_message(ctx, config) + .translated_content( + "snippet.update_failed", + Some(¶ms), + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message() + .await + .ephemeral(true); + + command.create_response(&ctx.http, response).await?; + } + } + + Ok(()) +} + +async fn handle_delete( + ctx: &Context, + command: &CommandInteraction, + options: &Vec, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let mut key = String::new(); + + if let Some(subcommand) = options.first() { + if let CommandDataOptionValue::SubCommand(sub_options) = &subcommand.value { + for option in sub_options { + if option.name == "key" { + if let CommandDataOptionValue::String(val) = &option.value { + key = val.to_string(); + } + } + } + } + } + + match delete_snippet(&key, pool).await { + Ok(_) => { + let mut params = HashMap::new(); + params.insert("key".to_string(), key.clone()); + + let response = MessageBuilder::success_message(ctx, config) + .translated_content( + "snippet.deleted", + Some(¶ms), + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message() + .await + .ephemeral(true); + + command.create_response(&ctx.http, response).await?; + } + Err(e) => { + let mut params = HashMap::new(); + params.insert("error".to_string(), e.to_string()); + + let response = MessageBuilder::error_message(ctx, config) + .translated_content( + "snippet.deletion_failed", + Some(¶ms), + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message() + .await + .ephemeral(true); + + command.create_response(&ctx.http, response).await?; + } + } + + Ok(()) +} diff --git a/rustmail/src/commands/snippet/text_command/mod.rs b/rustmail/src/commands/snippet/text_command/mod.rs new file mode 100644 index 00000000..3840f0e3 --- /dev/null +++ b/rustmail/src/commands/snippet/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod snippet; + +pub use snippet::*; diff --git a/rustmail/src/commands/snippet/text_command/snippet.rs b/rustmail/src/commands/snippet/text_command/snippet.rs new file mode 100644 index 00000000..7aa59560 --- /dev/null +++ b/rustmail/src/commands/snippet/text_command/snippet.rs @@ -0,0 +1,233 @@ +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::utils::*; +use regex::Regex; +use serenity::all::{Context, Message}; +use std::sync::Arc; + +pub async fn snippet_command( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { + let pool = config + .db_pool + .as_ref() + .ok_or_else(database_connection_failed)?; + + let content = match extract_reply_content(&msg.content, &config.command.prefix, &["snippet"]) { + Some(c) => c, + None => { + msg.reply( + &ctx.http, + "❌ Usage: `!snippet [args]`", + ) + .await?; + return Ok(()); + } + }; + + let mut parts = content.splitn(2, ' '); + let subcommand = parts.next().unwrap_or("").trim(); + let args = parts.next().unwrap_or("").trim(); + + match subcommand { + "create" => handle_create(&ctx, &msg, args, pool).await, + "list" => handle_list(&ctx, &msg, pool).await, + "show" => handle_show(&ctx, &msg, args, pool).await, + "edit" => handle_edit(&ctx, &msg, args, pool).await, + "delete" => handle_delete(&ctx, &msg, args, pool).await, + _ => { + msg.reply( + &ctx.http, + "❌ Unknown subcommand. Use: `create`, `list`, `show`, `edit`, or `delete`", + ) + .await?; + Ok(()) + } + } +} + +async fn handle_create( + ctx: &Context, + msg: &Message, + args: &str, + pool: &sqlx::SqlitePool, +) -> ModmailResult<()> { + let mut parts = args.splitn(2, ' '); + let key = parts.next().unwrap_or("").trim(); + let content = parts.next().unwrap_or("").trim(); + + if key.is_empty() || content.is_empty() { + msg.reply( + &ctx.http, + "❌ Usage: `!snippet create `", + ) + .await?; + return Ok(()); + } + + let key_regex = Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap(); + if !key_regex.is_match(key) { + msg.reply( + &ctx.http, + "❌ Snippet key must contain only alphanumeric characters, dashes, and underscores.", + ) + .await?; + return Ok(()); + } + + if content.len() > 4000 { + msg.reply( + &ctx.http, + "❌ Snippet content must be 4000 characters or less.", + ) + .await?; + return Ok(()); + } + + let created_by = msg.author.id.to_string(); + match create_snippet(key, content, &created_by, pool).await { + Ok(_) => { + msg.reply(&ctx.http, format!("✅ Snippet `{}` created successfully!", key)) + .await?; + } + Err(e) => { + msg.reply(&ctx.http, format!("❌ Failed to create snippet: {}", e)) + .await?; + } + } + + Ok(()) +} + +async fn handle_list( + ctx: &Context, + msg: &Message, + pool: &sqlx::SqlitePool, +) -> ModmailResult<()> { + let snippets = get_all_snippets(pool).await?; + + if snippets.is_empty() { + msg.reply(&ctx.http, "📝 No snippets found.").await?; + return Ok(()); + } + + let mut response = String::from("📝 **Available Snippets:**\n\n"); + for snippet in snippets.iter().take(25) { + let preview = if snippet.content.len() > 50 { + format!("{}...", &snippet.content[..50]) + } else { + snippet.content.clone() + }; + response.push_str(&format!("**`{}`** - {}\n", snippet.key, preview)); + } + + if snippets.len() > 25 { + response.push_str(&format!("\n*...and {} more*", snippets.len() - 25)); + } + + msg.reply(&ctx.http, response).await?; + Ok(()) +} + +async fn handle_show( + ctx: &Context, + msg: &Message, + args: &str, + pool: &sqlx::SqlitePool, +) -> ModmailResult<()> { + let key = args.trim(); + + if key.is_empty() { + msg.reply(&ctx.http, "❌ Usage: `!snippet show `") + .await?; + return Ok(()); + } + + match get_snippet_by_key(key, pool).await? { + Some(snippet) => { + let response = format!( + "📝 **Snippet: {}**\n\n{}\n\n*Created by <@{}> at {}*", + snippet.key, snippet.content, snippet.created_by, snippet.created_at + ); + msg.reply(&ctx.http, response).await?; + } + None => { + msg.reply(&ctx.http, format!("❌ Snippet `{}` not found.", key)) + .await?; + } + } + + Ok(()) +} + +async fn handle_edit( + ctx: &Context, + msg: &Message, + args: &str, + pool: &sqlx::SqlitePool, +) -> ModmailResult<()> { + let mut parts = args.splitn(2, ' '); + let key = parts.next().unwrap_or("").trim(); + let content = parts.next().unwrap_or("").trim(); + + if key.is_empty() || content.is_empty() { + msg.reply(&ctx.http, "❌ Usage: `!snippet edit `") + .await?; + return Ok(()); + } + + if content.len() > 4000 { + msg.reply( + &ctx.http, + "❌ Snippet content must be 4000 characters or less.", + ) + .await?; + return Ok(()); + } + + match update_snippet(key, content, pool).await { + Ok(_) => { + msg.reply(&ctx.http, format!("✅ Snippet `{}` updated successfully!", key)) + .await?; + } + Err(e) => { + msg.reply(&ctx.http, format!("❌ Failed to update snippet: {}", e)) + .await?; + } + } + + Ok(()) +} + +async fn handle_delete( + ctx: &Context, + msg: &Message, + args: &str, + pool: &sqlx::SqlitePool, +) -> ModmailResult<()> { + let key = args.trim(); + + if key.is_empty() { + msg.reply(&ctx.http, "❌ Usage: `!snippet delete `") + .await?; + return Ok(()); + } + + match delete_snippet(key, pool).await { + Ok(_) => { + msg.reply(&ctx.http, format!("✅ Snippet `{}` deleted successfully!", key)) + .await?; + } + Err(e) => { + msg.reply(&ctx.http, format!("❌ Failed to delete snippet: {}", e)) + .await?; + } + } + + Ok(()) +} diff --git a/rustmail/src/db/operations/mod.rs b/rustmail/src/db/operations/mod.rs index bd4d2d8c..3320a73a 100644 --- a/rustmail/src/db/operations/mod.rs +++ b/rustmail/src/db/operations/mod.rs @@ -4,6 +4,7 @@ pub mod logs; pub mod messages; pub mod reminders; pub mod scheduled; +pub mod snippets; pub mod threads; pub use features::*; @@ -12,4 +13,5 @@ pub use logs::*; pub use messages::*; pub use reminders::*; pub use scheduled::*; +pub use snippets::*; pub use threads::*; diff --git a/rustmail/src/handlers/guild_messages_handler.rs b/rustmail/src/handlers/guild_messages_handler.rs index 6ca38d68..9b2210df 100644 --- a/rustmail/src/handlers/guild_messages_handler.rs +++ b/rustmail/src/handlers/guild_messages_handler.rs @@ -78,6 +78,7 @@ impl GuildMessagesHandler { wrap_command!(lock, "take", take); wrap_command!(lock, "release", release); wrap_command!(lock, "ping", ping); + wrap_command!(lock, "snippet", snippet_command); drop(lock); h diff --git a/rustmail/src/i18n/language/en.rs b/rustmail/src/i18n/language/en.rs index 99061a19..e29756de 100644 --- a/rustmail/src/i18n/language/en.rs +++ b/rustmail/src/i18n/language/en.rs @@ -951,4 +951,131 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { "slash_command.ping_command".to_string(), DictionaryMessage::new("## Latency\n\nGateway latency: **{gateway_latency}** ms\nMinimal REST latency (GET /gateway): **{api_latency}** ms\nREST latency (message send): **{message_latency}** ms"), ); + + // Snippet commands + dict.messages.insert( + "slash_command.snippet_command_description".to_string(), + DictionaryMessage::new("Manage message snippets/templates"), + ); + dict.messages.insert( + "slash_command.snippet_create_description".to_string(), + DictionaryMessage::new("Create a new snippet"), + ); + dict.messages.insert( + "slash_command.snippet_list_description".to_string(), + DictionaryMessage::new("List all snippets"), + ); + dict.messages.insert( + "slash_command.snippet_show_description".to_string(), + DictionaryMessage::new("Show a snippet"), + ); + dict.messages.insert( + "slash_command.snippet_edit_description".to_string(), + DictionaryMessage::new("Edit a snippet"), + ); + dict.messages.insert( + "slash_command.snippet_delete_description".to_string(), + DictionaryMessage::new("Delete a snippet"), + ); + dict.messages.insert( + "slash_command.snippet_key_argument".to_string(), + DictionaryMessage::new("Snippet key (alphanumeric, dashes, underscores)"), + ); + dict.messages.insert( + "slash_command.snippet_content_argument".to_string(), + DictionaryMessage::new("Snippet content (max 4000 characters)"), + ); + dict.messages.insert( + "slash_command.reply_snippet_argument".to_string(), + DictionaryMessage::new("Use a snippet instead of typing a message"), + ); + + dict.messages.insert( + "snippet.invalid_key_format".to_string(), + DictionaryMessage::new("Snippet key must contain only alphanumeric characters, dashes, and underscores."), + ); + dict.messages.insert( + "snippet.content_too_long".to_string(), + DictionaryMessage::new("Snippet content must be 4000 characters or less."), + ); + dict.messages.insert( + "snippet.created".to_string(), + DictionaryMessage::new("Snippet `{key}` created successfully!"), + ); + dict.messages.insert( + "snippet.creation_failed".to_string(), + DictionaryMessage::new("Failed to create snippet: {error}"), + ); + dict.messages.insert( + "snippet.updated".to_string(), + DictionaryMessage::new("Snippet `{key}` updated successfully!"), + ); + dict.messages.insert( + "snippet.update_failed".to_string(), + DictionaryMessage::new("Failed to update snippet: {error}"), + ); + dict.messages.insert( + "snippet.deleted".to_string(), + DictionaryMessage::new("Snippet `{key}` deleted successfully!"), + ); + dict.messages.insert( + "snippet.deletion_failed".to_string(), + DictionaryMessage::new("Failed to delete snippet: {error}"), + ); + dict.messages.insert( + "snippet.not_found".to_string(), + DictionaryMessage::new("Snippet `{key}` not found."), + ); + dict.messages.insert( + "snippet.list_empty".to_string(), + DictionaryMessage::new("No snippets found."), + ); + dict.messages.insert( + "snippet.list_title".to_string(), + DictionaryMessage::new("📝 Available Snippets"), + ); + dict.messages.insert( + "snippet.list_more".to_string(), + DictionaryMessage::new("...and {count} more"), + ); + dict.messages.insert( + "snippet.show_title".to_string(), + DictionaryMessage::new("📝 Snippet: {key}"), + ); + dict.messages.insert( + "snippet.created_by".to_string(), + DictionaryMessage::new("Created by"), + ); + dict.messages.insert( + "snippet.created_at".to_string(), + DictionaryMessage::new("Created at"), + ); + dict.messages.insert( + "snippet.unknown_subcommand".to_string(), + DictionaryMessage::new("Unknown subcommand"), + ); + dict.messages.insert( + "snippet.text_usage".to_string(), + DictionaryMessage::new("Usage: `!snippet [args]`"), + ); + dict.messages.insert( + "snippet.text_create_usage".to_string(), + DictionaryMessage::new("Usage: `!snippet create `"), + ); + dict.messages.insert( + "snippet.text_show_usage".to_string(), + DictionaryMessage::new("Usage: `!snippet show `"), + ); + dict.messages.insert( + "snippet.text_edit_usage".to_string(), + DictionaryMessage::new("Usage: `!snippet edit `"), + ); + dict.messages.insert( + "snippet.text_delete_usage".to_string(), + DictionaryMessage::new("Usage: `!snippet delete `"), + ); + dict.messages.insert( + "snippet.unknown_text_subcommand".to_string(), + DictionaryMessage::new("Unknown subcommand. Use: `create`, `list`, `show`, `edit`, or `delete`"), + ); } diff --git a/rustmail/src/i18n/language/fr.rs b/rustmail/src/i18n/language/fr.rs index 9affdca1..0b47906f 100644 --- a/rustmail/src/i18n/language/fr.rs +++ b/rustmail/src/i18n/language/fr.rs @@ -966,4 +966,131 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { "slash_command.ping_command".to_string(), DictionaryMessage::new("## Latence\n\nLatence Gateway : **{gateway_latency}** ms.\nLatence REST minimale (GET /gateway) : **{api_latency}** ms.\nLatence REST (envoi d'un message) : **{message_latency}** ms."), ); + + // Commandes snippet + dict.messages.insert( + "slash_command.snippet_command_description".to_string(), + DictionaryMessage::new("Gérer les snippets/modèles de messages"), + ); + dict.messages.insert( + "slash_command.snippet_create_description".to_string(), + DictionaryMessage::new("Créer un nouveau snippet"), + ); + dict.messages.insert( + "slash_command.snippet_list_description".to_string(), + DictionaryMessage::new("Lister tous les snippets"), + ); + dict.messages.insert( + "slash_command.snippet_show_description".to_string(), + DictionaryMessage::new("Afficher un snippet"), + ); + dict.messages.insert( + "slash_command.snippet_edit_description".to_string(), + DictionaryMessage::new("Modifier un snippet"), + ); + dict.messages.insert( + "slash_command.snippet_delete_description".to_string(), + DictionaryMessage::new("Supprimer un snippet"), + ); + dict.messages.insert( + "slash_command.snippet_key_argument".to_string(), + DictionaryMessage::new("Clé du snippet (alphanumériques, tirets, underscores)"), + ); + dict.messages.insert( + "slash_command.snippet_content_argument".to_string(), + DictionaryMessage::new("Contenu du snippet (max 4000 caractères)"), + ); + dict.messages.insert( + "slash_command.reply_snippet_argument".to_string(), + DictionaryMessage::new("Utiliser un snippet au lieu de taper un message"), + ); + + dict.messages.insert( + "snippet.invalid_key_format".to_string(), + DictionaryMessage::new("La clé du snippet doit contenir uniquement des caractères alphanumériques, des tirets et des underscores."), + ); + dict.messages.insert( + "snippet.content_too_long".to_string(), + DictionaryMessage::new("Le contenu du snippet doit faire 4000 caractères ou moins."), + ); + dict.messages.insert( + "snippet.created".to_string(), + DictionaryMessage::new("Snippet `{key}` créé avec succès !"), + ); + dict.messages.insert( + "snippet.creation_failed".to_string(), + DictionaryMessage::new("Échec de la création du snippet : {error}"), + ); + dict.messages.insert( + "snippet.updated".to_string(), + DictionaryMessage::new("Snippet `{key}` modifié avec succès !"), + ); + dict.messages.insert( + "snippet.update_failed".to_string(), + DictionaryMessage::new("Échec de la modification du snippet : {error}"), + ); + dict.messages.insert( + "snippet.deleted".to_string(), + DictionaryMessage::new("Snippet `{key}` supprimé avec succès !"), + ); + dict.messages.insert( + "snippet.deletion_failed".to_string(), + DictionaryMessage::new("Échec de la suppression du snippet : {error}"), + ); + dict.messages.insert( + "snippet.not_found".to_string(), + DictionaryMessage::new("Snippet `{key}` introuvable."), + ); + dict.messages.insert( + "snippet.list_empty".to_string(), + DictionaryMessage::new("Aucun snippet trouvé."), + ); + dict.messages.insert( + "snippet.list_title".to_string(), + DictionaryMessage::new("📝 Snippets disponibles"), + ); + dict.messages.insert( + "snippet.list_more".to_string(), + DictionaryMessage::new("...et {count} de plus"), + ); + dict.messages.insert( + "snippet.show_title".to_string(), + DictionaryMessage::new("📝 Snippet : {key}"), + ); + dict.messages.insert( + "snippet.created_by".to_string(), + DictionaryMessage::new("Créé par"), + ); + dict.messages.insert( + "snippet.created_at".to_string(), + DictionaryMessage::new("Créé le"), + ); + dict.messages.insert( + "snippet.unknown_subcommand".to_string(), + DictionaryMessage::new("Sous-commande inconnue"), + ); + dict.messages.insert( + "snippet.text_usage".to_string(), + DictionaryMessage::new("Usage : `!snippet [args]`"), + ); + dict.messages.insert( + "snippet.text_create_usage".to_string(), + DictionaryMessage::new("Usage : `!snippet create `"), + ); + dict.messages.insert( + "snippet.text_show_usage".to_string(), + DictionaryMessage::new("Usage : `!snippet show `"), + ); + dict.messages.insert( + "snippet.text_edit_usage".to_string(), + DictionaryMessage::new("Usage : `!snippet edit `"), + ); + dict.messages.insert( + "snippet.text_delete_usage".to_string(), + DictionaryMessage::new("Usage : `!snippet delete `"), + ); + dict.messages.insert( + "snippet.unknown_text_subcommand".to_string(), + DictionaryMessage::new("Sous-commande inconnue. Utilisez : `create`, `list`, `show`, `edit`, ou `delete`"), + ); } diff --git a/rustmail/src/types/mod.rs b/rustmail/src/types/mod.rs index 13633bf0..861a3ee9 100644 --- a/rustmail/src/types/mod.rs +++ b/rustmail/src/types/mod.rs @@ -1,7 +1,9 @@ pub mod bot; pub mod logs; +pub mod snippet; pub mod threads_status; pub use bot::*; pub use logs::*; +pub use snippet::*; pub use threads_status::*; diff --git a/rustmail/src/types/snippet.rs b/rustmail/src/types/snippet.rs new file mode 100644 index 00000000..327fbdc6 --- /dev/null +++ b/rustmail/src/types/snippet.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Snippet { + pub id: i64, + pub key: String, + pub content: String, + pub created_by: String, + pub created_at: String, + pub updated_at: String, +} diff --git a/rustmail_types/src/api/mod.rs b/rustmail_types/src/api/mod.rs index 60ee249d..e1aa46b5 100644 --- a/rustmail_types/src/api/mod.rs +++ b/rustmail_types/src/api/mod.rs @@ -12,3 +12,13 @@ pub struct ConfigResponse { pub reminders: ReminderConfig, pub logs: LogsConfig, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Snippet { + pub id: i64, + pub key: String, + pub content: String, + pub created_by: String, + pub created_at: String, + pub updated_at: String, +} From 2a0299ea0d6ff3bfa44bb4974c789e61d99dd94f Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Wed, 26 Nov 2025 11:24:03 +0100 Subject: [PATCH 2/9] refactor(gitignore): add '/db/' folder instead of 'db/' --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 785bb6e9..457faf8f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ config.toml config.toml.backup node_modules .env -db/ +/db/ .idea .vscode/ package-lock.json From 6c976d1f47e3aa9386ad7aadd44f20433a696f78 Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Wed, 26 Nov 2025 11:24:16 +0100 Subject: [PATCH 3/9] feat(db): add CRUD for snippets --- rustmail/src/db/operations/snippets.rs | 103 +++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 rustmail/src/db/operations/snippets.rs diff --git a/rustmail/src/db/operations/snippets.rs b/rustmail/src/db/operations/snippets.rs new file mode 100644 index 00000000..b644f42b --- /dev/null +++ b/rustmail/src/db/operations/snippets.rs @@ -0,0 +1,103 @@ +use crate::prelude::errors::*; +use crate::prelude::types::*; + +pub async fn create_snippet( + key: &str, + content: &str, + created_by: &str, + pool: &sqlx::SqlitePool, +) -> ModmailResult<()> { + sqlx::query!( + r#" + INSERT INTO snippets (key, content, created_by) + VALUES (?, ?, ?) + "#, + key, + content, + created_by + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_snippet_by_key( + key: &str, + pool: &sqlx::SqlitePool, +) -> ModmailResult> { + let snippet = sqlx::query_as!( + Snippet, + r#" + SELECT id as "id!", key, content, created_by, created_at as "created_at: String", updated_at as "updated_at: String" + FROM snippets + WHERE key = ? + "#, + key + ) + .fetch_optional(pool) + .await?; + + Ok(snippet) +} + +pub async fn get_all_snippets(pool: &sqlx::SqlitePool) -> ModmailResult> { + let snippets = sqlx::query_as!( + Snippet, + r#" + SELECT id as "id!", key, content, created_by, created_at as "created_at: String", updated_at as "updated_at: String" + FROM snippets + ORDER BY created_at DESC + "# + ) + .fetch_all(pool) + .await?; + + Ok(snippets) +} + +pub async fn update_snippet( + key: &str, + content: &str, + pool: &sqlx::SqlitePool, +) -> ModmailResult<()> { + let result = sqlx::query!( + r#" + UPDATE snippets + SET content = ?, updated_at = CURRENT_TIMESTAMP + WHERE key = ? + "#, + content, + key + ) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(ModmailError::Database(DatabaseError::NotFound( + "Snippet not found".to_string(), + ))); + } + + Ok(()) +} + +pub async fn delete_snippet(key: &str, pool: &sqlx::SqlitePool) -> ModmailResult<()> { + let result = sqlx::query!( + r#" + DELETE FROM snippets + WHERE key = ? + "#, + key + ) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(ModmailError::Database(DatabaseError::NotFound( + "Snippet not found".to_string(), + ))); + } + + Ok(()) +} From 316b1576d1ecfb5796387eca0e84a458e8c5de99 Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Wed, 26 Nov 2025 11:25:04 +0100 Subject: [PATCH 4/9] refactor(trads): add all i18n keys for snippets implementation --- rustmail/src/i18n/language/en.rs | 36 +++++++++++++++++++++++++++++--- rustmail/src/i18n/language/fr.rs | 32 ++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/rustmail/src/i18n/language/en.rs b/rustmail/src/i18n/language/en.rs index e29756de..af3c327e 100644 --- a/rustmail/src/i18n/language/en.rs +++ b/rustmail/src/i18n/language/en.rs @@ -97,6 +97,10 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { DictionaryMessage::new("Failed to create thread") .with_description("An error occurred while creating the support thread"), ); + dict.messages.insert( + "snippet.already_exist".to_string(), + DictionaryMessage::new("The snippet with key '{key}' already exists."), + ); dict.messages.insert( "thread.user_still_in_server".to_string(), DictionaryMessage::new("User still in the server.") @@ -722,6 +726,10 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { "slash_command.reply_message_argument_description".to_string(), DictionaryMessage::new("The content of the message to send to the user"), ); + dict.messages.insert( + "slash_command.reply_snippet_argument_description".to_string(), + DictionaryMessage::new("Use a snippet instead of typing a message"), + ); dict.messages.insert( "slash_command.reply_attachment_argument_description".to_string(), DictionaryMessage::new("An optional attachment to send to the user"), @@ -952,11 +960,25 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { DictionaryMessage::new("## Latency\n\nGateway latency: **{gateway_latency}** ms\nMinimal REST latency (GET /gateway): **{api_latency}** ms\nREST latency (message send): **{message_latency}** ms"), ); - // Snippet commands dict.messages.insert( "slash_command.snippet_command_description".to_string(), DictionaryMessage::new("Manage message snippets/templates"), ); + dict.messages.insert( + "slash_command.snippet_command_help".to_string(), + DictionaryMessage::new( + "Manage message snippets/templates\n\n\ + **Subcommands:**\n\ + • `/snippet create ` - Create a new snippet\n\ + • `/snippet list` - List all available snippets\n\ + • `/snippet show ` - Display a specific snippet's content\n\ + • `/snippet edit ` - Update an existing snippet\n\ + • `/snippet delete ` - Delete a snippet\n\n\ + **Usage:**\n\ + • Slash command: `/reply snippet:`\n\ + • Text command: `!reply {{key}}`", + ), + ); dict.messages.insert( "slash_command.snippet_create_description".to_string(), DictionaryMessage::new("Create a new snippet"), @@ -992,7 +1014,9 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "snippet.invalid_key_format".to_string(), - DictionaryMessage::new("Snippet key must contain only alphanumeric characters, dashes, and underscores."), + DictionaryMessage::new( + "Snippet key must contain only alphanumeric characters, dashes, and underscores.", + ), ); dict.messages.insert( "snippet.content_too_long".to_string(), @@ -1030,6 +1054,10 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { "snippet.list_empty".to_string(), DictionaryMessage::new("No snippets found."), ); + dict.messages.insert( + "snippet.no_snippets_found".to_string(), + DictionaryMessage::new("No snippets found."), + ); dict.messages.insert( "snippet.list_title".to_string(), DictionaryMessage::new("📝 Available Snippets"), @@ -1076,6 +1104,8 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "snippet.unknown_text_subcommand".to_string(), - DictionaryMessage::new("Unknown subcommand. Use: `create`, `list`, `show`, `edit`, or `delete`"), + DictionaryMessage::new( + "Unknown subcommand. Use: `create`, `list`, `show`, `edit`, or `delete`", + ), ); } diff --git a/rustmail/src/i18n/language/fr.rs b/rustmail/src/i18n/language/fr.rs index 0b47906f..4135a6a4 100644 --- a/rustmail/src/i18n/language/fr.rs +++ b/rustmail/src/i18n/language/fr.rs @@ -748,6 +748,10 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { "slash_command.reply_message_argument_description".to_string(), DictionaryMessage::new("Le message à envoyer à l'utilisateur"), ); + dict.messages.insert( + "slash_command.reply_snippet_argument_description".to_string(), + DictionaryMessage::new("Utiliser un snippet au lieu d'écrire un message"), + ); dict.messages.insert( "slash_command.reply_attachment_argument_description".to_string(), DictionaryMessage::new("Une pièce jointe à envoyer avec le message"), @@ -967,11 +971,25 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { DictionaryMessage::new("## Latence\n\nLatence Gateway : **{gateway_latency}** ms.\nLatence REST minimale (GET /gateway) : **{api_latency}** ms.\nLatence REST (envoi d'un message) : **{message_latency}** ms."), ); - // Commandes snippet dict.messages.insert( "slash_command.snippet_command_description".to_string(), DictionaryMessage::new("Gérer les snippets/modèles de messages"), ); + dict.messages.insert( + "slash_command.snippet_command_help".to_string(), + DictionaryMessage::new( + "Gérer les snippets/modèles de messages\n\n\ + **Sous-commandes :**\n\ + • `/snippet create ` - Créer un nouveau snippet\n\ + • `/snippet list` - Lister tous les snippets disponibles\n\ + • `/snippet show ` - Afficher le contenu d'un snippet spécifique\n\ + • `/snippet edit ` - Modifier un snippet existant\n\ + • `/snippet delete ` - Supprimer un snippet\n\n\ + **Utilisation :**\n\ + • Commande slash : `/reply snippet:`\n\ + • Commande texte : `!reply {{clé}}`", + ), + ); dict.messages.insert( "slash_command.snippet_create_description".to_string(), DictionaryMessage::new("Créer un nouveau snippet"), @@ -1021,6 +1039,10 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { "snippet.creation_failed".to_string(), DictionaryMessage::new("Échec de la création du snippet : {error}"), ); + dict.messages.insert( + "snippet.already_exist".to_string(), + DictionaryMessage::new("Le snippet `{key}` existe déjà."), + ); dict.messages.insert( "snippet.updated".to_string(), DictionaryMessage::new("Snippet `{key}` modifié avec succès !"), @@ -1045,6 +1067,10 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { "snippet.list_empty".to_string(), DictionaryMessage::new("Aucun snippet trouvé."), ); + dict.messages.insert( + "snippet.no_snippets_found".to_string(), + DictionaryMessage::new("Aucun snippet trouvé."), + ); dict.messages.insert( "snippet.list_title".to_string(), DictionaryMessage::new("📝 Snippets disponibles"), @@ -1091,6 +1117,8 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "snippet.unknown_text_subcommand".to_string(), - DictionaryMessage::new("Sous-commande inconnue. Utilisez : `create`, `list`, `show`, `edit`, ou `delete`"), + DictionaryMessage::new( + "Sous-commande inconnue. Utilisez : `create`, `list`, `show`, `edit`, ou `delete`", + ), ); } From 41d677be86903edbfd681839529fb3060528b3e3 Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Wed, 26 Nov 2025 11:25:37 +0100 Subject: [PATCH 5/9] refactor(errors): add all snippet's errors to error framework --- rustmail/src/errors/dictionary.rs | 16 ++++++++++++++++ rustmail/src/errors/types.rs | 10 ++++++++++ 2 files changed, 26 insertions(+) diff --git a/rustmail/src/errors/dictionary.rs b/rustmail/src/errors/dictionary.rs index 73f72eaf..fa84f5a7 100644 --- a/rustmail/src/errors/dictionary.rs +++ b/rustmail/src/errors/dictionary.rs @@ -233,6 +233,22 @@ impl DictionaryManager { ("release.ticket_already_taken".to_string(), None) } CommandError::AlertSetFailed => ("alert.alert_set_failed".to_string(), None), + CommandError::SnippetNotFound(snippet) => { + let mut params = HashMap::new(); + params.insert("key".to_string(), snippet.clone()); + ("snippet.not_found".to_string(), Some(params)) + } + CommandError::InvalidSnippetKeyFormat => { + ("snippet.invalid_key_format".to_string(), None) + } + CommandError::SnippetContentTooLong => { + ("snippet.content_too_long".to_string(), None) + } + CommandError::SnippetAlreadyExists(snippet) => { + let mut params = HashMap::new(); + params.insert("key".to_string(), snippet.clone()); + ("snippet.already_exist".to_string(), Some(params)) + } _ => ("command.invalid_format".to_string(), None), }, ModmailError::Thread(thread_err) => match thread_err { diff --git a/rustmail/src/errors/types.rs b/rustmail/src/errors/types.rs index c04a47b3..80b7bbea 100644 --- a/rustmail/src/errors/types.rs +++ b/rustmail/src/errors/types.rs @@ -64,6 +64,10 @@ pub enum CommandError { TicketAlreadyTaken, TicketAlreadyReleased, AlertSetFailed, + SnippetNotFound(String), + SnippetAlreadyExists(String), + InvalidSnippetKeyFormat, + SnippetContentTooLong, } #[derive(Debug, Clone)] @@ -172,6 +176,12 @@ impl fmt::Display for CommandError { CommandError::TicketAlreadyTaken => write!(f, "Ticket already taken"), CommandError::TicketAlreadyReleased => write!(f, "Ticket already released"), CommandError::AlertSetFailed => write!(f, "Alert set failed"), + CommandError::SnippetNotFound(name) => write!(f, "Snippet not found: {}", name), + CommandError::InvalidSnippetKeyFormat => write!(f, "Invalid snippet key format"), + CommandError::SnippetContentTooLong => write!(f, "Snippet content is too long"), + CommandError::SnippetAlreadyExists(name) => { + write!(f, "Snippet already exists: {}", name) + } } } } From 093ab11d1cfa86e5c80325448b90a6ba0ebdcbcb Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Wed, 26 Nov 2025 11:26:18 +0100 Subject: [PATCH 6/9] refactor(snippet): use MessageBuilder instead of building embeds manually --- .../commands/snippet/slash_command/snippet.rs | 526 +++++++----------- .../commands/snippet/text_command/snippet.rs | 302 +++++++--- 2 files changed, 423 insertions(+), 405 deletions(-) diff --git a/rustmail/src/commands/snippet/slash_command/snippet.rs b/rustmail/src/commands/snippet/slash_command/snippet.rs index 387b0f25..c57bc617 100644 --- a/rustmail/src/commands/snippet/slash_command/snippet.rs +++ b/rustmail/src/commands/snippet/slash_command/snippet.rs @@ -5,13 +5,12 @@ use crate::prelude::errors::*; use crate::prelude::i18n::*; use crate::prelude::utils::*; use regex::Regex; -use std::collections::HashMap; +use serenity::FutureExt; use serenity::all::{ CommandDataOption, CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, - CreateCommand, CreateCommandOption, CreateEmbed, CreateInteractionResponse, - CreateInteractionResponseMessage, ResolvedOption, + CreateCommand, CreateCommandOption, ResolvedOption, }; -use serenity::FutureExt; +use std::collections::HashMap; use std::sync::Arc; pub struct SnippetCommand; @@ -24,8 +23,17 @@ impl RegistrableCommand for SnippetCommand { fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { async move { - get_translated_message(config, "slash_command.snippet_command_description", None, None, None, None).await - }.boxed() + get_translated_message( + config, + "slash_command.snippet_command_help", + None, + None, + None, + None, + ) + .await + } + .boxed() } fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { @@ -106,91 +114,79 @@ impl RegistrableCommand for SnippetCommand { ) .await; - vec![CreateCommand::new(name) - .description(cmd_desc) - .add_option( - CreateCommandOption::new( - CommandOptionType::SubCommand, - "create", - create_desc.clone(), - ) - .add_sub_option( + vec![ + CreateCommand::new(name) + .description(cmd_desc) + .add_option( CreateCommandOption::new( - CommandOptionType::String, - "key", - key_desc.clone(), + CommandOptionType::SubCommand, + "create", + create_desc.clone(), ) - .required(true), - ) - .add_sub_option( - CreateCommandOption::new( - CommandOptionType::String, - "content", - content_desc.clone(), + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "key", + key_desc.clone(), + ) + .required(true), ) - .required(true), - ), - ) - .add_option( - CreateCommandOption::new( + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "content", + content_desc.clone(), + ) + .required(true), + ), + ) + .add_option(CreateCommandOption::new( CommandOptionType::SubCommand, "list", list_desc, - ), - ) - .add_option( - CreateCommandOption::new( - CommandOptionType::SubCommand, - "show", - show_desc, - ) - .add_sub_option( - CreateCommandOption::new( - CommandOptionType::String, - "key", - key_desc.clone(), - ) - .required(true), - ), - ) - .add_option( - CreateCommandOption::new( - CommandOptionType::SubCommand, - "edit", - edit_desc, - ) - .add_sub_option( - CreateCommandOption::new( - CommandOptionType::String, - "key", - key_desc.clone(), - ) - .required(true), + )) + .add_option( + CreateCommandOption::new(CommandOptionType::SubCommand, "show", show_desc) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "key", + key_desc.clone(), + ) + .required(true), + ), ) - .add_sub_option( - CreateCommandOption::new( - CommandOptionType::String, - "content", - content_desc, - ) - .required(true), - ), - ) - .add_option( - CreateCommandOption::new( - CommandOptionType::SubCommand, - "delete", - delete_desc, + .add_option( + CreateCommandOption::new(CommandOptionType::SubCommand, "edit", edit_desc) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "key", + key_desc.clone(), + ) + .required(true), + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "content", + content_desc, + ) + .required(true), + ), ) - .add_sub_option( + .add_option( CreateCommandOption::new( - CommandOptionType::String, - "key", - key_desc, + CommandOptionType::SubCommand, + "delete", + delete_desc, ) - .required(true), + .add_sub_option( + CreateCommandOption::new(CommandOptionType::String, "key", key_desc) + .required(true), + ), ), - )] + ] }) } @@ -212,35 +208,36 @@ impl RegistrableCommand for SnippetCommand { .as_ref() .ok_or_else(database_connection_failed)?; + defer_response(&ctx, &command).await?; + let subcommand = &command.data.options[0]; let subcommand_name = &subcommand.name; match subcommand_name.as_str() { - "create" => handle_create(&ctx, &command, &command.data.options, pool, &config).await, + "create" => { + handle_create(&ctx, &command, &command.data.options, pool, &config).await + } "list" => handle_list(&ctx, &command, pool, &config).await, "show" => handle_show(&ctx, &command, &command.data.options, pool, &config).await, "edit" => handle_edit(&ctx, &command, &command.data.options, pool, &config).await, - "delete" => handle_delete(&ctx, &command, &command.data.options, pool, &config).await, + "delete" => { + handle_delete(&ctx, &command, &command.data.options, pool, &config).await + } _ => { - let error_msg = get_translated_message( - &config, - "snippet.unknown_subcommand", - None, - Some(command.user.id), - command.guild_id.map(|g| g.get()), - ) - .await; - - command - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content(format!("❌ {}", error_msg)) - .ephemeral(true), - ), + let response = MessageBuilder::system_message(&ctx, &config) + .translated_content( + "snippet.unknown_subcommand", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), ) - .await?; + .await + .to_channel(command.channel_id) + .build_interaction_message_followup() + .await; + + command.create_followup(&ctx.http, response).await?; + Ok(()) } } @@ -280,82 +277,39 @@ async fn handle_create( let key_regex = Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap(); if !key_regex.is_match(&key) { - let response = MessageBuilder::system_message(ctx, config) - .translated_content( - "snippet.invalid_key_format", - None, - Some(command.user.id), - command.guild_id.map(|g| g.get()), - ) - .await - .to_channel(command.channel_id) - .build_interaction_message() - .await - .ephemeral(true); - - command.create_response(&ctx.http, response).await?; - return Ok(()); + return Err(ModmailError::Command(CommandError::InvalidSnippetKeyFormat)); } if content.len() > 4000 { - let response = MessageBuilder::system_message(ctx, config) - .translated_content( - "snippet.content_too_long", - None, - Some(command.user.id), - command.guild_id.map(|g| g.get()), - ) - .await - .to_channel(command.channel_id) - .build_interaction_message() - .await - .ephemeral(true); - - command.create_response(&ctx.http, response).await?; - return Ok(()); + return Err(ModmailError::Command(CommandError::SnippetContentTooLong)); } let created_by = command.user.id.to_string(); match create_snippet(&key, &content, &created_by, pool).await { - Ok(_) => { - let mut params = HashMap::new(); - params.insert("key".to_string(), key.clone()); - - let response = MessageBuilder::system_message(ctx, config) - .translated_content( - "snippet.created", - Some(¶ms), - Some(command.user.id), - command.guild_id.map(|g| g.get()), - ) - .await - .to_channel(command.channel_id) - .build_interaction_message() - .await - .ephemeral(true); - - command.create_response(&ctx.http, response).await?; + Ok(_) => {} + Err(_) => { + return Err(ModmailError::Command(CommandError::SnippetAlreadyExists( + key.to_string(), + ))); } - Err(e) => { - let mut params = HashMap::new(); - params.insert("error".to_string(), e.to_string()); + } - let response = MessageBuilder::system_message(ctx, config) - .translated_content( - "snippet.creation_failed", - Some(¶ms), - Some(command.user.id), - command.guild_id.map(|g| g.get()), - ) - .await - .to_channel(command.channel_id) - .build_interaction_message() - .await - .ephemeral(true); + let mut params = HashMap::new(); + params.insert("key".to_string(), key.clone()); - command.create_response(&ctx.http, response).await?; - } - } + let response = MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.created", + Some(¶ms), + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message_followup() + .await; + + command.create_followup(&ctx.http, response).await?; Ok(()) } @@ -378,52 +332,35 @@ async fn handle_list( ) .await .to_channel(command.channel_id) - .build_interaction_message() - .await - .ephemeral(true); + .build_interaction_message_followup() + .await; - command.create_response(&ctx.http, response).await?; + command.create_followup(&ctx.http, response).await?; return Ok(()); } - let mut description = String::new(); - for snippet in snippets.iter().take(25) { - let preview = if snippet.content.len() > 50 { - format!("{}...", &snippet.content[..50]) - } else { - snippet.content.clone() - }; - description.push_str(&format!("**`{}`** - {}\n", snippet.key, preview)); - } - - if snippets.len() > 25 { - description.push_str(&format!("\n*...and {} more*", snippets.len() - 25)); - } - let title = get_translated_message( config, "snippet.list_title", None, Some(command.user.id), command.guild_id.map(|g| g.get()), + None, ) .await; - let embed = CreateEmbed::new() - .title(title) - .description(description) - .color(0x5865F2); - - command - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .embed(embed) - .ephemeral(true), - ), - ) - .await?; + let mut content = format!("**{}**\n\n", title); + for (index, snippet) in snippets.iter().enumerate() { + content.push_str(&format!("`{}` - {}\n\n", index + 1, snippet.key)); + } + + let response = MessageBuilder::system_message(ctx, config) + .content(content) + .to_channel(command.channel_id) + .build_interaction_message_followup() + .await; + + command.create_followup(&ctx.http, response).await?; Ok(()) } @@ -457,6 +394,7 @@ async fn handle_show( Some(&HashMap::from([("key".to_string(), snippet.key.clone())])), Some(command.user.id), command.guild_id.map(|g| g.get()), + None, ) .await; @@ -466,6 +404,7 @@ async fn handle_show( None, Some(command.user.id), command.guild_id.map(|g| g.get()), + None, ) .await; @@ -475,45 +414,32 @@ async fn handle_show( None, Some(command.user.id), command.guild_id.map(|g| g.get()), + None, ) .await; - let embed = CreateEmbed::new() - .title(title) - .description(&snippet.content) - .field(created_by_label, format!("<@{}>", snippet.created_by), true) - .field(created_at_label, &snippet.created_at, true) - .color(0x5865F2); - - command - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .embed(embed) - .ephemeral(true), - ), - ) - .await?; - } - None => { - let mut params = HashMap::new(); - params.insert("key".to_string(), key.clone()); - - let response = MessageBuilder::error_message(ctx, config) - .translated_content( - "snippet.not_found", - Some(¶ms), - Some(command.user.id), - command.guild_id.map(|g| g.get()), - ) - .await + let content = format!( + "**{}**\n\n{}\n\n*{}: <@{}> | {}: {}*", + title, + snippet.content, + created_by_label, + snippet.created_by, + created_at_label, + snippet.created_at + ); + + let response = MessageBuilder::system_message(ctx, config) + .content(content) .to_channel(command.channel_id) - .build_interaction_message() - .await - .ephemeral(true); + .build_interaction_message_followup() + .await; - command.create_response(&ctx.http, response).await?; + command.create_followup(&ctx.http, response).await?; + } + None => { + return Err(ModmailError::Command(CommandError::SnippetNotFound( + key.to_string(), + ))); } } @@ -551,63 +477,34 @@ async fn handle_edit( } if content.len() > 4000 { - let response = MessageBuilder::error_message(ctx, config) - .translated_content( - "snippet.content_too_long", - None, - Some(command.user.id), - command.guild_id.map(|g| g.get()), - ) - .await - .to_channel(command.channel_id) - .build_interaction_message() - .await - .ephemeral(true); - - command.create_response(&ctx.http, response).await?; - return Ok(()); + return Err(ModmailError::Command(CommandError::SnippetContentTooLong)); } match update_snippet(&key, &content, pool).await { - Ok(_) => { - let mut params = HashMap::new(); - params.insert("key".to_string(), key.clone()); - - let response = MessageBuilder::success_message(ctx, config) - .translated_content( - "snippet.updated", - Some(¶ms), - Some(command.user.id), - command.guild_id.map(|g| g.get()), - ) - .await - .to_channel(command.channel_id) - .build_interaction_message() - .await - .ephemeral(true); - - command.create_response(&ctx.http, response).await?; + Ok(_) => {} + Err(_) => { + return Err(ModmailError::Command(CommandError::SnippetNotFound( + key.to_string(), + ))); } - Err(e) => { - let mut params = HashMap::new(); - params.insert("error".to_string(), e.to_string()); - - let response = MessageBuilder::error_message(ctx, config) - .translated_content( - "snippet.update_failed", - Some(¶ms), - Some(command.user.id), - command.guild_id.map(|g| g.get()), - ) - .await - .to_channel(command.channel_id) - .build_interaction_message() - .await - .ephemeral(true); + }; - command.create_response(&ctx.http, response).await?; - } - } + let mut params = HashMap::new(); + params.insert("key".to_string(), key.clone()); + + let response = MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.updated", + Some(¶ms), + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message_followup() + .await; + + command.create_followup(&ctx.http, response).await?; Ok(()) } @@ -634,45 +531,30 @@ async fn handle_delete( } match delete_snippet(&key, pool).await { - Ok(_) => { - let mut params = HashMap::new(); - params.insert("key".to_string(), key.clone()); - - let response = MessageBuilder::success_message(ctx, config) - .translated_content( - "snippet.deleted", - Some(¶ms), - Some(command.user.id), - command.guild_id.map(|g| g.get()), - ) - .await - .to_channel(command.channel_id) - .build_interaction_message() - .await - .ephemeral(true); - - command.create_response(&ctx.http, response).await?; + Ok(_) => {} + Err(_) => { + return Err(ModmailError::Command(CommandError::SnippetNotFound( + key.to_string(), + ))); } - Err(e) => { - let mut params = HashMap::new(); - params.insert("error".to_string(), e.to_string()); - - let response = MessageBuilder::error_message(ctx, config) - .translated_content( - "snippet.deletion_failed", - Some(¶ms), - Some(command.user.id), - command.guild_id.map(|g| g.get()), - ) - .await - .to_channel(command.channel_id) - .build_interaction_message() - .await - .ephemeral(true); + }; - command.create_response(&ctx.http, response).await?; - } - } + let mut params = HashMap::new(); + params.insert("key".to_string(), key.clone()); + + let response = MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.deleted", + Some(¶ms), + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message_followup() + .await; + + command.create_followup(&ctx.http, response).await?; Ok(()) } diff --git a/rustmail/src/commands/snippet/text_command/snippet.rs b/rustmail/src/commands/snippet/text_command/snippet.rs index 7aa59560..2eeec41c 100644 --- a/rustmail/src/commands/snippet/text_command/snippet.rs +++ b/rustmail/src/commands/snippet/text_command/snippet.rs @@ -2,9 +2,11 @@ use crate::prelude::config::*; use crate::prelude::db::*; use crate::prelude::errors::*; use crate::prelude::handlers::*; +use crate::prelude::i18n::*; use crate::prelude::utils::*; use regex::Regex; use serenity::all::{Context, Message}; +use std::collections::HashMap; use std::sync::Arc; pub async fn snippet_command( @@ -21,11 +23,17 @@ pub async fn snippet_command( let content = match extract_reply_content(&msg.content, &config.command.prefix, &["snippet"]) { Some(c) => c, None => { - msg.reply( - &ctx.http, - "❌ Usage: `!snippet [args]`", - ) - .await?; + MessageBuilder::system_message(&ctx, config) + .translated_content( + "snippet.text_usage", + None, + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + ) + .await + .reply_to(msg) + .send(true) + .await?; return Ok(()); } }; @@ -35,17 +43,23 @@ pub async fn snippet_command( let args = parts.next().unwrap_or("").trim(); match subcommand { - "create" => handle_create(&ctx, &msg, args, pool).await, - "list" => handle_list(&ctx, &msg, pool).await, - "show" => handle_show(&ctx, &msg, args, pool).await, - "edit" => handle_edit(&ctx, &msg, args, pool).await, - "delete" => handle_delete(&ctx, &msg, args, pool).await, + "create" => handle_create(&ctx, &msg, args, pool, config).await, + "list" => handle_list(&ctx, &msg, pool, config).await, + "show" => handle_show(&ctx, &msg, args, pool, config).await, + "edit" => handle_edit(&ctx, &msg, args, pool, config).await, + "delete" => handle_delete(&ctx, &msg, args, pool, config).await, _ => { - msg.reply( - &ctx.http, - "❌ Unknown subcommand. Use: `create`, `list`, `show`, `edit`, or `delete`", - ) - .await?; + MessageBuilder::system_message(&ctx, config) + .translated_content( + "snippet.unknown_text_subcommand", + None, + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + ) + .await + .reply_to(msg) + .send(true) + .await?; Ok(()) } } @@ -56,51 +70,61 @@ async fn handle_create( msg: &Message, args: &str, pool: &sqlx::SqlitePool, + config: &Config, ) -> ModmailResult<()> { let mut parts = args.splitn(2, ' '); let key = parts.next().unwrap_or("").trim(); let content = parts.next().unwrap_or("").trim(); if key.is_empty() || content.is_empty() { - msg.reply( - &ctx.http, - "❌ Usage: `!snippet create `", - ) - .await?; + MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.text_create_usage", + None, + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + ) + .await + .reply_to(msg.clone()) + .send(true) + .await?; return Ok(()); } let key_regex = Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap(); if !key_regex.is_match(key) { - msg.reply( - &ctx.http, - "❌ Snippet key must contain only alphanumeric characters, dashes, and underscores.", - ) - .await?; - return Ok(()); + return Err(ModmailError::Command(CommandError::InvalidSnippetKeyFormat)); } if content.len() > 4000 { - msg.reply( - &ctx.http, - "❌ Snippet content must be 4000 characters or less.", - ) - .await?; - return Ok(()); + return Err(ModmailError::Command(CommandError::SnippetContentTooLong)); } let created_by = msg.author.id.to_string(); match create_snippet(key, content, &created_by, pool).await { - Ok(_) => { - msg.reply(&ctx.http, format!("✅ Snippet `{}` created successfully!", key)) - .await?; - } - Err(e) => { - msg.reply(&ctx.http, format!("❌ Failed to create snippet: {}", e)) - .await?; + Ok(_) => {} + Err(_) => { + return Err(ModmailError::Command(CommandError::SnippetAlreadyExists( + key.to_string(), + ))); } } + let mut params = HashMap::new(); + params.insert("key".to_string(), key.to_string()); + + MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.created", + Some(¶ms), + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + ) + .await + .reply_to(msg.clone()) + .send(true) + .await?; + Ok(()) } @@ -108,29 +132,46 @@ async fn handle_list( ctx: &Context, msg: &Message, pool: &sqlx::SqlitePool, + config: &Config, ) -> ModmailResult<()> { let snippets = get_all_snippets(pool).await?; if snippets.is_empty() { - msg.reply(&ctx.http, "📝 No snippets found.").await?; + MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.list_empty", + None, + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + ) + .await + .reply_to(msg.clone()) + .send(true) + .await?; return Ok(()); } - let mut response = String::from("📝 **Available Snippets:**\n\n"); - for snippet in snippets.iter().take(25) { - let preview = if snippet.content.len() > 50 { - format!("{}...", &snippet.content[..50]) - } else { - snippet.content.clone() - }; - response.push_str(&format!("**`{}`** - {}\n", snippet.key, preview)); - } + let title = get_translated_message( + config, + "snippet.list_title", + None, + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + None, + ) + .await; - if snippets.len() > 25 { - response.push_str(&format!("\n*...and {} more*", snippets.len() - 25)); + let mut response = format!("{}\n\n", title); + for (index, snippet) in snippets.iter().enumerate() { + response.push_str(&format!("`{}` {}\n\n", index + 1, snippet.key)); } - msg.reply(&ctx.http, response).await?; + MessageBuilder::system_message(ctx, config) + .content(response) + .reply_to(msg.clone()) + .send(true) + .await?; + Ok(()) } @@ -139,26 +180,80 @@ async fn handle_show( msg: &Message, args: &str, pool: &sqlx::SqlitePool, + config: &Config, ) -> ModmailResult<()> { let key = args.trim(); if key.is_empty() { - msg.reply(&ctx.http, "❌ Usage: `!snippet show `") + MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.text_show_usage", + None, + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + ) + .await + .reply_to(msg.clone()) + .send(true) .await?; return Ok(()); } match get_snippet_by_key(key, pool).await? { Some(snippet) => { + let mut params = HashMap::new(); + params.insert("key".to_string(), snippet.key.clone()); + + let title = get_translated_message( + config, + "snippet.show_title", + Some(¶ms), + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + None, + ) + .await; + + let created_by_label = get_translated_message( + config, + "snippet.created_by", + None, + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + None, + ) + .await; + + let created_at_label = get_translated_message( + config, + "snippet.created_at", + None, + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + None, + ) + .await; + let response = format!( - "📝 **Snippet: {}**\n\n{}\n\n*Created by <@{}> at {}*", - snippet.key, snippet.content, snippet.created_by, snippet.created_at + "{}\n\n{}\n\n*{}: <@{}> | {}: {}*", + title, + snippet.content, + created_by_label, + snippet.created_by, + created_at_label, + snippet.created_at ); - msg.reply(&ctx.http, response).await?; + + MessageBuilder::system_message(ctx, config) + .content(response) + .reply_to(msg.clone()) + .send(true) + .await?; } None => { - msg.reply(&ctx.http, format!("❌ Snippet `{}` not found.", key)) - .await?; + return Err(ModmailError::Command(CommandError::SnippetNotFound( + key.to_string(), + ))); } } @@ -170,36 +265,54 @@ async fn handle_edit( msg: &Message, args: &str, pool: &sqlx::SqlitePool, + config: &Config, ) -> ModmailResult<()> { let mut parts = args.splitn(2, ' '); let key = parts.next().unwrap_or("").trim(); let content = parts.next().unwrap_or("").trim(); if key.is_empty() || content.is_empty() { - msg.reply(&ctx.http, "❌ Usage: `!snippet edit `") + MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.text_edit_usage", + None, + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + ) + .await + .reply_to(msg.clone()) + .send(true) .await?; return Ok(()); } if content.len() > 4000 { - msg.reply( - &ctx.http, - "❌ Snippet content must be 4000 characters or less.", - ) - .await?; - return Ok(()); + return Err(ModmailError::Command(CommandError::SnippetContentTooLong)); } match update_snippet(key, content, pool).await { - Ok(_) => { - msg.reply(&ctx.http, format!("✅ Snippet `{}` updated successfully!", key)) - .await?; - } - Err(e) => { - msg.reply(&ctx.http, format!("❌ Failed to update snippet: {}", e)) - .await?; + Ok(_) => {} + Err(_) => { + return Err(ModmailError::Command(CommandError::SnippetNotFound( + key.to_string(), + ))); } - } + }; + + let mut params = HashMap::new(); + params.insert("key".to_string(), key.to_string()); + + MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.updated", + Some(¶ms), + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + ) + .await + .reply_to(msg.clone()) + .send(true) + .await?; Ok(()) } @@ -209,25 +322,48 @@ async fn handle_delete( msg: &Message, args: &str, pool: &sqlx::SqlitePool, + config: &Config, ) -> ModmailResult<()> { let key = args.trim(); if key.is_empty() { - msg.reply(&ctx.http, "❌ Usage: `!snippet delete `") + MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.text_delete_usage", + None, + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + ) + .await + .reply_to(msg.clone()) + .send(true) .await?; return Ok(()); } - match delete_snippet(key, pool).await { - Ok(_) => { - msg.reply(&ctx.http, format!("✅ Snippet `{}` deleted successfully!", key)) - .await?; + match delete_snippet(&key, pool).await { + Ok(_) => {} + Err(_) => { + return Err(ModmailError::Command(CommandError::SnippetNotFound( + key.to_string(), + ))); } - Err(e) => { - msg.reply(&ctx.http, format!("❌ Failed to delete snippet: {}", e)) - .await?; - } - } + }; + + let mut params = HashMap::new(); + params.insert("key".to_string(), key.to_string()); + + MessageBuilder::system_message(ctx, config) + .translated_content( + "snippet.deleted", + Some(¶ms), + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + ) + .await + .reply_to(msg.clone()) + .send(true) + .await?; Ok(()) } From 94117e0d675654df1efaec5ff316da3bba8e68dc Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Wed, 26 Nov 2025 11:27:22 +0100 Subject: [PATCH 7/9] refactor(reply): implement snippet logic to be able to send snippet message through reply command --- .../src/commands/reply/slash_command/reply.rs | 15 ++++++++++++--- rustmail/src/commands/reply/text_command/reply.rs | 6 +++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/rustmail/src/commands/reply/slash_command/reply.rs b/rustmail/src/commands/reply/slash_command/reply.rs index 510aa38a..7f80f007 100644 --- a/rustmail/src/commands/reply/slash_command/reply.rs +++ b/rustmail/src/commands/reply/slash_command/reply.rs @@ -60,6 +60,15 @@ impl RegistrableCommand for ReplyCommand { None, ) .await; + let snippet_desc = get_translated_message( + &config, + "slash_command.reply_snippet_argument_description", + None, + None, + None, + None, + ) + .await; let anonymous_desc = get_translated_message( &config, "slash_command.reply_anonymous_argument_description", @@ -85,7 +94,7 @@ impl RegistrableCommand for ReplyCommand { CreateCommandOption::new( CommandOptionType::String, "snippet", - "Use a snippet instead of typing a message", + snippet_desc, ) .required(false), ) @@ -157,8 +166,8 @@ impl RegistrableCommand for ReplyCommand { content = Some(snippet.content); } None => { - return Err(ModmailError::Command(CommandError::CommandFailed( - format!("Snippet '{}' not found", key), + return Err(ModmailError::Command(CommandError::SnippetNotFound( + key.to_string(), ))); } } diff --git a/rustmail/src/commands/reply/text_command/reply.rs b/rustmail/src/commands/reply/text_command/reply.rs index 5f7483ff..554e9a91 100644 --- a/rustmail/src/commands/reply/text_command/reply.rs +++ b/rustmail/src/commands/reply/text_command/reply.rs @@ -31,9 +31,9 @@ pub async fn reply( content = Some(snippet.content); } None => { - msg.reply(&ctx.http, format!("❌ Snippet `{}` not found.", snippet_key)) - .await?; - return Ok(()); + return Err(ModmailError::Command(CommandError::SnippetNotFound( + snippet_key.to_string(), + ))); } } } From 7fd57a4d2ff3d3a62da8a329ba25fe261ce47bf9 Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Wed, 26 Nov 2025 11:27:54 +0100 Subject: [PATCH 8/9] refactor(anonreply): implement snippet logic to be able to send snippet message through anonreply command --- .../anonreply/text_command/anonreply.rs | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/rustmail/src/commands/anonreply/text_command/anonreply.rs b/rustmail/src/commands/anonreply/text_command/anonreply.rs index fede788a..17d1dd51 100644 --- a/rustmail/src/commands/anonreply/text_command/anonreply.rs +++ b/rustmail/src/commands/anonreply/text_command/anonreply.rs @@ -21,8 +21,9 @@ pub async fn anonreply( .as_ref() .ok_or_else(database_connection_failed)?; - let mut content = extract_reply_content(&msg.content, &config.command.prefix, &["anonreply", "ar"]); - + let mut content = + extract_reply_content(&msg.content, &config.command.prefix, &["anonreply", "ar"]); + if let Some(text) = &content { if let Some(stripped) = text.strip_prefix("{{").and_then(|s| s.strip_suffix("}}")) { let snippet_key = stripped.trim(); @@ -31,9 +32,9 @@ pub async fn anonreply( content = Some(snippet.content); } None => { - msg.reply(&ctx.http, format!("❌ Snippet `{}` not found.", snippet_key)) - .await?; - return Ok(()); + return Err(ModmailError::Command(CommandError::SnippetNotFound( + snippet_key.to_string(), + ))); } } } @@ -42,20 +43,7 @@ pub async fn anonreply( let intent = extract_intent(content, &msg.attachments).await; let Some(intent) = intent else { - MessageBuilder::system_message(&ctx, config) - .translated_content( - "reply.missing_content", - None, - Some(msg.author.id), - msg.guild_id.map(|g| g.get()), - ) - .await - .color(0xFF0000) - .reply_to(msg.clone()) - .send_and_forget() - .await; - - return Err(validation_failed("Missing content")); + return Err(ModmailError::Message(MessageError::MessageEmpty)); }; let thread = fetch_thread(db_pool, &msg.channel_id.to_string()).await?; From 97a35184cfe1a1d4c8ee905b3cf0b09693209aa1 Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Wed, 26 Nov 2025 11:39:30 +0100 Subject: [PATCH 9/9] fix(sqlx): add missing sqlx sql files for compiling offline --- ...7c60424475d42fec68d891852cecc41e15c0a.json | 12 +++++ ...62c7229a0d4b04387acf03179c7449054a58a.json | 12 +++++ ...f9b5c2aa49cc2bb3ac886496adb0e220bf431.json | 50 +++++++++++++++++++ ...850548928cbfa6501788cd1c3b6d8b3fd6b19.json | 50 +++++++++++++++++++ ...39a46668ffc4cda6d9bec0fa1965317123c4e.json | 12 +++++ 5 files changed, 136 insertions(+) create mode 100644 .sqlx/query-0d8e91df51026f5a3400697b0797c60424475d42fec68d891852cecc41e15c0a.json create mode 100644 .sqlx/query-2b110957c98cd482afcc93eba2762c7229a0d4b04387acf03179c7449054a58a.json create mode 100644 .sqlx/query-68cdb069424b9fdd6468b28d8cdf9b5c2aa49cc2bb3ac886496adb0e220bf431.json create mode 100644 .sqlx/query-6cf54c26731b1dd92650445bac0850548928cbfa6501788cd1c3b6d8b3fd6b19.json create mode 100644 .sqlx/query-8b8bfcba2e57300a0f448095a1739a46668ffc4cda6d9bec0fa1965317123c4e.json diff --git a/.sqlx/query-0d8e91df51026f5a3400697b0797c60424475d42fec68d891852cecc41e15c0a.json b/.sqlx/query-0d8e91df51026f5a3400697b0797c60424475d42fec68d891852cecc41e15c0a.json new file mode 100644 index 00000000..f6b8e6dd --- /dev/null +++ b/.sqlx/query-0d8e91df51026f5a3400697b0797c60424475d42fec68d891852cecc41e15c0a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO snippets (key, content, created_by)\n VALUES (?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "0d8e91df51026f5a3400697b0797c60424475d42fec68d891852cecc41e15c0a" +} diff --git a/.sqlx/query-2b110957c98cd482afcc93eba2762c7229a0d4b04387acf03179c7449054a58a.json b/.sqlx/query-2b110957c98cd482afcc93eba2762c7229a0d4b04387acf03179c7449054a58a.json new file mode 100644 index 00000000..c794ebb2 --- /dev/null +++ b/.sqlx/query-2b110957c98cd482afcc93eba2762c7229a0d4b04387acf03179c7449054a58a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM snippets\n WHERE key = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "2b110957c98cd482afcc93eba2762c7229a0d4b04387acf03179c7449054a58a" +} diff --git a/.sqlx/query-68cdb069424b9fdd6468b28d8cdf9b5c2aa49cc2bb3ac886496adb0e220bf431.json b/.sqlx/query-68cdb069424b9fdd6468b28d8cdf9b5c2aa49cc2bb3ac886496adb0e220bf431.json new file mode 100644 index 00000000..f0f0fef5 --- /dev/null +++ b/.sqlx/query-68cdb069424b9fdd6468b28d8cdf9b5c2aa49cc2bb3ac886496adb0e220bf431.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT id as \"id!\", key, content, created_by, created_at as \"created_at: String\", updated_at as \"updated_at: String\"\n FROM snippets\n WHERE key = ?\n ", + "describe": { + "columns": [ + { + "name": "id!", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "key", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "content", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at: String", + "ordinal": 4, + "type_info": "Datetime" + }, + { + "name": "updated_at: String", + "ordinal": 5, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + false + ] + }, + "hash": "68cdb069424b9fdd6468b28d8cdf9b5c2aa49cc2bb3ac886496adb0e220bf431" +} diff --git a/.sqlx/query-6cf54c26731b1dd92650445bac0850548928cbfa6501788cd1c3b6d8b3fd6b19.json b/.sqlx/query-6cf54c26731b1dd92650445bac0850548928cbfa6501788cd1c3b6d8b3fd6b19.json new file mode 100644 index 00000000..79517eb6 --- /dev/null +++ b/.sqlx/query-6cf54c26731b1dd92650445bac0850548928cbfa6501788cd1c3b6d8b3fd6b19.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT id as \"id!\", key, content, created_by, created_at as \"created_at: String\", updated_at as \"updated_at: String\"\n FROM snippets\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "name": "id!", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "key", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "content", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at: String", + "ordinal": 4, + "type_info": "Datetime" + }, + { + "name": "updated_at: String", + "ordinal": 5, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "6cf54c26731b1dd92650445bac0850548928cbfa6501788cd1c3b6d8b3fd6b19" +} diff --git a/.sqlx/query-8b8bfcba2e57300a0f448095a1739a46668ffc4cda6d9bec0fa1965317123c4e.json b/.sqlx/query-8b8bfcba2e57300a0f448095a1739a46668ffc4cda6d9bec0fa1965317123c4e.json new file mode 100644 index 00000000..937f0cc4 --- /dev/null +++ b/.sqlx/query-8b8bfcba2e57300a0f448095a1739a46668ffc4cda6d9bec0fa1965317123c4e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE snippets\n SET content = ?, updated_at = CURRENT_TIMESTAMP\n WHERE key = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "8b8bfcba2e57300a0f448095a1739a46668ffc4cda6d9bec0fa1965317123c4e" +}