@opengeni/api-router 0.12.12 → 0.14.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.
Files changed (87) hide show
  1. package/dist/app.d.ts +18 -38
  2. package/dist/app.js +3 -1
  3. package/dist/auth/managed-auth.d.ts +35 -0
  4. package/dist/{chunk-5TULDPWX.js → chunk-36PW33ND.js} +4111 -1277
  5. package/dist/chunk-36PW33ND.js.map +1 -0
  6. package/dist/codex-redemption-security.d.ts +15 -0
  7. package/dist/github-access.d.ts +12 -0
  8. package/dist/github-browser-flow.d.ts +14 -0
  9. package/dist/http/auth.d.ts +3 -0
  10. package/dist/http/common.d.ts +3 -0
  11. package/dist/http/sse.d.ts +70 -0
  12. package/dist/index.d.ts +11 -13
  13. package/dist/index.js +1 -1
  14. package/dist/integrations/google-drive.d.ts +76 -0
  15. package/dist/integrations/oauth-client.d.ts +42 -0
  16. package/dist/integrations/provider-domain.d.ts +7 -0
  17. package/dist/integrations/slack-bot.d.ts +343 -0
  18. package/dist/mcp/documents.d.ts +8 -0
  19. package/dist/mcp/files.d.ts +5 -0
  20. package/dist/mcp/server.d.ts +107 -0
  21. package/dist/mcp/session-view.d.ts +191 -0
  22. package/dist/mcp/toolspace.d.ts +61 -0
  23. package/dist/model-catalog.d.ts +39 -0
  24. package/dist/observability.d.ts +3 -0
  25. package/dist/routes/api-keys.d.ts +3 -0
  26. package/dist/routes/billing.d.ts +16 -0
  27. package/dist/routes/capabilities.d.ts +3 -0
  28. package/dist/routes/catalog-assets.d.ts +5 -0
  29. package/dist/routes/codex.d.ts +10 -0
  30. package/dist/routes/connections.d.ts +3 -0
  31. package/dist/routes/documents.d.ts +3 -0
  32. package/dist/routes/enrollments.d.ts +3 -0
  33. package/dist/routes/environments.d.ts +5 -0
  34. package/dist/routes/files.d.ts +4 -0
  35. package/dist/routes/github.d.ts +3 -0
  36. package/dist/routes/insights.d.ts +3 -0
  37. package/dist/routes/install.d.ts +5 -0
  38. package/dist/routes/machines.d.ts +3 -0
  39. package/dist/routes/packs.d.ts +3 -0
  40. package/dist/routes/preference-registry.d.ts +5 -0
  41. package/dist/routes/rigs.d.ts +3 -0
  42. package/dist/routes/scheduled-tasks.d.ts +3 -0
  43. package/dist/routes/sessions.d.ts +15 -0
  44. package/dist/routes/social.d.ts +3 -0
  45. package/dist/routes/transcriptions.d.ts +3 -0
  46. package/dist/routes/workspace-capture.d.ts +59 -0
  47. package/dist/routes/workspace-instruction-policies.d.ts +3 -0
  48. package/dist/routes/workspace-state.d.ts +3 -0
  49. package/dist/routes/workspaces.d.ts +5 -0
  50. package/dist/sandbox/access.d.ts +21 -0
  51. package/dist/sandbox/auth-callout.d.ts +30 -0
  52. package/dist/sandbox/channel-a.d.ts +37 -0
  53. package/dist/sandbox/enrollment.d.ts +120 -0
  54. package/dist/sandbox/machines.d.ts +29 -0
  55. package/dist/sandbox/metrics-ingestion.d.ts +128 -0
  56. package/dist/sandbox/rematerialize.d.ts +24 -0
  57. package/dist/sandbox/viewer.d.ts +251 -0
  58. package/dist/transcription/providers/azure-openai.d.ts +9 -0
  59. package/dist/transcription/providers/codex-subscription.d.ts +9 -0
  60. package/dist/transcription/providers/openai.d.ts +9 -0
  61. package/dist/transcription/service.d.ts +10 -0
  62. package/dist/workspace-state-projection.d.ts +16 -0
  63. package/package.json +13 -13
  64. package/src/app.ts +67 -5
  65. package/src/integrations/google-drive.ts +869 -0
  66. package/src/integrations/oauth-client.ts +41 -6
  67. package/src/integrations/slack-bot.ts +756 -26
  68. package/src/mcp/server.ts +200 -3
  69. package/src/mcp/session-view.ts +1 -0
  70. package/src/mcp/toolspace.ts +17 -9
  71. package/src/routes/connections.ts +137 -7
  72. package/src/routes/documents.ts +13 -2
  73. package/src/routes/github.ts +7 -5
  74. package/src/routes/insights.ts +30 -0
  75. package/src/routes/preference-registry.ts +482 -0
  76. package/src/routes/sessions.ts +10 -0
  77. package/src/routes/transcriptions.ts +155 -0
  78. package/src/routes/workspace-state.ts +61 -0
  79. package/src/sandbox/channel-a.ts +2 -0
  80. package/src/sandbox/rematerialize.ts +111 -4
  81. package/src/sandbox/viewer.ts +29 -12
  82. package/src/transcription/providers/azure-openai.ts +45 -0
  83. package/src/transcription/providers/codex-subscription.ts +100 -0
  84. package/src/transcription/providers/openai.ts +93 -0
  85. package/src/transcription/service.ts +121 -0
  86. package/src/workspace-state-projection.ts +244 -0
  87. 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 { readResponseJsonBounded, type FetchLike } from "@opengeni/network";
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 = "channels.list" | "channel_history.read" | "users.list" | "message.post";
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
- assertExactOpenGeniSlackBotScopes(grantedScopes);
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(completed.operation, input.operationId);
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 && slackPostOutcomeMayBeAmbiguous(error) ? "ambiguous" : "failed",
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: `${SLACK_API_BASE}${slackMethodForOperation(operation)}`,
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, clientMessageId: 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 assertExactOpenGeniSlackBotScopes(grantedScopes: string[]): void {
642
- const required = new Set<string>(OPENGENI_SLACK_BOT_REQUIRED_SCOPES);
643
- const granted = new Set(grantedScopes);
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
- ...(missing.length ? [`missing: ${missing.join(", ")}`] : []),
650
- ...(forbidden.length ? [`forbidden: ${forbidden.join(", ")}`] : []),
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 must exactly match the OpenGeni manifest (${facts.join("; ")})`,
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 slackPostOutcomeMayBeAmbiguous(error: unknown): boolean {
1510
+ function slackMutationOutcomeMayBeAmbiguous(error: unknown): boolean {
781
1511
  if (!(error instanceof SlackBotProviderError)) return true;
782
1512
  return (
783
1513
  error.code === "transport_error" ||