feat(ticket): forum mode with tags (TICKET_USE_FORUM, channel tags instead of category)

This commit is contained in:
loki5512344 2026-09-21 20:45:22 +02:00
parent ac96cfcb59
commit fb6fd4fac0
Signed by: boba
GPG key ID: 253067914055423B
10 changed files with 349 additions and 11 deletions

View file

@ -4,3 +4,5 @@ pub const GUILD_ID: &str = "GUILD_ID";
pub const SUPER_ADMIN_ID: &str = "SUPER_ADMIN_ID";
pub const DB_PATH: &str = "DB_PATH";
pub const BOT_ACTIVITY: &str = "BOT_ACTIVITY";
pub const TICKET_USE_FORUM: &str = "TICKET_USE_FORUM";
pub const TICKET_FORUM_CHANNEL_ID: &str = "TICKET_FORUM_CHANNEL_ID";

View file

@ -12,6 +12,13 @@ fn env_opt(key: &str) -> Option<u64> {
std::env::var(key).ok()?.parse().ok()
}
fn env_bool(key: &str) -> bool {
matches!(
std::env::var(key).ok().as_deref().map(|v| v.trim().to_ascii_lowercase()),
Some(ref s) if s == "1" || s == "true" || s == "yes" || s == "on"
)
}
pub fn load() -> Result<BotConfig> {
let token = env(keys::DISCORD_TOKEN, None)?;
if token.trim().is_empty() || token == "your_token_here" {
@ -24,5 +31,7 @@ pub fn load() -> Result<BotConfig> {
super_admin_id: env_opt(keys::SUPER_ADMIN_ID),
db_path: env(keys::DB_PATH, Some("data/bot.db"))?,
activity: env(keys::BOT_ACTIVITY, Some("!help"))?,
ticket_use_forum: env_bool(keys::TICKET_USE_FORUM),
ticket_forum_channel_id: env_opt(keys::TICKET_FORUM_CHANNEL_ID),
})
}

View file

@ -9,4 +9,6 @@ pub struct BotConfig {
pub super_admin_id: Option<u64>,
pub db_path: String,
pub activity: String,
pub ticket_use_forum: bool,
pub ticket_forum_channel_id: Option<u64>,
}

View file

@ -39,6 +39,16 @@ impl EventHandler for Handler {
{
warn!(error=%e, "ensure_ticket_panel failed");
}
if crate::feature::ticket::forum::is_enabled() {
if let Err(e) = crate::feature::ticket::forum::ensure_forum_channel(
&http2,
serenity::all::GuildId::new(gid),
)
.await
{
warn!(error=%e, "ensure_forum_channel failed");
}
}
}
});
if let Some(gid) = self.guild_id {

158
src/feature/ticket/forum.rs Normal file
View file

@ -0,0 +1,158 @@
//! Forum mode for tickets - one Forum channel with tags instead of category + text channels.
//! Each topic maps to a ForumTag. Controlled by `TICKET_USE_FORUM` env (default false).
use std::collections::HashMap;
use std::sync::OnceLock;
use serenity::all::{
ChannelId, ChannelType, CreateChannel, CreateForumTag, EditChannel, ForumTagId, GuildId,
};
use serenity::http::Http;
use tracing::{info, warn};
/// Topic → tag display (mirrors `handler::TOPICS` but tag names are forum-specific).
const FORUM_TAGS: &[(&str, &str, char)] = &[
("general", "General", '🛠'),
("bug", "Bug", '🐛'),
("appeal", "Appeal", '🔇'),
("report", "Report", '😡'),
("owners", "Owners", '👑'),
];
static TAG_CACHE: OnceLock<HashMap<String, ForumTagId>> = OnceLock::new();
/// Returns true if forum mode enabled (`TICKET_USE_FORUM=1/true`).
pub fn is_enabled() -> bool {
matches!(
std::env::var("TICKET_USE_FORUM").ok().as_deref().map(|v| v.trim().to_ascii_lowercase()),
Some(ref s) if s == "1" || s == "true" || s == "yes" || s == "on"
)
}
/// Ensures forum channel exists (creates if missing) and tags are correct. Returns forum ChannelId.
pub async fn ensure_forum_channel(http: &Http, guild_id: GuildId) -> anyhow::Result<ChannelId> {
// 1. Try explicit ID from env
if let Ok(id_str) = std::env::var("TICKET_FORUM_CHANNEL_ID") {
if let Ok(id) = id_str.parse::<u64>() {
let cid = ChannelId::new(id);
if let Ok(ch) = guild_id.channels(http).await {
if let Some((_, gc)) = ch.iter().find(|(k, _)| **k == cid) {
if gc.kind == ChannelType::Forum {
sync_tags(http, cid).await?;
return Ok(cid);
}
}
}
}
}
// 2. Search existing forum channels
let channels = guild_id.channels(http).await?;
if let Some((id, _)) = channels.iter().find(|(_, c)| {
c.kind == ChannelType::Forum
&& (c.name.contains("support-forum")
|| c.name.contains("ticket-forum")
|| c.name.contains("tickets"))
}) {
sync_tags(http, *id).await?;
return Ok(*id);
}
// 3. Create new forum channel at top (tags synced after via EditChannel)
let created = guild_id
.create_channel(
http,
CreateChannel::new("🎫-support-forum")
.kind(ChannelType::Forum)
.topic("Tickets via forum tags — each post is a ticket")
.position(0),
)
.await?;
info!(channel=%created.id, "forum channel created for tickets");
let _ = sync_tags(http, created.id).await;
Ok(created.id)
}
async fn sync_tags(http: &Http, forum_id: ChannelId) -> anyhow::Result<()> {
if let Ok(serenity::all::Channel::Guild(gc)) = http.get_channel(forum_id).await {
let existing: Vec<String> = gc.available_tags.iter().map(|t| t.name.clone()).collect();
let expected: Vec<String> = FORUM_TAGS.iter().map(|(_, n, _)| n.to_string()).collect();
let need_sync =
existing.len() != expected.len() || !expected.iter().all(|e| existing.contains(e));
if need_sync {
let tags: Vec<CreateForumTag> = FORUM_TAGS
.iter()
.map(|(_, name, emoji)| CreateForumTag::new(*name).emoji(*emoji))
.collect();
let _ = forum_id.edit(http, EditChannel::new().available_tags(tags)).await;
info!(channel=%forum_id, "forum tags synced");
}
let mut map = HashMap::new();
for tag in gc.available_tags {
map.insert(tag.name.clone(), tag.id);
}
let _ = TAG_CACHE.set(map);
}
refresh_tag_cache(http, forum_id).await?;
Ok(())
}
async fn refresh_tag_cache(http: &Http, forum_id: ChannelId) -> anyhow::Result<()> {
if TAG_CACHE.get().is_some() {
return Ok(());
}
let ch = http.get_channel(forum_id).await?;
if let serenity::all::Channel::Guild(gc) = ch {
let mut map = HashMap::new();
for tag in gc.available_tags {
map.insert(tag.name.clone(), tag.id);
}
let _ = TAG_CACHE.set(map);
}
Ok(())
}
/// Returns ForumTagId for topic (e.g. "bug" → tag "Bug"). Requires ensure_forum_channel called before.
pub fn tag_id_for(topic: &str) -> Option<ForumTagId> {
let want = match topic {
"general" => "General",
"bug" => "Bug",
"appeal" => "Appeal",
"report" => "Report",
"owners" => "Owners",
_ => return None,
};
TAG_CACHE.get()?.get(want).copied()
}
/// Also try direct lookup via HTTP if cache miss (best effort sync).
pub async fn tag_id_for_async(http: &Http, forum_id: ChannelId, topic: &str) -> Option<ForumTagId> {
if let Some(id) = tag_id_for(topic) {
return Some(id);
}
if let Ok(serenity::all::Channel::Guild(gc)) = http.get_channel(forum_id).await {
let want = match topic {
"general" => "General",
"bug" => "Bug",
"appeal" => "Appeal",
"report" => "Report",
"owners" => "Owners",
_ => return None,
};
for tag in gc.available_tags {
if tag.name == want {
return Some(tag.id);
}
}
}
warn!(topic=%topic, "forum tag not found");
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tag_mapping() {
assert_eq!(FORUM_TAGS.len(), 5);
assert!(FORUM_TAGS.iter().any(|(v, _, _)| *v == "bug"));
}
}

View file

@ -1,10 +1,12 @@
//! Ticket handler - SelectMenu + Modal + private channels, claim/close with transcript.
//! 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::{
ChannelId, ChannelType, ComponentInteraction, Context, CreateActionRow, CreateChannel,
CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage,
CreateModal, CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, GuildId,
InputTextStyle, ModalInteraction, PermissionOverwrite, PermissionOverwriteType, Permissions,
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};
@ -362,9 +364,93 @@ async fn create_ticket_channel(
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}");
// Discord channel name max 100, lowercase alphanumeric + dashes
let name = name.chars().take(90).collect::<String>();
let overwrites = build_ticket_overwrites(user.id.get());
let channel = guild_id
@ -400,7 +486,6 @@ async fn create_ticket_channel(
let _ = channel
.send_message(&ctx.http, CreateMessage::new().embed(embed).components(vec![row]))
.await;
// Log opening
let _ = ChannelId::new(TICKET_LOGS)
.send_message(
&ctx.http,
@ -548,8 +633,26 @@ async fn handle_close(ctx: Context, interaction: ComponentInteraction) {
service::cleanup_ticket(pool, channel_id.get()).await;
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // SAFETY: small delay to let log flush, not in command path, jitter not needed
let _ = channel_id.delete(&ctx.http).await;
info!(channel=%channel_id, "ticket closed");
// 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 {

View file

@ -1,3 +1,4 @@
pub mod command;
pub mod forum;
pub mod handler;
pub mod service;

View file

@ -18,7 +18,13 @@ async fn main() -> anyhow::Result<()> {
// Use Lifecycle (avoid dead_code) - Phase 2 wiring placeholder.
let _ = <() as Lifecycle>::start(&()).await;
let _ = <() as Lifecycle>::shutdown(&()).await;
info!(prefix = %cfg.prefix, guild = ?cfg.guild_id, "starting bot-kernel");
info!(
prefix = %cfg.prefix,
guild = ?cfg.guild_id,
forum = cfg.ticket_use_forum,
forum_channel = ?cfg.ticket_forum_channel_id,
"starting bot-kernel"
);
let pool = db::pool::create(&cfg.db_path).await.context("db pool")?;
db::migration::run(&pool).await.context("migrations")?;