@vellumai/assistant 0.3.18 → 0.3.20
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 +155 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/docs/architecture/integrations.md +7 -11
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -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 +605 -104
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/checker.test.ts +60 -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 +779 -0
- 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 +185 -1
- package/src/__tests__/guardian-grant-minting.test.ts +532 -0
- 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 +58 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -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 +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
- 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__/trust-store.test.ts +2 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
- 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 +276 -212
- package/src/calls/call-domain.ts +56 -6
- package/src/calls/guardian-dispatch.ts +56 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +59 -4
- package/src/cli/core-commands.ts +0 -4
- 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 +18 -0
- 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/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +12 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +26 -0
- 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 +1 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -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/config-channels.ts +18 -0
- 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/handlers/skills.ts +45 -2
- 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/skills.ts +1 -0
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +6 -2
- package/src/daemon/main.ts +1 -0
- 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 +260 -422
- 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/tool-side-effects.ts +35 -9
- package/src/index.ts +0 -2
- 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 +13 -1
- package/src/memory/embedding-local.ts +22 -8
- package/src/memory/guardian-action-store.ts +133 -2
- package/src/memory/guardian-verification.ts +1 -1
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/schema.ts +35 -1
- package/src/memory/scoped-approval-grants.ts +518 -0
- 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/permissions/checker.ts +27 -0
- 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 +154 -0
- 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 -74
- package/src/runtime/routes/inbound-message-handler.ts +568 -409
- 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 +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
- 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/network/script-proxy/session-manager.ts +1 -5
- 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 +6 -0
- 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,
|
|
@@ -79,6 +90,8 @@ import {
|
|
|
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';
|
|
94
|
+
|
|
82
95
|
const log = getLogger('runtime-http');
|
|
83
96
|
|
|
84
97
|
/**
|
|
@@ -209,6 +222,19 @@ export async function handleChannelInbound(
|
|
|
209
222
|
typeof (rawCommandIntentForAcl as Record<string, unknown>).payload === 'string' &&
|
|
210
223
|
((rawCommandIntentForAcl as Record<string, unknown>).payload as string).startsWith('gv_');
|
|
211
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
|
+
|
|
212
238
|
if (body.senderExternalUserId) {
|
|
213
239
|
resolvedMember = findMember({
|
|
214
240
|
assistantId: canonicalAssistantId,
|
|
@@ -252,25 +278,36 @@ export async function handleChannelInbound(
|
|
|
252
278
|
}
|
|
253
279
|
}
|
|
254
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
|
+
|
|
255
302
|
if (denyNonMember) {
|
|
256
303
|
log.info({ sourceChannel, externalUserId: body.senderExternalUserId }, 'Ingress ACL: no member record, denying');
|
|
257
|
-
if (body.replyCallbackUrl) {
|
|
258
|
-
try {
|
|
259
|
-
await deliverChannelReply(body.replyCallbackUrl, {
|
|
260
|
-
chatId: externalChatId,
|
|
261
|
-
text: "Sorry, you haven't been approved to message this assistant. You can ask its Guardian for an invite.",
|
|
262
|
-
assistantId,
|
|
263
|
-
}, bearerToken);
|
|
264
|
-
} catch (err) {
|
|
265
|
-
log.error({ err, externalChatId }, 'Failed to deliver ACL rejection reply');
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
304
|
|
|
269
305
|
// Notify the guardian about the access request so they can approve/deny.
|
|
270
306
|
// Only fires when a guardian binding exists and no duplicate pending
|
|
271
307
|
// request already exists for this requester.
|
|
308
|
+
let guardianNotified = false;
|
|
272
309
|
try {
|
|
273
|
-
notifyGuardianOfAccessRequest({
|
|
310
|
+
guardianNotified = notifyGuardianOfAccessRequest({
|
|
274
311
|
canonicalAssistantId,
|
|
275
312
|
sourceChannel,
|
|
276
313
|
externalChatId,
|
|
@@ -282,25 +319,109 @@ export async function handleChannelInbound(
|
|
|
282
319
|
log.error({ err, sourceChannel, externalChatId }, 'Failed to notify guardian of access request');
|
|
283
320
|
}
|
|
284
321
|
|
|
285
|
-
return Response.json({ accepted: true, denied: true, reason: 'not_a_member' });
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (resolvedMember) {
|
|
290
|
-
if (resolvedMember.status !== 'active') {
|
|
291
|
-
log.info({ sourceChannel, memberId: resolvedMember.id, status: resolvedMember.status }, 'Ingress ACL: member not active, denying');
|
|
292
322
|
if (body.replyCallbackUrl) {
|
|
323
|
+
const replyText = guardianNotified
|
|
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."
|
|
325
|
+
: "Sorry, you haven't been approved to message this assistant.";
|
|
293
326
|
try {
|
|
294
327
|
await deliverChannelReply(body.replyCallbackUrl, {
|
|
295
328
|
chatId: externalChatId,
|
|
296
|
-
text:
|
|
329
|
+
text: replyText,
|
|
297
330
|
assistantId,
|
|
298
331
|
}, bearerToken);
|
|
299
332
|
} catch (err) {
|
|
300
333
|
log.error({ err, externalChatId }, 'Failed to deliver ACL rejection reply');
|
|
301
334
|
}
|
|
302
335
|
}
|
|
303
|
-
|
|
336
|
+
|
|
337
|
+
return Response.json({ accepted: true, denied: true, reason: 'not_a_member' });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (resolvedMember) {
|
|
342
|
+
if (resolvedMember.status !== 'active') {
|
|
343
|
+
// Same bypass logic as the no-member branch: verification codes and
|
|
344
|
+
// bootstrap commands must pass through even when the member record is
|
|
345
|
+
// revoked/blocked — otherwise the user can never re-verify.
|
|
346
|
+
let denyInactiveMember = true;
|
|
347
|
+
if (isGuardianVerifyCode) {
|
|
348
|
+
const hasPendingChallenge = !!getPendingChallenge(canonicalAssistantId, sourceChannel);
|
|
349
|
+
const hasActiveOutboundSession = !!findActiveSession(canonicalAssistantId, sourceChannel);
|
|
350
|
+
if (hasPendingChallenge || hasActiveOutboundSession) {
|
|
351
|
+
denyInactiveMember = false;
|
|
352
|
+
} else {
|
|
353
|
+
log.info({ sourceChannel, memberId: resolvedMember.id, hasPendingChallenge, hasActiveOutboundSession }, 'Ingress ACL: inactive member verification bypass denied');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (isBootstrapCommand) {
|
|
357
|
+
const bootstrapPayload = (rawCommandIntentForAcl as Record<string, unknown>).payload as string;
|
|
358
|
+
const bootstrapTokenForAcl = bootstrapPayload.slice(3);
|
|
359
|
+
const bootstrapSessionForAcl = resolveBootstrapToken(canonicalAssistantId, sourceChannel, bootstrapTokenForAcl);
|
|
360
|
+
if (bootstrapSessionForAcl && bootstrapSessionForAcl.status === 'pending_bootstrap') {
|
|
361
|
+
denyInactiveMember = false;
|
|
362
|
+
} else {
|
|
363
|
+
log.info({ sourceChannel, memberId: resolvedMember.id, hasValidBootstrapSession: false }, 'Ingress ACL: inactive member bootstrap bypass denied');
|
|
364
|
+
}
|
|
365
|
+
}
|
|
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
|
+
|
|
387
|
+
if (denyInactiveMember) {
|
|
388
|
+
log.info({ sourceChannel, memberId: resolvedMember.id, status: resolvedMember.status }, 'Ingress ACL: member not active, denying');
|
|
389
|
+
|
|
390
|
+
// For revoked/pending members, notify the guardian so they can
|
|
391
|
+
// re-approve. Blocked members are intentionally excluded — the
|
|
392
|
+
// guardian already made an explicit decision to block them.
|
|
393
|
+
let guardianNotified = false;
|
|
394
|
+
if (resolvedMember.status !== 'blocked') {
|
|
395
|
+
try {
|
|
396
|
+
guardianNotified = notifyGuardianOfAccessRequest({
|
|
397
|
+
canonicalAssistantId,
|
|
398
|
+
sourceChannel,
|
|
399
|
+
externalChatId,
|
|
400
|
+
senderExternalUserId: body.senderExternalUserId,
|
|
401
|
+
senderName: body.senderName,
|
|
402
|
+
senderUsername: body.senderUsername,
|
|
403
|
+
});
|
|
404
|
+
} catch (err) {
|
|
405
|
+
log.error({ err, sourceChannel, externalChatId }, 'Failed to notify guardian of access request');
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (body.replyCallbackUrl) {
|
|
410
|
+
const replyText = guardianNotified
|
|
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."
|
|
412
|
+
: "Sorry, you haven't been approved to message this assistant.";
|
|
413
|
+
try {
|
|
414
|
+
await deliverChannelReply(body.replyCallbackUrl, {
|
|
415
|
+
chatId: externalChatId,
|
|
416
|
+
text: replyText,
|
|
417
|
+
assistantId,
|
|
418
|
+
}, bearerToken);
|
|
419
|
+
} catch (err) {
|
|
420
|
+
log.error({ err, externalChatId }, 'Failed to deliver ACL rejection reply');
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return Response.json({ accepted: true, denied: true, reason: `member_${resolvedMember.status}` });
|
|
424
|
+
}
|
|
304
425
|
}
|
|
305
426
|
|
|
306
427
|
if (resolvedMember.policy === 'deny') {
|
|
@@ -309,7 +430,7 @@ export async function handleChannelInbound(
|
|
|
309
430
|
try {
|
|
310
431
|
await deliverChannelReply(body.replyCallbackUrl, {
|
|
311
432
|
chatId: externalChatId,
|
|
312
|
-
text: "Sorry, you haven't been approved to message this assistant.
|
|
433
|
+
text: "Sorry, you haven't been approved to message this assistant.",
|
|
313
434
|
assistantId,
|
|
314
435
|
}, bearerToken);
|
|
315
436
|
} catch (err) {
|
|
@@ -752,12 +873,13 @@ export async function handleChannelInbound(
|
|
|
752
873
|
});
|
|
753
874
|
}
|
|
754
875
|
|
|
755
|
-
// ──
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
// 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.
|
|
761
883
|
if (
|
|
762
884
|
!result.duplicate &&
|
|
763
885
|
!hasCallbackData &&
|
|
@@ -765,430 +887,346 @@ export async function handleChannelInbound(
|
|
|
765
887
|
body.senderExternalUserId &&
|
|
766
888
|
replyCallbackUrl
|
|
767
889
|
) {
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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;
|
|
788
916
|
}
|
|
917
|
+
}
|
|
918
|
+
if (codeMatch) break;
|
|
919
|
+
}
|
|
789
920
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
+
);
|
|
798
935
|
try {
|
|
799
|
-
await deliverChannelReply(replyCallbackUrl, {
|
|
800
|
-
chatId: externalChatId,
|
|
801
|
-
text: `You have multiple pending guardian questions. Please prefix your reply with the reference code (${codes.join(', ')}) to indicate which question you are answering.`,
|
|
802
|
-
assistantId,
|
|
803
|
-
}, bearerToken);
|
|
936
|
+
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: staleText, assistantId }, bearerToken);
|
|
804
937
|
} catch (err) {
|
|
805
|
-
log.error({ err, externalChatId }, 'Failed to deliver
|
|
938
|
+
log.error({ err, externalChatId }, 'Failed to deliver superseded terminal notice');
|
|
806
939
|
}
|
|
807
|
-
return Response.json({
|
|
808
|
-
accepted: true,
|
|
809
|
-
duplicate: false,
|
|
810
|
-
eventId: result.eventId,
|
|
811
|
-
guardianAnswer: 'disambiguation_sent',
|
|
812
|
-
});
|
|
940
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'stale_superseded' });
|
|
813
941
|
}
|
|
814
942
|
}
|
|
815
943
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
// the guardian action request if answerCall succeeds, so that a
|
|
821
|
-
// failed delivery (e.g. pending question timed out) leaves the
|
|
822
|
-
// request pending for retry from another channel.
|
|
823
|
-
const answerResult = await answerCall({ callSessionId: request.callSessionId, answer: answerText });
|
|
824
|
-
|
|
825
|
-
if (!('ok' in answerResult) || !answerResult.ok) {
|
|
826
|
-
const errorMsg = 'error' in answerResult ? answerResult.error : 'Unknown error';
|
|
827
|
-
log.warn({ callSessionId: request.callSessionId, error: errorMsg }, 'answerCall failed for guardian answer');
|
|
828
|
-
try {
|
|
829
|
-
const failureText = await composeGuardianActionMessageGenerative(
|
|
830
|
-
{ scenario: 'guardian_answer_delivery_failed' },
|
|
831
|
-
{},
|
|
832
|
-
guardianActionCopyGenerator,
|
|
833
|
-
);
|
|
834
|
-
await deliverChannelReply(replyCallbackUrl, {
|
|
835
|
-
chatId: externalChatId,
|
|
836
|
-
text: failureText,
|
|
837
|
-
assistantId,
|
|
838
|
-
}, bearerToken);
|
|
839
|
-
} catch (deliverErr) {
|
|
840
|
-
log.error({ err: deliverErr, externalChatId }, 'Failed to deliver guardian answer failure notice');
|
|
841
|
-
}
|
|
842
|
-
return Response.json({
|
|
843
|
-
accepted: true,
|
|
844
|
-
duplicate: false,
|
|
845
|
-
eventId: result.eventId,
|
|
846
|
-
guardianAnswer: 'answer_failed',
|
|
847
|
-
});
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
const resolved = resolveGuardianActionRequest(
|
|
851
|
-
request.id,
|
|
852
|
-
answerText,
|
|
853
|
-
sourceChannel,
|
|
854
|
-
body.senderExternalUserId,
|
|
855
|
-
);
|
|
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
|
+
}
|
|
856
948
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
// negotiation; instead tell the guardian the answer was relayed.
|
|
869
|
-
const freshRequest = getGuardianActionRequest(request.id);
|
|
870
|
-
|
|
871
|
-
// answerCall succeeded, so the answer was delivered regardless
|
|
872
|
-
// of the resolve race. Inform the guardian accordingly.
|
|
873
|
-
const relayedText = await composeGuardianActionMessageGenerative(
|
|
874
|
-
{
|
|
875
|
-
scenario: 'guardian_stale_answered' as const,
|
|
876
|
-
},
|
|
877
|
-
{},
|
|
878
|
-
guardianActionCopyGenerator,
|
|
879
|
-
);
|
|
880
|
-
try {
|
|
881
|
-
await deliverChannelReply(replyCallbackUrl, {
|
|
882
|
-
chatId: externalChatId,
|
|
883
|
-
text: relayedText,
|
|
884
|
-
assistantId,
|
|
885
|
-
}, bearerToken);
|
|
886
|
-
} catch (err) {
|
|
887
|
-
log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice');
|
|
888
|
-
}
|
|
889
|
-
log.info(
|
|
890
|
-
{ requestId: request.id, freshStatus: freshRequest?.status },
|
|
891
|
-
'answerCall succeeded but resolveGuardianActionRequest returned null — informed guardian answer was relayed',
|
|
892
|
-
);
|
|
893
|
-
return Response.json({
|
|
894
|
-
accepted: true,
|
|
895
|
-
duplicate: false,
|
|
896
|
-
eventId: result.eventId,
|
|
897
|
-
guardianAnswer: 'stale',
|
|
898
|
-
});
|
|
899
|
-
}
|
|
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();
|
|
900
960
|
}
|
|
961
|
+
codeMatch = { delivery: singleDelivery, request: singleReq, state, answerText: text };
|
|
901
962
|
}
|
|
902
963
|
}
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
964
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
if (validExpired.length > 0) {
|
|
925
|
-
let matchedExpired = validExpired.length === 1 ? validExpired[0] : null;
|
|
926
|
-
let expiredAnswerText = trimmedContent;
|
|
927
|
-
|
|
928
|
-
// Multiple expired deliveries: require request code prefix for disambiguation
|
|
929
|
-
if (validExpired.length > 1) {
|
|
930
|
-
for (const d of validExpired) {
|
|
931
|
-
const req = getGuardianActionRequest(d.requestId);
|
|
932
|
-
if (req && trimmedContent.toUpperCase().startsWith(req.requestCode)) {
|
|
933
|
-
matchedExpired = d;
|
|
934
|
-
expiredAnswerText = trimmedContent.slice(req.requestCode.length).trim();
|
|
935
|
-
break;
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
if (!matchedExpired) {
|
|
940
|
-
// Send disambiguation message listing the request codes
|
|
941
|
-
const codes = validExpired
|
|
942
|
-
.map((d) => {
|
|
943
|
-
const req = getGuardianActionRequest(d.requestId);
|
|
944
|
-
return req ? req.requestCode : null;
|
|
945
|
-
})
|
|
946
|
-
.filter((code): code is string => typeof code === 'string' && code.length > 0);
|
|
947
|
-
const disambiguationText = await composeGuardianActionMessageGenerative(
|
|
948
|
-
{
|
|
949
|
-
scenario: 'guardian_expired_disambiguation',
|
|
950
|
-
requestCodes: codes,
|
|
951
|
-
channel: sourceChannel,
|
|
952
|
-
},
|
|
953
|
-
{ 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
|
+
{},
|
|
954
982
|
guardianActionCopyGenerator,
|
|
955
983
|
);
|
|
956
984
|
try {
|
|
957
|
-
await deliverChannelReply(replyCallbackUrl, {
|
|
958
|
-
chatId: externalChatId,
|
|
959
|
-
text: disambiguationText,
|
|
960
|
-
assistantId,
|
|
961
|
-
}, bearerToken);
|
|
985
|
+
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: unknownText, assistantId }, bearerToken);
|
|
962
986
|
} catch (err) {
|
|
963
|
-
log.error({ err, externalChatId }, 'Failed to deliver
|
|
987
|
+
log.error({ err, externalChatId }, 'Failed to deliver unknown code notice');
|
|
964
988
|
}
|
|
965
|
-
return Response.json({
|
|
966
|
-
accepted: true,
|
|
967
|
-
duplicate: false,
|
|
968
|
-
eventId: result.eventId,
|
|
969
|
-
guardianAnswer: 'disambiguation_sent',
|
|
970
|
-
});
|
|
989
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'unknown_code' });
|
|
971
990
|
}
|
|
972
991
|
}
|
|
992
|
+
}
|
|
973
993
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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' },
|
|
1010
1035
|
{},
|
|
1011
1036
|
guardianActionCopyGenerator,
|
|
1012
1037
|
);
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
text: staleText,
|
|
1017
|
-
assistantId,
|
|
1018
|
-
}, bearerToken);
|
|
1019
|
-
} catch (err) {
|
|
1020
|
-
log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice for expired follow-up race');
|
|
1021
|
-
}
|
|
1022
|
-
return Response.json({
|
|
1023
|
-
accepted: true,
|
|
1024
|
-
duplicate: false,
|
|
1025
|
-
eventId: result.eventId,
|
|
1026
|
-
guardianAnswer: 'stale',
|
|
1027
|
-
});
|
|
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');
|
|
1028
1041
|
}
|
|
1042
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'answer_failed' });
|
|
1029
1043
|
}
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
1044
|
|
|
1035
|
-
|
|
1036
|
-
// When a request is in `awaiting_guardian_choice` state, the guardian has
|
|
1037
|
-
// already been asked "call back or send a message?". Their next message
|
|
1038
|
-
// is the reply to that prompt — route it through the conversation engine
|
|
1039
|
-
// to classify their intent.
|
|
1040
|
-
if (
|
|
1041
|
-
!result.duplicate &&
|
|
1042
|
-
!hasCallbackData &&
|
|
1043
|
-
trimmedContent.length > 0 &&
|
|
1044
|
-
body.senderExternalUserId &&
|
|
1045
|
-
replyCallbackUrl
|
|
1046
|
-
) {
|
|
1047
|
-
const followupDeliveries = getFollowupDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId);
|
|
1048
|
-
if (followupDeliveries.length > 0) {
|
|
1049
|
-
const validFollowup = followupDeliveries.filter(
|
|
1050
|
-
(d) => d.destinationExternalUserId === body.senderExternalUserId,
|
|
1051
|
-
);
|
|
1045
|
+
const resolved = resolveGuardianActionRequest(request.id, answerText, sourceChannel, body.senderExternalUserId);
|
|
1052
1046
|
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
if (req && trimmedContent.toUpperCase().startsWith(req.requestCode)) {
|
|
1062
|
-
matchedFollowup = d;
|
|
1063
|
-
followupReplyText = trimmedContent.slice(req.requestCode.length).trim();
|
|
1064
|
-
break;
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1047
|
+
if (resolved) {
|
|
1048
|
+
await tryMintGuardianActionGrant({
|
|
1049
|
+
request,
|
|
1050
|
+
answerText,
|
|
1051
|
+
decisionChannel: sourceChannel,
|
|
1052
|
+
guardianExternalUserId: body.senderExternalUserId,
|
|
1053
|
+
approvalConversationGenerator,
|
|
1054
|
+
});
|
|
1067
1055
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
const
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
})
|
|
1075
|
-
.filter((code): code is string => typeof code === 'string' && code.length > 0);
|
|
1076
|
-
const disambiguationText = await composeGuardianActionMessageGenerative(
|
|
1077
|
-
{
|
|
1078
|
-
scenario: 'guardian_followup_disambiguation',
|
|
1079
|
-
requestCodes: codes,
|
|
1080
|
-
channel: sourceChannel,
|
|
1081
|
-
},
|
|
1082
|
-
{ 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
|
+
{},
|
|
1083
1062
|
guardianActionCopyGenerator,
|
|
1084
1063
|
);
|
|
1085
1064
|
try {
|
|
1086
|
-
await deliverChannelReply(replyCallbackUrl, {
|
|
1087
|
-
chatId: externalChatId,
|
|
1088
|
-
text: disambiguationText,
|
|
1089
|
-
assistantId,
|
|
1090
|
-
}, bearerToken);
|
|
1065
|
+
await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: relayedText, assistantId }, bearerToken);
|
|
1091
1066
|
} catch (err) {
|
|
1092
|
-
log.error({ err, externalChatId }, 'Failed to deliver guardian
|
|
1067
|
+
log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice');
|
|
1093
1068
|
}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
});
|
|
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' });
|
|
1100
1074
|
}
|
|
1101
1075
|
}
|
|
1102
1076
|
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
+
}
|
|
1115
1094
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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');
|
|
1126
1106
|
}
|
|
1127
|
-
|
|
1107
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianFollowUp: 'stale_ignored' });
|
|
1108
|
+
}
|
|
1128
1109
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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 () => {
|
|
1136
1118
|
try {
|
|
1137
|
-
await
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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');
|
|
1144
1127
|
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
duplicate: false,
|
|
1148
|
-
eventId: result.eventId,
|
|
1149
|
-
guardianFollowUp: 'stale_ignored',
|
|
1150
|
-
});
|
|
1151
|
-
}
|
|
1128
|
+
})();
|
|
1129
|
+
}
|
|
1152
1130
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
await deliverChannelReply(replyCallbackUrl, {
|
|
1156
|
-
chatId: externalChatId,
|
|
1157
|
-
text: turnResult.replyText,
|
|
1158
|
-
assistantId,
|
|
1159
|
-
}, bearerToken);
|
|
1160
|
-
} catch (err) {
|
|
1161
|
-
log.error({ err, externalChatId }, 'Failed to deliver guardian follow-up conversation reply');
|
|
1162
|
-
}
|
|
1131
|
+
return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianFollowUp: turnResult.disposition });
|
|
1132
|
+
}
|
|
1163
1133
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
+
{},
|
|
1173
1183
|
guardianActionCopyGenerator,
|
|
1174
1184
|
);
|
|
1175
|
-
|
|
1176
|
-
chatId: externalChatId,
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
|
|
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' });
|
|
1182
1195
|
}
|
|
1183
|
-
|
|
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
|
+
}
|
|
1184
1201
|
}
|
|
1202
|
+
// Call not active or no pending request — fall through to follow-up
|
|
1203
|
+
}
|
|
1185
1204
|
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
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' });
|
|
1192
1230
|
}
|
|
1193
1231
|
}
|
|
1194
1232
|
}
|
|
@@ -1391,6 +1429,124 @@ export async function handleChannelInbound(
|
|
|
1391
1429
|
});
|
|
1392
1430
|
}
|
|
1393
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
|
+
|
|
1394
1550
|
// ---------------------------------------------------------------------------
|
|
1395
1551
|
// Non-member access request notification
|
|
1396
1552
|
// ---------------------------------------------------------------------------
|
|
@@ -1408,7 +1564,7 @@ function notifyGuardianOfAccessRequest(params: {
|
|
|
1408
1564
|
senderExternalUserId?: string;
|
|
1409
1565
|
senderName?: string;
|
|
1410
1566
|
senderUsername?: string;
|
|
1411
|
-
}):
|
|
1567
|
+
}): boolean {
|
|
1412
1568
|
const {
|
|
1413
1569
|
canonicalAssistantId,
|
|
1414
1570
|
sourceChannel,
|
|
@@ -1418,16 +1574,17 @@ function notifyGuardianOfAccessRequest(params: {
|
|
|
1418
1574
|
senderUsername,
|
|
1419
1575
|
} = params;
|
|
1420
1576
|
|
|
1421
|
-
if (!senderExternalUserId) return;
|
|
1577
|
+
if (!senderExternalUserId) return false;
|
|
1422
1578
|
|
|
1423
1579
|
const binding = getGuardianBinding(canonicalAssistantId, sourceChannel);
|
|
1424
1580
|
if (!binding) {
|
|
1425
1581
|
log.debug({ sourceChannel, canonicalAssistantId }, 'No guardian binding for access request notification');
|
|
1426
|
-
return;
|
|
1582
|
+
return false;
|
|
1427
1583
|
}
|
|
1428
1584
|
|
|
1429
1585
|
// Deduplicate: skip if there is already a pending approval request for
|
|
1430
|
-
// the same requester on this channel.
|
|
1586
|
+
// the same requester on this channel. Still return true — the guardian
|
|
1587
|
+
// was already notified for this request.
|
|
1431
1588
|
const existing = findPendingAccessRequestForRequester(
|
|
1432
1589
|
canonicalAssistantId,
|
|
1433
1590
|
sourceChannel,
|
|
@@ -1439,7 +1596,7 @@ function notifyGuardianOfAccessRequest(params: {
|
|
|
1439
1596
|
{ sourceChannel, senderExternalUserId, existingId: existing.id },
|
|
1440
1597
|
'Skipping duplicate access request notification',
|
|
1441
1598
|
);
|
|
1442
|
-
return;
|
|
1599
|
+
return true;
|
|
1443
1600
|
}
|
|
1444
1601
|
|
|
1445
1602
|
const senderIdentifier = senderName || senderUsername || senderExternalUserId;
|
|
@@ -1491,6 +1648,8 @@ function notifyGuardianOfAccessRequest(params: {
|
|
|
1491
1648
|
{ sourceChannel, senderExternalUserId, senderIdentifier },
|
|
1492
1649
|
'Guardian notified of non-member access request',
|
|
1493
1650
|
);
|
|
1651
|
+
|
|
1652
|
+
return true;
|
|
1494
1653
|
}
|
|
1495
1654
|
|
|
1496
1655
|
// ---------------------------------------------------------------------------
|