@daolmedo/openclaw-twilio-whatsapp 1.1.25 → 1.1.27

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,13 +343,84 @@ 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);
317
406
  return 3e3;
318
407
  }
408
+ function resolveMediaBatchInitialWaitMs() {
409
+ const rawValue = process.env.TWILIO_WHATSAPP_MEDIA_BATCH_INITIAL_WAIT_MS;
410
+ const raw = Number(rawValue);
411
+ if (rawValue !== void 0 && Number.isFinite(raw) && raw >= 0) {
412
+ return Math.min(raw, 6e4);
413
+ }
414
+ if (process.env.TWILIO_WHATSAPP_MEDIA_BATCH_FLUSH_MS !== void 0) {
415
+ return resolveMediaBatchFlushMs();
416
+ }
417
+ return 15e3;
418
+ }
419
+ function resolveMediaBatchMaxWaitMs() {
420
+ const raw = Number(process.env.TWILIO_WHATSAPP_MEDIA_BATCH_MAX_WAIT_MS);
421
+ if (Number.isFinite(raw) && raw >= 0) return Math.min(raw, 6e4);
422
+ return Math.min(resolveMediaBatchInitialWaitMs() + resolveMediaBatchFlushMs(), 6e4);
423
+ }
319
424
  function resolveMediaBatchMaxItems() {
320
425
  const raw = Number(process.env.TWILIO_WHATSAPP_MEDIA_BATCH_MAX_ITEMS);
321
426
  if (Number.isFinite(raw) && raw > 0) return Math.floor(Math.min(raw, 50));
@@ -325,29 +430,15 @@ function summarizeText2(text) {
325
430
  if (!text) return "chars=0";
326
431
  return `chars=${text.length} preview=${JSON.stringify(text.slice(0, 120))}`;
327
432
  }
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
433
  function resolvePendingMediaKey(accountId, fromPhone) {
349
434
  return `${accountId}:${fromPhone}`;
350
435
  }
436
+ function uniqueMessageSids(sids) {
437
+ return [...new Set(sids.map((sid) => sid?.trim()).filter((sid) => Boolean(sid)))];
438
+ }
439
+ function combineBatchMessageText(items) {
440
+ return items.map((item) => item.messageText.trim()).filter(Boolean).join("\n\n");
441
+ }
351
442
  async function downloadTwilioMedia(url, account) {
352
443
  try {
353
444
  const authHeader = "Basic " + Buffer.from(`${account.accountSid}:${account.authToken}`).toString("base64");
@@ -380,82 +471,88 @@ function enqueueConversationDispatch(key, task) {
380
471
  console.error(`[twilio-whatsapp] Conversation dispatch failed key=${key}:`, err);
381
472
  });
382
473
  dispatchQueueByConversation.set(key, next);
383
- void next.finally(() => {
474
+ void next.then(() => {
384
475
  if (dispatchQueueByConversation.get(key) === next) {
385
476
  dispatchQueueByConversation.delete(key);
386
477
  }
387
478
  });
479
+ return next;
388
480
  }
389
481
  function stopBatchTimer(batch) {
390
- if (batch.timer) {
391
- clearTimeout(batch.timer);
392
- batch.timer = void 0;
393
- }
482
+ if (!batch.timer) return;
483
+ clearTimeout(batch.timer);
484
+ batch.timer = void 0;
394
485
  }
395
- function armBatchTimer(api, key, batch) {
486
+ function armBatchTimer(api, dispatchInbound, key, batch) {
396
487
  stopBatchTimer(batch);
397
- const flushMs = resolveMediaBatchFlushMs();
488
+ const batchAgeMs = Math.max(0, Date.now() - batch.firstReceivedAt);
489
+ const desiredWaitMs = batch.items.length === 1 ? resolveMediaBatchInitialWaitMs() : resolveMediaBatchFlushMs();
490
+ const remainingMaxWaitMs = Math.max(0, resolveMediaBatchMaxWaitMs() - batchAgeMs);
491
+ const waitMs = Math.min(desiredWaitMs, remainingMaxWaitMs);
398
492
  batch.timer = setTimeout(() => {
399
- flushPendingMediaBatch(api, key, "timer");
400
- }, flushMs);
401
- const maybeTimer = batch.timer;
402
- maybeTimer.unref?.();
403
- }
404
- function combineBatchMessageText(items) {
405
- return items.map((item) => item.messageText.trim()).filter(Boolean).join("\n\n");
493
+ void flushPendingMediaBatch(api, dispatchInbound, key, "timer");
494
+ }, waitMs);
495
+ batch.timer.unref?.();
406
496
  }
407
- function uniqueMessageSids(sids) {
408
- return [...new Set(sids.map((sid) => sid?.trim()).filter((sid) => Boolean(sid)))];
409
- }
410
- function flushPendingMediaBatch(api, key, reason) {
497
+ async function flushPendingMediaBatch(api, dispatchInbound, key, reason) {
411
498
  const batch = pendingMediaByConversation.get(key);
412
499
  if (!batch) return;
413
500
  stopBatchTimer(batch);
414
501
  pendingMediaByConversation.delete(key);
415
502
  const items = [...batch.items].sort((a, b) => a.receivedAt - b.receivedAt);
416
- const last = items[items.length - 1];
503
+ const last = items.at(-1);
417
504
  if (!last) return;
418
505
  const resolvedMedia = items.flatMap((item) => item.resolvedMedia);
419
- if (!resolvedMedia.length && !combineBatchMessageText(items)) {
506
+ const messageText = combineBatchMessageText(items);
507
+ if (!resolvedMedia.length && !messageText) {
508
+ for (const item of items) item.resolve();
420
509
  return;
421
510
  }
422
511
  const messageSids = uniqueMessageSids(items.map((item) => item.fields.messageSid));
423
512
  console.log(
424
513
  `[twilio-whatsapp] Flushing media batch key=${key} reason=${reason} messages=${items.length} media=${resolvedMedia.length} sids=${messageSids.join(",") || "unknown"}`
425
514
  );
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;
515
+ try {
516
+ await enqueueConversationDispatch(
517
+ key,
518
+ () => dispatchTwilioInbound(api, dispatchInbound, {
519
+ ...last,
520
+ messageText,
521
+ resolvedMedia,
522
+ messageSids
523
+ })
524
+ );
525
+ for (const item of items) item.resolve();
526
+ } catch (err) {
527
+ for (const item of items) item.reject(err);
455
528
  }
456
- armBatchTimer(api, key, batch);
457
529
  }
458
- async function dispatchTwilioInbound(api, params) {
530
+ function queueMediaBatch(api, dispatchInbound, key, item) {
531
+ return new Promise((resolve, reject) => {
532
+ let batch = pendingMediaByConversation.get(key);
533
+ if (!batch) {
534
+ batch = { items: [], firstReceivedAt: item.receivedAt };
535
+ pendingMediaByConversation.set(key, batch);
536
+ }
537
+ batch.items.push({ ...item, resolve, reject });
538
+ console.log(
539
+ `[twilio-whatsapp] Buffered media inbound sid=${item.fields.messageSid || "unknown"} key=${key} pendingMessages=${batch.items.length} pendingMedia=${batch.items.reduce(
540
+ (count, pending) => count + pending.resolvedMedia.length,
541
+ 0
542
+ )}`
543
+ );
544
+ if (batch.items.length >= resolveMediaBatchMaxItems()) {
545
+ void flushPendingMediaBatch(api, dispatchInbound, key, "max-items");
546
+ return;
547
+ }
548
+ if (resolveMediaBatchFlushMs() === 0) {
549
+ void flushPendingMediaBatch(api, dispatchInbound, key, "disabled");
550
+ return;
551
+ }
552
+ armBatchTimer(api, dispatchInbound, key, batch);
553
+ });
554
+ }
555
+ async function dispatchTwilioInbound(api, dispatchInbound, params) {
459
556
  const {
460
557
  fields,
461
558
  stored,
@@ -472,25 +569,26 @@ async function dispatchTwilioInbound(api, params) {
472
569
  MediaPath: resolvedMedia[0].path,
473
570
  MediaUrl: resolvedMedia[0].path,
474
571
  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)
572
+ MediaPaths: resolvedMedia.map((media) => media.path),
573
+ MediaUrls: resolvedMedia.map((media) => media.path),
574
+ MediaTypes: resolvedMedia.map((media) => media.contentType)
478
575
  } : {};
479
576
  let bodyForAgent = messageText || void 0;
480
- const firstAudio = resolvedMedia.find((m) => m.contentType.startsWith("audio/"));
577
+ const firstAudio = resolvedMedia.find((media) => media.contentType.startsWith("audio/"));
481
578
  if (firstAudio) {
482
579
  try {
483
580
  const cfgAny = stored.cfg;
484
581
  const agentId = cfgAny.bindings?.find(
485
- (b) => b.match?.channel === "twilio-whatsapp" && b.match?.accountId === stored.accountId
582
+ (binding) => binding.match?.channel === "twilio-whatsapp" && binding.match?.accountId === stored.accountId
486
583
  )?.agentId;
487
- const agentDir = cfgAny.agents?.list?.find(
488
- (a) => a.id === agentId
489
- )?.agentDir;
584
+ if (!agentId) {
585
+ throw new Error(`No agent binding found for WhatsApp account ${stored.accountId}`);
586
+ }
587
+ const workspaceDir = api.runtime.agent.resolveAgentWorkspaceDir(stored.cfg, agentId);
490
588
  const auth = await api.runtime.modelAuth.resolveApiKeyForProvider({
491
589
  provider: "openai",
492
- cfg: api.config,
493
- agentDir
590
+ cfg: stored.cfg,
591
+ workspaceDir
494
592
  });
495
593
  if (!auth.apiKey) throw new Error("No OpenAI API key available for audio transcription");
496
594
  const audioBuffer = await readFile(firstAudio.path);
@@ -512,8 +610,7 @@ async function dispatchTwilioInbound(api, params) {
512
610
  const errBody = await transcribeRes.text();
513
611
  throw new Error(`OpenAI transcription HTTP ${transcribeRes.status}: ${errBody}`);
514
612
  }
515
- const transcribeJson = await transcribeRes.json();
516
- const text = transcribeJson.text;
613
+ const { text } = await transcribeRes.json();
517
614
  console.log("[twilio-whatsapp] Transcription result:", text);
518
615
  if (text) {
519
616
  bodyForAgent = messageText ? `${messageText}
@@ -560,10 +657,9 @@ ${text}` : text;
560
657
  }
561
658
  };
562
659
  try {
563
- const timestamp = Date.now();
564
- await dispatchInboundDirectDmWithRuntime({
660
+ await dispatchInbound({
565
661
  cfg: stored.cfg,
566
- runtime: { channel: stored.channelRuntime },
662
+ runtime: api.runtime,
567
663
  channel: "twilio-whatsapp",
568
664
  channelLabel: "WhatsApp",
569
665
  accountId: account.accountId,
@@ -575,14 +671,16 @@ ${text}` : text;
575
671
  rawBody: messageText,
576
672
  bodyForAgent,
577
673
  messageId: fields.messageSid,
578
- timestamp,
579
- commandAuthorized: true,
674
+ timestamp: Date.now(),
675
+ commandAuthorized: params.commandAuthorized,
676
+ inboundAccessAuthorized: true,
580
677
  provider: "twilio-whatsapp",
581
678
  surface: "twilio-whatsapp",
582
679
  originatingChannel: "twilio-whatsapp",
583
680
  originatingTo: senderAddress,
584
681
  extraContext: mediaContext,
585
682
  deliver: async (payload) => {
683
+ stopTyping();
586
684
  const text = payload.text ?? "";
587
685
  const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
588
686
  console.log(
@@ -613,6 +711,7 @@ ${text}` : text;
613
711
  console.error("[twilio-whatsapp] Session record error:", err);
614
712
  },
615
713
  onDispatchError: (err, info) => {
714
+ stopTyping();
616
715
  console.error(`[twilio-whatsapp] Dispatch error (${info.kind}):`, err);
617
716
  }
618
717
  });
@@ -620,15 +719,33 @@ ${text}` : text;
620
719
  stopTyping();
621
720
  }
622
721
  }
623
- function registerTwilioWhatsappHttpRoutes(api) {
722
+ function registerTwilioWhatsappHttpRoutes(api, dependencies = {}) {
723
+ const dispatchInbound = dependencies.dispatchInbound ?? dispatchInboundDirectDmWithRuntime;
724
+ const downloadMedia = dependencies.downloadMedia ?? downloadTwilioMedia;
624
725
  api.registerHttpRoute({
625
726
  path: "/twilio/whatsapp/events",
626
727
  auth: "plugin",
627
728
  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"] ?? "");
729
+ if (req.method !== "POST") {
730
+ twimlError(res, 405, "Method not allowed");
731
+ return true;
732
+ }
733
+ let rawBody;
734
+ try {
735
+ rawBody = await readRequestBodyWithLimit(req, {
736
+ maxBytes: WEBHOOK_MAX_BODY_BYTES,
737
+ timeoutMs: 1e4,
738
+ destroyOnLimit: false
739
+ });
740
+ } catch (err) {
741
+ if (isRequestBodyLimitError(err)) {
742
+ twimlError(res, err.statusCode, "Invalid request body");
743
+ return true;
744
+ }
745
+ throw err;
746
+ }
747
+ const signature = firstHeader(req, "x-twilio-signature");
748
+ const webhookUrl = firstHeader(req, "x-twilio-webhook-url");
632
749
  const fields = parseTwilioWebhook(rawBody);
633
750
  const toPhone = normalizePhoneNumber(fields.to);
634
751
  console.log(
@@ -646,20 +763,26 @@ function registerTwilioWhatsappHttpRoutes(api) {
646
763
  twimlError(res, 404, "Account not found");
647
764
  return true;
648
765
  }
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");
766
+ if (!webhookUrl || !signature) {
767
+ console.error("[twilio-whatsapp] Missing forwarded Twilio signature headers");
768
+ twimlError(res, 403, "Forbidden");
769
+ return true;
770
+ }
771
+ const valid = verifyTwilioSignature({
772
+ authToken: account.authToken,
773
+ signature,
774
+ url: webhookUrl,
775
+ rawBody
776
+ });
777
+ if (!valid) {
778
+ console.error("[twilio-whatsapp] Invalid Twilio signature");
779
+ twimlError(res, 403, "Forbidden");
780
+ return true;
781
+ }
782
+ if (!fields.accountSid || fields.accountSid !== account.accountSid) {
783
+ console.error("[twilio-whatsapp] Twilio AccountSid does not match the target account");
784
+ twimlError(res, 403, "Forbidden");
785
+ return true;
663
786
  }
664
787
  const fromPhone = normalizePhoneNumber(fields.from);
665
788
  const senderAddress = `whatsapp:${fromPhone}`;
@@ -667,11 +790,33 @@ function registerTwilioWhatsappHttpRoutes(api) {
667
790
  const conversationLabel = fields.profileName || fromPhone;
668
791
  const messageText = fields.body.trim();
669
792
  const hasMedia = fields.media.length > 0;
670
- const receivedAt = Date.now();
671
793
  if (!messageText && !hasMedia) {
672
794
  twimlOk(res);
673
795
  return true;
674
796
  }
797
+ const rateLimitKey = `${account.accountId}:${fromPhone}`;
798
+ if (webhookRateLimiter.isRateLimited(rateLimitKey)) {
799
+ console.warn(`[twilio-whatsapp] Inbound rate limit exceeded for account=${account.accountId}`);
800
+ twimlError(res, 429, "Too many requests");
801
+ return true;
802
+ }
803
+ const inboundAccess = resolveTwilioInboundAccess({
804
+ channelConfig: getTwilioChannelConfig(stored.cfg),
805
+ senderPhone: fromPhone
806
+ });
807
+ if (!inboundAccess.allowed) {
808
+ console.warn(
809
+ `[twilio-whatsapp] Blocked inbound sender for account=${account.accountId}: ${inboundAccess.reason}`
810
+ );
811
+ twimlOk(res);
812
+ return true;
813
+ }
814
+ if (fields.messageSid && replayCache.seen(`${account.accountId}:${fields.messageSid}`)) {
815
+ console.info(`[twilio-whatsapp] Ignoring replayed MessageSid for account=${account.accountId}`);
816
+ twimlOk(res);
817
+ return true;
818
+ }
819
+ const receivedAt = Date.now();
675
820
  twimlOk(res);
676
821
  console.log(
677
822
  `[twilio-whatsapp] Webhook acknowledged sid=${fields.messageSid || "unknown"} accountId=${account.accountId}`
@@ -679,7 +824,7 @@ function registerTwilioWhatsappHttpRoutes(api) {
679
824
  const resolvedMedia = [];
680
825
  if (hasMedia) {
681
826
  const results = await Promise.all(
682
- fields.media.map((m) => downloadTwilioMedia(m.url, account))
827
+ fields.media.map((m) => downloadMedia(m.url, account))
683
828
  );
684
829
  for (const r of results) {
685
830
  if (r) resolvedMedia.push(r);
@@ -689,18 +834,22 @@ function registerTwilioWhatsappHttpRoutes(api) {
689
834
  );
690
835
  }
691
836
  const pendingMediaKey = resolvePendingMediaKey(account.accountId, fromPhone);
837
+ const dispatchParams = {
838
+ fields,
839
+ stored,
840
+ account,
841
+ toPhone,
842
+ fromPhone,
843
+ senderAddress,
844
+ recipientAddress,
845
+ conversationLabel,
846
+ messageText,
847
+ resolvedMedia,
848
+ commandAuthorized: inboundAccess.commandAuthorized
849
+ };
692
850
  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,
851
+ await queueMediaBatch(api, dispatchInbound, pendingMediaKey, {
852
+ ...dispatchParams,
704
853
  receivedAt
705
854
  });
706
855
  return true;
@@ -712,22 +861,12 @@ function registerTwilioWhatsappHttpRoutes(api) {
712
861
  return true;
713
862
  }
714
863
  if (!hasMedia) {
715
- flushPendingMediaBatch(api, pendingMediaKey, "before-text");
864
+ await flushPendingMediaBatch(api, dispatchInbound, pendingMediaKey, "before-text");
716
865
  }
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
- });
866
+ await enqueueConversationDispatch(
867
+ pendingMediaKey,
868
+ () => dispatchTwilioInbound(api, dispatchInbound, dispatchParams)
869
+ );
731
870
  return true;
732
871
  }
733
872
  });
@@ -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.27",
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.27",
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
- }