From 5f07db934b506a6f4de6556c825a137c4e19c7dc Mon Sep 17 00:00:00 2001 From: Alix-007 <267018309+Alix-007@users.noreply.github.com> Date: Tue, 31 Mar 2026 01:39:25 +0800 Subject: [PATCH] fix(telegram): preserve reply context without breaking commands (#2197) --- pkg/channels/telegram/telegram.go | 36 +++++ .../telegram_group_command_filter_test.go | 50 ++++++ pkg/channels/telegram/telegram_test.go | 148 ++++++++++++++++++ 3 files changed, 234 insertions(+) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index f64a8f79b..da1cf8645 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -659,6 +659,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes } content = cleaned } + if quoted := formatQuotedReply(message); quoted != "" && !isCommandMessage(content) { + content = fmt.Sprintf("%s\n\n%s", quoted, content) + } // For forum topics, embed the thread ID as "chatID/threadID" so replies // route to the correct topic and each topic gets its own session. @@ -693,6 +696,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "first_name": user.FirstName, "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } + if message.ReplyToMessage != nil { + metadata["reply_to_message_id"] = fmt.Sprintf("%d", message.ReplyToMessage.MessageID) + } // Set parent_peer metadata for per-topic agent binding. if message.Chat.IsForum && threadID != 0 { @@ -713,6 +719,36 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes return nil } +func formatQuotedReply(message *telego.Message) string { + if message == nil || message.ReplyToMessage == nil { + return "" + } + + reply := message.ReplyToMessage + refContent := strings.TrimSpace(reply.Text) + if refContent == "" { + refContent = strings.TrimSpace(reply.Caption) + } + if refContent == "" { + return "" + } + + refAuthor := "unknown" + if reply.From != nil { + if username := strings.TrimSpace(reply.From.Username); username != "" { + refAuthor = username + } else if firstName := strings.TrimSpace(reply.From.FirstName); firstName != "" { + refAuthor = firstName + } + } + + return fmt.Sprintf("[quoted message from %s]: %s", refAuthor, refContent) +} + +func isCommandMessage(content string) bool { + return commands.HasCommandPrefix(content) +} + func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { diff --git a/pkg/channels/telegram/telegram_group_command_filter_test.go b/pkg/channels/telegram/telegram_group_command_filter_test.go index 614b2ca7f..67d424875 100644 --- a/pkg/channels/telegram/telegram_group_command_filter_test.go +++ b/pkg/channels/telegram/telegram_group_command_filter_test.go @@ -147,3 +147,53 @@ func TestIsBotMentioned_MentionEntityUnaffected(t *testing.T) { t.Fatal("expected mention entity to be treated as bot mention") } } + +func TestHandleMessage_GroupReplyCommandKeepsCommandPrefixAndMetadata(t *testing.T) { + ch, messageBus := newGroupMentionOnlyChannel(t, "testbot") + + msg := &telego.Message{ + Text: "/new@testbot", + Entities: []telego.MessageEntity{{ + Type: telego.EntityTypeBotCommand, + Offset: 0, + Length: len([]rune("/new@testbot")), + }}, + MessageID: 43, + Chat: telego.Chat{ + ID: 123, + Type: "group", + }, + From: &telego.User{ + ID: 7, + FirstName: "Alice", + }, + ReplyToMessage: &telego.Message{ + MessageID: 101, + Text: "old context", + From: &telego.User{ + Username: "bob", + }, + }, + } + + if err := ch.handleMessage(context.Background(), msg); err != nil { + t.Fatalf("handleMessage error: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + select { + case <-ctx.Done(): + t.Fatal("timeout waiting for message to be forwarded") + case inbound, ok := <-messageBus.InboundChan(): + if !ok { + t.Fatal("expected inbound message to be forwarded") + } + if inbound.Content != "/new" { + t.Fatalf("content=%q want=%q", inbound.Content, "/new") + } + if got := inbound.Metadata["reply_to_message_id"]; got != "101" { + t.Fatalf("reply_to_message_id=%q want=%q", got, "101") + } + } +} diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index fd189d9a7..42ca109b3 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -673,3 +673,151 @@ func TestHandleMessage_EmptyContent_Ignored(t *testing.T) { default: } } + +func TestHandleMessage_ReplyCarriesMetadataAndQuotedContext(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "what do you think?", + MessageID: 21, + Chat: telego.Chat{ + ID: 12345, + Type: "private", + }, + From: &telego.User{ + ID: 10, + FirstName: "Bob", + }, + ReplyToMessage: &telego.Message{ + MessageID: 99, + Text: "this is the old message", + From: &telego.User{ + Username: "alice", + }, + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok) + assert.Equal(t, "99", inbound.Metadata["reply_to_message_id"]) + assert.Equal( + t, + "[quoted message from alice]: this is the old message\n\nwhat do you think?", + inbound.Content, + ) +} + +func TestHandleMessage_ReplyWithoutTextOnlySetsReplyMetadata(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "ping", + MessageID: 22, + Chat: telego.Chat{ + ID: 12345, + Type: "private", + }, + From: &telego.User{ + ID: 10, + FirstName: "Bob", + }, + ReplyToMessage: &telego.Message{ + MessageID: 100, + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok) + assert.Equal(t, "100", inbound.Metadata["reply_to_message_id"]) + assert.Equal(t, "ping", inbound.Content) +} + +func TestHandleMessage_ReplyCommandKeepsCommandPrefix(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "/new", + MessageID: 23, + Chat: telego.Chat{ + ID: 12345, + Type: "private", + }, + From: &telego.User{ + ID: 10, + FirstName: "Bob", + }, + ReplyToMessage: &telego.Message{ + MessageID: 101, + Text: "old context text", + From: &telego.User{ + Username: "alice", + }, + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok) + assert.Equal(t, "101", inbound.Metadata["reply_to_message_id"]) + assert.Equal(t, "/new", inbound.Content) +} + +func TestHandleMessage_ReplyBangCommandKeepsCommandPrefix(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "!new", + MessageID: 24, + Chat: telego.Chat{ + ID: 12345, + Type: "private", + }, + From: &telego.User{ + ID: 10, + FirstName: "Bob", + }, + ReplyToMessage: &telego.Message{ + MessageID: 102, + Text: "old context text", + From: &telego.User{ + Username: "alice", + }, + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok) + assert.Equal(t, "102", inbound.Metadata["reply_to_message_id"]) + assert.Equal(t, "!new", inbound.Content) +}