fix(commands): remove examples from command discovery

This commit is contained in:
2026-07-21 13:52:53 +07:00
parent a809646828
commit 2180c2a59f
22 changed files with 47 additions and 154 deletions
+3 -5
View File
@@ -24,7 +24,7 @@ Telegram command names are user-facing contracts. When adding, renaming, or
deleting commands, update all related surfaces:
- module command registration in `internal/modules/<module>/`
- command parameter and example metadata used by Telegram and `/help`
- command parameter metadata used by Telegram and `/help`
- handler usage text and user-facing error text
- tests for registration, handlers, and command menu behavior
- README/docs when behavior changes are user-visible
@@ -34,10 +34,8 @@ Include units or currencies when meaningful (for example, `<vnd_amount>`), use
`[...]` for optional input, append `...` for remaining free text, and use
parentheses to document structured input (for example,
`<ratio(owned:new)>`). Keep metadata, usage errors, examples, and tests exact.
Public commands with parameters must provide an explicit copyable example;
commands without parameters must omit it. In both Telegram's native menu and
`/help`, append `Eg: <invocation>` inline; `/help` must wrap only the invocation
in Telegram HTML `<code>` formatting.
Telegram's native menu and `/help` show command syntax plus the summary without
example invocations.
## Stats Compatibility
+7 -10
View File
@@ -22,26 +22,23 @@ Disable modules with the `MODULES` environment variable.
## Command discovery
Public command registrations share their description plus optional `Parameters`
and `Example` metadata between Telegram's native `/` menu and the bot's `/help`
response. Telegram renders the `/command` separately, and its native description
field supports only single-line plain text—no copyable code block. For
`/stock_buy`, the bot therefore sends this description:
metadata between Telegram's native `/` menu and the bot's `/help` response.
Telegram renders the `/command` separately, so `/stock_buy` uses this native
description:
```text
<quantity> <ticker>. Buy VN stock at market price. Eg: /stock_buy 100 TCB
<quantity> <ticker>. Buy VN stock at market price.
```
`/help` combines the command syntax and summary, then appends `Eg:` and the
example on the same line. It wraps only the invocation in Telegram HTML
`<code>` formatting so it is easy to copy. Public commands with parameters must
register an explicit example; commands without parameters omit it entirely.
`/help` combines the full command syntax and summary on one line. Neither
discovery surface includes example invocations.
For future commands, use lowercase descriptive parameter names. Include units
or currencies when they affect meaning (`<vnd_amount>`, `<usd_to_spend>`), use
square brackets for optional input (`[date]`), append `...` when an argument
accepts remaining text (`[target...]`), and describe structured input in
parentheses (`<ratio(owned:new)>`, `<options(comma-separated)>`). Keep command
metadata, handler usage text, examples, tests, and this documentation aligned.
metadata, handler usage text, tests, and this documentation aligned.
### Stock dividend commands
+5 -10
View File
@@ -22,7 +22,7 @@ func TestBotCommandMenu_UsesLoadedPublicCommandsInModuleOrder(t *testing.T) {
{
Name: "beta",
Commands: []modules.Command{
{Name: "beta_public", Description: "Beta public", Parameters: "<value>", Example: "/beta_public demo", Visibility: modules.VisibilityPublic},
{Name: "beta_public", Description: "Beta public", Parameters: "<value>", Visibility: modules.VisibilityPublic},
{Name: "beta_private", Description: "Beta private", Visibility: modules.VisibilityPrivate},
},
},
@@ -38,7 +38,7 @@ func TestBotCommandMenu_UsesLoadedPublicCommandsInModuleOrder(t *testing.T) {
got := botCommandMenu(reg)
want := []models.BotCommand{
{Command: "beta_public", Description: "<value>. Beta public. Eg: /beta_public demo"},
{Command: "beta_public", Description: "<value>. Beta public."},
{Command: "alpha_public", Description: "Alpha public."},
}
if len(got) != len(want) {
@@ -90,15 +90,10 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
if got := command.Parameters; got != expectedParameters[command.Name] {
t.Errorf("/%s parameters = %q, want %q", command.Name, got, expectedParameters[command.Name])
}
example := command.ExampleInvocation()
if command.Parameters == "" {
if example != "" || strings.Contains(command.TelegramMenuDescription(), "Eg:") {
t.Errorf("/%s without parameters has example %q", command.Name, example)
}
} else if !strings.HasPrefix(example, "/"+command.Name) {
t.Errorf("/%s example = %q", command.Name, example)
}
description := command.TelegramMenuDescription()
if strings.Contains(description, "Eg:") {
t.Errorf("/%s native menu description contains an example: %q", command.Name, description)
}
if strings.ContainsAny(description, "\r\n") {
t.Errorf("/%s native menu description is multiline: %q", command.Name, description)
}
+4 -6
View File
@@ -137,12 +137,10 @@ Successful GIF replies include the result behind Telegram spoiler formatting.
The bot registers its Telegram command menu from loaded public modules on
every startup. The Go module registry is the single source of truth; no separate
command-menu file or manual registration step is required. A command's
description plus optional `Parameters` and `Example` metadata feed both
surfaces. Telegram renders the command name separately and accepts only a
single-line plain-text description, so parameterized commands append
`Eg: <invocation>` inline. `/help` uses the same inline layout and wraps only
the invocation in Telegram HTML `<code>` formatting. Commands without
parameters omit examples.
description plus optional `Parameters` metadata feed both surfaces. Telegram
renders the command name separately and accepts only a single-line plain-text
description. Both the native menu and `/help` show syntax plus the summary and
omit example invocations.
## Operations
@@ -3,58 +3,51 @@
## Context
Telegram's native command menu and `/help` exposed only short descriptions,
leaving users to discover parameters and examples through failed invocations or
source documentation.
leaving users to discover parameters through failed invocations or source
documentation.
## What Changed
- Extended the shared command registration with `Parameters` and `Example`
metadata and presentation helpers used by both discovery surfaces.
- Extended the shared command registration with `Parameters` metadata and
presentation helpers used by both discovery surfaces.
- Added metadata for all 40 public commands, including the exact stats grammar:
`[users | user <username> | cmd <command_name>]`.
- Normalized placeholders to lowercase descriptive names, including meaningful
units or currencies; `[...]` marks optional input, `...` remaining free text,
and parentheses structured input.
- Normalized `/wheelofnames` to `<options(comma-separated)>` across metadata,
usage text, and tests; its example remains
`/wheelofnames pizza, sushi, pho`.
usage text, and tests.
- Finalized dividend placeholders as `<vnd_per_share> <ticker>`,
`<ratio(owned:new)> <ticker>`, and
`<vnd_per_share> <ratio(owned:new)> <ticker>` for cash, share, and combined
commands. Examples and parsing remain unchanged.
- Native menu descriptions show only the summary for no-parameter commands.
Parameterized commands append an explicit example with the short `Eg:` label
on the same plain-text line.
- `/help` renders each invocation and summary together. Parameterized commands
append `Eg: <code>invocation</code>` on the same line, with only the copyable
invocation inside Telegram's HTML `<code>` formatting. No-parameter commands
omit the label and example. Dynamic command fields are HTML-escaped.
- Registration validation rejects multiline metadata, examples for another
command, public commands that provide only one of parameters or example, and
public descriptions over Telegram's 256-character limit.
commands. Parsing remains unchanged.
- Native menu descriptions show parameters followed by the summary, while
`/help` renders the complete invocation followed by the summary. Neither
discovery surface includes example invocations. Dynamic fields are
HTML-escaped in `/help`.
- Registration validation rejects multiline metadata and public descriptions
over Telegram's 256-character limit.
- Updated user and deployment documentation for the shared registry behavior.
## Reflection
Keeping syntax and examples beside each handler registration prevents the
native menu, `/help`, and implementation from drifting independently. The
native surface stays compact, while `/help` uses the richer layout Telegram
supports without sacrificing safe HTML rendering.
Keeping syntax beside each handler registration prevents the native menu,
`/help`, and implementation from drifting independently. Both discovery
surfaces stay compact without sacrificing safe HTML rendering in `/help`.
## Decisions
- Existing command names, handlers, parsers, and persisted data remain
unchanged; normalization is presentation-only.
- No command was added, renamed, or deleted, so no stats migration is needed.
- Public commands with parameters require an explicit example; commands
without parameters omit it.
- Discovery surfaces intentionally omit example invocations; handler usage
errors may still include focused examples.
- The complete `/help` output remains within Telegram's 4,096-character limit.
## Verification
- Passed: command presentation, validation, menu, and `/help` tests for all 40
public commands, including inline `<code>` rendering, no-parameter omission,
and both parameter/example validation branches.
public commands.
- Passed: `go test ./...`, including real MongoDB Testcontainers suites.
- Passed: `go vet ./...`
- Passed: `go build ./...`
@@ -62,5 +55,5 @@ supports without sacrificing safe HTML rendering.
## Next Steps
- Require parameter and example metadata updates alongside future public
command contract changes.
- Require parameter metadata updates alongside future public command contract
changes.
-4
View File
@@ -16,7 +16,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Show current crypto price in USD",
Parameters: "<coin>",
Example: "/coin_price BTC",
Handler: s.handlePrice,
},
{
@@ -24,7 +23,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Top up USD to your coin account",
Parameters: "<usd_amount>",
Example: "/coin_topup 1000",
Handler: s.handleTopup,
},
{
@@ -32,7 +30,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Spend a USD amount to buy coin",
Parameters: "<coin> <usd_to_spend>",
Example: "/coin_buy BTC 10",
Handler: s.handleBuy,
},
{
@@ -40,7 +37,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Sell enough coin to receive a USD amount",
Parameters: "<coin> <usd_to_receive>",
Example: "/coin_sell BTC 10",
Handler: s.handleSell,
},
{
-9
View File
@@ -17,11 +17,6 @@ func (c Command) InvocationSentence() string {
return withTerminalPunctuation(c.Invocation())
}
// ExampleInvocation returns the explicitly registered copyable example.
func (c Command) ExampleInvocation() string {
return strings.TrimSpace(c.Example)
}
// SummarySentence normalizes a command summary to a sentence without
// duplicating terminal punctuation supplied by the registration.
func (c Command) SummarySentence() string {
@@ -38,10 +33,6 @@ func (c Command) TelegramMenuDescription() string {
sb.WriteByte(' ')
}
sb.WriteString(c.SummarySentence())
if example := c.ExampleInvocation(); example != "" {
sb.WriteString(" Eg: ")
sb.WriteString(example)
}
return sb.String()
}
+4 -13
View File
@@ -7,26 +7,22 @@ func TestCommandPresentation(t *testing.T) {
name string
command Command
invocation string
example string
menu string
}{
{
name: "parameters and explicit example",
name: "parameters",
command: Command{
Name: "stock_buy",
Parameters: "<quantity> <ticker>",
Description: "Buy VN stock at market price",
Example: "/stock_buy 100 TCB",
},
invocation: "/stock_buy <quantity> <ticker>",
example: "/stock_buy 100 TCB",
menu: "<quantity> <ticker>. Buy VN stock at market price. Eg: /stock_buy 100 TCB",
menu: "<quantity> <ticker>. Buy VN stock at market price.",
},
{
name: "no parameters omits example",
name: "no parameters",
command: Command{Name: "ping", Description: "Health check!"},
invocation: "/ping",
example: "",
menu: "Health check!",
},
{
@@ -35,11 +31,9 @@ func TestCommandPresentation(t *testing.T) {
Name: "random",
Parameters: "<options(comma-separated)>",
Description: "Pick one option",
Example: "/random pizza, sushi",
},
invocation: "/random <options(comma-separated)>",
example: "/random pizza, sushi",
menu: "<options(comma-separated)>. Pick one option. Eg: /random pizza, sushi",
menu: "<options(comma-separated)>. Pick one option.",
},
}
@@ -48,9 +42,6 @@ func TestCommandPresentation(t *testing.T) {
if got := tc.command.Invocation(); got != tc.invocation {
t.Errorf("Invocation() = %q, want %q", got, tc.invocation)
}
if got := tc.command.ExampleInvocation(); got != tc.example {
t.Errorf("ExampleInvocation() = %q, want %q", got, tc.example)
}
if got := tc.command.TelegramMenuDescription(); got != tc.menu {
t.Errorf("TelegramMenuDescription() = %q, want %q", got, tc.menu)
}
-3
View File
@@ -21,7 +21,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Top up VND to your gold account",
Parameters: "<vnd_amount>",
Example: "/gold_topup 5000000",
Handler: s.handleTopup,
},
{
@@ -29,7 +28,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Buy gold at SJC sell price",
Parameters: "<luong>",
Example: "/gold_buy 1",
Handler: s.handleBuy,
},
{
@@ -37,7 +35,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Sell gold at SJC buy price",
Parameters: "<luong>",
Example: "/gold_sell 0.5",
Handler: s.handleSell,
},
{
-1
View File
@@ -27,7 +27,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "LoL matches for a date (dd, dd-mm, dd/mm, ddmm, or full date; default today)",
Parameters: "[date]",
Example: "/lol 21-07-2026",
Handler: s.handleSchedule,
},
{
-1
View File
@@ -21,7 +21,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Classic loldle — guess the current champion",
Parameters: "[champion]",
Example: "/loldle Ahri",
Handler: s.handleLoldle,
},
{
-3
View File
@@ -129,17 +129,14 @@ func senderMention(u *models.User) string {
func disclaimerCommand(name, description, defaultTarget string, allowCustomTarget bool) modules.Command {
parameters := ""
example := ""
if allowCustomTarget {
parameters = "[target...]"
example = "/" + name + " FBI"
}
return modules.Command{
Name: name,
Visibility: modules.VisibilityPublic,
Description: description,
Parameters: parameters,
Example: example,
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
if update.Message == nil {
return nil
-1
View File
@@ -31,7 +31,6 @@ func randomCommand() modules.Command {
Visibility: modules.VisibilityPublic,
Description: "Pick one random comma-separated option",
Parameters: "<options(comma-separated)>",
Example: "/random pizza, sushi, pho",
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
if update.Message == nil {
return nil
@@ -25,7 +25,6 @@ func wheelOfNamesCommand() modules.Command {
Visibility: modules.VisibilityPublic,
Description: "Pick one comma-separated option with wheel GIF when configured",
Parameters: "<options(comma-separated)>",
Example: "/wheelofnames pizza, sushi, pho",
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
if update.Message == nil {
return nil
-1
View File
@@ -40,7 +40,6 @@ type Command struct {
Visibility Visibility // public/protected/private
Description string // concise summary shown in command discovery (required, non-empty)
Parameters string // optional syntax after the command, e.g. "<quantity> <ticker>"
Example string // full invocation required with public Parameters; omitted without them
Handler CommandHandler // required
}
-1
View File
@@ -33,7 +33,6 @@ func statsCommand(c *counter) modules.Command {
Visibility: modules.VisibilityPublic,
Description: "Show command usage statistics",
Parameters: "[users | user <username> | cmd <command_name>]",
Example: "/stats user alice",
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
if update.Message == nil {
return nil
-7
View File
@@ -15,7 +15,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Show current VN stock price",
Parameters: "<ticker>",
Example: "/stock_price TCB",
Handler: s.handlePrice,
},
{
@@ -23,7 +22,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Top up VND to your stock account",
Parameters: "<vnd_amount>",
Example: "/stock_topup 5000000",
Handler: s.handleTopup,
},
{
@@ -31,7 +29,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Buy VN stock at market price",
Parameters: "<quantity> <ticker>",
Example: "/stock_buy 100 TCB",
Handler: s.handleBuy,
},
{
@@ -39,7 +36,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Sell VN stock back to VND",
Parameters: "<quantity> <ticker>",
Example: "/stock_sell 100 TCB",
Handler: s.handleSell,
},
{
@@ -47,7 +43,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Record cash dividend",
Parameters: "<vnd_per_share> <ticker>",
Example: "/stock_cash_dividend 1500 TCB",
Handler: s.handleCashDividend,
},
{
@@ -55,7 +50,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Record share dividend",
Parameters: "<ratio(owned:new)> <ticker>",
Example: "/stock_share_dividend 100:10 TCB",
Handler: s.handleShareDividend,
},
{
@@ -63,7 +57,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Record cash and share dividend",
Parameters: "<vnd_per_share> <ratio(owned:new)> <ticker>",
Example: "/stock_dividend 1500 100:10 TCB",
Handler: s.handleDividend,
},
{
-3
View File
@@ -50,9 +50,6 @@ func RenderHelp(reg *modules.Registry) string {
fmt.Fprintf(&sb, "\n%s %s",
html.EscapeString(command.InvocationSentence()),
html.EscapeString(command.SummarySentence()))
if example := command.ExampleInvocation(); example != "" {
fmt.Fprintf(&sb, " Eg: <code>%s</code>", html.EscapeString(example))
}
}
sections = append(sections, sb.String())
}
+3 -10
View File
@@ -74,13 +74,12 @@ func TestRenderHelp_GroupsByModuleAndSkipsNonPublic(t *testing.T) {
}
}
func TestRenderHelp_ShowsParametersAndCopyableExample(t *testing.T) {
func TestRenderHelp_ShowsParametersWithoutExample(t *testing.T) {
command := modules.Command{
Name: "buy",
Visibility: modules.VisibilityPublic,
Description: "Buy & hold",
Parameters: "<quantity> <ticker>",
Example: "/buy 100 TCB & hold",
Handler: helpTestNoop,
}
reg, err := modules.Build(
@@ -97,14 +96,8 @@ func TestRenderHelp_ShowsParametersAndCopyableExample(t *testing.T) {
if !strings.Contains(out, "/buy &lt;quantity&gt; &lt;ticker&gt;. Buy &amp; hold.") {
t.Fatalf("help missing formatted invocation and summary:\n%s", out)
}
if !strings.Contains(out, "<code>/buy 100 TCB &amp; hold</code>") {
t.Fatalf("help missing copyable example:\n%s", out)
}
if !strings.Contains(out, "Buy &amp; hold. Eg: <code>/buy 100 TCB &amp; hold</code>") {
t.Fatalf("help missing short example label:\n%s", out)
}
if strings.Contains(out, "<pre>") {
t.Fatalf("help example should use inline code, not a pre block:\n%s", out)
if strings.Contains(out, "Eg:") || strings.Contains(out, "<code>") || strings.Contains(out, "<pre>") {
t.Fatalf("help should not render examples:\n%s", out)
}
}
-19
View File
@@ -29,25 +29,6 @@ func validateCommand(c Command) error {
if strings.ContainsAny(c.Parameters, "\r\n") {
return fmt.Errorf("command %q: parameters must be single-line", c.Name)
}
if strings.ContainsAny(c.Example, "\r\n") {
return fmt.Errorf("command %q: example must be single-line", c.Name)
}
parameters := strings.TrimSpace(c.Parameters)
example := strings.TrimSpace(c.Example)
if c.Visibility == VisibilityPublic {
if parameters == "" && example != "" {
return fmt.Errorf("command %q: example requires parameters", c.Name)
}
if parameters != "" && example == "" {
return fmt.Errorf("command %q: example is required when parameters are present", c.Name)
}
}
if example != "" {
prefix := "/" + c.Name
if example != prefix && !strings.HasPrefix(example, prefix+" ") {
return fmt.Errorf("command %q: example must invoke %s", c.Name, prefix)
}
}
if c.Visibility == VisibilityPublic && utf8.RuneCountInString(c.TelegramMenuDescription()) > telegramCommandDescriptionMaxRunes {
return fmt.Errorf("command %q: Telegram menu description exceeds %d characters", c.Name, telegramCommandDescriptionMaxRunes)
}
+1 -18
View File
@@ -47,22 +47,6 @@ func TestValidateCommand_RejectsInvalidPresentationMetadata(t *testing.T) {
name: "multiline parameters",
command: Command{Name: "ok", Visibility: VisibilityPublic, Description: "d", Parameters: "<a>\n<b>", Handler: okHandler},
},
{
name: "multiline example",
command: Command{Name: "ok", Visibility: VisibilityPublic, Description: "d", Example: "/ok one\n/ok two", Handler: okHandler},
},
{
name: "example invokes another command",
command: Command{Name: "ok", Visibility: VisibilityPublic, Description: "d", Example: "/other", Handler: okHandler},
},
{
name: "example without parameters",
command: Command{Name: "ok", Visibility: VisibilityPublic, Description: "d", Example: "/ok", Handler: okHandler},
},
{
name: "parameters without example",
command: Command{Name: "ok", Visibility: VisibilityPublic, Description: "d", Parameters: "<value>", Handler: okHandler},
},
{
name: "native description too long",
command: Command{Name: "ok", Visibility: VisibilityPublic, Description: strings.Repeat("x", 257), Handler: okHandler},
@@ -86,13 +70,12 @@ func TestValidateCommand_AcceptsLegalNames(t *testing.T) {
}
}
func TestValidateCommand_AcceptsMatchedParametersAndExample(t *testing.T) {
func TestValidateCommand_AcceptsParameters(t *testing.T) {
command := Command{
Name: "ok",
Visibility: VisibilityPublic,
Description: "Do it",
Parameters: "<value>",
Example: "/ok demo",
Handler: okHandler,
}
if err := validateCommand(command); err != nil {
-1
View File
@@ -23,7 +23,6 @@ func New(deps modules.Deps) modules.Module {
Visibility: modules.VisibilityPublic,
Description: "Classic wordle — guess the 5-letter word",
Parameters: "[word]",
Example: "/wordle apple",
Handler: s.handleWordle,
},
{