-
Notifications
You must be signed in to change notification settings - Fork 3.8k
feat: add message chunking in Telegram Send method #935
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+313
−5
Merged
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
ee5b618
fix: migration ModelName, reasoning_content, shell regex, loop boundary
putueddy ec54031
feat: add Kimi/Moonshot and Opencode provider support
putueddy a6f4274
feat: add message chunking in Telegram Send method
putueddy 81aeaf1
fix: address Copilot review feedback on PR #932
putueddy 9c91d66
Address Copilot review feedback for Kimi/Opencode providers
putueddy 2dccee5
Address Copilot review feedback for Telegram message chunking
putueddy 3501962
test: add unit tests for Telegram Send() method
putueddy 4a067cd
Merge branch 'main' into feat/kimi-opencode-providers
putueddy d9b4af7
feat: add .env file loading and provider env overrides
putueddy 4b7e8d9
feat: add Exa AI search provider
putueddy 33109a1
Address Copilot review: handle HTML expansion exceeding Telegram limit
putueddy 8219b5a
Address Copilot review feedback for Exa search provider
putueddy 84ded81
Address Copilot review feedback for .env loading
putueddy df53f44
fix: format long lines in telegram_test.go to satisfy golines linter
putueddy 2fc8798
fix: add kimi-code migration alias and User-Agent test
putueddy 0e810a2
fix: tighten HTML-expansion test to stay under chunk size
putueddy e54b1d3
refactor: parse Kimi API hostname once in constructor instead of per-…
putueddy 5dcd42e
Merge upstream/main into fix/bugfixes
putueddy 56ad77b
Merge upstream/main into feat/dotenv-loading
putueddy 5b608ae
test: use guardCommand directly and improve assertions in DiskWiping …
putueddy e503c87
fix: add LiteLLM to env overrides and fix malformed .env test
putueddy d257f1a
merge: resolve conflict with upstream/main in provider_test.go
putueddy c5d2298
Merge remote-tracking branch 'origin/feat/kimi-opencode-providers' in…
putueddy 8ed351c
Merge remote-tracking branch 'origin/feat/telegram-chunking' into dep…
putueddy b7aaa5b
Merge remote-tracking branch 'origin/feat/dotenv-loading' into deploy…
putueddy fe97387
Merge remote-tracking branch 'origin/feat/exa-search' into deploy/pi-…
putueddy 8bd1935
telegram: lower MaxMessageLength to 4000 for HTML expansion margin
putueddy 3de4cb8
fix: pass original markdown to sendHTMLChunk for plain-text fallback
putueddy bd0018a
fix: use queue-based re-splitting for HTML expansion validation
putueddy 11017ac
merge: resolve conflicts with upstream/main
putueddy f07dbd1
fix: remove redundant SplitMessage in Send() per review feedback
putueddy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,273 @@ | ||
| 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(4000), | ||
| ) | ||
| 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_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", | ||
| ) | ||
| } | ||
putueddy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.