import { type Guild, type Ticket, db, tickets } from "@/database"; import { getGuild, getManagerRoleIds, getTicket, getTicketById, } from "@/database/queries"; import { text } from "@/utils/discord/components"; import { hasManagerPermissions } from "@/utils/discord/permissions"; import { loggers } from "@/utils/logging"; import { formatMessage } from "@/utils/tickets/formatting"; import { generateTranscript } from "@/utils/tickets/transcripts"; import { ActionRowBuilder, ButtonBuilder, type ButtonInteraction, ButtonStyle, ChannelType, ChatInputCommandInteraction, type Client, ContainerBuilder, DiscordAPIError, type GuildMember, type Interaction, type ModalSubmitInteraction, PermissionFlagsBits, TextDisplayBuilder, type User, } from "discord.js"; import { eq, sql } from "drizzle-orm"; import { getTicketContainer } from "./_shared"; const logger = loggers.interactions.child({ name: "ticket/actions" }); const canDmUser = (user: User): Promise => user .send({ content: "Hi! I'm just checking if your DMs are open.", flags: ["SuppressNotifications"], }) .then(() => true) .catch(() => false); export async function handleCreate( client: Client, interaction: ChatInputCommandInteraction | ButtonInteraction, ) { let targetUser: User | null = null; let openingReason: string | null = null; if ( interaction instanceof ChatInputCommandInteraction && interaction.options.getSubcommand() === "create-for" ) { const isModerator = await hasManagerPermissions( interaction.member as GuildMember, ); if (!isModerator) { await interaction.reply({ content: "❌ You don't have permission to create tickets for other users.", }); } targetUser = interaction.options.getUser("user", true); openingReason = interaction.options.getString("reason")?.trim() ?? null; } 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: targetUser ? targetUser.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: targetUser ? targetUser.id : interaction.user.id, openedBy: interaction.user.id, anonymousId: anonymousId, type: "general", status: "open", topic: openingReason, }) .returning(); const formatted = formatMessage( guildData.ticket_message ?? "", targetUser ? targetUser.id : interaction.user.id, ); const controls = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("ticket:close") .setLabel("Close") .setStyle(ButtonStyle.Danger) .setEmoji("🔒"), ); const containers = [ new ContainerBuilder() .setAccentColor(0x40a02b) .addTextDisplayComponents( new TextDisplayBuilder().setContent("# Ticket created!"), new TextDisplayBuilder().setContent(formatted), ) .addActionRowComponents(controls), ]; if (targetUser) { const container = new ContainerBuilder().setAccentColor(0x40a02b); container.addTextDisplayComponents( text( [ `Ticket created by <@${interaction.user.id}>`, openingReason ? `> ${openingReason}` : undefined, ] .filter(Boolean) .join("\n"), ), ); containers.push(container); } await channel.send({ components: containers, flags: ["IsComponentsV2"], }); const canDm = await canDmUser(targetUser ?? interaction.user); if (!canDm) { const formatCommand = (name: string) => ``; const view = formatCommand("view"); const list = formatCommand("list"); const textComponents = [ text("## I can't DM you!"), text( [ "You won't receive a notification when your ticket is handled.", `To check the outcome, use ${view} with the ID ${ticketNumber} or see all your tickets with ${list}.`, ].join(" "), ), ]; const container = new ContainerBuilder() .setAccentColor(0xdf8e1d) .addTextDisplayComponents(textComponents); await channel.send({ components: [container], flags: ["IsComponentsV2"], }); } await interaction.editReply({ content: `:white_check_mark: Ticket created ${channel.toString()}`, }); } export async function handleAddUser( client: Client, interaction: ChatInputCommandInteraction, ticket: Ticket, ) { const targetUser = interaction.options.getUser("user", true); const isStaff = await hasManagerPermissions( interaction.member as GuildMember, ); const isAuthor = interaction.user.id === ticket.authorId; if (!isStaff && !isAuthor) { await interaction.editReply( "❌ You don't have permission to add users to this ticket.", ); return; } const channel = interaction.channel; if (!channel || !("permissionOverwrites" in channel)) return; try { await channel.permissionOverwrites.edit(targetUser.id, { ViewChannel: true, SendMessages: true, AttachFiles: true, }); const updatedUsers = [ ...new Set([...(ticket.addedUsers || []), targetUser.id]), ]; await db .update(tickets) .set({ addedUsers: updatedUsers }) .where(eq(tickets.id, ticket.id)); await channel.send({ content: `✅ <@${targetUser.id}> has been added to the ticket by <@${interaction.user.id}>.`, }); await interaction.editReply("✅ User added successfully."); } catch (error) { logger.error( error, `Failed to add user ${targetUser.id} to ticket ${ticket.id}`, ); await interaction.editReply( "❌ Failed to add the user to the channel permissions.", ); } } export async function handleClose( client: Client, interaction: ChatInputCommandInteraction | ModalSubmitInteraction, _ticket: Ticket, guild: Guild, options: { publicReason?: string; privateReason?: string; deleteChannel?: boolean; }, ) { 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 isManager = await hasManagerPermissions( interaction.member as GuildMember, ); const channel = interaction.channel; if (!channel || !("permissionOverwrites" in channel)) return; const transcriptChannelId = guild.ticket_channel_id; await db .update(tickets) .set({ status: options.deleteChannel ? "archived" : "closed", closedAt: new Date(), closeReason: options.publicReason, privateReason: options.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 && !isManager) { await channel.permissionOverwrites.edit(ticket.authorId, { ViewChannel: false, }); for (const addedUserId of ticket.addedUsers || []) { await channel.permissionOverwrites .edit(addedUserId, { ViewChannel: false, }) .catch(() => null); } } } catch (error) { logger.error(error, `could not remove permissions for ${ticket.authorId}`); channel.send("Failed to update channel permissions for the ticket author."); } const controls = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("ticket:reopen") .setLabel("Reopen") .setStyle(ButtonStyle.Success) .setEmoji("🔓"), new ButtonBuilder() .setCustomId("ticket:delete") .setLabel("Delete") .setStyle(ButtonStyle.Danger) .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 { await author.send({ components: [publicMessage.container], flags: ["IsComponentsV2"], files: publicMessage.files, allowedMentions: { users: [], }, }); } catch (error) { if ( error instanceof DiscordAPIError && [50007, 50278].includes(error.code as number) ) { channel.send(`Couldn't DM the reporter, they may have DMs disabled.`); } else { channel.send("Couldn't DM the reporter."); 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"], 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}`); channel.send( "⚠️ Ticket closed but failed to send transcript/notification after multiple attempts.", ); } if (options.deleteChannel && channel.deletable) { await channel.delete(); return; } const channelName = channel.name; await channel.edit({ name: `closed-${channelName}`, }); } 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.", ); } if (ticket.thankedAt) { return interaction.editReply( "⚠️ You have already thanked the moderator for this ticket.", ); } 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}! 💖`, ); const thankedAt = new Date(); await db .update(tickets) .set({ thankedAt }) .where(eq(tickets.id, ticket.id)); 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, }); for (const addedUserId of ticket.addedUsers || []) { await channel.permissionOverwrites.edit(addedUserId, { 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 isModerator = await hasManagerPermissions( interaction.member as GuildMember, ); const isReporter = interaction.user.id === ticket.authorId; if (!isModerator && !isReporter) { await interaction.editReply( "❌ You don't have permission to generate a transcript for this ticket.", ); return; } 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."); }