diff --git a/src/markdown/whatsapp.test.ts b/src/markdown/whatsapp.test.ts new file mode 100644 index 0000000000..e69cfbeaf1 --- /dev/null +++ b/src/markdown/whatsapp.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { markdownToWhatsApp } from "./whatsapp.js"; + +describe("markdownToWhatsApp", () => { + it("converts **bold** to *bold*", () => { + expect(markdownToWhatsApp("**SOD Blast:**")).toBe("*SOD Blast:*"); + }); + + it("converts __bold__ to *bold*", () => { + expect(markdownToWhatsApp("__important__")).toBe("*important*"); + }); + + it("converts ~~strikethrough~~ to ~strikethrough~", () => { + expect(markdownToWhatsApp("~~deleted~~")).toBe("~deleted~"); + }); + + it("leaves single *italic* unchanged (already WhatsApp bold)", () => { + expect(markdownToWhatsApp("*text*")).toBe("*text*"); + }); + + it("leaves _italic_ unchanged (already WhatsApp italic)", () => { + expect(markdownToWhatsApp("_text_")).toBe("_text_"); + }); + + it("preserves fenced code blocks", () => { + const input = "```\nconst x = **bold**;\n```"; + expect(markdownToWhatsApp(input)).toBe(input); + }); + + it("preserves inline code", () => { + expect(markdownToWhatsApp("Use `**not bold**` here")).toBe("Use `**not bold**` here"); + }); + + it("handles mixed formatting", () => { + expect(markdownToWhatsApp("**bold** and ~~strike~~ and _italic_")).toBe( + "*bold* and ~strike~ and _italic_", + ); + }); + + it("handles multiple bold segments", () => { + expect(markdownToWhatsApp("**one** then **two**")).toBe("*one* then *two*"); + }); + + it("returns empty string for empty input", () => { + expect(markdownToWhatsApp("")).toBe(""); + }); + + it("returns plain text unchanged", () => { + expect(markdownToWhatsApp("no formatting here")).toBe("no formatting here"); + }); + + it("handles bold inside a sentence", () => { + expect(markdownToWhatsApp("This is **very** important")).toBe("This is *very* important"); + }); + + it("preserves code block with formatting inside", () => { + const input = "Before ```**bold** and ~~strike~~``` after **real bold**"; + expect(markdownToWhatsApp(input)).toBe( + "Before ```**bold** and ~~strike~~``` after *real bold*", + ); + }); +}); diff --git a/src/markdown/whatsapp.ts b/src/markdown/whatsapp.ts new file mode 100644 index 0000000000..9532bc8f7c --- /dev/null +++ b/src/markdown/whatsapp.ts @@ -0,0 +1,77 @@ +import { escapeRegExp } from "../utils.js"; +/** + * Convert standard Markdown formatting to WhatsApp-compatible markup. + * + * WhatsApp uses its own formatting syntax: + * bold: *text* + * italic: _text_ + * strikethrough: ~text~ + * monospace: ```text``` + * + * Standard Markdown uses: + * bold: **text** or __text__ + * italic: *text* or _text_ + * strikethrough: ~~text~~ + * code: `text` (inline) or ```text``` (block) + * + * The conversion preserves fenced code blocks and inline code, + * then converts bold and strikethrough markers. + */ + +/** Placeholder tokens used during conversion to protect code spans. */ +const FENCE_PLACEHOLDER = "\x00FENCE"; +const INLINE_CODE_PLACEHOLDER = "\x00CODE"; + +/** + * Convert standard Markdown bold/italic/strikethrough to WhatsApp formatting. + * + * Order of operations matters: + * 1. Protect fenced code blocks (```...```) — already WhatsApp-compatible + * 2. Protect inline code (`...`) — leave as-is + * 3. Convert **bold** → *bold* and __bold__ → *bold* + * 4. Convert ~~strike~~ → ~strike~ + * 5. Restore protected spans + * + * Italic *text* and _text_ are left alone since WhatsApp uses _text_ for italic + * and single * is already WhatsApp bold — no conversion needed for single markers. + */ +export function markdownToWhatsApp(text: string): string { + if (!text) { + return text; + } + + // 1. Extract and protect fenced code blocks + const fences: string[] = []; + let result = text.replace(/```[\s\S]*?```/g, (match) => { + fences.push(match); + return `${FENCE_PLACEHOLDER}${fences.length - 1}`; + }); + + // 2. Extract and protect inline code + const inlineCodes: string[] = []; + result = result.replace(/`[^`\n]+`/g, (match) => { + inlineCodes.push(match); + return `${INLINE_CODE_PLACEHOLDER}${inlineCodes.length - 1}`; + }); + + // 3. Convert **bold** → *bold* and __bold__ → *bold* + result = result.replace(/\*\*(.+?)\*\*/g, "*$1*"); + result = result.replace(/__(.+?)__/g, "*$1*"); + + // 4. Convert ~~strikethrough~~ → ~strikethrough~ + result = result.replace(/~~(.+?)~~/g, "~$1~"); + + // 5. Restore inline code + result = result.replace( + new RegExp(`${escapeRegExp(INLINE_CODE_PLACEHOLDER)}(\\d+)`, "g"), + (_, idx) => inlineCodes[Number(idx)] ?? "", + ); + + // 6. Restore fenced code blocks + result = result.replace( + new RegExp(`${escapeRegExp(FENCE_PLACEHOLDER)}(\\d+)`, "g"), + (_, idx) => fences[Number(idx)] ?? "", + ); + + return result; +} diff --git a/src/web/auto-reply/deliver-reply.ts b/src/web/auto-reply/deliver-reply.ts index 607b1ac418..cee7e1b79a 100644 --- a/src/web/auto-reply/deliver-reply.ts +++ b/src/web/auto-reply/deliver-reply.ts @@ -4,6 +4,7 @@ import type { WebInboundMsg } from "./types.js"; import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js"; import { logVerbose, shouldLogVerbose } from "../../globals.js"; import { convertMarkdownTables } from "../../markdown/tables.js"; +import { markdownToWhatsApp } from "../../markdown/whatsapp.js"; import { sleep } from "../../utils.js"; import { loadWebMedia } from "../media.js"; import { newConnectionId } from "../reconnect.js"; @@ -29,7 +30,9 @@ export async function deliverWebReply(params: { const replyStarted = Date.now(); const tableMode = params.tableMode ?? "code"; const chunkMode = params.chunkMode ?? "length"; - const convertedText = convertMarkdownTables(replyResult.text || "", tableMode); + const convertedText = markdownToWhatsApp( + convertMarkdownTables(replyResult.text || "", tableMode), + ); const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode); const mediaList = replyResult.mediaUrls?.length ? replyResult.mediaUrls diff --git a/src/web/outbound.ts b/src/web/outbound.ts index 1df9579893..08a0e36341 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -4,6 +4,7 @@ import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { getChildLogger } from "../logging/logger.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { convertMarkdownTables } from "../markdown/tables.js"; +import { markdownToWhatsApp } from "../markdown/whatsapp.js"; import { normalizePollInput, type PollInput } from "../polls.js"; import { toWhatsappJid } from "../utils.js"; import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js"; @@ -34,6 +35,7 @@ export async function sendMessageWhatsApp( accountId: resolvedAccountId ?? options.accountId, }); text = convertMarkdownTables(text ?? "", tableMode); + text = markdownToWhatsApp(text); const logger = getChildLogger({ module: "web-outbound", correlationId,