From c5448115593882f3039adc5dc4276972f192abcf Mon Sep 17 00:00:00 2001 From: Tseka Luk <79151285+TsekaLuk@users.noreply.github.com> Date: Sat, 14 Feb 2026 01:54:10 +0800 Subject: [PATCH] fix(whatsapp): preserve outbound document filenames (#15594) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 8e0d765d1d7ebf3375e9d82b27ffeb486c5be930 Co-authored-by: TsekaLuk <79151285+TsekaLuk@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Reviewed-by: @steipete --- CHANGELOG.md | 1 + src/gateway/server/ws-connection.ts | 20 +++++++++-- src/web/active-listener.ts | 1 + src/web/inbound/send-api.test.ts | 54 +++++++++++++++++++++++++++++ src/web/inbound/send-api.ts | 3 +- src/web/outbound.test.ts | 4 ++- src/web/outbound.ts | 5 ++- 7 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 src/web/inbound/send-api.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5809d0de463..a24d71f82e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo. - Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow. - Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane. +- WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending `"file"`. (#15594) Thanks @TsekaLuk. - Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent. - Gateway/Tools Invoke: sanitize `/tools/invoke` execution failures while preserving `400` for tool input errors and returning `500` for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck. - MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin. diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index 43bda018023..7ecbefda4ee 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -19,15 +19,29 @@ import { attachGatewayWsMessageHandler } from "./ws-connection/message-handler.j type SubsystemLogger = ReturnType; const LOG_HEADER_MAX_LEN = 300; -const LOG_HEADER_CONTROL_REGEX = /[\u0000-\u001f\u007f-\u009f]/g; const LOG_HEADER_FORMAT_REGEX = /\p{Cf}/gu; +function replaceControlChars(value: string): string { + let cleaned = ""; + for (const char of value) { + const codePoint = char.codePointAt(0); + if ( + codePoint !== undefined && + (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) + ) { + cleaned += " "; + continue; + } + cleaned += char; + } + return cleaned; +} + const sanitizeLogValue = (value: string | undefined): string | undefined => { if (!value) { return undefined; } - const cleaned = value - .replace(LOG_HEADER_CONTROL_REGEX, " ") + const cleaned = replaceControlChars(value) .replace(LOG_HEADER_FORMAT_REGEX, " ") .replace(/\s+/g, " ") .trim(); diff --git a/src/web/active-listener.ts b/src/web/active-listener.ts index 81170d3084f..0cb48ab405e 100644 --- a/src/web/active-listener.ts +++ b/src/web/active-listener.ts @@ -5,6 +5,7 @@ import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export type ActiveWebSendOptions = { gifPlayback?: boolean; accountId?: string; + fileName?: string; }; export type ActiveWebListener = { diff --git a/src/web/inbound/send-api.test.ts b/src/web/inbound/send-api.test.ts new file mode 100644 index 00000000000..9ef2486e041 --- /dev/null +++ b/src/web/inbound/send-api.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const recordChannelActivity = vi.fn(); +vi.mock("../../infra/channel-activity.js", () => ({ + recordChannelActivity: (...args: unknown[]) => recordChannelActivity(...args), +})); + +import { createWebSendApi } from "./send-api.js"; + +describe("createWebSendApi", () => { + const sendMessage = vi.fn(async () => ({ key: { id: "msg-1" } })); + const sendPresenceUpdate = vi.fn(async () => {}); + const api = createWebSendApi({ + sock: { sendMessage, sendPresenceUpdate }, + defaultAccountId: "main", + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses sendOptions fileName for outbound documents", async () => { + const payload = Buffer.from("pdf"); + await api.sendMessage("+1555", "doc", payload, "application/pdf", { fileName: "invoice.pdf" }); + expect(sendMessage).toHaveBeenCalledWith( + "1555@s.whatsapp.net", + expect.objectContaining({ + document: payload, + fileName: "invoice.pdf", + caption: "doc", + mimetype: "application/pdf", + }), + ); + expect(recordChannelActivity).toHaveBeenCalledWith({ + channel: "whatsapp", + accountId: "main", + direction: "outbound", + }); + }); + + it("falls back to default document filename when fileName is absent", async () => { + const payload = Buffer.from("pdf"); + await api.sendMessage("+1555", "doc", payload, "application/pdf"); + expect(sendMessage).toHaveBeenCalledWith( + "1555@s.whatsapp.net", + expect.objectContaining({ + document: payload, + fileName: "file", + caption: "doc", + mimetype: "application/pdf", + }), + ); + }); +}); diff --git a/src/web/inbound/send-api.ts b/src/web/inbound/send-api.ts index 7deb9540dbd..0517dc226ae 100644 --- a/src/web/inbound/send-api.ts +++ b/src/web/inbound/send-api.ts @@ -38,9 +38,10 @@ export function createWebSendApi(params: { ...(gifPlayback ? { gifPlayback: true } : {}), }; } else { + const fileName = sendOptions?.fileName?.trim() || "file"; payload = { document: mediaBuffer, - fileName: "file", + fileName, caption: text || undefined, mimetype: mediaType, }; diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts index 1d9fef7d0ab..9f6fdd901b8 100644 --- a/src/web/outbound.test.ts +++ b/src/web/outbound.test.ts @@ -130,7 +130,9 @@ describe("web outbound", () => { verbose: false, mediaUrl: "/tmp/file.pdf", }); - expect(sendMessage).toHaveBeenLastCalledWith("+1555", "doc", buf, "application/pdf"); + expect(sendMessage).toHaveBeenLastCalledWith("+1555", "doc", buf, "application/pdf", { + fileName: "file.pdf", + }); }); it("sends polls via active listener", async () => { diff --git a/src/web/outbound.ts b/src/web/outbound.ts index 08a0e363419..e09981541d1 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -45,6 +45,7 @@ export async function sendMessageWhatsApp( const jid = toWhatsappJid(to); let mediaBuffer: Buffer | undefined; let mediaType: string | undefined; + let documentFileName: string | undefined; if (options.mediaUrl) { const media = await loadWebMedia(options.mediaUrl); const caption = text || undefined; @@ -62,6 +63,7 @@ export async function sendMessageWhatsApp( text = caption ?? ""; } else { text = caption ?? ""; + documentFileName = media.fileName; } } outboundLog.info(`Sending message -> ${jid}${options.mediaUrl ? " (media)" : ""}`); @@ -70,9 +72,10 @@ export async function sendMessageWhatsApp( const hasExplicitAccountId = Boolean(options.accountId?.trim()); const accountId = hasExplicitAccountId ? resolvedAccountId : undefined; const sendOptions: ActiveWebSendOptions | undefined = - options.gifPlayback || accountId + options.gifPlayback || accountId || documentFileName ? { ...(options.gifPlayback ? { gifPlayback: true } : {}), + ...(documentFileName ? { fileName: documentFileName } : {}), accountId, } : undefined;