From 0dbc532c7ea2d7afdeb9205e67e57956b4bd255d Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 24 Dec 2025 21:32:08 +0100 Subject: [PATCH] feat(lib): extract CommandLoader from BotClient - Create dedicated CommandLoader class for command loading logic - Implement recursive directory scanning - Add category extraction from file paths - Add command validation and config-based filtering - Improve error handling with structured results - Enable better testability and separation of concerns --- src/lib/loaders/CommandLoader.ts | 110 +++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/lib/loaders/CommandLoader.ts diff --git a/src/lib/loaders/CommandLoader.ts b/src/lib/loaders/CommandLoader.ts new file mode 100644 index 0000000..0a76519 --- /dev/null +++ b/src/lib/loaders/CommandLoader.ts @@ -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 { + 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 { + 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 { + 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; + } +}