diff --git a/docs/aliases.md b/docs/aliases.md index cb59b6b..bb3c6a7 100644 --- a/docs/aliases.md +++ b/docs/aliases.md @@ -133,20 +133,40 @@ single call, while naming each kind would cost one read per alias — a round tr each against MongoDB, on a dispatcher that serves one update at a time. To find out what a name holds, `/insert` it. -Names list in their folded (lowercase) form, which is exactly what `/insert` -takes, and each is wrapped in a `` span so tapping one copies just that -name. +One line per alias, showing what the name holds, with the invocation in a +`` span so tapping it copies a command ready to send: -The list is trimmed to fit Telegram's 4096-character message limit and ends -with `…and N more.` when it does not fit; the count at the top is always the -true total. The trim budget counts the markup, not only the names — at 13 bytes -a pair the tags outweigh a short name. +``` +3 aliases: +/cheer — sticker +/clip — video +/greeting — text +``` + +Names list in their folded (lowercase) form, which is exactly what `/insert` +takes. + +This costs one store read per *listed* alias — `DocStore` has no bulk get and +the kind lives in the document. The reads stop as soon as the message is full, +so the cost is bounded by what fits in one reply rather than by how many +aliases exist. The list is trimmed to Telegram's 4096-character limit and ends +with `…and N more.`; the count at the top is always the true total. ## Behaviour worth knowing -**Text loses its formatting.** Bold, links and mentions are stored as plain -text: re-sending entities means carrying offsets that no longer line up once the -text is repeated in a different message. A bare URL still auto-links. +**Formatting survives.** Bold, italic, code, links and mentions are stored as +entities alongside the text, and captions keep theirs too. This works because +the text is re-sent byte-identical: entity offsets are relative to that text, +so they stay valid. They are sent back as entities rather than re-rendered as +markup, which avoids escaping and re-parsing content the user never wrote as +markup. + +**Another bot's message cannot be saved.** Telegram's own rule: *"Bots will not +be able to see messages from other bots regardless of mode."* The reply arrives +with its content stripped, so there is nothing to store and no setting that +would change it. `/alias` says so specifically rather than implying the format +was unsupported. Forwarding the message to yourself first and aliasing your own +copy works. **A `file_id` can stop working** — the original file was deleted, or Telegram rejects it. `/insert` answers with something actionable rather than a generic diff --git a/internal/modules/alias/alias.go b/internal/modules/alias/alias.go index 21c4c3b..33c7c3d 100644 --- a/internal/modules/alias/alias.go +++ b/internal/modules/alias/alias.go @@ -12,6 +12,8 @@ package alias import ( + "github.com/go-telegram/bot/models" + "github.com/tiennm99/miti99bot/internal/modules" "github.com/tiennm99/miti99bot/internal/storage" ) @@ -29,6 +31,15 @@ type Alias struct { Text string `bson:"text"` // message text for kindText, else the caption OwnerID int64 `bson:"ownerId"` // who assigned it last CreatedAt int64 `bson:"createdAt"` // unix millis + + // Entities carries the formatting of Text — bold, italic, code, links, + // mentions — so an alias comes back looking like what was saved. + // + // Storing them works because the text is re-sent byte-identical: entity + // offsets are relative to that text, so they stay valid. They are sent as + // entities rather than re-rendered as HTML, which avoids having to escape + // and re-parse content the user never wrote as markup. + Entities []models.MessageEntity `bson:"entities,omitempty"` } // Store is the module's typed view over its collection. diff --git a/internal/modules/alias/alias_inline.go b/internal/modules/alias/alias_inline.go index aaee73c..121d9bb 100644 --- a/internal/modules/alias/alias_inline.go +++ b/internal/modules/alias/alias_inline.go @@ -94,34 +94,37 @@ func inlineResult(name string, a Alias) models.InlineQueryResult { return &models.InlineQueryResultCachedSticker{ID: name, StickerFileID: a.FileID} case kindPhoto: return &models.InlineQueryResultCachedPhoto{ - ID: name, PhotoFileID: a.FileID, Title: name, Caption: a.Text, + ID: name, PhotoFileID: a.FileID, Title: name, Caption: a.Text, CaptionEntities: a.Entities, } case kindAnimation: return &models.InlineQueryResultCachedGif{ - ID: name, GifFileID: a.FileID, Title: name, Caption: a.Text, + ID: name, GifFileID: a.FileID, Title: name, Caption: a.Text, CaptionEntities: a.Entities, } case kindVideo: return &models.InlineQueryResultCachedVideo{ - ID: name, VideoFileID: a.FileID, Title: name, Caption: a.Text, + ID: name, VideoFileID: a.FileID, Title: name, Caption: a.Text, CaptionEntities: a.Entities, } case kindAudio: return &models.InlineQueryResultCachedAudio{ - ID: name, AudioFileID: a.FileID, Caption: a.Text, + ID: name, AudioFileID: a.FileID, Caption: a.Text, CaptionEntities: a.Entities, } case kindVoice: return &models.InlineQueryResultCachedVoice{ - ID: name, VoiceFileID: a.FileID, Title: name, Caption: a.Text, + ID: name, VoiceFileID: a.FileID, Title: name, Caption: a.Text, CaptionEntities: a.Entities, } case kindDocument: return &models.InlineQueryResultCachedDocument{ - ID: name, DocumentFileID: a.FileID, Title: name, Caption: a.Text, + ID: name, DocumentFileID: a.FileID, Title: name, Caption: a.Text, CaptionEntities: a.Entities, } case kindText: return &models.InlineQueryResultArticle{ - ID: name, - Title: name, - Description: a.Text, - InputMessageContent: &models.InputTextMessageContent{MessageText: a.Text}, + ID: name, + Title: name, + Description: a.Text, + InputMessageContent: &models.InputTextMessageContent{ + MessageText: a.Text, + Entities: a.Entities, + }, } } return nil diff --git a/internal/modules/alias/alias_media.go b/internal/modules/alias/alias_media.go index 8e664ec..51f2af5 100644 --- a/internal/modules/alias/alias_media.go +++ b/internal/modules/alias/alias_media.go @@ -26,6 +26,22 @@ const ( // only denies. const unsupportedRefusal = "That message cannot be saved. Reply to a sticker, photo, GIF, video, video note, audio, voice message, file, or plain text." +// otherBotRefusal explains a refusal no change here can lift. +// +// Telegram's own rule: "Bots will not be able to see messages from other bots +// regardless of mode." The reply arrives with its content stripped, so there is +// nothing to save and no setting that would help — worth saying outright rather +// than letting unsupportedRefusal imply the format was wrong. +const otherBotRefusal = "Telegram does not let bots read other bots' messages, so I cannot save that one. Forward it to yourself first, then reply to your copy." + +// fromAnotherBot reports whether a reply that captured nothing came from a bot. +// +// Only consulted after capture fails: this bot's *own* messages are readable, +// so anything of ours captures normally and never reaches here. +func fromAnotherBot(replied *models.Message) bool { + return replied != nil && replied.From != nil && replied.From.IsBot +} + // capture reduces a replied message to a storable alias. // // Order matters where Telegram populates more than one field: a GIF arrives as @@ -38,24 +54,23 @@ func capture(replied *models.Message) (Alias, bool) { case replied.Sticker != nil: return Alias{Kind: kindSticker, FileID: replied.Sticker.FileID}, true case replied.Animation != nil: - return Alias{Kind: kindAnimation, FileID: replied.Animation.FileID, Text: replied.Caption}, true + return Alias{Kind: kindAnimation, FileID: replied.Animation.FileID, Text: replied.Caption, Entities: replied.CaptionEntities}, true case replied.VideoNote != nil: return Alias{Kind: kindVideoNote, FileID: replied.VideoNote.FileID}, true case replied.Video != nil: - return Alias{Kind: kindVideo, FileID: replied.Video.FileID, Text: replied.Caption}, true + return Alias{Kind: kindVideo, FileID: replied.Video.FileID, Text: replied.Caption, Entities: replied.CaptionEntities}, true case replied.Voice != nil: - return Alias{Kind: kindVoice, FileID: replied.Voice.FileID, Text: replied.Caption}, true + return Alias{Kind: kindVoice, FileID: replied.Voice.FileID, Text: replied.Caption, Entities: replied.CaptionEntities}, true case replied.Audio != nil: - return Alias{Kind: kindAudio, FileID: replied.Audio.FileID, Text: replied.Caption}, true + return Alias{Kind: kindAudio, FileID: replied.Audio.FileID, Text: replied.Caption, Entities: replied.CaptionEntities}, true case len(replied.Photo) > 0: - return Alias{Kind: kindPhoto, FileID: largestPhoto(replied.Photo), Text: replied.Caption}, true + return Alias{Kind: kindPhoto, FileID: largestPhoto(replied.Photo), Text: replied.Caption, Entities: replied.CaptionEntities}, true case replied.Document != nil: - return Alias{Kind: kindDocument, FileID: replied.Document.FileID, Text: replied.Caption}, true + return Alias{Kind: kindDocument, FileID: replied.Document.FileID, Text: replied.Caption, Entities: replied.CaptionEntities}, true case replied.Text != "": - // Stored as plain text: entities (bold, links, mentions) are dropped, - // because re-sending them means carrying offsets that no longer line up - // once the text is repeated in a different message. - return Alias{Kind: kindText, Text: replied.Text}, true + // Entities come along: the text is re-sent unchanged, so the offsets + // they carry stay valid and the formatting survives. + return Alias{Kind: kindText, Text: replied.Text, Entities: replied.Entities}, true } return Alias{}, false } @@ -91,15 +106,15 @@ func send(ctx context.Context, b *bot.Bot, msg *models.Message, a Alias) error { }) case kindPhoto: _, err = b.SendPhoto(ctx, &bot.SendPhotoParams{ - ChatID: chatID, MessageThreadID: thread, Photo: file, Caption: a.Text, + ChatID: chatID, MessageThreadID: thread, Photo: file, Caption: a.Text, CaptionEntities: a.Entities, }) case kindAnimation: _, err = b.SendAnimation(ctx, &bot.SendAnimationParams{ - ChatID: chatID, MessageThreadID: thread, Animation: file, Caption: a.Text, + ChatID: chatID, MessageThreadID: thread, Animation: file, Caption: a.Text, CaptionEntities: a.Entities, }) case kindVideo: _, err = b.SendVideo(ctx, &bot.SendVideoParams{ - ChatID: chatID, MessageThreadID: thread, Video: file, Caption: a.Text, + ChatID: chatID, MessageThreadID: thread, Video: file, Caption: a.Text, CaptionEntities: a.Entities, }) case kindVideoNote: // Nor does a video note: Telegram renders it as a bare round clip. @@ -108,19 +123,19 @@ func send(ctx context.Context, b *bot.Bot, msg *models.Message, a Alias) error { }) case kindAudio: _, err = b.SendAudio(ctx, &bot.SendAudioParams{ - ChatID: chatID, MessageThreadID: thread, Audio: file, Caption: a.Text, + ChatID: chatID, MessageThreadID: thread, Audio: file, Caption: a.Text, CaptionEntities: a.Entities, }) case kindVoice: _, err = b.SendVoice(ctx, &bot.SendVoiceParams{ - ChatID: chatID, MessageThreadID: thread, Voice: file, Caption: a.Text, + ChatID: chatID, MessageThreadID: thread, Voice: file, Caption: a.Text, CaptionEntities: a.Entities, }) case kindDocument: _, err = b.SendDocument(ctx, &bot.SendDocumentParams{ - ChatID: chatID, MessageThreadID: thread, Document: file, Caption: a.Text, + ChatID: chatID, MessageThreadID: thread, Document: file, Caption: a.Text, CaptionEntities: a.Entities, }) case kindText: _, err = b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: chatID, MessageThreadID: thread, Text: a.Text, + ChatID: chatID, MessageThreadID: thread, Text: a.Text, Entities: a.Entities, }) default: // A kind written by a newer version of this module, or a corrupted diff --git a/internal/modules/alias/fallback_test.go b/internal/modules/alias/fallback_test.go index ca23d5b..064c1c9 100644 --- a/internal/modules/alias/fallback_test.go +++ b/internal/modules/alias/fallback_test.go @@ -132,7 +132,7 @@ func TestUnalias_DeletesAndThenNameIsFree(t *testing.T) { rb.Reset() rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/unalias temp")) - rb.AssertSentText(t, `Deleted "temp"`) + rb.AssertSentText(t, "Deleted temp") // Gone from /insert, from the list, and from the bare-command path. rb.Reset() diff --git a/internal/modules/alias/handlers.go b/internal/modules/alias/handlers.go index ed2c95d..ed29f83 100644 --- a/internal/modules/alias/handlers.go +++ b/internal/modules/alias/handlers.go @@ -99,13 +99,17 @@ func (s *state) handleAlias(ctx context.Context, b *bot.Bot, update *models.Upda // live registry, so it covers every module loaded in this deploy. if s.reg != nil { if _, taken := s.reg.AllCommands[key]; taken { - return chathelper.Reply(ctx, b, msg, fmt.Sprintf( - "/%s is already a command of mine. Pick another name.", key)) + return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( + "/%s is already a command of mine. Pick another name.", + html.EscapeString(key))) } } entry, ok := capture(msg.ReplyToMessage) if !ok { + if fromAnotherBot(msg.ReplyToMessage) { + return chathelper.Reply(ctx, b, msg, otherBotRefusal) + } return chathelper.Reply(ctx, b, msg, unsupportedRefusal) } entry.Name = display @@ -129,12 +133,13 @@ func (s *state) handleAlias(ctx context.Context, b *bot.Bot, update *models.Upda } if existed { - return chathelper.Reply(ctx, b, msg, fmt.Sprintf( - "Replaced /insert %s — it was a %s, now it is a %s.", - display, describe(previous.Kind), describe(entry.Kind))) + return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( + "Replaced /insert %s — it was a %s, now it is a %s.", + html.EscapeString(display), describe(previous.Kind), describe(entry.Kind))) } - return chathelper.Reply(ctx, b, msg, fmt.Sprintf( - "Saved. Use /insert %s to send that %s.", display, describe(entry.Kind))) + return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( + "Saved. Use /insert %s to send that %s.", + html.EscapeString(display), describe(entry.Kind))) } // handleInsert sends back whatever is stored under a name. @@ -158,8 +163,9 @@ func (s *state) handleInsert(ctx context.Context, b *bot.Bot, update *models.Upd return chathelper.Reply(ctx, b, msg, genericFailure) } if !found { - return chathelper.Reply(ctx, b, msg, fmt.Sprintf( - "Nothing is saved as %q. Reply to a message with /alias %s to save one.", display, display)) + return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( + "Nothing is saved as %s. Reply to a message with /alias %s to save one.", + html.EscapeString(display), html.EscapeString(display))) } if err := send(ctx, b, msg, entry); err != nil { @@ -167,8 +173,9 @@ func (s *state) handleInsert(ctx context.Context, b *bot.Bot, update *models.Upd // record predates a kind this build understands. Neither is worth an // opaque failure, and neither is retryable by the caller. log.Error("alias_insert_send", "name", key, "kind", entry.Kind, "err", err) - return chathelper.Reply(ctx, b, msg, fmt.Sprintf( - "%q can no longer be sent. Save it again with /alias %s.", display, display)) + return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( + "%s can no longer be sent. Save it again with /alias %s.", + html.EscapeString(display), html.EscapeString(display))) } return nil } @@ -200,14 +207,16 @@ func (s *state) handleUnalias(ctx context.Context, b *bot.Bot, update *models.Up log.Error("alias_unalias_lookup", "err", err) return chathelper.Reply(ctx, b, msg, genericFailure) } else if !found { - return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Nothing is saved as %q.", display)) + return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( + "Nothing is saved as %s.", html.EscapeString(display))) } if err := s.store.Delete(ctx, key); err != nil { log.Error("alias_unalias", "err", err) return chathelper.Reply(ctx, b, msg, genericFailure) } - return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Deleted %q.", display)) + return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( + "Deleted %s.", html.EscapeString(display))) } // handleFallback answers a /command nobody registered by treating it as an @@ -238,12 +247,7 @@ func (s *state) handleFallback(ctx context.Context, b *bot.Bot, name string, upd return nil } -// handleAliases lists every saved name. -// -// Names only, not what each one holds: the store answers "which keys exist" in -// one call, while naming the kinds would cost one read per alias — a round trip -// each against MongoDB, on a dispatcher that serves one update at a time. The -// cheap way to find out what a name holds is to /insert it. +// handleAliases lists every saved name with what it holds. func (s *state) handleAliases(ctx context.Context, b *bot.Bot, update *models.Update) error { ctx, cancel := context.WithTimeout(ctx, handlerTimeout) defer cancel() @@ -266,37 +270,61 @@ func (s *state) handleAliases(ctx context.Context, b *bot.Bot, update *models.Up // List gives no ordering guarantee, and an unstable list is unreadable when // it is the same command run twice. sort.Strings(names) - return chathelper.ReplyHTML(ctx, b, msg, renderNames(names)) + return chathelper.ReplyHTML(ctx, b, msg, s.renderNames(ctx, names)) } // renderNames formats the list as Telegram HTML, trimmed to fit one message. // -// Each name is wrapped in so tapping it copies just that name. The list -// exists to be read *and* reused, and a plain comma-separated run makes the -// reader select text by hand on a phone. -func renderNames(names []string) string { +// One line per alias, each showing what the name holds, with the invocation +// wrapped in so tapping it copies a command ready to send. +// +// This costs one store read per *listed* alias: DocStore has no bulk get and +// the kind lives in the document. The reads stop as soon as the message is +// full, so the cost is bounded by what fits in one reply rather than by how +// many aliases exist. +func (s *state) renderNames(ctx context.Context, names []string) string { var sb strings.Builder - fmt.Fprintf(&sb, "%d aliases:\n", len(names)) + fmt.Fprintf(&sb, "%d aliases:", len(names)) for i, name := range names { - // Escaped despite parseName already restricting names to [a-zA-Z0-9_]: - // the validation and the rendering are far apart, and a later relaxation - // of the name rules must not silently become an HTML injection. - entry := "" + html.EscapeString(name) + "" - // Reserve room for the "…and N more" tail before committing to a name, + entry, found, err := s.get(ctx, name) + if err != nil { + // One unreadable record must not blank the whole list. + log.Error("alias_list_get", "name", name, "err", err) + } else if !found { + continue // deleted between the List above and this read + } + + // Escaped despite parseName restricting names to [a-zA-Z0-9_]: the + // validation and the rendering are far apart, and a later relaxation of + // the name rules must not silently become an HTML injection. + line := "\n/" + html.EscapeString(name) + " — " + kindLabel(entry.Kind) + // Reserve room for the "…and N more" tail before committing to a line, // so the trim can never be what pushes the message over the limit. - if sb.Len()+len(entry)+2 > maxListBytes { + if sb.Len()+len(line) > maxListBytes { fmt.Fprintf(&sb, "\n…and %d more.", len(names)-i) return sb.String() } - if i > 0 { - sb.WriteString(", ") - } - sb.WriteString(entry) + sb.WriteString(line) } return sb.String() } +// kindLabel names a kind for the list. +// +// Differs from describe in exactly one case: text reads as "message" in a +// sentence ("send that message") but as "text" in a column of kinds, next to +// sticker and video. An empty kind means the read above failed. +func kindLabel(kind string) string { + switch kind { + case kindText: + return "text" + case "": + return "unreadable" + } + return describe(kind) +} + // get reads an alias. A missing name is not an error — it is the normal state // for a name nobody has claimed. func (s *state) get(ctx context.Context, key string) (Alias, bool, error) { diff --git a/internal/modules/alias/handlers_test.go b/internal/modules/alias/handlers_test.go index 755f10b..7ef8e24 100644 --- a/internal/modules/alias/handlers_test.go +++ b/internal/modules/alias/handlers_test.go @@ -100,7 +100,7 @@ func TestAlias_RoundTripsEveryKind(t *testing.T) { t.Run(tc.name, func(t *testing.T) { rb := installAlias(t) rb.Bot.ProcessUpdate(context.Background(), aliasCmd("thing", tc.replied)) - rb.AssertSentText(t, "Use /insert thing") + rb.AssertSentText(t, "Use /insert thing") rb.Reset() rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert thing")) @@ -320,10 +320,13 @@ func TestAliases_ListsSortedNames(t *testing.T) { if !strings.HasPrefix(got, "3 aliases:") { t.Errorf("reply = %q, want it to open with the count", got) } - // Keys are folded, so "Mid" lists as "mid" — which is what /insert takes. - // Each name is its own span so tapping one copies just that name. - if want := "alpha, mid, zeta"; !strings.Contains(got, want) { - t.Errorf("reply = %q, want sorted copyable names %q", got, want) + // 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/alpha — text" + + "\n/mid — text" + + "\n/zeta — 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" { @@ -331,6 +334,37 @@ func TestAliases_ListsSortedNames(t *testing.T) { } } +// 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{ + "/words — text", + "/stick — sticker", + "/clip — video", + "/loop — GIF", + "/pic — 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) { @@ -355,3 +389,82 @@ func TestAliases_TrimsToOneMessage(t *testing.T) { 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") +}