diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index 5700eed7..7a4192e6 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -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" diff --git a/internal/agent/systemprompt_sections.go b/internal/agent/systemprompt_sections.go index 9cba3581..a6a867d7 100644 --- a/internal/agent/systemprompt_sections.go +++ b/internal/agent/systemprompt_sections.go @@ -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 { diff --git a/internal/channels/zalo/format.go b/internal/channels/zalo/format.go new file mode 100644 index 00000000..fbdcb294 --- /dev/null +++ b/internal/channels/zalo/format.go @@ -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 ![alt](url) — 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,}`) +) diff --git a/internal/channels/zalo/format_test.go b/internal/channels/zalo/format_test.go new file mode 100644 index 00000000..ea3935c8 --- /dev/null +++ b/internal/channels/zalo/format_test.go @@ -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 ![alt](https://img.png) 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) + } + }) + } +} diff --git a/internal/channels/zalo/personal/channel.go b/internal/channels/zalo/personal/channel.go index f4ab8274..73bdfdcf 100644 --- a/internal/channels/zalo/personal/channel.go +++ b/internal/channels/zalo/personal/channel.go @@ -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() diff --git a/internal/channels/zalo/zalo.go b/internal/channels/zalo/zalo.go index 724d2e00..91825153 100644 --- a/internal/channels/zalo/zalo.go +++ b/internal/channels/zalo/zalo.go @@ -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