Skip to main content
A message can carry buttons. When someone clicks one, Discord sends your bot an interaction, like for a command, and your bot answers. This page adds a “Roll again” button to /dice, and explains how a click finds its way back to your code.

A button is a file

A button is declared with button() and exported. It says what the button looks like and what to do when it is clicked. Create src/components/reroll.ts (any file of src/ would do):
src/components/reroll.ts
Then put it in the answer of /dice:
src/commands/dice.ts
Use /dice sides:20: the answer has a button. Click it: the message changes to a new roll, with the button still there.

How a click finds your file

When Discord shows a button, it has to remember which one it is, so that a click can be sent back to the right bot with the right meaning. Discord does that with a short text attached to the button, which the bot chooses, and gets back on click. In ChapterJS, that text is the name of the export: reroll (a default export takes the name of its file). You never write it, you never see it, and it can’t collide with another button: two buttons with the same name are reported, and the second one is left out. When the click comes back, the framework reads the name, finds the button, and runs its run. This is the same rule as everywhere: what a file exports says what it is, and where the file sits changes nothing.

Data: what a button carries

The button needs to know how many sides the die has, and nothing is stored anywhere between the message and the click. So the button carries it: data: { sides: 'number' } declares a value, reroll({ sides }) gives it when the button is put in a message, and run receives it as data.sides, typed as a number. Where does it live? In the button itself, next to its name, inside the message on Discord. That makes it:
  • Stateless. Restart the bot, and a click on a week-old message still carries sides. Nothing to store, nothing to clean.
  • Limited. Discord gives 100 characters to the name and the data together. Carry ids, not names; a number, not a whole object. Carrying too much is an error where the button is made, with how much room there is.
  • Typed. data has three kinds: 'string', 'number' and 'boolean'. reroll({ sides: 'six' }) is underlined in the editor.
A button without data is put in a message as is, without calling it: components: [close].

Answering a click

The interaction of a button has everything a command’s has (reply(), defer(), edit(), followUp(), delete()), plus three things that only make sense for a click: update() is what you want most of the time: the person clicked on a message, and expects that message to change, not a new one to appear. interaction.message is that message, as a Message. Like a command, a click must be answered within 3 seconds, and the framework defers for you after 2, in a way the person does not see. A button whose run finishes without answering is a warning in your terminal, since the person reads “This interaction failed”.

Who may click

By default, everyone who sees the message can click. For a button that belongs to the person who used the command, who: 'author': anyone else reads “Only the person who used the command can use this button.” in private, and run never starts. Here is a “Close” button that removes the message:
src/components/close.ts
style is 'primary' (blue), 'secondary' (grey), 'success' (green) or 'danger' (red). The declaration gives the default look; where you put the button in a message, a second argument changes it for that message: reroll({ sides }, { disabled: true }). A button that only opens a link runs nothing in your bot, so it has no file: linkButton({ label: 'Docs', url: 'https://...' }), written where the message is.

Rows

Discord puts buttons in rows of up to 5. You never make rows yourself: buttons that follow each other in components are put in rows for you, 5 per row, and a menu takes a whole row. To choose the rows yourself, row([...]) exists.

When a file changes

A button lives on in the messages it was sent in, long after you changed your code. The framework handles each case without ever crashing:

Points of attention

  • update() once, like reply(). After it, the interaction is answered; use interaction.message.edit() for a second change.
  • Carry ids. data: { userId: 'string' }, then guild.fetchMember(data.userId) in run, rather than carrying a name that can change and takes room.
  • Hot reload needs nothing. The command file imports the button file; saving either reloads both together.
  • Buttons have a where too, like commands, which decides what run receives about the place.

Recap

  • A button is button({ ... }), exported; the name of its export is what a click sends back.
  • data is what the button carries in the message, typed, surviving restarts.
  • update() changes the message the button is on; who: 'author' keeps it for the person.
  • Old messages are answered plainly when the file changed.

Select menus

Let people pick among options, users, roles or channels.

Guide: Buttons

The same, in short.