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

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

View file

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