From: Anderson A. Giacomolli Date: Sat, 7 Nov 2026 15:23:09 +0000 (-0300) Subject: Split long replies between Markdown blocks X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/97729fc59a2fbd73e3f8a424cae4c825f5ba885b?ds=sidebyside Split long replies between Markdown blocks splitRich cuts the Markdown of a reply into parts of at most 32768 bytes, the limit of the Markdown of one rich message. It cuts after a blank line outside a code block, so a table, a code block, and a paragraph stay whole, and each part renders as the reply would. When no such cut fits, it cuts after a line break outside a code block, and then inside a code block, which it closes at the end of the part and opens again with the same fence line in the next part. A single line over the limit is cut at the limit, never inside a character. The Bot API allows 32768 characters of rendered text and does not say if a character is a byte. The bytes of the Markdown are at least the characters of the text, so a part is never over the limit. The bot does not use splitRich yet. Issue: fec23f9 --- diff --git a/markdown.go b/markdown.go index 48e9ace..1a2f4fe 100644 --- a/markdown.go +++ b/markdown.go @@ -169,8 +169,7 @@ func closingFence(lines []string, index int, fence string) int { } for end := index + 1; end < len(lines); end++ { - trimmed := strings.TrimSpace(lines[end]) - if strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == "" { + if closesFence(lines[end], fence) { return end } } @@ -178,6 +177,12 @@ func closingFence(lines []string, index int, fence string) int { return -1 } +// closesFence tells if line closes the code block of fence. +func closesFence(line, fence string) bool { + trimmed := strings.TrimSpace(line) + return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == "" +} + // headingText returns the text of an ATX heading line without its marks, or "" // when line is not a heading or has no text. func headingText(line string) string { diff --git a/telegram.go b/telegram.go index 9a24a4e..9dbe86e 100644 --- a/telegram.go +++ b/telegram.go @@ -14,6 +14,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" ) const ( @@ -33,6 +34,12 @@ const ( // messageUnits is the most UTF-16 code units of one Telegram message. messageUnits = 4096 + // richMessageBytes is the most bytes of the Markdown of one rich + // message. The Bot API allows 32768 characters of rendered text, and + // the Markdown of a text has at least as many bytes as the text has + // characters. + richMessageBytes = 32768 + // typingInterval is how often the bot shows the typing action while a // turn runs. Telegram shows the action for about five seconds. typingInterval = 4 * time.Second @@ -548,3 +555,83 @@ func prefixEnd(text string, limit int) int { return len(text) } + +// splitRich cuts the Markdown of a reply into parts of at most limit bytes. It +// cuts after a blank line outside a code block, so a table, a code block, or a +// paragraph stays whole. When no such cut fits in a part, it cuts after the +// last line break outside a code block, then after the last line break in a +// code block, which it closes at the end of the part and opens again in the +// next part with the same fence line. A line longer than limit is cut at the +// limit. It never cuts a character. +func splitRich(markdown string, limit int) []string { + var parts []string + for len(markdown) > limit { + cut, fence, opening := richCut(markdown, limit) + if fence == "" { + parts = append(parts, markdown[:cut]) + markdown = markdown[cut:] + + continue + } + + parts = append(parts, markdown[:cut]+fence+"\n") + markdown = opening + "\n" + markdown[cut:] + } + + return append(parts, markdown) +} + +// richCut returns where splitRich cuts markdown, which is longer than limit. +// For a cut in a code block, it also returns the fence that closes the block +// and the line that opens it. Each part before a cut holds text other than +// space, and a cut in a code block leaves room for the fence, so each cut makes +// the rest shorter. +func richCut(markdown string, limit int) (int, string, string) { + blank, line, code := 0, 0, 0 + var fence, opening, inFence, inOpening string + for start := 0; start < limit; { + end := strings.IndexByte(markdown[start:], '\n') + if end < 0 || start+end+1 > limit { + break + } + + end += start + 1 + text := strings.TrimRight(markdown[start:end], "\r\n") + _, opens := codeFence(text) + switch { + case fence != "": + if closesFence(text, fence) { + fence = "" + } else if end > len(opening)+1 && end+len(fence)+1 <= limit { + code, inFence, inOpening = end, fence, opening + } + case opens != "": + fence, opening = opens, text + case strings.TrimSpace(markdown[:end]) == "": + case strings.TrimSpace(text) == "": + blank = end + } + + if fence == "" && strings.TrimSpace(markdown[:end]) != "" { + line = end + } + + start = end + } + + switch { + case blank > 0: + return blank, "", "" + case line > 0: + return line, "", "" + case code > 0: + return code, inFence, inOpening + } + + cut := limit + for !utf8.RuneStart(markdown[cut]) { + cut-- + } + + return cut, "", "" +} diff --git a/telegram_test.go b/telegram_test.go index 2578b77..30b2fa1 100644 --- a/telegram_test.go +++ b/telegram_test.go @@ -409,6 +409,64 @@ func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) { }, parts) } +func TestSplitRich(t *testing.T) { + testCases := []struct { + name string + markdown string + limit int + parts []string + }{ + { + name: "short reply stays whole", + markdown: "| a | b |", + limit: 100, + parts: []string{"| a | b |"}, + }, + { + name: "cut after a blank line keeps a table whole", + markdown: "Intro.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n", + limit: 30, + parts: []string{"Intro.\n\n", "| a | b |\n|---|---|\n| 1 | 2 |\n"}, + }, + { + name: "blank line in a code block is not a cut", + markdown: "Text.\n\n```go\nx\n\ny\n```\n\nEnd.\n", + limit: 20, + parts: []string{"Text.\n\n", "```go\nx\n\ny\n```\n\n", "End.\n"}, + }, + { + name: "paragraph with no blank line is cut after a line", + markdown: "aaa\nbbb\nccc\n", + limit: 9, + parts: []string{"aaa\nbbb\n", "ccc\n"}, + }, + { + name: "long code block is closed and opened again", + markdown: "```sh\none\ntwo\nthree\n```\n", + limit: 16, + parts: []string{"```sh\none\n```\n", "```sh\ntwo\n```\n", "```sh\nthree\n```\n"}, + }, + { + name: "long line is cut at the limit and not inside a character", + markdown: "ab\u00e9cd", + limit: 3, + parts: []string{"ab", "\u00e9c", "d"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.parts, splitRich(tc.markdown, tc.limit)) + }) + } + + long := strings.Repeat("A paragraph of the reply.\n\n", 2000) + parts := splitRich(long, richMessageBytes) + require.Len(t, parts, 2) + require.Len(t, parts[0], richMessageBytes-richMessageBytes%len("A paragraph of the reply.\n\n")) + require.Equal(t, long, strings.Join(parts, "")) +} + func TestLoadBotConfig(t *testing.T) { testCases := []struct { name string