@opengeni/api-router 0.23.1 → 0.30.0

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 (152) hide show
  1. package/dist/app.d.ts +3 -2
  2. package/dist/app.js +5 -3
  3. package/dist/auth/canonical-human-session-admission.d.ts +27 -0
  4. package/dist/browser-auth-broker.d.ts +20 -0
  5. package/dist/browser-controller-authority.d.ts +61 -0
  6. package/dist/browser-network-route.d.ts +8 -0
  7. package/dist/browser-state-authority.d.ts +27 -0
  8. package/dist/{chunk-T4T2PGU4.js → chunk-2PQMSRCB.js} +37891 -17463
  9. package/dist/chunk-2PQMSRCB.js.map +1 -0
  10. package/dist/codemode.d.ts +23 -0
  11. package/dist/controller-data-plane.d.ts +12 -0
  12. package/dist/editable-artifact-live-hints.d.ts +11 -0
  13. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  14. package/dist/editable-artifact-office-import.d.ts +22 -0
  15. package/dist/editable-artifact-production.d.ts +29 -0
  16. package/dist/editable-artifact-websocket.d.ts +49 -0
  17. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  18. package/dist/github-browser-flow.d.ts +6 -0
  19. package/dist/http/auth.d.ts +1 -1
  20. package/dist/http/cors.d.ts +1 -0
  21. package/dist/http/sse.d.ts +18 -1
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.js +1796 -22
  24. package/dist/index.js.map +1 -1
  25. package/dist/integrations/api-integrations.d.ts +24 -0
  26. package/dist/integrations/atlassian.d.ts +176 -0
  27. package/dist/integrations/fiken.d.ts +233 -0
  28. package/dist/integrations/github-skill-source.d.ts +5 -0
  29. package/dist/integrations/google-drive.d.ts +85 -0
  30. package/dist/integrations/oauth-client.d.ts +22 -1
  31. package/dist/integrations/provider-oauth.d.ts +17 -0
  32. package/dist/integrations/slack-bot.d.ts +175 -1
  33. package/dist/integrations/slack-interactions.d.ts +24 -6
  34. package/dist/integrations/social-api.d.ts +2 -1
  35. package/dist/interaction-holder-heartbeat.d.ts +17 -0
  36. package/dist/interaction-metrics.d.ts +11 -0
  37. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  38. package/dist/mcp/editable-artifacts.d.ts +13 -0
  39. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  40. package/dist/mcp/server.d.ts +16 -4
  41. package/dist/memory-slack-delivery.d.ts +9 -0
  42. package/dist/model-catalog.d.ts +1 -0
  43. package/dist/routes/api-integrations.d.ts +8 -0
  44. package/dist/routes/browser-identities.d.ts +5 -0
  45. package/dist/routes/browser-sessions.d.ts +6 -0
  46. package/dist/routes/canonical-human-identities.d.ts +3 -0
  47. package/dist/routes/channels.d.ts +3 -0
  48. package/dist/routes/company-profile.d.ts +3 -0
  49. package/dist/routes/computer-sessions.d.ts +6 -0
  50. package/dist/routes/editable-artifacts.d.ts +44 -0
  51. package/dist/routes/integration-facets.d.ts +3 -0
  52. package/dist/routes/interaction-resources.d.ts +5 -0
  53. package/dist/routes/memory-slack-publications.d.ts +6 -0
  54. package/dist/routes/organization-memberships.d.ts +3 -0
  55. package/dist/routes/plugins.d.ts +8 -0
  56. package/dist/routes/sessions.d.ts +18 -2
  57. package/dist/routes/skills.d.ts +6 -0
  58. package/dist/routes/slack-task-policy.d.ts +3 -0
  59. package/dist/routes/supergrok.d.ts +3 -0
  60. package/dist/routes/video-generation.d.ts +3 -0
  61. package/dist/sandbox/channel-a.d.ts +62 -2
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  64. package/dist/sandbox/viewer.d.ts +7 -3
  65. package/dist/slack-reaction-files.d.ts +27 -0
  66. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  67. package/dist/transcription/providers/xai-subscription.d.ts +8 -0
  68. package/dist/xai-realtime.d.ts +26 -0
  69. package/dist/xai-subscription-auth.d.ts +23 -0
  70. package/package.json +20 -14
  71. package/src/app.ts +604 -64
  72. package/src/auth/canonical-human-session-admission.ts +116 -0
  73. package/src/auth/managed-auth.ts +101 -0
  74. package/src/browser-auth-broker.ts +155 -0
  75. package/src/browser-controller-authority.ts +186 -0
  76. package/src/browser-network-route.ts +34 -0
  77. package/src/browser-state-authority.ts +236 -0
  78. package/src/codemode.ts +186 -0
  79. package/src/controller-data-plane.ts +35 -0
  80. package/src/editable-artifact-live-hints.ts +64 -0
  81. package/src/editable-artifact-native-kernel.ts +659 -0
  82. package/src/editable-artifact-office-import.ts +230 -0
  83. package/src/editable-artifact-production.ts +419 -0
  84. package/src/editable-artifact-websocket.ts +311 -0
  85. package/src/editable-artifact-workspace-files.ts +186 -0
  86. package/src/github-browser-flow.ts +35 -6
  87. package/src/http/auth.ts +29 -9
  88. package/src/http/cors.ts +3 -0
  89. package/src/http/sse.ts +229 -5
  90. package/src/index.ts +119 -20
  91. package/src/integrations/api-integrations.ts +365 -0
  92. package/src/integrations/atlassian.ts +1621 -0
  93. package/src/integrations/fiken.ts +1230 -0
  94. package/src/integrations/github-skill-source.ts +142 -0
  95. package/src/integrations/google-drive.ts +1005 -64
  96. package/src/integrations/oauth-client.ts +123 -33
  97. package/src/integrations/provider-oauth.ts +756 -0
  98. package/src/integrations/slack-bot.ts +669 -11
  99. package/src/integrations/slack-interactions.ts +2063 -93
  100. package/src/integrations/social-api.ts +11 -0
  101. package/src/interaction-holder-heartbeat.ts +95 -0
  102. package/src/interaction-metrics.ts +159 -0
  103. package/src/mcp/documents.ts +146 -6
  104. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  105. package/src/mcp/editable-artifacts.ts +448 -0
  106. package/src/mcp/scheduled-task-view.ts +34 -0
  107. package/src/mcp/server.ts +1756 -355
  108. package/src/memory-slack-delivery.ts +209 -0
  109. package/src/model-catalog.ts +19 -6
  110. package/src/routes/api-integrations.ts +410 -0
  111. package/src/routes/browser-identities.ts +160 -0
  112. package/src/routes/browser-sessions.ts +4482 -0
  113. package/src/routes/canonical-human-identities.ts +156 -0
  114. package/src/routes/channels.ts +90 -0
  115. package/src/routes/company-profile.ts +255 -0
  116. package/src/routes/computer-sessions.ts +1602 -0
  117. package/src/routes/connections.ts +421 -99
  118. package/src/routes/documents.ts +22 -3
  119. package/src/routes/editable-artifacts.ts +1159 -0
  120. package/src/routes/files.ts +106 -4
  121. package/src/routes/github.ts +18 -2
  122. package/src/routes/install.ts +7 -1
  123. package/src/routes/integration-facets.ts +258 -0
  124. package/src/routes/interaction-resources.ts +595 -0
  125. package/src/routes/memory-slack-publications.ts +216 -0
  126. package/src/routes/organization-memberships.ts +53 -0
  127. package/src/routes/packs.ts +437 -7
  128. package/src/routes/plugins.ts +752 -0
  129. package/src/routes/rigs.ts +77 -20
  130. package/src/routes/scheduled-tasks.ts +67 -40
  131. package/src/routes/sessions.ts +830 -278
  132. package/src/routes/skills.ts +256 -0
  133. package/src/routes/slack-task-policy.ts +115 -0
  134. package/src/routes/supergrok.ts +717 -0
  135. package/src/routes/transcription-recordings.ts +9 -2
  136. package/src/routes/transcriptions.ts +2 -1
  137. package/src/routes/video-generation.ts +162 -0
  138. package/src/routes/workspaces.ts +68 -28
  139. package/src/sandbox/channel-a.ts +750 -116
  140. package/src/sandbox/machines.ts +5 -5
  141. package/src/sandbox/metrics-ingestion.ts +121 -3
  142. package/src/sandbox/rematerialize.ts +35 -47
  143. package/src/sandbox/viewer.ts +39 -12
  144. package/src/slack-reaction-files.ts +181 -0
  145. package/src/temporal-schedule-cleanup.ts +135 -0
  146. package/src/transcription/providers/xai-subscription.ts +110 -0
  147. package/src/transcription/service.ts +17 -2
  148. package/src/xai-realtime.ts +216 -0
  149. package/src/xai-subscription-auth.ts +132 -0
  150. package/dist/chunk-T4T2PGU4.js.map +0 -1
  151. package/dist/mcp/toolspace.d.ts +0 -71
  152. package/src/mcp/toolspace.ts +0 -1190
@@ -20,14 +20,18 @@ import {
20
20
  buildConnectionTokenResolver,
21
21
  claimSlackBotDeleteOperation,
22
22
  claimSlackBotPostOperation,
23
+ claimSlackBotUpdateOperation,
23
24
  completeSlackBotDeleteOperation,
24
25
  completeSlackBotPostOperation,
26
+ completeSlackBotUpdateOperation,
25
27
  getSession,
26
28
  listConnectionsMetadata,
27
29
  markSlackBotDeleteOperationProviderStarted,
30
+ markSlackBotPostOperationProviderStarted,
28
31
  recordAuditEvent,
29
32
  releaseSlackBotDeleteOperationClaim,
30
33
  releaseSlackBotPostOperationClaim,
34
+ releaseSlackBotUpdateOperationClaim,
31
35
  setConnectionStatus,
32
36
  type Database,
33
37
  } from "@opengeni/db";
@@ -41,6 +45,7 @@ import { HTTPException } from "hono/http-exception";
41
45
  const SLACK_API_BASE = "https://slack.com/api/";
42
46
  const SLACK_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
43
47
  const SLACK_FILE_RESPONSE_MAX_BYTES = 4 * 1024 * 1024;
48
+ export const SLACK_REACTION_IMAGE_MAX_BYTES = 4 * 1024 * 1024;
44
49
  const SLACK_FILE_CONTENT_PAGE_CHARS = 50_000;
45
50
  const SLACK_TIMEOUT_MS = 10_000;
46
51
  const MAX_CHANNEL_PAGE = 200;
@@ -64,7 +69,13 @@ const MAX_FILE_CURSOR_LENGTH = 1_024;
64
69
  const SLACK_FILE_CURSOR_VERSION = "files-v1";
65
70
  const MAX_PROJECTED_TEXT = 4_000;
66
71
  const SLACK_POST_CLAIM_LEASE_MS = 30_000;
72
+ const MAX_SLACK_POST_RECONCILIATION_PAGES = 8;
73
+ const SLACK_UPDATE_CLAIM_LEASE_MS = 30_000;
67
74
  const SLACK_DELETE_CLAIM_LEASE_MS = 30_000;
75
+ const MAX_SLACK_BLOCKS = 50;
76
+ const MAX_SLACK_BLOCK_BYTES = 32 * 1024;
77
+ const SLACK_PRIVATE_FILE_HOSTS = new Set(["files.slack.com", "slack.com"]);
78
+ const SLACK_REACTION_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
68
79
 
69
80
  type SlackPayload = Record<string, unknown> & { ok?: unknown; error?: unknown };
70
81
 
@@ -80,6 +91,21 @@ export type VerifiedOpenGeniSlackBot = {
80
91
  metadata: OpenGeniSlackBotConnectionMetadata;
81
92
  };
82
93
 
94
+ export type PreparedSlackReactionImage = Readonly<{
95
+ fileId: string;
96
+ filename: string;
97
+ declaredMimeType: string;
98
+ declaredSizeBytes: number | null;
99
+ downloadUrl: URL | null;
100
+ }>;
101
+
102
+ export type DownloadedSlackReactionImage = Readonly<{
103
+ fileId: string;
104
+ filename: string;
105
+ contentType: "image/png" | "image/jpeg" | "image/webp";
106
+ bytes: Uint8Array;
107
+ }>;
108
+
83
109
  export async function exchangeOpenGeniSlackAuthorizationCode(
84
110
  input: {
85
111
  code: string;
@@ -145,6 +171,25 @@ export type SlackBotReceipt = {
145
171
  clientMessageId?: string;
146
172
  };
147
173
 
174
+ export type SlackMessageBlock =
175
+ | {
176
+ type: "section";
177
+ block_id?: string;
178
+ text: { type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean };
179
+ }
180
+ | {
181
+ type: "actions";
182
+ block_id: string;
183
+ elements: Array<{
184
+ type: "button";
185
+ action_id: string;
186
+ value: string;
187
+ text: { type: "plain_text"; text: string; emoji?: boolean };
188
+ style?: "primary" | "danger";
189
+ }>;
190
+ }
191
+ | { type: "divider" };
192
+
148
193
  type SlackBotOperation =
149
194
  | "channels.list"
150
195
  | "channel_history.read"
@@ -154,6 +199,7 @@ type SlackBotOperation =
154
199
  | "file.info"
155
200
  | "file.content.read"
156
201
  | "message.post"
202
+ | "message.update"
157
203
  | "message.delete";
158
204
 
159
205
  type SlackBotContext = {
@@ -407,13 +453,42 @@ export class OpenGeniSlackBotClient {
407
453
  return await this.requireMemberChannel(headers, channelId);
408
454
  }
409
455
 
410
- async channelHistory(input: { channelId: string; limit?: number; cursor?: string }) {
456
+ async slackTaskPolicyFacts(channelId: string, userId: string) {
457
+ return await this.withAudit("channel_history.read", async (headers) => {
458
+ const conversation = await this.requireMemberChannel(headers, channelId);
459
+ const governed =
460
+ conversation.isShared ||
461
+ conversation.isExternallyShared ||
462
+ conversation.isOrgShared ||
463
+ conversation.isPendingExternallyShared ||
464
+ conversation.isMpim;
465
+ if (!governed) return { conversation, initiator: null };
466
+ const payload = await this.call(headers, "users.info", { user: userId });
467
+ const initiator = projectSlackTaskPolicyUser(payload.user, this.metadata.slackTeamId);
468
+ if (!initiator || initiator.id !== userId) {
469
+ throw new SlackBotProviderError("slack_task_initiator_unavailable");
470
+ }
471
+ return { conversation, initiator };
472
+ });
473
+ }
474
+
475
+ async channelHistory(input: {
476
+ channelId: string;
477
+ limit?: number;
478
+ cursor?: string;
479
+ latest?: string;
480
+ inclusive?: boolean;
481
+ authorizeRead?: () => Promise<void>;
482
+ }) {
411
483
  return await this.withAudit("channel_history.read", async (headers) => {
412
484
  const info = await this.requireMemberChannel(headers, input.channelId);
485
+ await input.authorizeRead?.();
413
486
  const payload = await this.call(headers, "conversations.history", {
414
487
  channel: input.channelId,
415
488
  limit: String(boundedInt(input.limit, MAX_HISTORY_PAGE, 50)),
416
489
  ...(input.cursor ? { cursor: input.cursor } : {}),
490
+ ...(input.latest ? { latest: input.latest } : {}),
491
+ ...(input.latest && input.inclusive ? { inclusive: "true" } : {}),
417
492
  });
418
493
  return {
419
494
  channel: info,
@@ -428,14 +503,22 @@ export class OpenGeniSlackBotClient {
428
503
  threadTimestamp: string;
429
504
  limit?: number;
430
505
  cursor?: string;
506
+ oldest?: string;
507
+ latest?: string;
508
+ inclusive?: boolean;
509
+ authorizeRead?: () => Promise<void>;
431
510
  }) {
432
511
  return await this.withAudit("thread_replies.read", async (headers) => {
433
512
  const info = await this.requireMemberChannel(headers, input.channelId);
513
+ await input.authorizeRead?.();
434
514
  const payload = await this.call(headers, "conversations.replies", {
435
515
  channel: input.channelId,
436
516
  ts: input.threadTimestamp,
437
517
  limit: String(boundedInt(input.limit, MAX_THREAD_PAGE, 50)),
438
518
  ...(input.cursor ? { cursor: input.cursor } : {}),
519
+ ...(input.oldest ? { oldest: input.oldest } : {}),
520
+ ...(input.latest ? { latest: input.latest } : {}),
521
+ ...((input.oldest || input.latest) && input.inclusive ? { inclusive: "true" } : {}),
439
522
  });
440
523
  return {
441
524
  channel: info,
@@ -557,6 +640,99 @@ export class OpenGeniSlackBotClient {
557
640
  });
558
641
  }
559
642
 
643
+ /**
644
+ * Re-fetch and authorize the exact reacted-message files before any byte or
645
+ * workspace-storage mutation. The returned private URLs are process-local
646
+ * capabilities only and must never be persisted, logged, or projected into
647
+ * session input.
648
+ */
649
+ async prepareReactionImageDownloads(input: {
650
+ channelId: string;
651
+ files: readonly { id: string; name: string; title: string }[];
652
+ }): Promise<PreparedSlackReactionImage[]> {
653
+ const result = await this.withAudit("file.content.read", async (headers) => {
654
+ await this.requireActiveNonSharedMemberChannel(headers, input.channelId);
655
+ const prepared: PreparedSlackReactionImage[] = [];
656
+ for (const candidate of input.files) {
657
+ const payload = await this.call(headers, "files.info", { file: candidate.id });
658
+ const fileRecord = slackRecord(payload.file);
659
+ const file = projectFile(fileRecord);
660
+ if (!fileRecord || !file || file.id !== candidate.id) {
661
+ throw new SlackBotProviderError("file_not_found");
662
+ }
663
+ if (!fileIsSharedToChannel(fileRecord, input.channelId)) {
664
+ throw new SlackBotProviderError("file_not_shared_to_channel");
665
+ }
666
+ let downloadUrl: URL | null = null;
667
+ try {
668
+ downloadUrl = privateSlackFileUrl(fileRecord);
669
+ } catch {
670
+ // A malformed provider URL is a per-file omission, not permission to
671
+ // send a credential to a different destination.
672
+ }
673
+ prepared.push(
674
+ Object.freeze({
675
+ fileId: file.id,
676
+ filename: file.name || file.title || candidate.name || candidate.title || file.id,
677
+ declaredMimeType: normalizedContentType(file.mimetype),
678
+ declaredSizeBytes: file.size,
679
+ downloadUrl,
680
+ }),
681
+ );
682
+ }
683
+ return { files: prepared };
684
+ });
685
+ return result.files;
686
+ }
687
+
688
+ /** Download and fully validate one previously authorized Slack image. */
689
+ async downloadReactionImage(
690
+ input: PreparedSlackReactionImage,
691
+ ): Promise<DownloadedSlackReactionImage> {
692
+ return await this.withAudit("file.content.read", async () => {
693
+ if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(input.declaredMimeType)) {
694
+ throw new SlackBotProviderError("unsupported_file_type");
695
+ }
696
+ if (!input.downloadUrl) throw new SlackBotProviderError("file_content_unavailable");
697
+ if (
698
+ input.declaredSizeBytes !== null &&
699
+ (input.declaredSizeBytes < 1 || input.declaredSizeBytes > SLACK_REACTION_IMAGE_MAX_BYTES)
700
+ ) {
701
+ throw new SlackBotProviderError("invalid_file_size");
702
+ }
703
+ const response = await this.fetchPrivateFile(input.downloadUrl, "file.content.read");
704
+ const responseContentType = normalizedContentType(response.headers.get("content-type"));
705
+ if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(responseContentType)) {
706
+ await response.body?.cancel().catch(() => undefined);
707
+ throw new SlackBotProviderError("unsupported_file_type");
708
+ }
709
+ let bytes: Uint8Array;
710
+ try {
711
+ bytes = await readResponseBodyBounded(
712
+ response,
713
+ SLACK_REACTION_IMAGE_MAX_BYTES,
714
+ "Slack image content",
715
+ );
716
+ } catch {
717
+ throw new SlackBotProviderError("invalid_file_content");
718
+ }
719
+ const sniffed = sniffSlackReactionImageMime(bytes);
720
+ if (!sniffed) throw new SlackBotProviderError("invalid_file_content");
721
+ if (input.declaredMimeType !== sniffed || responseContentType !== sniffed) {
722
+ throw new SlackBotProviderError("file_content_type_mismatch");
723
+ }
724
+ if (input.declaredSizeBytes !== null && input.declaredSizeBytes !== bytes.byteLength) {
725
+ throw new SlackBotProviderError("file_size_mismatch");
726
+ }
727
+ return Object.freeze({
728
+ fileId: input.fileId,
729
+ filename: input.filename,
730
+ contentType: sniffed,
731
+ bytes,
732
+ });
733
+ });
734
+ }
735
+
560
736
  async listUsers(input: { limit?: number; cursor?: string } = {}) {
561
737
  return await this.withAudit("users.list", async (headers) => {
562
738
  const payload = await this.call(headers, "users.list", {
@@ -645,17 +821,25 @@ export class OpenGeniSlackBotClient {
645
821
  });
646
822
  }
647
823
 
824
+ /**
825
+ * Internal server-owned delivery only. Generic model-facing MCP callers do
826
+ * not have a trustworthy durable logical-delivery identity and must never
827
+ * reach this method with a caller-generated operation ID.
828
+ */
648
829
  async postMessage(input: {
649
830
  operationId: string;
650
831
  channelId?: string;
651
832
  userId?: string;
652
833
  threadTimestamp?: string;
653
834
  text: string;
835
+ blocks?: SlackMessageBlock[];
836
+ requireActiveNonSharedChannel?: boolean;
654
837
  }) {
655
838
  const operation = "message.post" as const;
656
839
  const claimHolderId = crypto.randomUUID();
657
840
  let claimAcquired = false;
658
841
  let providerCallStarted = false;
842
+ let outcomeUnknown = false;
659
843
  try {
660
844
  const headers = await this.headersFor(operation);
661
845
  let channelId = input.channelId;
@@ -664,7 +848,7 @@ export class OpenGeniSlackBotClient {
664
848
  users: input.userId,
665
849
  });
666
850
  channelId = requiredSlackString(slackRecord(opened.channel)?.id, "channel.id");
667
- } else if (channelId) {
851
+ } else if (channelId && !input.requireActiveNonSharedChannel) {
668
852
  await this.requireMemberChannel(headers, channelId);
669
853
  }
670
854
  if (!channelId) {
@@ -672,12 +856,14 @@ export class OpenGeniSlackBotClient {
672
856
  }
673
857
  const targetKind = input.userId ? "user" : "channel";
674
858
  const targetId = input.userId ?? input.channelId!;
859
+ const blocks = validateSlackMessageBlocks(input.blocks);
675
860
  const requestDigest = this.postRequestDigest({
676
861
  operationId: input.operationId,
677
862
  targetKind,
678
863
  targetId,
679
864
  ...(input.threadTimestamp ? { threadTimestamp: input.threadTimestamp } : {}),
680
865
  text: input.text,
866
+ ...(blocks ? { blocks } : {}),
681
867
  });
682
868
  const claim = await claimSlackBotPostOperation(this.db, {
683
869
  accountId: this.context.accountId,
@@ -703,11 +889,59 @@ export class OpenGeniSlackBotClient {
703
889
  return this.completedPostResult(claim.operation, input.operationId, input.threadTimestamp);
704
890
  }
705
891
  claimAcquired = true;
892
+ outcomeUnknown = claim.kind === "reconcile";
893
+ if (claim.kind === "reconcile") {
894
+ const reconciled = await this.reconcilePostMessage({
895
+ operationId: input.operationId,
896
+ channelId,
897
+ ...(input.threadTimestamp ? { threadTimestamp: input.threadTimestamp } : {}),
898
+ text: input.text,
899
+ });
900
+ const completed = await completeSlackBotPostOperation(this.db, {
901
+ accountId: this.context.accountId,
902
+ workspaceId: this.context.workspaceId,
903
+ connectionId: this.connection.id,
904
+ operationId: input.operationId,
905
+ claimHolderId,
906
+ slackChannelId: channelId,
907
+ slackMessageTimestamp: reconciled.timestamp,
908
+ subjectId: this.context.subjectId,
909
+ auditMetadata: this.auditMetadata(operation, "succeeded", undefined, input.operationId),
910
+ });
911
+ if (completed.kind !== "completed") {
912
+ throw new Error("Slack post reconciliation lost its durable operation claim");
913
+ }
914
+ claimAcquired = false;
915
+ return this.completedPostResult(
916
+ completed.operation,
917
+ input.operationId,
918
+ input.threadTimestamp,
919
+ );
920
+ }
921
+ if (input.requireActiveNonSharedChannel) {
922
+ if (input.userId || !input.channelId) {
923
+ throw new Error("active non-shared channel validation requires channelId");
924
+ }
925
+ await this.requireActiveNonSharedMemberChannel(headers, channelId);
926
+ }
927
+ const providerStarted = await markSlackBotPostOperationProviderStarted(this.db, {
928
+ accountId: this.context.accountId,
929
+ workspaceId: this.context.workspaceId,
930
+ connectionId: this.connection.id,
931
+ operationId: input.operationId,
932
+ claimHolderId,
933
+ });
934
+ if (!providerStarted) {
935
+ throw new Error("Slack post operation lost its durable claim before provider call");
936
+ }
706
937
  providerCallStarted = true;
707
938
  const posted = await this.call(headers, "chat.postMessage", {
708
939
  channel: channelId,
709
940
  text: input.text,
941
+ ...(blocks ? { blocks: JSON.stringify(blocks) } : {}),
710
942
  client_msg_id: input.operationId,
943
+ unfurl_links: "false",
944
+ unfurl_media: "false",
711
945
  ...(input.threadTimestamp ? { thread_ts: input.threadTimestamp } : {}),
712
946
  });
713
947
  const slackChannelId = requiredSlackString(posted.channel, "channel");
@@ -734,8 +968,108 @@ export class OpenGeniSlackBotClient {
734
968
  );
735
969
  } catch (error) {
736
970
  const failureCode = safeFailureCode(error);
971
+ const ambiguous = providerCallStarted && slackMutationOutcomeMayBeAmbiguous(error);
737
972
  if (claimAcquired) {
738
973
  await releaseSlackBotPostOperationClaim(this.db, {
974
+ accountId: this.context.accountId,
975
+ workspaceId: this.context.workspaceId,
976
+ connectionId: this.connection.id,
977
+ operationId: input.operationId,
978
+ claimHolderId,
979
+ outcomeUnknown: outcomeUnknown || ambiguous,
980
+ failureCode,
981
+ }).catch(() => undefined);
982
+ }
983
+ await this.recordAudit(
984
+ operation,
985
+ outcomeUnknown || ambiguous ? "ambiguous" : "failed",
986
+ failureCode,
987
+ input.operationId,
988
+ );
989
+ throw error;
990
+ }
991
+ }
992
+
993
+ async updateMessage(input: {
994
+ operationId: string;
995
+ channelId: string;
996
+ timestamp: string;
997
+ text: string;
998
+ blocks?: SlackMessageBlock[];
999
+ }): Promise<{ channelId: string; timestamp: string; receipt: SlackBotReceipt }> {
1000
+ const operation = "message.update" as const;
1001
+ const claimHolderId = crypto.randomUUID();
1002
+ let claimAcquired = false;
1003
+ let providerCallStarted = false;
1004
+ const blocks = validateSlackMessageBlocks(input.blocks);
1005
+ try {
1006
+ const headers = await this.headersFor(operation);
1007
+ await this.requireMemberChannel(headers, input.channelId);
1008
+ const requestDigest = this.updateRequestDigest({ ...input, ...(blocks ? { blocks } : {}) });
1009
+ const claim = await claimSlackBotUpdateOperation(this.db, {
1010
+ accountId: this.context.accountId,
1011
+ workspaceId: this.context.workspaceId,
1012
+ connectionId: this.connection.id,
1013
+ operationId: input.operationId,
1014
+ slackChannelId: input.channelId,
1015
+ slackMessageTimestamp: input.timestamp,
1016
+ requestDigest,
1017
+ claimHolderId,
1018
+ claimLeaseMs: SLACK_UPDATE_CLAIM_LEASE_MS,
1019
+ });
1020
+ if (claim.kind === "connection_not_found") {
1021
+ throw new Error("OpenGeni Slack bot connection no longer exists");
1022
+ }
1023
+ if (claim.kind === "conflict") {
1024
+ throw new Error("operationId is already bound to a different Slack update request");
1025
+ }
1026
+ if (claim.kind === "in_progress") {
1027
+ throw new Error(
1028
+ "Slack update operation is already in progress; retry the same operationId",
1029
+ );
1030
+ }
1031
+ if (claim.kind === "completed") {
1032
+ return {
1033
+ channelId: claim.operation.slackChannelId,
1034
+ timestamp: claim.operation.slackMessageTimestamp,
1035
+ receipt: this.receipt(operation, input.operationId),
1036
+ };
1037
+ }
1038
+ claimAcquired = true;
1039
+ providerCallStarted = true;
1040
+ const updated = await this.call(headers, "chat.update", {
1041
+ channel: input.channelId,
1042
+ ts: input.timestamp,
1043
+ text: input.text,
1044
+ ...(blocks ? { blocks: JSON.stringify(blocks) } : {}),
1045
+ });
1046
+ const slackChannelId = requiredSlackString(updated.channel, "channel");
1047
+ const slackMessageTimestamp = requiredSlackString(updated.ts, "ts");
1048
+ if (slackChannelId !== input.channelId || slackMessageTimestamp !== input.timestamp) {
1049
+ throw new SlackBotProviderError("message_update_identity_mismatch");
1050
+ }
1051
+ const completed = await completeSlackBotUpdateOperation(this.db, {
1052
+ accountId: this.context.accountId,
1053
+ workspaceId: this.context.workspaceId,
1054
+ connectionId: this.connection.id,
1055
+ operationId: input.operationId,
1056
+ claimHolderId,
1057
+ subjectId: this.context.subjectId,
1058
+ auditMetadata: this.auditMetadata(operation, "succeeded", undefined, input.operationId),
1059
+ });
1060
+ if (completed !== "completed") {
1061
+ throw new Error("Slack update completion lost its durable operation claim");
1062
+ }
1063
+ claimAcquired = false;
1064
+ return {
1065
+ channelId: slackChannelId,
1066
+ timestamp: slackMessageTimestamp,
1067
+ receipt: this.receipt(operation, input.operationId),
1068
+ };
1069
+ } catch (error) {
1070
+ const failureCode = safeFailureCode(error);
1071
+ if (claimAcquired) {
1072
+ await releaseSlackBotUpdateOperationClaim(this.db, {
739
1073
  accountId: this.context.accountId,
740
1074
  workspaceId: this.context.workspaceId,
741
1075
  connectionId: this.connection.id,
@@ -895,6 +1229,61 @@ export class OpenGeniSlackBotClient {
895
1229
  }
896
1230
  }
897
1231
 
1232
+ private async reconcilePostMessage(input: {
1233
+ operationId: string;
1234
+ channelId: string;
1235
+ threadTimestamp?: string;
1236
+ text: string;
1237
+ }): Promise<{ timestamp: string }> {
1238
+ const method = input.threadTimestamp ? "conversations.replies" : "conversations.history";
1239
+ const headers = await this.headersForDestination("message.post", `${SLACK_API_BASE}${method}`);
1240
+ const seenCursors = new Set<string>();
1241
+ let cursor: string | null = null;
1242
+ let matchedTimestamp: string | null = null;
1243
+ let exhausted = false;
1244
+ for (let page = 0; page < MAX_SLACK_POST_RECONCILIATION_PAGES; page += 1) {
1245
+ const payload = await this.call(headers, method, {
1246
+ channel: input.channelId,
1247
+ limit: String(input.threadTimestamp ? MAX_THREAD_PAGE : MAX_HISTORY_PAGE),
1248
+ ...(input.threadTimestamp ? { ts: input.threadTimestamp } : {}),
1249
+ ...(cursor ? { cursor } : {}),
1250
+ });
1251
+ for (const value of slackArray(payload.messages)) {
1252
+ const message = slackRecord(value);
1253
+ if (!message || slackString(message.client_msg_id) !== input.operationId) continue;
1254
+ const timestamp = requiredSlackString(message.ts, "message.ts");
1255
+ const threadTimestamp = slackString(message.thread_ts);
1256
+ const threadMatches = input.threadTimestamp
1257
+ ? threadTimestamp === input.threadTimestamp
1258
+ : !threadTimestamp || threadTimestamp === timestamp;
1259
+ if (slackString(message.text) !== input.text || !threadMatches) {
1260
+ throw new SlackBotProviderError("post_reconciliation_mismatch");
1261
+ }
1262
+ if (matchedTimestamp && matchedTimestamp !== timestamp) {
1263
+ throw new SlackBotProviderError("post_reconciliation_duplicate");
1264
+ }
1265
+ matchedTimestamp = timestamp;
1266
+ }
1267
+ const nextCursor = responseCursor(payload);
1268
+ if (!nextCursor) {
1269
+ exhausted = true;
1270
+ break;
1271
+ }
1272
+ if (seenCursors.has(nextCursor)) {
1273
+ throw new SlackBotProviderError("post_reconciliation_invalid_cursor");
1274
+ }
1275
+ seenCursors.add(nextCursor);
1276
+ cursor = nextCursor;
1277
+ }
1278
+ if (!exhausted) {
1279
+ throw new SlackBotProviderError("post_reconciliation_truncated");
1280
+ }
1281
+ if (!matchedTimestamp) {
1282
+ throw new SlackBotProviderError("post_outcome_unknown");
1283
+ }
1284
+ return { timestamp: matchedTimestamp };
1285
+ }
1286
+
898
1287
  private async requireMemberChannel(headers: Record<string, string>, channelId: string) {
899
1288
  const payload = await this.call(headers, "conversations.info", {
900
1289
  channel: channelId,
@@ -909,6 +1298,20 @@ export class OpenGeniSlackBotClient {
909
1298
  return projected;
910
1299
  }
911
1300
 
1301
+ private async requireActiveNonSharedMemberChannel(
1302
+ headers: Record<string, string>,
1303
+ channelId: string,
1304
+ ) {
1305
+ const projected = await this.requireMemberChannel(headers, channelId);
1306
+ if (projected.isArchived) {
1307
+ throw new SlackBotProviderError("is_archived");
1308
+ }
1309
+ if (projected.isShared || projected.isExternallyShared || projected.isOrgShared) {
1310
+ throw new SlackBotProviderError("slack_connect_unsupported");
1311
+ }
1312
+ return projected;
1313
+ }
1314
+
912
1315
  private async requireFileForChannel(
913
1316
  headers: Record<string, string>,
914
1317
  operation: "file.info" | "file.content.read",
@@ -1064,13 +1467,17 @@ export class OpenGeniSlackBotClient {
1064
1467
  let redirected: URL;
1065
1468
  try {
1066
1469
  redirected = new URL(location, url);
1067
- assertPrivateSlackFileUrl(redirected);
1068
1470
  } catch {
1069
1471
  throw new SlackBotProviderError("invalid_file_redirect");
1070
1472
  }
1071
- if (isSlackInteractiveFileRedirect(redirected)) {
1473
+ if (isSlackOwnedInteractiveFileRedirect(redirected)) {
1072
1474
  throw new SlackBotProviderError("file_requires_user_access");
1073
1475
  }
1476
+ try {
1477
+ assertPrivateSlackFileUrl(redirected);
1478
+ } catch {
1479
+ throw new SlackBotProviderError("invalid_file_redirect");
1480
+ }
1074
1481
  return await this.fetchPrivateFileOnce(redirected, operation);
1075
1482
  }
1076
1483
 
@@ -1078,6 +1485,11 @@ export class OpenGeniSlackBotClient {
1078
1485
  url: URL,
1079
1486
  operation: "file.info" | "file.content.read",
1080
1487
  ): Promise<Response> {
1488
+ try {
1489
+ assertPrivateSlackFileUrl(url);
1490
+ } catch {
1491
+ throw new SlackBotProviderError("invalid_file_url");
1492
+ }
1081
1493
  const headers = await this.headersForDestination(operation, url.toString());
1082
1494
  let response: Response;
1083
1495
  try {
@@ -1155,6 +1567,7 @@ export class OpenGeniSlackBotClient {
1155
1567
  targetId: string;
1156
1568
  threadTimestamp?: string;
1157
1569
  text: string;
1570
+ blocks?: SlackMessageBlock[];
1158
1571
  }): string {
1159
1572
  const key = environmentsEncryptionKeyBytes(this.settings);
1160
1573
  if (!key) throw new Error("connection encryption is not configured");
@@ -1167,6 +1580,30 @@ export class OpenGeniSlackBotClient {
1167
1580
  targetId: input.targetId,
1168
1581
  threadTimestamp: input.threadTimestamp ?? null,
1169
1582
  text: input.text,
1583
+ blocks: input.blocks ?? null,
1584
+ }),
1585
+ )
1586
+ .digest("hex");
1587
+ }
1588
+
1589
+ private updateRequestDigest(input: {
1590
+ operationId: string;
1591
+ channelId: string;
1592
+ timestamp: string;
1593
+ text: string;
1594
+ blocks?: SlackMessageBlock[];
1595
+ }): string {
1596
+ const key = environmentsEncryptionKeyBytes(this.settings);
1597
+ if (!key) throw new Error("connection encryption is not configured");
1598
+ return createHmac("sha256", key)
1599
+ .update(
1600
+ JSON.stringify({
1601
+ operationId: input.operationId,
1602
+ connectionId: this.connection.id,
1603
+ channelId: input.channelId,
1604
+ timestamp: input.timestamp,
1605
+ text: input.text,
1606
+ blocks: input.blocks ?? null,
1170
1607
  }),
1171
1608
  )
1172
1609
  .digest("hex");
@@ -1424,10 +1861,15 @@ function projectChannel(value: unknown) {
1424
1861
  isPrivate: channel.is_private === true,
1425
1862
  isMember: channel.is_member === true,
1426
1863
  isDirectMessage: channel.is_im === true,
1864
+ isMpim: channel.is_mpim === true,
1427
1865
  isArchived: channel.is_archived === true,
1428
1866
  isShared: channel.is_shared === true,
1429
1867
  isExternallyShared: channel.is_ext_shared === true,
1430
1868
  isOrgShared: channel.is_org_shared === true,
1869
+ isPendingExternallyShared: channel.is_pending_ext_shared === true,
1870
+ contextTeamId: nullableBoundedSlackString(channel.context_team_id, 128),
1871
+ connectedTeamIds: slackStringArrayOrNull(channel.connected_team_ids, 128),
1872
+ sharedTeamIds: slackStringArrayOrNull(channel.shared_team_ids, 128),
1431
1873
  topic: boundedSlackString(slackRecord(channel.topic)?.value, 1_024),
1432
1874
  purpose: boundedSlackString(slackRecord(channel.purpose)?.value, 1_024),
1433
1875
  numMembers:
@@ -1437,6 +1879,45 @@ function projectChannel(value: unknown) {
1437
1879
  };
1438
1880
  }
1439
1881
 
1882
+ function projectSlackTaskPolicyUser(value: unknown, installationTeamId: string) {
1883
+ const user = slackRecord(value);
1884
+ const id = slackString(user?.id);
1885
+ const teamId = nullableBoundedSlackString(user?.team_id ?? user?.team, 128);
1886
+ if (!user || !id) return null;
1887
+ const guestFactsPresent =
1888
+ typeof user.is_restricted === "boolean" && typeof user.is_ultra_restricted === "boolean";
1889
+ const explicitExternal = typeof user.is_external === "boolean" ? user.is_external : null;
1890
+ return {
1891
+ id,
1892
+ teamId,
1893
+ isGuest: guestFactsPresent
1894
+ ? user.is_restricted === true || user.is_ultra_restricted === true
1895
+ : null,
1896
+ isExternal:
1897
+ explicitExternal !== null
1898
+ ? explicitExternal
1899
+ : teamId === null
1900
+ ? null
1901
+ : teamId !== installationTeamId,
1902
+ };
1903
+ }
1904
+
1905
+ function nullableBoundedSlackString(value: unknown, maxLength: number): string | null {
1906
+ const bounded = boundedSlackString(value, maxLength);
1907
+ return bounded || null;
1908
+ }
1909
+
1910
+ function slackStringArrayOrNull(value: unknown, maxLength: number): string[] | null {
1911
+ if (!Array.isArray(value)) return null;
1912
+ const result: string[] = [];
1913
+ for (const item of value) {
1914
+ const bounded = boundedSlackString(item, maxLength);
1915
+ if (!bounded) return null;
1916
+ result.push(bounded);
1917
+ }
1918
+ return [...new Set(result)].sort();
1919
+ }
1920
+
1440
1921
  function projectMessage(value: unknown) {
1441
1922
  const message = slackRecord(value) ?? {};
1442
1923
  return {
@@ -1884,8 +2365,8 @@ function privateSlackFileUrl(file: Record<string, unknown>): URL | null {
1884
2365
 
1885
2366
  function assertPrivateSlackFileUrl(url: URL): void {
1886
2367
  const hostname = url.hostname.toLowerCase();
1887
- if (url.protocol !== "https:" || (hostname !== "slack.com" && !hostname.endsWith(".slack.com"))) {
1888
- throw new Error("Slack file URL must use HTTPS on slack.com");
2368
+ if (url.protocol !== "https:" || !SLACK_PRIVATE_FILE_HOSTS.has(hostname) || url.port) {
2369
+ throw new Error("Slack file URL must use an allowed HTTPS Slack host");
1889
2370
  }
1890
2371
  if (url.username || url.password) {
1891
2372
  throw new Error("Slack file URL must not contain credentials");
@@ -1896,10 +2377,172 @@ function isSlackInteractiveFileRedirect(url: URL): boolean {
1896
2377
  return url.pathname === "/" && url.searchParams.has("redir");
1897
2378
  }
1898
2379
 
2380
+ function isSlackOwnedInteractiveFileRedirect(url: URL): boolean {
2381
+ const hostname = url.hostname.toLowerCase();
2382
+ return (
2383
+ url.protocol === "https:" &&
2384
+ !url.port &&
2385
+ !url.username &&
2386
+ !url.password &&
2387
+ (hostname === "slack.com" || hostname.endsWith(".slack.com")) &&
2388
+ isSlackInteractiveFileRedirect(url)
2389
+ );
2390
+ }
2391
+
1899
2392
  function normalizedContentType(value: string | null): string {
1900
2393
  return (value ?? "application/octet-stream").split(";", 1)[0]!.trim().toLowerCase();
1901
2394
  }
1902
2395
 
2396
+ export function sniffSlackReactionImageMime(
2397
+ bytes: Uint8Array,
2398
+ ): "image/png" | "image/jpeg" | "image/webp" | null {
2399
+ if (bytes.byteLength < 12 || hasMarkupPrefix(bytes)) return null;
2400
+ if (isCompletePng(bytes)) return "image/png";
2401
+ if (isCompleteJpeg(bytes)) return "image/jpeg";
2402
+ if (isCompleteWebp(bytes)) return "image/webp";
2403
+ return null;
2404
+ }
2405
+
2406
+ function hasMarkupPrefix(bytes: Uint8Array): boolean {
2407
+ const prefix = new TextDecoder("utf-8", { fatal: false })
2408
+ .decode(bytes.subarray(0, Math.min(bytes.byteLength, 256)))
2409
+ .replace(/^\uFEFF/u, "")
2410
+ .trimStart()
2411
+ .toLowerCase();
2412
+ return (
2413
+ prefix.startsWith("<svg") ||
2414
+ prefix.startsWith("<html") ||
2415
+ prefix.startsWith("<!doctype") ||
2416
+ prefix.startsWith("<?xml")
2417
+ );
2418
+ }
2419
+
2420
+ function isCompletePng(bytes: Uint8Array): boolean {
2421
+ const signature = [137, 80, 78, 71, 13, 10, 26, 10];
2422
+ if (!signature.every((value, index) => bytes[index] === value)) return false;
2423
+ let offset = 8;
2424
+ let sawHeader = false;
2425
+ while (offset + 12 <= bytes.byteLength) {
2426
+ const length = readUint32Be(bytes, offset);
2427
+ const typeOffset = offset + 4;
2428
+ const dataOffset = offset + 8;
2429
+ const next = dataOffset + length + 4;
2430
+ if (!Number.isSafeInteger(next) || next > bytes.byteLength) return false;
2431
+ const type = String.fromCharCode(
2432
+ bytes[typeOffset]!,
2433
+ bytes[typeOffset + 1]!,
2434
+ bytes[typeOffset + 2]!,
2435
+ bytes[typeOffset + 3]!,
2436
+ );
2437
+ if (!sawHeader) {
2438
+ if (type !== "IHDR" || length !== 13) return false;
2439
+ sawHeader = true;
2440
+ }
2441
+ if (type === "IEND") return length === 0 && next === bytes.byteLength;
2442
+ offset = next;
2443
+ }
2444
+ return false;
2445
+ }
2446
+
2447
+ function isCompleteJpeg(bytes: Uint8Array): boolean {
2448
+ if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return false;
2449
+ let offset = 2;
2450
+ let sawFrame = false;
2451
+ let inScan = false;
2452
+ while (offset < bytes.byteLength) {
2453
+ if (inScan) {
2454
+ if (bytes[offset] !== 0xff) {
2455
+ offset += 1;
2456
+ continue;
2457
+ }
2458
+ while (bytes[offset] === 0xff) offset += 1;
2459
+ const marker = bytes[offset];
2460
+ if (marker === undefined) return false;
2461
+ if (marker === 0x00 || (marker >= 0xd0 && marker <= 0xd7)) {
2462
+ offset += 1;
2463
+ continue;
2464
+ }
2465
+ if (marker === 0xd9) return sawFrame && offset + 1 === bytes.byteLength;
2466
+ inScan = false;
2467
+ offset -= 1;
2468
+ continue;
2469
+ }
2470
+ if (bytes[offset] !== 0xff) return false;
2471
+ while (bytes[offset] === 0xff) offset += 1;
2472
+ const marker = bytes[offset++];
2473
+ if (marker === undefined || marker === 0x00) return false;
2474
+ if (marker === 0xd9) return sawFrame && offset === bytes.byteLength;
2475
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
2476
+ if (offset + 2 > bytes.byteLength) return false;
2477
+ const length = (bytes[offset]! << 8) | bytes[offset + 1]!;
2478
+ if (length < 2 || offset + length > bytes.byteLength) return false;
2479
+ if (
2480
+ marker === 0xc0 ||
2481
+ marker === 0xc1 ||
2482
+ marker === 0xc2 ||
2483
+ marker === 0xc3 ||
2484
+ marker === 0xc5 ||
2485
+ marker === 0xc6 ||
2486
+ marker === 0xc7 ||
2487
+ marker === 0xc9 ||
2488
+ marker === 0xca ||
2489
+ marker === 0xcb ||
2490
+ marker === 0xcd ||
2491
+ marker === 0xce ||
2492
+ marker === 0xcf
2493
+ ) {
2494
+ sawFrame = true;
2495
+ }
2496
+ if (marker === 0xda) inScan = true;
2497
+ offset += length;
2498
+ }
2499
+ return false;
2500
+ }
2501
+
2502
+ function isCompleteWebp(bytes: Uint8Array): boolean {
2503
+ if (
2504
+ ascii(bytes, 0, 4) !== "RIFF" ||
2505
+ ascii(bytes, 8, 12) !== "WEBP" ||
2506
+ readUint32Le(bytes, 4) + 8 !== bytes.byteLength
2507
+ ) {
2508
+ return false;
2509
+ }
2510
+ let offset = 12;
2511
+ let sawImageChunk = false;
2512
+ while (offset + 8 <= bytes.byteLength) {
2513
+ const type = ascii(bytes, offset, offset + 4);
2514
+ const length = readUint32Le(bytes, offset + 4);
2515
+ const paddedLength = length + (length % 2);
2516
+ const next = offset + 8 + paddedLength;
2517
+ if (!Number.isSafeInteger(next) || next > bytes.byteLength) return false;
2518
+ if (type === "VP8 " || type === "VP8L" || type === "VP8X") sawImageChunk = true;
2519
+ offset = next;
2520
+ }
2521
+ return sawImageChunk && offset === bytes.byteLength;
2522
+ }
2523
+
2524
+ function readUint32Be(bytes: Uint8Array, offset: number): number {
2525
+ return (
2526
+ bytes[offset]! * 0x1_000000 +
2527
+ bytes[offset + 1]! * 0x1_0000 +
2528
+ bytes[offset + 2]! * 0x100 +
2529
+ bytes[offset + 3]!
2530
+ );
2531
+ }
2532
+
2533
+ function readUint32Le(bytes: Uint8Array, offset: number): number {
2534
+ return (
2535
+ bytes[offset]! +
2536
+ bytes[offset + 1]! * 0x100 +
2537
+ bytes[offset + 2]! * 0x1_0000 +
2538
+ bytes[offset + 3]! * 0x1_000000
2539
+ );
2540
+ }
2541
+
2542
+ function ascii(bytes: Uint8Array, start: number, end: number): string {
2543
+ return String.fromCharCode(...bytes.subarray(start, end));
2544
+ }
2545
+
1903
2546
  function isSupportedSlackTextContentType(value: string): boolean {
1904
2547
  return (
1905
2548
  value.startsWith("text/") ||
@@ -2075,11 +2718,26 @@ function slackMethodForOperation(operation: SlackBotOperation): string {
2075
2718
  return "files.info";
2076
2719
  case "message.post":
2077
2720
  return "chat.postMessage";
2721
+ case "message.update":
2722
+ return "chat.update";
2078
2723
  case "message.delete":
2079
2724
  return "chat.delete";
2080
2725
  }
2081
2726
  }
2082
2727
 
2728
+ function validateSlackMessageBlocks(
2729
+ blocks: SlackMessageBlock[] | undefined,
2730
+ ): SlackMessageBlock[] | undefined {
2731
+ if (blocks === undefined) return undefined;
2732
+ if (blocks.length < 1 || blocks.length > MAX_SLACK_BLOCKS) {
2733
+ throw new RangeError("Slack message blocks exceed the supported count");
2734
+ }
2735
+ if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > MAX_SLACK_BLOCK_BYTES) {
2736
+ throw new RangeError("Slack message blocks exceed the supported byte size");
2737
+ }
2738
+ return blocks;
2739
+ }
2740
+
2083
2741
  function boundedInt(value: number | undefined, max: number, fallback: number): number {
2084
2742
  return typeof value === "number" && Number.isInteger(value) && value > 0
2085
2743
  ? Math.min(value, max)
@@ -2131,9 +2789,9 @@ function safeFailureCode(error: unknown): string {
2131
2789
 
2132
2790
  function slackMutationOutcomeMayBeAmbiguous(error: unknown): boolean {
2133
2791
  if (!(error instanceof SlackBotProviderError)) return true;
2134
- return (
2135
- error.code === "transport_error" ||
2136
- error.code === "invalid_response" ||
2137
- error.code.startsWith("http_")
2138
- );
2792
+ if (error.code === "transport_error" || error.code === "invalid_response") return true;
2793
+ const httpStatus = /^http_(\d{3})$/u.exec(error.code)?.[1];
2794
+ if (!httpStatus) return false;
2795
+ const status = Number(httpStatus);
2796
+ return status === 408 || status >= 500;
2139
2797
  }