mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-08 20:20:17 +00:00
feat(commands): improve discovery and normalize parameters
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>"},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 <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: "<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 <quantity> <ticker>. Buy & 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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user