@opengeni/api-router 0.23.1 → 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.
Files changed (107) hide show
  1. package/dist/app.d.ts +2 -2
  2. package/dist/app.js +1 -1
  3. package/dist/browser-controller-authority.d.ts +43 -0
  4. package/dist/browser-state-authority.d.ts +27 -0
  5. package/dist/{chunk-T4T2PGU4.js → chunk-JIKNR5YL.js} +26181 -14408
  6. package/dist/chunk-JIKNR5YL.js.map +1 -0
  7. package/dist/codemode.d.ts +23 -0
  8. package/dist/editable-artifact-live-hints.d.ts +11 -0
  9. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  10. package/dist/editable-artifact-office-import.d.ts +22 -0
  11. package/dist/editable-artifact-production.d.ts +29 -0
  12. package/dist/editable-artifact-websocket.d.ts +49 -0
  13. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  14. package/dist/github-browser-flow.d.ts +6 -0
  15. package/dist/http/cors.d.ts +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +1796 -22
  18. package/dist/index.js.map +1 -1
  19. package/dist/integrations/api-integrations.d.ts +24 -0
  20. package/dist/integrations/atlassian.d.ts +176 -0
  21. package/dist/integrations/github-skill-source.d.ts +5 -0
  22. package/dist/integrations/google-drive.d.ts +85 -0
  23. package/dist/integrations/oauth-client.d.ts +22 -1
  24. package/dist/integrations/provider-oauth.d.ts +19 -0
  25. package/dist/integrations/slack-bot.d.ts +4 -0
  26. package/dist/integrations/slack-interactions.d.ts +8 -2
  27. package/dist/integrations/social-api.d.ts +2 -1
  28. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  29. package/dist/mcp/editable-artifacts.d.ts +13 -0
  30. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  31. package/dist/mcp/server.d.ts +14 -3
  32. package/dist/memory-slack-delivery.d.ts +9 -0
  33. package/dist/routes/api-integrations.d.ts +8 -0
  34. package/dist/routes/browser-identities.d.ts +5 -0
  35. package/dist/routes/browser-sessions.d.ts +6 -0
  36. package/dist/routes/company-profile.d.ts +3 -0
  37. package/dist/routes/computer-sessions.d.ts +6 -0
  38. package/dist/routes/editable-artifacts.d.ts +44 -0
  39. package/dist/routes/integration-features.d.ts +3 -0
  40. package/dist/routes/memory-slack-publications.d.ts +6 -0
  41. package/dist/routes/plugins.d.ts +8 -0
  42. package/dist/routes/sessions.d.ts +17 -2
  43. package/dist/routes/skills.d.ts +6 -0
  44. package/dist/routes/video-generation.d.ts +3 -0
  45. package/dist/sandbox/channel-a.d.ts +55 -2
  46. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  47. package/dist/sandbox/viewer.d.ts +4 -2
  48. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  49. package/package.json +19 -14
  50. package/src/app.ts +274 -37
  51. package/src/browser-controller-authority.ts +137 -0
  52. package/src/browser-state-authority.ts +236 -0
  53. package/src/codemode.ts +186 -0
  54. package/src/editable-artifact-live-hints.ts +64 -0
  55. package/src/editable-artifact-native-kernel.ts +659 -0
  56. package/src/editable-artifact-office-import.ts +230 -0
  57. package/src/editable-artifact-production.ts +419 -0
  58. package/src/editable-artifact-websocket.ts +311 -0
  59. package/src/editable-artifact-workspace-files.ts +186 -0
  60. package/src/github-browser-flow.ts +35 -6
  61. package/src/http/auth.ts +2 -0
  62. package/src/http/cors.ts +3 -0
  63. package/src/index.ts +119 -20
  64. package/src/integrations/api-integrations.ts +350 -0
  65. package/src/integrations/atlassian.ts +1621 -0
  66. package/src/integrations/github-skill-source.ts +142 -0
  67. package/src/integrations/google-drive.ts +1000 -64
  68. package/src/integrations/oauth-client.ts +123 -33
  69. package/src/integrations/provider-oauth.ts +777 -0
  70. package/src/integrations/slack-bot.ts +31 -2
  71. package/src/integrations/slack-interactions.ts +487 -27
  72. package/src/integrations/social-api.ts +11 -0
  73. package/src/mcp/documents.ts +32 -1
  74. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  75. package/src/mcp/editable-artifacts.ts +448 -0
  76. package/src/mcp/scheduled-task-view.ts +34 -0
  77. package/src/mcp/server.ts +923 -145
  78. package/src/memory-slack-delivery.ts +209 -0
  79. package/src/routes/api-integrations.ts +407 -0
  80. package/src/routes/browser-identities.ts +136 -0
  81. package/src/routes/browser-sessions.ts +2543 -0
  82. package/src/routes/company-profile.ts +255 -0
  83. package/src/routes/computer-sessions.ts +1247 -0
  84. package/src/routes/connections.ts +284 -99
  85. package/src/routes/documents.ts +22 -3
  86. package/src/routes/editable-artifacts.ts +1159 -0
  87. package/src/routes/files.ts +106 -4
  88. package/src/routes/github.ts +18 -2
  89. package/src/routes/install.ts +7 -1
  90. package/src/routes/integration-features.ts +258 -0
  91. package/src/routes/memory-slack-publications.ts +216 -0
  92. package/src/routes/packs.ts +437 -7
  93. package/src/routes/plugins.ts +751 -0
  94. package/src/routes/rigs.ts +77 -20
  95. package/src/routes/scheduled-tasks.ts +67 -40
  96. package/src/routes/sessions.ts +421 -245
  97. package/src/routes/skills.ts +174 -0
  98. package/src/routes/video-generation.ts +132 -0
  99. package/src/routes/workspaces.ts +46 -24
  100. package/src/sandbox/channel-a.ts +723 -116
  101. package/src/sandbox/metrics-ingestion.ts +121 -3
  102. package/src/sandbox/rematerialize.ts +35 -47
  103. package/src/sandbox/viewer.ts +29 -9
  104. package/src/temporal-schedule-cleanup.ts +135 -0
  105. package/dist/chunk-T4T2PGU4.js.map +0 -1
  106. package/dist/mcp/toolspace.d.ts +0 -71
  107. package/src/mcp/toolspace.ts +0 -1190
@@ -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;
@@ -191,7 +207,11 @@ export function slackEventInboxEntry(
191
207
  const text = boundedText(event.text);
192
208
  if (!userId || !channelId || !timestamp || !text) return null;
193
209
  let triggerKind: SlackInteractionTriggerKind;
194
- if (event.type === "app_mention") {
210
+ const explicitlyMentionsBot = text.includes(`<@${bot.botUserId}>`);
211
+ if (
212
+ event.type === "app_mention" ||
213
+ (event.type === "message" && threadTimestamp && explicitlyMentionsBot)
214
+ ) {
195
215
  // A mention is always an explicit invocation. In particular, a mention in
196
216
  // an otherwise-unmapped existing thread adopts that thread as the new
197
217
  // OpenGeni session surface; only ordinary message replies require a
@@ -390,14 +410,218 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
390
410
  return c.json({ ok: true });
391
411
  });
392
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
+
393
613
  app.post("/v1/workspaces/:workspaceId/integrations/slack/user-links", async (c) => {
394
614
  const workspaceId = c.req.param("workspaceId");
395
615
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
396
616
  const body = record(await c.req.json().catch(() => null));
397
617
  const linkToken = boundedString(body?.linkToken, 2_048);
398
618
  const signingSecret = deps.settings.slackSigningSecret;
399
- const link =
400
- linkToken && signingSecret ? verifySlackUserLinkToken(signingSecret, linkToken) : null;
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);
401
625
  if (!link || link.workspaceId !== workspaceId) {
402
626
  throw new HTTPException(400, {
403
627
  message: "invalid or expired Slack identity link",
@@ -409,18 +633,35 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
409
633
  message: "Slack installation not found",
410
634
  });
411
635
  }
412
- return c.json(
413
- await saveSlackBotUserLink(deps.db, {
636
+ try {
637
+ const prepared = await prepareSlackUserLinkAccessRequest(deps.db, {
414
638
  accountId: grant.accountId,
415
639
  workspaceId,
640
+ tokenDigest: createHash("sha256").update(linkToken).digest("hex"),
416
641
  connectionId: link.connectionId,
417
642
  slackTeamId: link.slackTeamId,
418
643
  slackUserId: link.slackUserId,
419
644
  subjectId: grant.subjectId,
420
- linkedBySubjectId: grant.subjectId,
421
- }),
422
- 201,
423
- );
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
+ }
424
665
  });
425
666
 
426
667
  app.delete(
@@ -680,6 +921,8 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
680
921
  if (!hasPermission(grant.permissions, "sessions:create")) {
681
922
  throw new SlackInteractionPermanentError("sessions_create_denied");
682
923
  }
924
+ const preparedEntry =
925
+ entry.triggerKind === "app_mention" ? await prepareSlackInvocationEntry(client, entry) : entry;
683
926
  const { interaction } = await getOrCreateSlackInteraction(deps.db, {
684
927
  accountId: entry.accountId,
685
928
  workspaceId: entry.workspaceId,
@@ -705,7 +948,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
705
948
  try {
706
949
  session = await createSessionForRequest(deps, grant, entry.workspaceId, {
707
950
  requestedSessionId: interaction.sessionReservationId,
708
- initialMessage: entry.text,
951
+ initialMessage: preparedEntry.text,
709
952
  turnInstructions: SLACK_TASK_INSTRUCTIONS,
710
953
  firstPartyMcpTools: [...SLACK_TASK_FIRST_PARTY_MCP_TOOLS],
711
954
  ...(preferredModel ? { model: preferredModel } : {}),
@@ -745,6 +988,105 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
745
988
  await acknowledgeSlackSession(deps, boundClient, bound, entry);
746
989
  }
747
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
+
748
1090
  async function acknowledgeSlackSession(
749
1091
  deps: ApiRouteDeps,
750
1092
  client: OpenGeniSlackBotClient,
@@ -1037,6 +1379,13 @@ export function slackReactionTaskText(context: SlackReactionMessageContext) {
1037
1379
  function slackReactionMessageLine(
1038
1380
  message: SlackReactionMessageContext["reactedMessage"],
1039
1381
  reacted: boolean,
1382
+ ) {
1383
+ return slackContextMessageLine(message, reacted ? "reacted message" : undefined);
1384
+ }
1385
+
1386
+ function slackContextMessageLine(
1387
+ message: SlackReactionMessageContext["reactedMessage"],
1388
+ annotation?: string,
1040
1389
  ) {
1041
1390
  const actor = message.userId || (message.botId ? `bot:${message.botId}` : "unknown");
1042
1391
  const text = message.text.trim() || "(no text)";
@@ -1057,7 +1406,7 @@ function slackReactionMessageLine(
1057
1406
  const fileSummary = fileLabels.length
1058
1407
  ? ` Files: ${fileLabels.join(", ")}${filesTruncated ? ", …" : ""}.`
1059
1408
  : "";
1060
- return `- ${message.timestamp || "unknown"} ${actor}${reacted ? " [reacted message]" : ""}: ${text}${fileSummary}`;
1409
+ return `- ${message.timestamp || "unknown"} ${actor}${annotation ? ` [${annotation}]` : ""}: ${text}${fileSummary}`;
1061
1410
  }
1062
1411
 
1063
1412
  async function continueSlackReactionSession(
@@ -1194,7 +1543,6 @@ async function deliverSlackSessionEvents(
1194
1543
  after: interaction.lastDeliveredSessionEventSequence,
1195
1544
  limit: 100,
1196
1545
  includeTypes: [...SLACK_DELIVERY_EVENT_TYPES],
1197
- authoritativeLatest: true,
1198
1546
  maxBytes: 256 * 1024,
1199
1547
  });
1200
1548
  if (page.events.length === 0) {
@@ -1214,14 +1562,65 @@ async function deliverSlackSessionEvents(
1214
1562
  let lastSequence = interaction.lastDeliveredSessionEventSequence;
1215
1563
  let terminal: Exclude<SlackInteraction["terminalDeliveryState"], "open"> | null = null;
1216
1564
  let latestAssistantText = "";
1217
- // Monitoring pages are newest-first. Slack delivery is a timeline surface:
1218
- // replay oldest-to-newest so progress cannot appear after a terminal result
1219
- // and the final message remains the final message in the thread.
1220
- for (const event of [...page.events].sort((left, right) => left.sequence - right.sequence)) {
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) {
1221
1620
  lastSequence = Math.max(lastSequence, event.sequence);
1222
1621
  if (event.type === "agent.message.completed") {
1223
1622
  latestAssistantText = safePayloadText(event.payload, "text");
1224
- if (latestAssistantText) {
1623
+ if (latestAssistantText && !terminalAssistantSequences.has(event.sequence)) {
1225
1624
  const progress = await claimSlackInteractionProgressDelivery(deps.db, {
1226
1625
  accountId: interaction.accountId,
1227
1626
  workspaceId: interaction.workspaceId,
@@ -1257,26 +1656,50 @@ async function deliverSlackSessionEvents(
1257
1656
  client,
1258
1657
  interaction,
1259
1658
  event,
1260
- `OpenGeni needs your input:\n${formatQuestions(request.questions)}\nReply in this thread, or use ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)}.`,
1659
+ `OpenGeni needs your input:\n${formatQuestions(request.questions)}\nReply in this thread.`,
1261
1660
  "human-input",
1262
1661
  );
1263
1662
  }
1264
1663
  } else if (event.type === "turn.completed") {
1265
1664
  const output = safePayloadText(event.payload, "output") || latestAssistantText;
1266
- await postDelivery(
1267
- client,
1268
- interaction,
1269
- event,
1270
- `${output || "OpenGeni finished this task."}\n\n${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} Reply in this thread to continue.`,
1271
- "final",
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)),
1272
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
+ }
1273
1696
  terminal = "completed";
1274
1697
  } else if (event.type === "turn.failed") {
1275
1698
  await postDelivery(
1276
1699
  client,
1277
1700
  interaction,
1278
1701
  event,
1279
- `OpenGeni could not complete this task. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} for the bounded failure details.`,
1702
+ "OpenGeni could not complete this task. Reply in this thread to retry or ask for details.",
1280
1703
  "failed",
1281
1704
  );
1282
1705
  terminal = "failed";
@@ -1432,10 +1855,47 @@ function linkUrl(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
1432
1855
  const signingSecret = deps.settings.slackSigningSecret;
1433
1856
  if (!base || !signingSecret) return "OpenGeni Settings → Integrations → Slack";
1434
1857
  const url = new URL(`/workspaces/${entry.workspaceId}/capabilities`, base);
1435
- url.searchParams.set("slack_link", createSlackUserLinkToken(signingSecret, entry));
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();
1436
1863
  return url.toString();
1437
1864
  }
1438
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
+
1439
1899
  type SlackUserLinkToken = {
1440
1900
  workspaceId: string;
1441
1901
  connectionId: string;
@@ -39,6 +39,17 @@ type ConnectionRef = { workspaceId: string; connectionId: string; subjectId?: st
39
39
 
40
40
  const MAX_LIVE_RESULTS = 50;
41
41
 
42
+ export function assertSocialConnectionProvider(
43
+ connection: Pick<SocialConnection, "provider">,
44
+ expected: "x" | "reddit",
45
+ ): void {
46
+ if (connection.provider !== expected) {
47
+ throw new Error(
48
+ `Selected account belongs to ${connection.provider}, not the ${expected} integration`,
49
+ );
50
+ }
51
+ }
52
+
42
53
  export async function socialSearchLive(
43
54
  deps: SocialApiDeps,
44
55
  ref: ConnectionRef,