@opengeni/api-router 0.30.3 → 2.1.0-canary.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/api-websocket.d.ts +18 -0
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth.d.ts +6 -0
- package/dist/{chunk-WCXHI3FF.js → chunk-QKDFBBUE.js} +26385 -15573
- package/dist/chunk-QKDFBBUE.js.map +1 -0
- package/dist/codemode.d.ts +8 -1
- package/dist/company-brain-okf.d.ts +24 -0
- package/dist/connected-machine-computer-access.d.ts +11 -0
- package/dist/connection-authority-owner.d.ts +59 -0
- package/dist/connection-ownership.d.ts +47 -0
- package/dist/controller-data-plane.d.ts +16 -0
- package/dist/editable-artifact-websocket.d.ts +5 -15
- package/dist/editable-artifact-workspace-files.d.ts +1 -1
- package/dist/http/api-error.d.ts +10 -0
- package/dist/http/cors.d.ts +5 -0
- package/dist/http/interaction-control-error.d.ts +13 -0
- package/dist/http/sse.d.ts +2 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +326 -82
- package/dist/index.js.map +1 -1
- package/dist/integrations/atlassian.d.ts +17 -0
- package/dist/integrations/fiken.d.ts +7 -2
- package/dist/integrations/google-drive.d.ts +18 -0
- package/dist/integrations/oauth-client.d.ts +22 -3
- package/dist/integrations/oauth-profiles.d.ts +195 -0
- package/dist/integrations/personal-github-repositories.d.ts +45 -0
- package/dist/integrations/personal-github.d.ts +40 -0
- package/dist/integrations/pr-review-provider.d.ts +22 -0
- package/dist/integrations/provider-oauth.d.ts +8 -0
- package/dist/integrations/slack-app-home.d.ts +24 -0
- package/dist/integrations/slack-bot.d.ts +149 -9
- package/dist/integrations/slack-interactions.d.ts +4 -3
- package/dist/integrations/slack-routing.d.ts +120 -0
- package/dist/integrations/social-oauth.d.ts +5 -0
- package/dist/interaction-frame-proxy.d.ts +68 -0
- package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
- package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
- package/dist/mcp/documents.d.ts +1 -0
- package/dist/mcp/remember.d.ts +28 -0
- package/dist/mcp/request-abort.d.ts +19 -0
- package/dist/mcp/scheduled-task-view.d.ts +4 -4
- package/dist/mcp/server.d.ts +15 -2
- package/dist/mcp/session-view.d.ts +4 -0
- package/dist/mcp/session-wait.d.ts +137 -0
- package/dist/routes/automations.d.ts +4 -0
- package/dist/routes/billing.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +4 -1
- package/dist/routes/company-brain.d.ts +3 -0
- package/dist/routes/company-profile.d.ts +2 -1
- package/dist/routes/connection-authorities.d.ts +3 -0
- package/dist/routes/personal-github-git-broker.d.ts +29 -0
- package/dist/routes/personal-github.d.ts +3 -0
- package/dist/routes/pr-review.d.ts +3 -0
- package/dist/routes/sessions.d.ts +1 -0
- package/dist/routes/user-resource-authorities.d.ts +3 -0
- package/dist/routes/workspace-capture.d.ts +10 -2
- package/dist/routes/workspace-learning.d.ts +3 -0
- package/dist/routes/workspaces.d.ts +19 -0
- package/dist/sandbox/channel-a.d.ts +11 -1
- package/dist/sandbox/connection-authority.d.ts +3 -0
- package/dist/sandbox/enrollment.d.ts +2 -0
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/metrics-ingestion.d.ts +10 -20
- package/dist/sandbox/viewer.d.ts +24 -1
- package/dist/sandbox-file-artifacts.d.ts +11 -0
- package/dist/scheduled-task-deletion.d.ts +14 -0
- package/dist/slack-reaction-files.d.ts +1 -1
- package/dist/temporal-schedule-cleanup.d.ts +1 -0
- package/package.json +20 -18
- package/src/api-websocket.ts +23 -0
- package/src/app.ts +153 -23
- package/src/auth/managed-auth.ts +25 -3
- package/src/codemode.ts +53 -23
- package/src/codex-realtime.ts +8 -2
- package/src/company-brain-okf.ts +340 -0
- package/src/connected-machine-computer-access.ts +33 -0
- package/src/connection-authority-owner.ts +61 -0
- package/src/connection-ownership.ts +180 -0
- package/src/controller-data-plane.ts +47 -0
- package/src/editable-artifact-native-kernel.ts +26 -15
- package/src/editable-artifact-office-import.ts +28 -3
- package/src/editable-artifact-production.ts +13 -7
- package/src/editable-artifact-websocket.ts +11 -18
- package/src/editable-artifact-workspace-files.ts +31 -6
- package/src/http/api-error.ts +28 -0
- package/src/http/auth.ts +4 -0
- package/src/http/cors.ts +35 -0
- package/src/http/interaction-control-error.ts +164 -0
- package/src/http/sse.ts +112 -66
- package/src/index.ts +46 -6
- package/src/integrations/atlassian.ts +146 -35
- package/src/integrations/fiken.ts +102 -22
- package/src/integrations/google-drive.ts +301 -92
- package/src/integrations/oauth-client.ts +272 -143
- package/src/integrations/oauth-profiles.ts +477 -0
- package/src/integrations/personal-github-repositories.ts +445 -0
- package/src/integrations/personal-github.ts +705 -0
- package/src/integrations/pr-review-provider.ts +246 -0
- package/src/integrations/provider-oauth.ts +121 -5
- package/src/integrations/slack-app-home.ts +300 -0
- package/src/integrations/slack-bot.ts +337 -57
- package/src/integrations/slack-interactions.ts +1265 -208
- package/src/integrations/slack-routing.ts +324 -0
- package/src/integrations/social-oauth.ts +25 -0
- package/src/interaction-frame-proxy.ts +409 -0
- package/src/mcp/company-brain-governed-writes.ts +262 -0
- package/src/mcp/company-profile-agent-admin.ts +205 -0
- package/src/mcp/documents.ts +37 -6
- package/src/mcp/files.ts +16 -1
- package/src/mcp/remember.ts +181 -0
- package/src/mcp/request-abort.ts +44 -0
- package/src/mcp/scheduled-task-view.ts +1 -0
- package/src/mcp/server.ts +719 -369
- package/src/mcp/session-view.ts +54 -0
- package/src/mcp/session-wait.ts +554 -0
- package/src/model-catalog.ts +20 -12
- package/src/routes/api-integrations.ts +4 -0
- package/src/routes/api-keys.ts +1 -0
- package/src/routes/automations.ts +534 -0
- package/src/routes/billing.ts +57 -1
- package/src/routes/browser-sessions.ts +329 -79
- package/src/routes/channels.ts +17 -1
- package/src/routes/codex.ts +50 -0
- package/src/routes/company-brain.ts +367 -0
- package/src/routes/company-profile.ts +1 -1
- package/src/routes/computer-sessions.ts +320 -93
- package/src/routes/connection-authorities.ts +139 -0
- package/src/routes/connections.ts +144 -34
- package/src/routes/documents.ts +221 -3
- package/src/routes/editable-artifacts.ts +11 -3
- package/src/routes/enrollments.ts +116 -26
- package/src/routes/environments.ts +127 -55
- package/src/routes/files.ts +72 -11
- package/src/routes/install.ts +82 -28
- package/src/routes/integration-facets.ts +29 -0
- package/src/routes/interaction-resources.ts +20 -2
- package/src/routes/machines.ts +275 -13
- package/src/routes/organization-memberships.ts +717 -28
- package/src/routes/packs.ts +4 -3
- package/src/routes/personal-github-git-broker.ts +785 -0
- package/src/routes/personal-github.ts +319 -0
- package/src/routes/pr-review.ts +531 -0
- package/src/routes/rigs.ts +104 -39
- package/src/routes/scheduled-tasks.ts +19 -21
- package/src/routes/sessions.ts +775 -38
- package/src/routes/social.ts +14 -2
- package/src/routes/supergrok.ts +23 -4
- package/src/routes/transcription-recordings.ts +8 -2
- package/src/routes/user-resource-authorities.ts +138 -0
- package/src/routes/workspace-artifacts.ts +4 -1
- package/src/routes/workspace-capture.ts +21 -0
- package/src/routes/workspace-learning.ts +228 -0
- package/src/routes/workspaces.ts +59 -6
- package/src/sandbox/auth-callout.ts +38 -12
- package/src/sandbox/channel-a.ts +242 -19
- package/src/sandbox/connection-authority.ts +3 -0
- package/src/sandbox/enrollment.ts +15 -3
- package/src/sandbox/machines.ts +186 -64
- package/src/sandbox/metrics-ingestion.ts +220 -58
- package/src/sandbox/viewer.ts +243 -19
- package/src/sandbox-file-artifacts.ts +329 -0
- package/src/scheduled-task-deletion.ts +127 -0
- package/src/slack-reaction-files.ts +16 -5
- package/src/temporal-schedule-cleanup.ts +13 -0
- package/dist/chunk-WCXHI3FF.js.map +0 -1
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
ListSlackUserLinkAccessRequestsResponse,
|
|
8
8
|
PrepareSlackUserLinkAccessRequest,
|
|
9
9
|
evaluateSlackTaskPolicy,
|
|
10
|
+
resolveWorkspaceSlackOrchestrationNoticeSettings,
|
|
10
11
|
resolveWorkspaceSlackReactionSummonSettings,
|
|
11
12
|
SlackReactionChannelListResponse,
|
|
12
13
|
SlackUserLinkAccessMutationRequest,
|
|
@@ -15,6 +16,9 @@ import {
|
|
|
15
16
|
type FileResourceRef,
|
|
16
17
|
type FirstPartyMcpToolName,
|
|
17
18
|
type HumanInputQuestion,
|
|
19
|
+
type ResolvedWorkspaceSlackOrchestrationNoticeSettings,
|
|
20
|
+
type ChildRequiresActionPayload,
|
|
21
|
+
type SessionAuthorizationListScope,
|
|
18
22
|
type SessionEvent,
|
|
19
23
|
type WorkspaceSlackReactionSummonSettings,
|
|
20
24
|
workspaceSlackReactionChannelAllowed,
|
|
@@ -31,13 +35,16 @@ import {
|
|
|
31
35
|
advanceSlackInteractionDelivery,
|
|
32
36
|
bindSlackInteractionSession,
|
|
33
37
|
cancelSlackUserLinkAccessRequest,
|
|
38
|
+
claimSlackAppHomeRefresh,
|
|
34
39
|
claimSlackInteractionDelivery,
|
|
35
40
|
claimSlackInteractionProgressDelivery,
|
|
36
41
|
claimSlackInteractionInbox,
|
|
37
42
|
closeSlackInteractionDelivery,
|
|
38
43
|
completeSlackUserLinkAccessIfGranted,
|
|
44
|
+
decodeSessionListCursor,
|
|
39
45
|
deferSlackInteractionDelivery,
|
|
40
46
|
deleteSlackBotUserLink,
|
|
47
|
+
enqueueSlackAppHomeRefresh,
|
|
41
48
|
enqueueSlackInteractionInbox,
|
|
42
49
|
getConnectionMetadata,
|
|
43
50
|
getOrCreateSlackInteraction,
|
|
@@ -45,17 +52,24 @@ import {
|
|
|
45
52
|
getSession,
|
|
46
53
|
getSessionEvent,
|
|
47
54
|
getSessionHumanInputRequest,
|
|
55
|
+
childRequiresActionResolutionExists,
|
|
56
|
+
getSessionSystemUpdateById,
|
|
48
57
|
getSlackBotPostOperation,
|
|
49
58
|
getSlackBotUserLink,
|
|
50
59
|
getSlackInteractionActionHandle,
|
|
51
60
|
getSlackInteractionByClientEventId,
|
|
52
61
|
getSlackInteractionById,
|
|
53
|
-
|
|
62
|
+
getSlackInteractionByConnectionRoute,
|
|
63
|
+
probeSlackActionHandleTenancy,
|
|
64
|
+
getSlackChannelRoute,
|
|
65
|
+
getSlackUserDmRoute,
|
|
66
|
+
listNamedSubjectSlackRoutableWorkspaces,
|
|
54
67
|
getActiveSlackTaskPolicy,
|
|
55
68
|
getSlackSharedTaskOrigin,
|
|
56
69
|
getSessionEventByClientEventId,
|
|
57
70
|
getWorkspace,
|
|
58
71
|
getWorkspaceGrant,
|
|
72
|
+
resolveSlackTargetAuthority,
|
|
59
73
|
listSlackInteractionProgressDeliveryEvidence,
|
|
60
74
|
listSessionEventPage,
|
|
61
75
|
listSessionHumanInputRequests,
|
|
@@ -63,18 +77,25 @@ import {
|
|
|
63
77
|
rekeySlackInteractionRoute,
|
|
64
78
|
reopenSlackInteractionDelivery,
|
|
65
79
|
reserveSlackInteractionActionHandles,
|
|
80
|
+
renewSlackAppHomeRefreshClaim,
|
|
66
81
|
releaseSlackInteractionDelivery,
|
|
82
|
+
releaseSlackAppHomeRefresh,
|
|
67
83
|
releaseSlackInteractionInbox,
|
|
68
84
|
requestSlackUserLinkWorkspaceAccess,
|
|
69
85
|
resolveSlackInstallationRoute,
|
|
86
|
+
resolveSlackInteractionFirstTaskHint,
|
|
70
87
|
saveSlackInteractionInboxReactionCheckpoint,
|
|
71
88
|
saveSlackSharedTaskOrigin,
|
|
72
89
|
settleSlackInteractionInbox,
|
|
90
|
+
settleSlackAppHomeRefresh,
|
|
73
91
|
settleSlackInteractionActionHandles,
|
|
92
|
+
listSessionsForSubject,
|
|
93
|
+
SessionListAccessError,
|
|
74
94
|
denySlackUserLinkAccessRequest,
|
|
75
95
|
prepareSlackUserLinkAccessRequest,
|
|
76
96
|
SlackUserLinkAccessPersistenceError,
|
|
77
97
|
type SlackInstallationRoute,
|
|
98
|
+
type SlackAppHomeRefresh,
|
|
78
99
|
type SlackInteraction,
|
|
79
100
|
type SlackInteractionActionHandle,
|
|
80
101
|
type SlackInteractionActionKind,
|
|
@@ -88,6 +109,7 @@ import {
|
|
|
88
109
|
hasPermission,
|
|
89
110
|
requireAccessContext,
|
|
90
111
|
requireAccessGrant,
|
|
112
|
+
requireSessionAuthorizationListScope,
|
|
91
113
|
type ApiRouteDeps,
|
|
92
114
|
} from "@opengeni/core";
|
|
93
115
|
import { publishDurableSessionEvents } from "@opengeni/events";
|
|
@@ -100,6 +122,20 @@ import {
|
|
|
100
122
|
type SlackMessageBlock,
|
|
101
123
|
SlackBotProviderError,
|
|
102
124
|
} from "./slack-bot";
|
|
125
|
+
import {
|
|
126
|
+
isSlackDirectMessageConversation,
|
|
127
|
+
resolveSlackWorkspaceRoute,
|
|
128
|
+
slackRoutedRequestText,
|
|
129
|
+
type SlackRouteResolution,
|
|
130
|
+
type SlackRouteTenancy,
|
|
131
|
+
} from "./slack-routing";
|
|
132
|
+
import {
|
|
133
|
+
buildSlackAppHomeAccessBlocks,
|
|
134
|
+
buildSlackAppHomeBlocks,
|
|
135
|
+
escapeSlackMrkdwn,
|
|
136
|
+
isSlackAppHomeLinkAction,
|
|
137
|
+
slackAppHomeOpenedEvent,
|
|
138
|
+
} from "./slack-app-home";
|
|
103
139
|
import { importSlackReactionImage, type ImportedSlackReactionImage } from "../slack-reaction-files";
|
|
104
140
|
|
|
105
141
|
export const SLACK_INTERACTION_MAX_BODY_BYTES = 256 * 1024;
|
|
@@ -112,6 +148,13 @@ export const SLACK_DELIVERY_EVENT_TYPES = [
|
|
|
112
148
|
"turn.failed",
|
|
113
149
|
"turn.cancelled",
|
|
114
150
|
"session.status.changed",
|
|
151
|
+
// Orchestration surfacing. Both are filtered again inside the pump: the
|
|
152
|
+
// workspace must have opted in (both notices default off), and then only a
|
|
153
|
+
// `child_requires_action` notice and a `limits` / `max_auto_continuations`
|
|
154
|
+
// goal pause reach Slack, so the thread stays quiet for the deferred child
|
|
155
|
+
// lifecycle kinds and for human/API/agent pauses the human already made.
|
|
156
|
+
"system.update.pending",
|
|
157
|
+
"goal.paused",
|
|
115
158
|
] as const;
|
|
116
159
|
|
|
117
160
|
const MAX_SLACK_TEXT_CHARS = 3_500;
|
|
@@ -125,6 +168,7 @@ const MAX_SLACK_REACTION_IMAGE_AGGREGATE_BYTES = 16 * 1024 * 1024;
|
|
|
125
168
|
const MAX_PROGRESS_MESSAGES = 3;
|
|
126
169
|
const SLACK_USER_LINK_TTL_MS = 15 * 60_000;
|
|
127
170
|
const INBOX_LEASE_MS = 30_000;
|
|
171
|
+
const APP_HOME_LEASE_MS = 300_000;
|
|
128
172
|
const DELIVERY_LEASE_MS = 30_000;
|
|
129
173
|
const MAX_DELIVERY_ATTEMPTS = 8;
|
|
130
174
|
const MAX_DELIVERY_RETRY_MS = 5 * 60_000;
|
|
@@ -132,6 +176,23 @@ const SLACK_INTERACTION_BOT_SUBJECT_ID = "service:slack-interaction";
|
|
|
132
176
|
const SLACK_ACTION_TTL_MS = 7 * 24 * 60 * 60_000;
|
|
133
177
|
const MAX_SLACK_APPROVALS_PER_CARD = 8;
|
|
134
178
|
const MAX_SLACK_ACTIONS_PER_CARD = 20;
|
|
179
|
+
/** Blocked-worker cards are pointers, so the question preview stays short. */
|
|
180
|
+
const MAX_SLACK_CHILD_DETAIL_CHARS = 240;
|
|
181
|
+
const SLACK_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
182
|
+
/**
|
|
183
|
+
* Goal pause reasons the Slack thread announces. `user_pause`, `api`, `agent`,
|
|
184
|
+
* and `no_progress` are deliberately absent.
|
|
185
|
+
*
|
|
186
|
+
* A Map, not a plain object: this lookup is the sole gate of a two-reason
|
|
187
|
+
* invariant, and a plain object indexed by a payload-derived string answers
|
|
188
|
+
* `constructor` (and every other prototype key) with a truthy value that would
|
|
189
|
+
* sail past the `if (!headline)` guard. No payload reaches it with such a
|
|
190
|
+
* reason today; the gate should not depend on that staying true.
|
|
191
|
+
*/
|
|
192
|
+
const SLACK_GOAL_PAUSED_HEADLINES = new Map<string, string>([
|
|
193
|
+
["limits", "Goal paused (budget)"],
|
|
194
|
+
["max_auto_continuations", "Goal paused (continuation cap)"],
|
|
195
|
+
]);
|
|
135
196
|
/**
|
|
136
197
|
* Slack delivery restrictions are durable session-level authority, not
|
|
137
198
|
* attacker-adjacent user-message context. Migration 0240 backfills this exact
|
|
@@ -357,6 +418,19 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
357
418
|
message: "Slack installation unavailable",
|
|
358
419
|
});
|
|
359
420
|
const event = record(payload.event);
|
|
421
|
+
const appHomeEvent = slackAppHomeOpenedEvent(payload);
|
|
422
|
+
if (appHomeEvent) {
|
|
423
|
+
await enqueueSlackAppHomeRefresh(deps.db, {
|
|
424
|
+
accountId: installation.accountId,
|
|
425
|
+
workspaceId: installation.workspaceId,
|
|
426
|
+
connectionId: installation.connectionId,
|
|
427
|
+
slackTeamId: appHomeEvent.slackTeamId,
|
|
428
|
+
slackUserId: appHomeEvent.slackUserId,
|
|
429
|
+
providerEventId: appHomeEvent.eventId,
|
|
430
|
+
providerViewHash: appHomeEvent.viewHash,
|
|
431
|
+
});
|
|
432
|
+
return c.json({ ok: true });
|
|
433
|
+
}
|
|
360
434
|
if (event?.type === "reaction_added") {
|
|
361
435
|
const [workspace, connection] = await Promise.all([
|
|
362
436
|
getWorkspace(deps.db, installation.workspaceId),
|
|
@@ -383,7 +457,7 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
383
457
|
app.post("/v1/integrations/slack/commands", async (c) => {
|
|
384
458
|
const signed = await readSignedSlackRequest(c, deps);
|
|
385
459
|
const form = new URLSearchParams(signed.rawBody);
|
|
386
|
-
if (form.get("command") !==
|
|
460
|
+
if (form.get("command") !== deps.settings.slackCommand) {
|
|
387
461
|
throw new HTTPException(400, { message: "invalid Slack command" });
|
|
388
462
|
}
|
|
389
463
|
const entry = normalizedFormInteraction(form, "slash_command");
|
|
@@ -392,6 +466,12 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
392
466
|
throw new HTTPException(403, {
|
|
393
467
|
message: "Slack installation unavailable",
|
|
394
468
|
});
|
|
469
|
+
// `<command> info` is a read-only ephemeral explainer. It never reaches the
|
|
470
|
+
// durable inbox, never verifies channel membership, and never creates a
|
|
471
|
+
// session, so it is safe anywhere the command is available.
|
|
472
|
+
if (isSlackInfoCommand(entry.text)) {
|
|
473
|
+
return c.json(await slackInfoCommandResponse(deps, installation, entry));
|
|
474
|
+
}
|
|
395
475
|
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
396
476
|
accountId: installation.accountId,
|
|
397
477
|
workspaceId: installation.workspaceId,
|
|
@@ -424,6 +504,7 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
424
504
|
const form = new URLSearchParams(signed.rawBody);
|
|
425
505
|
const payload = parseJsonObject(form.get("payload") ?? "");
|
|
426
506
|
if (payload.type === "block_actions") {
|
|
507
|
+
if (isSlackAppHomeLinkAction(payload)) return c.json({ ok: true });
|
|
427
508
|
const entry = normalizedBlockActionInteraction(payload);
|
|
428
509
|
const installation = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
|
|
429
510
|
if (!installation) {
|
|
@@ -783,7 +864,285 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
783
864
|
});
|
|
784
865
|
}
|
|
785
866
|
|
|
867
|
+
async function publishSlackAppHome(
|
|
868
|
+
deps: ApiRouteDeps,
|
|
869
|
+
installation: SlackInstallationRoute,
|
|
870
|
+
refresh: SlackAppHomeRefresh,
|
|
871
|
+
renewLease: () => Promise<void>,
|
|
872
|
+
): Promise<void> {
|
|
873
|
+
const link = await getSlackBotUserLink(
|
|
874
|
+
deps.db,
|
|
875
|
+
installation.workspaceId,
|
|
876
|
+
installation.connectionId,
|
|
877
|
+
refresh.slackUserId,
|
|
878
|
+
);
|
|
879
|
+
const grant = link
|
|
880
|
+
? await getWorkspaceGrant(deps.db, link.subjectId, installation.workspaceId, {
|
|
881
|
+
principalKind: "human_session",
|
|
882
|
+
})
|
|
883
|
+
: null;
|
|
884
|
+
if (
|
|
885
|
+
!grant ||
|
|
886
|
+
grant.accountId !== installation.accountId ||
|
|
887
|
+
!hasPermission(grant.permissions, "sessions:read")
|
|
888
|
+
) {
|
|
889
|
+
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
890
|
+
accountId: installation.accountId,
|
|
891
|
+
workspaceId: installation.workspaceId,
|
|
892
|
+
connectionId: installation.connectionId,
|
|
893
|
+
subjectId: "service:slack-app-home",
|
|
894
|
+
});
|
|
895
|
+
await publishSlackAppHomeAccessView(
|
|
896
|
+
client,
|
|
897
|
+
refresh,
|
|
898
|
+
renewLease,
|
|
899
|
+
buildSlackAppHomeAccessBlocks({
|
|
900
|
+
title: link ? "OpenGeni access needed" : "Connect your OpenGeni account",
|
|
901
|
+
message: link
|
|
902
|
+
? "Your Slack identity is linked, but it does not currently have access to this OpenGeni workspace."
|
|
903
|
+
: "Link this Slack identity to see your active tasks, requests, and recent results here.",
|
|
904
|
+
actionLabel: link ? "Request access" : "Connect OpenGeni",
|
|
905
|
+
actionUrl: slackAppHomeLinkUrl(
|
|
906
|
+
deps,
|
|
907
|
+
installation,
|
|
908
|
+
refresh.slackTeamId,
|
|
909
|
+
refresh.slackUserId,
|
|
910
|
+
),
|
|
911
|
+
}),
|
|
912
|
+
);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
917
|
+
accountId: installation.accountId,
|
|
918
|
+
workspaceId: installation.workspaceId,
|
|
919
|
+
connectionId: installation.connectionId,
|
|
920
|
+
subjectId: grant.subjectId,
|
|
921
|
+
});
|
|
922
|
+
// Host session authorization is resolved at publication time, before the
|
|
923
|
+
// database query, so App Home cannot leak rows through a stale broad list.
|
|
924
|
+
const authorizationScope = await requireSessionAuthorizationListScope(deps, grant, "core");
|
|
925
|
+
const sessions: Awaited<ReturnType<typeof listSessionsForSubject>>["sessions"] = [];
|
|
926
|
+
try {
|
|
927
|
+
let cursor: ReturnType<typeof decodeSessionListCursor> | undefined;
|
|
928
|
+
do {
|
|
929
|
+
await renewLease();
|
|
930
|
+
const page = await listSessionsForSubject(deps.db, installation.workspaceId, {
|
|
931
|
+
subjectId: grant.subjectId,
|
|
932
|
+
limit: 500,
|
|
933
|
+
...(cursor ? { cursor } : {}),
|
|
934
|
+
...(authorizationScope ? { authorizationScope } : {}),
|
|
935
|
+
});
|
|
936
|
+
if (!cursor) sessions.push(...page.pinned);
|
|
937
|
+
sessions.push(...page.sessions);
|
|
938
|
+
if (!page.nextCursor) break;
|
|
939
|
+
cursor = decodeSessionListCursor(page.nextCursor) ?? undefined;
|
|
940
|
+
if (!cursor) throw new Error("Slack App Home session cursor was invalid");
|
|
941
|
+
} while (cursor);
|
|
942
|
+
} catch (error) {
|
|
943
|
+
if (!(error instanceof SessionListAccessError)) throw error;
|
|
944
|
+
await publishSlackAppHomeAccessView(
|
|
945
|
+
client,
|
|
946
|
+
refresh,
|
|
947
|
+
renewLease,
|
|
948
|
+
buildSlackAppHomeAccessBlocks({
|
|
949
|
+
title: "OpenGeni access changed",
|
|
950
|
+
message:
|
|
951
|
+
"Your current OpenGeni access could not be verified. Reconnect before tasks are shown here.",
|
|
952
|
+
actionLabel: "Reconnect OpenGeni",
|
|
953
|
+
actionUrl: slackAppHomeLinkUrl(
|
|
954
|
+
deps,
|
|
955
|
+
installation,
|
|
956
|
+
refresh.slackTeamId,
|
|
957
|
+
refresh.slackUserId,
|
|
958
|
+
),
|
|
959
|
+
}),
|
|
960
|
+
);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const currentLink = await getSlackBotUserLink(
|
|
964
|
+
deps.db,
|
|
965
|
+
installation.workspaceId,
|
|
966
|
+
installation.connectionId,
|
|
967
|
+
refresh.slackUserId,
|
|
968
|
+
);
|
|
969
|
+
const currentGrant = currentLink
|
|
970
|
+
? await getWorkspaceGrant(deps.db, currentLink.subjectId, installation.workspaceId, {
|
|
971
|
+
principalKind: "human_session",
|
|
972
|
+
})
|
|
973
|
+
: null;
|
|
974
|
+
if (
|
|
975
|
+
!currentLink ||
|
|
976
|
+
currentLink.subjectId !== grant.subjectId ||
|
|
977
|
+
!currentGrant ||
|
|
978
|
+
currentGrant.accountId !== installation.accountId ||
|
|
979
|
+
!hasPermission(currentGrant.permissions, "sessions:read")
|
|
980
|
+
) {
|
|
981
|
+
await publishSlackAppHomeAccessView(
|
|
982
|
+
client,
|
|
983
|
+
refresh,
|
|
984
|
+
renewLease,
|
|
985
|
+
buildSlackAppHomeAccessBlocks({
|
|
986
|
+
title: "OpenGeni access changed",
|
|
987
|
+
message:
|
|
988
|
+
"Your current OpenGeni access could not be verified. Reconnect before tasks are shown here.",
|
|
989
|
+
actionLabel: "Reconnect OpenGeni",
|
|
990
|
+
actionUrl: slackAppHomeLinkUrl(
|
|
991
|
+
deps,
|
|
992
|
+
installation,
|
|
993
|
+
refresh.slackTeamId,
|
|
994
|
+
refresh.slackUserId,
|
|
995
|
+
),
|
|
996
|
+
}),
|
|
997
|
+
);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const currentAuthorizationScope = await requireSessionAuthorizationListScope(
|
|
1001
|
+
deps,
|
|
1002
|
+
currentGrant,
|
|
1003
|
+
"core",
|
|
1004
|
+
);
|
|
1005
|
+
if (
|
|
1006
|
+
slackSessionAuthorizationScopeKey(currentAuthorizationScope) !==
|
|
1007
|
+
slackSessionAuthorizationScopeKey(authorizationScope)
|
|
1008
|
+
) {
|
|
1009
|
+
await publishSlackAppHomeAccessView(
|
|
1010
|
+
client,
|
|
1011
|
+
refresh,
|
|
1012
|
+
renewLease,
|
|
1013
|
+
buildSlackAppHomeAccessBlocks({
|
|
1014
|
+
title: "OpenGeni access changed",
|
|
1015
|
+
message:
|
|
1016
|
+
"Your current task access changed while this view was loading. Reopen Home to refresh it safely.",
|
|
1017
|
+
actionLabel: "Open OpenGeni",
|
|
1018
|
+
actionUrl: slackWorkspaceUrl(deps, installation.workspaceId),
|
|
1019
|
+
}),
|
|
1020
|
+
);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (!refresh.providerViewHash) {
|
|
1024
|
+
await publishSlackAppHomeAccessView(
|
|
1025
|
+
client,
|
|
1026
|
+
refresh,
|
|
1027
|
+
renewLease,
|
|
1028
|
+
buildSlackAppHomeAccessBlocks({
|
|
1029
|
+
title: "Refresh OpenGeni Home",
|
|
1030
|
+
message:
|
|
1031
|
+
"Reopen Home to refresh your tasks safely. OpenGeni does not publish task data without Slack's current view version.",
|
|
1032
|
+
actionLabel: "Open OpenGeni",
|
|
1033
|
+
actionUrl: slackWorkspaceUrl(deps, installation.workspaceId),
|
|
1034
|
+
}),
|
|
1035
|
+
);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
await renewLease();
|
|
1039
|
+
await client.publishHomeView({
|
|
1040
|
+
userId: refresh.slackUserId,
|
|
1041
|
+
hash: refresh.providerViewHash,
|
|
1042
|
+
blocks: buildSlackAppHomeBlocks({
|
|
1043
|
+
sessions,
|
|
1044
|
+
workspaceUrl: slackWorkspaceUrl(deps, installation.workspaceId),
|
|
1045
|
+
sessionUrl: (sessionId) => slackSessionUrl(deps, installation.workspaceId, sessionId),
|
|
1046
|
+
}),
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function publishSlackAppHomeAccessView(
|
|
1051
|
+
client: OpenGeniSlackBotClient,
|
|
1052
|
+
refresh: SlackAppHomeRefresh,
|
|
1053
|
+
renewLease: () => Promise<void>,
|
|
1054
|
+
blocks: ReturnType<typeof buildSlackAppHomeAccessBlocks>,
|
|
1055
|
+
): Promise<void> {
|
|
1056
|
+
try {
|
|
1057
|
+
await renewLease();
|
|
1058
|
+
await client.publishHomeView({
|
|
1059
|
+
userId: refresh.slackUserId,
|
|
1060
|
+
hash: refresh.providerViewHash,
|
|
1061
|
+
blocks,
|
|
1062
|
+
});
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
if (!(error instanceof SlackBotProviderError) || error.code !== "hash_conflict") throw error;
|
|
1065
|
+
// Access views contain no task data. If an older authorized publication
|
|
1066
|
+
// advanced Slack's optimistic-concurrency hash, replace it fail-closed
|
|
1067
|
+
// rather than dropping the newer clearing obligation.
|
|
1068
|
+
await renewLease();
|
|
1069
|
+
await client.publishHomeView({
|
|
1070
|
+
userId: refresh.slackUserId,
|
|
1071
|
+
blocks,
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
786
1076
|
export async function drainSlackInteractionsOnce(deps: ApiRouteDeps): Promise<boolean> {
|
|
1077
|
+
const appHomeHolder = crypto.randomUUID();
|
|
1078
|
+
const refresh = await claimSlackAppHomeRefresh(deps.db, appHomeHolder, APP_HOME_LEASE_MS);
|
|
1079
|
+
if (refresh) {
|
|
1080
|
+
try {
|
|
1081
|
+
const installation = await resolveSlackInstallationRoute(deps.db, refresh.slackTeamId);
|
|
1082
|
+
if (
|
|
1083
|
+
!installation ||
|
|
1084
|
+
installation.accountId !== refresh.accountId ||
|
|
1085
|
+
installation.workspaceId !== refresh.workspaceId ||
|
|
1086
|
+
installation.connectionId !== refresh.connectionId
|
|
1087
|
+
) {
|
|
1088
|
+
throw new SlackInteractionPermanentError("slack_app_home_installation_changed");
|
|
1089
|
+
}
|
|
1090
|
+
const renewLease = async () => {
|
|
1091
|
+
if (
|
|
1092
|
+
!(await renewSlackAppHomeRefreshClaim(deps.db, {
|
|
1093
|
+
refresh,
|
|
1094
|
+
claimHolderId: appHomeHolder,
|
|
1095
|
+
claimLeaseMs: APP_HOME_LEASE_MS,
|
|
1096
|
+
}))
|
|
1097
|
+
) {
|
|
1098
|
+
throw new SlackInteractionPermanentError("slack_app_home_claim_lost");
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
await renewLease();
|
|
1102
|
+
await publishSlackAppHome(deps, installation, refresh, renewLease);
|
|
1103
|
+
await settleSlackAppHomeRefresh(deps.db, {
|
|
1104
|
+
refresh,
|
|
1105
|
+
claimHolderId: appHomeHolder,
|
|
1106
|
+
});
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
const code = safeErrorCode(error);
|
|
1109
|
+
console.error("[slack-interactions] App Home refresh failed", {
|
|
1110
|
+
workspaceId: refresh.workspaceId,
|
|
1111
|
+
connectionId: refresh.connectionId,
|
|
1112
|
+
slackUserId: refresh.slackUserId,
|
|
1113
|
+
providerEventId: refresh.providerEventId,
|
|
1114
|
+
desiredRevision: refresh.desiredRevision,
|
|
1115
|
+
attemptCount: refresh.attemptCount,
|
|
1116
|
+
errorCode: code,
|
|
1117
|
+
});
|
|
1118
|
+
if (error instanceof SlackBotProviderError && error.code === "hash_conflict") {
|
|
1119
|
+
await settleSlackAppHomeRefresh(deps.db, {
|
|
1120
|
+
refresh,
|
|
1121
|
+
claimHolderId: appHomeHolder,
|
|
1122
|
+
errorCode: code,
|
|
1123
|
+
});
|
|
1124
|
+
} else if (
|
|
1125
|
+
refresh.attemptCount >= 5 ||
|
|
1126
|
+
error instanceof SlackInteractionPermanentError ||
|
|
1127
|
+
permanentSlackDeliveryError(error)
|
|
1128
|
+
) {
|
|
1129
|
+
await settleSlackAppHomeRefresh(deps.db, {
|
|
1130
|
+
refresh,
|
|
1131
|
+
claimHolderId: appHomeHolder,
|
|
1132
|
+
errorCode: code,
|
|
1133
|
+
});
|
|
1134
|
+
} else {
|
|
1135
|
+
await releaseSlackAppHomeRefresh(deps.db, {
|
|
1136
|
+
refresh,
|
|
1137
|
+
claimHolderId: appHomeHolder,
|
|
1138
|
+
errorCode: code,
|
|
1139
|
+
retryAt: new Date(Date.now() + slackDeliveryRetryMs(error, refresh.attemptCount)),
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return true;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
787
1146
|
const holder = crypto.randomUUID();
|
|
788
1147
|
const entry = await claimSlackInteractionInbox(deps.db, holder, INBOX_LEASE_MS);
|
|
789
1148
|
if (entry) {
|
|
@@ -890,6 +1249,126 @@ export function startSlackInteractionPump(
|
|
|
890
1249
|
};
|
|
891
1250
|
}
|
|
892
1251
|
|
|
1252
|
+
/**
|
|
1253
|
+
* A concurrent creator may have won this thread in another workspace between the
|
|
1254
|
+
* tenancy probe and the insert, in which case `getOrCreateSlackInteraction`
|
|
1255
|
+
* adopts that row. The grant, the imported attachments, and the resolved route
|
|
1256
|
+
* all belong to the workspace this pass chose, so none of them may be used
|
|
1257
|
+
* against the adopted row. Retry instead: the next pass sees the thread through
|
|
1258
|
+
* the probe, routes to it, and authorizes it properly.
|
|
1259
|
+
*/
|
|
1260
|
+
function requireSlackInteractionMatchesTarget(
|
|
1261
|
+
interaction: Pick<SlackInteraction, "accountId" | "workspaceId">,
|
|
1262
|
+
target: { accountId: string; workspaceId: string },
|
|
1263
|
+
): void {
|
|
1264
|
+
if (
|
|
1265
|
+
interaction.accountId !== target.accountId ||
|
|
1266
|
+
interaction.workspaceId !== target.workspaceId
|
|
1267
|
+
) {
|
|
1268
|
+
throw new SlackInteractionRetryableError("slack_route_creation_pending");
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Gather the durable facts the routing decision needs, then decide.
|
|
1274
|
+
*
|
|
1275
|
+
* With the flag off nothing is read at all: the resolver short-circuits to the
|
|
1276
|
+
* installation's own workspace, so an existing single-workspace install issues
|
|
1277
|
+
* exactly the queries it issued before.
|
|
1278
|
+
*/
|
|
1279
|
+
async function resolveSlackRouteForEntry(
|
|
1280
|
+
deps: ApiRouteDeps,
|
|
1281
|
+
home: SlackRouteTenancy,
|
|
1282
|
+
entry: SlackInteractionInboxEntry,
|
|
1283
|
+
subjectId: string,
|
|
1284
|
+
options: {
|
|
1285
|
+
threadTenancy: SlackRouteTenancy | null;
|
|
1286
|
+
askEnabled: boolean;
|
|
1287
|
+
botUserId: string | null;
|
|
1288
|
+
},
|
|
1289
|
+
): Promise<SlackRouteResolution> {
|
|
1290
|
+
const base = {
|
|
1291
|
+
home,
|
|
1292
|
+
entry,
|
|
1293
|
+
botUserId: options.botUserId,
|
|
1294
|
+
threadTenancy: options.threadTenancy,
|
|
1295
|
+
channelRoute: null,
|
|
1296
|
+
dmRoute: null,
|
|
1297
|
+
personalWorkspaceId: null,
|
|
1298
|
+
candidates: [] as const,
|
|
1299
|
+
routingEnabled: false,
|
|
1300
|
+
askEnabled: false,
|
|
1301
|
+
} as const;
|
|
1302
|
+
// A mapped thread wins outright, and with the flag off nothing is consulted at
|
|
1303
|
+
// all, so neither case reads anything.
|
|
1304
|
+
if (!deps.settings.slackWorkspaceRoutingEnabled || options.threadTenancy) {
|
|
1305
|
+
return resolveSlackWorkspaceRoute(base);
|
|
1306
|
+
}
|
|
1307
|
+
const directMessage = isSlackDirectMessageConversation(entry);
|
|
1308
|
+
const [channelRoute, dmRoute, candidates] = await Promise.all([
|
|
1309
|
+
directMessage
|
|
1310
|
+
? Promise.resolve(null)
|
|
1311
|
+
: getSlackChannelRoute(deps.db, home, {
|
|
1312
|
+
connectionId: entry.connectionId,
|
|
1313
|
+
slackChannelId: entry.slackChannelId,
|
|
1314
|
+
}),
|
|
1315
|
+
directMessage
|
|
1316
|
+
? getSlackUserDmRoute(deps.db, home, {
|
|
1317
|
+
connectionId: entry.connectionId,
|
|
1318
|
+
slackUserId: entry.slackUserId,
|
|
1319
|
+
})
|
|
1320
|
+
: Promise.resolve(null),
|
|
1321
|
+
listNamedSubjectSlackRoutableWorkspaces(deps.db, { accountId: home.accountId, subjectId }),
|
|
1322
|
+
]);
|
|
1323
|
+
return resolveSlackWorkspaceRoute({
|
|
1324
|
+
...base,
|
|
1325
|
+
channelRoute,
|
|
1326
|
+
dmRoute,
|
|
1327
|
+
candidates,
|
|
1328
|
+
// The candidate set is built from the same derived pointer, so reading it
|
|
1329
|
+
// again would be a second query for an answer already in hand.
|
|
1330
|
+
personalWorkspaceId: candidates.find((candidate) => candidate.personal)?.workspaceId ?? null,
|
|
1331
|
+
routingEnabled: true,
|
|
1332
|
+
askEnabled: options.askEnabled,
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Tell the person exactly why nothing started, in their own bot DM.
|
|
1338
|
+
*
|
|
1339
|
+
* Every branch ends with "No session was created." because the one thing a
|
|
1340
|
+
* refusal must never do is leave someone believing work is under way.
|
|
1341
|
+
*
|
|
1342
|
+
* No access-request link is minted here. The existing link flow signs a token
|
|
1343
|
+
* for the installation's own workspace, and offering one for a workspace it
|
|
1344
|
+
* cannot yet resolve would send people to a page that refuses them. Until that
|
|
1345
|
+
* flow accepts a routed workspace, the refusal names the workspace and points
|
|
1346
|
+
* at an administrator.
|
|
1347
|
+
*/
|
|
1348
|
+
async function postSlackRouteRefusal(
|
|
1349
|
+
deps: ApiRouteDeps,
|
|
1350
|
+
client: OpenGeniSlackBotClient,
|
|
1351
|
+
entry: SlackInteractionInboxEntry,
|
|
1352
|
+
refusal: Extract<SlackRouteResolution, { kind: "denied" }> & Partial<SlackRouteTenancy>,
|
|
1353
|
+
): Promise<void> {
|
|
1354
|
+
const available = refusal.candidates.map((candidate) => candidate.label);
|
|
1355
|
+
const text =
|
|
1356
|
+
refusal.reason === "no_access_to_named"
|
|
1357
|
+
? `OpenGeni does not see a workspace named ${JSON.stringify(refusal.requested ?? "")} that you can start work in.${
|
|
1358
|
+
available.length > 0 ? ` You can use: ${available.join(", ")}.` : ""
|
|
1359
|
+
} No session was created.`
|
|
1360
|
+
: refusal.reason === "no_access_to_route"
|
|
1361
|
+
? `OpenGeni starts work from this conversation in ${
|
|
1362
|
+
refusal.requested ?? "another workspace"
|
|
1363
|
+
}, and you do not have access to it. Ask an OpenGeni administrator for access to that workspace, or point this conversation somewhere else in OpenGeni under Capabilities, then Slack. No session was created.`
|
|
1364
|
+
: "OpenGeni has no workspace it can start this task in for you. Ask an OpenGeni administrator to give you access to one. No session was created.";
|
|
1365
|
+
await client.postMessage({
|
|
1366
|
+
operationId: deterministicUuid(`slack-route-denied:${entry.id}:${refusal.reason}`),
|
|
1367
|
+
userId: entry.slackUserId,
|
|
1368
|
+
text: boundedOutput(text),
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
|
|
893
1372
|
async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
|
|
894
1373
|
if (entry.triggerKind === "block_action") {
|
|
895
1374
|
await processSlackBlockAction(deps, entry);
|
|
@@ -899,6 +1378,11 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
899
1378
|
await processSlackReactionInboxEntry(deps, entry);
|
|
900
1379
|
return;
|
|
901
1380
|
}
|
|
1381
|
+
// HOME is the installation binding's tenancy: it owns the bot credential and
|
|
1382
|
+
// connection, this inbox row, the identity links, and the post ledgers. TARGET
|
|
1383
|
+
// is the tenancy the resulting session lives in. They are equal today; the
|
|
1384
|
+
// routing resolver is what makes them diverge.
|
|
1385
|
+
const home = { accountId: entry.accountId, workspaceId: entry.workspaceId } as const;
|
|
902
1386
|
const installation = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
|
|
903
1387
|
if (
|
|
904
1388
|
!installation ||
|
|
@@ -909,8 +1393,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
909
1393
|
throw new SlackInteractionPermanentError("slack_task_installation_changed");
|
|
910
1394
|
}
|
|
911
1395
|
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
912
|
-
|
|
913
|
-
workspaceId: entry.workspaceId,
|
|
1396
|
+
...home,
|
|
914
1397
|
connectionId: entry.connectionId,
|
|
915
1398
|
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
916
1399
|
});
|
|
@@ -937,17 +1420,21 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
937
1420
|
privateHandoff: policyDecision.disposition === "private_handoff",
|
|
938
1421
|
});
|
|
939
1422
|
const routeKey = routePolicy.initialRouteKey;
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1423
|
+
// A mapped thread keeps the workspace it was created in, so the continuation
|
|
1424
|
+
// lookup is connection-scoped rather than workspace-fenced.
|
|
1425
|
+
const existing = await getSlackInteractionByConnectionRoute(deps.db, {
|
|
1426
|
+
accountId: home.accountId,
|
|
1427
|
+
connectionId: entry.connectionId,
|
|
944
1428
|
routeKey,
|
|
945
|
-
);
|
|
1429
|
+
});
|
|
946
1430
|
if (entry.triggerKind === "thread_reply" && !existing) return;
|
|
947
|
-
|
|
1431
|
+
// The identity link is a HOME fact: `slack_bot_user_links` is unique on
|
|
1432
|
+
// `(connection_id, slack_user_id)` and RLS-visible only under the
|
|
1433
|
+
// installation's own tenancy. It is read before the target is chosen because
|
|
1434
|
+
// the routing decision needs the subject it names.
|
|
948
1435
|
const link = await getSlackBotUserLink(
|
|
949
1436
|
deps.db,
|
|
950
|
-
|
|
1437
|
+
home.workspaceId,
|
|
951
1438
|
entry.connectionId,
|
|
952
1439
|
entry.slackUserId,
|
|
953
1440
|
);
|
|
@@ -959,16 +1446,58 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
959
1446
|
});
|
|
960
1447
|
return;
|
|
961
1448
|
}
|
|
962
|
-
const
|
|
963
|
-
|
|
1449
|
+
const resolution = await resolveSlackRouteForEntry(deps, home, entry, link.subjectId, {
|
|
1450
|
+
botUserId: installation.botUserId,
|
|
1451
|
+
threadTenancy: existing
|
|
1452
|
+
? { accountId: existing.accountId, workspaceId: existing.workspaceId }
|
|
1453
|
+
: null,
|
|
1454
|
+
// The first-use picker does not exist yet, so genuine ambiguity keeps the
|
|
1455
|
+
// installation's workspace rather than inventing an answer.
|
|
1456
|
+
askEnabled: false,
|
|
1457
|
+
});
|
|
1458
|
+
if (resolution.kind === "ask") {
|
|
1459
|
+
// Unreachable: `askEnabled` is false above because the first-use picker
|
|
1460
|
+
// lands in a later change. Fail loudly rather than guessing a workspace.
|
|
1461
|
+
throw new SlackInteractionPermanentError("slack_route_choice_unavailable");
|
|
1462
|
+
}
|
|
1463
|
+
if (resolution.kind === "denied") {
|
|
1464
|
+
await postSlackRouteRefusal(deps, client, entry, resolution);
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
const target: { accountId: string; workspaceId: string } = {
|
|
1468
|
+
accountId: resolution.accountId,
|
|
1469
|
+
workspaceId: resolution.workspaceId,
|
|
1470
|
+
};
|
|
1471
|
+
const grant = await resolveSlackTargetAuthority(deps.db, {
|
|
1472
|
+
subjectId: link.subjectId,
|
|
1473
|
+
targetAccountId: target.accountId,
|
|
1474
|
+
targetWorkspaceId: target.workspaceId,
|
|
964
1475
|
});
|
|
965
|
-
if (!grant
|
|
966
|
-
|
|
1476
|
+
if (!grant) {
|
|
1477
|
+
// Never quietly serve this from a workspace the person did not name. A
|
|
1478
|
+
// routed workspace the subject cannot reach is a refusal with the existing
|
|
1479
|
+
// access-request path, minted for the workspace they actually need.
|
|
1480
|
+
if (resolution.source === "installation" || resolution.source === "thread") {
|
|
1481
|
+
throw new SlackInteractionPermanentError("identity_access_revoked");
|
|
1482
|
+
}
|
|
1483
|
+
await postSlackRouteRefusal(deps, client, entry, {
|
|
1484
|
+
kind: "denied",
|
|
1485
|
+
reason: "no_access_to_route",
|
|
1486
|
+
requested: resolution.label,
|
|
1487
|
+
candidates: [],
|
|
1488
|
+
...target,
|
|
1489
|
+
});
|
|
1490
|
+
return;
|
|
967
1491
|
}
|
|
1492
|
+
// An override addresses the message; it is not part of the request.
|
|
1493
|
+
const routedEntry: SlackInteractionInboxEntry = {
|
|
1494
|
+
...entry,
|
|
1495
|
+
text: slackRoutedRequestText(entry.text, resolution, installation.botUserId),
|
|
1496
|
+
};
|
|
968
1497
|
|
|
969
1498
|
const alreadyDurable = await getSlackInteractionByClientEventId(
|
|
970
1499
|
deps.db,
|
|
971
|
-
|
|
1500
|
+
target.workspaceId,
|
|
972
1501
|
entry.connectionId,
|
|
973
1502
|
`slack:${entry.providerEventId}`,
|
|
974
1503
|
);
|
|
@@ -991,14 +1520,13 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
991
1520
|
if (!boundInteraction) {
|
|
992
1521
|
throw new Error("Durable Slack interaction could not bind its reserved session");
|
|
993
1522
|
}
|
|
994
|
-
await ensureSlackSharedTaskOrigin(deps, boundInteraction, entry, policyResolution);
|
|
1523
|
+
await ensureSlackSharedTaskOrigin(deps, boundInteraction, entry, home, policyResolution);
|
|
995
1524
|
const shouldRepairAcknowledgement =
|
|
996
1525
|
interaction.triggeringProviderEventId === entry.providerEventId ||
|
|
997
1526
|
(usesPrivateBotDm(boundInteraction, entry) && boundInteraction.ackSlackMessageTs === null);
|
|
998
1527
|
if (shouldRepairAcknowledgement) {
|
|
999
1528
|
const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
1000
|
-
|
|
1001
|
-
workspaceId: entry.workspaceId,
|
|
1529
|
+
...home,
|
|
1002
1530
|
connectionId: entry.connectionId,
|
|
1003
1531
|
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
1004
1532
|
sessionId: eventSessionId,
|
|
@@ -1008,7 +1536,8 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
1008
1536
|
return;
|
|
1009
1537
|
}
|
|
1010
1538
|
|
|
1011
|
-
let preparedEntry =
|
|
1539
|
+
let preparedEntry = routedEntry;
|
|
1540
|
+
let preparedModelContext: string | null = null;
|
|
1012
1541
|
let preparedAttachments: PreparedSlackReactionTask = {
|
|
1013
1542
|
resources: [],
|
|
1014
1543
|
attachments: [],
|
|
@@ -1019,7 +1548,8 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
1019
1548
|
const prepared = await prepareSlackInvocationEntry(
|
|
1020
1549
|
deps,
|
|
1021
1550
|
client,
|
|
1022
|
-
|
|
1551
|
+
routedEntry,
|
|
1552
|
+
target,
|
|
1023
1553
|
policyDecision.disposition === "private_handoff"
|
|
1024
1554
|
? async () => {
|
|
1025
1555
|
await requireSlackSharedReadAuthorization(deps, client, entry, policyResolution);
|
|
@@ -1027,31 +1557,28 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
1027
1557
|
: undefined,
|
|
1028
1558
|
);
|
|
1029
1559
|
preparedEntry = prepared.entry;
|
|
1560
|
+
preparedModelContext = prepared.modelContext;
|
|
1030
1561
|
preparedAttachments = prepared.attachments;
|
|
1031
1562
|
}
|
|
1032
1563
|
|
|
1033
1564
|
if (existing?.sessionId) {
|
|
1034
1565
|
const remounted = await remountSlackReactionTaskForSession(
|
|
1035
1566
|
deps,
|
|
1036
|
-
|
|
1567
|
+
target.workspaceId,
|
|
1037
1568
|
existing.sessionId,
|
|
1038
1569
|
preparedAttachments,
|
|
1039
1570
|
);
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
slackInvocationPreparedEntry(preparedEntry, remounted),
|
|
1045
|
-
remounted.resources,
|
|
1046
|
-
);
|
|
1571
|
+
const prepared = slackInvocationPreparedMessage(preparedEntry, remounted, preparedModelContext);
|
|
1572
|
+
await continueSlackSession(deps, grant, existing, prepared.entry, remounted.resources, {
|
|
1573
|
+
modelContext: prepared.modelContext,
|
|
1574
|
+
});
|
|
1047
1575
|
return;
|
|
1048
1576
|
}
|
|
1049
1577
|
if (!hasPermission(grant.permissions, "sessions:create")) {
|
|
1050
1578
|
throw new SlackInteractionPermanentError("sessions_create_denied");
|
|
1051
1579
|
}
|
|
1052
1580
|
const { interaction } = await getOrCreateSlackInteraction(deps.db, {
|
|
1053
|
-
|
|
1054
|
-
workspaceId: entry.workspaceId,
|
|
1581
|
+
...target,
|
|
1055
1582
|
connectionId: entry.connectionId,
|
|
1056
1583
|
slackTeamId: entry.slackTeamId,
|
|
1057
1584
|
slackChannelId: entry.slackChannelId,
|
|
@@ -1062,32 +1589,36 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
1062
1589
|
owningSubjectId: grant.subjectId,
|
|
1063
1590
|
visibility: routePolicy.visibility,
|
|
1064
1591
|
});
|
|
1592
|
+
requireSlackInteractionMatchesTarget(interaction, target);
|
|
1065
1593
|
if (interaction.sessionId) {
|
|
1066
1594
|
const remounted = await remountSlackReactionTaskForSession(
|
|
1067
1595
|
deps,
|
|
1068
|
-
|
|
1596
|
+
interaction.workspaceId,
|
|
1069
1597
|
interaction.sessionId,
|
|
1070
1598
|
preparedAttachments,
|
|
1071
1599
|
);
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
slackInvocationPreparedEntry(preparedEntry, remounted),
|
|
1077
|
-
remounted.resources,
|
|
1078
|
-
);
|
|
1600
|
+
const prepared = slackInvocationPreparedMessage(preparedEntry, remounted, preparedModelContext);
|
|
1601
|
+
await continueSlackSession(deps, grant, interaction, prepared.entry, remounted.resources, {
|
|
1602
|
+
modelContext: prepared.modelContext,
|
|
1603
|
+
});
|
|
1079
1604
|
return;
|
|
1080
1605
|
}
|
|
1081
1606
|
const preferredModel = await getLatestSessionModelForSubject(
|
|
1082
1607
|
deps.db,
|
|
1083
|
-
|
|
1608
|
+
interaction.workspaceId,
|
|
1084
1609
|
grant.subjectId,
|
|
1085
1610
|
);
|
|
1086
1611
|
let session: Awaited<ReturnType<typeof createSessionForRequest>>;
|
|
1087
1612
|
try {
|
|
1088
|
-
|
|
1613
|
+
const prepared = slackInvocationPreparedMessage(
|
|
1614
|
+
preparedEntry,
|
|
1615
|
+
preparedAttachments,
|
|
1616
|
+
preparedModelContext,
|
|
1617
|
+
);
|
|
1618
|
+
session = await createSessionForRequest(deps, grant, interaction.workspaceId, {
|
|
1089
1619
|
requestedSessionId: interaction.sessionReservationId,
|
|
1090
|
-
initialMessage:
|
|
1620
|
+
initialMessage: prepared.entry.text,
|
|
1621
|
+
...(prepared.modelContext ? { modelContext: prepared.modelContext } : {}),
|
|
1091
1622
|
instructions: SLACK_SESSION_INSTRUCTIONS,
|
|
1092
1623
|
firstPartyMcpTools: slackTaskFirstPartyMcpTools(deps.settings),
|
|
1093
1624
|
resources: preparedAttachments.resources,
|
|
@@ -1118,10 +1649,9 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
1118
1649
|
sessionId: session.id,
|
|
1119
1650
|
});
|
|
1120
1651
|
if (!bound) throw new Error("Slack route could not bind its durable session");
|
|
1121
|
-
await ensureSlackSharedTaskOrigin(deps, bound, entry, policyResolution);
|
|
1652
|
+
await ensureSlackSharedTaskOrigin(deps, bound, entry, home, policyResolution);
|
|
1122
1653
|
const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
1123
|
-
|
|
1124
|
-
workspaceId: entry.workspaceId,
|
|
1654
|
+
...home,
|
|
1125
1655
|
connectionId: entry.connectionId,
|
|
1126
1656
|
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
1127
1657
|
sessionId: session.id,
|
|
@@ -1164,6 +1694,7 @@ async function ensureSlackSharedTaskOrigin(
|
|
|
1164
1694
|
deps: ApiRouteDeps,
|
|
1165
1695
|
interaction: SlackInteraction,
|
|
1166
1696
|
entry: SlackInteractionInboxEntry,
|
|
1697
|
+
home: { accountId: string; workspaceId: string },
|
|
1167
1698
|
resolution:
|
|
1168
1699
|
| Awaited<ReturnType<typeof slackTaskPolicyDecision>>
|
|
1169
1700
|
| {
|
|
@@ -1189,6 +1720,11 @@ async function ensureSlackSharedTaskOrigin(
|
|
|
1189
1720
|
sourceChannelId: entry.slackChannelId,
|
|
1190
1721
|
sourceThreadTs: entry.slackThreadTs ?? entry.slackMessageTs,
|
|
1191
1722
|
initiatingSlackUserId: interaction.initiatingSlackUserId,
|
|
1723
|
+
// The task policy governs what may be read out of the Slack conversation,
|
|
1724
|
+
// which is an installation-surface concern, so its frozen revision keeps
|
|
1725
|
+
// home tenancy while the row itself follows the routed task.
|
|
1726
|
+
policyAccountId: home.accountId,
|
|
1727
|
+
policyWorkspaceId: home.workspaceId,
|
|
1192
1728
|
policyRevisionId: resolution.activePolicy.revision.id,
|
|
1193
1729
|
policyHash: resolution.activePolicy.revision.policyHash,
|
|
1194
1730
|
policyActivationVersion: resolution.activePolicy.head.activationVersion,
|
|
@@ -1225,8 +1761,15 @@ async function prepareSlackInvocationEntry(
|
|
|
1225
1761
|
deps: ApiRouteDeps,
|
|
1226
1762
|
client: OpenGeniSlackBotClient,
|
|
1227
1763
|
entry: SlackInteractionInboxEntry,
|
|
1764
|
+
// Imported attachments become File resources of the session's workspace, so
|
|
1765
|
+
// they follow TARGET tenancy rather than the installation's.
|
|
1766
|
+
target: { accountId: string; workspaceId: string },
|
|
1228
1767
|
authorizeRead?: () => Promise<void>,
|
|
1229
|
-
): Promise<{
|
|
1768
|
+
): Promise<{
|
|
1769
|
+
entry: SlackInteractionInboxEntry;
|
|
1770
|
+
attachments: PreparedSlackReactionTask;
|
|
1771
|
+
modelContext: string | null;
|
|
1772
|
+
}> {
|
|
1230
1773
|
const context = entry.slackThreadTs
|
|
1231
1774
|
? await client.threadReplies({
|
|
1232
1775
|
channelId: entry.slackChannelId,
|
|
@@ -1255,53 +1798,58 @@ async function prepareSlackInvocationEntry(
|
|
|
1255
1798
|
exactMessage = exact.messages.find((message) => message.timestamp === entry.slackMessageTs);
|
|
1256
1799
|
}
|
|
1257
1800
|
const attachments = exactMessage
|
|
1258
|
-
? await prepareSlackMessageAttachments(
|
|
1801
|
+
? await prepareSlackMessageAttachments(
|
|
1802
|
+
deps,
|
|
1803
|
+
client,
|
|
1804
|
+
entry,
|
|
1805
|
+
target,
|
|
1806
|
+
exactMessage.files,
|
|
1807
|
+
authorizeRead,
|
|
1808
|
+
)
|
|
1259
1809
|
: {
|
|
1260
1810
|
resources: [],
|
|
1261
1811
|
attachments: [],
|
|
1262
1812
|
omissionCodes: entry.hasFiles ? ["attachment_unavailable"] : [],
|
|
1263
1813
|
omittedCount: entry.hasFiles ? 1 : 0,
|
|
1264
1814
|
};
|
|
1265
|
-
const
|
|
1815
|
+
const modelContext =
|
|
1266
1816
|
entry.triggerKind === "app_mention"
|
|
1267
|
-
?
|
|
1817
|
+
? slackInvocationModelContext(entry.slackMessageTs, {
|
|
1268
1818
|
messages: context.messages,
|
|
1269
1819
|
nextCursor: context.nextCursor,
|
|
1270
1820
|
kind: entry.slackThreadTs ? "thread" : "channel",
|
|
1271
1821
|
})
|
|
1272
|
-
:
|
|
1822
|
+
: null;
|
|
1273
1823
|
return {
|
|
1274
|
-
entry
|
|
1824
|
+
entry,
|
|
1275
1825
|
attachments,
|
|
1826
|
+
modelContext,
|
|
1276
1827
|
};
|
|
1277
1828
|
}
|
|
1278
1829
|
|
|
1279
|
-
function
|
|
1830
|
+
function slackInvocationPreparedMessage(
|
|
1280
1831
|
entry: SlackInteractionInboxEntry,
|
|
1281
1832
|
attachments: PreparedSlackReactionTask,
|
|
1282
|
-
|
|
1833
|
+
modelContext: string | null,
|
|
1834
|
+
): { entry: SlackInteractionInboxEntry; modelContext: string | null } {
|
|
1283
1835
|
const manifest = slackAttachmentManifest(
|
|
1284
1836
|
attachments,
|
|
1285
1837
|
"Imported invocation attachments",
|
|
1286
1838
|
"invocation",
|
|
1287
1839
|
);
|
|
1288
|
-
|
|
1840
|
+
const combinedModelContext = [modelContext, manifest].filter(Boolean).join("\n\n");
|
|
1841
|
+
return {
|
|
1842
|
+
entry,
|
|
1843
|
+
modelContext: combinedModelContext.length > 0 ? combinedModelContext : null,
|
|
1844
|
+
};
|
|
1289
1845
|
}
|
|
1290
1846
|
|
|
1291
|
-
export function
|
|
1292
|
-
|
|
1847
|
+
export function slackInvocationModelContext(
|
|
1848
|
+
invocationTimestamp: string,
|
|
1293
1849
|
context: SlackInvocationMessageContext,
|
|
1294
1850
|
) {
|
|
1295
|
-
const invocation = {
|
|
1296
|
-
timestamp: entry.slackMessageTs,
|
|
1297
|
-
userId: entry.slackUserId,
|
|
1298
|
-
botId: "",
|
|
1299
|
-
threadTimestamp: "",
|
|
1300
|
-
text: entry.text,
|
|
1301
|
-
files: [],
|
|
1302
|
-
};
|
|
1303
1851
|
const surroundingLines = context.messages
|
|
1304
|
-
.filter((message) => message.timestamp !==
|
|
1852
|
+
.filter((message) => message.timestamp !== invocationTimestamp)
|
|
1305
1853
|
.slice(0, MAX_SLACK_INVOCATION_CONTEXT_MESSAGES)
|
|
1306
1854
|
.sort((left, right) => left.timestamp.localeCompare(right.timestamp))
|
|
1307
1855
|
.map((message) => slackContextMessageLine(message));
|
|
@@ -1313,46 +1861,24 @@ export function slackInvocationTaskText(
|
|
|
1313
1861
|
context.kind === "thread"
|
|
1314
1862
|
? "The containing thread was truncated at the bounded Slack context limit."
|
|
1315
1863
|
: "Only bounded nearby channel context was provided.";
|
|
1316
|
-
|
|
1317
|
-
"The exact Slack invocation was truncated at the bounded Slack input limit.";
|
|
1318
|
-
const prefix = [
|
|
1864
|
+
let prompt = [
|
|
1319
1865
|
"A linked, authorized Slack user explicitly mentioned OpenGeni.",
|
|
1866
|
+
"The visible user message on this turn is the exact accepted Slack invocation.",
|
|
1320
1867
|
"Treat references such as 'this', 'that', or 'the previous message' as referring to the bounded Slack context below when applicable.",
|
|
1321
1868
|
"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.",
|
|
1322
1869
|
"",
|
|
1323
|
-
|
|
1870
|
+
contextLabel,
|
|
1324
1871
|
].join("\n");
|
|
1325
|
-
const suffix = `\n\n${contextLabel}`;
|
|
1326
|
-
const invocationLine = slackContextMessageLine(invocation, "invocation");
|
|
1327
|
-
const reservedNotices = `\n${invocationTruncationNotice}\n${truncationNotice}`;
|
|
1328
|
-
const maxInvocationChars = Math.max(
|
|
1329
|
-
1,
|
|
1330
|
-
MAX_SLACK_INPUT_CHARS - prefix.length - suffix.length - reservedNotices.length - 1,
|
|
1331
|
-
);
|
|
1332
|
-
const invocationTruncated = invocationLine.length > maxInvocationChars;
|
|
1333
|
-
let prompt = `${prefix}\n${
|
|
1334
|
-
invocationTruncated
|
|
1335
|
-
? `${invocationLine.slice(0, Math.max(0, maxInvocationChars - 1))}…`
|
|
1336
|
-
: invocationLine
|
|
1337
|
-
}${suffix}`;
|
|
1338
1872
|
let contextTruncated = context.nextCursor !== null;
|
|
1339
1873
|
for (const line of surroundingLines) {
|
|
1340
1874
|
const candidate = `${prompt}\n${line}`;
|
|
1341
|
-
|
|
1342
|
-
...(invocationTruncated ? [invocationTruncationNotice] : []),
|
|
1343
|
-
truncationNotice,
|
|
1344
|
-
].join("\n");
|
|
1345
|
-
if (candidate.length + 1 + notices.length > MAX_SLACK_INPUT_CHARS) {
|
|
1875
|
+
if (candidate.length + 1 + truncationNotice.length > MAX_SLACK_INPUT_CHARS) {
|
|
1346
1876
|
contextTruncated = true;
|
|
1347
1877
|
break;
|
|
1348
1878
|
}
|
|
1349
1879
|
prompt = candidate;
|
|
1350
1880
|
}
|
|
1351
|
-
|
|
1352
|
-
...(invocationTruncated ? [invocationTruncationNotice] : []),
|
|
1353
|
-
...(contextTruncated ? [truncationNotice] : []),
|
|
1354
|
-
];
|
|
1355
|
-
return notices.length > 0 ? `${prompt}\n${notices.join("\n")}` : prompt;
|
|
1881
|
+
return contextTruncated ? `${prompt}\n${truncationNotice}` : prompt;
|
|
1356
1882
|
}
|
|
1357
1883
|
|
|
1358
1884
|
async function acknowledgeSlackSession(
|
|
@@ -1369,11 +1895,28 @@ async function acknowledgeSlackSession(
|
|
|
1369
1895
|
!directMessageShortcut && interaction.visibility === "private" && entry.triggerKind !== "dm";
|
|
1370
1896
|
const privateBotDm = directMessageShortcut || privateHandoff;
|
|
1371
1897
|
const operationId = deterministicUuid(`slack-ack:${interaction.id}`);
|
|
1372
|
-
|
|
1373
|
-
|
|
1898
|
+
// The acknowledgement carries exactly one session link plus the Status/Stop
|
|
1899
|
+
// buttons. The how-to prose it used to repeat forever now rides along once,
|
|
1900
|
+
// on this Slack identity's first accepted task in this installation.
|
|
1901
|
+
const started = directMessageShortcut
|
|
1902
|
+
? `OpenGeni started a private task from the selected DM message. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} The source DM was not opened to the bot or made workspace-visible.`
|
|
1374
1903
|
: privateHandoff
|
|
1375
|
-
? `OpenGeni started a private task from the selected Slack conversation. ${openSessionText(deps,
|
|
1376
|
-
: `OpenGeni started this task. ${openSessionText(deps,
|
|
1904
|
+
? `OpenGeni started a private task from the selected Slack conversation. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} Results stay private unless a separate authorized publication is approved.`
|
|
1905
|
+
: `OpenGeni started this task. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)}`;
|
|
1906
|
+
// The post ledger binds this fixed operation id to a digest over the message
|
|
1907
|
+
// text, and this function re-runs on every acknowledgement repair. Resolving
|
|
1908
|
+
// the hint freezes it durably before the post, so a repair renders identical
|
|
1909
|
+
// bytes; a failure here raises into the retryable inbox path rather than
|
|
1910
|
+
// binding the ledger to a hint-less acknowledgement it can never revise.
|
|
1911
|
+
const showHint = await resolveSlackInteractionFirstTaskHint(deps.db, {
|
|
1912
|
+
// The identity link is HOME; the interaction row is TARGET.
|
|
1913
|
+
workspaceId: entry.workspaceId,
|
|
1914
|
+
interactionWorkspaceId: interaction.workspaceId,
|
|
1915
|
+
connectionId: entry.connectionId,
|
|
1916
|
+
slackUserId: entry.slackUserId,
|
|
1917
|
+
interactionId: interaction.id,
|
|
1918
|
+
});
|
|
1919
|
+
const text = `${started}${showHint ? slackFirstTaskHintText(deps) : ""}`;
|
|
1377
1920
|
const controls = await controlActionBlocks(deps, interaction, {
|
|
1378
1921
|
messageOperationId: operationId,
|
|
1379
1922
|
sessionEventSequence: 0,
|
|
@@ -1412,14 +1955,38 @@ async function acknowledgeSlackSession(
|
|
|
1412
1955
|
}
|
|
1413
1956
|
}
|
|
1414
1957
|
|
|
1958
|
+
/**
|
|
1959
|
+
* The one-time onboarding sentence shown on a Slack identity's first
|
|
1960
|
+
* acknowledged task in one installation.
|
|
1961
|
+
*
|
|
1962
|
+
* Rendering is a pure function of the configured command plus the frozen
|
|
1963
|
+
* `firstTaskHint` fact, so every re-render of the same acknowledgement produces
|
|
1964
|
+
* the same bytes for the digest-bound post ledger.
|
|
1965
|
+
*
|
|
1966
|
+
* Note the one remaining input that is not frozen: the text embeds
|
|
1967
|
+
* `settings.slackCommand`. Changing that setting between an acknowledgement
|
|
1968
|
+
* post and a later repair of the same interaction would diverge the digest and
|
|
1969
|
+
* conflict its post operation. The command is deployment configuration that
|
|
1970
|
+
* must already match the registered Slack slash command, so it does not change
|
|
1971
|
+
* under a live installation; a deployment that does rename it should drain
|
|
1972
|
+
* in-flight Slack interactions first.
|
|
1973
|
+
*/
|
|
1974
|
+
function slackFirstTaskHintText(deps: ApiRouteDeps): string {
|
|
1975
|
+
const command = deps.settings.slackCommand;
|
|
1976
|
+
return `\n\nFirst time here: reply in this thread to continue this task, or reply \`stop\` to stop it. Start a new top-level DM or run \`${command}\` again for a new task. Run \`${command} info\` any time for the full summary.`;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1415
1979
|
async function processSlackReactionInboxEntry(
|
|
1416
1980
|
deps: ApiRouteDeps,
|
|
1417
1981
|
entry: SlackInteractionInboxEntry,
|
|
1418
1982
|
) {
|
|
1983
|
+
// Reaction summon is configured on the installation surface, so the settings,
|
|
1984
|
+
// the connection scopes, and the identity link are all HOME reads.
|
|
1985
|
+
const home = { accountId: entry.accountId, workspaceId: entry.workspaceId } as const;
|
|
1419
1986
|
const [workspace, connection, link] = await Promise.all([
|
|
1420
|
-
getWorkspace(deps.db,
|
|
1421
|
-
getConnectionMetadata(deps.db,
|
|
1422
|
-
getSlackBotUserLink(deps.db,
|
|
1987
|
+
getWorkspace(deps.db, home.workspaceId),
|
|
1988
|
+
getConnectionMetadata(deps.db, home.workspaceId, entry.connectionId, null),
|
|
1989
|
+
getSlackBotUserLink(deps.db, home.workspaceId, entry.connectionId, entry.slackUserId),
|
|
1423
1990
|
]);
|
|
1424
1991
|
const settings = resolveWorkspaceSlackReactionSummonSettings(workspace?.settings);
|
|
1425
1992
|
if (
|
|
@@ -1433,23 +2000,55 @@ async function processSlackReactionInboxEntry(
|
|
|
1433
2000
|
) {
|
|
1434
2001
|
return;
|
|
1435
2002
|
}
|
|
1436
|
-
|
|
1437
|
-
|
|
2003
|
+
// A reaction is never a direct message, so the routing decision here is the
|
|
2004
|
+
// channel's remembered answer, the sole candidate, or the installation's own
|
|
2005
|
+
// workspace. It runs before the provider context read so an unauthorized
|
|
2006
|
+
// subject never causes OpenGeni to read the Slack conversation.
|
|
2007
|
+
const routeResolution = await resolveSlackRouteForEntry(deps, home, entry, link.subjectId, {
|
|
2008
|
+
// A reaction carries no message text of the reacting person, so no prefix.
|
|
2009
|
+
botUserId: null,
|
|
2010
|
+
threadTenancy: null,
|
|
2011
|
+
askEnabled: false,
|
|
1438
2012
|
});
|
|
1439
|
-
if (
|
|
1440
|
-
throw new SlackInteractionPermanentError("
|
|
2013
|
+
if (routeResolution.kind === "ask") {
|
|
2014
|
+
throw new SlackInteractionPermanentError("slack_route_choice_unavailable");
|
|
1441
2015
|
}
|
|
1442
|
-
if (
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
2016
|
+
if (routeResolution.kind === "denied") {
|
|
2017
|
+
const summonClient = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
2018
|
+
...home,
|
|
2019
|
+
connectionId: entry.connectionId,
|
|
2020
|
+
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
2021
|
+
});
|
|
2022
|
+
await postSlackRouteRefusal(deps, summonClient, entry, routeResolution);
|
|
2023
|
+
return;
|
|
1447
2024
|
}
|
|
2025
|
+
let target: { accountId: string; workspaceId: string } = {
|
|
2026
|
+
accountId: routeResolution.accountId,
|
|
2027
|
+
workspaceId: routeResolution.workspaceId,
|
|
2028
|
+
};
|
|
2029
|
+
const authorizeTarget = async () => {
|
|
2030
|
+
const resolved = await resolveSlackTargetAuthority(deps.db, {
|
|
2031
|
+
subjectId: link.subjectId,
|
|
2032
|
+
targetAccountId: target.accountId,
|
|
2033
|
+
targetWorkspaceId: target.workspaceId,
|
|
2034
|
+
});
|
|
2035
|
+
if (!resolved) {
|
|
2036
|
+
throw new SlackInteractionPermanentError("identity_access_revoked");
|
|
2037
|
+
}
|
|
2038
|
+
if (
|
|
2039
|
+
!hasPermission(resolved.permissions, "sessions:create") ||
|
|
2040
|
+
!hasPermission(resolved.permissions, "sessions:control")
|
|
2041
|
+
) {
|
|
2042
|
+
throw new SlackInteractionPermanentError("reaction_session_permissions_denied");
|
|
2043
|
+
}
|
|
2044
|
+
return resolved;
|
|
2045
|
+
};
|
|
2046
|
+
let grant = await authorizeTarget();
|
|
1448
2047
|
|
|
1449
2048
|
const clientEventId = `slack:${entry.providerEventId}`;
|
|
1450
2049
|
const durableInteraction = await getSlackInteractionByClientEventId(
|
|
1451
2050
|
deps.db,
|
|
1452
|
-
|
|
2051
|
+
target.workspaceId,
|
|
1453
2052
|
entry.connectionId,
|
|
1454
2053
|
clientEventId,
|
|
1455
2054
|
);
|
|
@@ -1481,8 +2080,7 @@ async function processSlackReactionInboxEntry(
|
|
|
1481
2080
|
await reopenSlackInteractionDelivery(deps.db, boundInteraction);
|
|
1482
2081
|
if (shouldRepairAcknowledgement) {
|
|
1483
2082
|
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
1484
|
-
|
|
1485
|
-
workspaceId: entry.workspaceId,
|
|
2083
|
+
...home,
|
|
1486
2084
|
connectionId: entry.connectionId,
|
|
1487
2085
|
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
1488
2086
|
sessionId: eventSessionId,
|
|
@@ -1493,8 +2091,7 @@ async function processSlackReactionInboxEntry(
|
|
|
1493
2091
|
}
|
|
1494
2092
|
|
|
1495
2093
|
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
1496
|
-
|
|
1497
|
-
workspaceId: entry.workspaceId,
|
|
2094
|
+
...home,
|
|
1498
2095
|
connectionId: entry.connectionId,
|
|
1499
2096
|
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
1500
2097
|
});
|
|
@@ -1525,18 +2122,25 @@ async function processSlackReactionInboxEntry(
|
|
|
1525
2122
|
if (!saved) throw new Error("Slack reaction inbox checkpoint claim was lost");
|
|
1526
2123
|
},
|
|
1527
2124
|
});
|
|
1528
|
-
const preparedTask = await prepareSlackReactionTask(deps, client, entry, context);
|
|
1529
2125
|
const routeKey = slackRouteKey(entry.slackChannelId, context.threadTimestamp);
|
|
1530
|
-
const existing = await
|
|
1531
|
-
|
|
1532
|
-
entry.
|
|
1533
|
-
entry.connectionId,
|
|
2126
|
+
const existing = await getSlackInteractionByConnectionRoute(deps.db, {
|
|
2127
|
+
accountId: home.accountId,
|
|
2128
|
+
connectionId: entry.connectionId,
|
|
1534
2129
|
routeKey,
|
|
1535
|
-
);
|
|
2130
|
+
});
|
|
2131
|
+
if (
|
|
2132
|
+
existing &&
|
|
2133
|
+
(existing.accountId !== target.accountId || existing.workspaceId !== target.workspaceId)
|
|
2134
|
+
) {
|
|
2135
|
+
// A mapped thread keeps the workspace it was created in.
|
|
2136
|
+
target = { accountId: existing.accountId, workspaceId: existing.workspaceId };
|
|
2137
|
+
grant = await authorizeTarget();
|
|
2138
|
+
}
|
|
2139
|
+
const preparedTask = await prepareSlackReactionTask(deps, client, entry, target, context);
|
|
1536
2140
|
if (existing?.sessionId) {
|
|
1537
2141
|
const appendedTask = await remountSlackReactionTaskForSession(
|
|
1538
2142
|
deps,
|
|
1539
|
-
|
|
2143
|
+
existing.workspaceId,
|
|
1540
2144
|
existing.sessionId,
|
|
1541
2145
|
preparedTask,
|
|
1542
2146
|
);
|
|
@@ -1550,8 +2154,7 @@ async function processSlackReactionInboxEntry(
|
|
|
1550
2154
|
return;
|
|
1551
2155
|
}
|
|
1552
2156
|
const { interaction } = await getOrCreateSlackInteraction(deps.db, {
|
|
1553
|
-
|
|
1554
|
-
workspaceId: entry.workspaceId,
|
|
2157
|
+
...target,
|
|
1555
2158
|
connectionId: entry.connectionId,
|
|
1556
2159
|
slackTeamId: entry.slackTeamId,
|
|
1557
2160
|
slackChannelId: entry.slackChannelId,
|
|
@@ -1562,10 +2165,11 @@ async function processSlackReactionInboxEntry(
|
|
|
1562
2165
|
owningSubjectId: grant.subjectId,
|
|
1563
2166
|
visibility: "workspace",
|
|
1564
2167
|
});
|
|
2168
|
+
requireSlackInteractionMatchesTarget(interaction, target);
|
|
1565
2169
|
if (interaction.sessionId) {
|
|
1566
2170
|
const appendedTask = await remountSlackReactionTaskForSession(
|
|
1567
2171
|
deps,
|
|
1568
|
-
|
|
2172
|
+
interaction.workspaceId,
|
|
1569
2173
|
interaction.sessionId,
|
|
1570
2174
|
preparedTask,
|
|
1571
2175
|
);
|
|
@@ -1588,13 +2192,13 @@ async function processSlackReactionInboxEntry(
|
|
|
1588
2192
|
}
|
|
1589
2193
|
const preferredModel = await getLatestSessionModelForSubject(
|
|
1590
2194
|
deps.db,
|
|
1591
|
-
|
|
2195
|
+
interaction.workspaceId,
|
|
1592
2196
|
grant.subjectId,
|
|
1593
2197
|
);
|
|
1594
2198
|
const preparedEntry = slackReactionPreparedEntry(entry, context, preparedTask);
|
|
1595
2199
|
let session: Awaited<ReturnType<typeof createSessionForRequest>>;
|
|
1596
2200
|
try {
|
|
1597
|
-
session = await createSessionForRequest(deps, grant,
|
|
2201
|
+
session = await createSessionForRequest(deps, grant, interaction.workspaceId, {
|
|
1598
2202
|
requestedSessionId: interaction.sessionReservationId,
|
|
1599
2203
|
initialMessage: preparedEntry.text,
|
|
1600
2204
|
instructions: SLACK_SESSION_INSTRUCTIONS,
|
|
@@ -1761,16 +2365,25 @@ async function prepareSlackReactionTask(
|
|
|
1761
2365
|
deps: ApiRouteDeps,
|
|
1762
2366
|
client: OpenGeniSlackBotClient,
|
|
1763
2367
|
entry: SlackInteractionInboxEntry,
|
|
2368
|
+
target: { accountId: string; workspaceId: string },
|
|
1764
2369
|
context: SlackReactionMessageContext,
|
|
1765
2370
|
): Promise<PreparedSlackReactionTask> {
|
|
1766
|
-
return await prepareSlackMessageAttachments(
|
|
2371
|
+
return await prepareSlackMessageAttachments(
|
|
2372
|
+
deps,
|
|
2373
|
+
client,
|
|
2374
|
+
entry,
|
|
2375
|
+
target,
|
|
2376
|
+
context.reactedMessage.files,
|
|
2377
|
+
);
|
|
1767
2378
|
}
|
|
1768
2379
|
|
|
1769
2380
|
async function prepareSlackMessageAttachments(
|
|
1770
2381
|
deps: ApiRouteDeps,
|
|
1771
2382
|
client: OpenGeniSlackBotClient,
|
|
1772
2383
|
entry: SlackInteractionInboxEntry,
|
|
2384
|
+
target: { accountId: string; workspaceId: string },
|
|
1773
2385
|
exactFiles: readonly { id: string; name: string; title: string }[],
|
|
2386
|
+
authorizeSharedRead?: () => Promise<void>,
|
|
1774
2387
|
): Promise<PreparedSlackReactionTask> {
|
|
1775
2388
|
if (exactFiles.length === 0) {
|
|
1776
2389
|
return { resources: [], attachments: [], omissionCodes: [], omittedCount: 0 };
|
|
@@ -1790,12 +2403,13 @@ async function prepareSlackMessageAttachments(
|
|
|
1790
2403
|
name: file.name,
|
|
1791
2404
|
title: file.title,
|
|
1792
2405
|
})),
|
|
2406
|
+
...(authorizeSharedRead ? { authorizeSharedRead } : {}),
|
|
1793
2407
|
});
|
|
1794
2408
|
const downloaded: Awaited<ReturnType<OpenGeniSlackBotClient["downloadReactionImage"]>>[] = [];
|
|
1795
2409
|
let aggregateBytes = 0;
|
|
1796
2410
|
for (const image of prepared) {
|
|
1797
2411
|
try {
|
|
1798
|
-
const value = await client.downloadReactionImage(image);
|
|
2412
|
+
const value = await client.downloadReactionImage(image, authorizeSharedRead);
|
|
1799
2413
|
if (
|
|
1800
2414
|
value.bytes.byteLength > SLACK_REACTION_IMAGE_MAX_BYTES ||
|
|
1801
2415
|
aggregateBytes + value.bytes.byteLength > MAX_SLACK_REACTION_IMAGE_AGGREGATE_BYTES
|
|
@@ -1828,8 +2442,8 @@ async function prepareSlackMessageAttachments(
|
|
|
1828
2442
|
await importSlackReactionImage(
|
|
1829
2443
|
{ db: deps.db, objectStorage: deps.objectStorage },
|
|
1830
2444
|
{
|
|
1831
|
-
accountId:
|
|
1832
|
-
workspaceId:
|
|
2445
|
+
accountId: target.accountId,
|
|
2446
|
+
workspaceId: target.workspaceId,
|
|
1833
2447
|
connectionId: entry.connectionId,
|
|
1834
2448
|
slackTeamId: entry.slackTeamId,
|
|
1835
2449
|
slackChannelId: entry.slackChannelId,
|
|
@@ -1968,9 +2582,11 @@ async function acceptSlackReactionTask(
|
|
|
1968
2582
|
resources: FileResourceRef[],
|
|
1969
2583
|
) {
|
|
1970
2584
|
const clientEventId = `slack:${entry.providerEventId}`;
|
|
2585
|
+
// Session events and user messages belong to the workspace that owns the
|
|
2586
|
+
// session, which is the workspace the grant authorized, not the installation.
|
|
1971
2587
|
const existing = await getSessionEventByClientEventId(
|
|
1972
2588
|
deps.db,
|
|
1973
|
-
|
|
2589
|
+
grant.workspaceId,
|
|
1974
2590
|
sessionId,
|
|
1975
2591
|
clientEventId,
|
|
1976
2592
|
);
|
|
@@ -1980,7 +2596,7 @@ async function acceptSlackReactionTask(
|
|
|
1980
2596
|
}
|
|
1981
2597
|
return;
|
|
1982
2598
|
}
|
|
1983
|
-
await acceptSessionUserMessage(deps, grant,
|
|
2599
|
+
await acceptSessionUserMessage(deps, grant, grant.workspaceId, sessionId, {
|
|
1984
2600
|
text: entry.text,
|
|
1985
2601
|
resources,
|
|
1986
2602
|
clientEventId,
|
|
@@ -1993,6 +2609,7 @@ async function continueSlackSession(
|
|
|
1993
2609
|
interaction: SlackInteraction,
|
|
1994
2610
|
entry: SlackInteractionInboxEntry,
|
|
1995
2611
|
resources: FileResourceRef[] = [],
|
|
2612
|
+
options: { modelContext?: string | null } = {},
|
|
1996
2613
|
) {
|
|
1997
2614
|
if (
|
|
1998
2615
|
!interaction.sessionId ||
|
|
@@ -2023,7 +2640,7 @@ async function continueSlackSession(
|
|
|
2023
2640
|
}
|
|
2024
2641
|
const pending = await listSessionHumanInputRequests(
|
|
2025
2642
|
deps.db,
|
|
2026
|
-
|
|
2643
|
+
interaction.workspaceId,
|
|
2027
2644
|
interaction.sessionId,
|
|
2028
2645
|
{ status: "pending", limit: 2 },
|
|
2029
2646
|
);
|
|
@@ -2063,8 +2680,9 @@ async function continueSlackSession(
|
|
|
2063
2680
|
if (!hasPermission(grant.permissions, "sessions:control")) {
|
|
2064
2681
|
throw new SlackInteractionPermanentError("sessions_control_denied");
|
|
2065
2682
|
}
|
|
2066
|
-
await acceptSessionUserMessage(deps, grant,
|
|
2683
|
+
await acceptSessionUserMessage(deps, grant, interaction.workspaceId, interaction.sessionId, {
|
|
2067
2684
|
text: entry.text,
|
|
2685
|
+
...(options.modelContext ? { modelContext: options.modelContext } : {}),
|
|
2068
2686
|
resources,
|
|
2069
2687
|
clientEventId: `slack:${entry.providerEventId}`,
|
|
2070
2688
|
});
|
|
@@ -2085,6 +2703,9 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
|
|
|
2085
2703
|
const separator = entry.text.lastIndexOf(":");
|
|
2086
2704
|
const actionId = separator > 0 ? entry.text.slice(0, separator) : "";
|
|
2087
2705
|
const handleId = separator > 0 ? entry.text.slice(separator + 1) : "";
|
|
2706
|
+
// The block-action inbox row carries HOME tenancy, exactly like every other
|
|
2707
|
+
// inbox row: it is the installation binding that received the click.
|
|
2708
|
+
const home = { accountId: entry.accountId, workspaceId: entry.workspaceId } as const;
|
|
2088
2709
|
const route = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
|
|
2089
2710
|
if (
|
|
2090
2711
|
!route ||
|
|
@@ -2094,19 +2715,32 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
|
|
|
2094
2715
|
) {
|
|
2095
2716
|
throw new SlackInteractionPermanentError("slack_action_installation_changed");
|
|
2096
2717
|
}
|
|
2718
|
+
// A handle lives in the workspace that owns its session, which is not the
|
|
2719
|
+
// installation's workspace once routing is on. Learn that scope from the
|
|
2720
|
+
// content-free probe, then read the full handle under it: every authorization
|
|
2721
|
+
// check below is unchanged and still runs in the handle's own tenancy.
|
|
2722
|
+
const handleTenancy =
|
|
2723
|
+
(await probeSlackActionHandleTenancy(deps.db, {
|
|
2724
|
+
accountId: home.accountId,
|
|
2725
|
+
connectionId: entry.connectionId,
|
|
2726
|
+
handleId,
|
|
2727
|
+
})) ?? home;
|
|
2097
2728
|
const handle = await getSlackInteractionActionHandle(deps.db, {
|
|
2098
|
-
|
|
2099
|
-
workspaceId: entry.workspaceId,
|
|
2729
|
+
...handleTenancy,
|
|
2100
2730
|
handleId,
|
|
2101
2731
|
});
|
|
2102
2732
|
if (!handle || SLACK_ACTION_ID_BY_KIND[handle.actionKind] !== actionId) {
|
|
2103
2733
|
throw new SlackInteractionPermanentError("slack_action_handle_invalid");
|
|
2104
2734
|
}
|
|
2735
|
+
// An action handle is a TARGET fact: it is composite-FK'd to its interaction
|
|
2736
|
+
// and its RESTRICTIVE session-visibility policy resolves the session under the
|
|
2737
|
+
// handle's own tenancy, so every read, settle and interaction lookup below
|
|
2738
|
+
// uses the handle's scope rather than the installation's.
|
|
2739
|
+
const target = { accountId: handle.accountId, workspaceId: handle.workspaceId } as const;
|
|
2105
2740
|
if (handle.status !== "pending") return;
|
|
2106
2741
|
if (handle.expiresAt.getTime() <= Date.now()) {
|
|
2107
2742
|
await settleSlackInteractionActionHandles(deps.db, {
|
|
2108
|
-
|
|
2109
|
-
workspaceId: entry.workspaceId,
|
|
2743
|
+
...target,
|
|
2110
2744
|
handleId: handle.id,
|
|
2111
2745
|
result: "expired",
|
|
2112
2746
|
stale: true,
|
|
@@ -2118,14 +2752,15 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
|
|
|
2118
2752
|
}
|
|
2119
2753
|
const [interaction, link, post] = await Promise.all([
|
|
2120
2754
|
getSlackInteractionById(deps.db, {
|
|
2121
|
-
|
|
2122
|
-
workspaceId: entry.workspaceId,
|
|
2755
|
+
...target,
|
|
2123
2756
|
interactionId: handle.interactionId,
|
|
2124
2757
|
}),
|
|
2125
|
-
|
|
2758
|
+
// Identity stays HOME.
|
|
2759
|
+
getSlackBotUserLink(deps.db, home.workspaceId, entry.connectionId, entry.slackUserId),
|
|
2760
|
+
// The post ledger is written by the bot credential, so it stays HOME too.
|
|
2126
2761
|
getSlackBotPostOperation(
|
|
2127
2762
|
deps.db,
|
|
2128
|
-
|
|
2763
|
+
home.workspaceId,
|
|
2129
2764
|
entry.connectionId,
|
|
2130
2765
|
handle.messageOperationId,
|
|
2131
2766
|
),
|
|
@@ -2146,34 +2781,42 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
|
|
|
2146
2781
|
) {
|
|
2147
2782
|
throw new SlackInteractionPermanentError("slack_action_authority_changed");
|
|
2148
2783
|
}
|
|
2149
|
-
const grant = await
|
|
2150
|
-
|
|
2784
|
+
const grant = await resolveSlackTargetAuthority(deps.db, {
|
|
2785
|
+
subjectId: link.subjectId,
|
|
2786
|
+
targetAccountId: target.accountId,
|
|
2787
|
+
targetWorkspaceId: target.workspaceId,
|
|
2151
2788
|
});
|
|
2152
|
-
if (
|
|
2153
|
-
!grant ||
|
|
2154
|
-
grant.accountId !== entry.accountId ||
|
|
2155
|
-
!hasPermission(grant.permissions, "sessions:control")
|
|
2156
|
-
) {
|
|
2789
|
+
if (!grant || !hasPermission(grant.permissions, "sessions:control")) {
|
|
2157
2790
|
throw new SlackInteractionPermanentError("sessions_control_denied");
|
|
2158
2791
|
}
|
|
2159
2792
|
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
2160
|
-
|
|
2161
|
-
workspaceId: entry.workspaceId,
|
|
2793
|
+
...home,
|
|
2162
2794
|
connectionId: entry.connectionId,
|
|
2163
2795
|
subjectId: grant.subjectId,
|
|
2164
2796
|
sessionId: handle.sessionId,
|
|
2165
2797
|
});
|
|
2166
2798
|
const outcome = await executeSlackAction(deps, grant, interaction, handle);
|
|
2799
|
+
// A control click replaces the message it was pressed on. When that message
|
|
2800
|
+
// is this interaction's acknowledgement and this interaction won the one-time
|
|
2801
|
+
// onboarding hint, the update carries the hint forward: otherwise pressing
|
|
2802
|
+
// Status would permanently destroy the only copy of prose a Slack identity is
|
|
2803
|
+
// ever shown. The handle's message operation identifies the acknowledgement
|
|
2804
|
+
// exactly, independent of route rekeying. Later control cards have their own
|
|
2805
|
+
// operation ids, so the hint still appears on exactly one message.
|
|
2806
|
+
const acknowledgementOperationId = deterministicUuid(`slack-ack:${interaction.id}`);
|
|
2807
|
+
const updateText =
|
|
2808
|
+
interaction.firstTaskHint === true && handle.messageOperationId === acknowledgementOperationId
|
|
2809
|
+
? `${outcome.text}${slackFirstTaskHintText(deps)}`
|
|
2810
|
+
: outcome.text;
|
|
2167
2811
|
await client.updateMessage({
|
|
2168
2812
|
operationId: deterministicUuid(`slack-action-update:${handle.id}:${outcome.result}`),
|
|
2169
2813
|
channelId: entry.slackChannelId,
|
|
2170
2814
|
timestamp: entry.slackMessageTs,
|
|
2171
|
-
text:
|
|
2172
|
-
blocks: [{ type: "section", text: { type: "mrkdwn", text:
|
|
2815
|
+
text: updateText,
|
|
2816
|
+
blocks: [{ type: "section", text: { type: "mrkdwn", text: updateText } }],
|
|
2173
2817
|
});
|
|
2174
2818
|
await settleSlackInteractionActionHandles(deps.db, {
|
|
2175
|
-
|
|
2176
|
-
workspaceId: entry.workspaceId,
|
|
2819
|
+
...target,
|
|
2177
2820
|
handleId: handle.id,
|
|
2178
2821
|
result: outcome.result,
|
|
2179
2822
|
...(outcome.stale ? { stale: true } : {}),
|
|
@@ -2348,9 +2991,20 @@ async function executeSlackAction(
|
|
|
2348
2991
|
}
|
|
2349
2992
|
const session = await getSession(deps.db, grant.workspaceId, handle.sessionId);
|
|
2350
2993
|
if (!session) throw new SlackInteractionPermanentError("slack_action_session_missing");
|
|
2994
|
+
// The result post no longer advertises recurrence. The Status card is where
|
|
2995
|
+
// the requester finds it, and only while this exact requester still holds
|
|
2996
|
+
// schedule authority in this workspace. The deep link and the schedules
|
|
2997
|
+
// authority behind it are unchanged: the browser rechecks both.
|
|
2998
|
+
const recurring =
|
|
2999
|
+
hasPermission(grant.permissions, "sessions:read") &&
|
|
3000
|
+
hasPermission(grant.permissions, "scheduled_tasks:manage")
|
|
3001
|
+
? makeRecurringText(deps, grant.workspaceId, handle.sessionId)
|
|
3002
|
+
: null;
|
|
2351
3003
|
return {
|
|
2352
3004
|
result: "status",
|
|
2353
|
-
text: `${mention}OpenGeni task status: *${session.status.replaceAll("_", " ")}
|
|
3005
|
+
text: `${mention}OpenGeni task status: *${session.status.replaceAll("_", " ")}*.${
|
|
3006
|
+
recurring ? `\n\n${recurring}` : ""
|
|
3007
|
+
}`,
|
|
2354
3008
|
...(session.status === "cancelled" || session.status === "failed"
|
|
2355
3009
|
? {}
|
|
2356
3010
|
: { controlState: "active" as const }),
|
|
@@ -2367,18 +3021,25 @@ async function publishSlackSharedResult(
|
|
|
2367
3021
|
if (!handle.targetId || !interaction.sessionId) {
|
|
2368
3022
|
throw new SlackInteractionPermanentError("slack_shared_publication_target_invalid");
|
|
2369
3023
|
}
|
|
2370
|
-
const
|
|
3024
|
+
const publicationHome = await resolveSlackDeliveryHome(deps, interaction);
|
|
3025
|
+
const [origin, event] = await Promise.all([
|
|
2371
3026
|
getSlackSharedTaskOrigin(deps.db, {
|
|
2372
3027
|
accountId: interaction.accountId,
|
|
2373
3028
|
workspaceId: interaction.workspaceId,
|
|
2374
3029
|
interactionId: interaction.id,
|
|
2375
3030
|
}),
|
|
2376
|
-
getActiveSlackTaskPolicy(deps.db, {
|
|
2377
|
-
accountId: interaction.accountId,
|
|
2378
|
-
workspaceId: interaction.workspaceId,
|
|
2379
|
-
}),
|
|
2380
3031
|
getSessionEvent(deps.db, interaction.workspaceId, handle.targetId),
|
|
2381
3032
|
]);
|
|
3033
|
+
// The Slack task policy governs what may be read out of, and published back
|
|
3034
|
+
// to, the Slack conversation, so it is an installation-surface fact. The
|
|
3035
|
+
// origin froze which tenancy that was; a row written before routing existed
|
|
3036
|
+
// carries null and implied its own, because the two could not differ then.
|
|
3037
|
+
const activePolicy = origin
|
|
3038
|
+
? await getActiveSlackTaskPolicy(deps.db, {
|
|
3039
|
+
accountId: origin.policyAccountId ?? origin.accountId,
|
|
3040
|
+
workspaceId: origin.policyWorkspaceId ?? origin.workspaceId,
|
|
3041
|
+
})
|
|
3042
|
+
: null;
|
|
2382
3043
|
if (
|
|
2383
3044
|
!origin ||
|
|
2384
3045
|
origin.connectionId !== interaction.connectionId ||
|
|
@@ -2400,8 +3061,7 @@ async function publishSlackSharedResult(
|
|
|
2400
3061
|
};
|
|
2401
3062
|
}
|
|
2402
3063
|
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
2403
|
-
|
|
2404
|
-
workspaceId: interaction.workspaceId,
|
|
3064
|
+
...publicationHome,
|
|
2405
3065
|
connectionId: interaction.connectionId,
|
|
2406
3066
|
subjectId: grant.subjectId,
|
|
2407
3067
|
sessionId: interaction.sessionId,
|
|
@@ -2480,31 +3140,22 @@ async function validatedSlackRequester(
|
|
|
2480
3140
|
SlackInteraction,
|
|
2481
3141
|
"accountId" | "workspaceId" | "connectionId" | "initiatingSlackUserId" | "owningSubjectId"
|
|
2482
3142
|
>,
|
|
3143
|
+
// Identity links are HOME facts, unique on (connection_id, slack_user_id) and
|
|
3144
|
+
// RLS-visible only under the installation's own tenancy.
|
|
3145
|
+
home: { accountId: string; workspaceId: string },
|
|
2483
3146
|
) {
|
|
2484
3147
|
if (!interaction.initiatingSlackUserId) {
|
|
2485
|
-
return { authorized: false,
|
|
3148
|
+
return { authorized: false, mention: "" };
|
|
2486
3149
|
}
|
|
2487
|
-
const
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
),
|
|
2494
|
-
getWorkspaceGrant(deps.db, interaction.owningSubjectId, interaction.workspaceId, {
|
|
2495
|
-
principalKind: "human_session",
|
|
2496
|
-
}),
|
|
2497
|
-
]);
|
|
3150
|
+
const link = await getSlackBotUserLink(
|
|
3151
|
+
deps.db,
|
|
3152
|
+
home.workspaceId,
|
|
3153
|
+
interaction.connectionId,
|
|
3154
|
+
interaction.initiatingSlackUserId,
|
|
3155
|
+
);
|
|
2498
3156
|
const authorized = link?.subjectId === interaction.owningSubjectId;
|
|
2499
|
-
const canSchedule =
|
|
2500
|
-
authorized &&
|
|
2501
|
-
grant?.accountId === interaction.accountId &&
|
|
2502
|
-
hasPermission(grant.permissions, "sessions:read") &&
|
|
2503
|
-
hasPermission(grant.permissions, "sessions:control") &&
|
|
2504
|
-
hasPermission(grant.permissions, "scheduled_tasks:manage");
|
|
2505
3157
|
return {
|
|
2506
3158
|
authorized,
|
|
2507
|
-
canSchedule,
|
|
2508
3159
|
mention: authorized ? slackRequesterMention(interaction) : "",
|
|
2509
3160
|
};
|
|
2510
3161
|
}
|
|
@@ -2831,6 +3482,157 @@ async function slackHumanInputCard(
|
|
|
2831
3482
|
return { text, blocks, operationId };
|
|
2832
3483
|
}
|
|
2833
3484
|
|
|
3485
|
+
/**
|
|
3486
|
+
* A child of a Slack-originated session is never itself mapped to a Slack
|
|
3487
|
+
* thread, so the parent's bounded `child_requires_action` notice is the only
|
|
3488
|
+
* path that can tell the human that a worker they started is blocked. The card
|
|
3489
|
+
* is a pointer, not a second question card: one bounded preview of the first
|
|
3490
|
+
* question (or the waiting approval count) plus the child link. The child's own
|
|
3491
|
+
* OpenGeni card remains the place the human actually answers.
|
|
3492
|
+
*
|
|
3493
|
+
* Every deferred child lifecycle kind (`child_progress`,
|
|
3494
|
+
* `child_waiting_capacity`, `child_requires_action_resolved`, `child_paused`)
|
|
3495
|
+
* returns null here so Slack stays quiet, and so does every non-child machine
|
|
3496
|
+
* input that shares the `system.update.pending` event type.
|
|
3497
|
+
*
|
|
3498
|
+
* The caller has already proven that this workspace turned the notice on. The
|
|
3499
|
+
* card defaults off per workspace because the in-app rail and priority feed
|
|
3500
|
+
* already surface a blocked child.
|
|
3501
|
+
*/
|
|
3502
|
+
/**
|
|
3503
|
+
* The exact durable notice behind one `system.update.pending` event, or null
|
|
3504
|
+
* when it no longer describes a blocked worker.
|
|
3505
|
+
*
|
|
3506
|
+
* Slack delivery runs behind the session: a widened retry window, a replica
|
|
3507
|
+
* claim, or simply a later page can reach this event after the child already
|
|
3508
|
+
* got its answer. Two facts have to agree before a card is worth posting.
|
|
3509
|
+
*
|
|
3510
|
+
* A resolution supersedes a still-`pending` notice in the same commit, so
|
|
3511
|
+
* `superseded` (and an explicitly `cancelled` notice) is already a "no longer
|
|
3512
|
+
* blocked" fact. But a notice the parent's turn has claimed is `delivered` and
|
|
3513
|
+
* keeps that state forever, so state alone is not enough - the resolution row
|
|
3514
|
+
* for that exact (child, turn, generation) boundary is checked as well, on the
|
|
3515
|
+
* parent's own rows. A re-freeze is a new generation and a new notice.
|
|
3516
|
+
*
|
|
3517
|
+
* Every read is wrapped: a malformed or unreadable durable row is not a
|
|
3518
|
+
* delivery failure. Throwing would burn a delivery attempt and, after
|
|
3519
|
+
* MAX_DELIVERY_ATTEMPTS, close this interaction's whole delivery over one
|
|
3520
|
+
* notice nobody could have posted anyway. Silence is the correct outcome, and
|
|
3521
|
+
* it is the same outcome every other unpostable notice already takes.
|
|
3522
|
+
*/
|
|
3523
|
+
async function resolveSlackBlockedChildNotice(
|
|
3524
|
+
deps: ApiRouteDeps,
|
|
3525
|
+
interaction: SlackInteraction,
|
|
3526
|
+
sessionId: string,
|
|
3527
|
+
updateId: string,
|
|
3528
|
+
): Promise<ChildRequiresActionPayload | null> {
|
|
3529
|
+
try {
|
|
3530
|
+
const update = await getSessionSystemUpdateById(
|
|
3531
|
+
deps.db,
|
|
3532
|
+
interaction.workspaceId,
|
|
3533
|
+
sessionId,
|
|
3534
|
+
updateId,
|
|
3535
|
+
);
|
|
3536
|
+
if (!update || update.payload.type !== "child_requires_action") return null;
|
|
3537
|
+
if (update.state === "superseded" || update.state === "cancelled") return null;
|
|
3538
|
+
const resolved = await childRequiresActionResolutionExists(
|
|
3539
|
+
deps.db,
|
|
3540
|
+
interaction.workspaceId,
|
|
3541
|
+
sessionId,
|
|
3542
|
+
{
|
|
3543
|
+
childSessionId: update.payload.childSessionId,
|
|
3544
|
+
childTurnId: update.payload.childTurnId,
|
|
3545
|
+
childTurnGeneration: update.payload.childTurnGeneration,
|
|
3546
|
+
},
|
|
3547
|
+
);
|
|
3548
|
+
return resolved ? null : update.payload;
|
|
3549
|
+
} catch {
|
|
3550
|
+
return null;
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
async function slackChildRequiresActionCard(
|
|
3555
|
+
deps: ApiRouteDeps,
|
|
3556
|
+
interaction: SlackInteraction,
|
|
3557
|
+
event: SessionEvent,
|
|
3558
|
+
mention: string,
|
|
3559
|
+
): Promise<{ text: string } | null> {
|
|
3560
|
+
const sessionId = interaction.sessionId;
|
|
3561
|
+
if (!sessionId) return null;
|
|
3562
|
+
const preview = record(event.payload);
|
|
3563
|
+
if (boundedString(preview?.kind, 64) !== "child_requires_action") return null;
|
|
3564
|
+
const updateId = boundedString(preview?.updateId, 64);
|
|
3565
|
+
if (!updateId || !SLACK_UUID_PATTERN.test(updateId)) return null;
|
|
3566
|
+
// The bounded event preview is lossy by construction. Resolve the exact
|
|
3567
|
+
// durable notice under the ordinary workspace scope; the child session is
|
|
3568
|
+
// never read, only linked.
|
|
3569
|
+
const notice = await resolveSlackBlockedChildNotice(deps, interaction, sessionId, updateId);
|
|
3570
|
+
if (!notice) return null;
|
|
3571
|
+
const questions = notice.requests.filter((request) => request.kind === "human_input");
|
|
3572
|
+
const approvals = notice.requests.filter((request) => request.kind === "approval");
|
|
3573
|
+
const first = questions[0];
|
|
3574
|
+
const detail = first
|
|
3575
|
+
? `${boundedSlackChildDetail(first.firstQuestion)}${
|
|
3576
|
+
first.questionCount > 1 ? ` (+${first.questionCount - 1} more)` : ""
|
|
3577
|
+
}`
|
|
3578
|
+
: approvals.length > 0
|
|
3579
|
+
? `${approvals.length} tool approval${approvals.length > 1 ? "s are" : " is"} waiting for a human.`
|
|
3580
|
+
: "";
|
|
3581
|
+
const link = slackSessionUrl(deps, interaction.workspaceId, notice.childSessionId);
|
|
3582
|
+
const lines = [`${mention}A worker you started needs input.`];
|
|
3583
|
+
if (detail) lines.push(`> ${detail}`);
|
|
3584
|
+
if (link) lines.push(`<${link}|Open in OpenGeni>`);
|
|
3585
|
+
return { text: boundedOutput(lines.join("\n")) };
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
/**
|
|
3589
|
+
* A goal that paused because it ran out of budget or hit the continuation cap
|
|
3590
|
+
* stops making progress with nobody watching, so the Slack thread gets one
|
|
3591
|
+
* bounded line. A `user_pause` / `api` / `agent` pause is a decision the human
|
|
3592
|
+
* or their agent already made, and `no_progress` is not a stop the human must
|
|
3593
|
+
* act on, so none of them post. `goal.resumed` never posts.
|
|
3594
|
+
*
|
|
3595
|
+
* The caller has already proven that this workspace turned the notice on; it
|
|
3596
|
+
* defaults off per workspace.
|
|
3597
|
+
*/
|
|
3598
|
+
function slackGoalPausedText(
|
|
3599
|
+
deps: ApiRouteDeps,
|
|
3600
|
+
interaction: SlackInteraction,
|
|
3601
|
+
event: SessionEvent,
|
|
3602
|
+
mention: string,
|
|
3603
|
+
): string | null {
|
|
3604
|
+
if (!interaction.sessionId) return null;
|
|
3605
|
+
const reason = boundedString(record(event.payload)?.reason, 64);
|
|
3606
|
+
const headline = reason ? SLACK_GOAL_PAUSED_HEADLINES.get(reason) : undefined;
|
|
3607
|
+
if (!headline) return null;
|
|
3608
|
+
const link = slackSessionUrl(deps, interaction.workspaceId, interaction.sessionId);
|
|
3609
|
+
return boundedOutput(`${mention}${headline}.${link ? ` <${link}|Open in OpenGeni>` : ""}`);
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
/**
|
|
3613
|
+
* The installation tenancy that owns this interaction's Slack credential.
|
|
3614
|
+
*
|
|
3615
|
+
* `slack_interactions.connection_id` deliberately has no composite
|
|
3616
|
+
* `(workspace_id, connection_id)` foreign key, so a routed interaction may point
|
|
3617
|
+
* at the installation's connection from another workspace.
|
|
3618
|
+
*
|
|
3619
|
+
* A binding that does not resolve, or that now names a different connection, is
|
|
3620
|
+
* NOT turned into a new delivery precondition: this code previously used the
|
|
3621
|
+
* interaction's own tenancy unconditionally, so falling back to it keeps every
|
|
3622
|
+
* delivery that used to succeed succeeding. The fallback cannot post from the
|
|
3623
|
+
* wrong workspace either, because `claimSlackBotPostOperation` selects the
|
|
3624
|
+
* connection under the tenancy it is given and fails when it does not own it.
|
|
3625
|
+
*/
|
|
3626
|
+
async function resolveSlackDeliveryHome(
|
|
3627
|
+
deps: ApiRouteDeps,
|
|
3628
|
+
interaction: Pick<SlackInteraction, "accountId" | "workspaceId" | "connectionId" | "slackTeamId">,
|
|
3629
|
+
): Promise<{ accountId: string; workspaceId: string }> {
|
|
3630
|
+
const installation = await resolveSlackInstallationRoute(deps.db, interaction.slackTeamId);
|
|
3631
|
+
return installation && installation.connectionId === interaction.connectionId
|
|
3632
|
+
? { accountId: installation.accountId, workspaceId: installation.workspaceId }
|
|
3633
|
+
: { accountId: interaction.accountId, workspaceId: interaction.workspaceId };
|
|
3634
|
+
}
|
|
3635
|
+
|
|
2834
3636
|
async function deliverSlackSessionEvents(
|
|
2835
3637
|
deps: ApiRouteDeps,
|
|
2836
3638
|
interaction: SlackInteraction,
|
|
@@ -2850,14 +3652,65 @@ async function deliverSlackSessionEvents(
|
|
|
2850
3652
|
});
|
|
2851
3653
|
return;
|
|
2852
3654
|
}
|
|
3655
|
+
// The bot credential is owned by the installation's workspace and every
|
|
3656
|
+
// provider call is fenced on it, so the delivery client is always built from
|
|
3657
|
+
// HOME even when the session it reports on lives in another workspace.
|
|
3658
|
+
const home = await resolveSlackDeliveryHome(deps, interaction);
|
|
2853
3659
|
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
2854
|
-
|
|
2855
|
-
workspaceId: interaction.workspaceId,
|
|
3660
|
+
...home,
|
|
2856
3661
|
connectionId: interaction.connectionId,
|
|
2857
3662
|
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
2858
3663
|
sessionId: interaction.sessionId,
|
|
2859
3664
|
});
|
|
2860
|
-
const requester = await validatedSlackRequester(deps, interaction);
|
|
3665
|
+
const requester = await validatedSlackRequester(deps, interaction, home);
|
|
3666
|
+
// Both orchestration notices are per-workspace and OFF unless this workspace
|
|
3667
|
+
// turned them on. Resolved lazily so an ordinary page of turn/progress events
|
|
3668
|
+
// costs no extra workspace read, and memoized so one page resolves once. A
|
|
3669
|
+
// disabled notice takes the same "nothing to post for this event" path as an
|
|
3670
|
+
// undeliverable one: no post operation, no progress slot, and the delivery
|
|
3671
|
+
// cursor still advances past the event exactly as it would have.
|
|
3672
|
+
let orchestrationNotices: ResolvedWorkspaceSlackOrchestrationNoticeSettings | null = null;
|
|
3673
|
+
const orchestrationNoticeEnabled = async (
|
|
3674
|
+
notice: keyof ResolvedWorkspaceSlackOrchestrationNoticeSettings,
|
|
3675
|
+
): Promise<boolean> => {
|
|
3676
|
+
orchestrationNotices ??= resolveWorkspaceSlackOrchestrationNoticeSettings(
|
|
3677
|
+
(await getWorkspace(deps.db, interaction.workspaceId))?.settings,
|
|
3678
|
+
);
|
|
3679
|
+
return orchestrationNotices[notice];
|
|
3680
|
+
};
|
|
3681
|
+
/**
|
|
3682
|
+
* Post one orchestration notice through the SAME durable per-interaction slot
|
|
3683
|
+
* budget as assistant progress, so an orchestration that fans out to many
|
|
3684
|
+
* blocked children cannot turn one Slack task into an unbounded feed. That
|
|
3685
|
+
* budget is deliberately shared rather than a second private allowance: the
|
|
3686
|
+
* ceiling worth enforcing is the total number of posts the human did not ask
|
|
3687
|
+
* for, not a per-category one, and this whole feature exists because an
|
|
3688
|
+
* unsolicited post is worse than a missed one. Beyond the cap the notice goes
|
|
3689
|
+
* silent exactly like a fourth progress message.
|
|
3690
|
+
*
|
|
3691
|
+
* The slot is claimed only once a card is actually going to be posted, so a
|
|
3692
|
+
* skipped, stale, or already-resolved notice never burns one. Claims are
|
|
3693
|
+
* durable and keyed on the session event sequence, so a reaper retry, a
|
|
3694
|
+
* replica claim, or a replayed page reuses the same slot and the same post
|
|
3695
|
+
* operation instead of posting twice or consuming a second slot. The claimed
|
|
3696
|
+
* row never becomes terminal-coalescing evidence: that lookup only joins
|
|
3697
|
+
* `agent.message.completed` events.
|
|
3698
|
+
*/
|
|
3699
|
+
const postUnsolicitedNotice = async (event: SessionEvent, text: string, kind: string) => {
|
|
3700
|
+
const slot = await claimSlackInteractionProgressDelivery(deps.db, {
|
|
3701
|
+
accountId: interaction.accountId,
|
|
3702
|
+
workspaceId: interaction.workspaceId,
|
|
3703
|
+
interactionId: interaction.id,
|
|
3704
|
+
claimHolderId,
|
|
3705
|
+
sessionEventSequence: event.sequence,
|
|
3706
|
+
maxProgress: MAX_PROGRESS_MESSAGES,
|
|
3707
|
+
});
|
|
3708
|
+
if (slot.kind === "not_owned") {
|
|
3709
|
+
throw new Error("Slack orchestration notice lost its durable interaction claim");
|
|
3710
|
+
}
|
|
3711
|
+
if (slot.kind !== "claimed") return;
|
|
3712
|
+
await postDelivery(client, interaction, event, text, kind, slot.delivery.operationId);
|
|
3713
|
+
};
|
|
2861
3714
|
let lastSequence = interaction.lastDeliveredSessionEventSequence;
|
|
2862
3715
|
let terminal: Exclude<SlackInteraction["terminalDeliveryState"], "open"> | null = null;
|
|
2863
3716
|
let latestAssistantText = "";
|
|
@@ -2985,14 +3838,21 @@ async function deliverSlackSessionEvents(
|
|
|
2985
3838
|
card.blocks,
|
|
2986
3839
|
);
|
|
2987
3840
|
}
|
|
3841
|
+
} else if (event.type === "system.update.pending") {
|
|
3842
|
+
const card = (await orchestrationNoticeEnabled("childRequiresAction"))
|
|
3843
|
+
? await slackChildRequiresActionCard(deps, interaction, event, requester.mention)
|
|
3844
|
+
: null;
|
|
3845
|
+
if (card) await postUnsolicitedNotice(event, card.text, "child-blocked");
|
|
3846
|
+
} else if (event.type === "goal.paused") {
|
|
3847
|
+
const paused = (await orchestrationNoticeEnabled("goalPaused"))
|
|
3848
|
+
? slackGoalPausedText(deps, interaction, event, requester.mention)
|
|
3849
|
+
: null;
|
|
3850
|
+
if (paused) await postUnsolicitedNotice(event, paused, "goal-paused");
|
|
2988
3851
|
} else if (event.type === "turn.completed") {
|
|
2989
3852
|
const payloadOutput = safePayloadText(event.payload, "output");
|
|
2990
3853
|
const hasPublishableOutput = payloadOutput.trim().length > 0;
|
|
2991
3854
|
const output = hasPublishableOutput ? payloadOutput : latestAssistantText;
|
|
2992
3855
|
const normalizedOutput = output.trim();
|
|
2993
|
-
const recurringLink = requester.canSchedule
|
|
2994
|
-
? `\n\n${makeRecurringText(deps, interaction.workspaceId, interaction.sessionId)}`
|
|
2995
|
-
: "";
|
|
2996
3856
|
const existingProgress = progressEvidence.find(
|
|
2997
3857
|
(delivery) =>
|
|
2998
3858
|
slackDeliveryTextsCoalesce(boundedOutput(delivery.text).trim(), normalizedOutput) &&
|
|
@@ -3022,15 +3882,14 @@ async function deliverSlackSessionEvents(
|
|
|
3022
3882
|
);
|
|
3023
3883
|
const posted = await getSlackBotPostOperation(
|
|
3024
3884
|
deps.db,
|
|
3025
|
-
|
|
3885
|
+
home.workspaceId,
|
|
3026
3886
|
interaction.connectionId,
|
|
3027
3887
|
existingProgress.operationId,
|
|
3028
3888
|
);
|
|
3029
3889
|
if (posted?.slackChannelId && posted.slackMessageTimestamp) {
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
);
|
|
3890
|
+
// The result is the result. Continuation prose and the recurring
|
|
3891
|
+
// action live on the control/Status card and `<command> info`.
|
|
3892
|
+
const text = boundedOutput(`${requester.mention}${existingProgress.text}`);
|
|
3034
3893
|
await client.updateMessage({
|
|
3035
3894
|
operationId: deterministicUuid(
|
|
3036
3895
|
`slack-terminal-update:${interaction.id}:${event.sequence}`,
|
|
@@ -3058,10 +3917,8 @@ async function deliverSlackSessionEvents(
|
|
|
3058
3917
|
const operationId = deterministicUuid(
|
|
3059
3918
|
`slack-delivery:${interaction.id}:${event.sequence}:final`,
|
|
3060
3919
|
);
|
|
3061
|
-
const
|
|
3062
|
-
const text = boundedOutputWithSuffix(
|
|
3920
|
+
const text = boundedOutput(
|
|
3063
3921
|
`${requester.mention}${output || "OpenGeni finished this task."}`,
|
|
3064
|
-
finalSuffix,
|
|
3065
3922
|
);
|
|
3066
3923
|
const publicationBlocks = hasPublishableOutput
|
|
3067
3924
|
? await slackSharedResultPublicationBlocks(
|
|
@@ -3311,7 +4168,14 @@ function humanInputResponse(questions: HumanInputQuestion[], text: string) {
|
|
|
3311
4168
|
const matches = question.options.filter(
|
|
3312
4169
|
(option) => option.id.toLowerCase() === normalized || option.label.toLowerCase() === normalized,
|
|
3313
4170
|
);
|
|
3314
|
-
if (matches.length !== 1)
|
|
4171
|
+
if (matches.length !== 1) {
|
|
4172
|
+
const other = text.trim();
|
|
4173
|
+
if (!other) return null;
|
|
4174
|
+
return {
|
|
4175
|
+
outcome: "answered" as const,
|
|
4176
|
+
answers: [{ questionId: question.id, values: [], other }],
|
|
4177
|
+
};
|
|
4178
|
+
}
|
|
3315
4179
|
return {
|
|
3316
4180
|
outcome: "answered" as const,
|
|
3317
4181
|
answers: [{ questionId: question.id, values: [matches[0]!.id] }],
|
|
@@ -3326,11 +4190,123 @@ function formatQuestions(questions: HumanInputQuestion[]) {
|
|
|
3326
4190
|
.slice(0, 10)
|
|
3327
4191
|
.map((option) => option.label)
|
|
3328
4192
|
.join(", ");
|
|
3329
|
-
return `${index + 1}. ${boundedOutput(question.prompt)}${
|
|
4193
|
+
return `${index + 1}. ${boundedOutput(question.prompt)}${
|
|
4194
|
+
options ? ` (${options}, or reply with another value)` : ""
|
|
4195
|
+
}`;
|
|
3330
4196
|
})
|
|
3331
4197
|
.join("\n");
|
|
3332
4198
|
}
|
|
3333
4199
|
|
|
4200
|
+
export function isSlackInfoCommand(text: string): boolean {
|
|
4201
|
+
return text.trim().toLowerCase() === "info";
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
type SlackSlashResponse = {
|
|
4205
|
+
response_type: "ephemeral";
|
|
4206
|
+
text: string;
|
|
4207
|
+
unfurl_links: false;
|
|
4208
|
+
unfurl_media: false;
|
|
4209
|
+
blocks?: SlackMessageBlock[];
|
|
4210
|
+
};
|
|
4211
|
+
|
|
4212
|
+
/**
|
|
4213
|
+
* The ephemeral `<command> info` card.
|
|
4214
|
+
*
|
|
4215
|
+
* It is a projection of what the caller can already do, not an action: no
|
|
4216
|
+
* session, no durable inbox row, no provider post. It re-proves the exact
|
|
4217
|
+
* Slack identity link plus live workspace grants before any
|
|
4218
|
+
* workspace-identifying text is echoed, so an unlinked or access-revoked
|
|
4219
|
+
* identity receives the ordinary connect view instead.
|
|
4220
|
+
*/
|
|
4221
|
+
async function slackInfoCommandResponse(
|
|
4222
|
+
deps: ApiRouteDeps,
|
|
4223
|
+
installation: SlackInstallationRoute,
|
|
4224
|
+
entry: NormalizedSlackInteraction,
|
|
4225
|
+
): Promise<SlackSlashResponse> {
|
|
4226
|
+
const identity = {
|
|
4227
|
+
workspaceId: installation.workspaceId,
|
|
4228
|
+
connectionId: installation.connectionId,
|
|
4229
|
+
slackTeamId: entry.slackTeamId,
|
|
4230
|
+
slackUserId: entry.slackUserId,
|
|
4231
|
+
};
|
|
4232
|
+
const link = await getSlackBotUserLink(
|
|
4233
|
+
deps.db,
|
|
4234
|
+
installation.workspaceId,
|
|
4235
|
+
installation.connectionId,
|
|
4236
|
+
entry.slackUserId,
|
|
4237
|
+
);
|
|
4238
|
+
const grant = link
|
|
4239
|
+
? await getWorkspaceGrant(deps.db, link.subjectId, installation.workspaceId, {
|
|
4240
|
+
principalKind: "human_session",
|
|
4241
|
+
})
|
|
4242
|
+
: null;
|
|
4243
|
+
if (
|
|
4244
|
+
!grant ||
|
|
4245
|
+
grant.accountId !== installation.accountId ||
|
|
4246
|
+
!hasPermission(grant.permissions, "sessions:read")
|
|
4247
|
+
) {
|
|
4248
|
+
return ephemeralSlackResponse(
|
|
4249
|
+
link
|
|
4250
|
+
? `Your Slack identity is linked, but it does not currently have access to this OpenGeni workspace. Request access: ${linkUrl(deps, identity)}. No session was created.`
|
|
4251
|
+
: `Link your Slack identity to OpenGeni before starting work: ${linkUrl(deps, identity)}. No session was created.`,
|
|
4252
|
+
);
|
|
4253
|
+
}
|
|
4254
|
+
const workspace = await getWorkspace(deps.db, installation.workspaceId);
|
|
4255
|
+
const command = deps.settings.slackCommand;
|
|
4256
|
+
const botMention = slackBotMention(installation.botUserId);
|
|
4257
|
+
// Every line is gated on the grant that actually authorizes it, so the card
|
|
4258
|
+
// stays a projection of what this caller can do rather than a generic manual.
|
|
4259
|
+
const canControl = hasPermission(grant.permissions, "sessions:control");
|
|
4260
|
+
const canCreate = hasPermission(grant.permissions, "sessions:create");
|
|
4261
|
+
const schedules = hasPermission(grant.permissions, "scheduled_tasks:manage")
|
|
4262
|
+
? slackSchedulesUrl(deps, installation.workspaceId)
|
|
4263
|
+
: null;
|
|
4264
|
+
const workspaceUrl = slackWorkspaceUrl(deps, installation.workspaceId);
|
|
4265
|
+
const workspaceName = (workspace?.name ?? "").trim().slice(0, 120);
|
|
4266
|
+
const destination = workspaceName
|
|
4267
|
+
? `the *${escapeSlackMrkdwn(workspaceName)}* workspace`
|
|
4268
|
+
: "your OpenGeni workspace";
|
|
4269
|
+
const lines = [
|
|
4270
|
+
"*Working with OpenGeni in Slack*",
|
|
4271
|
+
"",
|
|
4272
|
+
...(canControl
|
|
4273
|
+
? [
|
|
4274
|
+
"• *Continue a task:* reply in its Slack thread.",
|
|
4275
|
+
"• *Stop a task:* press *Stop* on its card, or reply `stop` in its thread.",
|
|
4276
|
+
]
|
|
4277
|
+
: []),
|
|
4278
|
+
...(canCreate
|
|
4279
|
+
? [
|
|
4280
|
+
`• *Start a new task:* mention ${botMention} in a channel, run \`${command} <task>\`, or send a new top-level direct message.`,
|
|
4281
|
+
]
|
|
4282
|
+
: []),
|
|
4283
|
+
...(schedules
|
|
4284
|
+
? [
|
|
4285
|
+
`• *Make a result recurring:* press *Status* on the task card and open *Make recurring*, or open <${schedules}|Schedules>.`,
|
|
4286
|
+
]
|
|
4287
|
+
: []),
|
|
4288
|
+
`• *Where work lands:* ${destination}${workspaceUrl ? ` (<${workspaceUrl}|open OpenGeni>)` : ""}.`,
|
|
4289
|
+
];
|
|
4290
|
+
const text = lines.join("\n");
|
|
4291
|
+
return ephemeralSlackResponse(text, [
|
|
4292
|
+
{ type: "section", text: { type: "mrkdwn", text } },
|
|
4293
|
+
] as SlackMessageBlock[]);
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
function ephemeralSlackResponse(text: string, blocks?: SlackMessageBlock[]): SlackSlashResponse {
|
|
4297
|
+
return {
|
|
4298
|
+
response_type: "ephemeral",
|
|
4299
|
+
text,
|
|
4300
|
+
unfurl_links: false,
|
|
4301
|
+
unfurl_media: false,
|
|
4302
|
+
...(blocks ? { blocks } : {}),
|
|
4303
|
+
};
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
function slackBotMention(botUserId: string | null): string {
|
|
4307
|
+
return botUserId && /^[UWB][A-Z0-9]{1,63}$/.test(botUserId) ? `<@${botUserId}>` : "@OpenGeni";
|
|
4308
|
+
}
|
|
4309
|
+
|
|
3334
4310
|
function openSessionText(deps: ApiRouteDeps, workspaceId: string, sessionId: string) {
|
|
3335
4311
|
const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
|
|
3336
4312
|
if (!base) throw new Error("Slack session acknowledgement requires an absolute web base URL");
|
|
@@ -3338,15 +4314,95 @@ function openSessionText(deps: ApiRouteDeps, workspaceId: string, sessionId: str
|
|
|
3338
4314
|
return `<${url}|Open in OpenGeni>`;
|
|
3339
4315
|
}
|
|
3340
4316
|
|
|
3341
|
-
function
|
|
4317
|
+
function slackWorkspaceUrl(deps: ApiRouteDeps, workspaceId: string): string | null {
|
|
4318
|
+
return safeSlackActionUrl(deps, `/workspaces/${encodeURIComponent(workspaceId)}`);
|
|
4319
|
+
}
|
|
4320
|
+
|
|
4321
|
+
function slackSessionAuthorizationScopeKey(scope: SessionAuthorizationListScope | null): string {
|
|
4322
|
+
if (scope === null) return "standalone";
|
|
4323
|
+
if (scope.kind === "all") return "all";
|
|
4324
|
+
return JSON.stringify({
|
|
4325
|
+
kind: scope.kind,
|
|
4326
|
+
rootSessionIds: [...scope.rootSessionIds].sort(),
|
|
4327
|
+
sessionIds: [...scope.sessionIds].sort(),
|
|
4328
|
+
});
|
|
4329
|
+
}
|
|
4330
|
+
|
|
4331
|
+
function slackSessionUrl(
|
|
4332
|
+
deps: ApiRouteDeps,
|
|
4333
|
+
workspaceId: string,
|
|
4334
|
+
sessionId: string,
|
|
4335
|
+
): string | null {
|
|
4336
|
+
return safeSlackActionUrl(
|
|
4337
|
+
deps,
|
|
4338
|
+
`/workspaces/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`,
|
|
4339
|
+
);
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
function safeSlackActionUrl(deps: ApiRouteDeps, pathname: string): string | null {
|
|
3342
4343
|
const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
|
|
3343
|
-
if (!base)
|
|
3344
|
-
|
|
4344
|
+
if (!base) return null;
|
|
4345
|
+
try {
|
|
4346
|
+
const url = new URL(pathname, base);
|
|
4347
|
+
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) {
|
|
4348
|
+
return null;
|
|
4349
|
+
}
|
|
4350
|
+
return url.toString();
|
|
4351
|
+
} catch {
|
|
4352
|
+
return null;
|
|
4353
|
+
}
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
function slackAppHomeLinkUrl(
|
|
4357
|
+
deps: ApiRouteDeps,
|
|
4358
|
+
installation: SlackInstallationRoute,
|
|
4359
|
+
slackTeamId: string,
|
|
4360
|
+
slackUserId: string,
|
|
4361
|
+
): string | null {
|
|
4362
|
+
const signingSecret = deps.settings.slackSigningSecret;
|
|
4363
|
+
const base = slackWorkspaceUrl(deps, installation.workspaceId);
|
|
4364
|
+
if (!signingSecret || !base) return base;
|
|
4365
|
+
const url = new URL(
|
|
4366
|
+
`/workspaces/${encodeURIComponent(installation.workspaceId)}/capabilities`,
|
|
4367
|
+
base,
|
|
4368
|
+
);
|
|
4369
|
+
url.hash = new URLSearchParams({
|
|
4370
|
+
slack_link: createSlackUserLinkToken(signingSecret, {
|
|
4371
|
+
workspaceId: installation.workspaceId,
|
|
4372
|
+
connectionId: installation.connectionId,
|
|
4373
|
+
slackTeamId,
|
|
4374
|
+
slackUserId,
|
|
4375
|
+
}),
|
|
4376
|
+
}).toString();
|
|
4377
|
+
return url.toString();
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
function makeRecurringText(
|
|
4381
|
+
deps: ApiRouteDeps,
|
|
4382
|
+
workspaceId: string,
|
|
4383
|
+
sessionId: string,
|
|
4384
|
+
): string | null {
|
|
4385
|
+
// Unchanged deep-link contract: the schedules editor plus the exact source
|
|
4386
|
+
// session UUID, and nothing copied out of Slack. A deployment without an
|
|
4387
|
+
// absolute web base URL simply omits the action instead of failing a card.
|
|
4388
|
+
const base = slackSchedulesUrl(deps, workspaceId);
|
|
4389
|
+
if (!base) return null;
|
|
4390
|
+
const url = new URL(base);
|
|
3345
4391
|
url.searchParams.set("sourceSessionId", sessionId);
|
|
3346
4392
|
return `<${url.toString()}|Make recurring>`;
|
|
3347
4393
|
}
|
|
3348
4394
|
|
|
3349
|
-
function
|
|
4395
|
+
function slackSchedulesUrl(deps: ApiRouteDeps, workspaceId: string): string | null {
|
|
4396
|
+
return safeSlackActionUrl(deps, `/workspaces/${encodeURIComponent(workspaceId)}/schedules`);
|
|
4397
|
+
}
|
|
4398
|
+
|
|
4399
|
+
function linkUrl(
|
|
4400
|
+
deps: ApiRouteDeps,
|
|
4401
|
+
entry: Pick<
|
|
4402
|
+
SlackInteractionInboxEntry,
|
|
4403
|
+
"workspaceId" | "connectionId" | "slackTeamId" | "slackUserId"
|
|
4404
|
+
>,
|
|
4405
|
+
) {
|
|
3350
4406
|
const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
|
|
3351
4407
|
const signingSecret = deps.settings.slackSigningSecret;
|
|
3352
4408
|
if (!base || !signingSecret) return "OpenGeni Settings → Integrations → Slack";
|
|
@@ -3519,19 +4575,20 @@ function boundedText(value: unknown): string | null {
|
|
|
3519
4575
|
return trimmed.slice(0, MAX_SLACK_INPUT_CHARS);
|
|
3520
4576
|
}
|
|
3521
4577
|
|
|
4578
|
+
/** Single-line, character-bounded preview for a blocked-worker pointer card. */
|
|
4579
|
+
function boundedSlackChildDetail(value: string) {
|
|
4580
|
+
const collapsed = value.replace(/\s+/gu, " ").trim();
|
|
4581
|
+
return collapsed.length <= MAX_SLACK_CHILD_DETAIL_CHARS
|
|
4582
|
+
? collapsed
|
|
4583
|
+
: `${collapsed.slice(0, MAX_SLACK_CHILD_DETAIL_CHARS - 1)}…`;
|
|
4584
|
+
}
|
|
4585
|
+
|
|
3522
4586
|
function boundedOutput(value: string) {
|
|
3523
4587
|
return value.length <= MAX_SLACK_TEXT_CHARS
|
|
3524
4588
|
? value
|
|
3525
4589
|
: `${value.slice(0, MAX_SLACK_TEXT_CHARS - 20)}\n… output truncated`;
|
|
3526
4590
|
}
|
|
3527
4591
|
|
|
3528
|
-
function boundedOutputWithSuffix(value: string, suffix: string) {
|
|
3529
|
-
const maxValueChars = Math.max(0, MAX_SLACK_TEXT_CHARS - suffix.length);
|
|
3530
|
-
if (value.length <= maxValueChars) return `${value}${suffix}`;
|
|
3531
|
-
const truncation = "\n… output truncated";
|
|
3532
|
-
return `${value.slice(0, Math.max(0, maxValueChars - truncation.length))}${truncation}${suffix}`;
|
|
3533
|
-
}
|
|
3534
|
-
|
|
3535
4592
|
function safePayloadText(payload: unknown, field: string) {
|
|
3536
4593
|
const value = record(payload)?.[field];
|
|
3537
4594
|
return typeof value === "string" ? boundedOutput(value) : "";
|