import fs from 'fs'; import path from 'path'; import { AutocompleteInteraction, BaseInteraction, ChatInputCommandInteraction, GuildMember, Interaction, Message, MessageFlags, PermissionFlagsBits, REST, Routes, SlashCommandOptionsOnlyBuilder, VoiceState } from 'discord.js'; import { Logger } from './utils/log'; import { config } from './utils/config'; import { Bot } from './bot'; import { isModule } from './utils/misc'; export interface Command { name?: string; builder?: SlashCommandOptionsOnlyBuilder; requiresAdmin?: boolean; ownerOnly?: boolean; guild_keys?: Record; user_keys?: Record; execute?: (interaction: ChatInputCommandInteraction) => Promise; autocomplete?: (interaction: AutocompleteInteraction) => Promise; messageListener?: (msg: Message) => Promise; voiceStateListener?: ( prevState: VoiceState, newState: VoiceState ) => Promise; } export interface CommandCategoryInfo { name: string; description: string; } export 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(); const bot = Bot.get; bot.on('interactionCreate', (interaction: Interaction) => { this.onInteraction(interaction); }); bot.on('messageCreate', (message: Message) => { this.onMessage(message); }); bot.on('voiceStateUpdate', (oldState: VoiceState, newState: VoiceState) => { this.onVoiceStateUpdate(oldState, newState); }); } 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); } public getGuildKeys(): Record { return this.mergeKeys('guild_keys'); } public getUserKeys(): Record { return this.mergeKeys('user_keys'); } private mergeKeys( keyType: 'guild_keys' | 'user_keys' ): Record { return this.getAll() .flatMap((cmd) => (cmd[keyType] ? [cmd[keyType]] : [])) .reduce((acc, val) => Object.assign(acc, val), {}); } /* 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 extensions = ['js', 'mjs', 'cjs', 'ts']; for (const ext of extensions) { const descriptorPath = path.join(catPath, `_category.${ext}`); if (!fs.existsSync(descriptorPath)) continue; const module = await import(`file://${descriptorPath}`); return ( module.default?.default || module.default || (module as CommandCategoryInfo) ); } throw new Error('Missing categoryinfo.json'); } 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) => isModule(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 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 ); } } /* interaction handling */ private async executeCommandInteraction( interaction: ChatInputCommandInteraction ): Promise { const cmdName = interaction.commandName; const command = this.get(cmdName); if (!command) return this.log.error( 'Attempted to execute non-existing command (%s)', cmdName ); if (command.requiresAdmin) { const member = interaction.member as GuildMember; if (!member.permissions.has(PermissionFlagsBits.Administrator) && member.id != config.owner_id) { await interaction.reply({ content: "You don't have the permissions required to execute this command.", flags: MessageFlags.Ephemeral }); return; } } if (command.ownerOnly) { const member = interaction.member as GuildMember; if (member.id != config.owner_id) { await interaction.reply({ content: 'This command is restricted.', flags: MessageFlags.Ephemeral }); return; } } if (command.execute) { try { await command.execute(interaction); } catch (error) { this.log.error( 'Error occurred while executing command %s: %s', cmdName, error ); } } else this.log.error('Command is missing execute method: %s', cmdName); } private async autocompleteCommandInteraction( interaction: AutocompleteInteraction ): Promise { const cmdName = interaction.commandName; const command = this.get(cmdName); if (!command) return this.log.error( 'Attempted to execute unexisting command (%s)', cmdName ); if (command.autocomplete) { try { await command.autocomplete(interaction); } catch (error) { this.log.error( 'Error occurred while autocompleting command %s: %s', cmdName, error ); } } else this.log.error('Command is missing autocomplete method: %s', cmdName); } /* event listeners */ private async onInteraction(interaction: BaseInteraction): Promise { /* cmd execution */ if (interaction.isChatInputCommand()) return this.executeCommandInteraction(interaction); if (interaction.isAutocomplete()) return this.autocompleteCommandInteraction(interaction); return; } private async onMessage(message: Message): Promise { if (message.author.bot) return; for (const cmd of this.getAll()) if (cmd.messageListener) await cmd.messageListener(message); } private async onVoiceStateUpdate( oldState: VoiceState, newState: VoiceState ): Promise { for (const cmd of this.getAll()) if (cmd.voiceStateListener) await cmd.voiceStateListener(oldState, newState); } }