bot: log messages in ticket channels
vi did:web:vt3e.cat
Sun, 28 Jun 2026 01:19:21 +0100
4 files changed,
241 insertions(+),
1 deletions(-)
M
apps/bot/src/feats/tickets/README.md
→
apps/bot/src/feats/tickets/README.md
@@ -30,5 +30,5 @@
- [ ] other ticket stuff - [x] permissions - [ ] add participants - - [ ] log messages + - [x] log messages - [ ] private thread too
M
apps/bot/src/feats/tickets/index.ts
→
apps/bot/src/feats/tickets/index.ts
@@ -4,3 +4,4 @@ import { TicketModals } from "./modals";
export const ticketCommands = [ticketCommand]; export const ticketComponents = [...TicketModals, ...TicketButtons]; +export * from "./listeners";
A
apps/bot/src/feats/tickets/listeners.ts
@@ -0,0 +1,207 @@
+import type { Message } from "discord.js"; + +import { Result } from "@sapphire/framework"; +import { + createTicketMessageWithAttachments, + deleteTicketMessage, + editTicketMessage, + getTicketByChannelId, + getTicketMessageByMessageId, +} from "@stealth-developers/db"; +import { type Attachment, ChannelType, Events } from "discord.js"; +import { randomUUID } from "node:crypto"; + +import { client } from "@/client"; +import { logger, upsertUser } from "@/lib"; +import { getPublicUrl, s3 } from "@/lib/s3"; + +type UploadStatus = "pending" | "uploading" | "success" | "failed"; +type AttachmentStatus = { + attachment: Attachment; + fileName: string; + status: UploadStatus; + uploadedUrl?: string; +}; + +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 = (statusList: AttachmentStatus[]) => { + const list = statusList + .map((item) => { + const name = item.fileName ?? "unknown"; + const emoji = (() => { + switch (item.status) { + case "success": + return ":white_check_mark:"; + case "uploading": + return ":hourglass_flowing_sand:"; + case "pending": + return ":black_large_square:"; + case "failed": + return ":x:"; + } + })(); + + if (item.status === "success" && item.uploadedUrl) + return `${emoji} [${name}](<${item.uploadedUrl}>) (${formatSize(item.attachment.size)})`; + return `${emoji} ${name} (${formatSize(item.attachment.size)})`; + }) + .join("\n"); + + return list; +}; + +client.on(Events.MessageCreate, async (message) => { + if (message.channel.type !== ChannelType.GuildText) return; + if (!message.channel.name.match(/\d+$/)) return; + if (!message.guild) return; + + const ticket = await getTicketByChannelId(message.channel.id); + if (!ticket) return; + + const authorDb = await upsertUser(message.author, message.guild); + if (!authorDb) return; + + const isModerator = authorDb.moderator; + const isBot = message.author.bot; + const isSubject = authorDb?.discordId === ticket.subjectActor?.discordId; + const role = isModerator ? "moderator" : isBot ? "bot" : isSubject ? "subject" : "participant"; + + const subjectDiscordId = ticket.subjectActor?.discordId; + if (!subjectDiscordId) return; + + const anonymisedContent = message.content.replace(new RegExp(subjectDiscordId, "g"), "Reporter"); + + const attachments = Array.from(message.attachments.values()); + const total = attachments.length; + + const attachmentStatuses: AttachmentStatus[] = attachments.map((attachment) => ({ + attachment, + status: "pending", + fileName: `${randomUUID()}.${attachment.name.split(".").pop()}`, + })); + + const messageId = randomUUID(); + let statusMessage: Message | undefined; + + if (attachmentStatuses.length) { + statusMessage = await message.reply({ + content: `Attachments uploading...\n${renderBoard(attachmentStatuses)}`, + allowedMentions: { parse: [] }, + }); + + for (let i = 0; i < total; i++) { + const attachmentStatus = attachmentStatuses[i]!; + const { attachment, fileName } = attachmentStatus; + + attachmentStatuses[i]!.status = "uploading"; + + const key = `${ticket.id}/${messageId}/${fileName}`; + const publicUrl = getPublicUrl(key); + const file = s3.file(key); + + const attachmentRes = await Result.fromAsync(fetch(attachment.url)); + if (attachmentRes.isErr()) { + attachmentStatuses[i]!.status = "failed"; + statusMessage = await statusMessage.edit({ + content: `Attachments uploading...\n${renderBoard(attachmentStatuses)}`, + }); + logger.error(attachmentRes.unwrapErr(), `Failed to download attachment ${i + 1}/${total}`); + continue; + } + + const attachmentOk = attachmentRes.unwrap(); + const blob = await attachmentOk.blob(); + + const fileRes = await Result.fromAsync(file.write(blob)); + if (fileRes.isErr()) { + attachmentStatuses[i]!.status = "failed"; + await statusMessage.edit({ + content: `Attachments uploading...\n${renderBoard(attachmentStatuses)}`, + }); + logger.error(fileRes.unwrapErr(), `Failed to upload attachment ${i + 1}/${total}`); + continue; + } + + attachmentStatuses[i]!.status = "success"; + attachmentStatuses[i]!.uploadedUrl = publicUrl; + + statusMessage = await statusMessage.edit({ + content: `Attachments uploading...\n${renderBoard(attachmentStatuses)}`, + }); + } + + statusMessage = await statusMessage.edit({ + content: `Attachments uploaded successfully.\n${renderBoard(attachmentStatuses)}`, + }); + } + + const messageResult = await Result.fromAsync( + await createTicketMessageWithAttachments({ + id: messageId, + ticketId: ticket.id, + actorId: authorDb.id, + messageId: message.id, + content: anonymisedContent, + actorRole: role, + attachments: attachmentStatuses.map((status) => ({ + id: status.uploadedUrl!, + fileName: status.fileName, + fileType: status.attachment.contentType || status.fileName.split(".").pop()!, + fileSize: status.attachment.size, + })), + }), + ); + + if (messageResult.isErr()) { + logger.error(messageResult.unwrapErr(), `Failed to create ticket message with attachments`); + if (statusMessage) { + statusMessage.edit({ + content: `Failed to create ticket message with attachments.`, + }); + } else { + statusMessage = await message.channel.send( + `Failed to create ticket message with attachments.`, + ); + } + + return; + } +}); + +client.on(Events.MessageDelete, async (message) => { + if (message.channel.type !== ChannelType.GuildText) return; + if (!message.channel.name.match(/\d+$/)) return; + if (!message.guild) return; + + const dbMessage = await getTicketMessageByMessageId(message.id); + if (!dbMessage) return; + + await deleteTicketMessage(dbMessage.id); +}); + +client.on(Events.MessageUpdate, async (_, newMessage) => { + if (newMessage.channel.type !== ChannelType.GuildText) return; + if (!newMessage.channel.name.match(/\d+$/)) return; + if (!newMessage.guild) return; + + const dbMessage = await getTicketMessageByMessageId(newMessage.id); + if (!dbMessage) return; + + const ticket = await getTicketByChannelId(newMessage.channel.id); + if (!ticket) return; + + const subjectDiscordId = ticket.subjectActor?.discordId || "dummycontentwwawawa"; + + const anonymisedContent = newMessage.content.replace( + new RegExp(subjectDiscordId, "g"), + "Reporter", + ); + + await editTicketMessage(dbMessage.id, anonymisedContent); +});
A
apps/bot/src/lib/s3.ts
@@ -0,0 +1,32 @@
+import config from "@stealth-developers/config"; +import { S3Client } from "bun"; + +export const s3 = new S3Client({ + accessKeyId: config.s3.accessKeyId, + secretAccessKey: config.s3.secretAccessKey, + bucket: config.s3.bucket, + endpoint: config.s3.endpoint, + region: config.s3.region, +}); + +export function getPublicUrl(key: string) { + if (config.s3.publicUrl) { + return `${config.s3.publicUrl}/${key}`; + } + + return s3.file(key).presign({ expiresIn: 3600 }); +} + +export type PresignOptions = { + expiresIn?: number; + method?: "GET" | "PUT" | "POST" | "DELETE"; + headers?: Record<string, string>; +}; + +export function getPresignedUrl(key: string, opts: PresignOptions = {}) { + const { expiresIn = 3600, method, headers } = opts; + const presignOpts: any = { expiresIn }; + if (method) presignOpts.method = method; + if (headers) presignOpts.headers = headers; + return s3.file(key).presign(presignOpts); +}