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.

261 lines (253 loc) 10.7 kB
'use strict'; require('reflect-metadata'); var inversify = require('inversify'); var metadataKey_enum = require('../_shared/metadata-key.enum-BzzvGUId.cjs'); var controller_decorator = require('../_shared/controller.decorator-DX5lFlPZ.cjs'); var discord_js = require('discord.js'); require('../enum/index.cjs'); /** * `@Service()` decorator to mark a class as a service that can be injected into controllers or used as standalone services. * * @example * ```typescript * @Service() * class MyService { * constructor(private anotherService: AnotherService) {} * * doSomething() { * this.anotherService.alsoDoSomething() * console.log('Hello, World!') * } * } * ``` * @returns A decorator function to apply to the class. */ function Service() { return function(target) { if (!Reflect.hasMetadata(metadataKey_enum.MetadataKey.Injectable, target)) { inversify.injectable()(target); } }; } /** * This decorator is used to mark a class as a Discord command builder that later can be registered on the `@Command` decorator. * It defines the command type using metadata and dynamically makes the class injectable if it isn't already. * * @example * ```typescript * @CommandBuilder(CommandType.SLASH) * export class MySlashCommand implements CommandBuilderBase { * build(commandName: string): SlashCommandBuilder { * return new SlashCommandBuilder().setName(commandName).setDescription('A sample slash command') * } * } *``` * * @param commandType - The type of the command, specified from the `CommandType` enum. * @returns A decorator function that makes the target class injectable * and assigns the `commandType` metadata. */ function CommandBuilder(commandType) { return function(target) { // Check if the class is already injectable; if not, make it injectable dynamically if (!Reflect.hasMetadata(metadataKey_enum.MetadataKey.Injectable, target)) { inversify.injectable()(target); } // Define the command type metadata for the target class Reflect.defineMetadata(metadataKey_enum.MetadataKey.CommandType, commandType, target); }; } function isValidContext(context) { return context instanceof discord_js.BaseInteraction || context instanceof discord_js.Message || context instanceof discord_js.MessageReaction; } function applyGuards(descriptor, guards, propertyKey) { const originalMethod = descriptor.value; descriptor.value = async function(...args) { const [context] = args; if (!isValidContext(context)) { throw new Error(`The first argument of ${String(propertyKey)} must be an instance of Interaction, Message, or MessageReaction.`); } const container = Reflect.getMetadata(metadataKey_enum.MetadataKey.Container, this.constructor); for (const guard of guards){ let guardInstance; if (isGuardWithParams(guard)) { const { provide, params } = guard; guardInstance = container.get(provide, { autobind: true }); Object.assign(guardInstance, params); } else { guardInstance = container.get(guard, { autobind: true }); } if (!guardInstance.canActivate) { throw new Error(`Guard ${guard.constructor.name} applied to ${String(propertyKey)} does not have a valid canActivate method.`); } const canActivate = await guardInstance.canActivate(...args); if (!canActivate) { return; } } return originalMethod.apply(this, args); }; } /** * `@Guard()` decorator to mark a class as a Guard that later can be added on `@UseGuard` decorator. * * @example * ```typescript * @Guard() export class ButtonInteractionGuard implements GuardInterface { private readonly logger = new Logger(ButtonInteractionGuard.name) async canActivate(context: ButtonInteraction, { ownerId }: { ownerId: string }): Promise<boolean> { if (context.user.id !== ownerId) { this.logger.error( `User with id ${context.user.id} is not allowed to use this command that initiated by user with id ${ownerId}.`, ) const embed = generateErrorEmbed( `Hi <@${context.user.id}>, this command can only be used by the person who initiated it: <@${ownerId}>.`, ) await context.reply({ embeds: [embed], flags: MessageFlagsBitField.Flags.Ephemeral, }) return false } return true } } * ``` */ function Guard() { return function(target) { if (!Reflect.hasMetadata(metadataKey_enum.MetadataKey.Injectable, target)) { inversify.injectable()(target); } }; } /** * Type guard to check if the object is a GuardWithParams. * This function helps to check whether a guard is parameterized or not. * * @param guard - The guard to check. * @returns `true` if the guard has parameters, otherwise `false`. */ function isGuardWithParams(guard) { return typeof guard === 'object' && 'provide' in guard && 'params' in guard; } /** * `@UseGuard()` decorator to apply one or more guards to methods. * Guards are used to handle permission checks before executing a method. * Each guard must use `@Guard` decorator and implement the `canActivate` method, which determines * whether the method should be allowed to execute based on the provided context (Interaction, Message, or Reaction) and arguments. * This decorator ensures that all guards pass validation before calling the original method. * Supports guards that are parameterized (accepting additional parameters). * * @param guards - One or more guard classes to apply. These can be regular guards or guards with additional parameters. * - If providing a guard with parameters, it should be an object with: * - `provide`: The guard class to instantiate. Must implement `GuardInterface`. * - `params`: A record of key-value pairs to be passed as additional properties to the guard instance. * @returns A method decorator function that applies the guards to the method. * * @example * ```typescript * // Method-level usage * @Command('profile-{id}', CommandType.BUTTON) * @UseGuard( * { provide: RateLimiterGuard, params: { limit: 2, window: 3000 } }, * ButtonInteractionGuard * ) * async showProfileById(interaction: ButtonInteraction, { id }: { id: string }) { * await interaction.reply(`Profile ID: ${id}`) * } * * // Class-level usage * @Controller() * @UseGuard(GlobalGuard) * class MyController { * @Command('ping', CommandType.SLASH) * async ping(interaction: ChatInputCommandInteraction) { * await interaction.reply('Pong!') * } * } * ``` */ function UseGuard(...guards) { return function(target, propertyKey, descriptor) { if (descriptor && propertyKey) { // Method Decorator applyGuards(descriptor, guards, String(propertyKey)); Reflect.defineMetadata(metadataKey_enum.MetadataKey.Guards, guards, target, propertyKey); } else if (typeof target === 'function' && !propertyKey && !descriptor) { // Class Decorator const prototype = target.prototype; const methods = new Set(); const commandMap = controller_decorator.getCommandMap(prototype) || {}; Object.values(commandMap).flat().forEach((cmd)=>methods.add(cmd.methodName)); const messageHandlers = controller_decorator.getMessageHandlers(prototype) || []; messageHandlers.forEach((handler)=>methods.add(handler.method)); const reactionHandlers = controller_decorator.getReactionHandlers(prototype) || []; reactionHandlers.forEach((handler)=>methods.add(handler.method)); for (const methodName of methods){ const methodDescriptor = Object.getOwnPropertyDescriptor(prototype, methodName); if (methodDescriptor) { applyGuards(methodDescriptor, guards, methodName); Object.defineProperty(prototype, methodName, methodDescriptor); Reflect.defineMetadata(metadataKey_enum.MetadataKey.Guards, guards, prototype, methodName); } } } }; } /** * `@MeoCord()` decorator for declaring the MeoCord application class. * * This decorator stores the application options as metadata on the class. * All DI wiring — container creation, client binding, controller/service * registration — happens inside `MeoCordFactory.create()`, not here. * * @param {Object} options - The decorator options. * @param {ServiceIdentifier[]} options.controllers - The list of controllers to be registered. * @param {ClientOptions} options.clientOptions - The Discord client options for initializing the bot. * @param {ActivityOptions[]} [options.activities] - Optional activities for the bot. * @param {ServiceIdentifier[]} [options.services] - Optional services to be registered. * * @example * ```typescript * @MeoCord({ * controllers: [PingSlashController], * clientOptions: { * intents: [ * GatewayIntentBits.Guilds, * GatewayIntentBits.GuildMembers, * GatewayIntentBits.GuildMessages, * GatewayIntentBits.GuildMessageReactions, * GatewayIntentBits.MessageContent, * ], * partials: [Partials.Message, Partials.Channel, Partials.Reaction], * }, * activities: [{ * name: `${sample(['Genshin', 'ZZZ'])} with Romeo`, * type: ActivityType.Playing, * url: 'https://enka.network/u/824957678/', * }], * services: [MyStandaloneService], * }) * class MyApp {} * ``` **/ function MeoCord(options) { return (target)=>{ if (!Reflect.hasMetadata(metadataKey_enum.MetadataKey.Injectable, target)) { inversify.injectable()(target); } Reflect.defineMetadata(metadataKey_enum.MetadataKey.AppOptions, options, target); }; } exports.Command = controller_decorator.Command; exports.Controller = controller_decorator.Controller; exports.MessageHandler = controller_decorator.MessageHandler; exports.ReactionHandler = controller_decorator.ReactionHandler; exports.getCommandMap = controller_decorator.getCommandMap; exports.getMessageHandlers = controller_decorator.getMessageHandlers; exports.getReactionHandlers = controller_decorator.getReactionHandlers; exports.CommandBuilder = CommandBuilder; exports.Guard = Guard; exports.MeoCord = MeoCord; exports.Service = Service; exports.UseGuard = UseGuard;