meocord
v1.8.2
Published
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.
Downloads
3,289
Maintainers
Readme
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
- Getting Started
- Project Structure
- Configuration
- CLI Reference
- Guards
- Custom Decorators
- Testing
- Deployment
- Contributing
- License
Features
- Decorator-based controllers — Handle slash commands, buttons, modals, select menus, context menus, messages, and reactions with
@Command,@Controller, and@UseGuarddecorators. 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, andoverrideGuardlet 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.tsto 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
npx meocord create <your-app-name>The CLI detects installed package managers and prompts you to choose, or you can pass a flag directly:
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-yarnSet your Discord bot token in meocord.config.ts, then start the bot:
npx meocord start --dev # development with live-reload
npx meocord start --build --prod # production build + startQuick Example
A minimal slash command controller:
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:
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.tsConfiguration
meocord.config.ts
The top-level config file. At minimum it needs discordToken. The webpack hook lets you extend the build without ejecting.
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 MeoCordConfigESLint
MeoCord exports a base ESLint config from meocord/eslint. Extend it as needed:
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
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:
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-commandsGuards
Guards run before the handler method. Each guard implements canActivate — return true to allow, false to block.
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:
// 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
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 } }),
)@Command('profile', CommandType.SLASH)
@Protected(3)
async profile(interaction: ChatInputCommandInteraction) { ... }Attaching metadata for guards to read
// 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.
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.
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.
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.
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().
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.
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
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:
npm ci && npx meocord build --prodStrip dev dependencies:
npm ci --omit=dev # npm
yarn install --production # yarn
pnpm install --prod # pnpm
bun install --production # bunRequired files on the server:
dist/
node_modules/ (production only)
package.json
.env (if used)
<lockfile>Start in production:
npx meocord start --prodContributing
- Fork the repository
- Create a feature branch:
git checkout -b feat/your-feature - Commit with conventional commits:
git commit -m "feat: add X" - 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 page.
License
MeoCord Framework is licensed under the MIT License.
