@laburen/openclaw-plugin-whatsapp-api 0.0.1 → 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 -86
- package/src/channel.ts +0 -246
- package/src/inbound.ts +0 -126
- package/src/outbound.ts +0 -126
- package/src/runtime.ts +0 -18
- package/src/types.ts +0 -32
- package/src/webhook.ts +0 -119
package/index.js
ADDED
|
@@ -0,0 +1,1363 @@
|
|
|
1
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
//#region src/core/accounts.ts
|
|
7
|
+
/**
|
|
8
|
+
* SPDX-License-Identifier: MIT
|
|
9
|
+
*
|
|
10
|
+
* Account resolution for the WhatsApp API channel.
|
|
11
|
+
*
|
|
12
|
+
* Reads `channels.whatsapp-api` (and per-account entries) from Clawdbot config
|
|
13
|
+
* and returns fully resolved defaults for webhooks, Graph API, and media.
|
|
14
|
+
*/
|
|
15
|
+
const CHANNEL_ID$1 = "whatsapp-api";
|
|
16
|
+
const DEFAULT_WEBHOOK_PATH = "/webhook/whatsapp-api";
|
|
17
|
+
const DEFAULT_API_VERSION = "v22.0";
|
|
18
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
|
|
19
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
20
|
+
const DEFAULT_RETRY_BACKOFF_MS = 500;
|
|
21
|
+
const DEFAULT_DEDUPE_TTL_MS = 5 * 6e4;
|
|
22
|
+
const DEFAULT_MAX_BODY_BYTES = 512 * 1024;
|
|
23
|
+
const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
|
|
24
|
+
const DEFAULT_MEDIA_REQUEST_TIMEOUT_MS = 15e3;
|
|
25
|
+
function getChannelConfig(cfg) {
|
|
26
|
+
return (cfg.channels ?? {})[CHANNEL_ID$1] ?? {};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Lists configured WhatsApp API account ids.
|
|
30
|
+
*
|
|
31
|
+
* If `channels.whatsapp-api.accounts` is empty, returns the default account id
|
|
32
|
+
* so a single implicit account still works.
|
|
33
|
+
*
|
|
34
|
+
* @param cfg - Loaded Clawdbot / OpenClaw configuration
|
|
35
|
+
* @returns Distinct account ids
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* const ids = listAccountIds(cfg);
|
|
40
|
+
* // ['default'] or ['acct-a', 'acct-b']
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
function listAccountIds(cfg) {
|
|
44
|
+
const channelCfg = getChannelConfig(cfg);
|
|
45
|
+
const ids = /* @__PURE__ */ new Set();
|
|
46
|
+
const accounts = channelCfg.accounts ?? {};
|
|
47
|
+
if (Object.keys(accounts).length > 0) for (const id of Object.keys(accounts)) ids.add(id);
|
|
48
|
+
else ids.add(DEFAULT_ACCOUNT_ID);
|
|
49
|
+
return [...ids];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolves one account to effective settings (tokens, paths, limits).
|
|
53
|
+
*
|
|
54
|
+
* Unknown account ids fall back to an empty per-account object so channel-level
|
|
55
|
+
* keys still apply.
|
|
56
|
+
*
|
|
57
|
+
* @param cfg - Loaded configuration
|
|
58
|
+
* @param accountId - Account id, or `null`/`undefined` for default
|
|
59
|
+
* @returns Merged {@link ResolvedWhatsAppApiAccount}
|
|
60
|
+
*/
|
|
61
|
+
function resolveAccount(cfg, accountId) {
|
|
62
|
+
const channelCfg = getChannelConfig(cfg);
|
|
63
|
+
const id = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
64
|
+
const accountCfg = (channelCfg.accounts ?? {})[id] ?? {};
|
|
65
|
+
return {
|
|
66
|
+
accountId: id,
|
|
67
|
+
enabled: accountCfg.enabled ?? true,
|
|
68
|
+
webhookPath: accountCfg.webhookPath ?? channelCfg.webhookPath ?? DEFAULT_WEBHOOK_PATH,
|
|
69
|
+
inboundSharedSecret: accountCfg.inboundSharedSecret ?? channelCfg.inboundSharedSecret,
|
|
70
|
+
inboundAccessToken: accountCfg.inboundAccessToken ?? channelCfg.inboundAccessToken,
|
|
71
|
+
outboundPhoneNumberId: accountCfg.outboundPhoneNumberId ?? channelCfg.outboundPhoneNumberId,
|
|
72
|
+
outboundAccessToken: accountCfg.outboundAccessToken ?? channelCfg.outboundAccessToken,
|
|
73
|
+
outboundApiVersion: accountCfg.outboundApiVersion ?? channelCfg.outboundApiVersion ?? DEFAULT_API_VERSION,
|
|
74
|
+
requestTimeoutMs: accountCfg.requestTimeoutMs ?? channelCfg.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
75
|
+
maxRetries: accountCfg.maxRetries ?? channelCfg.maxRetries ?? DEFAULT_MAX_RETRIES,
|
|
76
|
+
retryBackoffMs: accountCfg.retryBackoffMs ?? channelCfg.retryBackoffMs ?? DEFAULT_RETRY_BACKOFF_MS,
|
|
77
|
+
dedupeTtlMs: accountCfg.dedupeTtlMs ?? channelCfg.dedupeTtlMs ?? DEFAULT_DEDUPE_TTL_MS,
|
|
78
|
+
maxBodyBytes: accountCfg.maxBodyBytes ?? channelCfg.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
|
|
79
|
+
mediaMaxBytes: accountCfg.mediaMaxBytes ?? channelCfg.mediaMaxBytes ?? DEFAULT_MEDIA_MAX_BYTES,
|
|
80
|
+
mediaRequestTimeoutMs: accountCfg.mediaRequestTimeoutMs ?? channelCfg.mediaRequestTimeoutMs ?? DEFAULT_MEDIA_REQUEST_TIMEOUT_MS,
|
|
81
|
+
mediaTempDir: accountCfg.mediaTempDir ?? channelCfg.mediaTempDir
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/utils/media/mime.ts
|
|
86
|
+
/**
|
|
87
|
+
* SPDX-License-Identifier: MIT
|
|
88
|
+
*
|
|
89
|
+
* MIME type and WhatsApp message kind inference from file names and MIME strings.
|
|
90
|
+
*/
|
|
91
|
+
const MIME_BY_EXT = {
|
|
92
|
+
".jpg": "image/jpeg",
|
|
93
|
+
".jpeg": "image/jpeg",
|
|
94
|
+
".png": "image/png",
|
|
95
|
+
".webp": "image/webp",
|
|
96
|
+
".gif": "image/gif",
|
|
97
|
+
".mp4": "video/mp4",
|
|
98
|
+
".mov": "video/quicktime",
|
|
99
|
+
".webm": "video/webm",
|
|
100
|
+
".mp3": "audio/mpeg",
|
|
101
|
+
".wav": "audio/wav",
|
|
102
|
+
".ogg": "audio/ogg",
|
|
103
|
+
".opus": "audio/ogg",
|
|
104
|
+
".m4a": "audio/mp4",
|
|
105
|
+
".pdf": "application/pdf",
|
|
106
|
+
".txt": "text/plain",
|
|
107
|
+
".csv": "text/csv",
|
|
108
|
+
".json": "application/json",
|
|
109
|
+
".zip": "application/zip"
|
|
110
|
+
};
|
|
111
|
+
function normalizeMime(value) {
|
|
112
|
+
const trimmed = value?.trim();
|
|
113
|
+
if (!trimmed) return;
|
|
114
|
+
return trimmed.split(";")[0]?.trim().toLowerCase();
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Maps MIME type and/or file extension to a WhatsApp Cloud API `type` bucket.
|
|
118
|
+
*
|
|
119
|
+
* @param params.mimeType - Optional raw MIME (e.g. from Graph)
|
|
120
|
+
* @param params.fileNameOrUrl - Used for extension fallback when MIME is absent
|
|
121
|
+
* @returns One of `image`, `video`, `audio`, or `document`
|
|
122
|
+
*/
|
|
123
|
+
function mediaKindFromMimeOrName(params) {
|
|
124
|
+
const mime = normalizeMime(params.mimeType);
|
|
125
|
+
if (mime?.startsWith("image/")) return "image";
|
|
126
|
+
if (mime?.startsWith("video/")) return "video";
|
|
127
|
+
if (mime?.startsWith("audio/")) return "audio";
|
|
128
|
+
const extMime = MIME_BY_EXT[path.extname(params.fileNameOrUrl ?? "").toLowerCase()];
|
|
129
|
+
if (extMime?.startsWith("image/")) return "image";
|
|
130
|
+
if (extMime?.startsWith("video/")) return "video";
|
|
131
|
+
if (extMime?.startsWith("audio/")) return "audio";
|
|
132
|
+
return "document";
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Looks up a MIME type from a file path or name extension.
|
|
136
|
+
*
|
|
137
|
+
* @param fileNameOrPath - Path or URL ending with an extension
|
|
138
|
+
*/
|
|
139
|
+
function mimeFromFileName(fileNameOrPath) {
|
|
140
|
+
return MIME_BY_EXT[path.extname(fileNameOrPath).toLowerCase()];
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Picks a file extension for a MIME type (for saving inbound media to disk).
|
|
144
|
+
*
|
|
145
|
+
* @param mimeType - MIME string; unknown types map to `.bin`
|
|
146
|
+
*/
|
|
147
|
+
function extensionFromMime(mimeType) {
|
|
148
|
+
const mime = normalizeMime(mimeType);
|
|
149
|
+
if (!mime) return ".bin";
|
|
150
|
+
return Object.entries(MIME_BY_EXT).find(([, value]) => value === mime)?.[0] ?? ".bin";
|
|
151
|
+
}
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/utils/outbound/payload.ts
|
|
154
|
+
function toTrimmedString(value) {
|
|
155
|
+
if (typeof value !== "string") return "";
|
|
156
|
+
return value.trim();
|
|
157
|
+
}
|
|
158
|
+
function asRecord$1(value) {
|
|
159
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
function parseNumber(value) {
|
|
163
|
+
const numeric = Number(value);
|
|
164
|
+
return Number.isFinite(numeric) ? numeric : void 0;
|
|
165
|
+
}
|
|
166
|
+
function getWhatsAppPayload(record) {
|
|
167
|
+
const whatsapp = asRecord$1(record.whatsapp) ?? {};
|
|
168
|
+
return {
|
|
169
|
+
...asRecord$1((asRecord$1(record.channelData) ?? {}).whatsapp) ?? {},
|
|
170
|
+
...whatsapp
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function normalizeOutboundLocation(record) {
|
|
174
|
+
const whatsapp = getWhatsAppPayload(record);
|
|
175
|
+
const raw = asRecord$1(record.location) ?? asRecord$1(whatsapp.location);
|
|
176
|
+
if (!raw) return;
|
|
177
|
+
const latitude = parseNumber(raw.latitude ?? raw.lat);
|
|
178
|
+
const longitude = parseNumber(raw.longitude ?? raw.lng ?? raw.lon);
|
|
179
|
+
if (latitude === void 0 || longitude === void 0) return;
|
|
180
|
+
return {
|
|
181
|
+
latitude,
|
|
182
|
+
longitude,
|
|
183
|
+
name: toTrimmedString(raw.name || raw.title) || void 0,
|
|
184
|
+
address: toTrimmedString(raw.address) || void 0
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function normalizeSingleContact(value) {
|
|
188
|
+
const raw = asRecord$1(value);
|
|
189
|
+
if (!raw) return;
|
|
190
|
+
const nameRecord = asRecord$1(raw.name);
|
|
191
|
+
const firstName = toTrimmedString(nameRecord?.first_name ?? raw.first_name ?? raw.firstName);
|
|
192
|
+
const lastName = toTrimmedString(nameRecord?.last_name ?? raw.last_name ?? raw.lastName);
|
|
193
|
+
const formattedName = toTrimmedString(nameRecord?.formatted_name) || toTrimmedString(raw.formatted_name || raw.formattedName || raw.display_name || raw.displayName) || [firstName, lastName].filter(Boolean).join(" ") || toTrimmedString(raw.phone || raw.msisdn);
|
|
194
|
+
if (!formattedName) return;
|
|
195
|
+
const phones = /* @__PURE__ */ new Set();
|
|
196
|
+
const directPhone = toTrimmedString(raw.phone || raw.msisdn);
|
|
197
|
+
if (directPhone) phones.add(directPhone);
|
|
198
|
+
const rawPhones = Array.isArray(raw.phones) ? raw.phones : [];
|
|
199
|
+
for (const rawPhone of rawPhones) {
|
|
200
|
+
if (typeof rawPhone === "string") {
|
|
201
|
+
const cleaned = toTrimmedString(rawPhone);
|
|
202
|
+
if (cleaned) phones.add(cleaned);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const cleaned = toTrimmedString(asRecord$1(rawPhone)?.phone);
|
|
206
|
+
if (cleaned) phones.add(cleaned);
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
name: {
|
|
210
|
+
formatted_name: formattedName,
|
|
211
|
+
first_name: firstName || void 0,
|
|
212
|
+
last_name: lastName || void 0
|
|
213
|
+
},
|
|
214
|
+
phones: phones.size > 0 ? [...phones].map((phone) => ({ phone })) : void 0
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function normalizeOutboundContacts(record) {
|
|
218
|
+
const whatsapp = getWhatsAppPayload(record);
|
|
219
|
+
const rawContacts = Array.isArray(record.contacts) && record.contacts || Array.isArray(whatsapp.contacts) && whatsapp.contacts || [];
|
|
220
|
+
const contacts = [];
|
|
221
|
+
for (const rawContact of rawContacts) {
|
|
222
|
+
const normalized = normalizeSingleContact(rawContact);
|
|
223
|
+
if (normalized) contacts.push(normalized);
|
|
224
|
+
}
|
|
225
|
+
const singleContact = normalizeSingleContact(record.contact) ?? normalizeSingleContact(whatsapp.contact);
|
|
226
|
+
if (singleContact) contacts.push(singleContact);
|
|
227
|
+
return contacts;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Extracts text, `mediaUrl`/`mediaUrls`, location, and contacts from a reply payload.
|
|
231
|
+
*
|
|
232
|
+
* Supports nested `whatsapp` / `channelData.whatsapp` objects used by multi-channel hosts.
|
|
233
|
+
*
|
|
234
|
+
* @param payload - Arbitrary reply payload from the agent/dispatcher
|
|
235
|
+
* @returns Safe defaults (empty strings/arrays) when shape is unknown
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```typescript
|
|
239
|
+
* const n = normalizeOutboundPayload({ text: "Hi", mediaUrl: "https://..." });
|
|
240
|
+
* // n.text, n.mediaUrls, n.location, n.contacts
|
|
241
|
+
* ```
|
|
242
|
+
*/
|
|
243
|
+
function normalizeOutboundPayload(payload) {
|
|
244
|
+
if (!payload || typeof payload !== "object") return {
|
|
245
|
+
text: "",
|
|
246
|
+
mediaUrls: [],
|
|
247
|
+
contacts: []
|
|
248
|
+
};
|
|
249
|
+
const record = payload;
|
|
250
|
+
const text = toTrimmedString(record.text) || toTrimmedString(record.body);
|
|
251
|
+
const mediaUrls = /* @__PURE__ */ new Set();
|
|
252
|
+
const mediaUrl = toTrimmedString(record.mediaUrl);
|
|
253
|
+
if (mediaUrl) mediaUrls.add(mediaUrl);
|
|
254
|
+
if (Array.isArray(record.mediaUrls)) for (const value of record.mediaUrls) {
|
|
255
|
+
const url = toTrimmedString(value);
|
|
256
|
+
if (url) mediaUrls.add(url);
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
text,
|
|
260
|
+
mediaUrls: [...mediaUrls],
|
|
261
|
+
location: normalizeOutboundLocation(record),
|
|
262
|
+
contacts: normalizeOutboundContacts(record)
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/outbound.ts
|
|
267
|
+
function sleep$1(ms) {
|
|
268
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
269
|
+
}
|
|
270
|
+
function normalizeRecipient(to) {
|
|
271
|
+
const trimmed = to.trim();
|
|
272
|
+
if (trimmed.startsWith("+")) return trimmed.slice(1);
|
|
273
|
+
return trimmed;
|
|
274
|
+
}
|
|
275
|
+
function readGraphError(bodyText) {
|
|
276
|
+
try {
|
|
277
|
+
return JSON.parse(bodyText).error?.message ?? null;
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function isRetryableStatus$1(status) {
|
|
283
|
+
return status === 429 || status >= 500;
|
|
284
|
+
}
|
|
285
|
+
async function withTimeout$1(input, init, timeoutMs) {
|
|
286
|
+
const controller = new AbortController();
|
|
287
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
288
|
+
try {
|
|
289
|
+
return await fetch(input, {
|
|
290
|
+
...init,
|
|
291
|
+
signal: controller.signal
|
|
292
|
+
});
|
|
293
|
+
} finally {
|
|
294
|
+
clearTimeout(timer);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function readRetryAfterMs(res) {
|
|
298
|
+
const value = res.headers.get("retry-after");
|
|
299
|
+
if (!value) return null;
|
|
300
|
+
const seconds = Number(value);
|
|
301
|
+
if (Number.isFinite(seconds) && seconds > 0) return Math.ceil(seconds * 1e3);
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
function resolveGraphAuth(account) {
|
|
305
|
+
const phoneNumberId = account.outboundPhoneNumberId?.trim();
|
|
306
|
+
const accessToken = account.outboundAccessToken?.trim();
|
|
307
|
+
if (!phoneNumberId || !accessToken) throw new Error("Missing outboundPhoneNumberId/outboundAccessToken in whatsapp-api account config");
|
|
308
|
+
return {
|
|
309
|
+
phoneNumberId,
|
|
310
|
+
accessToken
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
async function sendWithRetry(params) {
|
|
314
|
+
let lastErr = null;
|
|
315
|
+
const maxAttempts = Math.max(1, params.account.maxRetries + 1);
|
|
316
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) try {
|
|
317
|
+
const res = await withTimeout$1(params.endpoint, {
|
|
318
|
+
method: "POST",
|
|
319
|
+
headers: {
|
|
320
|
+
authorization: `Bearer ${params.accessToken}`,
|
|
321
|
+
"content-type": "application/json"
|
|
322
|
+
},
|
|
323
|
+
body: JSON.stringify(params.payload)
|
|
324
|
+
}, params.account.requestTimeoutMs);
|
|
325
|
+
const bodyText = await res.text();
|
|
326
|
+
if (!res.ok) {
|
|
327
|
+
const detail = readGraphError(bodyText);
|
|
328
|
+
const err = /* @__PURE__ */ new Error(`Meta API send failed: HTTP ${res.status}${detail ? ` - ${detail}` : ""}`);
|
|
329
|
+
if (!isRetryableStatus$1(res.status)) throw err;
|
|
330
|
+
lastErr = err;
|
|
331
|
+
const retryAfterMs = readRetryAfterMs(res);
|
|
332
|
+
if (attempt < maxAttempts) await sleep$1(retryAfterMs ?? params.account.retryBackoffMs * attempt);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const messageId = JSON.parse(bodyText).messages?.[0]?.id;
|
|
336
|
+
return messageId && messageId.trim() ? messageId.trim() : "unknown";
|
|
337
|
+
} catch (err) {
|
|
338
|
+
lastErr = err;
|
|
339
|
+
if (attempt >= maxAttempts) break;
|
|
340
|
+
await sleep$1(params.account.retryBackoffMs * attempt);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? "Unknown outbound error"));
|
|
344
|
+
}
|
|
345
|
+
async function uploadMediaFromLocalPath(params) {
|
|
346
|
+
const bytes = await readFile(params.localPath);
|
|
347
|
+
if (bytes.byteLength > params.account.mediaMaxBytes) throw new Error(`Local media exceeds mediaMaxBytes (${bytes.byteLength} > ${params.account.mediaMaxBytes})`);
|
|
348
|
+
const fileName = params.localPath.split("/").pop() ?? "media.bin";
|
|
349
|
+
const mimeType = mimeFromFileName(fileName);
|
|
350
|
+
const uploadEndpoint = params.endpoint.replace(/\/messages$/, "/media");
|
|
351
|
+
const maxAttempts = Math.max(1, params.account.maxRetries + 1);
|
|
352
|
+
let lastErr = null;
|
|
353
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
354
|
+
try {
|
|
355
|
+
const form = new FormData();
|
|
356
|
+
form.append("messaging_product", "whatsapp");
|
|
357
|
+
form.append("type", mimeType ?? "application/octet-stream");
|
|
358
|
+
form.append("file", new Blob([bytes], { type: mimeType ?? "application/octet-stream" }), fileName);
|
|
359
|
+
const res = await withTimeout$1(uploadEndpoint, {
|
|
360
|
+
method: "POST",
|
|
361
|
+
headers: { authorization: `Bearer ${params.accessToken}` },
|
|
362
|
+
body: form
|
|
363
|
+
}, params.account.requestTimeoutMs);
|
|
364
|
+
const bodyText = await res.text();
|
|
365
|
+
if (!res.ok) {
|
|
366
|
+
const err = /* @__PURE__ */ new Error(`Meta media upload failed: HTTP ${res.status} ${bodyText}`);
|
|
367
|
+
if (!isRetryableStatus$1(res.status)) throw err;
|
|
368
|
+
lastErr = err;
|
|
369
|
+
} else {
|
|
370
|
+
const parsed = JSON.parse(bodyText);
|
|
371
|
+
if (!parsed.id?.trim()) throw new Error("Meta media upload did not return media id");
|
|
372
|
+
return {
|
|
373
|
+
id: parsed.id.trim(),
|
|
374
|
+
mimeType
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
} catch (err) {
|
|
378
|
+
lastErr = err;
|
|
379
|
+
}
|
|
380
|
+
if (attempt < maxAttempts) await sleep$1(params.account.retryBackoffMs * attempt);
|
|
381
|
+
}
|
|
382
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? "Unknown media upload error"));
|
|
383
|
+
}
|
|
384
|
+
function isLocalMediaPath(value) {
|
|
385
|
+
return value.startsWith("/") || value.toLowerCase().startsWith("file://");
|
|
386
|
+
}
|
|
387
|
+
function toLocalPath(value) {
|
|
388
|
+
if (value.toLowerCase().startsWith("file://")) return decodeURIComponent(value.replace(/^file:\/\//i, ""));
|
|
389
|
+
return value;
|
|
390
|
+
}
|
|
391
|
+
async function sendWhatsAppApiMedia(params) {
|
|
392
|
+
const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
|
|
393
|
+
const endpoint = `https://graph.facebook.com/${params.account.outboundApiVersion}/${phoneNumberId}/messages`;
|
|
394
|
+
const kind = mediaKindFromMimeOrName({ fileNameOrUrl: params.mediaUrl });
|
|
395
|
+
let mediaPayload;
|
|
396
|
+
if (isLocalMediaPath(params.mediaUrl)) {
|
|
397
|
+
const localPath = toLocalPath(params.mediaUrl);
|
|
398
|
+
const uploaded = await uploadMediaFromLocalPath({
|
|
399
|
+
endpoint,
|
|
400
|
+
account: params.account,
|
|
401
|
+
accessToken,
|
|
402
|
+
localPath,
|
|
403
|
+
kind
|
|
404
|
+
});
|
|
405
|
+
mediaPayload = kind === "document" ? {
|
|
406
|
+
id: uploaded.id,
|
|
407
|
+
filename: localPath.split("/").pop() ?? "document"
|
|
408
|
+
} : { id: uploaded.id };
|
|
409
|
+
} else mediaPayload = kind === "document" ? {
|
|
410
|
+
link: params.mediaUrl,
|
|
411
|
+
filename: params.mediaUrl.split("/").pop() ?? "document"
|
|
412
|
+
} : { link: params.mediaUrl };
|
|
413
|
+
if (params.text && (kind === "image" || kind === "video" || kind === "document")) mediaPayload.caption = params.text;
|
|
414
|
+
return await sendWithRetry({
|
|
415
|
+
endpoint,
|
|
416
|
+
account: params.account,
|
|
417
|
+
accessToken,
|
|
418
|
+
payload: {
|
|
419
|
+
messaging_product: "whatsapp",
|
|
420
|
+
to: normalizeRecipient(params.to),
|
|
421
|
+
type: kind,
|
|
422
|
+
[kind]: mediaPayload
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
async function sendWhatsAppApiLocation(params) {
|
|
427
|
+
const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
|
|
428
|
+
return await sendWithRetry({
|
|
429
|
+
endpoint: `https://graph.facebook.com/${params.account.outboundApiVersion}/${phoneNumberId}/messages`,
|
|
430
|
+
account: params.account,
|
|
431
|
+
accessToken,
|
|
432
|
+
payload: {
|
|
433
|
+
messaging_product: "whatsapp",
|
|
434
|
+
to: normalizeRecipient(params.to),
|
|
435
|
+
type: "location",
|
|
436
|
+
location: {
|
|
437
|
+
latitude: params.location.latitude,
|
|
438
|
+
longitude: params.location.longitude,
|
|
439
|
+
name: params.location.name,
|
|
440
|
+
address: params.location.address
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
async function sendWhatsAppApiContacts(params) {
|
|
446
|
+
const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
|
|
447
|
+
return await sendWithRetry({
|
|
448
|
+
endpoint: `https://graph.facebook.com/${params.account.outboundApiVersion}/${phoneNumberId}/messages`,
|
|
449
|
+
account: params.account,
|
|
450
|
+
accessToken,
|
|
451
|
+
payload: {
|
|
452
|
+
messaging_product: "whatsapp",
|
|
453
|
+
to: normalizeRecipient(params.to),
|
|
454
|
+
type: "contacts",
|
|
455
|
+
contacts: params.contacts
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Sends a plain text message via the Cloud API.
|
|
461
|
+
*
|
|
462
|
+
* @param params - Recipient, body, and resolved account (tokens + API version)
|
|
463
|
+
* @returns Graph message id (or `"unknown"` if the response omits it)
|
|
464
|
+
* @throws If outbound credentials are missing or the recipient is empty
|
|
465
|
+
* @throws On non-retryable HTTP errors or exhausted retries
|
|
466
|
+
*/
|
|
467
|
+
async function sendWhatsAppApiText(params) {
|
|
468
|
+
const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
|
|
469
|
+
const recipient = normalizeRecipient(params.to);
|
|
470
|
+
if (!recipient) throw new Error("Recipient id is empty");
|
|
471
|
+
const endpoint = `https://graph.facebook.com/${params.account.outboundApiVersion}/${phoneNumberId}/messages`;
|
|
472
|
+
const payload = {
|
|
473
|
+
messaging_product: "whatsapp",
|
|
474
|
+
to: recipient,
|
|
475
|
+
type: "text",
|
|
476
|
+
text: { body: params.text }
|
|
477
|
+
};
|
|
478
|
+
return await sendWithRetry({
|
|
479
|
+
endpoint,
|
|
480
|
+
account: params.account,
|
|
481
|
+
accessToken,
|
|
482
|
+
payload
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Sends one or more Graph messages derived from a structured reply payload.
|
|
487
|
+
*
|
|
488
|
+
* May send text only, multiple media messages, a location, and/or contacts vCard payloads.
|
|
489
|
+
*
|
|
490
|
+
* @param params.to - WhatsApp user id (digits; leading `+` is stripped upstream)
|
|
491
|
+
* @param params.payload - Same shape as {@link normalizeOutboundPayload} accepts
|
|
492
|
+
* @param params.account - Resolved account for Graph auth and limits
|
|
493
|
+
* @returns Message ids for each Graph call; empty array if there is nothing to send
|
|
494
|
+
*/
|
|
495
|
+
async function sendWhatsAppApiReplyPayload(params) {
|
|
496
|
+
const normalized = normalizeOutboundPayload(params.payload);
|
|
497
|
+
if (!normalized.text && normalized.mediaUrls.length === 0 && !normalized.location && normalized.contacts.length === 0) return [];
|
|
498
|
+
if (normalized.mediaUrls.length === 0 && !normalized.location && normalized.contacts.length === 0) return [await sendWhatsAppApiText({
|
|
499
|
+
to: params.to,
|
|
500
|
+
text: normalized.text,
|
|
501
|
+
account: params.account
|
|
502
|
+
})];
|
|
503
|
+
const sentIds = [];
|
|
504
|
+
if (normalized.text && normalized.mediaUrls.length === 0) sentIds.push(await sendWhatsAppApiText({
|
|
505
|
+
to: params.to,
|
|
506
|
+
text: normalized.text,
|
|
507
|
+
account: params.account
|
|
508
|
+
}));
|
|
509
|
+
for (const [index, mediaUrl] of normalized.mediaUrls.entries()) {
|
|
510
|
+
const messageId = await sendWhatsAppApiMedia({
|
|
511
|
+
to: params.to,
|
|
512
|
+
mediaUrl,
|
|
513
|
+
text: index === 0 ? normalized.text : void 0,
|
|
514
|
+
account: params.account
|
|
515
|
+
});
|
|
516
|
+
sentIds.push(messageId);
|
|
517
|
+
}
|
|
518
|
+
if (normalized.location) sentIds.push(await sendWhatsAppApiLocation({
|
|
519
|
+
to: params.to,
|
|
520
|
+
location: normalized.location,
|
|
521
|
+
account: params.account
|
|
522
|
+
}));
|
|
523
|
+
if (normalized.contacts.length > 0) sentIds.push(await sendWhatsAppApiContacts({
|
|
524
|
+
to: params.to,
|
|
525
|
+
contacts: normalized.contacts,
|
|
526
|
+
account: params.account
|
|
527
|
+
}));
|
|
528
|
+
return sentIds;
|
|
529
|
+
}
|
|
530
|
+
//#endregion
|
|
531
|
+
//#region src/core/runtime.ts
|
|
532
|
+
let pluginApi = null;
|
|
533
|
+
/**
|
|
534
|
+
* Stores the plugin API for the lifetime of the process (set from `register()`).
|
|
535
|
+
*
|
|
536
|
+
* @param api - OpenClaw plugin API instance
|
|
537
|
+
*/
|
|
538
|
+
function setPluginApi(api) {
|
|
539
|
+
pluginApi = api;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Returns the stored plugin API.
|
|
543
|
+
*
|
|
544
|
+
* @returns The API previously passed to {@link setPluginApi}
|
|
545
|
+
* @throws If {@link setPluginApi} has not been called yet
|
|
546
|
+
*/
|
|
547
|
+
function getPluginApi() {
|
|
548
|
+
if (!pluginApi) throw new Error("whatsapp-api plugin API not initialized");
|
|
549
|
+
return pluginApi;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Convenience accessor for `getPluginApi().runtime` (config, logging, channel hooks).
|
|
553
|
+
*
|
|
554
|
+
* @returns OpenClaw plugin runtime
|
|
555
|
+
* @throws If the plugin API was not initialised
|
|
556
|
+
*/
|
|
557
|
+
function getPluginRuntime() {
|
|
558
|
+
return getPluginApi().runtime;
|
|
559
|
+
}
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region src/utils/common.ts
|
|
562
|
+
/**
|
|
563
|
+
* SPDX-License-Identifier: MIT
|
|
564
|
+
*
|
|
565
|
+
* Small type-narrowing helpers for parsing untrusted JSON (webhooks, payloads).
|
|
566
|
+
*/
|
|
567
|
+
/**
|
|
568
|
+
* Narrows a value to a plain object record (not array, not null).
|
|
569
|
+
*
|
|
570
|
+
* @param value - Typically a JSON subtree
|
|
571
|
+
* @returns The object, or `null` if not a non-array object
|
|
572
|
+
*/
|
|
573
|
+
function asRecord(value) {
|
|
574
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
575
|
+
return value;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Returns the value if it is an array, otherwise an empty array.
|
|
579
|
+
*
|
|
580
|
+
* @param value - Any JSON value
|
|
581
|
+
*/
|
|
582
|
+
function asArray(value) {
|
|
583
|
+
return Array.isArray(value) ? value : [];
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Returns a non-empty trimmed string, or `undefined`.
|
|
587
|
+
*
|
|
588
|
+
* @param value - Any JSON value
|
|
589
|
+
*/
|
|
590
|
+
function asTrimmedString(value) {
|
|
591
|
+
if (typeof value !== "string") return;
|
|
592
|
+
return value.trim() || void 0;
|
|
593
|
+
}
|
|
594
|
+
//#endregion
|
|
595
|
+
//#region src/core/wa-logger.ts
|
|
596
|
+
const CYAN = "\x1B[36m";
|
|
597
|
+
const YELLOW = "\x1B[33m";
|
|
598
|
+
const RED = "\x1B[31m";
|
|
599
|
+
const GRAY = "\x1B[90m";
|
|
600
|
+
const RESET = "\x1B[0m";
|
|
601
|
+
function consoleFallback(subsystem) {
|
|
602
|
+
const tag = `whatsapp-api/${subsystem}`;
|
|
603
|
+
return {
|
|
604
|
+
debug: (msg, meta) => console.debug(`${GRAY}[${tag}]${RESET}`, msg, ...meta ? [meta] : []),
|
|
605
|
+
info: (msg, meta) => console.log(`${CYAN}[${tag}]${RESET}`, msg, ...meta ? [meta] : []),
|
|
606
|
+
warn: (msg, meta) => console.warn(`${YELLOW}[${tag}]${RESET}`, msg, ...meta ? [meta] : []),
|
|
607
|
+
error: (msg, meta) => console.error(`${RED}[${tag}]${RESET}`, msg, ...meta ? [meta] : [])
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
function resolveRuntimeLogger(subsystem) {
|
|
611
|
+
try {
|
|
612
|
+
return getPluginRuntime().logging.getChildLogger({ subsystem: `whatsapp-api/${subsystem}` });
|
|
613
|
+
} catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
function formatMessage(message, meta) {
|
|
618
|
+
if (!meta || Object.keys(meta).length === 0) return message;
|
|
619
|
+
const parts = Object.entries(meta).map(([k, v]) => {
|
|
620
|
+
if (v === void 0 || v === null) return null;
|
|
621
|
+
if (typeof v === "object") return `${k}=${JSON.stringify(v)}`;
|
|
622
|
+
return `${k}=${v}`;
|
|
623
|
+
}).filter(Boolean);
|
|
624
|
+
return parts.length > 0 ? `${message} (${parts.join(", ")})` : message;
|
|
625
|
+
}
|
|
626
|
+
function createWaLogger(subsystem) {
|
|
627
|
+
let cachedLogger = null;
|
|
628
|
+
let resolved = false;
|
|
629
|
+
function getLogger() {
|
|
630
|
+
if (!resolved) {
|
|
631
|
+
cachedLogger = resolveRuntimeLogger(subsystem);
|
|
632
|
+
if (cachedLogger) resolved = true;
|
|
633
|
+
}
|
|
634
|
+
return cachedLogger ?? consoleFallback(subsystem);
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
subsystem,
|
|
638
|
+
debug(message, meta) {
|
|
639
|
+
getLogger().debug?.(formatMessage(message, meta), meta);
|
|
640
|
+
},
|
|
641
|
+
info(message, meta) {
|
|
642
|
+
getLogger().info(formatMessage(message, meta), meta);
|
|
643
|
+
},
|
|
644
|
+
warn(message, meta) {
|
|
645
|
+
getLogger().warn(formatMessage(message, meta), meta);
|
|
646
|
+
},
|
|
647
|
+
error(message, meta) {
|
|
648
|
+
getLogger().error(formatMessage(message, meta), meta);
|
|
649
|
+
},
|
|
650
|
+
child(name) {
|
|
651
|
+
return createWaLogger(`${subsystem}/${name}`);
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Creates a logger for a logical subsystem (e.g. `"webhook"`, `"channel/gateway"`).
|
|
657
|
+
*
|
|
658
|
+
* @param subsystem - Short name; prefixed with `whatsapp-api/` for routing
|
|
659
|
+
* @returns Logger with `debug`/`info`/`warn`/`error` and `child()`
|
|
660
|
+
*
|
|
661
|
+
* @example
|
|
662
|
+
* ```typescript
|
|
663
|
+
* const log = waLogger("channel/gateway");
|
|
664
|
+
* log.info("registered HTTP route", { path: routePath });
|
|
665
|
+
* ```
|
|
666
|
+
*/
|
|
667
|
+
function waLogger(subsystem) {
|
|
668
|
+
return createWaLogger(subsystem);
|
|
669
|
+
}
|
|
670
|
+
//#endregion
|
|
671
|
+
//#region src/utils/media/inbound.ts
|
|
672
|
+
/**
|
|
673
|
+
* SPDX-License-Identifier: MIT
|
|
674
|
+
*
|
|
675
|
+
* Downloads inbound media from the Graph API and writes a temp file for the agent.
|
|
676
|
+
*
|
|
677
|
+
* Uses `inboundAccessToken` or falls back to `outboundAccessToken` for bearer auth.
|
|
678
|
+
*/
|
|
679
|
+
const mediaLog = waLogger("media/inbound");
|
|
680
|
+
function sleep(ms) {
|
|
681
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
682
|
+
}
|
|
683
|
+
function isRetryableStatus(status) {
|
|
684
|
+
return status === 429 || status >= 500;
|
|
685
|
+
}
|
|
686
|
+
async function withTimeout(input, init, timeoutMs) {
|
|
687
|
+
const controller = new AbortController();
|
|
688
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
689
|
+
try {
|
|
690
|
+
return await fetch(input, {
|
|
691
|
+
...init,
|
|
692
|
+
signal: controller.signal
|
|
693
|
+
});
|
|
694
|
+
} finally {
|
|
695
|
+
clearTimeout(timer);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
async function fetchWithRetry(params) {
|
|
699
|
+
const maxAttempts = Math.max(1, params.account.maxRetries + 1);
|
|
700
|
+
let lastErr = null;
|
|
701
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
702
|
+
try {
|
|
703
|
+
const res = await withTimeout(params.url, params.init, Math.max(500, params.account.mediaRequestTimeoutMs));
|
|
704
|
+
if (res.ok) return res;
|
|
705
|
+
const body = await res.text();
|
|
706
|
+
const err = /* @__PURE__ */ new Error(`HTTP ${res.status} ${body}`);
|
|
707
|
+
if (!isRetryableStatus(res.status)) throw err;
|
|
708
|
+
lastErr = err;
|
|
709
|
+
} catch (err) {
|
|
710
|
+
lastErr = err;
|
|
711
|
+
}
|
|
712
|
+
if (attempt < maxAttempts) await sleep(params.account.retryBackoffMs * attempt);
|
|
713
|
+
}
|
|
714
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? "Unknown media error"));
|
|
715
|
+
}
|
|
716
|
+
function resolveAccessToken(account) {
|
|
717
|
+
const token = account.inboundAccessToken?.trim() ?? account.outboundAccessToken?.trim();
|
|
718
|
+
if (!token) throw new Error("Missing inboundAccessToken/outboundAccessToken for media download");
|
|
719
|
+
return token;
|
|
720
|
+
}
|
|
721
|
+
function resolveMediaStorageDir(account) {
|
|
722
|
+
const root = account.mediaTempDir?.trim() || path.join(tmpdir(), "openclaw-whatsapp-api-media");
|
|
723
|
+
return path.join(root, account.accountId);
|
|
724
|
+
}
|
|
725
|
+
function sanitizeFileName(base) {
|
|
726
|
+
return base.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Resolves `message.media` to a local file path for OpenClaw's inbound context.
|
|
730
|
+
*
|
|
731
|
+
* On any failure (missing token, Graph error, size limit), returns `{}` and logs.
|
|
732
|
+
*
|
|
733
|
+
* @param params.account - Resolved account (tokens, version, media limits, temp dir)
|
|
734
|
+
* @param params.message - Parsed inbound message (must include `media.id` to fetch)
|
|
735
|
+
* @param params.log - Optional warn sink (e.g. channel logger)
|
|
736
|
+
* @returns `mediaPath` and `mediaType` when a file was written; otherwise empty object
|
|
737
|
+
*/
|
|
738
|
+
async function downloadInboundMediaAttachment(params) {
|
|
739
|
+
try {
|
|
740
|
+
const media = params.message.media;
|
|
741
|
+
if (!media?.id) return {};
|
|
742
|
+
const token = resolveAccessToken(params.account);
|
|
743
|
+
const mediaInfoEndpoint = `https://graph.facebook.com/${params.account.outboundApiVersion}/${media.id}`;
|
|
744
|
+
let mediaInfoRes;
|
|
745
|
+
try {
|
|
746
|
+
mediaInfoRes = await fetchWithRetry({
|
|
747
|
+
account: params.account,
|
|
748
|
+
url: mediaInfoEndpoint,
|
|
749
|
+
init: {
|
|
750
|
+
method: "GET",
|
|
751
|
+
headers: { authorization: `Bearer ${token}` }
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
} catch (err) {
|
|
755
|
+
mediaLog.warn("media metadata fetch failed", {
|
|
756
|
+
mediaId: media.id,
|
|
757
|
+
error: String(err)
|
|
758
|
+
});
|
|
759
|
+
return {};
|
|
760
|
+
}
|
|
761
|
+
const meta = asRecord(await mediaInfoRes.json());
|
|
762
|
+
const mediaUrl = typeof meta?.url === "string" ? meta.url.trim() : "";
|
|
763
|
+
if (!mediaUrl) {
|
|
764
|
+
mediaLog.warn("media metadata missing URL", { mediaId: media.id });
|
|
765
|
+
return {};
|
|
766
|
+
}
|
|
767
|
+
const mediaType = typeof meta?.mime_type === "string" && meta.mime_type.trim() || media.mimeType || void 0;
|
|
768
|
+
let mediaRes;
|
|
769
|
+
try {
|
|
770
|
+
mediaRes = await fetchWithRetry({
|
|
771
|
+
account: params.account,
|
|
772
|
+
url: mediaUrl,
|
|
773
|
+
init: {
|
|
774
|
+
method: "GET",
|
|
775
|
+
headers: { authorization: `Bearer ${token}` }
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
} catch (err) {
|
|
779
|
+
mediaLog.warn("media download failed", {
|
|
780
|
+
mediaId: media.id,
|
|
781
|
+
error: String(err)
|
|
782
|
+
});
|
|
783
|
+
return {};
|
|
784
|
+
}
|
|
785
|
+
const bytes = new Uint8Array(await mediaRes.arrayBuffer());
|
|
786
|
+
if (bytes.byteLength > params.account.mediaMaxBytes) {
|
|
787
|
+
mediaLog.warn("media too large", {
|
|
788
|
+
mediaId: media.id,
|
|
789
|
+
size: bytes.byteLength,
|
|
790
|
+
limit: params.account.mediaMaxBytes
|
|
791
|
+
});
|
|
792
|
+
return {};
|
|
793
|
+
}
|
|
794
|
+
const dir = resolveMediaStorageDir(params.account);
|
|
795
|
+
await mkdir(dir, { recursive: true });
|
|
796
|
+
const extension = extensionFromMime(mediaType);
|
|
797
|
+
const baseName = sanitizeFileName(media.fileName || `${media.kind}-${media.id}${extension}`);
|
|
798
|
+
const filePath = path.join(dir, `${Date.now()}-${baseName}`);
|
|
799
|
+
await writeFile(filePath, bytes);
|
|
800
|
+
return {
|
|
801
|
+
mediaPath: filePath,
|
|
802
|
+
mediaType
|
|
803
|
+
};
|
|
804
|
+
} catch (err) {
|
|
805
|
+
mediaLog.warn("media attachment resolution failed", {
|
|
806
|
+
messageId: params.message.messageId,
|
|
807
|
+
error: String(err)
|
|
808
|
+
});
|
|
809
|
+
return {};
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
//#endregion
|
|
813
|
+
//#region src/inbound.ts
|
|
814
|
+
function readTextPayload(msg) {
|
|
815
|
+
const readLocationText = (location) => {
|
|
816
|
+
const coord = `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`;
|
|
817
|
+
const parts = [location.name, location.address].filter((value) => typeof value === "string" && Boolean(value.trim()));
|
|
818
|
+
return `<location: ${coord}${parts.length > 0 ? `; ${parts.join(" - ")}` : ""}>`;
|
|
819
|
+
};
|
|
820
|
+
const readContactLabel = (contact) => {
|
|
821
|
+
const primaryPhone = contact.phones[0];
|
|
822
|
+
const fields = [contact.name, primaryPhone].filter((value) => typeof value === "string" && Boolean(value.trim()));
|
|
823
|
+
return fields.length > 0 ? fields.join(", ") : void 0;
|
|
824
|
+
};
|
|
825
|
+
const readContactsText = (contacts) => {
|
|
826
|
+
if (contacts.length === 0) return "<contacts>";
|
|
827
|
+
if (contacts.length === 1) {
|
|
828
|
+
const label = readContactLabel(contacts[0]);
|
|
829
|
+
return label ? `<contact: ${label}>` : "<contact>";
|
|
830
|
+
}
|
|
831
|
+
const labels = contacts.map((contact) => readContactLabel(contact)).filter((value) => Boolean(value));
|
|
832
|
+
if (labels.length === 0) return `<contacts: ${contacts.length} contacts>`;
|
|
833
|
+
const shown = labels.slice(0, 3);
|
|
834
|
+
const remaining = Math.max(contacts.length - shown.length, 0);
|
|
835
|
+
const suffix = remaining > 0 ? ` (+${remaining} more)` : "";
|
|
836
|
+
return `<contacts: ${shown.join(", ")}${suffix}>`;
|
|
837
|
+
};
|
|
838
|
+
const msgType = typeof msg.type === "string" ? msg.type : "";
|
|
839
|
+
if (msgType === "text") {
|
|
840
|
+
const text = asRecord(msg.text);
|
|
841
|
+
return { text: typeof text?.body === "string" ? text.body.trim() : "" };
|
|
842
|
+
}
|
|
843
|
+
if (msgType === "image" || msgType === "video" || msgType === "audio" || msgType === "document" || msgType === "sticker") {
|
|
844
|
+
const rawMedia = asRecord(msg[msgType]);
|
|
845
|
+
const mediaId = asTrimmedString(rawMedia?.id);
|
|
846
|
+
const mimeType = asTrimmedString(rawMedia?.mime_type);
|
|
847
|
+
const fileName = asTrimmedString(rawMedia?.filename);
|
|
848
|
+
const caption = asTrimmedString(rawMedia?.caption);
|
|
849
|
+
const placeholder = `<media:${msgType}>`;
|
|
850
|
+
return {
|
|
851
|
+
text: caption ? `${caption}\n\n${placeholder}` : placeholder,
|
|
852
|
+
media: mediaId ? {
|
|
853
|
+
id: mediaId,
|
|
854
|
+
kind: msgType,
|
|
855
|
+
mimeType,
|
|
856
|
+
fileName
|
|
857
|
+
} : void 0
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
if (msgType === "location") {
|
|
861
|
+
const rawLocation = asRecord(msg.location);
|
|
862
|
+
const latitude = Number(rawLocation?.latitude);
|
|
863
|
+
const longitude = Number(rawLocation?.longitude);
|
|
864
|
+
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return { text: "<location>" };
|
|
865
|
+
const location = {
|
|
866
|
+
latitude,
|
|
867
|
+
longitude,
|
|
868
|
+
name: asTrimmedString(rawLocation?.name),
|
|
869
|
+
address: asTrimmedString(rawLocation?.address),
|
|
870
|
+
url: asTrimmedString(rawLocation?.url)
|
|
871
|
+
};
|
|
872
|
+
return {
|
|
873
|
+
text: readLocationText(location),
|
|
874
|
+
location
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
if (msgType === "contacts") {
|
|
878
|
+
const contacts = [];
|
|
879
|
+
for (const contactRaw of asArray(msg.contacts)) {
|
|
880
|
+
const contact = asRecord(contactRaw);
|
|
881
|
+
if (!contact) continue;
|
|
882
|
+
const nameRecord = asRecord(contact.name);
|
|
883
|
+
const name = asTrimmedString(nameRecord?.formatted_name) ?? asTrimmedString(nameRecord?.first_name) ?? asTrimmedString(nameRecord?.last_name);
|
|
884
|
+
const phones = asArray(contact.phones).map((phoneRaw) => asTrimmedString(asRecord(phoneRaw)?.phone)).filter((value) => Boolean(value));
|
|
885
|
+
contacts.push({
|
|
886
|
+
name,
|
|
887
|
+
phones
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
return {
|
|
891
|
+
text: readContactsText(contacts),
|
|
892
|
+
contacts
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
if (msgType === "reaction") return { text: "<reaction>" };
|
|
896
|
+
if (msgType === "button") return { text: "<button>" };
|
|
897
|
+
if (msgType === "interactive") return { text: "<interactive>" };
|
|
898
|
+
return { text: "" };
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Extracts inbound user messages from a Cloud API webhook body.
|
|
902
|
+
*
|
|
903
|
+
* Skips non-`messages` fields, empty bodies, and entries without `from`/`id`.
|
|
904
|
+
*
|
|
905
|
+
* @param params.payload - Parsed JSON object from the webhook POST body
|
|
906
|
+
* @param params.accountId - Account id to attach to each message
|
|
907
|
+
* @returns Zero or more normalised messages (order preserved)
|
|
908
|
+
*
|
|
909
|
+
* @example
|
|
910
|
+
* ```typescript
|
|
911
|
+
* const messages = parseWhatsAppCloudInbound({ payload: JSON.parse(body), accountId: "default" });
|
|
912
|
+
* for (const m of messages) {
|
|
913
|
+
* console.log(m.from, m.text);
|
|
914
|
+
* }
|
|
915
|
+
* ```
|
|
916
|
+
*/
|
|
917
|
+
function parseWhatsAppCloudInbound(params) {
|
|
918
|
+
const root = asRecord(params.payload);
|
|
919
|
+
if (!root) return [];
|
|
920
|
+
const result = [];
|
|
921
|
+
const entries = asArray(root.entry);
|
|
922
|
+
for (const entryRaw of entries) {
|
|
923
|
+
const entry = asRecord(entryRaw);
|
|
924
|
+
if (!entry) continue;
|
|
925
|
+
const changes = asArray(entry.changes);
|
|
926
|
+
for (const changeRaw of changes) {
|
|
927
|
+
const change = asRecord(changeRaw);
|
|
928
|
+
if (!change) continue;
|
|
929
|
+
if (change.field !== "messages") continue;
|
|
930
|
+
const value = asRecord(change.value);
|
|
931
|
+
if (!value) continue;
|
|
932
|
+
const metadata = asRecord(value.metadata) ?? {};
|
|
933
|
+
const to = typeof metadata.display_phone_number === "string" && metadata.display_phone_number.trim() || typeof metadata.phone_number_id === "string" && metadata.phone_number_id.trim() || "";
|
|
934
|
+
const contactNames = /* @__PURE__ */ new Map();
|
|
935
|
+
for (const contactRaw of asArray(value.contacts)) {
|
|
936
|
+
const contact = asRecord(contactRaw);
|
|
937
|
+
if (!contact) continue;
|
|
938
|
+
const waId = typeof contact.wa_id === "string" ? contact.wa_id.trim() : "";
|
|
939
|
+
const profile = asRecord(contact.profile);
|
|
940
|
+
const name = typeof profile?.name === "string" ? profile.name.trim() : "";
|
|
941
|
+
if (waId && name) contactNames.set(waId, name);
|
|
942
|
+
}
|
|
943
|
+
const messages = asArray(value.messages);
|
|
944
|
+
for (const messageRaw of messages) {
|
|
945
|
+
const msg = asRecord(messageRaw);
|
|
946
|
+
if (!msg) continue;
|
|
947
|
+
const from = typeof msg.from === "string" ? msg.from.trim() : "";
|
|
948
|
+
const messageId = typeof msg.id === "string" ? msg.id.trim() : "";
|
|
949
|
+
if (!from || !messageId) continue;
|
|
950
|
+
const parsed = readTextPayload(msg);
|
|
951
|
+
if (!parsed.text) continue;
|
|
952
|
+
const context = asRecord(msg.context);
|
|
953
|
+
const replyToId = asTrimmedString(context?.id) ?? asTrimmedString(context?.message_id);
|
|
954
|
+
const replyToSender = asTrimmedString(context?.from);
|
|
955
|
+
const replyToBody = asTrimmedString(context?.body) ?? asTrimmedString(asRecord(context?.text)?.body) ?? asTrimmedString(asRecord(context?.quoted_message)?.body);
|
|
956
|
+
const timestampRaw = typeof msg.timestamp === "string" ? Number(msg.timestamp) : NaN;
|
|
957
|
+
const timestamp = Number.isFinite(timestampRaw) && timestampRaw > 0 ? timestampRaw * 1e3 : void 0;
|
|
958
|
+
result.push({
|
|
959
|
+
accountId: params.accountId,
|
|
960
|
+
from,
|
|
961
|
+
to,
|
|
962
|
+
messageId,
|
|
963
|
+
text: parsed.text,
|
|
964
|
+
chatType: "direct",
|
|
965
|
+
timestamp,
|
|
966
|
+
senderName: contactNames.get(from),
|
|
967
|
+
replyToId,
|
|
968
|
+
replyToBody,
|
|
969
|
+
replyToSender,
|
|
970
|
+
media: parsed.media,
|
|
971
|
+
location: parsed.location,
|
|
972
|
+
contacts: parsed.contacts
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return result;
|
|
978
|
+
}
|
|
979
|
+
//#endregion
|
|
980
|
+
//#region src/webhook.ts
|
|
981
|
+
const webhookLog = waLogger("webhook");
|
|
982
|
+
const dedupeStore = /* @__PURE__ */ new Map();
|
|
983
|
+
function cleanupDedupe(now) {
|
|
984
|
+
for (const [key, expiresAt] of dedupeStore.entries()) if (expiresAt <= now) dedupeStore.delete(key);
|
|
985
|
+
}
|
|
986
|
+
function isDuplicate(accountId, messageId, ttlMs) {
|
|
987
|
+
const now = Date.now();
|
|
988
|
+
cleanupDedupe(now);
|
|
989
|
+
const key = `${accountId}:${messageId}`;
|
|
990
|
+
const current = dedupeStore.get(key);
|
|
991
|
+
if (current && current > now) return true;
|
|
992
|
+
dedupeStore.set(key, now + ttlMs);
|
|
993
|
+
return false;
|
|
994
|
+
}
|
|
995
|
+
async function readBody(req, maxBodyBytes) {
|
|
996
|
+
const chunks = [];
|
|
997
|
+
let total = 0;
|
|
998
|
+
for await (const chunk of req) {
|
|
999
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
1000
|
+
total += buf.length;
|
|
1001
|
+
if (total > maxBodyBytes) throw new Error("Payload exceeds maxBodyBytes");
|
|
1002
|
+
chunks.push(buf);
|
|
1003
|
+
}
|
|
1004
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1005
|
+
}
|
|
1006
|
+
function isAuthorized(req, account) {
|
|
1007
|
+
const required = account.inboundSharedSecret?.trim();
|
|
1008
|
+
if (!required) return true;
|
|
1009
|
+
const header = req.headers["x-openclaw-shared-secret"];
|
|
1010
|
+
return (Array.isArray(header) ? header[0] : header)?.trim() === required;
|
|
1011
|
+
}
|
|
1012
|
+
function json(res, statusCode, body) {
|
|
1013
|
+
res.statusCode = statusCode;
|
|
1014
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
1015
|
+
res.end(JSON.stringify(body));
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Handles a single HTTP request for the account's webhook path.
|
|
1019
|
+
*
|
|
1020
|
+
* - Rejects non-`POST` with `405`.
|
|
1021
|
+
* - Enforces `X-Openclaw-Shared-Secret` when `inboundSharedSecret` is set.
|
|
1022
|
+
* - Returns `400` on oversize or invalid JSON.
|
|
1023
|
+
* - Always ends the response; runs `onMessage` after responding (fire-and-forget loop).
|
|
1024
|
+
*
|
|
1025
|
+
* @param params.req - Node HTTP incoming message
|
|
1026
|
+
* @param params.res - Node HTTP server response (will be `end()`ed)
|
|
1027
|
+
* @param params.account - Resolved account (limits, secrets, dedupe TTL)
|
|
1028
|
+
* @param params.log - Optional overrides for info/warn/error (gateway may inject)
|
|
1029
|
+
* @param params.onMessage - Called for each non-duplicate parsed message
|
|
1030
|
+
* @returns `true` when this handler wrote the response (caller should not double-send)
|
|
1031
|
+
*/
|
|
1032
|
+
async function handleWhatsAppApiWebhook(params) {
|
|
1033
|
+
const { req, res, account, onMessage } = params;
|
|
1034
|
+
if (req.method !== "POST") {
|
|
1035
|
+
json(res, 405, {
|
|
1036
|
+
ok: false,
|
|
1037
|
+
error: "Method not allowed"
|
|
1038
|
+
});
|
|
1039
|
+
return true;
|
|
1040
|
+
}
|
|
1041
|
+
if (!isAuthorized(req, account)) {
|
|
1042
|
+
json(res, 403, {
|
|
1043
|
+
ok: false,
|
|
1044
|
+
error: "Forbidden"
|
|
1045
|
+
});
|
|
1046
|
+
return true;
|
|
1047
|
+
}
|
|
1048
|
+
let payload;
|
|
1049
|
+
try {
|
|
1050
|
+
const bodyText = await readBody(req, account.maxBodyBytes);
|
|
1051
|
+
payload = bodyText ? JSON.parse(bodyText) : {};
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
webhookLog.warn("invalid inbound payload", {
|
|
1054
|
+
accountId: account.accountId,
|
|
1055
|
+
error: String(err)
|
|
1056
|
+
});
|
|
1057
|
+
json(res, 400, {
|
|
1058
|
+
ok: false,
|
|
1059
|
+
error: "Invalid payload"
|
|
1060
|
+
});
|
|
1061
|
+
return true;
|
|
1062
|
+
}
|
|
1063
|
+
const inboundMessages = parseWhatsAppCloudInbound({
|
|
1064
|
+
payload,
|
|
1065
|
+
accountId: account.accountId
|
|
1066
|
+
});
|
|
1067
|
+
json(res, 200, {
|
|
1068
|
+
ok: true,
|
|
1069
|
+
accepted: inboundMessages.length,
|
|
1070
|
+
accountId: account.accountId
|
|
1071
|
+
});
|
|
1072
|
+
(async () => {
|
|
1073
|
+
for (const message of inboundMessages) {
|
|
1074
|
+
if (isDuplicate(account.accountId, message.messageId, account.dedupeTtlMs)) continue;
|
|
1075
|
+
try {
|
|
1076
|
+
await onMessage(message);
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
webhookLog.error("failed processing message", {
|
|
1079
|
+
messageId: message.messageId,
|
|
1080
|
+
accountId: account.accountId,
|
|
1081
|
+
error: String(err)
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
})();
|
|
1086
|
+
return true;
|
|
1087
|
+
}
|
|
1088
|
+
//#endregion
|
|
1089
|
+
//#region src/channel/plugin.ts
|
|
1090
|
+
const CHANNEL_ID = "whatsapp-api";
|
|
1091
|
+
const log$1 = waLogger("channel");
|
|
1092
|
+
/**
|
|
1093
|
+
* Resolves when `signal` aborts, or never if `signal` is undefined.
|
|
1094
|
+
*
|
|
1095
|
+
* @param signal - Gateway abort signal from OpenClaw
|
|
1096
|
+
* @param onAbort - Optional cleanup (e.g. update status) when aborted
|
|
1097
|
+
*/
|
|
1098
|
+
function waitUntilAbort(signal, onAbort) {
|
|
1099
|
+
return new Promise((resolve) => {
|
|
1100
|
+
const done = () => {
|
|
1101
|
+
onAbort?.();
|
|
1102
|
+
resolve();
|
|
1103
|
+
};
|
|
1104
|
+
if (!signal) return;
|
|
1105
|
+
if (signal.aborted) {
|
|
1106
|
+
done();
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
signal.addEventListener("abort", done, { once: true });
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Builds the string shown to the agent, using host `formatInboundEnvelope` when present.
|
|
1114
|
+
*
|
|
1115
|
+
* @param rt - Plugin runtime (may expose `channel.reply.formatInboundEnvelope`)
|
|
1116
|
+
* @param message - Parsed inbound message
|
|
1117
|
+
*/
|
|
1118
|
+
function buildInboundBody(rt, message) {
|
|
1119
|
+
const formatFn = ((rt?.channel)?.reply)?.formatInboundEnvelope;
|
|
1120
|
+
if (typeof formatFn === "function") return formatFn({
|
|
1121
|
+
channel: "WhatsApp API",
|
|
1122
|
+
from: message.senderName || message.from,
|
|
1123
|
+
timestamp: message.timestamp,
|
|
1124
|
+
body: message.text,
|
|
1125
|
+
chatType: "direct",
|
|
1126
|
+
sender: {
|
|
1127
|
+
name: message.senderName,
|
|
1128
|
+
id: message.from
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
return message.text;
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Resolves routing, enriches context (including optional media download), and runs the host dispatcher.
|
|
1135
|
+
*
|
|
1136
|
+
* @param params.accountId - Account handling this webhook
|
|
1137
|
+
* @param params.message - Normalised inbound message
|
|
1138
|
+
* @param params.onOutbound - Hook after at least one outbound Graph send succeeds
|
|
1139
|
+
*/
|
|
1140
|
+
async function dispatchInboundToAgent(params) {
|
|
1141
|
+
const { accountId, message, onOutbound } = params;
|
|
1142
|
+
const rt = getPluginRuntime();
|
|
1143
|
+
const cfg = rt.config.loadConfig();
|
|
1144
|
+
const account = resolveAccount(cfg, accountId);
|
|
1145
|
+
const route = rt.channel.routing.resolveAgentRoute({
|
|
1146
|
+
cfg,
|
|
1147
|
+
channel: CHANNEL_ID,
|
|
1148
|
+
accountId,
|
|
1149
|
+
peer: {
|
|
1150
|
+
kind: "direct",
|
|
1151
|
+
id: message.from
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
const body = buildInboundBody(rt, message);
|
|
1155
|
+
const to = `whatsapp-api:${message.from}`;
|
|
1156
|
+
const mediaAttachment = await downloadInboundMediaAttachment({
|
|
1157
|
+
account,
|
|
1158
|
+
message,
|
|
1159
|
+
log: { warn: (msg) => log$1.warn(msg) }
|
|
1160
|
+
});
|
|
1161
|
+
const replyApi = rt.channel.reply;
|
|
1162
|
+
const ctxPayload = replyApi.finalizeInboundContext({
|
|
1163
|
+
Body: body,
|
|
1164
|
+
BodyForAgent: message.text,
|
|
1165
|
+
RawBody: message.text,
|
|
1166
|
+
CommandBody: message.text,
|
|
1167
|
+
From: `whatsapp-api:${message.from}`,
|
|
1168
|
+
To: to,
|
|
1169
|
+
SessionKey: route.sessionKey,
|
|
1170
|
+
AccountId: route.accountId,
|
|
1171
|
+
ChatType: "direct",
|
|
1172
|
+
ConversationLabel: message.senderName || message.from,
|
|
1173
|
+
SenderName: message.senderName,
|
|
1174
|
+
SenderId: message.from,
|
|
1175
|
+
Provider: CHANNEL_ID,
|
|
1176
|
+
Surface: CHANNEL_ID,
|
|
1177
|
+
MessageSid: message.messageId,
|
|
1178
|
+
ReplyToId: message.replyToId,
|
|
1179
|
+
ReplyToBody: message.replyToBody,
|
|
1180
|
+
ReplyToSender: message.replyToSender,
|
|
1181
|
+
MediaPath: mediaAttachment.mediaPath,
|
|
1182
|
+
MediaType: mediaAttachment.mediaType,
|
|
1183
|
+
OriginatingChannel: CHANNEL_ID,
|
|
1184
|
+
OriginatingTo: to
|
|
1185
|
+
});
|
|
1186
|
+
const dispatchFn = replyApi.dispatchReplyWithBufferedBlockDispatcher;
|
|
1187
|
+
await dispatchFn({
|
|
1188
|
+
ctx: ctxPayload,
|
|
1189
|
+
cfg,
|
|
1190
|
+
dispatcherOptions: {
|
|
1191
|
+
deliver: async (payload) => {
|
|
1192
|
+
const account = resolveAccount(cfg, accountId);
|
|
1193
|
+
const sent = await sendWhatsAppApiReplyPayload({
|
|
1194
|
+
to: message.from,
|
|
1195
|
+
payload,
|
|
1196
|
+
account
|
|
1197
|
+
});
|
|
1198
|
+
if (sent.length === 0) {
|
|
1199
|
+
log$1.info("skipping outbound: reply payload empty", { messageId: message.messageId });
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
log$1.info(`sent ${sent.length} outbound message(s)`, {
|
|
1203
|
+
to: message.from,
|
|
1204
|
+
messageId: message.messageId
|
|
1205
|
+
});
|
|
1206
|
+
onOutbound?.();
|
|
1207
|
+
},
|
|
1208
|
+
onError: (err, info) => {
|
|
1209
|
+
log$1.error(`${info?.kind ?? "reply"} dispatch failed`, {
|
|
1210
|
+
accountId,
|
|
1211
|
+
messageId: message.messageId,
|
|
1212
|
+
error: String(err)
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Factory for the WhatsApp API channel plugin instance.
|
|
1220
|
+
*
|
|
1221
|
+
* Call from the plugin `register()` hook with the same `api` used for HTTP routes.
|
|
1222
|
+
*
|
|
1223
|
+
* @param api - OpenClaw plugin API (runtime, `registerHttpRoute`, logger)
|
|
1224
|
+
* @returns `ChannelPlugin` metadata, config, outbound, and gateway implementation
|
|
1225
|
+
*
|
|
1226
|
+
* @example
|
|
1227
|
+
* ```typescript
|
|
1228
|
+
* api.registerChannel({ plugin: createWhatsAppApiChannel(api) });
|
|
1229
|
+
* ```
|
|
1230
|
+
*/
|
|
1231
|
+
function createWhatsAppApiChannel(api) {
|
|
1232
|
+
return {
|
|
1233
|
+
id: CHANNEL_ID,
|
|
1234
|
+
meta: {
|
|
1235
|
+
id: CHANNEL_ID,
|
|
1236
|
+
label: "WhatsApp API",
|
|
1237
|
+
selectionLabel: "WhatsApp API (Cloud)",
|
|
1238
|
+
detailLabel: "WhatsApp API (Cloud)",
|
|
1239
|
+
docsPath: "/channels/whatsapp-api",
|
|
1240
|
+
blurb: "WhatsApp Cloud API channel with router-fed inbound webhook",
|
|
1241
|
+
order: 5
|
|
1242
|
+
},
|
|
1243
|
+
capabilities: {
|
|
1244
|
+
chatTypes: ["direct"],
|
|
1245
|
+
media: true,
|
|
1246
|
+
threads: false,
|
|
1247
|
+
reactions: false,
|
|
1248
|
+
edit: false,
|
|
1249
|
+
unsend: false,
|
|
1250
|
+
reply: false,
|
|
1251
|
+
effects: false,
|
|
1252
|
+
blockStreaming: false
|
|
1253
|
+
},
|
|
1254
|
+
config: {
|
|
1255
|
+
listAccountIds: (cfg) => listAccountIds(cfg),
|
|
1256
|
+
resolveAccount: (cfg, accountId) => resolveAccount(cfg, accountId),
|
|
1257
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID
|
|
1258
|
+
},
|
|
1259
|
+
outbound: {
|
|
1260
|
+
deliveryMode: "gateway",
|
|
1261
|
+
sendText: async ({ to, text, cfg, accountId }) => {
|
|
1262
|
+
return {
|
|
1263
|
+
channel: CHANNEL_ID,
|
|
1264
|
+
chatId: to,
|
|
1265
|
+
messageId: await sendWhatsAppApiText({
|
|
1266
|
+
to,
|
|
1267
|
+
text,
|
|
1268
|
+
account: resolveAccount(cfg, accountId ?? null)
|
|
1269
|
+
})
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
},
|
|
1273
|
+
gateway: {
|
|
1274
|
+
startAccount: async (ctx) => {
|
|
1275
|
+
const gwLog = waLogger("channel/gateway");
|
|
1276
|
+
const account = resolveAccount(ctx.cfg, ctx.accountId);
|
|
1277
|
+
if (!account.enabled) {
|
|
1278
|
+
gwLog.info("account disabled, skipping route", { accountId: account.accountId });
|
|
1279
|
+
return waitUntilAbort(ctx.abortSignal);
|
|
1280
|
+
}
|
|
1281
|
+
const routePath = account.webhookPath;
|
|
1282
|
+
ctx.setStatus({
|
|
1283
|
+
accountId: account.accountId,
|
|
1284
|
+
running: true,
|
|
1285
|
+
lastStartAt: Date.now(),
|
|
1286
|
+
lastError: null
|
|
1287
|
+
});
|
|
1288
|
+
api.registerHttpRoute({
|
|
1289
|
+
path: routePath,
|
|
1290
|
+
auth: "plugin",
|
|
1291
|
+
replaceExisting: true,
|
|
1292
|
+
handler: async (req, res) => {
|
|
1293
|
+
return await handleWhatsAppApiWebhook({
|
|
1294
|
+
req,
|
|
1295
|
+
res,
|
|
1296
|
+
account,
|
|
1297
|
+
log: {
|
|
1298
|
+
info: (msg) => gwLog.info(msg),
|
|
1299
|
+
warn: (msg) => gwLog.warn(msg),
|
|
1300
|
+
error: (msg) => gwLog.error(msg)
|
|
1301
|
+
},
|
|
1302
|
+
onMessage: async (message) => {
|
|
1303
|
+
ctx.setStatus({
|
|
1304
|
+
accountId: account.accountId,
|
|
1305
|
+
lastInboundAt: Date.now()
|
|
1306
|
+
});
|
|
1307
|
+
try {
|
|
1308
|
+
await dispatchInboundToAgent({
|
|
1309
|
+
accountId: account.accountId,
|
|
1310
|
+
message,
|
|
1311
|
+
onOutbound: () => {
|
|
1312
|
+
ctx.setStatus({
|
|
1313
|
+
accountId: account.accountId,
|
|
1314
|
+
lastOutboundAt: Date.now()
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
});
|
|
1318
|
+
} catch (err) {
|
|
1319
|
+
ctx.setStatus({
|
|
1320
|
+
accountId: account.accountId,
|
|
1321
|
+
lastError: String(err)
|
|
1322
|
+
});
|
|
1323
|
+
throw err;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
gwLog.info("registered HTTP route", { path: routePath });
|
|
1330
|
+
return waitUntilAbort(ctx.abortSignal, () => {
|
|
1331
|
+
gwLog.info("stopped account", { accountId: account.accountId });
|
|
1332
|
+
ctx.setStatus({
|
|
1333
|
+
accountId: account.accountId,
|
|
1334
|
+
running: false,
|
|
1335
|
+
lastStopAt: Date.now()
|
|
1336
|
+
});
|
|
1337
|
+
});
|
|
1338
|
+
},
|
|
1339
|
+
stopAccount: async (ctx) => {
|
|
1340
|
+
waLogger("channel/gateway").info("stopAccount called", { accountId: ctx.accountId });
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
//#endregion
|
|
1346
|
+
//#region index.ts
|
|
1347
|
+
const log = waLogger("plugin");
|
|
1348
|
+
/**
|
|
1349
|
+
* Default export: OpenClaw plugin manifest + `register` hook.
|
|
1350
|
+
*/
|
|
1351
|
+
const plugin = {
|
|
1352
|
+
id: "whatsapp-api",
|
|
1353
|
+
name: "WhatsApp API",
|
|
1354
|
+
description: "WhatsApp API channel plugin with inbound webhook and direct Meta outbound",
|
|
1355
|
+
configSchema: emptyPluginConfigSchema(),
|
|
1356
|
+
register(api) {
|
|
1357
|
+
setPluginApi(api);
|
|
1358
|
+
api.registerChannel({ plugin: createWhatsAppApiChannel(api) });
|
|
1359
|
+
log.info("plugin registered");
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
//#endregion
|
|
1363
|
+
export { plugin as default };
|