From 8e3f2c390495deb04598f3778e983022c5b93e22 Mon Sep 17 00:00:00 2001 From: neru Date: Sat, 10 Jan 2026 10:10:41 -0300 Subject: [PATCH] feat: add command mgr --- src/commands.ts | 197 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 src/commands.ts diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..3d201e2 --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,197 @@ +import fs from 'fs'; +import path from 'path'; + +import { + AutocompleteInteraction, + ChatInputCommandInteraction, + Message, + REST, + Routes, + SlashCommandOptionsOnlyBuilder, + VoiceState +} from 'discord.js'; +import { Logger } from './utils/log'; +import { Bot } from './bot'; +import { config } from './utils/config'; + +export interface Command { + name?: string; + requiresAdmin: boolean; + execute?: (interaction: ChatInputCommandInteraction) => Promise; + autocomplete?: (interaction: AutocompleteInteraction) => Promise; + messageListener?: (msg: Message) => Promise; + voiceStateListener?: ( + prevState: VoiceState, + newState: VoiceState + ) => Promise; + builder?: SlashCommandOptionsOnlyBuilder; +} + +interface CommandCategoryInfo { + name: string; + description: string; +} + +interface CommandCategory { + info: CommandCategoryInfo; + commands: Command[]; +} + +export class CommandManager { + private readonly folderPath: string; + private readonly log: Logger; + + private initialized: boolean = false; + private categories: Array = []; + + /* + public + */ + public constructor(cmdFolder: string) { + this.folderPath = path.resolve(__dirname, cmdFolder); + this.log = new Logger('Command manager'); + } + + public async init(): Promise { + if (this.initialized) + throw new Error('CommandManager was already initialized'); + this.initialized = true; + await this.populateCommands(); + this.registerSlashCommands(); + } + + public getCategories(): Array { + return this.categories; + } + + public get(commandName: string): Command | null { + return this.getAll().find((cmd) => cmd.name === commandName) || null; + } + + public getAll(): Command[] { + return this.categories.flatMap((cat) => cat.commands); + } + + public getAllInteractable(): Command[] { + return this.getAll().filter((cmd) => !!cmd.name); + } + + /* + internal + */ + private async populateCommands() { + if (!fs.existsSync(this.folderPath)) + throw new Error(`Command directory not found: ${this.folderPath}`); + + const categoryFolders = fs.readdirSync(this.folderPath); + + for (const categoryFolder of categoryFolders) { + const catPath = path.join(this.folderPath, categoryFolder); + await this.processCategory(catPath, categoryFolder); + } + } + + private async processCategory( + catPath: string, + folderName: string + ): Promise { + try { + const stat = fs.statSync(catPath); + if (!stat.isDirectory()) { + this.log.warning( + 'Skipping non-directory entry found on cmd folder (%s)', + folderName + ); + return; + } + + const catInfo = await this.loadCatInfo(catPath); + const commands = await this.loadCatCommands(catPath); + + if (catInfo == undefined) { + this.log.warning('Folder %s was missing info, ignoring', folderName); + return; + } + + this.categories.push({ + info: catInfo, + commands: commands + }); + } catch (err) { + this.log.error('Error processing category %s: %s', folderName, err); + } + } + + private async loadCatInfo( + catPath: string + ): Promise { + try { + const descriptorPath = path.join(catPath, 'category.json'); + if (!fs.existsSync(descriptorPath)) + throw new Error('Missing categoryinfo.json'); + + const content = await fs.promises.readFile(descriptorPath, 'utf-8'); + return JSON.parse(content) as CommandCategoryInfo; + } catch (err) { + this.log.error('Error loading category info at %s: %s', catPath, err); + return undefined; + } + } + + private async loadCatCommands(catPath: string): Promise> { + const promises = fs + .readdirSync(catPath) + .filter((file) => this.isValidCommandFile(file)) + .map( + async (file) => await this.attemptLoadCommand(path.join(catPath, file)) + ); + return (await Promise.all(promises)).filter((cmd): cmd is Command => !!cmd); + } + + /* + cmd parsing + */ + private isValidCommandFile(file: string): boolean { + return file.endsWith('.js') || file.endsWith('.ts'); + } + + private async attemptLoadCommand(filePath: string): Promise { + try { + const module = await import(`file://${filePath}`); + const command = + module.default?.default || module.default || (module as Command); + return command; + } catch (error) { + this.log.error( + `Error loading command ${path.basename(filePath)}: ${error}` + ); + return null; + } + } + + /* + misc functions + */ + private async registerSlashCommands(): Promise { + this.log.info('Registering slash commands...'); + + try { + const cmdJSONList = this.getAll() + .filter((cmd) => cmd.builder !== undefined) + .map((cmd) => cmd.builder?.toJSON()) + .filter((cmd) => cmd !== undefined); + + const rest = new REST({ version: '10' }).setToken(config.token as string); + await rest.put(Routes.applicationCommands(config.client_id), { + body: cmdJSONList + }); + + this.log.info('Registered %i commands', cmdJSONList.length); + } catch (err) { + this.log.warning( + 'Error occurred while registering slash commands: %s', + err + ); + } + } +}