Skip to content
GitHub

Your first command

MeoCord 4.1 · Start · page 3 of 41

Write a slash command with a builder, a controller and a service, register it in the app, and test it.

You'll learn

  • Describe a slash command to Discord with a builder
  • Handle it in a controller method that uses a service
  • List the controller in the app, and test the handler

Before this

A slash command in MeoCord has three parts: a builder that describes it to Discord, a controller method that handles it, and the app class that lists the controller. This page builds /greet, which answers "Hello, Ada!" to /greet name:Ada.

When to use it

Every command, button and form in a MeoCord bot follows this shape, so write this one first: the chapters after it add options, components and stages to the same three parts. To scaffold them instead, run npx meocord generate controller slash greeting: it writes a controller, its builder and its spec, and the CLI lists every generator.

Example

The builder describes the command. It receives the command's name from @Command, so the two can't drift apart:

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

The controller binds a method to the command with @Command. The method receives the interaction, and the options the user filled in as its second argument. It answers through respond(), and @Cooldown allows three calls per user every ten seconds:

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

The greeting comes from a service, a plain class marked with @Service():

services/greeting.service.ts
@Service()
export class GreetingService {
  buildGreeting(name: string): string {
    return `Hello, ${name}!`
  }
}

The app class lists the controller, and the discord.js client options:

app.ts
import { GatewayIntentBits } from 'discord.js'
import { MeoCord } from 'meocord/decorator'
import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller'

@MeoCord({
  controllers: [GreetingSlashController],
  clientOptions: { intents: [GatewayIntentBits.Guilds] },
})
export default class App {}

Run npx meocord start --dev, and /greet appears in your test server.

How it works

The builder

A command Discord knows about needs a builder, which is what gets registered: @CommandBuilder(CommandType.SLASH) marks the class, and its build() returns a discord.js SlashCommandBuilder. MeoCord calls it once, as the class loads, with the name @Command gives, and registers what it returns when the bot is ready. Slash commands covers options, subcommands, and where commands are registered.

The controller

@Controller() marks a class whose methods handle calls, and @Command('greet', GreetingCommandBuilder) binds one of them to /greet. The options arrive as a plain object, named as the builder names them, so the handler reads name without going through interaction.options.

The service

The controller asks for GreetingService in its constructor, and MeoCord creates one instance and passes it in. The same instance serves every call, and any other class that asks for it. The service needs no listing in the app: a controller that injects it is enough. Services covers the services you do list, and values that aren't classes.

The app

@MeoCord marks the class src/main.ts starts, and lists its controllers. A controller missing from controllers is never bound, and its commands are never registered. clientOptions are discord.js's own: GatewayIntentBits.Guilds is enough for slash commands, which arrive without further intents.

Testing

MeoCordTestingModule runs the handler through the same pipeline the bot does, with no Discord connection, and getResponse reports what respond() sent:

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!' }) },
    ])
  })
})

Run the specs with npm test, or your package manager's equivalent. Testing covers the module, and the mocks for every interaction type.

Gotchas

  • A new controller does nothing until it's in controllers. meocord generate doesn't edit src/app.ts, so add each class it writes there yourself.
  • A command's name is lowercase, at most 32 characters, with no spaces: Discord refuses others, and the builder throws as the controller loads, naming the builder and the command.
  • A method called directly skips most of the pipeline. Only its own and its controller's guards run. In a test, go through invoke, so its interceptors, validation, cooldowns and filters run as they do in the bot, and the app's global guards, interceptors and filters too when the module is built with app or fromApp.

Next steps