mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:20:42 +00:00
fix(whatsapp): sanitize tool XML and hide configured error text (#71830)
Merged via squash.
Prepared head SHA: 184d4a25e4
Co-authored-by: rubencu <4742789+rubencu@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
7f9815a297504c75022c4db2df250ce4cc9ff5c3f69250c67ca253b89148b9f3 config-baseline.json
|
||||
8bc9fda7c1096472beaa416a61043ce51d691d4dcad9ed3e0be46e68bb70b0ce config-baseline.core.json
|
||||
45162ff84813be8a1fe561ed8d6245a248d5c6288ef9e9af51bdf4ec05ef65ad config-baseline.channel.json
|
||||
0dd6583fafae6c9134e46c4cf9bddee9822d6436436dcb1a6dcba6d012962e51 config-baseline.plugin.json
|
||||
8903fd57a58acb9a7c949efc6b4197b249220dcd965420ceb7d884cb45fbc48d config-baseline.json
|
||||
86ad0927d992bc873affb3e20a31c6e3c95b2185a91f46cc8e6262a723a78f7d config-baseline.core.json
|
||||
bb7234c52b0bbf12de2a87fa553ec4e89e13aaba9d0d81cf1370621292da13e9 config-baseline.channel.json
|
||||
1f5592bfd141ba1e982ce31763a253c10afb080ab4ea2b6538299b114e29cee1 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
e7d03a0d5aed4f1afb5c7d5e235a166e1e248090632248eaa92b0016531e7f3b plugin-sdk-api-baseline.json
|
||||
b9bbf8e444b358485cb33c634d3f6f6588004a5c32482c1a473167957269ae58 plugin-sdk-api-baseline.jsonl
|
||||
c65b1aa1fb4cf402b90bedd3614eb5d7c3903ab860856392d1ee2481818a7a22 plugin-sdk-api-baseline.json
|
||||
da172742470204044c1542a3bba7f183161e90e742f6865c1c7f822dbdc7a7d6 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -393,6 +393,22 @@ When the linked self number is also present in `allowFrom`, WhatsApp self-chat s
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Error visibility
|
||||
|
||||
`channels.whatsapp.exposeErrorText` controls whether agent/provider error text is delivered back into WhatsApp. The default is `true`. Set it to `false` to keep failures quiet on WhatsApp while preserving other channel behavior.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
whatsapp: {
|
||||
exposeErrorText: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Per-account overrides use `channels.whatsapp.accounts.<id>.exposeErrorText`.
|
||||
|
||||
## Reply quoting
|
||||
|
||||
WhatsApp supports native reply quoting, where outbound replies visibly quote the inbound message. Control it with `channels.whatsapp.replyToMode`.
|
||||
@@ -660,7 +676,7 @@ Primary reference:
|
||||
High-signal WhatsApp fields:
|
||||
|
||||
- access: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`
|
||||
- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`, `reactionLevel`
|
||||
- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`, `reactionLevel`, `exposeErrorText`
|
||||
- multi-account: `accounts.<id>.enabled`, `accounts.<id>.authDir`, account-level overrides
|
||||
- operations: `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*`, `web.whatsapp.*`
|
||||
- session behavior: `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms.<id>.historyLimit`
|
||||
|
||||
@@ -45,6 +45,7 @@ export type ResolvedWhatsAppAccount = {
|
||||
direct?: WhatsAppAccountConfig["direct"];
|
||||
debounceMs?: number;
|
||||
replyToMode?: ReplyToMode;
|
||||
exposeErrorText?: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_WHATSAPP_MEDIA_MAX_MB = 50;
|
||||
@@ -156,6 +157,7 @@ export function resolveWhatsAppAccount(params: {
|
||||
direct: merged.direct,
|
||||
debounceMs: merged.debounceMs,
|
||||
replyToMode: merged.replyToMode,
|
||||
exposeErrorText: merged.exposeErrorText,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,54 @@ describe("deliverWebReply", () => {
|
||||
expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (text)");
|
||||
});
|
||||
|
||||
it("strips raw XML tool-call blocks before WhatsApp text delivery", async () => {
|
||||
const msg = makeMsg();
|
||||
|
||||
await deliverWebReply({
|
||||
replyResult: {
|
||||
text: 'Before\n<function_calls><invoke name="web_search"><parameter name="query">x</parameter></invoke></function_calls>\nAfter',
|
||||
},
|
||||
msg,
|
||||
maxMediaBytes: 1024 * 1024,
|
||||
textLimit: 4000,
|
||||
replyLogger,
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(msg.reply).toHaveBeenCalledTimes(1);
|
||||
const sentText = vi.mocked(msg.reply).mock.calls[0]?.[0];
|
||||
expect(sentText).not.toContain("function_calls");
|
||||
expect(sentText).not.toContain("invoke");
|
||||
expect(sentText).toContain("Before");
|
||||
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", {
|
||||
@@ -478,6 +526,30 @@ describe("deliverWebReply", () => {
|
||||
).not.toContain("boom");
|
||||
});
|
||||
|
||||
it("sanitizes XML tool-call blocks for outbound sendPayload delivery", async () => {
|
||||
const sendWhatsApp = vi.fn(async (_to: string, _text: string) => ({
|
||||
messageId: "wa-1",
|
||||
toJid: "jid",
|
||||
}));
|
||||
|
||||
await whatsappOutbound.sendPayload!({
|
||||
cfg: {},
|
||||
to: "5511999999999@c.us",
|
||||
text: "",
|
||||
payload: {
|
||||
text: 'Before\n<function_calls><invoke name="web_search"><parameter name="query">x</parameter></invoke></function_calls>\nAfter',
|
||||
},
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||
const sentText = sendWhatsApp.mock.calls[0]?.[1];
|
||||
expect(sentText).not.toContain("function_calls");
|
||||
expect(sentText).not.toContain("invoke");
|
||||
expect(sentText).toContain("Before");
|
||||
expect(sentText).toContain("After");
|
||||
});
|
||||
|
||||
it("keeps payload and auto-reply media normalization in parity", async () => {
|
||||
const payload = {
|
||||
text: "\n\ncaption",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { loadWebMedia } from "../media.js";
|
||||
import {
|
||||
type DeliverableWhatsAppOutboundPayload,
|
||||
normalizeWhatsAppOutboundPayload,
|
||||
normalizeWhatsAppPayloadTextPreservingIndentation,
|
||||
prepareWhatsAppOutboundMedia,
|
||||
@@ -24,6 +25,7 @@ import { elide } from "./util.js";
|
||||
|
||||
export async function deliverWebReply(params: {
|
||||
replyResult: ReplyPayload;
|
||||
normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
|
||||
msg: WebInboundMsg;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
maxMediaBytes: number;
|
||||
@@ -45,10 +47,14 @@ export async function deliverWebReply(params: {
|
||||
}
|
||||
const tableMode = params.tableMode ?? "code";
|
||||
const chunkMode = params.chunkMode ?? "length";
|
||||
const normalizedReply = normalizeWhatsAppOutboundPayload(replyResult, {
|
||||
normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation,
|
||||
});
|
||||
const convertedText = markdownToWhatsApp(convertMarkdownTables(normalizedReply.text, tableMode));
|
||||
const normalizedReply =
|
||||
params.normalizedReplyResult ??
|
||||
normalizeWhatsAppOutboundPayload(replyResult, {
|
||||
normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation,
|
||||
});
|
||||
const convertedText = markdownToWhatsApp(
|
||||
convertMarkdownTables(normalizedReply.text ?? "", tableMode),
|
||||
);
|
||||
const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode);
|
||||
const mediaList = normalizedReply.mediaUrls ?? [];
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ export async function monitorWebChannel(
|
||||
mediaMaxMb: account.mediaMaxMb,
|
||||
blockStreaming: account.blockStreaming,
|
||||
groups: account.groups,
|
||||
exposeErrorText: account.exposeErrorText,
|
||||
},
|
||||
},
|
||||
} satisfies ReturnType<typeof getRuntimeConfig>;
|
||||
|
||||
@@ -6,6 +6,7 @@ type CapturedReplyPayload = {
|
||||
text?: string;
|
||||
isReasoning?: boolean;
|
||||
isCompactionNotice?: boolean;
|
||||
isError?: boolean;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
@@ -434,6 +435,39 @@ describe("whatsapp inbound dispatch", () => {
|
||||
expect(rememberSentText).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("normalizes WhatsApp payload text before delivery and echo bookkeeping", async () => {
|
||||
const deliverReply = vi.fn(async () => undefined);
|
||||
const rememberSentText = vi.fn();
|
||||
|
||||
await dispatchBufferedReply({
|
||||
deliverReply,
|
||||
rememberSentText,
|
||||
});
|
||||
|
||||
const deliver = getCapturedDeliver();
|
||||
expect(deliver).toBeTypeOf("function");
|
||||
|
||||
await deliver?.(
|
||||
{
|
||||
text: 'Before\n<function_calls><invoke name="web_search"><parameter name="query">x</parameter></invoke></function_calls>\nAfter',
|
||||
},
|
||||
{ kind: "final" },
|
||||
);
|
||||
|
||||
expect(deliverReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyResult: expect.objectContaining({ text: "Before\n\nAfter" }),
|
||||
}),
|
||||
);
|
||||
expect(rememberSentText).toHaveBeenCalledWith(
|
||||
"Before\n\nAfter",
|
||||
expect.objectContaining({
|
||||
combinedBody: "hi",
|
||||
combinedBodySessionKey: "agent:main:whatsapp:direct:+1000",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses reasoning and compaction payloads before WhatsApp delivery", async () => {
|
||||
const deliverReply = vi.fn(async () => undefined);
|
||||
const rememberSentText = vi.fn();
|
||||
@@ -455,6 +489,76 @@ describe("whatsapp inbound dispatch", () => {
|
||||
expect(rememberSentText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses payloads that normalize to no visible WhatsApp content", async () => {
|
||||
const deliverReply = vi.fn(async () => undefined);
|
||||
const rememberSentText = vi.fn();
|
||||
|
||||
await dispatchBufferedReply({
|
||||
deliverReply,
|
||||
rememberSentText,
|
||||
});
|
||||
|
||||
const deliver = getCapturedDeliver();
|
||||
expect(deliver).toBeTypeOf("function");
|
||||
|
||||
await deliver?.(
|
||||
{
|
||||
text: '<function_calls><invoke name="web_search"><parameter name="query">x</parameter></invoke></function_calls>',
|
||||
},
|
||||
{ kind: "final" },
|
||||
);
|
||||
|
||||
expect(deliverReply).not.toHaveBeenCalled();
|
||||
expect(rememberSentText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses error payload text when exposeErrorText is false", async () => {
|
||||
const deliverReply = vi.fn(async () => undefined);
|
||||
const rememberSentText = vi.fn();
|
||||
|
||||
await dispatchBufferedReply({
|
||||
cfg: { channels: { whatsapp: { exposeErrorText: false } } } as never,
|
||||
deliverReply,
|
||||
rememberSentText,
|
||||
});
|
||||
|
||||
const deliver = getCapturedDeliver();
|
||||
expect(deliver).toBeTypeOf("function");
|
||||
|
||||
await deliver?.({ text: "provider exploded", isError: true }, { kind: "final" });
|
||||
|
||||
expect(deliverReply).not.toHaveBeenCalled();
|
||||
expect(rememberSentText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honors account-level exposeErrorText overrides for error payloads", async () => {
|
||||
const deliverReply = vi.fn(async () => undefined);
|
||||
const rememberSentText = vi.fn();
|
||||
|
||||
await dispatchBufferedReply({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accounts: {
|
||||
work: { exposeErrorText: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
deliverReply,
|
||||
rememberSentText,
|
||||
route: makeRoute({ accountId: "work" }),
|
||||
});
|
||||
|
||||
const deliver = getCapturedDeliver();
|
||||
expect(deliver).toBeTypeOf("function");
|
||||
|
||||
await deliver?.({ text: "provider exploded", isError: true }, { kind: "final" });
|
||||
|
||||
expect(deliverReply).not.toHaveBeenCalled();
|
||||
expect(rememberSentText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps WhatsApp blockStreaming=true to disableBlockStreaming=false", async () => {
|
||||
await dispatchBufferedReply();
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { resolveMergedWhatsAppAccountConfig } from "../../account-config.js";
|
||||
import {
|
||||
type DeliverableWhatsAppOutboundPayload,
|
||||
normalizeWhatsAppOutboundPayload,
|
||||
normalizeWhatsAppPayloadTextPreservingIndentation,
|
||||
} from "../../outbound-media-contract.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
import { formatGroupMembers } from "./group-members.js";
|
||||
import type { GroupHistoryEntry } from "./inbound-context.js";
|
||||
@@ -60,10 +66,14 @@ function resolveWhatsAppDisableBlockStreaming(cfg: ReturnType<LoadConfigFn>): bo
|
||||
function resolveWhatsAppDeliverablePayload(
|
||||
payload: ReplyPayload,
|
||||
info: { kind: ReplyLifecycleKind },
|
||||
options?: { exposeErrorText?: boolean },
|
||||
): ReplyPayload | null {
|
||||
if (payload.isReasoning === true || payload.isCompactionNotice === true) {
|
||||
return null;
|
||||
}
|
||||
if (payload.isError === true && options?.exposeErrorText === false) {
|
||||
return null;
|
||||
}
|
||||
if (info.kind === "tool") {
|
||||
if (!resolveSendableOutboundReplyParts(payload).hasMedia) {
|
||||
return null;
|
||||
@@ -241,6 +251,7 @@ export async function dispatchWhatsAppBufferedReply(params: {
|
||||
conversationId: string;
|
||||
deliverReply: (params: {
|
||||
replyResult: ReplyPayload;
|
||||
normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
|
||||
msg: WebInboundMsg;
|
||||
mediaLocalRoots: readonly string[];
|
||||
maxMediaBytes: number;
|
||||
@@ -280,6 +291,9 @@ export async function dispatchWhatsAppBufferedReply(params: {
|
||||
});
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.route.agentId);
|
||||
const disableBlockStreaming = resolveWhatsAppDisableBlockStreaming(params.cfg);
|
||||
const exposeErrorText =
|
||||
resolveMergedWhatsAppAccountConfig({ cfg: params.cfg, accountId: params.route.accountId })
|
||||
.exposeErrorText !== false;
|
||||
let didSendReply = false;
|
||||
let didLogHeartbeatStrip = false;
|
||||
|
||||
@@ -296,12 +310,26 @@ export async function dispatchWhatsAppBufferedReply(params: {
|
||||
}
|
||||
},
|
||||
deliver: async (payload: ReplyPayload, info: { kind: ReplyLifecycleKind }) => {
|
||||
const deliveryPayload = resolveWhatsAppDeliverablePayload(payload, info);
|
||||
const deliveryPayload = resolveWhatsAppDeliverablePayload(payload, info, {
|
||||
exposeErrorText,
|
||||
});
|
||||
if (!deliveryPayload) {
|
||||
return;
|
||||
}
|
||||
const normalizedOutboundPayload = normalizeWhatsAppOutboundPayload(deliveryPayload, {
|
||||
normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation,
|
||||
});
|
||||
const normalizedDeliveryPayload =
|
||||
deliveryPayload.text === undefined
|
||||
? { ...normalizedOutboundPayload, text: undefined }
|
||||
: normalizedOutboundPayload;
|
||||
const reply = resolveSendableOutboundReplyParts(normalizedDeliveryPayload);
|
||||
if (!reply.hasMedia && !reply.text.trim()) {
|
||||
return;
|
||||
}
|
||||
await params.deliverReply({
|
||||
replyResult: deliveryPayload,
|
||||
replyResult: normalizedDeliveryPayload,
|
||||
normalizedReplyResult: normalizedDeliveryPayload,
|
||||
msg: params.msg,
|
||||
mediaLocalRoots,
|
||||
maxMediaBytes: params.maxMediaBytes,
|
||||
@@ -313,17 +341,16 @@ export async function dispatchWhatsAppBufferedReply(params: {
|
||||
tableMode,
|
||||
});
|
||||
didSendReply = true;
|
||||
const shouldLog = deliveryPayload.text ? true : undefined;
|
||||
params.rememberSentText(deliveryPayload.text, {
|
||||
const shouldLog = normalizedDeliveryPayload.text ? true : undefined;
|
||||
params.rememberSentText(normalizedDeliveryPayload.text, {
|
||||
combinedBody: params.context.Body as string | undefined,
|
||||
combinedBodySessionKey: params.route.sessionKey,
|
||||
logVerboseMessage: shouldLog,
|
||||
});
|
||||
const fromDisplay =
|
||||
params.msg.chatType === "group" ? params.conversationId : (params.msg.from ?? "unknown");
|
||||
const reply = resolveSendableOutboundReplyParts(deliveryPayload);
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = deliveryPayload.text != null ? reply.text : "<media>";
|
||||
const preview = normalizedDeliveryPayload.text != null ? reply.text : "<media>";
|
||||
logVerbose(`Reply body: ${preview}${reply.hasMedia ? " (media)" : ""} -> ${fromDisplay}`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -39,6 +39,52 @@ describe("whatsappChannelOutbound", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps XML sanitizer normalization idempotent", () => {
|
||||
const raw = [
|
||||
"<function_calls>",
|
||||
' <invoke name="send_message">',
|
||||
' <parameter name="text">hidden</parameter>',
|
||||
" </invoke>",
|
||||
"</function_calls>",
|
||||
"After",
|
||||
].join("\n");
|
||||
const once = whatsappChannelOutbound.normalizePayload?.({ payload: { text: raw } });
|
||||
const twice = whatsappChannelOutbound.normalizePayload?.({ payload: { text: once?.text } });
|
||||
|
||||
expect(once?.text).toBe("After");
|
||||
expect(twice?.text).toBe("After");
|
||||
});
|
||||
|
||||
it("drops whitespace-only text after XML sanitizer removal", () => {
|
||||
const raw = [
|
||||
" <function_calls>",
|
||||
' <invoke name="send_message">',
|
||||
' <parameter name="text">hidden</parameter>',
|
||||
" </invoke>",
|
||||
" </function_calls>",
|
||||
].join("\n");
|
||||
|
||||
expect(whatsappChannelOutbound.normalizePayload?.({ payload: { text: raw } })).toEqual({
|
||||
text: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes XML tool payloads before plain HTML stripping", () => {
|
||||
const raw = [
|
||||
"Before",
|
||||
"<function_calls>",
|
||||
' <invoke name="send_message">',
|
||||
' <parameter name="text">hidden</parameter>',
|
||||
" </invoke>",
|
||||
"</function_calls>",
|
||||
"After",
|
||||
].join("\n");
|
||||
|
||||
expect(whatsappChannelOutbound.sanitizeText?.({ text: raw, payload: { text: raw } })).toBe(
|
||||
"Before\n\nAfter",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves indentation for live text sends", async () => {
|
||||
await whatsappChannelOutbound.sendText!({
|
||||
cfg: {},
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { chunkText } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import { createWhatsAppOutboundBase } from "./outbound-base.js";
|
||||
import { normalizeWhatsAppPayloadTextPreservingIndentation } from "./outbound-media-contract.js";
|
||||
import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js";
|
||||
import { getWhatsAppRuntime } from "./runtime.js";
|
||||
import { sendMessageWhatsApp, sendPollWhatsApp } from "./send.js";
|
||||
|
||||
export function normalizeWhatsAppChannelPayloadText(text: string | undefined): string {
|
||||
return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, "");
|
||||
return normalizeWhatsAppPayloadTextPreservingIndentation(text);
|
||||
}
|
||||
|
||||
function normalizeWhatsAppChannelSendText(text: string | undefined): string {
|
||||
@@ -27,6 +28,7 @@ export const whatsappChannelOutbound = {
|
||||
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
|
||||
normalizeText: normalizeWhatsAppChannelSendText,
|
||||
}),
|
||||
sendTextOnlyErrorPayloads: true,
|
||||
normalizePayload: ({ payload }: { payload: { text?: string } }) => ({
|
||||
...payload,
|
||||
text: normalizeWhatsAppChannelPayloadText(payload.text),
|
||||
|
||||
@@ -63,6 +63,20 @@ describe("whatsapp config schema", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts exposeErrorText at channel and account scope", () => {
|
||||
const res = expectWhatsAppConfigValid({
|
||||
exposeErrorText: false,
|
||||
accounts: {
|
||||
work: { exposeErrorText: true },
|
||||
},
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
expect(res.data.exposeErrorText).toBe(false);
|
||||
expect(res.data.accounts?.work?.exposeErrorText).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts enabled", () => {
|
||||
expectWhatsAppConfigValid({
|
||||
enabled: true,
|
||||
|
||||
@@ -21,4 +21,8 @@ export const whatsAppChannelConfigUiHints = {
|
||||
label: "WhatsApp Config Writes",
|
||||
help: "Allow WhatsApp to write config in response to channel events/commands (default: true).",
|
||||
},
|
||||
exposeErrorText: {
|
||||
label: "WhatsApp Error Text",
|
||||
help: "Deliver user-visible agent/provider error text into WhatsApp (default: true). Disable to keep failures quiet on WhatsApp.",
|
||||
},
|
||||
} satisfies Record<string, ChannelConfigUiHint>;
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -137,6 +167,50 @@ describe("whatsappOutbound sendPayload", () => {
|
||||
expect(sendWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses routed error payloads when error text is hidden", async () => {
|
||||
const sendWhatsApp = vi.fn();
|
||||
|
||||
const result = await whatsappOutbound.sendPayload!({
|
||||
cfg: { channels: { whatsapp: { exposeErrorText: false } } },
|
||||
to: "5511999999999@c.us",
|
||||
text: "",
|
||||
payload: { text: "provider exploded", isError: true },
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ channel: "whatsapp", messageId: "" });
|
||||
expect(sendWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses account-level error text visibility for routed payloads", async () => {
|
||||
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
|
||||
|
||||
await whatsappOutbound.sendPayload!({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
exposeErrorText: false,
|
||||
accounts: {
|
||||
work: { exposeErrorText: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
to: "5511999999999@c.us",
|
||||
text: "",
|
||||
payload: { text: "provider exploded", isError: true },
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||
expect(sendWhatsApp).toHaveBeenCalledWith(
|
||||
"5511999999999@c.us",
|
||||
"provider exploded",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes HTML-only text to whitespace-only payload", () => {
|
||||
expect(
|
||||
whatsappOutbound
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-re
|
||||
import { chunkText } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createWhatsAppOutboundBase } from "./outbound-base.js";
|
||||
import { normalizeWhatsAppPayloadText } from "./outbound-media-contract.js";
|
||||
import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js";
|
||||
|
||||
type WhatsAppSendModule = typeof import("./send.js");
|
||||
@@ -13,8 +14,8 @@ function loadWhatsAppSendModule(): Promise<WhatsAppSendModule> {
|
||||
return whatsAppSendModulePromise;
|
||||
}
|
||||
|
||||
function trimLeadingWhitespace(text: string | undefined): string {
|
||||
return text?.trimStart() ?? "";
|
||||
function normalizeOutboundText(text: string | undefined): string {
|
||||
return normalizeWhatsAppPayloadText(text);
|
||||
}
|
||||
|
||||
export const whatsappOutbound: ChannelOutboundAdapter = createWhatsAppOutboundBase({
|
||||
@@ -22,7 +23,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = createWhatsAppOutboundBa
|
||||
sendMessageWhatsApp: async (to, text, options) =>
|
||||
await (
|
||||
await loadWhatsAppSendModule()
|
||||
).sendMessageWhatsApp(to, trimLeadingWhitespace(text), {
|
||||
).sendMessageWhatsApp(to, normalizeOutboundText(text), {
|
||||
...options,
|
||||
}),
|
||||
sendPollWhatsApp: async (to, poll, options) =>
|
||||
@@ -30,6 +31,6 @@ export const whatsappOutbound: ChannelOutboundAdapter = createWhatsAppOutboundBa
|
||||
shouldLogVerbose: () => shouldLogVerbose(),
|
||||
resolveTarget: ({ to, allowFrom, mode }) =>
|
||||
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
|
||||
normalizeText: trimLeadingWhitespace,
|
||||
normalizeText: normalizeOutboundText,
|
||||
skipEmptyText: true,
|
||||
});
|
||||
|
||||
@@ -9,9 +9,9 @@ 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";
|
||||
import {
|
||||
normalizeWhatsAppOutboundPayload,
|
||||
normalizeWhatsAppPayloadText,
|
||||
@@ -144,7 +144,7 @@ export function createWhatsAppOutboundBase({
|
||||
chunker,
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
sanitizeText: ({ text }) => sanitizeForPlainText(text),
|
||||
sanitizeText: ({ text }) => normalizeText(text),
|
||||
pollMaxOptions: 12,
|
||||
resolveTarget,
|
||||
...createAttachedChannelResultAdapter({
|
||||
@@ -220,6 +220,13 @@ export function createWhatsAppOutboundBase({
|
||||
return {
|
||||
...outbound,
|
||||
sendPayload: async (ctx) => {
|
||||
if (
|
||||
ctx.payload.isError === true &&
|
||||
resolveMergedWhatsAppAccountConfig({ cfg: ctx.cfg, accountId: ctx.accountId })
|
||||
.exposeErrorText === false
|
||||
) {
|
||||
return { channel: "whatsapp", messageId: "" };
|
||||
}
|
||||
const payload = normalizeWhatsAppOutboundPayload(ctx.payload, { normalizeText });
|
||||
if (!payload.text && !(payload.mediaUrl || payload.mediaUrls?.length)) {
|
||||
if (ctx.payload.interactive || ctx.payload.presentation || ctx.payload.channelData) {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
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 { sleep } from "./text-runtime.js";
|
||||
import {
|
||||
sanitizeAssistantVisibleText,
|
||||
sanitizeAssistantVisibleTextWithProfile,
|
||||
stripToolCallXmlTags,
|
||||
sleep,
|
||||
} from "./text-runtime.js";
|
||||
|
||||
type WhatsAppOutboundPayloadLike = {
|
||||
text?: string;
|
||||
@@ -18,6 +24,22 @@ type WhatsAppLoadedMediaLike = {
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
export type NormalizedWhatsAppOutboundPayload<T extends WhatsAppOutboundPayloadLike> = Omit<
|
||||
T,
|
||||
"text" | "mediaUrl" | "mediaUrls"
|
||||
> & {
|
||||
text: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
|
||||
export type DeliverableWhatsAppOutboundPayload<T extends WhatsAppOutboundPayloadLike> = Omit<
|
||||
NormalizedWhatsAppOutboundPayload<T>,
|
||||
"text"
|
||||
> & {
|
||||
text?: string;
|
||||
};
|
||||
|
||||
export type CanonicalWhatsAppLoadedMedia = {
|
||||
buffer: Buffer;
|
||||
kind: "image" | "audio" | "video" | "document";
|
||||
@@ -30,14 +52,31 @@ 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 text?.trimStart() ?? "";
|
||||
return finalizeWhatsAppVisibleText(sanitizeAssistantVisibleText(text ?? "")).trimStart();
|
||||
}
|
||||
|
||||
function stripLeadingBlankLines(text: string): string {
|
||||
return text.replace(/^(?:[ \t]*\r?\n)+/, "");
|
||||
}
|
||||
|
||||
export function normalizeWhatsAppPayloadTextPreservingIndentation(
|
||||
text: string | undefined,
|
||||
): string {
|
||||
return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, "");
|
||||
const sanitized = sanitizeAssistantVisibleTextWithProfile(
|
||||
stripLeadingBlankLines(text ?? ""),
|
||||
"history",
|
||||
);
|
||||
const normalized = stripLeadingBlankLines(finalizeWhatsAppVisibleText(sanitized));
|
||||
return normalized.trim() ? normalized : "";
|
||||
}
|
||||
|
||||
export function resolveWhatsAppOutboundMediaUrls(
|
||||
@@ -59,11 +98,7 @@ export function normalizeWhatsAppOutboundPayload<T extends WhatsAppOutboundPaylo
|
||||
options?: {
|
||||
normalizeText?: (text: string | undefined) => string;
|
||||
},
|
||||
): Omit<T, "text" | "mediaUrl" | "mediaUrls"> & {
|
||||
text: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
} {
|
||||
): NormalizedWhatsAppOutboundPayload<T> {
|
||||
const mediaUrls = resolveWhatsAppOutboundMediaUrls(payload);
|
||||
const normalizeText = options?.normalizeText ?? normalizeWhatsAppPayloadText;
|
||||
return {
|
||||
|
||||
@@ -84,6 +84,7 @@ export type ChannelOutboundAdapter = {
|
||||
supportsPollDurationSeconds?: boolean;
|
||||
supportsAnonymousPolls?: boolean;
|
||||
normalizePayload?: (params: { payload: ReplyPayload }) => ReplyPayload | null;
|
||||
sendTextOnlyErrorPayloads?: boolean;
|
||||
shouldSkipPlainTextSanitization?: (params: { payload: ReplyPayload }) => boolean;
|
||||
resolveEffectiveTextChunkLimit?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
|
||||
@@ -15966,6 +15966,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
},
|
||||
],
|
||||
},
|
||||
exposeErrorText: {
|
||||
type: "boolean",
|
||||
},
|
||||
heartbeat: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -16254,6 +16257,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
},
|
||||
],
|
||||
},
|
||||
exposeErrorText: {
|
||||
type: "boolean",
|
||||
},
|
||||
heartbeat: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -16342,6 +16348,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
label: "WhatsApp Config Writes",
|
||||
help: "Allow WhatsApp to write config in response to channel events/commands (default: true).",
|
||||
},
|
||||
exposeErrorText: {
|
||||
label: "WhatsApp Error Text",
|
||||
help: "Deliver user-visible agent/provider error text into WhatsApp (default: true). Disable to keep failures quiet on WhatsApp.",
|
||||
},
|
||||
},
|
||||
unsupportedSecretRefSurfacePatterns: [
|
||||
"channels.whatsapp.accounts.*.creds.json",
|
||||
|
||||
@@ -105,6 +105,8 @@ type WhatsAppSharedConfig = {
|
||||
debounceMs?: number;
|
||||
/** Reply threading mode for auto-replies (off|first|all|batched). */
|
||||
replyToMode?: ReplyToMode;
|
||||
/** Whether WhatsApp should deliver user-visible error text. Default: true. */
|
||||
exposeErrorText?: boolean;
|
||||
/** Heartbeat visibility settings. */
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
/** Channel health monitor overrides for this channel/account. */
|
||||
|
||||
@@ -72,6 +72,21 @@ describe("WhatsApp prompt config Zod validation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("validates exposeErrorText at root and account scope", () => {
|
||||
const result = WhatsAppConfigSchema.safeParse({
|
||||
exposeErrorText: false,
|
||||
accounts: {
|
||||
work: { exposeErrorText: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.exposeErrorText).toBe(false);
|
||||
expect(result.data.accounts?.work?.exposeErrorText).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("validates WhatsAppAccountSchema directly", () => {
|
||||
const accountConfig = {
|
||||
name: "Personal Account",
|
||||
|
||||
@@ -83,6 +83,7 @@ function buildWhatsAppCommonShape(params: { useDefaults: boolean }) {
|
||||
? z.number().int().nonnegative().optional().default(0)
|
||||
: z.number().int().nonnegative().optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
exposeErrorText: z.boolean().optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
healthMonitor: ChannelHealthMonitorSchema,
|
||||
};
|
||||
|
||||
@@ -1797,6 +1797,113 @@ describe("deliverOutboundPayloads", () => {
|
||||
expect(hookMocks.runner.runMessageSent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps text-only error payloads on the normal text path by default", async () => {
|
||||
const sendPayload = vi.fn();
|
||||
const sendText = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" });
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "matrix",
|
||||
outbound: { deliveryMode: "direct", sendPayload, sendText },
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const results = await deliverOutboundPayloads({
|
||||
cfg: {},
|
||||
channel: "matrix",
|
||||
to: "!room:1",
|
||||
payloads: [{ text: "provider exploded", isError: true }],
|
||||
});
|
||||
|
||||
expect(results).toEqual([{ channel: "matrix", messageId: "mx-1" }]);
|
||||
expect(sendText).toHaveBeenCalledWith(expect.objectContaining({ text: "provider exploded" }));
|
||||
expect(sendPayload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes text-only error payloads through sendPayload when the adapter opts in", async () => {
|
||||
const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" });
|
||||
const sendText = vi.fn();
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "matrix",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendPayload,
|
||||
sendText,
|
||||
sendTextOnlyErrorPayloads: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const results = await deliverOutboundPayloads({
|
||||
cfg: {},
|
||||
channel: "matrix",
|
||||
to: "!room:1",
|
||||
payloads: [{ text: "provider exploded", isError: true }],
|
||||
});
|
||||
|
||||
expect(results).toEqual([{ channel: "matrix", messageId: "mx-1" }]);
|
||||
expect(sendPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "provider exploded",
|
||||
payload: expect.objectContaining({ text: "provider exploded", isError: true }),
|
||||
}),
|
||||
);
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not count no-op sendPayload results as delivered", async () => {
|
||||
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||
const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "" });
|
||||
const sendText = vi.fn();
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "matrix",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendPayload,
|
||||
sendText,
|
||||
sendTextOnlyErrorPayloads: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const results = await deliverOutboundPayloads({
|
||||
cfg: {},
|
||||
channel: "matrix",
|
||||
to: "!room:1",
|
||||
payloads: [{ text: "provider exploded", isError: true }],
|
||||
mirror: {
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
text: "provider exploded",
|
||||
},
|
||||
});
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(sendPayload).toHaveBeenCalledTimes(1);
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(hookMocks.runner.runMessageSent).not.toHaveBeenCalled();
|
||||
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits message_sent success for sendPayload deliveries", async () => {
|
||||
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||
const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" });
|
||||
|
||||
@@ -91,6 +91,7 @@ type ChannelHandler = {
|
||||
supportsMedia: boolean;
|
||||
sanitizeText?: (payload: ReplyPayload) => string;
|
||||
normalizePayload?: (payload: ReplyPayload) => ReplyPayload | null;
|
||||
sendTextOnlyErrorPayloads?: boolean;
|
||||
renderPresentation?: (payload: ReplyPayload) => Promise<ReplyPayload | null>;
|
||||
pinDeliveredMessage?: (params: {
|
||||
target: ChannelOutboundTargetRef;
|
||||
@@ -229,6 +230,7 @@ function createPluginHandler(
|
||||
normalizePayload: outbound.normalizePayload
|
||||
? (payload) => outbound.normalizePayload!({ payload })
|
||||
: undefined,
|
||||
sendTextOnlyErrorPayloads: outbound.sendTextOnlyErrorPayloads === true,
|
||||
renderPresentation: outbound.renderPresentation
|
||||
? async (payload) => {
|
||||
const presentation = normalizeMessagePresentation(payload.presentation);
|
||||
@@ -544,6 +546,18 @@ function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload {
|
||||
return summarizeOutboundPayloadForTransport(payload);
|
||||
}
|
||||
|
||||
function hasDeliveryResultIdentity(result: OutboundDeliveryResult): boolean {
|
||||
return Boolean(
|
||||
result.messageId ||
|
||||
result.chatId ||
|
||||
result.channelId ||
|
||||
result.roomId ||
|
||||
result.conversationId ||
|
||||
result.toJid ||
|
||||
result.pollId,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDeliveryPin(payload: ReplyPayload): ReplyPayloadDeliveryPin | undefined {
|
||||
const pin = payload.delivery?.pin;
|
||||
if (pin === true) {
|
||||
@@ -1107,17 +1121,22 @@ async function deliverOutboundPayloadsCore(
|
||||
const deliveryTarget = handler.buildTargetRef({ threadId: sendOverrides.threadId });
|
||||
if (
|
||||
handler.sendPayload &&
|
||||
(hasReplyPayloadContent({
|
||||
presentation: effectivePayload.presentation,
|
||||
interactive: effectivePayload.interactive,
|
||||
channelData: effectivePayload.channelData,
|
||||
}) ||
|
||||
((effectivePayload.isError === true && handler.sendTextOnlyErrorPayloads === true) ||
|
||||
hasReplyPayloadContent({
|
||||
presentation: effectivePayload.presentation,
|
||||
interactive: effectivePayload.interactive,
|
||||
channelData: effectivePayload.channelData,
|
||||
}) ||
|
||||
effectivePayload.audioAsVoice === true)
|
||||
) {
|
||||
const delivery = await handler.sendPayload(
|
||||
effectivePayload,
|
||||
applySendReplyToConsumption(sendOverrides),
|
||||
);
|
||||
if (!hasDeliveryResultIdentity(delivery)) {
|
||||
completeDeliveryDiagnostics(0);
|
||||
continue;
|
||||
}
|
||||
results.push(delivery);
|
||||
await maybePinDeliveredMessage({
|
||||
handler,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
sanitizeAssistantVisibleText,
|
||||
sanitizeAssistantVisibleTextWithProfile,
|
||||
stripToolCallXmlTags,
|
||||
stripAssistantInternalScaffolding,
|
||||
} from "./assistant-visible-text.js";
|
||||
import { stripModelSpecialTokens } from "./model-special-tokens.js";
|
||||
@@ -504,6 +505,17 @@ describe("stripAssistantInternalScaffolding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripToolCallXmlTags", () => {
|
||||
it("strips plural function/tool wrapper XML only when the opt-in flag is enabled", () => {
|
||||
const input =
|
||||
'prefix <function_calls><invoke name="find">secret</invoke></function_calls> suffix';
|
||||
expect(stripToolCallXmlTags(input)).toBe(input);
|
||||
expect(stripToolCallXmlTags(input, { stripFunctionCallsXmlPayloads: true })).toBe(
|
||||
"prefix suffix",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeAssistantVisibleText", () => {
|
||||
it("strips minimax, tool XML, downgraded tool markers, and think tags in one pass", () => {
|
||||
const input = [
|
||||
|
||||
@@ -29,7 +29,8 @@ const TOOL_CALL_TAG_NAMES = new Set([
|
||||
const TOOL_CALL_JSON_PAYLOAD_START_RE =
|
||||
/^(?:\s+[A-Za-z_:][-A-Za-z0-9_:.]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))*\s*(?:\r?\n\s*)?[[{]/;
|
||||
const TOOL_CALL_XML_PAYLOAD_START_RE =
|
||||
/^\s*(?:\r?\n\s*)?<(?:function|invoke|parameters?|arguments?)\b/i;
|
||||
/^\s*(?:\r?\n\s*)?<(?:function_call|tool_call|function|invoke|parameters?|arguments?)\b/i;
|
||||
const NESTED_JSON_TOOL_CALL_PAYLOAD_START_RE = /^\s*(?:\r?\n\s*)?<(?:function_call|tool_call)\b/i;
|
||||
|
||||
type ToolCallPayloadKind = "json" | "xml" | null;
|
||||
|
||||
@@ -124,6 +125,27 @@ function detectToolCallPayloadKind(text: string, start: number): ToolCallPayload
|
||||
return null;
|
||||
}
|
||||
|
||||
function startsWithNestedJsonToolCallPayload(text: string, start: number): boolean {
|
||||
if (!NESTED_JSON_TOOL_CALL_PAYLOAD_START_RE.test(text.slice(start))) {
|
||||
return false;
|
||||
}
|
||||
let cursor = start;
|
||||
while (cursor < text.length && /\s/.test(text[cursor])) {
|
||||
cursor += 1;
|
||||
}
|
||||
const nestedTag = parseToolCallTagAt(text, cursor);
|
||||
if (
|
||||
!nestedTag ||
|
||||
nestedTag.isClose ||
|
||||
nestedTag.isSelfClosing ||
|
||||
nestedTag.isTruncated ||
|
||||
(nestedTag.tagName !== "function_call" && nestedTag.tagName !== "tool_call")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return TOOL_CALL_JSON_PAYLOAD_START_RE.test(text.slice(nestedTag.end));
|
||||
}
|
||||
|
||||
function isLikelyStandaloneFunctionToolCall(
|
||||
text: string,
|
||||
tagStart: number,
|
||||
@@ -197,7 +219,10 @@ function parseToolCallTagAt(text: string, start: number): ParsedToolCallTag | nu
|
||||
};
|
||||
}
|
||||
|
||||
export function stripToolCallXmlTags(text: string): string {
|
||||
export function stripToolCallXmlTags(
|
||||
text: string,
|
||||
options: { stripFunctionCallsXmlPayloads?: boolean } = {},
|
||||
): string {
|
||||
if (!text || !TOOL_CALL_QUICK_RE.test(text)) {
|
||||
return text;
|
||||
}
|
||||
@@ -250,18 +275,24 @@ export function stripToolCallXmlTags(text: string): string {
|
||||
continue;
|
||||
}
|
||||
const payloadStart = tag.isTruncated ? tag.contentStart : tag.end;
|
||||
const payloadKind =
|
||||
tag.tagName === "tool_call" || tag.tagName === "function"
|
||||
? detectToolCallPayloadKind(text, payloadStart)
|
||||
: TOOL_CALL_JSON_PAYLOAD_START_RE.test(text.slice(payloadStart))
|
||||
? "json"
|
||||
: null;
|
||||
const shouldDetectXmlPayload =
|
||||
tag.tagName === "tool_call" ||
|
||||
tag.tagName === "function" ||
|
||||
(options.stripFunctionCallsXmlPayloads === true &&
|
||||
(tag.tagName === "function_calls" || tag.tagName === "tool_calls"));
|
||||
const payloadKind = shouldDetectXmlPayload
|
||||
? detectToolCallPayloadKind(text, payloadStart)
|
||||
: TOOL_CALL_JSON_PAYLOAD_START_RE.test(text.slice(payloadStart))
|
||||
? "json"
|
||||
: null;
|
||||
const shouldStripStandaloneFunction =
|
||||
tag.tagName !== "function" || isLikelyStandaloneFunctionToolCall(text, idx, tag);
|
||||
if (!tag.isClose && payloadKind && shouldStripStandaloneFunction) {
|
||||
inToolCallBlock = true;
|
||||
toolCallBlockContentStart = tag.end;
|
||||
toolCallBlockNeedsQuoteBalance = payloadKind === "json";
|
||||
toolCallBlockNeedsQuoteBalance =
|
||||
payloadKind === "json" ||
|
||||
(payloadKind === "xml" && startsWithNestedJsonToolCallPayload(text, payloadStart));
|
||||
toolCallBlockStart = idx;
|
||||
toolCallBlockTagName = tag.tagName;
|
||||
if (tag.isTruncated) {
|
||||
@@ -526,6 +557,7 @@ type AssistantVisibleTextPipelineOptions = {
|
||||
finalTrim: ReasoningTagTrim;
|
||||
preserveDowngradedToolText?: boolean;
|
||||
preserveMinimaxToolXml?: boolean;
|
||||
stripFunctionCallsXmlPayloads?: boolean;
|
||||
reasoningMode: ReasoningTagMode;
|
||||
reasoningTrim: ReasoningTagTrim;
|
||||
stageOrder: "reasoning-first" | "reasoning-last";
|
||||
@@ -586,7 +618,9 @@ function applyAssistantVisibleTextStagePipeline(
|
||||
}
|
||||
cleaned = stripModelSpecialTokens(cleaned);
|
||||
cleaned = stripRelevantMemoriesTags(cleaned);
|
||||
cleaned = stripToolCallXmlTags(cleaned);
|
||||
cleaned = stripToolCallXmlTags(cleaned, {
|
||||
stripFunctionCallsXmlPayloads: options.stripFunctionCallsXmlPayloads,
|
||||
});
|
||||
cleaned = stripPlainTextToolCallBlocks(cleaned);
|
||||
if (!options.preserveDowngradedToolText) {
|
||||
cleaned = stripDowngradedToolCallText(cleaned);
|
||||
|
||||
Reference in New Issue
Block a user