diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e2ac576582..73d1bebc7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,7 @@ Docs: https://docs.openclaw.ai - Channels/WhatsApp: detect explicit group `@mentions` again when the bot's own E.164 is in `allowFrom`, so shared-number setups no longer skip group pings that directly mention the bot. Fixes #49317. (#73453) Thanks @juan-flores077. - WhatsApp/reliability: publish real transport-liveness into WhatsApp channel status and force earlier reconnects on silent transport stalls, so quiet healthy sessions stay connected while wedged sockets recover before the later remote 408 path. (#72656) Thanks @Sathvik-1007. - Core/channels: tighten selected runtime, media, and plugin edge-case handling while preserving existing behavior. Thanks @jesse-merhi. +- Channels/WhatsApp: strip leaked plural tool-call XML wrappers on every WhatsApp-visible outbound path and allow `channels.whatsapp.exposeErrorText` to suppress visible error text per channel or account. (#71830) Thanks @rubencu. ## 2026.4.27 diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts index c8bf85f3d2e..4fb754e1f71 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts @@ -199,6 +199,32 @@ describe("deliverWebReply", () => { expect(sentText).toContain("After"); }); + it("uses the same final sanitizer stack for auto-reply text delivery", async () => { + const msg = makeMsg(); + + await deliverWebReply({ + replyResult: { + text: [ + "Before", + "", + ' ', + ' hidden', + " ", + "", + "
After
", + ].join("\n"), + }, + msg, + maxMediaBytes: 1024 * 1024, + textLimit: 4000, + replyLogger, + skipLog: true, + }); + + expect(msg.reply).toHaveBeenCalledTimes(1); + expect(vi.mocked(msg.reply).mock.calls[0]?.[0]).toBe("Before\n\nAfter\n"); + }); + it("keeps quote threading on every text chunk for a threaded reply", async () => { const msg = makeMsg(); cacheInboundMessageMeta("work", "15551234567@s.whatsapp.net", "reply-1", { diff --git a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts index 9165e9e32bd..5e09de52122 100644 --- a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts +++ b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts @@ -20,6 +20,36 @@ describe("whatsappOutbound sendPayload", () => { }); }); + it("uses the same final sanitizer stack for direct text sends", async () => { + const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" })); + + await whatsappOutbound.sendText!({ + cfg: {}, + to: "5511999999999@c.us", + text: [ + "Before", + "", + ' ', + ' hidden', + " ", + "", + "
After
", + ].join("\n"), + deps: { sendWhatsApp }, + }); + + expect(sendWhatsApp).toHaveBeenCalledWith( + "5511999999999@c.us", + "Before\n\nAfter\n", + expect.objectContaining({ + verbose: false, + cfg: {}, + accountId: undefined, + gifPlayback: undefined, + }), + ); + }); + it("trims leading whitespace for direct media captions", async () => { const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" })); diff --git a/extensions/whatsapp/src/outbound-base.ts b/extensions/whatsapp/src/outbound-base.ts index e429b946f0a..edeb8e43e4c 100644 --- a/extensions/whatsapp/src/outbound-base.ts +++ b/extensions/whatsapp/src/outbound-base.ts @@ -9,7 +9,6 @@ import { type ChannelOutboundAdapter, } from "openclaw/plugin-sdk/channel-send-result"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-send-deps"; import { sendTextMediaPayload } from "openclaw/plugin-sdk/reply-payload"; import { resolveMergedWhatsAppAccountConfig } from "./account-config.js"; @@ -145,7 +144,7 @@ export function createWhatsAppOutboundBase({ chunker, chunkerMode: "text", textChunkLimit: 4000, - sanitizeText: ({ text }) => sanitizeForPlainText(normalizeText(text)), + sanitizeText: ({ text }) => normalizeText(text), pollMaxOptions: 12, resolveTarget, ...createAttachedChannelResultAdapter({ diff --git a/extensions/whatsapp/src/outbound-media-contract.ts b/extensions/whatsapp/src/outbound-media-contract.ts index f2227d4ad5b..e01b2eb2d44 100644 --- a/extensions/whatsapp/src/outbound-media-contract.ts +++ b/extensions/whatsapp/src/outbound-media-contract.ts @@ -1,11 +1,13 @@ import fs from "node:fs/promises"; import path from "node:path"; import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime"; +import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { formatError } from "./session-errors.js"; import { sanitizeAssistantVisibleText, - sanitizeAssistantVisibleTextWithOptions, + sanitizeAssistantVisibleTextWithProfile, + stripToolCallXmlTags, sleep, } from "./text-runtime.js"; @@ -34,8 +36,16 @@ const WHATSAPP_VOICE_SAMPLE_RATE_HZ = 48_000; const WHATSAPP_VOICE_BITRATE = "64k"; const WHATSAPP_VOICE_MIMETYPE = "audio/ogg; codecs=opus"; +function stripWhatsAppPluralToolXml(text: string): string { + return stripToolCallXmlTags(text, { stripFunctionCallsXmlPayloads: true }); +} + +function finalizeWhatsAppVisibleText(text: string): string { + return sanitizeForPlainText(stripWhatsAppPluralToolXml(text)); +} + export function normalizeWhatsAppPayloadText(text: string | undefined): string { - return sanitizeAssistantVisibleText(text?.trimStart() ?? ""); + return finalizeWhatsAppVisibleText(sanitizeAssistantVisibleText(text ?? "")).trimStart(); } function stripLeadingBlankLines(text: string): string { @@ -45,10 +55,11 @@ function stripLeadingBlankLines(text: string): string { export function normalizeWhatsAppPayloadTextPreservingIndentation( text: string | undefined, ): string { - const sanitized = sanitizeAssistantVisibleTextWithOptions(stripLeadingBlankLines(text ?? ""), { - trim: "none", - }); - const normalized = stripLeadingBlankLines(sanitized); + const sanitized = sanitizeAssistantVisibleTextWithProfile( + stripLeadingBlankLines(text ?? ""), + "history", + ); + const normalized = stripLeadingBlankLines(finalizeWhatsAppVisibleText(sanitized)); return normalized.trim() ? normalized : ""; } diff --git a/src/shared/text/assistant-visible-text.test.ts b/src/shared/text/assistant-visible-text.test.ts index 938e0c5f706..c30deeb9688 100644 --- a/src/shared/text/assistant-visible-text.test.ts +++ b/src/shared/text/assistant-visible-text.test.ts @@ -529,54 +529,6 @@ describe("sanitizeAssistantVisibleText", () => { expect(sanitizeAssistantVisibleText(input)).toBe("Visible answer"); }); - it("strips plural tool_calls wrappers with nested tool_call blocks", () => { - const input = [ - "Before", - "", - '{"name":"read","arguments":{"path":"/tmp/x"}}', - "", - "After", - ].join("\n"); - - expect(sanitizeAssistantVisibleText(input)).toBe("Before\n\nAfter"); - }); - - it("strips plural function_calls wrappers with nested function_call blocks", () => { - const input = [ - "Before", - "", - '{"name":"read","arguments":{"path":"/tmp/x"}}', - "", - "After", - ].join("\n"); - - expect(sanitizeAssistantVisibleText(input)).toBe("Before\n\nAfter"); - }); - - it("does not close plural function_calls wrappers on matching text inside nested JSON", () => { - const input = [ - "Before", - "", - '{"name":"read","arguments":{"html":" SECRET"}}', - "", - "After", - ].join("\n"); - - expect(sanitizeAssistantVisibleText(input)).toBe("Before\n\nAfter"); - }); - - it("does not close plural tool_calls wrappers on matching text inside nested JSON", () => { - const input = [ - "Before", - "", - '{"name":"read","arguments":{"html":" SECRET"}}', - "", - "After", - ].join("\n"); - - expect(sanitizeAssistantVisibleText(input)).toBe("Before\n\nAfter"); - }); - it("drops malformed reasoning before orphan close tags when final text follows", () => { expect(sanitizeAssistantVisibleText("private chain of thought Visible answer")).toBe( "Visible answer", diff --git a/src/shared/text/assistant-visible-text.ts b/src/shared/text/assistant-visible-text.ts index 9a62e8bd820..7d6822cb585 100644 --- a/src/shared/text/assistant-visible-text.ts +++ b/src/shared/text/assistant-visible-text.ts @@ -572,14 +572,12 @@ const ASSISTANT_VISIBLE_TEXT_PIPELINE_OPTIONS: Record< reasoningMode: "strict", reasoningTrim: "both", stageOrder: "reasoning-last", - stripFunctionCallsXmlPayloads: true, }, history: { finalTrim: "none", reasoningMode: "strict", reasoningTrim: "none", stageOrder: "reasoning-last", - stripFunctionCallsXmlPayloads: true, }, "internal-scaffolding": { finalTrim: "start",