@daolmedo/openclaw-twilio-whatsapp 1.1.25 → 1.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +292 -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,13 +343,84 @@ function startTypingKeepalive(opts) {
|
|
|
309
343
|
};
|
|
310
344
|
}
|
|
311
345
|
|
|
346
|
+
// src/replay-cache.ts
|
|
347
|
+
function createReplayCache(options) {
|
|
348
|
+
const entries = /* @__PURE__ */ new Map();
|
|
349
|
+
function prune(nowMs) {
|
|
350
|
+
for (const [key, expiresAt] of entries) {
|
|
351
|
+
if (expiresAt <= nowMs) entries.delete(key);
|
|
352
|
+
}
|
|
353
|
+
while (entries.size > options.maxEntries) {
|
|
354
|
+
const oldest = entries.keys().next().value;
|
|
355
|
+
if (oldest === void 0) break;
|
|
356
|
+
entries.delete(oldest);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
seen(key, nowMs = Date.now()) {
|
|
361
|
+
if (!key) return false;
|
|
362
|
+
const expiresAt = entries.get(key);
|
|
363
|
+
if (expiresAt !== void 0 && expiresAt > nowMs) return true;
|
|
364
|
+
entries.delete(key);
|
|
365
|
+
entries.set(key, nowMs + options.ttlMs);
|
|
366
|
+
prune(nowMs);
|
|
367
|
+
return false;
|
|
368
|
+
},
|
|
369
|
+
clear() {
|
|
370
|
+
entries.clear();
|
|
371
|
+
},
|
|
372
|
+
size() {
|
|
373
|
+
return entries.size;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
312
378
|
// src/http-routes.ts
|
|
313
379
|
var MEDIA_MAX_BYTES = 16 * 1024 * 1024;
|
|
380
|
+
var WEBHOOK_MAX_BODY_BYTES = 128 * 1024;
|
|
381
|
+
var webhookRateLimiter = createFixedWindowRateLimiter({
|
|
382
|
+
windowMs: 6e4,
|
|
383
|
+
maxRequests: 120,
|
|
384
|
+
maxTrackedKeys: 4096
|
|
385
|
+
});
|
|
386
|
+
var replayCache = createReplayCache({ ttlMs: 10 * 6e4, maxEntries: 1e4 });
|
|
387
|
+
function twimlOk(res) {
|
|
388
|
+
res.writeHead(200, { "Content-Type": "text/xml; charset=utf-8" });
|
|
389
|
+
res.end('<?xml version="1.0" encoding="UTF-8"?><Response/>');
|
|
390
|
+
}
|
|
391
|
+
function twimlError(res, status, message) {
|
|
392
|
+
res.writeHead(status, { "Content-Type": "text/xml; charset=utf-8" });
|
|
393
|
+
res.end(
|
|
394
|
+
`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${message}</Message></Response>`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
function firstHeader(req, name) {
|
|
398
|
+
const value = req.headers[name.toLowerCase()];
|
|
399
|
+
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
|
400
|
+
}
|
|
401
|
+
var pendingMediaByConversation = /* @__PURE__ */ new Map();
|
|
402
|
+
var dispatchQueueByConversation = /* @__PURE__ */ new Map();
|
|
314
403
|
function resolveMediaBatchFlushMs() {
|
|
315
404
|
const raw = Number(process.env.TWILIO_WHATSAPP_MEDIA_BATCH_FLUSH_MS);
|
|
316
405
|
if (Number.isFinite(raw) && raw >= 0) return Math.min(raw, 6e4);
|
|
317
406
|
return 3e3;
|
|
318
407
|
}
|
|
408
|
+
function resolveMediaBatchInitialWaitMs() {
|
|
409
|
+
const rawValue = process.env.TWILIO_WHATSAPP_MEDIA_BATCH_INITIAL_WAIT_MS;
|
|
410
|
+
const raw = Number(rawValue);
|
|
411
|
+
if (rawValue !== void 0 && Number.isFinite(raw) && raw >= 0) {
|
|
412
|
+
return Math.min(raw, 6e4);
|
|
413
|
+
}
|
|
414
|
+
if (process.env.TWILIO_WHATSAPP_MEDIA_BATCH_FLUSH_MS !== void 0) {
|
|
415
|
+
return resolveMediaBatchFlushMs();
|
|
416
|
+
}
|
|
417
|
+
return 15e3;
|
|
418
|
+
}
|
|
419
|
+
function resolveMediaBatchMaxWaitMs() {
|
|
420
|
+
const raw = Number(process.env.TWILIO_WHATSAPP_MEDIA_BATCH_MAX_WAIT_MS);
|
|
421
|
+
if (Number.isFinite(raw) && raw >= 0) return Math.min(raw, 6e4);
|
|
422
|
+
return Math.min(resolveMediaBatchInitialWaitMs() + resolveMediaBatchFlushMs(), 6e4);
|
|
423
|
+
}
|
|
319
424
|
function resolveMediaBatchMaxItems() {
|
|
320
425
|
const raw = Number(process.env.TWILIO_WHATSAPP_MEDIA_BATCH_MAX_ITEMS);
|
|
321
426
|
if (Number.isFinite(raw) && raw > 0) return Math.floor(Math.min(raw, 50));
|
|
@@ -325,29 +430,15 @@ function summarizeText2(text) {
|
|
|
325
430
|
if (!text) return "chars=0";
|
|
326
431
|
return `chars=${text.length} preview=${JSON.stringify(text.slice(0, 120))}`;
|
|
327
432
|
}
|
|
328
|
-
async function readBody(req) {
|
|
329
|
-
return new Promise((resolve, reject) => {
|
|
330
|
-
const chunks = [];
|
|
331
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
332
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
333
|
-
req.on("error", reject);
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
function twimlOk(res) {
|
|
337
|
-
res.writeHead(200, { "Content-Type": "text/xml; charset=utf-8" });
|
|
338
|
-
res.end('<?xml version="1.0" encoding="UTF-8"?><Response/>');
|
|
339
|
-
}
|
|
340
|
-
function twimlError(res, status, message) {
|
|
341
|
-
res.writeHead(status, { "Content-Type": "text/xml; charset=utf-8" });
|
|
342
|
-
res.end(
|
|
343
|
-
`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${message}</Message></Response>`
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
var pendingMediaByConversation = /* @__PURE__ */ new Map();
|
|
347
|
-
var dispatchQueueByConversation = /* @__PURE__ */ new Map();
|
|
348
433
|
function resolvePendingMediaKey(accountId, fromPhone) {
|
|
349
434
|
return `${accountId}:${fromPhone}`;
|
|
350
435
|
}
|
|
436
|
+
function uniqueMessageSids(sids) {
|
|
437
|
+
return [...new Set(sids.map((sid) => sid?.trim()).filter((sid) => Boolean(sid)))];
|
|
438
|
+
}
|
|
439
|
+
function combineBatchMessageText(items) {
|
|
440
|
+
return items.map((item) => item.messageText.trim()).filter(Boolean).join("\n\n");
|
|
441
|
+
}
|
|
351
442
|
async function downloadTwilioMedia(url, account) {
|
|
352
443
|
try {
|
|
353
444
|
const authHeader = "Basic " + Buffer.from(`${account.accountSid}:${account.authToken}`).toString("base64");
|
|
@@ -380,82 +471,88 @@ function enqueueConversationDispatch(key, task) {
|
|
|
380
471
|
console.error(`[twilio-whatsapp] Conversation dispatch failed key=${key}:`, err);
|
|
381
472
|
});
|
|
382
473
|
dispatchQueueByConversation.set(key, next);
|
|
383
|
-
void next.
|
|
474
|
+
void next.then(() => {
|
|
384
475
|
if (dispatchQueueByConversation.get(key) === next) {
|
|
385
476
|
dispatchQueueByConversation.delete(key);
|
|
386
477
|
}
|
|
387
478
|
});
|
|
479
|
+
return next;
|
|
388
480
|
}
|
|
389
481
|
function stopBatchTimer(batch) {
|
|
390
|
-
if (batch.timer)
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
482
|
+
if (!batch.timer) return;
|
|
483
|
+
clearTimeout(batch.timer);
|
|
484
|
+
batch.timer = void 0;
|
|
394
485
|
}
|
|
395
|
-
function armBatchTimer(api, key, batch) {
|
|
486
|
+
function armBatchTimer(api, dispatchInbound, key, batch) {
|
|
396
487
|
stopBatchTimer(batch);
|
|
397
|
-
const
|
|
488
|
+
const batchAgeMs = Math.max(0, Date.now() - batch.firstReceivedAt);
|
|
489
|
+
const desiredWaitMs = batch.items.length === 1 ? resolveMediaBatchInitialWaitMs() : resolveMediaBatchFlushMs();
|
|
490
|
+
const remainingMaxWaitMs = Math.max(0, resolveMediaBatchMaxWaitMs() - batchAgeMs);
|
|
491
|
+
const waitMs = Math.min(desiredWaitMs, remainingMaxWaitMs);
|
|
398
492
|
batch.timer = setTimeout(() => {
|
|
399
|
-
flushPendingMediaBatch(api, key, "timer");
|
|
400
|
-
},
|
|
401
|
-
|
|
402
|
-
maybeTimer.unref?.();
|
|
403
|
-
}
|
|
404
|
-
function combineBatchMessageText(items) {
|
|
405
|
-
return items.map((item) => item.messageText.trim()).filter(Boolean).join("\n\n");
|
|
493
|
+
void flushPendingMediaBatch(api, dispatchInbound, key, "timer");
|
|
494
|
+
}, waitMs);
|
|
495
|
+
batch.timer.unref?.();
|
|
406
496
|
}
|
|
407
|
-
function
|
|
408
|
-
return [...new Set(sids.map((sid) => sid?.trim()).filter((sid) => Boolean(sid)))];
|
|
409
|
-
}
|
|
410
|
-
function flushPendingMediaBatch(api, key, reason) {
|
|
497
|
+
async function flushPendingMediaBatch(api, dispatchInbound, key, reason) {
|
|
411
498
|
const batch = pendingMediaByConversation.get(key);
|
|
412
499
|
if (!batch) return;
|
|
413
500
|
stopBatchTimer(batch);
|
|
414
501
|
pendingMediaByConversation.delete(key);
|
|
415
502
|
const items = [...batch.items].sort((a, b) => a.receivedAt - b.receivedAt);
|
|
416
|
-
const last = items
|
|
503
|
+
const last = items.at(-1);
|
|
417
504
|
if (!last) return;
|
|
418
505
|
const resolvedMedia = items.flatMap((item) => item.resolvedMedia);
|
|
419
|
-
|
|
506
|
+
const messageText = combineBatchMessageText(items);
|
|
507
|
+
if (!resolvedMedia.length && !messageText) {
|
|
508
|
+
for (const item of items) item.resolve();
|
|
420
509
|
return;
|
|
421
510
|
}
|
|
422
511
|
const messageSids = uniqueMessageSids(items.map((item) => item.fields.messageSid));
|
|
423
512
|
console.log(
|
|
424
513
|
`[twilio-whatsapp] Flushing media batch key=${key} reason=${reason} messages=${items.length} media=${resolvedMedia.length} sids=${messageSids.join(",") || "unknown"}`
|
|
425
514
|
);
|
|
426
|
-
|
|
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;
|
|
515
|
+
try {
|
|
516
|
+
await enqueueConversationDispatch(
|
|
517
|
+
key,
|
|
518
|
+
() => dispatchTwilioInbound(api, dispatchInbound, {
|
|
519
|
+
...last,
|
|
520
|
+
messageText,
|
|
521
|
+
resolvedMedia,
|
|
522
|
+
messageSids
|
|
523
|
+
})
|
|
524
|
+
);
|
|
525
|
+
for (const item of items) item.resolve();
|
|
526
|
+
} catch (err) {
|
|
527
|
+
for (const item of items) item.reject(err);
|
|
455
528
|
}
|
|
456
|
-
armBatchTimer(api, key, batch);
|
|
457
529
|
}
|
|
458
|
-
|
|
530
|
+
function queueMediaBatch(api, dispatchInbound, key, item) {
|
|
531
|
+
return new Promise((resolve, reject) => {
|
|
532
|
+
let batch = pendingMediaByConversation.get(key);
|
|
533
|
+
if (!batch) {
|
|
534
|
+
batch = { items: [], firstReceivedAt: item.receivedAt };
|
|
535
|
+
pendingMediaByConversation.set(key, batch);
|
|
536
|
+
}
|
|
537
|
+
batch.items.push({ ...item, resolve, reject });
|
|
538
|
+
console.log(
|
|
539
|
+
`[twilio-whatsapp] Buffered media inbound sid=${item.fields.messageSid || "unknown"} key=${key} pendingMessages=${batch.items.length} pendingMedia=${batch.items.reduce(
|
|
540
|
+
(count, pending) => count + pending.resolvedMedia.length,
|
|
541
|
+
0
|
|
542
|
+
)}`
|
|
543
|
+
);
|
|
544
|
+
if (batch.items.length >= resolveMediaBatchMaxItems()) {
|
|
545
|
+
void flushPendingMediaBatch(api, dispatchInbound, key, "max-items");
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (resolveMediaBatchFlushMs() === 0) {
|
|
549
|
+
void flushPendingMediaBatch(api, dispatchInbound, key, "disabled");
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
armBatchTimer(api, dispatchInbound, key, batch);
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
async function dispatchTwilioInbound(api, dispatchInbound, params) {
|
|
459
556
|
const {
|
|
460
557
|
fields,
|
|
461
558
|
stored,
|
|
@@ -472,25 +569,26 @@ async function dispatchTwilioInbound(api, params) {
|
|
|
472
569
|
MediaPath: resolvedMedia[0].path,
|
|
473
570
|
MediaUrl: resolvedMedia[0].path,
|
|
474
571
|
MediaType: resolvedMedia[0].contentType,
|
|
475
|
-
MediaPaths: resolvedMedia.map((
|
|
476
|
-
MediaUrls: resolvedMedia.map((
|
|
477
|
-
MediaTypes: resolvedMedia.map((
|
|
572
|
+
MediaPaths: resolvedMedia.map((media) => media.path),
|
|
573
|
+
MediaUrls: resolvedMedia.map((media) => media.path),
|
|
574
|
+
MediaTypes: resolvedMedia.map((media) => media.contentType)
|
|
478
575
|
} : {};
|
|
479
576
|
let bodyForAgent = messageText || void 0;
|
|
480
|
-
const firstAudio = resolvedMedia.find((
|
|
577
|
+
const firstAudio = resolvedMedia.find((media) => media.contentType.startsWith("audio/"));
|
|
481
578
|
if (firstAudio) {
|
|
482
579
|
try {
|
|
483
580
|
const cfgAny = stored.cfg;
|
|
484
581
|
const agentId = cfgAny.bindings?.find(
|
|
485
|
-
(
|
|
582
|
+
(binding) => binding.match?.channel === "twilio-whatsapp" && binding.match?.accountId === stored.accountId
|
|
486
583
|
)?.agentId;
|
|
487
|
-
|
|
488
|
-
(
|
|
489
|
-
|
|
584
|
+
if (!agentId) {
|
|
585
|
+
throw new Error(`No agent binding found for WhatsApp account ${stored.accountId}`);
|
|
586
|
+
}
|
|
587
|
+
const workspaceDir = api.runtime.agent.resolveAgentWorkspaceDir(stored.cfg, agentId);
|
|
490
588
|
const auth = await api.runtime.modelAuth.resolveApiKeyForProvider({
|
|
491
589
|
provider: "openai",
|
|
492
|
-
cfg:
|
|
493
|
-
|
|
590
|
+
cfg: stored.cfg,
|
|
591
|
+
workspaceDir
|
|
494
592
|
});
|
|
495
593
|
if (!auth.apiKey) throw new Error("No OpenAI API key available for audio transcription");
|
|
496
594
|
const audioBuffer = await readFile(firstAudio.path);
|
|
@@ -512,8 +610,7 @@ async function dispatchTwilioInbound(api, params) {
|
|
|
512
610
|
const errBody = await transcribeRes.text();
|
|
513
611
|
throw new Error(`OpenAI transcription HTTP ${transcribeRes.status}: ${errBody}`);
|
|
514
612
|
}
|
|
515
|
-
const
|
|
516
|
-
const text = transcribeJson.text;
|
|
613
|
+
const { text } = await transcribeRes.json();
|
|
517
614
|
console.log("[twilio-whatsapp] Transcription result:", text);
|
|
518
615
|
if (text) {
|
|
519
616
|
bodyForAgent = messageText ? `${messageText}
|
|
@@ -560,10 +657,9 @@ ${text}` : text;
|
|
|
560
657
|
}
|
|
561
658
|
};
|
|
562
659
|
try {
|
|
563
|
-
|
|
564
|
-
await dispatchInboundDirectDmWithRuntime({
|
|
660
|
+
await dispatchInbound({
|
|
565
661
|
cfg: stored.cfg,
|
|
566
|
-
runtime:
|
|
662
|
+
runtime: api.runtime,
|
|
567
663
|
channel: "twilio-whatsapp",
|
|
568
664
|
channelLabel: "WhatsApp",
|
|
569
665
|
accountId: account.accountId,
|
|
@@ -575,14 +671,16 @@ ${text}` : text;
|
|
|
575
671
|
rawBody: messageText,
|
|
576
672
|
bodyForAgent,
|
|
577
673
|
messageId: fields.messageSid,
|
|
578
|
-
timestamp,
|
|
579
|
-
commandAuthorized:
|
|
674
|
+
timestamp: Date.now(),
|
|
675
|
+
commandAuthorized: params.commandAuthorized,
|
|
676
|
+
inboundAccessAuthorized: true,
|
|
580
677
|
provider: "twilio-whatsapp",
|
|
581
678
|
surface: "twilio-whatsapp",
|
|
582
679
|
originatingChannel: "twilio-whatsapp",
|
|
583
680
|
originatingTo: senderAddress,
|
|
584
681
|
extraContext: mediaContext,
|
|
585
682
|
deliver: async (payload) => {
|
|
683
|
+
stopTyping();
|
|
586
684
|
const text = payload.text ?? "";
|
|
587
685
|
const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
|
|
588
686
|
console.log(
|
|
@@ -613,6 +711,7 @@ ${text}` : text;
|
|
|
613
711
|
console.error("[twilio-whatsapp] Session record error:", err);
|
|
614
712
|
},
|
|
615
713
|
onDispatchError: (err, info) => {
|
|
714
|
+
stopTyping();
|
|
616
715
|
console.error(`[twilio-whatsapp] Dispatch error (${info.kind}):`, err);
|
|
617
716
|
}
|
|
618
717
|
});
|
|
@@ -620,15 +719,33 @@ ${text}` : text;
|
|
|
620
719
|
stopTyping();
|
|
621
720
|
}
|
|
622
721
|
}
|
|
623
|
-
function registerTwilioWhatsappHttpRoutes(api) {
|
|
722
|
+
function registerTwilioWhatsappHttpRoutes(api, dependencies = {}) {
|
|
723
|
+
const dispatchInbound = dependencies.dispatchInbound ?? dispatchInboundDirectDmWithRuntime;
|
|
724
|
+
const downloadMedia = dependencies.downloadMedia ?? downloadTwilioMedia;
|
|
624
725
|
api.registerHttpRoute({
|
|
625
726
|
path: "/twilio/whatsapp/events",
|
|
626
727
|
auth: "plugin",
|
|
627
728
|
handler: async (req, res) => {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
729
|
+
if (req.method !== "POST") {
|
|
730
|
+
twimlError(res, 405, "Method not allowed");
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
let rawBody;
|
|
734
|
+
try {
|
|
735
|
+
rawBody = await readRequestBodyWithLimit(req, {
|
|
736
|
+
maxBytes: WEBHOOK_MAX_BODY_BYTES,
|
|
737
|
+
timeoutMs: 1e4,
|
|
738
|
+
destroyOnLimit: false
|
|
739
|
+
});
|
|
740
|
+
} catch (err) {
|
|
741
|
+
if (isRequestBodyLimitError(err)) {
|
|
742
|
+
twimlError(res, err.statusCode, "Invalid request body");
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
throw err;
|
|
746
|
+
}
|
|
747
|
+
const signature = firstHeader(req, "x-twilio-signature");
|
|
748
|
+
const webhookUrl = firstHeader(req, "x-twilio-webhook-url");
|
|
632
749
|
const fields = parseTwilioWebhook(rawBody);
|
|
633
750
|
const toPhone = normalizePhoneNumber(fields.to);
|
|
634
751
|
console.log(
|
|
@@ -646,20 +763,26 @@ function registerTwilioWhatsappHttpRoutes(api) {
|
|
|
646
763
|
twimlError(res, 404, "Account not found");
|
|
647
764
|
return true;
|
|
648
765
|
}
|
|
649
|
-
if (webhookUrl
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
766
|
+
if (!webhookUrl || !signature) {
|
|
767
|
+
console.error("[twilio-whatsapp] Missing forwarded Twilio signature headers");
|
|
768
|
+
twimlError(res, 403, "Forbidden");
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
const valid = verifyTwilioSignature({
|
|
772
|
+
authToken: account.authToken,
|
|
773
|
+
signature,
|
|
774
|
+
url: webhookUrl,
|
|
775
|
+
rawBody
|
|
776
|
+
});
|
|
777
|
+
if (!valid) {
|
|
778
|
+
console.error("[twilio-whatsapp] Invalid Twilio signature");
|
|
779
|
+
twimlError(res, 403, "Forbidden");
|
|
780
|
+
return true;
|
|
781
|
+
}
|
|
782
|
+
if (!fields.accountSid || fields.accountSid !== account.accountSid) {
|
|
783
|
+
console.error("[twilio-whatsapp] Twilio AccountSid does not match the target account");
|
|
784
|
+
twimlError(res, 403, "Forbidden");
|
|
785
|
+
return true;
|
|
663
786
|
}
|
|
664
787
|
const fromPhone = normalizePhoneNumber(fields.from);
|
|
665
788
|
const senderAddress = `whatsapp:${fromPhone}`;
|
|
@@ -667,11 +790,33 @@ function registerTwilioWhatsappHttpRoutes(api) {
|
|
|
667
790
|
const conversationLabel = fields.profileName || fromPhone;
|
|
668
791
|
const messageText = fields.body.trim();
|
|
669
792
|
const hasMedia = fields.media.length > 0;
|
|
670
|
-
const receivedAt = Date.now();
|
|
671
793
|
if (!messageText && !hasMedia) {
|
|
672
794
|
twimlOk(res);
|
|
673
795
|
return true;
|
|
674
796
|
}
|
|
797
|
+
const rateLimitKey = `${account.accountId}:${fromPhone}`;
|
|
798
|
+
if (webhookRateLimiter.isRateLimited(rateLimitKey)) {
|
|
799
|
+
console.warn(`[twilio-whatsapp] Inbound rate limit exceeded for account=${account.accountId}`);
|
|
800
|
+
twimlError(res, 429, "Too many requests");
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
const inboundAccess = resolveTwilioInboundAccess({
|
|
804
|
+
channelConfig: getTwilioChannelConfig(stored.cfg),
|
|
805
|
+
senderPhone: fromPhone
|
|
806
|
+
});
|
|
807
|
+
if (!inboundAccess.allowed) {
|
|
808
|
+
console.warn(
|
|
809
|
+
`[twilio-whatsapp] Blocked inbound sender for account=${account.accountId}: ${inboundAccess.reason}`
|
|
810
|
+
);
|
|
811
|
+
twimlOk(res);
|
|
812
|
+
return true;
|
|
813
|
+
}
|
|
814
|
+
if (fields.messageSid && replayCache.seen(`${account.accountId}:${fields.messageSid}`)) {
|
|
815
|
+
console.info(`[twilio-whatsapp] Ignoring replayed MessageSid for account=${account.accountId}`);
|
|
816
|
+
twimlOk(res);
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
const receivedAt = Date.now();
|
|
675
820
|
twimlOk(res);
|
|
676
821
|
console.log(
|
|
677
822
|
`[twilio-whatsapp] Webhook acknowledged sid=${fields.messageSid || "unknown"} accountId=${account.accountId}`
|
|
@@ -679,7 +824,7 @@ function registerTwilioWhatsappHttpRoutes(api) {
|
|
|
679
824
|
const resolvedMedia = [];
|
|
680
825
|
if (hasMedia) {
|
|
681
826
|
const results = await Promise.all(
|
|
682
|
-
fields.media.map((m) =>
|
|
827
|
+
fields.media.map((m) => downloadMedia(m.url, account))
|
|
683
828
|
);
|
|
684
829
|
for (const r of results) {
|
|
685
830
|
if (r) resolvedMedia.push(r);
|
|
@@ -689,18 +834,22 @@ function registerTwilioWhatsappHttpRoutes(api) {
|
|
|
689
834
|
);
|
|
690
835
|
}
|
|
691
836
|
const pendingMediaKey = resolvePendingMediaKey(account.accountId, fromPhone);
|
|
837
|
+
const dispatchParams = {
|
|
838
|
+
fields,
|
|
839
|
+
stored,
|
|
840
|
+
account,
|
|
841
|
+
toPhone,
|
|
842
|
+
fromPhone,
|
|
843
|
+
senderAddress,
|
|
844
|
+
recipientAddress,
|
|
845
|
+
conversationLabel,
|
|
846
|
+
messageText,
|
|
847
|
+
resolvedMedia,
|
|
848
|
+
commandAuthorized: inboundAccess.commandAuthorized
|
|
849
|
+
};
|
|
692
850
|
if (hasMedia && resolvedMedia.length > 0) {
|
|
693
|
-
queueMediaBatch(api, pendingMediaKey, {
|
|
694
|
-
|
|
695
|
-
stored,
|
|
696
|
-
account,
|
|
697
|
-
toPhone,
|
|
698
|
-
fromPhone,
|
|
699
|
-
senderAddress,
|
|
700
|
-
recipientAddress,
|
|
701
|
-
conversationLabel,
|
|
702
|
-
messageText,
|
|
703
|
-
resolvedMedia,
|
|
851
|
+
await queueMediaBatch(api, dispatchInbound, pendingMediaKey, {
|
|
852
|
+
...dispatchParams,
|
|
704
853
|
receivedAt
|
|
705
854
|
});
|
|
706
855
|
return true;
|
|
@@ -712,22 +861,12 @@ function registerTwilioWhatsappHttpRoutes(api) {
|
|
|
712
861
|
return true;
|
|
713
862
|
}
|
|
714
863
|
if (!hasMedia) {
|
|
715
|
-
flushPendingMediaBatch(api, pendingMediaKey, "before-text");
|
|
864
|
+
await flushPendingMediaBatch(api, dispatchInbound, pendingMediaKey, "before-text");
|
|
716
865
|
}
|
|
717
|
-
enqueueConversationDispatch(
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
account,
|
|
722
|
-
toPhone,
|
|
723
|
-
fromPhone,
|
|
724
|
-
senderAddress,
|
|
725
|
-
recipientAddress,
|
|
726
|
-
conversationLabel,
|
|
727
|
-
messageText,
|
|
728
|
-
resolvedMedia
|
|
729
|
-
});
|
|
730
|
-
});
|
|
866
|
+
await enqueueConversationDispatch(
|
|
867
|
+
pendingMediaKey,
|
|
868
|
+
() => dispatchTwilioInbound(api, dispatchInbound, dispatchParams)
|
|
869
|
+
);
|
|
731
870
|
return true;
|
|
732
871
|
}
|
|
733
872
|
});
|
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.27",
|
|
4
|
+
"description": "Twilio WhatsApp channel plugin for OpenClaw (Cody ISV)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=24.16.0 <25 || >=26.1.0"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"openclaw.plugin.json"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "esbuild index.ts --bundle --platform=node --format=esm --external:openclaw --external:openclaw/* --external:twilio --outfile=dist/index.js",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"test": "esbuild test/*.test.ts --bundle --platform=node --format=esm --external:openclaw --external:openclaw/* --external:twilio --outdir=.test-dist --out-extension:.js=.mjs --log-level=warning && node --test .test-dist/*.test.mjs",
|
|
20
|
+
"check": "npm run typecheck && npm test && npm run build",
|
|
21
|
+
"prepublishOnly": "npm run check"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"twilio": "^5.4.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "24.13.4",
|
|
28
|
+
"esbuild": "^0.28.0",
|
|
29
|
+
"openclaw": "2026.9.4",
|
|
30
|
+
"typescript": "^5.4.0"
|
|
31
|
+
},
|
|
32
|
+
"openclaw": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./dist/index.js"
|
|
35
|
+
],
|
|
36
|
+
"channel": {
|
|
37
|
+
"id": "twilio-whatsapp",
|
|
38
|
+
"label": "Twilio WhatsApp"
|
|
39
|
+
},
|
|
40
|
+
"install": {
|
|
41
|
+
"minHostVersion": ">=2026.9.4"
|
|
42
|
+
},
|
|
43
|
+
"compat": {
|
|
44
|
+
"pluginApi": ">=2026.9.4"
|
|
45
|
+
},
|
|
46
|
+
"build": {
|
|
47
|
+
"openclawVersion": "2026.9.4",
|
|
48
|
+
"pluginSdkVersion": "2026.9.4"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/index.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
declare const _default: {
|
|
2
|
-
id: string;
|
|
3
|
-
name: string;
|
|
4
|
-
description: string;
|
|
5
|
-
configSchema: import("openclaw/channels/plugins/types.plugin.js").ChannelConfigSchema;
|
|
6
|
-
register: (api: import("openclaw/plugins/types.js").OpenClawPluginApi) => void;
|
|
7
|
-
channelPlugin: import("openclaw/plugin-sdk/channel-core.js").ChannelPlugin<import("./src/accounts.js").ResolvedTwilioAccount>;
|
|
8
|
-
setChannelRuntime?: (runtime: import("openclaw/plugin-sdk/channel-core.js").PluginRuntime) => void;
|
|
9
|
-
};
|
|
10
|
-
export default _default;
|
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
|
-
}
|