import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, type ButtonInteraction, ButtonStyle, ChannelType, type ChatInputCommandInteraction, type Client, ContainerBuilder, FileBuilder, FileComponent, LabelBuilder, type ModalSubmitInteraction, PermissionFlagsBits, SectionBuilder, SeparatorBuilder, SlashCommandBuilder, SlashCommandSubcommandBuilder, TextDisplayBuilder, TextInputBuilder, TextInputStyle, } from "discord.js"; import { ModalBuilder } from "discord.js"; import { eq } from "drizzle-orm"; import { type Guild, type Ticket, db, guild, type ticketAttachment, tickets, } from "@/database"; import { makeComponentId, parseComponentId } from "@/utils/components"; import { formatMessage } from "@/utils/formatting"; import { loggers } from "@/utils/logging"; import { countTickets, getGuild, getManagerRoleIds, getTicket, getTicketMessage, } 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("message") .setDescription("Set the initial message sent in new tickets"), ) .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", ), ); const MODAL_IDS = { message: makeComponentId(commandData, "message"), reason: makeComponentId(commandData, "reason"), }; async function getTicketMessageModal(guildId: string) { const { data: message } = await getTicketMessage(guildId); const modal = new ModalBuilder() .setCustomId(MODAL_IDS.message) .setTitle("Edit Report Message"); const label = new LabelBuilder() .setLabel("Report Message Content") .setDescription("This will be the message shown to first time reporters.") .setTextInputComponent( new TextInputBuilder() .setCustomId("ticket:input") .setPlaceholder("meow meow :3") .setStyle(TextInputStyle.Paragraph) .setMaxLength(2000) .setRequired(true) .setValue(message ?? ""), ); modal.addLabelComponents(label); return modal; } function getTicketReasonModal() { const modal = new ModalBuilder() .setCustomId(MODAL_IDS.reason) .setTitle("Close Ticket Reason"); const pubReason = new LabelBuilder() .setLabel("Public Reason") .setDescription( "Provide a reason for closing the ticket - this will be DMd to the user", ) .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, privateReason); return modal; } // ==================================== // 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 === "message") { const modal = await getTicketMessageModal(interaction.guildId); await interaction.showModal(modal); return; } if (action === "create") { await handleCreate(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()); } 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."); } 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 === "create") { await handleCreate(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") return await interaction.showModal(getTicketReasonModal()); 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 === "message") { const message = interaction.fields.getTextInputValue("ticket:input"); await db .update(guild) .set({ ticket_message: message }) .where(eq(guild.guildId, interaction.guildId)); await interaction.editReply({ content: "Ticket message updated successfully!", }); } else if (action === "reason") { const publicReason = interaction.fields.getTextInputValue( "ticket:reason_input", ); const privateReason = interaction.fields.getTextInputValue( "ticket:private_reason_input", ); console.log(privateReason); console.log(publicReason); 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; const channel = await interaction.guild.channels.create({ name: `ticket-${anonymousId}`, 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, 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 (ticket.status === "closed") { await interaction.editReply("❌ This ticket is already closed."); return; } 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, }) .where(eq(tickets.id, ticket.id)); 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 [transcriptText, attachments] = await generateTranscript(ticket); const file = new AttachmentBuilder(Buffer.from(transcriptText, "utf-8"), { name: `transcript-${ticket.anonymousId}.txt`, }); const author = await client.users.fetch(ticket.authorId); const attachmentsString = attachments .map( (att: ticketAttachment, idx: number) => `[Attachment #${idx + 1} (${att.fileName.split(".").slice(-1)[0].toUpperCase()})](<${getPublicUrl(att.key)}>)`, ) .join(", "); const constructContainer = (includePrivateReason = false) => { const reasonContructor = (title: string, reason?: string) => reason ? `### ${title} Reason\n${reason}` : `### ${title} Reason\nNo ${title.toLowerCase()} reason provided.`; const container = new ContainerBuilder() .addTextDisplayComponents( new TextDisplayBuilder().setContent( [ `## Ticket #${ticket.anonymousId} closed`, `**Closed By**: <@${interaction.user.id}>`, ].join("\n"), ), ) .addSeparatorComponents(new SeparatorBuilder().setDivider(false)) .addTextDisplayComponents( new TextDisplayBuilder().setContent( [ reasonContructor("Public", reasons?.publicReason), includePrivateReason ? reasonContructor("Private", reasons?.privateReason) : null, ] .filter(Boolean) .join("\n"), ), ) .addSeparatorComponents( new SeparatorBuilder().setDivider(false).setSpacing(2), ) .addTextDisplayComponents( new TextDisplayBuilder().setContent( [ attachments.length > 0 ? `-# **Attachments:** ${attachmentsString}` : "-# **Attachments:** no attachments", ] .filter(Boolean) .join("\n"), ), ) .addFileComponents(new FileBuilder().setURL(`attachment://${file.name}`)); return container; }; // send message to the author try { await author.send({ components: [constructContainer(false)], flags: ["IsComponentsV2"], files: [ { attachment: Buffer.from(transcriptText, "utf-8"), name: `transcript-${ticket.anonymousId}.txt`, }, ], 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: [constructContainer(true)], flags: ["IsComponentsV2"], files: [ { attachment: Buffer.from(transcriptText, "utf-8"), name: `transcript-${ticket.anonymousId}.txt`, }, ], 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, ) { 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.anonymousId}.txt`, }, ], }); await interaction.editReply("✅ Transcript sent."); } export default { data: commandData, execute, buttonExecute, modalExecute, };