@opengeni/api-router 0.14.4 → 0.15.4
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 +6 -1
- package/dist/app.js +5 -1
- package/dist/auth/managed-auth.d.ts +2 -2
- package/dist/{chunk-36PW33ND.js → chunk-ICRZC3JH.js} +11438 -8894
- package/dist/chunk-ICRZC3JH.js.map +1 -0
- package/dist/index.js +16 -5
- package/dist/index.js.map +1 -1
- package/dist/integrations/slack-bot.d.ts +30 -1
- package/dist/integrations/slack-interactions.d.ts +47 -0
- package/dist/integrations/social-api.d.ts +91 -0
- package/dist/integrations/social-oauth.d.ts +84 -0
- package/dist/routes/workspace-artifacts.d.ts +3 -0
- package/package.json +10 -10
- package/src/app.ts +115 -28
- package/src/http/auth.ts +9 -1
- package/src/index.ts +17 -4
- package/src/integrations/oauth-client.ts +27 -10
- package/src/integrations/slack-bot.ts +100 -14
- package/src/integrations/slack-interactions.ts +1033 -0
- package/src/integrations/social-api.ts +504 -0
- package/src/integrations/social-oauth.ts +745 -0
- package/src/mcp/server.ts +383 -0
- package/src/routes/sessions.ts +23 -10
- package/src/routes/social.ts +67 -2
- package/src/routes/workspace-artifacts.ts +274 -0
- package/dist/chunk-36PW33ND.js.map +0 -1
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_FIRST_PARTY_MCP_TOOLS,
|
|
4
|
+
type AccessGrant,
|
|
5
|
+
type FirstPartyMcpToolName,
|
|
6
|
+
type HumanInputQuestion,
|
|
7
|
+
type SessionEvent,
|
|
8
|
+
} from "@opengeni/contracts";
|
|
9
|
+
import {
|
|
10
|
+
acceptSessionHumanInputResponse,
|
|
11
|
+
advanceSlackInteractionDelivery,
|
|
12
|
+
bindSlackInteractionSession,
|
|
13
|
+
claimSlackInteractionDelivery,
|
|
14
|
+
claimSlackInteractionProgressDelivery,
|
|
15
|
+
claimSlackInteractionInbox,
|
|
16
|
+
closeSlackInteractionDelivery,
|
|
17
|
+
deferSlackInteractionDelivery,
|
|
18
|
+
deleteSlackBotUserLink,
|
|
19
|
+
enqueueSlackInteractionInbox,
|
|
20
|
+
getOrCreateSlackInteraction,
|
|
21
|
+
getSlackBotUserLink,
|
|
22
|
+
getSlackInteractionByRoute,
|
|
23
|
+
getWorkspaceGrant,
|
|
24
|
+
listSessionEventPage,
|
|
25
|
+
listSessionHumanInputRequests,
|
|
26
|
+
rekeySlackInteractionRoute,
|
|
27
|
+
reopenSlackInteractionDelivery,
|
|
28
|
+
releaseSlackInteractionDelivery,
|
|
29
|
+
releaseSlackInteractionInbox,
|
|
30
|
+
resolveSlackInstallationRoute,
|
|
31
|
+
saveSlackBotUserLink,
|
|
32
|
+
settleSlackInteractionInbox,
|
|
33
|
+
type SlackInstallationRoute,
|
|
34
|
+
type SlackInteraction,
|
|
35
|
+
type SlackInteractionInboxEntry,
|
|
36
|
+
type SlackInteractionTriggerKind,
|
|
37
|
+
} from "@opengeni/db";
|
|
38
|
+
import {
|
|
39
|
+
acceptSessionUserMessage,
|
|
40
|
+
controlHumanSessionWorkstream,
|
|
41
|
+
createSessionForRequest,
|
|
42
|
+
hasPermission,
|
|
43
|
+
requireAccessGrant,
|
|
44
|
+
type ApiRouteDeps,
|
|
45
|
+
} from "@opengeni/core";
|
|
46
|
+
import { publishDurableSessionEvents } from "@opengeni/events";
|
|
47
|
+
import type { Context, Hono } from "hono";
|
|
48
|
+
import { HTTPException } from "hono/http-exception";
|
|
49
|
+
import { createOpenGeniSlackBotInteractionClient, SlackBotProviderError } from "./slack-bot";
|
|
50
|
+
|
|
51
|
+
export const SLACK_INTERACTION_MAX_BODY_BYTES = 256 * 1024;
|
|
52
|
+
export const SLACK_SIGNATURE_REPLAY_WINDOW_SECONDS = 300;
|
|
53
|
+
export const SLACK_DELIVERY_EVENT_TYPES = [
|
|
54
|
+
"agent.message.completed",
|
|
55
|
+
"session.humanInput.requested",
|
|
56
|
+
"turn.completed",
|
|
57
|
+
"turn.failed",
|
|
58
|
+
"turn.cancelled",
|
|
59
|
+
"session.status.changed",
|
|
60
|
+
] as const;
|
|
61
|
+
|
|
62
|
+
const MAX_SLACK_TEXT_CHARS = 3_500;
|
|
63
|
+
const MAX_SLACK_INPUT_CHARS = 8_000;
|
|
64
|
+
const MAX_PROGRESS_MESSAGES = 3;
|
|
65
|
+
const SLACK_USER_LINK_TTL_MS = 15 * 60_000;
|
|
66
|
+
const INBOX_LEASE_MS = 30_000;
|
|
67
|
+
const DELIVERY_LEASE_MS = 30_000;
|
|
68
|
+
const MAX_DELIVERY_ATTEMPTS = 8;
|
|
69
|
+
const MAX_DELIVERY_RETRY_MS = 5 * 60_000;
|
|
70
|
+
export const SLACK_TASK_INSTRUCTIONS = [
|
|
71
|
+
"This turn originated from Slack. Slack message and thread context is task-local only.",
|
|
72
|
+
"Do not write Slack context to Documents, Knowledge, Memory, preferences, Workspace Charter, instructions, or policy unless a separate explicit authorized user action requests it.",
|
|
73
|
+
"Never expose private reasoning, credentials, secrets, raw logs, or unbounded output.",
|
|
74
|
+
"Keep user-visible output concise, bounded, and safe to send back to Slack.",
|
|
75
|
+
].join(" ");
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Slack-originated tasks may retrieve the workspace bot's bounded read surface
|
|
79
|
+
* on demand. Connector tools are explicit-only, so freeze that narrow context
|
|
80
|
+
* selection at session creation while keeping Slack mutations out of the model
|
|
81
|
+
* surface; interaction delivery remains owned by the durable delivery pump.
|
|
82
|
+
*/
|
|
83
|
+
export const SLACK_TASK_FIRST_PARTY_MCP_TOOLS = [
|
|
84
|
+
...DEFAULT_FIRST_PARTY_MCP_TOOLS,
|
|
85
|
+
"slack_bot_list_channels",
|
|
86
|
+
"slack_bot_channel_history",
|
|
87
|
+
"slack_bot_thread_replies",
|
|
88
|
+
"slack_bot_list_users",
|
|
89
|
+
"slack_bot_list_files",
|
|
90
|
+
"slack_bot_file_info",
|
|
91
|
+
"slack_bot_file_content",
|
|
92
|
+
] satisfies readonly FirstPartyMcpToolName[];
|
|
93
|
+
|
|
94
|
+
export type NormalizedSlackInteraction = {
|
|
95
|
+
providerEventId: string;
|
|
96
|
+
providerMessageId: string;
|
|
97
|
+
slackTeamId: string;
|
|
98
|
+
slackUserId: string;
|
|
99
|
+
slackChannelId: string;
|
|
100
|
+
slackMessageTs: string;
|
|
101
|
+
slackThreadTs: string | null;
|
|
102
|
+
triggerKind: SlackInteractionTriggerKind;
|
|
103
|
+
text: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export function verifySlackRequestSignature(
|
|
107
|
+
input: {
|
|
108
|
+
timestamp: string | null;
|
|
109
|
+
signature: string | null;
|
|
110
|
+
rawBody: string;
|
|
111
|
+
},
|
|
112
|
+
signingSecret: string,
|
|
113
|
+
nowMs = Date.now(),
|
|
114
|
+
): boolean {
|
|
115
|
+
if (
|
|
116
|
+
!/^\d{1,16}$/.test(input.timestamp ?? "") ||
|
|
117
|
+
!/^v0=[0-9a-f]{64}$/.test(input.signature ?? "")
|
|
118
|
+
) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const timestamp = Number(input.timestamp);
|
|
122
|
+
if (!Number.isSafeInteger(timestamp)) return false;
|
|
123
|
+
const nowSeconds = Math.floor(nowMs / 1000);
|
|
124
|
+
if (Math.abs(nowSeconds - timestamp) > SLACK_SIGNATURE_REPLAY_WINDOW_SECONDS) return false;
|
|
125
|
+
const expected = `v0=${createHmac("sha256", signingSecret)
|
|
126
|
+
.update(`v0:${input.timestamp}:${input.rawBody}`)
|
|
127
|
+
.digest("hex")}`;
|
|
128
|
+
const actualBytes = Buffer.from(input.signature!, "utf8");
|
|
129
|
+
const expectedBytes = Buffer.from(expected, "utf8");
|
|
130
|
+
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function slackEventInboxEntry(
|
|
134
|
+
payload: unknown,
|
|
135
|
+
bot: Pick<SlackInstallationRoute, "botId" | "botUserId">,
|
|
136
|
+
): NormalizedSlackInteraction | null {
|
|
137
|
+
const envelope = record(payload);
|
|
138
|
+
if (!envelope || envelope.type !== "event_callback") return null;
|
|
139
|
+
const event = record(envelope.event);
|
|
140
|
+
const teamId = boundedString(envelope.team_id, 64);
|
|
141
|
+
const eventId = boundedString(envelope.event_id, 256);
|
|
142
|
+
if (!event || !teamId || !eventId) return null;
|
|
143
|
+
if (event.bot_id || event.bot_profile || event.subtype || event.user === bot.botUserId)
|
|
144
|
+
return null;
|
|
145
|
+
const userId = boundedString(event.user, 64);
|
|
146
|
+
const channelId = boundedString(event.channel, 64);
|
|
147
|
+
const timestamp = boundedString(event.ts, 64);
|
|
148
|
+
const threadTimestamp = boundedString(event.thread_ts, 64);
|
|
149
|
+
const text = boundedText(event.text);
|
|
150
|
+
if (!userId || !channelId || !timestamp || !text) return null;
|
|
151
|
+
let triggerKind: SlackInteractionTriggerKind;
|
|
152
|
+
if (event.type === "app_mention") {
|
|
153
|
+
// A mention is always an explicit invocation. In particular, a mention in
|
|
154
|
+
// an otherwise-unmapped existing thread adopts that thread as the new
|
|
155
|
+
// OpenGeni session surface; only ordinary message replies require a
|
|
156
|
+
// pre-existing route.
|
|
157
|
+
triggerKind = "app_mention";
|
|
158
|
+
} else if (event.type === "message" && threadTimestamp) {
|
|
159
|
+
triggerKind = "thread_reply";
|
|
160
|
+
} else if (event.type === "message" && event.channel_type === "im") {
|
|
161
|
+
triggerKind = "dm";
|
|
162
|
+
} else {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
providerEventId: eventId,
|
|
167
|
+
providerMessageId: `${teamId}:${channelId}:${timestamp}`,
|
|
168
|
+
slackTeamId: teamId,
|
|
169
|
+
slackUserId: userId,
|
|
170
|
+
slackChannelId: channelId,
|
|
171
|
+
slackMessageTs: timestamp,
|
|
172
|
+
slackThreadTs: threadTimestamp,
|
|
173
|
+
triggerKind,
|
|
174
|
+
text,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
179
|
+
app.post("/v1/integrations/slack/events", async (c) => {
|
|
180
|
+
const signed = await readSignedSlackRequest(c, deps);
|
|
181
|
+
const payload = parseJsonObject(signed.rawBody);
|
|
182
|
+
if (payload.type === "url_verification") {
|
|
183
|
+
const challenge = boundedString(payload.challenge, 512);
|
|
184
|
+
if (!challenge) throw new HTTPException(400, { message: "invalid Slack challenge" });
|
|
185
|
+
return c.json({ challenge });
|
|
186
|
+
}
|
|
187
|
+
const teamId = boundedString(payload.team_id, 64);
|
|
188
|
+
if (!teamId) throw new HTTPException(400, { message: "invalid Slack event" });
|
|
189
|
+
const installation = await resolveSlackInstallationRoute(deps.db, teamId);
|
|
190
|
+
if (!installation)
|
|
191
|
+
throw new HTTPException(403, {
|
|
192
|
+
message: "Slack installation unavailable",
|
|
193
|
+
});
|
|
194
|
+
const entry = slackEventInboxEntry(payload, installation);
|
|
195
|
+
if (entry) await enqueueNormalizedSlackInteraction(deps, installation, entry);
|
|
196
|
+
return c.json({ ok: true });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
app.post("/v1/integrations/slack/commands", async (c) => {
|
|
200
|
+
const signed = await readSignedSlackRequest(c, deps);
|
|
201
|
+
const form = new URLSearchParams(signed.rawBody);
|
|
202
|
+
if (form.get("command") !== "/opengeni") {
|
|
203
|
+
throw new HTTPException(400, { message: "invalid Slack command" });
|
|
204
|
+
}
|
|
205
|
+
const entry = normalizedFormInteraction(form, "slash_command");
|
|
206
|
+
const installation = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
|
|
207
|
+
if (!installation)
|
|
208
|
+
throw new HTTPException(403, {
|
|
209
|
+
message: "Slack installation unavailable",
|
|
210
|
+
});
|
|
211
|
+
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
212
|
+
accountId: installation.accountId,
|
|
213
|
+
workspaceId: installation.workspaceId,
|
|
214
|
+
connectionId: installation.connectionId,
|
|
215
|
+
subjectId: "service:slack-interaction",
|
|
216
|
+
});
|
|
217
|
+
try {
|
|
218
|
+
await client.verifyChannelAccess(entry.slackChannelId);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error instanceof SlackBotProviderError && error.code === "not_in_channel") {
|
|
221
|
+
return c.text(
|
|
222
|
+
"OpenGeni is not a member of this channel. Add @OpenGeni, then run /opengeni again.",
|
|
223
|
+
200,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
// Slash commands are not replayed through the Events API. A transient
|
|
227
|
+
// membership preflight failure must therefore fall through to the
|
|
228
|
+
// durable inbox, whose normal claim/backoff path retries the same task.
|
|
229
|
+
// Permanent provider/local failures remain an honest request failure.
|
|
230
|
+
if (!(error instanceof SlackBotProviderError) || permanentSlackDeliveryError(error)) {
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
await enqueueNormalizedSlackInteraction(deps, installation, entry);
|
|
235
|
+
return c.text("OpenGeni accepted this task and will reply in a thread.", 200);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
app.post("/v1/integrations/slack/interactions", async (c) => {
|
|
239
|
+
const signed = await readSignedSlackRequest(c, deps);
|
|
240
|
+
const form = new URLSearchParams(signed.rawBody);
|
|
241
|
+
const payload = parseJsonObject(form.get("payload") ?? "");
|
|
242
|
+
if (payload.type !== "message_action") {
|
|
243
|
+
throw new HTTPException(400, {
|
|
244
|
+
message: "unsupported Slack interaction",
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const team = record(payload.team);
|
|
248
|
+
const user = record(payload.user);
|
|
249
|
+
const channel = record(payload.channel);
|
|
250
|
+
const message = record(payload.message);
|
|
251
|
+
const triggerId = boundedString(payload.trigger_id, 256);
|
|
252
|
+
const teamId = boundedString(team?.id, 64);
|
|
253
|
+
const userId = boundedString(user?.id, 64);
|
|
254
|
+
const channelId = boundedString(channel?.id, 64);
|
|
255
|
+
const messageTs = boundedString(message?.ts, 64);
|
|
256
|
+
const threadTs = boundedString(message?.thread_ts, 64);
|
|
257
|
+
const text = boundedText(message?.text);
|
|
258
|
+
if (!triggerId || !teamId || !userId || !channelId || !messageTs || !text) {
|
|
259
|
+
throw new HTTPException(400, {
|
|
260
|
+
message: "invalid Slack message shortcut",
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const installation = await resolveSlackInstallationRoute(deps.db, teamId);
|
|
264
|
+
if (!installation)
|
|
265
|
+
throw new HTTPException(403, {
|
|
266
|
+
message: "Slack installation unavailable",
|
|
267
|
+
});
|
|
268
|
+
await enqueueNormalizedSlackInteraction(deps, installation, {
|
|
269
|
+
providerEventId: `shortcut:${triggerId}`,
|
|
270
|
+
providerMessageId: `shortcut:${triggerId}`,
|
|
271
|
+
slackTeamId: teamId,
|
|
272
|
+
slackUserId: userId,
|
|
273
|
+
slackChannelId: channelId,
|
|
274
|
+
slackMessageTs: messageTs,
|
|
275
|
+
slackThreadTs: threadTs,
|
|
276
|
+
triggerKind: "message_shortcut",
|
|
277
|
+
text,
|
|
278
|
+
});
|
|
279
|
+
return c.json({ ok: true });
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
app.post("/v1/workspaces/:workspaceId/integrations/slack/user-links", async (c) => {
|
|
283
|
+
const workspaceId = c.req.param("workspaceId");
|
|
284
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
|
|
285
|
+
const body = record(await c.req.json().catch(() => null));
|
|
286
|
+
const linkToken = boundedString(body?.linkToken, 2_048);
|
|
287
|
+
const signingSecret = deps.settings.slackSigningSecret;
|
|
288
|
+
const link =
|
|
289
|
+
linkToken && signingSecret ? verifySlackUserLinkToken(signingSecret, linkToken) : null;
|
|
290
|
+
if (!link || link.workspaceId !== workspaceId) {
|
|
291
|
+
throw new HTTPException(400, {
|
|
292
|
+
message: "invalid or expired Slack identity link",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
const route = await resolveSlackInstallationRoute(deps.db, link.slackTeamId);
|
|
296
|
+
if (!route || route.workspaceId !== workspaceId || route.connectionId !== link.connectionId) {
|
|
297
|
+
throw new HTTPException(404, {
|
|
298
|
+
message: "Slack installation not found",
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return c.json(
|
|
302
|
+
await saveSlackBotUserLink(deps.db, {
|
|
303
|
+
accountId: grant.accountId,
|
|
304
|
+
workspaceId,
|
|
305
|
+
connectionId: link.connectionId,
|
|
306
|
+
slackTeamId: link.slackTeamId,
|
|
307
|
+
slackUserId: link.slackUserId,
|
|
308
|
+
subjectId: grant.subjectId,
|
|
309
|
+
linkedBySubjectId: grant.subjectId,
|
|
310
|
+
}),
|
|
311
|
+
201,
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
app.delete(
|
|
316
|
+
"/v1/workspaces/:workspaceId/integrations/slack/user-links/:slackUserId",
|
|
317
|
+
async (c) => {
|
|
318
|
+
const workspaceId = c.req.param("workspaceId");
|
|
319
|
+
await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
320
|
+
const connectionId = boundedString(c.req.query("connectionId"), 64);
|
|
321
|
+
if (!connectionId) throw new HTTPException(400, { message: "connectionId is required" });
|
|
322
|
+
return c.json({
|
|
323
|
+
deleted: await deleteSlackBotUserLink(
|
|
324
|
+
deps.db,
|
|
325
|
+
workspaceId,
|
|
326
|
+
connectionId,
|
|
327
|
+
c.req.param("slackUserId"),
|
|
328
|
+
),
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function drainSlackInteractionsOnce(deps: ApiRouteDeps): Promise<boolean> {
|
|
335
|
+
const holder = crypto.randomUUID();
|
|
336
|
+
const entry = await claimSlackInteractionInbox(deps.db, holder, INBOX_LEASE_MS);
|
|
337
|
+
if (entry) {
|
|
338
|
+
try {
|
|
339
|
+
await processSlackInboxEntry(deps, entry);
|
|
340
|
+
await settleSlackInteractionInbox(deps.db, {
|
|
341
|
+
entry,
|
|
342
|
+
claimHolderId: holder,
|
|
343
|
+
outcome: "processed",
|
|
344
|
+
});
|
|
345
|
+
} catch (error) {
|
|
346
|
+
const code = safeErrorCode(error);
|
|
347
|
+
if (
|
|
348
|
+
entry.attemptCount >= 5 ||
|
|
349
|
+
permanentSlackInteractionError(error) ||
|
|
350
|
+
permanentSlackDeliveryError(error)
|
|
351
|
+
) {
|
|
352
|
+
await settleSlackInteractionInbox(deps.db, {
|
|
353
|
+
entry,
|
|
354
|
+
claimHolderId: holder,
|
|
355
|
+
outcome: "failed",
|
|
356
|
+
errorCode: code,
|
|
357
|
+
});
|
|
358
|
+
} else {
|
|
359
|
+
await releaseSlackInteractionInbox(deps.db, {
|
|
360
|
+
entry,
|
|
361
|
+
claimHolderId: holder,
|
|
362
|
+
errorCode: code,
|
|
363
|
+
retryAt: new Date(Date.now() + slackDeliveryRetryMs(error, entry.attemptCount)),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const deliveryHolder = crypto.randomUUID();
|
|
371
|
+
const interaction = await claimSlackInteractionDelivery(
|
|
372
|
+
deps.db,
|
|
373
|
+
deliveryHolder,
|
|
374
|
+
DELIVERY_LEASE_MS,
|
|
375
|
+
);
|
|
376
|
+
if (!interaction) return false;
|
|
377
|
+
try {
|
|
378
|
+
await deliverSlackSessionEvents(deps, interaction, deliveryHolder);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
const errorCode = slackDeliveryErrorCode(error);
|
|
381
|
+
if (
|
|
382
|
+
interaction.deliveryAttemptCount >= MAX_DELIVERY_ATTEMPTS ||
|
|
383
|
+
permanentSlackDeliveryError(error)
|
|
384
|
+
) {
|
|
385
|
+
await closeSlackInteractionDelivery(deps.db, {
|
|
386
|
+
...interaction,
|
|
387
|
+
claimHolderId: deliveryHolder,
|
|
388
|
+
sequence: interaction.lastDeliveredSessionEventSequence,
|
|
389
|
+
state: "failed",
|
|
390
|
+
errorCode,
|
|
391
|
+
}).catch(() => undefined);
|
|
392
|
+
} else {
|
|
393
|
+
await deferSlackInteractionDelivery(deps.db, {
|
|
394
|
+
...interaction,
|
|
395
|
+
claimHolderId: deliveryHolder,
|
|
396
|
+
retryAt: new Date(
|
|
397
|
+
Date.now() + slackDeliveryRetryMs(error, interaction.deliveryAttemptCount),
|
|
398
|
+
),
|
|
399
|
+
errorCode,
|
|
400
|
+
}).catch(() => undefined);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function startSlackInteractionPump(
|
|
407
|
+
deps: ApiRouteDeps,
|
|
408
|
+
options: { intervalMs?: number; maxPerTick?: number } = {},
|
|
409
|
+
): () => void {
|
|
410
|
+
let stopped = false;
|
|
411
|
+
let running = false;
|
|
412
|
+
const intervalMs = Math.max(250, options.intervalMs ?? 1_000);
|
|
413
|
+
const maxPerTick = Math.max(1, Math.min(50, options.maxPerTick ?? 10));
|
|
414
|
+
const tick = async () => {
|
|
415
|
+
if (stopped || running) return;
|
|
416
|
+
running = true;
|
|
417
|
+
try {
|
|
418
|
+
for (let index = 0; index < maxPerTick; index += 1) {
|
|
419
|
+
if (!(await drainSlackInteractionsOnce(deps))) break;
|
|
420
|
+
}
|
|
421
|
+
} finally {
|
|
422
|
+
running = false;
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
const timer = setInterval(() => void tick(), intervalMs);
|
|
426
|
+
void tick();
|
|
427
|
+
return () => {
|
|
428
|
+
stopped = true;
|
|
429
|
+
clearInterval(timer);
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
|
|
434
|
+
const routeKey = slackRouteKey(entry.slackChannelId, entry.slackThreadTs ?? entry.slackMessageTs);
|
|
435
|
+
const existing = await getSlackInteractionByRoute(
|
|
436
|
+
deps.db,
|
|
437
|
+
entry.workspaceId,
|
|
438
|
+
entry.connectionId,
|
|
439
|
+
routeKey,
|
|
440
|
+
);
|
|
441
|
+
if (entry.triggerKind === "thread_reply" && !existing) return;
|
|
442
|
+
|
|
443
|
+
const link = await getSlackBotUserLink(
|
|
444
|
+
deps.db,
|
|
445
|
+
entry.workspaceId,
|
|
446
|
+
entry.connectionId,
|
|
447
|
+
entry.slackUserId,
|
|
448
|
+
);
|
|
449
|
+
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
450
|
+
accountId: entry.accountId,
|
|
451
|
+
workspaceId: entry.workspaceId,
|
|
452
|
+
connectionId: entry.connectionId,
|
|
453
|
+
subjectId: link?.subjectId ?? "service:slack-interaction",
|
|
454
|
+
...(existing?.sessionId ? { sessionId: existing.sessionId } : {}),
|
|
455
|
+
});
|
|
456
|
+
await client.verifyChannelAccess(entry.slackChannelId);
|
|
457
|
+
if (!link) {
|
|
458
|
+
await client.postMessage({
|
|
459
|
+
operationId: deterministicUuid(`slack-link:${entry.id}`),
|
|
460
|
+
userId: entry.slackUserId,
|
|
461
|
+
text: `Link your Slack identity to OpenGeni before starting work: ${linkUrl(deps, entry)}. No session was created.`,
|
|
462
|
+
});
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const grant = await getWorkspaceGrant(deps.db, link.subjectId, entry.workspaceId, {
|
|
466
|
+
principalKind: "human_session",
|
|
467
|
+
});
|
|
468
|
+
if (!grant || grant.accountId !== entry.accountId) {
|
|
469
|
+
throw new SlackInteractionPermanentError("identity_access_revoked");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (existing?.sessionId) {
|
|
473
|
+
await continueSlackSession(deps, grant, existing, entry);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (!hasPermission(grant.permissions, "sessions:create")) {
|
|
477
|
+
throw new SlackInteractionPermanentError("sessions_create_denied");
|
|
478
|
+
}
|
|
479
|
+
const { interaction } = await getOrCreateSlackInteraction(deps.db, {
|
|
480
|
+
accountId: entry.accountId,
|
|
481
|
+
workspaceId: entry.workspaceId,
|
|
482
|
+
connectionId: entry.connectionId,
|
|
483
|
+
slackTeamId: entry.slackTeamId,
|
|
484
|
+
slackChannelId: entry.slackChannelId,
|
|
485
|
+
slackThreadTs: entry.slackThreadTs ?? entry.slackMessageTs,
|
|
486
|
+
routeKey,
|
|
487
|
+
triggeringProviderEventId: entry.providerEventId,
|
|
488
|
+
owningSubjectId: grant.subjectId,
|
|
489
|
+
visibility: entry.triggerKind === "dm" ? "private" : "workspace",
|
|
490
|
+
});
|
|
491
|
+
if (interaction.sessionId) {
|
|
492
|
+
await continueSlackSession(deps, grant, interaction, entry);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const session = await createSessionForRequest(deps, grant, entry.workspaceId, {
|
|
496
|
+
requestedSessionId: interaction.sessionReservationId,
|
|
497
|
+
initialMessage: entry.text,
|
|
498
|
+
turnInstructions: SLACK_TASK_INSTRUCTIONS,
|
|
499
|
+
firstPartyMcpTools: [...SLACK_TASK_FIRST_PARTY_MCP_TOOLS],
|
|
500
|
+
idempotencyKey: `slack:${entry.connectionId}:${entry.providerEventId}`,
|
|
501
|
+
clientEventId: `slack:${entry.providerEventId}`,
|
|
502
|
+
});
|
|
503
|
+
const bound = await bindSlackInteractionSession(deps.db, {
|
|
504
|
+
...interaction,
|
|
505
|
+
owningSubjectId: grant.subjectId,
|
|
506
|
+
sessionId: session.id,
|
|
507
|
+
});
|
|
508
|
+
if (!bound) throw new Error("Slack route could not bind its durable session");
|
|
509
|
+
const ack = await client.postMessage({
|
|
510
|
+
operationId: deterministicUuid(`slack-ack:${interaction.id}`),
|
|
511
|
+
channelId: entry.slackChannelId,
|
|
512
|
+
...(entry.triggerKind === "slash_command"
|
|
513
|
+
? {}
|
|
514
|
+
: { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
|
|
515
|
+
text: `OpenGeni started this task. ${openSessionText(deps, entry.workspaceId, session.id)} 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.`,
|
|
516
|
+
});
|
|
517
|
+
if (entry.triggerKind === "slash_command") {
|
|
518
|
+
await rekeySlackInteractionRoute(deps.db, {
|
|
519
|
+
...interaction,
|
|
520
|
+
routeKey: slackRouteKey(entry.slackChannelId, ack.timestamp),
|
|
521
|
+
slackThreadTs: ack.timestamp,
|
|
522
|
+
ackSlackMessageTs: ack.timestamp,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function continueSlackSession(
|
|
528
|
+
deps: ApiRouteDeps,
|
|
529
|
+
grant: AccessGrant,
|
|
530
|
+
interaction: SlackInteraction,
|
|
531
|
+
entry: SlackInteractionInboxEntry,
|
|
532
|
+
) {
|
|
533
|
+
if (
|
|
534
|
+
!interaction.sessionId ||
|
|
535
|
+
(interaction.visibility === "private" && interaction.owningSubjectId !== grant.subjectId)
|
|
536
|
+
) {
|
|
537
|
+
throw new SlackInteractionPermanentError("session_owner_mismatch");
|
|
538
|
+
}
|
|
539
|
+
await reopenSlackInteractionDelivery(deps.db, interaction);
|
|
540
|
+
if (entry.text.trim().toLowerCase() === "stop") {
|
|
541
|
+
if (!hasPermission(grant.permissions, "sessions:control")) {
|
|
542
|
+
throw new SlackInteractionPermanentError("sessions_control_denied");
|
|
543
|
+
}
|
|
544
|
+
await controlHumanSessionWorkstream(
|
|
545
|
+
deps,
|
|
546
|
+
{
|
|
547
|
+
accountId: grant.accountId,
|
|
548
|
+
workspaceId: grant.workspaceId,
|
|
549
|
+
subjectId: grant.subjectId,
|
|
550
|
+
sessionId: interaction.sessionId,
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
action: "pause",
|
|
554
|
+
clientEventId: deterministicUuid(`slack-stop:${entry.providerEventId}`),
|
|
555
|
+
reason: "Stopped from the originating Slack thread",
|
|
556
|
+
},
|
|
557
|
+
);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const pending = await listSessionHumanInputRequests(
|
|
561
|
+
deps.db,
|
|
562
|
+
entry.workspaceId,
|
|
563
|
+
interaction.sessionId,
|
|
564
|
+
{ status: "pending", limit: 2 },
|
|
565
|
+
);
|
|
566
|
+
if (pending.length === 1) {
|
|
567
|
+
const response = humanInputResponse(pending[0]!.questions, entry.text);
|
|
568
|
+
if (response) {
|
|
569
|
+
const accepted = await acceptSessionHumanInputResponse(deps.db, {
|
|
570
|
+
accountId: grant.accountId,
|
|
571
|
+
workspaceId: grant.workspaceId,
|
|
572
|
+
sessionId: interaction.sessionId,
|
|
573
|
+
requestId: pending[0]!.id,
|
|
574
|
+
response,
|
|
575
|
+
respondedBy: grant.subjectId,
|
|
576
|
+
clientEventId: `slack:${entry.providerEventId}`,
|
|
577
|
+
});
|
|
578
|
+
if (accepted.action === "accepted") {
|
|
579
|
+
await publishDurableSessionEvents(
|
|
580
|
+
deps.bus,
|
|
581
|
+
grant.workspaceId,
|
|
582
|
+
interaction.sessionId,
|
|
583
|
+
accepted.events,
|
|
584
|
+
);
|
|
585
|
+
if (accepted.workflowWakeRevision !== null) {
|
|
586
|
+
await deps.workflowClient.signalApprovalDecision({
|
|
587
|
+
accountId: grant.accountId,
|
|
588
|
+
workspaceId: grant.workspaceId,
|
|
589
|
+
sessionId: interaction.sessionId,
|
|
590
|
+
eventId: accepted.events[0]?.id ?? pending[0]!.id,
|
|
591
|
+
workflowId: `session-${interaction.sessionId}`,
|
|
592
|
+
workflowWakeRevision: accepted.workflowWakeRevision,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (!hasPermission(grant.permissions, "sessions:control")) {
|
|
600
|
+
throw new SlackInteractionPermanentError("sessions_control_denied");
|
|
601
|
+
}
|
|
602
|
+
await acceptSessionUserMessage(deps, grant, entry.workspaceId, interaction.sessionId, {
|
|
603
|
+
text: entry.text,
|
|
604
|
+
turnInstructions: SLACK_TASK_INSTRUCTIONS,
|
|
605
|
+
clientEventId: `slack:${entry.providerEventId}`,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function deliverSlackSessionEvents(
|
|
610
|
+
deps: ApiRouteDeps,
|
|
611
|
+
interaction: SlackInteraction,
|
|
612
|
+
claimHolderId: string,
|
|
613
|
+
) {
|
|
614
|
+
if (!interaction.sessionId) return;
|
|
615
|
+
const page = await listSessionEventPage(deps.db, interaction.workspaceId, interaction.sessionId, {
|
|
616
|
+
after: interaction.lastDeliveredSessionEventSequence,
|
|
617
|
+
limit: 100,
|
|
618
|
+
includeTypes: [...SLACK_DELIVERY_EVENT_TYPES],
|
|
619
|
+
authoritativeLatest: true,
|
|
620
|
+
maxBytes: 256 * 1024,
|
|
621
|
+
});
|
|
622
|
+
if (page.events.length === 0) {
|
|
623
|
+
await releaseSlackInteractionDelivery(deps.db, {
|
|
624
|
+
...interaction,
|
|
625
|
+
claimHolderId,
|
|
626
|
+
});
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const client = await createOpenGeniSlackBotInteractionClient(deps, {
|
|
630
|
+
accountId: interaction.accountId,
|
|
631
|
+
workspaceId: interaction.workspaceId,
|
|
632
|
+
connectionId: interaction.connectionId,
|
|
633
|
+
subjectId: interaction.owningSubjectId,
|
|
634
|
+
sessionId: interaction.sessionId,
|
|
635
|
+
});
|
|
636
|
+
let lastSequence = interaction.lastDeliveredSessionEventSequence;
|
|
637
|
+
let terminal: Exclude<SlackInteraction["terminalDeliveryState"], "open"> | null = null;
|
|
638
|
+
let latestAssistantText = "";
|
|
639
|
+
// Monitoring pages are newest-first. Slack delivery is a timeline surface:
|
|
640
|
+
// replay oldest-to-newest so progress cannot appear after a terminal result
|
|
641
|
+
// and the final message remains the final message in the thread.
|
|
642
|
+
for (const event of [...page.events].sort((left, right) => left.sequence - right.sequence)) {
|
|
643
|
+
lastSequence = Math.max(lastSequence, event.sequence);
|
|
644
|
+
if (event.type === "agent.message.completed") {
|
|
645
|
+
latestAssistantText = safePayloadText(event.payload, "text");
|
|
646
|
+
if (latestAssistantText) {
|
|
647
|
+
const progress = await claimSlackInteractionProgressDelivery(deps.db, {
|
|
648
|
+
accountId: interaction.accountId,
|
|
649
|
+
workspaceId: interaction.workspaceId,
|
|
650
|
+
interactionId: interaction.id,
|
|
651
|
+
claimHolderId,
|
|
652
|
+
sessionEventSequence: event.sequence,
|
|
653
|
+
maxProgress: MAX_PROGRESS_MESSAGES,
|
|
654
|
+
});
|
|
655
|
+
if (progress.kind === "not_owned") {
|
|
656
|
+
throw new Error("Slack progress delivery lost its durable interaction claim");
|
|
657
|
+
}
|
|
658
|
+
if (progress.kind === "claimed") {
|
|
659
|
+
await postDelivery(
|
|
660
|
+
client,
|
|
661
|
+
interaction,
|
|
662
|
+
event,
|
|
663
|
+
latestAssistantText,
|
|
664
|
+
"progress",
|
|
665
|
+
progress.delivery.operationId,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
} else if (event.type === "session.humanInput.requested") {
|
|
670
|
+
const requests = await listSessionHumanInputRequests(
|
|
671
|
+
deps.db,
|
|
672
|
+
interaction.workspaceId,
|
|
673
|
+
interaction.sessionId,
|
|
674
|
+
{ status: "pending", limit: 1 },
|
|
675
|
+
);
|
|
676
|
+
const request = requests[0];
|
|
677
|
+
if (request) {
|
|
678
|
+
await postDelivery(
|
|
679
|
+
client,
|
|
680
|
+
interaction,
|
|
681
|
+
event,
|
|
682
|
+
`OpenGeni needs your input:\n${formatQuestions(request.questions)}\nReply in this thread, or use ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)}.`,
|
|
683
|
+
"human-input",
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
} else if (event.type === "turn.completed") {
|
|
687
|
+
const output = safePayloadText(event.payload, "output") || latestAssistantText;
|
|
688
|
+
await postDelivery(
|
|
689
|
+
client,
|
|
690
|
+
interaction,
|
|
691
|
+
event,
|
|
692
|
+
`${output || "OpenGeni finished this task."}\n\n${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} Reply in this thread to continue.`,
|
|
693
|
+
"final",
|
|
694
|
+
);
|
|
695
|
+
terminal = "completed";
|
|
696
|
+
} else if (event.type === "turn.failed") {
|
|
697
|
+
await postDelivery(
|
|
698
|
+
client,
|
|
699
|
+
interaction,
|
|
700
|
+
event,
|
|
701
|
+
`OpenGeni could not complete this task. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} for the bounded failure details.`,
|
|
702
|
+
"failed",
|
|
703
|
+
);
|
|
704
|
+
terminal = "failed";
|
|
705
|
+
} else if (event.type === "turn.cancelled") {
|
|
706
|
+
await postDelivery(client, interaction, event, "OpenGeni stopped this task.", "cancelled");
|
|
707
|
+
terminal = "cancelled";
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (terminal) {
|
|
711
|
+
await closeSlackInteractionDelivery(deps.db, {
|
|
712
|
+
...interaction,
|
|
713
|
+
claimHolderId,
|
|
714
|
+
sequence: lastSequence,
|
|
715
|
+
state: terminal,
|
|
716
|
+
});
|
|
717
|
+
} else {
|
|
718
|
+
await advanceSlackInteractionDelivery(deps.db, {
|
|
719
|
+
...interaction,
|
|
720
|
+
claimHolderId,
|
|
721
|
+
sequence: lastSequence,
|
|
722
|
+
});
|
|
723
|
+
await releaseSlackInteractionDelivery(deps.db, {
|
|
724
|
+
...interaction,
|
|
725
|
+
claimHolderId,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function postDelivery(
|
|
731
|
+
client: Awaited<ReturnType<typeof createOpenGeniSlackBotInteractionClient>>,
|
|
732
|
+
interaction: SlackInteraction,
|
|
733
|
+
event: SessionEvent,
|
|
734
|
+
text: string,
|
|
735
|
+
kind: string,
|
|
736
|
+
operationId = deterministicUuid(`slack-delivery:${interaction.id}:${event.sequence}:${kind}`),
|
|
737
|
+
) {
|
|
738
|
+
await client.postMessage({
|
|
739
|
+
operationId,
|
|
740
|
+
channelId: interaction.slackChannelId,
|
|
741
|
+
threadTimestamp: interaction.slackThreadTs,
|
|
742
|
+
text: boundedOutput(text),
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function enqueueNormalizedSlackInteraction(
|
|
747
|
+
deps: ApiRouteDeps,
|
|
748
|
+
route: SlackInstallationRoute,
|
|
749
|
+
entry: NormalizedSlackInteraction,
|
|
750
|
+
) {
|
|
751
|
+
await enqueueSlackInteractionInbox(deps.db, {
|
|
752
|
+
accountId: route.accountId,
|
|
753
|
+
workspaceId: route.workspaceId,
|
|
754
|
+
connectionId: route.connectionId,
|
|
755
|
+
...entry,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function readSignedSlackRequest(c: Context, deps: ApiRouteDeps) {
|
|
760
|
+
const signingSecret = deps.settings.slackSigningSecret;
|
|
761
|
+
if (!signingSecret)
|
|
762
|
+
throw new HTTPException(503, {
|
|
763
|
+
message: "Slack interactions are disabled",
|
|
764
|
+
});
|
|
765
|
+
const rawBytes = new Uint8Array(await c.req.raw.arrayBuffer());
|
|
766
|
+
if (rawBytes.byteLength === 0 || rawBytes.byteLength > SLACK_INTERACTION_MAX_BODY_BYTES) {
|
|
767
|
+
throw new HTTPException(rawBytes.byteLength > SLACK_INTERACTION_MAX_BODY_BYTES ? 413 : 400, {
|
|
768
|
+
message: "invalid Slack request body",
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
const rawBody = new TextDecoder("utf-8", { fatal: true }).decode(rawBytes);
|
|
772
|
+
if (
|
|
773
|
+
!verifySlackRequestSignature(
|
|
774
|
+
{
|
|
775
|
+
timestamp: c.req.header("x-slack-request-timestamp") ?? null,
|
|
776
|
+
signature: c.req.header("x-slack-signature") ?? null,
|
|
777
|
+
rawBody,
|
|
778
|
+
},
|
|
779
|
+
signingSecret,
|
|
780
|
+
)
|
|
781
|
+
) {
|
|
782
|
+
throw new HTTPException(401, { message: "invalid Slack signature" });
|
|
783
|
+
}
|
|
784
|
+
return { rawBody };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function normalizedFormInteraction(
|
|
788
|
+
form: URLSearchParams,
|
|
789
|
+
triggerKind: "slash_command",
|
|
790
|
+
): NormalizedSlackInteraction {
|
|
791
|
+
const teamId = boundedString(form.get("team_id"), 64);
|
|
792
|
+
const userId = boundedString(form.get("user_id"), 64);
|
|
793
|
+
const channelId = boundedString(form.get("channel_id"), 64);
|
|
794
|
+
const triggerId = boundedString(form.get("trigger_id"), 256);
|
|
795
|
+
const text = boundedText(form.get("text"));
|
|
796
|
+
if (!teamId || !userId || !channelId || !triggerId || !text) {
|
|
797
|
+
throw new HTTPException(400, { message: "invalid Slack command payload" });
|
|
798
|
+
}
|
|
799
|
+
return {
|
|
800
|
+
providerEventId: `command:${triggerId}`,
|
|
801
|
+
providerMessageId: `command:${triggerId}`,
|
|
802
|
+
slackTeamId: teamId,
|
|
803
|
+
slackUserId: userId,
|
|
804
|
+
slackChannelId: channelId,
|
|
805
|
+
slackMessageTs: triggerId.slice(0, 64),
|
|
806
|
+
slackThreadTs: null,
|
|
807
|
+
triggerKind,
|
|
808
|
+
text,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function humanInputResponse(questions: HumanInputQuestion[], text: string) {
|
|
813
|
+
if (questions.length !== 1) return null;
|
|
814
|
+
const question = questions[0]!;
|
|
815
|
+
if (question.kind === "text") {
|
|
816
|
+
return {
|
|
817
|
+
outcome: "answered" as const,
|
|
818
|
+
answers: [{ questionId: question.id, values: [text] }],
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
const normalized = text.trim().toLowerCase();
|
|
822
|
+
const matches = question.options.filter(
|
|
823
|
+
(option) => option.id.toLowerCase() === normalized || option.label.toLowerCase() === normalized,
|
|
824
|
+
);
|
|
825
|
+
if (matches.length !== 1) return null;
|
|
826
|
+
return {
|
|
827
|
+
outcome: "answered" as const,
|
|
828
|
+
answers: [{ questionId: question.id, values: [matches[0]!.id] }],
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function formatQuestions(questions: HumanInputQuestion[]) {
|
|
833
|
+
return questions
|
|
834
|
+
.slice(0, 5)
|
|
835
|
+
.map((question, index) => {
|
|
836
|
+
const options = question.options
|
|
837
|
+
.slice(0, 10)
|
|
838
|
+
.map((option) => option.label)
|
|
839
|
+
.join(", ");
|
|
840
|
+
return `${index + 1}. ${boundedOutput(question.prompt)}${options ? ` (${options})` : ""}`;
|
|
841
|
+
})
|
|
842
|
+
.join("\n");
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function openSessionText(deps: ApiRouteDeps, workspaceId: string, sessionId: string) {
|
|
846
|
+
const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
|
|
847
|
+
return base
|
|
848
|
+
? `Open in OpenGeni: ${new URL(`/workspaces/${workspaceId}/sessions/${sessionId}`, base).toString()}`
|
|
849
|
+
: "Open this session in OpenGeni";
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function linkUrl(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
|
|
853
|
+
const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
|
|
854
|
+
const signingSecret = deps.settings.slackSigningSecret;
|
|
855
|
+
if (!base || !signingSecret) return "OpenGeni Settings → Integrations → Slack";
|
|
856
|
+
const url = new URL(`/workspaces/${entry.workspaceId}/capabilities`, base);
|
|
857
|
+
url.searchParams.set("slack_link", createSlackUserLinkToken(signingSecret, entry));
|
|
858
|
+
return url.toString();
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
type SlackUserLinkToken = {
|
|
862
|
+
workspaceId: string;
|
|
863
|
+
connectionId: string;
|
|
864
|
+
slackTeamId: string;
|
|
865
|
+
slackUserId: string;
|
|
866
|
+
expiresAt: number;
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
export function createSlackUserLinkToken(
|
|
870
|
+
signingSecret: string,
|
|
871
|
+
entry: Pick<
|
|
872
|
+
SlackInteractionInboxEntry,
|
|
873
|
+
"workspaceId" | "connectionId" | "slackTeamId" | "slackUserId"
|
|
874
|
+
>,
|
|
875
|
+
nowMs = Date.now(),
|
|
876
|
+
) {
|
|
877
|
+
const payload = Buffer.from(
|
|
878
|
+
JSON.stringify({
|
|
879
|
+
workspaceId: entry.workspaceId,
|
|
880
|
+
connectionId: entry.connectionId,
|
|
881
|
+
slackTeamId: entry.slackTeamId,
|
|
882
|
+
slackUserId: entry.slackUserId,
|
|
883
|
+
expiresAt: nowMs + SLACK_USER_LINK_TTL_MS,
|
|
884
|
+
} satisfies SlackUserLinkToken),
|
|
885
|
+
"utf8",
|
|
886
|
+
).toString("base64url");
|
|
887
|
+
const signature = createHmac("sha256", signingSecret).update(payload).digest("base64url");
|
|
888
|
+
return `${payload}.${signature}`;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export function verifySlackUserLinkToken(
|
|
892
|
+
signingSecret: string,
|
|
893
|
+
token: string,
|
|
894
|
+
nowMs = Date.now(),
|
|
895
|
+
): SlackUserLinkToken | null {
|
|
896
|
+
const [payload, signature, extra] = token.split(".");
|
|
897
|
+
if (!payload || !signature || extra || payload.length > 1_500 || signature.length > 128)
|
|
898
|
+
return null;
|
|
899
|
+
const expected = createHmac("sha256", signingSecret).update(payload).digest("base64url");
|
|
900
|
+
const actualBytes = Buffer.from(signature, "utf8");
|
|
901
|
+
const expectedBytes = Buffer.from(expected, "utf8");
|
|
902
|
+
if (actualBytes.length !== expectedBytes.length || !timingSafeEqual(actualBytes, expectedBytes)) {
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
try {
|
|
906
|
+
const value = record(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
|
|
907
|
+
const workspaceId = boundedString(value?.workspaceId, 64);
|
|
908
|
+
const connectionId = boundedString(value?.connectionId, 64);
|
|
909
|
+
const slackTeamId = boundedString(value?.slackTeamId, 64);
|
|
910
|
+
const slackUserId = boundedString(value?.slackUserId, 64);
|
|
911
|
+
const expiresAt = value?.expiresAt;
|
|
912
|
+
if (
|
|
913
|
+
!workspaceId ||
|
|
914
|
+
!connectionId ||
|
|
915
|
+
!slackTeamId ||
|
|
916
|
+
!slackUserId ||
|
|
917
|
+
typeof expiresAt !== "number" ||
|
|
918
|
+
!Number.isSafeInteger(expiresAt) ||
|
|
919
|
+
expiresAt < nowMs ||
|
|
920
|
+
expiresAt > nowMs + SLACK_USER_LINK_TTL_MS
|
|
921
|
+
) {
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
return { workspaceId, connectionId, slackTeamId, slackUserId, expiresAt };
|
|
925
|
+
} catch {
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function slackRouteKey(channelId: string, threadTs: string) {
|
|
931
|
+
return `${channelId}:${threadTs}`;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function deterministicUuid(value: string) {
|
|
935
|
+
const bytes = createHash("sha256").update(value).digest().subarray(0, 16);
|
|
936
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x50;
|
|
937
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
938
|
+
const hex = bytes.toString("hex");
|
|
939
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function parseJsonObject(value: string): Record<string, unknown> {
|
|
943
|
+
try {
|
|
944
|
+
const parsed = JSON.parse(value);
|
|
945
|
+
const result = record(parsed);
|
|
946
|
+
if (result) return result;
|
|
947
|
+
} catch {
|
|
948
|
+
// normalized below
|
|
949
|
+
}
|
|
950
|
+
throw new HTTPException(400, { message: "invalid Slack JSON payload" });
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
954
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
955
|
+
? (value as Record<string, unknown>)
|
|
956
|
+
: null;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function boundedString(value: unknown, max: number): string | null {
|
|
960
|
+
return typeof value === "string" && value.length > 0 && Buffer.byteLength(value) <= max
|
|
961
|
+
? value
|
|
962
|
+
: null;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function boundedText(value: unknown): string | null {
|
|
966
|
+
if (typeof value !== "string") return null;
|
|
967
|
+
const trimmed = value.trim();
|
|
968
|
+
if (!trimmed) return null;
|
|
969
|
+
return trimmed.slice(0, MAX_SLACK_INPUT_CHARS);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function boundedOutput(value: string) {
|
|
973
|
+
return value.length <= MAX_SLACK_TEXT_CHARS
|
|
974
|
+
? value
|
|
975
|
+
: `${value.slice(0, MAX_SLACK_TEXT_CHARS - 20)}\n… output truncated`;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function safePayloadText(payload: unknown, field: string) {
|
|
979
|
+
const value = record(payload)?.[field];
|
|
980
|
+
return typeof value === "string" ? boundedOutput(value) : "";
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function safeErrorCode(error: unknown) {
|
|
984
|
+
if (error instanceof SlackBotProviderError) return error.code.slice(0, 128);
|
|
985
|
+
const raw = error instanceof Error ? error.name : "slack_interaction_error";
|
|
986
|
+
return (
|
|
987
|
+
raw
|
|
988
|
+
.toLowerCase()
|
|
989
|
+
.replace(/[^a-z0-9_-]/g, "_")
|
|
990
|
+
.slice(0, 128) || "error"
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
class SlackInteractionPermanentError extends Error {}
|
|
995
|
+
|
|
996
|
+
function permanentSlackInteractionError(error: unknown) {
|
|
997
|
+
return error instanceof SlackInteractionPermanentError || error instanceof HTTPException;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const PERMANENT_SLACK_DELIVERY_CODES = new Set([
|
|
1001
|
+
"account_inactive",
|
|
1002
|
+
"cannot_reply_to_message",
|
|
1003
|
+
"channel_not_found",
|
|
1004
|
+
"invalid_auth",
|
|
1005
|
+
"invalid_ts",
|
|
1006
|
+
"is_archived",
|
|
1007
|
+
"message_not_found",
|
|
1008
|
+
"not_authed",
|
|
1009
|
+
"not_in_channel",
|
|
1010
|
+
"token_expired",
|
|
1011
|
+
"token_revoked",
|
|
1012
|
+
]);
|
|
1013
|
+
|
|
1014
|
+
function permanentSlackDeliveryError(error: unknown) {
|
|
1015
|
+
if (!(error instanceof SlackBotProviderError)) return false;
|
|
1016
|
+
if (PERMANENT_SLACK_DELIVERY_CODES.has(error.code)) return true;
|
|
1017
|
+
const status = /^http_(\d{3})$/.exec(error.code)?.[1];
|
|
1018
|
+
return status
|
|
1019
|
+
? Number(status) >= 400 && Number(status) < 500 && status !== "408" && status !== "429"
|
|
1020
|
+
: false;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function slackDeliveryRetryMs(error: unknown, attemptCount: number) {
|
|
1024
|
+
if (error instanceof SlackBotProviderError && error.retryAfterMs) {
|
|
1025
|
+
return error.retryAfterMs;
|
|
1026
|
+
}
|
|
1027
|
+
return Math.min(1_000 * 2 ** Math.max(0, attemptCount - 1), MAX_DELIVERY_RETRY_MS);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function slackDeliveryErrorCode(error: unknown) {
|
|
1031
|
+
if (error instanceof SlackBotProviderError) return error.code.slice(0, 128);
|
|
1032
|
+
return safeErrorCode(error);
|
|
1033
|
+
}
|