Skip to content
GitHub

Coming from discord.js

MeoCord 4.2

One small bot written with discord.js alone and with MeoCord, side by side, and what each part becomes.

MeoCord runs on discord.js 14, from 14.27.0. Every interaction, message and client you handle is the discord.js object you know, so what changes is the code around your handlers: routing, registration, error answers, cooldowns and tests. This page shows one small bot both ways. The discord.js side is typechecked against discord.js 14.27.0.

What maps to what

In a discord.js botIn MeoCord
A SlashCommandBuilder, sent with REST from a scriptA @CommandBuilder class, registered when the bot starts
The interactionCreate listener and its if chain@Command on a controller method; MeoCord routes to it
Splitting customId by handA pattern such as card/{ownerId:snowflake}/refresh, captured into an argument
A messageCreate listener that splits the contentA message command pattern, such as roll {sides}
Checks at the top of a handlerA guard
A Map of timestamps@Cooldown
try/catch around every handlerThe built-in fallback, or an exception filter
A catch that replies to a message commanddmOnError and dmOnCooldown, a DM to the author
client.on(Events.GuildMemberAdd, ...)@On('guildMemberAdd') on a controller method
Modules you import and pass aroundServices, injected by constructor

A slash command

In discord.js, the command is a builder you send to Discord yourself, and its handler is a branch of the interaction listener. The cooldown is yours to keep:

discordjs/bot.ts
// Commands are registered by a separate call, usually a script run before starting the bot
const greet = new SlashCommandBuilder()
  .setName('greet')
  .setDescription('Greets someone')
  .addStringOption(option => option.setName('name').setDescription('Who to greet').setRequired(true))

export async function register(token: string, applicationId: string) {
  await new REST().setToken(token).put(Routes.applicationCommands(applicationId), { body: [greet.toJSON()] })
}
discordjs/bot.ts
// Three uses per ten seconds, per user, kept by hand
const uses = new Map<string, number[]>()

async function onGreet(interaction: ChatInputCommandInteraction) {
  const now = Date.now()
  const recent = (uses.get(interaction.user.id) ?? []).filter(time => now - time < 10_000)
  if (recent.length >= 3) {
    await interaction.reply({ content: 'Slow down.', flags: MessageFlags.Ephemeral })
    return
  }
  uses.set(interaction.user.id, [...recent, now])
  await interaction.reply({ content: greetings.build(interaction.options.getString('name', true)) })
}

In MeoCord, the builder receives its name from @Command, registration happens at startup, and the cooldown is a decorator. The member's name arrives as an argument:

controllers/slash/builders/greeting.builder.ts
@CommandBuilder(CommandType.SLASH)
export class GreetingCommandBuilder {
  build(commandName: string) {
    return new SlashCommandBuilder()
      .setName(commandName)
      .setDescription('Greets someone')
      .addStringOption(option => option.setName('name').setDescription('Who to greet').setRequired(true))
  }
}
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) })
  }
}

A button with an owner

discord.js hands every button to the same listener, so the customId is split and checked by hand:

discordjs/bot.ts
// The customId is parsed by hand, and the owner check is part of the handler
async function onRefresh(interaction: ButtonInteraction, ownerId: string) {
  if (interaction.user.id !== ownerId) {
    await interaction.reply({ content: 'Only the user who opened this can use it.', flags: MessageFlags.Ephemeral })
    return
  }
  await interaction.update({ content: `Refreshed at ${new Date().toISOString()}` })
}

MeoCord routes by pattern, and the owner check becomes a guard that any button can reuse:

controllers/button/card.button.controller.ts
@Command('card/{ownerId:snowflake}/refresh', CommandType.BUTTON)
@UseGuard(OwnerGuard)
@Defer()
async refresh(interaction: ButtonInteraction) {
  const card = new EmbedBuilder().setTitle('Refreshed').setTimestamp()
  // Without `components`, the buttons come back as they were before the lock
  await respond(interaction).send({ embeds: [card] })
}
guards/owner.guard.ts
import { type ButtonInteraction } from 'discord.js'
import { GuardDeniedError } from 'meocord/common'
import { Guard } from 'meocord/decorator'
import { type GuardInterface } from 'meocord/interface'

/** Lets only the user whose id the button carries use it: `card/{ownerId:snowflake}/…` */
@Guard()
export class OwnerGuard implements GuardInterface {
  canActivate(interaction: ButtonInteraction, { ownerId }: { ownerId: string }): boolean {
    // Thrown, it is answered privately; returning false would deny silently
    if (interaction.user.id !== ownerId) throw new GuardDeniedError('Only the user who opened this can use it.')
    return true
  }
}

@Defer() acknowledges the click at once, so a slow handler never hits Discord's three-second limit.

Message commands

A prefix command in discord.js is a messageCreate listener: the prefix, the split into words and the check of each word are the bot's own:

discordjs/prefix.ts
// A message command is a listener on every message: the prefix, the split and the parsing are the bot's own
const client = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
})

client.on(Events.MessageCreate, async message => {
  if (message.author.bot || !message.content.startsWith('!')) return
  const [name, ...words] = message.content.slice(1).trim().split(/\s+/)
  if (name !== 'roll') return
  const sides = Number(words[0])
  if (!Number.isInteger(sides) || sides < 2) {
    await message.reply('Usage: !roll <sides> [note]')
    return
  }
  const note = words.slice(1).join(' ')
  const result = 1 + Math.floor(Math.random() * sides)
  await message.reply(note ? `${result} (${note})` : String(result))
})

In MeoCord it is a pattern. The app sets the prefix once, each param is checked before the handler runs, here by a schema, and a message that leaves out a required param gets the command's usage in reply, and one the schema refuses gets the reason:

controllers/message/dice.message.controller.ts
// !roll 20 for initiative  gives  { sides: 20, note: 'for initiative' }
@MessageHandler('roll {sides} {note...?}')
@Validate(z.object({ sides: z.coerce.number().int().min(2).max(100), note: z.string().optional() }))
async roll(message: Message, { sides, note }: { sides: number; note?: string }) {
  const result = 1 + Math.floor(Math.random() * sides)
  await message.reply(note ? `${result} (${note})` : String(result))
}

Errors

The discord.js bot wraps its listener in try/catch, and decides there what the member sees. In MeoCord, an error a handler throws reaches its exception filters first, then the built-in fallback, which logs it and tells the member something went wrong: privately, or in the reply a public @Defer started. A message command's error is logged only, apart from a UserError, whose message is the reply, unless the app turns on dmOnError. A filter answers an error of its own type in its own words:

filters/unknown-account.filter.ts
import { type ExecutionContext } from 'meocord/common'
import { Catch } from 'meocord/decorator'
import { type ExceptionFilter } from 'meocord/interface'

export class UnknownAccountError extends Error {}

@Catch(UnknownAccountError)
export class UnknownAccountFilter implements ExceptionFilter<UnknownAccountError> {
  async catch(error: UnknownAccountError, context: ExecutionContext) {
    // The params as they were when the error was thrown: here the pipe threw, so the uid is still the text
    const { uid } = context.getHandlerParams<{ uid: string }>() ?? {}
    await context.response?.error(error, { message: `There is no account ${uid}.`, visibility: 'private' })
  }
}

Events and the client

In discord.js, the client, its listeners and the error handling are wired up together:

discordjs/bot.ts
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] })

// One listener receives every interaction, and routes it
client.on(Events.InteractionCreate, async interaction => {
  try {
    if (interaction.isChatInputCommand() && interaction.commandName === 'greet') await onGreet(interaction)
    if (interaction.isButton()) {
      const [scope, ownerId, action] = interaction.customId.split('/')
      if (scope === 'card' && action === 'refresh') await onRefresh(interaction, ownerId)
    }
  } catch (error) {
    console.error(error)
    if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) {
      await interaction.reply({ content: 'Something went wrong.', flags: MessageFlags.Ephemeral })
    }
  }
})

client.on(Events.GuildMemberAdd, async (member: GuildMember) => {
  await member.send(`Welcome to ${member.guild.name}!`)
})

await client.login(process.env.DISCORD_TOKEN)

In MeoCord, an event is a decorated method, and the app class lists the controllers and client options:

controllers/event/welcome.controller.ts
import { type Client, type GuildMember } from 'discord.js'
import { Controller, On, Once } from 'meocord/decorator'

@Controller()
export class WelcomeController {
  // Every time a member joins; needs the GuildMembers intent
  @On('guildMemberAdd')
  async greet(member: GuildMember) {
    await member.send(`Welcome to ${member.guild.name}!`)
  }

  // The first time the client is ready, and never again
  @Once('clientReady')
  async warmCache(client: Client<true>) {
    await client.guilds.fetch()
  }
}
app-beyond-commands.ts
import { GatewayIntentBits, Partials } from 'discord.js'
import { MeoCord } from 'meocord/decorator'
import { WelcomeController } from '@src/controllers/event/welcome.controller'
import { DiceMessageController } from '@src/controllers/message/dice.message.controller'
import { KeywordMessageController } from '@src/controllers/message/keyword.message.controller'
import { StarReactionController } from '@src/controllers/reaction/star.reaction.controller'
import { ReminderScheduler } from '@src/services/reminder.scheduler'

@MeoCord({
  controllers: [DiceMessageController, KeywordMessageController, StarReactionController, WelcomeController],
  services: [ReminderScheduler],
  clientOptions: {
    intents: [
      GatewayIntentBits.Guilds,
      // Messages, and their content, for @MessageHandler
      GatewayIntentBits.GuildMessages,
      GatewayIntentBits.MessageContent,
      // Reactions for @ReactionHandler, with the partials for messages sent before the bot started
      GatewayIntentBits.GuildMessageReactions,
      // guildMemberAdd, for @On in WelcomeController
      GatewayIntentBits.GuildMembers,
    ],
    partials: [Partials.Message, Partials.Reaction],
  },
  // Patterned message handlers match after a !, or a mention of the bot
  messages: { prefix: '!', mention: true },
})
export default class App {}

Shared logic

The discord.js bot keeps its shared logic in a plain module. In MeoCord it is a service, created once and injected where it is needed, which a test can replace:

services/greeting.service.ts
@Service()
export class GreetingService {
  buildGreeting(name: string): string {
    return `Hello, ${name}!`
  }
}

Testing

MeoCord runs a handler through the same pipeline the bot uses, with mocks of the discord.js objects, and records what it sent:

controllers/slash/greeting.slash.controller.spec.ts
import { ChatInputCommandInteraction } from 'discord.js'
import { createChatInputOptions, createMockInteraction, getResponse, MeoCordTestingModule } from 'meocord/testing'
import { describe, expect, it } from 'vitest'
import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller'

describe('GreetingSlashController', () => {
  const module = MeoCordTestingModule.create({ controllers: [GreetingSlashController] }).compile()

  it('greets by name', async () => {
    const interaction = createMockInteraction(ChatInputCommandInteraction)
    interaction.options = createChatInputOptions({ name: 'Ada' })

    await module.invoke(GreetingSlashController, 'greet', interaction)

    expect(getResponse(interaction).calls).toEqual([
      { method: 'reply', payload: expect.objectContaining({ content: 'Hello, Ada!' }) },
    ])
  })
})

What you gain

The parts a discord.js bot writes by hand come with MeoCord. Commands register from their builders, buttons and modals route by pattern, guards and cooldowns are decorators, and an interaction whose handler throws still gets the member an answer. Nothing between you and the API is taken away: every handler receives discord.js's own objects, and a service that injects the Client can do anything discord.js can. Tests run a handler through the same pipeline the bot uses.

Moving over

Nothing has to move at once. The Client is injectable, so code that works on the client directly can live in a service while commands move to controllers one at a time. Until the last one has moved, set commands.register to false in meocord.config.ts and keep your own registration, since MeoCord's registration replaces the application's commands in each scope with its builders'. A slash command no controller handles goes to the app's exception filters, then gets "Command not found!", so give the app a filter for CommandNotFoundError while your old listener still answers some.

Next steps