mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-15 18:16:10 +00:00
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.
This commit is contained in:
1 parent
822bcdbca8
commit
be91d2eb41
33 files changed
+1882
-52
No files matched your search
@@ -0,0 +1,183 @@
|
||||
package alias_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/testutil"
|
||||
)
|
||||
|
||||
// inlineQuery builds an inline-mode update: "@botname <query>" typed in any chat.
|
||||
func inlineQuery(userID int64, query string) *models.Update {
|
||||
return &models.Update{
|
||||
ID: 1,
|
||||
InlineQuery: &models.InlineQuery{
|
||||
ID: "q1",
|
||||
From: &models.User{ID: userID, FirstName: "Test"},
|
||||
Query: query,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// inlineResults decodes the results the bot answered with. answerInlineQuery
|
||||
// sends them as a JSON array in the "results" form field.
|
||||
func inlineResults(t *testing.T, rb *testutil.RecordingBot) []map[string]any {
|
||||
t.Helper()
|
||||
call, ok := callTo(rb, "answerInlineQuery")
|
||||
if !ok {
|
||||
t.Fatalf("no answerInlineQuery call; got %+v", rb.Sent())
|
||||
}
|
||||
var out []map[string]any
|
||||
if err := json.Unmarshal([]byte(call.Form["results"]), &out); err != nil {
|
||||
t.Fatalf("decode results %q: %v", call.Form["results"], err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Each kind must be offered as the cached inline type that carries it — the
|
||||
// reason a file_id is stored rather than bytes.
|
||||
func TestInline_OffersEachKindAsItsCachedType(t *testing.T) {
|
||||
cases := []struct {
|
||||
alias string
|
||||
replied *models.Message
|
||||
wantType string
|
||||
idField string
|
||||
}{
|
||||
{"pic", &models.Message{Photo: []models.PhotoSize{{FileID: "photo-id", FileSize: 9}}}, "photo", "photo_file_id"},
|
||||
{"stick", &models.Message{Sticker: &models.Sticker{FileID: "sticker-id"}}, "sticker", "sticker_file_id"},
|
||||
{"movie", &models.Message{Video: &models.Video{FileID: "video-id"}}, "video", "video_file_id"},
|
||||
{"loop", &models.Message{Animation: &models.Animation{FileID: "anim-id"}}, "gif", "gif_file_id"},
|
||||
{"song", &models.Message{Audio: &models.Audio{FileID: "audio-id"}}, "audio", "audio_file_id"},
|
||||
{"note", &models.Message{Voice: &models.Voice{FileID: "voice-id"}}, "voice", "voice_file_id"},
|
||||
{"paper", &models.Message{Document: &models.Document{FileID: "doc-id"}}, "document", "document_file_id"},
|
||||
{"words", &models.Message{Text: "hello"}, "article", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.alias, func(t *testing.T) {
|
||||
rb := installAlias(t)
|
||||
rb.Bot.ProcessUpdate(context.Background(), aliasCmd(tc.alias, tc.replied))
|
||||
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), inlineQuery(7, tc.alias))
|
||||
|
||||
results := inlineResults(t, rb)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("got %d results, want 1: %+v", len(results), results)
|
||||
}
|
||||
if got := results[0]["type"]; got != tc.wantType {
|
||||
t.Errorf("type = %v, want %q", got, tc.wantType)
|
||||
}
|
||||
if got := results[0]["id"]; got != tc.alias {
|
||||
t.Errorf("id = %v, want the alias name %q", got, tc.alias)
|
||||
}
|
||||
if tc.idField != "" {
|
||||
if got, ok := results[0][tc.idField].(string); !ok || !strings.HasSuffix(got, "-id") {
|
||||
t.Errorf("%s = %v, want the saved file_id", tc.idField, results[0][tc.idField])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram defines no cached inline type for a video note, so it must be left
|
||||
// out rather than downgraded to a plain video — that would change what the
|
||||
// user saved.
|
||||
func TestInline_SkipsVideoNotes(t *testing.T) {
|
||||
rb := installAlias(t)
|
||||
rb.Bot.ProcessUpdate(context.Background(),
|
||||
aliasCmd("round", &models.Message{VideoNote: &models.VideoNote{FileID: "note-id"}}))
|
||||
rb.Bot.ProcessUpdate(context.Background(),
|
||||
aliasCmd("flat", &models.Message{Text: "text"}))
|
||||
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), inlineQuery(7, ""))
|
||||
|
||||
results := inlineResults(t, rb)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("got %d results, want only the non-video-note one: %+v", len(results), results)
|
||||
}
|
||||
if got := results[0]["id"]; got != "flat" {
|
||||
t.Errorf("id = %v, want the text alias", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty query lists everything; a non-empty one filters by prefix.
|
||||
func TestInline_FiltersByPrefix(t *testing.T) {
|
||||
rb := installAlias(t)
|
||||
for _, name := range []string{"cheer", "cheese", "boo"} {
|
||||
rb.Bot.ProcessUpdate(context.Background(), aliasCmd(name, &models.Message{Text: name}))
|
||||
}
|
||||
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), inlineQuery(7, ""))
|
||||
if got := len(inlineResults(t, rb)); got != 3 {
|
||||
t.Errorf("empty query returned %d results, want all 3", got)
|
||||
}
|
||||
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), inlineQuery(7, "che"))
|
||||
results := inlineResults(t, rb)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("prefix query returned %d results, want 2: %+v", len(results), results)
|
||||
}
|
||||
// Sorted, so the order is stable between identical queries.
|
||||
if results[0]["id"] != "cheer" || results[1]["id"] != "cheese" {
|
||||
t.Errorf("results = %v, %v; want cheer then cheese", results[0]["id"], results[1]["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// The prefix is folded the same way names are, so typing uppercase still finds
|
||||
// the alias.
|
||||
func TestInline_PrefixIsCaseInsensitive(t *testing.T) {
|
||||
rb := installAlias(t)
|
||||
rb.Bot.ProcessUpdate(context.Background(), aliasCmd("cheer", &models.Message{Text: "yay"}))
|
||||
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), inlineQuery(7, "CHE"))
|
||||
|
||||
if got := len(inlineResults(t, rb)); got != 1 {
|
||||
t.Errorf("got %d results for an uppercase prefix, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A query matching nothing must still be answered, or the caller's client
|
||||
// spins on an unanswered inline query.
|
||||
func TestInline_NoMatchesStillAnswers(t *testing.T) {
|
||||
rb := installAlias(t)
|
||||
rb.Bot.ProcessUpdate(context.Background(), inlineQuery(7, "nothing"))
|
||||
|
||||
call, ok := callTo(rb, "answerInlineQuery")
|
||||
if !ok {
|
||||
t.Fatalf("no answerInlineQuery call; got %+v", rb.Sent())
|
||||
}
|
||||
if got := call.Form["inline_query_id"]; got != "q1" {
|
||||
t.Errorf("inline_query_id = %q, want the query's id", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram caps answerInlineQuery at 50 results.
|
||||
func TestInline_CapsAtFiftyResults(t *testing.T) {
|
||||
rb := installAlias(t)
|
||||
for i := 0; i < 60; i++ {
|
||||
rb.Bot.ProcessUpdate(context.Background(),
|
||||
aliasCmd(uniqueName(i), &models.Message{Text: "x"}))
|
||||
}
|
||||
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), inlineQuery(7, ""))
|
||||
|
||||
// Exactly the cap, not merely "at most": 60 were saved, so a smaller
|
||||
// number would mean the listing quietly lost entries.
|
||||
if got := len(inlineResults(t, rb)); got != 50 {
|
||||
t.Errorf("returned %d results, want exactly Telegram's 50 cap", got)
|
||||
}
|
||||
}
|
||||
|
||||
// uniqueName builds a valid, distinct alias name for index i.
|
||||
func uniqueName(i int) string {
|
||||
return "n" + strings.Repeat("x", i/10) + string(rune('a'+i%10))
|
||||
}
|
||||
Reference in new issue
Block a user