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
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
";
|
||||
|
|
|
|||
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 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()),
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod command;
|
||||
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_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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue