diff --git a/docs/aliases.md b/docs/aliases.md index 23041be..18724d0 100644 --- a/docs/aliases.md +++ b/docs/aliases.md @@ -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 diff --git a/internal/modules/alias/alias_debug.go b/internal/modules/alias/alias_debug.go index 6c2454c..6dc3788 100644 --- a/internal/modules/alias/alias_debug.go +++ b/internal/modules/alias/alias_debug.go @@ -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 } diff --git a/internal/modules/alias/alias_media.go b/internal/modules/alias/alias_media.go index 51f2af5..3ad65ac 100644 --- a/internal/modules/alias/alias_media.go +++ b/internal/modules/alias/alias_media.go @@ -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 ." + // 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 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 diff --git a/internal/modules/alias/handlers.go b/internal/modules/alias/handlers.go index 7cb6fa3..5e9267e 100644 --- a/internal/modules/alias/handlers.go +++ b/internal/modules/alias/handlers.go @@ -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 diff --git a/internal/modules/alias/handlers_test.go b/internal/modules/alias/handlers_test.go index 7ef8e24..fc5153d 100644 --- a/internal/modules/alias/handlers_test.go +++ b/internal/modules/alias/handlers_test.go @@ -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") +}