M
src/interactions/buttons/tickets/ticket.ts
→
src/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(