From a6f42748708d4c5a28981366feadaf63ee136285 Mon Sep 17 00:00:00 2001 From: I Putu Eddy Irawan Date: Sun, 1 Mar 2026 08:56:12 +0700 Subject: [PATCH 1/6] feat: add message chunking in Telegram Send method Split HTML content into 4000-char chunks before sending to handle cases where markdown-to-HTML conversion causes messages to exceed Telegram's 4096-character limit. Uses the existing SplitMessage utility which preserves code block integrity across chunk boundaries. Co-Authored-By: Claude Opus 4.6 --- pkg/channels/telegram/telegram.go | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index a11cf53b8..ef0a1ef30 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -175,17 +175,22 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err htmlContent := markdownToTelegramHTML(msg.Content) - // Typing/placeholder handled by Manager.preSend — just send the message - tgMsg := tu.Message(tu.ID(chatID), htmlContent) - tgMsg.ParseMode = telego.ModeHTML + // Split HTML content into chunks that fit within Telegram's message limit. + // Use 4000 to leave headroom for HTML tag overhead beyond the 4096 limit. + chunks := channels.SplitMessage(htmlContent, 4000) + + for _, chunk := range chunks { + tgMsg := tu.Message(tu.ID(chatID), chunk) + tgMsg.ParseMode = telego.ModeHTML - if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ - "error": err.Error(), - }) - tgMsg.ParseMode = "" if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - return fmt.Errorf("telegram send: %w", channels.ErrTemporary) + logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ + "error": err.Error(), + }) + tgMsg.ParseMode = "" + if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { + return fmt.Errorf("telegram send: %w", channels.ErrTemporary) + } } } From 2dccee5044665ed3a02bd19f4576654b22798fce Mon Sep 17 00:00:00 2001 From: I Putu Eddy Irawan Date: Sun, 1 Mar 2026 09:40:09 +0700 Subject: [PATCH 2/6] Address Copilot review feedback for Telegram message chunking - Add early return for empty content to avoid silent no-op - Split raw markdown before HTML conversion so SplitMessage's code-fence-aware logic works correctly and HTML tags/entities are never broken by mid-tag splitting Co-Authored-By: Claude Opus 4.6 --- pkg/channels/telegram/telegram.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index ef0a1ef30..a28ae1bb9 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -173,14 +173,18 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } - htmlContent := markdownToTelegramHTML(msg.Content) + if msg.Content == "" { + return nil + } - // Split HTML content into chunks that fit within Telegram's message limit. - // Use 4000 to leave headroom for HTML tag overhead beyond the 4096 limit. - chunks := channels.SplitMessage(htmlContent, 4000) + // Split the raw markdown before converting to HTML so that + // SplitMessage's code-fence-aware logic works correctly and + // we never break HTML tags/entities by splitting converted output. + mdChunks := channels.SplitMessage(msg.Content, 4000) - for _, chunk := range chunks { - tgMsg := tu.Message(tu.ID(chatID), chunk) + for _, chunk := range mdChunks { + htmlContent := markdownToTelegramHTML(chunk) + tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { From 3501962977cb03debf5035b9626632600797e35f Mon Sep 17 00:00:00 2001 From: I Putu Eddy Irawan Date: Mon, 2 Mar 2026 21:54:35 +0700 Subject: [PATCH 3/6] test: add unit tests for Telegram Send() method Cover empty content early return, single-message send, multi-chunk splitting for long messages, HTML-to-plain-text fallback per chunk, and error propagation. Co-Authored-By: Claude Opus 4.6 --- pkg/channels/telegram/telegram_test.go | 235 +++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 pkg/channels/telegram/telegram_test.go diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go new file mode 100644 index 000000000..b93ea37ac --- /dev/null +++ b/pkg/channels/telegram/telegram_test.go @@ -0,0 +1,235 @@ +package telegram + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "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" +) + +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{} + +func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) { + return &ta.RequestData{}, nil +} + +func (s *stubConstructor) MultipartRequest(parameters map[string]string, files map[string]ta.NamedReader) (*ta.RequestData, error) { + return &ta.RequestData{}, nil +} + +// successResponse returns a ta.Response that telego will treat as a successful SendMessage. +func successResponse(t *testing.T) *ta.Response { + t.Helper() + msg := &telego.Message{MessageID: 1} + b, err := json.Marshal(msg) + 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 { + t.Helper() + + bot, err := telego.NewBot(testToken, + telego.WithAPICaller(caller), + telego.WithRequestConstructor(&stubConstructor{}), + telego.WithDiscardLogger(), + ) + require.NoError(t, err) + + base := channels.NewBaseChannel("telegram", nil, nil, nil, + channels.WithMaxMessageLength(4096), + ) + base.SetRunning(true) + + return &TelegramChannel{ + BaseChannel: base, + bot: bot, + chatIDs: make(map[string]int64), + } +} + +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_LongMessage_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 a message over 4000 chars so it gets split into multiple chunks. + longContent := strings.Repeat("a", 4001) + + err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: longContent, + }) + + assert.NoError(t, err) + assert.Greater(t, len(caller.calls), 1, "long message should be split into multiple SendMessage calls") +} + +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_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) +} From 33109a1676aa69150bcaabd46b30743538616b90 Mon Sep 17 00:00:00 2001 From: I Putu Eddy Irawan Date: Mon, 2 Mar 2026 22:35:41 +0700 Subject: [PATCH 4/6] Address Copilot review: handle HTML expansion exceeding Telegram limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When markdownToTelegramHTML expands a chunk beyond 4096 chars (e.g. **a** → a), re-split the markdown with a proportionally smaller maxLen so each resulting HTML chunk fits within Telegram's limit. Extract sendHTMLChunk helper to avoid duplicating the HTML-send + plain-text-fallback logic. Add test case for markdown-short-but-HTML-long scenario to verify the re-splitting behavior. Co-Authored-By: Claude Opus 4.6 --- pkg/channels/telegram/telegram.go | 44 ++++++++++++++++++++------ pkg/channels/telegram/telegram_test.go | 26 +++++++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index a28ae1bb9..c74eb20d5 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -184,23 +184,49 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err for _, chunk := range mdChunks { htmlContent := markdownToTelegramHTML(chunk) - tgMsg := tu.Message(tu.ID(chatID), htmlContent) - tgMsg.ParseMode = telego.ModeHTML - if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ - "error": err.Error(), - }) - tgMsg.ParseMode = "" - if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - return fmt.Errorf("telegram send: %w", channels.ErrTemporary) + // If HTML expansion pushes the chunk over Telegram's 4096-char limit, + // re-split the markdown chunk with a proportionally smaller maxLen. + if len([]rune(htmlContent)) > 4096 { + ratio := float64(len([]rune(chunk))) / float64(len([]rune(htmlContent))) + smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin + if smallerLen < 100 { + smallerLen = 100 } + subChunks := channels.SplitMessage(chunk, smallerLen) + for _, sub := range subChunks { + if err := c.sendHTMLChunk(ctx, chatID, markdownToTelegramHTML(sub)); err != nil { + return err + } + } + continue + } + + if err := c.sendHTMLChunk(ctx, chatID, htmlContent); err != nil { + return err } } return nil } +// sendHTMLChunk sends a single HTML message, falling back to plain text on parse failure. +func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent string) error { + tgMsg := tu.Message(tu.ID(chatID), htmlContent) + tgMsg.ParseMode = telego.ModeHTML + + if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { + logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ + "error": err.Error(), + }) + tgMsg.ParseMode = "" + if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { + return fmt.Errorf("telegram send: %w", channels.ErrTemporary) + } + } + return nil +} + // StartTyping implements channels.TypingCapable. // It sends ChatAction(typing) immediately and then repeats every 4 seconds // (Telegram's typing indicator expires after ~5s) in a background goroutine. diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index b93ea37ac..9d26bdd1a 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -196,6 +196,32 @@ func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) { 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 <= 4096 but whose HTML expansion is much longer. + // "**a**" (5 chars) becomes "a" (8 chars) in HTML, so repeating it many times + // yields HTML that exceeds Telegram's limit while markdown stays within it. + markdownContent := strings.Repeat("**a** ", 700) // ~4200 chars markdown, but HTML ~5600+ chars + assert.LessOrEqual(t, len([]rune(markdownContent)), 4200, "markdown content should be near Telegram limit") + + 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_NotRunning(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { From df53f4411ac5f7f7b41d9ddbaa060a0ed014a30a Mon Sep 17 00:00:00 2001 From: I Putu Eddy Irawan Date: Tue, 3 Mar 2026 09:11:24 +0700 Subject: [PATCH 5/6] fix: format long lines in telegram_test.go to satisfy golines linter Break function signatures and assert calls that exceed the 120-char golines limit onto multiple lines. Co-Authored-By: Claude Opus 4.6 --- pkg/channels/telegram/telegram_test.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 9d26bdd1a..ebbc36095 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -41,7 +41,10 @@ func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) { return &ta.RequestData{}, nil } -func (s *stubConstructor) MultipartRequest(parameters map[string]string, files map[string]ta.NamedReader) (*ta.RequestData, error) { +func (s *stubConstructor) MultipartRequest( + parameters map[string]string, + files map[string]ta.NamedReader, +) (*ta.RequestData, error) { return &ta.RequestData{}, nil } @@ -211,7 +214,10 @@ func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) { assert.LessOrEqual(t, len([]rune(markdownContent)), 4200, "markdown content should be near Telegram limit") htmlExpanded := markdownToTelegramHTML(markdownContent) - assert.Greater(t, len([]rune(htmlExpanded)), 4096, "HTML expansion must exceed Telegram limit for this test to be meaningful") + 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", @@ -219,7 +225,10 @@ func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) { }) assert.NoError(t, err) - assert.Greater(t, len(caller.calls), 1, "markdown-short but HTML-long message should be split into multiple SendMessage calls") + assert.Greater( + t, len(caller.calls), 1, + "markdown-short but HTML-long message should be split into multiple SendMessage calls", + ) } func TestSend_NotRunning(t *testing.T) { From 0e810a2ec4cd3cee813d3046c946ff11660996da Mon Sep 17 00:00:00 2001 From: I Putu Eddy Irawan Date: Tue, 3 Mar 2026 09:20:16 +0700 Subject: [PATCH 6/6] fix: tighten HTML-expansion test to stay under chunk size Reduce markdown input from 700 to 600 repeats (3600 runes) so it stays under the 4000-rune chunk threshold. This ensures the test actually exercises the HTML-expansion re-splitting logic rather than being split at the markdown level first. Co-Authored-By: Claude Opus 4.6 --- pkg/channels/telegram/telegram_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index ebbc36095..c75ba1957 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -207,11 +207,11 @@ func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) { } ch := newTestChannel(t, caller) - // Create markdown whose length is <= 4096 but whose HTML expansion is much longer. - // "**a**" (5 chars) becomes "a" (8 chars) in HTML, so repeating it many times + // Create markdown whose length is <= 4000 but whose HTML expansion is much longer. + // "**a** " (6 chars) becomes "a " (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** ", 700) // ~4200 chars markdown, but HTML ~5600+ chars - assert.LessOrEqual(t, len([]rune(markdownContent)), 4200, "markdown content should be near Telegram limit") + 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(