@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 +290 -162
- 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,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.
|
|
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 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
|
|
483
|
+
const last = items.at(-1);
|
|
414
484
|
if (!last) return;
|
|
415
485
|
const resolvedMedia = items.flatMap((item) => item.resolvedMedia);
|
|
416
|
-
|
|
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
|
|
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
|
-
|
|
424
|
-
await
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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((
|
|
472
|
-
MediaUrls: resolvedMedia.map((
|
|
473
|
-
MediaTypes: resolvedMedia.map((
|
|
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((
|
|
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
|
-
(
|
|
562
|
+
(binding) => binding.match?.channel === "twilio-whatsapp" && binding.match?.accountId === stored.accountId
|
|
482
563
|
)?.agentId;
|
|
483
|
-
|
|
484
|
-
(
|
|
485
|
-
|
|
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:
|
|
489
|
-
|
|
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
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
551
|
-
|
|
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
|
-
|
|
555
|
-
await dispatchInboundDirectDmWithRuntime({
|
|
640
|
+
await dispatchInbound({
|
|
556
641
|
cfg: stored.cfg,
|
|
557
|
-
runtime:
|
|
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:
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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) =>
|
|
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
|
-
|
|
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(
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
});
|
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
|
-
}
|