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.

338 lines (332 loc) 16 kB
'use strict'; require('reflect-metadata'); var inversify = require('inversify'); var discord_js = require('discord.js'); var theme = require('../_shared/theme-Bz-D4RbT.cjs'); var controller_decorator = require('../_shared/controller.decorator-DX5lFlPZ.cjs'); var lodashEs = require('lodash-es'); var enum_index = require('../enum/index.cjs'); var Table = require('cli-table3'); var metadataKey_enum = require('../_shared/metadata-key.enum-BzzvGUId.cjs'); require('node:util'); require('dayjs'); require('dayjs/plugin/utc.js'); require('dayjs/plugin/timezone.js'); require('path'); require('fs'); require('jiti'); require('chalk'); const createErrorEmbed = (description)=>{ const embed = new discord_js.EmbedBuilder(); embed.setColor(theme.Theme.errorColor); embed.setTitle('Oops!'); embed.setDescription(description); return embed; }; 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(lodashEs.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: enum_index.ReactionHandlerAction.ADD }); }); this.bot.on('messageReactionRemove', async (reaction, user)=>{ await this.handleReaction(reaction, { user, action: enum_index.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 = controller_decorator.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 enum_index.CommandType && builder) { builders.push(builder); } } } } try { if (this.bot.application) { await this.bot.application.commands.set(builders); const table = new Table({ 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 discord_js.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 = controller_decorator.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 === enum_index.CommandType.SLASH && interaction.isChatInputCommand() || type === enum_index.CommandType.BUTTON && interaction.isButton() || type === enum_index.CommandType.SELECT_MENU && interaction.isStringSelectMenu() || type === enum_index.CommandType.CONTEXT_MENU && interaction.isUserContextMenuCommand() || type === enum_index.CommandType.CONTEXT_MENU && interaction.isMessageContextMenuCommand() || type === enum_index.CommandType.MODAL_SUBMIT && interaction.isModalSubmit()) { this.logger.log('[INTERACTION]', `[${enum_index.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, enum_index.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: discord_js.MessageFlagsBitField.Flags.Ephemeral }); } } return; } } if (interaction.isRepliable()) { const embed = createErrorEmbed('Command not found!'); await interaction.reply({ embeds: [ embed ], flags: discord_js.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 = controller_decorator.getMessageHandlers(instance); return messageHandlers.some((handler)=>!handler.keyword || handler.keyword === messageContent); }); for (const controllerClass of relevantControllers){ const controllerInstance = this.getInstance(controllerClass); let messageHandlers = controller_decorator.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 = controller_decorator.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 = controller_decorator.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 theme.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()); } } /** * Recursively binds a class and all its constructor dependencies to the container in singleton scope. */ function bindDependencies(container, cls) { if (container.isBound(cls)) return; if (!Reflect.hasMetadata(metadataKey_enum.MetadataKey.Injectable, cls)) { inversify.injectable()(cls); } container.bind(cls).toSelf().inSingletonScope(); const deps = Reflect.getMetadata(metadataKey_enum.MetadataKey.ParamTypes, cls) || []; for (const dep of deps){ if (dep === discord_js.Client) continue; bindDependencies(container, dep); } } class MeoCordFactory { static create(target) { const options = Reflect.getMetadata(metadataKey_enum.MetadataKey.AppOptions, target); if (!options) { if (typeof target === 'function') { this.logger.error(`No @MeoCord() options found for class: ${target.name}`); } else { this.logger.error('No @MeoCord() options found for the provided target.'); } throw new Error('Target class is not decorated with @MeoCord().'); } const meocordConfig = theme.loadMeoCordConfig(); if (!meocordConfig) { throw new Error('MeoCord config not found. Ensure meocord.config.ts exists.'); } const container = new inversify.Container(); // Bind the Discord client as a constant value const discordClient = new discord_js.Client(options.clientOptions); container.bind(discord_js.Client).toConstantValue(discordClient); // Bind all controllers and their transitive dependencies for (const ctrl of options.controllers){ bindDependencies(container, ctrl); } // Bind and eagerly instantiate standalone services so their constructors run. // This is critical for event-driven services that register Discord event // listeners (or connect to external systems) inside their constructor. for (const svc of options.services ?? []){ bindDependencies(container, svc); container.get(svc); } // Stamp each controller class with the container so @UseGuard can resolve guards for (const ctrl of options.controllers){ Reflect.defineMetadata(metadataKey_enum.MetadataKey.Container, container, ctrl); } return new MeoCordApp(options.controllers, container, discordClient, meocordConfig.discordToken, options.activities); } } MeoCordFactory.logger = new theme.Logger(); exports.MeoCordFactory = MeoCordFactory;