@laburen/openclaw-plugin-whatsapp-api 0.1.0 → 0.2.1

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