//! Ticket handler - SelectMenu + Modal + private channels, claim/close with transcript. use super::service; use serenity::all::{ ChannelId, ChannelType, ComponentInteraction, Context, CreateActionRow, CreateChannel, CreateEmbed, 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 { let safe_user = sanitize_name(&user.name); let name = format!("ticket-{topic}-{safe_user}"); // Discord channel name max 100, lowercase alphanumeric + dashes 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; // Log opening 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 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), ] }