Replying
Answer an interaction from its handler with reply, defer, edit, and followUp. Covers why the answer goes through the handler, a reply as a list of components, keeping it to one person, and the message you get back.
Discord gives your bot three seconds to answer an interaction. seedcord puts the methods for answering on the handler. this.reply() sends the first response. this.defer() shows a thinking placeholder while you work, then this.edit() fills it in. this.followUp() sends another message after either one.
import { SlashHandler, SlashRoute } from '@seedcord/gateway';
@SlashRoute('ban')
export class Ban extends SlashHandler<'ban'> {
public async execute(): Promise<void> {
const target = this.options.getUser('target');
await this.reply(`Banned ${target.username}.`);
}
}Passing a string is the short form. seedcord wraps it in one text component.
Why answer through the handler?
On gateway, this.event is the discord.js interaction, so this.event.reply() sends a message too. On http it is Discord's raw payload, and you answer it through this.api. The handler's own methods take the same names on both transports, and each one records what it sent.
That record is the interaction's ack state. this.reply() in the handler above moves it to replied, and every method checks it before calling Discord. A second reply() throws at that line.
reply() was called when this interaction was already replied to.
Use followUp() for a new message or edit() to rewrite the reply. (route slash:ban)discord.js checks too, and its error reads "The reply to this interaction has already been sent or deferred." seedcord's says which method works from there and which route threw. Its cause carries a stack pointing at the line that answered first. On http, this.api doesn't check at all, so a second callback comes back from Discord as an API error. When a reply throws covers every case.
The state matters most when your handler refuses. You refuse by throwing a Notice. seedcord then sends its card through the same handler with send(), which reads the state to pick a method. If nothing went out yet, the card is the reply. After defer() it fills the thinking placeholder, and after a reply it arrives as a follow-up. You write the same throw from any of those points.
That holds while every answer goes through the handler. this.event.reply() reaches Discord and leaves the state at unacked. A callback on this.api.interactions does the same on http. seedcord then sends a Notice thrown after it as a first reply, which Discord rejects because the interaction was already acknowledged. seedcord drops that rejection with a debug line. The person who ran the command sees your message and never sees the refusal.
A reply is a list of components
A string gives you one block of text. A heading, a card, or a row of buttons goes in components, one entry per top-level component.
class class BanCardBanCard extends class BuilderComponent<BuilderKey extends BuilderType>BuilderComponent<'container'> {
constructor(name: stringname: string) {
super('container');
this.BaseComponent<ContainerBuilder>.instance: ContainerBuilderinstance.ContainerBuilder.addTextDisplayComponents(...components: RestOrArray<APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder)>): ContainerBuilderaddTextDisplayComponents((text: TextDisplayBuildertext) =>
text: TextDisplayBuildertext.TextDisplayBuilder.setContent(content: string): TextDisplayBuildersetContent(`Banned ${name: stringname}.`)
);
}
}
@SlashRoute<"ban">(...routes: "ban"[]): <TCtor>(constructor: AssertSlashRoute<"ban", TCtor>) => voidSlashRoute('ban')
export class class BanBan extends class SlashHandler<
Route extends keyof SlashRegistry,
Cache extends CacheType = CacheFor<Route>
>
SlashHandler<'ban'> {
public async Ban.execute(): Promise<void>execute(): interface Promise<T>Promise<void> {
const const target: Usertarget = this.SlashHandler<"ban", "cached">.options: SlashOptions<"ban", "cached">options.getUser: <"target">(name: "target") => UsergetUser('target');
await this.RepliableHandler<ChatInputCommandInteraction<"cached">, Core, SentMessage, Stream | BufferResolvable | JSONEncodable<...> | Attachment | AttachmentBuilder | AttachmentPayload, ReplySender>.reply(response: string | ReplyResponse<Stream | BufferResolvable | JSONEncodable<APIAttachment> | Attachment | AttachmentBuilder | AttachmentPayload>, opts?: SendOpts): Promise<SentMessage>reply({
ReplyResponse<Stream | BufferResolvable | JSONEncodable<APIAttachment> | Attachment | AttachmentBuilder | AttachmentPayload>.components: V2Component[]components: [new constructor BanCard(name: string): BanCardBanCard(const target: Usertarget.User.username: stringusername).BuilderComponent<"container">.component: ContainerBuildercomponent]
});
}
}BuilderComponent wraps a discord.js builder, and .component gives you the builder to put in components. A card that only Ban sends can stay in its file. Move BanCard to a file of its own once a second handler sends one, so rewording it takes one edit.
files uploads bytes alongside the components, covered in Files and attachments. The whole shape is ReplyResponse. Put it on a helper's return type when the helper builds a reply, so a wrong field fails inside the helper.
Warning
seedcord sets Discord's ComponentsV2 flag on every reply it sends. Discord forbids content, embeds, and poll on a message carrying that flag. reply, edit, followUp, update, and send all build their message out of components.
To send an embed or a poll, use discord.js's own methods on this.event. That skips the handler's ack state, so a later this.edit() throws and a thrown Notice never shows. Read Raw acks before you do.
Mentions inside your components
seedcord sends allowedMentions only when you set one. Discord's default for an interaction reply is { parse: ['users'] }, so a <@id> in your text pings that person. A role or @everyone mention stays plain text.
class ReasonText extends BuilderComponent<'text_display'> {
constructor(reason: string) {
super('text_display');
this.instance.setContent(reason);
}
}
@SlashRoute('ban')
export class Ban extends SlashHandler<'ban'> {
public async execute(): Promise<void> {
const reason = this.options.getString('reason') ?? '';
await this.reply({
components: [new ReasonText(reason).component],
allowedMentions: { parse: [] }
});
}
}reason there is text someone typed into your command, so it can hold anyone's <@id>. The empty parse blocks every mention in it, so your bot doesn't ping anyone. Set one on any reply that repeats what a person typed.
| field | takes | effect |
|---|---|---|
parse | any of 'users', 'roles', 'everyone' | turns on each kind in the array |
users | up to 100 ids | only those users ping |
roles | up to 100 ids | only those roles ping |
Only the person who ran the command sees it
Ephemeral is on by default. Pass ephemeral: false to show the reply to the whole channel. silent: true skips push and desktop notifications.
await this.reply('Banned.', {
ephemeral: false,
silent: true
});
followUp() takes ephemeral and silent the same way. edit() doesn't take options, since it rewrites a message that already has its flags.
The message you get back
reply() resolves to the message it created. Keep that message and you can rewrite or delete it later, which Follow-ups and edits shows.
const message = await this.reply('Banned.');Gateway and http differ
On gateway you get a discord.js Message. On http you get an
APIMessage, which is Discord's raw payload. followUp(),
edit(), and update()
all return the same type.