Skip to content

Commit 29f39db

Browse files
authored
fix(whatsapp): lower upload-file media sends (#81883)
Merged via squash. Prepared head SHA: 3b2ae9c Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Reviewed-by: @ngutman
1 parent 651ec20 commit 29f39db

12 files changed

Lines changed: 520 additions & 24 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ Docs: https://docs.openclaw.ai
214214
- Plugin SDK: bundle `openclaw/plugin-sdk/zod` into the published package artifact and verify the packed zod subpath stays self-contained, so pnpm global installs can register plugins without a package-local `zod` symlink. Fixes #78398. (#78515) Thanks @ggzeng.
215215
- Providers/Google: drop compaction-truncated Gemini thought signatures before replay so malformed Base64 no longer aborts the next assistant turn. (#82995) Thanks @wAngByg.
216216
- Gateway/mobile: allow paired iOS and Android clients to refresh same-family OS metadata on authenticated reconnect instead of requiring a new approval. (#83490) Thanks @ngutman.
217+
- WhatsApp: treat `upload-file` as a supported media send intent by lowering path/URL uploads through the channel's normal send-media transport. (#81883) Thanks @ngutman.
217218

218219
## 2026.5.17
219220

extensions/whatsapp/src/channel-actions.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ describe("whatsapp channel action helpers", () => {
130130
expect(describeWhatsAppMessageActions({ cfg, accountId: "default" })?.actions).toEqual([
131131
"react",
132132
"poll",
133+
"upload-file",
133134
]);
134135
});
135136

@@ -151,6 +152,7 @@ describe("whatsapp channel action helpers", () => {
151152

152153
expect(describeWhatsAppMessageActions({ cfg, accountId: "default" })?.actions).toEqual([
153154
"poll",
155+
"upload-file",
154156
]);
155157
});
156158

@@ -172,6 +174,7 @@ describe("whatsapp channel action helpers", () => {
172174
expect(describeWhatsAppMessageActions({ cfg, accountId: "work" })?.actions).toEqual([
173175
"react",
174176
"poll",
177+
"upload-file",
175178
]);
176179
});
177180

@@ -191,7 +194,11 @@ describe("whatsapp channel action helpers", () => {
191194
} as OpenClawConfig;
192195
hoisted.listWhatsAppAccountIds.mockReturnValue(["default", "work"]);
193196

194-
expect(describeWhatsAppMessageActions({ cfg })?.actions).toEqual(["react", "poll"]);
197+
expect(describeWhatsAppMessageActions({ cfg })?.actions).toEqual([
198+
"react",
199+
"poll",
200+
"upload-file",
201+
]);
195202
});
196203

197204
it("omits react in global discovery when only disabled accounts enable agent reactions", () => {
@@ -211,6 +218,6 @@ describe("whatsapp channel action helpers", () => {
211218
} as OpenClawConfig;
212219
hoisted.listWhatsAppAccountIds.mockReturnValue(["default", "work"]);
213220

214-
expect(describeWhatsAppMessageActions({ cfg })?.actions).toEqual(["poll"]);
221+
expect(describeWhatsAppMessageActions({ cfg })?.actions).toEqual(["poll", "upload-file"]);
215222
});
216223
});

extensions/whatsapp/src/channel-actions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,5 +80,6 @@ export function describeWhatsAppMessageActions(params: {
8080
if (gate("polls")) {
8181
actions.add("poll");
8282
}
83+
actions.add("upload-file");
8384
return { actions: Array.from(actions) };
8485
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +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";
9+
export { sendMessageWhatsApp } from "./send.js";
710
export { readStringOrNumberParam, readStringParam, type OpenClawConfig };

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

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,33 @@ 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),
21+
sendMessageWhatsApp: vi.fn(async () => ({
22+
messageId: "msg-media-1",
23+
toJid: "1555@s.whatsapp.net",
24+
})),
725
}));
826

927
vi.mock("./channel-react-action.runtime.js", async () => {
1028
return {
1129
handleWhatsAppAction: hoisted.handleWhatsAppAction,
30+
resolveAuthorizedWhatsAppOutboundTarget: hoisted.resolveAuthorizedWhatsAppOutboundTarget,
31+
resolveWhatsAppAccount: hoisted.resolveWhatsAppAccount,
32+
resolveWhatsAppMediaMaxBytes: hoisted.resolveWhatsAppMediaMaxBytes,
33+
sendMessageWhatsApp: hoisted.sendMessageWhatsApp,
1234
resolveReactionMessageId: ({
1335
args,
1436
toolContext,
@@ -41,7 +63,7 @@ vi.mock("./channel-react-action.runtime.js", async () => {
4163
readStringParam: (
4264
params: Record<string, unknown>,
4365
key: string,
44-
options?: { required?: boolean; allowEmpty?: boolean },
66+
options?: { required?: boolean; allowEmpty?: boolean; trim?: boolean },
4567
) => {
4668
const value = params[key];
4769
if (value == null) {
@@ -73,6 +95,145 @@ describe("whatsapp react action messageId resolution", () => {
7395

7496
beforeEach(() => {
7597
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);
103+
hoisted.sendMessageWhatsApp.mockClear();
104+
});
105+
106+
it("sends upload-file through the WhatsApp media send path", async () => {
107+
const mediaReadFile = vi.fn(async () => Buffer.from("media"));
108+
109+
const result = await handleWhatsAppReactAction({
110+
action: "upload-file",
111+
params: {
112+
to: "+1555",
113+
filePath: "/tmp/pic.png",
114+
caption: "picture caption",
115+
forceDocument: "true",
116+
gifPlayback: true,
117+
asVoice: "true",
118+
},
119+
cfg: baseCfg,
120+
accountId: "default",
121+
mediaLocalRoots: ["/tmp"],
122+
mediaReadFile,
123+
});
124+
125+
expect(hoisted.resolveAuthorizedWhatsAppOutboundTarget).toHaveBeenCalledWith({
126+
cfg: baseCfg,
127+
chatJid: "+1555",
128+
accountId: "default",
129+
actionLabel: "upload-file",
130+
});
131+
expect(hoisted.sendMessageWhatsApp).toHaveBeenCalledWith("+1555", "picture caption", {
132+
verbose: false,
133+
cfg: baseCfg,
134+
mediaUrl: "/tmp/pic.png",
135+
mediaAccess: undefined,
136+
mediaLocalRoots: ["/tmp"],
137+
mediaReadFile,
138+
gifPlayback: true,
139+
audioAsVoice: true,
140+
forceDocument: true,
141+
accountId: "default",
142+
});
143+
expect(result.details).toMatchObject({
144+
ok: true,
145+
channel: "whatsapp",
146+
action: "upload-file",
147+
messageId: "msg-media-1",
148+
toJid: "1555@s.whatsapp.net",
149+
});
150+
});
151+
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+
208+
await expect(
209+
handleWhatsAppReactAction({
210+
action: "upload-file",
211+
params: {
212+
to: "+1555",
213+
buffer: Buffer.from("hello").toString("base64"),
214+
contentType: "text/plain",
215+
filename: "hello.txt",
216+
},
217+
cfg: baseCfg,
218+
accountId: "default",
219+
}),
220+
).rejects.toThrow("WhatsApp upload-file buffer exceeds configured media limit");
221+
expect(hoisted.sendMessageWhatsApp).not.toHaveBeenCalled();
222+
});
223+
224+
it("requires upload-file media path input", async () => {
225+
await expect(
226+
handleWhatsAppReactAction({
227+
action: "upload-file",
228+
params: {
229+
to: "+1555",
230+
caption: "missing media",
231+
},
232+
cfg: baseCfg,
233+
accountId: "default",
234+
}),
235+
).rejects.toThrow("WhatsApp upload-file requires media");
236+
expect(hoisted.sendMessageWhatsApp).not.toHaveBeenCalled();
76237
});
77238

78239
it("uses explicit messageId when provided", async () => {

0 commit comments

Comments
 (0)