Optimize message chunking performance and sync platform limits with 2026 standards

This commit is contained in:
Andrew 2026-02-23 10:32:31 +00:00
parent 2e84df5e92
commit d0f0e90b52
6 changed files with 129 additions and 95 deletions

View file

@ -123,7 +123,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
limit := c.config.MaxMessageLength limit := c.config.MaxMessageLength
if limit <= 0 { if limit <= 0 {
limit = 1900 limit = 1900 // Discord limit is 2000 chars
} }
chunks := utils.SplitMessage(msg.Content, limit) chunks := utils.SplitMessage(msg.Content, limit)

View file

@ -512,7 +512,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
limit := c.config.MaxMessageLength limit := c.config.MaxMessageLength
if limit <= 0 { if limit <= 0 {
limit = 4000 limit = 4500 // LINE individual message bubbles are capped at 5,000 chars
} }
chunks := utils.SplitMessage(msg.Content, limit) chunks := utils.SplitMessage(msg.Content, limit)
@ -525,7 +525,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if i == 0 && hasReplyToken { if i == 0 && hasReplyToken {
if err := c.sendReply(ctx, tokenEntry.token, chunk, currentQuoteToken); err == nil { if err := c.sendReply(ctx, tokenEntry.token, chunk, currentQuoteToken); err == nil {
logger.DebugCF("line", "Message chunk sent via Reply API", map[string]interface{}{ logger.DebugCF("line", "Message chunk sent via Reply API", map[string]any{
"chat_id": msg.ChatID, "chat_id": msg.ChatID,
"quoted": currentQuoteToken != "", "quoted": currentQuoteToken != "",
"chunk": i + 1, "chunk": i + 1,

View file

@ -121,7 +121,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
limit := c.config.MaxMessageLength limit := c.config.MaxMessageLength
if limit <= 0 { if limit <= 0 {
limit = 3000 limit = 3500 // Slack has a 4,000 char limit (individual blocks may be 3k)
} }
chunks := utils.SplitMessage(msg.Content, limit) chunks := utils.SplitMessage(msg.Content, limit)

View file

@ -170,10 +170,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
} }
// Telegram HTML tags (like <pre><code>) can expand content significantly. // Telegram HTML tags (like <pre><code>) can expand content significantly.
// We use a safe headroom for the markdown-based split. // We use a safe headroom for the markdown-based split, but never exceed
// the configured limit.
effectiveMarkdownLimit := limit - 500 effectiveMarkdownLimit := limit - 500
if effectiveMarkdownLimit < 500 { if effectiveMarkdownLimit < 1 {
effectiveMarkdownLimit = 500 effectiveMarkdownLimit = 1
} }
chunks := utils.SplitMessage(msg.Content, effectiveMarkdownLimit) chunks := utils.SplitMessage(msg.Content, effectiveMarkdownLimit)
@ -199,8 +200,12 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
tgMsg.ParseMode = telego.ModeHTML 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{ logger.ErrorCF("telegram", "failed to send message in HTML mode, falling back to plain text", map[string]any{
"error": err.Error(), "error": err.Error(),
"chat_id": msg.ChatID,
"chunk_index": i,
"chunk_total": len(chunks),
"chunk_length": len(chunk),
}) })
tgMsg.ParseMode = "" tgMsg.ParseMode = ""
tgMsg.Text = chunk // Use raw chunk if HTML fails tgMsg.Text = chunk // Use raw chunk if HTML fails

View file

@ -28,10 +28,12 @@ func SplitMessage(content string, maxLen int) []string {
} }
runes := []rune(content) runes := []rune(content)
startIndex := 0
for len(runes) > 0 { for startIndex < len(runes) {
if len(runes) <= maxLen { remainingRunes := runes[startIndex:]
messages = append(messages, string(runes)) if len(remainingRunes) <= maxLen {
messages = append(messages, string(remainingRunes))
break break
} }
@ -41,113 +43,124 @@ func SplitMessage(content string, maxLen int) []string {
effectiveLimit = maxLen / 2 effectiveLimit = maxLen / 2
} }
// Find natural split point within the effective limit // Find natural split point within the effective limit from the current startIndex
// We pass the full slice and a limit so findLastSentenceBoundaryRunes can look ahead. // We pass the full slice so findLastSentenceBoundaryRunes can look ahead past the window
msgEnd := findLastSentenceBoundaryRunes(runes, effectiveLimit, 300) msgEndOffset := findLastSentenceBoundaryRunes(runes, startIndex+effectiveLimit, 300)
if msgEnd <= 0 { if msgEndOffset <= startIndex {
msgEnd = findLastNewlineRunes(runes[:effectiveLimit], 200) msgEndOffset = findLastNewlineRunes(remainingRunes[:effectiveLimit], 200)
if msgEndOffset >= 0 {
msgEndOffset += startIndex
}
} }
if msgEnd <= 0 { if msgEndOffset <= startIndex {
msgEnd = findLastSpaceRunes(runes[:effectiveLimit], 100) msgEndOffset = findLastSpaceRunes(remainingRunes[:effectiveLimit], 100)
if msgEndOffset >= 0 {
msgEndOffset += startIndex
}
} }
if msgEnd <= 0 { if msgEndOffset <= startIndex {
msgEnd = effectiveLimit msgEndOffset = startIndex + effectiveLimit
} }
// Check if this would end with an incomplete code block // Check if this would end with an incomplete code block
candidate := runes[:msgEnd] candidateRunes := runes[startIndex:msgEndOffset]
unclosedIdx := findLastUnclosedCodeBlockRunes(candidate) unclosedIdx := findLastUnclosedCodeBlockRunes(candidateRunes)
if unclosedIdx >= 0 { if unclosedIdx >= 0 {
// Message would end with incomplete code block // Absolute index of the unclosed fence
absUnclosedIdx := startIndex + unclosedIdx
// Try to extend up to maxLen to include the closing ``` // Try to extend up to maxLen to include the closing ```
if len(runes) > msgEnd { closingIdx := findNextClosingCodeBlockRunes(runes, msgEndOffset)
closingIdx := findNextClosingCodeBlockRunes(runes, msgEnd) if closingIdx > 0 && closingIdx <= startIndex+maxLen {
if closingIdx > 0 && closingIdx <= maxLen { msgEndOffset = closingIdx
// Extend to include the closing ``` } else {
msgEnd = closingIdx // Find first newline after opening fence to extract header
headerEnd := -1
for i := absUnclosedIdx; i < len(runes); i++ {
if runes[i] == '\n' {
headerEnd = i
break
}
}
if headerEnd == -1 {
headerEnd = absUnclosedIdx + 3
} else { } else {
// Code block is too long to fit in one chunk or missing closing fence. headerEnd++ // include newline
// Try to split inside by injecting closing and reopening fences. }
header := strings.TrimSpace(string(runes[absUnclosedIdx:headerEnd]))
// Find the header end (first newline after the opening ```) if msgEndOffset > headerEnd+20 {
headerEnd := -1 innerLimit := maxLen - 5
for i := unclosedIdx; i < len(runes); i++ { betterEnd := findLastSentenceBoundaryRunes(runes, startIndex+innerLimit, 300)
if runes[i] == '\n' { if betterEnd <= headerEnd {
headerEnd = i betterEnd = findLastNewlineRunes(runes[startIndex:startIndex+innerLimit], 200)
break if betterEnd >= 0 {
betterEnd += startIndex
} }
} }
if headerEnd == -1 { if betterEnd > headerEnd {
headerEnd = unclosedIdx + 3 msgEndOffset = betterEnd
} else { } else {
// include newline msgEndOffset = startIndex + innerLimit
headerEnd++
} }
header := strings.TrimSpace(string(runes[unclosedIdx:headerEnd])) chunkStr := string(runes[startIndex:msgEndOffset])
messages = append(messages, strings.TrimRight(chunkStr, " \t\n\r")+"\n```")
// If we have a reasonable amount of content after the header, split inside // Move startIndex to msgEndOffset but "inject" the header for the next iteration.
if msgEnd > headerEnd+20 { // We prepend the header and a newline.
// Find a better split point closer to maxLen injectedHeader := header + "\n"
innerLimit := maxLen - 5 // Leave room for "\n```" nextRunes := append([]rune(injectedHeader), runes[msgEndOffset:]...)
betterEnd := findLastSentenceBoundaryRunes(runes, innerLimit, 300) runes = append(runes[:0], nextRunes...) // Reuse capacity
if betterEnd <= headerEnd { startIndex = 0
betterEnd = findLastNewlineRunes(runes[:innerLimit], 200) continue
} }
if betterEnd > headerEnd { // Try to split before the code block
msgEnd = betterEnd newEnd := findLastSentenceBoundaryRunes(runes, absUnclosedIdx, 300)
} else { if newEnd <= startIndex {
msgEnd = innerLimit newEnd = findLastNewlineRunes(runes[startIndex:absUnclosedIdx], 200)
} if newEnd >= 0 {
newEnd += startIndex
chunkStr := string(runes[:msgEnd])
messages = append(messages, strings.TrimRight(chunkStr, " \t\n\r")+"\n```")
nextChunkStart := string(runes[msgEnd:])
content = strings.TrimSpace(header + "\n" + nextChunkStart)
runes = []rune(content)
continue
} }
}
if newEnd <= startIndex {
newEnd = findLastSpaceRunes(runes[startIndex:absUnclosedIdx], 100)
if newEnd >= 0 {
newEnd += startIndex
}
}
// Otherwise, try to split before the code block starts if newEnd > startIndex {
newEnd := findLastSentenceBoundaryRunes(runes, unclosedIdx, 300) msgEndOffset = newEnd
if newEnd <= 0 { } else {
newEnd = findLastNewlineRunes(runes[:unclosedIdx], 200) // Hard split inside (last resort)
} msgEndOffset = startIndex + maxLen - 5
if newEnd <= 0 { chunkStr := string(runes[startIndex:msgEndOffset])
newEnd = findLastSpaceRunes(runes[:unclosedIdx], 100) messages = append(messages, strings.TrimRight(chunkStr, " \t\n\r")+"\n```")
}
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
chunkStr := string(runes[:msgEnd])
messages = append(messages, strings.TrimRight(chunkStr, " \t\n\r")+"\n```")
nextChunkStart := string(runes[msgEnd:]) injectedHeader := header + "\n"
content = strings.TrimSpace(header + "\n" + nextChunkStart) nextRunes := append([]rune(injectedHeader), runes[msgEndOffset:]...)
runes = []rune(content) runes = append(runes[:0], nextRunes...)
continue startIndex = 0
} continue
}
} }
} }
} }
if msgEnd <= 0 { if msgEndOffset <= startIndex {
msgEnd = effectiveLimit msgEndOffset = startIndex + effectiveLimit
} }
messages = append(messages, string(runes[:msgEnd])) messages = append(messages, string(runes[startIndex:msgEndOffset]))
nextContent := strings.TrimSpace(string(runes[msgEnd:]))
runes = []rune(nextContent) // Advance startIndex and skip leading whitespace for next chunk
startIndex = msgEndOffset
for startIndex < len(runes) && (runes[startIndex] == ' ' || runes[startIndex] == '\n' || runes[startIndex] == '\t' || runes[startIndex] == '\r') {
startIndex++
}
} }
return messages return messages

View file

@ -99,6 +99,22 @@ func TestSplitMessage(t *testing.T) {
} }
}, },
}, },
{
name: "Prefer sentence boundary",
// Content is: 1700 'a's, then ". ", then 500 'b's.
// Effective limit with maxLen=2000 is 1800. 1700 is well within it.
content: strings.Repeat("a", 1700) + ". " + strings.Repeat("b", 500),
maxLen: 2000,
expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) {
if len([]rune(chunks[0])) != 1701 { // 1700 'a's + '.'
t.Errorf("Expected chunk 0 to be 1701 runes (split at period), got %d %q", len([]rune(chunks[0])), chunks[0])
}
if !strings.HasSuffix(chunks[0], ".") {
t.Errorf("Chunk 0 should end with a period, got suffix: %q", chunks[0][len(chunks[0])-5:])
}
},
},
} }
for _, tc := range tests { for _, tc := range tests {