refactor(modules): standardize error handling in interaction handlers

This commit is contained in:
syntaxbullet
2025-12-24 22:26:12 +01:00
parent d0c48188b9
commit 2412098536
5 changed files with 166 additions and 243 deletions

View File

@@ -1,6 +1,6 @@
import { ButtonInteraction } from "discord.js"; import { ButtonInteraction } from "discord.js";
import { lootdropService } from "./lootdrop.service"; import { lootdropService } from "./lootdrop.service";
import { createErrorEmbed } from "@/lib/embeds"; import { UserError } from "@/lib/errors";
import { getLootdropClaimedMessage } from "./lootdrop.view"; import { getLootdropClaimedMessage } from "./lootdrop.view";
export async function handleLootdropInteraction(interaction: ButtonInteraction) { 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); const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
if (result.success) { if (!result.success) {
await interaction.editReply({ throw new UserError(result.error || "Failed to claim.");
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.")]
});
} }
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 });
} }
} }

View File

@@ -1,40 +1,34 @@
import { ButtonInteraction, MessageFlags } from "discord.js"; import { ButtonInteraction, MessageFlags } from "discord.js";
import { inventoryService } from "@/modules/inventory/inventory.service"; import { inventoryService } from "@/modules/inventory/inventory.service";
import { userService } from "@/modules/user/user.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) { export async function handleShopInteraction(interaction: ButtonInteraction) {
if (!interaction.customId.startsWith("shop_buy_")) return; 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_", "")); const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
if (isNaN(itemId)) { if (isNaN(itemId)) {
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Item ID.")] }); throw new UserError("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 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} 🪙.` });
} }

View File

@@ -4,7 +4,7 @@ import { config } from "@/lib/config";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view"; import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types"; 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) => { export const handleFeedbackInteraction = async (interaction: Interaction) => {
// Handle select menu for choosing feedback type // Handle select menu for choosing feedback type
@@ -12,11 +12,7 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
const feedbackType = interaction.values[0] as FeedbackType; const feedbackType = interaction.values[0] as FeedbackType;
if (!feedbackType) { if (!feedbackType) {
await interaction.reply({ throw new UserError("Invalid feedback type selected.");
embeds: [createErrorEmbed("Invalid feedback type selected.")],
ephemeral: true
});
return;
} }
const modal = getFeedbackModal(feedbackType); const modal = getFeedbackModal(feedbackType);
@@ -34,79 +30,50 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
if (!feedbackType || !["FEATURE_REQUEST", "BUG_REPORT", "GENERAL"].includes(feedbackType)) { if (!feedbackType || !["FEATURE_REQUEST", "BUG_REPORT", "GENERAL"].includes(feedbackType)) {
console.error(`Invalid feedback type extracted: ${feedbackType} from customId: ${interaction.customId}`); console.error(`Invalid feedback type extracted: ${feedbackType} from customId: ${interaction.customId}`);
await interaction.reply({ throw new UserError("An error occurred processing your feedback. Please try again.");
embeds: [createErrorEmbed("An error occurred processing your feedback. Please try again.")],
ephemeral: true
});
return;
} }
if (!config.feedbackChannelId) { if (!config.feedbackChannelId) {
await interaction.reply({ throw new UserError("Feedback channel is not configured. Please contact an administrator.");
embeds: [createErrorEmbed("Feedback channel is not configured. Please contact an administrator.")],
ephemeral: true
});
return;
} }
try { // Parse modal inputs
// Parse modal inputs const title = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.TITLE_FIELD);
const title = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.TITLE_FIELD); const description = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD);
const description = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD);
// Build feedback data // Build feedback data
const feedbackData: FeedbackData = { const feedbackData: FeedbackData = {
type: feedbackType, type: feedbackType,
title, title,
description, description,
userId: interaction.user.id, userId: interaction.user.id,
username: interaction.user.username, username: interaction.user.username,
timestamp: new Date() timestamp: new Date()
}; };
// Get feedback channel // Get feedback channel
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null; const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
if (!channel) { if (!channel) {
await interaction.reply({ throw new UserError("Feedback channel not found. Please contact an administrator.");
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
});
}
} }
// 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
});
} }
}; };

View File

@@ -10,6 +10,7 @@ import {
import { tradeService } from "./trade.service"; import { tradeService } from "./trade.service";
import { inventoryService } from "@/modules/inventory/inventory.service"; import { inventoryService } from "@/modules/inventory/inventory.service";
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds"; import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
import { UserError } from "@lib/errors";
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view"; import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
@@ -22,35 +23,26 @@ export async function handleTradeInteraction(interaction: Interaction) {
if (!threadId) return; if (!threadId) return;
try { if (customId === 'trade_cancel') {
if (customId === 'trade_cancel') { await handleCancel(interaction, threadId);
await handleCancel(interaction, threadId); } else if (customId === 'trade_lock') {
} else if (customId === 'trade_lock') { await handleLock(interaction, threadId);
await handleLock(interaction, threadId); } else if (customId === 'trade_confirm') {
} else if (customId === 'trade_confirm') { // Confirm logic is handled implicitly by both locking or explicitly if needed.
// 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
// 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.
// unless we re-introduce a specific button. keeping basic handler stub if needed. } else if (customId === 'trade_add_money') {
} else if (customId === 'trade_add_money') { await handleAddMoneyClick(interaction);
await handleAddMoneyClick(interaction); } else if (customId === 'trade_money_modal') {
} else if (customId === 'trade_money_modal') { await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId);
await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId); } else if (customId === 'trade_add_item') {
} else if (customId === 'trade_add_item') { await handleAddItemClick(interaction as ButtonInteraction, threadId);
await handleAddItemClick(interaction as ButtonInteraction, threadId); } else if (customId === 'trade_select_item') {
} else if (customId === 'trade_select_item') { await handleItemSelect(interaction as StringSelectMenuInteraction, threadId);
await handleItemSelect(interaction as StringSelectMenuInteraction, threadId); } else if (customId === 'trade_remove_item') {
} else if (customId === 'trade_remove_item') { await handleRemoveItemClick(interaction as ButtonInteraction, threadId);
await handleRemoveItemClick(interaction as ButtonInteraction, threadId); } else if (customId === 'trade_remove_item_select') {
} else if (customId === 'trade_remove_item_select') { await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
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 });
}
} }
} }
@@ -93,7 +85,7 @@ async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId:
const amountStr = interaction.fields.getTextInputValue('amount'); const amountStr = interaction.fields.getTextInputValue('amount');
const amount = BigInt(amountStr); 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); tradeService.updateMoney(threadId, interaction.user.id, amount);
await interaction.deferUpdate(); // Acknowledge modal await interaction.deferUpdate(); // Acknowledge modal
@@ -126,7 +118,7 @@ async function handleItemSelect(interaction: StringSelectMenuInteraction, thread
// Assuming implementation implies adding 1 item for now // Assuming implementation implies adding 1 item for now
const item = await inventoryService.getItem(itemId); 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); tradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);

View File

@@ -1,120 +1,93 @@
import { ButtonInteraction, MessageFlags } from "discord.js"; import { ButtonInteraction, MessageFlags } from "discord.js";
import { config } from "@/lib/config"; import { config } from "@/lib/config";
import { getEnrollmentErrorEmbed, getEnrollmentSuccessMessage } from "./enrollment.view"; import { getEnrollmentSuccessMessage } from "./enrollment.view";
import { classService } from "@modules/class/class.service"; import { classService } from "@modules/class/class.service";
import { userService } from "@modules/user/user.service"; import { userService } from "@modules/user/user.service";
import { UserError } from "@/lib/errors";
import { sendWebhookMessage } from "@/lib/webhookUtils"; import { sendWebhookMessage } from "@/lib/webhookUtils";
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) { export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {
if (!interaction.inCachedGuild()) { if (!interaction.inCachedGuild()) {
await interaction.reply({ content: "This action can only be performed in a server.", flags: MessageFlags.Ephemeral }); throw new UserError("This action can only be performed in a server.");
return;
} }
const { studentRole, visitorRole } = config; const { studentRole, visitorRole } = config;
if (!studentRole || !visitorRole) { if (!studentRole || !visitorRole) {
await interaction.reply({ throw new UserError("No student or visitor role configured for enrollment.");
...getEnrollmentErrorEmbed("No student or visitor role configured for enrollment.", "Configuration Error"),
flags: MessageFlags.Ephemeral
});
return;
} }
try { // 1. Ensure user exists in DB and check current enrollment status
// 1. Ensure user exists in DB and check current enrollment status const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
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 // Check DB enrollment
if (user.class) { if (user.class) {
await interaction.reply({ throw new UserError("You are already enrolled in a class.");
...getEnrollmentErrorEmbed("You are already enrolled in a class.", "Enrollment Failed"), }
flags: MessageFlags.Ephemeral
});
return;
}
const member = interaction.member; const member = interaction.member;
// Check Discord role enrollment (Double safety) // Check Discord role enrollment (Double safety)
if (member.roles.cache.has(studentRole)) { if (member.roles.cache.has(studentRole)) {
await interaction.reply({ throw new UserError("You already have the student role.");
...getEnrollmentErrorEmbed("You already have the student role.", "Enrollment Failed"), }
flags: MessageFlags.Ephemeral
});
return;
}
// 2. Get available classes // 2. Get available classes
const allClasses = await classService.getAllClasses(); const allClasses = await classService.getAllClasses();
const validClasses = allClasses.filter(c => c.roleId); const validClasses = allClasses.filter(c => c.roleId);
if (validClasses.length === 0) { if (validClasses.length === 0) {
await interaction.reply({ throw new UserError("No classes with specified roles found in database.");
...getEnrollmentErrorEmbed("No classes with specified roles found in database.", "Configuration Error"), }
flags: MessageFlags.Ephemeral
});
return;
}
// 3. Pick random class // 3. Pick random class
const selectedClass = validClasses[Math.floor(Math.random() * validClasses.length)]!; const selectedClass = validClasses[Math.floor(Math.random() * validClasses.length)]!;
const classRoleId = selectedClass.roleId!; const classRoleId = selectedClass.roleId!;
// Check if the role exists in the guild // Check if the role exists in the guild
const classRole = interaction.guild.roles.cache.get(classRoleId); const classRole = interaction.guild.roles.cache.get(classRoleId);
if (!classRole) { if (!classRole) {
await interaction.reply({ throw new UserError(`The configured role ID \`${classRoleId}\` for class **${selectedClass.name}** does not exist in this server.`);
...getEnrollmentErrorEmbed(`The configured role ID \`${classRoleId}\` for class **${selectedClass.name}** does not exist in this server.`, "Configuration Error"), }
flags: MessageFlags.Ephemeral
});
return;
}
// 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); // Persist to DB
await member.roles.add(studentRole); await classService.assignClass(user.id.toString(), selectedClass.id);
await member.roles.add(classRole);
// Persist to DB await interaction.reply({
await classService.assignClass(user.id.toString(), selectedClass.id); ...getEnrollmentSuccessMessage(classRole.name),
flags: MessageFlags.Ephemeral
});
await interaction.reply({ // 5. Send Welcome Message (if configured)
...getEnrollmentSuccessMessage(classRole.name), if (config.welcomeChannelId) {
flags: MessageFlags.Ephemeral 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) const processedMessage = rawMessage
if (config.welcomeChannelId) { .replace(/{user}/g, member.toString())
const welcomeChannel = interaction.guild.channels.cache.get(config.welcomeChannelId); .replace(/{username}/g, member.user.username)
if (welcomeChannel && welcomeChannel.isTextBased()) { .replace(/{class}/g, selectedClass.name)
const rawMessage = config.welcomeMessage || "Welcome to Aurora, {user}! You have been enrolled as a **{class}**."; .replace(/{guild}/g, interaction.guild.name);
const processedMessage = rawMessage let payload;
.replace(/{user}/g, member.toString()) try {
.replace(/{username}/g, member.user.username) payload = JSON.parse(processedMessage);
.replace(/{class}/g, selectedClass.name) } catch {
.replace(/{guild}/g, interaction.guild.name); payload = processedMessage;
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));
} }
}
} catch (error) { // Fire and forget webhook
console.error("Enrollment error:", error); sendWebhookMessage(welcomeChannel, payload, interaction.client.user, "New Student Enrollment")
await interaction.reply({ .catch((err: any) => console.error("Failed to send welcome message:", err));
...getEnrollmentErrorEmbed("An unexpected error occurred during enrollment. Please contact an administrator.", "System Error"), }
flags: MessageFlags.Ephemeral
});
} }
} }