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

@ -11,5 +11,11 @@ RUST_LOG=info,serenity=warn,sqlx=warn
BIO_API_URL=http://localhost:3001/api BIO_API_URL=http://localhost:3001/api
DISCORD_BOT_SECRET=change-me-openssl-rand-hex-32 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) # JS inspector (tools/userbot/.env) - USER token only for local view (self-bot, ToS ban risk)
# DISCORD_USER_TOKEN=your_user_token_here # DISCORD_USER_TOKEN=your_user_token_here

View file

@ -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=?`. - [ ] **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`. - [ ] **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` проверке на старте. - [ ] **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<HashMap>`.
- Хелпер `tag_id_for(topic) -> Option<ForumTagId>` — 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 - Комьюнити ## Phase 4 - Комьюнити
- [x] Welcome embed (`feature/welcome`) - [x] Welcome embed (`feature/welcome`)

View file

@ -4,3 +4,5 @@ pub const GUILD_ID: &str = "GUILD_ID";
pub const SUPER_ADMIN_ID: &str = "SUPER_ADMIN_ID"; pub const SUPER_ADMIN_ID: &str = "SUPER_ADMIN_ID";
pub const DB_PATH: &str = "DB_PATH"; pub const DB_PATH: &str = "DB_PATH";
pub const BOT_ACTIVITY: &str = "BOT_ACTIVITY"; 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() 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> { pub fn load() -> Result<BotConfig> {
let token = env(keys::DISCORD_TOKEN, None)?; let token = env(keys::DISCORD_TOKEN, None)?;
if token.trim().is_empty() || token == "your_token_here" { 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), super_admin_id: env_opt(keys::SUPER_ADMIN_ID),
db_path: env(keys::DB_PATH, Some("data/bot.db"))?, db_path: env(keys::DB_PATH, Some("data/bot.db"))?,
activity: env(keys::BOT_ACTIVITY, Some("!help"))?, 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 super_admin_id: Option<u64>,
pub db_path: String, pub db_path: String,
pub activity: 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"); 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 { 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 super::service;
use crate::feature::ticket::forum;
use serenity::all::{ use serenity::all::{
ChannelId, ChannelType, ComponentInteraction, Context, CreateActionRow, CreateChannel, AutoArchiveDuration, ChannelId, ChannelType, ComponentInteraction, Context, CreateActionRow,
CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage, CreateChannel, CreateEmbed, CreateForumPost, CreateInteractionResponse,
CreateModal, CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, GuildId, CreateInteractionResponseMessage, CreateMessage, CreateModal, CreateSelectMenu,
InputTextStyle, ModalInteraction, PermissionOverwrite, PermissionOverwriteType, Permissions, CreateSelectMenuKind, CreateSelectMenuOption, GuildId, InputTextStyle, ModalInteraction,
PermissionOverwrite, PermissionOverwriteType, Permissions,
}; };
use tracing::{info, warn}; use tracing::{info, warn};
@ -362,9 +364,93 @@ async fn create_ticket_channel(
subject: &str, subject: &str,
description: &str, description: &str,
) -> anyhow::Result<ChannelId> { ) -> 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 safe_user = sanitize_name(&user.name);
let name = format!("ticket-{topic}-{safe_user}"); let name = format!("ticket-{topic}-{safe_user}");
// Discord channel name max 100, lowercase alphanumeric + dashes
let name = name.chars().take(90).collect::<String>(); let name = name.chars().take(90).collect::<String>();
let overwrites = build_ticket_overwrites(user.id.get()); let overwrites = build_ticket_overwrites(user.id.get());
let channel = guild_id let channel = guild_id
@ -400,7 +486,6 @@ async fn create_ticket_channel(
let _ = channel let _ = channel
.send_message(&ctx.http, CreateMessage::new().embed(embed).components(vec![row])) .send_message(&ctx.http, CreateMessage::new().embed(embed).components(vec![row]))
.await; .await;
// Log opening
let _ = ChannelId::new(TICKET_LOGS) let _ = ChannelId::new(TICKET_LOGS)
.send_message( .send_message(
&ctx.http, &ctx.http,
@ -548,8 +633,26 @@ async fn handle_close(ctx: Context, interaction: ComponentInteraction) {
service::cleanup_ticket(pool, channel_id.get()).await; 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 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; // Forum threads: archive+lock instead of delete (keeps history, respects forum lifecycle)
info!(channel=%channel_id, "ticket closed"); 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 { fn allow_view(id: serenity::all::RoleId) -> PermissionOverwrite {

View file

@ -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;

View file

@ -18,7 +18,13 @@ async fn main() -> anyhow::Result<()> {
// Use Lifecycle (avoid dead_code) - Phase 2 wiring placeholder. // Use Lifecycle (avoid dead_code) - Phase 2 wiring placeholder.
let _ = <() as Lifecycle>::start(&()).await; let _ = <() as Lifecycle>::start(&()).await;
let _ = <() as Lifecycle>::shutdown(&()).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")?; let pool = db::pool::create(&cfg.db_path).await.context("db pool")?;
db::migration::run(&pool).await.context("migrations")?; db::migration::run(&pool).await.context("migrations")?;