@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.
- package/changelog.json +6 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/senses/voice/call-auth.js +248 -0
- package/dist/senses/voice/process-guards.js +51 -0
- package/dist/senses/voice/twilio-phone-runtime.js +13 -0
- package/dist/senses/voice/twilio-phone.js +596 -303
- package/dist/senses/voice-entry.js +2 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
255
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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 ||
|
|
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 =
|
|
460
|
-
? raw
|
|
461
|
-
:
|
|
462
|
-
?
|
|
463
|
-
:
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
629
|
-
this.
|
|
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.
|
|
640
|
-
this.
|
|
641
|
-
this.
|
|
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
|
-
|
|
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 =
|
|
689
|
-
|
|
690
|
-
this.
|
|
691
|
-
this.
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
this.
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
this.
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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.
|
|
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
|
|
942
|
-
sessionKey: this.sessionKey
|
|
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.
|
|
1121
|
+
if (this.closed || generation !== this.playbackGeneration || !this.streamSid || !this.socket.isOpen())
|
|
1036
1122
|
return;
|
|
1037
|
-
this.
|
|
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.
|
|
1131
|
+
if (this.closed || generation !== this.playbackGeneration || !this.streamSid || !this.socket.isOpen())
|
|
1046
1132
|
return;
|
|
1047
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1257
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
const
|
|
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
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
-
|
|
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(
|
|
1587
|
-
this.
|
|
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.
|
|
1593
|
-
this.
|
|
1594
|
-
this.
|
|
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
|
-
|
|
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 =
|
|
1642
|
-
this.direction =
|
|
1643
|
-
this.outboundId =
|
|
1644
|
-
this.outboundReason =
|
|
1645
|
-
this.initialAudio = decodeVoiceCallAudioCustomParameter(
|
|
1646
|
-
|
|
1647
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
2481
|
-
this.
|
|
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.
|
|
2614
|
+
if (this.closed || !this.streamSid || !this.socket.isOpen())
|
|
2502
2615
|
return;
|
|
2503
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
2634
|
+
if (this.closed || !this.streamSid || !this.socket.isOpen())
|
|
2522
2635
|
return;
|
|
2523
|
-
this.
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4111
|
-
if (!
|
|
4281
|
+
const callId = openAISipCallId(event);
|
|
4282
|
+
if (!callId)
|
|
4112
4283
|
return textResponse(400, "missing OpenAI SIP call metadata");
|
|
4113
|
-
const
|
|
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
|
-
|
|
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
|
|
4125
|
-
const
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
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(
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
|
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
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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()
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4787
|
-
|
|
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
|
|
4795
|
-
const
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
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) => {
|