fix(whatsapp): preserve outbound document filenames (#15594)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 8e0d765d1d
Co-authored-by: TsekaLuk <79151285+TsekaLuk@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
This commit is contained in:
Tseka Luk
2026-02-14 01:54:10 +08:00
committed by GitHub
parent f59df95896
commit c544811559
7 changed files with 82 additions and 6 deletions

View File

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

View File

@@ -19,15 +19,29 @@ import { attachGatewayWsMessageHandler } from "./ws-connection/message-handler.j
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
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();

View File

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

View File

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

View File

@@ -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,
};

View File

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

View File

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