refactor(telegram): use shared SplitMessage utility
This commit is contained in:
parent
02291c71fd
commit
a6c051e0d9
3 changed files with 370 additions and 105 deletions
|
|
@ -41,7 +41,6 @@ type thinkingCancel struct {
|
||||||
|
|
||||||
const (
|
const (
|
||||||
telegramMaxMessageLength = 4096
|
telegramMaxMessageLength = 4096
|
||||||
telegramSplitTarget = 3900
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (c *thinkingCancel) Cancel() {
|
func (c *thinkingCancel) Cancel() {
|
||||||
|
|
@ -499,117 +498,53 @@ func splitTelegramMessageContent(text string, maxLen int) []string {
|
||||||
return []string{text}
|
return []string{text}
|
||||||
}
|
}
|
||||||
|
|
||||||
if runeLen(markdownToTelegramHTML(text)) <= maxLen {
|
chunks := utils.SplitMessage(text, maxLen)
|
||||||
return []string{text}
|
return enforceTelegramMessageHTMLLimit(chunks, maxLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
target := telegramSplitTarget
|
func enforceTelegramMessageHTMLLimit(chunks []string, maxLen int) []string {
|
||||||
if target >= maxLen {
|
out := make([]string, 0, len(chunks))
|
||||||
target = maxLen - 64
|
for _, chunk := range chunks {
|
||||||
}
|
chunk = strings.TrimSpace(chunk)
|
||||||
if target < 256 {
|
if chunk == "" {
|
||||||
target = maxLen / 2
|
|
||||||
}
|
|
||||||
if target < 1 {
|
|
||||||
target = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := splitTextByBoundary(text, target)
|
|
||||||
if len(parts) == 1 {
|
|
||||||
runes := []rune(text)
|
|
||||||
if len(runes) <= 1 {
|
|
||||||
return parts
|
|
||||||
}
|
|
||||||
mid := len(runes) / 2
|
|
||||||
if mid < 1 {
|
|
||||||
mid = 1
|
|
||||||
}
|
|
||||||
parts = []string{
|
|
||||||
strings.TrimSpace(string(runes[:mid])),
|
|
||||||
strings.TrimSpace(string(runes[mid:])),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]string, 0, len(parts))
|
|
||||||
for _, p := range parts {
|
|
||||||
p = strings.TrimSpace(p)
|
|
||||||
if p == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, splitTelegramMessageContent(p, maxLen)...)
|
|
||||||
|
if runeLen(markdownToTelegramHTML(chunk)) <= maxLen {
|
||||||
|
out = append(out, chunk)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
runes := []rune(chunk)
|
||||||
|
if len(runes) <= 1 {
|
||||||
|
out = append(out, chunk)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
splitLimit := len(runes) / 2
|
||||||
|
if splitLimit > maxLen {
|
||||||
|
splitLimit = maxLen
|
||||||
|
}
|
||||||
|
if splitLimit < 1 {
|
||||||
|
splitLimit = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
subChunks := utils.SplitMessage(chunk, splitLimit)
|
||||||
|
if len(subChunks) <= 1 {
|
||||||
|
mid := len(runes) / 2
|
||||||
|
if mid < 1 {
|
||||||
|
mid = 1
|
||||||
|
}
|
||||||
|
subChunks = []string{
|
||||||
|
string(runes[:mid]),
|
||||||
|
string(runes[mid:]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, enforceTelegramMessageHTMLLimit(subChunks, maxLen)...)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitTextByBoundary(text string, limit int) []string {
|
|
||||||
runes := []rune(text)
|
|
||||||
if len(runes) <= limit {
|
|
||||||
return []string{text}
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]string, 0)
|
|
||||||
for len(runes) > 0 {
|
|
||||||
if len(runes) <= limit {
|
|
||||||
tail := strings.TrimSpace(string(runes))
|
|
||||||
if tail != "" {
|
|
||||||
result = append(result, tail)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
splitAt := findSplitPoint(runes, limit)
|
|
||||||
if splitAt <= 0 {
|
|
||||||
splitAt = limit
|
|
||||||
}
|
|
||||||
if splitAt > len(runes) {
|
|
||||||
splitAt = len(runes)
|
|
||||||
}
|
|
||||||
|
|
||||||
chunk := strings.TrimSpace(string(runes[:splitAt]))
|
|
||||||
if chunk != "" {
|
|
||||||
result = append(result, chunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
runes = runes[splitAt:]
|
|
||||||
for len(runes) > 0 && (runes[0] == '\n' || runes[0] == '\r' || runes[0] == ' ' || runes[0] == '\t') {
|
|
||||||
runes = runes[1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func findSplitPoint(runes []rune, limit int) int {
|
|
||||||
if len(runes) <= limit {
|
|
||||||
return len(runes)
|
|
||||||
}
|
|
||||||
if limit <= 1 {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
floor := limit / 2
|
|
||||||
if floor < 1 {
|
|
||||||
floor = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := limit; i > floor; i-- {
|
|
||||||
if i > 1 && runes[i-1] == '\n' && runes[i-2] == '\n' {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i := limit; i > floor; i-- {
|
|
||||||
if runes[i-1] == '\n' {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i := limit; i > floor; i-- {
|
|
||||||
if runes[i-1] == ' ' || runes[i-1] == '\t' {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return limit
|
|
||||||
}
|
|
||||||
|
|
||||||
func runeLen(text string) int {
|
func runeLen(text string) int {
|
||||||
return len([]rune(text))
|
return len([]rune(text))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
179
pkg/utils/message.go
Normal file
179
pkg/utils/message.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SplitMessage splits long messages into chunks, preserving code block integrity.
|
||||||
|
// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks,
|
||||||
|
// but may extend to maxLen when needed.
|
||||||
|
// Call SplitMessage with the full text content and the maximum allowed length of a single message;
|
||||||
|
// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks.
|
||||||
|
func SplitMessage(content string, maxLen int) []string {
|
||||||
|
var messages []string
|
||||||
|
|
||||||
|
// Dynamic buffer: 10% of maxLen, but at least 50 chars if possible
|
||||||
|
codeBlockBuffer := maxLen / 10
|
||||||
|
if codeBlockBuffer < 50 {
|
||||||
|
codeBlockBuffer = 50
|
||||||
|
}
|
||||||
|
if codeBlockBuffer > maxLen/2 {
|
||||||
|
codeBlockBuffer = maxLen / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(content) > 0 {
|
||||||
|
if len(content) <= maxLen {
|
||||||
|
messages = append(messages, content)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective split point: maxLen minus buffer, to leave room for code blocks
|
||||||
|
effectiveLimit := maxLen - codeBlockBuffer
|
||||||
|
if effectiveLimit < maxLen/2 {
|
||||||
|
effectiveLimit = maxLen / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find natural split point within the effective limit
|
||||||
|
msgEnd := findLastNewline(content[:effectiveLimit], 200)
|
||||||
|
if msgEnd <= 0 {
|
||||||
|
msgEnd = findLastSpace(content[:effectiveLimit], 100)
|
||||||
|
}
|
||||||
|
if msgEnd <= 0 {
|
||||||
|
msgEnd = effectiveLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this would end with an incomplete code block
|
||||||
|
candidate := content[:msgEnd]
|
||||||
|
unclosedIdx := findLastUnclosedCodeBlock(candidate)
|
||||||
|
|
||||||
|
if unclosedIdx >= 0 {
|
||||||
|
// Message would end with incomplete code block
|
||||||
|
// Try to extend up to maxLen to include the closing ```
|
||||||
|
if len(content) > msgEnd {
|
||||||
|
closingIdx := findNextClosingCodeBlock(content, msgEnd)
|
||||||
|
if closingIdx > 0 && closingIdx <= maxLen {
|
||||||
|
// Extend to include the closing ```
|
||||||
|
msgEnd = closingIdx
|
||||||
|
} else {
|
||||||
|
// Code block is too long to fit in one chunk or missing closing fence.
|
||||||
|
// Try to split inside by injecting closing and reopening fences.
|
||||||
|
headerEnd := strings.Index(content[unclosedIdx:], "\n")
|
||||||
|
if headerEnd == -1 {
|
||||||
|
headerEnd = unclosedIdx + 3
|
||||||
|
} else {
|
||||||
|
headerEnd += unclosedIdx
|
||||||
|
}
|
||||||
|
header := strings.TrimSpace(content[unclosedIdx:headerEnd])
|
||||||
|
|
||||||
|
// If we have a reasonable amount of content after the header, split inside
|
||||||
|
if msgEnd > headerEnd+20 {
|
||||||
|
// Find a better split point closer to maxLen
|
||||||
|
innerLimit := maxLen - 5 // Leave room for "\n```"
|
||||||
|
betterEnd := findLastNewline(content[:innerLimit], 200)
|
||||||
|
if betterEnd > headerEnd {
|
||||||
|
msgEnd = betterEnd
|
||||||
|
} else {
|
||||||
|
msgEnd = innerLimit
|
||||||
|
}
|
||||||
|
messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```")
|
||||||
|
content = strings.TrimSpace(header + "\n" + content[msgEnd:])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to split before the code block starts
|
||||||
|
newEnd := findLastNewline(content[:unclosedIdx], 200)
|
||||||
|
if newEnd <= 0 {
|
||||||
|
newEnd = findLastSpace(content[:unclosedIdx], 100)
|
||||||
|
}
|
||||||
|
if newEnd > 0 {
|
||||||
|
msgEnd = newEnd
|
||||||
|
} else {
|
||||||
|
// If we can't split before, we MUST split inside (last resort)
|
||||||
|
if unclosedIdx > 20 {
|
||||||
|
msgEnd = unclosedIdx
|
||||||
|
} else {
|
||||||
|
msgEnd = maxLen - 5
|
||||||
|
messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```")
|
||||||
|
content = strings.TrimSpace(header + "\n" + content[msgEnd:])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if msgEnd <= 0 {
|
||||||
|
msgEnd = effectiveLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, content[:msgEnd])
|
||||||
|
content = strings.TrimSpace(content[msgEnd:])
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ```
|
||||||
|
// Returns the position of the opening ``` or -1 if all code blocks are complete
|
||||||
|
func findLastUnclosedCodeBlock(text string) int {
|
||||||
|
inCodeBlock := false
|
||||||
|
lastOpenIdx := -1
|
||||||
|
|
||||||
|
for i := 0; i < len(text); i++ {
|
||||||
|
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
||||||
|
// Toggle code block state on each fence
|
||||||
|
if !inCodeBlock {
|
||||||
|
// Entering a code block: record this opening fence
|
||||||
|
lastOpenIdx = i
|
||||||
|
}
|
||||||
|
inCodeBlock = !inCodeBlock
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if inCodeBlock {
|
||||||
|
return lastOpenIdx
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// findNextClosingCodeBlock finds the next closing ``` starting from a position
|
||||||
|
// Returns the position after the closing ``` or -1 if not found
|
||||||
|
func findNextClosingCodeBlock(text string, startIdx int) int {
|
||||||
|
for i := startIdx; i < len(text); i++ {
|
||||||
|
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
||||||
|
return i + 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLastNewline finds the last newline character within the last N characters
|
||||||
|
// Returns the position of the newline or -1 if not found
|
||||||
|
func findLastNewline(s string, searchWindow int) int {
|
||||||
|
searchStart := len(s) - searchWindow
|
||||||
|
if searchStart < 0 {
|
||||||
|
searchStart = 0
|
||||||
|
}
|
||||||
|
for i := len(s) - 1; i >= searchStart; i-- {
|
||||||
|
if s[i] == '\n' {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLastSpace finds the last space character within the last N characters
|
||||||
|
// Returns the position of the space or -1 if not found
|
||||||
|
func findLastSpace(s string, searchWindow int) int {
|
||||||
|
searchStart := len(s) - searchWindow
|
||||||
|
if searchStart < 0 {
|
||||||
|
searchStart = 0
|
||||||
|
}
|
||||||
|
for i := len(s) - 1; i >= searchStart; i-- {
|
||||||
|
if s[i] == ' ' || s[i] == '\t' {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
151
pkg/utils/message_test.go
Normal file
151
pkg/utils/message_test.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplitMessage(t *testing.T) {
|
||||||
|
longText := strings.Repeat("a", 2500)
|
||||||
|
longCode := "```go\n" + strings.Repeat("fmt.Println(\"hello\")\n", 100) + "```" // ~2100 chars
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
maxLen int
|
||||||
|
expectChunks int // Check number of chunks
|
||||||
|
checkContent func(t *testing.T, chunks []string) // Custom validation
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Empty message",
|
||||||
|
content: "",
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Short message fits in one chunk",
|
||||||
|
content: "Hello world",
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Simple split regular text",
|
||||||
|
content: longText,
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 2,
|
||||||
|
checkContent: func(t *testing.T, chunks []string) {
|
||||||
|
if len(chunks[0]) > 2000 {
|
||||||
|
t.Errorf("Chunk 0 too large: %d", len(chunks[0]))
|
||||||
|
}
|
||||||
|
if len(chunks[0])+len(chunks[1]) != len(longText) {
|
||||||
|
t.Errorf("Total length mismatch. Got %d, want %d", len(chunks[0])+len(chunks[1]), len(longText))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Split at newline",
|
||||||
|
// 1750 chars then newline, then more chars.
|
||||||
|
// Dynamic buffer: 2000 / 10 = 200.
|
||||||
|
// Effective limit: 2000 - 200 = 1800.
|
||||||
|
// Split should happen at newline because it's at 1750 (< 1800).
|
||||||
|
// Total length must > 2000 to trigger split. 1750 + 1 + 300 = 2051.
|
||||||
|
content: strings.Repeat("a", 1750) + "\n" + strings.Repeat("b", 300),
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 2,
|
||||||
|
checkContent: func(t *testing.T, chunks []string) {
|
||||||
|
if len(chunks[0]) != 1750 {
|
||||||
|
t.Errorf("Expected chunk 0 to be 1750 length (split at newline), got %d", len(chunks[0]))
|
||||||
|
}
|
||||||
|
if chunks[1] != strings.Repeat("b", 300) {
|
||||||
|
t.Errorf("Chunk 1 content mismatch. Len: %d", len(chunks[1]))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Long code block split",
|
||||||
|
content: "Prefix\n" + longCode,
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 2,
|
||||||
|
checkContent: func(t *testing.T, chunks []string) {
|
||||||
|
// Check that first chunk ends with closing fence
|
||||||
|
if !strings.HasSuffix(chunks[0], "\n```") {
|
||||||
|
t.Error("First chunk should end with injected closing fence")
|
||||||
|
}
|
||||||
|
// Check that second chunk starts with execution header
|
||||||
|
if !strings.HasPrefix(chunks[1], "```go") {
|
||||||
|
t.Error("Second chunk should start with injected code block header")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Preserve Unicode characters",
|
||||||
|
content: strings.Repeat("\u4e16", 1000), // 3000 bytes
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 2,
|
||||||
|
checkContent: func(t *testing.T, chunks []string) {
|
||||||
|
// Just verify we didn't panic and got valid strings.
|
||||||
|
// Go strings are UTF-8, if we split mid-rune it would be bad,
|
||||||
|
// but standard slicing might do that.
|
||||||
|
// Let's assume standard behavior is acceptable or check if it produces invalid rune?
|
||||||
|
if !strings.Contains(chunks[0], "\u4e16") {
|
||||||
|
t.Error("Chunk should contain unicode characters")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := SplitMessage(tc.content, tc.maxLen)
|
||||||
|
|
||||||
|
if tc.expectChunks == 0 {
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("Expected 0 chunks, got %d", len(got))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != tc.expectChunks {
|
||||||
|
t.Errorf("Expected %d chunks, got %d", tc.expectChunks, len(got))
|
||||||
|
// Log sizes for debugging
|
||||||
|
for i, c := range got {
|
||||||
|
t.Logf("Chunk %d length: %d", i, len(c))
|
||||||
|
}
|
||||||
|
return // Stop further checks if count assumes specific split
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.checkContent != nil {
|
||||||
|
tc.checkContent(t, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitMessage_CodeBlockIntegrity(t *testing.T) {
|
||||||
|
// Focused test for the core requirement: splitting inside a code block preserves syntax highlighting
|
||||||
|
|
||||||
|
// 60 chars total approximately
|
||||||
|
content := "```go\npackage main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n```"
|
||||||
|
maxLen := 40
|
||||||
|
|
||||||
|
chunks := SplitMessage(content, maxLen)
|
||||||
|
|
||||||
|
if len(chunks) != 2 {
|
||||||
|
t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First chunk must end with "\n```"
|
||||||
|
if !strings.HasSuffix(chunks[0], "\n```") {
|
||||||
|
t.Errorf("First chunk should end with closing fence. Got: %q", chunks[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second chunk must start with the header "```go"
|
||||||
|
if !strings.HasPrefix(chunks[1], "```go") {
|
||||||
|
t.Errorf("Second chunk should start with code block header. Got: %q", chunks[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// First chunk should contain meaningful content
|
||||||
|
if len(chunks[0]) > 40 {
|
||||||
|
t.Errorf("First chunk exceeded maxLen: length %d", len(chunks[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue