Skip to content

Commit 4dc4d79

Browse files
committed
fix(telegram): prevent code block breakage during message chunking
splitChunks now tracks fenced code blocks and skips paragraph boundaries inside them. Previously, blank lines inside code blocks could trigger chunk splits, separating opening and closing fences - breaking MarkdownV2 and forcing plain-text fallback. 3 new tests
1 parent da8907b commit 4dc4d79

2 files changed

Lines changed: 147 additions & 5 deletions

File tree

internal/telegram/format.go

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -257,15 +257,61 @@ func convertItalicAndEscape(line string) string {
257257
}
258258

259259
// splitChunks splits text into chunks no larger than maxBytes bytes.
260-
// It splits at paragraph boundaries (\n\n) when possible.
260+
// It splits at paragraph boundaries (\n\n) when possible, but never
261+
// splits inside fenced code blocks (```), which would break MarkdownV2
262+
// formatting and cause Telegram parse errors.
261263
// If a single paragraph exceeds maxBytes, it is split at the last space
262264
// before the limit.
263265
func splitChunks(text string, maxBytes int) []string {
264266
if maxBytes <= 0 {
265267
maxBytes = 4096
266268
}
267269

268-
paragraphs := strings.Split(text, "\n\n")
270+
// Walk through the text to find \n\n paragraph boundaries,
271+
// but skip \n\n that falls inside fenced code blocks (```).
272+
// \n\n inside a code block is kept as part of the block.
273+
var paragraphs []string
274+
start := 0
275+
inCodeBlock := false
276+
277+
for i := 0; i < len(text); {
278+
// Toggle code block state on ``` at the start of a line.
279+
if i <= len(text)-3 && text[i:i+3] == "```" {
280+
if i == 0 || text[i-1] == '\n' {
281+
inCodeBlock = !inCodeBlock
282+
i += 3
283+
continue
284+
}
285+
}
286+
287+
// Look for \n\n paragraph boundary — only split when outside
288+
// a code block.
289+
if i <= len(text)-2 && text[i:i+2] == "\n\n" && !inCodeBlock {
290+
seg := text[start:i]
291+
if len(seg) > 0 {
292+
paragraphs = append(paragraphs, seg)
293+
}
294+
i += 2 // skip the \n\n
295+
start = i
296+
continue
297+
}
298+
i++
299+
}
300+
301+
// Flush the remaining segment.
302+
if start < len(text) {
303+
seg := text[start:]
304+
if len(seg) > 0 {
305+
paragraphs = append(paragraphs, seg)
306+
}
307+
}
308+
309+
if len(paragraphs) == 0 {
310+
return nil
311+
}
312+
313+
// Now chunk paragraphs. Each paragraph (including code blocks) is
314+
// an atomic unit that won't be split at \n\n boundaries.
269315
var chunks []string
270316
var current strings.Builder
271317

@@ -289,7 +335,7 @@ func splitChunks(text string, maxBytes int) []string {
289335
chunks = append(chunks, current.String())
290336
current.Reset()
291337
}
292-
// Split the paragraph
338+
// Split the paragraph at spaces
293339
remaining := p
294340
for len(remaining) > 0 {
295341
if len(remaining) <= maxBytes {
@@ -323,5 +369,3 @@ func splitChunks(text string, maxBytes int) []string {
323369

324370
return chunks
325371
}
326-
327-
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package telegram
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// TestSplitChunks_CodeBlockIntact verifies that a code block smaller than
9+
// maxBytes stays in one chunk — \n\n inside the block doesn't trigger a split.
10+
func TestSplitChunks_CodeBlockIntact(t *testing.T) {
11+
// Code block with internal \n\n — should NOT be split
12+
before := strings.Repeat("x", 3500)
13+
code := "\n\n```\nsome code\n\ninside block\n```"
14+
after := "\n\nfinal text"
15+
16+
input := before + code + after
17+
18+
got := splitChunks(input, 4096)
19+
20+
// The code block must appear BALANCED (even number of ```) in one chunk.
21+
// Since the code block fits within 4096, it should be entirely in one chunk.
22+
foundComplete := false
23+
for _, chunk := range got {
24+
opens := strings.Count(chunk, "```")
25+
if opens == 2 {
26+
// Both open and close in same chunk
27+
foundComplete = true
28+
if !strings.Contains(chunk, "some code") {
29+
t.Error("code block appears intact but content missing")
30+
}
31+
}
32+
if opens == 1 {
33+
t.Errorf("unbalanced ``` in chunk (len=%d): %q", len(chunk), chunk[:min(80, len(chunk))])
34+
}
35+
}
36+
37+
if !foundComplete {
38+
t.Error("no chunk contains the complete code block")
39+
}
40+
41+
// All content must be present
42+
combined := strings.Join(got, "")
43+
if !strings.Contains(combined, "some code") {
44+
t.Error("lost code block content")
45+
}
46+
if !strings.Contains(combined, "final text") {
47+
t.Error("lost after-text content")
48+
}
49+
}
50+
51+
// TestSplitChunks_MultipleCodeBlocks verifies that multiple code blocks
52+
// are each kept intact during chunking.
53+
func TestSplitChunks_MultipleCodeBlocks(t *testing.T) {
54+
before := strings.Repeat("a", 3000)
55+
code1 := "\n\n```go\nfunc main() {}\n```"
56+
mid := "\n\n" + strings.Repeat("b", 1000)
57+
code2 := "\n\n```json\n{\"key\": \"val\"}\n```"
58+
59+
input := before + code1 + mid + code2
60+
61+
got := splitChunks(input, 4096)
62+
63+
for _, chunk := range got {
64+
opens := strings.Count(chunk, "```")
65+
if opens%2 != 0 {
66+
t.Errorf("unbalanced ``` in chunk: %q", chunk[:min(200, len(chunk))])
67+
}
68+
}
69+
70+
combined := strings.Join(got, "")
71+
if !strings.Contains(combined, "func main()") {
72+
t.Error("lost code1 content")
73+
}
74+
if !strings.Contains(combined, `"key": "val"`) {
75+
t.Error("lost code2 content")
76+
}
77+
}
78+
79+
// TestSplitChunks_InlineCodeUnaffected verifies that inline `code` spans
80+
// (single backtick) do NOT affect chunk splitting — only fenced blocks.
81+
func TestSplitChunks_InlineCodeUnaffected(t *testing.T) {
82+
before := strings.Repeat("a", 3000)
83+
mid := "\n\nUse `code` here."
84+
after := "\n\n" + strings.Repeat("b", 2000)
85+
86+
input := before + mid + after
87+
88+
got := splitChunks(input, 4096)
89+
90+
if len(got) < 2 {
91+
t.Fatalf("expected at least 2 chunks, got %d", len(got))
92+
}
93+
94+
combined := strings.Join(got, "")
95+
if !strings.Contains(combined, "`code`") {
96+
t.Error("lost inline code content")
97+
}
98+
}

0 commit comments

Comments
 (0)