Answering with respond()
Answer an interaction with one call that picks the right Discord method for where the answer stands.
You'll learn
- Send, edit and follow up without choosing Discord's call yourself
- Buy time with acknowledge() and show a modal
- Make a message private, and keep it private
Before this
Discord has a different call for each state an interaction's answer can be in: reply for a first answer,
deferReply and deferUpdate to buy time, editReply once deferred, update for a component's message, and
followUp for another message. Pick the wrong one and Discord rejects it.
respond(interaction) keeps track of where the answer stands and makes the right call, so a
handler says what to send, not how.
When to use it
Use respond() for every answer a handler sends. It works for commands, components and modals alike, and it's what
MeoCord's own views, @Defer and the error fallback use, so your answers and MeoCord's never
collide.
You can still call interaction.reply() directly. respond() reads the interaction's state on every call, so it
notices, but an answer sent around it doesn't get the theme's colour.
Example
@Command('profile', CommandType.SLASH)
async profile(interaction: ChatInputCommandInteraction) {
// "thinking…" while the profile loads
await respond(interaction).acknowledge()
const card = new EmbedBuilder().setTitle(interaction.user.username)
// Deferred, so this edits the reply
await respond(interaction).send({ embeds: [card] })
// Private, and only this message: flags never carry over to the next call
await respond(interaction).followUp({ content: 'Tip: /profile works in DMs too.', flags: MessageFlags.Ephemeral })
}/profileOpen in playgroundThe handler acknowledges first, so the user sees "thinking…" while the profile loads. send() then edits that
deferred reply, since the interaction is no longer unanswered, and followUp() adds a private tip.
A test sees the calls it made:
import { ChatInputCommandInteraction, User } from 'discord.js'
import { createMockInteraction, getResponse, MeoCordTestingModule } from 'meocord/testing'
import { describe, expect, it } from 'vitest'
import { ProfileSlashController } from '@src/controllers/slash/profile.slash.controller'
describe('ProfileSlashController', () => {
const module = MeoCordTestingModule.create({ controllers: [ProfileSlashController] }).compile()
it('defers, edits the deferred reply, then follows up privately', async () => {
const interaction = createMockInteraction(ChatInputCommandInteraction, {
user: createMockInteraction(User, { username: 'ada' }),
})
await module.invoke(ProfileSlashController, 'profile', interaction)
expect(getResponse(interaction).calls.map(call => call.method)).toEqual(['deferReply', 'editReply', 'followUp'])
})
})A call Discord refused stays in calls, in the order it was made, with the error it rejected with as error, and
doesn't count towards sent. A reply refused with 10062, once the three seconds have passed, reports sent: false,
so a test of what the user sees after a slow handler fails as the user would.
How it works
respond(interaction) returns the same object for the whole life of one interaction, so a helper, a guard and the
handler all see one answer. Interceptors and exception filters reach it as context.response.
Before each call, it reads where the answer stands from the interaction itself, as state: 'unanswered',
'deferred' or 'replied'. Answers made directly through discord.js, or by a collector, count too.
Answers asked for together go out one after another, in the order they were made, each from where the last left the
interaction. Two send() calls at once reply and then edit, rather than both replying, and so do a send() in flight
and the error the fallback answers with. When Discord refuses an answer, the state stays right:
- An acknowledgement that fails leaves the interaction unanswered, so the next
send()replies rather than throwing that failure again. - A reply or an update refused with 40060, because the interaction was answered elsewhere, still throws, and the next
send()edits that answer.
The calls
| Call | What it does |
|---|---|
acknowledge({ ephemeral }) | Buys time: a deferred reply for a command, shown as "thinking…", or an invisible deferred update for a component. A second call does nothing. |
send(payload, options?) | Replies to an unanswered command, updates an unanswered component's message, and edits the answer once it's deferred or sent. A second send() edits again. |
edit(payload, options?) | Edits the answer, as send() does once answered. |
followUp(payload, options?) | Another message after the answer. |
delete() | Deletes the answer. |
modal(modal) | Shows a modal. It must be the first response, so this throws once the interaction is acknowledged, or while another answer is in flight. |
error(error, { message, visibility }) | Shows an error in the presenter's style, and never throws. 'private' shows it only to the user who made the call. |
options is { fill?: boolean }. An embed with no colour, or a Components V2 container with no accent, takes the
theme's primary colour; { fill: false } sends that one message as written.
Private messages
A private message takes flags: MessageFlags.Ephemeral. Each call takes only the flags Discord accepts for it,
worked out afresh, so a private follow-up never makes the next message private. send() and followUp() payloads
are typed, so a flag no answer takes doesn't compile; one the call made can't take, such as Ephemeral on an edit, is
dropped with a warning in development.
While a command's reply is deferred and nothing has been sent, Discord turns a follow-up into that reply and ignores
its flags, so followUp() sends it as the edit. A private follow-up on a public deferral is the exception: the
deferral is deleted and the message is sent privately, rather than made public.
Components V2 and attachments
Once a message uses Components V2, its edits keep the flag, and content and embeds are dropped from them. When an
edit sends an embed again whose image is one of the message's own attachments, the image's URL is pointed at
attachment://, so the image survives the edit. The loading and error views a presenter draws
can carry files of their own; one added to a message by an edit keeps the message's attachments.
Gotchas
- A handler that never answers leaves the user with "The application did not respond". In development, MeoCord
warns once for each handler that ends without answering, or defers and never follows up, and names it, or the
interceptor that returned before the handler ran or finished, the outermost when several did. Turn the warning on
or off with
@MeoCord({ warnUnanswered }). modal()has to come first. Call it before anything acknowledges the interaction, and leave@Deferoff a handler that shows a modal, or use@Defer({ mode: 'auto' })and show the modal before it acknowledges.- A modal has no message to edit. After
modal(),send(),edit()anddelete()throw, saying so. Answer in the handler of the modal's submit, which gets an interaction of its own. ephemeral: trueis deprecated in discord.js. It's still read as the private flag, but writeflags: MessageFlags.Ephemeralin new code.
Next steps
- @Defer: acknowledge before the guards run, and lock a component's message while the handler works.
- Presenters: style the loading and error views
respond()shows for you. - Theming: the colour
respond()fills in, and how to change it.