fix(telegram): preserve reply context without breaking commands (#2197)

This commit is contained in:
Alix-007 2026-03-31 01:39:25 +08:00
parent 7b3f47128f
commit 5f07db934b
3 changed files with 234 additions and 0 deletions

View file

@ -659,6 +659,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
} }
content = cleaned 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 // For forum topics, embed the thread ID as "chatID/threadID" so replies
// route to the correct topic and each topic gets its own session. // 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, "first_name": user.FirstName,
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), "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. // Set parent_peer metadata for per-topic agent binding.
if message.Chat.IsForum && threadID != 0 { if message.Chat.IsForum && threadID != 0 {
@ -713,6 +719,36 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return nil 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 { func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
if err != nil { if err != nil {

View file

@ -147,3 +147,53 @@ func TestIsBotMentioned_MentionEntityUnaffected(t *testing.T) {
t.Fatal("expected mention entity to be treated as bot mention") 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")
}
}
}

View file

@ -673,3 +673,151 @@ func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
default: 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)
}