Files
miti99bot/internal/modules/alias/fallback_test.go
T
tiennm99 0260ef7fb4 feat(alias): add a shared alias dictionary invocable as a bare command
/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.
2026-09-04 11:36:50 +07:00

171 lines
5.8 KiB
Go

package alias_test
import (
"context"
"testing"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/modules/alias"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
)
// The headline of path B: a saved name becomes its own command.
func TestFallback_SavedNameWorksAsItsOwnCommand(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("cheer", &models.Message{Sticker: &models.Sticker{FileID: "sticker-id"}}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/cheer"))
call, ok := callTo(rb, "sendSticker")
if !ok {
t.Fatalf("no sendSticker call; /cheer did not resolve: %+v", rb.Sent())
}
if got := call.Form["sticker"]; got != "sticker-id" {
t.Errorf("sticker = %q, want the saved file_id", got)
}
}
// Groups send /cmd@botname, and the fallback must normalise that the same way
// the command matcher does.
func TestFallback_ToleratesAtBotnameSuffix(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("cheer", &models.Message{Text: "yay"}))
rb.Reset()
// The fixture builder stops the entity at '@', but real Telegram includes
// the whole "/cheer@miti99bot" in it — which is the case the stripping
// exists for, so the entity is widened here to match the wire format.
upd := testutil.NewPrivateMessage(7, "/cheer@miti99bot")
upd.Message.Entities[0].Length = len(upd.Message.Text)
rb.Bot.ProcessUpdate(context.Background(), upd)
if got := rb.LastSent().Text(); got != "yay" {
t.Errorf("reply = %q, want the alias to resolve despite the @suffix", got)
}
}
// Mobile keyboards autocapitalise; /Cheer must reach the same alias.
func TestFallback_IsCaseInsensitive(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("cheer", &models.Message{Text: "yay"}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/CHEER"))
if got := rb.LastSent().Text(); got != "yay" {
t.Errorf("reply = %q, want case-folded resolution", got)
}
}
// Silence on a miss is deliberate: the fallback sees every unrecognised command
// in every chat, so replying would turn typos into noise and would confirm
// which names are taken.
func TestFallback_UnknownNameIsSilent(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/pign"))
if calls := rb.Sent(); len(calls) != 0 {
t.Errorf("unknown command produced output: %+v", calls)
}
}
// The rule the user asked for: behaviour in code wins over an alias, always.
// Registered commands are installed before the fallback, and the bot library
// returns the first matching handler.
func TestFallback_NeverShadowsARegisteredCommand(t *testing.T) {
var realRan bool
realModule := func(_ modules.Deps) modules.Module {
return modules.Module{Commands: []modules.Command{{
Name: "cheer",
Visibility: modules.VisibilityPublic,
Description: "the real thing",
Handler: func(_ context.Context, _ *bot.Bot, _ *models.Update) error {
realRan = true
return nil
},
}}}
}
rb := testutil.NewRecordingBot(t)
reg, err := modules.Build([]string{"alias", "real"},
map[string]modules.Factory{"alias": alias.New, "real": realModule},
storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build: %v", err)
}
modules.Install(rb.Bot, reg, modules.Auth{})
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/cheer"))
if !realRan {
t.Error("the registered /cheer handler did not run")
}
}
// Refusing at assignment time is the companion to dispatch order: an alias
// named after a real command would only ever be reachable via /insert.
func TestAlias_RefusesNameOfARegisteredCommand(t *testing.T) {
rb := installAlias(t)
// /aliases is one of this module's own commands.
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("aliases", &models.Message{Text: "hijack"}))
rb.AssertSentText(t, "already a command of mine")
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert aliases"))
rb.AssertSentText(t, "Nothing is saved")
}
func TestUnalias_DeletesAndThenNameIsFree(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("temp", &models.Message{Text: "content"}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/unalias temp"))
rb.AssertSentText(t, `Deleted "temp"`)
// Gone from /insert, from the list, and from the bare-command path.
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert temp"))
rb.AssertSentText(t, "Nothing is saved")
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/aliases"))
rb.AssertSentText(t, "No aliases saved yet")
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/temp"))
if calls := rb.Sent(); len(calls) != 0 {
t.Errorf("deleted alias still answered as a command: %+v", calls)
}
}
func TestUnalias_UnknownNameIsReported(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/unalias ghost"))
rb.AssertSentText(t, "Nothing is saved")
}
// Deleting is open to anyone, matching the overwrite rule — the namespace is
// shared, so the permission model is too.
func TestUnalias_AnyoneMayDelete(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("shared", &models.Message{Text: "content"}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(99, "/unalias shared"))
rb.AssertSentText(t, "Deleted")
}