diff --git a/.env.example b/.env.example index 85c903e..b54d4cd 100644 --- a/.env.example +++ b/.env.example @@ -7,5 +7,9 @@ SUPER_ADMIN_ID=your_discord_user_id DB_PATH=data/bot.db RUST_LOG=info,serenity=warn,sqlx=warn +# Backend integration (Phase 3 - blog) +BIO_API_URL=http://localhost:3001/api +DISCORD_BOT_SECRET=change-me-openssl-rand-hex-32 + # JS inspector (tools/userbot/.env) - USER token only for local view (self-bot, ToS ban risk) # DISCORD_USER_TOKEN=your_user_token_here diff --git a/Cargo.toml b/Cargo.toml index 1a4e252..aaa0e0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ async-trait = "0.1.92" clap = { version = "4.6.6", features = ["derive"] } dotenvy = "0.15.7" poise = "0.7.0" -reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls"] } +reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "multipart"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" serenity = { version = "0.12.5", features = ["gateway", "cache", "client", "model", "rustls_backend", "utils"] } diff --git a/src/core/bot.rs b/src/core/bot.rs index 2dc8168..c90b741 100644 --- a/src/core/bot.rs +++ b/src/core/bot.rs @@ -57,6 +57,10 @@ impl EventHandler for Handler { } } } + + async fn message(&self, ctx: Context, new_message: serenity::all::Message) { + crate::feature::blog::handler::on_message(ctx, new_message).await; + } } async fn handle_component(ctx: Context, interaction: ComponentInteraction) { diff --git a/src/db/migration.rs b/src/db/migration.rs index 75405d5..f3ac635 100644 --- a/src/db/migration.rs +++ b/src/db/migration.rs @@ -12,7 +12,7 @@ pub async fn run(pool: &SqlitePool) -> anyhow::Result<()> { .execute(pool) .await?; - let migrations = [("V1__initial", V1_INITIAL)]; + let migrations = [("V1__initial", V1_INITIAL), ("V2__tickets", V2_TICKETS)]; for (name, sql) in migrations { let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _migrations WHERE name = ?") .bind(name) @@ -47,3 +47,21 @@ CREATE TABLE IF NOT EXISTS user_stats ( message_count INTEGER NOT NULL DEFAULT 0 ); "; +const V2_TICKETS: &str = " +CREATE TABLE IF NOT EXISTS ticket_blacklist ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + reason TEXT NOT NULL, + PRIMARY KEY (guild_id, user_id) +); +CREATE TABLE IF NOT EXISTS ticket_claims ( + channel_id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + claimed_by TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS tickets ( + channel_id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + opener_id TEXT NOT NULL +); +"; diff --git a/src/feature/blog/command.rs b/src/feature/blog/command.rs new file mode 100644 index 0000000..47ab1f4 --- /dev/null +++ b/src/feature/blog/command.rs @@ -0,0 +1,67 @@ +//! Manual slash command `/blog_post` for testing backend integration. +//! Admin only (`MANAGE_GUILD`), uses `defer` to avoid gateway delays. + +use crate::feature::Ctx; +use poise::CreateReply; +use serenity::all::CreateEmbed; +use tracing::info; + +use super::service::{Bilingual, NewsPayload}; + +/// Converts string to bilingual (duplicates to ru/en). +fn bilingual(s: &str) -> Bilingual { + Bilingual { ru: s.to_string(), en: s.to_string() } +} + +/// `/blog_post` - manual blog creation for testing (admin only). +#[poise::command(slash_command, prefix_command, required_permissions = "MANAGE_GUILD", guild_only)] +pub async fn blog_post( + ctx: Ctx<'_>, + #[description = "Post title"] title: String, + #[description = "Markdown content"] content: String, +) -> anyhow::Result<()> { + ctx.defer().await?; + let author_id = ctx.author().id.to_string(); + let channel_id = ctx.channel_id().to_string(); + // Use message id as unique per command invocation (timestamp based). + let message_id = format!("cmd-{}-{}", author_id, chrono_timestamp()); + let tags = vec!["manual".to_string()]; + let excerpt = content.chars().take(200).collect::(); + let payload = NewsPayload { + title: bilingual(&title), + content: bilingual(&content), + excerpt: Some(bilingual(&excerpt)), + cover_image: None, + images: Vec::new(), + tags, + channel_id, + message_id, + author_id: author_id.clone(), + }; + match super::service::post_news(payload).await { + Ok(resp) => { + info!(slug = %resp.slug, id = %resp.id, "manual blog_post success"); + let embed = CreateEmbed::new() + .title("✅ Blog posted") + .description(format!("Slug: `{}`\nID: `{}`", resp.slug, resp.id)) + .color(0x0057_F387); + ctx.send(CreateReply::default().embed(embed)).await?; + } + Err(e) => { + let embed = CreateEmbed::new() + .title("❌ Blog post failed") + .description(format!("{e}")) + .color(0x00ED_4245); + ctx.send(CreateReply::default().embed(embed).ephemeral(true)).await?; + } + } + Ok(()) +} + +/// Returns current unix millis as string for idempotency key. +fn chrono_timestamp() -> String { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis().to_string()) + .unwrap_or_else(|_| "0".to_string()) +} diff --git a/src/feature/blog/handler.rs b/src/feature/blog/handler.rs new file mode 100644 index 0000000..e38c028 --- /dev/null +++ b/src/feature/blog/handler.rs @@ -0,0 +1,198 @@ +//! Message handler for blog auto-posting. +//! Watches announcements/releases/github-log and forwards staff messages to backend. + +use serenity::all::{Context, CreateMessage, Member, Message}; +use tracing::{info, warn}; + +use super::service::{Bilingual, NewsPayload}; + +/// Allowed channels for auto-post. +const ANNOUNCEMENTS: u64 = 1509503667332714566; +const RELEASES: u64 = 1509503670163869808; +const GITHUB_LOG: u64 = 1509503672197840996; + +/// Staff roles that can trigger blog posts. +const STAFF_ROLES: &[u64] = &[ + 1547203753373859901, // Moderator + 1547203757711040572, // Staff + 1547203762035236925, // Owner +]; + +/// Checks if member has staff role or is super admin. +fn is_staff(member: &Member) -> bool { + if let Ok(super_id) = std::env::var("SUPER_ADMIN_ID") { + if let Ok(parsed) = super_id.parse::() { + if member.user.id.get() == parsed { + return true; + } + } + } + member.roles.iter().any(|r| STAFF_ROLES.contains(&r.get())) +} + +/// Extracts title as first non-empty line, fallback to truncated content. +fn extract_title(content: &str) -> String { + let first = content.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("Untitled"); + let mut t = first.to_string(); + if t.chars().count() > 120 { + t = t.chars().take(120).collect(); + } + t +} + +/// Creates bilingual wrapper (duplicates to both languages). +fn bilingual(s: &str) -> Bilingual { + Bilingual { ru: s.to_string(), en: s.to_string() } +} + +/// Builds excerpt from content (first 200 chars). +fn excerpt_of(content: &str) -> String { + let trimmed = content.trim(); + if trimmed.chars().count() <= 200 { + trimmed.to_string() + } else { + let mut e: String = trimmed.chars().take(200).collect(); + e.push('…'); + e + } +} + +/// Extracts tags from hashtags and channel default. +fn extract_tags(content: &str, channel_id: serenity::all::ChannelId) -> Vec { + let mut tags: Vec = Vec::new(); + // Channel default tags. + let default = match channel_id.get() { + ANNOUNCEMENTS => Some("announcement"), + RELEASES => Some("release"), + GITHUB_LOG => Some("github"), + _ => None, + }; + if let Some(d) = default { + tags.push(d.to_string()); + } + // Hashtags: #word (alphanumeric + - _) + for word in content.split_whitespace() { + if let Some(stripped) = word.strip_prefix('#') { + let clean: String = stripped + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') + .collect(); + if !clean.is_empty() && clean.len() <= 24 { + let lower = clean.to_lowercase(); + if !tags.contains(&lower) { + tags.push(lower); + } + } + } + } + tags +} + +/// Returns true if channel is monitored for blog posts. +fn is_allowed_channel(id: u64) -> bool { + matches!(id, ANNOUNCEMENTS | RELEASES | GITHUB_LOG) +} + +/// Checks if attachment MIME is an image. +fn is_image(mime: &Option) -> bool { + if let Some(m) = mime { m.starts_with("image/") } else { false } +} + +/// Processes a single message: uploads attachments then posts news. +async fn process_message(ctx: Context, msg: Message) -> anyhow::Result<()> { + let title = extract_title(&msg.content); + let content = if msg.content.trim().is_empty() { title.clone() } else { msg.content.clone() }; + let tags = extract_tags(&msg.content, msg.channel_id); + // Upload attachments. + let mut images: Vec = Vec::new(); + let mut cover_image: Option = None; + for att in &msg.attachments { + if !is_image(&att.content_type) { + continue; + } + // Download attachment bytes via its URL. + let bytes = download_bytes(&att.url).await?; + let mime = att.content_type.clone().unwrap_or_else(|| "image/png".to_string()); + match super::service::upload_image(bytes, att.filename.clone(), mime).await { + Ok(url) => { + if cover_image.is_none() { + cover_image = Some(url.clone()); + } + images.push(url); + info!(filename = %att.filename, "image uploaded"); + } + Err(e) => warn!(error = %e, filename = %att.filename, "image upload failed"), + } + } + let payload = NewsPayload { + title: bilingual(&title), + content: bilingual(&content), + excerpt: Some(bilingual(&excerpt_of(&content))), + cover_image, + images, + tags, + channel_id: msg.channel_id.to_string(), + message_id: msg.id.to_string(), + author_id: msg.author.id.to_string(), + }; + let resp = super::service::post_news(payload).await?; + info!( + id = %resp.id, + slug = %resp.slug, + imported = ?resp.imported, + msg = ?resp.message, + channel = %msg.channel_id, + message = %msg.id, + "blog post created from discord" + ); + // Acknowledge in channel (best-effort). + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new().content(format!("✅ Blog posted: `{}`", resp.slug)), + ) + .await; + Ok(()) +} + +/// Downloads bytes from URL via reqwest. +async fn download_bytes(url: &str) -> anyhow::Result> { + let resp = reqwest::Client::new().get(url).send().await?; + if !resp.status().is_success() { + anyhow::bail!("download failed {}", resp.status()); + } + Ok(resp.bytes().await?.to_vec()) +} + +/// Entry point for `EventHandler::message`. Non-blocking via `tokio::spawn`. +pub async fn on_message(ctx: Context, msg: Message) { + if msg.author.bot { + return; + } + if msg.content.trim().is_empty() && msg.attachments.is_empty() { + return; + } + if !is_allowed_channel(msg.channel_id.get()) { + return; + } + let Some(guild_id) = msg.guild_id else { + return; + }; + let member = match guild_id.member(&ctx.http, msg.author.id).await { + Ok(m) => m, + Err(e) => { + warn!(error = %e, user = %msg.author.id, "failed to fetch member"); + return; + } + }; + if !is_staff(&member) { + return; + } + let ctx_clone = ctx.clone(); + tokio::spawn(async move { + if let Err(e) = process_message(ctx_clone, msg).await { + warn!(error = %e, "blog process_message failed"); + } + }); +} diff --git a/src/feature/blog/mod.rs b/src/feature/blog/mod.rs new file mode 100644 index 0000000..d7732da --- /dev/null +++ b/src/feature/blog/mod.rs @@ -0,0 +1,6 @@ +//! Blog feature - Discord -> backend integration (Phase 3). +//! Re-exports handler, service and slash command for manual testing. + +pub mod command; +pub mod handler; +pub mod service; diff --git a/src/feature/blog/service.rs b/src/feature/blog/service.rs new file mode 100644 index 0000000..77fc9e4 --- /dev/null +++ b/src/feature/blog/service.rs @@ -0,0 +1,135 @@ +//! Backend client for blog integration (`POST /api/discord/*`). +//! Handles image upload (multipart) and news creation (JSON) with `X-Bot-Token` auth. + +use reqwest::multipart::{Form, Part}; +use serde::{Deserialize, Serialize}; +use tracing::{error, warn}; + +/// Bilingual field (`{ru, en}`) as expected by backend. +#[derive(Debug, Clone, Serialize)] +pub struct Bilingual { + /// Russian value. + pub ru: String, + /// English value. + pub en: String, +} + +/// Payload for `POST /api/discord/news`. +#[derive(Debug, Clone, Serialize)] +pub struct NewsPayload { + /// Title bilingual. + pub title: Bilingual, + /// Markdown content bilingual. + pub content: Bilingual, + /// Optional excerpt bilingual. + #[serde(skip_serializing_if = "Option::is_none")] + pub excerpt: Option, + /// Cover image URL from `/discord/upload`. + #[serde(rename = "coverImage")] + pub cover_image: Option, + /// Additional image URLs. + pub images: Vec, + /// Tags for filtering. + pub tags: Vec, + /// Origin Discord channel id. + #[serde(rename = "channelId")] + pub channel_id: String, + /// Origin Discord message id (idempotency). + #[serde(rename = "messageId")] + pub message_id: String, + /// Author Discord user id. + #[serde(rename = "authorId")] + pub author_id: String, +} + +/// Response from `POST /api/discord/news`. +#[derive(Debug, Clone, Deserialize)] +pub struct NewsResponse { + /// Created post id. + pub id: String, + /// Generated slug. + pub slug: String, + /// Whether new post was created. + #[serde(default)] + pub imported: Option, + /// Optional message (e.g. "Already imported"). + pub message: Option, +} + +/// Response from `POST /api/discord/upload`. +#[derive(Debug, Clone, Deserialize)] +struct UploadResponse { + url: String, +} + +/// Returns backend base URL from `BIO_API_URL` or default. +fn api_base() -> String { + std::env::var("BIO_API_URL").unwrap_or_else(|_| "http://localhost:3001/api".to_string()) +} + +/// Returns bot secret from `DISCORD_BOT_SECRET` env. +fn bot_secret() -> anyhow::Result { + std::env::var("DISCORD_BOT_SECRET").map_err(|_| anyhow::anyhow!("DISCORD_BOT_SECRET not set")) +} + +/// Uploads image bytes to `POST /api/discord/upload` (multipart). +/// +/// # Arguments +/// * `data` - Raw image bytes +/// * `filename` - Original filename with extension +/// * `mime` - MIME type (e.g. `image/png`) +pub async fn upload_image(data: Vec, filename: String, mime: String) -> anyhow::Result { + let secret = bot_secret()?; + let url = format!("{}/discord/upload", api_base().trim_end_matches('/')); + let client = reqwest::Client::new(); + let mime_owned = mime.clone(); + let part = Part::bytes(data).file_name(filename.clone()).mime_str(&mime_owned)?; + let form = Form::new().part("file", part); + let resp = client.post(&url).header("X-Bot-Token", &secret).multipart(form).send().await?; + let status = resp.status(); + let body = resp.text().await?; + if status.as_u16() == 401 { + warn!("upload 401 unauthorized"); + anyhow::bail!("upload unauthorized (401) - check DISCORD_BOT_SECRET"); + } + if status.as_u16() == 400 { + error!(body = %body, "upload 400"); + anyhow::bail!("upload rejected (400): {body}"); + } + if !status.is_success() { + anyhow::bail!("upload failed {status}: {body}"); + } + let parsed: UploadResponse = serde_json::from_str(&body)?; + Ok(parsed.url) +} + +/// Posts news to `POST /api/discord/news` with `X-Bot-Token`. +/// +/// Handles `401` (bad secret) and `400` (validation) explicitly. +pub async fn post_news(payload: NewsPayload) -> anyhow::Result { + let secret = bot_secret()?; + let url = format!("{}/discord/news", api_base().trim_end_matches('/')); + let client = reqwest::Client::new(); + let resp = client + .post(&url) + .header("X-Bot-Token", &secret) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + if status.as_u16() == 401 { + warn!("post_news 401 unauthorized"); + anyhow::bail!("post_news unauthorized (401) - check DISCORD_BOT_SECRET"); + } + if status.as_u16() == 400 { + error!(body = %body, "post_news 400"); + anyhow::bail!("post_news validation failed (400): {body}"); + } + if !status.is_success() { + anyhow::bail!("post_news failed {status}: {body}"); + } + let parsed: NewsResponse = serde_json::from_str(&body)?; + Ok(parsed) +} diff --git a/src/feature/mod.rs b/src/feature/mod.rs index 7022f68..6c95f24 100644 --- a/src/feature/mod.rs +++ b/src/feature/mod.rs @@ -1,4 +1,5 @@ pub mod admin; +pub mod blog; pub mod help; pub mod info; pub mod lang; @@ -23,6 +24,8 @@ pub async fn build_framework( cfg: BotConfig, pool: SqlitePool, ) -> anyhow::Result> { + // Share pool with component handlers (ticket transcript/blacklist/claim). + crate::feature::ticket::service::set_pool(pool.clone()); let opts = poise::FrameworkOptions { commands: vec![ help::command::help(), @@ -31,9 +34,12 @@ pub async fn build_framework( admin::command::shutdown(), verify::command::verify_setup(), ticket::command::ticket_panel(), + ticket::command::ticket_blacklist_add(), + ticket::command::ticket_blacklist_remove(), moderation::command::clear(), moderation::command::slowmode(), moderation::command::warn(), + blog::command::blog_post(), ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some(cfg.prefix.clone().into()), diff --git a/src/feature/ticket/command.rs b/src/feature/ticket/command.rs index 7c42034..0d8ba1f 100644 --- a/src/feature/ticket/command.rs +++ b/src/feature/ticket/command.rs @@ -1,9 +1,10 @@ use crate::feature::Ctx; +use crate::feature::ticket::service; use crate::util::i18n::{self, Lang}; use poise::CreateReply; use serenity::all::{CreateActionRow, CreateButton, CreateEmbed}; -/// /ticket-panel - panel with buttons in #open-ticket. +/// Panel with ticket open buttons. #[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 { @@ -23,3 +24,61 @@ pub async fn ticket_panel(ctx: Ctx<'_>) -> anyhow::Result<()> { ctx.send(CreateReply::default().embed(embed).components(vec![row])).await?; Ok(()) } + +/// Blacklists user from creating tickets. +#[poise::command( + slash_command, + prefix_command, + guild_only, + required_permissions = "MANAGE_CHANNELS" +)] +pub async fn ticket_blacklist_add( + ctx: Ctx<'_>, + #[description = "User to blacklist"] user: serenity::all::User, + #[description = "Reason"] reason: String, +) -> anyhow::Result<()> { + let Some(guild_id) = ctx.guild_id() else { + return Ok(()); + }; + service::add_blacklist(&ctx.data().pool, guild_id.get(), user.id.get(), &reason).await?; + ctx.send( + CreateReply::default() + .embed( + CreateEmbed::new() + .description(format!("Blacklisted <@{}>: {reason}", user.id)) + .color(0x00ED_4245), + ) + .ephemeral(true), + ) + .await?; + Ok(()) +} + +/// Removes user from ticket blacklist. +#[poise::command( + slash_command, + prefix_command, + guild_only, + required_permissions = "MANAGE_CHANNELS" +)] +pub async fn ticket_blacklist_remove( + ctx: Ctx<'_>, + #[description = "User to unblacklist"] user: serenity::all::User, +) -> anyhow::Result<()> { + let Some(guild_id) = ctx.guild_id() else { + return Ok(()); + }; + let removed = service::remove_blacklist(&ctx.data().pool, guild_id.get(), user.id.get()).await; + let msg = if removed { + format!("Removed blacklist for <@{}>", user.id) + } else { + format!("<@{}> was not blacklisted", user.id) + }; + ctx.send( + CreateReply::default() + .embed(CreateEmbed::new().description(msg).color(0x0057_F387)) + .ephemeral(true), + ) + .await?; + Ok(()) +} diff --git a/src/feature/ticket/handler.rs b/src/feature/ticket/handler.rs index 771adc4..6e98a8d 100644 --- a/src/feature/ticket/handler.rs +++ b/src/feature/ticket/handler.rs @@ -1,140 +1,171 @@ -//! Ticket handler - creates private channels on button, handles close. -//! English Javadoc style, no `sleep` without `// SAFETY:`. - +//! Ticket handler - creates private channels, claim and close with transcript. +use super::service; use serenity::all::{ - ChannelType, ComponentInteraction, Context, CreateChannel, CreateEmbed, + ChannelId, ChannelType, ComponentInteraction, Context, CreateChannel, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage, PermissionOverwrite, PermissionOverwriteType, Permissions, }; use tracing::{info, warn}; - -const SUPPORT_CATEGORY: u64 = 1547213779224363008; // OPEN TICKETS (bottom, staff-only) +const SUPPORT_CATEGORY: u64 = 1547213779224363008; const TICKET_LOGS: u64 = 1509503717127356446; - -/// Handles `ticket_general` / `ticket_bug` and `ticket_close`. +/// Handles ticket buttons. pub async fn on_button(ctx: Context, interaction: ComponentInteraction) { - let guild_id = match interaction.guild_id { - Some(g) => g, - None => return, - }; - let user = interaction.user.clone(); - let custom = interaction.data.custom_id.clone(); - - if custom == "ticket_general" || custom == "ticket_bug" { - let category = if custom == "ticket_bug" { "bug" } else { "general" }; - // Check existing open ticket for user (simple scan) - 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 { - let _ = interaction - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content("You already have an open ticket.") - .ephemeral(true), - ), - ) - .await; - return; - } - } - // No defer needed - channel creation is fast (<1s), respond directly after - let name = format!("ticket-{}-{}", category, user.name.to_lowercase().replace(' ', "-")); - let overwrites = build_ticket_overwrites(user.id.get()); - let channel = match guild_id - .create_channel( - &ctx.http, - CreateChannel::new(&name) - .kind(ChannelType::Text) - .category(serenity::all::ChannelId::new(SUPPORT_CATEGORY)) - .permissions(overwrites), - ) - .await - { - Ok(ch) => ch, - Err(e) => { - warn!(error = %e, "failed to create ticket"); - let _ = interaction - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content("Failed to create ticket. Check bot permissions.") - .ephemeral(true), - ), - ) - .await; - return; - } - }; - // Welcome embed with close button - let embed = CreateEmbed::new() - .title(format!("Ticket - {}", category)) - .description(format!( - "Hello <@{}>, staff will be with you shortly.\n\nCategory: **{}**\n\nClick **Close** to close.", - user.id, category - )) - .color(0x0058_65F2); - let row = serenity::all::CreateActionRow::Buttons(vec![ - serenity::all::CreateButton::new("ticket_close") - .label("Close") - .style(serenity::all::ButtonStyle::Danger) - .emoji('🔒'), - ]); - let _ = channel - .send_message(&ctx.http, CreateMessage::new().embed(embed).components(vec![row])) - .await; - let _ = interaction - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content(format!("Ticket created: <#{}>", channel.id)) - .ephemeral(true), - ), - ) - .await; - info!(user = %user.name, channel = %channel.id, "ticket created"); - return; - } - - if custom == "ticket_close" { - let channel_id = interaction.channel_id; - let _ = interaction - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content("Closing ticket... transcript will be saved.") - .ephemeral(true), - ), - ) - .await; - // Log to ticket-logs - let log_ch = serenity::all::ChannelId::new(TICKET_LOGS); - let _ = log_ch - .send_message( - &ctx.http, - CreateMessage::new().embed( - CreateEmbed::new() - .title("Ticket closed") - .description(format!("Closed by <@{}> in <#{}>", user.id, channel_id)) - .color(0x00ED_4245), - ), - ) - .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 - let _ = channel_id.delete(&ctx.http).await; - info!(channel = %channel_id, "ticket closed"); + match interaction.data.custom_id.as_str() { + "ticket_general" | "ticket_bug" => handle_create(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), + ), + ) + .await; +} +async fn handle_create(ctx: Context, interaction: ComponentInteraction) { + 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 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; + return; + } + } + let name = format!("ticket-{}-{}", category, user.name.to_lowercase().replace(' ', "-")); + let overwrites = build_ticket_overwrites(user.id.get()); + let channel = match guild_id + .create_channel( + &ctx.http, + CreateChannel::new(&name) + .kind(ChannelType::Text) + .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; + } + }; + 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}")) + .description(format!( + "Hello <@{}>, staff will be with you shortly.\n\nCategory: **{category}**", + user.id + )) + .color(0x0058_65F2); + 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 _ = 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"); +} +async fn handle_claim(ctx: Context, interaction: ComponentInteraction) { + let Some(guild_id) = interaction.guild_id else { return }; + let channel_id = interaction.channel_id; + let claimer = interaction.user.clone(); + if let Some(pool) = service::pool() { + if let Some(existing) = service::get_claim(pool, channel_id.get()).await { + ephemeral(&ctx, &interaction, format!("Already claimed by <@{existing}>")).await; + return; + } + if !service::try_claim(pool, channel_id.get(), guild_id.get(), claimer.id.get()).await { + ephemeral(&ctx, &interaction, "Claim failed, already claimed.".into()).await; + return; + } + } + ephemeral(&ctx, &interaction, format!("Ticket claimed by <@{}>", claimer.id)).await; + let _ = channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed( + CreateEmbed::new() + .title("Ticket claimed") + .description(format!("Claimed by <@{}>", claimer.id)) + .footer(serenity::all::CreateEmbedFooter::new(format!( + "Claimed by {}", + claimer.name + ))) + .color(0x0057_F387), + ), + ) + .await; + let _ = ChannelId::new(TICKET_LOGS) + .send_message( + &ctx.http, + CreateMessage::new().embed( + CreateEmbed::new() + .title("Ticket claimed") + .description(format!("<@{}> claimed ticket <#{}>", claimer.id, channel_id)) + .color(0x0057_F387), + ), + ) + .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(); + ephemeral(&ctx, &interaction, "Closing ticket... transcript will be saved.".into()).await; + let transcript = service::fetch_transcript(&ctx.http, channel_id).await; + let haste_url = service::upload_hastebin(&transcript).await; + service::deliver_transcript( + &ctx.http, + channel_id, + user.id.get(), + transcript.clone(), + haste_url.clone(), + ) + .await; + if let Some(pool) = service::pool() { + 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 + 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 + | Permissions::SEND_MESSAGES + | Permissions::READ_MESSAGE_HISTORY, + deny: Permissions::empty(), + kind: PermissionOverwriteType::Role(id), } } - -/// Builds permission overwrites for ticket channel. -/// - @everyone deny VIEW, user + Staff/Owner/Moderator allow. fn build_ticket_overwrites(user_id: u64) -> Vec { let everyone = serenity::all::RoleId::new(1509503154708811837); let staff = serenity::all::RoleId::new(1547203757711040572); @@ -154,26 +185,8 @@ fn build_ticket_overwrites(user_id: u64) -> Vec { deny: Permissions::empty(), kind: PermissionOverwriteType::Member(user), }, - PermissionOverwrite { - allow: Permissions::VIEW_CHANNEL - | Permissions::SEND_MESSAGES - | Permissions::READ_MESSAGE_HISTORY, - deny: Permissions::empty(), - kind: PermissionOverwriteType::Role(staff), - }, - PermissionOverwrite { - allow: Permissions::VIEW_CHANNEL - | Permissions::SEND_MESSAGES - | Permissions::READ_MESSAGE_HISTORY, - deny: Permissions::empty(), - kind: PermissionOverwriteType::Role(owner), - }, - PermissionOverwrite { - allow: Permissions::VIEW_CHANNEL - | Permissions::SEND_MESSAGES - | Permissions::READ_MESSAGE_HISTORY, - deny: Permissions::empty(), - kind: PermissionOverwriteType::Role(moderator), - }, + allow_view(staff), + allow_view(owner), + allow_view(moderator), ] } diff --git a/src/feature/ticket/mod.rs b/src/feature/ticket/mod.rs index a2f4939..c6da5e8 100644 --- a/src/feature/ticket/mod.rs +++ b/src/feature/ticket/mod.rs @@ -1,2 +1,3 @@ pub mod command; pub mod handler; +pub mod service; diff --git a/src/feature/ticket/service.rs b/src/feature/ticket/service.rs new file mode 100644 index 0000000..73510ca --- /dev/null +++ b/src/feature/ticket/service.rs @@ -0,0 +1,127 @@ +//! Ticket service +use serenity::all::{ChannelId, CreateAttachment, CreateMessage, Http, MessageId}; +use sqlx::SqlitePool; +use std::sync::OnceLock; +use tracing::{info, warn}; +static POOL: OnceLock = OnceLock::new(); +pub fn set_pool(p: SqlitePool) { + let _ = POOL.set(p); +} +pub fn pool() -> Option<&'static SqlitePool> { + POOL.get() +} +#[rustfmt::skip] +async fn ensure_tables(p: &SqlitePool) { + let _ = sqlx::query("CREATE TABLE IF NOT EXISTS ticket_blacklist (guild_id TEXT NOT NULL, user_id TEXT NOT NULL, reason TEXT NOT NULL, PRIMARY KEY (guild_id, user_id))").execute(p).await; + let _ = sqlx::query("CREATE TABLE IF NOT EXISTS ticket_claims (channel_id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, claimed_by TEXT NOT NULL)").execute(p).await; + 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; +} +#[rustfmt::skip] +pub async fn is_blacklisted(p: &SqlitePool, g: u64, u: u64) -> Option { + ensure_tables(p).await; + let row: Option<(String,)> = sqlx::query_as("SELECT reason FROM ticket_blacklist WHERE guild_id=? AND user_id=?").bind(g.to_string()).bind(u.to_string()).fetch_optional(p).await.unwrap_or(None); + row.map(|r| r.0) +} +#[rustfmt::skip] +pub async fn add_blacklist(p: &SqlitePool, g: u64, u: u64, r: &str) -> anyhow::Result<()> { + ensure_tables(p).await; + sqlx::query("INSERT OR REPLACE INTO ticket_blacklist(guild_id,user_id,reason) VALUES(?,?,?)").bind(g.to_string()).bind(u.to_string()).bind(r).execute(p).await?; + info!(guild=g, user=u, "blacklist added"); + Ok(()) +} +#[rustfmt::skip] +pub async fn remove_blacklist(p: &SqlitePool, g: u64, u: u64) -> bool { + ensure_tables(p).await; + let res = sqlx::query("DELETE FROM ticket_blacklist WHERE guild_id=? AND user_id=?").bind(g.to_string()).bind(u.to_string()).execute(p).await; + matches!(res, Ok(r) if r.rows_affected()>0) +} +#[rustfmt::skip] +pub async fn record_ticket(p: &SqlitePool, c: u64, g: u64, o: u64) { + ensure_tables(p).await; + let _ = sqlx::query("INSERT OR REPLACE INTO tickets(channel_id,guild_id,opener_id) VALUES(?,?,?)").bind(c.to_string()).bind(g.to_string()).bind(o.to_string()).execute(p).await; +} +#[rustfmt::skip] +pub async fn get_opener(p: &SqlitePool, c: u64) -> Option { + ensure_tables(p).await; + let row: Option<(String,)> = sqlx::query_as("SELECT opener_id FROM tickets WHERE channel_id=?").bind(c.to_string()).fetch_optional(p).await.unwrap_or(None); + row.and_then(|(s,)| s.parse().ok()) +} +#[rustfmt::skip] +pub async fn get_claim(p: &SqlitePool, c: u64) -> Option { + ensure_tables(p).await; + let row: Option<(String,)> = sqlx::query_as("SELECT claimed_by FROM ticket_claims WHERE channel_id=?").bind(c.to_string()).fetch_optional(p).await.unwrap_or(None); + row.and_then(|(s,)| s.parse().ok()) +} +#[rustfmt::skip] +pub async fn try_claim(p: &SqlitePool, c: u64, g: u64, cl: u64) -> bool { + ensure_tables(p).await; + if get_claim(p,c).await.is_some() { return false; } + let res = sqlx::query("INSERT OR IGNORE INTO ticket_claims(channel_id,guild_id,claimed_by) VALUES(?,?,?)").bind(c.to_string()).bind(g.to_string()).bind(cl.to_string()).execute(p).await; + matches!(res, Ok(r) if r.rows_affected()>0) +} +#[rustfmt::skip] +pub async fn cleanup_ticket(p: &SqlitePool, c: u64) { + let _ = sqlx::query("DELETE FROM tickets WHERE channel_id=?").bind(c.to_string()).execute(p).await; + let _ = sqlx::query("DELETE FROM ticket_claims WHERE channel_id=?").bind(c.to_string()).execute(p).await; +} +#[rustfmt::skip] +pub async fn fetch_transcript(h: &Http, ch: ChannelId) -> String { + let mut all=Vec::new(); + let mut before:Option=None; + loop { + let mut b=serenity::all::GetMessages::new().limit(100); + if let Some(id)=before { b=b.before(id); } + let msgs=match ch.messages(h,b).await { Ok(m)=>m, Err(e)=>{warn!(error=%e,"fetch_transcript failed"); break;}}; + if msgs.is_empty(){break;} + before=msgs.last().map(|m| m.id); + let len=msgs.len(); + all.extend(msgs); + if len<100{break;} + } + all.sort_by_key(|m| m.id); + let mut out=String::new(); + for m in all { + out.push_str(&format!("{} ({}):\n",m.author.name,m.author.id)); + if !m.content.is_empty(){out.push_str(&m.content); out.push('\n');} + for e in &m.embeds { + if let Some(t)=&e.title{out.push_str(&format!("[Embed title: {}]\n",t));} + if let Some(d)=&e.description{out.push_str(&format!("[Embed: {}]\n",d));} + } + out.push_str("---\n"); + } + if out.is_empty(){out.push_str("No messages in ticket.\n");} + out +} +#[rustfmt::skip] +pub async fn upload_hastebin(t: &str) -> Option { + let c=reqwest::Client::new(); + let b=serde_json::json!({"document":t}); + let r=c.post("https://hastebin.com/documents").json(&b).send().await.ok()?; + if !r.status().is_success(){return None;} + let v:serde_json::Value=r.json().await.ok()?; + let k=v.get("key")?.as_str()?; + Some(format!("https://hastebin.com/{k}")) +} +#[rustfmt::skip] +pub async fn deliver_transcript(h: &Http, ch: ChannelId, closer: u64, tr: String, url: Option) { + const LOG: u64=1509503717127356446; + let lc=ChannelId::new(LOG); + if let Some(u)=url.clone(){ + let _=lc.send_message(h,CreateMessage::new().content(format!("Ticket <#{ch}> closed by <@{closer}>\nTranscript: {u}"))).await; + } else { + let a=CreateAttachment::bytes(tr.clone().into_bytes(),"transcript.txt"); + let _=lc.send_message(h,CreateMessage::new().content(format!("Ticket <#{ch}> closed by <@{closer}>")).add_file(a)).await; + } + if let Some(p)=pool(){ + if let Some(o)=get_opener(p,ch.get()).await{ + if let Ok(dm)=serenity::all::UserId::new(o).create_dm_channel(h).await{ + if let Some(u)=url{ + let _=dm.send_message(h,CreateMessage::new().content(format!("Your ticket <#{ch}> was closed. Transcript: {u}"))).await; + } else { + let a=CreateAttachment::bytes(tr.into_bytes(),"transcript.txt"); + let _=dm.send_message(h,CreateMessage::new().content(format!("Your ticket <#{ch}> was closed.")).add_file(a)).await; + } + } + } + } +} diff --git a/src/feature/voice/handler.rs b/src/feature/voice/handler.rs index fa8ed3c..f9432c5 100644 --- a/src/feature/voice/handler.rs +++ b/src/feature/voice/handler.rs @@ -39,14 +39,13 @@ pub async fn on_voice_state_update(ctx: Context, old: Option, new: V if let Some(old_state) = old { if let Some(old_channel) = old_state.channel_id { if old_channel != HUB && old_channel.get() != 1509503696566882324 { - // check if channel empty - if let Ok(channel) = old_channel.to_channel(&ctx.http).await { - if let serenity::all::Channel::Guild(g) = channel { - if let Ok(members) = g.members(&ctx.cache) { - if members.is_empty() { - let _ = g.delete(&ctx.http).await; - info!(channel = %old_channel, "temp voice deleted (empty)"); - } + if let Ok(serenity::all::Channel::Guild(g)) = + old_channel.to_channel(&ctx.http).await + { + if let Ok(members) = g.members(&ctx.cache) { + if members.is_empty() { + let _ = g.delete(&ctx.http).await; + info!(channel = %old_channel, "temp voice deleted (empty)"); } } }