all repos — stealth-developers @ f4bff7bbedcb79af104f843a7a23fc52823afafa

refactor(tickets): break up tickets.ts into multiple files; move to commands/ dir
vi v@vt3e.cat
Thu, 26 Feb 2026 22:12:24 +0000
commit

f4bff7bbedcb79af104f843a7a23fc52823afafa

parent

f0eb5ca58607b05dd581866075a242643a9ffd40

D src/interactions/buttons/tickets/ticket.ts

@@ -1,1000 +0,0 @@

-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<number>`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<ButtonBuilder>().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<ButtonBuilder>().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<ButtonBuilder>().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 `<t:${Math.floor(date.getTime() / 1000)}:f>`; - }; - const tsRelative = (d: string | Date) => { - const date = new Date(d); - return `<t:${Math.floor(date.getTime() / 1000)}:R>`; - }; - - 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<ButtonBuilder>().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:<n> - 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, -};
A src/interactions/commands/tickets/_shared.ts

@@ -0,0 +1,164 @@

+import type { Attachment, Ticket } from "@/database"; +import { getPublicUrl } from "@/utils/s3"; +import { generateTranscript } from "@/utils/transcripts"; +import { + AttachmentBuilder, + ContainerBuilder, + FileBuilder, + LabelBuilder, + ModalBuilder, + SeparatorBuilder, + TextDisplayBuilder, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; + +// -- constants ------------------------------------------------------------------------------------ +export const PAGINATION_SIZE = 10; + +export const MODAL_IDS = { + message: "ticket:message", + reason: "ticket:reason", +} as const; + +export const INPUT_IDS = { + REASON: "ticket:reason_input", + PRIVATE_REASON: "ticket:private_reason_input", +} as const; + +// -- modals --------------------------------------------------------------------------------------- +export 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(INPUT_IDS.REASON) + .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(INPUT_IDS.PRIVATE_REASON) + .setPlaceholder("The issue was resolved.") + .setStyle(TextInputStyle.Paragraph) + .setMaxLength(2000) + .setRequired(false), + ); + + modal.addLabelComponents(pubReason); + if (staff) modal.addLabelComponents(privateReason); + return modal; +} + +// -- container builders --------------------------------------------------------------------------- + +export 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; +} + +export 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], + }; +}
A src/interactions/commands/tickets/actions.ts

@@ -0,0 +1,365 @@

+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 } 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<number>`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<ButtonBuilder>().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<ButtonBuilder>().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.", + }); + } +} + +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<ButtonBuilder>().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("⚠️ Deleting ticket channel in 5 seconds..."); + + const channel = interaction.channel; + if (channel && "delete" in channel) { + setTimeout(() => channel.delete(), 5000); + } +} + +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."); +}
A src/interactions/commands/tickets/gdpr.ts

@@ -0,0 +1,243 @@

+import { db, tickets } from "@/database"; +import { loggers } from "@/utils/logging"; +import { hasManagerPermissions } from "@/utils/permissions"; +import { getTicketByNumericId } from "@/utils/queries"; +import { generateTranscript } from "@/utils/transcripts"; +import { + ActionRowBuilder, + ButtonBuilder, + type ButtonInteraction, + ButtonStyle, + type ChatInputCommandInteraction, + type Client, + ContainerBuilder, + type GuildMember, + SeparatorBuilder, + TextDisplayBuilder, +} from "discord.js"; +import { eq } from "drizzle-orm"; +import { PAGINATION_SIZE, getTicketContainer } from "./_shared"; + +const logger = loggers.interactions.child({ name: "ticket/gdpr" }); + +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 `<t:${Math.floor(date.getTime() / 1000)}:f>`; + }; + const tsRelative = (d: string | Date) => { + const date = new Date(d); + return `<t:${Math.floor(date.getTime() / 1000)}:R>`; + }; + + 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 controls = new ActionRowBuilder<ButtonBuilder>().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), + ); + + const nextPage = Math.min(currentPage + 1, totalPages); + const prevPage = Math.max(currentPage - 1, 1); + if (nextPage !== prevPage) container.addActionRowComponents(controls); + + return { container, totalPages, page: currentPage, total }; +} + +export async function handleListPagination( + _client: Client, + interaction: ButtonInteraction, +) { + 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"); + } +} + +export 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"], + }); +} + +export 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"], + }); +} + +export 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"], + }); +}
A src/interactions/commands/tickets/index.ts

@@ -0,0 +1,223 @@

+import { + type ButtonInteraction, + type ChatInputCommandInteraction, + type Client, + type ModalSubmitInteraction, + SlashCommandBuilder, + SlashCommandSubcommandBuilder, +} from "discord.js"; + +import { parseComponentId } from "@/utils/components"; +import { getGuild, getTicket } from "@/utils/queries"; +import { getTicketReasonModal } from "./_shared"; +import { + handleClose, + handleCreate, + handleDelete, + handleReopen, + handleTranscript, +} from "./actions"; +import { + handleExport, + handleList, + handleListPagination, + handleViewTicket, +} from "./gdpr"; +import { handleReasonModal } from "./modals"; + +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", + ), + ) + .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", + ), + ); + +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."); +} + +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, +) { + const components = parseComponentId(interaction.customId); + const action = components.parts[0]; + + if (action === "reason") await handleReasonModal(client, interaction); +} + +export default { + data: commandData, + execute, + buttonExecute, + modalExecute, +};
A src/interactions/commands/tickets/modals.ts

@@ -0,0 +1,47 @@

+import { parseComponentId } from "@/utils/components"; +import { loggers } from "@/utils/logging"; +import { getGuild, getTicket } from "@/utils/queries"; +import type { Client, ModalSubmitInteraction } from "discord.js"; +import { INPUT_IDS } from "./_shared"; +import { handleClose } from "./actions"; + +const logger = loggers.interactions.child({ name: "ticket/modals" }); + +export async function handleReasonModal( + _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 publicReason = interaction.fields.getTextInputValue(INPUT_IDS.REASON); + let privateReason = ""; + try { + privateReason = interaction.fields.getTextInputValue( + INPUT_IDS.PRIVATE_REASON, + ); + } 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); + await handleClose(interaction.client, interaction, ticket, guildData, { + publicReason: publicReason || undefined, + privateReason: privateReason || undefined, + }); +}