feat(ticket): forum mode with tags (TICKET_USE_FORUM, channel tags instead of category)
This commit is contained in:
parent
ac96cfcb59
commit
fb6fd4fac0
10 changed files with 349 additions and 11 deletions
|
|
@ -11,5 +11,11 @@ RUST_LOG=info,serenity=warn,sqlx=warn
|
|||
BIO_API_URL=http://localhost:3001/api
|
||||
DISCORD_BOT_SECRET=change-me-openssl-rand-hex-32
|
||||
|
||||
# Tickets - forum mode (Phase 3.2): one Forum channel with tags instead of category
|
||||
# false = classic text channels in SUPPORT (max 500), true = forum posts (max 1000 active, tags)
|
||||
TICKET_USE_FORUM=false
|
||||
# Optional: force specific forum channel ID (if not set, auto-search by name "support-forum" or create)
|
||||
# TICKET_FORUM_CHANNEL_ID=1234567890123456789
|
||||
|
||||
# JS inspector (tools/userbot/.env) - USER token only for local view (self-bot, ToS ban risk)
|
||||
# DISCORD_USER_TOKEN=your_user_token_here
|
||||
|
|
|
|||
43
ROADMAP.md
43
ROADMAP.md
|
|
@ -57,7 +57,48 @@ cargo run --bin ds-setup -- bootstrap --guild-id 123 --dry-run
|
|||
- [ ] **Presence `watching N tickets` + counter** - `Context::set_presence(ActivityData::watching(format!("{} tickets", count)))`, обновлять на `create/close/reconcile`; `ticket_count` из `SELECT COUNT(*) FROM tickets WHERE guild_id=?`.
|
||||
- [ ] **Threads vs Channels** - сейчас `ChannelType::Text` в SUPPORT (лимит 500 каналов, видно в списке). При >50 тикетов/день → мигрировать на `ChannelType::PublicThread`/`PrivateThread` внутри `#open-ticket` (лимит 1000 активных тредов, авто-архив 24ч/7д). Флаг `TICKET_USE_THREADS=false` в env, абстрагировать `create_ticket_channel` vs `create_thread`.
|
||||
- [ ] **Anti-spam** - в тикет-каналах: `slowmode 5s` по умолчанию, `mention` лимит (1 @everyone → warn), бот роль выше `Verified`/`Muted` (иначе `403 add_roles`), `MANAGE_ROLES`/`MANAGE_CHANNELS` проверке на старте.
|
||||
- [ ] **Forum/channel tags vs одна категория** - см. § Forum Tags ниже.
|
||||
- [ ] **Forum/channel tags vs одна категория** - см. § Forum Tags ниже (детальный план Phase 3.2).
|
||||
|
||||
### Phase 3.2 - Forum Tags — детальный план (начни делать сейчас)
|
||||
**Цель:** один Forum-канал `#🎫-support-forum` вместо категории `SUPPORT` + 5 каналов-тикетов. Каждый тикет = пост с тегом.
|
||||
|
||||
- [ ] **1. Config** - `src/config/keys.rs` + `loader.rs`: `TICKET_USE_FORUM=false`, `TICKET_FORUM_CHANNEL_ID` (опционально), `TICKET_FORUM_NAME=support-forum`. Парсинг `env_bool`.
|
||||
- [ ] **2. Модуль `feature/ticket/forum.rs`** (≤200 строк):
|
||||
- `FORUM_TAGS: &[(&str,&str,char)] = [("general","General",🛠), ("bug","Bug",🐛), ("appeal","Appeal",🔇), ("report","Report",😡), ("owners","Owners",👑)]` — зеркало `TOPICS` в handler.
|
||||
- `ensure_forum_channel(ctx, guild_id) -> ChannelId`:
|
||||
1. `guild.channels()` ищет `kind==Forum && name.contains("support-forum") || id==TICKET_FORUM_CHANNEL_ID`.
|
||||
2. Если нет → `guild.create_channel(CreateChannel::new("🎫-support-forum").kind(Forum).position(0).topic("Tickets via forum tags").available_tags(create_tags()).default_forum_layout(ListView).flags(REQUIRE_TAG))`. Логи.
|
||||
3. Если есть но `available_tags.len()!=5 || name mismatch` → `channel.edit(EditChannel::new().available_tags(...))` (идемпотентно, проверка по именам тегов).
|
||||
4. Возвращает `forum_id`, кэширует `tag_name→ForumTagId` в `OnceLock<HashMap>`.
|
||||
- Хелпер `tag_id_for(topic) -> Option<ForumTagId>` — lookup после ensure.
|
||||
- Тесты: unit `test_tag_mapping`.
|
||||
- [ ] **3. Ветвление создания** - `handler.rs::create_ticket(...)`:
|
||||
```rust
|
||||
if is_forum_enabled() {
|
||||
let forum_id = forum::ensure_forum_channel(&ctx, guild_id).await?;
|
||||
let tag = forum::tag_id_for(topic).ok_or("unknown topic")?;
|
||||
let post = forum_id.create_forum_post(&ctx.http,
|
||||
CreateForumPost::new(sanitize_name(&subject), CreateMessage::new().embed(embed))
|
||||
.add_applied_tag(tag)
|
||||
.auto_archive_duration(AutoArchiveDuration::OneWeek)
|
||||
).await?;
|
||||
let thread_id = post.id; // пост = тред-канал
|
||||
thread_id.send_message(...claim/close buttons...).await?;
|
||||
// permissions: thread.add_member(user.id).await?; // юзер уже owner, staff видны через форум perms
|
||||
record_ticket(pool, thread_id, guild, user.id).await;
|
||||
} else {
|
||||
// старый путь: guild.create_channel(...SUPPORT_CATEGORY...).await
|
||||
}
|
||||
```
|
||||
- Флаг читается из `std::env::var("TICKET_USE_FORUM")` + `config` (не ломает текущий режим, default `false`).
|
||||
- Сохраняет совместимость: `ensure_ticket_panel` остаётся в `#open-ticket`, форум не трогает панель.
|
||||
- [ ] **4. Транскрипт/Close/Delete для тредов**:
|
||||
- `fetch_transcript(http, thread_id)` работает и для тредов (тот же `GetMessages`).
|
||||
- `handle_close`: для форума — `thread.edit(EditThread::new().archived(true).locked(true)).await` + `deliver_transcript` + `cleanup`, вместо `delete`. `Delete` кнопка → `channel.delete`.
|
||||
- Логика `is_forum_ticket` по `channel.kind==PublicThread/PrivateThread`.
|
||||
- [ ] **5. Миграция/совместимость**: старые channel-тикеты остаются, новые форум-тикеты попадают в ту же `tickets` таблицу (`channel_id` теперь может быть thread id). `orphan reconciliation` проверяет и `GuildChannel` и `Thread`.
|
||||
- [ ] **6. Ready хук**: в `core/bot.rs::ready` добавить `if is_forum_enabled() { forum::ensure_forum_channel(...).await }` параллельно `ensure_ticket_panel`.
|
||||
- [ ] **7. Документация/flag**: `.env.example` добавить `TICKET_USE_FORUM=false` + `TICKET_FORUM_CHANNEL_ID=`, `SETUP.md` — как включить, `ds-setup` bootstrap создаёт форум если флаг true.
|
||||
|
||||
## Phase 4 - Комьюнити
|
||||
- [x] Welcome embed (`feature/welcome`)
|
||||
|
|
|
|||
|
|
@ -4,3 +4,5 @@ pub const GUILD_ID: &str = "GUILD_ID";
|
|||
pub const SUPER_ADMIN_ID: &str = "SUPER_ADMIN_ID";
|
||||
pub const DB_PATH: &str = "DB_PATH";
|
||||
pub const BOT_ACTIVITY: &str = "BOT_ACTIVITY";
|
||||
pub const TICKET_USE_FORUM: &str = "TICKET_USE_FORUM";
|
||||
pub const TICKET_FORUM_CHANNEL_ID: &str = "TICKET_FORUM_CHANNEL_ID";
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ fn env_opt(key: &str) -> Option<u64> {
|
|||
std::env::var(key).ok()?.parse().ok()
|
||||
}
|
||||
|
||||
fn env_bool(key: &str) -> bool {
|
||||
matches!(
|
||||
std::env::var(key).ok().as_deref().map(|v| v.trim().to_ascii_lowercase()),
|
||||
Some(ref s) if s == "1" || s == "true" || s == "yes" || s == "on"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn load() -> Result<BotConfig> {
|
||||
let token = env(keys::DISCORD_TOKEN, None)?;
|
||||
if token.trim().is_empty() || token == "your_token_here" {
|
||||
|
|
@ -24,5 +31,7 @@ pub fn load() -> Result<BotConfig> {
|
|||
super_admin_id: env_opt(keys::SUPER_ADMIN_ID),
|
||||
db_path: env(keys::DB_PATH, Some("data/bot.db"))?,
|
||||
activity: env(keys::BOT_ACTIVITY, Some("!help"))?,
|
||||
ticket_use_forum: env_bool(keys::TICKET_USE_FORUM),
|
||||
ticket_forum_channel_id: env_opt(keys::TICKET_FORUM_CHANNEL_ID),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,4 +9,6 @@ pub struct BotConfig {
|
|||
pub super_admin_id: Option<u64>,
|
||||
pub db_path: String,
|
||||
pub activity: String,
|
||||
pub ticket_use_forum: bool,
|
||||
pub ticket_forum_channel_id: Option<u64>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,16 @@ impl EventHandler for Handler {
|
|||
{
|
||||
warn!(error=%e, "ensure_ticket_panel failed");
|
||||
}
|
||||
if crate::feature::ticket::forum::is_enabled() {
|
||||
if let Err(e) = crate::feature::ticket::forum::ensure_forum_channel(
|
||||
&http2,
|
||||
serenity::all::GuildId::new(gid),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(error=%e, "ensure_forum_channel failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(gid) = self.guild_id {
|
||||
|
|
|
|||
158
src/feature/ticket/forum.rs
Normal file
158
src/feature/ticket/forum.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
//! Forum mode for tickets - one Forum channel with tags instead of category + text channels.
|
||||
//! Each topic maps to a ForumTag. Controlled by `TICKET_USE_FORUM` env (default false).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serenity::all::{
|
||||
ChannelId, ChannelType, CreateChannel, CreateForumTag, EditChannel, ForumTagId, GuildId,
|
||||
};
|
||||
use serenity::http::Http;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Topic → tag display (mirrors `handler::TOPICS` but tag names are forum-specific).
|
||||
const FORUM_TAGS: &[(&str, &str, char)] = &[
|
||||
("general", "General", '🛠'),
|
||||
("bug", "Bug", '🐛'),
|
||||
("appeal", "Appeal", '🔇'),
|
||||
("report", "Report", '😡'),
|
||||
("owners", "Owners", '👑'),
|
||||
];
|
||||
|
||||
static TAG_CACHE: OnceLock<HashMap<String, ForumTagId>> = OnceLock::new();
|
||||
|
||||
/// Returns true if forum mode enabled (`TICKET_USE_FORUM=1/true`).
|
||||
pub fn is_enabled() -> bool {
|
||||
matches!(
|
||||
std::env::var("TICKET_USE_FORUM").ok().as_deref().map(|v| v.trim().to_ascii_lowercase()),
|
||||
Some(ref s) if s == "1" || s == "true" || s == "yes" || s == "on"
|
||||
)
|
||||
}
|
||||
|
||||
/// Ensures forum channel exists (creates if missing) and tags are correct. Returns forum ChannelId.
|
||||
pub async fn ensure_forum_channel(http: &Http, guild_id: GuildId) -> anyhow::Result<ChannelId> {
|
||||
// 1. Try explicit ID from env
|
||||
if let Ok(id_str) = std::env::var("TICKET_FORUM_CHANNEL_ID") {
|
||||
if let Ok(id) = id_str.parse::<u64>() {
|
||||
let cid = ChannelId::new(id);
|
||||
if let Ok(ch) = guild_id.channels(http).await {
|
||||
if let Some((_, gc)) = ch.iter().find(|(k, _)| **k == cid) {
|
||||
if gc.kind == ChannelType::Forum {
|
||||
sync_tags(http, cid).await?;
|
||||
return Ok(cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. Search existing forum channels
|
||||
let channels = guild_id.channels(http).await?;
|
||||
if let Some((id, _)) = channels.iter().find(|(_, c)| {
|
||||
c.kind == ChannelType::Forum
|
||||
&& (c.name.contains("support-forum")
|
||||
|| c.name.contains("ticket-forum")
|
||||
|| c.name.contains("tickets"))
|
||||
}) {
|
||||
sync_tags(http, *id).await?;
|
||||
return Ok(*id);
|
||||
}
|
||||
// 3. Create new forum channel at top (tags synced after via EditChannel)
|
||||
let created = guild_id
|
||||
.create_channel(
|
||||
http,
|
||||
CreateChannel::new("🎫-support-forum")
|
||||
.kind(ChannelType::Forum)
|
||||
.topic("Tickets via forum tags — each post is a ticket")
|
||||
.position(0),
|
||||
)
|
||||
.await?;
|
||||
info!(channel=%created.id, "forum channel created for tickets");
|
||||
let _ = sync_tags(http, created.id).await;
|
||||
Ok(created.id)
|
||||
}
|
||||
|
||||
async fn sync_tags(http: &Http, forum_id: ChannelId) -> anyhow::Result<()> {
|
||||
if let Ok(serenity::all::Channel::Guild(gc)) = http.get_channel(forum_id).await {
|
||||
let existing: Vec<String> = gc.available_tags.iter().map(|t| t.name.clone()).collect();
|
||||
let expected: Vec<String> = FORUM_TAGS.iter().map(|(_, n, _)| n.to_string()).collect();
|
||||
let need_sync =
|
||||
existing.len() != expected.len() || !expected.iter().all(|e| existing.contains(e));
|
||||
if need_sync {
|
||||
let tags: Vec<CreateForumTag> = FORUM_TAGS
|
||||
.iter()
|
||||
.map(|(_, name, emoji)| CreateForumTag::new(*name).emoji(*emoji))
|
||||
.collect();
|
||||
let _ = forum_id.edit(http, EditChannel::new().available_tags(tags)).await;
|
||||
info!(channel=%forum_id, "forum tags synced");
|
||||
}
|
||||
let mut map = HashMap::new();
|
||||
for tag in gc.available_tags {
|
||||
map.insert(tag.name.clone(), tag.id);
|
||||
}
|
||||
let _ = TAG_CACHE.set(map);
|
||||
}
|
||||
refresh_tag_cache(http, forum_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refresh_tag_cache(http: &Http, forum_id: ChannelId) -> anyhow::Result<()> {
|
||||
if TAG_CACHE.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let ch = http.get_channel(forum_id).await?;
|
||||
if let serenity::all::Channel::Guild(gc) = ch {
|
||||
let mut map = HashMap::new();
|
||||
for tag in gc.available_tags {
|
||||
map.insert(tag.name.clone(), tag.id);
|
||||
}
|
||||
let _ = TAG_CACHE.set(map);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns ForumTagId for topic (e.g. "bug" → tag "Bug"). Requires ensure_forum_channel called before.
|
||||
pub fn tag_id_for(topic: &str) -> Option<ForumTagId> {
|
||||
let want = match topic {
|
||||
"general" => "General",
|
||||
"bug" => "Bug",
|
||||
"appeal" => "Appeal",
|
||||
"report" => "Report",
|
||||
"owners" => "Owners",
|
||||
_ => return None,
|
||||
};
|
||||
TAG_CACHE.get()?.get(want).copied()
|
||||
}
|
||||
|
||||
/// Also try direct lookup via HTTP if cache miss (best effort sync).
|
||||
pub async fn tag_id_for_async(http: &Http, forum_id: ChannelId, topic: &str) -> Option<ForumTagId> {
|
||||
if let Some(id) = tag_id_for(topic) {
|
||||
return Some(id);
|
||||
}
|
||||
if let Ok(serenity::all::Channel::Guild(gc)) = http.get_channel(forum_id).await {
|
||||
let want = match topic {
|
||||
"general" => "General",
|
||||
"bug" => "Bug",
|
||||
"appeal" => "Appeal",
|
||||
"report" => "Report",
|
||||
"owners" => "Owners",
|
||||
_ => return None,
|
||||
};
|
||||
for tag in gc.available_tags {
|
||||
if tag.name == want {
|
||||
return Some(tag.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
warn!(topic=%topic, "forum tag not found");
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn tag_mapping() {
|
||||
assert_eq!(FORUM_TAGS.len(), 5);
|
||||
assert!(FORUM_TAGS.iter().any(|(v, _, _)| *v == "bug"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
//! Ticket handler - SelectMenu + Modal + private channels, claim/close with transcript.
|
||||
//! Ticket handler - SelectMenu + Modal + private channels (or Forum posts if TICKET_USE_FORUM), claim/close with transcript.
|
||||
use super::service;
|
||||
use crate::feature::ticket::forum;
|
||||
use serenity::all::{
|
||||
ChannelId, ChannelType, ComponentInteraction, Context, CreateActionRow, CreateChannel,
|
||||
CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage,
|
||||
CreateModal, CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, GuildId,
|
||||
InputTextStyle, ModalInteraction, PermissionOverwrite, PermissionOverwriteType, Permissions,
|
||||
AutoArchiveDuration, ChannelId, ChannelType, ComponentInteraction, Context, CreateActionRow,
|
||||
CreateChannel, CreateEmbed, CreateForumPost, CreateInteractionResponse,
|
||||
CreateInteractionResponseMessage, CreateMessage, CreateModal, CreateSelectMenu,
|
||||
CreateSelectMenuKind, CreateSelectMenuOption, GuildId, InputTextStyle, ModalInteraction,
|
||||
PermissionOverwrite, PermissionOverwriteType, Permissions,
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
|
|
@ -362,9 +364,93 @@ async fn create_ticket_channel(
|
|||
subject: &str,
|
||||
description: &str,
|
||||
) -> anyhow::Result<ChannelId> {
|
||||
// Forum branch (if TICKET_USE_FORUM=true and forum available) - each ticket = forum post with tag
|
||||
if forum::is_enabled() {
|
||||
if let Ok(forum_id) = forum::ensure_forum_channel(&ctx.http, guild_id).await {
|
||||
if let Some(tag_id) = forum::tag_id_for_async(&ctx.http, forum_id, topic).await {
|
||||
let embed = CreateEmbed::new()
|
||||
.title(subject)
|
||||
.description(format!(
|
||||
"Hello <@{}> — staff will be with you shortly.\n\n**Topic:** {label} (`{topic}`)\n**Description:**\n{description}",
|
||||
user.id
|
||||
))
|
||||
.color(0x0058_65F2)
|
||||
.footer(serenity::all::CreateEmbedFooter::new(format!(
|
||||
"Opened by {} • {}",
|
||||
user.name, topic
|
||||
)));
|
||||
// Forum post name must be 1-100 chars, use sanitized subject
|
||||
let post_name = subject.chars().take(90).collect::<String>();
|
||||
let post_name = if post_name.trim().len() < 2 {
|
||||
format!("ticket-{topic}-{}", sanitize_name(&user.name))
|
||||
} else {
|
||||
post_name
|
||||
};
|
||||
match forum_id
|
||||
.create_forum_post(
|
||||
&ctx.http,
|
||||
CreateForumPost::new(post_name.clone(), CreateMessage::new().embed(embed))
|
||||
.add_applied_tag(tag_id)
|
||||
.auto_archive_duration(AutoArchiveDuration::OneWeek),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(thread) => {
|
||||
let thread_id = thread.id;
|
||||
if let Some(pool) = service::pool() {
|
||||
service::record_ticket(
|
||||
pool,
|
||||
thread_id.get(),
|
||||
guild_id.get(),
|
||||
user.id.get(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let row = serenity::all::CreateActionRow::Buttons(vec![
|
||||
serenity::all::CreateButton::new("ticket_claim")
|
||||
.label("Claim")
|
||||
.style(serenity::all::ButtonStyle::Primary)
|
||||
.emoji('🙋'),
|
||||
serenity::all::CreateButton::new("ticket_close")
|
||||
.label("Close")
|
||||
.style(serenity::all::ButtonStyle::Danger)
|
||||
.emoji('🔒'),
|
||||
]);
|
||||
let _ = thread_id
|
||||
.send_message(
|
||||
&ctx.http,
|
||||
CreateMessage::new()
|
||||
.content(format!("<@{}> — staff control", user.id))
|
||||
.components(vec![row]),
|
||||
)
|
||||
.await;
|
||||
let _ = ChannelId::new(TICKET_LOGS)
|
||||
.send_message(
|
||||
&ctx.http,
|
||||
CreateMessage::new().embed(
|
||||
CreateEmbed::new()
|
||||
.title("Ticket opened (forum)")
|
||||
.description(format!(
|
||||
"<@{}> opened forum post <#{}> — **{label}**\nSubject: {subject}",
|
||||
user.id, thread_id
|
||||
))
|
||||
.color(0x0058_65F2),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
info!(user=%user.name, channel=%thread_id, topic=%topic, forum=%forum_id, "ticket created via forum");
|
||||
return Ok(thread_id);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error=%e, "forum post failed, falling back to channel");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: classic text channel in SUPPORT category
|
||||
let safe_user = sanitize_name(&user.name);
|
||||
let name = format!("ticket-{topic}-{safe_user}");
|
||||
// Discord channel name max 100, lowercase alphanumeric + dashes
|
||||
let name = name.chars().take(90).collect::<String>();
|
||||
let overwrites = build_ticket_overwrites(user.id.get());
|
||||
let channel = guild_id
|
||||
|
|
@ -400,7 +486,6 @@ async fn create_ticket_channel(
|
|||
let _ = channel
|
||||
.send_message(&ctx.http, CreateMessage::new().embed(embed).components(vec![row]))
|
||||
.await;
|
||||
// Log opening
|
||||
let _ = ChannelId::new(TICKET_LOGS)
|
||||
.send_message(
|
||||
&ctx.http,
|
||||
|
|
@ -548,8 +633,26 @@ async fn handle_close(ctx: Context, interaction: ComponentInteraction) {
|
|||
service::cleanup_ticket(pool, channel_id.get()).await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // SAFETY: small delay to let log flush, not in command path, jitter not needed
|
||||
// Forum threads: archive+lock instead of delete (keeps history, respects forum lifecycle)
|
||||
let is_thread = ctx
|
||||
.http
|
||||
.get_channel(channel_id)
|
||||
.await
|
||||
.map(|c| matches!(c, serenity::all::Channel::Guild(gc) if gc.thread_metadata.is_some()))
|
||||
.unwrap_or(false);
|
||||
if is_thread {
|
||||
let _ = channel_id
|
||||
.edit_thread(&ctx.http, serenity::all::EditThread::new().archived(true).locked(true))
|
||||
.await;
|
||||
// Try delete if archive fails (fallback)
|
||||
if ctx.http.get_channel(channel_id).await.is_ok() {
|
||||
// Already archived, no delete needed; optionally delete after archive
|
||||
}
|
||||
info!(channel=%channel_id, thread=true, "ticket thread closed (archived)");
|
||||
} else {
|
||||
let _ = channel_id.delete(&ctx.http).await;
|
||||
info!(channel=%channel_id, "ticket closed");
|
||||
}
|
||||
}
|
||||
|
||||
fn allow_view(id: serenity::all::RoleId) -> PermissionOverwrite {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod command;
|
||||
pub mod forum;
|
||||
pub mod handler;
|
||||
pub mod service;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,13 @@ async fn main() -> anyhow::Result<()> {
|
|||
// Use Lifecycle (avoid dead_code) - Phase 2 wiring placeholder.
|
||||
let _ = <() as Lifecycle>::start(&()).await;
|
||||
let _ = <() as Lifecycle>::shutdown(&()).await;
|
||||
info!(prefix = %cfg.prefix, guild = ?cfg.guild_id, "starting bot-kernel");
|
||||
info!(
|
||||
prefix = %cfg.prefix,
|
||||
guild = ?cfg.guild_id,
|
||||
forum = cfg.ticket_use_forum,
|
||||
forum_channel = ?cfg.ticket_forum_channel_id,
|
||||
"starting bot-kernel"
|
||||
);
|
||||
|
||||
let pool = db::pool::create(&cfg.db_path).await.context("db pool")?;
|
||||
db::migration::run(&pool).await.context("migrations")?;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue