mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(telegram): include quoted reply context and media in inbound turns (#2200)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user