@daolmedo/openclaw-twilio-whatsapp 1.1.22 → 1.1.24
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 +356 -176
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -63,30 +63,63 @@ async function sendTwilioWhatsappMessage(opts) {
|
|
|
63
63
|
const to = opts.to.startsWith("whatsapp:") ? opts.to : `whatsapp:${opts.to}`;
|
|
64
64
|
const chunks = opts.body ? splitMessage(opts.body) : [void 0];
|
|
65
65
|
let lastSid = "";
|
|
66
|
-
for (
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
to
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
66
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
67
|
+
const chunk = chunks[i];
|
|
68
|
+
console.log(
|
|
69
|
+
`[twilio-whatsapp] Twilio create start from=${from} to=${to} chunk=${i + 1}/${chunks.length} bodyChars=${chunk?.length ?? 0} media=${opts.mediaUrl ? "yes" : "no"}`
|
|
70
|
+
);
|
|
71
|
+
try {
|
|
72
|
+
const msg = await client.messages.create({
|
|
73
|
+
from,
|
|
74
|
+
to,
|
|
75
|
+
...chunk ? { body: chunk } : {},
|
|
76
|
+
...opts.mediaUrl ? { mediaUrl: [opts.mediaUrl] } : {}
|
|
77
|
+
});
|
|
78
|
+
lastSid = msg.sid;
|
|
79
|
+
console.log(
|
|
80
|
+
`[twilio-whatsapp] Twilio create ok sid=${msg.sid} status=${msg.status} chunk=${i + 1}/${chunks.length}`
|
|
81
|
+
);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error(
|
|
84
|
+
`[twilio-whatsapp] Twilio create failed chunk=${i + 1}/${chunks.length}:`,
|
|
85
|
+
err
|
|
86
|
+
);
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
74
89
|
}
|
|
75
90
|
return lastSid;
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
// src/outbound-adapter.ts
|
|
94
|
+
function summarizeText(text) {
|
|
95
|
+
if (!text) return "chars=0";
|
|
96
|
+
return `chars=${text.length} preview=${JSON.stringify(text.slice(0, 120))}`;
|
|
97
|
+
}
|
|
79
98
|
async function deliver(ctx) {
|
|
80
99
|
const account = resolveTwilioAccount(ctx.cfg, ctx.accountId);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
100
|
+
console.log(
|
|
101
|
+
`[twilio-whatsapp] Durable adapter deliver start accountId=${ctx.accountId ?? "default"} from=${account.phoneNumber} to=${ctx.to} ${summarizeText(ctx.text)} media=${ctx.mediaUrl ? "yes" : "no"}`
|
|
102
|
+
);
|
|
103
|
+
try {
|
|
104
|
+
const sid = await sendTwilioWhatsappMessage({
|
|
105
|
+
accountSid: account.accountSid,
|
|
106
|
+
authToken: account.authToken,
|
|
107
|
+
from: account.phoneNumber,
|
|
108
|
+
to: ctx.to,
|
|
109
|
+
body: ctx.text || void 0,
|
|
110
|
+
mediaUrl: ctx.mediaUrl
|
|
111
|
+
});
|
|
112
|
+
console.log(
|
|
113
|
+
`[twilio-whatsapp] Durable adapter deliver ok accountId=${ctx.accountId ?? "default"} outboundSid=${sid}`
|
|
114
|
+
);
|
|
115
|
+
return sid;
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(
|
|
118
|
+
`[twilio-whatsapp] Durable adapter deliver failed accountId=${ctx.accountId ?? "default"}:`,
|
|
119
|
+
err
|
|
120
|
+
);
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
90
123
|
}
|
|
91
124
|
var twilioWhatsappOutbound = {
|
|
92
125
|
deliveryMode: "direct",
|
|
@@ -211,7 +244,7 @@ var twilioWhatsappPlugin = {
|
|
|
211
244
|
// src/http-routes.ts
|
|
212
245
|
import { readFile } from "node:fs/promises";
|
|
213
246
|
import path from "node:path";
|
|
214
|
-
import {
|
|
247
|
+
import { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/direct-dm";
|
|
215
248
|
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
|
|
216
249
|
|
|
217
250
|
// src/webhook.ts
|
|
@@ -278,6 +311,20 @@ function startTypingKeepalive(opts) {
|
|
|
278
311
|
|
|
279
312
|
// src/http-routes.ts
|
|
280
313
|
var MEDIA_MAX_BYTES = 16 * 1024 * 1024;
|
|
314
|
+
function resolveMediaBatchFlushMs() {
|
|
315
|
+
const raw = Number(process.env.TWILIO_WHATSAPP_MEDIA_BATCH_FLUSH_MS);
|
|
316
|
+
if (Number.isFinite(raw) && raw >= 0) return Math.min(raw, 6e4);
|
|
317
|
+
return 3e3;
|
|
318
|
+
}
|
|
319
|
+
function resolveMediaBatchMaxItems() {
|
|
320
|
+
const raw = Number(process.env.TWILIO_WHATSAPP_MEDIA_BATCH_MAX_ITEMS);
|
|
321
|
+
if (Number.isFinite(raw) && raw > 0) return Math.floor(Math.min(raw, 50));
|
|
322
|
+
return 10;
|
|
323
|
+
}
|
|
324
|
+
function summarizeText2(text) {
|
|
325
|
+
if (!text) return "chars=0";
|
|
326
|
+
return `chars=${text.length} preview=${JSON.stringify(text.slice(0, 120))}`;
|
|
327
|
+
}
|
|
281
328
|
async function readBody(req) {
|
|
282
329
|
return new Promise((resolve, reject) => {
|
|
283
330
|
const chunks = [];
|
|
@@ -296,6 +343,11 @@ function twimlError(res, status, message) {
|
|
|
296
343
|
`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${message}</Message></Response>`
|
|
297
344
|
);
|
|
298
345
|
}
|
|
346
|
+
var pendingMediaByConversation = /* @__PURE__ */ new Map();
|
|
347
|
+
var dispatchQueueByConversation = /* @__PURE__ */ new Map();
|
|
348
|
+
function resolvePendingMediaKey(accountId, fromPhone) {
|
|
349
|
+
return `${accountId}:${fromPhone}`;
|
|
350
|
+
}
|
|
299
351
|
async function downloadTwilioMedia(url, account) {
|
|
300
352
|
try {
|
|
301
353
|
const authHeader = "Basic " + Buffer.from(`${account.accountSid}:${account.authToken}`).toString("base64");
|
|
@@ -322,6 +374,243 @@ async function downloadTwilioMedia(url, account) {
|
|
|
322
374
|
return null;
|
|
323
375
|
}
|
|
324
376
|
}
|
|
377
|
+
function enqueueConversationDispatch(key, task) {
|
|
378
|
+
const previous = dispatchQueueByConversation.get(key) ?? Promise.resolve();
|
|
379
|
+
const next = previous.catch(() => void 0).then(task).catch((err) => {
|
|
380
|
+
console.error(`[twilio-whatsapp] Conversation dispatch failed key=${key}:`, err);
|
|
381
|
+
});
|
|
382
|
+
dispatchQueueByConversation.set(key, next);
|
|
383
|
+
void next.finally(() => {
|
|
384
|
+
if (dispatchQueueByConversation.get(key) === next) {
|
|
385
|
+
dispatchQueueByConversation.delete(key);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
function stopBatchTimer(batch) {
|
|
390
|
+
if (batch.timer) {
|
|
391
|
+
clearTimeout(batch.timer);
|
|
392
|
+
batch.timer = void 0;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function armBatchTimer(api, key, batch) {
|
|
396
|
+
stopBatchTimer(batch);
|
|
397
|
+
const flushMs = resolveMediaBatchFlushMs();
|
|
398
|
+
batch.timer = setTimeout(() => {
|
|
399
|
+
flushPendingMediaBatch(api, key, "timer");
|
|
400
|
+
}, flushMs);
|
|
401
|
+
const maybeTimer = batch.timer;
|
|
402
|
+
maybeTimer.unref?.();
|
|
403
|
+
}
|
|
404
|
+
function combineBatchMessageText(items) {
|
|
405
|
+
return items.map((item) => item.messageText.trim()).filter(Boolean).join("\n\n");
|
|
406
|
+
}
|
|
407
|
+
function flushPendingMediaBatch(api, key, reason) {
|
|
408
|
+
const batch = pendingMediaByConversation.get(key);
|
|
409
|
+
if (!batch) return;
|
|
410
|
+
stopBatchTimer(batch);
|
|
411
|
+
pendingMediaByConversation.delete(key);
|
|
412
|
+
const items = [...batch.items].sort((a, b) => a.receivedAt - b.receivedAt);
|
|
413
|
+
const last = items[items.length - 1];
|
|
414
|
+
if (!last) return;
|
|
415
|
+
const resolvedMedia = items.flatMap((item) => item.resolvedMedia);
|
|
416
|
+
if (!resolvedMedia.length && !combineBatchMessageText(items)) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const messageSids = items.map((item) => item.fields.messageSid || "unknown");
|
|
420
|
+
console.log(
|
|
421
|
+
`[twilio-whatsapp] Flushing media batch key=${key} reason=${reason} messages=${items.length} media=${resolvedMedia.length} sids=${messageSids.join(",")}`
|
|
422
|
+
);
|
|
423
|
+
enqueueConversationDispatch(key, async () => {
|
|
424
|
+
await dispatchTwilioInbound(api, {
|
|
425
|
+
...last,
|
|
426
|
+
messageText: combineBatchMessageText(items),
|
|
427
|
+
resolvedMedia
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
function queueMediaBatch(api, key, item) {
|
|
432
|
+
let batch = pendingMediaByConversation.get(key);
|
|
433
|
+
if (!batch) {
|
|
434
|
+
batch = { items: [] };
|
|
435
|
+
pendingMediaByConversation.set(key, batch);
|
|
436
|
+
}
|
|
437
|
+
batch.items.push(item);
|
|
438
|
+
console.log(
|
|
439
|
+
`[twilio-whatsapp] Buffered media inbound sid=${item.fields.messageSid || "unknown"} key=${key} pendingMessages=${batch.items.length} pendingMedia=${batch.items.reduce(
|
|
440
|
+
(count, pending) => count + pending.resolvedMedia.length,
|
|
441
|
+
0
|
|
442
|
+
)}`
|
|
443
|
+
);
|
|
444
|
+
if (batch.items.length >= resolveMediaBatchMaxItems()) {
|
|
445
|
+
flushPendingMediaBatch(api, key, "max-items");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (resolveMediaBatchFlushMs() === 0) {
|
|
449
|
+
flushPendingMediaBatch(api, key, "disabled");
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
armBatchTimer(api, key, batch);
|
|
453
|
+
}
|
|
454
|
+
async function dispatchTwilioInbound(api, params) {
|
|
455
|
+
const {
|
|
456
|
+
fields,
|
|
457
|
+
stored,
|
|
458
|
+
account,
|
|
459
|
+
toPhone,
|
|
460
|
+
fromPhone,
|
|
461
|
+
senderAddress,
|
|
462
|
+
recipientAddress,
|
|
463
|
+
conversationLabel,
|
|
464
|
+
resolvedMedia
|
|
465
|
+
} = params;
|
|
466
|
+
let messageText = params.messageText;
|
|
467
|
+
const mediaContext = resolvedMedia.length > 0 ? {
|
|
468
|
+
MediaPath: resolvedMedia[0].path,
|
|
469
|
+
MediaUrl: resolvedMedia[0].path,
|
|
470
|
+
MediaType: resolvedMedia[0].contentType,
|
|
471
|
+
MediaPaths: resolvedMedia.map((m) => m.path),
|
|
472
|
+
MediaUrls: resolvedMedia.map((m) => m.path),
|
|
473
|
+
MediaTypes: resolvedMedia.map((m) => m.contentType)
|
|
474
|
+
} : {};
|
|
475
|
+
let bodyForAgent = messageText || void 0;
|
|
476
|
+
const firstAudio = resolvedMedia.find((m) => m.contentType.startsWith("audio/"));
|
|
477
|
+
if (firstAudio) {
|
|
478
|
+
try {
|
|
479
|
+
const cfgAny = stored.cfg;
|
|
480
|
+
const agentId = cfgAny.bindings?.find(
|
|
481
|
+
(b) => b.match?.channel === "twilio-whatsapp" && b.match?.accountId === stored.accountId
|
|
482
|
+
)?.agentId;
|
|
483
|
+
const agentDir = cfgAny.agents?.list?.find(
|
|
484
|
+
(a) => a.id === agentId
|
|
485
|
+
)?.agentDir;
|
|
486
|
+
const auth = await api.runtime.modelAuth.resolveApiKeyForProvider({
|
|
487
|
+
provider: "openai",
|
|
488
|
+
cfg: api.config,
|
|
489
|
+
agentDir
|
|
490
|
+
});
|
|
491
|
+
if (!auth.apiKey) throw new Error("No OpenAI API key available for audio transcription");
|
|
492
|
+
const audioBuffer = await readFile(firstAudio.path);
|
|
493
|
+
const form = new FormData();
|
|
494
|
+
form.append(
|
|
495
|
+
"file",
|
|
496
|
+
new Blob([audioBuffer], { type: firstAudio.contentType }),
|
|
497
|
+
path.basename(firstAudio.path)
|
|
498
|
+
);
|
|
499
|
+
form.append("model", "gpt-4o-mini-transcribe");
|
|
500
|
+
form.append("response_format", "json");
|
|
501
|
+
const transcribeRes = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
|
502
|
+
method: "POST",
|
|
503
|
+
headers: { Authorization: `Bearer ${auth.apiKey}` },
|
|
504
|
+
body: form,
|
|
505
|
+
signal: AbortSignal.timeout(12e4)
|
|
506
|
+
});
|
|
507
|
+
if (!transcribeRes.ok) {
|
|
508
|
+
const errBody = await transcribeRes.text();
|
|
509
|
+
throw new Error(`OpenAI transcription HTTP ${transcribeRes.status}: ${errBody}`);
|
|
510
|
+
}
|
|
511
|
+
const transcribeJson = await transcribeRes.json();
|
|
512
|
+
const text = transcribeJson.text;
|
|
513
|
+
console.log("[twilio-whatsapp] Transcription result:", text);
|
|
514
|
+
if (text) {
|
|
515
|
+
bodyForAgent = messageText ? `${messageText}
|
|
516
|
+
|
|
517
|
+
[Audio]
|
|
518
|
+
Transcript:
|
|
519
|
+
${text}` : `[Audio]
|
|
520
|
+
Transcript:
|
|
521
|
+
${text}`;
|
|
522
|
+
messageText = messageText ? `${messageText}
|
|
523
|
+
|
|
524
|
+
${text}` : text;
|
|
525
|
+
mediaContext.Transcript = text;
|
|
526
|
+
} else {
|
|
527
|
+
bodyForAgent = messageText || "<media:audio>";
|
|
528
|
+
}
|
|
529
|
+
} catch (err) {
|
|
530
|
+
console.error("[twilio-whatsapp] Audio transcription failed:", err);
|
|
531
|
+
bodyForAgent = messageText || "<media:audio>";
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
console.log(
|
|
535
|
+
`[twilio-whatsapp] Dispatching inbound sid=${fields.messageSid || "unknown"} bodyForAgent=${summarizeText2(bodyForAgent)} media=${resolvedMedia.length}`
|
|
536
|
+
);
|
|
537
|
+
const stopTypingKeepalive = fields.messageSid ? (() => {
|
|
538
|
+
console.log(`[twilio-whatsapp] Typing keepalive start sid=${fields.messageSid}`);
|
|
539
|
+
return startTypingKeepalive({
|
|
540
|
+
accountSid: account.accountSid,
|
|
541
|
+
authToken: account.authToken,
|
|
542
|
+
messageId: fields.messageSid
|
|
543
|
+
});
|
|
544
|
+
})() : () => {
|
|
545
|
+
};
|
|
546
|
+
let typingStopped = false;
|
|
547
|
+
const stopTyping = () => {
|
|
548
|
+
if (typingStopped) return;
|
|
549
|
+
typingStopped = true;
|
|
550
|
+
stopTypingKeepalive();
|
|
551
|
+
console.log(`[twilio-whatsapp] Typing keepalive stop sid=${fields.messageSid || "unknown"}`);
|
|
552
|
+
};
|
|
553
|
+
try {
|
|
554
|
+
const timestamp = Date.now();
|
|
555
|
+
await dispatchInboundDirectDmWithRuntime({
|
|
556
|
+
cfg: stored.cfg,
|
|
557
|
+
runtime: { channel: stored.channelRuntime },
|
|
558
|
+
channel: "twilio-whatsapp",
|
|
559
|
+
channelLabel: "WhatsApp",
|
|
560
|
+
accountId: account.accountId,
|
|
561
|
+
peer: { kind: "direct", id: fromPhone },
|
|
562
|
+
senderId: fromPhone,
|
|
563
|
+
senderAddress,
|
|
564
|
+
recipientAddress,
|
|
565
|
+
conversationLabel,
|
|
566
|
+
rawBody: messageText,
|
|
567
|
+
bodyForAgent,
|
|
568
|
+
messageId: fields.messageSid,
|
|
569
|
+
timestamp,
|
|
570
|
+
commandAuthorized: true,
|
|
571
|
+
provider: "twilio-whatsapp",
|
|
572
|
+
surface: "twilio-whatsapp",
|
|
573
|
+
originatingChannel: "twilio-whatsapp",
|
|
574
|
+
originatingTo: senderAddress,
|
|
575
|
+
extraContext: mediaContext,
|
|
576
|
+
deliver: async (payload) => {
|
|
577
|
+
const text = payload.text ?? "";
|
|
578
|
+
const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
|
|
579
|
+
console.log(
|
|
580
|
+
`[twilio-whatsapp] Direct deliver invoked sid=${fields.messageSid || "unknown"} ${summarizeText2(text)} media=${mediaUrl ? "yes" : "no"}`
|
|
581
|
+
);
|
|
582
|
+
if (!text && !mediaUrl) {
|
|
583
|
+
console.log(
|
|
584
|
+
`[twilio-whatsapp] Direct deliver skipped empty payload sid=${fields.messageSid || "unknown"}`
|
|
585
|
+
);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
console.log(
|
|
589
|
+
`[twilio-whatsapp] Twilio direct send start sid=${fields.messageSid || "unknown"} from=${recipientAddress} to=${senderAddress} ${summarizeText2(text)} media=${mediaUrl ? "yes" : "no"}`
|
|
590
|
+
);
|
|
591
|
+
const messageId = await sendTwilioWhatsappMessage({
|
|
592
|
+
accountSid: account.accountSid,
|
|
593
|
+
authToken: account.authToken,
|
|
594
|
+
from: toPhone,
|
|
595
|
+
to: fromPhone,
|
|
596
|
+
body: text || void 0,
|
|
597
|
+
mediaUrl
|
|
598
|
+
});
|
|
599
|
+
console.log(
|
|
600
|
+
`[twilio-whatsapp] Twilio direct send ok sid=${fields.messageSid || "unknown"} outboundSid=${messageId}`
|
|
601
|
+
);
|
|
602
|
+
},
|
|
603
|
+
onRecordError: (err) => {
|
|
604
|
+
console.error("[twilio-whatsapp] Session record error:", err);
|
|
605
|
+
},
|
|
606
|
+
onDispatchError: (err, info) => {
|
|
607
|
+
console.error(`[twilio-whatsapp] Dispatch error (${info.kind}):`, err);
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
} finally {
|
|
611
|
+
stopTyping();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
325
614
|
function registerTwilioWhatsappHttpRoutes(api) {
|
|
326
615
|
api.registerHttpRoute({
|
|
327
616
|
path: "/twilio/whatsapp/events",
|
|
@@ -333,6 +622,9 @@ function registerTwilioWhatsappHttpRoutes(api) {
|
|
|
333
622
|
const webhookUrl = String(headers["x-twilio-webhook-url"] ?? "");
|
|
334
623
|
const fields = parseTwilioWebhook(rawBody);
|
|
335
624
|
const toPhone = normalizePhoneNumber(fields.to);
|
|
625
|
+
console.log(
|
|
626
|
+
`[twilio-whatsapp] Webhook received sid=${fields.messageSid || "unknown"} from=${fields.from} to=${fields.to} bodyChars=${fields.body.length} media=${fields.media.length}`
|
|
627
|
+
);
|
|
336
628
|
const stored = getRuntimeForPhoneNumber(toPhone);
|
|
337
629
|
if (!stored) {
|
|
338
630
|
console.error(`[twilio-whatsapp] No runtime found for To=${fields.to}`);
|
|
@@ -358,7 +650,7 @@ function registerTwilioWhatsappHttpRoutes(api) {
|
|
|
358
650
|
return true;
|
|
359
651
|
}
|
|
360
652
|
} else {
|
|
361
|
-
console.warn("[twilio-whatsapp] Skipping sig verification
|
|
653
|
+
console.warn("[twilio-whatsapp] Skipping sig verification - headers missing");
|
|
362
654
|
}
|
|
363
655
|
const fromPhone = normalizePhoneNumber(fields.from);
|
|
364
656
|
const senderAddress = `whatsapp:${fromPhone}`;
|
|
@@ -366,11 +658,15 @@ function registerTwilioWhatsappHttpRoutes(api) {
|
|
|
366
658
|
const conversationLabel = fields.profileName || fromPhone;
|
|
367
659
|
const messageText = fields.body.trim();
|
|
368
660
|
const hasMedia = fields.media.length > 0;
|
|
661
|
+
const receivedAt = Date.now();
|
|
369
662
|
if (!messageText && !hasMedia) {
|
|
370
663
|
twimlOk(res);
|
|
371
664
|
return true;
|
|
372
665
|
}
|
|
373
666
|
twimlOk(res);
|
|
667
|
+
console.log(
|
|
668
|
+
`[twilio-whatsapp] Webhook acknowledged sid=${fields.messageSid || "unknown"} accountId=${account.accountId}`
|
|
669
|
+
);
|
|
374
670
|
const resolvedMedia = [];
|
|
375
671
|
if (hasMedia) {
|
|
376
672
|
const results = await Promise.all(
|
|
@@ -379,165 +675,49 @@ function registerTwilioWhatsappHttpRoutes(api) {
|
|
|
379
675
|
for (const r of results) {
|
|
380
676
|
if (r) resolvedMedia.push(r);
|
|
381
677
|
}
|
|
678
|
+
console.log(
|
|
679
|
+
`[twilio-whatsapp] Media resolved sid=${fields.messageSid || "unknown"} requested=${fields.media.length} saved=${resolvedMedia.length}`
|
|
680
|
+
);
|
|
382
681
|
}
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const agentDir = cfgAny.agents?.list?.find(
|
|
400
|
-
(a) => a.id === agentId
|
|
401
|
-
)?.agentDir;
|
|
402
|
-
const auth = await api.runtime.modelAuth.resolveApiKeyForProvider({
|
|
403
|
-
provider: "openai",
|
|
404
|
-
cfg: api.config,
|
|
405
|
-
agentDir
|
|
406
|
-
});
|
|
407
|
-
if (!auth.apiKey) throw new Error("No OpenAI API key available for audio transcription");
|
|
408
|
-
const audioBuffer = await readFile(firstAudio.path);
|
|
409
|
-
const form = new FormData();
|
|
410
|
-
form.append(
|
|
411
|
-
"file",
|
|
412
|
-
new Blob([audioBuffer], { type: firstAudio.contentType }),
|
|
413
|
-
path.basename(firstAudio.path)
|
|
414
|
-
);
|
|
415
|
-
form.append("model", "gpt-4o-mini-transcribe");
|
|
416
|
-
form.append("response_format", "json");
|
|
417
|
-
const transcribeRes = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
|
418
|
-
method: "POST",
|
|
419
|
-
headers: { Authorization: `Bearer ${auth.apiKey}` },
|
|
420
|
-
body: form,
|
|
421
|
-
signal: AbortSignal.timeout(12e4)
|
|
422
|
-
});
|
|
423
|
-
if (!transcribeRes.ok) {
|
|
424
|
-
const errBody = await transcribeRes.text();
|
|
425
|
-
throw new Error(`OpenAI transcription HTTP ${transcribeRes.status}: ${errBody}`);
|
|
426
|
-
}
|
|
427
|
-
const transcribeJson = await transcribeRes.json();
|
|
428
|
-
const text = transcribeJson.text;
|
|
429
|
-
console.log("[twilio-whatsapp] Transcription result:", text);
|
|
430
|
-
if (text) {
|
|
431
|
-
bodyForAgent = `[Audio]
|
|
432
|
-
Transcript:
|
|
433
|
-
${text}`;
|
|
434
|
-
mediaContext.Transcript = text;
|
|
435
|
-
} else {
|
|
436
|
-
bodyForAgent = messageText || "<media:audio>";
|
|
437
|
-
}
|
|
438
|
-
} catch (err) {
|
|
439
|
-
console.error("[twilio-whatsapp] Audio transcription failed:", err);
|
|
440
|
-
bodyForAgent = messageText || "<media:audio>";
|
|
441
|
-
}
|
|
682
|
+
const pendingMediaKey = resolvePendingMediaKey(account.accountId, fromPhone);
|
|
683
|
+
if (hasMedia && resolvedMedia.length > 0) {
|
|
684
|
+
queueMediaBatch(api, pendingMediaKey, {
|
|
685
|
+
fields,
|
|
686
|
+
stored,
|
|
687
|
+
account,
|
|
688
|
+
toPhone,
|
|
689
|
+
fromPhone,
|
|
690
|
+
senderAddress,
|
|
691
|
+
recipientAddress,
|
|
692
|
+
conversationLabel,
|
|
693
|
+
messageText,
|
|
694
|
+
resolvedMedia,
|
|
695
|
+
receivedAt
|
|
696
|
+
});
|
|
697
|
+
return true;
|
|
442
698
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const rawBodyForContext = mediaContext.Transcript ? String(mediaContext.Transcript) : messageText;
|
|
466
|
-
const { storePath, body } = buildEnvelope({
|
|
467
|
-
channel: "WhatsApp",
|
|
468
|
-
from: conversationLabel,
|
|
469
|
-
body: rawBodyForContext,
|
|
470
|
-
timestamp
|
|
471
|
-
});
|
|
472
|
-
const ctxPayload = stored.channelRuntime.reply.finalizeInboundContext({
|
|
473
|
-
Body: body,
|
|
474
|
-
BodyForAgent: bodyForAgent ?? rawBodyForContext,
|
|
475
|
-
RawBody: rawBodyForContext,
|
|
476
|
-
CommandBody: rawBodyForContext,
|
|
477
|
-
From: senderAddress,
|
|
478
|
-
To: recipientAddress,
|
|
479
|
-
SessionKey: route.sessionKey,
|
|
480
|
-
AccountId: route.accountId ?? account.accountId,
|
|
481
|
-
ChatType: "direct",
|
|
482
|
-
ConversationLabel: conversationLabel,
|
|
483
|
-
SenderId: fromPhone,
|
|
484
|
-
Provider: "twilio-whatsapp",
|
|
485
|
-
Surface: "twilio-whatsapp",
|
|
486
|
-
MessageSid: fields.messageSid,
|
|
487
|
-
MessageSidFull: fields.messageSid,
|
|
488
|
-
Timestamp: timestamp,
|
|
489
|
-
CommandAuthorized: true,
|
|
490
|
-
OriginatingChannel: "twilio-whatsapp",
|
|
491
|
-
OriginatingTo: senderAddress,
|
|
492
|
-
...mediaContext
|
|
493
|
-
});
|
|
494
|
-
stored.channelRuntime.inbound.dispatchReply({
|
|
495
|
-
cfg: stored.cfg,
|
|
496
|
-
channel: "twilio-whatsapp",
|
|
497
|
-
accountId: route.accountId ?? account.accountId,
|
|
498
|
-
agentId: route.agentId,
|
|
499
|
-
routeSessionKey: route.sessionKey,
|
|
500
|
-
storePath,
|
|
501
|
-
ctxPayload,
|
|
502
|
-
recordInboundSession: stored.channelRuntime.session.recordInboundSession,
|
|
503
|
-
dispatchReplyWithBufferedBlockDispatcher: stored.channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
504
|
-
delivery: {
|
|
505
|
-
deliver: async (payload) => {
|
|
506
|
-
const outbound = payload;
|
|
507
|
-
const text = outbound.text ?? "";
|
|
508
|
-
const mediaUrl = outbound.mediaUrl ?? outbound.mediaUrls?.[0];
|
|
509
|
-
if (!text && !mediaUrl) {
|
|
510
|
-
return { visibleReplySent: false };
|
|
511
|
-
}
|
|
512
|
-
const messageId = await sendTwilioWhatsappMessage({
|
|
513
|
-
accountSid: account.accountSid,
|
|
514
|
-
authToken: account.authToken,
|
|
515
|
-
from: toPhone,
|
|
516
|
-
to: fromPhone,
|
|
517
|
-
body: text || void 0,
|
|
518
|
-
mediaUrl
|
|
519
|
-
});
|
|
520
|
-
return {
|
|
521
|
-
messageIds: [messageId],
|
|
522
|
-
visibleReplySent: true
|
|
523
|
-
};
|
|
524
|
-
},
|
|
525
|
-
onError: (err, info) => {
|
|
526
|
-
stopTyping();
|
|
527
|
-
console.error(`[twilio-whatsapp] Dispatch error (${info.kind}):`, err);
|
|
528
|
-
}
|
|
529
|
-
},
|
|
530
|
-
replyPipeline: {},
|
|
531
|
-
record: {
|
|
532
|
-
onRecordError: (err) => {
|
|
533
|
-
console.error("[twilio-whatsapp] Session record error:", err);
|
|
534
|
-
}
|
|
535
|
-
},
|
|
536
|
-
messageId: fields.messageSid
|
|
537
|
-
}).catch((err) => {
|
|
538
|
-
console.error("[twilio-whatsapp] Unhandled dispatch error:", err);
|
|
539
|
-
}).finally(() => {
|
|
540
|
-
stopTyping();
|
|
699
|
+
if (hasMedia && resolvedMedia.length === 0 && !messageText) {
|
|
700
|
+
console.warn(
|
|
701
|
+
`[twilio-whatsapp] Dropping media-only inbound with no resolved media sid=${fields.messageSid || "unknown"}`
|
|
702
|
+
);
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
if (!hasMedia) {
|
|
706
|
+
flushPendingMediaBatch(api, pendingMediaKey, "before-text");
|
|
707
|
+
}
|
|
708
|
+
enqueueConversationDispatch(pendingMediaKey, async () => {
|
|
709
|
+
await dispatchTwilioInbound(api, {
|
|
710
|
+
fields,
|
|
711
|
+
stored,
|
|
712
|
+
account,
|
|
713
|
+
toPhone,
|
|
714
|
+
fromPhone,
|
|
715
|
+
senderAddress,
|
|
716
|
+
recipientAddress,
|
|
717
|
+
conversationLabel,
|
|
718
|
+
messageText,
|
|
719
|
+
resolvedMedia
|
|
720
|
+
});
|
|
541
721
|
});
|
|
542
722
|
return true;
|
|
543
723
|
}
|