Coming from Sapphire
One small bot written with Sapphire and with MeoCord, side by side, and what each piece becomes.
Before this
Sapphire and MeoCord both sit on discord.js and both take the plumbing off your hands. They differ in shape. Sapphire
builds a bot from pieces, classes it loads from folders by convention. MeoCord builds it from decorated controllers
that an app class lists, with dependencies injected by constructor. This page shows one small bot both ways. The
Sapphire side is typechecked against @sapphire/framework 5.5.1.
What maps to what
| In Sapphire | In MeoCord |
|---|---|
A Command piece in commands/ | A controller method with @Command, listed in the app class |
registerApplicationCommands and its registry | A @CommandBuilder class |
An InteractionHandler with parse() and run() | A method with a customId pattern such as card/{ownerId:snowflake}/refresh |
messageRun and Args | A message command pattern, with typed params |
| A precondition | A guard, for commands and components alike |
cooldownLimit and cooldownDelay | @Cooldown |
A listener for chatInputCommandError | An exception filter, or the built-in fallback |
A listener for messageCommandError | dmOnError and dmOnCooldown, a DM to the author |
A Listener piece in listeners/ | @On on a controller method |
container, augmented with your own properties | Services, injected by constructor |
A slash command
A Sapphire command is a class that registers itself, reads its options from the interaction, and reaches shared objects through the container:
// A piece in the commands folder, found by the file loader when the client starts
export class GreetCommand extends Command {
constructor(context: Command.LoaderContext, options: Command.Options) {
super(context, { ...options, description: 'Greets someone', cooldownLimit: 3, cooldownDelay: 10_000 })
}
override registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option => option.setName('name').setDescription('Who to greet').setRequired(true)),
)
}
override async chatInputRun(interaction: Command.ChatInputCommandInteraction) {
const name = interaction.options.getString('name', true)
await interaction.reply({ content: this.container.greetings.build(name) })
}
}export class GreetingService {
build(name: string): string {
return `Hello, ${name}!`
}
}
// Shared objects hang off Sapphire's container, typed by augmenting it
declare module '@sapphire/pieces' {
interface Container {
greetings: GreetingService
}
}
container.greetings = new GreetingService()In MeoCord, the builder is its own class, the options arrive as an argument, and the service is a constructor parameter, typed by its class with no augmentation:
@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) })
}
}@Service()
export class GreetingService {
buildGreeting(name: string): string {
return `Hello, ${name}!`
}
}A button with an owner
In Sapphire a button is an interaction handler: parse() decides whether it takes the interaction, and run()
handles it. Preconditions guard commands, so the owner check sits in run():
// An interaction handler decides in parse() whether a button is its own, and what to pass to run()
export class RefreshHandler extends InteractionHandler {
constructor(context: InteractionHandler.LoaderContext, options: InteractionHandler.Options) {
super(context, { ...options, interactionHandlerType: InteractionHandlerTypes.Button })
}
override parse(interaction: ButtonInteraction) {
const [scope, ownerId, action] = interaction.customId.split('/')
if (scope !== 'card' || action !== 'refresh') return this.none()
return this.some({ ownerId })
}
override async run(interaction: ButtonInteraction, { ownerId }: InteractionHandler.ParseResult<this>) {
// Preconditions guard commands; a handler checks for itself
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()}` })
}
}In MeoCord the pattern routes the click, and the owner check is 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
}
}Message commands
A Sapphire command handles messages with messageRun, reading each word from Args by type. The client loads the
message listeners only when asked, and takes the prefix:
// Message commands run once the client loads their listeners, with a prefix
export const client = new SapphireClient({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
defaultPrefix: '!',
loadMessageCommandListeners: true,
})// A message command reads its words one at a time from Args, by type
export class RollCommand extends Command {
constructor(context: Command.LoaderContext, options: Command.Options) {
super(context, { ...options, description: 'Rolls a die' })
}
override async messageRun(message: Message, args: Args) {
const sides = await args.pick('integer')
const note = await args.rest('string').catch(() => '')
const result = 1 + Math.floor(Math.random() * sides)
await message.reply(note ? `${result} (${note})` : String(result))
}
}In MeoCord a message command is a pattern. The app sets the prefix, the pattern or a schema types each param, 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
Sapphire emits an error a command throws as an event, and its default listeners log it. A listener of your own decides what the member is told:
// An error a command throws is an event; a listener for it decides what the member is told
export class ChatInputCommandError extends Listener<typeof Events.ChatInputCommandError> {
constructor(context: Listener.LoaderContext, options: Listener.Options) {
super(context, { ...options, event: Events.ChatInputCommandError })
}
override async run(error: unknown, { interaction }: ChatInputCommandErrorPayload) {
this.container.logger.error(error)
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({ content: 'Something went wrong.', flags: MessageFlags.Ephemeral })
}
}
}In MeoCord the built-in fallback logs the error 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. An exception filter
answers an error of its own type in its own words, on one handler, a controller or the whole bot:
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 startup
A Sapphire listener is a piece too, and the client finds it in listeners/:
export class WelcomeListener extends Listener<typeof Events.GuildMemberAdd> {
constructor(context: Listener.LoaderContext, options: Listener.Options) {
super(context, { ...options, event: Events.GuildMemberAdd })
}
override async run(member: GuildMember) {
await member.send(`Welcome to ${member.guild.name}!`)
}
}// Commands, handlers and listeners are loaded from folders next to the entry point
const client = new SapphireClient({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] })
await client.login(process.env.DISCORD_TOKEN)In MeoCord an event is a decorated method, and the app class lists what the bot is made of, so nothing is found by its location:
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, guards and cooldowns included, 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
Translations and subcommands are built in: typed catalogs, where a key the default catalog lacks
or a param left out fails to compile, and subcommand handlers on the parent command's builder.
Guards apply to buttons, select menus and modals as well as to commands, and meocord/testing runs a
handler through the same pipeline the bot uses. Scheduled work is a service that starts in onReady and stops in
onShutdown, as Scheduled tasks shows. MeoCord is built for TypeScript, so a JavaScript bot
moves to it as it moves over, and its decorators and typed params become what the compiler checks.
Moving over
Move a piece at a time. A command's chatInputRun body usually moves as it is into a controller method, and what it
read from the container becomes a constructor parameter. A precondition becomes a guard, and a messageRun becomes
a pattern whose params replace the args.pick calls.
Next steps
- Getting started: create a bot and start it.
- Services: what replaces the container, and how a test swaps one.
- Message commands: prefixes, patterns and what a user sees on a misuse.