diff --git a/src/core/bot.rs b/src/core/bot.rs index b9bf6ae..fc0a917 100644 --- a/src/core/bot.rs +++ b/src/core/bot.rs @@ -21,6 +21,8 @@ impl EventHandler for Handler { let http = ctx.http.clone(); let http2 = ctx.http.clone(); let gid_clone = self.guild_id; + let pool_clone = self.pool.clone(); + let ctx_clone = ctx.clone(); tokio::spawn(async move { // SAFETY: startup delay 2s - not in command path, lets cache fill, no jitter needed (one-shot on boot) tokio::time::sleep(std::time::Duration::from_secs(2)).await; @@ -39,8 +41,8 @@ impl EventHandler for Handler { { warn!(error=%e, "ensure_ticket_panel failed"); } - if crate::feature::ticket::forum::is_enabled() { - if let Err(e) = crate::feature::ticket::forum::ensure_forum_channel( + if crate::feature::ticket::service::forum::is_enabled() { + if let Err(e) = crate::feature::ticket::service::forum::ensure_forum_channel( &http2, serenity::all::GuildId::new(gid), ) @@ -49,6 +51,14 @@ impl EventHandler for Handler { warn!(error=%e, "ensure_forum_channel failed"); } } + crate::feature::ticket::service::count::reconcile_orphans(&http2, &pool_clone, gid) + .await; + let count = + crate::feature::ticket::service::count::count_tickets(&pool_clone, gid).await; + ctx_clone.set_presence( + Some(serenity::all::ActivityData::watching(format!("{} tickets", count))), + serenity::all::OnlineStatus::Online, + ); } }); if let Some(gid) = self.guild_id { @@ -62,9 +72,66 @@ impl EventHandler for Handler { } async fn guild_member_addition(&self, ctx: Context, member: serenity::all::Member) { - // Welcome + lang picker (both in English, no dead_code) crate::feature::welcome::handler::on_member_join(ctx.clone(), member.clone()).await; - crate::feature::lang::handler::on_member_join(ctx, member).await; + crate::feature::lang::handler::on_member_join(ctx.clone(), member.clone()).await; + crate::feature::moderation::logging::on_member_join(&ctx, &member).await; + } + + async fn guild_member_removal( + &self, + ctx: Context, + guild_id: serenity::all::GuildId, + user: serenity::all::User, + _member: Option, + ) { + crate::feature::moderation::logging::on_member_leave(&ctx, &user, Some(guild_id)).await; + } + + async fn message_update( + &self, + ctx: Context, + _old: Option, + new: Option, + event: serenity::all::MessageUpdateEvent, + ) { + if let Some(msg) = new { + if msg.author.bot { + return; + } + } + crate::feature::moderation::logging::on_message_edit(&ctx, &event).await; + } + + async fn message_delete( + &self, + ctx: Context, + channel_id: serenity::all::ChannelId, + deleted_message_id: serenity::all::MessageId, + guild_id: Option, + ) { + crate::feature::moderation::logging::on_message_delete( + &ctx, + channel_id, + deleted_message_id, + guild_id, + ) + .await; + } + + async fn message_delete_bulk( + &self, + ctx: Context, + channel_id: serenity::all::ChannelId, + multiple_deleted_messages_ids: Vec, + guild_id: Option, + ) { + let _ = guild_id; + crate::feature::moderation::logging::on_message_delete_bulk( + &ctx, + channel_id, + &multiple_deleted_messages_ids, + ) + .await; } async fn voice_state_update( @@ -91,7 +158,9 @@ impl EventHandler for Handler { serenity::all::Interaction::Modal(modal) => { let m = modal.clone(); if m.data.custom_id.starts_with("ticket_modal:") { - crate::feature::ticket::handler::on_modal(ctx, m).await; + crate::feature::ticket::handler::interaction::on_modal(ctx, m).await; + } else if m.data.custom_id == "ticket_close_modal" { + crate::feature::ticket::handler::claim::handle_close_modal(ctx, m).await; } } _ => {} diff --git a/src/db/migration.rs b/src/db/migration.rs index 8f948fb..1a8882d 100644 --- a/src/db/migration.rs +++ b/src/db/migration.rs @@ -12,8 +12,12 @@ pub async fn run(pool: &SqlitePool) -> anyhow::Result<()> { .execute(pool) .await?; - let migrations = - [("V1__initial", V1_INITIAL), ("V2__tickets", V2_TICKETS), ("V3__reminders", V3_REMINDERS)]; + let migrations = [ + ("V1__initial", V1_INITIAL), + ("V2__tickets", V2_TICKETS), + ("V3__reminders", V3_REMINDERS), + ("V4__ticket_cooldown", V4_COOLDOWN), + ]; for (name, sql) in migrations { let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _migrations WHERE name = ?") .bind(name) @@ -76,3 +80,11 @@ CREATE TABLE IF NOT EXISTS reminders ( sent INTEGER NOT NULL DEFAULT 0 ); "; +const V4_COOLDOWN: &str = " +CREATE TABLE IF NOT EXISTS ticket_cooldowns ( + user_id TEXT NOT NULL, + guild_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (user_id, guild_id) +); +"; diff --git a/src/feature/counter/task.rs b/src/feature/counter/task.rs index 10e7094..d9abc19 100644 --- a/src/feature/counter/task.rs +++ b/src/feature/counter/task.rs @@ -26,6 +26,28 @@ async fn ensure_channel(ctx: &Context, guild_id: GuildId) -> anyhow::Result::None), + ) + .await; + info!(channel=%id, "old counter migrated to new format"); return Ok(*id); } // Create outside category at position 0, deny CONNECT so users can't join, but VIEW remains diff --git a/src/feature/mod.rs b/src/feature/mod.rs index 0e30925..4aacb58 100644 --- a/src/feature/mod.rs +++ b/src/feature/mod.rs @@ -13,8 +13,15 @@ pub mod voice; pub mod welcome; use crate::config::BotConfig; +use crate::core::{dispatcher::RateLimiter, policy}; use poise::Framework; use sqlx::SqlitePool; +use std::sync::OnceLock; + +static RATE_LIMITER: OnceLock = OnceLock::new(); +fn limiter() -> &'static RateLimiter { + RATE_LIMITER.get_or_init(|| RateLimiter::new(policy::RATE_LIMIT_WINDOW_SECS)) +} pub struct Data { pub cfg: BotConfig, @@ -39,9 +46,14 @@ pub async fn build_framework( ticket::command::ticket_panel(), ticket::command::ticket_blacklist_add(), ticket::command::ticket_blacklist_remove(), + ticket::command::ticket_add(), + ticket::command::ticket_remove(), moderation::command::clear(), + moderation::command::purge(), moderation::command::slowmode(), moderation::command::warn(), + moderation::mute::mute(), + moderation::mute::unmute(), blog::command::blog_post(), poll::command::poll(), remind::command::remind(), @@ -50,6 +62,36 @@ pub async fn build_framework( prefix: Some(cfg.prefix.clone().into()), ..Default::default() }, + command_check: Some(|ctx| { + Box::pin(async move { + let key = format!("{}:{}", ctx.author().id, ctx.command().qualified_name); + if let Some(rem) = limiter().check(&key).await { + let _ = ctx + .send( + poise::CreateReply::default() + .content(format!("Rate limited β€” wait {:.1}s", rem.as_secs_f32())) + .ephemeral(true), + ) + .await; + return Ok(false); + } + let (q, _rx) = crate::core::queue::JobQueue::new( + policy::QUEUE_CAPACITY, + policy::MAX_HANDLER_CONCURRENCY, + ); + if q.try_submit(|| {}).is_err() { + let _ = ctx + .send( + poise::CreateReply::default() + .content("Server busy β€” try again in 2s") + .ephemeral(true), + ) + .await; + return Ok(false); + } + Ok(true) + }) + }), ..Default::default() }; diff --git a/src/feature/moderation/command.rs b/src/feature/moderation/command.rs index 4709014..13d500c 100644 --- a/src/feature/moderation/command.rs +++ b/src/feature/moderation/command.rs @@ -123,5 +123,46 @@ pub async fn warn( Ok(()) } +/// `/purge [user]` - bulk delete with optional user filter. +#[poise::command( + slash_command, + prefix_command, + required_permissions = "MANAGE_MESSAGES", + guild_only +)] +pub async fn purge( + ctx: Ctx<'_>, + #[description = "Amount 1-100"] amount: u8, + #[description = "Filter by user"] user: Option, +) -> anyhow::Result<()> { + let amount = amount.clamp(1, 100); + ctx.defer_ephemeral().await?; + let channel = ctx.channel_id(); + let msgs = channel.messages(ctx.http(), serenity::all::GetMessages::new().limit(100)).await?; + let ids: Vec<_> = msgs + .iter() + .filter(|m| user.as_ref().map(|u| m.author.id == u.id).unwrap_or(true)) + .take(amount as usize) + .map(|m| m.id) + .collect(); + if ids.len() >= 2 { + let _ = channel.delete_messages(ctx.http(), &ids).await; + } else if let Some(id) = ids.first() { + let _ = ctx.http().delete_message(channel, *id, None).await; + } + let desc = if let Some(u) = user { + format!("Purged {} messages from <@{}>", ids.len(), u.id) + } else { + format!("Purged {} messages", ids.len()) + }; + ctx.send( + CreateReply::default() + .embed(CreateEmbed::new().description(desc).color(0x0057_F387)) + .ephemeral(true), + ) + .await?; + Ok(()) +} + // Keep helper to avoid unused warning (used via get_lang). const _: Option) -> Option> = Some(lang_of); diff --git a/src/feature/moderation/logging.rs b/src/feature/moderation/logging.rs new file mode 100644 index 0000000..0e2810b --- /dev/null +++ b/src/feature/moderation/logging.rs @@ -0,0 +1,73 @@ +//! Mod-logs: join/leave/edit/delete β†’ #mod-logs (1509503710219210872). + +use serenity::all::{ChannelId, Context, CreateEmbed, CreateMessage, Member, MessageUpdateEvent}; + +const MOD_LOGS: ChannelId = ChannelId::new(1509503710219210872); + +pub async fn on_member_join(ctx: &Context, member: &Member) { + let embed = CreateEmbed::new() + .title("Member joined") + .description(format!("<@{}> `{}`", member.user.id, member.user.name)) + .color(0x0057_F387) + .thumbnail(member.user.avatar_url().unwrap_or_default()); + let _ = MOD_LOGS.send_message(&ctx.http, CreateMessage::new().embed(embed)).await; +} + +pub async fn on_member_leave( + ctx: &Context, + user: &serenity::all::User, + guild_id: Option, +) { + let embed = CreateEmbed::new() + .title("Member left") + .description(format!("<@{}> `{}` guild={:?}", user.id, user.name, guild_id)) + .color(0x00ED_4245); + let _ = MOD_LOGS.send_message(&ctx.http, CreateMessage::new().embed(embed)).await; +} + +pub async fn on_message_edit(ctx: &Context, event: &MessageUpdateEvent) { + let Some(content) = event.content.clone() else { return }; + if content.len() > 1000 { + return; + } + let embed = CreateEmbed::new() + .title("Message edited") + .description(format!( + "<#{}> by <@{}>\n```{}```", + event.channel_id, + event.author.as_ref().map(|a| a.id.to_string()).unwrap_or_default(), + content.chars().take(900).collect::() + )) + .color(0x00FAA81A); + let _ = MOD_LOGS.send_message(&ctx.http, CreateMessage::new().embed(embed)).await; +} + +pub async fn on_message_delete( + ctx: &Context, + channel_id: ChannelId, + message_id: serenity::all::MessageId, + guild_id: Option, +) { + let embed = CreateEmbed::new() + .title("Message deleted") + .description(format!( + "<#{}> message `{}` guild={:?}", + channel_id, + message_id.get(), + guild_id + )) + .color(0x0057_F387); + let _ = MOD_LOGS.send_message(&ctx.http, CreateMessage::new().embed(embed)).await; +} + +pub async fn on_message_delete_bulk( + ctx: &Context, + channel_id: ChannelId, + ids: &[serenity::all::MessageId], +) { + let embed = CreateEmbed::new() + .title("Messages purged") + .description(format!("<#{}> {} messages bulk deleted", channel_id, ids.len())) + .color(0x00ED_4245); + let _ = MOD_LOGS.send_message(&ctx.http, CreateMessage::new().embed(embed)).await; +} diff --git a/src/feature/moderation/mod.rs b/src/feature/moderation/mod.rs index 9fe7961..9d78195 100644 --- a/src/feature/moderation/mod.rs +++ b/src/feature/moderation/mod.rs @@ -1 +1,3 @@ pub mod command; +pub mod logging; +pub mod mute; diff --git a/src/feature/moderation/mute.rs b/src/feature/moderation/mute.rs new file mode 100644 index 0000000..0290b20 --- /dev/null +++ b/src/feature/moderation/mute.rs @@ -0,0 +1,110 @@ +//! Mute/unmute via timeout + Muted role (1547203722684137492). + +use crate::feature::Ctx; +use poise::CreateReply; +use serenity::all::{CreateEmbed, Timestamp}; + +const MUTED_ROLE: u64 = 1547203722684137492; + +fn parse_duration(s: &str) -> Option { + let s = s.trim().to_lowercase(); + if s.is_empty() { + return None; + } + let (num_str, unit) = s.split_at(s.len() - 1); + let n: u64 = if unit.chars().next().unwrap().is_ascii_digit() { + s.parse().ok()? + } else { + num_str.parse().ok()? + }; + let secs = match unit { + "s" if !s.chars().last().unwrap().is_ascii_digit() => n, + "m" => n * 60, + "h" => n * 3600, + "d" => n * 86400, + _ if s.chars().last().unwrap().is_ascii_digit() => n * 60, + _ => return None, + }; + if secs == 0 || secs > 2419200 { + return None; + } + Some(std::time::Duration::from_secs(secs)) +} + +/// `/mute @user 10m reason` - timeout + Muted role. +#[poise::command( + slash_command, + prefix_command, + guild_only, + required_permissions = "MODERATE_MEMBERS" +)] +pub async fn mute( + ctx: Ctx<'_>, + #[description = "User to mute"] user: serenity::all::User, + #[description = "Duration 10m/1h/1d or seconds"] duration: String, + #[description = "Reason"] reason: Option, +) -> anyhow::Result<()> { + let Some(guild_id) = ctx.guild_id() else { + return Ok(()); + }; + let dur = parse_duration(&duration).unwrap_or(std::time::Duration::from_secs(600)); + ctx.defer_ephemeral().await?; + let mut member = guild_id.member(ctx.http(), user.id).await?; + let secs = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + + dur.as_secs(); + let until = Timestamp::from_unix_timestamp(secs as i64).unwrap_or_else(|_| Timestamp::now()); + let _ = member.disable_communication_until_datetime(ctx.http(), until).await; + let _ = member.add_role(ctx.http(), serenity::all::RoleId::new(MUTED_ROLE)).await; + let reason = reason.unwrap_or_else(|| "No reason".to_string()); + let embed = CreateEmbed::new() + .title(format!("Muted {}", user.name)) + .description(format!("Duration: {}s\nReason: {}", dur.as_secs(), reason)) + .color(0x00ED_4245); + ctx.send(CreateReply::default().embed(embed).ephemeral(true)).await?; + log_mod(&ctx, &format!("Mute <@{}> {}s reason: {}", user.id, dur.as_secs(), reason)).await; + Ok(()) +} + +/// `/unmute @user` +#[poise::command( + slash_command, + prefix_command, + guild_only, + required_permissions = "MODERATE_MEMBERS" +)] +pub async fn unmute( + ctx: Ctx<'_>, + #[description = "User to unmute"] user: serenity::all::User, +) -> anyhow::Result<()> { + let Some(guild_id) = ctx.guild_id() else { + return Ok(()); + }; + ctx.defer_ephemeral().await?; + let mut member = guild_id.member(ctx.http(), user.id).await?; + let _ = member.enable_communication(ctx.http()).await; + let _ = member.remove_role(ctx.http(), serenity::all::RoleId::new(MUTED_ROLE)).await; + ctx.send( + CreateReply::default() + .embed( + CreateEmbed::new() + .description(format!("Unmuted <@{}>", user.id)) + .color(0x0057_F387), + ) + .ephemeral(true), + ) + .await?; + log_mod(&ctx, &format!("Unmute <@{}>", user.id)).await; + Ok(()) +} + +async fn log_mod(ctx: &Ctx<'_>, text: &str) { + let ch = serenity::all::ChannelId::new(1509503710219210872); + let _ = ch + .send_message( + ctx.http(), + serenity::all::CreateMessage::new() + .embed(CreateEmbed::new().title("Mod log").description(text).color(0x00ED_4245)), + ) + .await; +} diff --git a/src/feature/ticket/command.rs b/src/feature/ticket/command.rs index cf6ee86..4cd87fb 100644 --- a/src/feature/ticket/command.rs +++ b/src/feature/ticket/command.rs @@ -69,3 +69,80 @@ pub async fn ticket_blacklist_remove( .await?; Ok(()) } + +/// Adds user to current ticket (staff only, must be ticket channel). +#[poise::command( + slash_command, + prefix_command, + guild_only, + required_permissions = "MANAGE_CHANNELS" +)] +pub async fn ticket_add( + ctx: Ctx<'_>, + #[description = "User to add"] user: serenity::all::User, +) -> anyhow::Result<()> { + let channel_id = ctx.channel_id(); + if let Some(pool) = service::pool() { + if service::get_opener(pool, channel_id.get()).await.is_none() { + ctx.send(CreateReply::default().content("Not a ticket channel.").ephemeral(true)) + .await?; + return Ok(()); + } + } + service::add_user_to_ticket(ctx.http(), channel_id, user.id.get()).await?; + let _ = channel_id + .send_message( + ctx.http(), + serenity::all::CreateMessage::new().content(format!( + "<@{}> added <@{}> to ticket", + ctx.author().id, + user.id + )), + ) + .await; + ctx.send(CreateReply::default().content(format!("Added <@{}>", user.id)).ephemeral(true)) + .await?; + Ok(()) +} + +/// Removes user from current ticket. +#[poise::command( + slash_command, + prefix_command, + guild_only, + required_permissions = "MANAGE_CHANNELS" +)] +pub async fn ticket_remove( + ctx: Ctx<'_>, + #[description = "User to remove"] user: serenity::all::User, +) -> anyhow::Result<()> { + let channel_id = ctx.channel_id(); + if let Some(pool) = service::pool() { + if service::get_opener(pool, channel_id.get()).await.is_none() { + ctx.send(CreateReply::default().content("Not a ticket channel.").ephemeral(true)) + .await?; + return Ok(()); + } + if Some(user.id.get()) == service::get_opener(pool, channel_id.get()).await { + ctx.send( + CreateReply::default().content("Cannot remove ticket opener.").ephemeral(true), + ) + .await?; + return Ok(()); + } + } + service::remove_user_from_ticket(ctx.http(), channel_id, user.id.get()).await?; + let _ = channel_id + .send_message( + ctx.http(), + serenity::all::CreateMessage::new().content(format!( + "<@{}> removed <@{}>", + ctx.author().id, + user.id + )), + ) + .await; + ctx.send(CreateReply::default().content(format!("Removed <@{}>", user.id)).ephemeral(true)) + .await?; + Ok(()) +} diff --git a/src/feature/ticket/handler.rs b/src/feature/ticket/handler.rs deleted file mode 100644 index 51388a2..0000000 --- a/src/feature/ticket/handler.rs +++ /dev/null @@ -1,690 +0,0 @@ -//! Ticket handler - SelectMenu + Modal + private channels (or Forum posts if TICKET_USE_FORUM), claim/close with transcript. -use super::service; -use crate::feature::ticket::forum; -use serenity::all::{ - AutoArchiveDuration, ChannelId, ChannelType, ComponentInteraction, Context, CreateActionRow, - CreateChannel, CreateEmbed, CreateForumPost, CreateInteractionResponse, - CreateInteractionResponseMessage, CreateMessage, CreateModal, CreateSelectMenu, - CreateSelectMenuKind, CreateSelectMenuOption, GuildId, InputTextStyle, ModalInteraction, - PermissionOverwrite, PermissionOverwriteType, Permissions, -}; -use tracing::{info, warn}; - -const SUPPORT_CATEGORY: u64 = 1547213779224363008; -const TICKET_LOGS: u64 = 1509503717127356446; -/// Dedicated panel channel (#open-ticket / #🎫|open-ticket) -const TICKET_PANEL_CHANNEL: u64 = 1509503682914549861; - -/// Canonical topics - single source of truth for select menu + modal title + validation. -struct Topic { - value: &'static str, - label: &'static str, - emoji: char, - desc: &'static str, -} -const TOPICS: &[Topic] = &[ - Topic { - value: "general", - label: "General support", - emoji: 'πŸ› ', - desc: "Questions about bot / server", - }, - Topic { value: "bug", label: "Bug report", emoji: 'πŸ›', desc: "Report a bug or error" }, - Topic { - value: "appeal", label: "Mute / ban appeal", emoji: 'πŸ”‡', desc: "Appeal a punishment" - }, - Topic { value: "report", label: "Report user", emoji: '😑', desc: "Report rule violation" }, - Topic { - value: "owners", - label: "Contact owners", - emoji: 'πŸ‘‘', - desc: "Private contact with owners", - }, -]; - -fn topic_label(value: &str) -> Option<&'static str> { - TOPICS.iter().find(|t| t.value == value).map(|t| t.label) -} - -// --------------------------------------------------------------------------- -// Panel builders (pure, reusable) -// --------------------------------------------------------------------------- - -pub fn panel_embed() -> CreateEmbed { - CreateEmbed::new() - .title("🎫 Support tickets") - .description( - "**Choose a topic below to open a private ticket.**\n\ - \n\ - β€’ Staff will respond shortly β€” please describe your issue clearly.\n\ - β€’ One open ticket per user.\n\ - β€’ Abuse / spam β†’ blacklist.", - ) - .color(0x0058_65F2) - .footer(serenity::all::CreateEmbedFooter::new( - "Loki Dev β€’ Select topic β†’ fill form β†’ ticket created", - )) -} - -pub fn panel_select_row() -> CreateActionRow { - let options: Vec = TOPICS - .iter() - .map(|t| CreateSelectMenuOption::new(t.label, t.value).description(t.desc).emoji(t.emoji)) - .collect(); - let menu = CreateSelectMenu::new("ticket_topic", CreateSelectMenuKind::String { options }) - .placeholder("Choose topic…") - .min_values(1) - .max_values(1); - CreateActionRow::SelectMenu(menu) -} - -/// Ensures panel exists in dedicated channel. Called on Ready + on demand from /ticket_panel. -/// Idempotent: checks last 20 messages for bot's panel (select `ticket_topic`), creates if missing. -pub async fn ensure_ticket_panel( - http: &serenity::http::Http, - guild_id: GuildId, -) -> anyhow::Result<()> { - // Prefer constant id, fallback to search by name "open-ticket" - let channel_id = - resolve_panel_channel(http, guild_id).await.unwrap_or(ChannelId::new(TICKET_PANEL_CHANNEL)); - let msgs = channel_id.messages(http, serenity::all::GetMessages::new().limit(20)).await?; - let has_panel = msgs.iter().any(|m| { - m.author.id.get() == 1527966600173457408 - && m.components.iter().any(|row| { - row.components.iter().any(|c| match c { - serenity::all::ActionRowComponent::SelectMenu(sm) => { - sm.custom_id.as_deref() == Some("ticket_topic") - } - _ => false, - }) - }) - }); - if has_panel { - info!(channel=%channel_id, "ticket panel already exists"); - return Ok(()); - } - channel_id - .send_message( - http, - CreateMessage::new().embed(panel_embed()).components(vec![panel_select_row()]), - ) - .await?; - info!(channel=%channel_id, "ticket panel created on boot"); - Ok(()) -} - -async fn resolve_panel_channel( - http: &serenity::http::Http, - guild_id: GuildId, -) -> Option { - if let Ok(channels) = guild_id.channels(http).await { - if let Some((id, _)) = channels.iter().find(|(_, c)| c.name.contains("open-ticket")) { - return Some(*id); - } - } - None -} - -// --------------------------------------------------------------------------- -// Interaction routing -// --------------------------------------------------------------------------- - -/// Handles ticket buttons (claim/close) + legacy create buttons. -pub async fn on_button(ctx: Context, interaction: ComponentInteraction) { - match interaction.data.custom_id.as_str() { - "ticket_general" | "ticket_bug" => handle_create_legacy(ctx, interaction).await, - "ticket_claim" => handle_claim(ctx, interaction).await, - "ticket_close" => handle_close(ctx, interaction).await, - _ => {} - } -} - -/// Handles select menu `ticket_topic` -> shows modal with subject/description. -pub async fn on_select(ctx: Context, interaction: ComponentInteraction) { - if interaction.data.custom_id != "ticket_topic" { - return; - } - let topic = match &interaction.data.kind { - serenity::all::ComponentInteractionDataKind::StringSelect { values } => { - values.first().cloned().unwrap_or_default() - } - _ => return, - }; - if topic.is_empty() { - return; - } - let Some(label) = topic_label(&topic) else { - let _ = interaction - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content(format!("Unknown topic: {topic}")) - .ephemeral(true), - ), - ) - .await; - return; - }; - // Pre-checks before showing modal (blacklist / existing ticket) to avoid wasted modal. - if let Some(guild_id) = interaction.guild_id { - if let Some(pool) = service::pool() { - if let Some(reason) = - service::is_blacklisted(pool, guild_id.get(), interaction.user.id.get()).await - { - let _ = interaction - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content(format!("You are blacklisted: {reason}")) - .ephemeral(true), - ), - ) - .await; - return; - } - } - if let Ok(channels) = guild_id.channels(&ctx.http).await { - let exists = channels.values().any(|c| { - c.name.starts_with(&format!("ticket-{}", interaction.user.name.to_lowercase())) - || c.name.starts_with(&format!("ticket-{}", interaction.user.id)) - }); - if exists { - let _ = interaction - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content("You already have an open ticket.") - .ephemeral(true), - ), - ) - .await; - return; - } - } - } - let modal = CreateModal::new(format!("ticket_modal:{topic}"), format!("New ticket β€’ {label}")) - .components(vec![ - CreateActionRow::InputText( - serenity::all::CreateInputText::new(InputTextStyle::Short, "Subject", "subject") - .placeholder("Brief title (3-100 chars)") - .min_length(3) - .max_length(100) - .required(true), - ), - CreateActionRow::InputText( - serenity::all::CreateInputText::new( - InputTextStyle::Paragraph, - "Description", - "description", - ) - .placeholder("Describe your issue in detail (10-1000 chars)") - .min_length(10) - .max_length(1000) - .required(true), - ), - ]); - let _ = interaction.create_response(&ctx.http, CreateInteractionResponse::Modal(modal)).await; -} - -/// Handles modal submit `ticket_modal:` -> creates private ticket channel. -pub async fn on_modal(ctx: Context, interaction: ModalInteraction) { - let Some(topic) = interaction.data.custom_id.strip_prefix("ticket_modal:") else { - return; - }; - let Some(label) = topic_label(topic) else { - let _ = interaction - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content("Unknown ticket topic.") - .ephemeral(true), - ), - ) - .await; - return; - }; - let subject = - extract_modal_field(&interaction, "subject").unwrap_or_else(|| "No subject".into()); - let description = extract_modal_field(&interaction, "description").unwrap_or_default(); - // Basic validation mirrored from modal (discord enforces, but double-check) - if subject.trim().len() < 3 || description.trim().len() < 10 { - let _ = interaction - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content("Subject β‰₯3 and description β‰₯10 chars required.") - .ephemeral(true), - ), - ) - .await; - return; - } - let Some(guild_id) = interaction.guild_id else { - return; - }; - let user = interaction.user.clone(); - // Re-check blacklist / duplicate (race between modal open and submit) - if let Some(pool) = service::pool() { - if let Some(reason) = service::is_blacklisted(pool, guild_id.get(), user.id.get()).await { - let _ = interaction - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content(format!("You are blacklisted: {reason}")) - .ephemeral(true), - ), - ) - .await; - return; - } - } - if let Ok(channels) = guild_id.channels(&ctx.http).await { - let exists = channels.values().any(|c| { - c.name.starts_with(&format!("ticket-{}", user.name.to_lowercase())) - || c.name.starts_with(&format!("ticket-{}", user.id)) - }); - if exists { - let _ = interaction - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content("You already have an open ticket.") - .ephemeral(true), - ), - ) - .await; - return; - } - } - // Defer ephemeral thinking before heavy channel create (avoid 3s timeout) - let _ = interaction - .create_response( - &ctx.http, - CreateInteractionResponse::Defer( - CreateInteractionResponseMessage::new().ephemeral(true), - ), - ) - .await; - // Use helper that supports details - match create_ticket_channel(&ctx, guild_id, &user, topic, label, &subject, &description).await { - Ok(channel_id) => { - let _ = interaction - .create_followup( - &ctx.http, - serenity::all::CreateInteractionResponseFollowup::new() - .content(format!("Ticket created: <#{channel_id}>")) - .ephemeral(true), - ) - .await; - } - Err(e) => { - warn!(error=%e, "failed to create ticket via modal"); - let _ = interaction - .create_followup( - &ctx.http, - serenity::all::CreateInteractionResponseFollowup::new() - .content("Failed to create ticket. Try again later.") - .ephemeral(true), - ) - .await; - } - } -} - -fn extract_modal_field(modal: &ModalInteraction, custom_id: &str) -> Option { - for row in &modal.data.components { - for comp in &row.components { - if let serenity::all::ActionRowComponent::InputText(it) = comp { - if it.custom_id == custom_id { - return it.value.clone(); - } - } - } - } - None -} - -// --------------------------------------------------------------------------- -// Channel creation (shared for legacy + modal) -// --------------------------------------------------------------------------- - -async fn create_ticket_channel( - ctx: &Context, - guild_id: GuildId, - user: &serenity::all::User, - topic: &str, - label: &str, - subject: &str, - description: &str, -) -> anyhow::Result { - // Forum branch (if TICKET_USE_FORUM=true and forum available) - each ticket = forum post with tag - if forum::is_enabled() { - if let Ok(forum_id) = forum::ensure_forum_channel(&ctx.http, guild_id).await { - if let Some(tag_id) = forum::tag_id_for_async(&ctx.http, forum_id, topic).await { - let embed = CreateEmbed::new() - .title(subject) - .description(format!( - "Hello <@{}> β€” staff will be with you shortly.\n\n**Topic:** {label} (`{topic}`)\n**Description:**\n{description}", - user.id - )) - .color(0x0058_65F2) - .footer(serenity::all::CreateEmbedFooter::new(format!( - "Opened by {} β€’ {}", - user.name, topic - ))); - // Forum post name must be 1-100 chars, use sanitized subject - let post_name = subject.chars().take(90).collect::(); - let post_name = if post_name.trim().len() < 2 { - format!("ticket-{topic}-{}", sanitize_name(&user.name)) - } else { - post_name - }; - match forum_id - .create_forum_post( - &ctx.http, - CreateForumPost::new(post_name.clone(), CreateMessage::new().embed(embed)) - .add_applied_tag(tag_id) - .auto_archive_duration(AutoArchiveDuration::OneWeek), - ) - .await - { - Ok(thread) => { - let thread_id = thread.id; - if let Some(pool) = service::pool() { - service::record_ticket( - pool, - thread_id.get(), - guild_id.get(), - user.id.get(), - ) - .await; - } - let row = serenity::all::CreateActionRow::Buttons(vec![ - serenity::all::CreateButton::new("ticket_claim") - .label("Claim") - .style(serenity::all::ButtonStyle::Primary) - .emoji('πŸ™‹'), - serenity::all::CreateButton::new("ticket_close") - .label("Close") - .style(serenity::all::ButtonStyle::Danger) - .emoji('πŸ”’'), - ]); - let _ = thread_id - .send_message( - &ctx.http, - CreateMessage::new() - .content(format!("<@{}> β€” staff control", user.id)) - .components(vec![row]), - ) - .await; - let _ = ChannelId::new(TICKET_LOGS) - .send_message( - &ctx.http, - CreateMessage::new().embed( - CreateEmbed::new() - .title("Ticket opened (forum)") - .description(format!( - "<@{}> opened forum post <#{}> β€” **{label}**\nSubject: {subject}", - user.id, thread_id - )) - .color(0x0058_65F2), - ), - ) - .await; - info!(user=%user.name, channel=%thread_id, topic=%topic, forum=%forum_id, "ticket created via forum"); - return Ok(thread_id); - } - Err(e) => { - warn!(error=%e, "forum post failed, falling back to channel"); - } - } - } - } - } - // Fallback: classic text channel in SUPPORT category - let safe_user = sanitize_name(&user.name); - let name = format!("ticket-{topic}-{safe_user}"); - let name = name.chars().take(90).collect::(); - let overwrites = build_ticket_overwrites(user.id.get()); - let channel = guild_id - .create_channel( - &ctx.http, - CreateChannel::new(&name) - .kind(ChannelType::Text) - .category(ChannelId::new(SUPPORT_CATEGORY)) - .permissions(overwrites), - ) - .await?; - if let Some(pool) = service::pool() { - service::record_ticket(pool, channel.id.get(), guild_id.get(), user.id.get()).await; - } - let embed = CreateEmbed::new() - .title(subject) - .description(format!( - "Hello <@{}> β€” staff will be with you shortly.\n\n**Topic:** {label} (`{topic}`)\n**Description:**\n{description}", - user.id - )) - .color(0x0058_65F2) - .footer(serenity::all::CreateEmbedFooter::new(format!("Opened by {} β€’ {}", user.name, topic))); - let row = serenity::all::CreateActionRow::Buttons(vec![ - serenity::all::CreateButton::new("ticket_claim") - .label("Claim") - .style(serenity::all::ButtonStyle::Primary) - .emoji('πŸ™‹'), - serenity::all::CreateButton::new("ticket_close") - .label("Close") - .style(serenity::all::ButtonStyle::Danger) - .emoji('πŸ”’'), - ]); - let _ = channel - .send_message(&ctx.http, CreateMessage::new().embed(embed).components(vec![row])) - .await; - let _ = ChannelId::new(TICKET_LOGS) - .send_message( - &ctx.http, - CreateMessage::new().embed( - CreateEmbed::new() - .title("Ticket opened") - .description(format!( - "<@{}> opened <#{}> β€” **{label}**\nSubject: {subject}", - user.id, channel.id - )) - .color(0x0058_65F2), - ), - ) - .await; - info!(user=%user.name, channel=%channel.id, topic=%topic, "ticket created via modal"); - Ok(channel.id) -} - -fn sanitize_name(s: &str) -> String { - let lower = s.to_lowercase().replace(' ', "-"); - let mut out = String::new(); - for ch in lower.chars() { - if ch.is_ascii_alphanumeric() || ch == '-' { - out.push(ch); - } else { - out.push('-'); - } - } - let collapsed = out.split('-').filter(|p| !p.is_empty()).collect::>().join("-"); - if collapsed.is_empty() { "user".into() } else { collapsed } -} - -async fn ephemeral(ctx: &Context, i: &ComponentInteraction, content: String) { - let _ = i - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new().content(content).ephemeral(true), - ), - ) - .await; -} - -async fn handle_create_legacy(ctx: Context, interaction: ComponentInteraction) { - // Legacy button path - create with generic subject/desc - let Some(guild_id) = interaction.guild_id else { return }; - let user = interaction.user.clone(); - let custom = interaction.data.custom_id.clone(); - if let Some(pool) = service::pool() { - if let Some(reason) = service::is_blacklisted(pool, guild_id.get(), user.id.get()).await { - ephemeral(&ctx, &interaction, format!("You are blacklisted: {reason}")).await; - return; - } - } - let (topic, label) = - if custom == "ticket_bug" { ("bug", "Bug report") } else { ("general", "General support") }; - if let Ok(channels) = guild_id.channels(&ctx.http).await { - let exists = channels.values().any(|c| { - c.name.starts_with(&format!("ticket-{}", user.name.to_lowercase())) - || c.name.starts_with(&format!("ticket-{}", user.id)) - }); - if exists { - ephemeral(&ctx, &interaction, "You already have an open ticket.".into()).await; - return; - } - } - match create_ticket_channel( - &ctx, - guild_id, - &user, - topic, - label, - &format!("Ticket - {label}"), - "Opened via legacy button.", - ) - .await - { - Ok(cid) => ephemeral(&ctx, &interaction, format!("Ticket created: <#{cid}>")).await, - Err(e) => { - warn!(error=%e, "failed to create legacy ticket"); - ephemeral(&ctx, &interaction, "Failed to create ticket.".into()).await; - } - } -} - -async fn handle_claim(ctx: Context, interaction: ComponentInteraction) { - let Some(guild_id) = interaction.guild_id else { return }; - let channel_id = interaction.channel_id; - let claimer = interaction.user.clone(); - if let Some(pool) = service::pool() { - if let Some(existing) = service::get_claim(pool, channel_id.get()).await { - ephemeral(&ctx, &interaction, format!("Already claimed by <@{existing}>")).await; - return; - } - if !service::try_claim(pool, channel_id.get(), guild_id.get(), claimer.id.get()).await { - ephemeral(&ctx, &interaction, "Claim failed, already claimed.".into()).await; - return; - } - } - ephemeral(&ctx, &interaction, format!("Ticket claimed by <@{}>", claimer.id)).await; - let _ = channel_id - .send_message( - &ctx.http, - CreateMessage::new().embed( - CreateEmbed::new() - .title("Ticket claimed") - .description(format!("Claimed by <@{}>", claimer.id)) - .footer(serenity::all::CreateEmbedFooter::new(format!( - "Claimed by {}", - claimer.name - ))) - .color(0x0057_F387), - ), - ) - .await; - let _ = ChannelId::new(TICKET_LOGS) - .send_message( - &ctx.http, - CreateMessage::new().embed( - CreateEmbed::new() - .title("Ticket claimed") - .description(format!("<@{}> claimed ticket <#{}>", claimer.id, channel_id)) - .color(0x0057_F387), - ), - ) - .await; - info!(channel=%channel_id, claimer=%claimer.id, "ticket claimed"); -} - -async fn handle_close(ctx: Context, interaction: ComponentInteraction) { - let channel_id = interaction.channel_id; - let user = interaction.user.clone(); - ephemeral(&ctx, &interaction, "Closing ticket... transcript will be saved.".into()).await; - let transcript = service::fetch_transcript(&ctx.http, channel_id).await; - let haste_url = service::upload_hastebin(&transcript).await; - service::deliver_transcript( - &ctx.http, - channel_id, - user.id.get(), - transcript.clone(), - haste_url.clone(), - ) - .await; - if let Some(pool) = service::pool() { - service::cleanup_ticket(pool, channel_id.get()).await; - } - tokio::time::sleep(std::time::Duration::from_secs(2)).await; // SAFETY: small delay to let log flush, not in command path, jitter not needed - // Forum threads: archive+lock instead of delete (keeps history, respects forum lifecycle) - let is_thread = ctx - .http - .get_channel(channel_id) - .await - .map(|c| matches!(c, serenity::all::Channel::Guild(gc) if gc.thread_metadata.is_some())) - .unwrap_or(false); - if is_thread { - let _ = channel_id - .edit_thread(&ctx.http, serenity::all::EditThread::new().archived(true).locked(true)) - .await; - // Try delete if archive fails (fallback) - if ctx.http.get_channel(channel_id).await.is_ok() { - // Already archived, no delete needed; optionally delete after archive - } - info!(channel=%channel_id, thread=true, "ticket thread closed (archived)"); - } else { - let _ = channel_id.delete(&ctx.http).await; - info!(channel=%channel_id, "ticket closed"); - } -} - -fn allow_view(id: serenity::all::RoleId) -> PermissionOverwrite { - PermissionOverwrite { - allow: Permissions::VIEW_CHANNEL - | Permissions::SEND_MESSAGES - | Permissions::READ_MESSAGE_HISTORY, - deny: Permissions::empty(), - kind: PermissionOverwriteType::Role(id), - } -} -fn build_ticket_overwrites(user_id: u64) -> Vec { - let everyone = serenity::all::RoleId::new(1509503154708811837); - let staff = serenity::all::RoleId::new(1547203757711040572); - let owner = serenity::all::RoleId::new(1547203762035236925); - let moderator = serenity::all::RoleId::new(1547203753373859901); - let user = serenity::all::UserId::new(user_id); - vec![ - PermissionOverwrite { - allow: Permissions::empty(), - deny: Permissions::VIEW_CHANNEL, - kind: PermissionOverwriteType::Role(everyone), - }, - PermissionOverwrite { - allow: Permissions::VIEW_CHANNEL - | Permissions::SEND_MESSAGES - | Permissions::READ_MESSAGE_HISTORY, - deny: Permissions::empty(), - kind: PermissionOverwriteType::Member(user), - }, - allow_view(staff), - allow_view(owner), - allow_view(moderator), - ] -} diff --git a/src/feature/ticket/handler/claim.rs b/src/feature/ticket/handler/claim.rs new file mode 100644 index 0000000..c0eaa1a --- /dev/null +++ b/src/feature/ticket/handler/claim.rs @@ -0,0 +1,281 @@ +//! Ticket claim/close (staff-only claim). + +use crate::feature::ticket::service; +use serenity::all::{ChannelId, Context, CreateEmbed, CreateMessage}; +use tracing::info; + +async fn ephemeral(ctx: &Context, i: &serenity::all::ComponentInteraction, content: String) { + let _ = i + .create_response( + &ctx.http, + serenity::all::CreateInteractionResponse::Message( + serenity::all::CreateInteractionResponseMessage::new() + .content(content) + .ephemeral(true), + ), + ) + .await; +} + +pub async fn handle_claim(ctx: Context, interaction: serenity::all::ComponentInteraction) { + let Some(guild_id) = interaction.guild_id else { return }; + let channel_id = interaction.channel_id; + let claimer = interaction.user.clone(); + if let Some(pool) = service::pool() { + if let Some(opener) = service::get_opener(pool, channel_id.get()).await { + if opener == claimer.id.get() { + ephemeral(&ctx, &interaction, "You cannot claim your own ticket.".into()).await; + return; + } + } + } + const STAFF_ROLES: &[u64] = &[1547203757711040572, 1547203762035236925, 1547203753373859901]; + let is_staff = match guild_id.member(&ctx.http, claimer.id).await { + Ok(member) => member.roles.iter().any(|r| STAFF_ROLES.contains(&r.get())), + Err(_) => false, + }; + if !is_staff { + ephemeral(&ctx, &interaction, "Only staff can claim tickets.".into()).await; + return; + } + if let Some(pool) = service::pool() { + if let Some(existing) = service::get_claim(pool, channel_id.get()).await { + ephemeral(&ctx, &interaction, format!("Already claimed by <@{existing}>")).await; + return; + } + if !service::try_claim(pool, channel_id.get(), guild_id.get(), claimer.id.get()).await { + ephemeral(&ctx, &interaction, "Claim failed, already claimed.".into()).await; + return; + } + } + ephemeral(&ctx, &interaction, format!("Ticket claimed by <@{}>", claimer.id)).await; + let _ = channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed( + CreateEmbed::new() + .title("Ticket claimed") + .description(format!("Claimed by <@{}>", claimer.id)) + .footer(serenity::all::CreateEmbedFooter::new(format!( + "Claimed by {}", + claimer.name + ))) + .color(0x0057_F387), + ), + ) + .await; + let _ = ChannelId::new(super::TICKET_LOGS) + .send_message( + &ctx.http, + CreateMessage::new().embed( + CreateEmbed::new() + .title("Ticket claimed") + .description(format!("<@{}> claimed ticket <#{}>", claimer.id, channel_id)) + .color(0x0057_F387), + ), + ) + .await; + info!(channel=%channel_id, claimer=%claimer.id, "ticket claimed"); +} + +pub async fn handle_close(ctx: Context, interaction: serenity::all::ComponentInteraction) { + let modal = + serenity::all::CreateModal::new("ticket_close_modal", "Close ticket").components(vec![ + serenity::all::CreateActionRow::InputText( + serenity::all::CreateInputText::new( + serenity::all::InputTextStyle::Paragraph, + "Reason", + "close_reason", + ) + .placeholder("Why are you closing? (10-500 chars)") + .min_length(10) + .max_length(500) + .required(true), + ), + ]); + let _ = interaction + .create_response(&ctx.http, serenity::all::CreateInteractionResponse::Modal(modal)) + .await; +} +pub async fn handle_close_modal(ctx: Context, interaction: serenity::all::ModalInteraction) { + if interaction.data.custom_id != "ticket_close_modal" { + return; + } + let reason = interaction + .data + .components + .iter() + .flat_map(|r| &r.components) + .find_map(|c| match c { + serenity::all::ActionRowComponent::InputText(it) if it.custom_id == "close_reason" => { + it.value.clone() + } + _ => None, + }) + .unwrap_or_default(); + if reason.trim().len() < 10 { + let _ = interaction + .create_response( + &ctx.http, + serenity::all::CreateInteractionResponse::Message( + serenity::all::CreateInteractionResponseMessage::new() + .content("Reason 10-500 chars required.") + .ephemeral(true), + ), + ) + .await; + return; + } + let channel_id = interaction.channel_id; + let user = interaction.user.clone(); + let _ = interaction + .create_response( + &ctx.http, + serenity::all::CreateInteractionResponse::Defer( + serenity::all::CreateInteractionResponseMessage::new().ephemeral(true), + ), + ) + .await; + let transcript = service::fetch_transcript(&ctx.http, channel_id).await; + let haste_url = service::upload_hastebin(&transcript).await; + service::deliver_transcript( + &ctx.http, + channel_id, + user.id.get(), + format!("{transcript}\n\nClose reason: {reason}"), + haste_url.clone(), + ) + .await; + let opener_for_cooldown = if let Some(pool) = service::pool() { + service::get_opener(pool, channel_id.get()).await + } else { + None + }; + if let Some(pool) = service::pool() { + service::cleanup_ticket(pool, channel_id.get()).await; + if let Some(opener) = opener_for_cooldown { + if let Some(gid) = interaction.guild_id { + service::set_cooldown(pool, gid.get(), opener, 300).await; + } + } + } + let control_row = serenity::all::CreateActionRow::Buttons(vec![ + serenity::all::CreateButton::new("ticket_reopen") + .label("Reopen") + .style(serenity::all::ButtonStyle::Secondary), + serenity::all::CreateButton::new("ticket_transcript") + .label("Save") + .style(serenity::all::ButtonStyle::Secondary), + serenity::all::CreateButton::new("ticket_delete") + .label("Delete") + .style(serenity::all::ButtonStyle::Danger), + ]); + let embed = CreateEmbed::new() + .title("Ticket closed") + .description(format!("Closed by <@{}>\nReason: {reason}", user.id)) + .color(0x00ED_4245) + .footer(serenity::all::CreateEmbedFooter::new("Control panel below")); + if let Some(url) = haste_url.clone() { + let link_row = serenity::all::CreateActionRow::Buttons(vec![ + serenity::all::CreateButton::new_link(url).label("View transcript"), + ]); + let _ = channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed(embed).components(vec![control_row, link_row]), + ) + .await; + } else { + let _ = channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed(embed).components(vec![control_row]), + ) + .await; + } + let _ = ChannelId::new(super::TICKET_LOGS) + .send_message( + &ctx.http, + CreateMessage::new().embed( + CreateEmbed::new() + .title("Ticket closed") + .description(format!( + "<@{}> closed <#{}>\nReason: {reason}", + user.id, channel_id + )) + .color(0x00ED_4245), + ), + ) + .await; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let _ = interaction + .create_followup( + &ctx.http, + serenity::all::CreateInteractionResponseFollowup::new() + .content(format!("Ticket <#{channel_id}> closed.")) + .ephemeral(true), + ) + .await; + let is_thread = ctx + .http + .get_channel(channel_id) + .await + .map(|c| matches!(c, serenity::all::Channel::Guild(gc) if gc.thread_metadata.is_some())) + .unwrap_or(false); + if is_thread { + let _ = channel_id + .edit_thread(&ctx.http, serenity::all::EditThread::new().archived(true).locked(true)) + .await; + } + info!(channel=%channel_id, closer=%user.id, "ticket closed with reason"); +} +pub async fn handle_reopen(ctx: Context, interaction: serenity::all::ComponentInteraction) { + let channel_id = interaction.channel_id; + let is_thread = ctx + .http + .get_channel(channel_id) + .await + .map(|c| matches!(c, serenity::all::Channel::Guild(gc) if gc.thread_metadata.is_some())) + .unwrap_or(false); + if is_thread { + let _ = channel_id + .edit_thread(&ctx.http, serenity::all::EditThread::new().archived(false).locked(false)) + .await; + } + ephemeral(&ctx, &interaction, "Ticket reopened.".into()).await; + let _ = channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed( + CreateEmbed::new() + .title("Ticket reopened") + .description(format!("Reopened by <@{}>", interaction.user.id)) + .color(0x0057_F387), + ), + ) + .await; +} +pub async fn handle_transcript(ctx: Context, interaction: serenity::all::ComponentInteraction) { + let channel_id = interaction.channel_id; + ephemeral(&ctx, &interaction, "Generating transcript...".into()).await; + let transcript = service::fetch_transcript(&ctx.http, channel_id).await; + if let Some(url) = service::upload_hastebin(&transcript).await { + let _ = channel_id + .send_message(&ctx.http, CreateMessage::new().content(format!("Transcript: {url}"))) + .await; + } else { + let data = + serenity::all::CreateAttachment::bytes(transcript.into_bytes(), "transcript.txt"); + let _ = channel_id.send_message(&ctx.http, CreateMessage::new().add_file(data)).await; + } +} +pub async fn handle_delete(ctx: Context, interaction: serenity::all::ComponentInteraction) { + let channel_id = interaction.channel_id; + ephemeral(&ctx, &interaction, "Deleting ticket...".into()).await; + if let Some(pool) = service::pool() { + service::cleanup_ticket(pool, channel_id.get()).await; + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let _ = channel_id.delete(&ctx.http).await; + info!(channel=%channel_id, "ticket deleted via panel"); +} diff --git a/src/feature/ticket/handler/create.rs b/src/feature/ticket/handler/create.rs new file mode 100644 index 0000000..5d385bb --- /dev/null +++ b/src/feature/ticket/handler/create.rs @@ -0,0 +1,185 @@ +//! Ticket channel/forums creation (shared for modal + legacy). + +use super::{SUPPORT_CATEGORY, TICKET_LOGS, build_ticket_overwrites, ephemeral, sanitize_name}; +use crate::feature::ticket::service; +use crate::feature::ticket::service::forum; +use serenity::all::{ + AutoArchiveDuration, ChannelId, ChannelType, Context, CreateChannel, CreateEmbed, + CreateForumPost, CreateMessage, GuildId, +}; +use tracing::{info, warn}; + +pub async fn create_ticket_channel( + ctx: &Context, + guild_id: GuildId, + user: &serenity::all::User, + topic: &str, + label: &str, + subject: &str, + description: &str, +) -> anyhow::Result { + // Forum branch (if TICKET_USE_FORUM=true) + if forum::is_enabled() { + if let Ok(forum_id) = forum::ensure_forum_channel(&ctx.http, guild_id).await { + if let Some(tag_id) = forum::tag_id_for_async(&ctx.http, forum_id, topic).await { + let embed = CreateEmbed::new() + .title(subject) + .description(format!( + "Hello <@{}> β€” staff will be with you shortly.\n\n**Topic:** {label} (`{topic}`)\n**Description:**\n{description}", + user.id + )) + .color(0x0058_65F2) + .footer(serenity::all::CreateEmbedFooter::new(format!("Opened by {} β€’ {}", user.name, topic))); + let post_name = subject.chars().take(90).collect::(); + let post_name = if post_name.trim().len() < 2 { + format!("ticket-{topic}-{}", sanitize_name(&user.name)) + } else { + post_name + }; + match forum_id + .create_forum_post( + &ctx.http, + CreateForumPost::new(post_name.clone(), CreateMessage::new().embed(embed)) + .add_applied_tag(tag_id) + .auto_archive_duration(AutoArchiveDuration::OneWeek), + ) + .await + { + Ok(thread) => { + let thread_id = thread.id; + if let Some(pool) = service::pool() { + service::record_ticket( + pool, + thread_id.get(), + guild_id.get(), + user.id.get(), + ) + .await; + } + let row = serenity::all::CreateActionRow::Buttons(vec![ + serenity::all::CreateButton::new("ticket_claim") + .label("Claim") + .style(serenity::all::ButtonStyle::Primary) + .emoji('πŸ™‹'), + serenity::all::CreateButton::new("ticket_close") + .label("Close") + .style(serenity::all::ButtonStyle::Danger) + .emoji('πŸ”’'), + ]); + let _ = thread_id + .send_message( + &ctx.http, + CreateMessage::new() + .content(format!("<@{}> β€” staff control", user.id)) + .components(vec![row]), + ) + .await; + let _ = ChannelId::new(TICKET_LOGS) + .send_message( + &ctx.http, + CreateMessage::new().embed( + CreateEmbed::new().title("Ticket opened (forum)").description(format!("<@{}> opened forum post <#{}> β€” **{label}**\nSubject: {subject}", user.id, thread_id)).color(0x0058_65F2), + ), + ) + .await; + info!(user=%user.name, channel=%thread_id, topic=%topic, forum=%forum_id, "ticket created via forum"); + return Ok(thread_id); + } + Err(e) => warn!(error=%e, "forum post failed, falling back to channel"), + } + } + } + } + // Fallback: classic text channel in SUPPORT category + let safe_user = sanitize_name(&user.name); + let name = format!("ticket-{topic}-{safe_user}").chars().take(90).collect::(); + let overwrites = build_ticket_overwrites(user.id.get()); + let channel = guild_id + .create_channel( + &ctx.http, + CreateChannel::new(&name) + .kind(ChannelType::Text) + .category(ChannelId::new(SUPPORT_CATEGORY)) + .permissions(overwrites) + .rate_limit_per_user(5), + ) + .await?; + if let Some(pool) = service::pool() { + service::record_ticket(pool, channel.id.get(), guild_id.get(), user.id.get()).await; + } + let embed = CreateEmbed::new() + .title(subject) + .description(format!("Hello <@{}> β€” staff will be with you shortly.\n\n**Topic:** {label} (`{topic}`)\n**Description:**\n{description}", user.id)) + .color(0x0058_65F2) + .footer(serenity::all::CreateEmbedFooter::new(format!("Opened by {} β€’ {}", user.name, topic))); + let row = serenity::all::CreateActionRow::Buttons(vec![ + serenity::all::CreateButton::new("ticket_claim") + .label("Claim") + .style(serenity::all::ButtonStyle::Primary) + .emoji('πŸ™‹'), + serenity::all::CreateButton::new("ticket_close") + .label("Close") + .style(serenity::all::ButtonStyle::Danger) + .emoji('πŸ”’'), + ]); + let _ = channel + .send_message(&ctx.http, CreateMessage::new().embed(embed).components(vec![row])) + .await; + let _ = ChannelId::new(TICKET_LOGS) + .send_message( + &ctx.http, + CreateMessage::new().embed( + CreateEmbed::new() + .title("Ticket opened") + .description(format!( + "<@{}> opened <#{}> β€” **{label}**\nSubject: {subject}", + user.id, channel.id + )) + .color(0x0058_65F2), + ), + ) + .await; + info!(user=%user.name, channel=%channel.id, topic=%topic, "ticket created via modal"); + Ok(channel.id) +} + +pub async fn handle_create_legacy(ctx: Context, interaction: serenity::all::ComponentInteraction) { + let Some(guild_id) = interaction.guild_id else { return }; + let user = interaction.user.clone(); + let custom = interaction.data.custom_id.clone(); + if let Some(pool) = service::pool() { + if let Some(reason) = service::is_blacklisted(pool, guild_id.get(), user.id.get()).await { + ephemeral(&ctx, &interaction, format!("You are blacklisted: {reason}")).await; + return; + } + } + let (topic, label) = + if custom == "ticket_bug" { ("bug", "Bug report") } else { ("general", "General support") }; + if let Ok(channels) = guild_id.channels(&ctx.http).await { + let exists = channels.values().any(|c| { + c.name.starts_with(&format!("ticket-{}", user.name.to_lowercase())) + || c.name.starts_with(&format!("ticket-{}", user.id)) + }); + if exists { + ephemeral(&ctx, &interaction, "You already have an open ticket.".into()).await; + return; + } + } + match create_ticket_channel( + &ctx, + guild_id, + &user, + topic, + label, + &format!("Ticket - {label}"), + "Opened via legacy button.", + ) + .await + { + Ok(cid) => ephemeral(&ctx, &interaction, format!("Ticket created: <#{cid}>")).await, + Err(e) => { + warn!(error=%e, "failed to create legacy ticket"); + ephemeral(&ctx, &interaction, "Failed to create ticket.".into()).await; + } + } +} diff --git a/src/feature/ticket/handler/interaction.rs b/src/feature/ticket/handler/interaction.rs new file mode 100644 index 0000000..9409ffb --- /dev/null +++ b/src/feature/ticket/handler/interaction.rs @@ -0,0 +1,209 @@ +//! Ticket interaction - select menu β†’ modal, modal submit β†’ channel create (defer fast). + +use super::{create, topic_label}; +use crate::feature::ticket::service; +use serenity::all::{ + Context, CreateActionRow, CreateInteractionResponse, CreateInteractionResponseMessage, + CreateModal, InputTextStyle, ModalInteraction, +}; +use tracing::warn; + +/// Handles select menu `ticket_topic` -> shows modal. +pub async fn on_select(ctx: Context, interaction: serenity::all::ComponentInteraction) { + if interaction.data.custom_id != "ticket_topic" { + return; + } + let topic = match &interaction.data.kind { + serenity::all::ComponentInteractionDataKind::StringSelect { values } => { + values.first().cloned().unwrap_or_default() + } + _ => return, + }; + if topic.is_empty() { + return; + } + let Some(label) = topic_label(&topic) else { + let _ = interaction + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("Unknown topic: {topic}")) + .ephemeral(true), + ), + ) + .await; + return; + }; + // Fast pre-check: only blacklist (DB, ~5ms). Duplicate moved to modal submit. + if let Some(guild_id) = interaction.guild_id { + if let Some(pool) = service::pool() { + if let Some(reason) = + service::is_blacklisted(pool, guild_id.get(), interaction.user.id.get()).await + { + let _ = interaction + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("You are blacklisted: {reason}")) + .ephemeral(true), + ), + ) + .await; + return; + } + } + } + let modal = CreateModal::new(format!("ticket_modal:{topic}"), format!("New ticket β€’ {label}")) + .components(vec![ + CreateActionRow::InputText( + serenity::all::CreateInputText::new(InputTextStyle::Short, "Subject", "subject") + .placeholder("Brief title (3-100 chars)") + .min_length(3) + .max_length(100) + .required(true), + ), + CreateActionRow::InputText( + serenity::all::CreateInputText::new( + InputTextStyle::Paragraph, + "Description", + "description", + ) + .placeholder("Describe your issue in detail (10-1000 chars)") + .min_length(10) + .max_length(1000) + .required(true), + ), + ]); + let _ = interaction.create_response(&ctx.http, CreateInteractionResponse::Modal(modal)).await; +} + +/// Handles modal submit `ticket_modal:` -> creates ticket. +pub async fn on_modal(ctx: Context, interaction: ModalInteraction) { + let Some(topic) = interaction.data.custom_id.strip_prefix("ticket_modal:") else { + return; + }; + let Some(label) = topic_label(topic) else { + let _ = interaction + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Unknown ticket topic.") + .ephemeral(true), + ), + ) + .await; + return; + }; + let subject = + extract_modal_field(&interaction, "subject").unwrap_or_else(|| "No subject".into()); + let description = extract_modal_field(&interaction, "description").unwrap_or_default(); + if subject.trim().len() < 3 || description.trim().len() < 10 { + let _ = interaction + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Subject β‰₯3 and description β‰₯10 chars required.") + .ephemeral(true), + ), + ) + .await; + return; + } + let Some(guild_id) = interaction.guild_id else { return }; + let user = interaction.user.clone(); + // Defer immediately β€” channel creation is slow (1-2s) + let _ = interaction + .create_response( + &ctx.http, + CreateInteractionResponse::Defer( + CreateInteractionResponseMessage::new().ephemeral(true), + ), + ) + .await; + if let Some(pool) = service::pool() { + if let Some(reason) = service::is_blacklisted(pool, guild_id.get(), user.id.get()).await { + let _ = interaction + .create_followup( + &ctx.http, + serenity::all::CreateInteractionResponseFollowup::new() + .content(format!("You are blacklisted: {reason}")) + .ephemeral(true), + ) + .await; + return; + } + if let Some(rem) = service::is_on_cooldown(pool, guild_id.get(), user.id.get()).await { + let _ = interaction + .create_followup( + &ctx.http, + serenity::all::CreateInteractionResponseFollowup::new() + .content(format!( + "Cooldown: wait {}s before new ticket (5m after close).", + rem + )) + .ephemeral(true), + ) + .await; + return; + } + } + if let Ok(channels) = guild_id.channels(&ctx.http).await { + let exists = channels.values().any(|c| { + c.name.starts_with(&format!("ticket-{}", user.name.to_lowercase())) + || c.name.starts_with(&format!("ticket-{}", user.id)) + }); + if exists { + let _ = interaction + .create_followup( + &ctx.http, + serenity::all::CreateInteractionResponseFollowup::new() + .content("You already have an open ticket.") + .ephemeral(true), + ) + .await; + return; + } + } + match create::create_ticket_channel(&ctx, guild_id, &user, topic, label, &subject, &description) + .await + { + Ok(channel_id) => { + let _ = interaction + .create_followup( + &ctx.http, + serenity::all::CreateInteractionResponseFollowup::new() + .content(format!("Ticket created: <#{channel_id}>")) + .ephemeral(true), + ) + .await; + } + Err(e) => { + warn!(error=%e, "failed to create ticket via modal"); + let _ = interaction + .create_followup( + &ctx.http, + serenity::all::CreateInteractionResponseFollowup::new() + .content("Failed to create ticket. Try again later.") + .ephemeral(true), + ) + .await; + } + } +} + +fn extract_modal_field(modal: &ModalInteraction, custom_id: &str) -> Option { + for row in &modal.data.components { + for comp in &row.components { + if let serenity::all::ActionRowComponent::InputText(it) = comp { + if it.custom_id == custom_id { + return it.value.clone(); + } + } + } + } + None +} diff --git a/src/feature/ticket/handler/mod.rs b/src/feature/ticket/handler/mod.rs new file mode 100644 index 0000000..9ea6e25 --- /dev/null +++ b/src/feature/ticket/handler/mod.rs @@ -0,0 +1,200 @@ +//! Ticket handler - mod (topics, panel, routing). Re-exports submodules. + +pub mod claim; +pub mod create; +pub mod interaction; + +pub use interaction::on_select; + +use serenity::all::{ + ChannelId, Context, CreateActionRow, CreateEmbed, CreateSelectMenu, CreateSelectMenuKind, + CreateSelectMenuOption, GuildId, +}; +use tracing::info; + +pub const SUPPORT_CATEGORY: u64 = 1547213779224363008; +pub const TICKET_LOGS: u64 = 1509503717127356446; +const TICKET_PANEL_CHANNEL: u64 = 1509503682914549861; + +/// Canonical topics - single source of truth. +pub struct Topic { + pub value: &'static str, + pub label: &'static str, + pub emoji: char, + pub desc: &'static str, +} +pub const TOPICS: &[Topic] = &[ + Topic { + value: "general", + label: "General support", + emoji: 'πŸ› ', + desc: "Questions about bot / server", + }, + Topic { value: "bug", label: "Bug report", emoji: 'πŸ›', desc: "Report a bug or error" }, + Topic { + value: "appeal", label: "Mute / ban appeal", emoji: 'πŸ”‡', desc: "Appeal a punishment" + }, + Topic { value: "report", label: "Report user", emoji: '😑', desc: "Report rule violation" }, + Topic { + value: "owners", + label: "Contact owners", + emoji: 'πŸ‘‘', + desc: "Private contact with owners", + }, +]; + +pub fn topic_label(value: &str) -> Option<&'static str> { + TOPICS.iter().find(|t| t.value == value).map(|t| t.label) +} + +pub fn panel_embed() -> CreateEmbed { + CreateEmbed::new() + .title("🎫 Support tickets") + .description( + "**Choose a topic below to open a private ticket.**\n\n\ + β€’ Staff will respond shortly β€” please describe your issue clearly.\n\ + β€’ One open ticket per user.\n\ + β€’ Abuse / spam β†’ blacklist.", + ) + .color(0x0058_65F2) + .footer(serenity::all::CreateEmbedFooter::new( + "Loki Dev β€’ Select topic β†’ fill form β†’ ticket created", + )) +} + +pub fn panel_select_row() -> CreateActionRow { + let options: Vec = TOPICS + .iter() + .map(|t| CreateSelectMenuOption::new(t.label, t.value).description(t.desc).emoji(t.emoji)) + .collect(); + let menu = CreateSelectMenu::new("ticket_topic", CreateSelectMenuKind::String { options }) + .placeholder("Choose topic…") + .min_values(1) + .max_values(1); + CreateActionRow::SelectMenu(menu) +} + +/// Ensures panel exists in dedicated channel. Idempotent. +pub async fn ensure_ticket_panel( + http: &serenity::http::Http, + guild_id: GuildId, +) -> anyhow::Result<()> { + let channel_id = + resolve_panel_channel(http, guild_id).await.unwrap_or(ChannelId::new(TICKET_PANEL_CHANNEL)); + let msgs = channel_id.messages(http, serenity::all::GetMessages::new().limit(20)).await?; + let has_panel = msgs.iter().any(|m| { + m.author.id.get() == 1527966600173457408 + && m.components.iter().any(|row| { + row.components.iter().any(|c| match c { + serenity::all::ActionRowComponent::SelectMenu(sm) => { + sm.custom_id.as_deref() == Some("ticket_topic") + } + _ => false, + }) + }) + }); + if has_panel { + info!(channel=%channel_id, "ticket panel already exists"); + return Ok(()); + } + channel_id + .send_message( + http, + serenity::all::CreateMessage::new() + .embed(panel_embed()) + .components(vec![panel_select_row()]), + ) + .await?; + info!(channel=%channel_id, "ticket panel created on boot"); + Ok(()) +} + +async fn resolve_panel_channel( + http: &serenity::http::Http, + guild_id: GuildId, +) -> Option { + if let Ok(channels) = guild_id.channels(http).await { + if let Some((id, _)) = channels.iter().find(|(_, c)| c.name.contains("open-ticket")) { + return Some(*id); + } + } + None +} + +/// Handles ticket buttons (claim/close) + legacy create buttons. +pub async fn on_button(ctx: Context, interaction: serenity::all::ComponentInteraction) { + match interaction.data.custom_id.as_str() { + "ticket_general" | "ticket_bug" => create::handle_create_legacy(ctx, interaction).await, + "ticket_claim" => claim::handle_claim(ctx, interaction).await, + "ticket_close" => claim::handle_close(ctx, interaction).await, + "ticket_reopen" => claim::handle_reopen(ctx, interaction).await, + "ticket_transcript" => claim::handle_transcript(ctx, interaction).await, + "ticket_delete" => claim::handle_delete(ctx, interaction).await, + _ => {} + } +} + +pub(crate) fn sanitize_name(s: &str) -> String { + let lower = s.to_lowercase().replace(' ', "-"); + let mut out = String::new(); + for ch in lower.chars() { + if ch.is_ascii_alphanumeric() || ch == '-' { + out.push(ch); + } else { + out.push('-'); + } + } + let collapsed = out.split('-').filter(|p| !p.is_empty()).collect::>().join("-"); + if collapsed.is_empty() { "user".into() } else { collapsed } +} + +pub(crate) async fn ephemeral( + ctx: &Context, + i: &serenity::all::ComponentInteraction, + content: String, +) { + let _ = i + .create_response( + &ctx.http, + serenity::all::CreateInteractionResponse::Message( + serenity::all::CreateInteractionResponseMessage::new() + .content(content) + .ephemeral(true), + ), + ) + .await; +} + +pub(crate) fn allow_view(id: serenity::all::RoleId) -> serenity::all::PermissionOverwrite { + serenity::all::PermissionOverwrite { + allow: serenity::all::Permissions::VIEW_CHANNEL + | serenity::all::Permissions::SEND_MESSAGES + | serenity::all::Permissions::READ_MESSAGE_HISTORY, + deny: serenity::all::Permissions::empty(), + kind: serenity::all::PermissionOverwriteType::Role(id), + } +} +pub(crate) fn build_ticket_overwrites(user_id: u64) -> Vec { + let everyone = serenity::all::RoleId::new(1509503154708811837); + let staff = serenity::all::RoleId::new(1547203757711040572); + let owner = serenity::all::RoleId::new(1547203762035236925); + let moderator = serenity::all::RoleId::new(1547203753373859901); + let user = serenity::all::UserId::new(user_id); + vec![ + serenity::all::PermissionOverwrite { + allow: serenity::all::Permissions::empty(), + deny: serenity::all::Permissions::VIEW_CHANNEL, + kind: serenity::all::PermissionOverwriteType::Role(everyone), + }, + serenity::all::PermissionOverwrite { + allow: serenity::all::Permissions::VIEW_CHANNEL + | serenity::all::Permissions::SEND_MESSAGES + | serenity::all::Permissions::READ_MESSAGE_HISTORY, + deny: serenity::all::Permissions::empty(), + kind: serenity::all::PermissionOverwriteType::Member(user), + }, + allow_view(staff), + allow_view(owner), + allow_view(moderator), + ] +} diff --git a/src/feature/ticket/mod.rs b/src/feature/ticket/mod.rs index 62a937a..c6da5e8 100644 --- a/src/feature/ticket/mod.rs +++ b/src/feature/ticket/mod.rs @@ -1,4 +1,3 @@ pub mod command; -pub mod forum; pub mod handler; pub mod service; diff --git a/src/feature/ticket/service/count.rs b/src/feature/ticket/service/count.rs new file mode 100644 index 0000000..8ad15d8 --- /dev/null +++ b/src/feature/ticket/service/count.rs @@ -0,0 +1,42 @@ +use serenity::http::Http; +use sqlx::SqlitePool; +use tracing::{info, warn}; +#[rustfmt::skip] +pub async fn list_tickets(p: &SqlitePool) -> Vec<(String, String, String)> { + let _ = sqlx::query("CREATE TABLE IF NOT EXISTS tickets (channel_id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, opener_id TEXT NOT NULL)").execute(p).await; + sqlx::query_as::<_, (String, String, String)>("SELECT channel_id, guild_id, opener_id FROM tickets").fetch_all(p).await.unwrap_or_default() +} +#[rustfmt::skip] +pub async fn count_tickets(p: &SqlitePool, g: u64) -> i64 { + let _ = sqlx::query("CREATE TABLE IF NOT EXISTS tickets (channel_id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, opener_id TEXT NOT NULL)").execute(p).await; + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM tickets WHERE guild_id=?").bind(g.to_string()).fetch_one(p).await.unwrap_or(0) +} +pub async fn reconcile_orphans(http: &Http, pool: &SqlitePool, guild_id: u64) -> usize { + let tickets = list_tickets(pool).await; + let mut purged = 0; + for (cid_str, gid_str, opener) in tickets { + if gid_str != guild_id.to_string() { + continue; + } + if let Ok(cid) = cid_str.parse::() { + let cid = serenity::all::ChannelId::new(cid); + if http.get_channel(cid).await.is_err() { + let _ = sqlx::query("DELETE FROM tickets WHERE channel_id=?") + .bind(&cid_str) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM ticket_claims WHERE channel_id=?") + .bind(&cid_str) + .execute(pool) + .await; + purged += 1; + warn!(channel=%cid_str, opener=%opener, "orphan ticket purged"); + let _ = serenity::all::ChannelId::new(1509503717127356446).send_message(http, serenity::all::CreateMessage::new().embed(serenity::all::CreateEmbed::new().title("⚠️ Ticket Manually Deleted").description(format!("Ticket <#{cid_str}> (opener <@{opener}>) manually deleted β€” transcript unavailable.")).color(0x00ED_4245))).await; + } + } + } + if purged > 0 { + info!(purged, "orphan tickets reconciled"); + } + purged +} diff --git a/src/feature/ticket/forum.rs b/src/feature/ticket/service/forum.rs similarity index 100% rename from src/feature/ticket/forum.rs rename to src/feature/ticket/service/forum.rs diff --git a/src/feature/ticket/service.rs b/src/feature/ticket/service/mod.rs similarity index 67% rename from src/feature/ticket/service.rs rename to src/feature/ticket/service/mod.rs index 73510ca..68c0e6a 100644 --- a/src/feature/ticket/service.rs +++ b/src/feature/ticket/service/mod.rs @@ -1,4 +1,6 @@ //! Ticket service +pub mod count; +pub mod forum; use serenity::all::{ChannelId, CreateAttachment, CreateMessage, Http, MessageId}; use sqlx::SqlitePool; use std::sync::OnceLock; @@ -64,6 +66,80 @@ pub async fn cleanup_ticket(p: &SqlitePool, c: u64) { let _ = sqlx::query("DELETE FROM tickets WHERE channel_id=?").bind(c.to_string()).execute(p).await; let _ = sqlx::query("DELETE FROM ticket_claims WHERE channel_id=?").bind(c.to_string()).execute(p).await; } +pub async fn is_on_cooldown(p: &SqlitePool, g: u64, u: u64) -> Option { + let _ = sqlx::query("CREATE TABLE IF NOT EXISTS ticket_cooldowns (user_id TEXT NOT NULL, guild_id TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY (user_id, guild_id))").execute(p).await; + let row: Option<(i64,)> = + sqlx::query_as("SELECT expires_at FROM ticket_cooldowns WHERE user_id=? AND guild_id=?") + .bind(u.to_string()) + .bind(g.to_string()) + .fetch_optional(p) + .await + .unwrap_or(None); + if let Some((exp,)) = row { + let now = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + as i64; + if exp > now { + return Some((exp - now) as u64); + } + let _ = sqlx::query("DELETE FROM ticket_cooldowns WHERE user_id=? AND guild_id=?") + .bind(u.to_string()) + .bind(g.to_string()) + .execute(p) + .await; + } + None +} +pub async fn set_cooldown(p: &SqlitePool, g: u64, u: u64, secs: u64) { + let _ = sqlx::query("CREATE TABLE IF NOT EXISTS ticket_cooldowns (user_id TEXT NOT NULL, guild_id TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY (user_id, guild_id))").execute(p).await; + let exp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + + secs; + let _ = sqlx::query( + "INSERT OR REPLACE INTO ticket_cooldowns(user_id, guild_id, expires_at) VALUES(?,?,?)", + ) + .bind(u.to_string()) + .bind(g.to_string()) + .bind(exp as i64) + .execute(p) + .await; +} +pub async fn add_user_to_ticket( + http: &Http, + channel_id: ChannelId, + user_id: u64, +) -> anyhow::Result<()> { + let uid = serenity::all::UserId::new(user_id); + let is_thread = matches!(http.get_channel(channel_id).await, Ok(serenity::all::Channel::Guild(gc)) if gc.thread_metadata.is_some()); + if is_thread { + channel_id.add_thread_member(http, uid).await?; + } else { + let overwrite = serenity::all::PermissionOverwrite { + allow: serenity::all::Permissions::VIEW_CHANNEL + | serenity::all::Permissions::SEND_MESSAGES + | serenity::all::Permissions::READ_MESSAGE_HISTORY, + deny: serenity::all::Permissions::empty(), + kind: serenity::all::PermissionOverwriteType::Member(uid), + }; + channel_id.create_permission(http, overwrite).await?; + } + Ok(()) +} +pub async fn remove_user_from_ticket( + http: &Http, + channel_id: ChannelId, + user_id: u64, +) -> anyhow::Result<()> { + let uid = serenity::all::UserId::new(user_id); + let is_thread = matches!(http.get_channel(channel_id).await, Ok(serenity::all::Channel::Guild(gc)) if gc.thread_metadata.is_some()); + if is_thread { + channel_id.remove_thread_member(http, uid).await?; + } else { + channel_id + .delete_permission(http, serenity::all::PermissionOverwriteType::Member(uid)) + .await?; + } + Ok(()) +} #[rustfmt::skip] pub async fn fetch_transcript(h: &Http, ch: ChannelId) -> String { let mut all=Vec::new();