@vellumai/assistant 0.4.31 → 0.4.33

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.
Files changed (193) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/docs/architecture/memory.md +1 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -7
  5. package/src/__tests__/access-request-decision.test.ts +83 -1
  6. package/src/__tests__/actor-token-service.test.ts +0 -1
  7. package/src/__tests__/anthropic-provider.test.ts +86 -1
  8. package/src/__tests__/approval-routes-http.test.ts +0 -1
  9. package/src/__tests__/assistant-feature-flags-integration.test.ts +2 -2
  10. package/src/__tests__/call-controller.test.ts +0 -1
  11. package/src/__tests__/call-routes-http.test.ts +0 -1
  12. package/src/__tests__/channel-guardian.test.ts +0 -1
  13. package/src/__tests__/channel-invite-transport.test.ts +52 -40
  14. package/src/__tests__/checker.test.ts +37 -98
  15. package/src/__tests__/commit-message-enrichment-service.test.ts +4 -23
  16. package/src/__tests__/computer-use-session-working-dir.test.ts +0 -1
  17. package/src/__tests__/config-schema.test.ts +6 -5
  18. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  19. package/src/__tests__/daemon-server-session-init.test.ts +1 -19
  20. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  21. package/src/__tests__/followup-tools.test.ts +0 -30
  22. package/src/__tests__/gemini-provider.test.ts +79 -1
  23. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -1
  24. package/src/__tests__/guardian-dispatch.test.ts +0 -1
  25. package/src/__tests__/guardian-outbound-http.test.ts +0 -1
  26. package/src/__tests__/handlers-telegram-config.test.ts +0 -1
  27. package/src/__tests__/inbound-invite-redemption.test.ts +1 -4
  28. package/src/__tests__/ingress-reconcile.test.ts +3 -36
  29. package/src/__tests__/ipc-snapshot.test.ts +0 -4
  30. package/src/__tests__/managed-proxy-context.test.ts +163 -0
  31. package/src/__tests__/memory-lifecycle-e2e.test.ts +2 -2
  32. package/src/__tests__/memory-regressions.test.ts +6 -6
  33. package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1
  34. package/src/__tests__/migration-export-http.test.ts +0 -1
  35. package/src/__tests__/migration-import-commit-http.test.ts +0 -1
  36. package/src/__tests__/migration-import-preflight-http.test.ts +0 -1
  37. package/src/__tests__/migration-validate-http.test.ts +0 -1
  38. package/src/__tests__/non-member-access-request.test.ts +0 -1
  39. package/src/__tests__/notification-guardian-path.test.ts +0 -1
  40. package/src/__tests__/notification-telegram-adapter.test.ts +0 -4
  41. package/src/__tests__/openai-provider.test.ts +82 -0
  42. package/src/__tests__/provider-fail-open-selection.test.ts +134 -1
  43. package/src/__tests__/provider-managed-proxy-integration.test.ts +269 -0
  44. package/src/__tests__/recurrence-types.test.ts +0 -15
  45. package/src/__tests__/relay-server.test.ts +145 -2
  46. package/src/__tests__/sandbox-host-parity.test.ts +5 -2
  47. package/src/__tests__/schedule-tools.test.ts +28 -44
  48. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  49. package/src/__tests__/skill-feature-flags.test.ts +2 -2
  50. package/src/__tests__/slack-channel-config.test.ts +0 -1
  51. package/src/__tests__/slack-inbound-verification.test.ts +0 -1
  52. package/src/__tests__/sms-messaging-provider.test.ts +0 -4
  53. package/src/__tests__/task-management-tools.test.ts +111 -0
  54. package/src/__tests__/terminal-tools.test.ts +5 -2
  55. package/src/__tests__/trusted-contact-approval-notifier.test.ts +66 -74
  56. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +0 -1
  57. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +0 -1
  58. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  59. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  60. package/src/__tests__/twilio-config.test.ts +0 -3
  61. package/src/__tests__/twilio-routes.test.ts +0 -1
  62. package/src/__tests__/update-bulletin.test.ts +0 -2
  63. package/src/__tests__/user-reference.test.ts +47 -1
  64. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  65. package/src/__tests__/workspace-git-service.test.ts +2 -2
  66. package/src/amazon/session.ts +30 -91
  67. package/src/calls/call-controller.ts +423 -571
  68. package/src/calls/finalize-call.ts +20 -0
  69. package/src/calls/relay-access-wait.ts +340 -0
  70. package/src/calls/relay-server.ts +271 -956
  71. package/src/calls/relay-setup-router.ts +307 -0
  72. package/src/calls/relay-verification.ts +280 -0
  73. package/src/calls/twilio-config.ts +1 -8
  74. package/src/calls/voice-control-protocol.ts +184 -0
  75. package/src/calls/voice-session-bridge.ts +1 -8
  76. package/src/channels/config.ts +41 -2
  77. package/src/config/agent-schema.ts +1 -1
  78. package/src/config/bundled-skills/followups/TOOLS.json +0 -4
  79. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  80. package/src/config/bundled-skills/schedule/TOOLS.json +2 -10
  81. package/src/config/bundled-skills/slack/SKILL.md +2 -0
  82. package/src/config/bundled-skills/slack-digest-setup/SKILL.md +164 -0
  83. package/src/config/core-schema.ts +1 -1
  84. package/src/config/env.ts +0 -14
  85. package/src/config/feature-flag-registry.json +5 -5
  86. package/src/config/loader.ts +19 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/user-reference.ts +47 -9
  89. package/src/daemon/handlers/config-channels.ts +11 -10
  90. package/src/daemon/handlers/contacts.ts +5 -1
  91. package/src/daemon/handlers/session-history.ts +398 -0
  92. package/src/daemon/handlers/session-user-message.ts +982 -0
  93. package/src/daemon/handlers/sessions.ts +9 -1338
  94. package/src/daemon/ipc-contract/sessions.ts +0 -6
  95. package/src/daemon/ipc-contract-inventory.json +0 -1
  96. package/src/daemon/lifecycle.ts +18 -55
  97. package/src/home-base/app-link-store.ts +0 -7
  98. package/src/memory/channel-delivery-store.ts +1 -0
  99. package/src/memory/conversation-attention-store.ts +1 -1
  100. package/src/memory/conversation-store.ts +0 -51
  101. package/src/memory/db-init.ts +9 -1
  102. package/src/memory/delivery-crud.ts +13 -0
  103. package/src/memory/invite-store.ts +71 -1
  104. package/src/memory/job-handlers/conflict.ts +24 -0
  105. package/src/memory/migrations/040-invite-code-hash-column.ts +16 -0
  106. package/src/memory/migrations/105-contacts-and-triage.ts +4 -7
  107. package/src/memory/migrations/134-contacts-notes-column.ts +50 -33
  108. package/src/memory/migrations/index.ts +1 -0
  109. package/src/memory/migrations/registry.ts +6 -0
  110. package/src/memory/recall-cache.ts +0 -5
  111. package/src/memory/schema/calls.ts +274 -0
  112. package/src/memory/schema/contacts.ts +127 -0
  113. package/src/memory/schema/conversations.ts +129 -0
  114. package/src/memory/schema/guardian.ts +172 -0
  115. package/src/memory/schema/index.ts +8 -0
  116. package/src/memory/schema/infrastructure.ts +205 -0
  117. package/src/memory/schema/memory-core.ts +196 -0
  118. package/src/memory/schema/notifications.ts +191 -0
  119. package/src/memory/schema/tasks.ts +78 -0
  120. package/src/memory/schema.ts +1 -1385
  121. package/src/memory/slack-thread-store.ts +0 -69
  122. package/src/notifications/decisions-store.ts +2 -105
  123. package/src/notifications/deliveries-store.ts +0 -11
  124. package/src/notifications/preferences-store.ts +1 -58
  125. package/src/permissions/checker.ts +6 -17
  126. package/src/providers/anthropic/client.ts +6 -2
  127. package/src/providers/gemini/client.ts +13 -2
  128. package/src/providers/managed-proxy/constants.ts +55 -0
  129. package/src/providers/managed-proxy/context.ts +77 -0
  130. package/src/providers/registry.ts +112 -0
  131. package/src/runtime/auth/__tests__/guard-tests.test.ts +52 -26
  132. package/src/runtime/auth/token-service.ts +50 -0
  133. package/src/runtime/channel-guardian-service.ts +1 -3
  134. package/src/runtime/channel-invite-transport.ts +121 -34
  135. package/src/runtime/channel-invite-transports/email.ts +50 -0
  136. package/src/runtime/channel-invite-transports/slack.ts +81 -0
  137. package/src/runtime/channel-invite-transports/sms.ts +70 -0
  138. package/src/runtime/channel-invite-transports/telegram.ts +29 -11
  139. package/src/runtime/channel-invite-transports/voice.ts +12 -12
  140. package/src/runtime/http-server.ts +83 -722
  141. package/src/runtime/http-types.ts +0 -16
  142. package/src/runtime/invite-redemption-service.ts +193 -0
  143. package/src/runtime/invite-redemption-templates.ts +6 -6
  144. package/src/runtime/invite-service.ts +81 -11
  145. package/src/runtime/middleware/auth.ts +0 -12
  146. package/src/runtime/routes/access-request-decision.ts +52 -6
  147. package/src/runtime/routes/app-routes.ts +33 -0
  148. package/src/runtime/routes/approval-routes.ts +32 -0
  149. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +6 -0
  150. package/src/runtime/routes/attachment-routes.ts +32 -0
  151. package/src/runtime/routes/brain-graph-routes.ts +27 -0
  152. package/src/runtime/routes/call-routes.ts +41 -0
  153. package/src/runtime/routes/channel-readiness-routes.ts +20 -0
  154. package/src/runtime/routes/channel-routes.ts +70 -0
  155. package/src/runtime/routes/contact-routes.ts +96 -6
  156. package/src/runtime/routes/conversation-attention-routes.ts +15 -0
  157. package/src/runtime/routes/conversation-routes.ts +190 -193
  158. package/src/runtime/routes/debug-routes.ts +15 -0
  159. package/src/runtime/routes/events-routes.ts +16 -0
  160. package/src/runtime/routes/global-search-routes.ts +15 -0
  161. package/src/runtime/routes/guardian-action-routes.ts +22 -0
  162. package/src/runtime/routes/guardian-bootstrap-routes.ts +21 -6
  163. package/src/runtime/routes/guardian-refresh-routes.ts +20 -0
  164. package/src/runtime/routes/identity-routes.ts +20 -0
  165. package/src/runtime/routes/inbound-message-handler.ts +9 -3
  166. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +295 -10
  167. package/src/runtime/routes/inbound-stages/background-dispatch.ts +9 -42
  168. package/src/runtime/routes/inbound-stages/edit-intercept.ts +10 -0
  169. package/src/runtime/routes/integration-routes.ts +83 -0
  170. package/src/runtime/routes/invite-routes.ts +32 -0
  171. package/src/runtime/routes/migration-routes.ts +30 -0
  172. package/src/runtime/routes/pairing-routes.ts +18 -0
  173. package/src/runtime/routes/secret-routes.ts +20 -0
  174. package/src/runtime/routes/surface-action-routes.ts +26 -0
  175. package/src/runtime/routes/trust-rules-routes.ts +31 -0
  176. package/src/runtime/routes/twilio-routes.ts +79 -0
  177. package/src/schedule/recurrence-types.ts +1 -11
  178. package/src/tools/browser/browser-manager.ts +10 -1
  179. package/src/tools/browser/runtime-check.ts +3 -1
  180. package/src/tools/followups/followup_create.ts +9 -3
  181. package/src/tools/mcp/mcp-tool-factory.ts +0 -17
  182. package/src/tools/memory/definitions.ts +0 -6
  183. package/src/tools/network/script-proxy/session-manager.ts +38 -3
  184. package/src/tools/schedule/create.ts +1 -3
  185. package/src/tools/schedule/update.ts +9 -6
  186. package/src/tools/shared/shell-output.ts +7 -2
  187. package/src/twitter/session.ts +29 -77
  188. package/src/util/cookie-session.ts +114 -0
  189. package/src/util/platform.ts +0 -4
  190. package/src/workspace/git-service.ts +10 -4
  191. package/src/__tests__/conversation-routes.test.ts +0 -99
  192. package/src/__tests__/task-tools.test.ts +0 -685
  193. 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
- import { resolveUserReference } from "../config/user-reference.js";
13
+ import { resolveGuardianName } 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,45 @@ 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";
43
34
  import {
44
35
  composeVerificationVoice,
45
36
  GUARDIAN_VERIFY_TEMPLATE_KEYS,
46
37
  } from "../runtime/guardian-verification-templates.js";
47
- import { redeemVoiceInviteCode } from "../runtime/invite-service.js";
48
38
  import { parseJsonSafe } from "../util/json.js";
49
39
  import { getLogger } from "../util/logger.js";
50
40
  import {
51
41
  getAccessRequestPollIntervalMs,
52
- getGuardianWaitUpdateInitialIntervalMs,
53
- getGuardianWaitUpdateInitialWindowMs,
54
- getGuardianWaitUpdateSteadyMaxIntervalMs,
55
- getGuardianWaitUpdateSteadyMinIntervalMs,
56
42
  getTtsPlaybackDelayMs,
57
43
  getUserConsultationTimeoutMs,
58
44
  } from "./call-constants.js";
59
45
  import { CallController } from "./call-controller.js";
60
- import { persistCallCompletionMessage } from "./call-conversation-messages.js";
61
46
  import { addPointerMessage, formatDuration } from "./call-pointer-messages.js";
62
- import {
63
- fireCallCompletionNotifier,
64
- fireCallTranscriptNotifier,
65
- } from "./call-state.js";
47
+ import { fireCallTranscriptNotifier } from "./call-state.js";
66
48
  import { isTerminalState } from "./call-state-machine.js";
67
49
  import {
68
- expirePendingQuestions,
69
50
  getCallSession,
70
51
  recordCallEvent,
71
52
  updateCallSession,
72
53
  } from "./call-store.js";
54
+ import { finalizeCall } from "./finalize-call.js";
55
+ import {
56
+ classifyWaitUtterance,
57
+ emitAccessRequestCallbackHandoff,
58
+ scheduleNextHeartbeat,
59
+ } from "./relay-access-wait.js";
60
+ import { routeSetup } from "./relay-setup-router.js";
61
+ import {
62
+ attemptGuardianCodeVerification,
63
+ attemptInviteCodeRedemption,
64
+ parseDigitsFromSpeech,
65
+ } from "./relay-verification.js";
73
66
  import {
74
67
  extractPromptSpeakerMetadata,
75
68
  type PromptSpeakerContext,
@@ -427,7 +420,7 @@ export class RelayConnection {
427
420
  // If the call was still in guardian-wait with callback opt-in, emit the
428
421
  // handoff notification before cleaning up wait state.
429
422
  if (this.accessRequestWaitActive && this.callbackOptIn) {
430
- this.emitAccessRequestCallbackHandoff("transport_closed");
423
+ this.emitAccessRequestCallbackHandoffForReason("transport_closed");
431
424
  }
432
425
 
433
426
  // Clean up access request wait state on disconnect to stop polling
@@ -502,8 +495,6 @@ export class RelayConnection {
502
495
  }
503
496
  }
504
497
 
505
- expirePendingQuestions(this.callSessionId);
506
-
507
498
  // Revoke any scoped approval grants bound to this call session.
508
499
  // Revoke by both callSessionId and conversationId because the
509
500
  // guardian-approval-interception minting path sets callSessionId: null
@@ -522,20 +513,7 @@ export class RelayConnection {
522
513
  );
523
514
  }
524
515
 
525
- persistCallCompletionMessage(
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);
516
+ finalizeCall(this.callSessionId, session.conversationId);
539
517
  }
540
518
 
541
519
  // ── Private handlers ─────────────────────────────────────────────
@@ -551,349 +529,165 @@ export class RelayConnection {
551
529
  "ConversationRelay setup received",
552
530
  );
553
531
 
554
- // Store the callSid association on the call session
555
532
  const session = getCallSession(this.callSessionId);
556
- if (session) {
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
- }
533
+ this.recordSetupBookkeeping(session, msg);
572
534
 
573
- // Omit potentially sensitive keys from customParameters before persisting
574
- // to the call_events table. Only allow known-safe keys through.
575
- const safeCustomParameters = msg.customParameters
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,
535
+ const { outcome, resolved } = routeSetup({
536
+ callSessionId: this.callSessionId,
537
+ session,
585
538
  from: msg.from,
586
539
  to: msg.to,
587
- customParameters: safeCustomParameters,
540
+ customParameters: msg.customParameters,
588
541
  });
589
542
 
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
543
  const initialTrustContext = toTrustContext(
613
- initialActorTrust,
614
- otherPartyNumber,
544
+ resolved.actorTrust,
545
+ resolved.otherPartyNumber,
615
546
  );
616
-
617
547
  const controller = new CallController(
618
548
  this.callSessionId,
619
549
  this,
620
550
  session?.task ?? null,
621
551
  {
622
552
  broadcast: globalBroadcast,
623
- assistantId,
553
+ assistantId: resolved.assistantId,
624
554
  trustContext: initialTrustContext,
625
555
  },
626
556
  );
627
557
  this.setController(controller);
628
558
 
629
- // Detect outbound guardian verification call from persisted call session
630
- // mode first (deterministic source of truth), with setup custom parameter
631
- // as secondary signal for backward compatibility and observability.
632
- const persistedMode = session?.callMode;
633
- const persistedGvSessionId = session?.guardianVerificationSessionId;
634
- const customParamGvSessionId =
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,
559
+ switch (outcome.action) {
560
+ case "outbound_guardian_verification":
561
+ this.startOutboundGuardianVerification(
562
+ outcome.assistantId,
563
+ outcome.sessionId,
564
+ outcome.toNumber,
715
565
  );
716
-
717
- // Blocked members get immediate denial — the guardian already made
718
- // an explicit decision to block them. This must be checked before
719
- // invite redemption so a blocked caller cannot bypass the block by
720
- // redeeming an active invite.
721
- if (actorTrust.memberRecord?.channel.status === "blocked") {
722
- log.info(
723
- {
724
- callSessionId: this.callSessionId,
725
- from: msg.from,
726
- trustClass: actorTrust.trustClass,
727
- },
728
- "Inbound voice ACL: blocked caller denied",
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",
566
+ return;
567
+ case "callee_verification":
568
+ await this.startVerification(session, outcome.verificationConfig);
569
+ return;
570
+ case "deny":
571
+ this.denyInboundCall(msg.from, resolved, outcome);
572
+ return;
573
+ case "invite_redemption":
574
+ this.startInviteRedemption(
575
+ outcome.assistantId,
576
+ outcome.fromNumber,
577
+ outcome.friendName,
578
+ outcome.guardianName,
781
579
  );
782
-
580
+ return;
581
+ case "name_capture":
783
582
  recordCallEvent(
784
583
  this.callSessionId,
785
584
  "inbound_acl_name_capture_started",
786
585
  {
787
586
  from: msg.from,
788
- trustClass: actorTrust.trustClass,
587
+ trustClass: resolved.actorTrust.trustClass,
789
588
  },
790
589
  );
791
-
792
- this.startNameCapture(assistantId, msg.from);
590
+ this.startNameCapture(outcome.assistantId, outcome.fromNumber);
793
591
  return;
794
- }
795
-
796
- // Members with policy: 'deny' have status: 'active' so resolveActorTrust
797
- // classifies them as trusted_contact, but the guardian has explicitly
798
- // denied their access. Block them the same way the text-channel path does.
799
- if (actorTrust.memberRecord?.channel.policy === "deny") {
800
- log.info(
801
- {
802
- callSessionId: this.callSessionId,
803
- from: msg.from,
804
- channelId: actorTrust.memberRecord.channel.id,
805
- trustClass: actorTrust.trustClass,
806
- },
807
- "Inbound voice ACL: member policy deny",
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,
592
+ case "guardian_verification":
593
+ if (
594
+ resolved.actorTrust.memberRecord &&
595
+ (resolved.actorTrust.trustClass === "guardian" ||
596
+ resolved.actorTrust.trustClass === "trusted_contact")
597
+ ) {
598
+ touchContactInteraction(resolved.actorTrust.memberRecord.contact.id);
599
+ }
600
+ if (this.controller && resolved.actorTrust.trustClass !== "unknown") {
601
+ this.controller.setTrustContext(
602
+ toTrustContext(resolved.actorTrust, msg.from),
603
+ );
604
+ }
605
+ this.startInboundGuardianVerification(
606
+ outcome.assistantId,
607
+ outcome.fromNumber,
820
608
  );
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
609
  return;
834
- }
835
-
836
- // Members with policy: 'escalate' require guardian approval, but a live
837
- // voice call cannot be paused for async approval. Fail-closed by denying
838
- // the call with an appropriate message — mirrors the deny block above.
839
- if (actorTrust.memberRecord?.channel.policy === "escalate") {
840
- log.info(
841
- {
842
- callSessionId: this.callSessionId,
843
- from: msg.from,
844
- channelId: actorTrust.memberRecord.channel.id,
845
- trustClass: actorTrust.trustClass,
846
- },
847
- "Inbound voice ACL: member policy escalate — cannot hold live call for guardian approval",
848
- );
849
-
850
- recordCallEvent(this.callSessionId, "inbound_acl_denied", {
851
- from: msg.from,
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());
610
+ case "normal_call":
611
+ if (outcome.isInbound) {
612
+ if (
613
+ resolved.actorTrust.memberRecord &&
614
+ (resolved.actorTrust.trustClass === "guardian" ||
615
+ resolved.actorTrust.trustClass === "trusted_contact")
616
+ ) {
617
+ touchContactInteraction(
618
+ resolved.actorTrust.memberRecord.contact.id,
619
+ );
620
+ }
621
+ if (this.controller && resolved.actorTrust.trustClass !== "unknown") {
622
+ this.controller.setTrustContext(
623
+ toTrustContext(resolved.actorTrust, msg.from),
624
+ );
625
+ }
626
+ }
627
+ this.startNormalCallFlow(controller, outcome.isInbound);
874
628
  return;
875
- }
629
+ }
630
+ }
876
631
 
877
- // Guardian and trusted-contact callers proceed normally.
878
- if (actorTrust.memberRecord) {
879
- touchContactInteraction(actorTrust.memberRecord.contact.id);
632
+ /** Bookkeeping side-effects that run on every setup regardless of routing outcome. */
633
+ private recordSetupBookkeeping(
634
+ session: ReturnType<typeof getCallSession>,
635
+ msg: RelaySetupMessage,
636
+ ): void {
637
+ if (session) {
638
+ const updates: Parameters<typeof updateCallSession>[1] = {
639
+ providerCallSid: msg.callSid,
640
+ };
641
+ if (
642
+ !isTerminalState(session.status) &&
643
+ session.status !== "in_progress" &&
644
+ session.status !== "waiting_on_user"
645
+ ) {
646
+ updates.status = "in_progress";
647
+ if (!session.startedAt) updates.startedAt = Date.now();
880
648
  }
649
+ updateCallSession(this.callSessionId, updates);
650
+ }
881
651
 
882
- // Update the controller's guardian context with the trust-resolved
883
- // context so downstream policy gates have accurate actor metadata.
884
- if (this.controller && actorTrust.trustClass !== "unknown") {
885
- const resolvedTrustContext = toTrustContext(actorTrust, msg.from);
886
- this.controller.setTrustContext(resolvedTrustContext);
887
- }
652
+ const safeCustomParameters = msg.customParameters
653
+ ? Object.fromEntries(
654
+ Object.entries(msg.customParameters).filter(
655
+ ([key]) => !key.toLowerCase().includes("secret"),
656
+ ),
657
+ )
658
+ : undefined;
888
659
 
889
- if (pendingChallenge) {
890
- this.startInboundGuardianVerification(assistantId, msg.from);
891
- } else {
892
- this.startNormalCallFlow(controller, true);
893
- }
894
- } else {
895
- this.startNormalCallFlow(controller, false);
896
- }
660
+ recordCallEvent(this.callSessionId, "call_connected", {
661
+ callSid: msg.callSid,
662
+ from: msg.from,
663
+ to: msg.to,
664
+ customParameters: safeCustomParameters,
665
+ });
666
+ }
667
+
668
+ /** Deny an inbound call with a TTS message and schedule disconnect. */
669
+ private denyInboundCall(
670
+ from: string,
671
+ resolved: import("./relay-setup-router.js").SetupResolved,
672
+ outcome: { message: string; logReason: string },
673
+ ): void {
674
+ recordCallEvent(this.callSessionId, "inbound_acl_denied", {
675
+ from,
676
+ trustClass: resolved.actorTrust.trustClass,
677
+ denialReason: resolved.actorTrust.denialReason,
678
+ channelId: resolved.actorTrust.memberRecord?.channel.id,
679
+ memberPolicy: resolved.actorTrust.memberRecord?.channel.policy,
680
+ });
681
+ this.sendTextToken(outcome.message, true);
682
+ this.connectionState = "disconnecting";
683
+ updateCallSession(this.callSessionId, {
684
+ status: "failed",
685
+ endedAt: Date.now(),
686
+ lastError: outcome.logReason,
687
+ });
688
+ setTimeout(() => {
689
+ this.endSession(outcome.logReason);
690
+ }, getTtsPlaybackDelayMs());
897
691
  }
898
692
 
899
693
  /**
@@ -1127,59 +921,11 @@ export class RelayConnection {
1127
921
  }
1128
922
 
1129
923
  /**
1130
- * Extract digit characters from a speech transcript. Recognizes both
1131
- * raw digit characters ("1 2 3") and spoken number words ("one two three").
924
+ * Validate an entered code against the pending voice guardian challenge.
925
+ * Delegates to the extracted attemptGuardianCodeVerification() and
926
+ * interprets the structured result to drive side-effects.
1132
927
  */
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.
1181
- */
1182
- private attemptGuardianCodeVerification(enteredCode: string): void {
928
+ private handleGuardianCodeVerificationResult(enteredCode: string): void {
1183
929
  if (
1184
930
  !this.guardianChallengeAssistantId ||
1185
931
  !this.guardianVerificationFromNumber
@@ -1188,27 +934,26 @@ export class RelayConnection {
1188
934
  }
1189
935
 
1190
936
  const isOutbound = this.outboundGuardianVerificationSessionId != null;
1191
- const codeDigits = this.verificationCodeLength;
937
+ const assistantId = this.guardianChallengeAssistantId;
938
+ const fromNumber = this.guardianVerificationFromNumber;
1192
939
 
1193
- const result = validateAndConsumeChallenge(
1194
- this.guardianChallengeAssistantId,
1195
- "voice",
940
+ const result = attemptGuardianCodeVerification({
941
+ guardianChallengeAssistantId: assistantId,
942
+ guardianVerificationFromNumber: fromNumber,
1196
943
  enteredCode,
1197
- this.guardianVerificationFromNumber,
1198
- this.guardianVerificationFromNumber,
1199
- );
944
+ isOutbound,
945
+ codeDigits: this.verificationCodeLength,
946
+ verificationAttempts: this.verificationAttempts,
947
+ verificationMaxAttempts: this.verificationMaxAttempts,
948
+ });
1200
949
 
1201
- if (result.success) {
950
+ if (result.outcome === "success") {
1202
951
  this.connectionState = "connected";
1203
952
  this.guardianVerificationActive = false;
1204
953
  this.verificationAttempts = 0;
1205
954
  this.dtmfBuffer = "";
1206
955
 
1207
- const eventName = isOutbound
1208
- ? "outbound_guardian_voice_verification_succeeded"
1209
- : "guardian_voice_verification_succeeded";
1210
-
1211
- recordCallEvent(this.callSessionId, eventName, {
956
+ recordCallEvent(this.callSessionId, result.eventName, {
1212
957
  verificationType: result.verificationType,
1213
958
  });
1214
959
  log.info(
@@ -1218,65 +963,36 @@ export class RelayConnection {
1218
963
 
1219
964
  // Create the guardian binding now that verification succeeded.
1220
965
  if (result.verificationType === "guardian") {
1221
- const existingBinding = getGuardianBinding(
1222
- this.guardianChallengeAssistantId,
1223
- "voice",
1224
- );
1225
- if (
1226
- existingBinding &&
1227
- existingBinding.guardianExternalUserId !==
1228
- this.guardianVerificationFromNumber
1229
- ) {
966
+ if (result.bindingConflict) {
1230
967
  log.warn(
1231
968
  {
1232
969
  callSessionId: this.callSessionId,
1233
- existingGuardian: existingBinding.guardianExternalUserId,
970
+ existingGuardian: result.bindingConflict.existingGuardian,
1234
971
  },
1235
972
  "Guardian binding conflict: another user already holds the voice binding",
1236
973
  );
1237
974
  } else {
1238
- revokeGuardianBinding(this.guardianChallengeAssistantId, "voice");
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
-
975
+ revokeGuardianBinding(assistantId, "voice");
1249
976
  createGuardianBinding({
1250
- assistantId: this.guardianChallengeAssistantId,
977
+ assistantId,
1251
978
  channel: "voice",
1252
- guardianExternalUserId: this.guardianVerificationFromNumber,
1253
- guardianDeliveryChatId: this.guardianVerificationFromNumber,
1254
- guardianPrincipalId: canonicalPrincipal,
979
+ guardianExternalUserId: fromNumber,
980
+ guardianDeliveryChatId: fromNumber,
981
+ guardianPrincipalId: result.canonicalPrincipal!,
1255
982
  verifiedVia: "challenge",
1256
983
  });
1257
984
  }
1258
985
  }
1259
986
 
1260
987
  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
988
  this.connectionState = "disconnecting";
1266
-
1267
- const successText = composeVerificationVoice(
1268
- GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_SUCCESS,
1269
- { codeDigits },
1270
- );
1271
- this.sendTextToken(successText, true);
989
+ this.sendTextToken(result.ttsMessage!, true);
1272
990
 
1273
991
  updateCallSession(this.callSessionId, {
1274
992
  status: "completed",
1275
993
  endedAt: Date.now(),
1276
994
  });
1277
995
 
1278
- // Emit a pointer message to the origin conversation so the
1279
- // requesting chat sees a deterministic completion notice.
1280
996
  const successSession = getCallSession(this.callSessionId);
1281
997
  if (successSession?.initiatedFromConversationId) {
1282
998
  addPointerMessage(
@@ -1299,174 +1015,92 @@ export class RelayConnection {
1299
1015
  this.endSession("Verified — guardian challenge passed");
1300
1016
  }, getTtsPlaybackDelayMs());
1301
1017
  } else if (result.verificationType === "trusted_contact") {
1302
- // Inbound trusted-contact verification: activate and continue
1303
- // the live call with the shared handoff primitive.
1304
1018
  this.continueCallAfterTrustedContactActivation({
1305
- assistantId: this.guardianChallengeAssistantId,
1306
- fromNumber: this.guardianVerificationFromNumber,
1019
+ assistantId,
1020
+ fromNumber,
1307
1021
  });
1308
1022
  } else {
1309
- // Inbound guardian verification: create/update binding, then proceed
1310
- // to normal call flow. Mirrors the binding creation logic in
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
-
1023
+ // Inbound guardian verification: binding already handled above,
1024
+ // proceed to normal call flow.
1352
1025
  if (this.controller) {
1353
1026
  const verifiedActorTrust = resolveActorTrust({
1354
- assistantId: guardianAssistantId,
1027
+ assistantId,
1355
1028
  sourceChannel: "voice",
1356
- conversationExternalId: callerNumber,
1357
- actorExternalId: callerNumber,
1029
+ conversationExternalId: fromNumber,
1030
+ actorExternalId: fromNumber,
1358
1031
  });
1359
1032
  this.controller.setTrustContext(
1360
- toTrustContext(verifiedActorTrust, callerNumber),
1033
+ toTrustContext(verifiedActorTrust, fromNumber),
1361
1034
  );
1362
1035
  this.startNormalCallFlow(this.controller, true);
1363
1036
  }
1364
1037
  }
1365
- } else {
1366
- this.verificationAttempts++;
1038
+ } else if (result.outcome === "failure") {
1039
+ this.guardianVerificationActive = false;
1040
+ this.verificationAttempts = result.attempts;
1367
1041
 
1368
- if (this.verificationAttempts >= this.verificationMaxAttempts) {
1369
- // Immediately deactivate verification so DTMF/speech input during
1370
- // the goodbye window doesn't trigger more verification attempts.
1371
- this.guardianVerificationActive = false;
1042
+ recordCallEvent(this.callSessionId, result.eventName, {
1043
+ attempts: result.attempts,
1044
+ });
1045
+ log.warn(
1046
+ {
1047
+ callSessionId: this.callSessionId,
1048
+ attempts: result.attempts,
1049
+ isOutbound,
1050
+ },
1051
+ "Guardian voice verification failed — max attempts reached",
1052
+ );
1372
1053
 
1373
- const failEventName = isOutbound
1374
- ? "outbound_guardian_voice_verification_failed"
1375
- : "guardian_voice_verification_failed";
1054
+ this.sendTextToken(result.ttsMessage, true);
1376
1055
 
1377
- recordCallEvent(this.callSessionId, failEventName, {
1378
- attempts: this.verificationAttempts,
1379
- });
1380
- log.warn(
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);
1056
+ updateCallSession(this.callSessionId, {
1057
+ status: "failed",
1058
+ endedAt: Date.now(),
1059
+ lastError: "Guardian voice verification failed — max attempts exceeded",
1060
+ });
1396
1061
 
1397
- updateCallSession(this.callSessionId, {
1398
- status: "failed",
1399
- endedAt: Date.now(),
1400
- lastError:
1401
- "Guardian voice verification failed — max attempts exceeded",
1402
- });
1062
+ const failSession = getCallSession(this.callSessionId);
1063
+ if (failSession) {
1064
+ finalizeCall(this.callSessionId, failSession.conversationId);
1403
1065
 
1404
- const failSession = getCallSession(this.callSessionId);
1405
- if (failSession) {
1406
- expirePendingQuestions(this.callSessionId);
1407
- persistCallCompletionMessage(
1408
- failSession.conversationId,
1409
- this.callSessionId,
1066
+ if (isOutbound && failSession.initiatedFromConversationId) {
1067
+ addPointerMessage(
1068
+ failSession.initiatedFromConversationId,
1069
+ "guardian_verification_failed",
1070
+ failSession.toNumber,
1071
+ {
1072
+ channel: "voice",
1073
+ reason: "Max verification attempts exceeded",
1074
+ },
1410
1075
  ).catch((err) => {
1411
- log.error(
1076
+ log.warn(
1412
1077
  {
1078
+ conversationId: failSession.initiatedFromConversationId,
1413
1079
  err,
1414
- conversationId: failSession.conversationId,
1415
- callSessionId: this.callSessionId,
1416
1080
  },
1417
- "Failed to persist call completion message",
1081
+ "Skipping pointer write origin conversation may no longer exist",
1418
1082
  );
1419
1083
  });
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
1084
  }
1085
+ }
1447
1086
 
1448
- setTimeout(() => {
1449
- this.endSession("Verification failed — challenge rejected");
1450
- }, getTtsPlaybackDelayMs());
1451
- } else {
1452
- const retryText = isOutbound
1453
- ? composeVerificationVoice(
1454
- GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_RETRY,
1455
- { codeDigits },
1456
- )
1457
- : "That code was incorrect. Please try again.";
1087
+ setTimeout(() => {
1088
+ this.endSession("Verification failed — challenge rejected");
1089
+ }, getTtsPlaybackDelayMs());
1090
+ } else {
1091
+ // retry
1092
+ this.verificationAttempts = result.attempt;
1458
1093
 
1459
- log.info(
1460
- {
1461
- callSessionId: this.callSessionId,
1462
- attempt: this.verificationAttempts,
1463
- maxAttempts: this.verificationMaxAttempts,
1464
- isOutbound,
1465
- },
1466
- "Guardian voice verification attempt failed — retrying",
1467
- );
1468
- this.sendTextToken(retryText, true);
1469
- }
1094
+ log.info(
1095
+ {
1096
+ callSessionId: this.callSessionId,
1097
+ attempt: result.attempt,
1098
+ maxAttempts: result.maxAttempts,
1099
+ isOutbound,
1100
+ },
1101
+ "Guardian voice verification attempt failed — retrying",
1102
+ );
1103
+ this.sendTextToken(result.ttsMessage, true);
1470
1104
  }
1471
1105
  }
1472
1106
 
@@ -1794,7 +1428,7 @@ export class RelayConnection {
1794
1428
  */
1795
1429
  private handleAccessRequestTimeout(): void {
1796
1430
  // Emit callback handoff notification before clearing wait state
1797
- this.emitAccessRequestCallbackHandoff("timeout");
1431
+ this.emitAccessRequestCallbackHandoffForReason("timeout");
1798
1432
 
1799
1433
  this.clearAccessRequestWait();
1800
1434
 
@@ -1832,118 +1466,20 @@ export class RelayConnection {
1832
1466
  }, getTtsPlaybackDelayMs());
1833
1467
  }
1834
1468
 
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(
1469
+ private emitAccessRequestCallbackHandoffForReason(
1844
1470
  reason: "timeout" | "transport_closed",
1845
1471
  ): void {
1846
- if (!this.callbackOptIn) return;
1847
- if (!this.accessRequestId) return;
1848
- if (this.callbackHandoffNotified) return;
1849
-
1850
- this.callbackHandoffNotified = true;
1851
-
1852
- const assistantId =
1853
- this.accessRequestAssistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
1854
- const fromNumber = this.accessRequestFromNumber ?? null;
1855
-
1856
- // Resolve canonical request for requestCode and conversationId
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
- });
1472
+ const result = emitAccessRequestCallbackHandoff({
1473
+ reason,
1474
+ callbackOptIn: this.callbackOptIn,
1475
+ accessRequestId: this.accessRequestId,
1476
+ callbackHandoffNotified: this.callbackHandoffNotified,
1477
+ accessRequestAssistantId: this.accessRequestAssistantId,
1478
+ accessRequestFromNumber: this.accessRequestFromNumber,
1479
+ accessRequestCallerName: this.accessRequestCallerName,
1480
+ callSessionId: this.callSessionId,
1481
+ });
1482
+ this.callbackHandoffNotified = result.callbackHandoffNotified;
1947
1483
  }
1948
1484
 
1949
1485
  /**
@@ -1984,30 +1520,30 @@ export class RelayConnection {
1984
1520
  }
1985
1521
 
1986
1522
  /**
1987
- * Validate an entered invite code against active voice invites for the
1988
- * caller. On success, create/activate the contact and transition
1989
- * to the normal call flow. On failure, allow retries up to max attempts.
1523
+ * Validate an entered invite code against active voice invites.
1524
+ * Delegates to the extracted attemptInviteCodeRedemption() and
1525
+ * interprets the structured result to drive side-effects.
1990
1526
  */
1991
- private attemptInviteCodeRedemption(enteredCode: string): void {
1527
+ private handleInviteCodeRedemptionResult(enteredCode: string): void {
1992
1528
  if (!this.inviteRedemptionAssistantId || !this.inviteRedemptionFromNumber) {
1993
1529
  return;
1994
1530
  }
1995
1531
 
1996
- const result = redeemVoiceInviteCode({
1997
- assistantId: this.inviteRedemptionAssistantId,
1998
- callerExternalUserId: this.inviteRedemptionFromNumber,
1999
- sourceChannel: "voice",
2000
- code: enteredCode,
1532
+ const result = attemptInviteCodeRedemption({
1533
+ inviteRedemptionAssistantId: this.inviteRedemptionAssistantId,
1534
+ inviteRedemptionFromNumber: this.inviteRedemptionFromNumber,
1535
+ enteredCode,
1536
+ inviteRedemptionGuardianName: this.inviteRedemptionGuardianName,
2001
1537
  });
2002
1538
 
2003
- if (result.ok) {
1539
+ if (result.outcome === "success") {
2004
1540
  this.inviteRedemptionActive = false;
2005
1541
  this.verificationAttempts = 0;
2006
1542
  this.dtmfBuffer = "";
2007
1543
 
2008
1544
  recordCallEvent(this.callSessionId, "invite_redemption_succeeded", {
2009
1545
  memberId: result.memberId,
2010
- ...(result.type === "redeemed" ? { inviteId: result.inviteId } : {}),
1546
+ ...(result.inviteId ? { inviteId: result.inviteId } : {}),
2011
1547
  });
2012
1548
  log.info(
2013
1549
  {
@@ -2025,7 +1561,6 @@ export class RelayConnection {
2025
1561
  skipMemberActivation: true,
2026
1562
  });
2027
1563
  } else {
2028
- // On any invalid/expired code, emit exact deterministic failure copy and end call immediately.
2029
1564
  this.inviteRedemptionActive = false;
2030
1565
 
2031
1566
  recordCallEvent(this.callSessionId, "invite_redemption_failed", {
@@ -2036,12 +1571,7 @@ export class RelayConnection {
2036
1571
  "Voice invite redemption failed — invalid or expired code",
2037
1572
  );
2038
1573
 
2039
- const displayGuardian =
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
- );
1574
+ this.sendTextToken(result.ttsMessage, true);
2045
1575
 
2046
1576
  this.connectionState = "disconnecting";
2047
1577
 
@@ -2053,24 +1583,7 @@ export class RelayConnection {
2053
1583
 
2054
1584
  const failSession = getCallSession(this.callSessionId);
2055
1585
  if (failSession) {
2056
- expirePendingQuestions(this.callSessionId);
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
- );
1586
+ finalizeCall(this.callSessionId, failSession.conversationId);
2074
1587
  }
2075
1588
 
2076
1589
  setTimeout(() => {
@@ -2083,70 +1596,21 @@ export class RelayConnection {
2083
1596
 
2084
1597
  /**
2085
1598
  * Resolve a human-readable guardian label for voice wait copy.
2086
- * Prefers displayName from the guardian binding metadata, falls back
2087
- * to @username, then the user's preferred name from USER.md.
1599
+ * Delegates to the shared resolveGuardianName() which checks USER.md
1600
+ * first, then falls back to Contact.displayName, then DEFAULT_USER_REFERENCE.
2088
1601
  */
2089
1602
  private resolveGuardianLabel(): string {
2090
1603
  const assistantId =
2091
1604
  this.accessRequestAssistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
2092
1605
 
2093
- // Try the voice-channel binding first, then fall back to any active
2094
- // binding for the assistant (mirrors the cross-channel fallback pattern
2095
- // in access-request-helper.ts).
2096
- let metadataJson: string | null = null;
2097
- // Contacts-first: prefer the voice-bound guardian, then fall back to
2098
- // any guardian channel (mirrors the voice-first pattern in the legacy path).
1606
+ // Look up the guardian contact for a displayName fallback
2099
1607
  const voiceGuardian = findGuardianForChannel("voice", assistantId);
2100
1608
  const guardianChannels = voiceGuardian
2101
1609
  ? null
2102
1610
  : listGuardianChannels(assistantId);
2103
1611
  const guardianContact = voiceGuardian?.contact ?? guardianChannels?.contact;
2104
- if (guardianContact) {
2105
- const meta: Record<string, string> = {};
2106
- if (guardianContact.displayName) {
2107
- meta.displayName = guardianContact.displayName;
2108
- }
2109
- // Preserve the username fallback: use the voice channel's externalUserId
2110
- // so downstream parsing can fall back to @username when displayName is a
2111
- // raw external ID (e.g., phone number from contact-sync).
2112
- const voiceChannel =
2113
- voiceGuardian?.channel ??
2114
- guardianChannels?.channels.find((ch) => ch.type === "voice");
2115
- if (voiceChannel?.externalUserId) {
2116
- meta.username = voiceChannel.externalUserId;
2117
- }
2118
- if (Object.keys(meta).length > 0) {
2119
- metadataJson = JSON.stringify(meta);
2120
- }
2121
- }
2122
- if (!metadataJson) {
2123
- const voiceBinding = getGuardianBinding(assistantId, "voice");
2124
- if (voiceBinding?.metadataJson) {
2125
- metadataJson = voiceBinding.metadataJson;
2126
- }
2127
- }
2128
-
2129
- if (metadataJson) {
2130
- try {
2131
- const parsed = JSON.parse(metadataJson) as Record<string, unknown>;
2132
- if (
2133
- typeof parsed.displayName === "string" &&
2134
- parsed.displayName.trim().length > 0
2135
- ) {
2136
- return parsed.displayName.trim();
2137
- }
2138
- if (
2139
- typeof parsed.username === "string" &&
2140
- parsed.username.trim().length > 0
2141
- ) {
2142
- return `@${parsed.username.trim()}`;
2143
- }
2144
- } catch {
2145
- // ignore malformed metadata
2146
- }
2147
- }
2148
1612
 
2149
- return resolveUserReference();
1613
+ return resolveGuardianName(guardianContact?.displayName);
2150
1614
  }
2151
1615
 
2152
1616
  /**
@@ -2162,148 +1626,20 @@ export class RelayConnection {
2162
1626
  }
2163
1627
  }
2164
1628
 
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
1629
  private scheduleNextHeartbeat(): void {
2187
- if (!this.accessRequestWaitActive) return;
2188
-
2189
- const elapsed = Date.now() - this.accessRequestWaitStartedAt;
2190
- const initialWindow = getGuardianWaitUpdateInitialWindowMs();
2191
- const intervalMs =
2192
- elapsed < initialWindow
2193
- ? getGuardianWaitUpdateInitialIntervalMs()
2194
- : getGuardianWaitUpdateSteadyMinIntervalMs() +
2195
- Math.floor(
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);
1630
+ this.accessRequestHeartbeatTimer = scheduleNextHeartbeat({
1631
+ isWaitActive: () => this.accessRequestWaitActive,
1632
+ accessRequestWaitStartedAt: this.accessRequestWaitStartedAt,
1633
+ callSessionId: this.callSessionId,
1634
+ consumeSequence: () => this.heartbeatSequence++,
1635
+ resolveGuardianLabel: () => this.resolveGuardianLabel(),
1636
+ sendTextToken: (text, last) => this.sendTextToken(text, last),
1637
+ scheduleNext: () => this.scheduleNextHeartbeat(),
1638
+ });
2230
1639
  }
2231
1640
 
2232
- /**
2233
- * Classify a caller utterance during guardian wait into one of:
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";
1641
+ private classifyWaitUtterance(text: string) {
1642
+ return classifyWaitUtterance(text, this.callbackOfferMade);
2307
1643
  }
2308
1644
 
2309
1645
  /**
@@ -2483,9 +1819,7 @@ export class RelayConnection {
2483
1819
  this.connectionState === "verification_pending" &&
2484
1820
  this.guardianVerificationActive
2485
1821
  ) {
2486
- const spokenDigits = RelayConnection.parseDigitsFromSpeech(
2487
- msg.voicePrompt,
2488
- );
1822
+ const spokenDigits = parseDigitsFromSpeech(msg.voicePrompt);
2489
1823
  log.info(
2490
1824
  {
2491
1825
  callSessionId: this.callSessionId,
@@ -2496,7 +1830,7 @@ export class RelayConnection {
2496
1830
  );
2497
1831
  if (spokenDigits.length >= this.verificationCodeLength) {
2498
1832
  const enteredCode = spokenDigits.slice(0, this.verificationCodeLength);
2499
- this.attemptGuardianCodeVerification(enteredCode);
1833
+ this.handleGuardianCodeVerificationResult(enteredCode);
2500
1834
  } else if (spokenDigits.length > 0) {
2501
1835
  this.sendTextToken(
2502
1836
  `I heard ${spokenDigits.length} digits. Please enter all ${this.verificationCodeLength} digits of your code.`,
@@ -2512,9 +1846,7 @@ export class RelayConnection {
2512
1846
  this.connectionState === "verification_pending" &&
2513
1847
  this.inviteRedemptionActive
2514
1848
  ) {
2515
- const spokenDigits = RelayConnection.parseDigitsFromSpeech(
2516
- msg.voicePrompt,
2517
- );
1849
+ const spokenDigits = parseDigitsFromSpeech(msg.voicePrompt);
2518
1850
  log.info(
2519
1851
  {
2520
1852
  callSessionId: this.callSessionId,
@@ -2528,7 +1860,7 @@ export class RelayConnection {
2528
1860
  0,
2529
1861
  this.inviteRedemptionCodeLength,
2530
1862
  );
2531
- this.attemptInviteCodeRedemption(enteredCode);
1863
+ this.handleInviteCodeRedemptionResult(enteredCode);
2532
1864
  } else if (spokenDigits.length > 0) {
2533
1865
  this.sendTextToken(
2534
1866
  `I heard ${spokenDigits.length} digits. Please enter all ${this.inviteRedemptionCodeLength} digits of your code.`,
@@ -2683,7 +2015,7 @@ export class RelayConnection {
2683
2015
  this.verificationCodeLength,
2684
2016
  );
2685
2017
  this.dtmfBuffer = "";
2686
- this.attemptGuardianCodeVerification(enteredCode);
2018
+ this.handleGuardianCodeVerificationResult(enteredCode);
2687
2019
  }
2688
2020
  return;
2689
2021
  }
@@ -2702,7 +2034,7 @@ export class RelayConnection {
2702
2034
  this.inviteRedemptionCodeLength,
2703
2035
  );
2704
2036
  this.dtmfBuffer = "";
2705
- this.attemptInviteCodeRedemption(enteredCode);
2037
+ this.handleInviteCodeRedemptionResult(enteredCode);
2706
2038
  }
2707
2039
  return;
2708
2040
  }
@@ -2777,24 +2109,7 @@ export class RelayConnection {
2777
2109
 
2778
2110
  const session = getCallSession(this.callSessionId);
2779
2111
  if (session) {
2780
- expirePendingQuestions(this.callSessionId);
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
- );
2112
+ finalizeCall(this.callSessionId, session.conversationId);
2798
2113
  if (session.initiatedFromConversationId) {
2799
2114
  addPointerMessage(
2800
2115
  session.initiatedFromConversationId,