@opengeni/api-router 0.23.1 → 0.30.0

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 (152) hide show
  1. package/dist/app.d.ts +3 -2
  2. package/dist/app.js +5 -3
  3. package/dist/auth/canonical-human-session-admission.d.ts +27 -0
  4. package/dist/browser-auth-broker.d.ts +20 -0
  5. package/dist/browser-controller-authority.d.ts +61 -0
  6. package/dist/browser-network-route.d.ts +8 -0
  7. package/dist/browser-state-authority.d.ts +27 -0
  8. package/dist/{chunk-T4T2PGU4.js → chunk-2PQMSRCB.js} +37891 -17463
  9. package/dist/chunk-2PQMSRCB.js.map +1 -0
  10. package/dist/codemode.d.ts +23 -0
  11. package/dist/controller-data-plane.d.ts +12 -0
  12. package/dist/editable-artifact-live-hints.d.ts +11 -0
  13. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  14. package/dist/editable-artifact-office-import.d.ts +22 -0
  15. package/dist/editable-artifact-production.d.ts +29 -0
  16. package/dist/editable-artifact-websocket.d.ts +49 -0
  17. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  18. package/dist/github-browser-flow.d.ts +6 -0
  19. package/dist/http/auth.d.ts +1 -1
  20. package/dist/http/cors.d.ts +1 -0
  21. package/dist/http/sse.d.ts +18 -1
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.js +1796 -22
  24. package/dist/index.js.map +1 -1
  25. package/dist/integrations/api-integrations.d.ts +24 -0
  26. package/dist/integrations/atlassian.d.ts +176 -0
  27. package/dist/integrations/fiken.d.ts +233 -0
  28. package/dist/integrations/github-skill-source.d.ts +5 -0
  29. package/dist/integrations/google-drive.d.ts +85 -0
  30. package/dist/integrations/oauth-client.d.ts +22 -1
  31. package/dist/integrations/provider-oauth.d.ts +17 -0
  32. package/dist/integrations/slack-bot.d.ts +175 -1
  33. package/dist/integrations/slack-interactions.d.ts +24 -6
  34. package/dist/integrations/social-api.d.ts +2 -1
  35. package/dist/interaction-holder-heartbeat.d.ts +17 -0
  36. package/dist/interaction-metrics.d.ts +11 -0
  37. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  38. package/dist/mcp/editable-artifacts.d.ts +13 -0
  39. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  40. package/dist/mcp/server.d.ts +16 -4
  41. package/dist/memory-slack-delivery.d.ts +9 -0
  42. package/dist/model-catalog.d.ts +1 -0
  43. package/dist/routes/api-integrations.d.ts +8 -0
  44. package/dist/routes/browser-identities.d.ts +5 -0
  45. package/dist/routes/browser-sessions.d.ts +6 -0
  46. package/dist/routes/canonical-human-identities.d.ts +3 -0
  47. package/dist/routes/channels.d.ts +3 -0
  48. package/dist/routes/company-profile.d.ts +3 -0
  49. package/dist/routes/computer-sessions.d.ts +6 -0
  50. package/dist/routes/editable-artifacts.d.ts +44 -0
  51. package/dist/routes/integration-facets.d.ts +3 -0
  52. package/dist/routes/interaction-resources.d.ts +5 -0
  53. package/dist/routes/memory-slack-publications.d.ts +6 -0
  54. package/dist/routes/organization-memberships.d.ts +3 -0
  55. package/dist/routes/plugins.d.ts +8 -0
  56. package/dist/routes/sessions.d.ts +18 -2
  57. package/dist/routes/skills.d.ts +6 -0
  58. package/dist/routes/slack-task-policy.d.ts +3 -0
  59. package/dist/routes/supergrok.d.ts +3 -0
  60. package/dist/routes/video-generation.d.ts +3 -0
  61. package/dist/sandbox/channel-a.d.ts +62 -2
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  64. package/dist/sandbox/viewer.d.ts +7 -3
  65. package/dist/slack-reaction-files.d.ts +27 -0
  66. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  67. package/dist/transcription/providers/xai-subscription.d.ts +8 -0
  68. package/dist/xai-realtime.d.ts +26 -0
  69. package/dist/xai-subscription-auth.d.ts +23 -0
  70. package/package.json +20 -14
  71. package/src/app.ts +604 -64
  72. package/src/auth/canonical-human-session-admission.ts +116 -0
  73. package/src/auth/managed-auth.ts +101 -0
  74. package/src/browser-auth-broker.ts +155 -0
  75. package/src/browser-controller-authority.ts +186 -0
  76. package/src/browser-network-route.ts +34 -0
  77. package/src/browser-state-authority.ts +236 -0
  78. package/src/codemode.ts +186 -0
  79. package/src/controller-data-plane.ts +35 -0
  80. package/src/editable-artifact-live-hints.ts +64 -0
  81. package/src/editable-artifact-native-kernel.ts +659 -0
  82. package/src/editable-artifact-office-import.ts +230 -0
  83. package/src/editable-artifact-production.ts +419 -0
  84. package/src/editable-artifact-websocket.ts +311 -0
  85. package/src/editable-artifact-workspace-files.ts +186 -0
  86. package/src/github-browser-flow.ts +35 -6
  87. package/src/http/auth.ts +29 -9
  88. package/src/http/cors.ts +3 -0
  89. package/src/http/sse.ts +229 -5
  90. package/src/index.ts +119 -20
  91. package/src/integrations/api-integrations.ts +365 -0
  92. package/src/integrations/atlassian.ts +1621 -0
  93. package/src/integrations/fiken.ts +1230 -0
  94. package/src/integrations/github-skill-source.ts +142 -0
  95. package/src/integrations/google-drive.ts +1005 -64
  96. package/src/integrations/oauth-client.ts +123 -33
  97. package/src/integrations/provider-oauth.ts +756 -0
  98. package/src/integrations/slack-bot.ts +669 -11
  99. package/src/integrations/slack-interactions.ts +2063 -93
  100. package/src/integrations/social-api.ts +11 -0
  101. package/src/interaction-holder-heartbeat.ts +95 -0
  102. package/src/interaction-metrics.ts +159 -0
  103. package/src/mcp/documents.ts +146 -6
  104. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  105. package/src/mcp/editable-artifacts.ts +448 -0
  106. package/src/mcp/scheduled-task-view.ts +34 -0
  107. package/src/mcp/server.ts +1756 -355
  108. package/src/memory-slack-delivery.ts +209 -0
  109. package/src/model-catalog.ts +19 -6
  110. package/src/routes/api-integrations.ts +410 -0
  111. package/src/routes/browser-identities.ts +160 -0
  112. package/src/routes/browser-sessions.ts +4482 -0
  113. package/src/routes/canonical-human-identities.ts +156 -0
  114. package/src/routes/channels.ts +90 -0
  115. package/src/routes/company-profile.ts +255 -0
  116. package/src/routes/computer-sessions.ts +1602 -0
  117. package/src/routes/connections.ts +421 -99
  118. package/src/routes/documents.ts +22 -3
  119. package/src/routes/editable-artifacts.ts +1159 -0
  120. package/src/routes/files.ts +106 -4
  121. package/src/routes/github.ts +18 -2
  122. package/src/routes/install.ts +7 -1
  123. package/src/routes/integration-facets.ts +258 -0
  124. package/src/routes/interaction-resources.ts +595 -0
  125. package/src/routes/memory-slack-publications.ts +216 -0
  126. package/src/routes/organization-memberships.ts +53 -0
  127. package/src/routes/packs.ts +437 -7
  128. package/src/routes/plugins.ts +752 -0
  129. package/src/routes/rigs.ts +77 -20
  130. package/src/routes/scheduled-tasks.ts +67 -40
  131. package/src/routes/sessions.ts +830 -278
  132. package/src/routes/skills.ts +256 -0
  133. package/src/routes/slack-task-policy.ts +115 -0
  134. package/src/routes/supergrok.ts +717 -0
  135. package/src/routes/transcription-recordings.ts +9 -2
  136. package/src/routes/transcriptions.ts +2 -1
  137. package/src/routes/video-generation.ts +162 -0
  138. package/src/routes/workspaces.ts +68 -28
  139. package/src/sandbox/channel-a.ts +750 -116
  140. package/src/sandbox/machines.ts +5 -5
  141. package/src/sandbox/metrics-ingestion.ts +121 -3
  142. package/src/sandbox/rematerialize.ts +35 -47
  143. package/src/sandbox/viewer.ts +39 -12
  144. package/src/slack-reaction-files.ts +181 -0
  145. package/src/temporal-schedule-cleanup.ts +135 -0
  146. package/src/transcription/providers/xai-subscription.ts +110 -0
  147. package/src/transcription/service.ts +17 -2
  148. package/src/xai-realtime.ts +216 -0
  149. package/src/xai-subscription-auth.ts +132 -0
  150. package/dist/chunk-T4T2PGU4.js.map +0 -1
  151. package/dist/mcp/toolspace.d.ts +0 -71
  152. package/src/mcp/toolspace.ts +0 -1190
@@ -1,48 +1,83 @@
1
1
  import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
2
  import {
3
+ approvalIdentifier,
4
+ ApproveSlackUserLinkAccessRequest,
3
5
  DEFAULT_FIRST_PARTY_MCP_TOOLS,
4
6
  hasOpenGeniSlackReactionScope,
7
+ ListSlackUserLinkAccessRequestsResponse,
8
+ PrepareSlackUserLinkAccessRequest,
9
+ evaluateSlackTaskPolicy,
5
10
  resolveWorkspaceSlackReactionSummonSettings,
6
11
  SlackReactionChannelListResponse,
12
+ SlackUserLinkAccessMutationRequest,
13
+ SlackUserLinkAccessRequest,
7
14
  type AccessGrant,
15
+ type FileResourceRef,
8
16
  type FirstPartyMcpToolName,
9
17
  type HumanInputQuestion,
10
18
  type SessionEvent,
11
19
  type WorkspaceSlackReactionSummonSettings,
12
20
  workspaceSlackReactionChannelAllowed,
13
21
  } from "@opengeni/contracts";
22
+ import {
23
+ allowedFirstPartyMcpToolsForSession,
24
+ resolveFirstPartyMcpToolPolicy,
25
+ type Settings,
26
+ } from "@opengeni/config";
14
27
  import {
15
28
  acceptSessionHumanInputResponse,
29
+ acceptSessionApprovalDecision,
30
+ approveSlackUserLinkAccessRequest,
16
31
  advanceSlackInteractionDelivery,
17
32
  bindSlackInteractionSession,
33
+ cancelSlackUserLinkAccessRequest,
18
34
  claimSlackInteractionDelivery,
19
35
  claimSlackInteractionProgressDelivery,
20
36
  claimSlackInteractionInbox,
21
37
  closeSlackInteractionDelivery,
38
+ completeSlackUserLinkAccessIfGranted,
22
39
  deferSlackInteractionDelivery,
23
40
  deleteSlackBotUserLink,
24
41
  enqueueSlackInteractionInbox,
25
42
  getConnectionMetadata,
26
43
  getOrCreateSlackInteraction,
27
44
  getLatestSessionModelForSubject,
45
+ getSession,
46
+ getSessionEvent,
47
+ getSessionHumanInputRequest,
48
+ getSlackBotPostOperation,
28
49
  getSlackBotUserLink,
50
+ getSlackInteractionActionHandle,
29
51
  getSlackInteractionByClientEventId,
52
+ getSlackInteractionById,
30
53
  getSlackInteractionByRoute,
54
+ getActiveSlackTaskPolicy,
55
+ getSlackSharedTaskOrigin,
31
56
  getSessionEventByClientEventId,
32
57
  getWorkspace,
33
58
  getWorkspaceGrant,
59
+ listSlackInteractionProgressDeliveryEvidence,
34
60
  listSessionEventPage,
35
61
  listSessionHumanInputRequests,
62
+ listPendingSlackUserLinkAccessRequests,
36
63
  rekeySlackInteractionRoute,
37
64
  reopenSlackInteractionDelivery,
65
+ reserveSlackInteractionActionHandles,
38
66
  releaseSlackInteractionDelivery,
39
67
  releaseSlackInteractionInbox,
68
+ requestSlackUserLinkWorkspaceAccess,
40
69
  resolveSlackInstallationRoute,
41
- saveSlackBotUserLink,
42
70
  saveSlackInteractionInboxReactionCheckpoint,
71
+ saveSlackSharedTaskOrigin,
43
72
  settleSlackInteractionInbox,
73
+ settleSlackInteractionActionHandles,
74
+ denySlackUserLinkAccessRequest,
75
+ prepareSlackUserLinkAccessRequest,
76
+ SlackUserLinkAccessPersistenceError,
44
77
  type SlackInstallationRoute,
45
78
  type SlackInteraction,
79
+ type SlackInteractionActionHandle,
80
+ type SlackInteractionActionKind,
46
81
  type SlackInteractionInboxEntry,
47
82
  type SlackInteractionTriggerKind,
48
83
  } from "@opengeni/db";
@@ -51,6 +86,7 @@ import {
51
86
  controlHumanSessionWorkstream,
52
87
  createSessionForRequest,
53
88
  hasPermission,
89
+ requireAccessContext,
54
90
  requireAccessGrant,
55
91
  type ApiRouteDeps,
56
92
  } from "@opengeni/core";
@@ -59,15 +95,19 @@ import type { Context, Hono } from "hono";
59
95
  import { HTTPException } from "hono/http-exception";
60
96
  import {
61
97
  createOpenGeniSlackBotInteractionClient,
98
+ SLACK_REACTION_IMAGE_MAX_BYTES,
62
99
  type OpenGeniSlackBotClient,
100
+ type SlackMessageBlock,
63
101
  SlackBotProviderError,
64
102
  } from "./slack-bot";
103
+ import { importSlackReactionImage, type ImportedSlackReactionImage } from "../slack-reaction-files";
65
104
 
66
105
  export const SLACK_INTERACTION_MAX_BODY_BYTES = 256 * 1024;
67
106
  export const SLACK_SIGNATURE_REPLAY_WINDOW_SECONDS = 300;
68
107
  export const SLACK_DELIVERY_EVENT_TYPES = [
69
108
  "agent.message.completed",
70
109
  "session.humanInput.requested",
110
+ "session.requiresAction",
71
111
  "turn.completed",
72
112
  "turn.failed",
73
113
  "turn.cancelled",
@@ -76,16 +116,26 @@ export const SLACK_DELIVERY_EVENT_TYPES = [
76
116
 
77
117
  const MAX_SLACK_TEXT_CHARS = 3_500;
78
118
  const MAX_SLACK_INPUT_CHARS = 8_000;
119
+ const MAX_SLACK_INVOCATION_CONTEXT_MESSAGES = 15;
120
+ const MAX_SLACK_CHANNEL_CONTEXT_MESSAGES = 5;
79
121
  const MAX_SLACK_REACTION_CONTEXT_MESSAGES = 15;
80
122
  const MAX_SLACK_REACTION_FILE_SUMMARY_CHARS = 1_500;
123
+ const MAX_SLACK_REACTION_IMAGES = 4;
124
+ const MAX_SLACK_REACTION_IMAGE_AGGREGATE_BYTES = 16 * 1024 * 1024;
81
125
  const MAX_PROGRESS_MESSAGES = 3;
82
126
  const SLACK_USER_LINK_TTL_MS = 15 * 60_000;
83
127
  const INBOX_LEASE_MS = 30_000;
84
128
  const DELIVERY_LEASE_MS = 30_000;
85
129
  const MAX_DELIVERY_ATTEMPTS = 8;
86
130
  const MAX_DELIVERY_RETRY_MS = 5 * 60_000;
131
+ const SLACK_INTERACTION_BOT_SUBJECT_ID = "service:slack-interaction";
132
+ const SLACK_ACTION_TTL_MS = 7 * 24 * 60 * 60_000;
133
+ const MAX_SLACK_APPROVALS_PER_CARD = 8;
134
+ const MAX_SLACK_ACTIONS_PER_CARD = 20;
87
135
  export const SLACK_TASK_INSTRUCTIONS = [
88
136
  "This turn originated from Slack. Slack message and thread context is task-local only.",
137
+ "Execute direct, safe, sufficiently specified requests immediately.",
138
+ "Ask one concise clarifying question only when materially required information is missing or the requested action is risky, irreversible, or authorization-sensitive.",
89
139
  "Do not write Slack context to Documents, Knowledge, Memory, preferences, Workspace Charter, instructions, or policy unless a separate explicit authorized user action requests it.",
90
140
  "Never expose private reasoning, credentials, secrets, raw logs, or unbounded output.",
91
141
  "Keep user-visible output concise, bounded, and safe to send back to Slack.",
@@ -108,6 +158,14 @@ export const SLACK_TASK_FIRST_PARTY_MCP_TOOLS = [
108
158
  "slack_bot_file_content",
109
159
  ] satisfies readonly FirstPartyMcpToolName[];
110
160
 
161
+ function slackTaskFirstPartyMcpTools(settings: Settings): FirstPartyMcpToolName[] {
162
+ const policy = resolveFirstPartyMcpToolPolicy(settings);
163
+ const connectorTools = SLACK_TASK_FIRST_PARTY_MCP_TOOLS.filter(
164
+ (tool) => !DEFAULT_FIRST_PARTY_MCP_TOOLS.includes(tool),
165
+ );
166
+ return allowedFirstPartyMcpToolsForSession(settings, [...policy.default, ...connectorTools]);
167
+ }
168
+
111
169
  export type NormalizedSlackInteraction = {
112
170
  providerEventId: string;
113
171
  providerMessageId: string;
@@ -118,6 +176,7 @@ export type NormalizedSlackInteraction = {
118
176
  slackThreadTs: string | null;
119
177
  triggerKind: SlackInteractionTriggerKind;
120
178
  text: string;
179
+ hasFiles: boolean;
121
180
  };
122
181
 
123
182
  export function slackInteractionRoutePolicy(
@@ -125,8 +184,9 @@ export function slackInteractionRoutePolicy(
125
184
  SlackInteractionInboxEntry,
126
185
  "triggerKind" | "slackChannelId" | "slackThreadTs" | "slackMessageTs" | "slackUserId"
127
186
  >,
187
+ options: { privateHandoff?: boolean } = {},
128
188
  ) {
129
- const directMessageShortcut = isDirectMessageShortcut(entry);
189
+ const directMessageShortcut = isDirectMessageShortcut(entry) || options.privateHandoff === true;
130
190
  const source = slackRouteKey(entry.slackChannelId, entry.slackThreadTs ?? entry.slackMessageTs);
131
191
  return {
132
192
  directMessageShortcut,
@@ -188,10 +248,16 @@ export function slackEventInboxEntry(
188
248
  const channelId = boundedString(event.channel, 64);
189
249
  const timestamp = boundedString(event.ts, 64);
190
250
  const threadTimestamp = boundedString(event.thread_ts, 64);
191
- const text = boundedText(event.text);
251
+ const hasFiles =
252
+ Array.isArray(event.files) && event.files.some((file) => boundedString(record(file)?.id, 64));
253
+ const text = boundedText(event.text) ?? (hasFiles ? "(file-only Slack invocation)" : null);
192
254
  if (!userId || !channelId || !timestamp || !text) return null;
193
255
  let triggerKind: SlackInteractionTriggerKind;
194
- if (event.type === "app_mention") {
256
+ const explicitlyMentionsBot = text.includes(`<@${bot.botUserId}>`);
257
+ if (
258
+ event.type === "app_mention" ||
259
+ (event.type === "message" && threadTimestamp && explicitlyMentionsBot)
260
+ ) {
195
261
  // A mention is always an explicit invocation. In particular, a mention in
196
262
  // an otherwise-unmapped existing thread adopts that thread as the new
197
263
  // OpenGeni session surface; only ordinary message replies require a
@@ -214,6 +280,7 @@ export function slackEventInboxEntry(
214
280
  slackThreadTs: threadTimestamp,
215
281
  triggerKind,
216
282
  text,
283
+ hasFiles,
217
284
  };
218
285
  }
219
286
 
@@ -264,6 +331,7 @@ export function slackReactionInboxEntry(
264
331
  // Store only the exact emoji name before authorization/content fetch. The
265
332
  // provider message and bounded thread are projected at durable claim time.
266
333
  text: reaction,
334
+ hasFiles: false,
267
335
  };
268
336
  }
269
337
 
@@ -350,6 +418,15 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
350
418
  const signed = await readSignedSlackRequest(c, deps);
351
419
  const form = new URLSearchParams(signed.rawBody);
352
420
  const payload = parseJsonObject(form.get("payload") ?? "");
421
+ if (payload.type === "block_actions") {
422
+ const entry = normalizedBlockActionInteraction(payload);
423
+ const installation = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
424
+ if (!installation) {
425
+ throw new HTTPException(403, { message: "Slack installation unavailable" });
426
+ }
427
+ await enqueueNormalizedSlackInteraction(deps, installation, entry);
428
+ return c.json({ ok: true });
429
+ }
353
430
  if (payload.type !== "message_action") {
354
431
  throw new HTTPException(400, {
355
432
  message: "unsupported Slack interaction",
@@ -386,18 +463,223 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
386
463
  slackThreadTs: threadTs,
387
464
  triggerKind: "message_shortcut",
388
465
  text,
466
+ hasFiles: false,
389
467
  });
390
468
  return c.json({ ok: true });
391
469
  });
392
470
 
471
+ app.post("/v1/workspaces/:workspaceId/integrations/slack/user-link-intents", async (c) => {
472
+ const workspaceId = c.req.param("workspaceId");
473
+ const context = await requireManagedSlackLinkHuman(c, deps);
474
+ const payload = PrepareSlackUserLinkAccessRequest.parse(await c.req.json());
475
+ const signingSecret = deps.settings.slackSigningSecret;
476
+ const link = signingSecret ? verifySlackUserLinkToken(signingSecret, payload.linkToken) : null;
477
+ if (!link || link.workspaceId !== workspaceId) {
478
+ throw freshSlackLinkRequired();
479
+ }
480
+ const route = await resolveSlackInstallationRoute(deps.db, link.slackTeamId);
481
+ if (
482
+ !route ||
483
+ route.workspaceId !== workspaceId ||
484
+ route.connectionId !== link.connectionId ||
485
+ route.accountId.length === 0
486
+ ) {
487
+ throw freshSlackLinkRequired();
488
+ }
489
+ const workspace = await getWorkspace(deps.db, workspaceId);
490
+ if (!workspace || workspace.accountId !== route.accountId) {
491
+ throw freshSlackLinkRequired();
492
+ }
493
+ try {
494
+ const prepared = await prepareSlackUserLinkAccessRequest(deps.db, {
495
+ accountId: route.accountId,
496
+ workspaceId,
497
+ tokenDigest: createHash("sha256").update(payload.linkToken).digest("hex"),
498
+ connectionId: link.connectionId,
499
+ slackTeamId: link.slackTeamId,
500
+ slackUserId: link.slackUserId,
501
+ subjectId: context.subjectId,
502
+ subjectLabel: boundedString(context.subjectLabel, 512),
503
+ expiresAt: new Date(link.expiresAt),
504
+ });
505
+ const completed = await completeSlackUserLinkAccessIfGranted(deps.db, {
506
+ workspaceId,
507
+ requestId: prepared.id,
508
+ subjectId: context.subjectId,
509
+ });
510
+ if (!completed) throw freshSlackLinkRequired();
511
+ return c.json(
512
+ SlackUserLinkAccessRequest.parse({
513
+ ...completed,
514
+ workspaceDisplayName: workspace.name,
515
+ }),
516
+ 201,
517
+ );
518
+ } catch (error) {
519
+ throw slackLinkAccessHttpError(error);
520
+ }
521
+ });
522
+
523
+ app.get(
524
+ "/v1/workspaces/:workspaceId/integrations/slack/user-link-intents/:requestId",
525
+ async (c) => {
526
+ const workspaceId = c.req.param("workspaceId");
527
+ const context = await requireManagedSlackLinkHuman(c, deps);
528
+ const requestId = c.req.param("requestId");
529
+ try {
530
+ const current = await completeSlackUserLinkAccessIfGranted(deps.db, {
531
+ workspaceId,
532
+ requestId,
533
+ subjectId: context.subjectId,
534
+ });
535
+ if (!current) throw freshSlackLinkRequired();
536
+ const workspace = await getWorkspace(deps.db, workspaceId);
537
+ return c.json(
538
+ SlackUserLinkAccessRequest.parse({
539
+ ...current,
540
+ workspaceDisplayName: workspace?.name ?? null,
541
+ }),
542
+ );
543
+ } catch (error) {
544
+ throw slackLinkAccessHttpError(error);
545
+ }
546
+ },
547
+ );
548
+
549
+ app.post(
550
+ "/v1/workspaces/:workspaceId/integrations/slack/user-link-intents/:requestId/request-access",
551
+ async (c) => {
552
+ const workspaceId = c.req.param("workspaceId");
553
+ const context = await requireManagedSlackLinkHuman(c, deps);
554
+ const payload = SlackUserLinkAccessMutationRequest.parse(await c.req.json());
555
+ try {
556
+ const request = await requestSlackUserLinkWorkspaceAccess(deps.db, {
557
+ workspaceId,
558
+ requestId: c.req.param("requestId"),
559
+ actorSubjectId: context.subjectId,
560
+ ...payload,
561
+ });
562
+ const workspace = await getWorkspace(deps.db, workspaceId);
563
+ return c.json(
564
+ SlackUserLinkAccessRequest.parse({
565
+ ...request,
566
+ workspaceDisplayName: workspace?.name ?? null,
567
+ }),
568
+ );
569
+ } catch (error) {
570
+ throw slackLinkAccessHttpError(error);
571
+ }
572
+ },
573
+ );
574
+
575
+ app.post(
576
+ "/v1/workspaces/:workspaceId/integrations/slack/user-link-intents/:requestId/cancel",
577
+ async (c) => {
578
+ const workspaceId = c.req.param("workspaceId");
579
+ const context = await requireManagedSlackLinkHuman(c, deps);
580
+ const payload = SlackUserLinkAccessMutationRequest.parse(await c.req.json());
581
+ try {
582
+ const request = await cancelSlackUserLinkAccessRequest(deps.db, {
583
+ workspaceId,
584
+ requestId: c.req.param("requestId"),
585
+ actorSubjectId: context.subjectId,
586
+ ...payload,
587
+ });
588
+ const workspace = await getWorkspace(deps.db, workspaceId);
589
+ return c.json(
590
+ SlackUserLinkAccessRequest.parse({
591
+ ...request,
592
+ workspaceDisplayName: workspace?.name ?? null,
593
+ }),
594
+ );
595
+ } catch (error) {
596
+ throw slackLinkAccessHttpError(error);
597
+ }
598
+ },
599
+ );
600
+
601
+ app.get("/v1/workspaces/:workspaceId/members/access-requests/slack", async (c) => {
602
+ const workspaceId = c.req.param("workspaceId");
603
+ await requireAccessGrant(c, deps, workspaceId, "members:manage");
604
+ const workspace = await getWorkspace(deps.db, workspaceId);
605
+ const requests = await listPendingSlackUserLinkAccessRequests(deps.db, workspaceId);
606
+ return c.json(
607
+ ListSlackUserLinkAccessRequestsResponse.parse({
608
+ requests: requests.map((request) => ({
609
+ ...request,
610
+ workspaceDisplayName: workspace?.name ?? null,
611
+ })),
612
+ }),
613
+ );
614
+ });
615
+
616
+ app.post(
617
+ "/v1/workspaces/:workspaceId/members/access-requests/slack/:requestId/approve",
618
+ async (c) => {
619
+ const workspaceId = c.req.param("workspaceId");
620
+ const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
621
+ const payload = ApproveSlackUserLinkAccessRequest.parse(await c.req.json());
622
+ try {
623
+ const request = await approveSlackUserLinkAccessRequest(deps.db, {
624
+ workspaceId,
625
+ requestId: c.req.param("requestId"),
626
+ actorSubjectId: grant.subjectId,
627
+ expectedVersion: payload.expectedVersion,
628
+ idempotencyKey: payload.idempotencyKey,
629
+ permissions: payload.permissions,
630
+ ...(payload.role !== undefined ? { role: payload.role } : {}),
631
+ });
632
+ const workspace = await getWorkspace(deps.db, workspaceId);
633
+ return c.json(
634
+ SlackUserLinkAccessRequest.parse({
635
+ ...request,
636
+ workspaceDisplayName: workspace?.name ?? null,
637
+ }),
638
+ );
639
+ } catch (error) {
640
+ throw slackLinkAccessHttpError(error);
641
+ }
642
+ },
643
+ );
644
+
645
+ app.post(
646
+ "/v1/workspaces/:workspaceId/members/access-requests/slack/:requestId/deny",
647
+ async (c) => {
648
+ const workspaceId = c.req.param("workspaceId");
649
+ const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
650
+ const payload = SlackUserLinkAccessMutationRequest.parse(await c.req.json());
651
+ try {
652
+ const request = await denySlackUserLinkAccessRequest(deps.db, {
653
+ workspaceId,
654
+ requestId: c.req.param("requestId"),
655
+ actorSubjectId: grant.subjectId,
656
+ ...payload,
657
+ });
658
+ const workspace = await getWorkspace(deps.db, workspaceId);
659
+ return c.json(
660
+ SlackUserLinkAccessRequest.parse({
661
+ ...request,
662
+ workspaceDisplayName: workspace?.name ?? null,
663
+ }),
664
+ );
665
+ } catch (error) {
666
+ throw slackLinkAccessHttpError(error);
667
+ }
668
+ },
669
+ );
670
+
393
671
  app.post("/v1/workspaces/:workspaceId/integrations/slack/user-links", async (c) => {
394
672
  const workspaceId = c.req.param("workspaceId");
395
673
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
396
674
  const body = record(await c.req.json().catch(() => null));
397
675
  const linkToken = boundedString(body?.linkToken, 2_048);
398
676
  const signingSecret = deps.settings.slackSigningSecret;
399
- const link =
400
- linkToken && signingSecret ? verifySlackUserLinkToken(signingSecret, linkToken) : null;
677
+ if (!linkToken || !signingSecret) {
678
+ throw new HTTPException(400, {
679
+ message: "invalid or expired Slack identity link",
680
+ });
681
+ }
682
+ const link = verifySlackUserLinkToken(signingSecret, linkToken);
401
683
  if (!link || link.workspaceId !== workspaceId) {
402
684
  throw new HTTPException(400, {
403
685
  message: "invalid or expired Slack identity link",
@@ -409,18 +691,35 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
409
691
  message: "Slack installation not found",
410
692
  });
411
693
  }
412
- return c.json(
413
- await saveSlackBotUserLink(deps.db, {
694
+ try {
695
+ const prepared = await prepareSlackUserLinkAccessRequest(deps.db, {
414
696
  accountId: grant.accountId,
415
697
  workspaceId,
698
+ tokenDigest: createHash("sha256").update(linkToken).digest("hex"),
416
699
  connectionId: link.connectionId,
417
700
  slackTeamId: link.slackTeamId,
418
701
  slackUserId: link.slackUserId,
419
702
  subjectId: grant.subjectId,
420
- linkedBySubjectId: grant.subjectId,
421
- }),
422
- 201,
423
- );
703
+ subjectLabel: boundedString(grant.subjectLabel, 512),
704
+ expiresAt: new Date(link.expiresAt),
705
+ });
706
+ const completed = await completeSlackUserLinkAccessIfGranted(deps.db, {
707
+ workspaceId,
708
+ requestId: prepared.id,
709
+ subjectId: grant.subjectId,
710
+ });
711
+ if (completed?.status !== "completed") throw freshSlackLinkRequired();
712
+ const saved = await getSlackBotUserLink(
713
+ deps.db,
714
+ workspaceId,
715
+ link.connectionId,
716
+ link.slackUserId,
717
+ );
718
+ if (!saved || saved.subjectId !== grant.subjectId) throw freshSlackLinkRequired();
719
+ return c.json(saved, 201);
720
+ } catch (error) {
721
+ throw slackLinkAccessHttpError(error);
722
+ }
424
723
  });
425
724
 
426
725
  app.delete(
@@ -587,11 +886,51 @@ export function startSlackInteractionPump(
587
886
  }
588
887
 
589
888
  async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
889
+ if (entry.triggerKind === "block_action") {
890
+ await processSlackBlockAction(deps, entry);
891
+ return;
892
+ }
590
893
  if (entry.triggerKind === "reaction") {
591
894
  await processSlackReactionInboxEntry(deps, entry);
592
895
  return;
593
896
  }
594
- const routePolicy = slackInteractionRoutePolicy(entry);
897
+ const installation = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
898
+ if (
899
+ !installation ||
900
+ installation.accountId !== entry.accountId ||
901
+ installation.workspaceId !== entry.workspaceId ||
902
+ installation.connectionId !== entry.connectionId
903
+ ) {
904
+ throw new SlackInteractionPermanentError("slack_task_installation_changed");
905
+ }
906
+ const client = await createOpenGeniSlackBotInteractionClient(deps, {
907
+ accountId: entry.accountId,
908
+ workspaceId: entry.workspaceId,
909
+ connectionId: entry.connectionId,
910
+ subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
911
+ });
912
+ const policyResolution = isDirectMessageShortcut(entry)
913
+ ? ({
914
+ decision: {
915
+ disposition: "ordinary",
916
+ publication: "allow",
917
+ reason: "ordinary_conversation",
918
+ } as const,
919
+ activePolicy: null,
920
+ } as const)
921
+ : await slackTaskPolicyDecision(deps, client, entry);
922
+ const policyDecision = policyResolution.decision;
923
+ if (policyDecision.disposition === "deny") {
924
+ await client.postMessage({
925
+ operationId: deterministicUuid(`slack-policy-denied:${entry.id}`),
926
+ userId: entry.slackUserId,
927
+ text: "OpenGeni cannot start a task from this shared conversation under the current workspace policy. No conversation content was read or retained.",
928
+ });
929
+ return;
930
+ }
931
+ const routePolicy = slackInteractionRoutePolicy(entry, {
932
+ privateHandoff: policyDecision.disposition === "private_handoff",
933
+ });
595
934
  const routeKey = routePolicy.initialRouteKey;
596
935
  const existing = await getSlackInteractionByRoute(
597
936
  deps.db,
@@ -607,16 +946,6 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
607
946
  entry.connectionId,
608
947
  entry.slackUserId,
609
948
  );
610
- const client = await createOpenGeniSlackBotInteractionClient(deps, {
611
- accountId: entry.accountId,
612
- workspaceId: entry.workspaceId,
613
- connectionId: entry.connectionId,
614
- subjectId: link?.subjectId ?? "service:slack-interaction",
615
- ...(existing?.sessionId ? { sessionId: existing.sessionId } : {}),
616
- });
617
- if (routePolicy.requiresChannelAccess) {
618
- await client.verifyChannelAccess(entry.slackChannelId);
619
- }
620
949
  if (!link) {
621
950
  await client.postMessage({
622
951
  operationId: deterministicUuid(`slack-link:${entry.id}`),
@@ -657,15 +986,16 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
657
986
  if (!boundInteraction) {
658
987
  throw new Error("Durable Slack interaction could not bind its reserved session");
659
988
  }
989
+ await ensureSlackSharedTaskOrigin(deps, boundInteraction, entry, policyResolution);
660
990
  const shouldRepairAcknowledgement =
661
991
  interaction.triggeringProviderEventId === entry.providerEventId ||
662
- (isDirectMessageShortcut(entry) && boundInteraction.ackSlackMessageTs === null);
992
+ (usesPrivateBotDm(boundInteraction, entry) && boundInteraction.ackSlackMessageTs === null);
663
993
  if (shouldRepairAcknowledgement) {
664
994
  const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
665
995
  accountId: entry.accountId,
666
996
  workspaceId: entry.workspaceId,
667
997
  connectionId: entry.connectionId,
668
- subjectId: grant.subjectId,
998
+ subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
669
999
  sessionId: eventSessionId,
670
1000
  });
671
1001
  await acknowledgeSlackSession(deps, boundClient, boundInteraction, entry);
@@ -673,8 +1003,42 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
673
1003
  return;
674
1004
  }
675
1005
 
1006
+ let preparedEntry = entry;
1007
+ let preparedAttachments: PreparedSlackReactionTask = {
1008
+ resources: [],
1009
+ attachments: [],
1010
+ omissionCodes: [],
1011
+ omittedCount: 0,
1012
+ };
1013
+ if (entry.triggerKind === "app_mention" || entry.hasFiles) {
1014
+ const prepared = await prepareSlackInvocationEntry(
1015
+ deps,
1016
+ client,
1017
+ entry,
1018
+ policyDecision.disposition === "private_handoff"
1019
+ ? async () => {
1020
+ await requireSlackSharedReadAuthorization(deps, client, entry, policyResolution);
1021
+ }
1022
+ : undefined,
1023
+ );
1024
+ preparedEntry = prepared.entry;
1025
+ preparedAttachments = prepared.attachments;
1026
+ }
1027
+
676
1028
  if (existing?.sessionId) {
677
- await continueSlackSession(deps, grant, existing, entry);
1029
+ const remounted = await remountSlackReactionTaskForSession(
1030
+ deps,
1031
+ entry.workspaceId,
1032
+ existing.sessionId,
1033
+ preparedAttachments,
1034
+ );
1035
+ await continueSlackSession(
1036
+ deps,
1037
+ grant,
1038
+ existing,
1039
+ slackInvocationPreparedEntry(preparedEntry, remounted),
1040
+ remounted.resources,
1041
+ );
678
1042
  return;
679
1043
  }
680
1044
  if (!hasPermission(grant.permissions, "sessions:create")) {
@@ -689,11 +1053,24 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
689
1053
  slackThreadTs: entry.slackThreadTs ?? entry.slackMessageTs,
690
1054
  routeKey,
691
1055
  triggeringProviderEventId: entry.providerEventId,
1056
+ initiatingSlackUserId: entry.slackUserId,
692
1057
  owningSubjectId: grant.subjectId,
693
1058
  visibility: routePolicy.visibility,
694
1059
  });
695
1060
  if (interaction.sessionId) {
696
- await continueSlackSession(deps, grant, interaction, entry);
1061
+ const remounted = await remountSlackReactionTaskForSession(
1062
+ deps,
1063
+ entry.workspaceId,
1064
+ interaction.sessionId,
1065
+ preparedAttachments,
1066
+ );
1067
+ await continueSlackSession(
1068
+ deps,
1069
+ grant,
1070
+ interaction,
1071
+ slackInvocationPreparedEntry(preparedEntry, remounted),
1072
+ remounted.resources,
1073
+ );
697
1074
  return;
698
1075
  }
699
1076
  const preferredModel = await getLatestSessionModelForSubject(
@@ -705,9 +1082,10 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
705
1082
  try {
706
1083
  session = await createSessionForRequest(deps, grant, entry.workspaceId, {
707
1084
  requestedSessionId: interaction.sessionReservationId,
708
- initialMessage: entry.text,
1085
+ initialMessage: slackInvocationPreparedEntry(preparedEntry, preparedAttachments).text,
709
1086
  turnInstructions: SLACK_TASK_INSTRUCTIONS,
710
- firstPartyMcpTools: [...SLACK_TASK_FIRST_PARTY_MCP_TOOLS],
1087
+ firstPartyMcpTools: slackTaskFirstPartyMcpTools(deps.settings),
1088
+ resources: preparedAttachments.resources,
711
1089
  ...(preferredModel ? { model: preferredModel } : {}),
712
1090
  idempotencyKey: `slack:${entry.connectionId}:${entry.providerEventId}`,
713
1091
  clientEventId: `slack:${entry.providerEventId}`,
@@ -716,7 +1094,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
716
1094
  if (error instanceof HTTPException) {
717
1095
  await client.postMessage({
718
1096
  operationId: deterministicUuid(`slack-admission-failed:${interaction.id}`),
719
- ...(isDirectMessageShortcut(entry)
1097
+ ...(routePolicy.directMessageShortcut
720
1098
  ? { userId: entry.slackUserId }
721
1099
  : {
722
1100
  channelId: entry.slackChannelId,
@@ -735,16 +1113,243 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
735
1113
  sessionId: session.id,
736
1114
  });
737
1115
  if (!bound) throw new Error("Slack route could not bind its durable session");
1116
+ await ensureSlackSharedTaskOrigin(deps, bound, entry, policyResolution);
738
1117
  const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
739
1118
  accountId: entry.accountId,
740
1119
  workspaceId: entry.workspaceId,
741
1120
  connectionId: entry.connectionId,
742
- subjectId: grant.subjectId,
1121
+ subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
743
1122
  sessionId: session.id,
744
1123
  });
745
1124
  await acknowledgeSlackSession(deps, boundClient, bound, entry);
746
1125
  }
747
1126
 
1127
+ async function slackTaskPolicyDecision(
1128
+ deps: ApiRouteDeps,
1129
+ client: OpenGeniSlackBotClient,
1130
+ entry: SlackInteractionInboxEntry,
1131
+ ) {
1132
+ const facts = await client.slackTaskPolicyFacts(entry.slackChannelId, entry.slackUserId);
1133
+ const activePolicy = facts.initiator
1134
+ ? await getActiveSlackTaskPolicy(deps.db, {
1135
+ accountId: entry.accountId,
1136
+ workspaceId: entry.workspaceId,
1137
+ })
1138
+ : null;
1139
+ const decision = evaluateSlackTaskPolicy({
1140
+ policy: activePolicy?.revision.policy ?? null,
1141
+ conversation: {
1142
+ installationTeamId: entry.slackTeamId,
1143
+ conversationId: facts.conversation.id,
1144
+ contextTeamId: facts.conversation.contextTeamId,
1145
+ connectedTeamIds: facts.conversation.connectedTeamIds,
1146
+ sharedTeamIds: facts.conversation.sharedTeamIds,
1147
+ isShared: facts.conversation.isShared,
1148
+ isExternallyShared: facts.conversation.isExternallyShared,
1149
+ isOrgShared: facts.conversation.isOrgShared,
1150
+ isPendingExternallyShared: facts.conversation.isPendingExternallyShared,
1151
+ isMpim: facts.conversation.isMpim,
1152
+ },
1153
+ initiator: facts.initiator ?? { teamId: null, isGuest: null, isExternal: null },
1154
+ });
1155
+ return { decision, activePolicy };
1156
+ }
1157
+
1158
+ async function ensureSlackSharedTaskOrigin(
1159
+ deps: ApiRouteDeps,
1160
+ interaction: SlackInteraction,
1161
+ entry: SlackInteractionInboxEntry,
1162
+ resolution:
1163
+ | Awaited<ReturnType<typeof slackTaskPolicyDecision>>
1164
+ | {
1165
+ decision: {
1166
+ disposition: "ordinary";
1167
+ publication: "allow";
1168
+ reason: "ordinary_conversation";
1169
+ };
1170
+ activePolicy: null;
1171
+ },
1172
+ ): Promise<void> {
1173
+ if (resolution.decision.disposition !== "private_handoff") return;
1174
+ if (!resolution.activePolicy || !interaction.sessionId || !interaction.initiatingSlackUserId) {
1175
+ throw new SlackInteractionPermanentError("slack_shared_task_origin_incomplete");
1176
+ }
1177
+ await saveSlackSharedTaskOrigin(deps.db, {
1178
+ accountId: interaction.accountId,
1179
+ workspaceId: interaction.workspaceId,
1180
+ interactionId: interaction.id,
1181
+ connectionId: interaction.connectionId,
1182
+ sessionId: interaction.sessionId,
1183
+ slackTeamId: entry.slackTeamId,
1184
+ sourceChannelId: entry.slackChannelId,
1185
+ sourceThreadTs: entry.slackThreadTs ?? entry.slackMessageTs,
1186
+ initiatingSlackUserId: interaction.initiatingSlackUserId,
1187
+ policyRevisionId: resolution.activePolicy.revision.id,
1188
+ policyHash: resolution.activePolicy.revision.policyHash,
1189
+ policyActivationVersion: resolution.activePolicy.head.activationVersion,
1190
+ publicationMode: resolution.decision.publication,
1191
+ });
1192
+ }
1193
+
1194
+ async function requireSlackSharedReadAuthorization(
1195
+ deps: ApiRouteDeps,
1196
+ client: OpenGeniSlackBotClient,
1197
+ entry: SlackInteractionInboxEntry,
1198
+ resolution: Awaited<ReturnType<typeof slackTaskPolicyDecision>>,
1199
+ ): Promise<void> {
1200
+ const current = await slackTaskPolicyDecision(deps, client, entry);
1201
+ if (
1202
+ !resolution.activePolicy ||
1203
+ !current.activePolicy ||
1204
+ current.decision.disposition !== "private_handoff" ||
1205
+ current.activePolicy.revision.id !== resolution.activePolicy.revision.id ||
1206
+ current.activePolicy.revision.policyHash !== resolution.activePolicy.revision.policyHash ||
1207
+ current.activePolicy.head.activationVersion !== resolution.activePolicy.head.activationVersion
1208
+ ) {
1209
+ throw new SlackInteractionPermanentError("slack_shared_policy_changed_before_read");
1210
+ }
1211
+ }
1212
+
1213
+ export type SlackInvocationMessageContext = {
1214
+ messages: Awaited<ReturnType<OpenGeniSlackBotClient["threadReplies"]>>["messages"];
1215
+ nextCursor: string | null;
1216
+ kind: "thread" | "channel";
1217
+ };
1218
+
1219
+ async function prepareSlackInvocationEntry(
1220
+ deps: ApiRouteDeps,
1221
+ client: OpenGeniSlackBotClient,
1222
+ entry: SlackInteractionInboxEntry,
1223
+ authorizeRead?: () => Promise<void>,
1224
+ ): Promise<{ entry: SlackInteractionInboxEntry; attachments: PreparedSlackReactionTask }> {
1225
+ const context = entry.slackThreadTs
1226
+ ? await client.threadReplies({
1227
+ channelId: entry.slackChannelId,
1228
+ threadTimestamp: entry.slackThreadTs,
1229
+ limit: MAX_SLACK_INVOCATION_CONTEXT_MESSAGES,
1230
+ ...(authorizeRead ? { authorizeRead } : {}),
1231
+ })
1232
+ : await client.channelHistory({
1233
+ channelId: entry.slackChannelId,
1234
+ latest: entry.slackMessageTs,
1235
+ inclusive: true,
1236
+ limit: MAX_SLACK_CHANNEL_CONTEXT_MESSAGES,
1237
+ ...(authorizeRead ? { authorizeRead } : {}),
1238
+ });
1239
+ let exactMessage = context.messages.find((message) => message.timestamp === entry.slackMessageTs);
1240
+ if (!exactMessage && entry.hasFiles && entry.slackThreadTs) {
1241
+ const exact = await client.threadReplies({
1242
+ channelId: entry.slackChannelId,
1243
+ threadTimestamp: entry.slackThreadTs,
1244
+ oldest: entry.slackMessageTs,
1245
+ latest: entry.slackMessageTs,
1246
+ inclusive: true,
1247
+ limit: 1,
1248
+ ...(authorizeRead ? { authorizeRead } : {}),
1249
+ });
1250
+ exactMessage = exact.messages.find((message) => message.timestamp === entry.slackMessageTs);
1251
+ }
1252
+ const attachments = exactMessage
1253
+ ? await prepareSlackMessageAttachments(deps, client, entry, exactMessage.files)
1254
+ : {
1255
+ resources: [],
1256
+ attachments: [],
1257
+ omissionCodes: entry.hasFiles ? ["attachment_unavailable"] : [],
1258
+ omittedCount: entry.hasFiles ? 1 : 0,
1259
+ };
1260
+ const baseText =
1261
+ entry.triggerKind === "app_mention"
1262
+ ? slackInvocationTaskText(entry, {
1263
+ messages: context.messages,
1264
+ nextCursor: context.nextCursor,
1265
+ kind: entry.slackThreadTs ? "thread" : "channel",
1266
+ })
1267
+ : entry.text;
1268
+ return {
1269
+ entry: { ...entry, text: baseText },
1270
+ attachments,
1271
+ };
1272
+ }
1273
+
1274
+ function slackInvocationPreparedEntry(
1275
+ entry: SlackInteractionInboxEntry,
1276
+ attachments: PreparedSlackReactionTask,
1277
+ ): SlackInteractionInboxEntry {
1278
+ const manifest = slackAttachmentManifest(
1279
+ attachments,
1280
+ "Imported invocation attachments",
1281
+ "invocation",
1282
+ );
1283
+ return manifest ? { ...entry, text: `${entry.text}\n\n${manifest}` } : entry;
1284
+ }
1285
+
1286
+ export function slackInvocationTaskText(
1287
+ entry: Pick<SlackInteractionInboxEntry, "slackMessageTs" | "slackUserId" | "text">,
1288
+ context: SlackInvocationMessageContext,
1289
+ ) {
1290
+ const invocation = {
1291
+ timestamp: entry.slackMessageTs,
1292
+ userId: entry.slackUserId,
1293
+ botId: "",
1294
+ threadTimestamp: "",
1295
+ text: entry.text,
1296
+ files: [],
1297
+ };
1298
+ const surroundingLines = context.messages
1299
+ .filter((message) => message.timestamp !== entry.slackMessageTs)
1300
+ .slice(0, MAX_SLACK_INVOCATION_CONTEXT_MESSAGES)
1301
+ .sort((left, right) => left.timestamp.localeCompare(right.timestamp))
1302
+ .map((message) => slackContextMessageLine(message));
1303
+ const contextLabel =
1304
+ context.kind === "thread"
1305
+ ? "Bounded containing-thread context (oldest to newest):"
1306
+ : "Bounded nearby channel context before the invocation (oldest to newest):";
1307
+ const truncationNotice =
1308
+ context.kind === "thread"
1309
+ ? "The containing thread was truncated at the bounded Slack context limit."
1310
+ : "Only bounded nearby channel context was provided.";
1311
+ const invocationTruncationNotice =
1312
+ "The exact Slack invocation was truncated at the bounded Slack input limit.";
1313
+ const prefix = [
1314
+ "A linked, authorized Slack user explicitly mentioned OpenGeni.",
1315
+ "Treat references such as 'this', 'that', or 'the previous message' as referring to the bounded Slack context below when applicable.",
1316
+ "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.",
1317
+ "",
1318
+ "Exact invocation:",
1319
+ ].join("\n");
1320
+ const suffix = `\n\n${contextLabel}`;
1321
+ const invocationLine = slackContextMessageLine(invocation, "invocation");
1322
+ const reservedNotices = `\n${invocationTruncationNotice}\n${truncationNotice}`;
1323
+ const maxInvocationChars = Math.max(
1324
+ 1,
1325
+ MAX_SLACK_INPUT_CHARS - prefix.length - suffix.length - reservedNotices.length - 1,
1326
+ );
1327
+ const invocationTruncated = invocationLine.length > maxInvocationChars;
1328
+ let prompt = `${prefix}\n${
1329
+ invocationTruncated
1330
+ ? `${invocationLine.slice(0, Math.max(0, maxInvocationChars - 1))}…`
1331
+ : invocationLine
1332
+ }${suffix}`;
1333
+ let contextTruncated = context.nextCursor !== null;
1334
+ for (const line of surroundingLines) {
1335
+ const candidate = `${prompt}\n${line}`;
1336
+ const notices = [
1337
+ ...(invocationTruncated ? [invocationTruncationNotice] : []),
1338
+ truncationNotice,
1339
+ ].join("\n");
1340
+ if (candidate.length + 1 + notices.length > MAX_SLACK_INPUT_CHARS) {
1341
+ contextTruncated = true;
1342
+ break;
1343
+ }
1344
+ prompt = candidate;
1345
+ }
1346
+ const notices = [
1347
+ ...(invocationTruncated ? [invocationTruncationNotice] : []),
1348
+ ...(contextTruncated ? [truncationNotice] : []),
1349
+ ];
1350
+ return notices.length > 0 ? `${prompt}\n${notices.join("\n")}` : prompt;
1351
+ }
1352
+
748
1353
  async function acknowledgeSlackSession(
749
1354
  deps: ApiRouteDeps,
750
1355
  client: OpenGeniSlackBotClient,
@@ -755,9 +1360,23 @@ async function acknowledgeSlackSession(
755
1360
  throw new Error("Slack acknowledgement requires a bound session");
756
1361
  }
757
1362
  const directMessageShortcut = isDirectMessageShortcut(entry);
1363
+ const privateHandoff =
1364
+ !directMessageShortcut && interaction.visibility === "private" && entry.triggerKind !== "dm";
1365
+ const privateBotDm = directMessageShortcut || privateHandoff;
1366
+ const operationId = deterministicUuid(`slack-ack:${interaction.id}`);
1367
+ const text = directMessageShortcut
1368
+ ? `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.`
1369
+ : privateHandoff
1370
+ ? `OpenGeni started a private task from the selected Slack conversation. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this bot-DM thread to continue, or reply \`stop\` to stop. Results stay private unless a separate authorized publication is approved.`
1371
+ : `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.`;
1372
+ const controls = await controlActionBlocks(deps, interaction, {
1373
+ messageOperationId: operationId,
1374
+ sessionEventSequence: 0,
1375
+ state: "active",
1376
+ });
758
1377
  const ack = await client.postMessage({
759
- operationId: deterministicUuid(`slack-ack:${interaction.id}`),
760
- ...(directMessageShortcut
1378
+ operationId,
1379
+ ...(privateBotDm
761
1380
  ? { userId: entry.slackUserId }
762
1381
  : {
763
1382
  channelId: entry.slackChannelId,
@@ -765,18 +1384,24 @@ async function acknowledgeSlackSession(
765
1384
  ? {}
766
1385
  : { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
767
1386
  }),
768
- text: directMessageShortcut
769
- ? `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.`
770
- : `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.`,
1387
+ text,
1388
+ ...(controls.length > 0
1389
+ ? {
1390
+ blocks: [
1391
+ { type: "section", text: { type: "mrkdwn", text } },
1392
+ ...controls,
1393
+ ] as SlackMessageBlock[],
1394
+ }
1395
+ : {}),
771
1396
  });
772
- if (entry.triggerKind === "slash_command" || directMessageShortcut) {
1397
+ if (entry.triggerKind === "slash_command" || privateBotDm) {
773
1398
  const rekeyed = await rekeySlackInteractionRoute(deps.db, {
774
1399
  ...interaction,
775
1400
  routeKey: slackRouteKey(ack.channelId, ack.timestamp),
776
1401
  slackChannelId: ack.channelId,
777
1402
  slackThreadTs: ack.timestamp,
778
1403
  ackSlackMessageTs: ack.timestamp,
779
- repairUnacknowledgedPrivateShortcutDelivery: directMessageShortcut,
1404
+ repairUnacknowledgedPrivateShortcutDelivery: privateBotDm,
780
1405
  });
781
1406
  if (!rekeyed) throw new Error("Slack acknowledgement could not rekey its durable route");
782
1407
  }
@@ -854,7 +1479,7 @@ async function processSlackReactionInboxEntry(
854
1479
  accountId: entry.accountId,
855
1480
  workspaceId: entry.workspaceId,
856
1481
  connectionId: entry.connectionId,
857
- subjectId: grant.subjectId,
1482
+ subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
858
1483
  sessionId: eventSessionId,
859
1484
  });
860
1485
  await acknowledgeSlackReactionSession(deps, client, boundInteraction, settings.emoji);
@@ -866,7 +1491,7 @@ async function processSlackReactionInboxEntry(
866
1491
  accountId: entry.accountId,
867
1492
  workspaceId: entry.workspaceId,
868
1493
  connectionId: entry.connectionId,
869
- subjectId: grant.subjectId,
1494
+ subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
870
1495
  });
871
1496
  const context = await client.reactionMessageContext({
872
1497
  channelId: entry.slackChannelId,
@@ -895,11 +1520,7 @@ async function processSlackReactionInboxEntry(
895
1520
  if (!saved) throw new Error("Slack reaction inbox checkpoint claim was lost");
896
1521
  },
897
1522
  });
898
- const preparedEntry: SlackInteractionInboxEntry = {
899
- ...entry,
900
- slackThreadTs: context.threadTimestamp,
901
- text: slackReactionTaskText(context),
902
- };
1523
+ const preparedTask = await prepareSlackReactionTask(deps, client, entry, context);
903
1524
  const routeKey = slackRouteKey(entry.slackChannelId, context.threadTimestamp);
904
1525
  const existing = await getSlackInteractionByRoute(
905
1526
  deps.db,
@@ -908,7 +1529,19 @@ async function processSlackReactionInboxEntry(
908
1529
  routeKey,
909
1530
  );
910
1531
  if (existing?.sessionId) {
911
- await continueSlackReactionSession(deps, grant, existing, preparedEntry);
1532
+ const appendedTask = await remountSlackReactionTaskForSession(
1533
+ deps,
1534
+ entry.workspaceId,
1535
+ existing.sessionId,
1536
+ preparedTask,
1537
+ );
1538
+ await continueSlackReactionSession(
1539
+ deps,
1540
+ grant,
1541
+ existing,
1542
+ slackReactionPreparedEntry(entry, context, appendedTask),
1543
+ appendedTask.resources,
1544
+ );
912
1545
  return;
913
1546
  }
914
1547
  const { interaction } = await getOrCreateSlackInteraction(deps.db, {
@@ -920,11 +1553,24 @@ async function processSlackReactionInboxEntry(
920
1553
  slackThreadTs: context.threadTimestamp,
921
1554
  routeKey,
922
1555
  triggeringProviderEventId: entry.providerEventId,
1556
+ initiatingSlackUserId: entry.slackUserId,
923
1557
  owningSubjectId: grant.subjectId,
924
1558
  visibility: "workspace",
925
1559
  });
926
1560
  if (interaction.sessionId) {
927
- await continueSlackReactionSession(deps, grant, interaction, preparedEntry);
1561
+ const appendedTask = await remountSlackReactionTaskForSession(
1562
+ deps,
1563
+ entry.workspaceId,
1564
+ interaction.sessionId,
1565
+ preparedTask,
1566
+ );
1567
+ await continueSlackReactionSession(
1568
+ deps,
1569
+ grant,
1570
+ interaction,
1571
+ slackReactionPreparedEntry(entry, context, appendedTask),
1572
+ appendedTask.resources,
1573
+ );
928
1574
  return;
929
1575
  }
930
1576
  if (interaction.owningSubjectId !== grant.subjectId) {
@@ -940,6 +1586,7 @@ async function processSlackReactionInboxEntry(
940
1586
  entry.workspaceId,
941
1587
  grant.subjectId,
942
1588
  );
1589
+ const preparedEntry = slackReactionPreparedEntry(entry, context, preparedTask);
943
1590
  let session: Awaited<ReturnType<typeof createSessionForRequest>>;
944
1591
  try {
945
1592
  session = await createSessionForRequest(deps, grant, entry.workspaceId, {
@@ -948,7 +1595,8 @@ async function processSlackReactionInboxEntry(
948
1595
  turnInstructions: SLACK_TASK_INSTRUCTIONS,
949
1596
  // The exact reacted message and bounded containing thread are already in
950
1597
  // the prompt; do not expose general Slack history tools for this trigger.
951
- firstPartyMcpTools: [...DEFAULT_FIRST_PARTY_MCP_TOOLS],
1598
+ firstPartyMcpTools: resolveFirstPartyMcpToolPolicy(deps.settings).default,
1599
+ resources: preparedTask.resources,
952
1600
  ...(preferredModel ? { model: preferredModel } : {}),
953
1601
  // Every reaction entry converging on this route must use the same create
954
1602
  // key. This closes the same-owner multi-event race while the owner check
@@ -961,7 +1609,7 @@ async function processSlackReactionInboxEntry(
961
1609
  // by that operation. Replay this exact Slack event through the normal
962
1610
  // per-message idempotency boundary: the create winner is recognized as the
963
1611
  // initial event, while every distinct loser appends one durable task.
964
- await acceptSlackReactionTask(deps, grant, session.id, preparedEntry);
1612
+ await acceptSlackReactionTask(deps, grant, session.id, preparedEntry, preparedTask.resources);
965
1613
  } catch (error) {
966
1614
  if (error instanceof HTTPException) {
967
1615
  await client.postMessage({
@@ -1003,7 +1651,67 @@ type SlackReactionMessageContext = Awaited<
1003
1651
  ReturnType<OpenGeniSlackBotClient["reactionMessageContext"]>
1004
1652
  >;
1005
1653
 
1006
- export function slackReactionTaskText(context: SlackReactionMessageContext) {
1654
+ type PreparedSlackReactionTask = Readonly<{
1655
+ resources: FileResourceRef[];
1656
+ attachments: ImportedSlackReactionImage[];
1657
+ omissionCodes: string[];
1658
+ omittedCount: number;
1659
+ }>;
1660
+
1661
+ function slackReactionPreparedEntry(
1662
+ entry: SlackInteractionInboxEntry,
1663
+ context: SlackReactionMessageContext,
1664
+ prepared: PreparedSlackReactionTask,
1665
+ ): SlackInteractionInboxEntry {
1666
+ return {
1667
+ ...entry,
1668
+ slackThreadTs: context.threadTimestamp,
1669
+ text: slackReactionTaskText(context, prepared),
1670
+ };
1671
+ }
1672
+
1673
+ async function remountSlackReactionTaskForSession(
1674
+ deps: ApiRouteDeps,
1675
+ workspaceId: string,
1676
+ sessionId: string,
1677
+ prepared: PreparedSlackReactionTask,
1678
+ ): Promise<PreparedSlackReactionTask> {
1679
+ if (prepared.attachments.length === 0) return prepared;
1680
+ const session = await getSession(deps.db, workspaceId, sessionId);
1681
+ if (!session) throw new SlackInteractionPermanentError("session_not_found");
1682
+ const initialOrdinal = session.resources.reduce((maximum, resource) => {
1683
+ if (resource.kind !== "file" || !resource.mountPath) return maximum;
1684
+ const match = /^attachments\/slack\/(\d{2})-/u.exec(resource.mountPath);
1685
+ return match ? Math.max(maximum, Number(match[1])) : maximum;
1686
+ }, 0);
1687
+ const attachments = prepared.attachments.map((attachment, index) => {
1688
+ const basename = attachment.resource.mountPath?.replace(/^attachments\/slack\/\d{2}-/u, "");
1689
+ const ordinal = initialOrdinal + index + 1;
1690
+ if (ordinal > 99 || !basename) {
1691
+ throw new SlackInteractionPermanentError("slack_attachment_mount_exhausted");
1692
+ }
1693
+ const resource = Object.freeze({
1694
+ ...attachment.resource,
1695
+ mountPath: `attachments/slack/${String(ordinal).padStart(2, "0")}-${basename}`,
1696
+ });
1697
+ return Object.freeze({ ...attachment, resource });
1698
+ });
1699
+ return {
1700
+ ...prepared,
1701
+ attachments,
1702
+ resources: attachments.map((attachment) => attachment.resource),
1703
+ };
1704
+ }
1705
+
1706
+ export function slackReactionTaskText(
1707
+ context: SlackReactionMessageContext,
1708
+ prepared: PreparedSlackReactionTask = {
1709
+ resources: [],
1710
+ attachments: [],
1711
+ omissionCodes: [],
1712
+ omittedCount: 0,
1713
+ },
1714
+ ) {
1007
1715
  const reactedLine = slackReactionMessageLine(context.reactedMessage, true);
1008
1716
  const surroundingLines = context.messages
1009
1717
  .slice(0, MAX_SLACK_REACTION_CONTEXT_MESSAGES)
@@ -1014,7 +1722,8 @@ export function slackReactionTaskText(context: SlackReactionMessageContext) {
1014
1722
  let prompt = [
1015
1723
  "A linked, authorized Slack user explicitly summoned OpenGeni by reacting to one message.",
1016
1724
  "Use only the exact reacted message and bounded containing-thread context below.",
1017
- "If the intended action is ambiguous, ask a concise clarifying question in the originating thread before taking action.",
1725
+ "Execute a direct, safe, sufficiently specified request immediately.",
1726
+ "Ask one concise clarifying question only when materially required information is missing or the requested action is risky, irreversible, or authorization-sensitive.",
1018
1727
  "Do not infer permission to ingest or persist this Slack content into Knowledge, Memory, preferences, policy, instructions, or the Workspace Charter.",
1019
1728
  "",
1020
1729
  "Exact reacted message:",
@@ -1031,21 +1740,190 @@ export function slackReactionTaskText(context: SlackReactionMessageContext) {
1031
1740
  }
1032
1741
  prompt = candidate;
1033
1742
  }
1743
+ const attachmentManifest = slackReactionAttachmentManifest(prepared);
1744
+ if (attachmentManifest) {
1745
+ const candidate = `${prompt}\n\n${attachmentManifest}`;
1746
+ if (candidate.length + 1 + truncationNotice.length <= MAX_SLACK_INPUT_CHARS) {
1747
+ prompt = candidate;
1748
+ } else {
1749
+ truncated = true;
1750
+ }
1751
+ }
1034
1752
  return truncated ? `${prompt}\n${truncationNotice}` : prompt;
1035
1753
  }
1036
1754
 
1037
- function slackReactionMessageLine(
1038
- message: SlackReactionMessageContext["reactedMessage"],
1039
- reacted: boolean,
1040
- ) {
1041
- const actor = message.userId || (message.botId ? `bot:${message.botId}` : "unknown");
1042
- const text = message.text.trim() || "(no text)";
1043
- const fileLabels: string[] = [];
1044
- let fileChars = 0;
1045
- let filesTruncated = false;
1046
- for (const file of message.files) {
1047
- const label = file.title || file.name || file.id;
1048
- if (!label) continue;
1755
+ async function prepareSlackReactionTask(
1756
+ deps: ApiRouteDeps,
1757
+ client: OpenGeniSlackBotClient,
1758
+ entry: SlackInteractionInboxEntry,
1759
+ context: SlackReactionMessageContext,
1760
+ ): Promise<PreparedSlackReactionTask> {
1761
+ return await prepareSlackMessageAttachments(deps, client, entry, context.reactedMessage.files);
1762
+ }
1763
+
1764
+ async function prepareSlackMessageAttachments(
1765
+ deps: ApiRouteDeps,
1766
+ client: OpenGeniSlackBotClient,
1767
+ entry: SlackInteractionInboxEntry,
1768
+ exactFiles: readonly { id: string; name: string; title: string }[],
1769
+ ): Promise<PreparedSlackReactionTask> {
1770
+ if (exactFiles.length === 0) {
1771
+ return { resources: [], attachments: [], omissionCodes: [], omittedCount: 0 };
1772
+ }
1773
+ const selected = exactFiles.slice(0, MAX_SLACK_REACTION_IMAGES);
1774
+ const omissionCodes: string[] = [];
1775
+ let omittedCount = Math.max(0, exactFiles.length - selected.length);
1776
+ if (omittedCount > 0) omissionCodes.push("attachment_limit");
1777
+
1778
+ // Complete every provider authorization/file-share preflight before any
1779
+ // download or workspace mutation. A revocation or exact-channel failure
1780
+ // therefore leaves zero imported objects even when it affects a later file.
1781
+ const prepared = await client.prepareReactionImageDownloads({
1782
+ channelId: entry.slackChannelId,
1783
+ files: selected.map((file) => ({
1784
+ id: file.id,
1785
+ name: file.name,
1786
+ title: file.title,
1787
+ })),
1788
+ });
1789
+ const downloaded: Awaited<ReturnType<OpenGeniSlackBotClient["downloadReactionImage"]>>[] = [];
1790
+ let aggregateBytes = 0;
1791
+ for (const image of prepared) {
1792
+ try {
1793
+ const value = await client.downloadReactionImage(image);
1794
+ if (
1795
+ value.bytes.byteLength > SLACK_REACTION_IMAGE_MAX_BYTES ||
1796
+ aggregateBytes + value.bytes.byteLength > MAX_SLACK_REACTION_IMAGE_AGGREGATE_BYTES
1797
+ ) {
1798
+ omittedCount += 1;
1799
+ omissionCodes.push("attachment_size_limit");
1800
+ continue;
1801
+ }
1802
+ aggregateBytes += value.bytes.byteLength;
1803
+ downloaded.push(value);
1804
+ } catch (error) {
1805
+ if (slackReactionAuthorizationFailure(error)) throw error;
1806
+ omittedCount += 1;
1807
+ omissionCodes.push(slackReactionOmissionCode(error));
1808
+ }
1809
+ }
1810
+
1811
+ if (!deps.objectStorage) {
1812
+ return {
1813
+ resources: [],
1814
+ attachments: [],
1815
+ omissionCodes: [...omissionCodes, "storage_unavailable"],
1816
+ omittedCount: omittedCount + downloaded.length,
1817
+ };
1818
+ }
1819
+ const attachments: ImportedSlackReactionImage[] = [];
1820
+ for (const image of downloaded) {
1821
+ try {
1822
+ attachments.push(
1823
+ await importSlackReactionImage(
1824
+ { db: deps.db, objectStorage: deps.objectStorage },
1825
+ {
1826
+ accountId: entry.accountId,
1827
+ workspaceId: entry.workspaceId,
1828
+ connectionId: entry.connectionId,
1829
+ slackTeamId: entry.slackTeamId,
1830
+ slackChannelId: entry.slackChannelId,
1831
+ slackMessageTs: entry.slackMessageTs,
1832
+ },
1833
+ image,
1834
+ attachments.length + 1,
1835
+ ),
1836
+ );
1837
+ } catch {
1838
+ omittedCount += 1;
1839
+ omissionCodes.push("storage_failed");
1840
+ }
1841
+ }
1842
+ return {
1843
+ resources: attachments.map((attachment) => attachment.resource),
1844
+ attachments,
1845
+ omissionCodes,
1846
+ omittedCount,
1847
+ };
1848
+ }
1849
+
1850
+ function slackReactionAttachmentManifest(prepared: PreparedSlackReactionTask): string {
1851
+ return slackAttachmentManifest(
1852
+ prepared,
1853
+ "Imported reacted-message attachments",
1854
+ "reacted-message",
1855
+ );
1856
+ }
1857
+
1858
+ function slackAttachmentManifest(
1859
+ prepared: PreparedSlackReactionTask,
1860
+ heading: string,
1861
+ sourceLabel: string,
1862
+ ): string {
1863
+ const lines = prepared.attachments.map(
1864
+ (attachment, index) =>
1865
+ `${index + 1}. ${attachment.resource.mountPath} (${attachment.contentType}, ${attachment.sizeBytes} bytes)`,
1866
+ );
1867
+ const omission =
1868
+ prepared.omittedCount > 0
1869
+ ? `Some ${sourceLabel} attachments were omitted (${prepared.omittedCount}; ${
1870
+ [...new Set(prepared.omissionCodes)].slice(0, 4).join(", ") || "unavailable"
1871
+ }). Continue with the available attachments and text.`
1872
+ : "";
1873
+ if (lines.length === 0) return omission;
1874
+ return [
1875
+ `${heading} (Slack order; aligned to attached resources):`,
1876
+ ...lines,
1877
+ ...(omission ? [omission] : []),
1878
+ ].join("\n");
1879
+ }
1880
+
1881
+ function slackReactionAuthorizationFailure(error: unknown): boolean {
1882
+ if (!(error instanceof SlackBotProviderError)) return true;
1883
+ return new Set([
1884
+ "account_inactive",
1885
+ "channel_not_found",
1886
+ "file_not_found",
1887
+ "file_not_shared_to_channel",
1888
+ "http_401",
1889
+ "http_403",
1890
+ "invalid_auth",
1891
+ "missing_scope",
1892
+ "not_authed",
1893
+ "not_in_channel",
1894
+ "slack_connect_unsupported",
1895
+ "token_revoked",
1896
+ ]).has(error.code);
1897
+ }
1898
+
1899
+ function slackReactionOmissionCode(error: unknown): string {
1900
+ if (!(error instanceof SlackBotProviderError)) return "attachment_unavailable";
1901
+ if (error.code === "unsupported_file_type" || error.code === "file_content_type_mismatch") {
1902
+ return "unsupported_image";
1903
+ }
1904
+ if (error.code.includes("size")) return "attachment_size_limit";
1905
+ return "attachment_unavailable";
1906
+ }
1907
+
1908
+ function slackReactionMessageLine(
1909
+ message: SlackReactionMessageContext["reactedMessage"],
1910
+ reacted: boolean,
1911
+ ) {
1912
+ return slackContextMessageLine(message, reacted ? "reacted message" : undefined);
1913
+ }
1914
+
1915
+ function slackContextMessageLine(
1916
+ message: SlackReactionMessageContext["reactedMessage"],
1917
+ annotation?: string,
1918
+ ) {
1919
+ const actor = message.userId || (message.botId ? `bot:${message.botId}` : "unknown");
1920
+ const text = message.text.trim() || "(no text)";
1921
+ const fileLabels: string[] = [];
1922
+ let fileChars = 0;
1923
+ let filesTruncated = false;
1924
+ for (const file of message.files) {
1925
+ const label = file.title || file.name || file.id;
1926
+ if (!label) continue;
1049
1927
  const addedChars = label.length + (fileLabels.length > 0 ? 2 : 0);
1050
1928
  if (fileChars + addedChars > MAX_SLACK_REACTION_FILE_SUMMARY_CHARS) {
1051
1929
  filesTruncated = true;
@@ -1057,7 +1935,7 @@ function slackReactionMessageLine(
1057
1935
  const fileSummary = fileLabels.length
1058
1936
  ? ` Files: ${fileLabels.join(", ")}${filesTruncated ? ", …" : ""}.`
1059
1937
  : "";
1060
- return `- ${message.timestamp || "unknown"} ${actor}${reacted ? " [reacted message]" : ""}: ${text}${fileSummary}`;
1938
+ return `- ${message.timestamp || "unknown"} ${actor}${annotation ? ` [${annotation}]` : ""}: ${text}${fileSummary}`;
1061
1939
  }
1062
1940
 
1063
1941
  async function continueSlackReactionSession(
@@ -1065,6 +1943,7 @@ async function continueSlackReactionSession(
1065
1943
  grant: AccessGrant,
1066
1944
  interaction: SlackInteraction,
1067
1945
  entry: SlackInteractionInboxEntry,
1946
+ resources: FileResourceRef[],
1068
1947
  ) {
1069
1948
  if (
1070
1949
  !interaction.sessionId ||
@@ -1073,7 +1952,7 @@ async function continueSlackReactionSession(
1073
1952
  throw new SlackInteractionPermanentError("session_owner_mismatch");
1074
1953
  }
1075
1954
  await reopenSlackInteractionDelivery(deps.db, interaction);
1076
- await acceptSlackReactionTask(deps, grant, interaction.sessionId, entry);
1955
+ await acceptSlackReactionTask(deps, grant, interaction.sessionId, entry, resources);
1077
1956
  }
1078
1957
 
1079
1958
  async function acceptSlackReactionTask(
@@ -1081,6 +1960,7 @@ async function acceptSlackReactionTask(
1081
1960
  grant: AccessGrant,
1082
1961
  sessionId: string,
1083
1962
  entry: SlackInteractionInboxEntry,
1963
+ resources: FileResourceRef[],
1084
1964
  ) {
1085
1965
  const clientEventId = `slack:${entry.providerEventId}`;
1086
1966
  const existing = await getSessionEventByClientEventId(
@@ -1098,6 +1978,7 @@ async function acceptSlackReactionTask(
1098
1978
  await acceptSessionUserMessage(deps, grant, entry.workspaceId, sessionId, {
1099
1979
  text: entry.text,
1100
1980
  turnInstructions: SLACK_TASK_INSTRUCTIONS,
1981
+ resources,
1101
1982
  clientEventId,
1102
1983
  });
1103
1984
  }
@@ -1107,6 +1988,7 @@ async function continueSlackSession(
1107
1988
  grant: AccessGrant,
1108
1989
  interaction: SlackInteraction,
1109
1990
  entry: SlackInteractionInboxEntry,
1991
+ resources: FileResourceRef[] = [],
1110
1992
  ) {
1111
1993
  if (
1112
1994
  !interaction.sessionId ||
@@ -1180,10 +2062,772 @@ async function continueSlackSession(
1180
2062
  await acceptSessionUserMessage(deps, grant, entry.workspaceId, interaction.sessionId, {
1181
2063
  text: entry.text,
1182
2064
  turnInstructions: SLACK_TASK_INSTRUCTIONS,
2065
+ resources,
1183
2066
  clientEventId: `slack:${entry.providerEventId}`,
1184
2067
  });
1185
2068
  }
1186
2069
 
2070
+ const SLACK_ACTION_ID_BY_KIND: Record<SlackInteractionActionKind, string> = {
2071
+ approval_approve: "opengeni.approval.approve",
2072
+ approval_reject: "opengeni.approval.reject",
2073
+ human_input_select: "opengeni.human_input.select",
2074
+ human_input_skip: "opengeni.human_input.skip",
2075
+ session_status: "opengeni.session.status",
2076
+ session_pause: "opengeni.session.pause",
2077
+ session_resume: "opengeni.session.resume",
2078
+ shared_result_publish: "opengeni.shared_result.publish",
2079
+ };
2080
+
2081
+ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
2082
+ const separator = entry.text.lastIndexOf(":");
2083
+ const actionId = separator > 0 ? entry.text.slice(0, separator) : "";
2084
+ const handleId = separator > 0 ? entry.text.slice(separator + 1) : "";
2085
+ const route = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
2086
+ if (
2087
+ !route ||
2088
+ route.accountId !== entry.accountId ||
2089
+ route.workspaceId !== entry.workspaceId ||
2090
+ route.connectionId !== entry.connectionId
2091
+ ) {
2092
+ throw new SlackInteractionPermanentError("slack_action_installation_changed");
2093
+ }
2094
+ const handle = await getSlackInteractionActionHandle(deps.db, {
2095
+ accountId: entry.accountId,
2096
+ workspaceId: entry.workspaceId,
2097
+ handleId,
2098
+ });
2099
+ if (!handle || SLACK_ACTION_ID_BY_KIND[handle.actionKind] !== actionId) {
2100
+ throw new SlackInteractionPermanentError("slack_action_handle_invalid");
2101
+ }
2102
+ if (handle.status !== "pending") return;
2103
+ if (handle.expiresAt.getTime() <= Date.now()) {
2104
+ await settleSlackInteractionActionHandles(deps.db, {
2105
+ accountId: entry.accountId,
2106
+ workspaceId: entry.workspaceId,
2107
+ handleId: handle.id,
2108
+ result: "expired",
2109
+ stale: true,
2110
+ });
2111
+ return;
2112
+ }
2113
+ if (handle.authorizedSlackUserId !== entry.slackUserId) {
2114
+ throw new SlackInteractionPermanentError("slack_action_user_mismatch");
2115
+ }
2116
+ const [interaction, link, post] = await Promise.all([
2117
+ getSlackInteractionById(deps.db, {
2118
+ accountId: entry.accountId,
2119
+ workspaceId: entry.workspaceId,
2120
+ interactionId: handle.interactionId,
2121
+ }),
2122
+ getSlackBotUserLink(deps.db, entry.workspaceId, entry.connectionId, entry.slackUserId),
2123
+ getSlackBotPostOperation(
2124
+ deps.db,
2125
+ entry.workspaceId,
2126
+ entry.connectionId,
2127
+ handle.messageOperationId,
2128
+ ),
2129
+ ]);
2130
+ if (
2131
+ !interaction ||
2132
+ interaction.connectionId !== entry.connectionId ||
2133
+ interaction.slackTeamId !== entry.slackTeamId ||
2134
+ interaction.sessionId !== handle.sessionId ||
2135
+ interaction.owningSubjectId !== handle.authorizedSubjectId ||
2136
+ interaction.initiatingSlackUserId !== handle.authorizedSlackUserId ||
2137
+ !link ||
2138
+ link.subjectId !== handle.authorizedSubjectId ||
2139
+ !post ||
2140
+ post.status !== "completed" ||
2141
+ post.slackChannelId !== entry.slackChannelId ||
2142
+ post.slackMessageTimestamp !== entry.slackMessageTs
2143
+ ) {
2144
+ throw new SlackInteractionPermanentError("slack_action_authority_changed");
2145
+ }
2146
+ const grant = await getWorkspaceGrant(deps.db, link.subjectId, entry.workspaceId, {
2147
+ principalKind: "human_session",
2148
+ });
2149
+ if (
2150
+ !grant ||
2151
+ grant.accountId !== entry.accountId ||
2152
+ !hasPermission(grant.permissions, "sessions:control")
2153
+ ) {
2154
+ throw new SlackInteractionPermanentError("sessions_control_denied");
2155
+ }
2156
+ const client = await createOpenGeniSlackBotInteractionClient(deps, {
2157
+ accountId: entry.accountId,
2158
+ workspaceId: entry.workspaceId,
2159
+ connectionId: entry.connectionId,
2160
+ subjectId: grant.subjectId,
2161
+ sessionId: handle.sessionId,
2162
+ });
2163
+ const outcome = await executeSlackAction(deps, grant, interaction, handle);
2164
+ await client.updateMessage({
2165
+ operationId: deterministicUuid(`slack-action-update:${handle.id}:${outcome.result}`),
2166
+ channelId: entry.slackChannelId,
2167
+ timestamp: entry.slackMessageTs,
2168
+ text: outcome.text,
2169
+ blocks: [{ type: "section", text: { type: "mrkdwn", text: outcome.text } }],
2170
+ });
2171
+ await settleSlackInteractionActionHandles(deps.db, {
2172
+ accountId: entry.accountId,
2173
+ workspaceId: entry.workspaceId,
2174
+ handleId: handle.id,
2175
+ result: outcome.result,
2176
+ ...(outcome.stale ? { stale: true } : {}),
2177
+ });
2178
+ if (outcome.controlState) {
2179
+ await postSlackControlCard(
2180
+ deps,
2181
+ client,
2182
+ interaction,
2183
+ outcome.controlState,
2184
+ entry.providerEventId,
2185
+ );
2186
+ }
2187
+ }
2188
+
2189
+ async function executeSlackAction(
2190
+ deps: ApiRouteDeps,
2191
+ grant: AccessGrant,
2192
+ interaction: SlackInteraction,
2193
+ handle: SlackInteractionActionHandle,
2194
+ ): Promise<{
2195
+ result: string;
2196
+ text: string;
2197
+ stale?: boolean;
2198
+ controlState?: "active" | "paused";
2199
+ }> {
2200
+ const mention = slackRequesterMention(interaction);
2201
+ if (handle.actionKind === "shared_result_publish") {
2202
+ return await publishSlackSharedResult(deps, grant, interaction, handle);
2203
+ }
2204
+ if (handle.actionKind === "approval_approve" || handle.actionKind === "approval_reject") {
2205
+ if (!handle.targetId) throw new SlackInteractionPermanentError("slack_action_target_invalid");
2206
+ const decision = handle.actionKind === "approval_approve" ? "approve" : "reject";
2207
+ const accepted = await acceptSessionApprovalDecision(deps.db, {
2208
+ accountId: grant.accountId,
2209
+ workspaceId: grant.workspaceId,
2210
+ sessionId: handle.sessionId,
2211
+ subjectId: grant.subjectId,
2212
+ payload: { approvalId: handle.targetId, decision },
2213
+ clientEventId: `slack-action:${handle.id}`,
2214
+ });
2215
+ if (accepted.action === "conflict") {
2216
+ return {
2217
+ result: "stale",
2218
+ stale: true,
2219
+ text: `${mention}This approval is no longer pending.`,
2220
+ };
2221
+ }
2222
+ await publishDurableSessionEvents(
2223
+ deps.bus,
2224
+ grant.workspaceId,
2225
+ handle.sessionId,
2226
+ accepted.events,
2227
+ );
2228
+ await deps.workflowClient.signalApprovalDecision({
2229
+ accountId: grant.accountId,
2230
+ workspaceId: grant.workspaceId,
2231
+ sessionId: handle.sessionId,
2232
+ eventId: accepted.event.id,
2233
+ workflowId: `session-${handle.sessionId}`,
2234
+ workflowWakeRevision: accepted.workflowWakeRevision,
2235
+ });
2236
+ return {
2237
+ result: decision === "approve" ? "approved" : "rejected",
2238
+ text: `${mention}${decision === "approve" ? "Approved once" : "Rejected"}. OpenGeni will continue from the durable task state.`,
2239
+ };
2240
+ }
2241
+ if (handle.actionKind === "human_input_select" || handle.actionKind === "human_input_skip") {
2242
+ if (!handle.targetId) throw new SlackInteractionPermanentError("slack_action_target_invalid");
2243
+ const request = await getSessionHumanInputRequest(
2244
+ deps.db,
2245
+ grant.workspaceId,
2246
+ handle.sessionId,
2247
+ handle.targetId,
2248
+ );
2249
+ if (!request || request.status !== "pending") {
2250
+ return {
2251
+ result: "stale",
2252
+ stale: true,
2253
+ text: `${mention}This question is no longer pending.`,
2254
+ };
2255
+ }
2256
+ let response:
2257
+ | { outcome: "skipped" }
2258
+ | {
2259
+ outcome: "answered";
2260
+ answers: Array<{ questionId: string; values: string[] }>;
2261
+ };
2262
+ if (handle.actionKind === "human_input_skip") {
2263
+ if (!request.allowSkip) {
2264
+ throw new SlackInteractionPermanentError("slack_action_skip_not_allowed");
2265
+ }
2266
+ response = { outcome: "skipped" };
2267
+ } else {
2268
+ let selected: unknown;
2269
+ try {
2270
+ selected = JSON.parse(handle.targetValue ?? "");
2271
+ } catch {
2272
+ throw new SlackInteractionPermanentError("slack_action_target_invalid");
2273
+ }
2274
+ if (
2275
+ !Array.isArray(selected) ||
2276
+ selected.length !== 2 ||
2277
+ typeof selected[0] !== "string" ||
2278
+ typeof selected[1] !== "string"
2279
+ ) {
2280
+ throw new SlackInteractionPermanentError("slack_action_target_invalid");
2281
+ }
2282
+ response = {
2283
+ outcome: "answered",
2284
+ answers: [{ questionId: selected[0], values: [selected[1]] }],
2285
+ };
2286
+ }
2287
+ const accepted = await acceptSessionHumanInputResponse(deps.db, {
2288
+ accountId: grant.accountId,
2289
+ workspaceId: grant.workspaceId,
2290
+ sessionId: handle.sessionId,
2291
+ requestId: handle.targetId,
2292
+ response,
2293
+ respondedBy: grant.subjectId,
2294
+ clientEventId: `slack-action:${handle.id}`,
2295
+ });
2296
+ if (accepted.action !== "accepted") {
2297
+ return {
2298
+ result: "stale",
2299
+ stale: true,
2300
+ text: `${mention}This question is no longer pending.`,
2301
+ };
2302
+ }
2303
+ await publishDurableSessionEvents(
2304
+ deps.bus,
2305
+ grant.workspaceId,
2306
+ handle.sessionId,
2307
+ accepted.events,
2308
+ );
2309
+ if (accepted.workflowWakeRevision !== null) {
2310
+ await deps.workflowClient.signalApprovalDecision({
2311
+ accountId: grant.accountId,
2312
+ workspaceId: grant.workspaceId,
2313
+ sessionId: handle.sessionId,
2314
+ eventId: accepted.events[0]?.id ?? handle.targetId,
2315
+ workflowId: `session-${handle.sessionId}`,
2316
+ workflowWakeRevision: accepted.workflowWakeRevision,
2317
+ });
2318
+ }
2319
+ return {
2320
+ result: response.outcome === "skipped" ? "skipped" : "answered",
2321
+ text: `${mention}${response.outcome === "skipped" ? "Skipped the question" : "Answer submitted"}. OpenGeni will continue.`,
2322
+ };
2323
+ }
2324
+ if (handle.actionKind === "session_pause" || handle.actionKind === "session_resume") {
2325
+ const action = handle.actionKind === "session_pause" ? "pause" : "resume";
2326
+ await controlHumanSessionWorkstream(
2327
+ deps,
2328
+ {
2329
+ accountId: grant.accountId,
2330
+ workspaceId: grant.workspaceId,
2331
+ subjectId: grant.subjectId,
2332
+ sessionId: handle.sessionId,
2333
+ },
2334
+ {
2335
+ action,
2336
+ clientEventId: deterministicUuid(`slack-action:${handle.id}`),
2337
+ reason: `${action === "pause" ? "Paused" : "Resumed"} from the originating Slack thread`,
2338
+ },
2339
+ );
2340
+ return {
2341
+ result: action === "pause" ? "paused" : "resumed",
2342
+ text: `${mention}OpenGeni task ${action === "pause" ? "paused" : "resumed"}.`,
2343
+ controlState: action === "pause" ? "paused" : "active",
2344
+ };
2345
+ }
2346
+ const session = await getSession(deps.db, grant.workspaceId, handle.sessionId);
2347
+ if (!session) throw new SlackInteractionPermanentError("slack_action_session_missing");
2348
+ return {
2349
+ result: "status",
2350
+ text: `${mention}OpenGeni task status: *${session.status.replaceAll("_", " ")}*.`,
2351
+ ...(session.status === "cancelled" || session.status === "failed"
2352
+ ? {}
2353
+ : { controlState: "active" as const }),
2354
+ };
2355
+ }
2356
+
2357
+ async function publishSlackSharedResult(
2358
+ deps: ApiRouteDeps,
2359
+ grant: AccessGrant,
2360
+ interaction: SlackInteraction,
2361
+ handle: SlackInteractionActionHandle,
2362
+ ): Promise<{ result: string; text: string; stale?: boolean }> {
2363
+ const mention = slackRequesterMention(interaction);
2364
+ if (!handle.targetId || !interaction.sessionId) {
2365
+ throw new SlackInteractionPermanentError("slack_shared_publication_target_invalid");
2366
+ }
2367
+ const [origin, activePolicy, event] = await Promise.all([
2368
+ getSlackSharedTaskOrigin(deps.db, {
2369
+ accountId: interaction.accountId,
2370
+ workspaceId: interaction.workspaceId,
2371
+ interactionId: interaction.id,
2372
+ }),
2373
+ getActiveSlackTaskPolicy(deps.db, {
2374
+ accountId: interaction.accountId,
2375
+ workspaceId: interaction.workspaceId,
2376
+ }),
2377
+ getSessionEvent(deps.db, interaction.workspaceId, handle.targetId),
2378
+ ]);
2379
+ if (
2380
+ !origin ||
2381
+ origin.connectionId !== interaction.connectionId ||
2382
+ origin.sessionId !== interaction.sessionId ||
2383
+ origin.initiatingSlackUserId !== handle.authorizedSlackUserId ||
2384
+ !activePolicy ||
2385
+ activePolicy.revision.id !== origin.policyRevisionId ||
2386
+ activePolicy.revision.policyHash !== origin.policyHash ||
2387
+ activePolicy.head.activationVersion !== origin.policyActivationVersion ||
2388
+ origin.publicationMode === "never" ||
2389
+ !event ||
2390
+ event.sessionId !== interaction.sessionId ||
2391
+ event.type !== "turn.completed"
2392
+ ) {
2393
+ return {
2394
+ result: "stale",
2395
+ stale: true,
2396
+ text: `${mention}This publication approval is stale. The result was not posted.`,
2397
+ };
2398
+ }
2399
+ const client = await createOpenGeniSlackBotInteractionClient(deps, {
2400
+ accountId: interaction.accountId,
2401
+ workspaceId: interaction.workspaceId,
2402
+ connectionId: interaction.connectionId,
2403
+ subjectId: grant.subjectId,
2404
+ sessionId: interaction.sessionId,
2405
+ });
2406
+ let decision: ReturnType<typeof evaluateSlackTaskPolicy>;
2407
+ try {
2408
+ const facts = await client.slackTaskPolicyFacts(
2409
+ origin.sourceChannelId,
2410
+ origin.initiatingSlackUserId,
2411
+ );
2412
+ decision = evaluateSlackTaskPolicy({
2413
+ policy: activePolicy.revision.policy,
2414
+ conversation: {
2415
+ installationTeamId: origin.slackTeamId,
2416
+ conversationId: facts.conversation.id,
2417
+ contextTeamId: facts.conversation.contextTeamId,
2418
+ connectedTeamIds: facts.conversation.connectedTeamIds,
2419
+ sharedTeamIds: facts.conversation.sharedTeamIds,
2420
+ isShared: facts.conversation.isShared,
2421
+ isExternallyShared: facts.conversation.isExternallyShared,
2422
+ isOrgShared: facts.conversation.isOrgShared,
2423
+ isPendingExternallyShared: facts.conversation.isPendingExternallyShared,
2424
+ isMpim: facts.conversation.isMpim,
2425
+ },
2426
+ initiator: facts.initiator ?? { teamId: null, isGuest: null, isExternal: null },
2427
+ });
2428
+ } catch (error) {
2429
+ if (error instanceof SlackBotProviderError) {
2430
+ return {
2431
+ result: "stale",
2432
+ stale: true,
2433
+ text: `${mention}The shared conversation is no longer authorized. The result was not posted.`,
2434
+ };
2435
+ }
2436
+ throw error;
2437
+ }
2438
+ if (decision.disposition !== "private_handoff" || decision.publication === "never") {
2439
+ return {
2440
+ result: "stale",
2441
+ stale: true,
2442
+ text: `${mention}The shared conversation is no longer authorized. The result was not posted.`,
2443
+ };
2444
+ }
2445
+ const output = safePayloadText(event.payload, "output").trim();
2446
+ if (!output) {
2447
+ return {
2448
+ result: "stale",
2449
+ stale: true,
2450
+ text: `${mention}This result is no longer available for publication.`,
2451
+ };
2452
+ }
2453
+ await client.postMessage({
2454
+ operationId: deterministicUuid(
2455
+ `slack-shared-result:${origin.interactionId}:${event.id}:${origin.policyActivationVersion}`,
2456
+ ),
2457
+ channelId: origin.sourceChannelId,
2458
+ threadTimestamp: origin.sourceThreadTs,
2459
+ text: `<@${origin.initiatingSlackUserId}> ${boundedOutput(output)}`,
2460
+ });
2461
+ return {
2462
+ result: "published",
2463
+ text: `${mention}Published the approved result to the original shared conversation.`,
2464
+ };
2465
+ }
2466
+
2467
+ function slackRequesterMention(interaction: Pick<SlackInteraction, "initiatingSlackUserId">) {
2468
+ return interaction.initiatingSlackUserId &&
2469
+ /^[UW][A-Z0-9]{1,63}$/.test(interaction.initiatingSlackUserId)
2470
+ ? `<@${interaction.initiatingSlackUserId}> `
2471
+ : "";
2472
+ }
2473
+
2474
+ async function validatedSlackRequester(
2475
+ deps: ApiRouteDeps,
2476
+ interaction: Pick<
2477
+ SlackInteraction,
2478
+ "accountId" | "workspaceId" | "connectionId" | "initiatingSlackUserId" | "owningSubjectId"
2479
+ >,
2480
+ ) {
2481
+ if (!interaction.initiatingSlackUserId) {
2482
+ return { authorized: false, canSchedule: false, mention: "" };
2483
+ }
2484
+ const [link, grant] = await Promise.all([
2485
+ getSlackBotUserLink(
2486
+ deps.db,
2487
+ interaction.workspaceId,
2488
+ interaction.connectionId,
2489
+ interaction.initiatingSlackUserId,
2490
+ ),
2491
+ getWorkspaceGrant(deps.db, interaction.owningSubjectId, interaction.workspaceId, {
2492
+ principalKind: "human_session",
2493
+ }),
2494
+ ]);
2495
+ const authorized = link?.subjectId === interaction.owningSubjectId;
2496
+ const canSchedule =
2497
+ authorized &&
2498
+ grant?.accountId === interaction.accountId &&
2499
+ hasPermission(grant.permissions, "sessions:read") &&
2500
+ hasPermission(grant.permissions, "sessions:control") &&
2501
+ hasPermission(grant.permissions, "scheduled_tasks:manage");
2502
+ return {
2503
+ authorized,
2504
+ canSchedule,
2505
+ mention: authorized ? slackRequesterMention(interaction) : "",
2506
+ };
2507
+ }
2508
+
2509
+ async function controlActionBlocks(
2510
+ deps: ApiRouteDeps,
2511
+ interaction: SlackInteraction,
2512
+ input: {
2513
+ messageOperationId: string;
2514
+ sessionEventSequence: number;
2515
+ state: "active" | "paused";
2516
+ },
2517
+ ): Promise<SlackMessageBlock[]> {
2518
+ if (!interaction.sessionId || !interaction.initiatingSlackUserId) return [];
2519
+ const kinds: SlackInteractionActionKind[] = [
2520
+ "session_status",
2521
+ input.state === "paused" ? "session_resume" : "session_pause",
2522
+ ];
2523
+ const handles = await reserveSlackInteractionActionHandles(deps.db, {
2524
+ interaction,
2525
+ sessionEventSequence: input.sessionEventSequence,
2526
+ messageOperationId: input.messageOperationId,
2527
+ expiresAt: new Date(Date.now() + SLACK_ACTION_TTL_MS),
2528
+ actions: kinds.map((actionKind) => ({
2529
+ actionKind,
2530
+ actionKey: `${input.messageOperationId}:${actionKind}`,
2531
+ })),
2532
+ });
2533
+ const pending = handles.filter((handle) => handle.status === "pending");
2534
+ if (pending.length === 0) return [];
2535
+ return [
2536
+ {
2537
+ type: "actions",
2538
+ block_id: `opengeni_controls_${input.messageOperationId}`,
2539
+ elements: pending.map((handle) => ({
2540
+ type: "button" as const,
2541
+ action_id: SLACK_ACTION_ID_BY_KIND[handle.actionKind],
2542
+ value: handle.id,
2543
+ text: {
2544
+ type: "plain_text" as const,
2545
+ text:
2546
+ handle.actionKind === "session_status"
2547
+ ? "Status"
2548
+ : handle.actionKind === "session_pause"
2549
+ ? "Stop"
2550
+ : "Resume",
2551
+ emoji: true,
2552
+ },
2553
+ ...(handle.actionKind === "session_pause" ? { style: "danger" as const } : {}),
2554
+ })),
2555
+ },
2556
+ ];
2557
+ }
2558
+
2559
+ async function postSlackControlCard(
2560
+ deps: ApiRouteDeps,
2561
+ client: OpenGeniSlackBotClient,
2562
+ interaction: SlackInteraction,
2563
+ state: "active" | "paused",
2564
+ providerEventId: string,
2565
+ ) {
2566
+ if (!interaction.sessionId) return;
2567
+ const operationId = deterministicUuid(
2568
+ `slack-control:${interaction.id}:${providerEventId}:${state}`,
2569
+ );
2570
+ const text = `${slackRequesterMention(interaction)}OpenGeni task controls.`;
2571
+ const controls = await controlActionBlocks(deps, interaction, {
2572
+ messageOperationId: operationId,
2573
+ sessionEventSequence: 0,
2574
+ state,
2575
+ });
2576
+ await client.postMessage({
2577
+ operationId,
2578
+ channelId: interaction.slackChannelId,
2579
+ threadTimestamp: interaction.slackThreadTs,
2580
+ text,
2581
+ blocks: [{ type: "section", text: { type: "mrkdwn", text } }, ...controls],
2582
+ });
2583
+ }
2584
+
2585
+ type SlackApprovalSummary = { id: string; name: string };
2586
+
2587
+ function slackApprovalSummaries(payload: unknown): SlackApprovalSummary[] {
2588
+ const approvals = record(payload)?.approvals;
2589
+ if (!Array.isArray(approvals)) return [];
2590
+ return approvals
2591
+ .map((approval) => {
2592
+ const value = record(approval);
2593
+ const rawItem = record(value?.rawItem);
2594
+ const id = approvalIdentifier(approval);
2595
+ const name = boundedString(value?.name ?? value?.toolName ?? rawItem?.name, 256);
2596
+ return id ? { id, name: name ?? "OpenGeni action" } : null;
2597
+ })
2598
+ .filter((approval): approval is SlackApprovalSummary => approval !== null)
2599
+ .slice(0, MAX_SLACK_APPROVALS_PER_CARD);
2600
+ }
2601
+
2602
+ async function slackApprovalCard(
2603
+ deps: ApiRouteDeps,
2604
+ interaction: SlackInteraction,
2605
+ event: SessionEvent,
2606
+ mention: string,
2607
+ requesterAuthorized: boolean,
2608
+ ): Promise<{ text: string; blocks?: SlackMessageBlock[]; operationId: string }> {
2609
+ const operationId = deterministicUuid(
2610
+ `slack-delivery:${interaction.id}:${event.sequence}:approval`,
2611
+ );
2612
+ const approvals = slackApprovalSummaries(event.payload);
2613
+ const fallback = approvals.length
2614
+ ? `${mention}OpenGeni needs approval for ${approvals.map((approval) => approval.name).join(", ")}.`
2615
+ : `${mention}OpenGeni needs approval. Open the task to review it.`;
2616
+ if (
2617
+ !requesterAuthorized ||
2618
+ !interaction.sessionId ||
2619
+ !interaction.initiatingSlackUserId ||
2620
+ approvals.length === 0
2621
+ ) {
2622
+ return { text: fallback, operationId };
2623
+ }
2624
+ const specs = approvals
2625
+ .flatMap((approval) => [
2626
+ {
2627
+ actionKind: "approval_approve" as const,
2628
+ actionKey: `approval:${approval.id}:approve`,
2629
+ targetId: approval.id,
2630
+ },
2631
+ {
2632
+ actionKind: "approval_reject" as const,
2633
+ actionKey: `approval:${approval.id}:reject`,
2634
+ targetId: approval.id,
2635
+ },
2636
+ ])
2637
+ .slice(0, MAX_SLACK_ACTIONS_PER_CARD);
2638
+ const handles = await reserveSlackInteractionActionHandles(deps.db, {
2639
+ interaction,
2640
+ sessionEventSequence: event.sequence,
2641
+ messageOperationId: operationId,
2642
+ expiresAt: new Date(Date.now() + SLACK_ACTION_TTL_MS),
2643
+ actions: specs,
2644
+ });
2645
+ const byKey = new Map(handles.map((handle) => [handle.actionKey, handle]));
2646
+ const blocks: SlackMessageBlock[] = [];
2647
+ for (const approval of approvals) {
2648
+ const approve = byKey.get(`approval:${approval.id}:approve`);
2649
+ const reject = byKey.get(`approval:${approval.id}:reject`);
2650
+ blocks.push({
2651
+ type: "section",
2652
+ text: { type: "mrkdwn", text: `*Approval required*\n${approval.name}` },
2653
+ });
2654
+ if (approve?.status === "pending" && reject?.status === "pending") {
2655
+ blocks.push({
2656
+ type: "actions",
2657
+ block_id: `opengeni_approval_${event.sequence}_${blocks.length}`,
2658
+ elements: [
2659
+ {
2660
+ type: "button",
2661
+ action_id: SLACK_ACTION_ID_BY_KIND.approval_approve,
2662
+ value: approve.id,
2663
+ text: { type: "plain_text", text: "Approve once", emoji: true },
2664
+ style: "primary",
2665
+ },
2666
+ {
2667
+ type: "button",
2668
+ action_id: SLACK_ACTION_ID_BY_KIND.approval_reject,
2669
+ value: reject.id,
2670
+ text: { type: "plain_text", text: "Reject", emoji: true },
2671
+ style: "danger",
2672
+ },
2673
+ ],
2674
+ });
2675
+ }
2676
+ }
2677
+ return {
2678
+ text: fallback,
2679
+ blocks: [
2680
+ {
2681
+ type: "section",
2682
+ text: { type: "mrkdwn", text: `${mention}OpenGeni needs your approval.` },
2683
+ },
2684
+ ...blocks,
2685
+ ],
2686
+ operationId,
2687
+ };
2688
+ }
2689
+
2690
+ async function slackSharedResultPublicationBlocks(
2691
+ deps: ApiRouteDeps,
2692
+ interaction: SlackInteraction,
2693
+ event: SessionEvent,
2694
+ requesterAuthorized: boolean,
2695
+ messageOperationId: string,
2696
+ ): Promise<SlackMessageBlock[]> {
2697
+ if (!safePayloadText(event.payload, "output").trim()) return [];
2698
+ if (!requesterAuthorized || !interaction.sessionId || !interaction.initiatingSlackUserId) {
2699
+ return [];
2700
+ }
2701
+ const origin = await getSlackSharedTaskOrigin(deps.db, {
2702
+ accountId: interaction.accountId,
2703
+ workspaceId: interaction.workspaceId,
2704
+ interactionId: interaction.id,
2705
+ });
2706
+ if (!origin || origin.publicationMode === "never") return [];
2707
+ const [handle] = await reserveSlackInteractionActionHandles(deps.db, {
2708
+ interaction,
2709
+ sessionEventSequence: event.sequence,
2710
+ messageOperationId,
2711
+ expiresAt: new Date(Date.now() + SLACK_ACTION_TTL_MS),
2712
+ actions: [
2713
+ {
2714
+ actionKind: "shared_result_publish",
2715
+ actionKey: `shared-result:${event.id}:publish`,
2716
+ targetId: event.id,
2717
+ },
2718
+ ],
2719
+ });
2720
+ if (!handle || handle.status !== "pending") return [];
2721
+ return [
2722
+ {
2723
+ type: "actions",
2724
+ block_id: `opengeni_shared_result_${event.sequence}`,
2725
+ elements: [
2726
+ {
2727
+ type: "button",
2728
+ action_id: SLACK_ACTION_ID_BY_KIND.shared_result_publish,
2729
+ value: handle.id,
2730
+ text: {
2731
+ type: "plain_text",
2732
+ text:
2733
+ origin.publicationMode === "approval_required"
2734
+ ? "Approve & publish"
2735
+ : "Publish result",
2736
+ emoji: true,
2737
+ },
2738
+ style: "primary",
2739
+ },
2740
+ ],
2741
+ },
2742
+ ];
2743
+ }
2744
+
2745
+ async function slackHumanInputCard(
2746
+ deps: ApiRouteDeps,
2747
+ interaction: SlackInteraction,
2748
+ event: SessionEvent,
2749
+ mention: string,
2750
+ requesterAuthorized: boolean,
2751
+ ) {
2752
+ if (!interaction.sessionId) return null;
2753
+ const requestId = boundedString(record(record(event.payload)?.request)?.id, 64);
2754
+ if (!requestId) return null;
2755
+ const request = await getSessionHumanInputRequest(
2756
+ deps.db,
2757
+ interaction.workspaceId,
2758
+ interaction.sessionId,
2759
+ requestId,
2760
+ );
2761
+ if (!request || request.status !== "pending") return null;
2762
+ const operationId = deterministicUuid(
2763
+ `slack-delivery:${interaction.id}:${event.sequence}:human-input`,
2764
+ );
2765
+ const text = `${mention}OpenGeni needs your input:\n${formatQuestions(request.questions)}\nReply in this thread${request.allowSkip ? " or choose Skip" : ""}.`;
2766
+ if (!requesterAuthorized || !interaction.initiatingSlackUserId) return { text, operationId };
2767
+ const question = request.questions.length === 1 ? request.questions[0] : null;
2768
+ const canUseButtons =
2769
+ question?.kind === "single_select" &&
2770
+ question.options.length > 0 &&
2771
+ question.options.length <= 5;
2772
+ const actions: Array<{
2773
+ actionKind: SlackInteractionActionKind;
2774
+ actionKey: string;
2775
+ targetId: string;
2776
+ targetValue?: string;
2777
+ }> = [];
2778
+ if (canUseButtons && question) {
2779
+ for (const option of question.options) {
2780
+ actions.push({
2781
+ actionKind: "human_input_select",
2782
+ actionKey: `human:${request.id}:${question.id}:${option.id}`,
2783
+ targetId: request.id,
2784
+ targetValue: JSON.stringify([question.id, option.id]),
2785
+ });
2786
+ }
2787
+ }
2788
+ if (request.allowSkip) {
2789
+ actions.push({
2790
+ actionKind: "human_input_skip",
2791
+ actionKey: `human:${request.id}:skip`,
2792
+ targetId: request.id,
2793
+ });
2794
+ }
2795
+ if (actions.length === 0) return { text, operationId };
2796
+ const handles = await reserveSlackInteractionActionHandles(deps.db, {
2797
+ interaction,
2798
+ sessionEventSequence: event.sequence,
2799
+ messageOperationId: operationId,
2800
+ expiresAt: request.expiresAt
2801
+ ? new Date(Math.min(new Date(request.expiresAt).getTime(), Date.now() + SLACK_ACTION_TTL_MS))
2802
+ : new Date(Date.now() + SLACK_ACTION_TTL_MS),
2803
+ actions,
2804
+ });
2805
+ const pending = handles.filter((handle) => handle.status === "pending");
2806
+ const blocks: SlackMessageBlock[] = [{ type: "section", text: { type: "mrkdwn", text } }];
2807
+ if (pending.length > 0) {
2808
+ blocks.push({
2809
+ type: "actions",
2810
+ block_id: `opengeni_human_${event.sequence}`,
2811
+ elements: pending.map((handle) => {
2812
+ const option = question?.options.find(
2813
+ (candidate) => handle.targetValue === JSON.stringify([question.id, candidate.id]),
2814
+ );
2815
+ return {
2816
+ type: "button" as const,
2817
+ action_id: SLACK_ACTION_ID_BY_KIND[handle.actionKind],
2818
+ value: handle.id,
2819
+ text: {
2820
+ type: "plain_text" as const,
2821
+ text: handle.actionKind === "human_input_skip" ? "Skip" : (option?.label ?? "Choose"),
2822
+ emoji: true,
2823
+ },
2824
+ };
2825
+ }),
2826
+ });
2827
+ }
2828
+ return { text, blocks, operationId };
2829
+ }
2830
+
1187
2831
  async function deliverSlackSessionEvents(
1188
2832
  deps: ApiRouteDeps,
1189
2833
  interaction: SlackInteraction,
@@ -1194,7 +2838,6 @@ async function deliverSlackSessionEvents(
1194
2838
  after: interaction.lastDeliveredSessionEventSequence,
1195
2839
  limit: 100,
1196
2840
  includeTypes: [...SLACK_DELIVERY_EVENT_TYPES],
1197
- authoritativeLatest: true,
1198
2841
  maxBytes: 256 * 1024,
1199
2842
  });
1200
2843
  if (page.events.length === 0) {
@@ -1208,20 +2851,79 @@ async function deliverSlackSessionEvents(
1208
2851
  accountId: interaction.accountId,
1209
2852
  workspaceId: interaction.workspaceId,
1210
2853
  connectionId: interaction.connectionId,
1211
- subjectId: interaction.owningSubjectId,
2854
+ subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
1212
2855
  sessionId: interaction.sessionId,
1213
2856
  });
2857
+ const requester = await validatedSlackRequester(deps, interaction);
1214
2858
  let lastSequence = interaction.lastDeliveredSessionEventSequence;
1215
2859
  let terminal: Exclude<SlackInteraction["terminalDeliveryState"], "open"> | null = null;
1216
2860
  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)) {
2861
+ const orderedEvents = page.events
2862
+ .filter(
2863
+ (event) =>
2864
+ (event.turnAssociation === null || event.turnAssociation === "current") &&
2865
+ event.duplicateOfEventId === null,
2866
+ )
2867
+ .sort((left, right) => left.sequence - right.sequence);
2868
+ lastSequence = page.events.reduce(
2869
+ (latest, event) => Math.max(latest, event.sequence),
2870
+ lastSequence,
2871
+ );
2872
+ const terminalAssistantSequences = new Set<number>();
2873
+ for (let index = 0; index < orderedEvents.length; index += 1) {
2874
+ const event = orderedEvents[index]!;
2875
+ if (event.type !== "turn.completed") continue;
2876
+ const finalOutput = safePayloadText(event.payload, "output").trim();
2877
+ const candidates: SessionEvent[] = [];
2878
+ for (let candidateIndex = index - 1; candidateIndex >= 0; candidateIndex -= 1) {
2879
+ const candidate = orderedEvents[candidateIndex]!;
2880
+ if (
2881
+ candidate.type === "turn.completed" ||
2882
+ candidate.type === "turn.failed" ||
2883
+ candidate.type === "turn.cancelled"
2884
+ ) {
2885
+ break;
2886
+ }
2887
+ if (candidate.type !== "agent.message.completed") continue;
2888
+ if (event.turnId && candidate.turnId && event.turnId !== candidate.turnId) continue;
2889
+ candidates.push(candidate);
2890
+ }
2891
+ const terminalText =
2892
+ finalOutput ||
2893
+ candidates
2894
+ .map((candidate) => safePayloadText(candidate.payload, "text").trim())
2895
+ .find(Boolean) ||
2896
+ "";
2897
+ if (!terminalText) continue;
2898
+ let matchedTerminalSuffix = false;
2899
+ for (const candidate of candidates) {
2900
+ const assistantText = safePayloadText(candidate.payload, "text").trim();
2901
+ if (slackDeliveryTextsCoalesce(assistantText, terminalText)) {
2902
+ terminalAssistantSequences.add(candidate.sequence);
2903
+ matchedTerminalSuffix = true;
2904
+ } else if (matchedTerminalSuffix) {
2905
+ // Coalesce only the contiguous terminal-shaped suffix. An earlier
2906
+ // distinct progress update in the same turn remains independently
2907
+ // deliverable even when a later SDK projection repeats the result.
2908
+ break;
2909
+ }
2910
+ }
2911
+ }
2912
+ const progressEvidence = orderedEvents.some((event) => event.type === "turn.completed")
2913
+ ? await listSlackInteractionProgressDeliveryEvidence(deps.db, {
2914
+ accountId: interaction.accountId,
2915
+ workspaceId: interaction.workspaceId,
2916
+ interactionId: interaction.id,
2917
+ sessionId: interaction.sessionId,
2918
+ })
2919
+ : [];
2920
+ // Slack delivery pages are chronological. This keeps pagination lossless and
2921
+ // ensures progress cannot appear after a terminal result.
2922
+ for (const event of orderedEvents) {
1221
2923
  lastSequence = Math.max(lastSequence, event.sequence);
1222
2924
  if (event.type === "agent.message.completed") {
1223
2925
  latestAssistantText = safePayloadText(event.payload, "text");
1224
- if (latestAssistantText) {
2926
+ if (latestAssistantText && !terminalAssistantSequences.has(event.sequence)) {
1225
2927
  const progress = await claimSlackInteractionProgressDelivery(deps.db, {
1226
2928
  accountId: interaction.accountId,
1227
2929
  workspaceId: interaction.workspaceId,
@@ -1244,44 +2946,159 @@ async function deliverSlackSessionEvents(
1244
2946
  );
1245
2947
  }
1246
2948
  }
2949
+ } else if (event.type === "session.requiresAction") {
2950
+ const card = await slackApprovalCard(
2951
+ deps,
2952
+ interaction,
2953
+ event,
2954
+ requester.mention,
2955
+ requester.authorized,
2956
+ );
2957
+ await postDelivery(
2958
+ client,
2959
+ interaction,
2960
+ event,
2961
+ card.text,
2962
+ "approval",
2963
+ card.operationId,
2964
+ card.blocks,
2965
+ );
1247
2966
  } else if (event.type === "session.humanInput.requested") {
1248
- const requests = await listSessionHumanInputRequests(
1249
- deps.db,
1250
- interaction.workspaceId,
1251
- interaction.sessionId,
1252
- { status: "pending", limit: 1 },
2967
+ const card = await slackHumanInputCard(
2968
+ deps,
2969
+ interaction,
2970
+ event,
2971
+ requester.mention,
2972
+ requester.authorized,
1253
2973
  );
1254
- const request = requests[0];
1255
- if (request) {
2974
+ if (card) {
1256
2975
  await postDelivery(
1257
2976
  client,
1258
2977
  interaction,
1259
2978
  event,
1260
- `OpenGeni needs your input:\n${formatQuestions(request.questions)}\nReply in this thread, or use ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)}.`,
2979
+ card.text,
1261
2980
  "human-input",
2981
+ card.operationId,
2982
+ card.blocks,
1262
2983
  );
1263
2984
  }
1264
2985
  } else if (event.type === "turn.completed") {
1265
- 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",
2986
+ const payloadOutput = safePayloadText(event.payload, "output");
2987
+ const hasPublishableOutput = payloadOutput.trim().length > 0;
2988
+ const output = hasPublishableOutput ? payloadOutput : latestAssistantText;
2989
+ const normalizedOutput = output.trim();
2990
+ const recurringLink = requester.canSchedule
2991
+ ? `\n\n${makeRecurringText(deps, interaction.workspaceId, interaction.sessionId)}`
2992
+ : "";
2993
+ const existingProgress = progressEvidence.find(
2994
+ (delivery) =>
2995
+ slackDeliveryTextsCoalesce(boundedOutput(delivery.text).trim(), normalizedOutput) &&
2996
+ slackTerminalProgressSameTurn(event, delivery, interaction, terminalAssistantSequences),
1272
2997
  );
2998
+ if (existingProgress && normalizedOutput) {
2999
+ const publicationBlocks = hasPublishableOutput
3000
+ ? await slackSharedResultPublicationBlocks(
3001
+ deps,
3002
+ interaction,
3003
+ event,
3004
+ requester.authorized,
3005
+ existingProgress.operationId,
3006
+ )
3007
+ : [];
3008
+ // The assistant text may already have been accepted by Slack before a
3009
+ // replica observed turn.completed. Reconcile the same provider
3010
+ // operation id (including response-loss retries) instead of inventing
3011
+ // a second final post.
3012
+ await postDelivery(
3013
+ client,
3014
+ interaction,
3015
+ event,
3016
+ boundedOutput(existingProgress.text),
3017
+ "progress",
3018
+ existingProgress.operationId,
3019
+ );
3020
+ const posted = await getSlackBotPostOperation(
3021
+ deps.db,
3022
+ interaction.workspaceId,
3023
+ interaction.connectionId,
3024
+ existingProgress.operationId,
3025
+ );
3026
+ if (posted?.slackChannelId && posted.slackMessageTimestamp) {
3027
+ const text = boundedOutputWithSuffix(
3028
+ `${requester.mention}${existingProgress.text}`,
3029
+ recurringLink,
3030
+ );
3031
+ await client.updateMessage({
3032
+ operationId: deterministicUuid(
3033
+ `slack-terminal-update:${interaction.id}:${event.sequence}`,
3034
+ ),
3035
+ channelId: posted.slackChannelId,
3036
+ timestamp: posted.slackMessageTimestamp,
3037
+ text,
3038
+ ...(publicationBlocks.length > 0
3039
+ ? {
3040
+ blocks: [
3041
+ {
3042
+ type: "section",
3043
+ text: {
3044
+ type: "mrkdwn",
3045
+ text,
3046
+ },
3047
+ },
3048
+ ...publicationBlocks,
3049
+ ],
3050
+ }
3051
+ : {}),
3052
+ });
3053
+ }
3054
+ } else {
3055
+ const operationId = deterministicUuid(
3056
+ `slack-delivery:${interaction.id}:${event.sequence}:final`,
3057
+ );
3058
+ const finalSuffix = `\n\nReply in this thread to continue.${recurringLink}`;
3059
+ const text = boundedOutputWithSuffix(
3060
+ `${requester.mention}${output || "OpenGeni finished this task."}`,
3061
+ finalSuffix,
3062
+ );
3063
+ const publicationBlocks = hasPublishableOutput
3064
+ ? await slackSharedResultPublicationBlocks(
3065
+ deps,
3066
+ interaction,
3067
+ event,
3068
+ requester.authorized,
3069
+ operationId,
3070
+ )
3071
+ : [];
3072
+ await postDelivery(
3073
+ client,
3074
+ interaction,
3075
+ event,
3076
+ text,
3077
+ "final",
3078
+ operationId,
3079
+ publicationBlocks.length > 0
3080
+ ? [{ type: "section", text: { type: "mrkdwn", text } }, ...publicationBlocks]
3081
+ : undefined,
3082
+ );
3083
+ }
1273
3084
  terminal = "completed";
1274
3085
  } else if (event.type === "turn.failed") {
1275
3086
  await postDelivery(
1276
3087
  client,
1277
3088
  interaction,
1278
3089
  event,
1279
- `OpenGeni could not complete this task. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} for the bounded failure details.`,
3090
+ `${requester.mention}OpenGeni could not complete this task. Reply in this thread to retry or ask for details.`,
1280
3091
  "failed",
1281
3092
  );
1282
3093
  terminal = "failed";
1283
3094
  } else if (event.type === "turn.cancelled") {
1284
- await postDelivery(client, interaction, event, "OpenGeni stopped this task.", "cancelled");
3095
+ await postDelivery(
3096
+ client,
3097
+ interaction,
3098
+ event,
3099
+ `${requester.mention}OpenGeni stopped this task.`,
3100
+ "cancelled",
3101
+ );
1285
3102
  terminal = "cancelled";
1286
3103
  }
1287
3104
  }
@@ -1305,6 +3122,34 @@ async function deliverSlackSessionEvents(
1305
3122
  }
1306
3123
  }
1307
3124
 
3125
+ export function slackDeliveryTextsCoalesce(left: string, right: string): boolean {
3126
+ const normalizedLeft = left.trim();
3127
+ const normalizedRight = right.trim();
3128
+ if (!normalizedLeft || !normalizedRight) return false;
3129
+ if (normalizedLeft === normalizedRight) return true;
3130
+ const [shorter, longer] =
3131
+ normalizedLeft.length < normalizedRight.length
3132
+ ? [normalizedLeft, normalizedRight]
3133
+ : [normalizedRight, normalizedLeft];
3134
+ if (!longer.startsWith(shorter)) return false;
3135
+ const boundary = longer.slice(shorter.length, shorter.length + 1);
3136
+ return boundary.length === 0 || /[\s.,;:!?()[\]{}\-–—]/u.test(boundary);
3137
+ }
3138
+
3139
+ function slackTerminalProgressSameTurn(
3140
+ event: SessionEvent,
3141
+ delivery: { turnId: string | null; sessionEventSequence: number },
3142
+ interaction: SlackInteraction,
3143
+ samePageTerminalSequences: ReadonlySet<number>,
3144
+ ): boolean {
3145
+ if (event.turnId && delivery.turnId) return event.turnId === delivery.turnId;
3146
+ if (event.turnId || delivery.turnId) return false;
3147
+ return (
3148
+ delivery.sessionEventSequence === interaction.lastDeliveredSessionEventSequence ||
3149
+ samePageTerminalSequences.has(delivery.sessionEventSequence)
3150
+ );
3151
+ }
3152
+
1308
3153
  async function postDelivery(
1309
3154
  client: Awaited<ReturnType<typeof createOpenGeniSlackBotInteractionClient>>,
1310
3155
  interaction: SlackInteraction,
@@ -1312,12 +3157,14 @@ async function postDelivery(
1312
3157
  text: string,
1313
3158
  kind: string,
1314
3159
  operationId = deterministicUuid(`slack-delivery:${interaction.id}:${event.sequence}:${kind}`),
3160
+ blocks?: SlackMessageBlock[],
1315
3161
  ) {
1316
3162
  await client.postMessage({
1317
3163
  operationId,
1318
3164
  channelId: interaction.slackChannelId,
1319
3165
  threadTimestamp: interaction.slackThreadTs,
1320
3166
  text: boundedOutput(text),
3167
+ ...(blocks ? { blocks } : {}),
1321
3168
  });
1322
3169
  }
1323
3170
 
@@ -1384,6 +3231,67 @@ function normalizedFormInteraction(
1384
3231
  slackThreadTs: null,
1385
3232
  triggerKind,
1386
3233
  text,
3234
+ hasFiles: false,
3235
+ };
3236
+ }
3237
+
3238
+ const SLACK_BLOCK_ACTION_IDS = new Set([
3239
+ "opengeni.approval.approve",
3240
+ "opengeni.approval.reject",
3241
+ "opengeni.human_input.select",
3242
+ "opengeni.human_input.skip",
3243
+ "opengeni.session.status",
3244
+ "opengeni.session.pause",
3245
+ "opengeni.session.resume",
3246
+ "opengeni.shared_result.publish",
3247
+ ]);
3248
+
3249
+ export function normalizedBlockActionInteraction(
3250
+ payload: Record<string, unknown>,
3251
+ ): NormalizedSlackInteraction {
3252
+ const team = record(payload.team);
3253
+ const user = record(payload.user);
3254
+ const channel = record(payload.channel);
3255
+ const container = record(payload.container);
3256
+ const message = record(payload.message);
3257
+ const action = Array.isArray(payload.actions) ? record(payload.actions[0]) : null;
3258
+ const teamId = boundedString(team?.id, 64);
3259
+ const userId = boundedString(user?.id, 64);
3260
+ const channelId = boundedString(channel?.id, 64) ?? boundedString(container?.channel_id, 64);
3261
+ const messageTs = boundedString(message?.ts, 64) ?? boundedString(container?.message_ts, 64);
3262
+ const threadTs = boundedString(message?.thread_ts, 64);
3263
+ const actionTs = boundedString(action?.action_ts, 64) ?? boundedString(payload.action_ts, 64);
3264
+ const actionId = boundedString(action?.action_id, 128);
3265
+ const handleId = boundedString(action?.value, 64);
3266
+ if (
3267
+ !teamId ||
3268
+ !userId ||
3269
+ !channelId ||
3270
+ !messageTs ||
3271
+ !actionTs ||
3272
+ !actionId ||
3273
+ !SLACK_BLOCK_ACTION_IDS.has(actionId) ||
3274
+ !handleId ||
3275
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(handleId) ||
3276
+ !Array.isArray(payload.actions) ||
3277
+ payload.actions.length !== 1
3278
+ ) {
3279
+ throw new HTTPException(400, { message: "invalid Slack block action" });
3280
+ }
3281
+ const identity = createHash("sha256")
3282
+ .update([teamId, userId, channelId, messageTs, actionTs, actionId, handleId].join("\n"))
3283
+ .digest("hex");
3284
+ return {
3285
+ providerEventId: `block:${identity}`,
3286
+ providerMessageId: `block:${identity}`,
3287
+ slackTeamId: teamId,
3288
+ slackUserId: userId,
3289
+ slackChannelId: channelId,
3290
+ slackMessageTs: messageTs,
3291
+ slackThreadTs: threadTs,
3292
+ triggerKind: "block_action",
3293
+ text: `${actionId}:${handleId}`,
3294
+ hasFiles: false,
1387
3295
  };
1388
3296
  }
1389
3297
 
@@ -1422,9 +3330,17 @@ function formatQuestions(questions: HumanInputQuestion[]) {
1422
3330
 
1423
3331
  function openSessionText(deps: ApiRouteDeps, workspaceId: string, sessionId: string) {
1424
3332
  const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
1425
- return base
1426
- ? `Open in OpenGeni: ${new URL(`/workspaces/${workspaceId}/sessions/${sessionId}`, base).toString()}`
1427
- : "Open this session in OpenGeni";
3333
+ if (!base) throw new Error("Slack session acknowledgement requires an absolute web base URL");
3334
+ const url = new URL(`/workspaces/${workspaceId}/sessions/${sessionId}`, base).toString();
3335
+ return `<${url}|Open in OpenGeni>`;
3336
+ }
3337
+
3338
+ function makeRecurringText(deps: ApiRouteDeps, workspaceId: string, sessionId: string) {
3339
+ const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
3340
+ if (!base) throw new Error("Slack recurring action requires an absolute web base URL");
3341
+ const url = new URL(`/workspaces/${workspaceId}/schedules`, base);
3342
+ url.searchParams.set("sourceSessionId", sessionId);
3343
+ return `<${url.toString()}|Make recurring>`;
1428
3344
  }
1429
3345
 
1430
3346
  function linkUrl(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
@@ -1432,10 +3348,47 @@ function linkUrl(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
1432
3348
  const signingSecret = deps.settings.slackSigningSecret;
1433
3349
  if (!base || !signingSecret) return "OpenGeni Settings → Integrations → Slack";
1434
3350
  const url = new URL(`/workspaces/${entry.workspaceId}/capabilities`, base);
1435
- url.searchParams.set("slack_link", createSlackUserLinkToken(signingSecret, entry));
3351
+ // Fragments stay out of HTTP request lines, reverse-proxy logs, Referer
3352
+ // headers, and managed-auth callback URLs. Query-form bearers are rejected.
3353
+ url.hash = new URLSearchParams({
3354
+ slack_link: createSlackUserLinkToken(signingSecret, entry),
3355
+ }).toString();
1436
3356
  return url.toString();
1437
3357
  }
1438
3358
 
3359
+ async function requireManagedSlackLinkHuman(c: Context, deps: ApiRouteDeps) {
3360
+ if (c.req.header("authorization")) {
3361
+ throw new HTTPException(401, { message: "managed browser sign-in required" });
3362
+ }
3363
+ const context = await requireAccessContext(c, deps);
3364
+ if (context.mode !== "managed" || !context.subjectId.startsWith("user:")) {
3365
+ throw new HTTPException(403, { message: "managed browser sign-in required" });
3366
+ }
3367
+ return context;
3368
+ }
3369
+
3370
+ function freshSlackLinkRequired() {
3371
+ return new HTTPException(400, {
3372
+ message: "This Slack link is invalid or expired. Request a fresh link from Slack.",
3373
+ });
3374
+ }
3375
+
3376
+ function slackLinkAccessHttpError(error: unknown): HTTPException {
3377
+ if (error instanceof HTTPException) return error;
3378
+ if (!(error instanceof SlackUserLinkAccessPersistenceError)) throw error;
3379
+ if (error.code === "version_conflict" || error.code === "idempotency_conflict") {
3380
+ return new HTTPException(409, {
3381
+ message: "The Slack access request changed. Refresh and try again.",
3382
+ });
3383
+ }
3384
+ if (error.code === "state_conflict") {
3385
+ return new HTTPException(409, {
3386
+ message: "This Slack link is no longer pending. Request a fresh link from Slack.",
3387
+ });
3388
+ }
3389
+ return freshSlackLinkRequired();
3390
+ }
3391
+
1439
3392
  type SlackUserLinkToken = {
1440
3393
  workspaceId: string;
1441
3394
  connectionId: string;
@@ -1515,6 +3468,16 @@ function isDirectMessageShortcut(
1515
3468
  return entry.triggerKind === "message_shortcut" && entry.slackChannelId.startsWith("D");
1516
3469
  }
1517
3470
 
3471
+ function usesPrivateBotDm(
3472
+ interaction: Pick<SlackInteraction, "visibility">,
3473
+ entry: Pick<SlackInteractionInboxEntry, "triggerKind" | "slackChannelId">,
3474
+ ): boolean {
3475
+ return (
3476
+ isDirectMessageShortcut(entry) ||
3477
+ (interaction.visibility === "private" && entry.triggerKind !== "dm")
3478
+ );
3479
+ }
3480
+
1518
3481
  function deterministicUuid(value: string) {
1519
3482
  const bytes = createHash("sha256").update(value).digest().subarray(0, 16);
1520
3483
  bytes[6] = (bytes[6]! & 0x0f) | 0x50;
@@ -1559,6 +3522,13 @@ function boundedOutput(value: string) {
1559
3522
  : `${value.slice(0, MAX_SLACK_TEXT_CHARS - 20)}\n… output truncated`;
1560
3523
  }
1561
3524
 
3525
+ function boundedOutputWithSuffix(value: string, suffix: string) {
3526
+ const maxValueChars = Math.max(0, MAX_SLACK_TEXT_CHARS - suffix.length);
3527
+ if (value.length <= maxValueChars) return `${value}${suffix}`;
3528
+ const truncation = "\n… output truncated";
3529
+ return `${value.slice(0, Math.max(0, maxValueChars - truncation.length))}${truncation}${suffix}`;
3530
+ }
3531
+
1562
3532
  function safePayloadText(payload: unknown, field: string) {
1563
3533
  const value = record(payload)?.[field];
1564
3534
  return typeof value === "string" ? boundedOutput(value) : "";