@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,7 +1,16 @@
1
+ import { buildSlackUserLabelMap } from "@vellumai/slack-text";
1
2
  import { getLogger } from "../logger.js";
2
3
  import { fetchImpl } from "../fetch.js";
3
4
  import type { GatewayConfig } from "../config.js";
4
5
  import { SlackStore } from "../db/slack-store.js";
6
+ import { isRejection, resolveAssistant } from "../routing/resolve-assistant.js";
7
+ import {
8
+ CatchupAbortSignal,
9
+ fetchChannelHistorySince,
10
+ fetchThreadRepliesSince,
11
+ runWithConcurrency,
12
+ type SlackHistoryMessage,
13
+ } from "./slack-web.js";
5
14
  import {
6
15
  normalizeSlackAppMention,
7
16
  normalizeSlackDirectMessage,
@@ -30,6 +39,26 @@ const MAX_BACKOFF_MS = 30_000;
30
39
  const DEDUP_TTL_MS = 24 * 60 * 60 * 1_000;
31
40
  const DEDUP_CLEANUP_INTERVAL_MS = 60 * 60 * 1_000;
32
41
  const ACTIVE_THREAD_TTL_MS = 24 * 60 * 60 * 1_000;
42
+ const USER_RESOLVE_TIMEOUT_MS = 3_000;
43
+
44
+ /**
45
+ * Reconnect catch-up bounds.
46
+ *
47
+ * `MAX_LOOKBACK_MS` caps how far back we'll ask Slack for missed messages.
48
+ * Sleeps longer than this fall back to the daemon's existing inbound-
49
+ * triggered backfill (JARVIS-643) once new live events resume.
50
+ *
51
+ * `SAFETY_OVERLAP_MS` widens the `oldest` window slightly past the
52
+ * persisted watermark so a non-mention event that advanced the watermark
53
+ * cannot silently mask an earlier missed mention. Resulting overlap is
54
+ * absorbed by the compound `msg:${channel}:${ts}` dedup key.
55
+ *
56
+ * `HISTORY_LIMIT` and `CONCURRENCY` bound API budget per reconnect.
57
+ */
58
+ const CATCHUP_MAX_LOOKBACK_MS = 60 * 60 * 1_000;
59
+ const CATCHUP_SAFETY_OVERLAP_MS = 60 * 1_000;
60
+ const CATCHUP_HISTORY_LIMIT = 50;
61
+ const CATCHUP_CONCURRENCY = 4;
33
62
 
34
63
  export type SlackSocketModeConfig = {
35
64
  appToken: string;
@@ -61,6 +90,7 @@ export class SlackSocketModeClient {
61
90
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
62
91
  private dedupCleanupTimer: ReturnType<typeof setInterval> | null = null;
63
92
  private store: SlackStore;
93
+ private emitQueues: Map<string, Promise<void>> | undefined = new Map();
64
94
 
65
95
  constructor(
66
96
  config: SlackSocketModeConfig,
@@ -107,15 +137,7 @@ export class SlackSocketModeClient {
107
137
  if (data.team) {
108
138
  this.config.teamName = data.team;
109
139
  }
110
- // Warn if the bot token is missing scopes needed for file downloads.
111
- const scopes = resp.headers.get("x-oauth-scopes") ?? "";
112
- if (!scopes.split(",").some((s) => s.trim() === "files:read")) {
113
- log.warn(
114
- "Slack bot token is missing the 'files:read' scope — file/image " +
115
- "attachments will not be downloaded. Add 'files:read' to your " +
116
- "Slack app's Bot Token Scopes and reinstall the app.",
117
- );
118
- }
140
+ warnOnMissingSlackScopes(resp.headers.get("x-oauth-scopes") ?? "");
119
141
 
120
142
  log.info(
121
143
  {
@@ -247,10 +269,12 @@ export class SlackSocketModeClient {
247
269
  }
248
270
 
249
271
  /**
250
- * Register a thread as active so future replies (without @mention) are forwarded.
272
+ * Register a thread as active so future replies (without @mention) are
273
+ * forwarded. `channelId` is required so reconnect catch-up can scope a
274
+ * `conversations.replies` fetch to the right channel.
251
275
  */
252
- trackThread(threadTs: string): void {
253
- this.store.trackThread(threadTs, ACTIVE_THREAD_TTL_MS);
276
+ trackThread(threadTs: string, channelId: string): void {
277
+ this.store.trackThread(threadTs, channelId, ACTIVE_THREAD_TTL_MS);
254
278
  }
255
279
 
256
280
  /**
@@ -294,6 +318,13 @@ export class SlackSocketModeClient {
294
318
  ws.addEventListener("open", () => {
295
319
  log.info("Slack Socket Mode connected");
296
320
  this.reconnectAttempt = 0;
321
+ // Recover messages that arrived during the reconnect gap (Slack
322
+ // does not buffer Socket Mode events during disconnects). Runs
323
+ // off the open handler so initial-start, normal reconnect, and
324
+ // sleep/wake force-reconnect all share the same recovery path.
325
+ // Errors are swallowed inside replayMissedEvents — a failed
326
+ // catch-up should never destabilize the live socket.
327
+ void this.replayMissedEvents(ws);
297
328
  });
298
329
 
299
330
  ws.addEventListener("message", (messageEvent) => {
@@ -364,6 +395,7 @@ export class SlackSocketModeClient {
364
395
  type?: string;
365
396
  payload?: {
366
397
  event_id?: string;
398
+ event_time?: number;
367
399
  event?:
368
400
  | SlackAppMentionEvent
369
401
  | SlackDirectMessageEvent
@@ -429,11 +461,40 @@ export class SlackSocketModeClient {
429
461
 
430
462
  const eventPayload = envelope.payload;
431
463
  if (!eventPayload?.event) return;
432
-
433
- const event = eventPayload.event;
434
-
435
464
  if (!eventPayload.event_id) return;
436
465
 
466
+ this.processEventPayload({
467
+ event_id: eventPayload.event_id,
468
+ event_time: eventPayload.event_time,
469
+ event: eventPayload.event,
470
+ });
471
+ }
472
+
473
+ /**
474
+ * Filter, deduplicate, advance the watermark, and dispatch a single
475
+ * Slack event payload. Shared by the live Socket Mode path
476
+ * (`handleMessage`) and the reconnect catch-up path
477
+ * (`replayMissedEvents`) so both flows enforce identical filters,
478
+ * dedup, and ordering semantics.
479
+ *
480
+ * The `event_id` may be either a real Slack ID (live path) or a
481
+ * synthetic `replay:${channel}:${ts}` ID (replay path). Both flow
482
+ * through the same compound dedup table so the two paths never
483
+ * double-emit a message that arrived on both.
484
+ */
485
+ private processEventPayload(eventPayload: {
486
+ event_id: string;
487
+ event_time?: number;
488
+ event:
489
+ | SlackAppMentionEvent
490
+ | SlackDirectMessageEvent
491
+ | SlackChannelMessageEvent
492
+ | SlackMessageChangedEvent
493
+ | SlackMessageDeletedEvent
494
+ | SlackReactionAddedEvent
495
+ | SlackReactionRemovedEvent;
496
+ }): void {
497
+ const event = eventPayload.event;
437
498
  const dmEvent = event as SlackDirectMessageEvent;
438
499
  const channelEvent = event as SlackChannelMessageEvent;
439
500
  const messageChangedEvent = event as SlackMessageChangedEvent;
@@ -578,15 +639,78 @@ export class SlackSocketModeClient {
578
639
  "Slack event accepted by filter",
579
640
  );
580
641
 
581
- // Deduplicate on event_id
642
+ // Compound dedup. Live events are keyed by Slack `event_id`; replay
643
+ // events are keyed by `replay:${channel}:${ts}`. Both also write a
644
+ // `msg:${channel}:${ts}` key when the event has a stable
645
+ // (channel, ts) identity, so a message that arrives via both paths
646
+ // is deduped on the second arrival regardless of which came first.
582
647
  const eventId = eventPayload.event_id;
648
+ const messageKey = computeMessageDedupKey(event);
583
649
  if (this.store.hasEvent(eventId)) {
584
650
  log.debug({ eventId }, "Duplicate Slack event, skipping");
585
651
  return;
586
652
  }
653
+ if (messageKey && this.store.hasEvent(messageKey)) {
654
+ log.debug(
655
+ { eventId, messageKey },
656
+ "Slack event already seen via paired path, skipping",
657
+ );
658
+ return;
659
+ }
587
660
  this.store.markEventSeen(eventId, DEDUP_TTL_MS);
661
+ if (messageKey) {
662
+ this.store.markEventSeen(messageKey, DEDUP_TTL_MS);
663
+ }
664
+
665
+ // Advance the catch-up watermark before dispatch.
666
+ //
667
+ // Trade-off: emit happens off the per-channel `emitQueues` chain, which
668
+ // is in-memory and not persisted. The cases worth thinking about are:
669
+ //
670
+ // - daemon wedged, gateway alive: the queue stalls but does not drop;
671
+ // it drains when the daemon recovers. No loss.
672
+ // - gateway crash with daemon healthy: messages on the wire that have
673
+ // not yet been dedup-written are lost in memory, but the next
674
+ // reconnect refetches them via the watermark + 60s overlap. No loss.
675
+ // - gateway crash AND daemon outage simultaneously: the in-memory
676
+ // queue evaporates AND this watermark write has already advanced
677
+ // past the unsent messages, so the next reconnect will not refetch
678
+ // them. Genuinely lost.
679
+ //
680
+ // We accept the third case because the alternatives all regress
681
+ // something else: advancing after successful emit makes a slow emit
682
+ // stall the watermark and trigger wasteful refetch loops on every
683
+ // reconnect during transient slowness, and a later message in the same
684
+ // queue can still leapfrog the failed earlier one, so it does not
685
+ // actually fix the silent-skip. A persistent emit outbox would cover
686
+ // it, but that is a larger feature. The compensating daemon-side
687
+ // reactive backfill (`triggerSlackThreadBackfillIfNeeded`) hydrates
688
+ // thread context as soon as any follow-up message arrives, narrowing
689
+ // the user-visible blast radius to "fully missed mention with no
690
+ // follow-up, during a simultaneous gateway crash + daemon outage".
691
+ const watermarkTs = extractEventWatermarkTs(event, eventPayload.event_time);
692
+ if (watermarkTs) {
693
+ this.store.setLastSeenTsIfGreater(watermarkTs);
694
+ }
695
+
696
+ if (isAppMention) {
697
+ const appMentionEvent = event as SlackAppMentionEvent;
698
+ const threadTs = appMentionEvent.thread_ts ?? appMentionEvent.ts;
699
+ const routing = resolveAssistant(
700
+ this.config.gatewayConfig,
701
+ appMentionEvent.channel,
702
+ appMentionEvent.user,
703
+ );
704
+ if (threadTs && !isRejection(routing) && appMentionEvent.channel) {
705
+ this.store.trackThread(
706
+ threadTs,
707
+ appMentionEvent.channel,
708
+ ACTIVE_THREAD_TTL_MS,
709
+ );
710
+ }
711
+ }
588
712
 
589
- this.normalizeAndEmit(
713
+ this.enqueueNormalizeAndEmit(
590
714
  event,
591
715
  eventId,
592
716
  isAppMention,
@@ -599,7 +723,50 @@ export class SlackSocketModeClient {
599
723
  );
600
724
  }
601
725
 
602
- private normalizeAndEmit(
726
+ private extractTextBearingContent(
727
+ event:
728
+ | SlackAppMentionEvent
729
+ | SlackDirectMessageEvent
730
+ | SlackChannelMessageEvent
731
+ | SlackMessageChangedEvent
732
+ | SlackMessageDeletedEvent
733
+ | SlackReactionAddedEvent
734
+ | SlackReactionRemovedEvent,
735
+ ): string | undefined {
736
+ if (
737
+ event.type === "message" &&
738
+ (event as SlackMessageChangedEvent).subtype === "message_changed"
739
+ ) {
740
+ return (event as SlackMessageChangedEvent).message?.text;
741
+ }
742
+
743
+ if (event.type === "app_mention" || event.type === "message") {
744
+ return (event as SlackAppMentionEvent | SlackDirectMessageEvent).text;
745
+ }
746
+
747
+ return undefined;
748
+ }
749
+
750
+ private async resolveMentionLabelsForText(
751
+ text: string,
752
+ ): Promise<Record<string, string>> {
753
+ return buildSlackUserLabelMap(
754
+ [text],
755
+ async (id): Promise<string | undefined> => {
756
+ const userInfo = await Promise.race([
757
+ resolveSlackUser(id, this.config.botToken),
758
+ new Promise<undefined>((resolve) =>
759
+ setTimeout(resolve, USER_RESOLVE_TIMEOUT_MS),
760
+ ),
761
+ ]);
762
+ if (!userInfo) return undefined;
763
+ return userInfo.displayName || userInfo.username;
764
+ },
765
+ { ignoredUserIds: [this.config.botUserId] },
766
+ );
767
+ }
768
+
769
+ private enqueueNormalizeAndEmit(
603
770
  event:
604
771
  | SlackAppMentionEvent
605
772
  | SlackDirectMessageEvent
@@ -617,6 +784,103 @@ export class SlackSocketModeClient {
617
784
  isMessageDeleted: boolean,
618
785
  isDm: boolean,
619
786
  ): void {
787
+ const queues = (this.emitQueues ??= new Map());
788
+ const orderingKey = this.getEventOrderingKey(event, eventId);
789
+ const previous = queues.get(orderingKey) ?? Promise.resolve();
790
+ const current = previous
791
+ .catch(() => undefined)
792
+ .then(() =>
793
+ this.normalizeAndEmit(
794
+ event,
795
+ eventId,
796
+ isAppMention,
797
+ isActiveThreadReply,
798
+ isReactionAdded,
799
+ isReactionRemoved,
800
+ isMessageChanged,
801
+ isMessageDeleted,
802
+ isDm,
803
+ ),
804
+ );
805
+
806
+ queues.set(orderingKey, current);
807
+ void current
808
+ .catch((err: unknown) => {
809
+ log.error({ err, eventId }, "Slack event normalization failed");
810
+ })
811
+ .finally(() => {
812
+ if (queues.get(orderingKey) === current) {
813
+ queues.delete(orderingKey);
814
+ }
815
+ });
816
+ }
817
+
818
+ private getEventOrderingKey(
819
+ event:
820
+ | SlackAppMentionEvent
821
+ | SlackDirectMessageEvent
822
+ | SlackChannelMessageEvent
823
+ | SlackMessageChangedEvent
824
+ | SlackMessageDeletedEvent
825
+ | SlackReactionAddedEvent
826
+ | SlackReactionRemovedEvent,
827
+ eventId: string,
828
+ ): string {
829
+ if (event.type === "reaction_added" || event.type === "reaction_removed") {
830
+ const reaction = event as
831
+ | SlackReactionAddedEvent
832
+ | SlackReactionRemovedEvent;
833
+ return `${reaction.item.channel}:${reaction.item.ts}`;
834
+ }
835
+
836
+ if (
837
+ event.type === "message" &&
838
+ (event as SlackMessageChangedEvent).subtype === "message_changed"
839
+ ) {
840
+ const changed = event as SlackMessageChangedEvent;
841
+ return `${changed.channel}:${changed.message.thread_ts ?? changed.message.ts ?? eventId}`;
842
+ }
843
+
844
+ if (
845
+ event.type === "message" &&
846
+ (event as SlackMessageDeletedEvent).subtype === "message_deleted"
847
+ ) {
848
+ const deleted = event as SlackMessageDeletedEvent;
849
+ return `${deleted.channel}:${deleted.previous_message?.thread_ts ?? deleted.deleted_ts ?? eventId}`;
850
+ }
851
+
852
+ const message = event as
853
+ | SlackAppMentionEvent
854
+ | SlackDirectMessageEvent
855
+ | SlackChannelMessageEvent;
856
+ return `${message.channel}:${message.thread_ts ?? message.ts ?? eventId}`;
857
+ }
858
+
859
+ private async normalizeAndEmit(
860
+ event:
861
+ | SlackAppMentionEvent
862
+ | SlackDirectMessageEvent
863
+ | SlackChannelMessageEvent
864
+ | SlackMessageChangedEvent
865
+ | SlackMessageDeletedEvent
866
+ | SlackReactionAddedEvent
867
+ | SlackReactionRemovedEvent,
868
+ eventId: string,
869
+ isAppMention: boolean,
870
+ isActiveThreadReply: boolean,
871
+ isReactionAdded: boolean,
872
+ isReactionRemoved: boolean,
873
+ isMessageChanged: boolean,
874
+ isMessageDeleted: boolean,
875
+ isDm: boolean,
876
+ ): Promise<void> {
877
+ const text = this.extractTextBearingContent(event);
878
+ const userLabels = text ? await this.resolveMentionLabelsForText(text) : {};
879
+ const renderContext = {
880
+ botUserId: this.config.botUserId,
881
+ userLabels,
882
+ };
883
+
620
884
  let normalized: NormalizedSlackEvent | null;
621
885
  if (isReactionAdded) {
622
886
  normalized = normalizeSlackReactionAdded(
@@ -637,7 +901,9 @@ export class SlackSocketModeClient {
637
901
  event as SlackAppMentionEvent,
638
902
  eventId,
639
903
  this.config.gatewayConfig,
904
+ this.config.botUserId,
640
905
  this.config.botToken,
906
+ renderContext,
641
907
  );
642
908
  } else if (isMessageChanged) {
643
909
  normalized = normalizeSlackMessageEdit(
@@ -645,12 +911,14 @@ export class SlackSocketModeClient {
645
911
  eventId,
646
912
  this.config.gatewayConfig,
647
913
  this.config.botUserId,
914
+ renderContext,
648
915
  );
649
916
  } else if (isMessageDeleted) {
650
917
  normalized = normalizeSlackMessageDelete(
651
918
  event as SlackMessageDeletedEvent,
652
919
  eventId,
653
920
  this.config.gatewayConfig,
921
+ this.config.botUserId,
654
922
  );
655
923
  } else if (isActiveThreadReply) {
656
924
  normalized = normalizeSlackChannelMessage(
@@ -659,6 +927,7 @@ export class SlackSocketModeClient {
659
927
  this.config.gatewayConfig,
660
928
  this.config.botUserId,
661
929
  this.config.botToken,
930
+ renderContext,
662
931
  );
663
932
  } else if (isDm) {
664
933
  normalized = normalizeSlackDirectMessage(
@@ -667,6 +936,7 @@ export class SlackSocketModeClient {
667
936
  this.config.gatewayConfig,
668
937
  this.config.botUserId,
669
938
  this.config.botToken,
939
+ renderContext,
670
940
  );
671
941
  } else {
672
942
  log.warn(
@@ -692,12 +962,14 @@ export class SlackSocketModeClient {
692
962
  return;
693
963
  }
694
964
 
695
- // Track threads on the inbound side so follow-up replies (without
696
- // @mention) continue to be forwarded. Any forwarded event that carries
697
- // a thread_ts implies bot participation in that thread.
698
- const threadTs = (event as { thread_ts?: string }).thread_ts;
699
- if (threadTs) {
700
- this.store.trackThread(threadTs, ACTIVE_THREAD_TTL_MS);
965
+ // Track threads only for real participation signals so follow-up replies
966
+ // continue after app mentions and admitted messages, without reactions,
967
+ // edits, or deletes arming unrelated threads.
968
+ const threadTs = normalized.threadTs;
969
+ const channelId = normalized.event.message.conversationExternalId;
970
+ const shouldTrackActiveThread = isAppMention || isActiveThreadReply;
971
+ if (shouldTrackActiveThread && threadTs && channelId) {
972
+ this.store.trackThread(threadTs, channelId, ACTIVE_THREAD_TTL_MS);
701
973
  }
702
974
 
703
975
  // Enrich actor display name if the sync cache missed.
@@ -706,27 +978,250 @@ export class SlackSocketModeClient {
706
978
  // ensures the event is always emitted even if the Slack API hangs.
707
979
  const actor = normalized.event.actor;
708
980
  if (actor?.actorExternalId && !actor.displayName) {
709
- const USER_RESOLVE_TIMEOUT_MS = 3_000;
710
- Promise.race([
981
+ const mentionedLabel = userLabels[actor.actorExternalId];
982
+ if (mentionedLabel) {
983
+ actor.displayName = mentionedLabel;
984
+ }
985
+
986
+ const userInfo = await Promise.race([
711
987
  resolveSlackUser(actor.actorExternalId, this.config.botToken),
712
- new Promise<undefined>((r) => setTimeout(r, USER_RESOLVE_TIMEOUT_MS)),
713
- ])
714
- .then((userInfo) => {
715
- if (userInfo) {
716
- actor.displayName = userInfo.displayName;
717
- actor.username = userInfo.username;
718
- }
719
- this.onEvent(normalized!);
720
- })
721
- .catch(() => {
722
- this.onEvent(normalized!);
723
- });
724
- return;
988
+ new Promise<undefined>((resolve) =>
989
+ setTimeout(resolve, USER_RESOLVE_TIMEOUT_MS),
990
+ ),
991
+ ]);
992
+ if (userInfo) {
993
+ actor.displayName = userInfo.displayName;
994
+ actor.username = userInfo.username;
995
+ }
725
996
  }
726
997
 
727
998
  this.onEvent(normalized);
728
999
  }
729
1000
 
1001
+ /**
1002
+ * Catch up on messages that arrived during the reconnect window.
1003
+ *
1004
+ * Slack does not buffer Socket Mode events for disconnected clients
1005
+ * (see https://api.slack.com/apis/socket-mode), so on reconnect we
1006
+ * fetch a bounded slice of `conversations.history` /
1007
+ * `conversations.replies` since the persisted watermark and feed any
1008
+ * recovered messages back through `processEventPayload`. Compound
1009
+ * dedup (`msg:${channel}:${ts}`) prevents double-emit if the same
1010
+ * message also arrives via the live socket.
1011
+ *
1012
+ * Scope:
1013
+ * - Routed channels (gateway routing entries)
1014
+ * - Active threads (`slack_active_threads`)
1015
+ * - Known DM channels (`contact_channels` rows of type `slack` with
1016
+ * a `D…` external chat id)
1017
+ *
1018
+ * Brand-new mentions in unrouted, never-engaged channels are not
1019
+ * recoverable here — the daemon's existing inbound-triggered backfill
1020
+ * (`triggerSlackThreadBackfillIfNeeded`, `tryBackfillSlackDmIfCold`)
1021
+ * will hydrate context once the next live event arrives.
1022
+ */
1023
+ private async replayMissedEvents(ownerWs: WebSocket): Promise<void> {
1024
+ // Bail if a fresh forceReconnect has replaced the active socket
1025
+ // before the async work began. Without this gate, a stale generation
1026
+ // could fan out catch-up traffic that races with the new connection.
1027
+ if (this.ws !== ownerWs) return;
1028
+
1029
+ const botToken = this.config.botToken;
1030
+ if (!botToken) return;
1031
+
1032
+ // Bootstrap before the bot-identity check. The bot-identity check below
1033
+ // can keep returning early across reconnects if `auth.test` failed
1034
+ // transiently in `start()` and never retried — gating bootstrap on it
1035
+ // would leave the watermark unwritten for the entire degraded session,
1036
+ // and the eventual restart with a working `auth.test` would bootstrap
1037
+ // fresh against "now then" rather than "now at first ws.open", silently
1038
+ // widening the unrecoverable window. Bootstrap is identity-agnostic, so
1039
+ // run it first; the actual replay still requires `botUserId` and is
1040
+ // gated below.
1041
+ const persisted = this.store.getLastSeenTs();
1042
+ if (!persisted) {
1043
+ this.store.setLastSeenTsIfGreater(toSlackTs(Date.now()));
1044
+ log.info(
1045
+ "Slack catch-up: bootstrapped watermark, skipping initial replay",
1046
+ );
1047
+ return;
1048
+ }
1049
+
1050
+ const botUserId = this.config.botUserId;
1051
+ if (!botUserId) {
1052
+ log.debug("Skipping reconnect catch-up: bot user id not yet resolved");
1053
+ return;
1054
+ }
1055
+
1056
+ const minOldestMs = Date.now() - CATCHUP_MAX_LOOKBACK_MS;
1057
+ const persistedMs = Math.floor(Number(persisted) * 1_000);
1058
+ const overlapMs = Math.max(persistedMs - CATCHUP_SAFETY_OVERLAP_MS, 0);
1059
+ const oldestMs = Math.max(overlapMs, minOldestMs);
1060
+ const oldest = toSlackTs(oldestMs);
1061
+
1062
+ const routedChannels = new Set<string>();
1063
+ for (const entry of this.config.gatewayConfig.routingEntries) {
1064
+ // routingEntries is shared across channels (Slack, Telegram, WhatsApp,
1065
+ // …), so filter to keys that look like Slack conversation IDs. Slack
1066
+ // IDs always begin with C (public channel), D (DM/IM), or G (private
1067
+ // channel / multi-person IM) — see
1068
+ // https://api.slack.com/types/conversation.
1069
+ if (
1070
+ entry.type === "conversation_id" &&
1071
+ isSlackConversationId(entry.key)
1072
+ ) {
1073
+ routedChannels.add(entry.key);
1074
+ }
1075
+ }
1076
+ const dmChannels = this.store.listKnownSlackDmChannels();
1077
+ for (const channel of dmChannels) routedChannels.add(channel);
1078
+
1079
+ const activeThreads = this.store.listActiveThreadsWithChannel();
1080
+
1081
+ log.info(
1082
+ {
1083
+ oldest,
1084
+ channels: routedChannels.size,
1085
+ threads: activeThreads.length,
1086
+ },
1087
+ "Slack reconnect catch-up starting",
1088
+ );
1089
+
1090
+ let recovered = 0;
1091
+ const abort = new CatchupAbortSignal();
1092
+
1093
+ // Channel/DM history fan-out. We use conversations.history rather than
1094
+ // conversations.replies for top-level channels because we want
1095
+ // any unseen top-level message — replies in tracked threads are
1096
+ // covered separately below.
1097
+ const channelTasks = Array.from(routedChannels).map((channel) => {
1098
+ return async () => {
1099
+ if (this.ws !== ownerWs || abort.aborted) return;
1100
+ const result = await fetchChannelHistorySince({
1101
+ botToken,
1102
+ channel,
1103
+ oldest,
1104
+ limit: CATCHUP_HISTORY_LIMIT,
1105
+ abort,
1106
+ });
1107
+ if (this.ws !== ownerWs) return;
1108
+ for (const msg of sortMessagesAscendingByTs(result.messages)) {
1109
+ if (this.injectReplayMessage(channel, msg, botUserId)) recovered++;
1110
+ }
1111
+ };
1112
+ });
1113
+
1114
+ const threadTasks = activeThreads.map(({ channelId, threadTs }) => {
1115
+ return async () => {
1116
+ if (this.ws !== ownerWs || abort.aborted) return;
1117
+ const result = await fetchThreadRepliesSince({
1118
+ botToken,
1119
+ channel: channelId,
1120
+ threadTs,
1121
+ oldest,
1122
+ limit: CATCHUP_HISTORY_LIMIT,
1123
+ abort,
1124
+ });
1125
+ if (this.ws !== ownerWs) return;
1126
+ for (const msg of sortMessagesAscendingByTs(result.messages)) {
1127
+ // conversations.replies always returns the thread parent as the
1128
+ // first element regardless of `oldest` / `inclusive` — see
1129
+ // https://api.slack.com/methods/conversations.replies. The parent
1130
+ // was already processed when the thread was first tracked; replay
1131
+ // is for catching up on missed *replies*. Compound dedup would
1132
+ // catch a same-day re-emission, but for long-lived active threads
1133
+ // (TTL refreshed past the dedup window) the dedup row could have
1134
+ // expired, so filter explicitly.
1135
+ if (msg.ts === threadTs) continue;
1136
+ if (this.injectReplayMessage(channelId, msg, botUserId)) recovered++;
1137
+ }
1138
+ };
1139
+ });
1140
+
1141
+ try {
1142
+ await runWithConcurrency(
1143
+ [...channelTasks, ...threadTasks],
1144
+ CATCHUP_CONCURRENCY,
1145
+ );
1146
+ } catch (err) {
1147
+ log.warn({ err }, "Slack reconnect catch-up encountered an error");
1148
+ }
1149
+
1150
+ log.info({ recovered, oldest }, "Slack reconnect catch-up complete");
1151
+ }
1152
+
1153
+ /**
1154
+ * Build a synthetic events_api envelope for a recovered message and
1155
+ * dispatch it through the shared `processEventPayload` path. Returns
1156
+ * true if the message was passed through to processing (subject to
1157
+ * filter/dedup), false if it was skipped at this stage (no `ts`,
1158
+ * bot's own message, or other shape that the live filter would also
1159
+ * drop).
1160
+ */
1161
+ private injectReplayMessage(
1162
+ channel: string,
1163
+ msg: SlackHistoryMessage,
1164
+ botUserId: string,
1165
+ ): boolean {
1166
+ if (!msg.ts) return false;
1167
+
1168
+ // Skip the bot's own outbound messages and edits/deletes — the live
1169
+ // filter would already drop these and replaying them risks loops.
1170
+ if (msg.user === botUserId) return false;
1171
+ if (msg.bot_id) return false;
1172
+ if (
1173
+ msg.subtype &&
1174
+ msg.subtype !== "thread_broadcast" &&
1175
+ msg.subtype !== "file_share"
1176
+ ) {
1177
+ return false;
1178
+ }
1179
+
1180
+ const mentionsBot = msg.text?.includes(`<@${botUserId}>`) ?? false;
1181
+ const isDm = channel.startsWith("D");
1182
+ // DMs are always delivered as `type: "message"` with `channel_type: "im"`
1183
+ // by live Slack, even when the bot is `<@U…>`-mentioned in the body —
1184
+ // Slack only emits `app_mention` for non-DM channels. Synthesizing a DM
1185
+ // as `app_mention` would route through `normalizeSlackAppMention`, which
1186
+ // (intentionally) lacks the DM default-assistant fallback that
1187
+ // `normalizeSlackDirectMessage` provides, so an unrouted DM @-mention
1188
+ // would silently drop in `unmappedPolicy: "reject"` deployments.
1189
+ const eventType: "app_mention" | "message" =
1190
+ mentionsBot && !isDm ? "app_mention" : "message";
1191
+
1192
+ // Pass through `subtype`, `files`, `attachments`, and `blocks` so the
1193
+ // synthetic event has the same shape as a live Slack event for the
1194
+ // same message. Without this, recovered `file_share` messages would be
1195
+ // emitted as text-only and downstream attachment handling would diverge
1196
+ // between the live and replay paths. See
1197
+ // https://api.slack.com/events/message and
1198
+ // https://api.slack.com/events/app_mention for the live event shape.
1199
+ const syntheticEvent = {
1200
+ type: eventType,
1201
+ user: msg.user ?? "",
1202
+ text: msg.text ?? "",
1203
+ ts: msg.ts,
1204
+ thread_ts: msg.thread_ts,
1205
+ channel,
1206
+ channel_type: isDm ? "im" : "channel",
1207
+ team: msg.team,
1208
+ ...(msg.subtype ? { subtype: msg.subtype } : {}),
1209
+ ...(msg.files ? { files: msg.files } : {}),
1210
+ ...(msg.attachments ? { attachments: msg.attachments } : {}),
1211
+ ...(msg.blocks ? { blocks: msg.blocks } : {}),
1212
+ } as unknown as
1213
+ | SlackAppMentionEvent
1214
+ | SlackDirectMessageEvent
1215
+ | SlackChannelMessageEvent;
1216
+
1217
+ this.processEventPayload({
1218
+ event_id: `replay:${channel}:${msg.ts}`,
1219
+ event_time: Math.floor(Number(msg.ts)) || undefined,
1220
+ event: syntheticEvent,
1221
+ });
1222
+ return true;
1223
+ }
1224
+
730
1225
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
731
1226
  private handleInteractive(payload: Record<string, any> | undefined): void {
732
1227
  if (!payload) return;
@@ -793,6 +1288,173 @@ export class SlackSocketModeClient {
793
1288
  }
794
1289
  }
795
1290
 
1291
+ /**
1292
+ * Compute a stable `msg:${channel}:${ts}` dedup key for events that carry
1293
+ * a (channel, ts) identity. Used so the live and reconnect-replay paths
1294
+ * dedup symmetrically — a message that arrives via both paths is rejected
1295
+ * on the second arrival regardless of which came first.
1296
+ *
1297
+ * Returns undefined for events without a stable message identity (e.g.
1298
+ * `message_changed`, `message_deleted`, reactions). Those rely on their
1299
+ * Slack `event_id` for dedup; replay never synthesizes them.
1300
+ */
1301
+ function computeMessageDedupKey(event: {
1302
+ type?: string;
1303
+ subtype?: string;
1304
+ channel?: string;
1305
+ ts?: string;
1306
+ }): string | undefined {
1307
+ // Restrict to top-level message-shaped events. Edits/deletes carry a
1308
+ // separate `previous_message`/`message` payload and don't need this key
1309
+ // because the replay path doesn't synthesize them.
1310
+ if (event.type !== "message" && event.type !== "app_mention") {
1311
+ return undefined;
1312
+ }
1313
+ if (
1314
+ event.subtype === "message_changed" ||
1315
+ event.subtype === "message_deleted"
1316
+ ) {
1317
+ return undefined;
1318
+ }
1319
+ if (!event.channel || !event.ts) return undefined;
1320
+ return `msg:${event.channel}:${event.ts}`;
1321
+ }
1322
+
1323
+ /**
1324
+ * Extract the watermark timestamp for an event. Prefers the message ts,
1325
+ * falling back to envelope `event_time` for events that don't carry their
1326
+ * own ts (reactions). Returns a Slack-format `<seconds>.<micros>` string
1327
+ * or undefined when no usable timestamp is present.
1328
+ */
1329
+ function extractEventWatermarkTs(
1330
+ event: {
1331
+ ts?: string;
1332
+ item?: { ts?: string };
1333
+ deleted_ts?: string;
1334
+ message?: { ts?: string };
1335
+ },
1336
+ envelopeEventTime: number | undefined,
1337
+ ): string | undefined {
1338
+ if (event.ts) return event.ts;
1339
+ if (event.message?.ts) return event.message.ts;
1340
+ if (event.deleted_ts) return event.deleted_ts;
1341
+ if (event.item?.ts) return event.item.ts;
1342
+ if (envelopeEventTime) return `${envelopeEventTime}.000000`;
1343
+ return undefined;
1344
+ }
1345
+
1346
+ /** Convert millisecond epoch to a Slack `<seconds>.<micros>` timestamp string. */
1347
+ function toSlackTs(ms: number): string {
1348
+ const secs = Math.floor(ms / 1_000);
1349
+ const micros = Math.floor((ms % 1_000) * 1_000);
1350
+ return `${secs}.${String(micros).padStart(6, "0")}`;
1351
+ }
1352
+
1353
+ /**
1354
+ * True if `id` looks like a Slack conversation ID. Slack IDs are 9–11
1355
+ * uppercase-alphanumeric characters prefixed with `C` (public channel),
1356
+ * `D` (direct message / IM), or `G` (private channel / multi-person IM).
1357
+ * See https://api.slack.com/types/conversation.
1358
+ */
1359
+ function isSlackConversationId(id: string): boolean {
1360
+ return /^[CDG][A-Z0-9]+$/.test(id);
1361
+ }
1362
+
1363
+ /**
1364
+ * Result of inspecting a bot-token scope header. Exposed so callers can
1365
+ * decide how to surface missing scopes (logging, telemetry, both) without
1366
+ * coupling the inspection logic to a specific logger.
1367
+ */
1368
+ export interface SlackScopeCheckResult {
1369
+ filesReadMissing: boolean;
1370
+ missingHistoryScopes: string[];
1371
+ }
1372
+
1373
+ /**
1374
+ * Inspect a bot-token scope header and return which optional scopes are
1375
+ * absent. Pure / no side effects — exists alongside
1376
+ * `warnOnMissingSlackScopes` so it can be unit-tested without observing
1377
+ * logger output.
1378
+ *
1379
+ * - `files:read` — required for downloading file/image attachments.
1380
+ * - `*:history` (channels/im/groups/mpim) — required for
1381
+ * `conversations.history` and `conversations.replies`. Slack returns
1382
+ * `ok: false, error: "missing_scope"` per channel type that is missing
1383
+ * the corresponding scope (see
1384
+ * https://api.slack.com/methods/conversations.history), and the
1385
+ * catch-up error handler treats that as zero messages.
1386
+ */
1387
+ export function inspectSlackScopes(
1388
+ scopesHeader: string,
1389
+ ): SlackScopeCheckResult {
1390
+ const scopes = new Set(
1391
+ scopesHeader
1392
+ .split(",")
1393
+ .map((s) => s.trim())
1394
+ .filter(Boolean),
1395
+ );
1396
+ return {
1397
+ filesReadMissing: !scopes.has("files:read"),
1398
+ missingHistoryScopes: [
1399
+ "channels:history",
1400
+ "im:history",
1401
+ "groups:history",
1402
+ "mpim:history",
1403
+ ].filter((scope) => !scopes.has(scope)),
1404
+ };
1405
+ }
1406
+
1407
+ /**
1408
+ * Emit warnings for any bot-token scopes whose absence makes the gateway
1409
+ * silently degrade rather than fail loudly. Without this startup check the
1410
+ * user sees a successful boot followed by quiet "recovered: 0" log lines on
1411
+ * every reconnect, with no signal that catch-up is no-op'ing on
1412
+ * `missing_scope`.
1413
+ */
1414
+ export function warnOnMissingSlackScopes(scopesHeader: string): void {
1415
+ const { filesReadMissing, missingHistoryScopes } =
1416
+ inspectSlackScopes(scopesHeader);
1417
+ if (filesReadMissing) {
1418
+ log.warn(
1419
+ "Slack bot token is missing the 'files:read' scope — file/image " +
1420
+ "attachments will not be downloaded. Add 'files:read' to your " +
1421
+ "Slack app's Bot Token Scopes and reinstall the app.",
1422
+ );
1423
+ }
1424
+ if (missingHistoryScopes.length > 0) {
1425
+ log.warn(
1426
+ { missingHistoryScopes },
1427
+ "Slack bot token is missing one or more *:history scopes — " +
1428
+ "reconnect catch-up will not recover messages from the affected " +
1429
+ "channel types. Add the missing scopes to your Slack app's Bot " +
1430
+ "Token Scopes and reinstall the app.",
1431
+ );
1432
+ }
1433
+ }
1434
+
1435
+ /**
1436
+ * Sort Slack messages by `ts` ascending so they replay through the
1437
+ * per-channel emit queue in chronological order. `conversations.history`
1438
+ * returns messages newest-first
1439
+ * (https://api.slack.com/methods/conversations.history) and
1440
+ * `conversations.replies` makes no strict ordering guarantee beyond
1441
+ * "parent first", so we sort defensively rather than rely on either API's
1442
+ * order. Without this, a flurry of missed messages emits in reverse
1443
+ * order — the runtime sees the latest user message before the earlier
1444
+ * ones it depends on. Messages without a `ts` are dropped by
1445
+ * `injectReplayMessage` anyway; sort them last so they don't perturb
1446
+ * the order of the rest.
1447
+ */
1448
+ function sortMessagesAscendingByTs<T extends { ts?: string }>(
1449
+ messages: readonly T[],
1450
+ ): T[] {
1451
+ return [...messages].sort((a, b) => {
1452
+ const aTs = a.ts ? Number(a.ts) : Number.POSITIVE_INFINITY;
1453
+ const bTs = b.ts ? Number(b.ts) : Number.POSITIVE_INFINITY;
1454
+ return aTs - bTs;
1455
+ });
1456
+ }
1457
+
796
1458
  /**
797
1459
  * Factory function for creating a Slack Socket Mode client.
798
1460
  */