@vellumai/assistant 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +84 -7
- package/docs/trusted-contact-access.md +20 -0
- package/package.json +1 -1
- package/src/__tests__/access-request-decision.test.ts +0 -1
- package/src/__tests__/assistant-id-boundary-guard.test.ts +290 -0
- package/src/__tests__/call-routes-http.test.ts +0 -25
- package/src/__tests__/channel-guardian.test.ts +6 -5
- package/src/__tests__/config-schema.test.ts +2 -0
- package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +21 -0
- package/src/__tests__/guardian-outbound-http.test.ts +0 -1
- package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
- package/src/__tests__/ingress-routes-http.test.ts +55 -0
- package/src/__tests__/non-member-access-request.test.ts +28 -1
- package/src/__tests__/notification-decision-strategy.test.ts +44 -0
- package/src/__tests__/relay-server.test.ts +644 -4
- package/src/__tests__/session-init.benchmark.test.ts +0 -1
- package/src/__tests__/session-runtime-assembly.test.ts +4 -1
- package/src/__tests__/session-surfaces-task-progress.test.ts +43 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
- package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
- package/src/__tests__/trusted-contact-verification.test.ts +0 -1
- package/src/__tests__/twilio-routes.test.ts +4 -3
- package/src/__tests__/update-bulletin.test.ts +0 -1
- package/src/approvals/guardian-decision-primitive.ts +2 -1
- package/src/approvals/guardian-request-resolvers.ts +42 -3
- package/src/calls/call-constants.ts +8 -0
- package/src/calls/call-controller.ts +2 -1
- package/src/calls/call-domain.ts +5 -4
- package/src/calls/relay-server.ts +513 -116
- package/src/calls/twilio-routes.ts +3 -5
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +4 -3
- package/src/cli/core-commands.ts +7 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +164 -1
- package/src/config/bundled-skills/vercel-token-setup/SKILL.md +214 -0
- package/src/config/calls-schema.ts +12 -0
- package/src/config/feature-flag-registry.json +0 -8
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -2
- package/src/daemon/handlers/config-channels.ts +5 -7
- package/src/daemon/handlers/config-inbox.ts +2 -0
- package/src/daemon/handlers/index.ts +2 -1
- package/src/daemon/handlers/publish.ts +11 -46
- package/src/daemon/handlers/sessions.ts +11 -2
- package/src/daemon/ipc-contract/apps.ts +1 -0
- package/src/daemon/ipc-contract/inbox.ts +4 -0
- package/src/daemon/ipc-contract/integrations.ts +3 -1
- package/src/daemon/server.ts +2 -1
- package/src/daemon/session-agent-loop.ts +2 -1
- package/src/daemon/session-runtime-assembly.ts +3 -1
- package/src/daemon/session-surfaces.ts +29 -1
- package/src/memory/conversation-crud.ts +2 -1
- package/src/memory/conversation-title-service.ts +16 -2
- package/src/memory/db-init.ts +4 -0
- package/src/memory/delivery-crud.ts +2 -1
- package/src/memory/guardian-action-store.ts +2 -1
- package/src/memory/guardian-approvals.ts +3 -2
- package/src/memory/ingress-invite-store.ts +12 -2
- package/src/memory/ingress-member-store.ts +4 -3
- package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema.ts +10 -5
- package/src/notifications/copy-composer.ts +11 -1
- package/src/notifications/emit-signal.ts +2 -1
- package/src/runtime/access-request-helper.ts +11 -3
- package/src/runtime/actor-trust-resolver.ts +2 -2
- package/src/runtime/assistant-scope.ts +10 -0
- package/src/runtime/guardian-outbound-actions.ts +5 -4
- package/src/runtime/http-server.ts +11 -20
- package/src/runtime/ingress-service.ts +14 -0
- package/src/runtime/invite-redemption-service.ts +2 -1
- package/src/runtime/middleware/twilio-validation.ts +2 -4
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-route-shared.ts +3 -3
- package/src/runtime/routes/conversation-attention-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +2 -1
- package/src/runtime/routes/events-routes.ts +2 -3
- package/src/runtime/routes/inbound-conversation.ts +4 -3
- package/src/runtime/routes/inbound-message-handler.ts +4 -3
- package/src/runtime/routes/ingress-routes.ts +2 -0
- package/src/tools/calls/call-start.ts +2 -1
- package/src/tools/terminal/parser.ts +12 -0
- package/src/tools/tool-approval-handler.ts +2 -1
- package/src/workspace/git-service.ts +19 -0
|
@@ -10,16 +10,18 @@ import { randomInt } from 'node:crypto';
|
|
|
10
10
|
|
|
11
11
|
import type { ServerWebSocket } from 'bun';
|
|
12
12
|
|
|
13
|
-
import { isAssistantFeatureFlagEnabled } from '../config/assistant-feature-flags.js';
|
|
14
13
|
import { getConfig } from '../config/loader.js';
|
|
14
|
+
import { getCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
|
|
15
15
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
16
16
|
import { findActiveVoiceInvites } from '../memory/ingress-invite-store.js';
|
|
17
|
+
import { upsertMember } from '../memory/ingress-member-store.js';
|
|
17
18
|
import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
|
|
18
19
|
import { notifyGuardianOfAccessRequest } from '../runtime/access-request-helper.js';
|
|
19
20
|
import {
|
|
20
21
|
resolveActorTrust,
|
|
21
22
|
toGuardianRuntimeContextFromTrust,
|
|
22
23
|
} from '../runtime/actor-trust-resolver.js';
|
|
24
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
|
|
23
25
|
import {
|
|
24
26
|
getPendingChallenge,
|
|
25
27
|
validateAndConsumeChallenge,
|
|
@@ -31,7 +33,7 @@ import {
|
|
|
31
33
|
import { redeemVoiceInviteCode } from '../runtime/ingress-service.js';
|
|
32
34
|
import { parseJsonSafe } from '../util/json.js';
|
|
33
35
|
import { getLogger } from '../util/logger.js';
|
|
34
|
-
import {
|
|
36
|
+
import { getAccessRequestPollIntervalMs, getTtsPlaybackDelayMs, getUserConsultationTimeoutMs } from './call-constants.js';
|
|
35
37
|
import { CallController } from './call-controller.js';
|
|
36
38
|
import { persistCallCompletionMessage } from './call-conversation-messages.js';
|
|
37
39
|
import { addPointerMessage, formatDuration } from './call-pointer-messages.js';
|
|
@@ -144,7 +146,7 @@ export function setRelayBroadcast(fn: (msg: import('../daemon/ipc-contract.js').
|
|
|
144
146
|
/**
|
|
145
147
|
* Manages a single WebSocket connection for one call.
|
|
146
148
|
*/
|
|
147
|
-
export type RelayConnectionState = 'connected' | 'verification_pending' | 'disconnecting';
|
|
149
|
+
export type RelayConnectionState = 'connected' | 'verification_pending' | 'awaiting_name' | 'awaiting_guardian_decision' | 'disconnecting';
|
|
148
150
|
|
|
149
151
|
export class RelayConnection {
|
|
150
152
|
private ws: ServerWebSocket<RelayWebSocketData>;
|
|
@@ -180,6 +182,20 @@ export class RelayConnection {
|
|
|
180
182
|
private inviteRedemptionAssistantId: string | null = null;
|
|
181
183
|
private inviteRedemptionFromNumber: string | null = null;
|
|
182
184
|
private inviteRedemptionCodeLength = 6;
|
|
185
|
+
private inviteRedemptionFriendName: string | null = null;
|
|
186
|
+
private inviteRedemptionGuardianName: string | null = null;
|
|
187
|
+
|
|
188
|
+
// In-call guardian approval wait state (friend-initiated)
|
|
189
|
+
private accessRequestWaitActive = false;
|
|
190
|
+
private accessRequestId: string | null = null;
|
|
191
|
+
private accessRequestAssistantId: string | null = null;
|
|
192
|
+
private accessRequestFromNumber: string | null = null;
|
|
193
|
+
private accessRequestPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
194
|
+
private accessRequestTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
195
|
+
private accessRequestCallerName: string | null = null;
|
|
196
|
+
|
|
197
|
+
// Name capture timeout (unknown inbound callers)
|
|
198
|
+
private nameCaptureTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
183
199
|
|
|
184
200
|
constructor(ws: ServerWebSocket<RelayWebSocketData>, callSessionId: string) {
|
|
185
201
|
this.ws = ws;
|
|
@@ -304,6 +320,19 @@ export class RelayConnection {
|
|
|
304
320
|
this.controller.destroy();
|
|
305
321
|
this.controller = null;
|
|
306
322
|
}
|
|
323
|
+
if (this.accessRequestPollTimer) {
|
|
324
|
+
clearInterval(this.accessRequestPollTimer);
|
|
325
|
+
this.accessRequestPollTimer = null;
|
|
326
|
+
}
|
|
327
|
+
if (this.accessRequestTimeoutTimer) {
|
|
328
|
+
clearTimeout(this.accessRequestTimeoutTimer);
|
|
329
|
+
this.accessRequestTimeoutTimer = null;
|
|
330
|
+
}
|
|
331
|
+
if (this.nameCaptureTimeoutTimer) {
|
|
332
|
+
clearTimeout(this.nameCaptureTimeoutTimer);
|
|
333
|
+
this.nameCaptureTimeoutTimer = null;
|
|
334
|
+
}
|
|
335
|
+
this.accessRequestWaitActive = false;
|
|
307
336
|
this.abortController.abort();
|
|
308
337
|
log.info({ callSessionId: this.callSessionId }, 'RelayConnection destroyed');
|
|
309
338
|
}
|
|
@@ -315,6 +344,13 @@ export class RelayConnection {
|
|
|
315
344
|
* we still finalize the call lifecycle from the relay close signal.
|
|
316
345
|
*/
|
|
317
346
|
handleTransportClosed(code?: number, reason?: string): void {
|
|
347
|
+
// Clean up access request wait state on disconnect to stop polling
|
|
348
|
+
this.clearAccessRequestWait();
|
|
349
|
+
if (this.nameCaptureTimeoutTimer) {
|
|
350
|
+
clearTimeout(this.nameCaptureTimeoutTimer);
|
|
351
|
+
this.nameCaptureTimeoutTimer = null;
|
|
352
|
+
}
|
|
353
|
+
|
|
318
354
|
const session = getCallSession(this.callSessionId);
|
|
319
355
|
if (!session) return;
|
|
320
356
|
if (isTerminalState(session.status)) return;
|
|
@@ -427,7 +463,7 @@ export class RelayConnection {
|
|
|
427
463
|
// calls (created via createInboundVoiceSession) never do. Relying on
|
|
428
464
|
// task == null is unreliable: task-less outbound sessions would
|
|
429
465
|
// incorrectly bypass outbound verification.
|
|
430
|
-
const assistantId =
|
|
466
|
+
const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
|
|
431
467
|
const isInbound = session?.initiatedFromConversationId == null;
|
|
432
468
|
|
|
433
469
|
// Create and attach the session-backed voice controller. Seed guardian
|
|
@@ -500,89 +536,80 @@ export class RelayConnection {
|
|
|
500
536
|
const pendingChallenge = getPendingChallenge(assistantId, 'voice');
|
|
501
537
|
|
|
502
538
|
if (actorTrust.trustClass === 'unknown' && !pendingChallenge) {
|
|
503
|
-
// Before
|
|
504
|
-
// to the caller's phone number. If so,
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
539
|
+
// Before entering the name capture flow, check if there is an
|
|
540
|
+
// active voice invite bound to the caller's phone number. If so,
|
|
541
|
+
// enter the invite redemption subflow instead.
|
|
542
|
+
let voiceInvites: ReturnType<typeof findActiveVoiceInvites> = [];
|
|
543
|
+
try {
|
|
544
|
+
voiceInvites = findActiveVoiceInvites({
|
|
545
|
+
assistantId,
|
|
546
|
+
expectedExternalUserId: msg.from,
|
|
547
|
+
});
|
|
548
|
+
} catch (err) {
|
|
549
|
+
log.warn({ err, callSessionId: this.callSessionId }, 'Failed to check voice invites for unknown caller');
|
|
550
|
+
}
|
|
511
551
|
|
|
512
|
-
if
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
552
|
+
// Exclude invites that are past their expiresAt even if the DB
|
|
553
|
+
// status hasn't been lazily flipped to 'expired' yet.
|
|
554
|
+
const now = Date.now();
|
|
555
|
+
const nonExpiredInvites = voiceInvites.filter(i => !i.expiresAt || i.expiresAt > now);
|
|
556
|
+
|
|
557
|
+
// Blocked members get immediate denial — the guardian already made
|
|
558
|
+
// an explicit decision to block them. This must be checked before
|
|
559
|
+
// invite redemption so a blocked caller cannot bypass the block by
|
|
560
|
+
// redeeming an active invite.
|
|
561
|
+
if (actorTrust.memberRecord?.status === 'blocked') {
|
|
562
|
+
log.info(
|
|
563
|
+
{ callSessionId: this.callSessionId, from: msg.from, trustClass: actorTrust.trustClass },
|
|
564
|
+
'Inbound voice ACL: blocked caller denied',
|
|
565
|
+
);
|
|
522
566
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
567
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
|
|
568
|
+
from: msg.from,
|
|
569
|
+
trustClass: actorTrust.trustClass,
|
|
570
|
+
denialReason: actorTrust.denialReason,
|
|
571
|
+
});
|
|
527
572
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
573
|
+
this.sendTextToken('This number is not authorized to use this assistant.', true);
|
|
574
|
+
|
|
575
|
+
this.connectionState = 'disconnecting';
|
|
576
|
+
|
|
577
|
+
updateCallSession(this.callSessionId, {
|
|
578
|
+
status: 'failed',
|
|
579
|
+
endedAt: Date.now(),
|
|
580
|
+
lastError: 'Inbound voice ACL: caller blocked',
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
setTimeout(() => {
|
|
584
|
+
this.endSession('Inbound voice ACL denied — blocked');
|
|
585
|
+
}, getTtsPlaybackDelayMs());
|
|
586
|
+
return;
|
|
536
587
|
}
|
|
537
588
|
|
|
589
|
+
if (nonExpiredInvites.length > 0) {
|
|
590
|
+
// Use the first matching invite's metadata for personalized prompts
|
|
591
|
+
const matchedInvite = nonExpiredInvites[0];
|
|
592
|
+
log.info(
|
|
593
|
+
{ callSessionId: this.callSessionId, from: msg.from },
|
|
594
|
+
'Inbound voice ACL: unknown caller has active voice invite — entering redemption flow',
|
|
595
|
+
);
|
|
596
|
+
this.startInviteRedemption(assistantId, msg.from, matchedInvite.friendName, matchedInvite.guardianName);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Unknown/revoked/pending callers enter the name capture + guardian
|
|
601
|
+
// approval wait flow instead of being hard-rejected.
|
|
538
602
|
log.info(
|
|
539
603
|
{ callSessionId: this.callSessionId, from: msg.from, trustClass: actorTrust.trustClass },
|
|
540
|
-
'Inbound voice ACL: unknown caller
|
|
604
|
+
'Inbound voice ACL: unknown caller — entering name capture flow',
|
|
541
605
|
);
|
|
542
606
|
|
|
543
|
-
recordCallEvent(this.callSessionId, '
|
|
607
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_name_capture_started', {
|
|
544
608
|
from: msg.from,
|
|
545
609
|
trustClass: actorTrust.trustClass,
|
|
546
|
-
denialReason: actorTrust.denialReason,
|
|
547
610
|
});
|
|
548
611
|
|
|
549
|
-
|
|
550
|
-
// re-approve. Blocked members are intentionally excluded — the
|
|
551
|
-
// guardian already made an explicit decision to block them.
|
|
552
|
-
let guardianNotified = false;
|
|
553
|
-
if (actorTrust.memberRecord?.status !== 'blocked') {
|
|
554
|
-
try {
|
|
555
|
-
const accessResult = notifyGuardianOfAccessRequest({
|
|
556
|
-
canonicalAssistantId: assistantId,
|
|
557
|
-
sourceChannel: 'voice',
|
|
558
|
-
externalChatId: msg.from,
|
|
559
|
-
senderExternalUserId: actorTrust.canonicalSenderId ?? msg.from,
|
|
560
|
-
});
|
|
561
|
-
guardianNotified = accessResult.notified;
|
|
562
|
-
} catch (err) {
|
|
563
|
-
log.error({ err, callSessionId: this.callSessionId }, 'Failed to create access request for denied voice caller');
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Deny with deterministic voice copy and end the call.
|
|
568
|
-
// Mark as disconnecting so handlePrompt ignores caller input
|
|
569
|
-
// during the delay before the session ends.
|
|
570
|
-
const denialMessage = guardianNotified
|
|
571
|
-
? 'This number is not authorized. Your request has been forwarded to the account guardian.'
|
|
572
|
-
: 'This number is not authorized to use this assistant.';
|
|
573
|
-
this.sendTextToken(denialMessage, true);
|
|
574
|
-
|
|
575
|
-
this.connectionState = 'disconnecting';
|
|
576
|
-
|
|
577
|
-
updateCallSession(this.callSessionId, {
|
|
578
|
-
status: 'failed',
|
|
579
|
-
endedAt: Date.now(),
|
|
580
|
-
lastError: 'Inbound voice ACL: caller not authorized',
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
setTimeout(() => {
|
|
584
|
-
this.endSession('Inbound voice ACL denied');
|
|
585
|
-
}, 3000);
|
|
612
|
+
this.startNameCapture(assistantId, msg.from);
|
|
586
613
|
return;
|
|
587
614
|
}
|
|
588
615
|
|
|
@@ -614,7 +641,7 @@ export class RelayConnection {
|
|
|
614
641
|
|
|
615
642
|
setTimeout(() => {
|
|
616
643
|
this.endSession('Inbound voice ACL: member policy deny');
|
|
617
|
-
},
|
|
644
|
+
}, getTtsPlaybackDelayMs());
|
|
618
645
|
return;
|
|
619
646
|
}
|
|
620
647
|
|
|
@@ -646,7 +673,7 @@ export class RelayConnection {
|
|
|
646
673
|
|
|
647
674
|
setTimeout(() => {
|
|
648
675
|
this.endSession('Inbound voice ACL: member policy escalate');
|
|
649
|
-
},
|
|
676
|
+
}, getTtsPlaybackDelayMs());
|
|
650
677
|
return;
|
|
651
678
|
}
|
|
652
679
|
|
|
@@ -910,7 +937,7 @@ export class RelayConnection {
|
|
|
910
937
|
|
|
911
938
|
setTimeout(() => {
|
|
912
939
|
this.endSession('Guardian verification succeeded');
|
|
913
|
-
},
|
|
940
|
+
}, getTtsPlaybackDelayMs());
|
|
914
941
|
} else {
|
|
915
942
|
// Inbound: proceed to normal call flow
|
|
916
943
|
if (this.controller) {
|
|
@@ -981,7 +1008,7 @@ export class RelayConnection {
|
|
|
981
1008
|
|
|
982
1009
|
setTimeout(() => {
|
|
983
1010
|
this.endSession('Guardian verification failed');
|
|
984
|
-
},
|
|
1011
|
+
}, getTtsPlaybackDelayMs());
|
|
985
1012
|
} else {
|
|
986
1013
|
const retryText = isOutbound
|
|
987
1014
|
? composeVerificationVoice(GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_RETRY, { codeDigits })
|
|
@@ -1001,13 +1028,15 @@ export class RelayConnection {
|
|
|
1001
1028
|
* who has an active voice invite. Prompts the caller to enter their
|
|
1002
1029
|
* invite code via DTMF or speech.
|
|
1003
1030
|
*/
|
|
1004
|
-
private startInviteRedemption(assistantId: string, fromNumber: string): void {
|
|
1031
|
+
private startInviteRedemption(assistantId: string, fromNumber: string, friendName: string | null, guardianName: string | null): void {
|
|
1005
1032
|
this.inviteRedemptionActive = true;
|
|
1006
1033
|
this.inviteRedemptionAssistantId = assistantId;
|
|
1007
1034
|
this.inviteRedemptionFromNumber = fromNumber;
|
|
1035
|
+
this.inviteRedemptionFriendName = friendName;
|
|
1036
|
+
this.inviteRedemptionGuardianName = guardianName;
|
|
1008
1037
|
this.connectionState = 'verification_pending';
|
|
1009
1038
|
this.verificationAttempts = 0;
|
|
1010
|
-
this.verificationMaxAttempts =
|
|
1039
|
+
this.verificationMaxAttempts = 1;
|
|
1011
1040
|
this.inviteRedemptionCodeLength = 6;
|
|
1012
1041
|
this.dtmfBuffer = '';
|
|
1013
1042
|
|
|
@@ -1017,8 +1046,10 @@ export class RelayConnection {
|
|
|
1017
1046
|
maxAttempts: this.verificationMaxAttempts,
|
|
1018
1047
|
});
|
|
1019
1048
|
|
|
1049
|
+
const displayFriend = friendName ?? 'there';
|
|
1050
|
+
const displayGuardian = guardianName ?? 'your contact';
|
|
1020
1051
|
this.sendTextToken(
|
|
1021
|
-
|
|
1052
|
+
`Welcome ${displayFriend}. Please enter the 6-digit code that ${displayGuardian} provided you to verify your identity.`,
|
|
1022
1053
|
true,
|
|
1023
1054
|
);
|
|
1024
1055
|
|
|
@@ -1028,6 +1059,344 @@ export class RelayConnection {
|
|
|
1028
1059
|
);
|
|
1029
1060
|
}
|
|
1030
1061
|
|
|
1062
|
+
/**
|
|
1063
|
+
* Enter the name capture subflow for unknown inbound callers.
|
|
1064
|
+
* Prompts the caller to provide their name so we can include it
|
|
1065
|
+
* in the guardian notification.
|
|
1066
|
+
*/
|
|
1067
|
+
private startNameCapture(assistantId: string, fromNumber: string): void {
|
|
1068
|
+
this.accessRequestAssistantId = assistantId;
|
|
1069
|
+
this.accessRequestFromNumber = fromNumber;
|
|
1070
|
+
this.connectionState = 'awaiting_name';
|
|
1071
|
+
|
|
1072
|
+
this.sendTextToken(
|
|
1073
|
+
"Sorry, I don't recognize this number. I'll let my guardian know you called and see if I have permission to speak with you. Can I get your name?",
|
|
1074
|
+
true,
|
|
1075
|
+
);
|
|
1076
|
+
|
|
1077
|
+
// Start a timeout so silent callers don't keep the call open indefinitely.
|
|
1078
|
+
// Uses a 30-second window — enough time to speak a name but short enough
|
|
1079
|
+
// to avoid wasting resources on callers who never respond.
|
|
1080
|
+
const NAME_CAPTURE_TIMEOUT_MS = 30_000;
|
|
1081
|
+
this.nameCaptureTimeoutTimer = setTimeout(() => {
|
|
1082
|
+
if (this.connectionState !== 'awaiting_name') return;
|
|
1083
|
+
this.handleNameCaptureTimeout();
|
|
1084
|
+
}, NAME_CAPTURE_TIMEOUT_MS);
|
|
1085
|
+
|
|
1086
|
+
log.info(
|
|
1087
|
+
{ callSessionId: this.callSessionId, assistantId, timeoutMs: NAME_CAPTURE_TIMEOUT_MS },
|
|
1088
|
+
'Name capture started for unknown inbound caller',
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Handle the caller's name response during the name capture subflow.
|
|
1094
|
+
* Creates a canonical access request, notifies the guardian, and
|
|
1095
|
+
* enters the bounded wait loop for the guardian decision.
|
|
1096
|
+
*/
|
|
1097
|
+
private handleNameCaptureResponse(callerName: string): void {
|
|
1098
|
+
if (!this.accessRequestAssistantId || !this.accessRequestFromNumber) {
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Clear the name capture timeout since the caller responded.
|
|
1103
|
+
if (this.nameCaptureTimeoutTimer) {
|
|
1104
|
+
clearTimeout(this.nameCaptureTimeoutTimer);
|
|
1105
|
+
this.nameCaptureTimeoutTimer = null;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
this.accessRequestCallerName = callerName;
|
|
1109
|
+
|
|
1110
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_name_captured', {
|
|
1111
|
+
from: this.accessRequestFromNumber,
|
|
1112
|
+
callerName,
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
// Create canonical access request and notify the guardian, including
|
|
1116
|
+
// the caller's spoken name and voice channel metadata.
|
|
1117
|
+
try {
|
|
1118
|
+
const accessResult = notifyGuardianOfAccessRequest({
|
|
1119
|
+
canonicalAssistantId: this.accessRequestAssistantId,
|
|
1120
|
+
sourceChannel: 'voice',
|
|
1121
|
+
externalChatId: this.accessRequestFromNumber,
|
|
1122
|
+
senderExternalUserId: this.accessRequestFromNumber,
|
|
1123
|
+
senderName: callerName,
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
if (accessResult.notified) {
|
|
1127
|
+
this.accessRequestId = accessResult.requestId;
|
|
1128
|
+
log.info(
|
|
1129
|
+
{ callSessionId: this.callSessionId, requestId: accessResult.requestId, callerName },
|
|
1130
|
+
'Guardian notified of voice access request with caller name',
|
|
1131
|
+
);
|
|
1132
|
+
} else {
|
|
1133
|
+
log.warn(
|
|
1134
|
+
{ callSessionId: this.callSessionId },
|
|
1135
|
+
'Failed to notify guardian of voice access request — no sender ID',
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
log.error({ err, callSessionId: this.callSessionId }, 'Failed to create access request for voice caller');
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// If the access request was not successfully created (notifyGuardianOfAccessRequest
|
|
1143
|
+
// threw or returned notified: false), fail closed rather than leaving the caller
|
|
1144
|
+
// stuck on hold with no guardian poll target.
|
|
1145
|
+
if (!this.accessRequestId) {
|
|
1146
|
+
log.warn(
|
|
1147
|
+
{ callSessionId: this.callSessionId },
|
|
1148
|
+
'Access request ID is null after notification attempt — failing closed',
|
|
1149
|
+
);
|
|
1150
|
+
this.handleAccessRequestTimeout();
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Enter the bounded wait loop for the guardian decision
|
|
1155
|
+
this.startAccessRequestWait();
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Start a bounded in-call wait loop polling the canonical request
|
|
1160
|
+
* status until approved, denied, or timeout.
|
|
1161
|
+
*/
|
|
1162
|
+
private startAccessRequestWait(): void {
|
|
1163
|
+
this.accessRequestWaitActive = true;
|
|
1164
|
+
this.connectionState = 'awaiting_guardian_decision';
|
|
1165
|
+
|
|
1166
|
+
const timeoutMs = getUserConsultationTimeoutMs();
|
|
1167
|
+
const pollIntervalMs = getAccessRequestPollIntervalMs();
|
|
1168
|
+
|
|
1169
|
+
this.sendTextToken(
|
|
1170
|
+
"Thank you. I've let my guardian know. Please hold while I check if I have permission to speak with you.",
|
|
1171
|
+
true,
|
|
1172
|
+
);
|
|
1173
|
+
|
|
1174
|
+
updateCallSession(this.callSessionId, { status: 'waiting_on_user' });
|
|
1175
|
+
|
|
1176
|
+
// Poll the canonical request status
|
|
1177
|
+
this.accessRequestPollTimer = setInterval(() => {
|
|
1178
|
+
if (!this.accessRequestWaitActive || !this.accessRequestId) {
|
|
1179
|
+
this.clearAccessRequestWait();
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const request = getCanonicalGuardianRequest(this.accessRequestId);
|
|
1184
|
+
if (!request) {
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (request.status === 'approved') {
|
|
1189
|
+
this.handleAccessRequestApproved();
|
|
1190
|
+
} else if (request.status === 'denied') {
|
|
1191
|
+
this.handleAccessRequestDenied();
|
|
1192
|
+
}
|
|
1193
|
+
// 'pending' continues polling; 'expired'/'cancelled' handled by timeout
|
|
1194
|
+
}, pollIntervalMs);
|
|
1195
|
+
|
|
1196
|
+
// Timeout: give up waiting for the guardian
|
|
1197
|
+
this.accessRequestTimeoutTimer = setTimeout(() => {
|
|
1198
|
+
if (!this.accessRequestWaitActive) return;
|
|
1199
|
+
|
|
1200
|
+
log.info(
|
|
1201
|
+
{ callSessionId: this.callSessionId, requestId: this.accessRequestId },
|
|
1202
|
+
'Access request in-call wait timed out',
|
|
1203
|
+
);
|
|
1204
|
+
|
|
1205
|
+
this.handleAccessRequestTimeout();
|
|
1206
|
+
}, timeoutMs);
|
|
1207
|
+
|
|
1208
|
+
log.info(
|
|
1209
|
+
{ callSessionId: this.callSessionId, requestId: this.accessRequestId, timeoutMs },
|
|
1210
|
+
'Access request in-call wait started',
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Clean up access request wait state (timers, flags).
|
|
1216
|
+
*/
|
|
1217
|
+
private clearAccessRequestWait(): void {
|
|
1218
|
+
this.accessRequestWaitActive = false;
|
|
1219
|
+
if (this.accessRequestPollTimer) {
|
|
1220
|
+
clearInterval(this.accessRequestPollTimer);
|
|
1221
|
+
this.accessRequestPollTimer = null;
|
|
1222
|
+
}
|
|
1223
|
+
if (this.accessRequestTimeoutTimer) {
|
|
1224
|
+
clearTimeout(this.accessRequestTimeoutTimer);
|
|
1225
|
+
this.accessRequestTimeoutTimer = null;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Handle an approved access request: activate the caller as a trusted
|
|
1231
|
+
* contact, update runtime context, and continue with normal call flow.
|
|
1232
|
+
*/
|
|
1233
|
+
private handleAccessRequestApproved(): void {
|
|
1234
|
+
this.clearAccessRequestWait();
|
|
1235
|
+
this.connectionState = 'connected';
|
|
1236
|
+
|
|
1237
|
+
const assistantId = this.accessRequestAssistantId!;
|
|
1238
|
+
const fromNumber = this.accessRequestFromNumber!;
|
|
1239
|
+
const callerName = this.accessRequestCallerName;
|
|
1240
|
+
|
|
1241
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_access_approved', {
|
|
1242
|
+
from: fromNumber,
|
|
1243
|
+
callerName,
|
|
1244
|
+
requestId: this.accessRequestId,
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
// Activate the caller as a trusted contact via the existing upsert path
|
|
1248
|
+
try {
|
|
1249
|
+
upsertMember({
|
|
1250
|
+
assistantId,
|
|
1251
|
+
sourceChannel: 'voice',
|
|
1252
|
+
externalUserId: fromNumber,
|
|
1253
|
+
externalChatId: fromNumber,
|
|
1254
|
+
displayName: callerName ?? undefined,
|
|
1255
|
+
status: 'active',
|
|
1256
|
+
policy: 'allow',
|
|
1257
|
+
});
|
|
1258
|
+
} catch (err) {
|
|
1259
|
+
log.error({ err, callSessionId: this.callSessionId }, 'Failed to activate voice caller as trusted contact');
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// Re-resolve actor trust now that the member is active
|
|
1263
|
+
const updatedTrust = resolveActorTrust({
|
|
1264
|
+
assistantId,
|
|
1265
|
+
sourceChannel: 'voice',
|
|
1266
|
+
externalChatId: fromNumber,
|
|
1267
|
+
senderExternalUserId: fromNumber,
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
if (this.controller) {
|
|
1271
|
+
this.controller.setGuardianContext(
|
|
1272
|
+
toGuardianRuntimeContextFromTrust(updatedTrust, fromNumber),
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
updateCallSession(this.callSessionId, { status: 'in_progress' });
|
|
1277
|
+
|
|
1278
|
+
log.info(
|
|
1279
|
+
{ callSessionId: this.callSessionId, from: fromNumber },
|
|
1280
|
+
'Access request approved — caller activated and continuing call',
|
|
1281
|
+
);
|
|
1282
|
+
|
|
1283
|
+
// Use handleUserInstruction to deliver the approval-aware greeting
|
|
1284
|
+
// through the normal session pipeline.
|
|
1285
|
+
const guardianName = 'my guardian';
|
|
1286
|
+
if (this.controller) {
|
|
1287
|
+
this.controller.handleUserInstruction(
|
|
1288
|
+
`Great, ${guardianName} approved! Now how can I help you?`,
|
|
1289
|
+
).catch((err) => {
|
|
1290
|
+
log.error({ err, callSessionId: this.callSessionId }, 'Failed to deliver approval greeting');
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Handle a denied access request: deliver deterministic copy and hang up.
|
|
1297
|
+
*/
|
|
1298
|
+
private handleAccessRequestDenied(): void {
|
|
1299
|
+
this.clearAccessRequestWait();
|
|
1300
|
+
|
|
1301
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_access_denied', {
|
|
1302
|
+
from: this.accessRequestFromNumber,
|
|
1303
|
+
requestId: this.accessRequestId,
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
this.sendTextToken(
|
|
1307
|
+
"Sorry, my guardian says I'm not allowed to speak with you. Goodbye.",
|
|
1308
|
+
true,
|
|
1309
|
+
);
|
|
1310
|
+
|
|
1311
|
+
this.connectionState = 'disconnecting';
|
|
1312
|
+
|
|
1313
|
+
updateCallSession(this.callSessionId, {
|
|
1314
|
+
status: 'failed',
|
|
1315
|
+
endedAt: Date.now(),
|
|
1316
|
+
lastError: 'Inbound voice ACL: guardian denied access request',
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
log.info(
|
|
1320
|
+
{ callSessionId: this.callSessionId },
|
|
1321
|
+
'Access request denied — ending call',
|
|
1322
|
+
);
|
|
1323
|
+
|
|
1324
|
+
setTimeout(() => {
|
|
1325
|
+
this.endSession('Access request denied');
|
|
1326
|
+
}, getTtsPlaybackDelayMs());
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Handle an access request timeout: deliver deterministic copy and hang up.
|
|
1331
|
+
*/
|
|
1332
|
+
private handleAccessRequestTimeout(): void {
|
|
1333
|
+
this.clearAccessRequestWait();
|
|
1334
|
+
|
|
1335
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_access_timeout', {
|
|
1336
|
+
from: this.accessRequestFromNumber,
|
|
1337
|
+
requestId: this.accessRequestId,
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
this.sendTextToken(
|
|
1341
|
+
"Sorry, I can't get ahold of my guardian right now. I'll let them know you called.",
|
|
1342
|
+
true,
|
|
1343
|
+
);
|
|
1344
|
+
|
|
1345
|
+
this.connectionState = 'disconnecting';
|
|
1346
|
+
|
|
1347
|
+
updateCallSession(this.callSessionId, {
|
|
1348
|
+
status: 'failed',
|
|
1349
|
+
endedAt: Date.now(),
|
|
1350
|
+
lastError: 'Inbound voice ACL: guardian approval wait timed out',
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
log.info(
|
|
1354
|
+
{ callSessionId: this.callSessionId },
|
|
1355
|
+
'Access request timed out — ending call',
|
|
1356
|
+
);
|
|
1357
|
+
|
|
1358
|
+
setTimeout(() => {
|
|
1359
|
+
this.endSession('Access request timed out');
|
|
1360
|
+
}, getTtsPlaybackDelayMs());
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Handle a name capture timeout: the caller never provided their name
|
|
1365
|
+
* within the allotted window. Deliver deterministic copy and hang up.
|
|
1366
|
+
*/
|
|
1367
|
+
private handleNameCaptureTimeout(): void {
|
|
1368
|
+
if (this.nameCaptureTimeoutTimer) {
|
|
1369
|
+
clearTimeout(this.nameCaptureTimeoutTimer);
|
|
1370
|
+
this.nameCaptureTimeoutTimer = null;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_name_capture_timeout', {
|
|
1374
|
+
from: this.accessRequestFromNumber,
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
this.sendTextToken(
|
|
1378
|
+
"Sorry, I didn't catch your name. Please try calling back. Goodbye.",
|
|
1379
|
+
true,
|
|
1380
|
+
);
|
|
1381
|
+
|
|
1382
|
+
this.connectionState = 'disconnecting';
|
|
1383
|
+
|
|
1384
|
+
updateCallSession(this.callSessionId, {
|
|
1385
|
+
status: 'failed',
|
|
1386
|
+
endedAt: Date.now(),
|
|
1387
|
+
lastError: 'Inbound voice ACL: name capture timed out',
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
log.info(
|
|
1391
|
+
{ callSessionId: this.callSessionId },
|
|
1392
|
+
'Name capture timed out — ending call',
|
|
1393
|
+
);
|
|
1394
|
+
|
|
1395
|
+
setTimeout(() => {
|
|
1396
|
+
this.endSession('Name capture timed out');
|
|
1397
|
+
}, getTtsPlaybackDelayMs());
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1031
1400
|
/**
|
|
1032
1401
|
* Validate an entered invite code against active voice invites for the
|
|
1033
1402
|
* caller. On success, create/activate the ingress member and transition
|
|
@@ -1073,46 +1442,43 @@ export class RelayConnection {
|
|
|
1073
1442
|
this.startNormalCallFlow(this.controller, true);
|
|
1074
1443
|
}
|
|
1075
1444
|
} else {
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
if (this.verificationAttempts >= this.verificationMaxAttempts) {
|
|
1079
|
-
this.inviteRedemptionActive = false;
|
|
1445
|
+
// On any invalid/expired code, emit exact deterministic failure copy and end call immediately.
|
|
1446
|
+
this.inviteRedemptionActive = false;
|
|
1080
1447
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1448
|
+
recordCallEvent(this.callSessionId, 'invite_redemption_failed', {
|
|
1449
|
+
attempts: 1,
|
|
1450
|
+
});
|
|
1451
|
+
log.warn(
|
|
1452
|
+
{ callSessionId: this.callSessionId },
|
|
1453
|
+
'Voice invite redemption failed — invalid or expired code',
|
|
1454
|
+
);
|
|
1088
1455
|
|
|
1089
|
-
|
|
1456
|
+
const displayGuardian = this.inviteRedemptionGuardianName ?? 'your contact';
|
|
1457
|
+
this.sendTextToken(
|
|
1458
|
+
`Sorry, the code you provided is incorrect or has since expired. Please ask ${displayGuardian} for a new code. Goodbye.`,
|
|
1459
|
+
true,
|
|
1460
|
+
);
|
|
1090
1461
|
|
|
1091
|
-
|
|
1092
|
-
status: 'failed',
|
|
1093
|
-
endedAt: Date.now(),
|
|
1094
|
-
lastError: 'Voice invite redemption failed — max attempts exceeded',
|
|
1095
|
-
});
|
|
1462
|
+
this.connectionState = 'disconnecting';
|
|
1096
1463
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
});
|
|
1103
|
-
fireCallCompletionNotifier(failSession.conversationId, this.callSessionId);
|
|
1104
|
-
}
|
|
1464
|
+
updateCallSession(this.callSessionId, {
|
|
1465
|
+
status: 'failed',
|
|
1466
|
+
endedAt: Date.now(),
|
|
1467
|
+
lastError: 'Voice invite redemption failed — invalid or expired code',
|
|
1468
|
+
});
|
|
1105
1469
|
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
);
|
|
1114
|
-
this.sendTextToken('Invalid code. Please try again.', true);
|
|
1470
|
+
const failSession = getCallSession(this.callSessionId);
|
|
1471
|
+
if (failSession) {
|
|
1472
|
+
expirePendingQuestions(this.callSessionId);
|
|
1473
|
+
persistCallCompletionMessage(failSession.conversationId, this.callSessionId).catch((err) => {
|
|
1474
|
+
log.error({ err, conversationId: failSession.conversationId, callSessionId: this.callSessionId }, 'Failed to persist call completion message');
|
|
1475
|
+
});
|
|
1476
|
+
fireCallCompletionNotifier(failSession.conversationId, this.callSessionId);
|
|
1115
1477
|
}
|
|
1478
|
+
|
|
1479
|
+
setTimeout(() => {
|
|
1480
|
+
this.endSession('Invite redemption failed');
|
|
1481
|
+
}, getTtsPlaybackDelayMs());
|
|
1116
1482
|
}
|
|
1117
1483
|
}
|
|
1118
1484
|
|
|
@@ -1126,6 +1492,32 @@ export class RelayConnection {
|
|
|
1126
1492
|
return;
|
|
1127
1493
|
}
|
|
1128
1494
|
|
|
1495
|
+
// During name capture, the caller's response is their name.
|
|
1496
|
+
if (this.connectionState === 'awaiting_name') {
|
|
1497
|
+
const callerName = msg.voicePrompt.trim();
|
|
1498
|
+
if (!callerName) {
|
|
1499
|
+
// Whitespace-only or empty transcript (e.g. silence/noise) —
|
|
1500
|
+
// keep waiting for a real name. The name-capture timeout will
|
|
1501
|
+
// still fire if the caller never provides one.
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
log.info(
|
|
1505
|
+
{ callSessionId: this.callSessionId, callerName },
|
|
1506
|
+
'Name captured from unknown inbound caller',
|
|
1507
|
+
);
|
|
1508
|
+
this.handleNameCaptureResponse(callerName);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// During guardian decision wait, ignore caller speech — they are on hold.
|
|
1513
|
+
if (this.connectionState === 'awaiting_guardian_decision') {
|
|
1514
|
+
log.debug(
|
|
1515
|
+
{ callSessionId: this.callSessionId },
|
|
1516
|
+
'Ignoring voice prompt during guardian decision wait',
|
|
1517
|
+
);
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1129
1521
|
// During guardian verification (inbound or outbound), attempt to parse
|
|
1130
1522
|
// spoken digits from the transcript and validate them.
|
|
1131
1523
|
if (this.connectionState === 'verification_pending' && this.guardianVerificationActive) {
|
|
@@ -1256,6 +1648,11 @@ export class RelayConnection {
|
|
|
1256
1648
|
return;
|
|
1257
1649
|
}
|
|
1258
1650
|
|
|
1651
|
+
// Ignore DTMF during name capture and guardian decision wait
|
|
1652
|
+
if (this.connectionState === 'awaiting_name' || this.connectionState === 'awaiting_guardian_decision') {
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1259
1656
|
log.info(
|
|
1260
1657
|
{ callSessionId: this.callSessionId, digit: msg.digit },
|
|
1261
1658
|
'DTMF digit received',
|
|
@@ -1354,7 +1751,7 @@ export class RelayConnection {
|
|
|
1354
1751
|
// End the call with failed status after TTS plays
|
|
1355
1752
|
setTimeout(() => {
|
|
1356
1753
|
this.endSession('Verification failed');
|
|
1357
|
-
},
|
|
1754
|
+
}, getTtsPlaybackDelayMs());
|
|
1358
1755
|
} else {
|
|
1359
1756
|
// Allow another attempt
|
|
1360
1757
|
log.info(
|