Buttons, selects and modals
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
// 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}>` })
}button profile/175928847299117063/800000001Open in playgroundThe 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
- At startup, MeoCord collects every component pattern of every controller into one table, and ranks it: the most specific pattern first.
- When a component interaction arrives, its
customIdis 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. - 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:
CommandType | The handler receives |
|---|---|
BUTTON | ButtonInteraction |
SELECT_MENU | StringSelectMenuInteraction |
USER_SELECT_MENU | UserSelectMenuInteraction |
ROLE_SELECT_MENU | RoleSelectMenuInteraction |
MENTIONABLE_SELECT_MENU | MentionableSelectMenuInteraction |
CHANNEL_SELECT_MENU | ChannelSelectMenuInteraction |
MODAL_SUBMIT | ModalSubmitInteraction |
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:
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:
// 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: [] })
}
}/ticket; button ticket/42/closeOpen in playgroundbuild 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:
// `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)] })
}
}button counter/41Open in playground| Type | The handler receives | A segment such as |
|---|---|---|
none, or string | the text | abc |
int, number | a number | 42, -3; 2.5 |
bool | a boolean | true, off, yes |
snowflake | the text | 1234567890123456789 |
uuid | the text, as written | 0f8fad5b-d9cb-469f-a165-70867728950e |
words, such as open|closed | one of the words | open, as written |
- A segment of the wrong type matches no route, so the next pattern is tried:
counter/lotsreaches no handler here. - A
snowflakeis 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 anintwhile a number holds them exactly. Use it for a Discord ID rather than{x:number}, which rounds one:12345678901234567arrives as12345678901234568. Auuidis the 8-4-4-4-12 hex form, in either case. buildtakes a value of each type, and throws for one that wouldn't read back, such as1.5for anint. It takes asnowflakeor auuidonly as a string, and throws aTypeErrorfor anything else, such as a number, which may have lost an ID's digits already: pass the ID as text, such asuser.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 }forstats/{id}. A form's handler may name its fields. - A customId holds text the bot wrote, so there is no
memberorchanneltype, 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:
// 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}>` })
}button profile/175928847299117063/summaryOpen in playgroundSo 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:
@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 poll/lunch pizza,soupOpen in playground| Select menu | Second argument, beside the captured values |
|---|---|
| String | values: the chosen options' values |
| User | values, the chosen ids; users, the Users; members, those in the server |
| Role | values; roles, the Roles |
| Channel | values; channels, the channels |
| Mentionable | values; 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:
@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}.` })
}userselect assign/7 13,14Open in playgroundModals
A form's handler receives its submitted fields, keyed by their customId, beside the captured values:
// 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 })
}modal feedback/42 body='The bot is fast.'Open in playgroundA 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:
@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
bindThemeto 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:
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}, notticket-{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:
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
}
}// 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:
/** 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:
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:
// 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:
@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:
@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
- Answering with respond(): updates, follow-ups and forms.
- @Defer: locking a component's buttons while a slow handler runs.
- Autocomplete: suggestions while a member types.
- Validation: checking and converting captured values.