@vellumai/assistant 0.3.26 → 0.3.28
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 +48 -1
- package/Dockerfile +2 -2
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +6 -2
- package/src/__tests__/agent-loop.test.ts +119 -0
- package/src/__tests__/bundled-asset.test.ts +107 -0
- package/src/__tests__/canonical-guardian-store.test.ts +636 -0
- package/src/__tests__/channel-approval-routes.test.ts +174 -1
- package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
- package/src/__tests__/guardian-dispatch.test.ts +19 -19
- package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
- package/src/__tests__/mcp-cli.test.ts +77 -0
- package/src/__tests__/non-member-access-request.test.ts +31 -29
- package/src/__tests__/notification-decision-fallback.test.ts +61 -3
- package/src/__tests__/notification-decision-strategy.test.ts +17 -0
- package/src/__tests__/notification-guardian-path.test.ts +13 -15
- package/src/__tests__/onboarding-template-contract.test.ts +116 -21
- package/src/__tests__/secret-scanner-executor.test.ts +59 -0
- package/src/__tests__/secret-scanner.test.ts +8 -0
- package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
- package/src/__tests__/session-runtime-assembly.test.ts +76 -47
- package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
- package/src/agent/loop.ts +46 -3
- package/src/approvals/guardian-decision-primitive.ts +285 -0
- package/src/approvals/guardian-request-resolvers.ts +539 -0
- package/src/calls/guardian-dispatch.ts +46 -40
- package/src/calls/relay-server.ts +147 -2
- package/src/calls/types.ts +1 -1
- package/src/config/system-prompt.ts +2 -1
- package/src/config/templates/BOOTSTRAP.md +47 -31
- package/src/config/templates/USER.md +5 -0
- package/src/config/update-bulletin-template-path.ts +4 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
- package/src/daemon/handlers/guardian-actions.ts +45 -66
- package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
- package/src/daemon/lifecycle.ts +3 -16
- package/src/daemon/server.ts +18 -0
- package/src/daemon/session-agent-loop-handlers.ts +5 -4
- package/src/daemon/session-agent-loop.ts +32 -5
- package/src/daemon/session-process.ts +68 -307
- package/src/daemon/session-runtime-assembly.ts +112 -24
- package/src/daemon/session-tool-setup.ts +1 -0
- package/src/daemon/session.ts +1 -0
- package/src/home-base/prebuilt/seed.ts +2 -1
- package/src/hooks/templates.ts +2 -1
- package/src/memory/canonical-guardian-store.ts +524 -0
- package/src/memory/channel-guardian-store.ts +1 -0
- package/src/memory/db-init.ts +16 -0
- package/src/memory/guardian-action-store.ts +7 -60
- package/src/memory/guardian-approvals.ts +9 -4
- package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
- package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
- package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
- package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
- package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +52 -0
- package/src/notifications/copy-composer.ts +16 -4
- package/src/notifications/decision-engine.ts +57 -0
- package/src/permissions/defaults.ts +2 -0
- package/src/runtime/access-request-helper.ts +137 -0
- package/src/runtime/actor-trust-resolver.ts +225 -0
- package/src/runtime/channel-guardian-service.ts +12 -4
- package/src/runtime/guardian-context-resolver.ts +32 -7
- package/src/runtime/guardian-decision-types.ts +6 -0
- package/src/runtime/guardian-reply-router.ts +687 -0
- package/src/runtime/http-server.ts +8 -0
- package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
- package/src/runtime/routes/conversation-routes.ts +18 -0
- package/src/runtime/routes/guardian-action-routes.ts +100 -109
- package/src/runtime/routes/inbound-message-handler.ts +170 -525
- package/src/runtime/tool-grant-request-helper.ts +195 -0
- package/src/tools/executor.ts +13 -1
- package/src/tools/sensitive-output-placeholders.ts +203 -0
- package/src/tools/tool-approval-handler.ts +44 -1
- package/src/tools/types.ts +11 -0
- package/src/util/bundled-asset.ts +31 -0
- package/src/util/canonicalize-identity.ts +52 -0
|
@@ -6,40 +6,28 @@
|
|
|
6
6
|
*/
|
|
7
7
|
// Side-effect import: registers the Telegram invite transport adapter so
|
|
8
8
|
// getTransport('telegram') resolves at runtime.
|
|
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';
|
|
12
9
|
import type { ChannelId, InterfaceId } from '../../channels/types.js';
|
|
13
10
|
import { CHANNEL_IDS, INTERFACE_IDS, isChannelId, parseInterfaceId } from '../../channels/types.js';
|
|
14
11
|
import { getGatewayInternalBaseUrl } from '../../config/env.js';
|
|
15
12
|
import { RESEND_COOLDOWN_MS } from '../../daemon/handlers/config-channels.js';
|
|
16
13
|
import * as attachmentsStore from '../../memory/attachments-store.js';
|
|
17
|
-
import * as channelDeliveryStore from '../../memory/channel-delivery-store.js';
|
|
18
14
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
createCanonicalGuardianRequest,
|
|
16
|
+
listCanonicalGuardianRequests,
|
|
17
|
+
listPendingCanonicalGuardianRequestsByDestinationChat,
|
|
18
|
+
} from '../../memory/canonical-guardian-store.js';
|
|
19
|
+
import * as channelDeliveryStore from '../../memory/channel-delivery-store.js';
|
|
22
20
|
import { recordConversationSeenSignal } from '../../memory/conversation-attention-store.js';
|
|
23
21
|
import * as conversationStore from '../../memory/conversation-store.js';
|
|
24
22
|
import * as externalConversationStore from '../../memory/external-conversation-store.js';
|
|
25
|
-
import {
|
|
26
|
-
finalizeFollowup,
|
|
27
|
-
getDeliveriesByRequestId,
|
|
28
|
-
getExpiredDeliveriesByDestination,
|
|
29
|
-
getFollowupDeliveriesByDestination,
|
|
30
|
-
getGuardianActionRequest,
|
|
31
|
-
getPendingDeliveriesByDestination,
|
|
32
|
-
getPendingRequestByCallSessionId,
|
|
33
|
-
progressFollowupState,
|
|
34
|
-
resolveGuardianActionRequest,
|
|
35
|
-
startFollowupFromExpiredRequest,
|
|
36
|
-
} from '../../memory/guardian-action-store.js';
|
|
37
23
|
import { findMember, updateLastSeen, upsertMember } from '../../memory/ingress-member-store.js';
|
|
38
24
|
import { emitNotificationSignal } from '../../notifications/emit-signal.js';
|
|
39
25
|
import { checkIngressForSecrets } from '../../security/secret-ingress.js';
|
|
26
|
+
import { canonicalizeInboundIdentity } from '../../util/canonicalize-identity.js';
|
|
40
27
|
import { IngressBlockedError } from '../../util/errors.js';
|
|
41
28
|
import { getLogger } from '../../util/logger.js';
|
|
42
29
|
import { readHttpToken } from '../../util/platform.js';
|
|
30
|
+
import { notifyGuardianOfAccessRequest } from '../access-request-helper.js';
|
|
43
31
|
import {
|
|
44
32
|
buildApprovalUIMetadata,
|
|
45
33
|
getApprovalInfoByConversation,
|
|
@@ -58,11 +46,8 @@ import {
|
|
|
58
46
|
} from '../channel-guardian-service.js';
|
|
59
47
|
import { getTransport } from '../channel-invite-transport.js';
|
|
60
48
|
import { deliverChannelReply } from '../gateway-client.js';
|
|
61
|
-
import { processGuardianFollowUpTurn } from '../guardian-action-conversation-turn.js';
|
|
62
|
-
import { executeFollowupAction } from '../guardian-action-followup-executor.js';
|
|
63
|
-
import { tryMintGuardianActionGrant } from '../guardian-action-grant-minter.js';
|
|
64
|
-
import { composeGuardianActionMessageGenerative } from '../guardian-action-message-composer.js';
|
|
65
49
|
import { resolveGuardianContext } from '../guardian-context-resolver.js';
|
|
50
|
+
import { routeGuardianReply } from '../guardian-reply-router.js';
|
|
66
51
|
import {
|
|
67
52
|
composeChannelVerifyReply,
|
|
68
53
|
composeVerificationTelegram,
|
|
@@ -115,8 +100,8 @@ export async function handleChannelInbound(
|
|
|
115
100
|
gatewayOriginSecret?: string,
|
|
116
101
|
approvalCopyGenerator?: ApprovalCopyGenerator,
|
|
117
102
|
approvalConversationGenerator?: ApprovalConversationGenerator,
|
|
118
|
-
|
|
119
|
-
|
|
103
|
+
_guardianActionCopyGenerator?: GuardianActionCopyGenerator,
|
|
104
|
+
_guardianFollowUpConversationGenerator?: GuardianFollowUpConversationGenerator,
|
|
120
105
|
): Promise<Response> {
|
|
121
106
|
// Reject requests that lack valid gateway-origin proof. This ensures
|
|
122
107
|
// channel inbound messages can only arrive via the gateway (which
|
|
@@ -202,6 +187,28 @@ export async function handleChannelInbound(
|
|
|
202
187
|
log.debug({ raw: assistantId, canonical: canonicalAssistantId }, 'Canonicalized channel assistant ID');
|
|
203
188
|
}
|
|
204
189
|
|
|
190
|
+
// Coerce senderExternalUserId to a string at the boundary — the field
|
|
191
|
+
// comes from unvalidated JSON and may be a number, object, or other
|
|
192
|
+
// non-string type. Non-string truthy values would throw inside
|
|
193
|
+
// canonicalizeInboundIdentity when it calls .trim().
|
|
194
|
+
const rawSenderId = body.senderExternalUserId != null
|
|
195
|
+
? String(body.senderExternalUserId)
|
|
196
|
+
: undefined;
|
|
197
|
+
|
|
198
|
+
// Canonicalize the sender identity so all trust lookups, member matching,
|
|
199
|
+
// and guardian binding comparisons use a normalized form. Phone-like
|
|
200
|
+
// channels (sms, voice, whatsapp) are normalized to E.164; non-phone
|
|
201
|
+
// channels pass through the platform-stable ID unchanged.
|
|
202
|
+
const canonicalSenderId = rawSenderId
|
|
203
|
+
? canonicalizeInboundIdentity(sourceChannel, rawSenderId)
|
|
204
|
+
: null;
|
|
205
|
+
|
|
206
|
+
// Track whether the original payload included a sender identity. A
|
|
207
|
+
// whitespace-only senderExternalUserId canonicalizes to null but still
|
|
208
|
+
// represents an explicit (malformed) identity claim that must enter the
|
|
209
|
+
// ACL deny path rather than bypassing it.
|
|
210
|
+
const hasSenderIdentityClaim = rawSenderId !== undefined;
|
|
211
|
+
|
|
205
212
|
// ── Ingress ACL enforcement ──
|
|
206
213
|
// Track the resolved member so the escalate branch can reference it after
|
|
207
214
|
// recordInbound (where we have a conversationId).
|
|
@@ -235,13 +242,18 @@ export async function handleChannelInbound(
|
|
|
235
242
|
sourceMetadata: body.sourceMetadata,
|
|
236
243
|
});
|
|
237
244
|
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
+
if (canonicalSenderId || hasSenderIdentityClaim) {
|
|
246
|
+
// Only perform member lookup when we have a usable canonical ID.
|
|
247
|
+
// Whitespace-only senders (hasSenderIdentityClaim=true but
|
|
248
|
+
// canonicalSenderId=null) skip the lookup and fall into the deny path.
|
|
249
|
+
if (canonicalSenderId) {
|
|
250
|
+
resolvedMember = findMember({
|
|
251
|
+
assistantId: canonicalAssistantId,
|
|
252
|
+
sourceChannel,
|
|
253
|
+
externalUserId: canonicalSenderId,
|
|
254
|
+
externalChatId,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
245
257
|
|
|
246
258
|
if (!resolvedMember) {
|
|
247
259
|
// Determine whether a verification-code bypass is warranted: only allow
|
|
@@ -288,7 +300,7 @@ export async function handleChannelInbound(
|
|
|
288
300
|
sourceChannel,
|
|
289
301
|
externalChatId,
|
|
290
302
|
externalMessageId,
|
|
291
|
-
senderExternalUserId:
|
|
303
|
+
senderExternalUserId: canonicalSenderId ?? rawSenderId,
|
|
292
304
|
senderName: body.senderName,
|
|
293
305
|
senderUsername: body.senderUsername,
|
|
294
306
|
replyCallbackUrl: body.replyCallbackUrl,
|
|
@@ -300,21 +312,22 @@ export async function handleChannelInbound(
|
|
|
300
312
|
}
|
|
301
313
|
|
|
302
314
|
if (denyNonMember) {
|
|
303
|
-
log.info({ sourceChannel, externalUserId:
|
|
315
|
+
log.info({ sourceChannel, externalUserId: canonicalSenderId }, 'Ingress ACL: no member record, denying');
|
|
304
316
|
|
|
305
317
|
// Notify the guardian about the access request so they can approve/deny.
|
|
306
|
-
//
|
|
307
|
-
// request
|
|
318
|
+
// Uses the shared helper which handles guardian binding lookup,
|
|
319
|
+
// deduplication, canonical request creation, and notification emission.
|
|
308
320
|
let guardianNotified = false;
|
|
309
321
|
try {
|
|
310
|
-
|
|
322
|
+
const accessResult = notifyGuardianOfAccessRequest({
|
|
311
323
|
canonicalAssistantId,
|
|
312
324
|
sourceChannel,
|
|
313
325
|
externalChatId,
|
|
314
|
-
senderExternalUserId:
|
|
326
|
+
senderExternalUserId: canonicalSenderId ?? rawSenderId,
|
|
315
327
|
senderName: body.senderName,
|
|
316
328
|
senderUsername: body.senderUsername,
|
|
317
329
|
});
|
|
330
|
+
guardianNotified = accessResult.notified;
|
|
318
331
|
} catch (err) {
|
|
319
332
|
log.error({ err, sourceChannel, externalChatId }, 'Failed to notify guardian of access request');
|
|
320
333
|
}
|
|
@@ -373,7 +386,7 @@ export async function handleChannelInbound(
|
|
|
373
386
|
sourceChannel,
|
|
374
387
|
externalChatId,
|
|
375
388
|
externalMessageId,
|
|
376
|
-
senderExternalUserId:
|
|
389
|
+
senderExternalUserId: canonicalSenderId ?? rawSenderId,
|
|
377
390
|
senderName: body.senderName,
|
|
378
391
|
senderUsername: body.senderUsername,
|
|
379
392
|
replyCallbackUrl: body.replyCallbackUrl,
|
|
@@ -393,14 +406,15 @@ export async function handleChannelInbound(
|
|
|
393
406
|
let guardianNotified = false;
|
|
394
407
|
if (resolvedMember.status !== 'blocked') {
|
|
395
408
|
try {
|
|
396
|
-
|
|
409
|
+
const accessResult = notifyGuardianOfAccessRequest({
|
|
397
410
|
canonicalAssistantId,
|
|
398
411
|
sourceChannel,
|
|
399
412
|
externalChatId,
|
|
400
|
-
senderExternalUserId:
|
|
413
|
+
senderExternalUserId: canonicalSenderId ?? rawSenderId,
|
|
401
414
|
senderName: body.senderName,
|
|
402
415
|
senderUsername: body.senderUsername,
|
|
403
416
|
});
|
|
417
|
+
guardianNotified = accessResult.notified;
|
|
404
418
|
} catch (err) {
|
|
405
419
|
log.error({ err, sourceChannel, externalChatId }, 'Failed to notify guardian of access request');
|
|
406
420
|
}
|
|
@@ -570,7 +584,7 @@ export async function handleChannelInbound(
|
|
|
570
584
|
conversationId: result.conversationId,
|
|
571
585
|
sourceChannel,
|
|
572
586
|
externalChatId,
|
|
573
|
-
externalUserId:
|
|
587
|
+
externalUserId: canonicalSenderId ?? rawSenderId ?? null,
|
|
574
588
|
displayName: body.senderName ?? null,
|
|
575
589
|
username: body.senderUsername ?? null,
|
|
576
590
|
});
|
|
@@ -600,19 +614,16 @@ export async function handleChannelInbound(
|
|
|
600
614
|
assistantId: canonicalAssistantId,
|
|
601
615
|
});
|
|
602
616
|
|
|
603
|
-
|
|
604
|
-
|
|
617
|
+
createCanonicalGuardianRequest({
|
|
618
|
+
kind: 'tool_approval',
|
|
619
|
+
sourceType: 'channel',
|
|
620
|
+
sourceChannel,
|
|
605
621
|
conversationId: result.conversationId,
|
|
606
|
-
|
|
607
|
-
channel: sourceChannel,
|
|
608
|
-
requesterExternalUserId: body.senderExternalUserId ?? '',
|
|
609
|
-
requesterChatId: externalChatId,
|
|
622
|
+
requesterExternalUserId: canonicalSenderId ?? rawSenderId ?? undefined,
|
|
610
623
|
guardianExternalUserId: binding.guardianExternalUserId,
|
|
611
|
-
guardianChatId: binding.guardianDeliveryChatId,
|
|
612
624
|
toolName: 'ingress_message',
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS,
|
|
625
|
+
questionText: 'Ingress policy requires guardian approval',
|
|
626
|
+
expiresAt: new Date(Date.now() + GUARDIAN_APPROVAL_TTL_MS).toISOString(),
|
|
616
627
|
});
|
|
617
628
|
|
|
618
629
|
// Emit notification signal through the unified pipeline (fire-and-forget).
|
|
@@ -633,7 +644,7 @@ export async function handleChannelInbound(
|
|
|
633
644
|
conversationId: result.conversationId,
|
|
634
645
|
sourceChannel,
|
|
635
646
|
externalChatId,
|
|
636
|
-
senderIdentifier: body.senderName || body.senderUsername ||
|
|
647
|
+
senderIdentifier: body.senderName || body.senderUsername || rawSenderId || 'Unknown sender',
|
|
637
648
|
eventId: result.eventId,
|
|
638
649
|
},
|
|
639
650
|
dedupeKey: `escalation:${result.eventId}`,
|
|
@@ -679,14 +690,14 @@ export async function handleChannelInbound(
|
|
|
679
690
|
commandIntent?.type === 'start' &&
|
|
680
691
|
typeof commandIntent.payload === 'string' &&
|
|
681
692
|
(commandIntent.payload as string).startsWith('gv_') &&
|
|
682
|
-
|
|
693
|
+
rawSenderId
|
|
683
694
|
) {
|
|
684
695
|
const bootstrapToken = (commandIntent.payload as string).slice(3);
|
|
685
696
|
const bootstrapSession = resolveBootstrapToken(canonicalAssistantId, sourceChannel, bootstrapToken);
|
|
686
697
|
|
|
687
698
|
if (bootstrapSession && bootstrapSession.status === 'pending_bootstrap') {
|
|
688
699
|
// Bind the pending_bootstrap session to the sender's identity
|
|
689
|
-
bindSessionIdentity(bootstrapSession.id,
|
|
700
|
+
bindSessionIdentity(bootstrapSession.id, rawSenderId!, externalChatId);
|
|
690
701
|
|
|
691
702
|
// Transition bootstrap session to awaiting_response
|
|
692
703
|
updateSessionStatus(bootstrapSession.id, 'awaiting_response');
|
|
@@ -696,7 +707,7 @@ export async function handleChannelInbound(
|
|
|
696
707
|
const newSession = createOutboundSession({
|
|
697
708
|
assistantId: canonicalAssistantId,
|
|
698
709
|
channel: sourceChannel,
|
|
699
|
-
expectedExternalUserId:
|
|
710
|
+
expectedExternalUserId: rawSenderId!,
|
|
700
711
|
expectedChatId: externalChatId,
|
|
701
712
|
identityBindingStatus: 'bound',
|
|
702
713
|
destinationAddress: externalChatId,
|
|
@@ -748,13 +759,13 @@ export async function handleChannelInbound(
|
|
|
748
759
|
!result.duplicate &&
|
|
749
760
|
shouldInterceptVerification &&
|
|
750
761
|
guardianVerifyCode !== undefined &&
|
|
751
|
-
|
|
762
|
+
rawSenderId
|
|
752
763
|
) {
|
|
753
764
|
const verifyResult = validateAndConsumeChallenge(
|
|
754
765
|
canonicalAssistantId,
|
|
755
766
|
sourceChannel,
|
|
756
767
|
guardianVerifyCode,
|
|
757
|
-
|
|
768
|
+
canonicalSenderId ?? rawSenderId!,
|
|
758
769
|
externalChatId,
|
|
759
770
|
body.senderUsername,
|
|
760
771
|
body.senderName,
|
|
@@ -766,7 +777,7 @@ export async function handleChannelInbound(
|
|
|
766
777
|
upsertMember({
|
|
767
778
|
assistantId: canonicalAssistantId,
|
|
768
779
|
sourceChannel,
|
|
769
|
-
externalUserId:
|
|
780
|
+
externalUserId: canonicalSenderId ?? rawSenderId!,
|
|
770
781
|
externalChatId,
|
|
771
782
|
status: 'active',
|
|
772
783
|
policy: 'allow',
|
|
@@ -777,7 +788,7 @@ export async function handleChannelInbound(
|
|
|
777
788
|
const verifyLogLabel = verifyResult.verificationType === 'trusted_contact'
|
|
778
789
|
? 'Trusted contact verified'
|
|
779
790
|
: 'Guardian verified';
|
|
780
|
-
log.info({ sourceChannel, externalUserId:
|
|
791
|
+
log.info({ sourceChannel, externalUserId: canonicalSenderId, verificationType: verifyResult.verificationType }, `${verifyLogLabel}: auto-upserted ingress member`);
|
|
781
792
|
|
|
782
793
|
// Emit activated signal when a trusted contact completes verification.
|
|
783
794
|
// Member record is persisted above before this event fires, satisfying
|
|
@@ -796,12 +807,12 @@ export async function handleChannelInbound(
|
|
|
796
807
|
},
|
|
797
808
|
contextPayload: {
|
|
798
809
|
sourceChannel,
|
|
799
|
-
externalUserId:
|
|
810
|
+
externalUserId: canonicalSenderId ?? rawSenderId!,
|
|
800
811
|
externalChatId,
|
|
801
812
|
senderName: body.senderName ?? null,
|
|
802
813
|
senderUsername: body.senderUsername ?? null,
|
|
803
814
|
},
|
|
804
|
-
dedupeKey: `trusted-contact:activated:${canonicalAssistantId}:${sourceChannel}:${
|
|
815
|
+
dedupeKey: `trusted-contact:activated:${canonicalAssistantId}:${sourceChannel}:${canonicalSenderId ?? rawSenderId!}`,
|
|
805
816
|
});
|
|
806
817
|
}
|
|
807
818
|
}
|
|
@@ -873,377 +884,107 @@ export async function handleChannelInbound(
|
|
|
873
884
|
});
|
|
874
885
|
}
|
|
875
886
|
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
|
|
881
|
-
//
|
|
882
|
-
//
|
|
887
|
+
// Legacy voice guardian action interception removed — all guardian reply
|
|
888
|
+
// routing now flows through the canonical router below (routeGuardianReply),
|
|
889
|
+
// which handles request code matching, callback parsing, and NL classification
|
|
890
|
+
// against canonical_guardian_requests.
|
|
891
|
+
|
|
892
|
+
// ── Actor role resolution ──
|
|
893
|
+
// Uses shared channel-agnostic resolution so all ingress paths classify
|
|
894
|
+
// guardian vs non-guardian actors the same way.
|
|
895
|
+
const guardianCtx: GuardianContext = resolveGuardianContext({
|
|
896
|
+
assistantId: canonicalAssistantId,
|
|
897
|
+
sourceChannel,
|
|
898
|
+
externalChatId,
|
|
899
|
+
senderExternalUserId: rawSenderId,
|
|
900
|
+
senderUsername: body.senderUsername,
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
// ── Canonical guardian reply router ──
|
|
904
|
+
// Attempts to route inbound messages through the canonical decision pipeline
|
|
905
|
+
// before falling through to the legacy approval interception. Handles
|
|
906
|
+
// deterministic callbacks (button presses), request code prefixes, and
|
|
907
|
+
// NL classification via the conversational approval engine.
|
|
883
908
|
if (
|
|
884
909
|
!result.duplicate &&
|
|
885
|
-
|
|
886
|
-
trimmedContent.length > 0 &&
|
|
887
|
-
|
|
888
|
-
|
|
910
|
+
replyCallbackUrl &&
|
|
911
|
+
(trimmedContent.length > 0 || hasCallbackData) &&
|
|
912
|
+
rawSenderId &&
|
|
913
|
+
guardianCtx.actorRole === 'guardian'
|
|
889
914
|
) {
|
|
890
|
-
//
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
break;
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
if (codeMatch) break;
|
|
919
|
-
}
|
|
920
|
-
|
|
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
|
-
);
|
|
935
|
-
try {
|
|
936
|
-
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: staleText, assistantId }, bearerToken);
|
|
937
|
-
} catch (err) {
|
|
938
|
-
log.error({ err, externalChatId }, 'Failed to deliver superseded terminal notice');
|
|
939
|
-
}
|
|
940
|
-
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'stale_superseded' });
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
|
|
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
|
-
}
|
|
948
|
-
|
|
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();
|
|
960
|
-
}
|
|
961
|
-
codeMatch = { delivery: singleDelivery, request: singleReq, state, answerText: text };
|
|
962
|
-
}
|
|
915
|
+
// Compute destination-scoped pending request hints so the router can
|
|
916
|
+
// discover canonical requests delivered to this chat even when the
|
|
917
|
+
// request lacks a guardianExternalUserId (e.g. voice-originated
|
|
918
|
+
// pending_question requests).
|
|
919
|
+
//
|
|
920
|
+
// When delivery-scoped matches exist, union them with any identity-
|
|
921
|
+
// based pending requests so that requests without delivery rows (e.g.
|
|
922
|
+
// tool_approval requests created inline) are not silently excluded.
|
|
923
|
+
// Pass undefined (not []) when there are zero combined results so the
|
|
924
|
+
// router's own identity-based fallback stays active.
|
|
925
|
+
const deliveryScopedPendingRequests = listPendingCanonicalGuardianRequestsByDestinationChat(
|
|
926
|
+
sourceChannel,
|
|
927
|
+
externalChatId,
|
|
928
|
+
);
|
|
929
|
+
let pendingRequestIds: string[] | undefined;
|
|
930
|
+
if (deliveryScopedPendingRequests.length > 0) {
|
|
931
|
+
const deliveryIds = new Set(deliveryScopedPendingRequests.map(r => r.id));
|
|
932
|
+
// Also include identity-based pending requests so we don't hide them
|
|
933
|
+
const identityId = canonicalSenderId ?? rawSenderId!;
|
|
934
|
+
const identityPending = listCanonicalGuardianRequests({
|
|
935
|
+
status: 'pending',
|
|
936
|
+
guardianExternalUserId: identityId,
|
|
937
|
+
});
|
|
938
|
+
for (const r of identityPending) {
|
|
939
|
+
deliveryIds.add(r.id);
|
|
963
940
|
}
|
|
941
|
+
pendingRequestIds = [...deliveryIds];
|
|
942
|
+
}
|
|
964
943
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
try {
|
|
985
|
-
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: unknownText, assistantId }, bearerToken);
|
|
986
|
-
} catch (err) {
|
|
987
|
-
log.error({ err, externalChatId }, 'Failed to deliver unknown code notice');
|
|
988
|
-
}
|
|
989
|
-
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'unknown_code' });
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
}
|
|
944
|
+
const routerResult = await routeGuardianReply({
|
|
945
|
+
messageText: trimmedContent,
|
|
946
|
+
channel: sourceChannel,
|
|
947
|
+
actor: {
|
|
948
|
+
externalUserId: canonicalSenderId ?? rawSenderId!,
|
|
949
|
+
channel: sourceChannel,
|
|
950
|
+
isTrusted: false,
|
|
951
|
+
},
|
|
952
|
+
conversationId: result.conversationId,
|
|
953
|
+
callbackData: body.callbackData,
|
|
954
|
+
pendingRequestIds,
|
|
955
|
+
approvalConversationGenerator,
|
|
956
|
+
channelDeliveryContext: {
|
|
957
|
+
replyCallbackUrl,
|
|
958
|
+
guardianChatId: externalChatId,
|
|
959
|
+
assistantId: canonicalAssistantId,
|
|
960
|
+
bearerToken,
|
|
961
|
+
},
|
|
962
|
+
});
|
|
993
963
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
);
|
|
964
|
+
if (routerResult.consumed) {
|
|
965
|
+
// Deliver reply text if the router produced one
|
|
966
|
+
if (routerResult.replyText) {
|
|
1013
967
|
try {
|
|
1014
|
-
await deliverChannelReply(replyCallbackUrl, {
|
|
968
|
+
await deliverChannelReply(replyCallbackUrl, {
|
|
969
|
+
chatId: externalChatId,
|
|
970
|
+
text: routerResult.replyText,
|
|
971
|
+
assistantId: canonicalAssistantId,
|
|
972
|
+
}, bearerToken);
|
|
1015
973
|
} catch (err) {
|
|
1016
|
-
log.error({ err, externalChatId }, 'Failed to deliver
|
|
974
|
+
log.error({ err, externalChatId }, 'Failed to deliver canonical router reply');
|
|
1017
975
|
}
|
|
1018
|
-
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'disambiguation_sent' });
|
|
1019
976
|
}
|
|
1020
977
|
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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' },
|
|
1035
|
-
{},
|
|
1036
|
-
guardianActionCopyGenerator,
|
|
1037
|
-
);
|
|
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');
|
|
1041
|
-
}
|
|
1042
|
-
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'answer_failed' });
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
const resolved = resolveGuardianActionRequest(request.id, answerText, sourceChannel, body.senderExternalUserId);
|
|
1046
|
-
|
|
1047
|
-
if (resolved) {
|
|
1048
|
-
await tryMintGuardianActionGrant({
|
|
1049
|
-
request,
|
|
1050
|
-
answerText,
|
|
1051
|
-
decisionChannel: sourceChannel,
|
|
1052
|
-
guardianExternalUserId: body.senderExternalUserId,
|
|
1053
|
-
approvalConversationGenerator,
|
|
1054
|
-
});
|
|
1055
|
-
|
|
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
|
-
{},
|
|
1062
|
-
guardianActionCopyGenerator,
|
|
1063
|
-
);
|
|
1064
|
-
try {
|
|
1065
|
-
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: relayedText, assistantId }, bearerToken);
|
|
1066
|
-
} catch (err) {
|
|
1067
|
-
log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice');
|
|
1068
|
-
}
|
|
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' });
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
|
|
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) !== null;
|
|
1091
|
-
} else if (turnResult.disposition === 'decline') {
|
|
1092
|
-
stateApplied = finalizeFollowup(request.id, 'declined') !== null;
|
|
1093
|
-
}
|
|
1094
|
-
|
|
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');
|
|
1106
|
-
}
|
|
1107
|
-
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianFollowUp: 'stale_ignored' });
|
|
1108
|
-
}
|
|
1109
|
-
|
|
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 () => {
|
|
1118
|
-
try {
|
|
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');
|
|
1127
|
-
}
|
|
1128
|
-
})();
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianFollowUp: turnResult.disposition });
|
|
1132
|
-
}
|
|
1133
|
-
|
|
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
|
-
{},
|
|
1183
|
-
guardianActionCopyGenerator,
|
|
1184
|
-
);
|
|
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' });
|
|
1195
|
-
}
|
|
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
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
// Call not active or no pending request — fall through to follow-up
|
|
1203
|
-
}
|
|
1204
|
-
|
|
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' });
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
}
|
|
978
|
+
return Response.json({
|
|
979
|
+
accepted: true,
|
|
980
|
+
duplicate: false,
|
|
981
|
+
eventId: result.eventId,
|
|
982
|
+
canonicalRouter: routerResult.type,
|
|
983
|
+
requestId: routerResult.requestId,
|
|
984
|
+
});
|
|
1233
985
|
}
|
|
1234
986
|
}
|
|
1235
987
|
|
|
1236
|
-
// ── Actor role resolution ──
|
|
1237
|
-
// Uses shared channel-agnostic resolution so all ingress paths classify
|
|
1238
|
-
// guardian vs non-guardian actors the same way.
|
|
1239
|
-
const guardianCtx: GuardianContext = resolveGuardianContext({
|
|
1240
|
-
assistantId: canonicalAssistantId,
|
|
1241
|
-
sourceChannel,
|
|
1242
|
-
externalChatId,
|
|
1243
|
-
senderExternalUserId: body.senderExternalUserId,
|
|
1244
|
-
senderUsername: body.senderUsername,
|
|
1245
|
-
});
|
|
1246
|
-
|
|
1247
988
|
// ── Approval interception ──
|
|
1248
989
|
// Keep this active whenever callback context is available.
|
|
1249
990
|
if (
|
|
@@ -1256,7 +997,7 @@ export async function handleChannelInbound(
|
|
|
1256
997
|
content: trimmedContent,
|
|
1257
998
|
externalChatId,
|
|
1258
999
|
sourceChannel,
|
|
1259
|
-
senderExternalUserId:
|
|
1000
|
+
senderExternalUserId: canonicalSenderId ?? rawSenderId,
|
|
1260
1001
|
replyCallbackUrl,
|
|
1261
1002
|
bearerToken,
|
|
1262
1003
|
guardianCtx,
|
|
@@ -1547,111 +1288,6 @@ async function handleInviteTokenIntercept(params: {
|
|
|
1547
1288
|
return Response.json({ accepted: true, eventId: dedupResult.eventId, denied: true, inviteRedemption: outcome.reason });
|
|
1548
1289
|
}
|
|
1549
1290
|
|
|
1550
|
-
// ---------------------------------------------------------------------------
|
|
1551
|
-
// Non-member access request notification
|
|
1552
|
-
// ---------------------------------------------------------------------------
|
|
1553
|
-
|
|
1554
|
-
/**
|
|
1555
|
-
* Fire-and-forget: look up the guardian binding and, if present, create an
|
|
1556
|
-
* approval request + emit a notification signal so the guardian can
|
|
1557
|
-
* approve/deny the unknown user. Deduplicates by checking for an existing
|
|
1558
|
-
* pending approval for the same (requester, assistant, channel).
|
|
1559
|
-
*/
|
|
1560
|
-
function notifyGuardianOfAccessRequest(params: {
|
|
1561
|
-
canonicalAssistantId: string;
|
|
1562
|
-
sourceChannel: ChannelId;
|
|
1563
|
-
externalChatId: string;
|
|
1564
|
-
senderExternalUserId?: string;
|
|
1565
|
-
senderName?: string;
|
|
1566
|
-
senderUsername?: string;
|
|
1567
|
-
}): boolean {
|
|
1568
|
-
const {
|
|
1569
|
-
canonicalAssistantId,
|
|
1570
|
-
sourceChannel,
|
|
1571
|
-
externalChatId,
|
|
1572
|
-
senderExternalUserId,
|
|
1573
|
-
senderName,
|
|
1574
|
-
senderUsername,
|
|
1575
|
-
} = params;
|
|
1576
|
-
|
|
1577
|
-
if (!senderExternalUserId) return false;
|
|
1578
|
-
|
|
1579
|
-
const binding = getGuardianBinding(canonicalAssistantId, sourceChannel);
|
|
1580
|
-
if (!binding) {
|
|
1581
|
-
log.debug({ sourceChannel, canonicalAssistantId }, 'No guardian binding for access request notification');
|
|
1582
|
-
return false;
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
// Deduplicate: skip if there is already a pending approval request for
|
|
1586
|
-
// the same requester on this channel. Still return true — the guardian
|
|
1587
|
-
// was already notified for this request.
|
|
1588
|
-
const existing = findPendingAccessRequestForRequester(
|
|
1589
|
-
canonicalAssistantId,
|
|
1590
|
-
sourceChannel,
|
|
1591
|
-
senderExternalUserId,
|
|
1592
|
-
'ingress_access_request',
|
|
1593
|
-
);
|
|
1594
|
-
if (existing) {
|
|
1595
|
-
log.debug(
|
|
1596
|
-
{ sourceChannel, senderExternalUserId, existingId: existing.id },
|
|
1597
|
-
'Skipping duplicate access request notification',
|
|
1598
|
-
);
|
|
1599
|
-
return true;
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
const senderIdentifier = senderName || senderUsername || senderExternalUserId;
|
|
1603
|
-
const requestId = `access-req-${canonicalAssistantId}-${sourceChannel}-${senderExternalUserId}-${Date.now()}`;
|
|
1604
|
-
|
|
1605
|
-
const approvalRequest = createApprovalRequest({
|
|
1606
|
-
runId: `ingress-access-request-${Date.now()}`,
|
|
1607
|
-
requestId,
|
|
1608
|
-
conversationId: `access-req-${sourceChannel}-${senderExternalUserId}`,
|
|
1609
|
-
assistantId: canonicalAssistantId,
|
|
1610
|
-
channel: sourceChannel,
|
|
1611
|
-
requesterExternalUserId: senderExternalUserId,
|
|
1612
|
-
requesterChatId: externalChatId,
|
|
1613
|
-
guardianExternalUserId: binding.guardianExternalUserId,
|
|
1614
|
-
guardianChatId: binding.guardianDeliveryChatId,
|
|
1615
|
-
toolName: 'ingress_access_request',
|
|
1616
|
-
riskLevel: 'access_request',
|
|
1617
|
-
reason: `${senderIdentifier} is requesting access to the assistant`,
|
|
1618
|
-
expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS,
|
|
1619
|
-
});
|
|
1620
|
-
|
|
1621
|
-
void emitNotificationSignal({
|
|
1622
|
-
sourceEventName: 'ingress.access_request',
|
|
1623
|
-
sourceChannel,
|
|
1624
|
-
sourceSessionId: `access-req-${sourceChannel}-${senderExternalUserId}`,
|
|
1625
|
-
assistantId: canonicalAssistantId,
|
|
1626
|
-
attentionHints: {
|
|
1627
|
-
requiresAction: true,
|
|
1628
|
-
urgency: 'high',
|
|
1629
|
-
isAsyncBackground: false,
|
|
1630
|
-
visibleInSourceNow: false,
|
|
1631
|
-
},
|
|
1632
|
-
contextPayload: {
|
|
1633
|
-
requestId,
|
|
1634
|
-
sourceChannel,
|
|
1635
|
-
externalChatId,
|
|
1636
|
-
senderExternalUserId,
|
|
1637
|
-
senderName: senderName ?? null,
|
|
1638
|
-
senderUsername: senderUsername ?? null,
|
|
1639
|
-
senderIdentifier,
|
|
1640
|
-
},
|
|
1641
|
-
// Scoped to the approval request ID so duplicate notifications for the
|
|
1642
|
-
// same request are suppressed, but a new request (after deny/expire)
|
|
1643
|
-
// gets its own dedupe key and the guardian is notified again.
|
|
1644
|
-
dedupeKey: `access-request:${approvalRequest.id}`,
|
|
1645
|
-
});
|
|
1646
|
-
|
|
1647
|
-
log.info(
|
|
1648
|
-
{ sourceChannel, senderExternalUserId, senderIdentifier },
|
|
1649
|
-
'Guardian notified of non-member access request',
|
|
1650
|
-
);
|
|
1651
|
-
|
|
1652
|
-
return true;
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
1291
|
// ---------------------------------------------------------------------------
|
|
1656
1292
|
// Background message processing
|
|
1657
1293
|
// ---------------------------------------------------------------------------
|
|
@@ -1733,6 +1369,7 @@ function startPendingApprovalPromptWatcher(params: {
|
|
|
1733
1369
|
conversationId: string;
|
|
1734
1370
|
sourceChannel: ChannelId;
|
|
1735
1371
|
externalChatId: string;
|
|
1372
|
+
guardianActorRole: GuardianContext['actorRole'];
|
|
1736
1373
|
replyCallbackUrl: string;
|
|
1737
1374
|
bearerToken?: string;
|
|
1738
1375
|
assistantId?: string;
|
|
@@ -1742,12 +1379,19 @@ function startPendingApprovalPromptWatcher(params: {
|
|
|
1742
1379
|
conversationId,
|
|
1743
1380
|
sourceChannel,
|
|
1744
1381
|
externalChatId,
|
|
1382
|
+
guardianActorRole,
|
|
1745
1383
|
replyCallbackUrl,
|
|
1746
1384
|
bearerToken,
|
|
1747
1385
|
assistantId,
|
|
1748
1386
|
approvalCopyGenerator,
|
|
1749
1387
|
} = params;
|
|
1750
1388
|
|
|
1389
|
+
// Approval prompt delivery is guardian-only. Non-guardian and unverified
|
|
1390
|
+
// actors must never receive approval prompt broadcasts for the conversation.
|
|
1391
|
+
if (guardianActorRole !== 'guardian') {
|
|
1392
|
+
return () => {};
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1751
1395
|
let active = true;
|
|
1752
1396
|
const deliveredRequestIds = new Set<string>();
|
|
1753
1397
|
|
|
@@ -1826,6 +1470,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
|
|
|
1826
1470
|
conversationId,
|
|
1827
1471
|
sourceChannel,
|
|
1828
1472
|
externalChatId,
|
|
1473
|
+
guardianActorRole: guardianCtx.actorRole,
|
|
1829
1474
|
replyCallbackUrl,
|
|
1830
1475
|
bearerToken,
|
|
1831
1476
|
assistantId,
|