Coming from Necord
One small bot written with Necord and with MeoCord, side by side, and what each Nest provider becomes.
Before this
Necord brings discord.js into NestJS: handlers are Nest providers, guards are Nest guards, and the bot runs inside a
Nest application. MeoCord borrows the same ideas, controllers, services, guards, interceptors and filters, without
Nest. It builds them for Discord alone. This page shows one small bot both ways. The Necord side is typechecked
against necord 7.0.0 with @nestjs/core 12.1.0.
What maps to what
| In Necord | In MeoCord |
|---|---|
| A Nest module listing every provider | The app class, listing controllers |
@SlashCommand on a provider method | @Command on a controller method, with a @CommandBuilder class |
An options class with @StringOption, @Options() | The builder's options, arriving as the handler's second argument |
@Context() [interaction] | The interaction as the handler's first argument |
@Button('card/:ownerId/refresh'), @ComponentParam | @Command('card/{ownerId}/refresh', CommandType.BUTTON), with captures |
@TextCommand and @Arguments() | A message command pattern, with typed params |
A Nest CanActivate and NecordExecutionContext | A guard, given the interaction directly |
A Nest exception filter and NecordArgumentsHost | An exception filter, given the call |
A Nest exception filter on a @TextCommand | dmOnError and dmOnCooldown, a DM to the author |
@On('guildMemberAdd') with ContextOf | @On('guildMemberAdd'), with its own arguments |
Nest providers and @Injectable() | Services with @Service() |
A slash command
Necord reads the options from a class of its own, and the interaction from the context tuple:
// Options are declared on a class, and read with @Options()
class GreetOptions {
@StringOption({ name: 'name', description: 'Who to greet', required: true })
name: string
}
@Injectable()
export class GreetCommand {
constructor(private readonly greetings: GreetingService) {}
@SlashCommand({ name: 'greet', description: 'Greets someone' })
async greet(@Context() [interaction]: SlashCommandContext, @Options() { name }: GreetOptions) {
await interaction.reply({ content: this.greetings.build(name) })
}
}@Injectable()
export class GreetingService {
build(name: string): string {
return `Hello, ${name}!`
}
}In MeoCord the builder is a discord.js SlashCommandBuilder, and the handler receives the interaction and the
options directly:
@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
A Necord button is a route with : params, and its guard is a Nest guard that reads the interaction out of Nest's
execution context:
// A Nest guard reads the interaction out of Nest's execution context
@Injectable()
class OwnerGuard implements CanActivate {
async canActivate(context: ExecutionContext) {
const [interaction] = NecordExecutionContext.create(context).getContext<[ButtonInteraction]>()
const [, ownerId] = interaction.customId.split('/')
if (interaction.user.id === ownerId) return true
await interaction.reply({ content: 'Only the user who opened this can use it.', flags: MessageFlags.Ephemeral })
return false
}
}
@Injectable()
export class CardComponent {
@Button('card/:ownerId/refresh')
@UseGuards(OwnerGuard)
async refresh(@Context() [interaction]: ButtonContext, @ComponentParam('ownerId') ownerId: string) {
await interaction.update({ content: `Refreshed for <@${ownerId}> at ${new Date().toISOString()}` })
}
}In MeoCord the guard receives the interaction itself, and any button can reuse it:
@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 Necord text command receives its words as strings, and NecordModule's prefix option sets the prefix:
// A text command receives its words as strings; the prefix is NecordModule's `prefix` option
@Injectable()
export class RollCommand {
@TextCommand({ name: 'roll', description: 'Rolls a die' })
async roll(@Context() [message]: TextCommandContext, @Arguments() words: string[]) {
const sides = Number(words[0])
if (!Number.isInteger(sides) || sides < 2) return message.reply('Usage: !roll <sides> [note]')
const note = words.slice(1).join(' ')
const result = 1 + Math.floor(Math.random() * sides)
return message.reply(note ? `${result} (${note})` : String(result))
}
}In MeoCord a message command is a pattern. 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
Necord uses Nest's exception filters, which read the interaction from Necord's arguments host:
// A Nest exception filter, applied with @UseFilters or globally, reads the interaction from Necord's host
@Catch()
export class CommandErrorFilter implements ExceptionFilter {
async catch(error: unknown, host: ArgumentsHost) {
const [interaction] = NecordArgumentsHost.create(host).getContext<[ChatInputCommandInteraction]>()
console.error(error)
if (interaction.isRepliable() && !interaction.replied) {
await interaction.reply({ content: 'Something went wrong.', flags: MessageFlags.Ephemeral })
}
}
}A MeoCord filter works the same way: @UseFilter applies it to a handler or a controller, and @MeoCord({ filters })
to the whole bot, and it is given the call's context. An error no filter handles reaches 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:
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 Necord listener takes the event's arguments through @Context(), and the Nest module lists it with every other
provider:
@Injectable()
export class WelcomeListener {
@On('guildMemberAdd')
async greet(@Context() [member]: ContextOf<'guildMemberAdd'>) {
await member.send(`Welcome to ${member.guild.name}!`)
}
}// A Nest module lists every provider, handlers included; Necord finds the decorated ones among them
@Module({
imports: [
NecordModule.forRoot({
token: process.env.DISCORD_TOKEN!,
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
}),
],
providers: [GreetingService, GreetCommand, CardComponent, WelcomeListener],
})
class AppModule {}
await NestFactory.createApplicationContext(AppModule)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
Necord's package ships no testing helpers of its own. MeoCord's testing module 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
The ideas Necord borrows from Nest, controllers, services, guards, interceptors, pipes and exception filters, built
for Discord alone: the bot needs no Nest application, module or @Injectable() around it, and a guard receives the
interaction itself rather than an execution context to unwrap. Message commands get
typed patterns with flags, and meocord/testing runs a handler through the
same pipeline the bot uses, with mocks of discord.js's own classes.
Moving over
Necord providers become controllers and services almost line for line: @Injectable() becomes @Service(), a
handler's @Context() tuple becomes its first argument, and Nest guards and filters become MeoCord's.
Next steps
- Getting started: create a bot and start it.
- Services: providers without a Nest module.
- Exception filters: what the member is told when a handler throws.