From 8d3f594ff625186d85e9d876db67786ac75fb026 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Wed, 28 Jan 2026 14:09:59 +0800 Subject: [PATCH 1/3] 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 5909c9899..20b72792f 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, From c68283c896615d1b261765e429281a726faae8af Mon Sep 17 00:00:00 2001 From: zerone0x Date: Thu, 29 Jan 2026 01:40:39 +0800 Subject: [PATCH 2/3] fix(tui): handle /model status and /model list subcommands Previously `/model status` in the TUI would try to set the model to "status", resulting in "model not allowed: anthropic/status" error. Now the TUI correctly handles: - `/model status` - shows current model information - `/model list` - opens the model selector (same as /models) Fixes #3469 Co-Authored-By: Claude --- src/tui/tui-command-handlers.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index a14172809..3c77cfab3 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -278,6 +278,19 @@ export function createCommandHandlers(context: CommandHandlerContext) { case "model": if (!args) { await openModelSelector(); + } else if (args === "status") { + // Show current model information + const provider = state.sessionInfo.modelProvider ?? "unknown"; + const model = state.sessionInfo.model ?? "unknown"; + const current = `${provider}/${model}`; + chatLog.addSystem( + [`Current: ${current}`, "", "Switch: /model ", "Browse: /models"].join( + "\n", + ), + ); + } else if (args === "list") { + // /model list is an alias for /models + await openModelSelector(); } else { try { await client.patchSession({ From 303a19384af7e9d988e9e6ed32aa9cbd72c36750 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Thu, 29 Jan 2026 01:41:01 +0800 Subject: [PATCH 3/3] docs: add changelog entry for #3469 Co-Authored-By: Claude --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b72792f..2df36a2d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.molt.bot Status: beta. ### Changes +- TUI: handle `/model status` and `/model list` subcommands instead of treating them as model names. (#3469) Thanks @riskatcher. - 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.