@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.
@@ -1,2 +1,2 @@
1
- import { t as createVoiceCallRuntime } from "./runtime-entry-D0UJiLfi.js";
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 asObject(providersValue);
14
- const providers = asObject(providersValue) ?? {};
15
- const existing = asObject(providers[providerId]) ?? {};
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 = asObject(params.value) ?? {};
27
- const realtime = asObject(raw.realtime);
28
- const realtimeAgentContext = asObject(realtime?.agentContext);
29
- const twilio = asObject(raw.twilio);
30
- const streaming = asObject(raw.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 = getString(streaming, "openaiApiKey");
26
+ const streamingOpenAIApiKey = readStringField(streaming, "openaiApiKey");
34
27
  if (streamingOpenAIApiKey) legacyStreamingOpenAICompat.apiKey = streamingOpenAIApiKey;
35
- const streamingSttModel = getString(streaming, "sttModel");
28
+ const streamingSttModel = readStringField(streaming, "sttModel");
36
29
  if (streamingSttModel) legacyStreamingOpenAICompat.model = streamingSttModel;
37
- const streamingSilenceDurationMs = getNumber(streaming, "silenceDurationMs");
30
+ const streamingSilenceDurationMs = asFiniteNumber(streaming?.silenceDurationMs);
38
31
  if (streamingSilenceDurationMs !== void 0) legacyStreamingOpenAICompat.silenceDurationMs = streamingSilenceDurationMs;
39
- const streamingVadThreshold = getNumber(streaming, "vadThreshold");
32
+ const streamingVadThreshold = asFiniteNumber(streaming?.vadThreshold);
40
33
  if (streamingVadThreshold !== void 0) legacyStreamingOpenAICompat.vadThreshold = streamingVadThreshold;
41
- const streamingProvider = getString(streaming, "provider");
42
- const legacyStreamingProvider = getString(streaming, "sttProvider");
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 (getNumber(streaming, "silenceDurationMs") !== void 0) changes.push(`Moved ${configPathPrefix}.streaming.silenceDurationMs → ${configPathPrefix}.streaming.providers.openai.silenceDurationMs.`);
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 (getNumber(streaming, "vadThreshold") !== void 0) changes.push(`Moved ${configPathPrefix}.streaming.vadThreshold → ${configPathPrefix}.streaming.providers.openai.vadThreshold.`);
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
- if (countCallRecordChunks(call) <= 48) return call;
241
- const transcriptEntries = call.transcript.length;
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
- ...call.metadata,
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
- ...call,
325
+ ...boundedCall,
270
326
  ...candidateInput
271
327
  });
272
328
  if (countCallRecordChunks(candidate) <= 48) return candidate;
273
329
  }
274
- return call;
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) callMap.set(call.callId, call);
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
- for (const eventId of call.processedEventIds) processedEventIds.add(eventId);
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 { TerminalStates as _, CALL_RECORD_EVENT_META_MAX_ENTRIES as a, buildVoiceCallLegacyJsonlEventKey as c, loadActiveCallsFromStore as d, parseVoiceCallRecordLine as f, setVoiceCallStateRuntime 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 };
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-DS9sZa3C.js";
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": return {
125
- ...baseEvent,
126
- type: "call.speech",
127
- transcript: data.payload?.transcription_data?.transcript ?? data.payload?.transcription ?? "",
128
- isFinal: data.payload?.transcription_data?.is_final ?? data.payload?.is_final ?? true,
129
- confidence: data.payload?.transcription_data?.confidence ?? data.payload?.confidence
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-D0UJiLfi.js";
5
- import { c as verifyTwilioWebhook, i as readProviderJsonResponseText, n as cancelProviderResponseBody, r as readProviderErrorResponseSnippet, t as guardedJsonApiRequest } from "./guarded-json-api-DS9sZa3C.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-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 { setTimeout as setTimeout$1 } from "node:timers/promises";
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
- const text = await readProviderJsonResponseText(response);
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
- * Delete stored TwiML for a call, addressed by Twilio's provider call SID.
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
- deleteStoredTwimlForProviderCall(providerCallId) {
173
+ releaseCallState(providerCallId, callId) {
180
174
  const webhookUrl = this.callWebhookUrls.get(providerCallId);
181
- if (!webhookUrl) return;
182
- const callId = webhookUrl.match(/callId=([^&]+)/)?.[1];
183
- if (!callId) return;
184
- this.deleteStoredTwiml(callId);
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
- for (const retryDelayMs of TWILIO_CALL_UPDATE_RETRY_DELAYS_MS) try {
267
- await this.apiRequest(`/Calls/${providerCallId}.json`, { Twiml: twiml });
268
- return;
269
- } catch (err) {
270
- if (!isTwilioCallNotInProgressError(err)) throw err;
271
- console.warn(`[voice-call] Twilio ${operation} update hit call state race (21220); retrying in ${retryDelayMs}ms`);
272
- await setTimeout$1(retryDelayMs);
273
- }
274
- await this.apiRequest(`/Calls/${providerCallId}.json`, { Twiml: twiml });
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
- this.streamAuthTokens.delete(callSid);
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
- muLawAudio = await Promise.race([synthPromise, timeoutPromise]);
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) chunkDelivered += 1;
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
- let markSent = true;
642
- if (!signal.aborted) markSent = sendPlaybackMark(`tts-${Date.now()}`).sent;
643
- if (!signal.aborted && chunkAttempts > 0 && (chunkDelivered === 0 || !markSent)) {
644
- const failures = [];
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, expiresAt] of cache.seenUntil) if (!isFutureDateTimestampMs(expiresAt, { nowMs: now })) cache.seenUntil.delete(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 markWebhookReplay(cache, replayKey) {
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 true;
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 !== void 0) cache.seenUntil.set(replayKey, 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 false;
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 { markWebhookReplay as n, createWebhookReplayCache as t };
46
+ export { reserveWebhookReplay as n, createWebhookReplayCache as t };