Skip to content

Raw acks

Reply through discord.js's own methods on this.event and seedcord stops tracking the interaction. Covers the stale state that leaves, the lint rule reporting it, and the two cases that need one.

On gateway, this.event is the discord.js interaction, so you can call this.event.reply() and it reaches Discord the same way this.reply() does. It skips the ack state that seedcord's own methods keep.

A raw ack leaves the state stale

The reply sender reads the interaction's state once, when seedcord constructs your handler. That happens before execute() runs, and only seedcord's own reply methods move it afterwards. If you send an ack yourself, the copy stays where the last seedcord call left it.

src/handlers/Ban.ts
import { SlashHandler, SlashRoute } from '@seedcord/gateway';

@SlashRoute('ban')
export class Ban extends SlashHandler<'ban'> {
    public async execute(): Promise<void> {
        await this.event.reply('Banned.');

        await this.edit('Banned, and sessions revoked.');
    }
}

Discord accepts the first line. The second throws.

edit() was called when nothing has been sent yet.
Send with reply() or defer() first, then edit() rewrites it.
(route slash:ban)

Every later call reads that same stale state, so followUp() and delete() throw too.

The lint rule

The edit() throw above only fires when someone runs the command. The lint rule reports the raw call while you write it.

@seedcord/no-raw-interaction-acks reports every ack method on a repliable interaction inside a handler or middleware class, plus respond() on an autocomplete one. The recommended preset turns it on as an error.

src/handlers/Ban.ts
  6:15  error  Reply through this.reply().  @seedcord/no-raw-interaction-acks
discord.jsseedcord
reply()this.reply()
deferReply()this.defer()
editReply()this.edit()
followUp()this.followUp()
update()this.update()
deferUpdate()this.deferUpdate()
showModal()this.showModal()
deleteReply()this.delete()
fetchReply()the message a reply member already returned
respond()this.respond(), on an autocomplete handler

The rule resolves the receiver's type, which is how it reports this.event.reply() and a local alias alike. It misses a destructured const { reply } = this.event, since that call doesn't keep a receiver to resolve.

When you actually need one

  • An embed or a poll. The ComponentsV2 flag on every seedcord reply forbids both. this.event.reply() sends them.
  • A click you collected yourself. Gateway only. message.awaitMessageComponent() hands you a component interaction that never reached seedcord's dispatcher, so no handler and no sender exist for it. That click is its own interaction. Answering it leaves your handler's state where it was. An http bot answers each interaction in its own request, and a collector needs a process that stays alive between them.

seedcord has a replacement for each. Text Display and Container are Discord's replacements for content and embeds under the flag. seedcord routes a click by its customId to a ButtonHandler, for example, which any process can answer. A collector runs in the process that opened it, so a restart drops every prompt still waiting.

For the interaction that seedcord dispatched, pass the handler's sender to your helper. It's public. When your helper replies through it, the ack state your handler reads moves too.

hover for typestap for types, arrow keys walk the tokens
import type { class ReplySenderReplySender } from '@seedcord/gateway';

async function function confirmSaved(sender: ReplySender): Promise<void>confirmSaved(sender: ReplySendersender: class ReplySenderReplySender): interface Promise<T>Promise<void> {
    await sender: ReplySendersender.BaseReplySender<SentMessage, BufferResolvable | Stream | JSONEncodable<APIAttachment> | Attachment | AttachmentBuilder | AttachmentPayload>.followUp(response: string | ReplyResponse<BufferResolvable | Stream | JSONEncodable<APIAttachment> | Attachment | AttachmentBuilder | AttachmentPayload>, opts?: SendOpts): Promise<SentMessage>followUp('Saved to the audit log.');
}

@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> { await this.RepliableHandler<ChatInputCommandInteraction<"cached">, Core, SentMessage, BufferResolvable | Stream | JSONEncodable<...> | Attachment | AttachmentBuilder | AttachmentPayload, ReplySender>.reply(response: string | ReplyResponse<BufferResolvable | Stream | JSONEncodable<APIAttachment> | Attachment | AttachmentBuilder | AttachmentPayload>, opts?: SendOpts): Promise<SentMessage>reply('Banned.'); await function confirmSaved(sender: ReplySender): Promise<void>confirmSaved(this.RepliableHandler<ChatInputCommandInteraction<"cached">, Core, SentMessage, BufferResolvable | Stream | JSONEncodable<...> | Attachment | AttachmentBuilder | AttachmentPayload, ReplySender>.sender: ReplySendersender); } }

confirmSaved takes the sender because the reply members stay protected. Calling handler.reply() from outside the class doesn't compile. The sender carries the same methods.

Since the rule checks code inside handler and middleware classes, answer a collected click from a plain function like this one.

import type { Message } from 'discord.js';

export async function acknowledgeClick(
    prompt: Message
): Promise<void> {
    const click = await prompt.awaitMessageComponent({
        time: 30_000
    });

    await click.reply('Got it.');
}

seedcord ships getConfirmation for the common confirm-then-act case, which collects the click and answers it for you.

Http only

Http adds this.api, a typed REST client over core.rest from @discordjs/core/http-only. Use it for Discord calls beyond the reply surface, such as this.api.guilds.editMember().

The callbacks on api.interactions answer the interaction directly, which leaves the state stale the same way. The lint rule reads discord.js interaction types alone, so those calls pass it.