Skip to content
GitHub

Handler discovery

MeoCord 4.2 · Structuring your app · page 23 of 41 · since 4.1.0

List every handler your bot registered, with its metadata, for a help command, an admin page or generated docs.

You'll learn

  • Inject the registry and list handlers by kind or controller
  • Write a help command for slash and message commands
  • Read your own metadata from each handler

HandlerRegistry, from meocord/core, lists every handler the app registered: commands, components, modals, autocomplete, message, reaction and event handlers, on every controller and service. Each entry carries what was declared on it, so a help command reads the same names and descriptions Discord shows.

When to use it

Use it for anything that describes the bot from its own code: a /help or !help command, a page of commands on an admin dashboard, a list of events for a startup log, or generated documentation. A new command then shows up in all of them without another edit.

To find which handler a message or a custom ID would reach, in a test, use resolveRoute instead.

Example

services/help.service.ts
import { HandlerRegistry } from 'meocord/core'
import { Service } from 'meocord/decorator'
import { CommandType } from 'meocord/enum'

@Service()
export class HelpService {
  constructor(private readonly handlers: HandlerRegistry) {}

  // One line per slash command and subcommand, for a /help reply
  lines(): string[] {
    return this.handlers
      .list({ kind: 'command' })
      .filter(handler => handler.commandType === CommandType.SLASH)
      .map(handler => `/${handler.name}: ${handler.description ?? 'No description'}`)
  }
}

Inject it like any service. list({ kind: 'command' }) returns one entry per slash command, subcommand and context menu command. The service keeps the slash ones, by their commandType, and /help replies with a line for each.

How it works

The registry reads the handlers of every controller and service the app binds, once, on the first list(). Entries come class by class, in the order the app makes them, each class after what it injects.

list({ kind, controller }) filters by what a handler handles, by the class declaring it, or both, and narrows the entries' type to that kind. Every entry has controller, method, kind and name:

kindnameAlso
commandThe command, or a subcommand's full pathcommandType, command (the registered JSON), description
componentThe custom ID pattern, such as profile/{uid}commandType
modalThe custom ID patterncommandType
autocompleteThe command path, then the option it completes
messageThe pattern, or none for every messagecommand, aliases, description, scope, hidden, usage(prefix), matches(words)
reactionThe emoji, or none for every reaction
eventThe client eventonce

A subcommand's description is its own, and a context menu command has none.

Message commands

A message command is listed once, with the aliases, description and scope its handler declares:

controllers/message/moderation.message.controller.ts
@MessageHandler('mute {target:member} {duration:duration?} {reason...?}', {
  aliases: ['m', 'shush'],
  description: 'Times a member out, for 10 minutes unless told otherwise.',
  scope: 'guild',
})
async mute(
  message: Message,
  { target, duration, reason }: { target: GuildMember; duration?: number; reason?: string },
) {
  await target.timeout(duration ?? 600_000, reason)
  await message.reply(`Muted ${target.displayName}`)
}
Dispatches message m <@140000000000000014> 1h

For a help command, messageHelp(message, query?) answers as the built-in !help would: the commands the caller can use where they asked, or the one query names, its subcommands, or that nothing matches. It returns a MessageHelp to write your own way, with each param's label in the server's language where the app translates MeoCord's texts:

controllers/message/help.message.controller.ts
import { type Message } from 'discord.js'
import { HandlerRegistry } from 'meocord/core'
import { Controller, MessageHandler } from 'meocord/decorator'

@Controller()
export class HelpMessageController {
  constructor(private readonly handlers: HandlerRegistry) {}

  // !help lists the commands in a code block; !help mute, or !help m, shows one
  @MessageHandler('help {command...?}', { description: 'Lists the commands, or shows one.' })
  async help(message: Message, { command }: { command?: string }) {
    const help = await this.handlers.messageHelp(message, command)
    if (help.kind === 'list') {
      await message.reply(['```', ...help.commands.map(entry => entry.usage), '```'].join('\n'))
    } else if (help.kind === 'command') {
      await message.reply(
        help.commands.map(entry => [entry.usage, entry.description].filter(Boolean).join(': ')).join('\n'),
      )
    } else if (help.kind === 'parent') {
      await message.reply(help.subcommands.map(entry => entry.usage).join('\n'))
    } else {
      await message.reply(help.kind === 'unknown' ? `There is no ${help.query} command.` : 'Nothing to show here.')
    }
  }
}

An entry's usage('!') writes the command as a user types it, such as !mute <target> [duration] [reason…], and matches(words) tells whether words name it or one of its aliases, with the same case rules as the handler, for a listing that is not a help command.

Your own metadata

get and getAll read metadata declared on a handler, the method's first and then its controller's, as ExecutionContext does. A category made with createMetadata and set on each controller groups a help command's lines: handler.get(Category) ?? 'Other'. Custom decorators shows how to make one. A string or symbol key still works, and is deprecated as it is on ExecutionContext.

Testing

A testing module binds a registry of the controllers, services and providers it's given, so a test lists exactly their handlers:

services/help.service.spec.ts
describe('HelpService', () => {
  it('lists the slash commands, and not the context menu commands or message handlers', () => {
    const module = MeoCordTestingModule.create({
      controllers: [GreetingSlashController, ReportContextMenuController, KeywordMessageController],
      providers: [
        { provide: GreetingService, useClass: GreetingService },
        { provide: HelpService, useClass: HelpService },
      ],
    }).compile()

    expect(module.get(HelpService).lines()).toEqual(['/greet: Greets someone'])
  })
})
controllers/message/help.message.controller.spec.ts
describe('HelpMessageController', () => {
  const module = MeoCordTestingModule.create({
    app: OwnHelpApp,
    controllers: [ModerationMessageController, HelpMessageController],
  }).compile()

  it('lists the commands from the model the built-in help uses, and shows one', async () => {
    const list = createMockMessage({ content: '!help' })
    await module.dispatch(list)
    expect(replyTo(list)).toContain('!mute <target> [duration] [reason…]')

    const one = createMockMessage({ content: '!help nope' })
    await module.dispatch(one)
    expect(replyTo(one)).toBe('There is no nope command.')
  })
})

Gotchas

  • A handler on a class the app doesn't bind isn't listed. List its controller in controllers, or its service in services.
  • A subcommand is its own entry. /settings notify email is listed by its full path; group entries by their first word for one line per top-level command.
  • The prefix isn't known to an entry. Pass the one users type to usage(); an app with several prefixes picks one to show.

Next steps