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