Skip to content
GitHub

Coming from Necord

MeoCord 4.2

One small bot written with Necord and with MeoCord, side by side, and what each Nest provider becomes.

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 NecordIn MeoCord
A Nest module listing every providerThe 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:snowflake}/refresh', CommandType.BUTTON), with captures
@TextCommand and @Arguments()A message command pattern, with typed params
A Nest CanActivate and NecordExecutionContextA guard, given the interaction directly
A Nest exception filter and NecordArgumentsHostAn exception filter, given the call
A Nest exception filter on a @TextCommanddmOnError 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:

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

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

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

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 Necord text command receives its words as strings, and NecordModule's prefix option sets the prefix:

necord/roll.command.ts
// 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:

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

Necord uses Nest's exception filters, which read the interaction from Necord's arguments host:

necord/error.filter.ts
// 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:

filters/unknown-account.filter.ts
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:

necord/welcome.listener.ts
@Injectable()
export class WelcomeListener {
  @On('guildMemberAdd')
  async greet(@Context() [member]: ContextOf<'guildMemberAdd'>) {
    await member.send(`Welcome to ${member.guild.name}!`)
  }
}
necord/app.module.ts
// 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:

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

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:

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

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