feat: ticket v2 select+modal, counter top+human-only, help interactive
This commit is contained in:
parent
589238570a
commit
ac96cfcb59
9 changed files with 825 additions and 105 deletions
47
ROADMAP.md
47
ROADMAP.md
|
|
@ -12,6 +12,7 @@
|
||||||
📁 SUPPORT #open-ticket, #faq
|
📁 SUPPORT #open-ticket, #faq
|
||||||
📁 VOICE create-voice (temp), afk
|
📁 VOICE create-voice (temp), afk
|
||||||
📁 STAFF (🔒) #mod-logs, #bot-logs
|
📁 STAFF (🔒) #mod-logs, #bot-logs
|
||||||
|
↑ counter 👥 Members: N голосовой канал ВНЕ категории в самом верху (pos 0, bots excluded)
|
||||||
```
|
```
|
||||||
|
|
||||||
Роли: `@Verified` (кнопка в #welcome), `@Blogger`, `@Support`, `@Staff`, `@Muted`.
|
Роли: `@Verified` (кнопка в #welcome), `@Blogger`, `@Support`, `@Staff`, `@Muted`.
|
||||||
|
|
@ -23,7 +24,7 @@
|
||||||
- [x] `config/loader` dotenv → BotConfig, `db/pool` WAL + FK=ON (фикс C6 старого бота)
|
- [x] `config/loader` dotenv → BotConfig, `db/pool` WAL + FK=ON (фикс C6 старого бота)
|
||||||
- [x] `db/migration` idempotent транзакции (фикс C5)
|
- [x] `db/migration` idempotent транзакции (фикс C5)
|
||||||
- [x] `core/lifecycle` trait, `core/bot` ReadyEvent + graceful shutdown
|
- [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 с ворнингом)
|
- [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
|
cargo run --bin ds-setup -- bootstrap --guild-id 123 --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
## Phase 2 - Верификация и доступ (guild 1509503154708811837, next)
|
## Phase 2 - Верификация и доступ (guild 1509503154708811837)
|
||||||
- [ ] **Verify (embed+✅)** - `#✅|verify` read-only (`@everyone: VIEW ✅, SEND ❌, ADD_REACTIONS ✅`), embed + `✅` реакция → выдача `✅ Verified` (1509503647480811610), бот роль выше Verified, `GUILD_MESSAGE_REACTIONS` + partials. Кнопка - альтернатива, но делаем реакцию как просил. Фикс F3: проверка `MANAGE_ROLES`.
|
- [x] **Verify (embed+✅)** - `#✅|verify` read-only, auto-embed на старте, `✅` → `✅ Verified`
|
||||||
- [ ] `!mute/!unmute/!warn/!purge/!slowmode` - атомарные транзакции, без `UndeclaredThrowableException` (фикс C1)
|
- [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)
|
- [ ] Logging в #mod-logs (join/leave, edit/delete, mod actions)
|
||||||
- [ ] RateLimiter + `core/queue` Semaphore 100 (уже заготовка)
|
- [ ] 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`)
|
- [ ] RSS/Telegram → #blog-feed (webhook, `reqwest` + `feed-rs`)
|
||||||
- [ ] `!post` - анонс поста (embed с cover, линк)
|
- [ ] `!post` - анонс поста (embed с cover, линк)
|
||||||
- [x] GitHub webhook → #changelog (нативная интеграция Discord, без кода: GitHub webhook URL = `<discord-webhook-url>/github`)
|
- [x] GitHub webhook → #changelog (нативная интеграция Discord, без кода: GitHub webhook URL = `<discord-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:<topic>` (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 - Комьюнити
|
## Phase 4 - Комьюнити
|
||||||
- [x] Welcome embed (`feature/welcome`)
|
- [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] Polls (`/poll вопрос; вариант1; вариант2`, embed + реакции цифрами, `feature/poll`)
|
||||||
- [x] Scheduler / напоминания (`/remind 10m текст`, личные, SQLite + фоновый воркер 30с, `feature/remind`)
|
- [x] Scheduler / напоминания (`/remind 10m текст`, личные, SQLite + фоновый воркер 30с, `feature/remind`)
|
||||||
- [x] Temp voice (`#create-voice` → временный канал, `feature/voice`)
|
- [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`.
|
F1,F2,F4 - экономика: атомарные `UPDATE ... WHERE cash>=?`, daily `WHERE last_daily != today`.
|
||||||
F8/C1 - не блокировать gateway: все DB/HTTP вне EventHandler, `tokio::spawn`, `sqlx` async.
|
F8/C1 - не блокировать gateway: все DB/HTTP вне EventHandler, `tokio::spawn`, `sqlx` async.
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ impl EventHandler for Handler {
|
||||||
info!(user = %ready.user.name, guilds = ready.guilds.len(), "ready");
|
info!(user = %ready.user.name, guilds = ready.guilds.len(), "ready");
|
||||||
// Auto-create verify embed once (no manual !verify_setup needed).
|
// Auto-create verify embed once (no manual !verify_setup needed).
|
||||||
let http = ctx.http.clone();
|
let http = ctx.http.clone();
|
||||||
|
let http2 = ctx.http.clone();
|
||||||
|
let gid_clone = self.guild_id;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// SAFETY: startup delay 2s - not in command path, lets cache fill, no jitter needed (one-shot on boot)
|
// 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;
|
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");
|
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 {
|
if let Some(gid) = self.guild_id {
|
||||||
crate::feature::counter::task::spawn(ctx.clone(), serenity::all::GuildId::new(gid));
|
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) {
|
async fn interaction_create(&self, ctx: Context, interaction: serenity::all::Interaction) {
|
||||||
if let Some(comp) = interaction.as_message_component() {
|
match interaction {
|
||||||
let comp = comp.clone();
|
serenity::all::Interaction::Component(comp) => {
|
||||||
if comp.data.custom_id.starts_with("ticket_")
|
let c = comp.clone();
|
||||||
|| comp.data.custom_id.starts_with("lang_")
|
if c.data.custom_id.starts_with("ticket_")
|
||||||
{
|
|| c.data.custom_id == "ticket_topic"
|
||||||
handle_component(ctx, comp).await;
|
|| 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;
|
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_") {
|
} else if id.starts_with("ticket_") {
|
||||||
crate::feature::ticket::handler::on_button(ctx.clone(), interaction.clone()).await;
|
crate::feature::ticket::handler::on_button(ctx.clone(), interaction.clone()).await;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
//! Discord limits channel renames to ~2/10min, so we poll every 10 minutes
|
||||||
//! and skip the edit entirely when the count has not changed.
|
//! 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};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
const VOICE_CATEGORY: ChannelId = ChannelId::new(1509503649062326484);
|
const MARKER: &str = "👥 Members:";
|
||||||
const MARKER: &str = "👥|Members:";
|
|
||||||
const INTERVAL_SECS: u64 = 600;
|
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<ChannelId> {
|
async fn ensure_channel(ctx: &Context, guild_id: GuildId) -> anyhow::Result<ChannelId> {
|
||||||
let channels = guild_id.channels(&ctx.http).await?;
|
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::<ChannelId>::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);
|
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
|
let created = guild_id
|
||||||
.create_channel(
|
.create_channel(
|
||||||
&ctx.http,
|
&ctx.http,
|
||||||
CreateChannel::new(format!("{MARKER} 0"))
|
CreateChannel::new(format!("{MARKER} 0"))
|
||||||
.kind(ChannelType::Voice)
|
.kind(ChannelType::Voice)
|
||||||
.category(VOICE_CATEGORY),
|
.position(0)
|
||||||
|
.permissions(deny_connect),
|
||||||
)
|
)
|
||||||
.await?;
|
.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)
|
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<serenity::all::UserId> = 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.
|
/// Spawns the periodic counter-refresh task. Non-blocking, one instance per boot.
|
||||||
pub fn spawn(ctx: Context, guild_id: GuildId) {
|
pub fn spawn(ctx: Context, guild_id: GuildId) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|
@ -46,14 +125,30 @@ async fn run_once(
|
||||||
guild_id: GuildId,
|
guild_id: GuildId,
|
||||||
last_count: &mut Option<u64>,
|
last_count: &mut Option<u64>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let count =
|
// Always reconcile position first, even if count unchanged
|
||||||
ctx.http.get_guild_with_counts(guild_id).await?.approximate_member_count.unwrap_or(0);
|
|
||||||
if *last_count == Some(count) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let channel_id = ensure_channel(ctx, guild_id).await?;
|
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::<ChannelId>::None).position(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
channel_id.edit(&ctx.http, edit).await?;
|
||||||
*last_count = Some(count);
|
*last_count = Some(count);
|
||||||
info!(count, "member counter updated");
|
info!(count, "member counter updated (humans only, outside category, top)");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
src/feature/help/categories.rs
Normal file
61
src/feature/help/categories.rs
Normal file
|
|
@ -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<Cat> {
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,13 @@
|
||||||
use crate::feature::Ctx;
|
use crate::feature::Ctx;
|
||||||
use crate::util::i18n::{self, Lang};
|
|
||||||
use poise::CreateReply;
|
use poise::CreateReply;
|
||||||
use serenity::all::{CreateEmbed, CreateEmbedFooter};
|
use serenity::all::{CreateEmbed, CreateEmbedFooter};
|
||||||
|
|
||||||
// debug/test-only: ping, shutdown will be removed after Phase 1
|
/// Shows help with interactive category selector.
|
||||||
// help stays - beautiful embed with categories + pagination via select
|
|
||||||
|
|
||||||
/// `/help` + `!help` - nice embed by categories (poise 0.7).
|
|
||||||
#[poise::command(slash_command, prefix_command, track_edits)]
|
#[poise::command(slash_command, prefix_command, track_edits)]
|
||||||
pub async fn help(
|
pub async fn help(
|
||||||
ctx: Ctx<'_>,
|
ctx: Ctx<'_>,
|
||||||
#[description = "Command to show"] command: Option<String>,
|
#[description = "Command to show"] command: Option<String>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let lang = if let Some(member) = ctx.author_member().await {
|
|
||||||
let ids: Vec<u64> = member.roles.iter().map(|r| r.get()).collect();
|
|
||||||
i18n::from_roles(&ids)
|
|
||||||
} else {
|
|
||||||
Lang::En
|
|
||||||
};
|
|
||||||
if let Some(name) = command {
|
if let Some(name) = command {
|
||||||
if let Some(cmd) = ctx.framework().options().commands.iter().find(|c| c.name == name) {
|
if let Some(cmd) = ctx.framework().options().commands.iter().find(|c| c.name == name) {
|
||||||
let desc = cmd.description.as_deref().unwrap_or("no description");
|
let desc = cmd.description.as_deref().unwrap_or("no description");
|
||||||
|
|
@ -39,19 +29,12 @@ pub async fn help(
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
let prefix = ctx.data().cfg.prefix.clone();
|
||||||
let embed = CreateEmbed::new()
|
let guild_icon = ctx
|
||||||
.title(i18n::t("help.title", lang))
|
.guild_id()
|
||||||
.description(i18n::t("help.desc", lang))
|
.and_then(|_| ctx.cache().guild(ctx.guild_id()?).map(|g| g.icon_url().unwrap_or_default()));
|
||||||
.color(0x0058_65F2)
|
let embed = crate::feature::help::handler::main_embed(&prefix, &ctx.author().name, guild_icon);
|
||||||
.thumbnail(ctx.cache().current_user().avatar_url().unwrap_or_default())
|
let row = crate::feature::help::handler::selector_row();
|
||||||
.field(i18n::t("help.field.info", lang), "`/help` - this menu\n`/serverinfo` - server info\n`/ping` *(test, to be removed)*", true)
|
ctx.send(CreateReply::default().embed(embed).components(vec![row])).await?;
|
||||||
.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 <command> for details", ctx.author().name)))
|
|
||||||
.timestamp(serenity::all::Timestamp::now());
|
|
||||||
|
|
||||||
ctx.send(CreateReply::default().embed(embed)).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
135
src/feature/help/handler.rs
Normal file
135
src/feature/help/handler.rs
Normal file
|
|
@ -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<CreateSelectMenuOption> = categories::all()
|
||||||
|
.iter()
|
||||||
|
.map(|(name, cat)| {
|
||||||
|
CreateSelectMenuOption::new(
|
||||||
|
format!("{} {}", cat.emoji, name),
|
||||||
|
format!("category:{name}"),
|
||||||
|
)
|
||||||
|
.description(cat.desc)
|
||||||
|
.emoji(cat.emoji.parse::<serenity::all::ReactionType>().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 <n>", "Bulk delete messages (Mod)"),
|
||||||
|
("slowmode", "/slowmode <sec>", "Set slowmode (Mod)"),
|
||||||
|
("warn", "/warn @user <reason>", "Warn user (Mod)"),
|
||||||
|
("shutdown", "/shutdown", "Graceful shutdown (Owner)"),
|
||||||
|
("ticket_panel", "/ticket_panel", "Re-post ticket panel (Staff)"),
|
||||||
|
("ticket_blacklist_add", "/ticket_blacklist_add @user <reason>", "Block user from tickets"),
|
||||||
|
("ticket_blacklist_remove", "/ticket_blacklist_remove @user", "Unblock ticket user"),
|
||||||
|
("poll", "/poll <q; opt1; opt2>", "Create poll with reactions"),
|
||||||
|
("remind", "/remind <10m> <text>", "Personal reminder"),
|
||||||
|
("blog_post", "/blog_post <url>", "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<String>,
|
||||||
|
) -> 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 <command> 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<String>) -> CreateEmbed {
|
||||||
|
let total: usize = categories::all().iter().map(|(n, _)| category_commands(n).len()).sum();
|
||||||
|
build_main_embed(prefix, requester, total, guild_icon)
|
||||||
|
}
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
|
pub mod categories;
|
||||||
pub mod command;
|
pub mod command;
|
||||||
|
pub mod handler;
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,13 @@
|
||||||
use crate::feature::Ctx;
|
use crate::feature::Ctx;
|
||||||
use crate::feature::ticket::service;
|
use crate::feature::ticket::service;
|
||||||
use crate::util::i18n::{self, Lang};
|
|
||||||
use poise::CreateReply;
|
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)]
|
#[poise::command(slash_command, prefix_command, guild_only)]
|
||||||
pub async fn ticket_panel(ctx: Ctx<'_>) -> anyhow::Result<()> {
|
pub async fn ticket_panel(ctx: Ctx<'_>) -> anyhow::Result<()> {
|
||||||
let lang = if let Some(member) = ctx.author_member().await {
|
let embed = crate::feature::ticket::handler::panel_embed();
|
||||||
let ids: Vec<u64> = member.roles.iter().map(|r| r.get()).collect();
|
let row = crate::feature::ticket::handler::panel_select_row();
|
||||||
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('🐛'),
|
|
||||||
]);
|
|
||||||
ctx.send(CreateReply::default().embed(embed).components(vec![row])).await?;
|
ctx.send(CreateReply::default().embed(embed).components(vec![row])).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 super::service;
|
||||||
use serenity::all::{
|
use serenity::all::{
|
||||||
ChannelId, ChannelType, ComponentInteraction, Context, CreateChannel, CreateEmbed,
|
ChannelId, ChannelType, ComponentInteraction, Context, CreateActionRow, CreateChannel,
|
||||||
CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage,
|
CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage,
|
||||||
PermissionOverwrite, PermissionOverwriteType, Permissions,
|
CreateModal, CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, GuildId,
|
||||||
|
InputTextStyle, ModalInteraction, PermissionOverwrite, PermissionOverwriteType, Permissions,
|
||||||
};
|
};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
const SUPPORT_CATEGORY: u64 = 1547213779224363008;
|
const SUPPORT_CATEGORY: u64 = 1547213779224363008;
|
||||||
const TICKET_LOGS: u64 = 1509503717127356446;
|
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<CreateSelectMenuOption> = 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<ChannelId> {
|
||||||
|
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) {
|
pub async fn on_button(ctx: Context, interaction: ComponentInteraction) {
|
||||||
match interaction.data.custom_id.as_str() {
|
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_claim" => handle_claim(ctx, interaction).await,
|
||||||
"ticket_close" => handle_close(ctx, interaction).await,
|
"ticket_close" => handle_close(ctx, interaction).await,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async fn ephemeral(ctx: &Context, i: &ComponentInteraction, content: String) {
|
|
||||||
let _ = i
|
/// Handles select menu `ticket_topic` -> shows modal with subject/description.
|
||||||
.create_response(
|
pub async fn on_select(ctx: Context, interaction: ComponentInteraction) {
|
||||||
&ctx.http,
|
if interaction.data.custom_id != "ticket_topic" {
|
||||||
CreateInteractionResponse::Message(
|
return;
|
||||||
CreateInteractionResponseMessage::new().content(content).ephemeral(true),
|
}
|
||||||
|
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),
|
||||||
),
|
),
|
||||||
)
|
CreateActionRow::InputText(
|
||||||
.await;
|
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:<topic>` -> 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 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(pool) = service::pool() {
|
||||||
if let Some(reason) = service::is_blacklisted(pool, guild_id.get(), user.id.get()).await {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let category = if custom == "ticket_bug" { "bug" } else { "general" };
|
|
||||||
if let Ok(channels) = guild_id.channels(&ctx.http).await {
|
if let Ok(channels) = guild_id.channels(&ctx.http).await {
|
||||||
let exists = channels.values().any(|c| {
|
let exists = channels.values().any(|c| {
|
||||||
c.name.starts_with(&format!("ticket-{}", user.name.to_lowercase()))
|
c.name.starts_with(&format!("ticket-{}", user.name.to_lowercase()))
|
||||||
|| c.name.starts_with(&format!("ticket-{}", user.id))
|
|| c.name.starts_with(&format!("ticket-{}", user.id))
|
||||||
});
|
});
|
||||||
if exists {
|
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;
|
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<String> {
|
||||||
|
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<ChannelId> {
|
||||||
|
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 overwrites = build_ticket_overwrites(user.id.get());
|
||||||
let channel = match guild_id
|
let channel = guild_id
|
||||||
.create_channel(
|
.create_channel(
|
||||||
&ctx.http,
|
&ctx.http,
|
||||||
CreateChannel::new(&name)
|
CreateChannel::new(&name)
|
||||||
|
|
@ -58,25 +375,18 @@ async fn handle_create(ctx: Context, interaction: ComponentInteraction) {
|
||||||
.category(ChannelId::new(SUPPORT_CATEGORY))
|
.category(ChannelId::new(SUPPORT_CATEGORY))
|
||||||
.permissions(overwrites),
|
.permissions(overwrites),
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
{
|
|
||||||
Ok(ch) => ch,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(error=%e, "failed to create ticket");
|
|
||||||
ephemeral(&ctx, &interaction, "Failed to create ticket.".into()).await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Some(pool) = service::pool() {
|
if let Some(pool) = service::pool() {
|
||||||
service::record_ticket(pool, channel.id.get(), guild_id.get(), user.id.get()).await;
|
service::record_ticket(pool, channel.id.get(), guild_id.get(), user.id.get()).await;
|
||||||
}
|
}
|
||||||
let embed = CreateEmbed::new()
|
let embed = CreateEmbed::new()
|
||||||
.title(format!("Ticket - {category}"))
|
.title(subject)
|
||||||
.description(format!(
|
.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
|
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![
|
let row = serenity::all::CreateActionRow::Buttons(vec![
|
||||||
serenity::all::CreateButton::new("ticket_claim")
|
serenity::all::CreateButton::new("ticket_claim")
|
||||||
.label("Claim")
|
.label("Claim")
|
||||||
|
|
@ -90,9 +400,92 @@ async fn handle_create(ctx: Context, interaction: ComponentInteraction) {
|
||||||
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;
|
||||||
ephemeral(&ctx, &interaction, format!("Ticket created: <#{}>", channel.id)).await;
|
// Log opening
|
||||||
info!(user=%user.name, channel=%channel.id, "ticket created");
|
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::<Vec<_>>().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) {
|
async fn handle_claim(ctx: Context, interaction: ComponentInteraction) {
|
||||||
let Some(guild_id) = interaction.guild_id else { return };
|
let Some(guild_id) = interaction.guild_id else { return };
|
||||||
let channel_id = interaction.channel_id;
|
let channel_id = interaction.channel_id;
|
||||||
|
|
@ -136,6 +529,7 @@ async fn handle_claim(ctx: Context, interaction: ComponentInteraction) {
|
||||||
.await;
|
.await;
|
||||||
info!(channel=%channel_id, claimer=%claimer.id, "ticket claimed");
|
info!(channel=%channel_id, claimer=%claimer.id, "ticket claimed");
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_close(ctx: Context, interaction: ComponentInteraction) {
|
async fn handle_close(ctx: Context, interaction: ComponentInteraction) {
|
||||||
let channel_id = interaction.channel_id;
|
let channel_id = interaction.channel_id;
|
||||||
let user = interaction.user.clone();
|
let user = interaction.user.clone();
|
||||||
|
|
@ -157,6 +551,7 @@ async fn handle_close(ctx: Context, interaction: ComponentInteraction) {
|
||||||
let _ = channel_id.delete(&ctx.http).await;
|
let _ = channel_id.delete(&ctx.http).await;
|
||||||
info!(channel=%channel_id, "ticket closed");
|
info!(channel=%channel_id, "ticket closed");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn allow_view(id: serenity::all::RoleId) -> PermissionOverwrite {
|
fn allow_view(id: serenity::all::RoleId) -> PermissionOverwrite {
|
||||||
PermissionOverwrite {
|
PermissionOverwrite {
|
||||||
allow: Permissions::VIEW_CHANNEL
|
allow: Permissions::VIEW_CHANNEL
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue