diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c5321870..3f3f50a70 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,