From 141c3098f8c5e013df5fdc0bbe3bcc202d91a7af Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 13 Feb 2026 14:23:37 +0100 Subject: [PATCH] feat: standardize command error handling (Sprint 4) - Create withCommandErrorHandling utility in bot/lib/commandUtils.ts - Migrate economy commands: daily, exam, pay, trivia - Migrate inventory command: use - Migrate admin/moderation commands: warn, case, cases, clearwarning, warnings, note, notes, create_color, listing, webhook, refresh, terminal, featureflags, settings, prune - Add 9 unit tests for the utility - Update AGENTS.md with new recommended error handling pattern --- AGENTS.md | 43 ++++-- bot/commands/admin/case.ts | 57 ++++--- bot/commands/admin/cases.ts | 49 +++--- bot/commands/admin/clearwarning.ts | 101 ++++++------ bot/commands/admin/create_color.ts | 103 ++++++------- bot/commands/admin/featureflags.ts | 130 ++++++++-------- bot/commands/admin/listing.ts | 121 +++++++-------- bot/commands/admin/note.ts | 59 ++++--- bot/commands/admin/notes.ts | 43 +++--- bot/commands/admin/prune.ts | 239 ++++++++++++++--------------- bot/commands/admin/refresh.ts | 38 ++--- bot/commands/admin/settings.ts | 50 +++--- bot/commands/admin/terminal.ts | 20 +-- bot/commands/admin/warn.ts | 112 +++++++------- bot/commands/admin/warnings.ts | 35 ++--- bot/commands/admin/webhook.ts | 64 ++++---- bot/commands/economy/daily.ts | 36 ++--- bot/commands/economy/exam.ts | 103 ++++++------- bot/commands/economy/pay.ts | 22 +-- bot/commands/economy/trivia.ts | 93 +++++------ bot/commands/inventory/use.ts | 80 +++++----- bot/lib/commandUtils.test.ts | 147 ++++++++++++++++++ bot/lib/commandUtils.ts | 79 ++++++++++ 23 files changed, 990 insertions(+), 834 deletions(-) create mode 100644 bot/lib/commandUtils.test.ts create mode 100644 bot/lib/commandUtils.ts diff --git a/AGENTS.md b/AGENTS.md index 9b81057..3c5f6b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,22 +141,36 @@ throw new UserError("You don't have enough coins!"); throw new SystemError("Database connection failed"); ``` -### Standard Error Pattern +### Recommended: `withCommandErrorHandling` + +Use the `withCommandErrorHandling` utility from `@lib/commandUtils` to standardize +error handling across all commands. It handles `deferReply`, `UserError` display, +and unexpected error logging automatically. ```typescript -try { - const result = await service.method(); - await interaction.editReply({ embeds: [createSuccessEmbed(result)] }); -} catch (error) { - if (error instanceof UserError) { - await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); - } else { - console.error("Unexpected error:", error); - await interaction.editReply({ - embeds: [createErrorEmbed("An unexpected error occurred.")], - }); - } -} +import { withCommandErrorHandling } from "@lib/commandUtils"; + +export const myCommand = createCommand({ + data: new SlashCommandBuilder() + .setName("mycommand") + .setDescription("Does something"), + execute: async (interaction) => { + await withCommandErrorHandling( + interaction, + async () => { + const result = await service.method(); + await interaction.editReply({ embeds: [createSuccessEmbed(result)] }); + }, + { ephemeral: true } // optional: makes the deferred reply ephemeral + ); + }, +}); +``` + +Options: +- `ephemeral` — whether `deferReply` should be ephemeral +- `successMessage` — a simple string to send on success +- `onSuccess` — a callback invoked with the operation result ``` ## Database Patterns @@ -240,3 +254,4 @@ describe("serviceName", () => { | Environment | `shared/lib/env.ts` | | Embed helpers | `bot/lib/embeds.ts` | | Command utils | `shared/lib/utils.ts` | +| Error handler | `bot/lib/commandUtils.ts` | diff --git a/bot/commands/admin/case.ts b/bot/commands/admin/case.ts index 9d6e544..4ad7f94 100644 --- a/bot/commands/admin/case.ts +++ b/bot/commands/admin/case.ts @@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { moderationService } from "@shared/modules/moderation/moderation.service"; import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const moderationCase = createCommand({ data: new SlashCommandBuilder() @@ -16,39 +17,35 @@ export const moderationCase = createCommand({ .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await withCommandErrorHandling( + interaction, + async () => { + const caseId = interaction.options.getString("case_id", true).toUpperCase(); - try { - const caseId = interaction.options.getString("case_id", true).toUpperCase(); + // Validate case ID format + if (!caseId.match(/^CASE-\d+$/)) { + await interaction.editReply({ + embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")] + }); + return; + } - // Validate case ID format - if (!caseId.match(/^CASE-\d+$/)) { + // Get the case + const moderationCase = await moderationService.getCaseById(caseId); + + if (!moderationCase) { + await interaction.editReply({ + embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)] + }); + return; + } + + // Display the case await interaction.editReply({ - embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")] + embeds: [getCaseEmbed(moderationCase)] }); - return; - } - - // Get the case - const moderationCase = await moderationService.getCaseById(caseId); - - if (!moderationCase) { - await interaction.editReply({ - embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)] - }); - return; - } - - // Display the case - await interaction.editReply({ - embeds: [getCaseEmbed(moderationCase)] - }); - - } catch (error) { - console.error("Case command error:", error); - await interaction.editReply({ - embeds: [getModerationErrorEmbed("An error occurred while fetching the case.")] - }); - } + }, + { ephemeral: true } + ); } }); diff --git a/bot/commands/admin/cases.ts b/bot/commands/admin/cases.ts index 0eadb4d..a348dfd 100644 --- a/bot/commands/admin/cases.ts +++ b/bot/commands/admin/cases.ts @@ -1,7 +1,8 @@ import { createCommand } from "@shared/lib/utils"; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; +import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js"; import { moderationService } from "@shared/modules/moderation/moderation.service"; -import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; +import { getCasesListEmbed } from "@/modules/moderation/moderation.view"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const cases = createCommand({ data: new SlashCommandBuilder() @@ -22,33 +23,29 @@ export const cases = createCommand({ .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await withCommandErrorHandling( + interaction, + async () => { + const targetUser = interaction.options.getUser("user", true); + const activeOnly = interaction.options.getBoolean("active_only") || false; - try { - const targetUser = interaction.options.getUser("user", true); - const activeOnly = interaction.options.getBoolean("active_only") || false; + // Get cases for the user + const userCases = await moderationService.getUserCases(targetUser.id, activeOnly); - // Get cases for the user - const userCases = await moderationService.getUserCases(targetUser.id, activeOnly); + const title = activeOnly + ? `⚠️ Active Cases for ${targetUser.username}` + : `📋 All Cases for ${targetUser.username}`; - const title = activeOnly - ? `⚠️ Active Cases for ${targetUser.username}` - : `📋 All Cases for ${targetUser.username}`; + const description = userCases.length === 0 + ? undefined + : `Total cases: **${userCases.length}**`; - const description = userCases.length === 0 - ? undefined - : `Total cases: **${userCases.length}**`; - - // Display the cases - await interaction.editReply({ - embeds: [getCasesListEmbed(userCases, title, description)] - }); - - } catch (error) { - console.error("Cases command error:", error); - await interaction.editReply({ - embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")] - }); - } + // Display the cases + await interaction.editReply({ + embeds: [getCasesListEmbed(userCases, title, description)] + }); + }, + { ephemeral: true } + ); } }); diff --git a/bot/commands/admin/clearwarning.ts b/bot/commands/admin/clearwarning.ts index 0df875b..64e24a1 100644 --- a/bot/commands/admin/clearwarning.ts +++ b/bot/commands/admin/clearwarning.ts @@ -1,7 +1,8 @@ import { createCommand } from "@shared/lib/utils"; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; +import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js"; import { moderationService } from "@shared/modules/moderation/moderation.service"; import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const clearwarning = createCommand({ data: new SlashCommandBuilder() @@ -23,62 +24,58 @@ export const clearwarning = createCommand({ .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await withCommandErrorHandling( + interaction, + async () => { + const caseId = interaction.options.getString("case_id", true).toUpperCase(); + const reason = interaction.options.getString("reason") || "Cleared by moderator"; - try { - const caseId = interaction.options.getString("case_id", true).toUpperCase(); - const reason = interaction.options.getString("reason") || "Cleared by moderator"; + // Validate case ID format + if (!caseId.match(/^CASE-\d+$/)) { + await interaction.editReply({ + embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")] + }); + return; + } - // Validate case ID format - if (!caseId.match(/^CASE-\d+$/)) { - await interaction.editReply({ - embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")] + // Check if case exists and is active + const existingCase = await moderationService.getCaseById(caseId); + + if (!existingCase) { + await interaction.editReply({ + embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)] + }); + return; + } + + if (!existingCase.active) { + await interaction.editReply({ + embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)] + }); + return; + } + + if (existingCase.type !== 'warn') { + await interaction.editReply({ + embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)] + }); + return; + } + + // Clear the warning + await moderationService.clearCase({ + caseId, + clearedBy: interaction.user.id, + clearedByName: interaction.user.username, + reason }); - return; - } - // Check if case exists and is active - const existingCase = await moderationService.getCaseById(caseId); - - if (!existingCase) { + // Send success message await interaction.editReply({ - embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)] + embeds: [getClearSuccessEmbed(caseId)] }); - return; - } - - if (!existingCase.active) { - await interaction.editReply({ - embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)] - }); - return; - } - - if (existingCase.type !== 'warn') { - await interaction.editReply({ - embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)] - }); - return; - } - - // Clear the warning - await moderationService.clearCase({ - caseId, - clearedBy: interaction.user.id, - clearedByName: interaction.user.username, - reason - }); - - // Send success message - await interaction.editReply({ - embeds: [getClearSuccessEmbed(caseId)] - }); - - } catch (error) { - console.error("Clear warning command error:", error); - await interaction.editReply({ - embeds: [getModerationErrorEmbed("An error occurred while clearing the warning.")] - }); - } + }, + { ephemeral: true } + ); } }); diff --git a/bot/commands/admin/create_color.ts b/bot/commands/admin/create_color.ts index 493c779..206b882 100644 --- a/bot/commands/admin/create_color.ts +++ b/bot/commands/admin/create_color.ts @@ -1,10 +1,11 @@ import { createCommand } from "@shared/lib/utils"; -import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js"; +import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js"; import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config"; import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service"; import { DrizzleClient } from "@shared/db/DrizzleClient"; import { items } from "@db/schema"; import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const createColor = createCommand({ data: new SlashCommandBuilder() @@ -32,62 +33,60 @@ export const createColor = createCommand({ ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { - await interaction.deferReply(); + await withCommandErrorHandling( + interaction, + async () => { + const name = interaction.options.getString("name", true); + const colorInput = interaction.options.getString("color", true); + const price = interaction.options.getNumber("price") || 500; + const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png"; - const name = interaction.options.getString("name", true); - const colorInput = interaction.options.getString("color", true); - const price = interaction.options.getNumber("price") || 500; - const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png"; + // 1. Validate Color + const colorRegex = /^#([0-9A-F]{3}){1,2}$/i; + if (!colorRegex.test(colorInput)) { + await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] }); + return; + } - // 1. Validate Color - const colorRegex = /^#([0-9A-F]{3}){1,2}$/i; - if (!colorRegex.test(colorInput)) { - await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] }); - return; - } + // 2. Create Role + const role = await interaction.guild?.roles.create({ + name: name, + color: colorInput as any, + reason: `Created via /createcolor by ${interaction.user.tag}` + }); - try { - // 2. Create Role - const role = await interaction.guild?.roles.create({ - name: name, - color: colorInput as any, - reason: `Created via /createcolor by ${interaction.user.tag}` - }); + if (!role) { + throw new Error("Failed to create role."); + } - if (!role) { - throw new Error("Failed to create role."); - } + // 3. Add to guild settings + await guildSettingsService.addColorRole(interaction.guildId!, role.id); + invalidateGuildConfigCache(interaction.guildId!); - // 3. Add to guild settings - await guildSettingsService.addColorRole(interaction.guildId!, role.id); - invalidateGuildConfigCache(interaction.guildId!); + // 4. Create Item + await DrizzleClient.insert(items).values({ + name: `Color Role - ${name}`, + description: `Use this item to apply the ${name} color to your name.`, + type: "CONSUMABLE", + rarity: "Common", + price: BigInt(price), + iconUrl: "", + imageUrl: imageUrl, + usageData: { + consume: false, + effects: [{ type: "COLOR_ROLE", roleId: role.id }] + } as any + }); - // 4. Create Item - await DrizzleClient.insert(items).values({ - name: `Color Role - ${name}`, - description: `Use this item to apply the ${name} color to your name.`, - type: "CONSUMABLE", - rarity: "Common", - price: BigInt(price), - iconUrl: "", - imageUrl: imageUrl, - usageData: { - consume: false, - effects: [{ type: "COLOR_ROLE", roleId: role.id }] - } as any - }); - - // 5. Success - await interaction.editReply({ - embeds: [createSuccessEmbed( - `**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`, - "✅ Color Role & Item Created" - )] - }); - - } catch (error: any) { - console.error("Error in createcolor:", error); - await interaction.editReply({ embeds: [createErrorEmbed(`Failed to create color role: ${error.message}`)] }); - } + // 5. Success + await interaction.editReply({ + embeds: [createSuccessEmbed( + `**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`, + "✅ Color Role & Item Created" + )] + }); + }, + { ephemeral: true } + ); } }); diff --git a/bot/commands/admin/featureflags.ts b/bot/commands/admin/featureflags.ts index a5c85fc..9246357 100644 --- a/bot/commands/admin/featureflags.ts +++ b/bot/commands/admin/featureflags.ts @@ -2,7 +2,7 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js"; import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service"; import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; -import { UserError } from "@shared/lib/errors"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const featureflags = createCommand({ data: new SlashCommandBuilder() @@ -98,57 +98,53 @@ export const featureflags = createCommand({ ), autocomplete: async (interaction) => { const focused = interaction.options.getFocused(true); - + if (focused.name === "name") { const flags = await featureFlagsService.listFlags(); const filtered = flags .filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase())) .slice(0, 25); - + await interaction.respond( filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name })) ); } }, execute: async (interaction) => { - await interaction.deferReply(); + await withCommandErrorHandling( + interaction, + async () => { + const subcommand = interaction.options.getSubcommand(); - const subcommand = interaction.options.getSubcommand(); - - try { - switch (subcommand) { - case "list": - await handleList(interaction); - break; - case "create": - await handleCreate(interaction); - break; - case "delete": - await handleDelete(interaction); - break; - case "enable": - await handleEnable(interaction); - break; - case "disable": - await handleDisable(interaction); - break; - case "grant": - await handleGrant(interaction); - break; - case "revoke": - await handleRevoke(interaction); - break; - case "access": - await handleAccess(interaction); - break; - } - } catch (error) { - if (error instanceof UserError) { - await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); - } else { - throw error; - } - } + switch (subcommand) { + case "list": + await handleList(interaction); + break; + case "create": + await handleCreate(interaction); + break; + case "delete": + await handleDelete(interaction); + break; + case "enable": + await handleEnable(interaction); + break; + case "disable": + await handleDisable(interaction); + break; + case "grant": + await handleGrant(interaction); + break; + case "revoke": + await handleRevoke(interaction); + break; + case "access": + await handleAccess(interaction); + break; + } + }, + { ephemeral: true } + ); }, }); @@ -177,44 +173,44 @@ async function handleCreate(interaction: ChatInputCommandInteraction) { const description = interaction.options.getString("description"); const flag = await featureFlagsService.createFlag(name, description ?? undefined); - + if (!flag) { await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] }); return; } - - await interaction.editReply({ - embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)] + + await interaction.editReply({ + embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)] }); } async function handleDelete(interaction: ChatInputCommandInteraction) { const name = interaction.options.getString("name", true); - + const flag = await featureFlagsService.deleteFlag(name); - - await interaction.editReply({ - embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)] + + await interaction.editReply({ + embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)] }); } async function handleEnable(interaction: ChatInputCommandInteraction) { const name = interaction.options.getString("name", true); - + const flag = await featureFlagsService.setFlagEnabled(name, true); - - await interaction.editReply({ - embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)] + + await interaction.editReply({ + embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)] }); } async function handleDisable(interaction: ChatInputCommandInteraction) { const name = interaction.options.getString("name", true); - + const flag = await featureFlagsService.setFlagEnabled(name, false); - - await interaction.editReply({ - embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)] + + await interaction.editReply({ + embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)] }); } @@ -224,8 +220,8 @@ async function handleGrant(interaction: ChatInputCommandInteraction) { const role = interaction.options.getRole("role"); if (!user && !role) { - await interaction.editReply({ - embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")] + await interaction.editReply({ + embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")] }); return; } @@ -250,29 +246,29 @@ async function handleGrant(interaction: ChatInputCommandInteraction) { target = "Unknown"; } - await interaction.editReply({ - embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)] + await interaction.editReply({ + embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)] }); } async function handleRevoke(interaction: ChatInputCommandInteraction) { const id = interaction.options.getInteger("id", true); - + const access = await featureFlagsService.revokeAccess(id); - - await interaction.editReply({ - embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)] + + await interaction.editReply({ + embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)] }); } async function handleAccess(interaction: ChatInputCommandInteraction) { const name = interaction.options.getString("name", true); - + const accessRecords = await featureFlagsService.listAccess(name); if (accessRecords.length === 0) { - await interaction.editReply({ - embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)] + await interaction.editReply({ + embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)] }); return; } diff --git a/bot/commands/admin/listing.ts b/bot/commands/admin/listing.ts index dda9567..87441f8 100644 --- a/bot/commands/admin/listing.ts +++ b/bot/commands/admin/listing.ts @@ -7,12 +7,12 @@ import { } from "discord.js"; import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { createErrorEmbed } from "@lib/embeds"; -import { UserError } from "@shared/lib/errors"; import { items } from "@db/schema"; import { ilike, isNotNull, and, inArray } from "drizzle-orm"; import { DrizzleClient } from "@shared/db/DrizzleClient"; import { getShopListingMessage } from "@/modules/economy/shop.view"; import { EffectType, LootType } from "@shared/lib/constants"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const listing = createCommand({ data: new SlashCommandBuilder() @@ -31,72 +31,67 @@ export const listing = createCommand({ ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await withCommandErrorHandling( + interaction, + async () => { + const itemId = interaction.options.getNumber("item", true); + const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel; - const itemId = interaction.options.getNumber("item", true); - const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel; - - if (!targetChannel || !targetChannel.isSendable()) { - await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] }); - return; - } - - const item = await inventoryService.getItem(itemId); - if (!item) { - await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] }); - return; - } - - if (!item.price) { - await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] }); - return; - } - - // Prepare context for lootboxes - const context: { referencedItems: Map } = { referencedItems: new Map() }; - - const usageData = item.usageData as any; - const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX); - - if (lootboxEffect && lootboxEffect.pool) { - const itemIds = lootboxEffect.pool - .filter((drop: any) => drop.type === LootType.ITEM && drop.itemId) - .map((drop: any) => drop.itemId); - - if (itemIds.length > 0) { - // Remove duplicates - const uniqueIds = [...new Set(itemIds)] as number[]; - - const referencedItems = await DrizzleClient.select({ - id: items.id, - name: items.name, - rarity: items.rarity - }).from(items).where(inArray(items.id, uniqueIds)); - - for (const ref of referencedItems) { - context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' }); + if (!targetChannel || !targetChannel.isSendable()) { + await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] }); + return; } - } - } - const listingMessage = getShopListingMessage({ - ...item, - rarity: item.rarity || undefined, - formattedPrice: `${item.price} 🪙`, - price: item.price - }, context); + const item = await inventoryService.getItem(itemId); + if (!item) { + await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] }); + return; + } - try { - await targetChannel.send(listingMessage as any); - await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` }); - } catch (error: any) { - if (error instanceof UserError) { - await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); - } else { - console.error("Error creating listing:", error); - await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] }); - } - } + if (!item.price) { + await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] }); + return; + } + + // Prepare context for lootboxes + const context: { referencedItems: Map } = { referencedItems: new Map() }; + + const usageData = item.usageData as any; + const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX); + + if (lootboxEffect && lootboxEffect.pool) { + const itemIds = lootboxEffect.pool + .filter((drop: any) => drop.type === LootType.ITEM && drop.itemId) + .map((drop: any) => drop.itemId); + + if (itemIds.length > 0) { + // Remove duplicates + const uniqueIds = [...new Set(itemIds)] as number[]; + + const referencedItems = await DrizzleClient.select({ + id: items.id, + name: items.name, + rarity: items.rarity + }).from(items).where(inArray(items.id, uniqueIds)); + + for (const ref of referencedItems) { + context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' }); + } + } + } + + const listingMessage = getShopListingMessage({ + ...item, + rarity: item.rarity || undefined, + formattedPrice: `${item.price} 🪙`, + price: item.price + }, context); + + await targetChannel.send(listingMessage as any); + await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` }); + }, + { ephemeral: true } + ); }, autocomplete: async (interaction) => { const focusedValue = interaction.options.getFocused(); diff --git a/bot/commands/admin/note.ts b/bot/commands/admin/note.ts index 05622ba..86f09d2 100644 --- a/bot/commands/admin/note.ts +++ b/bot/commands/admin/note.ts @@ -1,8 +1,9 @@ import { createCommand } from "@shared/lib/utils"; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; +import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js"; import { moderationService } from "@shared/modules/moderation/moderation.service"; import { CaseType } from "@shared/lib/constants"; import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const note = createCommand({ data: new SlashCommandBuilder() @@ -24,39 +25,35 @@ export const note = createCommand({ .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await withCommandErrorHandling( + interaction, + async () => { + const targetUser = interaction.options.getUser("user", true); + const noteText = interaction.options.getString("note", true); - try { - const targetUser = interaction.options.getUser("user", true); - const noteText = interaction.options.getString("note", true); - - // Create the note case - const moderationCase = await moderationService.createCase({ - type: CaseType.NOTE, - userId: targetUser.id, - username: targetUser.username, - moderatorId: interaction.user.id, - moderatorName: interaction.user.username, - reason: noteText, - }); - - if (!moderationCase) { - await interaction.editReply({ - embeds: [getModerationErrorEmbed("Failed to create note.")] + // Create the note case + const moderationCase = await moderationService.createCase({ + type: CaseType.NOTE, + userId: targetUser.id, + username: targetUser.username, + moderatorId: interaction.user.id, + moderatorName: interaction.user.username, + reason: noteText, }); - return; - } - // Send success message - await interaction.editReply({ - embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)] - }); + if (!moderationCase) { + await interaction.editReply({ + embeds: [getModerationErrorEmbed("Failed to create note.")] + }); + return; + } - } catch (error) { - console.error("Note command error:", error); - await interaction.editReply({ - embeds: [getModerationErrorEmbed("An error occurred while adding the note.")] - }); - } + // Send success message + await interaction.editReply({ + embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)] + }); + }, + { ephemeral: true } + ); } }); diff --git a/bot/commands/admin/notes.ts b/bot/commands/admin/notes.ts index a46b3bb..3bb3c5f 100644 --- a/bot/commands/admin/notes.ts +++ b/bot/commands/admin/notes.ts @@ -1,7 +1,8 @@ import { createCommand } from "@shared/lib/utils"; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; +import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js"; import { moderationService } from "@shared/modules/moderation/moderation.service"; -import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; +import { getCasesListEmbed } from "@/modules/moderation/moderation.view"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const notes = createCommand({ data: new SlashCommandBuilder() @@ -16,28 +17,24 @@ export const notes = createCommand({ .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await withCommandErrorHandling( + interaction, + async () => { + const targetUser = interaction.options.getUser("user", true); - try { - const targetUser = interaction.options.getUser("user", true); + // Get all notes for the user + const userNotes = await moderationService.getUserNotes(targetUser.id); - // Get all notes for the user - const userNotes = await moderationService.getUserNotes(targetUser.id); - - // Display the notes - await interaction.editReply({ - embeds: [getCasesListEmbed( - userNotes, - `📝 Staff Notes for ${targetUser.username}`, - userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**` - )] - }); - - } catch (error) { - console.error("Notes command error:", error); - await interaction.editReply({ - embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")] - }); - } + // Display the notes + await interaction.editReply({ + embeds: [getCasesListEmbed( + userNotes, + `📝 Staff Notes for ${targetUser.username}`, + userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**` + )] + }); + }, + { ephemeral: true } + ); } }); diff --git a/bot/commands/admin/prune.ts b/bot/commands/admin/prune.ts index 3cc1c0e..d710f7f 100644 --- a/bot/commands/admin/prune.ts +++ b/bot/commands/admin/prune.ts @@ -10,6 +10,7 @@ import { getPruneWarningEmbed, getCancelledEmbed } from "@/modules/moderation/prune.view"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const prune = createCommand({ data: new SlashCommandBuilder() @@ -38,142 +39,126 @@ export const prune = createCommand({ .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await withCommandErrorHandling( + interaction, + async () => { + const amount = interaction.options.getInteger("amount"); + const user = interaction.options.getUser("user"); + const all = interaction.options.getBoolean("all") || false; - try { - const amount = interaction.options.getInteger("amount"); - const user = interaction.options.getUser("user"); - const all = interaction.options.getBoolean("all") || false; - - // Validate inputs - if (!amount && !all) { - // Default to 10 messages - } else if (amount && all) { - await interaction.editReply({ - embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")] - }); - return; - } - - const finalAmount = all ? 'all' : (amount || 10); - const confirmThreshold = config.moderation.prune.confirmThreshold; - - // Check if confirmation is needed - const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold); - - if (needsConfirmation) { - // Estimate message count for confirmation - let estimatedCount: number | undefined; - if (all) { - try { - estimatedCount = await pruneService.estimateMessageCount(interaction.channel!); - } catch { - estimatedCount = undefined; - } - } - - const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount); - const response = await interaction.editReply({ embeds, components }); - - try { - const confirmation = await response.awaitMessageComponent({ - filter: (i) => i.user.id === interaction.user.id, - componentType: ComponentType.Button, - time: 30000 - }); - - if (confirmation.customId === "cancel_prune") { - await confirmation.update({ - embeds: [getCancelledEmbed()], - components: [] - }); - return; - } - - // User confirmed, proceed with deletion - await confirmation.update({ - embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })], - components: [] - }); - - // Execute deletion with progress callback for 'all' mode - const result = await pruneService.deleteMessages( - interaction.channel!, - { - amount: typeof finalAmount === 'number' ? finalAmount : undefined, - userId: user?.id, - all - }, - all ? async (progress) => { - await interaction.editReply({ - embeds: [getProgressEmbed(progress)] - }); - } : undefined - ); - - // Show success + // Validate inputs + if (!amount && !all) { + // Default to 10 messages + } else if (amount && all) { await interaction.editReply({ - embeds: [getSuccessEmbed(result)], - components: [] + embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")] }); - - } catch (error) { - if (error instanceof Error && error.message.includes("time")) { - await interaction.editReply({ - embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")], - components: [] - }); - } else { - throw error; - } - } - } else { - // No confirmation needed, proceed directly - const result = await pruneService.deleteMessages( - interaction.channel!, - { - amount: finalAmount as number, - userId: user?.id, - all: false - } - ); - - // Check if no messages were found - if (result.deletedCount === 0) { - if (user) { - await interaction.editReply({ - embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)] - }); - } else { - await interaction.editReply({ - embeds: [getPruneWarningEmbed("No messages found to delete.")] - }); - } return; } - await interaction.editReply({ - embeds: [getSuccessEmbed(result)] - }); - } + const finalAmount = all ? 'all' : (amount || 10); + const confirmThreshold = config.moderation.prune.confirmThreshold; - } catch (error) { - console.error("Prune command error:", error); + // Check if confirmation is needed + const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold); - let errorMessage = "An unexpected error occurred while trying to delete messages."; - if (error instanceof Error) { - if (error.message.includes("permission")) { - errorMessage = "I don't have permission to delete messages in this channel."; - } else if (error.message.includes("channel type")) { - errorMessage = "This command cannot be used in this type of channel."; + if (needsConfirmation) { + // Estimate message count for confirmation + let estimatedCount: number | undefined; + if (all) { + try { + estimatedCount = await pruneService.estimateMessageCount(interaction.channel!); + } catch { + estimatedCount = undefined; + } + } + + const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount); + const response = await interaction.editReply({ embeds, components }); + + try { + const confirmation = await response.awaitMessageComponent({ + filter: (i) => i.user.id === interaction.user.id, + componentType: ComponentType.Button, + time: 30000 + }); + + if (confirmation.customId === "cancel_prune") { + await confirmation.update({ + embeds: [getCancelledEmbed()], + components: [] + }); + return; + } + + // User confirmed, proceed with deletion + await confirmation.update({ + embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })], + components: [] + }); + + // Execute deletion with progress callback for 'all' mode + const result = await pruneService.deleteMessages( + interaction.channel!, + { + amount: typeof finalAmount === 'number' ? finalAmount : undefined, + userId: user?.id, + all + }, + all ? async (progress) => { + await interaction.editReply({ + embeds: [getProgressEmbed(progress)] + }); + } : undefined + ); + + // Show success + await interaction.editReply({ + embeds: [getSuccessEmbed(result)], + components: [] + }); + + } catch (error) { + if (error instanceof Error && error.message.includes("time")) { + await interaction.editReply({ + embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")], + components: [] + }); + } else { + throw error; + } + } } else { - errorMessage = error.message; - } - } + // No confirmation needed, proceed directly + const result = await pruneService.deleteMessages( + interaction.channel!, + { + amount: finalAmount as number, + userId: user?.id, + all: false + } + ); - await interaction.editReply({ - embeds: [getPruneErrorEmbed(errorMessage)] - }); - } + // Check if no messages were found + if (result.deletedCount === 0) { + if (user) { + await interaction.editReply({ + embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)] + }); + } else { + await interaction.editReply({ + embeds: [getPruneWarningEmbed("No messages found to delete.")] + }); + } + return; + } + + await interaction.editReply({ + embeds: [getSuccessEmbed(result)] + }); + } + }, + { ephemeral: true } + ); } }); diff --git a/bot/commands/admin/refresh.ts b/bot/commands/admin/refresh.ts index 5e8d3f1..e5063ad 100644 --- a/bot/commands/admin/refresh.ts +++ b/bot/commands/admin/refresh.ts @@ -1,7 +1,8 @@ import { createCommand } from "@shared/lib/utils"; import { AuroraClient } from "@/lib/BotClient"; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; -import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; +import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js"; +import { createSuccessEmbed } from "@lib/embeds"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const refresh = createCommand({ data: new SlashCommandBuilder() @@ -9,25 +10,24 @@ export const refresh = createCommand({ .setDescription("Reloads all commands and config without restarting") .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await withCommandErrorHandling( + interaction, + async () => { + const start = Date.now(); + await AuroraClient.loadCommands(true); + const duration = Date.now() - start; - try { - const start = Date.now(); - await AuroraClient.loadCommands(true); - const duration = Date.now() - start; + // Deploy commands + await AuroraClient.deployCommands(); - // Deploy commands - await AuroraClient.deployCommands(); + const embed = createSuccessEmbed( + `Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`, + "System Refreshed" + ); - const embed = createSuccessEmbed( - `Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`, - "System Refreshed" - ); - - await interaction.editReply({ embeds: [embed] }); - } catch (error) { - console.error(error); - await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while refreshing commands. Check console for details.", "Refresh Failed")] }); - } + await interaction.editReply({ embeds: [embed] }); + }, + { ephemeral: true } + ); } }); \ No newline at end of file diff --git a/bot/commands/admin/settings.ts b/bot/commands/admin/settings.ts index 79da5b6..e886777 100644 --- a/bot/commands/admin/settings.ts +++ b/bot/commands/admin/settings.ts @@ -3,7 +3,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, Colors, ChatInputCommandInter import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service"; import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config"; -import { UserError } from "@shared/lib/errors"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const settings = createCommand({ data: new SlashCommandBuilder() @@ -84,33 +84,29 @@ export const settings = createCommand({ .setRequired(false))), execute: async (interaction) => { - await interaction.deferReply({ ephemeral: true }); + await withCommandErrorHandling( + interaction, + async () => { + const subcommand = interaction.options.getSubcommand(); + const guildId = interaction.guildId!; - const subcommand = interaction.options.getSubcommand(); - const guildId = interaction.guildId!; - - try { - switch (subcommand) { - case "show": - await handleShow(interaction, guildId); - break; - case "set": - await handleSet(interaction, guildId); - break; - case "reset": - await handleReset(interaction, guildId); - break; - case "colors": - await handleColors(interaction, guildId); - break; - } - } catch (error) { - if (error instanceof UserError) { - await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); - } else { - throw error; - } - } + switch (subcommand) { + case "show": + await handleShow(interaction, guildId); + break; + case "set": + await handleSet(interaction, guildId); + break; + case "reset": + await handleReset(interaction, guildId); + break; + case "colors": + await handleColors(interaction, guildId); + break; + } + }, + { ephemeral: true } + ); }, }); diff --git a/bot/commands/admin/terminal.ts b/bot/commands/admin/terminal.ts index 27ee76f..7b26015 100644 --- a/bot/commands/admin/terminal.ts +++ b/bot/commands/admin/terminal.ts @@ -2,7 +2,8 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js"; import { terminalService } from "@shared/modules/terminal/terminal.service"; -import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds"; +import { createErrorEmbed } from "@/lib/embeds"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const terminal = createCommand({ data: new SlashCommandBuilder() @@ -23,15 +24,14 @@ export const terminal = createCommand({ return; } - await interaction.reply({ ephemeral: true, content: "Initializing terminal..." }); - - try { - await terminalService.init(channel as TextChannel); - await interaction.editReply({ content: "✅ Terminal initialized!" }); - } catch (error) { - console.error(error); - await interaction.editReply({ content: "❌ Failed to initialize terminal." }); - } + await withCommandErrorHandling( + interaction, + async () => { + await terminalService.init(channel as TextChannel); + await interaction.editReply({ content: "✅ Terminal initialized!" }); + }, + { ephemeral: true } + ); } } }); diff --git a/bot/commands/admin/warn.ts b/bot/commands/admin/warn.ts index bfa1c99..73e7d8a 100644 --- a/bot/commands/admin/warn.ts +++ b/bot/commands/admin/warn.ts @@ -4,9 +4,9 @@ import { moderationService } from "@shared/modules/moderation/moderation.service import { getWarnSuccessEmbed, getModerationErrorEmbed, - getUserWarningEmbed } from "@/modules/moderation/moderation.view"; import { getGuildConfig } from "@shared/lib/config"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const warn = createCommand({ data: new SlashCommandBuilder() @@ -28,67 +28,63 @@ export const warn = createCommand({ .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await withCommandErrorHandling( + interaction, + async () => { + const targetUser = interaction.options.getUser("user", true); + const reason = interaction.options.getString("reason", true); - try { - const targetUser = interaction.options.getUser("user", true); - const reason = interaction.options.getString("reason", true); + // Don't allow warning bots + if (targetUser.bot) { + await interaction.editReply({ + embeds: [getModerationErrorEmbed("You cannot warn bots.")] + }); + return; + } - // Don't allow warning bots - if (targetUser.bot) { + // Don't allow self-warnings + if (targetUser.id === interaction.user.id) { + await interaction.editReply({ + embeds: [getModerationErrorEmbed("You cannot warn yourself.")] + }); + return; + } + + // Fetch guild config for moderation settings + const guildConfig = await getGuildConfig(interaction.guildId!); + + // Issue the warning via service + const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({ + userId: targetUser.id, + username: targetUser.username, + moderatorId: interaction.user.id, + moderatorName: interaction.user.username, + reason, + guildName: interaction.guild?.name || undefined, + dmTarget: targetUser, + timeoutTarget: await interaction.guild?.members.fetch(targetUser.id), + config: { + dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn, + autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold, + }, + }); + + // Send success message to moderator await interaction.editReply({ - embeds: [getModerationErrorEmbed("You cannot warn bots.")] + embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)] }); - return; - } - // Don't allow self-warnings - if (targetUser.id === interaction.user.id) { - await interaction.editReply({ - embeds: [getModerationErrorEmbed("You cannot warn yourself.")] - }); - return; - } - - // Fetch guild config for moderation settings - const guildConfig = await getGuildConfig(interaction.guildId!); - - // Issue the warning via service - const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({ - userId: targetUser.id, - username: targetUser.username, - moderatorId: interaction.user.id, - moderatorName: interaction.user.username, - reason, - guildName: interaction.guild?.name || undefined, - dmTarget: targetUser, - timeoutTarget: await interaction.guild?.members.fetch(targetUser.id), - config: { - dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn, - autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold, - }, - }); - - // Send success message to moderator - await interaction.editReply({ - embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)] - }); - - // Follow up if auto-timeout was issued - if (autoTimeoutIssued) { - await interaction.followUp({ - embeds: [getModerationErrorEmbed( - `⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.` - )], - flags: MessageFlags.Ephemeral - }); - } - - } catch (error) { - console.error("Warn command error:", error); - await interaction.editReply({ - embeds: [getModerationErrorEmbed("An error occurred while issuing the warning.")] - }); - } + // Follow up if auto-timeout was issued + if (autoTimeoutIssued) { + await interaction.followUp({ + embeds: [getModerationErrorEmbed( + `⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.` + )], + flags: MessageFlags.Ephemeral + }); + } + }, + { ephemeral: true } + ); } }); diff --git a/bot/commands/admin/warnings.ts b/bot/commands/admin/warnings.ts index f8395cd..b2731e3 100644 --- a/bot/commands/admin/warnings.ts +++ b/bot/commands/admin/warnings.ts @@ -1,7 +1,8 @@ import { createCommand } from "@shared/lib/utils"; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; +import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js"; import { moderationService } from "@shared/modules/moderation/moderation.service"; -import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; +import { getWarningsEmbed } from "@/modules/moderation/moderation.view"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const warnings = createCommand({ data: new SlashCommandBuilder() @@ -16,24 +17,20 @@ export const warnings = createCommand({ .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await withCommandErrorHandling( + interaction, + async () => { + const targetUser = interaction.options.getUser("user", true); - try { - const targetUser = interaction.options.getUser("user", true); + // Get active warnings for the user + const activeWarnings = await moderationService.getUserWarnings(targetUser.id); - // Get active warnings for the user - const activeWarnings = await moderationService.getUserWarnings(targetUser.id); - - // Display the warnings - await interaction.editReply({ - embeds: [getWarningsEmbed(activeWarnings, targetUser.username)] - }); - - } catch (error) { - console.error("Warnings command error:", error); - await interaction.editReply({ - embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")] - }); - } + // Display the warnings + await interaction.editReply({ + embeds: [getWarningsEmbed(activeWarnings, targetUser.username)] + }); + }, + { ephemeral: true } + ); } }); diff --git a/bot/commands/admin/webhook.ts b/bot/commands/admin/webhook.ts index e822ea5..3d38c06 100644 --- a/bot/commands/admin/webhook.ts +++ b/bot/commands/admin/webhook.ts @@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { createErrorEmbed } from "@/lib/embeds"; import { sendWebhookMessage } from "@/lib/webhookUtils"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const webhook = createCommand({ data: new SlashCommandBuilder() @@ -14,43 +15,40 @@ export const webhook = createCommand({ .setRequired(true) ), execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await withCommandErrorHandling( + interaction, + async () => { + const payloadString = interaction.options.getString("payload", true); + let payload; - const payloadString = interaction.options.getString("payload", true); - let payload; + try { + payload = JSON.parse(payloadString); + } catch (error) { + await interaction.editReply({ + embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")] + }); + return; + } - try { - payload = JSON.parse(payloadString); - } catch (error) { - await interaction.editReply({ - embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")] - }); - return; - } + const channel = interaction.channel; - const channel = interaction.channel; + if (!channel || !('createWebhook' in channel)) { + await interaction.editReply({ + embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")] + }); + return; + } - if (!channel || !('createWebhook' in channel)) { - await interaction.editReply({ - embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")] - }); - return; - } + await sendWebhookMessage( + channel, + payload, + interaction.client.user, + `Proxy message requested by ${interaction.user.tag}` + ); - try { - await sendWebhookMessage( - channel, - payload, - interaction.client.user, - `Proxy message requested by ${interaction.user.tag}` - ); - - await interaction.editReply({ content: "Message sent successfully!" }); - } catch (error) { - console.error("Webhook error:", error); - await interaction.editReply({ - embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")] - }); - } + await interaction.editReply({ content: "Message sent successfully!" }); + }, + { ephemeral: true } + ); } }); diff --git a/bot/commands/economy/daily.ts b/bot/commands/economy/daily.ts index 09a6b19..b5e3afc 100644 --- a/bot/commands/economy/daily.ts +++ b/bot/commands/economy/daily.ts @@ -2,35 +2,29 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder } from "discord.js"; import { economyService } from "@shared/modules/economy/economy.service"; -import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; -import { UserError } from "@shared/lib/errors"; +import { createSuccessEmbed } from "@lib/embeds"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const daily = createCommand({ data: new SlashCommandBuilder() .setName("daily") .setDescription("Claim your daily reward"), execute: async (interaction) => { - await interaction.deferReply(); - try { - const result = await economyService.claimDaily(interaction.user.id); + await withCommandErrorHandling( + interaction, + async () => { + const result = await economyService.claimDaily(interaction.user.id); - const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!") - .addFields( - { name: "Streak", value: `🔥 ${result.streak} days`, inline: true }, - { name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true }, - { name: "Next Reward", value: ` `, inline: true } - ) - .setColor("Gold"); + const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!") + .addFields( + { name: "Streak", value: `🔥 ${result.streak} days`, inline: true }, + { name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true }, + { name: "Next Reward", value: ` `, inline: true } + ) + .setColor("Gold"); - await interaction.editReply({ embeds: [embed] }); - - } catch (error: any) { - if (error instanceof UserError) { - await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); - } else { - console.error("Error claiming daily:", error); - await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] }); + await interaction.editReply({ embeds: [embed] }); } - } + ); } }); \ No newline at end of file diff --git a/bot/commands/economy/exam.ts b/bot/commands/economy/exam.ts index f7bfde2..6a6f82f 100644 --- a/bot/commands/economy/exam.ts +++ b/bot/commands/economy/exam.ts @@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder } from "discord.js"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { examService, ExamStatus } from "@shared/modules/economy/exam.service"; +import { withCommandErrorHandling } from "@lib/commandUtils"; const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; @@ -10,66 +11,62 @@ export const exam = createCommand({ .setName("exam") .setDescription("Take your weekly exam to earn rewards based on your XP progress."), execute: async (interaction) => { - await interaction.deferReply(); + await withCommandErrorHandling( + interaction, + async () => { + // First, try to take the exam or check status + const result = await examService.takeExam(interaction.user.id); - try { - // First, try to take the exam or check status - const result = await examService.takeExam(interaction.user.id); + if (result.status === ExamStatus.NOT_REGISTERED) { + // Register the user + const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username); + const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000); - if (result.status === ExamStatus.NOT_REGISTERED) { - // Register the user - const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username); - const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000); + await interaction.editReply({ + embeds: [createSuccessEmbed( + `You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` + + `Come back on () to take your first exam!`, + "Exam Registration Successful" + )] + }); + return; + } + const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000); + + if (result.status === ExamStatus.COOLDOWN) { + await interaction.editReply({ + embeds: [createErrorEmbed( + `You have already taken your exam for this week (or are waiting for your first week to pass).\n` + + `Next exam available: ()` + )] + }); + return; + } + + if (result.status === ExamStatus.MISSED) { + await interaction.editReply({ + embeds: [createErrorEmbed( + `You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` + + `You verify your attendance but score a **0**.\n` + + `Your next exam opportunity is: ()`, + "Exam Failed" + )] + }); + return; + } + + // If it reached here with AVAILABLE, it means they passed await interaction.editReply({ embeds: [createSuccessEmbed( - `You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` + - `Come back on () to take your first exam!`, - "Exam Registration Successful" + `**XP Gained:** ${result.xpDiff?.toString()}\n` + + `**Multiplier:** x${result.multiplier?.toFixed(2)}\n` + + `**Reward:** ${result.reward?.toString()} Currency\n\n` + + `See you next week: `, + "Exam Passed!" )] }); - return; } - - const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000); - - if (result.status === ExamStatus.COOLDOWN) { - await interaction.editReply({ - embeds: [createErrorEmbed( - `You have already taken your exam for this week (or are waiting for your first week to pass).\n` + - `Next exam available: ()` - )] - }); - return; - } - - if (result.status === ExamStatus.MISSED) { - await interaction.editReply({ - embeds: [createErrorEmbed( - `You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` + - `You verify your attendance but score a **0**.\n` + - `Your next exam opportunity is: ()`, - "Exam Failed" - )] - }); - return; - } - - // If it reached here with AVAILABLE, it means they passed - await interaction.editReply({ - embeds: [createSuccessEmbed( - `**XP Gained:** ${result.xpDiff?.toString()}\n` + - `**Multiplier:** x${result.multiplier?.toFixed(2)}\n` + - `**Reward:** ${result.reward?.toString()} Currency\n\n` + - `See you next week: `, - "Exam Passed!" - )] - }); - - } catch (error: any) { - console.error("Error in exam command:", error); - await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] }); - } + ); } }); - diff --git a/bot/commands/economy/pay.ts b/bot/commands/economy/pay.ts index b51163d..94e4a1d 100644 --- a/bot/commands/economy/pay.ts +++ b/bot/commands/economy/pay.ts @@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service"; import { userService } from "@shared/modules/user/user.service"; import { config } from "@shared/lib/config"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; -import { UserError } from "@shared/lib/errors"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const pay = createCommand({ data: new SlashCommandBuilder() @@ -50,20 +50,14 @@ export const pay = createCommand({ return; } - try { - await interaction.deferReply(); - await economyService.transfer(senderId, receiverId.toString(), amount); + await withCommandErrorHandling( + interaction, + async () => { + await economyService.transfer(senderId, receiverId.toString(), amount); - const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful"); - await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` }); - - } catch (error: any) { - if (error instanceof UserError) { - await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); - } else { - console.error("Error sending payment:", error); - await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] }); + const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful"); + await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` }); } - } + ); } }); diff --git a/bot/commands/economy/trivia.ts b/bot/commands/economy/trivia.ts index 3463bfa..e875699 100644 --- a/bot/commands/economy/trivia.ts +++ b/bot/commands/economy/trivia.ts @@ -6,6 +6,7 @@ import { createErrorEmbed } from "@lib/embeds"; import { UserError } from "@shared/lib/errors"; import { config } from "@shared/lib/config"; import { TriviaCategory } from "@shared/lib/constants"; +import { withCommandErrorHandling } from "@lib/commandUtils"; export const trivia = createCommand({ data: new SlashCommandBuilder() @@ -53,64 +54,54 @@ export const trivia = createCommand({ return; } - // User can play - defer publicly for trivia question - await interaction.deferReply(); + // User can play - use standardized error handling for the main operation + await withCommandErrorHandling( + interaction, + async () => { + // Start trivia session (deducts entry fee) + const session = await triviaService.startTrivia( + interaction.user.id, + interaction.user.username, + categoryId ? parseInt(categoryId) : undefined + ); - // Start trivia session (deducts entry fee) - const session = await triviaService.startTrivia( - interaction.user.id, - interaction.user.username, - categoryId ? parseInt(categoryId) : undefined + // Generate Components v2 message + const { components, flags } = getTriviaQuestionView(session, interaction.user.username); + + // Reply with Components v2 question + await interaction.editReply({ + components, + flags + }); + + // Set up automatic timeout cleanup + setTimeout(async () => { + const stillActive = triviaService.getSession(session.sessionId); + if (stillActive) { + // User didn't answer - clean up session with no reward + try { + await triviaService.submitAnswer(session.sessionId, interaction.user.id, false); + } catch (error) { + // Session already cleaned up, ignore + } + } + }, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period + } ); - // Generate Components v2 message - const { components, flags } = getTriviaQuestionView(session, interaction.user.username); - - // Reply with Components v2 question - await interaction.editReply({ - components, - flags - }); - - // Set up automatic timeout cleanup - setTimeout(async () => { - const stillActive = triviaService.getSession(session.sessionId); - if (stillActive) { - // User didn't answer - clean up session with no reward - try { - await triviaService.submitAnswer(session.sessionId, interaction.user.id, false); - } catch (error) { - // Session already cleaned up, ignore - } - } - }, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period - } catch (error: any) { + // Handle errors from the pre-defer canPlayTrivia check if (error instanceof UserError) { - // Check if we've already deferred - if (interaction.deferred) { - await interaction.editReply({ - embeds: [createErrorEmbed(error.message)] - }); - } else { - await interaction.reply({ - embeds: [createErrorEmbed(error.message)], - ephemeral: true - }); - } + await interaction.reply({ + embeds: [createErrorEmbed(error.message)], + ephemeral: true + }); } else { console.error("Error in trivia command:", error); - // Check if we've already deferred - if (interaction.deferred) { - await interaction.editReply({ - embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")] - }); - } else { - await interaction.reply({ - embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")], - ephemeral: true - }); - } + await interaction.reply({ + embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")], + ephemeral: true + }); } } } diff --git a/bot/commands/inventory/use.ts b/bot/commands/inventory/use.ts index 681e19a..017e2d0 100644 --- a/bot/commands/inventory/use.ts +++ b/bot/commands/inventory/use.ts @@ -4,8 +4,7 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { userService } from "@shared/modules/user/user.service"; import { createErrorEmbed } from "@lib/embeds"; import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view"; -import type { ItemUsageData } from "@shared/lib/types"; -import { UserError } from "@shared/lib/errors"; +import { withCommandErrorHandling } from "@lib/commandUtils"; import { getGuildConfig } from "@shared/lib/config"; export const use = createCommand({ @@ -19,57 +18,50 @@ export const use = createCommand({ .setAutocomplete(true) ), execute: async (interaction) => { - await interaction.deferReply(); + await withCommandErrorHandling( + interaction, + async () => { + const guildConfig = await getGuildConfig(interaction.guildId!); + const colorRoles = guildConfig.colorRoles ?? []; - const guildConfig = await getGuildConfig(interaction.guildId!); - const colorRoles = guildConfig.colorRoles ?? []; + const itemId = interaction.options.getNumber("item", true); + const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username); + if (!user) { + await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] }); + return; + } - const itemId = interaction.options.getNumber("item", true); - const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username); - if (!user) { - await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] }); - return; - } + const result = await inventoryService.useItem(user.id.toString(), itemId); - try { - const result = await inventoryService.useItem(user.id.toString(), itemId); - - const usageData = result.usageData; - if (usageData) { - for (const effect of usageData.effects) { - if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') { - try { - const member = await interaction.guild?.members.fetch(user.id.toString()); - if (member) { - if (effect.type === 'TEMP_ROLE') { - await member.roles.add(effect.roleId); - } else if (effect.type === 'COLOR_ROLE') { - // Remove existing color roles - const rolesToRemove = colorRoles.filter(r => member.roles.cache.has(r)); - if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove); - await member.roles.add(effect.roleId); + const usageData = result.usageData; + if (usageData) { + for (const effect of usageData.effects) { + if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') { + try { + const member = await interaction.guild?.members.fetch(user.id.toString()); + if (member) { + if (effect.type === 'TEMP_ROLE') { + await member.roles.add(effect.roleId); + } else if (effect.type === 'COLOR_ROLE') { + // Remove existing color roles + const rolesToRemove = colorRoles.filter(r => member.roles.cache.has(r)); + if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove); + await member.roles.add(effect.roleId); + } } + } catch (e) { + console.error("Failed to assign role in /use command:", e); + result.results.push("⚠️ Failed to assign role (Check bot permissions)"); } - } catch (e) { - console.error("Failed to assign role in /use command:", e); - result.results.push("⚠️ Failed to assign role (Check bot permissions)"); } } } + + const { embed, files } = getItemUseResultEmbed(result.results, result.item); + + await interaction.editReply({ embeds: [embed], files }); } - - const { embed, files } = getItemUseResultEmbed(result.results, result.item); - - await interaction.editReply({ embeds: [embed], files }); - - } catch (error: any) { - if (error instanceof UserError) { - await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); - } else { - console.error("Error using item:", error); - await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] }); - } - } + ); }, autocomplete: async (interaction) => { const focusedValue = interaction.options.getFocused(); diff --git a/bot/lib/commandUtils.test.ts b/bot/lib/commandUtils.test.ts new file mode 100644 index 0000000..0dd39b1 --- /dev/null +++ b/bot/lib/commandUtils.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test"; +import { UserError } from "@shared/lib/errors"; + +// --- Mocks --- + +const mockDeferReply = mock(() => Promise.resolve()); +const mockEditReply = mock(() => Promise.resolve()); + +const mockInteraction = { + deferReply: mockDeferReply, + editReply: mockEditReply, +} as any; + +const mockCreateErrorEmbed = mock((msg: string) => ({ description: msg, type: "error" })); + +mock.module("./embeds", () => ({ + createErrorEmbed: mockCreateErrorEmbed, +})); + +// Import AFTER mocking +const { withCommandErrorHandling } = await import("./commandUtils"); + +// --- Tests --- + +describe("withCommandErrorHandling", () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + mockDeferReply.mockClear(); + mockEditReply.mockClear(); + mockCreateErrorEmbed.mockClear(); + consoleErrorSpy = spyOn(console, "error").mockImplementation(() => { }); + }); + + it("should always call deferReply", async () => { + await withCommandErrorHandling( + mockInteraction, + async () => "result" + ); + + expect(mockDeferReply).toHaveBeenCalledTimes(1); + }); + + it("should pass ephemeral option to deferReply", async () => { + await withCommandErrorHandling( + mockInteraction, + async () => "result", + { ephemeral: true } + ); + + expect(mockDeferReply).toHaveBeenCalledWith({ ephemeral: true }); + }); + + it("should return the operation result on success", async () => { + const result = await withCommandErrorHandling( + mockInteraction, + async () => ({ data: "test" }) + ); + + expect(result).toEqual({ data: "test" }); + }); + + it("should call onSuccess with the result", async () => { + const onSuccess = mock(async (_result: string) => { }); + + await withCommandErrorHandling( + mockInteraction, + async () => "hello", + { onSuccess } + ); + + expect(onSuccess).toHaveBeenCalledWith("hello"); + }); + + it("should send successMessage when no onSuccess is provided", async () => { + await withCommandErrorHandling( + mockInteraction, + async () => "result", + { successMessage: "It worked!" } + ); + + expect(mockEditReply).toHaveBeenCalledWith({ + content: "It worked!", + }); + }); + + it("should prefer onSuccess over successMessage", async () => { + const onSuccess = mock(async (_result: string) => { }); + + await withCommandErrorHandling( + mockInteraction, + async () => "result", + { successMessage: "This should not be sent", onSuccess } + ); + + expect(onSuccess).toHaveBeenCalledTimes(1); + // editReply should NOT have been called with the successMessage + expect(mockEditReply).not.toHaveBeenCalledWith({ + content: "This should not be sent", + }); + }); + + it("should show error embed for UserError", async () => { + const result = await withCommandErrorHandling( + mockInteraction, + async () => { + throw new UserError("You can't do that!"); + } + ); + + expect(result).toBeUndefined(); + expect(mockCreateErrorEmbed).toHaveBeenCalledWith("You can't do that!"); + expect(mockEditReply).toHaveBeenCalledTimes(1); + }); + + it("should show generic error and log for unexpected errors", async () => { + const unexpectedError = new Error("Database exploded"); + + const result = await withCommandErrorHandling( + mockInteraction, + async () => { + throw unexpectedError; + } + ); + + expect(result).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Unexpected error in command:", + unexpectedError + ); + expect(mockCreateErrorEmbed).toHaveBeenCalledWith( + "An unexpected error occurred." + ); + expect(mockEditReply).toHaveBeenCalledTimes(1); + }); + + it("should return undefined on error", async () => { + const result = await withCommandErrorHandling( + mockInteraction, + async () => { + throw new Error("fail"); + } + ); + + expect(result).toBeUndefined(); + }); +}); diff --git a/bot/lib/commandUtils.ts b/bot/lib/commandUtils.ts new file mode 100644 index 0000000..5dd1114 --- /dev/null +++ b/bot/lib/commandUtils.ts @@ -0,0 +1,79 @@ +import type { ChatInputCommandInteraction } from "discord.js"; +import { UserError } from "@shared/lib/errors"; +import { createErrorEmbed } from "./embeds"; + +/** + * Wraps a command's core logic with standardized error handling. + * + * - Calls `interaction.deferReply()` automatically + * - On success, invokes `onSuccess` callback or sends `successMessage` + * - On `UserError`, shows the error message in an error embed + * - On unexpected errors, logs to console and shows a generic error embed + * + * @example + * ```typescript + * export const myCommand = createCommand({ + * execute: async (interaction) => { + * await withCommandErrorHandling( + * interaction, + * async () => { + * const result = await doSomething(); + * await interaction.editReply({ embeds: [createSuccessEmbed(result)] }); + * } + * ); + * } + * }); + * ``` + * + * @example + * ```typescript + * // With deferReply options (e.g. ephemeral) + * await withCommandErrorHandling( + * interaction, + * async () => doSomething(), + * { + * ephemeral: true, + * successMessage: "Done!", + * } + * ); + * ``` + */ +export async function withCommandErrorHandling( + interaction: ChatInputCommandInteraction, + operation: () => Promise, + options?: { + /** Message to send on success (if no onSuccess callback is provided) */ + successMessage?: string; + /** Callback invoked with the operation result on success */ + onSuccess?: (result: T) => Promise; + /** Whether the deferred reply should be ephemeral */ + ephemeral?: boolean; + } +): Promise { + try { + await interaction.deferReply({ ephemeral: options?.ephemeral }); + const result = await operation(); + + if (options?.onSuccess) { + await options.onSuccess(result); + } else if (options?.successMessage) { + await interaction.editReply({ + content: options.successMessage, + }); + } + + return result; + } catch (error) { + if (error instanceof UserError) { + await interaction.editReply({ + embeds: [createErrorEmbed(error.message)], + }); + } else { + console.error("Unexpected error in command:", error); + await interaction.editReply({ + embeds: [createErrorEmbed("An unexpected error occurred.")], + }); + } + return undefined; + } +}