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.
150 lines (146 loc) • 6.41 kB
JavaScript
import 'reflect-metadata';
import { injectable } from 'inversify';
import { BaseInteraction, Message, MessageReaction } from 'discord.js';
import { getCommandMap, getMessageHandlers, getReactionHandlers } from './controller.decorator.js';
import { MetadataKey } from '../enum/metadata-key.enum.js';
function isValidContext(context) {
return context instanceof BaseInteraction || context instanceof Message || context instanceof 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.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.Injectable, target)) {
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.Guards, guards, target, propertyKey);
} else if (typeof target === 'function' && !propertyKey && !descriptor) {
// Class Decorator
const prototype = target.prototype;
const methods = new Set();
const commandMap = getCommandMap(prototype) || {};
Object.values(commandMap).flat().forEach((cmd)=>methods.add(cmd.methodName));
const messageHandlers = getMessageHandlers(prototype) || [];
messageHandlers.forEach((handler)=>methods.add(handler.method));
const reactionHandlers = 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.Guards, guards, prototype, methodName);
}
}
}
};
}
export { Guard, UseGuard };