([\s\S]*?)`), "`${1}`"},
+ {regexp.MustCompile(`(?i)]*>([\s\S]*?)`), "[${2}](${1})"},
+}
+
+func htmlTagToWaMd(text string) string {
+ for _, r := range htmlToWaMdReplacers {
+ text = r.re.ReplaceAllString(text, r.repl)
+ }
+ return text
+}
+
+type waCodeBlockMatch struct {
+ text string
+ codes []string
+}
+
+// waExtractCodeBlocks pulls fenced code blocks out of text and replaces them with
+// \x00CB{n}\x00 placeholders so other regex passes don't mangle their contents.
+func waExtractCodeBlocks(text string) waCodeBlockMatch {
+ re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
+ matches := re.FindAllStringSubmatch(text, -1)
+
+ codes := make([]string, 0, len(matches))
+ for _, m := range matches {
+ codes = append(codes, m[1])
+ }
+
+ i := 0
+ text = re.ReplaceAllStringFunc(text, func(_ string) string {
+ placeholder := fmt.Sprintf("\x00CB%d\x00", i)
+ i++
+ return placeholder
+ })
+
+ return waCodeBlockMatch{text: text, codes: codes}
+}
+
+// chunkText splits text into pieces that fit within maxLen,
+// preferring to split at paragraph (\n\n) or line (\n) boundaries.
+func chunkText(text string, maxLen int) []string {
+ if len(text) <= maxLen {
+ return []string{text}
+ }
+
+ var chunks []string
+ for len(text) > 0 {
+ if len(text) <= maxLen {
+ chunks = append(chunks, text)
+ break
+ }
+ // Find the best split point: paragraph > line > space > hard cut.
+ cutAt := maxLen
+ if idx := strings.LastIndex(text[:maxLen], "\n\n"); idx > 0 {
+ cutAt = idx
+ } else if idx := strings.LastIndex(text[:maxLen], "\n"); idx > 0 {
+ cutAt = idx
+ } else if idx := strings.LastIndex(text[:maxLen], " "); idx > 0 {
+ cutAt = idx
+ }
+ chunks = append(chunks, strings.TrimRight(text[:cutAt], " \n"))
+ text = strings.TrimLeft(text[cutAt:], " \n")
+ }
+ return chunks
+}
diff --git a/internal/channels/whatsapp/format_test.go b/internal/channels/whatsapp/format_test.go
new file mode 100644
index 000000000..9b93ba86e
--- /dev/null
+++ b/internal/channels/whatsapp/format_test.go
@@ -0,0 +1,49 @@
+package whatsapp
+
+import "testing"
+
+func TestMarkdownToWhatsApp(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"empty", "", ""},
+ {"plain text", "hello world", "hello world"},
+ {"header h1", "# Title", "*Title*"},
+ {"header h3", "### Sub", "*Sub*"},
+ {"bold stars", "this is **bold** text", "this is *bold* text"},
+ {"bold underscores", "this is __bold__ text", "this is *bold* text"},
+ {"strikethrough", "~~deleted~~", "~deleted~"},
+ {"inline code", "use `fmt.Println`", "use ```fmt.Println```"},
+ {"link", "[Go](https://go.dev)", "Go https://go.dev"},
+ {"unordered list dash", "- item one\n- item two", "• item one\n• item two"},
+ {"unordered list star", "* item one\n* item two", "• item one\n• item two"},
+ {"blockquote", "> quoted text", "quoted text"},
+ {
+ "fenced code block preserved",
+ "```go\nfmt.Println(\"hi\")\n```",
+ "```\nfmt.Println(\"hi\")\n```",
+ },
+ {
+ "code block not mangled by bold regex",
+ "```\n**not bold**\n```",
+ "```\n**not bold**\n```",
+ },
+ {"collapse blank lines", "a\n\n\n\nb", "a\n\nb"},
+ {"html bold", "bold", "*bold*"},
+ {"html italic", "italic", "_italic_"},
+ {"html strikethrough", "+ {t("whatsapp.loginSuccessLoading")} +
+ )} + {status === "error" && ( +{errorMsg}
+ )} + {status === "waiting" && !qrPng && ( ++ {t("whatsapp.waitingForQr")} +
+ )} + {status === "waiting" && qrPng && ( + <> ++ {t("whatsapp.scanHint")} +
+ > + )} + {status === "idle" && ( +{t("whatsapp.initializing")}
+ )} +