From 0866ab4ed3e3f87581be78f6cd7f679dd99ff844 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 21 Jul 2026 11:45:20 +0700 Subject: [PATCH] feat(commands): improve discovery and normalize parameters --- AGENTS.md | 7 ++ README.md | 31 +++++++- cmd/server/command_menu.go | 2 +- cmd/server/command_menu_test.go | 73 ++++++++++++++++++- docs/deploy-coolify-selfhosted.md | 6 +- .../260721-1011-telegram-command-discovery.md | 60 +++++++++++++++ internal/modules/coin/coin.go | 8 ++ internal/modules/coin/handlers.go | 8 +- internal/modules/coin/handlers_test.go | 2 +- internal/modules/command_presentation.go | 59 +++++++++++++++ internal/modules/command_presentation_test.go | 59 +++++++++++++++ internal/modules/gold/gold.go | 10 ++- internal/modules/gold/handlers.go | 2 +- .../modules/gold/handlers_validation_test.go | 2 +- internal/modules/lol/lol.go | 2 + internal/modules/loldle/loldle.go | 2 + internal/modules/misc/handlers_test.go | 4 +- internal/modules/misc/misc.go | 8 ++ internal/modules/misc/random_picker.go | 4 +- internal/modules/misc/wheelofnames_command.go | 7 +- internal/modules/module.go | 4 +- internal/modules/stats/views.go | 4 +- internal/modules/stock/handlers.go | 14 ++-- internal/modules/stock/handlers_test.go | 14 ++-- internal/modules/stock/stock.go | 22 +++++- internal/modules/util/handlers_test.go | 6 ++ internal/modules/util/help.go | 17 ++--- internal/modules/util/help_test.go | 31 +++++++- internal/modules/validate.go | 24 +++++- internal/modules/validate_test.go | 41 +++++++++++ internal/modules/wordle/wordle.go | 2 + 31 files changed, 479 insertions(+), 56 deletions(-) create mode 100644 docs/journals/260721-1011-telegram-command-discovery.md create mode 100644 internal/modules/command_presentation.go create mode 100644 internal/modules/command_presentation_test.go diff --git a/AGENTS.md b/AGENTS.md index 51de163..3b6233a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,10 +24,17 @@ Telegram command names are user-facing contracts. When adding, renaming, or deleting commands, update all related surfaces: - module command registration in `internal/modules//` +- command parameter and example 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 +Use lowercase, descriptive parameter names in command metadata and usage text. +Include units or currencies when meaningful (for example, ``), use +`[...]` for optional input, append `...` for remaining free text, and use +parentheses to document structured input (for example, +``). Keep metadata, usage errors, examples, and tests exact. + ## Stats Compatibility The `stats` module persists command usage in the `stats` collection. Command diff --git a/README.md b/README.md index 7f63c78..7fb7163 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,40 @@ Atlas via long polling and an in-process cron scheduler. | `stock` | VN-stocks paper trading | | `gold` | Gold paper trading (opt-in; VNAppMob SJC buy/sell VND/luong) | | `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) | -| `stats` | `/stats` (top commands), `/stats users`, `/stats user `, `/stats cmd ` | +| `stats` | `/stats` (top commands), `/stats users`, `/stats user `, `/stats cmd ` | 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: + +```text + . Buy VN stock at market price. Example: /stock_buy 100 TCB +``` + +`/help` combines the command and parameter syntax, adds the same summary, then +puts the example on the next line in a copyable code block. When `Example` is +omitted, both surfaces use the bare command (for example, `/ping`). + +For future commands, use lowercase descriptive parameter names. Include units +or currencies when they affect meaning (``, ``), use +square brackets for optional input (`[date]`), append `...` when an argument +accepts remaining text (`[target...]`), and describe structured input in +parentheses (``, ``). Keep command +metadata, handler usage text, examples, tests, and this documentation aligned. + ### Stock dividend commands Stock dividends are manual portfolio adjustments: -- `/stock_cash_dividend ` credits a positive whole-VND amount for each pre-event share held. Example: `/stock_cash_dividend 1500 TCB`. -- `/stock_share_dividend ` adds `floor(pre_event_shares × new / owned)` whole shares. Example: `/stock_share_dividend 100:10 TCB`. -- `/stock_dividend ` applies both parts from the same pre-event holding and saves them together. Example: `/stock_dividend 1500 100:10 TCB`. +- `/stock_cash_dividend ` credits a positive whole-VND amount for each pre-event share held. Example: `/stock_cash_dividend 1500 TCB`. +- `/stock_share_dividend ` adds `floor(pre_event_shares × new / owned)` whole shares. Example: `/stock_share_dividend 100:10 TCB`. +- `/stock_dividend ` applies both parts from the same pre-event holding and saves them together. Example: `/stock_dividend 1500 100:10 TCB`. Ratios use `owned:new` exactly as written in the issuer notice. Equivalent unreduced ratios are accepted and the entered ratio is preserved in the reply. diff --git a/cmd/server/command_menu.go b/cmd/server/command_menu.go index 008707b..a6a659a 100644 --- a/cmd/server/command_menu.go +++ b/cmd/server/command_menu.go @@ -28,7 +28,7 @@ func botCommandMenu(reg *modules.Registry) []models.BotCommand { } out = append(out, models.BotCommand{ Command: cmd.Name, - Description: cmd.Description, + Description: cmd.TelegramMenuDescription(), }) } } diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index 4f13022..11e6e3f 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -3,12 +3,15 @@ package main import ( "context" "encoding/json" + "strings" "testing" + "unicode/utf8" "github.com/go-telegram/bot/models" "github.com/tiennm99/miti99bot/internal/modules" "github.com/tiennm99/miti99bot/internal/modules/stock" + moduleutil "github.com/tiennm99/miti99bot/internal/modules/util" "github.com/tiennm99/miti99bot/internal/storage" "github.com/tiennm99/miti99bot/internal/testutil" ) @@ -19,7 +22,7 @@ func TestBotCommandMenu_UsesLoadedPublicCommandsInModuleOrder(t *testing.T) { { Name: "beta", Commands: []modules.Command{ - {Name: "beta_public", Description: "Beta public", Visibility: modules.VisibilityPublic}, + {Name: "beta_public", Description: "Beta public", Parameters: "", Example: "/beta_public demo", Visibility: modules.VisibilityPublic}, {Name: "beta_private", Description: "Beta private", Visibility: modules.VisibilityPrivate}, }, }, @@ -35,8 +38,8 @@ func TestBotCommandMenu_UsesLoadedPublicCommandsInModuleOrder(t *testing.T) { got := botCommandMenu(reg) want := []models.BotCommand{ - {Command: "beta_public", Description: "Beta public"}, - {Command: "alpha_public", Description: "Alpha public"}, + {Command: "beta_public", Description: ". Beta public. Example: /beta_public demo"}, + {Command: "alpha_public", Description: "Alpha public. Example: /alpha_public"}, } if len(got) != len(want) { t.Fatalf("commands = %v, want %v", got, want) @@ -48,6 +51,68 @@ func TestBotCommandMenu_UsesLoadedPublicCommandsInModuleOrder(t *testing.T) { } } +func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { + reg, err := modules.Build(nil, factories(), storage.NewMemoryProvider(), modules.BuildOptions{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + + expectedParameters := map[string]string{ + "coin_price": "", + "coin_topup": "", + "coin_buy": " ", + "coin_sell": " ", + "gold_topup": "", + "gold_buy": "", + "gold_sell": "", + "lol": "[date]", + "loldle": "[champion]", + "random": "", + "stats": "[users | user | cmd ]", + "stock_price": "", + "stock_topup": "", + "stock_buy": " ", + "stock_sell": " ", + "stock_cash_dividend": " ", + "stock_share_dividend": " ", + "stock_dividend": " ", + "trongtruonghop": "[target...]", + "tth": "[target...]", + "wheelofnames": "", + "wordle": "[word]", + } + + menu := botCommandMenu(reg) + if len(menu) != len(reg.PublicCommands()) { + t.Fatalf("menu commands = %d, public commands = %d", len(menu), len(reg.PublicCommands())) + } + for _, command := range reg.PublicCommands() { + if got := command.Parameters; got != expectedParameters[command.Name] { + t.Errorf("/%s parameters = %q, want %q", command.Name, got, expectedParameters[command.Name]) + } + if !strings.HasPrefix(command.ExampleInvocation(), "/"+command.Name) { + t.Errorf("/%s example = %q", command.Name, command.ExampleInvocation()) + } + description := command.TelegramMenuDescription() + if strings.ContainsAny(description, "\r\n") { + t.Errorf("/%s native menu description is multiline: %q", command.Name, description) + } + if utf8.RuneCountInString(description) > telegramCommandDescriptionMaxRunesForTest { + t.Errorf("/%s native menu description exceeds Telegram limit: %d", command.Name, utf8.RuneCountInString(description)) + } + } + + help := moduleutil.RenderHelp(reg) + if utf8.RuneCountInString(help) > telegramMessageMaxRunesForTest { + t.Fatalf("/help source is %d characters, exceeds conservative Telegram limit %d", utf8.RuneCountInString(help), telegramMessageMaxRunesForTest) + } +} + +const ( + telegramCommandDescriptionMaxRunesForTest = 256 + telegramMessageMaxRunesForTest = 4096 +) + func TestBotCommandMenu_StockDividendContracts(t *testing.T) { mod := stock.New(modules.Deps{Store: storage.NewMemoryProvider().Collection("stock")}) mod.Name = "stock" @@ -99,7 +164,7 @@ func TestRegisterCommandMenu_CallsTelegramSetMyCommands(t *testing.T) { if err := json.Unmarshal([]byte(call.Form["commands"]), &cmds); err != nil { t.Fatalf("decode commands form field: %v; raw=%q", err, call.Form["commands"]) } - if len(cmds) != 1 || cmds[0].Command != "demo" || cmds[0].Description != "Demo command" { + if len(cmds) != 1 || cmds[0].Command != "demo" || cmds[0].Description != "Demo command. Example: /demo" { t.Fatalf("commands payload = %+v, want demo command", cmds) } } diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index 57af27f..1cf9029 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -136,7 +136,11 @@ 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. +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, while `/help` can put the example in a +copyable code block. An omitted example defaults to the bare command. ## Operations diff --git a/docs/journals/260721-1011-telegram-command-discovery.md b/docs/journals/260721-1011-telegram-command-discovery.md new file mode 100644 index 0000000..d575011 --- /dev/null +++ b/docs/journals/260721-1011-telegram-command-discovery.md @@ -0,0 +1,60 @@ +# Telegram Command Discovery Journal + +## 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. + +## What Changed + +- Extended the shared command registration with `Parameters` and `Example` + metadata and presentation helpers used by both discovery surfaces. +- Added metadata for all 40 public commands, including the exact stats grammar: + `[users | user | cmd ]`. +- Normalized placeholders to lowercase descriptive names, including meaningful + units or currencies; `[...]` marks optional input, `...` remaining free text, + and parentheses structured input. +- Normalized `/wheelofnames` to `` across metadata, + usage text, and tests; its example remains + `/wheelofnames pizza, sushi, pho`. +- Finalized dividend placeholders as ` `, + ` `, and + ` ` for cash, share, and combined + commands. Examples and parsing remain unchanged. +- Native menu descriptions now combine parameters, summary, and example on one + plain-text line. Registration validation rejects multiline metadata, examples + for another command, and public descriptions over Telegram's 256-character + limit. +- `/help` now renders each invocation and summary together, followed by a + copyable HTML `
` example. Dynamic command fields are HTML-escaped.
+- 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.
+
+## 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.
+- Commands without parameters default their example to the command invocation.
+- 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.
+- Passed: `go test ./...`, including real MongoDB Testcontainers suites.
+- Passed: `go vet ./...`
+- Passed: `go build ./...`
+- Passed: `golangci-lint run`
+
+## Next Steps
+
+- Require parameter and example metadata updates alongside future public
+  command contract changes.
diff --git a/internal/modules/coin/coin.go b/internal/modules/coin/coin.go
index ba4467c..f0f4e94 100644
--- a/internal/modules/coin/coin.go
+++ b/internal/modules/coin/coin.go
@@ -15,24 +15,32 @@ func New(deps modules.Deps) modules.Module {
 				Name:        "coin_price",
 				Visibility:  modules.VisibilityPublic,
 				Description: "Show current crypto price in USD",
+				Parameters:  "",
+				Example:     "/coin_price BTC",
 				Handler:     s.handlePrice,
 			},
 			{
 				Name:        "coin_topup",
 				Visibility:  modules.VisibilityPublic,
 				Description: "Top up USD to your coin account",
+				Parameters:  "",
+				Example:     "/coin_topup 1000",
 				Handler:     s.handleTopup,
 			},
 			{
 				Name:        "coin_buy",
 				Visibility:  modules.VisibilityPublic,
 				Description: "Spend a USD amount to buy coin",
+				Parameters:  " ",
+				Example:     "/coin_buy BTC 10",
 				Handler:     s.handleBuy,
 			},
 			{
 				Name:        "coin_sell",
 				Visibility:  modules.VisibilityPublic,
 				Description: "Sell enough coin to receive a USD amount",
+				Parameters:  " ",
+				Example:     "/coin_sell BTC 10",
 				Handler:     s.handleSell,
 			},
 			{
diff --git a/internal/modules/coin/handlers.go b/internal/modules/coin/handlers.go
index 0d19834..e338585 100644
--- a/internal/modules/coin/handlers.go
+++ b/internal/modules/coin/handlers.go
@@ -20,7 +20,7 @@ var (
 func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Update) error {
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 1 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_price \nExample: /coin_price BTC")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_price \nExample: /coin_price BTC")
 	}
 	coin, err := ResolveCoinSymbol(args[0])
 	if err != nil {
@@ -70,7 +70,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 2 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_buy  \nSpend USD to buy coin.\nAlternative: /coin_buy  \nExample: /coin_buy BTC 10")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_buy  \nSpend USD to buy coin.\nAlternative: /coin_buy  \nExample: /coin_buy BTC 10")
 	}
 	parsed, err := parseCoinValueArgs(args, isSafeUSD, errInvalidUSDAmount)
 	if errors.Is(err, errInvalidUSDAmount) {
@@ -124,7 +124,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 2 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_sell  \nSell enough coin to receive USD.\nAlternative: /coin_sell  \nExample: /coin_sell BTC 10")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_sell  \nSell enough coin to receive USD.\nAlternative: /coin_sell  \nExample: /coin_sell BTC 10")
 	}
 	parsed, err := parseCoinValueArgs(args, isSafeUSD, errInvalidUSDAmount)
 	if errors.Is(err, errInvalidUSDAmount) {
@@ -174,7 +174,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
 func formatInsufficientSellMessage(coin CoinSymbol, requestedUSD, heldQty, priceUSD float64) string {
 	heldQty = normalizeAmount(heldQty)
 	if heldQty == 0 {
-		return "No " + coin.Symbol + " available to sell.\nTry /coin_buy " + coin.Symbol + "  first."
+		return "No " + coin.Symbol + " available to sell.\nTry /coin_buy " + coin.Symbol + "  first."
 	}
 	availableUSD := heldQty * priceUSD
 	return "Not enough " + coin.Symbol + " to sell " + FormatUSD(requestedUSD) + ".\n" +
diff --git a/internal/modules/coin/handlers_test.go b/internal/modules/coin/handlers_test.go
index 6af5663..33fbcdb 100644
--- a/internal/modules/coin/handlers_test.go
+++ b/internal/modules/coin/handlers_test.go
@@ -182,7 +182,7 @@ func TestHandleSellInsufficientCoin(t *testing.T) {
 		t.Fatalf("handleSell: %v", err)
 	}
 	text := rb.LastSent().Text()
-	for _, want := range []string{"No ETH available to sell.", "Try /coin_buy ETH  first."} {
+	for _, want := range []string{"No ETH available to sell.", "Try /coin_buy ETH  first."} {
 		if !strings.Contains(text, want) {
 			t.Fatalf("zero-holdings sell message missing %q in %q", want, text)
 		}
diff --git a/internal/modules/command_presentation.go b/internal/modules/command_presentation.go
new file mode 100644
index 0000000..083f178
--- /dev/null
+++ b/internal/modules/command_presentation.go
@@ -0,0 +1,59 @@
+package modules
+
+import "strings"
+
+// Invocation returns the copy-neutral command syntax shown in /help.
+func (c Command) Invocation() string {
+	invocation := "/" + c.Name
+	if parameters := strings.TrimSpace(c.Parameters); parameters != "" {
+		invocation += " " + parameters
+	}
+	return invocation
+}
+
+// InvocationSentence returns the command syntax with exactly one logical
+// sentence terminator. Variadic syntax ending in "..." is already terminated.
+func (c Command) InvocationSentence() string {
+	return withTerminalPunctuation(c.Invocation())
+}
+
+// ExampleInvocation returns the copyable example, defaulting to the command
+// itself for commands that take no parameters.
+func (c Command) ExampleInvocation() string {
+	if example := strings.TrimSpace(c.Example); example != "" {
+		return example
+	}
+	return "/" + c.Name
+}
+
+// SummarySentence normalizes a command summary to a sentence without
+// duplicating terminal punctuation supplied by the registration.
+func (c Command) SummarySentence() string {
+	return withTerminalPunctuation(c.Description)
+}
+
+// TelegramMenuDescription returns the single-line plain-text description used
+// by setMyCommands. Telegram renders the /command separately and does not
+// support code blocks in this surface.
+func (c Command) TelegramMenuDescription() string {
+	var sb strings.Builder
+	if parameters := strings.TrimSpace(c.Parameters); parameters != "" {
+		sb.WriteString(withTerminalPunctuation(parameters))
+		sb.WriteByte(' ')
+	}
+	sb.WriteString(c.SummarySentence())
+	sb.WriteString(" Example: ")
+	sb.WriteString(c.ExampleInvocation())
+	return sb.String()
+}
+
+func withTerminalPunctuation(value string) string {
+	value = strings.TrimSpace(value)
+	if value == "" {
+		return ""
+	}
+	if strings.HasSuffix(value, ".") || strings.HasSuffix(value, "!") || strings.HasSuffix(value, "?") {
+		return value
+	}
+	return value + "."
+}
diff --git a/internal/modules/command_presentation_test.go b/internal/modules/command_presentation_test.go
new file mode 100644
index 0000000..75ce883
--- /dev/null
+++ b/internal/modules/command_presentation_test.go
@@ -0,0 +1,59 @@
+package modules
+
+import "testing"
+
+func TestCommandPresentation(t *testing.T) {
+	tests := []struct {
+		name       string
+		command    Command
+		invocation string
+		example    string
+		menu       string
+	}{
+		{
+			name: "parameters and explicit example",
+			command: Command{
+				Name:        "stock_buy",
+				Parameters:  " ",
+				Description: "Buy VN stock at market price",
+				Example:     "/stock_buy 100 TCB",
+			},
+			invocation: "/stock_buy  ",
+			example:    "/stock_buy 100 TCB",
+			menu:       " . Buy VN stock at market price. Example: /stock_buy 100 TCB",
+		},
+		{
+			name:       "no parameters uses command as example",
+			command:    Command{Name: "ping", Description: "Health check!"},
+			invocation: "/ping",
+			example:    "/ping",
+			menu:       "Health check! Example: /ping",
+		},
+		{
+			name: "variadic parameters keep ellipsis",
+			command: Command{
+				Name:        "random",
+				Parameters:  "",
+				Description: "Pick one option",
+				Example:     "/random pizza, sushi",
+			},
+			invocation: "/random ",
+			example:    "/random pizza, sushi",
+			menu:       ". Pick one option. Example: /random pizza, sushi",
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(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)
+			}
+		})
+	}
+}
diff --git a/internal/modules/gold/gold.go b/internal/modules/gold/gold.go
index 4e0d6c8..93ee75c 100644
--- a/internal/modules/gold/gold.go
+++ b/internal/modules/gold/gold.go
@@ -20,18 +20,24 @@ func New(deps modules.Deps) modules.Module {
 				Name:        "gold_topup",
 				Visibility:  modules.VisibilityPublic,
 				Description: "Top up VND to your gold account",
+				Parameters:  "",
+				Example:     "/gold_topup 5000000",
 				Handler:     s.handleTopup,
 			},
 			{
 				Name:        "gold_buy",
 				Visibility:  modules.VisibilityPublic,
-				Description: "Buy gold at SJC sell price (luong)",
+				Description: "Buy gold at SJC sell price",
+				Parameters:  "",
+				Example:     "/gold_buy 1",
 				Handler:     s.handleBuy,
 			},
 			{
 				Name:        "gold_sell",
 				Visibility:  modules.VisibilityPublic,
-				Description: "Sell gold at SJC buy price (luong)",
+				Description: "Sell gold at SJC buy price",
+				Parameters:  "",
+				Example:     "/gold_sell 0.5",
 				Handler:     s.handleSell,
 			},
 			{
diff --git a/internal/modules/gold/handlers.go b/internal/modules/gold/handlers.go
index 3a39ca3..df44988 100644
--- a/internal/modules/gold/handlers.go
+++ b/internal/modules/gold/handlers.go
@@ -52,7 +52,7 @@ func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Upda
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 1 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_topup \nExample: /gold_topup 5000000")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_topup \nExample: /gold_topup 5000000")
 	}
 	amount, ok := parsePositiveFinite(args[0])
 	if !ok || !isSafeVND(amount) {
diff --git a/internal/modules/gold/handlers_validation_test.go b/internal/modules/gold/handlers_validation_test.go
index 29b835b..ab2e15f 100644
--- a/internal/modules/gold/handlers_validation_test.go
+++ b/internal/modules/gold/handlers_validation_test.go
@@ -17,7 +17,7 @@ func TestHandlersRejectExtraArgs(t *testing.T) {
 		text string
 		want string
 	}{
-		{name: "topup currency", text: "/gold_topup 100 USD", want: "Usage: /gold_topup "},
+		{name: "topup currency", text: "/gold_topup 100 USD", want: "Usage: /gold_topup "},
 		{name: "buy unit", text: "/gold_buy 1 oz", want: "Usage: /gold_buy "},
 		{name: "sell symbol", text: "/gold_sell 1 SJC", want: "Usage: /gold_sell "},
 	}
diff --git a/internal/modules/lol/lol.go b/internal/modules/lol/lol.go
index 9f7ef74..04009b8 100644
--- a/internal/modules/lol/lol.go
+++ b/internal/modules/lol/lol.go
@@ -26,6 +26,8 @@ func New(deps modules.Deps) modules.Module {
 				Name:        "lol",
 				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,
 			},
 			{
diff --git a/internal/modules/loldle/loldle.go b/internal/modules/loldle/loldle.go
index 330b4a0..e5559f2 100644
--- a/internal/modules/loldle/loldle.go
+++ b/internal/modules/loldle/loldle.go
@@ -20,6 +20,8 @@ func New(deps modules.Deps) modules.Module {
 				Name:        "loldle",
 				Visibility:  modules.VisibilityPublic,
 				Description: "Classic loldle — guess the current champion",
+				Parameters:  "[champion]",
+				Example:     "/loldle Ahri",
 				Handler:     s.handleLoldle,
 			},
 			{
diff --git a/internal/modules/misc/handlers_test.go b/internal/modules/misc/handlers_test.go
index f18a795..b997823 100644
--- a/internal/modules/misc/handlers_test.go
+++ b/internal/modules/misc/handlers_test.go
@@ -142,8 +142,8 @@ func TestWheelOfNames_UsageWhenMissingOptions(t *testing.T) {
 			rb, _ := installMisc(t, 999)
 			rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text))
 
-			if got := rb.LastSent().Text(); got != wheelOfNamesUsage {
-				t.Errorf("wheelofnames reply = %q, want usage %q", got, wheelOfNamesUsage)
+			if got := rb.LastSent().Text(); got != wheelUsage {
+				t.Errorf("wheelofnames reply = %q, want usage %q", got, wheelUsage)
 			}
 		})
 	}
diff --git a/internal/modules/misc/misc.go b/internal/modules/misc/misc.go
index 7c3a95b..79becdd 100644
--- a/internal/modules/misc/misc.go
+++ b/internal/modules/misc/misc.go
@@ -128,10 +128,18 @@ 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
diff --git a/internal/modules/misc/random_picker.go b/internal/modules/misc/random_picker.go
index 48acd91..e59ed57 100644
--- a/internal/modules/misc/random_picker.go
+++ b/internal/modules/misc/random_picker.go
@@ -12,7 +12,7 @@ import (
 	"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
 )
 
-const randomUsage = "Usage: /random , , ..."
+const randomUsage = "Usage: /random "
 
 func splitWheelOptions(arg string) []string {
 	parts := strings.Split(arg, ",")
@@ -30,6 +30,8 @@ func randomCommand() modules.Command {
 		Name:        "random",
 		Visibility:  modules.VisibilityPublic,
 		Description: "Pick one random comma-separated option",
+		Parameters:  "",
+		Example:     "/random pizza, sushi, pho",
 		Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
 			if update.Message == nil {
 				return nil
diff --git a/internal/modules/misc/wheelofnames_command.go b/internal/modules/misc/wheelofnames_command.go
index d42ac57..37f68f8 100644
--- a/internal/modules/misc/wheelofnames_command.go
+++ b/internal/modules/misc/wheelofnames_command.go
@@ -15,7 +15,6 @@ import (
 )
 
 const (
-	wheelOfNamesUsage          = "Usage: /wheelofnames , , ..."
 	wheelFilename              = "wheelofnames.gif"
 	wheelResultCaptionMaxRunes = 900
 )
@@ -25,13 +24,15 @@ func wheelOfNamesCommand() modules.Command {
 		Name:        "wheelofnames",
 		Visibility:  modules.VisibilityPublic,
 		Description: "Pick one comma-separated option with wheel GIF when configured",
+		Parameters:  "",
+		Example:     "/wheelofnames pizza, sushi, pho",
 		Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
 			if update.Message == nil {
 				return nil
 			}
 			options := splitWheelOptions(chathelper.ArgAfterCommand(update.Message.Text))
 			if len(options) == 0 {
-				return chathelper.Reply(ctx, b, update.Message, wheelOfNamesUsage)
+				return chathelper.Reply(ctx, b, update.Message, wheelUsage)
 			}
 			winner := pickWheelOption(options)
 			animation, err := renderWheelOfNamesAnimation(ctx, options, winner)
@@ -63,6 +64,8 @@ func wheelOfNamesCommand() modules.Command {
 	}
 }
 
+const wheelUsage = "Usage: /wheelofnames "
+
 func wheelResultCaption(result string) string {
 	result = truncateWheelResultCaption(result)
 	return `Result: ` + html.EscapeString(result) + ``
diff --git a/internal/modules/module.go b/internal/modules/module.go
index 1c1e7c0..f02f352 100644
--- a/internal/modules/module.go
+++ b/internal/modules/module.go
@@ -38,7 +38,9 @@ type CronHandler func(ctx context.Context, deps Deps) error
 type Command struct {
 	Name        string         // ^[a-z0-9_]{1,32}$ — Telegram BotFather rules
 	Visibility  Visibility     // public/protected/private
-	Description string         // shown in /help (required, non-empty)
+	Description string         // concise summary shown in command discovery (required, non-empty)
+	Parameters  string         // optional syntax after the command, e.g. " "
+	Example     string         // optional full invocation; defaults to "/" + Name
 	Handler     CommandHandler // required
 }
 
diff --git a/internal/modules/stats/views.go b/internal/modules/stats/views.go
index 07762e7..840eacb 100644
--- a/internal/modules/stats/views.go
+++ b/internal/modules/stats/views.go
@@ -20,7 +20,7 @@ const statsUsage = `Usage:
 /stats
 /stats users
 /stats user 
-/stats cmd `
+/stats cmd `
 
 type row struct {
 	display string
@@ -32,6 +32,8 @@ func statsCommand(c *counter) modules.Command {
 		Name:        "stats",
 		Visibility:  modules.VisibilityPublic,
 		Description: "Show command usage statistics",
+		Parameters:  "[users | user  | cmd ]",
+		Example:     "/stats user alice",
 		Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
 			if update.Message == nil {
 				return nil
diff --git a/internal/modules/stock/handlers.go b/internal/modules/stock/handlers.go
index 15b8c91..d06056b 100644
--- a/internal/modules/stock/handlers.go
+++ b/internal/modules/stock/handlers.go
@@ -79,7 +79,7 @@ func parsePositiveFinite(raw string) (float64, bool) {
 func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Update) error {
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 1 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_price \nExample: /stock_price TCB")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_price \nExample: /stock_price TCB")
 	}
 	symbol, err := normalizeStockSymbol(args[0])
 	if err != nil {
@@ -109,7 +109,7 @@ func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Upda
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 1 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_topup \nExample: /stock_topup 5000000")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_topup \nExample: /stock_topup 5000000")
 	}
 	amount, ok := parsePositiveFinite(args[0])
 	if !ok {
@@ -141,7 +141,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 2 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_buy  \nExample: /stock_buy 100 TCB")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_buy  \nExample: /stock_buy 100 TCB")
 	}
 	qty, err := strconv.ParseInt(args[0], 10, 64)
 	if err != nil || qty <= 0 {
@@ -198,7 +198,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 2 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_sell  \nExample: /stock_sell 100 TCB")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_sell  \nExample: /stock_sell 100 TCB")
 	}
 	qty, err := strconv.ParseInt(args[0], 10, 64)
 	if err != nil || qty <= 0 {
@@ -258,7 +258,7 @@ func (s *state) handleCashDividend(ctx context.Context, b *bot.Bot, update *mode
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 2 {
 		return chathelper.Reply(ctx, b, update.Message,
-			"Usage: /stock_cash_dividend  \nExample: /stock_cash_dividend 1500 TCB")
+			"Usage: /stock_cash_dividend  \nExample: /stock_cash_dividend 1500 TCB")
 	}
 	vndPerShare, ok := parsePositiveWhole(args[0])
 	if !ok {
@@ -314,7 +314,7 @@ func (s *state) handleShareDividend(ctx context.Context, b *bot.Bot, update *mod
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 2 {
 		return chathelper.Reply(ctx, b, update.Message,
-			"Usage: /stock_share_dividend  \nExample: /stock_share_dividend 100:10 TCB")
+			"Usage: /stock_share_dividend  \nExample: /stock_share_dividend 100:10 TCB")
 	}
 	ratio, ok := parseShareRatio(args[0])
 	if !ok {
@@ -374,7 +374,7 @@ func (s *state) handleDividend(ctx context.Context, b *bot.Bot, update *models.U
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 3 {
 		return chathelper.Reply(ctx, b, update.Message,
-			"Usage: /stock_dividend   \nExample: /stock_dividend 1500 100:10 TCB")
+			"Usage: /stock_dividend   \nExample: /stock_dividend 1500 100:10 TCB")
 	}
 	vndPerShare, ok := parsePositiveWhole(args[0])
 	if !ok {
diff --git a/internal/modules/stock/handlers_test.go b/internal/modules/stock/handlers_test.go
index a81a274..59b19a1 100644
--- a/internal/modules/stock/handlers_test.go
+++ b/internal/modules/stock/handlers_test.go
@@ -72,7 +72,7 @@ func TestHandlePriceUsage(t *testing.T) {
 	if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_price")); err != nil {
 		t.Fatalf("handlePrice: %v", err)
 	}
-	rb.AssertSentText(t, "Usage: /stock_price ")
+	rb.AssertSentText(t, "Usage: /stock_price ")
 }
 
 func TestMutableHandlersRejectExtraArgs(t *testing.T) {
@@ -94,7 +94,7 @@ func TestMutableHandlersRejectExtraArgs(t *testing.T) {
 			run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error {
 				return s.handleTopup(ctx, rb.Bot, upd)
 			},
-			want: "Usage: /stock_topup ",
+			want: "Usage: /stock_topup ",
 		},
 		{
 			name: "buy",
@@ -102,7 +102,7 @@ func TestMutableHandlersRejectExtraArgs(t *testing.T) {
 			run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error {
 				return s.handleBuy(ctx, rb.Bot, upd)
 			},
-			want: "Usage: /stock_buy  ",
+			want: "Usage: /stock_buy  ",
 		},
 		{
 			name: "sell",
@@ -110,7 +110,7 @@ func TestMutableHandlersRejectExtraArgs(t *testing.T) {
 			run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error {
 				return s.handleSell(ctx, rb.Bot, upd)
 			},
-			want: "Usage: /stock_sell  ",
+			want: "Usage: /stock_sell  ",
 		},
 		{
 			name: "cash dividend",
@@ -118,7 +118,7 @@ func TestMutableHandlersRejectExtraArgs(t *testing.T) {
 			run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error {
 				return s.handleCashDividend(ctx, rb.Bot, upd)
 			},
-			want: "Usage: /stock_cash_dividend  ",
+			want: "Usage: /stock_cash_dividend  ",
 		},
 		{
 			name: "share dividend",
@@ -126,7 +126,7 @@ func TestMutableHandlersRejectExtraArgs(t *testing.T) {
 			run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error {
 				return s.handleShareDividend(ctx, rb.Bot, upd)
 			},
-			want: "Usage: /stock_share_dividend  ",
+			want: "Usage: /stock_share_dividend  ",
 		},
 		{
 			name: "dividend",
@@ -134,7 +134,7 @@ func TestMutableHandlersRejectExtraArgs(t *testing.T) {
 			run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error {
 				return s.handleDividend(ctx, rb.Bot, upd)
 			},
-			want: "Usage: /stock_dividend   ",
+			want: "Usage: /stock_dividend   ",
 		},
 	}
 	for _, tc := range cases {
diff --git a/internal/modules/stock/stock.go b/internal/modules/stock/stock.go
index 782b693..c010019 100644
--- a/internal/modules/stock/stock.go
+++ b/internal/modules/stock/stock.go
@@ -14,42 +14,56 @@ func New(deps modules.Deps) modules.Module {
 				Name:        "stock_price",
 				Visibility:  modules.VisibilityPublic,
 				Description: "Show current VN stock price",
+				Parameters:  "",
+				Example:     "/stock_price TCB",
 				Handler:     s.handlePrice,
 			},
 			{
 				Name:        "stock_topup",
 				Visibility:  modules.VisibilityPublic,
 				Description: "Top up VND to your stock account",
+				Parameters:  "",
+				Example:     "/stock_topup 5000000",
 				Handler:     s.handleTopup,
 			},
 			{
 				Name:        "stock_buy",
 				Visibility:  modules.VisibilityPublic,
-				Description: "Buy VN stock at market price (qty TICKER)",
+				Description: "Buy VN stock at market price",
+				Parameters:  " ",
+				Example:     "/stock_buy 100 TCB",
 				Handler:     s.handleBuy,
 			},
 			{
 				Name:        "stock_sell",
 				Visibility:  modules.VisibilityPublic,
-				Description: "Sell VN stock back to VND (qty TICKER)",
+				Description: "Sell VN stock back to VND",
+				Parameters:  " ",
+				Example:     "/stock_sell 100 TCB",
 				Handler:     s.handleSell,
 			},
 			{
 				Name:        "stock_cash_dividend",
 				Visibility:  modules.VisibilityPublic,
-				Description: "Record cash dividend (VND/share TICKER)",
+				Description: "Record cash dividend",
+				Parameters:  " ",
+				Example:     "/stock_cash_dividend 1500 TCB",
 				Handler:     s.handleCashDividend,
 			},
 			{
 				Name:        "stock_share_dividend",
 				Visibility:  modules.VisibilityPublic,
-				Description: "Record share dividend (owned:new TICKER)",
+				Description: "Record share dividend",
+				Parameters:  " ",
+				Example:     "/stock_share_dividend 100:10 TCB",
 				Handler:     s.handleShareDividend,
 			},
 			{
 				Name:        "stock_dividend",
 				Visibility:  modules.VisibilityPublic,
 				Description: "Record cash and share dividend",
+				Parameters:  "  ",
+				Example:     "/stock_dividend 1500 100:10 TCB",
 				Handler:     s.handleDividend,
 			},
 			{
diff --git a/internal/modules/util/handlers_test.go b/internal/modules/util/handlers_test.go
index ff11877..6ede62a 100644
--- a/internal/modules/util/handlers_test.go
+++ b/internal/modules/util/handlers_test.go
@@ -87,6 +87,12 @@ func TestHelp_RendersHTML(t *testing.T) {
 	if !strings.Contains(got.Text(), "util") {
 		t.Errorf("/help body missing util section; got %q", got.Text())
 	}
+	if !strings.Contains(got.Text(), "/help. Show all available commands.") {
+		t.Errorf("/help body missing formatted command summary; got %q", got.Text())
+	}
+	if !strings.Contains(got.Text(), "
/help
") { + t.Errorf("/help body missing copyable example; got %q", got.Text()) + } } func TestStickerID_NoReply_ShowsUsage(t *testing.T) { diff --git a/internal/modules/util/help.go b/internal/modules/util/help.go index 86f8537..a608073 100644 --- a/internal/modules/util/help.go +++ b/internal/modules/util/help.go @@ -32,16 +32,10 @@ func RenderHelp(reg *modules.Registry) string { return "no commands registered\n\n" + supportFooter } - type entry struct { - name string - description string - } - byModule := make(map[string][]entry, len(reg.Modules)) + byModule := make(map[string][]modules.Command, len(reg.Modules)) for _, c := range reg.PublicCommands() { - byModule[ownerOf(reg, c.Name)] = append(byModule[ownerOf(reg, c.Name)], entry{ - name: c.Name, description: c.Description, - }) + byModule[ownerOf(reg, c.Name)] = append(byModule[ownerOf(reg, c.Name)], c) } var sections []string @@ -52,8 +46,11 @@ func RenderHelp(reg *modules.Registry) string { } var sb strings.Builder fmt.Fprintf(&sb, "%s", html.EscapeString(mod.Name)) - for _, e := range es { - fmt.Fprintf(&sb, "\n/%s — %s", e.name, html.EscapeString(e.description)) + for _, command := range es { + fmt.Fprintf(&sb, "\n%s %s\n
%s
", + html.EscapeString(command.InvocationSentence()), + html.EscapeString(command.SummarySentence()), + html.EscapeString(command.ExampleInvocation())) } sections = append(sections, sb.String()) } diff --git a/internal/modules/util/help_test.go b/internal/modules/util/help_test.go index 3573540..b61f828 100644 --- a/internal/modules/util/help_test.go +++ b/internal/modules/util/help_test.go @@ -51,7 +51,8 @@ func TestRenderHelp_GroupsByModuleAndSkipsNonPublic(t *testing.T) { for _, want := range []string{ "alpha", "beta", - "/a_pub — alpha public", + "/a_pub. alpha public.", + "
/a_pub
", // HTML in user descriptions must be escaped. "beta <i>desc</i>", // Locks html.EscapeString contract: & → &, " → ". @@ -71,6 +72,34 @@ func TestRenderHelp_GroupsByModuleAndSkipsNonPublic(t *testing.T) { } } +func TestRenderHelp_ShowsParametersAndCopyableExample(t *testing.T) { + command := modules.Command{ + Name: "buy", + Visibility: modules.VisibilityPublic, + Description: "Buy & hold", + Parameters: " ", + Example: "/buy 100 TCB", + Handler: helpTestNoop, + } + reg, err := modules.Build( + []string{"stock"}, + map[string]modules.Factory{"stock": fakeFactory("stock", []modules.Command{command})}, + storage.NewMemoryProvider(), + modules.BuildOptions{}, + ) + if err != nil { + t.Fatalf("Build: %v", err) + } + + out := util.RenderHelp(reg) + if !strings.Contains(out, "/buy <quantity> <ticker>. Buy & hold.") { + t.Fatalf("help missing formatted invocation and summary:\n%s", out) + } + if !strings.Contains(out, "
/buy 100 TCB
") { + t.Fatalf("help missing copyable example:\n%s", out) + } +} + func TestRenderHelp_ModuleOrderMatchesEnvOrder(t *testing.T) { cmd := func(name string) modules.Command { return modules.Command{Name: name, Visibility: modules.VisibilityPublic, Description: name, Handler: helpTestNoop} diff --git a/internal/modules/validate.go b/internal/modules/validate.go index fcdc076..0b0881f 100644 --- a/internal/modules/validate.go +++ b/internal/modules/validate.go @@ -3,10 +3,14 @@ package modules import ( "fmt" "regexp" + "strings" + "unicode/utf8" ) var commandNameRe = regexp.MustCompile(`^[a-z0-9_]{1,32}$`) +const telegramCommandDescriptionMaxRunes = 256 + func validateCommand(c Command) error { if !commandNameRe.MatchString(c.Name) { return fmt.Errorf("command name %q must match %s", c.Name, commandNameRe) @@ -16,9 +20,27 @@ func validateCommand(c Command) error { default: return fmt.Errorf("command %q: unknown visibility %d", c.Name, c.Visibility) } - if c.Description == "" { + if strings.TrimSpace(c.Description) == "" { return fmt.Errorf("command %q: description is required", c.Name) } + if strings.ContainsAny(c.Description, "\r\n") { + return fmt.Errorf("command %q: description must be single-line", c.Name) + } + 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) + } + if example := strings.TrimSpace(c.Example); 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) + } if c.Handler == nil { return fmt.Errorf("command %q: handler is nil", c.Name) } diff --git a/internal/modules/validate_test.go b/internal/modules/validate_test.go index 9e78b2b..e89fef4 100644 --- a/internal/modules/validate_test.go +++ b/internal/modules/validate_test.go @@ -2,6 +2,7 @@ package modules import ( "context" + "strings" "testing" "github.com/go-telegram/bot" @@ -29,6 +30,46 @@ func TestValidateCommand_RejectsBadNames(t *testing.T) { } } +func TestValidateCommand_RejectsInvalidPresentationMetadata(t *testing.T) { + tests := []struct { + name string + command Command + }{ + { + name: "blank description", + command: Command{Name: "ok", Visibility: VisibilityPublic, Description: " ", Handler: okHandler}, + }, + { + name: "multiline description", + command: Command{Name: "ok", Visibility: VisibilityPublic, Description: "one\ntwo", Handler: okHandler}, + }, + { + name: "multiline parameters", + command: Command{Name: "ok", Visibility: VisibilityPublic, Description: "d", Parameters: "\n", 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: "native description too long", + command: Command{Name: "ok", Visibility: VisibilityPublic, Description: strings.Repeat("x", 250), Handler: okHandler}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := validateCommand(tc.command); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + func TestValidateCommand_AcceptsLegalNames(t *testing.T) { for _, name := range []string{"ping", "do_it", "a", "abc123", "x_1_y"} { if err := validateCommand(Command{Name: name, Visibility: VisibilityPublic, Description: "d", Handler: okHandler}); err != nil { diff --git a/internal/modules/wordle/wordle.go b/internal/modules/wordle/wordle.go index fc020b0..348fe45 100644 --- a/internal/modules/wordle/wordle.go +++ b/internal/modules/wordle/wordle.go @@ -22,6 +22,8 @@ func New(deps modules.Deps) modules.Module { Name: "wordle", Visibility: modules.VisibilityPublic, Description: "Classic wordle — guess the 5-letter word", + Parameters: "[word]", + Example: "/wordle apple", Handler: s.handleWordle, }, {