@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 +272 -153
- package/openclaw.plugin.json +1 -1
- package/package.json +51 -36
- package/dist/index.d.ts +0 -10
- package/dist/src/accounts.d.ts +0 -11
- package/dist/src/accounts.js +0 -38
- package/dist/src/channel.d.ts +0 -3
- package/dist/src/channel.js +0 -76
- package/dist/src/http-routes.d.ts +0 -2
- package/dist/src/http-routes.js +0 -117
- package/dist/src/outbound-adapter.d.ts +0 -2
- package/dist/src/outbound-adapter.js +0 -31
- package/dist/src/runtime-store.d.ts +0 -9
- package/dist/src/runtime-store.js +0 -10
- package/dist/src/send.d.ts +0 -8
- package/dist/src/send.js +0 -13
- package/dist/src/webhook.d.ts +0 -20
- package/dist/src/webhook.js +0 -28
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
|
|
5
|
+
var getTwilioChannelConfig = (cfg) => cfg.channels && cfg.channels["twilio-whatsapp"] ? cfg.channels["twilio-whatsapp"] : void 0;
|
|
6
6
|
function listTwilioAccountIds(cfg) {
|
|
7
|
-
const ch =
|
|
7
|
+
const ch = getTwilioChannelConfig(cfg);
|
|
8
8
|
return Object.keys(ch?.accounts ?? {});
|
|
9
9
|
}
|
|
10
10
|
function resolveTwilioAccount(cfg, accountId) {
|
|
11
|
-
const ch =
|
|
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 =
|
|
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/
|
|
248
|
-
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-
|
|
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.
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
},
|
|
401
|
-
|
|
402
|
-
maybeTimer.unref?.();
|
|
473
|
+
void flushPendingMediaBatch(api, dispatchInbound, key, "timer");
|
|
474
|
+
}, resolveMediaBatchFlushMs());
|
|
475
|
+
batch.timer.unref?.();
|
|
403
476
|
}
|
|
404
|
-
function
|
|
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
|
|
483
|
+
const last = items.at(-1);
|
|
417
484
|
if (!last) return;
|
|
418
485
|
const resolvedMedia = items.flatMap((item) => item.resolvedMedia);
|
|
419
|
-
|
|
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
|
-
|
|
427
|
-
await
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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((
|
|
476
|
-
MediaUrls: resolvedMedia.map((
|
|
477
|
-
MediaTypes: resolvedMedia.map((
|
|
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((
|
|
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
|
-
(
|
|
562
|
+
(binding) => binding.match?.channel === "twilio-whatsapp" && binding.match?.accountId === stored.accountId
|
|
486
563
|
)?.agentId;
|
|
487
|
-
|
|
488
|
-
(
|
|
489
|
-
|
|
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:
|
|
493
|
-
|
|
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
|
|
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
|
-
|
|
564
|
-
await dispatchInboundDirectDmWithRuntime({
|
|
640
|
+
await dispatchInbound({
|
|
565
641
|
cfg: stored.cfg,
|
|
566
|
-
runtime:
|
|
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:
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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) =>
|
|
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
|
-
|
|
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(
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
});
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,36 +1,51 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@daolmedo/openclaw-twilio-whatsapp",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "Twilio WhatsApp channel plugin for OpenClaw (Cody ISV)",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
},
|
|
9
|
-
"
|
|
10
|
-
"dist"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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;
|
package/dist/src/accounts.d.ts
DELETED
|
@@ -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;
|
package/dist/src/accounts.js
DELETED
|
@@ -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
|
-
}
|
package/dist/src/channel.d.ts
DELETED
package/dist/src/channel.js
DELETED
|
@@ -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
|
-
};
|
package/dist/src/http-routes.js
DELETED
|
@@ -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,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
|
-
}
|
package/dist/src/send.d.ts
DELETED
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
|
-
}
|
package/dist/src/webhook.d.ts
DELETED
|
@@ -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;
|
package/dist/src/webhook.js
DELETED
|
@@ -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
|
-
}
|