Coming from discord.js
One small bot written with discord.js alone and with MeoCord, side by side, and what each part becomes.
Before this
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 bot | In MeoCord |
|---|---|
A SlashCommandBuilder, sent with REST from a script | A @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 hand | A pattern such as card/{ownerId:snowflake}/refresh, captured into an argument |
A messageCreate listener that splits the content | A message command pattern, such as roll {sides} |
| Checks at the top of a handler | A guard |
A Map of timestamps | @Cooldown |
try/catch around every handler | The built-in fallback, or an exception filter |
A catch that replies to a message command | dmOnError and dmOnCooldown, a DM to the author |
client.on(Events.GuildMemberAdd, ...) | @On('guildMemberAdd') on a controller method |
| Modules you import and pass around | Services, 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:
// 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()] })
}// 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:
@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))
}
}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:
// 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:
@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] })
}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:
// 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:
// !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:
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:
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:
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()
}
}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:
@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:
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
- Getting started: create a bot and start it.
- Your first command: a slash command, its service and its test.
- Message commands: prefixes, patterns and what a user sees on a misuse.