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 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" +} 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..17d1dd51 100644 --- a/rustmail/src/commands/anonreply/text_command/anonreply.rs +++ b/rustmail/src/commands/anonreply/text_command/anonreply.rs @@ -21,24 +21,29 @@ 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 => { + return Err(ModmailError::Command(CommandError::SnippetNotFound( + snippet_key.to_string(), + ))); + } + } + } + } + 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?; 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..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", @@ -79,7 +88,15 @@ impl RegistrableCommand for ReplyCommand { "message", message_desc, ) - .required(true), + .required(false), + ) + .add_option( + CreateCommandOption::new( + CommandOptionType::String, + "snippet", + snippet_desc, + ) + .required(false), ) .add_option( CreateCommandOption::new( @@ -119,6 +136,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 +145,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 +159,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::SnippetNotFound( + key.to_string(), + ))); + } + } + } 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..554e9a91 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 => { + return Err(ModmailError::Command(CommandError::SnippetNotFound( + snippet_key.to_string(), + ))); + } + } + } + } + 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..c57bc617 --- /dev/null +++ b/rustmail/src/commands/snippet/slash_command/snippet.rs @@ -0,0 +1,560 @@ +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 serenity::FutureExt; +use serenity::all::{ + CommandDataOption, CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, + CreateCommand, CreateCommandOption, ResolvedOption, +}; +use std::collections::HashMap; +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_help", + 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)?; + + 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 + } + "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 response = MessageBuilder::system_message(&ctx, &config) + .translated_content( + "snippet.unknown_subcommand", + None, + 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(()) + } + } + }) + } +} + +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) { + return Err(ModmailError::Command(CommandError::InvalidSnippetKeyFormat)); + } + + if content.len() > 4000 { + return Err(ModmailError::Command(CommandError::SnippetContentTooLong)); + } + + let created_by = command.user.id.to_string(); + match create_snippet(&key, &content, &created_by, pool).await { + Ok(_) => {} + Err(_) => { + return Err(ModmailError::Command(CommandError::SnippetAlreadyExists( + key.to_string(), + ))); + } + } + + 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_followup() + .await; + + command.create_followup(&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_followup() + .await; + + command.create_followup(&ctx.http, response).await?; + return Ok(()); + } + + let title = get_translated_message( + config, + "snippet.list_title", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), + None, + ) + .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(()) +} + +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()), + None, + ) + .await; + + let created_by_label = get_translated_message( + config, + "snippet.created_by", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), + None, + ) + .await; + + let created_at_label = get_translated_message( + config, + "snippet.created_at", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), + None, + ) + .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_followup() + .await; + + command.create_followup(&ctx.http, response).await?; + } + None => { + return Err(ModmailError::Command(CommandError::SnippetNotFound( + key.to_string(), + ))); + } + } + + 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 { + return Err(ModmailError::Command(CommandError::SnippetContentTooLong)); + } + + match update_snippet(&key, &content, pool).await { + Ok(_) => {} + Err(_) => { + return Err(ModmailError::Command(CommandError::SnippetNotFound( + key.to_string(), + ))); + } + }; + + 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(()) +} + +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(_) => {} + Err(_) => { + return Err(ModmailError::Command(CommandError::SnippetNotFound( + key.to_string(), + ))); + } + }; + + 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/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..2eeec41c --- /dev/null +++ b/rustmail/src/commands/snippet/text_command/snippet.rs @@ -0,0 +1,369 @@ +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( + 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 => { + 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(()); + } + }; + + 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, 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, + _ => { + 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(()) + } + } +} + +async fn handle_create( + ctx: &Context, + 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() { + 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) { + return Err(ModmailError::Command(CommandError::InvalidSnippetKeyFormat)); + } + + if content.len() > 4000 { + return Err(ModmailError::Command(CommandError::SnippetContentTooLong)); + } + + let created_by = msg.author.id.to_string(); + match create_snippet(key, content, &created_by, pool).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(()) +} + +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() { + 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 title = get_translated_message( + config, + "snippet.list_title", + None, + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + None, + ) + .await; + + let mut response = format!("{}\n\n", title); + for (index, snippet) in snippets.iter().enumerate() { + response.push_str(&format!("`{}` {}\n\n", index + 1, snippet.key)); + } + + MessageBuilder::system_message(ctx, config) + .content(response) + .reply_to(msg.clone()) + .send(true) + .await?; + + Ok(()) +} + +async fn handle_show( + ctx: &Context, + msg: &Message, + args: &str, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let key = args.trim(); + + if key.is_empty() { + 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!( + "{}\n\n{}\n\n*{}: <@{}> | {}: {}*", + title, + snippet.content, + created_by_label, + snippet.created_by, + created_at_label, + snippet.created_at + ); + + MessageBuilder::system_message(ctx, config) + .content(response) + .reply_to(msg.clone()) + .send(true) + .await?; + } + None => { + return Err(ModmailError::Command(CommandError::SnippetNotFound( + key.to_string(), + ))); + } + } + + Ok(()) +} + +async fn handle_edit( + ctx: &Context, + 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() { + 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 { + return Err(ModmailError::Command(CommandError::SnippetContentTooLong)); + } + + match update_snippet(key, content, pool).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(()) +} + +async fn handle_delete( + ctx: &Context, + msg: &Message, + args: &str, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let key = args.trim(); + + if key.is_empty() { + 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(_) => {} + 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.deleted", + Some(¶ms), + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + ) + .await + .reply_to(msg.clone()) + .send(true) + .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/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(()) +} 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) + } } } } 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..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"), @@ -951,4 +959,153 @@ 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"), ); + + 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"), + ); + 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.no_snippets_found".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..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"), @@ -966,4 +970,155 @@ 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."), ); + + 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"), + ); + 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.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 !"), + ); + 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.no_snippets_found".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, +}