M
src/interactions/buttons/tickets/ticket.ts
→
src/interactions/buttons/tickets/ticket.ts
@@ -9,6 +9,7 @@ type ChatInputCommandInteraction,
type Client,
ContainerBuilder,
FileBuilder,
+ type GuildMember,
LabelBuilder,
type ModalSubmitInteraction,
PermissionFlagsBits,
@@ -20,7 +21,7 @@ TextInputBuilder,
TextInputStyle,
} from "discord.js";
import { ModalBuilder } from "discord.js";
-import { eq } from "drizzle-orm";
+import { eq, sql } from "drizzle-orm";
import {
type Guild,
@@ -33,10 +34,12 @@ } 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,
getTicketMessage,
} from "@/utils/queries";
import { getPublicUrl } from "@/utils/s3";
@@ -54,11 +57,6 @@ .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) =>
@@ -92,6 +90,31 @@ .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 = {
@@ -163,6 +186,101 @@ if (staff) modal.addLabelComponents(privateReason);
return modal;
}
+function constructTicketContainer(
+ ticket: Ticket,
+ attachmentsString: string,
+ file: AttachmentBuilder,
+ _context: "closed" | "view" = "closed",
+ isPublic = true,
+) {
+ console.log(ticket.closedAt);
+ 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: ticketAttachment, 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
// ====================================
@@ -185,11 +303,14 @@ const modal = await getTicketMessageModal(interaction.guildId);
await interaction.showModal(modal);
return;
}
-
if (action === "create") {
await handleCreate(client, interaction);
return;
}
+ if (action === "list") {
+ await handleList(client, interaction);
+ return;
+ }
const { data: guild } = await getGuild(interaction.guildId);
const { data: ticket } = await getTicket(
@@ -226,6 +347,7 @@ 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.");
}
@@ -242,12 +364,16 @@ });
return;
}
+ if (action === "list") {
+ await handleListPagination(client, interaction);
+ 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,
@@ -352,8 +478,18 @@ 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-${anonymousId}`,
+ name: `ticket-${paddedTicketNumber}`,
type: ChannelType.GuildText,
parent,
permissionOverwrites: [
@@ -392,6 +528,7 @@ .insert(tickets)
.values({
guildId: interaction.guild.id,
channelId: channel.id,
+ ticketNumber: ticketNumber,
authorId: interaction.user.id,
anonymousId: anonymousId,
type: "general",
@@ -430,15 +567,22 @@
async function handleClose(
client: Client,
interaction: ChatInputCommandInteraction | ModalSubmitInteraction,
- ticket: Ticket,
+ _ticket: Ticket,
guild: Guild,
reasons?: { publicReason?: string; privateReason?: string },
) {
- if (ticket.status === "closed") {
+ 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 closedByReporter = interaction.user.id === _ticket.authorId;
const channel = interaction.channel;
if (!channel || !("permissionOverwrites" in channel)) return;
@@ -452,8 +596,21 @@ status: "closed",
closedAt: new Date(),
closeReason: reasons?.publicReason,
privateReason: reasons?.privateReason,
+ closedBy: closedByReporter ? "reporter" : interaction.user.id,
})
- .where(eq(tickets.id, ticket.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
@@ -491,79 +648,17 @@ 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}> (${closedByReporter ? "reporter" : "staff"})`,
- ].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;
- };
+ const publicMessage = await getTicketContainer(ticket, "closed", true);
+ const staffMessage = await getTicketContainer(ticket, "closed", false);
// send message to the author
try {
await author.send({
- components: [constructContainer(false)],
+ components: [publicMessage.container],
flags: ["IsComponentsV2"],
- files: [
- {
- attachment: Buffer.from(transcriptText, "utf-8"),
- name: `transcript-${ticket.anonymousId}.txt`,
- },
- ],
+ files: publicMessage.files,
allowedMentions: {
users: [],
},
@@ -592,14 +687,9 @@ return;
}
await transcriptChannel.send({
- components: [constructContainer(true)],
+ components: [staffMessage.container],
flags: ["IsComponentsV2"],
- files: [
- {
- attachment: Buffer.from(transcriptText, "utf-8"),
- name: `transcript-${ticket.anonymousId}.txt`,
- },
- ],
+ files: staffMessage.files,
allowedMentions: {
users: [],
},
@@ -668,6 +758,16 @@ _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" })
@@ -693,7 +793,7 @@ await interaction.channel?.send({
files: [
{
attachment: Buffer.from(transcriptText, "utf-8"),
- name: `transcript-${ticket.anonymousId}.txt`,
+ name: `transcript-${ticket.id}.txt`,
},
],
});
@@ -701,6 +801,206 @@
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"],
+ });
+}
+
+// exports
export default {
data: commandData,
execute,