feat: tickets 2.5 transcript/hastebin claim/blacklist + blog Discord->backend POST /api/discord/news
This commit is contained in:
parent
a2b2c41654
commit
1d49d81fad
14 changed files with 797 additions and 160 deletions
|
|
@ -7,5 +7,9 @@ SUPER_ADMIN_ID=your_discord_user_id
|
||||||
DB_PATH=data/bot.db
|
DB_PATH=data/bot.db
|
||||||
RUST_LOG=info,serenity=warn,sqlx=warn
|
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)
|
# JS inspector (tools/userbot/.env) - USER token only for local view (self-bot, ToS ban risk)
|
||||||
# DISCORD_USER_TOKEN=your_user_token_here
|
# DISCORD_USER_TOKEN=your_user_token_here
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ async-trait = "0.1.92"
|
||||||
clap = { version = "4.6.6", features = ["derive"] }
|
clap = { version = "4.6.6", features = ["derive"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
poise = "0.7.0"
|
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 = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
serenity = { version = "0.12.5", features = ["gateway", "cache", "client", "model", "rustls_backend", "utils"] }
|
serenity = { version = "0.12.5", features = ["gateway", "cache", "client", "model", "rustls_backend", "utils"] }
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
async fn handle_component(ctx: Context, interaction: ComponentInteraction) {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pub async fn run(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let migrations = [("V1__initial", V1_INITIAL)];
|
let migrations = [("V1__initial", V1_INITIAL), ("V2__tickets", V2_TICKETS)];
|
||||||
for (name, sql) in migrations {
|
for (name, sql) in migrations {
|
||||||
let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _migrations WHERE name = ?")
|
let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _migrations WHERE name = ?")
|
||||||
.bind(name)
|
.bind(name)
|
||||||
|
|
@ -47,3 +47,21 @@ CREATE TABLE IF NOT EXISTS user_stats (
|
||||||
message_count INTEGER NOT NULL DEFAULT 0
|
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
|
||||||
|
);
|
||||||
|
";
|
||||||
|
|
|
||||||
67
src/feature/blog/command.rs
Normal file
67
src/feature/blog/command.rs
Normal 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
198
src/feature/blog/handler.rs
Normal 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
6
src/feature/blog/mod.rs
Normal 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
135
src/feature/blog/service.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod admin;
|
pub mod admin;
|
||||||
|
pub mod blog;
|
||||||
pub mod help;
|
pub mod help;
|
||||||
pub mod info;
|
pub mod info;
|
||||||
pub mod lang;
|
pub mod lang;
|
||||||
|
|
@ -23,6 +24,8 @@ pub async fn build_framework(
|
||||||
cfg: BotConfig,
|
cfg: BotConfig,
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
) -> anyhow::Result<Framework<Data, anyhow::Error>> {
|
) -> 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 {
|
let opts = poise::FrameworkOptions {
|
||||||
commands: vec![
|
commands: vec![
|
||||||
help::command::help(),
|
help::command::help(),
|
||||||
|
|
@ -31,9 +34,12 @@ pub async fn build_framework(
|
||||||
admin::command::shutdown(),
|
admin::command::shutdown(),
|
||||||
verify::command::verify_setup(),
|
verify::command::verify_setup(),
|
||||||
ticket::command::ticket_panel(),
|
ticket::command::ticket_panel(),
|
||||||
|
ticket::command::ticket_blacklist_add(),
|
||||||
|
ticket::command::ticket_blacklist_remove(),
|
||||||
moderation::command::clear(),
|
moderation::command::clear(),
|
||||||
moderation::command::slowmode(),
|
moderation::command::slowmode(),
|
||||||
moderation::command::warn(),
|
moderation::command::warn(),
|
||||||
|
blog::command::blog_post(),
|
||||||
],
|
],
|
||||||
prefix_options: poise::PrefixFrameworkOptions {
|
prefix_options: poise::PrefixFrameworkOptions {
|
||||||
prefix: Some(cfg.prefix.clone().into()),
|
prefix: Some(cfg.prefix.clone().into()),
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
use crate::feature::Ctx;
|
use crate::feature::Ctx;
|
||||||
|
use crate::feature::ticket::service;
|
||||||
use crate::util::i18n::{self, Lang};
|
use crate::util::i18n::{self, Lang};
|
||||||
use poise::CreateReply;
|
use poise::CreateReply;
|
||||||
use serenity::all::{CreateActionRow, CreateButton, CreateEmbed};
|
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)]
|
#[poise::command(slash_command, prefix_command, guild_only)]
|
||||||
pub async fn ticket_panel(ctx: Ctx<'_>) -> anyhow::Result<()> {
|
pub async fn ticket_panel(ctx: Ctx<'_>) -> anyhow::Result<()> {
|
||||||
let lang = if let Some(member) = ctx.author_member().await {
|
let 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?;
|
ctx.send(CreateReply::default().embed(embed).components(vec![row])).await?;
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,140 +1,171 @@
|
||||||
//! Ticket handler - creates private channels on button, handles close.
|
//! Ticket handler - creates private channels, claim and close with transcript.
|
||||||
//! English Javadoc style, no `sleep` without `// SAFETY:`.
|
use super::service;
|
||||||
|
|
||||||
use serenity::all::{
|
use serenity::all::{
|
||||||
ChannelType, ComponentInteraction, Context, CreateChannel, CreateEmbed,
|
ChannelId, ChannelType, ComponentInteraction, Context, CreateChannel, CreateEmbed,
|
||||||
CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage,
|
CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage,
|
||||||
PermissionOverwrite, PermissionOverwriteType, Permissions,
|
PermissionOverwrite, PermissionOverwriteType, Permissions,
|
||||||
};
|
};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
const SUPPORT_CATEGORY: u64 = 1547213779224363008;
|
||||||
const SUPPORT_CATEGORY: u64 = 1547213779224363008; // OPEN TICKETS (bottom, staff-only)
|
|
||||||
const TICKET_LOGS: u64 = 1509503717127356446;
|
const TICKET_LOGS: u64 = 1509503717127356446;
|
||||||
|
/// Handles ticket buttons.
|
||||||
/// Handles `ticket_general` / `ticket_bug` and `ticket_close`.
|
|
||||||
pub async fn on_button(ctx: Context, interaction: ComponentInteraction) {
|
pub async fn on_button(ctx: Context, interaction: ComponentInteraction) {
|
||||||
let guild_id = match interaction.guild_id {
|
match interaction.data.custom_id.as_str() {
|
||||||
Some(g) => g,
|
"ticket_general" | "ticket_bug" => handle_create(ctx, interaction).await,
|
||||||
None => return,
|
"ticket_claim" => handle_claim(ctx, interaction).await,
|
||||||
};
|
"ticket_close" => handle_close(ctx, interaction).await,
|
||||||
let user = interaction.user.clone();
|
_ => {}
|
||||||
let custom = interaction.data.custom_id.clone();
|
}
|
||||||
|
}
|
||||||
if custom == "ticket_general" || custom == "ticket_bug" {
|
async fn ephemeral(ctx: &Context, i: &ComponentInteraction, content: String) {
|
||||||
let category = if custom == "ticket_bug" { "bug" } else { "general" };
|
let _ = i
|
||||||
// Check existing open ticket for user (simple scan)
|
.create_response(
|
||||||
if let Ok(channels) = guild_id.channels(&ctx.http).await {
|
&ctx.http,
|
||||||
let exists = channels.values().any(|c| {
|
CreateInteractionResponse::Message(
|
||||||
c.name.starts_with(&format!("ticket-{}", user.name.to_lowercase()))
|
CreateInteractionResponseMessage::new().content(content).ephemeral(true),
|
||||||
|| c.name.starts_with(&format!("ticket-{}", user.id))
|
),
|
||||||
});
|
)
|
||||||
if exists {
|
.await;
|
||||||
let _ = interaction
|
}
|
||||||
.create_response(
|
async fn handle_create(ctx: Context, interaction: ComponentInteraction) {
|
||||||
&ctx.http,
|
let Some(guild_id) = interaction.guild_id else { return };
|
||||||
CreateInteractionResponse::Message(
|
let user = interaction.user.clone();
|
||||||
CreateInteractionResponseMessage::new()
|
let custom = interaction.data.custom_id.clone();
|
||||||
.content("You already have an open ticket.")
|
if let Some(pool) = service::pool() {
|
||||||
.ephemeral(true),
|
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;
|
||||||
.await;
|
}
|
||||||
return;
|
}
|
||||||
}
|
let category = if custom == "ticket_bug" { "bug" } else { "general" };
|
||||||
}
|
if let Ok(channels) = guild_id.channels(&ctx.http).await {
|
||||||
// No defer needed - channel creation is fast (<1s), respond directly after
|
let exists = channels.values().any(|c| {
|
||||||
let name = format!("ticket-{}-{}", category, user.name.to_lowercase().replace(' ', "-"));
|
c.name.starts_with(&format!("ticket-{}", user.name.to_lowercase()))
|
||||||
let overwrites = build_ticket_overwrites(user.id.get());
|
|| c.name.starts_with(&format!("ticket-{}", user.id))
|
||||||
let channel = match guild_id
|
});
|
||||||
.create_channel(
|
if exists {
|
||||||
&ctx.http,
|
ephemeral(&ctx, &interaction, "You already have an open ticket.".into()).await;
|
||||||
CreateChannel::new(&name)
|
return;
|
||||||
.kind(ChannelType::Text)
|
}
|
||||||
.category(serenity::all::ChannelId::new(SUPPORT_CATEGORY))
|
}
|
||||||
.permissions(overwrites),
|
let name = format!("ticket-{}-{}", category, user.name.to_lowercase().replace(' ', "-"));
|
||||||
)
|
let overwrites = build_ticket_overwrites(user.id.get());
|
||||||
.await
|
let channel = match guild_id
|
||||||
{
|
.create_channel(
|
||||||
Ok(ch) => ch,
|
&ctx.http,
|
||||||
Err(e) => {
|
CreateChannel::new(&name)
|
||||||
warn!(error = %e, "failed to create ticket");
|
.kind(ChannelType::Text)
|
||||||
let _ = interaction
|
.category(ChannelId::new(SUPPORT_CATEGORY))
|
||||||
.create_response(
|
.permissions(overwrites),
|
||||||
&ctx.http,
|
)
|
||||||
CreateInteractionResponse::Message(
|
.await
|
||||||
CreateInteractionResponseMessage::new()
|
{
|
||||||
.content("Failed to create ticket. Check bot permissions.")
|
Ok(ch) => ch,
|
||||||
.ephemeral(true),
|
Err(e) => {
|
||||||
),
|
warn!(error=%e, "failed to create ticket");
|
||||||
)
|
ephemeral(&ctx, &interaction, "Failed to create ticket.".into()).await;
|
||||||
.await;
|
return;
|
||||||
return;
|
}
|
||||||
}
|
};
|
||||||
};
|
if let Some(pool) = service::pool() {
|
||||||
// Welcome embed with close button
|
service::record_ticket(pool, channel.id.get(), guild_id.get(), user.id.get()).await;
|
||||||
let embed = CreateEmbed::new()
|
}
|
||||||
.title(format!("Ticket - {}", category))
|
let embed = CreateEmbed::new()
|
||||||
.description(format!(
|
.title(format!("Ticket - {category}"))
|
||||||
"Hello <@{}>, staff will be with you shortly.\n\nCategory: **{}**\n\nClick **Close** to close.",
|
.description(format!(
|
||||||
user.id, category
|
"Hello <@{}>, staff will be with you shortly.\n\nCategory: **{category}**",
|
||||||
))
|
user.id
|
||||||
.color(0x0058_65F2);
|
))
|
||||||
let row = serenity::all::CreateActionRow::Buttons(vec![
|
.color(0x0058_65F2);
|
||||||
serenity::all::CreateButton::new("ticket_close")
|
let row = serenity::all::CreateActionRow::Buttons(vec![
|
||||||
.label("Close")
|
serenity::all::CreateButton::new("ticket_claim")
|
||||||
.style(serenity::all::ButtonStyle::Danger)
|
.label("Claim")
|
||||||
.emoji('🔒'),
|
.style(serenity::all::ButtonStyle::Primary)
|
||||||
]);
|
.emoji('🙋'),
|
||||||
let _ = channel
|
serenity::all::CreateButton::new("ticket_close")
|
||||||
.send_message(&ctx.http, CreateMessage::new().embed(embed).components(vec![row]))
|
.label("Close")
|
||||||
.await;
|
.style(serenity::all::ButtonStyle::Danger)
|
||||||
let _ = interaction
|
.emoji('🔒'),
|
||||||
.create_response(
|
]);
|
||||||
&ctx.http,
|
let _ = channel
|
||||||
CreateInteractionResponse::Message(
|
.send_message(&ctx.http, CreateMessage::new().embed(embed).components(vec![row]))
|
||||||
CreateInteractionResponseMessage::new()
|
.await;
|
||||||
.content(format!("Ticket created: <#{}>", channel.id))
|
ephemeral(&ctx, &interaction, format!("Ticket created: <#{}>", channel.id)).await;
|
||||||
.ephemeral(true),
|
info!(user=%user.name, channel=%channel.id, "ticket created");
|
||||||
),
|
}
|
||||||
)
|
async fn handle_claim(ctx: Context, interaction: ComponentInteraction) {
|
||||||
.await;
|
let Some(guild_id) = interaction.guild_id else { return };
|
||||||
info!(user = %user.name, channel = %channel.id, "ticket created");
|
let channel_id = interaction.channel_id;
|
||||||
return;
|
let claimer = interaction.user.clone();
|
||||||
}
|
if let Some(pool) = service::pool() {
|
||||||
|
if let Some(existing) = service::get_claim(pool, channel_id.get()).await {
|
||||||
if custom == "ticket_close" {
|
ephemeral(&ctx, &interaction, format!("Already claimed by <@{existing}>")).await;
|
||||||
let channel_id = interaction.channel_id;
|
return;
|
||||||
let _ = interaction
|
}
|
||||||
.create_response(
|
if !service::try_claim(pool, channel_id.get(), guild_id.get(), claimer.id.get()).await {
|
||||||
&ctx.http,
|
ephemeral(&ctx, &interaction, "Claim failed, already claimed.".into()).await;
|
||||||
CreateInteractionResponse::Message(
|
return;
|
||||||
CreateInteractionResponseMessage::new()
|
}
|
||||||
.content("Closing ticket... transcript will be saved.")
|
}
|
||||||
.ephemeral(true),
|
ephemeral(&ctx, &interaction, format!("Ticket claimed by <@{}>", claimer.id)).await;
|
||||||
),
|
let _ = channel_id
|
||||||
)
|
.send_message(
|
||||||
.await;
|
&ctx.http,
|
||||||
// Log to ticket-logs
|
CreateMessage::new().embed(
|
||||||
let log_ch = serenity::all::ChannelId::new(TICKET_LOGS);
|
CreateEmbed::new()
|
||||||
let _ = log_ch
|
.title("Ticket claimed")
|
||||||
.send_message(
|
.description(format!("Claimed by <@{}>", claimer.id))
|
||||||
&ctx.http,
|
.footer(serenity::all::CreateEmbedFooter::new(format!(
|
||||||
CreateMessage::new().embed(
|
"Claimed by {}",
|
||||||
CreateEmbed::new()
|
claimer.name
|
||||||
.title("Ticket closed")
|
)))
|
||||||
.description(format!("Closed by <@{}> in <#{}>", user.id, channel_id))
|
.color(0x0057_F387),
|
||||||
.color(0x00ED_4245),
|
),
|
||||||
),
|
)
|
||||||
)
|
.await;
|
||||||
.await;
|
let _ = ChannelId::new(TICKET_LOGS)
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // SAFETY: small delay to let log flush, not in command path, jitter not needed
|
.send_message(
|
||||||
let _ = channel_id.delete(&ctx.http).await;
|
&ctx.http,
|
||||||
info!(channel = %channel_id, "ticket closed");
|
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> {
|
fn build_ticket_overwrites(user_id: u64) -> Vec<PermissionOverwrite> {
|
||||||
let everyone = serenity::all::RoleId::new(1509503154708811837);
|
let everyone = serenity::all::RoleId::new(1509503154708811837);
|
||||||
let staff = serenity::all::RoleId::new(1547203757711040572);
|
let staff = serenity::all::RoleId::new(1547203757711040572);
|
||||||
|
|
@ -154,26 +185,8 @@ fn build_ticket_overwrites(user_id: u64) -> Vec<PermissionOverwrite> {
|
||||||
deny: Permissions::empty(),
|
deny: Permissions::empty(),
|
||||||
kind: PermissionOverwriteType::Member(user),
|
kind: PermissionOverwriteType::Member(user),
|
||||||
},
|
},
|
||||||
PermissionOverwrite {
|
allow_view(staff),
|
||||||
allow: Permissions::VIEW_CHANNEL
|
allow_view(owner),
|
||||||
| Permissions::SEND_MESSAGES
|
allow_view(moderator),
|
||||||
| 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),
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod handler;
|
pub mod handler;
|
||||||
|
pub mod service;
|
||||||
|
|
|
||||||
127
src/feature/ticket/service.rs
Normal file
127
src/feature/ticket/service.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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_state) = old {
|
||||||
if let Some(old_channel) = old_state.channel_id {
|
if let Some(old_channel) = old_state.channel_id {
|
||||||
if old_channel != HUB && old_channel.get() != 1509503696566882324 {
|
if old_channel != HUB && old_channel.get() != 1509503696566882324 {
|
||||||
// check if channel empty
|
if let Ok(serenity::all::Channel::Guild(g)) =
|
||||||
if let Ok(channel) = old_channel.to_channel(&ctx.http).await {
|
old_channel.to_channel(&ctx.http).await
|
||||||
if let serenity::all::Channel::Guild(g) = channel {
|
{
|
||||||
if let Ok(members) = g.members(&ctx.cache) {
|
if let Ok(members) = g.members(&ctx.cache) {
|
||||||
if members.is_empty() {
|
if members.is_empty() {
|
||||||
let _ = g.delete(&ctx.http).await;
|
let _ = g.delete(&ctx.http).await;
|
||||||
info!(channel = %old_channel, "temp voice deleted (empty)");
|
info!(channel = %old_channel, "temp voice deleted (empty)");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue