From 24120985361fd2fe3eddf59a180a52b4db609e55 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 24 Dec 2025 22:26:12 +0100 Subject: [PATCH] refactor(modules): standardize error handling in interaction handlers --- src/modules/economy/lootdrop.interaction.ts | 43 +++--- src/modules/economy/shop.interaction.ts | 54 +++---- src/modules/feedback/feedback.interaction.ts | 109 +++++--------- src/modules/trade/trade.interaction.ts | 54 +++---- src/modules/user/enrollment.interaction.ts | 149 ++++++++----------- 5 files changed, 166 insertions(+), 243 deletions(-) diff --git a/src/modules/economy/lootdrop.interaction.ts b/src/modules/economy/lootdrop.interaction.ts index 6ed6f43..8d9859c 100644 --- a/src/modules/economy/lootdrop.interaction.ts +++ b/src/modules/economy/lootdrop.interaction.ts @@ -1,6 +1,6 @@ import { ButtonInteraction } from "discord.js"; import { lootdropService } from "./lootdrop.service"; -import { createErrorEmbed } from "@/lib/embeds"; +import { UserError } from "@/lib/errors"; import { getLootdropClaimedMessage } from "./lootdrop.view"; export async function handleLootdropInteraction(interaction: ButtonInteraction) { @@ -9,28 +9,25 @@ export async function handleLootdropInteraction(interaction: ButtonInteraction) const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username); - if (result.success) { - await interaction.editReply({ - content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!` - }); - - // Update original message to show claimed state - const originalEmbed = interaction.message.embeds[0]; - if (!originalEmbed) return; - - const { embeds, components } = getLootdropClaimedMessage( - originalEmbed.title || "💰 LOOTDROP!", - interaction.user.id, - result.amount || 0, - result.currency || "Coins" - ); - - await interaction.message.edit({ embeds, components }); - - } else { - await interaction.editReply({ - embeds: [createErrorEmbed(result.error || "Failed to claim.")] - }); + if (!result.success) { + throw new UserError(result.error || "Failed to claim."); } + + await interaction.editReply({ + content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!` + }); + + // Update original message to show claimed state + const originalEmbed = interaction.message.embeds[0]; + if (!originalEmbed) return; + + const { embeds, components } = getLootdropClaimedMessage( + originalEmbed.title || "💰 LOOTDROP!", + interaction.user.id, + result.amount || 0, + result.currency || "Coins" + ); + + await interaction.message.edit({ embeds, components }); } } diff --git a/src/modules/economy/shop.interaction.ts b/src/modules/economy/shop.interaction.ts index 2af78dc..b5439dc 100644 --- a/src/modules/economy/shop.interaction.ts +++ b/src/modules/economy/shop.interaction.ts @@ -1,40 +1,34 @@ import { ButtonInteraction, MessageFlags } from "discord.js"; import { inventoryService } from "@/modules/inventory/inventory.service"; import { userService } from "@/modules/user/user.service"; -import { createErrorEmbed, createWarningEmbed } from "@/lib/embeds"; +import { UserError } from "@/lib/errors"; export async function handleShopInteraction(interaction: ButtonInteraction) { if (!interaction.customId.startsWith("shop_buy_")) return; - try { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const itemId = parseInt(interaction.customId.replace("shop_buy_", "")); - if (isNaN(itemId)) { - await interaction.editReply({ embeds: [createErrorEmbed("Invalid Item ID.")] }); - return; - } - - const item = await inventoryService.getItem(itemId); - if (!item || !item.price) { - await interaction.editReply({ embeds: [createErrorEmbed("Item not found or not for sale.")] }); - return; - } - - const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username); - - // Double check balance here too, although service handles it, we want a nice message - if ((user.balance ?? 0n) < item.price) { - await interaction.editReply({ embeds: [createWarningEmbed(`You need ${item.price} 🪙 to buy this item. You have ${user.balance} 🪙.`)] }); - return; - } - - const result = await inventoryService.buyItem(user.id, item.id, 1n); - - await interaction.editReply({ content: `✅ **Success!** You bought **${item.name}** for ${item.price} 🪙.` }); - - } catch (error: any) { - console.error("Shop Purchase Error:", error); - await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An error occurred while processing your purchase.")] }); + const itemId = parseInt(interaction.customId.replace("shop_buy_", "")); + if (isNaN(itemId)) { + throw new UserError("Invalid Item ID."); } + + const item = await inventoryService.getItem(itemId); + if (!item || !item.price) { + throw new UserError("Item not found or not for sale."); + } + + const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username); + if (!user) { + throw new UserError("User profiles could not be loaded. Please try again later."); + } + + // Double check balance here too, although service handles it, we want a nice message + if ((user.balance ?? 0n) < item.price) { + throw new UserError(`You need ${item.price} 🪙 to buy this item. You have ${user.balance?.toString() ?? "0"} 🪙.`); + } + + await inventoryService.buyItem(user.id.toString(), item.id, 1n); + + await interaction.editReply({ content: `✅ **Success!** You bought **${item.name}** for ${item.price} 🪙.` }); } diff --git a/src/modules/feedback/feedback.interaction.ts b/src/modules/feedback/feedback.interaction.ts index 1b3b09d..ec31436 100644 --- a/src/modules/feedback/feedback.interaction.ts +++ b/src/modules/feedback/feedback.interaction.ts @@ -4,7 +4,7 @@ import { config } from "@/lib/config"; import { AuroraClient } from "@/lib/BotClient"; import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view"; import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types"; -import { createErrorEmbed, createSuccessEmbed } from "@/lib/embeds"; +import { UserError } from "@/lib/errors"; export const handleFeedbackInteraction = async (interaction: Interaction) => { // Handle select menu for choosing feedback type @@ -12,11 +12,7 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => { const feedbackType = interaction.values[0] as FeedbackType; if (!feedbackType) { - await interaction.reply({ - embeds: [createErrorEmbed("Invalid feedback type selected.")], - ephemeral: true - }); - return; + throw new UserError("Invalid feedback type selected."); } const modal = getFeedbackModal(feedbackType); @@ -34,79 +30,50 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => { if (!feedbackType || !["FEATURE_REQUEST", "BUG_REPORT", "GENERAL"].includes(feedbackType)) { console.error(`Invalid feedback type extracted: ${feedbackType} from customId: ${interaction.customId}`); - await interaction.reply({ - embeds: [createErrorEmbed("An error occurred processing your feedback. Please try again.")], - ephemeral: true - }); - return; + throw new UserError("An error occurred processing your feedback. Please try again."); } if (!config.feedbackChannelId) { - await interaction.reply({ - embeds: [createErrorEmbed("Feedback channel is not configured. Please contact an administrator.")], - ephemeral: true - }); - return; + throw new UserError("Feedback channel is not configured. Please contact an administrator."); } - try { - // Parse modal inputs - const title = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.TITLE_FIELD); - const description = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD); + // Parse modal inputs + const title = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.TITLE_FIELD); + const description = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD); - // Build feedback data - const feedbackData: FeedbackData = { - type: feedbackType, - title, - description, - userId: interaction.user.id, - username: interaction.user.username, - timestamp: new Date() - }; + // Build feedback data + const feedbackData: FeedbackData = { + type: feedbackType, + title, + description, + userId: interaction.user.id, + username: interaction.user.username, + timestamp: new Date() + }; - // Get feedback channel - const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null; + // Get feedback channel + const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null; - if (!channel) { - await interaction.reply({ - embeds: [createErrorEmbed("Feedback channel not found. Please contact an administrator.")], - ephemeral: true - }); - return; - } - - // Build and send beautiful message - const containers = buildFeedbackMessage(feedbackData); - - const feedbackMessage = await channel.send({ - components: containers as any, - flags: MessageFlags.IsComponentsV2 - }); - - // Add reaction votes - await feedbackMessage.react("👍"); - await feedbackMessage.react("👎"); - - // Confirm to user - await interaction.reply({ - embeds: [createSuccessEmbed("Your feedback has been submitted successfully! Thank you for helping improve Aurora.", "✨ Feedback Submitted")], - ephemeral: true - }); - - } catch (error: any) { - console.error("Error submitting feedback:", error); - - if (!interaction.replied && !interaction.deferred) { - await interaction.reply({ - embeds: [createErrorEmbed("An error occurred while submitting your feedback. Please try again later.")], - ephemeral: true - }); - } else { - await interaction.followUp({ - embeds: [createErrorEmbed("An error occurred while submitting your feedback. Please try again later.")], - ephemeral: true - }); - } + if (!channel) { + throw new UserError("Feedback channel not found. Please contact an administrator."); } + + // Build and send beautiful message + const containers = buildFeedbackMessage(feedbackData); + + const feedbackMessage = await channel.send({ + components: containers as any, + flags: MessageFlags.IsComponentsV2 + }); + + // Add reaction votes + await feedbackMessage.react("👍"); + await feedbackMessage.react("👎"); + + // Confirm to user + await interaction.reply({ + content: "✨ **Feedback Submitted**\nYour feedback has been submitted successfully! Thank you for helping improve Aurora.", + flags: MessageFlags.Ephemeral + }); } }; diff --git a/src/modules/trade/trade.interaction.ts b/src/modules/trade/trade.interaction.ts index e47f3be..dabe920 100644 --- a/src/modules/trade/trade.interaction.ts +++ b/src/modules/trade/trade.interaction.ts @@ -10,6 +10,7 @@ import { import { tradeService } from "./trade.service"; import { inventoryService } from "@/modules/inventory/inventory.service"; import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds"; +import { UserError } from "@lib/errors"; import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view"; @@ -22,35 +23,26 @@ export async function handleTradeInteraction(interaction: Interaction) { if (!threadId) return; - try { - if (customId === 'trade_cancel') { - await handleCancel(interaction, threadId); - } else if (customId === 'trade_lock') { - await handleLock(interaction, threadId); - } else if (customId === 'trade_confirm') { - // Confirm logic is handled implicitly by both locking or explicitly if needed. - // For now, locking both triggers execution, so no separate confirm handler is actively used - // unless we re-introduce a specific button. keeping basic handler stub if needed. - } else if (customId === 'trade_add_money') { - await handleAddMoneyClick(interaction); - } else if (customId === 'trade_money_modal') { - await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId); - } else if (customId === 'trade_add_item') { - await handleAddItemClick(interaction as ButtonInteraction, threadId); - } else if (customId === 'trade_select_item') { - await handleItemSelect(interaction as StringSelectMenuInteraction, threadId); - } else if (customId === 'trade_remove_item') { - await handleRemoveItemClick(interaction as ButtonInteraction, threadId); - } else if (customId === 'trade_remove_item_select') { - await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId); - } - } catch (error: any) { - const errorEmbed = createErrorEmbed(error.message); - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ embeds: [errorEmbed], ephemeral: true }); - } else { - await interaction.reply({ embeds: [errorEmbed], ephemeral: true }); - } + if (customId === 'trade_cancel') { + await handleCancel(interaction, threadId); + } else if (customId === 'trade_lock') { + await handleLock(interaction, threadId); + } else if (customId === 'trade_confirm') { + // Confirm logic is handled implicitly by both locking or explicitly if needed. + // For now, locking both triggers execution, so no separate confirm handler is actively used + // unless we re-introduce a specific button. keeping basic handler stub if needed. + } else if (customId === 'trade_add_money') { + await handleAddMoneyClick(interaction); + } else if (customId === 'trade_money_modal') { + await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId); + } else if (customId === 'trade_add_item') { + await handleAddItemClick(interaction as ButtonInteraction, threadId); + } else if (customId === 'trade_select_item') { + await handleItemSelect(interaction as StringSelectMenuInteraction, threadId); + } else if (customId === 'trade_remove_item') { + await handleRemoveItemClick(interaction as ButtonInteraction, threadId); + } else if (customId === 'trade_remove_item_select') { + await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId); } } @@ -93,7 +85,7 @@ async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId: const amountStr = interaction.fields.getTextInputValue('amount'); const amount = BigInt(amountStr); - if (amount < 0n) throw new Error("Amount must be positive"); + if (amount < 0n) throw new UserError("Amount must be positive"); tradeService.updateMoney(threadId, interaction.user.id, amount); await interaction.deferUpdate(); // Acknowledge modal @@ -126,7 +118,7 @@ async function handleItemSelect(interaction: StringSelectMenuInteraction, thread // Assuming implementation implies adding 1 item for now const item = await inventoryService.getItem(itemId); - if (!item) throw new Error("Item not found"); + if (!item) throw new UserError("Item not found"); tradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n); diff --git a/src/modules/user/enrollment.interaction.ts b/src/modules/user/enrollment.interaction.ts index ef690da..60d3e27 100644 --- a/src/modules/user/enrollment.interaction.ts +++ b/src/modules/user/enrollment.interaction.ts @@ -1,120 +1,93 @@ import { ButtonInteraction, MessageFlags } from "discord.js"; import { config } from "@/lib/config"; -import { getEnrollmentErrorEmbed, getEnrollmentSuccessMessage } from "./enrollment.view"; +import { getEnrollmentSuccessMessage } from "./enrollment.view"; import { classService } from "@modules/class/class.service"; import { userService } from "@modules/user/user.service"; +import { UserError } from "@/lib/errors"; import { sendWebhookMessage } from "@/lib/webhookUtils"; export async function handleEnrollmentInteraction(interaction: ButtonInteraction) { if (!interaction.inCachedGuild()) { - await interaction.reply({ content: "This action can only be performed in a server.", flags: MessageFlags.Ephemeral }); - return; + throw new UserError("This action can only be performed in a server."); } const { studentRole, visitorRole } = config; if (!studentRole || !visitorRole) { - await interaction.reply({ - ...getEnrollmentErrorEmbed("No student or visitor role configured for enrollment.", "Configuration Error"), - flags: MessageFlags.Ephemeral - }); - return; + throw new UserError("No student or visitor role configured for enrollment."); } - try { - // 1. Ensure user exists in DB and check current enrollment status - const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username); + // 1. Ensure user exists in DB and check current enrollment status + const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username); + if (!user) { + throw new UserError("User profiles could not be loaded. Please try again later."); + } - // Check DB enrollment - if (user.class) { - await interaction.reply({ - ...getEnrollmentErrorEmbed("You are already enrolled in a class.", "Enrollment Failed"), - flags: MessageFlags.Ephemeral - }); - return; - } + // Check DB enrollment + if (user.class) { + throw new UserError("You are already enrolled in a class."); + } - const member = interaction.member; + const member = interaction.member; - // Check Discord role enrollment (Double safety) - if (member.roles.cache.has(studentRole)) { - await interaction.reply({ - ...getEnrollmentErrorEmbed("You already have the student role.", "Enrollment Failed"), - flags: MessageFlags.Ephemeral - }); - return; - } + // Check Discord role enrollment (Double safety) + if (member.roles.cache.has(studentRole)) { + throw new UserError("You already have the student role."); + } - // 2. Get available classes - const allClasses = await classService.getAllClasses(); - const validClasses = allClasses.filter(c => c.roleId); + // 2. Get available classes + const allClasses = await classService.getAllClasses(); + const validClasses = allClasses.filter(c => c.roleId); - if (validClasses.length === 0) { - await interaction.reply({ - ...getEnrollmentErrorEmbed("No classes with specified roles found in database.", "Configuration Error"), - flags: MessageFlags.Ephemeral - }); - return; - } + if (validClasses.length === 0) { + throw new UserError("No classes with specified roles found in database."); + } - // 3. Pick random class - const selectedClass = validClasses[Math.floor(Math.random() * validClasses.length)]!; - const classRoleId = selectedClass.roleId!; + // 3. Pick random class + const selectedClass = validClasses[Math.floor(Math.random() * validClasses.length)]!; + const classRoleId = selectedClass.roleId!; - // Check if the role exists in the guild - const classRole = interaction.guild.roles.cache.get(classRoleId); - if (!classRole) { - await interaction.reply({ - ...getEnrollmentErrorEmbed(`The configured role ID \`${classRoleId}\` for class **${selectedClass.name}** does not exist in this server.`, "Configuration Error"), - flags: MessageFlags.Ephemeral - }); - return; - } + // Check if the role exists in the guild + const classRole = interaction.guild.roles.cache.get(classRoleId); + if (!classRole) { + throw new UserError(`The configured role ID \`${classRoleId}\` for class **${selectedClass.name}** does not exist in this server.`); + } - // 4. Perform Enrollment Actions + // 4. Perform Enrollment Actions + await member.roles.remove(visitorRole); + await member.roles.add(studentRole); + await member.roles.add(classRole); - await member.roles.remove(visitorRole); - await member.roles.add(studentRole); - await member.roles.add(classRole); + // Persist to DB + await classService.assignClass(user.id.toString(), selectedClass.id); - // Persist to DB - await classService.assignClass(user.id.toString(), selectedClass.id); + await interaction.reply({ + ...getEnrollmentSuccessMessage(classRole.name), + flags: MessageFlags.Ephemeral + }); - await interaction.reply({ - ...getEnrollmentSuccessMessage(classRole.name), - flags: MessageFlags.Ephemeral - }); + // 5. Send Welcome Message (if configured) + if (config.welcomeChannelId) { + const welcomeChannel = interaction.guild.channels.cache.get(config.welcomeChannelId); + if (welcomeChannel && welcomeChannel.isTextBased()) { + const rawMessage = config.welcomeMessage || "Welcome to Aurora, {user}! You have been enrolled as a **{class}**."; - // 5. Send Welcome Message (if configured) - if (config.welcomeChannelId) { - const welcomeChannel = interaction.guild.channels.cache.get(config.welcomeChannelId); - if (welcomeChannel && welcomeChannel.isTextBased()) { - const rawMessage = config.welcomeMessage || "Welcome to Aurora, {user}! You have been enrolled as a **{class}**."; + const processedMessage = rawMessage + .replace(/{user}/g, member.toString()) + .replace(/{username}/g, member.user.username) + .replace(/{class}/g, selectedClass.name) + .replace(/{guild}/g, interaction.guild.name); - const processedMessage = rawMessage - .replace(/{user}/g, member.toString()) - .replace(/{username}/g, member.user.username) - .replace(/{class}/g, selectedClass.name) - .replace(/{guild}/g, interaction.guild.name); - - let payload; - try { - payload = JSON.parse(processedMessage); - } catch { - payload = processedMessage; - } - - // Fire and forget webhook - sendWebhookMessage(welcomeChannel, payload, interaction.client.user, "New Student Enrollment") - .catch((err: any) => console.error("Failed to send welcome message:", err)); + let payload; + try { + payload = JSON.parse(processedMessage); + } catch { + payload = processedMessage; } - } - } catch (error) { - console.error("Enrollment error:", error); - await interaction.reply({ - ...getEnrollmentErrorEmbed("An unexpected error occurred during enrollment. Please contact an administrator.", "System Error"), - flags: MessageFlags.Ephemeral - }); + // Fire and forget webhook + sendWebhookMessage(welcomeChannel, payload, interaction.client.user, "New Student Enrollment") + .catch((err: any) => console.error("Failed to send welcome message:", err)); + } } } \ No newline at end of file