messageCreate event as server traffic, and Message.serverId is null for every one of them.
The full bot
dm-bot.ts
The SDK is ESM-only, so your project is an ES module (
"type": "module" in package.json) and top-level await is available. Use client.login().catch(...) instead if you prefer to keep the failure handling inline.Run it
From a checkout of the SDK, putCLOAK_TOKEN in a .env file and run:
examples/dm-smoke.ts.
To run your own copy:
How it works, piece by piece
Who may DM a bot
Who may DM a bot
The server decides, and it decides deny-by-default. A human may DM a bot only if that human owns the bot, or the two share at least one server.The SDK never pre-computes this. An ineligible target rejects with a
CloakActionError from the create step, before anything is encrypted or sent, which is why the sendDM call above sits in a try.Group DMs are not supported. A bot holds 1:1 conversations only.DMs arrive on messageCreate with a null serverId
DMs arrive on messageCreate with a null serverId
There is no separate DM event. A direct message fires
messageCreate with msg.isDM === true and msg.serverId === null, because a DM has no server at all.msg.dmId and msg.channelId both carry the dm id. That is one deliberate convention: channelId is always “the thing this message lives in”, so msg.reply() and msg.channel.send() work without branching.This bot inverts the usual guard. It returns on if (!msg.isDM) return; because server traffic is not its business. A bot that does server-scoped work needs the opposite line, if (msg.isDM || !msg.serverId) return;, before it touches client.guild(), client.can(), or anything else that takes a server id.First contact costs a key exchange
First contact costs a key exchange
The first send to a peer creates the DM and mints a conversation key wrapped to that peer’s identity. Both the dm id and the key are written to the keystore, so this happens once per peer per keystore, not once per restart.Losing the DM half of a keystore is mild: the peer-to-dm map is re-derivable (the create call is idempotent for an existing pair and returns the same id), and a lost DM key means the bot establishes a new one while the peer’s older messages stay unreadable.Losing the identity is not mild, which is why
keystorePath is set above. See Keys and the keystore.What does not work inside a DM
What does not work inside a DM
react(), unreact(), edit(), delete(), pin(), and unpin() all reject on a DM message. The backend implements those operations, but their result hooks never reach a bot, so the SDK would be issuing writes it can never observe. reply() and channel.send() work normally.msg.repliedTo is always null on a DM. The frame a bot receives carries no reply array, so there is nothing to decode.Slash commands are not dispatched from DMs. The structured command lane does not exist on the DM frame, and the menu a client’s / picker renders is published per server. messageCreate still fires, so parse msg.content yourself if you want DM commands.Sends are fire-and-forget
Sends are fire-and-forget
A resolved
sendDM() or reply() promise means the frame reached the wire. It does not mean the server accepted it.A server-side denial arrives on the sendRejected event, never as a rejected promise. Bots are rate-limited server-side, and a throttled send shows up there as code -9. In a DM the deny carries no routing, so serverId is null and channelId is a hint rather than a guarantee.The error event is separate again. It surfaces internal failures the SDK would otherwise swallow, including a key exchange that stalls. Registering a listener changes no control flow.Next
Direct messages
Eligibility, the unified
messageCreate, and DM-specific send semantics.Keys and the keystore
Why a bot with no keystore is bricked on its second run.
Sending messages
send(), reply(), SendOptions, and sendRejected.Events reference
Every event this bot listens for.