mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-19 22:22:47 +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.
171 lines
5.8 KiB
Go
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")
|
|
}
|