refactor(ticket): split handler 690→4 files ≤200, ≤4 files/folder (KISS/SOLID); move forum under service
This commit is contained in:
parent
e0d3b44118
commit
56ee953a8e
9 changed files with 693 additions and 693 deletions
|
|
@ -39,8 +39,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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<CreateSelectMenuOption> = 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<ChannelId> {
|
||||
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;
|
||||
};
|
||||
// Fast pre-check: only blacklist (DB, ~5ms). Duplicate check moved to modal submit after defer (guild.channels is 300-500ms and delays modal popup).
|
||||
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:<topic>` -> 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();
|
||||
// Defer immediately — Discord channel creation is slow (1-2s), avoid 3s timeout and UI lag
|
||||
let _ = interaction
|
||||
.create_response(
|
||||
&ctx.http,
|
||||
CreateInteractionResponse::Defer(
|
||||
CreateInteractionResponseMessage::new().ephemeral(true),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
// Re-check blacklist / duplicate after defer (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_followup(
|
||||
&ctx.http,
|
||||
serenity::all::CreateInteractionResponseFollowup::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_followup(
|
||||
&ctx.http,
|
||||
serenity::all::CreateInteractionResponseFollowup::new()
|
||||
.content("You already have an open ticket.")
|
||||
.ephemeral(true),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 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<String> {
|
||||
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<ChannelId> {
|
||||
// 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::<String>();
|
||||
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::<String>();
|
||||
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::<Vec<_>>().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();
|
||||
// Staff-only + not opener check (creator cannot claim own ticket)
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Require Staff/Owner/Moderator role
|
||||
const STAFF_ROLES: &[u64] = &[
|
||||
1547203757711040572, // Staff
|
||||
1547203762035236925, // Owner
|
||||
1547203753373859901, // Moderator
|
||||
];
|
||||
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(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<PermissionOverwrite> {
|
||||
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),
|
||||
]
|
||||
}
|
||||
114
src/feature/ticket/handler/claim.rs
Normal file
114
src/feature/ticket/handler/claim.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//! 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 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
|
||||
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, thread=true, "ticket thread closed (archived)");
|
||||
} else {
|
||||
let _ = channel_id.delete(&ctx.http).await;
|
||||
info!(channel=%channel_id, "ticket closed");
|
||||
}
|
||||
}
|
||||
184
src/feature/ticket/handler/create.rs
Normal file
184
src/feature/ticket/handler/create.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
//! 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<ChannelId> {
|
||||
// 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::<String>();
|
||||
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::<String>();
|
||||
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)
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
195
src/feature/ticket/handler/interaction.rs
Normal file
195
src/feature/ticket/handler/interaction.rs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
//! 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:<topic>` -> 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 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<String> {
|
||||
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
|
||||
}
|
||||
197
src/feature/ticket/handler/mod.rs
Normal file
197
src/feature/ticket/handler/mod.rs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
//! Ticket handler - mod (topics, panel, routing). Re-exports submodules.
|
||||
|
||||
pub mod claim;
|
||||
pub mod create;
|
||||
pub mod interaction;
|
||||
|
||||
pub use interaction::{on_modal, 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<CreateSelectMenuOption> = 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<ChannelId> {
|
||||
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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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::<Vec<_>>().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<serenity::all::PermissionOverwrite> {
|
||||
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),
|
||||
]
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
pub mod command;
|
||||
pub mod forum;
|
||||
pub mod handler;
|
||||
pub mod service;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! Ticket service
|
||||
pub mod forum;
|
||||
use serenity::all::{ChannelId, CreateAttachment, CreateMessage, Http, MessageId};
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::OnceLock;
|
||||
Loading…
Add table
Add a link
Reference in a new issue