import { type Guild, type Ticket, db, tickets } from "@/database"; import { formatMessage } from "@/utils/formatting"; import { loggers } from "@/utils/logging"; import { hasManagerPermissions } from "@/utils/permissions"; import { getGuild, getManagerRoleIds, getTicket, getTicketById, } from "@/utils/queries"; import { generateTranscript } from "@/utils/transcripts"; import { ActionRowBuilder, ButtonBuilder, type ButtonInteraction, ButtonStyle, ChannelType, type ChatInputCommandInteraction, type Client, ContainerBuilder, type GuildMember, type ModalSubmitInteraction, PermissionFlagsBits, TextDisplayBuilder, } from "discord.js"; import { eq, sql } from "drizzle-orm"; import { getTicketContainer } from "./_shared"; const logger = loggers.interactions.child({ name: "ticket/actions" }); export async function handleCreate( client: Client, interaction: ChatInputCommandInteraction | ButtonInteraction, ) { if (!interaction.guild) throw new Error("Guild is not available"); if (!client.user) throw new Error("Client user is not available"); await interaction.deferReply({ flags: ["Ephemeral"] }); const { data: guildData, exists } = await getGuild(interaction.guild.id); if (!exists) { await interaction.editReply({ content: "This server hasn't been setup yet.", }); return; } const anonymousId = crypto.randomUUID().split("-")[0]; const managerRoleIds = await getManagerRoleIds(interaction.guild.id); const parent = guildData.ticket_category_id ?? undefined; const [{ count }] = await db .select({ count: sql`COUNT(*)`.mapWith(Number) }) .from(tickets) .where(eq(tickets.guildId, interaction.guild.id)); const ticketNumber = (count ?? 0) + 1; const paddedTicketNumber = String(ticketNumber).padStart(4, "0"); const channel = await interaction.guild.channels.create({ name: `ticket-${paddedTicketNumber}`, type: ChannelType.GuildText, parent, permissionOverwrites: [ { id: interaction.guild.id, deny: [PermissionFlagsBits.ViewChannel], }, { id: interaction.user.id, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.AttachFiles, ], }, { id: client.user.id, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ManageChannels, ], }, ...managerRoleIds.map((roleId) => ({ id: roleId, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.AttachFiles, ], })), ], }); await db .insert(tickets) .values({ guildId: interaction.guild.id, channelId: channel.id, ticketNumber: ticketNumber, authorId: interaction.user.id, anonymousId: anonymousId, type: "general", status: "open", }) .returning(); const formatted = formatMessage(guildData.ticket_message ?? "", interaction); const controls = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("ticket:close") .setLabel("Close") .setStyle(ButtonStyle.Danger) .setEmoji("🔒"), ); const container = new ContainerBuilder() .setAccentColor(0xff5a00) .addTextDisplayComponents( new TextDisplayBuilder().setContent("# Ticket created!"), new TextDisplayBuilder().setContent(formatted), ) .addActionRowComponents(controls); await channel.send({ components: [container], flags: ["IsComponentsV2"], }); await interaction.editReply({ content: `:white_check_mark: Ticket created ${channel.toString()}`, }); } export async function handleClose( client: Client, interaction: ChatInputCommandInteraction | ModalSubmitInteraction, _ticket: Ticket, guild: Guild, reasons?: { publicReason?: string; privateReason?: string }, ) { if (!interaction.guildId || !interaction.channelId) { await interaction.editReply({ content: "This command can only be used in a server.", }); return; } if (_ticket.status === "closed") { await interaction.editReply("❌ This ticket is already closed."); return; } const closedByReporter = interaction.user.id === _ticket.authorId; const channel = interaction.channel; if (!channel || !("permissionOverwrites" in channel)) return; const transcriptChannelId = guild.ticket_channel_id; await db .update(tickets) .set({ status: "closed", closedAt: new Date(), closeReason: reasons?.publicReason, privateReason: reasons?.privateReason, closedBy: closedByReporter ? "reporter" : interaction.user.id, }) .where(eq(tickets.id, _ticket.id)); const { data: ticket, exists } = await getTicket( interaction.guildId, interaction.channelId, ); if (!exists || !ticket) { await interaction.editReply({ content: "❌ This channel is not a valid ticket.", }); return; } try { const member = await interaction.guild?.members .fetch(ticket.authorId) .catch(() => null); const isAdmin = member?.permissions.has(PermissionFlagsBits.Administrator); const isOwner = interaction.guild?.ownerId === ticket.authorId; if (!isAdmin && !isOwner) { await channel.permissionOverwrites.edit(ticket.authorId, { ViewChannel: false, }); } } catch (error) { logger.error(error, `could not remove permissions for ${ticket.authorId}`); } const controls = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("ticket:reopen") .setLabel("Reopen") .setStyle(ButtonStyle.Success) .setEmoji("🔓"), ); await channel.send({ content: `Ticket closed by <@${interaction.user.id}>.`, components: [controls], }); await interaction.editReply("✅ Ticket closed."); const author = await client.users.fetch(ticket.authorId); const publicMessage = await getTicketContainer(ticket, "closed", true); const staffMessage = await getTicketContainer(ticket, "closed", false); const retry = async (fn: () => Promise, label: string) => { const maxRetries = 5; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await fn(); return; } catch (error) { logger.error( error, `Attempt ${attempt}/${maxRetries} failed for ${label}`, ); if (attempt === maxRetries) { throw error; } } } }; const messageAuthor = async () => { try { const thankYou = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`ticket:thank:${ticket.id}`) .setLabel("Thank your moderator") .setEmoji("💖") .setStyle(ButtonStyle.Primary), ); publicMessage.container.addActionRowComponents(thankYou); await author.send({ components: [publicMessage.container], flags: ["IsComponentsV2"], files: publicMessage.files, allowedMentions: { users: [], }, }); } catch (error) { logger.error( error, `Could not send DM to user ${ticket.authorId} for closed ticket ${ticket.id}`, ); } }; const sendToStaff = async () => { try { if (!transcriptChannelId) { logger.warn( `No transcript channel configured for guild ${interaction.guildId}, skipping transcript post`, ); return; } const transcriptChannel = await client.channels.fetch(transcriptChannelId); if (!transcriptChannel || !("send" in transcriptChannel)) { logger.warn( `Transcript channel with ID ${transcriptChannelId} not found or is not a text channel`, ); return; } await transcriptChannel.send({ components: [staffMessage.container], flags: ["IsComponentsV2"], files: staffMessage.files, allowedMentions: { users: [], }, }); } catch (error) { logger.error( error, `Could not send ticket transcript to transcript channel ${transcriptChannelId} for ticket ${ticket.id}`, ); await interaction.followUp({ content: "⚠️ Ticket closed but failed to send transcript to transcript channel.", }); } }; try { await retry( messageAuthor, `DM user ${ticket.authorId} for ticket ${ticket.id}`, ); await retry( sendToStaff, `send transcript to channel ${transcriptChannelId} for ticket ${ticket.id}`, ); } catch (error) { logger.error(error, `Failed after all retries for ticket ${ticket.id}`); await interaction.followUp({ content: "⚠️ Ticket closed but failed to send transcript/notification after multiple attempts.", }); } } export async function handleThank( _client: Client, interaction: ButtonInteraction, ) { await interaction.deferReply({ flags: ["Ephemeral"] }); const id = Number(interaction.customId.split(":")[2]); const { data: ticket, exists: ticketExists } = await getTicketById(null, id); if (!ticketExists || !ticket) { logger.error( `Ticket not found for thank you interaction, ticket ID: ${id}, guild ID: ${interaction.guildId}`, ); return interaction.editReply( "⚠️ Ticket or guild data not found, cannot send thank you message.", ); } const { data: guild, exists: guildExists } = await getGuild(ticket.guildId); if (!guildExists || !guild || !guild.ticket_channel_id) { logger.error(`Ticket ${ticket.id} missing guild/channel data`); return interaction.editReply( "⚠️ Ticket data is missing channel information, cannot send thank you message.", ); } try { const channel = await _client.channels.fetch(guild.ticket_channel_id); if (!channel || !("send" in channel)) { logger.error( `Channel ${guild.ticket_channel_id} not found or not sendable for ticket ${ticket.id}`, ); return interaction.editReply( "⚠️ Could not find the ticket channel to send the thank you message.", ); } await channel.send( `The reporter says thank you for handling ticket #${ticket.ticketNumber}! 💖`, ); return interaction.editReply("A thank you message sent to the moderators!"); } catch (error) { logger.error( error, `Could not send thank you message for ticket ${ticket.id}`, ); return interaction.editReply( "⚠️ Failed to send thank you message to the moderator.", ); } } export async function handleReopen( _client: Client, interaction: ButtonInteraction | ChatInputCommandInteraction, ticket: Ticket, ) { if (ticket.status === "open") { await interaction.editReply("❌ This ticket is already open."); return; } const channel = interaction.channel; if (!channel || !("permissionOverwrites" in channel)) return; await db .update(tickets) .set({ status: "open", closedAt: null, }) .where(eq(tickets.id, ticket.id)); try { await channel.permissionOverwrites.edit(ticket.authorId, { ViewChannel: true, SendMessages: true, AttachFiles: true, }); } catch (error) { logger.error(error, `could not restore permissions for ${ticket.authorId}`); } const controls = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("ticket:close") .setLabel("Close") .setStyle(ButtonStyle.Danger) .setEmoji("🔒"), ); await channel.send({ content: `Ticket reopened by <@${interaction.user.id}>`, components: [controls], }); await interaction.editReply("✅ Ticket reopened."); } export async function handleDelete( _client: Client, interaction: ButtonInteraction | ChatInputCommandInteraction, ticket: Ticket, ) { const isStaff = await hasManagerPermissions( interaction.member as GuildMember, ); if (!isStaff) { await interaction.editReply( "❌ You don't have permission to delete this ticket, you can only close it.", ); return; } await db .update(tickets) .set({ status: "archived" }) .where(eq(tickets.id, ticket.id)); await interaction.editReply( "✅ Ticket deleted. Channel will be deleted shortly.", ); const channel = interaction.channel; if (channel && "delete" in channel) { channel.delete(); return; } logger.error( `Channel with ID ${interaction.channelId} not found or cannot be deleted for ticket ${ticket.id}`, ); interaction.editReply( "Ticket was marked as deleted but couldn't delete the channel.", ); } export async function handleTranscript( _client: Client, interaction: ButtonInteraction | ChatInputCommandInteraction, ticket: Ticket, ) { const [transcriptText] = await generateTranscript(ticket); if (interaction.channel?.isSendable()) await interaction.channel?.send({ files: [ { attachment: Buffer.from(transcriptText, "utf-8"), name: `transcript-${ticket.id}.txt`, }, ], }); await interaction.editReply("✅ Transcript sent."); }