refactor: separate bug command into several files; add /bug button command
willow hai@wlo.moe
Tue, 27 May 2025 23:38:53 +0100
6 files changed,
233 insertions(+),
127 deletions(-)
M
src/handlers/interactions.ts
→
src/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.ts
→
src/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/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;
M
tsconfig.json
→
tsconfig.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 + } }