feat: tickets 2.5 transcript/hastebin claim/blacklist + blog Discord->backend POST /api/discord/news

This commit is contained in:
loki5512344 2026-09-09 14:32:57 +02:00
parent a2b2c41654
commit 1d49d81fad
Signed by: boba
GPG key ID: 253067914055423B
14 changed files with 797 additions and 160 deletions

View file

@ -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) {

View file

@ -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
);
";

View file

@ -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::<String>();
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())
}

198
src/feature/blog/handler.rs Normal file
View file

@ -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::<u64>() {
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<String> {
let mut tags: Vec<String> = 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<String>) -> 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<String> = Vec::new();
let mut cover_image: Option<String> = 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<Vec<u8>> {
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");
}
});
}

6
src/feature/blog/mod.rs Normal file
View file

@ -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;

135
src/feature/blog/service.rs Normal file
View file

@ -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<Bilingual>,
/// Cover image URL from `/discord/upload`.
#[serde(rename = "coverImage")]
pub cover_image: Option<String>,
/// Additional image URLs.
pub images: Vec<String>,
/// Tags for filtering.
pub tags: Vec<String>,
/// 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<bool>,
/// Optional message (e.g. "Already imported").
pub message: Option<String>,
}
/// 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<String> {
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<u8>, filename: String, mime: String) -> anyhow::Result<String> {
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<NewsResponse> {
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)
}

View file

@ -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<Framework<Data, anyhow::Error>> {
// 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()),

View file

@ -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(())
}

View file

@ -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<PermissionOverwrite> {
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<PermissionOverwrite> {
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),
]
}

View file

@ -1,2 +1,3 @@
pub mod command;
pub mod handler;
pub mod service;

View file

@ -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<SqlitePool> = 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<String> {
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<u64> {
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<u64> {
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<MessageId>=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<String> {
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<String>) {
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;
}
}
}
}
}

View file

@ -39,14 +39,13 @@ pub async fn on_voice_state_update(ctx: Context, old: Option<VoiceState>, 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)");
}
}
}