Compare commits
6 Commits
f44b053a10
...
5420653b2b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5420653b2b | ||
|
|
f13ef781b6 | ||
|
|
82a4281f9b | ||
|
|
0dbc532c7e | ||
|
|
953942f563 | ||
|
|
6334275d02 |
@@ -1,72 +1,20 @@
|
|||||||
import { Events, MessageFlags } from "discord.js";
|
import { Events } from "discord.js";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { ComponentInteractionHandler, AutocompleteHandler, CommandHandler } from "@/lib/handlers";
|
||||||
import { userService } from "@/modules/user/user.service";
|
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
|
||||||
import type { Event } from "@lib/types";
|
import type { Event } from "@lib/types";
|
||||||
|
|
||||||
const event: Event<Events.InteractionCreate> = {
|
const event: Event<Events.InteractionCreate> = {
|
||||||
name: Events.InteractionCreate,
|
name: Events.InteractionCreate,
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
// Handle Trade Interactions
|
|
||||||
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
|
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
|
||||||
const { interactionRoutes } = await import("@lib/interaction.routes");
|
return ComponentInteractionHandler.handle(interaction);
|
||||||
|
|
||||||
for (const route of interactionRoutes) {
|
|
||||||
if (route.predicate(interaction)) {
|
|
||||||
const module = await route.handler();
|
|
||||||
const handlerMethod = module[route.method];
|
|
||||||
if (typeof handlerMethod === 'function') {
|
|
||||||
await handlerMethod(interaction);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
console.error(`Handler method ${route.method} not found in module`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.isAutocomplete()) {
|
if (interaction.isAutocomplete()) {
|
||||||
const command = AuroraClient.commands.get(interaction.commandName);
|
return AutocompleteHandler.handle(interaction);
|
||||||
if (!command || !command.autocomplete) return;
|
|
||||||
try {
|
|
||||||
await command.autocomplete(interaction);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!interaction.isChatInputCommand()) return;
|
if (interaction.isChatInputCommand()) {
|
||||||
|
return CommandHandler.handle(interaction);
|
||||||
const command = AuroraClient.commands.get(interaction.commandName);
|
|
||||||
|
|
||||||
if (!command) {
|
|
||||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Ensure user exists in database
|
|
||||||
try {
|
|
||||||
const user = await userService.getUserById(interaction.user.id);
|
|
||||||
if (!user) {
|
|
||||||
console.log(`🆕 Creating new user entry for ${interaction.user.tag}`);
|
|
||||||
await userService.createUser(interaction.user.id, interaction.user.username);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to check/create user:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await command.execute(interaction);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
|
||||||
if (interaction.replied || interaction.deferred) {
|
|
||||||
await interaction.followUp({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
|
||||||
} else {
|
|
||||||
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
|
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
|
||||||
import { readdir } from "node:fs/promises";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { Command, Event } from "@lib/types";
|
import type { Command } from "@lib/types";
|
||||||
import { env } from "@lib/env";
|
import { env } from "@lib/env";
|
||||||
import { config } from "@lib/config";
|
import { CommandLoader } from "@lib/loaders/CommandLoader";
|
||||||
|
import { EventLoader } from "@lib/loaders/EventLoader";
|
||||||
|
|
||||||
class Client extends DiscordClient {
|
export class Client extends DiscordClient {
|
||||||
|
|
||||||
commands: Collection<string, Command>;
|
commands: Collection<string, Command>;
|
||||||
|
private commandLoader: CommandLoader;
|
||||||
|
private eventLoader: EventLoader;
|
||||||
|
|
||||||
constructor({ intents }: { intents: number[] }) {
|
constructor({ intents }: { intents: number[] }) {
|
||||||
super({ intents });
|
super({ intents });
|
||||||
this.commands = new Collection<string, Command>();
|
this.commands = new Collection<string, Command>();
|
||||||
|
this.commandLoader = new CommandLoader(this);
|
||||||
|
this.eventLoader = new EventLoader(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCommands(reload: boolean = false) {
|
async loadCommands(reload: boolean = false) {
|
||||||
@@ -21,7 +25,9 @@ class Client extends DiscordClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const commandsPath = join(import.meta.dir, '../commands');
|
const commandsPath = join(import.meta.dir, '../commands');
|
||||||
await this.readCommandsRecursively(commandsPath, reload);
|
const result = await this.commandLoader.loadFromDirectory(commandsPath, reload);
|
||||||
|
|
||||||
|
console.log(`📦 Command loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadEvents(reload: boolean = false) {
|
async loadEvents(reload: boolean = false) {
|
||||||
@@ -31,109 +37,12 @@ class Client extends DiscordClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const eventsPath = join(import.meta.dir, '../events');
|
const eventsPath = join(import.meta.dir, '../events');
|
||||||
await this.readEventsRecursively(eventsPath, reload);
|
const result = await this.eventLoader.loadFromDirectory(eventsPath, reload);
|
||||||
|
|
||||||
|
console.log(`📦 Event loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readCommandsRecursively(dir: string, reload: boolean = false) {
|
|
||||||
try {
|
|
||||||
const files = await readdir(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = join(dir, file.name);
|
|
||||||
|
|
||||||
if (file.isDirectory()) {
|
|
||||||
await this.readCommandsRecursively(filePath, reload);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
|
|
||||||
const commandModule = await import(importPath);
|
|
||||||
const commands = Object.values(commandModule);
|
|
||||||
if (commands.length === 0) {
|
|
||||||
console.warn(`⚠️ No commands found in ${file.name}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract category from parent directory name
|
|
||||||
// filePath is like /path/to/commands/admin/features.ts
|
|
||||||
// we want "admin"
|
|
||||||
const pathParts = filePath.split('/');
|
|
||||||
const category = pathParts[pathParts.length - 2];
|
|
||||||
|
|
||||||
for (const command of commands) {
|
|
||||||
if (this.isValidCommand(command)) {
|
|
||||||
command.category = category; // Inject category
|
|
||||||
|
|
||||||
const isEnabled = config.commands[command.data.name] !== false; // Default true if undefined
|
|
||||||
|
|
||||||
if (!isEnabled) {
|
|
||||||
console.log(`🚫 Skipping disabled command: ${command.data.name}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.commands.set(command.data.name, command);
|
|
||||||
console.log(`✅ Loaded command: ${command.data.name}`);
|
|
||||||
} else {
|
|
||||||
console.warn(`⚠️ Skipping invalid command in ${file.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ Failed to load command from ${filePath}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error reading directory ${dir}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readEventsRecursively(dir: string, reload: boolean = false) {
|
|
||||||
try {
|
|
||||||
const files = await readdir(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = join(dir, file.name);
|
|
||||||
|
|
||||||
if (file.isDirectory()) {
|
|
||||||
await this.readEventsRecursively(filePath, reload);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
|
|
||||||
const eventModule = await import(importPath);
|
|
||||||
const event = eventModule.default;
|
|
||||||
|
|
||||||
if (this.isValidEvent(event)) {
|
|
||||||
if (event.once) {
|
|
||||||
this.once(event.name, (...args) => event.execute(...args));
|
|
||||||
} else {
|
|
||||||
this.on(event.name, (...args) => event.execute(...args));
|
|
||||||
}
|
|
||||||
console.log(`✅ Loaded event: ${event.name}`);
|
|
||||||
} else {
|
|
||||||
console.warn(`⚠️ Skipping invalid event in ${file.name}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ Failed to load event from ${filePath}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error reading directory ${dir}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private isValidCommand(command: any): command is Command {
|
|
||||||
return command && typeof command === 'object' && 'data' in command && 'execute' in command;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isValidEvent(event: any): event is Event<any> {
|
|
||||||
return event && typeof event === 'object' && 'name' in event && 'execute' in event;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deployCommands() {
|
async deployCommands() {
|
||||||
// We use env.DISCORD_BOT_TOKEN directly so this can run without client.login()
|
// We use env.DISCORD_BOT_TOKEN directly so this can run without client.login()
|
||||||
|
|||||||
21
src/lib/handlers/AutocompleteHandler.ts
Normal file
21
src/lib/handlers/AutocompleteHandler.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { AutocompleteInteraction } from "discord.js";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles autocomplete interactions for slash commands
|
||||||
|
*/
|
||||||
|
export class AutocompleteHandler {
|
||||||
|
static async handle(interaction: AutocompleteInteraction): Promise<void> {
|
||||||
|
const command = AuroraClient.commands.get(interaction.commandName);
|
||||||
|
|
||||||
|
if (!command || !command.autocomplete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await command.autocomplete(interaction);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/lib/handlers/CommandHandler.ts
Normal file
39
src/lib/handlers/CommandHandler.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { userService } from "@/modules/user/user.service";
|
||||||
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles slash command execution
|
||||||
|
* Includes user validation and comprehensive error handling
|
||||||
|
*/
|
||||||
|
export class CommandHandler {
|
||||||
|
static async handle(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
const command = AuroraClient.commands.get(interaction.commandName);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure user exists in database
|
||||||
|
try {
|
||||||
|
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to ensure user exists:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await command.execute(interaction);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
||||||
|
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/lib/handlers/ComponentInteractionHandler.ts
Normal file
27
src/lib/handlers/ComponentInteractionHandler.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction } from "discord.js";
|
||||||
|
|
||||||
|
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles component interactions (buttons, select menus, modals)
|
||||||
|
* Routes to appropriate handlers based on customId patterns
|
||||||
|
*/
|
||||||
|
export class ComponentInteractionHandler {
|
||||||
|
static async handle(interaction: ComponentInteraction): Promise<void> {
|
||||||
|
const { interactionRoutes } = await import("@lib/interaction.routes");
|
||||||
|
|
||||||
|
for (const route of interactionRoutes) {
|
||||||
|
if (route.predicate(interaction)) {
|
||||||
|
const module = await route.handler();
|
||||||
|
const handlerMethod = module[route.method];
|
||||||
|
|
||||||
|
if (typeof handlerMethod === 'function') {
|
||||||
|
await handlerMethod(interaction);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.error(`Handler method ${route.method} not found in module`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/lib/handlers/index.ts
Normal file
3
src/lib/handlers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { ComponentInteractionHandler } from "./ComponentInteractionHandler";
|
||||||
|
export { AutocompleteHandler } from "./AutocompleteHandler";
|
||||||
|
export { CommandHandler } from "./CommandHandler";
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
||||||
|
|
||||||
type InteractionHandler = (interaction: any) => Promise<void>;
|
// Union type for all component interactions
|
||||||
|
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||||
|
|
||||||
|
// Type for the handler function that modules export
|
||||||
|
type InteractionHandler = (interaction: ComponentInteraction) => Promise<void>;
|
||||||
|
|
||||||
|
// Type for the dynamically imported module containing the handler
|
||||||
|
interface InteractionModule {
|
||||||
|
[key: string]: (...args: any[]) => Promise<void> | any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route definition
|
||||||
interface InteractionRoute {
|
interface InteractionRoute {
|
||||||
predicate: (interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction) => boolean;
|
predicate: (interaction: ComponentInteraction) => boolean;
|
||||||
handler: () => Promise<any>;
|
handler: () => Promise<InteractionModule>;
|
||||||
method: string;
|
method: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
110
src/lib/loaders/CommandLoader.ts
Normal file
110
src/lib/loaders/CommandLoader.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { readdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Command } from "@lib/types";
|
||||||
|
import { config } from "@lib/config";
|
||||||
|
import type { LoadResult, LoadError } from "./types";
|
||||||
|
import type { Client } from "../BotClient";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles loading commands from the file system
|
||||||
|
*/
|
||||||
|
export class CommandLoader {
|
||||||
|
private client: Client;
|
||||||
|
|
||||||
|
constructor(client: Client) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load commands from a directory recursively
|
||||||
|
*/
|
||||||
|
async loadFromDirectory(dir: string, reload: boolean = false): Promise<LoadResult> {
|
||||||
|
const result: LoadResult = { loaded: 0, skipped: 0, errors: [] };
|
||||||
|
await this.scanDirectory(dir, reload, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively scan directory for command files
|
||||||
|
*/
|
||||||
|
private async scanDirectory(dir: string, reload: boolean, result: LoadResult): Promise<void> {
|
||||||
|
try {
|
||||||
|
const files = await readdir(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = join(dir, file.name);
|
||||||
|
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
await this.scanDirectory(filePath, reload, result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
|
||||||
|
|
||||||
|
await this.loadCommandFile(filePath, reload, result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading directory ${dir}:`, error);
|
||||||
|
result.errors.push({ file: dir, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a single command file
|
||||||
|
*/
|
||||||
|
private async loadCommandFile(filePath: string, reload: boolean, result: LoadResult): Promise<void> {
|
||||||
|
try {
|
||||||
|
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
|
||||||
|
const commandModule = await import(importPath);
|
||||||
|
const commands = Object.values(commandModule);
|
||||||
|
|
||||||
|
if (commands.length === 0) {
|
||||||
|
console.warn(`⚠️ No commands found in ${filePath}`);
|
||||||
|
result.skipped++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = this.extractCategory(filePath);
|
||||||
|
|
||||||
|
for (const command of commands) {
|
||||||
|
if (this.isValidCommand(command)) {
|
||||||
|
command.category = category;
|
||||||
|
|
||||||
|
const isEnabled = config.commands[command.data.name] !== false;
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
console.log(`🚫 Skipping disabled command: ${command.data.name}`);
|
||||||
|
result.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client.commands.set(command.data.name, command);
|
||||||
|
console.log(`✅ Loaded command: ${command.data.name}`);
|
||||||
|
result.loaded++;
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ Skipping invalid command in ${filePath}`);
|
||||||
|
result.skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Failed to load command from ${filePath}:`, error);
|
||||||
|
result.errors.push({ file: filePath, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract category from file path
|
||||||
|
* e.g., /path/to/commands/admin/features.ts -> "admin"
|
||||||
|
*/
|
||||||
|
private extractCategory(filePath: string): string {
|
||||||
|
const pathParts = filePath.split('/');
|
||||||
|
return pathParts[pathParts.length - 2] ?? "uncategorized";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to validate command structure
|
||||||
|
*/
|
||||||
|
private isValidCommand(command: any): command is Command {
|
||||||
|
return command && typeof command === 'object' && 'data' in command && 'execute' in command;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/lib/loaders/EventLoader.ts
Normal file
84
src/lib/loaders/EventLoader.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { readdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Event } from "@lib/types";
|
||||||
|
import type { LoadResult } from "./types";
|
||||||
|
import type { Client } from "../BotClient";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles loading events from the file system
|
||||||
|
*/
|
||||||
|
export class EventLoader {
|
||||||
|
private client: Client;
|
||||||
|
|
||||||
|
constructor(client: Client) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load events from a directory recursively
|
||||||
|
*/
|
||||||
|
async loadFromDirectory(dir: string, reload: boolean = false): Promise<LoadResult> {
|
||||||
|
const result: LoadResult = { loaded: 0, skipped: 0, errors: [] };
|
||||||
|
await this.scanDirectory(dir, reload, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively scan directory for event files
|
||||||
|
*/
|
||||||
|
private async scanDirectory(dir: string, reload: boolean, result: LoadResult): Promise<void> {
|
||||||
|
try {
|
||||||
|
const files = await readdir(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = join(dir, file.name);
|
||||||
|
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
await this.scanDirectory(filePath, reload, result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
|
||||||
|
|
||||||
|
await this.loadEventFile(filePath, reload, result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading directory ${dir}:`, error);
|
||||||
|
result.errors.push({ file: dir, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a single event file
|
||||||
|
*/
|
||||||
|
private async loadEventFile(filePath: string, reload: boolean, result: LoadResult): Promise<void> {
|
||||||
|
try {
|
||||||
|
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
|
||||||
|
const eventModule = await import(importPath);
|
||||||
|
const event = eventModule.default;
|
||||||
|
|
||||||
|
if (this.isValidEvent(event)) {
|
||||||
|
if (event.once) {
|
||||||
|
this.client.once(event.name, (...args) => event.execute(...args));
|
||||||
|
} else {
|
||||||
|
this.client.on(event.name, (...args) => event.execute(...args));
|
||||||
|
}
|
||||||
|
console.log(`✅ Loaded event: ${event.name}`);
|
||||||
|
result.loaded++;
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ Skipping invalid event in ${filePath}`);
|
||||||
|
result.skipped++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Failed to load event from ${filePath}:`, error);
|
||||||
|
result.errors.push({ file: filePath, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to validate event structure
|
||||||
|
*/
|
||||||
|
private isValidEvent(event: any): event is Event<any> {
|
||||||
|
return event && typeof event === 'object' && 'name' in event && 'execute' in event;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/lib/loaders/types.ts
Normal file
16
src/lib/loaders/types.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Result of loading commands or events
|
||||||
|
*/
|
||||||
|
export interface LoadResult {
|
||||||
|
loaded: number;
|
||||||
|
skipped: number;
|
||||||
|
errors: LoadError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error that occurred during loading
|
||||||
|
*/
|
||||||
|
export interface LoadError {
|
||||||
|
file: string;
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ mockUpdate.mockReturnValue({ set: mockSet });
|
|||||||
mockSet.mockReturnValue({ where: mockWhere });
|
mockSet.mockReturnValue({ where: mockWhere });
|
||||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||||
|
|
||||||
|
mockDelete.mockReturnValue({ where: mockWhere });
|
||||||
|
|
||||||
// Mock DrizzleClient
|
// Mock DrizzleClient
|
||||||
mock.module("@/lib/DrizzleClient", () => {
|
mock.module("@/lib/DrizzleClient", () => {
|
||||||
@@ -51,12 +52,39 @@ mock.module("@/lib/DrizzleClient", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mock withTransaction helper to use the same pattern as DrizzleClient.transaction
|
||||||
|
mock.module("@/lib/db", () => {
|
||||||
|
return {
|
||||||
|
withTransaction: async (callback: any, tx?: any) => {
|
||||||
|
if (tx) {
|
||||||
|
return callback(tx);
|
||||||
|
}
|
||||||
|
// Simulate transaction by calling the callback with mock db
|
||||||
|
return callback({
|
||||||
|
query: {
|
||||||
|
users: {
|
||||||
|
findFirst: mockFindFirst,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
insert: mockInsert,
|
||||||
|
update: mockUpdate,
|
||||||
|
delete: mockDelete,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
describe("userService", () => {
|
describe("userService", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFindFirst.mockReset();
|
mockFindFirst.mockReset();
|
||||||
mockInsert.mockClear();
|
mockInsert.mockClear();
|
||||||
mockValues.mockClear();
|
mockValues.mockClear();
|
||||||
mockReturning.mockClear();
|
mockReturning.mockClear();
|
||||||
|
mockUpdate.mockClear();
|
||||||
|
mockSet.mockClear();
|
||||||
|
mockWhere.mockClear();
|
||||||
|
mockDelete.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getUserById", () => {
|
describe("getUserById", () => {
|
||||||
@@ -80,7 +108,91 @@ describe("userService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createUser", () => {
|
describe("getUserByUsername", () => {
|
||||||
|
it("should return user when username exists", async () => {
|
||||||
|
const mockUser = { id: 456n, username: "alice", balance: 100n };
|
||||||
|
mockFindFirst.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const result = await userService.getUserByUsername("alice");
|
||||||
|
|
||||||
|
expect(result).toEqual(mockUser as any);
|
||||||
|
expect(mockFindFirst).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined when username not found", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await userService.getUserByUsername("nonexistent");
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getUserClass", () => {
|
||||||
|
it("should return user class when user has a class", async () => {
|
||||||
|
const mockClass = { id: 1n, name: "Warrior", emoji: "⚔️" };
|
||||||
|
const mockUser = { id: 123n, username: "testuser", class: mockClass };
|
||||||
|
mockFindFirst.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const result = await userService.getUserClass("123");
|
||||||
|
|
||||||
|
expect(result).toEqual(mockClass as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when user has no class", async () => {
|
||||||
|
const mockUser = { id: 123n, username: "testuser", class: null };
|
||||||
|
mockFindFirst.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const result = await userService.getUserClass("123");
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined when user not found", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await userService.getUserClass("999");
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getOrCreateUser (withTransaction)", () => {
|
||||||
|
it("should return existing user if found", async () => {
|
||||||
|
const mockUser = { id: 123n, username: "existinguser", class: null };
|
||||||
|
mockFindFirst.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const result = await userService.getOrCreateUser("123", "existinguser");
|
||||||
|
|
||||||
|
expect(result).toEqual(mockUser as any);
|
||||||
|
expect(mockFindFirst).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create new user if not found", async () => {
|
||||||
|
const newUser = { id: 789n, username: "newuser", classId: null };
|
||||||
|
|
||||||
|
// First call returns undefined (user not found)
|
||||||
|
// Second call returns the newly created user (after insert + re-query)
|
||||||
|
mockFindFirst
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockResolvedValueOnce({ id: 789n, username: "newuser", class: null });
|
||||||
|
|
||||||
|
mockReturning.mockResolvedValue([newUser]);
|
||||||
|
|
||||||
|
const result = await userService.getOrCreateUser("789", "newuser");
|
||||||
|
|
||||||
|
expect(mockInsert).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockValues).toHaveBeenCalledWith({
|
||||||
|
id: 789n,
|
||||||
|
username: "newuser"
|
||||||
|
});
|
||||||
|
// Should query twice: once to check, once after insert
|
||||||
|
expect(mockFindFirst).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createUser (withTransaction)", () => {
|
||||||
it("should create and return a new user", async () => {
|
it("should create and return a new user", async () => {
|
||||||
const newUser = { id: 456n, username: "newuser", classId: null };
|
const newUser = { id: 456n, username: "newuser", classId: null };
|
||||||
mockReturning.mockResolvedValue([newUser]);
|
mockReturning.mockResolvedValue([newUser]);
|
||||||
@@ -95,5 +207,53 @@ describe("userService", () => {
|
|||||||
classId: undefined
|
classId: undefined
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should create user with classId when provided", async () => {
|
||||||
|
const newUser = { id: 999n, username: "warrior", classId: 5n };
|
||||||
|
mockReturning.mockResolvedValue([newUser]);
|
||||||
|
|
||||||
|
const result = await userService.createUser("999", "warrior", 5n);
|
||||||
|
|
||||||
|
expect(result).toEqual(newUser as any);
|
||||||
|
expect(mockValues).toHaveBeenCalledWith({
|
||||||
|
id: 999n,
|
||||||
|
username: "warrior",
|
||||||
|
classId: 5n
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateUser (withTransaction)", () => {
|
||||||
|
it("should update user data", async () => {
|
||||||
|
const updatedUser = { id: 123n, username: "testuser", balance: 500n };
|
||||||
|
mockReturning.mockResolvedValue([updatedUser]);
|
||||||
|
|
||||||
|
const result = await userService.updateUser("123", { balance: 500n });
|
||||||
|
|
||||||
|
expect(result).toEqual(updatedUser as any);
|
||||||
|
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockSet).toHaveBeenCalledWith({ balance: 500n });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update multiple fields", async () => {
|
||||||
|
const updatedUser = { id: 456n, username: "alice", xp: 100n, level: 5 };
|
||||||
|
mockReturning.mockResolvedValue([updatedUser]);
|
||||||
|
|
||||||
|
const result = await userService.updateUser("456", { xp: 100n, level: 5 });
|
||||||
|
|
||||||
|
expect(result).toEqual(updatedUser as any);
|
||||||
|
expect(mockSet).toHaveBeenCalledWith({ xp: 100n, level: 5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteUser (withTransaction)", () => {
|
||||||
|
it("should delete user from database", async () => {
|
||||||
|
mockWhere.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await userService.deleteUser("123");
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockWhere).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { users } from "@/db/schema";
|
import { users } from "@/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
|
import { withTransaction } from "@/lib/db";
|
||||||
|
import type { Transaction } from "@/lib/types";
|
||||||
|
|
||||||
export const userService = {
|
export const userService = {
|
||||||
getUserById: async (id: string) => {
|
getUserById: async (id: string) => {
|
||||||
@@ -14,23 +16,27 @@ export const userService = {
|
|||||||
const user = await DrizzleClient.query.users.findFirst({ where: eq(users.username, username) });
|
const user = await DrizzleClient.query.users.findFirst({ where: eq(users.username, username) });
|
||||||
return user;
|
return user;
|
||||||
},
|
},
|
||||||
getOrCreateUser: async (id: string, username: string, tx?: any) => {
|
getOrCreateUser: async (id: string, username: string, tx?: Transaction) => {
|
||||||
const execute = async (txFn: any) => {
|
return await withTransaction(async (txFn) => {
|
||||||
let user = await txFn.query.users.findFirst({
|
let user = await txFn.query.users.findFirst({
|
||||||
where: eq(users.id, BigInt(id)),
|
where: eq(users.id, BigInt(id)),
|
||||||
with: { class: true }
|
with: { class: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
const [newUser] = await txFn.insert(users).values({
|
await txFn.insert(users).values({
|
||||||
id: BigInt(id),
|
id: BigInt(id),
|
||||||
username,
|
username,
|
||||||
}).returning();
|
}).returning();
|
||||||
user = { ...newUser, class: null };
|
|
||||||
|
// Re-query to get the user with class relation
|
||||||
|
user = await txFn.query.users.findFirst({
|
||||||
|
where: eq(users.id, BigInt(id)),
|
||||||
|
with: { class: true }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return user;
|
return user;
|
||||||
};
|
}, tx);
|
||||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
|
||||||
},
|
},
|
||||||
getUserClass: async (id: string) => {
|
getUserClass: async (id: string) => {
|
||||||
const user = await DrizzleClient.query.users.findFirst({
|
const user = await DrizzleClient.query.users.findFirst({
|
||||||
@@ -39,31 +45,28 @@ export const userService = {
|
|||||||
});
|
});
|
||||||
return user?.class;
|
return user?.class;
|
||||||
},
|
},
|
||||||
createUser: async (id: string | bigint, username: string, classId?: bigint, tx?: any) => {
|
createUser: async (id: string | bigint, username: string, classId?: bigint, tx?: Transaction) => {
|
||||||
const execute = async (txFn: any) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const [user] = await txFn.insert(users).values({
|
const [user] = await txFn.insert(users).values({
|
||||||
id: BigInt(id),
|
id: BigInt(id),
|
||||||
username,
|
username,
|
||||||
classId,
|
classId,
|
||||||
}).returning();
|
}).returning();
|
||||||
return user;
|
return user;
|
||||||
};
|
}, tx);
|
||||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
|
||||||
},
|
},
|
||||||
updateUser: async (id: string, data: Partial<typeof users.$inferInsert>, tx?: any) => {
|
updateUser: async (id: string, data: Partial<typeof users.$inferInsert>, tx?: Transaction) => {
|
||||||
const execute = async (txFn: any) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const [user] = await txFn.update(users)
|
const [user] = await txFn.update(users)
|
||||||
.set(data)
|
.set(data)
|
||||||
.where(eq(users.id, BigInt(id)))
|
.where(eq(users.id, BigInt(id)))
|
||||||
.returning();
|
.returning();
|
||||||
return user;
|
return user;
|
||||||
};
|
}, tx);
|
||||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
|
||||||
},
|
},
|
||||||
deleteUser: async (id: string, tx?: any) => {
|
deleteUser: async (id: string, tx?: Transaction) => {
|
||||||
const execute = async (txFn: any) => {
|
return await withTransaction(async (txFn) => {
|
||||||
await txFn.delete(users).where(eq(users.id, BigInt(id)));
|
await txFn.delete(users).where(eq(users.id, BigInt(id)));
|
||||||
};
|
}, tx);
|
||||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user