feat(commands): improve discovery and normalize parameters

This commit is contained in:
2026-07-21 11:45:20 +07:00
parent 55c4520673
commit 0866ab4ed3
31 changed files with 479 additions and 56 deletions
+7
View File
@@ -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/<module>/`
- 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, `<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.
## Stats Compatibility
The `stats` module persists command usage in the `stats` collection. Command
+27 -4
View File
@@ -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 <username>`, `/stats cmd <command>` |
| `stats` | `/stats` (top commands), `/stats users`, `/stats user <username>`, `/stats cmd <command_name>` |
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
<quantity> <ticker>. 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 (`<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.
### Stock dividend commands
Stock dividends are manual portfolio adjustments:
- `/stock_cash_dividend <vnd_per_share> <TICKER>` credits a positive whole-VND amount for each pre-event share held. Example: `/stock_cash_dividend 1500 TCB`.
- `/stock_share_dividend <owned:new> <TICKER>` adds `floor(pre_event_shares × new / owned)` whole shares. Example: `/stock_share_dividend 100:10 TCB`.
- `/stock_dividend <vnd_per_share> <owned:new> <TICKER>` applies both parts from the same pre-event holding and saves them together. Example: `/stock_dividend 1500 100:10 TCB`.
- `/stock_cash_dividend <vnd_per_share> <ticker>` credits a positive whole-VND amount for each pre-event share held. Example: `/stock_cash_dividend 1500 TCB`.
- `/stock_share_dividend <ratio(owned:new)> <ticker>` adds `floor(pre_event_shares × new / owned)` whole shares. Example: `/stock_share_dividend 100:10 TCB`.
- `/stock_dividend <vnd_per_share> <ratio(owned:new)> <ticker>` 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.
+1 -1
View File
@@ -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(),
})
}
}
+69 -4
View File
@@ -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: "<value>", 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: "<value>. 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>",
"coin_topup": "<usd_amount>",
"coin_buy": "<coin> <usd_to_spend>",
"coin_sell": "<coin> <usd_to_receive>",
"gold_topup": "<vnd_amount>",
"gold_buy": "<luong>",
"gold_sell": "<luong>",
"lol": "[date]",
"loldle": "[champion]",
"random": "<options(comma-separated)>",
"stats": "[users | user <username> | cmd <command_name>]",
"stock_price": "<ticker>",
"stock_topup": "<vnd_amount>",
"stock_buy": "<quantity> <ticker>",
"stock_sell": "<quantity> <ticker>",
"stock_cash_dividend": "<vnd_per_share> <ticker>",
"stock_share_dividend": "<ratio(owned:new)> <ticker>",
"stock_dividend": "<vnd_per_share> <ratio(owned:new)> <ticker>",
"trongtruonghop": "[target...]",
"tth": "[target...]",
"wheelofnames": "<options(comma-separated)>",
"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)
}
}
+5 -1
View File
@@ -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
@@ -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 <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`.
- 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 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 `<pre>` 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.
+8
View File
@@ -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: "<coin>",
Example: "/coin_price BTC",
Handler: s.handlePrice,
},
{
Name: "coin_topup",
Visibility: modules.VisibilityPublic,
Description: "Top up USD to your coin account",
Parameters: "<usd_amount>",
Example: "/coin_topup 1000",
Handler: s.handleTopup,
},
{
Name: "coin_buy",
Visibility: modules.VisibilityPublic,
Description: "Spend a USD amount to buy coin",
Parameters: "<coin> <usd_to_spend>",
Example: "/coin_buy BTC 10",
Handler: s.handleBuy,
},
{
Name: "coin_sell",
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,
},
{
+4 -4
View File
@@ -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 <COIN>\nExample: /coin_price BTC")
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_price <coin>\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 <COIN> <usd_amount>\nSpend USD to buy coin.\nAlternative: /coin_buy <usd_amount> <COIN>\nExample: /coin_buy BTC 10")
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_buy <coin> <usd_to_spend>\nSpend USD to buy coin.\nAlternative: /coin_buy <usd_to_spend> <coin>\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 <COIN> <usd_amount>\nSell enough coin to receive USD.\nAlternative: /coin_sell <usd_amount> <COIN>\nExample: /coin_sell BTC 10")
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_sell <coin> <usd_to_receive>\nSell enough coin to receive USD.\nAlternative: /coin_sell <usd_to_receive> <coin>\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 + " <usd_amount> first."
return "No " + coin.Symbol + " available to sell.\nTry /coin_buy " + coin.Symbol + " <usd_to_spend> first."
}
availableUSD := heldQty * priceUSD
return "Not enough " + coin.Symbol + " to sell " + FormatUSD(requestedUSD) + ".\n" +
+1 -1
View File
@@ -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 <usd_amount> first."} {
for _, want := range []string{"No ETH available to sell.", "Try /coin_buy ETH <usd_to_spend> first."} {
if !strings.Contains(text, want) {
t.Fatalf("zero-holdings sell message missing %q in %q", want, text)
}
+59
View File
@@ -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 + "."
}
@@ -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: "<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. 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: "<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. 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)
}
})
}
}
+8 -2
View File
@@ -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: "<vnd_amount>",
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: "<luong>",
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: "<luong>",
Example: "/gold_sell 0.5",
Handler: s.handleSell,
},
{
+1 -1
View File
@@ -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 <amount>\nExample: /gold_topup 5000000")
return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_topup <vnd_amount>\nExample: /gold_topup 5000000")
}
amount, ok := parsePositiveFinite(args[0])
if !ok || !isSafeVND(amount) {
@@ -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 <amount>"},
{name: "topup currency", text: "/gold_topup 100 USD", want: "Usage: /gold_topup <vnd_amount>"},
{name: "buy unit", text: "/gold_buy 1 oz", want: "Usage: /gold_buy <luong>"},
{name: "sell symbol", text: "/gold_sell 1 SJC", want: "Usage: /gold_sell <luong>"},
}
+2
View File
@@ -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,
},
{
+2
View File
@@ -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,
},
{
+2 -2
View File
@@ -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)
}
})
}
+8
View File
@@ -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
+3 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
)
const randomUsage = "Usage: /random <option1>, <option2>, ..."
const randomUsage = "Usage: /random <options(comma-separated)>"
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: "<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
@@ -15,7 +15,6 @@ import (
)
const (
wheelOfNamesUsage = "Usage: /wheelofnames <option1>, <option2>, ..."
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: "<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
}
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 <options(comma-separated)>"
func wheelResultCaption(result string) string {
result = truncateWheelResultCaption(result)
return `Result: <span class="tg-spoiler">` + html.EscapeString(result) + `</span>`
+3 -1
View File
@@ -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. "<quantity> <ticker>"
Example string // optional full invocation; defaults to "/" + Name
Handler CommandHandler // required
}
+3 -1
View File
@@ -20,7 +20,7 @@ const statsUsage = `Usage:
/stats
/stats users
/stats user <username>
/stats cmd <name>`
/stats cmd <command_name>`
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 <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 -7
View File
@@ -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 <TICKER>\nExample: /stock_price TCB")
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_price <ticker>\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 <amount>\nExample: /stock_topup 5000000")
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_topup <vnd_amount>\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 <qty> <TICKER>\nExample: /stock_buy 100 TCB")
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_buy <quantity> <ticker>\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 <qty> <TICKER>\nExample: /stock_sell 100 TCB")
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_sell <quantity> <ticker>\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 <vnd_per_share> <TICKER>\nExample: /stock_cash_dividend 1500 TCB")
"Usage: /stock_cash_dividend <vnd_per_share> <ticker>\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 <owned:new> <TICKER>\nExample: /stock_share_dividend 100:10 TCB")
"Usage: /stock_share_dividend <ratio(owned:new)> <ticker>\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 <vnd_per_share> <owned:new> <TICKER>\nExample: /stock_dividend 1500 100:10 TCB")
"Usage: /stock_dividend <vnd_per_share> <ratio(owned:new)> <ticker>\nExample: /stock_dividend 1500 100:10 TCB")
}
vndPerShare, ok := parsePositiveWhole(args[0])
if !ok {
+7 -7
View File
@@ -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 <TICKER>")
rb.AssertSentText(t, "Usage: /stock_price <ticker>")
}
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 <amount>",
want: "Usage: /stock_topup <vnd_amount>",
},
{
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 <qty> <TICKER>",
want: "Usage: /stock_buy <quantity> <ticker>",
},
{
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 <qty> <TICKER>",
want: "Usage: /stock_sell <quantity> <ticker>",
},
{
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 <vnd_per_share> <TICKER>",
want: "Usage: /stock_cash_dividend <vnd_per_share> <ticker>",
},
{
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 <owned:new> <TICKER>",
want: "Usage: /stock_share_dividend <ratio(owned:new)> <ticker>",
},
{
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 <vnd_per_share> <owned:new> <TICKER>",
want: "Usage: /stock_dividend <vnd_per_share> <ratio(owned:new)> <ticker>",
},
}
for _, tc := range cases {
+18 -4
View File
@@ -14,42 +14,56 @@ func New(deps modules.Deps) modules.Module {
Name: "stock_price",
Visibility: modules.VisibilityPublic,
Description: "Show current VN stock price",
Parameters: "<ticker>",
Example: "/stock_price TCB",
Handler: s.handlePrice,
},
{
Name: "stock_topup",
Visibility: modules.VisibilityPublic,
Description: "Top up VND to your stock account",
Parameters: "<vnd_amount>",
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: "<quantity> <ticker>",
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: "<quantity> <ticker>",
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: "<vnd_per_share> <ticker>",
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: "<ratio(owned:new)> <ticker>",
Example: "/stock_share_dividend 100:10 TCB",
Handler: s.handleShareDividend,
},
{
Name: "stock_dividend",
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,
},
{
+6
View File
@@ -87,6 +87,12 @@ func TestHelp_RendersHTML(t *testing.T) {
if !strings.Contains(got.Text(), "<b>util</b>") {
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(), "<pre>/help</pre>") {
t.Errorf("/help body missing copyable example; got %q", got.Text())
}
}
func TestStickerID_NoReply_ShowsUsage(t *testing.T) {
+7 -10
View File
@@ -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, "<b>%s</b>", 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<pre>%s</pre>",
html.EscapeString(command.InvocationSentence()),
html.EscapeString(command.SummarySentence()),
html.EscapeString(command.ExampleInvocation()))
}
sections = append(sections, sb.String())
}
+30 -1
View File
@@ -51,7 +51,8 @@ func TestRenderHelp_GroupsByModuleAndSkipsNonPublic(t *testing.T) {
for _, want := range []string{
"<b>alpha</b>",
"<b>beta</b>",
"/a_pub alpha public",
"/a_pub. alpha public.",
"<pre>/a_pub</pre>",
// HTML in user descriptions must be escaped.
"beta &lt;i&gt;desc&lt;/i&gt;",
// Locks html.EscapeString contract: & → &amp;, " → &#34;.
@@ -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: "<quantity> <ticker>",
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 &lt;quantity&gt; &lt;ticker&gt;. Buy &amp; hold.") {
t.Fatalf("help missing formatted invocation and summary:\n%s", out)
}
if !strings.Contains(out, "<pre>/buy 100 TCB</pre>") {
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}
+23 -1
View File
@@ -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)
}
+41
View File
@@ -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: "<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: "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 {
+2
View File
@@ -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,
},
{