@opengeni/api-router 0.30.3 → 2.2.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-3TP54PPX.js} +26692 -15389
- package/dist/chunk-3TP54PPX.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 +157 -9
- package/dist/integrations/slack-interactions.d.ts +39 -5
- 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 +161 -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 +342 -57
- package/src/integrations/slack-interactions.ts +1898 -259
- package/src/integrations/slack-routing.ts +337 -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 +353 -4
- 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,14 +7,20 @@ import {
|
|
|
7
7
|
ListSlackUserLinkAccessRequestsResponse,
|
|
8
8
|
PrepareSlackUserLinkAccessRequest,
|
|
9
9
|
evaluateSlackTaskPolicy,
|
|
10
|
+
resolveWorkspaceSlackOrchestrationNoticeSettings,
|
|
10
11
|
resolveWorkspaceSlackReactionSummonSettings,
|
|
12
|
+
SlackChannelRouteListResponse,
|
|
11
13
|
SlackReactionChannelListResponse,
|
|
14
|
+
UpdateSlackChannelRoutesRequest,
|
|
12
15
|
SlackUserLinkAccessMutationRequest,
|
|
13
16
|
SlackUserLinkAccessRequest,
|
|
14
17
|
type AccessGrant,
|
|
15
18
|
type FileResourceRef,
|
|
16
19
|
type FirstPartyMcpToolName,
|
|
17
20
|
type HumanInputQuestion,
|
|
21
|
+
type ResolvedWorkspaceSlackOrchestrationNoticeSettings,
|
|
22
|
+
type ChildRequiresActionPayload,
|
|
23
|
+
type SessionAuthorizationListScope,
|
|
18
24
|
type SessionEvent,
|
|
19
25
|
type WorkspaceSlackReactionSummonSettings,
|
|
20
26
|
workspaceSlackReactionChannelAllowed,
|
|
@@ -31,13 +37,16 @@ import {
|
|
|
31
37
|
advanceSlackInteractionDelivery,
|
|
32
38
|
bindSlackInteractionSession,
|
|
33
39
|
cancelSlackUserLinkAccessRequest,
|
|
40
|
+
claimSlackAppHomeRefresh,
|
|
34
41
|
claimSlackInteractionDelivery,
|
|
35
42
|
claimSlackInteractionProgressDelivery,
|
|
36
43
|
claimSlackInteractionInbox,
|
|
37
44
|
closeSlackInteractionDelivery,
|
|
38
45
|
completeSlackUserLinkAccessIfGranted,
|
|
46
|
+
decodeSessionListCursor,
|
|
39
47
|
deferSlackInteractionDelivery,
|
|
40
48
|
deleteSlackBotUserLink,
|
|
49
|
+
enqueueSlackAppHomeRefresh,
|
|
41
50
|
enqueueSlackInteractionInbox,
|
|
42
51
|
getConnectionMetadata,
|
|
43
52
|
getOrCreateSlackInteraction,
|
|
@@ -45,36 +54,61 @@ import {
|
|
|
45
54
|
getSession,
|
|
46
55
|
getSessionEvent,
|
|
47
56
|
getSessionHumanInputRequest,
|
|
57
|
+
childRequiresActionResolutionExists,
|
|
58
|
+
getSessionSystemUpdateById,
|
|
48
59
|
getSlackBotPostOperation,
|
|
49
60
|
getSlackBotUserLink,
|
|
50
61
|
getSlackInteractionActionHandle,
|
|
51
62
|
getSlackInteractionByClientEventId,
|
|
52
63
|
getSlackInteractionById,
|
|
53
|
-
|
|
64
|
+
getSlackInteractionByConnectionRoute,
|
|
65
|
+
probeSlackActionHandleTenancy,
|
|
66
|
+
openSlackRoutePrompt,
|
|
67
|
+
getSlackRoutePromptByOption,
|
|
68
|
+
settleSlackRoutePrompt,
|
|
69
|
+
answerSlackRoutePrompt,
|
|
70
|
+
type SlackRoutableWorkspace,
|
|
71
|
+
getSlackChannelRoute,
|
|
72
|
+
listSlackChannelRoutes,
|
|
73
|
+
upsertSlackChannelRoute,
|
|
74
|
+
deleteSlackChannelRoute,
|
|
75
|
+
getSlackUserDmRoute,
|
|
76
|
+
listNamedSubjectSlackRoutableWorkspaces,
|
|
54
77
|
getActiveSlackTaskPolicy,
|
|
55
78
|
getSlackSharedTaskOrigin,
|
|
56
79
|
getSessionEventByClientEventId,
|
|
57
80
|
getWorkspace,
|
|
58
81
|
getWorkspaceGrant,
|
|
82
|
+
namedSubjectPersonalWorkspaceId,
|
|
83
|
+
resolveSlackTargetAuthority,
|
|
59
84
|
listSlackInteractionProgressDeliveryEvidence,
|
|
60
85
|
listSessionEventPage,
|
|
61
86
|
listSessionHumanInputRequests,
|
|
62
87
|
listPendingSlackUserLinkAccessRequests,
|
|
88
|
+
listSlackInstallationBindings,
|
|
89
|
+
slackUserLinkAccessRequestTeam,
|
|
63
90
|
rekeySlackInteractionRoute,
|
|
64
91
|
reopenSlackInteractionDelivery,
|
|
65
92
|
reserveSlackInteractionActionHandles,
|
|
93
|
+
renewSlackAppHomeRefreshClaim,
|
|
66
94
|
releaseSlackInteractionDelivery,
|
|
95
|
+
releaseSlackAppHomeRefresh,
|
|
67
96
|
releaseSlackInteractionInbox,
|
|
68
97
|
requestSlackUserLinkWorkspaceAccess,
|
|
69
98
|
resolveSlackInstallationRoute,
|
|
99
|
+
resolveSlackInteractionFirstTaskHint,
|
|
70
100
|
saveSlackInteractionInboxReactionCheckpoint,
|
|
71
101
|
saveSlackSharedTaskOrigin,
|
|
72
102
|
settleSlackInteractionInbox,
|
|
103
|
+
settleSlackAppHomeRefresh,
|
|
73
104
|
settleSlackInteractionActionHandles,
|
|
105
|
+
listSessionsForSubject,
|
|
106
|
+
SessionListAccessError,
|
|
74
107
|
denySlackUserLinkAccessRequest,
|
|
75
108
|
prepareSlackUserLinkAccessRequest,
|
|
76
109
|
SlackUserLinkAccessPersistenceError,
|
|
77
110
|
type SlackInstallationRoute,
|
|
111
|
+
type SlackAppHomeRefresh,
|
|
78
112
|
type SlackInteraction,
|
|
79
113
|
type SlackInteractionActionHandle,
|
|
80
114
|
type SlackInteractionActionKind,
|
|
@@ -88,6 +122,7 @@ import {
|
|
|
88
122
|
hasPermission,
|
|
89
123
|
requireAccessContext,
|
|
90
124
|
requireAccessGrant,
|
|
125
|
+
requireSessionAuthorizationListScope,
|
|
91
126
|
type ApiRouteDeps,
|
|
92
127
|
} from "@opengeni/core";
|
|
93
128
|
import { publishDurableSessionEvents } from "@opengeni/events";
|
|
@@ -100,6 +135,20 @@ import {
|
|
|
100
135
|
type SlackMessageBlock,
|
|
101
136
|
SlackBotProviderError,
|
|
102
137
|
} from "./slack-bot";
|
|
138
|
+
import {
|
|
139
|
+
isSlackDirectMessageConversation,
|
|
140
|
+
resolveSlackWorkspaceRoute,
|
|
141
|
+
slackRoutedRequestText,
|
|
142
|
+
type SlackRouteResolution,
|
|
143
|
+
type SlackRouteTenancy,
|
|
144
|
+
} from "./slack-routing";
|
|
145
|
+
import {
|
|
146
|
+
buildSlackAppHomeAccessBlocks,
|
|
147
|
+
buildSlackAppHomeBlocks,
|
|
148
|
+
escapeSlackMrkdwn,
|
|
149
|
+
isSlackAppHomeLinkAction,
|
|
150
|
+
slackAppHomeOpenedEvent,
|
|
151
|
+
} from "./slack-app-home";
|
|
103
152
|
import { importSlackReactionImage, type ImportedSlackReactionImage } from "../slack-reaction-files";
|
|
104
153
|
|
|
105
154
|
export const SLACK_INTERACTION_MAX_BODY_BYTES = 256 * 1024;
|
|
@@ -112,6 +161,13 @@ export const SLACK_DELIVERY_EVENT_TYPES = [
|
|
|
112
161
|
"turn.failed",
|
|
113
162
|
"turn.cancelled",
|
|
114
163
|
"session.status.changed",
|
|
164
|
+
// Orchestration surfacing. Both are filtered again inside the pump: the
|
|
165
|
+
// workspace must have opted in (both notices default off), and then only a
|
|
166
|
+
// `child_requires_action` notice and a `limits` / `max_auto_continuations`
|
|
167
|
+
// goal pause reach Slack, so the thread stays quiet for the deferred child
|
|
168
|
+
// lifecycle kinds and for human/API/agent pauses the human already made.
|
|
169
|
+
"system.update.pending",
|
|
170
|
+
"goal.paused",
|
|
115
171
|
] as const;
|
|
116
172
|
|
|
117
173
|
const MAX_SLACK_TEXT_CHARS = 3_500;
|
|
@@ -125,6 +181,7 @@ const MAX_SLACK_REACTION_IMAGE_AGGREGATE_BYTES = 16 * 1024 * 1024;
|
|
|
125
181
|
const MAX_PROGRESS_MESSAGES = 3;
|
|
126
182
|
const SLACK_USER_LINK_TTL_MS = 15 * 60_000;
|
|
127
183
|
const INBOX_LEASE_MS = 30_000;
|
|
184
|
+
const APP_HOME_LEASE_MS = 300_000;
|
|
128
185
|
const DELIVERY_LEASE_MS = 30_000;
|
|
129
186
|
const MAX_DELIVERY_ATTEMPTS = 8;
|
|
130
187
|
const MAX_DELIVERY_RETRY_MS = 5 * 60_000;
|
|
@@ -132,6 +189,23 @@ const SLACK_INTERACTION_BOT_SUBJECT_ID = "service:slack-interaction";
|
|
|
132
189
|
const SLACK_ACTION_TTL_MS = 7 * 24 * 60 * 60_000;
|
|
133
190
|
const MAX_SLACK_APPROVALS_PER_CARD = 8;
|
|
134
191
|
const MAX_SLACK_ACTIONS_PER_CARD = 20;
|
|
192
|
+
/** Blocked-worker cards are pointers, so the question preview stays short. */
|
|
193
|
+
const MAX_SLACK_CHILD_DETAIL_CHARS = 240;
|
|
194
|
+
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;
|
|
195
|
+
/**
|
|
196
|
+
* Goal pause reasons the Slack thread announces. `user_pause`, `api`, `agent`,
|
|
197
|
+
* and `no_progress` are deliberately absent.
|
|
198
|
+
*
|
|
199
|
+
* A Map, not a plain object: this lookup is the sole gate of a two-reason
|
|
200
|
+
* invariant, and a plain object indexed by a payload-derived string answers
|
|
201
|
+
* `constructor` (and every other prototype key) with a truthy value that would
|
|
202
|
+
* sail past the `if (!headline)` guard. No payload reaches it with such a
|
|
203
|
+
* reason today; the gate should not depend on that staying true.
|
|
204
|
+
*/
|
|
205
|
+
const SLACK_GOAL_PAUSED_HEADLINES = new Map<string, string>([
|
|
206
|
+
["limits", "Goal paused (budget)"],
|
|
207
|
+
["max_auto_continuations", "Goal paused (continuation cap)"],
|
|
208
|
+
]);
|
|
135
209
|
/**
|
|
136
210
|
* Slack delivery restrictions are durable session-level authority, not
|
|
137
211
|
* attacker-adjacent user-message context. Migration 0240 backfills this exact
|
|
@@ -340,6 +414,46 @@ export function slackReactionInboxEntry(
|
|
|
340
414
|
};
|
|
341
415
|
}
|
|
342
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Prove a Slack link token may name this workspace.
|
|
419
|
+
*
|
|
420
|
+
* Before routing, a token could only ever name the installation's own
|
|
421
|
+
* workspace, and both intent routes asserted exactly that. A routed
|
|
422
|
+
* conversation asks for access to the workspace the work would actually land
|
|
423
|
+
* in, which is a different one, so the assertion becomes: the installation
|
|
424
|
+
* binding resolves for the token's team, it is the same connection the token
|
|
425
|
+
* was minted against, and the named workspace belongs to that installation's
|
|
426
|
+
* organization.
|
|
427
|
+
*
|
|
428
|
+
* That is the same fence the route tables carry. It does not widen access by
|
|
429
|
+
* itself: this only decides which workspace an access REQUEST may be raised
|
|
430
|
+
* for, and the request still goes through the ordinary approval path.
|
|
431
|
+
*/
|
|
432
|
+
async function resolveSlackLinkTargetInstallation(
|
|
433
|
+
deps: ApiRouteDeps,
|
|
434
|
+
link: { slackTeamId: string; connectionId: string },
|
|
435
|
+
workspaceId: string,
|
|
436
|
+
): Promise<{
|
|
437
|
+
accountId: string;
|
|
438
|
+
connectionId: string;
|
|
439
|
+
workspaceName: string;
|
|
440
|
+
/** Where the identity link lives, which is not the routed workspace. */
|
|
441
|
+
identityHome: { accountId: string; workspaceId: string };
|
|
442
|
+
} | null> {
|
|
443
|
+
const route = await resolveSlackInstallationRoute(deps.db, link.slackTeamId);
|
|
444
|
+
if (!route || route.connectionId !== link.connectionId || route.accountId.length === 0) {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
448
|
+
if (!workspace || workspace.accountId !== route.accountId) return null;
|
|
449
|
+
return {
|
|
450
|
+
accountId: route.accountId,
|
|
451
|
+
connectionId: route.connectionId,
|
|
452
|
+
workspaceName: workspace.name,
|
|
453
|
+
identityHome: { accountId: route.accountId, workspaceId: route.workspaceId },
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
343
457
|
export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
344
458
|
app.post("/v1/integrations/slack/events", async (c) => {
|
|
345
459
|
const signed = await readSignedSlackRequest(c, deps);
|
|
@@ -357,6 +471,19 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
357
471
|
message: "Slack installation unavailable",
|
|
358
472
|
});
|
|
359
473
|
const event = record(payload.event);
|
|
474
|
+
const appHomeEvent = slackAppHomeOpenedEvent(payload);
|
|
475
|
+
if (appHomeEvent) {
|
|
476
|
+
await enqueueSlackAppHomeRefresh(deps.db, {
|
|
477
|
+
accountId: installation.accountId,
|
|
478
|
+
workspaceId: installation.workspaceId,
|
|
479
|
+
connectionId: installation.connectionId,
|
|
480
|
+
slackTeamId: appHomeEvent.slackTeamId,
|
|
481
|
+
slackUserId: appHomeEvent.slackUserId,
|
|
482
|
+
providerEventId: appHomeEvent.eventId,
|
|
483
|
+
providerViewHash: appHomeEvent.viewHash,
|
|
484
|
+
});
|
|
485
|
+
return c.json({ ok: true });
|
|
486
|
+
}
|
|
360
487
|
if (event?.type === "reaction_added") {
|
|
361
488
|
const [workspace, connection] = await Promise.all([
|
|
362
489
|
getWorkspace(deps.db, installation.workspaceId),
|
|
@@ -383,7 +510,7 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
383
510
|
app.post("/v1/integrations/slack/commands", async (c) => {
|
|
384
511
|
const signed = await readSignedSlackRequest(c, deps);
|
|
385
512
|
const form = new URLSearchParams(signed.rawBody);
|
|
386
|
-
if (form.get("command") !==
|
|
513
|
+
if (form.get("command") !== deps.settings.slackCommand) {
|
|
387
514
|
throw new HTTPException(400, { message: "invalid Slack command" });
|
|
388
515
|
}
|
|
389
516
|
const entry = normalizedFormInteraction(form, "slash_command");
|
|
@@ -392,6 +519,12 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
392
519
|
throw new HTTPException(403, {
|
|
393
520
|
message: "Slack installation unavailable",
|
|
394
521
|
});
|
|
522
|
+
// `<command> info` is a read-only ephemeral explainer. It never reaches the
|
|
523
|
+
// durable inbox, never verifies channel membership, and never creates a
|
|
524
|
+
// session, so it is safe anywhere the command is available.
|
|
525
|
+
if (isSlackInfoCommand(entry.text)) {
|
|
526
|
+
return c.json(await slackInfoCommandResponse(deps, installation, entry));
|
|
527
|
+
}
|
|
395
528
|
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
396
529
|
accountId: installation.accountId,
|
|
397
530
|
workspaceId: installation.workspaceId,
|
|
@@ -424,6 +557,7 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
424
557
|
const form = new URLSearchParams(signed.rawBody);
|
|
425
558
|
const payload = parseJsonObject(form.get("payload") ?? "");
|
|
426
559
|
if (payload.type === "block_actions") {
|
|
560
|
+
if (isSlackAppHomeLinkAction(payload)) return c.json({ ok: true });
|
|
427
561
|
const entry = normalizedBlockActionInteraction(payload);
|
|
428
562
|
const installation = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
|
|
429
563
|
if (!installation) {
|
|
@@ -482,22 +616,13 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
482
616
|
if (!link || link.workspaceId !== workspaceId) {
|
|
483
617
|
throw freshSlackLinkRequired();
|
|
484
618
|
}
|
|
485
|
-
const
|
|
486
|
-
if (
|
|
487
|
-
!route ||
|
|
488
|
-
route.workspaceId !== workspaceId ||
|
|
489
|
-
route.connectionId !== link.connectionId ||
|
|
490
|
-
route.accountId.length === 0
|
|
491
|
-
) {
|
|
492
|
-
throw freshSlackLinkRequired();
|
|
493
|
-
}
|
|
494
|
-
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
495
|
-
if (!workspace || workspace.accountId !== route.accountId) {
|
|
619
|
+
const installation = await resolveSlackLinkTargetInstallation(deps, link, workspaceId);
|
|
620
|
+
if (!installation) {
|
|
496
621
|
throw freshSlackLinkRequired();
|
|
497
622
|
}
|
|
498
623
|
try {
|
|
499
624
|
const prepared = await prepareSlackUserLinkAccessRequest(deps.db, {
|
|
500
|
-
accountId:
|
|
625
|
+
accountId: installation.accountId,
|
|
501
626
|
workspaceId,
|
|
502
627
|
tokenDigest: createHash("sha256").update(payload.linkToken).digest("hex"),
|
|
503
628
|
connectionId: link.connectionId,
|
|
@@ -511,12 +636,13 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
511
636
|
workspaceId,
|
|
512
637
|
requestId: prepared.id,
|
|
513
638
|
subjectId: context.subjectId,
|
|
639
|
+
identityHome: installation.identityHome,
|
|
514
640
|
});
|
|
515
641
|
if (!completed) throw freshSlackLinkRequired();
|
|
516
642
|
return c.json(
|
|
517
643
|
SlackUserLinkAccessRequest.parse({
|
|
518
644
|
...completed,
|
|
519
|
-
workspaceDisplayName:
|
|
645
|
+
workspaceDisplayName: installation.workspaceName,
|
|
520
646
|
}),
|
|
521
647
|
201,
|
|
522
648
|
);
|
|
@@ -624,14 +750,33 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
624
750
|
const workspaceId = c.req.param("workspaceId");
|
|
625
751
|
const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
|
|
626
752
|
const payload = ApproveSlackUserLinkAccessRequest.parse(await c.req.json());
|
|
753
|
+
// The identity link belongs to the installation, which for a routed
|
|
754
|
+
// request is a different workspace from the one being granted. The
|
|
755
|
+
// request row names its Slack team, so the binding resolves from it.
|
|
756
|
+
const requestId = c.req.param("requestId");
|
|
757
|
+
const requestTeam = await slackUserLinkAccessRequestTeam(deps.db, {
|
|
758
|
+
workspaceId,
|
|
759
|
+
requestId,
|
|
760
|
+
});
|
|
761
|
+
const installation = requestTeam
|
|
762
|
+
? await resolveSlackInstallationRoute(deps.db, requestTeam.slackTeamId)
|
|
763
|
+
: null;
|
|
627
764
|
try {
|
|
628
765
|
const request = await approveSlackUserLinkAccessRequest(deps.db, {
|
|
629
766
|
workspaceId,
|
|
630
|
-
requestId
|
|
767
|
+
requestId,
|
|
631
768
|
actorSubjectId: grant.subjectId,
|
|
632
769
|
expectedVersion: payload.expectedVersion,
|
|
633
770
|
idempotencyKey: payload.idempotencyKey,
|
|
634
771
|
permissions: payload.permissions,
|
|
772
|
+
...(installation
|
|
773
|
+
? {
|
|
774
|
+
identityHome: {
|
|
775
|
+
accountId: installation.accountId,
|
|
776
|
+
workspaceId: installation.workspaceId,
|
|
777
|
+
},
|
|
778
|
+
}
|
|
779
|
+
: {}),
|
|
635
780
|
...(payload.role !== undefined ? { role: payload.role } : {}),
|
|
636
781
|
});
|
|
637
782
|
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
@@ -690,8 +835,8 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
690
835
|
message: "invalid or expired Slack identity link",
|
|
691
836
|
});
|
|
692
837
|
}
|
|
693
|
-
const
|
|
694
|
-
if (!
|
|
838
|
+
const installation = await resolveSlackLinkTargetInstallation(deps, link, workspaceId);
|
|
839
|
+
if (!installation) {
|
|
695
840
|
throw new HTTPException(404, {
|
|
696
841
|
message: "Slack installation not found",
|
|
697
842
|
});
|
|
@@ -712,6 +857,7 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
712
857
|
workspaceId,
|
|
713
858
|
requestId: prepared.id,
|
|
714
859
|
subjectId: grant.subjectId,
|
|
860
|
+
identityHome: installation.identityHome,
|
|
715
861
|
});
|
|
716
862
|
if (completed?.status !== "completed") throw freshSlackLinkRequired();
|
|
717
863
|
const saved = await getSlackBotUserLink(
|
|
@@ -745,6 +891,135 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
745
891
|
},
|
|
746
892
|
);
|
|
747
893
|
|
|
894
|
+
app.get("/v1/workspaces/:workspaceId/integrations/slack/channel-routes", async (c) => {
|
|
895
|
+
const workspaceId = c.req.param("workspaceId");
|
|
896
|
+
// Same gate as the reaction-channel allowlist beside it. The response names
|
|
897
|
+
// workspaces across the organization, including ones the reader may hold no
|
|
898
|
+
// grant in, so it is not an ordinary connections read.
|
|
899
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
900
|
+
const connectionId = boundedString(c.req.query("connectionId"), 64);
|
|
901
|
+
if (!connectionId) throw new HTTPException(400, { message: "connectionId is required" });
|
|
902
|
+
const routes = await listSlackChannelRoutes(
|
|
903
|
+
deps.db,
|
|
904
|
+
{ accountId: grant.accountId, workspaceId },
|
|
905
|
+
{ connectionId },
|
|
906
|
+
);
|
|
907
|
+
// Names are resolved once per distinct target rather than per route.
|
|
908
|
+
const names = new Map<string, string | null>();
|
|
909
|
+
for (const route of routes) {
|
|
910
|
+
if (names.has(route.targetWorkspaceId)) continue;
|
|
911
|
+
names.set(
|
|
912
|
+
route.targetWorkspaceId,
|
|
913
|
+
(await getWorkspace(deps.db, route.targetWorkspaceId))?.name ?? null,
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
return c.json(
|
|
917
|
+
SlackChannelRouteListResponse.parse({
|
|
918
|
+
routingEnabled: deps.settings.slackWorkspaceRoutingEnabled,
|
|
919
|
+
routes: routes.map((route) => ({
|
|
920
|
+
slackChannelId: route.slackChannelId,
|
|
921
|
+
targetWorkspaceId: route.targetWorkspaceId,
|
|
922
|
+
targetWorkspaceName: names.get(route.targetWorkspaceId) ?? null,
|
|
923
|
+
source: route.source,
|
|
924
|
+
updatedAt: route.updatedAt.toISOString(),
|
|
925
|
+
})),
|
|
926
|
+
}),
|
|
927
|
+
);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
app.put("/v1/workspaces/:workspaceId/integrations/slack/channel-routes", async (c) => {
|
|
931
|
+
const workspaceId = c.req.param("workspaceId");
|
|
932
|
+
// Same gate as the reaction-channel allowlist this sits beside: pointing a
|
|
933
|
+
// channel somewhere is an installation-administration act.
|
|
934
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
935
|
+
const payload = UpdateSlackChannelRoutesRequest.parse(await c.req.json());
|
|
936
|
+
const home = { accountId: grant.accountId, workspaceId };
|
|
937
|
+
const installation = await listSlackInstallationBindings(deps.db, home);
|
|
938
|
+
const binding = installation.find(
|
|
939
|
+
(candidate) => candidate.connectionId === payload.connectionId,
|
|
940
|
+
);
|
|
941
|
+
// The connection must be this workspace's own installation, and the route
|
|
942
|
+
// rows carry its team id, so a missing binding is a refusal rather than a
|
|
943
|
+
// row written with an empty team the column's own bound would reject.
|
|
944
|
+
if (!binding) {
|
|
945
|
+
throw new HTTPException(404, { message: "Slack installation not found" });
|
|
946
|
+
}
|
|
947
|
+
// Authorize EVERY target before writing anything. Applying routes one at a
|
|
948
|
+
// time means a refusal partway through would otherwise leave some channels
|
|
949
|
+
// moved and the rest not, behind an error that says the save failed.
|
|
950
|
+
//
|
|
951
|
+
// An admin may only point a channel at a workspace they could start work in
|
|
952
|
+
// themselves, and only inside this organization. The table's own CHECK
|
|
953
|
+
// enforces the second half; this refuses before writing rather than
|
|
954
|
+
// surfacing a constraint error.
|
|
955
|
+
// A channel may never be pointed at a personal workspace. The routing
|
|
956
|
+
// resolver already refuses to treat one as a candidate in a channel, and
|
|
957
|
+
// this is the same rule at the write boundary, where a durable route would
|
|
958
|
+
// otherwise smuggle it back in: every message in that channel would land
|
|
959
|
+
// somewhere nobody else in the channel can see.
|
|
960
|
+
//
|
|
961
|
+
// Only the caller's OWN personal workspace has to be excluded, and that is
|
|
962
|
+
// the whole rule rather than a partial one, because no other person's can
|
|
963
|
+
// reach this loop. `resolveSlackTargetAuthority` admits exactly two shapes:
|
|
964
|
+
// a `workspace_memberships` row this subject holds, or this subject's own
|
|
965
|
+
// personal-workspace pointer. There is no account-admin union, so an
|
|
966
|
+
// administrator does not thereby reach anyone else's personal workspace.
|
|
967
|
+
//
|
|
968
|
+
// Deliberately NOT justified by "the schema forbids a membership row on a
|
|
969
|
+
// personal workspace". It does not. Migration 0219's `42501` is a
|
|
970
|
+
// precondition inside the provisioning function, checked at provisioning
|
|
971
|
+
// time only, and `personal_workspace_has_no_membership_row` is recorded as
|
|
972
|
+
// `basis: "runtime"` - the parity report's own term for a property nothing
|
|
973
|
+
// in the schema prevents. It holds because every membership writer either
|
|
974
|
+
// requires `members:manage` on the target (which a personal-workspace grant
|
|
975
|
+
// deliberately omits) or excludes personal workspaces outright.
|
|
976
|
+
const callerPersonalWorkspaceId = await namedSubjectPersonalWorkspaceId(deps.db, {
|
|
977
|
+
accountId: grant.accountId,
|
|
978
|
+
subjectId: grant.subjectId,
|
|
979
|
+
});
|
|
980
|
+
for (const route of payload.routes) {
|
|
981
|
+
if (route.targetWorkspaceId === null) continue;
|
|
982
|
+
const authorized = await resolveSlackTargetAuthority(deps.db, {
|
|
983
|
+
subjectId: grant.subjectId,
|
|
984
|
+
targetAccountId: grant.accountId,
|
|
985
|
+
targetWorkspaceId: route.targetWorkspaceId,
|
|
986
|
+
});
|
|
987
|
+
if (!authorized || !hasPermission(authorized.permissions, "sessions:create")) {
|
|
988
|
+
throw new HTTPException(403, {
|
|
989
|
+
message: `you cannot start work in the workspace chosen for ${route.slackChannelId}`,
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
if (
|
|
993
|
+
callerPersonalWorkspaceId !== null &&
|
|
994
|
+
route.targetWorkspaceId === callerPersonalWorkspaceId
|
|
995
|
+
) {
|
|
996
|
+
throw new HTTPException(422, {
|
|
997
|
+
message: `a channel cannot be routed to a personal workspace: work started in ${route.slackChannelId} would be invisible to everyone else in it`,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
for (const route of payload.routes) {
|
|
1002
|
+
if (route.targetWorkspaceId === null) {
|
|
1003
|
+
await deleteSlackChannelRoute(deps.db, home, {
|
|
1004
|
+
connectionId: payload.connectionId,
|
|
1005
|
+
slackChannelId: route.slackChannelId,
|
|
1006
|
+
});
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
await upsertSlackChannelRoute(deps.db, home, {
|
|
1010
|
+
connectionId: payload.connectionId,
|
|
1011
|
+
slackTeamId: binding.slackTeamId,
|
|
1012
|
+
slackChannelId: route.slackChannelId,
|
|
1013
|
+
targetAccountId: grant.accountId,
|
|
1014
|
+
targetWorkspaceId: route.targetWorkspaceId,
|
|
1015
|
+
decidedBySubjectId: grant.subjectId,
|
|
1016
|
+
decidedBySlackUserId: "admin",
|
|
1017
|
+
source: "admin",
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
return c.json({ ok: true });
|
|
1021
|
+
});
|
|
1022
|
+
|
|
748
1023
|
app.get("/v1/workspaces/:workspaceId/integrations/slack/reaction-channels", async (c) => {
|
|
749
1024
|
const workspaceId = c.req.param("workspaceId");
|
|
750
1025
|
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
@@ -783,7 +1058,285 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
783
1058
|
});
|
|
784
1059
|
}
|
|
785
1060
|
|
|
1061
|
+
async function publishSlackAppHome(
|
|
1062
|
+
deps: ApiRouteDeps,
|
|
1063
|
+
installation: SlackInstallationRoute,
|
|
1064
|
+
refresh: SlackAppHomeRefresh,
|
|
1065
|
+
renewLease: () => Promise<void>,
|
|
1066
|
+
): Promise<void> {
|
|
1067
|
+
const link = await getSlackBotUserLink(
|
|
1068
|
+
deps.db,
|
|
1069
|
+
installation.workspaceId,
|
|
1070
|
+
installation.connectionId,
|
|
1071
|
+
refresh.slackUserId,
|
|
1072
|
+
);
|
|
1073
|
+
const grant = link
|
|
1074
|
+
? await getWorkspaceGrant(deps.db, link.subjectId, installation.workspaceId, {
|
|
1075
|
+
principalKind: "human_session",
|
|
1076
|
+
})
|
|
1077
|
+
: null;
|
|
1078
|
+
if (
|
|
1079
|
+
!grant ||
|
|
1080
|
+
grant.accountId !== installation.accountId ||
|
|
1081
|
+
!hasPermission(grant.permissions, "sessions:read")
|
|
1082
|
+
) {
|
|
1083
|
+
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
1084
|
+
accountId: installation.accountId,
|
|
1085
|
+
workspaceId: installation.workspaceId,
|
|
1086
|
+
connectionId: installation.connectionId,
|
|
1087
|
+
subjectId: "service:slack-app-home",
|
|
1088
|
+
});
|
|
1089
|
+
await publishSlackAppHomeAccessView(
|
|
1090
|
+
client,
|
|
1091
|
+
refresh,
|
|
1092
|
+
renewLease,
|
|
1093
|
+
buildSlackAppHomeAccessBlocks({
|
|
1094
|
+
title: link ? "OpenGeni access needed" : "Connect your OpenGeni account",
|
|
1095
|
+
message: link
|
|
1096
|
+
? "Your Slack identity is linked, but it does not currently have access to this OpenGeni workspace."
|
|
1097
|
+
: "Link this Slack identity to see your active tasks, requests, and recent results here.",
|
|
1098
|
+
actionLabel: link ? "Request access" : "Connect OpenGeni",
|
|
1099
|
+
actionUrl: slackAppHomeLinkUrl(
|
|
1100
|
+
deps,
|
|
1101
|
+
installation,
|
|
1102
|
+
refresh.slackTeamId,
|
|
1103
|
+
refresh.slackUserId,
|
|
1104
|
+
),
|
|
1105
|
+
}),
|
|
1106
|
+
);
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
1111
|
+
accountId: installation.accountId,
|
|
1112
|
+
workspaceId: installation.workspaceId,
|
|
1113
|
+
connectionId: installation.connectionId,
|
|
1114
|
+
subjectId: grant.subjectId,
|
|
1115
|
+
});
|
|
1116
|
+
// Host session authorization is resolved at publication time, before the
|
|
1117
|
+
// database query, so App Home cannot leak rows through a stale broad list.
|
|
1118
|
+
const authorizationScope = await requireSessionAuthorizationListScope(deps, grant, "core");
|
|
1119
|
+
const sessions: Awaited<ReturnType<typeof listSessionsForSubject>>["sessions"] = [];
|
|
1120
|
+
try {
|
|
1121
|
+
let cursor: ReturnType<typeof decodeSessionListCursor> | undefined;
|
|
1122
|
+
do {
|
|
1123
|
+
await renewLease();
|
|
1124
|
+
const page = await listSessionsForSubject(deps.db, installation.workspaceId, {
|
|
1125
|
+
subjectId: grant.subjectId,
|
|
1126
|
+
limit: 500,
|
|
1127
|
+
...(cursor ? { cursor } : {}),
|
|
1128
|
+
...(authorizationScope ? { authorizationScope } : {}),
|
|
1129
|
+
});
|
|
1130
|
+
if (!cursor) sessions.push(...page.pinned);
|
|
1131
|
+
sessions.push(...page.sessions);
|
|
1132
|
+
if (!page.nextCursor) break;
|
|
1133
|
+
cursor = decodeSessionListCursor(page.nextCursor) ?? undefined;
|
|
1134
|
+
if (!cursor) throw new Error("Slack App Home session cursor was invalid");
|
|
1135
|
+
} while (cursor);
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
if (!(error instanceof SessionListAccessError)) throw error;
|
|
1138
|
+
await publishSlackAppHomeAccessView(
|
|
1139
|
+
client,
|
|
1140
|
+
refresh,
|
|
1141
|
+
renewLease,
|
|
1142
|
+
buildSlackAppHomeAccessBlocks({
|
|
1143
|
+
title: "OpenGeni access changed",
|
|
1144
|
+
message:
|
|
1145
|
+
"Your current OpenGeni access could not be verified. Reconnect before tasks are shown here.",
|
|
1146
|
+
actionLabel: "Reconnect OpenGeni",
|
|
1147
|
+
actionUrl: slackAppHomeLinkUrl(
|
|
1148
|
+
deps,
|
|
1149
|
+
installation,
|
|
1150
|
+
refresh.slackTeamId,
|
|
1151
|
+
refresh.slackUserId,
|
|
1152
|
+
),
|
|
1153
|
+
}),
|
|
1154
|
+
);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
const currentLink = await getSlackBotUserLink(
|
|
1158
|
+
deps.db,
|
|
1159
|
+
installation.workspaceId,
|
|
1160
|
+
installation.connectionId,
|
|
1161
|
+
refresh.slackUserId,
|
|
1162
|
+
);
|
|
1163
|
+
const currentGrant = currentLink
|
|
1164
|
+
? await getWorkspaceGrant(deps.db, currentLink.subjectId, installation.workspaceId, {
|
|
1165
|
+
principalKind: "human_session",
|
|
1166
|
+
})
|
|
1167
|
+
: null;
|
|
1168
|
+
if (
|
|
1169
|
+
!currentLink ||
|
|
1170
|
+
currentLink.subjectId !== grant.subjectId ||
|
|
1171
|
+
!currentGrant ||
|
|
1172
|
+
currentGrant.accountId !== installation.accountId ||
|
|
1173
|
+
!hasPermission(currentGrant.permissions, "sessions:read")
|
|
1174
|
+
) {
|
|
1175
|
+
await publishSlackAppHomeAccessView(
|
|
1176
|
+
client,
|
|
1177
|
+
refresh,
|
|
1178
|
+
renewLease,
|
|
1179
|
+
buildSlackAppHomeAccessBlocks({
|
|
1180
|
+
title: "OpenGeni access changed",
|
|
1181
|
+
message:
|
|
1182
|
+
"Your current OpenGeni access could not be verified. Reconnect before tasks are shown here.",
|
|
1183
|
+
actionLabel: "Reconnect OpenGeni",
|
|
1184
|
+
actionUrl: slackAppHomeLinkUrl(
|
|
1185
|
+
deps,
|
|
1186
|
+
installation,
|
|
1187
|
+
refresh.slackTeamId,
|
|
1188
|
+
refresh.slackUserId,
|
|
1189
|
+
),
|
|
1190
|
+
}),
|
|
1191
|
+
);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
const currentAuthorizationScope = await requireSessionAuthorizationListScope(
|
|
1195
|
+
deps,
|
|
1196
|
+
currentGrant,
|
|
1197
|
+
"core",
|
|
1198
|
+
);
|
|
1199
|
+
if (
|
|
1200
|
+
slackSessionAuthorizationScopeKey(currentAuthorizationScope) !==
|
|
1201
|
+
slackSessionAuthorizationScopeKey(authorizationScope)
|
|
1202
|
+
) {
|
|
1203
|
+
await publishSlackAppHomeAccessView(
|
|
1204
|
+
client,
|
|
1205
|
+
refresh,
|
|
1206
|
+
renewLease,
|
|
1207
|
+
buildSlackAppHomeAccessBlocks({
|
|
1208
|
+
title: "OpenGeni access changed",
|
|
1209
|
+
message:
|
|
1210
|
+
"Your current task access changed while this view was loading. Reopen Home to refresh it safely.",
|
|
1211
|
+
actionLabel: "Open OpenGeni",
|
|
1212
|
+
actionUrl: slackWorkspaceUrl(deps, installation.workspaceId),
|
|
1213
|
+
}),
|
|
1214
|
+
);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
if (!refresh.providerViewHash) {
|
|
1218
|
+
await publishSlackAppHomeAccessView(
|
|
1219
|
+
client,
|
|
1220
|
+
refresh,
|
|
1221
|
+
renewLease,
|
|
1222
|
+
buildSlackAppHomeAccessBlocks({
|
|
1223
|
+
title: "Refresh OpenGeni Home",
|
|
1224
|
+
message:
|
|
1225
|
+
"Reopen Home to refresh your tasks safely. OpenGeni does not publish task data without Slack's current view version.",
|
|
1226
|
+
actionLabel: "Open OpenGeni",
|
|
1227
|
+
actionUrl: slackWorkspaceUrl(deps, installation.workspaceId),
|
|
1228
|
+
}),
|
|
1229
|
+
);
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
await renewLease();
|
|
1233
|
+
await client.publishHomeView({
|
|
1234
|
+
userId: refresh.slackUserId,
|
|
1235
|
+
hash: refresh.providerViewHash,
|
|
1236
|
+
blocks: buildSlackAppHomeBlocks({
|
|
1237
|
+
sessions,
|
|
1238
|
+
workspaceUrl: slackWorkspaceUrl(deps, installation.workspaceId),
|
|
1239
|
+
sessionUrl: (sessionId) => slackSessionUrl(deps, installation.workspaceId, sessionId),
|
|
1240
|
+
}),
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function publishSlackAppHomeAccessView(
|
|
1245
|
+
client: OpenGeniSlackBotClient,
|
|
1246
|
+
refresh: SlackAppHomeRefresh,
|
|
1247
|
+
renewLease: () => Promise<void>,
|
|
1248
|
+
blocks: ReturnType<typeof buildSlackAppHomeAccessBlocks>,
|
|
1249
|
+
): Promise<void> {
|
|
1250
|
+
try {
|
|
1251
|
+
await renewLease();
|
|
1252
|
+
await client.publishHomeView({
|
|
1253
|
+
userId: refresh.slackUserId,
|
|
1254
|
+
hash: refresh.providerViewHash,
|
|
1255
|
+
blocks,
|
|
1256
|
+
});
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
if (!(error instanceof SlackBotProviderError) || error.code !== "hash_conflict") throw error;
|
|
1259
|
+
// Access views contain no task data. If an older authorized publication
|
|
1260
|
+
// advanced Slack's optimistic-concurrency hash, replace it fail-closed
|
|
1261
|
+
// rather than dropping the newer clearing obligation.
|
|
1262
|
+
await renewLease();
|
|
1263
|
+
await client.publishHomeView({
|
|
1264
|
+
userId: refresh.slackUserId,
|
|
1265
|
+
blocks,
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
786
1270
|
export async function drainSlackInteractionsOnce(deps: ApiRouteDeps): Promise<boolean> {
|
|
1271
|
+
const appHomeHolder = crypto.randomUUID();
|
|
1272
|
+
const refresh = await claimSlackAppHomeRefresh(deps.db, appHomeHolder, APP_HOME_LEASE_MS);
|
|
1273
|
+
if (refresh) {
|
|
1274
|
+
try {
|
|
1275
|
+
const installation = await resolveSlackInstallationRoute(deps.db, refresh.slackTeamId);
|
|
1276
|
+
if (
|
|
1277
|
+
!installation ||
|
|
1278
|
+
installation.accountId !== refresh.accountId ||
|
|
1279
|
+
installation.workspaceId !== refresh.workspaceId ||
|
|
1280
|
+
installation.connectionId !== refresh.connectionId
|
|
1281
|
+
) {
|
|
1282
|
+
throw new SlackInteractionPermanentError("slack_app_home_installation_changed");
|
|
1283
|
+
}
|
|
1284
|
+
const renewLease = async () => {
|
|
1285
|
+
if (
|
|
1286
|
+
!(await renewSlackAppHomeRefreshClaim(deps.db, {
|
|
1287
|
+
refresh,
|
|
1288
|
+
claimHolderId: appHomeHolder,
|
|
1289
|
+
claimLeaseMs: APP_HOME_LEASE_MS,
|
|
1290
|
+
}))
|
|
1291
|
+
) {
|
|
1292
|
+
throw new SlackInteractionPermanentError("slack_app_home_claim_lost");
|
|
1293
|
+
}
|
|
1294
|
+
};
|
|
1295
|
+
await renewLease();
|
|
1296
|
+
await publishSlackAppHome(deps, installation, refresh, renewLease);
|
|
1297
|
+
await settleSlackAppHomeRefresh(deps.db, {
|
|
1298
|
+
refresh,
|
|
1299
|
+
claimHolderId: appHomeHolder,
|
|
1300
|
+
});
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
const code = safeErrorCode(error);
|
|
1303
|
+
console.error("[slack-interactions] App Home refresh failed", {
|
|
1304
|
+
workspaceId: refresh.workspaceId,
|
|
1305
|
+
connectionId: refresh.connectionId,
|
|
1306
|
+
slackUserId: refresh.slackUserId,
|
|
1307
|
+
providerEventId: refresh.providerEventId,
|
|
1308
|
+
desiredRevision: refresh.desiredRevision,
|
|
1309
|
+
attemptCount: refresh.attemptCount,
|
|
1310
|
+
errorCode: code,
|
|
1311
|
+
});
|
|
1312
|
+
if (error instanceof SlackBotProviderError && error.code === "hash_conflict") {
|
|
1313
|
+
await settleSlackAppHomeRefresh(deps.db, {
|
|
1314
|
+
refresh,
|
|
1315
|
+
claimHolderId: appHomeHolder,
|
|
1316
|
+
errorCode: code,
|
|
1317
|
+
});
|
|
1318
|
+
} else if (
|
|
1319
|
+
refresh.attemptCount >= 5 ||
|
|
1320
|
+
error instanceof SlackInteractionPermanentError ||
|
|
1321
|
+
permanentSlackDeliveryError(error)
|
|
1322
|
+
) {
|
|
1323
|
+
await settleSlackAppHomeRefresh(deps.db, {
|
|
1324
|
+
refresh,
|
|
1325
|
+
claimHolderId: appHomeHolder,
|
|
1326
|
+
errorCode: code,
|
|
1327
|
+
});
|
|
1328
|
+
} else {
|
|
1329
|
+
await releaseSlackAppHomeRefresh(deps.db, {
|
|
1330
|
+
refresh,
|
|
1331
|
+
claimHolderId: appHomeHolder,
|
|
1332
|
+
errorCode: code,
|
|
1333
|
+
retryAt: new Date(Date.now() + slackDeliveryRetryMs(error, refresh.attemptCount)),
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
return true;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
787
1340
|
const holder = crypto.randomUUID();
|
|
788
1341
|
const entry = await claimSlackInteractionInbox(deps.db, holder, INBOX_LEASE_MS);
|
|
789
1342
|
if (entry) {
|
|
@@ -890,6 +1443,467 @@ export function startSlackInteractionPump(
|
|
|
890
1443
|
};
|
|
891
1444
|
}
|
|
892
1445
|
|
|
1446
|
+
/**
|
|
1447
|
+
* A concurrent creator may have won this thread in another workspace between the
|
|
1448
|
+
* tenancy probe and the insert, in which case `getOrCreateSlackInteraction`
|
|
1449
|
+
* adopts that row. The grant, the imported attachments, and the resolved route
|
|
1450
|
+
* all belong to the workspace this pass chose, so none of them may be used
|
|
1451
|
+
* against the adopted row. Retry instead: the next pass sees the thread through
|
|
1452
|
+
* the probe, routes to it, and authorizes it properly.
|
|
1453
|
+
*/
|
|
1454
|
+
function requireSlackInteractionMatchesTarget(
|
|
1455
|
+
interaction: Pick<SlackInteraction, "accountId" | "workspaceId">,
|
|
1456
|
+
target: { accountId: string; workspaceId: string },
|
|
1457
|
+
): void {
|
|
1458
|
+
if (
|
|
1459
|
+
interaction.accountId !== target.accountId ||
|
|
1460
|
+
interaction.workspaceId !== target.workspaceId
|
|
1461
|
+
) {
|
|
1462
|
+
throw new SlackInteractionRetryableError("slack_route_creation_pending");
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* Gather the durable facts the routing decision needs, then decide.
|
|
1468
|
+
*
|
|
1469
|
+
* With the flag off nothing is read at all: the resolver short-circuits to the
|
|
1470
|
+
* installation's own workspace, so an existing single-workspace install issues
|
|
1471
|
+
* exactly the queries it issued before.
|
|
1472
|
+
*/
|
|
1473
|
+
async function resolveSlackRouteForEntry(
|
|
1474
|
+
deps: ApiRouteDeps,
|
|
1475
|
+
home: SlackRouteTenancy,
|
|
1476
|
+
entry: SlackInteractionInboxEntry,
|
|
1477
|
+
subjectId: string,
|
|
1478
|
+
options: {
|
|
1479
|
+
threadTenancy: SlackRouteTenancy | null;
|
|
1480
|
+
askEnabled: boolean;
|
|
1481
|
+
botUserId: string | null;
|
|
1482
|
+
},
|
|
1483
|
+
): Promise<SlackRouteResolution> {
|
|
1484
|
+
const base = {
|
|
1485
|
+
home,
|
|
1486
|
+
entry,
|
|
1487
|
+
botUserId: options.botUserId,
|
|
1488
|
+
threadTenancy: options.threadTenancy,
|
|
1489
|
+
channelRoute: null,
|
|
1490
|
+
dmRoute: null,
|
|
1491
|
+
personalWorkspaceId: null,
|
|
1492
|
+
candidates: [] as const,
|
|
1493
|
+
routingEnabled: false,
|
|
1494
|
+
askEnabled: false,
|
|
1495
|
+
} as const;
|
|
1496
|
+
// A mapped thread wins outright, and with the flag off nothing is consulted at
|
|
1497
|
+
// all, so neither case reads anything.
|
|
1498
|
+
if (!deps.settings.slackWorkspaceRoutingEnabled || options.threadTenancy) {
|
|
1499
|
+
return resolveSlackWorkspaceRoute(base);
|
|
1500
|
+
}
|
|
1501
|
+
const directMessage = isSlackDirectMessageConversation(entry);
|
|
1502
|
+
const [channelRoute, dmRoute, candidates] = await Promise.all([
|
|
1503
|
+
directMessage
|
|
1504
|
+
? Promise.resolve(null)
|
|
1505
|
+
: getSlackChannelRoute(deps.db, home, {
|
|
1506
|
+
connectionId: entry.connectionId,
|
|
1507
|
+
slackChannelId: entry.slackChannelId,
|
|
1508
|
+
}),
|
|
1509
|
+
directMessage
|
|
1510
|
+
? getSlackUserDmRoute(deps.db, home, {
|
|
1511
|
+
connectionId: entry.connectionId,
|
|
1512
|
+
slackUserId: entry.slackUserId,
|
|
1513
|
+
})
|
|
1514
|
+
: Promise.resolve(null),
|
|
1515
|
+
listNamedSubjectSlackRoutableWorkspaces(deps.db, { accountId: home.accountId, subjectId }),
|
|
1516
|
+
]);
|
|
1517
|
+
return resolveSlackWorkspaceRoute({
|
|
1518
|
+
...base,
|
|
1519
|
+
channelRoute,
|
|
1520
|
+
dmRoute,
|
|
1521
|
+
candidates,
|
|
1522
|
+
// The candidate set is built from the same derived pointer, so reading it
|
|
1523
|
+
// again would be a second query for an answer already in hand.
|
|
1524
|
+
personalWorkspaceId: candidates.find((candidate) => candidate.personal)?.workspaceId ?? null,
|
|
1525
|
+
routingEnabled: true,
|
|
1526
|
+
askEnabled: options.askEnabled,
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* Tell the person exactly why nothing started, in their own bot DM.
|
|
1532
|
+
*
|
|
1533
|
+
* Every branch ends with "No session was created." because the one thing a
|
|
1534
|
+
* refusal must never do is leave someone believing work is under way.
|
|
1535
|
+
*
|
|
1536
|
+
* Only `no_access_to_route` mints an access-request link, and it mints one for
|
|
1537
|
+
* the workspace they actually need rather than the installation's. The other
|
|
1538
|
+
* two branches deliberately do not: a prefix naming something they cannot
|
|
1539
|
+
* reach, and an empty candidate list, both describe a workspace OpenGeni has
|
|
1540
|
+
* not established it will confirm to this person, so they point at an
|
|
1541
|
+
* administrator instead.
|
|
1542
|
+
*/
|
|
1543
|
+
async function postSlackRouteRefusal(
|
|
1544
|
+
deps: ApiRouteDeps,
|
|
1545
|
+
client: OpenGeniSlackBotClient,
|
|
1546
|
+
entry: SlackInteractionInboxEntry,
|
|
1547
|
+
refusal: Extract<SlackRouteResolution, { kind: "denied" }> & Partial<SlackRouteTenancy>,
|
|
1548
|
+
): Promise<void> {
|
|
1549
|
+
const available = refusal.candidates.map((candidate) => candidate.label);
|
|
1550
|
+
const text =
|
|
1551
|
+
refusal.reason === "no_access_to_named"
|
|
1552
|
+
? `OpenGeni does not see a workspace named ${JSON.stringify(refusal.requested ?? "")} that you can start work in from here.${
|
|
1553
|
+
available.length > 0 ? ` You can use: ${available.join(", ")}.` : ""
|
|
1554
|
+
} No session was created.`
|
|
1555
|
+
: refusal.reason === "no_access_to_route"
|
|
1556
|
+
? `OpenGeni starts work from this conversation in ${
|
|
1557
|
+
refusal.requested ?? "another workspace"
|
|
1558
|
+
}, and you do not have access to it. Request access: ${linkUrl(
|
|
1559
|
+
deps,
|
|
1560
|
+
{
|
|
1561
|
+
// Minted for the workspace they actually need, not the
|
|
1562
|
+
// installation's. The token only decides which workspace an
|
|
1563
|
+
// access request may be raised for; approval is unchanged.
|
|
1564
|
+
workspaceId: refusal.workspaceId ?? entry.workspaceId,
|
|
1565
|
+
connectionId: entry.connectionId,
|
|
1566
|
+
slackTeamId: entry.slackTeamId,
|
|
1567
|
+
slackUserId: entry.slackUserId,
|
|
1568
|
+
},
|
|
1569
|
+
entry.createdAt.getTime(),
|
|
1570
|
+
)}. No session was created.`
|
|
1571
|
+
: // Deliberately "from this conversation" rather than "for you": in a
|
|
1572
|
+
// channel this also fires for someone whose only workspace is their
|
|
1573
|
+
// own personal one, and telling that person they have no workspace
|
|
1574
|
+
// at all would be false. Their bot DM still works.
|
|
1575
|
+
"OpenGeni has no workspace it can start this task in from this conversation. Ask an OpenGeni administrator to give you access to one, or message OpenGeni directly to work in your own workspace. No session was created.";
|
|
1576
|
+
await client.postMessage({
|
|
1577
|
+
operationId: deterministicUuid(`slack-route-denied:${entry.id}:${refusal.reason}`),
|
|
1578
|
+
userId: entry.slackUserId,
|
|
1579
|
+
text: boundedOutput(text),
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* A workspace name as a Slack button label.
|
|
1585
|
+
*
|
|
1586
|
+
* Slack renders a button label as plain text and the stored option snapshot caps
|
|
1587
|
+
* it at 128 bytes, so bound it once at the boundary: the button, the row and
|
|
1588
|
+
* every outcome card then agree. Counted in UTF-8 bytes, because that is what
|
|
1589
|
+
* the CHECK counts.
|
|
1590
|
+
*/
|
|
1591
|
+
function boundedSlackRouteLabel(label: string): string {
|
|
1592
|
+
const encoder = new TextEncoder();
|
|
1593
|
+
if (encoder.encode(label).length <= 72) return label;
|
|
1594
|
+
let bounded = label;
|
|
1595
|
+
while (bounded.length > 0 && encoder.encode(`${bounded}...`).length > 72) {
|
|
1596
|
+
bounded = bounded.slice(0, -1);
|
|
1597
|
+
}
|
|
1598
|
+
return `${bounded}...`;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* Say where the work went, quietly.
|
|
1603
|
+
*
|
|
1604
|
+
* The line exists to stop work landing somewhere surprising, so it appears only
|
|
1605
|
+
* when the routing decision was a genuine choice: a null label means routing is
|
|
1606
|
+
* off, or the person had exactly one workspace anyway, and a constant footer on
|
|
1607
|
+
* every message would be noise rather than information.
|
|
1608
|
+
*
|
|
1609
|
+
* The label is frozen on the interaction rather than looked up here, because a
|
|
1610
|
+
* workspace rename between the original post and a reconciliation would make the
|
|
1611
|
+
* post ledger's byte comparison raise.
|
|
1612
|
+
*/
|
|
1613
|
+
export function withWorkspaceLine(
|
|
1614
|
+
label: string | null,
|
|
1615
|
+
text: string,
|
|
1616
|
+
blocks?: SlackMessageBlock[],
|
|
1617
|
+
): { text: string; blocks?: SlackMessageBlock[] } {
|
|
1618
|
+
if (!label) return blocks ? { text, blocks } : { text };
|
|
1619
|
+
const suffix = `\n-> ${label}`;
|
|
1620
|
+
// Reserve the suffix rather than truncating after the fact, so the bounded
|
|
1621
|
+
// body plus the line still fits.
|
|
1622
|
+
const body = boundedOutput(text, MAX_SLACK_TEXT_CHARS - suffix.length);
|
|
1623
|
+
const line: SlackMessageBlock = {
|
|
1624
|
+
type: "context",
|
|
1625
|
+
elements: [{ type: "mrkdwn", text: `-> ${label}` }],
|
|
1626
|
+
};
|
|
1627
|
+
return {
|
|
1628
|
+
text: `${body}${suffix}`,
|
|
1629
|
+
...(blocks ? { blocks: [...blocks, line] } : {}),
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* The post-ledger seed for one interaction's messages.
|
|
1635
|
+
*
|
|
1636
|
+
* The ledger binds one operation id to a digest over the rendered bytes, so
|
|
1637
|
+
* adding the workspace line under an unchanged id would wedge every interaction
|
|
1638
|
+
* with a claimed-but-unposted row. But bumping the id for interactions whose
|
|
1639
|
+
* bytes did NOT change is not free either: their ledger rows become unreachable,
|
|
1640
|
+
* so a repair or a cross-deploy retry posts a duplicate instead of finding the
|
|
1641
|
+
* completed original, and an action handle still points at the old operation.
|
|
1642
|
+
*
|
|
1643
|
+
* Only a labelled interaction renders different bytes, and the label is written
|
|
1644
|
+
* once at insert and never updated, so it is a stable discriminator. Version the
|
|
1645
|
+
* id on the label rather than on the release: a labelled interaction can only
|
|
1646
|
+
* have been created by an image that already renders the line, and every
|
|
1647
|
+
* pre-existing and unlabelled interaction keeps its exact ledger identity.
|
|
1648
|
+
*/
|
|
1649
|
+
export function slackPostSeed(
|
|
1650
|
+
interaction: Pick<SlackInteraction, "routedWorkspaceLabel">,
|
|
1651
|
+
seed: string,
|
|
1652
|
+
): string {
|
|
1653
|
+
return interaction.routedWorkspaceLabel ? `${seed}:v2` : seed;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/** Sources that mean somebody, or something, actually chose this workspace. */
|
|
1657
|
+
const SLACK_ROUTE_SOURCES_WORTH_NAMING: ReadonlySet<string> = new Set([
|
|
1658
|
+
// Not "thread": a mapped thread reuses the interaction that already exists,
|
|
1659
|
+
// so it inherits the label frozen when that interaction was created and never
|
|
1660
|
+
// reaches the writer below.
|
|
1661
|
+
"prefix",
|
|
1662
|
+
"channel",
|
|
1663
|
+
"dm_route",
|
|
1664
|
+
"dm_personal",
|
|
1665
|
+
]);
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* The label to freeze on a new interaction, or null to say nothing.
|
|
1669
|
+
*
|
|
1670
|
+
* `installation` and `sole_candidate` both mean there was only ever one place
|
|
1671
|
+
* this could go, so naming it on every message would be noise.
|
|
1672
|
+
*
|
|
1673
|
+
* Bounded with the picker's own button cap so the frozen label and the button
|
|
1674
|
+
* that produced it agree; the column allows 128 bytes, which is headroom.
|
|
1675
|
+
*/
|
|
1676
|
+
async function routedWorkspaceLabelFor(
|
|
1677
|
+
deps: ApiRouteDeps,
|
|
1678
|
+
resolution: Extract<SlackRouteResolution, { kind: "resolved" }>,
|
|
1679
|
+
): Promise<string | null> {
|
|
1680
|
+
if (!SLACK_ROUTE_SOURCES_WORTH_NAMING.has(resolution.source)) return null;
|
|
1681
|
+
const label = resolution.label ?? (await getWorkspace(deps.db, resolution.workspaceId))?.name;
|
|
1682
|
+
if (!label) return null;
|
|
1683
|
+
const bounded = boundedSlackRouteLabel(label);
|
|
1684
|
+
return bounded.length > 0 ? bounded : null;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/** How long a first-use picker stays answerable. */
|
|
1688
|
+
const SLACK_ROUTE_PROMPT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
1689
|
+
|
|
1690
|
+
/** Buttons a card can carry before it stops being readable. */
|
|
1691
|
+
const MAX_SLACK_ROUTE_PROMPT_BUTTONS = 5;
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* Ask, once, where this conversation should work.
|
|
1695
|
+
*
|
|
1696
|
+
* The card is a bot DM rather than an ephemeral message: `chat.postEphemeral`
|
|
1697
|
+
* returns no durable `(channel, ts)` and no `client_msg_id` echo, and the whole
|
|
1698
|
+
* post ledger and its reconciliation are built on both.
|
|
1699
|
+
*
|
|
1700
|
+
* Nothing is created in any workspace here. The request is parked on the prompt
|
|
1701
|
+
* row and re-queued only once the person answers, so a picker that is never
|
|
1702
|
+
* answered leaves no session, no interaction and no half-started work.
|
|
1703
|
+
*/
|
|
1704
|
+
async function askSlackRouteChoice(
|
|
1705
|
+
deps: ApiRouteDeps,
|
|
1706
|
+
client: OpenGeniSlackBotClient,
|
|
1707
|
+
home: SlackRouteTenancy,
|
|
1708
|
+
entry: SlackInteractionInboxEntry,
|
|
1709
|
+
candidates: readonly SlackRoutableWorkspace[],
|
|
1710
|
+
): Promise<void> {
|
|
1711
|
+
const offered = candidates.slice(0, MAX_SLACK_ROUTE_PROMPT_BUTTONS);
|
|
1712
|
+
// Slack renders a button label as plain text and the stored snapshot caps it
|
|
1713
|
+
// at 128 bytes, so bound it once here: the button, the stored row and every
|
|
1714
|
+
// outcome card then agree, and the full name still shows in OpenGeni.
|
|
1715
|
+
const bounded = offered.map((candidate) => ({
|
|
1716
|
+
...candidate,
|
|
1717
|
+
label: boundedSlackRouteLabel(candidate.label),
|
|
1718
|
+
}));
|
|
1719
|
+
const opened = await openSlackRoutePrompt(deps.db, home, {
|
|
1720
|
+
connectionId: entry.connectionId,
|
|
1721
|
+
inboxId: entry.id,
|
|
1722
|
+
slackTeamId: entry.slackTeamId,
|
|
1723
|
+
slackUserId: entry.slackUserId,
|
|
1724
|
+
slackChannelId: entry.slackChannelId,
|
|
1725
|
+
slackMessageTs: entry.slackMessageTs,
|
|
1726
|
+
providerEventId: entry.providerEventId,
|
|
1727
|
+
triggerKind: entry.triggerKind,
|
|
1728
|
+
requestText: entry.text,
|
|
1729
|
+
hasFiles: entry.hasFiles,
|
|
1730
|
+
slackThreadTs: entry.slackThreadTs,
|
|
1731
|
+
messageOperationId: deterministicUuid(`slack-route-prompt:${entry.id}`),
|
|
1732
|
+
expiresAt: new Date(Date.now() + SLACK_ROUTE_PROMPT_TTL_MS),
|
|
1733
|
+
candidates: bounded,
|
|
1734
|
+
});
|
|
1735
|
+
if (!opened) return;
|
|
1736
|
+
// The prompt commits before the card is posted, so a transient Slack failure
|
|
1737
|
+
// would otherwise leave a pending row with no card in front of anybody and
|
|
1738
|
+
// silently deaden the conversation. Post unconditionally instead: the
|
|
1739
|
+
// operation id is persisted on the prompt and the card is rendered from the
|
|
1740
|
+
// prompt's own stored options, so a replay reproduces identical bytes and the
|
|
1741
|
+
// post ledger returns the completed original rather than posting twice.
|
|
1742
|
+
const where = opened.prompt.slackChannelId.startsWith("D")
|
|
1743
|
+
? "your messages with OpenGeni"
|
|
1744
|
+
: `<#${opened.prompt.slackChannelId}>`;
|
|
1745
|
+
const blocks: SlackMessageBlock[] = [
|
|
1746
|
+
{
|
|
1747
|
+
type: "section",
|
|
1748
|
+
text: {
|
|
1749
|
+
type: "mrkdwn",
|
|
1750
|
+
text: `OpenGeni can start this task in more than one workspace. Pick where ${where} should work. This choice is remembered.`,
|
|
1751
|
+
},
|
|
1752
|
+
},
|
|
1753
|
+
{
|
|
1754
|
+
type: "actions",
|
|
1755
|
+
block_id: "opengeni-route-choice",
|
|
1756
|
+
elements: [
|
|
1757
|
+
...opened.prompt.options.map((option) => ({
|
|
1758
|
+
type: "button" as const,
|
|
1759
|
+
action_id: "opengeni.route.select",
|
|
1760
|
+
value: option.id,
|
|
1761
|
+
text: { type: "plain_text" as const, text: option.candidateLabel },
|
|
1762
|
+
})),
|
|
1763
|
+
{
|
|
1764
|
+
type: "button" as const,
|
|
1765
|
+
action_id: "opengeni.route.cancel",
|
|
1766
|
+
value: opened.prompt.options[0]?.id ?? opened.prompt.id,
|
|
1767
|
+
text: { type: "plain_text" as const, text: "Not now" },
|
|
1768
|
+
},
|
|
1769
|
+
],
|
|
1770
|
+
},
|
|
1771
|
+
];
|
|
1772
|
+
// Deliberately unconditional rather than derived from the live candidate
|
|
1773
|
+
// count: a replay must render identical bytes, and membership can change
|
|
1774
|
+
// between attempts.
|
|
1775
|
+
const url = slackWorkspaceUrl(deps, home.workspaceId);
|
|
1776
|
+
blocks.push({
|
|
1777
|
+
type: "section",
|
|
1778
|
+
text: {
|
|
1779
|
+
type: "mrkdwn",
|
|
1780
|
+
text: url
|
|
1781
|
+
? `You can change this later in <${url}|OpenGeni>, under Capabilities, then Slack.`
|
|
1782
|
+
: "You can change this later in OpenGeni, under Capabilities, then Slack.",
|
|
1783
|
+
},
|
|
1784
|
+
});
|
|
1785
|
+
await client.postMessage({
|
|
1786
|
+
operationId: opened.prompt.messageOperationId,
|
|
1787
|
+
userId: opened.prompt.slackUserId,
|
|
1788
|
+
text: boundedOutput(
|
|
1789
|
+
`OpenGeni can start this task in more than one workspace. Pick where ${where} should work.`,
|
|
1790
|
+
),
|
|
1791
|
+
blocks,
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* Process one click on a picker.
|
|
1797
|
+
*
|
|
1798
|
+
* The stored option row is a prompt-time snapshot and is NEVER authority: the
|
|
1799
|
+
* chosen workspace is re-authorized live, because access can have been revoked
|
|
1800
|
+
* between the question and the answer.
|
|
1801
|
+
*/
|
|
1802
|
+
async function handleSlackRouteChoice(
|
|
1803
|
+
deps: ApiRouteDeps,
|
|
1804
|
+
home: SlackRouteTenancy,
|
|
1805
|
+
entry: SlackInteractionInboxEntry,
|
|
1806
|
+
actionId: string,
|
|
1807
|
+
optionId: string,
|
|
1808
|
+
): Promise<void> {
|
|
1809
|
+
if (!deps.settings.slackWorkspaceRoutingEnabled) {
|
|
1810
|
+
// A card can outlive the flag being turned back off. Honouring the click
|
|
1811
|
+
// would write a route row and start work through a path the operator has
|
|
1812
|
+
// disabled.
|
|
1813
|
+
throw new SlackInteractionPermanentError("slack_route_choice_unavailable");
|
|
1814
|
+
}
|
|
1815
|
+
const found = await getSlackRoutePromptByOption(deps.db, home, optionId);
|
|
1816
|
+
if (!found) throw new SlackInteractionPermanentError("slack_route_prompt_invalid");
|
|
1817
|
+
const { prompt, chosen } = found;
|
|
1818
|
+
// The same authorization pinning ordinary action handles use: a different
|
|
1819
|
+
// Slack human clicking someone else's card is ignored, not obeyed.
|
|
1820
|
+
if (prompt.slackUserId !== entry.slackUserId) {
|
|
1821
|
+
throw new SlackInteractionPermanentError("slack_route_prompt_user_mismatch");
|
|
1822
|
+
}
|
|
1823
|
+
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
1824
|
+
...home,
|
|
1825
|
+
connectionId: entry.connectionId,
|
|
1826
|
+
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
1827
|
+
});
|
|
1828
|
+
// The post ledger binds one operation id to a digest over the text, so the
|
|
1829
|
+
// outcome has to be part of the seed. A second click on a settled card
|
|
1830
|
+
// renders different bytes than the first, and reusing one id would wedge the
|
|
1831
|
+
// ledger rather than saying so.
|
|
1832
|
+
const replaceCard = async (outcome: string, text: string) => {
|
|
1833
|
+
await client.updateMessage({
|
|
1834
|
+
operationId: deterministicUuid(
|
|
1835
|
+
`slack-route-answered:${prompt.id}:${actionId}:${chosen.id}:${outcome}`,
|
|
1836
|
+
),
|
|
1837
|
+
channelId: entry.slackChannelId,
|
|
1838
|
+
timestamp: entry.slackMessageTs,
|
|
1839
|
+
text,
|
|
1840
|
+
// Every button goes with the card, so a sibling option cannot be pressed
|
|
1841
|
+
// after the question is settled.
|
|
1842
|
+
blocks: [{ type: "section", text: { type: "mrkdwn", text } }],
|
|
1843
|
+
});
|
|
1844
|
+
};
|
|
1845
|
+
|
|
1846
|
+
if (actionId === "opengeni.route.cancel") {
|
|
1847
|
+
const cancelled = await settleSlackRoutePrompt(deps.db, home, {
|
|
1848
|
+
promptId: prompt.id,
|
|
1849
|
+
status: "cancelled",
|
|
1850
|
+
});
|
|
1851
|
+
await replaceCard(
|
|
1852
|
+
cancelled ? "cancelled" : "already_settled",
|
|
1853
|
+
cancelled
|
|
1854
|
+
? "OpenGeni did not start this task. No session was created."
|
|
1855
|
+
: "OpenGeni already settled this choice.",
|
|
1856
|
+
);
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
const link = await getSlackBotUserLink(
|
|
1861
|
+
deps.db,
|
|
1862
|
+
home.workspaceId,
|
|
1863
|
+
entry.connectionId,
|
|
1864
|
+
entry.slackUserId,
|
|
1865
|
+
);
|
|
1866
|
+
const grant = link
|
|
1867
|
+
? await resolveSlackTargetAuthority(deps.db, {
|
|
1868
|
+
subjectId: link.subjectId,
|
|
1869
|
+
targetAccountId: chosen.candidateAccountId,
|
|
1870
|
+
targetWorkspaceId: chosen.candidateWorkspaceId,
|
|
1871
|
+
})
|
|
1872
|
+
: null;
|
|
1873
|
+
if (!grant || !hasPermission(grant.permissions, "sessions:create")) {
|
|
1874
|
+
// Fail closed WITHOUT touching the card: the person may still have another
|
|
1875
|
+
// workspace on it that they can use, and replacing the card would take the
|
|
1876
|
+
// remaining buttons away and leave a pending prompt nobody can answer.
|
|
1877
|
+
await client.postMessage({
|
|
1878
|
+
operationId: deterministicUuid(`slack-route-denied:${prompt.id}:${chosen.id}`),
|
|
1879
|
+
userId: entry.slackUserId,
|
|
1880
|
+
text: boundedOutput(
|
|
1881
|
+
`OpenGeni cannot start work in ${chosen.candidateLabel}: you do not have access to it. No session was created. Pick another workspace on the card, or ask an OpenGeni administrator for access.`,
|
|
1882
|
+
),
|
|
1883
|
+
});
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
const answered = await answerSlackRoutePrompt(deps.db, home, {
|
|
1888
|
+
promptId: prompt.id,
|
|
1889
|
+
targetAccountId: chosen.candidateAccountId,
|
|
1890
|
+
targetWorkspaceId: chosen.candidateWorkspaceId,
|
|
1891
|
+
answeredBySubjectId: grant.subjectId,
|
|
1892
|
+
decidedBySlackUserId: entry.slackUserId,
|
|
1893
|
+
});
|
|
1894
|
+
if (!answered.answered) {
|
|
1895
|
+
await replaceCard(
|
|
1896
|
+
"settled",
|
|
1897
|
+
"OpenGeni already settled this choice. No new session was created.",
|
|
1898
|
+
);
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
await replaceCard(
|
|
1902
|
+
"answered",
|
|
1903
|
+
`OpenGeni will use *${chosen.candidateLabel}* here. You can change this in OpenGeni under Capabilities, then Slack.`,
|
|
1904
|
+
);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
893
1907
|
async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
|
|
894
1908
|
if (entry.triggerKind === "block_action") {
|
|
895
1909
|
await processSlackBlockAction(deps, entry);
|
|
@@ -899,6 +1913,11 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
899
1913
|
await processSlackReactionInboxEntry(deps, entry);
|
|
900
1914
|
return;
|
|
901
1915
|
}
|
|
1916
|
+
// HOME is the installation binding's tenancy: it owns the bot credential and
|
|
1917
|
+
// connection, this inbox row, the identity links, and the post ledgers. TARGET
|
|
1918
|
+
// is the tenancy the resulting session lives in. They are equal today; the
|
|
1919
|
+
// routing resolver is what makes them diverge.
|
|
1920
|
+
const home = { accountId: entry.accountId, workspaceId: entry.workspaceId } as const;
|
|
902
1921
|
const installation = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
|
|
903
1922
|
if (
|
|
904
1923
|
!installation ||
|
|
@@ -909,8 +1928,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
909
1928
|
throw new SlackInteractionPermanentError("slack_task_installation_changed");
|
|
910
1929
|
}
|
|
911
1930
|
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
912
|
-
|
|
913
|
-
workspaceId: entry.workspaceId,
|
|
1931
|
+
...home,
|
|
914
1932
|
connectionId: entry.connectionId,
|
|
915
1933
|
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
916
1934
|
});
|
|
@@ -937,17 +1955,21 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
937
1955
|
privateHandoff: policyDecision.disposition === "private_handoff",
|
|
938
1956
|
});
|
|
939
1957
|
const routeKey = routePolicy.initialRouteKey;
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1958
|
+
// A mapped thread keeps the workspace it was created in, so the continuation
|
|
1959
|
+
// lookup is connection-scoped rather than workspace-fenced.
|
|
1960
|
+
const existing = await getSlackInteractionByConnectionRoute(deps.db, {
|
|
1961
|
+
accountId: home.accountId,
|
|
1962
|
+
connectionId: entry.connectionId,
|
|
944
1963
|
routeKey,
|
|
945
|
-
);
|
|
1964
|
+
});
|
|
946
1965
|
if (entry.triggerKind === "thread_reply" && !existing) return;
|
|
947
|
-
|
|
1966
|
+
// The identity link is a HOME fact: `slack_bot_user_links` is unique on
|
|
1967
|
+
// `(connection_id, slack_user_id)` and RLS-visible only under the
|
|
1968
|
+
// installation's own tenancy. It is read before the target is chosen because
|
|
1969
|
+
// the routing decision needs the subject it names.
|
|
948
1970
|
const link = await getSlackBotUserLink(
|
|
949
1971
|
deps.db,
|
|
950
|
-
|
|
1972
|
+
home.workspaceId,
|
|
951
1973
|
entry.connectionId,
|
|
952
1974
|
entry.slackUserId,
|
|
953
1975
|
);
|
|
@@ -955,20 +1977,59 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
955
1977
|
await client.postMessage({
|
|
956
1978
|
operationId: deterministicUuid(`slack-link:${entry.id}`),
|
|
957
1979
|
userId: entry.slackUserId,
|
|
958
|
-
text: `Link your Slack identity to OpenGeni before starting work: ${linkUrl(deps, entry)}. No session was created.`,
|
|
1980
|
+
text: `Link your Slack identity to OpenGeni before starting work: ${linkUrl(deps, entry, entry.createdAt.getTime())}. No session was created.`,
|
|
959
1981
|
});
|
|
960
1982
|
return;
|
|
961
1983
|
}
|
|
962
|
-
const
|
|
963
|
-
|
|
1984
|
+
const resolution = await resolveSlackRouteForEntry(deps, home, entry, link.subjectId, {
|
|
1985
|
+
botUserId: installation.botUserId,
|
|
1986
|
+
threadTenancy: existing
|
|
1987
|
+
? { accountId: existing.accountId, workspaceId: existing.workspaceId }
|
|
1988
|
+
: null,
|
|
1989
|
+
askEnabled: true,
|
|
1990
|
+
});
|
|
1991
|
+
if (resolution.kind === "ask") {
|
|
1992
|
+
await askSlackRouteChoice(deps, client, home, entry, resolution.candidates);
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
if (resolution.kind === "denied") {
|
|
1996
|
+
await postSlackRouteRefusal(deps, client, entry, resolution);
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
const target: { accountId: string; workspaceId: string } = {
|
|
2000
|
+
accountId: resolution.accountId,
|
|
2001
|
+
workspaceId: resolution.workspaceId,
|
|
2002
|
+
};
|
|
2003
|
+
const grant = await resolveSlackTargetAuthority(deps.db, {
|
|
2004
|
+
subjectId: link.subjectId,
|
|
2005
|
+
targetAccountId: target.accountId,
|
|
2006
|
+
targetWorkspaceId: target.workspaceId,
|
|
964
2007
|
});
|
|
965
|
-
if (!grant
|
|
966
|
-
|
|
2008
|
+
if (!grant) {
|
|
2009
|
+
// Never quietly serve this from a workspace the person did not name. A
|
|
2010
|
+
// routed workspace the subject cannot reach is a refusal with the existing
|
|
2011
|
+
// access-request path, minted for the workspace they actually need.
|
|
2012
|
+
if (resolution.source === "installation" || resolution.source === "thread") {
|
|
2013
|
+
throw new SlackInteractionPermanentError("identity_access_revoked");
|
|
2014
|
+
}
|
|
2015
|
+
await postSlackRouteRefusal(deps, client, entry, {
|
|
2016
|
+
kind: "denied",
|
|
2017
|
+
reason: "no_access_to_route",
|
|
2018
|
+
requested: resolution.label,
|
|
2019
|
+
candidates: [],
|
|
2020
|
+
...target,
|
|
2021
|
+
});
|
|
2022
|
+
return;
|
|
967
2023
|
}
|
|
2024
|
+
// An override addresses the message; it is not part of the request.
|
|
2025
|
+
const routedEntry: SlackInteractionInboxEntry = {
|
|
2026
|
+
...entry,
|
|
2027
|
+
text: slackRoutedRequestText(entry.text, resolution, installation.botUserId),
|
|
2028
|
+
};
|
|
968
2029
|
|
|
969
2030
|
const alreadyDurable = await getSlackInteractionByClientEventId(
|
|
970
2031
|
deps.db,
|
|
971
|
-
|
|
2032
|
+
target.workspaceId,
|
|
972
2033
|
entry.connectionId,
|
|
973
2034
|
`slack:${entry.providerEventId}`,
|
|
974
2035
|
);
|
|
@@ -991,14 +2052,13 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
991
2052
|
if (!boundInteraction) {
|
|
992
2053
|
throw new Error("Durable Slack interaction could not bind its reserved session");
|
|
993
2054
|
}
|
|
994
|
-
await ensureSlackSharedTaskOrigin(deps, boundInteraction, entry, policyResolution);
|
|
2055
|
+
await ensureSlackSharedTaskOrigin(deps, boundInteraction, entry, home, policyResolution);
|
|
995
2056
|
const shouldRepairAcknowledgement =
|
|
996
2057
|
interaction.triggeringProviderEventId === entry.providerEventId ||
|
|
997
2058
|
(usesPrivateBotDm(boundInteraction, entry) && boundInteraction.ackSlackMessageTs === null);
|
|
998
2059
|
if (shouldRepairAcknowledgement) {
|
|
999
2060
|
const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
1000
|
-
|
|
1001
|
-
workspaceId: entry.workspaceId,
|
|
2061
|
+
...home,
|
|
1002
2062
|
connectionId: entry.connectionId,
|
|
1003
2063
|
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
1004
2064
|
sessionId: eventSessionId,
|
|
@@ -1008,7 +2068,8 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
1008
2068
|
return;
|
|
1009
2069
|
}
|
|
1010
2070
|
|
|
1011
|
-
let preparedEntry =
|
|
2071
|
+
let preparedEntry = routedEntry;
|
|
2072
|
+
let preparedModelContext: string | null = null;
|
|
1012
2073
|
let preparedAttachments: PreparedSlackReactionTask = {
|
|
1013
2074
|
resources: [],
|
|
1014
2075
|
attachments: [],
|
|
@@ -1019,7 +2080,8 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
1019
2080
|
const prepared = await prepareSlackInvocationEntry(
|
|
1020
2081
|
deps,
|
|
1021
2082
|
client,
|
|
1022
|
-
|
|
2083
|
+
routedEntry,
|
|
2084
|
+
target,
|
|
1023
2085
|
policyDecision.disposition === "private_handoff"
|
|
1024
2086
|
? async () => {
|
|
1025
2087
|
await requireSlackSharedReadAuthorization(deps, client, entry, policyResolution);
|
|
@@ -1027,31 +2089,33 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
1027
2089
|
: undefined,
|
|
1028
2090
|
);
|
|
1029
2091
|
preparedEntry = prepared.entry;
|
|
2092
|
+
preparedModelContext = prepared.modelContext;
|
|
1030
2093
|
preparedAttachments = prepared.attachments;
|
|
1031
2094
|
}
|
|
1032
2095
|
|
|
1033
2096
|
if (existing?.sessionId) {
|
|
1034
2097
|
const remounted = await remountSlackReactionTaskForSession(
|
|
1035
2098
|
deps,
|
|
1036
|
-
|
|
2099
|
+
target.workspaceId,
|
|
1037
2100
|
existing.sessionId,
|
|
1038
2101
|
preparedAttachments,
|
|
1039
2102
|
);
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
slackInvocationPreparedEntry(preparedEntry, remounted),
|
|
1045
|
-
remounted.resources,
|
|
1046
|
-
);
|
|
2103
|
+
const prepared = slackInvocationPreparedMessage(preparedEntry, remounted, preparedModelContext);
|
|
2104
|
+
await continueSlackSession(deps, grant, existing, prepared.entry, remounted.resources, {
|
|
2105
|
+
modelContext: prepared.modelContext,
|
|
2106
|
+
});
|
|
1047
2107
|
return;
|
|
1048
2108
|
}
|
|
1049
2109
|
if (!hasPermission(grant.permissions, "sessions:create")) {
|
|
1050
2110
|
throw new SlackInteractionPermanentError("sessions_create_denied");
|
|
1051
2111
|
}
|
|
1052
2112
|
const { interaction } = await getOrCreateSlackInteraction(deps.db, {
|
|
1053
|
-
|
|
1054
|
-
|
|
2113
|
+
...target,
|
|
2114
|
+
// Frozen here, after authorization, so a workspace name is never shown to
|
|
2115
|
+
// someone who could not reach it. Null when the routing decision was not a
|
|
2116
|
+
// genuine choice, which is what keeps the line off every message in an
|
|
2117
|
+
// install that only ever had one workspace.
|
|
2118
|
+
routedWorkspaceLabel: await routedWorkspaceLabelFor(deps, resolution),
|
|
1055
2119
|
connectionId: entry.connectionId,
|
|
1056
2120
|
slackTeamId: entry.slackTeamId,
|
|
1057
2121
|
slackChannelId: entry.slackChannelId,
|
|
@@ -1062,32 +2126,36 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
1062
2126
|
owningSubjectId: grant.subjectId,
|
|
1063
2127
|
visibility: routePolicy.visibility,
|
|
1064
2128
|
});
|
|
2129
|
+
requireSlackInteractionMatchesTarget(interaction, target);
|
|
1065
2130
|
if (interaction.sessionId) {
|
|
1066
2131
|
const remounted = await remountSlackReactionTaskForSession(
|
|
1067
2132
|
deps,
|
|
1068
|
-
|
|
2133
|
+
interaction.workspaceId,
|
|
1069
2134
|
interaction.sessionId,
|
|
1070
2135
|
preparedAttachments,
|
|
1071
2136
|
);
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
slackInvocationPreparedEntry(preparedEntry, remounted),
|
|
1077
|
-
remounted.resources,
|
|
1078
|
-
);
|
|
2137
|
+
const prepared = slackInvocationPreparedMessage(preparedEntry, remounted, preparedModelContext);
|
|
2138
|
+
await continueSlackSession(deps, grant, interaction, prepared.entry, remounted.resources, {
|
|
2139
|
+
modelContext: prepared.modelContext,
|
|
2140
|
+
});
|
|
1079
2141
|
return;
|
|
1080
2142
|
}
|
|
1081
2143
|
const preferredModel = await getLatestSessionModelForSubject(
|
|
1082
2144
|
deps.db,
|
|
1083
|
-
|
|
2145
|
+
interaction.workspaceId,
|
|
1084
2146
|
grant.subjectId,
|
|
1085
2147
|
);
|
|
1086
2148
|
let session: Awaited<ReturnType<typeof createSessionForRequest>>;
|
|
1087
2149
|
try {
|
|
1088
|
-
|
|
2150
|
+
const prepared = slackInvocationPreparedMessage(
|
|
2151
|
+
preparedEntry,
|
|
2152
|
+
preparedAttachments,
|
|
2153
|
+
preparedModelContext,
|
|
2154
|
+
);
|
|
2155
|
+
session = await createSessionForRequest(deps, grant, interaction.workspaceId, {
|
|
1089
2156
|
requestedSessionId: interaction.sessionReservationId,
|
|
1090
|
-
initialMessage:
|
|
2157
|
+
initialMessage: prepared.entry.text,
|
|
2158
|
+
...(prepared.modelContext ? { modelContext: prepared.modelContext } : {}),
|
|
1091
2159
|
instructions: SLACK_SESSION_INSTRUCTIONS,
|
|
1092
2160
|
firstPartyMcpTools: slackTaskFirstPartyMcpTools(deps.settings),
|
|
1093
2161
|
resources: preparedAttachments.resources,
|
|
@@ -1118,10 +2186,9 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
1118
2186
|
sessionId: session.id,
|
|
1119
2187
|
});
|
|
1120
2188
|
if (!bound) throw new Error("Slack route could not bind its durable session");
|
|
1121
|
-
await ensureSlackSharedTaskOrigin(deps, bound, entry, policyResolution);
|
|
2189
|
+
await ensureSlackSharedTaskOrigin(deps, bound, entry, home, policyResolution);
|
|
1122
2190
|
const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
1123
|
-
|
|
1124
|
-
workspaceId: entry.workspaceId,
|
|
2191
|
+
...home,
|
|
1125
2192
|
connectionId: entry.connectionId,
|
|
1126
2193
|
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
1127
2194
|
sessionId: session.id,
|
|
@@ -1164,6 +2231,7 @@ async function ensureSlackSharedTaskOrigin(
|
|
|
1164
2231
|
deps: ApiRouteDeps,
|
|
1165
2232
|
interaction: SlackInteraction,
|
|
1166
2233
|
entry: SlackInteractionInboxEntry,
|
|
2234
|
+
home: { accountId: string; workspaceId: string },
|
|
1167
2235
|
resolution:
|
|
1168
2236
|
| Awaited<ReturnType<typeof slackTaskPolicyDecision>>
|
|
1169
2237
|
| {
|
|
@@ -1189,6 +2257,11 @@ async function ensureSlackSharedTaskOrigin(
|
|
|
1189
2257
|
sourceChannelId: entry.slackChannelId,
|
|
1190
2258
|
sourceThreadTs: entry.slackThreadTs ?? entry.slackMessageTs,
|
|
1191
2259
|
initiatingSlackUserId: interaction.initiatingSlackUserId,
|
|
2260
|
+
// The task policy governs what may be read out of the Slack conversation,
|
|
2261
|
+
// which is an installation-surface concern, so its frozen revision keeps
|
|
2262
|
+
// home tenancy while the row itself follows the routed task.
|
|
2263
|
+
policyAccountId: home.accountId,
|
|
2264
|
+
policyWorkspaceId: home.workspaceId,
|
|
1192
2265
|
policyRevisionId: resolution.activePolicy.revision.id,
|
|
1193
2266
|
policyHash: resolution.activePolicy.revision.policyHash,
|
|
1194
2267
|
policyActivationVersion: resolution.activePolicy.head.activationVersion,
|
|
@@ -1225,8 +2298,15 @@ async function prepareSlackInvocationEntry(
|
|
|
1225
2298
|
deps: ApiRouteDeps,
|
|
1226
2299
|
client: OpenGeniSlackBotClient,
|
|
1227
2300
|
entry: SlackInteractionInboxEntry,
|
|
2301
|
+
// Imported attachments become File resources of the session's workspace, so
|
|
2302
|
+
// they follow TARGET tenancy rather than the installation's.
|
|
2303
|
+
target: { accountId: string; workspaceId: string },
|
|
1228
2304
|
authorizeRead?: () => Promise<void>,
|
|
1229
|
-
): Promise<{
|
|
2305
|
+
): Promise<{
|
|
2306
|
+
entry: SlackInteractionInboxEntry;
|
|
2307
|
+
attachments: PreparedSlackReactionTask;
|
|
2308
|
+
modelContext: string | null;
|
|
2309
|
+
}> {
|
|
1230
2310
|
const context = entry.slackThreadTs
|
|
1231
2311
|
? await client.threadReplies({
|
|
1232
2312
|
channelId: entry.slackChannelId,
|
|
@@ -1255,53 +2335,58 @@ async function prepareSlackInvocationEntry(
|
|
|
1255
2335
|
exactMessage = exact.messages.find((message) => message.timestamp === entry.slackMessageTs);
|
|
1256
2336
|
}
|
|
1257
2337
|
const attachments = exactMessage
|
|
1258
|
-
? await prepareSlackMessageAttachments(
|
|
2338
|
+
? await prepareSlackMessageAttachments(
|
|
2339
|
+
deps,
|
|
2340
|
+
client,
|
|
2341
|
+
entry,
|
|
2342
|
+
target,
|
|
2343
|
+
exactMessage.files,
|
|
2344
|
+
authorizeRead,
|
|
2345
|
+
)
|
|
1259
2346
|
: {
|
|
1260
2347
|
resources: [],
|
|
1261
2348
|
attachments: [],
|
|
1262
2349
|
omissionCodes: entry.hasFiles ? ["attachment_unavailable"] : [],
|
|
1263
2350
|
omittedCount: entry.hasFiles ? 1 : 0,
|
|
1264
2351
|
};
|
|
1265
|
-
const
|
|
2352
|
+
const modelContext =
|
|
1266
2353
|
entry.triggerKind === "app_mention"
|
|
1267
|
-
?
|
|
2354
|
+
? slackInvocationModelContext(entry.slackMessageTs, {
|
|
1268
2355
|
messages: context.messages,
|
|
1269
2356
|
nextCursor: context.nextCursor,
|
|
1270
2357
|
kind: entry.slackThreadTs ? "thread" : "channel",
|
|
1271
2358
|
})
|
|
1272
|
-
:
|
|
2359
|
+
: null;
|
|
1273
2360
|
return {
|
|
1274
|
-
entry
|
|
2361
|
+
entry,
|
|
1275
2362
|
attachments,
|
|
2363
|
+
modelContext,
|
|
1276
2364
|
};
|
|
1277
2365
|
}
|
|
1278
2366
|
|
|
1279
|
-
function
|
|
2367
|
+
function slackInvocationPreparedMessage(
|
|
1280
2368
|
entry: SlackInteractionInboxEntry,
|
|
1281
2369
|
attachments: PreparedSlackReactionTask,
|
|
1282
|
-
|
|
2370
|
+
modelContext: string | null,
|
|
2371
|
+
): { entry: SlackInteractionInboxEntry; modelContext: string | null } {
|
|
1283
2372
|
const manifest = slackAttachmentManifest(
|
|
1284
2373
|
attachments,
|
|
1285
2374
|
"Imported invocation attachments",
|
|
1286
2375
|
"invocation",
|
|
1287
2376
|
);
|
|
1288
|
-
|
|
2377
|
+
const combinedModelContext = [modelContext, manifest].filter(Boolean).join("\n\n");
|
|
2378
|
+
return {
|
|
2379
|
+
entry,
|
|
2380
|
+
modelContext: combinedModelContext.length > 0 ? combinedModelContext : null,
|
|
2381
|
+
};
|
|
1289
2382
|
}
|
|
1290
2383
|
|
|
1291
|
-
export function
|
|
1292
|
-
|
|
2384
|
+
export function slackInvocationModelContext(
|
|
2385
|
+
invocationTimestamp: string,
|
|
1293
2386
|
context: SlackInvocationMessageContext,
|
|
1294
2387
|
) {
|
|
1295
|
-
const invocation = {
|
|
1296
|
-
timestamp: entry.slackMessageTs,
|
|
1297
|
-
userId: entry.slackUserId,
|
|
1298
|
-
botId: "",
|
|
1299
|
-
threadTimestamp: "",
|
|
1300
|
-
text: entry.text,
|
|
1301
|
-
files: [],
|
|
1302
|
-
};
|
|
1303
2388
|
const surroundingLines = context.messages
|
|
1304
|
-
.filter((message) => message.timestamp !==
|
|
2389
|
+
.filter((message) => message.timestamp !== invocationTimestamp)
|
|
1305
2390
|
.slice(0, MAX_SLACK_INVOCATION_CONTEXT_MESSAGES)
|
|
1306
2391
|
.sort((left, right) => left.timestamp.localeCompare(right.timestamp))
|
|
1307
2392
|
.map((message) => slackContextMessageLine(message));
|
|
@@ -1313,46 +2398,24 @@ export function slackInvocationTaskText(
|
|
|
1313
2398
|
context.kind === "thread"
|
|
1314
2399
|
? "The containing thread was truncated at the bounded Slack context limit."
|
|
1315
2400
|
: "Only bounded nearby channel context was provided.";
|
|
1316
|
-
|
|
1317
|
-
"The exact Slack invocation was truncated at the bounded Slack input limit.";
|
|
1318
|
-
const prefix = [
|
|
2401
|
+
let prompt = [
|
|
1319
2402
|
"A linked, authorized Slack user explicitly mentioned OpenGeni.",
|
|
2403
|
+
"The visible user message on this turn is the exact accepted Slack invocation.",
|
|
1320
2404
|
"Treat references such as 'this', 'that', or 'the previous message' as referring to the bounded Slack context below when applicable.",
|
|
1321
2405
|
"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
2406
|
"",
|
|
1323
|
-
|
|
2407
|
+
contextLabel,
|
|
1324
2408
|
].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
2409
|
let contextTruncated = context.nextCursor !== null;
|
|
1339
2410
|
for (const line of surroundingLines) {
|
|
1340
2411
|
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) {
|
|
2412
|
+
if (candidate.length + 1 + truncationNotice.length > MAX_SLACK_INPUT_CHARS) {
|
|
1346
2413
|
contextTruncated = true;
|
|
1347
2414
|
break;
|
|
1348
2415
|
}
|
|
1349
2416
|
prompt = candidate;
|
|
1350
2417
|
}
|
|
1351
|
-
|
|
1352
|
-
...(invocationTruncated ? [invocationTruncationNotice] : []),
|
|
1353
|
-
...(contextTruncated ? [truncationNotice] : []),
|
|
1354
|
-
];
|
|
1355
|
-
return notices.length > 0 ? `${prompt}\n${notices.join("\n")}` : prompt;
|
|
2418
|
+
return contextTruncated ? `${prompt}\n${truncationNotice}` : prompt;
|
|
1356
2419
|
}
|
|
1357
2420
|
|
|
1358
2421
|
async function acknowledgeSlackSession(
|
|
@@ -1368,17 +2431,43 @@ async function acknowledgeSlackSession(
|
|
|
1368
2431
|
const privateHandoff =
|
|
1369
2432
|
!directMessageShortcut && interaction.visibility === "private" && entry.triggerKind !== "dm";
|
|
1370
2433
|
const privateBotDm = directMessageShortcut || privateHandoff;
|
|
1371
|
-
const operationId = deterministicUuid(`slack-ack:${interaction.id}`);
|
|
1372
|
-
|
|
1373
|
-
|
|
2434
|
+
const operationId = deterministicUuid(slackPostSeed(interaction, `slack-ack:${interaction.id}`));
|
|
2435
|
+
// The acknowledgement carries exactly one session link plus the Status/Stop
|
|
2436
|
+
// buttons. The how-to prose it used to repeat forever now rides along once,
|
|
2437
|
+
// on this Slack identity's first accepted task in this installation.
|
|
2438
|
+
const started = directMessageShortcut
|
|
2439
|
+
? `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
2440
|
: privateHandoff
|
|
1375
|
-
? `OpenGeni started a private task from the selected Slack conversation. ${openSessionText(deps,
|
|
1376
|
-
: `OpenGeni started this task. ${openSessionText(deps,
|
|
2441
|
+
? `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.`
|
|
2442
|
+
: `OpenGeni started this task. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)}`;
|
|
2443
|
+
// The post ledger binds this fixed operation id to a digest over the message
|
|
2444
|
+
// text, and this function re-runs on every acknowledgement repair. Resolving
|
|
2445
|
+
// the hint freezes it durably before the post, so a repair renders identical
|
|
2446
|
+
// bytes; a failure here raises into the retryable inbox path rather than
|
|
2447
|
+
// binding the ledger to a hint-less acknowledgement it can never revise.
|
|
2448
|
+
const showHint = await resolveSlackInteractionFirstTaskHint(deps.db, {
|
|
2449
|
+
// The identity link is HOME; the interaction row is TARGET.
|
|
2450
|
+
workspaceId: entry.workspaceId,
|
|
2451
|
+
interactionWorkspaceId: interaction.workspaceId,
|
|
2452
|
+
connectionId: entry.connectionId,
|
|
2453
|
+
slackUserId: entry.slackUserId,
|
|
2454
|
+
interactionId: interaction.id,
|
|
2455
|
+
});
|
|
2456
|
+
const text = `${started}${showHint ? slackFirstTaskHintText(deps) : ""}`;
|
|
1377
2457
|
const controls = await controlActionBlocks(deps, interaction, {
|
|
1378
2458
|
messageOperationId: operationId,
|
|
1379
2459
|
sessionEventSequence: 0,
|
|
1380
2460
|
state: "active",
|
|
1381
2461
|
});
|
|
2462
|
+
// The approval and control cards let `text` and `blocks[0]` diverge, so the
|
|
2463
|
+
// line is appended to each independently rather than assuming they match.
|
|
2464
|
+
const acknowledgement = withWorkspaceLine(
|
|
2465
|
+
interaction.routedWorkspaceLabel,
|
|
2466
|
+
text,
|
|
2467
|
+
controls.length > 0
|
|
2468
|
+
? ([{ type: "section", text: { type: "mrkdwn", text } }, ...controls] as SlackMessageBlock[])
|
|
2469
|
+
: undefined,
|
|
2470
|
+
);
|
|
1382
2471
|
const ack = await client.postMessage({
|
|
1383
2472
|
operationId,
|
|
1384
2473
|
...(privateBotDm
|
|
@@ -1389,15 +2478,8 @@ async function acknowledgeSlackSession(
|
|
|
1389
2478
|
? {}
|
|
1390
2479
|
: { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
|
|
1391
2480
|
}),
|
|
1392
|
-
text,
|
|
1393
|
-
...(
|
|
1394
|
-
? {
|
|
1395
|
-
blocks: [
|
|
1396
|
-
{ type: "section", text: { type: "mrkdwn", text } },
|
|
1397
|
-
...controls,
|
|
1398
|
-
] as SlackMessageBlock[],
|
|
1399
|
-
}
|
|
1400
|
-
: {}),
|
|
2481
|
+
text: acknowledgement.text,
|
|
2482
|
+
...(acknowledgement.blocks ? { blocks: acknowledgement.blocks } : {}),
|
|
1401
2483
|
});
|
|
1402
2484
|
if (entry.triggerKind === "slash_command" || privateBotDm) {
|
|
1403
2485
|
const rekeyed = await rekeySlackInteractionRoute(deps.db, {
|
|
@@ -1412,14 +2494,38 @@ async function acknowledgeSlackSession(
|
|
|
1412
2494
|
}
|
|
1413
2495
|
}
|
|
1414
2496
|
|
|
2497
|
+
/**
|
|
2498
|
+
* The one-time onboarding sentence shown on a Slack identity's first
|
|
2499
|
+
* acknowledged task in one installation.
|
|
2500
|
+
*
|
|
2501
|
+
* Rendering is a pure function of the configured command plus the frozen
|
|
2502
|
+
* `firstTaskHint` fact, so every re-render of the same acknowledgement produces
|
|
2503
|
+
* the same bytes for the digest-bound post ledger.
|
|
2504
|
+
*
|
|
2505
|
+
* Note the one remaining input that is not frozen: the text embeds
|
|
2506
|
+
* `settings.slackCommand`. Changing that setting between an acknowledgement
|
|
2507
|
+
* post and a later repair of the same interaction would diverge the digest and
|
|
2508
|
+
* conflict its post operation. The command is deployment configuration that
|
|
2509
|
+
* must already match the registered Slack slash command, so it does not change
|
|
2510
|
+
* under a live installation; a deployment that does rename it should drain
|
|
2511
|
+
* in-flight Slack interactions first.
|
|
2512
|
+
*/
|
|
2513
|
+
function slackFirstTaskHintText(deps: ApiRouteDeps): string {
|
|
2514
|
+
const command = deps.settings.slackCommand;
|
|
2515
|
+
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.`;
|
|
2516
|
+
}
|
|
2517
|
+
|
|
1415
2518
|
async function processSlackReactionInboxEntry(
|
|
1416
2519
|
deps: ApiRouteDeps,
|
|
1417
2520
|
entry: SlackInteractionInboxEntry,
|
|
1418
2521
|
) {
|
|
2522
|
+
// Reaction summon is configured on the installation surface, so the settings,
|
|
2523
|
+
// the connection scopes, and the identity link are all HOME reads.
|
|
2524
|
+
const home = { accountId: entry.accountId, workspaceId: entry.workspaceId } as const;
|
|
1419
2525
|
const [workspace, connection, link] = await Promise.all([
|
|
1420
|
-
getWorkspace(deps.db,
|
|
1421
|
-
getConnectionMetadata(deps.db,
|
|
1422
|
-
getSlackBotUserLink(deps.db,
|
|
2526
|
+
getWorkspace(deps.db, home.workspaceId),
|
|
2527
|
+
getConnectionMetadata(deps.db, home.workspaceId, entry.connectionId, null),
|
|
2528
|
+
getSlackBotUserLink(deps.db, home.workspaceId, entry.connectionId, entry.slackUserId),
|
|
1423
2529
|
]);
|
|
1424
2530
|
const settings = resolveWorkspaceSlackReactionSummonSettings(workspace?.settings);
|
|
1425
2531
|
if (
|
|
@@ -1433,23 +2539,55 @@ async function processSlackReactionInboxEntry(
|
|
|
1433
2539
|
) {
|
|
1434
2540
|
return;
|
|
1435
2541
|
}
|
|
1436
|
-
|
|
1437
|
-
|
|
2542
|
+
// A reaction is never a direct message, so the routing decision here is the
|
|
2543
|
+
// channel's remembered answer, the sole candidate, or the installation's own
|
|
2544
|
+
// workspace. It runs before the provider context read so an unauthorized
|
|
2545
|
+
// subject never causes OpenGeni to read the Slack conversation.
|
|
2546
|
+
const routeResolution = await resolveSlackRouteForEntry(deps, home, entry, link.subjectId, {
|
|
2547
|
+
// A reaction carries no message text of the reacting person, so no prefix.
|
|
2548
|
+
botUserId: null,
|
|
2549
|
+
threadTenancy: null,
|
|
2550
|
+
askEnabled: false,
|
|
1438
2551
|
});
|
|
1439
|
-
if (
|
|
1440
|
-
throw new SlackInteractionPermanentError("
|
|
2552
|
+
if (routeResolution.kind === "ask") {
|
|
2553
|
+
throw new SlackInteractionPermanentError("slack_route_choice_unavailable");
|
|
1441
2554
|
}
|
|
1442
|
-
if (
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
2555
|
+
if (routeResolution.kind === "denied") {
|
|
2556
|
+
const summonClient = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
2557
|
+
...home,
|
|
2558
|
+
connectionId: entry.connectionId,
|
|
2559
|
+
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
2560
|
+
});
|
|
2561
|
+
await postSlackRouteRefusal(deps, summonClient, entry, routeResolution);
|
|
2562
|
+
return;
|
|
1447
2563
|
}
|
|
2564
|
+
let target: { accountId: string; workspaceId: string } = {
|
|
2565
|
+
accountId: routeResolution.accountId,
|
|
2566
|
+
workspaceId: routeResolution.workspaceId,
|
|
2567
|
+
};
|
|
2568
|
+
const authorizeTarget = async () => {
|
|
2569
|
+
const resolved = await resolveSlackTargetAuthority(deps.db, {
|
|
2570
|
+
subjectId: link.subjectId,
|
|
2571
|
+
targetAccountId: target.accountId,
|
|
2572
|
+
targetWorkspaceId: target.workspaceId,
|
|
2573
|
+
});
|
|
2574
|
+
if (!resolved) {
|
|
2575
|
+
throw new SlackInteractionPermanentError("identity_access_revoked");
|
|
2576
|
+
}
|
|
2577
|
+
if (
|
|
2578
|
+
!hasPermission(resolved.permissions, "sessions:create") ||
|
|
2579
|
+
!hasPermission(resolved.permissions, "sessions:control")
|
|
2580
|
+
) {
|
|
2581
|
+
throw new SlackInteractionPermanentError("reaction_session_permissions_denied");
|
|
2582
|
+
}
|
|
2583
|
+
return resolved;
|
|
2584
|
+
};
|
|
2585
|
+
let grant = await authorizeTarget();
|
|
1448
2586
|
|
|
1449
2587
|
const clientEventId = `slack:${entry.providerEventId}`;
|
|
1450
2588
|
const durableInteraction = await getSlackInteractionByClientEventId(
|
|
1451
2589
|
deps.db,
|
|
1452
|
-
|
|
2590
|
+
target.workspaceId,
|
|
1453
2591
|
entry.connectionId,
|
|
1454
2592
|
clientEventId,
|
|
1455
2593
|
);
|
|
@@ -1481,8 +2619,7 @@ async function processSlackReactionInboxEntry(
|
|
|
1481
2619
|
await reopenSlackInteractionDelivery(deps.db, boundInteraction);
|
|
1482
2620
|
if (shouldRepairAcknowledgement) {
|
|
1483
2621
|
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
1484
|
-
|
|
1485
|
-
workspaceId: entry.workspaceId,
|
|
2622
|
+
...home,
|
|
1486
2623
|
connectionId: entry.connectionId,
|
|
1487
2624
|
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
1488
2625
|
sessionId: eventSessionId,
|
|
@@ -1493,8 +2630,7 @@ async function processSlackReactionInboxEntry(
|
|
|
1493
2630
|
}
|
|
1494
2631
|
|
|
1495
2632
|
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
1496
|
-
|
|
1497
|
-
workspaceId: entry.workspaceId,
|
|
2633
|
+
...home,
|
|
1498
2634
|
connectionId: entry.connectionId,
|
|
1499
2635
|
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
1500
2636
|
});
|
|
@@ -1525,18 +2661,25 @@ async function processSlackReactionInboxEntry(
|
|
|
1525
2661
|
if (!saved) throw new Error("Slack reaction inbox checkpoint claim was lost");
|
|
1526
2662
|
},
|
|
1527
2663
|
});
|
|
1528
|
-
const preparedTask = await prepareSlackReactionTask(deps, client, entry, context);
|
|
1529
2664
|
const routeKey = slackRouteKey(entry.slackChannelId, context.threadTimestamp);
|
|
1530
|
-
const existing = await
|
|
1531
|
-
|
|
1532
|
-
entry.
|
|
1533
|
-
entry.connectionId,
|
|
2665
|
+
const existing = await getSlackInteractionByConnectionRoute(deps.db, {
|
|
2666
|
+
accountId: home.accountId,
|
|
2667
|
+
connectionId: entry.connectionId,
|
|
1534
2668
|
routeKey,
|
|
1535
|
-
);
|
|
2669
|
+
});
|
|
2670
|
+
if (
|
|
2671
|
+
existing &&
|
|
2672
|
+
(existing.accountId !== target.accountId || existing.workspaceId !== target.workspaceId)
|
|
2673
|
+
) {
|
|
2674
|
+
// A mapped thread keeps the workspace it was created in.
|
|
2675
|
+
target = { accountId: existing.accountId, workspaceId: existing.workspaceId };
|
|
2676
|
+
grant = await authorizeTarget();
|
|
2677
|
+
}
|
|
2678
|
+
const preparedTask = await prepareSlackReactionTask(deps, client, entry, target, context);
|
|
1536
2679
|
if (existing?.sessionId) {
|
|
1537
2680
|
const appendedTask = await remountSlackReactionTaskForSession(
|
|
1538
2681
|
deps,
|
|
1539
|
-
|
|
2682
|
+
existing.workspaceId,
|
|
1540
2683
|
existing.sessionId,
|
|
1541
2684
|
preparedTask,
|
|
1542
2685
|
);
|
|
@@ -1550,8 +2693,11 @@ async function processSlackReactionInboxEntry(
|
|
|
1550
2693
|
return;
|
|
1551
2694
|
}
|
|
1552
2695
|
const { interaction } = await getOrCreateSlackInteraction(deps.db, {
|
|
1553
|
-
|
|
1554
|
-
|
|
2696
|
+
...target,
|
|
2697
|
+
// A reaction routes exactly like a mention, so it names its workspace the
|
|
2698
|
+
// same way. Resolved after `authorizeTarget`, so a name never reaches
|
|
2699
|
+
// somebody who could not reach the workspace.
|
|
2700
|
+
routedWorkspaceLabel: await routedWorkspaceLabelFor(deps, routeResolution),
|
|
1555
2701
|
connectionId: entry.connectionId,
|
|
1556
2702
|
slackTeamId: entry.slackTeamId,
|
|
1557
2703
|
slackChannelId: entry.slackChannelId,
|
|
@@ -1562,10 +2708,11 @@ async function processSlackReactionInboxEntry(
|
|
|
1562
2708
|
owningSubjectId: grant.subjectId,
|
|
1563
2709
|
visibility: "workspace",
|
|
1564
2710
|
});
|
|
2711
|
+
requireSlackInteractionMatchesTarget(interaction, target);
|
|
1565
2712
|
if (interaction.sessionId) {
|
|
1566
2713
|
const appendedTask = await remountSlackReactionTaskForSession(
|
|
1567
2714
|
deps,
|
|
1568
|
-
|
|
2715
|
+
interaction.workspaceId,
|
|
1569
2716
|
interaction.sessionId,
|
|
1570
2717
|
preparedTask,
|
|
1571
2718
|
);
|
|
@@ -1588,13 +2735,13 @@ async function processSlackReactionInboxEntry(
|
|
|
1588
2735
|
}
|
|
1589
2736
|
const preferredModel = await getLatestSessionModelForSubject(
|
|
1590
2737
|
deps.db,
|
|
1591
|
-
|
|
2738
|
+
interaction.workspaceId,
|
|
1592
2739
|
grant.subjectId,
|
|
1593
2740
|
);
|
|
1594
2741
|
const preparedEntry = slackReactionPreparedEntry(entry, context, preparedTask);
|
|
1595
2742
|
let session: Awaited<ReturnType<typeof createSessionForRequest>>;
|
|
1596
2743
|
try {
|
|
1597
|
-
session = await createSessionForRequest(deps, grant,
|
|
2744
|
+
session = await createSessionForRequest(deps, grant, interaction.workspaceId, {
|
|
1598
2745
|
requestedSessionId: interaction.sessionReservationId,
|
|
1599
2746
|
initialMessage: preparedEntry.text,
|
|
1600
2747
|
instructions: SLACK_SESSION_INSTRUCTIONS,
|
|
@@ -1644,11 +2791,15 @@ async function acknowledgeSlackReactionSession(
|
|
|
1644
2791
|
if (!interaction.sessionId) {
|
|
1645
2792
|
throw new Error("Slack reaction acknowledgement requires a bound session");
|
|
1646
2793
|
}
|
|
2794
|
+
const started = `OpenGeni started from the :${emoji}: reaction. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} If the intended action is unclear, OpenGeni will ask in this thread. Reply here to continue, or reply \`stop\` to stop.`;
|
|
2795
|
+
const rendered = withWorkspaceLine(interaction.routedWorkspaceLabel, started);
|
|
1647
2796
|
await client.postMessage({
|
|
1648
|
-
operationId: deterministicUuid(
|
|
2797
|
+
operationId: deterministicUuid(
|
|
2798
|
+
slackPostSeed(interaction, `slack-reaction-ack:${interaction.id}`),
|
|
2799
|
+
),
|
|
1649
2800
|
channelId: interaction.slackChannelId,
|
|
1650
2801
|
threadTimestamp: interaction.slackThreadTs,
|
|
1651
|
-
text:
|
|
2802
|
+
text: rendered.text,
|
|
1652
2803
|
});
|
|
1653
2804
|
}
|
|
1654
2805
|
|
|
@@ -1761,16 +2912,25 @@ async function prepareSlackReactionTask(
|
|
|
1761
2912
|
deps: ApiRouteDeps,
|
|
1762
2913
|
client: OpenGeniSlackBotClient,
|
|
1763
2914
|
entry: SlackInteractionInboxEntry,
|
|
2915
|
+
target: { accountId: string; workspaceId: string },
|
|
1764
2916
|
context: SlackReactionMessageContext,
|
|
1765
2917
|
): Promise<PreparedSlackReactionTask> {
|
|
1766
|
-
return await prepareSlackMessageAttachments(
|
|
2918
|
+
return await prepareSlackMessageAttachments(
|
|
2919
|
+
deps,
|
|
2920
|
+
client,
|
|
2921
|
+
entry,
|
|
2922
|
+
target,
|
|
2923
|
+
context.reactedMessage.files,
|
|
2924
|
+
);
|
|
1767
2925
|
}
|
|
1768
2926
|
|
|
1769
2927
|
async function prepareSlackMessageAttachments(
|
|
1770
2928
|
deps: ApiRouteDeps,
|
|
1771
2929
|
client: OpenGeniSlackBotClient,
|
|
1772
2930
|
entry: SlackInteractionInboxEntry,
|
|
2931
|
+
target: { accountId: string; workspaceId: string },
|
|
1773
2932
|
exactFiles: readonly { id: string; name: string; title: string }[],
|
|
2933
|
+
authorizeSharedRead?: () => Promise<void>,
|
|
1774
2934
|
): Promise<PreparedSlackReactionTask> {
|
|
1775
2935
|
if (exactFiles.length === 0) {
|
|
1776
2936
|
return { resources: [], attachments: [], omissionCodes: [], omittedCount: 0 };
|
|
@@ -1790,12 +2950,13 @@ async function prepareSlackMessageAttachments(
|
|
|
1790
2950
|
name: file.name,
|
|
1791
2951
|
title: file.title,
|
|
1792
2952
|
})),
|
|
2953
|
+
...(authorizeSharedRead ? { authorizeSharedRead } : {}),
|
|
1793
2954
|
});
|
|
1794
2955
|
const downloaded: Awaited<ReturnType<OpenGeniSlackBotClient["downloadReactionImage"]>>[] = [];
|
|
1795
2956
|
let aggregateBytes = 0;
|
|
1796
2957
|
for (const image of prepared) {
|
|
1797
2958
|
try {
|
|
1798
|
-
const value = await client.downloadReactionImage(image);
|
|
2959
|
+
const value = await client.downloadReactionImage(image, authorizeSharedRead);
|
|
1799
2960
|
if (
|
|
1800
2961
|
value.bytes.byteLength > SLACK_REACTION_IMAGE_MAX_BYTES ||
|
|
1801
2962
|
aggregateBytes + value.bytes.byteLength > MAX_SLACK_REACTION_IMAGE_AGGREGATE_BYTES
|
|
@@ -1828,8 +2989,8 @@ async function prepareSlackMessageAttachments(
|
|
|
1828
2989
|
await importSlackReactionImage(
|
|
1829
2990
|
{ db: deps.db, objectStorage: deps.objectStorage },
|
|
1830
2991
|
{
|
|
1831
|
-
accountId:
|
|
1832
|
-
workspaceId:
|
|
2992
|
+
accountId: target.accountId,
|
|
2993
|
+
workspaceId: target.workspaceId,
|
|
1833
2994
|
connectionId: entry.connectionId,
|
|
1834
2995
|
slackTeamId: entry.slackTeamId,
|
|
1835
2996
|
slackChannelId: entry.slackChannelId,
|
|
@@ -1968,9 +3129,11 @@ async function acceptSlackReactionTask(
|
|
|
1968
3129
|
resources: FileResourceRef[],
|
|
1969
3130
|
) {
|
|
1970
3131
|
const clientEventId = `slack:${entry.providerEventId}`;
|
|
3132
|
+
// Session events and user messages belong to the workspace that owns the
|
|
3133
|
+
// session, which is the workspace the grant authorized, not the installation.
|
|
1971
3134
|
const existing = await getSessionEventByClientEventId(
|
|
1972
3135
|
deps.db,
|
|
1973
|
-
|
|
3136
|
+
grant.workspaceId,
|
|
1974
3137
|
sessionId,
|
|
1975
3138
|
clientEventId,
|
|
1976
3139
|
);
|
|
@@ -1980,7 +3143,7 @@ async function acceptSlackReactionTask(
|
|
|
1980
3143
|
}
|
|
1981
3144
|
return;
|
|
1982
3145
|
}
|
|
1983
|
-
await acceptSessionUserMessage(deps, grant,
|
|
3146
|
+
await acceptSessionUserMessage(deps, grant, grant.workspaceId, sessionId, {
|
|
1984
3147
|
text: entry.text,
|
|
1985
3148
|
resources,
|
|
1986
3149
|
clientEventId,
|
|
@@ -1993,6 +3156,7 @@ async function continueSlackSession(
|
|
|
1993
3156
|
interaction: SlackInteraction,
|
|
1994
3157
|
entry: SlackInteractionInboxEntry,
|
|
1995
3158
|
resources: FileResourceRef[] = [],
|
|
3159
|
+
options: { modelContext?: string | null } = {},
|
|
1996
3160
|
) {
|
|
1997
3161
|
if (
|
|
1998
3162
|
!interaction.sessionId ||
|
|
@@ -2023,7 +3187,7 @@ async function continueSlackSession(
|
|
|
2023
3187
|
}
|
|
2024
3188
|
const pending = await listSessionHumanInputRequests(
|
|
2025
3189
|
deps.db,
|
|
2026
|
-
|
|
3190
|
+
interaction.workspaceId,
|
|
2027
3191
|
interaction.sessionId,
|
|
2028
3192
|
{ status: "pending", limit: 2 },
|
|
2029
3193
|
);
|
|
@@ -2063,8 +3227,9 @@ async function continueSlackSession(
|
|
|
2063
3227
|
if (!hasPermission(grant.permissions, "sessions:control")) {
|
|
2064
3228
|
throw new SlackInteractionPermanentError("sessions_control_denied");
|
|
2065
3229
|
}
|
|
2066
|
-
await acceptSessionUserMessage(deps, grant,
|
|
3230
|
+
await acceptSessionUserMessage(deps, grant, interaction.workspaceId, interaction.sessionId, {
|
|
2067
3231
|
text: entry.text,
|
|
3232
|
+
...(options.modelContext ? { modelContext: options.modelContext } : {}),
|
|
2068
3233
|
resources,
|
|
2069
3234
|
clientEventId: `slack:${entry.providerEventId}`,
|
|
2070
3235
|
});
|
|
@@ -2085,6 +3250,9 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
|
|
|
2085
3250
|
const separator = entry.text.lastIndexOf(":");
|
|
2086
3251
|
const actionId = separator > 0 ? entry.text.slice(0, separator) : "";
|
|
2087
3252
|
const handleId = separator > 0 ? entry.text.slice(separator + 1) : "";
|
|
3253
|
+
// The block-action inbox row carries HOME tenancy, exactly like every other
|
|
3254
|
+
// inbox row: it is the installation binding that received the click.
|
|
3255
|
+
const home = { accountId: entry.accountId, workspaceId: entry.workspaceId } as const;
|
|
2088
3256
|
const route = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
|
|
2089
3257
|
if (
|
|
2090
3258
|
!route ||
|
|
@@ -2094,19 +3262,39 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
|
|
|
2094
3262
|
) {
|
|
2095
3263
|
throw new SlackInteractionPermanentError("slack_action_installation_changed");
|
|
2096
3264
|
}
|
|
3265
|
+
// A route choice is not an action handle: the picker exists BEFORE any
|
|
3266
|
+
// session, so there is no handle row to load and the ordinary path below
|
|
3267
|
+
// would refuse it. Branch first.
|
|
3268
|
+
if (actionId === "opengeni.route.select" || actionId === "opengeni.route.cancel") {
|
|
3269
|
+
await handleSlackRouteChoice(deps, home, entry, actionId, handleId);
|
|
3270
|
+
return;
|
|
3271
|
+
}
|
|
3272
|
+
// A handle lives in the workspace that owns its session, which is not the
|
|
3273
|
+
// installation's workspace once routing is on. Learn that scope from the
|
|
3274
|
+
// content-free probe, then read the full handle under it: every authorization
|
|
3275
|
+
// check below is unchanged and still runs in the handle's own tenancy.
|
|
3276
|
+
const handleTenancy =
|
|
3277
|
+
(await probeSlackActionHandleTenancy(deps.db, {
|
|
3278
|
+
accountId: home.accountId,
|
|
3279
|
+
connectionId: entry.connectionId,
|
|
3280
|
+
handleId,
|
|
3281
|
+
})) ?? home;
|
|
2097
3282
|
const handle = await getSlackInteractionActionHandle(deps.db, {
|
|
2098
|
-
|
|
2099
|
-
workspaceId: entry.workspaceId,
|
|
3283
|
+
...handleTenancy,
|
|
2100
3284
|
handleId,
|
|
2101
3285
|
});
|
|
2102
3286
|
if (!handle || SLACK_ACTION_ID_BY_KIND[handle.actionKind] !== actionId) {
|
|
2103
3287
|
throw new SlackInteractionPermanentError("slack_action_handle_invalid");
|
|
2104
3288
|
}
|
|
3289
|
+
// An action handle is a TARGET fact: it is composite-FK'd to its interaction
|
|
3290
|
+
// and its RESTRICTIVE session-visibility policy resolves the session under the
|
|
3291
|
+
// handle's own tenancy, so every read, settle and interaction lookup below
|
|
3292
|
+
// uses the handle's scope rather than the installation's.
|
|
3293
|
+
const target = { accountId: handle.accountId, workspaceId: handle.workspaceId } as const;
|
|
2105
3294
|
if (handle.status !== "pending") return;
|
|
2106
3295
|
if (handle.expiresAt.getTime() <= Date.now()) {
|
|
2107
3296
|
await settleSlackInteractionActionHandles(deps.db, {
|
|
2108
|
-
|
|
2109
|
-
workspaceId: entry.workspaceId,
|
|
3297
|
+
...target,
|
|
2110
3298
|
handleId: handle.id,
|
|
2111
3299
|
result: "expired",
|
|
2112
3300
|
stale: true,
|
|
@@ -2118,14 +3306,15 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
|
|
|
2118
3306
|
}
|
|
2119
3307
|
const [interaction, link, post] = await Promise.all([
|
|
2120
3308
|
getSlackInteractionById(deps.db, {
|
|
2121
|
-
|
|
2122
|
-
workspaceId: entry.workspaceId,
|
|
3309
|
+
...target,
|
|
2123
3310
|
interactionId: handle.interactionId,
|
|
2124
3311
|
}),
|
|
2125
|
-
|
|
3312
|
+
// Identity stays HOME.
|
|
3313
|
+
getSlackBotUserLink(deps.db, home.workspaceId, entry.connectionId, entry.slackUserId),
|
|
3314
|
+
// The post ledger is written by the bot credential, so it stays HOME too.
|
|
2126
3315
|
getSlackBotPostOperation(
|
|
2127
3316
|
deps.db,
|
|
2128
|
-
|
|
3317
|
+
home.workspaceId,
|
|
2129
3318
|
entry.connectionId,
|
|
2130
3319
|
handle.messageOperationId,
|
|
2131
3320
|
),
|
|
@@ -2146,34 +3335,51 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
|
|
|
2146
3335
|
) {
|
|
2147
3336
|
throw new SlackInteractionPermanentError("slack_action_authority_changed");
|
|
2148
3337
|
}
|
|
2149
|
-
const grant = await
|
|
2150
|
-
|
|
3338
|
+
const grant = await resolveSlackTargetAuthority(deps.db, {
|
|
3339
|
+
subjectId: link.subjectId,
|
|
3340
|
+
targetAccountId: target.accountId,
|
|
3341
|
+
targetWorkspaceId: target.workspaceId,
|
|
2151
3342
|
});
|
|
2152
|
-
if (
|
|
2153
|
-
!grant ||
|
|
2154
|
-
grant.accountId !== entry.accountId ||
|
|
2155
|
-
!hasPermission(grant.permissions, "sessions:control")
|
|
2156
|
-
) {
|
|
3343
|
+
if (!grant || !hasPermission(grant.permissions, "sessions:control")) {
|
|
2157
3344
|
throw new SlackInteractionPermanentError("sessions_control_denied");
|
|
2158
3345
|
}
|
|
2159
3346
|
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
2160
|
-
|
|
2161
|
-
workspaceId: entry.workspaceId,
|
|
3347
|
+
...home,
|
|
2162
3348
|
connectionId: entry.connectionId,
|
|
2163
3349
|
subjectId: grant.subjectId,
|
|
2164
3350
|
sessionId: handle.sessionId,
|
|
2165
3351
|
});
|
|
2166
3352
|
const outcome = await executeSlackAction(deps, grant, interaction, handle);
|
|
3353
|
+
// A control click replaces the message it was pressed on. When that message
|
|
3354
|
+
// is this interaction's acknowledgement and this interaction won the one-time
|
|
3355
|
+
// onboarding hint, the update carries the hint forward: otherwise pressing
|
|
3356
|
+
// Status would permanently destroy the only copy of prose a Slack identity is
|
|
3357
|
+
// ever shown. The handle's message operation identifies the acknowledgement
|
|
3358
|
+
// exactly, independent of route rekeying. Later control cards have their own
|
|
3359
|
+
// operation ids, so the hint still appears on exactly one message.
|
|
3360
|
+
const acknowledgementOperationId = deterministicUuid(
|
|
3361
|
+
slackPostSeed(interaction, `slack-ack:${interaction.id}`),
|
|
3362
|
+
);
|
|
3363
|
+
const updateText =
|
|
3364
|
+
interaction.firstTaskHint === true && handle.messageOperationId === acknowledgementOperationId
|
|
3365
|
+
? `${outcome.text}${slackFirstTaskHintText(deps)}`
|
|
3366
|
+
: outcome.text;
|
|
3367
|
+
// A control click replaces a message that already carried the line, so the
|
|
3368
|
+
// replacement carries it too rather than quietly stripping it.
|
|
3369
|
+
const rendered = withWorkspaceLine(interaction.routedWorkspaceLabel, updateText, [
|
|
3370
|
+
{ type: "section", text: { type: "mrkdwn", text: updateText } },
|
|
3371
|
+
]);
|
|
2167
3372
|
await client.updateMessage({
|
|
2168
|
-
operationId: deterministicUuid(
|
|
3373
|
+
operationId: deterministicUuid(
|
|
3374
|
+
slackPostSeed(interaction, `slack-action-update:${handle.id}:${outcome.result}`),
|
|
3375
|
+
),
|
|
2169
3376
|
channelId: entry.slackChannelId,
|
|
2170
3377
|
timestamp: entry.slackMessageTs,
|
|
2171
|
-
text:
|
|
2172
|
-
blocks
|
|
3378
|
+
text: rendered.text,
|
|
3379
|
+
...(rendered.blocks ? { blocks: rendered.blocks } : {}),
|
|
2173
3380
|
});
|
|
2174
3381
|
await settleSlackInteractionActionHandles(deps.db, {
|
|
2175
|
-
|
|
2176
|
-
workspaceId: entry.workspaceId,
|
|
3382
|
+
...target,
|
|
2177
3383
|
handleId: handle.id,
|
|
2178
3384
|
result: outcome.result,
|
|
2179
3385
|
...(outcome.stale ? { stale: true } : {}),
|
|
@@ -2348,9 +3554,20 @@ async function executeSlackAction(
|
|
|
2348
3554
|
}
|
|
2349
3555
|
const session = await getSession(deps.db, grant.workspaceId, handle.sessionId);
|
|
2350
3556
|
if (!session) throw new SlackInteractionPermanentError("slack_action_session_missing");
|
|
3557
|
+
// The result post no longer advertises recurrence. The Status card is where
|
|
3558
|
+
// the requester finds it, and only while this exact requester still holds
|
|
3559
|
+
// schedule authority in this workspace. The deep link and the schedules
|
|
3560
|
+
// authority behind it are unchanged: the browser rechecks both.
|
|
3561
|
+
const recurring =
|
|
3562
|
+
hasPermission(grant.permissions, "sessions:read") &&
|
|
3563
|
+
hasPermission(grant.permissions, "scheduled_tasks:manage")
|
|
3564
|
+
? makeRecurringText(deps, grant.workspaceId, handle.sessionId)
|
|
3565
|
+
: null;
|
|
2351
3566
|
return {
|
|
2352
3567
|
result: "status",
|
|
2353
|
-
text: `${mention}OpenGeni task status: *${session.status.replaceAll("_", " ")}
|
|
3568
|
+
text: `${mention}OpenGeni task status: *${session.status.replaceAll("_", " ")}*.${
|
|
3569
|
+
recurring ? `\n\n${recurring}` : ""
|
|
3570
|
+
}`,
|
|
2354
3571
|
...(session.status === "cancelled" || session.status === "failed"
|
|
2355
3572
|
? {}
|
|
2356
3573
|
: { controlState: "active" as const }),
|
|
@@ -2367,18 +3584,25 @@ async function publishSlackSharedResult(
|
|
|
2367
3584
|
if (!handle.targetId || !interaction.sessionId) {
|
|
2368
3585
|
throw new SlackInteractionPermanentError("slack_shared_publication_target_invalid");
|
|
2369
3586
|
}
|
|
2370
|
-
const
|
|
3587
|
+
const publicationHome = await resolveSlackDeliveryHome(deps, interaction);
|
|
3588
|
+
const [origin, event] = await Promise.all([
|
|
2371
3589
|
getSlackSharedTaskOrigin(deps.db, {
|
|
2372
3590
|
accountId: interaction.accountId,
|
|
2373
3591
|
workspaceId: interaction.workspaceId,
|
|
2374
3592
|
interactionId: interaction.id,
|
|
2375
3593
|
}),
|
|
2376
|
-
getActiveSlackTaskPolicy(deps.db, {
|
|
2377
|
-
accountId: interaction.accountId,
|
|
2378
|
-
workspaceId: interaction.workspaceId,
|
|
2379
|
-
}),
|
|
2380
3594
|
getSessionEvent(deps.db, interaction.workspaceId, handle.targetId),
|
|
2381
3595
|
]);
|
|
3596
|
+
// The Slack task policy governs what may be read out of, and published back
|
|
3597
|
+
// to, the Slack conversation, so it is an installation-surface fact. The
|
|
3598
|
+
// origin froze which tenancy that was; a row written before routing existed
|
|
3599
|
+
// carries null and implied its own, because the two could not differ then.
|
|
3600
|
+
const activePolicy = origin
|
|
3601
|
+
? await getActiveSlackTaskPolicy(deps.db, {
|
|
3602
|
+
accountId: origin.policyAccountId ?? origin.accountId,
|
|
3603
|
+
workspaceId: origin.policyWorkspaceId ?? origin.workspaceId,
|
|
3604
|
+
})
|
|
3605
|
+
: null;
|
|
2382
3606
|
if (
|
|
2383
3607
|
!origin ||
|
|
2384
3608
|
origin.connectionId !== interaction.connectionId ||
|
|
@@ -2400,8 +3624,7 @@ async function publishSlackSharedResult(
|
|
|
2400
3624
|
};
|
|
2401
3625
|
}
|
|
2402
3626
|
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
2403
|
-
|
|
2404
|
-
workspaceId: interaction.workspaceId,
|
|
3627
|
+
...publicationHome,
|
|
2405
3628
|
connectionId: interaction.connectionId,
|
|
2406
3629
|
subjectId: grant.subjectId,
|
|
2407
3630
|
sessionId: interaction.sessionId,
|
|
@@ -2480,31 +3703,22 @@ async function validatedSlackRequester(
|
|
|
2480
3703
|
SlackInteraction,
|
|
2481
3704
|
"accountId" | "workspaceId" | "connectionId" | "initiatingSlackUserId" | "owningSubjectId"
|
|
2482
3705
|
>,
|
|
3706
|
+
// Identity links are HOME facts, unique on (connection_id, slack_user_id) and
|
|
3707
|
+
// RLS-visible only under the installation's own tenancy.
|
|
3708
|
+
home: { accountId: string; workspaceId: string },
|
|
2483
3709
|
) {
|
|
2484
3710
|
if (!interaction.initiatingSlackUserId) {
|
|
2485
|
-
return { authorized: false,
|
|
3711
|
+
return { authorized: false, mention: "" };
|
|
2486
3712
|
}
|
|
2487
|
-
const
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
),
|
|
2494
|
-
getWorkspaceGrant(deps.db, interaction.owningSubjectId, interaction.workspaceId, {
|
|
2495
|
-
principalKind: "human_session",
|
|
2496
|
-
}),
|
|
2497
|
-
]);
|
|
3713
|
+
const link = await getSlackBotUserLink(
|
|
3714
|
+
deps.db,
|
|
3715
|
+
home.workspaceId,
|
|
3716
|
+
interaction.connectionId,
|
|
3717
|
+
interaction.initiatingSlackUserId,
|
|
3718
|
+
);
|
|
2498
3719
|
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
3720
|
return {
|
|
2506
3721
|
authorized,
|
|
2507
|
-
canSchedule,
|
|
2508
3722
|
mention: authorized ? slackRequesterMention(interaction) : "",
|
|
2509
3723
|
};
|
|
2510
3724
|
}
|
|
@@ -2610,7 +3824,7 @@ async function slackApprovalCard(
|
|
|
2610
3824
|
requesterAuthorized: boolean,
|
|
2611
3825
|
): Promise<{ text: string; blocks?: SlackMessageBlock[]; operationId: string }> {
|
|
2612
3826
|
const operationId = deterministicUuid(
|
|
2613
|
-
`slack-delivery:${interaction.id}:${event.sequence}:approval
|
|
3827
|
+
slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:approval`),
|
|
2614
3828
|
);
|
|
2615
3829
|
const approvals = slackApprovalSummaries(event.payload);
|
|
2616
3830
|
const fallback = approvals.length
|
|
@@ -2763,7 +3977,7 @@ async function slackHumanInputCard(
|
|
|
2763
3977
|
);
|
|
2764
3978
|
if (!request || request.status !== "pending") return null;
|
|
2765
3979
|
const operationId = deterministicUuid(
|
|
2766
|
-
`slack-delivery:${interaction.id}:${event.sequence}:human-input
|
|
3980
|
+
slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:human-input`),
|
|
2767
3981
|
);
|
|
2768
3982
|
const text = `${mention}OpenGeni needs your input:\n${formatQuestions(request.questions)}\nReply in this thread${request.allowSkip ? " or choose Skip" : ""}.`;
|
|
2769
3983
|
if (!requesterAuthorized || !interaction.initiatingSlackUserId) return { text, operationId };
|
|
@@ -2831,6 +4045,157 @@ async function slackHumanInputCard(
|
|
|
2831
4045
|
return { text, blocks, operationId };
|
|
2832
4046
|
}
|
|
2833
4047
|
|
|
4048
|
+
/**
|
|
4049
|
+
* A child of a Slack-originated session is never itself mapped to a Slack
|
|
4050
|
+
* thread, so the parent's bounded `child_requires_action` notice is the only
|
|
4051
|
+
* path that can tell the human that a worker they started is blocked. The card
|
|
4052
|
+
* is a pointer, not a second question card: one bounded preview of the first
|
|
4053
|
+
* question (or the waiting approval count) plus the child link. The child's own
|
|
4054
|
+
* OpenGeni card remains the place the human actually answers.
|
|
4055
|
+
*
|
|
4056
|
+
* Every deferred child lifecycle kind (`child_progress`,
|
|
4057
|
+
* `child_waiting_capacity`, `child_requires_action_resolved`, `child_paused`)
|
|
4058
|
+
* returns null here so Slack stays quiet, and so does every non-child machine
|
|
4059
|
+
* input that shares the `system.update.pending` event type.
|
|
4060
|
+
*
|
|
4061
|
+
* The caller has already proven that this workspace turned the notice on. The
|
|
4062
|
+
* card defaults off per workspace because the in-app rail and priority feed
|
|
4063
|
+
* already surface a blocked child.
|
|
4064
|
+
*/
|
|
4065
|
+
/**
|
|
4066
|
+
* The exact durable notice behind one `system.update.pending` event, or null
|
|
4067
|
+
* when it no longer describes a blocked worker.
|
|
4068
|
+
*
|
|
4069
|
+
* Slack delivery runs behind the session: a widened retry window, a replica
|
|
4070
|
+
* claim, or simply a later page can reach this event after the child already
|
|
4071
|
+
* got its answer. Two facts have to agree before a card is worth posting.
|
|
4072
|
+
*
|
|
4073
|
+
* A resolution supersedes a still-`pending` notice in the same commit, so
|
|
4074
|
+
* `superseded` (and an explicitly `cancelled` notice) is already a "no longer
|
|
4075
|
+
* blocked" fact. But a notice the parent's turn has claimed is `delivered` and
|
|
4076
|
+
* keeps that state forever, so state alone is not enough - the resolution row
|
|
4077
|
+
* for that exact (child, turn, generation) boundary is checked as well, on the
|
|
4078
|
+
* parent's own rows. A re-freeze is a new generation and a new notice.
|
|
4079
|
+
*
|
|
4080
|
+
* Every read is wrapped: a malformed or unreadable durable row is not a
|
|
4081
|
+
* delivery failure. Throwing would burn a delivery attempt and, after
|
|
4082
|
+
* MAX_DELIVERY_ATTEMPTS, close this interaction's whole delivery over one
|
|
4083
|
+
* notice nobody could have posted anyway. Silence is the correct outcome, and
|
|
4084
|
+
* it is the same outcome every other unpostable notice already takes.
|
|
4085
|
+
*/
|
|
4086
|
+
async function resolveSlackBlockedChildNotice(
|
|
4087
|
+
deps: ApiRouteDeps,
|
|
4088
|
+
interaction: SlackInteraction,
|
|
4089
|
+
sessionId: string,
|
|
4090
|
+
updateId: string,
|
|
4091
|
+
): Promise<ChildRequiresActionPayload | null> {
|
|
4092
|
+
try {
|
|
4093
|
+
const update = await getSessionSystemUpdateById(
|
|
4094
|
+
deps.db,
|
|
4095
|
+
interaction.workspaceId,
|
|
4096
|
+
sessionId,
|
|
4097
|
+
updateId,
|
|
4098
|
+
);
|
|
4099
|
+
if (!update || update.payload.type !== "child_requires_action") return null;
|
|
4100
|
+
if (update.state === "superseded" || update.state === "cancelled") return null;
|
|
4101
|
+
const resolved = await childRequiresActionResolutionExists(
|
|
4102
|
+
deps.db,
|
|
4103
|
+
interaction.workspaceId,
|
|
4104
|
+
sessionId,
|
|
4105
|
+
{
|
|
4106
|
+
childSessionId: update.payload.childSessionId,
|
|
4107
|
+
childTurnId: update.payload.childTurnId,
|
|
4108
|
+
childTurnGeneration: update.payload.childTurnGeneration,
|
|
4109
|
+
},
|
|
4110
|
+
);
|
|
4111
|
+
return resolved ? null : update.payload;
|
|
4112
|
+
} catch {
|
|
4113
|
+
return null;
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
|
|
4117
|
+
async function slackChildRequiresActionCard(
|
|
4118
|
+
deps: ApiRouteDeps,
|
|
4119
|
+
interaction: SlackInteraction,
|
|
4120
|
+
event: SessionEvent,
|
|
4121
|
+
mention: string,
|
|
4122
|
+
): Promise<{ text: string } | null> {
|
|
4123
|
+
const sessionId = interaction.sessionId;
|
|
4124
|
+
if (!sessionId) return null;
|
|
4125
|
+
const preview = record(event.payload);
|
|
4126
|
+
if (boundedString(preview?.kind, 64) !== "child_requires_action") return null;
|
|
4127
|
+
const updateId = boundedString(preview?.updateId, 64);
|
|
4128
|
+
if (!updateId || !SLACK_UUID_PATTERN.test(updateId)) return null;
|
|
4129
|
+
// The bounded event preview is lossy by construction. Resolve the exact
|
|
4130
|
+
// durable notice under the ordinary workspace scope; the child session is
|
|
4131
|
+
// never read, only linked.
|
|
4132
|
+
const notice = await resolveSlackBlockedChildNotice(deps, interaction, sessionId, updateId);
|
|
4133
|
+
if (!notice) return null;
|
|
4134
|
+
const questions = notice.requests.filter((request) => request.kind === "human_input");
|
|
4135
|
+
const approvals = notice.requests.filter((request) => request.kind === "approval");
|
|
4136
|
+
const first = questions[0];
|
|
4137
|
+
const detail = first
|
|
4138
|
+
? `${boundedSlackChildDetail(first.firstQuestion)}${
|
|
4139
|
+
first.questionCount > 1 ? ` (+${first.questionCount - 1} more)` : ""
|
|
4140
|
+
}`
|
|
4141
|
+
: approvals.length > 0
|
|
4142
|
+
? `${approvals.length} tool approval${approvals.length > 1 ? "s are" : " is"} waiting for a human.`
|
|
4143
|
+
: "";
|
|
4144
|
+
const link = slackSessionUrl(deps, interaction.workspaceId, notice.childSessionId);
|
|
4145
|
+
const lines = [`${mention}A worker you started needs input.`];
|
|
4146
|
+
if (detail) lines.push(`> ${detail}`);
|
|
4147
|
+
if (link) lines.push(`<${link}|Open in OpenGeni>`);
|
|
4148
|
+
return { text: boundedOutput(lines.join("\n")) };
|
|
4149
|
+
}
|
|
4150
|
+
|
|
4151
|
+
/**
|
|
4152
|
+
* A goal that paused because it ran out of budget or hit the continuation cap
|
|
4153
|
+
* stops making progress with nobody watching, so the Slack thread gets one
|
|
4154
|
+
* bounded line. A `user_pause` / `api` / `agent` pause is a decision the human
|
|
4155
|
+
* or their agent already made, and `no_progress` is not a stop the human must
|
|
4156
|
+
* act on, so none of them post. `goal.resumed` never posts.
|
|
4157
|
+
*
|
|
4158
|
+
* The caller has already proven that this workspace turned the notice on; it
|
|
4159
|
+
* defaults off per workspace.
|
|
4160
|
+
*/
|
|
4161
|
+
function slackGoalPausedText(
|
|
4162
|
+
deps: ApiRouteDeps,
|
|
4163
|
+
interaction: SlackInteraction,
|
|
4164
|
+
event: SessionEvent,
|
|
4165
|
+
mention: string,
|
|
4166
|
+
): string | null {
|
|
4167
|
+
if (!interaction.sessionId) return null;
|
|
4168
|
+
const reason = boundedString(record(event.payload)?.reason, 64);
|
|
4169
|
+
const headline = reason ? SLACK_GOAL_PAUSED_HEADLINES.get(reason) : undefined;
|
|
4170
|
+
if (!headline) return null;
|
|
4171
|
+
const link = slackSessionUrl(deps, interaction.workspaceId, interaction.sessionId);
|
|
4172
|
+
return boundedOutput(`${mention}${headline}.${link ? ` <${link}|Open in OpenGeni>` : ""}`);
|
|
4173
|
+
}
|
|
4174
|
+
|
|
4175
|
+
/**
|
|
4176
|
+
* The installation tenancy that owns this interaction's Slack credential.
|
|
4177
|
+
*
|
|
4178
|
+
* `slack_interactions.connection_id` deliberately has no composite
|
|
4179
|
+
* `(workspace_id, connection_id)` foreign key, so a routed interaction may point
|
|
4180
|
+
* at the installation's connection from another workspace.
|
|
4181
|
+
*
|
|
4182
|
+
* A binding that does not resolve, or that now names a different connection, is
|
|
4183
|
+
* NOT turned into a new delivery precondition: this code previously used the
|
|
4184
|
+
* interaction's own tenancy unconditionally, so falling back to it keeps every
|
|
4185
|
+
* delivery that used to succeed succeeding. The fallback cannot post from the
|
|
4186
|
+
* wrong workspace either, because `claimSlackBotPostOperation` selects the
|
|
4187
|
+
* connection under the tenancy it is given and fails when it does not own it.
|
|
4188
|
+
*/
|
|
4189
|
+
async function resolveSlackDeliveryHome(
|
|
4190
|
+
deps: ApiRouteDeps,
|
|
4191
|
+
interaction: Pick<SlackInteraction, "accountId" | "workspaceId" | "connectionId" | "slackTeamId">,
|
|
4192
|
+
): Promise<{ accountId: string; workspaceId: string }> {
|
|
4193
|
+
const installation = await resolveSlackInstallationRoute(deps.db, interaction.slackTeamId);
|
|
4194
|
+
return installation && installation.connectionId === interaction.connectionId
|
|
4195
|
+
? { accountId: installation.accountId, workspaceId: installation.workspaceId }
|
|
4196
|
+
: { accountId: interaction.accountId, workspaceId: interaction.workspaceId };
|
|
4197
|
+
}
|
|
4198
|
+
|
|
2834
4199
|
async function deliverSlackSessionEvents(
|
|
2835
4200
|
deps: ApiRouteDeps,
|
|
2836
4201
|
interaction: SlackInteraction,
|
|
@@ -2850,14 +4215,65 @@ async function deliverSlackSessionEvents(
|
|
|
2850
4215
|
});
|
|
2851
4216
|
return;
|
|
2852
4217
|
}
|
|
4218
|
+
// The bot credential is owned by the installation's workspace and every
|
|
4219
|
+
// provider call is fenced on it, so the delivery client is always built from
|
|
4220
|
+
// HOME even when the session it reports on lives in another workspace.
|
|
4221
|
+
const home = await resolveSlackDeliveryHome(deps, interaction);
|
|
2853
4222
|
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
2854
|
-
|
|
2855
|
-
workspaceId: interaction.workspaceId,
|
|
4223
|
+
...home,
|
|
2856
4224
|
connectionId: interaction.connectionId,
|
|
2857
4225
|
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
2858
4226
|
sessionId: interaction.sessionId,
|
|
2859
4227
|
});
|
|
2860
|
-
const requester = await validatedSlackRequester(deps, interaction);
|
|
4228
|
+
const requester = await validatedSlackRequester(deps, interaction, home);
|
|
4229
|
+
// Both orchestration notices are per-workspace and OFF unless this workspace
|
|
4230
|
+
// turned them on. Resolved lazily so an ordinary page of turn/progress events
|
|
4231
|
+
// costs no extra workspace read, and memoized so one page resolves once. A
|
|
4232
|
+
// disabled notice takes the same "nothing to post for this event" path as an
|
|
4233
|
+
// undeliverable one: no post operation, no progress slot, and the delivery
|
|
4234
|
+
// cursor still advances past the event exactly as it would have.
|
|
4235
|
+
let orchestrationNotices: ResolvedWorkspaceSlackOrchestrationNoticeSettings | null = null;
|
|
4236
|
+
const orchestrationNoticeEnabled = async (
|
|
4237
|
+
notice: keyof ResolvedWorkspaceSlackOrchestrationNoticeSettings,
|
|
4238
|
+
): Promise<boolean> => {
|
|
4239
|
+
orchestrationNotices ??= resolveWorkspaceSlackOrchestrationNoticeSettings(
|
|
4240
|
+
(await getWorkspace(deps.db, interaction.workspaceId))?.settings,
|
|
4241
|
+
);
|
|
4242
|
+
return orchestrationNotices[notice];
|
|
4243
|
+
};
|
|
4244
|
+
/**
|
|
4245
|
+
* Post one orchestration notice through the SAME durable per-interaction slot
|
|
4246
|
+
* budget as assistant progress, so an orchestration that fans out to many
|
|
4247
|
+
* blocked children cannot turn one Slack task into an unbounded feed. That
|
|
4248
|
+
* budget is deliberately shared rather than a second private allowance: the
|
|
4249
|
+
* ceiling worth enforcing is the total number of posts the human did not ask
|
|
4250
|
+
* for, not a per-category one, and this whole feature exists because an
|
|
4251
|
+
* unsolicited post is worse than a missed one. Beyond the cap the notice goes
|
|
4252
|
+
* silent exactly like a fourth progress message.
|
|
4253
|
+
*
|
|
4254
|
+
* The slot is claimed only once a card is actually going to be posted, so a
|
|
4255
|
+
* skipped, stale, or already-resolved notice never burns one. Claims are
|
|
4256
|
+
* durable and keyed on the session event sequence, so a reaper retry, a
|
|
4257
|
+
* replica claim, or a replayed page reuses the same slot and the same post
|
|
4258
|
+
* operation instead of posting twice or consuming a second slot. The claimed
|
|
4259
|
+
* row never becomes terminal-coalescing evidence: that lookup only joins
|
|
4260
|
+
* `agent.message.completed` events.
|
|
4261
|
+
*/
|
|
4262
|
+
const postUnsolicitedNotice = async (event: SessionEvent, text: string, kind: string) => {
|
|
4263
|
+
const slot = await claimSlackInteractionProgressDelivery(deps.db, {
|
|
4264
|
+
accountId: interaction.accountId,
|
|
4265
|
+
workspaceId: interaction.workspaceId,
|
|
4266
|
+
interactionId: interaction.id,
|
|
4267
|
+
claimHolderId,
|
|
4268
|
+
sessionEventSequence: event.sequence,
|
|
4269
|
+
maxProgress: MAX_PROGRESS_MESSAGES,
|
|
4270
|
+
});
|
|
4271
|
+
if (slot.kind === "not_owned") {
|
|
4272
|
+
throw new Error("Slack orchestration notice lost its durable interaction claim");
|
|
4273
|
+
}
|
|
4274
|
+
if (slot.kind !== "claimed") return;
|
|
4275
|
+
await postDelivery(client, interaction, event, text, kind, slot.delivery.operationId);
|
|
4276
|
+
};
|
|
2861
4277
|
let lastSequence = interaction.lastDeliveredSessionEventSequence;
|
|
2862
4278
|
let terminal: Exclude<SlackInteraction["terminalDeliveryState"], "open"> | null = null;
|
|
2863
4279
|
let latestAssistantText = "";
|
|
@@ -2985,14 +4401,21 @@ async function deliverSlackSessionEvents(
|
|
|
2985
4401
|
card.blocks,
|
|
2986
4402
|
);
|
|
2987
4403
|
}
|
|
4404
|
+
} else if (event.type === "system.update.pending") {
|
|
4405
|
+
const card = (await orchestrationNoticeEnabled("childRequiresAction"))
|
|
4406
|
+
? await slackChildRequiresActionCard(deps, interaction, event, requester.mention)
|
|
4407
|
+
: null;
|
|
4408
|
+
if (card) await postUnsolicitedNotice(event, card.text, "child-blocked");
|
|
4409
|
+
} else if (event.type === "goal.paused") {
|
|
4410
|
+
const paused = (await orchestrationNoticeEnabled("goalPaused"))
|
|
4411
|
+
? slackGoalPausedText(deps, interaction, event, requester.mention)
|
|
4412
|
+
: null;
|
|
4413
|
+
if (paused) await postUnsolicitedNotice(event, paused, "goal-paused");
|
|
2988
4414
|
} else if (event.type === "turn.completed") {
|
|
2989
4415
|
const payloadOutput = safePayloadText(event.payload, "output");
|
|
2990
4416
|
const hasPublishableOutput = payloadOutput.trim().length > 0;
|
|
2991
4417
|
const output = hasPublishableOutput ? payloadOutput : latestAssistantText;
|
|
2992
4418
|
const normalizedOutput = output.trim();
|
|
2993
|
-
const recurringLink = requester.canSchedule
|
|
2994
|
-
? `\n\n${makeRecurringText(deps, interaction.workspaceId, interaction.sessionId)}`
|
|
2995
|
-
: "";
|
|
2996
4419
|
const existingProgress = progressEvidence.find(
|
|
2997
4420
|
(delivery) =>
|
|
2998
4421
|
slackDeliveryTextsCoalesce(boundedOutput(delivery.text).trim(), normalizedOutput) &&
|
|
@@ -3022,46 +4445,49 @@ async function deliverSlackSessionEvents(
|
|
|
3022
4445
|
);
|
|
3023
4446
|
const posted = await getSlackBotPostOperation(
|
|
3024
4447
|
deps.db,
|
|
3025
|
-
|
|
4448
|
+
home.workspaceId,
|
|
3026
4449
|
interaction.connectionId,
|
|
3027
4450
|
existingProgress.operationId,
|
|
3028
4451
|
);
|
|
3029
4452
|
if (posted?.slackChannelId && posted.slackMessageTimestamp) {
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
4453
|
+
// The result is the result. Continuation prose and the recurring
|
|
4454
|
+
// action live on the control/Status card and `<command> info`.
|
|
4455
|
+
const text = boundedOutput(`${requester.mention}${existingProgress.text}`);
|
|
4456
|
+
// This rewrites a message that already carried the line, so it has to
|
|
4457
|
+
// carry it too, or the update would quietly strip it.
|
|
4458
|
+
const rendered = withWorkspaceLine(
|
|
4459
|
+
interaction.routedWorkspaceLabel,
|
|
4460
|
+
text,
|
|
4461
|
+
publicationBlocks.length > 0
|
|
4462
|
+
? ([
|
|
4463
|
+
{ type: "section", text: { type: "mrkdwn", text } },
|
|
4464
|
+
...publicationBlocks,
|
|
4465
|
+
] as SlackMessageBlock[])
|
|
4466
|
+
: undefined,
|
|
3033
4467
|
);
|
|
3034
4468
|
await client.updateMessage({
|
|
3035
4469
|
operationId: deterministicUuid(
|
|
3036
|
-
|
|
4470
|
+
slackPostSeed(
|
|
4471
|
+
interaction,
|
|
4472
|
+
`slack-terminal-update:${interaction.id}:${event.sequence}`,
|
|
4473
|
+
),
|
|
3037
4474
|
),
|
|
3038
4475
|
channelId: posted.slackChannelId,
|
|
3039
4476
|
timestamp: posted.slackMessageTimestamp,
|
|
3040
|
-
text,
|
|
3041
|
-
...(
|
|
4477
|
+
text: rendered.text,
|
|
4478
|
+
...(rendered.blocks
|
|
3042
4479
|
? {
|
|
3043
|
-
blocks:
|
|
3044
|
-
{
|
|
3045
|
-
type: "section",
|
|
3046
|
-
text: {
|
|
3047
|
-
type: "mrkdwn",
|
|
3048
|
-
text,
|
|
3049
|
-
},
|
|
3050
|
-
},
|
|
3051
|
-
...publicationBlocks,
|
|
3052
|
-
],
|
|
4480
|
+
blocks: rendered.blocks,
|
|
3053
4481
|
}
|
|
3054
4482
|
: {}),
|
|
3055
4483
|
});
|
|
3056
4484
|
}
|
|
3057
4485
|
} else {
|
|
3058
4486
|
const operationId = deterministicUuid(
|
|
3059
|
-
`slack-delivery:${interaction.id}:${event.sequence}:final
|
|
4487
|
+
slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:final`),
|
|
3060
4488
|
);
|
|
3061
|
-
const
|
|
3062
|
-
const text = boundedOutputWithSuffix(
|
|
4489
|
+
const text = boundedOutput(
|
|
3063
4490
|
`${requester.mention}${output || "OpenGeni finished this task."}`,
|
|
3064
|
-
finalSuffix,
|
|
3065
4491
|
);
|
|
3066
4492
|
const publicationBlocks = hasPublishableOutput
|
|
3067
4493
|
? await slackSharedResultPublicationBlocks(
|
|
@@ -3159,15 +4585,18 @@ async function postDelivery(
|
|
|
3159
4585
|
event: SessionEvent,
|
|
3160
4586
|
text: string,
|
|
3161
4587
|
kind: string,
|
|
3162
|
-
operationId = deterministicUuid(
|
|
4588
|
+
operationId = deterministicUuid(
|
|
4589
|
+
slackPostSeed(interaction, `slack-delivery:${interaction.id}:${event.sequence}:${kind}`),
|
|
4590
|
+
),
|
|
3163
4591
|
blocks?: SlackMessageBlock[],
|
|
3164
4592
|
) {
|
|
4593
|
+
const rendered = withWorkspaceLine(interaction.routedWorkspaceLabel, boundedOutput(text), blocks);
|
|
3165
4594
|
await client.postMessage({
|
|
3166
4595
|
operationId,
|
|
3167
4596
|
channelId: interaction.slackChannelId,
|
|
3168
4597
|
threadTimestamp: interaction.slackThreadTs,
|
|
3169
|
-
text:
|
|
3170
|
-
...(blocks ? { blocks } : {}),
|
|
4598
|
+
text: rendered.text,
|
|
4599
|
+
...(rendered.blocks ? { blocks: rendered.blocks } : {}),
|
|
3171
4600
|
});
|
|
3172
4601
|
}
|
|
3173
4602
|
|
|
@@ -3247,6 +4676,8 @@ const SLACK_BLOCK_ACTION_IDS = new Set([
|
|
|
3247
4676
|
"opengeni.session.pause",
|
|
3248
4677
|
"opengeni.session.resume",
|
|
3249
4678
|
"opengeni.shared_result.publish",
|
|
4679
|
+
"opengeni.route.select",
|
|
4680
|
+
"opengeni.route.cancel",
|
|
3250
4681
|
]);
|
|
3251
4682
|
|
|
3252
4683
|
export function normalizedBlockActionInteraction(
|
|
@@ -3311,7 +4742,14 @@ function humanInputResponse(questions: HumanInputQuestion[], text: string) {
|
|
|
3311
4742
|
const matches = question.options.filter(
|
|
3312
4743
|
(option) => option.id.toLowerCase() === normalized || option.label.toLowerCase() === normalized,
|
|
3313
4744
|
);
|
|
3314
|
-
if (matches.length !== 1)
|
|
4745
|
+
if (matches.length !== 1) {
|
|
4746
|
+
const other = text.trim();
|
|
4747
|
+
if (!other) return null;
|
|
4748
|
+
return {
|
|
4749
|
+
outcome: "answered" as const,
|
|
4750
|
+
answers: [{ questionId: question.id, values: [], other }],
|
|
4751
|
+
};
|
|
4752
|
+
}
|
|
3315
4753
|
return {
|
|
3316
4754
|
outcome: "answered" as const,
|
|
3317
4755
|
answers: [{ questionId: question.id, values: [matches[0]!.id] }],
|
|
@@ -3326,11 +4764,123 @@ function formatQuestions(questions: HumanInputQuestion[]) {
|
|
|
3326
4764
|
.slice(0, 10)
|
|
3327
4765
|
.map((option) => option.label)
|
|
3328
4766
|
.join(", ");
|
|
3329
|
-
return `${index + 1}. ${boundedOutput(question.prompt)}${
|
|
4767
|
+
return `${index + 1}. ${boundedOutput(question.prompt)}${
|
|
4768
|
+
options ? ` (${options}, or reply with another value)` : ""
|
|
4769
|
+
}`;
|
|
3330
4770
|
})
|
|
3331
4771
|
.join("\n");
|
|
3332
4772
|
}
|
|
3333
4773
|
|
|
4774
|
+
export function isSlackInfoCommand(text: string): boolean {
|
|
4775
|
+
return text.trim().toLowerCase() === "info";
|
|
4776
|
+
}
|
|
4777
|
+
|
|
4778
|
+
type SlackSlashResponse = {
|
|
4779
|
+
response_type: "ephemeral";
|
|
4780
|
+
text: string;
|
|
4781
|
+
unfurl_links: false;
|
|
4782
|
+
unfurl_media: false;
|
|
4783
|
+
blocks?: SlackMessageBlock[];
|
|
4784
|
+
};
|
|
4785
|
+
|
|
4786
|
+
/**
|
|
4787
|
+
* The ephemeral `<command> info` card.
|
|
4788
|
+
*
|
|
4789
|
+
* It is a projection of what the caller can already do, not an action: no
|
|
4790
|
+
* session, no durable inbox row, no provider post. It re-proves the exact
|
|
4791
|
+
* Slack identity link plus live workspace grants before any
|
|
4792
|
+
* workspace-identifying text is echoed, so an unlinked or access-revoked
|
|
4793
|
+
* identity receives the ordinary connect view instead.
|
|
4794
|
+
*/
|
|
4795
|
+
async function slackInfoCommandResponse(
|
|
4796
|
+
deps: ApiRouteDeps,
|
|
4797
|
+
installation: SlackInstallationRoute,
|
|
4798
|
+
entry: NormalizedSlackInteraction,
|
|
4799
|
+
): Promise<SlackSlashResponse> {
|
|
4800
|
+
const identity = {
|
|
4801
|
+
workspaceId: installation.workspaceId,
|
|
4802
|
+
connectionId: installation.connectionId,
|
|
4803
|
+
slackTeamId: entry.slackTeamId,
|
|
4804
|
+
slackUserId: entry.slackUserId,
|
|
4805
|
+
};
|
|
4806
|
+
const link = await getSlackBotUserLink(
|
|
4807
|
+
deps.db,
|
|
4808
|
+
installation.workspaceId,
|
|
4809
|
+
installation.connectionId,
|
|
4810
|
+
entry.slackUserId,
|
|
4811
|
+
);
|
|
4812
|
+
const grant = link
|
|
4813
|
+
? await getWorkspaceGrant(deps.db, link.subjectId, installation.workspaceId, {
|
|
4814
|
+
principalKind: "human_session",
|
|
4815
|
+
})
|
|
4816
|
+
: null;
|
|
4817
|
+
if (
|
|
4818
|
+
!grant ||
|
|
4819
|
+
grant.accountId !== installation.accountId ||
|
|
4820
|
+
!hasPermission(grant.permissions, "sessions:read")
|
|
4821
|
+
) {
|
|
4822
|
+
return ephemeralSlackResponse(
|
|
4823
|
+
link
|
|
4824
|
+
? `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.`
|
|
4825
|
+
: `Link your Slack identity to OpenGeni before starting work: ${linkUrl(deps, identity)}. No session was created.`,
|
|
4826
|
+
);
|
|
4827
|
+
}
|
|
4828
|
+
const workspace = await getWorkspace(deps.db, installation.workspaceId);
|
|
4829
|
+
const command = deps.settings.slackCommand;
|
|
4830
|
+
const botMention = slackBotMention(installation.botUserId);
|
|
4831
|
+
// Every line is gated on the grant that actually authorizes it, so the card
|
|
4832
|
+
// stays a projection of what this caller can do rather than a generic manual.
|
|
4833
|
+
const canControl = hasPermission(grant.permissions, "sessions:control");
|
|
4834
|
+
const canCreate = hasPermission(grant.permissions, "sessions:create");
|
|
4835
|
+
const schedules = hasPermission(grant.permissions, "scheduled_tasks:manage")
|
|
4836
|
+
? slackSchedulesUrl(deps, installation.workspaceId)
|
|
4837
|
+
: null;
|
|
4838
|
+
const workspaceUrl = slackWorkspaceUrl(deps, installation.workspaceId);
|
|
4839
|
+
const workspaceName = (workspace?.name ?? "").trim().slice(0, 120);
|
|
4840
|
+
const destination = workspaceName
|
|
4841
|
+
? `the *${escapeSlackMrkdwn(workspaceName)}* workspace`
|
|
4842
|
+
: "your OpenGeni workspace";
|
|
4843
|
+
const lines = [
|
|
4844
|
+
"*Working with OpenGeni in Slack*",
|
|
4845
|
+
"",
|
|
4846
|
+
...(canControl
|
|
4847
|
+
? [
|
|
4848
|
+
"• *Continue a task:* reply in its Slack thread.",
|
|
4849
|
+
"• *Stop a task:* press *Stop* on its card, or reply `stop` in its thread.",
|
|
4850
|
+
]
|
|
4851
|
+
: []),
|
|
4852
|
+
...(canCreate
|
|
4853
|
+
? [
|
|
4854
|
+
`• *Start a new task:* mention ${botMention} in a channel, run \`${command} <task>\`, or send a new top-level direct message.`,
|
|
4855
|
+
]
|
|
4856
|
+
: []),
|
|
4857
|
+
...(schedules
|
|
4858
|
+
? [
|
|
4859
|
+
`• *Make a result recurring:* press *Status* on the task card and open *Make recurring*, or open <${schedules}|Schedules>.`,
|
|
4860
|
+
]
|
|
4861
|
+
: []),
|
|
4862
|
+
`• *Where work lands:* ${destination}${workspaceUrl ? ` (<${workspaceUrl}|open OpenGeni>)` : ""}.`,
|
|
4863
|
+
];
|
|
4864
|
+
const text = lines.join("\n");
|
|
4865
|
+
return ephemeralSlackResponse(text, [
|
|
4866
|
+
{ type: "section", text: { type: "mrkdwn", text } },
|
|
4867
|
+
] as SlackMessageBlock[]);
|
|
4868
|
+
}
|
|
4869
|
+
|
|
4870
|
+
function ephemeralSlackResponse(text: string, blocks?: SlackMessageBlock[]): SlackSlashResponse {
|
|
4871
|
+
return {
|
|
4872
|
+
response_type: "ephemeral",
|
|
4873
|
+
text,
|
|
4874
|
+
unfurl_links: false,
|
|
4875
|
+
unfurl_media: false,
|
|
4876
|
+
...(blocks ? { blocks } : {}),
|
|
4877
|
+
};
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4880
|
+
function slackBotMention(botUserId: string | null): string {
|
|
4881
|
+
return botUserId && /^[UWB][A-Z0-9]{1,63}$/.test(botUserId) ? `<@${botUserId}>` : "@OpenGeni";
|
|
4882
|
+
}
|
|
4883
|
+
|
|
3334
4884
|
function openSessionText(deps: ApiRouteDeps, workspaceId: string, sessionId: string) {
|
|
3335
4885
|
const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
|
|
3336
4886
|
if (!base) throw new Error("Slack session acknowledgement requires an absolute web base URL");
|
|
@@ -3338,15 +4888,105 @@ function openSessionText(deps: ApiRouteDeps, workspaceId: string, sessionId: str
|
|
|
3338
4888
|
return `<${url}|Open in OpenGeni>`;
|
|
3339
4889
|
}
|
|
3340
4890
|
|
|
3341
|
-
function
|
|
4891
|
+
function slackWorkspaceUrl(deps: ApiRouteDeps, workspaceId: string): string | null {
|
|
4892
|
+
return safeSlackActionUrl(deps, `/workspaces/${encodeURIComponent(workspaceId)}`);
|
|
4893
|
+
}
|
|
4894
|
+
|
|
4895
|
+
function slackSessionAuthorizationScopeKey(scope: SessionAuthorizationListScope | null): string {
|
|
4896
|
+
if (scope === null) return "standalone";
|
|
4897
|
+
if (scope.kind === "all") return "all";
|
|
4898
|
+
return JSON.stringify({
|
|
4899
|
+
kind: scope.kind,
|
|
4900
|
+
rootSessionIds: [...scope.rootSessionIds].sort(),
|
|
4901
|
+
sessionIds: [...scope.sessionIds].sort(),
|
|
4902
|
+
});
|
|
4903
|
+
}
|
|
4904
|
+
|
|
4905
|
+
function slackSessionUrl(
|
|
4906
|
+
deps: ApiRouteDeps,
|
|
4907
|
+
workspaceId: string,
|
|
4908
|
+
sessionId: string,
|
|
4909
|
+
): string | null {
|
|
4910
|
+
return safeSlackActionUrl(
|
|
4911
|
+
deps,
|
|
4912
|
+
`/workspaces/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`,
|
|
4913
|
+
);
|
|
4914
|
+
}
|
|
4915
|
+
|
|
4916
|
+
function safeSlackActionUrl(deps: ApiRouteDeps, pathname: string): string | null {
|
|
3342
4917
|
const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
|
|
3343
|
-
if (!base)
|
|
3344
|
-
|
|
4918
|
+
if (!base) return null;
|
|
4919
|
+
try {
|
|
4920
|
+
const url = new URL(pathname, base);
|
|
4921
|
+
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) {
|
|
4922
|
+
return null;
|
|
4923
|
+
}
|
|
4924
|
+
return url.toString();
|
|
4925
|
+
} catch {
|
|
4926
|
+
return null;
|
|
4927
|
+
}
|
|
4928
|
+
}
|
|
4929
|
+
|
|
4930
|
+
function slackAppHomeLinkUrl(
|
|
4931
|
+
deps: ApiRouteDeps,
|
|
4932
|
+
installation: SlackInstallationRoute,
|
|
4933
|
+
slackTeamId: string,
|
|
4934
|
+
slackUserId: string,
|
|
4935
|
+
): string | null {
|
|
4936
|
+
const signingSecret = deps.settings.slackSigningSecret;
|
|
4937
|
+
const base = slackWorkspaceUrl(deps, installation.workspaceId);
|
|
4938
|
+
if (!signingSecret || !base) return base;
|
|
4939
|
+
const url = new URL(
|
|
4940
|
+
`/workspaces/${encodeURIComponent(installation.workspaceId)}/capabilities`,
|
|
4941
|
+
base,
|
|
4942
|
+
);
|
|
4943
|
+
url.hash = new URLSearchParams({
|
|
4944
|
+
slack_link: createSlackUserLinkToken(signingSecret, {
|
|
4945
|
+
workspaceId: installation.workspaceId,
|
|
4946
|
+
connectionId: installation.connectionId,
|
|
4947
|
+
slackTeamId,
|
|
4948
|
+
slackUserId,
|
|
4949
|
+
}),
|
|
4950
|
+
}).toString();
|
|
4951
|
+
return url.toString();
|
|
4952
|
+
}
|
|
4953
|
+
|
|
4954
|
+
function makeRecurringText(
|
|
4955
|
+
deps: ApiRouteDeps,
|
|
4956
|
+
workspaceId: string,
|
|
4957
|
+
sessionId: string,
|
|
4958
|
+
): string | null {
|
|
4959
|
+
// Unchanged deep-link contract: the schedules editor plus the exact source
|
|
4960
|
+
// session UUID, and nothing copied out of Slack. A deployment without an
|
|
4961
|
+
// absolute web base URL simply omits the action instead of failing a card.
|
|
4962
|
+
const base = slackSchedulesUrl(deps, workspaceId);
|
|
4963
|
+
if (!base) return null;
|
|
4964
|
+
const url = new URL(base);
|
|
3345
4965
|
url.searchParams.set("sourceSessionId", sessionId);
|
|
3346
4966
|
return `<${url.toString()}|Make recurring>`;
|
|
3347
4967
|
}
|
|
3348
4968
|
|
|
3349
|
-
function
|
|
4969
|
+
function slackSchedulesUrl(deps: ApiRouteDeps, workspaceId: string): string | null {
|
|
4970
|
+
return safeSlackActionUrl(deps, `/workspaces/${encodeURIComponent(workspaceId)}/schedules`);
|
|
4971
|
+
}
|
|
4972
|
+
|
|
4973
|
+
function linkUrl(
|
|
4974
|
+
deps: ApiRouteDeps,
|
|
4975
|
+
entry: Pick<
|
|
4976
|
+
SlackInteractionInboxEntry,
|
|
4977
|
+
"workspaceId" | "connectionId" | "slackTeamId" | "slackUserId"
|
|
4978
|
+
>,
|
|
4979
|
+
/**
|
|
4980
|
+
* When this URL goes into a message posted under a deterministic operation
|
|
4981
|
+
* id, the token has to be deterministic too. The post ledger binds that id to
|
|
4982
|
+
* a digest over the text, so a retry that mints a fresh token renders
|
|
4983
|
+
* different bytes and is refused as a conflicting request, permanently. Pass
|
|
4984
|
+
* the originating inbox row's creation time: the token is then stable for that
|
|
4985
|
+
* row and still expires fifteen minutes after the message could first have
|
|
4986
|
+
* been read.
|
|
4987
|
+
*/
|
|
4988
|
+
mintedAtMs?: number,
|
|
4989
|
+
) {
|
|
3350
4990
|
const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
|
|
3351
4991
|
const signingSecret = deps.settings.slackSigningSecret;
|
|
3352
4992
|
if (!base || !signingSecret) return "OpenGeni Settings → Integrations → Slack";
|
|
@@ -3354,7 +4994,7 @@ function linkUrl(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
|
|
|
3354
4994
|
// Fragments stay out of HTTP request lines, reverse-proxy logs, Referer
|
|
3355
4995
|
// headers, and managed-auth callback URLs. Query-form bearers are rejected.
|
|
3356
4996
|
url.hash = new URLSearchParams({
|
|
3357
|
-
slack_link: createSlackUserLinkToken(signingSecret, entry),
|
|
4997
|
+
slack_link: createSlackUserLinkToken(signingSecret, entry, mintedAtMs),
|
|
3358
4998
|
}).toString();
|
|
3359
4999
|
return url.toString();
|
|
3360
5000
|
}
|
|
@@ -3519,17 +5159,16 @@ function boundedText(value: unknown): string | null {
|
|
|
3519
5159
|
return trimmed.slice(0, MAX_SLACK_INPUT_CHARS);
|
|
3520
5160
|
}
|
|
3521
5161
|
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
5162
|
+
/** Single-line, character-bounded preview for a blocked-worker pointer card. */
|
|
5163
|
+
function boundedSlackChildDetail(value: string) {
|
|
5164
|
+
const collapsed = value.replace(/\s+/gu, " ").trim();
|
|
5165
|
+
return collapsed.length <= MAX_SLACK_CHILD_DETAIL_CHARS
|
|
5166
|
+
? collapsed
|
|
5167
|
+
: `${collapsed.slice(0, MAX_SLACK_CHILD_DETAIL_CHARS - 1)}…`;
|
|
3526
5168
|
}
|
|
3527
5169
|
|
|
3528
|
-
function
|
|
3529
|
-
|
|
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}`;
|
|
5170
|
+
function boundedOutput(value: string, limit = MAX_SLACK_TEXT_CHARS) {
|
|
5171
|
+
return value.length <= limit ? value : `${value.slice(0, limit - 20)}\n… output truncated`;
|
|
3533
5172
|
}
|
|
3534
5173
|
|
|
3535
5174
|
function safePayloadText(payload: unknown, field: string) {
|