Skip to content
GitHub

What you can build

MeoCord 4.1

Every kind of handler MeoCord runs, from slash commands to gateway events, each with a small working example.

A MeoCord bot is controllers, and a controller's methods are its handlers. Each kind of handler answers one thing a member does in Discord: runs a command, clicks a button, sends a message, reacts, or joins a server. Here is each kind, with a small example that compiles and runs, and the page that teaches it.

Slash commands

A member types /echo and picks its options. The builder describes the command Discord shows, and the handler answers it:

controllers/slash/echo.slash.controller.ts
// Describes the command to Discord: its name comes from @Command, the rest from here
@CommandBuilder(CommandType.SLASH)
export class EchoCommandBuilder {
  build(commandName: string) {
    return new SlashCommandBuilder()
      .setName(commandName)
      .setDescription('Repeats what you say, to you alone')
      .addStringOption(option => option.setName('text').setDescription('What to repeat').setRequired(true))
  }
}
controllers/slash/echo.slash.controller.ts
@Controller()
export class EchoSlashController {
  // /echo text:hello  gives  { text: 'hello' }
  @Command('echo', EchoCommandBuilder)
  async echo(interaction: ChatInputCommandInteraction, { text }: { text: string }) {
    await respond(interaction).send({ content: text, flags: MessageFlags.Ephemeral })
  }
}

Slash commands covers options, registering and entry point commands.

Subcommands

One command with groups and subcommands, such as /settings notify email, each routed to a handler of its own:

controllers/slash/settings.slash.controller.ts
@Controller()
export class SettingsSlashController {
  // The builder is declared once, on the command itself
  @Command('settings', SettingsCommandBuilder)
  async settings(interaction: ChatInputCommandInteraction) {
    await respond(interaction).send({ content: 'Pick a subcommand.' })
  }

  // The full path, as Discord displays it; plain CommandType.SLASH and no builder
  @Command('settings notify email', CommandType.SLASH)
  async notifyEmail(interaction: ChatInputCommandInteraction, { enabled }: { enabled: boolean }) {
    await respond(interaction).send({ content: `Email notifications ${enabled ? 'on' : 'off'}` })
  }
}

Subcommands covers groups and the builder.

Buttons

A button's custom ID carries what the handler needs, read back as typed params:

controllers/button/profile.button.controller.ts
// customId `profile/123/800000001` gives ownerId '123' and uid '800000001'
@Command('profile/{ownerId}/{uid}', CommandType.BUTTON)
async showProfile(interaction: ButtonInteraction, { ownerId, uid }: { ownerId: string; uid: string }) {
  await respond(interaction).send({ content: `Profile ${uid}, opened by <@${ownerId}>` })
}

Buttons, selects and modals covers patterns and routing.

Select menus

A member picks values from a menu, and the handler gets them:

controllers/select-menu/poll.select-menu.controller.ts
@Controller()
export class PollSelectMenuController {
  // poll/{pollId} captures the poll; values holds the options the member chose
  @Command('poll/{pollId}', CommandType.SELECT_MENU)
  async vote(interaction: StringSelectMenuInteraction, { pollId, values }: { pollId: string; values: string[] }) {
    await respond(interaction).send({
      content: `Poll ${pollId}: you picked ${values.join(', ')}.`,
      flags: MessageFlags.Ephemeral,
    })
  }
}

Select menus covers the user, role, channel and mentionable menus too.

Modals

A form a member fills in and submits, routed by its custom ID:

controllers/modal-submit/feedback.modal.controller.ts
// The second argument holds the captured `ticketId` and the submitted `body` field
@Command('feedback/{ticketId}', CommandType.MODAL_SUBMIT)
async submit(interaction: ModalSubmitInteraction, { ticketId, body }: { ticketId: string; body: string }) {
  await respond(interaction).send({ content: `Ticket ${ticketId}: ${body}`, flags: MessageFlags.Ephemeral })
}

Modals covers opening one and reading its fields.

Context menus

A member right-clicks a user or a message and picks the bot's command:

controllers/context-menu/report.context-menu.controller.ts
// Right-click a member, then Apps › Report user
@Command('Report user', ReportUserBuilder)
async report(interaction: UserContextMenuCommandInteraction) {
  await respond(interaction).send({
    content: `Thanks, ${interaction.targetUser.username} was reported to the staff.`,
    flags: MessageFlags.Ephemeral,
  })
}

Context menus covers message context menus and the builder.

Autocomplete

The bot suggests values for an option as the member types it:

controllers/slash/search.slash.controller.ts
@Controller()
export class SearchSlashController {
  constructor(private readonly catalog: CatalogService) {}

  @Command('search', SearchCommandBuilder)
  async search(interaction: ChatInputCommandInteraction, { query }: { query: string }) {
    await respond(interaction).send({ content: `Results for ${query}` })
  }

  @Autocomplete('search', 'query')
  async completeQuery(interaction: AutocompleteInteraction) {
    const { value } = interaction.options.getFocused(true)
    // Discord shows at most 25 choices
    const matches = this.catalog.find(value).slice(0, 25)

    await interaction.respond(matches.map(name => ({ name, value: name })))
  }
}

Autocomplete covers where the suggestions come from and their limits.

Message commands

A member sends !roll 20 for initiative, and the pattern and its schema hand the handler its values, typed:

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

Message commands covers prefixes, flags, lists and aliases.

Reactions

A member reacts to a message with an emoji, and the bot acts on it:

controllers/reaction/star.reaction.controller.ts
import { type MessageReaction, type PartialMessageReaction } from 'discord.js'
import { Controller, ReactionHandler } from 'meocord/decorator'
import { ReactionHandlerAction } from 'meocord/enum'
import { type ReactionEvent } from 'meocord/interface'

@Controller()
export class StarReactionController {
  @ReactionHandler('⭐')
  async star(reaction: MessageReaction | PartialMessageReaction, { user, action }: ReactionEvent) {
    if (action !== ReactionHandlerAction.ADD || user.bot) return
    await reaction.message.reply(`${user.username} starred this.`)
  }
}

Reactions and other messages covers removals and partial messages.

Gateway events

Anything else Discord tells the bot, such as a member joining a server:

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

Gateway events covers every event and when a handler runs.

Next steps

  • Your first command: build one of these from scratch, and run it.
  • Example bots: whole bots that put these handlers together, with their tests.
  • How a call runs: what happens between the member's click and your handler.
  • Services: share state and work between handlers, injected where they're needed.