@ouro.bot/cli 0.1.0-alpha.880 → 0.1.0-alpha.881

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.
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DEFAULT_TWILIO_MEDIA_MAX_UTTERANCE_MS = exports.DEFAULT_TWILIO_MEDIA_MIN_SPEECH_MS = exports.DEFAULT_TWILIO_MEDIA_SILENCE_END_MS = exports.DEFAULT_TWILIO_MEDIA_SPEECH_RMS_THRESHOLD = exports.DEFAULT_TWILIO_PHONE_TRANSPORT_MODE = exports.DEFAULT_TWILIO_PHONE_PLAYBACK_MODE = exports.TWILIO_PHONE_WEBHOOK_BASE_PATH = exports.DEFAULT_TWILIO_GREETING_PREBUFFER_MS = exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS = exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS = exports.DEFAULT_TWILIO_PHONE_PORT = exports.normalizeTwilioE164PhoneNumber = void 0;
36
+ exports.TwilioAudioStreamJobStore = exports.TwilioOpenAIRealtimeMediaStreamSession = exports.TwilioMediaStreamSession = exports.DEFAULT_TWILIO_MEDIA_MAX_UTTERANCE_MS = exports.DEFAULT_TWILIO_MEDIA_MIN_SPEECH_MS = exports.DEFAULT_TWILIO_MEDIA_SILENCE_END_MS = exports.DEFAULT_TWILIO_MEDIA_SPEECH_RMS_THRESHOLD = exports.DEFAULT_TWILIO_PHONE_TRANSPORT_MODE = exports.DEFAULT_TWILIO_PHONE_PLAYBACK_MODE = exports.TWILIO_PHONE_WEBHOOK_BASE_PATH = exports.DEFAULT_TWILIO_GREETING_PREBUFFER_MS = exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS = exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS = exports.DEFAULT_TWILIO_PHONE_PORT = exports.normalizeTwilioE164PhoneNumber = exports.PendingVoiceCalls = void 0;
37
37
  exports.resolveTwilioPhoneAgentRoot = resolveTwilioPhoneAgentRoot;
38
38
  exports.normalizeTwilioPhoneBasePath = normalizeTwilioPhoneBasePath;
39
39
  exports.normalizeTwilioPhonePlaybackMode = normalizeTwilioPhonePlaybackMode;
@@ -45,6 +45,7 @@ exports.openAISipWebhookUrl = openAISipWebhookUrl;
45
45
  exports.twilioOutboundCallWebhookUrl = twilioOutboundCallWebhookUrl;
46
46
  exports.twilioOutboundCallStatusCallbackUrl = twilioOutboundCallStatusCallbackUrl;
47
47
  exports.twilioOutboundCallAmdCallbackUrl = twilioOutboundCallAmdCallbackUrl;
48
+ exports.wsAsVoiceSessionSocket = wsAsVoiceSessionSocket;
48
49
  exports.twilioPhoneVoiceSessionKey = twilioPhoneVoiceSessionKey;
49
50
  exports.outboundCallAnsweredPrompt = outboundCallAnsweredPrompt;
50
51
  exports.computeOpenAIWebhookSignature = computeOpenAIWebhookSignature;
@@ -79,6 +80,9 @@ const turn_1 = require("./turn");
79
80
  const phone_1 = require("./phone");
80
81
  const audio_playback_1 = require("./audio-playback");
81
82
  const floor_controller_1 = require("./floor-controller");
83
+ const call_auth_1 = require("./call-auth");
84
+ var call_auth_2 = require("./call-auth");
85
+ Object.defineProperty(exports, "PendingVoiceCalls", { enumerable: true, get: function () { return call_auth_2.PendingVoiceCalls; } });
82
86
  var phone_2 = require("./phone");
83
87
  Object.defineProperty(exports, "normalizeTwilioE164PhoneNumber", { enumerable: true, get: function () { return phone_2.normalizeTwilioE164PhoneNumber; } });
84
88
  exports.DEFAULT_TWILIO_PHONE_PORT = 18910;
@@ -251,11 +255,8 @@ function sayTwiml(message) {
251
255
  function playTwiml(url) {
252
256
  return `<Play>${escapeXml(url)}</Play>`;
253
257
  }
254
- function parameterTwiml(name, value) {
255
- const trimmed = value?.trim();
256
- if (!trimmed)
257
- return "";
258
- return `<Parameter name="${escapeXml(name)}" value="${escapeXml(trimmed)}" />`;
258
+ function tokenParameterTwiml(token) {
259
+ return `<Parameter name="OuroToken" value="${escapeXml(token)}" />`;
259
260
  }
260
261
  function websocketRouteUrl(publicBaseUrl, route) {
261
262
  const url = new URL(route, publicBaseUrl);
@@ -266,22 +267,62 @@ function websocketRouteUrl(publicBaseUrl, route) {
266
267
  url.protocol = "wss:";
267
268
  return url.toString();
268
269
  }
269
- function mediaStreamTwiml(options, basePath, params, greetingJobId, customParams = {}, streamEngine) {
270
- const streamRoute = streamEngine ? `${basePath}/media-stream?engine=${encodeURIComponent(streamEngine)}` : `${basePath}/media-stream`;
271
- const streamUrl = websocketRouteUrl(options.publicBaseUrl, streamRoute);
272
- const twimlParams = {
273
- From: params.From,
274
- To: params.To,
275
- Agent: options.agentName,
276
- ...customParams,
277
- GreetingJobId: greetingJobId,
278
- };
270
+ const STREAM_FAILURE_LINE = "Sorry, I couldn't connect this call. Please try again.";
271
+ function streamFailureTwiml() {
272
+ return `${sayTwiml(STREAM_FAILURE_LINE)}<Hangup />`;
273
+ }
274
+ function mediaStreamTwiml(options, basePath, token) {
275
+ const streamUrl = websocketRouteUrl(options.publicBaseUrl, `${basePath}/media-stream`);
276
+ const endedUrl = routeUrl(options.publicBaseUrl, `${basePath}/stream-ended`);
279
277
  return [
280
- `<Connect><Stream url="${escapeXml(streamUrl)}">`,
281
- ...Object.entries(twimlParams).map(([name, value]) => parameterTwiml(name, value)),
278
+ `<Connect action="${escapeXml(endedUrl)}" method="POST"><Stream url="${escapeXml(streamUrl)}">`,
279
+ tokenParameterTwiml(token),
282
280
  `</Stream></Connect>`,
283
281
  ].join("");
284
282
  }
283
+ /**
284
+ * Record an admitted call and return the TwiML that connects its media stream, carrying a
285
+ * single-use token. Without the Twilio auth token (the token secret) or a CallSid the call is
286
+ * not admitted and the caller hears the failure line.
287
+ */
288
+ function admitMediaStreamCall(options, basePath, pending, identity) {
289
+ const secret = options.twilioAuthToken?.trim();
290
+ if (!secret || !identity.callSid) {
291
+ (0, runtime_1.emitNervesEvent)({
292
+ level: "warn",
293
+ component: "senses",
294
+ event: "senses.voice_media_stream_not_admitted",
295
+ message: "could not admit a Twilio call to a media stream: auth token or CallSid missing",
296
+ meta: { agentName: options.agentName, hasAuthToken: String(Boolean(secret)), hasCallSid: String(Boolean(identity.callSid)) },
297
+ });
298
+ return streamFailureTwiml();
299
+ }
300
+ const nonce = (0, call_auth_1.newVoiceCallNonce)();
301
+ pending.record(identity, nonce);
302
+ return mediaStreamTwiml(options, basePath, (0, call_auth_1.mintVoiceCallToken)({
303
+ secret,
304
+ purpose: "stream",
305
+ agentName: options.agentName,
306
+ callSid: identity.callSid,
307
+ direction: identity.direction,
308
+ outboundId: identity.outboundId,
309
+ nowMs: Date.now(),
310
+ nonce,
311
+ }));
312
+ }
313
+ function wsAsVoiceSessionSocket(ws) {
314
+ return {
315
+ send: (data) => ws.send(data),
316
+ close: () => {
317
+ if (ws.readyState === ws_1.WebSocket.OPEN || ws.readyState === ws_1.WebSocket.CONNECTING)
318
+ ws.close();
319
+ },
320
+ isOpen: () => ws.readyState === ws_1.WebSocket.OPEN,
321
+ on: (event, callback) => {
322
+ ws.on(event, callback);
323
+ },
324
+ };
325
+ }
285
326
  /* v8 ignore start -- private SIP URI query permutations are exercised through bridge routes; the queryless helper shape is not externally reachable today @preserve */
286
327
  function openAISipUri(options, customHeaders = {}) {
287
328
  const projectId = options.openaiSip?.projectId?.trim();
@@ -360,13 +401,23 @@ function friendIdFromCaller(from, callSid) {
360
401
  function voiceFriendId(options, from, callSid) {
361
402
  return options.defaultFriendId?.trim() || friendIdFromCaller(from, callSid);
362
403
  }
404
+ const SAFE_VOICE_FRIEND_ID = /^[A-Za-z0-9._-]{1,128}$/;
405
+ function isSafeVoiceFriendId(id) {
406
+ return SAFE_VOICE_FRIEND_ID.test(id) && id !== "." && id !== "..";
407
+ }
408
+ /**
409
+ * Resolve who is on a call. `friendId` may only come from server-side state (the outbound job
410
+ * record), is validated as a safe id before any store read, and must match a record id exactly
411
+ * (the store's display-name fallback is never used for voice). Everything else resolves from the
412
+ * phone number, or from the call itself when there is none.
413
+ */
363
414
  async function resolveVoiceFriendContext(options, input) {
364
415
  const agentRoot = resolveTwilioPhoneAgentRoot(options);
365
416
  const friendStore = new friends_1.FileFriendStore(path.join(agentRoot, "friends"));
366
417
  const explicitFriendId = input.friendId?.trim();
367
- if (explicitFriendId) {
418
+ if (explicitFriendId && isSafeVoiceFriendId(explicitFriendId)) {
368
419
  const existing = await friendStore.get(explicitFriendId);
369
- if (existing) {
420
+ if (existing && existing.id === explicitFriendId) {
370
421
  return {
371
422
  friendId: existing.id,
372
423
  friendStore,
@@ -376,7 +427,7 @@ async function resolveVoiceFriendContext(options, input) {
376
427
  }
377
428
  const remotePhone = (0, phone_1.normalizeTwilioE164PhoneNumber)(input.remotePhone);
378
429
  const provider = remotePhone ? "imessage-handle" : "local";
379
- const externalId = remotePhone || explicitFriendId || voiceFriendId(options, input.remotePhone ?? "", input.callSid);
430
+ const externalId = remotePhone || friendIdFromCaller("", input.callSid);
380
431
  const resolver = new friends_1.FriendResolver(friendStore, {
381
432
  provider,
382
433
  externalId,
@@ -386,6 +437,25 @@ async function resolveVoiceFriendContext(options, input) {
386
437
  const resolved = await resolver.resolve();
387
438
  return { friendId: resolved.friend.id, friendStore, resolved };
388
439
  }
440
+ /**
441
+ * The friend id for an inbound call: resolved from the signed caller ID, never from
442
+ * `defaultFriendId`. Falls back to a caller-derived id if the friend store is unreadable.
443
+ */
444
+ async function inboundVoiceFriendId(options, from, callSid) {
445
+ try {
446
+ return (await resolveVoiceFriendContext(options, { remotePhone: from || undefined, callSid })).friendId;
447
+ }
448
+ catch (error) {
449
+ (0, runtime_1.emitNervesEvent)({
450
+ level: "warn",
451
+ component: "senses",
452
+ event: "senses.voice_friend_resolve_error",
453
+ message: "could not resolve the inbound caller to a friend record",
454
+ meta: { agentName: options.agentName, callSid: safeSegment(callSid), error: errorMessage(error) },
455
+ });
456
+ return friendIdFromCaller(from, callSid);
457
+ }
458
+ }
389
459
  function phoneIdentitySegment(input) {
390
460
  const phoneish = input.replace(/[^0-9A-Za-z]+/g, "");
391
461
  return phoneish || safeSegment(input);
@@ -454,22 +524,22 @@ function buildNoSpeechTranscript(utteranceId) {
454
524
  source: "loopback",
455
525
  });
456
526
  }
457
- /* v8 ignore start -- ws RawData variants are provider/runtime transport shapes; session tests cover valid and invalid stream behavior @preserve */
458
527
  function parseTwilioMediaStreamMessage(raw) {
459
- const text = Buffer.isBuffer(raw)
460
- ? raw.toString("utf8")
461
- : Array.isArray(raw)
462
- ? Buffer.concat(raw).toString("utf8")
463
- : Buffer.from(raw).toString("utf8");
528
+ const text = typeof raw === "string"
529
+ ? raw
530
+ : Buffer.isBuffer(raw)
531
+ ? raw.toString("utf8")
532
+ : Array.isArray(raw)
533
+ ? Buffer.concat(raw).toString("utf8")
534
+ : Buffer.from(raw).toString("utf8");
464
535
  try {
465
536
  const parsed = JSON.parse(text);
466
537
  return parsed && typeof parsed === "object" ? parsed : null;
467
538
  }
468
- catch { /* v8 ignore next -- invalid provider socket JSON is observed at the session boundary @preserve */
539
+ catch {
469
540
  return null;
470
541
  }
471
542
  }
472
- /* v8 ignore stop */
473
543
  function stringField(value) {
474
544
  return typeof value === "string" ? value.trim() : "";
475
545
  }
@@ -590,9 +660,9 @@ async function transcribeRecordingOrNoSpeech(options) {
590
660
  throw error;
591
661
  }
592
662
  }
593
- /* v8 ignore start -- legacy cascade Media Streams socket loop is covered by bridge-level WebSocket tests; per-event socket/error permutations are transport-runtime edges @preserve */
594
663
  class TwilioMediaStreamSession {
595
- ws;
664
+ socket;
665
+ identity;
596
666
  options;
597
667
  mediaGreetingJobs;
598
668
  lifecycle;
@@ -609,6 +679,7 @@ class TwilioMediaStreamSession {
609
679
  playbackGeneration = 0;
610
680
  playbackActive = false;
611
681
  closed = false;
682
+ started = false;
612
683
  inSpeech = false;
613
684
  currentFrames = [];
614
685
  preRollFrames = [];
@@ -625,8 +696,9 @@ class TwilioMediaStreamSession {
625
696
  silenceEndFrames;
626
697
  minSpeechFrames;
627
698
  maxUtteranceFrames;
628
- constructor(ws, options, mediaGreetingJobs, lifecycle) {
629
- this.ws = ws;
699
+ constructor(socket, identity, options, mediaGreetingJobs, lifecycle) {
700
+ this.socket = socket;
701
+ this.identity = identity;
630
702
  this.options = options;
631
703
  this.mediaGreetingJobs = mediaGreetingJobs;
632
704
  this.lifecycle = lifecycle;
@@ -636,9 +708,9 @@ class TwilioMediaStreamSession {
636
708
  this.maxUtteranceFrames = frameLimitForMs(options.mediaMaxUtteranceMs ?? exports.DEFAULT_TWILIO_MEDIA_MAX_UTTERANCE_MS);
637
709
  }
638
710
  attach() {
639
- this.ws.on("message", (raw) => this.handleRawMessage(raw));
640
- this.ws.on("close", () => this.close());
641
- this.ws.on("error", (error) => {
711
+ this.socket.on("message", (raw) => this.handleRawMessage(raw));
712
+ this.socket.on("close", () => this.close());
713
+ this.socket.on("error", (error) => {
642
714
  (0, runtime_1.emitNervesEvent)({
643
715
  level: "error",
644
716
  component: "senses",
@@ -649,11 +721,10 @@ class TwilioMediaStreamSession {
649
721
  });
650
722
  }
651
723
  end() {
652
- if (this.ws.readyState === ws_1.WebSocket.OPEN || this.ws.readyState === ws_1.WebSocket.CONNECTING) {
653
- this.ws.close();
654
- }
724
+ this.socket.close();
655
725
  this.close();
656
726
  }
727
+ /** Feed a raw Twilio message that arrived before this session attached (the bridge holds the first `start`). */
657
728
  handleRawMessage(raw) {
658
729
  const message = parseTwilioMediaStreamMessage(raw);
659
730
  if (!message) {
@@ -684,54 +755,65 @@ class TwilioMediaStreamSession {
684
755
  }
685
756
  }
686
757
  async handleStart(start) {
758
+ if (this.started)
759
+ return;
760
+ this.started = true;
687
761
  this.streamSid = stringField(start?.streamSid);
688
- this.callSid = stringField(start?.callSid) || this.callSid;
689
- const direction = customParameter(start, "Direction");
690
- this.direction = direction;
691
- this.outboundId = customParameter(start, "OutboundId");
692
- const explicitFriendId = customParameter(start, "FriendId");
693
- if (direction === "outbound") {
694
- this.from = customParameter(start, "Remote") || customParameter(start, "To");
695
- this.to = customParameter(start, "Line") || customParameter(start, "From");
696
- }
697
- else {
698
- this.from = customParameter(start, "From");
699
- this.to = customParameter(start, "To");
700
- }
701
- this.friendId = explicitFriendId || voiceFriendId(this.options, this.from, this.callSid);
702
- this.sessionKey = twilioPhoneVoiceSessionKey({
703
- defaultFriendId: explicitFriendId || this.options.defaultFriendId,
704
- from: this.from,
705
- to: this.to,
706
- callSid: this.callSid,
707
- });
708
- this.lifecycle?.onIdentityChange?.(this, { callSid: this.callSid, outboundId: this.outboundId });
709
- this.callDir = path.join(this.options.outputDir, safeSegment(this.callSid));
710
- await fs.mkdir(this.callDir, { recursive: true });
711
- (0, runtime_1.emitNervesEvent)({
712
- component: "senses",
713
- event: "senses.voice_twilio_media_start",
714
- message: "Twilio Media Stream started",
715
- meta: {
716
- agentName: this.options.agentName,
717
- callSid: safeSegment(this.callSid),
718
- streamSid: safeSegment(this.streamSid || "stream"),
719
- sessionKey: this.sessionKey,
720
- },
721
- });
722
- const greetingJobId = customParameter(start, "GreetingJobId");
723
- const greetingJob = greetingJobId
724
- ? this.mediaGreetingJobs.get(safeSegment(this.callSid), greetingJobId)
725
- : null;
726
- if (greetingJob) {
727
- this.enqueueGreetingJob(greetingJobId, greetingJob);
762
+ this.callSid = this.identity.callSid;
763
+ this.direction = this.identity.direction;
764
+ this.outboundId = this.identity.outboundId ?? "";
765
+ this.from = this.identity.from;
766
+ this.to = this.identity.to;
767
+ try {
768
+ this.friendId = this.direction === "outbound" && this.identity.friendId && isSafeVoiceFriendId(this.identity.friendId)
769
+ ? this.identity.friendId
770
+ : this.direction === "outbound"
771
+ ? voiceFriendId(this.options, this.from, this.callSid)
772
+ : await inboundVoiceFriendId(this.options, this.from, this.callSid);
773
+ this.sessionKey = twilioPhoneVoiceSessionKey({
774
+ defaultFriendId: this.friendId,
775
+ from: this.from,
776
+ to: this.to,
777
+ callSid: this.callSid,
778
+ });
779
+ this.lifecycle?.onIdentityChange?.(this, { callSid: this.callSid, outboundId: this.outboundId });
780
+ this.callDir = path.join(this.options.outputDir, safeSegment(this.callSid));
781
+ await fs.mkdir(this.callDir, { recursive: true });
782
+ (0, runtime_1.emitNervesEvent)({
783
+ component: "senses",
784
+ event: "senses.voice_twilio_media_start",
785
+ message: "Twilio Media Stream started",
786
+ meta: {
787
+ agentName: this.options.agentName,
788
+ callSid: safeSegment(this.callSid),
789
+ streamSid: safeSegment(this.streamSid || "stream"),
790
+ sessionKey: this.sessionKey,
791
+ },
792
+ });
793
+ const greetingJobId = this.identity.greetingJobId ?? "";
794
+ const greetingJob = greetingJobId
795
+ ? this.mediaGreetingJobs.get(safeSegment(this.callSid), greetingJobId)
796
+ : null;
797
+ if (greetingJob) {
798
+ this.enqueueGreetingJob(greetingJobId, greetingJob);
799
+ }
800
+ else {
801
+ this.enqueuePrompt({
802
+ utteranceId: `twilio-${safeSegment(this.callSid)}-connected`,
803
+ promptText: callConnectedPrompt({ From: this.from, To: this.to }),
804
+ wasBargeIn: false,
805
+ });
806
+ }
728
807
  }
729
- else {
730
- this.enqueuePrompt({
731
- utteranceId: `twilio-${safeSegment(this.callSid)}-connected`,
732
- promptText: callConnectedPrompt({ From: this.from, To: this.to }),
733
- wasBargeIn: false,
808
+ catch (error) {
809
+ (0, runtime_1.emitNervesEvent)({
810
+ level: "error",
811
+ component: "senses",
812
+ event: "senses.voice_twilio_media_start_error",
813
+ message: "Twilio Media Stream could not start",
814
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), error: errorMessage(error) },
734
815
  });
816
+ this.end();
735
817
  }
736
818
  }
737
819
  handleMedia(media) {
@@ -829,6 +911,7 @@ class TwilioMediaStreamSession {
829
911
  });
830
912
  this.enqueueTurn(transcript, input.wasBargeIn);
831
913
  }
914
+ /* v8 ignore start -- unchanged queue error logging; turn failures are observed through the session turn tests @preserve */
832
915
  enqueueUtterance(utterance) {
833
916
  this.turnQueue = this.turnQueue
834
917
  .catch(() => undefined)
@@ -886,6 +969,7 @@ class TwilioMediaStreamSession {
886
969
  });
887
970
  });
888
971
  }
972
+ /* v8 ignore stop */
889
973
  async processUtterance(utterance) {
890
974
  await fs.mkdir(this.callDir, { recursive: true });
891
975
  const inputPath = path.join(this.callDir, `${safeSegment(utterance.utteranceId)}.wav`);
@@ -897,11 +981,13 @@ class TwilioMediaStreamSession {
897
981
  });
898
982
  if (this.direction === "outbound" && isVoicemailMenuTranscript(transcript.text)) {
899
983
  if (this.outboundId) {
984
+ /* v8 ignore start -- the voicemail status write is best-effort and must not block hanging up @preserve */
900
985
  await updateTwilioOutboundCallJob(this.options.outputDir, this.outboundId, {
901
986
  status: "voicemail",
902
987
  answeredBy: "voicemail_menu",
903
988
  transportCallSid: this.callSid,
904
989
  }).catch(() => null);
990
+ /* v8 ignore stop */
905
991
  }
906
992
  (0, runtime_1.emitNervesEvent)({
907
993
  component: "senses",
@@ -913,7 +999,7 @@ class TwilioMediaStreamSession {
913
999
  outboundId: safeSegment(this.outboundId || "unknown"),
914
1000
  },
915
1001
  });
916
- this.ws.close();
1002
+ this.socket.close();
917
1003
  this.close();
918
1004
  return;
919
1005
  }
@@ -938,19 +1024,15 @@ class TwilioMediaStreamSession {
938
1024
  const generation = this.startPlayback();
939
1025
  const turn = await (0, turn_1.runVoiceLoopbackTurn)({
940
1026
  agentName: this.options.agentName,
941
- friendId: this.friendId || voiceFriendId(this.options, this.from, this.callSid),
942
- sessionKey: this.sessionKey || twilioPhoneVoiceSessionKey({
943
- defaultFriendId: this.options.defaultFriendId,
944
- from: this.from,
945
- to: this.to,
946
- callSid: this.callSid,
947
- }),
1027
+ friendId: this.friendId,
1028
+ sessionKey: this.sessionKey,
948
1029
  transcript,
949
1030
  tts: this.options.tts,
950
1031
  runSenseTurn: this.options.runSenseTurn,
951
1032
  onAudioChunk: (chunk) => this.sendAudioChunk(chunk, generation),
952
1033
  voiceCall: {
953
1034
  requestEnd: (reason) => this.requestHangupAfterPlayback(reason),
1035
+ /* v8 ignore next -- tool audio playback is unchanged and exercised through the Realtime and SIP tool tests @preserve */
954
1036
  playAudio: (request) => this.playPreparedAudio(request),
955
1037
  },
956
1038
  });
@@ -962,11 +1044,13 @@ class TwilioMediaStreamSession {
962
1044
  this.sendAudioChunk(delivery.audio, generation);
963
1045
  }
964
1046
  }
1047
+ /* v8 ignore start -- runVoiceLoopbackTurn always yields at least one delivered segment (it falls back to a whole-reply synthesis); kept as a guard @preserve */
965
1048
  if (deliveries.length === 0) {
966
1049
  this.playbackActive = false;
967
1050
  this.completeHangupIfRequested("no_playback");
968
1051
  return;
969
1052
  }
1053
+ /* v8 ignore stop */
970
1054
  this.sendMark(generation, transcript.utteranceId);
971
1055
  if (this.hangupRequested)
972
1056
  this.armHangupFallback();
@@ -983,6 +1067,7 @@ class TwilioMediaStreamSession {
983
1067
  },
984
1068
  });
985
1069
  }
1070
+ /* v8 ignore start -- unchanged prebuffered greeting playback; the bridge greeting tests cover the path @preserve */
986
1071
  async streamGreetingJob(jobId, job) {
987
1072
  if (this.closed || !this.streamSid)
988
1073
  return;
@@ -1025,6 +1110,7 @@ class TwilioMediaStreamSession {
1025
1110
  },
1026
1111
  });
1027
1112
  }
1113
+ /* v8 ignore stop */
1028
1114
  startPlayback() {
1029
1115
  this.playbackGeneration += 1;
1030
1116
  this.playbackActive = true;
@@ -1032,9 +1118,9 @@ class TwilioMediaStreamSession {
1032
1118
  return this.playbackGeneration;
1033
1119
  }
1034
1120
  sendAudioChunk(chunk, generation) {
1035
- if (this.closed || generation !== this.playbackGeneration || !this.streamSid || this.ws.readyState !== ws_1.WebSocket.OPEN)
1121
+ if (this.closed || generation !== this.playbackGeneration || !this.streamSid || !this.socket.isOpen())
1036
1122
  return;
1037
- this.ws.send(JSON.stringify({
1123
+ this.socket.send(JSON.stringify({
1038
1124
  event: "media",
1039
1125
  streamSid: this.streamSid,
1040
1126
  media: { payload: Buffer.from(chunk).toString("base64") },
@@ -1042,14 +1128,15 @@ class TwilioMediaStreamSession {
1042
1128
  this.playbackBytesByGeneration.set(generation, (this.playbackBytesByGeneration.get(generation) ?? 0) + chunk.byteLength);
1043
1129
  }
1044
1130
  sendMark(generation, utteranceId) {
1045
- if (this.closed || generation !== this.playbackGeneration || !this.streamSid || this.ws.readyState !== ws_1.WebSocket.OPEN)
1131
+ if (this.closed || generation !== this.playbackGeneration || !this.streamSid || !this.socket.isOpen())
1046
1132
  return;
1047
- this.ws.send(JSON.stringify({
1133
+ this.socket.send(JSON.stringify({
1048
1134
  event: "mark",
1049
1135
  streamSid: this.streamSid,
1050
1136
  mark: { name: `voice-${generation}-${safeSegment(utteranceId)}` },
1051
1137
  }));
1052
1138
  }
1139
+ /* v8 ignore start -- unchanged cascade tool-audio playback @preserve */
1053
1140
  async playPreparedAudio(request) {
1054
1141
  const prepared = await (0, audio_playback_1.prepareVoiceCallAudio)(request, {
1055
1142
  agentRoot: resolveTwilioPhoneAgentRoot(this.options),
@@ -1077,13 +1164,14 @@ class TwilioMediaStreamSession {
1077
1164
  });
1078
1165
  return { label: prepared.label, durationMs: prepared.durationMs };
1079
1166
  }
1167
+ /* v8 ignore stop */
1080
1168
  interruptPlayback() {
1081
- if (!this.playbackActive || !this.streamSid || this.ws.readyState !== ws_1.WebSocket.OPEN)
1169
+ if (!this.playbackActive || !this.streamSid || !this.socket.isOpen())
1082
1170
  return false;
1083
1171
  this.cancelPendingHangup("barge_in");
1084
1172
  this.playbackGeneration += 1;
1085
1173
  this.playbackActive = false;
1086
- this.ws.send(JSON.stringify({ event: "clear", streamSid: this.streamSid }));
1174
+ this.socket.send(JSON.stringify({ event: "clear", streamSid: this.streamSid }));
1087
1175
  (0, runtime_1.emitNervesEvent)({
1088
1176
  component: "senses",
1089
1177
  event: "senses.voice_twilio_media_barge_in",
@@ -1092,6 +1180,7 @@ class TwilioMediaStreamSession {
1092
1180
  });
1093
1181
  return true;
1094
1182
  }
1183
+ /* v8 ignore start -- unchanged cascade hangup coordination and timers @preserve */
1095
1184
  requestHangupAfterPlayback(reason) {
1096
1185
  if (this.closed)
1097
1186
  return;
@@ -1144,6 +1233,7 @@ class TwilioMediaStreamSession {
1144
1233
  }, TWILIO_MEDIA_HANGUP_FALLBACK_MS);
1145
1234
  this.hangupFallbackTimer.unref?.();
1146
1235
  }
1236
+ /* v8 ignore stop */
1147
1237
  clearHangupFallback() {
1148
1238
  if (!this.hangupFallbackTimer)
1149
1239
  return;
@@ -1151,7 +1241,7 @@ class TwilioMediaStreamSession {
1151
1241
  this.hangupFallbackTimer = null;
1152
1242
  }
1153
1243
  }
1154
- /* v8 ignore stop */
1244
+ exports.TwilioMediaStreamSession = TwilioMediaStreamSession;
1155
1245
  const OPENAI_REALTIME_DEFAULT_MODEL = "gpt-realtime-2";
1156
1246
  const OPENAI_REALTIME_DEFAULT_VOICE = "cedar";
1157
1247
  const OPENAI_REALTIME_DEFAULT_TRANSCRIPTION_MODEL = "gpt-realtime-whisper";
@@ -1253,40 +1343,89 @@ function openAISipHeaderValue(headers, name) {
1253
1343
  }
1254
1344
  return "";
1255
1345
  }
1256
- function phoneFromSipHeader(value) {
1257
- const trimmed = value.trim();
1258
- if (!trimmed)
1259
- return "";
1260
- const tel = trimmed.match(/tel:([^>;]+)/i)?.[1];
1261
- if (tel)
1262
- return tel.trim();
1263
- const sip = trimmed.match(/sip:([^@>;]+)/i)?.[1];
1264
- if (sip)
1265
- return sip.trim();
1266
- const bracketed = trimmed.match(/<([^>]+)>/)?.[1];
1267
- return bracketed?.trim() || trimmed;
1268
- }
1269
- function openAISipCallMetadata(event) {
1346
+ /* v8 ignore stop */
1347
+ function openAISipCallId(event) {
1270
1348
  const data = event.data;
1271
- if (!data || typeof data !== "object")
1272
- return null;
1273
- const callId = stringField(data.call_id);
1274
- if (!callId)
1275
- return null;
1276
- const headers = openAISipHeaders(data.sip_headers);
1277
- const from = openAISipHeaderValue(headers, "X-Ouro-From") || phoneFromSipHeader(openAISipHeaderValue(headers, "From"));
1278
- const to = openAISipHeaderValue(headers, "X-Ouro-To") || phoneFromSipHeader(openAISipHeaderValue(headers, "To"));
1279
- const friendId = openAISipHeaderValue(headers, "X-Ouro-Friend-Id");
1349
+ return data && typeof data === "object" ? stringField(data.call_id) : "";
1350
+ }
1351
+ /**
1352
+ * Admit an incoming OpenAI SIP call only if it carries a token our signed Twilio webhook minted
1353
+ * for this agent and call. Caller, line, direction, outbound id, friend and reason come from the
1354
+ * record that webhook wrote, never from SIP headers.
1355
+ */
1356
+ function admitOpenAISipCall(options, pending, event) {
1357
+ const callId = openAISipCallId(event);
1358
+ const headers = openAISipHeaders(event.data?.sip_headers);
1359
+ const secret = options.twilioAuthToken?.trim();
1360
+ if (!secret)
1361
+ return { ok: false, reason: "no_secret" };
1362
+ if (openAISipHeaderValue(headers, "X-Ouro-Agent") !== options.agentName)
1363
+ return { ok: false, reason: "wrong_agent" };
1364
+ const callSid = openAISipHeaderValue(headers, "X-Ouro-Call-Sid");
1365
+ const verified = (0, call_auth_1.verifyVoiceCallToken)({
1366
+ secret,
1367
+ purpose: "sip",
1368
+ token: openAISipHeaderValue(headers, "X-Ouro-Call-Token"),
1369
+ agentName: options.agentName,
1370
+ callSid,
1371
+ nowMs: Date.now(),
1372
+ });
1373
+ if (!verified.ok)
1374
+ return { ok: false, reason: verified.reason };
1375
+ const identity = pending.consume(callSid, verified.nonce);
1376
+ if (!identity)
1377
+ return { ok: false, reason: "no_record" };
1378
+ if (identity.direction !== verified.direction
1379
+ || (identity.outboundId ?? "") !== (verified.outboundId ?? "")
1380
+ || identity.from !== (verified.from ?? "")
1381
+ || identity.to !== (verified.to ?? ""))
1382
+ return { ok: false, reason: "record_mismatch" };
1280
1383
  return {
1281
- callId,
1282
- from,
1283
- to,
1284
- direction: openAISipHeaderValue(headers, "X-Ouro-Direction") || "inbound",
1285
- outboundId: openAISipHeaderValue(headers, "X-Ouro-Outbound-Id"),
1286
- reason: openAISipHeaderValue(headers, "X-Ouro-Reason"),
1287
- friendId,
1384
+ ok: true,
1385
+ metadata: {
1386
+ callId,
1387
+ from: identity.from,
1388
+ to: identity.to,
1389
+ direction: identity.direction,
1390
+ outboundId: identity.outboundId ?? "",
1391
+ reason: identity.reason ?? "",
1392
+ friendId: identity.friendId ?? "",
1393
+ },
1288
1394
  };
1289
1395
  }
1396
+ /** Dial OpenAI SIP with a token for the signed webhook's recorded call; a call that cannot be admitted hears the failure line. */
1397
+ function admitSipCall(options, pending, identity) {
1398
+ const secret = options.twilioAuthToken?.trim();
1399
+ if (!secret || !identity.callSid) {
1400
+ (0, runtime_1.emitNervesEvent)({
1401
+ level: "warn",
1402
+ component: "senses",
1403
+ event: "senses.voice_sip_call_not_admitted",
1404
+ message: "could not admit a Twilio call to OpenAI SIP: auth token or CallSid missing",
1405
+ meta: { agentName: options.agentName, hasAuthToken: String(Boolean(secret)), hasCallSid: String(Boolean(identity.callSid)) },
1406
+ });
1407
+ return streamFailureTwiml();
1408
+ }
1409
+ const nonce = (0, call_auth_1.newVoiceCallNonce)();
1410
+ pending.record(identity, nonce);
1411
+ return openAISipDialTwiml(options, {
1412
+ "X-Ouro-Agent": options.agentName,
1413
+ "X-Ouro-Call-Sid": identity.callSid,
1414
+ "X-Ouro-Call-Token": (0, call_auth_1.mintVoiceCallToken)({
1415
+ secret,
1416
+ purpose: "sip",
1417
+ agentName: options.agentName,
1418
+ callSid: identity.callSid,
1419
+ direction: identity.direction,
1420
+ outboundId: identity.outboundId,
1421
+ from: identity.from,
1422
+ to: identity.to,
1423
+ nowMs: Date.now(),
1424
+ nonce,
1425
+ }),
1426
+ });
1427
+ }
1428
+ /* v8 ignore start -- unchanged SIP prompt and Realtime config helpers @preserve */
1290
1429
  function openAISipCallConnectedPrompt(metadata, voiceStyle) {
1291
1430
  const styleLine = voiceStyle?.trim()
1292
1431
  ? `Phone voice target for this first turn: ${voiceStyle.trim()}`
@@ -1311,15 +1450,6 @@ function openAISipCallConnectedPrompt(metadata, voiceStyle) {
1311
1450
  "Respond through the voice channel as yourself. Greet the caller naturally and briefly, then invite them to speak.",
1312
1451
  ].filter(Boolean).join("\n");
1313
1452
  }
1314
- function openAISipResponseHeaders(params, extra = {}) {
1315
- return {
1316
- "X-Ouro-Agent": params.Agent,
1317
- "X-Ouro-Direction": params.Direction,
1318
- "X-Ouro-From": params.From,
1319
- "X-Ouro-To": params.To,
1320
- ...extra,
1321
- };
1322
- }
1323
1453
  function boundedNumber(value, min, max) {
1324
1454
  if (value === undefined || !Number.isFinite(value))
1325
1455
  return undefined;
@@ -1397,20 +1527,6 @@ function realtimeToolsFromSelection(selection) {
1397
1527
  parameters: tool.function.parameters ?? { type: "object", properties: {} },
1398
1528
  }));
1399
1529
  }
1400
- /* v8 ignore start -- unchanged transport route parsing @preserve */
1401
- function mediaStreamRequestedConversationEngine(url) {
1402
- if (!url)
1403
- return undefined;
1404
- try {
1405
- const parsed = new URL(url, "wss://localhost");
1406
- const engine = parsed.searchParams.get("engine") ?? undefined;
1407
- return engine ? normalizeTwilioPhoneConversationEngine(engine) : undefined;
1408
- }
1409
- catch {
1410
- return undefined;
1411
- }
1412
- }
1413
- /* v8 ignore stop */
1414
1530
  function parseToolArguments(raw, name, selection) {
1415
1531
  if (typeof raw !== "string")
1416
1532
  throw new Error("invalid tool arguments: expected a JSON object string");
@@ -1534,9 +1650,9 @@ function pcmuPayloadDurationMs(payload) {
1534
1650
  return Math.max(1, Math.round(byteLength / OPENAI_REALTIME_PCMS_BYTES_PER_MS));
1535
1651
  }
1536
1652
  /* v8 ignore stop */
1537
- /* v8 ignore start -- Twilio Media Streams Realtime bridge is a fallback transport; direct SIP is the primary low-latency path and WebSocket integration tests cover representative behavior @preserve */
1538
1653
  class TwilioOpenAIRealtimeMediaStreamSession {
1539
- ws;
1654
+ socket;
1655
+ identity;
1540
1656
  options;
1541
1657
  lifecycle;
1542
1658
  streamSid = "";
@@ -1550,6 +1666,7 @@ class TwilioOpenAIRealtimeMediaStreamSession {
1550
1666
  sessionKey = "";
1551
1667
  sessionPath = "";
1552
1668
  closed = false;
1669
+ started = false;
1553
1670
  openaiReady = false;
1554
1671
  greetingSent = false;
1555
1672
  hangupRequested = false;
@@ -1583,15 +1700,16 @@ class TwilioOpenAIRealtimeMediaStreamSession {
1583
1700
  callerTurnSequence = 0;
1584
1701
  gatedResponseRequestSequence = 0;
1585
1702
  pendingGatedResponseId = null;
1586
- constructor(ws, options, lifecycle) {
1587
- this.ws = ws;
1703
+ constructor(socket, identity, options, lifecycle) {
1704
+ this.socket = socket;
1705
+ this.identity = identity;
1588
1706
  this.options = options;
1589
1707
  this.lifecycle = lifecycle;
1590
1708
  }
1591
1709
  attach() {
1592
- this.ws.on("message", (raw) => this.handleRawMessage(raw));
1593
- this.ws.on("close", () => this.close());
1594
- this.ws.on("error", (error) => {
1710
+ this.socket.on("message", (raw) => this.handleRawMessage(raw));
1711
+ this.socket.on("close", () => this.close());
1712
+ this.socket.on("error", (error) => {
1595
1713
  (0, runtime_1.emitNervesEvent)({
1596
1714
  level: "error",
1597
1715
  component: "senses",
@@ -1602,11 +1720,10 @@ class TwilioOpenAIRealtimeMediaStreamSession {
1602
1720
  });
1603
1721
  }
1604
1722
  end() {
1605
- if (this.ws.readyState === ws_1.WebSocket.OPEN || this.ws.readyState === ws_1.WebSocket.CONNECTING) {
1606
- this.ws.close();
1607
- }
1723
+ this.socket.close();
1608
1724
  this.close();
1609
1725
  }
1726
+ /** Feed a raw Twilio message that arrived before this session attached (the bridge holds the first `start`). */
1610
1727
  handleRawMessage(raw) {
1611
1728
  const message = parseTwilioMediaStreamMessage(raw);
1612
1729
  if (!message) {
@@ -1637,54 +1754,50 @@ class TwilioOpenAIRealtimeMediaStreamSession {
1637
1754
  }
1638
1755
  }
1639
1756
  async handleStart(start) {
1757
+ if (this.started)
1758
+ return;
1759
+ this.started = true;
1640
1760
  this.streamSid = stringField(start?.streamSid);
1641
- this.callSid = stringField(start?.callSid) || this.callSid;
1642
- this.direction = customParameter(start, "Direction");
1643
- this.outboundId = customParameter(start, "OutboundId");
1644
- this.outboundReason = customParameter(start, "Reason");
1645
- this.initialAudio = decodeVoiceCallAudioCustomParameter(customParameter(start, "InitialAudio"));
1646
- const explicitFriendId = customParameter(start, "FriendId");
1647
- if (this.direction === "outbound") {
1648
- this.from = customParameter(start, "Remote") || customParameter(start, "To");
1649
- this.to = customParameter(start, "Line") || customParameter(start, "From");
1650
- }
1651
- else {
1652
- this.from = customParameter(start, "From");
1653
- this.to = customParameter(start, "To");
1654
- }
1655
- const voiceContext = await resolveVoiceFriendContext(this.options, {
1656
- friendId: explicitFriendId,
1657
- remotePhone: this.from || undefined,
1658
- callSid: this.callSid,
1659
- });
1660
- this.friendId = voiceContext.friendId;
1661
- this.friendStore = voiceContext.friendStore;
1662
- this.resolvedContext = voiceContext.resolved;
1663
- this.sessionKey = twilioPhoneVoiceSessionKey({
1664
- defaultFriendId: this.friendId,
1665
- from: this.from,
1666
- to: this.to,
1667
- callSid: this.callSid,
1668
- });
1669
- this.lifecycle?.onIdentityChange?.(this, { callSid: this.callSid, outboundId: this.outboundId });
1670
- this.floor = new floor_controller_1.VoiceFloorController({
1671
- transport: "twilio-media-stream",
1672
- agentName: this.options.agentName,
1673
- callId: this.callSid,
1674
- });
1675
- this.floorUnsubscribe = this.floor.onTransition(() => this.flushQueuedGatedRealtimeRequest());
1676
- this.floor.apply({ type: "call.connected", atMs: Date.now(), callId: this.callSid });
1677
- (0, runtime_1.emitNervesEvent)({
1678
- component: "senses",
1679
- event: "senses.voice_twilio_realtime_start",
1680
- message: "Twilio OpenAI Realtime stream started",
1681
- meta: {
1682
- agentName: this.options.agentName,
1683
- callSid: safeSegment(this.callSid),
1684
- sessionKey: this.sessionKey,
1685
- },
1686
- });
1761
+ this.callSid = this.identity.callSid;
1762
+ this.direction = this.identity.direction;
1763
+ this.outboundId = this.identity.outboundId ?? "";
1764
+ this.outboundReason = this.identity.reason ?? "";
1765
+ this.initialAudio = decodeVoiceCallAudioCustomParameter(this.identity.initialAudio ?? "");
1766
+ this.from = this.identity.from;
1767
+ this.to = this.identity.to;
1687
1768
  try {
1769
+ const voiceContext = await resolveVoiceFriendContext(this.options, {
1770
+ friendId: this.direction === "outbound" ? this.identity.friendId : undefined,
1771
+ remotePhone: this.from || undefined,
1772
+ callSid: this.callSid,
1773
+ });
1774
+ this.friendId = voiceContext.friendId;
1775
+ this.friendStore = voiceContext.friendStore;
1776
+ this.resolvedContext = voiceContext.resolved;
1777
+ this.sessionKey = twilioPhoneVoiceSessionKey({
1778
+ defaultFriendId: this.friendId,
1779
+ from: this.from,
1780
+ to: this.to,
1781
+ callSid: this.callSid,
1782
+ });
1783
+ this.lifecycle?.onIdentityChange?.(this, { callSid: this.callSid, outboundId: this.outboundId });
1784
+ this.floor = new floor_controller_1.VoiceFloorController({
1785
+ transport: "twilio-media-stream",
1786
+ agentName: this.options.agentName,
1787
+ callId: this.callSid,
1788
+ });
1789
+ this.floorUnsubscribe = this.floor.onTransition(() => this.flushQueuedGatedRealtimeRequest());
1790
+ this.floor.apply({ type: "call.connected", atMs: Date.now(), callId: this.callSid });
1791
+ (0, runtime_1.emitNervesEvent)({
1792
+ component: "senses",
1793
+ event: "senses.voice_twilio_realtime_start",
1794
+ message: "Twilio OpenAI Realtime stream started",
1795
+ meta: {
1796
+ agentName: this.options.agentName,
1797
+ callSid: safeSegment(this.callSid),
1798
+ sessionKey: this.sessionKey,
1799
+ },
1800
+ });
1688
1801
  await this.startOpenAIRealtimeSession();
1689
1802
  }
1690
1803
  catch (error) {
@@ -1698,7 +1811,6 @@ class TwilioOpenAIRealtimeMediaStreamSession {
1698
1811
  this.end();
1699
1812
  }
1700
1813
  }
1701
- /* v8 ignore stop */
1702
1814
  async startOpenAIRealtimeSession() {
1703
1815
  const realtime = this.options.openaiRealtime;
1704
1816
  if (!realtime?.apiKey?.trim()) {
@@ -2460,8 +2572,9 @@ class TwilioOpenAIRealtimeMediaStreamSession {
2460
2572
  });
2461
2573
  }
2462
2574
  }
2575
+ /* v8 ignore stop */
2463
2576
  async playPreparedAudio(request, playbackOptions = {}) {
2464
- if (!this.streamSid || this.ws.readyState !== ws_1.WebSocket.OPEN) {
2577
+ if (!this.streamSid || !this.socket.isOpen()) {
2465
2578
  throw new Error("voice call media stream is not ready");
2466
2579
  }
2467
2580
  const prepared = await (0, audio_playback_1.prepareVoiceCallAudio)(request, {
@@ -2472,13 +2585,13 @@ class TwilioOpenAIRealtimeMediaStreamSession {
2472
2585
  if (playbackOptions.clearFirst ?? true)
2473
2586
  this.sendTwilioClear();
2474
2587
  for (let offset = 0; offset < prepared.audio.byteLength; offset += 160) {
2475
- if (this.closed || this.ws.readyState !== ws_1.WebSocket.OPEN)
2588
+ if (this.closed || !this.socket.isOpen())
2476
2589
  break;
2477
2590
  this.sendTwilioMedia(Buffer.from(prepared.audio.subarray(offset, offset + 160)).toString("base64"));
2478
2591
  await delay(20);
2479
2592
  }
2480
- if (!this.closed && this.ws.readyState === ws_1.WebSocket.OPEN) {
2481
- this.ws.send(JSON.stringify({
2593
+ if (!this.closed && this.socket.isOpen()) {
2594
+ this.socket.send(JSON.stringify({
2482
2595
  event: "mark",
2483
2596
  streamSid: this.streamSid,
2484
2597
  mark: { name: `tool-audio-${Date.now()}` },
@@ -2498,37 +2611,49 @@ class TwilioOpenAIRealtimeMediaStreamSession {
2498
2611
  return { label: prepared.label, durationMs: prepared.durationMs };
2499
2612
  }
2500
2613
  sendTwilioMedia(payload) {
2501
- if (this.closed || !this.streamSid || this.ws.readyState !== ws_1.WebSocket.OPEN)
2614
+ if (this.closed || !this.streamSid || !this.socket.isOpen())
2502
2615
  return;
2503
- this.ws.send(JSON.stringify({
2616
+ this.socket.send(JSON.stringify({
2504
2617
  event: "media",
2505
2618
  streamSid: this.streamSid,
2506
2619
  media: { payload },
2507
2620
  }));
2508
2621
  }
2509
2622
  sendTwilioMark(playback) {
2510
- if (this.closed || !this.streamSid || this.ws.readyState !== ws_1.WebSocket.OPEN)
2623
+ if (this.closed || !this.streamSid || !this.socket.isOpen())
2511
2624
  return;
2512
2625
  const name = `rt-${++this.playbackMarkIndex}`;
2513
2626
  this.playbackMarks.set(name, playback);
2514
- this.ws.send(JSON.stringify({
2627
+ this.socket.send(JSON.stringify({
2515
2628
  event: "mark",
2516
2629
  streamSid: this.streamSid,
2517
2630
  mark: { name },
2518
2631
  }));
2519
2632
  }
2520
2633
  sendTwilioClear() {
2521
- if (this.closed || !this.streamSid || this.ws.readyState !== ws_1.WebSocket.OPEN)
2634
+ if (this.closed || !this.streamSid || !this.socket.isOpen())
2522
2635
  return;
2523
- this.ws.send(JSON.stringify({ event: "clear", streamSid: this.streamSid }));
2636
+ this.socket.send(JSON.stringify({ event: "clear", streamSid: this.streamSid }));
2524
2637
  }
2525
2638
  appendTranscript(role, text) {
2526
2639
  const content = text.trim();
2527
2640
  if (!content || !this.sessionPath)
2528
2641
  return;
2529
2642
  this.sessionMessages.push({ role, content });
2530
- (0, context_1.saveSession)(this.sessionPath, this.sessionMessages);
2643
+ try {
2644
+ (0, context_1.saveSession)(this.sessionPath, this.sessionMessages);
2645
+ }
2646
+ catch (error) {
2647
+ (0, runtime_1.emitNervesEvent)({
2648
+ level: "error",
2649
+ component: "senses",
2650
+ event: "senses.voice_transcript_save_error",
2651
+ message: "voice transcript could not be saved; the call continues",
2652
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), error: errorMessage(error) },
2653
+ });
2654
+ }
2531
2655
  }
2656
+ /* v8 ignore start -- unchanged hangup and close transport @preserve */
2532
2657
  completeHangupIfReady(trigger) {
2533
2658
  if (!this.hangupRequested || this.closed)
2534
2659
  return;
@@ -2567,6 +2692,7 @@ class TwilioOpenAIRealtimeMediaStreamSession {
2567
2692
  });
2568
2693
  }
2569
2694
  }
2695
+ exports.TwilioOpenAIRealtimeMediaStreamSession = TwilioOpenAIRealtimeMediaStreamSession;
2570
2696
  /* v8 ignore stop */
2571
2697
  /* v8 ignore start -- direct SIP control has bridge-level coverage; provider failures, bootstrap races, and AMD timers are live-network edge permutations @preserve */
2572
2698
  class OpenAISipPhoneSession {
@@ -2627,7 +2753,7 @@ class OpenAISipPhoneSession {
2627
2753
  if (!sip)
2628
2754
  throw new Error("OpenAI SIP options are not configured");
2629
2755
  const voiceContext = await resolveVoiceFriendContext(this.options, {
2630
- friendId: this.metadata.friendId || this.options.defaultFriendId?.trim(),
2756
+ friendId: this.metadata.friendId || undefined,
2631
2757
  remotePhone: this.metadata.from || undefined,
2632
2758
  callSid: this.metadata.callId,
2633
2759
  });
@@ -3569,13 +3695,27 @@ class OpenAISipPhoneSession {
3569
3695
  return;
3570
3696
  this.openaiWs.send(JSON.stringify(event));
3571
3697
  }
3698
+ /* v8 ignore stop */
3572
3699
  appendTranscript(role, text) {
3573
3700
  const content = text.trim();
3701
+ /* v8 ignore next -- transcripts only arrive after start has set the session path @preserve */
3574
3702
  if (!content || !this.sessionPath)
3575
3703
  return;
3576
3704
  this.sessionMessages.push({ role, content });
3577
- (0, context_1.saveSession)(this.sessionPath, this.sessionMessages);
3705
+ try {
3706
+ (0, context_1.saveSession)(this.sessionPath, this.sessionMessages);
3707
+ }
3708
+ catch (error) {
3709
+ (0, runtime_1.emitNervesEvent)({
3710
+ level: "error",
3711
+ component: "senses",
3712
+ event: "senses.voice_transcript_save_error",
3713
+ message: "voice transcript could not be saved; the call continues",
3714
+ meta: { agentName: this.options.agentName, callId: safeSegment(this.metadata.callId), error: errorMessage(error) },
3715
+ });
3716
+ }
3578
3717
  }
3718
+ /* v8 ignore start -- unchanged SIP close transport @preserve */
3579
3719
  close(trigger) {
3580
3720
  if (this.closed)
3581
3721
  return;
@@ -3787,6 +3927,7 @@ class TwilioAudioStreamJobStore {
3787
3927
  return `${callSid}/${jobId}`;
3788
3928
  }
3789
3929
  }
3930
+ exports.TwilioAudioStreamJobStore = TwilioAudioStreamJobStore;
3790
3931
  function deliveredSegments(turn) {
3791
3932
  return turn.speechSegments.map((segment) => segment.tts);
3792
3933
  }
@@ -3913,7 +4054,7 @@ function computeTwilioSignature(input) {
3913
4054
  }
3914
4055
  function validateTwilioSignature(input) {
3915
4056
  if (!input.authToken.trim())
3916
- return true;
4057
+ return false;
3917
4058
  if (!input.signature.trim())
3918
4059
  return false;
3919
4060
  const expected = Buffer.from(computeTwilioSignature(input));
@@ -4066,7 +4207,7 @@ async function createTwilioOutboundCall(request, fetchImpl = fetch) {
4066
4207
  function verifyRequest(options, request, params) {
4067
4208
  const authToken = options.twilioAuthToken?.trim();
4068
4209
  if (!authToken)
4069
- return true;
4210
+ return false;
4070
4211
  return validateTwilioSignature({
4071
4212
  authToken,
4072
4213
  url: requestPublicUrl(options.publicBaseUrl, request.path),
@@ -4074,7 +4215,37 @@ function verifyRequest(options, request, params) {
4074
4215
  signature: headerValue(request.headers, "x-twilio-signature"),
4075
4216
  });
4076
4217
  }
4077
- async function handleOpenAISipWebhook(options, request, activeSipSessions) {
4218
+ async function rejectOpenAISipCallById(options, callId, reason) {
4219
+ (0, runtime_1.emitNervesEvent)({
4220
+ level: "warn",
4221
+ component: "senses",
4222
+ event: "senses.voice_openai_sip_call_refused",
4223
+ message: "refused an OpenAI SIP call that did not carry a valid call token",
4224
+ meta: { agentName: options.agentName, callId: safeSegment(callId), reason },
4225
+ });
4226
+ const sip = options.openaiSip;
4227
+ const apiKey = options.openaiRealtime?.apiKey?.trim();
4228
+ if (!sip || !apiKey)
4229
+ return;
4230
+ try {
4231
+ const response = await (sip.fetch ?? fetch)(openAISipCallActionUrl(sip, callId, "reject"), {
4232
+ method: "POST",
4233
+ headers: { authorization: `Bearer ${apiKey}` },
4234
+ });
4235
+ if (!response.ok)
4236
+ throw new Error(`OpenAI SIP call reject failed: ${response.status}`);
4237
+ }
4238
+ catch (error) {
4239
+ (0, runtime_1.emitNervesEvent)({
4240
+ level: "error",
4241
+ component: "senses",
4242
+ event: "senses.voice_openai_sip_call_reject_error",
4243
+ message: "OpenAI SIP call reject request failed",
4244
+ meta: { agentName: options.agentName, callId: safeSegment(callId), trigger: reason, error: errorMessage(error) },
4245
+ });
4246
+ }
4247
+ }
4248
+ async function handleOpenAISipWebhook(options, request, activeSipSessions, pending, recentSipWebhooks) {
4078
4249
  const rawBody = bodyText(request.body);
4079
4250
  const sip = options.openaiSip;
4080
4251
  const webhookSecret = sip?.webhookSecret?.trim();
@@ -4107,26 +4278,54 @@ async function handleOpenAISipWebhook(options, request, activeSipSessions) {
4107
4278
  return textResponse(400, "invalid OpenAI webhook payload");
4108
4279
  if (event.type !== "realtime.call.incoming")
4109
4280
  return textResponse(200, "ok");
4110
- const metadata = openAISipCallMetadata(event);
4111
- if (!metadata)
4281
+ const callId = openAISipCallId(event);
4282
+ if (!callId)
4112
4283
  return textResponse(400, "missing OpenAI SIP call metadata");
4113
- const session = new OpenAISipPhoneSession(options, metadata, activeSipSessions);
4284
+ const webhookId = headerValue(request.headers, "webhook-id");
4285
+ const newCall = recentSipWebhooks.add(`call:${callId}`);
4286
+ const newWebhook = webhookId ? recentSipWebhooks.add(`webhook:${webhookId}`) : true;
4287
+ if (!newCall || !newWebhook) {
4288
+ (0, runtime_1.emitNervesEvent)({
4289
+ level: "warn",
4290
+ component: "senses",
4291
+ event: "senses.voice_openai_sip_webhook_duplicate",
4292
+ message: "ignored a duplicate OpenAI SIP incoming-call webhook",
4293
+ meta: { agentName: options.agentName, callId: safeSegment(callId) },
4294
+ });
4295
+ return textResponse(200, "ok");
4296
+ }
4297
+ const admission = admitOpenAISipCall(options, pending, event);
4298
+ if (!admission.ok) {
4299
+ await rejectOpenAISipCallById(options, callId, admission.reason);
4300
+ return textResponse(200, "ok");
4301
+ }
4302
+ const session = new OpenAISipPhoneSession(options, admission.metadata, activeSipSessions);
4114
4303
  /* v8 ignore next -- async SIP session startup failures are logged inside the session; webhook request intentionally returns immediately @preserve */
4115
4304
  void session.start().catch(() => undefined);
4116
4305
  return textResponse(200, "ok");
4117
4306
  }
4118
- /* v8 ignore start -- Twilio webhook routing is covered by bridge/server tests; branch matrix mostly reflects transport fallbacks and provider callback variants @preserve */
4119
- async function handleIncoming(options, basePath, params, jobs) {
4307
+ async function handleIncoming(options, basePath, params, jobs, pending) {
4120
4308
  const callSid = params.CallSid?.trim() || "incoming";
4121
4309
  const safeCallSid = safeSegment(callSid);
4122
4310
  const callDir = path.join(options.outputDir, safeCallSid);
4123
4311
  const utteranceId = `twilio-${safeCallSid}-connected`;
4124
- const friendId = voiceFriendId(options, params.From?.trim() ?? "", callSid);
4125
- const sessionKey = twilioPhoneVoiceSessionKey({
4126
- defaultFriendId: options.defaultFriendId,
4127
- from: params.From?.trim() ?? "",
4128
- to: params.To?.trim() ?? "",
4129
- callSid,
4312
+ const from = params.From?.trim() ?? "";
4313
+ const to = params.To?.trim() ?? "";
4314
+ // Inbound callers are identified by the signed caller ID, never by defaultFriendId.
4315
+ const sessionKey = twilioPhoneVoiceSessionKey({ from, to, callSid });
4316
+ const legacyContext = async () => {
4317
+ const friendId = await inboundVoiceFriendId(options, from, callSid);
4318
+ return { friendId, sessionKey: twilioPhoneVoiceSessionKey({ defaultFriendId: friendId, from, to, callSid }) };
4319
+ };
4320
+ const admit = (engine, greetingJobId) => admitMediaStreamCall(options, basePath, pending, {
4321
+ callSid: params.CallSid?.trim() ?? "",
4322
+ agentName: options.agentName,
4323
+ direction: "inbound",
4324
+ from,
4325
+ to,
4326
+ engine,
4327
+ ...(greetingJobId ? { greetingJobId } : {}),
4328
+ ...(params.StirVerstat?.trim() ? { stirVerstat: params.StirVerstat.trim() } : {}),
4130
4329
  });
4131
4330
  (0, runtime_1.emitNervesEvent)({
4132
4331
  component: "senses",
@@ -4141,12 +4340,15 @@ async function handleIncoming(options, basePath, params, jobs) {
4141
4340
  message: "answering Twilio call by dialing OpenAI SIP",
4142
4341
  meta: { agentName: options.agentName, callSid: safeCallSid, sessionKey, conversationEngine: "openai-sip" },
4143
4342
  });
4144
- return xmlResponse(openAISipDialTwiml(options, openAISipResponseHeaders({
4145
- Agent: options.agentName,
4146
- Direction: "inbound",
4147
- From: params.From,
4148
- To: params.To,
4149
- })));
4343
+ return xmlResponse(admitSipCall(options, pending, {
4344
+ callSid: params.CallSid?.trim() ?? "",
4345
+ agentName: options.agentName,
4346
+ direction: "inbound",
4347
+ from,
4348
+ to,
4349
+ engine: "openai-sip",
4350
+ ...(params.StirVerstat?.trim() ? { stirVerstat: params.StirVerstat.trim() } : {}),
4351
+ }));
4150
4352
  }
4151
4353
  if (normalizeTwilioPhoneTransportMode(options.transportMode) === "media-stream") {
4152
4354
  if (usesOpenAIRealtimeConversationEngine(options)) {
@@ -4156,9 +4358,10 @@ async function handleIncoming(options, basePath, params, jobs) {
4156
4358
  message: "answering Twilio call with OpenAI Realtime Media Stream",
4157
4359
  meta: { agentName: options.agentName, callSid: safeCallSid, sessionKey, conversationEngine: "openai-realtime" },
4158
4360
  });
4159
- return xmlResponse(mediaStreamTwiml(options, basePath, params));
4361
+ return xmlResponse(admit("openai-realtime"));
4160
4362
  }
4161
4363
  try {
4364
+ const { friendId, sessionKey: legacySessionKey } = await legacyContext();
4162
4365
  await fs.mkdir(callDir, { recursive: true });
4163
4366
  const transcript = (0, transcript_1.buildVoiceTranscript)({
4164
4367
  utteranceId,
@@ -4177,7 +4380,7 @@ async function handleIncoming(options, basePath, params, jobs) {
4177
4380
  runTurn: (onAudioChunk) => (0, turn_1.runVoiceLoopbackTurn)({
4178
4381
  agentName: options.agentName,
4179
4382
  friendId,
4180
- sessionKey,
4383
+ sessionKey: legacySessionKey,
4181
4384
  transcript,
4182
4385
  tts: options.tts,
4183
4386
  runSenseTurn: options.runSenseTurn,
@@ -4198,7 +4401,7 @@ async function handleIncoming(options, basePath, params, jobs) {
4198
4401
  message: "answering Twilio call with a bidirectional Media Stream",
4199
4402
  meta: { agentName: options.agentName, callSid: safeCallSid, sessionKey, greetingJob: prebufferState },
4200
4403
  });
4201
- return xmlResponse(mediaStreamTwiml(options, basePath, params, prebufferState === "failed" ? undefined : greetingJobId));
4404
+ return xmlResponse(admit("cascade", greetingJobId));
4202
4405
  }
4203
4406
  catch (error) {
4204
4407
  (0, runtime_1.emitNervesEvent)({
@@ -4208,10 +4411,11 @@ async function handleIncoming(options, basePath, params, jobs) {
4208
4411
  message: "Twilio incoming media-stream greeting turn failed",
4209
4412
  meta: { agentName: options.agentName, callSid: safeCallSid, error: errorMessage(error), transportMode: "media-stream" },
4210
4413
  });
4211
- return xmlResponse(mediaStreamTwiml(options, basePath, params));
4414
+ return xmlResponse(admit("cascade"));
4212
4415
  }
4213
4416
  }
4214
4417
  try {
4418
+ const { friendId, sessionKey: legacySessionKey } = await legacyContext();
4215
4419
  await fs.mkdir(callDir, { recursive: true });
4216
4420
  if (normalizeTwilioPhonePlaybackMode(options.playbackMode) === "stream") {
4217
4421
  const transcript = (0, transcript_1.buildVoiceTranscript)({
@@ -4231,7 +4435,7 @@ async function handleIncoming(options, basePath, params, jobs) {
4231
4435
  runTurn: (onAudioChunk) => (0, turn_1.runVoiceLoopbackTurn)({
4232
4436
  agentName: options.agentName,
4233
4437
  friendId,
4234
- sessionKey,
4438
+ sessionKey: legacySessionKey,
4235
4439
  transcript,
4236
4440
  tts: options.tts,
4237
4441
  runSenseTurn: options.runSenseTurn,
@@ -4246,14 +4450,6 @@ async function handleIncoming(options, basePath, params, jobs) {
4246
4450
  message: "Twilio greeting prebuffer completed",
4247
4451
  meta: { agentName: options.agentName, callSid: safeCallSid, utteranceId, state: prebufferState },
4248
4452
  });
4249
- if (prebufferState === "failed") {
4250
- return xmlResponse(recordTwiml({
4251
- publicBaseUrl: options.publicBaseUrl,
4252
- basePath,
4253
- timeoutSeconds: options.recordTimeoutSeconds ?? exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS,
4254
- maxLengthSeconds: options.recordMaxLengthSeconds ?? exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS,
4255
- }));
4256
- }
4257
4453
  return xmlResponse(`${playTwiml(streamAudioUrl(options, basePath, safeCallSid, jobId))}${nextInputTwiml(options, basePath, "record")}`);
4258
4454
  }
4259
4455
  return await runPhonePromptTurn({
@@ -4263,7 +4459,7 @@ async function handleIncoming(options, basePath, params, jobs) {
4263
4459
  safeCallSid,
4264
4460
  utteranceId,
4265
4461
  friendId,
4266
- sessionKey,
4462
+ sessionKey: legacySessionKey,
4267
4463
  promptText: callConnectedPrompt(params),
4268
4464
  afterPlayback: "record",
4269
4465
  });
@@ -4284,7 +4480,7 @@ async function handleIncoming(options, basePath, params, jobs) {
4284
4480
  }));
4285
4481
  }
4286
4482
  }
4287
- async function handleOutgoing(options, basePath, outboundId, params, jobs) {
4483
+ async function handleOutgoing(options, basePath, outboundId, params, jobs, pending) {
4288
4484
  const job = await readTwilioOutboundCallJob(options.outputDir, outboundId);
4289
4485
  if (!job)
4290
4486
  return textResponse(404, "outbound voice call not found");
@@ -4299,7 +4495,7 @@ async function handleOutgoing(options, basePath, outboundId, params, jobs) {
4299
4495
  transportCallSid: callSid,
4300
4496
  events: [
4301
4497
  ...(job.events ?? []),
4302
- { at: new Date().toISOString(), status: nonHumanStatus, callSid, ...(answeredBy ? { answeredBy } : {}) },
4498
+ { at: new Date().toISOString(), status: nonHumanStatus, callSid, answeredBy },
4303
4499
  ],
4304
4500
  });
4305
4501
  (0, runtime_1.emitNervesEvent)({
@@ -4311,7 +4507,7 @@ async function handleOutgoing(options, basePath, outboundId, params, jobs) {
4311
4507
  callSid: safeCallSid,
4312
4508
  outboundId: safeSegment(job.outboundId),
4313
4509
  status: nonHumanStatus,
4314
- answeredBy: answeredBy ?? "unknown",
4510
+ answeredBy: String(answeredBy),
4315
4511
  },
4316
4512
  });
4317
4513
  return xmlResponse("<Hangup />");
@@ -4342,15 +4538,21 @@ async function handleOutgoing(options, basePath, outboundId, params, jobs) {
4342
4538
  message: "Twilio outbound voice call answered",
4343
4539
  meta: { agentName: options.agentName, callSid: safeCallSid, outboundId: safeSegment(job.outboundId), sessionKey },
4344
4540
  });
4345
- const streamParams = {
4346
- Direction: "outbound",
4347
- Remote: to,
4348
- Line: from,
4349
- FriendId: friendId,
4350
- OutboundId: job.outboundId,
4351
- Reason: job.reason,
4352
- InitialAudio: encodeVoiceCallAudioCustomParameter(job.initialAudio),
4353
- };
4541
+ // What the stream or SIP leg will be told about this call comes from the outbound job record
4542
+ // and the signed webhook, held server-side; the media stream never supplies it.
4543
+ const outboundIdentity = (engine, greetingJobId) => ({
4544
+ callSid: params.CallSid?.trim() ?? "",
4545
+ agentName: options.agentName,
4546
+ direction: "outbound",
4547
+ from: to,
4548
+ to: from,
4549
+ outboundId: job.outboundId,
4550
+ friendId,
4551
+ reason: job.reason,
4552
+ ...(job.initialAudio ? { initialAudio: encodeVoiceCallAudioCustomParameter(job.initialAudio) } : {}),
4553
+ engine,
4554
+ ...(greetingJobId ? { greetingJobId } : {}),
4555
+ });
4354
4556
  if (usesOpenAISipOutboundConversationEngine(options)) {
4355
4557
  (0, runtime_1.emitNervesEvent)({
4356
4558
  component: "senses",
@@ -4358,20 +4560,11 @@ async function handleOutgoing(options, basePath, outboundId, params, jobs) {
4358
4560
  message: "answering Twilio outbound call by dialing OpenAI SIP",
4359
4561
  meta: { agentName: options.agentName, callSid: safeCallSid, outboundId: safeSegment(job.outboundId), sessionKey, conversationEngine: "openai-sip" },
4360
4562
  });
4361
- return xmlResponse(openAISipDialTwiml(options, openAISipResponseHeaders({
4362
- Agent: options.agentName,
4363
- Direction: "outbound",
4364
- From: to,
4365
- To: from,
4366
- }, {
4367
- "X-Ouro-Outbound-Id": job.outboundId,
4368
- "X-Ouro-Friend-Id": friendId,
4369
- "X-Ouro-Reason": job.reason,
4370
- })));
4563
+ return xmlResponse(admitSipCall(options, pending, outboundIdentity("openai-sip")));
4371
4564
  }
4372
4565
  if (normalizeTwilioPhoneTransportMode(options.transportMode) === "media-stream") {
4373
4566
  if (usesOpenAIRealtimeOutboundConversationEngine(options)) {
4374
- return xmlResponse(mediaStreamTwiml(options, basePath, { From: from, To: to }, undefined, streamParams, "openai-realtime"));
4567
+ return xmlResponse(admitMediaStreamCall(options, basePath, pending, outboundIdentity("openai-realtime")));
4375
4568
  }
4376
4569
  try {
4377
4570
  await fs.mkdir(callDir, { recursive: true });
@@ -4393,7 +4586,7 @@ async function handleOutgoing(options, basePath, outboundId, params, jobs) {
4393
4586
  byteLength: String(job.prewarmedGreeting.byteLength),
4394
4587
  },
4395
4588
  });
4396
- return xmlResponse(mediaStreamTwiml(options, basePath, { From: from, To: to }, greetingJobId, streamParams));
4589
+ return xmlResponse(admitMediaStreamCall(options, basePath, pending, outboundIdentity("cascade", greetingJobId)));
4397
4590
  }
4398
4591
  catch (error) {
4399
4592
  (0, runtime_1.emitNervesEvent)({
@@ -4442,7 +4635,7 @@ async function handleOutgoing(options, basePath, outboundId, params, jobs) {
4442
4635
  message: "Twilio Media Stream greeting prebuffer completed",
4443
4636
  meta: { agentName: options.agentName, callSid: safeCallSid, utteranceId, state: prebufferState, transportMode: "media-stream" },
4444
4637
  });
4445
- return xmlResponse(mediaStreamTwiml(options, basePath, { From: from, To: to }, prebufferState === "failed" ? undefined : greetingJobId, streamParams));
4638
+ return xmlResponse(admitMediaStreamCall(options, basePath, pending, outboundIdentity("cascade", greetingJobId)));
4446
4639
  }
4447
4640
  catch (error) {
4448
4641
  (0, runtime_1.emitNervesEvent)({
@@ -4452,9 +4645,7 @@ async function handleOutgoing(options, basePath, outboundId, params, jobs) {
4452
4645
  message: "Twilio outbound media-stream greeting turn failed",
4453
4646
  meta: { agentName: options.agentName, callSid: safeCallSid, outboundId: safeSegment(job.outboundId), error: errorMessage(error), transportMode: "media-stream" },
4454
4647
  });
4455
- return xmlResponse(mediaStreamTwiml(options, basePath, { From: from, To: to }, undefined, {
4456
- ...streamParams,
4457
- }));
4648
+ return xmlResponse(admitMediaStreamCall(options, basePath, pending, outboundIdentity("cascade")));
4458
4649
  }
4459
4650
  }
4460
4651
  try {
@@ -4487,6 +4678,7 @@ async function handleOutgoing(options, basePath, outboundId, params, jobs) {
4487
4678
  }));
4488
4679
  }
4489
4680
  }
4681
+ /* v8 ignore start -- unchanged outbound status and AMD callbacks; the AMD lookup is not part of this change @preserve */
4490
4682
  async function handleOutgoingStatus(options, outboundId, params) {
4491
4683
  const job = await readTwilioOutboundCallJob(options.outputDir, outboundId);
4492
4684
  if (!job)
@@ -4564,6 +4756,7 @@ async function handleOutgoingAmdStatus(options, outboundId, params, activeMediaS
4564
4756
  });
4565
4757
  return textResponse(200, "ok");
4566
4758
  }
4759
+ /* v8 ignore stop */
4567
4760
  async function handleListen(options, basePath) {
4568
4761
  return xmlResponse(recordTwiml({
4569
4762
  publicBaseUrl: options.publicBaseUrl,
@@ -4590,9 +4783,14 @@ async function handleRecording(options, basePath, params, jobs) {
4590
4783
  const inputPath = path.join(callDir, `${safeRecordingSid}.wav`);
4591
4784
  const utteranceId = `twilio-${safeCallSid}-${safeRecordingSid}`;
4592
4785
  const downloadRecording = options.downloadRecording ?? defaultTwilioRecordingDownloader;
4593
- const friendId = voiceFriendId(options, recording.from, recording.callSid);
4786
+ // Outbound calls are placed by us (defaultFriendId stays their fallback); inbound callers are
4787
+ // identified by the signed caller ID only.
4788
+ const outboundCall = (params.Direction ?? "").trim().toLowerCase().startsWith("outbound");
4789
+ const friendId = outboundCall
4790
+ ? voiceFriendId(options, recording.from, recording.callSid)
4791
+ : await inboundVoiceFriendId(options, recording.from, recording.callSid);
4594
4792
  const sessionKey = twilioPhoneVoiceSessionKey({
4595
- defaultFriendId: options.defaultFriendId,
4793
+ defaultFriendId: outboundCall ? options.defaultFriendId : friendId,
4596
4794
  from: recording.from,
4597
4795
  to: recording.to,
4598
4796
  callSid: recording.callSid,
@@ -4620,7 +4818,7 @@ async function handleRecording(options, basePath, params, jobs) {
4620
4818
  const audio = await downloadRecording({
4621
4819
  recordingUrl: mediaUrl,
4622
4820
  accountSid: options.twilioAccountSid?.trim() || undefined,
4623
- authToken: options.twilioAuthToken?.trim() || undefined,
4821
+ authToken: options.twilioAuthToken?.trim(),
4624
4822
  });
4625
4823
  await fs.writeFile(inputPath, audio);
4626
4824
  const turnTranscript = await transcribeRecordingOrNoSpeech({
@@ -4647,7 +4845,7 @@ async function handleRecording(options, basePath, params, jobs) {
4647
4845
  const audio = await downloadRecording({
4648
4846
  recordingUrl: mediaUrl,
4649
4847
  accountSid: options.twilioAccountSid?.trim() || undefined,
4650
- authToken: options.twilioAuthToken?.trim() || undefined,
4848
+ authToken: options.twilioAuthToken?.trim(),
4651
4849
  });
4652
4850
  await fs.writeFile(inputPath, audio);
4653
4851
  const transcript = await transcribeRecordingOrNoSpeech({
@@ -4763,6 +4961,50 @@ async function handleAudioStream(options, basePath, requestPath, jobs) {
4763
4961
  });
4764
4962
  return streamResponse(job.stream(), job.mimeType);
4765
4963
  }
4964
+ const MEDIA_STREAM_START_TIMEOUT_MS = 10_000;
4965
+ /**
4966
+ * Check a Media Stream `start` against what the signed webhook recorded. The only accepted
4967
+ * identity is a record consumed with a valid, unexpired, single-use token for this agent and
4968
+ * this CallSid; stream parameters never supply identity.
4969
+ */
4970
+ function admitMediaStreamStart(options, pending, activeMediaStreams, start) {
4971
+ const callSid = stringField(start?.callSid);
4972
+ const refuse = (reason, rememberRejection) => {
4973
+ if (rememberRejection)
4974
+ pending.markRejected(callSid);
4975
+ (0, runtime_1.emitNervesEvent)({
4976
+ level: "warn",
4977
+ component: "senses",
4978
+ event: "senses.voice_media_stream_rejected",
4979
+ message: "rejected a Twilio Media Stream start",
4980
+ meta: { agentName: options.agentName, callSid: safeSegment(callSid || "unknown"), reason },
4981
+ });
4982
+ return null;
4983
+ };
4984
+ const secret = options.twilioAuthToken?.trim();
4985
+ if (!secret)
4986
+ return refuse("no_secret", false);
4987
+ const verified = (0, call_auth_1.verifyVoiceCallToken)({
4988
+ secret,
4989
+ purpose: "stream",
4990
+ token: customParameter(start, "OuroToken"),
4991
+ agentName: options.agentName,
4992
+ callSid,
4993
+ nowMs: Date.now(),
4994
+ });
4995
+ if (!verified.ok)
4996
+ return refuse(verified.reason, false);
4997
+ const identity = pending.consume(callSid, verified.nonce);
4998
+ if (!identity)
4999
+ return refuse("no_record", true);
5000
+ if (identity.direction !== verified.direction || (identity.outboundId ?? "") !== (verified.outboundId ?? "")) {
5001
+ return refuse("record_mismatch", true);
5002
+ }
5003
+ if (activeMediaStreams.reserved.has(callSid) || activeMediaStreams.byCallSid.has(callSid))
5004
+ return refuse("duplicate_call", false);
5005
+ activeMediaStreams.reserved.add(callSid);
5006
+ return identity;
5007
+ }
4766
5008
  function createTwilioPhoneBridge(options) {
4767
5009
  new URL(options.publicBaseUrl);
4768
5010
  const basePath = normalizeTwilioPhoneBasePath(options.basePath);
@@ -4772,30 +5014,77 @@ function createTwilioPhoneBridge(options) {
4772
5014
  const activeMediaStreams = {
4773
5015
  byCallSid: new Map(),
4774
5016
  byOutboundId: new Map(),
5017
+ reserved: new Set(),
4775
5018
  };
4776
5019
  const activeSipSessions = new ActiveOpenAISipSessions();
4777
- mediaStreams.on("connection", (ws, request) => {
5020
+ const pendingCalls = options.pendingVoiceCalls ?? new call_auth_1.PendingVoiceCalls();
5021
+ const recentSipWebhooks = new call_auth_1.RecentIds();
5022
+ mediaStreams.on("connection", (ws) => {
5023
+ // A malformed frame makes `ws` emit `error`; with no listener that is an uncaught exception,
5024
+ // and this socket is reachable before any start has been verified.
5025
+ ws.on("error", (error) => {
5026
+ (0, runtime_1.emitNervesEvent)({
5027
+ level: "warn",
5028
+ component: "senses",
5029
+ event: "senses.voice_media_stream_socket_error",
5030
+ message: "a Twilio Media Stream socket raised an error",
5031
+ meta: { agentName: options.agentName, error: error.message },
5032
+ });
5033
+ });
5034
+ let reservedCallSid = "";
4778
5035
  const lifecycle = {
4779
5036
  onIdentityChange: (activeSession, identity) => {
4780
- if (identity.callSid)
4781
- activeMediaStreams.byCallSid.set(identity.callSid, activeSession);
5037
+ activeMediaStreams.byCallSid.set(identity.callSid, activeSession);
4782
5038
  if (identity.outboundId)
4783
5039
  activeMediaStreams.byOutboundId.set(identity.outboundId, activeSession);
4784
5040
  },
4785
5041
  onClose: (activeSession, identity) => {
4786
- if (identity.callSid && activeMediaStreams.byCallSid.get(identity.callSid) === activeSession) {
4787
- activeMediaStreams.byCallSid.delete(identity.callSid);
4788
- }
5042
+ // The CallSid reservation and single-use token mean no other session can hold this CallSid.
5043
+ activeMediaStreams.byCallSid.delete(identity.callSid);
4789
5044
  if (identity.outboundId && activeMediaStreams.byOutboundId.get(identity.outboundId) === activeSession) {
4790
5045
  activeMediaStreams.byOutboundId.delete(identity.outboundId);
4791
5046
  }
4792
5047
  },
4793
5048
  };
4794
- const streamEngine = mediaStreamRequestedConversationEngine(request.url);
4795
- const session = streamEngine === "openai-realtime" || (!streamEngine && usesOpenAIRealtimeConversationEngine(options))
4796
- ? new TwilioOpenAIRealtimeMediaStreamSession(ws, options, lifecycle)
4797
- : new TwilioMediaStreamSession(ws, options, jobs, lifecycle);
4798
- session.attach();
5049
+ const socket = wsAsVoiceSessionSocket(ws);
5050
+ const startTimer = setTimeout(() => {
5051
+ ws.off("message", gate);
5052
+ (0, runtime_1.emitNervesEvent)({
5053
+ level: "warn",
5054
+ component: "senses",
5055
+ event: "senses.voice_media_stream_rejected",
5056
+ message: "rejected a Twilio Media Stream that never sent start",
5057
+ meta: { agentName: options.agentName, callSid: "unknown", reason: "start_timeout" },
5058
+ });
5059
+ socket.close();
5060
+ }, MEDIA_STREAM_START_TIMEOUT_MS);
5061
+ ws.on("close", () => {
5062
+ clearTimeout(startTimer);
5063
+ if (reservedCallSid)
5064
+ activeMediaStreams.reserved.delete(reservedCallSid);
5065
+ });
5066
+ // Nothing is decided about a stream until its first `start` is verified; the session class
5067
+ // is chosen from the consumed record, and the verified start is replayed into it.
5068
+ const gate = (raw) => {
5069
+ const message = parseTwilioMediaStreamMessage(raw);
5070
+ if (!message || stringField(message.event) !== "start")
5071
+ return;
5072
+ clearTimeout(startTimer);
5073
+ ws.off("message", gate);
5074
+ const identity = admitMediaStreamStart(options, pendingCalls, activeMediaStreams, message.start);
5075
+ if (!identity) {
5076
+ socket.close();
5077
+ return;
5078
+ }
5079
+ reservedCallSid = identity.callSid;
5080
+ const realtime = identity.engine === "openai-realtime" || (!identity.engine && usesOpenAIRealtimeConversationEngine(options));
5081
+ const session = realtime
5082
+ ? new TwilioOpenAIRealtimeMediaStreamSession(socket, identity, options, lifecycle)
5083
+ : new TwilioMediaStreamSession(socket, identity, options, jobs, lifecycle);
5084
+ session.attach();
5085
+ session.handleRawMessage(raw);
5086
+ };
5087
+ ws.on("message", gate);
4799
5088
  });
4800
5089
  return {
4801
5090
  async handle(request) {
@@ -4819,7 +5108,7 @@ function createTwilioPhoneBridge(options) {
4819
5108
  if (method !== "POST")
4820
5109
  return textResponse(405, "method not allowed");
4821
5110
  if (routePath === sipWebhookPath) {
4822
- return handleOpenAISipWebhook(options, { ...request, path: requestPath }, activeSipSessions);
5111
+ return handleOpenAISipWebhook(options, { ...request, path: requestPath }, activeSipSessions, pendingCalls, recentSipWebhooks);
4823
5112
  }
4824
5113
  const params = formParams(bodyText(request.body));
4825
5114
  if (!verifyRequest(options, { ...request, path: requestPath }, params)) {
@@ -4833,7 +5122,12 @@ function createTwilioPhoneBridge(options) {
4833
5122
  return textResponse(403, "invalid Twilio signature");
4834
5123
  }
4835
5124
  if (routePath === `${basePath}/incoming`)
4836
- return handleIncoming(options, basePath, params, jobs);
5125
+ return handleIncoming(options, basePath, params, jobs, pendingCalls);
5126
+ if (routePath === `${basePath}/stream-ended`) {
5127
+ // `<Connect action>` fires when the stream ends. A normal end just hangs up; a call whose
5128
+ // stream was refused (or never connected) hears the failure line instead of dead air.
5129
+ return xmlResponse(pendingCalls.settle(params.CallSid?.trim() ?? "") ? streamFailureTwiml() : "<Hangup />");
5130
+ }
4837
5131
  if (routePath.startsWith(`${basePath}/outgoing/`)) {
4838
5132
  const outgoingRest = routePath.slice(`${basePath}/outgoing/`.length);
4839
5133
  const [outboundIdPart, suffix] = outgoingRest.split("/");
@@ -4845,7 +5139,7 @@ function createTwilioPhoneBridge(options) {
4845
5139
  if (suffix === "amd")
4846
5140
  return handleOutgoingAmdStatus(options, outboundId, params, activeMediaStreams, activeSipSessions);
4847
5141
  if (suffix === undefined)
4848
- return handleOutgoing(options, basePath, outboundId, params, jobs);
5142
+ return handleOutgoing(options, basePath, outboundId, params, jobs, pendingCalls);
4849
5143
  }
4850
5144
  if (routePath === `${basePath}/listen`)
4851
5145
  return handleListen(options, basePath);
@@ -4878,7 +5172,6 @@ function createTwilioPhoneBridge(options) {
4878
5172
  },
4879
5173
  };
4880
5174
  }
4881
- /* v8 ignore stop */
4882
5175
  /* v8 ignore start -- HTTP server adapter behavior is covered through startTwilioPhoneBridgeServer smoke tests; low-level stream disconnect branches are platform-dependent @preserve */
4883
5176
  function readRequestBody(req, limitBytes = 1_000_000) {
4884
5177
  return new Promise((resolve, reject) => {