@daolmedo/openclaw-twilio-whatsapp 1.1.24 → 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,78 +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 flushPendingMediaBatch(api, key, reason) {
477
+ async function flushPendingMediaBatch(api, dispatchInbound, key, reason) {
408
478
  const batch = pendingMediaByConversation.get(key);
409
479
  if (!batch) return;
410
480
  stopBatchTimer(batch);
411
481
  pendingMediaByConversation.delete(key);
412
482
  const items = [...batch.items].sort((a, b) => a.receivedAt - b.receivedAt);
413
- const last = items[items.length - 1];
483
+ const last = items.at(-1);
414
484
  if (!last) return;
415
485
  const resolvedMedia = items.flatMap((item) => item.resolvedMedia);
416
- if (!resolvedMedia.length && !combineBatchMessageText(items)) {
486
+ const messageText = combineBatchMessageText(items);
487
+ if (!resolvedMedia.length && !messageText) {
488
+ for (const item of items) item.resolve();
417
489
  return;
418
490
  }
419
- const messageSids = items.map((item) => item.fields.messageSid || "unknown");
491
+ const messageSids = uniqueMessageSids(items.map((item) => item.fields.messageSid));
420
492
  console.log(
421
- `[twilio-whatsapp] Flushing media batch key=${key} reason=${reason} messages=${items.length} media=${resolvedMedia.length} sids=${messageSids.join(",")}`
493
+ `[twilio-whatsapp] Flushing media batch key=${key} reason=${reason} messages=${items.length} media=${resolvedMedia.length} sids=${messageSids.join(",") || "unknown"}`
422
494
  );
423
- enqueueConversationDispatch(key, async () => {
424
- await dispatchTwilioInbound(api, {
425
- ...last,
426
- messageText: combineBatchMessageText(items),
427
- resolvedMedia
428
- });
429
- });
430
- }
431
- function queueMediaBatch(api, key, item) {
432
- let batch = pendingMediaByConversation.get(key);
433
- if (!batch) {
434
- batch = { items: [] };
435
- pendingMediaByConversation.set(key, batch);
436
- }
437
- batch.items.push(item);
438
- console.log(
439
- `[twilio-whatsapp] Buffered media inbound sid=${item.fields.messageSid || "unknown"} key=${key} pendingMessages=${batch.items.length} pendingMedia=${batch.items.reduce(
440
- (count, pending) => count + pending.resolvedMedia.length,
441
- 0
442
- )}`
443
- );
444
- if (batch.items.length >= resolveMediaBatchMaxItems()) {
445
- flushPendingMediaBatch(api, key, "max-items");
446
- return;
447
- }
448
- if (resolveMediaBatchFlushMs() === 0) {
449
- flushPendingMediaBatch(api, key, "disabled");
450
- 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);
451
508
  }
452
- armBatchTimer(api, key, batch);
453
509
  }
454
- 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) {
455
536
  const {
456
537
  fields,
457
538
  stored,
@@ -468,25 +549,26 @@ async function dispatchTwilioInbound(api, params) {
468
549
  MediaPath: resolvedMedia[0].path,
469
550
  MediaUrl: resolvedMedia[0].path,
470
551
  MediaType: resolvedMedia[0].contentType,
471
- MediaPaths: resolvedMedia.map((m) => m.path),
472
- MediaUrls: resolvedMedia.map((m) => m.path),
473
- 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)
474
555
  } : {};
475
556
  let bodyForAgent = messageText || void 0;
476
- const firstAudio = resolvedMedia.find((m) => m.contentType.startsWith("audio/"));
557
+ const firstAudio = resolvedMedia.find((media) => media.contentType.startsWith("audio/"));
477
558
  if (firstAudio) {
478
559
  try {
479
560
  const cfgAny = stored.cfg;
480
561
  const agentId = cfgAny.bindings?.find(
481
- (b) => b.match?.channel === "twilio-whatsapp" && b.match?.accountId === stored.accountId
562
+ (binding) => binding.match?.channel === "twilio-whatsapp" && binding.match?.accountId === stored.accountId
482
563
  )?.agentId;
483
- const agentDir = cfgAny.agents?.list?.find(
484
- (a) => a.id === agentId
485
- )?.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);
486
568
  const auth = await api.runtime.modelAuth.resolveApiKeyForProvider({
487
569
  provider: "openai",
488
- cfg: api.config,
489
- agentDir
570
+ cfg: stored.cfg,
571
+ workspaceDir
490
572
  });
491
573
  if (!auth.apiKey) throw new Error("No OpenAI API key available for audio transcription");
492
574
  const audioBuffer = await readFile(firstAudio.path);
@@ -508,8 +590,7 @@ async function dispatchTwilioInbound(api, params) {
508
590
  const errBody = await transcribeRes.text();
509
591
  throw new Error(`OpenAI transcription HTTP ${transcribeRes.status}: ${errBody}`);
510
592
  }
511
- const transcribeJson = await transcribeRes.json();
512
- const text = transcribeJson.text;
593
+ const { text } = await transcribeRes.json();
513
594
  console.log("[twilio-whatsapp] Transcription result:", text);
514
595
  if (text) {
515
596
  bodyForAgent = messageText ? `${messageText}
@@ -534,27 +615,31 @@ ${text}` : text;
534
615
  console.log(
535
616
  `[twilio-whatsapp] Dispatching inbound sid=${fields.messageSid || "unknown"} bodyForAgent=${summarizeText2(bodyForAgent)} media=${resolvedMedia.length}`
536
617
  );
537
- const stopTypingKeepalive = fields.messageSid ? (() => {
538
- console.log(`[twilio-whatsapp] Typing keepalive start sid=${fields.messageSid}`);
539
- return startTypingKeepalive({
540
- accountSid: account.accountSid,
541
- authToken: account.authToken,
542
- messageId: fields.messageSid
543
- });
544
- })() : () => {
545
- };
618
+ const typingMessageSids = uniqueMessageSids(params.messageSids ?? [fields.messageSid]);
619
+ const stopTypingKeepalives = typingMessageSids.map((messageSid) => {
620
+ console.log(`[twilio-whatsapp] Typing keepalive start sid=${messageSid}`);
621
+ return {
622
+ messageSid,
623
+ stop: startTypingKeepalive({
624
+ accountSid: account.accountSid,
625
+ authToken: account.authToken,
626
+ messageId: messageSid
627
+ })
628
+ };
629
+ });
546
630
  let typingStopped = false;
547
631
  const stopTyping = () => {
548
632
  if (typingStopped) return;
549
633
  typingStopped = true;
550
- stopTypingKeepalive();
551
- console.log(`[twilio-whatsapp] Typing keepalive stop sid=${fields.messageSid || "unknown"}`);
634
+ for (const keepalive of stopTypingKeepalives) {
635
+ keepalive.stop();
636
+ console.log(`[twilio-whatsapp] Typing keepalive stop sid=${keepalive.messageSid}`);
637
+ }
552
638
  };
553
639
  try {
554
- const timestamp = Date.now();
555
- await dispatchInboundDirectDmWithRuntime({
640
+ await dispatchInbound({
556
641
  cfg: stored.cfg,
557
- runtime: { channel: stored.channelRuntime },
642
+ runtime: api.runtime,
558
643
  channel: "twilio-whatsapp",
559
644
  channelLabel: "WhatsApp",
560
645
  accountId: account.accountId,
@@ -566,14 +651,16 @@ ${text}` : text;
566
651
  rawBody: messageText,
567
652
  bodyForAgent,
568
653
  messageId: fields.messageSid,
569
- timestamp,
570
- commandAuthorized: true,
654
+ timestamp: Date.now(),
655
+ commandAuthorized: params.commandAuthorized,
656
+ inboundAccessAuthorized: true,
571
657
  provider: "twilio-whatsapp",
572
658
  surface: "twilio-whatsapp",
573
659
  originatingChannel: "twilio-whatsapp",
574
660
  originatingTo: senderAddress,
575
661
  extraContext: mediaContext,
576
662
  deliver: async (payload) => {
663
+ stopTyping();
577
664
  const text = payload.text ?? "";
578
665
  const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
579
666
  console.log(
@@ -604,6 +691,7 @@ ${text}` : text;
604
691
  console.error("[twilio-whatsapp] Session record error:", err);
605
692
  },
606
693
  onDispatchError: (err, info) => {
694
+ stopTyping();
607
695
  console.error(`[twilio-whatsapp] Dispatch error (${info.kind}):`, err);
608
696
  }
609
697
  });
@@ -611,15 +699,33 @@ ${text}` : text;
611
699
  stopTyping();
612
700
  }
613
701
  }
614
- function registerTwilioWhatsappHttpRoutes(api) {
702
+ function registerTwilioWhatsappHttpRoutes(api, dependencies = {}) {
703
+ const dispatchInbound = dependencies.dispatchInbound ?? dispatchInboundDirectDmWithRuntime;
704
+ const downloadMedia = dependencies.downloadMedia ?? downloadTwilioMedia;
615
705
  api.registerHttpRoute({
616
706
  path: "/twilio/whatsapp/events",
617
707
  auth: "plugin",
618
708
  handler: async (req, res) => {
619
- const rawBody = await readBody(req);
620
- const headers = req.headers;
621
- const signature = String(headers["x-twilio-signature"] ?? "");
622
- 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");
623
729
  const fields = parseTwilioWebhook(rawBody);
624
730
  const toPhone = normalizePhoneNumber(fields.to);
625
731
  console.log(
@@ -637,20 +743,26 @@ function registerTwilioWhatsappHttpRoutes(api) {
637
743
  twimlError(res, 404, "Account not found");
638
744
  return true;
639
745
  }
640
- if (webhookUrl && signature) {
641
- const valid = verifyTwilioSignature({
642
- authToken: account.authToken,
643
- signature,
644
- url: webhookUrl,
645
- rawBody
646
- });
647
- if (!valid) {
648
- console.error("[twilio-whatsapp] Invalid Twilio signature");
649
- twimlError(res, 403, "Forbidden");
650
- return true;
651
- }
652
- } else {
653
- 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;
654
766
  }
655
767
  const fromPhone = normalizePhoneNumber(fields.from);
656
768
  const senderAddress = `whatsapp:${fromPhone}`;
@@ -658,11 +770,33 @@ function registerTwilioWhatsappHttpRoutes(api) {
658
770
  const conversationLabel = fields.profileName || fromPhone;
659
771
  const messageText = fields.body.trim();
660
772
  const hasMedia = fields.media.length > 0;
661
- const receivedAt = Date.now();
662
773
  if (!messageText && !hasMedia) {
663
774
  twimlOk(res);
664
775
  return true;
665
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();
666
800
  twimlOk(res);
667
801
  console.log(
668
802
  `[twilio-whatsapp] Webhook acknowledged sid=${fields.messageSid || "unknown"} accountId=${account.accountId}`
@@ -670,7 +804,7 @@ function registerTwilioWhatsappHttpRoutes(api) {
670
804
  const resolvedMedia = [];
671
805
  if (hasMedia) {
672
806
  const results = await Promise.all(
673
- fields.media.map((m) => downloadTwilioMedia(m.url, account))
807
+ fields.media.map((m) => downloadMedia(m.url, account))
674
808
  );
675
809
  for (const r of results) {
676
810
  if (r) resolvedMedia.push(r);
@@ -680,18 +814,22 @@ function registerTwilioWhatsappHttpRoutes(api) {
680
814
  );
681
815
  }
682
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
+ };
683
830
  if (hasMedia && resolvedMedia.length > 0) {
684
- queueMediaBatch(api, pendingMediaKey, {
685
- fields,
686
- stored,
687
- account,
688
- toPhone,
689
- fromPhone,
690
- senderAddress,
691
- recipientAddress,
692
- conversationLabel,
693
- messageText,
694
- resolvedMedia,
831
+ await queueMediaBatch(api, dispatchInbound, pendingMediaKey, {
832
+ ...dispatchParams,
695
833
  receivedAt
696
834
  });
697
835
  return true;
@@ -703,22 +841,12 @@ function registerTwilioWhatsappHttpRoutes(api) {
703
841
  return true;
704
842
  }
705
843
  if (!hasMedia) {
706
- flushPendingMediaBatch(api, pendingMediaKey, "before-text");
844
+ await flushPendingMediaBatch(api, dispatchInbound, pendingMediaKey, "before-text");
707
845
  }
708
- enqueueConversationDispatch(pendingMediaKey, async () => {
709
- await dispatchTwilioInbound(api, {
710
- fields,
711
- stored,
712
- account,
713
- toPhone,
714
- fromPhone,
715
- senderAddress,
716
- recipientAddress,
717
- conversationLabel,
718
- messageText,
719
- resolvedMedia
720
- });
721
- });
846
+ await enqueueConversationDispatch(
847
+ pendingMediaKey,
848
+ () => dispatchTwilioInbound(api, dispatchInbound, dispatchParams)
849
+ );
722
850
  return true;
723
851
  }
724
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.24",
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
- }