diff --git a/src/core/bot.rs b/src/core/bot.rs index 1c04f64..2261265 100644 --- a/src/core/bot.rs +++ b/src/core/bot.rs @@ -21,6 +21,8 @@ impl EventHandler for Handler { let http = ctx.http.clone(); let http2 = ctx.http.clone(); let gid_clone = self.guild_id; + let pool_clone = self.pool.clone(); + let ctx_clone = ctx.clone(); 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; @@ -49,6 +51,47 @@ impl EventHandler for Handler { warn!(error=%e, "ensure_forum_channel failed"); } } + // Orphan reconciliation: purge DB rows whose channel/thread no longer exists + let tickets = + crate::feature::ticket::service::count::list_tickets(&pool_clone).await; + let mut purged = 0; + for (cid_str, gid_str, opener) in tickets { + if gid_str != gid.to_string() { + continue; + } + if let Ok(cid) = cid_str.parse::() { + let cid = serenity::all::ChannelId::new(cid); + if http2.get_channel(cid).await.is_err() { + let _ = sqlx::query("DELETE FROM tickets WHERE channel_id=?") + .bind(&cid_str) + .execute(&pool_clone) + .await; + let _ = sqlx::query("DELETE FROM ticket_claims WHERE channel_id=?") + .bind(&cid_str) + .execute(&pool_clone) + .await; + purged += 1; + warn!(channel=%cid_str, opener=%opener, "orphan ticket purged"); + // Log to ticket-logs without transcript (manually deleted) + let _ = serenity::all::ChannelId::new(1509503717127356446) + .send_message(&http2, serenity::all::CreateMessage::new().embed(serenity::all::CreateEmbed::new().title("⚠️ Ticket Manually Deleted").description(format!("Ticket <#{cid_str}> (opener <@{opener}>) manually deleted — transcript unavailable.")).color(0x00ED_4245))) + .await; + } + } + } + if purged > 0 { + info!(purged, "orphan tickets reconciled"); + } + // Presence: watching N tickets + let count = + crate::feature::ticket::service::count::count_tickets(&pool_clone, gid).await; + ctx_clone.set_presence( + Some(serenity::all::ActivityData::custom(format!( + "watching {} tickets", + count + ))), + serenity::all::OnlineStatus::Online, + ); } }); if let Some(gid) = self.guild_id { diff --git a/src/feature/ticket/handler/create.rs b/src/feature/ticket/handler/create.rs index ee58e63..5d385bb 100644 --- a/src/feature/ticket/handler/create.rs +++ b/src/feature/ticket/handler/create.rs @@ -100,7 +100,8 @@ pub async fn create_ticket_channel( CreateChannel::new(&name) .kind(ChannelType::Text) .category(ChannelId::new(SUPPORT_CATEGORY)) - .permissions(overwrites), + .permissions(overwrites) + .rate_limit_per_user(5), ) .await?; if let Some(pool) = service::pool() { diff --git a/src/feature/ticket/service/count.rs b/src/feature/ticket/service/count.rs new file mode 100644 index 0000000..da20956 --- /dev/null +++ b/src/feature/ticket/service/count.rs @@ -0,0 +1,11 @@ +use sqlx::SqlitePool; +#[rustfmt::skip] +pub async fn list_tickets(p: &SqlitePool) -> Vec<(String, String, String)> { + let _ = sqlx::query("CREATE TABLE IF NOT EXISTS tickets (channel_id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, opener_id TEXT NOT NULL)").execute(p).await; + sqlx::query_as::<_, (String, String, String)>("SELECT channel_id, guild_id, opener_id FROM tickets").fetch_all(p).await.unwrap_or_default() +} +#[rustfmt::skip] +pub async fn count_tickets(p: &SqlitePool, g: u64) -> i64 { + let _ = sqlx::query("CREATE TABLE IF NOT EXISTS tickets (channel_id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, opener_id TEXT NOT NULL)").execute(p).await; + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM tickets WHERE guild_id=?").bind(g.to_string()).fetch_one(p).await.unwrap_or(0) +} diff --git a/src/feature/ticket/service/mod.rs b/src/feature/ticket/service/mod.rs index 82942f4..68c0e6a 100644 --- a/src/feature/ticket/service/mod.rs +++ b/src/feature/ticket/service/mod.rs @@ -1,4 +1,5 @@ //! Ticket service +pub mod count; pub mod forum; use serenity::all::{ChannelId, CreateAttachment, CreateMessage, Http, MessageId}; use sqlx::SqlitePool;