Skip to main content
This bot declares two slash commands, /play and /volume, and dispatches them through one handler API. It also keeps a small text command of its own, help, which is where the one filter every mixed bot needs comes in. The SDK repo ships this as examples/command-bot.ts.

The full bot

command-bot.ts

Run it

Then type /play never gonna give you up in a channel the bot can see, or pick the command from the client’s / menu. Both routes reach the same handler.

How it works, piece by piece

login() publishes whatever is registered at that moment, which is what a human’s / picker lists. register() itself is synchronous and throws in your own stack trace on anything the server would reject: a bad name, a description over 100 characters, a duplicate, a required option after an optional one, too many commands.A command registered after login() still dispatches locally, but it is not in the published menu until the next login, and the SDK does not republish on its own. Publication is best effort and never blocks login: a rejection is logged behind debug and swallowed.
Option types are string, integer, boolean, user, channel, role, and number. The user, channel, and role types arrive as opaque id strings; the SDK does no resolution, so read them with getString() and normalize before comparing.Two rules are load-bearing:
  • Declared order is the positional text grammar. Every required option must precede every optional one, or register() throws.
  • The last option, when it is a string, is greedy. It swallows the rest of the line verbatim once every earlier option is filled, which is what makes /play never gonna give you up work without quotes.
choices is a flat list of strings, and each string is both the label and the value. The wire has no slot for a separate label. Booleans cannot carry choices, and for integer or number every choice must coerce.
An invocation reaches your bot on one of two lanes, and this file cannot tell which:
  • Structured: an encrypted commandData sidecar on the message frame, which names the target bot.
  • Text: the readable message content, parsed by the SDK’s grammar with the prefix from commandPrefix (default /).
Structured wins when both are present, and a message never dispatches twice. The text lane cannot address a bot, so every bot in the channel with /play registered will dispatch a text /play. ctx.source tells you which lane you got, which is mostly useful for logging.
Slash commands are not dispatched from direct messages. The structured lane does not exist on the DM frame, and the menu is published per server. messageCreate still fires for a DM, so parse msg.content yourself if you want DM commands.
A handler receives a CommandContext: name, the raw args record, source, the typed accessors getString, getInteger, getNumber, getBoolean (each returning null when the option is absent or does not coerce), the originating message, and reply(text, opts?).ctx.reply() is identical to ctx.message.reply(): a true reply, complete with the quote header and a “replied to you” notification that pierces mutes. That fits a command answer, which is genuinely a response to a specific message. For a plain send with no quote, use ctx.message.channel.send().Sends stay fire-and-forget here as everywhere: a resolved promise means the frame went out, and a server-side denial arrives on sendRejected.
A message the router already claimed carries msg.command, and its handler has already been dispatched by the time your messageCreate listener runs (messageCreate fires first, then dispatch). A bot that also parses text needs if (msg.command) return; or it answers the same message twice.msg.command is never set on fetched history, so replaying history cannot re-execute old commands, and it is never set on the bot’s own messages.
commandError carries { name, error, message, reason }:
  • reason: 'parse' means the text matched a registered command but did not fit its declaration: a missing required option, a value outside choices, a number that will not coerce. Your handler was not invoked, and the SDK does not auto-reply, because it never speaks unprompted. Reply yourself if you want the user to hear about it.
  • reason: 'handler' means your own handler threw or rejected.
An unregistered /foo emits nothing at all.
There is no ephemerality. An invocation is an ordinary message encrypted with the channel epoch key. Every member holding that key can read every invocation, and it is a durable message row subject only to the channel or per-message TTL. Nothing here is “only you can see this”, and deleting the message afterwards does not fake it.
The registry menu is plaintext server-side. Command names, descriptions, option names, and choice labels are stored and served unencrypted so a client’s / picker can render them without a key. Never put a secret, an internal hostname, or a customer name in a description or a choice.
Message bodies stay end-to-end encrypted throughout, including the structured sidecar. See What is encrypted, and what is not.
client.commands.list() returns the declarations you registered, and client.commands.size counts them, so a help command can be generated rather than maintained by hand. The example answers with msg.channel.send() instead of reply(): a help dump does not need to pierce anyone’s mutes.

Next

Slash commands

The text grammar, the two lanes, and publishing the menu.

Commands reference

Declarations, caps, and the context API.

Card bot

Answer a command with an encrypted rich card.

Moderation bot

Actions, typed denials, and the guild() handle.