Files
Memoh/internal/channel/adapters/telegram/stream_test.go
T
BBQ d3bf6bc90a fix(channel,attachment): channel quality refactor & attachment pipeline fixes (#349)
* feat(channel): add DingTalk channel adapter

- Add DingTalk channel adapter (`internal/channel/adapters/dingtalk/`) using dingtalk-stream-sdk-go, supporting inbound message receiving and outbound text/markdown reply
- Register DingTalk adapter in cmd/agent and cmd/memoh
- Add go.mod dependency: github.com/memohai/dingtalk-stream-sdk-go
- Add Dingtalk and Wecom SVG icons and Vue components to @memohai/icon
- Refactor existing icon components to remove redundant inline wrappers
- Add `channelTypeDisplayName` util for consistent channel label resolution
- Add DingTalk/WeCom i18n entries (en/zh) for types and typesShort
- Extend channel-icon, bot-channels, channel-settings-panel to support dingtalk/wecom
- Use channelTypeDisplayName in profile page to replace ad-hoc i18n lookup

* fix(channel,attachment): channel quality refactor & attachment pipeline fixes

Channel module:
- Fix RemoveAdapter not cleaning connectionMeta (stale status leak)
- Fix preparedAttachmentTypeFromMime misclassifying image/gif
- Fix sleepWithContext time.After goroutine/timer leak
- Export IsDataURL/IsHTTPURL/IsDataPath, dedup across packages
- Cache OutboundPolicy in managerOutboundStream to avoid repeated lookups
- Split OutboundAttachmentStore: extract ContainerAttachmentIngester interface
- Add ManagerOption funcs (WithInboundQueueSize, WithInboundWorkers, WithRefreshInterval)
- Add thread-safety docs on OutboundStream / managerOutboundStream
- Add debug logs on successful send/edit paths
- Expand outbound_prepare_test.go with 21 new cases
- Convert no-receiver adapter helpers to package-level funcs; drop unused params

DingTalk adapter:
- Implement AttachmentResolver: download inbound media via /v1.0/robot/messageFiles/download
- Fix pure-image inbound messages failing due to missing resolver

Attachment pipeline:
- Fix images invisible to LLM in pipeline (DCP) path: inject InlineImages into
  last user message when cfg.Query is empty
- Fix public_url fallback: skip direct URL-to-LLM when ContentHash is set,
  always prefer inlined persisted asset
- Inject path: carry ImageParts through agent.InjectMessage; inline persisted
  attachments in resolver inject goroutine so mid-stream images reach the model
- Fix ResolveMime for images: prefer content-sniffed MIME over platform-declared
  MIME (fixes Feishu sending image/png header for actual JPEG content → API 400)
2026-04-09 14:36:11 +08:00

702 lines
20 KiB
Go

package telegram
import (
"context"
"errors"
"strings"
"testing"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/memohai/memoh/internal/channel"
)
func mustPreparedTelegramEvent(t *testing.T, event channel.StreamEvent) channel.PreparedStreamEvent {
t.Helper()
prepared, err := channel.PrepareStreamEvent(context.Background(), nil, channel.ChannelConfig{
BotID: "bot-test",
ChannelType: Type,
}, event)
if err != nil {
t.Fatalf("prepare telegram stream event: %v", err)
}
return prepared
}
func TestTelegramOutboundStream_CloseNil(t *testing.T) {
t.Parallel()
var s *telegramOutboundStream
ctx := context.Background()
if err := s.Close(ctx); err != nil {
t.Fatalf("Close on nil stream should return nil: %v", err)
}
}
func TestTelegramOutboundStream_PushClosed(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter}
s.closed.Store(true)
ctx := context.Background()
err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{Type: channel.StreamEventDelta, Delta: "x"}))
if err == nil {
t.Fatal("Push on closed stream should return error")
}
if !strings.Contains(err.Error(), "closed") {
t.Fatalf("expected closed error: %v", err)
}
}
func TestTelegramOutboundStream_PushStatusNoOp(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter}
ctx := context.Background()
err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{Type: channel.StreamEventStatus}))
if err != nil {
t.Fatalf("StreamEventStatus should be no-op: %v", err)
}
}
func TestTelegramOutboundStream_PushNilAdapter(t *testing.T) {
t.Parallel()
s := &telegramOutboundStream{adapter: nil}
ctx := context.Background()
err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{Type: channel.StreamEventDelta, Delta: "x"}))
if err == nil {
t.Fatal("Push with nil adapter should return error")
}
if !strings.Contains(err.Error(), "not configured") {
t.Fatalf("expected not configured error: %v", err)
}
}
func TestTelegramOutboundStream_PushUnknownEventTypeSkipped(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter}
ctx := context.Background()
err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{Type: channel.StreamEventType("unknown")}))
if err != nil {
t.Fatalf("Push with unknown event type should be silently skipped: %v", err)
}
}
func TestTelegramOutboundStream_PushEmptyDeltaNoOp(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter}
ctx := context.Background()
err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{Type: channel.StreamEventDelta, Delta: ""}))
if err != nil {
t.Fatalf("empty delta should be no-op: %v", err)
}
}
func TestTelegramOutboundStream_PushErrorEventEmptyNoOp(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter}
ctx := context.Background()
err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{Type: channel.StreamEventError, Error: ""}))
if err != nil {
t.Fatalf("empty error event should be no-op: %v", err)
}
}
func TestTelegramOutboundStream_PushErrorEventRedactsRegisteredTokenFragments(t *testing.T) {
channel.ResetIMErrorSecretsForTest()
t.Cleanup(channel.ResetIMErrorSecretsForTest)
const botToken = "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
var sentText string
adapter := NewTelegramAdapter(nil)
stream, err := adapter.OpenStream(context.Background(), channel.ChannelConfig{
ID: "cfg-1",
Credentials: map[string]any{"botToken": botToken},
}, "12345", channel.StreamOptions{Metadata: map[string]any{"conversation_type": "private"}})
if err != nil {
t.Fatalf("open stream: %v", err)
}
s, ok := stream.(*telegramOutboundStream)
if !ok {
t.Fatalf("unexpected stream type %T", stream)
}
origGetBot := getOrCreateBotForTest
origSendText := sendTextForTest
getOrCreateBotForTest = func(_ *TelegramAdapter, _, _ string) (*tgbotapi.BotAPI, error) {
return &tgbotapi.BotAPI{Token: botToken}, nil
}
sendTextForTest = func(_ *tgbotapi.BotAPI, _ string, text string, _ int, _ string) (int64, int, error) {
sentText = text
return 1, 1, nil
}
defer func() {
getOrCreateBotForTest = origGetBot
sendTextForTest = origSendText
}()
prefixHalf := botToken[:len(botToken)/2]
err = s.Push(context.Background(), mustPreparedTelegramEvent(t, channel.StreamEvent{Type: channel.StreamEventError, Error: "request failed: " + prefixHalf}))
if err != nil {
t.Fatalf("push error event: %v", err)
}
if strings.Contains(sentText, prefixHalf) {
t.Fatalf("expected prefix half to be redacted, got %q", sentText)
}
if !strings.Contains(sentText, "Error: ") {
t.Fatalf("expected error prefix, got %q", sentText)
}
if !strings.Contains(sentText, strings.Repeat("*", len(prefixHalf))) {
t.Fatalf("expected redaction mask, got %q", sentText)
}
}
func TestTelegramOutboundStream_CloseContextCanceled(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := s.Close(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("Close with canceled context should return context.Canceled: %v", err)
}
}
// Test editStreamMessage dedup: no API call when content equals lastEdited (avoids Telegram "message is not modified" error).
func TestEditStreamMessage_NoEditWhenSameContent(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{
adapter: adapter,
streamChatID: 1,
streamMsgID: 1,
lastEdited: "hello",
lastEditedAt: time.Now().Add(-time.Minute),
}
ctx := context.Background()
tests := []struct {
name string
text string
}{
{"exact same", "hello"},
{"trimmed same", " hello "},
{"leading space", " hello"},
{"trailing space", "hello "},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := s.editStreamMessage(ctx, tt.text)
if err != nil {
t.Fatalf("editStreamMessage(same content) should return nil to avoid duplicate edit API call: %v", err)
}
})
}
}
func TestEditStreamMessage_NoEditWhenMessageNotSent(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter, streamMsgID: 0}
ctx := context.Background()
err := s.editStreamMessage(ctx, "any")
if err != nil {
t.Fatalf("editStreamMessage when streamMsgID==0 should return nil: %v", err)
}
}
func TestEditStreamMessage_NoEditWhenThrottled(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{
adapter: adapter,
streamChatID: 1,
streamMsgID: 1,
lastEdited: "a",
lastEditedAt: time.Now(), // just now, within throttle window
}
ctx := context.Background()
err := s.editStreamMessage(ctx, "ab")
if err != nil {
t.Fatalf("editStreamMessage within throttle window should skip edit and return nil: %v", err)
}
}
func TestEditStreamMessage_429SetsBackoffAndReturnsNil(t *testing.T) {
adapter := NewTelegramAdapter(nil)
before := time.Now().Add(-time.Minute)
s := &telegramOutboundStream{
adapter: adapter,
cfg: channel.ChannelConfig{ID: "test", Credentials: map[string]any{"bot_token": "fake"}},
streamChatID: 1,
streamMsgID: 1,
lastEdited: "a",
lastEditedAt: before,
}
ctx := context.Background()
origGetBot := getOrCreateBotForTest
origEdit := testEditFunc
getOrCreateBotForTest = func(_ *TelegramAdapter, _, _ string) (*tgbotapi.BotAPI, error) {
return &tgbotapi.BotAPI{Token: "fake"}, nil
}
testEditFunc = func(*tgbotapi.BotAPI, int64, int, string, string) error {
return tgbotapi.Error{
Code: 429,
Message: "Too Many Requests",
ResponseParameters: tgbotapi.ResponseParameters{RetryAfter: 2},
}
}
defer func() {
getOrCreateBotForTest = origGetBot
testEditFunc = origEdit
}()
err := s.editStreamMessage(ctx, "b")
if err != nil {
t.Fatalf("editStreamMessage on 429 should return nil (backoff): %v", err)
}
s.mu.Lock()
lastEdited := s.lastEdited
lastEditedAt := s.lastEditedAt
s.mu.Unlock()
if lastEdited != "a" {
t.Fatalf("on 429 lastEdited should remain unchanged: got %q", lastEdited)
}
if !lastEditedAt.After(before) {
t.Fatalf("on 429 lastEditedAt should be pushed forward for backoff: got %v", lastEditedAt)
}
}
func TestEditStreamMessageFinal_Success(t *testing.T) {
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{
adapter: adapter,
cfg: channel.ChannelConfig{ID: "test", Credentials: map[string]any{"bot_token": "fake"}},
streamChatID: 1,
streamMsgID: 1,
lastEdited: "a",
lastEditedAt: time.Now().Add(-time.Minute),
}
ctx := context.Background()
origGetBot := getOrCreateBotForTest
origEdit := testEditFunc
getOrCreateBotForTest = func(_ *TelegramAdapter, _, _ string) (*tgbotapi.BotAPI, error) {
return &tgbotapi.BotAPI{Token: "fake"}, nil
}
testEditFunc = func(*tgbotapi.BotAPI, int64, int, string, string) error {
return nil
}
defer func() {
getOrCreateBotForTest = origGetBot
testEditFunc = origEdit
}()
err := s.editStreamMessageFinal(ctx, "final text")
if err != nil {
t.Fatalf("editStreamMessageFinal should succeed: %v", err)
}
s.mu.Lock()
lastEdited := s.lastEdited
s.mu.Unlock()
if lastEdited != "final text" {
t.Fatalf("expected lastEdited to be updated: got %q", lastEdited)
}
}
func TestEditStreamMessageFinal_SameContentNoOp(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{
adapter: adapter,
streamChatID: 1,
streamMsgID: 1,
lastEdited: "same",
lastEditedAt: time.Now(),
}
ctx := context.Background()
err := s.editStreamMessageFinal(ctx, "same")
if err != nil {
t.Fatalf("editStreamMessageFinal with same content should return nil: %v", err)
}
}
func TestEditStreamMessageFinal_NoMessageNoOp(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{adapter: adapter, streamMsgID: 0}
ctx := context.Background()
err := s.editStreamMessageFinal(ctx, "any")
if err != nil {
t.Fatalf("editStreamMessageFinal when streamMsgID==0 should return nil: %v", err)
}
}
// --- Draft mode (sendMessageDraft) tests ---
func TestSendDraft_ThrottleSkip(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{
adapter: adapter,
cfg: channel.ChannelConfig{ID: "test", Credentials: map[string]any{"bot_token": "fake"}},
isPrivateChat: true,
draftID: 1,
streamChatID: 123,
lastEditedAt: time.Now(), // just now, within draft throttle window
}
ctx := context.Background()
err := s.sendDraft(ctx, "hello")
if err != nil {
t.Fatalf("sendDraft within throttle window should skip and return nil: %v", err)
}
}
func TestSendDraft_EmptyTextSkip(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{
adapter: adapter,
isPrivateChat: true,
draftID: 1,
streamChatID: 123,
lastEditedAt: time.Now().Add(-time.Minute),
}
ctx := context.Background()
err := s.sendDraft(ctx, " ")
if err != nil {
t.Fatalf("sendDraft with whitespace-only text should skip and return nil: %v", err)
}
}
func TestSendDraft_Success(t *testing.T) {
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{
adapter: adapter,
cfg: channel.ChannelConfig{ID: "test", Credentials: map[string]any{"bot_token": "fake"}},
isPrivateChat: true,
draftID: 1,
streamChatID: 123,
lastEditedAt: time.Now().Add(-time.Minute),
}
ctx := context.Background()
origGetBot := getOrCreateBotForTest
origDraft := sendDraftForTest
getOrCreateBotForTest = func(_ *TelegramAdapter, _, _ string) (*tgbotapi.BotAPI, error) {
return &tgbotapi.BotAPI{Token: "fake"}, nil
}
var capturedChatID int64
var capturedDraftID int
var capturedText string
sendDraftForTest = func(_ *tgbotapi.BotAPI, chatID int64, draftID int, text string, _ string) error {
capturedChatID = chatID
capturedDraftID = draftID
capturedText = text
return nil
}
defer func() {
getOrCreateBotForTest = origGetBot
sendDraftForTest = origDraft
}()
err := s.sendDraft(ctx, "streaming text")
if err != nil {
t.Fatalf("sendDraft should succeed: %v", err)
}
if capturedChatID != 123 {
t.Fatalf("expected chatID 123, got %d", capturedChatID)
}
if capturedDraftID != 1 {
t.Fatalf("expected draftID 1, got %d", capturedDraftID)
}
if capturedText != "streaming text" {
t.Fatalf("expected text 'streaming text', got %q", capturedText)
}
}
func TestSendDraft_429Backoff(t *testing.T) {
adapter := NewTelegramAdapter(nil)
before := time.Now().Add(-time.Minute)
s := &telegramOutboundStream{
adapter: adapter,
cfg: channel.ChannelConfig{ID: "test", Credentials: map[string]any{"bot_token": "fake"}},
isPrivateChat: true,
draftID: 1,
streamChatID: 123,
lastEditedAt: before,
}
ctx := context.Background()
origGetBot := getOrCreateBotForTest
origDraft := sendDraftForTest
getOrCreateBotForTest = func(_ *TelegramAdapter, _, _ string) (*tgbotapi.BotAPI, error) {
return &tgbotapi.BotAPI{Token: "fake"}, nil
}
sendDraftForTest = func(*tgbotapi.BotAPI, int64, int, string, string) error {
return tgbotapi.Error{
Code: 429,
Message: "Too Many Requests",
ResponseParameters: tgbotapi.ResponseParameters{RetryAfter: 2},
}
}
defer func() {
getOrCreateBotForTest = origGetBot
sendDraftForTest = origDraft
}()
err := s.sendDraft(ctx, "hello")
if err != nil {
t.Fatalf("sendDraft on 429 should return nil (backoff): %v", err)
}
s.mu.Lock()
lastEditedAt := s.lastEditedAt
s.mu.Unlock()
if !lastEditedAt.After(before) {
t.Fatalf("on 429 lastEditedAt should be pushed forward for backoff")
}
}
func TestDraftMode_DeltaUsesSendDraft(t *testing.T) {
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{
adapter: adapter,
cfg: channel.ChannelConfig{ID: "test", Credentials: map[string]any{"bot_token": "fake"}},
isPrivateChat: true,
draftID: 1,
streamChatID: 123,
}
ctx := context.Background()
origGetBot := getOrCreateBotForTest
origDraft := sendDraftForTest
getOrCreateBotForTest = func(_ *TelegramAdapter, _, _ string) (*tgbotapi.BotAPI, error) {
return &tgbotapi.BotAPI{Token: "fake"}, nil
}
draftCalls := 0
sendDraftForTest = func(*tgbotapi.BotAPI, int64, int, string, string) error {
draftCalls++
return nil
}
defer func() {
getOrCreateBotForTest = origGetBot
sendDraftForTest = origDraft
}()
err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{Type: channel.StreamEventDelta, Delta: "Hello "}))
if err != nil {
t.Fatalf("Push delta should succeed: %v", err)
}
if draftCalls != 1 {
t.Fatalf("expected 1 sendDraft call, got %d", draftCalls)
}
s.mu.Lock()
buf := s.buf.String()
s.mu.Unlock()
if buf != "Hello " {
t.Fatalf("expected buffer to be 'Hello ', got %q", buf)
}
}
func TestDraftMode_PhaseEndTextIsNoOp(t *testing.T) {
t.Parallel()
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{
adapter: adapter,
isPrivateChat: true,
draftID: 1,
streamChatID: 123,
}
s.buf.WriteString("some content")
ctx := context.Background()
err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{
Type: channel.StreamEventPhaseEnd,
Phase: channel.StreamPhaseText,
}))
if err != nil {
t.Fatalf("PhaseEnd in draft mode should be no-op: %v", err)
}
}
func TestDraftMode_ToolCallStartSendsPermanentMessage(t *testing.T) {
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{
adapter: adapter,
cfg: channel.ChannelConfig{ID: "test", Credentials: map[string]any{"bot_token": "fake"}},
target: "123",
isPrivateChat: true,
draftID: 1,
streamChatID: 123,
}
s.buf.WriteString("partial text")
ctx := context.Background()
origGetBot := getOrCreateBotForTest
origSendEdit := sendEditForTest
origSendText := sendTextForTest
getOrCreateBotForTest = func(_ *TelegramAdapter, _, _ string) (*tgbotapi.BotAPI, error) {
return &tgbotapi.BotAPI{Token: "fake"}, nil
}
var sentText string
sendTextForTest = func(_ *tgbotapi.BotAPI, _ string, text string, _ int, _ string) (int64, int, error) {
sentText = text
return 123, 1, nil
}
sendEditForTest = func(_ *tgbotapi.BotAPI, _ tgbotapi.EditMessageTextConfig) error {
t.Error("editMessage should not be called in draft mode")
return nil
}
defer func() {
getOrCreateBotForTest = origGetBot
sendEditForTest = origSendEdit
sendTextForTest = origSendText
}()
err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{Type: channel.StreamEventToolCallStart}))
if err != nil {
t.Fatalf("Push ToolCallStart should succeed: %v", err)
}
if sentText != "partial text" {
t.Fatalf("expected sendPermanentMessage with 'partial text', got %q", sentText)
}
s.mu.Lock()
bufAfter := s.buf.String()
chatID := s.streamChatID
s.mu.Unlock()
if bufAfter != "" {
t.Fatalf("buffer should be reset after ToolCallStart: got %q", bufAfter)
}
// streamChatID should be preserved in draft mode
if chatID != 123 {
t.Fatalf("streamChatID should be preserved in draft mode: got %d", chatID)
}
}
func TestDraftMode_FinalEmptyBufferSkipsDuplicate(t *testing.T) {
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{
adapter: adapter,
cfg: channel.ChannelConfig{ID: "test", Credentials: map[string]any{"bot_token": "fake"}},
target: "123",
isPrivateChat: true,
draftID: 1,
streamChatID: 123,
}
ctx := context.Background()
// Simulate: buffer was already committed during ToolCallStart, so it's empty.
// StreamEventFinal should NOT re-send the message via PlainText() fallback.
origGetBot := getOrCreateBotForTest
origSendText := sendTextForTest
getOrCreateBotForTest = func(_ *TelegramAdapter, _, _ string) (*tgbotapi.BotAPI, error) {
return &tgbotapi.BotAPI{Token: "fake"}, nil
}
sendTextForTest = func(_ *tgbotapi.BotAPI, _ string, _ string, _ int, _ string) (int64, int, error) {
t.Error("sendTelegramText should not be called when buffer is empty in draft mode")
return 0, 0, nil
}
defer func() {
getOrCreateBotForTest = origGetBot
sendTextForTest = origSendText
}()
err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{
Type: channel.StreamEventFinal,
Final: &channel.StreamFinalizePayload{
Message: channel.Message{Text: "already sent text"},
},
}))
if err != nil {
t.Fatalf("StreamEventFinal with empty buffer in draft mode should succeed: %v", err)
}
}
// TestDraftMode_MultipleFinalEventsOnlyOneSend verifies that when multiple
// StreamEventFinal events fire (one per assistant output in multi-tool-call
// responses), only the first one sends the buffer text as a permanent message.
// Subsequent finals find the buffer empty and skip sending.
func TestDraftMode_MultipleFinalEventsOnlyOneSend(t *testing.T) {
adapter := NewTelegramAdapter(nil)
s := &telegramOutboundStream{
adapter: adapter,
cfg: channel.ChannelConfig{ID: "test", Credentials: map[string]any{"bot_token": "fake"}},
target: "123",
isPrivateChat: true,
draftID: 1,
streamChatID: 123,
}
ctx := context.Background()
// Simulate buffer containing the final summary text
s.buf.WriteString("final summary")
origGetBot := getOrCreateBotForTest
origSendText := sendTextForTest
getOrCreateBotForTest = func(_ *TelegramAdapter, _, _ string) (*tgbotapi.BotAPI, error) {
return &tgbotapi.BotAPI{Token: "fake"}, nil
}
sendCount := 0
sendTextForTest = func(_ *tgbotapi.BotAPI, _ string, _ string, _ int, _ string) (int64, int, error) {
sendCount++
return 123, 1, nil
}
defer func() {
getOrCreateBotForTest = origGetBot
sendTextForTest = origSendText
}()
// Push 3 StreamEventFinal events (simulating 3 assistant outputs).
// Only the first should actually send a message.
for i, text := range []string{"intermediate 1", "intermediate 2", "final summary"} {
err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{
Type: channel.StreamEventFinal,
Final: &channel.StreamFinalizePayload{
Message: channel.Message{Text: text},
},
}))
if err != nil {
t.Fatalf("StreamEventFinal #%d should succeed: %v", i+1, err)
}
}
if sendCount != 1 {
t.Fatalf("expected exactly 1 sendTelegramText call, got %d", sendCount)
}
}