Skip to content
GitHub

Context menus

MeoCord 4.1 · Handling interactions · page 9 of 41 · since 4.0.0

Add a command to the Apps menu of a user or a message, and handle it like a slash command.

You'll learn

  • Describe a user or message command with a builder
  • Read the user or message the menu was opened on
  • Test a context menu command with its target

Before this

A context menu command appears when a member right-clicks a user or a message and opens Apps: "Report user", "Bookmark", "Translate". It takes no options. What it acts on is the user or message the menu was opened on, which the interaction carries as its target.

When to use it

Use a context menu when a command's subject is a particular user or message, and picking it by right-click is more natural than pasting an id: reporting a message, bookmarking it, looking up a member's profile.

When the member has to type something, such as a reason or an amount, a slash command with options fits better, or a context menu that opens a form with respond(interaction).modal(...).

Example

A user command's builder, with its type set to User:

controllers/context-menu/builders/report.builder.ts
// A context menu's name is what the menu shows, capitals and spaces included
@CommandBuilder(CommandType.CONTEXT_MENU)
export class ReportUserBuilder {
  build(commandName: string) {
    return new ContextMenuCommandBuilder().setName(commandName).setType(ApplicationCommandType.User)
  }
}
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,
  })
}

"Report user" appears in a member's Apps menu. The handler reads the member it was opened on from interaction.targetUser, and thanks the reporter privately.

How it works

A context menu command is registered and routed like a slash command:

  • The builder returns a ContextMenuCommandBuilder, with its type User or Message, under @CommandBuilder(CommandType.CONTEXT_MENU). Registering commands applies as it does to slash commands.
  • The name is what the menu shows. Unlike a slash command's, it may hold capitals and spaces, and @Command takes it as it is: @Command('Report user', ReportUserBuilder). A user command and a message command may share a name, since Discord keeps them apart by type, and each reaches its own handler.
  • The handler receives a UserContextMenuCommandInteraction or a MessageContextMenuCommandInteraction, and no options. It runs through the full pipeline and answers with respond().

Message commands

A message command's builder sets the type Message, and its handler reads interaction.targetMessage:

controllers/context-menu/report.context-menu.controller.ts
// Right-click a message, then Apps › Bookmark
@Command('Bookmark', BookmarkBuilder)
async bookmark(interaction: MessageContextMenuCommandInteraction) {
  await interaction.user.send({ content: `Bookmarked: ${interaction.targetMessage.url}` })
  await respond(interaction).send({ content: 'Sent to your DMs.', flags: MessageFlags.Ephemeral })
}

The handler's type

A handler declares the kind of interaction its builder registers: UserContextMenuCommandInteraction for a builder that sets ApplicationCommandType.User, and MessageContextMenuCommandInteraction for Message, as the examples do. MeoCord reads the kind from the builder's setType(), so a handler that declares the other kind doesn't compile, however its interaction is imported. The error is on its @Command: "Unable to resolve signature of method decorator when called as an expression".

A builder whose kind the compiler can't tell, one whose build() declares its return type as ContextMenuCommandBuilder or that picks the kind at runtime, lets its handler declare either. MeoCord then checks the kind as the controller loads: a handler of the other kind stops the bot, naming the handler and the builder. That check reads the decorator metadata the compiler emits, so it needs the interaction class imported as a value, as the generated controller does; import { type … } erases it.

A handler that serves both kinds takes their union, and narrows it with isUserContextMenuCommand() or isMessageContextMenuCommand(). meocord g co context-menu Report generates a user command with its handler typed to match, and --message a message one.

Testing

Give the mock its target in the overrides, since discord.js makes targetUser and targetMessage read-only. For a user command, a targetId is enough: targetUser is the client's cached user with that id, or one made, and in a server targetMember is their member:

controllers/context-menu/report.context-menu.controller.spec.ts
describe('ReportContextMenuController', () => {
  const module = MeoCordTestingModule.create({ controllers: [ReportContextMenuController] }).compile()

  it('reports the member the menu was opened on', async () => {
    const target = createMockUser()
    Object.assign(target, { username: 'mika' })
    const interaction = createMockInteraction(UserContextMenuCommandInteraction, {
      commandName: 'Report user',
      targetUser: target,
    })

    await module.invoke(ReportContextMenuController, 'report', interaction)

    expect(getResponse(interaction).calls[0].payload).toMatchObject({
      content: 'Thanks, mika was reported to the staff.',
    })
  })

  it('DMs a link to the message the menu was opened on', async () => {
    const targetMessage = createMockMessage({ id: '1300000000000000000' })
    Object.assign(targetMessage, { url: 'https://discord.com/channels/1/2/1300000000000000000' })
    const interaction = createMockInteraction(MessageContextMenuCommandInteraction, {
      commandName: 'Bookmark',
      targetMessage,
    })

    await module.invoke(ReportContextMenuController, 'bookmark', interaction)

    expect(interaction.user.send).toHaveBeenCalledWith({ content: `Bookmarked: ${targetMessage.url}` })
  })
})

Gotchas

  • A menu the member can't find. Context menu commands appear under Apps when right-clicking, not in the / list. Say so in your bot's help.
  • A handler typed for the other kind. A handler declaring MessageContextMenuCommandInteraction on a builder that sets User doesn't compile. Match the handler to the builder's setType().
  • A name mismatch. The name in @Command is the one the builder receives. Build with setName(commandName), so the menu and the handler can't disagree.
  • Replying in public by accident. A report or a bookmark is usually for the member alone; send it with MessageFlags.Ephemeral.

Next steps