all repos — stealth-developers @ e203bee07c6cc3a362a63e8cda29683fde77f728

refactor: separate bug command into several files; add /bug button command
willow hai@wlo.moe
Tue, 27 May 2025 23:38:53 +0100
commit

e203bee07c6cc3a362a63e8cda29683fde77f728

parent

5d2dc0c5c82b092d4230ffc2db5404d7cae4af5f

M src/handlers/interactions.tssrc/handlers/interactions.ts

@@ -12,6 +12,8 @@

async function getCommands(): Promise<ICommand[]> { const processFile = async (fileUrl: string) => { const { default: interaction } = await import(fileUrl); + if (!interaction) return; + if (!interaction.data) { logger.info(`${fileUrl} does not have a data property, skipping`); return;
M src/interactions/commands/bug.tssrc/interactions/commands/bug/report.ts

@@ -10,111 +10,32 @@ EmbedBuilder,

type GuildMember, ModalBuilder, type ModalSubmitInteraction, - SlashCommandBuilder, StringSelectMenuBuilder, type StringSelectMenuInteraction, StringSelectMenuOptionBuilder, TextInputBuilder, TextInputStyle, } from "discord.js"; -import config from "../../config.ts"; import { BugModel, - type BugType, GuildModel, getNextBugId, -} from "../../database/schemas.ts"; -import { createUserIfNotExists } from "../../utils/exists.ts"; -import { Logger } from "../../utils/logging.ts"; -import { hasManagerPermissions } from "../../utils/permissions.ts"; - -const logger = new Logger("bug-command"); - -// misc -const PROJECT_MAP = config.data.projects; +} from "../../../database/schemas.ts"; +import { createUserIfNotExists } from "../../../utils/exists.ts"; +import { Logger } from "../../../utils/logging.ts"; +import { hasManagerPermissions } from "../../../utils/permissions.ts"; +import { PROJECT_MAP, getProjectName, updateBugEmbed } from "./shared.ts"; -function getProjectName(value: string): string { - const project = PROJECT_MAP[value as keyof typeof PROJECT_MAP]; - if (!project) return "unknown project"; +const logger = new Logger("bug-report"); - return project.name; +export function getProjectChoices() { + return Object.entries(PROJECT_MAP).map(([key, project]) => ({ + name: project.displayName, + value: key, + })); } -async function updateBugEmbed( - client: Client, - bug: BugType, - messageId: string, - channelId: string, -) { - try { - const channel = await client.channels.fetch(channelId); - if (!channel?.isTextBased() || !("messages" in channel)) return; - - const message = await channel.messages.fetch(messageId); - const projectInfo = PROJECT_MAP[bug.project as keyof typeof PROJECT_MAP]; - - const embed = new EmbedBuilder() - .setAuthor({ - name: message.embeds[0].author?.name || "unknown user", - url: message.embeds[0].author?.url, - iconURL: message.embeds[0].author?.iconURL, - }) - .setTitle(bug.title) - .setColor(bug.status === "closed" ? 0x95a5a6 : 0xff6b6b) - .setDescription(bug.description) - .setFooter({ - text: `${projectInfo.name} • bug #${bug.bug_id} • ${bug.status}`, - }) - .setTimestamp(); - - if (projectInfo.iconURL) embed.setThumbnail(projectInfo.iconURL); - - // disable buttons if closed - const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents( - new ButtonBuilder() - .setCustomId(`bug:close:${bug.bug_id}`) - .setLabel(bug.status === "closed" ? "reopen" : "close") - .setStyle(ButtonStyle.Secondary) - .setEmoji(bug.status === "closed" ? "🔓" : "🔒"), - new ButtonBuilder() - .setCustomId(`bug:edit:${bug.bug_id}`) - .setLabel("edit") - .setStyle(ButtonStyle.Primary) - .setDisabled(bug.status === "closed"), - new ButtonBuilder() - .setCustomId(`bug:delete:${bug.bug_id}`) - .setLabel("delete") - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId("bug:new") - .setLabel("new bug") - .setStyle(ButtonStyle.Success), - ); - - await message.edit({ embeds: [embed], components: [buttons] }); - } catch (error) { - logger.error("failed to update bug embed:", error); - } -} - -// command stuff -const choices = Object.entries(PROJECT_MAP).map(([key, project]) => ({ - name: project.displayName, - value: key, -})); - -const commandData = new SlashCommandBuilder() - .setName("bug") - .setDescription("report a bug") - .addStringOption((option) => - option - .setName("project") - .setDescription("which project this bug affects") - .setRequired(true) - .addChoices(...choices), - ); - -async function execute( +export async function execute( _client: Client, interaction: ChatInputCommandInteraction, ) {

@@ -148,11 +69,10 @@ descriptionInput,

); modal.addComponents(firstRow, secondRow); - await interaction.showModal(modal); } -async function modalExecute( +export async function modalExecute( client: Client, interaction: ModalSubmitInteraction, ) {

@@ -175,17 +95,17 @@

try { const bug = await BugModel.findOne({ bug_id: bugId }); if (!bug) { - return interaction.reply({ + await interaction.reply({ content: "❌ bug not found.", flags: ["Ephemeral"], }); + return; } bug.title = title; bug.description = description; await bug.save(); - // check if msg exists & update embed if (bug.message_id && interaction.guild) { const guild = await GuildModel.findOne({ guild_id: interaction.guild.id,

@@ -329,7 +249,10 @@ });

} } -async function buttonExecute(client: Client, interaction: ButtonInteraction) { +export async function buttonExecute( + client: Client, + interaction: ButtonInteraction, +) { const [, action, bugId] = interaction.customId.split(":"); if (action === "new") {

@@ -348,11 +271,12 @@ const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(

selectMenu, ); - return interaction.reply({ + await interaction.reply({ content: "select a project to report a bug for:", components: [row], flags: ["Ephemeral"], }); + return; } const member = interaction.member as GuildMember;

@@ -360,19 +284,21 @@ const isManager = await hasManagerPermissions(member);

const bug = await BugModel.findOne({ bug_id: Number.parseInt(bugId) }); if (!bug) { - return interaction.reply({ + await interaction.reply({ content: "❌ bug not found.", flags: ["Ephemeral"], }); + return; } const isAuthor = bug.user_id === interaction.user.id; if (!isManager && !isAuthor) { - return interaction.reply({ + await interaction.reply({ content: "❌ you don't have permission to do this.", flags: ["Ephemeral"], }); + return; } switch (action) {

@@ -381,7 +307,6 @@ const newStatus = bug.status === "closed" ? "open" : "closed";

bug.status = newStatus; await bug.save(); - // update the embed if (bug.message_id && interaction.guild) { const guild = await GuildModel.findOne({ guild_id: interaction.guild.id,

@@ -400,10 +325,11 @@ }

case "edit": { if (bug.status === "closed") { - return interaction.reply({ + await interaction.reply({ content: "❌ cannot edit a closed bug.", flags: ["Ephemeral"], }); + return; } const modal = new ModalBuilder()

@@ -445,7 +371,6 @@

case "delete": { bug.deleteOne(); - // delete the message if it exists if (bug.message_id && interaction.guild) { const guild = await GuildModel.findOne({ guild_id: interaction.guild.id,

@@ -472,7 +397,7 @@ }

} } -async function selectMenuExecute( +export async function selectMenuExecute( _client: Client, interaction: StringSelectMenuInteraction, ) {

@@ -510,10 +435,10 @@ modal.addComponents(firstRow, secondRow);

await interaction.showModal(modal); } -export default { - data: commandData, +export const reportCommand = { execute, modalExecute, buttonExecute, selectMenuExecute, + getProjectChoices, };
A src/interactions/commands/bug/button.ts

@@ -0,0 +1,42 @@

+import { + ActionRowBuilder, + type ChatInputCommandInteraction, + type Client, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, +} from "discord.js"; +import { PROJECT_MAP } from "./shared.ts"; + +export async function execute( + _client: Client, + interaction: ChatInputCommandInteraction, +) { + const tgtUser = interaction.options.getUser("user", true); + + const options = Object.entries(PROJECT_MAP).map(([key, project]) => + new StringSelectMenuOptionBuilder() + .setLabel(project.displayName) + .setValue(key), + ); + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId("bug:project-select") + .setPlaceholder("select a project to report a bug") + .addOptions(...options); + + const selectRow = + new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu); + + await interaction.reply({ + content: `📝 <@${tgtUser.id}>, use the dropdown below to select the project + you want to report a bug for. in the future, you can also use the + </bug report:${interaction.commandId}> command to report bugs.` + .replace(/\s+/g, " ") + .trim(), + components: [selectRow], + }); +} + +export const buttonCommand = { + execute, +};
A src/interactions/commands/bug/index.ts

@@ -0,0 +1,57 @@

+import { type ApplicationCommandData, SlashCommandBuilder } from "discord.js"; +import type { ICommand } from "../../../types.ts"; +import { buttonCommand } from "./button.ts"; +import { reportCommand } from "./report.ts"; + +const commandData = new SlashCommandBuilder() + .setName("bug") + .setDescription("bug report management") + .addSubcommand((subcommand) => + subcommand + .setName("report") + .setDescription("report a new bug") + .addStringOption((option) => + option + .setName("project") + .setDescription("which project this bug affects") + .setRequired(true) + .addChoices(...reportCommand.getProjectChoices()), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName("button") + .setDescription("create a button to report bugs") + .addUserOption((option) => + option + .setName("user") + .setDescription("the user to send the button to") + .setRequired(true), + ), + ); + +export default { + data: commandData.toJSON() as ApplicationCommandData, + execute: async (client, interaction) => { + if (!interaction.isChatInputCommand()) return; + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case "report": + await reportCommand.execute(client, interaction); + break; + case "button": + await buttonCommand.execute(client, interaction); + break; + default: + await interaction.reply({ + content: "❌ unknown subcommand.", + flags: ["Ephemeral"], + }); + } + }, + modalExecute: reportCommand.modalExecute, + buttonExecute: reportCommand.buttonExecute, + selectMenuExecute: reportCommand.selectMenuExecute, +} satisfies ICommand;
A src/interactions/commands/bug/shared.ts

@@ -0,0 +1,75 @@

+import type { Client } from "discord.js"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, +} from "discord.js"; +import config from "../../../config.ts"; +import type { BugType } from "../../../database/schemas.ts"; +import { Logger } from "../../../utils/logging.ts"; + +const logger = new Logger("bug-shared"); +export const PROJECT_MAP = config.data.projects; + +export function getProjectName(value: string): string { + const project = PROJECT_MAP[value as keyof typeof PROJECT_MAP]; + if (!project) return "unknown project"; + return project.name; +} + +export async function updateBugEmbed( + client: Client, + bug: BugType, + messageId: string, + channelId: string, +): Promise<void> { + try { + const channel = await client.channels.fetch(channelId); + if (!channel?.isTextBased() || !("messages" in channel)) return; + + const message = await channel.messages.fetch(messageId); + const projectInfo = PROJECT_MAP[bug.project as keyof typeof PROJECT_MAP]; + + const embed = new EmbedBuilder() + .setAuthor({ + name: message.embeds[0].author?.name || "unknown user", + url: message.embeds[0].author?.url, + iconURL: message.embeds[0].author?.iconURL, + }) + .setTitle(bug.title) + .setColor(bug.status === "closed" ? 0x95a5a6 : 0xff6b6b) + .setDescription(bug.description) + .setFooter({ + text: `${projectInfo.name} • bug #${bug.bug_id} • ${bug.status}`, + }) + .setTimestamp(); + + if (projectInfo.iconURL) embed.setThumbnail(projectInfo.iconURL); + + const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder() + .setCustomId(`bug:close:${bug.bug_id}`) + .setLabel(bug.status === "closed" ? "reopen" : "close") + .setStyle(ButtonStyle.Secondary) + .setEmoji(bug.status === "closed" ? "🔓" : "🔒"), + new ButtonBuilder() + .setCustomId(`bug:edit:${bug.bug_id}`) + .setLabel("edit") + .setStyle(ButtonStyle.Primary) + .setDisabled(bug.status === "closed"), + new ButtonBuilder() + .setCustomId(`bug:delete:${bug.bug_id}`) + .setLabel("delete") + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId("bug:new") + .setLabel("new bug") + .setStyle(ButtonStyle.Success), + ); + + await message.edit({ embeds: [embed], components: [buttons] }); + } catch (error) { + logger.error("failed to update bug embed:", error); + } +}
M tsconfig.jsontsconfig.json

@@ -1,27 +1,32 @@

{ - "compilerOptions": { - // Enable latest features - "lib": ["ESNext", "DOM"], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } }