@vellumai/assistant 0.4.2 → 0.4.4
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/.env.example +3 -0
- package/ARCHITECTURE.md +124 -10
- package/README.md +43 -35
- package/docs/trusted-contact-access.md +20 -0
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +1 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
- package/src/__tests__/access-request-decision.test.ts +0 -1
- package/src/__tests__/actor-token-service.test.ts +1099 -0
- package/src/__tests__/agent-loop.test.ts +51 -0
- package/src/__tests__/approval-routes-http.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
- package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
- package/src/__tests__/call-controller.test.ts +49 -0
- package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
- package/src/__tests__/call-pointer-messages.test.ts +93 -3
- package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
- package/src/__tests__/call-routes-http.test.ts +0 -25
- package/src/__tests__/callback-handoff-copy.test.ts +186 -0
- package/src/__tests__/channel-approval-routes.test.ts +133 -12
- package/src/__tests__/channel-guardian.test.ts +0 -86
- package/src/__tests__/channel-readiness-service.test.ts +10 -16
- package/src/__tests__/checker.test.ts +33 -12
- package/src/__tests__/config-schema.test.ts +6 -0
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
- package/src/__tests__/conversation-routes.test.ts +12 -3
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/daemon-server-session-init.test.ts +4 -0
- package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
- package/src/__tests__/guardian-dispatch.test.ts +8 -0
- package/src/__tests__/guardian-outbound-http.test.ts +4 -5
- package/src/__tests__/guardian-question-mode.test.ts +200 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
- package/src/__tests__/guardian-routing-state.test.ts +525 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
- package/src/__tests__/handlers-telegram-config.test.ts +0 -83
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
- package/src/__tests__/headless-browser-navigate.test.ts +2 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
- package/src/__tests__/ingress-routes-http.test.ts +55 -0
- package/src/__tests__/ipc-snapshot.test.ts +18 -51
- package/src/__tests__/non-member-access-request.test.ts +159 -9
- package/src/__tests__/notification-decision-fallback.test.ts +129 -4
- package/src/__tests__/notification-decision-strategy.test.ts +106 -2
- package/src/__tests__/notification-guardian-path.test.ts +3 -0
- package/src/__tests__/recording-intent-handler.test.ts +1 -0
- package/src/__tests__/relay-server.test.ts +1475 -33
- package/src/__tests__/send-endpoint-busy.test.ts +5 -0
- package/src/__tests__/session-agent-loop.test.ts +1 -0
- package/src/__tests__/session-confirmation-signals.test.ts +523 -0
- package/src/__tests__/session-init.benchmark.test.ts +0 -2
- package/src/__tests__/session-runtime-assembly.test.ts +4 -1
- package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
- package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
- package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
- package/src/__tests__/tool-executor.test.ts +21 -2
- package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
- package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
- package/src/__tests__/trusted-contact-verification.test.ts +0 -1
- package/src/__tests__/twilio-config.test.ts +2 -13
- package/src/__tests__/twilio-routes.test.ts +4 -3
- package/src/__tests__/update-bulletin.test.ts +0 -1
- package/src/agent/loop.ts +1 -1
- package/src/approvals/guardian-decision-primitive.ts +12 -3
- package/src/approvals/guardian-request-resolvers.ts +169 -11
- package/src/calls/call-constants.ts +29 -0
- package/src/calls/call-controller.ts +11 -3
- package/src/calls/call-domain.ts +33 -11
- package/src/calls/call-pointer-message-composer.ts +154 -0
- package/src/calls/call-pointer-messages.ts +106 -27
- package/src/calls/guardian-dispatch.ts +4 -2
- package/src/calls/relay-server.ts +921 -112
- package/src/calls/twilio-config.ts +4 -11
- package/src/calls/twilio-routes.ts +4 -6
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-session-bridge.ts +4 -3
- package/src/cli/core-commands.ts +7 -4
- package/src/cli.ts +5 -4
- package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
- package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
- package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
- package/src/config/bundled-skills/messaging/SKILL.md +61 -12
- package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
- package/src/config/bundled-skills/twitter/SKILL.md +3 -3
- package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
- package/src/config/calls-schema.ts +36 -0
- package/src/config/env.ts +22 -0
- package/src/config/feature-flag-registry.json +8 -8
- package/src/config/schema.ts +2 -2
- package/src/config/skills.ts +11 -0
- package/src/config/system-prompt.ts +11 -1
- package/src/config/templates/SOUL.md +2 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
- package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
- package/src/daemon/call-pointer-generators.ts +59 -0
- package/src/daemon/computer-use-session.ts +2 -5
- package/src/daemon/handlers/apps.ts +76 -20
- package/src/daemon/handlers/config-channels.ts +9 -61
- package/src/daemon/handlers/config-inbox.ts +11 -3
- package/src/daemon/handlers/config-ingress.ts +28 -3
- package/src/daemon/handlers/config-telegram.ts +12 -0
- package/src/daemon/handlers/config.ts +2 -6
- package/src/daemon/handlers/index.ts +2 -1
- package/src/daemon/handlers/pairing.ts +2 -0
- package/src/daemon/handlers/publish.ts +11 -46
- package/src/daemon/handlers/sessions.ts +59 -5
- package/src/daemon/handlers/shared.ts +17 -2
- package/src/daemon/ipc-contract/apps.ts +1 -0
- package/src/daemon/ipc-contract/inbox.ts +4 -0
- package/src/daemon/ipc-contract/integrations.ts +1 -97
- package/src/daemon/ipc-contract/messages.ts +47 -1
- package/src/daemon/ipc-contract/notifications.ts +11 -0
- package/src/daemon/ipc-contract-inventory.json +2 -4
- package/src/daemon/lifecycle.ts +17 -0
- package/src/daemon/server.ts +16 -2
- package/src/daemon/session-agent-loop-handlers.ts +20 -0
- package/src/daemon/session-agent-loop.ts +24 -12
- package/src/daemon/session-lifecycle.ts +1 -1
- package/src/daemon/session-process.ts +11 -1
- package/src/daemon/session-runtime-assembly.ts +6 -1
- package/src/daemon/session-surfaces.ts +32 -3
- package/src/daemon/session.ts +88 -1
- package/src/daemon/tool-side-effects.ts +22 -0
- package/src/home-base/prebuilt/brain-graph.html +1483 -0
- package/src/home-base/prebuilt/index.html +40 -0
- package/src/inbound/platform-callback-registration.ts +157 -0
- package/src/memory/canonical-guardian-store.ts +1 -1
- package/src/memory/conversation-crud.ts +2 -1
- package/src/memory/conversation-title-service.ts +16 -2
- package/src/memory/db-init.ts +8 -0
- package/src/memory/delivery-crud.ts +2 -1
- package/src/memory/guardian-action-store.ts +2 -1
- package/src/memory/guardian-approvals.ts +3 -2
- package/src/memory/ingress-invite-store.ts +12 -2
- package/src/memory/ingress-member-store.ts +4 -3
- package/src/memory/migrations/038-actor-token-records.ts +39 -0
- package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/schema.ts +26 -5
- package/src/messaging/provider-types.ts +24 -0
- package/src/messaging/provider.ts +7 -0
- package/src/messaging/providers/gmail/adapter.ts +127 -0
- package/src/messaging/providers/sms/adapter.ts +40 -37
- package/src/notifications/adapters/macos.ts +45 -2
- package/src/notifications/broadcaster.ts +16 -0
- package/src/notifications/copy-composer.ts +50 -2
- package/src/notifications/decision-engine.ts +22 -9
- package/src/notifications/destination-resolver.ts +16 -2
- package/src/notifications/emit-signal.ts +18 -9
- package/src/notifications/guardian-question-mode.ts +419 -0
- package/src/notifications/signal.ts +14 -3
- package/src/permissions/checker.ts +13 -1
- package/src/permissions/prompter.ts +14 -0
- package/src/providers/anthropic/client.ts +20 -0
- package/src/providers/provider-send-message.ts +15 -3
- package/src/runtime/access-request-helper.ts +82 -4
- package/src/runtime/actor-token-service.ts +234 -0
- package/src/runtime/actor-token-store.ts +236 -0
- package/src/runtime/actor-trust-resolver.ts +2 -2
- package/src/runtime/assistant-scope.ts +10 -0
- package/src/runtime/channel-approvals.ts +5 -3
- package/src/runtime/channel-readiness-service.ts +23 -64
- package/src/runtime/channel-readiness-types.ts +3 -4
- package/src/runtime/channel-retry-sweep.ts +4 -1
- package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/guardian-context-resolver.ts +82 -0
- package/src/runtime/guardian-outbound-actions.ts +5 -7
- package/src/runtime/guardian-reply-router.ts +67 -30
- package/src/runtime/guardian-vellum-migration.ts +57 -0
- package/src/runtime/http-server.ts +75 -31
- package/src/runtime/http-types.ts +13 -0
- package/src/runtime/ingress-service.ts +14 -0
- package/src/runtime/invite-redemption-service.ts +10 -1
- package/src/runtime/local-actor-identity.ts +76 -0
- package/src/runtime/middleware/actor-token.ts +271 -0
- package/src/runtime/middleware/twilio-validation.ts +2 -4
- package/src/runtime/routes/approval-routes.ts +82 -7
- package/src/runtime/routes/brain-graph-routes.ts +222 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-readiness-routes.ts +71 -0
- package/src/runtime/routes/channel-route-shared.ts +3 -3
- package/src/runtime/routes/conversation-attention-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +142 -53
- package/src/runtime/routes/events-routes.ts +22 -8
- package/src/runtime/routes/guardian-action-routes.ts +45 -3
- package/src/runtime/routes/guardian-approval-interception.ts +29 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
- package/src/runtime/routes/inbound-conversation.ts +4 -3
- package/src/runtime/routes/inbound-message-handler.ts +147 -5
- package/src/runtime/routes/ingress-routes.ts +2 -0
- package/src/runtime/routes/integration-routes.ts +7 -15
- package/src/runtime/routes/pairing-routes.ts +163 -0
- package/src/runtime/routes/twilio-routes.ts +934 -0
- package/src/runtime/tool-grant-request-helper.ts +3 -1
- package/src/security/oauth2.ts +27 -2
- package/src/security/token-manager.ts +46 -10
- package/src/tools/browser/browser-execution.ts +4 -3
- package/src/tools/browser/browser-handoff.ts +10 -18
- package/src/tools/browser/browser-manager.ts +80 -25
- package/src/tools/browser/browser-screencast.ts +35 -119
- package/src/tools/calls/call-start.ts +2 -1
- package/src/tools/permission-checker.ts +15 -4
- package/src/tools/terminal/parser.ts +12 -0
- package/src/tools/tool-approval-handler.ts +244 -19
- package/src/workspace/git-service.ts +19 -0
- package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
- package/src/daemon/handlers/config-twilio.ts +0 -1082
|
@@ -10,17 +10,22 @@ import { randomInt } from 'node:crypto';
|
|
|
10
10
|
|
|
11
11
|
import type { ServerWebSocket } from 'bun';
|
|
12
12
|
|
|
13
|
-
import { isAssistantFeatureFlagEnabled } from '../config/assistant-feature-flags.js';
|
|
14
13
|
import { getConfig } from '../config/loader.js';
|
|
14
|
+
import { getCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
|
|
15
|
+
import { listActiveBindingsByAssistant } from '../memory/channel-guardian-store.js';
|
|
15
16
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
16
17
|
import { findActiveVoiceInvites } from '../memory/ingress-invite-store.js';
|
|
18
|
+
import { findMember, upsertMember } from '../memory/ingress-member-store.js';
|
|
17
19
|
import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
|
|
20
|
+
import { emitNotificationSignal } from '../notifications/emit-signal.js';
|
|
18
21
|
import { notifyGuardianOfAccessRequest } from '../runtime/access-request-helper.js';
|
|
19
22
|
import {
|
|
20
23
|
resolveActorTrust,
|
|
21
24
|
toGuardianRuntimeContextFromTrust,
|
|
22
25
|
} from '../runtime/actor-trust-resolver.js';
|
|
26
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
|
|
23
27
|
import {
|
|
28
|
+
getGuardianBinding,
|
|
24
29
|
getPendingChallenge,
|
|
25
30
|
validateAndConsumeChallenge,
|
|
26
31
|
} from '../runtime/channel-guardian-service.js';
|
|
@@ -31,7 +36,15 @@ import {
|
|
|
31
36
|
import { redeemVoiceInviteCode } from '../runtime/ingress-service.js';
|
|
32
37
|
import { parseJsonSafe } from '../util/json.js';
|
|
33
38
|
import { getLogger } from '../util/logger.js';
|
|
34
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
getAccessRequestPollIntervalMs,
|
|
41
|
+
getGuardianWaitUpdateInitialIntervalMs,
|
|
42
|
+
getGuardianWaitUpdateInitialWindowMs,
|
|
43
|
+
getGuardianWaitUpdateSteadyMaxIntervalMs,
|
|
44
|
+
getGuardianWaitUpdateSteadyMinIntervalMs,
|
|
45
|
+
getTtsPlaybackDelayMs,
|
|
46
|
+
getUserConsultationTimeoutMs,
|
|
47
|
+
} from './call-constants.js';
|
|
35
48
|
import { CallController } from './call-controller.js';
|
|
36
49
|
import { persistCallCompletionMessage } from './call-conversation-messages.js';
|
|
37
50
|
import { addPointerMessage, formatDuration } from './call-pointer-messages.js';
|
|
@@ -144,7 +157,7 @@ export function setRelayBroadcast(fn: (msg: import('../daemon/ipc-contract.js').
|
|
|
144
157
|
/**
|
|
145
158
|
* Manages a single WebSocket connection for one call.
|
|
146
159
|
*/
|
|
147
|
-
export type RelayConnectionState = 'connected' | 'verification_pending' | 'disconnecting';
|
|
160
|
+
export type RelayConnectionState = 'connected' | 'verification_pending' | 'awaiting_name' | 'awaiting_guardian_decision' | 'disconnecting';
|
|
148
161
|
|
|
149
162
|
export class RelayConnection {
|
|
150
163
|
private ws: ServerWebSocket<RelayWebSocketData>;
|
|
@@ -180,6 +193,34 @@ export class RelayConnection {
|
|
|
180
193
|
private inviteRedemptionAssistantId: string | null = null;
|
|
181
194
|
private inviteRedemptionFromNumber: string | null = null;
|
|
182
195
|
private inviteRedemptionCodeLength = 6;
|
|
196
|
+
private inviteRedemptionFriendName: string | null = null;
|
|
197
|
+
private inviteRedemptionGuardianName: string | null = null;
|
|
198
|
+
|
|
199
|
+
// In-call guardian approval wait state (friend-initiated)
|
|
200
|
+
private accessRequestWaitActive = false;
|
|
201
|
+
private accessRequestId: string | null = null;
|
|
202
|
+
private accessRequestAssistantId: string | null = null;
|
|
203
|
+
private accessRequestFromNumber: string | null = null;
|
|
204
|
+
private accessRequestPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
205
|
+
private accessRequestTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
206
|
+
private accessRequestCallerName: string | null = null;
|
|
207
|
+
|
|
208
|
+
// Name capture timeout (unknown inbound callers)
|
|
209
|
+
private nameCaptureTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
210
|
+
|
|
211
|
+
// Guardian wait heartbeat state
|
|
212
|
+
private accessRequestHeartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
|
213
|
+
private accessRequestWaitStartedAt: number = 0;
|
|
214
|
+
private heartbeatSequence = 0;
|
|
215
|
+
|
|
216
|
+
// In-wait prompt handling state
|
|
217
|
+
private lastInWaitReplyAt = 0;
|
|
218
|
+
private static readonly IN_WAIT_REPLY_COOLDOWN_MS = 3000;
|
|
219
|
+
|
|
220
|
+
// Callback offer state (in-memory per-call)
|
|
221
|
+
private callbackOfferMade = false;
|
|
222
|
+
private callbackOptIn = false;
|
|
223
|
+
private callbackHandoffNotified = false;
|
|
183
224
|
|
|
184
225
|
constructor(ws: ServerWebSocket<RelayWebSocketData>, callSessionId: string) {
|
|
185
226
|
this.ws = ws;
|
|
@@ -304,6 +345,23 @@ export class RelayConnection {
|
|
|
304
345
|
this.controller.destroy();
|
|
305
346
|
this.controller = null;
|
|
306
347
|
}
|
|
348
|
+
if (this.accessRequestPollTimer) {
|
|
349
|
+
clearInterval(this.accessRequestPollTimer);
|
|
350
|
+
this.accessRequestPollTimer = null;
|
|
351
|
+
}
|
|
352
|
+
if (this.accessRequestTimeoutTimer) {
|
|
353
|
+
clearTimeout(this.accessRequestTimeoutTimer);
|
|
354
|
+
this.accessRequestTimeoutTimer = null;
|
|
355
|
+
}
|
|
356
|
+
if (this.accessRequestHeartbeatTimer) {
|
|
357
|
+
clearTimeout(this.accessRequestHeartbeatTimer);
|
|
358
|
+
this.accessRequestHeartbeatTimer = null;
|
|
359
|
+
}
|
|
360
|
+
if (this.nameCaptureTimeoutTimer) {
|
|
361
|
+
clearTimeout(this.nameCaptureTimeoutTimer);
|
|
362
|
+
this.nameCaptureTimeoutTimer = null;
|
|
363
|
+
}
|
|
364
|
+
this.accessRequestWaitActive = false;
|
|
307
365
|
this.abortController.abort();
|
|
308
366
|
log.info({ callSessionId: this.callSessionId }, 'RelayConnection destroyed');
|
|
309
367
|
}
|
|
@@ -315,6 +373,19 @@ export class RelayConnection {
|
|
|
315
373
|
* we still finalize the call lifecycle from the relay close signal.
|
|
316
374
|
*/
|
|
317
375
|
handleTransportClosed(code?: number, reason?: string): void {
|
|
376
|
+
// If the call was still in guardian-wait with callback opt-in, emit the
|
|
377
|
+
// handoff notification before cleaning up wait state.
|
|
378
|
+
if (this.accessRequestWaitActive && this.callbackOptIn) {
|
|
379
|
+
this.emitAccessRequestCallbackHandoff('transport_closed');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Clean up access request wait state on disconnect to stop polling
|
|
383
|
+
this.clearAccessRequestWait();
|
|
384
|
+
if (this.nameCaptureTimeoutTimer) {
|
|
385
|
+
clearTimeout(this.nameCaptureTimeoutTimer);
|
|
386
|
+
this.nameCaptureTimeoutTimer = null;
|
|
387
|
+
}
|
|
388
|
+
|
|
318
389
|
const session = getCallSession(this.callSessionId);
|
|
319
390
|
if (!session) return;
|
|
320
391
|
if (isTerminalState(session.status)) return;
|
|
@@ -427,7 +498,7 @@ export class RelayConnection {
|
|
|
427
498
|
// calls (created via createInboundVoiceSession) never do. Relying on
|
|
428
499
|
// task == null is unreliable: task-less outbound sessions would
|
|
429
500
|
// incorrectly bypass outbound verification.
|
|
430
|
-
const assistantId =
|
|
501
|
+
const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
|
|
431
502
|
const isInbound = session?.initiatedFromConversationId == null;
|
|
432
503
|
|
|
433
504
|
// Create and attach the session-backed voice controller. Seed guardian
|
|
@@ -500,89 +571,80 @@ export class RelayConnection {
|
|
|
500
571
|
const pendingChallenge = getPendingChallenge(assistantId, 'voice');
|
|
501
572
|
|
|
502
573
|
if (actorTrust.trustClass === 'unknown' && !pendingChallenge) {
|
|
503
|
-
// Before
|
|
504
|
-
// to the caller's phone number. If so,
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
574
|
+
// Before entering the name capture flow, check if there is an
|
|
575
|
+
// active voice invite bound to the caller's phone number. If so,
|
|
576
|
+
// enter the invite redemption subflow instead.
|
|
577
|
+
let voiceInvites: ReturnType<typeof findActiveVoiceInvites> = [];
|
|
578
|
+
try {
|
|
579
|
+
voiceInvites = findActiveVoiceInvites({
|
|
580
|
+
assistantId,
|
|
581
|
+
expectedExternalUserId: msg.from,
|
|
582
|
+
});
|
|
583
|
+
} catch (err) {
|
|
584
|
+
log.warn({ err, callSessionId: this.callSessionId }, 'Failed to check voice invites for unknown caller');
|
|
585
|
+
}
|
|
511
586
|
|
|
512
|
-
if
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
587
|
+
// Exclude invites that are past their expiresAt even if the DB
|
|
588
|
+
// status hasn't been lazily flipped to 'expired' yet.
|
|
589
|
+
const now = Date.now();
|
|
590
|
+
const nonExpiredInvites = voiceInvites.filter(i => !i.expiresAt || i.expiresAt > now);
|
|
591
|
+
|
|
592
|
+
// Blocked members get immediate denial — the guardian already made
|
|
593
|
+
// an explicit decision to block them. This must be checked before
|
|
594
|
+
// invite redemption so a blocked caller cannot bypass the block by
|
|
595
|
+
// redeeming an active invite.
|
|
596
|
+
if (actorTrust.memberRecord?.status === 'blocked') {
|
|
597
|
+
log.info(
|
|
598
|
+
{ callSessionId: this.callSessionId, from: msg.from, trustClass: actorTrust.trustClass },
|
|
599
|
+
'Inbound voice ACL: blocked caller denied',
|
|
600
|
+
);
|
|
522
601
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
602
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
|
|
603
|
+
from: msg.from,
|
|
604
|
+
trustClass: actorTrust.trustClass,
|
|
605
|
+
denialReason: actorTrust.denialReason,
|
|
606
|
+
});
|
|
527
607
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
608
|
+
this.sendTextToken('This number is not authorized to use this assistant.', true);
|
|
609
|
+
|
|
610
|
+
this.connectionState = 'disconnecting';
|
|
611
|
+
|
|
612
|
+
updateCallSession(this.callSessionId, {
|
|
613
|
+
status: 'failed',
|
|
614
|
+
endedAt: Date.now(),
|
|
615
|
+
lastError: 'Inbound voice ACL: caller blocked',
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
setTimeout(() => {
|
|
619
|
+
this.endSession('Inbound voice ACL denied — blocked');
|
|
620
|
+
}, getTtsPlaybackDelayMs());
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (nonExpiredInvites.length > 0) {
|
|
625
|
+
// Use the first matching invite's metadata for personalized prompts
|
|
626
|
+
const matchedInvite = nonExpiredInvites[0];
|
|
627
|
+
log.info(
|
|
628
|
+
{ callSessionId: this.callSessionId, from: msg.from },
|
|
629
|
+
'Inbound voice ACL: unknown caller has active voice invite — entering redemption flow',
|
|
630
|
+
);
|
|
631
|
+
this.startInviteRedemption(assistantId, msg.from, matchedInvite.friendName, matchedInvite.guardianName);
|
|
632
|
+
return;
|
|
536
633
|
}
|
|
537
634
|
|
|
635
|
+
// Unknown/revoked/pending callers enter the name capture + guardian
|
|
636
|
+
// approval wait flow instead of being hard-rejected.
|
|
538
637
|
log.info(
|
|
539
638
|
{ callSessionId: this.callSessionId, from: msg.from, trustClass: actorTrust.trustClass },
|
|
540
|
-
'Inbound voice ACL: unknown caller
|
|
639
|
+
'Inbound voice ACL: unknown caller — entering name capture flow',
|
|
541
640
|
);
|
|
542
641
|
|
|
543
|
-
recordCallEvent(this.callSessionId, '
|
|
642
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_name_capture_started', {
|
|
544
643
|
from: msg.from,
|
|
545
644
|
trustClass: actorTrust.trustClass,
|
|
546
|
-
denialReason: actorTrust.denialReason,
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
// For revoked/pending members, notify the guardian so they can
|
|
550
|
-
// re-approve. Blocked members are intentionally excluded — the
|
|
551
|
-
// guardian already made an explicit decision to block them.
|
|
552
|
-
let guardianNotified = false;
|
|
553
|
-
if (actorTrust.memberRecord?.status !== 'blocked') {
|
|
554
|
-
try {
|
|
555
|
-
const accessResult = notifyGuardianOfAccessRequest({
|
|
556
|
-
canonicalAssistantId: assistantId,
|
|
557
|
-
sourceChannel: 'voice',
|
|
558
|
-
externalChatId: msg.from,
|
|
559
|
-
senderExternalUserId: actorTrust.canonicalSenderId ?? msg.from,
|
|
560
|
-
});
|
|
561
|
-
guardianNotified = accessResult.notified;
|
|
562
|
-
} catch (err) {
|
|
563
|
-
log.error({ err, callSessionId: this.callSessionId }, 'Failed to create access request for denied voice caller');
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Deny with deterministic voice copy and end the call.
|
|
568
|
-
// Mark as disconnecting so handlePrompt ignores caller input
|
|
569
|
-
// during the delay before the session ends.
|
|
570
|
-
const denialMessage = guardianNotified
|
|
571
|
-
? 'This number is not authorized. Your request has been forwarded to the account guardian.'
|
|
572
|
-
: 'This number is not authorized to use this assistant.';
|
|
573
|
-
this.sendTextToken(denialMessage, true);
|
|
574
|
-
|
|
575
|
-
this.connectionState = 'disconnecting';
|
|
576
|
-
|
|
577
|
-
updateCallSession(this.callSessionId, {
|
|
578
|
-
status: 'failed',
|
|
579
|
-
endedAt: Date.now(),
|
|
580
|
-
lastError: 'Inbound voice ACL: caller not authorized',
|
|
581
645
|
});
|
|
582
646
|
|
|
583
|
-
|
|
584
|
-
this.endSession('Inbound voice ACL denied');
|
|
585
|
-
}, 3000);
|
|
647
|
+
this.startNameCapture(assistantId, msg.from);
|
|
586
648
|
return;
|
|
587
649
|
}
|
|
588
650
|
|
|
@@ -614,7 +676,7 @@ export class RelayConnection {
|
|
|
614
676
|
|
|
615
677
|
setTimeout(() => {
|
|
616
678
|
this.endSession('Inbound voice ACL: member policy deny');
|
|
617
|
-
},
|
|
679
|
+
}, getTtsPlaybackDelayMs());
|
|
618
680
|
return;
|
|
619
681
|
}
|
|
620
682
|
|
|
@@ -646,7 +708,7 @@ export class RelayConnection {
|
|
|
646
708
|
|
|
647
709
|
setTimeout(() => {
|
|
648
710
|
this.endSession('Inbound voice ACL: member policy escalate');
|
|
649
|
-
},
|
|
711
|
+
}, getTtsPlaybackDelayMs());
|
|
650
712
|
return;
|
|
651
713
|
}
|
|
652
714
|
|
|
@@ -910,7 +972,7 @@ export class RelayConnection {
|
|
|
910
972
|
|
|
911
973
|
setTimeout(() => {
|
|
912
974
|
this.endSession('Guardian verification succeeded');
|
|
913
|
-
},
|
|
975
|
+
}, getTtsPlaybackDelayMs());
|
|
914
976
|
} else {
|
|
915
977
|
// Inbound: proceed to normal call flow
|
|
916
978
|
if (this.controller) {
|
|
@@ -981,7 +1043,7 @@ export class RelayConnection {
|
|
|
981
1043
|
|
|
982
1044
|
setTimeout(() => {
|
|
983
1045
|
this.endSession('Guardian verification failed');
|
|
984
|
-
},
|
|
1046
|
+
}, getTtsPlaybackDelayMs());
|
|
985
1047
|
} else {
|
|
986
1048
|
const retryText = isOutbound
|
|
987
1049
|
? composeVerificationVoice(GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_RETRY, { codeDigits })
|
|
@@ -1001,13 +1063,15 @@ export class RelayConnection {
|
|
|
1001
1063
|
* who has an active voice invite. Prompts the caller to enter their
|
|
1002
1064
|
* invite code via DTMF or speech.
|
|
1003
1065
|
*/
|
|
1004
|
-
private startInviteRedemption(assistantId: string, fromNumber: string): void {
|
|
1066
|
+
private startInviteRedemption(assistantId: string, fromNumber: string, friendName: string | null, guardianName: string | null): void {
|
|
1005
1067
|
this.inviteRedemptionActive = true;
|
|
1006
1068
|
this.inviteRedemptionAssistantId = assistantId;
|
|
1007
1069
|
this.inviteRedemptionFromNumber = fromNumber;
|
|
1070
|
+
this.inviteRedemptionFriendName = friendName;
|
|
1071
|
+
this.inviteRedemptionGuardianName = guardianName;
|
|
1008
1072
|
this.connectionState = 'verification_pending';
|
|
1009
1073
|
this.verificationAttempts = 0;
|
|
1010
|
-
this.verificationMaxAttempts =
|
|
1074
|
+
this.verificationMaxAttempts = 1;
|
|
1011
1075
|
this.inviteRedemptionCodeLength = 6;
|
|
1012
1076
|
this.dtmfBuffer = '';
|
|
1013
1077
|
|
|
@@ -1017,8 +1081,10 @@ export class RelayConnection {
|
|
|
1017
1081
|
maxAttempts: this.verificationMaxAttempts,
|
|
1018
1082
|
});
|
|
1019
1083
|
|
|
1084
|
+
const displayFriend = friendName ?? 'there';
|
|
1085
|
+
const displayGuardian = guardianName ?? 'your contact';
|
|
1020
1086
|
this.sendTextToken(
|
|
1021
|
-
|
|
1087
|
+
`Welcome ${displayFriend}. Please enter the 6-digit code that ${displayGuardian} provided you to verify your identity.`,
|
|
1022
1088
|
true,
|
|
1023
1089
|
);
|
|
1024
1090
|
|
|
@@ -1028,6 +1094,459 @@ export class RelayConnection {
|
|
|
1028
1094
|
);
|
|
1029
1095
|
}
|
|
1030
1096
|
|
|
1097
|
+
/**
|
|
1098
|
+
* Enter the name capture subflow for unknown inbound callers.
|
|
1099
|
+
* Prompts the caller to provide their name so we can include it
|
|
1100
|
+
* in the guardian notification.
|
|
1101
|
+
*/
|
|
1102
|
+
private startNameCapture(assistantId: string, fromNumber: string): void {
|
|
1103
|
+
this.accessRequestAssistantId = assistantId;
|
|
1104
|
+
this.accessRequestFromNumber = fromNumber;
|
|
1105
|
+
this.connectionState = 'awaiting_name';
|
|
1106
|
+
|
|
1107
|
+
this.sendTextToken(
|
|
1108
|
+
"Sorry, I don't recognize this number. I'll let my guardian know you called and see if I have permission to speak with you. Can I get your name?",
|
|
1109
|
+
true,
|
|
1110
|
+
);
|
|
1111
|
+
|
|
1112
|
+
// Start a timeout so silent callers don't keep the call open indefinitely.
|
|
1113
|
+
// Uses a 30-second window — enough time to speak a name but short enough
|
|
1114
|
+
// to avoid wasting resources on callers who never respond.
|
|
1115
|
+
const NAME_CAPTURE_TIMEOUT_MS = 30_000;
|
|
1116
|
+
this.nameCaptureTimeoutTimer = setTimeout(() => {
|
|
1117
|
+
if (this.connectionState !== 'awaiting_name') return;
|
|
1118
|
+
this.handleNameCaptureTimeout();
|
|
1119
|
+
}, NAME_CAPTURE_TIMEOUT_MS);
|
|
1120
|
+
|
|
1121
|
+
log.info(
|
|
1122
|
+
{ callSessionId: this.callSessionId, assistantId, timeoutMs: NAME_CAPTURE_TIMEOUT_MS },
|
|
1123
|
+
'Name capture started for unknown inbound caller',
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Handle the caller's name response during the name capture subflow.
|
|
1129
|
+
* Creates a canonical access request, notifies the guardian, and
|
|
1130
|
+
* enters the bounded wait loop for the guardian decision.
|
|
1131
|
+
*/
|
|
1132
|
+
private handleNameCaptureResponse(callerName: string): void {
|
|
1133
|
+
if (!this.accessRequestAssistantId || !this.accessRequestFromNumber) {
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Clear the name capture timeout since the caller responded.
|
|
1138
|
+
if (this.nameCaptureTimeoutTimer) {
|
|
1139
|
+
clearTimeout(this.nameCaptureTimeoutTimer);
|
|
1140
|
+
this.nameCaptureTimeoutTimer = null;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
this.accessRequestCallerName = callerName;
|
|
1144
|
+
|
|
1145
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_name_captured', {
|
|
1146
|
+
from: this.accessRequestFromNumber,
|
|
1147
|
+
callerName,
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
// Create canonical access request and notify the guardian, including
|
|
1151
|
+
// the caller's spoken name and voice channel metadata.
|
|
1152
|
+
try {
|
|
1153
|
+
const accessResult = notifyGuardianOfAccessRequest({
|
|
1154
|
+
canonicalAssistantId: this.accessRequestAssistantId,
|
|
1155
|
+
sourceChannel: 'voice',
|
|
1156
|
+
externalChatId: this.accessRequestFromNumber,
|
|
1157
|
+
senderExternalUserId: this.accessRequestFromNumber,
|
|
1158
|
+
senderName: callerName,
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
if (accessResult.notified) {
|
|
1162
|
+
this.accessRequestId = accessResult.requestId;
|
|
1163
|
+
log.info(
|
|
1164
|
+
{ callSessionId: this.callSessionId, requestId: accessResult.requestId, callerName },
|
|
1165
|
+
'Guardian notified of voice access request with caller name',
|
|
1166
|
+
);
|
|
1167
|
+
} else {
|
|
1168
|
+
log.warn(
|
|
1169
|
+
{ callSessionId: this.callSessionId },
|
|
1170
|
+
'Failed to notify guardian of voice access request — no sender ID',
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
} catch (err) {
|
|
1174
|
+
log.error({ err, callSessionId: this.callSessionId }, 'Failed to create access request for voice caller');
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// If the access request was not successfully created (notifyGuardianOfAccessRequest
|
|
1178
|
+
// threw or returned notified: false), fail closed rather than leaving the caller
|
|
1179
|
+
// stuck on hold with no guardian poll target.
|
|
1180
|
+
if (!this.accessRequestId) {
|
|
1181
|
+
log.warn(
|
|
1182
|
+
{ callSessionId: this.callSessionId },
|
|
1183
|
+
'Access request ID is null after notification attempt — failing closed',
|
|
1184
|
+
);
|
|
1185
|
+
this.handleAccessRequestTimeout();
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// Enter the bounded wait loop for the guardian decision
|
|
1190
|
+
this.startAccessRequestWait();
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Start a bounded in-call wait loop polling the canonical request
|
|
1195
|
+
* status until approved, denied, or timeout.
|
|
1196
|
+
*/
|
|
1197
|
+
private startAccessRequestWait(): void {
|
|
1198
|
+
this.accessRequestWaitActive = true;
|
|
1199
|
+
this.connectionState = 'awaiting_guardian_decision';
|
|
1200
|
+
|
|
1201
|
+
const timeoutMs = getUserConsultationTimeoutMs();
|
|
1202
|
+
const pollIntervalMs = getAccessRequestPollIntervalMs();
|
|
1203
|
+
|
|
1204
|
+
const guardianLabel = this.resolveGuardianLabel();
|
|
1205
|
+
this.sendTextToken(
|
|
1206
|
+
`Thank you. I've let ${guardianLabel} know. Please hold while I check if I have permission to speak with you.`,
|
|
1207
|
+
true,
|
|
1208
|
+
);
|
|
1209
|
+
|
|
1210
|
+
updateCallSession(this.callSessionId, { status: 'waiting_on_user' });
|
|
1211
|
+
|
|
1212
|
+
// Start the heartbeat timer for periodic progress updates
|
|
1213
|
+
this.accessRequestWaitStartedAt = Date.now();
|
|
1214
|
+
this.heartbeatSequence = 0;
|
|
1215
|
+
this.scheduleNextHeartbeat();
|
|
1216
|
+
|
|
1217
|
+
// Poll the canonical request status
|
|
1218
|
+
this.accessRequestPollTimer = setInterval(() => {
|
|
1219
|
+
if (!this.accessRequestWaitActive || !this.accessRequestId) {
|
|
1220
|
+
this.clearAccessRequestWait();
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const request = getCanonicalGuardianRequest(this.accessRequestId);
|
|
1225
|
+
if (!request) {
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (request.status === 'approved') {
|
|
1230
|
+
this.handleAccessRequestApproved();
|
|
1231
|
+
} else if (request.status === 'denied') {
|
|
1232
|
+
this.handleAccessRequestDenied();
|
|
1233
|
+
}
|
|
1234
|
+
// 'pending' continues polling; 'expired'/'cancelled' handled by timeout
|
|
1235
|
+
}, pollIntervalMs);
|
|
1236
|
+
|
|
1237
|
+
// Timeout: give up waiting for the guardian
|
|
1238
|
+
this.accessRequestTimeoutTimer = setTimeout(() => {
|
|
1239
|
+
if (!this.accessRequestWaitActive) return;
|
|
1240
|
+
|
|
1241
|
+
log.info(
|
|
1242
|
+
{ callSessionId: this.callSessionId, requestId: this.accessRequestId },
|
|
1243
|
+
'Access request in-call wait timed out',
|
|
1244
|
+
);
|
|
1245
|
+
|
|
1246
|
+
this.handleAccessRequestTimeout();
|
|
1247
|
+
}, timeoutMs);
|
|
1248
|
+
|
|
1249
|
+
log.info(
|
|
1250
|
+
{ callSessionId: this.callSessionId, requestId: this.accessRequestId, timeoutMs },
|
|
1251
|
+
'Access request in-call wait started',
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Clean up access request wait state (timers, flags).
|
|
1257
|
+
*/
|
|
1258
|
+
private clearAccessRequestWait(): void {
|
|
1259
|
+
this.accessRequestWaitActive = false;
|
|
1260
|
+
if (this.accessRequestPollTimer) {
|
|
1261
|
+
clearInterval(this.accessRequestPollTimer);
|
|
1262
|
+
this.accessRequestPollTimer = null;
|
|
1263
|
+
}
|
|
1264
|
+
if (this.accessRequestTimeoutTimer) {
|
|
1265
|
+
clearTimeout(this.accessRequestTimeoutTimer);
|
|
1266
|
+
this.accessRequestTimeoutTimer = null;
|
|
1267
|
+
}
|
|
1268
|
+
if (this.accessRequestHeartbeatTimer) {
|
|
1269
|
+
clearTimeout(this.accessRequestHeartbeatTimer);
|
|
1270
|
+
this.accessRequestHeartbeatTimer = null;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Handle an approved access request: activate the caller as a trusted
|
|
1276
|
+
* contact, update runtime context, and continue with normal call flow.
|
|
1277
|
+
*/
|
|
1278
|
+
private handleAccessRequestApproved(): void {
|
|
1279
|
+
this.clearAccessRequestWait();
|
|
1280
|
+
this.connectionState = 'connected';
|
|
1281
|
+
|
|
1282
|
+
const assistantId = this.accessRequestAssistantId!;
|
|
1283
|
+
const fromNumber = this.accessRequestFromNumber!;
|
|
1284
|
+
const callerName = this.accessRequestCallerName;
|
|
1285
|
+
|
|
1286
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_access_approved', {
|
|
1287
|
+
from: fromNumber,
|
|
1288
|
+
callerName,
|
|
1289
|
+
requestId: this.accessRequestId,
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
// Activate the caller as a trusted contact via the existing upsert path
|
|
1293
|
+
try {
|
|
1294
|
+
upsertMember({
|
|
1295
|
+
assistantId,
|
|
1296
|
+
sourceChannel: 'voice',
|
|
1297
|
+
externalUserId: fromNumber,
|
|
1298
|
+
externalChatId: fromNumber,
|
|
1299
|
+
displayName: callerName ?? undefined,
|
|
1300
|
+
status: 'active',
|
|
1301
|
+
policy: 'allow',
|
|
1302
|
+
});
|
|
1303
|
+
} catch (err) {
|
|
1304
|
+
log.error({ err, callSessionId: this.callSessionId }, 'Failed to activate voice caller as trusted contact');
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// Re-resolve actor trust now that the member is active
|
|
1308
|
+
const updatedTrust = resolveActorTrust({
|
|
1309
|
+
assistantId,
|
|
1310
|
+
sourceChannel: 'voice',
|
|
1311
|
+
externalChatId: fromNumber,
|
|
1312
|
+
senderExternalUserId: fromNumber,
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
if (this.controller) {
|
|
1316
|
+
this.controller.setGuardianContext(
|
|
1317
|
+
toGuardianRuntimeContextFromTrust(updatedTrust, fromNumber),
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
updateCallSession(this.callSessionId, { status: 'in_progress' });
|
|
1322
|
+
|
|
1323
|
+
log.info(
|
|
1324
|
+
{ callSessionId: this.callSessionId, from: fromNumber },
|
|
1325
|
+
'Access request approved — caller activated and continuing call',
|
|
1326
|
+
);
|
|
1327
|
+
|
|
1328
|
+
// Use handleUserInstruction to deliver the approval-aware greeting
|
|
1329
|
+
// through the normal session pipeline.
|
|
1330
|
+
const guardianLabel = this.resolveGuardianLabel();
|
|
1331
|
+
if (this.controller) {
|
|
1332
|
+
this.controller.handleUserInstruction(
|
|
1333
|
+
`Great, ${guardianLabel} approved! Now how can I help you?`,
|
|
1334
|
+
).catch((err) => {
|
|
1335
|
+
log.error({ err, callSessionId: this.callSessionId }, 'Failed to deliver approval greeting');
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Handle a denied access request: deliver deterministic copy and hang up.
|
|
1342
|
+
*/
|
|
1343
|
+
private handleAccessRequestDenied(): void {
|
|
1344
|
+
this.clearAccessRequestWait();
|
|
1345
|
+
|
|
1346
|
+
const guardianLabel = this.resolveGuardianLabel();
|
|
1347
|
+
|
|
1348
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_access_denied', {
|
|
1349
|
+
from: this.accessRequestFromNumber,
|
|
1350
|
+
requestId: this.accessRequestId,
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
this.sendTextToken(
|
|
1354
|
+
`Sorry, ${guardianLabel} says I'm not allowed to speak with you. Goodbye.`,
|
|
1355
|
+
true,
|
|
1356
|
+
);
|
|
1357
|
+
|
|
1358
|
+
this.connectionState = 'disconnecting';
|
|
1359
|
+
|
|
1360
|
+
updateCallSession(this.callSessionId, {
|
|
1361
|
+
status: 'failed',
|
|
1362
|
+
endedAt: Date.now(),
|
|
1363
|
+
lastError: 'Inbound voice ACL: guardian denied access request',
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
log.info(
|
|
1367
|
+
{ callSessionId: this.callSessionId },
|
|
1368
|
+
'Access request denied — ending call',
|
|
1369
|
+
);
|
|
1370
|
+
|
|
1371
|
+
setTimeout(() => {
|
|
1372
|
+
this.endSession('Access request denied');
|
|
1373
|
+
}, getTtsPlaybackDelayMs());
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Handle an access request timeout: deliver deterministic copy and hang up.
|
|
1378
|
+
*/
|
|
1379
|
+
private handleAccessRequestTimeout(): void {
|
|
1380
|
+
// Emit callback handoff notification before clearing wait state
|
|
1381
|
+
this.emitAccessRequestCallbackHandoff('timeout');
|
|
1382
|
+
|
|
1383
|
+
this.clearAccessRequestWait();
|
|
1384
|
+
|
|
1385
|
+
const guardianLabel = this.resolveGuardianLabel();
|
|
1386
|
+
|
|
1387
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_access_timeout', {
|
|
1388
|
+
from: this.accessRequestFromNumber,
|
|
1389
|
+
requestId: this.accessRequestId,
|
|
1390
|
+
callbackOptIn: this.callbackOptIn,
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
const callbackNote = this.callbackOptIn
|
|
1394
|
+
? ` I've noted that you'd like a callback — I'll pass that along to ${guardianLabel}.`
|
|
1395
|
+
: '';
|
|
1396
|
+
this.sendTextToken(
|
|
1397
|
+
`Sorry, I can't get ahold of ${guardianLabel} right now. I'll let them know you called.${callbackNote}`,
|
|
1398
|
+
true,
|
|
1399
|
+
);
|
|
1400
|
+
|
|
1401
|
+
this.connectionState = 'disconnecting';
|
|
1402
|
+
|
|
1403
|
+
updateCallSession(this.callSessionId, {
|
|
1404
|
+
status: 'failed',
|
|
1405
|
+
endedAt: Date.now(),
|
|
1406
|
+
lastError: 'Inbound voice ACL: guardian approval wait timed out',
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
log.info(
|
|
1410
|
+
{ callSessionId: this.callSessionId },
|
|
1411
|
+
'Access request timed out — ending call',
|
|
1412
|
+
);
|
|
1413
|
+
|
|
1414
|
+
setTimeout(() => {
|
|
1415
|
+
this.endSession('Access request timed out');
|
|
1416
|
+
}, getTtsPlaybackDelayMs());
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* Emit a callback handoff notification to the guardian when the caller
|
|
1421
|
+
* opted into a callback during guardian wait but the wait ended without
|
|
1422
|
+
* resolution (timeout or transport close).
|
|
1423
|
+
*
|
|
1424
|
+
* Idempotent: uses callbackHandoffNotified guard + deterministic dedupeKey
|
|
1425
|
+
* to ensure at most one notification per call/request.
|
|
1426
|
+
*/
|
|
1427
|
+
private emitAccessRequestCallbackHandoff(reason: 'timeout' | 'transport_closed'): void {
|
|
1428
|
+
if (!this.callbackOptIn) return;
|
|
1429
|
+
if (!this.accessRequestId) return;
|
|
1430
|
+
if (this.callbackHandoffNotified) return;
|
|
1431
|
+
|
|
1432
|
+
this.callbackHandoffNotified = true;
|
|
1433
|
+
|
|
1434
|
+
const assistantId = this.accessRequestAssistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
|
|
1435
|
+
const fromNumber = this.accessRequestFromNumber ?? null;
|
|
1436
|
+
|
|
1437
|
+
// Resolve canonical request for requestCode and conversationId
|
|
1438
|
+
const canonicalRequest = this.accessRequestId
|
|
1439
|
+
? getCanonicalGuardianRequest(this.accessRequestId)
|
|
1440
|
+
: null;
|
|
1441
|
+
|
|
1442
|
+
// Resolve trusted-contact member reference when possible
|
|
1443
|
+
let requesterMemberId: string | null = null;
|
|
1444
|
+
if (fromNumber) {
|
|
1445
|
+
try {
|
|
1446
|
+
const member = findMember({
|
|
1447
|
+
assistantId,
|
|
1448
|
+
sourceChannel: 'voice',
|
|
1449
|
+
externalUserId: fromNumber,
|
|
1450
|
+
externalChatId: fromNumber,
|
|
1451
|
+
});
|
|
1452
|
+
if (member && member.status === 'active' && member.policy === 'allow') {
|
|
1453
|
+
requesterMemberId = member.id;
|
|
1454
|
+
}
|
|
1455
|
+
} catch (err) {
|
|
1456
|
+
log.warn({ err, callSessionId: this.callSessionId }, 'Failed to resolve member for callback handoff');
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const dedupeKey = `access-request-callback-handoff:${this.accessRequestId}`;
|
|
1461
|
+
const sourceSessionId = canonicalRequest?.conversationId
|
|
1462
|
+
?? `access-req-callback-${this.accessRequestId}`;
|
|
1463
|
+
|
|
1464
|
+
void emitNotificationSignal({
|
|
1465
|
+
sourceEventName: 'ingress.access_request.callback_handoff',
|
|
1466
|
+
sourceChannel: 'voice',
|
|
1467
|
+
sourceSessionId,
|
|
1468
|
+
assistantId,
|
|
1469
|
+
attentionHints: {
|
|
1470
|
+
requiresAction: false,
|
|
1471
|
+
urgency: 'medium',
|
|
1472
|
+
isAsyncBackground: true,
|
|
1473
|
+
visibleInSourceNow: false,
|
|
1474
|
+
},
|
|
1475
|
+
contextPayload: {
|
|
1476
|
+
requestId: this.accessRequestId,
|
|
1477
|
+
requestCode: canonicalRequest?.requestCode ?? null,
|
|
1478
|
+
callSessionId: this.callSessionId,
|
|
1479
|
+
sourceChannel: 'voice',
|
|
1480
|
+
reason,
|
|
1481
|
+
callbackOptIn: true,
|
|
1482
|
+
callerPhoneNumber: fromNumber,
|
|
1483
|
+
callerName: this.accessRequestCallerName ?? null,
|
|
1484
|
+
requesterExternalUserId: fromNumber,
|
|
1485
|
+
requesterChatId: fromNumber,
|
|
1486
|
+
requesterMemberId,
|
|
1487
|
+
requesterMemberSourceChannel: requesterMemberId ? 'voice' : null,
|
|
1488
|
+
},
|
|
1489
|
+
dedupeKey,
|
|
1490
|
+
}).then(() => {
|
|
1491
|
+
recordCallEvent(this.callSessionId, 'callback_handoff_notified', {
|
|
1492
|
+
requestId: this.accessRequestId,
|
|
1493
|
+
reason,
|
|
1494
|
+
requesterMemberId,
|
|
1495
|
+
});
|
|
1496
|
+
log.info(
|
|
1497
|
+
{ callSessionId: this.callSessionId, requestId: this.accessRequestId, reason },
|
|
1498
|
+
'Callback handoff notification emitted',
|
|
1499
|
+
);
|
|
1500
|
+
}).catch((err) => {
|
|
1501
|
+
recordCallEvent(this.callSessionId, 'callback_handoff_failed', {
|
|
1502
|
+
requestId: this.accessRequestId,
|
|
1503
|
+
reason,
|
|
1504
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1505
|
+
});
|
|
1506
|
+
log.error(
|
|
1507
|
+
{ err, callSessionId: this.callSessionId, requestId: this.accessRequestId },
|
|
1508
|
+
'Failed to emit callback handoff notification',
|
|
1509
|
+
);
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Handle a name capture timeout: the caller never provided their name
|
|
1515
|
+
* within the allotted window. Deliver deterministic copy and hang up.
|
|
1516
|
+
*/
|
|
1517
|
+
private handleNameCaptureTimeout(): void {
|
|
1518
|
+
if (this.nameCaptureTimeoutTimer) {
|
|
1519
|
+
clearTimeout(this.nameCaptureTimeoutTimer);
|
|
1520
|
+
this.nameCaptureTimeoutTimer = null;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_name_capture_timeout', {
|
|
1524
|
+
from: this.accessRequestFromNumber,
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
this.sendTextToken(
|
|
1528
|
+
"Sorry, I didn't catch your name. Please try calling back. Goodbye.",
|
|
1529
|
+
true,
|
|
1530
|
+
);
|
|
1531
|
+
|
|
1532
|
+
this.connectionState = 'disconnecting';
|
|
1533
|
+
|
|
1534
|
+
updateCallSession(this.callSessionId, {
|
|
1535
|
+
status: 'failed',
|
|
1536
|
+
endedAt: Date.now(),
|
|
1537
|
+
lastError: 'Inbound voice ACL: name capture timed out',
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
log.info(
|
|
1541
|
+
{ callSessionId: this.callSessionId },
|
|
1542
|
+
'Name capture timed out — ending call',
|
|
1543
|
+
);
|
|
1544
|
+
|
|
1545
|
+
setTimeout(() => {
|
|
1546
|
+
this.endSession('Name capture timed out');
|
|
1547
|
+
}, getTtsPlaybackDelayMs());
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1031
1550
|
/**
|
|
1032
1551
|
* Validate an entered invite code against active voice invites for the
|
|
1033
1552
|
* caller. On success, create/activate the ingress member and transition
|
|
@@ -1073,45 +1592,306 @@ export class RelayConnection {
|
|
|
1073
1592
|
this.startNormalCallFlow(this.controller, true);
|
|
1074
1593
|
}
|
|
1075
1594
|
} else {
|
|
1076
|
-
|
|
1595
|
+
// On any invalid/expired code, emit exact deterministic failure copy and end call immediately.
|
|
1596
|
+
this.inviteRedemptionActive = false;
|
|
1077
1597
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1598
|
+
recordCallEvent(this.callSessionId, 'invite_redemption_failed', {
|
|
1599
|
+
attempts: 1,
|
|
1600
|
+
});
|
|
1601
|
+
log.warn(
|
|
1602
|
+
{ callSessionId: this.callSessionId },
|
|
1603
|
+
'Voice invite redemption failed — invalid or expired code',
|
|
1604
|
+
);
|
|
1080
1605
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
'Voice invite redemption failed — max attempts reached',
|
|
1087
|
-
);
|
|
1606
|
+
const displayGuardian = this.inviteRedemptionGuardianName ?? 'your contact';
|
|
1607
|
+
this.sendTextToken(
|
|
1608
|
+
`Sorry, the code you provided is incorrect or has since expired. Please ask ${displayGuardian} for a new code. Goodbye.`,
|
|
1609
|
+
true,
|
|
1610
|
+
);
|
|
1088
1611
|
|
|
1089
|
-
|
|
1612
|
+
this.connectionState = 'disconnecting';
|
|
1090
1613
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1614
|
+
updateCallSession(this.callSessionId, {
|
|
1615
|
+
status: 'failed',
|
|
1616
|
+
endedAt: Date.now(),
|
|
1617
|
+
lastError: 'Voice invite redemption failed — invalid or expired code',
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
const failSession = getCallSession(this.callSessionId);
|
|
1621
|
+
if (failSession) {
|
|
1622
|
+
expirePendingQuestions(this.callSessionId);
|
|
1623
|
+
persistCallCompletionMessage(failSession.conversationId, this.callSessionId).catch((err) => {
|
|
1624
|
+
log.error({ err, conversationId: failSession.conversationId, callSessionId: this.callSessionId }, 'Failed to persist call completion message');
|
|
1095
1625
|
});
|
|
1626
|
+
fireCallCompletionNotifier(failSession.conversationId, this.callSessionId);
|
|
1627
|
+
}
|
|
1096
1628
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1629
|
+
setTimeout(() => {
|
|
1630
|
+
this.endSession('Invite redemption failed');
|
|
1631
|
+
}, getTtsPlaybackDelayMs());
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// ── Guardian wait UX layer ─────────────────────────────────────
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* Resolve a human-readable guardian label for voice wait copy.
|
|
1639
|
+
* Prefers displayName from the guardian binding metadata, falls back
|
|
1640
|
+
* to @username, then "my guardian".
|
|
1641
|
+
*/
|
|
1642
|
+
private resolveGuardianLabel(): string {
|
|
1643
|
+
const assistantId = this.accessRequestAssistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
|
|
1644
|
+
|
|
1645
|
+
// Try the voice-channel binding first, then fall back to any active
|
|
1646
|
+
// binding for the assistant (mirrors the cross-channel fallback pattern
|
|
1647
|
+
// in access-request-helper.ts).
|
|
1648
|
+
let metadataJson: string | null = null;
|
|
1649
|
+
const voiceBinding = getGuardianBinding(assistantId, 'voice');
|
|
1650
|
+
if (voiceBinding?.metadataJson) {
|
|
1651
|
+
metadataJson = voiceBinding.metadataJson;
|
|
1652
|
+
} else {
|
|
1653
|
+
const allBindings = listActiveBindingsByAssistant(assistantId);
|
|
1654
|
+
if (allBindings.length > 0 && allBindings[0].metadataJson) {
|
|
1655
|
+
metadataJson = allBindings[0].metadataJson;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
if (metadataJson) {
|
|
1660
|
+
try {
|
|
1661
|
+
const parsed = JSON.parse(metadataJson) as Record<string, unknown>;
|
|
1662
|
+
if (typeof parsed.displayName === 'string' && parsed.displayName.trim().length > 0) {
|
|
1663
|
+
return parsed.displayName.trim();
|
|
1664
|
+
}
|
|
1665
|
+
if (typeof parsed.username === 'string' && parsed.username.trim().length > 0) {
|
|
1666
|
+
return `@${parsed.username.trim()}`;
|
|
1667
|
+
}
|
|
1668
|
+
} catch {
|
|
1669
|
+
// ignore malformed metadata
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
return 'my guardian';
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* Generate a non-repetitive heartbeat message for the caller based
|
|
1677
|
+
* on the current sequence counter and guardian label.
|
|
1678
|
+
*/
|
|
1679
|
+
private getHeartbeatMessage(): string {
|
|
1680
|
+
const guardianLabel = this.resolveGuardianLabel();
|
|
1681
|
+
const seq = this.heartbeatSequence++;
|
|
1682
|
+
const messages = [
|
|
1683
|
+
`Still waiting to hear back from ${guardianLabel}. Thank you for your patience.`,
|
|
1684
|
+
`I'm still trying to reach ${guardianLabel}. One moment please.`,
|
|
1685
|
+
`Hang tight, still waiting on ${guardianLabel}.`,
|
|
1686
|
+
`Still checking with ${guardianLabel}. I appreciate you waiting.`,
|
|
1687
|
+
`I haven't heard back from ${guardianLabel} yet. Thanks for holding.`,
|
|
1688
|
+
];
|
|
1689
|
+
return messages[seq % messages.length];
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* Schedule the next heartbeat update. Uses the initial fixed interval
|
|
1694
|
+
* during the initial window, then jitters between steady min/max.
|
|
1695
|
+
*/
|
|
1696
|
+
private scheduleNextHeartbeat(): void {
|
|
1697
|
+
if (!this.accessRequestWaitActive) return;
|
|
1698
|
+
|
|
1699
|
+
const elapsed = Date.now() - this.accessRequestWaitStartedAt;
|
|
1700
|
+
const initialWindow = getGuardianWaitUpdateInitialWindowMs();
|
|
1701
|
+
const intervalMs = elapsed < initialWindow
|
|
1702
|
+
? getGuardianWaitUpdateInitialIntervalMs()
|
|
1703
|
+
: getGuardianWaitUpdateSteadyMinIntervalMs() +
|
|
1704
|
+
Math.floor(Math.random() * Math.max(0, getGuardianWaitUpdateSteadyMaxIntervalMs() - getGuardianWaitUpdateSteadyMinIntervalMs()));
|
|
1705
|
+
|
|
1706
|
+
this.accessRequestHeartbeatTimer = setTimeout(() => {
|
|
1707
|
+
if (!this.accessRequestWaitActive) return;
|
|
1708
|
+
|
|
1709
|
+
const message = this.getHeartbeatMessage();
|
|
1710
|
+
this.sendTextToken(message, true);
|
|
1711
|
+
|
|
1712
|
+
recordCallEvent(this.callSessionId, 'voice_guardian_wait_heartbeat_sent', {
|
|
1713
|
+
sequence: this.heartbeatSequence - 1,
|
|
1714
|
+
message,
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
log.debug(
|
|
1718
|
+
{ callSessionId: this.callSessionId, sequence: this.heartbeatSequence - 1 },
|
|
1719
|
+
'Guardian wait heartbeat sent',
|
|
1720
|
+
);
|
|
1721
|
+
|
|
1722
|
+
// Schedule the next heartbeat
|
|
1723
|
+
this.scheduleNextHeartbeat();
|
|
1724
|
+
}, intervalMs);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* Classify a caller utterance during guardian wait into one of:
|
|
1729
|
+
* - 'empty': whitespace or noise
|
|
1730
|
+
* - 'patience_check': asking for status or checking in
|
|
1731
|
+
* - 'impatient': expressing frustration or wanting to end
|
|
1732
|
+
* - 'callback_opt_in': explicitly agreeing to a callback
|
|
1733
|
+
* - 'callback_decline': explicitly declining a callback
|
|
1734
|
+
* - 'neutral': anything else
|
|
1735
|
+
*/
|
|
1736
|
+
private classifyWaitUtterance(text: string): 'empty' | 'patience_check' | 'impatient' | 'callback_opt_in' | 'callback_decline' | 'neutral' {
|
|
1737
|
+
const lower = text.toLowerCase().trim();
|
|
1738
|
+
if (lower.length === 0) return 'empty';
|
|
1739
|
+
|
|
1740
|
+
// Callback opt-in patterns (check before impatience to catch "yes call me back")
|
|
1741
|
+
if (this.callbackOfferMade) {
|
|
1742
|
+
if (/\b(yes|yeah|yep|sure|okay|ok|please)\b.*\b(call\s*(me\s*)?back|callback)\b/.test(lower)
|
|
1743
|
+
|| /\b(call\s*(me\s*)?back|callback)\b.*\b(yes|yeah|please|sure)\b/.test(lower)
|
|
1744
|
+
|| /^(yes|yeah|yep|sure|okay|ok|please)\s*[.,!]?\s*$/.test(lower)
|
|
1745
|
+
|| /\bcall\s*(me\s*)?back\b/.test(lower)
|
|
1746
|
+
|| /\bplease\s+do\b/.test(lower)) {
|
|
1747
|
+
return 'callback_opt_in';
|
|
1748
|
+
}
|
|
1749
|
+
if (/\b(no|nah|nope)\b/.test(lower)
|
|
1750
|
+
|| /\bi('?ll| will)\s+hold\b/.test(lower)
|
|
1751
|
+
|| /\bi('?ll| will)\s+wait\b/.test(lower)) {
|
|
1752
|
+
return 'callback_decline';
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// Impatience patterns
|
|
1757
|
+
if (/\bhurry\s*(up)?\b/.test(lower)
|
|
1758
|
+
|| /\btaking\s+(too\s+|so\s+)?long\b/.test(lower)
|
|
1759
|
+
|| /\bforget\s+it\b/.test(lower)
|
|
1760
|
+
|| /\bnever\s*mind\b/.test(lower)
|
|
1761
|
+
|| /\bdon'?t\s+have\s+time\b/.test(lower)
|
|
1762
|
+
|| /\bhow\s+much\s+longer\b/.test(lower)
|
|
1763
|
+
|| /\bi('?m| am)\s+(getting\s+)?impatient\b/.test(lower)
|
|
1764
|
+
|| /\bthis\s+is\s+(ridiculous|absurd|crazy)\b/.test(lower)
|
|
1765
|
+
|| /\bcome\s+on\b/.test(lower)
|
|
1766
|
+
|| /\bi\s+(gotta|have\s+to|need\s+to)\s+go\b/.test(lower)) {
|
|
1767
|
+
return 'impatient';
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// Patience check / status inquiry patterns
|
|
1771
|
+
if (/\bhello\??\s*$/.test(lower)
|
|
1772
|
+
|| /\bstill\s+there\b/.test(lower)
|
|
1773
|
+
|| /\bany\s+(update|news)\b/.test(lower)
|
|
1774
|
+
|| /\bwhat('?s| is)\s+(happening|going\s+on)\b/.test(lower)
|
|
1775
|
+
|| /\bare\s+you\s+still\b/.test(lower)
|
|
1776
|
+
|| /\bhow\s+(long|much\s+longer)\b/.test(lower)
|
|
1777
|
+
|| /\banyone\s+there\b/.test(lower)) {
|
|
1778
|
+
return 'patience_check';
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
return 'neutral';
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* Handle a caller utterance during the guardian decision wait state.
|
|
1786
|
+
* Provides reassurance, impatience detection, and callback offer.
|
|
1787
|
+
*/
|
|
1788
|
+
private handleWaitStatePrompt(text: string): void {
|
|
1789
|
+
const now = Date.now();
|
|
1790
|
+
const classification = this.classifyWaitUtterance(text);
|
|
1791
|
+
|
|
1792
|
+
recordCallEvent(this.callSessionId, 'voice_guardian_wait_prompt_classified', {
|
|
1793
|
+
classification,
|
|
1794
|
+
transcript: text,
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1797
|
+
if (classification === 'empty') return;
|
|
1798
|
+
|
|
1799
|
+
const guardianLabel = this.resolveGuardianLabel();
|
|
1800
|
+
|
|
1801
|
+
// Callback decisions must always be processed regardless of cooldown —
|
|
1802
|
+
// the caller is answering a direct question and dropping their response
|
|
1803
|
+
// would silently discard their decision.
|
|
1804
|
+
switch (classification) {
|
|
1805
|
+
case 'callback_opt_in': {
|
|
1806
|
+
this.callbackOptIn = true;
|
|
1807
|
+
this.lastInWaitReplyAt = now;
|
|
1808
|
+
recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_opt_in_set', {});
|
|
1809
|
+
if (this.accessRequestHeartbeatTimer) {
|
|
1810
|
+
clearTimeout(this.accessRequestHeartbeatTimer);
|
|
1811
|
+
this.accessRequestHeartbeatTimer = null;
|
|
1104
1812
|
}
|
|
1813
|
+
this.sendTextToken(
|
|
1814
|
+
`Noted, I'll make sure ${guardianLabel} knows you'd like a callback. For now, I'll keep trying to reach them.`,
|
|
1815
|
+
true,
|
|
1816
|
+
);
|
|
1817
|
+
this.scheduleNextHeartbeat();
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
case 'callback_decline': {
|
|
1821
|
+
this.callbackOptIn = false;
|
|
1822
|
+
this.lastInWaitReplyAt = now;
|
|
1823
|
+
recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_opt_in_declined', {});
|
|
1824
|
+
if (this.accessRequestHeartbeatTimer) {
|
|
1825
|
+
clearTimeout(this.accessRequestHeartbeatTimer);
|
|
1826
|
+
this.accessRequestHeartbeatTimer = null;
|
|
1827
|
+
}
|
|
1828
|
+
this.sendTextToken(
|
|
1829
|
+
`No problem, I'll keep holding. Still waiting on ${guardianLabel}.`,
|
|
1830
|
+
true,
|
|
1831
|
+
);
|
|
1832
|
+
this.scheduleNextHeartbeat();
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
default:
|
|
1836
|
+
break;
|
|
1837
|
+
}
|
|
1105
1838
|
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1839
|
+
// Enforce cooldown on non-callback utterances to prevent spam
|
|
1840
|
+
if (now - this.lastInWaitReplyAt < RelayConnection.IN_WAIT_REPLY_COOLDOWN_MS) {
|
|
1841
|
+
log.debug({ callSessionId: this.callSessionId }, 'In-wait reply suppressed by cooldown');
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
this.lastInWaitReplyAt = now;
|
|
1845
|
+
|
|
1846
|
+
switch (classification) {
|
|
1847
|
+
case 'impatient': {
|
|
1848
|
+
if (this.accessRequestHeartbeatTimer) {
|
|
1849
|
+
clearTimeout(this.accessRequestHeartbeatTimer);
|
|
1850
|
+
this.accessRequestHeartbeatTimer = null;
|
|
1851
|
+
}
|
|
1852
|
+
if (!this.callbackOfferMade) {
|
|
1853
|
+
this.callbackOfferMade = true;
|
|
1854
|
+
recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_offer_sent', {});
|
|
1855
|
+
this.sendTextToken(
|
|
1856
|
+
`I understand this is taking a while. I can have ${guardianLabel} call you back once I hear from them. Would you like that, or would you prefer to keep holding?`,
|
|
1857
|
+
true,
|
|
1858
|
+
);
|
|
1859
|
+
} else {
|
|
1860
|
+
// Already offered callback — just reassure
|
|
1861
|
+
this.sendTextToken(
|
|
1862
|
+
`I hear you, I'm sorry for the wait. Still trying to reach ${guardianLabel}.`,
|
|
1863
|
+
true,
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
this.scheduleNextHeartbeat();
|
|
1867
|
+
break;
|
|
1868
|
+
}
|
|
1869
|
+
case 'patience_check': {
|
|
1870
|
+
// Immediate reassurance — reset the heartbeat timer so we
|
|
1871
|
+
// don't double up with a scheduled heartbeat
|
|
1872
|
+
if (this.accessRequestHeartbeatTimer) {
|
|
1873
|
+
clearTimeout(this.accessRequestHeartbeatTimer);
|
|
1874
|
+
this.accessRequestHeartbeatTimer = null;
|
|
1875
|
+
}
|
|
1876
|
+
this.sendTextToken(
|
|
1877
|
+
`Yes, I'm still here. Still waiting to hear back from ${guardianLabel}.`,
|
|
1878
|
+
true,
|
|
1113
1879
|
);
|
|
1114
|
-
this.
|
|
1880
|
+
this.scheduleNextHeartbeat();
|
|
1881
|
+
break;
|
|
1882
|
+
}
|
|
1883
|
+
case 'neutral':
|
|
1884
|
+
default: {
|
|
1885
|
+
if (this.accessRequestHeartbeatTimer) {
|
|
1886
|
+
clearTimeout(this.accessRequestHeartbeatTimer);
|
|
1887
|
+
this.accessRequestHeartbeatTimer = null;
|
|
1888
|
+
}
|
|
1889
|
+
this.sendTextToken(
|
|
1890
|
+
`Thanks for that. I'm still waiting on ${guardianLabel}. I'll let you know as soon as I hear back.`,
|
|
1891
|
+
true,
|
|
1892
|
+
);
|
|
1893
|
+
this.scheduleNextHeartbeat();
|
|
1894
|
+
break;
|
|
1115
1895
|
}
|
|
1116
1896
|
}
|
|
1117
1897
|
}
|
|
@@ -1126,6 +1906,30 @@ export class RelayConnection {
|
|
|
1126
1906
|
return;
|
|
1127
1907
|
}
|
|
1128
1908
|
|
|
1909
|
+
// During name capture, the caller's response is their name.
|
|
1910
|
+
if (this.connectionState === 'awaiting_name') {
|
|
1911
|
+
const callerName = msg.voicePrompt.trim();
|
|
1912
|
+
if (!callerName) {
|
|
1913
|
+
// Whitespace-only or empty transcript (e.g. silence/noise) —
|
|
1914
|
+
// keep waiting for a real name. The name-capture timeout will
|
|
1915
|
+
// still fire if the caller never provides one.
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
log.info(
|
|
1919
|
+
{ callSessionId: this.callSessionId, callerName },
|
|
1920
|
+
'Name captured from unknown inbound caller',
|
|
1921
|
+
);
|
|
1922
|
+
this.handleNameCaptureResponse(callerName);
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// During guardian decision wait, classify caller speech for
|
|
1927
|
+
// reassurance, impatience detection, and callback offer.
|
|
1928
|
+
if (this.connectionState === 'awaiting_guardian_decision') {
|
|
1929
|
+
this.handleWaitStatePrompt(msg.voicePrompt);
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1129
1933
|
// During guardian verification (inbound or outbound), attempt to parse
|
|
1130
1934
|
// spoken digits from the transcript and validate them.
|
|
1131
1935
|
if (this.connectionState === 'verification_pending' && this.guardianVerificationActive) {
|
|
@@ -1256,6 +2060,11 @@ export class RelayConnection {
|
|
|
1256
2060
|
return;
|
|
1257
2061
|
}
|
|
1258
2062
|
|
|
2063
|
+
// Ignore DTMF during name capture and guardian decision wait
|
|
2064
|
+
if (this.connectionState === 'awaiting_name' || this.connectionState === 'awaiting_guardian_decision') {
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
1259
2068
|
log.info(
|
|
1260
2069
|
{ callSessionId: this.callSessionId, digit: msg.digit },
|
|
1261
2070
|
'DTMF digit received',
|
|
@@ -1354,7 +2163,7 @@ export class RelayConnection {
|
|
|
1354
2163
|
// End the call with failed status after TTS plays
|
|
1355
2164
|
setTimeout(() => {
|
|
1356
2165
|
this.endSession('Verification failed');
|
|
1357
|
-
},
|
|
2166
|
+
}, getTtsPlaybackDelayMs());
|
|
1358
2167
|
} else {
|
|
1359
2168
|
// Allow another attempt
|
|
1360
2169
|
log.info(
|