@laburen/openclaw-plugin-whatsapp-api 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/index.d.ts +21 -0
- package/index.js +1363 -0
- package/package.json +33 -7
- package/index.ts +0 -16
- package/src/accounts.ts +0 -102
- package/src/channel.ts +0 -253
- package/src/inbound.ts +0 -233
- package/src/outbound.ts +0 -390
- package/src/runtime.ts +0 -18
- package/src/types.ts +0 -61
- package/src/utils/common.ts +0 -19
- package/src/utils/media/inbound.ts +0 -164
- package/src/utils/media/mime.ts +0 -74
- package/src/utils/outbound/payload.ts +0 -173
- package/src/webhook.ts +0 -119
package/src/outbound.ts
DELETED
|
@@ -1,390 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ResolvedWhatsAppApiAccount,
|
|
3
|
-
WhatsAppApiSendTextParams,
|
|
4
|
-
} from "./types.js";
|
|
5
|
-
import { readFile } from "node:fs/promises";
|
|
6
|
-
import { mediaKindFromMimeOrName, mimeFromFileName } from "./utils/media/mime.js";
|
|
7
|
-
import { normalizeOutboundPayload } from "./utils/outbound/payload.js";
|
|
8
|
-
|
|
9
|
-
function sleep(ms: number): Promise<void> {
|
|
10
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function normalizeRecipient(to: string): string {
|
|
14
|
-
const trimmed = to.trim();
|
|
15
|
-
if (trimmed.startsWith("+")) {
|
|
16
|
-
return trimmed.slice(1);
|
|
17
|
-
}
|
|
18
|
-
return trimmed;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function readGraphError(bodyText: string): string | null {
|
|
22
|
-
try {
|
|
23
|
-
const parsed = JSON.parse(bodyText) as { error?: { message?: string } };
|
|
24
|
-
return parsed.error?.message ?? null;
|
|
25
|
-
} catch {
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function isRetryableStatus(status: number): boolean {
|
|
31
|
-
return status === 429 || status >= 500;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async function withTimeout(input: RequestInfo | URL, init: RequestInit, timeoutMs: number) {
|
|
35
|
-
const controller = new AbortController();
|
|
36
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
37
|
-
try {
|
|
38
|
-
return await fetch(input, {
|
|
39
|
-
...init,
|
|
40
|
-
signal: controller.signal,
|
|
41
|
-
});
|
|
42
|
-
} finally {
|
|
43
|
-
clearTimeout(timer);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function readRetryAfterMs(res: Response): number | null {
|
|
48
|
-
const value = res.headers.get("retry-after");
|
|
49
|
-
if (!value) {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
const seconds = Number(value);
|
|
53
|
-
if (Number.isFinite(seconds) && seconds > 0) {
|
|
54
|
-
return Math.ceil(seconds * 1000);
|
|
55
|
-
}
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function resolveGraphAuth(account: ResolvedWhatsAppApiAccount): { phoneNumberId: string; accessToken: string } {
|
|
60
|
-
const phoneNumberId = account.outboundPhoneNumberId?.trim();
|
|
61
|
-
const accessToken = account.outboundAccessToken?.trim();
|
|
62
|
-
if (!phoneNumberId || !accessToken) {
|
|
63
|
-
throw new Error("Missing outboundPhoneNumberId/outboundAccessToken in whatsapp-api account config");
|
|
64
|
-
}
|
|
65
|
-
return { phoneNumberId, accessToken };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function sendWithRetry(params: {
|
|
69
|
-
endpoint: string;
|
|
70
|
-
account: ResolvedWhatsAppApiAccount;
|
|
71
|
-
accessToken: string;
|
|
72
|
-
payload: Record<string, unknown>;
|
|
73
|
-
}): Promise<string> {
|
|
74
|
-
let lastErr: unknown = null;
|
|
75
|
-
const maxAttempts = Math.max(1, params.account.maxRetries + 1);
|
|
76
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
77
|
-
try {
|
|
78
|
-
const res = await withTimeout(
|
|
79
|
-
params.endpoint,
|
|
80
|
-
{
|
|
81
|
-
method: "POST",
|
|
82
|
-
headers: {
|
|
83
|
-
authorization: `Bearer ${params.accessToken}`,
|
|
84
|
-
"content-type": "application/json",
|
|
85
|
-
},
|
|
86
|
-
body: JSON.stringify(params.payload),
|
|
87
|
-
},
|
|
88
|
-
params.account.requestTimeoutMs,
|
|
89
|
-
);
|
|
90
|
-
const bodyText = await res.text();
|
|
91
|
-
if (!res.ok) {
|
|
92
|
-
const detail = readGraphError(bodyText);
|
|
93
|
-
const err = new Error(
|
|
94
|
-
`Meta API send failed: HTTP ${res.status}${detail ? ` - ${detail}` : ""}`,
|
|
95
|
-
);
|
|
96
|
-
if (!isRetryableStatus(res.status)) {
|
|
97
|
-
throw err;
|
|
98
|
-
}
|
|
99
|
-
lastErr = err;
|
|
100
|
-
const retryAfterMs = readRetryAfterMs(res);
|
|
101
|
-
if (attempt < maxAttempts) {
|
|
102
|
-
await sleep(retryAfterMs ?? params.account.retryBackoffMs * attempt);
|
|
103
|
-
}
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
const parsed = JSON.parse(bodyText) as { messages?: Array<{ id?: string }> };
|
|
107
|
-
const messageId = parsed.messages?.[0]?.id;
|
|
108
|
-
return messageId && messageId.trim() ? messageId.trim() : "unknown";
|
|
109
|
-
} catch (err) {
|
|
110
|
-
lastErr = err;
|
|
111
|
-
if (attempt >= maxAttempts) {
|
|
112
|
-
break;
|
|
113
|
-
}
|
|
114
|
-
await sleep(params.account.retryBackoffMs * attempt);
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? "Unknown outbound error"));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async function uploadMediaFromLocalPath(params: {
|
|
123
|
-
endpoint: string;
|
|
124
|
-
account: ResolvedWhatsAppApiAccount;
|
|
125
|
-
accessToken: string;
|
|
126
|
-
localPath: string;
|
|
127
|
-
kind: "image" | "video" | "audio" | "document";
|
|
128
|
-
}): Promise<{ id: string; mimeType?: string }> {
|
|
129
|
-
const bytes = await readFile(params.localPath);
|
|
130
|
-
if (bytes.byteLength > params.account.mediaMaxBytes) {
|
|
131
|
-
throw new Error(
|
|
132
|
-
`Local media exceeds mediaMaxBytes (${bytes.byteLength} > ${params.account.mediaMaxBytes})`,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
const fileName = params.localPath.split("/").pop() ?? "media.bin";
|
|
136
|
-
const mimeType = mimeFromFileName(fileName);
|
|
137
|
-
const uploadEndpoint = params.endpoint.replace(/\/messages$/, "/media");
|
|
138
|
-
const maxAttempts = Math.max(1, params.account.maxRetries + 1);
|
|
139
|
-
let lastErr: unknown = null;
|
|
140
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
141
|
-
try {
|
|
142
|
-
const form = new FormData();
|
|
143
|
-
form.append("messaging_product", "whatsapp");
|
|
144
|
-
form.append("type", mimeType ?? "application/octet-stream");
|
|
145
|
-
form.append(
|
|
146
|
-
"file",
|
|
147
|
-
new Blob([bytes], { type: mimeType ?? "application/octet-stream" }),
|
|
148
|
-
fileName,
|
|
149
|
-
);
|
|
150
|
-
const res = await withTimeout(
|
|
151
|
-
uploadEndpoint,
|
|
152
|
-
{
|
|
153
|
-
method: "POST",
|
|
154
|
-
headers: {
|
|
155
|
-
authorization: `Bearer ${params.accessToken}`,
|
|
156
|
-
},
|
|
157
|
-
body: form,
|
|
158
|
-
},
|
|
159
|
-
params.account.requestTimeoutMs,
|
|
160
|
-
);
|
|
161
|
-
const bodyText = await res.text();
|
|
162
|
-
if (!res.ok) {
|
|
163
|
-
const err = new Error(`Meta media upload failed: HTTP ${res.status} ${bodyText}`);
|
|
164
|
-
if (!isRetryableStatus(res.status)) {
|
|
165
|
-
throw err;
|
|
166
|
-
}
|
|
167
|
-
lastErr = err;
|
|
168
|
-
} else {
|
|
169
|
-
const parsed = JSON.parse(bodyText) as { id?: string };
|
|
170
|
-
if (!parsed.id?.trim()) {
|
|
171
|
-
throw new Error("Meta media upload did not return media id");
|
|
172
|
-
}
|
|
173
|
-
return { id: parsed.id.trim(), mimeType };
|
|
174
|
-
}
|
|
175
|
-
} catch (err) {
|
|
176
|
-
lastErr = err;
|
|
177
|
-
}
|
|
178
|
-
if (attempt < maxAttempts) {
|
|
179
|
-
await sleep(params.account.retryBackoffMs * attempt);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? "Unknown media upload error"));
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function isLocalMediaPath(value: string): boolean {
|
|
186
|
-
return value.startsWith("/") || value.toLowerCase().startsWith("file://");
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function toLocalPath(value: string): string {
|
|
190
|
-
if (value.toLowerCase().startsWith("file://")) {
|
|
191
|
-
return decodeURIComponent(value.replace(/^file:\/\//i, ""));
|
|
192
|
-
}
|
|
193
|
-
return value;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async function sendWhatsAppApiMedia(params: {
|
|
197
|
-
to: string;
|
|
198
|
-
text?: string;
|
|
199
|
-
mediaUrl: string;
|
|
200
|
-
account: ResolvedWhatsAppApiAccount;
|
|
201
|
-
}): Promise<string> {
|
|
202
|
-
const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
|
|
203
|
-
const endpoint = `https://graph.facebook.com/${params.account.outboundApiVersion}/${phoneNumberId}/messages`;
|
|
204
|
-
const kind = mediaKindFromMimeOrName({ fileNameOrUrl: params.mediaUrl });
|
|
205
|
-
|
|
206
|
-
let mediaPayload: Record<string, unknown>;
|
|
207
|
-
if (isLocalMediaPath(params.mediaUrl)) {
|
|
208
|
-
const localPath = toLocalPath(params.mediaUrl);
|
|
209
|
-
const uploaded = await uploadMediaFromLocalPath({
|
|
210
|
-
endpoint,
|
|
211
|
-
account: params.account,
|
|
212
|
-
accessToken,
|
|
213
|
-
localPath,
|
|
214
|
-
kind,
|
|
215
|
-
});
|
|
216
|
-
mediaPayload =
|
|
217
|
-
kind === "document"
|
|
218
|
-
? { id: uploaded.id, filename: localPath.split("/").pop() ?? "document" }
|
|
219
|
-
: { id: uploaded.id };
|
|
220
|
-
} else {
|
|
221
|
-
mediaPayload =
|
|
222
|
-
kind === "document"
|
|
223
|
-
? { link: params.mediaUrl, filename: params.mediaUrl.split("/").pop() ?? "document" }
|
|
224
|
-
: { link: params.mediaUrl };
|
|
225
|
-
}
|
|
226
|
-
if (params.text && (kind === "image" || kind === "video" || kind === "document")) {
|
|
227
|
-
mediaPayload.caption = params.text;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
return await sendWithRetry({
|
|
231
|
-
endpoint,
|
|
232
|
-
account: params.account,
|
|
233
|
-
accessToken,
|
|
234
|
-
payload: {
|
|
235
|
-
messaging_product: "whatsapp",
|
|
236
|
-
to: normalizeRecipient(params.to),
|
|
237
|
-
type: kind,
|
|
238
|
-
[kind]: mediaPayload,
|
|
239
|
-
},
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async function sendWhatsAppApiLocation(params: {
|
|
244
|
-
to: string;
|
|
245
|
-
location: {
|
|
246
|
-
latitude: number;
|
|
247
|
-
longitude: number;
|
|
248
|
-
name?: string;
|
|
249
|
-
address?: string;
|
|
250
|
-
};
|
|
251
|
-
account: ResolvedWhatsAppApiAccount;
|
|
252
|
-
}): Promise<string> {
|
|
253
|
-
const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
|
|
254
|
-
const endpoint = `https://graph.facebook.com/${params.account.outboundApiVersion}/${phoneNumberId}/messages`;
|
|
255
|
-
return await sendWithRetry({
|
|
256
|
-
endpoint,
|
|
257
|
-
account: params.account,
|
|
258
|
-
accessToken,
|
|
259
|
-
payload: {
|
|
260
|
-
messaging_product: "whatsapp",
|
|
261
|
-
to: normalizeRecipient(params.to),
|
|
262
|
-
type: "location",
|
|
263
|
-
location: {
|
|
264
|
-
latitude: params.location.latitude,
|
|
265
|
-
longitude: params.location.longitude,
|
|
266
|
-
name: params.location.name,
|
|
267
|
-
address: params.location.address,
|
|
268
|
-
},
|
|
269
|
-
},
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
async function sendWhatsAppApiContacts(params: {
|
|
274
|
-
to: string;
|
|
275
|
-
contacts: Array<{
|
|
276
|
-
name: {
|
|
277
|
-
formatted_name: string;
|
|
278
|
-
first_name?: string;
|
|
279
|
-
last_name?: string;
|
|
280
|
-
};
|
|
281
|
-
phones?: Array<{
|
|
282
|
-
phone: string;
|
|
283
|
-
type?: string;
|
|
284
|
-
wa_id?: string;
|
|
285
|
-
}>;
|
|
286
|
-
}>;
|
|
287
|
-
account: ResolvedWhatsAppApiAccount;
|
|
288
|
-
}): Promise<string> {
|
|
289
|
-
const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
|
|
290
|
-
const endpoint = `https://graph.facebook.com/${params.account.outboundApiVersion}/${phoneNumberId}/messages`;
|
|
291
|
-
return await sendWithRetry({
|
|
292
|
-
endpoint,
|
|
293
|
-
account: params.account,
|
|
294
|
-
accessToken,
|
|
295
|
-
payload: {
|
|
296
|
-
messaging_product: "whatsapp",
|
|
297
|
-
to: normalizeRecipient(params.to),
|
|
298
|
-
type: "contacts",
|
|
299
|
-
contacts: params.contacts,
|
|
300
|
-
},
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
export async function sendWhatsAppApiText(params: WhatsAppApiSendTextParams): Promise<string> {
|
|
305
|
-
const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
|
|
306
|
-
|
|
307
|
-
const recipient = normalizeRecipient(params.to);
|
|
308
|
-
if (!recipient) {
|
|
309
|
-
throw new Error("Recipient id is empty");
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
const endpoint = `https://graph.facebook.com/${params.account.outboundApiVersion}/${phoneNumberId}/messages`;
|
|
313
|
-
const payload = {
|
|
314
|
-
messaging_product: "whatsapp",
|
|
315
|
-
to: recipient,
|
|
316
|
-
type: "text",
|
|
317
|
-
text: { body: params.text },
|
|
318
|
-
};
|
|
319
|
-
|
|
320
|
-
return await sendWithRetry({
|
|
321
|
-
endpoint,
|
|
322
|
-
account: params.account,
|
|
323
|
-
accessToken,
|
|
324
|
-
payload,
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
export function normalizeReplyText(payload: unknown): string {
|
|
329
|
-
return normalizeOutboundPayload(payload).text;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
export async function sendWhatsAppApiReplyPayload(params: {
|
|
333
|
-
to: string;
|
|
334
|
-
payload: unknown;
|
|
335
|
-
account: ResolvedWhatsAppApiAccount;
|
|
336
|
-
}): Promise<string[]> {
|
|
337
|
-
const normalized = normalizeOutboundPayload(params.payload);
|
|
338
|
-
if (
|
|
339
|
-
!normalized.text &&
|
|
340
|
-
normalized.mediaUrls.length === 0 &&
|
|
341
|
-
!normalized.location &&
|
|
342
|
-
normalized.contacts.length === 0
|
|
343
|
-
) {
|
|
344
|
-
return [];
|
|
345
|
-
}
|
|
346
|
-
if (
|
|
347
|
-
normalized.mediaUrls.length === 0 &&
|
|
348
|
-
!normalized.location &&
|
|
349
|
-
normalized.contacts.length === 0
|
|
350
|
-
) {
|
|
351
|
-
return [await sendWhatsAppApiText({ to: params.to, text: normalized.text, account: params.account })];
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const sentIds: string[] = [];
|
|
355
|
-
if (normalized.text && normalized.mediaUrls.length === 0) {
|
|
356
|
-
sentIds.push(await sendWhatsAppApiText({ to: params.to, text: normalized.text, account: params.account }));
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
for (const [index, mediaUrl] of normalized.mediaUrls.entries()) {
|
|
360
|
-
const messageId = await sendWhatsAppApiMedia({
|
|
361
|
-
to: params.to,
|
|
362
|
-
mediaUrl,
|
|
363
|
-
text: index === 0 ? normalized.text : undefined,
|
|
364
|
-
account: params.account,
|
|
365
|
-
});
|
|
366
|
-
sentIds.push(messageId);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
if (normalized.location) {
|
|
370
|
-
sentIds.push(
|
|
371
|
-
await sendWhatsAppApiLocation({
|
|
372
|
-
to: params.to,
|
|
373
|
-
location: normalized.location,
|
|
374
|
-
account: params.account,
|
|
375
|
-
}),
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
if (normalized.contacts.length > 0) {
|
|
380
|
-
sentIds.push(
|
|
381
|
-
await sendWhatsAppApiContacts({
|
|
382
|
-
to: params.to,
|
|
383
|
-
contacts: normalized.contacts,
|
|
384
|
-
account: params.account,
|
|
385
|
-
}),
|
|
386
|
-
);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
return sentIds;
|
|
390
|
-
}
|
package/src/runtime.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
-
|
|
3
|
-
let pluginApi: OpenClawPluginApi | null = null;
|
|
4
|
-
|
|
5
|
-
export function setPluginApi(api: OpenClawPluginApi): void {
|
|
6
|
-
pluginApi = api;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function getPluginApi(): OpenClawPluginApi {
|
|
10
|
-
if (!pluginApi) {
|
|
11
|
-
throw new Error("whatsapp-api plugin API not initialized");
|
|
12
|
-
}
|
|
13
|
-
return pluginApi;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function getPluginRuntime(): OpenClawPluginApi["runtime"] {
|
|
17
|
-
return getPluginApi().runtime;
|
|
18
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
export type ResolvedWhatsAppApiAccount = {
|
|
2
|
-
accountId: string;
|
|
3
|
-
enabled: boolean;
|
|
4
|
-
webhookPath: string;
|
|
5
|
-
inboundSharedSecret?: string;
|
|
6
|
-
inboundAccessToken?: string;
|
|
7
|
-
outboundPhoneNumberId?: string;
|
|
8
|
-
outboundAccessToken?: string;
|
|
9
|
-
outboundApiVersion: string;
|
|
10
|
-
requestTimeoutMs: number;
|
|
11
|
-
maxRetries: number;
|
|
12
|
-
retryBackoffMs: number;
|
|
13
|
-
dedupeTtlMs: number;
|
|
14
|
-
maxBodyBytes: number;
|
|
15
|
-
mediaMaxBytes: number;
|
|
16
|
-
mediaRequestTimeoutMs: number;
|
|
17
|
-
mediaTempDir?: string;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export type WhatsAppApiInboundMedia = {
|
|
21
|
-
id: string;
|
|
22
|
-
kind: "image" | "video" | "audio" | "document" | "sticker";
|
|
23
|
-
mimeType?: string;
|
|
24
|
-
fileName?: string;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export type WhatsAppApiInboundLocation = {
|
|
28
|
-
latitude: number;
|
|
29
|
-
longitude: number;
|
|
30
|
-
name?: string;
|
|
31
|
-
address?: string;
|
|
32
|
-
url?: string;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
export type WhatsAppApiInboundContact = {
|
|
36
|
-
name?: string;
|
|
37
|
-
phones: string[];
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export type WhatsAppApiInboundMessage = {
|
|
41
|
-
accountId: string;
|
|
42
|
-
from: string;
|
|
43
|
-
to: string;
|
|
44
|
-
messageId: string;
|
|
45
|
-
text: string;
|
|
46
|
-
chatType: "direct";
|
|
47
|
-
timestamp?: number;
|
|
48
|
-
senderName?: string;
|
|
49
|
-
replyToId?: string;
|
|
50
|
-
replyToBody?: string;
|
|
51
|
-
replyToSender?: string;
|
|
52
|
-
media?: WhatsAppApiInboundMedia;
|
|
53
|
-
location?: WhatsAppApiInboundLocation;
|
|
54
|
-
contacts?: WhatsAppApiInboundContact[];
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
export type WhatsAppApiSendTextParams = {
|
|
58
|
-
to: string;
|
|
59
|
-
text: string;
|
|
60
|
-
account: ResolvedWhatsAppApiAccount;
|
|
61
|
-
};
|
package/src/utils/common.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
export function asRecord(value: unknown): Record<string, unknown> | null {
|
|
2
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
3
|
-
return null;
|
|
4
|
-
}
|
|
5
|
-
return value as Record<string, unknown>;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function asArray(value: unknown): unknown[] {
|
|
9
|
-
return Array.isArray(value) ? value : [];
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function asTrimmedString(value: unknown): string | undefined {
|
|
13
|
-
if (typeof value !== "string") {
|
|
14
|
-
return undefined;
|
|
15
|
-
}
|
|
16
|
-
const trimmed = value.trim();
|
|
17
|
-
return trimmed || undefined;
|
|
18
|
-
}
|
|
19
|
-
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import type { ResolvedWhatsAppApiAccount, WhatsAppApiInboundMessage } from "../../types.js";
|
|
5
|
-
import { asRecord } from "../common.js";
|
|
6
|
-
import { extensionFromMime } from "./mime.js";
|
|
7
|
-
|
|
8
|
-
type LogLike = {
|
|
9
|
-
warn?: (message: string) => void;
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
function sleep(ms: number): Promise<void> {
|
|
13
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function isRetryableStatus(status: number): boolean {
|
|
17
|
-
return status === 429 || status >= 500;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
async function withTimeout(input: RequestInfo | URL, init: RequestInit, timeoutMs: number) {
|
|
21
|
-
const controller = new AbortController();
|
|
22
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
23
|
-
try {
|
|
24
|
-
return await fetch(input, {
|
|
25
|
-
...init,
|
|
26
|
-
signal: controller.signal,
|
|
27
|
-
});
|
|
28
|
-
} finally {
|
|
29
|
-
clearTimeout(timer);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async function fetchWithRetry(params: {
|
|
34
|
-
account: ResolvedWhatsAppApiAccount;
|
|
35
|
-
url: string;
|
|
36
|
-
init: RequestInit;
|
|
37
|
-
}): Promise<Response> {
|
|
38
|
-
const maxAttempts = Math.max(1, params.account.maxRetries + 1);
|
|
39
|
-
let lastErr: unknown = null;
|
|
40
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
41
|
-
try {
|
|
42
|
-
const res = await withTimeout(
|
|
43
|
-
params.url,
|
|
44
|
-
params.init,
|
|
45
|
-
Math.max(500, params.account.mediaRequestTimeoutMs),
|
|
46
|
-
);
|
|
47
|
-
if (res.ok) {
|
|
48
|
-
return res;
|
|
49
|
-
}
|
|
50
|
-
const body = await res.text();
|
|
51
|
-
const err = new Error(`HTTP ${res.status} ${body}`);
|
|
52
|
-
if (!isRetryableStatus(res.status)) {
|
|
53
|
-
throw err;
|
|
54
|
-
}
|
|
55
|
-
lastErr = err;
|
|
56
|
-
} catch (err) {
|
|
57
|
-
lastErr = err;
|
|
58
|
-
}
|
|
59
|
-
if (attempt < maxAttempts) {
|
|
60
|
-
await sleep(params.account.retryBackoffMs * attempt);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? "Unknown media error"));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function resolveAccessToken(account: ResolvedWhatsAppApiAccount): string {
|
|
67
|
-
const token = account.inboundAccessToken?.trim() ?? account.outboundAccessToken?.trim();
|
|
68
|
-
if (!token) {
|
|
69
|
-
throw new Error("Missing inboundAccessToken/outboundAccessToken for media download");
|
|
70
|
-
}
|
|
71
|
-
return token;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function resolveMediaStorageDir(account: ResolvedWhatsAppApiAccount): string {
|
|
75
|
-
const root = account.mediaTempDir?.trim() || path.join(tmpdir(), "openclaw-whatsapp-api-media");
|
|
76
|
-
return path.join(root, account.accountId);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function sanitizeFileName(base: string): string {
|
|
80
|
-
return base.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export async function downloadInboundMediaAttachment(params: {
|
|
84
|
-
account: ResolvedWhatsAppApiAccount;
|
|
85
|
-
message: WhatsAppApiInboundMessage;
|
|
86
|
-
log?: LogLike;
|
|
87
|
-
}): Promise<{ mediaPath?: string; mediaType?: string }> {
|
|
88
|
-
try {
|
|
89
|
-
const media = params.message.media;
|
|
90
|
-
if (!media?.id) {
|
|
91
|
-
return {};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const token = resolveAccessToken(params.account);
|
|
95
|
-
const mediaInfoEndpoint = `https://graph.facebook.com/${params.account.outboundApiVersion}/${media.id}`;
|
|
96
|
-
let mediaInfoRes: Response;
|
|
97
|
-
try {
|
|
98
|
-
mediaInfoRes = await fetchWithRetry({
|
|
99
|
-
account: params.account,
|
|
100
|
-
url: mediaInfoEndpoint,
|
|
101
|
-
init: {
|
|
102
|
-
method: "GET",
|
|
103
|
-
headers: {
|
|
104
|
-
authorization: `Bearer ${token}`,
|
|
105
|
-
},
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
} catch (err) {
|
|
109
|
-
params.log?.warn?.(`[whatsapp-api] media metadata fetch failed for mediaId=${media.id}: ${String(err)}`);
|
|
110
|
-
return {};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const metaRaw = (await mediaInfoRes.json()) as unknown;
|
|
114
|
-
const meta = asRecord(metaRaw);
|
|
115
|
-
const mediaUrl = typeof meta?.url === "string" ? meta.url.trim() : "";
|
|
116
|
-
if (!mediaUrl) {
|
|
117
|
-
params.log?.warn?.(`[whatsapp-api] media metadata missing URL for mediaId=${media.id}`);
|
|
118
|
-
return {};
|
|
119
|
-
}
|
|
120
|
-
const mediaType =
|
|
121
|
-
(typeof meta?.mime_type === "string" && meta.mime_type.trim()) || media.mimeType || undefined;
|
|
122
|
-
|
|
123
|
-
let mediaRes: Response;
|
|
124
|
-
try {
|
|
125
|
-
mediaRes = await fetchWithRetry({
|
|
126
|
-
account: params.account,
|
|
127
|
-
url: mediaUrl,
|
|
128
|
-
init: {
|
|
129
|
-
method: "GET",
|
|
130
|
-
headers: {
|
|
131
|
-
authorization: `Bearer ${token}`,
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
});
|
|
135
|
-
} catch (err) {
|
|
136
|
-
params.log?.warn?.(`[whatsapp-api] media download failed for mediaId=${media.id}: ${String(err)}`);
|
|
137
|
-
return {};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const bytes = new Uint8Array(await mediaRes.arrayBuffer());
|
|
141
|
-
if (bytes.byteLength > params.account.mediaMaxBytes) {
|
|
142
|
-
params.log?.warn?.(
|
|
143
|
-
`[whatsapp-api] media too large for mediaId=${media.id}: ${bytes.byteLength} > ${params.account.mediaMaxBytes}`,
|
|
144
|
-
);
|
|
145
|
-
return {};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const dir = resolveMediaStorageDir(params.account);
|
|
149
|
-
await mkdir(dir, { recursive: true });
|
|
150
|
-
const extension = extensionFromMime(mediaType);
|
|
151
|
-
const baseName = sanitizeFileName(media.fileName || `${media.kind}-${media.id}${extension}`);
|
|
152
|
-
const filePath = path.join(dir, `${Date.now()}-${baseName}`);
|
|
153
|
-
await writeFile(filePath, bytes);
|
|
154
|
-
return {
|
|
155
|
-
mediaPath: filePath,
|
|
156
|
-
mediaType,
|
|
157
|
-
};
|
|
158
|
-
} catch (err) {
|
|
159
|
-
params.log?.warn?.(
|
|
160
|
-
`[whatsapp-api] media attachment resolution failed for messageId=${params.message.messageId}: ${String(err)}`,
|
|
161
|
-
);
|
|
162
|
-
return {};
|
|
163
|
-
}
|
|
164
|
-
}
|