Skip to content

Commit 5c10d65

Browse files
fix: auto-split long messages to fit Telegram 4096-char limit
Messages exceeding Telegram's 4096-character limit now automatically split into multiple messages. Split prefers newline boundaries (within last 25% of chunk) for natural breaks, falls back to rune boundary. This fixes the "Bad Request: message is too long" error that caused outbox retry loops for long Claude responses. - splitMessage() operates on runes (UTF-8 safe for Korean/emoji) - SendMessage() sends chunks sequentially - 4 new test cases: content preservation, newline preference, Korean Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ded14b4 commit 5c10d65

2 files changed

Lines changed: 131 additions & 5 deletions

File tree

internal/bot/bot.go

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,54 @@ func (b *Bot) SetWorker(w *worker.Worker) {
5050
b.worker = w
5151
}
5252

53+
// telegramMaxLength is the maximum message length allowed by Telegram API.
54+
const telegramMaxLength = 4096
55+
5356
// SendMessage sends a plain text message to the configured chat.
57+
// Messages longer than 4096 characters are automatically split into multiple messages.
5458
func (b *Bot) SendMessage(text string) error {
5559
text = strings.ToValidUTF8(text, "")
56-
msg := tgbotapi.NewMessage(b.cfg.TelegramChatID, text)
57-
_, err := b.api.Send(msg)
58-
if err != nil {
59-
b.logger.Error("SendMessage failed", "error", err)
60+
61+
chunks := splitMessage(text, telegramMaxLength)
62+
for i, chunk := range chunks {
63+
msg := tgbotapi.NewMessage(b.cfg.TelegramChatID, chunk)
64+
if _, err := b.api.Send(msg); err != nil {
65+
b.logger.Error("SendMessage failed", "error", err, "chunk", i+1, "total", len(chunks))
66+
return err
67+
}
68+
}
69+
return nil
70+
}
71+
72+
// splitMessage splits text into chunks that fit within maxLen runes.
73+
// Prefers splitting at newlines, falls back to rune boundary.
74+
func splitMessage(text string, maxLen int) []string {
75+
runes := []rune(text)
76+
if len(runes) <= maxLen {
77+
return []string{text}
78+
}
79+
80+
var chunks []string
81+
for len(runes) > 0 {
82+
if len(runes) <= maxLen {
83+
chunks = append(chunks, string(runes))
84+
break
85+
}
86+
87+
// Find the best split point: last newline within maxLen
88+
chunk := runes[:maxLen]
89+
splitAt := maxLen
90+
for i := len(chunk) - 1; i >= maxLen*3/4; i-- {
91+
if chunk[i] == '\n' {
92+
splitAt = i + 1 // include the newline in current chunk
93+
break
94+
}
95+
}
96+
97+
chunks = append(chunks, string(runes[:splitAt]))
98+
runes = runes[splitAt:]
6099
}
61-
return err
100+
return chunks
62101
}
63102

64103
// SendTyping sends a "typing..." indicator to the chat.

internal/bot/bot_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,90 @@ func TestSafeTruncateNoEllipsis(t *testing.T) {
4141
t.Errorf("Expected no ellipsis, got %q", got)
4242
}
4343
}
44+
45+
func TestSplitMessage(t *testing.T) {
46+
tests := []struct {
47+
name string
48+
input string
49+
maxLen int
50+
wantCount int
51+
}{
52+
{"short message", "hello", 4096, 1},
53+
{"empty", "", 4096, 1},
54+
{"exact limit", string(make([]rune, 4096)), 4096, 1},
55+
{"just over limit", string(make([]rune, 4097)), 4096, 2},
56+
}
57+
58+
for _, tt := range tests {
59+
t.Run(tt.name, func(t *testing.T) {
60+
chunks := splitMessage(tt.input, tt.maxLen)
61+
if len(chunks) != tt.wantCount {
62+
t.Errorf("splitMessage() returned %d chunks, want %d", len(chunks), tt.wantCount)
63+
}
64+
})
65+
}
66+
}
67+
68+
func TestSplitMessagePreservesContent(t *testing.T) {
69+
// Build a long message with newlines
70+
var input string
71+
for i := 0; i < 200; i++ {
72+
input += "This is line number that is fairly long.\n"
73+
}
74+
75+
chunks := splitMessage(input, 100)
76+
77+
// Reassemble and verify no content is lost
78+
var reassembled string
79+
for _, c := range chunks {
80+
reassembled += c
81+
}
82+
if reassembled != input {
83+
t.Errorf("Content lost during split: input len=%d, reassembled len=%d", len(input), len(reassembled))
84+
}
85+
}
86+
87+
func TestSplitMessagePrefersNewline(t *testing.T) {
88+
// 80 chars + newline + 80 chars, with maxLen=100
89+
line1 := string(make([]rune, 80))
90+
line2 := string(make([]rune, 80))
91+
input := line1 + "\n" + line2
92+
93+
chunks := splitMessage(input, 100)
94+
95+
if len(chunks) != 2 {
96+
t.Fatalf("Expected 2 chunks, got %d", len(chunks))
97+
}
98+
// First chunk should end at newline (81 runes: 80 + '\n')
99+
if []rune(chunks[0])[len([]rune(chunks[0]))-1] != '\n' {
100+
t.Error("Expected first chunk to end with newline")
101+
}
102+
}
103+
104+
func TestSplitMessageKorean(t *testing.T) {
105+
// Korean text should split on rune boundaries, not byte boundaries
106+
var input string
107+
for i := 0; i < 2000; i++ {
108+
input += "안녕"
109+
}
110+
// 4000 Korean runes
111+
112+
chunks := splitMessage(input, 3000)
113+
114+
// Verify each chunk is valid and within limit
115+
for i, c := range chunks {
116+
runes := []rune(c)
117+
if len(runes) > 3000 {
118+
t.Errorf("Chunk %d has %d runes, exceeds limit 3000", i, len(runes))
119+
}
120+
}
121+
122+
// Verify no content lost
123+
var reassembled string
124+
for _, c := range chunks {
125+
reassembled += c
126+
}
127+
if reassembled != input {
128+
t.Error("Korean content lost during split")
129+
}
130+
}

0 commit comments

Comments
 (0)