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
JavaScript
'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 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;