From fb6fd4fac025f36f1cbc157688616fbbc10c5015 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Mon, 21 Sep 2026 20:45:22 +0200 Subject: [PATCH] feat(ticket): forum mode with tags (TICKET_USE_FORUM, channel tags instead of category) --- .env.example | 6 ++ ROADMAP.md | 43 ++++++++- src/config/keys.rs | 2 + src/config/loader.rs | 9 ++ src/config/mod.rs | 2 + src/core/bot.rs | 10 +++ src/feature/ticket/forum.rs | 158 ++++++++++++++++++++++++++++++++++ src/feature/ticket/handler.rs | 121 ++++++++++++++++++++++++-- src/feature/ticket/mod.rs | 1 + src/main.rs | 8 +- 10 files changed, 349 insertions(+), 11 deletions(-) create mode 100644 src/feature/ticket/forum.rs diff --git a/.env.example b/.env.example index b54d4cd..1fea948 100644 --- a/.env.example +++ b/.env.example @@ -11,5 +11,11 @@ RUST_LOG=info,serenity=warn,sqlx=warn BIO_API_URL=http://localhost:3001/api DISCORD_BOT_SECRET=change-me-openssl-rand-hex-32 +# Tickets - forum mode (Phase 3.2): one Forum channel with tags instead of category +# false = classic text channels in SUPPORT (max 500), true = forum posts (max 1000 active, tags) +TICKET_USE_FORUM=false +# Optional: force specific forum channel ID (if not set, auto-search by name "support-forum" or create) +# TICKET_FORUM_CHANNEL_ID=1234567890123456789 + # JS inspector (tools/userbot/.env) - USER token only for local view (self-bot, ToS ban risk) # DISCORD_USER_TOKEN=your_user_token_here diff --git a/ROADMAP.md b/ROADMAP.md index bb1d7d9..4999a05 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,7 +57,48 @@ cargo run --bin ds-setup -- bootstrap --guild-id 123 --dry-run - [ ] **Presence `watching N tickets` + counter** - `Context::set_presence(ActivityData::watching(format!("{} tickets", count)))`, обновлять на `create/close/reconcile`; `ticket_count` из `SELECT COUNT(*) FROM tickets WHERE guild_id=?`. - [ ] **Threads vs Channels** - сейчас `ChannelType::Text` в SUPPORT (лимит 500 каналов, видно в списке). При >50 тикетов/день → мигрировать на `ChannelType::PublicThread`/`PrivateThread` внутри `#open-ticket` (лимит 1000 активных тредов, авто-архив 24ч/7д). Флаг `TICKET_USE_THREADS=false` в env, абстрагировать `create_ticket_channel` vs `create_thread`. - [ ] **Anti-spam** - в тикет-каналах: `slowmode 5s` по умолчанию, `mention` лимит (1 @everyone → warn), бот роль выше `Verified`/`Muted` (иначе `403 add_roles`), `MANAGE_ROLES`/`MANAGE_CHANNELS` проверке на старте. -- [ ] **Forum/channel tags vs одна категория** - см. § Forum Tags ниже. +- [ ] **Forum/channel tags vs одна категория** - см. § Forum Tags ниже (детальный план Phase 3.2). + +### Phase 3.2 - Forum Tags — детальный план (начни делать сейчас) +**Цель:** один Forum-канал `#🎫-support-forum` вместо категории `SUPPORT` + 5 каналов-тикетов. Каждый тикет = пост с тегом. + +- [ ] **1. Config** - `src/config/keys.rs` + `loader.rs`: `TICKET_USE_FORUM=false`, `TICKET_FORUM_CHANNEL_ID` (опционально), `TICKET_FORUM_NAME=support-forum`. Парсинг `env_bool`. +- [ ] **2. Модуль `feature/ticket/forum.rs`** (≤200 строк): + - `FORUM_TAGS: &[(&str,&str,char)] = [("general","General",🛠), ("bug","Bug",🐛), ("appeal","Appeal",🔇), ("report","Report",😡), ("owners","Owners",👑)]` — зеркало `TOPICS` в handler. + - `ensure_forum_channel(ctx, guild_id) -> ChannelId`: + 1. `guild.channels()` ищет `kind==Forum && name.contains("support-forum") || id==TICKET_FORUM_CHANNEL_ID`. + 2. Если нет → `guild.create_channel(CreateChannel::new("🎫-support-forum").kind(Forum).position(0).topic("Tickets via forum tags").available_tags(create_tags()).default_forum_layout(ListView).flags(REQUIRE_TAG))`. Логи. + 3. Если есть но `available_tags.len()!=5 || name mismatch` → `channel.edit(EditChannel::new().available_tags(...))` (идемпотентно, проверка по именам тегов). + 4. Возвращает `forum_id`, кэширует `tag_name→ForumTagId` в `OnceLock`. + - Хелпер `tag_id_for(topic) -> Option` — lookup после ensure. + - Тесты: unit `test_tag_mapping`. +- [ ] **3. Ветвление создания** - `handler.rs::create_ticket(...)`: + ```rust + if is_forum_enabled() { + let forum_id = forum::ensure_forum_channel(&ctx, guild_id).await?; + let tag = forum::tag_id_for(topic).ok_or("unknown topic")?; + let post = forum_id.create_forum_post(&ctx.http, + CreateForumPost::new(sanitize_name(&subject), CreateMessage::new().embed(embed)) + .add_applied_tag(tag) + .auto_archive_duration(AutoArchiveDuration::OneWeek) + ).await?; + let thread_id = post.id; // пост = тред-канал + thread_id.send_message(...claim/close buttons...).await?; + // permissions: thread.add_member(user.id).await?; // юзер уже owner, staff видны через форум perms + record_ticket(pool, thread_id, guild, user.id).await; + } else { + // старый путь: guild.create_channel(...SUPPORT_CATEGORY...).await + } + ``` + - Флаг читается из `std::env::var("TICKET_USE_FORUM")` + `config` (не ломает текущий режим, default `false`). + - Сохраняет совместимость: `ensure_ticket_panel` остаётся в `#open-ticket`, форум не трогает панель. +- [ ] **4. Транскрипт/Close/Delete для тредов**: + - `fetch_transcript(http, thread_id)` работает и для тредов (тот же `GetMessages`). + - `handle_close`: для форума — `thread.edit(EditThread::new().archived(true).locked(true)).await` + `deliver_transcript` + `cleanup`, вместо `delete`. `Delete` кнопка → `channel.delete`. + - Логика `is_forum_ticket` по `channel.kind==PublicThread/PrivateThread`. +- [ ] **5. Миграция/совместимость**: старые channel-тикеты остаются, новые форум-тикеты попадают в ту же `tickets` таблицу (`channel_id` теперь может быть thread id). `orphan reconciliation` проверяет и `GuildChannel` и `Thread`. +- [ ] **6. Ready хук**: в `core/bot.rs::ready` добавить `if is_forum_enabled() { forum::ensure_forum_channel(...).await }` параллельно `ensure_ticket_panel`. +- [ ] **7. Документация/flag**: `.env.example` добавить `TICKET_USE_FORUM=false` + `TICKET_FORUM_CHANNEL_ID=`, `SETUP.md` — как включить, `ds-setup` bootstrap создаёт форум если флаг true. ## Phase 4 - Комьюнити - [x] Welcome embed (`feature/welcome`) diff --git a/src/config/keys.rs b/src/config/keys.rs index db3c5d2..4c071ad 100644 --- a/src/config/keys.rs +++ b/src/config/keys.rs @@ -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"; diff --git a/src/config/loader.rs b/src/config/loader.rs index 1d5ff3b..ea8c513 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -12,6 +12,13 @@ fn env_opt(key: &str) -> Option { 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 { let token = env(keys::DISCORD_TOKEN, None)?; if token.trim().is_empty() || token == "your_token_here" { @@ -24,5 +31,7 @@ pub fn load() -> Result { 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), }) } diff --git a/src/config/mod.rs b/src/config/mod.rs index 9e6423c..d2a65f9 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -9,4 +9,6 @@ pub struct BotConfig { pub super_admin_id: Option, pub db_path: String, pub activity: String, + pub ticket_use_forum: bool, + pub ticket_forum_channel_id: Option, } diff --git a/src/core/bot.rs b/src/core/bot.rs index 4139995..b9bf6ae 100644 --- a/src/core/bot.rs +++ b/src/core/bot.rs @@ -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 { diff --git a/src/feature/ticket/forum.rs b/src/feature/ticket/forum.rs new file mode 100644 index 0000000..cf19b1c --- /dev/null +++ b/src/feature/ticket/forum.rs @@ -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> = 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 { + // 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::() { + 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 = gc.available_tags.iter().map(|t| t.name.clone()).collect(); + let expected: Vec = 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 = 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 { + 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 { + 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")); + } +} diff --git a/src/feature/ticket/handler.rs b/src/feature/ticket/handler.rs index 6ff23cf..51388a2 100644 --- a/src/feature/ticket/handler.rs +++ b/src/feature/ticket/handler.rs @@ -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 { + // 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::(); + 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::(); 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 { diff --git a/src/feature/ticket/mod.rs b/src/feature/ticket/mod.rs index c6da5e8..62a937a 100644 --- a/src/feature/ticket/mod.rs +++ b/src/feature/ticket/mod.rs @@ -1,3 +1,4 @@ pub mod command; +pub mod forum; pub mod handler; pub mod service; diff --git a/src/main.rs b/src/main.rs index 5c85c35..4477acf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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")?;