Skip to main content
This bot listens for !request <text> in any server channel and sends the approver a direct message card with Approve and Deny buttons. Approve tells the clicker so with an ephemeral reply, strips the buttons with editCard(), and DMs the requester. Deny opens a modal for a reason first, then does the same with the reason attached. The approver can also answer with /approve from the DM’s / picker instead of clicking. After a restart the bot reads its DM history with fetchDmMessages() and recovers every card whose buttons are still live, so a click on a card sent before the restart still works. It is the shortest demonstration of the full interaction lifecycle on the DM lane: sendCardDM(), one click or one DM slash command, exactly one answer per interaction, a card retired through the bot’s own echo, and DM history as the source of truth across restarts.
Everything past the send needs a Cloak backend that honors wire version 3: the bot’s own DM echo, DM history, and DM slash commands all ride the DM lane’s message broadcast. Against an older backend the card still sends and the click still arrives, but there is no echo to editCard(), fetchDmMessages() comes back empty, and the DM’s / picker cannot reach the bot; a typed /approve ... still does. See What a wire version 3 backend adds.

The full bot

approval-bot.ts

Run it

Then, in any text channel the bot can see, type !request a new keyboard. The card lands in the approver’s DM with the bot. Click Approve or Deny there, or type /approve in the DM (from the picker, or by hand) with the request id from the card. Restart the bot and click a card that was sent before the restart: it still retires.

How it works, piece by piece

The same CardMessage, the same v1 or v2 envelope, encrypted under the peer-keyed DM key. threadId and postId are not valid on it and groupId is ignored. A bot cannot DM itself, and an approver who neither owns the bot nor shares a server with it is refused server-side with a CloakActionError from the create step.
The buttons’ customIds are approve:<id>:<requesterId> and deny:<id>:<requesterId>, and the embed’s description is the request text. That is why remember() can rebuild a Pending entry from nothing but the card, whether it arrives as the live echo or as a history row. customId lives inside the encrypted envelope and is opaque to the server, but the DM’s other participant can read it, so it carries routing state and nothing sensitive.
fetchDmMessages(approverId, { limit: 50 }) resolves the DM from the approver’s user id the way sendDM() does, reads it on the DM cursor with the same cursors and limits as a channel read, and decrypts every row under the DM key. Rows carry card, createdAt, isDM: true, and serverId: null, and they include the bot’s own messages, which is the whole point here: a recovered row is a Message whose editCard() still works. A card whose components are already empty was decided before the restart and is skipped. fetchMessages(null, dmId, opts) is the same read by dm id, for a DM this session has already seen traffic in.
The approver’s client fetches the bot’s menu when it opens the DM, so /approve shows in the DM’s / picker. The invocation rides the DM lane’s message broadcast and dispatches through the same registry as a channel command, with ctx.serverId null, ctx.dmId set, ctx.channelId equal to the dm id, and ctx.userId the invoker. There is no permission gate in a DM, which is why the handler checks ctx.userId itself. ctx.reply() sends into the DM. dm: true is the default; a command declared dm: false is hidden from the DM picker and dropped if invoked there anyway. Typing /approve <id> approve by hand works too, on any backend, since the text lane runs in DMs. See Slash commands.
The approver’s app encrypts the click under the DM key and the server pushes it to this bot’s sessions only. It arrives with serverId: null and channelId equal to the dm id, and ephemeral() and showModal() answer under the same DM key. The server mints the interaction id and keeps it for 15 minutes.
Approve answers with ephemeral(), which the clicker alone sees under the card and which is never stored. Deny answers with showModal(). The modal’s submit is a second interaction, and it gets its own answer, ack(). A second call on the same interaction rejects locally with a CloakClientError whose source is 'interaction', and nothing goes on the wire.The unknown-request branch answers too. An unanswered click leaves the clicker’s control pending until their client’s 3 second timeout.
The edit addresses the row by its send time. An Interaction carries the click time, so the bot keeps the card Message, from its own DM echo on messageCreate or from the history read, and calls card.editCard() on it. That runs client.editCard(null, dmId, messageId, card, { createdAt }) under the DM key. components: [] strips the buttons; a second card with disabled: true on each button would leave them visible but inert.The echo and the history rows exist only on a backend that honors wire version 3. On an older backend the DM arrives through the legacy notify signal alone, which carries no timestamp and no card, and editCard() on such a message rejects with a message saying so.
sendDM() creates the DM and exchanges its key on first contact, then sends. The requester must own the bot or share a server with it, which they do, since they typed !request in one.
Nothing here is DM-specific except the sends and the history read. Swap sendCardDM(approverId, card) for sendCard(serverId, approvalsChannelId, card), fetchDmMessages(approverId, opts) for fetchMessages(serverId, approvalsChannelId, opts), keep the echo the same way (it has always existed on the server lane), and card.editCard() works unchanged. A click then arrives with serverId set and channelId equal to the channel.
A card with rows rides the v2 envelope. A Cloak client from before this release drops that card entirely and shows only the message’s content, which is why the content line names the request id, the requester, and the request in full, and why the footer spells out the /approve form.

Next

Direct messages

sendCardDM(), DM history, DM slash commands, and what wire version 3 adds.

Buttons and selects

Rows, the click lifecycle, and the three answers.

Slash commands

The dm flag and the DM CommandContext.

Modals

The form the Deny button opens.