Refine message chunking and shell safety guard based on Copilot review
This commit is contained in:
parent
43609ca958
commit
2e84df5e92
4 changed files with 54 additions and 26 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue