Merge pull request #7 from pkonowrocki/fix/discord-message-splitting

fix: use rune-based splitting for Discord messages
This commit is contained in:
Piotrek Konowrocki 2026-02-16 22:44:06 +01:00 committed by GitHub
commit 71fc0ff751
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -117,54 +117,54 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return nil return nil
} }
// splitMessage splits long messages into chunks, preserving code block integrity // splitMessage splits long messages into chunks, preserving code block integrity.
// Uses natural boundaries (newlines, spaces) and extends messages slightly to avoid breaking code blocks // All length calculations use rune count (characters) since Discord's 2000-char
// limit is character-based, not byte-based.
func splitMessage(content string, limit int) []string { func splitMessage(content string, limit int) []string {
var messages []string var messages []string
runes := []rune(content)
for len(content) > 0 { for len(runes) > 0 {
if len(content) <= limit { if len(runes) <= limit {
messages = append(messages, content) messages = append(messages, string(runes))
break break
} }
msgEnd := limit msgEnd := limit
// Find natural split point within the limit // Find natural split point within the limit
msgEnd = findLastNewline(content[:limit], 200) msgEnd = findLastRuneNewline(runes[:limit], 200)
if msgEnd <= 0 { if msgEnd <= 0 {
msgEnd = findLastSpace(content[:limit], 100) msgEnd = findLastRuneSpace(runes[:limit], 100)
} }
if msgEnd <= 0 { if msgEnd <= 0 {
msgEnd = limit msgEnd = limit
} }
// Check if this would end with an incomplete code block // Check if this would end with an incomplete code block
candidate := content[:msgEnd] unclosedRuneIdx := findLastUnclosedCodeBlockRune(runes[:msgEnd])
unclosedIdx := findLastUnclosedCodeBlock(candidate)
if unclosedIdx >= 0 { if unclosedRuneIdx >= 0 {
// Message would end with incomplete code block // Message would end with incomplete code block
// Try to extend to include the closing ``` (with some buffer) // Try to extend to include the closing ``` (with some buffer)
extendedLimit := limit + 500 // Allow 500 char buffer for code blocks extendedLimit := limit + 400
if len(content) > extendedLimit { if len(runes) > extendedLimit {
closingIdx := findNextClosingCodeBlock(content, msgEnd) closingRuneIdx := findNextClosingCodeBlockRune(runes, msgEnd)
if closingIdx > 0 && closingIdx <= extendedLimit { if closingRuneIdx > 0 && closingRuneIdx <= extendedLimit {
// Extend to include the closing ``` msgEnd = closingRuneIdx
msgEnd = closingIdx
} else { } else {
// Can't find closing, split before the code block // Can't find closing, split before the code block
msgEnd = findLastNewline(content[:unclosedIdx], 200) msgEnd = findLastRuneNewline(runes[:unclosedRuneIdx], 200)
if msgEnd <= 0 { if msgEnd <= 0 {
msgEnd = findLastSpace(content[:unclosedIdx], 100) msgEnd = findLastRuneSpace(runes[:unclosedRuneIdx], 100)
} }
if msgEnd <= 0 { if msgEnd <= 0 {
msgEnd = unclosedIdx msgEnd = unclosedRuneIdx
} }
} }
} else { } else {
// Remaining content fits within extended limit // Remaining content fits within extended limit
msgEnd = len(content) msgEnd = len(runes)
} }
} }
@ -172,22 +172,23 @@ func splitMessage(content string, limit int) []string {
msgEnd = limit msgEnd = limit
} }
messages = append(messages, content[:msgEnd]) messages = append(messages, string(runes[:msgEnd]))
content = strings.TrimSpace(content[msgEnd:]) remaining := strings.TrimSpace(string(runes[msgEnd:]))
runes = []rune(remaining)
} }
return messages return messages
} }
// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ``` // findLastUnclosedCodeBlockRune finds the last opening ``` that doesn't have a closing ```
// Returns the position of the opening ``` or -1 if all code blocks are complete // using rune-based indexing. Returns the rune position or -1 if all code blocks are complete.
func findLastUnclosedCodeBlock(text string) int { func findLastUnclosedCodeBlockRune(runes []rune) int {
count := 0 count := 0
lastOpenIdx := -1 lastOpenIdx := -1
for i := 0; i < len(text); i++ { for i := 0; i < len(runes); i++ {
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { if i+2 < len(runes) && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
if count == 0 { if count%2 == 0 {
lastOpenIdx = i lastOpenIdx = i
} }
count++ count++
@ -195,48 +196,50 @@ func findLastUnclosedCodeBlock(text string) int {
} }
} }
// If odd number of ``` markers, last one is unclosed
if count%2 == 1 { if count%2 == 1 {
return lastOpenIdx return lastOpenIdx
} }
return -1 return -1
} }
// findNextClosingCodeBlock finds the next closing ``` starting from a position // findNextClosingCodeBlockRune finds the next closing ``` starting from a rune position.
// Returns the position after the closing ``` or -1 if not found // Returns the rune position after the closing ``` or -1 if not found.
func findNextClosingCodeBlock(text string, startIdx int) int { func findNextClosingCodeBlockRune(runes []rune, startIdx int) int {
for i := startIdx; i < len(text); i++ { for i := startIdx; i < len(runes); i++ {
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { if i+2 < len(runes) && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
return i + 3 // Include any trailing newline after the closing ```
end := i + 3
if end < len(runes) && runes[end] == '\n' {
end++
}
return end
} }
} }
return -1 return -1
} }
// findLastNewline finds the last newline character within the last N characters // findLastRuneNewline finds the last newline within the last N runes.
// Returns the position of the newline or -1 if not found func findLastRuneNewline(runes []rune, searchWindow int) int {
func findLastNewline(s string, searchWindow int) int { searchStart := len(runes) - searchWindow
searchStart := len(s) - searchWindow
if searchStart < 0 { if searchStart < 0 {
searchStart = 0 searchStart = 0
} }
for i := len(s) - 1; i >= searchStart; i-- { for i := len(runes) - 1; i >= searchStart; i-- {
if s[i] == '\n' { if runes[i] == '\n' {
return i return i
} }
} }
return -1 return -1
} }
// findLastSpace finds the last space character within the last N characters // findLastRuneSpace finds the last space within the last N runes.
// Returns the position of the space or -1 if not found func findLastRuneSpace(runes []rune, searchWindow int) int {
func findLastSpace(s string, searchWindow int) int { searchStart := len(runes) - searchWindow
searchStart := len(s) - searchWindow
if searchStart < 0 { if searchStart < 0 {
searchStart = 0 searchStart = 0
} }
for i := len(s) - 1; i >= searchStart; i-- { for i := len(runes) - 1; i >= searchStart; i-- {
if s[i] == ' ' || s[i] == '\t' { if runes[i] == ' ' || runes[i] == '\t' {
return i return i
} }
} }