all repos — stealth-developers @ fde303d6daf3ee22a4b0c2982dff0719a0c68529

feat(tickets): allow attaching public & private reasons when closed
vi v@vt3e.cat
Tue, 17 Feb 2026 18:46:32 +0000
commit

fde303d6daf3ee22a4b0c2982dff0719a0c68529

parent

bbd5dbc3aea3aac2648df933de6001346d3f7503

M src/database/schema/tickets.tssrc/database/schema/tickets.ts

@@ -23,6 +23,8 @@ createdAt: integer("created_at", { mode: "timestamp" })

.default(sql`(unixepoch())`) .notNull(), closedAt: integer("closed_at", { mode: "timestamp" }), + closeReason: text("close_reason"), + privateReason: text("private_reason"), }, (t) => [ index("tickets_guild_idx").on(t.guildId),
M src/interactions/buttons/tickets/message.tssrc/interactions/buttons/tickets/message.ts

@@ -15,15 +15,13 @@ TextInputBuilder,

TextInputStyle, } from "discord.js"; +import { makeComponentId } from "@/utils/components"; import { formatMessage } from "@/utils/formatting"; import { loggers } from "@/utils/logging"; import { hasManagerPermissions } from "@/utils/permissions"; import { getGuild } from "@/utils/queries"; const logger = loggers.interactions.child({ name: "messageCommand" }); - -const MODAL_ID = "ticket-message:message"; -const INPUT_ID = "message_content"; const commandData = new SlashCommandBuilder() .setName("ticket-message")

@@ -34,6 +32,9 @@ .setName("channel")

.setDescription("The channel to send the ticket message in") .setRequired(true), ); + +const MODAL_ID = makeComponentId(commandData, "message"); +const INPUT_ID = "message_content"; async function execute( _client: Client,
M src/interactions/buttons/tickets/ticket.tssrc/interactions/buttons/tickets/ticket.ts

@@ -1,5 +1,6 @@

import { ActionRowBuilder, + AttachmentBuilder, ButtonBuilder, type ButtonInteraction, ButtonStyle,

@@ -7,9 +8,13 @@ ChannelType,

type ChatInputCommandInteraction, type Client, ContainerBuilder, + FileBuilder, + FileComponent, LabelBuilder, type ModalSubmitInteraction, PermissionFlagsBits, + SectionBuilder, + SeparatorBuilder, SlashCommandBuilder, SlashCommandSubcommandBuilder, TextDisplayBuilder,

@@ -27,6 +32,7 @@ guild,

type ticketAttachment, tickets, } from "@/database"; +import { makeComponentId, parseComponentId } from "@/utils/components"; import { formatMessage } from "@/utils/formatting"; import { loggers } from "@/utils/logging"; import {

@@ -61,7 +67,15 @@ .setDescription("Close the ticket in the current channel")

.addStringOption((option) => option .setName("reason") - .setDescription("Reason for closing the ticket") + .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), ), )

@@ -83,11 +97,16 @@ "Get the transcript of the ticket in the current channel",

), ); -async function getModal(guildId: string) { +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("ticket:message") + .setCustomId(MODAL_IDS.message) .setTitle("Edit Report Message"); const label = new LabelBuilder()

@@ -107,6 +126,43 @@ 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 // ====================================

@@ -125,13 +181,11 @@ return;

} if (action === "message") { - const modal = await getModal(interaction.guildId); + const modal = await getTicketMessageModal(interaction.guildId); await interaction.showModal(modal); return; } - await interaction.deferReply({ flags: ["Ephemeral"] }); - if (action === "create") { await handleCreate(client, interaction); return;

@@ -144,29 +198,43 @@ interaction.channelId,

); if (!ticket) { - await interaction.editReply({ + 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 (action === "close") await handleClose(client, interaction, ticket, guild); - else if (action === "reopen") await handleReopen(client, interaction, ticket); + 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, ) { - console.log("recd"); - await interaction.deferReply({ flags: ["Ephemeral"] }); const [action] = interaction.customId.split(":").slice(1); if (!interaction.channelId || !interaction.guildId) { - await interaction.editReply({ + await interaction.reply({ content: "This command can only be used in a server.", }); return;

@@ -184,17 +252,21 @@ interaction.channelId,

); if (!ticket) { - await interaction.editReply({ + await interaction.reply({ content: "❌ This channel is not a valid ticket.", }); return; } - if (action === "close") await handleClose(client, interaction, ticket, guild); - else if (action === "reopen") await handleReopen(client, interaction, ticket); + 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(

@@ -202,14 +274,15 @@ _client: Client,

interaction: ModalSubmitInteraction, ) { await interaction.deferReply({ flags: ["Ephemeral"] }); - if (!interaction.guildId) { + if (!interaction.channelId || !interaction.guildId) { await interaction.editReply({ content: "This command can only be used in a server.", }); return; } - const action = interaction.customId.split(":")[1]; + const components = parseComponentId(interaction.customId); + const action = components.parts[0]; if (action === "message") { const message = interaction.fields.getTextInputValue("ticket:input");

@@ -222,9 +295,34 @@

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); - // TODO)) show modal for entering a close reason + 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, + }); + } } // ====================================

@@ -236,6 +334,7 @@ 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) {

@@ -326,9 +425,10 @@ }

async function handleClose( client: Client, - interaction: ButtonInteraction | ChatInputCommandInteraction, + interaction: ChatInputCommandInteraction | ModalSubmitInteraction, ticket: Ticket, guild: Guild, + reasons?: { publicReason?: string; privateReason?: string }, ) { if (ticket.status === "closed") { await interaction.editReply("❌ This ticket is already closed.");

@@ -345,6 +445,8 @@ .update(tickets)

.set({ status: "closed", closedAt: new Date(), + closeReason: reasons?.publicReason, + privateReason: reasons?.privateReason, }) .where(eq(tickets.id, ticket.id));

@@ -386,6 +488,10 @@

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

@@ -395,15 +501,67 @@ `[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`, }, ], - content: `Your ticket #${ticket.anonymousId} has been closed by <@${interaction.user.id}>. The transcript of the ticket is attached.${attachments.length > 0 ? ` The ticket also had the following attachments: ${attachmentsString}` : ""}`, + allowedMentions: { + users: [], + }, }); } catch (error) { logger.error(

@@ -412,36 +570,34 @@ `Could not send DM to user ${ticket.authorId} for closed ticket ${ticket.id}`,

); } - 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; - } - + // send message to transcript channel try { - const closeString = [ - `Transcript for ticket #${ticket.anonymousId} by <@${ticket.authorId}>.`, - attachments.length > 0 ? `-# Attachments: ${attachmentsString}` : null, - ] - .filter(Boolean) - .join("\n"); + 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({ - content: closeString, + components: [constructContainer(true)], + flags: ["IsComponentsV2"], files: [ { attachment: Buffer.from(transcriptText, "utf-8"), name: `transcript-${ticket.anonymousId}.txt`, }, ], + allowedMentions: { + users: [], + }, }); } catch (error) { logger.error(
A src/utils/components.ts

@@ -0,0 +1,19 @@

+export function makeComponentId( + cmd: unknown, + ...parts: Array<string | number> +): string { + const name = + typeof cmd === "string" + ? cmd + : cmd && typeof (cmd as { name?: string }).name === "string" + ? (cmd as { name?: string }).name + : ""; + if (!name) throw new Error("command name is required to make a component id"); + if (parts.length === 0) return name; + return [name, ...parts.map(String)].join(":"); +} + +export function parseComponentId(customId: string) { + const [commandName, ...parts] = customId.split(":"); + return { commandName, parts }; +}