feat(ticket): forum mode with tags (TICKET_USE_FORUM, channel tags instead of category)
This commit is contained in:
parent
ac96cfcb59
commit
fb6fd4fac0
10 changed files with 349 additions and 11 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
158
src/feature/ticket/forum.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod command;
|
||||
pub mod forum;
|
||||
pub mod handler;
|
||||
pub mod service;
|
||||
|
|
|
|||
|
|
@ -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")?;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue