Coming from discordx
One small bot written with discordx and with MeoCord, side by side, and what each decorator becomes.
Before this
discordx and MeoCord both describe a bot with decorators on classes. They differ in what they take off your hands.
With discordx, the bot imports its files, passes each interaction to executeInteraction and each message to
executeCommand, and calls initApplicationCommands itself. MeoCord does all of that from one app class. This page
shows one small bot both ways. The discordx side is typechecked against discordx 11.13.3.
What maps to what
| In discordx | In MeoCord |
|---|---|
A @Discord() class | A @Controller() class, listed in the app class |
@Slash and a @SlashOption per parameter | @Command with a @CommandBuilder class |
@ButtonComponent({ id }), a string or a RegExp | @Command('card/{ownerId}/refresh', CommandType.BUTTON), with captures |
@SimpleCommand and a @SimpleCommandOption | A message command pattern, with typed params |
A guard function with next() | A guard class, which can inject services |
A catch around executeCommand | dmOnError and dmOnCooldown, a DM to the author |
RateLimit from @discordx/utilities | @Cooldown |
@On({ event }) with ArgsOf | @On('guildMemberAdd'), with its own arguments |
DIService.engine set to tsyringe or TypeDI | Built in: services are injected by constructor |
importx over your files | The app class's controllers list |
A slash command
discordx describes the command in its decorators, one per option, and uses tsyringe here for injection:
@Discord()
@injectable()
export class Greet {
constructor(private readonly greetings: GreetingService) {}
// The command is described by the decorators, and registered by initApplicationCommands()
@Slash({ name: 'greet', description: 'Greets someone' })
@Guard(RateLimit(TIME_UNIT.seconds, 10, { rateValue: 3, ephemeral: true }))
async greet(
@SlashOption({
name: 'name',
description: 'Who to greet',
type: ApplicationCommandOptionType.String,
required: true,
})
name: string,
interaction: CommandInteraction,
) {
await interaction.reply({ content: this.greetings.build(name) })
}
}// Resolved through tsyringe, which main.ts hands to discordx
@injectable()
export class GreetingService {
build(name: string): string {
return `Hello, ${name}!`
}
}In MeoCord the builder is a discord.js SlashCommandBuilder, the options arrive together as one argument, and
injection needs no container of your choosing:
@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
discordx matches a button's customId against a string or a regular expression, and the handler reads its parts
itself. A guard is a function that calls next() to let the call through:
// A guard is a function; the id is a regular expression, and the handler reads its parts itself
const OnlyOwner: GuardFunction<ButtonInteraction> = async (interaction, _client, next) => {
const [, ownerId] = interaction.customId.split('/')
if (interaction.user.id === ownerId) return next()
await interaction.reply({ content: 'Only the user who opened this can use it.', flags: MessageFlags.Ephemeral })
}
@Discord()
export class Card {
@ButtonComponent({ id: /^card\/\d+\/refresh$/ })
@Guard(OnlyOwner)
async refresh(interaction: ButtonInteraction) {
await interaction.update({ content: `Refreshed at ${new Date().toISOString()}` })
}
}In MeoCord the pattern captures ownerId, and the guard is a class, so it can inject services:
@Command('card/{ownerId}/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}/…` */
@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
}
}Message commands
A discordx simple command declares each word as a @SimpleCommandOption, and runs only when the bot hands each
message to executeCommand:
// Simple commands run only when the bot hands each message to executeCommand
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
})
client.on('messageCreate', async message => {
await client.executeCommand(message)
})// A simple command declares each word as an option; the client's messageCreate listener passes messages on
@Discord()
export class Roll {
@SimpleCommand({ name: 'roll', prefix: '!' })
async roll(
@SimpleCommandOption({ name: 'sides', type: SimpleCommandOptionType.Number }) sides: number | undefined,
command: SimpleCommandMessage,
) {
if (!sides || sides < 2) {
await command.message.reply('Usage: !roll <sides>')
return
}
await command.message.reply(String(1 + Math.floor(Math.random() * sides)))
}
}In MeoCord a message command is a pattern, and MeoCord reads every message itself. 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
discordx's documentation names no hook for an error a handler throws, so the bot catches it where it calls
executeInteraction, or in the handler. In MeoCord, the error reaches the handler's
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.
Events and startup
A discordx listener takes the event's arguments as one tuple, typed with ArgsOf, and the bot finds the class by
importing its file:
@Discord()
export class Welcome {
@On({ event: 'guildMemberAdd' })
async greet([member]: ArgsOf<'guildMemberAdd'>) {
await member.send(`Welcome to ${member.guild.name}!`)
}
}DIService.engine = tsyringeDependencyRegistryEngine.setInjector(container)
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] })
// Registration, and passing interactions on, are the bot's to wire up
client.once('clientReady', async () => {
await client.initApplicationCommands()
})
client.on('interactionCreate', interaction => {
client.executeInteraction(interaction)
})
// Every decorated class is found by importing the files that declare it
await importx(`${dirname(import.meta.url)}/{greet,card,welcome}.ts`)
await client.login(process.env.DISCORD_TOKEN!)In MeoCord the listener receives the event's arguments as they are, and the app class lists the controllers:
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 {}Testing
MeoCord runs a handler through the same pipeline the bot uses, with mocks of the discord.js objects:
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
Guards are classes that inject services, exception filters decide what a member is told
when a handler throws, typed catalogs translate the bot, and
meocord/testing runs a handler through the same pipeline the bot uses. Pagination is a component
with a typed route, as Paginated lists shows. Each bot is its own process, with its own
config, token and logs, so it restarts and deploys on its own, and one bot spreads across processes with
sharding.
Moving over
Class by class: a @Discord() class becomes a @Controller(), each @Slash a @Command with a builder, and the
@SlashOption parameters become that builder's options, read from the handler's second argument. A guard function
becomes a guard class, and a @SimpleCommand a pattern.
Next steps
- Getting started: create a bot and start it.
- Slash commands: builders, options and registration.
- Guards: checks that run before a handler, and tell the caller why not.