@vellumai/assistant 0.4.31 → 0.4.32
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 +1 -1
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -7
- package/src/__tests__/anthropic-provider.test.ts +86 -1
- package/src/__tests__/assistant-feature-flags-integration.test.ts +2 -2
- package/src/__tests__/checker.test.ts +37 -98
- package/src/__tests__/commit-message-enrichment-service.test.ts +15 -0
- package/src/__tests__/config-schema.test.ts +6 -5
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +1 -19
- package/src/__tests__/followup-tools.test.ts +0 -30
- package/src/__tests__/gemini-provider.test.ts +79 -1
- package/src/__tests__/ipc-snapshot.test.ts +0 -4
- package/src/__tests__/managed-proxy-context.test.ts +163 -0
- package/src/__tests__/memory-lifecycle-e2e.test.ts +2 -2
- package/src/__tests__/memory-regressions.test.ts +6 -6
- package/src/__tests__/openai-provider.test.ts +82 -0
- package/src/__tests__/provider-fail-open-selection.test.ts +134 -1
- package/src/__tests__/provider-managed-proxy-integration.test.ts +269 -0
- package/src/__tests__/recurrence-types.test.ts +0 -15
- package/src/__tests__/schedule-tools.test.ts +28 -44
- package/src/__tests__/skill-feature-flags.test.ts +2 -2
- package/src/__tests__/task-management-tools.test.ts +111 -0
- package/src/__tests__/twilio-config.test.ts +0 -3
- package/src/amazon/session.ts +30 -91
- package/src/calls/call-controller.ts +423 -571
- package/src/calls/finalize-call.ts +20 -0
- package/src/calls/relay-access-wait.ts +340 -0
- package/src/calls/relay-server.ts +267 -902
- package/src/calls/relay-setup-router.ts +307 -0
- package/src/calls/relay-verification.ts +280 -0
- package/src/calls/twilio-config.ts +1 -8
- package/src/calls/voice-control-protocol.ts +184 -0
- package/src/calls/voice-session-bridge.ts +1 -8
- package/src/config/agent-schema.ts +1 -1
- package/src/config/bundled-skills/followups/TOOLS.json +0 -4
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/bundled-skills/schedule/TOOLS.json +2 -10
- package/src/config/core-schema.ts +1 -1
- package/src/config/env.ts +0 -10
- package/src/config/feature-flag-registry.json +1 -1
- package/src/config/loader.ts +19 -0
- package/src/config/schema.ts +2 -2
- package/src/daemon/handlers/session-history.ts +398 -0
- package/src/daemon/handlers/session-user-message.ts +982 -0
- package/src/daemon/handlers/sessions.ts +9 -1338
- package/src/daemon/ipc-contract/sessions.ts +0 -6
- package/src/daemon/ipc-contract-inventory.json +0 -1
- package/src/daemon/lifecycle.ts +0 -29
- package/src/home-base/app-link-store.ts +0 -7
- package/src/memory/conversation-attention-store.ts +1 -1
- package/src/memory/conversation-store.ts +0 -51
- package/src/memory/db-init.ts +5 -1
- package/src/memory/job-handlers/conflict.ts +24 -0
- package/src/memory/migrations/105-contacts-and-triage.ts +4 -7
- package/src/memory/migrations/134-contacts-notes-column.ts +50 -33
- package/src/memory/migrations/registry.ts +6 -0
- package/src/memory/recall-cache.ts +0 -5
- package/src/memory/schema/calls.ts +274 -0
- package/src/memory/schema/contacts.ts +125 -0
- package/src/memory/schema/conversations.ts +129 -0
- package/src/memory/schema/guardian.ts +172 -0
- package/src/memory/schema/index.ts +8 -0
- package/src/memory/schema/infrastructure.ts +205 -0
- package/src/memory/schema/memory-core.ts +196 -0
- package/src/memory/schema/notifications.ts +191 -0
- package/src/memory/schema/tasks.ts +78 -0
- package/src/memory/schema.ts +1 -1385
- package/src/memory/slack-thread-store.ts +0 -69
- package/src/notifications/decisions-store.ts +2 -105
- package/src/notifications/deliveries-store.ts +0 -11
- package/src/notifications/preferences-store.ts +1 -58
- package/src/permissions/checker.ts +6 -17
- package/src/providers/anthropic/client.ts +6 -2
- package/src/providers/gemini/client.ts +13 -2
- package/src/providers/managed-proxy/constants.ts +55 -0
- package/src/providers/managed-proxy/context.ts +77 -0
- package/src/providers/registry.ts +112 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -23
- package/src/runtime/http-server.ts +83 -722
- package/src/runtime/http-types.ts +0 -16
- package/src/runtime/middleware/auth.ts +0 -12
- package/src/runtime/routes/app-routes.ts +33 -0
- package/src/runtime/routes/approval-routes.ts +32 -0
- package/src/runtime/routes/attachment-routes.ts +32 -0
- package/src/runtime/routes/brain-graph-routes.ts +27 -0
- package/src/runtime/routes/call-routes.ts +41 -0
- package/src/runtime/routes/channel-readiness-routes.ts +20 -0
- package/src/runtime/routes/channel-routes.ts +70 -0
- package/src/runtime/routes/contact-routes.ts +63 -0
- package/src/runtime/routes/conversation-attention-routes.ts +15 -0
- package/src/runtime/routes/conversation-routes.ts +190 -193
- package/src/runtime/routes/debug-routes.ts +15 -0
- package/src/runtime/routes/events-routes.ts +16 -0
- package/src/runtime/routes/global-search-routes.ts +15 -0
- package/src/runtime/routes/guardian-action-routes.ts +22 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +20 -0
- package/src/runtime/routes/guardian-refresh-routes.ts +20 -0
- package/src/runtime/routes/identity-routes.ts +20 -0
- package/src/runtime/routes/inbound-message-handler.ts +8 -0
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +6 -6
- package/src/runtime/routes/integration-routes.ts +83 -0
- package/src/runtime/routes/invite-routes.ts +31 -0
- package/src/runtime/routes/migration-routes.ts +30 -0
- package/src/runtime/routes/pairing-routes.ts +18 -0
- package/src/runtime/routes/secret-routes.ts +20 -0
- package/src/runtime/routes/surface-action-routes.ts +26 -0
- package/src/runtime/routes/trust-rules-routes.ts +31 -0
- package/src/runtime/routes/twilio-routes.ts +79 -0
- package/src/schedule/recurrence-types.ts +1 -11
- package/src/tools/followups/followup_create.ts +9 -3
- package/src/tools/mcp/mcp-tool-factory.ts +0 -17
- package/src/tools/memory/definitions.ts +0 -6
- package/src/tools/network/script-proxy/session-manager.ts +38 -3
- package/src/tools/schedule/create.ts +1 -3
- package/src/tools/schedule/update.ts +9 -6
- package/src/twitter/session.ts +29 -77
- package/src/util/cookie-session.ts +114 -0
- package/src/__tests__/conversation-routes.test.ts +0 -99
- package/src/__tests__/task-tools.test.ts +0 -685
- package/src/contacts/startup-migration.ts +0 -21
|
@@ -10,10 +10,8 @@ import { randomInt } from "node:crypto";
|
|
|
10
10
|
|
|
11
11
|
import type { ServerWebSocket } from "bun";
|
|
12
12
|
|
|
13
|
-
import { getConfig } from "../config/loader.js";
|
|
14
13
|
import { resolveUserReference } from "../config/user-reference.js";
|
|
15
14
|
import {
|
|
16
|
-
findContactChannel,
|
|
17
15
|
findGuardianForChannel,
|
|
18
16
|
listGuardianChannels,
|
|
19
17
|
} from "../contacts/contact-store.js";
|
|
@@ -26,50 +24,46 @@ import {
|
|
|
26
24
|
import { getAssistantName } from "../daemon/identity-helpers.js";
|
|
27
25
|
import { getCanonicalGuardianRequest } from "../memory/canonical-guardian-store.js";
|
|
28
26
|
import * as conversationStore from "../memory/conversation-store.js";
|
|
29
|
-
import { findActiveVoiceInvites } from "../memory/invite-store.js";
|
|
30
27
|
import { revokeScopedApprovalGrantsForContext } from "../memory/scoped-approval-grants.js";
|
|
31
|
-
import { emitNotificationSignal } from "../notifications/emit-signal.js";
|
|
32
28
|
import { notifyGuardianOfAccessRequest } from "../runtime/access-request-helper.js";
|
|
33
29
|
import {
|
|
34
30
|
resolveActorTrust,
|
|
35
31
|
toTrustContext,
|
|
36
32
|
} from "../runtime/actor-trust-resolver.js";
|
|
37
33
|
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
|
|
38
|
-
import {
|
|
39
|
-
getGuardianBinding,
|
|
40
|
-
getPendingChallenge,
|
|
41
|
-
validateAndConsumeChallenge,
|
|
42
|
-
} from "../runtime/channel-guardian-service.js";
|
|
34
|
+
import { getGuardianBinding } from "../runtime/channel-guardian-service.js";
|
|
43
35
|
import {
|
|
44
36
|
composeVerificationVoice,
|
|
45
37
|
GUARDIAN_VERIFY_TEMPLATE_KEYS,
|
|
46
38
|
} from "../runtime/guardian-verification-templates.js";
|
|
47
|
-
import { redeemVoiceInviteCode } from "../runtime/invite-service.js";
|
|
48
39
|
import { parseJsonSafe } from "../util/json.js";
|
|
49
40
|
import { getLogger } from "../util/logger.js";
|
|
50
41
|
import {
|
|
51
42
|
getAccessRequestPollIntervalMs,
|
|
52
|
-
getGuardianWaitUpdateInitialIntervalMs,
|
|
53
|
-
getGuardianWaitUpdateInitialWindowMs,
|
|
54
|
-
getGuardianWaitUpdateSteadyMaxIntervalMs,
|
|
55
|
-
getGuardianWaitUpdateSteadyMinIntervalMs,
|
|
56
43
|
getTtsPlaybackDelayMs,
|
|
57
44
|
getUserConsultationTimeoutMs,
|
|
58
45
|
} from "./call-constants.js";
|
|
59
46
|
import { CallController } from "./call-controller.js";
|
|
60
|
-
import { persistCallCompletionMessage } from "./call-conversation-messages.js";
|
|
61
47
|
import { addPointerMessage, formatDuration } from "./call-pointer-messages.js";
|
|
62
|
-
import {
|
|
63
|
-
fireCallCompletionNotifier,
|
|
64
|
-
fireCallTranscriptNotifier,
|
|
65
|
-
} from "./call-state.js";
|
|
48
|
+
import { fireCallTranscriptNotifier } from "./call-state.js";
|
|
66
49
|
import { isTerminalState } from "./call-state-machine.js";
|
|
67
50
|
import {
|
|
68
|
-
expirePendingQuestions,
|
|
69
51
|
getCallSession,
|
|
70
52
|
recordCallEvent,
|
|
71
53
|
updateCallSession,
|
|
72
54
|
} from "./call-store.js";
|
|
55
|
+
import { finalizeCall } from "./finalize-call.js";
|
|
56
|
+
import {
|
|
57
|
+
classifyWaitUtterance,
|
|
58
|
+
emitAccessRequestCallbackHandoff,
|
|
59
|
+
scheduleNextHeartbeat,
|
|
60
|
+
} from "./relay-access-wait.js";
|
|
61
|
+
import { routeSetup } from "./relay-setup-router.js";
|
|
62
|
+
import {
|
|
63
|
+
attemptGuardianCodeVerification,
|
|
64
|
+
attemptInviteCodeRedemption,
|
|
65
|
+
parseDigitsFromSpeech,
|
|
66
|
+
} from "./relay-verification.js";
|
|
73
67
|
import {
|
|
74
68
|
extractPromptSpeakerMetadata,
|
|
75
69
|
type PromptSpeakerContext,
|
|
@@ -427,7 +421,7 @@ export class RelayConnection {
|
|
|
427
421
|
// If the call was still in guardian-wait with callback opt-in, emit the
|
|
428
422
|
// handoff notification before cleaning up wait state.
|
|
429
423
|
if (this.accessRequestWaitActive && this.callbackOptIn) {
|
|
430
|
-
this.
|
|
424
|
+
this.emitAccessRequestCallbackHandoffForReason("transport_closed");
|
|
431
425
|
}
|
|
432
426
|
|
|
433
427
|
// Clean up access request wait state on disconnect to stop polling
|
|
@@ -502,8 +496,6 @@ export class RelayConnection {
|
|
|
502
496
|
}
|
|
503
497
|
}
|
|
504
498
|
|
|
505
|
-
expirePendingQuestions(this.callSessionId);
|
|
506
|
-
|
|
507
499
|
// Revoke any scoped approval grants bound to this call session.
|
|
508
500
|
// Revoke by both callSessionId and conversationId because the
|
|
509
501
|
// guardian-approval-interception minting path sets callSessionId: null
|
|
@@ -522,20 +514,7 @@ export class RelayConnection {
|
|
|
522
514
|
);
|
|
523
515
|
}
|
|
524
516
|
|
|
525
|
-
|
|
526
|
-
session.conversationId,
|
|
527
|
-
this.callSessionId,
|
|
528
|
-
).catch((err) => {
|
|
529
|
-
log.error(
|
|
530
|
-
{
|
|
531
|
-
err,
|
|
532
|
-
conversationId: session.conversationId,
|
|
533
|
-
callSessionId: this.callSessionId,
|
|
534
|
-
},
|
|
535
|
-
"Failed to persist call completion message",
|
|
536
|
-
);
|
|
537
|
-
});
|
|
538
|
-
fireCallCompletionNotifier(session.conversationId, this.callSessionId);
|
|
517
|
+
finalizeCall(this.callSessionId, session.conversationId);
|
|
539
518
|
}
|
|
540
519
|
|
|
541
520
|
// ── Private handlers ─────────────────────────────────────────────
|
|
@@ -551,349 +530,165 @@ export class RelayConnection {
|
|
|
551
530
|
"ConversationRelay setup received",
|
|
552
531
|
);
|
|
553
532
|
|
|
554
|
-
// Store the callSid association on the call session
|
|
555
533
|
const session = getCallSession(this.callSessionId);
|
|
556
|
-
|
|
557
|
-
const updates: Parameters<typeof updateCallSession>[1] = {
|
|
558
|
-
providerCallSid: msg.callSid,
|
|
559
|
-
};
|
|
560
|
-
if (
|
|
561
|
-
!isTerminalState(session.status) &&
|
|
562
|
-
session.status !== "in_progress" &&
|
|
563
|
-
session.status !== "waiting_on_user"
|
|
564
|
-
) {
|
|
565
|
-
updates.status = "in_progress";
|
|
566
|
-
if (!session.startedAt) {
|
|
567
|
-
updates.startedAt = Date.now();
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
updateCallSession(this.callSessionId, updates);
|
|
571
|
-
}
|
|
534
|
+
this.recordSetupBookkeeping(session, msg);
|
|
572
535
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
? Object.fromEntries(
|
|
577
|
-
Object.entries(msg.customParameters).filter(
|
|
578
|
-
([key]) => !key.toLowerCase().includes("secret"),
|
|
579
|
-
),
|
|
580
|
-
)
|
|
581
|
-
: undefined;
|
|
582
|
-
|
|
583
|
-
recordCallEvent(this.callSessionId, "call_connected", {
|
|
584
|
-
callSid: msg.callSid,
|
|
536
|
+
const { outcome, resolved } = routeSetup({
|
|
537
|
+
callSessionId: this.callSessionId,
|
|
538
|
+
session,
|
|
585
539
|
from: msg.from,
|
|
586
540
|
to: msg.to,
|
|
587
|
-
customParameters:
|
|
541
|
+
customParameters: msg.customParameters,
|
|
588
542
|
});
|
|
589
543
|
|
|
590
|
-
// Inbound calls skip callee verification — verification is an
|
|
591
|
-
// outbound-call concern where we need to confirm the callee's identity.
|
|
592
|
-
// We use initiatedFromConversationId rather than task == null because
|
|
593
|
-
// outbound calls always have an initiating conversation, while inbound
|
|
594
|
-
// calls (created via createInboundVoiceSession) never do. Relying on
|
|
595
|
-
// task == null is unreliable: task-less outbound sessions would
|
|
596
|
-
// incorrectly bypass outbound verification.
|
|
597
|
-
const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
|
|
598
|
-
const isInbound = session?.initiatedFromConversationId == null;
|
|
599
|
-
|
|
600
|
-
// Create and attach the session-backed voice controller. Seed guardian
|
|
601
|
-
// actor context from the other party's identity + active binding so
|
|
602
|
-
// first-turn behavior matches channel ingress semantics. For inbound
|
|
603
|
-
// calls msg.from is the caller; for outbound calls msg.to is the
|
|
604
|
-
// recipient (msg.from is the assistant's Twilio number).
|
|
605
|
-
const otherPartyNumber = isInbound ? msg.from : msg.to;
|
|
606
|
-
const initialActorTrust = resolveActorTrust({
|
|
607
|
-
assistantId,
|
|
608
|
-
sourceChannel: "voice",
|
|
609
|
-
conversationExternalId: otherPartyNumber,
|
|
610
|
-
actorExternalId: otherPartyNumber || undefined,
|
|
611
|
-
});
|
|
612
544
|
const initialTrustContext = toTrustContext(
|
|
613
|
-
|
|
614
|
-
otherPartyNumber,
|
|
545
|
+
resolved.actorTrust,
|
|
546
|
+
resolved.otherPartyNumber,
|
|
615
547
|
);
|
|
616
|
-
|
|
617
548
|
const controller = new CallController(
|
|
618
549
|
this.callSessionId,
|
|
619
550
|
this,
|
|
620
551
|
session?.task ?? null,
|
|
621
552
|
{
|
|
622
553
|
broadcast: globalBroadcast,
|
|
623
|
-
assistantId,
|
|
554
|
+
assistantId: resolved.assistantId,
|
|
624
555
|
trustContext: initialTrustContext,
|
|
625
556
|
},
|
|
626
557
|
);
|
|
627
558
|
this.setController(controller);
|
|
628
559
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
msg.customParameters?.guardianVerificationSessionId;
|
|
636
|
-
const guardianVerificationSessionId =
|
|
637
|
-
persistedGvSessionId ?? customParamGvSessionId;
|
|
638
|
-
|
|
639
|
-
if (
|
|
640
|
-
persistedMode === "guardian_verification" &&
|
|
641
|
-
guardianVerificationSessionId
|
|
642
|
-
) {
|
|
643
|
-
this.startOutboundGuardianVerification(
|
|
644
|
-
assistantId,
|
|
645
|
-
guardianVerificationSessionId,
|
|
646
|
-
msg.to,
|
|
647
|
-
);
|
|
648
|
-
return;
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// Secondary signal: custom parameter without persisted mode (pre-migration sessions)
|
|
652
|
-
if (!persistedMode && customParamGvSessionId) {
|
|
653
|
-
log.warn(
|
|
654
|
-
{
|
|
655
|
-
callSessionId: this.callSessionId,
|
|
656
|
-
guardianVerificationSessionId: customParamGvSessionId,
|
|
657
|
-
},
|
|
658
|
-
"Guardian verification detected via setup custom parameter (no persisted call_mode) — entering verification path",
|
|
659
|
-
);
|
|
660
|
-
this.startOutboundGuardianVerification(
|
|
661
|
-
assistantId,
|
|
662
|
-
customParamGvSessionId,
|
|
663
|
-
msg.to,
|
|
664
|
-
);
|
|
665
|
-
return;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
const config = getConfig();
|
|
669
|
-
const verificationConfig = config.calls.verification;
|
|
670
|
-
if (!isInbound && verificationConfig.enabled) {
|
|
671
|
-
await this.startVerification(session, verificationConfig);
|
|
672
|
-
} else if (isInbound) {
|
|
673
|
-
// ── Trusted-contact ACL enforcement for inbound voice ──
|
|
674
|
-
// Resolve the caller's trust classification before allowing the call
|
|
675
|
-
// to proceed. Guardian and trusted-contact callers pass through;
|
|
676
|
-
// unknown callers are denied with deterministic voice copy and an
|
|
677
|
-
// access request is created for the guardian — unless there is a
|
|
678
|
-
// pending voice guardian challenge, in which case the caller is
|
|
679
|
-
// expected to be unknown (no binding yet) and should enter the
|
|
680
|
-
// verification flow.
|
|
681
|
-
const actorTrust = resolveActorTrust({
|
|
682
|
-
assistantId,
|
|
683
|
-
sourceChannel: "voice",
|
|
684
|
-
conversationExternalId: msg.from,
|
|
685
|
-
actorExternalId: msg.from || undefined,
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
// Check for a pending voice guardian challenge before the ACL deny
|
|
689
|
-
// gate. An unknown caller with a pending challenge is expected —
|
|
690
|
-
// they need to complete verification to establish a binding.
|
|
691
|
-
const pendingChallenge = getPendingChallenge(assistantId, "voice");
|
|
692
|
-
|
|
693
|
-
if (actorTrust.trustClass === "unknown" && !pendingChallenge) {
|
|
694
|
-
// Before entering the name capture flow, check if there is an
|
|
695
|
-
// active voice invite bound to the caller's phone number. If so,
|
|
696
|
-
// enter the invite redemption subflow instead.
|
|
697
|
-
let voiceInvites: ReturnType<typeof findActiveVoiceInvites> = [];
|
|
698
|
-
try {
|
|
699
|
-
voiceInvites = findActiveVoiceInvites({
|
|
700
|
-
assistantId,
|
|
701
|
-
expectedExternalUserId: msg.from,
|
|
702
|
-
});
|
|
703
|
-
} catch (err) {
|
|
704
|
-
log.warn(
|
|
705
|
-
{ err, callSessionId: this.callSessionId },
|
|
706
|
-
"Failed to check voice invites for unknown caller",
|
|
707
|
-
);
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
// Exclude invites that are past their expiresAt even if the DB
|
|
711
|
-
// status hasn't been lazily flipped to 'expired' yet.
|
|
712
|
-
const now = Date.now();
|
|
713
|
-
const nonExpiredInvites = voiceInvites.filter(
|
|
714
|
-
(i) => !i.expiresAt || i.expiresAt > now,
|
|
560
|
+
switch (outcome.action) {
|
|
561
|
+
case "outbound_guardian_verification":
|
|
562
|
+
this.startOutboundGuardianVerification(
|
|
563
|
+
outcome.assistantId,
|
|
564
|
+
outcome.sessionId,
|
|
565
|
+
outcome.toNumber,
|
|
715
566
|
);
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
);
|
|
730
|
-
|
|
731
|
-
recordCallEvent(this.callSessionId, "inbound_acl_denied", {
|
|
732
|
-
from: msg.from,
|
|
733
|
-
trustClass: actorTrust.trustClass,
|
|
734
|
-
denialReason: actorTrust.denialReason,
|
|
735
|
-
});
|
|
736
|
-
|
|
737
|
-
this.sendTextToken(
|
|
738
|
-
"This number is not authorized to use this assistant.",
|
|
739
|
-
true,
|
|
740
|
-
);
|
|
741
|
-
|
|
742
|
-
this.connectionState = "disconnecting";
|
|
743
|
-
|
|
744
|
-
updateCallSession(this.callSessionId, {
|
|
745
|
-
status: "failed",
|
|
746
|
-
endedAt: Date.now(),
|
|
747
|
-
lastError: "Inbound voice ACL: caller blocked",
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
setTimeout(() => {
|
|
751
|
-
this.endSession("Inbound voice ACL denied — blocked");
|
|
752
|
-
}, getTtsPlaybackDelayMs());
|
|
753
|
-
return;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
if (nonExpiredInvites.length > 0) {
|
|
757
|
-
// Use the first matching invite's metadata for personalized prompts
|
|
758
|
-
const matchedInvite = nonExpiredInvites[0];
|
|
759
|
-
log.info(
|
|
760
|
-
{ callSessionId: this.callSessionId, from: msg.from },
|
|
761
|
-
"Inbound voice ACL: unknown caller has active voice invite — entering redemption flow",
|
|
762
|
-
);
|
|
763
|
-
this.startInviteRedemption(
|
|
764
|
-
assistantId,
|
|
765
|
-
msg.from,
|
|
766
|
-
matchedInvite.friendName,
|
|
767
|
-
matchedInvite.guardianName,
|
|
768
|
-
);
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// Unknown/revoked/pending callers enter the name capture + guardian
|
|
773
|
-
// approval wait flow instead of being hard-rejected.
|
|
774
|
-
log.info(
|
|
775
|
-
{
|
|
776
|
-
callSessionId: this.callSessionId,
|
|
777
|
-
from: msg.from,
|
|
778
|
-
trustClass: actorTrust.trustClass,
|
|
779
|
-
},
|
|
780
|
-
"Inbound voice ACL: unknown caller — entering name capture flow",
|
|
567
|
+
return;
|
|
568
|
+
case "callee_verification":
|
|
569
|
+
await this.startVerification(session, outcome.verificationConfig);
|
|
570
|
+
return;
|
|
571
|
+
case "deny":
|
|
572
|
+
this.denyInboundCall(msg.from, resolved, outcome);
|
|
573
|
+
return;
|
|
574
|
+
case "invite_redemption":
|
|
575
|
+
this.startInviteRedemption(
|
|
576
|
+
outcome.assistantId,
|
|
577
|
+
outcome.fromNumber,
|
|
578
|
+
outcome.friendName,
|
|
579
|
+
outcome.guardianName,
|
|
781
580
|
);
|
|
782
|
-
|
|
581
|
+
return;
|
|
582
|
+
case "name_capture":
|
|
783
583
|
recordCallEvent(
|
|
784
584
|
this.callSessionId,
|
|
785
585
|
"inbound_acl_name_capture_started",
|
|
786
586
|
{
|
|
787
587
|
from: msg.from,
|
|
788
|
-
trustClass: actorTrust.trustClass,
|
|
588
|
+
trustClass: resolved.actorTrust.trustClass,
|
|
789
589
|
},
|
|
790
590
|
);
|
|
791
|
-
|
|
792
|
-
this.startNameCapture(assistantId, msg.from);
|
|
591
|
+
this.startNameCapture(outcome.assistantId, outcome.fromNumber);
|
|
793
592
|
return;
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
recordCallEvent(this.callSessionId, "inbound_acl_denied", {
|
|
811
|
-
from: msg.from,
|
|
812
|
-
trustClass: actorTrust.trustClass,
|
|
813
|
-
channelId: actorTrust.memberRecord.channel.id,
|
|
814
|
-
memberPolicy: actorTrust.memberRecord.channel.policy,
|
|
815
|
-
});
|
|
816
|
-
|
|
817
|
-
this.sendTextToken(
|
|
818
|
-
"This number is not authorized to use this assistant.",
|
|
819
|
-
true,
|
|
593
|
+
case "guardian_verification":
|
|
594
|
+
if (
|
|
595
|
+
resolved.actorTrust.memberRecord &&
|
|
596
|
+
(resolved.actorTrust.trustClass === "guardian" ||
|
|
597
|
+
resolved.actorTrust.trustClass === "trusted_contact")
|
|
598
|
+
) {
|
|
599
|
+
touchContactInteraction(resolved.actorTrust.memberRecord.contact.id);
|
|
600
|
+
}
|
|
601
|
+
if (this.controller && resolved.actorTrust.trustClass !== "unknown") {
|
|
602
|
+
this.controller.setTrustContext(
|
|
603
|
+
toTrustContext(resolved.actorTrust, msg.from),
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
this.startInboundGuardianVerification(
|
|
607
|
+
outcome.assistantId,
|
|
608
|
+
outcome.fromNumber,
|
|
820
609
|
);
|
|
821
|
-
|
|
822
|
-
this.connectionState = "disconnecting";
|
|
823
|
-
|
|
824
|
-
updateCallSession(this.callSessionId, {
|
|
825
|
-
status: "failed",
|
|
826
|
-
endedAt: Date.now(),
|
|
827
|
-
lastError: "Inbound voice ACL: member policy deny",
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
setTimeout(() => {
|
|
831
|
-
this.endSession("Inbound voice ACL: member policy deny");
|
|
832
|
-
}, getTtsPlaybackDelayMs());
|
|
833
610
|
return;
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
trustClass: actorTrust.trustClass,
|
|
853
|
-
channelId: actorTrust.memberRecord.channel.id,
|
|
854
|
-
memberPolicy: actorTrust.memberRecord.channel.policy,
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
this.sendTextToken(
|
|
858
|
-
"This number requires guardian approval for calls. Please have the account guardian update your permissions.",
|
|
859
|
-
true,
|
|
860
|
-
);
|
|
861
|
-
|
|
862
|
-
this.connectionState = "disconnecting";
|
|
863
|
-
|
|
864
|
-
updateCallSession(this.callSessionId, {
|
|
865
|
-
status: "failed",
|
|
866
|
-
endedAt: Date.now(),
|
|
867
|
-
lastError:
|
|
868
|
-
"Inbound voice ACL: member policy escalate — voice calls cannot await guardian approval",
|
|
869
|
-
});
|
|
870
|
-
|
|
871
|
-
setTimeout(() => {
|
|
872
|
-
this.endSession("Inbound voice ACL: member policy escalate");
|
|
873
|
-
}, getTtsPlaybackDelayMs());
|
|
611
|
+
case "normal_call":
|
|
612
|
+
if (outcome.isInbound) {
|
|
613
|
+
if (
|
|
614
|
+
resolved.actorTrust.memberRecord &&
|
|
615
|
+
(resolved.actorTrust.trustClass === "guardian" ||
|
|
616
|
+
resolved.actorTrust.trustClass === "trusted_contact")
|
|
617
|
+
) {
|
|
618
|
+
touchContactInteraction(
|
|
619
|
+
resolved.actorTrust.memberRecord.contact.id,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
if (this.controller && resolved.actorTrust.trustClass !== "unknown") {
|
|
623
|
+
this.controller.setTrustContext(
|
|
624
|
+
toTrustContext(resolved.actorTrust, msg.from),
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
this.startNormalCallFlow(controller, outcome.isInbound);
|
|
874
629
|
return;
|
|
875
|
-
|
|
630
|
+
}
|
|
631
|
+
}
|
|
876
632
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
633
|
+
/** Bookkeeping side-effects that run on every setup regardless of routing outcome. */
|
|
634
|
+
private recordSetupBookkeeping(
|
|
635
|
+
session: ReturnType<typeof getCallSession>,
|
|
636
|
+
msg: RelaySetupMessage,
|
|
637
|
+
): void {
|
|
638
|
+
if (session) {
|
|
639
|
+
const updates: Parameters<typeof updateCallSession>[1] = {
|
|
640
|
+
providerCallSid: msg.callSid,
|
|
641
|
+
};
|
|
642
|
+
if (
|
|
643
|
+
!isTerminalState(session.status) &&
|
|
644
|
+
session.status !== "in_progress" &&
|
|
645
|
+
session.status !== "waiting_on_user"
|
|
646
|
+
) {
|
|
647
|
+
updates.status = "in_progress";
|
|
648
|
+
if (!session.startedAt) updates.startedAt = Date.now();
|
|
880
649
|
}
|
|
650
|
+
updateCallSession(this.callSessionId, updates);
|
|
651
|
+
}
|
|
881
652
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
653
|
+
const safeCustomParameters = msg.customParameters
|
|
654
|
+
? Object.fromEntries(
|
|
655
|
+
Object.entries(msg.customParameters).filter(
|
|
656
|
+
([key]) => !key.toLowerCase().includes("secret"),
|
|
657
|
+
),
|
|
658
|
+
)
|
|
659
|
+
: undefined;
|
|
888
660
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
|
|
661
|
+
recordCallEvent(this.callSessionId, "call_connected", {
|
|
662
|
+
callSid: msg.callSid,
|
|
663
|
+
from: msg.from,
|
|
664
|
+
to: msg.to,
|
|
665
|
+
customParameters: safeCustomParameters,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Deny an inbound call with a TTS message and schedule disconnect. */
|
|
670
|
+
private denyInboundCall(
|
|
671
|
+
from: string,
|
|
672
|
+
resolved: import("./relay-setup-router.js").SetupResolved,
|
|
673
|
+
outcome: { message: string; logReason: string },
|
|
674
|
+
): void {
|
|
675
|
+
recordCallEvent(this.callSessionId, "inbound_acl_denied", {
|
|
676
|
+
from,
|
|
677
|
+
trustClass: resolved.actorTrust.trustClass,
|
|
678
|
+
denialReason: resolved.actorTrust.denialReason,
|
|
679
|
+
channelId: resolved.actorTrust.memberRecord?.channel.id,
|
|
680
|
+
memberPolicy: resolved.actorTrust.memberRecord?.channel.policy,
|
|
681
|
+
});
|
|
682
|
+
this.sendTextToken(outcome.message, true);
|
|
683
|
+
this.connectionState = "disconnecting";
|
|
684
|
+
updateCallSession(this.callSessionId, {
|
|
685
|
+
status: "failed",
|
|
686
|
+
endedAt: Date.now(),
|
|
687
|
+
lastError: outcome.logReason,
|
|
688
|
+
});
|
|
689
|
+
setTimeout(() => {
|
|
690
|
+
this.endSession(outcome.logReason);
|
|
691
|
+
}, getTtsPlaybackDelayMs());
|
|
897
692
|
}
|
|
898
693
|
|
|
899
694
|
/**
|
|
@@ -1127,59 +922,11 @@ export class RelayConnection {
|
|
|
1127
922
|
}
|
|
1128
923
|
|
|
1129
924
|
/**
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
|
|
1133
|
-
private static parseDigitsFromSpeech(transcript: string): string {
|
|
1134
|
-
const wordToDigit: Record<string, string> = {
|
|
1135
|
-
zero: "0",
|
|
1136
|
-
oh: "0",
|
|
1137
|
-
o: "0",
|
|
1138
|
-
one: "1",
|
|
1139
|
-
won: "1",
|
|
1140
|
-
two: "2",
|
|
1141
|
-
too: "2",
|
|
1142
|
-
to: "2",
|
|
1143
|
-
three: "3",
|
|
1144
|
-
four: "4",
|
|
1145
|
-
for: "4",
|
|
1146
|
-
fore: "4",
|
|
1147
|
-
five: "5",
|
|
1148
|
-
six: "6",
|
|
1149
|
-
seven: "7",
|
|
1150
|
-
eight: "8",
|
|
1151
|
-
ate: "8",
|
|
1152
|
-
nine: "9",
|
|
1153
|
-
};
|
|
1154
|
-
|
|
1155
|
-
const digits: string[] = [];
|
|
1156
|
-
const lower = transcript.toLowerCase();
|
|
1157
|
-
|
|
1158
|
-
// Split on whitespace and non-alphanumeric boundaries
|
|
1159
|
-
const tokens = lower.split(/[\s,.\-;:!?]+/);
|
|
1160
|
-
for (const token of tokens) {
|
|
1161
|
-
if (/^\d$/.test(token)) {
|
|
1162
|
-
digits.push(token);
|
|
1163
|
-
} else if (wordToDigit[token]) {
|
|
1164
|
-
digits.push(wordToDigit[token]);
|
|
1165
|
-
} else if (/^\d+$/.test(token)) {
|
|
1166
|
-
// Multi-digit number like "123456" — split into individual digits
|
|
1167
|
-
digits.push(...token.split(""));
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
return digits.join("");
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
/**
|
|
1175
|
-
* Attempt to validate an entered code against the pending voice guardian
|
|
1176
|
-
* challenge via validateAndConsumeChallenge. On success, binds the
|
|
1177
|
-
* guardian and transitions appropriately:
|
|
1178
|
-
* - Inbound: transitions to normal call flow
|
|
1179
|
-
* - Outbound: plays success template and ends the call
|
|
1180
|
-
* On failure, enforces max attempts and terminates the call if exhausted.
|
|
925
|
+
* Validate an entered code against the pending voice guardian challenge.
|
|
926
|
+
* Delegates to the extracted attemptGuardianCodeVerification() and
|
|
927
|
+
* interprets the structured result to drive side-effects.
|
|
1181
928
|
*/
|
|
1182
|
-
private
|
|
929
|
+
private handleGuardianCodeVerificationResult(enteredCode: string): void {
|
|
1183
930
|
if (
|
|
1184
931
|
!this.guardianChallengeAssistantId ||
|
|
1185
932
|
!this.guardianVerificationFromNumber
|
|
@@ -1188,27 +935,26 @@ export class RelayConnection {
|
|
|
1188
935
|
}
|
|
1189
936
|
|
|
1190
937
|
const isOutbound = this.outboundGuardianVerificationSessionId != null;
|
|
1191
|
-
const
|
|
938
|
+
const assistantId = this.guardianChallengeAssistantId;
|
|
939
|
+
const fromNumber = this.guardianVerificationFromNumber;
|
|
1192
940
|
|
|
1193
|
-
const result =
|
|
1194
|
-
|
|
1195
|
-
|
|
941
|
+
const result = attemptGuardianCodeVerification({
|
|
942
|
+
guardianChallengeAssistantId: assistantId,
|
|
943
|
+
guardianVerificationFromNumber: fromNumber,
|
|
1196
944
|
enteredCode,
|
|
1197
|
-
|
|
1198
|
-
this.
|
|
1199
|
-
|
|
945
|
+
isOutbound,
|
|
946
|
+
codeDigits: this.verificationCodeLength,
|
|
947
|
+
verificationAttempts: this.verificationAttempts,
|
|
948
|
+
verificationMaxAttempts: this.verificationMaxAttempts,
|
|
949
|
+
});
|
|
1200
950
|
|
|
1201
|
-
if (result.success) {
|
|
951
|
+
if (result.outcome === "success") {
|
|
1202
952
|
this.connectionState = "connected";
|
|
1203
953
|
this.guardianVerificationActive = false;
|
|
1204
954
|
this.verificationAttempts = 0;
|
|
1205
955
|
this.dtmfBuffer = "";
|
|
1206
956
|
|
|
1207
|
-
|
|
1208
|
-
? "outbound_guardian_voice_verification_succeeded"
|
|
1209
|
-
: "guardian_voice_verification_succeeded";
|
|
1210
|
-
|
|
1211
|
-
recordCallEvent(this.callSessionId, eventName, {
|
|
957
|
+
recordCallEvent(this.callSessionId, result.eventName, {
|
|
1212
958
|
verificationType: result.verificationType,
|
|
1213
959
|
});
|
|
1214
960
|
log.info(
|
|
@@ -1218,65 +964,36 @@ export class RelayConnection {
|
|
|
1218
964
|
|
|
1219
965
|
// Create the guardian binding now that verification succeeded.
|
|
1220
966
|
if (result.verificationType === "guardian") {
|
|
1221
|
-
|
|
1222
|
-
this.guardianChallengeAssistantId,
|
|
1223
|
-
"voice",
|
|
1224
|
-
);
|
|
1225
|
-
if (
|
|
1226
|
-
existingBinding &&
|
|
1227
|
-
existingBinding.guardianExternalUserId !==
|
|
1228
|
-
this.guardianVerificationFromNumber
|
|
1229
|
-
) {
|
|
967
|
+
if (result.bindingConflict) {
|
|
1230
968
|
log.warn(
|
|
1231
969
|
{
|
|
1232
970
|
callSessionId: this.callSessionId,
|
|
1233
|
-
existingGuardian:
|
|
971
|
+
existingGuardian: result.bindingConflict.existingGuardian,
|
|
1234
972
|
},
|
|
1235
973
|
"Guardian binding conflict: another user already holds the voice binding",
|
|
1236
974
|
);
|
|
1237
975
|
} else {
|
|
1238
|
-
revokeGuardianBinding(
|
|
1239
|
-
|
|
1240
|
-
// Unify all channel bindings onto the canonical (vellum) principal
|
|
1241
|
-
const vellumBinding = getGuardianBinding(
|
|
1242
|
-
this.guardianChallengeAssistantId,
|
|
1243
|
-
"vellum",
|
|
1244
|
-
);
|
|
1245
|
-
const canonicalPrincipal =
|
|
1246
|
-
vellumBinding?.guardianPrincipalId ??
|
|
1247
|
-
this.guardianVerificationFromNumber;
|
|
1248
|
-
|
|
976
|
+
revokeGuardianBinding(assistantId, "voice");
|
|
1249
977
|
createGuardianBinding({
|
|
1250
|
-
assistantId
|
|
978
|
+
assistantId,
|
|
1251
979
|
channel: "voice",
|
|
1252
|
-
guardianExternalUserId:
|
|
1253
|
-
guardianDeliveryChatId:
|
|
1254
|
-
guardianPrincipalId: canonicalPrincipal
|
|
980
|
+
guardianExternalUserId: fromNumber,
|
|
981
|
+
guardianDeliveryChatId: fromNumber,
|
|
982
|
+
guardianPrincipalId: result.canonicalPrincipal!,
|
|
1255
983
|
verifiedVia: "challenge",
|
|
1256
984
|
});
|
|
1257
985
|
}
|
|
1258
986
|
}
|
|
1259
987
|
|
|
1260
988
|
if (isOutbound) {
|
|
1261
|
-
// Outbound guardian verification: play success and hang up.
|
|
1262
|
-
// There is no normal conversation to transition to.
|
|
1263
|
-
// Set disconnecting to ignore any further DTMF/speech input
|
|
1264
|
-
// during the brief delay before the session ends.
|
|
1265
989
|
this.connectionState = "disconnecting";
|
|
1266
|
-
|
|
1267
|
-
const successText = composeVerificationVoice(
|
|
1268
|
-
GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_SUCCESS,
|
|
1269
|
-
{ codeDigits },
|
|
1270
|
-
);
|
|
1271
|
-
this.sendTextToken(successText, true);
|
|
990
|
+
this.sendTextToken(result.ttsMessage!, true);
|
|
1272
991
|
|
|
1273
992
|
updateCallSession(this.callSessionId, {
|
|
1274
993
|
status: "completed",
|
|
1275
994
|
endedAt: Date.now(),
|
|
1276
995
|
});
|
|
1277
996
|
|
|
1278
|
-
// Emit a pointer message to the origin conversation so the
|
|
1279
|
-
// requesting chat sees a deterministic completion notice.
|
|
1280
997
|
const successSession = getCallSession(this.callSessionId);
|
|
1281
998
|
if (successSession?.initiatedFromConversationId) {
|
|
1282
999
|
addPointerMessage(
|
|
@@ -1299,174 +1016,92 @@ export class RelayConnection {
|
|
|
1299
1016
|
this.endSession("Verified — guardian challenge passed");
|
|
1300
1017
|
}, getTtsPlaybackDelayMs());
|
|
1301
1018
|
} else if (result.verificationType === "trusted_contact") {
|
|
1302
|
-
// Inbound trusted-contact verification: activate and continue
|
|
1303
|
-
// the live call with the shared handoff primitive.
|
|
1304
1019
|
this.continueCallAfterTrustedContactActivation({
|
|
1305
|
-
assistantId
|
|
1306
|
-
fromNumber
|
|
1020
|
+
assistantId,
|
|
1021
|
+
fromNumber,
|
|
1307
1022
|
});
|
|
1308
1023
|
} else {
|
|
1309
|
-
// Inbound guardian verification:
|
|
1310
|
-
// to normal call flow.
|
|
1311
|
-
// verification-intercept.ts for the inbound channel path.
|
|
1312
|
-
const guardianAssistantId = this.guardianChallengeAssistantId;
|
|
1313
|
-
const callerNumber = this.guardianVerificationFromNumber;
|
|
1314
|
-
|
|
1315
|
-
const existingBinding = getGuardianBinding(
|
|
1316
|
-
guardianAssistantId,
|
|
1317
|
-
"voice",
|
|
1318
|
-
);
|
|
1319
|
-
if (
|
|
1320
|
-
existingBinding &&
|
|
1321
|
-
existingBinding.guardianExternalUserId !== callerNumber
|
|
1322
|
-
) {
|
|
1323
|
-
log.warn(
|
|
1324
|
-
{
|
|
1325
|
-
sourceChannel: "voice",
|
|
1326
|
-
existingGuardian: existingBinding.guardianExternalUserId,
|
|
1327
|
-
},
|
|
1328
|
-
"Guardian binding conflict: another user already holds the voice channel binding",
|
|
1329
|
-
);
|
|
1330
|
-
} else {
|
|
1331
|
-
revokeGuardianBinding(guardianAssistantId, "voice");
|
|
1332
|
-
|
|
1333
|
-
// Resolve canonical principal from the vellum channel binding
|
|
1334
|
-
// so all channel bindings share a single principal identity.
|
|
1335
|
-
const vellumBinding = getGuardianBinding(
|
|
1336
|
-
guardianAssistantId,
|
|
1337
|
-
"vellum",
|
|
1338
|
-
);
|
|
1339
|
-
const canonicalPrincipal =
|
|
1340
|
-
vellumBinding?.guardianPrincipalId ?? callerNumber;
|
|
1341
|
-
|
|
1342
|
-
createGuardianBinding({
|
|
1343
|
-
assistantId: guardianAssistantId,
|
|
1344
|
-
channel: "voice",
|
|
1345
|
-
guardianExternalUserId: callerNumber,
|
|
1346
|
-
guardianDeliveryChatId: callerNumber,
|
|
1347
|
-
guardianPrincipalId: canonicalPrincipal,
|
|
1348
|
-
verifiedVia: "challenge",
|
|
1349
|
-
});
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1024
|
+
// Inbound guardian verification: binding already handled above,
|
|
1025
|
+
// proceed to normal call flow.
|
|
1352
1026
|
if (this.controller) {
|
|
1353
1027
|
const verifiedActorTrust = resolveActorTrust({
|
|
1354
|
-
assistantId
|
|
1028
|
+
assistantId,
|
|
1355
1029
|
sourceChannel: "voice",
|
|
1356
|
-
conversationExternalId:
|
|
1357
|
-
actorExternalId:
|
|
1030
|
+
conversationExternalId: fromNumber,
|
|
1031
|
+
actorExternalId: fromNumber,
|
|
1358
1032
|
});
|
|
1359
1033
|
this.controller.setTrustContext(
|
|
1360
|
-
toTrustContext(verifiedActorTrust,
|
|
1034
|
+
toTrustContext(verifiedActorTrust, fromNumber),
|
|
1361
1035
|
);
|
|
1362
1036
|
this.startNormalCallFlow(this.controller, true);
|
|
1363
1037
|
}
|
|
1364
1038
|
}
|
|
1365
|
-
} else {
|
|
1366
|
-
this.
|
|
1039
|
+
} else if (result.outcome === "failure") {
|
|
1040
|
+
this.guardianVerificationActive = false;
|
|
1041
|
+
this.verificationAttempts = result.attempts;
|
|
1367
1042
|
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1043
|
+
recordCallEvent(this.callSessionId, result.eventName, {
|
|
1044
|
+
attempts: result.attempts,
|
|
1045
|
+
});
|
|
1046
|
+
log.warn(
|
|
1047
|
+
{
|
|
1048
|
+
callSessionId: this.callSessionId,
|
|
1049
|
+
attempts: result.attempts,
|
|
1050
|
+
isOutbound,
|
|
1051
|
+
},
|
|
1052
|
+
"Guardian voice verification failed — max attempts reached",
|
|
1053
|
+
);
|
|
1372
1054
|
|
|
1373
|
-
|
|
1374
|
-
? "outbound_guardian_voice_verification_failed"
|
|
1375
|
-
: "guardian_voice_verification_failed";
|
|
1055
|
+
this.sendTextToken(result.ttsMessage, true);
|
|
1376
1056
|
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
callSessionId: this.callSessionId,
|
|
1383
|
-
attempts: this.verificationAttempts,
|
|
1384
|
-
isOutbound,
|
|
1385
|
-
},
|
|
1386
|
-
"Guardian voice verification failed — max attempts reached",
|
|
1387
|
-
);
|
|
1388
|
-
|
|
1389
|
-
const failureText = isOutbound
|
|
1390
|
-
? composeVerificationVoice(
|
|
1391
|
-
GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_FAILURE,
|
|
1392
|
-
{ codeDigits },
|
|
1393
|
-
)
|
|
1394
|
-
: "Verification failed. Goodbye.";
|
|
1395
|
-
this.sendTextToken(failureText, true);
|
|
1057
|
+
updateCallSession(this.callSessionId, {
|
|
1058
|
+
status: "failed",
|
|
1059
|
+
endedAt: Date.now(),
|
|
1060
|
+
lastError: "Guardian voice verification failed — max attempts exceeded",
|
|
1061
|
+
});
|
|
1396
1062
|
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
lastError:
|
|
1401
|
-
"Guardian voice verification failed — max attempts exceeded",
|
|
1402
|
-
});
|
|
1063
|
+
const failSession = getCallSession(this.callSessionId);
|
|
1064
|
+
if (failSession) {
|
|
1065
|
+
finalizeCall(this.callSessionId, failSession.conversationId);
|
|
1403
1066
|
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
failSession.
|
|
1409
|
-
|
|
1067
|
+
if (isOutbound && failSession.initiatedFromConversationId) {
|
|
1068
|
+
addPointerMessage(
|
|
1069
|
+
failSession.initiatedFromConversationId,
|
|
1070
|
+
"guardian_verification_failed",
|
|
1071
|
+
failSession.toNumber,
|
|
1072
|
+
{
|
|
1073
|
+
channel: "voice",
|
|
1074
|
+
reason: "Max verification attempts exceeded",
|
|
1075
|
+
},
|
|
1410
1076
|
).catch((err) => {
|
|
1411
|
-
log.
|
|
1077
|
+
log.warn(
|
|
1412
1078
|
{
|
|
1079
|
+
conversationId: failSession.initiatedFromConversationId,
|
|
1413
1080
|
err,
|
|
1414
|
-
conversationId: failSession.conversationId,
|
|
1415
|
-
callSessionId: this.callSessionId,
|
|
1416
1081
|
},
|
|
1417
|
-
"
|
|
1082
|
+
"Skipping pointer write — origin conversation may no longer exist",
|
|
1418
1083
|
);
|
|
1419
1084
|
});
|
|
1420
|
-
fireCallCompletionNotifier(
|
|
1421
|
-
failSession.conversationId,
|
|
1422
|
-
this.callSessionId,
|
|
1423
|
-
);
|
|
1424
|
-
|
|
1425
|
-
// Emit a pointer message to the origin conversation so the
|
|
1426
|
-
// requesting chat sees a deterministic failure notice.
|
|
1427
|
-
if (isOutbound && failSession.initiatedFromConversationId) {
|
|
1428
|
-
addPointerMessage(
|
|
1429
|
-
failSession.initiatedFromConversationId,
|
|
1430
|
-
"guardian_verification_failed",
|
|
1431
|
-
failSession.toNumber,
|
|
1432
|
-
{
|
|
1433
|
-
channel: "voice",
|
|
1434
|
-
reason: "Max verification attempts exceeded",
|
|
1435
|
-
},
|
|
1436
|
-
).catch((err) => {
|
|
1437
|
-
log.warn(
|
|
1438
|
-
{
|
|
1439
|
-
conversationId: failSession.initiatedFromConversationId,
|
|
1440
|
-
err,
|
|
1441
|
-
},
|
|
1442
|
-
"Skipping pointer write — origin conversation may no longer exist",
|
|
1443
|
-
);
|
|
1444
|
-
});
|
|
1445
|
-
}
|
|
1446
1085
|
}
|
|
1086
|
+
}
|
|
1447
1087
|
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_RETRY,
|
|
1455
|
-
{ codeDigits },
|
|
1456
|
-
)
|
|
1457
|
-
: "That code was incorrect. Please try again.";
|
|
1088
|
+
setTimeout(() => {
|
|
1089
|
+
this.endSession("Verification failed — challenge rejected");
|
|
1090
|
+
}, getTtsPlaybackDelayMs());
|
|
1091
|
+
} else {
|
|
1092
|
+
// retry
|
|
1093
|
+
this.verificationAttempts = result.attempt;
|
|
1458
1094
|
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
}
|
|
1095
|
+
log.info(
|
|
1096
|
+
{
|
|
1097
|
+
callSessionId: this.callSessionId,
|
|
1098
|
+
attempt: result.attempt,
|
|
1099
|
+
maxAttempts: result.maxAttempts,
|
|
1100
|
+
isOutbound,
|
|
1101
|
+
},
|
|
1102
|
+
"Guardian voice verification attempt failed — retrying",
|
|
1103
|
+
);
|
|
1104
|
+
this.sendTextToken(result.ttsMessage, true);
|
|
1470
1105
|
}
|
|
1471
1106
|
}
|
|
1472
1107
|
|
|
@@ -1794,7 +1429,7 @@ export class RelayConnection {
|
|
|
1794
1429
|
*/
|
|
1795
1430
|
private handleAccessRequestTimeout(): void {
|
|
1796
1431
|
// Emit callback handoff notification before clearing wait state
|
|
1797
|
-
this.
|
|
1432
|
+
this.emitAccessRequestCallbackHandoffForReason("timeout");
|
|
1798
1433
|
|
|
1799
1434
|
this.clearAccessRequestWait();
|
|
1800
1435
|
|
|
@@ -1832,118 +1467,20 @@ export class RelayConnection {
|
|
|
1832
1467
|
}, getTtsPlaybackDelayMs());
|
|
1833
1468
|
}
|
|
1834
1469
|
|
|
1835
|
-
|
|
1836
|
-
* Emit a callback handoff notification to the guardian when the caller
|
|
1837
|
-
* opted into a callback during guardian wait but the wait ended without
|
|
1838
|
-
* resolution (timeout or transport close).
|
|
1839
|
-
*
|
|
1840
|
-
* Idempotent: uses callbackHandoffNotified guard + deterministic dedupeKey
|
|
1841
|
-
* to ensure at most one notification per call/request.
|
|
1842
|
-
*/
|
|
1843
|
-
private emitAccessRequestCallbackHandoff(
|
|
1470
|
+
private emitAccessRequestCallbackHandoffForReason(
|
|
1844
1471
|
reason: "timeout" | "transport_closed",
|
|
1845
1472
|
): void {
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
this.
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
const canonicalRequest = this.accessRequestId
|
|
1858
|
-
? getCanonicalGuardianRequest(this.accessRequestId)
|
|
1859
|
-
: null;
|
|
1860
|
-
|
|
1861
|
-
// Resolve trusted-contact member reference when possible
|
|
1862
|
-
let requesterMemberId: string | null = null;
|
|
1863
|
-
if (fromNumber) {
|
|
1864
|
-
try {
|
|
1865
|
-
const contactResult = findContactChannel({
|
|
1866
|
-
channelType: "voice",
|
|
1867
|
-
externalUserId: fromNumber,
|
|
1868
|
-
externalChatId: fromNumber,
|
|
1869
|
-
});
|
|
1870
|
-
if (
|
|
1871
|
-
contactResult &&
|
|
1872
|
-
contactResult.channel.status === "active" &&
|
|
1873
|
-
contactResult.channel.policy === "allow"
|
|
1874
|
-
) {
|
|
1875
|
-
requesterMemberId = contactResult.channel.id;
|
|
1876
|
-
}
|
|
1877
|
-
} catch (err) {
|
|
1878
|
-
log.warn(
|
|
1879
|
-
{ err, callSessionId: this.callSessionId },
|
|
1880
|
-
"Failed to resolve member for callback handoff",
|
|
1881
|
-
);
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
const dedupeKey = `access-request-callback-handoff:${this.accessRequestId}`;
|
|
1886
|
-
const sourceSessionId =
|
|
1887
|
-
canonicalRequest?.conversationId ??
|
|
1888
|
-
`access-req-callback-${this.accessRequestId}`;
|
|
1889
|
-
|
|
1890
|
-
void emitNotificationSignal({
|
|
1891
|
-
sourceEventName: "ingress.access_request.callback_handoff",
|
|
1892
|
-
sourceChannel: "voice",
|
|
1893
|
-
sourceSessionId,
|
|
1894
|
-
assistantId,
|
|
1895
|
-
attentionHints: {
|
|
1896
|
-
requiresAction: false,
|
|
1897
|
-
urgency: "medium",
|
|
1898
|
-
isAsyncBackground: true,
|
|
1899
|
-
visibleInSourceNow: false,
|
|
1900
|
-
},
|
|
1901
|
-
contextPayload: {
|
|
1902
|
-
requestId: this.accessRequestId,
|
|
1903
|
-
requestCode: canonicalRequest?.requestCode ?? null,
|
|
1904
|
-
callSessionId: this.callSessionId,
|
|
1905
|
-
sourceChannel: "voice",
|
|
1906
|
-
reason,
|
|
1907
|
-
callbackOptIn: true,
|
|
1908
|
-
callerPhoneNumber: fromNumber,
|
|
1909
|
-
callerName: this.accessRequestCallerName ?? null,
|
|
1910
|
-
requesterExternalUserId: fromNumber,
|
|
1911
|
-
requesterChatId: fromNumber,
|
|
1912
|
-
requesterMemberId,
|
|
1913
|
-
requesterMemberSourceChannel: requesterMemberId ? "voice" : null,
|
|
1914
|
-
},
|
|
1915
|
-
dedupeKey,
|
|
1916
|
-
})
|
|
1917
|
-
.then(() => {
|
|
1918
|
-
recordCallEvent(this.callSessionId, "callback_handoff_notified", {
|
|
1919
|
-
requestId: this.accessRequestId,
|
|
1920
|
-
reason,
|
|
1921
|
-
requesterMemberId,
|
|
1922
|
-
});
|
|
1923
|
-
log.info(
|
|
1924
|
-
{
|
|
1925
|
-
callSessionId: this.callSessionId,
|
|
1926
|
-
requestId: this.accessRequestId,
|
|
1927
|
-
reason,
|
|
1928
|
-
},
|
|
1929
|
-
"Callback handoff notification emitted",
|
|
1930
|
-
);
|
|
1931
|
-
})
|
|
1932
|
-
.catch((err) => {
|
|
1933
|
-
recordCallEvent(this.callSessionId, "callback_handoff_failed", {
|
|
1934
|
-
requestId: this.accessRequestId,
|
|
1935
|
-
reason,
|
|
1936
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1937
|
-
});
|
|
1938
|
-
log.error(
|
|
1939
|
-
{
|
|
1940
|
-
err,
|
|
1941
|
-
callSessionId: this.callSessionId,
|
|
1942
|
-
requestId: this.accessRequestId,
|
|
1943
|
-
},
|
|
1944
|
-
"Failed to emit callback handoff notification",
|
|
1945
|
-
);
|
|
1946
|
-
});
|
|
1473
|
+
const result = emitAccessRequestCallbackHandoff({
|
|
1474
|
+
reason,
|
|
1475
|
+
callbackOptIn: this.callbackOptIn,
|
|
1476
|
+
accessRequestId: this.accessRequestId,
|
|
1477
|
+
callbackHandoffNotified: this.callbackHandoffNotified,
|
|
1478
|
+
accessRequestAssistantId: this.accessRequestAssistantId,
|
|
1479
|
+
accessRequestFromNumber: this.accessRequestFromNumber,
|
|
1480
|
+
accessRequestCallerName: this.accessRequestCallerName,
|
|
1481
|
+
callSessionId: this.callSessionId,
|
|
1482
|
+
});
|
|
1483
|
+
this.callbackHandoffNotified = result.callbackHandoffNotified;
|
|
1947
1484
|
}
|
|
1948
1485
|
|
|
1949
1486
|
/**
|
|
@@ -1984,30 +1521,30 @@ export class RelayConnection {
|
|
|
1984
1521
|
}
|
|
1985
1522
|
|
|
1986
1523
|
/**
|
|
1987
|
-
* Validate an entered invite code against active voice invites
|
|
1988
|
-
*
|
|
1989
|
-
*
|
|
1524
|
+
* Validate an entered invite code against active voice invites.
|
|
1525
|
+
* Delegates to the extracted attemptInviteCodeRedemption() and
|
|
1526
|
+
* interprets the structured result to drive side-effects.
|
|
1990
1527
|
*/
|
|
1991
|
-
private
|
|
1528
|
+
private handleInviteCodeRedemptionResult(enteredCode: string): void {
|
|
1992
1529
|
if (!this.inviteRedemptionAssistantId || !this.inviteRedemptionFromNumber) {
|
|
1993
1530
|
return;
|
|
1994
1531
|
}
|
|
1995
1532
|
|
|
1996
|
-
const result =
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
1533
|
+
const result = attemptInviteCodeRedemption({
|
|
1534
|
+
inviteRedemptionAssistantId: this.inviteRedemptionAssistantId,
|
|
1535
|
+
inviteRedemptionFromNumber: this.inviteRedemptionFromNumber,
|
|
1536
|
+
enteredCode,
|
|
1537
|
+
inviteRedemptionGuardianName: this.inviteRedemptionGuardianName,
|
|
2001
1538
|
});
|
|
2002
1539
|
|
|
2003
|
-
if (result.
|
|
1540
|
+
if (result.outcome === "success") {
|
|
2004
1541
|
this.inviteRedemptionActive = false;
|
|
2005
1542
|
this.verificationAttempts = 0;
|
|
2006
1543
|
this.dtmfBuffer = "";
|
|
2007
1544
|
|
|
2008
1545
|
recordCallEvent(this.callSessionId, "invite_redemption_succeeded", {
|
|
2009
1546
|
memberId: result.memberId,
|
|
2010
|
-
...(result.
|
|
1547
|
+
...(result.inviteId ? { inviteId: result.inviteId } : {}),
|
|
2011
1548
|
});
|
|
2012
1549
|
log.info(
|
|
2013
1550
|
{
|
|
@@ -2025,7 +1562,6 @@ export class RelayConnection {
|
|
|
2025
1562
|
skipMemberActivation: true,
|
|
2026
1563
|
});
|
|
2027
1564
|
} else {
|
|
2028
|
-
// On any invalid/expired code, emit exact deterministic failure copy and end call immediately.
|
|
2029
1565
|
this.inviteRedemptionActive = false;
|
|
2030
1566
|
|
|
2031
1567
|
recordCallEvent(this.callSessionId, "invite_redemption_failed", {
|
|
@@ -2036,12 +1572,7 @@ export class RelayConnection {
|
|
|
2036
1572
|
"Voice invite redemption failed — invalid or expired code",
|
|
2037
1573
|
);
|
|
2038
1574
|
|
|
2039
|
-
|
|
2040
|
-
this.inviteRedemptionGuardianName ?? "your contact";
|
|
2041
|
-
this.sendTextToken(
|
|
2042
|
-
`Sorry, the code you provided is incorrect or has since expired. Please ask ${displayGuardian} for a new code. Goodbye.`,
|
|
2043
|
-
true,
|
|
2044
|
-
);
|
|
1575
|
+
this.sendTextToken(result.ttsMessage, true);
|
|
2045
1576
|
|
|
2046
1577
|
this.connectionState = "disconnecting";
|
|
2047
1578
|
|
|
@@ -2053,24 +1584,7 @@ export class RelayConnection {
|
|
|
2053
1584
|
|
|
2054
1585
|
const failSession = getCallSession(this.callSessionId);
|
|
2055
1586
|
if (failSession) {
|
|
2056
|
-
|
|
2057
|
-
persistCallCompletionMessage(
|
|
2058
|
-
failSession.conversationId,
|
|
2059
|
-
this.callSessionId,
|
|
2060
|
-
).catch((err) => {
|
|
2061
|
-
log.error(
|
|
2062
|
-
{
|
|
2063
|
-
err,
|
|
2064
|
-
conversationId: failSession.conversationId,
|
|
2065
|
-
callSessionId: this.callSessionId,
|
|
2066
|
-
},
|
|
2067
|
-
"Failed to persist call completion message",
|
|
2068
|
-
);
|
|
2069
|
-
});
|
|
2070
|
-
fireCallCompletionNotifier(
|
|
2071
|
-
failSession.conversationId,
|
|
2072
|
-
this.callSessionId,
|
|
2073
|
-
);
|
|
1587
|
+
finalizeCall(this.callSessionId, failSession.conversationId);
|
|
2074
1588
|
}
|
|
2075
1589
|
|
|
2076
1590
|
setTimeout(() => {
|
|
@@ -2162,148 +1676,20 @@ export class RelayConnection {
|
|
|
2162
1676
|
}
|
|
2163
1677
|
}
|
|
2164
1678
|
|
|
2165
|
-
/**
|
|
2166
|
-
* Generate a non-repetitive heartbeat message for the caller based
|
|
2167
|
-
* on the current sequence counter and guardian label.
|
|
2168
|
-
*/
|
|
2169
|
-
private getHeartbeatMessage(): string {
|
|
2170
|
-
const guardianLabel = this.resolveGuardianLabel();
|
|
2171
|
-
const seq = this.heartbeatSequence++;
|
|
2172
|
-
const messages = [
|
|
2173
|
-
`Still waiting to hear back from ${guardianLabel}. Thank you for your patience.`,
|
|
2174
|
-
`I'm still trying to reach ${guardianLabel}. One moment please.`,
|
|
2175
|
-
`Hang tight, still waiting on ${guardianLabel}.`,
|
|
2176
|
-
`Still checking with ${guardianLabel}. I appreciate you waiting.`,
|
|
2177
|
-
`I haven't heard back from ${guardianLabel} yet. Thanks for holding.`,
|
|
2178
|
-
];
|
|
2179
|
-
return messages[seq % messages.length];
|
|
2180
|
-
}
|
|
2181
|
-
|
|
2182
|
-
/**
|
|
2183
|
-
* Schedule the next heartbeat update. Uses the initial fixed interval
|
|
2184
|
-
* during the initial window, then jitters between steady min/max.
|
|
2185
|
-
*/
|
|
2186
1679
|
private scheduleNextHeartbeat(): void {
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
Math.random() *
|
|
2197
|
-
Math.max(
|
|
2198
|
-
0,
|
|
2199
|
-
getGuardianWaitUpdateSteadyMaxIntervalMs() -
|
|
2200
|
-
getGuardianWaitUpdateSteadyMinIntervalMs(),
|
|
2201
|
-
),
|
|
2202
|
-
);
|
|
2203
|
-
|
|
2204
|
-
this.accessRequestHeartbeatTimer = setTimeout(() => {
|
|
2205
|
-
if (!this.accessRequestWaitActive) return;
|
|
2206
|
-
|
|
2207
|
-
const message = this.getHeartbeatMessage();
|
|
2208
|
-
this.sendTextToken(message, true);
|
|
2209
|
-
|
|
2210
|
-
recordCallEvent(
|
|
2211
|
-
this.callSessionId,
|
|
2212
|
-
"voice_guardian_wait_heartbeat_sent",
|
|
2213
|
-
{
|
|
2214
|
-
sequence: this.heartbeatSequence - 1,
|
|
2215
|
-
message,
|
|
2216
|
-
},
|
|
2217
|
-
);
|
|
2218
|
-
|
|
2219
|
-
log.debug(
|
|
2220
|
-
{
|
|
2221
|
-
callSessionId: this.callSessionId,
|
|
2222
|
-
sequence: this.heartbeatSequence - 1,
|
|
2223
|
-
},
|
|
2224
|
-
"Guardian wait heartbeat sent",
|
|
2225
|
-
);
|
|
2226
|
-
|
|
2227
|
-
// Schedule the next heartbeat
|
|
2228
|
-
this.scheduleNextHeartbeat();
|
|
2229
|
-
}, intervalMs);
|
|
1680
|
+
this.accessRequestHeartbeatTimer = scheduleNextHeartbeat({
|
|
1681
|
+
isWaitActive: () => this.accessRequestWaitActive,
|
|
1682
|
+
accessRequestWaitStartedAt: this.accessRequestWaitStartedAt,
|
|
1683
|
+
callSessionId: this.callSessionId,
|
|
1684
|
+
consumeSequence: () => this.heartbeatSequence++,
|
|
1685
|
+
resolveGuardianLabel: () => this.resolveGuardianLabel(),
|
|
1686
|
+
sendTextToken: (text, last) => this.sendTextToken(text, last),
|
|
1687
|
+
scheduleNext: () => this.scheduleNextHeartbeat(),
|
|
1688
|
+
});
|
|
2230
1689
|
}
|
|
2231
1690
|
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
* - 'empty': whitespace or noise
|
|
2235
|
-
* - 'patience_check': asking for status or checking in
|
|
2236
|
-
* - 'impatient': expressing frustration or wanting to end
|
|
2237
|
-
* - 'callback_opt_in': explicitly agreeing to a callback
|
|
2238
|
-
* - 'callback_decline': explicitly declining a callback
|
|
2239
|
-
* - 'neutral': anything else
|
|
2240
|
-
*/
|
|
2241
|
-
private classifyWaitUtterance(
|
|
2242
|
-
text: string,
|
|
2243
|
-
):
|
|
2244
|
-
| "empty"
|
|
2245
|
-
| "patience_check"
|
|
2246
|
-
| "impatient"
|
|
2247
|
-
| "callback_opt_in"
|
|
2248
|
-
| "callback_decline"
|
|
2249
|
-
| "neutral" {
|
|
2250
|
-
const lower = text.toLowerCase().trim();
|
|
2251
|
-
if (lower.length === 0) return "empty";
|
|
2252
|
-
|
|
2253
|
-
// Callback opt-in patterns (check before impatience to catch "yes call me back")
|
|
2254
|
-
if (this.callbackOfferMade) {
|
|
2255
|
-
if (
|
|
2256
|
-
/\b(yes|yeah|yep|sure|okay|ok|please)\b.*\b(call\s*(me\s*)?back|callback)\b/.test(
|
|
2257
|
-
lower,
|
|
2258
|
-
) ||
|
|
2259
|
-
/\b(call\s*(me\s*)?back|callback)\b.*\b(yes|yeah|please|sure)\b/.test(
|
|
2260
|
-
lower,
|
|
2261
|
-
) ||
|
|
2262
|
-
/^(yes|yeah|yep|sure|okay|ok|please)\s*[.,!]?\s*$/.test(lower) ||
|
|
2263
|
-
/\bcall\s*(me\s*)?back\b/.test(lower) ||
|
|
2264
|
-
/\bplease\s+do\b/.test(lower)
|
|
2265
|
-
) {
|
|
2266
|
-
return "callback_opt_in";
|
|
2267
|
-
}
|
|
2268
|
-
if (
|
|
2269
|
-
/\b(no|nah|nope)\b/.test(lower) ||
|
|
2270
|
-
/\bi('?ll| will)\s+hold\b/.test(lower) ||
|
|
2271
|
-
/\bi('?ll| will)\s+wait\b/.test(lower)
|
|
2272
|
-
) {
|
|
2273
|
-
return "callback_decline";
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
|
|
2277
|
-
// Impatience patterns
|
|
2278
|
-
if (
|
|
2279
|
-
/\bhurry\s*(up)?\b/.test(lower) ||
|
|
2280
|
-
/\btaking\s+(too\s+|so\s+)?long\b/.test(lower) ||
|
|
2281
|
-
/\bforget\s+it\b/.test(lower) ||
|
|
2282
|
-
/\bnever\s*mind\b/.test(lower) ||
|
|
2283
|
-
/\bdon'?t\s+have\s+time\b/.test(lower) ||
|
|
2284
|
-
/\bhow\s+much\s+longer\b/.test(lower) ||
|
|
2285
|
-
/\bi('?m| am)\s+(getting\s+)?impatient\b/.test(lower) ||
|
|
2286
|
-
/\bthis\s+is\s+(ridiculous|absurd|crazy)\b/.test(lower) ||
|
|
2287
|
-
/\bcome\s+on\b/.test(lower) ||
|
|
2288
|
-
/\bi\s+(gotta|have\s+to|need\s+to)\s+go\b/.test(lower)
|
|
2289
|
-
) {
|
|
2290
|
-
return "impatient";
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
// Patience check / status inquiry patterns
|
|
2294
|
-
if (
|
|
2295
|
-
/\bhello\??\s*$/.test(lower) ||
|
|
2296
|
-
/\bstill\s+there\b/.test(lower) ||
|
|
2297
|
-
/\bany\s+(update|news)\b/.test(lower) ||
|
|
2298
|
-
/\bwhat('?s| is)\s+(happening|going\s+on)\b/.test(lower) ||
|
|
2299
|
-
/\bare\s+you\s+still\b/.test(lower) ||
|
|
2300
|
-
/\bhow\s+(long|much\s+longer)\b/.test(lower) ||
|
|
2301
|
-
/\banyone\s+there\b/.test(lower)
|
|
2302
|
-
) {
|
|
2303
|
-
return "patience_check";
|
|
2304
|
-
}
|
|
2305
|
-
|
|
2306
|
-
return "neutral";
|
|
1691
|
+
private classifyWaitUtterance(text: string) {
|
|
1692
|
+
return classifyWaitUtterance(text, this.callbackOfferMade);
|
|
2307
1693
|
}
|
|
2308
1694
|
|
|
2309
1695
|
/**
|
|
@@ -2483,9 +1869,7 @@ export class RelayConnection {
|
|
|
2483
1869
|
this.connectionState === "verification_pending" &&
|
|
2484
1870
|
this.guardianVerificationActive
|
|
2485
1871
|
) {
|
|
2486
|
-
const spokenDigits =
|
|
2487
|
-
msg.voicePrompt,
|
|
2488
|
-
);
|
|
1872
|
+
const spokenDigits = parseDigitsFromSpeech(msg.voicePrompt);
|
|
2489
1873
|
log.info(
|
|
2490
1874
|
{
|
|
2491
1875
|
callSessionId: this.callSessionId,
|
|
@@ -2496,7 +1880,7 @@ export class RelayConnection {
|
|
|
2496
1880
|
);
|
|
2497
1881
|
if (spokenDigits.length >= this.verificationCodeLength) {
|
|
2498
1882
|
const enteredCode = spokenDigits.slice(0, this.verificationCodeLength);
|
|
2499
|
-
this.
|
|
1883
|
+
this.handleGuardianCodeVerificationResult(enteredCode);
|
|
2500
1884
|
} else if (spokenDigits.length > 0) {
|
|
2501
1885
|
this.sendTextToken(
|
|
2502
1886
|
`I heard ${spokenDigits.length} digits. Please enter all ${this.verificationCodeLength} digits of your code.`,
|
|
@@ -2512,9 +1896,7 @@ export class RelayConnection {
|
|
|
2512
1896
|
this.connectionState === "verification_pending" &&
|
|
2513
1897
|
this.inviteRedemptionActive
|
|
2514
1898
|
) {
|
|
2515
|
-
const spokenDigits =
|
|
2516
|
-
msg.voicePrompt,
|
|
2517
|
-
);
|
|
1899
|
+
const spokenDigits = parseDigitsFromSpeech(msg.voicePrompt);
|
|
2518
1900
|
log.info(
|
|
2519
1901
|
{
|
|
2520
1902
|
callSessionId: this.callSessionId,
|
|
@@ -2528,7 +1910,7 @@ export class RelayConnection {
|
|
|
2528
1910
|
0,
|
|
2529
1911
|
this.inviteRedemptionCodeLength,
|
|
2530
1912
|
);
|
|
2531
|
-
this.
|
|
1913
|
+
this.handleInviteCodeRedemptionResult(enteredCode);
|
|
2532
1914
|
} else if (spokenDigits.length > 0) {
|
|
2533
1915
|
this.sendTextToken(
|
|
2534
1916
|
`I heard ${spokenDigits.length} digits. Please enter all ${this.inviteRedemptionCodeLength} digits of your code.`,
|
|
@@ -2683,7 +2065,7 @@ export class RelayConnection {
|
|
|
2683
2065
|
this.verificationCodeLength,
|
|
2684
2066
|
);
|
|
2685
2067
|
this.dtmfBuffer = "";
|
|
2686
|
-
this.
|
|
2068
|
+
this.handleGuardianCodeVerificationResult(enteredCode);
|
|
2687
2069
|
}
|
|
2688
2070
|
return;
|
|
2689
2071
|
}
|
|
@@ -2702,7 +2084,7 @@ export class RelayConnection {
|
|
|
2702
2084
|
this.inviteRedemptionCodeLength,
|
|
2703
2085
|
);
|
|
2704
2086
|
this.dtmfBuffer = "";
|
|
2705
|
-
this.
|
|
2087
|
+
this.handleInviteCodeRedemptionResult(enteredCode);
|
|
2706
2088
|
}
|
|
2707
2089
|
return;
|
|
2708
2090
|
}
|
|
@@ -2777,24 +2159,7 @@ export class RelayConnection {
|
|
|
2777
2159
|
|
|
2778
2160
|
const session = getCallSession(this.callSessionId);
|
|
2779
2161
|
if (session) {
|
|
2780
|
-
|
|
2781
|
-
persistCallCompletionMessage(
|
|
2782
|
-
session.conversationId,
|
|
2783
|
-
this.callSessionId,
|
|
2784
|
-
).catch((err) => {
|
|
2785
|
-
log.error(
|
|
2786
|
-
{
|
|
2787
|
-
err,
|
|
2788
|
-
conversationId: session.conversationId,
|
|
2789
|
-
callSessionId: this.callSessionId,
|
|
2790
|
-
},
|
|
2791
|
-
"Failed to persist call completion message",
|
|
2792
|
-
);
|
|
2793
|
-
});
|
|
2794
|
-
fireCallCompletionNotifier(
|
|
2795
|
-
session.conversationId,
|
|
2796
|
-
this.callSessionId,
|
|
2797
|
-
);
|
|
2162
|
+
finalizeCall(this.callSessionId, session.conversationId);
|
|
2798
2163
|
if (session.initiatedFromConversationId) {
|
|
2799
2164
|
addPointerMessage(
|
|
2800
2165
|
session.initiatedFromConversationId,
|