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
Markdown
# 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).