@vellumai/assistant 0.3.19 → 0.3.21
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 +151 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/bun.lock +139 -2
- package/docs/architecture/integrations.md +7 -11
- package/package.json +2 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
- package/src/__tests__/approval-primitive.test.ts +540 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
- package/src/__tests__/call-controller.test.ts +439 -108
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/cli.test.ts +42 -1
- package/src/__tests__/config-schema.test.ts +11 -127
- package/src/__tests__/config-watcher.test.ts +0 -8
- package/src/__tests__/daemon-lifecycle.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +8 -2
- package/src/__tests__/diff.test.ts +22 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
- package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
- package/src/__tests__/guardian-dispatch.test.ts +124 -0
- package/src/__tests__/guardian-grant-minting.test.ts +6 -17
- package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
- package/src/__tests__/invite-redemption-service.test.ts +306 -0
- package/src/__tests__/ipc-snapshot.test.ts +57 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
- package/src/__tests__/sandbox-host-parity.test.ts +6 -13
- package/src/__tests__/scoped-approval-grants.test.ts +6 -6
- package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
- package/src/__tests__/session-load-history-repair.test.ts +169 -2
- package/src/__tests__/session-runtime-assembly.test.ts +33 -5
- package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
- package/src/__tests__/skill-feature-flags.test.ts +188 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
- package/src/__tests__/skill-mirror-parity.test.ts +1 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
- package/src/__tests__/system-prompt.test.ts +1 -1
- package/src/__tests__/terminal-sandbox.test.ts +142 -9
- package/src/__tests__/terminal-tools.test.ts +2 -93
- package/src/__tests__/thread-seed-composer.test.ts +18 -0
- package/src/__tests__/tool-approval-handler.test.ts +350 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
- package/src/agent/loop.ts +36 -1
- package/src/approvals/approval-primitive.ts +381 -0
- package/src/approvals/guardian-decision-primitive.ts +191 -0
- package/src/calls/call-controller.ts +252 -209
- package/src/calls/call-domain.ts +44 -6
- package/src/calls/guardian-dispatch.ts +48 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +46 -30
- package/src/cli/core-commands.ts +0 -4
- package/src/cli/mcp.ts +58 -0
- package/src/cli.ts +76 -34
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
- package/src/config/assistant-feature-flags.ts +162 -0
- package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
- package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
- package/src/config/bundled-skills/notifications/SKILL.md +1 -1
- package/src/config/bundled-skills/reminder/SKILL.md +49 -2
- package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
- package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
- package/src/config/core-schema.ts +1 -1
- package/src/config/env-registry.ts +10 -0
- package/src/config/feature-flag-registry.json +61 -0
- package/src/config/loader.ts +22 -1
- package/src/config/mcp-schema.ts +46 -0
- package/src/config/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +18 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +0 -1
- package/src/config/skills.ts +9 -0
- package/src/config/system-prompt.ts +110 -46
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +19 -1
- package/src/config/vellum-skills/catalog.json +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/config-watcher.ts +0 -1
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/guardian-invite-intent.ts +124 -0
- package/src/daemon/handlers/avatar.ts +68 -0
- package/src/daemon/handlers/browser.ts +2 -2
- package/src/daemon/handlers/guardian-actions.ts +120 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/sessions.ts +19 -0
- package/src/daemon/handlers/shared.ts +3 -1
- package/src/daemon/install-cli-launchers.ts +58 -13
- package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -2
- package/src/daemon/ipc-contract/settings.ts +25 -2
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +14 -2
- package/src/daemon/main.ts +1 -0
- package/src/daemon/providers-setup.ts +26 -1
- package/src/daemon/server.ts +1 -0
- package/src/daemon/session-lifecycle.ts +52 -7
- package/src/daemon/session-memory.ts +45 -0
- package/src/daemon/session-process.ts +258 -432
- package/src/daemon/session-runtime-assembly.ts +12 -0
- package/src/daemon/session-skill-tools.ts +14 -1
- package/src/daemon/session-tool-setup.ts +5 -0
- package/src/daemon/session.ts +11 -0
- package/src/daemon/shutdown-handlers.ts +11 -0
- package/src/daemon/tool-side-effects.ts +35 -9
- package/src/index.ts +2 -2
- package/src/mcp/client.ts +152 -0
- package/src/mcp/manager.ts +139 -0
- package/src/memory/conversation-display-order-migration.ts +44 -0
- package/src/memory/conversation-queries.ts +2 -0
- package/src/memory/conversation-store.ts +91 -0
- package/src/memory/db-init.ts +5 -1
- package/src/memory/embedding-local.ts +13 -8
- package/src/memory/guardian-action-store.ts +125 -2
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +2 -1
- package/src/memory/schema.ts +5 -1
- package/src/memory/scoped-approval-grants.ts +14 -5
- package/src/messaging/providers/slack/client.ts +12 -0
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/notifications/decision-engine.ts +49 -12
- package/src/notifications/emit-signal.ts +7 -0
- package/src/notifications/signal.ts +7 -0
- package/src/notifications/thread-seed-composer.ts +2 -1
- package/src/runtime/channel-approval-types.ts +16 -6
- package/src/runtime/channel-approvals.ts +19 -15
- package/src/runtime/channel-invite-transport.ts +85 -0
- package/src/runtime/channel-invite-transports/telegram.ts +105 -0
- package/src/runtime/guardian-action-grant-minter.ts +92 -35
- package/src/runtime/guardian-action-message-composer.ts +30 -0
- package/src/runtime/guardian-decision-types.ts +91 -0
- package/src/runtime/http-server.ts +23 -1
- package/src/runtime/ingress-service.ts +22 -0
- package/src/runtime/invite-redemption-service.ts +181 -0
- package/src/runtime/invite-redemption-templates.ts +39 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/guardian-action-routes.ts +206 -0
- package/src/runtime/routes/guardian-approval-interception.ts +66 -190
- package/src/runtime/routes/identity-routes.ts +73 -0
- package/src/runtime/routes/inbound-message-handler.ts +486 -394
- package/src/runtime/routes/pairing-routes.ts +4 -0
- package/src/security/encrypted-store.ts +31 -17
- package/src/security/keychain.ts +176 -2
- package/src/security/secure-keys.ts +97 -0
- package/src/security/tool-approval-digest.ts +1 -1
- package/src/tools/browser/browser-execution.ts +2 -2
- package/src/tools/browser/browser-manager.ts +46 -32
- package/src/tools/browser/browser-screencast.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -1
- package/src/tools/executor.ts +22 -17
- package/src/tools/mcp/mcp-tool-factory.ts +100 -0
- package/src/tools/network/script-proxy/session-manager.ts +1 -5
- package/src/tools/registry.ts +64 -1
- package/src/tools/skills/load.ts +22 -8
- package/src/tools/system/avatar-generator.ts +119 -0
- package/src/tools/system/navigate-settings.ts +65 -0
- package/src/tools/system/open-system-settings.ts +75 -0
- package/src/tools/system/voice-config.ts +121 -32
- package/src/tools/terminal/backends/native.ts +40 -19
- package/src/tools/terminal/backends/types.ts +3 -3
- package/src/tools/terminal/parser.ts +1 -1
- package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
- package/src/tools/terminal/sandbox.ts +1 -12
- package/src/tools/terminal/shell.ts +3 -31
- package/src/tools/tool-approval-handler.ts +141 -3
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +10 -2
- package/src/util/diff.ts +36 -13
- package/Dockerfile.sandbox +0 -5
- package/src/__tests__/doordash-client.test.ts +0 -187
- package/src/__tests__/doordash-session.test.ts +0 -154
- package/src/__tests__/signup-e2e.test.ts +0 -354
- package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
- package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
- package/src/cli/doordash.ts +0 -1057
- package/src/config/bundled-skills/doordash/SKILL.md +0 -163
- package/src/config/templates/LOOKS.md +0 -25
- package/src/doordash/cart-queries.ts +0 -787
- package/src/doordash/client.ts +0 -1016
- package/src/doordash/order-queries.ts +0 -85
- package/src/doordash/queries.ts +0 -13
- package/src/doordash/query-extractor.ts +0 -94
- package/src/doordash/search-queries.ts +0 -203
- package/src/doordash/session.ts +0 -84
- package/src/doordash/store-queries.ts +0 -246
- package/src/doordash/types.ts +0 -367
- package/src/tools/terminal/backends/docker.ts +0 -379
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Channel inbound message handler: validates, records, and routes inbound
|
|
3
3
|
* messages from all channels. Handles ingress ACL, edits, guardian
|
|
4
|
-
* verification, guardian action answers,
|
|
4
|
+
* verification, guardian action answers, approval interception, and
|
|
5
|
+
* invite token redemption.
|
|
5
6
|
*/
|
|
7
|
+
// Side-effect import: registers the Telegram invite transport adapter so
|
|
8
|
+
// getTransport('telegram') resolves at runtime.
|
|
6
9
|
import { answerCall } from '../../calls/call-domain.js';
|
|
10
|
+
import { isTerminalState } from '../../calls/call-state-machine.js';
|
|
11
|
+
import { getCallSession } from '../../calls/call-store.js';
|
|
7
12
|
import type { ChannelId, InterfaceId } from '../../channels/types.js';
|
|
8
13
|
import { CHANNEL_IDS, INTERFACE_IDS, isChannelId, parseInterfaceId } from '../../channels/types.js';
|
|
9
14
|
import { getGatewayInternalBaseUrl } from '../../config/env.js';
|
|
@@ -19,10 +24,12 @@ import * as conversationStore from '../../memory/conversation-store.js';
|
|
|
19
24
|
import * as externalConversationStore from '../../memory/external-conversation-store.js';
|
|
20
25
|
import {
|
|
21
26
|
finalizeFollowup,
|
|
27
|
+
getDeliveriesByRequestId,
|
|
22
28
|
getExpiredDeliveriesByDestination,
|
|
23
29
|
getFollowupDeliveriesByDestination,
|
|
24
30
|
getGuardianActionRequest,
|
|
25
31
|
getPendingDeliveriesByDestination,
|
|
32
|
+
getPendingRequestByCallSessionId,
|
|
26
33
|
progressFollowupState,
|
|
27
34
|
resolveGuardianActionRequest,
|
|
28
35
|
startFollowupFromExpiredRequest,
|
|
@@ -49,9 +56,11 @@ import {
|
|
|
49
56
|
updateSessionStatus,
|
|
50
57
|
validateAndConsumeChallenge,
|
|
51
58
|
} from '../channel-guardian-service.js';
|
|
59
|
+
import { getTransport } from '../channel-invite-transport.js';
|
|
52
60
|
import { deliverChannelReply } from '../gateway-client.js';
|
|
53
61
|
import { processGuardianFollowUpTurn } from '../guardian-action-conversation-turn.js';
|
|
54
62
|
import { executeFollowupAction } from '../guardian-action-followup-executor.js';
|
|
63
|
+
import { tryMintGuardianActionGrant } from '../guardian-action-grant-minter.js';
|
|
55
64
|
import { composeGuardianActionMessageGenerative } from '../guardian-action-message-composer.js';
|
|
56
65
|
import { resolveGuardianContext } from '../guardian-context-resolver.js';
|
|
57
66
|
import {
|
|
@@ -67,6 +76,8 @@ import type {
|
|
|
67
76
|
GuardianFollowUpConversationGenerator,
|
|
68
77
|
MessageProcessor,
|
|
69
78
|
} from '../http-types.js';
|
|
79
|
+
import { redeemInvite } from '../invite-redemption-service.js';
|
|
80
|
+
import { getInviteRedemptionReply } from '../invite-redemption-templates.js';
|
|
70
81
|
import { deliverReplyViaCallback } from './channel-delivery-routes.js';
|
|
71
82
|
import {
|
|
72
83
|
canonicalChannelAssistantId,
|
|
@@ -78,7 +89,8 @@ import {
|
|
|
78
89
|
} from './channel-route-shared.js';
|
|
79
90
|
import { handleApprovalInterception } from './guardian-approval-interception.js';
|
|
80
91
|
import { deliverGeneratedApprovalPrompt } from './guardian-approval-prompt.js';
|
|
81
|
-
|
|
92
|
+
|
|
93
|
+
import '../channel-invite-transports/telegram.js';
|
|
82
94
|
|
|
83
95
|
const log = getLogger('runtime-http');
|
|
84
96
|
|
|
@@ -210,6 +222,19 @@ export async function handleChannelInbound(
|
|
|
210
222
|
typeof (rawCommandIntentForAcl as Record<string, unknown>).payload === 'string' &&
|
|
211
223
|
((rawCommandIntentForAcl as Record<string, unknown>).payload as string).startsWith('gv_');
|
|
212
224
|
|
|
225
|
+
// Parse invite token from /start iv_<token> commands using the transport
|
|
226
|
+
// adapter. The token is extracted once here so both the ACL bypass and
|
|
227
|
+
// the intercept handler can reference it without re-parsing.
|
|
228
|
+
const commandIntentForAcl = rawCommandIntentForAcl && typeof rawCommandIntentForAcl === 'object' && !Array.isArray(rawCommandIntentForAcl)
|
|
229
|
+
? rawCommandIntentForAcl as Record<string, unknown>
|
|
230
|
+
: undefined;
|
|
231
|
+
const inviteTransport = getTransport(sourceChannel);
|
|
232
|
+
const inviteToken = inviteTransport?.extractInboundToken({
|
|
233
|
+
commandIntent: commandIntentForAcl,
|
|
234
|
+
content: trimmedContent,
|
|
235
|
+
sourceMetadata: body.sourceMetadata,
|
|
236
|
+
});
|
|
237
|
+
|
|
213
238
|
if (body.senderExternalUserId) {
|
|
214
239
|
resolvedMember = findMember({
|
|
215
240
|
assistantId: canonicalAssistantId,
|
|
@@ -253,6 +278,27 @@ export async function handleChannelInbound(
|
|
|
253
278
|
}
|
|
254
279
|
}
|
|
255
280
|
|
|
281
|
+
// ── Invite token intercept (non-member) ──
|
|
282
|
+
// /start iv_<token> deep links grant access without guardian approval.
|
|
283
|
+
// Intercept here — before the deny gate — so valid invites short-circuit
|
|
284
|
+
// the ACL rejection and never reach the agent pipeline.
|
|
285
|
+
if (inviteToken && denyNonMember) {
|
|
286
|
+
const inviteResult = await handleInviteTokenIntercept({
|
|
287
|
+
rawToken: inviteToken,
|
|
288
|
+
sourceChannel,
|
|
289
|
+
externalChatId,
|
|
290
|
+
externalMessageId,
|
|
291
|
+
senderExternalUserId: body.senderExternalUserId,
|
|
292
|
+
senderName: body.senderName,
|
|
293
|
+
senderUsername: body.senderUsername,
|
|
294
|
+
replyCallbackUrl: body.replyCallbackUrl,
|
|
295
|
+
bearerToken,
|
|
296
|
+
assistantId,
|
|
297
|
+
canonicalAssistantId,
|
|
298
|
+
});
|
|
299
|
+
if (inviteResult) return inviteResult;
|
|
300
|
+
}
|
|
301
|
+
|
|
256
302
|
if (denyNonMember) {
|
|
257
303
|
log.info({ sourceChannel, externalUserId: body.senderExternalUserId }, 'Ingress ACL: no member record, denying');
|
|
258
304
|
|
|
@@ -275,7 +321,7 @@ export async function handleChannelInbound(
|
|
|
275
321
|
|
|
276
322
|
if (body.replyCallbackUrl) {
|
|
277
323
|
const replyText = guardianNotified
|
|
278
|
-
? "
|
|
324
|
+
? "Hmm looks like you don't have access to talk to me. I'll let them know you tried talking to me and get back to you."
|
|
279
325
|
: "Sorry, you haven't been approved to message this assistant.";
|
|
280
326
|
try {
|
|
281
327
|
await deliverChannelReply(body.replyCallbackUrl, {
|
|
@@ -318,6 +364,26 @@ export async function handleChannelInbound(
|
|
|
318
364
|
}
|
|
319
365
|
}
|
|
320
366
|
|
|
367
|
+
// ── Invite token intercept (inactive member) ──
|
|
368
|
+
// Same as the non-member branch: invite tokens can reactivate
|
|
369
|
+
// revoked/pending members without requiring guardian approval.
|
|
370
|
+
if (inviteToken && denyInactiveMember) {
|
|
371
|
+
const inviteResult = await handleInviteTokenIntercept({
|
|
372
|
+
rawToken: inviteToken,
|
|
373
|
+
sourceChannel,
|
|
374
|
+
externalChatId,
|
|
375
|
+
externalMessageId,
|
|
376
|
+
senderExternalUserId: body.senderExternalUserId,
|
|
377
|
+
senderName: body.senderName,
|
|
378
|
+
senderUsername: body.senderUsername,
|
|
379
|
+
replyCallbackUrl: body.replyCallbackUrl,
|
|
380
|
+
bearerToken,
|
|
381
|
+
assistantId,
|
|
382
|
+
canonicalAssistantId,
|
|
383
|
+
});
|
|
384
|
+
if (inviteResult) return inviteResult;
|
|
385
|
+
}
|
|
386
|
+
|
|
321
387
|
if (denyInactiveMember) {
|
|
322
388
|
log.info({ sourceChannel, memberId: resolvedMember.id, status: resolvedMember.status }, 'Ingress ACL: member not active, denying');
|
|
323
389
|
|
|
@@ -342,7 +408,7 @@ export async function handleChannelInbound(
|
|
|
342
408
|
|
|
343
409
|
if (body.replyCallbackUrl) {
|
|
344
410
|
const replyText = guardianNotified
|
|
345
|
-
? "
|
|
411
|
+
? "Hmm looks like you don't have access to talk to me. I'll let them know you tried talking to me and get back to you."
|
|
346
412
|
: "Sorry, you haven't been approved to message this assistant.";
|
|
347
413
|
try {
|
|
348
414
|
await deliverChannelReply(body.replyCallbackUrl, {
|
|
@@ -807,12 +873,13 @@ export async function handleChannelInbound(
|
|
|
807
873
|
});
|
|
808
874
|
}
|
|
809
875
|
|
|
810
|
-
// ──
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
// not be misclassified as
|
|
876
|
+
// ── Unified guardian action answer interception ──
|
|
877
|
+
// Deterministic priority matching: pending → follow-up → expired.
|
|
878
|
+
// When the guardian includes an explicit request code, match it across all
|
|
879
|
+
// states in priority order. When only one actionable request exists,
|
|
880
|
+
// auto-match without requiring a code prefix. Callback payloads (inline
|
|
881
|
+
// button presses) are excluded — they should not be misclassified as
|
|
882
|
+
// guardian answers.
|
|
816
883
|
if (
|
|
817
884
|
!result.duplicate &&
|
|
818
885
|
!hasCallbackData &&
|
|
@@ -820,439 +887,346 @@ export async function handleChannelInbound(
|
|
|
820
887
|
body.senderExternalUserId &&
|
|
821
888
|
replyCallbackUrl
|
|
822
889
|
) {
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
890
|
+
// Gather deliveries across all states for this destination, filtered by sender identity
|
|
891
|
+
const allPending = getPendingDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId)
|
|
892
|
+
.filter((d) => d.destinationExternalUserId === body.senderExternalUserId);
|
|
893
|
+
const allFollowup = getFollowupDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId)
|
|
894
|
+
.filter((d) => d.destinationExternalUserId === body.senderExternalUserId);
|
|
895
|
+
const allExpired = getExpiredDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId)
|
|
896
|
+
.filter((d) => d.destinationExternalUserId === body.senderExternalUserId);
|
|
897
|
+
const totalActionable = allPending.length + allFollowup.length + allExpired.length;
|
|
898
|
+
|
|
899
|
+
if (totalActionable > 0) {
|
|
900
|
+
// ── Try to parse an explicit request code from the message ──
|
|
901
|
+
// Check all deliveries across states for a code prefix match, in priority order
|
|
902
|
+
type CodeMatch = { delivery: typeof allPending[0]; request: NonNullable<ReturnType<typeof getGuardianActionRequest>>; state: 'pending' | 'followup' | 'expired'; answerText: string };
|
|
903
|
+
let codeMatch: CodeMatch | null = null;
|
|
904
|
+
const upperContent = trimmedContent.toUpperCase();
|
|
905
|
+
const orderedSets: Array<{ deliveries: typeof allPending; state: 'pending' | 'followup' | 'expired' }> = [
|
|
906
|
+
{ deliveries: allPending, state: 'pending' },
|
|
907
|
+
{ deliveries: allFollowup, state: 'followup' },
|
|
908
|
+
{ deliveries: allExpired, state: 'expired' },
|
|
909
|
+
];
|
|
910
|
+
for (const { deliveries, state } of orderedSets) {
|
|
911
|
+
for (const d of deliveries) {
|
|
912
|
+
const req = getGuardianActionRequest(d.requestId);
|
|
913
|
+
if (req && upperContent.startsWith(req.requestCode)) {
|
|
914
|
+
codeMatch = { delivery: d, request: req, state, answerText: trimmedContent.slice(req.requestCode.length).trim() };
|
|
915
|
+
break;
|
|
843
916
|
}
|
|
917
|
+
}
|
|
918
|
+
if (codeMatch) break;
|
|
919
|
+
}
|
|
844
920
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
921
|
+
// ── Explicit code targets a non-pending state: handle terminal/remap ──
|
|
922
|
+
if (codeMatch && codeMatch.state !== 'pending') {
|
|
923
|
+
const targetReq = codeMatch.request;
|
|
924
|
+
|
|
925
|
+
// Superseded request with no active call → terminal notice
|
|
926
|
+
if (targetReq.status === 'expired' && targetReq.expiredReason === 'superseded') {
|
|
927
|
+
const callSession = getCallSession(targetReq.callSessionId);
|
|
928
|
+
const callStillActive = callSession && !isTerminalState(callSession.status);
|
|
929
|
+
if (!callStillActive) {
|
|
930
|
+
const staleText = await composeGuardianActionMessageGenerative(
|
|
931
|
+
{ scenario: 'guardian_stale_superseded' },
|
|
932
|
+
{},
|
|
933
|
+
guardianActionCopyGenerator,
|
|
934
|
+
);
|
|
853
935
|
try {
|
|
854
|
-
await deliverChannelReply(replyCallbackUrl, {
|
|
855
|
-
chatId: externalChatId,
|
|
856
|
-
text: `You have multiple pending guardian questions. Please prefix your reply with the reference code (${codes.join(', ')}) to indicate which question you are answering.`,
|
|
857
|
-
assistantId,
|
|
858
|
-
}, bearerToken);
|
|
936
|
+
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: staleText, assistantId }, bearerToken);
|
|
859
937
|
} catch (err) {
|
|
860
|
-
log.error({ err, externalChatId }, 'Failed to deliver
|
|
938
|
+
log.error({ err, externalChatId }, 'Failed to deliver superseded terminal notice');
|
|
861
939
|
}
|
|
862
|
-
return Response.json({
|
|
863
|
-
accepted: true,
|
|
864
|
-
duplicate: false,
|
|
865
|
-
eventId: result.eventId,
|
|
866
|
-
guardianAnswer: 'disambiguation_sent',
|
|
867
|
-
});
|
|
940
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'stale_superseded' });
|
|
868
941
|
}
|
|
869
942
|
}
|
|
870
943
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
// the guardian action request if answerCall succeeds, so that a
|
|
876
|
-
// failed delivery (e.g. pending question timed out) leaves the
|
|
877
|
-
// request pending for retry from another channel.
|
|
878
|
-
const answerResult = await answerCall({ callSessionId: request.callSessionId, answer: answerText });
|
|
879
|
-
|
|
880
|
-
if (!('ok' in answerResult) || !answerResult.ok) {
|
|
881
|
-
const errorMsg = 'error' in answerResult ? answerResult.error : 'Unknown error';
|
|
882
|
-
log.warn({ callSessionId: request.callSessionId, error: errorMsg }, 'answerCall failed for guardian answer');
|
|
883
|
-
try {
|
|
884
|
-
const failureText = await composeGuardianActionMessageGenerative(
|
|
885
|
-
{ scenario: 'guardian_answer_delivery_failed' },
|
|
886
|
-
{},
|
|
887
|
-
guardianActionCopyGenerator,
|
|
888
|
-
);
|
|
889
|
-
await deliverChannelReply(replyCallbackUrl, {
|
|
890
|
-
chatId: externalChatId,
|
|
891
|
-
text: failureText,
|
|
892
|
-
assistantId,
|
|
893
|
-
}, bearerToken);
|
|
894
|
-
} catch (deliverErr) {
|
|
895
|
-
log.error({ err: deliverErr, externalChatId }, 'Failed to deliver guardian answer failure notice');
|
|
896
|
-
}
|
|
897
|
-
return Response.json({
|
|
898
|
-
accepted: true,
|
|
899
|
-
duplicate: false,
|
|
900
|
-
eventId: result.eventId,
|
|
901
|
-
guardianAnswer: 'answer_failed',
|
|
902
|
-
});
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
const resolved = resolveGuardianActionRequest(
|
|
906
|
-
request.id,
|
|
907
|
-
answerText,
|
|
908
|
-
sourceChannel,
|
|
909
|
-
body.senderExternalUserId,
|
|
910
|
-
);
|
|
911
|
-
|
|
912
|
-
if (resolved) {
|
|
913
|
-
// Mint a scoped grant so the voice call can consume it
|
|
914
|
-
// for subsequent tool confirmations.
|
|
915
|
-
tryMintGuardianActionGrant({
|
|
916
|
-
resolvedRequest: resolved,
|
|
917
|
-
answerText,
|
|
918
|
-
decisionChannel: sourceChannel,
|
|
919
|
-
guardianExternalUserId: body.senderExternalUserId,
|
|
920
|
-
});
|
|
944
|
+
// If the code pointed to expired/follow-up but there's a pending request,
|
|
945
|
+
// route intentionally to the expired/follow-up handler with explanation
|
|
946
|
+
// (the per-state blocks below will pick it up via codeMatch).
|
|
947
|
+
}
|
|
921
948
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
const freshRequest = getGuardianActionRequest(request.id);
|
|
934
|
-
|
|
935
|
-
// answerCall succeeded, so the answer was delivered regardless
|
|
936
|
-
// of the resolve race. Inform the guardian accordingly.
|
|
937
|
-
const relayedText = await composeGuardianActionMessageGenerative(
|
|
938
|
-
{
|
|
939
|
-
scenario: 'guardian_stale_answered' as const,
|
|
940
|
-
},
|
|
941
|
-
{},
|
|
942
|
-
guardianActionCopyGenerator,
|
|
943
|
-
);
|
|
944
|
-
try {
|
|
945
|
-
await deliverChannelReply(replyCallbackUrl, {
|
|
946
|
-
chatId: externalChatId,
|
|
947
|
-
text: relayedText,
|
|
948
|
-
assistantId,
|
|
949
|
-
}, bearerToken);
|
|
950
|
-
} catch (err) {
|
|
951
|
-
log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice');
|
|
952
|
-
}
|
|
953
|
-
log.info(
|
|
954
|
-
{ requestId: request.id, freshStatus: freshRequest?.status },
|
|
955
|
-
'answerCall succeeded but resolveGuardianActionRequest returned null — informed guardian answer was relayed',
|
|
956
|
-
);
|
|
957
|
-
return Response.json({
|
|
958
|
-
accepted: true,
|
|
959
|
-
duplicate: false,
|
|
960
|
-
eventId: result.eventId,
|
|
961
|
-
guardianAnswer: 'stale',
|
|
962
|
-
});
|
|
963
|
-
}
|
|
949
|
+
// ── Auto-match: single actionable request across all states ──
|
|
950
|
+
// When there's only one request and no explicit code, auto-match directly
|
|
951
|
+
if (!codeMatch && totalActionable === 1) {
|
|
952
|
+
const singleDelivery = allPending[0] ?? allFollowup[0] ?? allExpired[0];
|
|
953
|
+
const singleReq = getGuardianActionRequest(singleDelivery.requestId);
|
|
954
|
+
if (singleReq) {
|
|
955
|
+
const state: 'pending' | 'followup' | 'expired' = allPending.length === 1 ? 'pending' : allFollowup.length === 1 ? 'followup' : 'expired';
|
|
956
|
+
// Strip the code prefix if the guardian uses it out of habit
|
|
957
|
+
let text = trimmedContent;
|
|
958
|
+
if (upperContent.startsWith(singleReq.requestCode)) {
|
|
959
|
+
text = trimmedContent.slice(singleReq.requestCode.length).trim();
|
|
964
960
|
}
|
|
961
|
+
codeMatch = { delivery: singleDelivery, request: singleReq, state, answerText: text };
|
|
965
962
|
}
|
|
966
963
|
}
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
964
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
if (validExpired.length > 0) {
|
|
989
|
-
let matchedExpired = validExpired.length === 1 ? validExpired[0] : null;
|
|
990
|
-
let expiredAnswerText = trimmedContent;
|
|
991
|
-
|
|
992
|
-
// Multiple expired deliveries: require request code prefix for disambiguation
|
|
993
|
-
if (validExpired.length > 1) {
|
|
994
|
-
for (const d of validExpired) {
|
|
995
|
-
const req = getGuardianActionRequest(d.requestId);
|
|
996
|
-
if (req && trimmedContent.toUpperCase().startsWith(req.requestCode)) {
|
|
997
|
-
matchedExpired = d;
|
|
998
|
-
expiredAnswerText = trimmedContent.slice(req.requestCode.length).trim();
|
|
999
|
-
break;
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
if (!matchedExpired) {
|
|
1004
|
-
// Send disambiguation message listing the request codes
|
|
1005
|
-
const codes = validExpired
|
|
1006
|
-
.map((d) => {
|
|
1007
|
-
const req = getGuardianActionRequest(d.requestId);
|
|
1008
|
-
return req ? req.requestCode : null;
|
|
1009
|
-
})
|
|
1010
|
-
.filter((code): code is string => typeof code === 'string' && code.length > 0);
|
|
1011
|
-
const disambiguationText = await composeGuardianActionMessageGenerative(
|
|
1012
|
-
{
|
|
1013
|
-
scenario: 'guardian_expired_disambiguation',
|
|
1014
|
-
requestCodes: codes,
|
|
1015
|
-
channel: sourceChannel,
|
|
1016
|
-
},
|
|
1017
|
-
{ requiredKeywords: codes },
|
|
965
|
+
// ── Unknown code: message looks like a code prefix but doesn't match anything ──
|
|
966
|
+
// Detect when the message starts with a 6-char alphanumeric token that
|
|
967
|
+
// resembles a request code but doesn't match any known delivery.
|
|
968
|
+
if (!codeMatch && totalActionable > 0) {
|
|
969
|
+
const possibleCodeMatch = trimmedContent.match(/^([A-F0-9]{6})\s/i);
|
|
970
|
+
if (possibleCodeMatch) {
|
|
971
|
+
const candidateCode = possibleCodeMatch[1].toUpperCase();
|
|
972
|
+
// Check if this code exists in ANY delivery across states
|
|
973
|
+
const allDeliveries = [...allPending, ...allFollowup, ...allExpired];
|
|
974
|
+
const knownCodes = allDeliveries
|
|
975
|
+
.map((d) => { const req = getGuardianActionRequest(d.requestId); return req?.requestCode; })
|
|
976
|
+
.filter((code): code is string => typeof code === 'string');
|
|
977
|
+
const isKnown = knownCodes.includes(candidateCode);
|
|
978
|
+
if (!isKnown) {
|
|
979
|
+
const unknownText = await composeGuardianActionMessageGenerative(
|
|
980
|
+
{ scenario: 'guardian_unknown_code', unknownCode: candidateCode },
|
|
981
|
+
{},
|
|
1018
982
|
guardianActionCopyGenerator,
|
|
1019
983
|
);
|
|
1020
984
|
try {
|
|
1021
|
-
await deliverChannelReply(replyCallbackUrl, {
|
|
1022
|
-
chatId: externalChatId,
|
|
1023
|
-
text: disambiguationText,
|
|
1024
|
-
assistantId,
|
|
1025
|
-
}, bearerToken);
|
|
985
|
+
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: unknownText, assistantId }, bearerToken);
|
|
1026
986
|
} catch (err) {
|
|
1027
|
-
log.error({ err, externalChatId }, 'Failed to deliver
|
|
987
|
+
log.error({ err, externalChatId }, 'Failed to deliver unknown code notice');
|
|
1028
988
|
}
|
|
1029
|
-
return Response.json({
|
|
1030
|
-
accepted: true,
|
|
1031
|
-
duplicate: false,
|
|
1032
|
-
eventId: result.eventId,
|
|
1033
|
-
guardianAnswer: 'disambiguation_sent',
|
|
1034
|
-
});
|
|
989
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'unknown_code' });
|
|
1035
990
|
}
|
|
1036
991
|
}
|
|
992
|
+
}
|
|
1037
993
|
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
994
|
+
// ── No match and multiple actionable requests → disambiguation ──
|
|
995
|
+
if (!codeMatch && totalActionable > 1) {
|
|
996
|
+
const allDeliveries = [...allPending, ...allFollowup, ...allExpired];
|
|
997
|
+
const codes = allDeliveries
|
|
998
|
+
.map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
|
|
999
|
+
.filter((code): code is string => typeof code === 'string' && code.length > 0);
|
|
1000
|
+
|
|
1001
|
+
// Choose the appropriate disambiguation scenario based on which states are present
|
|
1002
|
+
const disambiguationScenario = allPending.length > 0
|
|
1003
|
+
? 'guardian_pending_disambiguation' as const
|
|
1004
|
+
: allFollowup.length > 0
|
|
1005
|
+
? 'guardian_followup_disambiguation' as const
|
|
1006
|
+
: 'guardian_expired_disambiguation' as const;
|
|
1007
|
+
|
|
1008
|
+
const disambiguationText = await composeGuardianActionMessageGenerative(
|
|
1009
|
+
{ scenario: disambiguationScenario, requestCodes: codes, channel: sourceChannel },
|
|
1010
|
+
{ requiredKeywords: codes },
|
|
1011
|
+
guardianActionCopyGenerator,
|
|
1012
|
+
);
|
|
1013
|
+
try {
|
|
1014
|
+
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: disambiguationText, assistantId }, bearerToken);
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
log.error({ err, externalChatId }, 'Failed to deliver guardian action disambiguation message');
|
|
1017
|
+
}
|
|
1018
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'disambiguation_sent' });
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ── Dispatch matched delivery by state ──
|
|
1022
|
+
if (codeMatch) {
|
|
1023
|
+
const { request, state, answerText } = codeMatch;
|
|
1024
|
+
|
|
1025
|
+
// ── PENDING state handler ──
|
|
1026
|
+
if (state === 'pending' && request.status === 'pending') {
|
|
1027
|
+
const answerResult = await answerCall({ callSessionId: request.callSessionId, answer: answerText, pendingQuestionId: request.pendingQuestionId });
|
|
1028
|
+
|
|
1029
|
+
if (!('ok' in answerResult) || !answerResult.ok) {
|
|
1030
|
+
const errorMsg = 'error' in answerResult ? answerResult.error : 'Unknown error';
|
|
1031
|
+
log.warn({ callSessionId: request.callSessionId, error: errorMsg }, 'answerCall failed for guardian answer');
|
|
1032
|
+
try {
|
|
1033
|
+
const failureText = await composeGuardianActionMessageGenerative(
|
|
1034
|
+
{ scenario: 'guardian_answer_delivery_failed' },
|
|
1074
1035
|
{},
|
|
1075
1036
|
guardianActionCopyGenerator,
|
|
1076
1037
|
);
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
text: staleText,
|
|
1081
|
-
assistantId,
|
|
1082
|
-
}, bearerToken);
|
|
1083
|
-
} catch (err) {
|
|
1084
|
-
log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice for expired follow-up race');
|
|
1085
|
-
}
|
|
1086
|
-
return Response.json({
|
|
1087
|
-
accepted: true,
|
|
1088
|
-
duplicate: false,
|
|
1089
|
-
eventId: result.eventId,
|
|
1090
|
-
guardianAnswer: 'stale',
|
|
1091
|
-
});
|
|
1038
|
+
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: failureText, assistantId }, bearerToken);
|
|
1039
|
+
} catch (deliverErr) {
|
|
1040
|
+
log.error({ err: deliverErr, externalChatId }, 'Failed to deliver guardian answer failure notice');
|
|
1092
1041
|
}
|
|
1042
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'answer_failed' });
|
|
1093
1043
|
}
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
1044
|
|
|
1099
|
-
|
|
1100
|
-
// When a request is in `awaiting_guardian_choice` state, the guardian has
|
|
1101
|
-
// already been asked "call back or send a message?". Their next message
|
|
1102
|
-
// is the reply to that prompt — route it through the conversation engine
|
|
1103
|
-
// to classify their intent.
|
|
1104
|
-
if (
|
|
1105
|
-
!result.duplicate &&
|
|
1106
|
-
!hasCallbackData &&
|
|
1107
|
-
trimmedContent.length > 0 &&
|
|
1108
|
-
body.senderExternalUserId &&
|
|
1109
|
-
replyCallbackUrl
|
|
1110
|
-
) {
|
|
1111
|
-
const followupDeliveries = getFollowupDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId);
|
|
1112
|
-
if (followupDeliveries.length > 0) {
|
|
1113
|
-
const validFollowup = followupDeliveries.filter(
|
|
1114
|
-
(d) => d.destinationExternalUserId === body.senderExternalUserId,
|
|
1115
|
-
);
|
|
1045
|
+
const resolved = resolveGuardianActionRequest(request.id, answerText, sourceChannel, body.senderExternalUserId);
|
|
1116
1046
|
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
if (req && trimmedContent.toUpperCase().startsWith(req.requestCode)) {
|
|
1126
|
-
matchedFollowup = d;
|
|
1127
|
-
followupReplyText = trimmedContent.slice(req.requestCode.length).trim();
|
|
1128
|
-
break;
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1047
|
+
if (resolved) {
|
|
1048
|
+
await tryMintGuardianActionGrant({
|
|
1049
|
+
request,
|
|
1050
|
+
answerText,
|
|
1051
|
+
decisionChannel: sourceChannel,
|
|
1052
|
+
guardianExternalUserId: body.senderExternalUserId,
|
|
1053
|
+
approvalConversationGenerator,
|
|
1054
|
+
});
|
|
1131
1055
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
})
|
|
1139
|
-
.filter((code): code is string => typeof code === 'string' && code.length > 0);
|
|
1140
|
-
const disambiguationText = await composeGuardianActionMessageGenerative(
|
|
1141
|
-
{
|
|
1142
|
-
scenario: 'guardian_followup_disambiguation',
|
|
1143
|
-
requestCodes: codes,
|
|
1144
|
-
channel: sourceChannel,
|
|
1145
|
-
},
|
|
1146
|
-
{ requiredKeywords: codes },
|
|
1056
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'resolved' });
|
|
1057
|
+
} else {
|
|
1058
|
+
const freshRequest = getGuardianActionRequest(request.id);
|
|
1059
|
+
const relayedText = await composeGuardianActionMessageGenerative(
|
|
1060
|
+
{ scenario: 'guardian_stale_answered' as const },
|
|
1061
|
+
{},
|
|
1147
1062
|
guardianActionCopyGenerator,
|
|
1148
1063
|
);
|
|
1149
1064
|
try {
|
|
1150
|
-
await deliverChannelReply(replyCallbackUrl, {
|
|
1151
|
-
chatId: externalChatId,
|
|
1152
|
-
text: disambiguationText,
|
|
1153
|
-
assistantId,
|
|
1154
|
-
}, bearerToken);
|
|
1065
|
+
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: relayedText, assistantId }, bearerToken);
|
|
1155
1066
|
} catch (err) {
|
|
1156
|
-
log.error({ err, externalChatId }, 'Failed to deliver guardian
|
|
1067
|
+
log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice');
|
|
1157
1068
|
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
});
|
|
1069
|
+
log.info(
|
|
1070
|
+
{ requestId: request.id, freshStatus: freshRequest?.status },
|
|
1071
|
+
'answerCall succeeded but resolveGuardianActionRequest returned null — informed guardian answer was relayed',
|
|
1072
|
+
);
|
|
1073
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'stale' });
|
|
1164
1074
|
}
|
|
1165
1075
|
}
|
|
1166
1076
|
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1077
|
+
// ── FOLLOW-UP state handler ──
|
|
1078
|
+
if (state === 'followup' && request.followupState === 'awaiting_guardian_choice') {
|
|
1079
|
+
const turnResult = await processGuardianFollowUpTurn(
|
|
1080
|
+
{
|
|
1081
|
+
questionText: request.questionText,
|
|
1082
|
+
lateAnswerText: request.lateAnswerText ?? '',
|
|
1083
|
+
guardianReply: answerText,
|
|
1084
|
+
},
|
|
1085
|
+
guardianFollowUpConversationGenerator,
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
let stateApplied = true;
|
|
1089
|
+
if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
|
|
1090
|
+
stateApplied = progressFollowupState(request.id, 'dispatching', turnResult.disposition) !== undefined;
|
|
1091
|
+
} else if (turnResult.disposition === 'decline') {
|
|
1092
|
+
stateApplied = finalizeFollowup(request.id, 'declined') !== undefined;
|
|
1093
|
+
}
|
|
1179
1094
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1095
|
+
if (!stateApplied) {
|
|
1096
|
+
log.warn({ requestId: request.id, disposition: turnResult.disposition }, 'Follow-up state transition failed (already resolved)');
|
|
1097
|
+
const staleText = await composeGuardianActionMessageGenerative(
|
|
1098
|
+
{ scenario: 'guardian_stale_followup' as const },
|
|
1099
|
+
{},
|
|
1100
|
+
guardianActionCopyGenerator,
|
|
1101
|
+
);
|
|
1102
|
+
try {
|
|
1103
|
+
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: staleText, assistantId }, bearerToken);
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
log.error({ err, externalChatId }, 'Failed to deliver stale follow-up notice');
|
|
1190
1106
|
}
|
|
1191
|
-
|
|
1107
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianFollowUp: 'stale_ignored' });
|
|
1108
|
+
}
|
|
1192
1109
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1110
|
+
try {
|
|
1111
|
+
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: turnResult.replyText, assistantId }, bearerToken);
|
|
1112
|
+
} catch (err) {
|
|
1113
|
+
log.error({ err, externalChatId }, 'Failed to deliver guardian follow-up conversation reply');
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
|
|
1117
|
+
void (async () => {
|
|
1200
1118
|
try {
|
|
1201
|
-
await
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1119
|
+
const execResult = await executeFollowupAction(
|
|
1120
|
+
request.id,
|
|
1121
|
+
turnResult.disposition as 'call_back' | 'message_back',
|
|
1122
|
+
guardianActionCopyGenerator,
|
|
1123
|
+
);
|
|
1124
|
+
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: execResult.guardianReplyText, assistantId }, bearerToken);
|
|
1125
|
+
} catch (execErr) {
|
|
1126
|
+
log.error({ err: execErr, requestId: request.id }, 'Follow-up action execution or completion reply failed');
|
|
1208
1127
|
}
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
duplicate: false,
|
|
1212
|
-
eventId: result.eventId,
|
|
1213
|
-
guardianFollowUp: 'stale_ignored',
|
|
1214
|
-
});
|
|
1215
|
-
}
|
|
1128
|
+
})();
|
|
1129
|
+
}
|
|
1216
1130
|
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
await deliverChannelReply(replyCallbackUrl, {
|
|
1220
|
-
chatId: externalChatId,
|
|
1221
|
-
text: turnResult.replyText,
|
|
1222
|
-
assistantId,
|
|
1223
|
-
}, bearerToken);
|
|
1224
|
-
} catch (err) {
|
|
1225
|
-
log.error({ err, externalChatId }, 'Failed to deliver guardian follow-up conversation reply');
|
|
1226
|
-
}
|
|
1131
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianFollowUp: turnResult.disposition });
|
|
1132
|
+
}
|
|
1227
1133
|
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1134
|
+
// ── EXPIRED state handler ──
|
|
1135
|
+
if (state === 'expired' && request.status === 'expired' && request.followupState === 'none') {
|
|
1136
|
+
// Superseded remap: if the request was superseded (not timed out
|
|
1137
|
+
// or disconnected), check whether the call is still active with a
|
|
1138
|
+
// current pending request. If so, remap the late approval to the
|
|
1139
|
+
// current request instead of entering the callback/message follow-up.
|
|
1140
|
+
if (request.expiredReason === 'superseded') {
|
|
1141
|
+
const callSession = getCallSession(request.callSessionId);
|
|
1142
|
+
const callStillActive = callSession && !isTerminalState(callSession.status);
|
|
1143
|
+
const currentPending = callStillActive
|
|
1144
|
+
? getPendingRequestByCallSessionId(request.callSessionId)
|
|
1145
|
+
: null;
|
|
1146
|
+
|
|
1147
|
+
if (callStillActive && currentPending) {
|
|
1148
|
+
const currentDeliveries = getDeliveriesByRequestId(currentPending.id);
|
|
1149
|
+
// When senderExternalUserId is present, verify the sender has a
|
|
1150
|
+
// matching delivery on the current pending request. When it's absent
|
|
1151
|
+
// (trusted session), allow the remap without delivery check.
|
|
1152
|
+
const senderHasDelivery = body.senderExternalUserId
|
|
1153
|
+
? currentDeliveries.some((d) => d.destinationExternalUserId === body.senderExternalUserId)
|
|
1154
|
+
: true;
|
|
1155
|
+
if (!senderHasDelivery) {
|
|
1156
|
+
log.info(
|
|
1157
|
+
{ supersededRequestId: request.id, currentRequestId: currentPending.id, senderExternalUserId: body.senderExternalUserId },
|
|
1158
|
+
'Superseded remap skipped: sender has no delivery on current pending request',
|
|
1159
|
+
);
|
|
1160
|
+
} else {
|
|
1161
|
+
const remapResult = await answerCall({
|
|
1162
|
+
callSessionId: currentPending.callSessionId,
|
|
1163
|
+
answer: answerText,
|
|
1164
|
+
pendingQuestionId: currentPending.pendingQuestionId,
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
if ('ok' in remapResult && remapResult.ok) {
|
|
1168
|
+
const resolved = resolveGuardianActionRequest(currentPending.id, answerText, sourceChannel, body.senderExternalUserId);
|
|
1169
|
+
|
|
1170
|
+
if (resolved) {
|
|
1171
|
+
await tryMintGuardianActionGrant({
|
|
1172
|
+
request: currentPending,
|
|
1173
|
+
answerText,
|
|
1174
|
+
decisionChannel: sourceChannel,
|
|
1175
|
+
guardianExternalUserId: body.senderExternalUserId,
|
|
1176
|
+
approvalConversationGenerator,
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const remapText = await composeGuardianActionMessageGenerative(
|
|
1181
|
+
{ scenario: 'guardian_superseded_remap', questionText: currentPending.questionText },
|
|
1182
|
+
{},
|
|
1237
1183
|
guardianActionCopyGenerator,
|
|
1238
1184
|
);
|
|
1239
|
-
|
|
1240
|
-
chatId: externalChatId,
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1185
|
+
try {
|
|
1186
|
+
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: remapText, assistantId }, bearerToken);
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
log.error({ err, externalChatId }, 'Failed to deliver superseded remap confirmation');
|
|
1189
|
+
}
|
|
1190
|
+
log.info(
|
|
1191
|
+
{ supersededRequestId: request.id, remappedToRequestId: currentPending.id },
|
|
1192
|
+
'Late approval for superseded request remapped to current pending request',
|
|
1193
|
+
);
|
|
1194
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'superseded_remapped' });
|
|
1246
1195
|
}
|
|
1247
|
-
|
|
1196
|
+
log.warn(
|
|
1197
|
+
{ callSessionId: currentPending.callSessionId, error: 'error' in remapResult ? remapResult.error : 'unknown' },
|
|
1198
|
+
'Superseded remap answerCall failed, falling through to follow-up',
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1248
1201
|
}
|
|
1202
|
+
// Call not active or no pending request — fall through to follow-up
|
|
1203
|
+
}
|
|
1249
1204
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1205
|
+
const followupResult = startFollowupFromExpiredRequest(request.id, answerText);
|
|
1206
|
+
if (followupResult) {
|
|
1207
|
+
const followupText = await composeGuardianActionMessageGenerative(
|
|
1208
|
+
{ scenario: 'guardian_late_answer_followup', questionText: request.questionText, lateAnswerText: answerText },
|
|
1209
|
+
{},
|
|
1210
|
+
guardianActionCopyGenerator,
|
|
1211
|
+
);
|
|
1212
|
+
try {
|
|
1213
|
+
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: followupText, assistantId }, bearerToken);
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
log.error({ err, externalChatId }, 'Failed to deliver guardian action late answer follow-up');
|
|
1216
|
+
}
|
|
1217
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'followup_initiated' });
|
|
1218
|
+
} else {
|
|
1219
|
+
const staleText = await composeGuardianActionMessageGenerative(
|
|
1220
|
+
{ scenario: 'guardian_stale_expired' as const },
|
|
1221
|
+
{},
|
|
1222
|
+
guardianActionCopyGenerator,
|
|
1223
|
+
);
|
|
1224
|
+
try {
|
|
1225
|
+
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: staleText, assistantId }, bearerToken);
|
|
1226
|
+
} catch (err) {
|
|
1227
|
+
log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice for expired follow-up race');
|
|
1228
|
+
}
|
|
1229
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'stale' });
|
|
1256
1230
|
}
|
|
1257
1231
|
}
|
|
1258
1232
|
}
|
|
@@ -1455,6 +1429,124 @@ export async function handleChannelInbound(
|
|
|
1455
1429
|
});
|
|
1456
1430
|
}
|
|
1457
1431
|
|
|
1432
|
+
// ---------------------------------------------------------------------------
|
|
1433
|
+
// Invite token intercept
|
|
1434
|
+
// ---------------------------------------------------------------------------
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* Handle an inbound invite token for a non-member or inactive member.
|
|
1438
|
+
*
|
|
1439
|
+
* Redeems the invite, delivers a deterministic reply, and returns a Response
|
|
1440
|
+
* to short-circuit the handler. Returns `null` when the intercept should not
|
|
1441
|
+
* fire (e.g. already_member outcome — let normal flow handle it).
|
|
1442
|
+
*/
|
|
1443
|
+
async function handleInviteTokenIntercept(params: {
|
|
1444
|
+
rawToken: string;
|
|
1445
|
+
sourceChannel: ChannelId;
|
|
1446
|
+
externalChatId: string;
|
|
1447
|
+
externalMessageId: string;
|
|
1448
|
+
senderExternalUserId?: string;
|
|
1449
|
+
senderName?: string;
|
|
1450
|
+
senderUsername?: string;
|
|
1451
|
+
replyCallbackUrl?: string;
|
|
1452
|
+
bearerToken?: string;
|
|
1453
|
+
assistantId?: string;
|
|
1454
|
+
canonicalAssistantId: string;
|
|
1455
|
+
}): Promise<Response | null> {
|
|
1456
|
+
const {
|
|
1457
|
+
rawToken,
|
|
1458
|
+
sourceChannel,
|
|
1459
|
+
externalChatId,
|
|
1460
|
+
externalMessageId,
|
|
1461
|
+
senderExternalUserId,
|
|
1462
|
+
senderName,
|
|
1463
|
+
senderUsername,
|
|
1464
|
+
replyCallbackUrl,
|
|
1465
|
+
bearerToken,
|
|
1466
|
+
assistantId,
|
|
1467
|
+
canonicalAssistantId,
|
|
1468
|
+
} = params;
|
|
1469
|
+
|
|
1470
|
+
// Record the inbound event for dedup tracking BEFORE performing redemption.
|
|
1471
|
+
// Without this, duplicate webhook deliveries (common with Telegram) would
|
|
1472
|
+
// not be tracked: the first delivery redeems the invite and returns early,
|
|
1473
|
+
// then the retry finds an active member, passes ACL, and the raw
|
|
1474
|
+
// /start iv_<token> message leaks into the agent pipeline.
|
|
1475
|
+
const dedupResult = channelDeliveryStore.recordInbound(
|
|
1476
|
+
sourceChannel,
|
|
1477
|
+
externalChatId,
|
|
1478
|
+
externalMessageId,
|
|
1479
|
+
{ assistantId: canonicalAssistantId },
|
|
1480
|
+
);
|
|
1481
|
+
|
|
1482
|
+
if (dedupResult.duplicate) {
|
|
1483
|
+
return Response.json({
|
|
1484
|
+
accepted: true,
|
|
1485
|
+
duplicate: true,
|
|
1486
|
+
eventId: dedupResult.eventId,
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const outcome = redeemInvite({
|
|
1491
|
+
rawToken,
|
|
1492
|
+
sourceChannel,
|
|
1493
|
+
externalUserId: senderExternalUserId,
|
|
1494
|
+
externalChatId,
|
|
1495
|
+
displayName: senderName,
|
|
1496
|
+
username: senderUsername,
|
|
1497
|
+
assistantId: canonicalAssistantId,
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
log.info(
|
|
1501
|
+
{ sourceChannel, externalChatId, ok: outcome.ok, type: outcome.ok ? outcome.type : undefined, reason: !outcome.ok ? outcome.reason : undefined },
|
|
1502
|
+
'Invite token intercept: redemption result',
|
|
1503
|
+
);
|
|
1504
|
+
|
|
1505
|
+
// already_member means the user has an active record — let the normal
|
|
1506
|
+
// flow handle them (they passed ACL or the member is active).
|
|
1507
|
+
if (outcome.ok && outcome.type === 'already_member') {
|
|
1508
|
+
// Deliver a quick acknowledgement and short-circuit so the user
|
|
1509
|
+
// does not trigger the deny gate or a duplicate agent loop.
|
|
1510
|
+
const replyText = getInviteRedemptionReply(outcome);
|
|
1511
|
+
if (replyCallbackUrl) {
|
|
1512
|
+
try {
|
|
1513
|
+
await deliverChannelReply(replyCallbackUrl, {
|
|
1514
|
+
chatId: externalChatId,
|
|
1515
|
+
text: replyText,
|
|
1516
|
+
assistantId,
|
|
1517
|
+
}, bearerToken);
|
|
1518
|
+
} catch (err) {
|
|
1519
|
+
log.error({ err, externalChatId }, 'Failed to deliver invite already-member reply');
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
channelDeliveryStore.markProcessed(dedupResult.eventId);
|
|
1523
|
+
return Response.json({ accepted: true, eventId: dedupResult.eventId, inviteRedemption: 'already_member' });
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
const replyText = getInviteRedemptionReply(outcome);
|
|
1527
|
+
|
|
1528
|
+
if (replyCallbackUrl) {
|
|
1529
|
+
try {
|
|
1530
|
+
await deliverChannelReply(replyCallbackUrl, {
|
|
1531
|
+
chatId: externalChatId,
|
|
1532
|
+
text: replyText,
|
|
1533
|
+
assistantId,
|
|
1534
|
+
}, bearerToken);
|
|
1535
|
+
} catch (err) {
|
|
1536
|
+
log.error({ err, externalChatId }, 'Failed to deliver invite redemption reply');
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
if (outcome.ok && outcome.type === 'redeemed') {
|
|
1541
|
+
channelDeliveryStore.markProcessed(dedupResult.eventId);
|
|
1542
|
+
return Response.json({ accepted: true, eventId: dedupResult.eventId, inviteRedemption: 'redeemed', memberId: outcome.memberId });
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Failed redemption — inform the user and deny
|
|
1546
|
+
channelDeliveryStore.markProcessed(dedupResult.eventId);
|
|
1547
|
+
return Response.json({ accepted: true, eventId: dedupResult.eventId, denied: true, inviteRedemption: outcome.reason });
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1458
1550
|
// ---------------------------------------------------------------------------
|
|
1459
1551
|
// Non-member access request notification
|
|
1460
1552
|
// ---------------------------------------------------------------------------
|