mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-12 04:20:49 +00:00
/alias <name> saves a replied message under a name and /insert <name> sends it back; /aliases lists every name and /unalias deletes one. Every Telegram format is supported — sticker, photo, GIF, video, video note, audio, voice, document, plain text — and each is kept as the file_id Telegram already issued, so nothing is downloaded and an alias survives redeploys. The namespace is global and the last assignment wins, matching the shared sticker pack; /unalias is open to anyone for the same reason. A saved name also works as its own command: /cheer rather than /insert cheer. This needs two new seams in the module contract. Module.Fallback handles a /command no module registered, and the dispatcher installs it after every Command — the bot library returns the first matching handler, so code always beats a name resolved at runtime, including an alias that shares a command added in a later build. /alias refuses a name already in the registry for the same reason, since such an alias would only reach /insert. An unknown command stays silent: the fallback sees every unrecognised /foo in every chat, so replying would make typos noisy and would confirm which names exist. Module.Inline answers inline-mode queries — "@botname <prefix>" from any chat, filtered by prefix and capped at Telegram's 50 results. Each result is a cached inline type carrying the stored file_id, so the picker renders real previews without an upload. Video notes are omitted because Telegram defines no InlineQueryResultCachedVideoNote and substituting a plain video would change what was saved. Inline mode must be enabled in BotFather before Telegram delivers these updates. Both slots are single-occupancy with conflict detection at Build. Auth.Permits learns the inline sender so a gated inline handler would not deny everyone. Build's command indexing and slot claiming move into addCommands/addSingletons, keeping it under the project's cyclomatic cap. Also restores the sticker module: /addsticker moves back out of util, which has no store, into internal/modules/sticker as its only command.
202 lines
7.0 KiB
Go
202 lines
7.0 KiB
Go
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"
|
|
)
|
|
|
|
func TestBotCommandMenu_UsesLoadedPublicCommandsInModuleOrder(t *testing.T) {
|
|
reg := &modules.Registry{
|
|
Modules: []modules.Module{
|
|
{
|
|
Name: "beta",
|
|
Commands: []modules.Command{
|
|
{Name: "beta_public", Description: "Beta public", Parameters: "<value>", Visibility: modules.VisibilityPublic},
|
|
{Name: "beta_private", Description: "Beta private", Visibility: modules.VisibilityPrivate},
|
|
},
|
|
},
|
|
{
|
|
Name: "alpha",
|
|
Commands: []modules.Command{
|
|
{Name: "alpha_public", Description: "Alpha public", Visibility: modules.VisibilityPublic},
|
|
{Name: "alpha_protected", Description: "Alpha protected", Visibility: modules.VisibilityProtected},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
got := botCommandMenu(reg)
|
|
want := []models.BotCommand{
|
|
{Command: "beta_public", Description: "<value>. Beta public."},
|
|
{Command: "alpha_public", Description: "Alpha public."},
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("commands = %v, want %v", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("commands[%d] = %+v, want %+v", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
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{
|
|
"amlich": "[date]",
|
|
"duonglich": "<date> [nhuan]",
|
|
"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]",
|
|
"monkeyd_crawl": "<url> [font_size]",
|
|
"monkeyd_tags": "<url>",
|
|
"random": "<option,...>",
|
|
"addsticker": "[emoji...]",
|
|
"alias": "<name>",
|
|
"aliases": "",
|
|
"unalias": "<name>",
|
|
"insert": "<name>",
|
|
"stats": "[users | user <username> | cmd <command_name>]",
|
|
"stock_events": "<ticker> [days]",
|
|
"stock_info": "<ticker>",
|
|
"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>",
|
|
"trongtruonghop": "[target...]",
|
|
"tth": "[target...]",
|
|
"wheelofnames": "<option,...>",
|
|
"wordle": "[word]",
|
|
}
|
|
|
|
menu := botCommandMenu(reg)
|
|
if len(menu) != len(reg.PublicCommands()) {
|
|
t.Fatalf("menu commands = %d, public commands = %d", len(menu), len(reg.PublicCommands()))
|
|
}
|
|
// This map lists the commands whose parameter strings are pinned; commands
|
|
// absent from it are not asserted (the lookup yields "" for them).
|
|
seen := map[string]bool{}
|
|
for _, command := range reg.PublicCommands() {
|
|
seen[command.Name] = true
|
|
if got := command.Parameters; got != expectedParameters[command.Name] {
|
|
t.Errorf("/%s parameters = %q, want %q", command.Name, got, expectedParameters[command.Name])
|
|
}
|
|
description := command.TelegramMenuDescription()
|
|
if strings.Contains(description, "Eg:") {
|
|
t.Errorf("/%s native menu description contains an example: %q", command.Name, description)
|
|
}
|
|
if strings.ContainsAny(description, "\r\n") {
|
|
t.Errorf("/%s native menu description is multiline: %q", command.Name, description)
|
|
}
|
|
if utf8.RuneCountInString(description) > telegramCommandDescriptionMaxRunesForTest {
|
|
t.Errorf("/%s native menu description exceeds Telegram limit: %d", command.Name, utf8.RuneCountInString(description))
|
|
}
|
|
}
|
|
|
|
// Every expectation must correspond to a registered command.
|
|
//
|
|
// Without this, an entry whose command stopped being registered simply
|
|
// stopped being checked: the forward loop only visits commands that exist,
|
|
// so removing a whole module from factories() left the suite green. That is
|
|
// also what makes the parameterless entries above load-bearing rather than
|
|
// decorative — "" == "" asserts nothing on its own, but the command having
|
|
// to exist at all does.
|
|
for name := range expectedParameters {
|
|
if !seen[name] {
|
|
t.Errorf("/%s is expected but not registered — a module dropped out of factories()", name)
|
|
}
|
|
}
|
|
|
|
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"
|
|
got := botCommandMenu(&modules.Registry{Modules: []modules.Module{mod}})
|
|
|
|
commands := make(map[string]string, len(got))
|
|
for _, command := range got {
|
|
commands[command.Command] = command.Description
|
|
if len(command.Description) > 256 {
|
|
t.Fatalf("description for %s exceeds Telegram limit", command.Command)
|
|
}
|
|
}
|
|
for _, name := range []string{"stock_cash_dividend", "stock_share_dividend"} {
|
|
if commands[name] == "" {
|
|
t.Fatalf("stock menu missing %s: %v", name, commands)
|
|
}
|
|
}
|
|
if _, exists := commands["stock_dividend"]; exists {
|
|
t.Fatalf("retired stock_dividend remains in public menu: %v", commands)
|
|
}
|
|
if _, exists := commands["stock_bonus"]; exists {
|
|
t.Fatalf("stock_bonus remains in public menu: %v", commands)
|
|
}
|
|
}
|
|
|
|
func TestRegisterCommandMenu_CallsTelegramSetMyCommands(t *testing.T) {
|
|
reg := &modules.Registry{
|
|
Modules: []modules.Module{{
|
|
Name: "demo",
|
|
Commands: []modules.Command{{
|
|
Name: "demo",
|
|
Description: "Demo command",
|
|
Visibility: modules.VisibilityPublic,
|
|
}},
|
|
}},
|
|
}
|
|
rb := testutil.NewRecordingBot(t)
|
|
|
|
n, err := registerCommandMenu(context.Background(), rb.Bot, reg)
|
|
if err != nil {
|
|
t.Fatalf("registerCommandMenu: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("registered count = %d, want 1", n)
|
|
}
|
|
|
|
call := rb.LastSent()
|
|
if call.Method != "setMyCommands" {
|
|
t.Fatalf("method = %q, want setMyCommands", call.Method)
|
|
}
|
|
var cmds []models.BotCommand
|
|
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." {
|
|
t.Fatalf("commands payload = %+v, want demo command", cmds)
|
|
}
|
|
}
|