feat(bugs): init implementation
vi v@vt3e.cat
Thu, 26 Feb 2026 04:24:36 +0000
10 files changed,
439 insertions(+),
32 deletions(-)
M
src/config.ts
→
src/config.ts
@@ -9,25 +9,24 @@ token: z.string(),
app_id: z.string(), }); -const projectSchema = z.record( - z.string(), - z.object({ - universe: z.string(), - name: z.string(), - displayName: z.string(), - iconURL: z.string().optional(), - codes: z - .array( - z.object({ - code: z.string(), - expired: z.boolean().optional(), - expiredAt: z.string().optional(), - addedAt: z.string().optional(), - }), - ) - .optional(), - }), -); +const Project = z.object({ + universe: z.string(), + name: z.string(), + displayName: z.string(), + iconURL: z.string().optional(), + codes: z + .array( + z.object({ + code: z.string(), + expired: z.boolean().optional(), + expiredAt: z.string().optional(), + addedAt: z.string().optional(), + }), + ) + .optional(), +}); + +const projectSchema = z.record(z.string(), Project); const s3Schema = z.object({ accessKeyId: z.string(),@@ -62,4 +61,6 @@ logger.error(` ${err.message}: ${err.path}`);
process.exit(1); } -export default { data: validateConfig() }; +export default validateConfig(); + +export type Project = z.infer<typeof Project>;
M
src/events/messageCreate.ts
→
src/events/messageCreate.ts
@@ -134,7 +134,7 @@ ownerId: savedMessage.id,
fileName: attachment.name, contentType: attachment.contentType ?? "application/octet-stream", size: attachment.size, - bucket: config.data.s3.bucket, + bucket: config.s3.bucket, key: key, url: publicUrl, });
M
src/handlers/interactions.ts
→
src/handlers/interactions.ts
@@ -57,7 +57,7 @@ return logger.error("client application is not available");
if (interactions.length === 0) return; logger.info("registering interactions..."); - const rest = new REST({ version: "9" }).setToken(cfg.data.discord.token); + const rest = new REST({ version: "9" }).setToken(cfg.discord.token); try { logger.info("registering commands...");
M
src/index.ts
→
src/index.ts
@@ -4,7 +4,7 @@
import client from "./bot.ts"; async function main() { - await client.login(config.data.discord.token); + await client.login(config.discord.token); } main();
A
src/interactions/commands/bug/index.ts
@@ -0,0 +1,102 @@
+import { + type ButtonInteraction, + type ChatInputCommandInteraction, + type Client, + type ModalSubmitInteraction, + SlashCommandBuilder, +} from "discord.js"; +import { buildReportModal } from "./_shared"; +import { handleNewBugModal } from "./modals"; + +const commandData = new SlashCommandBuilder() + .setName("bug") + .setDescription("Make a bug report") + .addSubcommand((subcommand) => + subcommand.setName("report").setDescription("Report a new bug"), + ) + .addSubcommand((subcommand) => + subcommand + .setName("help") + .setDescription("Help a user report a bug") + .addUserOption((option) => + option + .setName("user") + .setDescription("The user to send the button to") + .setRequired(true), + ), + ); + +async function execute( + _client: Client, + interaction: ChatInputCommandInteraction, +) { + if (!interaction.isChatInputCommand()) return; + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case "report": + await interaction.showModal(buildReportModal()); + break; + case "help": + // await helpCommand.execute(client, interaction); + break; + default: + await interaction.reply({ + content: "❌ Unknown subcommand.", + flags: ["Ephemeral"], + }); + } +} + +async function buttonExecute(client: Client, interaction: ButtonInteraction) { + const [, action] = interaction.customId.split(":"); + + switch (action) { + case "close": + // await handleCloseButton(client, interaction); + break; + case "open": + // await handleOpenButton(client, interaction); + break; + case "edit": + // await handleEditButton(client, interaction); + break; + case "delete": + // await handleDeleteButton(client, interaction); + break; + default: + await interaction.reply({ + content: "❌ Unknown button action.", + flags: ["Ephemeral"], + }); + } +} + +async function modalExecute( + client: Client, + interaction: ModalSubmitInteraction, +) { + const [, action] = interaction.customId.split(":"); + console.log(interaction.customId); + switch (action) { + case "edit": + // await handleEditModal(client, interaction); + break; + case "report": + await handleNewBugModal(client, interaction); + break; + default: + await interaction.reply({ + content: "❌ Unknown modal action.", + flags: ["Ephemeral"], + }); + } +} + +export default { + data: commandData, + buttonExecute, + modalExecute, + execute, +};
A
src/interactions/commands/bug/modals.ts
@@ -0,0 +1,101 @@
+import { bugs, db } from "@/database"; +import { getGuild } from "@/utils/queries"; +import type { Client, ModalSubmitInteraction } from "discord.js"; +import { eq, sql } from "drizzle-orm"; +import { + INPUT_IDS, + MODAL_IDS, + VALIDATION, + constructContainer, +} 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<number>`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(); + + // TODO)) media uploads to s3 + const container = await constructContainer(bug[0], interaction.user.id); + 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; + + // send message + // TODO)) buttons + const message = await channel.send({ + components: [container], + flags: ["IsComponentsV2"], + }); + + // 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}`, + ); +}
M
src/interactions/commands/config.ts
→
src/interactions/commands/config.ts
@@ -104,7 +104,7 @@ });
return; } - const isDev = interaction.user.id === cfg.data.developerId; + const isDev = interaction.user.id === cfg.developerId; const isManager = hasManagerPermissions(interaction.member as GuildMember); if (!(isDev || isManager)) {
M
src/utils/choices.ts
→
src/utils/choices.ts
@@ -1,7 +1,7 @@
import config from "@/config"; export function getProjectChoices() { - return Object.entries(config.data.projects).map(([key, project]) => ({ + return Object.entries(config.projects).map(([key, project]) => ({ name: project.displayName, value: key, }));
M
src/utils/s3.ts
→
src/utils/s3.ts
@@ -2,16 +2,16 @@ import config from "@/config";
import { S3Client } from "bun"; export const s3 = new S3Client({ - accessKeyId: config.data.s3.accessKeyId, - secretAccessKey: config.data.s3.secretAccessKey, - bucket: config.data.s3.bucket, - endpoint: config.data.s3.endpoint, - region: config.data.s3.region, + 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.data.s3.publicUrl) { - return `${config.data.s3.publicUrl}/${key}`; + if (config.s3.publicUrl) { + return `${config.s3.publicUrl}/${key}`; } return s3.file(key).presign({ expiresIn: 3600 });