@openclaw/voice-call 2026.5.12 → 2026.5.14-beta.2

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.
@@ -611,7 +611,7 @@ function validateProviderConfig(config) {
611
611
  }
612
612
  if (config.realtime.enabled && config.inboundPolicy === "disabled") errors.push("plugins.entries.voice-call.config.inboundPolicy must not be \"disabled\" when realtime.enabled is true");
613
613
  if (config.realtime.enabled && config.streaming.enabled) errors.push("plugins.entries.voice-call.config.realtime.enabled and plugins.entries.voice-call.config.streaming.enabled cannot both be true");
614
- if (config.realtime.enabled && config.provider && config.provider !== "twilio") errors.push("plugins.entries.voice-call.config.provider must be \"twilio\" when realtime.enabled is true");
614
+ if (config.realtime.enabled && config.provider && config.provider !== "twilio" && config.provider !== "telnyx") errors.push("plugins.entries.voice-call.config.provider must be \"twilio\" or \"telnyx\" when realtime.enabled is true");
615
615
  return {
616
616
  valid: errors.length === 0,
617
617
  errors
@@ -1,4 +1,4 @@
1
- import { t as VoiceCallConfigSchema } from "./config-Beumk4ED.js";
1
+ import { t as VoiceCallConfigSchema } from "./config-C8gX5Cik.js";
2
2
  import { asOptionalRecord, readStringField } from "openclaw/plugin-sdk/string-coerce-runtime";
3
3
  //#region extensions/voice-call/src/config-compat.ts
4
4
  const VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION = "2026.6.0";
@@ -1,6 +1,6 @@
1
1
  import { fetchWithSsrFGuard } from "./runtime-api.js";
2
2
  import "./api.js";
3
- import { t as getHeader } from "./http-headers-B5L5gMpK.js";
3
+ import { a as getHeader } from "./runtime-entry-ox4PGoxT.js";
4
4
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
5
5
  import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
6
6
  import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -577,7 +577,12 @@ async function guardedJsonApiRequest(params) {
577
577
  throw new Error(`${params.errorPrefix}: ${response.status} ${errorText}`);
578
578
  }
579
579
  const text = await response.text();
580
- return text ? JSON.parse(text) : void 0;
580
+ if (!text) return;
581
+ try {
582
+ return JSON.parse(text);
583
+ } catch {
584
+ throw new Error(`${params.errorPrefix}: malformed JSON response`);
585
+ }
581
586
  } finally {
582
587
  await release();
583
588
  }
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { definePluginEntry, sleep } from "./runtime-api.js";
2
2
  import "./api.js";
3
- import { i as resolveVoiceCallConfig, s as validateProviderConfig } from "./config-Beumk4ED.js";
4
- import { a as setupTailscaleExposureRoute, i as getTailscaleSelfInfo, n as resolveWebhookExposureStatus, r as cleanupTailscaleExposureRoute, s as resolveUserPath, t as createVoiceCallRuntime } from "./runtime-entry-CQfEI6TJ.js";
5
- import { i as parseVoiceCallPluginConfig, r as normalizeVoiceCallLegacyConfigInput, t as formatVoiceCallLegacyConfigWarnings } from "./config-compat-BSV2aOY6.js";
3
+ import { i as resolveVoiceCallConfig, s as validateProviderConfig } from "./config-C8gX5Cik.js";
4
+ import { c as getTailscaleSelfInfo, l as setupTailscaleExposureRoute, o as resolveWebhookExposureStatus, p as resolveUserPath, s as cleanupTailscaleExposureRoute, t as createVoiceCallRuntime } from "./runtime-entry-ox4PGoxT.js";
5
+ import { i as parseVoiceCallPluginConfig, r as normalizeVoiceCallLegacyConfigInput, t as formatVoiceCallLegacyConfigWarnings } from "./config-compat-DJqJ8NzH.js";
6
6
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
7
7
  import { ErrorCodes, callGatewayFromCli, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
8
8
  import { normalizeOptionalLowercaseString, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -1,6 +1,5 @@
1
- import { t as escapeXml } from "./voice-mapping-DMm-YvxM.js";
2
- import { t as getHeader } from "./http-headers-B5L5gMpK.js";
3
- import { n as reconstructWebhookUrl, r as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-C5TNOqhE.js";
1
+ import { a as getHeader, m as escapeXml } from "./runtime-entry-ox4PGoxT.js";
2
+ import { n as reconstructWebhookUrl, r as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-D_aM8XmA.js";
4
3
  import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
5
4
  import crypto from "node:crypto";
6
5
  //#region extensions/voice-call/src/providers/plivo.ts
@@ -13,7 +13,7 @@ const DEFAULT_MAX_QUEUED_AUDIO_BYTES = TELEPHONY_SAMPLE_RATE * 120;
13
13
  const PCM16_MAX_AMPLITUDE = 32768;
14
14
  const MULAW_LINEAR_SAMPLES = new Int16Array(256);
15
15
  for (let i = 0; i < MULAW_LINEAR_SAMPLES.length; i += 1) MULAW_LINEAR_SAMPLES[i] = decodeMulawSample(i);
16
- var RealtimeTwilioAudioPacer = class {
16
+ var RealtimeAudioPacer = class {
17
17
  constructor(params) {
18
18
  this.params = params;
19
19
  this.queue = [];
@@ -53,10 +53,7 @@ var RealtimeTwilioAudioPacer = class {
53
53
  this.clearTimer();
54
54
  this.queue = [];
55
55
  this.queuedAudioBytes = 0;
56
- this.params.sendJson({
57
- event: "clear",
58
- streamSid: this.params.streamSid
59
- });
56
+ this.params.send(this.params.serializer.clear());
60
57
  return clearedAudioBytes;
61
58
  }
62
59
  close() {
@@ -86,17 +83,9 @@ var RealtimeTwilioAudioPacer = class {
86
83
  let sent = true;
87
84
  if (item.type === "audio") {
88
85
  this.queuedAudioBytes = Math.max(0, this.queuedAudioBytes - item.chunk.length);
89
- sent = this.params.sendJson({
90
- event: "media",
91
- streamSid: this.params.streamSid,
92
- media: { payload: item.chunk.toString("base64") }
93
- });
86
+ sent = this.params.send(this.params.serializer.media(item.chunk.toString("base64")));
94
87
  delayMs = item.durationMs || TELEPHONY_CHUNK_MS;
95
- } else sent = this.params.sendJson({
96
- event: "mark",
97
- streamSid: this.params.streamSid,
98
- mark: { name: item.name }
99
- });
88
+ } else sent = this.params.send(this.params.serializer.mark(item.name));
100
89
  if (!sent) {
101
90
  this.queue = [];
102
91
  this.queuedAudioBytes = 0;
@@ -148,6 +137,156 @@ function decodeMulawSample(value) {
148
137
  return sign ? -sample : sample;
149
138
  }
150
139
  //#endregion
140
+ //#region extensions/voice-call/src/webhook/stream-frame-adapter.ts
141
+ function parseTimestampMs(value) {
142
+ if (typeof value === "number" && Number.isFinite(value)) return value;
143
+ if (typeof value === "string") {
144
+ const parsed = Number.parseInt(value, 10);
145
+ return Number.isFinite(parsed) ? parsed : void 0;
146
+ }
147
+ }
148
+ function tryParseJson(rawMessage) {
149
+ try {
150
+ const parsed = JSON.parse(rawMessage);
151
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
152
+ } catch {}
153
+ return null;
154
+ }
155
+ function normalizeBase64ForCompare(value) {
156
+ return value.replace(/=+$/u, "").replace(/-/gu, "+").replace(/_/gu, "/");
157
+ }
158
+ function isValidBase64Payload(value) {
159
+ return normalizeBase64ForCompare(Buffer.from(value, "base64").toString("base64")) === normalizeBase64ForCompare(value);
160
+ }
161
+ var TwilioStreamFrameAdapter = class {
162
+ constructor() {
163
+ this.providerName = "twilio";
164
+ this.streamSid = "";
165
+ }
166
+ parseInbound(rawMessage) {
167
+ const msg = tryParseJson(rawMessage);
168
+ if (!msg) return { kind: "ignored" };
169
+ const event = msg.event;
170
+ if (event === "start") {
171
+ const startData = typeof msg.start === "object" && msg.start !== null ? msg.start : void 0;
172
+ const streamSid = typeof startData?.streamSid === "string" ? startData.streamSid : "";
173
+ const callSid = typeof startData?.callSid === "string" ? startData.callSid : "";
174
+ if (!streamSid || !callSid) return { kind: "ignored" };
175
+ this.streamSid = streamSid;
176
+ return {
177
+ kind: "start",
178
+ streamId: streamSid,
179
+ providerCallId: callSid
180
+ };
181
+ }
182
+ if (event === "media") {
183
+ const mediaData = typeof msg.media === "object" && msg.media !== null ? msg.media : void 0;
184
+ const payload = typeof mediaData?.payload === "string" ? mediaData.payload : void 0;
185
+ if (!payload || !isValidBase64Payload(payload)) return { kind: "ignored" };
186
+ return {
187
+ kind: "media",
188
+ payloadBase64: payload,
189
+ timestampMs: parseTimestampMs(mediaData?.timestamp),
190
+ track: typeof mediaData?.track === "string" ? mediaData.track : void 0
191
+ };
192
+ }
193
+ if (event === "mark") {
194
+ const markData = typeof msg.mark === "object" && msg.mark !== null ? msg.mark : void 0;
195
+ return {
196
+ kind: "mark",
197
+ name: typeof markData?.name === "string" ? markData.name : void 0
198
+ };
199
+ }
200
+ if (event === "stop") return { kind: "stop" };
201
+ return { kind: "ignored" };
202
+ }
203
+ serializeMedia(payloadBase64) {
204
+ return JSON.stringify({
205
+ event: "media",
206
+ streamSid: this.streamSid,
207
+ media: { payload: payloadBase64 }
208
+ });
209
+ }
210
+ serializeClear() {
211
+ return JSON.stringify({
212
+ event: "clear",
213
+ streamSid: this.streamSid
214
+ });
215
+ }
216
+ serializeMark(name) {
217
+ return JSON.stringify({
218
+ event: "mark",
219
+ streamSid: this.streamSid,
220
+ mark: { name }
221
+ });
222
+ }
223
+ };
224
+ var TelnyxStreamFrameAdapter = class {
225
+ constructor() {
226
+ this.providerName = "telnyx";
227
+ }
228
+ parseInbound(rawMessage) {
229
+ const msg = tryParseJson(rawMessage);
230
+ if (!msg) return { kind: "ignored" };
231
+ const event = msg.event;
232
+ const topLevelStreamId = typeof msg.stream_id === "string" && msg.stream_id ? msg.stream_id : void 0;
233
+ if (event === "start") {
234
+ const startData = typeof msg.start === "object" && msg.start !== null ? msg.start : void 0;
235
+ const providerCallId = typeof startData?.call_control_id === "string" && startData.call_control_id ? startData.call_control_id : void 0;
236
+ if (!topLevelStreamId || !providerCallId) return { kind: "ignored" };
237
+ return {
238
+ kind: "start",
239
+ streamId: topLevelStreamId,
240
+ providerCallId
241
+ };
242
+ }
243
+ if (event === "media") {
244
+ const mediaData = typeof msg.media === "object" && msg.media !== null ? msg.media : void 0;
245
+ const payload = typeof mediaData?.payload === "string" ? mediaData.payload : void 0;
246
+ if (!payload || !isValidBase64Payload(payload)) return { kind: "ignored" };
247
+ return {
248
+ kind: "media",
249
+ payloadBase64: payload,
250
+ timestampMs: parseTimestampMs(mediaData?.timestamp),
251
+ track: typeof mediaData?.track === "string" ? mediaData.track : void 0
252
+ };
253
+ }
254
+ if (event === "mark") {
255
+ const markData = typeof msg.mark === "object" && msg.mark !== null ? msg.mark : void 0;
256
+ return {
257
+ kind: "mark",
258
+ name: typeof markData?.name === "string" ? markData.name : void 0
259
+ };
260
+ }
261
+ if (event === "stop") return { kind: "stop" };
262
+ if (event === "error") {
263
+ const errorData = typeof msg.payload === "object" && msg.payload !== null ? msg.payload : void 0;
264
+ return {
265
+ kind: "error",
266
+ code: typeof errorData?.code === "string" || typeof errorData?.code === "number" ? String(errorData.code) : void 0,
267
+ title: typeof errorData?.title === "string" ? errorData.title : void 0,
268
+ detail: typeof errorData?.detail === "string" ? errorData.detail : void 0
269
+ };
270
+ }
271
+ return { kind: "ignored" };
272
+ }
273
+ serializeMedia(payloadBase64) {
274
+ return JSON.stringify({
275
+ event: "media",
276
+ media: { payload: payloadBase64 }
277
+ });
278
+ }
279
+ serializeClear() {
280
+ return JSON.stringify({ event: "clear" });
281
+ }
282
+ serializeMark(name) {
283
+ return JSON.stringify({
284
+ event: "mark",
285
+ mark: { name }
286
+ });
287
+ }
288
+ };
289
+ //#endregion
151
290
  //#region extensions/voice-call/src/webhook/realtime-handler.ts
152
291
  const STREAM_TOKEN_TTL_MS = 3e4;
153
292
  const DEFAULT_HOST = "localhost:8443";
@@ -318,23 +457,29 @@ var RealtimeCallHandler = class {
318
457
  return `${this.publicPathPrefix}${normalizePath(this.config.streamPath ?? "/voice/stream/realtime")}`;
319
458
  }
320
459
  buildTwiMLPayload(req, params) {
321
- const host = this.publicOrigin || req.headers.host || DEFAULT_HOST;
322
460
  const rawDirection = params?.get("Direction");
323
- const token = this.issueStreamToken({
324
- from: params?.get("From") ?? void 0,
325
- to: params?.get("To") ?? void 0,
326
- direction: rawDirection?.startsWith("outbound") ? "outbound" : "inbound"
327
- });
328
- return {
329
- statusCode: 200,
330
- headers: { "Content-Type": "text/xml" },
331
- body: `<?xml version="1.0" encoding="UTF-8"?>
461
+ const previousOrigin = this.publicOrigin;
462
+ if (!previousOrigin) this.publicOrigin = req.headers.host ?? DEFAULT_HOST;
463
+ try {
464
+ const { streamUrl } = this.issueStreamSession({
465
+ providerName: "twilio",
466
+ from: params?.get("From") ?? void 0,
467
+ to: params?.get("To") ?? void 0,
468
+ direction: rawDirection?.startsWith("outbound") ? "outbound" : "inbound"
469
+ });
470
+ return {
471
+ statusCode: 200,
472
+ headers: { "Content-Type": "text/xml" },
473
+ body: `<?xml version="1.0" encoding="UTF-8"?>
332
474
  <Response>
333
475
  <Connect>
334
- <Stream url="${`wss://${host}${this.getStreamPathPattern()}/${token}`}" />
476
+ <Stream url="${streamUrl}" />
335
477
  </Connect>
336
478
  </Response>`
337
- };
479
+ };
480
+ } finally {
481
+ this.publicOrigin = previousOrigin;
482
+ }
338
483
  }
339
484
  handleWebSocketUpgrade(request, socket, head) {
340
485
  const token = new URL(request.url ?? "/", "wss://localhost").pathname.split("/").pop() ?? null;
@@ -344,6 +489,7 @@ var RealtimeCallHandler = class {
344
489
  socket.destroy();
345
490
  return;
346
491
  }
492
+ const adapter = (callerMeta.providerName ?? "twilio") === "telnyx" ? new TelnyxStreamFrameAdapter() : new TwilioStreamFrameAdapter();
347
493
  new WebSocketServer({
348
494
  noServer: true,
349
495
  maxPayload: MAX_REALTIME_MESSAGE_BYTES
@@ -356,43 +502,44 @@ var RealtimeCallHandler = class {
356
502
  let lastMediaGapWarnAt = 0;
357
503
  ws.on("message", (data) => {
358
504
  try {
359
- const msg = JSON.parse(data.toString());
360
- if (!initialized && msg.event === "start") {
505
+ const frame = adapter.parseInbound(data.toString());
506
+ if (frame.kind === "ignored") return;
507
+ if (frame.kind === "start") {
508
+ if (initialized) return;
361
509
  initialized = true;
362
- const startData = typeof msg.start === "object" && msg.start !== null ? msg.start : void 0;
363
- const streamSid = typeof startData?.streamSid === "string" ? startData.streamSid : "unknown";
364
- const callSid = typeof startData?.callSid === "string" ? startData.callSid : "unknown";
365
- activeCallSid = callSid;
366
- const nextBridge = this.handleCall(streamSid, callSid, ws, callerMeta);
510
+ activeCallSid = frame.providerCallId;
511
+ const nextBridge = this.handleCall(frame.streamId, frame.providerCallId, ws, callerMeta, adapter);
367
512
  if (!nextBridge) return;
368
513
  bridge = nextBridge;
369
514
  return;
370
515
  }
371
516
  if (!bridge) return;
372
- const mediaData = typeof msg.media === "object" && msg.media !== null ? msg.media : void 0;
373
- if (msg.event === "media" && typeof mediaData?.payload === "string") {
374
- const audio = Buffer.from(mediaData.payload, "base64");
517
+ if (frame.kind === "media") {
518
+ const audio = Buffer.from(frame.payloadBase64, "base64");
375
519
  bridge.sendAudio(audio);
376
- const mediaTimestamp = typeof mediaData.timestamp === "number" ? mediaData.timestamp : typeof mediaData.timestamp === "string" ? Number.parseInt(mediaData.timestamp, 10) : NaN;
377
- if (Number.isFinite(mediaTimestamp)) {
520
+ if (frame.timestampMs !== void 0) {
378
521
  if (lastMediaTimestamp !== void 0) {
379
- const gapMs = mediaTimestamp - lastMediaTimestamp;
522
+ const gapMs = frame.timestampMs - lastMediaTimestamp;
380
523
  const now = Date.now();
381
524
  if ((gapMs > 120 || gapMs < 0) && now - lastMediaGapWarnAt > 5e3) {
382
525
  lastMediaGapWarnAt = now;
383
- console.warn(`[voice-call] realtime media timestamp gap providerCallId=${activeCallSid} gapMs=${gapMs} timestamp=${mediaTimestamp}`);
526
+ console.warn(`[voice-call] realtime media timestamp gap providerCallId=${activeCallSid} gapMs=${gapMs} timestamp=${frame.timestampMs}`);
384
527
  }
385
528
  }
386
- lastMediaTimestamp = mediaTimestamp;
387
- bridge.setMediaTimestamp(mediaTimestamp);
529
+ lastMediaTimestamp = frame.timestampMs;
530
+ bridge.setMediaTimestamp(frame.timestampMs);
388
531
  }
389
532
  return;
390
533
  }
391
- if (msg.event === "mark") {
534
+ if (frame.kind === "mark") {
392
535
  bridge.acknowledgeMark();
393
536
  return;
394
537
  }
395
- if (msg.event === "stop") {
538
+ if (frame.kind === "error") {
539
+ console.error(`[voice-call] realtime WS error frame providerCallId=${activeCallSid} code=${frame.code ?? "?"} title=${frame.title ?? ""} detail=${frame.detail ?? ""}`);
540
+ return;
541
+ }
542
+ if (frame.kind === "stop") {
396
543
  stopReceived = true;
397
544
  this.closeTelephonyBridge(activeCallSid, bridge, "completed");
398
545
  }
@@ -428,6 +575,19 @@ var RealtimeCallHandler = class {
428
575
  };
429
576
  }
430
577
  }
578
+ issueStreamSession(request = {}) {
579
+ const token = this.issueStreamToken({
580
+ providerName: request.providerName ?? "twilio",
581
+ callId: request.callId,
582
+ from: request.from,
583
+ to: request.to,
584
+ direction: request.direction
585
+ });
586
+ return {
587
+ token,
588
+ streamUrl: `wss://${this.publicOrigin || DEFAULT_HOST}${this.getStreamPathPattern()}/${token}`
589
+ };
590
+ }
431
591
  issueStreamToken(meta = {}) {
432
592
  const token = randomUUID();
433
593
  this.pendingStreamTokens.set(token, {
@@ -445,10 +605,12 @@ var RealtimeCallHandler = class {
445
605
  return {
446
606
  from: entry.from,
447
607
  to: entry.to,
448
- direction: entry.direction
608
+ direction: entry.direction,
609
+ providerName: entry.providerName,
610
+ callId: entry.callId
449
611
  };
450
612
  }
451
- handleCall(streamSid, callSid, ws, callerMeta) {
613
+ handleCall(streamSid, callSid, ws, callerMeta, adapter) {
452
614
  const registration = this.registerCallInManager(callSid, callerMeta);
453
615
  if (!registration) {
454
616
  ws.close(1008, "Caller rejected by policy");
@@ -508,14 +670,14 @@ var RealtimeCallHandler = class {
508
670
  callEndEmitted = true;
509
671
  this.endCallInManager(callSid, callId, reason);
510
672
  };
511
- const sendJson = (message) => {
673
+ const sendString = (message) => {
512
674
  if (ws.readyState !== WebSocket.OPEN) return false;
513
675
  if (ws.bufferedAmount > MAX_REALTIME_WS_BUFFERED_BYTES) {
514
676
  console.warn(`[voice-call] realtime outbound websocket backpressure before send callId=${callId} providerCallId=${callSid} bufferedBytes=${ws.bufferedAmount}`);
515
677
  ws.close(1013, "Backpressure: send buffer exceeded");
516
678
  return false;
517
679
  }
518
- ws.send(JSON.stringify(message));
680
+ ws.send(message);
519
681
  if (ws.bufferedAmount > MAX_REALTIME_WS_BUFFERED_BYTES) {
520
682
  console.warn(`[voice-call] realtime outbound websocket backpressure after send callId=${callId} providerCallId=${callSid} bufferedBytes=${ws.bufferedAmount}`);
521
683
  ws.close(1013, "Backpressure: send buffer exceeded");
@@ -523,9 +685,13 @@ var RealtimeCallHandler = class {
523
685
  }
524
686
  return true;
525
687
  };
526
- const audioPacer = new RealtimeTwilioAudioPacer({
527
- streamSid,
528
- sendJson,
688
+ const audioPacer = new RealtimeAudioPacer({
689
+ send: sendString,
690
+ serializer: {
691
+ media: (payload) => adapter.serializeMedia(payload),
692
+ clear: () => adapter.serializeClear(),
693
+ mark: (name) => adapter.serializeMark(name)
694
+ },
529
695
  onBackpressure: () => {
530
696
  console.warn(`[voice-call] realtime paced audio backpressure callId=${callId} providerCallId=${callSid}`);
531
697
  if (ws.readyState === WebSocket.OPEN) ws.close(1013, "Backpressure: paced audio queue exceeded");
@@ -915,20 +1081,14 @@ var RealtimeCallHandler = class {
915
1081
  ...callerMeta.from ? { from: callerMeta.from } : {},
916
1082
  ...callerMeta.to ? { to: callerMeta.to } : {}
917
1083
  };
918
- this.manager.processEvent({
919
- id: `realtime-initiated-${callSid}`,
920
- callId: callSid,
921
- type: "call.initiated",
922
- ...baseFields
923
- });
924
- const callRecord = this.manager.getCallByProviderCallId(callSid);
1084
+ const callRecord = this.resolveRealtimeCall(callSid, callerMeta, baseFields);
925
1085
  if (!callRecord) return null;
926
1086
  const initialGreeting = this.extractInitialGreeting(callRecord);
927
1087
  console.log(`[voice-call] Realtime call ${callRecord.callId} initial greeting ${initialGreeting ? "queued" : "absent"}`);
928
1088
  if (callRecord.metadata) delete callRecord.metadata.initialMessage;
929
1089
  this.manager.processEvent({
930
1090
  id: `realtime-answered-${callSid}`,
931
- callId: callSid,
1091
+ callId: callRecord.callId,
932
1092
  type: "call.answered",
933
1093
  ...baseFields
934
1094
  });
@@ -937,6 +1097,19 @@ var RealtimeCallHandler = class {
937
1097
  initialGreetingInstructions: buildGreetingInstructions(this.config.instructions, initialGreeting)
938
1098
  };
939
1099
  }
1100
+ resolveRealtimeCall(callSid, callerMeta, baseFields) {
1101
+ if (callerMeta.callId) {
1102
+ const call = this.manager.getCall(callerMeta.callId);
1103
+ return call?.providerCallId === callSid ? call : null;
1104
+ }
1105
+ this.manager.processEvent({
1106
+ id: `realtime-initiated-${callSid}`,
1107
+ callId: callSid,
1108
+ type: "call.initiated",
1109
+ ...baseFields
1110
+ });
1111
+ return this.manager.getCallByProviderCallId(callSid) ?? null;
1112
+ }
940
1113
  extractInitialGreeting(call) {
941
1114
  return typeof call.metadata?.initialMessage === "string" ? call.metadata.initialMessage : void 0;
942
1115
  }
@@ -1,5 +1,5 @@
1
- import { o as resolveVoiceCallSessionKey } from "./config-Beumk4ED.js";
2
- import { t as resolveVoiceResponseModel } from "./response-model-CyF5K80p.js";
1
+ import { o as resolveVoiceCallSessionKey } from "./config-C8gX5Cik.js";
2
+ import { f as resolveVoiceResponseModel } from "./runtime-entry-ox4PGoxT.js";
3
3
  import { normalizeLowercaseStringOrEmpty } 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";
@@ -1,14 +1,10 @@
1
1
  import { isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText } from "./runtime-api.js";
2
2
  import "./api.js";
3
- import { a as resolveVoiceCallEffectiveConfig, c as deepMergeDefined, i as resolveVoiceCallConfig, n as normalizeVoiceCallConfig, o as resolveVoiceCallSessionKey, r as resolveTwilioAuthToken, s as validateProviderConfig } from "./config-Beumk4ED.js";
4
- import { n as mapVoiceToPolly, t as escapeXml } from "./voice-mapping-DMm-YvxM.js";
5
- import { t as resolveVoiceResponseModel } from "./response-model-CyF5K80p.js";
6
- import { a as convertPcmToMulaw8k, t as isProviderStatusTerminal } from "./call-status-CuIeqfac.js";
7
- import { t as getHeader } from "./http-headers-B5L5gMpK.js";
3
+ import { a as resolveVoiceCallEffectiveConfig, c as deepMergeDefined, i as resolveVoiceCallConfig, n as normalizeVoiceCallConfig, o as resolveVoiceCallSessionKey, r as resolveTwilioAuthToken, s as validateProviderConfig } from "./config-C8gX5Cik.js";
8
4
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
9
5
  import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
10
- import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
11
- import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, buildRealtimeVoiceAgentConsultPolicyInstructions, consultRealtimeVoiceAgent, createTalkSessionController, recordTalkObservabilityEvent, resolveRealtimeVoiceAgentConsultTools, resolveRealtimeVoiceAgentConsultToolsAllow, resolveRealtimeVoiceFastContextConsult } from "openclaw/plugin-sdk/realtime-voice";
6
+ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
7
+ import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, buildRealtimeVoiceAgentConsultPolicyInstructions, consultRealtimeVoiceAgent, convertPcmToMulaw8k, createTalkSessionController, recordTalkObservabilityEvent, resolveRealtimeVoiceAgentConsultTools, resolveRealtimeVoiceAgentConsultToolsAllow, resolveRealtimeVoiceFastContextConsult } from "openclaw/plugin-sdk/realtime-voice";
12
8
  import { z } from "zod";
13
9
  import fs from "node:fs";
14
10
  import os from "node:os";
@@ -351,6 +347,41 @@ function resolvePreferredTtsVoice(config) {
351
347
  return resolveProviderVoiceSetting(config.tts?.providers?.[providerId]);
352
348
  }
353
349
  //#endregion
350
+ //#region extensions/voice-call/src/voice-mapping.ts
351
+ /**
352
+ * Escape XML special characters for TwiML and other XML responses.
353
+ */
354
+ function escapeXml(text) {
355
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
356
+ }
357
+ /**
358
+ * Map of OpenAI voice names to similar Twilio Polly voices.
359
+ */
360
+ const OPENAI_TO_POLLY_MAP = {
361
+ alloy: "Polly.Joanna",
362
+ echo: "Polly.Matthew",
363
+ fable: "Polly.Amy",
364
+ onyx: "Polly.Brian",
365
+ nova: "Polly.Salli",
366
+ shimmer: "Polly.Kimberly"
367
+ };
368
+ /**
369
+ * Default Polly voice when no mapping is found.
370
+ */
371
+ const DEFAULT_POLLY_VOICE = "Polly.Joanna";
372
+ /**
373
+ * Map OpenAI voice names to Twilio Polly equivalents.
374
+ * Falls through if already a valid Polly/Google voice.
375
+ *
376
+ * @param voice - OpenAI voice name (alloy, echo, etc.) or Polly voice name
377
+ * @returns Polly voice name suitable for Twilio TwiML
378
+ */
379
+ function mapVoiceToPolly(voice) {
380
+ if (!voice) return DEFAULT_POLLY_VOICE;
381
+ if (voice.startsWith("Polly.") || voice.startsWith("Google.")) return voice;
382
+ return OPENAI_TO_POLLY_MAP[normalizeLowercaseStringOrEmpty(voice)] || "Polly.Joanna";
383
+ }
384
+ //#endregion
354
385
  //#region extensions/voice-call/src/manager/twiml.ts
355
386
  function generateNotifyTwiml(message, voice) {
356
387
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -485,13 +516,24 @@ async function initiateCall(ctx, to, sessionKey, options) {
485
516
  preConnectTwiml = generateDtmfRedirectTwiml(dtmfSequence, ctx.webhookUrl);
486
517
  console.log(`[voice-call] Using pre-connect DTMF TwiML for call ${callId} (digits=${dtmfSequence.length}, initialMessage=${initialMessage ? "yes" : "no"})`);
487
518
  }
519
+ const streamSession = ctx.config.realtime?.enabled && ctx.provider.name === "telnyx" && ctx.streamSessionIssuer ? ctx.streamSessionIssuer({
520
+ providerName: "telnyx",
521
+ callId,
522
+ from,
523
+ to,
524
+ direction: "outbound"
525
+ }) : void 0;
488
526
  const result = await ctx.provider.initiateCall({
489
527
  callId,
490
528
  from,
491
529
  to,
492
530
  webhookUrl: ctx.webhookUrl,
493
531
  inlineTwiml,
494
- preConnectTwiml
532
+ preConnectTwiml,
533
+ ...streamSession ? {
534
+ streamUrl: streamSession.streamUrl,
535
+ streamAuthToken: streamSession.token
536
+ } : {}
495
537
  });
496
538
  callRecord.providerCallId = result.providerCallId;
497
539
  ctx.providerCallIdMap.set(result.providerCallId, callId);
@@ -830,13 +872,26 @@ function processEvent(ctx, event) {
830
872
  switch (event.type) {
831
873
  case "call.initiated":
832
874
  transitionState(call, "initiated");
833
- if (call.direction === "inbound" && call.providerCallId && ctx.provider?.answerCall) ctx.provider.answerCall({
834
- callId: call.callId,
835
- providerCallId: call.providerCallId
836
- }).catch((err) => {
837
- const message = formatErrorMessage(err);
838
- console.warn(`[voice-call] Failed to answer inbound call ${call.providerCallId}:`, message);
839
- });
875
+ if (call.direction === "inbound" && call.providerCallId && ctx.provider?.answerCall) {
876
+ const inboundStreamSession = ctx.config.realtime?.enabled && ctx.provider.name === "telnyx" && ctx.streamSessionIssuer ? ctx.streamSessionIssuer({
877
+ providerName: "telnyx",
878
+ callId: call.callId,
879
+ from: call.from,
880
+ to: call.to,
881
+ direction: "inbound"
882
+ }) : void 0;
883
+ ctx.provider.answerCall({
884
+ callId: call.callId,
885
+ providerCallId: call.providerCallId,
886
+ ...inboundStreamSession ? {
887
+ streamUrl: inboundStreamSession.streamUrl,
888
+ streamAuthToken: inboundStreamSession.token
889
+ } : {}
890
+ }).catch((err) => {
891
+ const message = formatErrorMessage(err);
892
+ console.warn(`[voice-call] Failed to answer inbound call ${call.providerCallId}:`, message);
893
+ });
894
+ }
840
895
  break;
841
896
  case "call.ringing":
842
897
  transitionState(call, "ringing");
@@ -1110,7 +1165,8 @@ var CallManager = class {
1110
1165
  initialMessageInFlight: this.initialMessageInFlight,
1111
1166
  onCallAnswered: (call) => {
1112
1167
  this.maybeSpeakInitialMessageOnAnswered(call);
1113
- }
1168
+ },
1169
+ streamSessionIssuer: this.streamSessionIssuer
1114
1170
  };
1115
1171
  }
1116
1172
  /**
@@ -1249,6 +1305,27 @@ async function resolveRealtimeFastContextConsult(params) {
1249
1305
  });
1250
1306
  }
1251
1307
  //#endregion
1308
+ //#region extensions/voice-call/src/response-model.ts
1309
+ function resolveVoiceResponseModel(params) {
1310
+ const modelRef = params.voiceConfig.responseModel ?? `${params.agentRuntime.defaults.provider}/${params.agentRuntime.defaults.model}`;
1311
+ const slashIndex = modelRef.indexOf("/");
1312
+ return {
1313
+ modelRef,
1314
+ provider: slashIndex === -1 ? params.agentRuntime.defaults.provider : modelRef.slice(0, slashIndex),
1315
+ model: slashIndex === -1 ? modelRef : modelRef.slice(slashIndex + 1)
1316
+ };
1317
+ }
1318
+ //#endregion
1319
+ //#region extensions/voice-call/src/telephony-audio.ts
1320
+ /**
1321
+ * Chunk audio buffer into 20ms frames for streaming (8kHz mono mu-law).
1322
+ */
1323
+ function chunkAudio(audio, chunkSize = 160) {
1324
+ return (function* () {
1325
+ for (let i = 0; i < audio.length; i += chunkSize) yield audio.subarray(i, Math.min(i + chunkSize, audio.length));
1326
+ })();
1327
+ }
1328
+ //#endregion
1252
1329
  //#region extensions/voice-call/src/telephony-tts.ts
1253
1330
  const TELEPHONY_DEFAULT_TTS_TIMEOUT_MS = 8e3;
1254
1331
  function createTelephonyTtsProvider(params) {
@@ -1730,6 +1807,14 @@ function resolveWebhookExposureStatus(config) {
1730
1807
  };
1731
1808
  }
1732
1809
  //#endregion
1810
+ //#region extensions/voice-call/src/http-headers.ts
1811
+ function getHeader(headers, name) {
1812
+ const target = normalizeLowercaseStringOrEmpty(name);
1813
+ const value = headers[target] ?? Object.entries(headers).find(([key]) => normalizeLowercaseStringOrEmpty(key) === target)?.[1];
1814
+ if (Array.isArray(value)) return value[0];
1815
+ return value;
1816
+ }
1817
+ //#endregion
1733
1818
  //#region extensions/voice-call/src/media-stream.ts
1734
1819
  const DEFAULT_PRE_START_TIMEOUT_MS = 5e3;
1735
1820
  const DEFAULT_MAX_PENDING_CONNECTIONS = 32;
@@ -1748,6 +1833,14 @@ function normalizeWsMessageData(data) {
1748
1833
  if (Array.isArray(data)) return Buffer.concat(data);
1749
1834
  return Buffer.from(data);
1750
1835
  }
1836
+ function parseTwilioMediaMessage(data) {
1837
+ const raw = normalizeWsMessageData(data);
1838
+ try {
1839
+ return JSON.parse(raw.toString("utf8"));
1840
+ } catch (cause) {
1841
+ throw new Error("Twilio media stream message was malformed JSON", { cause });
1842
+ }
1843
+ }
1751
1844
  /**
1752
1845
  * Manages WebSocket connections for Twilio media streams.
1753
1846
  */
@@ -1823,8 +1916,7 @@ var MediaStreamHandler = class {
1823
1916
  }
1824
1917
  ws.on("message", async (data) => {
1825
1918
  try {
1826
- const raw = normalizeWsMessageData(data);
1827
- const message = JSON.parse(raw.toString("utf8"));
1919
+ const message = parseTwilioMediaMessage(data);
1828
1920
  switch (message.event) {
1829
1921
  case "connected":
1830
1922
  console.log("[MediaStream] Twilio connected");
@@ -2357,6 +2449,25 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2357
2449
  }
2358
2450
  };
2359
2451
  //#endregion
2452
+ //#region extensions/voice-call/src/providers/shared/call-status.ts
2453
+ const TERMINAL_PROVIDER_STATUS_TO_END_REASON = {
2454
+ completed: "completed",
2455
+ failed: "failed",
2456
+ busy: "busy",
2457
+ "no-answer": "no-answer",
2458
+ canceled: "hangup-bot"
2459
+ };
2460
+ function normalizeProviderStatus(status) {
2461
+ const normalized = normalizeOptionalLowercaseString(status);
2462
+ return normalized && normalized.length > 0 ? normalized : "unknown";
2463
+ }
2464
+ function mapProviderStatusToEndReason(status) {
2465
+ return TERMINAL_PROVIDER_STATUS_TO_END_REASON[normalizeProviderStatus(status)] ?? null;
2466
+ }
2467
+ function isProviderStatusTerminal(status) {
2468
+ return mapProviderStatusToEndReason(status) !== null;
2469
+ }
2470
+ //#endregion
2360
2471
  //#region extensions/voice-call/src/webhook/stale-call-reaper.ts
2361
2472
  const CHECK_INTERVAL_MS = 3e4;
2362
2473
  function startStaleCallReaper(params) {
@@ -2390,11 +2501,11 @@ const TRANSCRIPT_LOG_MAX_CHARS = 200;
2390
2501
  let realtimeTranscriptionRuntimePromise;
2391
2502
  let responseGeneratorModulePromise;
2392
2503
  function loadRealtimeTranscriptionRuntime() {
2393
- realtimeTranscriptionRuntimePromise ??= import("./realtime-transcription.runtime-B2h70y2W.js");
2504
+ realtimeTranscriptionRuntimePromise ??= import("./realtime-transcription.runtime-CuOCFrMc.js");
2394
2505
  return realtimeTranscriptionRuntimePromise;
2395
2506
  }
2396
2507
  function loadResponseGeneratorModule() {
2397
- responseGeneratorModulePromise ??= import("./response-generator-splfS1mU.js");
2508
+ responseGeneratorModulePromise ??= import("./response-generator-ZeNpwsyL.js");
2398
2509
  return responseGeneratorModulePromise;
2399
2510
  }
2400
2511
  function sanitizeTranscriptForLog(value) {
@@ -2418,8 +2529,8 @@ function appendRecentTalkEventMetadata(call, event) {
2418
2529
  recentTalkEvents: recent.slice(-10)
2419
2530
  };
2420
2531
  }
2421
- function buildRequestUrl(requestUrl, requestHost, fallbackHost = "localhost") {
2422
- return new URL$1(requestUrl ?? "/", `http://${requestHost ?? fallbackHost}`);
2532
+ function buildRequestUrl(requestUrl) {
2533
+ return new URL$1(requestUrl ?? "/", "http://localhost");
2423
2534
  }
2424
2535
  function normalizeProxyIp(value) {
2425
2536
  const trimmed = value?.trim();
@@ -2745,7 +2856,7 @@ var VoiceCallWebhookServer = class {
2745
2856
  }
2746
2857
  getUpgradePathname(request) {
2747
2858
  try {
2748
- return buildRequestUrl(request.url, request.headers.host).pathname;
2859
+ return buildRequestUrl(request.url).pathname;
2749
2860
  } catch {
2750
2861
  return null;
2751
2862
  }
@@ -2768,7 +2879,7 @@ var VoiceCallWebhookServer = class {
2768
2879
  this.writeWebhookResponse(res, payload);
2769
2880
  }
2770
2881
  async runWebhookPipeline(req, webhookPath) {
2771
- const url = buildRequestUrl(req.url, req.headers.host);
2882
+ const url = buildRequestUrl(req.url);
2772
2883
  if (url.pathname === "/voice/hold-music") return {
2773
2884
  statusCode: 200,
2774
2885
  headers: { "Content-Type": "text/xml" },
@@ -2902,7 +3013,7 @@ var VoiceCallWebhookServer = class {
2902
3013
  }
2903
3014
  isRealtimeWebSocketUpgrade(req) {
2904
3015
  try {
2905
- const pathname = buildRequestUrl(req.url, req.headers.host).pathname;
3016
+ const pathname = buildRequestUrl(req.url).pathname;
2906
3017
  const pattern = this.realtimeHandler?.getStreamPathPattern();
2907
3018
  return Boolean(pattern && pathname.startsWith(pattern));
2908
3019
  } catch {
@@ -3011,27 +3122,27 @@ let mockProviderPromise;
3011
3122
  let realtimeVoiceRuntimePromise;
3012
3123
  let realtimeHandlerPromise;
3013
3124
  function loadTelnyxProvider() {
3014
- telnyxProviderPromise ??= import("./telnyx-DPwKir7b.js");
3125
+ telnyxProviderPromise ??= import("./telnyx-Df3IfYAS.js");
3015
3126
  return telnyxProviderPromise;
3016
3127
  }
3017
3128
  function loadTwilioProvider() {
3018
- twilioProviderPromise ??= import("./twilio-D-QbSGSk.js");
3129
+ twilioProviderPromise ??= import("./twilio-B3zpyWY5.js");
3019
3130
  return twilioProviderPromise;
3020
3131
  }
3021
3132
  function loadPlivoProvider() {
3022
- plivoProviderPromise ??= import("./plivo-AW4Op9oA.js");
3133
+ plivoProviderPromise ??= import("./plivo--HUS8UBT.js");
3023
3134
  return plivoProviderPromise;
3024
3135
  }
3025
3136
  function loadMockProvider() {
3026
- mockProviderPromise ??= import("./mock-0CEjFJi5.js");
3137
+ mockProviderPromise ??= import("./mock-BvTP8Rmx.js");
3027
3138
  return mockProviderPromise;
3028
3139
  }
3029
3140
  function loadRealtimeVoiceRuntime() {
3030
- realtimeVoiceRuntimePromise ??= import("./realtime-voice.runtime-Bkh4nvLn.js");
3141
+ realtimeVoiceRuntimePromise ??= import("./realtime-voice.runtime-BM0lAf0B.js");
3031
3142
  return realtimeVoiceRuntimePromise;
3032
3143
  }
3033
3144
  function loadRealtimeHandler() {
3034
- realtimeHandlerPromise ??= import("./realtime-handler-B_aqJXZj.js");
3145
+ realtimeHandlerPromise ??= import("./realtime-handler-Cj_3954k.js");
3035
3146
  return realtimeHandlerPromise;
3036
3147
  }
3037
3148
  function resolveVoiceCallConsultSessionKey(call) {
@@ -3249,8 +3360,10 @@ async function createVoiceCallRuntime(params) {
3249
3360
  if (!publicUrl && config.tailscale?.mode !== "off") publicUrl = await setupTailscaleExposure(config);
3250
3361
  const webhookUrl = publicUrl ?? localUrl;
3251
3362
  if (providerRequiresPublicWebhook(provider.name) && isProviderUnreachableWebhookUrl(webhookUrl)) throw new Error(`[voice-call] ${provider.name} requires a publicly reachable webhook URL. Refusing to use local-only webhook ${webhookUrl}. Set plugins.entries.voice-call.config.publicUrl or enable tunnel/tailscale exposure.`);
3252
- if (publicUrl && provider.name === "twilio") provider.setPublicUrl(publicUrl);
3363
+ if (publicUrl) provider.setPublicUrl?.(publicUrl);
3253
3364
  if (publicUrl && realtimeProvider) webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
3365
+ const realtimeHandler = webhookServer.getRealtimeHandler();
3366
+ if (realtimeHandler) manager.streamSessionIssuer = (request) => realtimeHandler.issueStreamSession(request);
3254
3367
  if (provider.name === "twilio" && config.streaming?.enabled) {
3255
3368
  const twilioProvider = provider;
3256
3369
  if (ttsRuntime?.textToSpeechTelephony) try {
@@ -3293,4 +3406,4 @@ async function createVoiceCallRuntime(params) {
3293
3406
  }
3294
3407
  }
3295
3408
  //#endregion
3296
- export { setupTailscaleExposureRoute as a, getTailscaleSelfInfo as i, resolveWebhookExposureStatus as n, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as o, cleanupTailscaleExposureRoute as r, resolveUserPath as s, createVoiceCallRuntime as t };
3409
+ export { getHeader as a, getTailscaleSelfInfo as c, chunkAudio as d, resolveVoiceResponseModel as f, mapVoiceToPolly as h, normalizeProviderStatus as i, setupTailscaleExposureRoute as l, escapeXml as m, isProviderStatusTerminal as n, resolveWebhookExposureStatus as o, resolveUserPath as p, mapProviderStatusToEndReason as r, cleanupTailscaleExposureRoute as s, createVoiceCallRuntime as t, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as u };
@@ -1,2 +1,2 @@
1
- import { t as createVoiceCallRuntime } from "./runtime-entry-CQfEI6TJ.js";
1
+ import { t as createVoiceCallRuntime } from "./runtime-entry-ox4PGoxT.js";
2
2
  export { createVoiceCallRuntime };
package/dist/setup-api.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as migrateVoiceCallLegacyConfigInput } from "./config-compat-BSV2aOY6.js";
1
+ import { n as migrateVoiceCallLegacyConfigInput } from "./config-compat-DJqJ8NzH.js";
2
2
  import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
3
3
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
4
4
  //#region extensions/voice-call/setup-api.ts
@@ -1,4 +1,4 @@
1
- import { i as verifyTelnyxWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-C5TNOqhE.js";
1
+ import { i as verifyTelnyxWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-D_aM8XmA.js";
2
2
  import crypto from "node:crypto";
3
3
  //#region extensions/voice-call/src/providers/telnyx.ts
4
4
  function normalizeTelnyxDirection(direction) {
@@ -10,6 +10,14 @@ function normalizeTelnyxDirection(direction) {
10
10
  default: return;
11
11
  }
12
12
  }
13
+ function normalizeBase64ForCompare(value) {
14
+ return value.replace(/=+$/u, "").replace(/-/gu, "+").replace(/_/gu, "/");
15
+ }
16
+ function decodeClientStateBase64(value) {
17
+ const buffer = Buffer.from(value, "base64");
18
+ if (normalizeBase64ForCompare(buffer.toString("base64")) !== normalizeBase64ForCompare(value)) return null;
19
+ return buffer.toString("utf8");
20
+ }
13
21
  var TelnyxProvider = class {
14
22
  constructor(config, options = {}) {
15
23
  this.name = "telnyx";
@@ -79,11 +87,7 @@ var TelnyxProvider = class {
79
87
  */
80
88
  normalizeEvent(data, dedupeKey) {
81
89
  let callId = "";
82
- if (data.payload?.client_state) try {
83
- callId = Buffer.from(data.payload.client_state, "base64").toString("utf8");
84
- } catch {
85
- callId = data.payload.client_state;
86
- }
90
+ if (data.payload?.client_state) callId = decodeClientStateBase64(data.payload.client_state) ?? data.payload.client_state;
87
91
  if (!callId) callId = data.payload?.call_control_id || "";
88
92
  const baseEvent = {
89
93
  id: data.id || crypto.randomUUID(),
@@ -134,6 +138,8 @@ var TelnyxProvider = class {
134
138
  type: "call.dtmf",
135
139
  digits: data.payload?.digit || ""
136
140
  };
141
+ case "streaming.started":
142
+ case "streaming.stopped": return null;
137
143
  default: return null;
138
144
  }
139
145
  }
@@ -163,20 +169,19 @@ var TelnyxProvider = class {
163
169
  return "completed";
164
170
  }
165
171
  }
166
- /**
167
- * Initiate an outbound call via Telnyx API.
168
- */
169
172
  async initiateCall(input) {
173
+ const body = {
174
+ connection_id: this.connectionId,
175
+ to: input.to,
176
+ from: input.from,
177
+ webhook_url: input.webhookUrl,
178
+ webhook_url_method: "POST",
179
+ client_state: Buffer.from(input.callId).toString("base64"),
180
+ timeout_secs: 30,
181
+ ...input.streamUrl ? buildTelnyxStreamingFields(input.streamUrl, input.streamAuthToken) : {}
182
+ };
170
183
  return {
171
- providerCallId: (await this.apiRequest("/calls", {
172
- connection_id: this.connectionId,
173
- to: input.to,
174
- from: input.from,
175
- webhook_url: input.webhookUrl,
176
- webhook_url_method: "POST",
177
- client_state: Buffer.from(input.callId).toString("base64"),
178
- timeout_secs: 30
179
- })).data.call_control_id,
184
+ providerCallId: (await this.apiRequest("/calls", body)).data.call_control_id,
180
185
  status: "initiated"
181
186
  };
182
187
  }
@@ -186,11 +191,12 @@ var TelnyxProvider = class {
186
191
  async hangupCall(input) {
187
192
  await this.apiRequest(`/calls/${input.providerCallId}/actions/hangup`, { command_id: crypto.randomUUID() }, { allowNotFound: true });
188
193
  }
189
- /**
190
- * Answer an inbound Telnyx Call Control leg.
191
- */
192
194
  async answerCall(input) {
193
- await this.apiRequest(`/calls/${input.providerCallId}/actions/answer`, { command_id: `openclaw-answer-${input.callId}` });
195
+ const body = {
196
+ command_id: `openclaw-answer-${input.callId}`,
197
+ ...input.streamUrl ? buildTelnyxStreamingFields(input.streamUrl, input.streamAuthToken) : {}
198
+ };
199
+ await this.apiRequest(`/calls/${input.providerCallId}/actions/answer`, body);
194
200
  }
195
201
  /**
196
202
  * Play TTS audio via Telnyx speak action.
@@ -256,5 +262,17 @@ var TelnyxProvider = class {
256
262
  }
257
263
  }
258
264
  };
265
+ function buildTelnyxStreamingFields(streamUrl, streamAuthToken) {
266
+ return {
267
+ stream_url: streamUrl,
268
+ stream_track: "inbound_track",
269
+ stream_codec: "PCMU",
270
+ stream_bidirectional_mode: "rtp",
271
+ stream_bidirectional_codec: "PCMU",
272
+ stream_bidirectional_sampling_rate: 8e3,
273
+ stream_bidirectional_target_legs: "self",
274
+ ...streamAuthToken ? { stream_auth_token: streamAuthToken } : {}
275
+ };
276
+ }
259
277
  //#endregion
260
278
  export { TelnyxProvider };
@@ -1,9 +1,7 @@
1
1
  import { fetchWithSsrFGuard } from "./runtime-api.js";
2
2
  import "./api.js";
3
- import { n as mapVoiceToPolly, t as escapeXml } from "./voice-mapping-DMm-YvxM.js";
4
- import { i as chunkAudio, n as mapProviderStatusToEndReason, r as normalizeProviderStatus, t as isProviderStatusTerminal } from "./call-status-CuIeqfac.js";
5
- import { t as getHeader } from "./http-headers-B5L5gMpK.js";
6
- import { a as verifyTwilioWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-C5TNOqhE.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-ox4PGoxT.js";
4
+ import { a as verifyTwilioWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-D_aM8XmA.js";
7
5
  import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
8
6
  import crypto from "node:crypto";
9
7
  import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
@@ -61,7 +59,12 @@ async function twilioApiRequest(params) {
61
59
  throw new TwilioApiError(response.status, errorText);
62
60
  }
63
61
  const text = await response.text();
64
- return text ? JSON.parse(text) : void 0;
62
+ if (!text) return;
63
+ try {
64
+ return JSON.parse(text);
65
+ } catch {
66
+ throw new Error("Twilio API returned malformed JSON.");
67
+ }
65
68
  } finally {
66
69
  await release();
67
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.5.12",
3
+ "version": "2026.5.14-beta.2",
4
4
  "description": "OpenClaw voice-call plugin",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,7 +10,7 @@
10
10
  "dependencies": {
11
11
  "commander": "14.0.3",
12
12
  "typebox": "1.1.38",
13
- "ws": "8.20.0",
13
+ "ws": "8.20.1",
14
14
  "zod": "4.4.3"
15
15
  },
16
16
  "devDependencies": {
@@ -18,7 +18,7 @@
18
18
  "openclaw": "workspace:*"
19
19
  },
20
20
  "peerDependencies": {
21
- "openclaw": ">=2026.5.12"
21
+ "openclaw": ">=2026.5.14-beta.2"
22
22
  },
23
23
  "peerDependenciesMeta": {
24
24
  "openclaw": {
@@ -35,10 +35,10 @@
35
35
  "minHostVersion": ">=2026.4.10"
36
36
  },
37
37
  "compat": {
38
- "pluginApi": ">=2026.5.12"
38
+ "pluginApi": ">=2026.5.14-beta.2"
39
39
  },
40
40
  "build": {
41
- "openclawVersion": "2026.5.12"
41
+ "openclawVersion": "2026.5.14-beta.2"
42
42
  },
43
43
  "release": {
44
44
  "publishToClawHub": true,
@@ -1,32 +0,0 @@
1
- import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
2
- import { convertPcmToMulaw8k } from "openclaw/plugin-sdk/realtime-voice";
3
- //#region extensions/voice-call/src/telephony-audio.ts
4
- /**
5
- * Chunk audio buffer into 20ms frames for streaming (8kHz mono mu-law).
6
- */
7
- function chunkAudio(audio, chunkSize = 160) {
8
- return (function* () {
9
- for (let i = 0; i < audio.length; i += chunkSize) yield audio.subarray(i, Math.min(i + chunkSize, audio.length));
10
- })();
11
- }
12
- //#endregion
13
- //#region extensions/voice-call/src/providers/shared/call-status.ts
14
- const TERMINAL_PROVIDER_STATUS_TO_END_REASON = {
15
- completed: "completed",
16
- failed: "failed",
17
- busy: "busy",
18
- "no-answer": "no-answer",
19
- canceled: "hangup-bot"
20
- };
21
- function normalizeProviderStatus(status) {
22
- const normalized = normalizeOptionalLowercaseString(status);
23
- return normalized && normalized.length > 0 ? normalized : "unknown";
24
- }
25
- function mapProviderStatusToEndReason(status) {
26
- return TERMINAL_PROVIDER_STATUS_TO_END_REASON[normalizeProviderStatus(status)] ?? null;
27
- }
28
- function isProviderStatusTerminal(status) {
29
- return mapProviderStatusToEndReason(status) !== null;
30
- }
31
- //#endregion
32
- export { convertPcmToMulaw8k as a, chunkAudio as i, mapProviderStatusToEndReason as n, normalizeProviderStatus as r, isProviderStatusTerminal as t };
@@ -1,10 +0,0 @@
1
- import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
2
- //#region extensions/voice-call/src/http-headers.ts
3
- function getHeader(headers, name) {
4
- const target = normalizeLowercaseStringOrEmpty(name);
5
- const value = headers[target] ?? Object.entries(headers).find(([key]) => normalizeLowercaseStringOrEmpty(key) === target)?.[1];
6
- if (Array.isArray(value)) return value[0];
7
- return value;
8
- }
9
- //#endregion
10
- export { getHeader as t };
@@ -1,12 +0,0 @@
1
- //#region extensions/voice-call/src/response-model.ts
2
- function resolveVoiceResponseModel(params) {
3
- const modelRef = params.voiceConfig.responseModel ?? `${params.agentRuntime.defaults.provider}/${params.agentRuntime.defaults.model}`;
4
- const slashIndex = modelRef.indexOf("/");
5
- return {
6
- modelRef,
7
- provider: slashIndex === -1 ? params.agentRuntime.defaults.provider : modelRef.slice(0, slashIndex),
8
- model: slashIndex === -1 ? modelRef : modelRef.slice(slashIndex + 1)
9
- };
10
- }
11
- //#endregion
12
- export { resolveVoiceResponseModel as t };
@@ -1,37 +0,0 @@
1
- import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
2
- //#region extensions/voice-call/src/voice-mapping.ts
3
- /**
4
- * Escape XML special characters for TwiML and other XML responses.
5
- */
6
- function escapeXml(text) {
7
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
8
- }
9
- /**
10
- * Map of OpenAI voice names to similar Twilio Polly voices.
11
- */
12
- const OPENAI_TO_POLLY_MAP = {
13
- alloy: "Polly.Joanna",
14
- echo: "Polly.Matthew",
15
- fable: "Polly.Amy",
16
- onyx: "Polly.Brian",
17
- nova: "Polly.Salli",
18
- shimmer: "Polly.Kimberly"
19
- };
20
- /**
21
- * Default Polly voice when no mapping is found.
22
- */
23
- const DEFAULT_POLLY_VOICE = "Polly.Joanna";
24
- /**
25
- * Map OpenAI voice names to Twilio Polly equivalents.
26
- * Falls through if already a valid Polly/Google voice.
27
- *
28
- * @param voice - OpenAI voice name (alloy, echo, etc.) or Polly voice name
29
- * @returns Polly voice name suitable for Twilio TwiML
30
- */
31
- function mapVoiceToPolly(voice) {
32
- if (!voice) return DEFAULT_POLLY_VOICE;
33
- if (voice.startsWith("Polly.") || voice.startsWith("Google.")) return voice;
34
- return OPENAI_TO_POLLY_MAP[normalizeLowercaseStringOrEmpty(voice)] || "Polly.Joanna";
35
- }
36
- //#endregion
37
- export { mapVoiceToPolly as n, escapeXml as t };
File without changes