all repos — stealth-developers @ 06404f4204962483adf1a2e8d42c80fbe1d50390

feat(bugs): init implementation
vi v@vt3e.cat
Thu, 26 Feb 2026 04:24:36 +0000
commit

06404f4204962483adf1a2e8d42c80fbe1d50390

parent

2a969de7ae6d1926b61eea5c14ce8c9971bcd579

M src/config.tssrc/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.tssrc/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.tssrc/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.tssrc/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/_shared.ts

@@ -0,0 +1,203 @@

+import config from "@/config"; +import { type Bug, attachments, db } from "@/database"; +import { + type Client, + ContainerBuilder, + FileUploadBuilder, + LabelBuilder, + ModalBuilder, + SectionBuilder, + type Snowflake, + StringSelectMenuBuilder, + TextDisplayBuilder, + TextInputBuilder, + TextInputStyle, + ThumbnailBuilder, +} from "discord.js"; +import { and, eq } from "drizzle-orm"; + +// -- types ---------------------------------------------------------------------------------------- +type ProjectChoice<T extends "name" | "label" = "name"> = T extends "name" + ? { name: string; value: string } + : { label: string; value: string; default: boolean }; + +// -- constants ------------------------------------------------------------------------------------ +export const PROJECT_MAP = config.projects; + +export const INPUT_IDS = { + AFFECTED: "affectedInput", + TITLE: "titleInput", + DESCRIPTION: "descriptionInput", + MEDIA: "fileUploadInput", +} as const; +export const MODAL_IDS = { + REPORT: "bug:report", + EDIT: "bug:edit", +} as const; +export const VALIDATION = { + TITLE_MAX_LENGTH: 100, + DESCRIPTION_MAX_LENGTH: 1000, + THREAD_NAME_MAX_LENGTH: 50, + AUTO_ARCHIVE_DURATION: 1440, +} as const; + +// -- local helpres -------------------------------------------------------------------------------- +export function getProjectChoices<T extends "name" | "label" = "name">( + label: T = "name" as T, + defaultProjectKey?: string, +): ProjectChoice<T>[] { + return Object.entries(PROJECT_MAP).map(([key, project]) => + label === "name" + ? { name: project.displayName, value: key } + : { + label: project.displayName, + value: key, + default: key === defaultProjectKey, + }, + ) as ProjectChoice<T>[]; +} + +// -- main ----------------------------------------------------------------------------------------- +export async function constructContainer( + bug: Bug, + userId: Snowflake, +): Promise<ContainerBuilder | undefined> { + const project = PROJECT_MAP[bug.projects[0]]; + if (!project) return undefined; + + const text = { + body: new TextDisplayBuilder().setContent( + [`### ${bug.title}`, `${bug.description}`].join("\n"), + ), + footer: { + text: new TextDisplayBuilder().setContent( + [`-# #${bug.id}`, project.displayName, `Reported by <@${userId}>`].join( + " • ", + ), + ), + icon: project.iconURL + ? new ThumbnailBuilder().setURL(project.iconURL) + : undefined, + }, + }; + + const section = new SectionBuilder(); + if (text.footer.icon) section.setThumbnailAccessory(text.footer.icon); + section.addTextDisplayComponents(text.body); + + const media = await db + .select() + .from(attachments) + .where( + and(eq(attachments.ownerId, bug.id), eq(attachments.ownerType, "bug")), + ); + const urls = media.map((attachment) => attachment.url); + + section.addTextDisplayComponents(text.footer.text); + const container = new ContainerBuilder().addSectionComponents(section); + return container; +} + +export function buildReportModal(bugId?: string, bug?: Bug) { + const terminology = config.terminology; + const capitalizedTerminology = + terminology.charAt(0).toUpperCase() + terminology.slice(1); + + const projectOptions = bug + ? getProjectChoices("label", bug.projects[0]) + : getProjectChoices("label"); + const selectedProject = bug + ? projectOptions.find((opt) => opt.value === bug.projects[0]) + : undefined; + + const gameLabel = new LabelBuilder() + .setLabel(capitalizedTerminology) + .setDescription(`The ${terminology} affected by this bug`) + .setStringSelectMenuComponent( + new StringSelectMenuBuilder() + .setMaxValues(1) + .setMinValues(1) + .setCustomId(INPUT_IDS.AFFECTED) + .setPlaceholder(selectedProject?.label || `Select the ${terminology}`) + .addOptions(projectOptions), + ); + + const titleLabel = new LabelBuilder() + .setLabel("Bug Title") + .setDescription(bug ? "Update the bug title" : "A short summary of the bug") + .setTextInputComponent( + (() => { + const input = new TextInputBuilder() + .setCustomId(INPUT_IDS.TITLE) + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setMaxLength(VALIDATION.TITLE_MAX_LENGTH); + if (bug) input.setValue(bug.title); + return input; + })(), + ); + + const descriptionLabel = new LabelBuilder() + .setLabel("Bug Description") + .setDescription( + bug ? "Update the bug description" : "Describe the bug in detail", + ) + .setTextInputComponent( + (() => { + const input = new TextInputBuilder() + .setCustomId(INPUT_IDS.DESCRIPTION) + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setMaxLength(VALIDATION.DESCRIPTION_MAX_LENGTH); + if (bug) input.setValue(bug.description); + return input; + })(), + ); + + const modal = new ModalBuilder() + .setCustomId(bug ? `${MODAL_IDS.EDIT}:${bugId}` : `${MODAL_IDS.REPORT}:NEW`) + .setTitle(bug ? `Edit Bug #${bugId}` : "Report a Bug"); + + if (bug) { + modal.setLabelComponents(gameLabel, titleLabel, descriptionLabel); + } else { + const mediaLabel = new LabelBuilder() + .setLabel("Media") + .setDescription( + "Provide any screenshots or videos that demonstrate the bug", + ) + .setFileUploadComponent( + new FileUploadBuilder().setCustomId(INPUT_IDS.MEDIA).setRequired(false), + ); + modal.setLabelComponents( + gameLabel, + titleLabel, + descriptionLabel, + mediaLabel, + ); + } + + return modal; +} + +export async function updateBugEmbed( + client: Client, + bug: Bug, + messageId: Snowflake, + channelId: Snowflake, +) { + const channel = await client.channels.fetch(channelId); + if (!channel || !channel.isTextBased() || !channel.isSendable()) return; + + const message = await channel.messages.fetch(messageId); + const project = PROJECT_MAP[bug.projects[0]]; + if (!project) return; + + const container = await constructContainer(bug, message.author.id); + if (!container) return; + + await message.edit({ + components: [container], + flags: ["IsComponentsV2"], + }); +}
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.tssrc/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.tssrc/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.tssrc/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 });