mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:30:47 +00:00
fix: localize whatsapp outbound sanitization
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 : "";
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user