mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
1670 lines
50 KiB
Go
1670 lines
50 KiB
Go
package telegram
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mymmrac/telego"
|
|
ta "github.com/mymmrac/telego/telegoapi"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
|
)
|
|
|
|
const testToken = "1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc"
|
|
|
|
// stubCaller implements ta.Caller for testing.
|
|
type stubCaller struct {
|
|
calls []stubCall
|
|
callFn func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error)
|
|
}
|
|
|
|
type stubCall struct {
|
|
URL string
|
|
Data *ta.RequestData
|
|
}
|
|
|
|
func (s *stubCaller) Call(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
s.calls = append(s.calls, stubCall{URL: url, Data: data})
|
|
return s.callFn(ctx, url, data)
|
|
}
|
|
|
|
// stubConstructor implements ta.RequestConstructor for testing.
|
|
type stubConstructor struct{}
|
|
|
|
type multipartCall struct {
|
|
Parameters map[string]string
|
|
FileSizes map[string]int
|
|
}
|
|
|
|
func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) {
|
|
b, err := json.Marshal(parameters)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &ta.RequestData{
|
|
ContentType: "application/json",
|
|
BodyRaw: b,
|
|
}, nil
|
|
}
|
|
|
|
func (s *stubConstructor) MultipartRequest(
|
|
parameters map[string]string,
|
|
files map[string]ta.NamedReader,
|
|
) (*ta.RequestData, error) {
|
|
return &ta.RequestData{}, nil
|
|
}
|
|
|
|
type multipartRecordingConstructor struct {
|
|
stubConstructor
|
|
calls []multipartCall
|
|
}
|
|
|
|
func (s *multipartRecordingConstructor) MultipartRequest(
|
|
parameters map[string]string,
|
|
files map[string]ta.NamedReader,
|
|
) (*ta.RequestData, error) {
|
|
call := multipartCall{
|
|
Parameters: make(map[string]string, len(parameters)),
|
|
FileSizes: make(map[string]int, len(files)),
|
|
}
|
|
for k, v := range parameters {
|
|
call.Parameters[k] = v
|
|
}
|
|
for field, file := range files {
|
|
if file == nil {
|
|
continue
|
|
}
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
call.FileSizes[field] = len(data)
|
|
}
|
|
s.calls = append(s.calls, call)
|
|
return &ta.RequestData{}, nil
|
|
}
|
|
|
|
// successResponse returns a ta.Response that telego will treat as a successful SendMessage.
|
|
func successResponse(t *testing.T) *ta.Response {
|
|
return successResponseWithMessageID(t, 1)
|
|
}
|
|
|
|
func successResponseWithMessageID(t *testing.T, messageID int) *ta.Response {
|
|
t.Helper()
|
|
msg := &telego.Message{MessageID: messageID}
|
|
b, err := json.Marshal(msg)
|
|
require.NoError(t, err)
|
|
return &ta.Response{Ok: true, Result: b}
|
|
}
|
|
|
|
func successMediaGroupResponse(t *testing.T, messageIDs ...int) *ta.Response {
|
|
t.Helper()
|
|
messages := make([]telego.Message, 0, len(messageIDs))
|
|
for _, messageID := range messageIDs {
|
|
messages = append(messages, telego.Message{MessageID: messageID})
|
|
}
|
|
b, err := json.Marshal(messages)
|
|
require.NoError(t, err)
|
|
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{})
|
|
}
|
|
|
|
func newTestChannelWithConstructor(
|
|
t *testing.T,
|
|
caller *stubCaller,
|
|
constructor ta.RequestConstructor,
|
|
) *TelegramChannel {
|
|
t.Helper()
|
|
|
|
bot, err := telego.NewBot(testToken,
|
|
telego.WithAPICaller(caller),
|
|
telego.WithRequestConstructor(constructor),
|
|
telego.WithDiscardLogger(),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
base := channels.NewBaseChannel("telegram", nil, nil, nil,
|
|
channels.WithMaxMessageLength(4000),
|
|
)
|
|
base.SetRunning(true)
|
|
|
|
return &TelegramChannel{
|
|
BaseChannel: base,
|
|
bot: bot,
|
|
chatIDs: make(map[string]int64),
|
|
bc: &config.Channel{Type: config.ChannelTelegram, Enabled: true},
|
|
tgCfg: &config.TelegramSettings{},
|
|
progress: channels.NewToolFeedbackAnimator(nil),
|
|
}
|
|
}
|
|
|
|
func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) {
|
|
constructor := &multipartRecordingConstructor{}
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
switch {
|
|
case strings.Contains(url, "sendPhoto"):
|
|
return nil, errors.New(`api: 400 "Bad Request: PHOTO_INVALID_DIMENSIONS"`)
|
|
case strings.Contains(url, "sendDocument"):
|
|
return successResponse(t), nil
|
|
default:
|
|
t.Fatalf("unexpected API call: %s", url)
|
|
return nil, nil
|
|
}
|
|
},
|
|
}
|
|
ch := newTestChannelWithConstructor(t, caller, constructor)
|
|
|
|
store := media.NewFileMediaStore()
|
|
ch.SetMediaStore(store)
|
|
|
|
tmpDir := t.TempDir()
|
|
localPath := filepath.Join(tmpDir, "woodstock-en-10s.png")
|
|
content := []byte("fake-png-content")
|
|
require.NoError(t, os.WriteFile(localPath, content, 0o644))
|
|
|
|
ref, err := store.Store(
|
|
localPath,
|
|
media.MediaMeta{Filename: "woodstock-en-10s.png", ContentType: "image/png"},
|
|
"scope-1",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "12345",
|
|
Parts: []bus.MediaPart{{
|
|
Type: "image",
|
|
Ref: ref,
|
|
Caption: "caption",
|
|
}},
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.Len(t, caller.calls, 2)
|
|
assert.Contains(t, caller.calls[0].URL, "sendPhoto")
|
|
assert.Contains(t, caller.calls[1].URL, "sendDocument")
|
|
require.Len(t, constructor.calls, 2)
|
|
assert.Equal(t, len(content), constructor.calls[0].FileSizes["photo"])
|
|
assert.Equal(t, len(content), constructor.calls[1].FileSizes["document"])
|
|
assert.Equal(t, "caption", constructor.calls[1].Parameters["caption"])
|
|
}
|
|
|
|
func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {
|
|
constructor := &multipartRecordingConstructor{}
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
return nil, errors.New("api: 500 \"server exploded\"")
|
|
},
|
|
}
|
|
ch := newTestChannelWithConstructor(t, caller, constructor)
|
|
|
|
store := media.NewFileMediaStore()
|
|
ch.SetMediaStore(store)
|
|
|
|
tmpDir := t.TempDir()
|
|
localPath := filepath.Join(tmpDir, "image.png")
|
|
require.NoError(t, os.WriteFile(localPath, []byte("fake-png-content"), 0o644))
|
|
|
|
ref, err := store.Store(localPath, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1")
|
|
require.NoError(t, err)
|
|
|
|
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "12345",
|
|
Parts: []bus.MediaPart{{
|
|
Type: "image",
|
|
Ref: ref,
|
|
}},
|
|
})
|
|
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, channels.ErrTemporary)
|
|
require.Len(t, caller.calls, 1)
|
|
assert.Contains(t, caller.calls[0].URL, "sendPhoto")
|
|
require.Len(t, constructor.calls, 1)
|
|
assert.NotContains(t, caller.calls[0].URL, "sendDocument")
|
|
}
|
|
|
|
func TestSendMedia_MultipleImagesUseMediaGroup(t *testing.T) {
|
|
constructor := &multipartRecordingConstructor{}
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
if strings.Contains(url, "sendMediaGroup") {
|
|
return successMediaGroupResponse(t, 101, 102), nil
|
|
}
|
|
t.Fatalf("unexpected API call: %s", url)
|
|
return nil, nil
|
|
},
|
|
}
|
|
ch := newTestChannelWithConstructor(t, caller, constructor)
|
|
|
|
store := media.NewFileMediaStore()
|
|
ch.SetMediaStore(store)
|
|
|
|
tmpDir := t.TempDir()
|
|
firstPath := filepath.Join(tmpDir, "first.png")
|
|
secondPath := filepath.Join(tmpDir, "second.png")
|
|
require.NoError(t, os.WriteFile(firstPath, []byte("first-image"), 0o644))
|
|
require.NoError(t, os.WriteFile(secondPath, []byte("second-image"), 0o644))
|
|
|
|
firstRef, err := store.Store(firstPath, media.MediaMeta{Filename: "first.png", ContentType: "image/png"}, "scope-1")
|
|
require.NoError(t, err)
|
|
secondRef, err := store.Store(secondPath, media.MediaMeta{Filename: "second.png", ContentType: "image/png"}, "scope-1")
|
|
require.NoError(t, err)
|
|
|
|
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "12345",
|
|
Parts: []bus.MediaPart{
|
|
{Type: "image", Ref: firstRef, Caption: "album caption"},
|
|
{Type: "image", Ref: secondRef},
|
|
},
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"101", "102"}, ids)
|
|
require.Len(t, caller.calls, 1)
|
|
assert.Contains(t, caller.calls[0].URL, "sendMediaGroup")
|
|
require.Len(t, constructor.calls, 1)
|
|
require.Len(t, constructor.calls[0].FileSizes, 2)
|
|
|
|
var mediaPayload []map[string]any
|
|
require.NoError(t, json.Unmarshal([]byte(constructor.calls[0].Parameters["media"]), &mediaPayload))
|
|
require.Len(t, mediaPayload, 2)
|
|
assert.Equal(t, "album caption", mediaPayload[0]["caption"])
|
|
_, hasSecondCaption := mediaPayload[1]["caption"]
|
|
assert.False(t, hasSecondCaption)
|
|
}
|
|
|
|
func TestSendMedia_MoreThanTenImagesSplitIntoMediaGroups(t *testing.T) {
|
|
constructor := &multipartRecordingConstructor{}
|
|
callIndex := 0
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
if !strings.Contains(url, "sendMediaGroup") {
|
|
t.Fatalf("unexpected API call: %s", url)
|
|
}
|
|
callIndex++
|
|
if callIndex == 1 {
|
|
return successMediaGroupResponse(t, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010), nil
|
|
}
|
|
if callIndex == 2 {
|
|
return successMediaGroupResponse(t, 1011, 1012, 1013, 1014, 1015), nil
|
|
}
|
|
t.Fatalf("unexpected sendMediaGroup call #%d", callIndex)
|
|
return nil, nil
|
|
},
|
|
}
|
|
ch := newTestChannelWithConstructor(t, caller, constructor)
|
|
|
|
store := media.NewFileMediaStore()
|
|
ch.SetMediaStore(store)
|
|
|
|
tmpDir := t.TempDir()
|
|
parts := make([]bus.MediaPart, 0, 15)
|
|
for i := 0; i < 15; i++ {
|
|
path := filepath.Join(tmpDir, "image-"+strconv.Itoa(i)+".png")
|
|
require.NoError(t, os.WriteFile(path, []byte("img-"+strconv.Itoa(i)), 0o644))
|
|
ref, err := store.Store(path, media.MediaMeta{Filename: filepath.Base(path), ContentType: "image/png"}, "scope-1")
|
|
require.NoError(t, err)
|
|
part := bus.MediaPart{Type: "image", Ref: ref}
|
|
if i == 0 {
|
|
part.Caption = "long album caption"
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
|
|
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "12345",
|
|
Parts: parts,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{
|
|
"1001", "1002", "1003", "1004", "1005",
|
|
"1006", "1007", "1008", "1009", "1010",
|
|
"1011", "1012", "1013", "1014", "1015",
|
|
}, ids)
|
|
require.Len(t, caller.calls, 2)
|
|
require.Len(t, constructor.calls, 2)
|
|
}
|
|
|
|
func TestSendMedia_SingleImageLongCaptionSendsTextFirst(t *testing.T) {
|
|
constructor := &multipartRecordingConstructor{}
|
|
longCaption := strings.Repeat("a", telegramCaptionLimit) + " tail overflow"
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
switch {
|
|
case strings.Contains(url, "sendMessage"):
|
|
return successResponseWithMessageID(t, 201), nil
|
|
case strings.Contains(url, "sendPhoto"):
|
|
return successResponseWithMessageID(t, 202), nil
|
|
default:
|
|
t.Fatalf("unexpected API call: %s", url)
|
|
return nil, nil
|
|
}
|
|
},
|
|
}
|
|
ch := newTestChannelWithConstructor(t, caller, constructor)
|
|
|
|
store := media.NewFileMediaStore()
|
|
ch.SetMediaStore(store)
|
|
|
|
tmpDir := t.TempDir()
|
|
path := filepath.Join(tmpDir, "image.png")
|
|
require.NoError(t, os.WriteFile(path, []byte("img"), 0o644))
|
|
ref, err := store.Store(path, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1")
|
|
require.NoError(t, err)
|
|
|
|
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "12345",
|
|
Parts: []bus.MediaPart{{
|
|
Type: "image",
|
|
Ref: ref,
|
|
Caption: longCaption,
|
|
}},
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"201", "202"}, ids)
|
|
require.Len(t, caller.calls, 2)
|
|
assert.Contains(t, caller.calls[0].URL, "sendMessage")
|
|
assert.Contains(t, caller.calls[1].URL, "sendPhoto")
|
|
assert.Equal(t, "", constructor.calls[0].Parameters["caption"])
|
|
}
|
|
|
|
func TestSendMedia_MediaGroupLongCaptionSendsTextFirst(t *testing.T) {
|
|
constructor := &multipartRecordingConstructor{}
|
|
longCaption := strings.Repeat("b", telegramCaptionLimit) + " trailing explanation"
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
switch {
|
|
case strings.Contains(url, "sendMessage"):
|
|
return successResponseWithMessageID(t, 301), nil
|
|
case strings.Contains(url, "sendMediaGroup"):
|
|
return successMediaGroupResponse(t, 302, 303), nil
|
|
default:
|
|
t.Fatalf("unexpected API call: %s", url)
|
|
return nil, nil
|
|
}
|
|
},
|
|
}
|
|
ch := newTestChannelWithConstructor(t, caller, constructor)
|
|
|
|
store := media.NewFileMediaStore()
|
|
ch.SetMediaStore(store)
|
|
|
|
tmpDir := t.TempDir()
|
|
firstPath := filepath.Join(tmpDir, "first.png")
|
|
secondPath := filepath.Join(tmpDir, "second.png")
|
|
require.NoError(t, os.WriteFile(firstPath, []byte("first-image"), 0o644))
|
|
require.NoError(t, os.WriteFile(secondPath, []byte("second-image"), 0o644))
|
|
|
|
firstRef, err := store.Store(firstPath, media.MediaMeta{Filename: "first.png", ContentType: "image/png"}, "scope-1")
|
|
require.NoError(t, err)
|
|
secondRef, err := store.Store(secondPath, media.MediaMeta{Filename: "second.png", ContentType: "image/png"}, "scope-1")
|
|
require.NoError(t, err)
|
|
|
|
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "12345",
|
|
Parts: []bus.MediaPart{
|
|
{Type: "image", Ref: firstRef, Caption: longCaption},
|
|
{Type: "image", Ref: secondRef},
|
|
},
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"301", "302", "303"}, ids)
|
|
require.Len(t, caller.calls, 2)
|
|
assert.Contains(t, caller.calls[0].URL, "sendMessage")
|
|
assert.Contains(t, caller.calls[1].URL, "sendMediaGroup")
|
|
}
|
|
|
|
func TestSendMedia_MultiGroupLongCaptionSendsTextBeforeGroups(t *testing.T) {
|
|
constructor := &multipartRecordingConstructor{}
|
|
longCaption := strings.Repeat("c", telegramCaptionLimit) + " overflow before second album"
|
|
callOrder := make([]string, 0, 3)
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
switch {
|
|
case strings.Contains(url, "sendMessage"):
|
|
callOrder = append(callOrder, "text")
|
|
return successResponseWithMessageID(t, 499), nil
|
|
case strings.Contains(url, "sendMediaGroup"):
|
|
callOrder = append(callOrder, "group")
|
|
if len(callOrder) == 2 {
|
|
return successMediaGroupResponse(t, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410), nil
|
|
}
|
|
if len(callOrder) == 3 {
|
|
return successMediaGroupResponse(t, 411, 412, 413, 414, 415), nil
|
|
}
|
|
t.Fatalf("unexpected sendMediaGroup order: %v", callOrder)
|
|
return nil, nil
|
|
default:
|
|
t.Fatalf("unexpected API call: %s", url)
|
|
return nil, nil
|
|
}
|
|
},
|
|
}
|
|
ch := newTestChannelWithConstructor(t, caller, constructor)
|
|
|
|
store := media.NewFileMediaStore()
|
|
ch.SetMediaStore(store)
|
|
|
|
tmpDir := t.TempDir()
|
|
parts := make([]bus.MediaPart, 0, 15)
|
|
for i := 0; i < 15; i++ {
|
|
path := filepath.Join(tmpDir, "image-"+strconv.Itoa(i)+".png")
|
|
require.NoError(t, os.WriteFile(path, []byte("img-"+strconv.Itoa(i)), 0o644))
|
|
ref, err := store.Store(path, media.MediaMeta{Filename: filepath.Base(path), ContentType: "image/png"}, "scope-1")
|
|
require.NoError(t, err)
|
|
part := bus.MediaPart{Type: "image", Ref: ref}
|
|
if i == 0 {
|
|
part.Caption = longCaption
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
|
|
ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
|
ChatID: "12345",
|
|
Parts: parts,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{
|
|
"499",
|
|
"401", "402", "403", "404", "405",
|
|
"406", "407", "408", "409", "410",
|
|
"411", "412", "413", "414", "415",
|
|
}, ids)
|
|
assert.Equal(t, []string{"text", "group", "group"}, callOrder)
|
|
}
|
|
|
|
func TestSend_EmptyContent(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
t.Fatal("SendMessage should not be called for empty content")
|
|
return nil, nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "12345",
|
|
Content: "",
|
|
})
|
|
|
|
assert.NoError(t, err)
|
|
assert.Empty(t, caller.calls, "no API calls should be made for empty content")
|
|
}
|
|
|
|
func TestSend_ShortMessage_SingleCall(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
return successResponse(t), nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "12345",
|
|
Content: "Hello, world!",
|
|
})
|
|
|
|
assert.NoError(t, err)
|
|
assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call")
|
|
}
|
|
|
|
func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
switch {
|
|
case strings.Contains(url, "editMessageText"):
|
|
return successResponseWithMessageID(t, 1), nil
|
|
default:
|
|
t.Fatalf("unexpected API call: %s", url)
|
|
return nil, nil
|
|
}
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`")
|
|
|
|
ids, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "12345",
|
|
Content: "final reply",
|
|
})
|
|
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, []string{"1"}, ids)
|
|
require.Len(t, caller.calls, 1)
|
|
assert.Contains(t, caller.calls[0].URL, "editMessageText")
|
|
_, ok := ch.currentToolFeedbackMessage("12345")
|
|
assert.False(t, ok, "tracked tool feedback should be cleared after final reply")
|
|
}
|
|
|
|
func TestSend_ToolFeedbackTrackingIsTopicScoped(t *testing.T) {
|
|
nextMessageID := 0
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
nextMessageID++
|
|
return successResponseWithMessageID(t, nextMessageID), nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "-1001234567890",
|
|
Content: "🔧 `read_file`",
|
|
Context: bus.InboundContext{
|
|
Channel: "telegram",
|
|
ChatID: "-1001234567890",
|
|
TopicID: "42",
|
|
Raw: map[string]string{
|
|
"message_kind": "tool_feedback",
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, ok := ch.currentToolFeedbackMessage("-1001234567890")
|
|
assert.False(t, ok, "base chat should not track topic-specific tool feedback")
|
|
|
|
msgID, ok := ch.currentToolFeedbackMessage("-1001234567890/42")
|
|
require.True(t, ok, "topic chat should track tool feedback")
|
|
assert.Equal(t, "1", msgID)
|
|
}
|
|
|
|
func TestSend_TopicReplyDoesNotFinalizeDifferentTopicToolFeedback(t *testing.T) {
|
|
nextMessageID := 0
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
nextMessageID++
|
|
return successResponseWithMessageID(t, nextMessageID), nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "-1001234567890",
|
|
Content: "🔧 `read_file`",
|
|
Context: bus.InboundContext{
|
|
Channel: "telegram",
|
|
ChatID: "-1001234567890",
|
|
TopicID: "42",
|
|
Raw: map[string]string{
|
|
"message_kind": "tool_feedback",
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
ids, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "-1001234567890",
|
|
Content: "final reply in another topic",
|
|
Context: bus.InboundContext{
|
|
Channel: "telegram",
|
|
ChatID: "-1001234567890",
|
|
TopicID: "43",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, caller.calls, 2)
|
|
assert.Equal(t, []string{"2"}, ids)
|
|
assert.Contains(t, caller.calls[1].URL, "sendMessage")
|
|
assert.NotContains(t, caller.calls[1].URL, "editMessageText")
|
|
|
|
_, ok := ch.currentToolFeedbackMessage("-1001234567890/42")
|
|
assert.True(t, ok, "tool feedback in the original topic should remain tracked")
|
|
}
|
|
|
|
func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) {
|
|
ch := newTestChannel(t, &stubCaller{
|
|
callFn: func(context.Context, string, *ta.RequestData) (*ta.Response, error) {
|
|
t.Fatal("unexpected API call")
|
|
return nil, nil
|
|
},
|
|
})
|
|
ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`")
|
|
|
|
msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage(
|
|
context.Background(),
|
|
"12345",
|
|
"final reply",
|
|
func(_ context.Context, chatID, messageID, content string) error {
|
|
_, ok := ch.currentToolFeedbackMessage(chatID)
|
|
assert.False(t, ok, "tracked tool feedback should be stopped before edit")
|
|
assert.Equal(t, "12345", chatID)
|
|
assert.Equal(t, "1", messageID)
|
|
assert.Equal(t, "final reply", content)
|
|
return nil
|
|
},
|
|
)
|
|
|
|
assert.True(t, handled)
|
|
assert.Equal(t, []string{"1"}, msgIDs)
|
|
}
|
|
|
|
func TestSend_ToolFeedbackStaysSingleMessageAfterHTMLExpansion(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
return successResponse(t), nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "12345",
|
|
Content: "🔧 `read_file`\n" + strings.Repeat("<", 2000),
|
|
Context: bus.InboundContext{
|
|
Channel: "telegram",
|
|
ChatID: "12345",
|
|
Raw: map[string]string{
|
|
"message_kind": "tool_feedback",
|
|
},
|
|
},
|
|
})
|
|
|
|
assert.NoError(t, err)
|
|
assert.Len(t, caller.calls, 1, "tool feedback should stay a single Telegram message after HTML escaping")
|
|
}
|
|
|
|
func TestFitToolFeedbackForTelegram_ReservesAnimationFrame(t *testing.T) {
|
|
content := "🔧 `read_file`\n" + strings.Repeat("a", 4096)
|
|
|
|
fitted := fitToolFeedbackForTelegram(content, false, 4096)
|
|
animated := strings.Replace(
|
|
fitted,
|
|
"`\n",
|
|
strings.Repeat(".", channels.MaxToolFeedbackAnimationFrameLength())+"`\n",
|
|
1,
|
|
)
|
|
|
|
if got := len([]rune(parseContent(animated, false))); got > 4096 {
|
|
t.Fatalf("animated parsed length = %d, want <= 4096", got)
|
|
}
|
|
}
|
|
|
|
func TestSend_LongMessage_SingleCall(t *testing.T) {
|
|
// With WithMaxMessageLength(4000), the Manager pre-splits messages before
|
|
// they reach Send(). A message at exactly 4000 chars should go through
|
|
// as a single SendMessage call (no re-split needed since HTML expansion
|
|
// won't exceed 4096 for plain text).
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
return successResponse(t), nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
|
|
longContent := strings.Repeat("a", 4000)
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "12345",
|
|
Content: longContent,
|
|
})
|
|
|
|
assert.NoError(t, err)
|
|
assert.Len(t, caller.calls, 1, "pre-split message within limit should result in one SendMessage call")
|
|
}
|
|
|
|
func TestSend_HTMLFallback_PerChunk(t *testing.T) {
|
|
callCount := 0
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
callCount++
|
|
// Fail on odd calls (HTML attempt), succeed on even calls (plain text fallback)
|
|
if callCount%2 == 1 {
|
|
return nil, errors.New("Bad Request: can't parse entities")
|
|
}
|
|
return successResponse(t), nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "12345",
|
|
Content: "Hello **world**",
|
|
})
|
|
|
|
assert.NoError(t, err)
|
|
// One short message → 1 HTML attempt (fail) + 1 plain text fallback (success) = 2 calls
|
|
assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text fallback")
|
|
}
|
|
|
|
func TestSend_HTMLFallback_BothFail(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
return nil, errors.New("send failed")
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "12345",
|
|
Content: "Hello",
|
|
})
|
|
|
|
assert.Error(t, err)
|
|
assert.True(t, errors.Is(err, channels.ErrTemporary), "error should wrap ErrTemporary")
|
|
assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text attempt")
|
|
}
|
|
|
|
func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) {
|
|
// With a long message that gets split into 2 chunks, if both HTML and
|
|
// plain text fail on the first chunk, Send should return early.
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
return nil, errors.New("send failed")
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
|
|
longContent := strings.Repeat("x", 4001)
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "12345",
|
|
Content: longContent,
|
|
})
|
|
|
|
assert.Error(t, err)
|
|
// Should fail on the first chunk (2 calls: HTML + fallback), never reaching the second chunk.
|
|
assert.Equal(t, 2, len(caller.calls), "should stop after first chunk fails both HTML and plain text")
|
|
}
|
|
|
|
func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
return successResponse(t), nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
|
|
// Create markdown whose length is <= 4000 but whose HTML expansion is much longer.
|
|
// "**a** " (6 chars) becomes "<b>a</b> " (9 chars) in HTML, so repeating it many times
|
|
// yields HTML that exceeds Telegram's limit while markdown stays within it.
|
|
markdownContent := strings.Repeat("**a** ", 600) // 3600 chars markdown, HTML ~5400+ chars
|
|
assert.LessOrEqual(t, len([]rune(markdownContent)), 4000, "markdown content must not exceed chunk size")
|
|
|
|
htmlExpanded := markdownToTelegramHTML(markdownContent)
|
|
assert.Greater(
|
|
t, len([]rune(htmlExpanded)), 4096,
|
|
"HTML expansion must exceed Telegram limit for this test to be meaningful",
|
|
)
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "12345",
|
|
Content: markdownContent,
|
|
})
|
|
|
|
assert.NoError(t, err)
|
|
assert.Greater(
|
|
t, len(caller.calls), 1,
|
|
"markdown-short but HTML-long message should be split into multiple SendMessage calls",
|
|
)
|
|
}
|
|
|
|
func TestSend_HTMLOverflow_WordBoundary(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
return successResponse(t), nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
|
|
// We want to force a split near index ~2600 while keeping markdown length <= 4000.
|
|
// Prefix of 430 bold units (6 chars each) = 2580 chars.
|
|
// Expansion per unit is +3 chars when converted to HTML, so 2580 + 430*3 = 3870.
|
|
prefix := strings.Repeat("**a** ", 430)
|
|
targetWord := "TARGETWORDTHATSTAYSTOGETHER"
|
|
// Suffix of 230 bold units (6 chars each) = 1380 chars.
|
|
// Total markdown length: 2580 (prefix) + 27 (target word) + 1380 (suffix) = 3987 <= 4000.
|
|
// HTML expansion adds ~3 chars per bold unit: (430 + 230)*3 = 1980 extra chars,
|
|
// so total HTML length comfortably exceeds 4096.
|
|
suffix := strings.Repeat(" **b**", 230)
|
|
content := prefix + targetWord + suffix
|
|
|
|
// Ensure the test content matches the intended boundary conditions.
|
|
assert.LessOrEqual(t, len([]rune(content)), 4000, "markdown content must not exceed chunk size for this test")
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "123456",
|
|
Content: content,
|
|
})
|
|
|
|
assert.NoError(t, err)
|
|
|
|
foundFullWord := false
|
|
for i, call := range caller.calls {
|
|
var params map[string]any
|
|
err := json.Unmarshal(call.Data.BodyRaw, ¶ms)
|
|
require.NoError(t, err)
|
|
text, _ := params["text"].(string)
|
|
|
|
hasWord := strings.Contains(text, targetWord)
|
|
t.Logf("Chunk %d length: %d, contains target word: %v", i, len(text), hasWord)
|
|
|
|
if hasWord {
|
|
foundFullWord = true
|
|
break
|
|
}
|
|
}
|
|
|
|
assert.True(t, foundFullWord, "The target word should not be split between chunks")
|
|
}
|
|
|
|
func TestSend_NotRunning(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
t.Fatal("should not be called")
|
|
return nil, nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
ch.SetRunning(false)
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "12345",
|
|
Content: "Hello",
|
|
})
|
|
|
|
assert.ErrorIs(t, err, channels.ErrNotRunning)
|
|
assert.Empty(t, caller.calls)
|
|
}
|
|
|
|
func TestSend_InvalidChatID(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
t.Fatal("should not be called")
|
|
return nil, nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "not-a-number",
|
|
Content: "Hello",
|
|
})
|
|
|
|
assert.Error(t, err)
|
|
assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed")
|
|
assert.Empty(t, caller.calls)
|
|
}
|
|
|
|
func TestParseTelegramChatID_Plain(t *testing.T) {
|
|
cid, tid, err := parseTelegramChatID("12345")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(12345), cid)
|
|
assert.Equal(t, 0, tid)
|
|
}
|
|
|
|
func TestParseTelegramChatID_NegativeGroup(t *testing.T) {
|
|
cid, tid, err := parseTelegramChatID("-1001234567890")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(-1001234567890), cid)
|
|
assert.Equal(t, 0, tid)
|
|
}
|
|
|
|
func TestParseTelegramChatID_WithThreadID(t *testing.T) {
|
|
cid, tid, err := parseTelegramChatID("-1001234567890/42")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(-1001234567890), cid)
|
|
assert.Equal(t, 42, tid)
|
|
}
|
|
|
|
func TestParseTelegramChatID_GeneralTopic(t *testing.T) {
|
|
cid, tid, err := parseTelegramChatID("-100123/1")
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(-100123), cid)
|
|
assert.Equal(t, 1, tid)
|
|
}
|
|
|
|
func TestParseTelegramChatID_Invalid(t *testing.T) {
|
|
_, _, err := parseTelegramChatID("not-a-number")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestParseTelegramChatID_InvalidThreadID(t *testing.T) {
|
|
_, _, err := parseTelegramChatID("-100123/not-a-thread")
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "invalid thread ID")
|
|
}
|
|
|
|
func TestSend_WithForumThreadID(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
return successResponse(t), nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "-1001234567890/42",
|
|
Content: "Hello from topic",
|
|
})
|
|
|
|
assert.NoError(t, err)
|
|
assert.Len(t, caller.calls, 1)
|
|
}
|
|
|
|
func TestSend_UsesContextTopicIDWhenChatIDDoesNotIncludeThread(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
return successResponse(t), nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
|
|
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
|
ChatID: "-1001234567890",
|
|
Content: "Hello from topic context",
|
|
Context: bus.InboundContext{
|
|
Channel: "telegram",
|
|
ChatID: "-1001234567890",
|
|
TopicID: "42",
|
|
},
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.Len(t, caller.calls, 1)
|
|
|
|
var params struct {
|
|
ChatID int64 `json:"chat_id"`
|
|
MessageThreadID int `json:"message_thread_id"`
|
|
Text string `json:"text"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms))
|
|
assert.Equal(t, int64(-1001234567890), params.ChatID)
|
|
assert.Equal(t, 42, params.MessageThreadID)
|
|
assert.Equal(t, "Hello from topic context", params.Text)
|
|
}
|
|
|
|
func TestBeginStream_UpdateUsesForumThreadID(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
return &ta.Response{Ok: true, Result: []byte("true")}, nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
ch.tgCfg.Streaming.Enabled = true
|
|
|
|
streamer, err := ch.BeginStream(context.Background(), "-1001234567890/42")
|
|
require.NoError(t, err)
|
|
require.NoError(t, streamer.Update(context.Background(), "partial"))
|
|
require.Len(t, caller.calls, 1)
|
|
assert.Contains(t, caller.calls[0].URL, "sendMessageDraft")
|
|
|
|
var params struct {
|
|
ChatID int64 `json:"chat_id"`
|
|
MessageThreadID int `json:"message_thread_id"`
|
|
Text string `json:"text"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms))
|
|
assert.Equal(t, int64(-1001234567890), params.ChatID)
|
|
assert.Equal(t, 42, params.MessageThreadID)
|
|
assert.Equal(t, "partial", params.Text)
|
|
}
|
|
|
|
func TestBeginStream_UsesDefaultThrottleWhenOnlyEnabled(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
return &ta.Response{Ok: true, Result: []byte("true")}, nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
ch.tgCfg.Streaming = config.StreamingConfig{Enabled: true}
|
|
|
|
streamer, err := ch.BeginStream(context.Background(), "12345")
|
|
require.NoError(t, err)
|
|
require.NoError(t, streamer.Update(context.Background(), "partial"))
|
|
require.NoError(t, streamer.Update(context.Background(), "partial plus one"))
|
|
|
|
require.Len(t, caller.calls, 1, "second small update should be throttled by defaults")
|
|
}
|
|
|
|
func TestBeginStream_UpdateReturnsErrorWhenDraftFails(t *testing.T) {
|
|
callCount := 0
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
callCount++
|
|
if callCount == 1 {
|
|
return nil, errors.New("draft unsupported")
|
|
}
|
|
return &ta.Response{Ok: true, Result: []byte("true")}, nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
ch.tgCfg.Streaming = config.StreamingConfig{Enabled: true}
|
|
|
|
streamer, err := ch.BeginStream(context.Background(), "12345")
|
|
require.NoError(t, err)
|
|
|
|
err = streamer.Update(context.Background(), "partial")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "draft unsupported")
|
|
|
|
streamer.Cancel(context.Background())
|
|
require.Len(t, caller.calls, 2)
|
|
assert.Contains(t, caller.calls[1].URL, "sendMessageDraft")
|
|
|
|
var params struct {
|
|
ChatID int64 `json:"chat_id"`
|
|
DraftID int `json:"draft_id"`
|
|
Text string `json:"text"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(caller.calls[1].Data.BodyRaw, ¶ms))
|
|
assert.Equal(t, int64(12345), params.ChatID)
|
|
assert.NotZero(t, params.DraftID)
|
|
assert.Equal(t, " ", params.Text)
|
|
}
|
|
|
|
func TestBeginStream_CancelClearsExistingDraft(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
return &ta.Response{Ok: true, Result: []byte("true")}, nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
ch.tgCfg.Streaming = config.StreamingConfig{Enabled: true}
|
|
|
|
streamer, err := ch.BeginStream(context.Background(), "12345")
|
|
require.NoError(t, err)
|
|
require.NoError(t, streamer.Update(context.Background(), "partial"))
|
|
streamer.Cancel(context.Background())
|
|
|
|
require.Len(t, caller.calls, 2)
|
|
assert.Contains(t, caller.calls[1].URL, "sendMessageDraft")
|
|
|
|
var params struct {
|
|
ChatID int64 `json:"chat_id"`
|
|
DraftID int `json:"draft_id"`
|
|
Text string `json:"text"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(caller.calls[1].Data.BodyRaw, ¶ms))
|
|
assert.Equal(t, int64(12345), params.ChatID)
|
|
assert.NotZero(t, params.DraftID)
|
|
assert.Equal(t, " ", params.Text)
|
|
}
|
|
|
|
func TestBeginStream_FinalizeClearsExistingDraft(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
if strings.Contains(url, "sendMessage") && !strings.Contains(url, "sendMessageDraft") {
|
|
return successResponse(t), nil
|
|
}
|
|
return &ta.Response{Ok: true, Result: []byte("true")}, nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
ch.tgCfg.Streaming = config.StreamingConfig{Enabled: true}
|
|
|
|
streamer, err := ch.BeginStream(context.Background(), "12345")
|
|
require.NoError(t, err)
|
|
require.NoError(t, streamer.Update(context.Background(), "partial"))
|
|
require.NoError(t, streamer.Finalize(context.Background(), "final"))
|
|
|
|
require.Len(t, caller.calls, 3)
|
|
assert.Contains(t, caller.calls[0].URL, "sendMessageDraft")
|
|
assert.Contains(t, caller.calls[1].URL, "sendMessage")
|
|
assert.Contains(t, caller.calls[2].URL, "sendMessageDraft")
|
|
|
|
var params struct {
|
|
ChatID int64 `json:"chat_id"`
|
|
DraftID int `json:"draft_id"`
|
|
Text string `json:"text"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(caller.calls[2].Data.BodyRaw, ¶ms))
|
|
assert.Equal(t, int64(12345), params.ChatID)
|
|
assert.NotZero(t, params.DraftID)
|
|
assert.Equal(t, " ", params.Text)
|
|
}
|
|
|
|
func TestBeginStream_FinalizeUsesForumThreadID(t *testing.T) {
|
|
caller := &stubCaller{
|
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
|
return successResponse(t), nil
|
|
},
|
|
}
|
|
ch := newTestChannel(t, caller)
|
|
ch.tgCfg.Streaming.Enabled = true
|
|
|
|
streamer, err := ch.BeginStream(context.Background(), "-1001234567890/42")
|
|
require.NoError(t, err)
|
|
require.NoError(t, streamer.Finalize(context.Background(), "final"))
|
|
require.Len(t, caller.calls, 1)
|
|
assert.Contains(t, caller.calls[0].URL, "sendMessage")
|
|
|
|
var params struct {
|
|
ChatID int64 `json:"chat_id"`
|
|
MessageThreadID int `json:"message_thread_id"`
|
|
Text string `json:"text"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms))
|
|
assert.Equal(t, int64(-1001234567890), params.ChatID)
|
|
assert.Equal(t, 42, params.MessageThreadID)
|
|
assert.Equal(t, "final", params.Text)
|
|
}
|
|
|
|
func TestHandleMessage_ForumTopic_SetsMetadata(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: "hello from topic",
|
|
MessageID: 10,
|
|
MessageThreadID: 42,
|
|
Chat: telego.Chat{
|
|
ID: -1001234567890,
|
|
Type: "supergroup",
|
|
IsForum: true,
|
|
},
|
|
From: &telego.User{
|
|
ID: 7,
|
|
FirstName: "Alice",
|
|
},
|
|
}
|
|
|
|
err := ch.handleMessage(context.Background(), msg)
|
|
require.NoError(t, err)
|
|
|
|
inbound, ok := <-messageBus.InboundChan()
|
|
require.True(t, ok, "expected inbound message")
|
|
|
|
// ChatID remains the parent chat; TopicID isolates the sub-conversation.
|
|
assert.Equal(t, "-1001234567890", inbound.ChatID)
|
|
assert.Equal(t, "group", inbound.Context.ChatType)
|
|
assert.Equal(t, "42", inbound.Context.TopicID)
|
|
}
|
|
|
|
func TestHandleMessage_NoForum_NoThreadMetadata(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: "regular group message",
|
|
MessageID: 11,
|
|
Chat: telego.Chat{
|
|
ID: -100999,
|
|
Type: "group",
|
|
},
|
|
From: &telego.User{
|
|
ID: 8,
|
|
FirstName: "Bob",
|
|
},
|
|
}
|
|
|
|
err := ch.handleMessage(context.Background(), msg)
|
|
require.NoError(t, err)
|
|
|
|
inbound, ok := <-messageBus.InboundChan()
|
|
require.True(t, ok)
|
|
|
|
// Plain chatID without thread suffix
|
|
assert.Equal(t, "-100999", inbound.ChatID)
|
|
|
|
assert.Equal(t, "group", inbound.Context.ChatType)
|
|
assert.Empty(t, inbound.Context.TopicID)
|
|
}
|
|
|
|
func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
|
|
messageBus := bus.NewMessageBus()
|
|
ch := &TelegramChannel{
|
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
|
chatIDs: make(map[string]int64),
|
|
ctx: context.Background(),
|
|
}
|
|
|
|
// In regular groups, reply threads set MessageThreadID to the original
|
|
// message ID. This should NOT trigger per-thread session isolation.
|
|
msg := &telego.Message{
|
|
Text: "reply in thread",
|
|
MessageID: 20,
|
|
MessageThreadID: 15,
|
|
Chat: telego.Chat{
|
|
ID: -100999,
|
|
Type: "supergroup",
|
|
IsForum: false,
|
|
},
|
|
From: &telego.User{
|
|
ID: 9,
|
|
FirstName: "Carol",
|
|
},
|
|
}
|
|
|
|
err := ch.handleMessage(context.Background(), msg)
|
|
require.NoError(t, err)
|
|
|
|
inbound, ok := <-messageBus.InboundChan()
|
|
require.True(t, ok)
|
|
|
|
// chatID should NOT include thread suffix for non-forum groups
|
|
assert.Equal(t, "-100999", inbound.ChatID)
|
|
|
|
assert.Equal(t, "group", inbound.Context.ChatType)
|
|
assert.Empty(t, inbound.Context.TopicID)
|
|
}
|
|
|
|
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.Context.ReplyToMessageID)
|
|
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.Context.ReplyToMessageID)
|
|
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{
|
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
|
chatIDs: make(map[string]int64),
|
|
ctx: context.Background(),
|
|
}
|
|
|
|
// Service message with no text/caption/media (like ForumTopicCreated)
|
|
msg := &telego.Message{
|
|
MessageID: 123,
|
|
Chat: telego.Chat{
|
|
ID: 456,
|
|
Type: "group",
|
|
},
|
|
From: &telego.User{
|
|
ID: 789,
|
|
FirstName: "User",
|
|
},
|
|
}
|
|
|
|
err := ch.handleMessage(context.Background(), msg)
|
|
require.NoError(t, err)
|
|
|
|
// Should NOT publish to message bus
|
|
select {
|
|
case <-messageBus.InboundChan():
|
|
t.Fatal("Empty message should not be published to message bus")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_MediaGroupCombinesCaptionMessages(t *testing.T) {
|
|
messageBus, ch := newMediaGroupTestChannel(10 * time.Millisecond)
|
|
base := testMediaGroupMessage("album-1")
|
|
first := base
|
|
first.MessageID = 1
|
|
second := base
|
|
second.MessageID = 2
|
|
second.Caption = "meal caption"
|
|
|
|
require.NoError(t, ch.handleMessage(context.Background(), &first))
|
|
require.NoError(t, ch.handleMessage(context.Background(), &second))
|
|
|
|
select {
|
|
case inbound := <-messageBus.InboundChan():
|
|
assert.Equal(t, "2", inbound.Context.MessageID)
|
|
assert.Equal(t, "meal caption", inbound.Content)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for combined media group message")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_MediaGroupWaitsForStaggeredMessages(t *testing.T) {
|
|
messageBus, ch := newMediaGroupTestChannel(100 * time.Millisecond)
|
|
base := testMediaGroupMessage("album-staggered")
|
|
first := base
|
|
first.MessageID = 1
|
|
first.Caption = "first caption"
|
|
second := base
|
|
second.MessageID = 2
|
|
second.Caption = "second caption"
|
|
|
|
require.NoError(t, ch.handleMessage(context.Background(), &first))
|
|
time.Sleep(50 * time.Millisecond)
|
|
require.NoError(t, ch.handleMessage(context.Background(), &second))
|
|
|
|
select {
|
|
case inbound := <-messageBus.InboundChan():
|
|
t.Fatalf("media group flushed before idle delay reset: %#v", inbound)
|
|
case <-time.After(75 * time.Millisecond):
|
|
}
|
|
|
|
select {
|
|
case inbound := <-messageBus.InboundChan():
|
|
assert.Equal(t, "1", inbound.Context.MessageID)
|
|
assert.Equal(t, "first caption\nsecond caption", inbound.Content)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for staggered media group message")
|
|
}
|
|
}
|
|
|
|
func TestFlushMediaGroupIgnoresStaleTimerGeneration(t *testing.T) {
|
|
messageBus, ch := newMediaGroupTestChannel(time.Hour)
|
|
base := testMediaGroupMessage("album-generation")
|
|
first := base
|
|
first.MessageID = 1
|
|
first.Caption = "first"
|
|
second := base
|
|
second.MessageID = 2
|
|
second.Caption = "second"
|
|
key := "456:album-generation"
|
|
|
|
ch.mediaGroupMu.Lock()
|
|
ch.mediaGroups[key] = &telegramMediaGroup{
|
|
messages: []*telego.Message{&first, &second},
|
|
generation: 2,
|
|
}
|
|
ch.mediaGroupMu.Unlock()
|
|
|
|
ch.flushMediaGroup(context.Background(), key, 1)
|
|
|
|
select {
|
|
case inbound := <-messageBus.InboundChan():
|
|
t.Fatalf("stale media group generation flushed unexpectedly: %#v", inbound)
|
|
default:
|
|
}
|
|
|
|
ch.mediaGroupMu.Lock()
|
|
_, stillPending := ch.mediaGroups[key]
|
|
ch.mediaGroupMu.Unlock()
|
|
require.True(t, stillPending, "stale flush should leave the current batch pending")
|
|
|
|
ch.flushMediaGroup(context.Background(), key, 2)
|
|
|
|
select {
|
|
case inbound := <-messageBus.InboundChan():
|
|
assert.Equal(t, "1", inbound.Context.MessageID)
|
|
assert.Equal(t, "first\nsecond", inbound.Content)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for current generation media group flush")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_MediaGroupAfterDelayStartsNewBatch(t *testing.T) {
|
|
messageBus, ch := newMediaGroupTestChannel(10 * time.Millisecond)
|
|
base := testMediaGroupMessage("album-split")
|
|
first := base
|
|
first.MessageID = 1
|
|
first.Caption = "first"
|
|
second := base
|
|
second.MessageID = 2
|
|
second.Caption = "second"
|
|
|
|
require.NoError(t, ch.handleMessage(context.Background(), &first))
|
|
select {
|
|
case inbound := <-messageBus.InboundChan():
|
|
assert.Equal(t, "1", inbound.Context.MessageID)
|
|
assert.Equal(t, "first", inbound.Content)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for first media group batch")
|
|
}
|
|
|
|
require.NoError(t, ch.handleMessage(context.Background(), &second))
|
|
select {
|
|
case inbound := <-messageBus.InboundChan():
|
|
assert.Equal(t, "2", inbound.Context.MessageID)
|
|
assert.Equal(t, "second", inbound.Content)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for second media group batch")
|
|
}
|
|
}
|
|
|
|
func TestStopFlushesPendingMediaGroups(t *testing.T) {
|
|
messageBus, ch := newMediaGroupTestChannel(time.Hour)
|
|
base := testMediaGroupMessage("album-stop")
|
|
msg := base
|
|
msg.MessageID = 1
|
|
msg.Caption = "caption before stop"
|
|
|
|
require.NoError(t, ch.handleMessage(context.Background(), &msg))
|
|
require.NoError(t, ch.Stop(context.Background()))
|
|
|
|
select {
|
|
case inbound := <-messageBus.InboundChan():
|
|
assert.Equal(t, "1", inbound.Context.MessageID)
|
|
assert.Equal(t, "caption before stop", inbound.Content)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for pending media group flush on stop")
|
|
}
|
|
}
|
|
|
|
func TestNewTelegramChannelUsesConfiguredMediaGroupDelay(t *testing.T) {
|
|
ch, err := NewTelegramChannel(
|
|
&config.Channel{Type: config.ChannelTelegram, Enabled: true},
|
|
&config.TelegramSettings{
|
|
Token: *config.NewSecureString(testToken),
|
|
MediaGroupDelayMS: 750,
|
|
},
|
|
bus.NewMessageBus(),
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 750*time.Millisecond, ch.mediaGroupDelay)
|
|
|
|
ch, err = NewTelegramChannel(
|
|
&config.Channel{Type: config.ChannelTelegram, Enabled: true},
|
|
&config.TelegramSettings{Token: *config.NewSecureString(testToken)},
|
|
bus.NewMessageBus(),
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, defaultMediaGroupDelay, ch.mediaGroupDelay)
|
|
}
|
|
|
|
func newMediaGroupTestChannel(delay time.Duration) (*bus.MessageBus, *TelegramChannel) {
|
|
messageBus := bus.NewMessageBus()
|
|
ch := &TelegramChannel{
|
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
|
chatIDs: make(map[string]int64),
|
|
ctx: context.Background(),
|
|
mediaGroups: make(map[string]*telegramMediaGroup),
|
|
mediaGroupDelay: delay,
|
|
}
|
|
return messageBus, ch
|
|
}
|
|
|
|
func testMediaGroupMessage(mediaGroupID string) telego.Message {
|
|
return telego.Message{
|
|
Chat: telego.Chat{
|
|
ID: 456,
|
|
Type: "private",
|
|
},
|
|
From: &telego.User{
|
|
ID: 789,
|
|
FirstName: "User",
|
|
},
|
|
MediaGroupID: mediaGroupID,
|
|
}
|
|
}
|