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