@openclaw/voice-call 2026.5.2 → 2026.5.3-beta.1
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/api.js +2 -0
- package/dist/call-status-CXldV5o8.js +32 -0
- package/dist/cli-metadata.js +12 -0
- package/dist/config-7w04YpHh.js +548 -0
- package/dist/config-compat-B0me39_4.js +129 -0
- package/dist/guarded-json-api-Btx5EE4w.js +591 -0
- package/dist/http-headers-BrnxBasF.js +10 -0
- package/dist/index.js +1284 -0
- package/dist/mock-CeKvfVEd.js +135 -0
- package/dist/plivo-B-a7KFoT.js +393 -0
- package/dist/realtime-handler-B63CIDP2.js +325 -0
- package/dist/realtime-transcription.runtime-B2h70y2W.js +2 -0
- package/dist/realtime-voice.runtime-Bkh4nvLn.js +2 -0
- package/dist/response-generator-BrcmwDZU.js +182 -0
- package/dist/response-model-CyF5K80p.js +12 -0
- package/dist/runtime-api.js +6 -0
- package/dist/runtime-entry-88ytYAQa.js +3119 -0
- package/dist/runtime-entry.js +2 -0
- package/dist/setup-api.js +37 -0
- package/dist/telnyx-jjBE8boz.js +260 -0
- package/dist/twilio-1OqbcXLL.js +676 -0
- package/dist/voice-mapping-BYDGdWGx.js +40 -0
- package/package.json +14 -6
- package/api.ts +0 -16
- package/cli-metadata.ts +0 -10
- package/config-api.ts +0 -12
- package/index.test.ts +0 -943
- package/index.ts +0 -794
- package/runtime-api.ts +0 -20
- package/runtime-entry.ts +0 -1
- package/setup-api.ts +0 -47
- package/src/allowlist.test.ts +0 -18
- package/src/allowlist.ts +0 -19
- package/src/cli.ts +0 -845
- package/src/config-compat.test.ts +0 -120
- package/src/config-compat.ts +0 -227
- package/src/config.test.ts +0 -479
- package/src/config.ts +0 -808
- package/src/core-bridge.ts +0 -14
- package/src/deep-merge.test.ts +0 -40
- package/src/deep-merge.ts +0 -23
- package/src/gateway-continue-operation.ts +0 -200
- package/src/http-headers.test.ts +0 -16
- package/src/http-headers.ts +0 -15
- package/src/manager/context.ts +0 -42
- package/src/manager/events.test.ts +0 -581
- package/src/manager/events.ts +0 -288
- package/src/manager/lifecycle.ts +0 -53
- package/src/manager/lookup.test.ts +0 -52
- package/src/manager/lookup.ts +0 -35
- package/src/manager/outbound.test.ts +0 -528
- package/src/manager/outbound.ts +0 -486
- package/src/manager/state.ts +0 -48
- package/src/manager/store.ts +0 -106
- package/src/manager/timers.test.ts +0 -129
- package/src/manager/timers.ts +0 -113
- package/src/manager/twiml.test.ts +0 -13
- package/src/manager/twiml.ts +0 -17
- package/src/manager.closed-loop.test.ts +0 -236
- package/src/manager.inbound-allowlist.test.ts +0 -188
- package/src/manager.notify.test.ts +0 -377
- package/src/manager.restore.test.ts +0 -183
- package/src/manager.test-harness.ts +0 -127
- package/src/manager.ts +0 -392
- package/src/media-stream.test.ts +0 -768
- package/src/media-stream.ts +0 -708
- package/src/providers/base.ts +0 -97
- package/src/providers/mock.test.ts +0 -78
- package/src/providers/mock.ts +0 -185
- package/src/providers/plivo.test.ts +0 -93
- package/src/providers/plivo.ts +0 -601
- package/src/providers/shared/call-status.test.ts +0 -24
- package/src/providers/shared/call-status.ts +0 -24
- package/src/providers/shared/guarded-json-api.test.ts +0 -106
- package/src/providers/shared/guarded-json-api.ts +0 -42
- package/src/providers/telnyx.test.ts +0 -340
- package/src/providers/telnyx.ts +0 -394
- package/src/providers/twilio/api.test.ts +0 -145
- package/src/providers/twilio/api.ts +0 -93
- package/src/providers/twilio/twiml-policy.test.ts +0 -84
- package/src/providers/twilio/twiml-policy.ts +0 -87
- package/src/providers/twilio/webhook.ts +0 -34
- package/src/providers/twilio.test.ts +0 -591
- package/src/providers/twilio.ts +0 -861
- package/src/providers/twilio.types.ts +0 -17
- package/src/realtime-defaults.ts +0 -3
- package/src/realtime-fast-context.test.ts +0 -88
- package/src/realtime-fast-context.ts +0 -165
- package/src/realtime-transcription.runtime.ts +0 -4
- package/src/realtime-voice.runtime.ts +0 -5
- package/src/response-generator.test.ts +0 -321
- package/src/response-generator.ts +0 -318
- package/src/response-model.test.ts +0 -71
- package/src/response-model.ts +0 -23
- package/src/runtime.test.ts +0 -536
- package/src/runtime.ts +0 -510
- package/src/telephony-audio.test.ts +0 -61
- package/src/telephony-audio.ts +0 -12
- package/src/telephony-tts.test.ts +0 -196
- package/src/telephony-tts.ts +0 -235
- package/src/test-fixtures.ts +0 -73
- package/src/tts-provider-voice.test.ts +0 -34
- package/src/tts-provider-voice.ts +0 -21
- package/src/tunnel.test.ts +0 -166
- package/src/tunnel.ts +0 -314
- package/src/types.ts +0 -291
- package/src/utils.test.ts +0 -17
- package/src/utils.ts +0 -14
- package/src/voice-mapping.test.ts +0 -34
- package/src/voice-mapping.ts +0 -68
- package/src/webhook/realtime-handler.test.ts +0 -598
- package/src/webhook/realtime-handler.ts +0 -485
- package/src/webhook/stale-call-reaper.test.ts +0 -88
- package/src/webhook/stale-call-reaper.ts +0 -38
- package/src/webhook/tailscale.test.ts +0 -214
- package/src/webhook/tailscale.ts +0 -129
- package/src/webhook-exposure.test.ts +0 -33
- package/src/webhook-exposure.ts +0 -84
- package/src/webhook-security.test.ts +0 -770
- package/src/webhook-security.ts +0 -994
- package/src/webhook.hangup-once.lifecycle.test.ts +0 -135
- package/src/webhook.test.ts +0 -1470
- package/src/webhook.ts +0 -908
- package/src/webhook.types.ts +0 -5
- package/src/websocket-test-support.ts +0 -72
- package/tsconfig.json +0 -16
|
@@ -1,485 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import http from "node:http";
|
|
3
|
-
import type { Duplex } from "node:stream";
|
|
4
|
-
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
5
|
-
import {
|
|
6
|
-
buildRealtimeVoiceAgentConsultWorkingResponse,
|
|
7
|
-
createRealtimeVoiceBridgeSession,
|
|
8
|
-
REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME,
|
|
9
|
-
type RealtimeVoiceBridgeSession,
|
|
10
|
-
type RealtimeVoiceProviderConfig,
|
|
11
|
-
type RealtimeVoiceProviderPlugin,
|
|
12
|
-
} from "openclaw/plugin-sdk/realtime-voice";
|
|
13
|
-
import WebSocket, { WebSocketServer } from "ws";
|
|
14
|
-
import type { VoiceCallRealtimeConfig } from "../config.js";
|
|
15
|
-
import type { CallManager } from "../manager.js";
|
|
16
|
-
import type { VoiceCallProvider } from "../providers/base.js";
|
|
17
|
-
import type { CallRecord, NormalizedEvent } from "../types.js";
|
|
18
|
-
import type { WebhookResponsePayload } from "../webhook.types.js";
|
|
19
|
-
|
|
20
|
-
export type ToolHandlerContext = {
|
|
21
|
-
partialUserTranscript?: string;
|
|
22
|
-
};
|
|
23
|
-
export type ToolHandlerFn = (
|
|
24
|
-
args: unknown,
|
|
25
|
-
callId: string,
|
|
26
|
-
context: ToolHandlerContext,
|
|
27
|
-
) => Promise<unknown>;
|
|
28
|
-
|
|
29
|
-
const STREAM_TOKEN_TTL_MS = 30_000;
|
|
30
|
-
const DEFAULT_HOST = "localhost:8443";
|
|
31
|
-
const MAX_REALTIME_MESSAGE_BYTES = 256 * 1024;
|
|
32
|
-
|
|
33
|
-
function normalizePath(pathname: string): string {
|
|
34
|
-
const trimmed = pathname.trim();
|
|
35
|
-
if (!trimmed) {
|
|
36
|
-
return "/";
|
|
37
|
-
}
|
|
38
|
-
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
39
|
-
if (prefixed === "/") {
|
|
40
|
-
return prefixed;
|
|
41
|
-
}
|
|
42
|
-
return prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function buildGreetingInstructions(
|
|
46
|
-
baseInstructions: string | undefined,
|
|
47
|
-
greeting: string | undefined,
|
|
48
|
-
): string | undefined {
|
|
49
|
-
const trimmedGreeting = greeting?.trim();
|
|
50
|
-
if (!trimmedGreeting) {
|
|
51
|
-
return undefined;
|
|
52
|
-
}
|
|
53
|
-
const intro =
|
|
54
|
-
"Start the call by greeting the caller naturally. Include this greeting in your first spoken reply:";
|
|
55
|
-
return baseInstructions
|
|
56
|
-
? `${baseInstructions}\n\n${intro} "${trimmedGreeting}"`
|
|
57
|
-
: `${intro} "${trimmedGreeting}"`;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
type PendingStreamToken = {
|
|
61
|
-
expiry: number;
|
|
62
|
-
from?: string;
|
|
63
|
-
to?: string;
|
|
64
|
-
direction?: "inbound" | "outbound";
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
type CallRegistration = {
|
|
68
|
-
callId: string;
|
|
69
|
-
initialGreetingInstructions?: string;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
type ActiveRealtimeVoiceBridge = RealtimeVoiceBridgeSession;
|
|
73
|
-
|
|
74
|
-
type RealtimeSpeakResult = {
|
|
75
|
-
success: boolean;
|
|
76
|
-
error?: string;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
export class RealtimeCallHandler {
|
|
80
|
-
private readonly toolHandlers = new Map<string, ToolHandlerFn>();
|
|
81
|
-
private readonly pendingStreamTokens = new Map<string, PendingStreamToken>();
|
|
82
|
-
private readonly activeBridgesByCallId = new Map<string, ActiveRealtimeVoiceBridge>();
|
|
83
|
-
private readonly partialUserTranscriptsByCallId = new Map<string, string>();
|
|
84
|
-
private publicOrigin: string | null = null;
|
|
85
|
-
private publicPathPrefix = "";
|
|
86
|
-
|
|
87
|
-
constructor(
|
|
88
|
-
private readonly config: VoiceCallRealtimeConfig,
|
|
89
|
-
private readonly manager: CallManager,
|
|
90
|
-
private readonly provider: VoiceCallProvider,
|
|
91
|
-
private readonly realtimeProvider: RealtimeVoiceProviderPlugin,
|
|
92
|
-
private readonly providerConfig: RealtimeVoiceProviderConfig,
|
|
93
|
-
private readonly servePath: string,
|
|
94
|
-
) {}
|
|
95
|
-
|
|
96
|
-
setPublicUrl(url: string): void {
|
|
97
|
-
try {
|
|
98
|
-
const parsed = new URL(url);
|
|
99
|
-
this.publicOrigin = parsed.host;
|
|
100
|
-
const normalizedServePath = normalizePath(this.servePath);
|
|
101
|
-
const normalizedPublicPath = normalizePath(parsed.pathname);
|
|
102
|
-
const idx = normalizedPublicPath.indexOf(normalizedServePath);
|
|
103
|
-
this.publicPathPrefix = idx > 0 ? normalizedPublicPath.slice(0, idx) : "";
|
|
104
|
-
} catch {
|
|
105
|
-
this.publicOrigin = null;
|
|
106
|
-
this.publicPathPrefix = "";
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
getStreamPathPattern(): string {
|
|
111
|
-
return `${this.publicPathPrefix}${normalizePath(this.config.streamPath ?? "/voice/stream/realtime")}`;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
buildTwiMLPayload(req: http.IncomingMessage, params?: URLSearchParams): WebhookResponsePayload {
|
|
115
|
-
const host = this.publicOrigin || req.headers.host || DEFAULT_HOST;
|
|
116
|
-
const rawDirection = params?.get("Direction");
|
|
117
|
-
const token = this.issueStreamToken({
|
|
118
|
-
from: params?.get("From") ?? undefined,
|
|
119
|
-
to: params?.get("To") ?? undefined,
|
|
120
|
-
direction: rawDirection?.startsWith("outbound") ? "outbound" : "inbound",
|
|
121
|
-
});
|
|
122
|
-
const wsUrl = `wss://${host}${this.getStreamPathPattern()}/${token}`;
|
|
123
|
-
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
124
|
-
<Response>
|
|
125
|
-
<Connect>
|
|
126
|
-
<Stream url="${wsUrl}" />
|
|
127
|
-
</Connect>
|
|
128
|
-
</Response>`;
|
|
129
|
-
return {
|
|
130
|
-
statusCode: 200,
|
|
131
|
-
headers: { "Content-Type": "text/xml" },
|
|
132
|
-
body: twiml,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
handleWebSocketUpgrade(request: http.IncomingMessage, socket: Duplex, head: Buffer): void {
|
|
137
|
-
const url = new URL(request.url ?? "/", "wss://localhost");
|
|
138
|
-
const token = url.pathname.split("/").pop() ?? null;
|
|
139
|
-
const callerMeta = token ? this.consumeStreamToken(token) : null;
|
|
140
|
-
if (!callerMeta) {
|
|
141
|
-
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
142
|
-
socket.destroy();
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const wss = new WebSocketServer({
|
|
147
|
-
noServer: true,
|
|
148
|
-
// Reject oversized realtime frames before JSON parsing or bridge setup runs.
|
|
149
|
-
maxPayload: MAX_REALTIME_MESSAGE_BYTES,
|
|
150
|
-
});
|
|
151
|
-
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
152
|
-
let bridge: ActiveRealtimeVoiceBridge | null = null;
|
|
153
|
-
let initialized = false;
|
|
154
|
-
|
|
155
|
-
ws.on("message", (data: Buffer) => {
|
|
156
|
-
try {
|
|
157
|
-
const msg = JSON.parse(data.toString()) as Record<string, unknown>;
|
|
158
|
-
if (!initialized && msg.event === "start") {
|
|
159
|
-
initialized = true;
|
|
160
|
-
const startData =
|
|
161
|
-
typeof msg.start === "object" && msg.start !== null
|
|
162
|
-
? (msg.start as Record<string, unknown>)
|
|
163
|
-
: undefined;
|
|
164
|
-
const streamSid =
|
|
165
|
-
typeof startData?.streamSid === "string" ? startData.streamSid : "unknown";
|
|
166
|
-
const callSid = typeof startData?.callSid === "string" ? startData.callSid : "unknown";
|
|
167
|
-
const nextBridge = this.handleCall(streamSid, callSid, ws, callerMeta);
|
|
168
|
-
if (!nextBridge) {
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
bridge = nextBridge;
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
if (!bridge) {
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
const mediaData =
|
|
178
|
-
typeof msg.media === "object" && msg.media !== null
|
|
179
|
-
? (msg.media as Record<string, unknown>)
|
|
180
|
-
: undefined;
|
|
181
|
-
if (msg.event === "media" && typeof mediaData?.payload === "string") {
|
|
182
|
-
bridge.sendAudio(Buffer.from(mediaData.payload, "base64"));
|
|
183
|
-
if (typeof mediaData.timestamp === "number") {
|
|
184
|
-
bridge.setMediaTimestamp(mediaData.timestamp);
|
|
185
|
-
} else if (typeof mediaData.timestamp === "string") {
|
|
186
|
-
bridge.setMediaTimestamp(Number.parseInt(mediaData.timestamp, 10));
|
|
187
|
-
}
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
if (msg.event === "mark") {
|
|
191
|
-
bridge.acknowledgeMark();
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
if (msg.event === "stop") {
|
|
195
|
-
bridge.close();
|
|
196
|
-
}
|
|
197
|
-
} catch (error) {
|
|
198
|
-
console.error("[voice-call] realtime WS parse failed:", error);
|
|
199
|
-
}
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
ws.on("close", () => {
|
|
203
|
-
bridge?.close();
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
ws.on("error", (error) => {
|
|
207
|
-
console.error("[voice-call] realtime WS error:", error);
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
registerToolHandler(name: string, fn: ToolHandlerFn): void {
|
|
213
|
-
this.toolHandlers.set(name, fn);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
speak(callId: string, instructions: string): RealtimeSpeakResult {
|
|
217
|
-
const bridge = this.activeBridgesByCallId.get(callId);
|
|
218
|
-
if (!bridge) {
|
|
219
|
-
return { success: false, error: "No active realtime bridge for call" };
|
|
220
|
-
}
|
|
221
|
-
try {
|
|
222
|
-
bridge.triggerGreeting(instructions);
|
|
223
|
-
return { success: true };
|
|
224
|
-
} catch (error) {
|
|
225
|
-
return { success: false, error: formatErrorMessage(error) };
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
private issueStreamToken(meta: Omit<PendingStreamToken, "expiry"> = {}): string {
|
|
230
|
-
const token = randomUUID();
|
|
231
|
-
this.pendingStreamTokens.set(token, { expiry: Date.now() + STREAM_TOKEN_TTL_MS, ...meta });
|
|
232
|
-
for (const [candidate, entry] of this.pendingStreamTokens) {
|
|
233
|
-
if (Date.now() > entry.expiry) {
|
|
234
|
-
this.pendingStreamTokens.delete(candidate);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
return token;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
private consumeStreamToken(token: string): Omit<PendingStreamToken, "expiry"> | null {
|
|
241
|
-
const entry = this.pendingStreamTokens.get(token);
|
|
242
|
-
if (!entry) {
|
|
243
|
-
return null;
|
|
244
|
-
}
|
|
245
|
-
this.pendingStreamTokens.delete(token);
|
|
246
|
-
if (Date.now() > entry.expiry) {
|
|
247
|
-
return null;
|
|
248
|
-
}
|
|
249
|
-
return {
|
|
250
|
-
from: entry.from,
|
|
251
|
-
to: entry.to,
|
|
252
|
-
direction: entry.direction,
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
private handleCall(
|
|
257
|
-
streamSid: string,
|
|
258
|
-
callSid: string,
|
|
259
|
-
ws: WebSocket,
|
|
260
|
-
callerMeta: Omit<PendingStreamToken, "expiry">,
|
|
261
|
-
): ActiveRealtimeVoiceBridge | null {
|
|
262
|
-
const registration = this.registerCallInManager(callSid, callerMeta);
|
|
263
|
-
if (!registration) {
|
|
264
|
-
ws.close(1008, "Caller rejected by policy");
|
|
265
|
-
return null;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const { callId, initialGreetingInstructions } = registration;
|
|
269
|
-
console.log(
|
|
270
|
-
`[voice-call] Realtime bridge starting for call ${callId} (providerCallId=${callSid}, initialGreeting=${initialGreetingInstructions ? "queued" : "absent"})`,
|
|
271
|
-
);
|
|
272
|
-
let callEndEmitted = false;
|
|
273
|
-
const emitCallEnd = (reason: "completed" | "error") => {
|
|
274
|
-
if (callEndEmitted) {
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
callEndEmitted = true;
|
|
278
|
-
this.endCallInManager(callSid, callId, reason);
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
const bridge = createRealtimeVoiceBridgeSession({
|
|
282
|
-
provider: this.realtimeProvider,
|
|
283
|
-
providerConfig: this.providerConfig,
|
|
284
|
-
instructions: this.config.instructions,
|
|
285
|
-
tools: this.config.tools,
|
|
286
|
-
initialGreetingInstructions,
|
|
287
|
-
triggerGreetingOnReady: Boolean(initialGreetingInstructions),
|
|
288
|
-
audioSink: {
|
|
289
|
-
isOpen: () => ws.readyState === WebSocket.OPEN,
|
|
290
|
-
sendAudio: (muLaw) => {
|
|
291
|
-
ws.send(
|
|
292
|
-
JSON.stringify({
|
|
293
|
-
event: "media",
|
|
294
|
-
streamSid,
|
|
295
|
-
media: { payload: muLaw.toString("base64") },
|
|
296
|
-
}),
|
|
297
|
-
);
|
|
298
|
-
},
|
|
299
|
-
clearAudio: () => {
|
|
300
|
-
ws.send(JSON.stringify({ event: "clear", streamSid }));
|
|
301
|
-
},
|
|
302
|
-
sendMark: (markName) => {
|
|
303
|
-
ws.send(JSON.stringify({ event: "mark", streamSid, mark: { name: markName } }));
|
|
304
|
-
},
|
|
305
|
-
},
|
|
306
|
-
onTranscript: (role, text, isFinal) => {
|
|
307
|
-
if (!isFinal) {
|
|
308
|
-
if (role === "user" && text.trim()) {
|
|
309
|
-
this.partialUserTranscriptsByCallId.set(callId, text);
|
|
310
|
-
}
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
if (role === "user") {
|
|
314
|
-
this.partialUserTranscriptsByCallId.delete(callId);
|
|
315
|
-
const event: NormalizedEvent = {
|
|
316
|
-
id: `realtime-speech-${callSid}-${Date.now()}`,
|
|
317
|
-
type: "call.speech",
|
|
318
|
-
callId,
|
|
319
|
-
providerCallId: callSid,
|
|
320
|
-
timestamp: Date.now(),
|
|
321
|
-
transcript: text,
|
|
322
|
-
isFinal: true,
|
|
323
|
-
};
|
|
324
|
-
this.manager.processEvent(event);
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
this.manager.processEvent({
|
|
328
|
-
id: `realtime-bot-${callSid}-${Date.now()}`,
|
|
329
|
-
type: "call.speaking",
|
|
330
|
-
callId,
|
|
331
|
-
providerCallId: callSid,
|
|
332
|
-
timestamp: Date.now(),
|
|
333
|
-
text,
|
|
334
|
-
});
|
|
335
|
-
},
|
|
336
|
-
onToolCall: (toolEvent, session) => {
|
|
337
|
-
void this.executeToolCall(
|
|
338
|
-
session,
|
|
339
|
-
callId,
|
|
340
|
-
toolEvent.callId || toolEvent.itemId,
|
|
341
|
-
toolEvent.name,
|
|
342
|
-
toolEvent.args,
|
|
343
|
-
);
|
|
344
|
-
},
|
|
345
|
-
onError: (error) => {
|
|
346
|
-
console.error("[voice-call] realtime voice error:", error.message);
|
|
347
|
-
},
|
|
348
|
-
onClose: (reason) => {
|
|
349
|
-
this.activeBridgesByCallId.delete(callId);
|
|
350
|
-
this.activeBridgesByCallId.delete(callSid);
|
|
351
|
-
this.partialUserTranscriptsByCallId.delete(callId);
|
|
352
|
-
if (reason !== "error") {
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
emitCallEnd("error");
|
|
356
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
357
|
-
ws.close(1011, "Bridge disconnected");
|
|
358
|
-
}
|
|
359
|
-
void this.provider
|
|
360
|
-
.hangupCall({ callId, providerCallId: callSid, reason: "error" })
|
|
361
|
-
.catch((error: unknown) => {
|
|
362
|
-
console.warn(
|
|
363
|
-
`[voice-call] Failed to hang up realtime call ${callSid}: ${formatErrorMessage(
|
|
364
|
-
error,
|
|
365
|
-
)}`,
|
|
366
|
-
);
|
|
367
|
-
});
|
|
368
|
-
},
|
|
369
|
-
});
|
|
370
|
-
this.activeBridgesByCallId.set(callId, bridge);
|
|
371
|
-
this.activeBridgesByCallId.set(callSid, bridge);
|
|
372
|
-
const closeBridge = bridge.close.bind(bridge);
|
|
373
|
-
bridge.close = () => {
|
|
374
|
-
this.activeBridgesByCallId.delete(callId);
|
|
375
|
-
this.activeBridgesByCallId.delete(callSid);
|
|
376
|
-
this.partialUserTranscriptsByCallId.delete(callId);
|
|
377
|
-
closeBridge();
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
bridge.connect().catch((error: Error) => {
|
|
381
|
-
console.error("[voice-call] Failed to connect realtime bridge:", error);
|
|
382
|
-
bridge.close();
|
|
383
|
-
emitCallEnd("error");
|
|
384
|
-
ws.close(1011, "Failed to connect");
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
return bridge;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
private registerCallInManager(
|
|
391
|
-
callSid: string,
|
|
392
|
-
callerMeta: Omit<PendingStreamToken, "expiry"> = {},
|
|
393
|
-
): CallRegistration | null {
|
|
394
|
-
const timestamp = Date.now();
|
|
395
|
-
const baseFields = {
|
|
396
|
-
providerCallId: callSid,
|
|
397
|
-
timestamp,
|
|
398
|
-
direction: callerMeta.direction ?? "inbound",
|
|
399
|
-
...(callerMeta.from ? { from: callerMeta.from } : {}),
|
|
400
|
-
...(callerMeta.to ? { to: callerMeta.to } : {}),
|
|
401
|
-
};
|
|
402
|
-
|
|
403
|
-
this.manager.processEvent({
|
|
404
|
-
id: `realtime-initiated-${callSid}`,
|
|
405
|
-
callId: callSid,
|
|
406
|
-
type: "call.initiated",
|
|
407
|
-
...baseFields,
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
const callRecord = this.manager.getCallByProviderCallId(callSid);
|
|
411
|
-
if (!callRecord) {
|
|
412
|
-
return null;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const initialGreeting = this.extractInitialGreeting(callRecord);
|
|
416
|
-
console.log(
|
|
417
|
-
`[voice-call] Realtime call ${callRecord.callId} initial greeting ${initialGreeting ? "queued" : "absent"}`,
|
|
418
|
-
);
|
|
419
|
-
if (callRecord.metadata) {
|
|
420
|
-
delete callRecord.metadata.initialMessage;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
this.manager.processEvent({
|
|
424
|
-
id: `realtime-answered-${callSid}`,
|
|
425
|
-
callId: callSid,
|
|
426
|
-
type: "call.answered",
|
|
427
|
-
...baseFields,
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
return {
|
|
431
|
-
callId: callRecord.callId,
|
|
432
|
-
initialGreetingInstructions: buildGreetingInstructions(
|
|
433
|
-
this.config.instructions,
|
|
434
|
-
initialGreeting,
|
|
435
|
-
),
|
|
436
|
-
};
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
private extractInitialGreeting(call: CallRecord): string | undefined {
|
|
440
|
-
return typeof call.metadata?.initialMessage === "string"
|
|
441
|
-
? call.metadata.initialMessage
|
|
442
|
-
: undefined;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
private endCallInManager(callSid: string, callId: string, reason: "completed" | "error"): void {
|
|
446
|
-
this.manager.processEvent({
|
|
447
|
-
id: `realtime-ended-${callSid}-${Date.now()}`,
|
|
448
|
-
type: "call.ended",
|
|
449
|
-
callId,
|
|
450
|
-
providerCallId: callSid,
|
|
451
|
-
timestamp: Date.now(),
|
|
452
|
-
reason,
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
private async executeToolCall(
|
|
457
|
-
bridge: ActiveRealtimeVoiceBridge,
|
|
458
|
-
callId: string,
|
|
459
|
-
bridgeCallId: string,
|
|
460
|
-
name: string,
|
|
461
|
-
args: unknown,
|
|
462
|
-
): Promise<void> {
|
|
463
|
-
const handler = this.toolHandlers.get(name);
|
|
464
|
-
if (
|
|
465
|
-
handler &&
|
|
466
|
-
name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME &&
|
|
467
|
-
bridge.bridge.supportsToolResultContinuation &&
|
|
468
|
-
!this.config.fastContext.enabled
|
|
469
|
-
) {
|
|
470
|
-
bridge.submitToolResult(
|
|
471
|
-
bridgeCallId,
|
|
472
|
-
buildRealtimeVoiceAgentConsultWorkingResponse("caller"),
|
|
473
|
-
{ willContinue: true },
|
|
474
|
-
);
|
|
475
|
-
}
|
|
476
|
-
const result = !handler
|
|
477
|
-
? { error: `Tool "${name}" not available` }
|
|
478
|
-
: await handler(args, callId, {
|
|
479
|
-
partialUserTranscript: this.partialUserTranscriptsByCallId.get(callId),
|
|
480
|
-
}).catch((error: unknown) => ({
|
|
481
|
-
error: formatErrorMessage(error),
|
|
482
|
-
}));
|
|
483
|
-
bridge.submitToolResult(bridgeCallId, result);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { startStaleCallReaper } from "./stale-call-reaper.js";
|
|
3
|
-
|
|
4
|
-
describe("startStaleCallReaper", () => {
|
|
5
|
-
beforeEach(() => {
|
|
6
|
-
vi.useFakeTimers();
|
|
7
|
-
vi.setSystemTime(new Date("2026-03-22T12:00:00.000Z"));
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
afterEach(() => {
|
|
11
|
-
vi.useRealTimers();
|
|
12
|
-
vi.restoreAllMocks();
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("returns null when disabled or non-positive", () => {
|
|
16
|
-
const manager = {
|
|
17
|
-
getActiveCalls: vi.fn(() => []),
|
|
18
|
-
endCall: vi.fn(),
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
expect(startStaleCallReaper({ manager: manager as never })).toBeNull();
|
|
22
|
-
expect(
|
|
23
|
-
startStaleCallReaper({ manager: manager as never, staleCallReaperSeconds: 0 }),
|
|
24
|
-
).toBeNull();
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("reaps stale calls and ignores fresh ones", async () => {
|
|
28
|
-
const endCall = vi.fn(async () => {});
|
|
29
|
-
const manager = {
|
|
30
|
-
getActiveCalls: vi.fn(() => [
|
|
31
|
-
{
|
|
32
|
-
callId: "call-stale",
|
|
33
|
-
startedAt: Date.now() - 61_000,
|
|
34
|
-
state: "active",
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
callId: "call-fresh",
|
|
38
|
-
startedAt: Date.now() - 10_000,
|
|
39
|
-
state: "active",
|
|
40
|
-
},
|
|
41
|
-
]),
|
|
42
|
-
endCall,
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const stop = startStaleCallReaper({
|
|
46
|
-
manager: manager as never,
|
|
47
|
-
staleCallReaperSeconds: 60,
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
await vi.advanceTimersByTimeAsync(30_000);
|
|
51
|
-
|
|
52
|
-
expect(endCall).toHaveBeenCalledTimes(1);
|
|
53
|
-
expect(endCall).toHaveBeenCalledWith("call-stale");
|
|
54
|
-
|
|
55
|
-
stop?.();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("logs and swallows endCall failures", async () => {
|
|
59
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
60
|
-
const manager = {
|
|
61
|
-
getActiveCalls: vi.fn(() => [
|
|
62
|
-
{
|
|
63
|
-
callId: "call-stale",
|
|
64
|
-
startedAt: Date.now() - 61_000,
|
|
65
|
-
state: "active",
|
|
66
|
-
},
|
|
67
|
-
]),
|
|
68
|
-
endCall: vi.fn(async () => {
|
|
69
|
-
throw new Error("network");
|
|
70
|
-
}),
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const stop = startStaleCallReaper({
|
|
74
|
-
manager: manager as never,
|
|
75
|
-
staleCallReaperSeconds: 60,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
await vi.advanceTimersByTimeAsync(30_000);
|
|
79
|
-
await Promise.resolve();
|
|
80
|
-
|
|
81
|
-
expect(warn).toHaveBeenCalledWith(
|
|
82
|
-
"[voice-call] Reaper failed to end call call-stale:",
|
|
83
|
-
expect.any(Error),
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
stop?.();
|
|
87
|
-
});
|
|
88
|
-
});
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import type { CallManager } from "../manager.js";
|
|
2
|
-
import { TerminalStates } from "../types.js";
|
|
3
|
-
|
|
4
|
-
const CHECK_INTERVAL_MS = 30_000;
|
|
5
|
-
|
|
6
|
-
export function startStaleCallReaper(params: {
|
|
7
|
-
manager: CallManager;
|
|
8
|
-
staleCallReaperSeconds?: number;
|
|
9
|
-
}): (() => void) | null {
|
|
10
|
-
const maxAgeSeconds = params.staleCallReaperSeconds;
|
|
11
|
-
if (!maxAgeSeconds || maxAgeSeconds <= 0) {
|
|
12
|
-
return null;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const maxAgeMs = maxAgeSeconds * 1000;
|
|
16
|
-
const interval = setInterval(() => {
|
|
17
|
-
const now = Date.now();
|
|
18
|
-
for (const call of params.manager.getActiveCalls()) {
|
|
19
|
-
if (call.answeredAt || TerminalStates.has(call.state)) {
|
|
20
|
-
continue;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const age = now - call.startedAt;
|
|
24
|
-
if (age > maxAgeMs) {
|
|
25
|
-
console.log(
|
|
26
|
-
`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`,
|
|
27
|
-
);
|
|
28
|
-
void params.manager.endCall(call.callId).catch((err) => {
|
|
29
|
-
console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}, CHECK_INTERVAL_MS);
|
|
34
|
-
|
|
35
|
-
return () => {
|
|
36
|
-
clearInterval(interval);
|
|
37
|
-
};
|
|
38
|
-
}
|