From 861be9c93632f9709ffecd0e5a45e97e591b3b28 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Wed, 28 Jan 2026 14:09:59 +0800 Subject: [PATCH] fix(whatsapp): normalize literal \n escape sequences in outbound messages Some LLM providers output literal \n characters (backslash-n) instead of actual newlines. This causes WhatsApp to display the escape sequences as text rather than rendering line breaks. Add normalizeOutboundTextNewlines() to convert literal \n, \r\n, and \r escape sequences to actual newlines before sending messages via the WhatsApp adapter. Fixes #3082 Co-Authored-By: Claude --- CHANGELOG.md | 1 + src/auto-reply/inbound.test.ts | 39 ++++++++++++++++++++++- src/auto-reply/reply/inbound-text.ts | 14 ++++++++ src/channels/plugins/outbound/whatsapp.ts | 7 ++-- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e16c962a4..63816972b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.molt.bot Status: beta. ### Changes +- WhatsApp: normalize literal `\n` escape sequences to actual newlines in outbound messages. (#3082) - Rebrand: rename the npm package/CLI to `moltbot`, add a `moltbot` compatibility shim, and move extensions to the `@moltbot/*` scope. - Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev. - macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk). diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts index b50113c05..75c7cfb2f 100644 --- a/src/auto-reply/inbound.test.ts +++ b/src/auto-reply/inbound.test.ts @@ -15,7 +15,10 @@ import { shouldSkipDuplicateInbound, } from "./reply/inbound-dedupe.js"; import { formatInboundBodyWithSenderMeta } from "./reply/inbound-sender-meta.js"; -import { normalizeInboundTextNewlines } from "./reply/inbound-text.js"; +import { + normalizeInboundTextNewlines, + normalizeOutboundTextNewlines, +} from "./reply/inbound-text.js"; import { resolveGroupRequireMention } from "./reply/groups.js"; import { buildMentionRegexes, @@ -69,6 +72,40 @@ describe("normalizeInboundTextNewlines", () => { }); }); +describe("normalizeOutboundTextNewlines", () => { + it("keeps real newlines", () => { + expect(normalizeOutboundTextNewlines("a\nb")).toBe("a\nb"); + }); + + it("converts literal \\n to real newlines", () => { + expect(normalizeOutboundTextNewlines("Hello\\nWorld")).toBe("Hello\nWorld"); + }); + + it("converts multiple literal \\n sequences", () => { + expect(normalizeOutboundTextNewlines("Line1\\n\\nLine2")).toBe("Line1\n\nLine2"); + }); + + it("converts literal \\r\\n to real newlines", () => { + expect(normalizeOutboundTextNewlines("a\\r\\nb")).toBe("a\nb"); + }); + + it("converts literal \\r to real newlines", () => { + expect(normalizeOutboundTextNewlines("a\\rb")).toBe("a\nb"); + }); + + it("handles empty string", () => { + expect(normalizeOutboundTextNewlines("")).toBe(""); + }); + + it("handles text without escape sequences", () => { + expect(normalizeOutboundTextNewlines("Hello World")).toBe("Hello World"); + }); + + it("handles mixed real and literal newlines", () => { + expect(normalizeOutboundTextNewlines("Real\nand\\nliteral")).toBe("Real\nand\nliteral"); + }); +}); + describe("finalizeInboundContext", () => { it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => { const ctx: MsgContext = { diff --git a/src/auto-reply/reply/inbound-text.ts b/src/auto-reply/reply/inbound-text.ts index dd17752b4..84f83cac8 100644 --- a/src/auto-reply/reply/inbound-text.ts +++ b/src/auto-reply/reply/inbound-text.ts @@ -1,3 +1,17 @@ export function normalizeInboundTextNewlines(input: string): string { return input.replaceAll("\r\n", "\n").replaceAll("\r", "\n").replaceAll("\\n", "\n"); } + +/** + * Normalize outbound text newlines before sending to channels. + * Converts literal `\n` escape sequences to actual newlines. + * Some LLM providers may output literal `\n` in their responses, + * which causes WhatsApp (and potentially other channels) to display + * the escape sequence as text instead of rendering a line break. + */ +export function normalizeOutboundTextNewlines(input: string): string { + if (!input) return input; + // Convert literal \n (backslash-n) sequences to actual newlines. + // Also handle \r\n and \r for consistency. + return input.replaceAll("\\r\\n", "\n").replaceAll("\\r", "\n").replaceAll("\\n", "\n"); +} diff --git a/src/channels/plugins/outbound/whatsapp.ts b/src/channels/plugins/outbound/whatsapp.ts index 303a015da..101298043 100644 --- a/src/channels/plugins/outbound/whatsapp.ts +++ b/src/channels/plugins/outbound/whatsapp.ts @@ -1,4 +1,5 @@ import { chunkText } from "../../../auto-reply/chunk.js"; +import { normalizeOutboundTextNewlines } from "../../../auto-reply/reply/inbound-text.js"; import { shouldLogVerbose } from "../../../globals.js"; import { sendPollWhatsApp } from "../../../web/outbound.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../../whatsapp/normalize.js"; @@ -60,7 +61,8 @@ export const whatsappOutbound: ChannelOutboundAdapter = { sendText: async ({ to, text, accountId, deps, gifPlayback }) => { const send = deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp; - const result = await send(to, text, { + const normalizedText = normalizeOutboundTextNewlines(text); + const result = await send(to, normalizedText, { verbose: false, accountId: accountId ?? undefined, gifPlayback, @@ -70,7 +72,8 @@ export const whatsappOutbound: ChannelOutboundAdapter = { sendMedia: async ({ to, text, mediaUrl, accountId, deps, gifPlayback }) => { const send = deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp; - const result = await send(to, text, { + const normalizedText = normalizeOutboundTextNewlines(text); + const result = await send(to, normalizedText, { verbose: false, mediaUrl, accountId: accountId ?? undefined,