import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, type ButtonInteraction, ButtonStyle, ChannelType, type ChatInputCommandInteraction, type Client, ContainerBuilder, FileBuilder, type GuildMember, LabelBuilder, type ModalSubmitInteraction, PermissionFlagsBits, SeparatorBuilder, SlashCommandBuilder, SlashCommandSubcommandBuilder, TextDisplayBuilder, TextInputBuilder, TextInputStyle, } from "discord.js"; import { ModalBuilder } from "discord.js"; import { eq, sql } from "drizzle-orm"; import { type Attachment, type Guild, type Ticket, db, guild, tickets, } from "@/database"; import { makeComponentId, parseComponentId } from "@/utils/components"; import { formatMessage } from "@/utils/formatting"; import { loggers } from "@/utils/logging"; import { hasManagerPermissions } from "@/utils/permissions"; import { getGuild, getManagerRoleIds, getTicket, getTicketByNumericId, } from "@/utils/queries"; import { getPublicUrl } from "@/utils/s3"; import { generateTranscript } from "@/utils/transcripts"; const logger = loggers.interactions.child({ name: "ticket" }); const commandData = new SlashCommandBuilder() .setName("ticket") .setDescription("Manage and create new tickets") .addSubcommand( new SlashCommandSubcommandBuilder() .setName("create") .setDescription("Create a new ticket"), ) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("close") .setDescription("Close the ticket in the current channel") .addStringOption((option) => option .setName("reason") .setDescription("Reason for closing the ticket, shown to the user") .setRequired(false), ) .addStringOption((option) => option .setName("private_reason") .setDescription( "Private reason for closing the ticket, shown only to staff", ) .setRequired(false), ), ) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("reopen") .setDescription("Reopen the ticket in the current channel"), ) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("delete") .setDescription("Delete the ticket in the current channel"), ) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("transcript") .setDescription( "Get the transcript of the ticket in the current channel", ), ) // gdpr related .addSubcommand( new SlashCommandSubcommandBuilder() .setName("list") .setDescription("Lists all of your current and past tickets"), ) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("view") .setDescription("View details about a specific ticket") .addIntegerOption((option) => option .setName("ticket_id") .setDescription("The ID of the ticket you want to view") .setRequired(true), ), ) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("export") .setDescription( "Export all of your ticket data in a machine readable format", ), ); const MODAL_IDS = { message: makeComponentId(commandData, "message"), reason: makeComponentId(commandData, "reason"), }; function getTicketReasonModal(staff = false) { const modal = new ModalBuilder() .setCustomId(MODAL_IDS.reason) .setTitle("Close Ticket"); const pubReason = new LabelBuilder() .setLabel("Public Reason") .setDescription( staff ? "Provide a reason for closing the ticket - this will be visible to the user" : "Provide a reason for closing the ticket", ) .setTextInputComponent( new TextInputBuilder() .setCustomId("ticket:reason_input") .setPlaceholder("The issue was resolved.") .setStyle(TextInputStyle.Paragraph) .setMaxLength(2000) .setRequired(false), ); const privateReason = new LabelBuilder() .setLabel("Private Reason") .setDescription( "Provide a reason for closing the ticket - this will only be visible to staff", ) .setTextInputComponent( new TextInputBuilder() .setCustomId("ticket:private_reason_input") .setPlaceholder("The issue was resolved.") .setStyle(TextInputStyle.Paragraph) .setMaxLength(2000) .setRequired(false), ); modal.addLabelComponents(pubReason); if (staff) modal.addLabelComponents(privateReason); return modal; } function constructTicketContainer( ticket: Ticket, attachmentsString: string, file: AttachmentBuilder, _context: "closed" | "view" = "closed", isPublic = true, ) { const closedByReporter = ticket.closedBy === "reporter"; const closedByString = ticket.closedAt ? closedByReporter ? "reporter" : `<@${ticket.closedBy}> (staff)` : "N/A"; const reasons = { publicReason: ticket.closeReason ?? undefined, privateReason: ticket.privateReason ?? undefined, }; const reasonContructor = (title: string, reason?: string) => reason ? `### ${title} Reason\n${reason}` : `### ${title} Reason\n-# No ${title.toLowerCase()} reason provided.`; const container = new ContainerBuilder() .addTextDisplayComponents( new TextDisplayBuilder().setContent( [ `## Ticket #${ticket.id} (${ticket.anonymousId}) - ${ticket.status}`, `**Closed By**: ${closedByString}`, ].join("\n"), ), ) .addSeparatorComponents(new SeparatorBuilder().setDivider(false)) .addTextDisplayComponents( new TextDisplayBuilder().setContent( [ reasonContructor("Public", reasons?.publicReason), isPublic ? null : reasonContructor("Private", reasons?.privateReason), ] .filter(Boolean) .join("\n"), ), ) .addSeparatorComponents( new SeparatorBuilder().setDivider(false).setSpacing(2), ) .addTextDisplayComponents( new TextDisplayBuilder().setContent( [ attachmentsString.length > 0 ? `-# **Attachments:** ${attachmentsString}` : "-# **Attachments:** no attachments", ] .filter(Boolean) .join("\n"), ), ) .addFileComponents(new FileBuilder().setURL(`attachment://${file.name}`)); return container; } async function getTicketContainer( ticket: Ticket, context: "closed" | "view" = "closed", isPublic = true, ) { const [transcriptText, attachments] = await generateTranscript(ticket); const file = new AttachmentBuilder(Buffer.from(transcriptText, "utf-8"), { name: `transcript-${ticket.id}.txt`, }); const attachmentsString = attachments .map( (att: Attachment, idx: number) => `[Attachment #${idx + 1} (${att.fileName.split(".").slice(-1)[0].toUpperCase()})](<${getPublicUrl(att.key)}>)`, ) .join(", "); return { container: constructTicketContainer( ticket, attachmentsString, file, context, isPublic, ), transcriptText, attachments, files: [file], }; } // ==================================== // interaction handlers // ==================================== async function execute( client: Client, interaction: ChatInputCommandInteraction, ) { const action = interaction.options.getSubcommand(); if (!interaction.channelId || !interaction.guildId) { await interaction.reply({ content: "This command can only be used in a server.", flags: ["Ephemeral"], }); return; } if (action === "create") { await handleCreate(client, interaction); return; } if (action === "list") { await handleList(client, interaction); return; } if (action === "export") { await handleExport(client, interaction); return; } const { data: guild } = await getGuild(interaction.guildId); const { data: ticket } = await getTicket( interaction.guildId, interaction.channelId, ); if (!ticket) { await interaction.reply({ content: "❌ This channel is not a valid ticket.", }); return; } if (action === "close") { const publicReason = interaction.options.getString("reason") ?? undefined; const privateReason = interaction.options.getString("private_reason") ?? undefined; if (publicReason || privateReason) { await handleClose(client, interaction, ticket, guild, { publicReason, privateReason, }); return; } return await interaction.showModal( getTicketReasonModal(interaction.user.id !== ticket.authorId), ); } await interaction.deferReply({ flags: ["Ephemeral"] }); if (action === "reopen") await handleReopen(client, interaction, ticket); else if (action === "delete") await handleDelete(client, interaction, ticket); else if (action === "transcript") await handleTranscript(client, interaction, ticket); else if (action === "view") await handleViewTicket(client, interaction); else await interaction.editReply("❌ Invalid or unhandled action."); } export async function buttonExecute( client: Client, interaction: ButtonInteraction, ) { const [action] = interaction.customId.split(":").slice(1); if (!interaction.channelId || !interaction.guildId) { await interaction.reply({ content: "This command can only be used in a server.", }); return; } if (action === "list") { await handleListPagination(client, interaction); return; } if (action === "create") { await handleCreate(client, interaction); return; } const { data: ticket } = await getTicket( interaction.guildId, interaction.channelId, ); if (!ticket) { await interaction.reply({ content: "❌ This channel is not a valid ticket.", }); return; } if (action === "close") return await interaction.showModal( getTicketReasonModal(interaction.user.id !== ticket.authorId), ); await interaction.deferReply({ flags: ["Ephemeral"] }); if (action === "reopen") await handleReopen(client, interaction, ticket); else if (action === "delete") await handleDelete(client, interaction, ticket); else if (action === "transcript") await handleTranscript(client, interaction, ticket); else await interaction.editReply("❌ Invalid or unhandled action."); } async function modalExecute( _client: Client, interaction: ModalSubmitInteraction, ) { await interaction.deferReply({ flags: ["Ephemeral"] }); if (!interaction.channelId || !interaction.guildId) { await interaction.editReply({ content: "This command can only be used in a server.", }); return; } const components = parseComponentId(interaction.customId); const action = components.parts[0]; if (action === "reason") { const publicReason = interaction.fields.getTextInputValue( "ticket:reason_input", ); let privateReason = ""; try { privateReason = interaction.fields.getTextInputValue( "ticket:private_reason_input", ); } catch {} 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; } const { data: guildData } = await getGuild(interaction.guildId); handleClose(interaction.client, interaction, ticket, guildData, { publicReason: publicReason || undefined, privateReason: privateReason || undefined, }); } } // ==================================== // action handlers // ==================================== 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; if (!interaction.guild) throw new Error("Guild is not available"); 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()}`, }); } 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("🔓"), 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); // send message to the author try { 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}`, ); } // send message to transcript channel 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.", }); } } 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."); } 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("⚠️ Deleting ticket channel in 5 seconds..."); const channel = interaction.channel; if (channel && "delete" in channel) { setTimeout(() => channel.delete(), 5000); } } 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."); } // ==================================== // gdpr related // ==================================== const PAGINATION_SIZE = 10; async function buildListContainerForUser(userId: string, page = 1) { const ticketsRes = await db .select() .from(tickets) .where(eq(tickets.authorId, userId)); if (!ticketsRes || ticketsRes.length === 0) { const empty = new ContainerBuilder() .setAccentColor(0xff5a00) .addTextDisplayComponents( new TextDisplayBuilder().setContent( "## Your Tickets\n\nYou have no tickets.", ), ); return { container: empty, totalPages: 0, page: 1, total: 0 }; } const sortedTickets = ticketsRes.sort((a, b) => { const dateA = a.closedAt ?? a.createdAt; const dateB = b.closedAt ?? b.createdAt; return dateB.getTime() - dateA.getTime(); }); const total = sortedTickets.length; const totalPages = Math.max(1, Math.ceil(total / PAGINATION_SIZE)); const currentPage = Math.min(Math.max(1, page), totalPages); const paged = sortedTickets.slice( (currentPage - 1) * PAGINATION_SIZE, currentPage * PAGINATION_SIZE, ); const tsExact = (d: string | Date) => { const date = new Date(d); return ``; }; const tsRelative = (d: string | Date) => { const date = new Date(d); return ``; }; const ticketLines = paged.map((t) => { let id = `**#${t.anonymousId}**`; if (t.closedAt) id = `~~**${id}**~~ (closed)`; const created = `${tsExact(t.createdAt)} (${tsRelative(t.createdAt)})`; const closed = t.closedAt ? `\n * opened ${tsExact(t.closedAt)} (${tsRelative(t.closedAt)})` : ""; return `- ${id}\n * created ${created}${closed}`; }); const header = `## Your Tickets - ${total} total\n`; const container = new ContainerBuilder() .setAccentColor(0xff5a00) .addTextDisplayComponents( new TextDisplayBuilder().setContent(`${header}${ticketLines.join("\n")}`), ) .addSeparatorComponents( new SeparatorBuilder().setDivider(false).setSpacing(1), ); const nextPage = Math.min(currentPage + 1, totalPages); const prevPage = Math.max(currentPage - 1, 1); const controls = new ActionRowBuilder().addComponents( new ButtonBuilder() .setDisabled(true) .setLabel(`Page ${currentPage} of ${totalPages}`) .setStyle(ButtonStyle.Secondary) .setCustomId("ticket:list:page_info"), new ButtonBuilder() .setCustomId(`ticket:list:page:${Math.max(1, currentPage - 1)}`) .setLabel("Prev") .setStyle(ButtonStyle.Primary) .setDisabled(currentPage <= 1), new ButtonBuilder() .setCustomId(`ticket:list:page:${Math.min(totalPages, currentPage + 1)}`) .setLabel("Next") .setStyle(ButtonStyle.Primary) .setDisabled(currentPage >= totalPages), ); if (nextPage !== prevPage) container.addActionRowComponents(controls); return { container, totalPages, page: currentPage, total }; } async function handleListPagination( _client: Client, interaction: ButtonInteraction, ) { // ticket:list:page: const parts = interaction.customId.split(":"); const pagePart = parts[3]; const page = pagePart ? Number.parseInt(pagePart, 10) : 1; if (Number.isNaN(page) || page < 1) { try { await interaction.reply({ content: "Invalid page.", flags: ["Ephemeral"], }); } catch {} return; } const { container } = await buildListContainerForUser( interaction.user.id, page, ); try { await interaction.update({ components: [container] }); } catch (err) { try { await interaction.reply({ content: "Could not update page, try /ticket list again.", flags: ["Ephemeral"], }); } catch {} logger.error(err, "failed to update ticket list pagination"); } } async function handleList( _client: Client, interaction: ChatInputCommandInteraction, ) { if (!interaction.guild) { await interaction.reply({ content: "❌ This command can only be used in a server.", flags: ["Ephemeral"], }); return; } const { container } = await buildListContainerForUser(interaction.user.id, 1); await interaction.reply({ components: [container], flags: ["IsComponentsV2", "Ephemeral"], }); } async function handleViewTicket( _client: Client, interaction: ChatInputCommandInteraction, ) { if (!interaction.guild) { await interaction.editReply({ content: "❌ This command can only be used in a server.", }); return; } const ticketId = interaction.options.getInteger("ticket_id", true); const { data: ticket, exists } = await getTicketByNumericId( interaction.guild.id, ticketId, ); if (!exists || !ticket) { await interaction.editReply({ content: `❌ Ticket with ID ${ticketId} not found.`, }); return; } const isStaff = await hasManagerPermissions( interaction.member as GuildMember, ); const isAuthor = ticket.authorId === interaction.user.id; if (!isStaff && !isAuthor) { await interaction.editReply({ content: "❌ You don't have permission to view this ticket.", }); return; } const { container, files } = await getTicketContainer( ticket, "view", !isStaff, ); await interaction.editReply({ components: [container], files: files, flags: ["IsComponentsV2"], }); } async function handleExport( _client: Client, interaction: ChatInputCommandInteraction, ) { const _tickets = await db .select() .from(tickets) .where(eq(tickets.authorId, interaction.user.id)); const transcripts = await Promise.all( _tickets.map(async (ticket) => { const [transcriptText] = await generateTranscript(ticket); return transcriptText; }), ); const combined = transcripts.join("\n\n\n\n"); await interaction.reply({ content: "Here is your exported ticket data:", files: [ { attachment: Buffer.from(combined, "utf-8"), name: `tickets-export-${interaction.user.id}.txt`, }, ], flags: ["Ephemeral"], }); } // exports export default { data: commandData, execute, buttonExecute, modalExecute, };