slack-term 1.24.0 → 1.26.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/todo.ts ADDED
@@ -0,0 +1,483 @@
1
+ // Reaction-backed task tracking.
2
+ //
3
+ // A task is a message carrying the marker reaction (:pushpin: by default). Its
4
+ // progress lives in a second reaction, and optional "reason" flags stack on top:
5
+ //
6
+ // progress (priority order, highest first)
7
+ // 1 done :white_check_mark:
8
+ // 2 dropped :no_entry_sign:
9
+ // 3 doing :eyes:
10
+ // 4 pending :hourglass_flowing_sand:
11
+ // 5 undefined (marker only, no progress reaction)
12
+ //
13
+ // reason flags (any number, independent of progress)
14
+ // alert :exclamation:
15
+ // needs-discussion :question:
16
+ // waiting :speech_balloon: ball is with the other party IN this thread
17
+ // blocked :lock: stuck OUTSIDE this conversation
18
+ //
19
+ // waiting vs blocked: "whose ball is it" has only three answers, and `pending`
20
+ // already means "mine". So the remaining two are a flag each — waiting = I've
21
+ // replied and am waiting on someone in this conversation; blocked = something
22
+ // outside it (another task, a third party, an external dependency).
23
+ //
24
+ // Why the reference itself ("blocked on WHICH task / WHOM") is not stored here:
25
+ // reactions are a set of enum values, they cannot carry a pointer. The obvious
26
+ // alternative — posting a machine-readable token in a thread reply, e.g.
27
+ // `todo:v1 blocked-on=@alice` — does NOT work: Slack's full-text index splits on
28
+ // punctuation, so a quoted search for "blocked-on" returns 0 hits (measured),
29
+ // and "1.91" matches 1.91.0. Any future attempt needs a single alphanumeric
30
+ // marker word (e.g. `tdblock`) plus a bare @mention, not a symbol-laden token.
31
+ //
32
+ // The invariant is NOT "exactly one progress reaction" but "at least one, and
33
+ // readers collapse by priority". That is what makes `todo set` safe to interrupt
34
+ // — see the ordering comment on todoSet().
35
+ //
36
+ // Everything here is built on search's has:/hasmy: modifiers because
37
+ // search.messages does not return a reactions field at all: filtering results
38
+ // client-side is impossible, so the filter must live in the query.
39
+
40
+ import { existsSync, readFileSync } from "node:fs";
41
+ import { homedir } from "node:os";
42
+ import { join } from "node:path";
43
+
44
+ import {
45
+ RateLimitError,
46
+ authTest,
47
+ history,
48
+ reactionAdd,
49
+ reactionRemove,
50
+ reactionsGet,
51
+ } from "./slack.ts";
52
+ import { cacheGet, cacheSet } from "./cache.ts";
53
+
54
+ // ── config ────────────────────────────────────────────────────────────────
55
+
56
+ export type ProgressState = "done" | "dropped" | "doing" | "pending";
57
+ export type FlagName = "alert" | "needs-discussion" | "waiting" | "blocked";
58
+ export type LsState = ProgressState | "untriaged" | "stuck" | "open";
59
+
60
+ export type TodoConfig = {
61
+ marker: string;
62
+ /** Highest priority first — readers collapse a multi-reaction message to progress[0]. */
63
+ progress: Record<ProgressState, string>;
64
+ flags: Record<FlagName, string>;
65
+ };
66
+
67
+ export const DEFAULT_TODO_CONFIG: TodoConfig = {
68
+ marker: "pushpin",
69
+ progress: {
70
+ done: "white_check_mark",
71
+ dropped: "no_entry_sign",
72
+ doing: "eyes",
73
+ // ⏳ hourglass_flowing_sand, NOT ⌛ hourglass: measured against a live
74
+ // workspace, real usage is 21× :hourglass_flowing_sand: and 0× :hourglass:.
75
+ pending: "hourglass_flowing_sand",
76
+ },
77
+ flags: {
78
+ alert: "exclamation",
79
+ "needs-discussion": "question",
80
+ waiting: "speech_balloon",
81
+ blocked: "lock",
82
+ },
83
+ };
84
+
85
+ /** Progress states from highest to lowest priority. */
86
+ export const PROGRESS_ORDER: ProgressState[] = ["done", "dropped", "doing", "pending"];
87
+
88
+ export const FLAG_NAMES: FlagName[] = ["alert", "needs-discussion", "waiting", "blocked"];
89
+
90
+ function todoConfigPath(): string {
91
+ // Same directory resolution as profiles.json / cache.json.
92
+ return join(process.env.HOME || homedir(), ".config", "slack-cli", "todo.json");
93
+ }
94
+
95
+ function stripColons(s: string): string {
96
+ return s.replace(/^:+|:+$/g, "");
97
+ }
98
+
99
+ /** Load ~/.config/slack-cli/todo.json over the defaults. Fail-soft: a missing or
100
+ * malformed file just means "use the defaults" — a broken override must not
101
+ * make every todo command unusable. */
102
+ export function loadTodoConfig(path = todoConfigPath()): TodoConfig {
103
+ const cfg: TodoConfig = {
104
+ marker: DEFAULT_TODO_CONFIG.marker,
105
+ progress: { ...DEFAULT_TODO_CONFIG.progress },
106
+ flags: { ...DEFAULT_TODO_CONFIG.flags },
107
+ };
108
+ try {
109
+ if (!existsSync(path)) return cfg;
110
+ const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<TodoConfig>;
111
+ if (typeof raw.marker === "string" && raw.marker.trim()) cfg.marker = stripColons(raw.marker.trim());
112
+ for (const s of PROGRESS_ORDER) {
113
+ const v = raw.progress?.[s];
114
+ if (typeof v === "string" && v.trim()) cfg.progress[s] = stripColons(v.trim());
115
+ }
116
+ for (const f of FLAG_NAMES) {
117
+ const v = raw.flags?.[f];
118
+ if (typeof v === "string" && v.trim()) cfg.flags[f] = stripColons(v.trim());
119
+ }
120
+ } catch {
121
+ // Unreadable/invalid config — defaults.
122
+ }
123
+ return cfg;
124
+ }
125
+
126
+ // ── query building ────────────────────────────────────────────────────────
127
+
128
+ /** `hasmy::eyes:` / `has::eyes:` — the modifier's argument is itself colon-wrapped. */
129
+ function hasTerm(emoji: string, shared: boolean, negate = false): string {
130
+ return `${negate ? "-" : ""}${shared ? "has" : "hasmy"}::${stripColons(emoji)}:`;
131
+ }
132
+
133
+ export type LsQueryOpts = {
134
+ /** true → has: (anyone's reactions). Default false → hasmy: (only mine). */
135
+ shared?: boolean;
136
+ /** #channel (or @user / raw name) to scope with in:. */
137
+ in?: string;
138
+ /** @user (or "me") to scope with from: — the sender is usually the person
139
+ * you're blocked on, so this covers most of "who is this waiting on?". */
140
+ from?: string;
141
+ };
142
+
143
+ /** Build the ONE search query that answers a `todo ls --state <state>`.
144
+ *
145
+ * Never split a state into several searches: search.messages is a Tier-2
146
+ * method (~20 req/min) and a per-state fan-out burns that budget instantly.
147
+ * Priority is expressed as negations of the higher-priority emoji, which is
148
+ * exactly the "collapse by priority" rule the state model defines. */
149
+ export function buildTodoQuery(state: LsState, cfg: TodoConfig, opts: LsQueryOpts = {}): string {
150
+ const shared = opts.shared === true;
151
+ const has = (e: string) => hasTerm(e, shared);
152
+ const not = (e: string) => hasTerm(e, shared, true);
153
+ const terms: string[] = [];
154
+
155
+ if (state === "untriaged") {
156
+ // Marked as a task but with no progress reaction at all.
157
+ terms.push(has(cfg.marker), ...PROGRESS_ORDER.map((s) => not(cfg.progress[s])));
158
+ } else if (state === "stuck") {
159
+ // Not finished, and carrying at least one reason flag.
160
+ terms.push(
161
+ has(cfg.marker),
162
+ not(cfg.progress.done),
163
+ not(cfg.progress.dropped),
164
+ `(${FLAG_NAMES.map((f) => has(cfg.flags[f])).join(" OR ")})`,
165
+ );
166
+ } else if (state === "open") {
167
+ // Default view: everything marked as a task that isn't finished or dropped.
168
+ terms.push(has(cfg.marker), not(cfg.progress.done), not(cfg.progress.dropped));
169
+ } else {
170
+ // A progress state: its own emoji, minus every higher-priority emoji.
171
+ const idx = PROGRESS_ORDER.indexOf(state);
172
+ terms.push(has(cfg.progress[state]));
173
+ for (const higher of PROGRESS_ORDER.slice(0, idx)) terms.push(not(cfg.progress[higher]));
174
+ }
175
+
176
+ if (opts.in) {
177
+ const ref = opts.in.startsWith("#") || opts.in.startsWith("@") ? opts.in : `#${opts.in}`;
178
+ terms.push(`in:${ref}`);
179
+ }
180
+ if (opts.from) {
181
+ // "me" is a Slack search keyword in its own right — never prefix it with @.
182
+ const who = opts.from === "me" || opts.from.startsWith("@") ? opts.from : `@${opts.from}`;
183
+ terms.push(`from:${who}`);
184
+ }
185
+ return terms.join(" ");
186
+ }
187
+
188
+ // ── rate-limit retry ──────────────────────────────────────────────────────
189
+
190
+ export const MAX_RATE_LIMIT_RETRIES = 3;
191
+
192
+ export const _internals = {
193
+ // Swappable so tests never wait real time (same pattern as tail.ts).
194
+ sleep: (ms: number): Promise<void> => new Promise((res) => setTimeout(res, ms)),
195
+ };
196
+
197
+ /** Run an API call, backing off and retrying on 429. Mirrors tail.ts: warn on
198
+ * stderr, sleep `retryAfter`, try again — but bounded, so a persistently
199
+ * throttled workspace fails loudly instead of hanging forever. */
200
+ export async function withRateLimitRetry<T>(
201
+ label: string,
202
+ fn: () => Promise<T>,
203
+ maxRetries = MAX_RATE_LIMIT_RETRIES,
204
+ ): Promise<T> {
205
+ for (let attempt = 0; ; attempt++) {
206
+ try {
207
+ return await fn();
208
+ } catch (e: unknown) {
209
+ if (!(e instanceof RateLimitError)) throw e;
210
+ if (attempt >= maxRetries) {
211
+ throw new Error(
212
+ `Slack rate limit hit on ${label} — gave up after ${maxRetries} retries. Try again in a minute.`,
213
+ );
214
+ }
215
+ console.error(`Warning: Slack rate limit hit on ${label} — backing off for ${e.retryAfter}s`);
216
+ await _internals.sleep(e.retryAfter * 1000);
217
+ }
218
+ }
219
+ }
220
+
221
+ // ── cached lookups ────────────────────────────────────────────────────────
222
+
223
+ const identityMemo = new Map<string, { userId: string; teamId: string }>();
224
+
225
+ /** auth.test, memoised for the process and keyed by token (never persisted: this
226
+ * is identity, not a lookup, and a stale self-ID would corrupt `todo set`).
227
+ * Yields both the user ID (to tell my reactions from other people's) and the
228
+ * team ID (the cache namespace). */
229
+ async function identity(token: string, cookie?: string): Promise<{ userId: string; teamId: string }> {
230
+ const hit = identityMemo.get(token);
231
+ if (hit) return hit;
232
+ const info = await withRateLimitRetry("auth.test", () => authTest(token, cookie));
233
+ const id = { userId: info.userId, teamId: info.teamId };
234
+ identityMemo.set(token, id);
235
+ return id;
236
+ }
237
+
238
+ /** The authenticated user's ID — needed to tell my reactions from other people's. */
239
+ export async function selfUserId(token: string, cookie?: string): Promise<string> {
240
+ return (await identity(token, cookie)).userId;
241
+ }
242
+
243
+ /** Cache namespace for this token's workspace. Fail-soft: if auth.test can't be
244
+ * reached, return "" — cacheGet/cacheSet treat that as a miss, so the command
245
+ * runs uncached rather than risking another workspace's IDs. */
246
+ export async function cacheScope(token: string, cookie?: string): Promise<string> {
247
+ try {
248
+ return (await identity(token, cookie)).teamId;
249
+ } catch {
250
+ return "";
251
+ }
252
+ }
253
+
254
+ export function resetSelfIdForTests(): void {
255
+ identityMemo.clear();
256
+ }
257
+
258
+ /** resolveChannel() with the persistent cache in front of it (channel name → ID
259
+ * is stable for an hour). Raw IDs and permalinks never hit the cache. */
260
+ export async function resolveChannelCached(
261
+ token: string,
262
+ ref: string,
263
+ resolve: (token: string, ref: string, cookie?: string) => Promise<string>,
264
+ cookie?: string,
265
+ ): Promise<string> {
266
+ if (!ref.startsWith("#")) return resolve(token, ref, cookie);
267
+ const scope = await cacheScope(token, cookie);
268
+ const hit = cacheGet("channels", scope, ref);
269
+ if (hit) return hit;
270
+ const id = await withRateLimitRetry("resolveChannel", () => resolve(token, ref, cookie));
271
+ cacheSet("channels", scope, ref, id);
272
+ return id;
273
+ }
274
+
275
+ /** userID → handle with the persistent cache in front of it. Callers put their
276
+ * own in-process Map in front of THIS, making the lookup two-tier:
277
+ * Map → cache.json (1h TTL) → users.info. */
278
+ export async function userHandleCached(
279
+ token: string,
280
+ userId: string,
281
+ lookup: (token: string, userId: string, cookie?: string) => Promise<string>,
282
+ cookie?: string,
283
+ ): Promise<string> {
284
+ const scope = await cacheScope(token, cookie);
285
+ const hit = cacheGet("users", scope, userId);
286
+ if (hit) return hit;
287
+ const handle = await withRateLimitRetry("users.info", () => lookup(token, userId, cookie));
288
+ // Don't persist a failed resolution — userInfoPair/userName fall back to the
289
+ // raw ID, and caching that would pin the ugly form for an hour.
290
+ if (handle && handle !== userId) cacheSet("users", scope, userId, handle);
291
+ return handle;
292
+ }
293
+
294
+ // ── set / flag ────────────────────────────────────────────────────────────
295
+
296
+ export type SetResult = {
297
+ noop: boolean;
298
+ added?: ProgressState;
299
+ removed: string[];
300
+ /** Progress emoji that could not be removed — the message is left multi-state. */
301
+ leftover: string[];
302
+ };
303
+
304
+ /** Move a task to `state`.
305
+ *
306
+ * ORDER IS THE WHOLE POINT: reactions.add runs FIRST, then the other progress
307
+ * reactions are removed one at a time.
308
+ *
309
+ * Never invert this to remove-then-add. Removing first leaves a window with
310
+ * zero progress reactions; a crash, a 429 or a Ctrl-C inside that window drops
311
+ * the task out of every `todo ls` query permanently, with nothing left to point
312
+ * at it. Add-first's worst case is a message carrying two progress reactions —
313
+ * harmless, because readers collapse by priority, and trivially repaired later
314
+ * by `todo doctor --fix`.
315
+ *
316
+ * Removes are awaited serially for the same reason: Promise.all would drop the
317
+ * ordering guarantee (and could land a remove before the add is acknowledged).
318
+ * Individual remove failures (no_reaction, already gone) are swallowed so one
319
+ * bad emoji cannot strand the rest. */
320
+ export async function todoSet(
321
+ token: string,
322
+ channel: string,
323
+ ts: string,
324
+ state: ProgressState,
325
+ cfg: TodoConfig,
326
+ cookie?: string,
327
+ ): Promise<SetResult> {
328
+ const target = cfg.progress[state];
329
+ const selfId = await selfUserId(token, cookie);
330
+
331
+ // Always a live read — reaction state is the thing we're about to change, so a
332
+ // cached copy could make us skip a real transition.
333
+ const reactions = await withRateLimitRetry("reactions.get", () => reactionsGet(token, channel, ts, cookie));
334
+ const mine = new Set(
335
+ reactions.filter((r) => r.users.includes(selfId)).map((r) => r.name),
336
+ );
337
+ const myProgress = PROGRESS_ORDER.map((s) => cfg.progress[s]).filter((e) => mine.has(e));
338
+
339
+ if (myProgress.length === 1 && myProgress[0] === target) {
340
+ return { noop: true, removed: [], leftover: [] };
341
+ }
342
+
343
+ // 1. ADD FIRST — after this line the task is findable under the new state.
344
+ if (!mine.has(target)) {
345
+ await withRateLimitRetry("reactions.add", () => reactionAdd(token, channel, ts, target, cookie));
346
+ }
347
+
348
+ // 2. Then retire the stale progress reactions, strictly one after another.
349
+ const removed: string[] = [];
350
+ const leftover: string[] = [];
351
+ for (const emoji of myProgress) {
352
+ if (emoji === target) continue;
353
+ try {
354
+ await withRateLimitRetry("reactions.remove", () => reactionRemove(token, channel, ts, emoji, cookie));
355
+ removed.push(emoji);
356
+ } catch {
357
+ // no_reaction / raced removal / lost scope — keep going, report at the end.
358
+ leftover.push(emoji);
359
+ }
360
+ }
361
+ return { noop: false, added: state, removed, leftover };
362
+ }
363
+
364
+ /** Reason flags stack freely, so this is a plain add/remove with no ordering rule. */
365
+ export async function todoFlag(
366
+ token: string,
367
+ channel: string,
368
+ ts: string,
369
+ flag: FlagName,
370
+ cfg: TodoConfig,
371
+ remove: boolean,
372
+ cookie?: string,
373
+ ): Promise<string> {
374
+ const emoji = cfg.flags[flag];
375
+ if (remove) {
376
+ await withRateLimitRetry("reactions.remove", () => reactionRemove(token, channel, ts, emoji, cookie));
377
+ } else {
378
+ await withRateLimitRetry("reactions.add", () => reactionAdd(token, channel, ts, emoji, cookie));
379
+ }
380
+ return emoji;
381
+ }
382
+
383
+ // ── doctor ────────────────────────────────────────────────────────────────
384
+
385
+ export const DOCTOR_DEFAULT_LIMIT = 1000;
386
+
387
+ export type DoctorFinding = {
388
+ ts: string;
389
+ text: string;
390
+ /** Progress emoji found on the message, highest priority first. */
391
+ emoji: string[];
392
+ /** The one that survives the priority rule. */
393
+ keep: string;
394
+ fixed?: boolean;
395
+ error?: string;
396
+ };
397
+
398
+ export type DoctorReport = {
399
+ scanned: number;
400
+ findings: DoctorFinding[];
401
+ /** True when the scan stopped at `limit` with more history left — reported, never silent. */
402
+ truncated: boolean;
403
+ };
404
+
405
+ /** Scan a channel's history for messages carrying more than one of MY progress
406
+ * reactions, and optionally repair them.
407
+ *
408
+ * Paging is strictly sequential — parallel page fetches would multiply the
409
+ * request rate against a Tier-3 method for no latency win worth having, and
410
+ * cursors are inherently serial anyway. */
411
+ export async function todoDoctor(
412
+ token: string,
413
+ channel: string,
414
+ cfg: TodoConfig,
415
+ opts: { fix?: boolean; limit?: number; cookie?: string } = {},
416
+ ): Promise<DoctorReport> {
417
+ const limit = opts.limit ?? DOCTOR_DEFAULT_LIMIT;
418
+ const cookie = opts.cookie;
419
+ const selfId = await selfUserId(token, cookie);
420
+ const progressEmoji = PROGRESS_ORDER.map((s) => cfg.progress[s]);
421
+
422
+ const findings: DoctorFinding[] = [];
423
+ let scanned = 0;
424
+ let cursor: string | undefined;
425
+ let truncated = false;
426
+
427
+ while (scanned < limit) {
428
+ const page = Math.min(200, limit - scanned);
429
+ const resp = (await withRateLimitRetry("conversations.history", () =>
430
+ history(token, channel, page, undefined, cursor, cookie))) as {
431
+ messages?: Array<{ ts?: string; text?: string; reactions?: Array<{ name?: string; users?: string[] }> }>;
432
+ response_metadata?: { next_cursor?: string };
433
+ };
434
+ const messages = resp.messages ?? [];
435
+ scanned += messages.length;
436
+
437
+ for (const m of messages) {
438
+ const ts = String(m.ts ?? "");
439
+ if (!ts) continue;
440
+ const mine = new Set(
441
+ (m.reactions ?? []).filter((r) => (r.users ?? []).map(String).includes(selfId)).map((r) => String(r.name ?? "")),
442
+ );
443
+ const found = progressEmoji.filter((e) => mine.has(e));
444
+ if (found.length < 2) continue;
445
+ findings.push({ ts, text: String(m.text ?? "").split("\n")[0] ?? "", emoji: found, keep: found[0]! });
446
+ }
447
+
448
+ cursor = resp.response_metadata?.next_cursor || undefined;
449
+ // An empty page with a cursor would otherwise spin forever without ever
450
+ // reaching `limit` — Slack rarely does this, but the loop must terminate.
451
+ if (!cursor || messages.length === 0) break;
452
+ if (scanned >= limit) {
453
+ truncated = true;
454
+ break;
455
+ }
456
+ }
457
+
458
+ if (opts.fix) {
459
+ for (const f of findings) {
460
+ // Same invariant as todoSet: the winner must be present BEFORE anything is
461
+ // removed. The scan says it already is, but that snapshot can be stale by
462
+ // now, so re-assert it — already_reacted just means it was true — and only
463
+ // then remove the losers, serially.
464
+ try {
465
+ try {
466
+ await withRateLimitRetry("reactions.add", () => reactionAdd(token, channel, f.ts, f.keep, cookie));
467
+ } catch (e: unknown) {
468
+ if (!(e instanceof Error) || !/already_reacted/.test(e.message)) throw e;
469
+ }
470
+ for (const emoji of f.emoji) {
471
+ if (emoji === f.keep) continue;
472
+ await withRateLimitRetry("reactions.remove", () => reactionRemove(token, channel, f.ts, emoji, cookie));
473
+ }
474
+ f.fixed = true;
475
+ } catch (e: unknown) {
476
+ f.fixed = false;
477
+ f.error = e instanceof Error ? e.message : String(e);
478
+ }
479
+ }
480
+ }
481
+
482
+ return { scanned, findings, truncated };
483
+ }