@openclaw/voice-call 2026.7.2-beta.7 → 2026.8.1-beta.2
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/doctor-contract-api.js +10 -8
- package/dist/{guarded-json-api-DS9sZa3C.js → guarded-json-api-DEDwgf4J.js} +24 -46
- package/dist/index.js +308 -629
- package/dist/{mock-YsvBTX-7.js → mock-BCq0q36l.js} +2 -3
- package/dist/{plivo-vZ--Wqxv.js → plivo-DbI17uAc.js} +45 -8
- package/dist/{realtime-handler--i_OERv6.js → realtime-handler-uXR0QgVa.js} +364 -108
- package/dist/{response-generator-D-7ApVfT.js → response-generator-D-hkzo5Y.js} +13 -5
- package/dist/{runtime-entry-D0UJiLfi.js → runtime-entry-F13eJ4pp.js} +279 -172
- package/dist/runtime-entry.js +1 -1
- package/dist/setup-api.js +17 -24
- package/dist/{store-path-nYL-yM0S.js → store-path-51_oC_xn.js} +70 -13
- package/dist/{telnyx-Dx6cfLvV.js → telnyx-sIS_WgnY.js} +3 -2
- package/dist/{twilio-D_D8KysU.js → twilio-CbeVrVAl.js} +20 -26
- package/dist/{webhook-replay-aaP79dI8.js → webhook-replay-FQrEGVSM.js} +20 -6
- package/openclaw.plugin.json +49 -4
- package/package.json +4 -4
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { TtsConfigSchema, isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText } from "./runtime-api.js";
|
|
2
2
|
import "./api.js";
|
|
3
3
|
import { t as TWILIO_REGIONS } from "./twilio-region-4fkgz3UG.js";
|
|
4
|
-
import { _ as
|
|
4
|
+
import { _ as releaseRejectedProviderCall, b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, g as appendCallReplayKey, l as findCallMatchesInStore, p as persistCallRecord, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore, v as rememberManagerReplayKey, x as TerminalStates, y as reserveRejectedProviderCall } from "./store-path-51_oC_xn.js";
|
|
5
5
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
6
6
|
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
|
|
7
|
-
import { MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
|
8
7
|
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
|
9
8
|
import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
10
9
|
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
|
@@ -21,6 +20,7 @@ import fs from "node:fs";
|
|
|
21
20
|
import crypto from "node:crypto";
|
|
22
21
|
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
|
|
23
22
|
import path from "node:path";
|
|
23
|
+
import { MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
|
24
24
|
import os from "node:os";
|
|
25
25
|
import { root } from "openclaw/plugin-sdk/security-runtime";
|
|
26
26
|
import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
|
@@ -1207,7 +1207,8 @@ async function continueCall(ctx, callId, prompt) {
|
|
|
1207
1207
|
const turnStartedAt = Date.now();
|
|
1208
1208
|
const turnToken = provider.name === "twilio" ? crypto.randomUUID() : void 0;
|
|
1209
1209
|
try {
|
|
1210
|
-
await speak(ctx, callId, prompt);
|
|
1210
|
+
const speakResult = await speak(ctx, callId, prompt);
|
|
1211
|
+
if (!speakResult.success) return speakResult;
|
|
1211
1212
|
transitionState(call, "listening");
|
|
1212
1213
|
persistCallRecord(ctx.storePath, call);
|
|
1213
1214
|
const listenStartedAt = Date.now();
|
|
@@ -1329,9 +1330,9 @@ function createWebhookCall(params) {
|
|
|
1329
1330
|
...effective.numberRouteKey ? { numberRouteKey: effective.numberRouteKey } : {}
|
|
1330
1331
|
}
|
|
1331
1332
|
};
|
|
1333
|
+
persistCallRecord(params.ctx.storePath, callRecord);
|
|
1332
1334
|
params.ctx.activeCalls.set(callId, callRecord);
|
|
1333
1335
|
params.ctx.providerCallIdMap.set(params.providerCallId, callId);
|
|
1334
|
-
persistCallRecord(params.ctx.storePath, callRecord);
|
|
1335
1336
|
log.info(`Created ${params.direction} call record: ${callId} caller=${redactIdentifier(params.from)}`);
|
|
1336
1337
|
return callRecord;
|
|
1337
1338
|
}
|
|
@@ -1372,9 +1373,7 @@ function processEvent(ctx, event) {
|
|
|
1372
1373
|
log.warn(`Inbound call rejected by policy but no provider to hang up (providerCallId: ${pid}, caller=${redactIdentifier(event.from)}); call will time out on provider side.`);
|
|
1373
1374
|
return { kind: "ignored" };
|
|
1374
1375
|
}
|
|
1375
|
-
ctx.processedEventIds.add(dedupeKey);
|
|
1376
1376
|
if (ctx.rejectedProviderCallIds.has(pid)) return { kind: "ignored" };
|
|
1377
|
-
ctx.rejectedProviderCallIds.add(pid);
|
|
1378
1377
|
const callId = event.callId ?? pid;
|
|
1379
1378
|
persistRejectedInboundCall({
|
|
1380
1379
|
ctx,
|
|
@@ -1382,13 +1381,16 @@ function processEvent(ctx, event) {
|
|
|
1382
1381
|
dedupeKey,
|
|
1383
1382
|
providerCallId: pid
|
|
1384
1383
|
});
|
|
1384
|
+
const rejectionReservation = reserveRejectedProviderCall(ctx.rejectedProviderCallIds, pid);
|
|
1385
|
+
if (rejectionReservation === void 0) return { kind: "ignored" };
|
|
1386
|
+
rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
|
|
1385
1387
|
log.info(`Rejecting inbound call by policy: ${pid}`);
|
|
1386
1388
|
ctx.provider.hangupCall({
|
|
1387
1389
|
callId,
|
|
1388
1390
|
providerCallId: pid,
|
|
1389
1391
|
reason: "hangup-bot"
|
|
1390
1392
|
}).catch((err) => {
|
|
1391
|
-
ctx.rejectedProviderCallIds
|
|
1393
|
+
releaseRejectedProviderCall(ctx.rejectedProviderCallIds, pid, rejectionReservation);
|
|
1392
1394
|
const message = formatErrorMessage(err);
|
|
1393
1395
|
log.warn(`Failed to reject inbound call ${pid}: ${message}`);
|
|
1394
1396
|
});
|
|
@@ -1403,126 +1405,162 @@ function processEvent(ctx, event) {
|
|
|
1403
1405
|
});
|
|
1404
1406
|
event.callId = call.callId;
|
|
1405
1407
|
}
|
|
1406
|
-
if (!call) return {
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1408
|
+
if (!call) return {
|
|
1409
|
+
kind: "ignored",
|
|
1410
|
+
replayable: true
|
|
1411
|
+
};
|
|
1412
|
+
const activeCall = call;
|
|
1413
|
+
const previousCall = {
|
|
1414
|
+
state: activeCall.state,
|
|
1415
|
+
providerCallId: activeCall.providerCallId,
|
|
1416
|
+
answeredAt: activeCall.answeredAt,
|
|
1417
|
+
endedAt: activeCall.endedAt,
|
|
1418
|
+
endReason: activeCall.endReason,
|
|
1419
|
+
transcriptLength: activeCall.transcript.length,
|
|
1420
|
+
processedEventIds: [...activeCall.processedEventIds]
|
|
1421
|
+
};
|
|
1422
|
+
const shouldCommitReplayKey = !(event.type === "call.error" && event.retryable);
|
|
1423
|
+
const effects = [];
|
|
1419
1424
|
let result = { kind: "processed" };
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
callId: call.callId,
|
|
1427
|
-
from: call.from,
|
|
1428
|
-
to: call.to,
|
|
1429
|
-
direction: "inbound"
|
|
1430
|
-
}) : void 0;
|
|
1431
|
-
ctx.provider.answerCall({
|
|
1432
|
-
callId: call.callId,
|
|
1433
|
-
providerCallId: call.providerCallId,
|
|
1434
|
-
...inboundStreamSession ? {
|
|
1435
|
-
streamUrl: inboundStreamSession.streamUrl,
|
|
1436
|
-
streamAuthToken: inboundStreamSession.token
|
|
1437
|
-
} : {}
|
|
1438
|
-
}).catch((err) => {
|
|
1439
|
-
const message = formatErrorMessage(err);
|
|
1440
|
-
log.warn(`Failed to answer inbound call ${call.providerCallId}: ${message}`);
|
|
1441
|
-
});
|
|
1425
|
+
const startDurationTimer = () => {
|
|
1426
|
+
startMaxDurationTimer({
|
|
1427
|
+
ctx,
|
|
1428
|
+
callId: activeCall.callId,
|
|
1429
|
+
onTimeout: async (callId) => {
|
|
1430
|
+
await endCall(ctx, callId, { reason: "timeout" });
|
|
1442
1431
|
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
transcript: event.transcript,
|
|
1489
|
-
waiterResolved: resolved
|
|
1490
|
-
};
|
|
1432
|
+
});
|
|
1433
|
+
};
|
|
1434
|
+
const prepareLiveDurationTimer = () => {
|
|
1435
|
+
if (!activeCall.answeredAt) {
|
|
1436
|
+
activeCall.answeredAt = event.timestamp;
|
|
1437
|
+
effects.push(startDurationTimer);
|
|
1438
|
+
}
|
|
1439
|
+
};
|
|
1440
|
+
const publishProviderCallId = (terminal = false) => {
|
|
1441
|
+
if (!event.providerCallId || event.providerCallId === previousCall.providerCallId) return;
|
|
1442
|
+
if (!terminal) ctx.providerCallIdMap.set(event.providerCallId, activeCall.callId);
|
|
1443
|
+
if (previousCall.providerCallId) {
|
|
1444
|
+
if (ctx.providerCallIdMap.get(previousCall.providerCallId) === activeCall.callId) ctx.providerCallIdMap.delete(previousCall.providerCallId);
|
|
1445
|
+
}
|
|
1446
|
+
};
|
|
1447
|
+
try {
|
|
1448
|
+
if (event.providerCallId && event.providerCallId !== activeCall.providerCallId) activeCall.providerCallId = event.providerCallId;
|
|
1449
|
+
if (shouldCommitReplayKey) appendCallReplayKey(activeCall.processedEventIds, dedupeKey);
|
|
1450
|
+
switch (event.type) {
|
|
1451
|
+
case "call.initiated": {
|
|
1452
|
+
transitionState(activeCall, "initiated");
|
|
1453
|
+
const inboundProvider = ctx.provider;
|
|
1454
|
+
const inboundProviderCallId = activeCall.providerCallId;
|
|
1455
|
+
const answerInboundCall = inboundProvider?.answerCall?.bind(inboundProvider);
|
|
1456
|
+
if (activeCall.direction === "inbound" && inboundProviderCallId && answerInboundCall) effects.push(() => {
|
|
1457
|
+
const inboundStreamSession = ctx.config.realtime?.enabled && inboundProvider?.name === "telnyx" && ctx.streamSessionIssuer ? ctx.streamSessionIssuer({
|
|
1458
|
+
providerName: "telnyx",
|
|
1459
|
+
callId: activeCall.callId,
|
|
1460
|
+
from: activeCall.from,
|
|
1461
|
+
to: activeCall.to,
|
|
1462
|
+
direction: "inbound"
|
|
1463
|
+
}) : void 0;
|
|
1464
|
+
answerInboundCall({
|
|
1465
|
+
callId: activeCall.callId,
|
|
1466
|
+
providerCallId: inboundProviderCallId,
|
|
1467
|
+
...inboundStreamSession ? {
|
|
1468
|
+
streamUrl: inboundStreamSession.streamUrl,
|
|
1469
|
+
streamAuthToken: inboundStreamSession.token
|
|
1470
|
+
} : {}
|
|
1471
|
+
}).catch((err) => {
|
|
1472
|
+
const message = formatErrorMessage(err);
|
|
1473
|
+
log.warn(`Failed to answer inbound call ${activeCall.providerCallId}: ${message}`);
|
|
1474
|
+
});
|
|
1475
|
+
});
|
|
1476
|
+
break;
|
|
1491
1477
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1478
|
+
case "call.ringing":
|
|
1479
|
+
transitionState(activeCall, "ringing");
|
|
1480
|
+
break;
|
|
1481
|
+
case "call.answered":
|
|
1482
|
+
activeCall.answeredAt = event.timestamp;
|
|
1483
|
+
transitionState(activeCall, "answered");
|
|
1484
|
+
effects.push(startDurationTimer, () => ctx.onCallAnswered?.(activeCall));
|
|
1485
|
+
break;
|
|
1486
|
+
case "call.active":
|
|
1487
|
+
transitionState(activeCall, "active");
|
|
1488
|
+
break;
|
|
1489
|
+
case "call.speaking":
|
|
1490
|
+
case "call.assistant-speech":
|
|
1491
|
+
prepareLiveDurationTimer();
|
|
1492
|
+
transitionState(activeCall, "speaking");
|
|
1493
|
+
if (event.type === "call.assistant-speech" && event.transcript.trim()) addTranscriptEntry(activeCall, "bot", event.transcript);
|
|
1494
|
+
break;
|
|
1495
|
+
case "call.speech":
|
|
1496
|
+
if (event.isFinal) {
|
|
1497
|
+
const waiter = ctx.transcriptWaiters.get(activeCall.callId);
|
|
1498
|
+
if (waiter?.turnToken && waiter.turnToken !== event.turnToken) {
|
|
1499
|
+
log.warn(`Ignoring speech event with mismatched turn token for ${activeCall.callId}`);
|
|
1500
|
+
result = { kind: "ignored" };
|
|
1501
|
+
break;
|
|
1502
|
+
}
|
|
1503
|
+
addTranscriptEntry(activeCall, "user", event.transcript);
|
|
1504
|
+
result = {
|
|
1505
|
+
kind: "final-speech",
|
|
1506
|
+
call: activeCall,
|
|
1507
|
+
transcript: event.transcript,
|
|
1508
|
+
waiterResolved: Boolean(waiter)
|
|
1509
|
+
};
|
|
1510
|
+
if (waiter) effects.push(() => {
|
|
1511
|
+
resolveTranscriptWaiter(ctx, activeCall.callId, event.transcript, event.turnToken);
|
|
1512
|
+
});
|
|
1498
1513
|
}
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
finalizeCall({
|
|
1506
|
-
ctx,
|
|
1507
|
-
call,
|
|
1508
|
-
endReason: event.reason,
|
|
1509
|
-
endedAt: event.timestamp
|
|
1510
|
-
});
|
|
1511
|
-
return { kind: "processed" };
|
|
1512
|
-
case "call.error":
|
|
1513
|
-
if (!event.retryable) {
|
|
1514
|
+
prepareLiveDurationTimer();
|
|
1515
|
+
transitionState(activeCall, "listening");
|
|
1516
|
+
break;
|
|
1517
|
+
case "call.silence":
|
|
1518
|
+
case "call.dtmf": break;
|
|
1519
|
+
case "call.ended":
|
|
1514
1520
|
finalizeCall({
|
|
1515
1521
|
ctx,
|
|
1516
|
-
call,
|
|
1517
|
-
endReason:
|
|
1518
|
-
endedAt: event.timestamp
|
|
1519
|
-
transcriptRejectReason: `Call error: ${event.error}`
|
|
1522
|
+
call: activeCall,
|
|
1523
|
+
endReason: event.reason,
|
|
1524
|
+
endedAt: event.timestamp
|
|
1520
1525
|
});
|
|
1526
|
+
publishProviderCallId(true);
|
|
1527
|
+
rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
|
|
1521
1528
|
return { kind: "processed" };
|
|
1522
|
-
|
|
1523
|
-
|
|
1529
|
+
case "call.error":
|
|
1530
|
+
if (!event.retryable) {
|
|
1531
|
+
finalizeCall({
|
|
1532
|
+
ctx,
|
|
1533
|
+
call: activeCall,
|
|
1534
|
+
endReason: "error",
|
|
1535
|
+
endedAt: event.timestamp,
|
|
1536
|
+
transcriptRejectReason: `Call error: ${event.error}`
|
|
1537
|
+
});
|
|
1538
|
+
publishProviderCallId(true);
|
|
1539
|
+
rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
|
|
1540
|
+
return { kind: "processed" };
|
|
1541
|
+
}
|
|
1542
|
+
result = {
|
|
1543
|
+
kind: "processed",
|
|
1544
|
+
replayable: true
|
|
1545
|
+
};
|
|
1546
|
+
break;
|
|
1547
|
+
}
|
|
1548
|
+
persistCallRecord(ctx.storePath, activeCall);
|
|
1549
|
+
publishProviderCallId();
|
|
1550
|
+
if (shouldCommitReplayKey) rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
|
|
1551
|
+
} catch (err) {
|
|
1552
|
+
Object.assign(activeCall, {
|
|
1553
|
+
state: previousCall.state,
|
|
1554
|
+
providerCallId: previousCall.providerCallId,
|
|
1555
|
+
answeredAt: previousCall.answeredAt,
|
|
1556
|
+
endedAt: previousCall.endedAt,
|
|
1557
|
+
endReason: previousCall.endReason
|
|
1558
|
+
});
|
|
1559
|
+
activeCall.transcript.length = previousCall.transcriptLength;
|
|
1560
|
+
activeCall.processedEventIds.splice(0, activeCall.processedEventIds.length, ...previousCall.processedEventIds);
|
|
1561
|
+
throw err;
|
|
1524
1562
|
}
|
|
1525
|
-
|
|
1563
|
+
for (const effect of effects) effect();
|
|
1526
1564
|
return result;
|
|
1527
1565
|
}
|
|
1528
1566
|
//#endregion
|
|
@@ -1564,7 +1602,7 @@ var CallManager = class {
|
|
|
1564
1602
|
this.activeCalls = /* @__PURE__ */ new Map();
|
|
1565
1603
|
this.providerCallIdMap = /* @__PURE__ */ new Map();
|
|
1566
1604
|
this.processedEventIds = /* @__PURE__ */ new Set();
|
|
1567
|
-
this.rejectedProviderCallIds = /* @__PURE__ */ new
|
|
1605
|
+
this.rejectedProviderCallIds = /* @__PURE__ */ new Map();
|
|
1568
1606
|
this.provider = null;
|
|
1569
1607
|
this.webhookUrl = null;
|
|
1570
1608
|
this.activeTurnCalls = /* @__PURE__ */ new Set();
|
|
@@ -1585,7 +1623,7 @@ var CallManager = class {
|
|
|
1585
1623
|
fs.mkdirSync(this.storePath, { recursive: true });
|
|
1586
1624
|
const persisted = loadActiveCallsFromStore(this.storePath);
|
|
1587
1625
|
this.processedEventIds = persisted.processedEventIds;
|
|
1588
|
-
this.rejectedProviderCallIds =
|
|
1626
|
+
this.rejectedProviderCallIds = /* @__PURE__ */ new Map();
|
|
1589
1627
|
const verified = await this.verifyRestoredCalls(provider, persisted.activeCalls);
|
|
1590
1628
|
this.activeCalls = verified;
|
|
1591
1629
|
this.providerCallIdMap = /* @__PURE__ */ new Map();
|
|
@@ -2381,6 +2419,7 @@ const DEFAULT_MAX_PENDING_CONNECTIONS_PER_IP = 4;
|
|
|
2381
2419
|
const DEFAULT_MAX_CONNECTIONS = 128;
|
|
2382
2420
|
const MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
|
|
2383
2421
|
const MAX_WS_BUFFERED_BYTES = 1024 * 1024;
|
|
2422
|
+
const MAX_PENDING_TTS_OPERATIONS_PER_STREAM = 8;
|
|
2384
2423
|
const CLOSE_REASON_LOG_MAX_CHARS = 120;
|
|
2385
2424
|
function sanitizeLogText(value, maxChars) {
|
|
2386
2425
|
const sanitized = value.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
|
|
@@ -2406,6 +2445,8 @@ function parseTwilioMediaMessage(data) {
|
|
|
2406
2445
|
var MediaStreamHandler = class {
|
|
2407
2446
|
constructor(config) {
|
|
2408
2447
|
this.wss = null;
|
|
2448
|
+
this.closePromise = null;
|
|
2449
|
+
this.closing = false;
|
|
2409
2450
|
this.sessions = /* @__PURE__ */ new Map();
|
|
2410
2451
|
this.pendingConnections = /* @__PURE__ */ new Map();
|
|
2411
2452
|
this.pendingByIp = /* @__PURE__ */ new Map();
|
|
@@ -2423,6 +2464,10 @@ var MediaStreamHandler = class {
|
|
|
2423
2464
|
* Handle WebSocket upgrade for media stream connections.
|
|
2424
2465
|
*/
|
|
2425
2466
|
handleUpgrade(request, socket, head) {
|
|
2467
|
+
if (this.closing) {
|
|
2468
|
+
this.rejectUpgrade(socket, 503, "Media stream handler is shutting down");
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2426
2471
|
if (!this.wss) {
|
|
2427
2472
|
this.wss = new WebSocketServer({
|
|
2428
2473
|
noServer: true,
|
|
@@ -2464,6 +2509,23 @@ var MediaStreamHandler = class {
|
|
|
2464
2509
|
throw error;
|
|
2465
2510
|
}
|
|
2466
2511
|
}
|
|
2512
|
+
close(shutdownBarrier = Promise.resolve()) {
|
|
2513
|
+
if (this.closePromise) return this.closePromise;
|
|
2514
|
+
this.closing = true;
|
|
2515
|
+
const wss = this.wss;
|
|
2516
|
+
this.wss = null;
|
|
2517
|
+
this.closePromise = (async () => {
|
|
2518
|
+
if (wss) await new Promise((resolve) => {
|
|
2519
|
+
wss.close(() => resolve());
|
|
2520
|
+
for (const ws of wss.clients) ws.terminate();
|
|
2521
|
+
});
|
|
2522
|
+
await shutdownBarrier;
|
|
2523
|
+
})().finally(() => {
|
|
2524
|
+
this.closing = false;
|
|
2525
|
+
this.closePromise = null;
|
|
2526
|
+
});
|
|
2527
|
+
return this.closePromise;
|
|
2528
|
+
}
|
|
2467
2529
|
/**
|
|
2468
2530
|
* Handle new WebSocket connection from Twilio.
|
|
2469
2531
|
*/
|
|
@@ -2483,6 +2545,11 @@ var MediaStreamHandler = class {
|
|
|
2483
2545
|
console.log("[MediaStream] Twilio connected");
|
|
2484
2546
|
break;
|
|
2485
2547
|
case "start":
|
|
2548
|
+
if (session) {
|
|
2549
|
+
console.warn("[MediaStream] Rejecting duplicate start frame for active connection");
|
|
2550
|
+
ws.close(1008, "Duplicate start");
|
|
2551
|
+
break;
|
|
2552
|
+
}
|
|
2486
2553
|
session = this.handleStart(ws, message, streamToken);
|
|
2487
2554
|
if (session) this.clearPendingConnection(ws);
|
|
2488
2555
|
break;
|
|
@@ -2852,6 +2919,7 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2852
2919
|
*/
|
|
2853
2920
|
async queueTts(streamSid, playFn) {
|
|
2854
2921
|
const queue = this.getTtsQueue(streamSid);
|
|
2922
|
+
if (queue.length >= MAX_PENDING_TTS_OPERATIONS_PER_STREAM) throw new Error(`Telephony TTS queue is full for stream; maxPending=${MAX_PENDING_TTS_OPERATIONS_PER_STREAM}`);
|
|
2855
2923
|
let resolveEntry;
|
|
2856
2924
|
let rejectEntry;
|
|
2857
2925
|
const promise = new Promise((resolve, reject) => {
|
|
@@ -2871,8 +2939,8 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2871
2939
|
* Clear TTS queue and interrupt current playback (barge-in).
|
|
2872
2940
|
*/
|
|
2873
2941
|
clearTtsQueue(streamSid, _reason = "unspecified") {
|
|
2874
|
-
const queue = this.
|
|
2875
|
-
this.resolveQueuedTtsEntries(queue);
|
|
2942
|
+
const queue = this.ttsQueues.get(streamSid);
|
|
2943
|
+
if (queue) this.resolveQueuedTtsEntries(queue);
|
|
2876
2944
|
this.ttsActiveControllers.get(streamSid)?.abort();
|
|
2877
2945
|
const session = this.sessions.get(streamSid);
|
|
2878
2946
|
if (session?.talk.activeTurnId) {
|
|
@@ -2901,8 +2969,9 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2901
2969
|
while (true) {
|
|
2902
2970
|
const queue = this.ttsQueues.get(streamSid);
|
|
2903
2971
|
if (!queue || queue.length === 0) {
|
|
2904
|
-
this.ttsPlaying.
|
|
2972
|
+
this.ttsPlaying.delete(streamSid);
|
|
2905
2973
|
this.ttsActiveControllers.delete(streamSid);
|
|
2974
|
+
this.ttsQueues.delete(streamSid);
|
|
2906
2975
|
return;
|
|
2907
2976
|
}
|
|
2908
2977
|
const entry = queue.shift();
|
|
@@ -3071,7 +3140,7 @@ const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
|
|
|
3071
3140
|
const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
|
|
3072
3141
|
const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2e3;
|
|
3073
3142
|
const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
|
|
3074
|
-
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-D-
|
|
3143
|
+
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-D-hkzo5Y.js"));
|
|
3075
3144
|
function appendRecentTalkEventMetadata(call, event) {
|
|
3076
3145
|
const metadata = call.metadata ?? {};
|
|
3077
3146
|
const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
|
|
@@ -3147,6 +3216,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3147
3216
|
this.server = null;
|
|
3148
3217
|
this.listeningUrl = null;
|
|
3149
3218
|
this.startPromise = null;
|
|
3219
|
+
this.stopPromise = null;
|
|
3150
3220
|
this.stopStaleCallReaper = null;
|
|
3151
3221
|
this.webhookInFlightLimiter = createWebhookInFlightLimiter();
|
|
3152
3222
|
this.mediaStreamHandler = null;
|
|
@@ -3348,6 +3418,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3348
3418
|
* Idempotent: returns immediately if the server is already listening.
|
|
3349
3419
|
*/
|
|
3350
3420
|
async start() {
|
|
3421
|
+
if (this.stopPromise) await this.stopPromise;
|
|
3351
3422
|
const { port, bind, path: webhookPath } = this.config.serve;
|
|
3352
3423
|
const streamPath = this.config.streaming.streamPath;
|
|
3353
3424
|
if (this.server?.listening) return this.listeningUrl ?? this.resolveListeningUrl(bind, webhookPath);
|
|
@@ -3397,26 +3468,46 @@ var VoiceCallWebhookServer = class {
|
|
|
3397
3468
|
/**
|
|
3398
3469
|
* Stop the webhook server.
|
|
3399
3470
|
*/
|
|
3400
|
-
|
|
3471
|
+
stop() {
|
|
3472
|
+
if (this.stopPromise) return this.stopPromise;
|
|
3473
|
+
const server = this.server;
|
|
3474
|
+
const serverClosePromise = new Promise((resolve, reject) => {
|
|
3475
|
+
if (!server) {
|
|
3476
|
+
resolve();
|
|
3477
|
+
return;
|
|
3478
|
+
}
|
|
3479
|
+
server.close((error) => {
|
|
3480
|
+
if (error) {
|
|
3481
|
+
reject(error);
|
|
3482
|
+
return;
|
|
3483
|
+
}
|
|
3484
|
+
resolve();
|
|
3485
|
+
});
|
|
3486
|
+
});
|
|
3487
|
+
this.startPromise = null;
|
|
3401
3488
|
for (const timer of this.pendingDisconnectHangups.values()) clearTimeout(timer);
|
|
3402
3489
|
this.pendingDisconnectHangups.clear();
|
|
3403
|
-
this.webhookInFlightLimiter.clear();
|
|
3404
|
-
this.startPromise = null;
|
|
3405
3490
|
if (this.stopStaleCallReaper) {
|
|
3406
3491
|
this.stopStaleCallReaper();
|
|
3407
3492
|
this.stopStaleCallReaper = null;
|
|
3408
3493
|
}
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
resolve()
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3494
|
+
this.webhookInFlightLimiter.clear();
|
|
3495
|
+
this.stopPromise = (async () => {
|
|
3496
|
+
const results = await Promise.allSettled([
|
|
3497
|
+
serverClosePromise,
|
|
3498
|
+
this.mediaStreamHandler?.close(serverClosePromise) ?? Promise.resolve(),
|
|
3499
|
+
this.realtimeHandler?.close(serverClosePromise) ?? Promise.resolve()
|
|
3500
|
+
]);
|
|
3501
|
+
for (const timer of this.pendingDisconnectHangups.values()) clearTimeout(timer);
|
|
3502
|
+
this.pendingDisconnectHangups.clear();
|
|
3503
|
+
if (this.server === server) this.server = null;
|
|
3504
|
+
this.listeningUrl = null;
|
|
3505
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
3506
|
+
if (failure) throw failure.reason;
|
|
3507
|
+
})().finally(() => {
|
|
3508
|
+
this.stopPromise = null;
|
|
3419
3509
|
});
|
|
3510
|
+
return this.stopPromise;
|
|
3420
3511
|
}
|
|
3421
3512
|
resolveListeningUrl(bind, webhookPath) {
|
|
3422
3513
|
const address = this.server?.address();
|
|
@@ -3521,9 +3612,9 @@ var VoiceCallWebhookServer = class {
|
|
|
3521
3612
|
const isReplay = Boolean(verification.isReplay);
|
|
3522
3613
|
if (isReplay) {
|
|
3523
3614
|
this.logger.warn("Replay detected; skipping event side effects");
|
|
3524
|
-
if (this.provider.name === "twilio") return buildTwilioReplayTwiML();
|
|
3525
3615
|
const cachedResponse = await this.getCachedReplayResponse(verification.verifiedRequestKey);
|
|
3526
3616
|
if (cachedResponse) return cachedResponse;
|
|
3617
|
+
if (this.provider.name === "twilio") return buildTwilioReplayTwiML();
|
|
3527
3618
|
}
|
|
3528
3619
|
const buildResponse = async () => {
|
|
3529
3620
|
const initialTwiML = this.provider.consumeInitialTwiML?.(ctx);
|
|
@@ -3547,12 +3638,11 @@ var VoiceCallWebhookServer = class {
|
|
|
3547
3638
|
return this.realtimeHandler.buildTwiMLPayload(req, realtimeParams);
|
|
3548
3639
|
}
|
|
3549
3640
|
const parsed = this.provider.parseWebhookEvent(ctx, { verifiedRequestKey: verification.verifiedRequestKey });
|
|
3550
|
-
if (!isReplay
|
|
3641
|
+
if (!isReplay && this.processParsedEvents(parsed.events)) verification.releaseReplay?.();
|
|
3551
3642
|
return normalizeWebhookResponse(parsed);
|
|
3552
3643
|
};
|
|
3553
3644
|
if (isReplay) return await buildResponse();
|
|
3554
|
-
|
|
3555
|
-
return await this.cacheReplayResponse(verification.verifiedRequestKey, buildResponse);
|
|
3645
|
+
return await this.cacheReplayResponse(verification.verifiedRequestKey, buildResponse, verification.releaseReplay);
|
|
3556
3646
|
} finally {
|
|
3557
3647
|
this.webhookInFlightLimiter.release(inFlightKey);
|
|
3558
3648
|
}
|
|
@@ -3578,21 +3668,28 @@ var VoiceCallWebhookServer = class {
|
|
|
3578
3668
|
}
|
|
3579
3669
|
return cloneWebhookResponsePayload(await entry.response);
|
|
3580
3670
|
}
|
|
3581
|
-
async cacheReplayResponse(key, buildResponse) {
|
|
3671
|
+
async cacheReplayResponse(key, buildResponse, releaseReplay) {
|
|
3582
3672
|
const now = Date.now();
|
|
3583
3673
|
const expiresAt = resolveExpiresAtMsFromDurationMs(WEBHOOK_REPLAY_RESPONSE_TTL_MS, { nowMs: now });
|
|
3584
3674
|
this.replayResponseCacheCalls += 1;
|
|
3585
3675
|
if (this.replayResponseCacheCalls % WEBHOOK_REPLAY_RESPONSE_PRUNE_INTERVAL === 0) this.pruneReplayResponses(now);
|
|
3586
|
-
|
|
3587
|
-
|
|
3676
|
+
let cachedEntry;
|
|
3677
|
+
const ownerResponse = buildResponse().then(cloneWebhookResponsePayload).catch((err) => {
|
|
3678
|
+
if (cachedEntry && this.replayResponses.get(key) === cachedEntry) this.replayResponses.delete(key);
|
|
3679
|
+
releaseReplay?.();
|
|
3588
3680
|
throw err;
|
|
3589
3681
|
});
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3682
|
+
const response = ownerResponse.then((payload) => this.provider.name === "twilio" ? buildTwilioReplayTwiML() : cloneWebhookResponsePayload(payload));
|
|
3683
|
+
response.catch(() => {});
|
|
3684
|
+
if (expiresAt !== void 0) {
|
|
3685
|
+
cachedEntry = {
|
|
3686
|
+
expiresAt,
|
|
3687
|
+
response
|
|
3688
|
+
};
|
|
3689
|
+
this.replayResponses.set(key, cachedEntry);
|
|
3690
|
+
}
|
|
3594
3691
|
if (this.replayResponses.size > WEBHOOK_REPLAY_RESPONSE_MAX_ENTRIES) this.pruneReplayResponses(now);
|
|
3595
|
-
return cloneWebhookResponsePayload(await
|
|
3692
|
+
return cloneWebhookResponsePayload(await ownerResponse);
|
|
3596
3693
|
}
|
|
3597
3694
|
verifyPreAuthWebhookHeaders(headers) {
|
|
3598
3695
|
if (this.config.skipSignatureVerification) return { ok: true };
|
|
@@ -3656,20 +3753,25 @@ var VoiceCallWebhookServer = class {
|
|
|
3656
3753
|
}
|
|
3657
3754
|
}
|
|
3658
3755
|
processParsedEvents(events) {
|
|
3756
|
+
let replayable = false;
|
|
3659
3757
|
for (const event of events) try {
|
|
3660
|
-
this.processEventWithAutoResponse(event);
|
|
3758
|
+
replayable = this.processEventWithAutoResponse(event) || replayable;
|
|
3661
3759
|
} catch (err) {
|
|
3662
3760
|
this.logger.error(`Error processing event ${event.type}: ${String(err)}`);
|
|
3761
|
+
throw err;
|
|
3663
3762
|
}
|
|
3763
|
+
return replayable;
|
|
3664
3764
|
}
|
|
3665
3765
|
processEventWithAutoResponse(event) {
|
|
3666
3766
|
const result = this.manager.processEvent(event);
|
|
3667
|
-
if (result.kind !== "final-speech"
|
|
3767
|
+
if (result.kind !== "final-speech") return result.replayable === true;
|
|
3768
|
+
if (result.waiterResolved) return false;
|
|
3668
3769
|
const callMode = result.call.metadata?.mode;
|
|
3669
|
-
if (result.call.direction !== "inbound" && callMode !== "conversation") return;
|
|
3770
|
+
if (result.call.direction !== "inbound" && callMode !== "conversation") return false;
|
|
3670
3771
|
this.handleInboundResponse(result.call.callId, result.transcript).catch((err) => {
|
|
3671
3772
|
this.logger.warn(`Failed to auto-respond: ${String(err)}`);
|
|
3672
3773
|
});
|
|
3774
|
+
return false;
|
|
3673
3775
|
}
|
|
3674
3776
|
writeWebhookResponse(res, payload) {
|
|
3675
3777
|
res.statusCode = payload.statusCode;
|
|
@@ -3746,12 +3848,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
|
|
|
3746
3848
|
"Do not print secret values or dump environment variables; only check whether required configuration is present.",
|
|
3747
3849
|
"Be accurate, brief, and speakable."
|
|
3748
3850
|
].join(" ");
|
|
3749
|
-
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-
|
|
3750
|
-
const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-
|
|
3751
|
-
const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-
|
|
3752
|
-
const loadMockProvider = createLazyRuntimeModule(() => import("./mock-
|
|
3851
|
+
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-sIS_WgnY.js"));
|
|
3852
|
+
const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-CbeVrVAl.js"));
|
|
3853
|
+
const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-DbI17uAc.js"));
|
|
3854
|
+
const loadMockProvider = createLazyRuntimeModule(() => import("./mock-BCq0q36l.js"));
|
|
3753
3855
|
const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
|
|
3754
|
-
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler
|
|
3856
|
+
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-uXR0QgVa.js"));
|
|
3755
3857
|
function resolveVoiceCallConsultSessionKey(call) {
|
|
3756
3858
|
return resolveVoiceCallSessionKey({
|
|
3757
3859
|
config: call.config,
|
|
@@ -3775,7 +3877,7 @@ function mapVoiceCallConsultTranscript(call, context) {
|
|
|
3775
3877
|
}
|
|
3776
3878
|
function createRuntimeResourceLifecycle(params) {
|
|
3777
3879
|
let tunnelResult = null;
|
|
3778
|
-
let
|
|
3880
|
+
let stopPromise = null;
|
|
3779
3881
|
const runStep = async (step, suppressErrors) => {
|
|
3780
3882
|
if (suppressErrors) {
|
|
3781
3883
|
await step().catch(() => {});
|
|
@@ -3787,19 +3889,21 @@ function createRuntimeResourceLifecycle(params) {
|
|
|
3787
3889
|
setTunnelResult: (result) => {
|
|
3788
3890
|
tunnelResult = result;
|
|
3789
3891
|
},
|
|
3790
|
-
stop:
|
|
3791
|
-
if (
|
|
3792
|
-
stopped = true;
|
|
3892
|
+
stop: (opts) => {
|
|
3893
|
+
if (stopPromise) return stopPromise;
|
|
3793
3894
|
const suppressErrors = opts?.suppressErrors ?? false;
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
await
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
await
|
|
3802
|
-
|
|
3895
|
+
stopPromise = (async () => {
|
|
3896
|
+
await runStep(async () => {
|
|
3897
|
+
if (tunnelResult) await tunnelResult.stop();
|
|
3898
|
+
}, suppressErrors);
|
|
3899
|
+
await runStep(async () => {
|
|
3900
|
+
await cleanupTailscaleExposure(params.config);
|
|
3901
|
+
}, suppressErrors);
|
|
3902
|
+
await runStep(async () => {
|
|
3903
|
+
await params.webhookServer.stop();
|
|
3904
|
+
}, suppressErrors);
|
|
3905
|
+
})();
|
|
3906
|
+
return stopPromise;
|
|
3803
3907
|
}
|
|
3804
3908
|
};
|
|
3805
3909
|
}
|
|
@@ -3937,6 +4041,7 @@ async function createVoiceCallRuntime(params) {
|
|
|
3937
4041
|
tools: resolveRealtimeVoiceAgentConsultTools(config.realtime.toolPolicy, config.realtime.tools)
|
|
3938
4042
|
}, manager, provider, realtimeProvider.provider, realtimeProvider.providerConfig, config.serve.path, cfg, resolveRealtimeInstructions);
|
|
3939
4043
|
if (config.realtime.toolPolicy !== "none") realtimeHandler.registerToolHandler(REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, async (args, callId, handlerContext) => {
|
|
4044
|
+
handlerContext.abortSignal?.throwIfAborted();
|
|
3940
4045
|
const call = manager.getCall(callId);
|
|
3941
4046
|
if (!call) return { error: `Call "${callId}" not found` };
|
|
3942
4047
|
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
|
@@ -3967,6 +4072,7 @@ async function createVoiceCallRuntime(params) {
|
|
|
3967
4072
|
args,
|
|
3968
4073
|
logger: log
|
|
3969
4074
|
});
|
|
4075
|
+
handlerContext.abortSignal?.throwIfAborted();
|
|
3970
4076
|
if (fastContext.handled) {
|
|
3971
4077
|
assertRealtimeVoiceAgentConsultModelSelectionUnlocked(modelLockParams);
|
|
3972
4078
|
return fastContext.result;
|
|
@@ -4003,7 +4109,8 @@ async function createVoiceCallRuntime(params) {
|
|
|
4003
4109
|
spawnedBy: requesterSessionKey,
|
|
4004
4110
|
contextMode: requesterSessionKey ? "fork" : void 0,
|
|
4005
4111
|
toolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow(effectiveConfig.realtime.toolPolicy),
|
|
4006
|
-
extraSystemPrompt: REALTIME_VOICE_CONSULT_SYSTEM_PROMPT
|
|
4112
|
+
extraSystemPrompt: REALTIME_VOICE_CONSULT_SYSTEM_PROMPT,
|
|
4113
|
+
abortSignal: handlerContext.abortSignal
|
|
4007
4114
|
});
|
|
4008
4115
|
});
|
|
4009
4116
|
webhookServer.setRealtimeHandler(realtimeHandler);
|
|
@@ -4058,7 +4165,7 @@ async function createVoiceCallRuntime(params) {
|
|
|
4058
4165
|
}
|
|
4059
4166
|
if (realtimeProvider) log.info(`[voice-call] Realtime voice provider: ${realtimeProvider.provider.id}`);
|
|
4060
4167
|
await manager.initialize(provider, webhookUrl);
|
|
4061
|
-
const stop =
|
|
4168
|
+
const stop = () => lifecycle.stop();
|
|
4062
4169
|
log.info("[voice-call] Runtime initialized");
|
|
4063
4170
|
log.info(`[voice-call] Webhook URL: ${webhookUrl}`);
|
|
4064
4171
|
if (publicUrl && publicUrl !== webhookUrl) log.info(`[voice-call] Public URL: ${publicUrl}`);
|