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 <noreply@anthropic.com>
This commit is contained in:
zerone0x 2026-01-28 14:09:59 +08:00
parent 93c2d65398
commit 861be9c936
4 changed files with 58 additions and 3 deletions

View File

@ -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).

View File

@ -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 = {

View File

@ -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");
}

View File

@ -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,