fix: use rune-based splitting for Discord messages
Discord's 2000-character limit counts Unicode characters, not bytes. The old byte-based splitMessage could produce invalid chunks for multi-byte content (emoji, non-ASCII) and cut right at the limit boundary causing 4xx errors. Switch all split helpers to operate on []rune slices and reduce the code-block extension buffer from 500 to 400 characters.
This commit is contained in:
parent
720fae6aec
commit
37f4132bb1
1 changed files with 50 additions and 47 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue