@openclaw/voice-call 2026.9.7 → 2026.10.1-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/.setup/{cli-D6uwMXyS.mjs → cli-Cn1leykM.mjs} +57 -37
- package/dist/.setup/{config-Ds7xoZbI.mjs → config-CX9J-053.mjs} +18 -119
- package/dist/.setup/{guarded-json-api-Bqc6QS8A.mjs → guarded-json-api-x55yqHvW.mjs} +39 -94
- package/dist/.setup/{mock-ClWW6Ufu.mjs → mock-eLv_Dc4o.mjs} +3 -6
- package/dist/.setup/{plivo-69ObrQPk.mjs → plivo-CpPIENu4.mjs} +48 -101
- package/dist/.setup/{realtime-handler-BgDPcPZt.mjs → realtime-handler-Do7Sz7Gh.mjs} +123 -235
- package/dist/.setup/{response-generator-BzKrJMEb.mjs → response-generator-DAhbXEta.mjs} +6 -25
- package/dist/.setup/{runtime-entry-BNOQRXii.mjs → runtime-entry-CbSKxSKk.mjs} +554 -425
- package/dist/.setup/state-retention-api-ONgqE-_V.mjs +201 -0
- package/dist/.setup/store-path-Cfwff6j7.mjs +33 -0
- package/dist/.setup/{telnyx-DjFGCfy1.mjs → telnyx-DPilutCH.mjs} +10 -29
- package/dist/.setup/{twilio-Bp3gfmUB.mjs → twilio-BWFNGyVx.mjs} +78 -201
- package/dist/.setup/{twilio-region-4fkgz3UG.mjs → twilio-region-wBOx-8sz.mjs} +1 -4
- package/dist/.setup/voice-mapping-B_iAUkER.mjs +31 -0
- package/dist/.setup/{webhook-replay-CCQSoBNF.mjs → webhook-replay-B7RJNWNG.mjs} +2 -5
- package/dist/doctor-contract-api.js +7 -330
- package/dist/index.js +18 -16
- package/dist/runtime-entry.js +1 -1
- package/dist/state-retention-api.js +2 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -6
- package/dist/.setup/utils-B5pnM_Ij.mjs +0 -437
- package/node_modules/ws/LICENSE +0 -20
- package/node_modules/ws/README.md +0 -548
- package/node_modules/ws/browser.js +0 -8
- package/node_modules/ws/index.js +0 -22
- package/node_modules/ws/lib/buffer-util.js +0 -131
- package/node_modules/ws/lib/constants.js +0 -19
- package/node_modules/ws/lib/event-target.js +0 -292
- package/node_modules/ws/lib/extension.js +0 -203
- package/node_modules/ws/lib/limiter.js +0 -55
- package/node_modules/ws/lib/permessage-deflate.js +0 -530
- package/node_modules/ws/lib/receiver.js +0 -743
- package/node_modules/ws/lib/sender.js +0 -607
- package/node_modules/ws/lib/stream.js +0 -161
- package/node_modules/ws/lib/subprotocol.js +0 -62
- package/node_modules/ws/lib/validation.js +0 -152
- package/node_modules/ws/lib/websocket-server.js +0 -562
- package/node_modules/ws/lib/websocket.js +0 -1407
- package/node_modules/ws/package.json +0 -74
- package/node_modules/ws/wrapper.mjs +0 -21
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, sendHttpRequestRejection } from "../api.js";
|
|
2
|
-
import { a as resolveVoiceCallEffectiveConfig, c as resolveVoiceCallSessionKey, f as resolveVoiceCallRealtimeTools, i as resolveVoiceCallConfig, l as resolveVoiceCallStreamExposurePaths, m as normalizePhoneNumber, n as normalizeVoiceCallConfig, o as resolveVoiceCallNumberRouteKeyForCall, p as isAllowlistedCaller, r as resolveTwilioAuthToken, u as validateProviderConfig } from "./config-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { a as resolveVoiceCallEffectiveConfig, c as resolveVoiceCallSessionKey, f as resolveVoiceCallRealtimeTools, i as resolveVoiceCallConfig, l as resolveVoiceCallStreamExposurePaths, m as normalizePhoneNumber, n as normalizeVoiceCallConfig, o as resolveVoiceCallNumberRouteKeyForCall, p as isAllowlistedCaller, r as resolveTwilioAuthToken, u as validateProviderConfig } from "./config-CX9J-053.mjs";
|
|
3
|
+
import { n as mapVoiceToPolly, t as escapeXml } from "./voice-mapping-B_iAUkER.mjs";
|
|
4
|
+
import { r as resolveUserPath, t as resolveDefaultVoiceCallStoreDir } from "./store-path-Cfwff6j7.mjs";
|
|
5
5
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
6
6
|
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
|
|
7
7
|
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
|
@@ -10,22 +10,80 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
|
|
10
10
|
import { listAgentIds, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime";
|
|
11
11
|
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
|
12
12
|
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, assertRealtimeVoiceAgentConsultModelSelectionUnlocked, buildRealtimeVoiceAgentConsultPolicyInstructions, consultRealtimeVoiceAgent, convertPcmToMulaw8k, createTalkSessionController, recordTalkObservabilityEvent, resolveRealtimeVoiceAgentConsultToolsAllow, resolveRealtimeVoiceFastContextConsult } from "openclaw/plugin-sdk/realtime-voice";
|
|
13
|
-
import { WEBHOOK_BODY_READ_DEFAULTS, createWebhookInFlightLimiter, normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
|
13
|
+
import { WEBHOOK_BODY_READ_DEFAULTS, createWebhookInFlightLimiter, normalizeWebhookPath, rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
|
|
14
|
+
import { z } from "zod";
|
|
14
15
|
import { MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
|
15
16
|
import fs from "node:fs";
|
|
16
17
|
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
|
17
|
-
import crypto from "node:crypto";
|
|
18
|
+
import crypto, { randomUUID } from "node:crypto";
|
|
18
19
|
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
|
|
19
|
-
import
|
|
20
|
+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
20
21
|
import * as realtimeBootstrapSdk from "openclaw/plugin-sdk/realtime-bootstrap-context";
|
|
21
22
|
import { spawn } from "node:child_process";
|
|
22
23
|
import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
|
23
24
|
import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
|
|
24
25
|
import http from "node:http";
|
|
25
26
|
import { URL as URL$1 } from "node:url";
|
|
27
|
+
import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
|
|
26
28
|
import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime";
|
|
27
|
-
import { rejectWebSocketUpgrade } from "openclaw/plugin-sdk/websocket-runtime";
|
|
29
|
+
import { WebSocket, WebSocketServer, rejectWebSocketUpgrade } from "openclaw/plugin-sdk/websocket-runtime";
|
|
30
|
+
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
|
|
28
31
|
import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime";
|
|
32
|
+
//#region extensions/voice-call/src/types.ts
|
|
33
|
+
const ProviderNameSchema = z.enum([
|
|
34
|
+
"telnyx",
|
|
35
|
+
"twilio",
|
|
36
|
+
"plivo",
|
|
37
|
+
"mock"
|
|
38
|
+
]);
|
|
39
|
+
const EndReasonSchema = z.enum([
|
|
40
|
+
"completed",
|
|
41
|
+
"hangup-user",
|
|
42
|
+
"hangup-bot",
|
|
43
|
+
"timeout",
|
|
44
|
+
"error",
|
|
45
|
+
"failed",
|
|
46
|
+
"no-answer",
|
|
47
|
+
"busy",
|
|
48
|
+
"voicemail"
|
|
49
|
+
]);
|
|
50
|
+
const CallStateSchema = z.enum([
|
|
51
|
+
"initiated",
|
|
52
|
+
"ringing",
|
|
53
|
+
"answered",
|
|
54
|
+
"active",
|
|
55
|
+
"speaking",
|
|
56
|
+
"listening",
|
|
57
|
+
...EndReasonSchema.options
|
|
58
|
+
]);
|
|
59
|
+
const TerminalStates = new Set(EndReasonSchema.options);
|
|
60
|
+
const CallDirectionSchema = z.enum(["outbound", "inbound"]);
|
|
61
|
+
const TranscriptEntrySchema = z.object({
|
|
62
|
+
timestamp: z.number(),
|
|
63
|
+
speaker: z.enum(["bot", "user"]),
|
|
64
|
+
text: z.string(),
|
|
65
|
+
isFinal: z.boolean().default(true)
|
|
66
|
+
});
|
|
67
|
+
const CallRecordSchema = z.object({
|
|
68
|
+
callId: z.string(),
|
|
69
|
+
providerCallId: z.string().optional(),
|
|
70
|
+
provider: ProviderNameSchema,
|
|
71
|
+
direction: CallDirectionSchema,
|
|
72
|
+
state: CallStateSchema,
|
|
73
|
+
from: z.string(),
|
|
74
|
+
to: z.string(),
|
|
75
|
+
sessionKey: z.string().optional(),
|
|
76
|
+
/** Agent selected when the call was created; optional only for retained history. */
|
|
77
|
+
agentId: z.string().optional(),
|
|
78
|
+
startedAt: z.number(),
|
|
79
|
+
answeredAt: z.number().optional(),
|
|
80
|
+
endedAt: z.number().optional(),
|
|
81
|
+
endReason: EndReasonSchema.optional(),
|
|
82
|
+
transcript: z.array(TranscriptEntrySchema).default([]),
|
|
83
|
+
processedEventIds: z.array(z.string()).default([]),
|
|
84
|
+
metadata: z.record(z.string(), z.unknown()).optional()
|
|
85
|
+
});
|
|
86
|
+
//#endregion
|
|
29
87
|
//#region extensions/voice-call/src/manager/state.ts
|
|
30
88
|
const ConversationStates = /* @__PURE__ */ new Set(["speaking", "listening"]);
|
|
31
89
|
const StateOrder = [
|
|
@@ -38,25 +96,15 @@ const StateOrder = [
|
|
|
38
96
|
];
|
|
39
97
|
function transitionState(call, newState) {
|
|
40
98
|
if (call.state === newState || TerminalStates.has(call.state)) return;
|
|
41
|
-
if (TerminalStates.has(newState))
|
|
42
|
-
call.state = newState;
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
if (ConversationStates.has(call.state) && ConversationStates.has(newState)) {
|
|
46
|
-
call.state = newState;
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
const currentIndex = StateOrder.indexOf(call.state);
|
|
50
|
-
if (StateOrder.indexOf(newState) > currentIndex) call.state = newState;
|
|
99
|
+
if (TerminalStates.has(newState) || ConversationStates.has(call.state) && ConversationStates.has(newState) || StateOrder.indexOf(newState) > StateOrder.indexOf(call.state)) call.state = newState;
|
|
51
100
|
}
|
|
52
101
|
function addTranscriptEntry(call, speaker, text) {
|
|
53
|
-
|
|
102
|
+
call.transcript.push({
|
|
54
103
|
timestamp: Date.now(),
|
|
55
104
|
speaker,
|
|
56
105
|
text,
|
|
57
106
|
isFinal: true
|
|
58
|
-
};
|
|
59
|
-
call.transcript.push(entry);
|
|
107
|
+
});
|
|
60
108
|
}
|
|
61
109
|
/** Stage persisted changes without exposing uncommitted state through active-call getters. */
|
|
62
110
|
function copyCallRecord(call) {
|
|
@@ -68,6 +116,303 @@ function copyCallRecord(call) {
|
|
|
68
116
|
};
|
|
69
117
|
}
|
|
70
118
|
//#endregion
|
|
119
|
+
//#region extensions/voice-call/src/runtime-state.ts
|
|
120
|
+
const { setRuntime: setVoiceCallStateRuntime, tryGetRuntime: getOptionalVoiceCallStateRuntime } = createPluginRuntimeStore({
|
|
121
|
+
pluginId: "voice-call-state",
|
|
122
|
+
errorMessage: "Voice Call state runtime not initialized"
|
|
123
|
+
});
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region extensions/voice-call/src/manager/replay-keys.ts
|
|
126
|
+
/** Match the existing voice-call webhook replay-cache cardinality. */
|
|
127
|
+
const MAX_MANAGER_REPLAY_KEYS = 1e4;
|
|
128
|
+
function pruneOldestEntries(keys, maxEntries) {
|
|
129
|
+
while (keys.size > maxEntries) {
|
|
130
|
+
const oldest = keys.keys().next().value;
|
|
131
|
+
if (oldest === void 0) break;
|
|
132
|
+
keys.delete(oldest);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Remember one manager replay key without refreshing duplicate insertion order. */
|
|
136
|
+
function rememberManagerReplayKey(keys, key, maxEntries = MAX_MANAGER_REPLAY_KEYS) {
|
|
137
|
+
keys.add(key);
|
|
138
|
+
pruneOldestEntries(keys, maxEntries);
|
|
139
|
+
}
|
|
140
|
+
/** Append one per-call replay key and retain only the newest bounded suffix. */
|
|
141
|
+
function appendCallReplayKey(keys, key, maxEntries = 500) {
|
|
142
|
+
keys.push(key);
|
|
143
|
+
trimCallReplayKeys(keys, maxEntries);
|
|
144
|
+
}
|
|
145
|
+
/** Normalize restored or externally constructed call replay history in place. */
|
|
146
|
+
function trimCallReplayKeys(keys, maxEntries = 500) {
|
|
147
|
+
const overflow = keys.length - maxEntries;
|
|
148
|
+
if (overflow > 0) keys.splice(0, overflow);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Reserve one rejected provider call. The token prevents a stale failed
|
|
152
|
+
* hangup from deleting a newer reservation after bounded eviction and reuse.
|
|
153
|
+
*/
|
|
154
|
+
function reserveRejectedProviderCall(calls, providerCallId, maxEntries = MAX_MANAGER_REPLAY_KEYS) {
|
|
155
|
+
if (calls.has(providerCallId)) return;
|
|
156
|
+
const reservation = Symbol(providerCallId);
|
|
157
|
+
calls.set(providerCallId, reservation);
|
|
158
|
+
pruneOldestEntries(calls, maxEntries);
|
|
159
|
+
return reservation;
|
|
160
|
+
}
|
|
161
|
+
/** Release a rejected-call reservation only when the failing attempt still owns it. */
|
|
162
|
+
function releaseRejectedProviderCall(calls, providerCallId, reservation) {
|
|
163
|
+
if (calls.get(providerCallId) === reservation) calls.delete(providerCallId);
|
|
164
|
+
}
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region extensions/voice-call/src/manager/store.ts
|
|
167
|
+
const CALL_RECORD_EVENTS_NAMESPACE = "call-record-events";
|
|
168
|
+
const CALL_RECORD_EVENT_CHUNKS_NAMESPACE = "call-record-event-chunks";
|
|
169
|
+
const MAX_CALL_RECORD_EVENTS = 1e3;
|
|
170
|
+
/** Extra metadata entries retained so pruning can safely trim oldest rows. */
|
|
171
|
+
const CALL_RECORD_EVENT_META_MAX_ENTRIES = 1100;
|
|
172
|
+
const MAX_CHUNKS_PER_CALL_RECORD_EVENT = 48;
|
|
173
|
+
const CALL_RECORD_CHUNK_MAX_ENTRIES = 48048;
|
|
174
|
+
/** Raw UTF-8 bytes stored per call record chunk before base64 encoding. */
|
|
175
|
+
const RAW_CALL_RECORD_CHUNK_BYTES = 48128;
|
|
176
|
+
const CALL_RECORD_READ_BATCH_KEYS = 128;
|
|
177
|
+
let callRecordEventSequence = 0;
|
|
178
|
+
function createCallRecordStateStores(storePath, stateRuntime) {
|
|
179
|
+
const state = stateRuntime ?? getOptionalVoiceCallStateRuntime()?.state;
|
|
180
|
+
if (!state) throw new Error("Voice Call state runtime not initialized");
|
|
181
|
+
const env = {
|
|
182
|
+
...process.env,
|
|
183
|
+
OPENCLAW_STATE_DIR: storePath
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
events: state.openKeyedStore({
|
|
187
|
+
namespace: CALL_RECORD_EVENTS_NAMESPACE,
|
|
188
|
+
maxEntries: CALL_RECORD_EVENT_META_MAX_ENTRIES,
|
|
189
|
+
env
|
|
190
|
+
}),
|
|
191
|
+
chunks: state.openKeyedStore({
|
|
192
|
+
namespace: CALL_RECORD_EVENT_CHUNKS_NAMESPACE,
|
|
193
|
+
maxEntries: CALL_RECORD_CHUNK_MAX_ENTRIES,
|
|
194
|
+
env
|
|
195
|
+
})
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/** Open call stores and log failures instead of breaking restore paths. */
|
|
199
|
+
function tryCreateCallRecordStateStores(storePath, stateRuntime) {
|
|
200
|
+
try {
|
|
201
|
+
return createCallRecordStateStores(storePath, stateRuntime);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error("[voice-call] Failed to open SQLite call record store:", err);
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function buildChunkKey(eventKey, index) {
|
|
208
|
+
return `${eventKey}:chunk:${String(index).padStart(4, "0")}`;
|
|
209
|
+
}
|
|
210
|
+
/** Allocate monotonic ordering metadata for newly persisted call records. */
|
|
211
|
+
function nextCallRecordOrder() {
|
|
212
|
+
const sequence = callRecordEventSequence;
|
|
213
|
+
callRecordEventSequence = (callRecordEventSequence + 1) % 1e6;
|
|
214
|
+
return {
|
|
215
|
+
persistedAt: Date.now(),
|
|
216
|
+
sequence
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function parseEventKeySequence(key) {
|
|
220
|
+
const sequence = /^event:[^:]+:(\d+):/.exec(key)?.[1];
|
|
221
|
+
return sequence ? Number.parseInt(sequence, 10) : 0;
|
|
222
|
+
}
|
|
223
|
+
function countCallRecordChunks(call) {
|
|
224
|
+
return Math.max(1, Math.ceil(Buffer.byteLength(JSON.stringify(call), "utf8") / RAW_CALL_RECORD_CHUNK_BYTES));
|
|
225
|
+
}
|
|
226
|
+
/** Truncate oversized call records to fit the bounded plugin state chunk budget. */
|
|
227
|
+
function prepareVoiceCallRecordForStorage(call) {
|
|
228
|
+
let boundedCall = call;
|
|
229
|
+
if (call.processedEventIds.length > 500) {
|
|
230
|
+
boundedCall = {
|
|
231
|
+
...call,
|
|
232
|
+
processedEventIds: [...call.processedEventIds]
|
|
233
|
+
};
|
|
234
|
+
trimCallReplayKeys(boundedCall.processedEventIds);
|
|
235
|
+
}
|
|
236
|
+
if (countCallRecordChunks(boundedCall) <= MAX_CHUNKS_PER_CALL_RECORD_EVENT) return boundedCall;
|
|
237
|
+
const transcriptEntries = boundedCall.transcript.length;
|
|
238
|
+
const metadata = {
|
|
239
|
+
...boundedCall.metadata,
|
|
240
|
+
voiceCallPersistence: {
|
|
241
|
+
transcriptTruncated: true,
|
|
242
|
+
originalTranscriptEntries: transcriptEntries
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const candidateInputs = [
|
|
246
|
+
{
|
|
247
|
+
transcript: call.transcript.slice(-20),
|
|
248
|
+
metadata
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
transcript: [],
|
|
252
|
+
metadata
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
transcript: [],
|
|
256
|
+
metadata: { voiceCallPersistence: {
|
|
257
|
+
transcriptTruncated: true,
|
|
258
|
+
originalTranscriptEntries: transcriptEntries,
|
|
259
|
+
metadataTruncated: true
|
|
260
|
+
} }
|
|
261
|
+
}
|
|
262
|
+
];
|
|
263
|
+
for (const candidateInput of candidateInputs) {
|
|
264
|
+
const candidate = CallRecordSchema.parse({
|
|
265
|
+
...boundedCall,
|
|
266
|
+
...candidateInput
|
|
267
|
+
});
|
|
268
|
+
if (countCallRecordChunks(candidate) <= MAX_CHUNKS_PER_CALL_RECORD_EVENT) return candidate;
|
|
269
|
+
}
|
|
270
|
+
return boundedCall;
|
|
271
|
+
}
|
|
272
|
+
/** Encode one bounded record; chunks are produced only when requested by the writer. */
|
|
273
|
+
function encodeCallRecordEvent(call) {
|
|
274
|
+
const serialized = JSON.stringify(prepareVoiceCallRecordForStorage(call));
|
|
275
|
+
const buffer = Buffer.from(serialized, "utf8");
|
|
276
|
+
const chunkCount = Math.max(1, Math.ceil(buffer.byteLength / RAW_CALL_RECORD_CHUNK_BYTES));
|
|
277
|
+
if (chunkCount > MAX_CHUNKS_PER_CALL_RECORD_EVENT) throw new Error(`voice-call record exceeds SQLite chunk limit (${chunkCount}/${MAX_CHUNKS_PER_CALL_RECORD_EVENT})`);
|
|
278
|
+
return {
|
|
279
|
+
meta: {
|
|
280
|
+
chunkCount,
|
|
281
|
+
byteLength: buffer.byteLength
|
|
282
|
+
},
|
|
283
|
+
chunk(index) {
|
|
284
|
+
return {
|
|
285
|
+
index,
|
|
286
|
+
dataBase64: buffer.subarray(index * RAW_CALL_RECORD_CHUNK_BYTES, (index + 1) * RAW_CALL_RECORD_CHUNK_BYTES).toString("base64")
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
async function pruneCallRecordEvents(stores) {
|
|
292
|
+
if (stores.events.count && await stores.events.count() <= 1e3) return;
|
|
293
|
+
const rows = await stores.events.entries();
|
|
294
|
+
if (rows.length <= 1e3) return;
|
|
295
|
+
const sorted = rows.toSorted((a, b) => a.createdAt - b.createdAt || a.key.localeCompare(b.key));
|
|
296
|
+
for (const row of sorted.slice(0, rows.length - MAX_CALL_RECORD_EVENTS)) {
|
|
297
|
+
const meta = await stores.events.lookup(row.key);
|
|
298
|
+
await stores.events.delete(row.key);
|
|
299
|
+
if (meta) for (let index = 0; index < meta.chunkCount; index += 1) await stores.chunks.delete(buildChunkKey(row.key, index));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function isValidCallRecordChunkCount(chunkCount) {
|
|
303
|
+
return Number.isSafeInteger(chunkCount) && chunkCount >= 1 && chunkCount <= MAX_CHUNKS_PER_CALL_RECORD_EVENT;
|
|
304
|
+
}
|
|
305
|
+
async function readCallRecordEvent(stores, eventKey, meta, records) {
|
|
306
|
+
if (!isValidCallRecordChunkCount(meta.chunkCount)) return null;
|
|
307
|
+
const chunks = [];
|
|
308
|
+
for (let index = 0; index < meta.chunkCount; index += 1) {
|
|
309
|
+
const result = records?.[index];
|
|
310
|
+
if (result && !result.ok) throw result.error;
|
|
311
|
+
const chunk = records ? result?.value : await stores.chunks.lookup(buildChunkKey(eventKey, index));
|
|
312
|
+
if (!chunk || chunk.index !== index) return null;
|
|
313
|
+
chunks.push(Buffer.from(chunk.dataBase64, "base64"));
|
|
314
|
+
}
|
|
315
|
+
const serialized = Buffer.concat(chunks, meta.byteLength).toString("utf8");
|
|
316
|
+
try {
|
|
317
|
+
return CallRecordSchema.parse(JSON.parse(serialized));
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** Read all persisted call records in stable persisted order. */
|
|
323
|
+
async function readCallRecordEvents(stores) {
|
|
324
|
+
const entries = (await stores.events.entries()).toSorted((a, b) => a.createdAt - b.createdAt || a.key.localeCompare(b.key));
|
|
325
|
+
const sqliteCalls = [];
|
|
326
|
+
let batchEnd = 0;
|
|
327
|
+
let chunkOffset = 0;
|
|
328
|
+
let chunkRecords;
|
|
329
|
+
for (const [entryIndex, entry] of entries.entries()) {
|
|
330
|
+
if (entryIndex >= batchEnd && stores.chunks.lookupMany) {
|
|
331
|
+
const keys = [];
|
|
332
|
+
for (let next = entryIndex;; next++) {
|
|
333
|
+
const row = entries[next];
|
|
334
|
+
if (!row) break;
|
|
335
|
+
const chunkCount = row.value?.chunkCount;
|
|
336
|
+
if (!isValidCallRecordChunkCount(chunkCount) || keys.length + chunkCount > CALL_RECORD_READ_BATCH_KEYS) break;
|
|
337
|
+
for (let index = 0; index < chunkCount; index++) keys.push(buildChunkKey(row.key, index));
|
|
338
|
+
batchEnd = next + 1;
|
|
339
|
+
}
|
|
340
|
+
chunkRecords = keys.length > 0 ? await stores.chunks.lookupMany(keys) : void 0;
|
|
341
|
+
chunkOffset = 0;
|
|
342
|
+
}
|
|
343
|
+
const records = chunkRecords?.slice(chunkOffset, chunkOffset + entry.value.chunkCount);
|
|
344
|
+
const call = await readCallRecordEvent(stores, entry.key, entry.value, records);
|
|
345
|
+
if (chunkRecords) chunkOffset += entry.value.chunkCount;
|
|
346
|
+
if (call) sqliteCalls.push({
|
|
347
|
+
call,
|
|
348
|
+
persistedAt: entry.value.persistedAt ?? entry.createdAt,
|
|
349
|
+
sequence: entry.value.sequence ?? parseEventKeySequence(entry.key),
|
|
350
|
+
orderKey: entry.key
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return sqliteCalls.toSorted((a, b) => a.persistedAt - b.persistedAt || a.sequence - b.sequence || a.orderKey.localeCompare(b.orderKey)).map((entry) => entry.call);
|
|
354
|
+
}
|
|
355
|
+
async function persistCallRecord(storePath, call, stateRuntime) {
|
|
356
|
+
try {
|
|
357
|
+
const stores = createCallRecordStateStores(storePath, stateRuntime);
|
|
358
|
+
const order = nextCallRecordOrder();
|
|
359
|
+
const eventKey = `event:${order.persistedAt.toString(36)}:${String(order.sequence).padStart(6, "0")}:${randomUUID()}`;
|
|
360
|
+
const encoded = encodeCallRecordEvent(call);
|
|
361
|
+
for (let index = 0; index < encoded.meta.chunkCount; index += 1) await stores.chunks.register(buildChunkKey(eventKey, index), encoded.chunk(index));
|
|
362
|
+
await stores.events.register(eventKey, {
|
|
363
|
+
...encoded.meta,
|
|
364
|
+
...order
|
|
365
|
+
});
|
|
366
|
+
await pruneCallRecordEvents(stores);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
console.error("[voice-call] Failed to persist call record:", err);
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** Restore nonterminal active calls and event indexes from persisted records. */
|
|
373
|
+
async function loadActiveCallsFromStore(storePath, stateRuntime) {
|
|
374
|
+
const stores = tryCreateCallRecordStateStores(storePath, stateRuntime);
|
|
375
|
+
let calls = [];
|
|
376
|
+
try {
|
|
377
|
+
calls = stores ? await readCallRecordEvents(stores) : [];
|
|
378
|
+
} catch (err) {
|
|
379
|
+
console.error("[voice-call] Failed to read SQLite call records:", err);
|
|
380
|
+
}
|
|
381
|
+
const callMap = /* @__PURE__ */ new Map();
|
|
382
|
+
for (const call of calls) {
|
|
383
|
+
callMap.delete(call.callId);
|
|
384
|
+
callMap.set(call.callId, call);
|
|
385
|
+
}
|
|
386
|
+
const activeCalls = /* @__PURE__ */ new Map();
|
|
387
|
+
const processedEventIds = /* @__PURE__ */ new Set();
|
|
388
|
+
for (const [callId, call] of callMap) {
|
|
389
|
+
trimCallReplayKeys(call.processedEventIds);
|
|
390
|
+
for (const eventId of call.processedEventIds) rememberManagerReplayKey(processedEventIds, eventId);
|
|
391
|
+
if (TerminalStates.has(call.state)) continue;
|
|
392
|
+
activeCalls.set(callId, call);
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
activeCalls,
|
|
396
|
+
processedEventIds
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/** Resolve an internal ID or retained provider alias to its newest logical call snapshot. */
|
|
400
|
+
async function findCallInStore(storePath, callId, stateRuntime) {
|
|
401
|
+
const calls = await readCallRecordEvents(createCallRecordStateStores(storePath, stateRuntime));
|
|
402
|
+
const match = calls.findLast((call) => call.callId === callId) ?? calls.findLast((call) => call.providerCallId === callId);
|
|
403
|
+
return match ? calls.findLast((call) => call.callId === match.callId) : void 0;
|
|
404
|
+
}
|
|
405
|
+
async function getCallHistoryFromStore(storePath, limit = 50, stateRuntime) {
|
|
406
|
+
if (limit <= 0) return [];
|
|
407
|
+
const stores = tryCreateCallRecordStateStores(storePath, stateRuntime);
|
|
408
|
+
if (stores) try {
|
|
409
|
+
return (await readCallRecordEvents(stores)).slice(-limit);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
console.error("[voice-call] Failed to read SQLite call history:", err);
|
|
412
|
+
}
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
//#endregion
|
|
71
416
|
//#region extensions/voice-call/src/manager/timer-delays.ts
|
|
72
417
|
/** Convert seconds to a safe timeout delay in milliseconds. */
|
|
73
418
|
function resolveVoiceCallSecondsTimerDelayMs(seconds, minMs = 1) {
|
|
@@ -151,17 +496,11 @@ function waitForFinalTranscript(ctx, callId, turnToken) {
|
|
|
151
496
|
//#endregion
|
|
152
497
|
//#region extensions/voice-call/src/manager/lifecycle.ts
|
|
153
498
|
const log$1 = createSubsystemLogger("voice-call/lifecycle");
|
|
154
|
-
/** Remove a provider-call mapping only when it still points at this call. */
|
|
155
|
-
function removeProviderCallMapping(providerCallIdMap, call) {
|
|
156
|
-
if (!call.providerCallId) return;
|
|
157
|
-
if (providerCallIdMap.get(call.providerCallId) === call.callId) providerCallIdMap.delete(call.providerCallId);
|
|
158
|
-
}
|
|
159
499
|
/** Finalize under the manager mutation queue, publishing cleanup only after persistence. */
|
|
160
500
|
async function finalizeCall(params) {
|
|
161
501
|
const { ctx, call, endReason } = params;
|
|
162
502
|
if (ctx.activeCalls.get(call.callId) !== call) return;
|
|
163
|
-
|
|
164
|
-
if (!TerminalStates.has(previousState)) {
|
|
503
|
+
if (!TerminalStates.has(call.state)) {
|
|
165
504
|
const next = copyCallRecord(params.preparedCall ?? call);
|
|
166
505
|
next.endedAt = params.endedAt ?? Date.now();
|
|
167
506
|
next.endReason = endReason;
|
|
@@ -178,7 +517,7 @@ async function finalizeCall(params) {
|
|
|
178
517
|
}
|
|
179
518
|
if (ctx.transcriptWaiters) rejectTranscriptWaiter({ transcriptWaiters: ctx.transcriptWaiters }, call.callId, params.transcriptRejectReason ?? `Call ended: ${endReason}`);
|
|
180
519
|
ctx.activeCalls.delete(call.callId);
|
|
181
|
-
|
|
520
|
+
if (call.providerCallId && ctx.providerCallIdMap.get(call.providerCallId) === call.callId) ctx.providerCallIdMap.delete(call.providerCallId);
|
|
182
521
|
}
|
|
183
522
|
//#endregion
|
|
184
523
|
//#region extensions/voice-call/src/manager/lookup.ts
|
|
@@ -200,52 +539,11 @@ function findCall(params) {
|
|
|
200
539
|
}
|
|
201
540
|
//#endregion
|
|
202
541
|
//#region extensions/voice-call/src/tts-provider-voice.ts
|
|
203
|
-
/** Read voice setting aliases from one provider-specific config block. */
|
|
204
|
-
function resolveProviderVoiceSetting(providerConfig) {
|
|
205
|
-
if (!providerConfig || typeof providerConfig !== "object") return;
|
|
206
|
-
const candidate = providerConfig;
|
|
207
|
-
return normalizeOptionalString(candidate.speakerVoice) ?? normalizeOptionalString(candidate.speakerVoiceId) ?? normalizeOptionalString(candidate.voice) ?? normalizeOptionalString(candidate.voiceId);
|
|
208
|
-
}
|
|
209
542
|
/** Resolve the active provider's preferred voice id/name from voice-call TTS config. */
|
|
210
543
|
function resolvePreferredTtsVoice(config) {
|
|
211
544
|
const providerId = config.tts?.provider;
|
|
212
|
-
|
|
213
|
-
return
|
|
214
|
-
}
|
|
215
|
-
//#endregion
|
|
216
|
-
//#region extensions/voice-call/src/voice-mapping.ts
|
|
217
|
-
/**
|
|
218
|
-
* Escape XML special characters for TwiML and other XML responses.
|
|
219
|
-
*/
|
|
220
|
-
function escapeXml(text) {
|
|
221
|
-
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
222
|
-
}
|
|
223
|
-
/**
|
|
224
|
-
* Map of OpenAI voice names to similar Twilio Polly voices.
|
|
225
|
-
*/
|
|
226
|
-
const OPENAI_TO_POLLY_MAP = /* @__PURE__ */ new Map([
|
|
227
|
-
["alloy", "Polly.Joanna"],
|
|
228
|
-
["echo", "Polly.Matthew"],
|
|
229
|
-
["fable", "Polly.Amy"],
|
|
230
|
-
["onyx", "Polly.Brian"],
|
|
231
|
-
["nova", "Polly.Salli"],
|
|
232
|
-
["shimmer", "Polly.Kimberly"]
|
|
233
|
-
]);
|
|
234
|
-
/**
|
|
235
|
-
* Default Polly voice when no mapping is found.
|
|
236
|
-
*/
|
|
237
|
-
const DEFAULT_POLLY_VOICE = "Polly.Joanna";
|
|
238
|
-
/**
|
|
239
|
-
* Map OpenAI voice names to Twilio Polly equivalents.
|
|
240
|
-
* Falls through if already a valid Polly/Google voice.
|
|
241
|
-
*
|
|
242
|
-
* @param voice - OpenAI voice name (alloy, echo, etc.) or Polly voice name
|
|
243
|
-
* @returns Polly voice name suitable for Twilio TwiML
|
|
244
|
-
*/
|
|
245
|
-
function mapVoiceToPolly(voice) {
|
|
246
|
-
if (!voice) return DEFAULT_POLLY_VOICE;
|
|
247
|
-
if (voice.startsWith("Polly.") || voice.startsWith("Google.")) return voice;
|
|
248
|
-
return OPENAI_TO_POLLY_MAP.get(normalizeLowercaseStringOrEmpty(voice)) ?? DEFAULT_POLLY_VOICE;
|
|
545
|
+
const candidate = providerId ? config.tts?.providers?.[providerId] : void 0;
|
|
546
|
+
return normalizeOptionalString(candidate?.speakerVoice) ?? normalizeOptionalString(candidate?.speakerVoiceId) ?? normalizeOptionalString(candidate?.voice) ?? normalizeOptionalString(candidate?.voiceId);
|
|
249
547
|
}
|
|
250
548
|
//#endregion
|
|
251
549
|
//#region extensions/voice-call/src/manager/mutations.ts
|
|
@@ -314,8 +612,7 @@ function isCurrentCall(ctx, call) {
|
|
|
314
612
|
function validateDtmfDigits(digits) {
|
|
315
613
|
return /^[0-9*#wWpP,]+$/.test(digits) ? null : "digits may only contain digits, *, #, comma, w, p";
|
|
316
614
|
}
|
|
317
|
-
async function initiateCall(ctx, to, sessionKey,
|
|
318
|
-
const opts = typeof options === "string" ? { message: options } : options ?? {};
|
|
615
|
+
async function initiateCall(ctx, to, sessionKey, opts = {}) {
|
|
319
616
|
const initialMessage = opts.message;
|
|
320
617
|
const mode = opts.mode ?? ctx.config.outbound.defaultMode;
|
|
321
618
|
const dtmfSequence = opts.dtmfSequence;
|
|
@@ -350,7 +647,7 @@ async function initiateCall(ctx, to, sessionKey, options) {
|
|
|
350
647
|
error: `Maximum concurrent calls (${ctx.config.maxConcurrentCalls}) reached`
|
|
351
648
|
};
|
|
352
649
|
const callId = crypto.randomUUID();
|
|
353
|
-
const from = ctx.config.fromNumber || (ctx.provider
|
|
650
|
+
const from = ctx.config.fromNumber || (ctx.provider.name === "mock" ? "+15550000000" : void 0);
|
|
354
651
|
if (!from) return {
|
|
355
652
|
callId: "",
|
|
356
653
|
success: false,
|
|
@@ -483,13 +780,12 @@ async function speak(ctx, callId, text, options) {
|
|
|
483
780
|
});
|
|
484
781
|
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
|
485
782
|
const voice = resolvePreferredTtsVoice(resolveVoiceCallEffectiveConfig(ctx.config, numberRouteKey).config);
|
|
486
|
-
const playbackOptions = options?.listenAfterPlayback ? { listenAfterPlayback: true } : {};
|
|
487
783
|
await provider.playTts({
|
|
488
784
|
callId,
|
|
489
785
|
providerCallId: call.providerCallId ?? providerCallId,
|
|
490
786
|
text,
|
|
491
787
|
voice,
|
|
492
|
-
...
|
|
788
|
+
...options?.listenAfterPlayback ? { listenAfterPlayback: true } : {}
|
|
493
789
|
});
|
|
494
790
|
if (!await updateCall(ctx, call, (next) => addTranscriptEntry(next, "bot", text))) return {
|
|
495
791
|
success: false,
|
|
@@ -506,10 +802,8 @@ async function speak(ctx, callId, text, options) {
|
|
|
506
802
|
};
|
|
507
803
|
}
|
|
508
804
|
}
|
|
509
|
-
function
|
|
510
|
-
|
|
511
|
-
if (!ctx.config.streaming.enabled) return true;
|
|
512
|
-
return ctx.provider.isConversationStreamConnectEnabled?.() !== true;
|
|
805
|
+
function hasConversationStreamConnect(provider, config) {
|
|
806
|
+
return provider?.name === "twilio" && config.streaming.enabled && provider.isConversationStreamConnectEnabled?.() === true;
|
|
513
807
|
}
|
|
514
808
|
async function sendDtmf(ctx, callId, digits) {
|
|
515
809
|
const validationError = validateDtmfDigits(digits);
|
|
@@ -595,7 +889,7 @@ async function speakInitialMessage(ctx, providerCallId) {
|
|
|
595
889
|
ctx.trackCallWork(work);
|
|
596
890
|
}, delayMs);
|
|
597
891
|
ctx.notifyHangupTimers.set(call.callId, timer);
|
|
598
|
-
} else if (mode === "conversation" && ctx.provider &&
|
|
892
|
+
} else if (mode === "conversation" && ctx.provider && !hasConversationStreamConnect(ctx.provider, ctx.config)) {
|
|
599
893
|
if (!await updateCall(ctx, call, (next) => transitionState(next, "listening")) || !isCurrentCall(ctx, call)) return;
|
|
600
894
|
if (ctx.isStopping()) throw new Error("Voice Call manager is stopping");
|
|
601
895
|
await ctx.provider.startListening({
|
|
@@ -661,7 +955,7 @@ async function continueCall(ctx, callId, prompt) {
|
|
|
661
955
|
success: false,
|
|
662
956
|
error: "Call has ended"
|
|
663
957
|
};
|
|
664
|
-
console.log(
|
|
958
|
+
console.log(`[voice-call] continueCall latency call=${call.callId} totalMs=${lastTurnLatencyMs} listenWaitMs=${lastTurnListenWaitMs}`);
|
|
665
959
|
return {
|
|
666
960
|
success: true,
|
|
667
961
|
transcript
|
|
@@ -742,8 +1036,7 @@ function shouldAcceptInbound(config, from) {
|
|
|
742
1036
|
}
|
|
743
1037
|
async function createWebhookCall(params) {
|
|
744
1038
|
const callId = crypto.randomUUID();
|
|
745
|
-
const
|
|
746
|
-
const effectiveConfig = effective.config;
|
|
1039
|
+
const { config: effectiveConfig, numberRouteKey } = resolveVoiceCallEffectiveConfig(params.ctx.config, params.direction === "inbound" ? params.to : void 0);
|
|
747
1040
|
const callRecord = {
|
|
748
1041
|
callId,
|
|
749
1042
|
providerCallId: params.providerCallId,
|
|
@@ -764,7 +1057,7 @@ async function createWebhookCall(params) {
|
|
|
764
1057
|
processedEventIds: [],
|
|
765
1058
|
metadata: {
|
|
766
1059
|
initialMessage: params.direction === "inbound" ? effectiveConfig.inboundGreeting || "Hello! How can I help you today?" : void 0,
|
|
767
|
-
...
|
|
1060
|
+
...numberRouteKey ? { numberRouteKey } : {}
|
|
768
1061
|
}
|
|
769
1062
|
};
|
|
770
1063
|
await persistCallRecord(params.ctx.storePath, callRecord, params.ctx.stateRuntime);
|
|
@@ -820,9 +1113,8 @@ async function processEventInQueue(ctx, event) {
|
|
|
820
1113
|
if (call && providerCallId && providerCallId !== call.providerCallId) {
|
|
821
1114
|
if ((providerCallId === event.callId && retained ? retained : await findCallInStore(ctx.storePath, providerCallId, ctx.stateRuntime))?.callId === call.callId) providerCallId = call.providerCallId;
|
|
822
1115
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
if (eventDirection === "inbound" && !shouldAcceptInbound(ctx.config, event.from)) {
|
|
1116
|
+
if (!call && providerCallId && event.direction) {
|
|
1117
|
+
if (event.direction === "inbound" && !shouldAcceptInbound(ctx.config, event.from)) {
|
|
826
1118
|
const pid = providerCallId;
|
|
827
1119
|
if (!ctx.provider) {
|
|
828
1120
|
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.`);
|
|
@@ -855,7 +1147,7 @@ async function processEventInQueue(ctx, event) {
|
|
|
855
1147
|
call = await createWebhookCall({
|
|
856
1148
|
ctx,
|
|
857
1149
|
providerCallId,
|
|
858
|
-
direction:
|
|
1150
|
+
direction: event.direction,
|
|
859
1151
|
from: event.from || "unknown",
|
|
860
1152
|
to: event.to || ctx.config.fromNumber || "unknown"
|
|
861
1153
|
});
|
|
@@ -866,7 +1158,7 @@ async function processEventInQueue(ctx, event) {
|
|
|
866
1158
|
replayable: true
|
|
867
1159
|
};
|
|
868
1160
|
const activeCall = copyCallRecord(call);
|
|
869
|
-
const
|
|
1161
|
+
const previousProviderCallId = call.providerCallId;
|
|
870
1162
|
const shouldCommitReplayKey = !(event.type === "call.error" && event.retryable);
|
|
871
1163
|
const effects = [];
|
|
872
1164
|
let result = { kind: "processed" };
|
|
@@ -884,10 +1176,10 @@ async function processEventInQueue(ctx, event) {
|
|
|
884
1176
|
}
|
|
885
1177
|
};
|
|
886
1178
|
const publishProviderCallId = (terminal = false) => {
|
|
887
|
-
if (!providerCallId || providerCallId ===
|
|
1179
|
+
if (!providerCallId || providerCallId === previousProviderCallId) return;
|
|
888
1180
|
if (!terminal) ctx.providerCallIdMap.set(providerCallId, activeCall.callId);
|
|
889
|
-
if (
|
|
890
|
-
if (ctx.providerCallIdMap.get(
|
|
1181
|
+
if (previousProviderCallId) {
|
|
1182
|
+
if (ctx.providerCallIdMap.get(previousProviderCallId) === activeCall.callId) ctx.providerCallIdMap.delete(previousProviderCallId);
|
|
891
1183
|
}
|
|
892
1184
|
};
|
|
893
1185
|
if (providerCallId && providerCallId !== activeCall.providerCallId) activeCall.providerCallId = providerCallId;
|
|
@@ -963,35 +1255,26 @@ async function processEventInQueue(ctx, event) {
|
|
|
963
1255
|
break;
|
|
964
1256
|
case "call.silence":
|
|
965
1257
|
case "call.dtmf": break;
|
|
1258
|
+
case "call.error":
|
|
966
1259
|
case "call.ended":
|
|
1260
|
+
if (event.type === "call.error" && event.retryable) {
|
|
1261
|
+
result = {
|
|
1262
|
+
kind: "processed",
|
|
1263
|
+
replayable: true
|
|
1264
|
+
};
|
|
1265
|
+
break;
|
|
1266
|
+
}
|
|
967
1267
|
await finalizeCall({
|
|
968
1268
|
ctx,
|
|
969
1269
|
call,
|
|
970
1270
|
preparedCall: activeCall,
|
|
971
|
-
endReason: event.reason,
|
|
972
|
-
endedAt: event.timestamp
|
|
1271
|
+
endReason: event.type === "call.ended" ? event.reason : "error",
|
|
1272
|
+
endedAt: event.timestamp,
|
|
1273
|
+
transcriptRejectReason: event.type === "call.error" ? `Call error: ${event.error}` : void 0
|
|
973
1274
|
});
|
|
974
1275
|
publishProviderCallId(true);
|
|
975
1276
|
rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
|
|
976
1277
|
return { kind: "processed" };
|
|
977
|
-
case "call.error":
|
|
978
|
-
if (!event.retryable) {
|
|
979
|
-
await finalizeCall({
|
|
980
|
-
ctx,
|
|
981
|
-
call,
|
|
982
|
-
preparedCall: activeCall,
|
|
983
|
-
endReason: "error",
|
|
984
|
-
endedAt: event.timestamp,
|
|
985
|
-
transcriptRejectReason: `Call error: ${event.error}`
|
|
986
|
-
});
|
|
987
|
-
publishProviderCallId(true);
|
|
988
|
-
rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
|
|
989
|
-
return { kind: "processed" };
|
|
990
|
-
}
|
|
991
|
-
result = {
|
|
992
|
-
kind: "processed",
|
|
993
|
-
replayable: true
|
|
994
|
-
};
|
|
995
1278
|
}
|
|
996
1279
|
await persistCallRecord(ctx.storePath, activeCall, ctx.stateRuntime);
|
|
997
1280
|
Object.assign(call, activeCall);
|
|
@@ -1001,19 +1284,25 @@ async function processEventInQueue(ctx, event) {
|
|
|
1001
1284
|
return result;
|
|
1002
1285
|
}
|
|
1003
1286
|
//#endregion
|
|
1287
|
+
//#region extensions/voice-call/src/resolve-call-agent-id.ts
|
|
1288
|
+
/** Setup and startup must resolve the same owner before provisioning telephony. */
|
|
1289
|
+
function resolveVoiceCallAgentId(config, coreConfig) {
|
|
1290
|
+
return config.agentId ? normalizeAgentId(config.agentId) : resolveDefaultAgentId(coreConfig, {
|
|
1291
|
+
surface: "Voice Call",
|
|
1292
|
+
hint: "Set plugins.entries.voice-call.config.agentId to a configured agent ID."
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
function resolveCallAgentId(call) {
|
|
1296
|
+
if (!call.agentId?.trim()) throw new Error("Voice Call has no recorded agent owner. Start a new call and hang up any remaining call with your provider; saved history is unchanged.");
|
|
1297
|
+
return normalizeAgentId(call.agentId);
|
|
1298
|
+
}
|
|
1299
|
+
//#endregion
|
|
1004
1300
|
//#region extensions/voice-call/src/manager.ts
|
|
1005
1301
|
function markRestoredCallSkipped(call, endReason) {
|
|
1006
1302
|
call.endedAt = Date.now();
|
|
1007
1303
|
call.endReason = endReason;
|
|
1008
1304
|
call.state = endReason;
|
|
1009
1305
|
}
|
|
1010
|
-
function incrementRestoreStatusCount(counts, status) {
|
|
1011
|
-
const key = normalizeOptionalString(status) ?? "terminal";
|
|
1012
|
-
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1013
|
-
}
|
|
1014
|
-
function resolveRestoredMaxDurationAnchor(call) {
|
|
1015
|
-
return call.answeredAt ?? (call.state === "speaking" || call.state === "listening" ? call.startedAt : void 0);
|
|
1016
|
-
}
|
|
1017
1306
|
function resolveDefaultStoreBase(config, storePath) {
|
|
1018
1307
|
const rawOverride = storePath?.trim() || config.store?.trim();
|
|
1019
1308
|
if (rawOverride) return resolveUserPath(rawOverride);
|
|
@@ -1033,10 +1322,13 @@ var CallManager = class {
|
|
|
1033
1322
|
stop() {
|
|
1034
1323
|
if (this.stopPromise) return this.stopPromise;
|
|
1035
1324
|
this.closing = true;
|
|
1036
|
-
|
|
1037
|
-
for (const
|
|
1038
|
-
|
|
1039
|
-
|
|
1325
|
+
const clearTimers = () => {
|
|
1326
|
+
for (const timers of [this.maxDurationTimers, this.notifyHangupTimers]) {
|
|
1327
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
1328
|
+
timers.clear();
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
clearTimers();
|
|
1040
1332
|
for (const waiter of this.transcriptWaiters.values()) {
|
|
1041
1333
|
clearTimeout(waiter.timeout);
|
|
1042
1334
|
waiter.reject(/* @__PURE__ */ new Error("Voice Call runtime stopped"));
|
|
@@ -1048,10 +1340,7 @@ var CallManager = class {
|
|
|
1048
1340
|
const results = await Promise.allSettled(this.pendingWork);
|
|
1049
1341
|
for (const result of results) if (result.status === "rejected") failures.push(result.reason);
|
|
1050
1342
|
}
|
|
1051
|
-
|
|
1052
|
-
for (const timer of timers.values()) clearTimeout(timer);
|
|
1053
|
-
timers.clear();
|
|
1054
|
-
}
|
|
1343
|
+
clearTimers();
|
|
1055
1344
|
if (failures.length > 0) throw new AggregateError(failures, "Voice Call work failed during shutdown");
|
|
1056
1345
|
})();
|
|
1057
1346
|
return this.stopPromise;
|
|
@@ -1092,10 +1381,6 @@ var CallManager = class {
|
|
|
1092
1381
|
this.storePath = resolveDefaultStoreBase(config, storePath);
|
|
1093
1382
|
this.stateRuntime = stateRuntime;
|
|
1094
1383
|
}
|
|
1095
|
-
/**
|
|
1096
|
-
* Initialize the call manager with a provider.
|
|
1097
|
-
* Verifies persisted calls with the provider and restarts timers.
|
|
1098
|
-
*/
|
|
1099
1384
|
initialize(provider, webhookUrl) {
|
|
1100
1385
|
if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("Voice Call manager is stopping"));
|
|
1101
1386
|
const pending = this.initializeFromStore(provider, webhookUrl);
|
|
@@ -1119,7 +1404,7 @@ var CallManager = class {
|
|
|
1119
1404
|
const timers = [];
|
|
1120
1405
|
let skippedAlreadyElapsedTimers = 0;
|
|
1121
1406
|
for (const [callId, call] of verified) {
|
|
1122
|
-
const maxDurationAnchor =
|
|
1407
|
+
const maxDurationAnchor = call.answeredAt ?? (call.state === "speaking" || call.state === "listening" ? call.startedAt : void 0);
|
|
1123
1408
|
if (maxDurationAnchor !== void 0 && !TerminalStates.has(call.state)) {
|
|
1124
1409
|
const elapsed = Date.now() - maxDurationAnchor;
|
|
1125
1410
|
const maxDurationMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds);
|
|
@@ -1175,6 +1460,12 @@ var CallManager = class {
|
|
|
1175
1460
|
try {
|
|
1176
1461
|
for (const [callId, call] of candidates) {
|
|
1177
1462
|
if (this.closing) break;
|
|
1463
|
+
try {
|
|
1464
|
+
resolveCallAgentId(call);
|
|
1465
|
+
} catch (error) {
|
|
1466
|
+
console.warn(`[voice-call] Skipped restored call ${callId}: ${formatErrorMessage(error)}`);
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1178
1469
|
if (!call.providerCallId) {
|
|
1179
1470
|
skippedNoProviderCallId += 1;
|
|
1180
1471
|
continue;
|
|
@@ -1195,7 +1486,8 @@ var CallManager = class {
|
|
|
1195
1486
|
}
|
|
1196
1487
|
const task = provider.getCallStatus({ providerCallId: call.providerCallId }).then(async (result) => {
|
|
1197
1488
|
if (result.isTerminal) {
|
|
1198
|
-
|
|
1489
|
+
const status = normalizeOptionalString(result.status) ?? "terminal";
|
|
1490
|
+
skippedTerminalStatuses.set(status, (skippedTerminalStatuses.get(status) ?? 0) + 1);
|
|
1199
1491
|
markRestoredCallSkipped(call, "completed");
|
|
1200
1492
|
await persistCallRecord(this.storePath, call, this.stateRuntime);
|
|
1201
1493
|
} else if (result.isUnknown) {
|
|
@@ -1226,9 +1518,6 @@ var CallManager = class {
|
|
|
1226
1518
|
if (keptVerificationFailures > 0) console.log(`[voice-call] Kept ${keptVerificationFailures} restored call(s) after verification failure (relying on timer)`);
|
|
1227
1519
|
return verified;
|
|
1228
1520
|
}
|
|
1229
|
-
getProvider() {
|
|
1230
|
-
return this.provider;
|
|
1231
|
-
}
|
|
1232
1521
|
async initiateCall(to, sessionKey, options) {
|
|
1233
1522
|
return this.runOperation(() => initiateCall(this.getContext(), to, sessionKey, options));
|
|
1234
1523
|
}
|
|
@@ -1238,15 +1527,9 @@ var CallManager = class {
|
|
|
1238
1527
|
async sendDtmf(callId, digits) {
|
|
1239
1528
|
return this.runOperation(() => sendDtmf(this.getContext(), callId, digits));
|
|
1240
1529
|
}
|
|
1241
|
-
/**
|
|
1242
|
-
* Speak the initial message for a call (called when media stream connects).
|
|
1243
|
-
*/
|
|
1244
1530
|
async speakInitialMessage(providerCallId) {
|
|
1245
1531
|
return this.runOperation(() => speakInitialMessage(this.getContext(), providerCallId));
|
|
1246
1532
|
}
|
|
1247
|
-
/**
|
|
1248
|
-
* Continue call: speak prompt, then wait for user's final transcript.
|
|
1249
|
-
*/
|
|
1250
1533
|
async continueCall(callId, prompt) {
|
|
1251
1534
|
return this.runOperation(() => continueCall(this.getContext(), callId, prompt));
|
|
1252
1535
|
}
|
|
@@ -1298,18 +1581,11 @@ var CallManager = class {
|
|
|
1298
1581
|
invalidateAutoResponse(call) {
|
|
1299
1582
|
this.autoResponseOwners.delete(call);
|
|
1300
1583
|
}
|
|
1301
|
-
shouldDeferConversationInitialMessageUntilStreamConnect() {
|
|
1302
|
-
if (!this.provider || this.provider.name !== "twilio" || !this.config.streaming.enabled) return false;
|
|
1303
|
-
const streamAwareProvider = this.provider;
|
|
1304
|
-
if (typeof streamAwareProvider.isConversationStreamConnectEnabled !== "function") return false;
|
|
1305
|
-
return streamAwareProvider.isConversationStreamConnectEnabled();
|
|
1306
|
-
}
|
|
1307
1584
|
maybeSpeakInitialMessageOnAnswered(call) {
|
|
1308
1585
|
if (!(normalizeOptionalString(call.metadata?.initialMessage) ?? "")) return;
|
|
1309
1586
|
const mode = call.metadata?.mode ?? "conversation";
|
|
1310
1587
|
if (mode === "conversation") {
|
|
1311
|
-
if (this.config.realtime.enabled) return;
|
|
1312
|
-
if (this.shouldDeferConversationInitialMessageUntilStreamConnect()) return;
|
|
1588
|
+
if (this.config.realtime.enabled || hasConversationStreamConnect(this.provider, this.config)) return;
|
|
1313
1589
|
} else if (mode !== "notify") return;
|
|
1314
1590
|
if (!this.provider || !call.providerCallId) return;
|
|
1315
1591
|
this.speakInitialMessage(call.providerCallId).catch((err) => {
|
|
@@ -1370,19 +1646,6 @@ async function buildRealtimeVoiceInstructions(params) {
|
|
|
1370
1646
|
return sections.filter(Boolean).join("\n\n");
|
|
1371
1647
|
}
|
|
1372
1648
|
//#endregion
|
|
1373
|
-
//#region extensions/voice-call/src/resolve-call-agent-id.ts
|
|
1374
|
-
/** Setup and startup must resolve the same owner before provisioning telephony. */
|
|
1375
|
-
function resolveVoiceCallAgentId(config, coreConfig) {
|
|
1376
|
-
return config.agentId ? normalizeAgentId(config.agentId) : resolveDefaultAgentId(coreConfig, {
|
|
1377
|
-
surface: "Voice Call",
|
|
1378
|
-
hint: "Set plugins.entries.voice-call.config.agentId to a configured agent ID."
|
|
1379
|
-
});
|
|
1380
|
-
}
|
|
1381
|
-
/** Keep one agent owner for the full call, including legacy stored records. */
|
|
1382
|
-
function resolveCallAgentId(call, config) {
|
|
1383
|
-
return normalizeAgentId(call.agentId ?? config.agentId);
|
|
1384
|
-
}
|
|
1385
|
-
//#endregion
|
|
1386
1649
|
//#region extensions/voice-call/src/response-model.ts
|
|
1387
1650
|
/** Resolve provider/model fields from explicit voice config or agent defaults. */
|
|
1388
1651
|
function resolveVoiceResponseModel(params) {
|
|
@@ -1395,16 +1658,7 @@ function resolveVoiceResponseModel(params) {
|
|
|
1395
1658
|
};
|
|
1396
1659
|
}
|
|
1397
1660
|
//#endregion
|
|
1398
|
-
//#region extensions/voice-call/src/telephony-audio.ts
|
|
1399
|
-
/**
|
|
1400
|
-
* Chunk audio buffer into 20ms frames for streaming (8kHz mono mu-law).
|
|
1401
|
-
*/
|
|
1402
|
-
function* chunkAudio(audio, chunkSize = 160) {
|
|
1403
|
-
for (let i = 0; i < audio.length; i += chunkSize) yield audio.subarray(i, i + chunkSize);
|
|
1404
|
-
}
|
|
1405
|
-
//#endregion
|
|
1406
1661
|
//#region extensions/voice-call/src/telephony-tts.ts
|
|
1407
|
-
/** Default timeout for one telephony synthesis request. */
|
|
1408
1662
|
const TELEPHONY_DEFAULT_TTS_TIMEOUT_MS = 8e3;
|
|
1409
1663
|
var UnsupportedTelephonyTtsOutputFormatError = class extends Error {
|
|
1410
1664
|
constructor(outputFormat, provider) {
|
|
@@ -1485,7 +1739,7 @@ function buildTailscaleExposureArgs(opts) {
|
|
|
1485
1739
|
opts.localUrl ?? "off"
|
|
1486
1740
|
];
|
|
1487
1741
|
}
|
|
1488
|
-
async function runTailscaleCommand(args
|
|
1742
|
+
async function runTailscaleCommand(args) {
|
|
1489
1743
|
try {
|
|
1490
1744
|
const result = await runCommandWithTimeout(["tailscale", ...args], {
|
|
1491
1745
|
killProcessTree: true,
|
|
@@ -1495,7 +1749,7 @@ async function runTailscaleCommand(args, timeoutMs = 2500) {
|
|
|
1495
1749
|
},
|
|
1496
1750
|
outputCapture: "head",
|
|
1497
1751
|
terminateOnOutputLimit: { stdout: true },
|
|
1498
|
-
timeoutMs
|
|
1752
|
+
timeoutMs: 2500
|
|
1499
1753
|
});
|
|
1500
1754
|
if (result.termination !== "exit" || result.outputLimitExceeded) return {
|
|
1501
1755
|
code: -1,
|
|
@@ -1621,20 +1875,7 @@ async function terminateNgrokProcess(proc, isClosed) {
|
|
|
1621
1875
|
if (isClosed()) finish();
|
|
1622
1876
|
});
|
|
1623
1877
|
}
|
|
1624
|
-
|
|
1625
|
-
proc.stdout.on("error", (error) => onError("stdout", error));
|
|
1626
|
-
proc.stderr.on("error", (error) => onError("stderr", error));
|
|
1627
|
-
}
|
|
1628
|
-
/**
|
|
1629
|
-
* Start an ngrok tunnel to expose the local webhook server.
|
|
1630
|
-
*
|
|
1631
|
-
* Uses the ngrok CLI which must be installed: https://ngrok.com/download
|
|
1632
|
-
*
|
|
1633
|
-
* @example
|
|
1634
|
-
* const tunnel = await startNgrokTunnel({ port: 3334, path: '/voice/webhook' });
|
|
1635
|
-
* console.log('Public URL:', tunnel.publicUrl);
|
|
1636
|
-
* // Later: await tunnel.stop();
|
|
1637
|
-
*/
|
|
1878
|
+
/** Start an ngrok CLI tunnel and retain its child until the tunnel is stopped. */
|
|
1638
1879
|
async function startNgrokTunnel(config) {
|
|
1639
1880
|
const args = [
|
|
1640
1881
|
"http",
|
|
@@ -1644,7 +1885,7 @@ async function startNgrokTunnel(config) {
|
|
|
1644
1885
|
"--log-format",
|
|
1645
1886
|
"json"
|
|
1646
1887
|
];
|
|
1647
|
-
if (config.
|
|
1888
|
+
if (config.ngrokDomain) args.push("--domain", config.ngrokDomain);
|
|
1648
1889
|
return new Promise((resolve, reject) => {
|
|
1649
1890
|
const proc = spawn("ngrok", args, {
|
|
1650
1891
|
stdio: [
|
|
@@ -1652,9 +1893,9 @@ async function startNgrokTunnel(config) {
|
|
|
1652
1893
|
"pipe",
|
|
1653
1894
|
"pipe"
|
|
1654
1895
|
],
|
|
1655
|
-
...config.
|
|
1896
|
+
...config.ngrokAuthToken ? { env: {
|
|
1656
1897
|
...process.env,
|
|
1657
|
-
NGROK_AUTHTOKEN: config.
|
|
1898
|
+
NGROK_AUTHTOKEN: config.ngrokAuthToken
|
|
1658
1899
|
} } : {}
|
|
1659
1900
|
});
|
|
1660
1901
|
let startupSettled = false;
|
|
@@ -1712,7 +1953,7 @@ async function startNgrokTunnel(config) {
|
|
|
1712
1953
|
if (combined.includes(NGROK_ERROR_MARKER)) rejectIfPending(`ngrok error: ${formatBoundedChildOutput(combined)}`, true);
|
|
1713
1954
|
stderrTail = sliceUtf16Safe(combined, -8);
|
|
1714
1955
|
});
|
|
1715
|
-
|
|
1956
|
+
for (const stream of ["stdout", "stderr"]) proc[stream].on("error", (error) => {
|
|
1716
1957
|
rejectIfPending(`ngrok ${stream} error: ${error.message}`, true);
|
|
1717
1958
|
});
|
|
1718
1959
|
proc.on("error", (err) => {
|
|
@@ -1728,53 +1969,42 @@ async function startNgrokTunnel(config) {
|
|
|
1728
1969
|
});
|
|
1729
1970
|
});
|
|
1730
1971
|
}
|
|
1731
|
-
async function startTailscaleTunnel(config) {
|
|
1732
|
-
const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
|
|
1733
|
-
const routes = [{
|
|
1734
|
-
publicPath: path,
|
|
1735
|
-
localPath: path
|
|
1736
|
-
}, ...config.streamPaths ?? []].map(({ publicPath, localPath }) => {
|
|
1737
|
-
const normalizedPublicPath = publicPath.startsWith("/") ? publicPath : `/${publicPath}`;
|
|
1738
|
-
const normalizedLocalPath = localPath.startsWith("/") ? localPath : `/${localPath}`;
|
|
1739
|
-
return {
|
|
1740
|
-
path: normalizedPublicPath,
|
|
1741
|
-
localUrl: `http://127.0.0.1:${config.port}${normalizedLocalPath}`
|
|
1742
|
-
};
|
|
1743
|
-
});
|
|
1744
|
-
const publicUrl = await setupTailscaleExposureRoutes({
|
|
1745
|
-
mode: config.mode,
|
|
1746
|
-
port: config.tailscalePort,
|
|
1747
|
-
routes
|
|
1748
|
-
});
|
|
1749
|
-
if (!publicUrl) throw new Error(`Tailscale ${config.mode} failed`);
|
|
1750
|
-
return {
|
|
1751
|
-
publicUrl,
|
|
1752
|
-
provider: `tailscale-${config.mode}`,
|
|
1753
|
-
stop: async () => {
|
|
1754
|
-
for (const route of routes) await cleanupTailscaleExposureRoute({
|
|
1755
|
-
mode: config.mode,
|
|
1756
|
-
port: config.tailscalePort,
|
|
1757
|
-
path: route.path
|
|
1758
|
-
});
|
|
1759
|
-
}
|
|
1760
|
-
};
|
|
1761
|
-
}
|
|
1762
1972
|
async function startTunnel(config) {
|
|
1763
1973
|
switch (config.provider) {
|
|
1764
|
-
case "ngrok": return startNgrokTunnel(
|
|
1765
|
-
port: config.port,
|
|
1766
|
-
path: config.path,
|
|
1767
|
-
authToken: config.ngrokAuthToken,
|
|
1768
|
-
domain: config.ngrokDomain
|
|
1769
|
-
});
|
|
1974
|
+
case "ngrok": return startNgrokTunnel(config);
|
|
1770
1975
|
case "tailscale-serve":
|
|
1771
|
-
case "tailscale-funnel":
|
|
1772
|
-
mode
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1976
|
+
case "tailscale-funnel": {
|
|
1977
|
+
const mode = config.provider === "tailscale-serve" ? "serve" : "funnel";
|
|
1978
|
+
const tailscalePort = config.tailscalePort ?? 443;
|
|
1979
|
+
const routes = [{
|
|
1980
|
+
publicPath: config.path,
|
|
1981
|
+
localPath: config.path
|
|
1982
|
+
}, ...config.streamPaths ?? []].map(({ publicPath, localPath }) => {
|
|
1983
|
+
const normalizedPublicPath = publicPath.startsWith("/") ? publicPath : `/${publicPath}`;
|
|
1984
|
+
const normalizedLocalPath = localPath.startsWith("/") ? localPath : `/${localPath}`;
|
|
1985
|
+
return {
|
|
1986
|
+
path: normalizedPublicPath,
|
|
1987
|
+
localUrl: `http://127.0.0.1:${config.port}${normalizedLocalPath}`
|
|
1988
|
+
};
|
|
1989
|
+
});
|
|
1990
|
+
const publicUrl = await setupTailscaleExposureRoutes({
|
|
1991
|
+
mode,
|
|
1992
|
+
port: tailscalePort,
|
|
1993
|
+
routes
|
|
1994
|
+
});
|
|
1995
|
+
if (!publicUrl) throw new Error(`Tailscale ${mode} failed`);
|
|
1996
|
+
return {
|
|
1997
|
+
publicUrl,
|
|
1998
|
+
provider: `tailscale-${mode}`,
|
|
1999
|
+
stop: async () => {
|
|
2000
|
+
for (const route of routes) await cleanupTailscaleExposureRoute({
|
|
2001
|
+
mode,
|
|
2002
|
+
port: tailscalePort,
|
|
2003
|
+
path: route.path
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
1778
2008
|
default: return null;
|
|
1779
2009
|
}
|
|
1780
2010
|
}
|
|
@@ -1843,10 +2073,6 @@ function canonicalizeVoiceCallMediaBase64(payloadBase64) {
|
|
|
1843
2073
|
return canonicalizeBase64(payloadBase64.replaceAll("-", "+").replaceAll("_", "/"));
|
|
1844
2074
|
}
|
|
1845
2075
|
//#endregion
|
|
1846
|
-
//#region extensions/voice-call/src/websocket.ts
|
|
1847
|
-
const require = createRequire(import.meta.url);
|
|
1848
|
-
const { WebSocket, WebSocketServer } = require(path.join(path.dirname(require.resolve("ws/package.json")), "index.js"));
|
|
1849
|
-
//#endregion
|
|
1850
2076
|
//#region extensions/voice-call/src/media-stream.ts
|
|
1851
2077
|
const DEFAULT_PRE_START_TIMEOUT_MS = 5e3;
|
|
1852
2078
|
const DEFAULT_MAX_PENDING_CONNECTIONS = 32;
|
|
@@ -1863,24 +2089,17 @@ function sanitizeLogText(value, maxChars) {
|
|
|
1863
2089
|
if (sanitized.length <= maxChars) return sanitized;
|
|
1864
2090
|
return `${truncateUtf16Safe(sanitized, maxChars)}...`;
|
|
1865
2091
|
}
|
|
1866
|
-
function normalizeWsMessageData(data) {
|
|
1867
|
-
if (Buffer.isBuffer(data)) return data;
|
|
1868
|
-
if (Array.isArray(data)) return Buffer.concat(data);
|
|
1869
|
-
return Buffer.from(data);
|
|
1870
|
-
}
|
|
1871
2092
|
function parseTwilioMediaMessage(data) {
|
|
1872
|
-
const raw =
|
|
2093
|
+
const raw = rawDataToString(data);
|
|
1873
2094
|
try {
|
|
1874
|
-
return JSON.parse(raw
|
|
2095
|
+
return JSON.parse(raw);
|
|
1875
2096
|
} catch (cause) {
|
|
1876
2097
|
throw new Error("Twilio media stream message was malformed JSON", { cause });
|
|
1877
2098
|
}
|
|
1878
2099
|
}
|
|
1879
|
-
/**
|
|
1880
|
-
* Manages WebSocket connections for Twilio media streams.
|
|
1881
|
-
*/
|
|
1882
2100
|
var MediaStreamHandler = class {
|
|
1883
2101
|
constructor(config) {
|
|
2102
|
+
this.config = config;
|
|
1884
2103
|
this.wss = null;
|
|
1885
2104
|
this.closePromise = null;
|
|
1886
2105
|
this.closing = false;
|
|
@@ -1889,22 +2108,17 @@ var MediaStreamHandler = class {
|
|
|
1889
2108
|
this.pendingByIp = /* @__PURE__ */ new Map();
|
|
1890
2109
|
this.inflightUpgrades = 0;
|
|
1891
2110
|
this.ttsQueues = /* @__PURE__ */ new Map();
|
|
1892
|
-
this.ttsPlaying = /* @__PURE__ */ new Map();
|
|
1893
2111
|
this.ttsActiveControllers = /* @__PURE__ */ new Map();
|
|
1894
2112
|
this.pendingPlaybackMarks = /* @__PURE__ */ new Map();
|
|
1895
2113
|
this.ignoredPlaybackMarks = /* @__PURE__ */ new Map();
|
|
1896
|
-
this.config = config;
|
|
1897
2114
|
this.preStartTimeoutMs = resolveTimerTimeoutMs(config.preStartTimeoutMs, DEFAULT_PRE_START_TIMEOUT_MS);
|
|
1898
2115
|
this.maxPendingConnections = config.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS;
|
|
1899
2116
|
this.maxPendingConnectionsPerIp = config.maxPendingConnectionsPerIp ?? DEFAULT_MAX_PENDING_CONNECTIONS_PER_IP;
|
|
1900
2117
|
this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
1901
2118
|
}
|
|
1902
|
-
/**
|
|
1903
|
-
* Handle WebSocket upgrade for media stream connections.
|
|
1904
|
-
*/
|
|
1905
2119
|
handleUpgrade(request, socket, head) {
|
|
1906
2120
|
if (this.closing) {
|
|
1907
|
-
this.rejectUpgrade(socket,
|
|
2121
|
+
this.rejectUpgrade(socket, "Media stream handler is shutting down");
|
|
1908
2122
|
return;
|
|
1909
2123
|
}
|
|
1910
2124
|
if (!this.wss) {
|
|
@@ -1916,8 +2130,8 @@ var MediaStreamHandler = class {
|
|
|
1916
2130
|
this.handleConnection(ws, req);
|
|
1917
2131
|
});
|
|
1918
2132
|
}
|
|
1919
|
-
if (this.
|
|
1920
|
-
this.rejectUpgrade(socket,
|
|
2133
|
+
if (this.wss.clients.size + this.inflightUpgrades >= this.maxConnections) {
|
|
2134
|
+
this.rejectUpgrade(socket, "Too many media stream connections");
|
|
1921
2135
|
return;
|
|
1922
2136
|
}
|
|
1923
2137
|
this.inflightUpgrades += 1;
|
|
@@ -1936,15 +2150,11 @@ var MediaStreamHandler = class {
|
|
|
1936
2150
|
socket.once("close", handleUpgradeAbort);
|
|
1937
2151
|
try {
|
|
1938
2152
|
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
|
1939
|
-
|
|
1940
|
-
socket.removeListener("close", handleUpgradeAbort);
|
|
1941
|
-
releaseUpgradeReservation();
|
|
2153
|
+
handleUpgradeAbort();
|
|
1942
2154
|
this.wss?.emit("connection", ws, request);
|
|
1943
2155
|
});
|
|
1944
2156
|
} catch (error) {
|
|
1945
|
-
|
|
1946
|
-
socket.removeListener("close", handleUpgradeAbort);
|
|
1947
|
-
releaseUpgradeReservation();
|
|
2157
|
+
handleUpgradeAbort();
|
|
1948
2158
|
throw error;
|
|
1949
2159
|
}
|
|
1950
2160
|
}
|
|
@@ -1965,9 +2175,6 @@ var MediaStreamHandler = class {
|
|
|
1965
2175
|
});
|
|
1966
2176
|
return this.closePromise;
|
|
1967
2177
|
}
|
|
1968
|
-
/**
|
|
1969
|
-
* Handle new WebSocket connection from Twilio.
|
|
1970
|
-
*/
|
|
1971
2178
|
async handleConnection(ws, _request) {
|
|
1972
2179
|
let session = null;
|
|
1973
2180
|
const streamToken = this.getStreamToken(_request);
|
|
@@ -2032,9 +2239,6 @@ var MediaStreamHandler = class {
|
|
|
2032
2239
|
console.error("[MediaStream] WebSocket error:", error);
|
|
2033
2240
|
});
|
|
2034
2241
|
}
|
|
2035
|
-
/**
|
|
2036
|
-
* Handle stream start event.
|
|
2037
|
-
*/
|
|
2038
2242
|
handleStart(ws, message, streamToken) {
|
|
2039
2243
|
const streamSid = message.streamSid || "";
|
|
2040
2244
|
const callSid = message.start?.callSid || "";
|
|
@@ -2172,9 +2376,6 @@ var MediaStreamHandler = class {
|
|
|
2172
2376
|
});
|
|
2173
2377
|
this.config.onTranscriptionReady?.(session.callId, session.streamSid);
|
|
2174
2378
|
}
|
|
2175
|
-
/**
|
|
2176
|
-
* Handle stream stop event.
|
|
2177
|
-
*/
|
|
2178
2379
|
handleStop(session) {
|
|
2179
2380
|
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
|
|
2180
2381
|
this.clearTtsState(session.streamSid);
|
|
@@ -2203,9 +2404,6 @@ var MediaStreamHandler = class {
|
|
|
2203
2404
|
if (resolvedIp) return resolvedIp;
|
|
2204
2405
|
return request.socket.remoteAddress || "unknown";
|
|
2205
2406
|
}
|
|
2206
|
-
getCurrentConnectionCount() {
|
|
2207
|
-
return this.wss ? this.wss.clients.size + this.inflightUpgrades : this.inflightUpgrades;
|
|
2208
|
-
}
|
|
2209
2407
|
registerPendingConnection(ws, ip) {
|
|
2210
2408
|
if (this.pendingConnections.size >= this.maxPendingConnections) {
|
|
2211
2409
|
console.warn("[MediaStream] Rejecting connection: pending connection limit reached");
|
|
@@ -2241,25 +2439,19 @@ var MediaStreamHandler = class {
|
|
|
2241
2439
|
}
|
|
2242
2440
|
this.pendingByIp.set(pending.ip, current - 1);
|
|
2243
2441
|
}
|
|
2244
|
-
rejectUpgrade(socket,
|
|
2245
|
-
const statusText = statusCode === 429 ? "Too Many Requests" : "Service Unavailable";
|
|
2442
|
+
rejectUpgrade(socket, message) {
|
|
2246
2443
|
const body = `${message}\n`;
|
|
2247
|
-
socket.write(`HTTP/1.1
|
|
2444
|
+
socket.write(`HTTP/1.1 503 Service Unavailable\r
|
|
2445
|
+
Connection: close\r
|
|
2248
2446
|
Content-Type: text/plain; charset=utf-8\r
|
|
2249
2447
|
Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
2250
2448
|
` + body);
|
|
2251
2449
|
socket.destroy();
|
|
2252
2450
|
}
|
|
2253
|
-
/**
|
|
2254
|
-
* Get an active session with an open WebSocket, or undefined if unavailable.
|
|
2255
|
-
*/
|
|
2256
2451
|
getOpenSession(streamSid) {
|
|
2257
2452
|
const session = this.sessions.get(streamSid);
|
|
2258
2453
|
return session?.ws.readyState === WebSocket.OPEN ? session : void 0;
|
|
2259
2454
|
}
|
|
2260
|
-
/**
|
|
2261
|
-
* Send a message to a stream's WebSocket if available.
|
|
2262
|
-
*/
|
|
2263
2455
|
sendToStream(streamSid, message) {
|
|
2264
2456
|
const session = this.getOpenSession(streamSid);
|
|
2265
2457
|
if (!session) return false;
|
|
@@ -2299,9 +2491,6 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2299
2491
|
media: { payload: muLawAudio.toString("base64") }
|
|
2300
2492
|
});
|
|
2301
2493
|
}
|
|
2302
|
-
/**
|
|
2303
|
-
* Send a mark event to track audio playback position.
|
|
2304
|
-
*/
|
|
2305
2494
|
sendMark(streamSid, name) {
|
|
2306
2495
|
return this.sendToStream(streamSid, {
|
|
2307
2496
|
event: "mark",
|
|
@@ -2312,7 +2501,8 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2312
2501
|
/** Send a completion mark and wait until Twilio reports that buffered playback reached it. */
|
|
2313
2502
|
async sendMarkAndWait(streamSid, name, audioDurationMs, signal) {
|
|
2314
2503
|
signal.throwIfAborted();
|
|
2315
|
-
const marks = this.
|
|
2504
|
+
const marks = this.pendingPlaybackMarks.get(streamSid) ?? /* @__PURE__ */ new Map();
|
|
2505
|
+
this.pendingPlaybackMarks.set(streamSid, marks);
|
|
2316
2506
|
if (marks.has(name)) throw new Error(`Telephony playback mark is already pending: ${name}`);
|
|
2317
2507
|
this.ignoredPlaybackMarks.get(streamSid)?.delete(name);
|
|
2318
2508
|
let pending;
|
|
@@ -2342,9 +2532,6 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2342
2532
|
if (!this.sendMark(streamSid, name)) pending.settle(/* @__PURE__ */ new Error("Telephony stream playback failed: completion mark not delivered"));
|
|
2343
2533
|
return acknowledgement;
|
|
2344
2534
|
}
|
|
2345
|
-
/**
|
|
2346
|
-
* Clear audio buffer (interrupt playback).
|
|
2347
|
-
*/
|
|
2348
2535
|
clearAudio(streamSid) {
|
|
2349
2536
|
this.invalidatePlaybackMarks(streamSid);
|
|
2350
2537
|
return this.sendToStream(streamSid, {
|
|
@@ -2357,30 +2544,21 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2357
2544
|
* Only one TTS operation plays at a time per stream to prevent overlap.
|
|
2358
2545
|
*/
|
|
2359
2546
|
async queueTts(streamSid, playFn) {
|
|
2360
|
-
const queue = this.
|
|
2547
|
+
const queue = this.ttsQueues.get(streamSid) ?? [];
|
|
2548
|
+
this.ttsQueues.set(streamSid, queue);
|
|
2361
2549
|
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}`);
|
|
2362
|
-
|
|
2363
|
-
let rejectEntry;
|
|
2364
|
-
const promise = new Promise((resolve, reject) => {
|
|
2365
|
-
resolveEntry = resolve;
|
|
2366
|
-
rejectEntry = reject;
|
|
2367
|
-
});
|
|
2550
|
+
const { promise, resolve, reject } = createDeferred();
|
|
2368
2551
|
queue.push({
|
|
2369
2552
|
playFn,
|
|
2370
2553
|
controller: new AbortController(),
|
|
2371
|
-
resolve
|
|
2372
|
-
reject
|
|
2554
|
+
resolve,
|
|
2555
|
+
reject
|
|
2373
2556
|
});
|
|
2374
|
-
if (!this.
|
|
2557
|
+
if (!this.ttsActiveControllers.has(streamSid)) this.processQueue(streamSid);
|
|
2375
2558
|
return promise;
|
|
2376
2559
|
}
|
|
2377
|
-
/**
|
|
2378
|
-
* Clear TTS queue and interrupt current playback (barge-in).
|
|
2379
|
-
*/
|
|
2380
2560
|
clearTtsQueue(streamSid, _reason = "unspecified") {
|
|
2381
|
-
|
|
2382
|
-
if (queue) this.resolveQueuedTtsEntries(queue);
|
|
2383
|
-
this.ttsActiveControllers.get(streamSid)?.abort();
|
|
2561
|
+
this.abortTtsPlayback(streamSid);
|
|
2384
2562
|
const session = this.sessions.get(streamSid);
|
|
2385
2563
|
if (session?.talk.activeTurnId) {
|
|
2386
2564
|
const cancelled = session.talk.cancelTurn({ payload: {
|
|
@@ -2392,20 +2570,6 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2392
2570
|
}
|
|
2393
2571
|
this.clearAudio(streamSid);
|
|
2394
2572
|
}
|
|
2395
|
-
getTtsQueue(streamSid) {
|
|
2396
|
-
const existing = this.ttsQueues.get(streamSid);
|
|
2397
|
-
if (existing) return existing;
|
|
2398
|
-
const queue = [];
|
|
2399
|
-
this.ttsQueues.set(streamSid, queue);
|
|
2400
|
-
return queue;
|
|
2401
|
-
}
|
|
2402
|
-
getPendingPlaybackMarks(streamSid) {
|
|
2403
|
-
const existing = this.pendingPlaybackMarks.get(streamSid);
|
|
2404
|
-
if (existing) return existing;
|
|
2405
|
-
const marks = /* @__PURE__ */ new Map();
|
|
2406
|
-
this.pendingPlaybackMarks.set(streamSid, marks);
|
|
2407
|
-
return marks;
|
|
2408
|
-
}
|
|
2409
2573
|
acknowledgePlaybackMark(streamSid, name) {
|
|
2410
2574
|
const ignored = this.ignoredPlaybackMarks.get(streamSid);
|
|
2411
2575
|
if (ignored?.delete(name)) {
|
|
@@ -2429,16 +2593,10 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2429
2593
|
}
|
|
2430
2594
|
this.ignoredPlaybackMarks.set(streamSid, ignored);
|
|
2431
2595
|
}
|
|
2432
|
-
/**
|
|
2433
|
-
* Process the TTS queue for a stream.
|
|
2434
|
-
* Uses iterative approach to avoid stack accumulation from recursion.
|
|
2435
|
-
*/
|
|
2436
2596
|
async processQueue(streamSid) {
|
|
2437
|
-
this.ttsPlaying.set(streamSid, true);
|
|
2438
2597
|
while (true) {
|
|
2439
2598
|
const queue = this.ttsQueues.get(streamSid);
|
|
2440
2599
|
if (!queue || queue.length === 0) {
|
|
2441
|
-
this.ttsPlaying.delete(streamSid);
|
|
2442
2600
|
this.ttsActiveControllers.delete(streamSid);
|
|
2443
2601
|
this.ttsQueues.delete(streamSid);
|
|
2444
2602
|
return;
|
|
@@ -2518,21 +2676,18 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
|
|
|
2518
2676
|
return turn.turnId;
|
|
2519
2677
|
}
|
|
2520
2678
|
clearTtsState(streamSid) {
|
|
2521
|
-
|
|
2522
|
-
if (queue) this.resolveQueuedTtsEntries(queue);
|
|
2523
|
-
this.ttsActiveControllers.get(streamSid)?.abort();
|
|
2679
|
+
this.abortTtsPlayback(streamSid);
|
|
2524
2680
|
this.ttsActiveControllers.delete(streamSid);
|
|
2525
|
-
this.ttsPlaying.delete(streamSid);
|
|
2526
2681
|
this.ttsQueues.delete(streamSid);
|
|
2527
2682
|
this.invalidatePlaybackMarks(streamSid);
|
|
2528
2683
|
this.ignoredPlaybackMarks.delete(streamSid);
|
|
2529
2684
|
}
|
|
2530
|
-
|
|
2531
|
-
const
|
|
2532
|
-
for (const entry of pending) {
|
|
2685
|
+
abortTtsPlayback(streamSid) {
|
|
2686
|
+
for (const entry of this.ttsQueues.get(streamSid)?.splice(0) ?? []) {
|
|
2533
2687
|
entry.controller.abort();
|
|
2534
2688
|
entry.resolve();
|
|
2535
2689
|
}
|
|
2690
|
+
this.ttsActiveControllers.get(streamSid)?.abort();
|
|
2536
2691
|
}
|
|
2537
2692
|
};
|
|
2538
2693
|
//#endregion
|
|
@@ -2577,33 +2732,38 @@ const CHECK_INTERVAL_MS = 3e4;
|
|
|
2577
2732
|
* can be absent even while the call is actively transcribing. These states
|
|
2578
2733
|
* prove the call is live and should not be reaped. */
|
|
2579
2734
|
const LiveConversationStates = /* @__PURE__ */ new Set(["speaking", "listening"]);
|
|
2580
|
-
/**
|
|
2735
|
+
/** Stop joins provider hangups before the call manager can close. */
|
|
2581
2736
|
function startStaleCallReaper(params) {
|
|
2582
2737
|
const maxAgeSeconds = params.staleCallReaperSeconds;
|
|
2583
2738
|
if (!maxAgeSeconds || maxAgeSeconds <= 0) return null;
|
|
2584
2739
|
const maxAgeMs = maxAgeSeconds * 1e3;
|
|
2585
|
-
const
|
|
2586
|
-
const interval = setInterval(() => {
|
|
2740
|
+
const reap = async () => {
|
|
2587
2741
|
const now = Date.now();
|
|
2742
|
+
const hangups = [];
|
|
2588
2743
|
for (const call of params.manager.getActiveCalls()) {
|
|
2589
2744
|
if (call.answeredAt || TerminalStates.has(call.state) || LiveConversationStates.has(call.state)) continue;
|
|
2590
2745
|
const age = now - call.startedAt;
|
|
2591
|
-
if (age > maxAgeMs
|
|
2592
|
-
callsBeingReaped.add(call.callId);
|
|
2746
|
+
if (age > maxAgeMs) {
|
|
2593
2747
|
console.log(`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1e3)}s, state: ${call.state})`);
|
|
2594
|
-
params.manager.endCall(call.callId).then((result) => {
|
|
2748
|
+
const operation = params.manager.endCall(call.callId).then((result) => {
|
|
2595
2749
|
if (!result.success) console.warn(`[voice-call] Reaper failed to end call ${call.callId}: ${result.error ?? "unknown error"}`);
|
|
2596
2750
|
}).catch((err) => {
|
|
2597
2751
|
console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
|
|
2598
|
-
}).finally(() => {
|
|
2599
|
-
callsBeingReaped.delete(call.callId);
|
|
2600
2752
|
});
|
|
2753
|
+
hangups.push(operation);
|
|
2601
2754
|
}
|
|
2602
2755
|
}
|
|
2603
|
-
|
|
2604
|
-
return () => {
|
|
2605
|
-
clearInterval(interval);
|
|
2756
|
+
await Promise.allSettled(hangups);
|
|
2606
2757
|
};
|
|
2758
|
+
if (params.scheduler.signal.aborted) return null;
|
|
2759
|
+
const scheduler = params.scheduler.scope();
|
|
2760
|
+
scheduler.schedule({
|
|
2761
|
+
id: "stale-call-reaper",
|
|
2762
|
+
delayMs: CHECK_INTERVAL_MS,
|
|
2763
|
+
everyMs: CHECK_INTERVAL_MS,
|
|
2764
|
+
run: reap
|
|
2765
|
+
});
|
|
2766
|
+
return () => scheduler.stop();
|
|
2607
2767
|
}
|
|
2608
2768
|
//#endregion
|
|
2609
2769
|
//#region extensions/voice-call/src/webhook/stream-disconnect-grace.ts
|
|
@@ -2650,7 +2810,7 @@ const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
|
|
|
2650
2810
|
const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
|
|
2651
2811
|
const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
|
|
2652
2812
|
const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.mjs"));
|
|
2653
|
-
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-
|
|
2813
|
+
const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-DAhbXEta.mjs"));
|
|
2654
2814
|
function appendRecentTalkEventMetadata(metadata, event) {
|
|
2655
2815
|
const previous = metadata ?? {};
|
|
2656
2816
|
const recent = Array.isArray(previous.recentTalkEvents) ? previous.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
|
|
@@ -2670,13 +2830,11 @@ function appendRecentTalkEventMetadata(metadata, event) {
|
|
|
2670
2830
|
function buildRequestUrl(requestUrl) {
|
|
2671
2831
|
return new URL$1(requestUrl ?? "/", "http://localhost");
|
|
2672
2832
|
}
|
|
2673
|
-
function resolveForwardedClientIp(request,
|
|
2674
|
-
const normalizedTrustedProxyIps = new Set(trustedProxyIPs.map((ip) => normalizeProxyIp(ip)).filter((ip) => Boolean(ip)));
|
|
2833
|
+
function resolveForwardedClientIp(request, normalizedTrustedProxyIps) {
|
|
2675
2834
|
const forwardedFor = getHeader(request.headers, "x-forwarded-for");
|
|
2676
2835
|
if (forwardedFor) {
|
|
2677
2836
|
const forwardedIps = normalizeStringEntries(forwardedFor.split(","));
|
|
2678
2837
|
if (forwardedIps.length > 0) {
|
|
2679
|
-
if (normalizedTrustedProxyIps.size === 0) return forwardedIps[0];
|
|
2680
2838
|
for (let index = forwardedIps.length - 1; index >= 0; index -= 1) {
|
|
2681
2839
|
const hop = forwardedIps[index];
|
|
2682
2840
|
if (!normalizedTrustedProxyIps.has(normalizeProxyIp(hop) ?? "")) return hop;
|
|
@@ -2717,12 +2875,14 @@ function cloneWebhookResponsePayload(payload) {
|
|
|
2717
2875
|
body: payload.body
|
|
2718
2876
|
};
|
|
2719
2877
|
}
|
|
2720
|
-
/**
|
|
2721
|
-
* HTTP server for receiving voice call webhooks from providers.
|
|
2722
|
-
* Supports WebSocket upgrades for media streams when streaming is enabled.
|
|
2723
|
-
*/
|
|
2724
2878
|
var VoiceCallWebhookServer = class {
|
|
2725
|
-
constructor(config, manager, provider, coreConfig, fullConfig, agentRuntime, logger) {
|
|
2879
|
+
constructor(scheduler, config, manager, provider, coreConfig, fullConfig, agentRuntime, logger) {
|
|
2880
|
+
this.scheduler = scheduler;
|
|
2881
|
+
this.manager = manager;
|
|
2882
|
+
this.provider = provider;
|
|
2883
|
+
this.coreConfig = coreConfig;
|
|
2884
|
+
this.fullConfig = fullConfig;
|
|
2885
|
+
this.agentRuntime = agentRuntime;
|
|
2726
2886
|
this.server = null;
|
|
2727
2887
|
this.listeningUrl = null;
|
|
2728
2888
|
this.startPromise = null;
|
|
@@ -2735,18 +2895,12 @@ var VoiceCallWebhookServer = class {
|
|
|
2735
2895
|
this.replayResponses = /* @__PURE__ */ new Map();
|
|
2736
2896
|
this.replayResponseCacheCalls = 0;
|
|
2737
2897
|
this.config = normalizeVoiceCallConfig(config);
|
|
2738
|
-
|
|
2739
|
-
this.provider = provider;
|
|
2740
|
-
this.coreConfig = coreConfig ?? null;
|
|
2741
|
-
this.fullConfig = fullConfig ?? null;
|
|
2742
|
-
this.agentRuntime = agentRuntime ?? null;
|
|
2743
|
-
this.logger = logger ?? {
|
|
2898
|
+
const rawLogger = logger ?? {
|
|
2744
2899
|
info: console.log,
|
|
2745
2900
|
warn: console.warn,
|
|
2746
2901
|
error: console.error,
|
|
2747
2902
|
debug: console.debug
|
|
2748
2903
|
};
|
|
2749
|
-
const rawLogger = this.logger;
|
|
2750
2904
|
const rawDebug = rawLogger.debug;
|
|
2751
2905
|
this.logger = {
|
|
2752
2906
|
info: (msg) => rawLogger.info(`[voice-call] ${msg}`),
|
|
@@ -2791,14 +2945,13 @@ var VoiceCallWebhookServer = class {
|
|
|
2791
2945
|
const normalizedRemoteIp = normalizeProxyIp(remoteIp);
|
|
2792
2946
|
const fromTrustedProxy = normalizedTrustedProxyIps.size > 0 && normalizedRemoteIp !== void 0 && normalizedTrustedProxyIps.has(normalizedRemoteIp);
|
|
2793
2947
|
if (this.config.webhookSecurity.trustForwardingHeaders && fromTrustedProxy) {
|
|
2794
|
-
const forwardedIp = resolveForwardedClientIp(request,
|
|
2948
|
+
const forwardedIp = resolveForwardedClientIp(request, normalizedTrustedProxyIps);
|
|
2795
2949
|
if (forwardedIp) return forwardedIp;
|
|
2796
2950
|
}
|
|
2797
2951
|
return remoteIp;
|
|
2798
2952
|
}
|
|
2799
2953
|
shouldSuppressBargeInForInitialMessage(call) {
|
|
2800
|
-
if (!call || call.direction !== "outbound") return false;
|
|
2801
|
-
if (call.state !== "speaking") return false;
|
|
2954
|
+
if (!call || call.direction !== "outbound" || call.state !== "speaking") return false;
|
|
2802
2955
|
if ((call.metadata?.mode ?? "conversation") !== "conversation") return false;
|
|
2803
2956
|
return (normalizeOptionalString(call.metadata?.initialMessage) ?? "").length > 0;
|
|
2804
2957
|
}
|
|
@@ -2820,9 +2973,6 @@ var VoiceCallWebhookServer = class {
|
|
|
2820
2973
|
current.provider.clearTtsQueue(providerCallId);
|
|
2821
2974
|
return generation;
|
|
2822
2975
|
}
|
|
2823
|
-
/**
|
|
2824
|
-
* Initialize media streaming with the selected realtime transcription provider.
|
|
2825
|
-
*/
|
|
2826
2976
|
async initializeMediaStreaming() {
|
|
2827
2977
|
const streaming = this.config.streaming;
|
|
2828
2978
|
const pluginConfig = this.fullConfig ?? this.coreConfig ?? void 0;
|
|
@@ -2940,10 +3090,6 @@ var VoiceCallWebhookServer = class {
|
|
|
2940
3090
|
this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
|
|
2941
3091
|
this.logger.info("Media streaming initialized");
|
|
2942
3092
|
}
|
|
2943
|
-
/**
|
|
2944
|
-
* Start the webhook server.
|
|
2945
|
-
* Idempotent: returns immediately if the server is already listening.
|
|
2946
|
-
*/
|
|
2947
3093
|
async start() {
|
|
2948
3094
|
if (this.stopPromise) await this.stopPromise;
|
|
2949
3095
|
const { port, bind, path: webhookPath } = this.config.serve;
|
|
@@ -2988,6 +3134,7 @@ var VoiceCallWebhookServer = class {
|
|
|
2988
3134
|
}
|
|
2989
3135
|
resolve(url);
|
|
2990
3136
|
this.stopStaleCallReaper = startStaleCallReaper({
|
|
3137
|
+
scheduler: this.scheduler,
|
|
2991
3138
|
manager: this.manager,
|
|
2992
3139
|
staleCallReaperSeconds: this.config.staleCallReaperSeconds
|
|
2993
3140
|
});
|
|
@@ -3013,13 +3160,12 @@ var VoiceCallWebhookServer = class {
|
|
|
3013
3160
|
});
|
|
3014
3161
|
this.startPromise = null;
|
|
3015
3162
|
this.streamDisconnectGrace.close();
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
this.stopStaleCallReaper = null;
|
|
3019
|
-
}
|
|
3163
|
+
const reaperStopped = this.stopStaleCallReaper?.();
|
|
3164
|
+
this.stopStaleCallReaper = null;
|
|
3020
3165
|
this.webhookInFlightLimiter.clear();
|
|
3021
3166
|
this.stopPromise = (async () => {
|
|
3022
3167
|
const results = await Promise.allSettled([
|
|
3168
|
+
reaperStopped,
|
|
3023
3169
|
serverClosePromise,
|
|
3024
3170
|
this.mediaStreamHandler?.close(serverClosePromise) ?? Promise.resolve(),
|
|
3025
3171
|
this.realtimeHandler?.close(serverClosePromise) ?? Promise.resolve()
|
|
@@ -3049,9 +3195,6 @@ var VoiceCallWebhookServer = class {
|
|
|
3049
3195
|
return null;
|
|
3050
3196
|
}
|
|
3051
3197
|
}
|
|
3052
|
-
isWebhookPathMatch(requestPath, configuredPath) {
|
|
3053
|
-
return normalizeWebhookPath(requestPath) === normalizeWebhookPath(configuredPath);
|
|
3054
|
-
}
|
|
3055
3198
|
async handleRequest(req, res, webhookPath) {
|
|
3056
3199
|
const payload = await this.runWebhookPipeline(req, webhookPath, res);
|
|
3057
3200
|
if (payload) this.writeWebhookResponse(res, payload);
|
|
@@ -3067,7 +3210,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3067
3210
|
<Play loop="0">https://s3.amazonaws.com/com.twilio.music.classical/BusyStrings.mp3</Play>
|
|
3068
3211
|
</Response>`
|
|
3069
3212
|
};
|
|
3070
|
-
if (
|
|
3213
|
+
if (normalizeWebhookPath(url.pathname) !== normalizeWebhookPath(webhookPath)) return {
|
|
3071
3214
|
statusCode: 404,
|
|
3072
3215
|
body: "Not Found"
|
|
3073
3216
|
};
|
|
@@ -3075,9 +3218,9 @@ var VoiceCallWebhookServer = class {
|
|
|
3075
3218
|
statusCode: 405,
|
|
3076
3219
|
body: "Method Not Allowed"
|
|
3077
3220
|
};
|
|
3078
|
-
const
|
|
3079
|
-
if (
|
|
3080
|
-
this.logger.warn(`Webhook rejected before body read: ${
|
|
3221
|
+
const headerRejection = this.verifyPreAuthWebhookHeaders(req.headers);
|
|
3222
|
+
if (headerRejection) {
|
|
3223
|
+
this.logger.warn(`Webhook rejected before body read: ${headerRejection}`);
|
|
3081
3224
|
return {
|
|
3082
3225
|
statusCode: 401,
|
|
3083
3226
|
body: "Unauthorized"
|
|
@@ -3178,11 +3321,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3178
3321
|
if (now !== void 0) {
|
|
3179
3322
|
for (const [key, entry] of this.replayResponses) if (entry.expiresAt <= now) this.replayResponses.delete(key);
|
|
3180
3323
|
}
|
|
3181
|
-
|
|
3182
|
-
const oldest = this.replayResponses.keys().next().value;
|
|
3183
|
-
if (!oldest) break;
|
|
3184
|
-
this.replayResponses.delete(oldest);
|
|
3185
|
-
}
|
|
3324
|
+
pruneMapToMaxSize(this.replayResponses, WEBHOOK_REPLAY_RESPONSE_MAX_ENTRIES);
|
|
3186
3325
|
}
|
|
3187
3326
|
async getCachedReplayResponse(key) {
|
|
3188
3327
|
const now = asDateTimestampMs(Date.now());
|
|
@@ -3218,33 +3357,24 @@ var VoiceCallWebhookServer = class {
|
|
|
3218
3357
|
return cloneWebhookResponsePayload(await ownerResponse);
|
|
3219
3358
|
}
|
|
3220
3359
|
verifyPreAuthWebhookHeaders(headers) {
|
|
3221
|
-
if (this.config.skipSignatureVerification) return
|
|
3360
|
+
if (this.config.skipSignatureVerification) return;
|
|
3222
3361
|
switch (this.provider.name) {
|
|
3223
3362
|
case "telnyx": {
|
|
3224
3363
|
const signature = getHeader(headers, "telnyx-signature-ed25519");
|
|
3225
3364
|
const timestamp = getHeader(headers, "telnyx-timestamp");
|
|
3226
|
-
if (signature && timestamp) return
|
|
3227
|
-
return
|
|
3228
|
-
ok: false,
|
|
3229
|
-
reason: "missing Telnyx signature or timestamp header"
|
|
3230
|
-
};
|
|
3365
|
+
if (signature && timestamp) return;
|
|
3366
|
+
return "missing Telnyx signature or timestamp header";
|
|
3231
3367
|
}
|
|
3232
3368
|
case "twilio":
|
|
3233
|
-
if (getHeader(headers, "x-twilio-signature")) return
|
|
3234
|
-
return
|
|
3235
|
-
ok: false,
|
|
3236
|
-
reason: "missing X-Twilio-Signature header"
|
|
3237
|
-
};
|
|
3369
|
+
if (getHeader(headers, "x-twilio-signature")) return;
|
|
3370
|
+
return "missing X-Twilio-Signature header";
|
|
3238
3371
|
case "plivo": {
|
|
3239
3372
|
const hasV3 = Boolean(getHeader(headers, "x-plivo-signature-v3")) && Boolean(getHeader(headers, "x-plivo-signature-v3-nonce"));
|
|
3240
3373
|
const hasV2 = Boolean(getHeader(headers, "x-plivo-signature-v2")) && Boolean(getHeader(headers, "x-plivo-signature-v2-nonce"));
|
|
3241
|
-
if (hasV3 || hasV2) return
|
|
3242
|
-
return
|
|
3243
|
-
ok: false,
|
|
3244
|
-
reason: "missing Plivo signature headers"
|
|
3245
|
-
};
|
|
3374
|
+
if (hasV3 || hasV2) return;
|
|
3375
|
+
return "missing Plivo signature headers";
|
|
3246
3376
|
}
|
|
3247
|
-
default: return
|
|
3377
|
+
default: return;
|
|
3248
3378
|
}
|
|
3249
3379
|
}
|
|
3250
3380
|
isRealtimeWebSocketUpgrade(req) {
|
|
@@ -3304,10 +3434,6 @@ var VoiceCallWebhookServer = class {
|
|
|
3304
3434
|
if (payload.headers) for (const [key, value] of Object.entries(payload.headers)) res.setHeader(key, value);
|
|
3305
3435
|
res.end(payload.body);
|
|
3306
3436
|
}
|
|
3307
|
-
/**
|
|
3308
|
-
* Handle auto-response for inbound calls using the agent system.
|
|
3309
|
-
* Supports tool calling for richer voice interactions.
|
|
3310
|
-
*/
|
|
3311
3437
|
async handleInboundResponse(callId, userMessage) {
|
|
3312
3438
|
this.logger.info(`Auto-responding to inbound call ${callId} chars=${userMessage.length}`);
|
|
3313
3439
|
const call = this.manager.getCall(callId);
|
|
@@ -3348,7 +3474,7 @@ var VoiceCallWebhookServer = class {
|
|
|
3348
3474
|
sessionKey: call.sessionKey,
|
|
3349
3475
|
from: call.from,
|
|
3350
3476
|
senderIsOwner: call.direction === "inbound" ? false : void 0,
|
|
3351
|
-
agentId: resolveCallAgentId(call
|
|
3477
|
+
agentId: resolveCallAgentId(call),
|
|
3352
3478
|
transcript: call.transcript,
|
|
3353
3479
|
userMessage,
|
|
3354
3480
|
onEarlyText: speakResponse
|
|
@@ -3375,12 +3501,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
|
|
|
3375
3501
|
"Do not print secret values or dump environment variables; only check whether required configuration is present.",
|
|
3376
3502
|
"Be accurate, brief, and speakable."
|
|
3377
3503
|
].join(" ");
|
|
3378
|
-
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-
|
|
3379
|
-
const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-
|
|
3380
|
-
const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-
|
|
3381
|
-
const loadMockProvider = createLazyRuntimeModule(() => import("./mock-
|
|
3504
|
+
const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-DPilutCH.mjs"));
|
|
3505
|
+
const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-BWFNGyVx.mjs"));
|
|
3506
|
+
const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-CpPIENu4.mjs"));
|
|
3507
|
+
const loadMockProvider = createLazyRuntimeModule(() => import("./mock-eLv_Dc4o.mjs"));
|
|
3382
3508
|
const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.mjs"));
|
|
3383
|
-
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-
|
|
3509
|
+
const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-Do7Sz7Gh.mjs"));
|
|
3384
3510
|
function mapVoiceCallConsultTranscript(call, context) {
|
|
3385
3511
|
const transcript = (call.transcript ?? []).map((entry) => ({
|
|
3386
3512
|
role: entry.speaker === "bot" ? "assistant" : "user",
|
|
@@ -3504,13 +3630,10 @@ async function createRealtimeInstructionsResolver(params) {
|
|
|
3504
3630
|
})];
|
|
3505
3631
|
}));
|
|
3506
3632
|
const instructionsByAgentId = new Map(entries);
|
|
3507
|
-
return (call) =>
|
|
3508
|
-
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
|
3509
|
-
const effectiveConfig = resolveVoiceCallEffectiveConfig(params.config, numberRouteKey).config;
|
|
3510
|
-
return instructionsByAgentId.get(resolveCallAgentId(call, effectiveConfig)) ?? genericInstructions;
|
|
3511
|
-
};
|
|
3633
|
+
return (call) => instructionsByAgentId.get(resolveCallAgentId(call)) ?? genericInstructions;
|
|
3512
3634
|
}
|
|
3513
3635
|
async function createVoiceCallRuntime(params) {
|
|
3636
|
+
params.scheduler.signal.throwIfAborted();
|
|
3514
3637
|
const { config: rawConfig, coreConfig, fullConfig, agentRuntime, stateRuntime, ttsRuntime, logger } = params;
|
|
3515
3638
|
const log = logger ?? {
|
|
3516
3639
|
info: console.log,
|
|
@@ -3532,7 +3655,7 @@ async function createVoiceCallRuntime(params) {
|
|
|
3532
3655
|
if (stateRuntime) setVoiceCallStateRuntime({ state: stateRuntime });
|
|
3533
3656
|
const manager = new CallManager(config, void 0, cfg.session, stateRuntime);
|
|
3534
3657
|
const realtimeVoiceRuntime = config.realtime.enabled ? await loadRealtimeVoiceRuntime() : null;
|
|
3535
|
-
const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig, cfg, agentRuntime, log);
|
|
3658
|
+
const webhookServer = new VoiceCallWebhookServer(params.scheduler, config, manager, provider, coreConfig, cfg, agentRuntime, log);
|
|
3536
3659
|
if (realtimeVoiceRuntime) {
|
|
3537
3660
|
const { RealtimeCallHandler } = await loadRealtimeHandler();
|
|
3538
3661
|
const resolveRealtimeInstructions = await createRealtimeInstructionsResolver({
|
|
@@ -3547,7 +3670,7 @@ async function createVoiceCallRuntime(params) {
|
|
|
3547
3670
|
const resolveCallRegistration = (call) => {
|
|
3548
3671
|
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
|
3549
3672
|
const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
|
|
3550
|
-
const agentId = resolveCallAgentId(call
|
|
3673
|
+
const agentId = resolveCallAgentId(call);
|
|
3551
3674
|
const resolved = realtimeVoiceRuntime.resolveConfiguredRealtimeVoiceProvider({
|
|
3552
3675
|
configuredProviderId: effectiveConfig.realtime.provider,
|
|
3553
3676
|
providerConfigs: effectiveConfig.realtime.providers,
|
|
@@ -3571,7 +3694,7 @@ async function createVoiceCallRuntime(params) {
|
|
|
3571
3694
|
if (!call) return { error: `Call "${callId}" not found` };
|
|
3572
3695
|
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
|
3573
3696
|
const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
|
|
3574
|
-
const agentId = resolveCallAgentId(call
|
|
3697
|
+
const agentId = resolveCallAgentId(call);
|
|
3575
3698
|
const sessionKey = resolveVoiceCallSessionKey({
|
|
3576
3699
|
config: {
|
|
3577
3700
|
...effectiveConfig,
|
|
@@ -3601,6 +3724,12 @@ async function createVoiceCallRuntime(params) {
|
|
|
3601
3724
|
labels: {
|
|
3602
3725
|
audienceLabel: "caller",
|
|
3603
3726
|
contextName: "OpenClaw memory or session context"
|
|
3727
|
+
},
|
|
3728
|
+
liveness: {
|
|
3729
|
+
signal: handlerContext.abortSignal,
|
|
3730
|
+
assertCurrent() {
|
|
3731
|
+
if (manager.getCall(callId) !== call || TerminalStates.has(call.state)) throw new Error(`Call "${callId}" is no longer active`);
|
|
3732
|
+
}
|
|
3604
3733
|
}
|
|
3605
3734
|
});
|
|
3606
3735
|
handlerContext.abortSignal?.throwIfAborted();
|
|
@@ -3719,4 +3848,4 @@ async function createVoiceCallRuntime(params) {
|
|
|
3719
3848
|
}
|
|
3720
3849
|
}
|
|
3721
3850
|
//#endregion
|
|
3722
|
-
export {
|
|
3851
|
+
export { MAX_CALL_RECORD_EVENTS as _, normalizeProviderStatus as a, loadActiveCallsFromStore as b, resolveWebhookExposureStatus as c, setupTailscaleExposureRoutes as d, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as f, generateDtmfRedirectTwiml as g, resolveVoiceCallAgentId as h, mapProviderStatusToEndReason as i, cleanupTailscaleExposureRoute as l, resolveCallAgentId as m, normalizeProxyIp as n, canonicalizeVoiceCallMediaBase64 as o, resolveVoiceResponseModel as p, isProviderStatusTerminal as r, getHeader as s, createVoiceCallRuntime as t, getTailscaleSelfInfo as u, findCallInStore as v, setVoiceCallStateRuntime as x, getCallHistoryFromStore as y };
|