@opengeni/api-router 0.12.12 → 0.14.3
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 +18 -38
- package/dist/app.js +3 -1
- package/dist/auth/managed-auth.d.ts +35 -0
- package/dist/{chunk-5TULDPWX.js → chunk-36PW33ND.js} +4111 -1277
- package/dist/chunk-36PW33ND.js.map +1 -0
- package/dist/codex-redemption-security.d.ts +15 -0
- package/dist/github-access.d.ts +12 -0
- package/dist/github-browser-flow.d.ts +14 -0
- package/dist/http/auth.d.ts +3 -0
- package/dist/http/common.d.ts +3 -0
- package/dist/http/sse.d.ts +70 -0
- package/dist/index.d.ts +11 -13
- package/dist/index.js +1 -1
- package/dist/integrations/google-drive.d.ts +76 -0
- package/dist/integrations/oauth-client.d.ts +42 -0
- package/dist/integrations/provider-domain.d.ts +7 -0
- package/dist/integrations/slack-bot.d.ts +343 -0
- package/dist/mcp/documents.d.ts +8 -0
- package/dist/mcp/files.d.ts +5 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/session-view.d.ts +191 -0
- package/dist/mcp/toolspace.d.ts +61 -0
- package/dist/model-catalog.d.ts +39 -0
- package/dist/observability.d.ts +3 -0
- package/dist/routes/api-keys.d.ts +3 -0
- package/dist/routes/billing.d.ts +16 -0
- package/dist/routes/capabilities.d.ts +3 -0
- package/dist/routes/catalog-assets.d.ts +5 -0
- package/dist/routes/codex.d.ts +10 -0
- package/dist/routes/connections.d.ts +3 -0
- package/dist/routes/documents.d.ts +3 -0
- package/dist/routes/enrollments.d.ts +3 -0
- package/dist/routes/environments.d.ts +5 -0
- package/dist/routes/files.d.ts +4 -0
- package/dist/routes/github.d.ts +3 -0
- package/dist/routes/insights.d.ts +3 -0
- package/dist/routes/install.d.ts +5 -0
- package/dist/routes/machines.d.ts +3 -0
- package/dist/routes/packs.d.ts +3 -0
- package/dist/routes/preference-registry.d.ts +5 -0
- package/dist/routes/rigs.d.ts +3 -0
- package/dist/routes/scheduled-tasks.d.ts +3 -0
- package/dist/routes/sessions.d.ts +15 -0
- package/dist/routes/social.d.ts +3 -0
- package/dist/routes/transcriptions.d.ts +3 -0
- package/dist/routes/workspace-capture.d.ts +59 -0
- package/dist/routes/workspace-instruction-policies.d.ts +3 -0
- package/dist/routes/workspace-state.d.ts +3 -0
- package/dist/routes/workspaces.d.ts +5 -0
- package/dist/sandbox/access.d.ts +21 -0
- package/dist/sandbox/auth-callout.d.ts +30 -0
- package/dist/sandbox/channel-a.d.ts +37 -0
- package/dist/sandbox/enrollment.d.ts +120 -0
- package/dist/sandbox/machines.d.ts +29 -0
- package/dist/sandbox/metrics-ingestion.d.ts +128 -0
- package/dist/sandbox/rematerialize.d.ts +24 -0
- package/dist/sandbox/viewer.d.ts +251 -0
- package/dist/transcription/providers/azure-openai.d.ts +9 -0
- package/dist/transcription/providers/codex-subscription.d.ts +9 -0
- package/dist/transcription/providers/openai.d.ts +9 -0
- package/dist/transcription/service.d.ts +10 -0
- package/dist/workspace-state-projection.d.ts +16 -0
- package/package.json +13 -13
- package/src/app.ts +67 -5
- package/src/integrations/google-drive.ts +869 -0
- package/src/integrations/oauth-client.ts +41 -6
- package/src/integrations/slack-bot.ts +756 -26
- package/src/mcp/server.ts +200 -3
- package/src/mcp/session-view.ts +1 -0
- package/src/mcp/toolspace.ts +17 -9
- package/src/routes/connections.ts +137 -7
- package/src/routes/documents.ts +13 -2
- package/src/routes/github.ts +7 -5
- package/src/routes/insights.ts +30 -0
- package/src/routes/preference-registry.ts +482 -0
- package/src/routes/sessions.ts +10 -0
- package/src/routes/transcriptions.ts +155 -0
- package/src/routes/workspace-state.ts +61 -0
- package/src/sandbox/channel-a.ts +2 -0
- package/src/sandbox/rematerialize.ts +111 -4
- package/src/sandbox/viewer.ts +29 -12
- package/src/transcription/providers/azure-openai.ts +45 -0
- package/src/transcription/providers/codex-subscription.ts +100 -0
- package/src/transcription/providers/openai.ts +93 -0
- package/src/transcription/service.ts +121 -0
- package/src/workspace-state-projection.ts +244 -0
- package/dist/chunk-5TULDPWX.js.map +0 -1
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { createHmac } from "node:crypto";
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
|
|
3
3
|
import {
|
|
4
4
|
OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
|
|
5
5
|
OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
|
|
6
|
-
OPENGENI_SLACK_BOT_FORBIDDEN_SCOPES,
|
|
7
6
|
OPENGENI_SLACK_BOT_REQUIRED_SCOPES,
|
|
7
|
+
evaluateOpenGeniSlackBotScopes,
|
|
8
8
|
type AccessGrant,
|
|
9
9
|
type ConnectionMetadata,
|
|
10
10
|
type OpenGeniSlackBotConnectionMetadata,
|
|
11
11
|
} from "@opengeni/contracts";
|
|
12
12
|
import {
|
|
13
|
+
isOpenGeniSlackBotConnection,
|
|
13
14
|
isTrustedScheduledSlackBotSession,
|
|
14
15
|
openGeniSlackBotMetadata,
|
|
15
16
|
requireOpenGeniSlackBotConnection,
|
|
@@ -17,28 +18,51 @@ import {
|
|
|
17
18
|
} from "@opengeni/core";
|
|
18
19
|
import {
|
|
19
20
|
buildConnectionTokenResolver,
|
|
21
|
+
claimSlackBotDeleteOperation,
|
|
20
22
|
claimSlackBotPostOperation,
|
|
23
|
+
completeSlackBotDeleteOperation,
|
|
21
24
|
completeSlackBotPostOperation,
|
|
22
25
|
getSession,
|
|
26
|
+
listConnectionsMetadata,
|
|
27
|
+
markSlackBotDeleteOperationProviderStarted,
|
|
23
28
|
recordAuditEvent,
|
|
29
|
+
releaseSlackBotDeleteOperationClaim,
|
|
24
30
|
releaseSlackBotPostOperationClaim,
|
|
25
31
|
setConnectionStatus,
|
|
26
32
|
type Database,
|
|
27
33
|
} from "@opengeni/db";
|
|
28
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
readResponseBodyBounded,
|
|
36
|
+
readResponseJsonBounded,
|
|
37
|
+
type FetchLike,
|
|
38
|
+
} from "@opengeni/network";
|
|
29
39
|
import { HTTPException } from "hono/http-exception";
|
|
30
40
|
|
|
31
41
|
const SLACK_API_BASE = "https://slack.com/api/";
|
|
32
42
|
const SLACK_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
|
|
43
|
+
const SLACK_FILE_RESPONSE_MAX_BYTES = 4 * 1024 * 1024;
|
|
44
|
+
const SLACK_FILE_CONTENT_PAGE_CHARS = 50_000;
|
|
33
45
|
const SLACK_TIMEOUT_MS = 10_000;
|
|
34
46
|
const MAX_CHANNEL_PAGE = 200;
|
|
35
47
|
const MAX_HISTORY_PAGE = 100;
|
|
48
|
+
const MAX_THREAD_PAGE = 100;
|
|
36
49
|
const MAX_USER_PAGE = 200;
|
|
50
|
+
const MAX_FILE_PAGE = 200;
|
|
51
|
+
const MAX_FILE_CURSOR_LENGTH = 1_024;
|
|
52
|
+
const SLACK_FILE_CURSOR_VERSION = "files-v1";
|
|
37
53
|
const MAX_PROJECTED_TEXT = 4_000;
|
|
38
54
|
const SLACK_POST_CLAIM_LEASE_MS = 30_000;
|
|
55
|
+
const SLACK_DELETE_CLAIM_LEASE_MS = 30_000;
|
|
39
56
|
|
|
40
57
|
type SlackPayload = Record<string, unknown> & { ok?: unknown; error?: unknown };
|
|
41
58
|
|
|
59
|
+
export type SlackFilesListPage = { count: number; page: number };
|
|
60
|
+
|
|
61
|
+
export type SlackFilesCursorContext = {
|
|
62
|
+
connectionId: string;
|
|
63
|
+
key: Uint8Array;
|
|
64
|
+
};
|
|
65
|
+
|
|
42
66
|
export type VerifiedOpenGeniSlackBot = {
|
|
43
67
|
grantedScopes: string[];
|
|
44
68
|
metadata: OpenGeniSlackBotConnectionMetadata;
|
|
@@ -103,7 +127,16 @@ export type SlackBotReceipt = {
|
|
|
103
127
|
clientMessageId?: string;
|
|
104
128
|
};
|
|
105
129
|
|
|
106
|
-
type SlackBotOperation =
|
|
130
|
+
type SlackBotOperation =
|
|
131
|
+
| "channels.list"
|
|
132
|
+
| "channel_history.read"
|
|
133
|
+
| "thread_replies.read"
|
|
134
|
+
| "users.list"
|
|
135
|
+
| "files.list"
|
|
136
|
+
| "file.info"
|
|
137
|
+
| "file.content.read"
|
|
138
|
+
| "message.post"
|
|
139
|
+
| "message.delete";
|
|
107
140
|
|
|
108
141
|
type SlackBotContext = {
|
|
109
142
|
accountId: string;
|
|
@@ -157,7 +190,7 @@ export async function verifyOpenGeniSlackBotCredential(
|
|
|
157
190
|
): Promise<VerifiedOpenGeniSlackBot> {
|
|
158
191
|
const authResponse = await slackApiFetch(fetchImpl, "auth.test", token, {});
|
|
159
192
|
const grantedScopes = parseGrantedScopes(authResponse.response.headers.get("x-oauth-scopes"));
|
|
160
|
-
|
|
193
|
+
assertOpenGeniSlackBotScopes(grantedScopes);
|
|
161
194
|
const auth = authResponse.payload;
|
|
162
195
|
const slackTeamId = requiredSlackString(auth.team_id, "team_id");
|
|
163
196
|
const slackTeamName = requiredSlackString(auth.team, "team");
|
|
@@ -225,13 +258,34 @@ export async function resolveSlackBotConnectionForTool(input: {
|
|
|
225
258
|
) {
|
|
226
259
|
throw new Error("this scheduled session is bound to a different OpenGeni Slack bot connection");
|
|
227
260
|
}
|
|
228
|
-
const connectionId = boundConnectionId ?? input.requestedConnectionId;
|
|
229
|
-
if (!connectionId) {
|
|
230
|
-
throw new Error("connectionId is required outside a Slack-bot-bound scheduled session");
|
|
231
|
-
}
|
|
232
261
|
if (!boundConnectionId && !input.grant.permissions.includes("connections:read")) {
|
|
233
262
|
throw new Error("connections:read is required to select an OpenGeni Slack bot connection");
|
|
234
263
|
}
|
|
264
|
+
let connectionId = boundConnectionId ?? input.requestedConnectionId;
|
|
265
|
+
if (!connectionId) {
|
|
266
|
+
const activeConnections = (
|
|
267
|
+
await listConnectionsMetadata(input.db, input.grant.workspaceId, null)
|
|
268
|
+
).filter(
|
|
269
|
+
(connection) => connection.status === "active" && isOpenGeniSlackBotConnection(connection),
|
|
270
|
+
);
|
|
271
|
+
if (activeConnections.length === 0) {
|
|
272
|
+
throw new Error("no active OpenGeni Slack bot connection is installed in this workspace");
|
|
273
|
+
}
|
|
274
|
+
if (activeConnections.length > 1) {
|
|
275
|
+
const principals = new Set(
|
|
276
|
+
activeConnections.map((candidate) => {
|
|
277
|
+
const metadata = openGeniSlackBotMetadata(candidate.metadata)!;
|
|
278
|
+
return `${metadata.slackTeamId}:${metadata.botId}:${metadata.botUserId}`;
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
if (principals.size > 1) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
"connectionId is required because this workspace has multiple active OpenGeni Slack bot connections",
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
connectionId = activeConnections[0]!.id;
|
|
288
|
+
}
|
|
235
289
|
const connection = await requireOpenGeniSlackBotConnection(
|
|
236
290
|
input.db,
|
|
237
291
|
input.grant.workspaceId,
|
|
@@ -304,6 +358,29 @@ export class OpenGeniSlackBotClient {
|
|
|
304
358
|
});
|
|
305
359
|
}
|
|
306
360
|
|
|
361
|
+
async threadReplies(input: {
|
|
362
|
+
channelId: string;
|
|
363
|
+
threadTimestamp: string;
|
|
364
|
+
limit?: number;
|
|
365
|
+
cursor?: string;
|
|
366
|
+
}) {
|
|
367
|
+
return await this.withAudit("thread_replies.read", async (headers) => {
|
|
368
|
+
const info = await this.requireMemberChannel(headers, input.channelId);
|
|
369
|
+
const payload = await this.call(headers, "conversations.replies", {
|
|
370
|
+
channel: input.channelId,
|
|
371
|
+
ts: input.threadTimestamp,
|
|
372
|
+
limit: String(boundedInt(input.limit, MAX_THREAD_PAGE, 50)),
|
|
373
|
+
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
374
|
+
});
|
|
375
|
+
return {
|
|
376
|
+
channel: info,
|
|
377
|
+
threadTimestamp: input.threadTimestamp,
|
|
378
|
+
messages: slackArray(payload.messages).map(projectMessage),
|
|
379
|
+
nextCursor: responseCursor(payload),
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
307
384
|
async listUsers(input: { limit?: number; cursor?: string } = {}) {
|
|
308
385
|
return await this.withAudit("users.list", async (headers) => {
|
|
309
386
|
const payload = await this.call(headers, "users.list", {
|
|
@@ -319,10 +396,84 @@ export class OpenGeniSlackBotClient {
|
|
|
319
396
|
});
|
|
320
397
|
}
|
|
321
398
|
|
|
399
|
+
async listFiles(input: { channelId: string; limit?: number; cursor?: string }) {
|
|
400
|
+
return await this.withAudit("files.list", async (headers) => {
|
|
401
|
+
const requestedPage = this.fileListPage(input);
|
|
402
|
+
const info = await this.requireMemberChannel(headers, input.channelId);
|
|
403
|
+
const payload = await this.call(headers, "files.list", {
|
|
404
|
+
channel: input.channelId,
|
|
405
|
+
count: String(requestedPage.count),
|
|
406
|
+
page: String(requestedPage.page),
|
|
407
|
+
});
|
|
408
|
+
const files = slackArray(payload.files)
|
|
409
|
+
.map(projectFile)
|
|
410
|
+
.filter((file): file is NonNullable<typeof file> => file !== null);
|
|
411
|
+
const nextPage = nextSlackFilesListPage(payload, requestedPage, files.length);
|
|
412
|
+
return {
|
|
413
|
+
channel: info,
|
|
414
|
+
files,
|
|
415
|
+
nextCursor:
|
|
416
|
+
nextPage === null
|
|
417
|
+
? null
|
|
418
|
+
: this.fileListCursor({
|
|
419
|
+
channelId: input.channelId,
|
|
420
|
+
count: requestedPage.count,
|
|
421
|
+
page: nextPage,
|
|
422
|
+
}),
|
|
423
|
+
};
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async fileInfo(input: { channelId: string; fileId: string; parentFileId?: string }) {
|
|
428
|
+
return await this.withAudit("file.info", async (headers) => {
|
|
429
|
+
const info = await this.requireMemberChannel(headers, input.channelId);
|
|
430
|
+
const { file } = await this.requireFileForChannel(headers, "file.info", input);
|
|
431
|
+
return { channel: info, file };
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async fileContent(input: {
|
|
436
|
+
channelId: string;
|
|
437
|
+
fileId: string;
|
|
438
|
+
parentFileId?: string;
|
|
439
|
+
offset?: number;
|
|
440
|
+
}) {
|
|
441
|
+
return await this.withAudit("file.content.read", async (headers) => {
|
|
442
|
+
const info = await this.requireMemberChannel(headers, input.channelId);
|
|
443
|
+
const { fileRecord, file, parentFileRecord } = await this.requireFileForChannel(
|
|
444
|
+
headers,
|
|
445
|
+
"file.content.read",
|
|
446
|
+
input,
|
|
447
|
+
);
|
|
448
|
+
const embeddedTranscript = embeddedHuddleTranscription(fileRecord, parentFileRecord);
|
|
449
|
+
const { contentType, content } =
|
|
450
|
+
embeddedTranscript ?? (await this.readPrivateFileText(fileRecord, "file.content.read"));
|
|
451
|
+
const offset =
|
|
452
|
+
typeof input.offset === "number" && Number.isInteger(input.offset) && input.offset >= 0
|
|
453
|
+
? input.offset
|
|
454
|
+
: 0;
|
|
455
|
+
if (offset > content.length) {
|
|
456
|
+
throw new SlackBotProviderError("invalid_file_offset");
|
|
457
|
+
}
|
|
458
|
+
const page = content.slice(offset, offset + SLACK_FILE_CONTENT_PAGE_CHARS);
|
|
459
|
+
const nextOffset = offset + page.length < content.length ? offset + page.length : null;
|
|
460
|
+
return {
|
|
461
|
+
channel: info,
|
|
462
|
+
file,
|
|
463
|
+
contentType,
|
|
464
|
+
offset,
|
|
465
|
+
content: page,
|
|
466
|
+
nextOffset,
|
|
467
|
+
truncated: nextOffset !== null,
|
|
468
|
+
};
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
322
472
|
async postMessage(input: {
|
|
323
473
|
operationId: string;
|
|
324
474
|
channelId?: string;
|
|
325
475
|
userId?: string;
|
|
476
|
+
threadTimestamp?: string;
|
|
326
477
|
text: string;
|
|
327
478
|
}) {
|
|
328
479
|
const operation = "message.post" as const;
|
|
@@ -347,6 +498,7 @@ export class OpenGeniSlackBotClient {
|
|
|
347
498
|
operationId: input.operationId,
|
|
348
499
|
targetKind,
|
|
349
500
|
targetId,
|
|
501
|
+
...(input.threadTimestamp ? { threadTimestamp: input.threadTimestamp } : {}),
|
|
350
502
|
text: input.text,
|
|
351
503
|
});
|
|
352
504
|
const claim = await claimSlackBotPostOperation(this.db, {
|
|
@@ -370,7 +522,7 @@ export class OpenGeniSlackBotClient {
|
|
|
370
522
|
throw new Error("Slack post operation is already in progress; retry the same operationId");
|
|
371
523
|
}
|
|
372
524
|
if (claim.kind === "completed") {
|
|
373
|
-
return this.completedPostResult(claim.operation, input.operationId);
|
|
525
|
+
return this.completedPostResult(claim.operation, input.operationId, input.threadTimestamp);
|
|
374
526
|
}
|
|
375
527
|
claimAcquired = true;
|
|
376
528
|
providerCallStarted = true;
|
|
@@ -378,6 +530,7 @@ export class OpenGeniSlackBotClient {
|
|
|
378
530
|
channel: channelId,
|
|
379
531
|
text: input.text,
|
|
380
532
|
client_msg_id: input.operationId,
|
|
533
|
+
...(input.threadTimestamp ? { thread_ts: input.threadTimestamp } : {}),
|
|
381
534
|
});
|
|
382
535
|
const slackChannelId = requiredSlackString(posted.channel, "channel");
|
|
383
536
|
const slackMessageTimestamp = requiredSlackString(posted.ts, "ts");
|
|
@@ -396,7 +549,11 @@ export class OpenGeniSlackBotClient {
|
|
|
396
549
|
throw new Error("Slack post completion lost its durable operation claim");
|
|
397
550
|
}
|
|
398
551
|
claimAcquired = false;
|
|
399
|
-
return this.completedPostResult(
|
|
552
|
+
return this.completedPostResult(
|
|
553
|
+
completed.operation,
|
|
554
|
+
input.operationId,
|
|
555
|
+
input.threadTimestamp,
|
|
556
|
+
);
|
|
400
557
|
} catch (error) {
|
|
401
558
|
const failureCode = safeFailureCode(error);
|
|
402
559
|
if (claimAcquired) {
|
|
@@ -411,7 +568,129 @@ export class OpenGeniSlackBotClient {
|
|
|
411
568
|
}
|
|
412
569
|
await this.recordAudit(
|
|
413
570
|
operation,
|
|
414
|
-
providerCallStarted &&
|
|
571
|
+
providerCallStarted && slackMutationOutcomeMayBeAmbiguous(error) ? "ambiguous" : "failed",
|
|
572
|
+
failureCode,
|
|
573
|
+
input.operationId,
|
|
574
|
+
);
|
|
575
|
+
throw error;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async deleteMessage(input: { operationId: string; channelId: string; timestamp: string }) {
|
|
580
|
+
const operation = "message.delete" as const;
|
|
581
|
+
const claimHolderId = crypto.randomUUID();
|
|
582
|
+
const principal = this.deletePrincipal();
|
|
583
|
+
const requestDigest = this.deleteRequestDigest(input);
|
|
584
|
+
let claimAcquired = false;
|
|
585
|
+
let providerCallStarted = false;
|
|
586
|
+
let outcomeUnknown = false;
|
|
587
|
+
try {
|
|
588
|
+
const claim = await claimSlackBotDeleteOperation(this.db, {
|
|
589
|
+
accountId: this.context.accountId,
|
|
590
|
+
workspaceId: this.context.workspaceId,
|
|
591
|
+
connectionId: this.connection.id,
|
|
592
|
+
operationId: input.operationId,
|
|
593
|
+
principalType: principal.type,
|
|
594
|
+
principalId: principal.id,
|
|
595
|
+
toolName: "slack_bot_delete_message",
|
|
596
|
+
channelId: input.channelId,
|
|
597
|
+
messageTimestamp: input.timestamp,
|
|
598
|
+
requestDigest,
|
|
599
|
+
claimHolderId,
|
|
600
|
+
claimLeaseMs: SLACK_DELETE_CLAIM_LEASE_MS,
|
|
601
|
+
});
|
|
602
|
+
if (claim.kind === "connection_not_found") {
|
|
603
|
+
throw new Error("OpenGeni Slack bot connection no longer exists");
|
|
604
|
+
}
|
|
605
|
+
if (claim.kind === "conflict") {
|
|
606
|
+
throw new Error("operationId is already bound to a different Slack delete request");
|
|
607
|
+
}
|
|
608
|
+
if (claim.kind === "in_progress") {
|
|
609
|
+
throw new Error(
|
|
610
|
+
"Slack delete operation is already in progress; retry the same operationId",
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
if (claim.kind === "completed") {
|
|
614
|
+
return this.completedDeleteResult(claim.operation, input.operationId);
|
|
615
|
+
}
|
|
616
|
+
claimAcquired = true;
|
|
617
|
+
outcomeUnknown = claim.kind === "reconcile";
|
|
618
|
+
const headers = await this.headersFor(operation);
|
|
619
|
+
await this.requireMemberChannel(headers, input.channelId);
|
|
620
|
+
if (claim.kind === "reconcile") {
|
|
621
|
+
const exists = await this.slackMessageExists(input.channelId, input.timestamp);
|
|
622
|
+
if (!exists) {
|
|
623
|
+
const completed = await completeSlackBotDeleteOperation(this.db, {
|
|
624
|
+
accountId: this.context.accountId,
|
|
625
|
+
workspaceId: this.context.workspaceId,
|
|
626
|
+
connectionId: this.connection.id,
|
|
627
|
+
operationId: input.operationId,
|
|
628
|
+
claimHolderId,
|
|
629
|
+
slackChannelId: input.channelId,
|
|
630
|
+
slackMessageTimestamp: input.timestamp,
|
|
631
|
+
subjectId: this.context.subjectId,
|
|
632
|
+
auditMetadata: this.auditMetadata(operation, "succeeded", undefined, input.operationId),
|
|
633
|
+
});
|
|
634
|
+
if (completed.kind !== "completed") {
|
|
635
|
+
throw new Error("Slack delete reconciliation lost its durable operation claim");
|
|
636
|
+
}
|
|
637
|
+
claimAcquired = false;
|
|
638
|
+
return this.completedDeleteResult(completed.operation, input.operationId);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const providerStarted = await markSlackBotDeleteOperationProviderStarted(this.db, {
|
|
642
|
+
accountId: this.context.accountId,
|
|
643
|
+
workspaceId: this.context.workspaceId,
|
|
644
|
+
connectionId: this.connection.id,
|
|
645
|
+
operationId: input.operationId,
|
|
646
|
+
claimHolderId,
|
|
647
|
+
});
|
|
648
|
+
if (!providerStarted) {
|
|
649
|
+
throw new Error("Slack delete operation lost its durable claim before provider call");
|
|
650
|
+
}
|
|
651
|
+
providerCallStarted = true;
|
|
652
|
+
const deleted = await this.call(headers, "chat.delete", {
|
|
653
|
+
channel: input.channelId,
|
|
654
|
+
ts: input.timestamp,
|
|
655
|
+
}).catch((error) => {
|
|
656
|
+
if (error instanceof SlackBotProviderError && error.code === "message_not_found") {
|
|
657
|
+
return { ok: true, channel: input.channelId, ts: input.timestamp };
|
|
658
|
+
}
|
|
659
|
+
throw error;
|
|
660
|
+
});
|
|
661
|
+
const completed = await completeSlackBotDeleteOperation(this.db, {
|
|
662
|
+
accountId: this.context.accountId,
|
|
663
|
+
workspaceId: this.context.workspaceId,
|
|
664
|
+
connectionId: this.connection.id,
|
|
665
|
+
operationId: input.operationId,
|
|
666
|
+
claimHolderId,
|
|
667
|
+
slackChannelId: requiredSlackString(deleted.channel, "channel"),
|
|
668
|
+
slackMessageTimestamp: requiredSlackString(deleted.ts, "ts"),
|
|
669
|
+
subjectId: this.context.subjectId,
|
|
670
|
+
auditMetadata: this.auditMetadata(operation, "succeeded", undefined, input.operationId),
|
|
671
|
+
});
|
|
672
|
+
if (completed.kind !== "completed") {
|
|
673
|
+
throw new Error("Slack delete completion lost its durable operation claim");
|
|
674
|
+
}
|
|
675
|
+
claimAcquired = false;
|
|
676
|
+
return this.completedDeleteResult(completed.operation, input.operationId);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
const failureCode = safeFailureCode(error);
|
|
679
|
+
const ambiguous = providerCallStarted && slackMutationOutcomeMayBeAmbiguous(error);
|
|
680
|
+
if (claimAcquired) {
|
|
681
|
+
await releaseSlackBotDeleteOperationClaim(this.db, {
|
|
682
|
+
accountId: this.context.accountId,
|
|
683
|
+
workspaceId: this.context.workspaceId,
|
|
684
|
+
connectionId: this.connection.id,
|
|
685
|
+
operationId: input.operationId,
|
|
686
|
+
claimHolderId,
|
|
687
|
+
outcomeUnknown: outcomeUnknown || ambiguous,
|
|
688
|
+
failureCode,
|
|
689
|
+
}).catch(() => undefined);
|
|
690
|
+
}
|
|
691
|
+
await this.recordAudit(
|
|
692
|
+
operation,
|
|
693
|
+
ambiguous ? "ambiguous" : "failed",
|
|
415
694
|
failureCode,
|
|
416
695
|
input.operationId,
|
|
417
696
|
);
|
|
@@ -419,6 +698,22 @@ export class OpenGeniSlackBotClient {
|
|
|
419
698
|
}
|
|
420
699
|
}
|
|
421
700
|
|
|
701
|
+
private async slackMessageExists(channelId: string, timestamp: string): Promise<boolean> {
|
|
702
|
+
const headers = await this.headersForDestination(
|
|
703
|
+
"message.delete",
|
|
704
|
+
`${SLACK_API_BASE}chat.getPermalink`,
|
|
705
|
+
);
|
|
706
|
+
try {
|
|
707
|
+
await this.call(headers, "chat.getPermalink", { channel: channelId, message_ts: timestamp });
|
|
708
|
+
return true;
|
|
709
|
+
} catch (error) {
|
|
710
|
+
if (error instanceof SlackBotProviderError && error.code === "message_not_found") {
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
422
717
|
private async requireMemberChannel(headers: Record<string, string>, channelId: string) {
|
|
423
718
|
const payload = await this.call(headers, "conversations.info", { channel: channelId });
|
|
424
719
|
const projected = projectChannel(payload.channel);
|
|
@@ -428,6 +723,83 @@ export class OpenGeniSlackBotClient {
|
|
|
428
723
|
return projected;
|
|
429
724
|
}
|
|
430
725
|
|
|
726
|
+
private async requireFileForChannel(
|
|
727
|
+
headers: Record<string, string>,
|
|
728
|
+
operation: "file.info" | "file.content.read",
|
|
729
|
+
input: { channelId: string; fileId: string; parentFileId?: string },
|
|
730
|
+
) {
|
|
731
|
+
const payload = await this.call(headers, "files.info", {
|
|
732
|
+
file: input.fileId,
|
|
733
|
+
...(operation === "file.content.read" ? { include_transcription: "true" } : {}),
|
|
734
|
+
});
|
|
735
|
+
const fileRecord = slackRecord(payload.file);
|
|
736
|
+
const file = projectFile(fileRecord);
|
|
737
|
+
if (!fileRecord || !file) {
|
|
738
|
+
throw new SlackBotProviderError("file_not_found");
|
|
739
|
+
}
|
|
740
|
+
if (fileIsSharedToChannel(fileRecord, input.channelId)) {
|
|
741
|
+
return { fileRecord, file, parentFileRecord: null };
|
|
742
|
+
}
|
|
743
|
+
if (!input.parentFileId || input.parentFileId === input.fileId) {
|
|
744
|
+
throw new SlackBotProviderError("file_not_found");
|
|
745
|
+
}
|
|
746
|
+
const parentPayload = await this.call(headers, "files.info", {
|
|
747
|
+
file: input.parentFileId,
|
|
748
|
+
});
|
|
749
|
+
const parentRecord = slackRecord(parentPayload.file);
|
|
750
|
+
if (!parentRecord || !fileIsSharedToChannel(parentRecord, input.channelId)) {
|
|
751
|
+
throw new SlackBotProviderError("file_not_found");
|
|
752
|
+
}
|
|
753
|
+
if (parentReferencesSlackFile(parentRecord, input.fileId)) {
|
|
754
|
+
return { fileRecord, file, parentFileRecord: parentRecord };
|
|
755
|
+
}
|
|
756
|
+
const parent = await this.readPrivateFileText(parentRecord, operation);
|
|
757
|
+
if (!embeddedSlackFileIds(parent.content).has(input.fileId)) {
|
|
758
|
+
throw new SlackBotProviderError("file_not_found");
|
|
759
|
+
}
|
|
760
|
+
return { fileRecord, file, parentFileRecord: parentRecord };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private async readPrivateFileText(
|
|
764
|
+
fileRecord: Record<string, unknown>,
|
|
765
|
+
operation: "file.info" | "file.content.read",
|
|
766
|
+
): Promise<{ contentType: string; content: string }> {
|
|
767
|
+
const downloadUrl = privateSlackFileUrl(fileRecord);
|
|
768
|
+
if (!downloadUrl) {
|
|
769
|
+
throw new SlackBotProviderError("file_content_unavailable");
|
|
770
|
+
}
|
|
771
|
+
let response: Response;
|
|
772
|
+
try {
|
|
773
|
+
response = await this.fetchPrivateFile(downloadUrl, operation);
|
|
774
|
+
} catch (error) {
|
|
775
|
+
if (
|
|
776
|
+
error instanceof SlackBotProviderError &&
|
|
777
|
+
error.code === "file_requires_user_access" &&
|
|
778
|
+
slackString(fileRecord.mode) === "huddle_transcript"
|
|
779
|
+
) {
|
|
780
|
+
throw new SlackBotProviderError("huddle_transcript_requires_participant_access");
|
|
781
|
+
}
|
|
782
|
+
throw error;
|
|
783
|
+
}
|
|
784
|
+
const contentType = normalizedContentType(response.headers.get("content-type"));
|
|
785
|
+
if (!isSupportedSlackTextContentType(contentType)) {
|
|
786
|
+
await response.body?.cancel().catch(() => undefined);
|
|
787
|
+
throw new SlackBotProviderError("unsupported_file_type");
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
const content = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
791
|
+
await readResponseBodyBounded(
|
|
792
|
+
response,
|
|
793
|
+
SLACK_FILE_RESPONSE_MAX_BYTES,
|
|
794
|
+
"Slack file content",
|
|
795
|
+
),
|
|
796
|
+
);
|
|
797
|
+
return { contentType, content };
|
|
798
|
+
} catch {
|
|
799
|
+
throw new SlackBotProviderError("invalid_file_content");
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
431
803
|
private async call(
|
|
432
804
|
headers: Record<string, string>,
|
|
433
805
|
method: string,
|
|
@@ -463,6 +835,16 @@ export class OpenGeniSlackBotClient {
|
|
|
463
835
|
}
|
|
464
836
|
|
|
465
837
|
private async headersFor(operation: SlackBotOperation): Promise<Record<string, string>> {
|
|
838
|
+
return await this.headersForDestination(
|
|
839
|
+
operation,
|
|
840
|
+
`${SLACK_API_BASE}${slackMethodForOperation(operation)}`,
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
private async headersForDestination(
|
|
845
|
+
operation: SlackBotOperation,
|
|
846
|
+
destinationUrl: string,
|
|
847
|
+
): Promise<Record<string, string>> {
|
|
466
848
|
const result = await this.resolveCredential({
|
|
467
849
|
workspaceId: this.context.workspaceId,
|
|
468
850
|
serverId: "opengeni-slack-bot",
|
|
@@ -474,7 +856,7 @@ export class OpenGeniSlackBotClient {
|
|
|
474
856
|
scopes: [...OPENGENI_SLACK_BOT_REQUIRED_SCOPES],
|
|
475
857
|
subjectScope: "workspace",
|
|
476
858
|
},
|
|
477
|
-
destinationUrl
|
|
859
|
+
destinationUrl,
|
|
478
860
|
});
|
|
479
861
|
if (result.status !== "ok" || result.connectionId !== this.connection.id) {
|
|
480
862
|
throw new Error("OpenGeni Slack bot connection needs to be reinstalled");
|
|
@@ -482,6 +864,56 @@ export class OpenGeniSlackBotClient {
|
|
|
482
864
|
return result.headers;
|
|
483
865
|
}
|
|
484
866
|
|
|
867
|
+
private async fetchPrivateFile(
|
|
868
|
+
url: URL,
|
|
869
|
+
operation: "file.info" | "file.content.read",
|
|
870
|
+
): Promise<Response> {
|
|
871
|
+
const response = await this.fetchPrivateFileOnce(url, operation);
|
|
872
|
+
if (response.status < 300 || response.status >= 400) {
|
|
873
|
+
return response;
|
|
874
|
+
}
|
|
875
|
+
const location = response.headers.get("location");
|
|
876
|
+
await response.body?.cancel().catch(() => undefined);
|
|
877
|
+
if (!location) throw new SlackBotProviderError(`http_${response.status}`);
|
|
878
|
+
let redirected: URL;
|
|
879
|
+
try {
|
|
880
|
+
redirected = new URL(location, url);
|
|
881
|
+
assertPrivateSlackFileUrl(redirected);
|
|
882
|
+
} catch {
|
|
883
|
+
throw new SlackBotProviderError("invalid_file_redirect");
|
|
884
|
+
}
|
|
885
|
+
if (isSlackInteractiveFileRedirect(redirected)) {
|
|
886
|
+
throw new SlackBotProviderError("file_requires_user_access");
|
|
887
|
+
}
|
|
888
|
+
return await this.fetchPrivateFileOnce(redirected, operation);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
private async fetchPrivateFileOnce(
|
|
892
|
+
url: URL,
|
|
893
|
+
operation: "file.info" | "file.content.read",
|
|
894
|
+
): Promise<Response> {
|
|
895
|
+
const headers = await this.headersForDestination(operation, url.toString());
|
|
896
|
+
let response: Response;
|
|
897
|
+
try {
|
|
898
|
+
response = await this.fetchImpl(url, {
|
|
899
|
+
method: "GET",
|
|
900
|
+
headers: {
|
|
901
|
+
...headers,
|
|
902
|
+
accept: "text/*, application/json, application/xml, application/xhtml+xml",
|
|
903
|
+
},
|
|
904
|
+
redirect: "manual",
|
|
905
|
+
signal: AbortSignal.timeout(SLACK_TIMEOUT_MS),
|
|
906
|
+
});
|
|
907
|
+
} catch {
|
|
908
|
+
throw new SlackBotProviderError("transport_error");
|
|
909
|
+
}
|
|
910
|
+
if (!response.ok && (response.status < 300 || response.status >= 400)) {
|
|
911
|
+
await response.body?.cancel().catch(() => undefined);
|
|
912
|
+
throw new SlackBotProviderError(`http_${response.status}`);
|
|
913
|
+
}
|
|
914
|
+
return response;
|
|
915
|
+
}
|
|
916
|
+
|
|
485
917
|
private receipt(operation: SlackBotOperation, operationId?: string): SlackBotReceipt {
|
|
486
918
|
return {
|
|
487
919
|
credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
|
|
@@ -489,7 +921,8 @@ export class OpenGeniSlackBotClient {
|
|
|
489
921
|
connectionId: this.connection.id,
|
|
490
922
|
slackTeamId: this.metadata.slackTeamId,
|
|
491
923
|
operation,
|
|
492
|
-
...(operationId ? { operationId
|
|
924
|
+
...(operationId ? { operationId } : {}),
|
|
925
|
+
...(operation === "message.post" && operationId ? { clientMessageId: operationId } : {}),
|
|
493
926
|
};
|
|
494
927
|
}
|
|
495
928
|
|
|
@@ -499,6 +932,7 @@ export class OpenGeniSlackBotClient {
|
|
|
499
932
|
slackMessageTimestamp: string | null;
|
|
500
933
|
},
|
|
501
934
|
operationId: string,
|
|
935
|
+
threadTimestamp?: string,
|
|
502
936
|
) {
|
|
503
937
|
if (!operation.slackChannelId || !operation.slackMessageTimestamp) {
|
|
504
938
|
throw new Error("completed Slack post operation is missing its provider result");
|
|
@@ -506,14 +940,31 @@ export class OpenGeniSlackBotClient {
|
|
|
506
940
|
return {
|
|
507
941
|
channelId: operation.slackChannelId,
|
|
508
942
|
timestamp: operation.slackMessageTimestamp,
|
|
943
|
+
threadTimestamp: threadTimestamp ?? null,
|
|
509
944
|
receipt: this.receipt("message.post", operationId),
|
|
510
945
|
};
|
|
511
946
|
}
|
|
512
947
|
|
|
948
|
+
private completedDeleteResult(
|
|
949
|
+
operation: { slackChannelId: string | null; slackMessageTimestamp: string | null },
|
|
950
|
+
operationId: string,
|
|
951
|
+
) {
|
|
952
|
+
if (!operation.slackChannelId || !operation.slackMessageTimestamp) {
|
|
953
|
+
throw new Error("completed Slack delete operation is missing its provider result");
|
|
954
|
+
}
|
|
955
|
+
return {
|
|
956
|
+
channelId: operation.slackChannelId,
|
|
957
|
+
timestamp: operation.slackMessageTimestamp,
|
|
958
|
+
deleted: true,
|
|
959
|
+
receipt: this.receipt("message.delete", operationId),
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
513
963
|
private postRequestDigest(input: {
|
|
514
964
|
operationId: string;
|
|
515
965
|
targetKind: "channel" | "user";
|
|
516
966
|
targetId: string;
|
|
967
|
+
threadTimestamp?: string;
|
|
517
968
|
text: string;
|
|
518
969
|
}): string {
|
|
519
970
|
const key = environmentsEncryptionKeyBytes(this.settings);
|
|
@@ -525,12 +976,59 @@ export class OpenGeniSlackBotClient {
|
|
|
525
976
|
connectionId: this.connection.id,
|
|
526
977
|
targetKind: input.targetKind,
|
|
527
978
|
targetId: input.targetId,
|
|
979
|
+
threadTimestamp: input.threadTimestamp ?? null,
|
|
528
980
|
text: input.text,
|
|
529
981
|
}),
|
|
530
982
|
)
|
|
531
983
|
.digest("hex");
|
|
532
984
|
}
|
|
533
985
|
|
|
986
|
+
private deleteRequestDigest(input: {
|
|
987
|
+
operationId: string;
|
|
988
|
+
channelId: string;
|
|
989
|
+
timestamp: string;
|
|
990
|
+
}): string {
|
|
991
|
+
const key = environmentsEncryptionKeyBytes(this.settings);
|
|
992
|
+
if (!key) throw new Error("connection encryption is not configured");
|
|
993
|
+
return createHmac("sha256", key)
|
|
994
|
+
.update(
|
|
995
|
+
JSON.stringify({
|
|
996
|
+
operationId: input.operationId,
|
|
997
|
+
connectionId: this.connection.id,
|
|
998
|
+
toolName: "slack_bot_delete_message",
|
|
999
|
+
channelId: input.channelId,
|
|
1000
|
+
timestamp: input.timestamp,
|
|
1001
|
+
}),
|
|
1002
|
+
)
|
|
1003
|
+
.digest("hex");
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
private deletePrincipal(): { type: "subject" | "service"; id: string } {
|
|
1007
|
+
if (this.context.subjectId) {
|
|
1008
|
+
return { type: "subject", id: this.context.subjectId };
|
|
1009
|
+
}
|
|
1010
|
+
if (this.context.scheduledTaskId) {
|
|
1011
|
+
return { type: "service", id: `scheduler:${this.context.scheduledTaskId}` };
|
|
1012
|
+
}
|
|
1013
|
+
return { type: "service", id: `session:${this.context.sessionId ?? "workspace"}` };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
private fileListPage(input: {
|
|
1017
|
+
channelId: string;
|
|
1018
|
+
limit?: number;
|
|
1019
|
+
cursor?: string;
|
|
1020
|
+
}): SlackFilesListPage {
|
|
1021
|
+
const key = environmentsEncryptionKeyBytes(this.settings);
|
|
1022
|
+
if (!key) throw new Error("connection encryption is not configured");
|
|
1023
|
+
return resolveSlackFilesListPage(input, { connectionId: this.connection.id, key });
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
private fileListCursor(input: { channelId: string; count: number; page: number }): string {
|
|
1027
|
+
const key = environmentsEncryptionKeyBytes(this.settings);
|
|
1028
|
+
if (!key) throw new Error("connection encryption is not configured");
|
|
1029
|
+
return createSlackFilesListCursor(input, { connectionId: this.connection.id, key });
|
|
1030
|
+
}
|
|
1031
|
+
|
|
534
1032
|
private async recordAudit(
|
|
535
1033
|
operation: SlackBotOperation,
|
|
536
1034
|
outcome: "succeeded" | "failed" | "ambiguous",
|
|
@@ -638,21 +1136,16 @@ async function slackApiFetchWithHeaders(
|
|
|
638
1136
|
return { response, payload };
|
|
639
1137
|
}
|
|
640
1138
|
|
|
641
|
-
function
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
const missing = [...required].filter((scope) => !granted.has(scope));
|
|
645
|
-
const forbidden = OPENGENI_SLACK_BOT_FORBIDDEN_SCOPES.filter((scope) => granted.has(scope));
|
|
646
|
-
const unsupported = grantedScopes.filter((scope) => !required.has(scope));
|
|
647
|
-
if (missing.length || forbidden.length || unsupported.length) {
|
|
1139
|
+
function assertOpenGeniSlackBotScopes(grantedScopes: string[]): void {
|
|
1140
|
+
const policy = evaluateOpenGeniSlackBotScopes(grantedScopes);
|
|
1141
|
+
if (!policy.accepted) {
|
|
648
1142
|
const facts = [
|
|
649
|
-
...(
|
|
650
|
-
...(
|
|
651
|
-
...(unsupported.length ? [`unsupported: ${unsupported.join(", ")}`] : []),
|
|
1143
|
+
...(policy.missingRequired.length ? [`missing: ${policy.missingRequired.join(", ")}`] : []),
|
|
1144
|
+
...(policy.unsupported.length ? [`unsupported: ${policy.unsupported.join(", ")}`] : []),
|
|
652
1145
|
];
|
|
653
1146
|
throw new SlackBotCredentialVerificationError(
|
|
654
1147
|
"scope_mismatch",
|
|
655
|
-
`Slack bot scopes
|
|
1148
|
+
`Slack bot scopes do not satisfy the OpenGeni manifest (${facts.join("; ")})`,
|
|
656
1149
|
);
|
|
657
1150
|
}
|
|
658
1151
|
}
|
|
@@ -701,9 +1194,123 @@ function projectMessage(value: unknown) {
|
|
|
701
1194
|
botId: boundedSlackString(message.bot_id, 64),
|
|
702
1195
|
threadTimestamp: boundedSlackString(message.thread_ts, 64),
|
|
703
1196
|
text: boundedSlackString(message.text, MAX_PROJECTED_TEXT),
|
|
1197
|
+
files: slackArray(message.files)
|
|
1198
|
+
.map(projectFile)
|
|
1199
|
+
.filter((file): file is NonNullable<typeof file> => file !== null),
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function projectFile(value: unknown) {
|
|
1204
|
+
const file = slackRecord(value);
|
|
1205
|
+
const id = slackString(file?.id);
|
|
1206
|
+
if (!file || !id) return null;
|
|
1207
|
+
const canvasMetadata = slackRecord(file.canvas_metadata);
|
|
1208
|
+
return {
|
|
1209
|
+
id,
|
|
1210
|
+
name: boundedSlackString(file.name, 512),
|
|
1211
|
+
title: boundedSlackString(file.title, 512),
|
|
1212
|
+
mimetype: boundedSlackString(file.mimetype, 256),
|
|
1213
|
+
filetype: boundedSlackString(file.filetype, 128),
|
|
1214
|
+
mode: boundedSlackString(file.mode, 64),
|
|
1215
|
+
size:
|
|
1216
|
+
typeof file.size === "number" && Number.isSafeInteger(file.size) && file.size >= 0
|
|
1217
|
+
? file.size
|
|
1218
|
+
: null,
|
|
1219
|
+
originatingHuddleId: boundedSlackString(canvasMetadata?.originating_huddle_id, 64),
|
|
1220
|
+
huddleTranscriptFileId: boundedSlackString(file.huddle_transcript_file_id, 64),
|
|
704
1221
|
};
|
|
705
1222
|
}
|
|
706
1223
|
|
|
1224
|
+
function parentReferencesSlackFile(
|
|
1225
|
+
parentFile: Record<string, unknown>,
|
|
1226
|
+
childFileId: string,
|
|
1227
|
+
): boolean {
|
|
1228
|
+
if (slackString(parentFile.huddle_transcript_file_id) === childFileId) {
|
|
1229
|
+
return true;
|
|
1230
|
+
}
|
|
1231
|
+
return slackArray(parentFile.embedded_file_ids).some(
|
|
1232
|
+
(candidate) => slackString(candidate) === childFileId,
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function fileIsSharedToChannel(file: Record<string, unknown>, channelId: string): boolean {
|
|
1237
|
+
if (
|
|
1238
|
+
[file.channels, file.groups, file.ims].some((value) =>
|
|
1239
|
+
slackArray(value).some((candidate) => slackString(candidate) === channelId),
|
|
1240
|
+
)
|
|
1241
|
+
) {
|
|
1242
|
+
return true;
|
|
1243
|
+
}
|
|
1244
|
+
const shares = slackRecord(file.shares);
|
|
1245
|
+
return ["public", "private"].some((visibility) => {
|
|
1246
|
+
const byChannel = slackRecord(shares?.[visibility]);
|
|
1247
|
+
return Boolean(byChannel && Object.hasOwn(byChannel, channelId));
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function privateSlackFileUrl(file: Record<string, unknown>): URL | null {
|
|
1252
|
+
const raw = slackString(file.url_private_download) || slackString(file.url_private);
|
|
1253
|
+
if (!raw) return null;
|
|
1254
|
+
try {
|
|
1255
|
+
const url = new URL(raw);
|
|
1256
|
+
assertPrivateSlackFileUrl(url);
|
|
1257
|
+
return url;
|
|
1258
|
+
} catch {
|
|
1259
|
+
throw new SlackBotProviderError("invalid_file_url");
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function assertPrivateSlackFileUrl(url: URL): void {
|
|
1264
|
+
const hostname = url.hostname.toLowerCase();
|
|
1265
|
+
if (url.protocol !== "https:" || (hostname !== "slack.com" && !hostname.endsWith(".slack.com"))) {
|
|
1266
|
+
throw new Error("Slack file URL must use HTTPS on slack.com");
|
|
1267
|
+
}
|
|
1268
|
+
if (url.username || url.password) {
|
|
1269
|
+
throw new Error("Slack file URL must not contain credentials");
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function isSlackInteractiveFileRedirect(url: URL): boolean {
|
|
1274
|
+
return url.pathname === "/" && url.searchParams.has("redir");
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function normalizedContentType(value: string | null): string {
|
|
1278
|
+
return (value ?? "application/octet-stream").split(";", 1)[0]!.trim().toLowerCase();
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function isSupportedSlackTextContentType(value: string): boolean {
|
|
1282
|
+
return (
|
|
1283
|
+
value.startsWith("text/") ||
|
|
1284
|
+
value === "application/json" ||
|
|
1285
|
+
value === "application/xml" ||
|
|
1286
|
+
value === "application/xhtml+xml" ||
|
|
1287
|
+
value === "application/vnd.slack-docs" ||
|
|
1288
|
+
value === "application/vnd.slack-huddle-transcript"
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function embeddedHuddleTranscription(
|
|
1293
|
+
fileRecord: Record<string, unknown>,
|
|
1294
|
+
parentFileRecord: Record<string, unknown> | null,
|
|
1295
|
+
): { contentType: string; content: string } | null {
|
|
1296
|
+
for (const record of [fileRecord, parentFileRecord]) {
|
|
1297
|
+
if (!record) continue;
|
|
1298
|
+
const transcription = slackRecord(record.huddle_transcription);
|
|
1299
|
+
if (!transcription) continue;
|
|
1300
|
+
const content = JSON.stringify(transcription);
|
|
1301
|
+
if (content !== "{}") {
|
|
1302
|
+
return { contentType: "application/json", content };
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
return null;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function embeddedSlackFileIds(content: string): Set<string> {
|
|
1309
|
+
return new Set(
|
|
1310
|
+
[...content.matchAll(/\bFile ID:\s*sf:([A-Z][A-Z0-9]{4,63})\b/g)].map((match) => match[1]!),
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
707
1314
|
function projectUser(value: unknown) {
|
|
708
1315
|
const user = slackRecord(value);
|
|
709
1316
|
const id = slackString(user?.id);
|
|
@@ -723,16 +1330,131 @@ function responseCursor(payload: SlackPayload): string | null {
|
|
|
723
1330
|
return boundedSlackString(slackRecord(payload.response_metadata)?.next_cursor, 1_024) || null;
|
|
724
1331
|
}
|
|
725
1332
|
|
|
1333
|
+
export function resolveSlackFilesListPage(
|
|
1334
|
+
input: { channelId: string; limit?: number; cursor?: string },
|
|
1335
|
+
context: SlackFilesCursorContext,
|
|
1336
|
+
): SlackFilesListPage {
|
|
1337
|
+
const requestedCount = boundedInt(input.limit, MAX_FILE_PAGE, 100);
|
|
1338
|
+
if (!input.cursor) return { count: requestedCount, page: 1 };
|
|
1339
|
+
if (input.cursor.length > MAX_FILE_CURSOR_LENGTH) {
|
|
1340
|
+
throw new SlackBotProviderError("invalid_files_cursor");
|
|
1341
|
+
}
|
|
1342
|
+
const [version, encoded, signature, extra] = input.cursor.split(".");
|
|
1343
|
+
if (
|
|
1344
|
+
version !== SLACK_FILE_CURSOR_VERSION ||
|
|
1345
|
+
!encoded ||
|
|
1346
|
+
!signature ||
|
|
1347
|
+
extra !== undefined ||
|
|
1348
|
+
!/^[A-Za-z0-9_-]+$/.test(encoded) ||
|
|
1349
|
+
!/^[A-Za-z0-9_-]+$/.test(signature)
|
|
1350
|
+
) {
|
|
1351
|
+
throw new SlackBotProviderError("invalid_files_cursor");
|
|
1352
|
+
}
|
|
1353
|
+
const expectedSignature = createHmac("sha256", context.key).update(encoded).digest();
|
|
1354
|
+
const receivedSignature = Buffer.from(signature, "base64url");
|
|
1355
|
+
if (
|
|
1356
|
+
receivedSignature.toString("base64url") !== signature ||
|
|
1357
|
+
receivedSignature.length !== expectedSignature.length ||
|
|
1358
|
+
!timingSafeEqual(receivedSignature, expectedSignature)
|
|
1359
|
+
) {
|
|
1360
|
+
throw new SlackBotProviderError("invalid_files_cursor");
|
|
1361
|
+
}
|
|
1362
|
+
let decoded: unknown;
|
|
1363
|
+
try {
|
|
1364
|
+
const bytes = Buffer.from(encoded, "base64url");
|
|
1365
|
+
if (bytes.toString("base64url") !== encoded) {
|
|
1366
|
+
throw new Error("non-canonical cursor");
|
|
1367
|
+
}
|
|
1368
|
+
decoded = JSON.parse(bytes.toString("utf8"));
|
|
1369
|
+
} catch {
|
|
1370
|
+
throw new SlackBotProviderError("invalid_files_cursor");
|
|
1371
|
+
}
|
|
1372
|
+
const cursor = slackRecord(decoded);
|
|
1373
|
+
if (
|
|
1374
|
+
!cursor ||
|
|
1375
|
+
Object.keys(cursor).sort().join(",") !== "channelId,connectionId,count,page" ||
|
|
1376
|
+
cursor.connectionId !== context.connectionId ||
|
|
1377
|
+
cursor.channelId !== input.channelId ||
|
|
1378
|
+
!positiveSafeInt(cursor.count) ||
|
|
1379
|
+
cursor.count > MAX_FILE_PAGE ||
|
|
1380
|
+
!positiveSafeInt(cursor.page) ||
|
|
1381
|
+
(input.limit !== undefined && requestedCount !== cursor.count)
|
|
1382
|
+
) {
|
|
1383
|
+
throw new SlackBotProviderError("invalid_files_cursor");
|
|
1384
|
+
}
|
|
1385
|
+
return { count: cursor.count, page: cursor.page };
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
export function createSlackFilesListCursor(
|
|
1389
|
+
input: { channelId: string; count: number; page: number },
|
|
1390
|
+
context: SlackFilesCursorContext,
|
|
1391
|
+
): string {
|
|
1392
|
+
const encoded = Buffer.from(
|
|
1393
|
+
JSON.stringify({
|
|
1394
|
+
connectionId: context.connectionId,
|
|
1395
|
+
channelId: input.channelId,
|
|
1396
|
+
count: input.count,
|
|
1397
|
+
page: input.page,
|
|
1398
|
+
}),
|
|
1399
|
+
).toString("base64url");
|
|
1400
|
+
const signature = createHmac("sha256", context.key).update(encoded).digest("base64url");
|
|
1401
|
+
return `${SLACK_FILE_CURSOR_VERSION}.${encoded}.${signature}`;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
export function nextSlackFilesListPage(
|
|
1405
|
+
payload: SlackPayload,
|
|
1406
|
+
requested: SlackFilesListPage,
|
|
1407
|
+
returnedFileCount: number,
|
|
1408
|
+
): number | null {
|
|
1409
|
+
const responseMetadata = slackRecord(payload.response_metadata);
|
|
1410
|
+
const paging = slackRecord(payload.paging);
|
|
1411
|
+
if (responseMetadata && Object.hasOwn(responseMetadata, "next_cursor")) {
|
|
1412
|
+
throw new SlackBotProviderError("invalid_files_paging");
|
|
1413
|
+
}
|
|
1414
|
+
const count = paging?.count;
|
|
1415
|
+
const total = paging?.total;
|
|
1416
|
+
const page = paging?.page;
|
|
1417
|
+
const pages = paging?.pages;
|
|
1418
|
+
if (
|
|
1419
|
+
!positiveSafeInt(count) ||
|
|
1420
|
+
!nonNegativeSafeInt(total) ||
|
|
1421
|
+
!positiveSafeInt(page) ||
|
|
1422
|
+
!nonNegativeSafeInt(pages) ||
|
|
1423
|
+
count !== requested.count ||
|
|
1424
|
+
page !== requested.page ||
|
|
1425
|
+
returnedFileCount > count ||
|
|
1426
|
+
pages !== (total === 0 ? 0 : Math.ceil(total / count)) ||
|
|
1427
|
+
page > Math.max(1, pages)
|
|
1428
|
+
) {
|
|
1429
|
+
throw new SlackBotProviderError("invalid_files_paging");
|
|
1430
|
+
}
|
|
1431
|
+
if (page >= pages) return null;
|
|
1432
|
+
const nextPage = page + 1;
|
|
1433
|
+
if (!positiveSafeInt(nextPage)) {
|
|
1434
|
+
throw new SlackBotProviderError("invalid_files_paging");
|
|
1435
|
+
}
|
|
1436
|
+
return nextPage;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
726
1439
|
function slackMethodForOperation(operation: SlackBotOperation): string {
|
|
727
1440
|
switch (operation) {
|
|
728
1441
|
case "channels.list":
|
|
729
1442
|
return "conversations.list";
|
|
730
1443
|
case "channel_history.read":
|
|
731
1444
|
return "conversations.history";
|
|
1445
|
+
case "thread_replies.read":
|
|
1446
|
+
return "conversations.replies";
|
|
732
1447
|
case "users.list":
|
|
733
1448
|
return "users.list";
|
|
1449
|
+
case "files.list":
|
|
1450
|
+
return "files.list";
|
|
1451
|
+
case "file.info":
|
|
1452
|
+
case "file.content.read":
|
|
1453
|
+
return "files.info";
|
|
734
1454
|
case "message.post":
|
|
735
1455
|
return "chat.postMessage";
|
|
1456
|
+
case "message.delete":
|
|
1457
|
+
return "chat.delete";
|
|
736
1458
|
}
|
|
737
1459
|
}
|
|
738
1460
|
|
|
@@ -742,6 +1464,14 @@ function boundedInt(value: number | undefined, max: number, fallback: number): n
|
|
|
742
1464
|
: fallback;
|
|
743
1465
|
}
|
|
744
1466
|
|
|
1467
|
+
function positiveSafeInt(value: unknown): value is number {
|
|
1468
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function nonNegativeSafeInt(value: unknown): value is number {
|
|
1472
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
745
1475
|
function slackArray(value: unknown): unknown[] {
|
|
746
1476
|
return Array.isArray(value) ? value : [];
|
|
747
1477
|
}
|
|
@@ -777,7 +1507,7 @@ function safeFailureCode(error: unknown): string {
|
|
|
777
1507
|
return "local_validation_failed";
|
|
778
1508
|
}
|
|
779
1509
|
|
|
780
|
-
function
|
|
1510
|
+
function slackMutationOutcomeMayBeAmbiguous(error: unknown): boolean {
|
|
781
1511
|
if (!(error instanceof SlackBotProviderError)) return true;
|
|
782
1512
|
return (
|
|
783
1513
|
error.code === "transport_error" ||
|