/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
/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
Register before login()
Register before login()
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.Declaring options
Declaring options
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 upwork 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.One handler, two lanes
One handler, two lanes
An invocation reaches your bot on one of two lanes, and this file cannot tell which:
- Structured: an encrypted
commandDatasidecar 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/).
/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.The handler context
The handler context
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.The msg.command filter
The msg.command filter
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, and when the SDK stays quiet
commandError, and when the SDK stays quiet
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 outsidechoices, 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.
/foo emits nothing at all.Two properties of v1 to design around
Two properties of v1 to design around
Message bodies stay end-to-end encrypted throughout, including the structured sidecar. See What is encrypted, and what is not.
commands.list() as a help command
commands.list() as a help command
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.