@openclaw/voice-call 2026.7.2-beta.7 → 2026.8.1-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/doctor-contract-api.js +11 -8
- package/dist/{guarded-json-api-DS9sZa3C.js → guarded-json-api-j4OEG0fR.js} +24 -46
- package/dist/index.js +644 -913
- package/dist/{mock-YsvBTX-7.js → mock-Dv7KxYGt.js} +5 -4
- package/dist/{plivo-vZ--Wqxv.js → plivo-BbVnahnQ.js} +45 -8
- package/dist/{realtime-handler--i_OERv6.js → realtime-handler-BaMEKbmJ.js} +579 -209
- package/dist/{response-generator-D-7ApVfT.js → response-generator-tjyX4tDq.js} +70 -15
- package/dist/{runtime-entry-D0UJiLfi.js → runtime-entry-DK8kPCGC.js} +687 -355
- package/dist/runtime-entry.js +1 -1
- package/dist/setup-api.js +17 -24
- package/dist/{store-path-nYL-yM0S.js → store-path-51_oC_xn.js} +70 -13
- package/dist/{telnyx-Dx6cfLvV.js → telnyx-DO8dFQcb.js} +14 -9
- package/dist/{twilio-D_D8KysU.js → twilio-JDt_1nlz.js} +58 -51
- package/dist/{webhook-replay-aaP79dI8.js → webhook-replay-FQrEGVSM.js} +20 -6
- package/openclaw.plugin.json +69 -6
- package/package.json +4 -4
package/dist/runtime-entry.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as createVoiceCallRuntime } from "./runtime-entry-
|
|
1
|
+
import { t as createVoiceCallRuntime } from "./runtime-entry-DK8kPCGC.js";
|
|
2
2
|
export { createVoiceCallRuntime };
|
package/dist/setup-api.js
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
|
-
import { asOptionalRecord, isRecord, readStringField } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
1
|
+
import { asFiniteNumber, asOptionalRecord, isRecord, readStringField } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
2
2
|
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
3
3
|
//#region extensions/voice-call/src/config-migration.ts
|
|
4
|
-
const asObject = asOptionalRecord;
|
|
5
|
-
const getString = readStringField;
|
|
6
|
-
/** Read finite numeric config values. */
|
|
7
|
-
function getNumber(obj, key) {
|
|
8
|
-
const value = obj?.[key];
|
|
9
|
-
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
10
|
-
}
|
|
11
4
|
/** Merge legacy provider-specific values into the canonical providers map. */
|
|
12
5
|
function mergeProviderConfig(providersValue, providerId, compatValues) {
|
|
13
|
-
if (Object.keys(compatValues).length === 0) return
|
|
14
|
-
const providers =
|
|
15
|
-
const existing =
|
|
6
|
+
if (Object.keys(compatValues).length === 0) return asOptionalRecord(providersValue);
|
|
7
|
+
const providers = asOptionalRecord(providersValue) ?? {};
|
|
8
|
+
const existing = asOptionalRecord(providers[providerId]) ?? {};
|
|
16
9
|
return {
|
|
17
10
|
...providers,
|
|
18
11
|
[providerId]: {
|
|
@@ -23,23 +16,23 @@ function mergeProviderConfig(providersValue, providerId, compatValues) {
|
|
|
23
16
|
}
|
|
24
17
|
/** Migrate legacy voice-call config input to the current canonical shape. */
|
|
25
18
|
function migrateVoiceCallLegacyConfigInput(params) {
|
|
26
|
-
const raw =
|
|
27
|
-
const realtime =
|
|
28
|
-
const realtimeAgentContext =
|
|
29
|
-
const twilio =
|
|
30
|
-
const streaming =
|
|
19
|
+
const raw = asOptionalRecord(params.value) ?? {};
|
|
20
|
+
const realtime = asOptionalRecord(raw.realtime);
|
|
21
|
+
const realtimeAgentContext = asOptionalRecord(realtime?.agentContext);
|
|
22
|
+
const twilio = asOptionalRecord(raw.twilio);
|
|
23
|
+
const streaming = asOptionalRecord(raw.streaming);
|
|
31
24
|
const configPathPrefix = params.configPathPrefix ?? "plugins.entries.voice-call.config";
|
|
32
25
|
const legacyStreamingOpenAICompat = {};
|
|
33
|
-
const streamingOpenAIApiKey =
|
|
26
|
+
const streamingOpenAIApiKey = readStringField(streaming, "openaiApiKey");
|
|
34
27
|
if (streamingOpenAIApiKey) legacyStreamingOpenAICompat.apiKey = streamingOpenAIApiKey;
|
|
35
|
-
const streamingSttModel =
|
|
28
|
+
const streamingSttModel = readStringField(streaming, "sttModel");
|
|
36
29
|
if (streamingSttModel) legacyStreamingOpenAICompat.model = streamingSttModel;
|
|
37
|
-
const streamingSilenceDurationMs =
|
|
30
|
+
const streamingSilenceDurationMs = asFiniteNumber(streaming?.silenceDurationMs);
|
|
38
31
|
if (streamingSilenceDurationMs !== void 0) legacyStreamingOpenAICompat.silenceDurationMs = streamingSilenceDurationMs;
|
|
39
|
-
const streamingVadThreshold =
|
|
32
|
+
const streamingVadThreshold = asFiniteNumber(streaming?.vadThreshold);
|
|
40
33
|
if (streamingVadThreshold !== void 0) legacyStreamingOpenAICompat.vadThreshold = streamingVadThreshold;
|
|
41
|
-
const streamingProvider =
|
|
42
|
-
const legacyStreamingProvider =
|
|
34
|
+
const streamingProvider = readStringField(streaming, "provider");
|
|
35
|
+
const legacyStreamingProvider = readStringField(streaming, "sttProvider");
|
|
43
36
|
const normalizedStreaming = streaming ? {
|
|
44
37
|
...streaming,
|
|
45
38
|
provider: streamingProvider ?? legacyStreamingProvider,
|
|
@@ -74,9 +67,9 @@ function migrateVoiceCallLegacyConfigInput(params) {
|
|
|
74
67
|
if (typeof streaming?.sttProvider === "string") changes.push(`Moved ${configPathPrefix}.streaming.sttProvider → ${configPathPrefix}.streaming.provider.`);
|
|
75
68
|
if (typeof streaming?.openaiApiKey === "string") changes.push(`Moved ${configPathPrefix}.streaming.openaiApiKey → ${configPathPrefix}.streaming.providers.openai.apiKey.`);
|
|
76
69
|
if (typeof streaming?.sttModel === "string") changes.push(`Moved ${configPathPrefix}.streaming.sttModel → ${configPathPrefix}.streaming.providers.openai.model.`);
|
|
77
|
-
if (
|
|
70
|
+
if (asFiniteNumber(streaming?.silenceDurationMs) !== void 0) changes.push(`Moved ${configPathPrefix}.streaming.silenceDurationMs → ${configPathPrefix}.streaming.providers.openai.silenceDurationMs.`);
|
|
78
71
|
else if (typeof streaming?.silenceDurationMs === "number") changes.push(`Removed invalid ${configPathPrefix}.streaming.silenceDurationMs.`);
|
|
79
|
-
if (
|
|
72
|
+
if (asFiniteNumber(streaming?.vadThreshold) !== void 0) changes.push(`Moved ${configPathPrefix}.streaming.vadThreshold → ${configPathPrefix}.streaming.providers.openai.vadThreshold.`);
|
|
80
73
|
else if (typeof streaming?.vadThreshold === "number") changes.push(`Removed invalid ${configPathPrefix}.streaming.vadThreshold.`);
|
|
81
74
|
if (realtimeAgentContext && Object.hasOwn(realtimeAgentContext, "includeSystemPrompt")) changes.push(`Removed ${configPathPrefix}.realtime.agentContext.includeSystemPrompt.`);
|
|
82
75
|
return {
|
|
@@ -130,6 +130,54 @@ const { setRuntime: setVoiceCallStateRuntime, tryGetRuntime: getOptionalVoiceCal
|
|
|
130
130
|
errorMessage: "Voice Call state runtime not initialized"
|
|
131
131
|
});
|
|
132
132
|
//#endregion
|
|
133
|
+
//#region extensions/voice-call/src/manager/replay-keys.ts
|
|
134
|
+
/** Match the existing voice-call webhook replay-cache cardinality. */
|
|
135
|
+
const MAX_MANAGER_REPLAY_KEYS = 1e4;
|
|
136
|
+
function pruneOldestSetEntries(keys, maxEntries) {
|
|
137
|
+
while (keys.size > maxEntries) {
|
|
138
|
+
const oldest = keys.values().next().value;
|
|
139
|
+
if (oldest === void 0) break;
|
|
140
|
+
keys.delete(oldest);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function pruneOldestMapEntries(keys, maxEntries) {
|
|
144
|
+
while (keys.size > maxEntries) {
|
|
145
|
+
const oldest = keys.keys().next().value;
|
|
146
|
+
if (oldest === void 0) break;
|
|
147
|
+
keys.delete(oldest);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Remember one manager replay key without refreshing duplicate insertion order. */
|
|
151
|
+
function rememberManagerReplayKey(keys, key, maxEntries = MAX_MANAGER_REPLAY_KEYS) {
|
|
152
|
+
keys.add(key);
|
|
153
|
+
pruneOldestSetEntries(keys, maxEntries);
|
|
154
|
+
}
|
|
155
|
+
/** Append one per-call replay key and retain only the newest bounded suffix. */
|
|
156
|
+
function appendCallReplayKey(keys, key, maxEntries = 500) {
|
|
157
|
+
keys.push(key);
|
|
158
|
+
trimCallReplayKeys(keys, maxEntries);
|
|
159
|
+
}
|
|
160
|
+
/** Normalize restored or externally constructed call replay history in place. */
|
|
161
|
+
function trimCallReplayKeys(keys, maxEntries = 500) {
|
|
162
|
+
const overflow = keys.length - maxEntries;
|
|
163
|
+
if (overflow > 0) keys.splice(0, overflow);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Reserve one rejected provider call. The token prevents a stale failed
|
|
167
|
+
* hangup from deleting a newer reservation after bounded eviction and reuse.
|
|
168
|
+
*/
|
|
169
|
+
function reserveRejectedProviderCall(calls, providerCallId, maxEntries = MAX_MANAGER_REPLAY_KEYS) {
|
|
170
|
+
if (calls.has(providerCallId)) return;
|
|
171
|
+
const reservation = Symbol(providerCallId);
|
|
172
|
+
calls.set(providerCallId, reservation);
|
|
173
|
+
pruneOldestMapEntries(calls, maxEntries);
|
|
174
|
+
return reservation;
|
|
175
|
+
}
|
|
176
|
+
/** Release a rejected-call reservation only when the failing attempt still owns it. */
|
|
177
|
+
function releaseRejectedProviderCall(calls, providerCallId, reservation) {
|
|
178
|
+
if (calls.get(providerCallId) === reservation) calls.delete(providerCallId);
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
133
181
|
//#region extensions/voice-call/src/manager/store.ts
|
|
134
182
|
/** Plugin state namespace for call record event metadata. */
|
|
135
183
|
const CALL_RECORD_EVENTS_NAMESPACE = "call-record-events";
|
|
@@ -237,10 +285,18 @@ function countCallRecordChunks(call) {
|
|
|
237
285
|
}
|
|
238
286
|
/** Truncate oversized call records to fit the bounded plugin state chunk budget. */
|
|
239
287
|
function prepareVoiceCallRecordForStorage(call) {
|
|
240
|
-
|
|
241
|
-
|
|
288
|
+
let boundedCall = call;
|
|
289
|
+
if (call.processedEventIds.length > 500) {
|
|
290
|
+
boundedCall = {
|
|
291
|
+
...call,
|
|
292
|
+
processedEventIds: [...call.processedEventIds]
|
|
293
|
+
};
|
|
294
|
+
trimCallReplayKeys(boundedCall.processedEventIds);
|
|
295
|
+
}
|
|
296
|
+
if (countCallRecordChunks(boundedCall) <= 48) return boundedCall;
|
|
297
|
+
const transcriptEntries = boundedCall.transcript.length;
|
|
242
298
|
const metadata = {
|
|
243
|
-
...
|
|
299
|
+
...boundedCall.metadata,
|
|
244
300
|
voiceCallPersistence: {
|
|
245
301
|
transcriptTruncated: true,
|
|
246
302
|
originalTranscriptEntries: transcriptEntries
|
|
@@ -266,12 +322,12 @@ function prepareVoiceCallRecordForStorage(call) {
|
|
|
266
322
|
];
|
|
267
323
|
for (const candidateInput of candidateInputs) {
|
|
268
324
|
const candidate = CallRecordSchema.parse({
|
|
269
|
-
...
|
|
325
|
+
...boundedCall,
|
|
270
326
|
...candidateInput
|
|
271
327
|
});
|
|
272
328
|
if (countCallRecordChunks(candidate) <= 48) return candidate;
|
|
273
329
|
}
|
|
274
|
-
return
|
|
330
|
+
return boundedCall;
|
|
275
331
|
}
|
|
276
332
|
/** Register a serialized call record event and its chunks, then prune old events. */
|
|
277
333
|
function registerCallRecordEvent(stores, eventKey, call, order) {
|
|
@@ -356,17 +412,19 @@ function loadActiveCallsFromStore(storePath) {
|
|
|
356
412
|
if (calls.length === 0) return {
|
|
357
413
|
activeCalls: /* @__PURE__ */ new Map(),
|
|
358
414
|
providerCallIdMap: /* @__PURE__ */ new Map(),
|
|
359
|
-
processedEventIds: /* @__PURE__ */ new Set()
|
|
360
|
-
rejectedProviderCallIds: /* @__PURE__ */ new Set()
|
|
415
|
+
processedEventIds: /* @__PURE__ */ new Set()
|
|
361
416
|
};
|
|
362
417
|
const callMap = /* @__PURE__ */ new Map();
|
|
363
|
-
for (const call of calls)
|
|
418
|
+
for (const call of calls) {
|
|
419
|
+
callMap.delete(call.callId);
|
|
420
|
+
callMap.set(call.callId, call);
|
|
421
|
+
}
|
|
364
422
|
const activeCalls = /* @__PURE__ */ new Map();
|
|
365
423
|
const providerCallIdMap = /* @__PURE__ */ new Map();
|
|
366
424
|
const processedEventIds = /* @__PURE__ */ new Set();
|
|
367
|
-
const rejectedProviderCallIds = /* @__PURE__ */ new Set();
|
|
368
425
|
for (const [callId, call] of callMap) {
|
|
369
|
-
|
|
426
|
+
trimCallReplayKeys(call.processedEventIds);
|
|
427
|
+
for (const eventId of call.processedEventIds) rememberManagerReplayKey(processedEventIds, eventId);
|
|
370
428
|
if (TerminalStates.has(call.state)) continue;
|
|
371
429
|
activeCalls.set(callId, call);
|
|
372
430
|
if (call.providerCallId) providerCallIdMap.set(call.providerCallId, callId);
|
|
@@ -374,8 +432,7 @@ function loadActiveCallsFromStore(storePath) {
|
|
|
374
432
|
return {
|
|
375
433
|
activeCalls,
|
|
376
434
|
providerCallIdMap,
|
|
377
|
-
processedEventIds
|
|
378
|
-
rejectedProviderCallIds
|
|
435
|
+
processedEventIds
|
|
379
436
|
};
|
|
380
437
|
}
|
|
381
438
|
function readCallHistoryFromStore(storePath) {
|
|
@@ -407,4 +464,4 @@ function resolveDefaultVoiceCallStoreDir(env = process.env) {
|
|
|
407
464
|
return path.join(resolveStateDir(env), "voice-calls");
|
|
408
465
|
}
|
|
409
466
|
//#endregion
|
|
410
|
-
export {
|
|
467
|
+
export { releaseRejectedProviderCall as _, CALL_RECORD_EVENT_META_MAX_ENTRIES as a, setVoiceCallStateRuntime as b, buildVoiceCallLegacyJsonlEventKey as c, loadActiveCallsFromStore as d, parseVoiceCallRecordLine as f, appendCallReplayKey as g, resolveVoiceCallLegacyCallLogPath as h, CALL_RECORD_EVENT_CHUNKS_NAMESPACE as i, findCallMatchesInStore as l, prepareVoiceCallRecordForStorage as m, CALL_RECORD_CHUNK_MAX_ENTRIES as n, MAX_CALL_RECORD_EVENTS as o, persistCallRecord as p, CALL_RECORD_EVENTS_NAMESPACE as r, RAW_CALL_RECORD_CHUNK_BYTES as s, resolveDefaultVoiceCallStoreDir as t, getCallHistoryFromStore as u, rememberManagerReplayKey as v, TerminalStates as x, reserveRejectedProviderCall as y };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { s as verifyTelnyxWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-
|
|
1
|
+
import { s as verifyTelnyxWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-j4OEG0fR.js";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
//#region extensions/voice-call/src/providers/telnyx.ts
|
|
4
4
|
function normalizeTelnyxDirection(direction) {
|
|
@@ -57,7 +57,8 @@ var TelnyxProvider = class {
|
|
|
57
57
|
ok: result.ok,
|
|
58
58
|
reason: result.reason,
|
|
59
59
|
isReplay: result.isReplay,
|
|
60
|
-
verifiedRequestKey: result.verifiedRequestKey
|
|
60
|
+
verifiedRequestKey: result.verifiedRequestKey,
|
|
61
|
+
releaseReplay: result.releaseReplay
|
|
61
62
|
};
|
|
62
63
|
}
|
|
63
64
|
/**
|
|
@@ -121,13 +122,17 @@ var TelnyxProvider = class {
|
|
|
121
122
|
type: "call.speaking",
|
|
122
123
|
text: data.payload?.text || ""
|
|
123
124
|
};
|
|
124
|
-
case "call.transcription":
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
case "call.transcription": {
|
|
126
|
+
const transcript = data.payload?.transcription_data?.transcript ?? data.payload?.transcription ?? "";
|
|
127
|
+
if (!transcript.trim()) return null;
|
|
128
|
+
return {
|
|
129
|
+
...baseEvent,
|
|
130
|
+
type: "call.speech",
|
|
131
|
+
transcript,
|
|
132
|
+
isFinal: data.payload?.transcription_data?.is_final ?? data.payload?.is_final ?? true,
|
|
133
|
+
confidence: data.payload?.transcription_data?.confidence ?? data.payload?.confidence
|
|
134
|
+
};
|
|
135
|
+
}
|
|
131
136
|
case "call.hangup": return {
|
|
132
137
|
...baseEvent,
|
|
133
138
|
type: "call.ended",
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { fetchWithSsrFGuard } from "./runtime-api.js";
|
|
2
2
|
import "./api.js";
|
|
3
3
|
import { n as requireSupportedTwilioApiHostname, r as resolveTwilioApiBaseUrl } from "./twilio-region-4fkgz3UG.js";
|
|
4
|
-
import { _ as escapeXml, a as normalizeProviderStatus, i as mapProviderStatusToEndReason, p as chunkAudio, r as isProviderStatusTerminal, s as getHeader, v as mapVoiceToPolly } from "./runtime-entry-
|
|
5
|
-
import { c as verifyTwilioWebhook, i as
|
|
4
|
+
import { _ as escapeXml, a as normalizeProviderStatus, i as mapProviderStatusToEndReason, p as chunkAudio, r as isProviderStatusTerminal, s as getHeader, v as mapVoiceToPolly } from "./runtime-entry-DK8kPCGC.js";
|
|
5
|
+
import { c as verifyTwilioWebhook, i as readVoiceCallProviderJsonResponse, n as cancelProviderResponseBody, r as readProviderErrorResponseSnippet, t as guardedJsonApiRequest } from "./guarded-json-api-j4OEG0fR.js";
|
|
6
6
|
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
7
7
|
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
|
8
8
|
import crypto from "node:crypto";
|
|
9
9
|
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
|
10
|
-
import {
|
|
10
|
+
import { retryAsync } from "openclaw/plugin-sdk/retry-runtime";
|
|
11
11
|
//#region extensions/voice-call/src/providers/twilio/api.ts
|
|
12
12
|
const TWILIO_API_TIMEOUT_MS = 3e4;
|
|
13
13
|
/** Parse Twilio JSON error responses without trusting response shape. */
|
|
@@ -68,13 +68,7 @@ async function twilioApiRequest(params) {
|
|
|
68
68
|
const errorText = await readProviderErrorResponseSnippet(response);
|
|
69
69
|
throw new TwilioApiError(response.status, errorText);
|
|
70
70
|
}
|
|
71
|
-
|
|
72
|
-
if (!text) return;
|
|
73
|
-
try {
|
|
74
|
-
return JSON.parse(text);
|
|
75
|
-
} catch {
|
|
76
|
-
throw new Error("Twilio API returned malformed JSON.");
|
|
77
|
-
}
|
|
71
|
+
return await readVoiceCallProviderJsonResponse(response, "Twilio API returned malformed JSON.");
|
|
78
72
|
} finally {
|
|
79
73
|
await release();
|
|
80
74
|
}
|
|
@@ -138,7 +132,8 @@ function verifyTwilioProviderWebhook(params) {
|
|
|
138
132
|
ok: result.ok,
|
|
139
133
|
reason: result.reason,
|
|
140
134
|
isReplay: result.isReplay,
|
|
141
|
-
verifiedRequestKey: result.verifiedRequestKey
|
|
135
|
+
verifiedRequestKey: result.verifiedRequestKey,
|
|
136
|
+
releaseReplay: result.releaseReplay
|
|
142
137
|
};
|
|
143
138
|
}
|
|
144
139
|
//#endregion
|
|
@@ -172,17 +167,20 @@ var TwilioProvider = class TwilioProvider {
|
|
|
172
167
|
this.notifyCalls.delete(callId);
|
|
173
168
|
}
|
|
174
169
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* This is used when we only have `providerCallId` (e.g. hangup).
|
|
170
|
+
* Release all process-local metadata owned by one Twilio call.
|
|
171
|
+
* Terminal webhooks can be replayed, so this must stay idempotent.
|
|
178
172
|
*/
|
|
179
|
-
|
|
173
|
+
releaseCallState(providerCallId, callId) {
|
|
180
174
|
const webhookUrl = this.callWebhookUrls.get(providerCallId);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
175
|
+
let resolvedCallId = callId;
|
|
176
|
+
if (!resolvedCallId && webhookUrl) try {
|
|
177
|
+
resolvedCallId = new URL(webhookUrl).searchParams.get("callId") || void 0;
|
|
178
|
+
} catch {}
|
|
179
|
+
if (resolvedCallId) this.deleteStoredTwiml(resolvedCallId);
|
|
180
|
+
this.callWebhookUrls.delete(providerCallId);
|
|
181
|
+
this.callStreamMap.delete(providerCallId);
|
|
185
182
|
this.streamAuthTokens.delete(providerCallId);
|
|
183
|
+
this.activeStreamCalls.delete(providerCallId);
|
|
186
184
|
}
|
|
187
185
|
constructor(config, options = {}) {
|
|
188
186
|
this.name = "twilio";
|
|
@@ -190,6 +188,7 @@ var TwilioProvider = class TwilioProvider {
|
|
|
190
188
|
this.currentPublicUrl = null;
|
|
191
189
|
this.ttsProvider = null;
|
|
192
190
|
this.mediaStreamHandler = null;
|
|
191
|
+
this.playbackMarkSequence = 0;
|
|
193
192
|
this.callStreamMap = /* @__PURE__ */ new Map();
|
|
194
193
|
this.streamAuthTokens = /* @__PURE__ */ new Map();
|
|
195
194
|
this.twimlStorage = /* @__PURE__ */ new Map();
|
|
@@ -263,15 +262,16 @@ var TwilioProvider = class TwilioProvider {
|
|
|
263
262
|
});
|
|
264
263
|
}
|
|
265
264
|
async updateLiveCallTwiml(providerCallId, twiml, operation) {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
265
|
+
await retryAsync(() => this.apiRequest(`/Calls/${providerCallId}.json`, { Twiml: twiml }), {
|
|
266
|
+
attempts: TWILIO_CALL_UPDATE_RETRY_DELAYS_MS.length + 1,
|
|
267
|
+
minDelayMs: 0,
|
|
268
|
+
shouldRetry: isTwilioCallNotInProgressError,
|
|
269
|
+
delayMs: ({ attempt }) => TWILIO_CALL_UPDATE_RETRY_DELAYS_MS[attempt - 1] ?? 0,
|
|
270
|
+
onRetry: ({ delayMs }) => {
|
|
271
|
+
console.warn(`[voice-call] Twilio ${operation} update hit call state race (21220); retrying in ${delayMs}ms`);
|
|
272
|
+
},
|
|
273
|
+
sleep: (delayMs) => sleepWithAbort(delayMs)
|
|
274
|
+
});
|
|
275
275
|
}
|
|
276
276
|
/**
|
|
277
277
|
* Verify Twilio webhook signature using HMAC-SHA1.
|
|
@@ -303,6 +303,7 @@ var TwilioProvider = class TwilioProvider {
|
|
|
303
303
|
dedupeKey,
|
|
304
304
|
turnToken: turnTokenFromQuery
|
|
305
305
|
});
|
|
306
|
+
if (event?.direction === "inbound" && event.type !== "call.ended" && event.providerCallId && this.currentPublicUrl && !this.callWebhookUrls.has(event.providerCallId)) this.callWebhookUrls.set(event.providerCallId, this.currentPublicUrl);
|
|
306
307
|
const twiml = this.generateTwimlResponse(ctx);
|
|
307
308
|
return {
|
|
308
309
|
events: event ? [event] : [],
|
|
@@ -347,7 +348,7 @@ var TwilioProvider = class TwilioProvider {
|
|
|
347
348
|
to: params.get("To") || void 0
|
|
348
349
|
};
|
|
349
350
|
const speechResult = params.get("SpeechResult");
|
|
350
|
-
if (speechResult) return {
|
|
351
|
+
if (speechResult?.trim()) return {
|
|
351
352
|
...baseEvent,
|
|
352
353
|
type: "call.speech",
|
|
353
354
|
transcript: speechResult,
|
|
@@ -375,14 +376,13 @@ var TwilioProvider = class TwilioProvider {
|
|
|
375
376
|
};
|
|
376
377
|
const endReason = mapProviderStatusToEndReason(callStatus);
|
|
377
378
|
if (endReason) {
|
|
378
|
-
|
|
379
|
-
this.activeStreamCalls.delete(callSid);
|
|
380
|
-
if (callIdOverride) this.deleteStoredTwiml(callIdOverride);
|
|
381
|
-
return {
|
|
379
|
+
const event = {
|
|
382
380
|
...baseEvent,
|
|
383
381
|
type: "call.ended",
|
|
384
382
|
reason: endReason
|
|
385
383
|
};
|
|
384
|
+
this.releaseCallState(callSid, callIdOverride);
|
|
385
|
+
return event;
|
|
386
386
|
}
|
|
387
387
|
return null;
|
|
388
388
|
}
|
|
@@ -524,11 +524,8 @@ var TwilioProvider = class TwilioProvider {
|
|
|
524
524
|
* Hang up a call via Twilio API.
|
|
525
525
|
*/
|
|
526
526
|
async hangupCall(input) {
|
|
527
|
-
this.deleteStoredTwimlForProviderCall(input.providerCallId);
|
|
528
|
-
this.callWebhookUrls.delete(input.providerCallId);
|
|
529
|
-
this.streamAuthTokens.delete(input.providerCallId);
|
|
530
|
-
this.activeStreamCalls.delete(input.providerCallId);
|
|
531
527
|
await this.apiRequest(`/Calls/${input.providerCallId}.json`, { Status: "completed" }, { allowNotFound: true });
|
|
528
|
+
this.releaseCallState(input.providerCallId, input.callId);
|
|
532
529
|
}
|
|
533
530
|
/**
|
|
534
531
|
* Play TTS audio via Twilio.
|
|
@@ -593,10 +590,6 @@ var TwilioProvider = class TwilioProvider {
|
|
|
593
590
|
const raw = handler.sendAudio(streamSid, audio);
|
|
594
591
|
return normalizeSendResult(raw);
|
|
595
592
|
};
|
|
596
|
-
const sendPlaybackMark = (name) => {
|
|
597
|
-
const raw = handler.sendMark(streamSid, name);
|
|
598
|
-
return normalizeSendResult(raw);
|
|
599
|
-
};
|
|
600
593
|
await handler.queueTts(streamSid, async (signal) => {
|
|
601
594
|
const sendKeepAlive = () => {
|
|
602
595
|
sendAudioChunk(SILENCE_CHUNK);
|
|
@@ -607,6 +600,7 @@ var TwilioProvider = class TwilioProvider {
|
|
|
607
600
|
}, CHUNK_DELAY_MS);
|
|
608
601
|
let muLawAudio;
|
|
609
602
|
let synthTimeout = null;
|
|
603
|
+
let removeAbortListener = () => {};
|
|
610
604
|
const synthTimeoutMs = ttsProvider.synthesisTimeoutMs;
|
|
611
605
|
try {
|
|
612
606
|
const synthPromise = ttsProvider.synthesizeForTelephony(text);
|
|
@@ -615,10 +609,23 @@ var TwilioProvider = class TwilioProvider {
|
|
|
615
609
|
reject(/* @__PURE__ */ new Error(`Telephony TTS synthesis timed out after ${synthTimeoutMs}ms`));
|
|
616
610
|
}, synthTimeoutMs);
|
|
617
611
|
});
|
|
618
|
-
|
|
612
|
+
const abortPromise = new Promise((_, reject) => {
|
|
613
|
+
const onAbort = () => {
|
|
614
|
+
reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("Telephony TTS synthesis aborted"));
|
|
615
|
+
};
|
|
616
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
617
|
+
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
|
618
|
+
if (signal.aborted) onAbort();
|
|
619
|
+
});
|
|
620
|
+
muLawAudio = await Promise.race([
|
|
621
|
+
synthPromise,
|
|
622
|
+
timeoutPromise,
|
|
623
|
+
abortPromise
|
|
624
|
+
]);
|
|
619
625
|
} finally {
|
|
620
626
|
if (synthTimeout) clearTimeout(synthTimeout);
|
|
621
627
|
clearInterval(keepAlive);
|
|
628
|
+
removeAbortListener();
|
|
622
629
|
}
|
|
623
630
|
if (muLawAudio.length === 0) throw new Error("Telephony TTS produced no audio");
|
|
624
631
|
let chunkAttempts = 0;
|
|
@@ -627,7 +634,11 @@ var TwilioProvider = class TwilioProvider {
|
|
|
627
634
|
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
|
|
628
635
|
if (signal.aborted) break;
|
|
629
636
|
chunkAttempts += 1;
|
|
630
|
-
if (sendAudioChunk(chunk).sent)
|
|
637
|
+
if (!sendAudioChunk(chunk).sent) {
|
|
638
|
+
handler.clearAudio(streamSid);
|
|
639
|
+
throw new Error(`Telephony stream playback failed: audio chunk ${chunkAttempts} not delivered`);
|
|
640
|
+
}
|
|
641
|
+
chunkDelivered += 1;
|
|
631
642
|
const waitMs = nextChunkDueAt - Date.now();
|
|
632
643
|
if (waitMs > 0) try {
|
|
633
644
|
await sleepWithAbort(Math.ceil(waitMs), signal);
|
|
@@ -638,14 +649,10 @@ var TwilioProvider = class TwilioProvider {
|
|
|
638
649
|
nextChunkDueAt += CHUNK_DELAY_MS;
|
|
639
650
|
if (signal.aborted) break;
|
|
640
651
|
}
|
|
641
|
-
|
|
642
|
-
if (
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
if (chunkDelivered === 0) failures.push("no audio chunks delivered");
|
|
646
|
-
if (!markSent) failures.push("completion mark not delivered");
|
|
647
|
-
throw new Error(`Telephony stream playback failed: ${failures.join("; ")}`);
|
|
648
|
-
}
|
|
652
|
+
if (signal.aborted) return;
|
|
653
|
+
if (chunkAttempts === 0 || chunkDelivered !== chunkAttempts) throw new Error("Telephony stream playback failed: incomplete audio delivery");
|
|
654
|
+
const markName = `tts-${Date.now()}-${++this.playbackMarkSequence}`;
|
|
655
|
+
await handler.sendMarkAndWait(streamSid, markName, muLawAudio.length / 8, signal);
|
|
649
656
|
});
|
|
650
657
|
}
|
|
651
658
|
/**
|
|
@@ -10,23 +10,37 @@ function createWebhookReplayCache() {
|
|
|
10
10
|
};
|
|
11
11
|
}
|
|
12
12
|
function pruneWebhookReplayCache(cache, now) {
|
|
13
|
-
for (const [key,
|
|
13
|
+
for (const [key, reservation] of cache.seenUntil) if (!isFutureDateTimestampMs(reservation.expiresAt, { nowMs: now })) cache.seenUntil.delete(key);
|
|
14
14
|
while (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
|
|
15
15
|
const oldest = cache.seenUntil.keys().next().value;
|
|
16
16
|
if (!oldest) break;
|
|
17
17
|
cache.seenUntil.delete(oldest);
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
function
|
|
20
|
+
function reserveWebhookReplay(cache, replayKey) {
|
|
21
21
|
const now = Date.now();
|
|
22
22
|
cache.calls += 1;
|
|
23
23
|
if (cache.calls % REPLAY_CACHE_PRUNE_INTERVAL === 0) pruneWebhookReplayCache(cache, now);
|
|
24
24
|
const existing = cache.seenUntil.get(replayKey);
|
|
25
|
-
if (existing !== void 0 && isFutureDateTimestampMs(existing, { nowMs: now })) return
|
|
25
|
+
if (existing !== void 0 && isFutureDateTimestampMs(existing.expiresAt, { nowMs: now })) return {
|
|
26
|
+
isReplay: true,
|
|
27
|
+
verifiedRequestKey: replayKey
|
|
28
|
+
};
|
|
26
29
|
const expiresAt = resolveExpiresAtMsFromDurationMs(REPLAY_WINDOW_MS, { nowMs: now });
|
|
27
|
-
if (expiresAt
|
|
30
|
+
if (expiresAt === void 0) return {
|
|
31
|
+
isReplay: false,
|
|
32
|
+
verifiedRequestKey: replayKey
|
|
33
|
+
};
|
|
34
|
+
const reservation = { expiresAt };
|
|
35
|
+
cache.seenUntil.set(replayKey, reservation);
|
|
28
36
|
if (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) pruneWebhookReplayCache(cache, now);
|
|
29
|
-
return
|
|
37
|
+
return {
|
|
38
|
+
isReplay: false,
|
|
39
|
+
verifiedRequestKey: replayKey,
|
|
40
|
+
releaseReplay: () => {
|
|
41
|
+
if (cache.seenUntil.get(replayKey) === reservation) cache.seenUntil.delete(replayKey);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
30
44
|
}
|
|
31
45
|
//#endregion
|
|
32
|
-
export {
|
|
46
|
+
export { reserveWebhookReplay as n, createWebhookReplayCache as t };
|