@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/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 };