tide-commander 1.87.0 → 1.89.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 (71) hide show
  1. package/dist/assets/{BossLogsModal-S3Rke-8g.js → BossLogsModal-BK6N5fG2.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-BjWGNCnz.js → BossSpawnModal-BTy-lus4.js} +1 -1
  3. package/dist/assets/{ControlsModal-6yfU0XjZ.js → ControlsModal-B4MhaF1V.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-CYq0hNz6.js → DockerLogsModal-C33dAwy1.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-ZBdqRDqm.js → EmbeddedEditor-BfjjT-GF.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-BcV5jAse.js → GmailOAuthSetup-TQyjHs3_.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-DyUW_STE.js → GoogleOAuthSetup-DAIzYKy8.js} +1 -1
  8. package/dist/assets/{IframeModal-D9A3dUUc.js → IframeModal-g8tC4aah.js} +1 -1
  9. package/dist/assets/IntegrationsPanel-CuKr7702.js +2 -0
  10. package/dist/assets/{LogViewerModal-BWkbY7wa.js → LogViewerModal-DO45Kea0.js} +1 -1
  11. package/dist/assets/{MonitoringModal-AZzokZAZ.js → MonitoringModal-OIwmagj2.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-q98eiBfq.js → PM2LogsModal-BRQzSiFN.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-CTxRP2qE.js → RestoreArchivedAreaModal-CBRN9Xpb.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-C11dztp1.js → Scene2DCanvas-4J4ZefT6.js} +1 -1
  15. package/dist/assets/{SceneManager-CsW9MYrD.js → SceneManager-DZsJcYvW.js} +1 -1
  16. package/dist/assets/{SkillsPanel-BeZr9w6E.js → SkillsPanel-DHk7h3Ja.js} +1 -1
  17. package/dist/assets/SlackMultiInstanceSetup-Dp1q2zM1.js +2 -0
  18. package/dist/assets/{SpawnModal-DY_KM6lX.js → SpawnModal-CfozYMNI.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-D6RvjGX9.js → SubordinateAssignmentModal-BBfbpVUr.js} +1 -1
  20. package/dist/assets/{TriggerManagerPanel-BmqjXv9T.js → TriggerManagerPanel-DQw9nt1r.js} +2 -2
  21. package/dist/assets/{WorkflowEditorPanel-Rd5ZjJmt.js → WorkflowEditorPanel-BM2ec8CS.js} +1 -1
  22. package/dist/assets/{index-DSvJOrb7.js → index-BiAZinYH.js} +2 -2
  23. package/dist/assets/{index-BYVHgVEo.js → index-BqbR55dr.js} +1 -1
  24. package/dist/assets/{index-DRGyDtmm.js → index-CcSJA57k.js} +1 -1
  25. package/dist/assets/{index-BtJyOo4p.js → index-DNEUJDeO.js} +1 -1
  26. package/dist/assets/{index-BoORE9Q1.js → index-DY9w7IcH.js} +1 -1
  27. package/dist/assets/{index-DHHRkTG1.js → index-bcwTXJ6F.js} +1 -1
  28. package/dist/assets/index-fZfyvIUZ.js +2 -0
  29. package/dist/assets/{index-BxaEkSIx.js → index-jXkaBxIq.js} +3 -3
  30. package/dist/assets/index-xEvpFBA8.js +8 -0
  31. package/dist/assets/main-Bw5ZddEN.css +1 -0
  32. package/dist/assets/main-D-YFCprA.js +213 -0
  33. package/dist/assets/{web-D3zCwsS9.js → web-BrBkKQlr.js} +1 -1
  34. package/dist/assets/{web-DS0FHmg8.js → web-DCu3NTho.js} +1 -1
  35. package/dist/assets/{web-DEq3Te_H.js → web-DX588C-g.js} +1 -1
  36. package/dist/index.html +2 -2
  37. package/dist/src/packages/server/data/builtin-skills/explore-database.js +175 -0
  38. package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
  39. package/dist/src/packages/server/data/event-queries.js +2 -0
  40. package/dist/src/packages/server/data/index.js +56 -2
  41. package/dist/src/packages/server/data/migrations/006_slack_messages_integration_instance.sql +9 -0
  42. package/dist/src/packages/server/index.js +2 -1
  43. package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +9 -1
  44. package/dist/src/packages/server/integrations/slack/index.js +65 -19
  45. package/dist/src/packages/server/integrations/slack/slack-client.js +44 -602
  46. package/dist/src/packages/server/integrations/slack/slack-config.js +229 -29
  47. package/dist/src/packages/server/integrations/slack/slack-instance-manifest.js +150 -0
  48. package/dist/src/packages/server/integrations/slack/slack-instance.js +801 -0
  49. package/dist/src/packages/server/integrations/slack/slack-polling-client.js +522 -0
  50. package/dist/src/packages/server/integrations/slack/slack-routes.js +243 -24
  51. package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +53 -20
  52. package/dist/src/packages/server/integrations/slack/slack-watermark-store.js +124 -0
  53. package/dist/src/packages/server/integrations/whatsapp/index.js +5 -4
  54. package/dist/src/packages/server/integrations/whatsapp/whatsapp-client.js +10 -0
  55. package/dist/src/packages/server/integrations/whatsapp/whatsapp-routes.js +68 -0
  56. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +127 -0
  57. package/dist/src/packages/server/routes/database.js +221 -0
  58. package/dist/src/packages/server/routes/files.js +219 -18
  59. package/dist/src/packages/server/routes/index.js +2 -0
  60. package/dist/src/packages/server/services/building-service.js +41 -0
  61. package/dist/src/packages/server/services/database-service.js +61 -9
  62. package/dist/src/packages/server/services/index.js +1 -0
  63. package/dist/src/packages/server/services/ssh-tunnel-service.js +255 -0
  64. package/dist/src/packages/server/websocket/handler.js +2 -1
  65. package/dist/src/packages/server/websocket/handlers/database-handler.js +35 -0
  66. package/package.json +3 -1
  67. package/dist/assets/IntegrationsPanel-CHaNJBJW.js +0 -2
  68. package/dist/assets/index-BOr_tbLK.js +0 -2
  69. package/dist/assets/index-Co7njQ0Q.js +0 -8
  70. package/dist/assets/main-BrZe9Zbd.js +0 -201
  71. package/dist/assets/main-kpU9m5LW.css +0 -1
@@ -0,0 +1,522 @@
1
+ /**
2
+ * SlackPollingClient
3
+ *
4
+ * Polling-mode inbound transport for Slack USER tokens (xoxp-). User tokens
5
+ * cannot use Socket Mode, so we periodically poll the Web API instead.
6
+ *
7
+ * Pipeline per cycle:
8
+ * 1. (lazy) refresh channel list via `conversations.list` every
9
+ * `channelListRefreshEveryNCycles` ticks. Includes public/private
10
+ * channels, IMs, and MPIMs that the user is a member of.
11
+ * 2. For each tracked channel, fetch newer messages via
12
+ * `conversations.history` with `oldest=lastTs` (exclusive). On the
13
+ * first sight of a channel apply a backfill cap.
14
+ * 3. Hand each message to `dispatchInboundMessage(event)` shaped exactly
15
+ * like a Socket-Mode `message` event so the existing dispatcher pipeline
16
+ * (subtype filter, self-skip, user resolve, SQLite log, WS broadcast,
17
+ * trigger listeners, reply waiters) works unchanged.
18
+ * 4. Advance the channel's watermark to the newest dispatched ts and
19
+ * persist via the watermark store (atomic write).
20
+ *
21
+ * Rate-limit handling: on a Slack 429 with `Retry-After`, we wait that many
22
+ * seconds before retrying THAT channel and continue with the rest. The
23
+ * Slack header is authoritative — we don't add jittered backoff on top.
24
+ *
25
+ * Shutdown: `stop()` clears the timer, sets running=false, and awaits any
26
+ * in-flight cycle. The integration's main shutdown calls this.
27
+ *
28
+ * PII safety: never log message bodies, channel names, user names/emails, or
29
+ * DM contents. Logs only carry channel ids, ts values, and cycle metadata.
30
+ */
31
+ import { createLogger } from '../../utils/logger.js';
32
+ const log = createLogger('SlackPolling');
33
+ const DEFAULT_SCHEDULER = {
34
+ setTimeout: (cb, ms) => setTimeout(cb, ms),
35
+ clearTimeout: (h) => clearTimeout(h),
36
+ };
37
+ /** Pull a Retry-After value (seconds) from a Slack 429 error, if present. */
38
+ function readRetryAfter(err) {
39
+ const e = err;
40
+ if (typeof e?.retryAfter === 'number')
41
+ return e.retryAfter;
42
+ if (typeof e?.data?.retry_after === 'number')
43
+ return e.data.retry_after;
44
+ const headerVal = e?.headers?.['retry-after'];
45
+ if (typeof headerVal === 'string') {
46
+ const n = parseInt(headerVal, 10);
47
+ if (Number.isFinite(n))
48
+ return n;
49
+ }
50
+ return undefined;
51
+ }
52
+ function isRateLimitedError(err) {
53
+ const e = err;
54
+ if (e?.code === 'slack_webapi_rate_limited_error')
55
+ return true;
56
+ if (e?.data?.error === 'ratelimited')
57
+ return true;
58
+ return readRetryAfter(err) !== undefined;
59
+ }
60
+ function isInvalidAuthError(err) {
61
+ const slackErr = err?.data?.error;
62
+ return slackErr === 'invalid_auth' || slackErr === 'token_revoked' || slackErr === 'account_inactive';
63
+ }
64
+ // ─── The client ───
65
+ export class SlackPollingClient {
66
+ webClient;
67
+ watermarkStore;
68
+ dispatch;
69
+ intervalMs;
70
+ backfillMessageCap;
71
+ backfillSeconds;
72
+ concurrency;
73
+ channelListRefreshEveryNCycles;
74
+ channelTypes;
75
+ allowlistChannelIds;
76
+ keepAllDms;
77
+ minMsBetweenCalls;
78
+ scheduler;
79
+ now;
80
+ /** Pacer state: serializes outbound API calls through a promise chain. */
81
+ rateGateChain = Promise.resolve();
82
+ /** When > now(), all paced calls sleep until that wall-clock ms. Set on 429. */
83
+ globalPauseUntil = 0;
84
+ running = false;
85
+ timer = null;
86
+ inFlight = null;
87
+ cycleCount = 0;
88
+ /** ChannelId → seconds to skip until (epoch ms) after a 429. */
89
+ channelBackoffUntil = new Map();
90
+ /** ChannelId set known from the most recent list refresh. */
91
+ knownChannels = new Set();
92
+ /** Set when we've hit a fatal auth error so the loop self-stops. */
93
+ fatalError = null;
94
+ /** Optional callback the wrapper can subscribe to for fatal errors. */
95
+ onFatalError = null;
96
+ constructor(opts) {
97
+ this.webClient = opts.webClient;
98
+ this.watermarkStore = opts.watermarkStore;
99
+ this.dispatch = opts.dispatch;
100
+ this.intervalMs = clamp(opts.intervalSec, 10, 600) * 1000;
101
+ this.backfillMessageCap = Math.max(1, opts.backfillMessageCap);
102
+ this.backfillSeconds = Math.max(60, opts.backfillSeconds);
103
+ this.concurrency = clamp(opts.concurrency, 1, 8);
104
+ this.channelListRefreshEveryNCycles = Math.max(1, opts.channelListRefreshEveryNCycles);
105
+ this.channelTypes = opts.channelTypes ?? 'public_channel,private_channel,im,mpim';
106
+ this.allowlistChannelIds = new Set((opts.allowlistChannelIds ?? []).map((s) => s.trim()).filter(Boolean));
107
+ this.keepAllDms = opts.keepAllDms !== false;
108
+ this.minMsBetweenCalls = Math.max(0, opts.minMsBetweenCalls ?? 0);
109
+ this.scheduler = opts.scheduler ?? DEFAULT_SCHEDULER;
110
+ this.now = opts.now ?? Date.now;
111
+ }
112
+ /**
113
+ * Throttle gate for outbound Slack API calls. All calls (history + replies)
114
+ * go through here so the per-cycle burst is smeared across the cycle. When
115
+ * `minMsBetweenCalls` is 0 the gate is a no-op — used by tests.
116
+ *
117
+ * Honors `globalPauseUntil` set by 429 handlers so the entire client backs
118
+ * off when Slack tells us to.
119
+ */
120
+ async paceCall(fn) {
121
+ // Stop() was called while we were queued — bail without consuming the
122
+ // pacing slot or making the API call.
123
+ if (!this.running) {
124
+ throw new Error('SlackPollingClient stopped');
125
+ }
126
+ if (this.minMsBetweenCalls === 0 && this.globalPauseUntil <= this.now()) {
127
+ return fn();
128
+ }
129
+ const prev = this.rateGateChain;
130
+ let release;
131
+ this.rateGateChain = new Promise((r) => { release = r; });
132
+ await prev;
133
+ // Re-check after waiting on the gate — stop() could have fired meanwhile.
134
+ if (!this.running) {
135
+ release();
136
+ throw new Error('SlackPollingClient stopped');
137
+ }
138
+ // Sleep through any global pause first.
139
+ const pauseRemainingMs = this.globalPauseUntil - this.now();
140
+ if (pauseRemainingMs > 0) {
141
+ await new Promise((r) => setTimeout(r, pauseRemainingMs));
142
+ }
143
+ try {
144
+ return await fn();
145
+ }
146
+ finally {
147
+ // Release the gate after the min interval — the next paced call will
148
+ // unblock then. We don't await this; it's fire-and-forget.
149
+ if (this.minMsBetweenCalls > 0) {
150
+ setTimeout(release, this.minMsBetweenCalls);
151
+ }
152
+ else {
153
+ release();
154
+ }
155
+ }
156
+ }
157
+ /** Subscribe to fatal-error notifications (e.g. invalid_auth). */
158
+ setOnFatalError(cb) {
159
+ this.onFatalError = cb;
160
+ }
161
+ /**
162
+ * Start the polling loop. Returns once the watermark store is loaded and
163
+ * the first cycle is scheduled — does NOT wait for the first cycle to
164
+ * complete (matches Socket Mode's start-then-listen contract).
165
+ */
166
+ async start() {
167
+ if (this.running)
168
+ return;
169
+ this.running = true;
170
+ this.fatalError = null;
171
+ await this.watermarkStore.load();
172
+ log.log(`Polling started: interval=${this.intervalMs / 1000}s, concurrency=${this.concurrency}, paceMs=${this.minMsBetweenCalls}, backfillCap=${this.backfillMessageCap}`);
173
+ // Kick the first cycle immediately so we don't wait `intervalMs` to see
174
+ // anything. Subsequent cycles are scheduled at the END of each cycle.
175
+ this.scheduleNextCycle(0);
176
+ }
177
+ /**
178
+ * Stop the loop. Awaits the in-flight cycle so callers don't see ghost
179
+ * dispatches after `stop()` resolves.
180
+ */
181
+ async stop() {
182
+ this.running = false;
183
+ if (this.timer) {
184
+ this.scheduler.clearTimeout(this.timer);
185
+ this.timer = null;
186
+ }
187
+ if (this.inFlight) {
188
+ await this.inFlight.catch(() => undefined);
189
+ }
190
+ }
191
+ /** Force one cycle to run NOW. Returns when the cycle finishes. */
192
+ async runOnce() {
193
+ if (!this.running)
194
+ return;
195
+ if (this.inFlight) {
196
+ await this.inFlight.catch(() => undefined);
197
+ return;
198
+ }
199
+ await this.runCycleSafely();
200
+ }
201
+ // ─── Internals ───
202
+ scheduleNextCycle(delayMs) {
203
+ if (!this.running)
204
+ return;
205
+ if (this.timer) {
206
+ this.scheduler.clearTimeout(this.timer);
207
+ }
208
+ this.timer = this.scheduler.setTimeout(() => {
209
+ this.timer = null;
210
+ void this.runCycleSafely();
211
+ }, delayMs);
212
+ }
213
+ runCycleSafely() {
214
+ if (!this.running)
215
+ return Promise.resolve();
216
+ if (this.inFlight)
217
+ return this.inFlight;
218
+ const p = this.runCycle()
219
+ .catch((err) => {
220
+ log.error(`Polling cycle failed: ${describeErr(err)}`);
221
+ })
222
+ .finally(() => {
223
+ this.inFlight = null;
224
+ if (this.running && !this.fatalError) {
225
+ this.scheduleNextCycle(this.intervalMs);
226
+ }
227
+ });
228
+ this.inFlight = p;
229
+ return p;
230
+ }
231
+ async runCycle() {
232
+ if (!this.running)
233
+ return;
234
+ this.cycleCount += 1;
235
+ const cycleStart = this.now();
236
+ // Refresh channel list on first cycle and every N cycles after.
237
+ if (this.cycleCount === 1 || this.cycleCount % this.channelListRefreshEveryNCycles === 0) {
238
+ try {
239
+ await this.refreshChannelList();
240
+ }
241
+ catch (err) {
242
+ if (isInvalidAuthError(err)) {
243
+ this.handleFatal(`invalid_auth on conversations.list (cycle=${this.cycleCount})`);
244
+ return;
245
+ }
246
+ log.warn(`channel list refresh failed (cycle=${this.cycleCount}): ${describeErr(err)}`);
247
+ }
248
+ }
249
+ const channels = Array.from(this.knownChannels);
250
+ if (channels.length === 0) {
251
+ log.log(`cycle=${this.cycleCount}: no channels visible, skipping fetch`);
252
+ return;
253
+ }
254
+ // One-line heartbeat at the start of every cycle so the user can see the
255
+ // poller is ticking even when no new messages are dispatched.
256
+ log.log(`cycle=${this.cycleCount} starting: channels=${channels.length} paceMs=${this.minMsBetweenCalls} (~${Math.round(channels.length * this.minMsBetweenCalls / 1000)}s expected)`);
257
+ // First-cycle backfill is gentler: serialize all channels so we don't
258
+ // hit `conversations.history` for hundreds of channels in parallel.
259
+ const effectiveConcurrency = this.cycleCount === 1 ? 1 : this.concurrency;
260
+ let dispatched = 0;
261
+ let skippedBackoff = 0;
262
+ await mapWithConcurrency(channels, effectiveConcurrency, async (channelId) => {
263
+ // Bail mid-cycle if stop() was called. Lets reconnect() be near-instant
264
+ // instead of waiting up to ~5 minutes for a long pace-throttled cycle
265
+ // to drain.
266
+ if (!this.running)
267
+ return;
268
+ // Skip channels still under 429 backoff.
269
+ const backoffUntil = this.channelBackoffUntil.get(channelId);
270
+ if (backoffUntil && this.now() < backoffUntil) {
271
+ skippedBackoff += 1;
272
+ return;
273
+ }
274
+ try {
275
+ const n = await this.pollChannel(channelId);
276
+ dispatched += n;
277
+ }
278
+ catch (err) {
279
+ if (isInvalidAuthError(err)) {
280
+ this.handleFatal(`invalid_auth on conversations.history (channel=${channelId})`);
281
+ return;
282
+ }
283
+ if (isRateLimitedError(err)) {
284
+ const seconds = readRetryAfter(err) ?? 60;
285
+ const until = this.now() + seconds * 1000;
286
+ this.channelBackoffUntil.set(channelId, until);
287
+ // Apply a workspace-wide pause too — subsequent paceCall()s wait
288
+ // until the Retry-After window clears so we don't burst more 429s.
289
+ if (until > this.globalPauseUntil)
290
+ this.globalPauseUntil = until;
291
+ log.warn(`429 on channel=${channelId}, backoff=${seconds}s (cycle=${this.cycleCount})`);
292
+ return;
293
+ }
294
+ log.warn(`history failed channel=${channelId}: ${describeErr(err)}`);
295
+ }
296
+ });
297
+ const elapsed = this.now() - cycleStart;
298
+ // log() not debug() — single line per cycle is the user's heartbeat that
299
+ // polling is alive. ~1 line per pollingIntervalSec is not noisy.
300
+ log.log(`cycle=${this.cycleCount} channels=${channels.length} dispatched=${dispatched} skippedBackoff=${skippedBackoff} elapsedMs=${elapsed}`);
301
+ }
302
+ async refreshChannelList() {
303
+ const next = new Set();
304
+ let cursor;
305
+ let pages = 0;
306
+ do {
307
+ const res = await this.paceCall(() => this.webClient.conversations.list({
308
+ types: this.channelTypes,
309
+ limit: 200,
310
+ cursor,
311
+ }));
312
+ for (const ch of res.channels ?? []) {
313
+ if (!ch?.id)
314
+ continue;
315
+ if (ch.is_archived)
316
+ continue;
317
+ // For shared/public channels Slack provides `is_member`; for IM/MPIM
318
+ // membership is implicit (the call only returns DMs you're in).
319
+ const memberOrDm = ch.is_member !== false || ch.is_im || ch.is_mpim;
320
+ if (!memberOrDm)
321
+ continue;
322
+ // Apply allowlist if any. DMs (id starts with 'D' or is_im) bypass
323
+ // the allowlist when keepAllDms is true.
324
+ if (this.allowlistChannelIds.size > 0) {
325
+ const isDm = !!ch.is_im || ch.id.startsWith('D');
326
+ const allowed = this.allowlistChannelIds.has(ch.id) || (this.keepAllDms && isDm);
327
+ if (!allowed)
328
+ continue;
329
+ }
330
+ next.add(ch.id);
331
+ }
332
+ cursor = res.response_metadata?.next_cursor || undefined;
333
+ pages += 1;
334
+ if (pages > 10)
335
+ break; // safety: 10 pages × 200 = 2000 channels max per refresh
336
+ } while (cursor);
337
+ this.knownChannels = next;
338
+ log.log(`channel list refresh: ${next.size} channels visible (cycle=${this.cycleCount})`);
339
+ }
340
+ /**
341
+ * Poll one channel: fetch all messages newer than the watermark (or apply
342
+ * backfill cap on first sight), shape each into a Socket-Mode-like event,
343
+ * dispatch, and advance the watermark to the newest seen ts.
344
+ *
345
+ * Returns the number of messages dispatched.
346
+ */
347
+ async pollChannel(channelId) {
348
+ const wm = this.watermarkStore.get(channelId);
349
+ const isFirstSight = !wm;
350
+ let oldest = wm?.lastTs;
351
+ if (isFirstSight) {
352
+ // Cap lookback to backfillSeconds ago.
353
+ const sinceMs = this.now() - this.backfillSeconds * 1000;
354
+ oldest = (sinceMs / 1000).toFixed(6);
355
+ }
356
+ // Slack returns messages newest-first. We accumulate one page at most for
357
+ // ongoing polling; on first sight we still cap by backfillMessageCap.
358
+ const limit = isFirstSight ? this.backfillMessageCap : 100;
359
+ const res = await this.paceCall(() => this.webClient.conversations.history({
360
+ channel: channelId,
361
+ oldest,
362
+ limit,
363
+ }));
364
+ const messages = (res.messages ?? []).filter((m) => !!m && typeof m.ts === 'string');
365
+ if (messages.length === 0)
366
+ return 0;
367
+ // Slack returns newest-first; dispatch in chronological order so trigger
368
+ // listeners and dedupe see them in real-world order.
369
+ const ordered = [...messages].sort((a, b) => parseFloat(a.ts) - parseFloat(b.ts));
370
+ let dispatched = 0;
371
+ let highestTs = wm?.lastTs ?? '0';
372
+ for (const msg of ordered) {
373
+ // Defense-in-depth: never re-dispatch ts <= watermark even if the
374
+ // server returned an unexpected row.
375
+ if (wm && parseFloat(msg.ts) <= parseFloat(wm.lastTs))
376
+ continue;
377
+ const event = {
378
+ ts: msg.ts,
379
+ thread_ts: msg.thread_ts,
380
+ channel: channelId,
381
+ user: msg.user,
382
+ text: msg.text,
383
+ subtype: msg.subtype,
384
+ files: Array.isArray(msg.files) ? msg.files : undefined,
385
+ };
386
+ try {
387
+ await this.dispatch(event);
388
+ dispatched += 1;
389
+ }
390
+ catch (err) {
391
+ log.error(`dispatch error channel=${channelId} ts=${msg.ts}: ${describeErr(err)}`);
392
+ }
393
+ if (parseFloat(msg.ts) > parseFloat(highestTs)) {
394
+ highestTs = msg.ts;
395
+ }
396
+ // ─── Cheap thread-reply heuristic ───
397
+ // If this message is a thread parent (reply_count > 0) and the latest
398
+ // reply is newer than what we've already seen for this channel, fetch
399
+ // the new replies. This catches replies in threads whose parent is in
400
+ // the current cycle's history window — which is the majority case for
401
+ // active conversations. Older threads with new replies are still
402
+ // missed (their parent doesn't re-surface in history) — option 2 would
403
+ // fix that with per-thread watermarks.
404
+ const parentTs = msg.ts;
405
+ const replyCount = msg.reply_count ?? 0;
406
+ const latestReply = msg.latest_reply;
407
+ const repliesAreNew = !!latestReply && parseFloat(latestReply) > parseFloat(wm?.lastTs ?? '0');
408
+ if (replyCount > 0 && repliesAreNew) {
409
+ const oldestForReplies = wm?.lastTs ?? parentTs;
410
+ try {
411
+ const repliesRes = await this.paceCall(() => this.webClient.conversations.replies({
412
+ channel: channelId,
413
+ ts: parentTs,
414
+ oldest: oldestForReplies,
415
+ limit: 100,
416
+ }));
417
+ const replies = (repliesRes.messages ?? []).filter((r) => !!r && typeof r.ts === 'string' && r.ts !== parentTs);
418
+ // conversations.replies returns oldest-first, but be defensive.
419
+ const orderedReplies = [...replies].sort((a, b) => parseFloat(a.ts) - parseFloat(b.ts));
420
+ for (const reply of orderedReplies) {
421
+ // Defense: skip ts already at or below the watermark (handles overlap).
422
+ if (wm && parseFloat(reply.ts) <= parseFloat(wm.lastTs))
423
+ continue;
424
+ const replyEvent = {
425
+ ts: reply.ts,
426
+ thread_ts: reply.thread_ts ?? parentTs,
427
+ channel: channelId,
428
+ user: reply.user,
429
+ text: reply.text,
430
+ subtype: reply.subtype,
431
+ files: Array.isArray(reply.files) ? reply.files : undefined,
432
+ };
433
+ try {
434
+ await this.dispatch(replyEvent);
435
+ dispatched += 1;
436
+ }
437
+ catch (err) {
438
+ log.error(`dispatch error (reply) channel=${channelId} ts=${reply.ts}: ${describeErr(err)}`);
439
+ }
440
+ if (parseFloat(reply.ts) > parseFloat(highestTs)) {
441
+ highestTs = reply.ts;
442
+ }
443
+ }
444
+ }
445
+ catch (err) {
446
+ // 429 / invalid_auth on replies fetch are non-fatal for this cycle —
447
+ // we already dispatched the parent. Bubble auth errors to the outer
448
+ // caller so the loop halts; rate limits just skip this thread.
449
+ if (isInvalidAuthError(err))
450
+ throw err;
451
+ if (isRateLimitedError(err)) {
452
+ const seconds = readRetryAfter(err) ?? 60;
453
+ const until = this.now() + seconds * 1000;
454
+ if (until > this.globalPauseUntil)
455
+ this.globalPauseUntil = until;
456
+ log.warn(`429 on conversations.replies channel=${channelId} thread=${parentTs}, deferred ${seconds}s`);
457
+ }
458
+ else {
459
+ log.warn(`replies fetch failed channel=${channelId} thread=${parentTs}: ${describeErr(err)}`);
460
+ }
461
+ }
462
+ }
463
+ }
464
+ if (highestTs !== (wm?.lastTs ?? '0')) {
465
+ await this.watermarkStore.set(channelId, highestTs);
466
+ }
467
+ // Clear any prior backoff record on success.
468
+ if (this.channelBackoffUntil.has(channelId)) {
469
+ this.channelBackoffUntil.delete(channelId);
470
+ }
471
+ return dispatched;
472
+ }
473
+ handleFatal(reason) {
474
+ this.fatalError = reason;
475
+ this.running = false;
476
+ if (this.timer) {
477
+ this.scheduler.clearTimeout(this.timer);
478
+ this.timer = null;
479
+ }
480
+ log.error(`Polling halted: ${reason}`);
481
+ this.onFatalError?.(reason);
482
+ }
483
+ }
484
+ // ─── Helpers ───
485
+ function clamp(n, lo, hi) {
486
+ if (Number.isNaN(n))
487
+ return lo;
488
+ return Math.min(Math.max(n, lo), hi);
489
+ }
490
+ function describeErr(err) {
491
+ if (err instanceof Error)
492
+ return err.message;
493
+ return String(err);
494
+ }
495
+ /**
496
+ * Run `worker(item)` for each item with a max of `concurrency` in flight.
497
+ * Errors thrown by individual workers are swallowed by the caller's
498
+ * try/catch around `worker` — this helper itself never rejects.
499
+ */
500
+ async function mapWithConcurrency(items, concurrency, worker) {
501
+ if (items.length === 0)
502
+ return;
503
+ const limit = Math.max(1, Math.min(concurrency, items.length));
504
+ let cursor = 0;
505
+ const runners = [];
506
+ for (let i = 0; i < limit; i += 1) {
507
+ runners.push((async () => {
508
+ while (true) {
509
+ const idx = cursor;
510
+ cursor += 1;
511
+ if (idx >= items.length)
512
+ return;
513
+ await worker(items[idx]);
514
+ }
515
+ })());
516
+ }
517
+ await Promise.all(runners);
518
+ }
519
+ /** Adapter so the production code can pass a real Slack WebClient. */
520
+ export function asPollingWebClient(client) {
521
+ return client;
522
+ }