fix: localize whatsapp outbound sanitization

This commit is contained in:
Marcus Castro
2026-04-28 23:14:46 -03:00
parent 6b5f0115c6
commit b142f1d589
7 changed files with 75 additions and 58 deletions

View File

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

View File

@@ -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",
"<function_calls>",
' <invoke name="send_message">',
' <parameter name="text"><b>hidden</b></parameter>',
" </invoke>",
"</function_calls>",
"<div>After</div>",
].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", {

View File

@@ -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",
"<function_calls>",
' <invoke name="send_message">',
' <parameter name="text"><b>hidden</b></parameter>',
" </invoke>",
"</function_calls>",
"<div>After</div>",
].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" }));

View File

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

View File

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

View File

@@ -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",
"<tool_calls>",
'<tool_call>{"name":"read","arguments":{"path":"/tmp/x"}}</tool_call>',
"</tool_calls>",
"After",
].join("\n");
expect(sanitizeAssistantVisibleText(input)).toBe("Before\n\nAfter");
});
it("strips plural function_calls wrappers with nested function_call blocks", () => {
const input = [
"Before",
"<function_calls>",
'<function_call>{"name":"read","arguments":{"path":"/tmp/x"}}</function_call>',
"</function_calls>",
"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",
"<function_calls>",
'<function_call>{"name":"read","arguments":{"html":"</function_calls> SECRET"}}</function_call>',
"</function_calls>",
"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",
"<tool_calls>",
'<tool_call>{"name":"read","arguments":{"html":"</tool_calls> SECRET"}}</tool_call>',
"</tool_calls>",
"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 </think> Visible answer")).toBe(
"Visible answer",

View File

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