diff --git a/crates/rustmail/src/api/handler/categories/categories.rs b/crates/rustmail/src/api/handler/categories/categories.rs new file mode 100644 index 00000000..0a38f97a --- /dev/null +++ b/crates/rustmail/src/api/handler/categories/categories.rs @@ -0,0 +1,255 @@ +use crate::db::operations::ticket_categories::CATEGORY_BUTTON_HARD_LIMIT; +use crate::db::operations::{ + count_enabled_categories, create_category, delete_category, get_category_by_id, + get_category_by_name, get_category_settings, list_all_categories, update_category, + update_category_settings, +}; +use crate::db::repr::{TicketCategory, TicketCategorySettings}; +use crate::prelude::types::*; +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; +use std::sync::Arc; +use tokio::sync::Mutex; + +async fn pool(bot_state: &Arc>) -> Result { + let state_lock = bot_state.lock().await; + match &state_lock.db_pool { + Some(p) => Ok(p.clone()), + None => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "Database not initialized".to_string(), + )), + } +} + +fn internal(e: impl ToString) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +} + +#[derive(Serialize, Deserialize)] +pub struct CategoryDto { + pub id: String, + pub name: String, + pub description: Option, + pub emoji: Option, + pub discord_category_id: String, + pub position: i64, + pub enabled: bool, + pub created_at: i64, + pub updated_at: i64, +} + +impl From for CategoryDto { + fn from(c: TicketCategory) -> Self { + Self { + id: c.id, + name: c.name, + description: c.description, + emoji: c.emoji, + discord_category_id: c.discord_category_id, + position: c.position, + enabled: c.enabled, + created_at: c.created_at, + updated_at: c.updated_at, + } + } +} + +pub async fn list_categories_handler( + State(bot_state): State>>, +) -> Result>, (StatusCode, String)> { + let p = pool(&bot_state).await?; + let cats = list_all_categories(&p).await.map_err(internal)?; + Ok(Json(cats.into_iter().map(CategoryDto::from).collect())) +} + +#[derive(Deserialize)] +pub struct CreateCategoryRequest { + pub name: String, + pub description: Option, + pub emoji: Option, + pub discord_category_id: String, +} + +pub async fn create_category_handler( + State(bot_state): State>>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let name = req.name.trim(); + if name.is_empty() { + return Err((StatusCode::BAD_REQUEST, "Name required".to_string())); + } + if req.discord_category_id.parse::().is_err() { + return Err(( + StatusCode::BAD_REQUEST, + "Invalid discord_category_id".to_string(), + )); + } + let p = pool(&bot_state).await?; + + let enabled_count = count_enabled_categories(&p).await.map_err(internal)?; + if enabled_count as usize >= CATEGORY_BUTTON_HARD_LIMIT { + return Err(( + StatusCode::BAD_REQUEST, + format!("Maximum {} enabled categories", CATEGORY_BUTTON_HARD_LIMIT), + )); + } + + if get_category_by_name(name, &p) + .await + .map_err(internal)? + .is_some() + { + return Err(( + StatusCode::CONFLICT, + "Category with this name already exists".to_string(), + )); + } + + let created = create_category( + name, + req.description.as_deref(), + req.emoji.as_deref(), + &req.discord_category_id, + &p, + ) + .await + .map_err(internal)?; + Ok(Json(created.into())) +} + +#[derive(Deserialize)] +pub struct UpdateCategoryRequest { + pub name: Option, + pub description: Option>, + pub emoji: Option>, + pub discord_category_id: Option, + pub position: Option, + pub enabled: Option, +} + +pub async fn update_category_handler( + State(bot_state): State>>, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let p = pool(&bot_state).await?; + + let existing = get_category_by_id(&id, &p) + .await + .map_err(internal)? + .ok_or((StatusCode::NOT_FOUND, "Category not found".to_string()))?; + + let trimmed_name = req.name.as_deref().map(str::trim); + if let Some(name) = trimmed_name { + if name.is_empty() { + return Err((StatusCode::BAD_REQUEST, "Name required".to_string())); + } + + if let Some(conflict) = get_category_by_name(name, &p).await.map_err(internal)? { + if conflict.id != existing.id { + return Err(( + StatusCode::CONFLICT, + "Category with this name already exists".to_string(), + )); + } + } + } + + if let Some(true) = req.enabled { + if !existing.enabled { + let enabled_count = count_enabled_categories(&p).await.map_err(internal)?; + if enabled_count as usize >= CATEGORY_BUTTON_HARD_LIMIT { + return Err(( + StatusCode::BAD_REQUEST, + format!("Maximum {} enabled categories", CATEGORY_BUTTON_HARD_LIMIT), + )); + } + } + } + + if let Some(ref did) = req.discord_category_id { + if did.parse::().is_err() { + return Err(( + StatusCode::BAD_REQUEST, + "Invalid discord_category_id".to_string(), + )); + } + } + + update_category( + &id, + trimmed_name, + req.description.as_ref().map(|o| o.as_deref()), + req.emoji.as_ref().map(|o| o.as_deref()), + req.discord_category_id.as_deref(), + req.position, + req.enabled, + &p, + ) + .await + .map_err(internal)?; + + let updated = get_category_by_id(&id, &p) + .await + .map_err(internal)? + .ok_or((StatusCode::NOT_FOUND, "Category not found".to_string()))?; + Ok(Json(updated.into())) +} + +pub async fn delete_category_handler( + State(bot_state): State>>, + Path(id): Path, +) -> Result { + let p = pool(&bot_state).await?; + let existed = delete_category(&id, &p).await.map_err(internal)?; + if existed { + Ok(StatusCode::NO_CONTENT) + } else { + Err((StatusCode::NOT_FOUND, "Category not found".to_string())) + } +} + +#[derive(Serialize, Deserialize)] +pub struct CategorySettingsDto { + pub enabled: bool, + pub selection_timeout_s: i64, +} + +impl From for CategorySettingsDto { + fn from(s: TicketCategorySettings) -> Self { + Self { + enabled: s.enabled, + selection_timeout_s: s.selection_timeout_s, + } + } +} + +pub async fn get_category_settings_handler( + State(bot_state): State>>, +) -> Result, (StatusCode, String)> { + let p = pool(&bot_state).await?; + let s = get_category_settings(&p).await.map_err(internal)?; + Ok(Json(s.into())) +} + +pub async fn update_category_settings_handler( + State(bot_state): State>>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + if req.selection_timeout_s < 30 { + return Err(( + StatusCode::BAD_REQUEST, + "selection_timeout_s must be >= 30".to_string(), + )); + } + let p = pool(&bot_state).await?; + update_category_settings(req.enabled, req.selection_timeout_s, &p) + .await + .map_err(internal)?; + let s = get_category_settings(&p).await.map_err(internal)?; + Ok(Json(s.into())) +} diff --git a/crates/rustmail/src/api/handler/categories/mod.rs b/crates/rustmail/src/api/handler/categories/mod.rs new file mode 100644 index 00000000..c5436771 --- /dev/null +++ b/crates/rustmail/src/api/handler/categories/mod.rs @@ -0,0 +1,3 @@ +mod categories; + +pub use categories::*; diff --git a/crates/rustmail/src/api/handler/mod.rs b/crates/rustmail/src/api/handler/mod.rs index 1d9f19be..2afc135f 100644 --- a/crates/rustmail/src/api/handler/mod.rs +++ b/crates/rustmail/src/api/handler/mod.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod apikeys; pub mod auth; pub mod bot; +pub mod categories; pub mod externals; pub mod panel; pub mod user; @@ -10,6 +11,7 @@ pub use admin::*; pub use apikeys::*; pub use auth::*; pub use bot::*; +pub use categories::*; pub use externals::*; pub use panel::*; pub use user::*; diff --git a/crates/rustmail/src/api/router.rs b/crates/rustmail/src/api/router.rs index 9ce14537..61286b6e 100644 --- a/crates/rustmail/src/api/router.rs +++ b/crates/rustmail/src/api/router.rs @@ -7,6 +7,7 @@ use tokio::sync::Mutex; pub fn create_api_router(bot_state: Arc>) -> Router { let admin_router = create_admin_router(bot_state.clone()); let apikeys_router = create_apikeys_router(bot_state.clone()); + let categories_router = create_categories_router(bot_state.clone()); let bot_router = create_bot_router(bot_state.clone()); let auth_router = create_auth_router(); let panel_router = create_panel_router(bot_state.clone()); @@ -16,6 +17,7 @@ pub fn create_api_router(bot_state: Arc>) -> Router { let app = Router::new() .nest("/api/admin", admin_router) .nest("/api/apikeys", apikeys_router) + .nest("/api/categories", categories_router) .nest("/api/bot", bot_router) .nest("/api/auth", auth_router) .nest("/api/panel", panel_router) diff --git a/crates/rustmail/src/api/routes/categories.rs b/crates/rustmail/src/api/routes/categories.rs new file mode 100644 index 00000000..f51f8824 --- /dev/null +++ b/crates/rustmail/src/api/routes/categories.rs @@ -0,0 +1,27 @@ +use crate::prelude::api::*; +use crate::prelude::types::*; +use axum::Router; +use axum::routing::{delete, get, patch, post, put}; +use rustmail_types::api::panel_permissions::PanelPermission; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub fn create_categories_router(bot_state: Arc>) -> Router>> { + Router::new() + .route("/", get(list_categories_handler)) + .route("/", post(create_category_handler)) + .route("/{id}", patch(update_category_handler)) + .route("/{id}", delete(delete_category_handler)) + .route("/settings", get(get_category_settings_handler)) + .route("/settings", put(update_category_settings_handler)) + .layer(axum::middleware::from_fn_with_state( + bot_state.clone(), + move |state, jar, req, next| { + require_panel_permission(state, jar, req, next, PanelPermission::ManageCategories) + }, + )) + .layer(axum::middleware::from_fn_with_state( + bot_state, + auth_middleware, + )) +} diff --git a/crates/rustmail/src/api/routes/mod.rs b/crates/rustmail/src/api/routes/mod.rs index 1d9f19be..2afc135f 100644 --- a/crates/rustmail/src/api/routes/mod.rs +++ b/crates/rustmail/src/api/routes/mod.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod apikeys; pub mod auth; pub mod bot; +pub mod categories; pub mod externals; pub mod panel; pub mod user; @@ -10,6 +11,7 @@ pub use admin::*; pub use apikeys::*; pub use auth::*; pub use bot::*; +pub use categories::*; pub use externals::*; pub use panel::*; pub use user::*; diff --git a/crates/rustmail/src/api/utils/panel_permissions.rs b/crates/rustmail/src/api/utils/panel_permissions.rs index 80f2d17c..d33db331 100644 --- a/crates/rustmail/src/api/utils/panel_permissions.rs +++ b/crates/rustmail/src/api/utils/panel_permissions.rs @@ -72,6 +72,7 @@ pub async fn get_user_panel_permissions( PanelPermission::ManageTickets, PanelPermission::ManageApiKeys, PanelPermission::ManagePermissions, + PanelPermission::ManageCategories, ]; cache.insert(user_id.to_string(), permissions.clone()).await; return permissions; @@ -85,6 +86,7 @@ pub async fn get_user_panel_permissions( PanelPermission::ManageTickets, PanelPermission::ManageApiKeys, PanelPermission::ManagePermissions, + PanelPermission::ManageCategories, ]; } diff --git a/crates/rustmail/src/bot.rs b/crates/rustmail/src/bot.rs index f2acd81d..00fb8ff1 100644 --- a/crates/rustmail/src/bot.rs +++ b/crates/rustmail/src/bot.rs @@ -147,6 +147,7 @@ pub async fn run_bot( registry.register_command(PingCommand); registry.register_command(SnippetCommand); registry.register_command(StatusCommand); + registry.register_command(CategoryCommand); let registry = Arc::new(registry); diff --git a/crates/rustmail/src/commands/category/mod.rs b/crates/rustmail/src/commands/category/mod.rs new file mode 100644 index 00000000..b8524198 --- /dev/null +++ b/crates/rustmail/src/commands/category/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/crates/rustmail/src/commands/category/slash_command/category.rs b/crates/rustmail/src/commands/category/slash_command/category.rs new file mode 100644 index 00000000..71a9a8a9 --- /dev/null +++ b/crates/rustmail/src/commands/category/slash_command/category.rs @@ -0,0 +1,571 @@ +use crate::db::operations::ticket_categories::CATEGORY_BUTTON_HARD_LIMIT; +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 serenity::FutureExt; +use serenity::all::{ + CommandDataOption, CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, + CreateCommand, CreateCommandOption, ResolvedOption, +}; +use std::collections::HashMap; +use std::sync::Arc; + +pub struct CategoryCommand; + +#[async_trait::async_trait] +impl RegistrableCommand for CategoryCommand { + fn name(&self) -> &'static str { + "category" + } + + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { get_translated_message(config, "help.category", None, None, None, None).await } + .boxed() + } + + fn register(&self, _config: &Config) -> BoxFuture<'_, Vec> { + Box::pin(async move { + vec![ + CreateCommand::new("category") + .description("Manage ticket categories") + .add_option( + CreateCommandOption::new( + CommandOptionType::SubCommand, + "create", + "Create a new category", + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "discord_category_id", + "Discord category channel ID", + ) + .required(true), + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "name", + "Category name", + ) + .required(true), + ) + .add_sub_option(CreateCommandOption::new( + CommandOptionType::String, + "description", + "Category description", + )) + .add_sub_option(CreateCommandOption::new( + CommandOptionType::String, + "emoji", + "Button emoji", + )), + ) + .add_option(CreateCommandOption::new( + CommandOptionType::SubCommand, + "list", + "List all categories", + )) + .add_option( + CreateCommandOption::new( + CommandOptionType::SubCommand, + "delete", + "Delete a category", + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "name", + "Category name", + ) + .required(true), + ), + ) + .add_option( + CreateCommandOption::new( + CommandOptionType::SubCommand, + "rename", + "Rename a category", + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "old_name", + "Current name", + ) + .required(true), + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "new_name", + "New name", + ) + .required(true), + ), + ) + .add_option( + CreateCommandOption::new( + CommandOptionType::SubCommand, + "move", + "Move a category position", + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "name", + "Category name", + ) + .required(true), + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::Integer, + "position", + "New position", + ) + .required(true), + ), + ) + .add_option( + CreateCommandOption::new( + CommandOptionType::SubCommand, + "enable", + "Enable a category", + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "name", + "Category name", + ) + .required(true), + ), + ) + .add_option( + CreateCommandOption::new( + CommandOptionType::SubCommand, + "disable", + "Disable a category", + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "name", + "Category name", + ) + .required(true), + ), + ) + .add_option( + CreateCommandOption::new( + CommandOptionType::SubCommand, + "timeout", + "Set selection timeout in seconds", + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::Integer, + "seconds", + "Seconds", + ) + .required(true), + ), + ) + .add_option(CreateCommandOption::new( + CommandOptionType::SubCommand, + "on", + "Enable category selection feature", + )) + .add_option(CreateCommandOption::new( + CommandOptionType::SubCommand, + "off", + "Disable category selection feature", + )), + ] + }) + } + + 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 = match command.data.options.first() { + Some(s) => s, + None => return Ok(()), + }; + + match subcommand.name.as_str() { + "create" => sub_create(&ctx, &command, &command.data.options, pool, &config).await, + "list" => sub_list(&ctx, &command, pool, &config).await, + "delete" => sub_delete(&ctx, &command, &command.data.options, pool, &config).await, + "rename" => sub_rename(&ctx, &command, &command.data.options, pool, &config).await, + "move" => sub_move(&ctx, &command, &command.data.options, pool, &config).await, + "enable" => { + sub_enable_one(&ctx, &command, &command.data.options, pool, &config, true).await + } + "disable" => { + sub_enable_one(&ctx, &command, &command.data.options, pool, &config, false) + .await + } + "timeout" => { + sub_timeout(&ctx, &command, &command.data.options, pool, &config).await + } + "on" => sub_feature(&ctx, &command, pool, &config, true).await, + "off" => sub_feature(&ctx, &command, pool, &config, false).await, + _ => reply(&ctx, &command, &config, "category.unknown_subcommand", None).await, + } + }) + } +} + +fn get_string(opts: &[CommandDataOption], key: &str) -> Option { + let sub = opts.first()?; + if let CommandDataOptionValue::SubCommand(sub_opts) = &sub.value { + for o in sub_opts { + if o.name == key { + if let CommandDataOptionValue::String(s) = &o.value { + return Some(s.clone()); + } + } + } + } + None +} + +fn get_int(opts: &[CommandDataOption], key: &str) -> Option { + let sub = opts.first()?; + if let CommandDataOptionValue::SubCommand(sub_opts) = &sub.value { + for o in sub_opts { + if o.name == key { + if let CommandDataOptionValue::Integer(v) = &o.value { + return Some(*v); + } + } + } + } + None +} + +async fn reply( + ctx: &Context, + command: &CommandInteraction, + config: &Config, + key: &str, + params: Option>, +) -> ModmailResult<()> { + let mut p = params.unwrap_or_default(); + p.insert("prefix".to_string(), config.command.prefix.clone()); + let _ = MessageBuilder::system_message(ctx, config) + .translated_content( + key, + Some(&p), + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .send_interaction_followup(command, true) + .await; + Ok(()) +} + +async fn sub_create( + ctx: &Context, + command: &CommandInteraction, + options: &[CommandDataOption], + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let discord_id = match get_string(options, "discord_category_id") { + Some(v) => v, + None => return reply(ctx, command, config, "category.create_usage", None).await, + }; + let name = match get_string(options, "name") { + Some(v) => v, + None => return reply(ctx, command, config, "category.create_usage", None).await, + }; + let description = get_string(options, "description"); + let emoji = get_string(options, "emoji"); + + if discord_id.parse::().is_err() { + return reply( + ctx, + command, + config, + "category.invalid_discord_category", + None, + ) + .await; + } + + let enabled = count_enabled_categories(pool).await?; + if enabled as usize >= CATEGORY_BUTTON_HARD_LIMIT { + let mut params = HashMap::new(); + params.insert("max".to_string(), CATEGORY_BUTTON_HARD_LIMIT.to_string()); + return reply( + ctx, + command, + config, + "category.too_many_enabled", + Some(params), + ) + .await; + } + + if get_category_by_name(&name, pool).await?.is_some() { + return reply(ctx, command, config, "category.already_exists", None).await; + } + + let created = create_category( + &name, + description.as_deref(), + emoji.as_deref(), + &discord_id, + pool, + ) + .await?; + let mut params = HashMap::new(); + params.insert("name".to_string(), created.name); + reply(ctx, command, config, "category.created", Some(params)).await +} + +async fn sub_list( + ctx: &Context, + command: &CommandInteraction, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let cats = list_all_categories(pool).await?; + if cats.is_empty() { + return reply(ctx, command, config, "category.list_empty", None).await; + } + let header = get_translated_message( + config, + "category.list_header", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), + None, + ) + .await; + let enabled_label = get_translated_message( + config, + "category.state_enabled", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), + None, + ) + .await; + let disabled_label = get_translated_message( + config, + "category.state_disabled", + None, + Some(command.user.id), + command.guild_id.map(|g| g.get()), + None, + ) + .await; + let mut body = format!("**{}**\n\n", header); + for cat in &cats { + let state = if cat.enabled { + enabled_label.clone() + } else { + disabled_label.clone() + }; + let emoji = cat.emoji.clone().unwrap_or_default(); + body.push_str(&format!( + "`{}` {} **{}** — {}\n", + cat.position, emoji, cat.name, state + )); + } + let _ = MessageBuilder::system_message(ctx, config) + .content(body) + .to_channel(command.channel_id) + .send_interaction_followup(command, true) + .await; + Ok(()) +} + +async fn sub_delete( + ctx: &Context, + command: &CommandInteraction, + options: &[CommandDataOption], + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let name = match get_string(options, "name") { + Some(v) => v, + None => return reply(ctx, command, config, "category.text_usage", None).await, + }; + let cat = match get_category_by_name(&name, pool).await? { + Some(c) => c, + None => return reply(ctx, command, config, "category.not_found", None).await, + }; + delete_category(&cat.id, pool).await?; + let mut params = HashMap::new(); + params.insert("name".to_string(), cat.name); + reply(ctx, command, config, "category.deleted", Some(params)).await +} + +async fn sub_rename( + ctx: &Context, + command: &CommandInteraction, + options: &[CommandDataOption], + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let old = match get_string(options, "old_name") { + Some(v) => v, + None => return reply(ctx, command, config, "category.text_usage", None).await, + }; + let new = match get_string(options, "new_name") { + Some(v) => v, + None => return reply(ctx, command, config, "category.text_usage", None).await, + }; + let new = new.trim().to_string(); + if new.is_empty() { + return reply(ctx, command, config, "category.text_usage", None).await; + } + let cat = match get_category_by_name(&old, pool).await? { + Some(c) => c, + None => return reply(ctx, command, config, "category.not_found", None).await, + }; + if let Some(existing) = get_category_by_name(&new, pool).await? { + if existing.id != cat.id { + return reply(ctx, command, config, "category.already_exists", None).await; + } + } + update_category(&cat.id, Some(&new), None, None, None, None, None, pool).await?; + let mut params = HashMap::new(); + params.insert("name".to_string(), new); + reply(ctx, command, config, "category.renamed", Some(params)).await +} + +async fn sub_move( + ctx: &Context, + command: &CommandInteraction, + options: &[CommandDataOption], + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let name = match get_string(options, "name") { + Some(v) => v, + None => return reply(ctx, command, config, "category.text_usage", None).await, + }; + let pos = match get_int(options, "position") { + Some(v) => v, + None => return reply(ctx, command, config, "category.text_usage", None).await, + }; + let cat = match get_category_by_name(&name, pool).await? { + Some(c) => c, + None => return reply(ctx, command, config, "category.not_found", None).await, + }; + update_category(&cat.id, None, None, None, None, Some(pos), None, pool).await?; + let mut params = HashMap::new(); + params.insert("name".to_string(), cat.name); + params.insert("position".to_string(), pos.to_string()); + reply(ctx, command, config, "category.moved", Some(params)).await +} + +async fn sub_enable_one( + ctx: &Context, + command: &CommandInteraction, + options: &[CommandDataOption], + pool: &sqlx::SqlitePool, + config: &Config, + enable: bool, +) -> ModmailResult<()> { + let name = match get_string(options, "name") { + Some(v) => v, + None => return reply(ctx, command, config, "category.text_usage", None).await, + }; + let cat = match get_category_by_name(&name, pool).await? { + Some(c) => c, + None => return reply(ctx, command, config, "category.not_found", None).await, + }; + if enable && !cat.enabled { + let enabled_count = count_enabled_categories(pool).await?; + if enabled_count as usize >= CATEGORY_BUTTON_HARD_LIMIT { + let mut params = HashMap::new(); + params.insert("max".to_string(), CATEGORY_BUTTON_HARD_LIMIT.to_string()); + return reply( + ctx, + command, + config, + "category.too_many_enabled", + Some(params), + ) + .await; + } + } + update_category(&cat.id, None, None, None, None, None, Some(enable), pool).await?; + let key = if enable { + "category.enabled_one" + } else { + "category.disabled_one" + }; + let mut params = HashMap::new(); + params.insert("name".to_string(), cat.name); + reply(ctx, command, config, key, Some(params)).await +} + +async fn sub_timeout( + ctx: &Context, + command: &CommandInteraction, + options: &[CommandDataOption], + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let secs = match get_int(options, "seconds") { + Some(v) if v >= 30 => v, + _ => return reply(ctx, command, config, "category.text_usage", None).await, + }; + let settings = get_category_settings(pool).await?; + update_category_settings(settings.enabled, secs, pool).await?; + let mut params = HashMap::new(); + params.insert("seconds".to_string(), secs.to_string()); + reply( + ctx, + command, + config, + "category.timeout_updated", + Some(params), + ) + .await +} + +async fn sub_feature( + ctx: &Context, + command: &CommandInteraction, + pool: &sqlx::SqlitePool, + config: &Config, + enable: bool, +) -> ModmailResult<()> { + let settings = get_category_settings(pool).await?; + update_category_settings(enable, settings.selection_timeout_s, pool).await?; + let key = if enable { + "category.feature_enabled" + } else { + "category.feature_disabled" + }; + reply(ctx, command, config, key, None).await +} diff --git a/crates/rustmail/src/commands/category/slash_command/mod.rs b/crates/rustmail/src/commands/category/slash_command/mod.rs new file mode 100644 index 00000000..3d1c1634 --- /dev/null +++ b/crates/rustmail/src/commands/category/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod category; + +pub use category::*; diff --git a/crates/rustmail/src/commands/category/text_command/category.rs b/crates/rustmail/src/commands/category/text_command/category.rs new file mode 100644 index 00000000..0f17ba4d --- /dev/null +++ b/crates/rustmail/src/commands/category/text_command/category.rs @@ -0,0 +1,340 @@ +use crate::db::operations::ticket_categories::CATEGORY_BUTTON_HARD_LIMIT; +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 serenity::all::{Context, Message}; +use std::collections::HashMap; +use std::sync::Arc; + +pub async fn category_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, &["category"]) { + Some(c) => c, + None => { + return send_translated(&ctx, config, &msg, "category.text_usage", None).await; + } + }; + + let mut parts = content.splitn(2, ' '); + let sub = parts.next().unwrap_or("").trim(); + let args = parts.next().unwrap_or("").trim(); + + match sub { + "create" => handle_create(&ctx, &msg, args, pool, config).await, + "list" => handle_list(&ctx, &msg, pool, config).await, + "rename" => handle_rename(&ctx, &msg, args, pool, config).await, + "move" => handle_move(&ctx, &msg, args, pool, config).await, + "delete" | "remove" => handle_delete(&ctx, &msg, args, pool, config).await, + "enable" => handle_enable_one(&ctx, &msg, args, pool, config, true).await, + "disable" => handle_enable_one(&ctx, &msg, args, pool, config, false).await, + "timeout" => handle_timeout(&ctx, &msg, args, pool, config).await, + "on" => handle_feature_toggle(&ctx, &msg, pool, config, true).await, + "off" => handle_feature_toggle(&ctx, &msg, pool, config, false).await, + _ => send_translated(&ctx, config, &msg, "category.unknown_subcommand", None).await, + } +} + +async fn send_translated( + ctx: &Context, + config: &Config, + msg: &Message, + key: &str, + params: Option<&HashMap>, +) -> ModmailResult<()> { + let mut p = HashMap::new(); + p.insert("prefix".to_string(), config.command.prefix.clone()); + if let Some(extra) = params { + for (k, v) in extra { + p.insert(k.clone(), v.clone()); + } + } + MessageBuilder::system_message(ctx, config) + .translated_content( + key, + Some(&p), + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + ) + .await + .reply_to(msg.clone()) + .send(true) + .await?; + Ok(()) +} + +async fn handle_create( + ctx: &Context, + msg: &Message, + args: &str, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + // Format: [| description] [| emoji] + let mut parts = args.splitn(2, ' '); + let discord_id_raw = parts.next().unwrap_or("").trim(); + let rest = parts.next().unwrap_or("").trim(); + + if discord_id_raw.is_empty() || rest.is_empty() { + return send_translated(ctx, config, msg, "category.create_usage", None).await; + } + + if discord_id_raw.parse::().is_err() { + return send_translated(ctx, config, msg, "category.invalid_discord_category", None).await; + } + + let segments: Vec<&str> = rest.split('|').map(|s| s.trim()).collect(); + let name = segments.first().map(|s| s.to_string()).unwrap_or_default(); + if name.is_empty() { + return send_translated(ctx, config, msg, "category.create_usage", None).await; + } + let description = segments + .get(1) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()); + let emoji = segments + .get(2) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()); + + let enabled = list_enabled_categories(pool).await?; + if enabled.len() >= CATEGORY_BUTTON_HARD_LIMIT { + let mut params = HashMap::new(); + params.insert("max".to_string(), CATEGORY_BUTTON_HARD_LIMIT.to_string()); + return send_translated(ctx, config, msg, "category.too_many_enabled", Some(¶ms)).await; + } + + if let Some(_) = get_category_by_name(&name, pool).await? { + return send_translated(ctx, config, msg, "category.already_exists", None).await; + } + + let created = create_category( + &name, + description.as_deref(), + emoji.as_deref(), + discord_id_raw, + pool, + ) + .await?; + + let mut params = HashMap::new(); + params.insert("name".to_string(), created.name.clone()); + send_translated(ctx, config, msg, "category.created", Some(¶ms)).await +} + +async fn handle_list( + ctx: &Context, + msg: &Message, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let cats = list_all_categories(pool).await?; + if cats.is_empty() { + return send_translated(ctx, config, msg, "category.list_empty", None).await; + } + + let header = get_translated_message( + config, + "category.list_header", + None, + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + None, + ) + .await; + let enabled_label = get_translated_message( + config, + "category.state_enabled", + None, + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + None, + ) + .await; + let disabled_label = get_translated_message( + config, + "category.state_disabled", + None, + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + None, + ) + .await; + + let mut body = format!("**{}**\n\n", header); + for cat in &cats { + let state = if cat.enabled { + enabled_label.clone() + } else { + disabled_label.clone() + }; + let emoji = cat.emoji.clone().unwrap_or_default(); + body.push_str(&format!( + "`{}` {} **{}** — {}\n", + cat.position, emoji, cat.name, state + )); + } + + MessageBuilder::system_message(ctx, config) + .content(body) + .reply_to(msg.clone()) + .send(true) + .await?; + Ok(()) +} + +async fn handle_rename( + ctx: &Context, + msg: &Message, + args: &str, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let mut parts = args.splitn(2, ' '); + let old = parts.next().unwrap_or("").trim(); + let new = parts.next().unwrap_or("").trim(); + if old.is_empty() || new.is_empty() { + return send_translated(ctx, config, msg, "category.text_usage", None).await; + } + let cat = match get_category_by_name(old, pool).await? { + Some(c) => c, + None => return send_translated(ctx, config, msg, "category.not_found", None).await, + }; + if let Some(existing) = get_category_by_name(new, pool).await? { + if existing.id != cat.id { + return send_translated(ctx, config, msg, "category.already_exists", None).await; + } + } + update_category(&cat.id, Some(new), None, None, None, None, None, pool).await?; + let mut params = HashMap::new(); + params.insert("name".to_string(), new.to_string()); + send_translated(ctx, config, msg, "category.renamed", Some(¶ms)).await +} + +async fn handle_move( + ctx: &Context, + msg: &Message, + args: &str, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let mut parts = args.splitn(2, ' '); + let name = parts.next().unwrap_or("").trim(); + let pos_s = parts.next().unwrap_or("").trim(); + let position: i64 = match pos_s.parse() { + Ok(p) => p, + Err(_) => return send_translated(ctx, config, msg, "category.text_usage", None).await, + }; + let cat = match get_category_by_name(name, pool).await? { + Some(c) => c, + None => return send_translated(ctx, config, msg, "category.not_found", None).await, + }; + update_category(&cat.id, None, None, None, None, Some(position), None, pool).await?; + let mut params = HashMap::new(); + params.insert("name".to_string(), cat.name); + params.insert("position".to_string(), position.to_string()); + send_translated(ctx, config, msg, "category.moved", Some(¶ms)).await +} + +async fn handle_delete( + ctx: &Context, + msg: &Message, + args: &str, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let name = args.trim(); + if name.is_empty() { + return send_translated(ctx, config, msg, "category.text_usage", None).await; + } + let cat = match get_category_by_name(name, pool).await? { + Some(c) => c, + None => return send_translated(ctx, config, msg, "category.not_found", None).await, + }; + delete_category(&cat.id, pool).await?; + let mut params = HashMap::new(); + params.insert("name".to_string(), cat.name); + send_translated(ctx, config, msg, "category.deleted", Some(¶ms)).await +} + +async fn handle_enable_one( + ctx: &Context, + msg: &Message, + args: &str, + pool: &sqlx::SqlitePool, + config: &Config, + enable: bool, +) -> ModmailResult<()> { + let name = args.trim(); + if name.is_empty() { + return send_translated(ctx, config, msg, "category.text_usage", None).await; + } + let cat = match get_category_by_name(name, pool).await? { + Some(c) => c, + None => return send_translated(ctx, config, msg, "category.not_found", None).await, + }; + if enable && !cat.enabled { + let enabled_count = count_enabled_categories(pool).await?; + if enabled_count as usize >= CATEGORY_BUTTON_HARD_LIMIT { + let mut params = HashMap::new(); + params.insert("max".to_string(), CATEGORY_BUTTON_HARD_LIMIT.to_string()); + return send_translated(ctx, config, msg, "category.too_many_enabled", Some(¶ms)) + .await; + } + } + update_category(&cat.id, None, None, None, None, None, Some(enable), pool).await?; + let key = if enable { + "category.enabled_one" + } else { + "category.disabled_one" + }; + let mut params = HashMap::new(); + params.insert("name".to_string(), cat.name); + send_translated(ctx, config, msg, key, Some(¶ms)).await +} + +async fn handle_timeout( + ctx: &Context, + msg: &Message, + args: &str, + pool: &sqlx::SqlitePool, + config: &Config, +) -> ModmailResult<()> { + let secs: i64 = match args.trim().parse() { + Ok(v) if v >= 30 => v, + _ => return send_translated(ctx, config, msg, "category.text_usage", None).await, + }; + let settings = get_category_settings(pool).await?; + update_category_settings(settings.enabled, secs, pool).await?; + let mut params = HashMap::new(); + params.insert("seconds".to_string(), secs.to_string()); + send_translated(ctx, config, msg, "category.timeout_updated", Some(¶ms)).await +} + +async fn handle_feature_toggle( + ctx: &Context, + msg: &Message, + pool: &sqlx::SqlitePool, + config: &Config, + enable: bool, +) -> ModmailResult<()> { + let settings = get_category_settings(pool).await?; + update_category_settings(enable, settings.selection_timeout_s, pool).await?; + let key = if enable { + "category.feature_enabled" + } else { + "category.feature_disabled" + }; + send_translated(ctx, config, msg, key, None).await +} diff --git a/crates/rustmail/src/commands/category/text_command/mod.rs b/crates/rustmail/src/commands/category/text_command/mod.rs new file mode 100644 index 00000000..3d1c1634 --- /dev/null +++ b/crates/rustmail/src/commands/category/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod category; + +pub use category::*; diff --git a/crates/rustmail/src/commands/mod.rs b/crates/rustmail/src/commands/mod.rs index ab447c50..f4c6a1e4 100644 --- a/crates/rustmail/src/commands/mod.rs +++ b/crates/rustmail/src/commands/mod.rs @@ -13,6 +13,7 @@ pub mod add_reminder; pub mod add_staff; pub mod alert; pub mod anonreply; +pub mod category; pub mod close; pub mod delete; pub mod edit; @@ -37,6 +38,7 @@ pub use add_reminder::*; pub use add_staff::*; pub use alert::*; pub use anonreply::*; +pub use category::*; pub use close::*; pub use delete::*; pub use edit::*; diff --git a/crates/rustmail/src/db/operations/mod.rs b/crates/rustmail/src/db/operations/mod.rs index 9cb336f8..9938b3ea 100644 --- a/crates/rustmail/src/db/operations/mod.rs +++ b/crates/rustmail/src/db/operations/mod.rs @@ -9,6 +9,7 @@ pub mod scheduled; pub mod snippets; pub mod statistics; pub mod threads; +pub mod ticket_categories; pub use api_keys::*; pub use features::*; @@ -21,3 +22,4 @@ pub use scheduled::*; pub use snippets::*; pub use statistics::*; pub use threads::*; +pub use ticket_categories::*; diff --git a/crates/rustmail/src/db/operations/ticket_categories.rs b/crates/rustmail/src/db/operations/ticket_categories.rs new file mode 100644 index 00000000..92909e53 --- /dev/null +++ b/crates/rustmail/src/db/operations/ticket_categories.rs @@ -0,0 +1,439 @@ +use crate::db::repr::{PendingCategorySelection, TicketCategory, TicketCategorySettings}; +use crate::prelude::errors::*; +use chrono::Utc; +use sqlx::{Row, SqlitePool}; +use uuid::Uuid; + +pub const CATEGORY_BUTTON_HARD_LIMIT: usize = 24; + +pub async fn get_category_settings(pool: &SqlitePool) -> ModmailResult { + let row = sqlx::query( + "SELECT enabled, selection_timeout_s FROM ticket_category_settings WHERE id = 1", + ) + .fetch_optional(pool) + .await + .map_err(|e| { + eprintln!("Failed to fetch category settings: {e:?}"); + validation_failed("Failed to fetch category settings") + })?; + + Ok(match row { + Some(row) => TicketCategorySettings { + enabled: row.get::("enabled") != 0, + selection_timeout_s: row.get::("selection_timeout_s"), + }, + None => TicketCategorySettings { + enabled: false, + selection_timeout_s: 300, + }, + }) +} + +pub async fn update_category_settings( + enabled: bool, + selection_timeout_s: i64, + pool: &SqlitePool, +) -> ModmailResult<()> { + sqlx::query( + r#" + INSERT INTO ticket_category_settings (id, enabled, selection_timeout_s) + VALUES (1, ?, ?) + ON CONFLICT(id) DO UPDATE SET + enabled = excluded.enabled, + selection_timeout_s = excluded.selection_timeout_s + "#, + ) + .bind(enabled as i64) + .bind(selection_timeout_s) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to update category settings: {e:?}"); + validation_failed("Failed to update category settings") + })?; + + Ok(()) +} + +fn row_to_category(row: sqlx::sqlite::SqliteRow) -> TicketCategory { + TicketCategory { + id: row.get::("id"), + name: row.get::("name"), + description: row.get::, _>("description"), + emoji: row.get::, _>("emoji"), + discord_category_id: row.get::("discord_category_id"), + position: row.get::("position"), + enabled: row.get::("enabled") != 0, + created_at: row.get::("created_at"), + updated_at: row.get::("updated_at"), + } +} + +pub async fn list_all_categories(pool: &SqlitePool) -> ModmailResult> { + let rows = sqlx::query( + r#" + SELECT id, name, description, emoji, discord_category_id, + position, enabled, created_at, updated_at + FROM ticket_categories + ORDER BY position ASC, created_at ASC + "#, + ) + .fetch_all(pool) + .await + .map_err(|e| { + eprintln!("Failed to list categories: {e:?}"); + validation_failed("Failed to list categories") + })?; + + Ok(rows.into_iter().map(row_to_category).collect()) +} + +pub async fn list_enabled_categories(pool: &SqlitePool) -> ModmailResult> { + let rows = sqlx::query( + r#" + SELECT id, name, description, emoji, discord_category_id, + position, enabled, created_at, updated_at + FROM ticket_categories + WHERE enabled = 1 + ORDER BY position ASC, created_at ASC + "#, + ) + .fetch_all(pool) + .await + .map_err(|e| { + eprintln!("Failed to list enabled categories: {e:?}"); + validation_failed("Failed to list enabled categories") + })?; + + Ok(rows.into_iter().map(row_to_category).collect()) +} + +pub async fn count_enabled_categories(pool: &SqlitePool) -> ModmailResult { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ticket_categories WHERE enabled = 1") + .fetch_one(pool) + .await + .map_err(|e| { + eprintln!("Failed to count enabled categories: {e:?}"); + validation_failed("Failed to count enabled categories") + })?; + Ok(count) +} + +pub async fn get_category_by_id( + id: &str, + pool: &SqlitePool, +) -> ModmailResult> { + let row = sqlx::query( + r#" + SELECT id, name, description, emoji, discord_category_id, + position, enabled, created_at, updated_at + FROM ticket_categories + WHERE id = ? + "#, + ) + .bind(id) + .fetch_optional(pool) + .await + .map_err(|e| { + eprintln!("Failed to fetch category: {e:?}"); + validation_failed("Failed to fetch category") + })?; + + Ok(row.map(row_to_category)) +} + +pub async fn get_category_by_name( + name: &str, + pool: &SqlitePool, +) -> ModmailResult> { + let row = sqlx::query( + r#" + SELECT id, name, description, emoji, discord_category_id, + position, enabled, created_at, updated_at + FROM ticket_categories + WHERE name = ? COLLATE NOCASE + LIMIT 1 + "#, + ) + .bind(name) + .fetch_optional(pool) + .await + .map_err(|e| { + eprintln!("Failed to fetch category by name: {e:?}"); + validation_failed("Failed to fetch category by name") + })?; + + Ok(row.map(row_to_category)) +} + +pub async fn create_category( + name: &str, + description: Option<&str>, + emoji: Option<&str>, + discord_category_id: &str, + pool: &SqlitePool, +) -> ModmailResult { + let id = Uuid::new_v4().to_string(); + let now = Utc::now().timestamp(); + + let max_position: Option = + sqlx::query_scalar("SELECT MAX(position) FROM ticket_categories") + .fetch_one(pool) + .await + .map_err(|e| { + eprintln!("Failed to compute category position: {e:?}"); + validation_failed("Failed to compute category position") + })?; + let position = max_position.unwrap_or(-1) + 1; + + sqlx::query( + r#" + INSERT INTO ticket_categories + (id, name, description, emoji, discord_category_id, position, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) + "#, + ) + .bind(&id) + .bind(name) + .bind(description) + .bind(emoji) + .bind(discord_category_id) + .bind(position) + .bind(now) + .bind(now) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to create category: {e:?}"); + validation_failed("Failed to create category") + })?; + + Ok(TicketCategory { + id, + name: name.to_string(), + description: description.map(|s| s.to_string()), + emoji: emoji.map(|s| s.to_string()), + discord_category_id: discord_category_id.to_string(), + position, + enabled: true, + created_at: now, + updated_at: now, + }) +} + +pub async fn update_category( + id: &str, + name: Option<&str>, + description: Option>, + emoji: Option>, + discord_category_id: Option<&str>, + position: Option, + enabled: Option, + pool: &SqlitePool, +) -> ModmailResult<()> { + let existing = get_category_by_id(id, pool) + .await? + .ok_or_else(|| validation_failed("Category not found"))?; + + let new_name = name.unwrap_or(&existing.name); + let new_description = match description { + Some(opt) => opt.map(|s| s.to_string()), + None => existing.description.clone(), + }; + let new_emoji = match emoji { + Some(opt) => opt.map(|s| s.to_string()), + None => existing.emoji.clone(), + }; + let new_discord_category_id = discord_category_id.unwrap_or(&existing.discord_category_id); + let new_position = position.unwrap_or(existing.position); + let new_enabled = enabled.unwrap_or(existing.enabled); + let now = Utc::now().timestamp(); + + sqlx::query( + r#" + UPDATE ticket_categories + SET name = ?, description = ?, emoji = ?, discord_category_id = ?, + position = ?, enabled = ?, updated_at = ? + WHERE id = ? + "#, + ) + .bind(new_name) + .bind(new_description) + .bind(new_emoji) + .bind(new_discord_category_id) + .bind(new_position) + .bind(new_enabled as i64) + .bind(now) + .bind(id) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to update category: {e:?}"); + validation_failed("Failed to update category") + })?; + + Ok(()) +} + +pub async fn delete_category(id: &str, pool: &SqlitePool) -> ModmailResult { + let res = sqlx::query("DELETE FROM ticket_categories WHERE id = ?") + .bind(id) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to delete category: {e:?}"); + validation_failed("Failed to delete category") + })?; + + Ok(res.rows_affected() > 0) +} + +pub async fn set_thread_category( + thread_id: &str, + category_id: Option<&str>, + pool: &SqlitePool, +) -> ModmailResult<()> { + sqlx::query("UPDATE threads SET ticket_category_id = ? WHERE id = ?") + .bind(category_id) + .bind(thread_id) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to set thread category: {e:?}"); + validation_failed("Failed to set thread category") + })?; + Ok(()) +} + +pub async fn upsert_pending_selection( + user_id: i64, + prompt_msg_id: &str, + dm_channel_id: &str, + started_at: i64, + expires_at: i64, + queued_msg_ids: &[String], + pool: &SqlitePool, +) -> ModmailResult<()> { + let queued_json = serde_json::to_string(queued_msg_ids) + .map_err(|_| validation_failed("Failed to serialize queued messages"))?; + + sqlx::query( + r#" + INSERT INTO pending_category_selections + (user_id, prompt_msg_id, dm_channel_id, started_at, expires_at, queued_msg_ids) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + prompt_msg_id = excluded.prompt_msg_id, + dm_channel_id = excluded.dm_channel_id, + started_at = excluded.started_at, + expires_at = excluded.expires_at, + queued_msg_ids = excluded.queued_msg_ids + "#, + ) + .bind(user_id) + .bind(prompt_msg_id) + .bind(dm_channel_id) + .bind(started_at) + .bind(expires_at) + .bind(&queued_json) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to upsert pending selection: {e:?}"); + validation_failed("Failed to upsert pending selection") + })?; + + Ok(()) +} + +fn row_to_pending(row: sqlx::sqlite::SqliteRow) -> PendingCategorySelection { + let queued_json: String = row.get::("queued_msg_ids"); + let queued_msg_ids: Vec = serde_json::from_str(&queued_json).unwrap_or_default(); + + PendingCategorySelection { + user_id: row.get::("user_id"), + prompt_msg_id: row.get::("prompt_msg_id"), + dm_channel_id: row.get::("dm_channel_id"), + started_at: row.get::("started_at"), + expires_at: row.get::("expires_at"), + queued_msg_ids, + } +} + +pub async fn get_pending_selection( + user_id: i64, + pool: &SqlitePool, +) -> ModmailResult> { + let row = sqlx::query( + r#" + SELECT user_id, prompt_msg_id, dm_channel_id, started_at, expires_at, queued_msg_ids + FROM pending_category_selections + WHERE user_id = ? + "#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + .map_err(|e| { + eprintln!("Failed to fetch pending selection: {e:?}"); + validation_failed("Failed to fetch pending selection") + })?; + + Ok(row.map(row_to_pending)) +} + +pub async fn append_queued_message( + user_id: i64, + message_id: &str, + pool: &SqlitePool, +) -> ModmailResult<()> { + if let Some(mut pending) = get_pending_selection(user_id, pool).await? { + if pending.queued_msg_ids.iter().any(|id| id == message_id) { + return Ok(()); + } + pending.queued_msg_ids.push(message_id.to_string()); + let queued_json = serde_json::to_string(&pending.queued_msg_ids) + .map_err(|_| validation_failed("Failed to serialize queued messages"))?; + sqlx::query("UPDATE pending_category_selections SET queued_msg_ids = ? WHERE user_id = ?") + .bind(&queued_json) + .bind(user_id) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to append queued message: {e:?}"); + validation_failed("Failed to append queued message") + })?; + } + Ok(()) +} + +pub async fn delete_pending_selection(user_id: i64, pool: &SqlitePool) -> ModmailResult { + let res = sqlx::query("DELETE FROM pending_category_selections WHERE user_id = ?") + .bind(user_id) + .execute(pool) + .await + .map_err(|e| { + eprintln!("Failed to delete pending selection: {e:?}"); + validation_failed("Failed to delete pending selection") + })?; + Ok(res.rows_affected() > 0) +} + +pub async fn list_all_pending_selections( + pool: &SqlitePool, +) -> ModmailResult> { + let rows = sqlx::query( + r#" + SELECT user_id, prompt_msg_id, dm_channel_id, started_at, expires_at, queued_msg_ids + FROM pending_category_selections + "#, + ) + .fetch_all(pool) + .await + .map_err(|e| { + eprintln!("Failed to list pending selections: {e:?}"); + validation_failed("Failed to list pending selections") + })?; + + Ok(rows.into_iter().map(row_to_pending).collect()) +} diff --git a/crates/rustmail/src/db/repr.rs b/crates/rustmail/src/db/repr.rs index 1321a077..37feef6a 100644 --- a/crates/rustmail/src/db/repr.rs +++ b/crates/rustmail/src/db/repr.rs @@ -18,6 +18,36 @@ pub struct ApiKey { pub is_active: bool, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TicketCategory { + pub id: String, + pub name: String, + pub description: Option, + pub emoji: Option, + pub discord_category_id: String, + pub position: i64, + pub enabled: bool, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TicketCategorySettings { + pub enabled: bool, + pub selection_timeout_s: i64, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct PendingCategorySelection { + pub user_id: i64, + pub prompt_msg_id: String, + pub dm_channel_id: String, + pub started_at: i64, + pub expires_at: i64, + pub queued_msg_ids: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum Permission { diff --git a/crates/rustmail/src/handlers/guild_interaction_handler.rs b/crates/rustmail/src/handlers/guild_interaction_handler.rs index 28d46a38..d2a7dec8 100644 --- a/crates/rustmail/src/handlers/guild_interaction_handler.rs +++ b/crates/rustmail/src/handlers/guild_interaction_handler.rs @@ -82,6 +82,14 @@ impl EventHandler for InteractionHandler { async fn interaction_create(&self, ctx: Context, interaction: Interaction) { match interaction { Interaction::Component(mut comp) => { + match handle_category_component_interaction(&ctx, &self.config, &comp).await { + Ok(true) => return, + Ok(false) => {} + Err(e) => { + eprintln!("category interaction error: {e:?}"); + return; + } + } if let Err(..) = handle_feature_component_interaction(&ctx, &self.config, &comp).await { diff --git a/crates/rustmail/src/handlers/guild_messages_handler.rs b/crates/rustmail/src/handlers/guild_messages_handler.rs index eb147eca..40a1f31f 100644 --- a/crates/rustmail/src/handlers/guild_messages_handler.rs +++ b/crates/rustmail/src/handlers/guild_messages_handler.rs @@ -84,6 +84,7 @@ impl GuildMessagesHandler { wrap_command!(lock, "ping", ping); wrap_command!(lock, ["snippet", "s"], snippet_command); wrap_command!(lock, "status", status_command); + wrap_command!(lock, "category", category_command); drop(lock); h @@ -195,6 +196,10 @@ async fn manage_incoming_message( } } } else { + if maybe_start_category_selection(ctx, config, msg).await { + drop(guard); + return Ok(()); + } create_channel(ctx, msg, config).await; } diff --git a/crates/rustmail/src/handlers/ready_handler.rs b/crates/rustmail/src/handlers/ready_handler.rs index ca968263..335b77e5 100644 --- a/crates/rustmail/src/handlers/ready_handler.rs +++ b/crates/rustmail/src/handlers/ready_handler.rs @@ -71,6 +71,7 @@ impl EventHandler for ReadyHandler { send_recovery_summary(&ctx, &config, &recovery_results).await; sync_features(&ctx, &config).await; hydrate_scheduled_closures(&ctx, &config).await; + hydrate_pending_category_selections(&ctx, &config).await; } }); diff --git a/crates/rustmail/src/i18n/language/en.rs b/crates/rustmail/src/i18n/language/en.rs index 82a4a3a7..f8f7a7be 100644 --- a/crates/rustmail/src/i18n/language/en.rs +++ b/crates/rustmail/src/i18n/language/en.rs @@ -938,6 +938,23 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { "help.force_close".to_string(), DictionaryMessage::new("Force-closes a ticket when an error prevents normal closure. This command will be removed in future versions. To force-close a ticket, use `!force_close` or `!fc` inside a ticket."), ); + dict.messages.insert( + "help.category".to_string(), + DictionaryMessage::new( + "Manages ticket categories that users can select to direct their requests.\n\n\ + **Subcommands:**\n\ + `create [| description] [| emoji]` - Creates a new category.\n\ + `list` - Lists all configured categories.\n\ + `rename ` - Renames an existing category.\n\ + `move ` - Changes the position of a category.\n\ + `delete ` or `remove ` - Deletes a category.\n\ + `enable ` - Enables a specific category.\n\ + `disable ` - Disables a specific category.\n\ + `on` - Enables the category selection feature globally.\n\ + `off` - Disables the category selection feature globally.\n\ + `timeout ` - Sets the time limit (in seconds) users have to select a category before defaulting." + ), + ); dict.messages.insert( "help.help".to_string(), DictionaryMessage::new("Displays a list of all available commands with a short description. To view the help message, use `!help`. If you want help with a specific command, type `!help `."), @@ -1906,4 +1923,114 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { "status.maintenance_activity".to_string(), DictionaryMessage::new("🔧 Maintenance in progress"), ); + dict.messages.insert( + "category.prompt_title".to_string(), + DictionaryMessage::new("Choose a category"), + ); + dict.messages.insert( + "category.prompt_message".to_string(), + DictionaryMessage::new("Please choose a category for your ticket. If you don't choose within {timeout_minutes} minutes, your ticket will be created in the default inbox."), + ); + dict.messages.insert( + "category.default_button_label".to_string(), + DictionaryMessage::new("General"), + ); + dict.messages.insert( + "category.selection_expired".to_string(), + DictionaryMessage::new( + "Selection window expired, your ticket was created in the default inbox.", + ), + ); + dict.messages.insert( + "category.ticket_opened_in".to_string(), + DictionaryMessage::new("Your ticket has been opened in **{category}**."), + ); + dict.messages.insert( + "category.too_many_enabled".to_string(), + DictionaryMessage::new("Too many categories enabled. The maximum is {max}."), + ); + dict.messages.insert( + "category.not_found".to_string(), + DictionaryMessage::new("Category not found."), + ); + dict.messages.insert( + "category.already_exists".to_string(), + DictionaryMessage::new("A category with this name already exists."), + ); + dict.messages.insert( + "category.invalid_emoji".to_string(), + DictionaryMessage::new("Invalid emoji."), + ); + dict.messages.insert( + "category.invalid_discord_category".to_string(), + DictionaryMessage::new("Invalid Discord category ID."), + ); + dict.messages.insert( + "category.created".to_string(), + DictionaryMessage::new("Category **{name}** created."), + ); + dict.messages.insert( + "category.deleted".to_string(), + DictionaryMessage::new("Category **{name}** deleted."), + ); + dict.messages.insert( + "category.renamed".to_string(), + DictionaryMessage::new("Category renamed to **{name}**."), + ); + dict.messages.insert( + "category.moved".to_string(), + DictionaryMessage::new("Category **{name}** moved to position {position}."), + ); + dict.messages.insert( + "category.timeout_updated".to_string(), + DictionaryMessage::new("Selection timeout set to {seconds} seconds."), + ); + dict.messages.insert( + "category.feature_enabled".to_string(), + DictionaryMessage::new("Category selection feature enabled."), + ); + dict.messages.insert( + "category.feature_disabled".to_string(), + DictionaryMessage::new("Category selection feature disabled."), + ); + dict.messages.insert( + "category.enabled_one".to_string(), + DictionaryMessage::new("Category **{name}** enabled."), + ); + dict.messages.insert( + "category.disabled_one".to_string(), + DictionaryMessage::new("Category **{name}** disabled."), + ); + dict.messages.insert( + "category.list_header".to_string(), + DictionaryMessage::new("Ticket categories"), + ); + dict.messages.insert( + "category.list_empty".to_string(), + DictionaryMessage::new("No categories defined."), + ); + dict.messages.insert( + "category.list_item".to_string(), + DictionaryMessage::new("`{position}` {emoji} **{name}** — {state}"), + ); + dict.messages.insert( + "category.state_enabled".to_string(), + DictionaryMessage::new("enabled"), + ); + dict.messages.insert( + "category.state_disabled".to_string(), + DictionaryMessage::new("disabled"), + ); + dict.messages.insert( + "category.unknown_subcommand".to_string(), + DictionaryMessage::new("Unknown subcommand. Use one of: create, list, rename, move, delete, enable, disable, timeout, on, off."), + ); + dict.messages.insert( + "category.text_usage".to_string(), + DictionaryMessage::new("Usage: `{prefix}category ...`"), + ); + dict.messages.insert( + "category.create_usage".to_string(), + DictionaryMessage::new("Usage: `{prefix}category create [| description] [| emoji]`"), + ); } diff --git a/crates/rustmail/src/i18n/language/fr.rs b/crates/rustmail/src/i18n/language/fr.rs index ccd6d9f0..71f02949 100644 --- a/crates/rustmail/src/i18n/language/fr.rs +++ b/crates/rustmail/src/i18n/language/fr.rs @@ -954,6 +954,23 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { "help.force_close".to_string(), DictionaryMessage::new("Ferme un ticket lorsqu'une erreur empêche la fermeture normale. Cette commande disparaîtra dans les prochaines versions. Pour forcer la fermeture d'un ticket, faites `!force_close` ou `!fc` dans un ticket."), ); + dict.messages.insert( + "help.category".to_string(), + DictionaryMessage::new( + "Gère les catégories de tickets que les utilisateurs peuvent sélectionner pour diriger leurs demandes.\n\n\ + **Sous-commandes :**\n\ + `create [| description] [| emoji]` - Crée une nouvelle catégorie.\n\ + `list` - Affiche la liste des catégories configurées.\n\ + `rename ` - Renomme une catégorie existante.\n\ + `move ` - Change la position d'une catégorie.\n\ + `delete ` ou `remove ` - Supprime une catégorie.\n\ + `enable ` - Active une catégorie spécifique.\n\ + `disable ` - Désactive une catégorie spécifique.\n\ + `on` - Active globalement la fonctionnalité de sélection de catégorie.\n\ + `off` - Désactive globalement la fonctionnalité de sélection de catégorie.\n\ + `timeout ` - Définit le délai (en secondes) dont disposent les utilisateurs pour choisir une catégorie avant le choix par défaut." + ), + ); dict.messages.insert( "help.help".to_string(), DictionaryMessage::new("Affiche une liste de toutes les commandes disponibles avec une brève description. Pour afficher le message d'aide, faites `!help`. Si vous souhaitez obtenir de l'aide sur une commande spécifique, faites `!help `."), @@ -1917,4 +1934,114 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { "status.maintenance_activity".to_string(), DictionaryMessage::new("🔧 Maintenance en cours"), ); + dict.messages.insert( + "category.prompt_title".to_string(), + DictionaryMessage::new("Choisissez une catégorie"), + ); + dict.messages.insert( + "category.prompt_message".to_string(), + DictionaryMessage::new("Merci de choisir une catégorie pour votre ticket. Si vous ne choisissez pas dans les {timeout_minutes} minutes, votre ticket sera créé dans la catégorie par défaut."), + ); + dict.messages.insert( + "category.default_button_label".to_string(), + DictionaryMessage::new("Général"), + ); + dict.messages.insert( + "category.selection_expired".to_string(), + DictionaryMessage::new( + "Délai de sélection expiré, votre ticket a été créé dans la catégorie par défaut.", + ), + ); + dict.messages.insert( + "category.ticket_opened_in".to_string(), + DictionaryMessage::new("Votre ticket a été ouvert dans **{category}**."), + ); + dict.messages.insert( + "category.too_many_enabled".to_string(), + DictionaryMessage::new("Trop de catégories activées. Le maximum est {max}."), + ); + dict.messages.insert( + "category.not_found".to_string(), + DictionaryMessage::new("Catégorie introuvable."), + ); + dict.messages.insert( + "category.already_exists".to_string(), + DictionaryMessage::new("Une catégorie avec ce nom existe déjà."), + ); + dict.messages.insert( + "category.invalid_emoji".to_string(), + DictionaryMessage::new("Emoji invalide."), + ); + dict.messages.insert( + "category.invalid_discord_category".to_string(), + DictionaryMessage::new("ID de catégorie Discord invalide."), + ); + dict.messages.insert( + "category.created".to_string(), + DictionaryMessage::new("Catégorie **{name}** créée."), + ); + dict.messages.insert( + "category.deleted".to_string(), + DictionaryMessage::new("Catégorie **{name}** supprimée."), + ); + dict.messages.insert( + "category.renamed".to_string(), + DictionaryMessage::new("Catégorie renommée en **{name}**."), + ); + dict.messages.insert( + "category.moved".to_string(), + DictionaryMessage::new("Catégorie **{name}** déplacée en position {position}."), + ); + dict.messages.insert( + "category.timeout_updated".to_string(), + DictionaryMessage::new("Délai de sélection réglé à {seconds} secondes."), + ); + dict.messages.insert( + "category.feature_enabled".to_string(), + DictionaryMessage::new("Fonctionnalité de sélection de catégorie activée."), + ); + dict.messages.insert( + "category.feature_disabled".to_string(), + DictionaryMessage::new("Fonctionnalité de sélection de catégorie désactivée."), + ); + dict.messages.insert( + "category.enabled_one".to_string(), + DictionaryMessage::new("Catégorie **{name}** activée."), + ); + dict.messages.insert( + "category.disabled_one".to_string(), + DictionaryMessage::new("Catégorie **{name}** désactivée."), + ); + dict.messages.insert( + "category.list_header".to_string(), + DictionaryMessage::new("Catégories de ticket"), + ); + dict.messages.insert( + "category.list_empty".to_string(), + DictionaryMessage::new("Aucune catégorie définie."), + ); + dict.messages.insert( + "category.list_item".to_string(), + DictionaryMessage::new("`{position}` {emoji} **{name}** — {state}"), + ); + dict.messages.insert( + "category.state_enabled".to_string(), + DictionaryMessage::new("activée"), + ); + dict.messages.insert( + "category.state_disabled".to_string(), + DictionaryMessage::new("désactivée"), + ); + dict.messages.insert( + "category.unknown_subcommand".to_string(), + DictionaryMessage::new("Sous-commande inconnue. Utilisez : create, list, rename, move, delete, enable, disable, timeout, on, off."), + ); + dict.messages.insert( + "category.text_usage".to_string(), + DictionaryMessage::new("Utilisation : `{prefix}category ...`"), + ); + dict.messages.insert( + "category.create_usage".to_string(), + DictionaryMessage::new("Utilisation : `{prefix}category create [| description] [| emoji]`"), + ); } diff --git a/crates/rustmail/src/modules/categories.rs b/crates/rustmail/src/modules/categories.rs new file mode 100644 index 00000000..b1f016bf --- /dev/null +++ b/crates/rustmail/src/modules/categories.rs @@ -0,0 +1,310 @@ +use crate::modules::threads::create_or_get_thread_for_user; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use chrono::Utc; +use serenity::all::{ + ButtonStyle, ChannelId, ComponentInteraction, Context, CreateInteractionResponse, + CreateInteractionResponseMessage, Message, ReactionType, UserId, +}; +use serenity::builder::{CreateActionRow, CreateButton}; +use std::collections::HashMap; +use tokio::time::{Duration, sleep}; + +pub const CATEGORY_BUTTON_MAX_PER_ROW: usize = 5; +pub const CATEGORY_BUTTON_MAX_ROWS: usize = 5; + +fn build_category_components( + categories: &[TicketCategory], + default_label: &str, +) -> Vec { + let mut buttons: Vec = Vec::new(); + + for cat in categories.iter().take(CATEGORY_BUTTON_HARD_LIMIT) { + let mut btn = CreateButton::new(format!("category:pick:{}", cat.id)) + .label(cat.name.clone()) + .style(ButtonStyle::Primary); + if let Some(emoji) = cat.emoji.as_deref() { + if let Some(react) = parse_emoji(emoji) { + btn = btn.emoji(react); + } + } + buttons.push(btn); + } + + buttons.push( + CreateButton::new("category:default") + .label(default_label.to_string()) + .style(ButtonStyle::Secondary), + ); + + let mut rows: Vec = Vec::new(); + for chunk in buttons.chunks(CATEGORY_BUTTON_MAX_PER_ROW) { + if rows.len() >= CATEGORY_BUTTON_MAX_ROWS { + break; + } + rows.push(CreateActionRow::Buttons(chunk.to_vec())); + } + rows +} + +fn parse_emoji(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + trimmed.parse::().ok() +} + +pub async fn maybe_start_category_selection(ctx: &Context, config: &Config, msg: &Message) -> bool { + let pool = match &config.db_pool { + Some(p) => p, + None => return false, + }; + + let settings = match get_category_settings(pool).await { + Ok(s) => s, + Err(_) => return false, + }; + if !settings.enabled { + return false; + } + + if let Ok(Some(_)) = get_pending_selection(msg.author.id.get() as i64, pool).await { + match append_queued_message(msg.author.id.get() as i64, &msg.id.to_string(), pool).await { + Ok(_) => return true, + Err(err) => { + eprintln!( + "failed to append queued message for user {} and message {}: {}", + msg.author.id.get(), + msg.id, + err + ); + return false; + } + } + } + + let categories = match list_enabled_categories(pool).await { + Ok(c) => c, + Err(_) => return false, + }; + if categories.is_empty() { + return false; + } + + let timeout_minutes = (settings.selection_timeout_s / 60).max(1); + let mut params = HashMap::new(); + params.insert("timeout_minutes".to_string(), timeout_minutes.to_string()); + + let default_label = get_translated_message( + config, + "category.default_button_label", + None, + Some(msg.author.id), + None, + None, + ) + .await; + + let components = build_category_components(&categories, &default_label); + + let sent = MessageBuilder::system_message(ctx, config) + .translated_content( + "category.prompt_message", + Some(¶ms), + Some(msg.author.id), + None, + ) + .await + .to_user(msg.author.id) + .components(components) + .send(true) + .await; + + let prompt = match sent { + Ok(m) => m, + Err(e) => { + eprintln!("Failed to send category prompt: {e:?}"); + return false; + } + }; + + let now = Utc::now().timestamp(); + let expires_at = now + settings.selection_timeout_s; + let dm_channel_id = prompt.channel_id.to_string(); + + if let Err(e) = upsert_pending_selection( + msg.author.id.get() as i64, + &prompt.id.to_string(), + &dm_channel_id, + now, + expires_at, + &[msg.id.to_string()], + pool, + ) + .await + { + eprintln!("Failed to persist pending selection: {e:?}"); + return false; + } + + schedule_category_timeout(ctx, config, msg.author.id.get() as i64, expires_at); + true +} + +pub fn schedule_category_timeout(ctx: &Context, config: &Config, user_id: i64, expires_at: i64) { + let ctx_clone = ctx.clone(); + let config_clone = config.clone(); + let now = Utc::now().timestamp(); + let delay = (expires_at - now).max(0) as u64; + + tokio::spawn(async move { + if delay > 0 { + sleep(Duration::from_secs(delay)).await; + } + let pool = match config_clone.db_pool.as_ref() { + Some(p) => p, + None => return, + }; + match get_pending_selection(user_id, pool).await { + Ok(Some(pending)) => { + if pending.expires_at <= Utc::now().timestamp() { + if let Err(e) = + finalize_with_category(&ctx_clone, &config_clone, user_id, None).await + { + eprintln!("Failed to finalize expired selection: {e:?}"); + } + } else { + schedule_category_timeout( + &ctx_clone, + &config_clone, + user_id, + pending.expires_at, + ); + } + } + _ => {} + } + }); +} + +pub async fn finalize_with_category( + ctx: &Context, + config: &Config, + user_id: i64, + category_id: Option<&str>, +) -> Result<(), Box> { + let pool = match &config.db_pool { + Some(p) => p, + None => return Ok(()), + }; + + let pending = match get_pending_selection(user_id, pool).await? { + Some(p) => p, + None => return Ok(()), + }; + + let user = UserId::new(user_id as u64); + + let (discord_override, ticket_cat_id) = if let Some(id) = category_id { + match get_category_by_id(id, pool).await? { + Some(cat) if cat.enabled => { + let parent = cat.discord_category_id.parse::().ok(); + (parent, Some(cat.id.clone())) + } + Some(cat) => { + eprintln!( + "Selected category {} is disabled; falling back to default inbox", + cat.id + ); + (None, None) + } + None => (None, None), + } + } else { + (None, None) + }; + + let (target_channel_id, _is_new) = create_or_get_thread_for_user( + ctx, + config, + user, + discord_override, + ticket_cat_id.as_deref(), + ) + .await?; + + let dm_channel = ChannelId::new(pending.dm_channel_id.parse::().unwrap_or(0)); + for mid in &pending.queued_msg_ids { + if let Ok(id_u64) = mid.parse::() { + if let Ok(m) = dm_channel.message(&ctx.http, id_u64).await { + if let Err(e) = send_to_thread(ctx, target_channel_id, &m, config, false).await { + eprintln!("Failed to forward queued DM message: {e:?}"); + } + } + } + } + + if !delete_pending_selection(user_id, pool).await? { + return Err("Failed to clear pending selection".into()); + } + + Ok(()) +} + +pub async fn handle_category_component_interaction( + ctx: &Context, + config: &Config, + interaction: &ComponentInteraction, +) -> Result> { + let custom_id = &interaction.data.custom_id; + if !custom_id.starts_with("category:") { + return Ok(false); + } + + let _ = interaction + .create_response( + &ctx.http, + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new().components(vec![]), + ), + ) + .await; + + let user_id = interaction.user.id.get() as i64; + + let category_id: Option = if custom_id == "category:default" { + None + } else if let Some(rest) = custom_id.strip_prefix("category:pick:") { + Some(rest.to_string()) + } else { + return Ok(true); + }; + + finalize_with_category(ctx, config, user_id, category_id.as_deref()).await?; + Ok(true) +} + +pub async fn hydrate_pending_category_selections(ctx: &Context, config: &Config) { + let Some(pool) = config.db_pool.as_ref() else { + return; + }; + let list = match list_all_pending_selections(pool).await { + Ok(l) => l, + Err(e) => { + eprintln!("Failed to load pending category selections: {e:?}"); + return; + } + }; + for pending in list { + if pending.expires_at <= Utc::now().timestamp() { + if let Err(e) = finalize_with_category(ctx, config, pending.user_id, None).await { + eprintln!("Failed to hydrate-finalize pending selection: {e:?}"); + } + } else { + schedule_category_timeout(ctx, config, pending.user_id, pending.expires_at); + } + } +} diff --git a/crates/rustmail/src/modules/mod.rs b/crates/rustmail/src/modules/mod.rs index 43094091..689f5f1a 100644 --- a/crates/rustmail/src/modules/mod.rs +++ b/crates/rustmail/src/modules/mod.rs @@ -1,3 +1,4 @@ +pub mod categories; pub mod commands; pub mod message_recovery; pub mod reminders; @@ -5,6 +6,7 @@ pub mod scheduled_closures; pub mod threads; pub mod threads_status; +pub use categories::*; pub use commands::*; pub use message_recovery::*; pub use reminders::*; diff --git a/crates/rustmail/src/modules/threads.rs b/crates/rustmail/src/modules/threads.rs index 57adcab5..a3ae7a05 100644 --- a/crates/rustmail/src/modules/threads.rs +++ b/crates/rustmail/src/modules/threads.rs @@ -13,10 +13,12 @@ use std::str::FromStr; use std::time::Duration; use tokio::time::sleep; -async fn create_or_get_thread_for_user( +pub async fn create_or_get_thread_for_user( ctx: &Context, config: &Config, user_id: UserId, + discord_category_override: Option, + ticket_category_id: Option<&str>, ) -> Result<(ChannelId, bool), Box> { let pool = match &config.db_pool { Some(pool) => pool, @@ -34,20 +36,26 @@ async fn create_or_get_thread_for_user( let thread_name = format!("🔴・{}・0m", username); let staff_guild_id = GuildId::new(config.bot.get_staff_guild_id()); - let channel_builder = - CreateChannel::new(&thread_name).category(ChannelId::new(config.thread.inbox_category_id)); + let parent_id = discord_category_override.unwrap_or(config.thread.inbox_category_id); + let channel_builder = CreateChannel::new(&thread_name).category(ChannelId::new(parent_id)); let channel = staff_guild_id .create_channel(&ctx.http, channel_builder) .await?; - let _ = create_thread_for_user(&channel, user_id.get() as i64, &username, pool) + let thread_id = create_thread_for_user(&channel, user_id.get() as i64, &username, pool) .await .map_err(|e| { eprintln!("Error creating thread: {}", e); e })?; + if let Some(cat_id) = ticket_category_id { + if let Err(e) = set_thread_category(&thread_id, Some(cat_id), pool).await { + eprintln!("Failed to set thread category for thread {thread_id}: {e}"); + } + } + let canonical_channel_id_str = get_thread_channel_by_user_id(user_id, pool).await; let (target_channel_id, is_new_thread) = if let Some(canonical_id_str) = canonical_channel_id_str { @@ -160,7 +168,7 @@ pub async fn create_channel(ctx: &Context, msg: &Message, config: &Config) { } let (target_channel_id, _is_new_thread) = - match create_or_get_thread_for_user(ctx, config, msg.author.id).await { + match create_or_get_thread_for_user(ctx, config, msg.author.id, None, None).await { Ok(res) => res, Err(e) => { eprintln!("Failed to create or get thread: {}", e); diff --git a/crates/rustmail_panel/src/components/categories.rs b/crates/rustmail_panel/src/components/categories.rs new file mode 100644 index 00000000..cdb226b2 --- /dev/null +++ b/crates/rustmail_panel/src/components/categories.rs @@ -0,0 +1,605 @@ +use crate::components::forbidden::Forbidden403; +use crate::i18n::yew::use_translation; +use crate::types::PanelPermission; +use gloo_net::http::Request; +use serde::{Deserialize, Serialize}; +use wasm_bindgen_futures::spawn_local; +use web_sys::HtmlInputElement; +use yew::prelude::*; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CategoryDto { + pub id: String, + pub name: String, + pub description: Option, + pub emoji: Option, + pub discord_category_id: String, + pub position: i64, + pub enabled: bool, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CategorySettingsDto { + pub enabled: bool, + pub selection_timeout_s: i64, +} + +#[derive(Debug, Clone, Serialize)] +struct CreateCategoryRequest { + name: String, + description: Option, + emoji: Option, + discord_category_id: String, +} + +#[derive(Debug, Clone, Serialize, Default)] +struct UpdateCategoryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enabled: Option, +} + +async fn fetch_categories() -> Result, String> { + let resp = Request::get("/api/categories") + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() != 200 { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("HTTP {}: {}", status, body)); + } + resp.json::>() + .await + .map_err(|e| e.to_string()) +} + +async fn fetch_settings() -> Result { + let resp = Request::get("/api/categories/settings") + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() != 200 { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("HTTP {}: {}", status, body)); + } + resp.json::() + .await + .map_err(|e| e.to_string()) +} + +#[function_component(CategoriesPage)] +pub fn categories_page() -> Html { + let (i18n, _set_language) = use_translation(); + + let permissions = use_state(|| None::>); + { + let permissions = permissions.clone(); + use_effect_with((), move |_| { + spawn_local(async move { + if let Ok(resp) = Request::get("/api/user/permissions").send().await { + if let Ok(perms) = resp.json::>().await { + permissions.set(Some(perms)); + } + } + }); + || () + }); + } + + if let Some(perms) = (*permissions).as_ref() { + if !perms.contains(&PanelPermission::ManageCategories) { + return html! { + + }; + } + } else { + return html! { +
+
{i18n.t("panel.forbidden.checking_permissions")}
+
+ }; + } + + let categories = use_state(|| Vec::::new()); + let settings = use_state(|| None::); + let loading = use_state(|| true); + let error = use_state(|| None::); + let show_create_modal = use_state(|| false); + + let reload = { + let categories = categories.clone(); + let settings = settings.clone(); + let loading = loading.clone(); + let error = error.clone(); + let i18n = i18n.clone(); + Callback::from(move |_| { + let categories = categories.clone(); + let settings = settings.clone(); + let loading = loading.clone(); + let error = error.clone(); + let i18n = i18n.clone(); + spawn_local(async move { + loading.set(true); + let cats = fetch_categories().await; + let s = fetch_settings().await; + match (cats, s) { + (Ok(c), Ok(s)) => { + categories.set(c); + settings.set(Some(s)); + error.set(None); + } + (Err(e), _) | (_, Err(e)) => error.set(Some(format!( + "{}: {}", + i18n.t("panel.categories.error_load"), + e + ))), + } + loading.set(false); + }); + }) + }; + + { + let reload = reload.clone(); + use_effect_with((), move |_| { + reload.emit(()); + || () + }); + } + + let on_toggle_enabled = { + let settings = settings.clone(); + let reload = reload.clone(); + Callback::from(move |_| { + let current = match (*settings).clone() { + Some(s) => s, + None => return, + }; + let new_settings = CategorySettingsDto { + enabled: !current.enabled, + selection_timeout_s: current.selection_timeout_s, + }; + let reload = reload.clone(); + spawn_local(async move { + if let Ok(req) = Request::put("/api/categories/settings").json(&new_settings) { + let _ = req.send().await; + } + reload.emit(()); + }); + }) + }; + + let on_timeout_change = { + let settings = settings.clone(); + let reload = reload.clone(); + Callback::from(move |e: Event| { + let current = match (*settings).clone() { + Some(s) => s, + None => return, + }; + let input: HtmlInputElement = e.target_unchecked_into(); + let val = input + .value() + .parse::() + .unwrap_or(current.selection_timeout_s); + if val < 30 { + return; + } + let new_settings = CategorySettingsDto { + enabled: current.enabled, + selection_timeout_s: val, + }; + let reload = reload.clone(); + spawn_local(async move { + if let Ok(req) = Request::put("/api/categories/settings").json(&new_settings) { + let _ = req.send().await; + } + reload.emit(()); + }); + }) + }; + + let on_toggle_category = { + let reload = reload.clone(); + Callback::from(move |(id, enabled): (String, bool)| { + let body = UpdateCategoryRequest { + enabled: Some(enabled), + ..Default::default() + }; + let reload = reload.clone(); + spawn_local(async move { + let url = format!("/api/categories/{}", id); + if let Ok(req) = Request::patch(&url).json(&body) { + let _ = req.send().await; + } + reload.emit(()); + }); + }) + }; + + let on_delete_category = { + let reload = reload.clone(); + Callback::from(move |id: String| { + let reload = reload.clone(); + spawn_local(async move { + let url = format!("/api/categories/{}", id); + let _ = Request::delete(&url).send().await; + reload.emit(()); + }); + }) + }; + + let on_create_click = { + let show_create_modal = show_create_modal.clone(); + Callback::from(move |_| show_create_modal.set(true)) + }; + + let on_close_modal = { + let show_create_modal = show_create_modal.clone(); + Callback::from(move |_| show_create_modal.set(false)) + }; + + let on_created = { + let show_create_modal = show_create_modal.clone(); + let reload = reload.clone(); + Callback::from(move |_| { + show_create_modal.set(false); + reload.emit(()); + }) + }; + + html! { +
+
+

{i18n.t("panel.categories.title")}

+ +
+ + { + if let Some(s) = (*settings).clone() { + html! { +
+

{i18n.t("panel.categories.settings")}

+
+
+

{i18n.t("panel.categories.feature_enabled")}

+

{i18n.t("panel.categories.feature_enabled_help")}

+
+ +
+
+ + +

{i18n.t("panel.categories.timeout_help")}

+
+
+ } + } else { + html! {} + } + } + + { + if *loading { + html! { +
+

{i18n.t("panel.categories.loading")}

+
+ } + } else if let Some(err) = (*error).clone() { + html! { +
{err}
+ } + } else if categories.is_empty() { + html! { +
+

{i18n.t("panel.categories.no_categories")}

+
+ } + } else { + html! { +
+ { + categories.iter().map(|c| { + let cat = c.clone(); + let cat_id = cat.id.clone(); + let toggle_id = cat.id.clone(); + let on_toggle_category = on_toggle_category.clone(); + let on_delete_category = on_delete_category.clone(); + html! { + + } + }).collect::() + } +
+ } + } + } + + { + if *show_create_modal { + html! { + + } + } else { + html! {} + } + } +
+ } +} + +#[derive(Properties, PartialEq)] +struct CategoryCardProps { + category: CategoryDto, + on_toggle: Callback, + on_delete: Callback, +} + +#[function_component(CategoryCard)] +fn category_card(props: &CategoryCardProps) -> Html { + let (i18n, _set_language) = use_translation(); + let c = &props.category; + let enabled = c.enabled; + let on_toggle = props.on_toggle.clone(); + let on_delete = props.on_delete.clone(); + let cat_id = c.id.clone(); + + html! { +
+
+
+

+ { c.emoji.clone().unwrap_or_default() }{" "}{&c.name} +

+ { + if let Some(desc) = &c.description { + html! {

{desc}

} + } else { + html! {} + } + } +

+ {i18n.t("panel.categories.discord_category_id")}{": "}{&c.discord_category_id} +

+
+
+ + +
+
+
+ } +} + +#[derive(Properties, PartialEq)] +struct CreateCategoryModalProps { + on_close: Callback<()>, + on_created: Callback<()>, +} + +#[function_component(CreateCategoryModal)] +fn create_category_modal(props: &CreateCategoryModalProps) -> Html { + let (i18n, _set_language) = use_translation(); + let name_ref = use_node_ref(); + let desc_ref = use_node_ref(); + let emoji_ref = use_node_ref(); + let discord_id_ref = use_node_ref(); + let creating = use_state(|| false); + let error = use_state(|| None::); + + let on_submit = { + let name_ref = name_ref.clone(); + let desc_ref = desc_ref.clone(); + let emoji_ref = emoji_ref.clone(); + let discord_id_ref = discord_id_ref.clone(); + let creating = creating.clone(); + let error = error.clone(); + let on_created = props.on_created.clone(); + let i18n_clone = i18n.clone(); + + Callback::from(move |_| { + let name = name_ref + .cast::() + .map(|i| i.value()) + .unwrap_or_default(); + let discord_id = discord_id_ref + .cast::() + .map(|i| i.value()) + .unwrap_or_default(); + let desc = desc_ref + .cast::() + .map(|i| i.value()) + .filter(|s| !s.trim().is_empty()); + let emoji = emoji_ref + .cast::() + .map(|i| i.value()) + .filter(|s| !s.trim().is_empty()); + + if name.trim().is_empty() { + error.set(Some(i18n_clone.t("panel.categories.error_name_required"))); + return; + } + if discord_id.trim().is_empty() { + error.set(Some( + i18n_clone.t("panel.categories.error_discord_id_required"), + )); + return; + } + + let req = CreateCategoryRequest { + name, + description: desc, + emoji, + discord_category_id: discord_id, + }; + + let creating = creating.clone(); + let error = error.clone(); + let on_created = on_created.clone(); + let i18n_clone2 = i18n_clone.clone(); + creating.set(true); + spawn_local(async move { + match Request::post("/api/categories").json(&req) { + Ok(r) => match r.send().await { + Ok(resp) => { + if resp.status() == 200 { + error.set(None); + on_created.emit(()); + } else { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + error.set(Some(format!( + "{}: {} {}", + i18n_clone2.t("panel.categories.error_create"), + status, + text + ))); + } + } + Err(e) => { + error.set(Some(format!("{:?}", e))); + } + }, + Err(e) => { + error.set(Some(format!("{:?}", e))); + } + } + creating.set(false); + }); + }) + }; + + html! { +
+
+
+

{i18n.t("panel.categories.modal.title")}

+ + { + if let Some(err) = (*error).clone() { + html! { +
{err}
+ } + } else { + html! {} + } + } + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ } +} diff --git a/crates/rustmail_panel/src/components/mod.rs b/crates/rustmail_panel/src/components/mod.rs index a223c752..5239cab9 100644 --- a/crates/rustmail_panel/src/components/mod.rs +++ b/crates/rustmail_panel/src/components/mod.rs @@ -1,4 +1,5 @@ pub mod api_keys; +pub mod categories; pub mod configuration; pub mod forbidden; pub mod home; diff --git a/crates/rustmail_panel/src/components/navbar.rs b/crates/rustmail_panel/src/components/navbar.rs index b01d1660..919bf700 100644 --- a/crates/rustmail_panel/src/components/navbar.rs +++ b/crates/rustmail_panel/src/components/navbar.rs @@ -35,6 +35,7 @@ pub fn rustmail_navbar(props: &RustmailNavbarProps) -> Html { let home_active = current_path == "/panel"; let config_active = current_path == "/panel/configuration"; let apikeys_active = current_path == "/panel/apikeys"; + let categories_active = current_path == "/panel/categories"; let tickets_active = current_path.starts_with("/panel/tickets"); let admin_active = current_path == "/admin"; @@ -45,6 +46,9 @@ pub fn rustmail_navbar(props: &RustmailNavbarProps) -> Html { let has_manage_permissions = props .permissions .contains(&PanelPermission::ManagePermissions); + let has_manage_categories = props + .permissions + .contains(&PanelPermission::ManageCategories); html! {