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.

581 lines (441 loc) 19.9 kB
# MeoCord Framework **MeoCord** is a decorator-based Discord bot framework built on top of discord.js. It brings a NestJS-style architecture — controllers, services, guards, and dependency injection — to bot development, with a full CLI, TypeScript-first design, and testing utilities included out of the box. --- ## Table of Contents - [Features](#features) - [Getting Started](#getting-started) - [Prerequisites](#prerequisites) - [Create a New App](#create-a-new-app) - [Quick Example](#quick-example) - [Project Structure](#project-structure) - [Configuration](#configuration) - [meocord.config.ts](#meocordconfigts) - [ESLint](#eslint) - [CLI Reference](#cli-reference) - [Guards](#guards) - [Custom Decorators](#custom-decorators) - [Testing](#testing) - [Deployment](#deployment) - [Contributing](#contributing) - [License](#license) --- ## Features - **Decorator-based controllers** — Handle slash commands, buttons, modals, select menus, context menus, messages, and reactions with `@Command`, `@Controller`, and `@UseGuard` decorators. No routing boilerplate. - **Dependency injection** — Built on Inversify. Services are wired into controllers automatically; no manual instantiation or service locators. - **Guard system** — Pre-execution hooks for auth, rate limiting, metrics, and anything else. Apply per-method or per-class with `@UseGuard`. Guards receive the full interaction context. - **Full CLI** — `meocord create`, `build`, `start`, `generate`. Scaffolds controllers, services, and guards; handles Webpack builds for both development and production. - **Testing utilities** — `MeoCordTestingModule`, `createMockInteraction`, `createMockMessage`, `createMockUser`, `createMockClient`, `createMockGuild`, `createMockChannel`, `createChatInputOptions`, and `overrideGuard` let you test controllers against real guard logic without a Discord connection. Type guards and reply state machines work out of the box. - **TypeScript-first** — Strict types throughout. Decorator metadata, `DeepMocked<T>` for test mocks, and typed config interfaces included. - **Extensible build** — Expose a Webpack config hook in `meocord.config.ts` to add rules, plugins, or loaders without ejecting. --- ## Getting Started ### Prerequisites - **Runtime**: Node.js (latest LTS) or Bun 1.x+ - **TypeScript**: 5.0+ - **Package manager**: npm, yarn, pnpm, or bun MeoCord ships dual ESM/CJS builds. New projects generated by the CLI are preconfigured for ESM. ### Create a New App ```shell npx meocord create <your-app-name> ``` The CLI detects installed package managers and prompts you to choose, or you can pass a flag directly: ```shell npx meocord create <your-app-name> --use-bun npx meocord create <your-app-name> --use-npm npx meocord create <your-app-name> --use-pnpm npx meocord create <your-app-name> --use-yarn ``` Set your Discord bot token in `meocord.config.ts`, then start the bot: ```shell npx meocord start --dev # development with live-reload npx meocord start --build --prod # production build + start ``` ### Quick Example A minimal slash command controller: ```typescript import { Controller, Command, UseGuard } from 'meocord/decorator' import { CommandType } from 'meocord/enum' import { type ChatInputCommandInteraction } from 'discord.js' import { RateLimiterGuard } from '@src/guards/rate-limiter.guard.js' import { GreetingService } from '@src/services/greeting.service.js' @Controller() export class GreetingSlashController { constructor(private readonly greetingService: GreetingService) {} @Command('greet', CommandType.SLASH) @UseGuard({ provide: RateLimiterGuard, params: { limit: 3, window: 10_000 } }) async greet(interaction: ChatInputCommandInteraction) { const name = interaction.options.getString('name', true) const message = await this.greetingService.buildGreeting(name) await interaction.reply({ content: message }) } } ``` Register it in `src/app.ts`: ```typescript import { MeoCord } from 'meocord/decorator' import { GatewayIntentBits, Partials } from 'discord.js' import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller.js' import { GreetingService } from '@src/services/greeting.service.js' @MeoCord({ controllers: [GreetingSlashController], // `services` is for specialized, event-driven services (e.g. RabbitMQ consumers, // schedulers). Regular business-logic services are injected via controller // constructors — they don't belong here. services: [RabbitMQService], clientOptions: { intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], partials: [Partials.Message, Partials.Channel], }, }) export class App {} ``` --- ## Project Structure ``` . ├── meocord.config.ts ├── eslint.config.ts ├── jest.config.ts ├── tsconfig.json ├── tsconfig.eslint.json ├── tsconfig.test.json ├── package.json └── src ├── main.ts # Entry point — bootstraps the app ├── app.ts # Root module — registers controllers and services ├── controllers │ ├── slash │ │ ├── builders/ # Slash command option/subcommand builders │ │ ├── sample.slash.controller.ts │ │ └── sample.slash.controller.spec.ts │ ├── button │ │ ├── sample.button.controller.ts │ │ └── sample.button.controller.spec.ts │ ├── select-menu │ │ ├── sample.select-menu.controller.ts │ │ └── sample.select-menu.controller.spec.ts │ ├── modal-submit │ │ ├── sample.modal-submit.controller.ts │ │ └── sample.modal-submit.controller.spec.ts │ ├── context-menu │ │ ├── builders/ # Context menu command builders │ │ ├── sample.context-menu.controller.ts │ │ └── sample.context-menu.controller.spec.ts │ ├── message │ │ ├── sample.message.controller.ts │ │ └── sample.message.controller.spec.ts │ └── reaction │ ├── sample.reaction.controller.ts │ └── sample.reaction.controller.spec.ts ├── guards │ ├── rate-limit.guard.ts │ └── rate-limit.guard.spec.ts └── services ├── sample.service.ts └── sample.service.spec.ts ``` --- ## Configuration ### `meocord.config.ts` The top-level config file. At minimum it needs `discordToken`. The `webpack` hook lets you extend the build without ejecting. ```typescript import { type MeoCordConfig } from 'meocord/interface' export default { appName: 'MyBot', discordToken: process.env.TOKEN!, webpack: config => { config.module.rules?.push({ // add custom rules here }) return config }, } satisfies MeoCordConfig ``` ### ESLint MeoCord exports a base ESLint config from `meocord/eslint`. Extend it as needed: ```javascript import meocordEslint, { typescriptConfig } from 'meocord/eslint' import unusedImports from 'eslint-plugin-unused-imports' export default [ ...meocordEslint, { ...typescriptConfig, plugins: { ...typescriptConfig.plugins, 'unused-imports': unusedImports, }, rules: { ...typescriptConfig.rules, 'unused-imports/no-unused-imports': 'error', }, }, ] ``` --- ## CLI Reference ```shell npx meocord --help ``` | Command | Alias | Description | |------------|-------|--------------------------------------| | `create` | — | Scaffold a new MeoCord application | | `build` | — | Compile the application via Webpack | | `start` | — | Start the application | | `generate` | `g` | Scaffold controllers, services, guards | | `show` | — | Display framework info | **Common flags:** ```shell npx meocord build --prod # production build npx meocord build --dev # development build npx meocord start --dev # dev mode with live-reload npx meocord start --build --prod # production build + start npx meocord g co slash "profile" # generate a slash controller npx meocord g --help # list all generator sub-commands ``` --- ## Guards Guards run before the handler method. Each guard implements `canActivate`return `true` to allow, `false` to block. ```typescript import { Guard } from 'meocord/decorator' import { type GuardInterface } from 'meocord/interface' import { type ChatInputCommandInteraction } from 'discord.js' import { RedisService } from '@src/services/redis.service.js' @Guard() export class RateLimiterGuard implements GuardInterface { constructor(private readonly redis: RedisService) {} // limit and window are injected via @UseGuard params limit = 5 window = 60_000 async canActivate(interaction: ChatInputCommandInteraction): Promise<boolean> { const key = `ratelimit:${interaction.user.id}` const count = await this.redis.increment(key, this.window) return count <= this.limit } } ``` Apply to a single method or an entire controller: ```typescript // Per-method, with params @Command('search', CommandType.SLASH) @UseGuard({ provide: RateLimiterGuard, params: { limit: 5, window: 60_000 } }) async search(interaction: ChatInputCommandInteraction) { ... } // Per-class (applies to every command in the controller) @Controller() @UseGuard(MetricsGuard, DefaultGuard) export class ProfileController { ... } ``` --- ## Custom Decorators MeoCord exports `applyDecorators` and `SetMetadata` from `meocord/common` for composing reusable decorators. ### Composing guards into a reusable decorator ```typescript import { applyDecorators } from 'meocord/common' import { UseGuard } from 'meocord/decorator' import { DefaultGuard, RateLimiterGuard } from '@src/guards/index.js' export const Protected = (limit = 5) => applyDecorators( UseGuard(DefaultGuard, { provide: RateLimiterGuard, params: { limit } }), ) ``` ```typescript @Command('profile', CommandType.SLASH) @Protected(3) async profile(interaction: ChatInputCommandInteraction) { ... } ``` ### Attaching metadata for guards to read ```typescript // Define the metadata decorator import { SetMetadata } from 'meocord/common' export const Roles = (...roles: string[]) => SetMetadata('roles', roles) // Read it inside a guard @Guard() export class RolesGuard implements GuardInterface { async canActivate(interaction: ChatInputCommandInteraction): Promise<boolean> { const required: string[] = Reflect.getMetadata('roles', interaction.constructor) ?? [] if (!required.length) return true // ... validate member roles return true } } // Compose into a single decorator export const RequireRoles = (...roles: string[]) => applyDecorators(Roles(...roles), UseGuard(RolesGuard)) // Apply @Command('ban', CommandType.SLASH) @RequireRoles('admin', 'moderator') async ban(interaction: ChatInputCommandInteraction) { ... } ``` --- ## Testing MeoCord ships a `meocord/testing` entry point with utilities for testing controllers in isolation — no real Discord connection required. ### `MeoCordTestingModule` Builds an isolated DI container from your controllers and providers. ```typescript import { MeoCordTestingModule } from 'meocord/testing' import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller.js' import { GreetingService } from '@src/services/greeting.service.js' const module = MeoCordTestingModule.create({ controllers: [GreetingSlashController], providers: [{ provide: GreetingService, useValue: mockGreetingService }], }).compile() const controller = module.get(GreetingSlashController) ``` ### `createMockInteraction` Creates a smart mock instance of any discord.js class. The full prototype chain is preserved so `instanceof` checks pass at every level. **Type guards run real logic** — `isButton()`, `isRepliable()`, `isChatInputCommand()`, etc. are backed by the actual discord.js prototype methods. The right fields (`type`, `componentType`, `commandType`) are set based on the class you pass in, so no manual `.mockReturnValue(true)` setup is needed. All type guard methods are still `jest.fn()` and can be overridden per test. **Reply state machine** — for repliable interactions, `replied` and `deferred` start as `false`. Calling `reply()` or `deferReply()` twice throws, just like a real interaction. `followUp()`, `editReply()`, and `deleteReply()` throw if called before any reply. The ephemeral flag is tracked on `interaction.ephemeral`. All reply methods are still `jest.fn()` so call assertions work normally. ```typescript import { createMockInteraction } from 'meocord/testing' import { ChatInputCommandInteraction, ButtonInteraction, BaseInteraction } from 'discord.js' const interaction = createMockInteraction(ChatInputCommandInteraction) // instanceof works at every level expect(interaction).toBeInstanceOf(ChatInputCommandInteraction) // true expect(interaction).toBeInstanceOf(BaseInteraction) // true // type guards work — no manual setup needed interaction.isChatInputCommand() // → true interaction.isRepliable() // → true interaction.isButton() // → false // reply state machine interaction.replied // → false await interaction.reply({ content: 'hi' }) interaction.replied // → true await interaction.reply({ content: 'again' }) // → throws (already replied) // still jest.fn() — call assertions work normally expect(interaction.reply).toHaveBeenCalledWith({ content: 'hi' }) // direct property writes work normally interaction.guildId = 'guild-123' ``` Works for any discord.js class — interactions, `Message`, `MessageReaction`, and anything else. No per-type maintenance. ### `createChatInputOptions` Builds a typed options resolver from a plain record. Type routing mirrors the real `CommandInteractionOptionResolver`: wrong-type access returns `null`, `required=true` throws if the option is absent. ```typescript import { createMockInteraction, createChatInputOptions } from 'meocord/testing' import { ChatInputCommandInteraction } from 'discord.js' const interaction = createMockInteraction(ChatInputCommandInteraction) interaction.options = createChatInputOptions({ subcommandGroup: 'admin', subcommand: 'ban', user: { id: '123456789' }, reason: 'spam', duration: 7, }) interaction.options.getSubcommandGroup() // → 'admin' interaction.options.getSubcommand(true) // → 'ban' interaction.options.getUser('user') // → { id: '123456789' } interaction.options.getString('reason') // → 'spam' interaction.options.getNumber('duration') // → 7 interaction.options.getString('duration') // → null (wrong type) interaction.options.getNumber('x', true) // → throws (absent + required) ``` All methods are `jest.fn()` — override any per test with `.mockReturnValue()`. ### `createMockUser` / `createMockClient` / `createMockGuild` / `createMockChannel` Convenience wrappers for common discord.js classes. All methods are auto-stubbed as `jest.fn()`. Nested managers (`client.users`, `guild.members`, etc.) are independent nested stubs. ```typescript import { createMockUser, createMockClient, createMockGuild, createMockChannel } from 'meocord/testing' import { TextChannel } from 'discord.js' const user = createMockUser() const client = createMockClient() const guild = createMockGuild() const channel = createMockChannel(TextChannel) // override nested manager methods per test ;(client.users as any).fetch = jest.fn(() => Promise.resolve(user)) await (client.users as any).fetch('user-123') expect((client.users as any).fetch).toHaveBeenCalledWith('user-123') ``` ### `createMockMessage` Creates a smart mock `Message`. Tracks a `deleted` boolean — `delete()`, `edit()`, `reply()`, `react()`, `pin()`, and `unpin()` throw if the message has already been deleted. `edit()` and `reply()` resolve to a new mock `Message` instance. All methods are `jest.fn()`. ```typescript import { createMockMessage } from 'meocord/testing' const msg = createMockMessage() msg.deleted // → false await msg.delete() msg.deleted // → true await msg.delete() // → throws (already deleted) await msg.edit({ content: 'x' }) // → throws (already deleted) // edit() and reply() resolve to a new Message mock const edited = await createMockMessage().edit({ content: 'updated' }) edited.delete // → jest.fn() // still jest.fn() — assertions work expect(msg.delete).toHaveBeenCalledTimes(1) ``` ### `overrideGuard` Replaces a guard class in the DI container with a stub. No guard dependencies need to be provided. ```typescript const module = MeoCordTestingModule.create({ controllers: [GreetingSlashController], providers: [{ provide: GreetingService, useValue: mockGreetingService }], }) .overrideGuard(MetricsGuard).useValue({ canActivate: () => true }) .overrideGuard(RateLimiterGuard).useValue({ canActivate: () => true }) .compile() ``` `canActivate: () => true` allows the method to run. `() => false` blocks it. Multiple guards chain fluently. ### Full example ```typescript import { jest } from '@jest/globals' import { MeoCordTestingModule, createMockInteraction, createChatInputOptions } from 'meocord/testing' import { ChatInputCommandInteraction } from 'discord.js' import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller.js' import { GreetingService } from '@src/services/greeting.service.js' import { RateLimiterGuard } from '@src/guards/rate-limiter.guard.js' describe('GreetingSlashController', () => { let controller: GreetingSlashController let greetingService: { buildGreeting: jest.MockedFunction<GreetingService['buildGreeting']> } beforeEach(() => { greetingService = { buildGreeting: jest.fn() } const module = MeoCordTestingModule.create({ controllers: [GreetingSlashController], providers: [{ provide: GreetingService, useValue: greetingService }], }) .overrideGuard(RateLimiterGuard).useValue({ canActivate: () => true }) .compile() controller = module.get(GreetingSlashController) }) it('replies with a greeting for the provided name', async () => { jest.mocked(greetingService.buildGreeting).mockResolvedValue('Hello, Alice!') const interaction = createMockInteraction(ChatInputCommandInteraction) interaction.options = createChatInputOptions({ name: 'Alice' }) await controller.greet(interaction) expect(greetingService.buildGreeting).toHaveBeenCalledWith('Alice') expect(interaction.reply).toHaveBeenCalledWith({ content: 'Hello, Alice!' }) }) }) ``` --- ## Deployment Install all dependencies and build for production: ```shell npm ci && npx meocord build --prod ``` Strip dev dependencies: ```shell npm ci --omit=dev # npm yarn install --production # yarn pnpm install --prod # pnpm bun install --production # bun ``` Required files on the server: ``` dist/ node_modules/ (production only) package.json .env (if used) <lockfile> ``` Start in production: ```shell npx meocord start --prod ``` --- ## Contributing 1. Fork the repository 2. Create a feature branch: `git checkout -b feat/your-feature` 3. Commit with conventional commits: `git commit -m "feat: add X"` 4. Push and open a pull request against `main` Include a description of what changed and why, and add tests for any new behaviour. --- ## Release Notes Full changelog is available on the [GitHub Releases](https://github.com/l7aromeo/meocord/releases) page. --- ## License **MeoCord Framework** is licensed under the [MIT License](./LICENSE).