mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 04:16:43 +00:00
Text and caption formatting now survives an alias. Bold, italic, code, links
and mentions are stored as entities beside the text and sent back with it, on
the /insert, bare-command and inline paths alike. This works because the text
is re-sent byte-identical, so the offsets the entities carry stay valid — the
earlier comment claiming otherwise was wrong. They go back as entities rather
than re-rendered markup, which avoids escaping and re-parsing content the user
never wrote as markup.
/aliases now lists one line per name with what it holds, so the list says what
each will send:
3 aliases:
/cheer — sticker
/clip — video
/greeting — text
That costs one store read per listed alias, since DocStore has no bulk get and
the kind lives in the document. The reads stop once the message is full, so the
cost is bounded by what fits in one reply rather than by how many aliases exist.
Every reply that names a command or an alias wraps it in <code>, so tapping it
copies something ready to send. The generic usage lines stay plain text: they
contain a literal <name> placeholder that HTML mode would swallow as a tag.
Replying to another bot's message gets its own refusal. Telegram delivers that
reply with the content stripped, so capture finds nothing and the format advice
read as if the wrong kind had been sent. Checked only after capture fails, so
this bot's own messages — which are readable — never reach it.
471 lines
16 KiB
Go
471 lines
16 KiB
Go
package alias_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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"
|
|
)
|
|
|
|
// installAlias builds a registry holding only the alias module. Both commands
|
|
// are public, so no auth is needed for them to dispatch.
|
|
func installAlias(t *testing.T) *testutil.RecordingBot {
|
|
t.Helper()
|
|
rb := testutil.NewRecordingBot(t)
|
|
reg, err := modules.Build([]string{"alias"},
|
|
map[string]modules.Factory{"alias": alias.New},
|
|
storage.NewMemoryProvider(), modules.BuildOptions{})
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
modules.Install(rb.Bot, reg, modules.Auth{})
|
|
return rb
|
|
}
|
|
|
|
// aliasCmd builds "/alias <name>" replying to the given message.
|
|
func aliasCmd(name string, replied *models.Message) *models.Update {
|
|
upd := testutil.NewPrivateMessage(7, "/alias "+name)
|
|
upd.Message.ReplyToMessage = replied
|
|
return upd
|
|
}
|
|
|
|
func callTo(rb *testutil.RecordingBot, method string) (testutil.SentCall, bool) {
|
|
for _, c := range rb.Sent() {
|
|
if c.Method == method {
|
|
return c, true
|
|
}
|
|
}
|
|
return testutil.SentCall{}, false
|
|
}
|
|
|
|
// Every supported kind must round-trip: saved by file_id, then sent back
|
|
// through the one send method that accepts it.
|
|
func TestAlias_RoundTripsEveryKind(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
replied *models.Message
|
|
method string
|
|
field string
|
|
fileID string
|
|
}{
|
|
{
|
|
name: "sticker",
|
|
replied: &models.Message{Sticker: &models.Sticker{FileID: "sticker-id"}},
|
|
method: "sendSticker", field: "sticker", fileID: "sticker-id",
|
|
},
|
|
{
|
|
name: "photo",
|
|
replied: &models.Message{Photo: []models.PhotoSize{{FileID: "small", FileSize: 10}, {FileID: "photo-id", FileSize: 900}}},
|
|
method: "sendPhoto", field: "photo", fileID: "photo-id",
|
|
},
|
|
{
|
|
name: "animation",
|
|
replied: &models.Message{Animation: &models.Animation{FileID: "anim-id"}},
|
|
method: "sendAnimation", field: "animation", fileID: "anim-id",
|
|
},
|
|
{
|
|
name: "video",
|
|
replied: &models.Message{Video: &models.Video{FileID: "video-id"}},
|
|
method: "sendVideo", field: "video", fileID: "video-id",
|
|
},
|
|
{
|
|
name: "video note",
|
|
replied: &models.Message{VideoNote: &models.VideoNote{FileID: "note-id"}},
|
|
method: "sendVideoNote", field: "video_note", fileID: "note-id",
|
|
},
|
|
{
|
|
name: "audio",
|
|
replied: &models.Message{Audio: &models.Audio{FileID: "audio-id"}},
|
|
method: "sendAudio", field: "audio", fileID: "audio-id",
|
|
},
|
|
{
|
|
name: "voice",
|
|
replied: &models.Message{Voice: &models.Voice{FileID: "voice-id"}},
|
|
method: "sendVoice", field: "voice", fileID: "voice-id",
|
|
},
|
|
{
|
|
name: "document",
|
|
replied: &models.Message{Document: &models.Document{FileID: "doc-id"}},
|
|
method: "sendDocument", field: "document", fileID: "doc-id",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(), aliasCmd("thing", tc.replied))
|
|
rb.AssertSentText(t, "Use <code>/insert thing</code>")
|
|
|
|
rb.Reset()
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert thing"))
|
|
|
|
call, ok := callTo(rb, tc.method)
|
|
if !ok {
|
|
t.Fatalf("no %s call; got %+v", tc.method, rb.Sent())
|
|
}
|
|
// The stored file_id is handed straight back, never re-uploaded.
|
|
if got := call.Form[tc.field]; got != tc.fileID {
|
|
t.Errorf("%s = %q, want the saved file_id %q", tc.field, got, tc.fileID)
|
|
}
|
|
if _, ok := callTo(rb, "sendDocument"); ok && tc.method != "sendDocument" {
|
|
t.Error("media was re-sent as a document")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAlias_TextRoundTrips(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(),
|
|
aliasCmd("greeting", &models.Message{Text: "xin chào"}))
|
|
|
|
rb.Reset()
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert greeting"))
|
|
|
|
if got := rb.LastSent().Text(); got != "xin chào" {
|
|
t.Errorf("insert sent %q, want the saved text", got)
|
|
}
|
|
}
|
|
|
|
// A photo's caption is part of what was saved, so it comes back with it.
|
|
func TestAlias_KeepsCaption(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(), aliasCmd("pic", &models.Message{
|
|
Photo: []models.PhotoSize{{FileID: "photo-id", FileSize: 100}},
|
|
Caption: "a caption",
|
|
}))
|
|
|
|
rb.Reset()
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert pic"))
|
|
|
|
call, ok := callTo(rb, "sendPhoto")
|
|
if !ok {
|
|
t.Fatalf("no sendPhoto call; got %+v", rb.Sent())
|
|
}
|
|
if got := call.Form["caption"]; got != "a caption" {
|
|
t.Errorf("caption = %q, want it preserved", got)
|
|
}
|
|
}
|
|
|
|
// A GIF arrives as an Animation *and* a Document. The specific kind must win,
|
|
// or /insert would send it as a plain file.
|
|
func TestAlias_AnimationBeatsDocument(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(), aliasCmd("gif", &models.Message{
|
|
Animation: &models.Animation{FileID: "anim-id"},
|
|
Document: &models.Document{FileID: "doc-id"},
|
|
}))
|
|
|
|
rb.Reset()
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert gif"))
|
|
|
|
if _, ok := callTo(rb, "sendAnimation"); !ok {
|
|
t.Errorf("want sendAnimation; got %+v", rb.Sent())
|
|
}
|
|
if _, ok := callTo(rb, "sendDocument"); ok {
|
|
t.Error("a GIF was sent as a document")
|
|
}
|
|
}
|
|
|
|
// Global namespace, last assignment wins — and the reply says so, since there
|
|
// is no /unalias to undo a mistake with.
|
|
func TestAlias_OverwriteAnnouncesReplacement(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(),
|
|
aliasCmd("dup", &models.Message{Sticker: &models.Sticker{FileID: "first"}}))
|
|
|
|
rb.Reset()
|
|
// A different user, to prove the namespace is shared rather than per-caller.
|
|
upd := testutil.NewPrivateMessage(99, "/alias dup")
|
|
upd.Message.ReplyToMessage = &models.Message{Text: "second"}
|
|
rb.Bot.ProcessUpdate(context.Background(), upd)
|
|
|
|
got := rb.LastSent().Text()
|
|
if !strings.Contains(got, "Replaced") || !strings.Contains(got, "was a sticker") {
|
|
t.Errorf("reply = %q, want it to name what it replaced", got)
|
|
}
|
|
|
|
rb.Reset()
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert dup"))
|
|
if got := rb.LastSent().Text(); got != "second" {
|
|
t.Errorf("insert sent %q, want the newer binding", got)
|
|
}
|
|
}
|
|
|
|
// Lookups fold case; the stored spelling is what gets echoed back.
|
|
func TestAlias_NameIsCaseInsensitive(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(),
|
|
aliasCmd("LOUD", &models.Message{Text: "shouted"}))
|
|
|
|
rb.Reset()
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert loud"))
|
|
if got := rb.LastSent().Text(); got != "shouted" {
|
|
t.Errorf("insert sent %q, want the alias found case-insensitively", got)
|
|
}
|
|
}
|
|
|
|
// A leading @ is a natural slip for a username-shaped name, not a new request.
|
|
func TestAlias_StripsLeadingAt(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(),
|
|
aliasCmd("@handle", &models.Message{Text: "saved"}))
|
|
|
|
rb.Reset()
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert handle"))
|
|
if got := rb.LastSent().Text(); got != "saved" {
|
|
t.Errorf("insert sent %q, want the @-stripped name to resolve", got)
|
|
}
|
|
}
|
|
|
|
func TestAlias_RejectsBadNames(t *testing.T) {
|
|
// Spaces are what separate a name from a sentence; the rest are shapes a
|
|
// username cannot take.
|
|
for _, name := range []string{"", "two words", "9lives", "has-dash", "hasдot.", strings.Repeat("a", 33)} {
|
|
t.Run("name="+name, func(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(),
|
|
aliasCmd(name, &models.Message{Text: "content"}))
|
|
|
|
rb.AssertSentText(t, "The name is one word")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAlias_RejectsUnsupportedMessage(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(),
|
|
aliasCmd("place", &models.Message{Location: &models.Location{Latitude: 1, Longitude: 2}}))
|
|
|
|
rb.AssertSentText(t, "cannot be saved")
|
|
}
|
|
|
|
func TestAlias_NoReplyShowsUsage(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/alias solo"))
|
|
|
|
rb.AssertSentText(t, "Reply to a message")
|
|
}
|
|
|
|
func TestInsert_UnknownNameExplainsHowToSaveOne(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert nothing"))
|
|
|
|
rb.AssertSentText(t, "Nothing is saved")
|
|
rb.AssertSentText(t, "/alias nothing")
|
|
}
|
|
|
|
// A file_id can stop working — the file was deleted, or Telegram rejects it.
|
|
// The caller gets an actionable sentence, not an opaque failure.
|
|
func TestInsert_DeadFileIDIsExplained(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(),
|
|
aliasCmd("gone", &models.Message{Sticker: &models.Sticker{FileID: "stale"}}))
|
|
|
|
rb.Reset()
|
|
rb.FailMethodCode("sendSticker", 400, "Bad Request: wrong file identifier")
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert gone"))
|
|
|
|
rb.AssertSentText(t, "can no longer be sent")
|
|
}
|
|
|
|
// Forum supergroups route by topic: a reply without the thread id lands in
|
|
// General instead of where the command was typed.
|
|
func TestInsert_KeepsForumTopic(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(),
|
|
aliasCmd("topical", &models.Message{Sticker: &models.Sticker{FileID: "sticker-id"}}))
|
|
|
|
rb.Reset()
|
|
upd := testutil.NewSupergroupMessage(-100, 7, "/insert topical")
|
|
upd.Message.MessageThreadID = 42
|
|
rb.Bot.ProcessUpdate(context.Background(), upd)
|
|
|
|
call, ok := callTo(rb, "sendSticker")
|
|
if !ok {
|
|
t.Fatalf("no sendSticker call; got %+v", rb.Sent())
|
|
}
|
|
if got := call.Form["message_thread_id"]; got != "42" {
|
|
t.Errorf("message_thread_id = %q, want the topic the command came from", got)
|
|
}
|
|
}
|
|
|
|
func TestAliases_EmptyStoreExplainsHowToSave(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/aliases"))
|
|
|
|
rb.AssertSentText(t, "No aliases saved yet")
|
|
}
|
|
|
|
// Names are listed sorted and counted. Order must be stable, or the same
|
|
// command run twice reads as a different list.
|
|
func TestAliases_ListsSortedNames(t *testing.T) {
|
|
rb := installAlias(t)
|
|
for _, name := range []string{"zeta", "alpha", "Mid"} {
|
|
rb.Bot.ProcessUpdate(context.Background(),
|
|
aliasCmd(name, &models.Message{Text: "x"}))
|
|
}
|
|
|
|
rb.Reset()
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/aliases"))
|
|
|
|
call := rb.LastSent()
|
|
got := call.Text()
|
|
if !strings.HasPrefix(got, "3 aliases:") {
|
|
t.Errorf("reply = %q, want it to open with the count", got)
|
|
}
|
|
// One line per alias: a copyable command plus what it holds. Keys are
|
|
// folded, so "Mid" lists as "mid" — which is what /insert takes.
|
|
want := "\n<code>/alpha</code> — text" +
|
|
"\n<code>/mid</code> — text" +
|
|
"\n<code>/zeta</code> — text"
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("reply = %q, want sorted lines %q", got, want)
|
|
}
|
|
// The markup is inert without the parse mode.
|
|
if pm := call.Form["parse_mode"]; pm != "HTML" {
|
|
t.Errorf("parse_mode = %q, want HTML", pm)
|
|
}
|
|
}
|
|
|
|
// Each kind is named in the list, so /aliases says what it will send.
|
|
func TestAliases_ShowsEachKind(t *testing.T) {
|
|
rb := installAlias(t)
|
|
cases := map[string]*models.Message{
|
|
"words": {Text: "hi"},
|
|
"stick": {Sticker: &models.Sticker{FileID: "s"}},
|
|
"clip": {Video: &models.Video{FileID: "v"}},
|
|
"loop": {Animation: &models.Animation{FileID: "a"}},
|
|
"pic": {Photo: []models.PhotoSize{{FileID: "p", FileSize: 1}}},
|
|
}
|
|
for name, replied := range cases {
|
|
rb.Bot.ProcessUpdate(context.Background(), aliasCmd(name, replied))
|
|
}
|
|
|
|
rb.Reset()
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/aliases"))
|
|
|
|
got := rb.LastSent().Text()
|
|
for _, want := range []string{
|
|
"<code>/words</code> — text",
|
|
"<code>/stick</code> — sticker",
|
|
"<code>/clip</code> — video",
|
|
"<code>/loop</code> — GIF",
|
|
"<code>/pic</code> — photo",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("reply missing %q; got %q", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The reply must fit one Telegram message (4096 chars), and the trimming
|
|
// notice must not itself be what overflows it.
|
|
func TestAliases_TrimsToOneMessage(t *testing.T) {
|
|
rb := installAlias(t)
|
|
const total = 400
|
|
for i := 0; i < total; i++ {
|
|
rb.Bot.ProcessUpdate(context.Background(),
|
|
aliasCmd(fmt.Sprintf("name%026d", i), &models.Message{Text: "x"}))
|
|
}
|
|
|
|
rb.Reset()
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/aliases"))
|
|
|
|
got := rb.LastSent().Text()
|
|
if len(got) > 4096 {
|
|
t.Errorf("reply is %d bytes, past Telegram's 4096 limit", len(got))
|
|
}
|
|
if !strings.Contains(got, "more.") {
|
|
t.Errorf("reply = %q...; want a trimming notice", got[:min(120, len(got))])
|
|
}
|
|
if !strings.HasPrefix(got, fmt.Sprintf("%d aliases:", total)) {
|
|
t.Errorf("reply should still report the true total; got %q", got[:min(60, len(got))])
|
|
}
|
|
}
|
|
|
|
// Formatting is part of what was saved: bold, italic, code and links come back
|
|
// as entities, so the text is re-sent looking like the original.
|
|
func TestAlias_PreservesTextFormatting(t *testing.T) {
|
|
rb := installAlias(t)
|
|
upd := aliasCmd("styled", &models.Message{
|
|
Text: "bold code",
|
|
Entities: []models.MessageEntity{
|
|
{Type: models.MessageEntityTypeBold, Offset: 0, Length: 4},
|
|
{Type: models.MessageEntityTypeCode, Offset: 5, Length: 4},
|
|
},
|
|
})
|
|
rb.Bot.ProcessUpdate(context.Background(), upd)
|
|
|
|
rb.Reset()
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert styled"))
|
|
|
|
call, ok := callTo(rb, "sendMessage")
|
|
if !ok {
|
|
t.Fatalf("no sendMessage call; got %+v", rb.Sent())
|
|
}
|
|
if got := call.Form["text"]; got != "bold code" {
|
|
t.Errorf("text = %q, want it unchanged so the offsets stay valid", got)
|
|
}
|
|
// Entities ride along as JSON; offsets are relative to the text above.
|
|
ents := call.Form["entities"]
|
|
for _, want := range []string{`"type":"bold"`, `"type":"code"`, `"offset":5`, `"length":4`} {
|
|
if !strings.Contains(ents, want) {
|
|
t.Errorf("entities = %q, missing %s", ents, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A caption's formatting survives the same way.
|
|
func TestAlias_PreservesCaptionFormatting(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(), aliasCmd("pic", &models.Message{
|
|
Photo: []models.PhotoSize{{FileID: "photo-id", FileSize: 1}},
|
|
Caption: "italic",
|
|
CaptionEntities: []models.MessageEntity{
|
|
{Type: models.MessageEntityTypeItalic, Offset: 0, Length: 6},
|
|
},
|
|
}))
|
|
|
|
rb.Reset()
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert pic"))
|
|
|
|
call, ok := callTo(rb, "sendPhoto")
|
|
if !ok {
|
|
t.Fatalf("no sendPhoto call; got %+v", rb.Sent())
|
|
}
|
|
if got := call.Form["caption_entities"]; !strings.Contains(got, `"type":"italic"`) {
|
|
t.Errorf("caption_entities = %q, want the italic run preserved", got)
|
|
}
|
|
}
|
|
|
|
// Telegram never delivers another bot's message content, so the refusal has to
|
|
// say that rather than implying the format was unsupported.
|
|
func TestAlias_ExplainsAnotherBotsMessage(t *testing.T) {
|
|
rb := installAlias(t)
|
|
// What arrives when the reply points at another bot's message: a sender
|
|
// that is a bot, and no readable content.
|
|
rb.Bot.ProcessUpdate(context.Background(), aliasCmd("botmsg", &models.Message{
|
|
From: &models.User{ID: 555, IsBot: true, FirstName: "OtherBot"},
|
|
}))
|
|
|
|
rb.AssertSentText(t, "does not let bots read other bots")
|
|
}
|
|
|
|
// A human's unsupported message still gets the format advice, not the bot one.
|
|
func TestAlias_UnsupportedFromHumanKeepsFormatAdvice(t *testing.T) {
|
|
rb := installAlias(t)
|
|
rb.Bot.ProcessUpdate(context.Background(), aliasCmd("place", &models.Message{
|
|
From: &models.User{ID: 7, FirstName: "Test"},
|
|
Location: &models.Location{Latitude: 1, Longitude: 2},
|
|
}))
|
|
|
|
rb.AssertSentText(t, "cannot be saved")
|
|
}
|