Skip to content
GitHub

Coming from discordx

MeoCord 4.2

One small bot written with discordx and with MeoCord, side by side, and what each decorator becomes.

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 discordxIn MeoCord
A @Discord() classA @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:snowflake}/refresh', CommandType.BUTTON), with captures
@SimpleCommand and a @SimpleCommandOptionA message command pattern, with typed params
A guard function with next()A guard class, which can inject services
A catch around executeCommanddmOnError 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 TypeDIBuilt in: services are injected by constructor
importx over your filesThe app class's controllers list

A slash command

discordx describes the command in its decorators, one per option, and uses tsyringe here for injection:

discordx/greet.ts
@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) })
  }
}
discordx/greeting.service.ts
// 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:

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

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:

discordx/card.ts
// 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:

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
  }
}

Message commands

A discordx simple command declares each word as a @SimpleCommandOption, and runs only when the bot hands each message to executeCommand:

discordx/messages.ts
// 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)
})
discordx/roll.ts
// 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:

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

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:

discordx/welcome.ts
@Discord()
export class Welcome {
  @On({ event: 'guildMemberAdd' })
  async greet([member]: ArgsOf<'guildMemberAdd'>) {
    await member.send(`Welcome to ${member.guild.name}!`)
  }
}
discordx/main.ts
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:

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 {}

Testing

MeoCord runs a handler through the same pipeline the bot uses, with mocks of the discord.js objects:

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

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