From cb86ec4cf3462ef95dd834ea47a6cd02e9d1ca38 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 8 Sep 2026 16:02:07 +0700 Subject: [PATCH] feat(alias): log the shape of a capture at debug level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capture failures worth debugging are all about what Telegram did not deliver: a reply stripped of its content, a caption where text was expected, or a kind that falls through the switch. None of that is visible from the user-facing refusal, which only says "unsupported". One alias_capture line per /alias reports field names, lengths and counts — never message text, so aliased messages cannot travel with the logs. --- docs/aliases.md | 19 ++++ internal/modules/alias/alias_debug.go | 87 +++++++++++++++++ internal/modules/alias/alias_debug_test.go | 106 +++++++++++++++++++++ internal/modules/alias/handlers.go | 8 +- 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 internal/modules/alias/alias_debug.go create mode 100644 internal/modules/alias/alias_debug_test.go diff --git a/docs/aliases.md b/docs/aliases.md index bb3c6a7..ca7b078 100644 --- a/docs/aliases.md +++ b/docs/aliases.md @@ -178,5 +178,24 @@ failure: the reason `chathelper.Reply` documents: without it Telegram routes the message to a supergroup's General topic instead of the topic the command was typed in. +## Debugging a capture + +Set `LOG_LEVEL=debug` and every `/alias` logs one `alias_capture` line +describing what Telegram actually delivered: + +``` +alias_capture reply=present reply_id=9 fields=none text_len=0 caption_len=0 + entities=0 captured=false from_id=555 from_bot=true +``` + +`fields=none captured=false from_bot=true` is the signature of another bot's +message arriving stripped. `reply=absent` means Telegram delivered the command +with no reply attached at all — indistinguishable from the caller forgetting to +reply, which is why the line exists. + +It reports **shape, never content**: field names, lengths and counts, but no +message text. The line lands in stdout and whatever ships it, so aliased +messages must not travel with it; a test asserts nothing leaks. + Both handlers run under a 10-second deadline. The bot processes updates one at a time, so that bound is what keeps a slow store from stalling other users. diff --git a/internal/modules/alias/alias_debug.go b/internal/modules/alias/alias_debug.go new file mode 100644 index 0000000..6c2454c --- /dev/null +++ b/internal/modules/alias/alias_debug.go @@ -0,0 +1,87 @@ +package alias + +import ( + "strings" + + "github.com/go-telegram/bot/models" +) + +// replyShape describes what /alias was handed, for the debug log. +// +// Enable with LOG_LEVEL=debug; silent otherwise. It exists because the failures +// worth debugging here are all about what Telegram *did not* deliver: a reply +// stripped of its content (another bot's message), a caption where text was +// expected, or a kind that falls through capture's switch. Those are invisible +// from the user-facing refusal, which only says the message was unsupported. +// +// Deliberately reports shape, never content. Lengths and field names are +// enough to explain a capture failure, and message text in a log line would +// mean every aliased message ending up in stdout and whatever ships it. +func replyShape(replied *models.Message, entry Alias, ok bool) []any { + if replied == nil { + // The case that motivated this: Telegram can deliver /alias with no + // reply attached at all, which is indistinguishable from the caller + // forgetting to reply. + return []any{"reply", "absent"} + } + + attrs := []any{ + "reply", "present", + "reply_id", replied.ID, + "fields", strings.Join(populatedFields(replied), ","), + "text_len", len(replied.Text), + "caption_len", len(replied.Caption), + "entities", len(replied.Entities) + len(replied.CaptionEntities), + "captured", ok, + } + if ok { + attrs = append(attrs, "kind", entry.Kind) + } + if replied.From != nil { + attrs = append(attrs, "from_id", replied.From.ID, "from_bot", replied.From.IsBot) + } else { + // No sender at all — an anonymous channel post or a stripped reply. + attrs = append(attrs, "from", "absent") + } + if replied.SenderChat != nil { + attrs = append(attrs, "sender_chat", replied.SenderChat.ID) + } + return attrs +} + +// 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". +func populatedFields(m *models.Message) []string { + var out []string + add := func(present bool, name string) { + if present { + out = append(out, 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") + } + return out +} diff --git a/internal/modules/alias/alias_debug_test.go b/internal/modules/alias/alias_debug_test.go new file mode 100644 index 0000000..2933cf2 --- /dev/null +++ b/internal/modules/alias/alias_debug_test.go @@ -0,0 +1,106 @@ +package alias + +import ( + "fmt" + "strings" + "testing" + + "github.com/go-telegram/bot/models" +) + +// attrs renders replyShape's key/value pairs into one string for assertions. +func attrs(t *testing.T, replied *models.Message) string { + t.Helper() + entry, ok := capture(replied) + pairs := replyShape(replied, entry, ok) + if len(pairs)%2 != 0 { + t.Fatalf("replyShape returned %d values, want key/value pairs", len(pairs)) + } + var sb strings.Builder + for i := 0; i < len(pairs); i += 2 { + fmt.Fprintf(&sb, "%v=%v ", pairs[i], pairs[i+1]) + } + return sb.String() +} + +func TestReplyShape(t *testing.T) { + cases := []struct { + name string + replied *models.Message + want []string + }{ + { + // The case this exists for: /alias arrives with no reply attached, + // which looks the same to the caller as forgetting to reply. + name: "absent reply", + replied: nil, + want: []string{"reply=absent"}, + }, + { + // What another bot's message looks like once Telegram strips it: + // delivered, from a bot, carrying nothing. + name: "stripped bot reply", + replied: &models.Message{ID: 9, From: &models.User{ID: 555, IsBot: true}}, + want: []string{"reply=present", "fields=none", "captured=false", "from_bot=true", "from_id=555"}, + }, + { + name: "text reply", + replied: &models.Message{ + ID: 4, + From: &models.User{ID: 7}, + Text: "hello", + Entities: []models.MessageEntity{{Type: models.MessageEntityTypeBold, Length: 5}}, + }, + want: []string{"fields=text", "text_len=5", "entities=1", "captured=true", "kind=text", "from_bot=false"}, + }, + { + // A kind capture refuses still reports what arrived, so + // "unsupported" can be told apart from "empty". + name: "unsupported kind", + replied: &models.Message{ID: 5, From: &models.User{ID: 7}, Location: &models.Location{Latitude: 1}}, + want: []string{"fields=location", "captured=false"}, + }, + { + name: "photo with caption", + replied: &models.Message{ + ID: 6, + From: &models.User{ID: 7}, + Photo: []models.PhotoSize{{FileID: "p"}}, + Caption: "cap", + }, + want: []string{"caption", "photo", "caption_len=3", "kind=photo"}, + }, + { + // No From at all — an anonymous channel post, or a stripped reply. + name: "no sender", + replied: &models.Message{ID: 7, SenderChat: &models.Chat{ID: -100}}, + want: []string{"from=absent", "sender_chat=-100"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := attrs(t, tc.replied) + for _, want := range tc.want { + if !strings.Contains(got, want) { + t.Errorf("replyShape = %q, missing %q", got, want) + } + } + }) + } +} + +// The debug line reports shape, never content: it lands in stdout and whatever +// ships it, so aliased message text must not travel with it. +func TestReplyShape_LogsNoMessageContent(t *testing.T) { + const secret = "SENSITIVE-MESSAGE-BODY" + replied := &models.Message{ + ID: 1, + From: &models.User{ID: 7, FirstName: secret, Username: secret}, + Text: secret, + Caption: secret, + } + + if got := attrs(t, replied); strings.Contains(got, secret) { + t.Errorf("replyShape leaked message content: %q", got) + } +} diff --git a/internal/modules/alias/handlers.go b/internal/modules/alias/handlers.go index ed29f83..b10fc08 100644 --- a/internal/modules/alias/handlers.go +++ b/internal/modules/alias/handlers.go @@ -85,6 +85,13 @@ func (s *state) handleAlias(ctx context.Context, b *bot.Bot, update *models.Upda return nil } + // Capture first, only so the debug line below can report what arrived even + // on the early returns. It is a pure read of the update — no store, no API + // — so running it before the name checks costs nothing and changes nothing + // about which reply the caller sees. + entry, ok := capture(msg.ReplyToMessage) + log.Debug("alias_capture", replyShape(msg.ReplyToMessage, entry, ok)...) + display, key, err := parseName(chathelper.ArgAfterCommand(msg.Text)) if err != nil { return chathelper.Reply(ctx, b, msg, usageAlias) @@ -105,7 +112,6 @@ func (s *state) handleAlias(ctx context.Context, b *bot.Bot, update *models.Upda } } - entry, ok := capture(msg.ReplyToMessage) if !ok { if fromAnotherBot(msg.ReplyToMessage) { return chathelper.Reply(ctx, b, msg, otherBotRefusal)