When handling inbound Telegram messages from forum topics, InboundContext.ChatID was set to the plain chat ID (without thread). This caused outbound messages to lose the thread routing, sending replies to the General topic instead of the specific forum topic — even though typing indicators correctly targeted the right thread because StartTyping received the compositeChatID. The fix sets InboundContext.ChatID to compositeChatID (chatID/threadID format) for forum messages, matching the format already used for session routing and typing indicators. TopicID is still preserved as a redundant fallback for any path that strips the composite format. Closes #3110
1722 lines
50 KiB
Go
1722 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 includes the thread ID for forum topics so outbound
|
|
// delivery resolves the correct topic without relying solely on TopicID fallback.
|
|
assert.Equal(t, "-1001234567890/42", 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_LocationForwardedAsText(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{
|
|
MessageID: 3049,
|
|
Location: &telego.Location{
|
|
Latitude: 35.197713,
|
|
Longitude: 136.885705,
|
|
},
|
|
Chat: telego.Chat{
|
|
ID: 456,
|
|
Type: "private",
|
|
},
|
|
From: &telego.User{
|
|
ID: 789,
|
|
FirstName: "User",
|
|
},
|
|
}
|
|
|
|
err := ch.handleMessage(context.Background(), msg)
|
|
require.NoError(t, err)
|
|
|
|
select {
|
|
case inbound := <-messageBus.InboundChan():
|
|
assert.Equal(t, "[User location: lat=35.197713, lng=136.885705]", inbound.Content)
|
|
assert.Equal(t, "3049", inbound.Context.MessageID)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for location message")
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|