From 733cd48d6422a326809dd9a7f39fd5d687b261bf Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 4 Sep 2026 13:28:50 +0700 Subject: [PATCH] feat(alias): keep text formatting, name kinds in /aliases, copyable commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , so tapping it copies something ready to send. The generic usage lines stay plain text: they contain a literal 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. --- docs/aliases.md | 40 ++++++-- internal/modules/alias/alias.go | 11 +++ internal/modules/alias/alias_inline.go | 23 +++-- internal/modules/alias/alias_media.go | 49 ++++++---- internal/modules/alias/fallback_test.go | 2 +- internal/modules/alias/handlers.go | 98 ++++++++++++------- internal/modules/alias/handlers_test.go | 123 +++++++++++++++++++++++- 7 files changed, 268 insertions(+), 78 deletions(-) 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") +}