fix(whatsapp): preserve upload attachment metadata (#128186)

This commit is contained in:
Peter Steinberger
2026-08-23 04:48:15 -07:00
committed by GitHub
parent 057ed79f96
commit 93c36f517e
5 changed files with 361 additions and 24 deletions
@@ -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 };
@@ -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) => {
+14 -13
View File
@@ -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<string, unknown>;
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,
+181 -2
View File
@@ -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" },
+24 -8
View File
@@ -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> | void;
},
): Promise<{ messageId: string; toJid: string }> {
return await sendWhatsAppUploadFile(to, body, options);
}
export async function sendWhatsAppUploadFile(
to: string,
body: string,
options: Parameters<typeof sendMessageWhatsApp>[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<typeof sendMessageWhatsApp>[2],
options: Parameters<typeof sendMessageWhatsApp>[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,
);
}