feat(telegram): include quoted reply context and media in inbound turns (#2200)

This commit is contained in:
Mauro
2026-03-31 04:46:41 +02:00
committed by GitHub
parent 073cc3f65e
commit 2d8556205f
2 changed files with 319 additions and 0 deletions
+136
View File
@@ -660,6 +660,23 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
content = cleaned
}
if message.ReplyToMessage != nil {
quotedMedia := quotedTelegramMediaRefs(
message.ReplyToMessage,
func(fileID, ext, filename string) string {
localPath := c.downloadFile(ctx, fileID, ext)
if localPath == "" {
return ""
}
return storeMedia(localPath, filename)
},
)
if len(quotedMedia) > 0 {
mediaPaths = append(quotedMedia, mediaPaths...)
}
content = c.prependTelegramQuotedReply(content, message.ReplyToMessage)
}
// For forum topics, embed the thread ID as "chatID/threadID" so replies
// route to the correct topic and each topic gets its own session.
// Only forum groups (IsForum) are handled; regular group reply threads
@@ -693,6 +710,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 +733,122 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return nil
}
func (c *TelegramChannel) prependTelegramQuotedReply(content string, reply *telego.Message) string {
quoted := strings.TrimSpace(telegramQuotedContent(reply))
if quoted == "" {
return content
}
author := telegramQuotedAuthor(reply)
role := c.telegramQuotedRole(reply)
if strings.TrimSpace(content) == "" {
return fmt.Sprintf("[quoted %s message from %s]: %s", role, author, quoted)
}
return fmt.Sprintf("[quoted %s message from %s]: %s\n\n%s", role, author, quoted, content)
}
func (c *TelegramChannel) telegramQuotedRole(message *telego.Message) string {
if message == nil {
return "unknown"
}
if message.From != nil {
if !message.From.IsBot {
return "user"
}
if c.isOwnBotUser(message.From) {
return "assistant"
}
return "bot"
}
if message.SenderChat != nil {
return "chat"
}
return "unknown"
}
func (c *TelegramChannel) isOwnBotUser(user *telego.User) bool {
if c == nil || c.bot == nil || user == nil || !user.IsBot {
return false
}
if botID := c.bot.ID(); botID != 0 && user.ID == botID {
return true
}
botUsername := strings.TrimPrefix(strings.TrimSpace(c.bot.Username()), "@")
if botUsername == "" {
return false
}
return strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(user.Username), "@"), botUsername)
}
func telegramQuotedAuthor(message *telego.Message) string {
if message == nil || message.From == nil {
return "unknown"
}
if username := strings.TrimSpace(message.From.Username); username != "" {
return username
}
if firstName := strings.TrimSpace(message.From.FirstName); firstName != "" {
return firstName
}
return "unknown"
}
func telegramQuotedContent(message *telego.Message) string {
if message == nil {
return ""
}
var parts []string
if text := strings.TrimSpace(message.Text); text != "" {
parts = append(parts, text)
}
if caption := strings.TrimSpace(message.Caption); caption != "" {
parts = append(parts, caption)
}
switch {
case len(message.Photo) > 0:
parts = append(parts, "[image: photo]")
}
switch {
case message.Voice != nil:
parts = append(parts, "[voice]")
case message.Audio != nil:
parts = append(parts, "[audio]")
}
if message.Document != nil {
parts = append(parts, "[file]")
}
return strings.Join(parts, "\n")
}
func quotedTelegramMediaRefs(
message *telego.Message,
resolve func(fileID, ext, filename string) string,
) []string {
if message == nil || resolve == nil {
return nil
}
var refs []string
if message.Voice != nil {
if ref := resolve(message.Voice.FileID, ".ogg", "voice.ogg"); ref != "" {
refs = append(refs, ref)
}
}
if message.Audio != nil {
if ref := resolve(message.Audio.FileID, ".mp3", "audio.mp3"); ref != "" {
refs = append(refs, ref)
}
}
return refs
}
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
if err != nil {
+183
View File
@@ -7,6 +7,7 @@ import (
"io"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
@@ -104,6 +105,13 @@ func successResponse(t *testing.T) *ta.Response {
return &ta.Response{Ok: true, Result: b}
}
func successUserResponse(t *testing.T, user *telego.User) *ta.Response {
t.Helper()
b, err := json.Marshal(user)
require.NoError(t, err)
return &ta.Response{Ok: true, Result: b}
}
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
return newTestChannelWithConstructor(t, caller, &stubConstructor{})
@@ -642,6 +650,181 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
assert.Empty(t, inbound.Metadata["parent_peer_id"])
}
func assertHandleMessageQuotedUserReply(
t *testing.T,
chatID int64,
messageID int,
userID int64,
userName string,
userText string,
replyMessageID int,
replyText string,
replyCaption string,
replyAuthorID int64,
replyAuthorName string,
expectedContent string,
) {
t.Helper()
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
ctx: context.Background(),
}
msg := &telego.Message{
Text: userText,
MessageID: messageID,
Chat: telego.Chat{
ID: chatID,
Type: "private",
},
From: &telego.User{
ID: userID,
FirstName: userName,
},
ReplyToMessage: &telego.Message{
MessageID: replyMessageID,
Text: replyText,
Caption: replyCaption,
From: &telego.User{
ID: replyAuthorID,
FirstName: replyAuthorName,
},
},
}
err := ch.handleMessage(context.Background(), msg)
require.NoError(t, err)
inbound, ok := <-messageBus.InboundChan()
require.True(t, ok)
assert.Equal(t, strconv.Itoa(replyMessageID), inbound.Metadata["reply_to_message_id"])
assert.Equal(t, expectedContent, inbound.Content)
}
func TestHandleMessage_ReplyToMessage_PrependsQuotedTextAndMetadata(t *testing.T) {
assertHandleMessageQuotedUserReply(
t,
456,
21,
11,
"Alice",
"follow up",
99,
"old context",
"",
12,
"Bob",
"[quoted user message from Bob]: old context\n\nfollow up",
)
}
func TestHandleMessage_ReplyToMessage_UsesCaptionWhenQuotedTextMissing(t *testing.T) {
assertHandleMessageQuotedUserReply(
t,
789,
22,
13,
"Carol",
"answer this",
100,
"",
"caption context",
14,
"Dave",
"[quoted user message from Dave]: caption context\n\nanswer this",
)
}
func TestHandleMessage_ReplyToOwnBotMessage_UsesAssistantRole(t *testing.T) {
messageBus := bus.NewMessageBus()
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
if strings.Contains(url, "getMe") {
return successUserResponse(t, &telego.User{
ID: 42,
IsBot: true,
FirstName: "Pico",
Username: "afjcjsbx_picoclaw_bot",
}), nil
}
t.Fatalf("unexpected API call: %s", url)
return nil, nil
},
}
ch := newTestChannel(t, caller)
ch.BaseChannel = channels.NewBaseChannel("telegram", nil, messageBus, nil)
ch.ctx = context.Background()
msg := &telego.Message{
Text: "ti ricordi questo file?",
MessageID: 23,
Chat: telego.Chat{
ID: 999,
Type: "private",
},
From: &telego.User{
ID: 15,
FirstName: "Eve",
},
ReplyToMessage: &telego.Message{
MessageID: 101,
Text: "Fatto! Ho creato il file notizie_2026_03_28.md",
From: &telego.User{
ID: 42,
IsBot: true,
FirstName: "Pico",
Username: "afjcjsbx_picoclaw_bot",
},
},
}
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,
"[quoted assistant message from afjcjsbx_picoclaw_bot]: Fatto! Ho creato il file notizie_2026_03_28.md\n\nti ricordi questo file?",
inbound.Content,
)
}
func TestTelegramQuotedContent_IncludesVoiceMarkerAlongsideCaption(t *testing.T) {
msg := &telego.Message{
Caption: "listen to this",
Voice: &telego.Voice{
FileID: "voice-file",
},
}
assert.Equal(t, "listen to this\n[voice]", telegramQuotedContent(msg))
}
func TestQuotedTelegramMediaRefs_ResolvesQuotedAudioInOrder(t *testing.T) {
msg := &telego.Message{
Voice: &telego.Voice{FileID: "voice-file"},
Audio: &telego.Audio{FileID: "audio-file"},
}
var calls []string
refs := quotedTelegramMediaRefs(msg, func(fileID, ext, filename string) string {
calls = append(calls, fileID+"|"+ext+"|"+filename)
return "ref://" + filename
})
assert.Equal(
t,
[]string{"voice-file|.ogg|voice.ogg", "audio-file|.mp3|audio.mp3"},
calls,
)
assert.Equal(t, []string{"ref://voice.ogg", "ref://audio.mp3"}, refs)
}
func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{