Skip to content

Commit d043bd5

Browse files
committed
fix(whatsapp): preserve scoped upload-file media
1 parent 91a4e55 commit d043bd5

8 files changed

Lines changed: 364 additions & 26 deletions

extensions/whatsapp/src/channel-react-action.runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
33

44
export { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
55
export { handleWhatsAppAction } from "./action-runtime.js";
6+
export { resolveAuthorizedWhatsAppOutboundTarget } from "./action-runtime-target-auth.js";
7+
export { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "./accounts.js";
68
export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js";
79
export { sendMessageWhatsApp } from "./send.js";
810
export { readStringOrNumberParam, readStringParam, type OpenClawConfig };

extensions/whatsapp/src/channel-react-action.test.ts

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ import type { OpenClawConfig } from "./runtime-api.js";
44

55
const hoisted = vi.hoisted(() => ({
66
handleWhatsAppAction: vi.fn(async () => ({ content: [{ type: "text", text: '{"ok":true}' }] })),
7+
resolveAuthorizedWhatsAppOutboundTarget: vi.fn(
8+
({
9+
chatJid,
10+
accountId,
11+
}: {
12+
chatJid: string;
13+
accountId?: string;
14+
}): { to: string; accountId: string } => ({
15+
to: chatJid,
16+
accountId: accountId ?? "default",
17+
}),
18+
),
19+
resolveWhatsAppAccount: vi.fn(() => ({ accountId: "default", mediaMaxMb: 50 })),
20+
resolveWhatsAppMediaMaxBytes: vi.fn(() => 50 * 1024 * 1024),
721
sendMessageWhatsApp: vi.fn(async () => ({
822
messageId: "msg-media-1",
923
toJid: "1555@s.whatsapp.net",
@@ -13,6 +27,9 @@ const hoisted = vi.hoisted(() => ({
1327
vi.mock("./channel-react-action.runtime.js", async () => {
1428
return {
1529
handleWhatsAppAction: hoisted.handleWhatsAppAction,
30+
resolveAuthorizedWhatsAppOutboundTarget: hoisted.resolveAuthorizedWhatsAppOutboundTarget,
31+
resolveWhatsAppAccount: hoisted.resolveWhatsAppAccount,
32+
resolveWhatsAppMediaMaxBytes: hoisted.resolveWhatsAppMediaMaxBytes,
1633
sendMessageWhatsApp: hoisted.sendMessageWhatsApp,
1734
resolveReactionMessageId: ({
1835
args,
@@ -78,6 +95,11 @@ describe("whatsapp react action messageId resolution", () => {
7895

7996
beforeEach(() => {
8097
hoisted.handleWhatsAppAction.mockClear();
98+
hoisted.resolveAuthorizedWhatsAppOutboundTarget.mockClear();
99+
hoisted.resolveWhatsAppAccount.mockClear();
100+
hoisted.resolveWhatsAppMediaMaxBytes.mockClear();
101+
hoisted.resolveWhatsAppAccount.mockReturnValue({ accountId: "default", mediaMaxMb: 50 });
102+
hoisted.resolveWhatsAppMediaMaxBytes.mockReturnValue(50 * 1024 * 1024);
81103
hoisted.sendMessageWhatsApp.mockClear();
82104
});
83105

@@ -90,20 +112,32 @@ describe("whatsapp react action messageId resolution", () => {
90112
to: "+1555",
91113
filePath: "/tmp/pic.png",
92114
caption: "picture caption",
115+
forceDocument: "true",
116+
gifPlayback: true,
117+
asVoice: "true",
93118
},
94119
cfg: baseCfg,
95120
accountId: "default",
96121
mediaLocalRoots: ["/tmp"],
97122
mediaReadFile,
98123
});
99124

125+
expect(hoisted.resolveAuthorizedWhatsAppOutboundTarget).toHaveBeenCalledWith({
126+
cfg: baseCfg,
127+
chatJid: "+1555",
128+
accountId: "default",
129+
actionLabel: "upload-file",
130+
});
100131
expect(hoisted.sendMessageWhatsApp).toHaveBeenCalledWith("+1555", "picture caption", {
101132
verbose: false,
102133
cfg: baseCfg,
103134
mediaUrl: "/tmp/pic.png",
104135
mediaAccess: undefined,
105136
mediaLocalRoots: ["/tmp"],
106137
mediaReadFile,
138+
gifPlayback: true,
139+
audioAsVoice: true,
140+
forceDocument: true,
107141
accountId: "default",
108142
});
109143
expect(result.details).toMatchObject({
@@ -115,19 +149,75 @@ describe("whatsapp react action messageId resolution", () => {
115149
});
116150
});
117151

118-
it("rejects upload-file buffer payloads without a media path", async () => {
152+
it("does not send upload-file when target authorization fails", async () => {
153+
hoisted.resolveAuthorizedWhatsAppOutboundTarget.mockImplementationOnce(() => {
154+
throw new Error("WhatsApp upload-file blocked");
155+
});
156+
157+
await expect(
158+
handleWhatsAppReactAction({
159+
action: "upload-file",
160+
params: {
161+
to: "+1555",
162+
filePath: "/tmp/pic.png",
163+
},
164+
cfg: baseCfg,
165+
accountId: "default",
166+
}),
167+
).rejects.toThrow("WhatsApp upload-file blocked");
168+
expect(hoisted.sendMessageWhatsApp).not.toHaveBeenCalled();
169+
});
170+
171+
it("sends upload-file from the hydrated buffer payload", async () => {
172+
await handleWhatsAppReactAction({
173+
action: "upload-file",
174+
params: {
175+
to: "+1555",
176+
buffer: Buffer.from("hello").toString("base64"),
177+
contentType: "text/plain",
178+
filename: "hello.txt",
179+
filePath: "/tmp/hello.txt",
180+
forceDocument: true,
181+
message: "file caption",
182+
},
183+
cfg: baseCfg,
184+
accountId: "default",
185+
});
186+
187+
expect(hoisted.sendMessageWhatsApp).toHaveBeenCalledWith("+1555", "file caption", {
188+
verbose: false,
189+
cfg: baseCfg,
190+
mediaPayload: {
191+
buffer: Buffer.from("hello"),
192+
contentType: "text/plain",
193+
fileName: "hello.txt",
194+
},
195+
mediaAccess: undefined,
196+
mediaLocalRoots: undefined,
197+
mediaReadFile: undefined,
198+
gifPlayback: undefined,
199+
audioAsVoice: undefined,
200+
forceDocument: true,
201+
accountId: "default",
202+
});
203+
});
204+
205+
it("rejects upload-file buffers above the WhatsApp media limit", async () => {
206+
hoisted.resolveWhatsAppMediaMaxBytes.mockReturnValueOnce(4);
207+
119208
await expect(
120209
handleWhatsAppReactAction({
121210
action: "upload-file",
122211
params: {
123212
to: "+1555",
124213
buffer: Buffer.from("hello").toString("base64"),
214+
contentType: "text/plain",
125215
filename: "hello.txt",
126216
},
127217
cfg: baseCfg,
128218
accountId: "default",
129219
}),
130-
).rejects.toThrow("WhatsApp upload-file cannot send buffer payloads");
220+
).rejects.toThrow("WhatsApp upload-file buffer exceeds configured media limit");
131221
expect(hoisted.sendMessageWhatsApp).not.toHaveBeenCalled();
132222
});
133223

extensions/whatsapp/src/channel-react-action.ts

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { jsonResult } from "openclaw/plugin-sdk/channel-actions";
22
import {
33
isWhatsAppGroupJid,
4+
resolveAuthorizedWhatsAppOutboundTarget,
5+
resolveWhatsAppAccount,
6+
resolveWhatsAppMediaMaxBytes,
47
resolveReactionMessageId,
58
handleWhatsAppAction,
69
normalizeWhatsAppTarget,
@@ -50,25 +53,122 @@ function readUploadFileCaptionText(args: Record<string, unknown>): string {
5053
);
5154
}
5255

53-
async function handleWhatsAppUploadFileAction(params: WhatsAppMessageActionParams) {
54-
const mediaUrl = readUploadFileMediaSource(params.params);
55-
if (!mediaUrl) {
56-
if (readStringParam(params.params, "buffer", { trim: false })) {
56+
function readBooleanParam(args: Record<string, unknown>, key: string): boolean | undefined {
57+
const value = args[key];
58+
if (typeof value === "boolean") {
59+
return value;
60+
}
61+
if (typeof value !== "string") {
62+
return undefined;
63+
}
64+
const normalized = value.trim().toLowerCase();
65+
if (normalized === "true") {
66+
return true;
67+
}
68+
if (normalized === "false") {
69+
return false;
70+
}
71+
return undefined;
72+
}
73+
74+
function hasUploadFileBufferPayload(args: Record<string, unknown>): boolean {
75+
return readStringParam(args, "buffer", { trim: false }) !== undefined;
76+
}
77+
78+
function extractBase64Payload(encoded: string): string {
79+
const match = /^data:[^;]+;base64,(.*)$/i.exec(encoded.trim());
80+
return match ? match[1] : encoded;
81+
}
82+
83+
function estimateBase64DecodedBytes(encoded: string): number {
84+
const compact = extractBase64Payload(encoded).replace(/\s/g, "");
85+
if (!compact) {
86+
return 0;
87+
}
88+
const padding = compact.endsWith("==") ? 2 : compact.endsWith("=") ? 1 : 0;
89+
return Math.max(0, Math.floor((compact.length * 3) / 4) - padding);
90+
}
91+
92+
function decodeUploadFileMediaPayload(params: {
93+
args: Record<string, unknown>;
94+
encoded: string;
95+
maxBytes?: number;
96+
}):
97+
| {
98+
buffer: Buffer;
99+
contentType?: string;
100+
fileName?: string;
101+
}
102+
| undefined {
103+
if (params.maxBytes !== undefined) {
104+
const estimatedBytes = estimateBase64DecodedBytes(params.encoded);
105+
if (estimatedBytes > params.maxBytes) {
57106
throw new Error(
58-
"WhatsApp upload-file cannot send buffer payloads. Use media, mediaUrl, filePath, path, or fileUrl instead.",
107+
`WhatsApp upload-file buffer exceeds configured media limit (${estimatedBytes} bytes > ${params.maxBytes} bytes).`,
59108
);
60109
}
61-
throw new Error("WhatsApp upload-file requires media, mediaUrl, filePath, path, or fileUrl.");
110+
}
111+
const contentType =
112+
readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType");
113+
const fileName =
114+
readStringParam(params.args, "filename") ?? readStringParam(params.args, "fileName");
115+
const buffer = Buffer.from(extractBase64Payload(params.encoded), "base64");
116+
if (params.maxBytes !== undefined && buffer.byteLength > params.maxBytes) {
117+
throw new Error(
118+
`WhatsApp upload-file buffer exceeds configured media limit (${buffer.byteLength} bytes > ${params.maxBytes} bytes).`,
119+
);
120+
}
121+
return {
122+
buffer,
123+
...(contentType ? { contentType } : {}),
124+
...(fileName ? { fileName } : {}),
125+
};
126+
}
127+
128+
async function handleWhatsAppUploadFileAction(params: WhatsAppMessageActionParams) {
129+
const mediaUrl = readUploadFileMediaSource(params.params);
130+
const encodedPayload = readStringParam(params.params, "buffer", { trim: false });
131+
if (!mediaUrl && !hasUploadFileBufferPayload(params.params)) {
132+
throw new Error(
133+
"WhatsApp upload-file requires media, mediaUrl, filePath, path, fileUrl, or buffer.",
134+
);
62135
}
63136
const to = readStringParam(params.params, "to", { required: true });
64-
const result = await sendMessageWhatsApp(to, readUploadFileCaptionText(params.params), {
137+
const resolved = resolveAuthorizedWhatsAppOutboundTarget({
138+
cfg: params.cfg,
139+
chatJid: to,
140+
accountId: params.accountId ?? undefined,
141+
actionLabel: "upload-file",
142+
});
143+
const account = resolveWhatsAppAccount({
144+
cfg: params.cfg,
145+
accountId: resolved.accountId,
146+
});
147+
const mediaPayload = encodedPayload
148+
? decodeUploadFileMediaPayload({
149+
args: params.params,
150+
encoded: encodedPayload,
151+
maxBytes: resolveWhatsAppMediaMaxBytes(account),
152+
})
153+
: undefined;
154+
const result = await sendMessageWhatsApp(resolved.to, readUploadFileCaptionText(params.params), {
65155
verbose: false,
66156
cfg: params.cfg,
67-
mediaUrl,
157+
...(mediaUrl && !mediaPayload ? { mediaUrl } : {}),
158+
...(mediaPayload ? { mediaPayload } : {}),
68159
mediaAccess: params.mediaAccess,
69160
mediaLocalRoots: params.mediaLocalRoots,
70161
mediaReadFile: params.mediaReadFile,
71-
accountId: params.accountId ?? undefined,
162+
gifPlayback: readBooleanParam(params.params, "gifPlayback") ?? undefined,
163+
audioAsVoice:
164+
readBooleanParam(params.params, "asVoice") ??
165+
readBooleanParam(params.params, "audioAsVoice") ??
166+
undefined,
167+
forceDocument:
168+
readBooleanParam(params.params, "forceDocument") ??
169+
readBooleanParam(params.params, "asDocument") ??
170+
undefined,
171+
accountId: resolved.accountId,
72172
});
73173
return jsonResult({
74174
ok: true,

extensions/whatsapp/src/outbound-media-contract.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,35 @@ export function normalizeWhatsAppOutboundPayload<T extends WhatsAppOutboundPaylo
110110
};
111111
}
112112

113+
function inferWhatsAppMediaKind(
114+
media: WhatsAppLoadedMediaLike,
115+
): "image" | "audio" | "video" | "document" {
116+
if (
117+
media.kind === "image" ||
118+
media.kind === "audio" ||
119+
media.kind === "video" ||
120+
media.kind === "document"
121+
) {
122+
return media.kind;
123+
}
124+
const contentType = normalizeContentType(media.contentType);
125+
if (contentType.startsWith("image/")) {
126+
return "image";
127+
}
128+
if (contentType.startsWith("audio/")) {
129+
return "audio";
130+
}
131+
if (contentType.startsWith("video/")) {
132+
return "video";
133+
}
134+
return "document";
135+
}
136+
113137
function normalizeWhatsAppLoadedMedia(
114138
media: WhatsAppLoadedMediaLike,
115139
mediaUrl?: string,
116140
): CanonicalWhatsAppLoadedMedia {
117-
const kind =
118-
media.kind === "image" || media.kind === "audio" || media.kind === "video"
119-
? media.kind
120-
: "document";
141+
const kind = inferWhatsAppMediaKind(media);
121142
const mimetype =
122143
kind === "audio" && isWhatsAppNativeVoiceAudio({ contentType: media.contentType, mediaUrl })
123144
? WHATSAPP_VOICE_MIMETYPE

0 commit comments

Comments
 (0)