feat(telegram): include quoted reply context and media in inbound turns (#2200)
This commit is contained in:
parent
073cc3f65e
commit
2d8556205f
2 changed files with 319 additions and 0 deletions
|
|
@ -660,6 +660,23 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
content = cleaned
|
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
|
// 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.
|
||||||
// Only forum groups (IsForum) are handled; regular group reply threads
|
// 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,
|
"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 +733,122 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
return nil
|
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 {
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
|
@ -104,6 +105,13 @@ func successResponse(t *testing.T) *ta.Response {
|
||||||
return &ta.Response{Ok: true, Result: b}
|
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.
|
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
|
||||||
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
|
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
|
||||||
return newTestChannelWithConstructor(t, caller, &stubConstructor{})
|
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"])
|
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) {
|
func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
|
||||||
messageBus := bus.NewMessageBus()
|
messageBus := bus.NewMessageBus()
|
||||||
ch := &TelegramChannel{
|
ch := &TelegramChannel{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue