UNPKG

meocord

Version:

Decorator-based Discord bot framework built on discord.js. Brings NestJS-style controllers, dependency injection, guards, and testing utilities to bot development — with a full CLI and TypeScript-first design.

266 lines (263 loc) 12.8 kB
import { SlashCommandBuilder, MessageFlagsBitField } from 'discord.js'; import { Logger } from '../common/logger.js'; import '../common/theme.js'; import { getCommandMap, getMessageHandlers, getReactionHandlers } from '../decorator/controller.decorator.js'; import { sample } from 'lodash-es'; import { createErrorEmbed } from '../util/embed.util.js'; import { ReactionHandlerAction, CommandType } from '../enum/controller.enum.js'; import CliTable3 from 'cli-table3'; class MeoCordApp { getInstance(controllerClass) { if (!this.controllerInstancesCache.has(controllerClass)) { this.controllerInstancesCache.set(controllerClass, this.container.get(controllerClass)); } return this.controllerInstancesCache.get(controllerClass); } async start() { try { this.logger.log('Starting bot...'); this.bot.on('clientReady', async ()=>{ this.activityInterval = setInterval(()=>{ this.bot.user?.setActivity(sample(this.activities)); }, 10000); await this.registerCommands(); }); this.bot.on('interactionCreate', async (interaction)=>{ await this.handleInteraction(interaction); }); this.bot.on('messageCreate', async (message)=>{ await this.handleMessage(message); }); this.bot.on('messageReactionAdd', async (reaction, user)=>{ await this.handleReaction(reaction, { user, action: ReactionHandlerAction.ADD }); }); this.bot.on('messageReactionRemove', async (reaction, user)=>{ await this.handleReaction(reaction, { user, action: ReactionHandlerAction.REMOVE }); }); await this.bot.login(this.discordToken); this.logger.log('Bot is online!'); } catch (error) { this.logger.error('Error during bot startup:', error); } } async registerCommands() { const builders = []; for (const controllerClass of this.controllerClasses){ const instance = this.getInstance(controllerClass); const commandMap = getCommandMap(instance); for(const commandName in commandMap){ const commandMetadataArray = commandMap[commandName]; if (!Array.isArray(commandMetadataArray)) continue; for (const { builder, type } of commandMetadataArray){ if (type in CommandType && builder) { builders.push(builder); } } } } try { if (this.bot.application) { await this.bot.application.commands.set(builders); const table = new CliTable3({ head: [ 'Name', 'Type', 'Sub-commands' ], colWidths: [ null, null, 30 ], wordWrap: true }); for (const builder of builders){ const json = typeof builder.toJSON === 'function' ? builder.toJSON() : builder; const typeName = json?.type === 1 ? 'SlashCommand' : json?.type === 2 ? 'UserContextMenu' : json?.type === 3 ? 'MessageContextMenu' : builder instanceof SlashCommandBuilder ? 'SlashCommand' : 'Command'; const name = json?.name || builder.name; const subCommands = Array.isArray(json?.options) && json.options.length ? json.options.map((opt)=>opt.name).join(', ') : ''; table.push([ name, typeName, subCommands ]); } this.logger.log(`Registered ${builders.length} bot commands:\n${table.toString()}`); } } catch (error) { this.logger.error('Error during command registration:', error); } } async handleInteraction(interaction) { for (const controllerClass of this.controllerClasses){ const controllerInstance = this.getInstance(controllerClass); const commandMap = getCommandMap(controllerInstance); if (!commandMap) continue; let commandMetadataArray = undefined; let commandIdentifier = undefined; if (interaction.isChatInputCommand() || interaction.isContextMenuCommand()) { commandIdentifier = interaction.commandName; commandMetadataArray = commandMap[commandIdentifier]; } else if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) { commandIdentifier = interaction.customId; const foundEntry = Object.entries(commandMap).find(([commandName, metaArray])=>{ if (!Array.isArray(metaArray)) return false; return metaArray.some((meta)=>{ if (!meta.regex || !commandIdentifier) return false; const match = meta.regex.exec(commandIdentifier); if (match?.groups) { interaction.dynamicParams = match.groups; return true; } return commandIdentifier === commandName; }); }); if (foundEntry) { commandMetadataArray = foundEntry[1]; } } if (commandMetadataArray && commandMetadataArray.length > 0) { const commandMetadata = commandMetadataArray[0]; const { methodName, type } = commandMetadata; try { if (type === CommandType.SLASH && interaction.isChatInputCommand() || type === CommandType.BUTTON && interaction.isButton() || type === CommandType.SELECT_MENU && interaction.isStringSelectMenu() || type === CommandType.CONTEXT_MENU && interaction.isUserContextMenuCommand() || type === CommandType.CONTEXT_MENU && interaction.isMessageContextMenuCommand() || type === CommandType.MODAL_SUBMIT && interaction.isModalSubmit()) { this.logger.log('[INTERACTION]', `[${CommandType[type]}]`, `[${methodName}]`); let dynamicParams = {}; if (interaction.isChatInputCommand() && interaction.options) { dynamicParams = interaction.options.data.reduce((acc, opt)=>{ acc[opt.name] = opt.value; return acc; }, {}); } else if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) { dynamicParams = interaction.dynamicParams || {}; } await controllerInstance[methodName](interaction, dynamicParams); return; } else { this.logger.debug(type, methodName, CommandType.BUTTON, interaction.isButton()); this.logger.warn(`Interaction type mismatch for command "${commandIdentifier}". Interaction type: ${interaction.type}.`); } } catch (error) { this.logger.error(`Error executing command "${commandIdentifier}":`, error); if (interaction.isRepliable()) { const embed = createErrorEmbed('An error occurred while executing the command.'); await interaction.reply({ embeds: [ embed ], flags: MessageFlagsBitField.Flags.Ephemeral }); } } return; } } if (interaction.isRepliable()) { const embed = createErrorEmbed('Command not found!'); await interaction.reply({ embeds: [ embed ], flags: MessageFlagsBitField.Flags.Ephemeral }); } } async handleMessage(message) { if (message.author.bot || !message.content?.trim()) return; const messageContent = message.content.trim(); const relevantControllers = this.controllerClasses.filter((controllerClass)=>{ const instance = this.getInstance(controllerClass); const messageHandlers = getMessageHandlers(instance); return messageHandlers.some((handler)=>!handler.keyword || handler.keyword === messageContent); }); for (const controllerClass of relevantControllers){ const controllerInstance = this.getInstance(controllerClass); let messageHandlers = getMessageHandlers(controllerInstance); messageHandlers = messageHandlers.sort((a, b)=>{ if (a.keyword && !b.keyword) return -1; if (!a.keyword && b.keyword) return 1; return 0; }); for (const handler of messageHandlers){ const { keyword, method } = handler; if (!keyword || keyword === messageContent) { try { await controllerInstance[method](message); } catch (error) { this.logger.error(`Error handling message "${messageContent}" for method "${method}":`, error); } } } } } async handleReaction(reaction, { user, action }) { await reaction.message.fetch(); const relevantControllers = this.controllerClasses.filter((controllerClass)=>{ const instance = this.getInstance(controllerClass); const reactionHandlers = getReactionHandlers(instance); return reactionHandlers.some((handler)=>!handler.emoji || handler.emoji === reaction.emoji.name); }); for (const controllerClass of relevantControllers){ const controllerInstance = this.getInstance(controllerClass); let reactionHandlers = getReactionHandlers(controllerInstance); reactionHandlers = reactionHandlers.sort((a, b)=>{ if (a.emoji && !b.emoji) return -1; if (!a.emoji && b.emoji) return 1; return 0; }); for (const handler of reactionHandlers){ const { emoji, method } = handler; if (!emoji || emoji === reaction.emoji.name) { try { await controllerInstance[method](reaction, { user, action }); } catch (error) { this.logger.error(`Error handling reaction "${reaction.emoji.name}" for method "${method}":`, error); } } } } } async gracefulShutdown() { if (this.isShuttingDown) { process.exit(1); } if (this.bot) { try { this.isShuttingDown = true; this.logger.log('Shutting down bot...'); if (this.activityInterval) clearInterval(this.activityInterval); this.bot.removeAllListeners(); await this.bot.destroy(); this.logger.log('Bot has shut down'); process.exit(0); } catch (error) { this.logger.error('Error during shutdown:', error); process.exit(1); } } } constructor(controllerClasses, container, discordClient, discordToken, activities){ this.controllerClasses = controllerClasses; this.container = container; this.discordClient = discordClient; this.discordToken = discordToken; this.activities = activities; this.logger = new Logger(MeoCordApp.name); this.isShuttingDown = false; this.activityInterval = null; this.controllerInstancesCache = new Map(); this.bot = this.discordClient; process.on('SIGINT', ()=>this.gracefulShutdown()); process.on('SIGTERM', ()=>this.gracefulShutdown()); } } export { MeoCordApp };