@daolmedo/openclaw-twilio-whatsapp 1.1.25 → 1.1.26

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/dist/index.js CHANGED
@@ -2,13 +2,13 @@
2
2
  import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
3
3
 
4
4
  // src/accounts.ts
5
- var getChannelConfig = (cfg) => cfg.channels && cfg.channels["twilio-whatsapp"] ? cfg.channels["twilio-whatsapp"] : void 0;
5
+ var getTwilioChannelConfig = (cfg) => cfg.channels && cfg.channels["twilio-whatsapp"] ? cfg.channels["twilio-whatsapp"] : void 0;
6
6
  function listTwilioAccountIds(cfg) {
7
- const ch = getChannelConfig(cfg);
7
+ const ch = getTwilioChannelConfig(cfg);
8
8
  return Object.keys(ch?.accounts ?? {});
9
9
  }
10
10
  function resolveTwilioAccount(cfg, accountId) {
11
- const ch = getChannelConfig(cfg);
11
+ const ch = getTwilioChannelConfig(cfg);
12
12
  const id = accountId ?? "default";
13
13
  const entry = ch?.accounts?.[id];
14
14
  if (!entry) {
@@ -24,7 +24,7 @@ function resolveTwilioAccount(cfg, accountId) {
24
24
  };
25
25
  }
26
26
  function findTwilioAccountByPhoneNumber(cfg, phoneNumber) {
27
- const ch = getChannelConfig(cfg);
27
+ const ch = getTwilioChannelConfig(cfg);
28
28
  if (!ch?.accounts) return void 0;
29
29
  for (const [id, entry] of Object.entries(ch.accounts)) {
30
30
  const normalized = normalizePhoneNumber(entry.phoneNumber);
@@ -171,9 +171,7 @@ var twilioWhatsappPlugin = {
171
171
  selectionLabel: "WhatsApp (Twilio)",
172
172
  docsPath: "/channels/twilio-whatsapp",
173
173
  blurb: "WhatsApp messaging via Twilio Business API",
174
- markdownCapable: false,
175
- showInSetup: true,
176
- showConfigured: true
174
+ markdownCapable: false
177
175
  },
178
176
  capabilities: {
179
177
  chatTypes: ["direct"],
@@ -204,16 +202,9 @@ var twilioWhatsappPlugin = {
204
202
  async startAccount(ctx) {
205
203
  const account = ctx.account;
206
204
  const phoneNumber = normalizePhoneNumber(account.phoneNumber);
207
- if (!ctx.channelRuntime) {
208
- console.error(
209
- `[twilio-whatsapp] channelRuntime not provided for account ${ctx.accountId}. Inbound dispatch will not work.`
210
- );
211
- return;
212
- }
213
205
  setRuntimeForPhoneNumber(phoneNumber, {
214
206
  cfg: ctx.cfg,
215
- accountId: ctx.accountId,
216
- channelRuntime: ctx.channelRuntime
207
+ accountId: ctx.accountId
217
208
  });
218
209
  ctx.setStatus({
219
210
  accountId: ctx.accountId,
@@ -243,9 +234,51 @@ var twilioWhatsappPlugin = {
243
234
 
244
235
  // src/http-routes.ts
245
236
  import { readFile } from "node:fs/promises";
246
- import path from "node:path";
247
- import { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/direct-dm";
248
- import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
237
+ import * as path from "node:path";
238
+ import { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/channel-inbound";
239
+ import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
240
+ import {
241
+ createFixedWindowRateLimiter,
242
+ isRequestBodyLimitError,
243
+ readRequestBodyWithLimit
244
+ } from "openclaw/plugin-sdk/webhook-ingress";
245
+
246
+ // src/access.ts
247
+ function normalizeAllowEntry(value) {
248
+ const raw = String(value).trim();
249
+ return raw === "*" ? raw : normalizePhoneNumber(raw);
250
+ }
251
+ function resolveTwilioInboundAccess(params) {
252
+ const policy = params.channelConfig?.dmPolicy ?? "pairing";
253
+ const sender = normalizePhoneNumber(params.senderPhone);
254
+ const entries = (params.channelConfig?.allowFrom ?? []).map(normalizeAllowEntry);
255
+ const hasSpecificMatch = entries.some((entry) => entry !== "*" && entry === sender);
256
+ const hasWildcard = entries.includes("*");
257
+ if (policy === "disabled") {
258
+ return { allowed: false, commandAuthorized: false, reason: "dmPolicy is disabled" };
259
+ }
260
+ if (policy === "open") {
261
+ return {
262
+ allowed: true,
263
+ // A public wildcard can admit conversation traffic, but must not grant
264
+ // privileged OpenClaw slash/control commands to every WhatsApp sender.
265
+ commandAuthorized: hasSpecificMatch,
266
+ reason: "dmPolicy is open"
267
+ };
268
+ }
269
+ if (hasSpecificMatch || hasWildcard) {
270
+ return {
271
+ allowed: true,
272
+ commandAuthorized: hasSpecificMatch,
273
+ reason: `${policy} sender matched allowFrom`
274
+ };
275
+ }
276
+ return {
277
+ allowed: false,
278
+ commandAuthorized: false,
279
+ reason: policy === "pairing" ? "pairing is required and this plugin has no paired sender entry" : "sender is not in allowFrom"
280
+ };
281
+ }
249
282
 
250
283
  // src/webhook.ts
251
284
  import twilio2 from "twilio";
@@ -271,6 +304,7 @@ function parseTwilioWebhook(rawBody) {
271
304
  waId: p["WaId"] ?? "",
272
305
  profileName: p["ProfileName"] ?? "",
273
306
  messageSid: p["MessageSid"] ?? "",
307
+ accountSid: p["AccountSid"] ?? "",
274
308
  media,
275
309
  repliedToSid: p["OriginalRepliedMessageSid"]
276
310
  };
@@ -309,8 +343,63 @@ function startTypingKeepalive(opts) {
309
343
  };
310
344
  }
311
345
 
346
+ // src/replay-cache.ts
347
+ function createReplayCache(options) {
348
+ const entries = /* @__PURE__ */ new Map();
349
+ function prune(nowMs) {
350
+ for (const [key, expiresAt] of entries) {
351
+ if (expiresAt <= nowMs) entries.delete(key);
352
+ }
353
+ while (entries.size > options.maxEntries) {
354
+ const oldest = entries.keys().next().value;
355
+ if (oldest === void 0) break;
356
+ entries.delete(oldest);
357
+ }
358
+ }
359
+ return {
360
+ seen(key, nowMs = Date.now()) {
361
+ if (!key) return false;
362
+ const expiresAt = entries.get(key);
363
+ if (expiresAt !== void 0 && expiresAt > nowMs) return true;
364
+ entries.delete(key);
365
+ entries.set(key, nowMs + options.ttlMs);
366
+ prune(nowMs);
367
+ return false;
368
+ },
369
+ clear() {
370
+ entries.clear();
371
+ },
372
+ size() {
373
+ return entries.size;
374
+ }
375
+ };
376
+ }
377
+
312
378
  // src/http-routes.ts
313
379
  var MEDIA_MAX_BYTES = 16 * 1024 * 1024;
380
+ var WEBHOOK_MAX_BODY_BYTES = 128 * 1024;
381
+ var webhookRateLimiter = createFixedWindowRateLimiter({
382
+ windowMs: 6e4,
383
+ maxRequests: 120,
384
+ maxTrackedKeys: 4096
385
+ });
386
+ var replayCache = createReplayCache({ ttlMs: 10 * 6e4, maxEntries: 1e4 });
387
+ function twimlOk(res) {
388
+ res.writeHead(200, { "Content-Type": "text/xml; charset=utf-8" });
389
+ res.end('<?xml version="1.0" encoding="UTF-8"?><Response/>');
390
+ }
391
+ function twimlError(res, status, message) {
392
+ res.writeHead(status, { "Content-Type": "text/xml; charset=utf-8" });
393
+ res.end(
394
+ `<?xml version="1.0" encoding="UTF-8"?><Response><Message>${message}</Message></Response>`
395
+ );
396
+ }
397
+ function firstHeader(req, name) {
398
+ const value = req.headers[name.toLowerCase()];
399
+ return Array.isArray(value) ? value[0] ?? "" : value ?? "";
400
+ }
401
+ var pendingMediaByConversation = /* @__PURE__ */ new Map();
402
+ var dispatchQueueByConversation = /* @__PURE__ */ new Map();
314
403
  function resolveMediaBatchFlushMs() {
315
404
  const raw = Number(process.env.TWILIO_WHATSAPP_MEDIA_BATCH_FLUSH_MS);
316
405
  if (Number.isFinite(raw) && raw >= 0) return Math.min(raw, 6e4);
@@ -325,29 +414,15 @@ function summarizeText2(text) {
325
414
  if (!text) return "chars=0";
326
415
  return `chars=${text.length} preview=${JSON.stringify(text.slice(0, 120))}`;
327
416
  }
328
- async function readBody(req) {
329
- return new Promise((resolve, reject) => {
330
- const chunks = [];
331
- req.on("data", (chunk) => chunks.push(chunk));
332
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
333
- req.on("error", reject);
334
- });
335
- }
336
- function twimlOk(res) {
337
- res.writeHead(200, { "Content-Type": "text/xml; charset=utf-8" });
338
- res.end('<?xml version="1.0" encoding="UTF-8"?><Response/>');
339
- }
340
- function twimlError(res, status, message) {
341
- res.writeHead(status, { "Content-Type": "text/xml; charset=utf-8" });
342
- res.end(
343
- `<?xml version="1.0" encoding="UTF-8"?><Response><Message>${message}</Message></Response>`
344
- );
345
- }
346
- var pendingMediaByConversation = /* @__PURE__ */ new Map();
347
- var dispatchQueueByConversation = /* @__PURE__ */ new Map();
348
417
  function resolvePendingMediaKey(accountId, fromPhone) {
349
418
  return `${accountId}:${fromPhone}`;
350
419
  }
420
+ function uniqueMessageSids(sids) {
421
+ return [...new Set(sids.map((sid) => sid?.trim()).filter((sid) => Boolean(sid)))];
422
+ }
423
+ function combineBatchMessageText(items) {
424
+ return items.map((item) => item.messageText.trim()).filter(Boolean).join("\n\n");
425
+ }
351
426
  async function downloadTwilioMedia(url, account) {
352
427
  try {
353
428
  const authHeader = "Basic " + Buffer.from(`${account.accountSid}:${account.authToken}`).toString("base64");
@@ -380,82 +455,84 @@ function enqueueConversationDispatch(key, task) {
380
455
  console.error(`[twilio-whatsapp] Conversation dispatch failed key=${key}:`, err);
381
456
  });
382
457
  dispatchQueueByConversation.set(key, next);
383
- void next.finally(() => {
458
+ void next.then(() => {
384
459
  if (dispatchQueueByConversation.get(key) === next) {
385
460
  dispatchQueueByConversation.delete(key);
386
461
  }
387
462
  });
463
+ return next;
388
464
  }
389
465
  function stopBatchTimer(batch) {
390
- if (batch.timer) {
391
- clearTimeout(batch.timer);
392
- batch.timer = void 0;
393
- }
466
+ if (!batch.timer) return;
467
+ clearTimeout(batch.timer);
468
+ batch.timer = void 0;
394
469
  }
395
- function armBatchTimer(api, key, batch) {
470
+ function armBatchTimer(api, dispatchInbound, key, batch) {
396
471
  stopBatchTimer(batch);
397
- const flushMs = resolveMediaBatchFlushMs();
398
472
  batch.timer = setTimeout(() => {
399
- flushPendingMediaBatch(api, key, "timer");
400
- }, flushMs);
401
- const maybeTimer = batch.timer;
402
- maybeTimer.unref?.();
473
+ void flushPendingMediaBatch(api, dispatchInbound, key, "timer");
474
+ }, resolveMediaBatchFlushMs());
475
+ batch.timer.unref?.();
403
476
  }
404
- function combineBatchMessageText(items) {
405
- return items.map((item) => item.messageText.trim()).filter(Boolean).join("\n\n");
406
- }
407
- function uniqueMessageSids(sids) {
408
- return [...new Set(sids.map((sid) => sid?.trim()).filter((sid) => Boolean(sid)))];
409
- }
410
- function flushPendingMediaBatch(api, key, reason) {
477
+ async function flushPendingMediaBatch(api, dispatchInbound, key, reason) {
411
478
  const batch = pendingMediaByConversation.get(key);
412
479
  if (!batch) return;
413
480
  stopBatchTimer(batch);
414
481
  pendingMediaByConversation.delete(key);
415
482
  const items = [...batch.items].sort((a, b) => a.receivedAt - b.receivedAt);
416
- const last = items[items.length - 1];
483
+ const last = items.at(-1);
417
484
  if (!last) return;
418
485
  const resolvedMedia = items.flatMap((item) => item.resolvedMedia);
419
- if (!resolvedMedia.length && !combineBatchMessageText(items)) {
486
+ const messageText = combineBatchMessageText(items);
487
+ if (!resolvedMedia.length && !messageText) {
488
+ for (const item of items) item.resolve();
420
489
  return;
421
490
  }
422
491
  const messageSids = uniqueMessageSids(items.map((item) => item.fields.messageSid));
423
492
  console.log(
424
493
  `[twilio-whatsapp] Flushing media batch key=${key} reason=${reason} messages=${items.length} media=${resolvedMedia.length} sids=${messageSids.join(",") || "unknown"}`
425
494
  );
426
- enqueueConversationDispatch(key, async () => {
427
- await dispatchTwilioInbound(api, {
428
- ...last,
429
- messageText: combineBatchMessageText(items),
430
- resolvedMedia,
431
- messageSids
432
- });
433
- });
434
- }
435
- function queueMediaBatch(api, key, item) {
436
- let batch = pendingMediaByConversation.get(key);
437
- if (!batch) {
438
- batch = { items: [] };
439
- pendingMediaByConversation.set(key, batch);
440
- }
441
- batch.items.push(item);
442
- console.log(
443
- `[twilio-whatsapp] Buffered media inbound sid=${item.fields.messageSid || "unknown"} key=${key} pendingMessages=${batch.items.length} pendingMedia=${batch.items.reduce(
444
- (count, pending) => count + pending.resolvedMedia.length,
445
- 0
446
- )}`
447
- );
448
- if (batch.items.length >= resolveMediaBatchMaxItems()) {
449
- flushPendingMediaBatch(api, key, "max-items");
450
- return;
451
- }
452
- if (resolveMediaBatchFlushMs() === 0) {
453
- flushPendingMediaBatch(api, key, "disabled");
454
- return;
495
+ try {
496
+ await enqueueConversationDispatch(
497
+ key,
498
+ () => dispatchTwilioInbound(api, dispatchInbound, {
499
+ ...last,
500
+ messageText,
501
+ resolvedMedia,
502
+ messageSids
503
+ })
504
+ );
505
+ for (const item of items) item.resolve();
506
+ } catch (err) {
507
+ for (const item of items) item.reject(err);
455
508
  }
456
- armBatchTimer(api, key, batch);
457
509
  }
458
- async function dispatchTwilioInbound(api, params) {
510
+ function queueMediaBatch(api, dispatchInbound, key, item) {
511
+ return new Promise((resolve, reject) => {
512
+ let batch = pendingMediaByConversation.get(key);
513
+ if (!batch) {
514
+ batch = { items: [] };
515
+ pendingMediaByConversation.set(key, batch);
516
+ }
517
+ batch.items.push({ ...item, resolve, reject });
518
+ console.log(
519
+ `[twilio-whatsapp] Buffered media inbound sid=${item.fields.messageSid || "unknown"} key=${key} pendingMessages=${batch.items.length} pendingMedia=${batch.items.reduce(
520
+ (count, pending) => count + pending.resolvedMedia.length,
521
+ 0
522
+ )}`
523
+ );
524
+ if (batch.items.length >= resolveMediaBatchMaxItems()) {
525
+ void flushPendingMediaBatch(api, dispatchInbound, key, "max-items");
526
+ return;
527
+ }
528
+ if (resolveMediaBatchFlushMs() === 0) {
529
+ void flushPendingMediaBatch(api, dispatchInbound, key, "disabled");
530
+ return;
531
+ }
532
+ armBatchTimer(api, dispatchInbound, key, batch);
533
+ });
534
+ }
535
+ async function dispatchTwilioInbound(api, dispatchInbound, params) {
459
536
  const {
460
537
  fields,
461
538
  stored,
@@ -472,25 +549,26 @@ async function dispatchTwilioInbound(api, params) {
472
549
  MediaPath: resolvedMedia[0].path,
473
550
  MediaUrl: resolvedMedia[0].path,
474
551
  MediaType: resolvedMedia[0].contentType,
475
- MediaPaths: resolvedMedia.map((m) => m.path),
476
- MediaUrls: resolvedMedia.map((m) => m.path),
477
- MediaTypes: resolvedMedia.map((m) => m.contentType)
552
+ MediaPaths: resolvedMedia.map((media) => media.path),
553
+ MediaUrls: resolvedMedia.map((media) => media.path),
554
+ MediaTypes: resolvedMedia.map((media) => media.contentType)
478
555
  } : {};
479
556
  let bodyForAgent = messageText || void 0;
480
- const firstAudio = resolvedMedia.find((m) => m.contentType.startsWith("audio/"));
557
+ const firstAudio = resolvedMedia.find((media) => media.contentType.startsWith("audio/"));
481
558
  if (firstAudio) {
482
559
  try {
483
560
  const cfgAny = stored.cfg;
484
561
  const agentId = cfgAny.bindings?.find(
485
- (b) => b.match?.channel === "twilio-whatsapp" && b.match?.accountId === stored.accountId
562
+ (binding) => binding.match?.channel === "twilio-whatsapp" && binding.match?.accountId === stored.accountId
486
563
  )?.agentId;
487
- const agentDir = cfgAny.agents?.list?.find(
488
- (a) => a.id === agentId
489
- )?.agentDir;
564
+ if (!agentId) {
565
+ throw new Error(`No agent binding found for WhatsApp account ${stored.accountId}`);
566
+ }
567
+ const workspaceDir = api.runtime.agent.resolveAgentWorkspaceDir(stored.cfg, agentId);
490
568
  const auth = await api.runtime.modelAuth.resolveApiKeyForProvider({
491
569
  provider: "openai",
492
- cfg: api.config,
493
- agentDir
570
+ cfg: stored.cfg,
571
+ workspaceDir
494
572
  });
495
573
  if (!auth.apiKey) throw new Error("No OpenAI API key available for audio transcription");
496
574
  const audioBuffer = await readFile(firstAudio.path);
@@ -512,8 +590,7 @@ async function dispatchTwilioInbound(api, params) {
512
590
  const errBody = await transcribeRes.text();
513
591
  throw new Error(`OpenAI transcription HTTP ${transcribeRes.status}: ${errBody}`);
514
592
  }
515
- const transcribeJson = await transcribeRes.json();
516
- const text = transcribeJson.text;
593
+ const { text } = await transcribeRes.json();
517
594
  console.log("[twilio-whatsapp] Transcription result:", text);
518
595
  if (text) {
519
596
  bodyForAgent = messageText ? `${messageText}
@@ -560,10 +637,9 @@ ${text}` : text;
560
637
  }
561
638
  };
562
639
  try {
563
- const timestamp = Date.now();
564
- await dispatchInboundDirectDmWithRuntime({
640
+ await dispatchInbound({
565
641
  cfg: stored.cfg,
566
- runtime: { channel: stored.channelRuntime },
642
+ runtime: api.runtime,
567
643
  channel: "twilio-whatsapp",
568
644
  channelLabel: "WhatsApp",
569
645
  accountId: account.accountId,
@@ -575,14 +651,16 @@ ${text}` : text;
575
651
  rawBody: messageText,
576
652
  bodyForAgent,
577
653
  messageId: fields.messageSid,
578
- timestamp,
579
- commandAuthorized: true,
654
+ timestamp: Date.now(),
655
+ commandAuthorized: params.commandAuthorized,
656
+ inboundAccessAuthorized: true,
580
657
  provider: "twilio-whatsapp",
581
658
  surface: "twilio-whatsapp",
582
659
  originatingChannel: "twilio-whatsapp",
583
660
  originatingTo: senderAddress,
584
661
  extraContext: mediaContext,
585
662
  deliver: async (payload) => {
663
+ stopTyping();
586
664
  const text = payload.text ?? "";
587
665
  const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
588
666
  console.log(
@@ -613,6 +691,7 @@ ${text}` : text;
613
691
  console.error("[twilio-whatsapp] Session record error:", err);
614
692
  },
615
693
  onDispatchError: (err, info) => {
694
+ stopTyping();
616
695
  console.error(`[twilio-whatsapp] Dispatch error (${info.kind}):`, err);
617
696
  }
618
697
  });
@@ -620,15 +699,33 @@ ${text}` : text;
620
699
  stopTyping();
621
700
  }
622
701
  }
623
- function registerTwilioWhatsappHttpRoutes(api) {
702
+ function registerTwilioWhatsappHttpRoutes(api, dependencies = {}) {
703
+ const dispatchInbound = dependencies.dispatchInbound ?? dispatchInboundDirectDmWithRuntime;
704
+ const downloadMedia = dependencies.downloadMedia ?? downloadTwilioMedia;
624
705
  api.registerHttpRoute({
625
706
  path: "/twilio/whatsapp/events",
626
707
  auth: "plugin",
627
708
  handler: async (req, res) => {
628
- const rawBody = await readBody(req);
629
- const headers = req.headers;
630
- const signature = String(headers["x-twilio-signature"] ?? "");
631
- const webhookUrl = String(headers["x-twilio-webhook-url"] ?? "");
709
+ if (req.method !== "POST") {
710
+ twimlError(res, 405, "Method not allowed");
711
+ return true;
712
+ }
713
+ let rawBody;
714
+ try {
715
+ rawBody = await readRequestBodyWithLimit(req, {
716
+ maxBytes: WEBHOOK_MAX_BODY_BYTES,
717
+ timeoutMs: 1e4,
718
+ destroyOnLimit: false
719
+ });
720
+ } catch (err) {
721
+ if (isRequestBodyLimitError(err)) {
722
+ twimlError(res, err.statusCode, "Invalid request body");
723
+ return true;
724
+ }
725
+ throw err;
726
+ }
727
+ const signature = firstHeader(req, "x-twilio-signature");
728
+ const webhookUrl = firstHeader(req, "x-twilio-webhook-url");
632
729
  const fields = parseTwilioWebhook(rawBody);
633
730
  const toPhone = normalizePhoneNumber(fields.to);
634
731
  console.log(
@@ -646,20 +743,26 @@ function registerTwilioWhatsappHttpRoutes(api) {
646
743
  twimlError(res, 404, "Account not found");
647
744
  return true;
648
745
  }
649
- if (webhookUrl && signature) {
650
- const valid = verifyTwilioSignature({
651
- authToken: account.authToken,
652
- signature,
653
- url: webhookUrl,
654
- rawBody
655
- });
656
- if (!valid) {
657
- console.error("[twilio-whatsapp] Invalid Twilio signature");
658
- twimlError(res, 403, "Forbidden");
659
- return true;
660
- }
661
- } else {
662
- console.warn("[twilio-whatsapp] Skipping sig verification - headers missing");
746
+ if (!webhookUrl || !signature) {
747
+ console.error("[twilio-whatsapp] Missing forwarded Twilio signature headers");
748
+ twimlError(res, 403, "Forbidden");
749
+ return true;
750
+ }
751
+ const valid = verifyTwilioSignature({
752
+ authToken: account.authToken,
753
+ signature,
754
+ url: webhookUrl,
755
+ rawBody
756
+ });
757
+ if (!valid) {
758
+ console.error("[twilio-whatsapp] Invalid Twilio signature");
759
+ twimlError(res, 403, "Forbidden");
760
+ return true;
761
+ }
762
+ if (!fields.accountSid || fields.accountSid !== account.accountSid) {
763
+ console.error("[twilio-whatsapp] Twilio AccountSid does not match the target account");
764
+ twimlError(res, 403, "Forbidden");
765
+ return true;
663
766
  }
664
767
  const fromPhone = normalizePhoneNumber(fields.from);
665
768
  const senderAddress = `whatsapp:${fromPhone}`;
@@ -667,11 +770,33 @@ function registerTwilioWhatsappHttpRoutes(api) {
667
770
  const conversationLabel = fields.profileName || fromPhone;
668
771
  const messageText = fields.body.trim();
669
772
  const hasMedia = fields.media.length > 0;
670
- const receivedAt = Date.now();
671
773
  if (!messageText && !hasMedia) {
672
774
  twimlOk(res);
673
775
  return true;
674
776
  }
777
+ const rateLimitKey = `${account.accountId}:${fromPhone}`;
778
+ if (webhookRateLimiter.isRateLimited(rateLimitKey)) {
779
+ console.warn(`[twilio-whatsapp] Inbound rate limit exceeded for account=${account.accountId}`);
780
+ twimlError(res, 429, "Too many requests");
781
+ return true;
782
+ }
783
+ const inboundAccess = resolveTwilioInboundAccess({
784
+ channelConfig: getTwilioChannelConfig(stored.cfg),
785
+ senderPhone: fromPhone
786
+ });
787
+ if (!inboundAccess.allowed) {
788
+ console.warn(
789
+ `[twilio-whatsapp] Blocked inbound sender for account=${account.accountId}: ${inboundAccess.reason}`
790
+ );
791
+ twimlOk(res);
792
+ return true;
793
+ }
794
+ if (fields.messageSid && replayCache.seen(`${account.accountId}:${fields.messageSid}`)) {
795
+ console.info(`[twilio-whatsapp] Ignoring replayed MessageSid for account=${account.accountId}`);
796
+ twimlOk(res);
797
+ return true;
798
+ }
799
+ const receivedAt = Date.now();
675
800
  twimlOk(res);
676
801
  console.log(
677
802
  `[twilio-whatsapp] Webhook acknowledged sid=${fields.messageSid || "unknown"} accountId=${account.accountId}`
@@ -679,7 +804,7 @@ function registerTwilioWhatsappHttpRoutes(api) {
679
804
  const resolvedMedia = [];
680
805
  if (hasMedia) {
681
806
  const results = await Promise.all(
682
- fields.media.map((m) => downloadTwilioMedia(m.url, account))
807
+ fields.media.map((m) => downloadMedia(m.url, account))
683
808
  );
684
809
  for (const r of results) {
685
810
  if (r) resolvedMedia.push(r);
@@ -689,18 +814,22 @@ function registerTwilioWhatsappHttpRoutes(api) {
689
814
  );
690
815
  }
691
816
  const pendingMediaKey = resolvePendingMediaKey(account.accountId, fromPhone);
817
+ const dispatchParams = {
818
+ fields,
819
+ stored,
820
+ account,
821
+ toPhone,
822
+ fromPhone,
823
+ senderAddress,
824
+ recipientAddress,
825
+ conversationLabel,
826
+ messageText,
827
+ resolvedMedia,
828
+ commandAuthorized: inboundAccess.commandAuthorized
829
+ };
692
830
  if (hasMedia && resolvedMedia.length > 0) {
693
- queueMediaBatch(api, pendingMediaKey, {
694
- fields,
695
- stored,
696
- account,
697
- toPhone,
698
- fromPhone,
699
- senderAddress,
700
- recipientAddress,
701
- conversationLabel,
702
- messageText,
703
- resolvedMedia,
831
+ await queueMediaBatch(api, dispatchInbound, pendingMediaKey, {
832
+ ...dispatchParams,
704
833
  receivedAt
705
834
  });
706
835
  return true;
@@ -712,22 +841,12 @@ function registerTwilioWhatsappHttpRoutes(api) {
712
841
  return true;
713
842
  }
714
843
  if (!hasMedia) {
715
- flushPendingMediaBatch(api, pendingMediaKey, "before-text");
844
+ await flushPendingMediaBatch(api, dispatchInbound, pendingMediaKey, "before-text");
716
845
  }
717
- enqueueConversationDispatch(pendingMediaKey, async () => {
718
- await dispatchTwilioInbound(api, {
719
- fields,
720
- stored,
721
- account,
722
- toPhone,
723
- fromPhone,
724
- senderAddress,
725
- recipientAddress,
726
- conversationLabel,
727
- messageText,
728
- resolvedMedia
729
- });
730
- });
846
+ await enqueueConversationDispatch(
847
+ pendingMediaKey,
848
+ () => dispatchTwilioInbound(api, dispatchInbound, dispatchParams)
849
+ );
731
850
  return true;
732
851
  }
733
852
  });
@@ -2,7 +2,7 @@
2
2
  "id": "twilio-whatsapp",
3
3
  "name": "Twilio WhatsApp",
4
4
  "description": "WhatsApp channel via Twilio Business API (ISV/WABA)",
5
- "version": "1.0.0",
5
+ "version": "1.1.26",
6
6
  "channels": ["twilio-whatsapp"],
7
7
  "channelConfigs": {
8
8
  "twilio-whatsapp": {
package/package.json CHANGED
@@ -1,36 +1,51 @@
1
- {
2
- "name": "@daolmedo/openclaw-twilio-whatsapp",
3
- "version": "1.1.25",
4
- "description": "Twilio WhatsApp channel plugin for OpenClaw (Cody ISV)",
5
- "type": "module",
6
- "exports": {
7
- ".": "./dist/index.js"
8
- },
9
- "files": [
10
- "dist",
11
- "openclaw.plugin.json"
12
- ],
13
- "scripts": {
14
- "build": "esbuild index.ts --bundle --platform=node --format=esm --external:openclaw --external:openclaw/* --external:twilio --outfile=dist/index.js",
15
- "prepublishOnly": "npm run build"
16
- },
17
- "dependencies": {
18
- "twilio": "^5.4.0"
19
- },
20
- "devDependencies": {
21
- "esbuild": "^0.28.0",
22
- "typescript": "^5.4.0"
23
- },
24
- "openclaw": {
25
- "extensions": [
26
- "./dist/index.js"
27
- ],
28
- "channel": {
29
- "id": "twilio-whatsapp",
30
- "label": "Twilio WhatsApp"
31
- },
32
- "install": {
33
- "minHostVersion": ">=2026.3.24"
34
- }
35
- }
36
- }
1
+ {
2
+ "name": "@daolmedo/openclaw-twilio-whatsapp",
3
+ "version": "1.1.26",
4
+ "description": "Twilio WhatsApp channel plugin for OpenClaw (Cody ISV)",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=24.16.0 <25 || >=26.1.0"
8
+ },
9
+ "exports": {
10
+ ".": "./dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "openclaw.plugin.json"
15
+ ],
16
+ "scripts": {
17
+ "build": "esbuild index.ts --bundle --platform=node --format=esm --external:openclaw --external:openclaw/* --external:twilio --outfile=dist/index.js",
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "esbuild test/*.test.ts --bundle --platform=node --format=esm --external:openclaw --external:openclaw/* --external:twilio --outdir=.test-dist --out-extension:.js=.mjs --log-level=warning && node --test .test-dist/*.test.mjs",
20
+ "check": "npm run typecheck && npm test && npm run build",
21
+ "prepublishOnly": "npm run check"
22
+ },
23
+ "dependencies": {
24
+ "twilio": "^5.4.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "24.13.4",
28
+ "esbuild": "^0.28.0",
29
+ "openclaw": "2026.9.4",
30
+ "typescript": "^5.4.0"
31
+ },
32
+ "openclaw": {
33
+ "extensions": [
34
+ "./dist/index.js"
35
+ ],
36
+ "channel": {
37
+ "id": "twilio-whatsapp",
38
+ "label": "Twilio WhatsApp"
39
+ },
40
+ "install": {
41
+ "minHostVersion": ">=2026.9.4"
42
+ },
43
+ "compat": {
44
+ "pluginApi": ">=2026.9.4"
45
+ },
46
+ "build": {
47
+ "openclawVersion": "2026.9.4",
48
+ "pluginSdkVersion": "2026.9.4"
49
+ }
50
+ }
51
+ }
package/dist/index.d.ts DELETED
@@ -1,10 +0,0 @@
1
- declare const _default: {
2
- id: string;
3
- name: string;
4
- description: string;
5
- configSchema: import("openclaw/channels/plugins/types.plugin.js").ChannelConfigSchema;
6
- register: (api: import("openclaw/plugins/types.js").OpenClawPluginApi) => void;
7
- channelPlugin: import("openclaw/plugin-sdk/channel-core.js").ChannelPlugin<import("./src/accounts.js").ResolvedTwilioAccount>;
8
- setChannelRuntime?: (runtime: import("openclaw/plugin-sdk/channel-core.js").PluginRuntime) => void;
9
- };
10
- export default _default;
@@ -1,11 +0,0 @@
1
- import type { OpenClawConfig } from "openclaw/channels/plugins/types.js";
2
- export type ResolvedTwilioAccount = {
3
- accountId: string;
4
- accountSid: string;
5
- authToken: string;
6
- phoneNumber: string;
7
- };
8
- export declare function listTwilioAccountIds(cfg: OpenClawConfig): string[];
9
- export declare function resolveTwilioAccount(cfg: OpenClawConfig, accountId?: string | null): ResolvedTwilioAccount;
10
- export declare function findTwilioAccountByPhoneNumber(cfg: OpenClawConfig, phoneNumber: string): ResolvedTwilioAccount | undefined;
11
- export declare function normalizePhoneNumber(phone: string): string;
@@ -1,38 +0,0 @@
1
- const getChannelConfig = (cfg) => cfg.channels &&
2
- cfg.channels["twilio-whatsapp"]
3
- ? cfg.channels["twilio-whatsapp"]
4
- : undefined;
5
- export function listTwilioAccountIds(cfg) {
6
- const ch = getChannelConfig(cfg);
7
- return Object.keys(ch?.accounts ?? {});
8
- }
9
- export function resolveTwilioAccount(cfg, accountId) {
10
- const ch = getChannelConfig(cfg);
11
- const id = accountId ?? "default";
12
- const entry = ch?.accounts?.[id];
13
- if (!entry) {
14
- throw new Error(`[twilio-whatsapp] No account configured for accountId="${id}". ` +
15
- `Add channels.twilio-whatsapp.accounts.${id} to openclaw.json.`);
16
- }
17
- return {
18
- accountId: id,
19
- accountSid: entry.accountSid,
20
- authToken: entry.authToken,
21
- phoneNumber: entry.phoneNumber,
22
- };
23
- }
24
- export function findTwilioAccountByPhoneNumber(cfg, phoneNumber) {
25
- const ch = getChannelConfig(cfg);
26
- if (!ch?.accounts)
27
- return undefined;
28
- for (const [id, entry] of Object.entries(ch.accounts)) {
29
- const normalized = normalizePhoneNumber(entry.phoneNumber);
30
- if (normalized === normalizePhoneNumber(phoneNumber)) {
31
- return { accountId: id, ...entry };
32
- }
33
- }
34
- return undefined;
35
- }
36
- export function normalizePhoneNumber(phone) {
37
- return phone.replace(/^whatsapp:/, "").replace(/\s+/g, "");
38
- }
@@ -1,3 +0,0 @@
1
- import type { ChannelPlugin } from "openclaw/channels/plugins/types.js";
2
- import { type ResolvedTwilioAccount } from "./accounts.js";
3
- export declare const twilioWhatsappPlugin: ChannelPlugin<ResolvedTwilioAccount>;
@@ -1,76 +0,0 @@
1
- import { listTwilioAccountIds, normalizePhoneNumber, resolveTwilioAccount, } from "./accounts.js";
2
- import { twilioWhatsappOutbound } from "./outbound-adapter.js";
3
- import { clearRuntimeForPhoneNumber, setRuntimeForPhoneNumber } from "./runtime-store.js";
4
- export const twilioWhatsappPlugin = {
5
- id: "twilio-whatsapp",
6
- meta: {
7
- id: "twilio-whatsapp",
8
- label: "Twilio WhatsApp",
9
- selectionLabel: "WhatsApp (Twilio)",
10
- docsPath: "/channels/twilio-whatsapp",
11
- blurb: "WhatsApp messaging via Twilio Business API",
12
- markdownCapable: false,
13
- showInSetup: true,
14
- showConfigured: true,
15
- },
16
- capabilities: {
17
- chatTypes: ["direct"],
18
- media: true,
19
- },
20
- config: {
21
- listAccountIds: listTwilioAccountIds,
22
- resolveAccount(cfg, accountId) {
23
- return resolveTwilioAccount(cfg, accountId);
24
- },
25
- isConfigured(account) {
26
- return Boolean(account.accountSid && account.authToken && account.phoneNumber);
27
- },
28
- describeAccount(account) {
29
- return {
30
- accountId: account.accountId,
31
- configured: Boolean(account.accountSid && account.authToken && account.phoneNumber),
32
- name: `WhatsApp ${account.phoneNumber}`,
33
- };
34
- },
35
- hasConfiguredState({ cfg }) {
36
- return listTwilioAccountIds(cfg).length > 0;
37
- },
38
- },
39
- outbound: twilioWhatsappOutbound,
40
- gateway: {
41
- async startAccount(ctx) {
42
- const account = ctx.account;
43
- const phoneNumber = normalizePhoneNumber(account.phoneNumber);
44
- if (!ctx.channelRuntime) {
45
- console.error(`[twilio-whatsapp] channelRuntime not provided for account ${ctx.accountId}. ` +
46
- "Inbound dispatch will not work.");
47
- return;
48
- }
49
- setRuntimeForPhoneNumber(phoneNumber, {
50
- cfg: ctx.cfg,
51
- accountId: ctx.accountId,
52
- channelRuntime: ctx.channelRuntime,
53
- });
54
- ctx.setStatus({
55
- accountId: ctx.accountId,
56
- running: true,
57
- connected: true,
58
- configured: true,
59
- name: `WhatsApp ${account.phoneNumber}`,
60
- webhookPath: "/twilio/whatsapp/events",
61
- });
62
- console.log(`[twilio-whatsapp] Account ${ctx.accountId} started, listening on /twilio/whatsapp/events for ${account.phoneNumber}`);
63
- await new Promise((_, reject) => {
64
- ctx.abortSignal.addEventListener("abort", () => reject(new Error("aborted")));
65
- }).catch(() => {
66
- clearRuntimeForPhoneNumber(phoneNumber);
67
- ctx.setStatus({
68
- accountId: ctx.accountId,
69
- running: false,
70
- connected: false,
71
- });
72
- console.log(`[twilio-whatsapp] Account ${ctx.accountId} stopped`);
73
- });
74
- },
75
- },
76
- };
@@ -1,2 +0,0 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugins/types.js";
2
- export declare function registerTwilioWhatsappHttpRoutes(api: OpenClawPluginApi): void;
@@ -1,117 +0,0 @@
1
- import { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/direct-dm.js";
2
- import { findTwilioAccountByPhoneNumber, normalizePhoneNumber, } from "./accounts.js";
3
- import { parseTwilioWebhook, verifyTwilioSignature } from "./webhook.js";
4
- import { sendTwilioWhatsappMessage } from "./send.js";
5
- import { getRuntimeForPhoneNumber } from "./runtime-store.js";
6
- async function readBody(req) {
7
- return new Promise((resolve, reject) => {
8
- const chunks = [];
9
- req.on("data", (chunk) => chunks.push(chunk));
10
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
11
- req.on("error", reject);
12
- });
13
- }
14
- function twimlOk(res) {
15
- res.writeHead(200, { "Content-Type": "text/xml; charset=utf-8" });
16
- res.end('<?xml version="1.0" encoding="UTF-8"?><Response/>');
17
- }
18
- function twimlError(res, status, message) {
19
- res.writeHead(status, { "Content-Type": "text/xml; charset=utf-8" });
20
- res.end(`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${message}</Message></Response>`);
21
- }
22
- export function registerTwilioWhatsappHttpRoutes(api) {
23
- api.registerHttpRoute({
24
- path: "/twilio/whatsapp/events",
25
- auth: "plugin",
26
- handler: async (req, res) => {
27
- const rawBody = await readBody(req);
28
- const headers = req.headers;
29
- const signature = String(headers["x-twilio-signature"] ?? "");
30
- const webhookUrl = String(headers["x-twilio-webhook-url"] ?? "");
31
- const fields = parseTwilioWebhook(rawBody);
32
- const toPhone = normalizePhoneNumber(fields.to);
33
- const stored = getRuntimeForPhoneNumber(toPhone);
34
- if (!stored) {
35
- console.error(`[twilio-whatsapp] No runtime found for To=${fields.to}`);
36
- twimlError(res, 404, "No agent bound to this number");
37
- return true;
38
- }
39
- const account = findTwilioAccountByPhoneNumber(stored.cfg, toPhone);
40
- if (!account) {
41
- console.error(`[twilio-whatsapp] Account not found for phone=${toPhone}`);
42
- twimlError(res, 404, "Account not found");
43
- return true;
44
- }
45
- if (webhookUrl && signature) {
46
- const valid = verifyTwilioSignature({
47
- authToken: account.authToken,
48
- signature,
49
- url: webhookUrl,
50
- rawBody,
51
- });
52
- if (!valid) {
53
- console.error("[twilio-whatsapp] Invalid Twilio signature");
54
- twimlError(res, 403, "Forbidden");
55
- return true;
56
- }
57
- }
58
- else {
59
- console.warn("[twilio-whatsapp] Skipping sig verification — headers missing");
60
- }
61
- const fromPhone = normalizePhoneNumber(fields.from);
62
- const senderAddress = `whatsapp:${fromPhone}`;
63
- const recipientAddress = `whatsapp:${toPhone}`;
64
- const conversationLabel = fields.profileName || fromPhone;
65
- const messageText = fields.body.trim();
66
- const hasMedia = fields.media.length > 0;
67
- if (!messageText && !hasMedia) {
68
- twimlOk(res);
69
- return true;
70
- }
71
- const bodyForAgent = hasMedia
72
- ? [messageText, ...fields.media.map((m) => `[Media: ${m.url}]`)].filter(Boolean).join("\n")
73
- : messageText;
74
- twimlOk(res);
75
- dispatchInboundDirectDmWithRuntime({
76
- cfg: stored.cfg,
77
- runtime: { channel: stored.channelRuntime },
78
- channel: "twilio-whatsapp",
79
- channelLabel: "WhatsApp",
80
- accountId: account.accountId,
81
- peer: { kind: "direct", id: fromPhone },
82
- senderId: fromPhone,
83
- senderAddress,
84
- recipientAddress,
85
- conversationLabel,
86
- rawBody: messageText,
87
- bodyForAgent,
88
- messageId: fields.messageSid,
89
- timestamp: Date.now(),
90
- commandAuthorized: true,
91
- deliver: async (payload) => {
92
- const text = payload.text ?? "";
93
- const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
94
- if (!text && !mediaUrl)
95
- return;
96
- await sendTwilioWhatsappMessage({
97
- accountSid: account.accountSid,
98
- authToken: account.authToken,
99
- from: toPhone,
100
- to: fromPhone,
101
- body: text || undefined,
102
- mediaUrl,
103
- });
104
- },
105
- onRecordError: (err) => {
106
- console.error("[twilio-whatsapp] Session record error:", err);
107
- },
108
- onDispatchError: (err, info) => {
109
- console.error(`[twilio-whatsapp] Dispatch error (${info.kind}):`, err);
110
- },
111
- }).catch((err) => {
112
- console.error("[twilio-whatsapp] Unhandled dispatch error:", err);
113
- });
114
- return true;
115
- },
116
- });
117
- }
@@ -1,2 +0,0 @@
1
- import type { ChannelOutboundAdapter } from "openclaw/channels/plugins/types.adapters.js";
2
- export declare const twilioWhatsappOutbound: ChannelOutboundAdapter;
@@ -1,31 +0,0 @@
1
- import { resolveTwilioAccount } from "./accounts.js";
2
- import { sendTwilioWhatsappMessage } from "./send.js";
3
- async function deliver(ctx) {
4
- const account = resolveTwilioAccount(ctx.cfg, ctx.accountId);
5
- const sid = await sendTwilioWhatsappMessage({
6
- accountSid: account.accountSid,
7
- authToken: account.authToken,
8
- from: account.phoneNumber,
9
- to: ctx.to,
10
- body: ctx.text || undefined,
11
- mediaUrl: ctx.mediaUrl,
12
- });
13
- return sid;
14
- }
15
- export const twilioWhatsappOutbound = {
16
- deliveryMode: "direct",
17
- async sendText(ctx) {
18
- const messageId = await deliver(ctx);
19
- return {
20
- channel: "twilio-whatsapp",
21
- messageId,
22
- };
23
- },
24
- async sendMedia(ctx) {
25
- const messageId = await deliver(ctx);
26
- return {
27
- channel: "twilio-whatsapp",
28
- messageId,
29
- };
30
- },
31
- };
@@ -1,9 +0,0 @@
1
- import type { OpenClawConfig } from "openclaw/channels/plugins/types.js";
2
- export type StoredTwilioRuntime = {
3
- cfg: OpenClawConfig;
4
- accountId: string;
5
- channelRuntime: NonNullable<import("openclaw/channels/plugins/types.adapters.js").ChannelGatewayContext["channelRuntime"]>;
6
- };
7
- export declare function setRuntimeForPhoneNumber(phoneNumber: string, runtime: StoredTwilioRuntime): void;
8
- export declare function clearRuntimeForPhoneNumber(phoneNumber: string): void;
9
- export declare function getRuntimeForPhoneNumber(phoneNumber: string): StoredTwilioRuntime | undefined;
@@ -1,10 +0,0 @@
1
- const phoneToRuntime = new Map();
2
- export function setRuntimeForPhoneNumber(phoneNumber, runtime) {
3
- phoneToRuntime.set(phoneNumber, runtime);
4
- }
5
- export function clearRuntimeForPhoneNumber(phoneNumber) {
6
- phoneToRuntime.delete(phoneNumber);
7
- }
8
- export function getRuntimeForPhoneNumber(phoneNumber) {
9
- return phoneToRuntime.get(phoneNumber);
10
- }
@@ -1,8 +0,0 @@
1
- export declare function sendTwilioWhatsappMessage(opts: {
2
- accountSid: string;
3
- authToken: string;
4
- from: string;
5
- to: string;
6
- body?: string;
7
- mediaUrl?: string;
8
- }): Promise<string>;
package/dist/src/send.js DELETED
@@ -1,13 +0,0 @@
1
- import twilio from "twilio";
2
- export async function sendTwilioWhatsappMessage(opts) {
3
- const client = twilio(opts.accountSid, opts.authToken);
4
- const from = opts.from.startsWith("whatsapp:") ? opts.from : `whatsapp:${opts.from}`;
5
- const to = opts.to.startsWith("whatsapp:") ? opts.to : `whatsapp:${opts.to}`;
6
- const msg = await client.messages.create({
7
- from,
8
- to,
9
- ...(opts.body ? { body: opts.body } : {}),
10
- ...(opts.mediaUrl ? { mediaUrl: [opts.mediaUrl] } : {}),
11
- });
12
- return msg.sid;
13
- }
@@ -1,20 +0,0 @@
1
- export type ParsedTwilioWebhook = {
2
- from: string;
3
- to: string;
4
- body: string;
5
- waId: string;
6
- profileName: string;
7
- messageSid: string;
8
- media: Array<{
9
- url: string;
10
- contentType: string;
11
- }>;
12
- repliedToSid?: string;
13
- };
14
- export declare function verifyTwilioSignature(params: {
15
- authToken: string;
16
- signature: string;
17
- url: string;
18
- rawBody: string;
19
- }): boolean;
20
- export declare function parseTwilioWebhook(rawBody: string): ParsedTwilioWebhook;
@@ -1,28 +0,0 @@
1
- import twilio from "twilio";
2
- export function verifyTwilioSignature(params) {
3
- try {
4
- const parsed = Object.fromEntries(new URLSearchParams(params.rawBody));
5
- return twilio.validateRequest(params.authToken, params.signature, params.url, parsed);
6
- }
7
- catch {
8
- return false;
9
- }
10
- }
11
- export function parseTwilioWebhook(rawBody) {
12
- const p = Object.fromEntries(new URLSearchParams(rawBody));
13
- const numMedia = parseInt(p["NumMedia"] ?? "0", 10);
14
- const media = Array.from({ length: numMedia }, (_, i) => ({
15
- url: p[`MediaUrl${i}`] ?? "",
16
- contentType: p[`MediaContentType${i}`] ?? "application/octet-stream",
17
- }));
18
- return {
19
- from: (p["From"] ?? "").replace("whatsapp:", ""),
20
- to: (p["To"] ?? "").replace("whatsapp:", ""),
21
- body: p["Body"] ?? "",
22
- waId: p["WaId"] ?? "",
23
- profileName: p["ProfileName"] ?? "",
24
- messageSid: p["MessageSid"] ?? "",
25
- media,
26
- repliedToSid: p["OriginalRepliedMessageSid"],
27
- };
28
- }