mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
feat(alias): log the shape of a capture at debug level
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.
This commit is contained in:
1 parent
2a86e028f6
commit
cb86ec4cf3
4 files changed
+219
-1
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user