feat: ticket impl
vi v@vt3e.cat
Tue, 17 Feb 2026 14:10:01 +0000
7 files changed,
618 insertions(+),
71 deletions(-)
M
bun.lock
→
bun.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.json
→
package.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.ts
→
src/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.ts
→
src/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"); } }, };
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]; +}