@openclaw/voice-call 2026.6.8 → 2026.6.9

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,6 +1,6 @@
1
1
  import { fetchWithSsrFGuard } from "./runtime-api.js";
2
2
  import "./api.js";
3
- import { a as getHeader } from "./runtime-entry-DeDXZz2k.js";
3
+ import { a as getHeader } from "./runtime-entry-B8fUMehO.js";
4
4
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
5
5
  import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
6
6
  import { isFutureDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
@@ -560,6 +560,61 @@ function verifyPlivoWebhook(ctx, authToken, options) {
560
560
  };
561
561
  }
562
562
  //#endregion
563
+ //#region extensions/voice-call/src/providers/shared/response-body.ts
564
+ const PROVIDER_JSON_RESPONSE_MAX_BYTES = 1 * 1024 * 1024;
565
+ const PROVIDER_ERROR_RESPONSE_MAX_BYTES = 8 * 1024;
566
+ const TRUNCATED_SUFFIX = "... [truncated]";
567
+ async function cancelProviderResponseBody(response) {
568
+ await response.body?.cancel().catch(() => void 0);
569
+ }
570
+ function appendTruncatedSuffix(text) {
571
+ return `${text.trimEnd()}${TRUNCATED_SUFFIX}`;
572
+ }
573
+ async function readProviderResponseTextWithLimit(params) {
574
+ if (!params.response.body) return "";
575
+ const reader = params.response.body.getReader();
576
+ const decoder = new TextDecoder();
577
+ let totalBytes = 0;
578
+ let text = "";
579
+ try {
580
+ while (true) {
581
+ const { done, value } = await reader.read();
582
+ if (done) return text + decoder.decode();
583
+ if (!value?.byteLength) continue;
584
+ const remainingBytes = params.maxBytes - totalBytes;
585
+ if (value.byteLength > remainingBytes) {
586
+ if (params.truncateOnLimit) {
587
+ const clipped = remainingBytes > 0 ? value.slice(0, remainingBytes) : void 0;
588
+ if (clipped) text += decoder.decode(clipped, { stream: true });
589
+ await reader.cancel().catch(() => void 0);
590
+ return appendTruncatedSuffix(text + decoder.decode());
591
+ }
592
+ await reader.cancel().catch(() => void 0);
593
+ throw new Error(`provider response body too large: ${totalBytes + value.byteLength} bytes (limit: ${params.maxBytes} bytes)`);
594
+ }
595
+ text += decoder.decode(value, { stream: true });
596
+ totalBytes += value.byteLength;
597
+ }
598
+ } finally {
599
+ try {
600
+ reader.releaseLock();
601
+ } catch {}
602
+ }
603
+ }
604
+ async function readProviderJsonResponseText(response) {
605
+ return await readProviderResponseTextWithLimit({
606
+ response,
607
+ maxBytes: PROVIDER_JSON_RESPONSE_MAX_BYTES
608
+ });
609
+ }
610
+ async function readProviderErrorResponseSnippet(response) {
611
+ return await readProviderResponseTextWithLimit({
612
+ response,
613
+ maxBytes: PROVIDER_ERROR_RESPONSE_MAX_BYTES,
614
+ truncateOnLimit: true
615
+ });
616
+ }
617
+ //#endregion
563
618
  //#region extensions/voice-call/src/providers/shared/guarded-json-api.ts
564
619
  /** Send a provider JSON request through the SSRF guard and parse bounded JSON responses. */
565
620
  async function guardedJsonApiRequest(params) {
@@ -575,11 +630,14 @@ async function guardedJsonApiRequest(params) {
575
630
  });
576
631
  try {
577
632
  if (!response.ok) {
578
- if (params.allowNotFound && response.status === 404) return;
579
- const errorText = await response.text();
633
+ if (params.allowNotFound && response.status === 404) {
634
+ await cancelProviderResponseBody(response);
635
+ return;
636
+ }
637
+ const errorText = await readProviderErrorResponseSnippet(response);
580
638
  throw new Error(`${params.errorPrefix}: ${response.status} ${errorText}`);
581
639
  }
582
- const text = await response.text();
640
+ const text = await readProviderJsonResponseText(response);
583
641
  if (!text) return;
584
642
  try {
585
643
  return JSON.parse(text);
@@ -591,4 +649,4 @@ async function guardedJsonApiRequest(params) {
591
649
  }
592
650
  }
593
651
  //#endregion
594
- export { verifyTwilioWebhook as a, verifyTelnyxWebhook as i, reconstructWebhookUrl as n, verifyPlivoWebhook as r, guardedJsonApiRequest as t };
652
+ export { reconstructWebhookUrl as a, verifyTwilioWebhook as c, readProviderJsonResponseText as i, cancelProviderResponseBody as n, verifyPlivoWebhook as o, readProviderErrorResponseSnippet as r, verifyTelnyxWebhook as s, guardedJsonApiRequest as t };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { definePluginEntry, sleep } from "./runtime-api.js";
2
2
  import "./api.js";
3
3
  import { i as resolveVoiceCallConfig, s as validateProviderConfig } from "./config-jaA6vOZ2.js";
4
- import { c as getTailscaleSelfInfo, l as setupTailscaleExposureRoute, o as resolveWebhookExposureStatus, p as resolveUserPath, s as cleanupTailscaleExposureRoute, t as createVoiceCallRuntime } from "./runtime-entry-DeDXZz2k.js";
4
+ import { c as getTailscaleSelfInfo, l as setupTailscaleExposureRoute, o as resolveWebhookExposureStatus, p as resolveUserPath, s as cleanupTailscaleExposureRoute, t as createVoiceCallRuntime } from "./runtime-entry-B8fUMehO.js";
5
5
  import { c as getCallHistoryFromStore, m as setVoiceCallStateRuntime } from "./store-8M2m4Isq.js";
6
6
  import { i as parseVoiceCallPluginConfig, r as normalizeVoiceCallLegacyConfigInput, t as formatVoiceCallLegacyConfigWarnings } from "./config-compat-BPPFhsJ7.js";
7
7
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
@@ -1,5 +1,5 @@
1
- import { a as getHeader, m as escapeXml } from "./runtime-entry-DeDXZz2k.js";
2
- import { n as reconstructWebhookUrl, r as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-tNxwxQt0.js";
1
+ import { a as getHeader, m as escapeXml } from "./runtime-entry-B8fUMehO.js";
2
+ import { a as reconstructWebhookUrl, o as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-DHj6sSuY.js";
3
3
  import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
4
4
  import crypto from "node:crypto";
5
5
  //#region extensions/voice-call/src/providers/plivo.ts
@@ -1,5 +1,5 @@
1
1
  import { o as resolveVoiceCallSessionKey } from "./config-jaA6vOZ2.js";
2
- import { f as resolveVoiceResponseModel } from "./runtime-entry-DeDXZz2k.js";
2
+ import { f as resolveVoiceResponseModel } from "./runtime-entry-B8fUMehO.js";
3
3
  import { isRecord, normalizeLowercaseStringOrEmpty, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
4
4
  import crypto from "node:crypto";
5
5
  import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime";
@@ -107,6 +107,16 @@ function startMaxDurationTimer(params) {
107
107
  }, maxDurationMs);
108
108
  params.ctx.maxDurationTimers.set(params.callId, timer);
109
109
  }
110
+ /** Backfill max-duration enforcement from the first live conversation signal. */
111
+ function ensureMaxDurationTimerForLiveCall(params) {
112
+ if (params.call.answeredAt) return;
113
+ params.call.answeredAt = params.liveAt;
114
+ startMaxDurationTimer({
115
+ ctx: params.ctx,
116
+ callId: params.call.callId,
117
+ onTimeout: params.onTimeout
118
+ });
119
+ }
110
120
  /** Clear and forget a pending final-transcript waiter. */
111
121
  function clearTranscriptWaiter(ctx, callId) {
112
122
  const waiter = ctx.transcriptWaiters.get(callId);
@@ -418,6 +428,14 @@ async function speak(ctx, callId, text) {
418
428
  };
419
429
  const { call, providerCallId, provider } = connected;
420
430
  try {
431
+ ensureMaxDurationTimerForLiveCall({
432
+ ctx,
433
+ call,
434
+ liveAt: Date.now(),
435
+ onTimeout: async (id) => {
436
+ await endCall(ctx, id, { reason: "timeout" });
437
+ }
438
+ });
421
439
  transitionState(call, "speaking");
422
440
  persistCallRecord(ctx.storePath, call);
423
441
  const numberRouteKey = typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to;
@@ -795,6 +813,14 @@ function processEvent(ctx, event) {
795
813
  transitionState(call, "active");
796
814
  break;
797
815
  case "call.speaking":
816
+ ensureMaxDurationTimerForLiveCall({
817
+ ctx,
818
+ call,
819
+ liveAt: event.timestamp,
820
+ onTimeout: async (callId) => {
821
+ await endCall(ctx, callId, { reason: "timeout" });
822
+ }
823
+ });
798
824
  transitionState(call, "speaking");
799
825
  break;
800
826
  case "call.speech":
@@ -807,6 +833,14 @@ function processEvent(ctx, event) {
807
833
  }
808
834
  addTranscriptEntry(call, "user", event.transcript);
809
835
  }
836
+ ensureMaxDurationTimerForLiveCall({
837
+ ctx,
838
+ call,
839
+ liveAt: event.timestamp,
840
+ onTimeout: async (callId) => {
841
+ await endCall(ctx, callId, { reason: "timeout" });
842
+ }
843
+ });
810
844
  transitionState(call, "listening");
811
845
  break;
812
846
  case "call.silence":
@@ -857,6 +891,9 @@ function incrementRestoreStatusCount(counts, status) {
857
891
  const key = normalizeOptionalString(status) ?? "terminal";
858
892
  counts.set(key, (counts.get(key) ?? 0) + 1);
859
893
  }
894
+ function resolveRestoredMaxDurationAnchor(call) {
895
+ return call.answeredAt ?? (call.state === "speaking" || call.state === "listening" ? call.startedAt : void 0);
896
+ }
860
897
  function resolveDefaultStoreBase(config, storePath) {
861
898
  const rawOverride = storePath?.trim() || config.store?.trim();
862
899
  if (rawOverride) return resolveUserPath(rawOverride);
@@ -903,24 +940,31 @@ var CallManager = class {
903
940
  this.providerCallIdMap = /* @__PURE__ */ new Map();
904
941
  for (const [callId, call] of verified) if (call.providerCallId) this.providerCallIdMap.set(call.providerCallId, callId);
905
942
  let skippedAlreadyElapsedTimers = 0;
906
- for (const [callId, call] of verified) if (call.answeredAt && !TerminalStates.has(call.state)) {
907
- const elapsed = Date.now() - call.answeredAt;
908
- const maxDurationMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds);
909
- if (elapsed >= maxDurationMs) {
910
- verified.delete(callId);
911
- if (call.providerCallId) this.providerCallIdMap.delete(call.providerCallId);
912
- skippedAlreadyElapsedTimers += 1;
913
- continue;
914
- }
915
- startMaxDurationTimer({
916
- ctx: this.getContext(),
917
- callId,
918
- timeoutMs: maxDurationMs - elapsed,
919
- onTimeout: async (id) => {
920
- await endCall(this.getContext(), id, { reason: "timeout" });
943
+ for (const [callId, call] of verified) {
944
+ const maxDurationAnchor = resolveRestoredMaxDurationAnchor(call);
945
+ if (maxDurationAnchor !== void 0 && !TerminalStates.has(call.state)) {
946
+ const elapsed = Date.now() - maxDurationAnchor;
947
+ const maxDurationMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds);
948
+ if (elapsed >= maxDurationMs) {
949
+ verified.delete(callId);
950
+ if (call.providerCallId) this.providerCallIdMap.delete(call.providerCallId);
951
+ skippedAlreadyElapsedTimers += 1;
952
+ continue;
921
953
  }
922
- });
923
- console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`);
954
+ if (call.answeredAt === void 0) {
955
+ call.answeredAt = maxDurationAnchor;
956
+ persistCallRecord(this.storePath, call);
957
+ }
958
+ startMaxDurationTimer({
959
+ ctx: this.getContext(),
960
+ callId,
961
+ timeoutMs: maxDurationMs - elapsed,
962
+ onTimeout: async (id) => {
963
+ await endCall(this.getContext(), id, { reason: "timeout" });
964
+ }
965
+ });
966
+ console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`);
967
+ }
924
968
  }
925
969
  if (skippedAlreadyElapsedTimers > 0) console.log(`[voice-call] Skipped ${skippedAlreadyElapsedTimers} restored call(s) whose max-duration timer already elapsed`);
926
970
  if (verified.size > 0) console.log(`[voice-call] Restored ${verified.size} active call(s) from store`);
@@ -2290,23 +2334,6 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2290
2334
  }
2291
2335
  this.clearAudio(streamSid);
2292
2336
  }
2293
- /**
2294
- * Get active session by call ID.
2295
- */
2296
- getSessionByCallId(callId) {
2297
- return [...this.sessions.values()].find((session) => session.callId === callId);
2298
- }
2299
- /**
2300
- * Close all sessions.
2301
- */
2302
- closeAll() {
2303
- for (const session of this.sessions.values()) {
2304
- this.clearTtsState(session.streamSid);
2305
- session.sttSession.close();
2306
- session.ws.close();
2307
- }
2308
- this.sessions.clear();
2309
- }
2310
2337
  getTtsQueue(streamSid) {
2311
2338
  const existing = this.ttsQueues.get(streamSid);
2312
2339
  if (existing) return existing;
@@ -2442,6 +2469,11 @@ function isProviderStatusTerminal(status) {
2442
2469
  //#endregion
2443
2470
  //#region extensions/voice-call/src/webhook/stale-call-reaper.ts
2444
2471
  const CHECK_INTERVAL_MS = 3e4;
2472
+ /** States that indicate a live conversation with speech/transcription.
2473
+ * Inbound Twilio calls may never fire a call.answered event, so answeredAt
2474
+ * can be absent even while the call is actively transcribing. These states
2475
+ * prove the call is live and should not be reaped. */
2476
+ const LiveConversationStates = new Set(["speaking", "listening"]);
2445
2477
  /** Start a stale-call reaper and return its cleanup callback. */
2446
2478
  function startStaleCallReaper(params) {
2447
2479
  const maxAgeSeconds = params.staleCallReaperSeconds;
@@ -2450,7 +2482,7 @@ function startStaleCallReaper(params) {
2450
2482
  const interval = setInterval(() => {
2451
2483
  const now = Date.now();
2452
2484
  for (const call of params.manager.getActiveCalls()) {
2453
- if (call.answeredAt || TerminalStates.has(call.state)) continue;
2485
+ if (call.answeredAt || TerminalStates.has(call.state) || LiveConversationStates.has(call.state)) continue;
2454
2486
  const age = now - call.startedAt;
2455
2487
  if (age > maxAgeMs) {
2456
2488
  console.log(`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1e3)}s, state: ${call.state})`);
@@ -2478,7 +2510,7 @@ function loadRealtimeTranscriptionRuntime() {
2478
2510
  return realtimeTranscriptionRuntimePromise;
2479
2511
  }
2480
2512
  function loadResponseGeneratorModule() {
2481
- responseGeneratorModulePromise ??= import("./response-generator-39zOYri_.js");
2513
+ responseGeneratorModulePromise ??= import("./response-generator-D5yL0gt3.js");
2482
2514
  return responseGeneratorModulePromise;
2483
2515
  }
2484
2516
  function sanitizeTranscriptForLog(value) {
@@ -3055,7 +3087,11 @@ var VoiceCallWebhookServer = class {
3055
3087
  try {
3056
3088
  const pathname = buildRequestUrl(req.url).pathname;
3057
3089
  const pattern = this.realtimeHandler?.getStreamPathPattern();
3058
- return Boolean(pattern && pathname.startsWith(pattern));
3090
+ if (!pattern) return false;
3091
+ const normalizedPattern = this.normalizeWebhookPathForMatch(pattern);
3092
+ const normalizedPathname = this.normalizeWebhookPathForMatch(pathname);
3093
+ if (normalizedPattern === "/") return true;
3094
+ return normalizedPathname === normalizedPattern || normalizedPathname.startsWith(`${normalizedPattern}/`);
3059
3095
  } catch {
3060
3096
  return false;
3061
3097
  }
@@ -3162,15 +3198,15 @@ let mockProviderPromise;
3162
3198
  let realtimeVoiceRuntimePromise;
3163
3199
  let realtimeHandlerPromise;
3164
3200
  function loadTelnyxProvider() {
3165
- telnyxProviderPromise ??= import("./telnyx-YbJp3QiY.js");
3201
+ telnyxProviderPromise ??= import("./telnyx-COPVE0cj.js");
3166
3202
  return telnyxProviderPromise;
3167
3203
  }
3168
3204
  function loadTwilioProvider() {
3169
- twilioProviderPromise ??= import("./twilio-DBcVB36L.js");
3205
+ twilioProviderPromise ??= import("./twilio-BD7lW2wp.js");
3170
3206
  return twilioProviderPromise;
3171
3207
  }
3172
3208
  function loadPlivoProvider() {
3173
- plivoProviderPromise ??= import("./plivo-K3Nv0D8o.js");
3209
+ plivoProviderPromise ??= import("./plivo-14MWaE98.js");
3174
3210
  return plivoProviderPromise;
3175
3211
  }
3176
3212
  function loadMockProvider() {
@@ -1,2 +1,2 @@
1
- import { t as createVoiceCallRuntime } from "./runtime-entry-DeDXZz2k.js";
1
+ import { t as createVoiceCallRuntime } from "./runtime-entry-B8fUMehO.js";
2
2
  export { createVoiceCallRuntime };
@@ -1,4 +1,4 @@
1
- import { i as verifyTelnyxWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-tNxwxQt0.js";
1
+ import { s as verifyTelnyxWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-DHj6sSuY.js";
2
2
  import crypto from "node:crypto";
3
3
  //#region extensions/voice-call/src/providers/telnyx.ts
4
4
  function normalizeTelnyxDirection(direction) {
@@ -1,7 +1,7 @@
1
1
  import { fetchWithSsrFGuard } from "./runtime-api.js";
2
2
  import "./api.js";
3
- import { a as getHeader, d as chunkAudio, h as mapVoiceToPolly, i as normalizeProviderStatus, m as escapeXml, n as isProviderStatusTerminal, r as mapProviderStatusToEndReason } from "./runtime-entry-DeDXZz2k.js";
4
- import { a as verifyTwilioWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-tNxwxQt0.js";
3
+ import { a as getHeader, d as chunkAudio, h as mapVoiceToPolly, i as normalizeProviderStatus, m as escapeXml, n as isProviderStatusTerminal, r as mapProviderStatusToEndReason } from "./runtime-entry-B8fUMehO.js";
4
+ import { c as verifyTwilioWebhook, i as readProviderJsonResponseText, n as cancelProviderResponseBody, r as readProviderErrorResponseSnippet, t as guardedJsonApiRequest } from "./guarded-json-api-DHj6sSuY.js";
5
5
  import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
6
6
  import crypto from "node:crypto";
7
7
  import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
@@ -57,11 +57,14 @@ async function twilioApiRequest(params) {
57
57
  });
58
58
  try {
59
59
  if (!response.ok) {
60
- if (params.allowNotFound && response.status === 404) return;
61
- const errorText = await response.text();
60
+ if (params.allowNotFound && response.status === 404) {
61
+ await cancelProviderResponseBody(response);
62
+ return;
63
+ }
64
+ const errorText = await readProviderErrorResponseSnippet(response);
62
65
  throw new TwilioApiError(response.status, errorText);
63
66
  }
64
- const text = await response.text();
67
+ const text = await readProviderJsonResponseText(response);
65
68
  if (!text) return;
66
69
  try {
67
70
  return JSON.parse(text);
@@ -202,9 +205,6 @@ var TwilioProvider = class TwilioProvider {
202
205
  setPublicUrl(url) {
203
206
  this.currentPublicUrl = url;
204
207
  }
205
- getPublicUrl() {
206
- return this.currentPublicUrl;
207
- }
208
208
  setTTSProvider(provider) {
209
209
  this.ttsProvider = provider;
210
210
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.6.8",
3
+ "version": "2026.6.9",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@openclaw/voice-call",
9
- "version": "2026.6.8",
9
+ "version": "2026.6.9",
10
10
  "dependencies": {
11
11
  "commander": "14.0.3",
12
12
  "typebox": "1.1.39",
@@ -14,7 +14,7 @@
14
14
  "zod": "4.4.3"
15
15
  },
16
16
  "peerDependencies": {
17
- "openclaw": ">=2026.6.8"
17
+ "openclaw": ">=2026.6.9"
18
18
  },
19
19
  "peerDependenciesMeta": {
20
20
  "openclaw": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.6.8",
3
+ "version": "2026.6.9",
4
4
  "description": "OpenClaw voice-call plugin for Twilio, Telnyx, and Plivo phone calls.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,7 +14,7 @@
14
14
  "zod": "4.4.3"
15
15
  },
16
16
  "peerDependencies": {
17
- "openclaw": ">=2026.6.8"
17
+ "openclaw": ">=2026.6.9"
18
18
  },
19
19
  "peerDependenciesMeta": {
20
20
  "openclaw": {
@@ -31,10 +31,10 @@
31
31
  "minHostVersion": ">=2026.4.10"
32
32
  },
33
33
  "compat": {
34
- "pluginApi": ">=2026.6.8"
34
+ "pluginApi": ">=2026.6.9"
35
35
  },
36
36
  "build": {
37
- "openclawVersion": "2026.6.8"
37
+ "openclawVersion": "2026.6.9"
38
38
  },
39
39
  "release": {
40
40
  "publishToClawHub": true,