Skip to content

Commit 8ed351c

Browse files
committed
Merge remote-tracking branch 'origin/feat/telegram-chunking' into deploy/pi-integration
2 parents c5d2298 + 0e810a2 commit 8ed351c

File tree

2 files changed

+309
-4
lines changed

2 files changed

+309
-4
lines changed

pkg/channels/telegram/telegram.go

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -233,13 +233,49 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
233233
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
234234
}
235235

236-
htmlContent := markdownToTelegramHTML(msg.Content)
236+
if msg.Content == "" {
237+
return nil
238+
}
239+
240+
// Split the raw markdown before converting to HTML so that
241+
// SplitMessage's code-fence-aware logic works correctly and
242+
// we never break HTML tags/entities by splitting converted output.
243+
mdChunks := channels.SplitMessage(msg.Content, 4000)
237244

238-
// Typing/placeholder handled by Manager.preSend — just send the message
245+
for _, chunk := range mdChunks {
246+
htmlContent := markdownToTelegramHTML(chunk)
247+
248+
// If HTML expansion pushes the chunk over Telegram's 4096-char limit,
249+
// re-split the markdown chunk with a proportionally smaller maxLen.
250+
if len([]rune(htmlContent)) > 4096 {
251+
ratio := float64(len([]rune(chunk))) / float64(len([]rune(htmlContent)))
252+
smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin
253+
if smallerLen < 100 {
254+
smallerLen = 100
255+
}
256+
subChunks := channels.SplitMessage(chunk, smallerLen)
257+
for _, sub := range subChunks {
258+
if err := c.sendHTMLChunk(ctx, chatID, markdownToTelegramHTML(sub)); err != nil {
259+
return err
260+
}
261+
}
262+
continue
263+
}
264+
265+
if err := c.sendHTMLChunk(ctx, chatID, htmlContent); err != nil {
266+
return err
267+
}
268+
}
269+
270+
return nil
271+
}
272+
273+
// sendHTMLChunk sends a single HTML message, falling back to plain text on parse failure.
274+
func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent string) error {
239275
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
240276
tgMsg.ParseMode = telego.ModeHTML
241277

242-
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
278+
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
243279
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
244280
"error": err.Error(),
245281
})
@@ -248,7 +284,6 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
248284
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
249285
}
250286
}
251-
252287
return nil
253288
}
254289

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
package telegram
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"strings"
8+
"testing"
9+
10+
"github.com/mymmrac/telego"
11+
ta "github.com/mymmrac/telego/telegoapi"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/sipeed/picoclaw/pkg/bus"
16+
"github.com/sipeed/picoclaw/pkg/channels"
17+
)
18+
19+
const testToken = "1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc"
20+
21+
// stubCaller implements ta.Caller for testing.
22+
type stubCaller struct {
23+
calls []stubCall
24+
callFn func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error)
25+
}
26+
27+
type stubCall struct {
28+
URL string
29+
Data *ta.RequestData
30+
}
31+
32+
func (s *stubCaller) Call(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
33+
s.calls = append(s.calls, stubCall{URL: url, Data: data})
34+
return s.callFn(ctx, url, data)
35+
}
36+
37+
// stubConstructor implements ta.RequestConstructor for testing.
38+
type stubConstructor struct{}
39+
40+
func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) {
41+
return &ta.RequestData{}, nil
42+
}
43+
44+
func (s *stubConstructor) MultipartRequest(
45+
parameters map[string]string,
46+
files map[string]ta.NamedReader,
47+
) (*ta.RequestData, error) {
48+
return &ta.RequestData{}, nil
49+
}
50+
51+
// successResponse returns a ta.Response that telego will treat as a successful SendMessage.
52+
func successResponse(t *testing.T) *ta.Response {
53+
t.Helper()
54+
msg := &telego.Message{MessageID: 1}
55+
b, err := json.Marshal(msg)
56+
require.NoError(t, err)
57+
return &ta.Response{Ok: true, Result: b}
58+
}
59+
60+
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
61+
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
62+
t.Helper()
63+
64+
bot, err := telego.NewBot(testToken,
65+
telego.WithAPICaller(caller),
66+
telego.WithRequestConstructor(&stubConstructor{}),
67+
telego.WithDiscardLogger(),
68+
)
69+
require.NoError(t, err)
70+
71+
base := channels.NewBaseChannel("telegram", nil, nil, nil,
72+
channels.WithMaxMessageLength(4096),
73+
)
74+
base.SetRunning(true)
75+
76+
return &TelegramChannel{
77+
BaseChannel: base,
78+
bot: bot,
79+
chatIDs: make(map[string]int64),
80+
}
81+
}
82+
83+
func TestSend_EmptyContent(t *testing.T) {
84+
caller := &stubCaller{
85+
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
86+
t.Fatal("SendMessage should not be called for empty content")
87+
return nil, nil
88+
},
89+
}
90+
ch := newTestChannel(t, caller)
91+
92+
err := ch.Send(context.Background(), bus.OutboundMessage{
93+
ChatID: "12345",
94+
Content: "",
95+
})
96+
97+
assert.NoError(t, err)
98+
assert.Empty(t, caller.calls, "no API calls should be made for empty content")
99+
}
100+
101+
func TestSend_ShortMessage_SingleCall(t *testing.T) {
102+
caller := &stubCaller{
103+
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
104+
return successResponse(t), nil
105+
},
106+
}
107+
ch := newTestChannel(t, caller)
108+
109+
err := ch.Send(context.Background(), bus.OutboundMessage{
110+
ChatID: "12345",
111+
Content: "Hello, world!",
112+
})
113+
114+
assert.NoError(t, err)
115+
assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call")
116+
}
117+
118+
func TestSend_LongMessage_MultipleCalls(t *testing.T) {
119+
caller := &stubCaller{
120+
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
121+
return successResponse(t), nil
122+
},
123+
}
124+
ch := newTestChannel(t, caller)
125+
126+
// Create a message over 4000 chars so it gets split into multiple chunks.
127+
longContent := strings.Repeat("a", 4001)
128+
129+
err := ch.Send(context.Background(), bus.OutboundMessage{
130+
ChatID: "12345",
131+
Content: longContent,
132+
})
133+
134+
assert.NoError(t, err)
135+
assert.Greater(t, len(caller.calls), 1, "long message should be split into multiple SendMessage calls")
136+
}
137+
138+
func TestSend_HTMLFallback_PerChunk(t *testing.T) {
139+
callCount := 0
140+
caller := &stubCaller{
141+
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
142+
callCount++
143+
// Fail on odd calls (HTML attempt), succeed on even calls (plain text fallback)
144+
if callCount%2 == 1 {
145+
return nil, errors.New("Bad Request: can't parse entities")
146+
}
147+
return successResponse(t), nil
148+
},
149+
}
150+
ch := newTestChannel(t, caller)
151+
152+
err := ch.Send(context.Background(), bus.OutboundMessage{
153+
ChatID: "12345",
154+
Content: "Hello **world**",
155+
})
156+
157+
assert.NoError(t, err)
158+
// One short message → 1 HTML attempt (fail) + 1 plain text fallback (success) = 2 calls
159+
assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text fallback")
160+
}
161+
162+
func TestSend_HTMLFallback_BothFail(t *testing.T) {
163+
caller := &stubCaller{
164+
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
165+
return nil, errors.New("send failed")
166+
},
167+
}
168+
ch := newTestChannel(t, caller)
169+
170+
err := ch.Send(context.Background(), bus.OutboundMessage{
171+
ChatID: "12345",
172+
Content: "Hello",
173+
})
174+
175+
assert.Error(t, err)
176+
assert.True(t, errors.Is(err, channels.ErrTemporary), "error should wrap ErrTemporary")
177+
assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text attempt")
178+
}
179+
180+
func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) {
181+
// With a long message that gets split into 2 chunks, if both HTML and
182+
// plain text fail on the first chunk, Send should return early.
183+
caller := &stubCaller{
184+
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
185+
return nil, errors.New("send failed")
186+
},
187+
}
188+
ch := newTestChannel(t, caller)
189+
190+
longContent := strings.Repeat("x", 4001)
191+
192+
err := ch.Send(context.Background(), bus.OutboundMessage{
193+
ChatID: "12345",
194+
Content: longContent,
195+
})
196+
197+
assert.Error(t, err)
198+
// Should fail on the first chunk (2 calls: HTML + fallback), never reaching the second chunk.
199+
assert.Equal(t, 2, len(caller.calls), "should stop after first chunk fails both HTML and plain text")
200+
}
201+
202+
func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) {
203+
caller := &stubCaller{
204+
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
205+
return successResponse(t), nil
206+
},
207+
}
208+
ch := newTestChannel(t, caller)
209+
210+
// Create markdown whose length is <= 4000 but whose HTML expansion is much longer.
211+
// "**a** " (6 chars) becomes "<b>a</b> " (9 chars) in HTML, so repeating it many times
212+
// yields HTML that exceeds Telegram's limit while markdown stays within it.
213+
markdownContent := strings.Repeat("**a** ", 600) // 3600 chars markdown, HTML ~5400+ chars
214+
assert.LessOrEqual(t, len([]rune(markdownContent)), 4000, "markdown content must not exceed chunk size")
215+
216+
htmlExpanded := markdownToTelegramHTML(markdownContent)
217+
assert.Greater(
218+
t, len([]rune(htmlExpanded)), 4096,
219+
"HTML expansion must exceed Telegram limit for this test to be meaningful",
220+
)
221+
222+
err := ch.Send(context.Background(), bus.OutboundMessage{
223+
ChatID: "12345",
224+
Content: markdownContent,
225+
})
226+
227+
assert.NoError(t, err)
228+
assert.Greater(
229+
t, len(caller.calls), 1,
230+
"markdown-short but HTML-long message should be split into multiple SendMessage calls",
231+
)
232+
}
233+
234+
func TestSend_NotRunning(t *testing.T) {
235+
caller := &stubCaller{
236+
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
237+
t.Fatal("should not be called")
238+
return nil, nil
239+
},
240+
}
241+
ch := newTestChannel(t, caller)
242+
ch.SetRunning(false)
243+
244+
err := ch.Send(context.Background(), bus.OutboundMessage{
245+
ChatID: "12345",
246+
Content: "Hello",
247+
})
248+
249+
assert.ErrorIs(t, err, channels.ErrNotRunning)
250+
assert.Empty(t, caller.calls)
251+
}
252+
253+
func TestSend_InvalidChatID(t *testing.T) {
254+
caller := &stubCaller{
255+
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
256+
t.Fatal("should not be called")
257+
return nil, nil
258+
},
259+
}
260+
ch := newTestChannel(t, caller)
261+
262+
err := ch.Send(context.Background(), bus.OutboundMessage{
263+
ChatID: "not-a-number",
264+
Content: "Hello",
265+
})
266+
267+
assert.Error(t, err)
268+
assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed")
269+
assert.Empty(t, caller.calls)
270+
}

0 commit comments

Comments
 (0)