@openclaw/voice-call 2026.8.1-beta.2 → 2026.8.1-beta.3
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/README.md +4 -3
- package/dist/doctor-contract-api.js +1 -0
- package/dist/{guarded-json-api-DEDwgf4J.js → guarded-json-api-j4OEG0fR.js} +1 -1
- package/dist/index.js +390 -338
- package/dist/{mock-BCq0q36l.js → mock-Dv7KxYGt.js} +3 -1
- package/dist/{plivo-DbI17uAc.js → plivo-BbVnahnQ.js} +2 -2
- package/dist/{realtime-handler-uXR0QgVa.js → realtime-handler-BaMEKbmJ.js} +239 -125
- package/dist/{response-generator-D-hkzo5Y.js → response-generator-tjyX4tDq.js} +59 -12
- package/dist/{runtime-entry-F13eJ4pp.js → runtime-entry-DK8kPCGC.js} +422 -197
- package/dist/runtime-entry.js +1 -1
- package/dist/{telnyx-sIS_WgnY.js → telnyx-DO8dFQcb.js} +12 -8
- package/dist/{twilio-CbeVrVAl.js → twilio-JDt_1nlz.js} +40 -27
- package/openclaw.plugin.json +20 -2
- package/package.json +4 -4
|
@@ -79,10 +79,12 @@ var MockProvider = class {
|
|
|
79
79
|
}
|
|
80
80
|
case "call.speech": {
|
|
81
81
|
const payload = evt;
|
|
82
|
+
const transcript = payload.transcript ?? "";
|
|
83
|
+
if (!transcript.trim()) return null;
|
|
82
84
|
return {
|
|
83
85
|
...base,
|
|
84
86
|
type: evt.type,
|
|
85
|
-
transcript
|
|
87
|
+
transcript,
|
|
86
88
|
isFinal: payload.isFinal ?? true,
|
|
87
89
|
confidence: payload.confidence
|
|
88
90
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { _ as escapeXml, s as getHeader } from "./runtime-entry-
|
|
2
|
-
import { a as reconstructWebhookUrl, o as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-
|
|
1
|
+
import { _ as escapeXml, s as getHeader } from "./runtime-entry-DK8kPCGC.js";
|
|
2
|
+
import { a as reconstructWebhookUrl, o as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-j4OEG0fR.js";
|
|
3
3
|
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
5
|
//#region extensions/voice-call/src/providers/plivo.ts
|
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import { o as canonicalizeVoiceCallMediaBase64 } from "./runtime-entry-
|
|
1
|
+
import { T as REALTIME_VOICE_END_CALL_TOOL_NAME, o as canonicalizeVoiceCallMediaBase64, x as resolveVoiceCallPublicPathPrefix } from "./runtime-entry-DK8kPCGC.js";
|
|
2
2
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
3
|
+
import { asNullableRecord, asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
3
4
|
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
|
4
5
|
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, buildRealtimeVoiceAgentConsultWorkingResponse, calculateMulawRms, createRealtimeVoiceSessionHarness, createSpeechThresholdGate, readRealtimeVoiceConsultQuestion, readSpeakableRealtimeVoiceToolResult } from "openclaw/plugin-sdk/realtime-voice";
|
|
5
6
|
import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
|
6
|
-
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { isFutureDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
8
9
|
import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
|
9
10
|
import "node:http";
|
|
10
11
|
import WebSocket$1, { WebSocketServer } from "ws";
|
|
11
12
|
//#region extensions/voice-call/src/webhook/realtime-audio-pacer.ts
|
|
12
13
|
const TELEPHONY_SAMPLE_RATE = 8e3;
|
|
13
14
|
const TELEPHONY_CHUNK_BYTES = 160;
|
|
14
|
-
const
|
|
15
|
+
const LEAD_MS = 160;
|
|
15
16
|
const DEFAULT_MAX_QUEUED_AUDIO_BYTES = TELEPHONY_SAMPLE_RATE * 120;
|
|
16
17
|
const QUEUE_COMPACT_HEAD_THRESHOLD = 256;
|
|
17
18
|
/** Paces outgoing mulaw audio frames at telephony cadence. */
|
|
@@ -23,6 +24,7 @@ var RealtimeAudioPacer = class {
|
|
|
23
24
|
this.timer = null;
|
|
24
25
|
this.queuedAudioBytes = 0;
|
|
25
26
|
this.closed = false;
|
|
27
|
+
this.streamClockMs = null;
|
|
26
28
|
}
|
|
27
29
|
/** Queue mulaw audio and split it into 20ms-ish telephony chunks. */
|
|
28
30
|
sendAudio(muLaw) {
|
|
@@ -37,7 +39,7 @@ var RealtimeAudioPacer = class {
|
|
|
37
39
|
this.queue.push({
|
|
38
40
|
type: "audio",
|
|
39
41
|
chunk,
|
|
40
|
-
durationMs:
|
|
42
|
+
durationMs: chunk.length / 8
|
|
41
43
|
});
|
|
42
44
|
this.queuedAudioBytes += chunk.length;
|
|
43
45
|
}
|
|
@@ -59,6 +61,7 @@ var RealtimeAudioPacer = class {
|
|
|
59
61
|
this.clearTimer();
|
|
60
62
|
this.resetQueue();
|
|
61
63
|
this.queuedAudioBytes = 0;
|
|
64
|
+
this.streamClockMs = null;
|
|
62
65
|
this.params.send(this.params.serializer.clear());
|
|
63
66
|
return clearedAudioBytes;
|
|
64
67
|
}
|
|
@@ -72,6 +75,7 @@ var RealtimeAudioPacer = class {
|
|
|
72
75
|
this.clearTimer();
|
|
73
76
|
this.resetQueue();
|
|
74
77
|
this.queuedAudioBytes = 0;
|
|
78
|
+
this.streamClockMs = null;
|
|
75
79
|
}
|
|
76
80
|
/** Clear the scheduled pump timer. */
|
|
77
81
|
clearTimer() {
|
|
@@ -110,25 +114,34 @@ var RealtimeAudioPacer = class {
|
|
|
110
114
|
this.queue.length = 0;
|
|
111
115
|
this.queueHead = 0;
|
|
112
116
|
}
|
|
113
|
-
/**
|
|
117
|
+
/** Fill the provider playout cushion, then wake at the next timeline boundary. */
|
|
114
118
|
pump() {
|
|
115
119
|
this.timer = null;
|
|
116
120
|
if (this.closed) return;
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
121
|
+
const now = performance.now();
|
|
122
|
+
this.streamClockMs ??= now;
|
|
123
|
+
while (this.pendingQueueSize > 0 && this.streamClockMs < now + LEAD_MS) {
|
|
124
|
+
const item = this.takeNextItem();
|
|
125
|
+
if (!item) break;
|
|
126
|
+
if (!(item.type === "audio" ? this.sendAudioItem(item) : this.params.send(this.params.serializer.mark(item.name)))) {
|
|
127
|
+
this.resetQueue();
|
|
128
|
+
this.queuedAudioBytes = 0;
|
|
129
|
+
this.streamClockMs = null;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (this.pendingQueueSize === 0) {
|
|
134
|
+
this.streamClockMs = null;
|
|
129
135
|
return;
|
|
130
136
|
}
|
|
131
|
-
|
|
137
|
+
const delayMs = Math.max(1, this.streamClockMs - LEAD_MS - performance.now());
|
|
138
|
+
this.timer = setTimeout(() => this.pump(), delayMs);
|
|
139
|
+
}
|
|
140
|
+
sendAudioItem(item) {
|
|
141
|
+
this.queuedAudioBytes = Math.max(0, this.queuedAudioBytes - item.chunk.length);
|
|
142
|
+
const sent = this.params.send(this.params.serializer.media(item.chunk.toString("base64")));
|
|
143
|
+
this.streamClockMs = (this.streamClockMs ?? performance.now()) + item.durationMs;
|
|
144
|
+
return sent;
|
|
132
145
|
}
|
|
133
146
|
};
|
|
134
147
|
//#endregion
|
|
@@ -144,15 +157,13 @@ function parseTimestampMs(value) {
|
|
|
144
157
|
/** Parse a JSON object frame, returning null for invalid or non-object payloads. */
|
|
145
158
|
function tryParseJson(rawMessage) {
|
|
146
159
|
try {
|
|
147
|
-
|
|
148
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
160
|
+
return asNullableRecord(JSON.parse(rawMessage));
|
|
149
161
|
} catch {}
|
|
150
162
|
return null;
|
|
151
163
|
}
|
|
152
164
|
/** Read an object-valued field from a parsed frame. */
|
|
153
165
|
function readRecordField(record, field) {
|
|
154
|
-
|
|
155
|
-
return typeof value === "object" && value !== null ? value : void 0;
|
|
166
|
+
return asOptionalObjectRecord(record[field]);
|
|
156
167
|
}
|
|
157
168
|
/** Parse a common provider media frame. */
|
|
158
169
|
function parseMediaFrame(msg) {
|
|
@@ -297,6 +308,8 @@ const STREAM_TOKEN_TTL_MS = 3e4;
|
|
|
297
308
|
const DEFAULT_HOST = "localhost:8443";
|
|
298
309
|
const MAX_REALTIME_MESSAGE_BYTES = 256 * 1024;
|
|
299
310
|
const MAX_REALTIME_WS_BUFFERED_BYTES = 1024 * 1024;
|
|
311
|
+
const REALTIME_MEDIA_INACTIVITY_TIMEOUT_MS = 3e4;
|
|
312
|
+
const REALTIME_DISCONNECT_HANGUP_GRACE_MS = 2e3;
|
|
300
313
|
const FORCED_CONSULT_FALLBACK_DELAY_MS = 200;
|
|
301
314
|
const FORCED_CONSULT_NATIVE_DEDUPE_MS = 2e3;
|
|
302
315
|
const FORCED_CONSULT_RESULT_MAX_CHARS = 1800;
|
|
@@ -429,25 +442,24 @@ function appendRecentTalkEventMetadata(call, event) {
|
|
|
429
442
|
call.metadata = metadata;
|
|
430
443
|
}
|
|
431
444
|
var RealtimeCallHandler = class {
|
|
432
|
-
constructor(config, manager,
|
|
445
|
+
constructor(config, manager, resolveCallRegistration, servePath, streamDisconnectLifecycle, coreConfig) {
|
|
433
446
|
this.config = config;
|
|
434
447
|
this.manager = manager;
|
|
435
|
-
this.
|
|
436
|
-
this.realtimeProvider = realtimeProvider;
|
|
437
|
-
this.providerConfig = providerConfig;
|
|
448
|
+
this.resolveCallRegistration = resolveCallRegistration;
|
|
438
449
|
this.servePath = servePath;
|
|
450
|
+
this.streamDisconnectLifecycle = streamDisconnectLifecycle;
|
|
439
451
|
this.coreConfig = coreConfig;
|
|
440
|
-
this.resolveInstructions = resolveInstructions;
|
|
441
452
|
this.toolHandlers = /* @__PURE__ */ new Map();
|
|
442
453
|
this.pendingStreamTokens = /* @__PURE__ */ new Map();
|
|
443
454
|
this.activeSockets = /* @__PURE__ */ new Set();
|
|
444
455
|
this.serverClosingSockets = /* @__PURE__ */ new WeakSet();
|
|
445
456
|
this.activeBridgesByCallId = /* @__PURE__ */ new Map();
|
|
446
|
-
this.
|
|
457
|
+
this.activeTelephonyBindingsByCallId = /* @__PURE__ */ new Map();
|
|
447
458
|
this.userTranscriptStatesByCallId = /* @__PURE__ */ new Map();
|
|
448
459
|
this.forcedConsultsByCallId = /* @__PURE__ */ new Map();
|
|
449
460
|
this.consultSessionsByCallId = /* @__PURE__ */ new Map();
|
|
450
461
|
this.nativeConsultsInFlightByCallId = /* @__PURE__ */ new Map();
|
|
462
|
+
this.terminationAttempts = /* @__PURE__ */ new Set();
|
|
451
463
|
this.closePromise = null;
|
|
452
464
|
this.closing = false;
|
|
453
465
|
this.publicOrigin = null;
|
|
@@ -457,10 +469,7 @@ var RealtimeCallHandler = class {
|
|
|
457
469
|
try {
|
|
458
470
|
const parsed = new URL(url);
|
|
459
471
|
this.publicOrigin = parsed.host;
|
|
460
|
-
|
|
461
|
-
const normalizedPublicPath = normalizeWebhookPath(parsed.pathname);
|
|
462
|
-
const idx = normalizedPublicPath.indexOf(normalizedServePath);
|
|
463
|
-
this.publicPathPrefix = idx > 0 ? normalizedPublicPath.slice(0, idx) : "";
|
|
472
|
+
this.publicPathPrefix = resolveVoiceCallPublicPathPrefix(parsed.pathname, this.servePath);
|
|
464
473
|
} catch {
|
|
465
474
|
this.publicOrigin = null;
|
|
466
475
|
this.publicPathPrefix = "";
|
|
@@ -513,10 +522,10 @@ var RealtimeCallHandler = class {
|
|
|
513
522
|
maxPayload: MAX_REALTIME_MESSAGE_BYTES
|
|
514
523
|
}).handleUpgrade(request, socket, head, (ws) => {
|
|
515
524
|
this.activeSockets.add(ws);
|
|
516
|
-
let
|
|
525
|
+
let telephonyBinding = null;
|
|
517
526
|
let initialized = false;
|
|
518
527
|
let activeCallSid = "unknown";
|
|
519
|
-
let
|
|
528
|
+
let activeStreamSid = "unknown";
|
|
520
529
|
let lastMediaTimestamp;
|
|
521
530
|
let lastMediaGapWarnAt = 0;
|
|
522
531
|
ws.on("message", (data) => {
|
|
@@ -527,15 +536,18 @@ var RealtimeCallHandler = class {
|
|
|
527
536
|
if (initialized) return;
|
|
528
537
|
initialized = true;
|
|
529
538
|
activeCallSid = frame.providerCallId;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
539
|
+
activeStreamSid = frame.streamId;
|
|
540
|
+
const nextBinding = this.handleCall(frame.streamId, frame.providerCallId, ws, callerMeta, adapter);
|
|
541
|
+
if (!nextBinding) return;
|
|
542
|
+
telephonyBinding = nextBinding;
|
|
543
|
+
this.streamDisconnectLifecycle.connect(activeCallSid, activeStreamSid);
|
|
533
544
|
return;
|
|
534
545
|
}
|
|
535
|
-
if (!
|
|
546
|
+
if (!telephonyBinding) return;
|
|
536
547
|
if (frame.kind === "media") {
|
|
537
548
|
const audio = Buffer.from(frame.payloadBase64, "base64");
|
|
538
|
-
|
|
549
|
+
telephonyBinding.noteMediaActivity();
|
|
550
|
+
telephonyBinding.bridge.sendAudio(audio);
|
|
539
551
|
if (frame.timestampMs !== void 0) {
|
|
540
552
|
if (lastMediaTimestamp !== void 0) {
|
|
541
553
|
const gapMs = frame.timestampMs - lastMediaTimestamp;
|
|
@@ -546,33 +558,31 @@ var RealtimeCallHandler = class {
|
|
|
546
558
|
}
|
|
547
559
|
}
|
|
548
560
|
lastMediaTimestamp = frame.timestampMs;
|
|
549
|
-
bridge.setMediaTimestamp(frame.timestampMs);
|
|
561
|
+
telephonyBinding.bridge.setMediaTimestamp(frame.timestampMs);
|
|
550
562
|
}
|
|
551
563
|
return;
|
|
552
564
|
}
|
|
553
565
|
if (frame.kind === "mark") {
|
|
554
|
-
bridge.acknowledgeMark();
|
|
566
|
+
telephonyBinding.bridge.acknowledgeMark();
|
|
555
567
|
return;
|
|
556
568
|
}
|
|
557
569
|
if (frame.kind === "error") {
|
|
558
570
|
console.error(`[voice-call] realtime WS error frame providerCallId=${activeCallSid} code=${frame.code ?? "?"} title=${frame.title ?? ""} detail=${frame.detail ?? ""}`);
|
|
559
571
|
return;
|
|
560
572
|
}
|
|
561
|
-
if (frame.kind === "stop")
|
|
562
|
-
stopReceived = true;
|
|
563
|
-
this.closeTelephonyBridge(activeCallSid, bridge, "completed");
|
|
564
|
-
}
|
|
573
|
+
if (frame.kind === "stop") telephonyBinding.close("disconnect");
|
|
565
574
|
} catch (error) {
|
|
566
575
|
console.error("[voice-call] realtime WS parse failed:", error);
|
|
567
576
|
}
|
|
568
577
|
});
|
|
569
|
-
ws.on("close", (
|
|
578
|
+
ws.on("close", () => {
|
|
570
579
|
this.activeSockets.delete(ws);
|
|
571
|
-
const reason = this.serverClosingSockets.has(ws)
|
|
572
|
-
|
|
580
|
+
const reason = this.serverClosingSockets.has(ws) ? "shutdown" : "disconnect";
|
|
581
|
+
if (telephonyBinding) telephonyBinding.close(reason);
|
|
573
582
|
});
|
|
574
583
|
ws.on("error", (error) => {
|
|
575
584
|
console.error("[voice-call] realtime WS error:", error);
|
|
585
|
+
ws.terminate();
|
|
576
586
|
});
|
|
577
587
|
if (this.closing) {
|
|
578
588
|
this.serverClosingSockets.add(ws);
|
|
@@ -593,7 +603,8 @@ var RealtimeCallHandler = class {
|
|
|
593
603
|
this.serverClosingSockets.add(ws);
|
|
594
604
|
ws.once("close", () => resolve());
|
|
595
605
|
ws.terminate();
|
|
596
|
-
}))]).then(() => {
|
|
606
|
+
}))]).then(async () => {
|
|
607
|
+
await Promise.all(this.terminationAttempts);
|
|
597
608
|
this.pendingStreamTokens.clear();
|
|
598
609
|
}).finally(() => {
|
|
599
610
|
this.closing = false;
|
|
@@ -635,13 +646,24 @@ var RealtimeCallHandler = class {
|
|
|
635
646
|
}
|
|
636
647
|
issueStreamToken(meta = {}) {
|
|
637
648
|
const token = randomUUID();
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
649
|
+
const expiry = resolveExpiresAtMsFromDurationMs(STREAM_TOKEN_TTL_MS, { nowMs: Date.now() });
|
|
650
|
+
if (expiry !== void 0) {
|
|
651
|
+
this.pendingStreamTokens.set(token, {
|
|
652
|
+
expiry,
|
|
653
|
+
...meta
|
|
654
|
+
});
|
|
655
|
+
const host = this.publicOrigin || DEFAULT_HOST;
|
|
656
|
+
const streamPathPattern = this.getStreamPathPattern();
|
|
657
|
+
setTimeout(() => {
|
|
658
|
+
if (!this.pendingStreamTokens.has(token)) return;
|
|
659
|
+
this.pendingStreamTokens.delete(token);
|
|
660
|
+
if (this.closing) return;
|
|
661
|
+
const call = meta.callId ? ` for call ${meta.callId}` : "";
|
|
662
|
+
const endpoints = [meta.from ? `from ${meta.from}` : "", meta.to ? `to ${meta.to}` : ""].filter(Boolean).join(" ");
|
|
663
|
+
const participants = endpoints ? ` (${endpoints})` : "";
|
|
664
|
+
console.warn(`[voice-call] Realtime stream WebSocket never connected within ${STREAM_TOKEN_TTL_MS / 1e3}s${call}${participants} — the provider could not reach wss://${host}${streamPathPattern}/<token>. Verify the stream path is exposed (tailscale serve/funnel --set-path).`);
|
|
665
|
+
}, STREAM_TOKEN_TTL_MS).unref?.();
|
|
666
|
+
}
|
|
645
667
|
return token;
|
|
646
668
|
}
|
|
647
669
|
consumeStreamToken(token) {
|
|
@@ -658,20 +680,58 @@ var RealtimeCallHandler = class {
|
|
|
658
680
|
};
|
|
659
681
|
}
|
|
660
682
|
handleCall(streamSid, callSid, ws, callerMeta, adapter) {
|
|
661
|
-
const
|
|
662
|
-
if (!
|
|
683
|
+
const preparedCall = this.prepareCallInManager(callSid, callerMeta);
|
|
684
|
+
if (!preparedCall) {
|
|
663
685
|
ws.close(1008, "Caller rejected by policy");
|
|
664
686
|
return null;
|
|
665
687
|
}
|
|
666
|
-
const {
|
|
667
|
-
const
|
|
688
|
+
const { callRecord } = preparedCall;
|
|
689
|
+
const callId = callRecord.callId;
|
|
690
|
+
const hadPredecessorOnAdmission = this.activeBridgesByCallId.has(callId);
|
|
691
|
+
const previousTelephonyBinding = this.activeTelephonyBindingsByCallId.get(callId);
|
|
692
|
+
let callEndPromise;
|
|
693
|
+
const emitCallEnd = (cause) => {
|
|
694
|
+
if (callEndPromise) return callEndPromise;
|
|
695
|
+
const reason = cause === "error" ? "error" : cause === "inactivity" ? "timeout" : "completed";
|
|
696
|
+
const attempt = this.manager.endCall(callId, { reason }).then((result) => {
|
|
697
|
+
if (!result.success) {
|
|
698
|
+
console.warn(`[voice-call] Failed to end realtime call callId=${callId} providerCallId=${callSid} reason=${reason}: ${result.error ?? "unknown error"}; call remains active`);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
console.log(`[voice-call] Realtime call ended callId=${callId} providerCallId=${callSid} reason=${cause}`);
|
|
702
|
+
});
|
|
703
|
+
callEndPromise = attempt;
|
|
704
|
+
this.terminationAttempts.add(attempt);
|
|
705
|
+
const release = () => this.terminationAttempts.delete(attempt);
|
|
706
|
+
attempt.then(release, release);
|
|
707
|
+
return attempt;
|
|
708
|
+
};
|
|
709
|
+
let registration;
|
|
710
|
+
try {
|
|
711
|
+
registration = this.resolveCallRegistration(callRecord);
|
|
712
|
+
} catch (error) {
|
|
713
|
+
console.error(`[voice-call] Failed to resolve realtime call registration callId=${callId} providerCallId=${callSid}: ${formatErrorMessage(error)}`);
|
|
714
|
+
if (!hadPredecessorOnAdmission) emitCallEnd("error");
|
|
715
|
+
ws.close(1011, "Check realtime configuration for routed agent");
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
const { baseFields, initialGreeting } = preparedCall;
|
|
719
|
+
if (callRecord.metadata) delete callRecord.metadata.initialMessage;
|
|
720
|
+
this.manager.processEvent({
|
|
721
|
+
id: `realtime-answered-${callSid}`,
|
|
722
|
+
callId,
|
|
723
|
+
type: "call.answered",
|
|
724
|
+
...baseFields
|
|
725
|
+
});
|
|
726
|
+
const { agentId, instructions, provider: realtimeProvider, providerConfig } = registration;
|
|
727
|
+
const initialGreetingInstructions = buildGreetingInstructions(instructions, initialGreeting);
|
|
668
728
|
const harness = createRealtimeVoiceSessionHarness({
|
|
669
729
|
talk: {
|
|
670
730
|
sessionId: `voice-call:${callId}:realtime`,
|
|
671
731
|
mode: "realtime",
|
|
672
732
|
transport: "gateway-relay",
|
|
673
733
|
brain: "agent-consult",
|
|
674
|
-
provider:
|
|
734
|
+
provider: realtimeProvider.id
|
|
675
735
|
},
|
|
676
736
|
talkPayloads: {
|
|
677
737
|
turnStarted: () => ({
|
|
@@ -697,7 +757,7 @@ var RealtimeCallHandler = class {
|
|
|
697
757
|
},
|
|
698
758
|
onTalkEvent: (event) => appendRecentTalkEventMetadata(callRecord, event)
|
|
699
759
|
});
|
|
700
|
-
let providerHandlesInputAudioBargeIn =
|
|
760
|
+
let providerHandlesInputAudioBargeIn = realtimeProvider.capabilities?.handlesInputAudioBargeIn === true;
|
|
701
761
|
const cancelOutputAudioForBargeIn = (source, interruptProvider, clearedAudioBytes = 0) => {
|
|
702
762
|
const outputAudioActive = harness.talk.outputAudioActive;
|
|
703
763
|
const pendingTelephonyAudio = audioPacer.hasPendingAudio();
|
|
@@ -727,20 +787,6 @@ var RealtimeCallHandler = class {
|
|
|
727
787
|
}
|
|
728
788
|
});
|
|
729
789
|
console.log(`[voice-call] Realtime bridge starting for call ${callId} (providerCallId=${callSid}, initialGreeting=${initialGreetingInstructions ? "queued" : "absent"})`);
|
|
730
|
-
let callEndEmitted = false;
|
|
731
|
-
const emitCallEnd = (reason) => {
|
|
732
|
-
if (callEndEmitted) return;
|
|
733
|
-
callEndEmitted = true;
|
|
734
|
-
this.endCallInManager(callSid, callId, reason);
|
|
735
|
-
if (reason !== "error") return;
|
|
736
|
-
Promise.resolve(this.provider.hangupCall({
|
|
737
|
-
callId,
|
|
738
|
-
providerCallId: callSid,
|
|
739
|
-
reason
|
|
740
|
-
})).catch((error) => {
|
|
741
|
-
console.warn(`[voice-call] Failed to hang up realtime call ${callSid}: ${formatErrorMessage(error)}`);
|
|
742
|
-
});
|
|
743
|
-
};
|
|
744
790
|
const sendString = (message) => {
|
|
745
791
|
if (ws.readyState !== WebSocket$1.OPEN) return false;
|
|
746
792
|
if (ws.bufferedAmount > MAX_REALTIME_WS_BUFFERED_BYTES) {
|
|
@@ -773,15 +819,15 @@ var RealtimeCallHandler = class {
|
|
|
773
819
|
speechFrames: BARGE_IN_REQUIRED_LOUD_CHUNKS,
|
|
774
820
|
silenceFrames: 12
|
|
775
821
|
});
|
|
776
|
-
const interruptResponseOnInputAudio = typeof
|
|
777
|
-
const hadPredecessorOnAdmission = this.activeBridgesByCallId.has(callId);
|
|
822
|
+
const interruptResponseOnInputAudio = typeof providerConfig.interruptResponseOnInputAudio === "boolean" ? providerConfig.interruptResponseOnInputAudio : void 0;
|
|
778
823
|
const nativeConsultOwner = {};
|
|
779
824
|
const userTranscriptAdoption = this.beginUserTranscriptOwnerAdoption(callId);
|
|
780
825
|
const userTranscriptOwner = userTranscriptAdoption.owner;
|
|
781
826
|
const bridgeParams = {
|
|
782
|
-
provider:
|
|
827
|
+
provider: realtimeProvider,
|
|
783
828
|
cfg: this.coreConfig,
|
|
784
|
-
|
|
829
|
+
agentId,
|
|
830
|
+
providerConfig,
|
|
785
831
|
interruptResponseOnInputAudio,
|
|
786
832
|
instructions,
|
|
787
833
|
tools: this.config.tools,
|
|
@@ -981,6 +1027,7 @@ var RealtimeCallHandler = class {
|
|
|
981
1027
|
final: true
|
|
982
1028
|
});
|
|
983
1029
|
if (reason !== "error") return;
|
|
1030
|
+
this.streamDisconnectLifecycle.retire(callSid, streamSid);
|
|
984
1031
|
if (ws.readyState === WebSocket$1.OPEN) ws.close(1011, "Bridge disconnected");
|
|
985
1032
|
if (owner && !ownsCallState || !owner && hadPredecessorOnAdmission && this.activeBridgesByCallId.has(callId)) return;
|
|
986
1033
|
emitCallEnd("error");
|
|
@@ -1001,27 +1048,12 @@ var RealtimeCallHandler = class {
|
|
|
1001
1048
|
this.commitUserTranscriptOwnerAdoption(callId, userTranscriptAdoption);
|
|
1002
1049
|
nativeConsultOwner.current = session;
|
|
1003
1050
|
providerHandlesInputAudioBargeIn = session.bridge.handlesInputAudioBargeIn ?? providerHandlesInputAudioBargeIn;
|
|
1004
|
-
const closeTelephony = (reason) => {
|
|
1005
|
-
try {
|
|
1006
|
-
session.close();
|
|
1007
|
-
} finally {
|
|
1008
|
-
emitCallEnd(reason);
|
|
1009
|
-
}
|
|
1010
|
-
};
|
|
1011
1051
|
const previousConsultSession = this.consultSessionsByCallId.get(callId);
|
|
1012
1052
|
if (previousConsultSession && previousConsultSession.owner !== session) this.cancelConsultSession(callId, previousConsultSession.owner);
|
|
1013
1053
|
this.consultSessionsByCallId.set(callId, {
|
|
1014
1054
|
owner: session,
|
|
1015
1055
|
coordinator: harness.forcedConsults
|
|
1016
1056
|
});
|
|
1017
|
-
this.activeBridgesByCallId.set(callId, session);
|
|
1018
|
-
this.activeBridgesByCallId.set(callSid, session);
|
|
1019
|
-
const telephonyCloser = {
|
|
1020
|
-
owner: session,
|
|
1021
|
-
close: closeTelephony
|
|
1022
|
-
};
|
|
1023
|
-
this.activeTelephonyClosersByCallId.set(callId, telephonyCloser);
|
|
1024
|
-
this.activeTelephonyClosersByCallId.set(callSid, telephonyCloser);
|
|
1025
1057
|
const sendAudioToSession = session.sendAudio.bind(session);
|
|
1026
1058
|
session.sendAudio = (audio) => {
|
|
1027
1059
|
if (speechDetector.accept({
|
|
@@ -1051,9 +1083,65 @@ var RealtimeCallHandler = class {
|
|
|
1051
1083
|
audioPacer.close();
|
|
1052
1084
|
}
|
|
1053
1085
|
};
|
|
1086
|
+
let livenessTimer;
|
|
1087
|
+
const clearLivenessTimer = () => {
|
|
1088
|
+
if (livenessTimer) {
|
|
1089
|
+
clearTimeout(livenessTimer);
|
|
1090
|
+
livenessTimer = void 0;
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
let bindingClosed = false;
|
|
1094
|
+
const closeBinding = (binding, cause) => {
|
|
1095
|
+
if (bindingClosed) return callEndPromise ?? Promise.resolve();
|
|
1096
|
+
bindingClosed = true;
|
|
1097
|
+
clearLivenessTimer();
|
|
1098
|
+
const ownsCall = this.activeTelephonyBindingsByCallId.get(callId) === binding;
|
|
1099
|
+
let termination = Promise.resolve();
|
|
1100
|
+
try {
|
|
1101
|
+
session.close();
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
console.warn(`[voice-call] Failed to close realtime bridge ${callSid}: ${formatErrorMessage(error)}`);
|
|
1104
|
+
} finally {
|
|
1105
|
+
this.clearActiveTelephonyBinding(callId, binding);
|
|
1106
|
+
if (cause === "disconnect") this.streamDisconnectLifecycle.disconnect(callSid, streamSid);
|
|
1107
|
+
else this.streamDisconnectLifecycle.retire(callSid, streamSid);
|
|
1108
|
+
if (ownsCall && cause && cause !== "disconnect") termination = emitCallEnd(cause);
|
|
1109
|
+
}
|
|
1110
|
+
return termination;
|
|
1111
|
+
};
|
|
1112
|
+
const telephonyBinding = {
|
|
1113
|
+
bridge: session,
|
|
1114
|
+
close: (cause) => closeBinding(telephonyBinding, cause),
|
|
1115
|
+
endCall: () => {
|
|
1116
|
+
closeBinding(telephonyBinding);
|
|
1117
|
+
if (ws.readyState === WebSocket$1.OPEN) ws.close(1e3, "Call ended");
|
|
1118
|
+
},
|
|
1119
|
+
noteMediaActivity: () => {
|
|
1120
|
+
if (bindingClosed || this.activeTelephonyBindingsByCallId.get(callId) !== telephonyBinding) return;
|
|
1121
|
+
clearLivenessTimer();
|
|
1122
|
+
livenessTimer = setTimeout(() => {
|
|
1123
|
+
console.warn(`[voice-call] Realtime media inactive callId=${callId} providerCallId=${callSid} timeoutMs=${REALTIME_MEDIA_INACTIVITY_TIMEOUT_MS} graceMs=${REALTIME_DISCONNECT_HANGUP_GRACE_MS}`);
|
|
1124
|
+
livenessTimer = setTimeout(() => {
|
|
1125
|
+
telephonyBinding.close("inactivity");
|
|
1126
|
+
if (ws.readyState === WebSocket$1.OPEN) ws.close(1e3, "Media inactivity");
|
|
1127
|
+
}, REALTIME_DISCONNECT_HANGUP_GRACE_MS);
|
|
1128
|
+
livenessTimer.unref?.();
|
|
1129
|
+
}, REALTIME_MEDIA_INACTIVITY_TIMEOUT_MS);
|
|
1130
|
+
livenessTimer.unref?.();
|
|
1131
|
+
},
|
|
1132
|
+
retire: () => {
|
|
1133
|
+
closeBinding(telephonyBinding);
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
this.activeBridgesByCallId.set(callId, session);
|
|
1137
|
+
this.activeBridgesByCallId.set(callSid, session);
|
|
1138
|
+
this.activeTelephonyBindingsByCallId.set(callId, telephonyBinding);
|
|
1139
|
+
telephonyBinding.noteMediaActivity();
|
|
1140
|
+
if (previousTelephonyBinding && previousTelephonyBinding !== telephonyBinding) previousTelephonyBinding.retire();
|
|
1054
1141
|
session.connect().catch((error) => {
|
|
1055
1142
|
console.error("[voice-call] Failed to connect realtime bridge:", error);
|
|
1056
1143
|
const ownsCallState = this.isActiveBridgeOwner(callId, session);
|
|
1144
|
+
this.streamDisconnectLifecycle.retire(callSid, streamSid);
|
|
1057
1145
|
try {
|
|
1058
1146
|
session.close();
|
|
1059
1147
|
} catch (closeError) {
|
|
@@ -1063,7 +1151,7 @@ var RealtimeCallHandler = class {
|
|
|
1063
1151
|
ws.close(1011, "Failed to connect");
|
|
1064
1152
|
}
|
|
1065
1153
|
});
|
|
1066
|
-
return
|
|
1154
|
+
return telephonyBinding;
|
|
1067
1155
|
}
|
|
1068
1156
|
beginUserTranscriptOwnerAdoption(callId) {
|
|
1069
1157
|
const adoption = {
|
|
@@ -1187,9 +1275,11 @@ var RealtimeCallHandler = class {
|
|
|
1187
1275
|
for (const key of [callId, callSid]) {
|
|
1188
1276
|
if (this.activeBridgesByCallId.get(key) !== owner) continue;
|
|
1189
1277
|
this.activeBridgesByCallId.delete(key);
|
|
1190
|
-
this.activeTelephonyClosersByCallId.delete(key);
|
|
1191
1278
|
}
|
|
1192
1279
|
}
|
|
1280
|
+
clearActiveTelephonyBinding(callId, binding) {
|
|
1281
|
+
if (this.activeTelephonyBindingsByCallId.get(callId) === binding) this.activeTelephonyBindingsByCallId.delete(callId);
|
|
1282
|
+
}
|
|
1193
1283
|
resolveUserTranscriptContext(callId, owner) {
|
|
1194
1284
|
const state = this.getUserTranscriptState(callId, owner);
|
|
1195
1285
|
return state?.partial ?? state?.recentFinal;
|
|
@@ -1228,14 +1318,6 @@ var RealtimeCallHandler = class {
|
|
|
1228
1318
|
});
|
|
1229
1319
|
}
|
|
1230
1320
|
}
|
|
1231
|
-
closeTelephonyBridge(callIdOrSid, bridge, reason) {
|
|
1232
|
-
const closer = this.activeTelephonyClosersByCallId.get(callIdOrSid);
|
|
1233
|
-
if (closer && closer.owner === bridge) {
|
|
1234
|
-
closer.close(reason);
|
|
1235
|
-
return;
|
|
1236
|
-
}
|
|
1237
|
-
bridge?.close();
|
|
1238
|
-
}
|
|
1239
1321
|
scheduleForcedAgentConsult(params) {
|
|
1240
1322
|
if (this.config.consultPolicy !== "always" || this.activeBridgesByCallId.get(params.callId) !== params.session) return;
|
|
1241
1323
|
const question = params.transcript.trim();
|
|
@@ -1312,7 +1394,7 @@ var RealtimeCallHandler = class {
|
|
|
1312
1394
|
}, FORCED_CONSULT_NATIVE_DEDUPE_MS).unref?.();
|
|
1313
1395
|
}
|
|
1314
1396
|
}
|
|
1315
|
-
|
|
1397
|
+
prepareCallInManager(callSid, callerMeta = {}) {
|
|
1316
1398
|
const baseFields = {
|
|
1317
1399
|
providerCallId: callSid,
|
|
1318
1400
|
timestamp: Date.now(),
|
|
@@ -1324,18 +1406,10 @@ var RealtimeCallHandler = class {
|
|
|
1324
1406
|
if (!callRecord) return null;
|
|
1325
1407
|
const initialGreeting = this.extractInitialGreeting(callRecord);
|
|
1326
1408
|
console.log(`[voice-call] Realtime call ${callRecord.callId} initial greeting ${initialGreeting ? "queued" : "absent"}`);
|
|
1327
|
-
if (callRecord.metadata) delete callRecord.metadata.initialMessage;
|
|
1328
|
-
this.manager.processEvent({
|
|
1329
|
-
id: `realtime-answered-${callSid}`,
|
|
1330
|
-
callId: callRecord.callId,
|
|
1331
|
-
type: "call.answered",
|
|
1332
|
-
...baseFields
|
|
1333
|
-
});
|
|
1334
|
-
const instructions = this.resolveInstructions?.(callRecord) ?? this.config.instructions;
|
|
1335
1409
|
return {
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1410
|
+
callRecord,
|
|
1411
|
+
baseFields,
|
|
1412
|
+
initialGreeting
|
|
1339
1413
|
};
|
|
1340
1414
|
}
|
|
1341
1415
|
resolveRealtimeCall(callSid, callerMeta, baseFields) {
|
|
@@ -1354,17 +1428,57 @@ var RealtimeCallHandler = class {
|
|
|
1354
1428
|
extractInitialGreeting(call) {
|
|
1355
1429
|
return typeof call.metadata?.initialMessage === "string" ? call.metadata.initialMessage : void 0;
|
|
1356
1430
|
}
|
|
1357
|
-
|
|
1358
|
-
this.
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1431
|
+
async executeEndCallTool(params) {
|
|
1432
|
+
const binding = this.activeTelephonyBindingsByCallId.get(params.callId);
|
|
1433
|
+
if (!binding || binding.bridge !== params.bridge || !this.isActiveBridgeOwner(params.callId, params.bridge)) return;
|
|
1434
|
+
let result;
|
|
1435
|
+
try {
|
|
1436
|
+
result = await this.manager.endCall(params.callId);
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
result = {
|
|
1439
|
+
success: false,
|
|
1440
|
+
error: formatErrorMessage(error)
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
if (this.activeTelephonyBindingsByCallId.get(params.callId) !== binding || !this.isActiveBridgeOwner(params.callId, params.bridge)) return;
|
|
1444
|
+
if (!result.success) {
|
|
1445
|
+
const toolResult = { error: `Could not end the current phone call: ${result.error?.trim() || "the telephony provider returned no reason"}. Tell the caller the call could not be ended and they can hang up or ask you to try again.` };
|
|
1446
|
+
await params.bridge.submitToolResult(params.bridgeCallId, toolResult);
|
|
1447
|
+
params.harness.emit({
|
|
1448
|
+
type: "tool.error",
|
|
1449
|
+
turnId: params.turnId,
|
|
1450
|
+
callId: params.bridgeCallId,
|
|
1451
|
+
payload: {
|
|
1452
|
+
name: REALTIME_VOICE_END_CALL_TOOL_NAME,
|
|
1453
|
+
result: toolResult
|
|
1454
|
+
},
|
|
1455
|
+
final: true
|
|
1456
|
+
});
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
params.harness.emit({
|
|
1460
|
+
type: "tool.result",
|
|
1461
|
+
turnId: params.turnId,
|
|
1462
|
+
callId: params.bridgeCallId,
|
|
1463
|
+
payload: {
|
|
1464
|
+
name: REALTIME_VOICE_END_CALL_TOOL_NAME,
|
|
1465
|
+
result: { success: true }
|
|
1466
|
+
},
|
|
1467
|
+
final: true
|
|
1365
1468
|
});
|
|
1469
|
+
binding.endCall();
|
|
1366
1470
|
}
|
|
1367
1471
|
async executeToolCall(bridge, callId, bridgeCallId, name, args, turnId, harness, userTranscriptOwner) {
|
|
1472
|
+
if (name === "openclaw_end_call") {
|
|
1473
|
+
await this.executeEndCallTool({
|
|
1474
|
+
bridge,
|
|
1475
|
+
callId,
|
|
1476
|
+
bridgeCallId,
|
|
1477
|
+
turnId,
|
|
1478
|
+
harness
|
|
1479
|
+
});
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1368
1482
|
const handler = this.toolHandlers.get(name);
|
|
1369
1483
|
const startedAt = Date.now();
|
|
1370
1484
|
const hasResultError = (result) => {
|