Skip to content
GitHub

Buttons, selects and modals

MeoCord 4.1 Ā· Handling interactions Ā· page 7 of 41 Ā· since 4.0.0

Route buttons, select menus and forms to handlers by their customId, with parts of it captured as params.

You'll learn

  • Route a component to a handler with a customId pattern
  • Build customIds from a route, so button and handler agree
  • Read a select menu's choices and a form's fields
  • Leave a click to a discord.js collector

Before this

Buttons, select menus and modals, the forms a command can open, don't have names the way commands do. Each carries a customId you choose when you send it, and MeoCord routes the interaction by matching that id against the patterns in your @Command decorators. Parts of the id can be captured, so one handler serves every ticket, poll or profile.

When to use it

Use a component whenever a member acts on a message the bot sent: a button to close a ticket, a menu to pick a role, a form to write a report. A handler with a pattern is the right choice when the click should work any time, even after the bot restarts, since the id alone says what to do.

For a short-lived choice that only matters for the next minute, such as a quick yes/no on one message, a discord.js collector can be simpler: see Collectors.

Example

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}>` })
}
Dispatches button profile/123/800000001

The pattern profile/{ownerId}/{uid} matches a customId such as profile/123/800000001. The two parameters are captured and arrive as the handler's second argument, as text; a parameter that names a type, such as {count:int}, arrives as its value. See Typed params.

How it works

  1. At startup, MeoCord collects every component pattern of every controller into one table, and ranks it: the most specific pattern first.
  2. When a component interaction arrives, its customId is matched against the patterns of its component type, in that order, so a button never reaches a select menu's handler. The first that matches wins.
  3. The handler runs through the pipeline with the interaction and the params: the captured values, plus a form's fields or a select menu's choices.

The component type comes from @Command's second argument, such as CommandType.BUTTON, and decides which discord.js interaction the handler receives:

CommandTypeThe handler receives
BUTTONButtonInteraction
SELECT_MENUStringSelectMenuInteraction
USER_SELECT_MENUUserSelectMenuInteraction
ROLE_SELECT_MENURoleSelectMenuInteraction
MENTIONABLE_SELECT_MENUMentionableSelectMenuInteraction
CHANNEL_SELECT_MENUChannelSelectMenuInteraction
MODAL_SUBMITModalSubmitInteraction

Patterns

/ separates a pattern's segments, and a parameter must fill a whole segment. profile/{uuid} and gi-profile/{ownerId} are fine, since the hyphen in the second is inside a literal segment. profile-{uuid} throws as soon as @Command decorates the method, naming it:

text
ProfileController.show: Invalid pattern "profile-{uuid}": {uuid} must occupy a whole segment, so it has to be
preceded and followed by "/" or by the ends of the pattern. Write "a/{uuid}" rather
than "a-{uuid}".

A parameter matches everything up to the next /, so an id you don't control, such as a uuid with hyphens, is captured whole.

Building customIds with a route

route() turns a pattern into a value @Command takes and that builds the ids it matches, so the button you send and the handler that receives it share one definition:

controllers/button/ticket.button.controller.ts
// One definition for the button you send and the handler that receives it
export const ticketAction = route('ticket/{id}/{action:close|reopen}')

@Controller()
export class TicketButtonController {
  @Command('ticket', CommandType.SLASH)
  async open(interaction: ChatInputCommandInteraction) {
    const close = new ButtonBuilder()
      .setCustomId(ticketAction.build({ id: 42, action: 'close' })) // 'ticket/42/close'
      .setLabel('Close')
      .setStyle(ButtonStyle.Danger)
    await respond(interaction).send({
      content: 'Ticket #42 opened.',
      components: [new ActionRowBuilder<ButtonBuilder>().addComponents(close)],
    })
  }

  @Command(ticketAction, CommandType.BUTTON)
  async act(interaction: ButtonInteraction, { id, action }: { id: string; action: 'close' | 'reopen' }) {
    const done = action === 'close' ? 'closed' : 'reopened'
    await respond(interaction).send({ content: `Ticket #${id}: ${done}.`, components: [] })
  }
}
Dispatches /ticket; button ticket/42/close

build takes exactly the pattern's params: a missing or unknown one fails to compile, and so does a button's or a select menu's handler whose params name something other than the route's params and, for a select menu, its choices. A / or % inside a value is encoded, and the handler receives it decoded, so a value never spills into the next segment. An empty value, or an id longer than Discord's 100 characters, throws.

Typed params

A parameter can name its type, {name:type}, and the handler receives the value rather than its text:

controllers/button/counter.button.controller.ts
// `counter/41` gives count 41, a number; `counter/lots` matches no route
export const counter = route('counter/{count:int}')

const counterButton = (count: number) =>
  new ActionRowBuilder<ButtonBuilder>().addComponents(
    new ButtonBuilder().setCustomId(counter.build({ count })).setLabel(`${count}`).setStyle(ButtonStyle.Primary),
  )

@Controller()
export class CounterButtonController {
  @Command(counter, CommandType.BUTTON)
  async count(interaction: ButtonInteraction, { count }: { count: number }) {
    await respond(interaction).send({ components: [counterButton(count + 1)] })
  }
}
Dispatches button counter/41
TypeThe handler receivesA segment such as
none, or stringthe textabc
int, numbera number42, -3; 2.5
boola booleantrue, off, yes
words, such as open|closedone of the wordsopen, as written
  • A segment of the wrong type matches no route, so the next pattern is tried: counter/lots reaches no handler here.
  • build takes a value of each type, and throws for one that wouldn't read back, such as 1.5 for an int.
  • The handler's params are checked when the code compiles, for a pattern written as a string as for a route: { count: string } for {count:int} is an error, and, for a button or a select menu, so is a name other than the route's params and a select menu's choices, such as { uid } for stats/{id}. A form's handler may name its fields.
  • A customId holds text the bot wrote, so there is no member or channel type, as a message command has. Capture the ID, {userId}, and fetch it in the handler; {target:member} stops the bot where it's declared.

Overlapping patterns

Patterns with different segment counts never compete. When two with the same count can both match an id, the one that spells out more literal text wins, whatever order they were declared in:

controllers/button/profile.button.controller.ts
// Both match `profile/summary/456`; the one with more literal text wins it
@Command('profile/summary/{uid}', CommandType.BUTTON)
async showSummary(interaction: ButtonInteraction, { uid }: { uid: string }) {
  await respond(interaction).send({ content: `Summary of ${uid}` })
}
Dispatches button profile/summary/456

Between equally literal patterns, the one with fewer parameters wins, then the one whose typed parameters take fewer values: words to choose from, then bool, int, number, and text last. So beside page/{name}, page/{n:int} takes page/5 and leaves page/last to the other, in whatever order they're declared.

Between two equally specific patterns, such as a/{x}/c and a/b/{y}, which both take a/b/c, the one whose controller is listed first in @MeoCord({ controllers }) runs, or, within one controller, the one declared first. In the next major version (5.0), the pattern that spells out the first segment where the two differ runs instead: a/b/{y} here.

MeoCord warns once at startup about every pair of patterns of one component type that can both take an id: the two profile patterns above, page/{name} and page/{n:int}, and a/{x}/c and a/b/{y}. For each pair it names the handler that runs and why, and where 5.0 would run the other one, what to do. The bot still starts, and the ranking decides which handler runs; MeoCordTestingModule.compile() gives the same warning, and findRouteConflicts lists the pairs. Patterns with different literals in the same place, such as profile/view/{uid} and profile/summary/{uid}, never overlap.

Two handlers whose patterns match exactly the same ids, such as profile/{uid} and profile/{id}, stop the bot at startup, naming both, since only one of them could ever run. meocord register refuses them too, before it sends any command, as do the shard manager, before it spawns a shard, and MeoCordTestingModule.compile().

Select menus

A select menu's handler receives what the member chose, beside the captured values:

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,
    })
  }
}
Dispatches select poll/lunch pizza,soup
Select menuSecond argument, beside the captured values
Stringvalues: the chosen options' values
Uservalues, the chosen ids; users, the Users; members, those in the server
Rolevalues; roles, the Roles
Channelvalues; channels, the channels
Mentionablevalues; users, members and roles, as they were chosen

Each is an array: values a string[], users a User[], and members, roles and channels as discord.js resolves them. The handler's declaration is checked when the code compiles, so values: number or users: string is an error, while a type a choice can hold, such as members: GuildMember[] or readonly Role[], compiles. A captured param of the same name, as in pick/{values}, takes the choice's place.

A user, role, mentionable or channel select is its own command type because Discord sends it with different resolved data. Declaring SELECT_MENU for a user select is a type error rather than a silent mismatch:

controllers/select-menu/assign.select-menu.controller.ts
@Command('assign/{taskId}', CommandType.USER_SELECT_MENU)
async assign(interaction: UserSelectMenuInteraction, { taskId }: { taskId: string }) {
  const names = interaction.users.map(user => user.username).join(', ')
  await respond(interaction).send({ content: `Task ${taskId} is assigned to ${names}.` })
}
Dispatches userselect assign/7 13,14

Modals

A form's handler receives its submitted fields, keyed by their customId, beside the captured values:

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 })
}
Dispatches modal feedback/42 body='The bot is fast.'

A command opens the form with respond(interaction).modal(...), and its customId routes the submission here. A file upload field arrives as an array of the uploaded Attachments. When a field or a choice shares a name with a captured value, the captured value wins, and development logs a warning.

Collectors

A discord.js collector answers clicks on one message for a while, without a route. The click has no @Command pattern, and the collector's callback answers it:

controllers/button/poll.collector.controller.ts
@Controller()
export class QuickPollController {
  @Command('quickpoll', CommandType.SLASH)
  async start(interaction: ChatInputCommandInteraction) {
    const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
      new ButtonBuilder().setCustomId('quickpoll-yes').setLabel('Yes').setStyle(ButtonStyle.Success),
      new ButtonBuilder().setCustomId('quickpoll-no').setLabel('No').setStyle(ButtonStyle.Secondary),
    )
    const message = await respond(interaction).send({ content: 'Ship it?', components: [row] })

    // No @Command routes these clicks: the collector answers them, for one minute
    message?.createMessageComponentCollector({ componentType: ComponentType.Button, time: 60_000 }).on(
      'collect',
      bindTheme(async (click: ButtonInteraction) => {
        await respond(click).send({
          embeds: [{ description: `${click.user.username} voted ${click.customId.slice(10)}` }],
        })
      }),
    )
  }
}
  • MeoCord leaves the click to the collector. A button, select menu or modal submission no route takes, while anything else listens for the client's interactions, gets 1.5 seconds before MeoCord answers "Command not found!". If the collector answered by then, MeoCord says nothing, and the observers aren't told about it.
  • An app's own @On('interactionCreate') counts as a listener too, so in such an app a genuinely dead button is answered after 1.5 seconds rather than at once.
  • Wrap the callback in bindTheme to answer in the theme of the handler that started the collector. Without it, the callback runs in the client's event, where the app's theme applies, not the handler's @UseTheme.

awaitMessageComponent() and awaitModalSubmit() work the same way, and keep the handler's theme across their await.

When nothing matches

An interaction no pattern takes raises CommandNotFoundError. The built-in fallback answers it with "Command not found!" and logs a warning naming the customId. When a button seems dead, that log line is the first place to look.

To check which handler an id reaches without running it, use resolveRoute:

controllers/button/profile.button.controller.spec.ts
import { ButtonInteraction } from 'discord.js'
import { MeoCord } from 'meocord/decorator'
import { CommandType } from 'meocord/enum'
import {
  createMockInteraction,
  findRouteConflicts,
  getResponse,
  MeoCordTestingModule,
  resolveRoute,
} from 'meocord/testing'
import { describe, expect, it } from 'vitest'
import { ProfileButtonController } from '@src/controllers/button/profile.button.controller'

@MeoCord({ controllers: [ProfileButtonController], clientOptions: { intents: [] } })
class App {}

describe('ProfileButtonController', () => {
  const module = MeoCordTestingModule.create({ controllers: [ProfileButtonController] }).compile()

  it('receives the values its pattern captures', async () => {
    const interaction = createMockInteraction(ButtonInteraction, { customId: 'profile/123/800000001' })

    await module.invoke(ProfileButtonController, 'showProfile', interaction)

    expect(getResponse(interaction).calls[0].payload).toMatchObject({ content: 'Profile 800000001, opened by <@123>' })
  })

  it('routes an id both patterns match to the more literal one', () => {
    const route = (customId: string) => resolveRoute(App, { type: CommandType.BUTTON, customId })

    expect(route('profile/summary/456')).toMatchObject({ method: 'showSummary', params: { uid: '456' } })
    expect(route('profile/123/456')).toMatchObject({ method: 'showProfile', params: { ownerId: '123', uid: '456' } })
  })

  it('lists the pair, which MeoCord warns about at startup', () => {
    expect(findRouteConflicts(App)).toEqual([
      { type: CommandType.BUTTON, patterns: ['profile/summary/{uid}', 'profile/{ownerId}/{uid}'] },
    ])
  })
})

Gotchas

  • A pattern that shares a segment with a parameter throws. Write ticket/{id}, not ticket-{id}.
  • An untyped parameter is text. { id: number } for {id} converts nothing: write {id:int}, or use Validation to convert it.
  • A customId over 100 characters is refused by Discord. Keep ids short: capture ids, not text.
  • A collector on an unrouted id delays dead buttons elsewhere. While any collector is listening, a genuinely dead button in the app is answered after 1.5 seconds. Give long-lived components a route.

Build it

Submitted feedback needs a place to live. A service keeps it in memory, and settings say where the review post goes, read from FEEDBACK_CHANNEL_ID, which you add to .env. staffRoleId, who reviews it, is for Guards, later:

tutorial/feedback.service.ts
export interface Feedback {
  id: string
  authorId: string
  // The author's language, so the verdict reaches them in it
  locale: Locale
  about: string
  details: string
  status: 'open' | 'approved' | 'rejected'
}

// Keeps feedback in memory, so a restart forgets it; the database recipe shows the lasting version
@Service()
export class FeedbackService {
  private readonly items = new Map<string, Feedback>()
  private next = 1

  add(entry: Pick<Feedback, 'authorId' | 'locale' | 'about' | 'details'>): Feedback {
    const feedback: Feedback = { ...entry, id: String(this.next++), status: 'open' }
    this.items.set(feedback.id, feedback)
    return feedback
  }

  get(id: string): Feedback {
    const feedback = this.items.get(id)
    if (!feedback) throw new FeedbackNotFoundError(id)
    return feedback
  }

  decide(id: string, status: 'approved' | 'rejected'): Feedback {
    const feedback = this.get(id)
    feedback.status = status
    return feedback
  }
}
tutorial/feedback.settings.ts
// Where feedback goes and who reviews it, read from the environment; tests provide their own
@Service()
export class FeedbackSettings {
  readonly reviewChannelId = process.env.FEEDBACK_CHANNEL_ID ?? ''
  readonly staffRoleId = process.env.STAFF_ROLE_ID ?? ''
}

get throws a FeedbackNotFoundError for an id it doesn't know:

tutorial/feedback.errors.ts
/** Thrown for a feedback id the service does not hold, such as a button left from before a restart. */
export class FeedbackNotFoundError extends Error {
  constructor(readonly id: string) {
    super(`No feedback #${id}`)
  }
}

The feedback controller receives both through its constructor, and MeoCord creates them for it; Services explains how:

tutorial/feedback.controller.ts
constructor(
  private readonly feedback: FeedbackService,
  private readonly settings: FeedbackSettings,
) {}

The /feedback form's customId is feedback/submit. Handle its submission: the member's fields arrive as params, and the bot posts them for the staff with two review buttons whose ids carry the feedback's id:

tutorial/feedback.controller.ts
// The form's fields arrive as the second argument, named by their custom IDs
@Command('feedback/submit', CommandType.MODAL_SUBMIT)
async submit(interaction: ModalSubmitInteraction, { about, details }: { about: string; details: string }) {
  const feedback = this.feedback.add({ authorId: interaction.user.id, locale: interaction.locale, about, details })

  const button = (verdict: 'approve' | 'reject', style: ButtonStyle) =>
    new ButtonBuilder()
      .setCustomId(`feedback/${feedback.id}/${verdict}`)
      .setLabel(verdict === 'approve' ? 'Approve' : 'Reject')
      .setStyle(style)
  const channel = await interaction.client.channels.fetch(this.settings.reviewChannelId)
  if (!channel?.isSendable()) throw new Error('FEEDBACK_CHANNEL_ID is not a channel the bot can post in.')
  await channel.send({
    embeds: [
      new EmbedBuilder()
        .setTitle(`Feedback #${feedback.id} from ${interaction.user.username}`)
        .setDescription(`**${about}**\n${details}`),
    ],
    components: [
      new ActionRowBuilder<ButtonBuilder>().addComponents(
        button('approve', ButtonStyle.Success),
        button('reject', ButtonStyle.Danger),
      ),
    ],
  })

  await respond(interaction).send({ content: 'Thanks! The staff will read it soon.', flags: MessageFlags.Ephemeral })
}

A message the bot posts with channel.send isn't one of MeoCord's answers, so it keeps Discord's default look; Theming colours it. Now handle the review buttons, feedback/{id}/approve and feedback/{id}/reject:

tutorial/review.controller.ts
@Controller()
export class ReviewController {
  constructor(private readonly feedback: FeedbackService) {}

  @Command('feedback/{id}/approve', CommandType.BUTTON)
  @Defer()
  async approve(interaction: ButtonInteraction, { id }: { id: string }) {
    await this.decide(interaction, id, 'approved')
  }

  @Command('feedback/{id}/reject', CommandType.BUTTON)
  @Defer()
  async reject(interaction: ButtonInteraction, { id }: { id: string }) {
    await this.decide(interaction, id, 'rejected')
  }

  private async decide(interaction: ButtonInteraction, id: string, status: 'approved' | 'rejected') {
    const feedback = this.feedback.decide(id, status)

    // The review post keeps its text, gains the verdict, and loses its buttons
    const [post] = interaction.message.embeds
    const verdict = (post ? EmbedBuilder.from(post) : new EmbedBuilder()).setFooter({
      text: `${status === 'approved' ? 'Approved' : 'Rejected'} by ${interaction.user.username}.`,
    })
    await respond(interaction).send({ embeds: [verdict], components: [] })

    // The author hears back; closed DMs are not the reviewer's problem
    const text =
      status === 'approved'
        ? `Your feedback ā€œ${feedback.about}ā€ was approved. Thank you!`
        : `Your feedback ā€œ${feedback.about}ā€ was not taken up this time.`
    await interaction.client.users.send(feedback.authorId, { content: text }).catch(() => undefined)
  }
}

@Defer() acknowledges each click before the handler runs, since saving the verdict, editing the post and sending a DM can take longer than the three seconds Discord allows; @Defer covers it. The verdict goes through respond(), so its embed takes the theme's primary colour. Add ReviewController to the app:

tutorial/app.ts
@MeoCord({
  controllers: [
    // The slash command and its form, and the review buttons
    FeedbackController,
    ReviewController,
  ],
  clientOptions: {
    intents: [
      // Interactions arrive with Guilds alone, and DMs are sent, not read
      GatewayIntentBits.Guilds,
    ],
  },
})
export default class App {}

Run /feedback, submit the form, and click Approve in the review channel: the post gains the verdict, and the author gets a DM.

Next steps