@vellumai/assistant 0.4.31 → 0.4.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +1 -1
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -7
- package/src/__tests__/anthropic-provider.test.ts +86 -1
- package/src/__tests__/assistant-feature-flags-integration.test.ts +2 -2
- package/src/__tests__/checker.test.ts +37 -98
- package/src/__tests__/commit-message-enrichment-service.test.ts +15 -0
- package/src/__tests__/config-schema.test.ts +6 -5
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +1 -19
- package/src/__tests__/followup-tools.test.ts +0 -30
- package/src/__tests__/gemini-provider.test.ts +79 -1
- package/src/__tests__/ipc-snapshot.test.ts +0 -4
- package/src/__tests__/managed-proxy-context.test.ts +163 -0
- package/src/__tests__/memory-lifecycle-e2e.test.ts +2 -2
- package/src/__tests__/memory-regressions.test.ts +6 -6
- package/src/__tests__/openai-provider.test.ts +82 -0
- package/src/__tests__/provider-fail-open-selection.test.ts +134 -1
- package/src/__tests__/provider-managed-proxy-integration.test.ts +269 -0
- package/src/__tests__/recurrence-types.test.ts +0 -15
- package/src/__tests__/schedule-tools.test.ts +28 -44
- package/src/__tests__/skill-feature-flags.test.ts +2 -2
- package/src/__tests__/task-management-tools.test.ts +111 -0
- package/src/__tests__/twilio-config.test.ts +0 -3
- package/src/amazon/session.ts +30 -91
- package/src/calls/call-controller.ts +423 -571
- package/src/calls/finalize-call.ts +20 -0
- package/src/calls/relay-access-wait.ts +340 -0
- package/src/calls/relay-server.ts +267 -902
- package/src/calls/relay-setup-router.ts +307 -0
- package/src/calls/relay-verification.ts +280 -0
- package/src/calls/twilio-config.ts +1 -8
- package/src/calls/voice-control-protocol.ts +184 -0
- package/src/calls/voice-session-bridge.ts +1 -8
- package/src/config/agent-schema.ts +1 -1
- package/src/config/bundled-skills/followups/TOOLS.json +0 -4
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/bundled-skills/schedule/TOOLS.json +2 -10
- package/src/config/core-schema.ts +1 -1
- package/src/config/env.ts +0 -10
- package/src/config/feature-flag-registry.json +1 -1
- package/src/config/loader.ts +19 -0
- package/src/config/schema.ts +2 -2
- package/src/daemon/handlers/session-history.ts +398 -0
- package/src/daemon/handlers/session-user-message.ts +982 -0
- package/src/daemon/handlers/sessions.ts +9 -1338
- package/src/daemon/ipc-contract/sessions.ts +0 -6
- package/src/daemon/ipc-contract-inventory.json +0 -1
- package/src/daemon/lifecycle.ts +0 -29
- package/src/home-base/app-link-store.ts +0 -7
- package/src/memory/conversation-attention-store.ts +1 -1
- package/src/memory/conversation-store.ts +0 -51
- package/src/memory/db-init.ts +5 -1
- package/src/memory/job-handlers/conflict.ts +24 -0
- package/src/memory/migrations/105-contacts-and-triage.ts +4 -7
- package/src/memory/migrations/134-contacts-notes-column.ts +50 -33
- package/src/memory/migrations/registry.ts +6 -0
- package/src/memory/recall-cache.ts +0 -5
- package/src/memory/schema/calls.ts +274 -0
- package/src/memory/schema/contacts.ts +125 -0
- package/src/memory/schema/conversations.ts +129 -0
- package/src/memory/schema/guardian.ts +172 -0
- package/src/memory/schema/index.ts +8 -0
- package/src/memory/schema/infrastructure.ts +205 -0
- package/src/memory/schema/memory-core.ts +196 -0
- package/src/memory/schema/notifications.ts +191 -0
- package/src/memory/schema/tasks.ts +78 -0
- package/src/memory/schema.ts +1 -1385
- package/src/memory/slack-thread-store.ts +0 -69
- package/src/notifications/decisions-store.ts +2 -105
- package/src/notifications/deliveries-store.ts +0 -11
- package/src/notifications/preferences-store.ts +1 -58
- package/src/permissions/checker.ts +6 -17
- package/src/providers/anthropic/client.ts +6 -2
- package/src/providers/gemini/client.ts +13 -2
- package/src/providers/managed-proxy/constants.ts +55 -0
- package/src/providers/managed-proxy/context.ts +77 -0
- package/src/providers/registry.ts +112 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -23
- package/src/runtime/http-server.ts +83 -722
- package/src/runtime/http-types.ts +0 -16
- package/src/runtime/middleware/auth.ts +0 -12
- package/src/runtime/routes/app-routes.ts +33 -0
- package/src/runtime/routes/approval-routes.ts +32 -0
- package/src/runtime/routes/attachment-routes.ts +32 -0
- package/src/runtime/routes/brain-graph-routes.ts +27 -0
- package/src/runtime/routes/call-routes.ts +41 -0
- package/src/runtime/routes/channel-readiness-routes.ts +20 -0
- package/src/runtime/routes/channel-routes.ts +70 -0
- package/src/runtime/routes/contact-routes.ts +63 -0
- package/src/runtime/routes/conversation-attention-routes.ts +15 -0
- package/src/runtime/routes/conversation-routes.ts +190 -193
- package/src/runtime/routes/debug-routes.ts +15 -0
- package/src/runtime/routes/events-routes.ts +16 -0
- package/src/runtime/routes/global-search-routes.ts +15 -0
- package/src/runtime/routes/guardian-action-routes.ts +22 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +20 -0
- package/src/runtime/routes/guardian-refresh-routes.ts +20 -0
- package/src/runtime/routes/identity-routes.ts +20 -0
- package/src/runtime/routes/inbound-message-handler.ts +8 -0
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +6 -6
- package/src/runtime/routes/integration-routes.ts +83 -0
- package/src/runtime/routes/invite-routes.ts +31 -0
- package/src/runtime/routes/migration-routes.ts +30 -0
- package/src/runtime/routes/pairing-routes.ts +18 -0
- package/src/runtime/routes/secret-routes.ts +20 -0
- package/src/runtime/routes/surface-action-routes.ts +26 -0
- package/src/runtime/routes/trust-rules-routes.ts +31 -0
- package/src/runtime/routes/twilio-routes.ts +79 -0
- package/src/schedule/recurrence-types.ts +1 -11
- package/src/tools/followups/followup_create.ts +9 -3
- package/src/tools/mcp/mcp-tool-factory.ts +0 -17
- package/src/tools/memory/definitions.ts +0 -6
- package/src/tools/network/script-proxy/session-manager.ts +38 -3
- package/src/tools/schedule/create.ts +1 -3
- package/src/tools/schedule/update.ts +9 -6
- package/src/twitter/session.ts +29 -77
- package/src/util/cookie-session.ts +114 -0
- package/src/__tests__/conversation-routes.test.ts +0 -99
- package/src/__tests__/task-tools.test.ts +0 -685
- package/src/contacts/startup-migration.ts +0 -21
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure routing logic extracted from RelayConnection.handleSetup.
|
|
3
|
+
*
|
|
4
|
+
* Given a setup context (call session, actor trust, voice config, ACL policy),
|
|
5
|
+
* returns a discriminated union describing what the relay connection should do
|
|
6
|
+
* next — without performing any side effects itself.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getConfig } from "../config/loader.js";
|
|
10
|
+
import { findActiveVoiceInvites } from "../memory/invite-store.js";
|
|
11
|
+
import {
|
|
12
|
+
type ActorTrustContext,
|
|
13
|
+
resolveActorTrust,
|
|
14
|
+
} from "../runtime/actor-trust-resolver.js";
|
|
15
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
|
|
16
|
+
import { getPendingChallenge } from "../runtime/channel-guardian-service.js";
|
|
17
|
+
import { getLogger } from "../util/logger.js";
|
|
18
|
+
import type { CallSession } from "./types.js";
|
|
19
|
+
|
|
20
|
+
const log = getLogger("relay-setup-router");
|
|
21
|
+
|
|
22
|
+
// ── Setup context ────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface SetupContext {
|
|
25
|
+
callSessionId: string;
|
|
26
|
+
session: CallSession | null;
|
|
27
|
+
from: string;
|
|
28
|
+
to: string;
|
|
29
|
+
customParameters?: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Setup outcomes ───────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export type SetupOutcome =
|
|
35
|
+
| { action: "normal_call"; isInbound: boolean }
|
|
36
|
+
| {
|
|
37
|
+
action: "guardian_verification";
|
|
38
|
+
assistantId: string;
|
|
39
|
+
fromNumber: string;
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
action: "outbound_guardian_verification";
|
|
43
|
+
assistantId: string;
|
|
44
|
+
sessionId: string;
|
|
45
|
+
toNumber: string;
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
action: "callee_verification";
|
|
49
|
+
verificationConfig: { maxAttempts: number; codeLength: number };
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
action: "invite_redemption";
|
|
53
|
+
assistantId: string;
|
|
54
|
+
fromNumber: string;
|
|
55
|
+
friendName: string | null;
|
|
56
|
+
guardianName: string | null;
|
|
57
|
+
}
|
|
58
|
+
| { action: "name_capture"; assistantId: string; fromNumber: string }
|
|
59
|
+
| { action: "deny"; message: string; logReason: string };
|
|
60
|
+
|
|
61
|
+
// ── Resolved context produced alongside the outcome ──────────────────
|
|
62
|
+
|
|
63
|
+
export interface SetupResolved {
|
|
64
|
+
assistantId: string;
|
|
65
|
+
isInbound: boolean;
|
|
66
|
+
otherPartyNumber: string;
|
|
67
|
+
actorTrust: ActorTrustContext;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Router ───────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Determine the setup outcome for an incoming relay connection.
|
|
74
|
+
*
|
|
75
|
+
* This function is pure routing logic — it reads state but performs no
|
|
76
|
+
* side effects (no call-session mutations, no event recording, no WS
|
|
77
|
+
* messages). The caller (`RelayConnection.handleSetup`) is responsible
|
|
78
|
+
* for acting on the returned outcome.
|
|
79
|
+
*/
|
|
80
|
+
export function routeSetup(ctx: SetupContext): {
|
|
81
|
+
outcome: SetupOutcome;
|
|
82
|
+
resolved: SetupResolved;
|
|
83
|
+
} {
|
|
84
|
+
const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
|
|
85
|
+
const isInbound = ctx.session?.initiatedFromConversationId == null;
|
|
86
|
+
const otherPartyNumber = isInbound ? ctx.from : ctx.to;
|
|
87
|
+
|
|
88
|
+
const actorTrust = resolveActorTrust({
|
|
89
|
+
assistantId,
|
|
90
|
+
sourceChannel: "voice",
|
|
91
|
+
conversationExternalId: otherPartyNumber,
|
|
92
|
+
actorExternalId: otherPartyNumber || undefined,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const resolved: SetupResolved = {
|
|
96
|
+
assistantId,
|
|
97
|
+
isInbound,
|
|
98
|
+
otherPartyNumber,
|
|
99
|
+
actorTrust,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ── Outbound guardian verification (persisted mode) ──────────────
|
|
103
|
+
const persistedMode = ctx.session?.callMode;
|
|
104
|
+
const persistedGvSessionId = ctx.session?.guardianVerificationSessionId;
|
|
105
|
+
const customParamGvSessionId =
|
|
106
|
+
ctx.customParameters?.guardianVerificationSessionId;
|
|
107
|
+
const guardianVerificationSessionId =
|
|
108
|
+
persistedGvSessionId ?? customParamGvSessionId;
|
|
109
|
+
|
|
110
|
+
if (
|
|
111
|
+
persistedMode === "guardian_verification" &&
|
|
112
|
+
guardianVerificationSessionId
|
|
113
|
+
) {
|
|
114
|
+
return {
|
|
115
|
+
outcome: {
|
|
116
|
+
action: "outbound_guardian_verification",
|
|
117
|
+
assistantId,
|
|
118
|
+
sessionId: guardianVerificationSessionId,
|
|
119
|
+
toNumber: ctx.to,
|
|
120
|
+
},
|
|
121
|
+
resolved,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Secondary signal: custom parameter without persisted mode (pre-migration)
|
|
126
|
+
if (!persistedMode && customParamGvSessionId) {
|
|
127
|
+
log.warn(
|
|
128
|
+
{
|
|
129
|
+
callSessionId: ctx.callSessionId,
|
|
130
|
+
guardianVerificationSessionId: customParamGvSessionId,
|
|
131
|
+
},
|
|
132
|
+
"Guardian verification detected via setup custom parameter (no persisted call_mode) — entering verification path",
|
|
133
|
+
);
|
|
134
|
+
return {
|
|
135
|
+
outcome: {
|
|
136
|
+
action: "outbound_guardian_verification",
|
|
137
|
+
assistantId,
|
|
138
|
+
sessionId: customParamGvSessionId,
|
|
139
|
+
toNumber: ctx.to,
|
|
140
|
+
},
|
|
141
|
+
resolved,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Outbound callee verification ────────────────────────────────
|
|
146
|
+
const config = getConfig();
|
|
147
|
+
const verificationConfig = config.calls.verification;
|
|
148
|
+
if (!isInbound && verificationConfig.enabled) {
|
|
149
|
+
return {
|
|
150
|
+
outcome: {
|
|
151
|
+
action: "callee_verification",
|
|
152
|
+
verificationConfig,
|
|
153
|
+
},
|
|
154
|
+
resolved,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Outbound normal call ────────────────────────────────────────
|
|
159
|
+
if (!isInbound) {
|
|
160
|
+
return {
|
|
161
|
+
outcome: { action: "normal_call", isInbound: false },
|
|
162
|
+
resolved,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Inbound call ACL evaluation ─────────────────────────────────
|
|
167
|
+
const pendingChallenge = getPendingChallenge(assistantId, "voice");
|
|
168
|
+
|
|
169
|
+
if (actorTrust.trustClass === "unknown" && !pendingChallenge) {
|
|
170
|
+
// Check for blocked caller
|
|
171
|
+
if (actorTrust.memberRecord?.channel.status === "blocked") {
|
|
172
|
+
log.info(
|
|
173
|
+
{
|
|
174
|
+
callSessionId: ctx.callSessionId,
|
|
175
|
+
from: ctx.from,
|
|
176
|
+
trustClass: actorTrust.trustClass,
|
|
177
|
+
},
|
|
178
|
+
"Inbound voice ACL: blocked caller denied",
|
|
179
|
+
);
|
|
180
|
+
return {
|
|
181
|
+
outcome: {
|
|
182
|
+
action: "deny",
|
|
183
|
+
message: "This number is not authorized to use this assistant.",
|
|
184
|
+
logReason: "Inbound voice ACL: caller blocked",
|
|
185
|
+
},
|
|
186
|
+
resolved,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check for active voice invites
|
|
191
|
+
let voiceInvites: ReturnType<typeof findActiveVoiceInvites> = [];
|
|
192
|
+
try {
|
|
193
|
+
voiceInvites = findActiveVoiceInvites({
|
|
194
|
+
assistantId,
|
|
195
|
+
expectedExternalUserId: ctx.from,
|
|
196
|
+
});
|
|
197
|
+
} catch (err) {
|
|
198
|
+
log.warn(
|
|
199
|
+
{ err, callSessionId: ctx.callSessionId },
|
|
200
|
+
"Failed to check voice invites for unknown caller",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const nonExpiredInvites = voiceInvites.filter(
|
|
206
|
+
(i) => !i.expiresAt || i.expiresAt > now,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
if (nonExpiredInvites.length > 0) {
|
|
210
|
+
const matchedInvite = nonExpiredInvites[0];
|
|
211
|
+
log.info(
|
|
212
|
+
{ callSessionId: ctx.callSessionId, from: ctx.from },
|
|
213
|
+
"Inbound voice ACL: unknown caller has active voice invite — entering redemption flow",
|
|
214
|
+
);
|
|
215
|
+
return {
|
|
216
|
+
outcome: {
|
|
217
|
+
action: "invite_redemption",
|
|
218
|
+
assistantId,
|
|
219
|
+
fromNumber: ctx.from,
|
|
220
|
+
friendName: matchedInvite.friendName,
|
|
221
|
+
guardianName: matchedInvite.guardianName,
|
|
222
|
+
},
|
|
223
|
+
resolved,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Unknown caller — name capture flow
|
|
228
|
+
log.info(
|
|
229
|
+
{
|
|
230
|
+
callSessionId: ctx.callSessionId,
|
|
231
|
+
from: ctx.from,
|
|
232
|
+
trustClass: actorTrust.trustClass,
|
|
233
|
+
},
|
|
234
|
+
"Inbound voice ACL: unknown caller — entering name capture flow",
|
|
235
|
+
);
|
|
236
|
+
return {
|
|
237
|
+
outcome: {
|
|
238
|
+
action: "name_capture",
|
|
239
|
+
assistantId,
|
|
240
|
+
fromNumber: ctx.from,
|
|
241
|
+
},
|
|
242
|
+
resolved,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Members with policy: 'deny'
|
|
247
|
+
if (actorTrust.memberRecord?.channel.policy === "deny") {
|
|
248
|
+
log.info(
|
|
249
|
+
{
|
|
250
|
+
callSessionId: ctx.callSessionId,
|
|
251
|
+
from: ctx.from,
|
|
252
|
+
channelId: actorTrust.memberRecord.channel.id,
|
|
253
|
+
trustClass: actorTrust.trustClass,
|
|
254
|
+
},
|
|
255
|
+
"Inbound voice ACL: member policy deny",
|
|
256
|
+
);
|
|
257
|
+
return {
|
|
258
|
+
outcome: {
|
|
259
|
+
action: "deny",
|
|
260
|
+
message: "This number is not authorized to use this assistant.",
|
|
261
|
+
logReason: "Inbound voice ACL: member policy deny",
|
|
262
|
+
},
|
|
263
|
+
resolved,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Members with policy: 'escalate' — live calls can't wait for approval
|
|
268
|
+
if (actorTrust.memberRecord?.channel.policy === "escalate") {
|
|
269
|
+
log.info(
|
|
270
|
+
{
|
|
271
|
+
callSessionId: ctx.callSessionId,
|
|
272
|
+
from: ctx.from,
|
|
273
|
+
channelId: actorTrust.memberRecord.channel.id,
|
|
274
|
+
trustClass: actorTrust.trustClass,
|
|
275
|
+
},
|
|
276
|
+
"Inbound voice ACL: member policy escalate — cannot hold live call for guardian approval",
|
|
277
|
+
);
|
|
278
|
+
return {
|
|
279
|
+
outcome: {
|
|
280
|
+
action: "deny",
|
|
281
|
+
message:
|
|
282
|
+
"This number requires guardian approval for calls. Please have the account guardian update your permissions.",
|
|
283
|
+
logReason:
|
|
284
|
+
"Inbound voice ACL: member policy escalate — voice calls cannot await guardian approval",
|
|
285
|
+
},
|
|
286
|
+
resolved,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Guardian verification challenge
|
|
291
|
+
if (pendingChallenge) {
|
|
292
|
+
return {
|
|
293
|
+
outcome: {
|
|
294
|
+
action: "guardian_verification",
|
|
295
|
+
assistantId,
|
|
296
|
+
fromNumber: ctx.from,
|
|
297
|
+
},
|
|
298
|
+
resolved,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Guardian and trusted-contact callers proceed normally
|
|
303
|
+
return {
|
|
304
|
+
outcome: { action: "normal_call", isInbound: true },
|
|
305
|
+
resolved,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracted verification logic for the ConversationRelay voice pipeline.
|
|
3
|
+
*
|
|
4
|
+
* These pure-ish functions encapsulate the decision-making for guardian code
|
|
5
|
+
* verification and invite code redemption without directly mutating relay
|
|
6
|
+
* connection state. They return structured result objects that the caller
|
|
7
|
+
* (RelayConnection) interprets to drive side-effects (TTS, session updates,
|
|
8
|
+
* timer scheduling, etc.).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
getGuardianBinding,
|
|
13
|
+
validateAndConsumeChallenge,
|
|
14
|
+
} from "../runtime/channel-guardian-service.js";
|
|
15
|
+
import {
|
|
16
|
+
composeVerificationVoice,
|
|
17
|
+
GUARDIAN_VERIFY_TEMPLATE_KEYS,
|
|
18
|
+
} from "../runtime/guardian-verification-templates.js";
|
|
19
|
+
import { redeemVoiceInviteCode } from "../runtime/invite-service.js";
|
|
20
|
+
import type { CallEventType } from "./types.js";
|
|
21
|
+
|
|
22
|
+
// ── parseDigitsFromSpeech ──────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const wordToDigit: Record<string, string> = {
|
|
25
|
+
zero: "0",
|
|
26
|
+
oh: "0",
|
|
27
|
+
o: "0",
|
|
28
|
+
one: "1",
|
|
29
|
+
won: "1",
|
|
30
|
+
two: "2",
|
|
31
|
+
too: "2",
|
|
32
|
+
to: "2",
|
|
33
|
+
three: "3",
|
|
34
|
+
four: "4",
|
|
35
|
+
for: "4",
|
|
36
|
+
fore: "4",
|
|
37
|
+
five: "5",
|
|
38
|
+
six: "6",
|
|
39
|
+
seven: "7",
|
|
40
|
+
eight: "8",
|
|
41
|
+
ate: "8",
|
|
42
|
+
nine: "9",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extract digit characters from a speech transcript. Recognizes both
|
|
47
|
+
* raw digit characters ("1 2 3") and spoken number words ("one two three").
|
|
48
|
+
*/
|
|
49
|
+
export function parseDigitsFromSpeech(transcript: string): string {
|
|
50
|
+
const digits: string[] = [];
|
|
51
|
+
const lower = transcript.toLowerCase();
|
|
52
|
+
|
|
53
|
+
// Split on whitespace and non-alphanumeric boundaries
|
|
54
|
+
const tokens = lower.split(/[\s,.\-;:!?]+/);
|
|
55
|
+
for (const token of tokens) {
|
|
56
|
+
if (/^\d$/.test(token)) {
|
|
57
|
+
digits.push(token);
|
|
58
|
+
} else if (wordToDigit[token]) {
|
|
59
|
+
digits.push(wordToDigit[token]);
|
|
60
|
+
} else if (/^\d+$/.test(token)) {
|
|
61
|
+
// Multi-digit number like "123456" — split into individual digits
|
|
62
|
+
digits.push(...token.split(""));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return digits.join("");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Guardian code verification ─────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export interface GuardianVerificationParams {
|
|
72
|
+
guardianChallengeAssistantId: string;
|
|
73
|
+
guardianVerificationFromNumber: string;
|
|
74
|
+
enteredCode: string;
|
|
75
|
+
isOutbound: boolean;
|
|
76
|
+
codeDigits: number;
|
|
77
|
+
verificationAttempts: number;
|
|
78
|
+
verificationMaxAttempts: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type GuardianVerificationResult =
|
|
82
|
+
| {
|
|
83
|
+
outcome: "success";
|
|
84
|
+
verificationType: "guardian" | "trusted_contact";
|
|
85
|
+
/** Event name to record */
|
|
86
|
+
eventName: CallEventType;
|
|
87
|
+
/** For guardian type: whether a binding conflict was detected */
|
|
88
|
+
bindingConflict?: {
|
|
89
|
+
existingGuardian: string;
|
|
90
|
+
};
|
|
91
|
+
/** For guardian type when no conflict: the canonical principal to use */
|
|
92
|
+
canonicalPrincipal?: string;
|
|
93
|
+
/** For outbound success: the TTS text to play */
|
|
94
|
+
ttsMessage?: string;
|
|
95
|
+
}
|
|
96
|
+
| {
|
|
97
|
+
outcome: "failure";
|
|
98
|
+
eventName: CallEventType;
|
|
99
|
+
ttsMessage: string;
|
|
100
|
+
attempts: number;
|
|
101
|
+
}
|
|
102
|
+
| {
|
|
103
|
+
outcome: "retry";
|
|
104
|
+
ttsMessage: string;
|
|
105
|
+
attempt: number;
|
|
106
|
+
maxAttempts: number;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Core logic for validating an entered code against the pending voice
|
|
111
|
+
* guardian challenge. Returns a structured result describing what happened
|
|
112
|
+
* so the caller can apply side-effects (state mutations, TTS, session
|
|
113
|
+
* updates) without this function needing access to the relay connection.
|
|
114
|
+
*/
|
|
115
|
+
export function attemptGuardianCodeVerification(
|
|
116
|
+
params: GuardianVerificationParams,
|
|
117
|
+
): GuardianVerificationResult {
|
|
118
|
+
const {
|
|
119
|
+
guardianChallengeAssistantId,
|
|
120
|
+
guardianVerificationFromNumber,
|
|
121
|
+
enteredCode,
|
|
122
|
+
isOutbound,
|
|
123
|
+
codeDigits,
|
|
124
|
+
verificationAttempts,
|
|
125
|
+
verificationMaxAttempts,
|
|
126
|
+
} = params;
|
|
127
|
+
|
|
128
|
+
const result = validateAndConsumeChallenge(
|
|
129
|
+
guardianChallengeAssistantId,
|
|
130
|
+
"voice",
|
|
131
|
+
enteredCode,
|
|
132
|
+
guardianVerificationFromNumber,
|
|
133
|
+
guardianVerificationFromNumber,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (result.success) {
|
|
137
|
+
const eventName = isOutbound
|
|
138
|
+
? "outbound_guardian_voice_verification_succeeded"
|
|
139
|
+
: "guardian_voice_verification_succeeded";
|
|
140
|
+
|
|
141
|
+
// Resolve binding conflict and canonical principal for guardian type
|
|
142
|
+
let bindingConflict: { existingGuardian: string } | undefined;
|
|
143
|
+
let canonicalPrincipal: string | undefined;
|
|
144
|
+
|
|
145
|
+
if (result.verificationType === "guardian") {
|
|
146
|
+
const existingBinding = getGuardianBinding(
|
|
147
|
+
guardianChallengeAssistantId,
|
|
148
|
+
"voice",
|
|
149
|
+
);
|
|
150
|
+
if (
|
|
151
|
+
existingBinding &&
|
|
152
|
+
existingBinding.guardianExternalUserId !==
|
|
153
|
+
guardianVerificationFromNumber
|
|
154
|
+
) {
|
|
155
|
+
bindingConflict = {
|
|
156
|
+
existingGuardian: existingBinding.guardianExternalUserId,
|
|
157
|
+
};
|
|
158
|
+
} else {
|
|
159
|
+
// Resolve canonical principal from the vellum channel binding
|
|
160
|
+
const vellumBinding = getGuardianBinding(
|
|
161
|
+
guardianChallengeAssistantId,
|
|
162
|
+
"vellum",
|
|
163
|
+
);
|
|
164
|
+
canonicalPrincipal =
|
|
165
|
+
vellumBinding?.guardianPrincipalId ?? guardianVerificationFromNumber;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let ttsMessage: string | undefined;
|
|
170
|
+
if (isOutbound) {
|
|
171
|
+
ttsMessage = composeVerificationVoice(
|
|
172
|
+
GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_SUCCESS,
|
|
173
|
+
{ codeDigits },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
outcome: "success",
|
|
179
|
+
verificationType: result.verificationType,
|
|
180
|
+
eventName,
|
|
181
|
+
bindingConflict,
|
|
182
|
+
canonicalPrincipal,
|
|
183
|
+
ttsMessage,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Failure path
|
|
188
|
+
const newAttempts = verificationAttempts + 1;
|
|
189
|
+
|
|
190
|
+
if (newAttempts >= verificationMaxAttempts) {
|
|
191
|
+
const failEventName = isOutbound
|
|
192
|
+
? "outbound_guardian_voice_verification_failed"
|
|
193
|
+
: "guardian_voice_verification_failed";
|
|
194
|
+
|
|
195
|
+
const failureText = isOutbound
|
|
196
|
+
? composeVerificationVoice(GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_FAILURE, {
|
|
197
|
+
codeDigits,
|
|
198
|
+
})
|
|
199
|
+
: "Verification failed. Goodbye.";
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
outcome: "failure",
|
|
203
|
+
eventName: failEventName,
|
|
204
|
+
ttsMessage: failureText,
|
|
205
|
+
attempts: newAttempts,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const retryText = isOutbound
|
|
210
|
+
? composeVerificationVoice(GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_RETRY, {
|
|
211
|
+
codeDigits,
|
|
212
|
+
})
|
|
213
|
+
: "That code was incorrect. Please try again.";
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
outcome: "retry",
|
|
217
|
+
ttsMessage: retryText,
|
|
218
|
+
attempt: newAttempts,
|
|
219
|
+
maxAttempts: verificationMaxAttempts,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Invite code redemption ─────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
export interface InviteRedemptionParams {
|
|
226
|
+
inviteRedemptionAssistantId: string;
|
|
227
|
+
inviteRedemptionFromNumber: string;
|
|
228
|
+
enteredCode: string;
|
|
229
|
+
inviteRedemptionGuardianName: string | null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export type InviteRedemptionResult =
|
|
233
|
+
| {
|
|
234
|
+
outcome: "success";
|
|
235
|
+
memberId: string;
|
|
236
|
+
type: "redeemed" | "already_member";
|
|
237
|
+
inviteId?: string;
|
|
238
|
+
}
|
|
239
|
+
| {
|
|
240
|
+
outcome: "failure";
|
|
241
|
+
ttsMessage: string;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Validate an entered invite code against active voice invites for the
|
|
246
|
+
* caller. Returns a structured result so the caller can handle state
|
|
247
|
+
* mutations and session updates.
|
|
248
|
+
*/
|
|
249
|
+
export function attemptInviteCodeRedemption(
|
|
250
|
+
params: InviteRedemptionParams,
|
|
251
|
+
): InviteRedemptionResult {
|
|
252
|
+
const {
|
|
253
|
+
inviteRedemptionAssistantId,
|
|
254
|
+
inviteRedemptionFromNumber,
|
|
255
|
+
enteredCode,
|
|
256
|
+
inviteRedemptionGuardianName,
|
|
257
|
+
} = params;
|
|
258
|
+
|
|
259
|
+
const result = redeemVoiceInviteCode({
|
|
260
|
+
assistantId: inviteRedemptionAssistantId,
|
|
261
|
+
callerExternalUserId: inviteRedemptionFromNumber,
|
|
262
|
+
sourceChannel: "voice",
|
|
263
|
+
code: enteredCode,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (result.ok) {
|
|
267
|
+
return {
|
|
268
|
+
outcome: "success",
|
|
269
|
+
memberId: result.memberId,
|
|
270
|
+
type: result.type,
|
|
271
|
+
...(result.type === "redeemed" ? { inviteId: result.inviteId } : {}),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const displayGuardian = inviteRedemptionGuardianName ?? "your contact";
|
|
276
|
+
return {
|
|
277
|
+
outcome: "failure",
|
|
278
|
+
ttsMessage: `Sorry, the code you provided is incorrect or has since expired. Please ask ${displayGuardian} for a new code. Goodbye.`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getTwilioPhoneNumberEnv
|
|
1
|
+
import { getTwilioPhoneNumberEnv } from "../config/env.js";
|
|
2
2
|
import { loadConfig } from "../config/loader.js";
|
|
3
3
|
import {
|
|
4
4
|
getPublicBaseUrl,
|
|
@@ -43,14 +43,7 @@ export function getTwilioConfig(assistantId?: string): TwilioConfig {
|
|
|
43
43
|
const phoneNumber = resolveTwilioPhoneNumber(config, assistantId);
|
|
44
44
|
const webhookBaseUrl = getPublicBaseUrl(config);
|
|
45
45
|
|
|
46
|
-
// Always use the centralized relay URL derived from the public ingress base URL.
|
|
47
|
-
// TWILIO_WSS_BASE_URL is ignored.
|
|
48
46
|
let wssBaseUrl: string;
|
|
49
|
-
if (getTwilioWssBaseUrl()) {
|
|
50
|
-
log.warn(
|
|
51
|
-
"TWILIO_WSS_BASE_URL env var is deprecated. Relay URL is derived from ingress.publicBaseUrl.",
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
47
|
try {
|
|
55
48
|
wssBaseUrl = getTwilioRelayUrl(config);
|
|
56
49
|
} catch {
|