Skip to content
GitHub

Buttons, selects and modals

MeoCord 4.2 Ā· 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/175928847299117063/800000001` gives ownerId '175928847299117063' and uid '800000001'
@Command('profile/{ownerId:snowflake}/{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/175928847299117063/800000001

The pattern profile/{ownerId:snowflake}/{uid} matches a customId such as profile/175928847299117063/800000001. The two parameters are captured and arrive as the handler's second argument. :snowflake takes only a Discord ID, which arrives as text, as an untyped parameter does; a parameter that names another 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
snowflakethe text1234567890123456789
uuidthe text, as written0f8fad5b-d9cb-469f-a165-70867728950e
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.
  • A snowflake is a Discord ID: 17 to 20 digits, with no leading zero, up to the largest 64-bit value. Its top 42 bits count milliseconds since 2015-01-01, Discord's epoch, so every ID made from 2015-01-28 on has at least 17 digits. It stays text, as a number can't hold that many digits exactly; shorter digits are an int while a number holds them exactly. Use it for a Discord ID rather than {x:number}, which rounds one: 12345678901234567 arrives as 12345678901234568. A uuid is the 8-4-4-4-12 hex form, in either case.
  • build takes a value of each type, and throws for one that wouldn't read back, such as 1.5 for an int. It takes a snowflake or a uuid only as a string, and throws a TypeError for anything else, such as a number, which may have lost an ID's digits already: pass the ID as text, such as user.id.
  • 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, they're compared segment by segment, left to right: at the first segment one spells out as literal text and the other leaves to a parameter, the literal one wins, whatever order they were declared in:

controllers/button/profile.button.controller.ts
// Both match `profile/175928847299117063/summary`; `summary`, literal where the other has a param, wins it
@Command('profile/{ownerId:snowflake}/summary', CommandType.BUTTON)
async showSummary(interaction: ButtonInteraction, { ownerId }: { ownerId: string }) {
  await respond(interaction).send({ content: `Summary for <@${ownerId}>` })
}
Dispatches button profile/175928847299117063/summary

So profile/me/{section} takes profile/me/edit from profile/{userId}/edit, which still takes profile/123/edit, and a/{x} takes a/abcd from {x}/abcd, though the second spells out more text.

When that leaves two tied, with literals and parameters in the same places, the one whose typed parameter takes fewer values at the first place they differ wins: words to choose from, then bool, uuid, snowflake, 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.

Only two patterns still tied, with the same literals and equally narrow parameters in every place, can collide, such as t/{a:on|off} and t/{b:off|no}, which both take t/off. The one whose controller is listed first in @MeoCord({ controllers }) runs, or, within one controller, the one declared first. MeoCord warns once at startup about each such pair, naming an id both take and the handler that runs; the next major version (5.0) refuses to start with one. MeoCordTestingModule.compile() gives the same warning, and findRouteConflicts lists the pairs, each with the pattern that runs. 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. Its alsoMatches lists the other patterns that take the id and lost to it:

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/175928847299117063/800000001' })

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

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

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

    const summary = 'profile/175928847299117063/summary'
    expect(route(summary)).toMatchObject({ method: 'showSummary', params: { ownerId: '175928847299117063' } })
    // The pattern that also takes the id and lost to it
    expect(route(summary)?.alsoMatches).toEqual(['profile/{ownerId:snowflake}/{uid}'])
    expect(route('profile/175928847299117063/456')).toMatchObject({
      method: 'showProfile',
      params: { ownerId: '175928847299117063', uid: '456' },
    })
  })

  it('lists no pair, as the ranking tells every id apart', () => {
    expect(findRouteConflicts(App)).toEqual([])
  })
})

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