diff --git a/ROADMAP.md b/ROADMAP.md index 9bd276d..bb1d7d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,7 @@ 📁 SUPPORT #open-ticket, #faq 📁 VOICE create-voice (temp), afk 📁 STAFF (🔒) #mod-logs, #bot-logs + ↑ counter 👥 Members: N голосовой канал ВНЕ категории в самом верху (pos 0, bots excluded) ``` Роли: `@Verified` (кнопка в #welcome), `@Blogger`, `@Support`, `@Staff`, `@Muted`. @@ -23,7 +24,7 @@ - [x] `config/loader` dotenv → BotConfig, `db/pool` WAL + FK=ON (фикс C6 старого бота) - [x] `db/migration` idempotent транзакции (фикс C5) - [x] `core/lifecycle` trait, `core/bot` ReadyEvent + graceful shutdown -- [x] `feature/help|info|admin` - `!ping`, `!serverinfo`, `!shutdown` (owner) +- [x] `feature/help|info|admin` - `!ping`, `!serverinfo`, `!shutdown` (owner) → теперь help интерактивный (селект категорий) - [x] `ds-setup` CLI: `inspect` / `bootstrap --dry-run/--apply` (BOT token, user-token с ворнингом) **Запуск:** @@ -34,9 +35,11 @@ cargo run --bin ds-setup -- inspect --guild-id 123 cargo run --bin ds-setup -- bootstrap --guild-id 123 --dry-run ``` -## Phase 2 - Верификация и доступ (guild 1509503154708811837, next) -- [ ] **Verify (embed+✅)** - `#✅|verify` read-only (`@everyone: VIEW ✅, SEND ❌, ADD_REACTIONS ✅`), embed + `✅` реакция → выдача `✅ Verified` (1509503647480811610), бот роль выше Verified, `GUILD_MESSAGE_REACTIONS` + partials. Кнопка - альтернатива, но делаем реакцию как просил. Фикс F3: проверка `MANAGE_ROLES`. -- [ ] `!mute/!unmute/!warn/!purge/!slowmode` - атомарные транзакции, без `UndeclaredThrowableException` (фикс C1) +## Phase 2 - Верификация и доступ (guild 1509503154708811837) +- [x] **Verify (embed+✅)** - `#✅|verify` read-only, auto-embed на старте, `✅` → `✅ Verified` +- [x] Help - переписан как в Java: `help/categories.rs` + `help/handler.rs` + селект `help:category` → `UpdateMessage`, цвета/эмодзи по категориям, фильтр мод-прав (Info/Mod/Tickets/Utility), как `HelpMenuBuilder.java`/`HelpMenuHandler.java` +- [x] Counter fix - `feature/counter/task.rs`: вне категории `position(0)`, каждый старт проверяет `parent_id` и `position`, поднимает вверх; считает только живых (пагинация `members(1000, after)`, `!user.bot`), fallback на `approximate_member_count` +- [ ] `!mute/!unmute/!warn/!purge/!slowmode` - атомарные транзакции - [ ] Logging в #mod-logs (join/leave, edit/delete, mod actions) - [ ] RateLimiter + `core/queue` Semaphore 100 (уже заготовка) @@ -44,11 +47,21 @@ cargo run --bin ds-setup -- bootstrap --guild-id 123 --dry-run - [ ] RSS/Telegram → #blog-feed (webhook, `reqwest` + `feed-rs`) - [ ] `!post` - анонс поста (embed с cover, линк) - [x] GitHub webhook → #changelog (нативная интеграция Discord, без кода: GitHub webhook URL = `/github`) -- [x] Ticket (кнопки в #open-ticket → приватный канал/тред в SUPPORT, overwrites за 1 операцию, claim, blacklist, transcript → hastebin) - `feature/ticket` +- [x] Ticket v2 - SelectMenu `ticket_topic` + Modal `ticket_modal:` (subject/description) → приватный канал в SUPPORT, overwrites за 1 операцию, sanitize имени, claim, blacklist, transcript → hastebin - `feature/ticket/handler.rs` + auto-panel `ensure_ticket_panel()` на Ready (как verify) + +### Phase 3.1 - Ticket hardening (TODO, приоритезировано 22.09.2026) +- [ ] **Add/Remove user** - `/ticket_add @user` `/ticket_remove @user` (staff): `MANAGE_CHANNELS` check, `channel.permission_overwrites` add/remove Member overwrite `VIEW+SEND`, лог в ticket-logs, DM добавленному. +- [ ] **Close reason modal + Reopen/Delete** - `Close` → modal `ticket_close_reason` (reason 10-500), затем `ControlPanel`: `Reopen` (снимает `archived`, восстанавливает overwrites), `Transcript` (haste + file), `Delete` (транскрипт → `ChannelId::delete`). Как `ticketPresentation.js:166` `buildClosedControlPanelRow()` (Reopen secondary, Save secondary, Delete danger). +- [ ] **Cooldown 5 мин + RateLimiter per user** - после `cleanup_ticket` писать в `ticket_cooldowns(user_id, guild_id, expires_at)`; при `on_select/on_modal` проверять `now < expires_at` → ephemeral `Wait 3m 12s`. Plus глобальный `RateLimiter` per-user 1 ticket/5m + burst check. +- [ ] **Orphan reconciliation on boot** - `ticketReconcile()` в `ready`: `SELECT tickets` → для каждого `guild.channels().fetch(ticket.channel_id)` если `None` → `DELETE FROM tickets/claims`, лог embed `Ticket Manually Deleted` (без транскрипта, как `threadDelete.js`), dm owner. Итого `ticketRepository.listOpenTickets()` аналог. +- [ ] **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 ниже. ## Phase 4 - Комьюнити - [x] Welcome embed (`feature/welcome`) -- [x] Counters (member count в voice-канале, `feature/counter`, refresh 10 мин) +- [x] Counters (member count в voice-канале, `feature/counter`, refresh 10 мин, теперь вне категории наверху) - [x] Polls (`/poll вопрос; вариант1; вариант2`, embed + реакции цифрами, `feature/poll`) - [x] Scheduler / напоминания (`/remind 10m текст`, личные, SQLite + фоновый воркер 30с, `feature/remind`) - [x] Temp voice (`#create-voice` → временный канал, `feature/voice`) @@ -59,6 +72,28 @@ cargo run --bin ds-setup -- bootstrap --guild-id 123 --dry-run --- +## Forum / Channel Tags — вместо одной категории SUPPORT + +**Что это:** Discord с 2022г поддерживает **Forum-каналы** (`ChannelType::Forum`). Внутри один канал-форум, а каждый тикет — пост-тред с тегами. Теги — это встроенные лейблы Discord (до 20 штук, цвет+эмодзи), например: `❓ General`, `🐛 Bug`, `🔇 Appeal`, `😡 Report`. Юзер при создании поста выбирает тег(и), фильтрация по тегам нативная. + +**Как это соотносится с твоей текущей схемой:** +- Сейчас: `SUPPORT` — категория, внутри создаются `ticket-bug-username` каналы (`ChannelType::Text`, `parent_id=SUPPORT_CATEGORY`, `permissions` на каждого юзера). Минус: жрёт лимит 500 каналов, каждый тикет виден в сайдбаре всем стаффам сразу, надо руками чистить. +- Форум-альтернатива: создаёшь **один** канал `Forum` (`#support-tickets`, `ChannelType::Forum`, `available_tags=[ General, Bug, Appeal, Report, Owners ]` + `default_reaction_emoji`). Панель остаётся (`StringSelect` → modal), но `create_ticket` делает `forum.create_forum_post(title=subject, content=description, tags=[topicTag])` → пост-тред. Права наследуются от форума (private threads видны только `@everyone` deny + thread member). Преимущества и лимиты: + +| Критерий | Каналы в категории | Forum tags | +|---|---|---| +| Лимит | 500 каналов/гильдия | 1000 активных тредов + неограниченно архивированных | +| Сайдбар | каждый тикет — отдельный канал (шум) | один форум-канал, внутри список постов (чисто) | +| Теги/фильтр | категория одна, топик в имени канала `ticket-bug-` | нативные теги (фильтр, поиск, цвет, required/optional) | +| Права | `PermissionOverwrite` на канал (кастом) | `thread_add_member` + `VIEW_CHANNEL` deny на форум | +| Транскрипт | `channel.messages().fetch()` | `thread.messages().fetch()` аналогично | +| Авто-архив | ручной `delete` | нативный `default_auto_archive_duration` 1ч/24ч/7д | +| Сортировка | вручную | по активности, по тегам | + +**Рекомендация для Loki Dev:** пока <50 тикетов/день — оставляй **каналы** (проще дебажить, транскрипт/claim привычнее). При росте — мигрировать на **форум с тегами** без смены UX: селект/ модалка остаются, меняется только `service::create_ticket` (флаг `TICKET_USE_FORUM`). В форум-режиме `SUPPORT_CATEGORY` не нужен, теги создаются один раз на старте (`GET/PATCH /channels/{forum.id}` с `available_tags`). Это «канал-теги вместо категории» — один канал с 5 тегами заменяет 5 подканалов/префиксов. + +--- + ## Уроки старого бота - что НЕ повторяем F1,F2,F4 - экономика: атомарные `UPDATE ... WHERE cash>=?`, daily `WHERE last_daily != today`. F8/C1 - не блокировать gateway: все DB/HTTP вне EventHandler, `tokio::spawn`, `sqlx` async. diff --git a/src/core/bot.rs b/src/core/bot.rs index 4fdb2aa..4139995 100644 --- a/src/core/bot.rs +++ b/src/core/bot.rs @@ -19,6 +19,8 @@ impl EventHandler for Handler { info!(user = %ready.user.name, guilds = ready.guilds.len(), "ready"); // Auto-create verify embed once (no manual !verify_setup needed). let http = ctx.http.clone(); + let http2 = ctx.http.clone(); + let gid_clone = self.guild_id; tokio::spawn(async move { // 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; @@ -28,6 +30,16 @@ impl EventHandler for Handler { { warn!(error = %e, "ensure_verify_message failed"); } + if let Some(gid) = gid_clone { + if let Err(e) = crate::feature::ticket::handler::ensure_ticket_panel( + &http2, + serenity::all::GuildId::new(gid), + ) + .await + { + warn!(error=%e, "ensure_ticket_panel failed"); + } + } }); if let Some(gid) = self.guild_id { crate::feature::counter::task::spawn(ctx.clone(), serenity::all::GuildId::new(gid)); @@ -55,13 +67,24 @@ impl EventHandler for Handler { } async fn interaction_create(&self, ctx: Context, interaction: serenity::all::Interaction) { - if let Some(comp) = interaction.as_message_component() { - let comp = comp.clone(); - if comp.data.custom_id.starts_with("ticket_") - || comp.data.custom_id.starts_with("lang_") - { - handle_component(ctx, comp).await; + match interaction { + serenity::all::Interaction::Component(comp) => { + let c = comp.clone(); + if c.data.custom_id.starts_with("ticket_") + || c.data.custom_id == "ticket_topic" + || c.data.custom_id.starts_with("lang_") + || c.data.custom_id == "help:category" + { + handle_component(ctx, c).await; + } } + serenity::all::Interaction::Modal(modal) => { + let m = modal.clone(); + if m.data.custom_id.starts_with("ticket_modal:") { + crate::feature::ticket::handler::on_modal(ctx, m).await; + } + } + _ => {} } } @@ -85,6 +108,10 @@ async fn handle_component(ctx: Context, interaction: ComponentInteraction) { crate::feature::lang::handler::on_lang_button(ctx.clone(), member, lang).await; } } + } else if id == "ticket_topic" { + crate::feature::ticket::handler::on_select(ctx.clone(), interaction.clone()).await; + } else if id == "help:category" { + crate::feature::help::handler::on_select(ctx.clone(), interaction.clone()).await; } else if id.starts_with("ticket_") { crate::feature::ticket::handler::on_button(ctx.clone(), interaction.clone()).await; } diff --git a/src/feature/counter/task.rs b/src/feature/counter/task.rs index 3214a0b..10e7094 100644 --- a/src/feature/counter/task.rs +++ b/src/feature/counter/task.rs @@ -1,32 +1,111 @@ -//! Background loop: keeps a voice channel named "👥|Members: N" in sync. +//! Background loop: keeps a voice channel named "👥 Members: N" in sync, outside categories at top. //! Discord limits channel renames to ~2/10min, so we poll every 10 minutes //! and skip the edit entirely when the count has not changed. +//! Counts human members only (excludes bots). -use serenity::all::{ChannelId, ChannelType, Context, CreateChannel, EditChannel, GuildId}; +use serenity::all::{ + ChannelId, ChannelType, Context, CreateChannel, EditChannel, GuildId, PermissionOverwrite, + PermissionOverwriteType, Permissions, +}; use tracing::{info, warn}; -const VOICE_CATEGORY: ChannelId = ChannelId::new(1509503649062326484); -const MARKER: &str = "👥|Members:"; +const MARKER: &str = "👥 Members:"; const INTERVAL_SECS: u64 = 600; -/// Finds the counter channel by name marker, creating it once if missing. +/// Finds the counter channel by name marker, creating it once if missing outside any category at top. +/// Enforces outside-category + position 0 on every call (startup reconcile). async fn ensure_channel(ctx: &Context, guild_id: GuildId) -> anyhow::Result { let channels = guild_id.channels(&ctx.http).await?; - if let Some((id, _)) = channels.iter().find(|(_, c)| c.name.starts_with(MARKER)) { + if let Some((id, ch)) = channels.iter().find(|(_, c)| c.name.starts_with(MARKER)) { + let needs_move = ch.parent_id.is_some() || ch.position != 0; + if needs_move { + let mut edit = EditChannel::new().position(0); + if ch.parent_id.is_some() { + edit = edit.category(Option::::None); + } + 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)"); + } return Ok(*id); } + // Create outside category at position 0, deny CONNECT so users can't join, but VIEW remains + let everyone = serenity::all::RoleId::new(guild_id.get()); + let overwrites = vec![PermissionOverwrite { + allow: Permissions::VIEW_CHANNEL | Permissions::CONNECT, + deny: Permissions::empty(), + kind: PermissionOverwriteType::Role(everyone), + }]; + // Note: we allow CONNECT but voice channel will appear as counter; to prevent joining we could deny CONNECT + // For counter, keep VIEW only; if you want to block joining, set deny CONNECT. Keep allow for visibility. + // Here we set deny CONNECT to make it non-joinable but still visible at top. + let deny_connect = vec![PermissionOverwrite { + allow: Permissions::VIEW_CHANNEL, + deny: Permissions::CONNECT, + kind: PermissionOverwriteType::Role(everyone), + }]; + let _ = overwrites; // keep for reference let created = guild_id .create_channel( &ctx.http, CreateChannel::new(format!("{MARKER} 0")) .kind(ChannelType::Voice) - .category(VOICE_CATEGORY), + .position(0) + .permissions(deny_connect), ) .await?; - info!(channel = %created.id, "member counter channel created"); + info!(channel = %created.id, "member counter channel created at top (outside category, bots excluded)"); Ok(created.id) } +/// Counts human (non-bot) members via paginated guild members fetch. +/// Falls back to approximate count if privileged fetch fails. +async fn fetch_human_count(ctx: &Context, guild_id: GuildId) -> u64 { + // Try accurate paginated fetch (requires GUILD_MEMBERS intent) + let mut after: Option = None; + let mut human_count: u64 = 0; + let mut total_fetched: usize = 0; + loop { + let batch = match guild_id.members(&ctx.http, Some(1000), after).await { + Ok(m) => m, + Err(e) => { + warn!(error=%e, "members fetch failed, fallback to approximate"); + // Fallback + if let Ok(g) = ctx.http.get_guild_with_counts(guild_id).await { + return g.approximate_member_count.unwrap_or(human_count); + } + return human_count; + } + }; + if batch.is_empty() { + break; + } + total_fetched += batch.len(); + for m in &batch { + if !m.user.bot { + human_count += 1; + } + } + if batch.len() < 1000 { + break; + } + after = batch.last().map(|m| m.user.id); + // Safety: prevent infinite loop on huge guilds (>10k) - but still handle + if total_fetched > 15000 { + warn!("large guild >15k members, truncated count"); + break; + } + } + // If we got zero but guild not empty, fallback + if human_count == 0 && total_fetched == 0 { + if let Ok(g) = ctx.http.get_guild_with_counts(guild_id).await { + if let Some(c) = g.approximate_member_count { + return c; + } + } + } + human_count +} + /// Spawns the periodic counter-refresh task. Non-blocking, one instance per boot. pub fn spawn(ctx: Context, guild_id: GuildId) { tokio::spawn(async move { @@ -46,14 +125,30 @@ async fn run_once( guild_id: GuildId, last_count: &mut Option, ) -> anyhow::Result<()> { - let count = - ctx.http.get_guild_with_counts(guild_id).await?.approximate_member_count.unwrap_or(0); - if *last_count == Some(count) { - return Ok(()); - } + // Always reconcile position first, even if count unchanged let channel_id = ensure_channel(ctx, guild_id).await?; - channel_id.edit(&ctx.http, EditChannel::new().name(format!("{MARKER} {count}"))).await?; + let count = fetch_human_count(ctx, guild_id).await; + if *last_count == Some(count) { + // Still need to verify name is correct (channel could have been renamed manually) - check via cache + if let Ok(channels) = guild_id.channels(&ctx.http).await { + if let Some((_, ch)) = channels.iter().find(|(id, _)| **id == channel_id) { + let expected = format!("{MARKER} {count}"); + if ch.name == expected { + return Ok(()); + } + } + } + } + let mut edit = EditChannel::new().name(format!("{MARKER} {count}")); + if let Ok(channels) = guild_id.channels(&ctx.http).await { + if let Some((_, ch)) = channels.iter().find(|(id, _)| **id == channel_id) { + if ch.parent_id.is_some() || ch.position != 0 { + edit = edit.category(Option::::None).position(0); + } + } + } + channel_id.edit(&ctx.http, edit).await?; *last_count = Some(count); - info!(count, "member counter updated"); + info!(count, "member counter updated (humans only, outside category, top)"); Ok(()) } diff --git a/src/feature/help/categories.rs b/src/feature/help/categories.rs new file mode 100644 index 0000000..7f03700 --- /dev/null +++ b/src/feature/help/categories.rs @@ -0,0 +1,61 @@ +//! Help categories - static mapping of category → emoji, color, description. +//! Mirrors Java `HelpCategories.java` but trimmed for Rust kernel (no economy/music). + +use serenity::all::Colour; + +/// Category id is the display name (sorted alphabetically in selector). +pub struct Cat { + pub emoji: &'static str, + pub color: Colour, + pub desc: &'static str, +} + +pub fn all() -> Vec<(&'static str, Cat)> { + vec![ + ( + "Information", + Cat { + emoji: "ℹ️", color: Colour::from_rgb(52, 152, 219), desc: "Server & bot info" + }, + ), + ( + "Moderation", + Cat { + emoji: "🛡️", + color: Colour::from_rgb(231, 76, 60), + desc: "Mod tools (requires perms)", + }, + ), + ( + "Tickets", + Cat { + emoji: "🎫", + color: Colour::from_rgb(88, 101, 242), + desc: "Support tickets & blacklist", + }, + ), + ( + "Utility", + Cat { + emoji: "🔧", + color: Colour::from_rgb(46, 204, 113), + desc: "Polls, reminders, blog", + }, + ), + ] +} + +pub fn get(cat: &str) -> Option { + all().into_iter().find(|(n, _)| *n == cat).map(|(_, c)| c) +} + +/// Maps poise command name → category name. +pub fn category_of(cmd_name: &str) -> &'static str { + match cmd_name { + "help" | "ping" | "serverinfo" => "Information", + "verify_setup" | "clear" | "slowmode" | "warn" | "shutdown" => "Moderation", + "ticket_panel" | "ticket_blacklist_add" | "ticket_blacklist_remove" => "Tickets", + "poll" | "remind" | "blog_post" => "Utility", + _ => "Utility", + } +} diff --git a/src/feature/help/command.rs b/src/feature/help/command.rs index 700ba19..4883c79 100644 --- a/src/feature/help/command.rs +++ b/src/feature/help/command.rs @@ -1,23 +1,13 @@ use crate::feature::Ctx; -use crate::util::i18n::{self, Lang}; use poise::CreateReply; use serenity::all::{CreateEmbed, CreateEmbedFooter}; -// debug/test-only: ping, shutdown will be removed after Phase 1 -// help stays - beautiful embed with categories + pagination via select - -/// `/help` + `!help` - nice embed by categories (poise 0.7). +/// Shows help with interactive category selector. #[poise::command(slash_command, prefix_command, track_edits)] pub async fn help( ctx: Ctx<'_>, #[description = "Command to show"] command: Option, ) -> anyhow::Result<()> { - let lang = if let Some(member) = ctx.author_member().await { - let ids: Vec = member.roles.iter().map(|r| r.get()).collect(); - i18n::from_roles(&ids) - } else { - Lang::En - }; if let Some(name) = command { if let Some(cmd) = ctx.framework().options().commands.iter().find(|c| c.name == name) { let desc = cmd.description.as_deref().unwrap_or("no description"); @@ -39,19 +29,12 @@ pub async fn help( } return Ok(()); } - - let embed = CreateEmbed::new() - .title(i18n::t("help.title", lang)) - .description(i18n::t("help.desc", lang)) - .color(0x0058_65F2) - .thumbnail(ctx.cache().current_user().avatar_url().unwrap_or_default()) - .field(i18n::t("help.field.info", lang), "`/help` - this menu\n`/serverinfo` - server info\n`/ping` *(test, to be removed)*", true) - .field(i18n::t("help.field.moderation", lang), "`/verify_setup` - embed ✅ in #verify (Staff only)\n`/shutdown` *(test, to be removed)*", true) - .field(i18n::t("help.field.tickets", lang), "`/ticket_panel` - panel in #open-ticket (buttons)", true) - .field(i18n::t("help.field.verify", lang), "Channel `#✅|verify` - read-only, react `✅` → `✅ Verified`", false) - .footer(CreateEmbedFooter::new(format!("Requested by {} • /help for details", ctx.author().name))) - .timestamp(serenity::all::Timestamp::now()); - - ctx.send(CreateReply::default().embed(embed)).await?; + let prefix = ctx.data().cfg.prefix.clone(); + let guild_icon = ctx + .guild_id() + .and_then(|_| ctx.cache().guild(ctx.guild_id()?).map(|g| g.icon_url().unwrap_or_default())); + let embed = crate::feature::help::handler::main_embed(&prefix, &ctx.author().name, guild_icon); + let row = crate::feature::help::handler::selector_row(); + ctx.send(CreateReply::default().embed(embed).components(vec![row])).await?; Ok(()) } diff --git a/src/feature/help/handler.rs b/src/feature/help/handler.rs new file mode 100644 index 0000000..26afa5a --- /dev/null +++ b/src/feature/help/handler.rs @@ -0,0 +1,135 @@ +//! Help select handler - edits help message when user picks category. + +use super::categories; +use serenity::all::{ + ComponentInteraction, ComponentInteractionDataKind, Context, CreateActionRow, CreateEmbed, + CreateEmbedFooter, CreateInteractionResponse, CreateSelectMenu, CreateSelectMenuKind, + CreateSelectMenuOption, +}; + +/// Builds the category selector row - reused in main page and category pages. +pub fn selector_row() -> CreateActionRow { + let options: Vec = categories::all() + .iter() + .map(|(name, cat)| { + CreateSelectMenuOption::new( + format!("{} {}", cat.emoji, name), + format!("category:{name}"), + ) + .description(cat.desc) + .emoji(cat.emoji.parse::().unwrap_or('📌'.into())) + }) + .collect(); + let menu = CreateSelectMenu::new("help:category", CreateSelectMenuKind::String { options }) + .placeholder("📂 Choose category") + .min_values(1) + .max_values(1); + CreateActionRow::SelectMenu(menu) +} + +fn category_commands(cat: &str) -> Vec<(&'static str, &'static str, &'static str)> { + // (name, usage, desc) - keep in sync with `feature/mod.rs` registration + let all = vec![ + ("help", "/help [command]", "Shows help (this menu)"), + ("ping", "/ping", "Latency check"), + ("serverinfo", "/serverinfo", "Server member & channel info"), + ("verify_setup", "/verify_setup", "Re-post verify embed (Staff)"), + ("clear", "/clear ", "Bulk delete messages (Mod)"), + ("slowmode", "/slowmode ", "Set slowmode (Mod)"), + ("warn", "/warn @user ", "Warn user (Mod)"), + ("shutdown", "/shutdown", "Graceful shutdown (Owner)"), + ("ticket_panel", "/ticket_panel", "Re-post ticket panel (Staff)"), + ("ticket_blacklist_add", "/ticket_blacklist_add @user ", "Block user from tickets"), + ("ticket_blacklist_remove", "/ticket_blacklist_remove @user", "Unblock ticket user"), + ("poll", "/poll ", "Create poll with reactions"), + ("remind", "/remind <10m> ", "Personal reminder"), + ("blog_post", "/blog_post ", "Announce blog post"), + ]; + all.into_iter().filter(|(n, _, _)| categories::category_of(n) == cat).collect() +} + +fn build_category_embed(cat: &str, requester: &str) -> CreateEmbed { + let c = categories::get(cat).unwrap_or(categories::Cat { + emoji: "📌", + color: serenity::all::Colour::from_rgb(88, 101, 242), + desc: "", + }); + let cmds = category_commands(cat); + let mut desc = format!("**{} {}** — {}\n\n", c.emoji, cat, c.desc); + if cmds.is_empty() { + desc.push_str("_No commands in this category_"); + } else { + for (name, usage, d) in &cmds { + desc.push_str(&format!("`{}` — {}\n", usage, d)); + // add alias for search + let _ = name; + } + } + desc.push_str("\n*Use the selector below to switch categories.*"); + CreateEmbed::new() + .title(format!("{} {}", c.emoji, cat)) + .description(desc) + .color(c.color) + .footer(CreateEmbedFooter::new(format!( + "Requested by {requester} • {} commands", + cmds.len() + ))) +} + +fn build_main_embed( + prefix: &str, + requester: &str, + total: usize, + guild_icon: Option, +) -> CreateEmbed { + let mut cat_text = String::new(); + for (name, cat) in categories::all() { + let count = category_commands(name).len(); + cat_text.push_str(&format!("{} **{}** — {} ({} cmds)\n", cat.emoji, name, cat.desc, count)); + } + let mut e = CreateEmbed::new() + .title("📚 Loki Bot — Help") + .description(format!( + "Hi! Slash + prefix `{}` bot. **{}** commands total.\n\n**📂 Categories:**\n{}\nUse the selector below to view commands.", + prefix, total, cat_text + )) + .color(0x0058_65F2) + .footer(CreateEmbedFooter::new(format!("Requested by {requester} • /help for details"))); + if let Some(url) = guild_icon { + e = e.thumbnail(url); + } + e +} + +/// Handles `help:category` select. Edits the original help message. +pub async fn on_select(ctx: Context, interaction: ComponentInteraction) { + if interaction.data.custom_id != "help:category" { + return; + } + let value = match &interaction.data.kind { + ComponentInteractionDataKind::StringSelect { values } => { + values.first().cloned().unwrap_or_default() + } + _ => return, + }; + let Some(cat) = value.strip_prefix("category:") else { return }; + let embed = build_category_embed(cat, &interaction.user.name); + let row = selector_row(); + // Edit original message via UpdateMessage response + let _ = interaction + .create_response( + &ctx.http, + CreateInteractionResponse::UpdateMessage( + serenity::all::CreateInteractionResponseMessage::new() + .embed(embed) + .components(vec![row]), + ), + ) + .await; +} + +/// Helper for command.rs to build main page. +pub fn main_embed(prefix: &str, requester: &str, guild_icon: Option) -> CreateEmbed { + let total: usize = categories::all().iter().map(|(n, _)| category_commands(n).len()).sum(); + build_main_embed(prefix, requester, total, guild_icon) +} diff --git a/src/feature/help/mod.rs b/src/feature/help/mod.rs index 9fe7961..c6f5fb0 100644 --- a/src/feature/help/mod.rs +++ b/src/feature/help/mod.rs @@ -1 +1,3 @@ +pub mod categories; pub mod command; +pub mod handler; diff --git a/src/feature/ticket/command.rs b/src/feature/ticket/command.rs index 0d8ba1f..cf6ee86 100644 --- a/src/feature/ticket/command.rs +++ b/src/feature/ticket/command.rs @@ -1,26 +1,13 @@ use crate::feature::Ctx; use crate::feature::ticket::service; -use crate::util::i18n::{self, Lang}; use poise::CreateReply; -use serenity::all::{CreateActionRow, CreateButton, CreateEmbed}; +use serenity::all::CreateEmbed; -/// Panel with ticket open buttons. +/// Re-posts modern ticket panel (select + modal). #[poise::command(slash_command, prefix_command, guild_only)] pub async fn ticket_panel(ctx: Ctx<'_>) -> anyhow::Result<()> { - let lang = if let Some(member) = ctx.author_member().await { - let ids: Vec = member.roles.iter().map(|r| r.get()).collect(); - i18n::from_roles(&ids) - } else { - Lang::En - }; - let embed = CreateEmbed::new() - .title(i18n::t("ticket.title", lang)) - .description(i18n::t("ticket.desc", lang)) - .color(0x0058_65F2); - let row = CreateActionRow::Buttons(vec![ - CreateButton::new("ticket_general").label("General").emoji('🛠'), - CreateButton::new("ticket_bug").label("Bug").emoji('🐛'), - ]); + let embed = crate::feature::ticket::handler::panel_embed(); + let row = crate::feature::ticket::handler::panel_select_row(); ctx.send(CreateReply::default().embed(embed).components(vec![row])).await?; Ok(()) } diff --git a/src/feature/ticket/handler.rs b/src/feature/ticket/handler.rs index 6e98a8d..6ff23cf 100644 --- a/src/feature/ticket/handler.rs +++ b/src/feature/ticket/handler.rs @@ -1,56 +1,373 @@ -//! Ticket handler - creates private channels, claim and close with transcript. +//! Ticket handler - SelectMenu + Modal + private channels, claim/close with transcript. use super::service; use serenity::all::{ - ChannelId, ChannelType, ComponentInteraction, Context, CreateChannel, CreateEmbed, - CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage, - PermissionOverwrite, PermissionOverwriteType, Permissions, + ChannelId, ChannelType, ComponentInteraction, Context, CreateActionRow, CreateChannel, + CreateEmbed, 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; -/// Handles ticket buttons. +/// 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 = 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 { + 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(ctx, interaction).await, + "ticket_general" | "ticket_bug" => handle_create_legacy(ctx, interaction).await, "ticket_claim" => handle_claim(ctx, interaction).await, "ticket_close" => handle_close(ctx, interaction).await, _ => {} } } -async fn ephemeral(ctx: &Context, i: &ComponentInteraction, content: String) { - let _ = i - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new().content(content).ephemeral(true), + +/// 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), ), - ) - .await; + 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; } -async fn handle_create(ctx: Context, interaction: ComponentInteraction) { - let Some(guild_id) = interaction.guild_id else { return }; + +/// Handles modal submit `ticket_modal:` -> 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(); - let custom = interaction.data.custom_id.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 { - ephemeral(&ctx, &interaction, format!("You are blacklisted: {reason}")).await; + let _ = interaction + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("You are blacklisted: {reason}")) + .ephemeral(true), + ), + ) + .await; return; } } - let category = if custom == "ticket_bug" { "bug" } else { "general" }; 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; + let _ = interaction + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("You already have an open ticket.") + .ephemeral(true), + ), + ) + .await; return; } } - let name = format!("ticket-{}-{}", category, user.name.to_lowercase().replace(' ', "-")); + // 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 { + 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 { + 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 = match guild_id + let channel = guild_id .create_channel( &ctx.http, CreateChannel::new(&name) @@ -58,25 +375,18 @@ async fn handle_create(ctx: Context, interaction: ComponentInteraction) { .category(ChannelId::new(SUPPORT_CATEGORY)) .permissions(overwrites), ) - .await - { - Ok(ch) => ch, - Err(e) => { - warn!(error=%e, "failed to create ticket"); - ephemeral(&ctx, &interaction, "Failed to create ticket.".into()).await; - return; - } - }; + .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(format!("Ticket - {category}")) + .title(subject) .description(format!( - "Hello <@{}>, staff will be with you shortly.\n\nCategory: **{category}**", + "Hello <@{}> — staff will be with you shortly.\n\n**Topic:** {label} (`{topic}`)\n**Description:**\n{description}", user.id )) - .color(0x0058_65F2); + .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") @@ -90,9 +400,92 @@ async fn handle_create(ctx: Context, interaction: ComponentInteraction) { let _ = channel .send_message(&ctx.http, CreateMessage::new().embed(embed).components(vec![row])) .await; - ephemeral(&ctx, &interaction, format!("Ticket created: <#{}>", channel.id)).await; - info!(user=%user.name, channel=%channel.id, "ticket created"); + // Log opening + 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::>().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; @@ -136,6 +529,7 @@ async fn handle_claim(ctx: Context, interaction: ComponentInteraction) { .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(); @@ -157,6 +551,7 @@ async fn handle_close(ctx: Context, interaction: ComponentInteraction) { 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