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