Compare commits
No commits in common. "bfdc5ce28230258dd98a5ba82237cdd6d04b068c" and "fb6fd4fac025f36f1cbc157688616fbbc10c5015" have entirely different histories.
bfdc5ce282
...
fb6fd4fac0
18 changed files with 698 additions and 1448 deletions
|
|
@ -21,8 +21,6 @@ impl EventHandler for Handler {
|
||||||
let http = ctx.http.clone();
|
let http = ctx.http.clone();
|
||||||
let http2 = ctx.http.clone();
|
let http2 = ctx.http.clone();
|
||||||
let gid_clone = self.guild_id;
|
let gid_clone = self.guild_id;
|
||||||
let pool_clone = self.pool.clone();
|
|
||||||
let ctx_clone = ctx.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// SAFETY: startup delay 2s - not in command path, lets cache fill, no jitter needed (one-shot on boot)
|
// 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;
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
|
@ -41,8 +39,8 @@ impl EventHandler for Handler {
|
||||||
{
|
{
|
||||||
warn!(error=%e, "ensure_ticket_panel failed");
|
warn!(error=%e, "ensure_ticket_panel failed");
|
||||||
}
|
}
|
||||||
if crate::feature::ticket::service::forum::is_enabled() {
|
if crate::feature::ticket::forum::is_enabled() {
|
||||||
if let Err(e) = crate::feature::ticket::service::forum::ensure_forum_channel(
|
if let Err(e) = crate::feature::ticket::forum::ensure_forum_channel(
|
||||||
&http2,
|
&http2,
|
||||||
serenity::all::GuildId::new(gid),
|
serenity::all::GuildId::new(gid),
|
||||||
)
|
)
|
||||||
|
|
@ -51,14 +49,6 @@ impl EventHandler for Handler {
|
||||||
warn!(error=%e, "ensure_forum_channel failed");
|
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 {
|
if let Some(gid) = self.guild_id {
|
||||||
|
|
@ -72,66 +62,9 @@ impl EventHandler for Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn guild_member_addition(&self, ctx: Context, member: serenity::all::Member) {
|
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::welcome::handler::on_member_join(ctx.clone(), member.clone()).await;
|
||||||
crate::feature::lang::handler::on_member_join(ctx.clone(), member.clone()).await;
|
crate::feature::lang::handler::on_member_join(ctx, member).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<serenity::all::Member>,
|
|
||||||
) {
|
|
||||||
crate::feature::moderation::logging::on_member_leave(&ctx, &user, Some(guild_id)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn message_update(
|
|
||||||
&self,
|
|
||||||
ctx: Context,
|
|
||||||
_old: Option<serenity::all::Message>,
|
|
||||||
new: Option<serenity::all::Message>,
|
|
||||||
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<serenity::all::GuildId>,
|
|
||||||
) {
|
|
||||||
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<serenity::all::MessageId>,
|
|
||||||
guild_id: Option<serenity::all::GuildId>,
|
|
||||||
) {
|
|
||||||
let _ = guild_id;
|
|
||||||
crate::feature::moderation::logging::on_message_delete_bulk(
|
|
||||||
&ctx,
|
|
||||||
channel_id,
|
|
||||||
&multiple_deleted_messages_ids,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn voice_state_update(
|
async fn voice_state_update(
|
||||||
|
|
@ -158,9 +91,7 @@ impl EventHandler for Handler {
|
||||||
serenity::all::Interaction::Modal(modal) => {
|
serenity::all::Interaction::Modal(modal) => {
|
||||||
let m = modal.clone();
|
let m = modal.clone();
|
||||||
if m.data.custom_id.starts_with("ticket_modal:") {
|
if m.data.custom_id.starts_with("ticket_modal:") {
|
||||||
crate::feature::ticket::handler::interaction::on_modal(ctx, m).await;
|
crate::feature::ticket::handler::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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,8 @@ pub async fn run(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let migrations = [
|
let migrations =
|
||||||
("V1__initial", V1_INITIAL),
|
[("V1__initial", V1_INITIAL), ("V2__tickets", V2_TICKETS), ("V3__reminders", V3_REMINDERS)];
|
||||||
("V2__tickets", V2_TICKETS),
|
|
||||||
("V3__reminders", V3_REMINDERS),
|
|
||||||
("V4__ticket_cooldown", V4_COOLDOWN),
|
|
||||||
];
|
|
||||||
for (name, sql) in migrations {
|
for (name, sql) in migrations {
|
||||||
let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _migrations WHERE name = ?")
|
let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _migrations WHERE name = ?")
|
||||||
.bind(name)
|
.bind(name)
|
||||||
|
|
@ -80,11 +76,3 @@ CREATE TABLE IF NOT EXISTS reminders (
|
||||||
sent INTEGER NOT NULL DEFAULT 0
|
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)
|
|
||||||
);
|
|
||||||
";
|
|
||||||
|
|
|
||||||
|
|
@ -26,28 +26,6 @@ async fn ensure_channel(ctx: &Context, guild_id: GuildId) -> anyhow::Result<Chan
|
||||||
let _ = id.edit(&ctx.http, edit).await;
|
let _ = id.edit(&ctx.http, edit).await;
|
||||||
info!(channel=%id, position=%ch.position, parent=?ch.parent_id, "member counter reconciled to top (outside category, pos 0)");
|
info!(channel=%id, position=%ch.position, parent=?ch.parent_id, "member counter reconciled to top (outside category, pos 0)");
|
||||||
}
|
}
|
||||||
// Cleanup old duplicates with different marker (e.g. "👥|Members:" with separator)
|
|
||||||
for (oid, oc) in channels.iter().filter(|(oid, _)| *oid != id) {
|
|
||||||
if oc.name.contains("Members:") {
|
|
||||||
// Old counter with different marker or duplicate — delete
|
|
||||||
let _ = oid.delete(&ctx.http).await;
|
|
||||||
info!(channel=%oid, name=%oc.name, "old member counter deleted (migration)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(*id);
|
|
||||||
}
|
|
||||||
// Migration: found old marker but not new — rename/move it instead of creating new
|
|
||||||
if let Some((id, _)) = channels.iter().find(|(_, c)| c.name.contains("Members:")) {
|
|
||||||
let _ = id
|
|
||||||
.edit(
|
|
||||||
&ctx.http,
|
|
||||||
EditChannel::new()
|
|
||||||
.name(format!("{MARKER} 0"))
|
|
||||||
.position(0)
|
|
||||||
.category(Option::<ChannelId>::None),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
info!(channel=%id, "old counter migrated to new format");
|
|
||||||
return Ok(*id);
|
return Ok(*id);
|
||||||
}
|
}
|
||||||
// Create outside category at position 0, deny CONNECT so users can't join, but VIEW remains
|
// Create outside category at position 0, deny CONNECT so users can't join, but VIEW remains
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,8 @@ pub mod voice;
|
||||||
pub mod welcome;
|
pub mod welcome;
|
||||||
|
|
||||||
use crate::config::BotConfig;
|
use crate::config::BotConfig;
|
||||||
use crate::core::{dispatcher::RateLimiter, policy};
|
|
||||||
use poise::Framework;
|
use poise::Framework;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
static RATE_LIMITER: OnceLock<RateLimiter> = OnceLock::new();
|
|
||||||
fn limiter() -> &'static RateLimiter {
|
|
||||||
RATE_LIMITER.get_or_init(|| RateLimiter::new(policy::RATE_LIMIT_WINDOW_SECS))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Data {
|
pub struct Data {
|
||||||
pub cfg: BotConfig,
|
pub cfg: BotConfig,
|
||||||
|
|
@ -46,14 +39,9 @@ pub async fn build_framework(
|
||||||
ticket::command::ticket_panel(),
|
ticket::command::ticket_panel(),
|
||||||
ticket::command::ticket_blacklist_add(),
|
ticket::command::ticket_blacklist_add(),
|
||||||
ticket::command::ticket_blacklist_remove(),
|
ticket::command::ticket_blacklist_remove(),
|
||||||
ticket::command::ticket_add(),
|
|
||||||
ticket::command::ticket_remove(),
|
|
||||||
moderation::command::clear(),
|
moderation::command::clear(),
|
||||||
moderation::command::purge(),
|
|
||||||
moderation::command::slowmode(),
|
moderation::command::slowmode(),
|
||||||
moderation::command::warn(),
|
moderation::command::warn(),
|
||||||
moderation::mute::mute(),
|
|
||||||
moderation::mute::unmute(),
|
|
||||||
blog::command::blog_post(),
|
blog::command::blog_post(),
|
||||||
poll::command::poll(),
|
poll::command::poll(),
|
||||||
remind::command::remind(),
|
remind::command::remind(),
|
||||||
|
|
@ -62,36 +50,6 @@ pub async fn build_framework(
|
||||||
prefix: Some(cfg.prefix.clone().into()),
|
prefix: Some(cfg.prefix.clone().into()),
|
||||||
..Default::default()
|
..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()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,46 +123,5 @@ pub async fn warn(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `/purge <amount> [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<serenity::all::User>,
|
|
||||||
) -> 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).
|
// Keep helper to avoid unused warning (used via get_lang).
|
||||||
const _: Option<fn(&Ctx<'_>) -> Option<Lang>> = Some(lang_of);
|
const _: Option<fn(&Ctx<'_>) -> Option<Lang>> = Some(lang_of);
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
//! 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<serenity::all::GuildId>,
|
|
||||||
) {
|
|
||||||
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::<String>()
|
|
||||||
))
|
|
||||||
.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<serenity::all::GuildId>,
|
|
||||||
) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1 @@
|
||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod logging;
|
|
||||||
pub mod mute;
|
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
//! 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<std::time::Duration> {
|
|
||||||
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<String>,
|
|
||||||
) -> 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;
|
|
||||||
}
|
|
||||||
|
|
@ -69,80 +69,3 @@ pub async fn ticket_blacklist_remove(
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
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(())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
690
src/feature/ticket/handler.rs
Normal file
690
src/feature/ticket/handler.rs
Normal file
|
|
@ -0,0 +1,690 @@
|
||||||
|
//! 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;
|
||||||
|
};
|
||||||
|
// 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:<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();
|
||||||
|
// 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<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();
|
||||||
|
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),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
//! 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");
|
|
||||||
}
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
//! 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)
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,209 +0,0 @@
|
||||||
//! 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 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<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
|
|
||||||
}
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
//! 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<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,
|
|
||||||
"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::<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,3 +1,4 @@
|
||||||
pub mod command;
|
pub mod command;
|
||||||
|
pub mod forum;
|
||||||
pub mod handler;
|
pub mod handler;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
//! Ticket service
|
//! Ticket service
|
||||||
pub mod count;
|
|
||||||
pub mod forum;
|
|
||||||
use serenity::all::{ChannelId, CreateAttachment, CreateMessage, Http, MessageId};
|
use serenity::all::{ChannelId, CreateAttachment, CreateMessage, Http, MessageId};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
@ -66,80 +64,6 @@ 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 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;
|
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<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 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]
|
#[rustfmt::skip]
|
||||||
pub async fn fetch_transcript(h: &Http, ch: ChannelId) -> String {
|
pub async fn fetch_transcript(h: &Http, ch: ChannelId) -> String {
|
||||||
let mut all=Vec::new();
|
let mut all=Vec::new();
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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::<u64>() {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue