Refine message chunking and shell safety guard based on Copilot review

This commit is contained in:
Andrew 2026-02-23 10:16:25 +00:00
parent 43609ca958
commit 2e84df5e92
4 changed files with 54 additions and 26 deletions

View file

@ -169,7 +169,14 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
limit = 4000 // Default Telegram limit (~4096) limit = 4000 // Default Telegram limit (~4096)
} }
chunks := utils.SplitMessage(msg.Content, limit) // Telegram HTML tags (like <pre><code>) can expand content significantly.
// We use a safe headroom for the markdown-based split.
effectiveMarkdownLimit := limit - 500
if effectiveMarkdownLimit < 500 {
effectiveMarkdownLimit = 500
}
chunks := utils.SplitMessage(msg.Content, effectiveMarkdownLimit)
for i, chunk := range chunks { for i, chunk := range chunks {
htmlContent := markdownToTelegramHTML(chunk) htmlContent := markdownToTelegramHTML(chunk)

View file

@ -290,9 +290,9 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
return "" return ""
} }
// Match absolute paths: Unix (starts with / after space/start/quotes) or Windows (X:\) // Match absolute paths: Unix (starts with / after space/start/quotes/=/:) or Windows (X:\)
// This regex is careful not to match scoped packages like @mastra/core // This regex is careful not to match scoped packages like @mastra/core
pathPattern := regexp.MustCompile(`(?:\s|^|["'|&;])(/[^\s\"'|&;]+)|([A-Za-z]:\\[^\\\"'|&;]+)`) pathPattern := regexp.MustCompile(`(?:\s|^|["'|&;=:])(/[^\s\"'|&;:]+)|([A-Za-z]:\\[^\\\"'|&;:]+)`)
matches := pathPattern.FindAllStringSubmatch(cmd, -1) matches := pathPattern.FindAllStringSubmatch(cmd, -1)
for _, match := range matches { for _, match := range matches {

View file

@ -9,6 +9,13 @@ import (
// but may extend to maxLen when needed. It respects rune counts to ensure multi-byte // but may extend to maxLen when needed. It respects rune counts to ensure multi-byte
// characters (like emojis or CJK) are not split in half. // characters (like emojis or CJK) are not split in half.
func SplitMessage(content string, maxLen int) []string { func SplitMessage(content string, maxLen int) []string {
if content == "" {
return nil
}
if maxLen <= 0 {
return []string{content}
}
var messages []string var messages []string
// Dynamic buffer: 10% of maxLen, but at least 50 chars if possible // Dynamic buffer: 10% of maxLen, but at least 50 chars if possible
@ -35,7 +42,8 @@ func SplitMessage(content string, maxLen int) []string {
} }
// Find natural split point within the effective limit // Find natural split point within the effective limit
msgEnd := findLastSentenceBoundaryRunes(runes[:effectiveLimit], 300) // We pass the full slice and a limit so findLastSentenceBoundaryRunes can look ahead.
msgEnd := findLastSentenceBoundaryRunes(runes, effectiveLimit, 300)
if msgEnd <= 0 { if msgEnd <= 0 {
msgEnd = findLastNewlineRunes(runes[:effectiveLimit], 200) msgEnd = findLastNewlineRunes(runes[:effectiveLimit], 200)
} }
@ -84,7 +92,7 @@ func SplitMessage(content string, maxLen int) []string {
if msgEnd > headerEnd+20 { if msgEnd > headerEnd+20 {
// Find a better split point closer to maxLen // Find a better split point closer to maxLen
innerLimit := maxLen - 5 // Leave room for "\n```" innerLimit := maxLen - 5 // Leave room for "\n```"
betterEnd := findLastSentenceBoundaryRunes(runes[:innerLimit], 300) betterEnd := findLastSentenceBoundaryRunes(runes, innerLimit, 300)
if betterEnd <= headerEnd { if betterEnd <= headerEnd {
betterEnd = findLastNewlineRunes(runes[:innerLimit], 200) betterEnd = findLastNewlineRunes(runes[:innerLimit], 200)
} }
@ -105,7 +113,7 @@ func SplitMessage(content string, maxLen int) []string {
} }
// Otherwise, try to split before the code block starts // Otherwise, try to split before the code block starts
newEnd := findLastSentenceBoundaryRunes(runes[:unclosedIdx], 300) newEnd := findLastSentenceBoundaryRunes(runes, unclosedIdx, 300)
if newEnd <= 0 { if newEnd <= 0 {
newEnd = findLastNewlineRunes(runes[:unclosedIdx], 200) newEnd = findLastNewlineRunes(runes[:unclosedIdx], 200)
} }
@ -210,21 +218,27 @@ func findLastSpaceRunes(runes []rune, searchWindow int) int {
return -1 return -1
} }
// findLastSentenceBoundaryRunes finds the last sentence-ending punctuation // findLastSentenceBoundaryRunes finds the last sentence-ending punctuation within a limit.
// Returns the position after the punctuation or -1 if not found // It looks ahead to verify the boundary is real (followed by space, newline, or end of string).
func findLastSentenceBoundaryRunes(runes []rune, searchWindow int) int { func findLastSentenceBoundaryRunes(runes []rune, limit int, searchWindow int) int {
searchStart := len(runes) - searchWindow if limit > len(runes) {
limit = len(runes)
}
searchStart := limit - searchWindow
if searchStart < 0 { if searchStart < 0 {
searchStart = 0 searchStart = 0
} }
for i := len(runes) - 1; i >= searchStart; i-- { for i := limit - 1; i >= searchStart; i-- {
switch runes[i] { switch runes[i] {
case '.', '!', '?', '。', '', '': case '.', '!', '?', '。', '', '':
// Ensure it's the end of a sentence (followed by space, newline, or end of string) // Ensure it's a true boundary:
if i == len(runes)-1 || runes[i+1] == ' ' || runes[i+1] == '\n' || runes[i+1] == '\t' { // either it's the very end of the full message,
// or the NEXT rune (lookahead) is a space, newline, or tab.
if i == len(runes)-1 || (i+1 < len(runes) && (runes[i+1] == ' ' || runes[i+1] == '\n' || runes[i+1] == '\t')) {
return i + 1 return i + 1
} }
} }
} }
return -1 return -1
} }

View file

@ -28,6 +28,12 @@ func TestSplitMessage(t *testing.T) {
maxLen: 2000, maxLen: 2000,
expectChunks: 1, expectChunks: 1,
}, },
{
name: "MaxLen 0 (no split)",
content: "Hello world",
maxLen: 0,
expectChunks: 1,
},
{ {
name: "Simple split regular text", name: "Simple split regular text",
content: longText, content: longText,
@ -44,11 +50,6 @@ func TestSplitMessage(t *testing.T) {
}, },
{ {
name: "Split at newline", 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), content: strings.Repeat("a", 1750) + "\n" + strings.Repeat("b", 300),
maxLen: 2000, maxLen: 2000,
expectChunks: 2, expectChunks: 2,
@ -67,11 +68,9 @@ func TestSplitMessage(t *testing.T) {
maxLen: 2000, maxLen: 2000,
expectChunks: 2, expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) { checkContent: func(t *testing.T, chunks []string) {
// Check that first chunk ends with closing fence
if !strings.HasSuffix(chunks[0], "\n```") { if !strings.HasSuffix(chunks[0], "\n```") {
t.Error("First chunk should end with injected closing fence") t.Error("First chunk should end with injected closing fence")
} }
// Check that second chunk starts with execution header
if !strings.HasPrefix(chunks[1], "```go") { if !strings.HasPrefix(chunks[1], "```go") {
t.Error("Second chunk should start with injected code block header") t.Error("Second chunk should start with injected code block header")
} }
@ -83,12 +82,20 @@ func TestSplitMessage(t *testing.T) {
maxLen: 2000, maxLen: 2000,
expectChunks: 2, expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) { checkContent: func(t *testing.T, chunks []string) {
// Just verify we didn't panic and got valid strings. // Each chunk should stay within max runes limit
// Go strings are UTF-8, if we split mid-rune it would be bad, for i, chunk := range chunks {
// but standard slicing might do that. runeCount := len([]rune(chunk))
// Let's assume standard behavior is acceptable or check if it produces invalid rune? if runeCount > 2000 {
if !strings.Contains(chunks[0], "\u4e16") { t.Errorf("Chunk %d has too many runes: %d", i, runeCount)
t.Error("Chunk should contain unicode characters") }
}
// Verify total rune count
totalRunes := 0
for _, chunk := range chunks {
totalRunes += len([]rune(chunk))
}
if totalRunes != 2500 {
t.Errorf("Total rune count mismatch. Got %d, want 2500", totalRunes)
} }
}, },
}, },