Declare a command surface¶
You want /scout table new <a channel>: one namespace people learn, an
argument the platform draws a picker for, and an identifier it resolved rather
than text somebody typed.
This is the declaring half. Reading what arrives is Read command arguments.
1. Reach the capability¶
Commands are optional, so ask for them rather than assuming:
cmds, ok := chatplatform.AsCommands(provider)
if !ok {
// this platform has no commands, or you did not declare NeedCommands
return
}
AsCommands answers false unless the provider implements it and you
declared NeedCommands when you built the client. A capability you did not ask
for stays unreachable even where the platform has it.
2. Ask what the platform can carry, before you build the spec¶
if !cmds.SupportsOption(chatplatform.OptionChannel) {
// decide deliberately: fall back to OptionString and resolve it yourself,
// or refuse to start and say which platform cannot do what you need
}
Do this before registering, not after. Where SupportsOption answers false,
RegisterCommands refuses the spec. It does not quietly register a text box
in place of your picker. That refusal is the point: a command that registers
cleanly and can never complete is worse than one that never registers, because
you find out about the first from a user.
OptionString is always supported. A platform that could not carry text could
not carry a command at all.
3. Build the spec¶
A flat command carries options:
ask := chatplatform.CommandSpec{
Name: "ask",
Description: "Put a question to Scout",
Options: []chatplatform.CommandOption{
{Name: "question", Description: "what to ask", Type: chatplatform.OptionString, Required: true},
{Name: "private", Description: "answer only to you", Type: chatplatform.OptionBoolean},
},
}
Required options come first. Platforms refuse the registration otherwise, and
so does Validate. A spec breaking the rule looks perfectly reasonable in Go,
which is exactly why it is worth catching.
The seven types are OptionString (the zero value), OptionInteger,
OptionNumber, OptionBoolean, OptionChannel, OptionUser and OptionRole.
An option written before types existed keeps working: no Type means text.
4. Nest, when a flat name stops reading well¶
A command carries either options or a tree, never both. Subcommands and groups mix freely under one command:
scout := chatplatform.CommandSpec{
Name: "scout",
Description: "Run a table",
Subcommands: []chatplatform.Subcommand{
{Name: "join", Description: "Join the current session"},
{Name: "leave", Description: "Leave the current session"},
},
Groups: []chatplatform.CommandGroup{{
Name: "table", Description: "Manage a table",
Subcommands: []chatplatform.Subcommand{{
Name: "new", Description: "Create a table",
Options: []chatplatform.CommandOption{
{Name: "channel", Description: "where it runs", Type: chatplatform.OptionChannel, Required: true},
},
}},
}},
}
That gives /scout join, /scout leave and /scout table new.
Options beside subcommands is refused, and not for tidiness: declaring subcommands makes the base command uninvokable, so a command carrying both registers without complaint and then does nothing.
Nesting stops there. A group holds subcommands; a subcommand holds options. Neither holds itself, so the illegal shape is one you cannot write.
5. Register the whole set¶
if err := cmds.RegisterCommands(ctx, []chatplatform.CommandSpec{ask, scout}); err != nil {
return fmt.Errorf("register commands: %w", err)
}
RegisterCommands is declarative: it replaces the registered set entirely,
is idempotent, and is safe to run on every start. There is no partial update, so
the registered set cannot drift from the declared one. Passing an empty slice
removes everything.
6. Let Validate tell you before the platform does¶
Every spec is validated before anything is sent. Each rule refuses something a platform refuses on the wire, where it arrives as a provider-shaped 400, after connecting, reading like a configuration fault rather than like your spec being wrong.
| refused | sentinel |
|---|---|
a name outside 1–32 runes of lowercase letters, digits, - or _ |
ErrInvalidCommandName |
| a description outside 1–100 runes | ErrInvalidDescription |
| more than 25 options, subcommands or groups at one level | ErrTooManyOptions |
| options beside subcommands | ErrOptionsWithSubcommands |
| a group with nothing in it | ErrEmptyGroup |
| a required option after an optional one | ErrOptionOrder |
an OptionType this contract does not define |
ErrUnknownOptionType |
| over 8000 runes of names, descriptions and choices combined | ErrCommandTooLarge |
Match them with errors.Is; the message names the command, subcommand or option
at fault.
The name rule is narrower than Discord's, deliberately¶
Names take Unicode letters and digits, so искать and こまんど are fine. What
they do not take is combining marks, which Devanagari and Thai use heavily:
कमल and ไทย pass, while हिन्दी (the word "Hindi") does not.
That is a real cost and you cannot tell by looking which of your names it will
refuse. The trade is that a refusal here is visible at Validate, where a name
the platform rejects is a 400 on a live gateway. If it bites you, say so and it
will be widened; the rule is conservative, not principled.
What is not here¶
No choice lists and no autocomplete. No dense argument syntax; flags like
-t <target> -c <count> are
issue #19 and
deliberately deferred.