mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
fix(whatsapp): preserve upload attachment metadata (#128186)
This commit is contained in:
committed by
GitHub
parent
057ed79f96
commit
93c36f517e
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user