slack-term 1.48.0 → 1.49.1

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/scope.ts ADDED
@@ -0,0 +1,472 @@
1
+ // `.slack-term/` — per-directory state, found like `.git`: the nearest one
2
+ // walking up from the cwd is the scope, so each lane's tree (or repo) owns its
3
+ // own aliases and settings without a pid, an env var or a daemon. No
4
+ // `.slack-term/` anywhere up the tree falls back to ~/.config/slack-term/.
5
+ //
6
+ // .slack-term/
7
+ // README.md what this is (written on init)
8
+ // .gitignore "*" — nothing in here is ever committed
9
+ // settings.yml written by people: prefix, channel, as_bot, subscriptions
10
+ // settings.local.yml written by `slack alias`; overrides settings.yml
11
+ // inbox/ archived/ incoming messages, one file each; handled ones move to archived/
12
+ // outbox/ sent/ outgoing messages, one file each; sent ones move to sent/
13
+ //
14
+ // Every write is tmp + rename (a reader never sees half a file), and the
15
+ // read-modify-write of settings.local.yml holds a lock file, so two `slack
16
+ // alias set` at once cannot drop each other's entry.
17
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, join, resolve } from "node:path";
20
+
21
+ export const SCOPE_DIR = ".slack-term";
22
+ export const SCOPE_SUBDIRS = ["inbox", "archived", "outbox", "sent"] as const;
23
+
24
+ export type Scope = { dir: string; global: boolean };
25
+
26
+ /** The global fallback: ~/.config/slack-term (SLACK_TERM_HOME overrides, for tests). */
27
+ export function globalScopeDir(): string {
28
+ return process.env.SLACK_TERM_HOME ?? join(homedir(), ".config", "slack-term");
29
+ }
30
+
31
+ /** The nearest `.slack-term/` at or above `cwd`, else the global one. */
32
+ export function findScope(cwd = process.cwd()): Scope {
33
+ let d = resolve(cwd);
34
+ for (;;) {
35
+ const c = join(d, SCOPE_DIR);
36
+ try {
37
+ if (statSync(c).isDirectory()) return { dir: c, global: false };
38
+ } catch { /* not here */ }
39
+ const up = dirname(d);
40
+ if (up === d) break;
41
+ d = up;
42
+ }
43
+ return { dir: globalScopeDir(), global: true };
44
+ }
45
+
46
+ const README = `# .slack-term/
47
+
48
+ State for [slack-term](https://github.com/snomiao/slack-term) in this directory.
49
+ \`slack\` uses the nearest \`.slack-term/\` walking up from where it runs (like
50
+ \`.git\`), so everything below this directory shares it. Without one it uses
51
+ ~/.config/slack-term/.
52
+
53
+ Nothing here is committed: \`.gitignore\` holds \`*\`.
54
+
55
+ | Path | What |
56
+ |---|---|
57
+ | \`settings.yml\` | yours to edit: \`prefix\` (put in front of every \`slack reply\`, e.g. \`"[design]"\`), \`channel\` (default), \`as_bot\`, \`subscriptions\` |
58
+ | \`settings.local.yml\` | written by \`slack alias set/unset\`; same keys, wins over \`settings.yml\` |
59
+ | \`inbox/\` → \`archived/\` | one file per incoming message (\`slack stream --inbox\`): who, where, the text with secrets redacted, a files count, the permalink. Handled = moved to \`archived/\`, by \`slack done\` or by a sent outbox reply with \`in_reply_to\` |
60
+ | \`outbox/\` → \`sent/\` | one file per queued reply (\`slack outbox add\`, redacted); \`slack outbox flush\` sends them and moves each to \`sent/\` with its ts and permalink. A failure stays, with \`error\` and \`attempts\` |
61
+
62
+ \`restrict_to_subscriptions\`, \`auto_subscribe\` and \`nudge_after_min\` count only in
63
+ \`settings.yml\`. With \`restrict_to_subscriptions: true\`, only \`settings.yml\`'s
64
+ subscriptions count, \`slack alias set\` refuses (the owner uses \`--shared\`), and
65
+ \`reply\` / \`outbox flush\` refuse any other thread.
66
+
67
+ \`subscriptions\` maps an alias to a place:
68
+
69
+ subscriptions:
70
+ board:
71
+ channel: C00000001
72
+ thread_ts: "1700000000.000100"
73
+
74
+ ## Commands
75
+
76
+ slack alias ls # the aliases here
77
+ slack alias set <name> <permalink> # name a thread (a reply's permalink names its root)
78
+ slack alias unset <name>
79
+ slack read <name> # read the thread
80
+ slack reply <name> '<text>' # reply in it, with the prefix, behind the --code gate
81
+ slack reply --to <permalink> '<text>' # reply in the thread where that message is
82
+ slack reply <name> --to <permalink> … # refused when that message is in another thread
83
+ slack inbox ls | listen | nudge # what was routed here
84
+ slack done <file|ts> # handled without a reply
85
+ slack outbox add <name> '<text>' --in-reply-to <ts>
86
+ slack outbox flush # send the queue (one --code for the batch)
87
+ slack alias who <channel> <ts> # which dirs subscribe a thread
88
+ `;
89
+
90
+ const SETTINGS = `# slack-term settings for this directory. settings.local.yml (written by
91
+ # \`slack alias\`) has the same keys and wins over this file.
92
+ #
93
+ # prefix: "[lane]" # put in front of every \`slack reply\`; never doubled
94
+ # channel: C00000001 # default channel
95
+ # as_bot: false # reply as the bot (SLACK_BOT_TOKEN)
96
+ # subscriptions: # alias -> place (\`slack alias set\` writes settings.local.yml)
97
+ # board:
98
+ # channel: C00000001
99
+ # thread_ts: "1700000000.000100"
100
+ #
101
+ # Only here, never in settings.local.yml:
102
+ # restrict_to_subscriptions: true # the subscriptions above are the whole allowlist,
103
+ # # and prefix / as_bot / channel come from here only
104
+ # owner: ~/ws/x/tree/shr # with restrict: --shared writes run only from under it
105
+ # auto_subscribe: [D00000001] # new top-level messages there are subscribed here
106
+ # nudge_after_min: 30 # \`slack inbox nudge\` reports what waits longer
107
+ `;
108
+
109
+ export type Subscription = { channel: string; thread_ts?: string; added?: string; note?: string };
110
+ export type Settings = {
111
+ prefix?: string; channel?: string; as_bot?: boolean; subscriptions?: Record<string, Subscription>;
112
+ /** settings.yml only: `subscriptions` there are the whole allowlist —
113
+ * settings.local.yml can neither add one nor switch this off, and `reply`
114
+ * refuses a thread that is not in it. */
115
+ restrict_to_subscriptions?: boolean;
116
+ /** settings.yml only: channels whose NEW top-level messages are subscribed
117
+ * here automatically (as `t-<ts>`), so a fresh DM thread has an owner. */
118
+ auto_subscribe?: string[];
119
+ /** Minutes an inbox entry may wait before `slack inbox nudge` reports it. */
120
+ nudge_after_min?: number;
121
+ /** settings.yml only: the directory whose lane owns this allowlist (e.g. the
122
+ * coordinator's tree). In a restricted scope, `--shared` writes are refused
123
+ * unless they run from under it — every lane is the same uid, so the CLI is
124
+ * the only thing that can tell them apart. */
125
+ owner?: string;
126
+ };
127
+
128
+ /** Create (or complete) `<parent>/.slack-term/`. Idempotent: existing files are kept. */
129
+ export function initScope(parent: string, settings: Omit<Settings, "subscriptions"> = {}): { dir: string; created: string[] } {
130
+ const dir = join(resolve(parent), SCOPE_DIR);
131
+ const created: string[] = [];
132
+ mkdirSync(dir, { recursive: true });
133
+ for (const sub of SCOPE_SUBDIRS) {
134
+ const d = join(dir, sub);
135
+ if (!existsSync(d)) { mkdirSync(d, { recursive: true }); created.push(`${sub}/`); }
136
+ }
137
+ const put = (name: string, body: string): void => {
138
+ const p = join(dir, name);
139
+ if (existsSync(p)) return;
140
+ atomicWrite(p, body);
141
+ created.push(name);
142
+ };
143
+ put(".gitignore", "*\n");
144
+ put("README.md", README);
145
+ const given = Object.fromEntries(Object.entries(settings).filter(([, v]) => v !== undefined));
146
+ put("settings.yml", SETTINGS + (Object.keys(given).length ? "\n" + Bun.YAML.stringify(given, null, 2) + "\n" : ""));
147
+ if (dir !== globalScopeDir()) registerScope(dir);
148
+ return { dir, created };
149
+ }
150
+
151
+ export function atomicWrite(path: string, body: string): void {
152
+ mkdirSync(dirname(path), { recursive: true });
153
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
154
+ writeFileSync(tmp, body, { mode: 0o600 });
155
+ renameSync(tmp, path);
156
+ }
157
+
158
+ /** Hold `<path>.lock` (O_EXCL) around `fn`. A lock older than `staleMs` is a
159
+ * crashed writer's and is taken over. */
160
+ export function withFileLock<T>(path: string, fn: () => T, staleMs = 10_000, waitMs = 5_000): T {
161
+ const lock = `${path}.lock`;
162
+ mkdirSync(dirname(lock), { recursive: true });
163
+ const until = Date.now() + waitMs;
164
+ for (;;) {
165
+ try {
166
+ closeSync(openSync(lock, "wx"));
167
+ break;
168
+ } catch (e) {
169
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
170
+ try {
171
+ if (Date.now() - statSync(lock).mtimeMs > staleMs) { unlinkSync(lock); continue; }
172
+ } catch { continue; }
173
+ if (Date.now() > until) throw new Error(`${lock} is held by another writer`);
174
+ Bun.sleepSync(25);
175
+ }
176
+ }
177
+ try {
178
+ return fn();
179
+ } finally {
180
+ try { unlinkSync(lock); } catch { /* already gone */ }
181
+ }
182
+ }
183
+
184
+ function asSubscription(v: unknown): Subscription | undefined {
185
+ if (!v || typeof v !== "object") return undefined;
186
+ const o = v as Record<string, unknown>;
187
+ if (typeof o.channel !== "string" || !o.channel) return undefined;
188
+ const s: Subscription = { channel: o.channel };
189
+ // A ts written unquoted parses as a number and loses its trailing zeros.
190
+ if (typeof o.thread_ts === "string" && /^\d{10}\.\d{6}$/.test(o.thread_ts)) s.thread_ts = o.thread_ts;
191
+ if (typeof o.added === "string") s.added = o.added;
192
+ if (typeof o.note === "string") s.note = o.note;
193
+ return s;
194
+ }
195
+
196
+ /** One settings file, validated; unknown keys and malformed entries are dropped. */
197
+ export function readSettingsFile(path: string): Settings {
198
+ let raw: unknown;
199
+ try {
200
+ raw = Bun.YAML.parse(readFileSync(path, "utf8"));
201
+ } catch (e) {
202
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return {};
203
+ throw new Error(`${path}: ${e instanceof Error ? e.message : String(e)}`);
204
+ }
205
+ if (!raw || typeof raw !== "object") return {};
206
+ const o = raw as Record<string, unknown>;
207
+ const s: Settings = {};
208
+ if (typeof o.prefix === "string" && o.prefix.trim()) s.prefix = o.prefix.trim();
209
+ // `prefix: [lane]` unquoted is a YAML list: take the one-element form as meant.
210
+ else if (Array.isArray(o.prefix) && o.prefix.length === 1 && typeof o.prefix[0] === "string") s.prefix = `[${o.prefix[0]}]`;
211
+ if (typeof o.channel === "string" && o.channel) s.channel = o.channel;
212
+ if (typeof o.as_bot === "boolean") s.as_bot = o.as_bot;
213
+ if (o.restrict_to_subscriptions === true) s.restrict_to_subscriptions = true;
214
+ if (Array.isArray(o.auto_subscribe)) s.auto_subscribe = o.auto_subscribe.filter((c): c is string => typeof c === "string");
215
+ if (typeof o.nudge_after_min === "number" && o.nudge_after_min > 0) s.nudge_after_min = o.nudge_after_min;
216
+ if (typeof o.owner === "string" && o.owner) s.owner = o.owner;
217
+ if (o.subscriptions && typeof o.subscriptions === "object") {
218
+ const subs: Record<string, Subscription> = {};
219
+ for (const [k, v] of Object.entries(o.subscriptions as Record<string, unknown>)) {
220
+ const sub = asSubscription(v);
221
+ if (sub && validAliasName(k)) subs[k] = sub;
222
+ }
223
+ s.subscriptions = subs;
224
+ }
225
+ return s;
226
+ }
227
+
228
+ /** settings.yml with settings.local.yml over it (subscriptions merge by name). */
229
+ export function readSettings(scope: Scope): Settings {
230
+ const base = readSettingsFile(join(scope.dir, "settings.yml"));
231
+ const local = readSettingsFile(join(scope.dir, "settings.local.yml"));
232
+ // What only settings.yml may say.
233
+ delete local.restrict_to_subscriptions;
234
+ delete local.auto_subscribe;
235
+ delete local.owner;
236
+ if (base.restrict_to_subscriptions) {
237
+ // Who the lane posts as is the owner's call too: a local prefix `[shr]`
238
+ // would let any lane speak as another.
239
+ delete local.subscriptions;
240
+ delete local.prefix;
241
+ delete local.as_bot;
242
+ delete local.channel;
243
+ }
244
+ const subs = { ...base.subscriptions, ...local.subscriptions };
245
+ const out: Settings = { ...base, ...local };
246
+ if (Object.keys(subs).length) out.subscriptions = subs;
247
+ else delete out.subscriptions;
248
+ return out;
249
+ }
250
+
251
+ // --- aliases ---
252
+
253
+ export type Alias = Subscription & { name: string; scope: Scope; file: "settings.yml" | "settings.local.yml" };
254
+
255
+ const ALIAS_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
256
+
257
+ /** Lowercase letters, digits, `.` `_` `-`; never a bare ts or a Slack id. */
258
+ export function validAliasName(name: string): boolean {
259
+ return ALIAS_RE.test(name) && !/^\d{10}\.\d{6}$/.test(name);
260
+ }
261
+
262
+ export function listAliases(scope: Scope): Alias[] {
263
+ const baseFile = readSettingsFile(join(scope.dir, "settings.yml"));
264
+ const base = baseFile.subscriptions ?? {};
265
+ const local = baseFile.restrict_to_subscriptions ? {} : readSettingsFile(join(scope.dir, "settings.local.yml")).subscriptions ?? {};
266
+ const out: Alias[] = [];
267
+ for (const [name, s] of Object.entries(base)) if (!local[name]) out.push({ ...s, name, scope, file: "settings.yml" });
268
+ for (const [name, s] of Object.entries(local)) out.push({ ...s, name, scope, file: "settings.local.yml" });
269
+ return out.sort((a, b) => a.name.localeCompare(b.name));
270
+ }
271
+
272
+ /** A bare alias name, in the nearest scope, then the global one. */
273
+ export function lookupAlias(name: string, cwd = process.cwd()): Alias | undefined {
274
+ if (!validAliasName(name)) return undefined;
275
+ const near = findScope(cwd);
276
+ const scopes = near.global ? [near] : [near, { dir: globalScopeDir(), global: true }];
277
+ for (const s of scopes) {
278
+ const a = listAliases(s).find((x) => x.name === name);
279
+ if (a) return a;
280
+ }
281
+ return undefined;
282
+ }
283
+
284
+ const LOCAL_HEADER = "# Written by `slack alias set/unset`; wins over settings.yml. Safe to edit by hand.\n";
285
+
286
+ /** The comment block a settings file starts with: kept when the file is rewritten. */
287
+ function leadingComments(path: string): string {
288
+ try {
289
+ const lines = readFileSync(path, "utf8").split("\n");
290
+ const head: string[] = [];
291
+ for (const l of lines) { if (l.startsWith("#") || (!l.trim() && head.length)) head.push(l); else break; }
292
+ return head.length ? head.join("\n").replace(/\n*$/, "\n") : "";
293
+ } catch {
294
+ return "";
295
+ }
296
+ }
297
+
298
+ /** Read-modify-write one settings file under its lock. `shared` = settings.yml
299
+ * (the allowlist owner's file: the relay or the coordinator, never the lane). */
300
+ function updateSettings(scope: Scope, shared: boolean, fn: (s: Record<string, unknown>) => void): void {
301
+ const path = join(scope.dir, shared ? "settings.yml" : "settings.local.yml");
302
+ withFileLock(path, () => {
303
+ const header = shared ? leadingComments(path) : LOCAL_HEADER;
304
+ let raw: Record<string, unknown> = {};
305
+ try {
306
+ const v = Bun.YAML.parse(readFileSync(path, "utf8"));
307
+ if (v && typeof v === "object" && !Array.isArray(v)) raw = v as Record<string, unknown>;
308
+ } catch (e) {
309
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw new Error(`${path}: ${e instanceof Error ? e.message : String(e)}`);
310
+ }
311
+ fn(raw);
312
+ atomicWrite(path, header + Bun.YAML.stringify(raw, null, 2) + "\n");
313
+ });
314
+ }
315
+
316
+ /** Is this scope's allowlist locked to settings.yml? */
317
+ export function isRestricted(scope: Scope): boolean {
318
+ return readSettingsFile(join(scope.dir, "settings.yml")).restrict_to_subscriptions === true;
319
+ }
320
+
321
+ /** May the process at `cwd` write `scope`'s settings.yml? Always when the
322
+ * scope is not restricted; otherwise only from under its `owner` dir (and
323
+ * never when no owner is named). */
324
+ export function sharedWriteRefusal(scope: Scope, cwd = process.cwd()): string | undefined {
325
+ if (!isRestricted(scope)) return undefined;
326
+ const owner = readSettingsFile(join(scope.dir, "settings.yml")).owner;
327
+ if (!owner) return `${scope.dir} is restricted and names no \`owner\` in settings.yml: nobody may write it with --shared (edit the file)`;
328
+ const real = (p: string): string => { try { return realpathSync(resolve(p)); } catch { return resolve(p); } };
329
+ const o = real(owner.replace(/^~(?=\/|$)/, homedir()));
330
+ const c = real(cwd);
331
+ if (c === o || c.startsWith(o.endsWith("/") ? o : o + "/")) return undefined;
332
+ return `${scope.dir} is owned by ${owner}: --shared writes run only from under that directory`;
333
+ }
334
+
335
+ export type SetAliasOpts = {
336
+ /** Write settings.yml instead of settings.local.yml. */
337
+ shared?: boolean;
338
+ /** The owner's own rule (auto_subscribe) writing: skips the owner/cwd check. */
339
+ asOwner?: boolean;
340
+ cwd?: string;
341
+ };
342
+
343
+ /** Add or replace an alias — in settings.local.yml, or with `shared` in
344
+ * settings.yml — and return the one it replaced. A restricted scope refuses
345
+ * the local write (its allowlist is not the lane's to extend), and a shared
346
+ * write from outside its owner dir. */
347
+ export function setAlias(scope: Scope, name: string, sub: Subscription, opts: SetAliasOpts | boolean = {}): Subscription | undefined {
348
+ const o: SetAliasOpts = typeof opts === "boolean" ? { shared: opts } : opts;
349
+ const shared = o.shared === true;
350
+ if (!validAliasName(name)) throw new Error(`invalid alias name "${name}" (a-z 0-9 . _ -, at most 64, not a bare ts)`);
351
+ if (!shared && isRestricted(scope)) throw new RestrictedError(scope);
352
+ if (shared && !o.asOwner) {
353
+ const why = sharedWriteRefusal(scope, o.cwd);
354
+ if (why) throw new RestrictedError(scope, why);
355
+ }
356
+ const before = listAliases(scope).find((a) => a.name === name);
357
+ updateSettings(scope, shared, (raw) => {
358
+ const subs = (raw.subscriptions && typeof raw.subscriptions === "object" ? raw.subscriptions : {}) as Record<string, unknown>;
359
+ subs[name] = { channel: sub.channel, ...(sub.thread_ts ? { thread_ts: sub.thread_ts } : {}), added: sub.added ?? new Date().toISOString(), ...(sub.note ? { note: sub.note } : {}) };
360
+ raw.subscriptions = subs;
361
+ });
362
+ return before;
363
+ }
364
+
365
+ /** Remove an alias from settings.local.yml. One defined in settings.yml is
366
+ * refused: that file belongs to whoever wrote it. */
367
+ export function unsetAlias(scope: Scope, name: string, shared = false, cwd?: string): "removed" | "absent" | "in-settings.yml" {
368
+ const a = listAliases(scope).find((x) => x.name === name);
369
+ if (!a) return "absent";
370
+ if (a.file === "settings.yml" && !shared) return "in-settings.yml";
371
+ if (a.file === "settings.yml") {
372
+ const why = sharedWriteRefusal(scope, cwd);
373
+ if (why) throw new RestrictedError(scope, why);
374
+ }
375
+ updateSettings(scope, a.file === "settings.yml", (raw) => {
376
+ const subs = raw.subscriptions as Record<string, unknown> | undefined;
377
+ if (subs) delete subs[name];
378
+ if (subs && !Object.keys(subs).length) delete raw.subscriptions;
379
+ });
380
+ return "removed";
381
+ }
382
+
383
+ export class RestrictedError extends Error {
384
+ constructor(scope: Scope, why?: string) {
385
+ super(why ?? `${scope.dir} is restricted (restrict_to_subscriptions in settings.yml): only its owner adds subscriptions, with \`slack alias set --shared\` run from the owner's directory`);
386
+ }
387
+ }
388
+
389
+ // --- the registry: every .slack-term/ on this machine, for the relay fan-out ---
390
+
391
+ function registryPath(): string {
392
+ return join(globalScopeDir(), "scopes");
393
+ }
394
+
395
+ /** Remember `dir` (a .slack-term/ path) so a stream can deliver into it. */
396
+ export function registerScope(dir: string): void {
397
+ const p = registryPath();
398
+ withFileLock(p, () => {
399
+ const have = registeredDirs();
400
+ if (have.includes(dir)) return;
401
+ atomicWrite(p, [...have, dir].join("\n") + "\n");
402
+ });
403
+ }
404
+
405
+ function registeredDirs(): string[] {
406
+ try {
407
+ return readFileSync(registryPath(), "utf8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
408
+ } catch {
409
+ return [];
410
+ }
411
+ }
412
+
413
+ /** Registered scopes that still exist, plus the global one. */
414
+ export function registeredScopes(): Scope[] {
415
+ const out: Scope[] = [];
416
+ for (const d of registeredDirs()) {
417
+ try { if (statSync(d).isDirectory()) out.push({ dir: d, global: false }); } catch { /* moved or removed */ }
418
+ }
419
+ const g = globalScopeDir();
420
+ // The global dir always exists once anything registered; it is a scope only
421
+ // when it has settings of its own.
422
+ const hasSettings = existsSync(join(g, "settings.yml")) || existsSync(join(g, "settings.local.yml"));
423
+ if (hasSettings && !out.some((s) => s.dir === g)) out.push({ dir: g, global: true });
424
+ return out;
425
+ }
426
+
427
+ /** Which scopes subscribe a message: a thread subscription matches its thread
428
+ * (the message's thread_ts, or its own ts for a root); a channel-only one
429
+ * matches everything in the channel. */
430
+ export function whoSubscribes(channel: string, threadOrTs: string, scopes = registeredScopes()): Alias[] {
431
+ const out: Alias[] = [];
432
+ for (const s of scopes) {
433
+ for (const a of listAliases(s)) {
434
+ if (a.channel === channel && (a.thread_ts === undefined || a.thread_ts === threadOrTs)) out.push(a);
435
+ }
436
+ }
437
+ return out;
438
+ }
439
+
440
+ // --- reply text ---
441
+
442
+ /** Put `prefix` in front of `text`, never twice: a caller that already wrote
443
+ * `[lane] ` (or `[lane@machine] `) keeps one (owner request 11-02:
444
+ * "[shr] [shr]"). Only the exact lane is stripped — `[lanex]` or another
445
+ * lane's tag is the caller's text. Returns "" when nothing is left. */
446
+ export function applyPrefix(text: string, prefix: string | undefined): string {
447
+ const p = prefix?.trim();
448
+ if (!p) return text;
449
+ const m = /^\[([^\]@\s]+)\]$/.exec(p);
450
+ let body = text;
451
+ if (m) {
452
+ const lane = m[1]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
453
+ body = text.replace(new RegExp(`^\\s*\\[${lane}(?:@[a-z0-9-]{1,16})?\\][ \\t]*`), "");
454
+ } else if (text.trimStart().startsWith(p)) {
455
+ body = text.trimStart().slice(p.length).replace(/^[ \t]+/, "");
456
+ }
457
+ return body.trim() ? `${p} ${body}` : "";
458
+ }
459
+
460
+ /** Where a message sits: its channel and its thread root (its own ts at top level). */
461
+ export type MessagePlace = { channel: string; ts: string; root: string };
462
+
463
+ /** Why an explicit destination is not where the answered message lives (the
464
+ * 11-02 "replied in the wrong thread"), or undefined when it is. */
465
+ export function threadMismatch(dest: { channel: string; threadTs?: string }, answered: MessagePlace): string | undefined {
466
+ if (dest.channel === answered.channel && dest.threadTs === answered.root) return undefined;
467
+ const where = (c: string, t?: string): string => (t ? `${c} thread ${t}` : `${c} (top level)`);
468
+ return `the message you are answering (${answered.ts}) is in ${where(answered.channel, answered.root)}, ` +
469
+ `but the destination is ${where(dest.channel, dest.threadTs)}.\n` +
470
+ ` Drop the destination to reply where the message is: slack reply --to <permalink> '…'\n` +
471
+ ` Or pass --any-thread if the other thread is really meant.`;
472
+ }
package/ts/slack.ts CHANGED
@@ -41,7 +41,41 @@ function base(): string {
41
41
  return (process.env.SLACK_API_BASE ?? "https://slack.com/api").replace(/\/$/, "");
42
42
  }
43
43
 
44
- async function call(token: string, method: string, init: RequestInit, cookie?: string): Promise<Json> {
44
+ /** How long one call may spend waiting out 429s before it throws
45
+ * RateLimitError (seconds, summed over its retries). A 429 means Slack
46
+ * rejected the request unprocessed, so retrying it — even a write — cannot
47
+ * duplicate anything. One-shot commands wait (`pinlog`, `read`, `send`…);
48
+ * `slack stream` sets 0 because it schedules around Retry-After itself.
49
+ * SLACK_RATELIMIT_WAIT_SEC overrides the default. */
50
+ let rateLimitWaitSec = ((): number => {
51
+ const v = Number(process.env.SLACK_RATELIMIT_WAIT_SEC);
52
+ return Number.isFinite(v) && v >= 0 ? v : 120;
53
+ })();
54
+ export function setRateLimitWait(sec: number): void {
55
+ rateLimitWaitSec = sec;
56
+ }
57
+
58
+ /** Run `once`; while it throws RateLimitError and the budget allows, wait out
59
+ * its Retry-After (one stderr line each, method and seconds only) and retry. */
60
+ export async function withRateLimitWait<T>(once: () => Promise<T>, sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))): Promise<T> {
61
+ let waited = 0;
62
+ for (;;) {
63
+ try {
64
+ return await once();
65
+ } catch (e) {
66
+ if (!(e instanceof RateLimitError) || waited + e.retryAfter > rateLimitWaitSec) throw e;
67
+ waited += e.retryAfter;
68
+ console.error(`slack: ${e.message} — waiting`);
69
+ await sleep(e.retryAfter * 1000);
70
+ }
71
+ }
72
+ }
73
+
74
+ function call(token: string, method: string, init: RequestInit, cookie?: string): Promise<Json> {
75
+ return withRateLimitWait(() => callOnce(token, method, init, cookie));
76
+ }
77
+
78
+ async function callOnce(token: string, method: string, init: RequestInit, cookie?: string): Promise<Json> {
45
79
  cookie = token.startsWith("xoxc-") ? (cookie ?? resolveCookie()) : undefined;
46
80
  const extraHeaders: Record<string, string> = {};
47
81
  if (cookie) extraHeaders["Cookie"] = `d=${cookie}`;
@@ -95,7 +129,11 @@ async function call(token: string, method: string, init: RequestInit, cookie?: s
95
129
  // Internal Slack API caller — does NOT reject xoxc tokens.
96
130
  // Used for hidden endpoints (drafts, etc.) only accessible via session tokens.
97
131
  // cookie: raw xoxd value (without "d=" prefix); injected as Cookie header when present.
98
- async function callSession(token: string, method: string, init: RequestInit, cookie?: string): Promise<Json> {
132
+ function callSession(token: string, method: string, init: RequestInit, cookie?: string): Promise<Json> {
133
+ return withRateLimitWait(() => callSessionOnce(token, method, init, cookie));
134
+ }
135
+
136
+ async function callSessionOnce(token: string, method: string, init: RequestInit, cookie?: string): Promise<Json> {
99
137
  const extraHeaders: Record<string, string> = {};
100
138
  if (cookie) extraHeaders["Cookie"] = `d=${cookie}`;
101
139
  const res = await fetch(`${base()}/${method}`, {
package/ts/stream.ts CHANGED
@@ -136,6 +136,8 @@ export type StreamOpts = {
136
136
  selfUsers: Set<string>;
137
137
  selfBots: Set<string>;
138
138
  json: boolean;
139
+ /** Also called with every emitted match and its attachment count (`slack stream --inbox`). */
140
+ onMatch?: (rec: StreamMatch, files: number) => void;
139
141
  once: boolean;
140
142
  /** Seconds back to (re)start every channel from, overriding the saved cursor. */
141
143
  sinceSec?: number;
@@ -371,6 +373,7 @@ async function consider(
371
373
  match,
372
374
  };
373
375
  ctx.opts.json ? _internals.out(JSON.stringify(rec)) : _internals.out(humanLine(rec));
376
+ ctx.opts.onMatch?.(rec, Array.isArray(m.files) ? m.files.length : 0);
374
377
  ctx.matches++;
375
378
  return true;
376
379
  }
package/ts/tick.ts ADDED
@@ -0,0 +1,47 @@
1
+ // `slack tick`: turn a "⬜ <n>." item of a posted checklist into "✅ <n>.",
2
+ // changing nothing else (10-30: when an announced ⬜ criterion is done,
3
+ // edit the original to ✅ rather than only posting a new "done" reply).
4
+ // Slack may store the box as the shortcode, so both spellings are handled and
5
+ // the tick keeps the spelling it found.
6
+
7
+ const BOX = /(⬜️?|:white_large_square:)(\s*)(\d+)\./g;
8
+ const DONE = /(✅|:white_check_mark:)\s*(\d+)\./g;
9
+
10
+ export type TickResult =
11
+ | { kind: "ticked"; text: string; n: number }
12
+ | { kind: "already"; n: number }
13
+ | { kind: "none" }
14
+ | { kind: "ambiguous"; open: number[] }
15
+ | { kind: "missing"; n: number };
16
+
17
+ /** Tick item `n`, or — without `n` — the one open item there is. */
18
+ export function tickText(text: string, n?: number): TickResult {
19
+ const open = [...text.matchAll(BOX)].map((m) => Number(m[3]));
20
+ const done = [...text.matchAll(DONE)].map((m) => Number(m[2]));
21
+ if (n === undefined) {
22
+ if (open.length === 0) return done.length ? { kind: "already", n: done[0]! } : { kind: "none" };
23
+ if (open.length > 1) return { kind: "ambiguous", open };
24
+ n = open[0]!;
25
+ }
26
+ if (!open.includes(n)) return done.includes(n) ? { kind: "already", n } : { kind: "missing", n };
27
+ let hit = false;
28
+ const out = text.replace(BOX, (all, box: string, sp: string, num: string) => {
29
+ if (hit || Number(num) !== n) return all;
30
+ hit = true;
31
+ return `${box.startsWith(":") ? ":white_check_mark:" : "✅"}${sp}${num}.`;
32
+ });
33
+ return { kind: "ticked", text: out, n };
34
+ }
35
+
36
+ /** An edit keeps the lane tag the message was posted with (`[lane] ` or
37
+ * `[lane@host] `), so a rewrite cannot drop or change who said it. Only when
38
+ * `lanePrefix` (the scope's prefix) is the tag the original starts with. */
39
+ export function keepLanePrefix(original: string, next: string, lanePrefix: string | undefined): string {
40
+ const m = /^\[([^\]@\s]+)\]$/.exec(lanePrefix?.trim() ?? "");
41
+ if (!m) return next;
42
+ const lane = m[1]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
43
+ const tag = new RegExp(`^\\[${lane}(?:@[a-z0-9-]{1,16})?\\][ \\t]*`);
44
+ const had = tag.exec(original);
45
+ if (!had) return next;
46
+ return had[0] + next.replace(tag, "");
47
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 Zachary Rice
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.