Compare commits
3 Commits
1523a392c2
...
66d5145885
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66d5145885 | ||
|
|
2412098536 | ||
|
|
d0c48188b9 |
72
docs/MODULE_STRUCTURE.md
Normal file
72
docs/MODULE_STRUCTURE.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Aurora Module Structure Guide
|
||||||
|
|
||||||
|
This guide documents the standard module organization patterns used in the Aurora codebase. Following these patterns ensures consistency, maintainability, and clear separation of concerns.
|
||||||
|
|
||||||
|
## Module Anatomy
|
||||||
|
|
||||||
|
A typical module in `@modules/` is organized into several files, each with a specific responsibility.
|
||||||
|
|
||||||
|
Example: `trade` module
|
||||||
|
- `trade.service.ts`: Business logic and data access.
|
||||||
|
- `trade.view.ts`: Discord UI components (embeds, modals, select menus).
|
||||||
|
- `trade.interaction.ts`: Handler for interaction events (buttons, modals, etc.).
|
||||||
|
- `trade.types.ts`: TypeScript interfaces and types.
|
||||||
|
- `trade.service.test.ts`: Unit tests for the service logic.
|
||||||
|
|
||||||
|
## File Responsibilities
|
||||||
|
|
||||||
|
### 1. Service (`*.service.ts`)
|
||||||
|
The core of the module. It contains the business logic, database interactions (using Drizzle), and state management.
|
||||||
|
- **Rules**:
|
||||||
|
- Export a singleton instance: `export const tradeService = new TradeService();`
|
||||||
|
- Should not contain Discord-specific rendering logic (return data, not embeds).
|
||||||
|
- Throw `UserError` for validation issues that should be shown to the user.
|
||||||
|
|
||||||
|
### 2. View (`*.view.ts`)
|
||||||
|
Handles the creation of Discord-specific UI elements like `EmbedBuilder`, `ActionRowBuilder`, and `ModalBuilder`.
|
||||||
|
- **Rules**:
|
||||||
|
- Focus on formatting and presentation.
|
||||||
|
- Takes raw data (from services) and returns Discord components.
|
||||||
|
|
||||||
|
### 3. Interaction Handler (`*.interaction.ts`)
|
||||||
|
The entry point for Discord component interactions (buttons, select menus, modals).
|
||||||
|
- **Rules**:
|
||||||
|
- Export a single handler function: `export async function handleTradeInteraction(interaction: Interaction) { ... }`
|
||||||
|
- Routes internal `customId` patterns to specific logic.
|
||||||
|
- Relies on `ComponentInteractionHandler` for centralized error handling.
|
||||||
|
- **No local try-catch** for standard validation errors; let them bubble up as `UserError`.
|
||||||
|
|
||||||
|
### 4. Types (`*.types.ts`)
|
||||||
|
Central location for module-specific TypeScript types and constants.
|
||||||
|
- **Rules**:
|
||||||
|
- Define interfaces for complex data structures.
|
||||||
|
- Use enums or literal types for states and custom IDs.
|
||||||
|
|
||||||
|
## Interaction Routing
|
||||||
|
|
||||||
|
All interaction handlers must be registered in `src/lib/interaction.routes.ts`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
predicate: (i) => i.customId.startsWith("module_"),
|
||||||
|
handler: () => import("@/modules/module/module.interaction"),
|
||||||
|
method: 'handleModuleInteraction'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling Standards
|
||||||
|
|
||||||
|
Aurora uses a centralized error handling pattern in `ComponentInteractionHandler`.
|
||||||
|
|
||||||
|
1. **UserError**: Use this for validation errors or issues the user can fix (e.g., "Insufficient funds").
|
||||||
|
- `throw new UserError("You need more coins!");`
|
||||||
|
2. **SystemError / Generic Error**: Use this for unexpected system failures.
|
||||||
|
- These are logged to the console/logger and show a generic "Unexpected error" message to the user.
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
- **Directory Name**: Lowercase, singular (e.g., `trade`, `inventory`).
|
||||||
|
- **File Names**: `moduleName.type.ts` (e.g., `trade.service.ts`).
|
||||||
|
- **Class Names**: PascalCase (e.g., `TradeService`).
|
||||||
|
- **Service Instances**: camelCase (e.g., `tradeService`).
|
||||||
|
- **Interaction Method**: `handle[ModuleName]Interaction`.
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction } from "discord.js";
|
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
||||||
import { logger } from "@lib/logger";
|
import { logger } from "@lib/logger";
|
||||||
|
import { UserError } from "@lib/errors";
|
||||||
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles component interactions (buttons, select menus, modals)
|
* Handles component interactions (buttons, select menus, modals)
|
||||||
* Routes to appropriate handlers based on customId patterns
|
* Routes to appropriate handlers based on customId patterns
|
||||||
|
* Provides centralized error handling with UserError differentiation
|
||||||
*/
|
*/
|
||||||
export class ComponentInteractionHandler {
|
export class ComponentInteractionHandler {
|
||||||
static async handle(interaction: ComponentInteraction): Promise<void> {
|
static async handle(interaction: ComponentInteraction): Promise<void> {
|
||||||
@@ -17,12 +20,59 @@ export class ComponentInteractionHandler {
|
|||||||
const handlerMethod = module[route.method];
|
const handlerMethod = module[route.method];
|
||||||
|
|
||||||
if (typeof handlerMethod === 'function') {
|
if (typeof handlerMethod === 'function') {
|
||||||
await handlerMethod(interaction);
|
try {
|
||||||
return;
|
await handlerMethod(interaction);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
await this.handleError(interaction, error, route.method);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error(`Handler method ${route.method} not found in module`);
|
logger.error(`Handler method ${route.method} not found in module`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles errors from interaction handlers
|
||||||
|
* Differentiates between UserError (user-facing) and system errors
|
||||||
|
*/
|
||||||
|
private static async handleError(
|
||||||
|
interaction: ComponentInteraction,
|
||||||
|
error: unknown,
|
||||||
|
handlerName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const isUserError = error instanceof UserError;
|
||||||
|
|
||||||
|
// Determine error message
|
||||||
|
const errorMessage = isUserError
|
||||||
|
? (error as Error).message
|
||||||
|
: 'An unexpected error occurred. Please try again later.';
|
||||||
|
|
||||||
|
// Log system errors (non-user errors) for debugging
|
||||||
|
if (!isUserError) {
|
||||||
|
logger.error(`Error in ${handlerName}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorEmbed = createErrorEmbed(errorMessage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle different interaction states
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp({
|
||||||
|
embeds: [errorEmbed],
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [errorEmbed],
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (replyError) {
|
||||||
|
// If we can't send a reply, log it
|
||||||
|
logger.error(`Failed to send error response in ${handlerName}:`, replyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ interface InteractionRoute {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const interactionRoutes: InteractionRoute[] = [
|
export const interactionRoutes: InteractionRoute[] = [
|
||||||
|
// --- TRADE MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
|
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
|
||||||
handler: () => import("@/modules/trade/trade.interaction"),
|
handler: () => import("@/modules/trade/trade.interaction"),
|
||||||
method: 'handleTradeInteraction'
|
method: 'handleTradeInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- ECONOMY MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
|
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
|
||||||
handler: () => import("@/modules/economy/shop.interaction"),
|
handler: () => import("@/modules/economy/shop.interaction"),
|
||||||
@@ -34,16 +37,22 @@ export const interactionRoutes: InteractionRoute[] = [
|
|||||||
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
||||||
method: 'handleLootdropInteraction'
|
method: 'handleLootdropInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- ADMIN MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.customId.startsWith("createitem_"),
|
predicate: (i) => i.customId.startsWith("createitem_"),
|
||||||
handler: () => import("@/modules/admin/item_wizard"),
|
handler: () => import("@/modules/admin/item_wizard"),
|
||||||
method: 'handleItemWizardInteraction'
|
method: 'handleItemWizardInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- USER MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.isButton() && i.customId === "enrollment",
|
predicate: (i) => i.isButton() && i.customId === "enrollment",
|
||||||
handler: () => import("@/modules/user/enrollment.interaction"),
|
handler: () => import("@/modules/user/enrollment.interaction"),
|
||||||
method: 'handleEnrollmentInteraction'
|
method: 'handleEnrollmentInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- FEEDBACK MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.customId.startsWith("feedback_"),
|
predicate: (i) => i.customId.startsWith("feedback_"),
|
||||||
handler: () => import("@/modules/feedback/feedback.interaction"),
|
handler: () => import("@/modules/feedback/feedback.interaction"),
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} 🪙.` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user