@opengeni/api-router 0.26.1 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +1 -0
- package/dist/app.js +5 -3
- package/dist/auth/canonical-human-session-admission.d.ts +27 -0
- package/dist/browser-auth-broker.d.ts +20 -0
- package/dist/browser-controller-authority.d.ts +19 -1
- package/dist/browser-network-route.d.ts +8 -0
- package/dist/{chunk-JIKNR5YL.js → chunk-2PQMSRCB.js} +15518 -6863
- package/dist/chunk-2PQMSRCB.js.map +1 -0
- package/dist/controller-data-plane.d.ts +12 -0
- package/dist/http/auth.d.ts +1 -1
- package/dist/http/sse.d.ts +18 -1
- package/dist/index.js +1 -1
- package/dist/integrations/api-integrations.d.ts +2 -2
- package/dist/integrations/fiken.d.ts +233 -0
- package/dist/integrations/google-drive.d.ts +6 -6
- package/dist/integrations/provider-oauth.d.ts +0 -2
- package/dist/integrations/slack-bot.d.ts +171 -1
- package/dist/integrations/slack-interactions.d.ts +17 -5
- package/dist/interaction-holder-heartbeat.d.ts +17 -0
- package/dist/interaction-metrics.d.ts +11 -0
- package/dist/mcp/server.d.ts +3 -2
- package/dist/model-catalog.d.ts +1 -0
- package/dist/routes/canonical-human-identities.d.ts +3 -0
- package/dist/routes/channels.d.ts +3 -0
- package/dist/routes/integration-facets.d.ts +3 -0
- package/dist/routes/interaction-resources.d.ts +5 -0
- package/dist/routes/organization-memberships.d.ts +3 -0
- package/dist/routes/sessions.d.ts +2 -1
- package/dist/routes/slack-task-policy.d.ts +3 -0
- package/dist/routes/supergrok.d.ts +3 -0
- package/dist/sandbox/channel-a.d.ts +8 -1
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/viewer.d.ts +3 -1
- package/dist/slack-reaction-files.d.ts +27 -0
- package/dist/transcription/providers/xai-subscription.d.ts +8 -0
- package/dist/xai-realtime.d.ts +26 -0
- package/dist/xai-subscription-auth.d.ts +23 -0
- package/package.json +17 -16
- package/src/app.ts +352 -49
- package/src/auth/canonical-human-session-admission.ts +116 -0
- package/src/auth/managed-auth.ts +101 -0
- package/src/browser-auth-broker.ts +155 -0
- package/src/browser-controller-authority.ts +50 -1
- package/src/browser-network-route.ts +34 -0
- package/src/controller-data-plane.ts +35 -0
- package/src/http/auth.ts +27 -9
- package/src/http/sse.ts +229 -5
- package/src/integrations/api-integrations.ts +57 -42
- package/src/integrations/fiken.ts +1230 -0
- package/src/integrations/google-drive.ts +31 -26
- package/src/integrations/provider-oauth.ts +70 -91
- package/src/integrations/slack-bot.ts +638 -9
- package/src/integrations/slack-interactions.ts +1583 -73
- package/src/interaction-holder-heartbeat.ts +95 -0
- package/src/interaction-metrics.ts +159 -0
- package/src/mcp/documents.ts +115 -6
- package/src/mcp/server.ts +865 -242
- package/src/model-catalog.ts +19 -6
- package/src/routes/api-integrations.ts +32 -29
- package/src/routes/browser-identities.ts +24 -0
- package/src/routes/browser-sessions.ts +2122 -183
- package/src/routes/canonical-human-identities.ts +156 -0
- package/src/routes/channels.ts +90 -0
- package/src/routes/computer-sessions.ts +441 -86
- package/src/routes/connections.ts +141 -4
- package/src/routes/{integration-features.ts → integration-facets.ts} +63 -63
- package/src/routes/interaction-resources.ts +595 -0
- package/src/routes/organization-memberships.ts +53 -0
- package/src/routes/plugins.ts +2 -1
- package/src/routes/sessions.ts +419 -43
- package/src/routes/skills.ts +92 -10
- package/src/routes/slack-task-policy.ts +115 -0
- package/src/routes/supergrok.ts +717 -0
- package/src/routes/transcription-recordings.ts +9 -2
- package/src/routes/transcriptions.ts +2 -1
- package/src/routes/video-generation.ts +34 -4
- package/src/routes/workspaces.ts +22 -4
- package/src/sandbox/channel-a.ts +29 -2
- package/src/sandbox/machines.ts +5 -5
- package/src/sandbox/viewer.ts +10 -3
- package/src/slack-reaction-files.ts +181 -0
- package/src/transcription/providers/xai-subscription.ts +110 -0
- package/src/transcription/service.ts +17 -2
- package/src/xai-realtime.ts +216 -0
- package/src/xai-subscription-auth.ts +132 -0
- package/dist/chunk-JIKNR5YL.js.map +0 -1
- package/dist/routes/integration-features.d.ts +0 -3
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import {
|
|
3
|
+
approvalIdentifier,
|
|
3
4
|
ApproveSlackUserLinkAccessRequest,
|
|
4
5
|
DEFAULT_FIRST_PARTY_MCP_TOOLS,
|
|
5
6
|
hasOpenGeniSlackReactionScope,
|
|
6
7
|
ListSlackUserLinkAccessRequestsResponse,
|
|
7
8
|
PrepareSlackUserLinkAccessRequest,
|
|
9
|
+
evaluateSlackTaskPolicy,
|
|
8
10
|
resolveWorkspaceSlackReactionSummonSettings,
|
|
9
11
|
SlackReactionChannelListResponse,
|
|
10
12
|
SlackUserLinkAccessMutationRequest,
|
|
11
13
|
SlackUserLinkAccessRequest,
|
|
12
14
|
type AccessGrant,
|
|
15
|
+
type FileResourceRef,
|
|
13
16
|
type FirstPartyMcpToolName,
|
|
14
17
|
type HumanInputQuestion,
|
|
15
18
|
type SessionEvent,
|
|
16
19
|
type WorkspaceSlackReactionSummonSettings,
|
|
17
20
|
workspaceSlackReactionChannelAllowed,
|
|
18
21
|
} from "@opengeni/contracts";
|
|
22
|
+
import {
|
|
23
|
+
allowedFirstPartyMcpToolsForSession,
|
|
24
|
+
resolveFirstPartyMcpToolPolicy,
|
|
25
|
+
type Settings,
|
|
26
|
+
} from "@opengeni/config";
|
|
19
27
|
import {
|
|
20
28
|
acceptSessionHumanInputResponse,
|
|
29
|
+
acceptSessionApprovalDecision,
|
|
21
30
|
approveSlackUserLinkAccessRequest,
|
|
22
31
|
advanceSlackInteractionDelivery,
|
|
23
32
|
bindSlackInteractionSession,
|
|
@@ -33,9 +42,17 @@ import {
|
|
|
33
42
|
getConnectionMetadata,
|
|
34
43
|
getOrCreateSlackInteraction,
|
|
35
44
|
getLatestSessionModelForSubject,
|
|
45
|
+
getSession,
|
|
46
|
+
getSessionEvent,
|
|
47
|
+
getSessionHumanInputRequest,
|
|
48
|
+
getSlackBotPostOperation,
|
|
36
49
|
getSlackBotUserLink,
|
|
50
|
+
getSlackInteractionActionHandle,
|
|
37
51
|
getSlackInteractionByClientEventId,
|
|
52
|
+
getSlackInteractionById,
|
|
38
53
|
getSlackInteractionByRoute,
|
|
54
|
+
getActiveSlackTaskPolicy,
|
|
55
|
+
getSlackSharedTaskOrigin,
|
|
39
56
|
getSessionEventByClientEventId,
|
|
40
57
|
getWorkspace,
|
|
41
58
|
getWorkspaceGrant,
|
|
@@ -45,17 +62,22 @@ import {
|
|
|
45
62
|
listPendingSlackUserLinkAccessRequests,
|
|
46
63
|
rekeySlackInteractionRoute,
|
|
47
64
|
reopenSlackInteractionDelivery,
|
|
65
|
+
reserveSlackInteractionActionHandles,
|
|
48
66
|
releaseSlackInteractionDelivery,
|
|
49
67
|
releaseSlackInteractionInbox,
|
|
50
68
|
requestSlackUserLinkWorkspaceAccess,
|
|
51
69
|
resolveSlackInstallationRoute,
|
|
52
70
|
saveSlackInteractionInboxReactionCheckpoint,
|
|
71
|
+
saveSlackSharedTaskOrigin,
|
|
53
72
|
settleSlackInteractionInbox,
|
|
73
|
+
settleSlackInteractionActionHandles,
|
|
54
74
|
denySlackUserLinkAccessRequest,
|
|
55
75
|
prepareSlackUserLinkAccessRequest,
|
|
56
76
|
SlackUserLinkAccessPersistenceError,
|
|
57
77
|
type SlackInstallationRoute,
|
|
58
78
|
type SlackInteraction,
|
|
79
|
+
type SlackInteractionActionHandle,
|
|
80
|
+
type SlackInteractionActionKind,
|
|
59
81
|
type SlackInteractionInboxEntry,
|
|
60
82
|
type SlackInteractionTriggerKind,
|
|
61
83
|
} from "@opengeni/db";
|
|
@@ -73,15 +95,19 @@ import type { Context, Hono } from "hono";
|
|
|
73
95
|
import { HTTPException } from "hono/http-exception";
|
|
74
96
|
import {
|
|
75
97
|
createOpenGeniSlackBotInteractionClient,
|
|
98
|
+
SLACK_REACTION_IMAGE_MAX_BYTES,
|
|
76
99
|
type OpenGeniSlackBotClient,
|
|
100
|
+
type SlackMessageBlock,
|
|
77
101
|
SlackBotProviderError,
|
|
78
102
|
} from "./slack-bot";
|
|
103
|
+
import { importSlackReactionImage, type ImportedSlackReactionImage } from "../slack-reaction-files";
|
|
79
104
|
|
|
80
105
|
export const SLACK_INTERACTION_MAX_BODY_BYTES = 256 * 1024;
|
|
81
106
|
export const SLACK_SIGNATURE_REPLAY_WINDOW_SECONDS = 300;
|
|
82
107
|
export const SLACK_DELIVERY_EVENT_TYPES = [
|
|
83
108
|
"agent.message.completed",
|
|
84
109
|
"session.humanInput.requested",
|
|
110
|
+
"session.requiresAction",
|
|
85
111
|
"turn.completed",
|
|
86
112
|
"turn.failed",
|
|
87
113
|
"turn.cancelled",
|
|
@@ -94,14 +120,22 @@ const MAX_SLACK_INVOCATION_CONTEXT_MESSAGES = 15;
|
|
|
94
120
|
const MAX_SLACK_CHANNEL_CONTEXT_MESSAGES = 5;
|
|
95
121
|
const MAX_SLACK_REACTION_CONTEXT_MESSAGES = 15;
|
|
96
122
|
const MAX_SLACK_REACTION_FILE_SUMMARY_CHARS = 1_500;
|
|
123
|
+
const MAX_SLACK_REACTION_IMAGES = 4;
|
|
124
|
+
const MAX_SLACK_REACTION_IMAGE_AGGREGATE_BYTES = 16 * 1024 * 1024;
|
|
97
125
|
const MAX_PROGRESS_MESSAGES = 3;
|
|
98
126
|
const SLACK_USER_LINK_TTL_MS = 15 * 60_000;
|
|
99
127
|
const INBOX_LEASE_MS = 30_000;
|
|
100
128
|
const DELIVERY_LEASE_MS = 30_000;
|
|
101
129
|
const MAX_DELIVERY_ATTEMPTS = 8;
|
|
102
130
|
const MAX_DELIVERY_RETRY_MS = 5 * 60_000;
|
|
131
|
+
const SLACK_INTERACTION_BOT_SUBJECT_ID = "service:slack-interaction";
|
|
132
|
+
const SLACK_ACTION_TTL_MS = 7 * 24 * 60 * 60_000;
|
|
133
|
+
const MAX_SLACK_APPROVALS_PER_CARD = 8;
|
|
134
|
+
const MAX_SLACK_ACTIONS_PER_CARD = 20;
|
|
103
135
|
export const SLACK_TASK_INSTRUCTIONS = [
|
|
104
136
|
"This turn originated from Slack. Slack message and thread context is task-local only.",
|
|
137
|
+
"Execute direct, safe, sufficiently specified requests immediately.",
|
|
138
|
+
"Ask one concise clarifying question only when materially required information is missing or the requested action is risky, irreversible, or authorization-sensitive.",
|
|
105
139
|
"Do not write Slack context to Documents, Knowledge, Memory, preferences, Workspace Charter, instructions, or policy unless a separate explicit authorized user action requests it.",
|
|
106
140
|
"Never expose private reasoning, credentials, secrets, raw logs, or unbounded output.",
|
|
107
141
|
"Keep user-visible output concise, bounded, and safe to send back to Slack.",
|
|
@@ -124,6 +158,14 @@ export const SLACK_TASK_FIRST_PARTY_MCP_TOOLS = [
|
|
|
124
158
|
"slack_bot_file_content",
|
|
125
159
|
] satisfies readonly FirstPartyMcpToolName[];
|
|
126
160
|
|
|
161
|
+
function slackTaskFirstPartyMcpTools(settings: Settings): FirstPartyMcpToolName[] {
|
|
162
|
+
const policy = resolveFirstPartyMcpToolPolicy(settings);
|
|
163
|
+
const connectorTools = SLACK_TASK_FIRST_PARTY_MCP_TOOLS.filter(
|
|
164
|
+
(tool) => !DEFAULT_FIRST_PARTY_MCP_TOOLS.includes(tool),
|
|
165
|
+
);
|
|
166
|
+
return allowedFirstPartyMcpToolsForSession(settings, [...policy.default, ...connectorTools]);
|
|
167
|
+
}
|
|
168
|
+
|
|
127
169
|
export type NormalizedSlackInteraction = {
|
|
128
170
|
providerEventId: string;
|
|
129
171
|
providerMessageId: string;
|
|
@@ -134,6 +176,7 @@ export type NormalizedSlackInteraction = {
|
|
|
134
176
|
slackThreadTs: string | null;
|
|
135
177
|
triggerKind: SlackInteractionTriggerKind;
|
|
136
178
|
text: string;
|
|
179
|
+
hasFiles: boolean;
|
|
137
180
|
};
|
|
138
181
|
|
|
139
182
|
export function slackInteractionRoutePolicy(
|
|
@@ -141,8 +184,9 @@ export function slackInteractionRoutePolicy(
|
|
|
141
184
|
SlackInteractionInboxEntry,
|
|
142
185
|
"triggerKind" | "slackChannelId" | "slackThreadTs" | "slackMessageTs" | "slackUserId"
|
|
143
186
|
>,
|
|
187
|
+
options: { privateHandoff?: boolean } = {},
|
|
144
188
|
) {
|
|
145
|
-
const directMessageShortcut = isDirectMessageShortcut(entry);
|
|
189
|
+
const directMessageShortcut = isDirectMessageShortcut(entry) || options.privateHandoff === true;
|
|
146
190
|
const source = slackRouteKey(entry.slackChannelId, entry.slackThreadTs ?? entry.slackMessageTs);
|
|
147
191
|
return {
|
|
148
192
|
directMessageShortcut,
|
|
@@ -204,7 +248,9 @@ export function slackEventInboxEntry(
|
|
|
204
248
|
const channelId = boundedString(event.channel, 64);
|
|
205
249
|
const timestamp = boundedString(event.ts, 64);
|
|
206
250
|
const threadTimestamp = boundedString(event.thread_ts, 64);
|
|
207
|
-
const
|
|
251
|
+
const hasFiles =
|
|
252
|
+
Array.isArray(event.files) && event.files.some((file) => boundedString(record(file)?.id, 64));
|
|
253
|
+
const text = boundedText(event.text) ?? (hasFiles ? "(file-only Slack invocation)" : null);
|
|
208
254
|
if (!userId || !channelId || !timestamp || !text) return null;
|
|
209
255
|
let triggerKind: SlackInteractionTriggerKind;
|
|
210
256
|
const explicitlyMentionsBot = text.includes(`<@${bot.botUserId}>`);
|
|
@@ -234,6 +280,7 @@ export function slackEventInboxEntry(
|
|
|
234
280
|
slackThreadTs: threadTimestamp,
|
|
235
281
|
triggerKind,
|
|
236
282
|
text,
|
|
283
|
+
hasFiles,
|
|
237
284
|
};
|
|
238
285
|
}
|
|
239
286
|
|
|
@@ -284,6 +331,7 @@ export function slackReactionInboxEntry(
|
|
|
284
331
|
// Store only the exact emoji name before authorization/content fetch. The
|
|
285
332
|
// provider message and bounded thread are projected at durable claim time.
|
|
286
333
|
text: reaction,
|
|
334
|
+
hasFiles: false,
|
|
287
335
|
};
|
|
288
336
|
}
|
|
289
337
|
|
|
@@ -370,6 +418,15 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
370
418
|
const signed = await readSignedSlackRequest(c, deps);
|
|
371
419
|
const form = new URLSearchParams(signed.rawBody);
|
|
372
420
|
const payload = parseJsonObject(form.get("payload") ?? "");
|
|
421
|
+
if (payload.type === "block_actions") {
|
|
422
|
+
const entry = normalizedBlockActionInteraction(payload);
|
|
423
|
+
const installation = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
|
|
424
|
+
if (!installation) {
|
|
425
|
+
throw new HTTPException(403, { message: "Slack installation unavailable" });
|
|
426
|
+
}
|
|
427
|
+
await enqueueNormalizedSlackInteraction(deps, installation, entry);
|
|
428
|
+
return c.json({ ok: true });
|
|
429
|
+
}
|
|
373
430
|
if (payload.type !== "message_action") {
|
|
374
431
|
throw new HTTPException(400, {
|
|
375
432
|
message: "unsupported Slack interaction",
|
|
@@ -406,6 +463,7 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
406
463
|
slackThreadTs: threadTs,
|
|
407
464
|
triggerKind: "message_shortcut",
|
|
408
465
|
text,
|
|
466
|
+
hasFiles: false,
|
|
409
467
|
});
|
|
410
468
|
return c.json({ ok: true });
|
|
411
469
|
});
|
|
@@ -828,11 +886,51 @@ export function startSlackInteractionPump(
|
|
|
828
886
|
}
|
|
829
887
|
|
|
830
888
|
async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
|
|
889
|
+
if (entry.triggerKind === "block_action") {
|
|
890
|
+
await processSlackBlockAction(deps, entry);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
831
893
|
if (entry.triggerKind === "reaction") {
|
|
832
894
|
await processSlackReactionInboxEntry(deps, entry);
|
|
833
895
|
return;
|
|
834
896
|
}
|
|
835
|
-
const
|
|
897
|
+
const installation = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
|
|
898
|
+
if (
|
|
899
|
+
!installation ||
|
|
900
|
+
installation.accountId !== entry.accountId ||
|
|
901
|
+
installation.workspaceId !== entry.workspaceId ||
|
|
902
|
+
installation.connectionId !== entry.connectionId
|
|
903
|
+
) {
|
|
904
|
+
throw new SlackInteractionPermanentError("slack_task_installation_changed");
|
|
905
|
+
}
|
|
906
|
+
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
907
|
+
accountId: entry.accountId,
|
|
908
|
+
workspaceId: entry.workspaceId,
|
|
909
|
+
connectionId: entry.connectionId,
|
|
910
|
+
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
911
|
+
});
|
|
912
|
+
const policyResolution = isDirectMessageShortcut(entry)
|
|
913
|
+
? ({
|
|
914
|
+
decision: {
|
|
915
|
+
disposition: "ordinary",
|
|
916
|
+
publication: "allow",
|
|
917
|
+
reason: "ordinary_conversation",
|
|
918
|
+
} as const,
|
|
919
|
+
activePolicy: null,
|
|
920
|
+
} as const)
|
|
921
|
+
: await slackTaskPolicyDecision(deps, client, entry);
|
|
922
|
+
const policyDecision = policyResolution.decision;
|
|
923
|
+
if (policyDecision.disposition === "deny") {
|
|
924
|
+
await client.postMessage({
|
|
925
|
+
operationId: deterministicUuid(`slack-policy-denied:${entry.id}`),
|
|
926
|
+
userId: entry.slackUserId,
|
|
927
|
+
text: "OpenGeni cannot start a task from this shared conversation under the current workspace policy. No conversation content was read or retained.",
|
|
928
|
+
});
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
const routePolicy = slackInteractionRoutePolicy(entry, {
|
|
932
|
+
privateHandoff: policyDecision.disposition === "private_handoff",
|
|
933
|
+
});
|
|
836
934
|
const routeKey = routePolicy.initialRouteKey;
|
|
837
935
|
const existing = await getSlackInteractionByRoute(
|
|
838
936
|
deps.db,
|
|
@@ -848,16 +946,6 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
848
946
|
entry.connectionId,
|
|
849
947
|
entry.slackUserId,
|
|
850
948
|
);
|
|
851
|
-
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
852
|
-
accountId: entry.accountId,
|
|
853
|
-
workspaceId: entry.workspaceId,
|
|
854
|
-
connectionId: entry.connectionId,
|
|
855
|
-
subjectId: link?.subjectId ?? "service:slack-interaction",
|
|
856
|
-
...(existing?.sessionId ? { sessionId: existing.sessionId } : {}),
|
|
857
|
-
});
|
|
858
|
-
if (routePolicy.requiresChannelAccess) {
|
|
859
|
-
await client.verifyChannelAccess(entry.slackChannelId);
|
|
860
|
-
}
|
|
861
949
|
if (!link) {
|
|
862
950
|
await client.postMessage({
|
|
863
951
|
operationId: deterministicUuid(`slack-link:${entry.id}`),
|
|
@@ -898,15 +986,16 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
898
986
|
if (!boundInteraction) {
|
|
899
987
|
throw new Error("Durable Slack interaction could not bind its reserved session");
|
|
900
988
|
}
|
|
989
|
+
await ensureSlackSharedTaskOrigin(deps, boundInteraction, entry, policyResolution);
|
|
901
990
|
const shouldRepairAcknowledgement =
|
|
902
991
|
interaction.triggeringProviderEventId === entry.providerEventId ||
|
|
903
|
-
(
|
|
992
|
+
(usesPrivateBotDm(boundInteraction, entry) && boundInteraction.ackSlackMessageTs === null);
|
|
904
993
|
if (shouldRepairAcknowledgement) {
|
|
905
994
|
const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
906
995
|
accountId: entry.accountId,
|
|
907
996
|
workspaceId: entry.workspaceId,
|
|
908
997
|
connectionId: entry.connectionId,
|
|
909
|
-
subjectId:
|
|
998
|
+
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
910
999
|
sessionId: eventSessionId,
|
|
911
1000
|
});
|
|
912
1001
|
await acknowledgeSlackSession(deps, boundClient, boundInteraction, entry);
|
|
@@ -914,15 +1003,47 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
914
1003
|
return;
|
|
915
1004
|
}
|
|
916
1005
|
|
|
1006
|
+
let preparedEntry = entry;
|
|
1007
|
+
let preparedAttachments: PreparedSlackReactionTask = {
|
|
1008
|
+
resources: [],
|
|
1009
|
+
attachments: [],
|
|
1010
|
+
omissionCodes: [],
|
|
1011
|
+
omittedCount: 0,
|
|
1012
|
+
};
|
|
1013
|
+
if (entry.triggerKind === "app_mention" || entry.hasFiles) {
|
|
1014
|
+
const prepared = await prepareSlackInvocationEntry(
|
|
1015
|
+
deps,
|
|
1016
|
+
client,
|
|
1017
|
+
entry,
|
|
1018
|
+
policyDecision.disposition === "private_handoff"
|
|
1019
|
+
? async () => {
|
|
1020
|
+
await requireSlackSharedReadAuthorization(deps, client, entry, policyResolution);
|
|
1021
|
+
}
|
|
1022
|
+
: undefined,
|
|
1023
|
+
);
|
|
1024
|
+
preparedEntry = prepared.entry;
|
|
1025
|
+
preparedAttachments = prepared.attachments;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
917
1028
|
if (existing?.sessionId) {
|
|
918
|
-
|
|
1029
|
+
const remounted = await remountSlackReactionTaskForSession(
|
|
1030
|
+
deps,
|
|
1031
|
+
entry.workspaceId,
|
|
1032
|
+
existing.sessionId,
|
|
1033
|
+
preparedAttachments,
|
|
1034
|
+
);
|
|
1035
|
+
await continueSlackSession(
|
|
1036
|
+
deps,
|
|
1037
|
+
grant,
|
|
1038
|
+
existing,
|
|
1039
|
+
slackInvocationPreparedEntry(preparedEntry, remounted),
|
|
1040
|
+
remounted.resources,
|
|
1041
|
+
);
|
|
919
1042
|
return;
|
|
920
1043
|
}
|
|
921
1044
|
if (!hasPermission(grant.permissions, "sessions:create")) {
|
|
922
1045
|
throw new SlackInteractionPermanentError("sessions_create_denied");
|
|
923
1046
|
}
|
|
924
|
-
const preparedEntry =
|
|
925
|
-
entry.triggerKind === "app_mention" ? await prepareSlackInvocationEntry(client, entry) : entry;
|
|
926
1047
|
const { interaction } = await getOrCreateSlackInteraction(deps.db, {
|
|
927
1048
|
accountId: entry.accountId,
|
|
928
1049
|
workspaceId: entry.workspaceId,
|
|
@@ -932,11 +1053,24 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
932
1053
|
slackThreadTs: entry.slackThreadTs ?? entry.slackMessageTs,
|
|
933
1054
|
routeKey,
|
|
934
1055
|
triggeringProviderEventId: entry.providerEventId,
|
|
1056
|
+
initiatingSlackUserId: entry.slackUserId,
|
|
935
1057
|
owningSubjectId: grant.subjectId,
|
|
936
1058
|
visibility: routePolicy.visibility,
|
|
937
1059
|
});
|
|
938
1060
|
if (interaction.sessionId) {
|
|
939
|
-
|
|
1061
|
+
const remounted = await remountSlackReactionTaskForSession(
|
|
1062
|
+
deps,
|
|
1063
|
+
entry.workspaceId,
|
|
1064
|
+
interaction.sessionId,
|
|
1065
|
+
preparedAttachments,
|
|
1066
|
+
);
|
|
1067
|
+
await continueSlackSession(
|
|
1068
|
+
deps,
|
|
1069
|
+
grant,
|
|
1070
|
+
interaction,
|
|
1071
|
+
slackInvocationPreparedEntry(preparedEntry, remounted),
|
|
1072
|
+
remounted.resources,
|
|
1073
|
+
);
|
|
940
1074
|
return;
|
|
941
1075
|
}
|
|
942
1076
|
const preferredModel = await getLatestSessionModelForSubject(
|
|
@@ -948,9 +1082,10 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
948
1082
|
try {
|
|
949
1083
|
session = await createSessionForRequest(deps, grant, entry.workspaceId, {
|
|
950
1084
|
requestedSessionId: interaction.sessionReservationId,
|
|
951
|
-
initialMessage: preparedEntry.text,
|
|
1085
|
+
initialMessage: slackInvocationPreparedEntry(preparedEntry, preparedAttachments).text,
|
|
952
1086
|
turnInstructions: SLACK_TASK_INSTRUCTIONS,
|
|
953
|
-
firstPartyMcpTools:
|
|
1087
|
+
firstPartyMcpTools: slackTaskFirstPartyMcpTools(deps.settings),
|
|
1088
|
+
resources: preparedAttachments.resources,
|
|
954
1089
|
...(preferredModel ? { model: preferredModel } : {}),
|
|
955
1090
|
idempotencyKey: `slack:${entry.connectionId}:${entry.providerEventId}`,
|
|
956
1091
|
clientEventId: `slack:${entry.providerEventId}`,
|
|
@@ -959,7 +1094,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
959
1094
|
if (error instanceof HTTPException) {
|
|
960
1095
|
await client.postMessage({
|
|
961
1096
|
operationId: deterministicUuid(`slack-admission-failed:${interaction.id}`),
|
|
962
|
-
...(
|
|
1097
|
+
...(routePolicy.directMessageShortcut
|
|
963
1098
|
? { userId: entry.slackUserId }
|
|
964
1099
|
: {
|
|
965
1100
|
channelId: entry.slackChannelId,
|
|
@@ -978,16 +1113,103 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
|
|
|
978
1113
|
sessionId: session.id,
|
|
979
1114
|
});
|
|
980
1115
|
if (!bound) throw new Error("Slack route could not bind its durable session");
|
|
1116
|
+
await ensureSlackSharedTaskOrigin(deps, bound, entry, policyResolution);
|
|
981
1117
|
const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
982
1118
|
accountId: entry.accountId,
|
|
983
1119
|
workspaceId: entry.workspaceId,
|
|
984
1120
|
connectionId: entry.connectionId,
|
|
985
|
-
subjectId:
|
|
1121
|
+
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
986
1122
|
sessionId: session.id,
|
|
987
1123
|
});
|
|
988
1124
|
await acknowledgeSlackSession(deps, boundClient, bound, entry);
|
|
989
1125
|
}
|
|
990
1126
|
|
|
1127
|
+
async function slackTaskPolicyDecision(
|
|
1128
|
+
deps: ApiRouteDeps,
|
|
1129
|
+
client: OpenGeniSlackBotClient,
|
|
1130
|
+
entry: SlackInteractionInboxEntry,
|
|
1131
|
+
) {
|
|
1132
|
+
const facts = await client.slackTaskPolicyFacts(entry.slackChannelId, entry.slackUserId);
|
|
1133
|
+
const activePolicy = facts.initiator
|
|
1134
|
+
? await getActiveSlackTaskPolicy(deps.db, {
|
|
1135
|
+
accountId: entry.accountId,
|
|
1136
|
+
workspaceId: entry.workspaceId,
|
|
1137
|
+
})
|
|
1138
|
+
: null;
|
|
1139
|
+
const decision = evaluateSlackTaskPolicy({
|
|
1140
|
+
policy: activePolicy?.revision.policy ?? null,
|
|
1141
|
+
conversation: {
|
|
1142
|
+
installationTeamId: entry.slackTeamId,
|
|
1143
|
+
conversationId: facts.conversation.id,
|
|
1144
|
+
contextTeamId: facts.conversation.contextTeamId,
|
|
1145
|
+
connectedTeamIds: facts.conversation.connectedTeamIds,
|
|
1146
|
+
sharedTeamIds: facts.conversation.sharedTeamIds,
|
|
1147
|
+
isShared: facts.conversation.isShared,
|
|
1148
|
+
isExternallyShared: facts.conversation.isExternallyShared,
|
|
1149
|
+
isOrgShared: facts.conversation.isOrgShared,
|
|
1150
|
+
isPendingExternallyShared: facts.conversation.isPendingExternallyShared,
|
|
1151
|
+
isMpim: facts.conversation.isMpim,
|
|
1152
|
+
},
|
|
1153
|
+
initiator: facts.initiator ?? { teamId: null, isGuest: null, isExternal: null },
|
|
1154
|
+
});
|
|
1155
|
+
return { decision, activePolicy };
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
async function ensureSlackSharedTaskOrigin(
|
|
1159
|
+
deps: ApiRouteDeps,
|
|
1160
|
+
interaction: SlackInteraction,
|
|
1161
|
+
entry: SlackInteractionInboxEntry,
|
|
1162
|
+
resolution:
|
|
1163
|
+
| Awaited<ReturnType<typeof slackTaskPolicyDecision>>
|
|
1164
|
+
| {
|
|
1165
|
+
decision: {
|
|
1166
|
+
disposition: "ordinary";
|
|
1167
|
+
publication: "allow";
|
|
1168
|
+
reason: "ordinary_conversation";
|
|
1169
|
+
};
|
|
1170
|
+
activePolicy: null;
|
|
1171
|
+
},
|
|
1172
|
+
): Promise<void> {
|
|
1173
|
+
if (resolution.decision.disposition !== "private_handoff") return;
|
|
1174
|
+
if (!resolution.activePolicy || !interaction.sessionId || !interaction.initiatingSlackUserId) {
|
|
1175
|
+
throw new SlackInteractionPermanentError("slack_shared_task_origin_incomplete");
|
|
1176
|
+
}
|
|
1177
|
+
await saveSlackSharedTaskOrigin(deps.db, {
|
|
1178
|
+
accountId: interaction.accountId,
|
|
1179
|
+
workspaceId: interaction.workspaceId,
|
|
1180
|
+
interactionId: interaction.id,
|
|
1181
|
+
connectionId: interaction.connectionId,
|
|
1182
|
+
sessionId: interaction.sessionId,
|
|
1183
|
+
slackTeamId: entry.slackTeamId,
|
|
1184
|
+
sourceChannelId: entry.slackChannelId,
|
|
1185
|
+
sourceThreadTs: entry.slackThreadTs ?? entry.slackMessageTs,
|
|
1186
|
+
initiatingSlackUserId: interaction.initiatingSlackUserId,
|
|
1187
|
+
policyRevisionId: resolution.activePolicy.revision.id,
|
|
1188
|
+
policyHash: resolution.activePolicy.revision.policyHash,
|
|
1189
|
+
policyActivationVersion: resolution.activePolicy.head.activationVersion,
|
|
1190
|
+
publicationMode: resolution.decision.publication,
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async function requireSlackSharedReadAuthorization(
|
|
1195
|
+
deps: ApiRouteDeps,
|
|
1196
|
+
client: OpenGeniSlackBotClient,
|
|
1197
|
+
entry: SlackInteractionInboxEntry,
|
|
1198
|
+
resolution: Awaited<ReturnType<typeof slackTaskPolicyDecision>>,
|
|
1199
|
+
): Promise<void> {
|
|
1200
|
+
const current = await slackTaskPolicyDecision(deps, client, entry);
|
|
1201
|
+
if (
|
|
1202
|
+
!resolution.activePolicy ||
|
|
1203
|
+
!current.activePolicy ||
|
|
1204
|
+
current.decision.disposition !== "private_handoff" ||
|
|
1205
|
+
current.activePolicy.revision.id !== resolution.activePolicy.revision.id ||
|
|
1206
|
+
current.activePolicy.revision.policyHash !== resolution.activePolicy.revision.policyHash ||
|
|
1207
|
+
current.activePolicy.head.activationVersion !== resolution.activePolicy.head.activationVersion
|
|
1208
|
+
) {
|
|
1209
|
+
throw new SlackInteractionPermanentError("slack_shared_policy_changed_before_read");
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
991
1213
|
export type SlackInvocationMessageContext = {
|
|
992
1214
|
messages: Awaited<ReturnType<OpenGeniSlackBotClient["threadReplies"]>>["messages"];
|
|
993
1215
|
nextCursor: string | null;
|
|
@@ -995,31 +1217,72 @@ export type SlackInvocationMessageContext = {
|
|
|
995
1217
|
};
|
|
996
1218
|
|
|
997
1219
|
async function prepareSlackInvocationEntry(
|
|
1220
|
+
deps: ApiRouteDeps,
|
|
998
1221
|
client: OpenGeniSlackBotClient,
|
|
999
1222
|
entry: SlackInteractionInboxEntry,
|
|
1000
|
-
)
|
|
1223
|
+
authorizeRead?: () => Promise<void>,
|
|
1224
|
+
): Promise<{ entry: SlackInteractionInboxEntry; attachments: PreparedSlackReactionTask }> {
|
|
1001
1225
|
const context = entry.slackThreadTs
|
|
1002
1226
|
? await client.threadReplies({
|
|
1003
1227
|
channelId: entry.slackChannelId,
|
|
1004
1228
|
threadTimestamp: entry.slackThreadTs,
|
|
1005
1229
|
limit: MAX_SLACK_INVOCATION_CONTEXT_MESSAGES,
|
|
1230
|
+
...(authorizeRead ? { authorizeRead } : {}),
|
|
1006
1231
|
})
|
|
1007
1232
|
: await client.channelHistory({
|
|
1008
1233
|
channelId: entry.slackChannelId,
|
|
1009
1234
|
latest: entry.slackMessageTs,
|
|
1010
1235
|
inclusive: true,
|
|
1011
1236
|
limit: MAX_SLACK_CHANNEL_CONTEXT_MESSAGES,
|
|
1237
|
+
...(authorizeRead ? { authorizeRead } : {}),
|
|
1012
1238
|
});
|
|
1239
|
+
let exactMessage = context.messages.find((message) => message.timestamp === entry.slackMessageTs);
|
|
1240
|
+
if (!exactMessage && entry.hasFiles && entry.slackThreadTs) {
|
|
1241
|
+
const exact = await client.threadReplies({
|
|
1242
|
+
channelId: entry.slackChannelId,
|
|
1243
|
+
threadTimestamp: entry.slackThreadTs,
|
|
1244
|
+
oldest: entry.slackMessageTs,
|
|
1245
|
+
latest: entry.slackMessageTs,
|
|
1246
|
+
inclusive: true,
|
|
1247
|
+
limit: 1,
|
|
1248
|
+
...(authorizeRead ? { authorizeRead } : {}),
|
|
1249
|
+
});
|
|
1250
|
+
exactMessage = exact.messages.find((message) => message.timestamp === entry.slackMessageTs);
|
|
1251
|
+
}
|
|
1252
|
+
const attachments = exactMessage
|
|
1253
|
+
? await prepareSlackMessageAttachments(deps, client, entry, exactMessage.files)
|
|
1254
|
+
: {
|
|
1255
|
+
resources: [],
|
|
1256
|
+
attachments: [],
|
|
1257
|
+
omissionCodes: entry.hasFiles ? ["attachment_unavailable"] : [],
|
|
1258
|
+
omittedCount: entry.hasFiles ? 1 : 0,
|
|
1259
|
+
};
|
|
1260
|
+
const baseText =
|
|
1261
|
+
entry.triggerKind === "app_mention"
|
|
1262
|
+
? slackInvocationTaskText(entry, {
|
|
1263
|
+
messages: context.messages,
|
|
1264
|
+
nextCursor: context.nextCursor,
|
|
1265
|
+
kind: entry.slackThreadTs ? "thread" : "channel",
|
|
1266
|
+
})
|
|
1267
|
+
: entry.text;
|
|
1013
1268
|
return {
|
|
1014
|
-
...entry,
|
|
1015
|
-
|
|
1016
|
-
messages: context.messages,
|
|
1017
|
-
nextCursor: context.nextCursor,
|
|
1018
|
-
kind: entry.slackThreadTs ? "thread" : "channel",
|
|
1019
|
-
}),
|
|
1269
|
+
entry: { ...entry, text: baseText },
|
|
1270
|
+
attachments,
|
|
1020
1271
|
};
|
|
1021
1272
|
}
|
|
1022
1273
|
|
|
1274
|
+
function slackInvocationPreparedEntry(
|
|
1275
|
+
entry: SlackInteractionInboxEntry,
|
|
1276
|
+
attachments: PreparedSlackReactionTask,
|
|
1277
|
+
): SlackInteractionInboxEntry {
|
|
1278
|
+
const manifest = slackAttachmentManifest(
|
|
1279
|
+
attachments,
|
|
1280
|
+
"Imported invocation attachments",
|
|
1281
|
+
"invocation",
|
|
1282
|
+
);
|
|
1283
|
+
return manifest ? { ...entry, text: `${entry.text}\n\n${manifest}` } : entry;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1023
1286
|
export function slackInvocationTaskText(
|
|
1024
1287
|
entry: Pick<SlackInteractionInboxEntry, "slackMessageTs" | "slackUserId" | "text">,
|
|
1025
1288
|
context: SlackInvocationMessageContext,
|
|
@@ -1097,9 +1360,23 @@ async function acknowledgeSlackSession(
|
|
|
1097
1360
|
throw new Error("Slack acknowledgement requires a bound session");
|
|
1098
1361
|
}
|
|
1099
1362
|
const directMessageShortcut = isDirectMessageShortcut(entry);
|
|
1363
|
+
const privateHandoff =
|
|
1364
|
+
!directMessageShortcut && interaction.visibility === "private" && entry.triggerKind !== "dm";
|
|
1365
|
+
const privateBotDm = directMessageShortcut || privateHandoff;
|
|
1366
|
+
const operationId = deterministicUuid(`slack-ack:${interaction.id}`);
|
|
1367
|
+
const text = directMessageShortcut
|
|
1368
|
+
? `OpenGeni started a private task from the selected DM message. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this bot-DM thread to continue, or reply \`stop\` to stop. The source DM was not opened to the bot or made workspace-visible.`
|
|
1369
|
+
: privateHandoff
|
|
1370
|
+
? `OpenGeni started a private task from the selected Slack conversation. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this bot-DM thread to continue, or reply \`stop\` to stop. Results stay private unless a separate authorized publication is approved.`
|
|
1371
|
+
: `OpenGeni started this task. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this thread to continue, or reply \`stop\` to stop. Start a new top-level DM or invoke /opengeni again for a new session.`;
|
|
1372
|
+
const controls = await controlActionBlocks(deps, interaction, {
|
|
1373
|
+
messageOperationId: operationId,
|
|
1374
|
+
sessionEventSequence: 0,
|
|
1375
|
+
state: "active",
|
|
1376
|
+
});
|
|
1100
1377
|
const ack = await client.postMessage({
|
|
1101
|
-
operationId
|
|
1102
|
-
...(
|
|
1378
|
+
operationId,
|
|
1379
|
+
...(privateBotDm
|
|
1103
1380
|
? { userId: entry.slackUserId }
|
|
1104
1381
|
: {
|
|
1105
1382
|
channelId: entry.slackChannelId,
|
|
@@ -1107,18 +1384,24 @@ async function acknowledgeSlackSession(
|
|
|
1107
1384
|
? {}
|
|
1108
1385
|
: { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
|
|
1109
1386
|
}),
|
|
1110
|
-
text
|
|
1111
|
-
|
|
1112
|
-
|
|
1387
|
+
text,
|
|
1388
|
+
...(controls.length > 0
|
|
1389
|
+
? {
|
|
1390
|
+
blocks: [
|
|
1391
|
+
{ type: "section", text: { type: "mrkdwn", text } },
|
|
1392
|
+
...controls,
|
|
1393
|
+
] as SlackMessageBlock[],
|
|
1394
|
+
}
|
|
1395
|
+
: {}),
|
|
1113
1396
|
});
|
|
1114
|
-
if (entry.triggerKind === "slash_command" ||
|
|
1397
|
+
if (entry.triggerKind === "slash_command" || privateBotDm) {
|
|
1115
1398
|
const rekeyed = await rekeySlackInteractionRoute(deps.db, {
|
|
1116
1399
|
...interaction,
|
|
1117
1400
|
routeKey: slackRouteKey(ack.channelId, ack.timestamp),
|
|
1118
1401
|
slackChannelId: ack.channelId,
|
|
1119
1402
|
slackThreadTs: ack.timestamp,
|
|
1120
1403
|
ackSlackMessageTs: ack.timestamp,
|
|
1121
|
-
repairUnacknowledgedPrivateShortcutDelivery:
|
|
1404
|
+
repairUnacknowledgedPrivateShortcutDelivery: privateBotDm,
|
|
1122
1405
|
});
|
|
1123
1406
|
if (!rekeyed) throw new Error("Slack acknowledgement could not rekey its durable route");
|
|
1124
1407
|
}
|
|
@@ -1196,7 +1479,7 @@ async function processSlackReactionInboxEntry(
|
|
|
1196
1479
|
accountId: entry.accountId,
|
|
1197
1480
|
workspaceId: entry.workspaceId,
|
|
1198
1481
|
connectionId: entry.connectionId,
|
|
1199
|
-
subjectId:
|
|
1482
|
+
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
1200
1483
|
sessionId: eventSessionId,
|
|
1201
1484
|
});
|
|
1202
1485
|
await acknowledgeSlackReactionSession(deps, client, boundInteraction, settings.emoji);
|
|
@@ -1208,7 +1491,7 @@ async function processSlackReactionInboxEntry(
|
|
|
1208
1491
|
accountId: entry.accountId,
|
|
1209
1492
|
workspaceId: entry.workspaceId,
|
|
1210
1493
|
connectionId: entry.connectionId,
|
|
1211
|
-
subjectId:
|
|
1494
|
+
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
1212
1495
|
});
|
|
1213
1496
|
const context = await client.reactionMessageContext({
|
|
1214
1497
|
channelId: entry.slackChannelId,
|
|
@@ -1237,11 +1520,7 @@ async function processSlackReactionInboxEntry(
|
|
|
1237
1520
|
if (!saved) throw new Error("Slack reaction inbox checkpoint claim was lost");
|
|
1238
1521
|
},
|
|
1239
1522
|
});
|
|
1240
|
-
const
|
|
1241
|
-
...entry,
|
|
1242
|
-
slackThreadTs: context.threadTimestamp,
|
|
1243
|
-
text: slackReactionTaskText(context),
|
|
1244
|
-
};
|
|
1523
|
+
const preparedTask = await prepareSlackReactionTask(deps, client, entry, context);
|
|
1245
1524
|
const routeKey = slackRouteKey(entry.slackChannelId, context.threadTimestamp);
|
|
1246
1525
|
const existing = await getSlackInteractionByRoute(
|
|
1247
1526
|
deps.db,
|
|
@@ -1250,7 +1529,19 @@ async function processSlackReactionInboxEntry(
|
|
|
1250
1529
|
routeKey,
|
|
1251
1530
|
);
|
|
1252
1531
|
if (existing?.sessionId) {
|
|
1253
|
-
|
|
1532
|
+
const appendedTask = await remountSlackReactionTaskForSession(
|
|
1533
|
+
deps,
|
|
1534
|
+
entry.workspaceId,
|
|
1535
|
+
existing.sessionId,
|
|
1536
|
+
preparedTask,
|
|
1537
|
+
);
|
|
1538
|
+
await continueSlackReactionSession(
|
|
1539
|
+
deps,
|
|
1540
|
+
grant,
|
|
1541
|
+
existing,
|
|
1542
|
+
slackReactionPreparedEntry(entry, context, appendedTask),
|
|
1543
|
+
appendedTask.resources,
|
|
1544
|
+
);
|
|
1254
1545
|
return;
|
|
1255
1546
|
}
|
|
1256
1547
|
const { interaction } = await getOrCreateSlackInteraction(deps.db, {
|
|
@@ -1262,11 +1553,24 @@ async function processSlackReactionInboxEntry(
|
|
|
1262
1553
|
slackThreadTs: context.threadTimestamp,
|
|
1263
1554
|
routeKey,
|
|
1264
1555
|
triggeringProviderEventId: entry.providerEventId,
|
|
1556
|
+
initiatingSlackUserId: entry.slackUserId,
|
|
1265
1557
|
owningSubjectId: grant.subjectId,
|
|
1266
1558
|
visibility: "workspace",
|
|
1267
1559
|
});
|
|
1268
1560
|
if (interaction.sessionId) {
|
|
1269
|
-
|
|
1561
|
+
const appendedTask = await remountSlackReactionTaskForSession(
|
|
1562
|
+
deps,
|
|
1563
|
+
entry.workspaceId,
|
|
1564
|
+
interaction.sessionId,
|
|
1565
|
+
preparedTask,
|
|
1566
|
+
);
|
|
1567
|
+
await continueSlackReactionSession(
|
|
1568
|
+
deps,
|
|
1569
|
+
grant,
|
|
1570
|
+
interaction,
|
|
1571
|
+
slackReactionPreparedEntry(entry, context, appendedTask),
|
|
1572
|
+
appendedTask.resources,
|
|
1573
|
+
);
|
|
1270
1574
|
return;
|
|
1271
1575
|
}
|
|
1272
1576
|
if (interaction.owningSubjectId !== grant.subjectId) {
|
|
@@ -1282,6 +1586,7 @@ async function processSlackReactionInboxEntry(
|
|
|
1282
1586
|
entry.workspaceId,
|
|
1283
1587
|
grant.subjectId,
|
|
1284
1588
|
);
|
|
1589
|
+
const preparedEntry = slackReactionPreparedEntry(entry, context, preparedTask);
|
|
1285
1590
|
let session: Awaited<ReturnType<typeof createSessionForRequest>>;
|
|
1286
1591
|
try {
|
|
1287
1592
|
session = await createSessionForRequest(deps, grant, entry.workspaceId, {
|
|
@@ -1290,7 +1595,8 @@ async function processSlackReactionInboxEntry(
|
|
|
1290
1595
|
turnInstructions: SLACK_TASK_INSTRUCTIONS,
|
|
1291
1596
|
// The exact reacted message and bounded containing thread are already in
|
|
1292
1597
|
// the prompt; do not expose general Slack history tools for this trigger.
|
|
1293
|
-
firstPartyMcpTools:
|
|
1598
|
+
firstPartyMcpTools: resolveFirstPartyMcpToolPolicy(deps.settings).default,
|
|
1599
|
+
resources: preparedTask.resources,
|
|
1294
1600
|
...(preferredModel ? { model: preferredModel } : {}),
|
|
1295
1601
|
// Every reaction entry converging on this route must use the same create
|
|
1296
1602
|
// key. This closes the same-owner multi-event race while the owner check
|
|
@@ -1303,7 +1609,7 @@ async function processSlackReactionInboxEntry(
|
|
|
1303
1609
|
// by that operation. Replay this exact Slack event through the normal
|
|
1304
1610
|
// per-message idempotency boundary: the create winner is recognized as the
|
|
1305
1611
|
// initial event, while every distinct loser appends one durable task.
|
|
1306
|
-
await acceptSlackReactionTask(deps, grant, session.id, preparedEntry);
|
|
1612
|
+
await acceptSlackReactionTask(deps, grant, session.id, preparedEntry, preparedTask.resources);
|
|
1307
1613
|
} catch (error) {
|
|
1308
1614
|
if (error instanceof HTTPException) {
|
|
1309
1615
|
await client.postMessage({
|
|
@@ -1345,7 +1651,67 @@ type SlackReactionMessageContext = Awaited<
|
|
|
1345
1651
|
ReturnType<OpenGeniSlackBotClient["reactionMessageContext"]>
|
|
1346
1652
|
>;
|
|
1347
1653
|
|
|
1348
|
-
|
|
1654
|
+
type PreparedSlackReactionTask = Readonly<{
|
|
1655
|
+
resources: FileResourceRef[];
|
|
1656
|
+
attachments: ImportedSlackReactionImage[];
|
|
1657
|
+
omissionCodes: string[];
|
|
1658
|
+
omittedCount: number;
|
|
1659
|
+
}>;
|
|
1660
|
+
|
|
1661
|
+
function slackReactionPreparedEntry(
|
|
1662
|
+
entry: SlackInteractionInboxEntry,
|
|
1663
|
+
context: SlackReactionMessageContext,
|
|
1664
|
+
prepared: PreparedSlackReactionTask,
|
|
1665
|
+
): SlackInteractionInboxEntry {
|
|
1666
|
+
return {
|
|
1667
|
+
...entry,
|
|
1668
|
+
slackThreadTs: context.threadTimestamp,
|
|
1669
|
+
text: slackReactionTaskText(context, prepared),
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
async function remountSlackReactionTaskForSession(
|
|
1674
|
+
deps: ApiRouteDeps,
|
|
1675
|
+
workspaceId: string,
|
|
1676
|
+
sessionId: string,
|
|
1677
|
+
prepared: PreparedSlackReactionTask,
|
|
1678
|
+
): Promise<PreparedSlackReactionTask> {
|
|
1679
|
+
if (prepared.attachments.length === 0) return prepared;
|
|
1680
|
+
const session = await getSession(deps.db, workspaceId, sessionId);
|
|
1681
|
+
if (!session) throw new SlackInteractionPermanentError("session_not_found");
|
|
1682
|
+
const initialOrdinal = session.resources.reduce((maximum, resource) => {
|
|
1683
|
+
if (resource.kind !== "file" || !resource.mountPath) return maximum;
|
|
1684
|
+
const match = /^attachments\/slack\/(\d{2})-/u.exec(resource.mountPath);
|
|
1685
|
+
return match ? Math.max(maximum, Number(match[1])) : maximum;
|
|
1686
|
+
}, 0);
|
|
1687
|
+
const attachments = prepared.attachments.map((attachment, index) => {
|
|
1688
|
+
const basename = attachment.resource.mountPath?.replace(/^attachments\/slack\/\d{2}-/u, "");
|
|
1689
|
+
const ordinal = initialOrdinal + index + 1;
|
|
1690
|
+
if (ordinal > 99 || !basename) {
|
|
1691
|
+
throw new SlackInteractionPermanentError("slack_attachment_mount_exhausted");
|
|
1692
|
+
}
|
|
1693
|
+
const resource = Object.freeze({
|
|
1694
|
+
...attachment.resource,
|
|
1695
|
+
mountPath: `attachments/slack/${String(ordinal).padStart(2, "0")}-${basename}`,
|
|
1696
|
+
});
|
|
1697
|
+
return Object.freeze({ ...attachment, resource });
|
|
1698
|
+
});
|
|
1699
|
+
return {
|
|
1700
|
+
...prepared,
|
|
1701
|
+
attachments,
|
|
1702
|
+
resources: attachments.map((attachment) => attachment.resource),
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
export function slackReactionTaskText(
|
|
1707
|
+
context: SlackReactionMessageContext,
|
|
1708
|
+
prepared: PreparedSlackReactionTask = {
|
|
1709
|
+
resources: [],
|
|
1710
|
+
attachments: [],
|
|
1711
|
+
omissionCodes: [],
|
|
1712
|
+
omittedCount: 0,
|
|
1713
|
+
},
|
|
1714
|
+
) {
|
|
1349
1715
|
const reactedLine = slackReactionMessageLine(context.reactedMessage, true);
|
|
1350
1716
|
const surroundingLines = context.messages
|
|
1351
1717
|
.slice(0, MAX_SLACK_REACTION_CONTEXT_MESSAGES)
|
|
@@ -1356,7 +1722,8 @@ export function slackReactionTaskText(context: SlackReactionMessageContext) {
|
|
|
1356
1722
|
let prompt = [
|
|
1357
1723
|
"A linked, authorized Slack user explicitly summoned OpenGeni by reacting to one message.",
|
|
1358
1724
|
"Use only the exact reacted message and bounded containing-thread context below.",
|
|
1359
|
-
"
|
|
1725
|
+
"Execute a direct, safe, sufficiently specified request immediately.",
|
|
1726
|
+
"Ask one concise clarifying question only when materially required information is missing or the requested action is risky, irreversible, or authorization-sensitive.",
|
|
1360
1727
|
"Do not infer permission to ingest or persist this Slack content into Knowledge, Memory, preferences, policy, instructions, or the Workspace Charter.",
|
|
1361
1728
|
"",
|
|
1362
1729
|
"Exact reacted message:",
|
|
@@ -1373,9 +1740,171 @@ export function slackReactionTaskText(context: SlackReactionMessageContext) {
|
|
|
1373
1740
|
}
|
|
1374
1741
|
prompt = candidate;
|
|
1375
1742
|
}
|
|
1743
|
+
const attachmentManifest = slackReactionAttachmentManifest(prepared);
|
|
1744
|
+
if (attachmentManifest) {
|
|
1745
|
+
const candidate = `${prompt}\n\n${attachmentManifest}`;
|
|
1746
|
+
if (candidate.length + 1 + truncationNotice.length <= MAX_SLACK_INPUT_CHARS) {
|
|
1747
|
+
prompt = candidate;
|
|
1748
|
+
} else {
|
|
1749
|
+
truncated = true;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1376
1752
|
return truncated ? `${prompt}\n${truncationNotice}` : prompt;
|
|
1377
1753
|
}
|
|
1378
1754
|
|
|
1755
|
+
async function prepareSlackReactionTask(
|
|
1756
|
+
deps: ApiRouteDeps,
|
|
1757
|
+
client: OpenGeniSlackBotClient,
|
|
1758
|
+
entry: SlackInteractionInboxEntry,
|
|
1759
|
+
context: SlackReactionMessageContext,
|
|
1760
|
+
): Promise<PreparedSlackReactionTask> {
|
|
1761
|
+
return await prepareSlackMessageAttachments(deps, client, entry, context.reactedMessage.files);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
async function prepareSlackMessageAttachments(
|
|
1765
|
+
deps: ApiRouteDeps,
|
|
1766
|
+
client: OpenGeniSlackBotClient,
|
|
1767
|
+
entry: SlackInteractionInboxEntry,
|
|
1768
|
+
exactFiles: readonly { id: string; name: string; title: string }[],
|
|
1769
|
+
): Promise<PreparedSlackReactionTask> {
|
|
1770
|
+
if (exactFiles.length === 0) {
|
|
1771
|
+
return { resources: [], attachments: [], omissionCodes: [], omittedCount: 0 };
|
|
1772
|
+
}
|
|
1773
|
+
const selected = exactFiles.slice(0, MAX_SLACK_REACTION_IMAGES);
|
|
1774
|
+
const omissionCodes: string[] = [];
|
|
1775
|
+
let omittedCount = Math.max(0, exactFiles.length - selected.length);
|
|
1776
|
+
if (omittedCount > 0) omissionCodes.push("attachment_limit");
|
|
1777
|
+
|
|
1778
|
+
// Complete every provider authorization/file-share preflight before any
|
|
1779
|
+
// download or workspace mutation. A revocation or exact-channel failure
|
|
1780
|
+
// therefore leaves zero imported objects even when it affects a later file.
|
|
1781
|
+
const prepared = await client.prepareReactionImageDownloads({
|
|
1782
|
+
channelId: entry.slackChannelId,
|
|
1783
|
+
files: selected.map((file) => ({
|
|
1784
|
+
id: file.id,
|
|
1785
|
+
name: file.name,
|
|
1786
|
+
title: file.title,
|
|
1787
|
+
})),
|
|
1788
|
+
});
|
|
1789
|
+
const downloaded: Awaited<ReturnType<OpenGeniSlackBotClient["downloadReactionImage"]>>[] = [];
|
|
1790
|
+
let aggregateBytes = 0;
|
|
1791
|
+
for (const image of prepared) {
|
|
1792
|
+
try {
|
|
1793
|
+
const value = await client.downloadReactionImage(image);
|
|
1794
|
+
if (
|
|
1795
|
+
value.bytes.byteLength > SLACK_REACTION_IMAGE_MAX_BYTES ||
|
|
1796
|
+
aggregateBytes + value.bytes.byteLength > MAX_SLACK_REACTION_IMAGE_AGGREGATE_BYTES
|
|
1797
|
+
) {
|
|
1798
|
+
omittedCount += 1;
|
|
1799
|
+
omissionCodes.push("attachment_size_limit");
|
|
1800
|
+
continue;
|
|
1801
|
+
}
|
|
1802
|
+
aggregateBytes += value.bytes.byteLength;
|
|
1803
|
+
downloaded.push(value);
|
|
1804
|
+
} catch (error) {
|
|
1805
|
+
if (slackReactionAuthorizationFailure(error)) throw error;
|
|
1806
|
+
omittedCount += 1;
|
|
1807
|
+
omissionCodes.push(slackReactionOmissionCode(error));
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
if (!deps.objectStorage) {
|
|
1812
|
+
return {
|
|
1813
|
+
resources: [],
|
|
1814
|
+
attachments: [],
|
|
1815
|
+
omissionCodes: [...omissionCodes, "storage_unavailable"],
|
|
1816
|
+
omittedCount: omittedCount + downloaded.length,
|
|
1817
|
+
};
|
|
1818
|
+
}
|
|
1819
|
+
const attachments: ImportedSlackReactionImage[] = [];
|
|
1820
|
+
for (const image of downloaded) {
|
|
1821
|
+
try {
|
|
1822
|
+
attachments.push(
|
|
1823
|
+
await importSlackReactionImage(
|
|
1824
|
+
{ db: deps.db, objectStorage: deps.objectStorage },
|
|
1825
|
+
{
|
|
1826
|
+
accountId: entry.accountId,
|
|
1827
|
+
workspaceId: entry.workspaceId,
|
|
1828
|
+
connectionId: entry.connectionId,
|
|
1829
|
+
slackTeamId: entry.slackTeamId,
|
|
1830
|
+
slackChannelId: entry.slackChannelId,
|
|
1831
|
+
slackMessageTs: entry.slackMessageTs,
|
|
1832
|
+
},
|
|
1833
|
+
image,
|
|
1834
|
+
attachments.length + 1,
|
|
1835
|
+
),
|
|
1836
|
+
);
|
|
1837
|
+
} catch {
|
|
1838
|
+
omittedCount += 1;
|
|
1839
|
+
omissionCodes.push("storage_failed");
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
return {
|
|
1843
|
+
resources: attachments.map((attachment) => attachment.resource),
|
|
1844
|
+
attachments,
|
|
1845
|
+
omissionCodes,
|
|
1846
|
+
omittedCount,
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
function slackReactionAttachmentManifest(prepared: PreparedSlackReactionTask): string {
|
|
1851
|
+
return slackAttachmentManifest(
|
|
1852
|
+
prepared,
|
|
1853
|
+
"Imported reacted-message attachments",
|
|
1854
|
+
"reacted-message",
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function slackAttachmentManifest(
|
|
1859
|
+
prepared: PreparedSlackReactionTask,
|
|
1860
|
+
heading: string,
|
|
1861
|
+
sourceLabel: string,
|
|
1862
|
+
): string {
|
|
1863
|
+
const lines = prepared.attachments.map(
|
|
1864
|
+
(attachment, index) =>
|
|
1865
|
+
`${index + 1}. ${attachment.resource.mountPath} (${attachment.contentType}, ${attachment.sizeBytes} bytes)`,
|
|
1866
|
+
);
|
|
1867
|
+
const omission =
|
|
1868
|
+
prepared.omittedCount > 0
|
|
1869
|
+
? `Some ${sourceLabel} attachments were omitted (${prepared.omittedCount}; ${
|
|
1870
|
+
[...new Set(prepared.omissionCodes)].slice(0, 4).join(", ") || "unavailable"
|
|
1871
|
+
}). Continue with the available attachments and text.`
|
|
1872
|
+
: "";
|
|
1873
|
+
if (lines.length === 0) return omission;
|
|
1874
|
+
return [
|
|
1875
|
+
`${heading} (Slack order; aligned to attached resources):`,
|
|
1876
|
+
...lines,
|
|
1877
|
+
...(omission ? [omission] : []),
|
|
1878
|
+
].join("\n");
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function slackReactionAuthorizationFailure(error: unknown): boolean {
|
|
1882
|
+
if (!(error instanceof SlackBotProviderError)) return true;
|
|
1883
|
+
return new Set([
|
|
1884
|
+
"account_inactive",
|
|
1885
|
+
"channel_not_found",
|
|
1886
|
+
"file_not_found",
|
|
1887
|
+
"file_not_shared_to_channel",
|
|
1888
|
+
"http_401",
|
|
1889
|
+
"http_403",
|
|
1890
|
+
"invalid_auth",
|
|
1891
|
+
"missing_scope",
|
|
1892
|
+
"not_authed",
|
|
1893
|
+
"not_in_channel",
|
|
1894
|
+
"slack_connect_unsupported",
|
|
1895
|
+
"token_revoked",
|
|
1896
|
+
]).has(error.code);
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
function slackReactionOmissionCode(error: unknown): string {
|
|
1900
|
+
if (!(error instanceof SlackBotProviderError)) return "attachment_unavailable";
|
|
1901
|
+
if (error.code === "unsupported_file_type" || error.code === "file_content_type_mismatch") {
|
|
1902
|
+
return "unsupported_image";
|
|
1903
|
+
}
|
|
1904
|
+
if (error.code.includes("size")) return "attachment_size_limit";
|
|
1905
|
+
return "attachment_unavailable";
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1379
1908
|
function slackReactionMessageLine(
|
|
1380
1909
|
message: SlackReactionMessageContext["reactedMessage"],
|
|
1381
1910
|
reacted: boolean,
|
|
@@ -1414,6 +1943,7 @@ async function continueSlackReactionSession(
|
|
|
1414
1943
|
grant: AccessGrant,
|
|
1415
1944
|
interaction: SlackInteraction,
|
|
1416
1945
|
entry: SlackInteractionInboxEntry,
|
|
1946
|
+
resources: FileResourceRef[],
|
|
1417
1947
|
) {
|
|
1418
1948
|
if (
|
|
1419
1949
|
!interaction.sessionId ||
|
|
@@ -1422,7 +1952,7 @@ async function continueSlackReactionSession(
|
|
|
1422
1952
|
throw new SlackInteractionPermanentError("session_owner_mismatch");
|
|
1423
1953
|
}
|
|
1424
1954
|
await reopenSlackInteractionDelivery(deps.db, interaction);
|
|
1425
|
-
await acceptSlackReactionTask(deps, grant, interaction.sessionId, entry);
|
|
1955
|
+
await acceptSlackReactionTask(deps, grant, interaction.sessionId, entry, resources);
|
|
1426
1956
|
}
|
|
1427
1957
|
|
|
1428
1958
|
async function acceptSlackReactionTask(
|
|
@@ -1430,6 +1960,7 @@ async function acceptSlackReactionTask(
|
|
|
1430
1960
|
grant: AccessGrant,
|
|
1431
1961
|
sessionId: string,
|
|
1432
1962
|
entry: SlackInteractionInboxEntry,
|
|
1963
|
+
resources: FileResourceRef[],
|
|
1433
1964
|
) {
|
|
1434
1965
|
const clientEventId = `slack:${entry.providerEventId}`;
|
|
1435
1966
|
const existing = await getSessionEventByClientEventId(
|
|
@@ -1447,6 +1978,7 @@ async function acceptSlackReactionTask(
|
|
|
1447
1978
|
await acceptSessionUserMessage(deps, grant, entry.workspaceId, sessionId, {
|
|
1448
1979
|
text: entry.text,
|
|
1449
1980
|
turnInstructions: SLACK_TASK_INSTRUCTIONS,
|
|
1981
|
+
resources,
|
|
1450
1982
|
clientEventId,
|
|
1451
1983
|
});
|
|
1452
1984
|
}
|
|
@@ -1456,6 +1988,7 @@ async function continueSlackSession(
|
|
|
1456
1988
|
grant: AccessGrant,
|
|
1457
1989
|
interaction: SlackInteraction,
|
|
1458
1990
|
entry: SlackInteractionInboxEntry,
|
|
1991
|
+
resources: FileResourceRef[] = [],
|
|
1459
1992
|
) {
|
|
1460
1993
|
if (
|
|
1461
1994
|
!interaction.sessionId ||
|
|
@@ -1529,10 +2062,772 @@ async function continueSlackSession(
|
|
|
1529
2062
|
await acceptSessionUserMessage(deps, grant, entry.workspaceId, interaction.sessionId, {
|
|
1530
2063
|
text: entry.text,
|
|
1531
2064
|
turnInstructions: SLACK_TASK_INSTRUCTIONS,
|
|
2065
|
+
resources,
|
|
1532
2066
|
clientEventId: `slack:${entry.providerEventId}`,
|
|
1533
2067
|
});
|
|
1534
2068
|
}
|
|
1535
2069
|
|
|
2070
|
+
const SLACK_ACTION_ID_BY_KIND: Record<SlackInteractionActionKind, string> = {
|
|
2071
|
+
approval_approve: "opengeni.approval.approve",
|
|
2072
|
+
approval_reject: "opengeni.approval.reject",
|
|
2073
|
+
human_input_select: "opengeni.human_input.select",
|
|
2074
|
+
human_input_skip: "opengeni.human_input.skip",
|
|
2075
|
+
session_status: "opengeni.session.status",
|
|
2076
|
+
session_pause: "opengeni.session.pause",
|
|
2077
|
+
session_resume: "opengeni.session.resume",
|
|
2078
|
+
shared_result_publish: "opengeni.shared_result.publish",
|
|
2079
|
+
};
|
|
2080
|
+
|
|
2081
|
+
async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
|
|
2082
|
+
const separator = entry.text.lastIndexOf(":");
|
|
2083
|
+
const actionId = separator > 0 ? entry.text.slice(0, separator) : "";
|
|
2084
|
+
const handleId = separator > 0 ? entry.text.slice(separator + 1) : "";
|
|
2085
|
+
const route = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
|
|
2086
|
+
if (
|
|
2087
|
+
!route ||
|
|
2088
|
+
route.accountId !== entry.accountId ||
|
|
2089
|
+
route.workspaceId !== entry.workspaceId ||
|
|
2090
|
+
route.connectionId !== entry.connectionId
|
|
2091
|
+
) {
|
|
2092
|
+
throw new SlackInteractionPermanentError("slack_action_installation_changed");
|
|
2093
|
+
}
|
|
2094
|
+
const handle = await getSlackInteractionActionHandle(deps.db, {
|
|
2095
|
+
accountId: entry.accountId,
|
|
2096
|
+
workspaceId: entry.workspaceId,
|
|
2097
|
+
handleId,
|
|
2098
|
+
});
|
|
2099
|
+
if (!handle || SLACK_ACTION_ID_BY_KIND[handle.actionKind] !== actionId) {
|
|
2100
|
+
throw new SlackInteractionPermanentError("slack_action_handle_invalid");
|
|
2101
|
+
}
|
|
2102
|
+
if (handle.status !== "pending") return;
|
|
2103
|
+
if (handle.expiresAt.getTime() <= Date.now()) {
|
|
2104
|
+
await settleSlackInteractionActionHandles(deps.db, {
|
|
2105
|
+
accountId: entry.accountId,
|
|
2106
|
+
workspaceId: entry.workspaceId,
|
|
2107
|
+
handleId: handle.id,
|
|
2108
|
+
result: "expired",
|
|
2109
|
+
stale: true,
|
|
2110
|
+
});
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
if (handle.authorizedSlackUserId !== entry.slackUserId) {
|
|
2114
|
+
throw new SlackInteractionPermanentError("slack_action_user_mismatch");
|
|
2115
|
+
}
|
|
2116
|
+
const [interaction, link, post] = await Promise.all([
|
|
2117
|
+
getSlackInteractionById(deps.db, {
|
|
2118
|
+
accountId: entry.accountId,
|
|
2119
|
+
workspaceId: entry.workspaceId,
|
|
2120
|
+
interactionId: handle.interactionId,
|
|
2121
|
+
}),
|
|
2122
|
+
getSlackBotUserLink(deps.db, entry.workspaceId, entry.connectionId, entry.slackUserId),
|
|
2123
|
+
getSlackBotPostOperation(
|
|
2124
|
+
deps.db,
|
|
2125
|
+
entry.workspaceId,
|
|
2126
|
+
entry.connectionId,
|
|
2127
|
+
handle.messageOperationId,
|
|
2128
|
+
),
|
|
2129
|
+
]);
|
|
2130
|
+
if (
|
|
2131
|
+
!interaction ||
|
|
2132
|
+
interaction.connectionId !== entry.connectionId ||
|
|
2133
|
+
interaction.slackTeamId !== entry.slackTeamId ||
|
|
2134
|
+
interaction.sessionId !== handle.sessionId ||
|
|
2135
|
+
interaction.owningSubjectId !== handle.authorizedSubjectId ||
|
|
2136
|
+
interaction.initiatingSlackUserId !== handle.authorizedSlackUserId ||
|
|
2137
|
+
!link ||
|
|
2138
|
+
link.subjectId !== handle.authorizedSubjectId ||
|
|
2139
|
+
!post ||
|
|
2140
|
+
post.status !== "completed" ||
|
|
2141
|
+
post.slackChannelId !== entry.slackChannelId ||
|
|
2142
|
+
post.slackMessageTimestamp !== entry.slackMessageTs
|
|
2143
|
+
) {
|
|
2144
|
+
throw new SlackInteractionPermanentError("slack_action_authority_changed");
|
|
2145
|
+
}
|
|
2146
|
+
const grant = await getWorkspaceGrant(deps.db, link.subjectId, entry.workspaceId, {
|
|
2147
|
+
principalKind: "human_session",
|
|
2148
|
+
});
|
|
2149
|
+
if (
|
|
2150
|
+
!grant ||
|
|
2151
|
+
grant.accountId !== entry.accountId ||
|
|
2152
|
+
!hasPermission(grant.permissions, "sessions:control")
|
|
2153
|
+
) {
|
|
2154
|
+
throw new SlackInteractionPermanentError("sessions_control_denied");
|
|
2155
|
+
}
|
|
2156
|
+
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
2157
|
+
accountId: entry.accountId,
|
|
2158
|
+
workspaceId: entry.workspaceId,
|
|
2159
|
+
connectionId: entry.connectionId,
|
|
2160
|
+
subjectId: grant.subjectId,
|
|
2161
|
+
sessionId: handle.sessionId,
|
|
2162
|
+
});
|
|
2163
|
+
const outcome = await executeSlackAction(deps, grant, interaction, handle);
|
|
2164
|
+
await client.updateMessage({
|
|
2165
|
+
operationId: deterministicUuid(`slack-action-update:${handle.id}:${outcome.result}`),
|
|
2166
|
+
channelId: entry.slackChannelId,
|
|
2167
|
+
timestamp: entry.slackMessageTs,
|
|
2168
|
+
text: outcome.text,
|
|
2169
|
+
blocks: [{ type: "section", text: { type: "mrkdwn", text: outcome.text } }],
|
|
2170
|
+
});
|
|
2171
|
+
await settleSlackInteractionActionHandles(deps.db, {
|
|
2172
|
+
accountId: entry.accountId,
|
|
2173
|
+
workspaceId: entry.workspaceId,
|
|
2174
|
+
handleId: handle.id,
|
|
2175
|
+
result: outcome.result,
|
|
2176
|
+
...(outcome.stale ? { stale: true } : {}),
|
|
2177
|
+
});
|
|
2178
|
+
if (outcome.controlState) {
|
|
2179
|
+
await postSlackControlCard(
|
|
2180
|
+
deps,
|
|
2181
|
+
client,
|
|
2182
|
+
interaction,
|
|
2183
|
+
outcome.controlState,
|
|
2184
|
+
entry.providerEventId,
|
|
2185
|
+
);
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
async function executeSlackAction(
|
|
2190
|
+
deps: ApiRouteDeps,
|
|
2191
|
+
grant: AccessGrant,
|
|
2192
|
+
interaction: SlackInteraction,
|
|
2193
|
+
handle: SlackInteractionActionHandle,
|
|
2194
|
+
): Promise<{
|
|
2195
|
+
result: string;
|
|
2196
|
+
text: string;
|
|
2197
|
+
stale?: boolean;
|
|
2198
|
+
controlState?: "active" | "paused";
|
|
2199
|
+
}> {
|
|
2200
|
+
const mention = slackRequesterMention(interaction);
|
|
2201
|
+
if (handle.actionKind === "shared_result_publish") {
|
|
2202
|
+
return await publishSlackSharedResult(deps, grant, interaction, handle);
|
|
2203
|
+
}
|
|
2204
|
+
if (handle.actionKind === "approval_approve" || handle.actionKind === "approval_reject") {
|
|
2205
|
+
if (!handle.targetId) throw new SlackInteractionPermanentError("slack_action_target_invalid");
|
|
2206
|
+
const decision = handle.actionKind === "approval_approve" ? "approve" : "reject";
|
|
2207
|
+
const accepted = await acceptSessionApprovalDecision(deps.db, {
|
|
2208
|
+
accountId: grant.accountId,
|
|
2209
|
+
workspaceId: grant.workspaceId,
|
|
2210
|
+
sessionId: handle.sessionId,
|
|
2211
|
+
subjectId: grant.subjectId,
|
|
2212
|
+
payload: { approvalId: handle.targetId, decision },
|
|
2213
|
+
clientEventId: `slack-action:${handle.id}`,
|
|
2214
|
+
});
|
|
2215
|
+
if (accepted.action === "conflict") {
|
|
2216
|
+
return {
|
|
2217
|
+
result: "stale",
|
|
2218
|
+
stale: true,
|
|
2219
|
+
text: `${mention}This approval is no longer pending.`,
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
await publishDurableSessionEvents(
|
|
2223
|
+
deps.bus,
|
|
2224
|
+
grant.workspaceId,
|
|
2225
|
+
handle.sessionId,
|
|
2226
|
+
accepted.events,
|
|
2227
|
+
);
|
|
2228
|
+
await deps.workflowClient.signalApprovalDecision({
|
|
2229
|
+
accountId: grant.accountId,
|
|
2230
|
+
workspaceId: grant.workspaceId,
|
|
2231
|
+
sessionId: handle.sessionId,
|
|
2232
|
+
eventId: accepted.event.id,
|
|
2233
|
+
workflowId: `session-${handle.sessionId}`,
|
|
2234
|
+
workflowWakeRevision: accepted.workflowWakeRevision,
|
|
2235
|
+
});
|
|
2236
|
+
return {
|
|
2237
|
+
result: decision === "approve" ? "approved" : "rejected",
|
|
2238
|
+
text: `${mention}${decision === "approve" ? "Approved once" : "Rejected"}. OpenGeni will continue from the durable task state.`,
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
if (handle.actionKind === "human_input_select" || handle.actionKind === "human_input_skip") {
|
|
2242
|
+
if (!handle.targetId) throw new SlackInteractionPermanentError("slack_action_target_invalid");
|
|
2243
|
+
const request = await getSessionHumanInputRequest(
|
|
2244
|
+
deps.db,
|
|
2245
|
+
grant.workspaceId,
|
|
2246
|
+
handle.sessionId,
|
|
2247
|
+
handle.targetId,
|
|
2248
|
+
);
|
|
2249
|
+
if (!request || request.status !== "pending") {
|
|
2250
|
+
return {
|
|
2251
|
+
result: "stale",
|
|
2252
|
+
stale: true,
|
|
2253
|
+
text: `${mention}This question is no longer pending.`,
|
|
2254
|
+
};
|
|
2255
|
+
}
|
|
2256
|
+
let response:
|
|
2257
|
+
| { outcome: "skipped" }
|
|
2258
|
+
| {
|
|
2259
|
+
outcome: "answered";
|
|
2260
|
+
answers: Array<{ questionId: string; values: string[] }>;
|
|
2261
|
+
};
|
|
2262
|
+
if (handle.actionKind === "human_input_skip") {
|
|
2263
|
+
if (!request.allowSkip) {
|
|
2264
|
+
throw new SlackInteractionPermanentError("slack_action_skip_not_allowed");
|
|
2265
|
+
}
|
|
2266
|
+
response = { outcome: "skipped" };
|
|
2267
|
+
} else {
|
|
2268
|
+
let selected: unknown;
|
|
2269
|
+
try {
|
|
2270
|
+
selected = JSON.parse(handle.targetValue ?? "");
|
|
2271
|
+
} catch {
|
|
2272
|
+
throw new SlackInteractionPermanentError("slack_action_target_invalid");
|
|
2273
|
+
}
|
|
2274
|
+
if (
|
|
2275
|
+
!Array.isArray(selected) ||
|
|
2276
|
+
selected.length !== 2 ||
|
|
2277
|
+
typeof selected[0] !== "string" ||
|
|
2278
|
+
typeof selected[1] !== "string"
|
|
2279
|
+
) {
|
|
2280
|
+
throw new SlackInteractionPermanentError("slack_action_target_invalid");
|
|
2281
|
+
}
|
|
2282
|
+
response = {
|
|
2283
|
+
outcome: "answered",
|
|
2284
|
+
answers: [{ questionId: selected[0], values: [selected[1]] }],
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
const accepted = await acceptSessionHumanInputResponse(deps.db, {
|
|
2288
|
+
accountId: grant.accountId,
|
|
2289
|
+
workspaceId: grant.workspaceId,
|
|
2290
|
+
sessionId: handle.sessionId,
|
|
2291
|
+
requestId: handle.targetId,
|
|
2292
|
+
response,
|
|
2293
|
+
respondedBy: grant.subjectId,
|
|
2294
|
+
clientEventId: `slack-action:${handle.id}`,
|
|
2295
|
+
});
|
|
2296
|
+
if (accepted.action !== "accepted") {
|
|
2297
|
+
return {
|
|
2298
|
+
result: "stale",
|
|
2299
|
+
stale: true,
|
|
2300
|
+
text: `${mention}This question is no longer pending.`,
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
await publishDurableSessionEvents(
|
|
2304
|
+
deps.bus,
|
|
2305
|
+
grant.workspaceId,
|
|
2306
|
+
handle.sessionId,
|
|
2307
|
+
accepted.events,
|
|
2308
|
+
);
|
|
2309
|
+
if (accepted.workflowWakeRevision !== null) {
|
|
2310
|
+
await deps.workflowClient.signalApprovalDecision({
|
|
2311
|
+
accountId: grant.accountId,
|
|
2312
|
+
workspaceId: grant.workspaceId,
|
|
2313
|
+
sessionId: handle.sessionId,
|
|
2314
|
+
eventId: accepted.events[0]?.id ?? handle.targetId,
|
|
2315
|
+
workflowId: `session-${handle.sessionId}`,
|
|
2316
|
+
workflowWakeRevision: accepted.workflowWakeRevision,
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
return {
|
|
2320
|
+
result: response.outcome === "skipped" ? "skipped" : "answered",
|
|
2321
|
+
text: `${mention}${response.outcome === "skipped" ? "Skipped the question" : "Answer submitted"}. OpenGeni will continue.`,
|
|
2322
|
+
};
|
|
2323
|
+
}
|
|
2324
|
+
if (handle.actionKind === "session_pause" || handle.actionKind === "session_resume") {
|
|
2325
|
+
const action = handle.actionKind === "session_pause" ? "pause" : "resume";
|
|
2326
|
+
await controlHumanSessionWorkstream(
|
|
2327
|
+
deps,
|
|
2328
|
+
{
|
|
2329
|
+
accountId: grant.accountId,
|
|
2330
|
+
workspaceId: grant.workspaceId,
|
|
2331
|
+
subjectId: grant.subjectId,
|
|
2332
|
+
sessionId: handle.sessionId,
|
|
2333
|
+
},
|
|
2334
|
+
{
|
|
2335
|
+
action,
|
|
2336
|
+
clientEventId: deterministicUuid(`slack-action:${handle.id}`),
|
|
2337
|
+
reason: `${action === "pause" ? "Paused" : "Resumed"} from the originating Slack thread`,
|
|
2338
|
+
},
|
|
2339
|
+
);
|
|
2340
|
+
return {
|
|
2341
|
+
result: action === "pause" ? "paused" : "resumed",
|
|
2342
|
+
text: `${mention}OpenGeni task ${action === "pause" ? "paused" : "resumed"}.`,
|
|
2343
|
+
controlState: action === "pause" ? "paused" : "active",
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
const session = await getSession(deps.db, grant.workspaceId, handle.sessionId);
|
|
2347
|
+
if (!session) throw new SlackInteractionPermanentError("slack_action_session_missing");
|
|
2348
|
+
return {
|
|
2349
|
+
result: "status",
|
|
2350
|
+
text: `${mention}OpenGeni task status: *${session.status.replaceAll("_", " ")}*.`,
|
|
2351
|
+
...(session.status === "cancelled" || session.status === "failed"
|
|
2352
|
+
? {}
|
|
2353
|
+
: { controlState: "active" as const }),
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
async function publishSlackSharedResult(
|
|
2358
|
+
deps: ApiRouteDeps,
|
|
2359
|
+
grant: AccessGrant,
|
|
2360
|
+
interaction: SlackInteraction,
|
|
2361
|
+
handle: SlackInteractionActionHandle,
|
|
2362
|
+
): Promise<{ result: string; text: string; stale?: boolean }> {
|
|
2363
|
+
const mention = slackRequesterMention(interaction);
|
|
2364
|
+
if (!handle.targetId || !interaction.sessionId) {
|
|
2365
|
+
throw new SlackInteractionPermanentError("slack_shared_publication_target_invalid");
|
|
2366
|
+
}
|
|
2367
|
+
const [origin, activePolicy, event] = await Promise.all([
|
|
2368
|
+
getSlackSharedTaskOrigin(deps.db, {
|
|
2369
|
+
accountId: interaction.accountId,
|
|
2370
|
+
workspaceId: interaction.workspaceId,
|
|
2371
|
+
interactionId: interaction.id,
|
|
2372
|
+
}),
|
|
2373
|
+
getActiveSlackTaskPolicy(deps.db, {
|
|
2374
|
+
accountId: interaction.accountId,
|
|
2375
|
+
workspaceId: interaction.workspaceId,
|
|
2376
|
+
}),
|
|
2377
|
+
getSessionEvent(deps.db, interaction.workspaceId, handle.targetId),
|
|
2378
|
+
]);
|
|
2379
|
+
if (
|
|
2380
|
+
!origin ||
|
|
2381
|
+
origin.connectionId !== interaction.connectionId ||
|
|
2382
|
+
origin.sessionId !== interaction.sessionId ||
|
|
2383
|
+
origin.initiatingSlackUserId !== handle.authorizedSlackUserId ||
|
|
2384
|
+
!activePolicy ||
|
|
2385
|
+
activePolicy.revision.id !== origin.policyRevisionId ||
|
|
2386
|
+
activePolicy.revision.policyHash !== origin.policyHash ||
|
|
2387
|
+
activePolicy.head.activationVersion !== origin.policyActivationVersion ||
|
|
2388
|
+
origin.publicationMode === "never" ||
|
|
2389
|
+
!event ||
|
|
2390
|
+
event.sessionId !== interaction.sessionId ||
|
|
2391
|
+
event.type !== "turn.completed"
|
|
2392
|
+
) {
|
|
2393
|
+
return {
|
|
2394
|
+
result: "stale",
|
|
2395
|
+
stale: true,
|
|
2396
|
+
text: `${mention}This publication approval is stale. The result was not posted.`,
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
2400
|
+
accountId: interaction.accountId,
|
|
2401
|
+
workspaceId: interaction.workspaceId,
|
|
2402
|
+
connectionId: interaction.connectionId,
|
|
2403
|
+
subjectId: grant.subjectId,
|
|
2404
|
+
sessionId: interaction.sessionId,
|
|
2405
|
+
});
|
|
2406
|
+
let decision: ReturnType<typeof evaluateSlackTaskPolicy>;
|
|
2407
|
+
try {
|
|
2408
|
+
const facts = await client.slackTaskPolicyFacts(
|
|
2409
|
+
origin.sourceChannelId,
|
|
2410
|
+
origin.initiatingSlackUserId,
|
|
2411
|
+
);
|
|
2412
|
+
decision = evaluateSlackTaskPolicy({
|
|
2413
|
+
policy: activePolicy.revision.policy,
|
|
2414
|
+
conversation: {
|
|
2415
|
+
installationTeamId: origin.slackTeamId,
|
|
2416
|
+
conversationId: facts.conversation.id,
|
|
2417
|
+
contextTeamId: facts.conversation.contextTeamId,
|
|
2418
|
+
connectedTeamIds: facts.conversation.connectedTeamIds,
|
|
2419
|
+
sharedTeamIds: facts.conversation.sharedTeamIds,
|
|
2420
|
+
isShared: facts.conversation.isShared,
|
|
2421
|
+
isExternallyShared: facts.conversation.isExternallyShared,
|
|
2422
|
+
isOrgShared: facts.conversation.isOrgShared,
|
|
2423
|
+
isPendingExternallyShared: facts.conversation.isPendingExternallyShared,
|
|
2424
|
+
isMpim: facts.conversation.isMpim,
|
|
2425
|
+
},
|
|
2426
|
+
initiator: facts.initiator ?? { teamId: null, isGuest: null, isExternal: null },
|
|
2427
|
+
});
|
|
2428
|
+
} catch (error) {
|
|
2429
|
+
if (error instanceof SlackBotProviderError) {
|
|
2430
|
+
return {
|
|
2431
|
+
result: "stale",
|
|
2432
|
+
stale: true,
|
|
2433
|
+
text: `${mention}The shared conversation is no longer authorized. The result was not posted.`,
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
throw error;
|
|
2437
|
+
}
|
|
2438
|
+
if (decision.disposition !== "private_handoff" || decision.publication === "never") {
|
|
2439
|
+
return {
|
|
2440
|
+
result: "stale",
|
|
2441
|
+
stale: true,
|
|
2442
|
+
text: `${mention}The shared conversation is no longer authorized. The result was not posted.`,
|
|
2443
|
+
};
|
|
2444
|
+
}
|
|
2445
|
+
const output = safePayloadText(event.payload, "output").trim();
|
|
2446
|
+
if (!output) {
|
|
2447
|
+
return {
|
|
2448
|
+
result: "stale",
|
|
2449
|
+
stale: true,
|
|
2450
|
+
text: `${mention}This result is no longer available for publication.`,
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
await client.postMessage({
|
|
2454
|
+
operationId: deterministicUuid(
|
|
2455
|
+
`slack-shared-result:${origin.interactionId}:${event.id}:${origin.policyActivationVersion}`,
|
|
2456
|
+
),
|
|
2457
|
+
channelId: origin.sourceChannelId,
|
|
2458
|
+
threadTimestamp: origin.sourceThreadTs,
|
|
2459
|
+
text: `<@${origin.initiatingSlackUserId}> ${boundedOutput(output)}`,
|
|
2460
|
+
});
|
|
2461
|
+
return {
|
|
2462
|
+
result: "published",
|
|
2463
|
+
text: `${mention}Published the approved result to the original shared conversation.`,
|
|
2464
|
+
};
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
function slackRequesterMention(interaction: Pick<SlackInteraction, "initiatingSlackUserId">) {
|
|
2468
|
+
return interaction.initiatingSlackUserId &&
|
|
2469
|
+
/^[UW][A-Z0-9]{1,63}$/.test(interaction.initiatingSlackUserId)
|
|
2470
|
+
? `<@${interaction.initiatingSlackUserId}> `
|
|
2471
|
+
: "";
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
async function validatedSlackRequester(
|
|
2475
|
+
deps: ApiRouteDeps,
|
|
2476
|
+
interaction: Pick<
|
|
2477
|
+
SlackInteraction,
|
|
2478
|
+
"accountId" | "workspaceId" | "connectionId" | "initiatingSlackUserId" | "owningSubjectId"
|
|
2479
|
+
>,
|
|
2480
|
+
) {
|
|
2481
|
+
if (!interaction.initiatingSlackUserId) {
|
|
2482
|
+
return { authorized: false, canSchedule: false, mention: "" };
|
|
2483
|
+
}
|
|
2484
|
+
const [link, grant] = await Promise.all([
|
|
2485
|
+
getSlackBotUserLink(
|
|
2486
|
+
deps.db,
|
|
2487
|
+
interaction.workspaceId,
|
|
2488
|
+
interaction.connectionId,
|
|
2489
|
+
interaction.initiatingSlackUserId,
|
|
2490
|
+
),
|
|
2491
|
+
getWorkspaceGrant(deps.db, interaction.owningSubjectId, interaction.workspaceId, {
|
|
2492
|
+
principalKind: "human_session",
|
|
2493
|
+
}),
|
|
2494
|
+
]);
|
|
2495
|
+
const authorized = link?.subjectId === interaction.owningSubjectId;
|
|
2496
|
+
const canSchedule =
|
|
2497
|
+
authorized &&
|
|
2498
|
+
grant?.accountId === interaction.accountId &&
|
|
2499
|
+
hasPermission(grant.permissions, "sessions:read") &&
|
|
2500
|
+
hasPermission(grant.permissions, "sessions:control") &&
|
|
2501
|
+
hasPermission(grant.permissions, "scheduled_tasks:manage");
|
|
2502
|
+
return {
|
|
2503
|
+
authorized,
|
|
2504
|
+
canSchedule,
|
|
2505
|
+
mention: authorized ? slackRequesterMention(interaction) : "",
|
|
2506
|
+
};
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
async function controlActionBlocks(
|
|
2510
|
+
deps: ApiRouteDeps,
|
|
2511
|
+
interaction: SlackInteraction,
|
|
2512
|
+
input: {
|
|
2513
|
+
messageOperationId: string;
|
|
2514
|
+
sessionEventSequence: number;
|
|
2515
|
+
state: "active" | "paused";
|
|
2516
|
+
},
|
|
2517
|
+
): Promise<SlackMessageBlock[]> {
|
|
2518
|
+
if (!interaction.sessionId || !interaction.initiatingSlackUserId) return [];
|
|
2519
|
+
const kinds: SlackInteractionActionKind[] = [
|
|
2520
|
+
"session_status",
|
|
2521
|
+
input.state === "paused" ? "session_resume" : "session_pause",
|
|
2522
|
+
];
|
|
2523
|
+
const handles = await reserveSlackInteractionActionHandles(deps.db, {
|
|
2524
|
+
interaction,
|
|
2525
|
+
sessionEventSequence: input.sessionEventSequence,
|
|
2526
|
+
messageOperationId: input.messageOperationId,
|
|
2527
|
+
expiresAt: new Date(Date.now() + SLACK_ACTION_TTL_MS),
|
|
2528
|
+
actions: kinds.map((actionKind) => ({
|
|
2529
|
+
actionKind,
|
|
2530
|
+
actionKey: `${input.messageOperationId}:${actionKind}`,
|
|
2531
|
+
})),
|
|
2532
|
+
});
|
|
2533
|
+
const pending = handles.filter((handle) => handle.status === "pending");
|
|
2534
|
+
if (pending.length === 0) return [];
|
|
2535
|
+
return [
|
|
2536
|
+
{
|
|
2537
|
+
type: "actions",
|
|
2538
|
+
block_id: `opengeni_controls_${input.messageOperationId}`,
|
|
2539
|
+
elements: pending.map((handle) => ({
|
|
2540
|
+
type: "button" as const,
|
|
2541
|
+
action_id: SLACK_ACTION_ID_BY_KIND[handle.actionKind],
|
|
2542
|
+
value: handle.id,
|
|
2543
|
+
text: {
|
|
2544
|
+
type: "plain_text" as const,
|
|
2545
|
+
text:
|
|
2546
|
+
handle.actionKind === "session_status"
|
|
2547
|
+
? "Status"
|
|
2548
|
+
: handle.actionKind === "session_pause"
|
|
2549
|
+
? "Stop"
|
|
2550
|
+
: "Resume",
|
|
2551
|
+
emoji: true,
|
|
2552
|
+
},
|
|
2553
|
+
...(handle.actionKind === "session_pause" ? { style: "danger" as const } : {}),
|
|
2554
|
+
})),
|
|
2555
|
+
},
|
|
2556
|
+
];
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
async function postSlackControlCard(
|
|
2560
|
+
deps: ApiRouteDeps,
|
|
2561
|
+
client: OpenGeniSlackBotClient,
|
|
2562
|
+
interaction: SlackInteraction,
|
|
2563
|
+
state: "active" | "paused",
|
|
2564
|
+
providerEventId: string,
|
|
2565
|
+
) {
|
|
2566
|
+
if (!interaction.sessionId) return;
|
|
2567
|
+
const operationId = deterministicUuid(
|
|
2568
|
+
`slack-control:${interaction.id}:${providerEventId}:${state}`,
|
|
2569
|
+
);
|
|
2570
|
+
const text = `${slackRequesterMention(interaction)}OpenGeni task controls.`;
|
|
2571
|
+
const controls = await controlActionBlocks(deps, interaction, {
|
|
2572
|
+
messageOperationId: operationId,
|
|
2573
|
+
sessionEventSequence: 0,
|
|
2574
|
+
state,
|
|
2575
|
+
});
|
|
2576
|
+
await client.postMessage({
|
|
2577
|
+
operationId,
|
|
2578
|
+
channelId: interaction.slackChannelId,
|
|
2579
|
+
threadTimestamp: interaction.slackThreadTs,
|
|
2580
|
+
text,
|
|
2581
|
+
blocks: [{ type: "section", text: { type: "mrkdwn", text } }, ...controls],
|
|
2582
|
+
});
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
type SlackApprovalSummary = { id: string; name: string };
|
|
2586
|
+
|
|
2587
|
+
function slackApprovalSummaries(payload: unknown): SlackApprovalSummary[] {
|
|
2588
|
+
const approvals = record(payload)?.approvals;
|
|
2589
|
+
if (!Array.isArray(approvals)) return [];
|
|
2590
|
+
return approvals
|
|
2591
|
+
.map((approval) => {
|
|
2592
|
+
const value = record(approval);
|
|
2593
|
+
const rawItem = record(value?.rawItem);
|
|
2594
|
+
const id = approvalIdentifier(approval);
|
|
2595
|
+
const name = boundedString(value?.name ?? value?.toolName ?? rawItem?.name, 256);
|
|
2596
|
+
return id ? { id, name: name ?? "OpenGeni action" } : null;
|
|
2597
|
+
})
|
|
2598
|
+
.filter((approval): approval is SlackApprovalSummary => approval !== null)
|
|
2599
|
+
.slice(0, MAX_SLACK_APPROVALS_PER_CARD);
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
async function slackApprovalCard(
|
|
2603
|
+
deps: ApiRouteDeps,
|
|
2604
|
+
interaction: SlackInteraction,
|
|
2605
|
+
event: SessionEvent,
|
|
2606
|
+
mention: string,
|
|
2607
|
+
requesterAuthorized: boolean,
|
|
2608
|
+
): Promise<{ text: string; blocks?: SlackMessageBlock[]; operationId: string }> {
|
|
2609
|
+
const operationId = deterministicUuid(
|
|
2610
|
+
`slack-delivery:${interaction.id}:${event.sequence}:approval`,
|
|
2611
|
+
);
|
|
2612
|
+
const approvals = slackApprovalSummaries(event.payload);
|
|
2613
|
+
const fallback = approvals.length
|
|
2614
|
+
? `${mention}OpenGeni needs approval for ${approvals.map((approval) => approval.name).join(", ")}.`
|
|
2615
|
+
: `${mention}OpenGeni needs approval. Open the task to review it.`;
|
|
2616
|
+
if (
|
|
2617
|
+
!requesterAuthorized ||
|
|
2618
|
+
!interaction.sessionId ||
|
|
2619
|
+
!interaction.initiatingSlackUserId ||
|
|
2620
|
+
approvals.length === 0
|
|
2621
|
+
) {
|
|
2622
|
+
return { text: fallback, operationId };
|
|
2623
|
+
}
|
|
2624
|
+
const specs = approvals
|
|
2625
|
+
.flatMap((approval) => [
|
|
2626
|
+
{
|
|
2627
|
+
actionKind: "approval_approve" as const,
|
|
2628
|
+
actionKey: `approval:${approval.id}:approve`,
|
|
2629
|
+
targetId: approval.id,
|
|
2630
|
+
},
|
|
2631
|
+
{
|
|
2632
|
+
actionKind: "approval_reject" as const,
|
|
2633
|
+
actionKey: `approval:${approval.id}:reject`,
|
|
2634
|
+
targetId: approval.id,
|
|
2635
|
+
},
|
|
2636
|
+
])
|
|
2637
|
+
.slice(0, MAX_SLACK_ACTIONS_PER_CARD);
|
|
2638
|
+
const handles = await reserveSlackInteractionActionHandles(deps.db, {
|
|
2639
|
+
interaction,
|
|
2640
|
+
sessionEventSequence: event.sequence,
|
|
2641
|
+
messageOperationId: operationId,
|
|
2642
|
+
expiresAt: new Date(Date.now() + SLACK_ACTION_TTL_MS),
|
|
2643
|
+
actions: specs,
|
|
2644
|
+
});
|
|
2645
|
+
const byKey = new Map(handles.map((handle) => [handle.actionKey, handle]));
|
|
2646
|
+
const blocks: SlackMessageBlock[] = [];
|
|
2647
|
+
for (const approval of approvals) {
|
|
2648
|
+
const approve = byKey.get(`approval:${approval.id}:approve`);
|
|
2649
|
+
const reject = byKey.get(`approval:${approval.id}:reject`);
|
|
2650
|
+
blocks.push({
|
|
2651
|
+
type: "section",
|
|
2652
|
+
text: { type: "mrkdwn", text: `*Approval required*\n${approval.name}` },
|
|
2653
|
+
});
|
|
2654
|
+
if (approve?.status === "pending" && reject?.status === "pending") {
|
|
2655
|
+
blocks.push({
|
|
2656
|
+
type: "actions",
|
|
2657
|
+
block_id: `opengeni_approval_${event.sequence}_${blocks.length}`,
|
|
2658
|
+
elements: [
|
|
2659
|
+
{
|
|
2660
|
+
type: "button",
|
|
2661
|
+
action_id: SLACK_ACTION_ID_BY_KIND.approval_approve,
|
|
2662
|
+
value: approve.id,
|
|
2663
|
+
text: { type: "plain_text", text: "Approve once", emoji: true },
|
|
2664
|
+
style: "primary",
|
|
2665
|
+
},
|
|
2666
|
+
{
|
|
2667
|
+
type: "button",
|
|
2668
|
+
action_id: SLACK_ACTION_ID_BY_KIND.approval_reject,
|
|
2669
|
+
value: reject.id,
|
|
2670
|
+
text: { type: "plain_text", text: "Reject", emoji: true },
|
|
2671
|
+
style: "danger",
|
|
2672
|
+
},
|
|
2673
|
+
],
|
|
2674
|
+
});
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
return {
|
|
2678
|
+
text: fallback,
|
|
2679
|
+
blocks: [
|
|
2680
|
+
{
|
|
2681
|
+
type: "section",
|
|
2682
|
+
text: { type: "mrkdwn", text: `${mention}OpenGeni needs your approval.` },
|
|
2683
|
+
},
|
|
2684
|
+
...blocks,
|
|
2685
|
+
],
|
|
2686
|
+
operationId,
|
|
2687
|
+
};
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
async function slackSharedResultPublicationBlocks(
|
|
2691
|
+
deps: ApiRouteDeps,
|
|
2692
|
+
interaction: SlackInteraction,
|
|
2693
|
+
event: SessionEvent,
|
|
2694
|
+
requesterAuthorized: boolean,
|
|
2695
|
+
messageOperationId: string,
|
|
2696
|
+
): Promise<SlackMessageBlock[]> {
|
|
2697
|
+
if (!safePayloadText(event.payload, "output").trim()) return [];
|
|
2698
|
+
if (!requesterAuthorized || !interaction.sessionId || !interaction.initiatingSlackUserId) {
|
|
2699
|
+
return [];
|
|
2700
|
+
}
|
|
2701
|
+
const origin = await getSlackSharedTaskOrigin(deps.db, {
|
|
2702
|
+
accountId: interaction.accountId,
|
|
2703
|
+
workspaceId: interaction.workspaceId,
|
|
2704
|
+
interactionId: interaction.id,
|
|
2705
|
+
});
|
|
2706
|
+
if (!origin || origin.publicationMode === "never") return [];
|
|
2707
|
+
const [handle] = await reserveSlackInteractionActionHandles(deps.db, {
|
|
2708
|
+
interaction,
|
|
2709
|
+
sessionEventSequence: event.sequence,
|
|
2710
|
+
messageOperationId,
|
|
2711
|
+
expiresAt: new Date(Date.now() + SLACK_ACTION_TTL_MS),
|
|
2712
|
+
actions: [
|
|
2713
|
+
{
|
|
2714
|
+
actionKind: "shared_result_publish",
|
|
2715
|
+
actionKey: `shared-result:${event.id}:publish`,
|
|
2716
|
+
targetId: event.id,
|
|
2717
|
+
},
|
|
2718
|
+
],
|
|
2719
|
+
});
|
|
2720
|
+
if (!handle || handle.status !== "pending") return [];
|
|
2721
|
+
return [
|
|
2722
|
+
{
|
|
2723
|
+
type: "actions",
|
|
2724
|
+
block_id: `opengeni_shared_result_${event.sequence}`,
|
|
2725
|
+
elements: [
|
|
2726
|
+
{
|
|
2727
|
+
type: "button",
|
|
2728
|
+
action_id: SLACK_ACTION_ID_BY_KIND.shared_result_publish,
|
|
2729
|
+
value: handle.id,
|
|
2730
|
+
text: {
|
|
2731
|
+
type: "plain_text",
|
|
2732
|
+
text:
|
|
2733
|
+
origin.publicationMode === "approval_required"
|
|
2734
|
+
? "Approve & publish"
|
|
2735
|
+
: "Publish result",
|
|
2736
|
+
emoji: true,
|
|
2737
|
+
},
|
|
2738
|
+
style: "primary",
|
|
2739
|
+
},
|
|
2740
|
+
],
|
|
2741
|
+
},
|
|
2742
|
+
];
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
async function slackHumanInputCard(
|
|
2746
|
+
deps: ApiRouteDeps,
|
|
2747
|
+
interaction: SlackInteraction,
|
|
2748
|
+
event: SessionEvent,
|
|
2749
|
+
mention: string,
|
|
2750
|
+
requesterAuthorized: boolean,
|
|
2751
|
+
) {
|
|
2752
|
+
if (!interaction.sessionId) return null;
|
|
2753
|
+
const requestId = boundedString(record(record(event.payload)?.request)?.id, 64);
|
|
2754
|
+
if (!requestId) return null;
|
|
2755
|
+
const request = await getSessionHumanInputRequest(
|
|
2756
|
+
deps.db,
|
|
2757
|
+
interaction.workspaceId,
|
|
2758
|
+
interaction.sessionId,
|
|
2759
|
+
requestId,
|
|
2760
|
+
);
|
|
2761
|
+
if (!request || request.status !== "pending") return null;
|
|
2762
|
+
const operationId = deterministicUuid(
|
|
2763
|
+
`slack-delivery:${interaction.id}:${event.sequence}:human-input`,
|
|
2764
|
+
);
|
|
2765
|
+
const text = `${mention}OpenGeni needs your input:\n${formatQuestions(request.questions)}\nReply in this thread${request.allowSkip ? " or choose Skip" : ""}.`;
|
|
2766
|
+
if (!requesterAuthorized || !interaction.initiatingSlackUserId) return { text, operationId };
|
|
2767
|
+
const question = request.questions.length === 1 ? request.questions[0] : null;
|
|
2768
|
+
const canUseButtons =
|
|
2769
|
+
question?.kind === "single_select" &&
|
|
2770
|
+
question.options.length > 0 &&
|
|
2771
|
+
question.options.length <= 5;
|
|
2772
|
+
const actions: Array<{
|
|
2773
|
+
actionKind: SlackInteractionActionKind;
|
|
2774
|
+
actionKey: string;
|
|
2775
|
+
targetId: string;
|
|
2776
|
+
targetValue?: string;
|
|
2777
|
+
}> = [];
|
|
2778
|
+
if (canUseButtons && question) {
|
|
2779
|
+
for (const option of question.options) {
|
|
2780
|
+
actions.push({
|
|
2781
|
+
actionKind: "human_input_select",
|
|
2782
|
+
actionKey: `human:${request.id}:${question.id}:${option.id}`,
|
|
2783
|
+
targetId: request.id,
|
|
2784
|
+
targetValue: JSON.stringify([question.id, option.id]),
|
|
2785
|
+
});
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
if (request.allowSkip) {
|
|
2789
|
+
actions.push({
|
|
2790
|
+
actionKind: "human_input_skip",
|
|
2791
|
+
actionKey: `human:${request.id}:skip`,
|
|
2792
|
+
targetId: request.id,
|
|
2793
|
+
});
|
|
2794
|
+
}
|
|
2795
|
+
if (actions.length === 0) return { text, operationId };
|
|
2796
|
+
const handles = await reserveSlackInteractionActionHandles(deps.db, {
|
|
2797
|
+
interaction,
|
|
2798
|
+
sessionEventSequence: event.sequence,
|
|
2799
|
+
messageOperationId: operationId,
|
|
2800
|
+
expiresAt: request.expiresAt
|
|
2801
|
+
? new Date(Math.min(new Date(request.expiresAt).getTime(), Date.now() + SLACK_ACTION_TTL_MS))
|
|
2802
|
+
: new Date(Date.now() + SLACK_ACTION_TTL_MS),
|
|
2803
|
+
actions,
|
|
2804
|
+
});
|
|
2805
|
+
const pending = handles.filter((handle) => handle.status === "pending");
|
|
2806
|
+
const blocks: SlackMessageBlock[] = [{ type: "section", text: { type: "mrkdwn", text } }];
|
|
2807
|
+
if (pending.length > 0) {
|
|
2808
|
+
blocks.push({
|
|
2809
|
+
type: "actions",
|
|
2810
|
+
block_id: `opengeni_human_${event.sequence}`,
|
|
2811
|
+
elements: pending.map((handle) => {
|
|
2812
|
+
const option = question?.options.find(
|
|
2813
|
+
(candidate) => handle.targetValue === JSON.stringify([question.id, candidate.id]),
|
|
2814
|
+
);
|
|
2815
|
+
return {
|
|
2816
|
+
type: "button" as const,
|
|
2817
|
+
action_id: SLACK_ACTION_ID_BY_KIND[handle.actionKind],
|
|
2818
|
+
value: handle.id,
|
|
2819
|
+
text: {
|
|
2820
|
+
type: "plain_text" as const,
|
|
2821
|
+
text: handle.actionKind === "human_input_skip" ? "Skip" : (option?.label ?? "Choose"),
|
|
2822
|
+
emoji: true,
|
|
2823
|
+
},
|
|
2824
|
+
};
|
|
2825
|
+
}),
|
|
2826
|
+
});
|
|
2827
|
+
}
|
|
2828
|
+
return { text, blocks, operationId };
|
|
2829
|
+
}
|
|
2830
|
+
|
|
1536
2831
|
async function deliverSlackSessionEvents(
|
|
1537
2832
|
deps: ApiRouteDeps,
|
|
1538
2833
|
interaction: SlackInteraction,
|
|
@@ -1556,9 +2851,10 @@ async function deliverSlackSessionEvents(
|
|
|
1556
2851
|
accountId: interaction.accountId,
|
|
1557
2852
|
workspaceId: interaction.workspaceId,
|
|
1558
2853
|
connectionId: interaction.connectionId,
|
|
1559
|
-
subjectId:
|
|
2854
|
+
subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
|
|
1560
2855
|
sessionId: interaction.sessionId,
|
|
1561
2856
|
});
|
|
2857
|
+
const requester = await validatedSlackRequester(deps, interaction);
|
|
1562
2858
|
let lastSequence = interaction.lastDeliveredSessionEventSequence;
|
|
1563
2859
|
let terminal: Exclude<SlackInteraction["terminalDeliveryState"], "open"> | null = null;
|
|
1564
2860
|
let latestAssistantText = "";
|
|
@@ -1599,10 +2895,17 @@ async function deliverSlackSessionEvents(
|
|
|
1599
2895
|
.find(Boolean) ||
|
|
1600
2896
|
"";
|
|
1601
2897
|
if (!terminalText) continue;
|
|
2898
|
+
let matchedTerminalSuffix = false;
|
|
1602
2899
|
for (const candidate of candidates) {
|
|
1603
2900
|
const assistantText = safePayloadText(candidate.payload, "text").trim();
|
|
1604
|
-
if (assistantText
|
|
2901
|
+
if (slackDeliveryTextsCoalesce(assistantText, terminalText)) {
|
|
1605
2902
|
terminalAssistantSequences.add(candidate.sequence);
|
|
2903
|
+
matchedTerminalSuffix = true;
|
|
2904
|
+
} else if (matchedTerminalSuffix) {
|
|
2905
|
+
// Coalesce only the contiguous terminal-shaped suffix. An earlier
|
|
2906
|
+
// distinct progress update in the same turn remains independently
|
|
2907
|
+
// deliverable even when a later SDK projection repeats the result.
|
|
2908
|
+
break;
|
|
1606
2909
|
}
|
|
1607
2910
|
}
|
|
1608
2911
|
}
|
|
@@ -1643,35 +2946,65 @@ async function deliverSlackSessionEvents(
|
|
|
1643
2946
|
);
|
|
1644
2947
|
}
|
|
1645
2948
|
}
|
|
2949
|
+
} else if (event.type === "session.requiresAction") {
|
|
2950
|
+
const card = await slackApprovalCard(
|
|
2951
|
+
deps,
|
|
2952
|
+
interaction,
|
|
2953
|
+
event,
|
|
2954
|
+
requester.mention,
|
|
2955
|
+
requester.authorized,
|
|
2956
|
+
);
|
|
2957
|
+
await postDelivery(
|
|
2958
|
+
client,
|
|
2959
|
+
interaction,
|
|
2960
|
+
event,
|
|
2961
|
+
card.text,
|
|
2962
|
+
"approval",
|
|
2963
|
+
card.operationId,
|
|
2964
|
+
card.blocks,
|
|
2965
|
+
);
|
|
1646
2966
|
} else if (event.type === "session.humanInput.requested") {
|
|
1647
|
-
const
|
|
1648
|
-
deps
|
|
1649
|
-
interaction
|
|
1650
|
-
|
|
1651
|
-
|
|
2967
|
+
const card = await slackHumanInputCard(
|
|
2968
|
+
deps,
|
|
2969
|
+
interaction,
|
|
2970
|
+
event,
|
|
2971
|
+
requester.mention,
|
|
2972
|
+
requester.authorized,
|
|
1652
2973
|
);
|
|
1653
|
-
|
|
1654
|
-
if (request) {
|
|
2974
|
+
if (card) {
|
|
1655
2975
|
await postDelivery(
|
|
1656
2976
|
client,
|
|
1657
2977
|
interaction,
|
|
1658
2978
|
event,
|
|
1659
|
-
|
|
2979
|
+
card.text,
|
|
1660
2980
|
"human-input",
|
|
2981
|
+
card.operationId,
|
|
2982
|
+
card.blocks,
|
|
1661
2983
|
);
|
|
1662
2984
|
}
|
|
1663
2985
|
} else if (event.type === "turn.completed") {
|
|
1664
|
-
const
|
|
2986
|
+
const payloadOutput = safePayloadText(event.payload, "output");
|
|
2987
|
+
const hasPublishableOutput = payloadOutput.trim().length > 0;
|
|
2988
|
+
const output = hasPublishableOutput ? payloadOutput : latestAssistantText;
|
|
1665
2989
|
const normalizedOutput = output.trim();
|
|
2990
|
+
const recurringLink = requester.canSchedule
|
|
2991
|
+
? `\n\n${makeRecurringText(deps, interaction.workspaceId, interaction.sessionId)}`
|
|
2992
|
+
: "";
|
|
1666
2993
|
const existingProgress = progressEvidence.find(
|
|
1667
2994
|
(delivery) =>
|
|
1668
|
-
boundedOutput(delivery.text).trim()
|
|
1669
|
-
(event
|
|
1670
|
-
? event.turnId === delivery.turnId
|
|
1671
|
-
: delivery.sessionEventSequence === interaction.lastDeliveredSessionEventSequence ||
|
|
1672
|
-
terminalAssistantSequences.has(delivery.sessionEventSequence)),
|
|
2995
|
+
slackDeliveryTextsCoalesce(boundedOutput(delivery.text).trim(), normalizedOutput) &&
|
|
2996
|
+
slackTerminalProgressSameTurn(event, delivery, interaction, terminalAssistantSequences),
|
|
1673
2997
|
);
|
|
1674
2998
|
if (existingProgress && normalizedOutput) {
|
|
2999
|
+
const publicationBlocks = hasPublishableOutput
|
|
3000
|
+
? await slackSharedResultPublicationBlocks(
|
|
3001
|
+
deps,
|
|
3002
|
+
interaction,
|
|
3003
|
+
event,
|
|
3004
|
+
requester.authorized,
|
|
3005
|
+
existingProgress.operationId,
|
|
3006
|
+
)
|
|
3007
|
+
: [];
|
|
1675
3008
|
// The assistant text may already have been accepted by Slack before a
|
|
1676
3009
|
// replica observed turn.completed. Reconcile the same provider
|
|
1677
3010
|
// operation id (including response-loss retries) instead of inventing
|
|
@@ -1684,13 +3017,68 @@ async function deliverSlackSessionEvents(
|
|
|
1684
3017
|
"progress",
|
|
1685
3018
|
existingProgress.operationId,
|
|
1686
3019
|
);
|
|
3020
|
+
const posted = await getSlackBotPostOperation(
|
|
3021
|
+
deps.db,
|
|
3022
|
+
interaction.workspaceId,
|
|
3023
|
+
interaction.connectionId,
|
|
3024
|
+
existingProgress.operationId,
|
|
3025
|
+
);
|
|
3026
|
+
if (posted?.slackChannelId && posted.slackMessageTimestamp) {
|
|
3027
|
+
const text = boundedOutputWithSuffix(
|
|
3028
|
+
`${requester.mention}${existingProgress.text}`,
|
|
3029
|
+
recurringLink,
|
|
3030
|
+
);
|
|
3031
|
+
await client.updateMessage({
|
|
3032
|
+
operationId: deterministicUuid(
|
|
3033
|
+
`slack-terminal-update:${interaction.id}:${event.sequence}`,
|
|
3034
|
+
),
|
|
3035
|
+
channelId: posted.slackChannelId,
|
|
3036
|
+
timestamp: posted.slackMessageTimestamp,
|
|
3037
|
+
text,
|
|
3038
|
+
...(publicationBlocks.length > 0
|
|
3039
|
+
? {
|
|
3040
|
+
blocks: [
|
|
3041
|
+
{
|
|
3042
|
+
type: "section",
|
|
3043
|
+
text: {
|
|
3044
|
+
type: "mrkdwn",
|
|
3045
|
+
text,
|
|
3046
|
+
},
|
|
3047
|
+
},
|
|
3048
|
+
...publicationBlocks,
|
|
3049
|
+
],
|
|
3050
|
+
}
|
|
3051
|
+
: {}),
|
|
3052
|
+
});
|
|
3053
|
+
}
|
|
1687
3054
|
} else {
|
|
3055
|
+
const operationId = deterministicUuid(
|
|
3056
|
+
`slack-delivery:${interaction.id}:${event.sequence}:final`,
|
|
3057
|
+
);
|
|
3058
|
+
const finalSuffix = `\n\nReply in this thread to continue.${recurringLink}`;
|
|
3059
|
+
const text = boundedOutputWithSuffix(
|
|
3060
|
+
`${requester.mention}${output || "OpenGeni finished this task."}`,
|
|
3061
|
+
finalSuffix,
|
|
3062
|
+
);
|
|
3063
|
+
const publicationBlocks = hasPublishableOutput
|
|
3064
|
+
? await slackSharedResultPublicationBlocks(
|
|
3065
|
+
deps,
|
|
3066
|
+
interaction,
|
|
3067
|
+
event,
|
|
3068
|
+
requester.authorized,
|
|
3069
|
+
operationId,
|
|
3070
|
+
)
|
|
3071
|
+
: [];
|
|
1688
3072
|
await postDelivery(
|
|
1689
3073
|
client,
|
|
1690
3074
|
interaction,
|
|
1691
3075
|
event,
|
|
1692
|
-
|
|
3076
|
+
text,
|
|
1693
3077
|
"final",
|
|
3078
|
+
operationId,
|
|
3079
|
+
publicationBlocks.length > 0
|
|
3080
|
+
? [{ type: "section", text: { type: "mrkdwn", text } }, ...publicationBlocks]
|
|
3081
|
+
: undefined,
|
|
1694
3082
|
);
|
|
1695
3083
|
}
|
|
1696
3084
|
terminal = "completed";
|
|
@@ -1699,12 +3087,18 @@ async function deliverSlackSessionEvents(
|
|
|
1699
3087
|
client,
|
|
1700
3088
|
interaction,
|
|
1701
3089
|
event,
|
|
1702
|
-
|
|
3090
|
+
`${requester.mention}OpenGeni could not complete this task. Reply in this thread to retry or ask for details.`,
|
|
1703
3091
|
"failed",
|
|
1704
3092
|
);
|
|
1705
3093
|
terminal = "failed";
|
|
1706
3094
|
} else if (event.type === "turn.cancelled") {
|
|
1707
|
-
await postDelivery(
|
|
3095
|
+
await postDelivery(
|
|
3096
|
+
client,
|
|
3097
|
+
interaction,
|
|
3098
|
+
event,
|
|
3099
|
+
`${requester.mention}OpenGeni stopped this task.`,
|
|
3100
|
+
"cancelled",
|
|
3101
|
+
);
|
|
1708
3102
|
terminal = "cancelled";
|
|
1709
3103
|
}
|
|
1710
3104
|
}
|
|
@@ -1728,6 +3122,34 @@ async function deliverSlackSessionEvents(
|
|
|
1728
3122
|
}
|
|
1729
3123
|
}
|
|
1730
3124
|
|
|
3125
|
+
export function slackDeliveryTextsCoalesce(left: string, right: string): boolean {
|
|
3126
|
+
const normalizedLeft = left.trim();
|
|
3127
|
+
const normalizedRight = right.trim();
|
|
3128
|
+
if (!normalizedLeft || !normalizedRight) return false;
|
|
3129
|
+
if (normalizedLeft === normalizedRight) return true;
|
|
3130
|
+
const [shorter, longer] =
|
|
3131
|
+
normalizedLeft.length < normalizedRight.length
|
|
3132
|
+
? [normalizedLeft, normalizedRight]
|
|
3133
|
+
: [normalizedRight, normalizedLeft];
|
|
3134
|
+
if (!longer.startsWith(shorter)) return false;
|
|
3135
|
+
const boundary = longer.slice(shorter.length, shorter.length + 1);
|
|
3136
|
+
return boundary.length === 0 || /[\s.,;:!?()[\]{}\-–—]/u.test(boundary);
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
function slackTerminalProgressSameTurn(
|
|
3140
|
+
event: SessionEvent,
|
|
3141
|
+
delivery: { turnId: string | null; sessionEventSequence: number },
|
|
3142
|
+
interaction: SlackInteraction,
|
|
3143
|
+
samePageTerminalSequences: ReadonlySet<number>,
|
|
3144
|
+
): boolean {
|
|
3145
|
+
if (event.turnId && delivery.turnId) return event.turnId === delivery.turnId;
|
|
3146
|
+
if (event.turnId || delivery.turnId) return false;
|
|
3147
|
+
return (
|
|
3148
|
+
delivery.sessionEventSequence === interaction.lastDeliveredSessionEventSequence ||
|
|
3149
|
+
samePageTerminalSequences.has(delivery.sessionEventSequence)
|
|
3150
|
+
);
|
|
3151
|
+
}
|
|
3152
|
+
|
|
1731
3153
|
async function postDelivery(
|
|
1732
3154
|
client: Awaited<ReturnType<typeof createOpenGeniSlackBotInteractionClient>>,
|
|
1733
3155
|
interaction: SlackInteraction,
|
|
@@ -1735,12 +3157,14 @@ async function postDelivery(
|
|
|
1735
3157
|
text: string,
|
|
1736
3158
|
kind: string,
|
|
1737
3159
|
operationId = deterministicUuid(`slack-delivery:${interaction.id}:${event.sequence}:${kind}`),
|
|
3160
|
+
blocks?: SlackMessageBlock[],
|
|
1738
3161
|
) {
|
|
1739
3162
|
await client.postMessage({
|
|
1740
3163
|
operationId,
|
|
1741
3164
|
channelId: interaction.slackChannelId,
|
|
1742
3165
|
threadTimestamp: interaction.slackThreadTs,
|
|
1743
3166
|
text: boundedOutput(text),
|
|
3167
|
+
...(blocks ? { blocks } : {}),
|
|
1744
3168
|
});
|
|
1745
3169
|
}
|
|
1746
3170
|
|
|
@@ -1807,6 +3231,67 @@ function normalizedFormInteraction(
|
|
|
1807
3231
|
slackThreadTs: null,
|
|
1808
3232
|
triggerKind,
|
|
1809
3233
|
text,
|
|
3234
|
+
hasFiles: false,
|
|
3235
|
+
};
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
const SLACK_BLOCK_ACTION_IDS = new Set([
|
|
3239
|
+
"opengeni.approval.approve",
|
|
3240
|
+
"opengeni.approval.reject",
|
|
3241
|
+
"opengeni.human_input.select",
|
|
3242
|
+
"opengeni.human_input.skip",
|
|
3243
|
+
"opengeni.session.status",
|
|
3244
|
+
"opengeni.session.pause",
|
|
3245
|
+
"opengeni.session.resume",
|
|
3246
|
+
"opengeni.shared_result.publish",
|
|
3247
|
+
]);
|
|
3248
|
+
|
|
3249
|
+
export function normalizedBlockActionInteraction(
|
|
3250
|
+
payload: Record<string, unknown>,
|
|
3251
|
+
): NormalizedSlackInteraction {
|
|
3252
|
+
const team = record(payload.team);
|
|
3253
|
+
const user = record(payload.user);
|
|
3254
|
+
const channel = record(payload.channel);
|
|
3255
|
+
const container = record(payload.container);
|
|
3256
|
+
const message = record(payload.message);
|
|
3257
|
+
const action = Array.isArray(payload.actions) ? record(payload.actions[0]) : null;
|
|
3258
|
+
const teamId = boundedString(team?.id, 64);
|
|
3259
|
+
const userId = boundedString(user?.id, 64);
|
|
3260
|
+
const channelId = boundedString(channel?.id, 64) ?? boundedString(container?.channel_id, 64);
|
|
3261
|
+
const messageTs = boundedString(message?.ts, 64) ?? boundedString(container?.message_ts, 64);
|
|
3262
|
+
const threadTs = boundedString(message?.thread_ts, 64);
|
|
3263
|
+
const actionTs = boundedString(action?.action_ts, 64) ?? boundedString(payload.action_ts, 64);
|
|
3264
|
+
const actionId = boundedString(action?.action_id, 128);
|
|
3265
|
+
const handleId = boundedString(action?.value, 64);
|
|
3266
|
+
if (
|
|
3267
|
+
!teamId ||
|
|
3268
|
+
!userId ||
|
|
3269
|
+
!channelId ||
|
|
3270
|
+
!messageTs ||
|
|
3271
|
+
!actionTs ||
|
|
3272
|
+
!actionId ||
|
|
3273
|
+
!SLACK_BLOCK_ACTION_IDS.has(actionId) ||
|
|
3274
|
+
!handleId ||
|
|
3275
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(handleId) ||
|
|
3276
|
+
!Array.isArray(payload.actions) ||
|
|
3277
|
+
payload.actions.length !== 1
|
|
3278
|
+
) {
|
|
3279
|
+
throw new HTTPException(400, { message: "invalid Slack block action" });
|
|
3280
|
+
}
|
|
3281
|
+
const identity = createHash("sha256")
|
|
3282
|
+
.update([teamId, userId, channelId, messageTs, actionTs, actionId, handleId].join("\n"))
|
|
3283
|
+
.digest("hex");
|
|
3284
|
+
return {
|
|
3285
|
+
providerEventId: `block:${identity}`,
|
|
3286
|
+
providerMessageId: `block:${identity}`,
|
|
3287
|
+
slackTeamId: teamId,
|
|
3288
|
+
slackUserId: userId,
|
|
3289
|
+
slackChannelId: channelId,
|
|
3290
|
+
slackMessageTs: messageTs,
|
|
3291
|
+
slackThreadTs: threadTs,
|
|
3292
|
+
triggerKind: "block_action",
|
|
3293
|
+
text: `${actionId}:${handleId}`,
|
|
3294
|
+
hasFiles: false,
|
|
1810
3295
|
};
|
|
1811
3296
|
}
|
|
1812
3297
|
|
|
@@ -1845,9 +3330,17 @@ function formatQuestions(questions: HumanInputQuestion[]) {
|
|
|
1845
3330
|
|
|
1846
3331
|
function openSessionText(deps: ApiRouteDeps, workspaceId: string, sessionId: string) {
|
|
1847
3332
|
const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
3333
|
+
if (!base) throw new Error("Slack session acknowledgement requires an absolute web base URL");
|
|
3334
|
+
const url = new URL(`/workspaces/${workspaceId}/sessions/${sessionId}`, base).toString();
|
|
3335
|
+
return `<${url}|Open in OpenGeni>`;
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
function makeRecurringText(deps: ApiRouteDeps, workspaceId: string, sessionId: string) {
|
|
3339
|
+
const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
|
|
3340
|
+
if (!base) throw new Error("Slack recurring action requires an absolute web base URL");
|
|
3341
|
+
const url = new URL(`/workspaces/${workspaceId}/schedules`, base);
|
|
3342
|
+
url.searchParams.set("sourceSessionId", sessionId);
|
|
3343
|
+
return `<${url.toString()}|Make recurring>`;
|
|
1851
3344
|
}
|
|
1852
3345
|
|
|
1853
3346
|
function linkUrl(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
|
|
@@ -1975,6 +3468,16 @@ function isDirectMessageShortcut(
|
|
|
1975
3468
|
return entry.triggerKind === "message_shortcut" && entry.slackChannelId.startsWith("D");
|
|
1976
3469
|
}
|
|
1977
3470
|
|
|
3471
|
+
function usesPrivateBotDm(
|
|
3472
|
+
interaction: Pick<SlackInteraction, "visibility">,
|
|
3473
|
+
entry: Pick<SlackInteractionInboxEntry, "triggerKind" | "slackChannelId">,
|
|
3474
|
+
): boolean {
|
|
3475
|
+
return (
|
|
3476
|
+
isDirectMessageShortcut(entry) ||
|
|
3477
|
+
(interaction.visibility === "private" && entry.triggerKind !== "dm")
|
|
3478
|
+
);
|
|
3479
|
+
}
|
|
3480
|
+
|
|
1978
3481
|
function deterministicUuid(value: string) {
|
|
1979
3482
|
const bytes = createHash("sha256").update(value).digest().subarray(0, 16);
|
|
1980
3483
|
bytes[6] = (bytes[6]! & 0x0f) | 0x50;
|
|
@@ -2019,6 +3522,13 @@ function boundedOutput(value: string) {
|
|
|
2019
3522
|
: `${value.slice(0, MAX_SLACK_TEXT_CHARS - 20)}\n… output truncated`;
|
|
2020
3523
|
}
|
|
2021
3524
|
|
|
3525
|
+
function boundedOutputWithSuffix(value: string, suffix: string) {
|
|
3526
|
+
const maxValueChars = Math.max(0, MAX_SLACK_TEXT_CHARS - suffix.length);
|
|
3527
|
+
if (value.length <= maxValueChars) return `${value}${suffix}`;
|
|
3528
|
+
const truncation = "\n… output truncated";
|
|
3529
|
+
return `${value.slice(0, Math.max(0, maxValueChars - truncation.length))}${truncation}${suffix}`;
|
|
3530
|
+
}
|
|
3531
|
+
|
|
2022
3532
|
function safePayloadText(payload: unknown, field: string) {
|
|
2023
3533
|
const value = record(payload)?.[field];
|
|
2024
3534
|
return typeof value === "string" ? boundedOutput(value) : "";
|