@opengeni/api-router 0.26.1 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/app.d.ts +1 -0
  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 +19 -1
  6. package/dist/browser-network-route.d.ts +8 -0
  7. package/dist/{chunk-JIKNR5YL.js → chunk-2PQMSRCB.js} +15518 -6863
  8. package/dist/chunk-2PQMSRCB.js.map +1 -0
  9. package/dist/controller-data-plane.d.ts +12 -0
  10. package/dist/http/auth.d.ts +1 -1
  11. package/dist/http/sse.d.ts +18 -1
  12. package/dist/index.js +1 -1
  13. package/dist/integrations/api-integrations.d.ts +2 -2
  14. package/dist/integrations/fiken.d.ts +233 -0
  15. package/dist/integrations/google-drive.d.ts +6 -6
  16. package/dist/integrations/provider-oauth.d.ts +0 -2
  17. package/dist/integrations/slack-bot.d.ts +171 -1
  18. package/dist/integrations/slack-interactions.d.ts +17 -5
  19. package/dist/interaction-holder-heartbeat.d.ts +17 -0
  20. package/dist/interaction-metrics.d.ts +11 -0
  21. package/dist/mcp/server.d.ts +3 -2
  22. package/dist/model-catalog.d.ts +1 -0
  23. package/dist/routes/canonical-human-identities.d.ts +3 -0
  24. package/dist/routes/channels.d.ts +3 -0
  25. package/dist/routes/integration-facets.d.ts +3 -0
  26. package/dist/routes/interaction-resources.d.ts +5 -0
  27. package/dist/routes/organization-memberships.d.ts +3 -0
  28. package/dist/routes/sessions.d.ts +2 -1
  29. package/dist/routes/slack-task-policy.d.ts +3 -0
  30. package/dist/routes/supergrok.d.ts +3 -0
  31. package/dist/sandbox/channel-a.d.ts +8 -1
  32. package/dist/sandbox/machines.d.ts +2 -2
  33. package/dist/sandbox/viewer.d.ts +3 -1
  34. package/dist/slack-reaction-files.d.ts +27 -0
  35. package/dist/transcription/providers/xai-subscription.d.ts +8 -0
  36. package/dist/xai-realtime.d.ts +26 -0
  37. package/dist/xai-subscription-auth.d.ts +23 -0
  38. package/package.json +17 -16
  39. package/src/app.ts +352 -49
  40. package/src/auth/canonical-human-session-admission.ts +116 -0
  41. package/src/auth/managed-auth.ts +101 -0
  42. package/src/browser-auth-broker.ts +155 -0
  43. package/src/browser-controller-authority.ts +50 -1
  44. package/src/browser-network-route.ts +34 -0
  45. package/src/controller-data-plane.ts +35 -0
  46. package/src/http/auth.ts +27 -9
  47. package/src/http/sse.ts +229 -5
  48. package/src/integrations/api-integrations.ts +57 -42
  49. package/src/integrations/fiken.ts +1230 -0
  50. package/src/integrations/google-drive.ts +31 -26
  51. package/src/integrations/provider-oauth.ts +70 -91
  52. package/src/integrations/slack-bot.ts +638 -9
  53. package/src/integrations/slack-interactions.ts +1583 -73
  54. package/src/interaction-holder-heartbeat.ts +95 -0
  55. package/src/interaction-metrics.ts +159 -0
  56. package/src/mcp/documents.ts +115 -6
  57. package/src/mcp/server.ts +865 -242
  58. package/src/model-catalog.ts +19 -6
  59. package/src/routes/api-integrations.ts +32 -29
  60. package/src/routes/browser-identities.ts +24 -0
  61. package/src/routes/browser-sessions.ts +2122 -183
  62. package/src/routes/canonical-human-identities.ts +156 -0
  63. package/src/routes/channels.ts +90 -0
  64. package/src/routes/computer-sessions.ts +441 -86
  65. package/src/routes/connections.ts +141 -4
  66. package/src/routes/{integration-features.ts → integration-facets.ts} +63 -63
  67. package/src/routes/interaction-resources.ts +595 -0
  68. package/src/routes/organization-memberships.ts +53 -0
  69. package/src/routes/plugins.ts +2 -1
  70. package/src/routes/sessions.ts +419 -43
  71. package/src/routes/skills.ts +92 -10
  72. package/src/routes/slack-task-policy.ts +115 -0
  73. package/src/routes/supergrok.ts +717 -0
  74. package/src/routes/transcription-recordings.ts +9 -2
  75. package/src/routes/transcriptions.ts +2 -1
  76. package/src/routes/video-generation.ts +34 -4
  77. package/src/routes/workspaces.ts +22 -4
  78. package/src/sandbox/channel-a.ts +29 -2
  79. package/src/sandbox/machines.ts +5 -5
  80. package/src/sandbox/viewer.ts +10 -3
  81. package/src/slack-reaction-files.ts +181 -0
  82. package/src/transcription/providers/xai-subscription.ts +110 -0
  83. package/src/transcription/service.ts +17 -2
  84. package/src/xai-realtime.ts +216 -0
  85. package/src/xai-subscription-auth.ts +132 -0
  86. package/dist/chunk-JIKNR5YL.js.map +0 -1
  87. package/dist/routes/integration-features.d.ts +0 -3
@@ -1,23 +1,32 @@
1
1
  import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
2
  import {
3
+ approvalIdentifier,
3
4
  ApproveSlackUserLinkAccessRequest,
4
5
  DEFAULT_FIRST_PARTY_MCP_TOOLS,
5
6
  hasOpenGeniSlackReactionScope,
6
7
  ListSlackUserLinkAccessRequestsResponse,
7
8
  PrepareSlackUserLinkAccessRequest,
9
+ evaluateSlackTaskPolicy,
8
10
  resolveWorkspaceSlackReactionSummonSettings,
9
11
  SlackReactionChannelListResponse,
10
12
  SlackUserLinkAccessMutationRequest,
11
13
  SlackUserLinkAccessRequest,
12
14
  type AccessGrant,
15
+ type FileResourceRef,
13
16
  type FirstPartyMcpToolName,
14
17
  type HumanInputQuestion,
15
18
  type SessionEvent,
16
19
  type WorkspaceSlackReactionSummonSettings,
17
20
  workspaceSlackReactionChannelAllowed,
18
21
  } from "@opengeni/contracts";
22
+ import {
23
+ allowedFirstPartyMcpToolsForSession,
24
+ resolveFirstPartyMcpToolPolicy,
25
+ type Settings,
26
+ } from "@opengeni/config";
19
27
  import {
20
28
  acceptSessionHumanInputResponse,
29
+ acceptSessionApprovalDecision,
21
30
  approveSlackUserLinkAccessRequest,
22
31
  advanceSlackInteractionDelivery,
23
32
  bindSlackInteractionSession,
@@ -33,9 +42,17 @@ import {
33
42
  getConnectionMetadata,
34
43
  getOrCreateSlackInteraction,
35
44
  getLatestSessionModelForSubject,
45
+ getSession,
46
+ getSessionEvent,
47
+ getSessionHumanInputRequest,
48
+ getSlackBotPostOperation,
36
49
  getSlackBotUserLink,
50
+ getSlackInteractionActionHandle,
37
51
  getSlackInteractionByClientEventId,
52
+ getSlackInteractionById,
38
53
  getSlackInteractionByRoute,
54
+ getActiveSlackTaskPolicy,
55
+ getSlackSharedTaskOrigin,
39
56
  getSessionEventByClientEventId,
40
57
  getWorkspace,
41
58
  getWorkspaceGrant,
@@ -45,17 +62,22 @@ import {
45
62
  listPendingSlackUserLinkAccessRequests,
46
63
  rekeySlackInteractionRoute,
47
64
  reopenSlackInteractionDelivery,
65
+ reserveSlackInteractionActionHandles,
48
66
  releaseSlackInteractionDelivery,
49
67
  releaseSlackInteractionInbox,
50
68
  requestSlackUserLinkWorkspaceAccess,
51
69
  resolveSlackInstallationRoute,
52
70
  saveSlackInteractionInboxReactionCheckpoint,
71
+ saveSlackSharedTaskOrigin,
53
72
  settleSlackInteractionInbox,
73
+ settleSlackInteractionActionHandles,
54
74
  denySlackUserLinkAccessRequest,
55
75
  prepareSlackUserLinkAccessRequest,
56
76
  SlackUserLinkAccessPersistenceError,
57
77
  type SlackInstallationRoute,
58
78
  type SlackInteraction,
79
+ type SlackInteractionActionHandle,
80
+ type SlackInteractionActionKind,
59
81
  type SlackInteractionInboxEntry,
60
82
  type SlackInteractionTriggerKind,
61
83
  } from "@opengeni/db";
@@ -73,15 +95,19 @@ import type { Context, Hono } from "hono";
73
95
  import { HTTPException } from "hono/http-exception";
74
96
  import {
75
97
  createOpenGeniSlackBotInteractionClient,
98
+ SLACK_REACTION_IMAGE_MAX_BYTES,
76
99
  type OpenGeniSlackBotClient,
100
+ type SlackMessageBlock,
77
101
  SlackBotProviderError,
78
102
  } from "./slack-bot";
103
+ import { importSlackReactionImage, type ImportedSlackReactionImage } from "../slack-reaction-files";
79
104
 
80
105
  export const SLACK_INTERACTION_MAX_BODY_BYTES = 256 * 1024;
81
106
  export const SLACK_SIGNATURE_REPLAY_WINDOW_SECONDS = 300;
82
107
  export const SLACK_DELIVERY_EVENT_TYPES = [
83
108
  "agent.message.completed",
84
109
  "session.humanInput.requested",
110
+ "session.requiresAction",
85
111
  "turn.completed",
86
112
  "turn.failed",
87
113
  "turn.cancelled",
@@ -94,14 +120,22 @@ const MAX_SLACK_INVOCATION_CONTEXT_MESSAGES = 15;
94
120
  const MAX_SLACK_CHANNEL_CONTEXT_MESSAGES = 5;
95
121
  const MAX_SLACK_REACTION_CONTEXT_MESSAGES = 15;
96
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;
97
125
  const MAX_PROGRESS_MESSAGES = 3;
98
126
  const SLACK_USER_LINK_TTL_MS = 15 * 60_000;
99
127
  const INBOX_LEASE_MS = 30_000;
100
128
  const DELIVERY_LEASE_MS = 30_000;
101
129
  const MAX_DELIVERY_ATTEMPTS = 8;
102
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;
103
135
  export const SLACK_TASK_INSTRUCTIONS = [
104
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.",
105
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.",
106
140
  "Never expose private reasoning, credentials, secrets, raw logs, or unbounded output.",
107
141
  "Keep user-visible output concise, bounded, and safe to send back to Slack.",
@@ -124,6 +158,14 @@ export const SLACK_TASK_FIRST_PARTY_MCP_TOOLS = [
124
158
  "slack_bot_file_content",
125
159
  ] satisfies readonly FirstPartyMcpToolName[];
126
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
+
127
169
  export type NormalizedSlackInteraction = {
128
170
  providerEventId: string;
129
171
  providerMessageId: string;
@@ -134,6 +176,7 @@ export type NormalizedSlackInteraction = {
134
176
  slackThreadTs: string | null;
135
177
  triggerKind: SlackInteractionTriggerKind;
136
178
  text: string;
179
+ hasFiles: boolean;
137
180
  };
138
181
 
139
182
  export function slackInteractionRoutePolicy(
@@ -141,8 +184,9 @@ export function slackInteractionRoutePolicy(
141
184
  SlackInteractionInboxEntry,
142
185
  "triggerKind" | "slackChannelId" | "slackThreadTs" | "slackMessageTs" | "slackUserId"
143
186
  >,
187
+ options: { privateHandoff?: boolean } = {},
144
188
  ) {
145
- const directMessageShortcut = isDirectMessageShortcut(entry);
189
+ const directMessageShortcut = isDirectMessageShortcut(entry) || options.privateHandoff === true;
146
190
  const source = slackRouteKey(entry.slackChannelId, entry.slackThreadTs ?? entry.slackMessageTs);
147
191
  return {
148
192
  directMessageShortcut,
@@ -204,7 +248,9 @@ export function slackEventInboxEntry(
204
248
  const channelId = boundedString(event.channel, 64);
205
249
  const timestamp = boundedString(event.ts, 64);
206
250
  const threadTimestamp = boundedString(event.thread_ts, 64);
207
- 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);
208
254
  if (!userId || !channelId || !timestamp || !text) return null;
209
255
  let triggerKind: SlackInteractionTriggerKind;
210
256
  const explicitlyMentionsBot = text.includes(`<@${bot.botUserId}>`);
@@ -234,6 +280,7 @@ export function slackEventInboxEntry(
234
280
  slackThreadTs: threadTimestamp,
235
281
  triggerKind,
236
282
  text,
283
+ hasFiles,
237
284
  };
238
285
  }
239
286
 
@@ -284,6 +331,7 @@ export function slackReactionInboxEntry(
284
331
  // Store only the exact emoji name before authorization/content fetch. The
285
332
  // provider message and bounded thread are projected at durable claim time.
286
333
  text: reaction,
334
+ hasFiles: false,
287
335
  };
288
336
  }
289
337
 
@@ -370,6 +418,15 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
370
418
  const signed = await readSignedSlackRequest(c, deps);
371
419
  const form = new URLSearchParams(signed.rawBody);
372
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
+ }
373
430
  if (payload.type !== "message_action") {
374
431
  throw new HTTPException(400, {
375
432
  message: "unsupported Slack interaction",
@@ -406,6 +463,7 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
406
463
  slackThreadTs: threadTs,
407
464
  triggerKind: "message_shortcut",
408
465
  text,
466
+ hasFiles: false,
409
467
  });
410
468
  return c.json({ ok: true });
411
469
  });
@@ -828,11 +886,51 @@ export function startSlackInteractionPump(
828
886
  }
829
887
 
830
888
  async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
889
+ if (entry.triggerKind === "block_action") {
890
+ await processSlackBlockAction(deps, entry);
891
+ return;
892
+ }
831
893
  if (entry.triggerKind === "reaction") {
832
894
  await processSlackReactionInboxEntry(deps, entry);
833
895
  return;
834
896
  }
835
- 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
+ });
836
934
  const routeKey = routePolicy.initialRouteKey;
837
935
  const existing = await getSlackInteractionByRoute(
838
936
  deps.db,
@@ -848,16 +946,6 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
848
946
  entry.connectionId,
849
947
  entry.slackUserId,
850
948
  );
851
- const client = await createOpenGeniSlackBotInteractionClient(deps, {
852
- accountId: entry.accountId,
853
- workspaceId: entry.workspaceId,
854
- connectionId: entry.connectionId,
855
- subjectId: link?.subjectId ?? "service:slack-interaction",
856
- ...(existing?.sessionId ? { sessionId: existing.sessionId } : {}),
857
- });
858
- if (routePolicy.requiresChannelAccess) {
859
- await client.verifyChannelAccess(entry.slackChannelId);
860
- }
861
949
  if (!link) {
862
950
  await client.postMessage({
863
951
  operationId: deterministicUuid(`slack-link:${entry.id}`),
@@ -898,15 +986,16 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
898
986
  if (!boundInteraction) {
899
987
  throw new Error("Durable Slack interaction could not bind its reserved session");
900
988
  }
989
+ await ensureSlackSharedTaskOrigin(deps, boundInteraction, entry, policyResolution);
901
990
  const shouldRepairAcknowledgement =
902
991
  interaction.triggeringProviderEventId === entry.providerEventId ||
903
- (isDirectMessageShortcut(entry) && boundInteraction.ackSlackMessageTs === null);
992
+ (usesPrivateBotDm(boundInteraction, entry) && boundInteraction.ackSlackMessageTs === null);
904
993
  if (shouldRepairAcknowledgement) {
905
994
  const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
906
995
  accountId: entry.accountId,
907
996
  workspaceId: entry.workspaceId,
908
997
  connectionId: entry.connectionId,
909
- subjectId: grant.subjectId,
998
+ subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
910
999
  sessionId: eventSessionId,
911
1000
  });
912
1001
  await acknowledgeSlackSession(deps, boundClient, boundInteraction, entry);
@@ -914,15 +1003,47 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
914
1003
  return;
915
1004
  }
916
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
+
917
1028
  if (existing?.sessionId) {
918
- 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
+ );
919
1042
  return;
920
1043
  }
921
1044
  if (!hasPermission(grant.permissions, "sessions:create")) {
922
1045
  throw new SlackInteractionPermanentError("sessions_create_denied");
923
1046
  }
924
- const preparedEntry =
925
- entry.triggerKind === "app_mention" ? await prepareSlackInvocationEntry(client, entry) : entry;
926
1047
  const { interaction } = await getOrCreateSlackInteraction(deps.db, {
927
1048
  accountId: entry.accountId,
928
1049
  workspaceId: entry.workspaceId,
@@ -932,11 +1053,24 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
932
1053
  slackThreadTs: entry.slackThreadTs ?? entry.slackMessageTs,
933
1054
  routeKey,
934
1055
  triggeringProviderEventId: entry.providerEventId,
1056
+ initiatingSlackUserId: entry.slackUserId,
935
1057
  owningSubjectId: grant.subjectId,
936
1058
  visibility: routePolicy.visibility,
937
1059
  });
938
1060
  if (interaction.sessionId) {
939
- 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
+ );
940
1074
  return;
941
1075
  }
942
1076
  const preferredModel = await getLatestSessionModelForSubject(
@@ -948,9 +1082,10 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
948
1082
  try {
949
1083
  session = await createSessionForRequest(deps, grant, entry.workspaceId, {
950
1084
  requestedSessionId: interaction.sessionReservationId,
951
- initialMessage: preparedEntry.text,
1085
+ initialMessage: slackInvocationPreparedEntry(preparedEntry, preparedAttachments).text,
952
1086
  turnInstructions: SLACK_TASK_INSTRUCTIONS,
953
- firstPartyMcpTools: [...SLACK_TASK_FIRST_PARTY_MCP_TOOLS],
1087
+ firstPartyMcpTools: slackTaskFirstPartyMcpTools(deps.settings),
1088
+ resources: preparedAttachments.resources,
954
1089
  ...(preferredModel ? { model: preferredModel } : {}),
955
1090
  idempotencyKey: `slack:${entry.connectionId}:${entry.providerEventId}`,
956
1091
  clientEventId: `slack:${entry.providerEventId}`,
@@ -959,7 +1094,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
959
1094
  if (error instanceof HTTPException) {
960
1095
  await client.postMessage({
961
1096
  operationId: deterministicUuid(`slack-admission-failed:${interaction.id}`),
962
- ...(isDirectMessageShortcut(entry)
1097
+ ...(routePolicy.directMessageShortcut
963
1098
  ? { userId: entry.slackUserId }
964
1099
  : {
965
1100
  channelId: entry.slackChannelId,
@@ -978,16 +1113,103 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
978
1113
  sessionId: session.id,
979
1114
  });
980
1115
  if (!bound) throw new Error("Slack route could not bind its durable session");
1116
+ await ensureSlackSharedTaskOrigin(deps, bound, entry, policyResolution);
981
1117
  const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
982
1118
  accountId: entry.accountId,
983
1119
  workspaceId: entry.workspaceId,
984
1120
  connectionId: entry.connectionId,
985
- subjectId: grant.subjectId,
1121
+ subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
986
1122
  sessionId: session.id,
987
1123
  });
988
1124
  await acknowledgeSlackSession(deps, boundClient, bound, entry);
989
1125
  }
990
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
+
991
1213
  export type SlackInvocationMessageContext = {
992
1214
  messages: Awaited<ReturnType<OpenGeniSlackBotClient["threadReplies"]>>["messages"];
993
1215
  nextCursor: string | null;
@@ -995,31 +1217,72 @@ export type SlackInvocationMessageContext = {
995
1217
  };
996
1218
 
997
1219
  async function prepareSlackInvocationEntry(
1220
+ deps: ApiRouteDeps,
998
1221
  client: OpenGeniSlackBotClient,
999
1222
  entry: SlackInteractionInboxEntry,
1000
- ): Promise<SlackInteractionInboxEntry> {
1223
+ authorizeRead?: () => Promise<void>,
1224
+ ): Promise<{ entry: SlackInteractionInboxEntry; attachments: PreparedSlackReactionTask }> {
1001
1225
  const context = entry.slackThreadTs
1002
1226
  ? await client.threadReplies({
1003
1227
  channelId: entry.slackChannelId,
1004
1228
  threadTimestamp: entry.slackThreadTs,
1005
1229
  limit: MAX_SLACK_INVOCATION_CONTEXT_MESSAGES,
1230
+ ...(authorizeRead ? { authorizeRead } : {}),
1006
1231
  })
1007
1232
  : await client.channelHistory({
1008
1233
  channelId: entry.slackChannelId,
1009
1234
  latest: entry.slackMessageTs,
1010
1235
  inclusive: true,
1011
1236
  limit: MAX_SLACK_CHANNEL_CONTEXT_MESSAGES,
1237
+ ...(authorizeRead ? { authorizeRead } : {}),
1012
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;
1013
1268
  return {
1014
- ...entry,
1015
- text: slackInvocationTaskText(entry, {
1016
- messages: context.messages,
1017
- nextCursor: context.nextCursor,
1018
- kind: entry.slackThreadTs ? "thread" : "channel",
1019
- }),
1269
+ entry: { ...entry, text: baseText },
1270
+ attachments,
1020
1271
  };
1021
1272
  }
1022
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
+
1023
1286
  export function slackInvocationTaskText(
1024
1287
  entry: Pick<SlackInteractionInboxEntry, "slackMessageTs" | "slackUserId" | "text">,
1025
1288
  context: SlackInvocationMessageContext,
@@ -1097,9 +1360,23 @@ async function acknowledgeSlackSession(
1097
1360
  throw new Error("Slack acknowledgement requires a bound session");
1098
1361
  }
1099
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
+ });
1100
1377
  const ack = await client.postMessage({
1101
- operationId: deterministicUuid(`slack-ack:${interaction.id}`),
1102
- ...(directMessageShortcut
1378
+ operationId,
1379
+ ...(privateBotDm
1103
1380
  ? { userId: entry.slackUserId }
1104
1381
  : {
1105
1382
  channelId: entry.slackChannelId,
@@ -1107,18 +1384,24 @@ async function acknowledgeSlackSession(
1107
1384
  ? {}
1108
1385
  : { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
1109
1386
  }),
1110
- text: directMessageShortcut
1111
- ? `OpenGeni started a private task from the selected DM message. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this bot-DM thread to continue, or reply \`stop\` to stop. The source DM was not opened to the bot or made workspace-visible.`
1112
- : `OpenGeni started this task. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this thread to continue, or reply \`stop\` to stop. Start a new top-level DM or invoke /opengeni again for a new session.`,
1387
+ text,
1388
+ ...(controls.length > 0
1389
+ ? {
1390
+ blocks: [
1391
+ { type: "section", text: { type: "mrkdwn", text } },
1392
+ ...controls,
1393
+ ] as SlackMessageBlock[],
1394
+ }
1395
+ : {}),
1113
1396
  });
1114
- if (entry.triggerKind === "slash_command" || directMessageShortcut) {
1397
+ if (entry.triggerKind === "slash_command" || privateBotDm) {
1115
1398
  const rekeyed = await rekeySlackInteractionRoute(deps.db, {
1116
1399
  ...interaction,
1117
1400
  routeKey: slackRouteKey(ack.channelId, ack.timestamp),
1118
1401
  slackChannelId: ack.channelId,
1119
1402
  slackThreadTs: ack.timestamp,
1120
1403
  ackSlackMessageTs: ack.timestamp,
1121
- repairUnacknowledgedPrivateShortcutDelivery: directMessageShortcut,
1404
+ repairUnacknowledgedPrivateShortcutDelivery: privateBotDm,
1122
1405
  });
1123
1406
  if (!rekeyed) throw new Error("Slack acknowledgement could not rekey its durable route");
1124
1407
  }
@@ -1196,7 +1479,7 @@ async function processSlackReactionInboxEntry(
1196
1479
  accountId: entry.accountId,
1197
1480
  workspaceId: entry.workspaceId,
1198
1481
  connectionId: entry.connectionId,
1199
- subjectId: grant.subjectId,
1482
+ subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
1200
1483
  sessionId: eventSessionId,
1201
1484
  });
1202
1485
  await acknowledgeSlackReactionSession(deps, client, boundInteraction, settings.emoji);
@@ -1208,7 +1491,7 @@ async function processSlackReactionInboxEntry(
1208
1491
  accountId: entry.accountId,
1209
1492
  workspaceId: entry.workspaceId,
1210
1493
  connectionId: entry.connectionId,
1211
- subjectId: grant.subjectId,
1494
+ subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
1212
1495
  });
1213
1496
  const context = await client.reactionMessageContext({
1214
1497
  channelId: entry.slackChannelId,
@@ -1237,11 +1520,7 @@ async function processSlackReactionInboxEntry(
1237
1520
  if (!saved) throw new Error("Slack reaction inbox checkpoint claim was lost");
1238
1521
  },
1239
1522
  });
1240
- const preparedEntry: SlackInteractionInboxEntry = {
1241
- ...entry,
1242
- slackThreadTs: context.threadTimestamp,
1243
- text: slackReactionTaskText(context),
1244
- };
1523
+ const preparedTask = await prepareSlackReactionTask(deps, client, entry, context);
1245
1524
  const routeKey = slackRouteKey(entry.slackChannelId, context.threadTimestamp);
1246
1525
  const existing = await getSlackInteractionByRoute(
1247
1526
  deps.db,
@@ -1250,7 +1529,19 @@ async function processSlackReactionInboxEntry(
1250
1529
  routeKey,
1251
1530
  );
1252
1531
  if (existing?.sessionId) {
1253
- 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
+ );
1254
1545
  return;
1255
1546
  }
1256
1547
  const { interaction } = await getOrCreateSlackInteraction(deps.db, {
@@ -1262,11 +1553,24 @@ async function processSlackReactionInboxEntry(
1262
1553
  slackThreadTs: context.threadTimestamp,
1263
1554
  routeKey,
1264
1555
  triggeringProviderEventId: entry.providerEventId,
1556
+ initiatingSlackUserId: entry.slackUserId,
1265
1557
  owningSubjectId: grant.subjectId,
1266
1558
  visibility: "workspace",
1267
1559
  });
1268
1560
  if (interaction.sessionId) {
1269
- 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
+ );
1270
1574
  return;
1271
1575
  }
1272
1576
  if (interaction.owningSubjectId !== grant.subjectId) {
@@ -1282,6 +1586,7 @@ async function processSlackReactionInboxEntry(
1282
1586
  entry.workspaceId,
1283
1587
  grant.subjectId,
1284
1588
  );
1589
+ const preparedEntry = slackReactionPreparedEntry(entry, context, preparedTask);
1285
1590
  let session: Awaited<ReturnType<typeof createSessionForRequest>>;
1286
1591
  try {
1287
1592
  session = await createSessionForRequest(deps, grant, entry.workspaceId, {
@@ -1290,7 +1595,8 @@ async function processSlackReactionInboxEntry(
1290
1595
  turnInstructions: SLACK_TASK_INSTRUCTIONS,
1291
1596
  // The exact reacted message and bounded containing thread are already in
1292
1597
  // the prompt; do not expose general Slack history tools for this trigger.
1293
- firstPartyMcpTools: [...DEFAULT_FIRST_PARTY_MCP_TOOLS],
1598
+ firstPartyMcpTools: resolveFirstPartyMcpToolPolicy(deps.settings).default,
1599
+ resources: preparedTask.resources,
1294
1600
  ...(preferredModel ? { model: preferredModel } : {}),
1295
1601
  // Every reaction entry converging on this route must use the same create
1296
1602
  // key. This closes the same-owner multi-event race while the owner check
@@ -1303,7 +1609,7 @@ async function processSlackReactionInboxEntry(
1303
1609
  // by that operation. Replay this exact Slack event through the normal
1304
1610
  // per-message idempotency boundary: the create winner is recognized as the
1305
1611
  // initial event, while every distinct loser appends one durable task.
1306
- await acceptSlackReactionTask(deps, grant, session.id, preparedEntry);
1612
+ await acceptSlackReactionTask(deps, grant, session.id, preparedEntry, preparedTask.resources);
1307
1613
  } catch (error) {
1308
1614
  if (error instanceof HTTPException) {
1309
1615
  await client.postMessage({
@@ -1345,7 +1651,67 @@ type SlackReactionMessageContext = Awaited<
1345
1651
  ReturnType<OpenGeniSlackBotClient["reactionMessageContext"]>
1346
1652
  >;
1347
1653
 
1348
- 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
+ ) {
1349
1715
  const reactedLine = slackReactionMessageLine(context.reactedMessage, true);
1350
1716
  const surroundingLines = context.messages
1351
1717
  .slice(0, MAX_SLACK_REACTION_CONTEXT_MESSAGES)
@@ -1356,7 +1722,8 @@ export function slackReactionTaskText(context: SlackReactionMessageContext) {
1356
1722
  let prompt = [
1357
1723
  "A linked, authorized Slack user explicitly summoned OpenGeni by reacting to one message.",
1358
1724
  "Use only the exact reacted message and bounded containing-thread context below.",
1359
- "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.",
1360
1727
  "Do not infer permission to ingest or persist this Slack content into Knowledge, Memory, preferences, policy, instructions, or the Workspace Charter.",
1361
1728
  "",
1362
1729
  "Exact reacted message:",
@@ -1373,9 +1740,171 @@ export function slackReactionTaskText(context: SlackReactionMessageContext) {
1373
1740
  }
1374
1741
  prompt = candidate;
1375
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
+ }
1376
1752
  return truncated ? `${prompt}\n${truncationNotice}` : prompt;
1377
1753
  }
1378
1754
 
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
+
1379
1908
  function slackReactionMessageLine(
1380
1909
  message: SlackReactionMessageContext["reactedMessage"],
1381
1910
  reacted: boolean,
@@ -1414,6 +1943,7 @@ async function continueSlackReactionSession(
1414
1943
  grant: AccessGrant,
1415
1944
  interaction: SlackInteraction,
1416
1945
  entry: SlackInteractionInboxEntry,
1946
+ resources: FileResourceRef[],
1417
1947
  ) {
1418
1948
  if (
1419
1949
  !interaction.sessionId ||
@@ -1422,7 +1952,7 @@ async function continueSlackReactionSession(
1422
1952
  throw new SlackInteractionPermanentError("session_owner_mismatch");
1423
1953
  }
1424
1954
  await reopenSlackInteractionDelivery(deps.db, interaction);
1425
- await acceptSlackReactionTask(deps, grant, interaction.sessionId, entry);
1955
+ await acceptSlackReactionTask(deps, grant, interaction.sessionId, entry, resources);
1426
1956
  }
1427
1957
 
1428
1958
  async function acceptSlackReactionTask(
@@ -1430,6 +1960,7 @@ async function acceptSlackReactionTask(
1430
1960
  grant: AccessGrant,
1431
1961
  sessionId: string,
1432
1962
  entry: SlackInteractionInboxEntry,
1963
+ resources: FileResourceRef[],
1433
1964
  ) {
1434
1965
  const clientEventId = `slack:${entry.providerEventId}`;
1435
1966
  const existing = await getSessionEventByClientEventId(
@@ -1447,6 +1978,7 @@ async function acceptSlackReactionTask(
1447
1978
  await acceptSessionUserMessage(deps, grant, entry.workspaceId, sessionId, {
1448
1979
  text: entry.text,
1449
1980
  turnInstructions: SLACK_TASK_INSTRUCTIONS,
1981
+ resources,
1450
1982
  clientEventId,
1451
1983
  });
1452
1984
  }
@@ -1456,6 +1988,7 @@ async function continueSlackSession(
1456
1988
  grant: AccessGrant,
1457
1989
  interaction: SlackInteraction,
1458
1990
  entry: SlackInteractionInboxEntry,
1991
+ resources: FileResourceRef[] = [],
1459
1992
  ) {
1460
1993
  if (
1461
1994
  !interaction.sessionId ||
@@ -1529,10 +2062,772 @@ async function continueSlackSession(
1529
2062
  await acceptSessionUserMessage(deps, grant, entry.workspaceId, interaction.sessionId, {
1530
2063
  text: entry.text,
1531
2064
  turnInstructions: SLACK_TASK_INSTRUCTIONS,
2065
+ resources,
1532
2066
  clientEventId: `slack:${entry.providerEventId}`,
1533
2067
  });
1534
2068
  }
1535
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
+
1536
2831
  async function deliverSlackSessionEvents(
1537
2832
  deps: ApiRouteDeps,
1538
2833
  interaction: SlackInteraction,
@@ -1556,9 +2851,10 @@ async function deliverSlackSessionEvents(
1556
2851
  accountId: interaction.accountId,
1557
2852
  workspaceId: interaction.workspaceId,
1558
2853
  connectionId: interaction.connectionId,
1559
- subjectId: interaction.owningSubjectId,
2854
+ subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
1560
2855
  sessionId: interaction.sessionId,
1561
2856
  });
2857
+ const requester = await validatedSlackRequester(deps, interaction);
1562
2858
  let lastSequence = interaction.lastDeliveredSessionEventSequence;
1563
2859
  let terminal: Exclude<SlackInteraction["terminalDeliveryState"], "open"> | null = null;
1564
2860
  let latestAssistantText = "";
@@ -1599,10 +2895,17 @@ async function deliverSlackSessionEvents(
1599
2895
  .find(Boolean) ||
1600
2896
  "";
1601
2897
  if (!terminalText) continue;
2898
+ let matchedTerminalSuffix = false;
1602
2899
  for (const candidate of candidates) {
1603
2900
  const assistantText = safePayloadText(candidate.payload, "text").trim();
1604
- if (assistantText === terminalText) {
2901
+ if (slackDeliveryTextsCoalesce(assistantText, terminalText)) {
1605
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;
1606
2909
  }
1607
2910
  }
1608
2911
  }
@@ -1643,35 +2946,65 @@ async function deliverSlackSessionEvents(
1643
2946
  );
1644
2947
  }
1645
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
+ );
1646
2966
  } else if (event.type === "session.humanInput.requested") {
1647
- const requests = await listSessionHumanInputRequests(
1648
- deps.db,
1649
- interaction.workspaceId,
1650
- interaction.sessionId,
1651
- { status: "pending", limit: 1 },
2967
+ const card = await slackHumanInputCard(
2968
+ deps,
2969
+ interaction,
2970
+ event,
2971
+ requester.mention,
2972
+ requester.authorized,
1652
2973
  );
1653
- const request = requests[0];
1654
- if (request) {
2974
+ if (card) {
1655
2975
  await postDelivery(
1656
2976
  client,
1657
2977
  interaction,
1658
2978
  event,
1659
- `OpenGeni needs your input:\n${formatQuestions(request.questions)}\nReply in this thread.`,
2979
+ card.text,
1660
2980
  "human-input",
2981
+ card.operationId,
2982
+ card.blocks,
1661
2983
  );
1662
2984
  }
1663
2985
  } else if (event.type === "turn.completed") {
1664
- const output = safePayloadText(event.payload, "output") || latestAssistantText;
2986
+ const payloadOutput = safePayloadText(event.payload, "output");
2987
+ const hasPublishableOutput = payloadOutput.trim().length > 0;
2988
+ const output = hasPublishableOutput ? payloadOutput : latestAssistantText;
1665
2989
  const normalizedOutput = output.trim();
2990
+ const recurringLink = requester.canSchedule
2991
+ ? `\n\n${makeRecurringText(deps, interaction.workspaceId, interaction.sessionId)}`
2992
+ : "";
1666
2993
  const existingProgress = progressEvidence.find(
1667
2994
  (delivery) =>
1668
- boundedOutput(delivery.text).trim() === normalizedOutput &&
1669
- (event.turnId
1670
- ? event.turnId === delivery.turnId
1671
- : delivery.sessionEventSequence === interaction.lastDeliveredSessionEventSequence ||
1672
- terminalAssistantSequences.has(delivery.sessionEventSequence)),
2995
+ slackDeliveryTextsCoalesce(boundedOutput(delivery.text).trim(), normalizedOutput) &&
2996
+ slackTerminalProgressSameTurn(event, delivery, interaction, terminalAssistantSequences),
1673
2997
  );
1674
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
+ : [];
1675
3008
  // The assistant text may already have been accepted by Slack before a
1676
3009
  // replica observed turn.completed. Reconcile the same provider
1677
3010
  // operation id (including response-loss retries) instead of inventing
@@ -1684,13 +3017,68 @@ async function deliverSlackSessionEvents(
1684
3017
  "progress",
1685
3018
  existingProgress.operationId,
1686
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
+ }
1687
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
+ : [];
1688
3072
  await postDelivery(
1689
3073
  client,
1690
3074
  interaction,
1691
3075
  event,
1692
- `${output || "OpenGeni finished this task."}\n\nReply in this thread to continue.`,
3076
+ text,
1693
3077
  "final",
3078
+ operationId,
3079
+ publicationBlocks.length > 0
3080
+ ? [{ type: "section", text: { type: "mrkdwn", text } }, ...publicationBlocks]
3081
+ : undefined,
1694
3082
  );
1695
3083
  }
1696
3084
  terminal = "completed";
@@ -1699,12 +3087,18 @@ async function deliverSlackSessionEvents(
1699
3087
  client,
1700
3088
  interaction,
1701
3089
  event,
1702
- "OpenGeni could not complete this task. Reply in this thread to retry or ask for details.",
3090
+ `${requester.mention}OpenGeni could not complete this task. Reply in this thread to retry or ask for details.`,
1703
3091
  "failed",
1704
3092
  );
1705
3093
  terminal = "failed";
1706
3094
  } else if (event.type === "turn.cancelled") {
1707
- 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
+ );
1708
3102
  terminal = "cancelled";
1709
3103
  }
1710
3104
  }
@@ -1728,6 +3122,34 @@ async function deliverSlackSessionEvents(
1728
3122
  }
1729
3123
  }
1730
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
+
1731
3153
  async function postDelivery(
1732
3154
  client: Awaited<ReturnType<typeof createOpenGeniSlackBotInteractionClient>>,
1733
3155
  interaction: SlackInteraction,
@@ -1735,12 +3157,14 @@ async function postDelivery(
1735
3157
  text: string,
1736
3158
  kind: string,
1737
3159
  operationId = deterministicUuid(`slack-delivery:${interaction.id}:${event.sequence}:${kind}`),
3160
+ blocks?: SlackMessageBlock[],
1738
3161
  ) {
1739
3162
  await client.postMessage({
1740
3163
  operationId,
1741
3164
  channelId: interaction.slackChannelId,
1742
3165
  threadTimestamp: interaction.slackThreadTs,
1743
3166
  text: boundedOutput(text),
3167
+ ...(blocks ? { blocks } : {}),
1744
3168
  });
1745
3169
  }
1746
3170
 
@@ -1807,6 +3231,67 @@ function normalizedFormInteraction(
1807
3231
  slackThreadTs: null,
1808
3232
  triggerKind,
1809
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,
1810
3295
  };
1811
3296
  }
1812
3297
 
@@ -1845,9 +3330,17 @@ function formatQuestions(questions: HumanInputQuestion[]) {
1845
3330
 
1846
3331
  function openSessionText(deps: ApiRouteDeps, workspaceId: string, sessionId: string) {
1847
3332
  const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
1848
- return base
1849
- ? `Open in OpenGeni: ${new URL(`/workspaces/${workspaceId}/sessions/${sessionId}`, base).toString()}`
1850
- : "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>`;
1851
3344
  }
1852
3345
 
1853
3346
  function linkUrl(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
@@ -1975,6 +3468,16 @@ function isDirectMessageShortcut(
1975
3468
  return entry.triggerKind === "message_shortcut" && entry.slackChannelId.startsWith("D");
1976
3469
  }
1977
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
+
1978
3481
  function deterministicUuid(value: string) {
1979
3482
  const bytes = createHash("sha256").update(value).digest().subarray(0, 16);
1980
3483
  bytes[6] = (bytes[6]! & 0x0f) | 0x50;
@@ -2019,6 +3522,13 @@ function boundedOutput(value: string) {
2019
3522
  : `${value.slice(0, MAX_SLACK_TEXT_CHARS - 20)}\n… output truncated`;
2020
3523
  }
2021
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
+
2022
3532
  function safePayloadText(payload: unknown, field: string) {
2023
3533
  const value = record(payload)?.[field];
2024
3534
  return typeof value === "string" ? boundedOutput(value) : "";