slack-term 1.42.0 → 1.43.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.
package/ts/stream.ts ADDED
@@ -0,0 +1,1048 @@
1
+ // `slack stream` — every new message the identity can see that matches --grep,
2
+ // one line per match, across all of its channels (top-level posts AND thread
3
+ // replies), resumable from a persisted per-channel cursor.
4
+ //
5
+ // Transport: the relay is the primary mode, polling the fallback. Slack's
6
+ // real-time paths need either a desktop session (RTM, user identity only) or
7
+ // an Events API / Socket Mode endpoint — which a CLI cannot own, hence the
8
+ // relay (worker/). Without it the stream polls, and says so loudly on stderr.
9
+ //
10
+ // Polling is paced so the fallback stays inside Slack's limits: every cycle
11
+ // reads each channel's history from its cursor only (new top-level posts);
12
+ // the full "thread window" re-read — which shows every thread whose
13
+ // `latest_reply` moved past that thread's cursor, and only those threads cost
14
+ // a conversations.replies call — runs once per --thread-scan period per
15
+ // channel, spread across cycles. A channel with no top-level post inside the
16
+ // window has no thread to watch and skips the window read; a DM/MPIM like that
17
+ // is not read at all between periods. All history/replies calls go through one
18
+ // token bucket (callsPerMin).
19
+ //
20
+ // Delivery is at-least-once: the cursor is saved right after each match is
21
+ // written, so a crash between the write and the save can repeat that one line.
22
+ // Consumers dedupe on channel.id + ts.
23
+ //
24
+ // Relay (optional, worker/): a Cloudflare Worker turns Slack's Events API into
25
+ // a stream of doorbells — {channel, ts, thread_ts}, no text. On a bell the
26
+ // stream reads that one message through the Web API and runs it through the
27
+ // same filter, so a mention is emitted within a second or two. Polling keeps
28
+ // running underneath at a long interval as the safety net (a bell the relay
29
+ // never got, a reconnect), and returns to --interval while the relay is down.
30
+ // Both paths emit through one `seen` set, so a message is never printed twice.
31
+ //
32
+ // Not covered (documented in README): edits (a message edited INTO matching is
33
+ // not re-emitted), and replies to a thread whose parent is older than the
34
+ // thread window.
35
+
36
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
37
+ import { dirname } from "node:path";
38
+ import type { Cursor, Doorbell, Subscribe } from "./relay.ts";
39
+ import { RelayAuthError } from "./relay.ts";
40
+ import { history, RateLimitError, repliesPage, userConversations, userName, type Json } from "./slack.ts";
41
+
42
+ export type ChannelRef = { id: string; name: string; isIm: boolean; isMpim?: boolean; user?: string };
43
+
44
+ export type Page = { messages: Record<string, Json>[]; nextCursor?: string };
45
+
46
+ /** The Slack surface the stream needs — injected so tests run without HTTP. */
47
+ export type StreamClient = {
48
+ listChannels(): Promise<ChannelRef[]>;
49
+ history(channel: string, oldest: string, cursor?: string): Promise<Page>;
50
+ replies(channel: string, threadTs: string, oldest: string, cursor?: string): Promise<Page>;
51
+ userName(id: string): Promise<string>;
52
+ };
53
+
54
+ function page(resp: Json): Page {
55
+ const r = resp as { messages?: Json; has_more?: Json; response_metadata?: { next_cursor?: Json } };
56
+ const messages = (Array.isArray(r.messages) ? r.messages : [])
57
+ .filter((m): m is Record<string, Json> => !!m && typeof m === "object" && !Array.isArray(m));
58
+ const next = r.has_more === true ? r.response_metadata?.next_cursor : undefined;
59
+ return typeof next === "string" && next ? { messages, nextCursor: next } : { messages };
60
+ }
61
+
62
+ /** The real client: Slack Web API calls as the given identity. */
63
+ export function webClient(token: string, cookie?: string): StreamClient {
64
+ return {
65
+ async listChannels() {
66
+ const raw = await userConversations(token, "public_channel,private_channel,im,mpim", cookie);
67
+ return raw.flatMap((c): ChannelRef[] => {
68
+ if (!c || typeof c !== "object" || Array.isArray(c) || typeof c.id !== "string") return [];
69
+ const isIm = c.is_im === true;
70
+ return [{
71
+ id: c.id, name: str(c.name) || c.id, isIm,
72
+ ...(c.is_mpim === true ? { isMpim: true } : {}),
73
+ ...(isIm && typeof c.user === "string" ? { user: c.user } : {}),
74
+ }];
75
+ });
76
+ },
77
+ history: async (channel, oldest, cursor) => page(await history(token, channel, 200, oldest, cursor, cookie)),
78
+ replies: async (channel, threadTs, oldest, cursor) =>
79
+ page(await repliesPage(token, channel, threadTs, { oldest, ...(cursor ? { cursor } : {}) }, cookie)),
80
+ userName: (id) => userName(token, id, cookie),
81
+ };
82
+ }
83
+
84
+ export type ChanState = {
85
+ /** When this channel started being watched: a thread first seen gets this as its cursor. */
86
+ since: string;
87
+ /** Every top-level message with ts ≤ cursor has been matched (or skipped). */
88
+ cursor: string;
89
+ /** Per thread (parent ts): every reply with ts ≤ value has been matched. */
90
+ threads: Record<string, string>;
91
+ /** Newest top-level ts any scan has seen: older than the thread window means
92
+ * no thread in it can gain a reply the poll watches. */
93
+ latest?: string;
94
+ /** When the thread window was last read in full (sec). */
95
+ scanned?: string;
96
+ };
97
+
98
+ export type StreamState = {
99
+ version: 1; identity: string; channels: Record<string, ChanState>;
100
+ /** Matches already emitted, "channel:ts" → ts — what keeps the relay path and
101
+ * the polling path from printing the same message twice. */
102
+ seen?: Record<string, number>;
103
+ /** Last relay bell fully handled; a restart resumes the relay from here. */
104
+ relay?: { url: string; seq: number; epoch?: string };
105
+ /** Threads a --replies-to id has posted in, "channel:thread_ts" → ts of its
106
+ * latest post seen there. The parent's `reply_users` lists at most five
107
+ * people, so this is what remembers that the bot is in a busy thread. */
108
+ joined?: Record<string, number>;
109
+ /** The --replies-to ids `joined` was recorded for (sorted, comma-joined): a
110
+ * run with other ids starts it afresh. */
111
+ joinedBy?: string;
112
+ };
113
+
114
+ export type StreamMatch = {
115
+ type: "message" | "reply";
116
+ channel: { id: string; name: string };
117
+ ts: string;
118
+ thread_ts: string | null;
119
+ user: { id: string; name: string };
120
+ text: string;
121
+ permalink: string;
122
+ /** Why it was printed: its text matched --grep, or (failing that) it is a
123
+ * reply in a thread started by a --replies-to sender. */
124
+ match: "grep" | "replies-to";
125
+ };
126
+
127
+ export type StreamOpts = {
128
+ grep: RegExp;
129
+ /** Also emit replies in threads one of these ids (user `U…` or bot `B…`)
130
+ * started (Slack's `parent_user_id`) or has replied in, whether or not they
131
+ * match --grep. */
132
+ repliesTo?: Set<string>;
133
+ /** Restrict to these channel ids; otherwise every conversation the identity is in. */
134
+ channels?: string[];
135
+ /** Sender ids whose posts are never emitted: the identity itself (user id / bot id). */
136
+ selfUsers: Set<string>;
137
+ selfBots: Set<string>;
138
+ json: boolean;
139
+ once: boolean;
140
+ /** Seconds back to (re)start every channel from, overriding the saved cursor. */
141
+ sinceSec?: number;
142
+ intervalMs: number;
143
+ threadWindowSec: number;
144
+ statePath: string;
145
+ /** Workspace URL ("https://acme.slack.com/") for permalinks. */
146
+ teamUrl: string;
147
+ /** auth.test user id — a state file written by another identity is refused. */
148
+ identity: string;
149
+ /** Messages younger than this are left for the next cycle, so a message Slack
150
+ * has not made visible yet cannot be skipped by a cursor that jumped past it. */
151
+ settleSec?: number;
152
+ /** Consecutive failed cycles before giving up (exit 3). */
153
+ maxFailures?: number;
154
+ backoffBaseMs?: number;
155
+ backoffCapMs?: number;
156
+ /** Re-list the identity's channels every N cycles (new invites / DMs). */
157
+ refreshEvery?: number;
158
+ signal?: AbortSignal;
159
+ /** Doorbell relay. While connected, the full poll only runs every
160
+ * max(intervalMs, reconcileMs) as a safety net. Ignored with --once. */
161
+ relay?: { url: string; subscribe: Subscribe; reconcileMs: number };
162
+ /** Read each channel's full thread window at most this often (default 180 s);
163
+ * the cycles in between read history from the cursor only. 0 = every cycle. */
164
+ threadScanSec?: number;
165
+ /** Pace history/replies calls to this many per minute (default 40, under
166
+ * Slack's Tier 3 ~50/min); 0 = unpaced. */
167
+ callsPerMin?: number;
168
+ /** Why there is no relay, for the polling-mode warning. */
169
+ pollReason?: string;
170
+ };
171
+
172
+ /** Where the real-time setup is documented — the polling warning points here. */
173
+ export const RELAY_DOCS = "https://github.com/snomiao/slack-term/blob/main/worker/README.md";
174
+
175
+ export const PHI = 1.618;
176
+ /** Relay bells handled per turn of the loop before the poll gets a look in. */
177
+ const MAX_DRAIN = 25;
178
+ /** Bells queued beyond this are dropped in favour of a catch-up poll. */
179
+ const MAX_QUEUED_BELLS = 2000;
180
+ /** Reads of a bell's message before giving up on it (~4 min with φ backoff). */
181
+ const BELL_TRIES = 8;
182
+
183
+ export const _internals = {
184
+ now: (): number => Date.now(),
185
+ sleep: (ms: number, signal?: AbortSignal): Promise<void> => new Promise((res) => {
186
+ if (signal?.aborted) return res();
187
+ // The listener goes when the timer fires, so long-lived signals don't collect them.
188
+ const onAbort = (): void => { clearTimeout(t); res(); };
189
+ const t = setTimeout(() => { signal?.removeEventListener("abort", onAbort); res(); }, ms);
190
+ signal?.addEventListener("abort", onAbort, { once: true });
191
+ }),
192
+ out: (line: string): void => { process.stdout.write(line + "\n"); },
193
+ err: (line: string): void => { process.stderr.write(line + "\n"); },
194
+ pidAlive: (pid: number): boolean => {
195
+ try { process.kill(pid, 0); return true; } catch (e) { return (e as { code?: string }).code === "EPERM"; }
196
+ },
197
+ };
198
+
199
+ /** φ backoff: min(cap, base·φ^(attempt-1)). With the defaults (2 s, cap 300 s,
200
+ * 12 attempts) the worst-case total wait before giving up is ~16 min. */
201
+ export function phiDelay(attempt: number, baseMs: number, capMs: number): number {
202
+ return Math.min(capMs, Math.round(baseMs * Math.pow(PHI, Math.max(0, attempt - 1))));
203
+ }
204
+
205
+ /** Errors that no retry can fix — the whole stream stops (exit 3). */
206
+ const FATAL = ["invalid_auth", "not_authed", "token_revoked", "token_expired", "account_inactive", "no_permission"];
207
+ /** Errors confined to one channel — that channel is skipped, loudly, the rest go on. */
208
+ const CHANNEL = ["channel_not_found", "not_in_channel", "missing_scope", "is_archived"];
209
+
210
+ export class StreamFatal extends Error {}
211
+
212
+ function errText(e: unknown): string {
213
+ return e instanceof Error ? e.message : String(e);
214
+ }
215
+ function classify(e: unknown): "fatal" | "channel" | "transient" {
216
+ const m = errText(e);
217
+ if (FATAL.some((c) => m.includes(c))) return "fatal";
218
+ if (CHANNEL.some((c) => m.includes(c))) return "channel";
219
+ return "transient";
220
+ }
221
+
222
+ const str = (v: Json | undefined): string => (typeof v === "string" ? v : "");
223
+ const num = (ts: string): number => Number(ts) || 0;
224
+ const fmt = (sec: number): string => sec.toFixed(6);
225
+
226
+ // Housekeeping subtypes: never a person talking.
227
+ const SKIP_SUBTYPES = new Set([
228
+ "message_changed", "message_deleted", "channel_join", "channel_leave", "group_join", "group_leave",
229
+ ]);
230
+
231
+ /** The text --grep runs against: the message body plus legacy attachment text
232
+ * (bots and integrations often put everything there). */
233
+ export function matchText(m: Record<string, Json>): string {
234
+ const parts = [str(m.text)];
235
+ if (Array.isArray(m.attachments)) {
236
+ for (const a of m.attachments) {
237
+ if (a && typeof a === "object" && !Array.isArray(a)) {
238
+ parts.push(str(a.pretext), str(a.text), str(a.fallback));
239
+ }
240
+ }
241
+ }
242
+ return parts.filter(Boolean).join("\n");
243
+ }
244
+
245
+ export function permalink(teamUrl: string, channel: string, ts: string, threadTs?: string): string {
246
+ const base = teamUrl.endsWith("/") ? teamUrl : teamUrl + "/";
247
+ const p = `${base}archives/${channel}/p${ts.replace(".", "")}`;
248
+ return threadTs && threadTs !== ts ? `${p}?thread_ts=${threadTs}&cid=${channel}` : p;
249
+ }
250
+
251
+ export function loadState(path: string, identity: string): StreamState {
252
+ if (!existsSync(path)) return { version: 1, identity, channels: {} };
253
+ let parsed: StreamState;
254
+ try {
255
+ parsed = JSON.parse(readFileSync(path, "utf8")) as StreamState;
256
+ } catch (e) {
257
+ // A corrupt cursor must not silently restart from "now" (that would drop
258
+ // whatever arrived while we were down) — refuse and let a human decide.
259
+ throw new StreamFatal(`state file ${path} is unreadable (${errText(e)}); move it aside to start fresh`);
260
+ }
261
+ const shapeOk = parsed && typeof parsed === "object" && parsed.version === 1 &&
262
+ parsed.channels && typeof parsed.channels === "object" &&
263
+ Object.values(parsed.channels).every((c) =>
264
+ c && typeof c.since === "string" && typeof c.cursor === "string" && c.threads && typeof c.threads === "object");
265
+ if (!shapeOk) throw new StreamFatal(`state file ${path} is not a slack stream state; move it aside to start fresh`);
266
+ if (parsed.identity !== identity) {
267
+ throw new StreamFatal(`state file ${path} belongs to identity ${parsed.identity}, not ${identity}; pass a different --state`);
268
+ }
269
+ return parsed;
270
+ }
271
+
272
+ export function saveState(path: string, st: StreamState): void {
273
+ mkdirSync(dirname(path), { recursive: true });
274
+ const tmp = `${path}.tmp-${process.pid}`;
275
+ writeFileSync(tmp, JSON.stringify(st));
276
+ renameSync(tmp, path);
277
+ }
278
+
279
+ /** One stream per state file: a second one would emit every match twice. */
280
+ export function acquireLock(path: string): () => void {
281
+ const lock = `${path}.lock`;
282
+ mkdirSync(dirname(lock), { recursive: true });
283
+ // Remove the lock only while it is still ours: if it was taken over (removed
284
+ // by hand, reclaimed as stale), the new owner's lock must survive us.
285
+ const release = (): void => {
286
+ try { if (readFileSync(lock, "utf8").trim() === String(process.pid)) unlinkSync(lock); } catch { /* already gone */ }
287
+ };
288
+ try {
289
+ writeFileSync(lock, String(process.pid), { flag: "wx" });
290
+ } catch {
291
+ const pid = Number(readFileSync(lock, "utf8").trim());
292
+ if (pid && _internals.pidAlive(pid)) {
293
+ throw new StreamFatal(`another slack stream (pid ${pid}) already uses ${path}`);
294
+ }
295
+ unlinkSync(lock); // stale: its owner is gone
296
+ writeFileSync(lock, String(process.pid), { flag: "wx" }); // throws if another stream won the race
297
+ }
298
+ return release;
299
+ }
300
+
301
+ type Ctx = {
302
+ opts: StreamOpts;
303
+ client: StreamClient;
304
+ state: StreamState;
305
+ names: Map<string, string>;
306
+ matches: number;
307
+ /** Threads read this run and found without a --replies-to id → the ts of the
308
+ * last reply read: absence is known up to there, and only later replies need
309
+ * reading (a busy thread is never re-read from its start). */
310
+ notJoined?: Map<string, number>;
311
+ };
312
+
313
+ async function nameOf(ctx: Ctx, id: string): Promise<string> {
314
+ let n = ctx.names.get(id);
315
+ if (n === undefined) {
316
+ n = await ctx.client.userName(id);
317
+ ctx.names.set(id, n);
318
+ }
319
+ return n;
320
+ }
321
+
322
+ /** Match one message and, only if it matches, resolve names and emit it.
323
+ * Nothing about a non-matching message is printed or logged. */
324
+ async function consider(
325
+ ctx: Ctx, ch: ChannelRef, m: Record<string, Json>, isReply: boolean, parent?: Record<string, Json>,
326
+ ): Promise<boolean> {
327
+ if (SKIP_SUBTYPES.has(str(m.subtype))) return false;
328
+ const uid = str(m.user);
329
+ const bid = str(m.bot_id);
330
+ // Note a --replies-to id's own reply (usually the bot's, which is never
331
+ // printed) before the self-echo check drops it: later replies in that
332
+ // thread count even when reply_users no longer shows it.
333
+ const ids = ctx.opts.repliesTo;
334
+ const tts = str(m.thread_ts);
335
+ if (isReply && ids && tts && (ids.has(uid) || ids.has(bid))) {
336
+ const joined = (ctx.state.joined ??= {});
337
+ joined[`${ch.id}:${tts}`] = Math.max(joined[`${ch.id}:${tts}`] ?? 0, num(str(m.ts)));
338
+ }
339
+ if ((uid && ctx.opts.selfUsers.has(uid)) || (bid && ctx.opts.selfBots.has(bid))) return false;
340
+ ctx.opts.grep.lastIndex = 0; // a /g or /y regex keeps state between test() calls
341
+ const match = ctx.opts.grep.test(matchText(m))
342
+ ? "grep"
343
+ : isReply && ids && await inThreadOf(ctx, ch, ids, m, parent)
344
+ ? "replies-to"
345
+ : undefined;
346
+ if (!match) return false;
347
+
348
+ const ts = str(m.ts);
349
+ // A message can arrive twice — from a relay bell and from the poll — and
350
+ // must be printed once.
351
+ // Recorded only while a relay can deliver (the only second path); always
352
+ // checked, since a relay run may have printed it ahead of the poll.
353
+ const key = `${ch.id}:${ts}`;
354
+ if (ctx.state.seen?.[key] !== undefined) return false;
355
+ if (ctx.opts.relay && !ctx.opts.once) (ctx.state.seen ??= {})[key] = num(ts);
356
+ const threadTs = str(m.thread_ts) || null;
357
+ const senderId = uid || bid;
358
+ const senderName = uid
359
+ ? await nameOf(ctx, uid)
360
+ : str(m.username) || str((m.bot_profile as Record<string, Json> | undefined)?.name) || bid;
361
+ const chName = ch.isIm ? `@${ch.user ? await nameOf(ctx, ch.user) : ch.id}` : ch.isMpim ? ch.name : `#${ch.name}`;
362
+ const rec: StreamMatch = {
363
+ type: isReply ? "reply" : "message",
364
+ channel: { id: ch.id, name: chName },
365
+ ts,
366
+ thread_ts: threadTs,
367
+ user: { id: senderId, name: senderName },
368
+ // Slack escapes only these three; the regex ran on the raw text above.
369
+ text: str(m.text).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&"),
370
+ permalink: permalink(ctx.opts.teamUrl, ch.id, ts, threadTs ?? undefined),
371
+ match,
372
+ };
373
+ ctx.opts.json ? _internals.out(JSON.stringify(rec)) : _internals.out(humanLine(rec));
374
+ ctx.matches++;
375
+ return true;
376
+ }
377
+
378
+ /** Did one of `ids` start this reply's thread, or reply in it? The starter is
379
+ * on every reply; the repliers come from what this stream has seen them post
380
+ * (`joined`) and the parent's `reply_users`. That list holds at most five
381
+ * entries (user or bot ids), so when it is cut short — or the parent is not at
382
+ * hand — the thread is read once in full. */
383
+ async function inThreadOf(
384
+ ctx: Ctx, ch: ChannelRef, ids: Set<string>, m: Record<string, Json>, parent?: Record<string, Json>,
385
+ ): Promise<boolean> {
386
+ if (ids.has(str(m.parent_user_id))) return true;
387
+ const tts = str(m.thread_ts);
388
+ const known = knownMember(ctx, ch, ids, tts, parent);
389
+ if (known !== undefined) return known;
390
+ const key = `${ch.id}:${tts}`;
391
+ const checked = ctx.notJoined?.get(key);
392
+ if (checked !== undefined && checked >= num(str(m.ts))) return false;
393
+ const from = checked !== undefined ? fmt(checked) : "0";
394
+ readMembership(ctx, ch, ids, tts, await allPages((c) => ctx.client.replies(ch.id, tts, from, c)));
395
+ return ctx.state.joined?.[key] !== undefined;
396
+ }
397
+
398
+ /** Membership from the parent and what this stream remembers — true, false,
399
+ * or undefined when only reading the thread can tell. */
400
+ function knownMember(
401
+ ctx: Ctx, ch: ChannelRef, ids: Set<string>, tts: string, parent?: Record<string, Json>,
402
+ ): boolean | undefined {
403
+ if (ctx.state.joined?.[`${ch.id}:${tts}`] !== undefined) return true;
404
+ if (!parent) return undefined;
405
+ // The starter, by either id (a reply's parent_user_id is only ever a user id).
406
+ if (ids.has(str(parent.user)) || ids.has(str(parent.bot_id))) return true;
407
+ const users = Array.isArray(parent.reply_users) ? parent.reply_users.map((u) => str(u)) : undefined;
408
+ if (users?.some((u) => ids.has(u))) return true;
409
+ // A complete list without the ids is a no. A missing list, or one cut to its
410
+ // five entries, says nothing.
411
+ const count = typeof parent.reply_users_count === "number" ? parent.reply_users_count : undefined;
412
+ if (users && (count !== undefined ? count <= users.length : users.length < 5)) return false;
413
+ return undefined;
414
+ }
415
+
416
+ /** Record what a read of the thread (from its start, or from where the last
417
+ * read of it stopped) shows: a post by the ids, or absence up to its last reply. */
418
+ function readMembership(ctx: Ctx, ch: ChannelRef, ids: Set<string>, tts: string, read: Record<string, Json>[]): void {
419
+ const key = `${ch.id}:${tts}`;
420
+ const posts = read.filter((r) => ids.has(str(r.user)) || ids.has(str(r.bot_id))).map((r) => num(str(r.ts)));
421
+ if (posts.length) {
422
+ // The latest post: `joined` expires 30 days after it.
423
+ const joined = (ctx.state.joined ??= {});
424
+ joined[key] = Math.max(joined[key] ?? 0, ...posts);
425
+ ctx.notJoined?.delete(key);
426
+ return;
427
+ }
428
+ const last = Math.max(ctx.notJoined?.get(key) ?? 0, ...read.map((r) => num(str(r.ts))));
429
+ (ctx.notJoined ??= new Map()).set(key, last);
430
+ }
431
+
432
+ export function humanLine(r: StreamMatch): string {
433
+ const when = new Date(num(r.ts) * 1000).toISOString().replace(/\.\d+Z$/, "Z");
434
+ const mark = r.type === "reply" ? "↳ " : "";
435
+ const body = r.text.split("\n").join("\n ");
436
+ return `${when} ${r.channel.name} ${mark}@${r.user.name}: ${body}\n ${r.permalink}`;
437
+ }
438
+
439
+ async function allPages(fetch: (cursor?: string) => Promise<Page>): Promise<Record<string, Json>[]> {
440
+ const out: Record<string, Json>[] = [];
441
+ let cursor: string | undefined;
442
+ do {
443
+ const p = await fetch(cursor);
444
+ out.push(...p.messages);
445
+ cursor = p.nextCursor;
446
+ } while (cursor);
447
+ return out;
448
+ }
449
+
450
+ /** Scan one channel up to `horizon` (now − settle) and advance its cursors.
451
+ * `full` re-reads the whole thread window (finding replies under older
452
+ * parents); otherwise only what is past the cursor is read. */
453
+ export async function scanChannel(ctx: Ctx, ch: ChannelRef, nowSec: number, full = true): Promise<void> {
454
+ const st = ctx.state.channels[ch.id]!;
455
+ const horizon = nowSec - (ctx.opts.settleSec ?? 5);
456
+ const windowStart = nowSec - ctx.opts.threadWindowSec;
457
+ const cursor = num(st.cursor);
458
+ const oldest = full ? Math.min(cursor, windowStart) : cursor;
459
+
460
+ const msgs = (await allPages((c) => ctx.client.history(ch.id, fmt(oldest), c)))
461
+ .sort((a, b) => num(str(a.ts)) - num(str(b.ts)));
462
+ // A broadcast's parent, for --replies-to.
463
+ const byTs = ctx.opts.repliesTo ? new Map(msgs.map((x) => [str(x.ts), x])) : undefined;
464
+ const newest = msgs.at(-1);
465
+ if (newest && num(str(newest.ts)) > num(st.latest ?? "0")) st.latest = str(newest.ts);
466
+ if (full) st.scanned = fmt(nowSec);
467
+
468
+ for (const m of msgs) {
469
+ const t = num(str(m.ts));
470
+ if (t <= cursor || t > horizon) continue;
471
+ // A thread_broadcast is a reply also shown in the channel; it is emitted
472
+ // here (once) and skipped when its thread's replies are read.
473
+ const tts = str(m.thread_ts);
474
+ const hit = await consider(ctx, ch, m, tts !== "" && tts !== str(m.ts), tts ? byTs?.get(tts) : undefined);
475
+ st.cursor = str(m.ts);
476
+ // Persist right after a match is written, so a restart cannot repeat it.
477
+ if (hit) saveState(ctx.opts.statePath, ctx.state);
478
+ }
479
+ if (num(st.cursor) < horizon) st.cursor = fmt(horizon);
480
+
481
+ // Threads: any parent in the window whose latest reply is past its cursor.
482
+ for (const p of msgs) {
483
+ const pts = str(p.ts);
484
+ const latest = num(str(p.latest_reply));
485
+ if (!latest || str(p.thread_ts) !== pts) continue;
486
+ const tc = num(st.threads[pts] ?? st.since);
487
+ if (latest <= tc) continue;
488
+ // When --replies-to cannot tell from the parent whether the ids are in this
489
+ // thread, this one conversations.replies read also covers what membership
490
+ // needs (from the start, or from where the last check stopped) — a second
491
+ // read could be rate-limited on every retry and stall the scan.
492
+ const ids = ctx.opts.repliesTo;
493
+ const check = ids !== undefined && knownMember(ctx, ch, ids, pts, p) === undefined;
494
+ const from = check ? Math.min(tc, ctx.notJoined?.get(`${ch.id}:${pts}`) ?? 0) : tc;
495
+ const reps = (await allPages((c) => ctx.client.replies(ch.id, pts, fmt(from), c)))
496
+ .sort((a, b) => num(str(a.ts)) - num(str(b.ts)));
497
+ if (check) readMembership(ctx, ch, ids!, pts, reps);
498
+ for (const r of reps) {
499
+ const t = num(str(r.ts));
500
+ if (str(r.ts) === pts || t <= tc || t > horizon) continue;
501
+ const hit = str(r.subtype) !== "thread_broadcast" && await consider(ctx, ch, r, true, p);
502
+ st.threads[pts] = str(r.ts);
503
+ if (hit) saveState(ctx.opts.statePath, ctx.state);
504
+ }
505
+ st.threads[pts] = fmt(Math.max(num(st.threads[pts] ?? "0"), Math.min(latest, horizon)));
506
+ }
507
+ // Forget threads that slid out of the window.
508
+ for (const k of Object.keys(st.threads)) if (num(k) < windowStart) delete st.threads[k];
509
+ saveState(ctx.opts.statePath, ctx.state);
510
+ }
511
+
512
+ /** Read the one message a bell names and run it through the filter. Cursors
513
+ * are not touched: the poll still owns them and will pass over this message
514
+ * (already in `seen`) on its next scan. Returns false when the message is not
515
+ * visible (yet). */
516
+ export async function ringBell(ctx: Ctx, ch: ChannelRef, bell: Doorbell): Promise<boolean> {
517
+ // conversations.replies on the message's own ts returns that message first
518
+ // even when it has no thread; a reply is read from just before its ts. Either
519
+ // way it is near the start, however much was posted since — but follow the
520
+ // cursor (a few pages at most) in case that second was busy. Slack puts
521
+ // the thread's parent at the top of every page, whatever `oldest` says: it
522
+ // carries `reply_users` for --replies-to.
523
+ let m: Record<string, Json> | undefined;
524
+ let parent: Record<string, Json> | undefined;
525
+ let cursor: string | undefined;
526
+ // --replies-to and nothing remembered about this thread: read it from where
527
+ // the last check stopped (or its start) in this one read, which then answers
528
+ // membership too. A second read could be rate-limited on every retry of the
529
+ // bell, so the bell would never be delivered.
530
+ const ids = ctx.opts.repliesTo;
531
+ const key = `${ch.id}:${bell.thread_ts ?? ""}`;
532
+ if (ids && bell.thread_ts && ctx.state.joined?.[key] === undefined) {
533
+ const from = Math.min(ctx.notJoined?.get(key) ?? 0, num(bell.ts) - 1);
534
+ const read = await allPages((c) => ctx.client.replies(ch.id, bell.thread_ts!, fmt(from), c));
535
+ m = read.find((x) => str(x.ts) === bell.ts);
536
+ parent = read.find((x) => str(x.ts) === bell.thread_ts);
537
+ if (m) readMembership(ctx, ch, ids, bell.thread_ts, read);
538
+ }
539
+ for (let i = 0; i < 5 && !m; i++) {
540
+ const p = await ctx.client.replies(ch.id, bell.thread_ts ?? bell.ts, fmt(num(bell.ts) - 1), cursor);
541
+ m = p.messages.find((x) => str(x.ts) === bell.ts);
542
+ parent ??= bell.thread_ts ? p.messages.find((x) => str(x.ts) === bell.thread_ts) : undefined;
543
+ cursor = p.nextCursor;
544
+ if (!cursor) break;
545
+ }
546
+ if (!m) return false;
547
+ const tts = str(m.thread_ts);
548
+ // Already behind the poll's cursors: the poll has considered it (and
549
+ // printed it if it matched) — e.g. a replay over state from before `seen`.
550
+ // Top-level posts and broadcasts ride the channel cursor; replies their
551
+ // thread's.
552
+ const st = ctx.state.channels[ch.id];
553
+ const isReply = tts !== "" && tts !== bell.ts && str(m.subtype) !== "thread_broadcast";
554
+ const polled = isReply ? st?.threads[tts] : st?.cursor;
555
+ if (polled !== undefined && num(bell.ts) <= num(polled)) return true;
556
+ const joined = Object.keys(ctx.state.joined ?? {}).length;
557
+ const hit = await consider(ctx, ch, m, tts !== "" && tts !== bell.ts, parent);
558
+ if (hit || Object.keys(ctx.state.joined ?? {}).length !== joined) saveState(ctx.opts.statePath, ctx.state);
559
+ return true;
560
+ }
561
+
562
+ /** How long a thread the bot posted in keeps counting for --replies-to after
563
+ * its last post there (a relay bell can reach a thread of any age). */
564
+ const JOINED_TTL_SEC = 30 * 86400;
565
+
566
+ function pruneJoined(st: StreamState, oldestSec: number): void {
567
+ if (!st.joined) return;
568
+ for (const [k, t] of Object.entries(st.joined)) if (t < oldestSec) delete st.joined[k];
569
+ if (!Object.keys(st.joined).length) delete st.joined;
570
+ }
571
+
572
+ /** Drop `seen` entries no scan can reach any more. */
573
+ function pruneSeen(st: StreamState, oldestSec: number): void {
574
+ if (!st.seen) return;
575
+ for (const [k, t] of Object.entries(st.seen)) if (t < oldestSec) delete st.seen[k];
576
+ if (!Object.keys(st.seen).length) delete st.seen;
577
+ }
578
+
579
+ /** Calls let through at once before pacing starts. */
580
+ const PACE_BURST = 10;
581
+
582
+ /** A token bucket (GCRA) on history/replies: `perMin` sustained, PACE_BURST
583
+ * at once. Slack's per-method limits are shared by every caller of the app,
584
+ * so bells and the poll draw from the same bucket. */
585
+ export function paced(client: StreamClient, perMin: number, signal?: AbortSignal): StreamClient {
586
+ if (!(perMin > 0)) return client;
587
+ const gap = 60_000 / perMin;
588
+ let tat = -Infinity; // theoretical arrival time of the next call
589
+ const pace = async (): Promise<void> => {
590
+ const now = _internals.now();
591
+ tat = Math.max(tat, now);
592
+ const wait = tat - now - (PACE_BURST - 1) * gap;
593
+ tat += gap;
594
+ if (wait > 0) await _internals.sleep(wait, signal);
595
+ };
596
+ return {
597
+ listChannels: () => client.listChannels(),
598
+ userName: (id) => client.userName(id),
599
+ history: async (c, oldest, cursor) => { await pace(); return client.history(c, oldest, cursor); },
600
+ replies: async (c, t, oldest, cursor) => { await pace(); return client.replies(c, t, oldest, cursor); },
601
+ };
602
+ }
603
+
604
+ /** What one poll cycle reads per channel: the full thread window, from the
605
+ * cursor only, or nothing (an idle DM between periods). Full reads go to the
606
+ * most overdue channels first, at most `cap` of them, so they spread across
607
+ * the period instead of landing on one cycle. */
608
+ export function planCycle(
609
+ channels: ChannelRef[], state: StreamState, nowSec: number,
610
+ o: { windowSec: number; periodSec: number; intervalSec: number; once: boolean },
611
+ ): Map<string, "full" | "cursor" | "skip"> {
612
+ const windowStart = nowSec - o.windowSec;
613
+ const st = (c: ChannelRef): ChanState | undefined => state.channels[c.id];
614
+ // Nothing top-level inside the window: no watched thread can gain a reply.
615
+ const idle = (c: ChannelRef): boolean => st(c)?.scanned !== undefined && num(st(c)!.latest ?? "0") < windowStart;
616
+ const due = (c: ChannelRef): boolean => o.once || st(c)?.scanned === undefined || nowSec - num(st(c)!.scanned!) >= o.periodSec;
617
+ const plan = new Map<string, "full" | "cursor" | "skip">();
618
+ const active = channels.filter((c) => !idle(c));
619
+ const cap = o.once || o.periodSec <= o.intervalSec
620
+ ? Infinity
621
+ : Math.max(1, Math.ceil(active.length * o.intervalSec / o.periodSec));
622
+ const fulls = active.filter(due).sort((a, b) => num(st(a)?.scanned ?? "0") - num(st(b)?.scanned ?? "0")).slice(0, cap);
623
+ for (const c of fulls) plan.set(c.id, "full");
624
+ for (const c of channels) {
625
+ if (plan.has(c.id)) continue;
626
+ plan.set(c.id, idle(c) && (c.isIm || c.isMpim) && !due(c) ? "skip" : "cursor");
627
+ }
628
+ return plan;
629
+ }
630
+
631
+ /** Run the stream. Resolves with the process exit code:
632
+ * 0 — matches were emitted (--once) / stopped by a signal (long-running);
633
+ * 2 — --once found no match;
634
+ * 3 — transport/config failure (stderr says what). */
635
+ export async function runStream(client: StreamClient, opts: StreamOpts): Promise<number> {
636
+ const maxFailures = opts.maxFailures ?? (opts.once ? 3 : 12);
637
+ const base = opts.backoffBaseMs ?? 2000;
638
+ const cap = opts.backoffCapMs ?? 300_000;
639
+ const refreshEvery = opts.refreshEvery ?? 10;
640
+ const relay = opts.once ? undefined : opts.relay;
641
+ if (!relay && !opts.once) {
642
+ _internals.err(
643
+ `slack stream: WARNING: polling mode (${opts.pollReason ?? "no relay configured"}) — this is the fallback, ` +
644
+ `not the intended setup: matches arrive up to ${Math.round(opts.intervalMs / 1000)}s late (replies under older ` +
645
+ `parents up to ${Math.round(Math.max(opts.threadScanSec ?? 180, opts.intervalMs / 1000))}s) and every cycle spends ` +
646
+ `Slack API calls. For real time, deploy the relay and set SLACK_RELAY_URL + SLACK_RELAY_TOKEN: ${RELAY_DOCS}`,
647
+ );
648
+ }
649
+ client = paced(client, opts.callsPerMin ?? 40, opts.signal);
650
+
651
+ let release: (() => void) | undefined;
652
+ // Stops the relay subscription when the stream ends, for any reason.
653
+ const inner = new AbortController();
654
+ const stopInner = (): void => inner.abort();
655
+ opts.signal?.addEventListener("abort", stopInner, { once: true });
656
+ try {
657
+ release = acquireLock(opts.statePath);
658
+ const state = loadState(opts.statePath, opts.identity);
659
+ // `joined` answers "is one of THESE ids in that thread": recorded for
660
+ // other ids (an explicit --state reused with a new --replies-to), drop it.
661
+ const joinedBy = opts.repliesTo ? [...opts.repliesTo].sort().join(",") : undefined;
662
+ if (state.joinedBy !== joinedBy) delete state.joined;
663
+ if (joinedBy) state.joinedBy = joinedBy;
664
+ else delete state.joinedBy;
665
+ const ctx: Ctx = { opts, client, state, names: new Map(), matches: 0 };
666
+ const skipped = new Set<string>();
667
+ let channels: ChannelRef[] = [];
668
+ const runStartSec = _internals.now() / 1000;
669
+ let lastRefreshSec = runStartSec;
670
+ let lastSkip = "";
671
+ let failures = 0;
672
+ let cycle = 0;
673
+ let lastFullMs = -Infinity;
674
+ // Slack's Retry-After applies to every caller, not just the request that
675
+ // got it: while it runs, neither bells nor the poll touch the API.
676
+ let cooldownUntil = 0;
677
+ const coolDown = (e: RateLimitError): void => {
678
+ _internals.err(`slack stream: rate limited — waiting ${e.retryAfter}s`);
679
+ cooldownUntil = Math.max(cooldownUntil, _internals.now() + e.retryAfter * 1000);
680
+ };
681
+
682
+ const refresh = async (nowSec: number): Promise<void> => {
683
+ const listed = await client.listChannels();
684
+ channels = listed.filter((c) => !opts.channels || opts.channels.includes(c.id));
685
+ if (opts.channels) {
686
+ for (const id of opts.channels) {
687
+ if (!channels.some((c) => c.id === id)) {
688
+ throw new StreamFatal(`--channel ${id}: the identity is not a member of it`);
689
+ }
690
+ }
691
+ }
692
+ const isFirst = Object.keys(state.channels).length === 0;
693
+ for (const c of channels) {
694
+ if (state.channels[c.id]) continue;
695
+ // First run: start now. A channel that appears later (new invite /
696
+ // DM) starts at the previous refresh, so the gap between joining
697
+ // and noticing it is still scanned. --since reaches back further,
698
+ // for late-discovered channels too.
699
+ const natural = isFirst || cycle === 0 ? nowSec : lastRefreshSec;
700
+ const start = Math.min(natural, opts.sinceSec !== undefined ? runStartSec - opts.sinceSec : Infinity);
701
+ state.channels[c.id] = { since: fmt(start), cursor: fmt(start), threads: {} };
702
+ }
703
+ lastRefreshSec = nowSec;
704
+ saveState(opts.statePath, state);
705
+ };
706
+
707
+ if (opts.sinceSec !== undefined) {
708
+ // An explicit --since replays from that point: drop the saved cursors,
709
+ // and the record of what was printed, so it is printed again.
710
+ state.channels = {};
711
+ delete state.seen;
712
+ // ...and the relay position, so the replay of what the relay keeps
713
+ // survives an interruption (a restart then resumes it from 0, too).
714
+ delete state.relay;
715
+ }
716
+
717
+ // --- relay: a background subscription that only queues bells; the loop
718
+ // below handles them, so Slack calls and state writes stay sequential.
719
+ // `live` starts undefined so the first outcome, up or down, is announced.
720
+ /** tries: reads that worked but did not find the message; fails: reads that errored. */
721
+ type Pending = { seq: number; bell: Doorbell; tries: number; dueMs: number; fails?: number };
722
+ const bus = {
723
+ queue: [] as Pending[], // in seq order, due now
724
+ retry: [] as Pending[], // waiting for dueMs
725
+ inflight: undefined as Pending | undefined, // being read right now
726
+ onDrain: undefined as (() => void) | undefined, // a paused relay waiting for room
727
+ handled: 0, // highest seq resolved (emitted, filtered out, or abandoned)
728
+ live: undefined as boolean | undefined, full: false,
729
+ };
730
+ let wake = new AbortController();
731
+ const ring = (): void => wake.abort();
732
+ const setLive = (live: boolean, why = ""): void => {
733
+ if (bus.live === live) return;
734
+ bus.live = live;
735
+ _internals.err(live
736
+ ? `slack stream: relay connected — polling every ${Math.round(Math.max(opts.intervalMs, relay!.reconcileMs) / 1000)}s as a safety net`
737
+ : `slack stream: relay unavailable${why ? ` (${why})` : ""} — polling every ${Math.round(opts.intervalMs / 1000)}s`);
738
+ ring();
739
+ };
740
+ const relayLoop = async (r: NonNullable<typeof relay>): Promise<void> => {
741
+ // Resume where we stopped. Without a saved point — or with --since —
742
+ // ask for everything the relay keeps: a bell that rang between our
743
+ // start and the first connection must not be lost, and handleBell drops
744
+ // whatever is older than the channel's start (or --since).
745
+ let after: Cursor = opts.sinceSec === undefined && state.relay?.url === r.url
746
+ ? { seq: state.relay.seq, ...(state.relay.epoch ? { epoch: state.relay.epoch } : {}) }
747
+ : { seq: 0 };
748
+ let attempt = 0;
749
+ const pendingCount = (): number => bus.queue.length + bus.retry.length + (bus.inflight ? 1 : 0);
750
+ while (!inner.signal.aborted) {
751
+ // Flow control: after an overflow, let the backlog drain before
752
+ // resubscribing; the relay replays what we did not take from storage.
753
+ while (pendingCount() >= MAX_QUEUED_BELLS / 2 && !inner.signal.aborted) {
754
+ await new Promise<void>((res) => {
755
+ const onAbort = (): void => res();
756
+ bus.onDrain = () => { inner.signal.removeEventListener("abort", onAbort); res(); };
757
+ inner.signal.addEventListener("abort", onAbort, { once: true });
758
+ });
759
+ }
760
+ if (inner.signal.aborted) return;
761
+ const conn = new AbortController();
762
+ const stopConn = (): void => conn.abort();
763
+ inner.signal.addEventListener("abort", stopConn, { once: true });
764
+ let paused = false;
765
+ let why = "stream closed";
766
+ try {
767
+ await r.subscribe(after, conn.signal, {
768
+ hello: (h) => {
769
+ attempt = 0;
770
+ // Bells after our resume point are gone: poll to catch up.
771
+ if (h.gap) bus.full = true;
772
+ if (h.gap || state.relay?.url !== r.url || state.relay.epoch !== h.epoch) {
773
+ state.relay = { url: r.url, seq: h.seq, ...(h.epoch ? { epoch: h.epoch } : {}) };
774
+ // The relay's numbering may have restarted: nothing from the
775
+ // old numbering may push the resume point past h.seq.
776
+ bus.handled = Math.min(bus.handled, h.seq);
777
+ for (const p of [...bus.queue, ...bus.retry, ...(bus.inflight ? [bus.inflight] : [])]) p.seq = Math.min(p.seq, h.seq);
778
+ }
779
+ after = { seq: h.seq, ...(h.epoch ? { epoch: h.epoch } : {}) };
780
+ setLive(true);
781
+ },
782
+ bell: (seq, bell) => {
783
+ if (paused) return;
784
+ if (pendingCount() >= MAX_QUEUED_BELLS) {
785
+ // Bells arrive faster than they can be read. Rather than grow
786
+ // without bound — or drop bells the poll may never find —
787
+ // hang up and come back for this one and the rest later.
788
+ _internals.err(`slack stream: relay backlog over ${MAX_QUEUED_BELLS} — pausing the relay until it drains`);
789
+ paused = true;
790
+ conn.abort();
791
+ setLive(false, "paused while its backlog drains");
792
+ return;
793
+ }
794
+ after = { ...after, seq };
795
+ bus.queue.push({ seq, bell, tries: 0, dueMs: 0 });
796
+ ring();
797
+ },
798
+ });
799
+ } catch (e) {
800
+ if (e instanceof RelayAuthError) {
801
+ if (bus.live) setLive(false, errText(e));
802
+ bus.live = false;
803
+ _internals.err(`slack stream: relay disabled for this run: ${errText(e)} — check SLACK_RELAY_TOKEN`);
804
+ return;
805
+ }
806
+ why = errText(e);
807
+ } finally {
808
+ inner.signal.removeEventListener("abort", stopConn);
809
+ }
810
+ if (inner.signal.aborted) return;
811
+ if (paused) continue;
812
+ setLive(false, why);
813
+ attempt++;
814
+ await _internals.sleep(phiDelay(attempt, 2000, 60_000), inner.signal);
815
+ }
816
+ };
817
+ if (relay) void relayLoop(relay);
818
+
819
+ /** true = resolved (emitted, or nothing to emit); false = not visible yet. */
820
+ const handleBell = async (bell: Doorbell): Promise<boolean> => {
821
+ if (opts.channels && !opts.channels.includes(bell.channel)) return true;
822
+ let ch = channels.find((c) => c.id === bell.channel);
823
+ if (!ch) {
824
+ // A channel joined since the last refresh. Re-list at most once a
825
+ // minute; until then the bell waits rather than being written off.
826
+ if (_internals.now() / 1000 - lastRefreshSec < 60) return false;
827
+ await refresh(_internals.now() / 1000);
828
+ ch = channels.find((c) => c.id === bell.channel);
829
+ }
830
+ const st = ch && state.channels[ch.id];
831
+ if (!ch || !st || skipped.has(ch.id)) return true;
832
+ // Nothing from before this channel was being watched (first run, or
833
+ // --since): a replayed bell must not reach behind that.
834
+ if (num(bell.ts) < num(st.since)) return true;
835
+ // Our own post (the relay marks the app bot's): never printed, so not
836
+ // worth a read — but for --replies-to it still says we are in the thread.
837
+ if (bell.self && opts.selfUsers.has(bell.self)) {
838
+ if (opts.repliesTo?.has(bell.self) && bell.thread_ts) {
839
+ const joined = (state.joined ??= {});
840
+ const k = `${ch.id}:${bell.thread_ts}`;
841
+ joined[k] = Math.max(joined[k] ?? 0, num(bell.ts));
842
+ ctx.notJoined?.delete(k);
843
+ saveState(opts.statePath, state);
844
+ }
845
+ return true;
846
+ }
847
+ return ringBell(ctx, ch, bell);
848
+ };
849
+ /** Resume point: every bell up to it is resolved. A bell still being
850
+ * retried holds it back, so a restart gets that bell again. */
851
+ const ack = (): void => {
852
+ if (!state.relay || state.relay.url !== relay?.url) return;
853
+ const pending = [...bus.queue, ...bus.retry, ...(bus.inflight ? [bus.inflight] : [])].map((p) => p.seq);
854
+ state.relay.seq = Math.max(0, pending.length ? Math.min(...pending) - 1 : Math.max(state.relay.seq, bus.handled));
855
+ };
856
+ /** Handle due bells — at most MAX_DRAIN per call, so a flood cannot starve
857
+ * the poll. A bell whose message is not readable yet is retried with φ
858
+ * backoff, then abandoned loudly (the poll still covers its window). */
859
+ const drainBells = async (): Promise<void> => {
860
+ const nowMs = _internals.now();
861
+ const due = bus.retry.filter((p) => p.dueMs <= nowMs);
862
+ if (due.length) {
863
+ bus.retry = bus.retry.filter((p) => p.dueMs > nowMs);
864
+ bus.queue = [...due, ...bus.queue].sort((a, b) => a.seq - b.seq);
865
+ }
866
+ let n = 0;
867
+ while (bus.queue.length && n < MAX_DRAIN && !opts.signal?.aborted && _internals.now() >= cooldownUntil) {
868
+ // Off the queue before the await: the subscription may replace the
869
+ // queue meanwhile (backlog overflow), and must not lose this bell.
870
+ const p = bus.queue.shift()!;
871
+ bus.inflight = p;
872
+ n++;
873
+ let done: boolean;
874
+ let failed: unknown;
875
+ try {
876
+ done = await handleBell(p.bell);
877
+ } catch (e) {
878
+ if (e instanceof RateLimitError) {
879
+ // Back to the scheduler, so the poll is not starved by the wait.
880
+ coolDown(e);
881
+ p.dueMs = cooldownUntil;
882
+ bus.retry.push(p);
883
+ bus.inflight = undefined;
884
+ break;
885
+ }
886
+ done = false;
887
+ failed = e;
888
+ }
889
+ bus.inflight = undefined;
890
+ // Slack saying the message (or its thread) does not exist is an
891
+ // answer, not an outage: it counts as a "not visible" try, so a
892
+ // deleted message is abandoned instead of retried forever.
893
+ if (failed !== undefined && /thread_not_found|message_not_found/.test(errText(failed))) failed = undefined;
894
+ if (failed !== undefined && classify(failed) === "channel") {
895
+ // The channel itself is unreadable (left, archived, no scope).
896
+ _internals.err(`slack stream: relay bell for ${p.bell.channel} dropped: ${errText(failed)}`);
897
+ done = true;
898
+ }
899
+ if (failed === undefined && bellsFailing) {
900
+ _internals.err("slack stream: relay bell reads recovered");
901
+ bellsFailing = false;
902
+ }
903
+ if (done) {
904
+ bus.handled = Math.max(bus.handled, p.seq);
905
+ } else if (failed !== undefined) {
906
+ // A transient failure says nothing about the message: keep it
907
+ // pending (holding the resume point) for as long as it takes.
908
+ p.fails = (p.fails ?? 0) + 1;
909
+ if (!bellsFailing) _internals.err(`slack stream: relay bell for ${p.bell.channel} failed (${errText(failed)}) — retrying`);
910
+ bellsFailing = true;
911
+ p.dueMs = _internals.now() + phiDelay(p.fails, 1500, 60_000);
912
+ bus.retry.push(p);
913
+ } else if (++p.tries >= BELL_TRIES) {
914
+ // Read fine, but the message is not there. Name the place, never the message.
915
+ _internals.err(`slack stream: relay bell for ${p.bell.channel} abandoned after ${p.tries} tries (not visible) — only the poll can find it now`);
916
+ bus.handled = Math.max(bus.handled, p.seq);
917
+ } else {
918
+ p.dueMs = _internals.now() + phiDelay(p.tries, 1500, 60_000);
919
+ bus.retry.push(p);
920
+ }
921
+ }
922
+ if (bus.onDrain && bus.queue.length + bus.retry.length < MAX_QUEUED_BELLS / 2) {
923
+ bus.onDrain();
924
+ bus.onDrain = undefined;
925
+ }
926
+ const before = state.relay?.seq;
927
+ ack();
928
+ if (n || state.relay?.seq !== before) saveState(opts.statePath, state);
929
+ };
930
+ const interval = (): number => (bus.live === true && relay ? Math.max(opts.intervalMs, relay.reconcileMs) : opts.intervalMs);
931
+ const pollDue = (): boolean => {
932
+ const t = _internals.now();
933
+ return (t >= lastFullMs + interval() || bus.full) && t >= cooldownUntil;
934
+ };
935
+ // Polls and bells take turns: a waiting bell gets one slice between two
936
+ // polls (however long a scan takes), and an overdue poll goes before a
937
+ // second slice (however many bells are waiting).
938
+ const bellsWaiting = (): boolean => bus.queue.length > 0 || bus.retry.some((p) => p.dueMs <= _internals.now());
939
+ let bellsOwed = false;
940
+ let bellsFailing = false; // one stderr line per streak of failed bell reads
941
+
942
+ while (!opts.signal?.aborted) {
943
+ if (pollDue() && !(bellsOwed && bellsWaiting())) {
944
+ bus.full = false;
945
+ const nowSec = _internals.now() / 1000;
946
+ try {
947
+ if (cycle % refreshEvery === 0) await refresh(nowSec);
948
+
949
+ const periodSec = Math.max(0, opts.threadScanSec ?? 180);
950
+ const plan = planCycle(channels.filter((c) => !skipped.has(c.id)), state, nowSec, {
951
+ windowSec: opts.threadWindowSec, periodSec, intervalSec: interval() / 1000, once: opts.once,
952
+ });
953
+ for (const ch of channels) {
954
+ if (opts.signal?.aborted) break;
955
+ if (skipped.has(ch.id)) continue;
956
+ const mode = plan.get(ch.id) ?? "full";
957
+ if (mode === "skip") continue;
958
+ for (;;) {
959
+ try {
960
+ const st = state.channels[ch.id]!;
961
+ // An idle channel whose period is up has nothing in the window to
962
+ // re-read: the cursor read stands in for the full one.
963
+ const idleDue = mode === "cursor" && st.scanned !== undefined &&
964
+ num(st.latest ?? "0") < nowSec - opts.threadWindowSec && nowSec - num(st.scanned) >= periodSec;
965
+ await scanChannel(ctx, ch, nowSec, mode === "full");
966
+ if (idleDue) { st.scanned = fmt(nowSec); saveState(opts.statePath, state); }
967
+ break;
968
+ } catch (e) {
969
+ if (e instanceof RateLimitError) {
970
+ coolDown(e);
971
+ await _internals.sleep(cooldownUntil - _internals.now(), opts.signal);
972
+ if (opts.signal?.aborted) break;
973
+ continue;
974
+ }
975
+ if (classify(e) === "channel") {
976
+ skipped.add(ch.id);
977
+ lastSkip = errText(e);
978
+ _internals.err(`slack stream: skipping ${ch.isIm ? ch.id : "#" + ch.name} (${ch.id}): ${errText(e)}`);
979
+ break;
980
+ }
981
+ throw e;
982
+ }
983
+ }
984
+ }
985
+ if (channels.length > 0 && channels.every((c) => skipped.has(c.id))) {
986
+ throw new StreamFatal(`every channel failed — nothing left to watch (last: ${lastSkip})`);
987
+ }
988
+ if (failures > 0) _internals.err(`slack stream: recovered after ${failures} failed attempt(s)`);
989
+ failures = 0;
990
+ } catch (e) {
991
+ if (e instanceof StreamFatal || classify(e) === "fatal") {
992
+ _internals.err(`slack stream: fatal: ${errText(e)}`);
993
+ return 3;
994
+ }
995
+ failures++;
996
+ if (failures >= maxFailures) {
997
+ _internals.err(`slack stream: giving up after ${failures} consecutive failures: ${errText(e)}`);
998
+ return 3;
999
+ }
1000
+ const delay = phiDelay(failures, base, cap);
1001
+ _internals.err(`slack stream: ${errText(e)} — reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${failures}/${maxFailures})`);
1002
+ await _internals.sleep(delay, opts.signal);
1003
+ bus.full = true;
1004
+ continue; // retry the same cycle
1005
+ }
1006
+
1007
+ cycle++;
1008
+ pruneSeen(state, nowSec - opts.threadWindowSec - 86400);
1009
+ pruneJoined(state, nowSec - JOINED_TTL_SEC);
1010
+ if (opts.once) {
1011
+ saveState(opts.statePath, state);
1012
+ if (ctx.matches === 0) _internals.err(`slack stream: no matches in ${channels.length - skipped.size} channel(s)`);
1013
+ return ctx.matches > 0 ? 0 : 2;
1014
+ }
1015
+ lastFullMs = nowSec * 1000;
1016
+ bellsOwed = true;
1017
+ }
1018
+
1019
+ // Sleep until the next full poll or bell retry, or until a bell (or a
1020
+ // relay up/down) wakes us.
1021
+ if (opts.signal?.aborted) break;
1022
+ const nowMs = _internals.now();
1023
+ const until = Math.max(
1024
+ cooldownUntil,
1025
+ bus.queue.length || bus.full ? nowMs : Math.min(lastFullMs + interval(), ...bus.retry.map((p) => p.dueMs)),
1026
+ );
1027
+ if (until > nowMs) {
1028
+ wake = new AbortController();
1029
+ const w = wake;
1030
+ const stop = (): void => w.abort();
1031
+ opts.signal?.addEventListener("abort", stop, { once: true });
1032
+ await _internals.sleep(until - nowMs, w.signal);
1033
+ opts.signal?.removeEventListener("abort", stop);
1034
+ }
1035
+ if (pollDue() && !(bellsOwed && bellsWaiting())) continue;
1036
+ await drainBells();
1037
+ bellsOwed = false;
1038
+ }
1039
+ return 0;
1040
+ } catch (e) {
1041
+ _internals.err(`slack stream: fatal: ${errText(e)}`);
1042
+ return 3;
1043
+ } finally {
1044
+ inner.abort();
1045
+ opts.signal?.removeEventListener("abort", stopInner);
1046
+ release?.();
1047
+ }
1048
+ }