import type { Attachment, Ticket } from "@/database"; import { text } from "@/utils/discord/components"; import { getPublicUrl } from "@/utils/s3"; import { generateTranscript } from "@/utils/tickets/transcripts"; import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, CheckboxBuilder, ContainerBuilder, 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), ); const deleteGroup = new LabelBuilder() .setLabel("Immediately delete channel") .setCheckboxComponent( new CheckboxBuilder().setCustomId("ticket:delete_checkbox").setDefault(false), ); modal.addLabelComponents(pubReason); if (staff) modal.addLabelComponents(privateReason, deleteGroup); return modal; } // -- container builders --------------------------------------------------------------------------- export function constructTicketContainer( ticket: Ticket, attachmentsString: string, _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 buttons = new ActionRowBuilder().addComponents( new ButtonBuilder() .setLabel("View Transcript") .setStyle(ButtonStyle.Link) .setURL(`https://tickets.vt3e.cat/tickets/${ticket.id}`), ); if (isPublic) { buttons.addComponents( new ButtonBuilder() .setCustomId(`ticket:thank:${ticket.id}`) .setLabel("Thank your moderator") .setEmoji("💖") .setStyle(ButtonStyle.Primary), ); } const wasBehalfTicket = ticket.openedBy !== ticket.authorId; const reasonText = ticket.topic ? `for reason: ${ticket.topic}` : "without a provided reason"; const behalfText = wasBehalfTicket ? `**Opened By**: <@${ticket.openedBy}> ${reasonText}` : null; const container = new ContainerBuilder() .addTextDisplayComponents( text( [ `## Ticket #${ticket.id} (${ticket.anonymousId}) - ${ticket.status}`, `**Closed By**: ${closedByString}`, behalfText, ].join("\n"), ), ) .addSeparatorComponents(new SeparatorBuilder().setDivider(false)) .addTextDisplayComponents( text( [ reasonContructor("Public", reasons?.publicReason), isPublic ? null : reasonContructor("Private", reasons?.privateReason), ] .filter(Boolean) .join("\n"), ), ) .addSeparatorComponents(new SeparatorBuilder().setDivider(false).setSpacing(2)) .addTextDisplayComponents( text( [ attachmentsString.length > 0 ? `-# **Attachments:** ${attachmentsString}` : "-# **Attachments:** no attachments", ] .filter(Boolean) .join("\n"), ), ) .addActionRowComponents(buttons); 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, context, isPublic), transcriptText, attachments, files: [file], }; }