@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.
- package/ARCHITECTURE.md +1 -1
- package/docs/architecture/memory.md +1 -1
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -7
- package/src/__tests__/access-request-decision.test.ts +83 -1
- package/src/__tests__/actor-token-service.test.ts +0 -1
- package/src/__tests__/anthropic-provider.test.ts +86 -1
- package/src/__tests__/approval-routes-http.test.ts +0 -1
- package/src/__tests__/assistant-feature-flags-integration.test.ts +2 -2
- package/src/__tests__/call-controller.test.ts +0 -1
- package/src/__tests__/call-routes-http.test.ts +0 -1
- package/src/__tests__/channel-guardian.test.ts +0 -1
- package/src/__tests__/channel-invite-transport.test.ts +52 -40
- package/src/__tests__/checker.test.ts +37 -98
- package/src/__tests__/commit-message-enrichment-service.test.ts +4 -23
- package/src/__tests__/computer-use-session-working-dir.test.ts +0 -1
- package/src/__tests__/config-schema.test.ts +6 -5
- package/src/__tests__/credential-security-invariants.test.ts +2 -0
- package/src/__tests__/daemon-server-session-init.test.ts +1 -19
- package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
- package/src/__tests__/followup-tools.test.ts +0 -30
- package/src/__tests__/gemini-provider.test.ts +79 -1
- package/src/__tests__/guardian-action-followup-executor.test.ts +0 -1
- package/src/__tests__/guardian-dispatch.test.ts +0 -1
- package/src/__tests__/guardian-outbound-http.test.ts +0 -1
- package/src/__tests__/handlers-telegram-config.test.ts +0 -1
- package/src/__tests__/inbound-invite-redemption.test.ts +1 -4
- package/src/__tests__/ingress-reconcile.test.ts +3 -36
- 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__/migration-cross-version-compatibility.test.ts +0 -1
- package/src/__tests__/migration-export-http.test.ts +0 -1
- package/src/__tests__/migration-import-commit-http.test.ts +0 -1
- package/src/__tests__/migration-import-preflight-http.test.ts +0 -1
- package/src/__tests__/migration-validate-http.test.ts +0 -1
- package/src/__tests__/non-member-access-request.test.ts +0 -1
- package/src/__tests__/notification-guardian-path.test.ts +0 -1
- package/src/__tests__/notification-telegram-adapter.test.ts +0 -4
- 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__/relay-server.test.ts +145 -2
- package/src/__tests__/sandbox-host-parity.test.ts +5 -2
- package/src/__tests__/schedule-tools.test.ts +28 -44
- package/src/__tests__/session-init.benchmark.test.ts +0 -2
- package/src/__tests__/skill-feature-flags.test.ts +2 -2
- package/src/__tests__/slack-channel-config.test.ts +0 -1
- package/src/__tests__/slack-inbound-verification.test.ts +0 -1
- package/src/__tests__/sms-messaging-provider.test.ts +0 -4
- package/src/__tests__/task-management-tools.test.ts +111 -0
- package/src/__tests__/terminal-tools.test.ts +5 -2
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +66 -74
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +0 -1
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +0 -1
- package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
- package/src/__tests__/trusted-contact-verification.test.ts +0 -1
- package/src/__tests__/twilio-config.test.ts +0 -3
- package/src/__tests__/twilio-routes.test.ts +0 -1
- package/src/__tests__/update-bulletin.test.ts +0 -2
- package/src/__tests__/user-reference.test.ts +47 -1
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
- package/src/__tests__/workspace-git-service.test.ts +2 -2
- 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 +271 -956
- 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/channels/config.ts +41 -2
- 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/bundled-skills/slack/SKILL.md +2 -0
- package/src/config/bundled-skills/slack-digest-setup/SKILL.md +164 -0
- package/src/config/core-schema.ts +1 -1
- package/src/config/env.ts +0 -14
- package/src/config/feature-flag-registry.json +5 -5
- package/src/config/loader.ts +19 -0
- package/src/config/schema.ts +2 -2
- package/src/config/user-reference.ts +47 -9
- package/src/daemon/handlers/config-channels.ts +11 -10
- package/src/daemon/handlers/contacts.ts +5 -1
- 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 +18 -55
- package/src/home-base/app-link-store.ts +0 -7
- package/src/memory/channel-delivery-store.ts +1 -0
- package/src/memory/conversation-attention-store.ts +1 -1
- package/src/memory/conversation-store.ts +0 -51
- package/src/memory/db-init.ts +9 -1
- package/src/memory/delivery-crud.ts +13 -0
- package/src/memory/invite-store.ts +71 -1
- package/src/memory/job-handlers/conflict.ts +24 -0
- package/src/memory/migrations/040-invite-code-hash-column.ts +16 -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/index.ts +1 -0
- 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 +127 -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 +52 -26
- package/src/runtime/auth/token-service.ts +50 -0
- package/src/runtime/channel-guardian-service.ts +1 -3
- package/src/runtime/channel-invite-transport.ts +121 -34
- package/src/runtime/channel-invite-transports/email.ts +50 -0
- package/src/runtime/channel-invite-transports/slack.ts +81 -0
- package/src/runtime/channel-invite-transports/sms.ts +70 -0
- package/src/runtime/channel-invite-transports/telegram.ts +29 -11
- package/src/runtime/channel-invite-transports/voice.ts +12 -12
- package/src/runtime/http-server.ts +83 -722
- package/src/runtime/http-types.ts +0 -16
- package/src/runtime/invite-redemption-service.ts +193 -0
- package/src/runtime/invite-redemption-templates.ts +6 -6
- package/src/runtime/invite-service.ts +81 -11
- package/src/runtime/middleware/auth.ts +0 -12
- package/src/runtime/routes/access-request-decision.ts +52 -6
- package/src/runtime/routes/app-routes.ts +33 -0
- package/src/runtime/routes/approval-routes.ts +32 -0
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +6 -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 +96 -6
- 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 +21 -6
- 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 +9 -3
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +295 -10
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +9 -42
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +10 -0
- package/src/runtime/routes/integration-routes.ts +83 -0
- package/src/runtime/routes/invite-routes.ts +32 -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/browser/browser-manager.ts +10 -1
- package/src/tools/browser/runtime-check.ts +3 -1
- 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/tools/shared/shell-output.ts +7 -2
- package/src/twitter/session.ts +29 -77
- package/src/util/cookie-session.ts +114 -0
- package/src/util/platform.ts +0 -4
- package/src/workspace/git-service.ts +10 -4
- 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 {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
535
|
+
const { outcome, resolved } = routeSetup({
|
|
536
|
+
callSessionId: this.callSessionId,
|
|
537
|
+
session,
|
|
585
538
|
from: msg.from,
|
|
586
539
|
to: msg.to,
|
|
587
|
-
customParameters:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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());
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
}
|
|
895
|
-
|
|
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
|
-
*
|
|
1131
|
-
*
|
|
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
|
|
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
|
|
937
|
+
const assistantId = this.guardianChallengeAssistantId;
|
|
938
|
+
const fromNumber = this.guardianVerificationFromNumber;
|
|
1192
939
|
|
|
1193
|
-
const result =
|
|
1194
|
-
|
|
1195
|
-
|
|
940
|
+
const result = attemptGuardianCodeVerification({
|
|
941
|
+
guardianChallengeAssistantId: assistantId,
|
|
942
|
+
guardianVerificationFromNumber: fromNumber,
|
|
1196
943
|
enteredCode,
|
|
1197
|
-
|
|
1198
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
|
977
|
+
assistantId,
|
|
1251
978
|
channel: "voice",
|
|
1252
|
-
guardianExternalUserId:
|
|
1253
|
-
guardianDeliveryChatId:
|
|
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
|
|
1306
|
-
fromNumber
|
|
1019
|
+
assistantId,
|
|
1020
|
+
fromNumber,
|
|
1307
1021
|
});
|
|
1308
1022
|
} 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
|
-
|
|
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
|
|
1027
|
+
assistantId,
|
|
1355
1028
|
sourceChannel: "voice",
|
|
1356
|
-
conversationExternalId:
|
|
1357
|
-
actorExternalId:
|
|
1029
|
+
conversationExternalId: fromNumber,
|
|
1030
|
+
actorExternalId: fromNumber,
|
|
1358
1031
|
});
|
|
1359
1032
|
this.controller.setTrustContext(
|
|
1360
|
-
toTrustContext(verifiedActorTrust,
|
|
1033
|
+
toTrustContext(verifiedActorTrust, fromNumber),
|
|
1361
1034
|
);
|
|
1362
1035
|
this.startNormalCallFlow(this.controller, true);
|
|
1363
1036
|
}
|
|
1364
1037
|
}
|
|
1365
|
-
} else {
|
|
1366
|
-
this.
|
|
1038
|
+
} else if (result.outcome === "failure") {
|
|
1039
|
+
this.guardianVerificationActive = false;
|
|
1040
|
+
this.verificationAttempts = result.attempts;
|
|
1367
1041
|
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
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
|
-
|
|
1374
|
-
? "outbound_guardian_voice_verification_failed"
|
|
1375
|
-
: "guardian_voice_verification_failed";
|
|
1054
|
+
this.sendTextToken(result.ttsMessage, true);
|
|
1376
1055
|
|
|
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);
|
|
1056
|
+
updateCallSession(this.callSessionId, {
|
|
1057
|
+
status: "failed",
|
|
1058
|
+
endedAt: Date.now(),
|
|
1059
|
+
lastError: "Guardian voice verification failed — max attempts exceeded",
|
|
1060
|
+
});
|
|
1396
1061
|
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
failSession.
|
|
1409
|
-
|
|
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.
|
|
1076
|
+
log.warn(
|
|
1412
1077
|
{
|
|
1078
|
+
conversationId: failSession.initiatedFromConversationId,
|
|
1413
1079
|
err,
|
|
1414
|
-
conversationId: failSession.conversationId,
|
|
1415
|
-
callSessionId: this.callSessionId,
|
|
1416
1080
|
},
|
|
1417
|
-
"
|
|
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
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
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
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
|
1988
|
-
*
|
|
1989
|
-
*
|
|
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
|
|
1527
|
+
private handleInviteCodeRedemptionResult(enteredCode: string): void {
|
|
1992
1528
|
if (!this.inviteRedemptionAssistantId || !this.inviteRedemptionFromNumber) {
|
|
1993
1529
|
return;
|
|
1994
1530
|
}
|
|
1995
1531
|
|
|
1996
|
-
const result =
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
2087
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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,
|