mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 23:08:28 +00:00
fix(whatsapp): convert Markdown bold/strikethrough to WhatsApp formatting (#14285)
* fix(whatsapp): convert Markdown bold/strikethrough to WhatsApp formatting * refactor: Move `escapeRegExp` utility function to `utils.js`. --------- Co-authored-by: Luna AI <luna@coredirection.ai>
This commit is contained in:
62
src/markdown/whatsapp.test.ts
Normal file
62
src/markdown/whatsapp.test.ts
Normal file
@@ -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*",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/markdown/whatsapp.ts
Normal file
77
src/markdown/whatsapp.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { WebInboundMsg } from "./types.js";
|
|||||||
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js";
|
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js";
|
||||||
import { logVerbose, shouldLogVerbose } from "../../globals.js";
|
import { logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||||
|
import { markdownToWhatsApp } from "../../markdown/whatsapp.js";
|
||||||
import { sleep } from "../../utils.js";
|
import { sleep } from "../../utils.js";
|
||||||
import { loadWebMedia } from "../media.js";
|
import { loadWebMedia } from "../media.js";
|
||||||
import { newConnectionId } from "../reconnect.js";
|
import { newConnectionId } from "../reconnect.js";
|
||||||
@@ -29,7 +30,9 @@ export async function deliverWebReply(params: {
|
|||||||
const replyStarted = Date.now();
|
const replyStarted = Date.now();
|
||||||
const tableMode = params.tableMode ?? "code";
|
const tableMode = params.tableMode ?? "code";
|
||||||
const chunkMode = params.chunkMode ?? "length";
|
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 textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode);
|
||||||
const mediaList = replyResult.mediaUrls?.length
|
const mediaList = replyResult.mediaUrls?.length
|
||||||
? replyResult.mediaUrls
|
? replyResult.mediaUrls
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
|||||||
import { getChildLogger } from "../logging/logger.js";
|
import { getChildLogger } from "../logging/logger.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import { convertMarkdownTables } from "../markdown/tables.js";
|
import { convertMarkdownTables } from "../markdown/tables.js";
|
||||||
|
import { markdownToWhatsApp } from "../markdown/whatsapp.js";
|
||||||
import { normalizePollInput, type PollInput } from "../polls.js";
|
import { normalizePollInput, type PollInput } from "../polls.js";
|
||||||
import { toWhatsappJid } from "../utils.js";
|
import { toWhatsappJid } from "../utils.js";
|
||||||
import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js";
|
import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js";
|
||||||
@@ -34,6 +35,7 @@ export async function sendMessageWhatsApp(
|
|||||||
accountId: resolvedAccountId ?? options.accountId,
|
accountId: resolvedAccountId ?? options.accountId,
|
||||||
});
|
});
|
||||||
text = convertMarkdownTables(text ?? "", tableMode);
|
text = convertMarkdownTables(text ?? "", tableMode);
|
||||||
|
text = markdownToWhatsApp(text);
|
||||||
const logger = getChildLogger({
|
const logger = getChildLogger({
|
||||||
module: "web-outbound",
|
module: "web-outbound",
|
||||||
correlationId,
|
correlationId,
|
||||||
|
|||||||
Reference in New Issue
Block a user