@opengeni/api-router 0.22.2 → 0.26.1
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/dist/app.d.ts +2 -2
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth.d.ts +0 -30
- package/dist/browser-controller-authority.d.ts +43 -0
- package/dist/browser-state-authority.d.ts +27 -0
- package/dist/{chunk-HWXJW5C7.js → chunk-JIKNR5YL.js} +27993 -14546
- package/dist/chunk-JIKNR5YL.js.map +1 -0
- package/dist/codemode.d.ts +23 -0
- package/dist/editable-artifact-live-hints.d.ts +11 -0
- package/dist/editable-artifact-native-kernel.d.ts +37 -0
- package/dist/editable-artifact-office-import.d.ts +22 -0
- package/dist/editable-artifact-production.d.ts +29 -0
- package/dist/editable-artifact-websocket.d.ts +49 -0
- package/dist/editable-artifact-workspace-files.d.ts +23 -0
- package/dist/github-browser-flow.d.ts +6 -0
- package/dist/http/cors.d.ts +1 -0
- package/dist/http/sse.d.ts +2 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1824 -30
- package/dist/index.js.map +1 -1
- package/dist/integrations/api-integrations.d.ts +24 -0
- package/dist/integrations/atlassian.d.ts +176 -0
- package/dist/integrations/github-skill-source.d.ts +5 -0
- package/dist/integrations/google-drive.d.ts +85 -0
- package/dist/integrations/oauth-client.d.ts +30 -1
- package/dist/integrations/provider-oauth.d.ts +19 -0
- package/dist/integrations/slack-bot.d.ts +4 -0
- package/dist/integrations/slack-interactions.d.ts +14 -2
- package/dist/integrations/social-api.d.ts +2 -1
- package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
- package/dist/mcp/editable-artifacts.d.ts +13 -0
- package/dist/mcp/receipts.d.ts +28 -0
- package/dist/mcp/scheduled-task-view.d.ts +518 -0
- package/dist/mcp/server.d.ts +14 -3
- package/dist/memory-slack-delivery.d.ts +9 -0
- package/dist/routes/api-integrations.d.ts +8 -0
- package/dist/routes/browser-identities.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +6 -0
- package/dist/routes/company-profile.d.ts +3 -0
- package/dist/routes/computer-sessions.d.ts +6 -0
- package/dist/routes/editable-artifacts.d.ts +44 -0
- package/dist/routes/integration-features.d.ts +3 -0
- package/dist/routes/memory-slack-publications.d.ts +6 -0
- package/dist/routes/plugins.d.ts +8 -0
- package/dist/routes/sessions.d.ts +17 -2
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/video-generation.d.ts +3 -0
- package/dist/sandbox/auth-callout.d.ts +2 -0
- package/dist/sandbox/channel-a.d.ts +59 -2
- package/dist/sandbox/metrics-ingestion.d.ts +6 -1
- package/dist/sandbox/viewer.d.ts +4 -2
- package/dist/temporal-schedule-cleanup.d.ts +26 -0
- package/package.json +19 -14
- package/src/app.ts +277 -41
- package/src/auth/managed-auth.ts +0 -16
- package/src/browser-controller-authority.ts +137 -0
- package/src/browser-state-authority.ts +236 -0
- package/src/codemode.ts +186 -0
- package/src/editable-artifact-live-hints.ts +64 -0
- package/src/editable-artifact-native-kernel.ts +659 -0
- package/src/editable-artifact-office-import.ts +230 -0
- package/src/editable-artifact-production.ts +419 -0
- package/src/editable-artifact-websocket.ts +311 -0
- package/src/editable-artifact-workspace-files.ts +186 -0
- package/src/github-browser-flow.ts +35 -6
- package/src/http/auth.ts +2 -0
- package/src/http/cors.ts +3 -0
- package/src/http/sse.ts +101 -6
- package/src/index.ts +147 -23
- package/src/integrations/api-integrations.ts +350 -0
- package/src/integrations/atlassian.ts +1621 -0
- package/src/integrations/github-skill-source.ts +142 -0
- package/src/integrations/google-drive.ts +1000 -64
- package/src/integrations/oauth-client.ts +159 -89
- package/src/integrations/provider-oauth.ts +777 -0
- package/src/integrations/slack-bot.ts +31 -2
- package/src/integrations/slack-interactions.ts +610 -42
- package/src/integrations/social-api.ts +11 -0
- package/src/mcp/documents.ts +74 -26
- package/src/mcp/editable-artifact-query-schema.ts +236 -0
- package/src/mcp/editable-artifacts.ts +448 -0
- package/src/mcp/receipts.ts +95 -0
- package/src/mcp/scheduled-task-view.ts +642 -0
- package/src/mcp/server.ts +1718 -310
- package/src/memory-slack-delivery.ts +209 -0
- package/src/observability.ts +3 -3
- package/src/routes/api-integrations.ts +407 -0
- package/src/routes/api-keys.ts +7 -1
- package/src/routes/browser-identities.ts +136 -0
- package/src/routes/browser-sessions.ts +2543 -0
- package/src/routes/codex.ts +7 -4
- package/src/routes/company-profile.ts +255 -0
- package/src/routes/computer-sessions.ts +1247 -0
- package/src/routes/connections.ts +358 -102
- package/src/routes/documents.ts +22 -3
- package/src/routes/editable-artifacts.ts +1159 -0
- package/src/routes/enrollments.ts +54 -12
- package/src/routes/environments.ts +60 -11
- package/src/routes/files.ts +277 -65
- package/src/routes/github.ts +18 -2
- package/src/routes/install.ts +38 -2
- package/src/routes/integration-features.ts +258 -0
- package/src/routes/machines.ts +1 -1
- package/src/routes/memory-slack-publications.ts +216 -0
- package/src/routes/packs.ts +437 -7
- package/src/routes/plugins.ts +751 -0
- package/src/routes/rigs.ts +77 -20
- package/src/routes/scheduled-tasks.ts +94 -42
- package/src/routes/sessions.ts +475 -234
- package/src/routes/skills.ts +174 -0
- package/src/routes/transcription-recordings.ts +65 -33
- package/src/routes/video-generation.ts +132 -0
- package/src/routes/workspaces.ts +46 -24
- package/src/sandbox/auth-callout.ts +16 -4
- package/src/sandbox/channel-a.ts +809 -85
- package/src/sandbox/enrollment.ts +13 -3
- package/src/sandbox/machines.ts +1 -1
- package/src/sandbox/metrics-ingestion.ts +121 -3
- package/src/sandbox/rematerialize.ts +35 -47
- package/src/sandbox/viewer.ts +58 -29
- package/src/temporal-schedule-cleanup.ts +135 -0
- package/dist/chunk-HWXJW5C7.js.map +0 -1
- package/dist/mcp/toolspace.d.ts +0 -62
- package/src/mcp/toolspace.ts +0 -1186
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import {
|
|
3
|
+
ApproveSlackUserLinkAccessRequest,
|
|
3
4
|
DEFAULT_FIRST_PARTY_MCP_TOOLS,
|
|
4
5
|
hasOpenGeniSlackReactionScope,
|
|
6
|
+
ListSlackUserLinkAccessRequestsResponse,
|
|
7
|
+
PrepareSlackUserLinkAccessRequest,
|
|
5
8
|
resolveWorkspaceSlackReactionSummonSettings,
|
|
6
9
|
SlackReactionChannelListResponse,
|
|
10
|
+
SlackUserLinkAccessMutationRequest,
|
|
11
|
+
SlackUserLinkAccessRequest,
|
|
7
12
|
type AccessGrant,
|
|
8
13
|
type FirstPartyMcpToolName,
|
|
9
14
|
type HumanInputQuestion,
|
|
@@ -13,12 +18,15 @@ import {
|
|
|
13
18
|
} from "@opengeni/contracts";
|
|
14
19
|
import {
|
|
15
20
|
acceptSessionHumanInputResponse,
|
|
21
|
+
approveSlackUserLinkAccessRequest,
|
|
16
22
|
advanceSlackInteractionDelivery,
|
|
17
23
|
bindSlackInteractionSession,
|
|
24
|
+
cancelSlackUserLinkAccessRequest,
|
|
18
25
|
claimSlackInteractionDelivery,
|
|
19
26
|
claimSlackInteractionProgressDelivery,
|
|
20
27
|
claimSlackInteractionInbox,
|
|
21
28
|
closeSlackInteractionDelivery,
|
|
29
|
+
completeSlackUserLinkAccessIfGranted,
|
|
22
30
|
deferSlackInteractionDelivery,
|
|
23
31
|
deleteSlackBotUserLink,
|
|
24
32
|
enqueueSlackInteractionInbox,
|
|
@@ -31,16 +39,21 @@ import {
|
|
|
31
39
|
getSessionEventByClientEventId,
|
|
32
40
|
getWorkspace,
|
|
33
41
|
getWorkspaceGrant,
|
|
42
|
+
listSlackInteractionProgressDeliveryEvidence,
|
|
34
43
|
listSessionEventPage,
|
|
35
44
|
listSessionHumanInputRequests,
|
|
45
|
+
listPendingSlackUserLinkAccessRequests,
|
|
36
46
|
rekeySlackInteractionRoute,
|
|
37
47
|
reopenSlackInteractionDelivery,
|
|
38
48
|
releaseSlackInteractionDelivery,
|
|
39
49
|
releaseSlackInteractionInbox,
|
|
50
|
+
requestSlackUserLinkWorkspaceAccess,
|
|
40
51
|
resolveSlackInstallationRoute,
|
|
41
|
-
saveSlackBotUserLink,
|
|
42
52
|
saveSlackInteractionInboxReactionCheckpoint,
|
|
43
53
|
settleSlackInteractionInbox,
|
|
54
|
+
denySlackUserLinkAccessRequest,
|
|
55
|
+
prepareSlackUserLinkAccessRequest,
|
|
56
|
+
SlackUserLinkAccessPersistenceError,
|
|
44
57
|
type SlackInstallationRoute,
|
|
45
58
|
type SlackInteraction,
|
|
46
59
|
type SlackInteractionInboxEntry,
|
|
@@ -51,6 +64,7 @@ import {
|
|
|
51
64
|
controlHumanSessionWorkstream,
|
|
52
65
|
createSessionForRequest,
|
|
53
66
|
hasPermission,
|
|
67
|
+
requireAccessContext,
|
|
54
68
|
requireAccessGrant,
|
|
55
69
|
type ApiRouteDeps,
|
|
56
70
|
} from "@opengeni/core";
|
|
@@ -76,6 +90,8 @@ export const SLACK_DELIVERY_EVENT_TYPES = [
|
|
|
76
90
|
|
|
77
91
|
const MAX_SLACK_TEXT_CHARS = 3_500;
|
|
78
92
|
const MAX_SLACK_INPUT_CHARS = 8_000;
|
|
93
|
+
const MAX_SLACK_INVOCATION_CONTEXT_MESSAGES = 15;
|
|
94
|
+
const MAX_SLACK_CHANNEL_CONTEXT_MESSAGES = 5;
|
|
79
95
|
const MAX_SLACK_REACTION_CONTEXT_MESSAGES = 15;
|
|
80
96
|
const MAX_SLACK_REACTION_FILE_SUMMARY_CHARS = 1_500;
|
|
81
97
|
const MAX_PROGRESS_MESSAGES = 3;
|
|
@@ -120,6 +136,31 @@ export type NormalizedSlackInteraction = {
|
|
|
120
136
|
text: string;
|
|
121
137
|
};
|
|
122
138
|
|
|
139
|
+
export function slackInteractionRoutePolicy(
|
|
140
|
+
entry: Pick<
|
|
141
|
+
SlackInteractionInboxEntry,
|
|
142
|
+
"triggerKind" | "slackChannelId" | "slackThreadTs" | "slackMessageTs" | "slackUserId"
|
|
143
|
+
>,
|
|
144
|
+
) {
|
|
145
|
+
const directMessageShortcut = isDirectMessageShortcut(entry);
|
|
146
|
+
const source = slackRouteKey(entry.slackChannelId, entry.slackThreadTs ?? entry.slackMessageTs);
|
|
147
|
+
return {
|
|
148
|
+
directMessageShortcut,
|
|
149
|
+
requiresChannelAccess: !directMessageShortcut,
|
|
150
|
+
visibility:
|
|
151
|
+
entry.triggerKind === "dm" || directMessageShortcut
|
|
152
|
+
? ("private" as const)
|
|
153
|
+
: ("workspace" as const),
|
|
154
|
+
// A human-to-human DM may be shared by multiple linked workspace users. The
|
|
155
|
+
// signed shortcut authorizes only the invoking user, so the pre-ack route
|
|
156
|
+
// must keep each user's private reservation distinct until it is rekeyed to
|
|
157
|
+
// that user's OpenGeni bot-DM thread.
|
|
158
|
+
initialRouteKey: directMessageShortcut
|
|
159
|
+
? `${source}:shortcut-user:${entry.slackUserId}`
|
|
160
|
+
: source,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
123
164
|
export function verifySlackRequestSignature(
|
|
124
165
|
input: {
|
|
125
166
|
timestamp: string | null;
|
|
@@ -166,7 +207,11 @@ export function slackEventInboxEntry(
|
|
|
166
207
|
const text = boundedText(event.text);
|
|
167
208
|
if (!userId || !channelId || !timestamp || !text) return null;
|
|
168
209
|
let triggerKind: SlackInteractionTriggerKind;
|
|
169
|
-
|
|
210
|
+
const explicitlyMentionsBot = text.includes(`<@${bot.botUserId}>`);
|
|
211
|
+
if (
|
|
212
|
+
event.type === "app_mention" ||
|
|
213
|
+
(event.type === "message" && threadTimestamp && explicitlyMentionsBot)
|
|
214
|
+
) {
|
|
170
215
|
// A mention is always an explicit invocation. In particular, a mention in
|
|
171
216
|
// an otherwise-unmapped existing thread adopts that thread as the new
|
|
172
217
|
// OpenGeni session surface; only ordinary message replies require a
|
|
@@ -365,14 +410,218 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
365
410
|
return c.json({ ok: true });
|
|
366
411
|
});
|
|
367
412
|
|
|
413
|
+
app.post("/v1/workspaces/:workspaceId/integrations/slack/user-link-intents", async (c) => {
|
|
414
|
+
const workspaceId = c.req.param("workspaceId");
|
|
415
|
+
const context = await requireManagedSlackLinkHuman(c, deps);
|
|
416
|
+
const payload = PrepareSlackUserLinkAccessRequest.parse(await c.req.json());
|
|
417
|
+
const signingSecret = deps.settings.slackSigningSecret;
|
|
418
|
+
const link = signingSecret ? verifySlackUserLinkToken(signingSecret, payload.linkToken) : null;
|
|
419
|
+
if (!link || link.workspaceId !== workspaceId) {
|
|
420
|
+
throw freshSlackLinkRequired();
|
|
421
|
+
}
|
|
422
|
+
const route = await resolveSlackInstallationRoute(deps.db, link.slackTeamId);
|
|
423
|
+
if (
|
|
424
|
+
!route ||
|
|
425
|
+
route.workspaceId !== workspaceId ||
|
|
426
|
+
route.connectionId !== link.connectionId ||
|
|
427
|
+
route.accountId.length === 0
|
|
428
|
+
) {
|
|
429
|
+
throw freshSlackLinkRequired();
|
|
430
|
+
}
|
|
431
|
+
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
432
|
+
if (!workspace || workspace.accountId !== route.accountId) {
|
|
433
|
+
throw freshSlackLinkRequired();
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
const prepared = await prepareSlackUserLinkAccessRequest(deps.db, {
|
|
437
|
+
accountId: route.accountId,
|
|
438
|
+
workspaceId,
|
|
439
|
+
tokenDigest: createHash("sha256").update(payload.linkToken).digest("hex"),
|
|
440
|
+
connectionId: link.connectionId,
|
|
441
|
+
slackTeamId: link.slackTeamId,
|
|
442
|
+
slackUserId: link.slackUserId,
|
|
443
|
+
subjectId: context.subjectId,
|
|
444
|
+
subjectLabel: boundedString(context.subjectLabel, 512),
|
|
445
|
+
expiresAt: new Date(link.expiresAt),
|
|
446
|
+
});
|
|
447
|
+
const completed = await completeSlackUserLinkAccessIfGranted(deps.db, {
|
|
448
|
+
workspaceId,
|
|
449
|
+
requestId: prepared.id,
|
|
450
|
+
subjectId: context.subjectId,
|
|
451
|
+
});
|
|
452
|
+
if (!completed) throw freshSlackLinkRequired();
|
|
453
|
+
return c.json(
|
|
454
|
+
SlackUserLinkAccessRequest.parse({
|
|
455
|
+
...completed,
|
|
456
|
+
workspaceDisplayName: workspace.name,
|
|
457
|
+
}),
|
|
458
|
+
201,
|
|
459
|
+
);
|
|
460
|
+
} catch (error) {
|
|
461
|
+
throw slackLinkAccessHttpError(error);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
app.get(
|
|
466
|
+
"/v1/workspaces/:workspaceId/integrations/slack/user-link-intents/:requestId",
|
|
467
|
+
async (c) => {
|
|
468
|
+
const workspaceId = c.req.param("workspaceId");
|
|
469
|
+
const context = await requireManagedSlackLinkHuman(c, deps);
|
|
470
|
+
const requestId = c.req.param("requestId");
|
|
471
|
+
try {
|
|
472
|
+
const current = await completeSlackUserLinkAccessIfGranted(deps.db, {
|
|
473
|
+
workspaceId,
|
|
474
|
+
requestId,
|
|
475
|
+
subjectId: context.subjectId,
|
|
476
|
+
});
|
|
477
|
+
if (!current) throw freshSlackLinkRequired();
|
|
478
|
+
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
479
|
+
return c.json(
|
|
480
|
+
SlackUserLinkAccessRequest.parse({
|
|
481
|
+
...current,
|
|
482
|
+
workspaceDisplayName: workspace?.name ?? null,
|
|
483
|
+
}),
|
|
484
|
+
);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
throw slackLinkAccessHttpError(error);
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
app.post(
|
|
492
|
+
"/v1/workspaces/:workspaceId/integrations/slack/user-link-intents/:requestId/request-access",
|
|
493
|
+
async (c) => {
|
|
494
|
+
const workspaceId = c.req.param("workspaceId");
|
|
495
|
+
const context = await requireManagedSlackLinkHuman(c, deps);
|
|
496
|
+
const payload = SlackUserLinkAccessMutationRequest.parse(await c.req.json());
|
|
497
|
+
try {
|
|
498
|
+
const request = await requestSlackUserLinkWorkspaceAccess(deps.db, {
|
|
499
|
+
workspaceId,
|
|
500
|
+
requestId: c.req.param("requestId"),
|
|
501
|
+
actorSubjectId: context.subjectId,
|
|
502
|
+
...payload,
|
|
503
|
+
});
|
|
504
|
+
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
505
|
+
return c.json(
|
|
506
|
+
SlackUserLinkAccessRequest.parse({
|
|
507
|
+
...request,
|
|
508
|
+
workspaceDisplayName: workspace?.name ?? null,
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
} catch (error) {
|
|
512
|
+
throw slackLinkAccessHttpError(error);
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
app.post(
|
|
518
|
+
"/v1/workspaces/:workspaceId/integrations/slack/user-link-intents/:requestId/cancel",
|
|
519
|
+
async (c) => {
|
|
520
|
+
const workspaceId = c.req.param("workspaceId");
|
|
521
|
+
const context = await requireManagedSlackLinkHuman(c, deps);
|
|
522
|
+
const payload = SlackUserLinkAccessMutationRequest.parse(await c.req.json());
|
|
523
|
+
try {
|
|
524
|
+
const request = await cancelSlackUserLinkAccessRequest(deps.db, {
|
|
525
|
+
workspaceId,
|
|
526
|
+
requestId: c.req.param("requestId"),
|
|
527
|
+
actorSubjectId: context.subjectId,
|
|
528
|
+
...payload,
|
|
529
|
+
});
|
|
530
|
+
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
531
|
+
return c.json(
|
|
532
|
+
SlackUserLinkAccessRequest.parse({
|
|
533
|
+
...request,
|
|
534
|
+
workspaceDisplayName: workspace?.name ?? null,
|
|
535
|
+
}),
|
|
536
|
+
);
|
|
537
|
+
} catch (error) {
|
|
538
|
+
throw slackLinkAccessHttpError(error);
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
app.get("/v1/workspaces/:workspaceId/members/access-requests/slack", async (c) => {
|
|
544
|
+
const workspaceId = c.req.param("workspaceId");
|
|
545
|
+
await requireAccessGrant(c, deps, workspaceId, "members:manage");
|
|
546
|
+
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
547
|
+
const requests = await listPendingSlackUserLinkAccessRequests(deps.db, workspaceId);
|
|
548
|
+
return c.json(
|
|
549
|
+
ListSlackUserLinkAccessRequestsResponse.parse({
|
|
550
|
+
requests: requests.map((request) => ({
|
|
551
|
+
...request,
|
|
552
|
+
workspaceDisplayName: workspace?.name ?? null,
|
|
553
|
+
})),
|
|
554
|
+
}),
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
app.post(
|
|
559
|
+
"/v1/workspaces/:workspaceId/members/access-requests/slack/:requestId/approve",
|
|
560
|
+
async (c) => {
|
|
561
|
+
const workspaceId = c.req.param("workspaceId");
|
|
562
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
|
|
563
|
+
const payload = ApproveSlackUserLinkAccessRequest.parse(await c.req.json());
|
|
564
|
+
try {
|
|
565
|
+
const request = await approveSlackUserLinkAccessRequest(deps.db, {
|
|
566
|
+
workspaceId,
|
|
567
|
+
requestId: c.req.param("requestId"),
|
|
568
|
+
actorSubjectId: grant.subjectId,
|
|
569
|
+
expectedVersion: payload.expectedVersion,
|
|
570
|
+
idempotencyKey: payload.idempotencyKey,
|
|
571
|
+
permissions: payload.permissions,
|
|
572
|
+
...(payload.role !== undefined ? { role: payload.role } : {}),
|
|
573
|
+
});
|
|
574
|
+
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
575
|
+
return c.json(
|
|
576
|
+
SlackUserLinkAccessRequest.parse({
|
|
577
|
+
...request,
|
|
578
|
+
workspaceDisplayName: workspace?.name ?? null,
|
|
579
|
+
}),
|
|
580
|
+
);
|
|
581
|
+
} catch (error) {
|
|
582
|
+
throw slackLinkAccessHttpError(error);
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
app.post(
|
|
588
|
+
"/v1/workspaces/:workspaceId/members/access-requests/slack/:requestId/deny",
|
|
589
|
+
async (c) => {
|
|
590
|
+
const workspaceId = c.req.param("workspaceId");
|
|
591
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
|
|
592
|
+
const payload = SlackUserLinkAccessMutationRequest.parse(await c.req.json());
|
|
593
|
+
try {
|
|
594
|
+
const request = await denySlackUserLinkAccessRequest(deps.db, {
|
|
595
|
+
workspaceId,
|
|
596
|
+
requestId: c.req.param("requestId"),
|
|
597
|
+
actorSubjectId: grant.subjectId,
|
|
598
|
+
...payload,
|
|
599
|
+
});
|
|
600
|
+
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
601
|
+
return c.json(
|
|
602
|
+
SlackUserLinkAccessRequest.parse({
|
|
603
|
+
...request,
|
|
604
|
+
workspaceDisplayName: workspace?.name ?? null,
|
|
605
|
+
}),
|
|
606
|
+
);
|
|
607
|
+
} catch (error) {
|
|
608
|
+
throw slackLinkAccessHttpError(error);
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
);
|
|
612
|
+
|
|
368
613
|
app.post("/v1/workspaces/:workspaceId/integrations/slack/user-links", async (c) => {
|
|
369
614
|
const workspaceId = c.req.param("workspaceId");
|
|
370
615
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
|
|
371
616
|
const body = record(await c.req.json().catch(() => null));
|
|
372
617
|
const linkToken = boundedString(body?.linkToken, 2_048);
|
|
373
618
|
const signingSecret = deps.settings.slackSigningSecret;
|
|
374
|
-
|
|
375
|
-
|
|
619
|
+
if (!linkToken || !signingSecret) {
|
|
620
|
+
throw new HTTPException(400, {
|
|
621
|
+
message: "invalid or expired Slack identity link",
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
const link = verifySlackUserLinkToken(signingSecret, linkToken);
|
|
376
625
|
if (!link || link.workspaceId !== workspaceId) {
|
|
377
626
|
throw new HTTPException(400, {
|
|
378
627
|
message: "invalid or expired Slack identity link",
|
|
@@ -384,18 +633,35 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
384
633
|
message: "Slack installation not found",
|
|
385
634
|
});
|
|
386
635
|
}
|
|
387
|
-
|
|
388
|
-
await
|
|
636
|
+
try {
|
|
637
|
+
const prepared = await prepareSlackUserLinkAccessRequest(deps.db, {
|
|
389
638
|
accountId: grant.accountId,
|
|
390
639
|
workspaceId,
|
|
640
|
+
tokenDigest: createHash("sha256").update(linkToken).digest("hex"),
|
|
391
641
|
connectionId: link.connectionId,
|
|
392
642
|
slackTeamId: link.slackTeamId,
|
|
393
643
|
slackUserId: link.slackUserId,
|
|
394
644
|
subjectId: grant.subjectId,
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
645
|
+
subjectLabel: boundedString(grant.subjectLabel, 512),
|
|
646
|
+
expiresAt: new Date(link.expiresAt),
|
|
647
|
+
});
|
|
648
|
+
const completed = await completeSlackUserLinkAccessIfGranted(deps.db, {
|
|
649
|
+
workspaceId,
|
|
650
|
+
requestId: prepared.id,
|
|
651
|
+
subjectId: grant.subjectId,
|
|
652
|
+
});
|
|
653
|
+
if (completed?.status !== "completed") throw freshSlackLinkRequired();
|
|
654
|
+
const saved = await getSlackBotUserLink(
|
|
655
|
+
deps.db,
|
|
656
|
+
workspaceId,
|
|
657
|
+
link.connectionId,
|
|
658
|
+
link.slackUserId,
|
|
659
|
+
);
|
|
660
|
+
if (!saved || saved.subjectId !== grant.subjectId) throw freshSlackLinkRequired();
|
|
661
|
+
return c.json(saved, 201);
|
|
662
|
+
} catch (error) {
|
|
663
|
+
throw slackLinkAccessHttpError(error);
|
|
664
|
+
}
|
|
399
665
|
});
|
|
400
666
|
|
|
401
667
|
app.delete(
|
|
@@ -566,7 +832,8 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
566
832
|
await processSlackReactionInboxEntry(deps, entry);
|
|
567
833
|
return;
|
|
568
834
|
}
|
|
569
|
-
const
|
|
835
|
+
const routePolicy = slackInteractionRoutePolicy(entry);
|
|
836
|
+
const routeKey = routePolicy.initialRouteKey;
|
|
570
837
|
const existing = await getSlackInteractionByRoute(
|
|
571
838
|
deps.db,
|
|
572
839
|
entry.workspaceId,
|
|
@@ -588,7 +855,9 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
588
855
|
subjectId: link?.subjectId ?? "service:slack-interaction",
|
|
589
856
|
...(existing?.sessionId ? { sessionId: existing.sessionId } : {}),
|
|
590
857
|
});
|
|
591
|
-
|
|
858
|
+
if (routePolicy.requiresChannelAccess) {
|
|
859
|
+
await client.verifyChannelAccess(entry.slackChannelId);
|
|
860
|
+
}
|
|
592
861
|
if (!link) {
|
|
593
862
|
await client.postMessage({
|
|
594
863
|
operationId: deterministicUuid(`slack-link:${entry.id}`),
|
|
@@ -604,6 +873,47 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
604
873
|
throw new SlackInteractionPermanentError("identity_access_revoked");
|
|
605
874
|
}
|
|
606
875
|
|
|
876
|
+
const alreadyDurable = await getSlackInteractionByClientEventId(
|
|
877
|
+
deps.db,
|
|
878
|
+
entry.workspaceId,
|
|
879
|
+
entry.connectionId,
|
|
880
|
+
`slack:${entry.providerEventId}`,
|
|
881
|
+
);
|
|
882
|
+
if (alreadyDurable) {
|
|
883
|
+
const { interaction, eventSessionId } = alreadyDurable;
|
|
884
|
+
if (interaction.visibility === "private" && interaction.owningSubjectId !== grant.subjectId) {
|
|
885
|
+
throw new SlackInteractionPermanentError("session_owner_mismatch");
|
|
886
|
+
}
|
|
887
|
+
if (interaction.sessionId !== null && interaction.sessionId !== eventSessionId) {
|
|
888
|
+
throw new SlackInteractionPermanentError("slack_interaction_event_conflict");
|
|
889
|
+
}
|
|
890
|
+
const boundInteraction =
|
|
891
|
+
interaction.sessionId !== null
|
|
892
|
+
? interaction
|
|
893
|
+
: await bindSlackInteractionSession(deps.db, {
|
|
894
|
+
...interaction,
|
|
895
|
+
owningSubjectId: grant.subjectId,
|
|
896
|
+
sessionId: eventSessionId,
|
|
897
|
+
});
|
|
898
|
+
if (!boundInteraction) {
|
|
899
|
+
throw new Error("Durable Slack interaction could not bind its reserved session");
|
|
900
|
+
}
|
|
901
|
+
const shouldRepairAcknowledgement =
|
|
902
|
+
interaction.triggeringProviderEventId === entry.providerEventId ||
|
|
903
|
+
(isDirectMessageShortcut(entry) && boundInteraction.ackSlackMessageTs === null);
|
|
904
|
+
if (shouldRepairAcknowledgement) {
|
|
905
|
+
const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
906
|
+
accountId: entry.accountId,
|
|
907
|
+
workspaceId: entry.workspaceId,
|
|
908
|
+
connectionId: entry.connectionId,
|
|
909
|
+
subjectId: grant.subjectId,
|
|
910
|
+
sessionId: eventSessionId,
|
|
911
|
+
});
|
|
912
|
+
await acknowledgeSlackSession(deps, boundClient, boundInteraction, entry);
|
|
913
|
+
}
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
|
|
607
917
|
if (existing?.sessionId) {
|
|
608
918
|
await continueSlackSession(deps, grant, existing, entry);
|
|
609
919
|
return;
|
|
@@ -611,6 +921,8 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
611
921
|
if (!hasPermission(grant.permissions, "sessions:create")) {
|
|
612
922
|
throw new SlackInteractionPermanentError("sessions_create_denied");
|
|
613
923
|
}
|
|
924
|
+
const preparedEntry =
|
|
925
|
+
entry.triggerKind === "app_mention" ? await prepareSlackInvocationEntry(client, entry) : entry;
|
|
614
926
|
const { interaction } = await getOrCreateSlackInteraction(deps.db, {
|
|
615
927
|
accountId: entry.accountId,
|
|
616
928
|
workspaceId: entry.workspaceId,
|
|
@@ -621,7 +933,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
621
933
|
routeKey,
|
|
622
934
|
triggeringProviderEventId: entry.providerEventId,
|
|
623
935
|
owningSubjectId: grant.subjectId,
|
|
624
|
-
visibility:
|
|
936
|
+
visibility: routePolicy.visibility,
|
|
625
937
|
});
|
|
626
938
|
if (interaction.sessionId) {
|
|
627
939
|
await continueSlackSession(deps, grant, interaction, entry);
|
|
@@ -636,7 +948,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
636
948
|
try {
|
|
637
949
|
session = await createSessionForRequest(deps, grant, entry.workspaceId, {
|
|
638
950
|
requestedSessionId: interaction.sessionReservationId,
|
|
639
|
-
initialMessage:
|
|
951
|
+
initialMessage: preparedEntry.text,
|
|
640
952
|
turnInstructions: SLACK_TASK_INSTRUCTIONS,
|
|
641
953
|
firstPartyMcpTools: [...SLACK_TASK_FIRST_PARTY_MCP_TOOLS],
|
|
642
954
|
...(preferredModel ? { model: preferredModel } : {}),
|
|
@@ -647,10 +959,14 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
647
959
|
if (error instanceof HTTPException) {
|
|
648
960
|
await client.postMessage({
|
|
649
961
|
operationId: deterministicUuid(`slack-admission-failed:${interaction.id}`),
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
962
|
+
...(isDirectMessageShortcut(entry)
|
|
963
|
+
? { userId: entry.slackUserId }
|
|
964
|
+
: {
|
|
965
|
+
channelId: entry.slackChannelId,
|
|
966
|
+
...(entry.triggerKind === "slash_command"
|
|
967
|
+
? {}
|
|
968
|
+
: { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
|
|
969
|
+
}),
|
|
654
970
|
text: slackAdmissionFailureText(error),
|
|
655
971
|
});
|
|
656
972
|
}
|
|
@@ -662,21 +978,149 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
662
978
|
sessionId: session.id,
|
|
663
979
|
});
|
|
664
980
|
if (!bound) throw new Error("Slack route could not bind its durable session");
|
|
981
|
+
const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
982
|
+
accountId: entry.accountId,
|
|
983
|
+
workspaceId: entry.workspaceId,
|
|
984
|
+
connectionId: entry.connectionId,
|
|
985
|
+
subjectId: grant.subjectId,
|
|
986
|
+
sessionId: session.id,
|
|
987
|
+
});
|
|
988
|
+
await acknowledgeSlackSession(deps, boundClient, bound, entry);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
export type SlackInvocationMessageContext = {
|
|
992
|
+
messages: Awaited<ReturnType<OpenGeniSlackBotClient["threadReplies"]>>["messages"];
|
|
993
|
+
nextCursor: string | null;
|
|
994
|
+
kind: "thread" | "channel";
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
async function prepareSlackInvocationEntry(
|
|
998
|
+
client: OpenGeniSlackBotClient,
|
|
999
|
+
entry: SlackInteractionInboxEntry,
|
|
1000
|
+
): Promise<SlackInteractionInboxEntry> {
|
|
1001
|
+
const context = entry.slackThreadTs
|
|
1002
|
+
? await client.threadReplies({
|
|
1003
|
+
channelId: entry.slackChannelId,
|
|
1004
|
+
threadTimestamp: entry.slackThreadTs,
|
|
1005
|
+
limit: MAX_SLACK_INVOCATION_CONTEXT_MESSAGES,
|
|
1006
|
+
})
|
|
1007
|
+
: await client.channelHistory({
|
|
1008
|
+
channelId: entry.slackChannelId,
|
|
1009
|
+
latest: entry.slackMessageTs,
|
|
1010
|
+
inclusive: true,
|
|
1011
|
+
limit: MAX_SLACK_CHANNEL_CONTEXT_MESSAGES,
|
|
1012
|
+
});
|
|
1013
|
+
return {
|
|
1014
|
+
...entry,
|
|
1015
|
+
text: slackInvocationTaskText(entry, {
|
|
1016
|
+
messages: context.messages,
|
|
1017
|
+
nextCursor: context.nextCursor,
|
|
1018
|
+
kind: entry.slackThreadTs ? "thread" : "channel",
|
|
1019
|
+
}),
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
export function slackInvocationTaskText(
|
|
1024
|
+
entry: Pick<SlackInteractionInboxEntry, "slackMessageTs" | "slackUserId" | "text">,
|
|
1025
|
+
context: SlackInvocationMessageContext,
|
|
1026
|
+
) {
|
|
1027
|
+
const invocation = {
|
|
1028
|
+
timestamp: entry.slackMessageTs,
|
|
1029
|
+
userId: entry.slackUserId,
|
|
1030
|
+
botId: "",
|
|
1031
|
+
threadTimestamp: "",
|
|
1032
|
+
text: entry.text,
|
|
1033
|
+
files: [],
|
|
1034
|
+
};
|
|
1035
|
+
const surroundingLines = context.messages
|
|
1036
|
+
.filter((message) => message.timestamp !== entry.slackMessageTs)
|
|
1037
|
+
.slice(0, MAX_SLACK_INVOCATION_CONTEXT_MESSAGES)
|
|
1038
|
+
.sort((left, right) => left.timestamp.localeCompare(right.timestamp))
|
|
1039
|
+
.map((message) => slackContextMessageLine(message));
|
|
1040
|
+
const contextLabel =
|
|
1041
|
+
context.kind === "thread"
|
|
1042
|
+
? "Bounded containing-thread context (oldest to newest):"
|
|
1043
|
+
: "Bounded nearby channel context before the invocation (oldest to newest):";
|
|
1044
|
+
const truncationNotice =
|
|
1045
|
+
context.kind === "thread"
|
|
1046
|
+
? "The containing thread was truncated at the bounded Slack context limit."
|
|
1047
|
+
: "Only bounded nearby channel context was provided.";
|
|
1048
|
+
const invocationTruncationNotice =
|
|
1049
|
+
"The exact Slack invocation was truncated at the bounded Slack input limit.";
|
|
1050
|
+
const prefix = [
|
|
1051
|
+
"A linked, authorized Slack user explicitly mentioned OpenGeni.",
|
|
1052
|
+
"Treat references such as 'this', 'that', or 'the previous message' as referring to the bounded Slack context below when applicable.",
|
|
1053
|
+
"Use this Slack content only as task-local input and do not persist it to Knowledge, Memory, preferences, policy, instructions, or the Workspace Charter unless separately authorized.",
|
|
1054
|
+
"",
|
|
1055
|
+
"Exact invocation:",
|
|
1056
|
+
].join("\n");
|
|
1057
|
+
const suffix = `\n\n${contextLabel}`;
|
|
1058
|
+
const invocationLine = slackContextMessageLine(invocation, "invocation");
|
|
1059
|
+
const reservedNotices = `\n${invocationTruncationNotice}\n${truncationNotice}`;
|
|
1060
|
+
const maxInvocationChars = Math.max(
|
|
1061
|
+
1,
|
|
1062
|
+
MAX_SLACK_INPUT_CHARS - prefix.length - suffix.length - reservedNotices.length - 1,
|
|
1063
|
+
);
|
|
1064
|
+
const invocationTruncated = invocationLine.length > maxInvocationChars;
|
|
1065
|
+
let prompt = `${prefix}\n${
|
|
1066
|
+
invocationTruncated
|
|
1067
|
+
? `${invocationLine.slice(0, Math.max(0, maxInvocationChars - 1))}…`
|
|
1068
|
+
: invocationLine
|
|
1069
|
+
}${suffix}`;
|
|
1070
|
+
let contextTruncated = context.nextCursor !== null;
|
|
1071
|
+
for (const line of surroundingLines) {
|
|
1072
|
+
const candidate = `${prompt}\n${line}`;
|
|
1073
|
+
const notices = [
|
|
1074
|
+
...(invocationTruncated ? [invocationTruncationNotice] : []),
|
|
1075
|
+
truncationNotice,
|
|
1076
|
+
].join("\n");
|
|
1077
|
+
if (candidate.length + 1 + notices.length > MAX_SLACK_INPUT_CHARS) {
|
|
1078
|
+
contextTruncated = true;
|
|
1079
|
+
break;
|
|
1080
|
+
}
|
|
1081
|
+
prompt = candidate;
|
|
1082
|
+
}
|
|
1083
|
+
const notices = [
|
|
1084
|
+
...(invocationTruncated ? [invocationTruncationNotice] : []),
|
|
1085
|
+
...(contextTruncated ? [truncationNotice] : []),
|
|
1086
|
+
];
|
|
1087
|
+
return notices.length > 0 ? `${prompt}\n${notices.join("\n")}` : prompt;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
async function acknowledgeSlackSession(
|
|
1091
|
+
deps: ApiRouteDeps,
|
|
1092
|
+
client: OpenGeniSlackBotClient,
|
|
1093
|
+
interaction: SlackInteraction,
|
|
1094
|
+
entry: SlackInteractionInboxEntry,
|
|
1095
|
+
) {
|
|
1096
|
+
if (!interaction.sessionId) {
|
|
1097
|
+
throw new Error("Slack acknowledgement requires a bound session");
|
|
1098
|
+
}
|
|
1099
|
+
const directMessageShortcut = isDirectMessageShortcut(entry);
|
|
665
1100
|
const ack = await client.postMessage({
|
|
666
1101
|
operationId: deterministicUuid(`slack-ack:${interaction.id}`),
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
1102
|
+
...(directMessageShortcut
|
|
1103
|
+
? { userId: entry.slackUserId }
|
|
1104
|
+
: {
|
|
1105
|
+
channelId: entry.slackChannelId,
|
|
1106
|
+
...(entry.triggerKind === "slash_command"
|
|
1107
|
+
? {}
|
|
1108
|
+
: { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
|
|
1109
|
+
}),
|
|
1110
|
+
text: directMessageShortcut
|
|
1111
|
+
? `OpenGeni started a private task from the selected DM message. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this bot-DM thread to continue, or reply \`stop\` to stop. The source DM was not opened to the bot or made workspace-visible.`
|
|
1112
|
+
: `OpenGeni started this task. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this thread to continue, or reply \`stop\` to stop. Start a new top-level DM or invoke /opengeni again for a new session.`,
|
|
672
1113
|
});
|
|
673
|
-
if (entry.triggerKind === "slash_command") {
|
|
674
|
-
await rekeySlackInteractionRoute(deps.db, {
|
|
1114
|
+
if (entry.triggerKind === "slash_command" || directMessageShortcut) {
|
|
1115
|
+
const rekeyed = await rekeySlackInteractionRoute(deps.db, {
|
|
675
1116
|
...interaction,
|
|
676
|
-
routeKey: slackRouteKey(
|
|
1117
|
+
routeKey: slackRouteKey(ack.channelId, ack.timestamp),
|
|
1118
|
+
slackChannelId: ack.channelId,
|
|
677
1119
|
slackThreadTs: ack.timestamp,
|
|
678
1120
|
ackSlackMessageTs: ack.timestamp,
|
|
1121
|
+
repairUnacknowledgedPrivateShortcutDelivery: directMessageShortcut,
|
|
679
1122
|
});
|
|
1123
|
+
if (!rekeyed) throw new Error("Slack acknowledgement could not rekey its durable route");
|
|
680
1124
|
}
|
|
681
1125
|
}
|
|
682
1126
|
|
|
@@ -935,6 +1379,13 @@ export function slackReactionTaskText(context: SlackReactionMessageContext) {
|
|
|
935
1379
|
function slackReactionMessageLine(
|
|
936
1380
|
message: SlackReactionMessageContext["reactedMessage"],
|
|
937
1381
|
reacted: boolean,
|
|
1382
|
+
) {
|
|
1383
|
+
return slackContextMessageLine(message, reacted ? "reacted message" : undefined);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function slackContextMessageLine(
|
|
1387
|
+
message: SlackReactionMessageContext["reactedMessage"],
|
|
1388
|
+
annotation?: string,
|
|
938
1389
|
) {
|
|
939
1390
|
const actor = message.userId || (message.botId ? `bot:${message.botId}` : "unknown");
|
|
940
1391
|
const text = message.text.trim() || "(no text)";
|
|
@@ -955,7 +1406,7 @@ function slackReactionMessageLine(
|
|
|
955
1406
|
const fileSummary = fileLabels.length
|
|
956
1407
|
? ` Files: ${fileLabels.join(", ")}${filesTruncated ? ", …" : ""}.`
|
|
957
1408
|
: "";
|
|
958
|
-
return `- ${message.timestamp || "unknown"} ${actor}${
|
|
1409
|
+
return `- ${message.timestamp || "unknown"} ${actor}${annotation ? ` [${annotation}]` : ""}: ${text}${fileSummary}`;
|
|
959
1410
|
}
|
|
960
1411
|
|
|
961
1412
|
async function continueSlackReactionSession(
|
|
@@ -1092,7 +1543,6 @@ async function deliverSlackSessionEvents(
|
|
|
1092
1543
|
after: interaction.lastDeliveredSessionEventSequence,
|
|
1093
1544
|
limit: 100,
|
|
1094
1545
|
includeTypes: [...SLACK_DELIVERY_EVENT_TYPES],
|
|
1095
|
-
authoritativeLatest: true,
|
|
1096
1546
|
maxBytes: 256 * 1024,
|
|
1097
1547
|
});
|
|
1098
1548
|
if (page.events.length === 0) {
|
|
@@ -1112,14 +1562,65 @@ async function deliverSlackSessionEvents(
|
|
|
1112
1562
|
let lastSequence = interaction.lastDeliveredSessionEventSequence;
|
|
1113
1563
|
let terminal: Exclude<SlackInteraction["terminalDeliveryState"], "open"> | null = null;
|
|
1114
1564
|
let latestAssistantText = "";
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1565
|
+
const orderedEvents = page.events
|
|
1566
|
+
.filter(
|
|
1567
|
+
(event) =>
|
|
1568
|
+
(event.turnAssociation === null || event.turnAssociation === "current") &&
|
|
1569
|
+
event.duplicateOfEventId === null,
|
|
1570
|
+
)
|
|
1571
|
+
.sort((left, right) => left.sequence - right.sequence);
|
|
1572
|
+
lastSequence = page.events.reduce(
|
|
1573
|
+
(latest, event) => Math.max(latest, event.sequence),
|
|
1574
|
+
lastSequence,
|
|
1575
|
+
);
|
|
1576
|
+
const terminalAssistantSequences = new Set<number>();
|
|
1577
|
+
for (let index = 0; index < orderedEvents.length; index += 1) {
|
|
1578
|
+
const event = orderedEvents[index]!;
|
|
1579
|
+
if (event.type !== "turn.completed") continue;
|
|
1580
|
+
const finalOutput = safePayloadText(event.payload, "output").trim();
|
|
1581
|
+
const candidates: SessionEvent[] = [];
|
|
1582
|
+
for (let candidateIndex = index - 1; candidateIndex >= 0; candidateIndex -= 1) {
|
|
1583
|
+
const candidate = orderedEvents[candidateIndex]!;
|
|
1584
|
+
if (
|
|
1585
|
+
candidate.type === "turn.completed" ||
|
|
1586
|
+
candidate.type === "turn.failed" ||
|
|
1587
|
+
candidate.type === "turn.cancelled"
|
|
1588
|
+
) {
|
|
1589
|
+
break;
|
|
1590
|
+
}
|
|
1591
|
+
if (candidate.type !== "agent.message.completed") continue;
|
|
1592
|
+
if (event.turnId && candidate.turnId && event.turnId !== candidate.turnId) continue;
|
|
1593
|
+
candidates.push(candidate);
|
|
1594
|
+
}
|
|
1595
|
+
const terminalText =
|
|
1596
|
+
finalOutput ||
|
|
1597
|
+
candidates
|
|
1598
|
+
.map((candidate) => safePayloadText(candidate.payload, "text").trim())
|
|
1599
|
+
.find(Boolean) ||
|
|
1600
|
+
"";
|
|
1601
|
+
if (!terminalText) continue;
|
|
1602
|
+
for (const candidate of candidates) {
|
|
1603
|
+
const assistantText = safePayloadText(candidate.payload, "text").trim();
|
|
1604
|
+
if (assistantText === terminalText) {
|
|
1605
|
+
terminalAssistantSequences.add(candidate.sequence);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
const progressEvidence = orderedEvents.some((event) => event.type === "turn.completed")
|
|
1610
|
+
? await listSlackInteractionProgressDeliveryEvidence(deps.db, {
|
|
1611
|
+
accountId: interaction.accountId,
|
|
1612
|
+
workspaceId: interaction.workspaceId,
|
|
1613
|
+
interactionId: interaction.id,
|
|
1614
|
+
sessionId: interaction.sessionId,
|
|
1615
|
+
})
|
|
1616
|
+
: [];
|
|
1617
|
+
// Slack delivery pages are chronological. This keeps pagination lossless and
|
|
1618
|
+
// ensures progress cannot appear after a terminal result.
|
|
1619
|
+
for (const event of orderedEvents) {
|
|
1119
1620
|
lastSequence = Math.max(lastSequence, event.sequence);
|
|
1120
1621
|
if (event.type === "agent.message.completed") {
|
|
1121
1622
|
latestAssistantText = safePayloadText(event.payload, "text");
|
|
1122
|
-
if (latestAssistantText) {
|
|
1623
|
+
if (latestAssistantText && !terminalAssistantSequences.has(event.sequence)) {
|
|
1123
1624
|
const progress = await claimSlackInteractionProgressDelivery(deps.db, {
|
|
1124
1625
|
accountId: interaction.accountId,
|
|
1125
1626
|
workspaceId: interaction.workspaceId,
|
|
@@ -1155,26 +1656,50 @@ async function deliverSlackSessionEvents(
|
|
|
1155
1656
|
client,
|
|
1156
1657
|
interaction,
|
|
1157
1658
|
event,
|
|
1158
|
-
`OpenGeni needs your input:\n${formatQuestions(request.questions)}\nReply in this thread
|
|
1659
|
+
`OpenGeni needs your input:\n${formatQuestions(request.questions)}\nReply in this thread.`,
|
|
1159
1660
|
"human-input",
|
|
1160
1661
|
);
|
|
1161
1662
|
}
|
|
1162
1663
|
} else if (event.type === "turn.completed") {
|
|
1163
1664
|
const output = safePayloadText(event.payload, "output") || latestAssistantText;
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1665
|
+
const normalizedOutput = output.trim();
|
|
1666
|
+
const existingProgress = progressEvidence.find(
|
|
1667
|
+
(delivery) =>
|
|
1668
|
+
boundedOutput(delivery.text).trim() === normalizedOutput &&
|
|
1669
|
+
(event.turnId
|
|
1670
|
+
? event.turnId === delivery.turnId
|
|
1671
|
+
: delivery.sessionEventSequence === interaction.lastDeliveredSessionEventSequence ||
|
|
1672
|
+
terminalAssistantSequences.has(delivery.sessionEventSequence)),
|
|
1170
1673
|
);
|
|
1674
|
+
if (existingProgress && normalizedOutput) {
|
|
1675
|
+
// The assistant text may already have been accepted by Slack before a
|
|
1676
|
+
// replica observed turn.completed. Reconcile the same provider
|
|
1677
|
+
// operation id (including response-loss retries) instead of inventing
|
|
1678
|
+
// a second final post.
|
|
1679
|
+
await postDelivery(
|
|
1680
|
+
client,
|
|
1681
|
+
interaction,
|
|
1682
|
+
event,
|
|
1683
|
+
boundedOutput(existingProgress.text),
|
|
1684
|
+
"progress",
|
|
1685
|
+
existingProgress.operationId,
|
|
1686
|
+
);
|
|
1687
|
+
} else {
|
|
1688
|
+
await postDelivery(
|
|
1689
|
+
client,
|
|
1690
|
+
interaction,
|
|
1691
|
+
event,
|
|
1692
|
+
`${output || "OpenGeni finished this task."}\n\nReply in this thread to continue.`,
|
|
1693
|
+
"final",
|
|
1694
|
+
);
|
|
1695
|
+
}
|
|
1171
1696
|
terminal = "completed";
|
|
1172
1697
|
} else if (event.type === "turn.failed") {
|
|
1173
1698
|
await postDelivery(
|
|
1174
1699
|
client,
|
|
1175
1700
|
interaction,
|
|
1176
1701
|
event,
|
|
1177
|
-
|
|
1702
|
+
"OpenGeni could not complete this task. Reply in this thread to retry or ask for details.",
|
|
1178
1703
|
"failed",
|
|
1179
1704
|
);
|
|
1180
1705
|
terminal = "failed";
|
|
@@ -1330,10 +1855,47 @@ function linkUrl(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
|
|
|
1330
1855
|
const signingSecret = deps.settings.slackSigningSecret;
|
|
1331
1856
|
if (!base || !signingSecret) return "OpenGeni Settings → Integrations → Slack";
|
|
1332
1857
|
const url = new URL(`/workspaces/${entry.workspaceId}/capabilities`, base);
|
|
1333
|
-
|
|
1858
|
+
// Fragments stay out of HTTP request lines, reverse-proxy logs, Referer
|
|
1859
|
+
// headers, and managed-auth callback URLs. Query-form bearers are rejected.
|
|
1860
|
+
url.hash = new URLSearchParams({
|
|
1861
|
+
slack_link: createSlackUserLinkToken(signingSecret, entry),
|
|
1862
|
+
}).toString();
|
|
1334
1863
|
return url.toString();
|
|
1335
1864
|
}
|
|
1336
1865
|
|
|
1866
|
+
async function requireManagedSlackLinkHuman(c: Context, deps: ApiRouteDeps) {
|
|
1867
|
+
if (c.req.header("authorization")) {
|
|
1868
|
+
throw new HTTPException(401, { message: "managed browser sign-in required" });
|
|
1869
|
+
}
|
|
1870
|
+
const context = await requireAccessContext(c, deps);
|
|
1871
|
+
if (context.mode !== "managed" || !context.subjectId.startsWith("user:")) {
|
|
1872
|
+
throw new HTTPException(403, { message: "managed browser sign-in required" });
|
|
1873
|
+
}
|
|
1874
|
+
return context;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
function freshSlackLinkRequired() {
|
|
1878
|
+
return new HTTPException(400, {
|
|
1879
|
+
message: "This Slack link is invalid or expired. Request a fresh link from Slack.",
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function slackLinkAccessHttpError(error: unknown): HTTPException {
|
|
1884
|
+
if (error instanceof HTTPException) return error;
|
|
1885
|
+
if (!(error instanceof SlackUserLinkAccessPersistenceError)) throw error;
|
|
1886
|
+
if (error.code === "version_conflict" || error.code === "idempotency_conflict") {
|
|
1887
|
+
return new HTTPException(409, {
|
|
1888
|
+
message: "The Slack access request changed. Refresh and try again.",
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
if (error.code === "state_conflict") {
|
|
1892
|
+
return new HTTPException(409, {
|
|
1893
|
+
message: "This Slack link is no longer pending. Request a fresh link from Slack.",
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
return freshSlackLinkRequired();
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1337
1899
|
type SlackUserLinkToken = {
|
|
1338
1900
|
workspaceId: string;
|
|
1339
1901
|
connectionId: string;
|
|
@@ -1407,6 +1969,12 @@ function slackRouteKey(channelId: string, threadTs: string) {
|
|
|
1407
1969
|
return `${channelId}:${threadTs}`;
|
|
1408
1970
|
}
|
|
1409
1971
|
|
|
1972
|
+
function isDirectMessageShortcut(
|
|
1973
|
+
entry: Pick<SlackInteractionInboxEntry, "triggerKind" | "slackChannelId">,
|
|
1974
|
+
) {
|
|
1975
|
+
return entry.triggerKind === "message_shortcut" && entry.slackChannelId.startsWith("D");
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1410
1978
|
function deterministicUuid(value: string) {
|
|
1411
1979
|
const bytes = createHash("sha256").update(value).digest().subarray(0, 16);
|
|
1412
1980
|
bytes[6] = (bytes[6]! & 0x0f) | 0x50;
|