! commands, and each one exercises a different corner of the SDK: reactions, message history, the moderation actions behind the guild() handle, the two halves of a mention, and the typed error the server sends back when it says no.
The SDK repo ships this as examples/mod-bot.ts. Everything below is server-scoped, so the very first thing the message handler does is drop direct messages.
The full bot
mod-bot.ts
Run it
!ping, !roles, !bans, !history 50, !purge 5, !timeout <userId> 600, !pin as a reply, !announce <text>, or just @ the bot.
How it works, piece by piece
The DM guard comes first
The DM guard comes first
messageCreate is unified: direct messages arrive on the same event with isDM: true and a null serverId. Every command in this bot is server-scoped, so if (msg.isDM || !msg.serverId) return; runs before anything else.Without it, client.guild(msg.serverId) does not typecheck, and in plain JavaScript the bot builds a handle around null and every action misfires. Any handler that touches serverId, can(), or guild() needs this guard. See Direct messages.The guild() handle
The guild() handle
client.guild(serverId) binds a server id onto every server-scoped method: fetchRoles(), fetchBans(), fetchMessages(), timeout(), send(), and the rest. It is pure sugar over the flat client surface, with no cache and no state, so creating one per event costs nothing. The Message objects it hands back are fully actionable, which is how !purge and !pin reach delete() and pin(). See The guild() handle.Mentions are two independent halves
Mentions are two independent halves
msg.mentionsMe is true when the decrypted body carries a <@id> entity naming this bot. It covers direct mentions only: @everyone and @here land on msg.mentions.everyone and msg.mentions.here, and a role mention surfaces as a plain name in msg.mentions.names, because the SDK does not know which roles the bot holds.Sending a mention takes two pieces that the SDK deliberately never infers from each other:userMention(id)produces the pill, which is literal text inside the encrypted body.{ mentions: [{ userId: id }] }produces the ping, which is what the server routes the notification from.
Typed denials with CloakActionError
Typed denials with CloakActionError
Any action here can be refused.
can() is advisory and the server re-checks everything, so a call that looked fine locally can still come back denied. The SDK rejects with a CloakActionError carrying opcode, code, message, and permission when the denial maps to one.This bot replies the denial into the channel rather than only logging it, so the person who typed the command learns why it failed. Anything that is not a CloakActionError falls through to console.error, because that is a bug rather than a policy decision. See Handling errors.Typing indicators around slow work
Typing indicators around slow work
!purge is many round trips, so it shows the typing dots while it works. Three rules matter:- Receivers clear the indicator themselves about 10 seconds after the last
true, so re-assert it inside the loop. Repeats of the same value inside the SDK’s roughly one second throttle are dropped without touching the wire. - Clear it with
sendTyping(serverId, channelId, false)in afinally. An indicator left on after a crash lingers for the full timeout, and clearing it is the entire reason thefalseargument exists. sendTyping()moves the same selection cursor asend()does, and likesend()it is fire-and-forget: a resolved promise means the frame went out.
Why !purge reverses the page
Why !purge reverses the page
fetchMessages() returns a page with the newest message last, so !purge reverses it and deletes from the newest end. The command message you typed is too new to appear in that page, so it is never part of the batch and stays put. The cap of 50 keeps a single typo from clearing a channel. Deleting someone else’s message needs message_manage. See Moderation.Sorting roles by position
Sorting roles by position
Lower
position values sort higher, and position can be null, so the sort is ascending with a fallback: (a.position ?? 99) - (b.position ?? 99). fetchRoles() is the healthy read on this surface. Each Role carries id, name, color, secondColor, position, and memberCount. See Members and roles.What happened to !members
What happened to !members
The shipped example still carries a
!members command built on fetchMembers(). It is left out here on purpose.fetchRoles() is unaffected. If you need to check a name against the server’s roles, that is the call to use.Presence is set once
Presence is set once
setStatus('online') writes an account-level base status to the server, so it survives reconnects, restarts, and redeploys. Set it at ready and leave it alone. Do not re-send it on a timer.Base online means “defer to liveness”: a bot with no live session displays as offline whatever its base says. The other four values (invisible, away, busy, dnd) pin the display, and dnd also suppresses push on every device.Observing events, including your own
Observing events, including your own
messageDelete, pinUpdate, reactionUpdate, and channelCreate report what the server broadcasts, and they fire for the bot’s own actions too. When !purge deletes, you see the deletes. When !pin reacts, you see the reaction. Guard against reacting to your own effects if one of these handlers ever does real work.typing is different in one non-obvious way: its serverId and channelId are always null. The relay frame carries no routing at all, so there is genuinely nothing to read. Do not substitute the bot’s own selection cursor, which points wherever the bot last sent and has nothing to do with who is typing.Next
Moderation
Bans, timeouts, purges, and pins in depth.
Mentions and replies
The pill, the ping, and true replies.
Handling errors
What CloakActionError carries and how to read it.
Welcome bot
A simpler starting example to compare against.