discord-bot-kernel/src/feature/ticket/service/mod.rs

202 lines
9.2 KiB
Rust

//! Ticket service
pub mod forum;
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;
}
pub async fn is_on_cooldown(p: &SqlitePool, g: u64, u: u64) -> Option<u64> {
let _ = sqlx::query("CREATE TABLE IF NOT EXISTS ticket_cooldowns (user_id TEXT NOT NULL, guild_id TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY (user_id, guild_id))").execute(p).await;
let row: Option<(i64,)> =
sqlx::query_as("SELECT expires_at FROM ticket_cooldowns WHERE user_id=? AND guild_id=?")
.bind(u.to_string())
.bind(g.to_string())
.fetch_optional(p)
.await
.unwrap_or(None);
if let Some((exp,)) = row {
let now =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
as i64;
if exp > now {
return Some((exp - now) as u64);
}
let _ = sqlx::query("DELETE FROM ticket_cooldowns WHERE user_id=? AND guild_id=?")
.bind(u.to_string())
.bind(g.to_string())
.execute(p)
.await;
}
None
}
pub async fn set_cooldown(p: &SqlitePool, g: u64, u: u64, secs: u64) {
let _ = sqlx::query("CREATE TABLE IF NOT EXISTS ticket_cooldowns (user_id TEXT NOT NULL, guild_id TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY (user_id, guild_id))").execute(p).await;
let exp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
+ secs;
let _ = sqlx::query(
"INSERT OR REPLACE INTO ticket_cooldowns(user_id, guild_id, expires_at) VALUES(?,?,?)",
)
.bind(u.to_string())
.bind(g.to_string())
.bind(exp as i64)
.execute(p)
.await;
}
pub async fn add_user_to_ticket(
http: &Http,
channel_id: ChannelId,
user_id: u64,
) -> anyhow::Result<()> {
let uid = serenity::all::UserId::new(user_id);
let is_thread = matches!(http.get_channel(channel_id).await, Ok(serenity::all::Channel::Guild(gc)) if gc.thread_metadata.is_some());
if is_thread {
channel_id.add_thread_member(http, uid).await?;
} else {
let overwrite = serenity::all::PermissionOverwrite {
allow: serenity::all::Permissions::VIEW_CHANNEL
| serenity::all::Permissions::SEND_MESSAGES
| serenity::all::Permissions::READ_MESSAGE_HISTORY,
deny: serenity::all::Permissions::empty(),
kind: serenity::all::PermissionOverwriteType::Member(uid),
};
channel_id.create_permission(http, overwrite).await?;
}
Ok(())
}
pub async fn remove_user_from_ticket(
http: &Http,
channel_id: ChannelId,
user_id: u64,
) -> anyhow::Result<()> {
let uid = serenity::all::UserId::new(user_id);
let is_thread = matches!(http.get_channel(channel_id).await, Ok(serenity::all::Channel::Guild(gc)) if gc.thread_metadata.is_some());
if is_thread {
channel_id.remove_thread_member(http, uid).await?;
} else {
channel_id
.delete_permission(http, serenity::all::PermissionOverwriteType::Member(uid))
.await?;
}
Ok(())
}
#[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;
}
}
}
}
}