Skip to content
Merged
Show file tree
Hide file tree
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 Mar 1, 2026
ec54031
feat: add Kimi/Moonshot and Opencode provider support
putueddy Mar 1, 2026
a6f4274
feat: add message chunking in Telegram Send method
putueddy Mar 1, 2026
81aeaf1
fix: address Copilot review feedback on PR #932
putueddy Mar 1, 2026
9c91d66
Address Copilot review feedback for Kimi/Opencode providers
putueddy Mar 1, 2026
2dccee5
Address Copilot review feedback for Telegram message chunking
putueddy Mar 1, 2026
3501962
test: add unit tests for Telegram Send() method
putueddy Mar 2, 2026
4a067cd
Merge branch 'main' into feat/kimi-opencode-providers
putueddy Mar 2, 2026
d9b4af7
feat: add .env file loading and provider env overrides
putueddy Mar 2, 2026
4b7e8d9
feat: add Exa AI search provider
putueddy Mar 2, 2026
33109a1
Address Copilot review: handle HTML expansion exceeding Telegram limit
putueddy Mar 2, 2026
8219b5a
Address Copilot review feedback for Exa search provider
putueddy Mar 2, 2026
84ded81
Address Copilot review feedback for .env loading
putueddy Mar 2, 2026
df53f44
fix: format long lines in telegram_test.go to satisfy golines linter
putueddy Mar 3, 2026
2fc8798
fix: add kimi-code migration alias and User-Agent test
putueddy Mar 3, 2026
0e810a2
fix: tighten HTML-expansion test to stay under chunk size
putueddy Mar 3, 2026
e54b1d3
refactor: parse Kimi API hostname once in constructor instead of per-…
putueddy Mar 3, 2026
5dcd42e
Merge upstream/main into fix/bugfixes
putueddy Mar 3, 2026
56ad77b
Merge upstream/main into feat/dotenv-loading
putueddy Mar 3, 2026
5b608ae
test: use guardCommand directly and improve assertions in DiskWiping …
putueddy Mar 3, 2026
e503c87
fix: add LiteLLM to env overrides and fix malformed .env test
putueddy Mar 3, 2026
d257f1a
merge: resolve conflict with upstream/main in provider_test.go
putueddy Mar 3, 2026
c5d2298
Merge remote-tracking branch 'origin/feat/kimi-opencode-providers' in…
putueddy Mar 3, 2026
8ed351c
Merge remote-tracking branch 'origin/feat/telegram-chunking' into dep…
putueddy Mar 3, 2026
b7aaa5b
Merge remote-tracking branch 'origin/feat/dotenv-loading' into deploy…
putueddy Mar 3, 2026
fe97387
Merge remote-tracking branch 'origin/feat/exa-search' into deploy/pi-…
putueddy Mar 3, 2026
8bd1935
telegram: lower MaxMessageLength to 4000 for HTML expansion margin
putueddy Mar 4, 2026
3de4cb8
fix: pass original markdown to sendHTMLChunk for plain-text fallback
putueddy Mar 4, 2026
bd0018a
fix: use queue-based re-splitting for HTML expansion validation
putueddy Mar 4, 2026
11017ac
merge: resolve conflicts with upstream/main
putueddy Mar 4, 2026
f07dbd1
fix: remove redundant SplitMessage in Send() per review feedback
putueddy Mar 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions pkg/channels/telegram/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
telegramCfg,
bus,
telegramCfg.AllowFrom,
channels.WithMaxMessageLength(4096),
channels.WithMaxMessageLength(4000),
channels.WithGroupTrigger(telegramCfg.GroupTrigger),
channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
)
Expand Down Expand Up @@ -233,22 +233,57 @@ 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
}

// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
// so msg.Content is guaranteed to be within that limit. We still need to
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
queue := []string{msg.Content}
for len(queue) > 0 {
chunk := queue[0]
queue = queue[1:]

htmlContent := markdownToTelegramHTML(chunk)

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
}
// Push sub-chunks back to the front of the queue for
// re-validation instead of sending them blindly.
subChunks := channels.SplitMessage(chunk, smallerLen)
queue = append(subChunks, queue...)
continue
}

// Typing/placeholder handled by Manager.preSend — just send the message
if err := c.sendHTMLChunk(ctx, chatID, htmlContent, chunk); err != nil {
return err
}
}

return nil
}

// sendHTMLChunk sends a single HTML message, falling back to the original
// markdown as plain text on parse failure so users never see raw HTML tags.
func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent, mdFallback string) error {
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
tgMsg.ParseMode = telego.ModeHTML

if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
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.Text = mdFallback
tgMsg.ParseMode = ""
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
}
}

return nil
}

Expand Down
273 changes: 273 additions & 0 deletions pkg/channels/telegram/telegram_test.go
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",
)
}

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)
}
Loading