Skip to content
GitHub

Overview

MeoCord 4.2 · Start · page 1 of 41

What MeoCord is, what a bot built with it is made of, and how its parts run from build to shutdown.

You'll learn

  • Tell what MeoCord adds to discord.js, and when it fits
  • Name the parts of a MeoCord bot and what each one does
  • Follow a bot from build to shutdown

MeoCord is a framework for Discord bots built on discord.js. You write a bot as controllers and services, and decorators connect them to Discord: @Command binds a method to a slash command, a button or a modal, and the framework routes each interaction to it.

Two parts carry the everyday work. respond() answers every interaction with the call Discord expects for where the answer stands, so a handler says what to send and never which method sends it. And meocord/testing runs a handler exactly as the bot does, through the same pipeline, with mocks of discord.js's own classes, so a test passes because the bot works.

Every call a handler receives passes through that pipeline. Guards decide whether it runs, interceptors wrap it, validation and pipes check and shape its input, cooldowns limit how often it runs, and exception filters decide what the user is told when it throws. Guards, interceptors and exception filters apply to one method, a whole controller or the entire bot; cooldowns to a method or a controller; validation to a method.

When to use it

MeoCord is for bots that grow: many commands and components, rules about who may use them, and code a team wants to test. It gives a bot the structure a web framework gives a server, and with it:

  • One call that knows where the answer stands. respond(interaction) tracks each answer as unanswered, deferred or replied, read from the interaction before every call, and makes the call Discord expects: reply or update first, editReply once the answer is deferred or sent, followUp for another message. A second send() edits rather than replying twice, and @Defer acknowledges a slow handler before Discord's three seconds are up. See Answering with respond().
  • Tests that run the way the bot runs. invoke and dispatch send a call through the same pipeline the bot uses, guards, validation, cooldowns and filters included, and getResponse reports what respond() sent. Mocks of discord.js's own classes keep their prototypes and follow Discord's reply rules, and fromApp builds the testing module from the app class itself. See Testing, Mocks and Invoke and dispatch.
  • One pipeline around every call. Guards, interceptors, validation, pipes, cooldowns and exception filters run in a fixed order around each handler they apply to. Guards, interceptors and filters are set on one method, a controller or the whole bot, cooldowns on a method or a controller, and validation and pipes on a method. See How a call runs.
  • Cooldowns and translations built in. @Cooldown counts per user, server, channel or resource, in memory or in Redis, and typed catalogs translate what the bot says and the names of its commands.
  • Routes and params the compiler checks. A button's counter/{count:int} gives its handler count as a number, and a message command's pay {to:member} {amount:int} a member and a number. A handler whose params don't fit its pattern fails to compile, and so does a catalog with a key the default catalog lacks, or, written inline, as const or with defineCatalog, a {param} its default message doesn't take. expectCompleteCatalog finds a missing message, and such a {param} in a plain or JSON catalog too, in a test. See Components, Message command params and Localisation.
  • Services by constructor. A controller or a service names what it needs in its constructor, and MeoCord makes each one once, in dependency order. See Services.
  • A CLI from create to deploy. npx meocord create starts a project, generate scaffolds a controller, service, guard, interceptor, filter, pipe or observer with its spec, start --dev rebuilds and restarts on every change, and build and register ship it. See The CLI.

It covers every interaction Discord sends, from slash commands, subcommands and autocomplete to buttons, the five select menus, modals, context menus and activity entry points, plus messages, reactions and any gateway event.

Example

A slash command that greets whoever it names, at most three times in ten seconds per user. The controller handles it, and a service it asks for in its constructor makes the greeting:

controllers/slash/greeting.slash.controller.ts
import { type ChatInputCommandInteraction } from 'discord.js'
import { respond } from 'meocord/common'
import { Command, Controller, Cooldown } from 'meocord/decorator'
import { GreetingCommandBuilder } from '@src/controllers/slash/builders/greeting.builder'
import { GreetingService } from '@src/services/greeting.service'

@Controller()
export class GreetingSlashController {
  constructor(private readonly greetingService: GreetingService) {}

  @Command('greet', GreetingCommandBuilder)
  @Cooldown({ uses: 3, seconds: 10 })
  async greet(interaction: ChatInputCommandInteraction, { name }: { name: string }) {
    await respond(interaction).send({ content: this.greetingService.buildGreeting(name) })
  }
}

The app class lists the controller, with the options discord.js's client is made from:

app.ts
import { GatewayIntentBits } from 'discord.js'
import { MeoCord } from 'meocord/decorator'
import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller'

@MeoCord({
  controllers: [GreetingSlashController],
  clientOptions: { intents: [GatewayIntentBits.Guilds] },
})
export default class App {}

Your first command builds this bot step by step.

How it works

A MeoCord bot is a discord.js bot with a container and a router in front of it. Here is a bot from build to shutdown, and where each part of this guide sits.

Build

meocord build compiles the bot and its meocord.config.ts into dist/. The built bot reads only dist/meocord.config.mjs, so it starts without TypeScript; see Configuration.

Start

dist/main.js calls MeoCordFactory.create(App) on the class @MeoCord decorates, then app.start().

  1. Create. The factory reads @MeoCord's options and the built config, then makes one container. It binds the discord.js Client, made from clientOptions, the translator, the handler registry, the shard context, the cooldown store, and every controller and service the app lists, with everything they inject, as singletons. It builds the message and component routes once, so a message pattern it can't read, or two patterns that match the same messages or customIds, stop the bot here. With process sharding, the first process instead becomes a manager that starts the shards, each of which runs these steps itself.
  2. Log in. start() first makes every provided value, waiting for async factories, and the services @MeoCord lists, so their constructors run before login. It then attaches MeoCord's listeners to the client and logs in. A failed factory or login rejects start() and sets the exit code to 1.
  3. Ready. When Discord says the client is ready, whatever isn't made yet, such as the controllers and the services only injected, is resolved, and the onReady hooks run in dependency order; see Lifecycle hooks. Alongside, the commands the builders describe are registered; see Slash commands.

Dispatch

Every interaction, message and reaction goes to the handler it belongs to:

What arrivesFound byHandler
A slash, context menu or entry point commandits name, and a subcommand's path@Command(name, Builder)
An autocomplete requestthe command's path and the option being typed@Autocomplete
A button, select menu or modalits customId, against every pattern, most specific first@Command(pattern, CommandType.…)
A messageits words after the prefix, or any text@MessageHandler
A reaction added or removedits emoji, or any emoji@ReactionHandler
Any other client eventits name@On and @Once

The handler then runs through the pipeline: @Defer's acknowledgement, guards, interceptors around validation, pipes, cooldowns and the handler, all inside exception filters. Observers are told about the call as it starts and once it has settled. respond() makes the right call to Discord for where the answer stands.

What lives how long

Lives for the whole appMade for each call
controllers and servicesguards
interceptors, exception filters, pipes and observersExecutionContext
the Client, translator and cooldown storethe handler's arguments

So a service or a field on a controller holds state across calls, and a guard holds none, unless it's bound: supplied by a provider, listed in services or injected by another class. A test shows it: one controller and one service serve two calls, and each call gets its own guard.

concepts/lifetimes.ts
// One instance for the whole app: what it holds is shared by every call
@Service()
export class VisitCounter {
  count = 0
}

// A new instance for every call: it holds nothing between them
@Guard()
export class CountingGuard implements GuardInterface {
  static created = 0

  constructor() {
    CountingGuard.created += 1
  }

  canActivate(): boolean {
    return true
  }
}

@Controller()
export class VisitButtonController {
  constructor(private readonly visits: VisitCounter) {}

  @Command('visit', CommandType.BUTTON)
  @UseGuard(CountingGuard)
  async visit(interaction: ButtonInteraction) {
    this.visits.count += 1
    await respond(interaction).send({ content: `Visit number ${this.visits.count}` })
  }
}
Dispatches button visit; button visit
concepts/lifetimes.spec.ts
describe('lifetimes', () => {
  it('shares one controller and service across calls, and makes a guard for each call', async () => {
    const module = MeoCordTestingModule.create({
      controllers: [VisitButtonController],
      providers: [{ provide: VisitCounter, useClass: VisitCounter }],
    }).compile()
    const click = () => createMockInteraction(ButtonInteraction, { customId: 'visit' })

    await module.invoke(VisitButtonController, 'visit', click())
    await module.invoke(VisitButtonController, 'visit', click())

    expect(module.get(VisitButtonController)).toBe(module.get(VisitButtonController))
    expect(module.get(VisitCounter).count).toBe(2)
    expect(CountingGuard.created).toBe(2)
  })
})

Where discord.js begins

MeoCord makes the client, logs it in and routes what it receives. Everything a handler touches is discord.js's own: the interaction, message, reaction and client are the library's classes, and anything discord.js can do, a handler or a service that injects the Client can do too. See Services.

Stop

On SIGINT or SIGTERM, the onShutdown hooks run in reverse dependency order, within shutdownTimeout, and the client is destroyed. See Lifecycle hooks.

Other ways to build a bot

If you've built a bot before, most of what you know carries over. Each framework below is a good home for the bots built on it, and each "coming from" page shows one bot both ways. The comparison is as of 25 September 2026, against the versions named, from each project's own documentation and published packages; corrections are welcome in the issue tracker.

From discord.js alone (14.27.0). Every framework here, MeoCord included, is built on it, and a MeoCord handler receives discord.js's own interaction, message and client. What you stop writing is the plumbing around them: the command handler, component routing, error handling and answer tracking. See Coming from discord.js.

From Sapphire (@sapphire/framework 5.5.1), the most downloaded of these frameworks on npm. Its commands, listeners and preconditions become controllers, @On handlers and guards, and the global container becomes constructor injection. Translations and subcommands, plugins in Sapphire, are built into MeoCord, and scheduled work is a service, as Scheduled tasks shows. See Coming from Sapphire.

From Necord (7.0.0), a NestJS module. Nest's guards, interceptors, pipes and exception filters have MeoCord counterparts with the same names and roles, built for Discord alone, so the bot needs no Nest application around it. @TextCommand becomes a typed message pattern, and @necord/localization typed catalogs. See Coming from Necord.

From discordx (11.13.3). Decorators on classes, as in MeoCord. Guard functions become guard classes that inject services, TSyringe or TypeDI become the built-in injection, and @SimpleCommand a typed pattern. Where discordx runs several bots in one process, a MeoCord bot is its own process, with its own config, token and logs. See Coming from discordx.

At a glance

"None in its docs" means the project's documentation, as of the date above, names no such feature; a community package may add one.

MeoCordSapphireNecorddiscordx
Declaring handlersdecoratorspieces, loaded from foldersdecoratorsdecorators
Dependency injectionconstructor injection, built ina global containerNestJS'sTSyringe or TypeDI
Before a handlerguards, interceptors, validation, pipespreconditionsNestJS's guards, interceptors and pipesguard functions
Around errorsexception filterserror events, with listenersNestJS's exception filtersnone in its docs
Cooldowns@CooldowncooldownDelay, built innone in its docsa RateLimit guard, @discordx/utilities
Prefix commandstyped patterns, flags and prefixesyes, with argument parsing@TextCommand, with arguments@SimpleCommand, with options
Testing toolkitmeocord/testing: invoke, mocksnone in its docsNestJS's testing modulenone in its docs
Translationstyped catalogs, built in@sapphire/plugin-i18next@necord/localizationnone in its docs
LanguageTypeScriptTypeScript or JavaScriptTypeScriptTypeScript
Documented runtimeNode.js 22.13+ or BunNode.js 18+Node.js 20.19+ or 22.13+Node.js 20+
Needsnothing elsenothing elsea NestJS applicationnothing else

Good to know

  • A modern core. MeoCord runs on discord.js 14.27 or a later 14.x, with Node.js 22.13+ or Bun. What a plugin would add elsewhere is a plain service here: injected where it's needed, and tested like the rest of the bot.
  • Built for TypeScript. The compiler checks each handler's params against its route or pattern, and each catalog's keys against the default one's, and npx meocord create starts every project in TypeScript.
  • One process, one bot. A process logs in with the token its meocord.config.ts gives, so each bot keeps its own config, token and logs, and restarts on its own. One bot grows across processes, a shard in each; see Sharding.

Next steps