all repos — stealth-developers @ bbd5dbc3aea3aac2648df933de6001346d3f7503

feat: ticket impl
vi v@vt3e.cat
Tue, 17 Feb 2026 14:10:01 +0000
commit

bbd5dbc3aea3aac2648df933de6001346d3f7503

parent

0ad474abcf4c9b5950a6297e3403fb7537b0186b

M bun.lockbun.lock

@@ -7,6 +7,7 @@ "dependencies": {

"@libsql/client": "^0.17.0", "discord.js": "^14.24.0", "drizzle-orm": "^1.0.0-beta.12-a5629fb", + "nanoid": "^5.1.6", "pino": "^10.3.0", "pino-pretty": "^13.1.3", "zod": "^4.3.6",

@@ -325,6 +326,8 @@

"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mssql": ["mssql@11.0.1", "", { "dependencies": { "@tediousjs/connection-string": "^0.5.0", "commander": "^11.0.0", "debug": "^4.3.3", "rfdc": "^1.3.0", "tarn": "^3.0.2", "tedious": "^18.2.1" }, "bin": { "mssql": "bin/mssql" } }, "sha512-KlGNsugoT90enKlR8/G36H0kTxPthDhmtNUCwEHvgRza5Cjpjoj+P2X6eMpFUDN7pFrJZsKadL4x990G8RBE1w=="], + + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], "native-duplexpair": ["native-duplexpair@1.0.0", "", {}, "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA=="],
M package.jsonpackage.json

@@ -27,6 +27,7 @@ "dependencies": {

"@libsql/client": "^0.17.0", "discord.js": "^14.24.0", "drizzle-orm": "^1.0.0-beta.12-a5629fb", + "nanoid": "^5.1.6", "pino": "^10.3.0", "pino-pretty": "^13.1.3", "zod": "^4.3.6"
M src/database/schema/tickets.tssrc/database/schema/tickets.ts

@@ -4,6 +4,7 @@

export const tickets = sqliteTable( "tickets", { + /** @internal this should never be exposed to an end user */ id: integer("id").primaryKey({ autoIncrement: true }), guildId: text("guild_id").notNull(), channelId: text("channel_id"),

@@ -117,3 +118,4 @@ );

export type Ticket = typeof tickets.$inferSelect; export type TicketMessage = typeof ticketMessages.$inferSelect; +export type ticketAttachment = typeof ticketAttachments.$inferSelect;
M src/events/messageCreate.tssrc/events/messageCreate.ts

@@ -1,4 +1,21 @@

-import { type Client, Events, type Message } from "discord.js"; +import { db, ticketAttachments, ticketMessages, tickets } from "@/database"; +import { getPublicUrl, s3 } from "@/utils/s3"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type Client, + Events, + type Message, +} from "discord.js"; +import { and, eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; + +import config from "@/config"; +import { loggers } from "@/utils/logging"; +const logger = loggers.events.child({ name: "messageCreate" }); + +type UploadStatus = "queued" | "uploading" | "done" | "failed"; export default { event: Events.MessageCreate,

@@ -7,6 +24,139 @@ if (message.author.bot) return;

if (message.content.includes("grok is this true")) { if (message.channel.isSendable()) await message.channel.send("yeh"); + } + + const ticket = db + .select() + .from(tickets) + .where( + and( + eq(tickets.channelId, message.channelId), + eq(tickets.guildId, message.guildId ?? ""), + ), + ) + .get(); + + if (!ticket) return; + + try { + const savedMessage = db + .insert(ticketMessages) + .values({ + ticketId: ticket.id, + authorId: message.author.id, + authorType: "user", + content: message.content, + createdAt: message.createdAt, + }) + .returning() + .get(); + + if (message.attachments.size > 0) { + const attachmentsArray = Array.from(message.attachments.values()); + const total = attachmentsArray.length; + + const statusList: { + attachment: (typeof attachmentsArray)[number]; + status: UploadStatus; + url?: string; + }[] = attachmentsArray.map((att) => ({ + attachment: att, + status: "queued", + })); + + const formatSize = (size: number) => { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`; + if (size < 1024 * 1024 * 1024) + return `${(size / (1024 * 1024)).toFixed(2)} MB`; + return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`; + }; + + const renderBoard = () => { + const list = statusList + .map((item) => { + const name = item.attachment.name ?? "unknown"; + const emoji = (() => { + switch (item.status) { + case "done": + return ":white_check_mark:"; + case "uploading": + return ":hourglass_flowing_sand:"; + case "queued": + return ":black_large_square:"; + case "failed": + return ":x:"; + } + })(); + + if (item.status === "done" && item.url) + return `${emoji} [${name}](<${item.url}>) (${formatSize(item.attachment.size)})`; + return `${emoji} ${name} (${formatSize(item.attachment.size)})`; + }) + .join("\n"); + + return list; + }; + + let statusMessage: Message | null = null; + if (message.channel.isSendable()) { + statusMessage = await message.reply({ + content: `**Attachment upload status**\n${renderBoard()}`, + }); + } + + for (let i = 0; i < total; i++) { + const { attachment } = statusList[i]; + if (!statusMessage) continue; + + try { + statusList[i].status = "uploading"; + await statusMessage.edit( + `**Attachment upload status**\n${renderBoard()}`, + ); + + const key = `${ticket.anonymousId}/${savedMessage.id}/${nanoid( + 8, + )}_${attachment.name}`; + const file = s3.file(key); + + const resp = await fetch(attachment.url); + const blob = await resp.blob(); + await file.write(blob); + + const publicUrl = getPublicUrl(key); + await db.insert(ticketAttachments).values({ + id: attachment.id, + ticketId: ticket.id, + messageId: savedMessage.id, + fileName: attachment.name, + contentType: attachment.contentType ?? "application/octet-stream", + size: attachment.size, + bucket: config.data.s3.bucket, + key: key, + url: attachment.url, + }); + + statusList[i].status = "done"; + statusList[i].url = publicUrl; + await statusMessage.edit( + `**Attachment upload status**\n${renderBoard()}`, + ); + } catch (error) { + logger.error( + error, + `Failed to upload attachment ${attachment.name}`, + ); + statusList[i].status = "failed"; + await statusMessage.edit( + `**Attachment upload status**\n${renderBoard()}`, + ); + } + } + } + } catch (error) { + logger.error(error, "Failed to log ticket message"); } }, };
M src/interactions/buttons/tickets/message.tssrc/interactions/buttons/tickets/message.ts

@@ -4,16 +4,25 @@ ButtonBuilder,

ButtonStyle, type ChatInputCommandInteraction, type Client, + ContainerBuilder, + type GuildMember, + LabelBuilder, + ModalBuilder, type ModalSubmitInteraction, SlashCommandBuilder, + TextDisplayBuilder, + TextInputBuilder, + TextInputStyle, } from "discord.js"; -import { db, guild as guildTable, manager as managerTable } from "@/database"; +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 = "message:report_message"; +const MODAL_ID = "ticket-message:message"; const INPUT_ID = "message_content"; const commandData = new SlashCommandBuilder()

@@ -32,48 +41,110 @@ interaction: ChatInputCommandInteraction,

) { if (!interaction.guild || !interaction.member) { await interaction.reply({ - content: "❌ this command can only be used in a server.", + content: "❌ This command can only be used in a server.", flags: ["Ephemeral"], }); return; } - const channel = interaction.options.getChannel("channel"); + const hasPerms = await hasManagerPermissions( + interaction.member as GuildMember, + ); + if (!hasPerms) { + await interaction.reply({ + content: "❌ You don't have permission to use this command.", + flags: ["Ephemeral"], + }); + return; + } + + const targetChannel = interaction.options.getChannel("channel", true); + + // construct & show the modal + const textInput = new LabelBuilder() + .setLabel("Message Content") + .setTextInputComponent( + new TextInputBuilder() + .setCustomId(INPUT_ID) + .setStyle(TextInputStyle.Paragraph) + .setRequired(true), + ); + + await interaction.showModal( + new ModalBuilder() + .setCustomId(`${MODAL_ID}:${targetChannel.id}`) + .setTitle("Ticket Message") + .setLabelComponents(textInput), + ); +} + +async function modalExecute( + _client: Client, + interaction: ModalSubmitInteraction, +) { + if (!interaction.guild || !interaction.member) return; + + const channelId = interaction.customId.split(":")[2]; + + const { data: guildData } = await getGuild(interaction.guild.id); + const channel = interaction.guild.channels.cache.get(channelId); if (!channel) { await interaction.reply({ - content: "❌ Please provide a valid channel.", + content: "❌ The channel specified is invalid.", flags: ["Ephemeral"], }); return; } - if (!("send" in channel)) { + if (!channel.isSendable()) { await interaction.reply({ - content: "❌ This channel does not support sending messages.", + content: "❌ I'm not able to send messages in the specified channel.", flags: ["Ephemeral"], }); return; } - const buttonRow = new ActionRowBuilder<ButtonBuilder>().addComponents( + const message = interaction.fields.getTextInputValue(INPUT_ID); + const formatted = formatMessage(message, interaction); + + const controls = new ActionRowBuilder<ButtonBuilder>().addComponents( new ButtonBuilder() .setCustomId("ticket:create") - .setLabel("Confirm") + .setLabel("Open a Ticket") + .setEmoji("📩") .setStyle(ButtonStyle.Success), ); - await channel.send({ - content: `Ticket message from ${interaction.user.tag}:`, - components: [buttonRow], - }); + const container = new ContainerBuilder() + .addTextDisplayComponents(new TextDisplayBuilder().setContent(formatted)) + .addActionRowComponents(controls); + + const sent = await channel + .send({ components: [container], flags: ["IsComponentsV2"] }) + .catch((err) => { + logger.error({ err, channelId }, "Failed to send ticket message"); + return null; + }); + + if (!sent) { + await interaction.reply({ + content: "❌ Failed to send the message in the specified channel.", + flags: ["Ephemeral"], + }); + return; + } await interaction.reply({ - content: "✅ Ticket message sent.", + content: `✅ Message sent successfully! ${sent.url}`, flags: ["Ephemeral"], + allowedMentions: { + users: [], + }, }); } export default { data: commandData, execute, + modalExecute, };
M src/interactions/buttons/tickets/ticket.tssrc/interactions/buttons/tickets/ticket.ts

@@ -1,13 +1,3 @@

-import { type Ticket, db, guild, tickets } from "@/database"; -import type { ICommand } from "@/types"; -import { formatMessage } from "@/utils/formatting"; -import { - countTickets, - getGuild, - getManagerRoleIds, - getTicket, - getTicketMessage, -} from "@/utils/queries"; import { ActionRowBuilder, ButtonBuilder,

@@ -17,12 +7,9 @@ ChannelType,

type ChatInputCommandInteraction, type Client, ContainerBuilder, - EmbedBuilder, - type Interaction, LabelBuilder, type ModalSubmitInteraction, PermissionFlagsBits, - SectionBuilder, SlashCommandBuilder, SlashCommandSubcommandBuilder, TextDisplayBuilder,

@@ -32,6 +19,28 @@ } 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 { 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")

@@ -48,7 +57,13 @@ )

.addSubcommand( new SlashCommandSubcommandBuilder() .setName("close") - .setDescription("Close the ticket in the current channel"), + .setDescription("Close the ticket in the current channel") + .addStringOption((option) => + option + .setName("reason") + .setDescription("Reason for closing the ticket") + .setRequired(false), + ), ) .addSubcommand( new SlashCommandSubcommandBuilder()

@@ -59,6 +74,13 @@ .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", + ), ); async function getModal(guildId: string) {

@@ -97,6 +119,7 @@

if (!interaction.channelId || !interaction.guildId) { await interaction.reply({ content: "This command can only be used in a server.", + flags: ["Ephemeral"], }); return; }

@@ -109,15 +132,36 @@ }

await interaction.deferReply({ flags: ["Ephemeral"] }); - if (action === "create") await handleCreate(client, interaction); + if (action === "create") { + await handleCreate(client, interaction); + return; + } + + const { data: guild } = await getGuild(interaction.guildId); + const { data: ticket } = await getTicket( + interaction.guildId, + interaction.channelId, + ); - const ticket = await getTicket(interaction.guildId, interaction.channelId); + if (!ticket) { + await interaction.editReply({ + 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); + else if (action === "delete") await handleDelete(client, interaction, ticket); + else if (action === "transcript") + await handleTranscript(client, interaction, ticket); } export async function buttonExecute( client: Client, interaction: ButtonInteraction, ) { + console.log("recd"); await interaction.deferReply({ flags: ["Ephemeral"] }); const [action] = interaction.customId.split(":").slice(1);

@@ -128,9 +172,29 @@ });

return; } - if (action === "create") await handleCreate(client, interaction); + 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.editReply({ + content: "❌ This channel is not a valid ticket.", + }); + return; + } - const ticket = await getTicket(interaction.guildId, interaction.channelId); + if (action === "close") await handleClose(client, interaction, ticket, guild); + else 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); } async function modalExecute(

@@ -159,6 +223,8 @@ await interaction.editReply({

content: "Ticket message updated successfully!", }); } + + // TODO)) show modal for entering a close reason } // ====================================

@@ -171,7 +237,7 @@ ) {

if (!interaction.guild) throw new Error("Guild is not available"); if (!client.user) throw new Error("Client user is not available"); - const { data: guild, exists } = await getGuild(interaction.guild.id); + const { data: guildData, exists } = await getGuild(interaction.guild.id); if (!exists) { await interaction.editReply({ content: "This server hasn't been setup yet.",

@@ -181,14 +247,12 @@ }

const anonymousId = crypto.randomUUID().split("-")[0]; const managerRoleIds = await getManagerRoleIds(interaction.guild.id); - - const ticketCount = await countTickets(guild.guildId); - const paddedTicketCount = (ticketCount + 1).toString().padStart(3, "0"); + const parent = guildData.ticket_category_id ?? undefined; const channel = await interaction.guild.channels.create({ - name: `ticket-${paddedTicketCount}`, + name: `ticket-${anonymousId}`, type: ChannelType.GuildText, - parent: guild.ticket_category_id, + parent, permissionOverwrites: [ { id: interaction.guild.id,

@@ -220,40 +284,32 @@ })),

], }); - const _ticket = ( - await db - .insert(tickets) - .values({ - guildId: interaction.guild.id, - channelId: channel.id, - authorId: interaction.user.id, - anonymousId: anonymousId, - type: "general", - status: "open", - }) - .returning() - )[0]; + 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(guild.ticket_message ?? "", interaction); - - const embed = new EmbedBuilder() - .setColor("#ff5a00") - .setTitle("Ticket created!") - .setDescription(formatMessage(guild.ticket_message ?? "", interaction)) - .setFooter({ text: `ref: ${anonymousId}` }); + const formatted = formatMessage(guildData.ticket_message ?? "", interaction); const controls = new ActionRowBuilder<ButtonBuilder>().addComponents( new ButtonBuilder() .setCustomId("ticket:close") - .setLabel("Close ticket") + .setLabel("Close") .setStyle(ButtonStyle.Danger) - .setEmoji("🔏"), + .setEmoji("🔒"), ); const container = new ContainerBuilder() .setAccentColor(0xff5a00) .addTextDisplayComponents( - new TextDisplayBuilder().setContent("# Ticket Created!"), + new TextDisplayBuilder().setContent("# Ticket created!"), new TextDisplayBuilder().setContent(formatted), ) .addActionRowComponents(controls);

@@ -268,23 +324,221 @@ content: `:white_check_mark: Ticket created ${channel.toString()}`,

}); } -async function handleDelete( +async function handleClose( + client: Client, + interaction: ButtonInteraction | ChatInputCommandInteraction, + ticket: Ticket, + guild: Guild, +) { + 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(), + }) + .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<ButtonBuilder>().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 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(", "); + + try { + await author.send({ + 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}` : ""}`, + }); + } catch (error) { + logger.error( + error, + `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; + } + + try { + const closeString = [ + `Transcript for ticket #${ticket.anonymousId} by <@${ticket.authorId}>.`, + attachments.length > 0 ? `-# Attachments: ${attachmentsString}` : null, + ] + .filter(Boolean) + .join("\n"); + + await transcriptChannel.send({ + content: closeString, + files: [ + { + attachment: Buffer.from(transcriptText, "utf-8"), + name: `transcript-${ticket.anonymousId}.txt`, + }, + ], + }); + } 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, + 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<ButtonBuilder>().addComponents( + new ButtonBuilder() + .setCustomId("ticket:close") + .setLabel("Close") + .setStyle(ButtonStyle.Danger) + .setEmoji("🔒"), + ); + + await channel.send({ + content: `Ticket reopened by <@${interaction.user.id}>`, + components: [controls], + }); -async function handleClose( + await interaction.editReply("✅ Ticket reopened."); +} + +async function handleDelete( _client: Client, - interaction: ButtonInteraction, + 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 handleReopen( +async function handleTranscript( _client: Client, - interaction: ButtonInteraction, + 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,
A src/utils/transcripts.ts

@@ -0,0 +1,66 @@

+import { + type Ticket, + db, + type ticketAttachment, + ticketAttachments, + ticketMessages, +} from "@/database"; +import { getPublicUrl } from "@/utils/s3"; +import { asc, eq } from "drizzle-orm"; + +export async function generateTranscript( + ticket: Ticket, +): Promise<[string, ticketAttachment[]]> { + const messages = await db + .select() + .from(ticketMessages) + .where(eq(ticketMessages.ticketId, ticket.id)) + .orderBy(asc(ticketMessages.createdAt)); + + const attachments = await db + .select() + .from(ticketAttachments) + .where(eq(ticketAttachments.ticketId, ticket.id)); + + const attachmentMap = new Map<number, typeof attachments>(); + for (const att of attachments) { + const existing = attachmentMap.get(att.messageId ?? 0) ?? []; + attachmentMap.set(att.messageId ?? 0, [...existing, att]); + } + + const lines = messages.map((msg) => { + const msgAttachments = attachmentMap.get(msg.id) ?? []; + const time = new Date(msg.createdAt).toISOString(); + + const author = + msg.authorType === "user" ? "reporter" : `<@${msg.authorId}>`; + + let text = `[${time}] ${author}: ${msg.content}`; + + if (msgAttachments.length > 0) { + const attLines = msgAttachments + .map((att) => { + return `\n (Attachment) ${att.fileName}: ${getPublicUrl(att.key)}`; + }) + .join(""); + text += attLines; + } + + return text; + }); + + const separator = "=================================================="; + const header = [ + separator, + ` TRANSCRIPT FOR TICKET #${ticket.anonymousId}`, + ` CREATED : ${new Date(ticket.createdAt).toISOString()}`, + ` CLOSED : ${ticket.closedAt ? new Date(ticket.closedAt).toISOString() : new Date().toISOString()}`, + "", + " Times are displayed in UTC.", + " All attachments are listed with their public URLs.", + separator, + "\n", + ].join("\n"); + + return [header + lines.join("\n"), attachments]; +}