@vellumai/vellum-gateway 0.7.0 → 0.7.2

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 (162) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +67 -25
  3. package/Dockerfile +2 -0
  4. package/README.md +50 -13
  5. package/bun.lock +16 -2
  6. package/knip.json +3 -1
  7. package/package.json +3 -1
  8. package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
  9. package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
  10. package/src/__tests__/config-file-watcher.test.ts +181 -0
  11. package/src/__tests__/config.test.ts +0 -1
  12. package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
  13. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
  14. package/src/__tests__/credential-watcher.test.ts +30 -2
  15. package/src/__tests__/db-connection-isolation.test.ts +157 -0
  16. package/src/__tests__/fake-assistant-ipc.ts +39 -0
  17. package/src/__tests__/feature-flags-route.test.ts +8 -8
  18. package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
  19. package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
  20. package/src/__tests__/live-voice-websocket.test.ts +0 -1
  21. package/src/__tests__/load-guards.test.ts +0 -1
  22. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
  23. package/src/__tests__/oauth-callback.test.ts +0 -1
  24. package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
  25. package/src/__tests__/rate-limit-loopback.test.ts +1 -1
  26. package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
  27. package/src/__tests__/resolve-assistant.test.ts +0 -1
  28. package/src/__tests__/route-schema-guard.test.ts +42 -6
  29. package/src/__tests__/runtime-client.test.ts +0 -1
  30. package/src/__tests__/runtime-health-proxy.test.ts +0 -1
  31. package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
  32. package/src/__tests__/runtime-proxy.test.ts +0 -1
  33. package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
  34. package/src/__tests__/slack-display-name.test.ts +66 -1
  35. package/src/__tests__/slack-normalize.test.ts +158 -4
  36. package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
  37. package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
  38. package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
  39. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
  40. package/src/__tests__/stt-stream-websocket.test.ts +0 -1
  41. package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
  42. package/src/__tests__/telegram-send-attachments.test.ts +0 -1
  43. package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
  44. package/src/__tests__/text-verification-helpers.test.ts +136 -0
  45. package/src/__tests__/twilio-media-websocket.test.ts +0 -1
  46. package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
  47. package/src/__tests__/twilio-webhooks.test.ts +220 -3
  48. package/src/__tests__/upstream-transport.test.ts +0 -36
  49. package/src/__tests__/whatsapp-download.test.ts +0 -1
  50. package/src/__tests__/whatsapp-webhook.test.ts +0 -1
  51. package/src/auth/guardian-refresh.ts +4 -18
  52. package/src/auth/ipc-route-policy.ts +217 -0
  53. package/src/backup/backup-key.ts +138 -0
  54. package/src/backup/backup-routes.ts +159 -0
  55. package/src/backup/backup-worker.ts +374 -0
  56. package/src/backup/list-snapshots.ts +97 -0
  57. package/src/backup/local-writer.ts +87 -0
  58. package/src/backup/offsite-writer.ts +182 -0
  59. package/src/backup/paths.ts +123 -0
  60. package/src/backup/stream-crypt.ts +258 -0
  61. package/src/chrome-extension-origins.ts +28 -0
  62. package/src/cli/enable-proxy.ts +0 -1
  63. package/src/config-file-cache.ts +3 -19
  64. package/src/config-file-utils.ts +124 -0
  65. package/src/config-file-watcher.ts +57 -25
  66. package/src/config.ts +4 -7
  67. package/src/db/connection.ts +65 -3
  68. package/src/db/contact-store.ts +30 -1
  69. package/src/db/data-migrations/index.ts +2 -0
  70. package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
  71. package/src/db/schema.ts +92 -0
  72. package/src/db/slack-store.ts +144 -11
  73. package/src/feature-flag-registry.json +40 -152
  74. package/src/handlers/handle-inbound.ts +123 -0
  75. package/src/http/middleware/auth.ts +44 -1
  76. package/src/http/middleware/cors.ts +84 -0
  77. package/src/http/middleware/rate-limit.ts +6 -8
  78. package/src/http/routes/auto-approve-thresholds.ts +17 -1
  79. package/src/http/routes/brain-graph-proxy.ts +1 -1
  80. package/src/http/routes/channel-readiness-proxy.ts +2 -2
  81. package/src/http/routes/channel-verification-session-proxy.ts +19 -37
  82. package/src/http/routes/contact-prompt.ts +149 -0
  83. package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
  84. package/src/http/routes/email-webhook.test.ts +0 -1
  85. package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
  86. package/src/http/routes/ipc-runtime-proxy.ts +95 -0
  87. package/src/http/routes/log-export.test.ts +0 -1
  88. package/src/http/routes/log-tail.test.ts +336 -0
  89. package/src/http/routes/log-tail.ts +87 -0
  90. package/src/http/routes/migration-proxy.ts +1 -2
  91. package/src/http/routes/oauth-apps-proxy.ts +2 -2
  92. package/src/http/routes/oauth-providers-proxy.ts +2 -2
  93. package/src/http/routes/pair.ts +322 -0
  94. package/src/http/routes/privacy-config.ts +65 -79
  95. package/src/http/routes/runtime-health-proxy.ts +2 -2
  96. package/src/http/routes/runtime-proxy.ts +3 -1
  97. package/src/http/routes/slack-control-plane-proxy.ts +3 -20
  98. package/src/http/routes/stt-stream-websocket.ts +2 -3
  99. package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
  100. package/src/http/routes/telegram-webhook.test.ts +0 -1
  101. package/src/http/routes/telegram-webhook.ts +6 -0
  102. package/src/http/routes/trust-rules.suggest.test.ts +25 -0
  103. package/src/http/routes/trust-rules.ts +7 -0
  104. package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
  105. package/src/http/routes/twilio-media-websocket.ts +5 -5
  106. package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
  107. package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
  108. package/src/http/routes/twilio-voice-webhook.ts +45 -1
  109. package/src/http/routes/whatsapp-webhook.test.ts +0 -1
  110. package/src/index.ts +357 -278
  111. package/src/ipc/assistant-client.ts +8 -4
  112. package/src/ipc/contact-handlers.ts +88 -3
  113. package/src/ipc/threshold-handlers.ts +2 -0
  114. package/src/post-assistant-ready.ts +5 -3
  115. package/src/risk/bash-risk-classifier.test.ts +35 -27
  116. package/src/risk/bash-risk-classifier.ts +44 -14
  117. package/src/risk/command-registry/commands/assistant.ts +8 -19
  118. package/src/risk/command-registry.test.ts +0 -15
  119. package/src/risk/risk-classifier-parity.test.ts +1 -3
  120. package/src/runtime/client.ts +58 -3
  121. package/src/schema.ts +277 -104
  122. package/src/slack/normalize.test.ts +98 -0
  123. package/src/slack/normalize.ts +107 -32
  124. package/src/slack/slack-web.ts +213 -0
  125. package/src/slack/socket-mode.ts +701 -39
  126. package/src/telegram/send.test.ts +0 -1
  127. package/src/twilio/validate-webhook.ts +53 -14
  128. package/src/twilio/webhook-sync-trigger.ts +58 -0
  129. package/src/twilio/webhook-sync.test.ts +286 -0
  130. package/src/twilio/webhook-sync.ts +84 -0
  131. package/src/util/is-loopback-address.ts +27 -0
  132. package/src/velay/bridge-utils.ts +228 -0
  133. package/src/velay/client.test.ts +939 -0
  134. package/src/velay/client.ts +555 -0
  135. package/src/velay/http-bridge.test.ts +217 -0
  136. package/src/velay/http-bridge.ts +83 -0
  137. package/src/velay/protocol.ts +178 -0
  138. package/src/velay/test-fake-websocket.ts +69 -0
  139. package/src/velay/websocket-bridge.test.ts +367 -0
  140. package/src/velay/websocket-bridge.ts +324 -0
  141. package/src/verification/binding-helpers.ts +107 -0
  142. package/src/verification/code-parsing.ts +44 -0
  143. package/src/verification/contact-helpers.ts +342 -0
  144. package/src/verification/identity-match.ts +68 -0
  145. package/src/verification/identity.ts +61 -0
  146. package/src/verification/rate-limit-helpers.ts +205 -0
  147. package/src/verification/reply-delivery.ts +109 -0
  148. package/src/verification/session-helpers.ts +164 -0
  149. package/src/verification/text-verification.ts +372 -0
  150. package/src/version.ts +35 -0
  151. package/src/voice/verification.ts +456 -0
  152. package/src/webhook-pipeline.ts +4 -0
  153. package/src/__tests__/browser-relay-websocket.test.ts +0 -698
  154. package/src/__tests__/telegram-only-default.test.ts +0 -133
  155. package/src/auth/capability-tokens.ts +0 -248
  156. package/src/http/routes/browser-extension-pair.ts +0 -455
  157. package/src/http/routes/browser-relay-websocket.ts +0 -381
  158. package/src/http/routes/config-file-utils.ts +0 -73
  159. package/src/ipc/capability-token-handlers.ts +0 -30
  160. package/src/pairing/approved-devices-store.ts +0 -110
  161. package/src/pairing/pairing-routes.ts +0 -379
  162. package/src/pairing/pairing-store.ts +0 -218
@@ -1,3 +1,8 @@
1
+ import {
2
+ renderSlackTextForModel,
3
+ stripLeadingSlackMentionFallback,
4
+ stripLeadingSlackUserMention,
5
+ } from "@vellumai/slack-text";
1
6
  import type { GatewayConfig } from "../config.js";
2
7
  import { fetchImpl } from "../fetch.js";
3
8
  import { resolveAssistant, isRejection } from "../routing/resolve-assistant.js";
@@ -275,15 +280,55 @@ export interface SlackMessageDeletedEvent {
275
280
  };
276
281
  }
277
282
 
283
+ export type SlackTextRenderContext = {
284
+ botUserId?: string;
285
+ userLabels?: Record<string, string>;
286
+ };
287
+
278
288
  /**
279
- * Strip leading bot-mention tokens (`<@U...>`) from the message text.
280
- * Slack wraps mentions as `<@UXXXXXX>`, often at the start of an
281
- * app_mention event's text field. We remove all leading occurrences
282
- * so the assistant receives clean user content.
289
+ * Strip leading bot-mention tokens from Slack message text.
290
+ *
291
+ * When the bot user ID is known, only that exact mention is stripped. Without a
292
+ * bot user ID, strip just one leading mention as an app_mention compatibility
293
+ * fallback.
283
294
  */
284
- export function stripBotMention(text: string): string {
285
- const stripped = text.replace(/^(<@[A-Z0-9]+>\s*)+/i, "").trim();
286
- return stripped || text.trim();
295
+ export function stripBotMention(text: string, botUserId?: string): string {
296
+ const stripped = botUserId
297
+ ? stripLeadingSlackUserMention(text, botUserId)
298
+ : stripLeadingSlackMentionFallback(text);
299
+ return stripped.trim() || text.trim();
300
+ }
301
+
302
+ function renderSlackInboundText(
303
+ text: string,
304
+ context: SlackTextRenderContext = {},
305
+ options: {
306
+ stripLeadingBotMention?: boolean;
307
+ fallbackStripFirstMention?: boolean;
308
+ } = {},
309
+ ): string {
310
+ let stripped = text;
311
+ if (options.stripLeadingBotMention) {
312
+ stripped = context.botUserId
313
+ ? stripBotMention(text, context.botUserId)
314
+ : options.fallbackStripFirstMention
315
+ ? stripBotMention(text)
316
+ : text.trim();
317
+ }
318
+
319
+ return renderSlackTextForModel(stripped, {
320
+ userLabels: context.userLabels,
321
+ });
322
+ }
323
+
324
+ function withBotUserId(
325
+ botUserId: string | undefined,
326
+ context: SlackTextRenderContext | undefined,
327
+ ): SlackTextRenderContext {
328
+ return {
329
+ ...context,
330
+ botUserId: context?.botUserId ?? botUserId,
331
+ };
287
332
  }
288
333
 
289
334
  function extractSlackAttachments(files: SlackFile[] | undefined): Array<{
@@ -307,6 +352,18 @@ function extractSlackAttachments(files: SlackFile[] | undefined): Array<{
307
352
  }));
308
353
  }
309
354
 
355
+ function extractSlackFileMap(
356
+ files: SlackFile[] | undefined,
357
+ ): Map<string, SlackFile> | undefined {
358
+ if (!files || files.length === 0) return undefined;
359
+ const downloadableFiles = files.filter(
360
+ (f) => f.url_private_download || f.url_private,
361
+ );
362
+ return downloadableFiles.length
363
+ ? new Map(downloadableFiles.map((f) => [f.id, f]))
364
+ : undefined;
365
+ }
366
+
310
367
  export type NormalizedSlackEvent = {
311
368
  event: GatewayInboundEvent;
312
369
  routing: RouteResult;
@@ -332,6 +389,7 @@ export function normalizeSlackDirectMessage(
332
389
  config: GatewayConfig,
333
390
  botUserId?: string,
334
391
  botToken?: string,
392
+ renderContext?: SlackTextRenderContext,
335
393
  ): NormalizedSlackEvent | null {
336
394
  // Ignore messages from the bot itself
337
395
  if (botUserId && event.user === botUserId) return null;
@@ -360,13 +418,7 @@ export function normalizeSlackDirectMessage(
360
418
  event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
361
419
 
362
420
  const attachments = extractSlackAttachments(event.files);
363
- const slackFiles = event.files?.length
364
- ? new Map(
365
- event.files
366
- .filter((f) => f.url_private_download || f.url_private)
367
- .map((f) => [f.id, f]),
368
- )
369
- : undefined;
421
+ const slackFiles = extractSlackFileMap(event.files);
370
422
 
371
423
  // Use cache-only lookup to avoid blocking normalization on network calls.
372
424
  // A background fetch warms the cache for subsequent messages from this user.
@@ -374,6 +426,10 @@ export function normalizeSlackDirectMessage(
374
426
  botToken && event.user
375
427
  ? resolveSlackUserSync(event.user, botToken)
376
428
  : undefined;
429
+ const content = renderSlackInboundText(
430
+ event.text,
431
+ withBotUserId(botUserId, renderContext),
432
+ );
377
433
 
378
434
  return {
379
435
  event: {
@@ -381,7 +437,7 @@ export function normalizeSlackDirectMessage(
381
437
  sourceChannel: "slack",
382
438
  receivedAt: new Date().toISOString(),
383
439
  message: {
384
- content: event.text,
440
+ content,
385
441
  conversationExternalId: event.channel,
386
442
  externalMessageId,
387
443
  ...(attachments.length > 0 ? { attachments } : {}),
@@ -396,6 +452,7 @@ export function normalizeSlackDirectMessage(
396
452
  source: {
397
453
  updateId: eventId,
398
454
  messageId: event.ts,
455
+ chatType: "im",
399
456
  ...(event.thread_ts ? { threadId: event.thread_ts } : {}),
400
457
  },
401
458
  raw: event as unknown as Record<string, unknown>,
@@ -420,6 +477,7 @@ export function normalizeSlackChannelMessage(
420
477
  config: GatewayConfig,
421
478
  botUserId?: string,
422
479
  botToken?: string,
480
+ renderContext?: SlackTextRenderContext,
423
481
  ): NormalizedSlackEvent | null {
424
482
  if (botUserId && event.user === botUserId) return null;
425
483
  // file_share is allowed so image/file uploads are delivered to the assistant.
@@ -429,18 +487,16 @@ export function normalizeSlackChannelMessage(
429
487
  const routing = resolveAssistant(config, event.channel, event.user);
430
488
  if (isRejection(routing)) return null;
431
489
 
432
- const content = stripBotMention(event.text);
490
+ const content = renderSlackInboundText(
491
+ event.text,
492
+ withBotUserId(botUserId, renderContext),
493
+ { stripLeadingBotMention: true, fallbackStripFirstMention: true },
494
+ );
433
495
  const externalMessageId =
434
496
  event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
435
497
 
436
498
  const attachments = extractSlackAttachments(event.files);
437
- const slackFiles = event.files?.length
438
- ? new Map(
439
- event.files
440
- .filter((f) => f.url_private_download || f.url_private)
441
- .map((f) => [f.id, f]),
442
- )
443
- : undefined;
499
+ const slackFiles = extractSlackFileMap(event.files);
444
500
 
445
501
  const userInfo =
446
502
  botToken && event.user
@@ -491,25 +547,28 @@ export function normalizeSlackAppMention(
491
547
  event: SlackAppMentionEvent,
492
548
  eventId: string,
493
549
  config: GatewayConfig,
550
+ botUserId?: string,
494
551
  botToken?: string,
552
+ renderContext?: SlackTextRenderContext,
495
553
  ): NormalizedSlackEvent | null {
496
554
  const routing = resolveAssistant(config, event.channel, event.user);
497
555
  if (isRejection(routing)) {
498
556
  return null;
499
557
  }
500
558
 
501
- const content = stripBotMention(event.text);
559
+ const content = renderSlackInboundText(
560
+ event.text,
561
+ withBotUserId(botUserId, renderContext),
562
+ {
563
+ stripLeadingBotMention: true,
564
+ fallbackStripFirstMention: true,
565
+ },
566
+ );
502
567
  const externalMessageId =
503
568
  event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
504
569
 
505
570
  const attachments = extractSlackAttachments(event.files);
506
- const slackFiles = event.files?.length
507
- ? new Map(
508
- event.files
509
- .filter((f) => f.url_private_download || f.url_private)
510
- .map((f) => [f.id, f]),
511
- )
512
- : undefined;
571
+ const slackFiles = extractSlackFileMap(event.files);
513
572
 
514
573
  const userInfo =
515
574
  botToken && event.user
@@ -791,6 +850,7 @@ export function normalizeSlackMessageEdit(
791
850
  eventId: string,
792
851
  config: GatewayConfig,
793
852
  botUserId?: string,
853
+ renderContext?: SlackTextRenderContext,
794
854
  ): NormalizedSlackEvent | null {
795
855
  const edited = event.message;
796
856
  if (!edited) return null;
@@ -816,7 +876,14 @@ export function normalizeSlackMessageEdit(
816
876
  }
817
877
  if (isRejection(routing)) return null;
818
878
 
819
- const content = stripBotMention(edited.text);
879
+ const content = renderSlackInboundText(
880
+ edited.text,
881
+ withBotUserId(botUserId, renderContext),
882
+ {
883
+ stripLeadingBotMention: true,
884
+ fallbackStripFirstMention: !botUserId,
885
+ },
886
+ );
820
887
 
821
888
  // Each edit event gets a unique externalMessageId so the dedup pipeline
822
889
  // does not discard subsequent edits of the same Slack message.
@@ -875,9 +942,17 @@ export function normalizeSlackMessageDelete(
875
942
  event: SlackMessageDeletedEvent,
876
943
  eventId: string,
877
944
  config: GatewayConfig,
945
+ botUserId?: string,
878
946
  ): NormalizedSlackEvent | null {
879
947
  if (!event.deleted_ts) return null;
880
948
 
949
+ // Drop deletions of the bot's own messages. Slack echoes self-deletes back
950
+ // via Socket Mode; without this filter the bot's user ID flows into the
951
+ // assistant's ACL as the actor, fails member lookup (the bot is never its
952
+ // own trusted contact), and triggers a spurious access-request notification
953
+ // to the guardian. Mirrors the bot-self filter on the edit path above.
954
+ if (botUserId && event.previous_message?.user === botUserId) return null;
955
+
881
956
  // Use the previous author for actor identity when available; otherwise fall
882
957
  // back to a synthetic identifier so routing/trust still has something to key on.
883
958
  const actorId = event.previous_message?.user ?? "slack-system";
@@ -0,0 +1,213 @@
1
+ import { fetchImpl } from "../fetch.js";
2
+ import { getLogger } from "../logger.js";
3
+
4
+ const log = getLogger("slack-web");
5
+
6
+ /**
7
+ * Subset of the Slack `conversations.history` / `conversations.replies`
8
+ * message payload that the gateway needs for catch-up. Synthesized
9
+ * Socket Mode envelopes are built by mapping these fields onto the
10
+ * `app_mention` / `message` event shapes that the live path already
11
+ * understands.
12
+ */
13
+ export type SlackHistoryMessage = {
14
+ type?: string;
15
+ subtype?: string;
16
+ user?: string;
17
+ bot_id?: string;
18
+ text?: string;
19
+ ts: string;
20
+ thread_ts?: string;
21
+ team?: string;
22
+ blocks?: unknown[];
23
+ files?: unknown[];
24
+ attachments?: unknown[];
25
+ edited?: { user?: string; ts?: string };
26
+ };
27
+
28
+ type SlackHistoryResponse = {
29
+ ok: boolean;
30
+ error?: string;
31
+ messages?: SlackHistoryMessage[];
32
+ has_more?: boolean;
33
+ };
34
+
35
+ export type FetchSlackHistoryResult = {
36
+ /** Messages returned by Slack. Empty on transient failure. */
37
+ messages: SlackHistoryMessage[];
38
+ /** True when Slack indicates additional pages are available. */
39
+ hasMore: boolean;
40
+ /**
41
+ * `true` when the call succeeded (HTTP 200 + `ok: true`). False results
42
+ * are logged and silently treated as zero messages so a transient outage
43
+ * during catch-up doesn't block reconnect.
44
+ */
45
+ ok: boolean;
46
+ };
47
+
48
+ /** Slack rate-limit handling — wait this long after a 429 before retrying. */
49
+ const RATE_LIMIT_FALLBACK_MS = 5_000;
50
+
51
+ /**
52
+ * Coordinates per-cycle catch-up cancellation. When any worker hits a 429,
53
+ * it calls `abort()`; remaining workers check `aborted` before each call
54
+ * and bail out without issuing further requests, so the catch-up cycle
55
+ * stops cleanly instead of cascading into more rate-limit responses.
56
+ */
57
+ export class CatchupAbortSignal {
58
+ private flag = false;
59
+ abort(): void {
60
+ this.flag = true;
61
+ }
62
+ get aborted(): boolean {
63
+ return this.flag;
64
+ }
65
+ }
66
+
67
+ async function callSlackApi(
68
+ url: string,
69
+ botToken: string,
70
+ abort: CatchupAbortSignal | undefined,
71
+ ): Promise<SlackHistoryResponse | undefined> {
72
+ if (abort?.aborted) return undefined;
73
+
74
+ let resp: Response;
75
+ try {
76
+ resp = await fetchImpl(url, {
77
+ method: "GET",
78
+ headers: { Authorization: `Bearer ${botToken}` },
79
+ });
80
+ } catch (err) {
81
+ log.warn({ err, url: redact(url) }, "Slack catch-up fetch threw");
82
+ return undefined;
83
+ }
84
+
85
+ if (resp.status === 429) {
86
+ const retryHeader = resp.headers.get("retry-after");
87
+ const waitMs = retryHeader
88
+ ? Number.parseInt(retryHeader, 10) * 1_000
89
+ : RATE_LIMIT_FALLBACK_MS;
90
+ abort?.abort();
91
+ log.warn(
92
+ { url: redact(url), waitMs },
93
+ "Slack catch-up rate limited; aborting cycle",
94
+ );
95
+ return undefined;
96
+ }
97
+
98
+ if (!resp.ok) {
99
+ log.warn(
100
+ { url: redact(url), status: resp.status },
101
+ "Slack catch-up returned non-OK HTTP status",
102
+ );
103
+ return undefined;
104
+ }
105
+
106
+ let data: SlackHistoryResponse;
107
+ try {
108
+ data = (await resp.json()) as SlackHistoryResponse;
109
+ } catch (err) {
110
+ log.warn({ err, url: redact(url) }, "Slack catch-up response not JSON");
111
+ return undefined;
112
+ }
113
+
114
+ if (!data.ok) {
115
+ log.warn(
116
+ { url: redact(url), error: data.error },
117
+ "Slack catch-up API returned ok=false",
118
+ );
119
+ return undefined;
120
+ }
121
+
122
+ return data;
123
+ }
124
+
125
+ /** Strip the channel/oldest query for log readability. */
126
+ function redact(url: string): string {
127
+ const idx = url.indexOf("?");
128
+ return idx >= 0 ? url.slice(0, idx) : url;
129
+ }
130
+
131
+ /**
132
+ * Fetch messages in `channel` strictly newer than `oldest` (Slack ts).
133
+ * `inclusive=false` is the default; we set it explicitly to make the
134
+ * behavior obvious on read. No pagination — we cap at `limit` messages
135
+ * per channel per reconnect to bound API budget.
136
+ */
137
+ export async function fetchChannelHistorySince(params: {
138
+ botToken: string;
139
+ channel: string;
140
+ oldest: string;
141
+ limit: number;
142
+ abort?: CatchupAbortSignal;
143
+ }): Promise<FetchSlackHistoryResult> {
144
+ const { botToken, channel, oldest, limit, abort } = params;
145
+ const url =
146
+ "https://slack.com/api/conversations.history" +
147
+ `?channel=${encodeURIComponent(channel)}` +
148
+ `&oldest=${encodeURIComponent(oldest)}` +
149
+ `&limit=${limit}` +
150
+ `&inclusive=false`;
151
+ const data = await callSlackApi(url, botToken, abort);
152
+ if (!data) return { messages: [], hasMore: false, ok: false };
153
+ return {
154
+ messages: data.messages ?? [],
155
+ hasMore: data.has_more === true,
156
+ ok: true,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Fetch replies in a thread strictly newer than `oldest`. Uses
162
+ * `conversations.replies` because it returns a smaller, thread-scoped
163
+ * window than `conversations.history` and avoids re-paging the channel
164
+ * for every active thread on reconnect.
165
+ */
166
+ export async function fetchThreadRepliesSince(params: {
167
+ botToken: string;
168
+ channel: string;
169
+ threadTs: string;
170
+ oldest: string;
171
+ limit: number;
172
+ abort?: CatchupAbortSignal;
173
+ }): Promise<FetchSlackHistoryResult> {
174
+ const { botToken, channel, threadTs, oldest, limit, abort } = params;
175
+ const url =
176
+ "https://slack.com/api/conversations.replies" +
177
+ `?channel=${encodeURIComponent(channel)}` +
178
+ `&ts=${encodeURIComponent(threadTs)}` +
179
+ `&oldest=${encodeURIComponent(oldest)}` +
180
+ `&limit=${limit}` +
181
+ `&inclusive=false`;
182
+ const data = await callSlackApi(url, botToken, abort);
183
+ if (!data) return { messages: [], hasMore: false, ok: false };
184
+ return {
185
+ messages: data.messages ?? [],
186
+ hasMore: data.has_more === true,
187
+ ok: true,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Drives a list of async tasks with bounded concurrency. Used to fan out
193
+ * catch-up fetches across channels without flooding Slack's rate limits.
194
+ */
195
+ export async function runWithConcurrency<T>(
196
+ tasks: Array<() => Promise<T>>,
197
+ concurrency: number,
198
+ ): Promise<T[]> {
199
+ const results: T[] = new Array(tasks.length);
200
+ let cursor = 0;
201
+ const workers = Array.from(
202
+ { length: Math.min(concurrency, tasks.length) },
203
+ async () => {
204
+ while (true) {
205
+ const idx = cursor++;
206
+ if (idx >= tasks.length) return;
207
+ results[idx] = await tasks[idx]();
208
+ }
209
+ },
210
+ );
211
+ await Promise.all(workers);
212
+ return results;
213
+ }