From 93c36f517e179004e47da92e278735b38d247ea9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 23 Aug 2026 04:48:15 -0700 Subject: [PATCH] fix(whatsapp): preserve upload attachment metadata (#128186) --- .../src/channel-react-action.runtime.ts | 2 +- .../whatsapp/src/channel-react-action.test.ts | 141 ++++++++++++++ .../whatsapp/src/channel-react-action.ts | 27 +-- extensions/whatsapp/src/send.test.ts | 183 +++++++++++++++++- extensions/whatsapp/src/send.ts | 32 ++- 5 files changed, 361 insertions(+), 24 deletions(-) diff --git a/extensions/whatsapp/src/channel-react-action.runtime.ts b/extensions/whatsapp/src/channel-react-action.runtime.ts index 142696ef00ab..ac7fb4b15965 100644 --- a/extensions/whatsapp/src/channel-react-action.runtime.ts +++ b/extensions/whatsapp/src/channel-react-action.runtime.ts @@ -7,5 +7,5 @@ export { handleWhatsAppAction } from "./action-runtime.js"; export { resolveAuthorizedWhatsAppOutboundTarget } from "./action-runtime-target-auth.js"; export { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "./accounts.js"; export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js"; -export { sendMessageWhatsApp } from "./send.js"; +export { sendWhatsAppUploadFile as sendMessageWhatsApp } from "./send.js"; export { readStringOrNumberParam, readStringParam, type OpenClawConfig }; diff --git a/extensions/whatsapp/src/channel-react-action.test.ts b/extensions/whatsapp/src/channel-react-action.test.ts index ca6e48ae0b46..9fb5a4ca9302 100644 --- a/extensions/whatsapp/src/channel-react-action.test.ts +++ b/extensions/whatsapp/src/channel-react-action.test.ts @@ -150,6 +150,79 @@ describe("whatsapp react action messageId resolution", () => { }); }); + it.each([ + { + sourceKey: "filePath", + source: "/tmp/generated-attachment.bin", + filenameKey: "filename", + filename: "Quarterly Report.pdf", + }, + { + sourceKey: "mediaUrl", + source: "https://example.com/download?id=42", + filenameKey: "fileName", + filename: "Invoice.pdf", + }, + ])( + "preserves the requested $filenameKey for an upload-file $sourceKey", + async ({ sourceKey, source, filenameKey, filename }) => { + await handleWhatsAppMessageAction({ + action: "upload-file", + params: { + to: "+1555", + [sourceKey]: source, + [filenameKey]: filename, + forceDocument: true, + }, + cfg: baseCfg, + accountId: "default", + }); + + expect(hoisted.sendMessageWhatsApp).toHaveBeenCalledWith( + "+1555", + "", + expect.objectContaining({ mediaUrl: source, fileName: filename }), + ); + }, + ); + + it.each([ + { + name: "a local path's contentType", + source: { filePath: "/tmp/generated-attachment.bin" }, + metadata: { contentType: "image/png" }, + expectedContentType: "image/png", + }, + { + name: "a URL's mimeType alias", + source: { mediaUrl: "https://example.com/video" }, + metadata: { mimeType: "video/mp4" }, + expectedContentType: "video/mp4", + }, + { + name: "contentType before a URL's mimeType alias", + source: { mediaUrl: "https://example.com/document" }, + metadata: { contentType: "application/pdf", mimeType: "image/png" }, + expectedContentType: "application/pdf", + }, + ])("preserves $name for upload-file", async ({ source, metadata, expectedContentType }) => { + await handleWhatsAppMessageAction({ + action: "upload-file", + params: { to: "+1555", ...source, ...metadata }, + cfg: baseCfg, + accountId: "default", + }); + + expect(hoisted.sendMessageWhatsApp).toHaveBeenCalledWith( + "+1555", + "", + expect.objectContaining({ + mediaUrl: source.filePath ?? source.mediaUrl, + contentType: expectedContentType, + }), + ); + }); + it("uses toolContext current chat for same-chat upload-file", async () => { const mediaReadFile = vi.fn(async () => Buffer.from("media")); @@ -240,6 +313,74 @@ describe("whatsapp react action messageId resolution", () => { }); }); + it.each([ + { + name: "the data URL", + metadata: {}, + expectedContentType: "image/png", + }, + { + name: "an explicit contentType", + metadata: { contentType: "application/pdf" }, + expectedContentType: "application/pdf", + }, + { + name: "an explicit mimeType alias", + metadata: { mimeType: "image/jpeg" }, + expectedContentType: "image/jpeg", + }, + { + name: "contentType before its mimeType alias", + metadata: { contentType: "application/pdf", mimeType: "image/jpeg" }, + expectedContentType: "application/pdf", + }, + ])( + "resolves upload-file buffer MIME metadata from $name", + async ({ metadata, expectedContentType }) => { + await handleWhatsAppMessageAction({ + action: "upload-file", + params: { + to: "+1555", + buffer: `data:image/png;base64,${Buffer.from("image").toString("base64")}`, + ...metadata, + }, + cfg: baseCfg, + accountId: "default", + }); + + expect(hoisted.sendMessageWhatsApp).toHaveBeenCalledWith( + "+1555", + "", + expect.objectContaining({ + mediaPayload: expect.objectContaining({ + buffer: Buffer.from("image"), + contentType: expectedContentType, + }), + }), + ); + }, + ); + + it("prefers the filename field over its fileName alias for URL uploads", async () => { + await handleWhatsAppMessageAction({ + action: "upload-file", + params: { + to: "+1555", + mediaUrl: "https://example.com/download", + filename: "preferred.pdf", + fileName: "ignored.pdf", + }, + cfg: baseCfg, + accountId: "default", + }); + + expect(hoisted.sendMessageWhatsApp).toHaveBeenCalledWith( + "+1555", + "", + expect.objectContaining({ fileName: "preferred.pdf" }), + ); + }); + it.each(["SGVsbG8=!", "data:text/plain,hello", "data:text/plain;base64"])( "rejects malformed upload-file buffer %s", async (buffer) => { diff --git a/extensions/whatsapp/src/channel-react-action.ts b/extensions/whatsapp/src/channel-react-action.ts index 9b255627865c..15f49a22a212 100644 --- a/extensions/whatsapp/src/channel-react-action.ts +++ b/extensions/whatsapp/src/channel-react-action.ts @@ -75,14 +75,10 @@ function readWhatsAppActionChatJid(params: WhatsAppMessageActionParams): string return normalizeWhatsAppTarget(params.toolContext.currentChannelId) ?? undefined; } -function extractBase64Payload(encoded: string): string { - const match = /^data:[^;]+;base64,(.*)$/is.exec(encoded.trim()); - return match?.[1] ?? encoded; -} - function decodeUploadFileMediaPayload(params: { - args: Record; encoded: string; + contentType?: string; + fileName?: string; maxBytes?: number; }): | { @@ -91,7 +87,8 @@ function decodeUploadFileMediaPayload(params: { fileName?: string; } | undefined { - const payload = extractBase64Payload(params.encoded); + const dataUrl = /^data:([^;]+);base64,(.*)$/is.exec(params.encoded.trim()); + const payload = dataUrl?.[2] ?? params.encoded; if (params.maxBytes !== undefined) { // Enforce the budget before canonicalization and decode so hostile input cannot force an // oversized Buffer allocation before rejection. @@ -102,10 +99,7 @@ function decodeUploadFileMediaPayload(params: { ); } } - const contentType = - readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType"); - const fileName = - readStringParam(params.args, "filename") ?? readStringParam(params.args, "fileName"); + const contentType = params.contentType ?? dataUrl?.[1]; const canonicalPayload = canonicalizeBase64(payload); if (!canonicalPayload) { throw new Error("WhatsApp upload-file buffer must be valid base64 or a base64 data URL."); @@ -119,13 +113,17 @@ function decodeUploadFileMediaPayload(params: { return { buffer, ...(contentType ? { contentType } : {}), - ...(fileName ? { fileName } : {}), + ...(params.fileName ? { fileName: params.fileName } : {}), }; } async function handleWhatsAppUploadFileAction(params: WhatsAppMessageActionParams) { const mediaUrl = readUploadFileMediaSource(params.params); const encodedPayload = readStringParam(params.params, "buffer", { trim: false }); + const contentType = + readStringParam(params.params, "contentType") ?? readStringParam(params.params, "mimeType"); + const fileName = + readStringParam(params.params, "filename") ?? readStringParam(params.params, "fileName"); if (!mediaUrl && !hasUploadFileBufferPayload(params.params)) { throw new Error( "WhatsApp upload-file requires media, mediaUrl, filePath, path, fileUrl, or buffer.", @@ -145,8 +143,9 @@ async function handleWhatsAppUploadFileAction(params: WhatsAppMessageActionParam }); const mediaPayload = encodedPayload ? decodeUploadFileMediaPayload({ - args: params.params, encoded: encodedPayload, + contentType, + fileName, maxBytes: resolveWhatsAppMediaMaxBytes(account), }) : undefined; @@ -155,6 +154,8 @@ async function handleWhatsAppUploadFileAction(params: WhatsAppMessageActionParam cfg: params.cfg, ...(mediaUrl && !mediaPayload ? { mediaUrl } : {}), ...(mediaPayload ? { mediaPayload } : {}), + ...(mediaUrl && !mediaPayload && fileName ? { fileName } : {}), + ...(mediaUrl && !mediaPayload && contentType ? { contentType } : {}), mediaAccess: params.mediaAccess, mediaLocalRoots: params.mediaLocalRoots, mediaReadFile: params.mediaReadFile, diff --git a/extensions/whatsapp/src/send.test.ts b/extensions/whatsapp/src/send.test.ts index 673493674274..b5d960b9570b 100644 --- a/extensions/whatsapp/src/send.test.ts +++ b/extensions/whatsapp/src/send.test.ts @@ -17,6 +17,7 @@ const hoisted = vi.hoisted(() => ({ })); const loadWebMediaMock = vi.fn(); let sendMessageWhatsApp: typeof import("./send.js").sendMessageWhatsApp; +let sendWhatsAppUploadFile: typeof import("./send.js").sendWhatsAppUploadFile; let sendPollWhatsApp: typeof import("./send.js").sendPollWhatsApp; let sendReactionWhatsApp: typeof import("./send.js").sendReactionWhatsApp; let sendTypingWhatsApp: typeof import("./send.js").sendTypingWhatsApp; @@ -81,8 +82,13 @@ describe("web outbound", () => { ); beforeAll(async () => { - ({ sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp, sendTypingWhatsApp } = - await import("./send.js")); + ({ + sendMessageWhatsApp, + sendWhatsAppUploadFile, + sendPollWhatsApp, + sendReactionWhatsApp, + sendTypingWhatsApp, + } = await import("./send.js")); const { resetLogger: loadedResetLogger, setLoggerOverride: loadedSetLoggerOverride } = await import("openclaw/plugin-sdk/runtime-env"); resetLogger = loadedResetLogger; @@ -594,6 +600,179 @@ describe("web outbound", () => { }); }); + it.each([ + { + name: "a loaded document", + source: "/tmp/generated-attachment.bin", + loadedMedia: { + contentType: "application/pdf", + kind: "document", + fileName: "generated-attachment.bin", + }, + requestedFileName: "Quarterly Report.pdf", + expectedFileName: "Quarterly Report.pdf", + expectedMimeType: "application/pdf", + forceDocument: false, + }, + { + name: "a forced image document", + source: "https://example.com/download?id=42", + loadedMedia: { + contentType: "image/png", + kind: "image", + fileName: "download", + }, + requestedFileName: "Photo.png", + expectedFileName: "Photo.png", + expectedMimeType: "image/png", + forceDocument: true, + }, + { + name: "an opaque image inferred from its requested filename", + source: "https://example.com/blob", + loadedMedia: { + contentType: "application/octet-stream", + kind: "document", + fileName: "blob", + }, + requestedFileName: "Receipt.png", + expectedFileName: "Receipt.png", + expectedMimeType: "image/png", + forceDocument: true, + }, + { + name: "a requested document filename with control characters", + source: "/tmp/generated-attachment.bin", + loadedMedia: { + contentType: "application/pdf", + kind: "document", + fileName: "generated-attachment.bin", + }, + requestedFileName: "Quarterly\r\nReport.pdf", + expectedFileName: "QuarterlyReport.pdf", + expectedMimeType: "application/pdf", + forceDocument: false, + }, + ])( + "preserves the requested upload filename for $name", + async ({ + source, + loadedMedia, + requestedFileName, + expectedFileName, + expectedMimeType, + forceDocument, + }) => { + const buffer = Buffer.from("attachment"); + const mediaReadFile = vi.fn(async () => buffer); + loadWebMediaMock.mockResolvedValueOnce({ buffer, ...loadedMedia }); + + await sendWhatsAppUploadFile("+1555", "attachment", { + verbose: false, + cfg: WHATSAPP_TEST_CFG, + mediaUrl: source, + fileName: requestedFileName, + forceDocument, + mediaLocalRoots: ["/tmp/approved"], + mediaReadFile, + }); + + expect(loadWebMediaMock).toHaveBeenCalledWith(source, { + maxBytes: 50 * 1024 * 1024, + localRoots: ["/tmp/approved"], + readFile: mediaReadFile, + hostReadCapability: true, + }); + expect(sendMessage).toHaveBeenLastCalledWith( + "+1555", + "attachment", + buffer, + expectedMimeType, + { + ...(forceDocument ? { asDocument: true } : {}), + fileName: expectedFileName, + }, + ); + }, + ); + + it.each([ + { + name: "a local image despite a misleading document filename", + source: "/tmp/upload.bin", + contentType: "image/png", + fileName: "misleading.pdf", + expectedSendOptions: undefined, + }, + { + name: "a remote video despite its generic loader classification", + source: "https://example.com/opaque-video", + contentType: "video/mp4", + fileName: "video.bin", + expectedSendOptions: undefined, + }, + { + name: "a remote PDF as a document", + source: "https://example.com/opaque-document", + contentType: "application/pdf", + fileName: "report.pdf", + expectedSendOptions: { fileName: "report.pdf" }, + }, + { + name: "a forced local image document", + source: "/tmp/upload.bin", + contentType: "image/png", + fileName: "photo.png", + forceDocument: true, + expectedSendOptions: { asDocument: true, fileName: "photo.png" }, + }, + ])( + "uses explicit upload MIME metadata to deliver $name", + async ({ source, contentType, fileName, forceDocument, expectedSendOptions }) => { + const buffer = Buffer.from("attachment"); + loadWebMediaMock.mockResolvedValueOnce({ + buffer, + contentType: "application/octet-stream", + kind: "document", + fileName: "download.bin", + }); + + await sendWhatsAppUploadFile("+1555", "attachment", { + verbose: false, + cfg: WHATSAPP_TEST_CFG, + mediaUrl: source, + contentType, + fileName, + forceDocument, + }); + + if (expectedSendOptions) { + expect(sendMessage).toHaveBeenLastCalledWith( + "+1555", + "attachment", + buffer, + contentType, + expectedSendOptions, + ); + } else { + expect(sendMessage).toHaveBeenLastCalledWith("+1555", "attachment", buffer, contentType); + } + }, + ); + + it("keeps data-URL image bytes on the native image transport", async () => { + const buffer = Buffer.from("image"); + + await sendWhatsAppUploadFile("+1555", "image caption", { + verbose: false, + cfg: WHATSAPP_TEST_CFG, + mediaPayload: { buffer, contentType: "image/png" }, + }); + + expect(hoisted.loadOutboundMediaFromUrl).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenLastCalledWith("+1555", "image caption", buffer, "image/png"); + }); + it.each([ { contentType: "image/png", fileName: "photo.png" }, { contentType: "video/mp4", fileName: "clip.mp4" }, diff --git a/extensions/whatsapp/src/send.ts b/extensions/whatsapp/src/send.ts index 7d2b304b938d..fd984261581d 100644 --- a/extensions/whatsapp/src/send.ts +++ b/extensions/whatsapp/src/send.ts @@ -153,6 +153,14 @@ export async function sendMessageWhatsApp( /** Report each accepted internal platform send before the next fallible send. */ onDeliveryResult?: (result: { messageId: string; toJid: string }) => Promise | void; }, +): Promise<{ messageId: string; toJid: string }> { + return await sendWhatsAppUploadFile(to, body, options); +} + +export async function sendWhatsAppUploadFile( + to: string, + body: string, + options: Parameters[2] & { fileName?: string; contentType?: string }, ): Promise<{ messageId: string; toJid: string }> { return await withWhatsAppLogicalDeliveryActivity(() => sendMessageWhatsAppInActivityScope(to, body, options), @@ -162,7 +170,7 @@ export async function sendMessageWhatsApp( async function sendMessageWhatsAppInActivityScope( to: string, body: string, - options: Parameters[2], + options: Parameters[2] & { fileName?: string; contentType?: string }, ): Promise<{ messageId: string; toJid: string }> { let text = options.preserveLeadingWhitespace ? body : normalizeWhatsAppPayloadText(body); const jid = toWhatsappJid(to); @@ -224,14 +232,22 @@ async function sendMessageWhatsAppInActivityScope( } else if (primaryMediaUrl) { // Injected readers must carry an explicit local-root boundary. The shared loader enforces // that contract; never restore the former implicit `localRoots: "any"` widening here. + const loadedMedia = await loadOutboundMediaFromUrl(primaryMediaUrl, { + maxBytes: resolveWhatsAppMediaMaxBytes(account), + optimizeImages: options.forceDocument ? false : undefined, + mediaAccess: options.mediaAccess, + mediaLocalRoots: options.mediaLocalRoots, + mediaReadFile: options.mediaReadFile, + }); + // An explicit upload MIME supersedes the loader's inferred kind; preserving a stale + // document guess would incorrectly send native images and videos as documents. + const mediaWithRequestedType = options.contentType + ? { ...loadedMedia, contentType: options.contentType, kind: undefined } + : loadedMedia; media = await prepareWhatsAppOutboundMedia( - await loadOutboundMediaFromUrl(primaryMediaUrl, { - maxBytes: resolveWhatsAppMediaMaxBytes(account), - optimizeImages: options.forceDocument ? false : undefined, - mediaAccess: options.mediaAccess, - mediaLocalRoots: options.mediaLocalRoots, - mediaReadFile: options.mediaReadFile, - }), + options.fileName + ? { ...mediaWithRequestedType, fileName: options.fileName } + : mediaWithRequestedType, primaryMediaUrl, ); }