fix(alias): tell an unreadable reply apart from an unsupported one

Replying to another bot's message and running /alias answered with the
list of kinds that can be saved, which blames the format of a message
the bot was never shown — it may well have been a photo. Telegram strips
the content of another bot's message, and the sender is not always marked
as a bot: an anonymous or service-posted message arrives equally empty,
so the existing bot check missed it.

Judge on whether any content arrived instead. A reply with no content
field at all, and a command that arrives with no reply attached, now both
say what happened and end with the one action that works: forward the
message into the chat and reply to your own copy. A reply that did arrive
with content of a refused kind — a poll, a location — still gets the list
of supported kinds.

One contentFields table backs both the check and the alias_capture debug
line, so the log line always explains the refusal the caller was given.
This commit is contained in:
2026-09-08 16:20:35 +07:00
parent 0f49bfdb35
commit f6e2bbca38
5 changed files with 148 additions and 36 deletions
+19 -3
View File
@@ -186,9 +186,25 @@ 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.
would change it.
Every refusal for a message that could not be *read* — as opposed to one whose
kind is unsupported — ends with the same advice, because it is the only thing
that works: **forward it into the chat and reply to your copy.** A forwarded
copy is a new message sent by a user, so it arrives intact. Three shapes reach
that advice, and they are told apart deliberately:
| What arrived | Answer |
| --- | --- |
| Reply from a sender marked as a bot | Telegram does not let bots read other bots' messages |
| Reply with a message id but no content field at all | That message reached me with no content |
| No reply attached at all | Reply to the message you want to save — and if you did, Telegram did not pass it along |
The middle case exists because the sender is not always marked: an anonymous or
service-posted message can arrive equally empty. None of the three lists the
supported kinds, which would blame the format of a message the bot was never
shown — it may well have been a photo. Only a reply that *did* arrive with
content of a kind the module refuses (a poll, a location) gets that list.
**A `file_id` can stop working** — the original file was deleted, or Telegram
rejects it. `/insert` answers with something actionable rather than a generic
+13 -24
View File
@@ -51,37 +51,26 @@ func replyShape(replied *models.Message, entry Alias, ok bool) []any {
// populatedFields names the content fields the replied message actually has.
//
// Covers more than capture handles on purpose: the point is to show what
// arrived, including kinds this module refuses, so "unsupported" can be told
// apart from "empty".
// Reads the same contentFields table hasContent tests, so the line always
// explains the refusal the caller was given. The two provenance markers are
// appended separately: they say where a message came from, not what it holds,
// and a reply carrying only those is still empty.
func populatedFields(m *models.Message) []string {
var out []string
add := func(present bool, name string) {
if present {
out = append(out, name)
for _, f := range contentFields {
if f.present(m) {
out = append(out, f.name)
}
}
add(m.Text != "", "text")
add(m.Caption != "", "caption")
add(m.Sticker != nil, "sticker")
add(len(m.Photo) > 0, "photo")
add(m.Animation != nil, "animation")
add(m.Video != nil, "video")
add(m.VideoNote != nil, "video_note")
add(m.Audio != nil, "audio")
add(m.Voice != nil, "voice")
add(m.Document != nil, "document")
add(m.Location != nil, "location")
add(m.Contact != nil, "contact")
add(m.Poll != nil, "poll")
add(m.Dice != nil, "dice")
add(m.Venue != nil, "venue")
add(m.Game != nil, "game")
add(m.ViaBot != nil, "via_bot")
add(m.ForwardOrigin != nil, "forward_origin")
if len(out) == 0 {
// The signature of a reply Telegram delivered but emptied.
out = append(out, "none")
}
if m.ViaBot != nil {
out = append(out, "via_bot")
}
if m.ForwardOrigin != nil {
out = append(out, "forward_origin")
}
return out
}
+70 -1
View File
@@ -26,13 +26,37 @@ 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."
// forwardAdvice is the one action that recovers a message this bot cannot
// read. A forwarded copy is a new message sent by a user, so it arrives with
// its content intact and captures like anything else.
//
// Shared by every refusal below, because "forward it and reply to the copy" is
// the answer to all of them — only the reason differs.
const forwardAdvice = "Forward it into this chat, then reply to your copy with /alias <name>."
// 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."
const otherBotRefusal = "Telegram does not let bots read other bots' messages, so I cannot save that one. " + forwardAdvice
// strippedReplyRefusal answers a reply Telegram delivered empty: it carried a
// message id but no content field at all.
//
// Separate from otherBotRefusal because the sender is not always marked — an
// anonymous or service-posted message can arrive the same way — and separate
// from unsupportedRefusal because listing the supported kinds would be a lie
// about what went wrong. The message may well have been a photo; this bot was
// simply never shown it.
const strippedReplyRefusal = "That message reached me with no content, so there is nothing for me to save. " + forwardAdvice
// noReplyRefusal answers /alias <name> that arrived with no reply attached.
//
// It cannot tell a caller who forgot to reply from one whose reply Telegram did
// not pass along, so it addresses both in order of likelihood.
const noReplyRefusal = "Reply to the message you want to save. If you did reply, Telegram did not pass that message to me. " + forwardAdvice
// fromAnotherBot reports whether a reply that captured nothing came from a bot.
//
@@ -42,6 +66,51 @@ func fromAnotherBot(replied *models.Message) bool {
return replied != nil && replied.From != nil && replied.From.IsBot
}
// contentFields names every content field a replied message can carry, with a
// test for its presence.
//
// One table, so the "did anything arrive at all" check and the debug line's
// field list cannot drift apart. It deliberately covers more than capture
// handles: a poll or a location is content this module refuses, which is a
// different answer to the caller than content that never arrived.
var contentFields = []struct {
name string
present func(*models.Message) bool
}{
{"text", func(m *models.Message) bool { return m.Text != "" }},
{"caption", func(m *models.Message) bool { return m.Caption != "" }},
{"sticker", func(m *models.Message) bool { return m.Sticker != nil }},
{"photo", func(m *models.Message) bool { return len(m.Photo) > 0 }},
{"animation", func(m *models.Message) bool { return m.Animation != nil }},
{"video", func(m *models.Message) bool { return m.Video != nil }},
{"video_note", func(m *models.Message) bool { return m.VideoNote != nil }},
{"audio", func(m *models.Message) bool { return m.Audio != nil }},
{"voice", func(m *models.Message) bool { return m.Voice != nil }},
{"document", func(m *models.Message) bool { return m.Document != nil }},
{"location", func(m *models.Message) bool { return m.Location != nil }},
{"contact", func(m *models.Message) bool { return m.Contact != nil }},
{"poll", func(m *models.Message) bool { return m.Poll != nil }},
{"dice", func(m *models.Message) bool { return m.Dice != nil }},
{"venue", func(m *models.Message) bool { return m.Venue != nil }},
{"game", func(m *models.Message) bool { return m.Game != nil }},
}
// hasContent reports whether replied carries any content field at all.
//
// False is the signature of a message that was delivered stripped rather than
// one whose kind is unsupported, and the two need different advice.
func hasContent(replied *models.Message) bool {
if replied == nil {
return false
}
for _, f := range contentFields {
if f.present(replied) {
return true
}
}
return false
}
// capture reduces a replied message to a storable alias.
//
// Order matters where Telegram populates more than one field: a GIF arrives as
+10 -1
View File
@@ -96,7 +96,10 @@ func (s *state) handleAlias(ctx context.Context, b *bot.Bot, update *models.Upda
return chathelper.Reply(ctx, b, msg, usageAlias)
}
if msg.ReplyToMessage == nil {
return chathelper.Reply(ctx, b, msg, usageAlias)
// Not necessarily a caller who forgot to reply: Telegram also delivers
// /alias with the reply detached, and the two are indistinguishable
// here, so the answer has to cover both.
return chathelper.Reply(ctx, b, msg, noReplyRefusal)
}
// A real command always wins at dispatch, so an alias sharing its name
@@ -115,6 +118,12 @@ func (s *state) handleAlias(ctx context.Context, b *bot.Bot, update *models.Upda
if fromAnotherBot(msg.ReplyToMessage) {
return chathelper.Reply(ctx, b, msg, otherBotRefusal)
}
// Nothing arrived to judge. Listing the supported kinds here would
// blame the format of a message this bot was never shown — the reply
// may well have been a photo.
if !hasContent(msg.ReplyToMessage) {
return chathelper.Reply(ctx, b, msg, strippedReplyRefusal)
}
return chathelper.Reply(ctx, b, msg, unsupportedRefusal)
}
entry.Name = display
+36 -7
View File
@@ -246,13 +246,6 @@ func TestAlias_RejectsUnsupportedMessage(t *testing.T) {
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"))
@@ -468,3 +461,39 @@ func TestAlias_UnsupportedFromHumanKeepsFormatAdvice(t *testing.T) {
rb.AssertSentText(t, "cannot be saved")
}
// A reply Telegram delivered empty must not be blamed on the message format:
// the caller gets the one action that recovers it.
func TestAlias_StrippedReplySuggestsForwarding(t *testing.T) {
rb := installAlias(t)
// A message id and a human sender, but no content field at all — what
// arrives when Telegram passes the reply along without its payload.
rb.Bot.ProcessUpdate(context.Background(), aliasCmd("gone", &models.Message{
ID: 9,
From: &models.User{ID: 7, FirstName: "Test"},
}))
rb.AssertSentText(t, "no content")
rb.AssertSentText(t, "Forward it into this chat")
}
// /alias with no reply attached covers both readings: a caller who forgot to
// reply, and a reply Telegram dropped on the way.
func TestAlias_NoReplySuggestsForwarding(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), aliasCmd("gone", nil))
rb.AssertSentText(t, "Reply to the message you want to save")
rb.AssertSentText(t, "Forward it into this chat")
}
// The other-bot refusal carries the same advice, so every unreadable reply
// ends with an action rather than only a reason.
func TestAlias_OtherBotRefusalSuggestsForwarding(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), aliasCmd("botmsg", &models.Message{
From: &models.User{ID: 555, IsBot: true, FirstName: "OtherBot"},
}))
rb.AssertSentText(t, "Forward it into this chat")
}