mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-09 16:18:07 +00:00
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:
+19
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user