import { unlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { bugs, db } from "@/database"; import { getGuild } from "@/database/queries"; import { AttachmentBuilder } from "discord.js"; import type { Client, ModalSubmitInteraction } from "discord.js"; import { eq, sql } from "drizzle-orm"; import { INPUT_IDS, MODAL_IDS, VALIDATION, constructContainer, updateBugEmbed, } from "./_shared"; export async function handleNewBugModal( client: Client, interaction: ModalSubmitInteraction, ) { if (!interaction.guildId) return; const title = interaction.fields.getTextInputValue(INPUT_IDS.TITLE); const description = interaction.fields.getTextInputValue( INPUT_IDS.DESCRIPTION, ); const affected = interaction.fields.getStringSelectValues(INPUT_IDS.AFFECTED); const media = interaction.fields.getUploadedFiles(INPUT_IDS.MEDIA); await interaction.deferReply({ flags: ["Ephemeral"] }); const [{ count }] = await db .select({ count: sql`COUNT(*)`.mapWith(Number) }) .from(bugs) .where(eq(bugs.guildId, interaction.guildId)); const bugNumber = count + 1; const paddedNumber = bugNumber.toString().padStart(4, "0"); const bug = await db .insert(bugs) .values({ guildId: interaction.guildId, bugNumber: bugNumber, authorId: interaction.user.id, status: "open", title: title, description: description, projects: [...affected], sent: false, messageId: null, threadId: null, createdAt: new Date(), updatedAt: new Date(), closedAt: null, }) .returning(); const tempFiles: string[] = []; let attachment: AttachmentBuilder | undefined; if (media) { for (const [_, _attachment] of media) { try { const fileUrl = _attachment.url; const fileName = _attachment.name; if (!fileUrl) continue; const res = await fetch(fileUrl); if (!res.ok) continue; const arrayBuffer = await res.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, "_"); const tmpPath = path.join( os.tmpdir(), `${Date.now()}-${Math.random().toString(36).slice(2)}-${safeName}`, ); await writeFile(tmpPath, buffer); tempFiles.push(tmpPath); attachment = new AttachmentBuilder(buffer, { name: fileName }); } catch (err) { console.error("error processing uploaded file:", err); } } } const container = await constructContainer( bug[0], interaction.user.id, attachment, ); if (!container) return; const { data, exists } = await getGuild(interaction.guildId); if (!exists) return; const channelId = data.bug_channel_id; if (!channelId) return; const channel = await interaction.guild?.channels.fetch(channelId); if (!channel || !channel.isSendable()) return; const message = await channel.send({ components: [container], flags: ["IsComponentsV2"], ...(attachment ? { files: [attachment] } : {}), }); for (const filePath of tempFiles) { try { await unlink(filePath); } catch (err) { console.error("error deleting temp file:", err); } } // thread function truncateTitle(title: string, maxLength: number): string { return title.length > maxLength ? `${title.substring(0, maxLength)}...` : title; } const threadName = `#${paddedNumber} ${truncateTitle(title, VALIDATION.THREAD_NAME_MAX_LENGTH)}`; const thread = await message.startThread({ name: threadName, autoArchiveDuration: 1440, }); // save & update user await db .update(bugs) .set({ messageId: message.id, threadId: thread.id, sent: true }) .where(eq(bugs.id, bug[0].id)); thread.send( "Use this space to discuss the bug, provide additional details, or ask questions.", ); interaction.editReply( `Bug #${paddedNumber} created successfully! Find it here: ${message.url}`, ); } export async function handleEditModal( client: Client, interaction: ModalSubmitInteraction, ) { if (!interaction.guildId) return; const parts = interaction.customId.split(":"); const id = parts[2]; if (!id) { await interaction.reply({ content: "❌ Could not determine bug id.", flags: ["Ephemeral"], }); return; } const title = interaction.fields.getTextInputValue(INPUT_IDS.TITLE); const description = interaction.fields.getTextInputValue( INPUT_IDS.DESCRIPTION, ); const affected = interaction.fields.getStringSelectValues(INPUT_IDS.AFFECTED); if ( title.length > VALIDATION.TITLE_MAX_LENGTH || description.length > VALIDATION.DESCRIPTION_MAX_LENGTH ) { await interaction.reply({ content: "❌ Input too long.", flags: ["Ephemeral"], }); return; } const parsedId = Number(id); if (Number.isNaN(parsedId)) { await interaction.reply({ content: "❌ Invalid bug id.", flags: ["Ephemeral"], }); return; } await db .update(bugs) .set({ title, description, projects: [...affected], updatedAt: new Date(), }) .where(eq(bugs.id, parsedId)); const [updated] = await db.select().from(bugs).where(eq(bugs.id, parsedId)); if (!updated) { await interaction.reply({ content: "❌ Failed to fetch updated bug.", flags: ["Ephemeral"], }); return; } const { data, exists } = await getGuild(interaction.guildId); if (!exists) { await interaction.reply({ content: "✅ Bug updated, but guild configuration missing for updating the public message.", flags: ["Ephemeral"], }); return; } const channelId = data.bug_channel_id; if (!channelId) { await interaction.reply({ content: "✅ Bug updated, but no bug channel configured to update message.", flags: ["Ephemeral"], }); return; } if (updated.messageId) { await updateBugEmbed(client, updated, updated.messageId, channelId); } await interaction.reply({ content: `✅ Bug #${updated.bugNumber.toString().padStart(4, "0")} updated.`, flags: ["Ephemeral"], }); }