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 ); } } }