mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 00:16:38 +00:00
feat(zalo): strip markdown from outbound messages for clean plain text
Zalo API does not support any markup rendering. LLM output containing markdown artifacts (**bold**, `code`, headers, etc.) was sent as-is, making messages hard to read. Add StripMarkdown() to convert markdown to clean plain text, and inject a system prompt hint instructing the model to output plain text on Zalo channels.
This commit is contained in:
1 parent
86ba785fe3
commit
31ba182b40
6 files changed
+165
No files matched your search
@@ -160,6 +160,11 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string {
|
||||
lines = append(lines, buildMessagingSection()...)
|
||||
}
|
||||
|
||||
// 9.5. Channel formatting hints (e.g. Zalo → plain text)
|
||||
if hint := buildChannelFormattingHint(cfg.ChannelType); hint != nil {
|
||||
lines = append(lines, hint...)
|
||||
}
|
||||
|
||||
// 10. Extra system prompt (wrapped in tags for context isolation)
|
||||
if cfg.ExtraPrompt != "" {
|
||||
header := "## Additional Context"
|
||||
|
||||
@@ -248,6 +248,24 @@ func buildRuntimeSection(cfg SystemPromptConfig) []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
// buildChannelFormattingHint returns platform-specific formatting guidance.
|
||||
// Zalo does not render any markup, so we instruct the model to use plain text.
|
||||
func buildChannelFormattingHint(channelType string) []string {
|
||||
switch channelType {
|
||||
case "zalo", "zalo_personal":
|
||||
return []string{
|
||||
"## Output Formatting",
|
||||
"",
|
||||
"This channel (Zalo) does NOT support any text formatting — no Markdown, no HTML, no bold/italic/code.",
|
||||
"Always respond in clean plain text. Do not use **, __, `, ```, #, > or any markup syntax.",
|
||||
"For lists use simple dashes or bullets (•). For code, just paste the code as-is without fencing.",
|
||||
"",
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// hasBootstrapFile checks if BOOTSTRAP.md is present in the context files.
|
||||
func hasBootstrapFile(files []bootstrap.ContextFile) bool {
|
||||
for _, f := range files {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package zalo
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StripMarkdown removes markdown formatting artifacts from text, producing
|
||||
// clean plain text suitable for Zalo which does not support any markup.
|
||||
func StripMarkdown(text string) string {
|
||||
if text == "" {
|
||||
return text
|
||||
}
|
||||
|
||||
// 1. Strip fenced code blocks — keep content, remove ``` delimiters
|
||||
text = reFencedCode.ReplaceAllString(text, "$1")
|
||||
|
||||
// 2. Strip inline code backticks
|
||||
text = reInlineCode.ReplaceAllString(text, "$1")
|
||||
|
||||
// 3. Strip images  — remove entirely
|
||||
text = reImage.ReplaceAllString(text, "")
|
||||
|
||||
// 4. Strip links [text](url) → text (url)
|
||||
text = reLink.ReplaceAllString(text, "$1 ($2)")
|
||||
|
||||
// 5. Strip bold+italic (***text*** or ___text___)
|
||||
text = reBoldItalicStar.ReplaceAllString(text, "$1")
|
||||
text = reBoldItalicUnder.ReplaceAllString(text, "$1")
|
||||
|
||||
// 6. Strip bold (**text** or __text__)
|
||||
text = reBoldStar.ReplaceAllString(text, "$1")
|
||||
text = reBoldUnder.ReplaceAllString(text, "$1")
|
||||
|
||||
// 7. Strip strikethrough ~~text~~
|
||||
text = reStrikethrough.ReplaceAllString(text, "$1")
|
||||
|
||||
// 8. Strip headers (lines starting with #)
|
||||
text = reHeader.ReplaceAllString(text, "$1")
|
||||
|
||||
// 9. Strip horizontal rules
|
||||
text = reHorizontalRule.ReplaceAllString(text, "")
|
||||
|
||||
// 10. Strip blockquotes
|
||||
text = reBlockquote.ReplaceAllString(text, "$1")
|
||||
|
||||
// 11. Replace bullet markers with •
|
||||
text = reBullet.ReplaceAllString(text, "${1}• ")
|
||||
|
||||
// Clean up excessive blank lines (3+ → 2)
|
||||
text = reExcessiveNewlines.ReplaceAllString(text, "\n\n")
|
||||
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
var (
|
||||
reFencedCode = regexp.MustCompile("(?s)```[a-zA-Z0-9]*\\n?(.*?)```")
|
||||
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
||||
reImage = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`)
|
||||
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||
reBoldItalicStar = regexp.MustCompile(`\*{3}(.+?)\*{3}`)
|
||||
reBoldItalicUnder = regexp.MustCompile(`_{3}(.+?)_{3}`)
|
||||
reBoldStar = regexp.MustCompile(`\*{2}(.+?)\*{2}`)
|
||||
reBoldUnder = regexp.MustCompile(`_{2}(.+?)_{2}`)
|
||||
reStrikethrough = regexp.MustCompile(`~~(.+?)~~`)
|
||||
reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`)
|
||||
reHorizontalRule = regexp.MustCompile(`(?m)^[\s]*[-*_]{3,}[\s]*$`)
|
||||
reBlockquote = regexp.MustCompile(`(?m)^>\s?(.*)$`)
|
||||
reBullet = regexp.MustCompile(`(?m)^(\s*)[-*+]\s+`)
|
||||
|
||||
reExcessiveNewlines = regexp.MustCompile(`\n{3,}`)
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
package zalo
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStripMarkdown(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"plain text", "hello world", "hello world"},
|
||||
|
||||
// Bold & italic
|
||||
{"bold stars", "this is **bold** text", "this is bold text"},
|
||||
{"bold underscores", "this is __bold__ text", "this is bold text"},
|
||||
{"bold+italic stars", "***important***", "important"},
|
||||
{"strikethrough", "this is ~~deleted~~ text", "this is deleted text"},
|
||||
|
||||
// Code
|
||||
{"inline code", "use `fmt.Println` here", "use fmt.Println here"},
|
||||
{"fenced code block", "before\n```go\nfmt.Println(\"hi\")\n```\nafter", "before\nfmt.Println(\"hi\")\n\nafter"},
|
||||
{"fenced code block no lang", "```\ncode here\n```", "code here"},
|
||||
|
||||
// Links & images
|
||||
{"link", "click [here](https://example.com) now", "click here (https://example.com) now"},
|
||||
{"image", "see  below", "see below"},
|
||||
|
||||
// Headers
|
||||
{"h1", "# Title", "Title"},
|
||||
{"h3", "### Section", "Section"},
|
||||
{"h6", "###### Deep", "Deep"},
|
||||
|
||||
// Horizontal rules
|
||||
{"hr dashes", "above\n---\nbelow", "above\n\nbelow"},
|
||||
{"hr stars", "above\n***\nbelow", "above\n\nbelow"},
|
||||
|
||||
// Blockquotes
|
||||
{"blockquote", "> this is quoted\n> second line", "this is quoted\nsecond line"},
|
||||
{"nested blockquote", "> > deep", "> deep"},
|
||||
|
||||
// Bullets
|
||||
{"dash bullet", "- item one\n- item two", "• item one\n• item two"},
|
||||
{"star bullet", "* item one\n* item two", "• item one\n• item two"},
|
||||
{"plus bullet", "+ item one", "• item one"},
|
||||
{"indented bullet", "list:\n - nested item", "list:\n • nested item"},
|
||||
|
||||
// Excessive newlines
|
||||
{"excessive newlines", "a\n\n\n\nb", "a\n\nb"},
|
||||
|
||||
// Mixed
|
||||
{"mixed markdown", "## Hello\n\nThis is **bold** and `code`.\n\n- item\n- [link](url)\n\n> quote", "Hello\n\nThis is bold and code.\n\n• item\n• link (url)\n\nquote"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := StripMarkdown(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("StripMarkdown(%q)\n got: %q\nwant: %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels/typing"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
@@ -159,6 +160,9 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
return fmt.Errorf("zalo_personal channel not running")
|
||||
}
|
||||
|
||||
// Strip markdown — Zalo does not support any markup rendering.
|
||||
msg.Content = zalo.StripMarkdown(msg.Content)
|
||||
|
||||
// Stop typing indicator before sending response
|
||||
if ctrl, ok := c.typingCtrls.LoadAndDelete(msg.ChatID); ok {
|
||||
ctrl.(*typing.Controller).Stop()
|
||||
|
||||
@@ -111,6 +111,9 @@ func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error {
|
||||
return fmt.Errorf("zalo bot not running")
|
||||
}
|
||||
|
||||
// Strip markdown — Zalo does not support any markup rendering.
|
||||
msg.Content = StripMarkdown(msg.Content)
|
||||
|
||||
// Check for media in content (URL-based photo sending)
|
||||
if strings.Contains(msg.Content, "[photo:") {
|
||||
// Extract photo URL from "[photo:URL]" pattern
|
||||
|
||||
Reference in new issue
Block a user