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/cli.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  import { createHash } from "node:crypto";
5
5
  import { existsSync, readFileSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
- import { join, resolve } from "node:path";
7
+ import { dirname, join, resolve } from "node:path";
8
8
 
9
9
  import yargs, { type Options } from "yargs";
10
10
  import { hideBin } from "yargs/helpers";
@@ -13,7 +13,7 @@ import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveTok
13
13
  import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
14
14
  import { cmdAuthLogin, cmdAuthLoginChrome, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp, cmdAuthSave, cmdAuthTokens } from "./auth.ts";
15
15
  import { cmdTail, parseSince as parseDuration } from "./tail.ts";
16
- import { runStream, webClient } from "./stream.ts";
16
+ import { runStream, webClient, type StreamMatch } from "./stream.ts";
17
17
  import { relaySubscriber, relayTransport } from "./relay.ts";
18
18
  import { DEFAULT_DIR, initRelay, initReport, relayHealth } from "./relayInit.ts";
19
19
  import { agentCommands } from "./agent.ts";
@@ -48,7 +48,14 @@ import {
48
48
  ASK_LANGS,
49
49
  type AskFound,
50
50
  type AskLang,
51
+ ASK_POLL,
52
+ askNextPollDelay,
51
53
  } from "./ask.ts";
54
+ import { applyPrefix, findScope, initScope, isRestricted, listAliases, lookupAlias, readSettings, RestrictedError, setAlias, threadMismatch, unsetAlias, whoSubscribes, type MessagePlace, type Scope } from "./scope.ts";
55
+ import { keepLanePrefix, tickText } from "./tick.ts";
56
+ import { emojiMap, firstDifference, normText, storedAsSent } from "./readback.ts";
57
+ import { choiceHint, choiceQuestionLang } from "./choiceHint.ts";
58
+ import { addOutbox, deliver, listInbox, listOutbox, markDone, overdue, recordFailure, recordSent as recordOutboxSent } from "./inbox.ts";
52
59
  import {
53
60
  POLL_KEYCAPS,
54
61
  POLL_MARKER,
@@ -107,6 +114,7 @@ import {
107
114
  send as slackSend,
108
115
  pinsAdd,
109
116
  RateLimitError,
117
+ setRateLimitWait,
110
118
  repliesCursor,
111
119
  type MessageMetadata,
112
120
  getPermalink,
@@ -439,10 +447,47 @@ async function cmdMsgsTarget(token: string, target: string, limit: number, forma
439
447
  }
440
448
 
441
449
  // --- thread ---
442
- async function cmdThread(token: string, target: string, ts: string, limit: number, format: "text" | "jsonl" = "text", cookie?: string): Promise<void> {
450
+ /** One message as `<ts> TAB <author> TAB <text>`: the author is the user or
451
+ * bot id, the text Slack's stored form with `\` newline CR TAB escaped, and
452
+ * attachments counted as ` [files: N]` (the earlier thread tool's read format). */
453
+ function tsvLine(m: Record<string, Json>): string {
454
+ const esc = (t: string): string => t.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
455
+ const author = typeof m.user === "string" ? m.user : typeof m.bot_id === "string" ? m.bot_id : "";
456
+ const files = Array.isArray(m.files) && m.files.length ? ` [files: ${m.files.length}]` : "";
457
+ return `${String(m.ts)}\t${author}\t${esc(typeof m.text === "string" ? m.text : "")}${files}`;
458
+ }
459
+
460
+ /** Every message of a thread, every page — or an error: a thread that cannot
461
+ * be read whole is never returned as if it were complete. */
462
+ async function wholeThread(token: string, channelId: string, ts: string, cookie?: string): Promise<Record<string, Json>[]> {
463
+ const out: Record<string, Json>[] = [];
464
+ let cursor: string | undefined;
465
+ for (let page = 0; page < 200; page++) {
466
+ const r = asRecord((await repliesPage(token, channelId, ts, cursor ? { cursor } : {}, cookie)) as Json);
467
+ for (const m of asArray(r.messages).map(asRecord)) if (!out.some((o) => o.ts === m.ts)) out.push(m);
468
+ cursor = String(asRecord(r.response_metadata).next_cursor ?? "") || undefined;
469
+ if (!cursor) return out;
470
+ }
471
+ throw new Error("thread has more than 200 pages: refusing to return it partially");
472
+ }
473
+
474
+ async function cmdThread(token: string, target: string, ts: string, limit: number, format: "text" | "jsonl" | "tsv" = "text", cookie?: string, since?: string): Promise<void> {
443
475
  const channelId = await resolveChannel(token, target, cookie);
444
- const resp = (await replies(token, channelId, parseInputTs(ts), limit, cookie)) as Record<string, Json>;
445
- const msgs = asArray(resp.messages).map(asRecord);
476
+ const all = since !== undefined || format === "tsv";
477
+ let msgs = all
478
+ ? await wholeThread(token, channelId, parseInputTs(ts), cookie)
479
+ : asArray(((await replies(token, channelId, parseInputTs(ts), limit, cookie)) as Record<string, Json>).messages).map(asRecord);
480
+ if (since !== undefined) {
481
+ // The same ts forms the thread argument takes (Slack ts or the ISO stamp
482
+ // `read` prints); anything else would compare as NaN and drop every reply.
483
+ const after = Number(parseInputTs(since));
484
+ if (!Number.isFinite(after)) throw new Error(`--since needs a message ts (1700000000.000100) or ISO stamp (got ${stripTerminalControls(since)})`);
485
+ msgs = msgs.filter((m) => Number(m.ts) > after);
486
+ }
487
+ if (format === "tsv") {
488
+ for (const m of msgs) console.log(tsvLine(m));
489
+ return;
490
+ }
446
491
  if (format === "jsonl") {
447
492
  for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
448
493
  return;
@@ -1287,9 +1332,89 @@ function parseTargetThread(s: string, permalinkThreads = true): { ref: string; t
1287
1332
  if (/^\d{10}\.\d{6}$/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: maybeTs };
1288
1333
  if (/^\d{4}-\d{2}-\d{2}T/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: isoToSlackTs(maybeTs) };
1289
1334
  }
1335
+ // A bare name (no #, @ or :) that is an alias in the nearest .slack-term/.
1336
+ const alias = lookupAlias(s);
1337
+ if (alias) return alias.thread_ts ? { ref: alias.channel, threadTs: alias.thread_ts } : { ref: alias.channel };
1290
1338
  return { ref: s };
1291
1339
  }
1292
1340
 
1341
+ /** Where `slack reply` (and `outbox flush`) posts: the destination — alias,
1342
+ * permalink (its root), `#chan:<ts>` or a bare ts in settings' channel —
1343
+ * and/or `--to`'s message, which must agree with it unless `anyThread`. A
1344
+ * restricted scope also requires the thread to be one of its subscriptions.
1345
+ * `{ error }` is a refusal (exit 2): nothing was sent. */
1346
+ async function resolveReplyTarget(
1347
+ token: string, cookie: string | undefined, scope: Scope, dest: string | undefined, to: string | undefined, anyThread = false,
1348
+ ): Promise<{ channel: string; threadTs: string } | { error: string }> {
1349
+ const settings = readSettings(scope);
1350
+ let channel: string | undefined;
1351
+ let threadTs: string | undefined;
1352
+ if (dest) {
1353
+ if (parseSlackPermalink(dest)) {
1354
+ const place = await messagePlace(token, dest, cookie);
1355
+ channel = place.channel;
1356
+ threadTs = place.root;
1357
+ } else {
1358
+ let p = parseTargetThread(dest);
1359
+ if (!p.threadTs && /^\d{10}\.\d{6}$/.test(dest)) {
1360
+ if (!settings.channel) return { error: `a bare ts needs a channel: set \`channel\` in .slack-term/settings.yml, or pass #chan:${dest}` };
1361
+ p = { ref: settings.channel, threadTs: dest };
1362
+ }
1363
+ if (!p.threadTs) return { error: `"${dest}" is not a thread — reply only posts in threads. For a top-level post use \`slack send\`.` };
1364
+ channel = await resolveChannel(token, p.ref, cookie);
1365
+ threadTs = p.threadTs;
1366
+ }
1367
+ }
1368
+ if (to) {
1369
+ const answered = await messagePlace(token, to, cookie);
1370
+ if (channel === undefined) {
1371
+ channel = answered.channel;
1372
+ threadTs = answered.root;
1373
+ } else if (!anyThread) {
1374
+ const why = threadMismatch({ channel, ...(threadTs ? { threadTs } : {}) }, answered);
1375
+ if (why) return { error: why };
1376
+ }
1377
+ }
1378
+ if (!channel || !threadTs) return { error: "reply needs a destination (alias, permalink, #chan:<ts>) or --to <permalink>" };
1379
+ if (isRestricted(scope) && !listAliases(scope).some((a) => a.channel === channel && (a.thread_ts === undefined || a.thread_ts === threadTs))) {
1380
+ return { error: `${channel} thread ${threadTs} is not one of ${scope.dir}'s subscriptions (restrict_to_subscriptions)` };
1381
+ }
1382
+ return { channel, threadTs };
1383
+ }
1384
+
1385
+ /** Where the message a permalink (or `#chan:<ts>`) names really sits: its
1386
+ * channel and thread root. A permalink's `?thread_ts=` settles it; otherwise
1387
+ * the thread is read, since a reply's bare permalink looks like a root's. */
1388
+ async function messagePlace(token: string, target: string, cookie?: string): Promise<MessagePlace> {
1389
+ const url = parseSlackPermalink(target);
1390
+ let ref: string;
1391
+ let ts: string | undefined;
1392
+ let root: string | undefined;
1393
+ if (url) {
1394
+ ref = url.channel;
1395
+ ts = url.ts;
1396
+ root = url.threadTs;
1397
+ } else {
1398
+ const p = parseTargetThread(target);
1399
+ ref = p.ref;
1400
+ ts = p.threadTs;
1401
+ }
1402
+ if (!ts) throw new Error(`--to needs a message permalink (or #chan:<ts>), got "${target}"`);
1403
+ const channel = await resolveChannel(token, ref, cookie);
1404
+ if (!root) {
1405
+ try {
1406
+ const msgs = (asRecord((await replies(token, channel, ts, 1, cookie)) as Json).messages ?? []) as Json[];
1407
+ const own = msgs.map(asRecord).find((m) => m.ts === ts) ?? asRecord(msgs[0]);
1408
+ root = typeof own.thread_ts === "string" ? own.thread_ts : ts;
1409
+ } catch (e) {
1410
+ // A top-level message nobody answered has no thread to read.
1411
+ if (!/thread_not_found/.test(e instanceof Error ? e.message : String(e))) throw e;
1412
+ root = ts;
1413
+ }
1414
+ }
1415
+ return { channel, ts, root };
1416
+ }
1417
+
1293
1418
  /** Human-readable destination label for confirm gates, e.g. "#general (C0123456789)".
1294
1419
  * When ref is already a #channel/@user label, keep it; otherwise resolve the channel
1295
1420
  * name via conversations.info (fail-soft: a raw ID is still unambiguous). */
@@ -1489,6 +1614,12 @@ interface EditArgs {
1489
1614
  * `send --as-bot`. */
1490
1615
  mentionToken?: string;
1491
1616
  mentionCookie?: string;
1617
+ /** `slack tick`: the new text is the original with item `n` (or the one open
1618
+ * item) ticked — computed from what is stored, so nothing else changes. */
1619
+ tick?: { n?: number };
1620
+ /** The scope's `[lane]` prefix: an edit keeps the lane tag the original
1621
+ * was posted with. */
1622
+ lanePrefix?: string;
1492
1623
  }
1493
1624
  /** The bot token, plus the bot↔user DM when the target names an `@handle`.
1494
1625
  *
@@ -1559,11 +1690,24 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
1559
1690
  }
1560
1691
  const originalText = typeof original.text === "string" ? original.text : "";
1561
1692
 
1693
+ let wanted = args.newText;
1694
+ if (args.tick) {
1695
+ const r = tickText(originalText, args.tick.n);
1696
+ if (r.kind === "already") { console.log(`already ticked (${r.n}.)`); process.exit(0); }
1697
+ if (r.kind === "none") { console.error("Error: the message has no ⬜ <n>. item"); process.exit(2); }
1698
+ if (r.kind === "missing") { console.error(`Error: the message has no ⬜ ${r.n}. item`); process.exit(2); }
1699
+ if (r.kind === "ambiguous") { console.error(`Error: several open items (${r.open.join(", ")}): name one, e.g. slack tick <target> ${r.open[0]}`); process.exit(2); }
1700
+ wanted = r.text;
1701
+ } else {
1702
+ wanted = keepLanePrefix(originalText, wanted, args.lanePrefix);
1703
+ }
1704
+
1562
1705
  // Convert @handle → <@USERID> before hashing/editing (unresolved stay as text).
1706
+ // A tick rewrites stored text, whose mentions are encoded already.
1563
1707
  const mentionCookie = args.mentionCookie ?? args.cookie;
1564
- const newText = args.mentions
1565
- ? await encodeMentions(args.mentionToken ?? token, args.newText, channelId, mentionCookie ? { cookie: mentionCookie } : {})
1566
- : args.newText;
1708
+ const newText = args.mentions && !args.tick
1709
+ ? await encodeMentions(args.mentionToken ?? token, wanted, channelId, mentionCookie ? { cookie: mentionCookie } : {})
1710
+ : wanted;
1567
1711
 
1568
1712
  guardUrlBoundaries(newText, args.allowUrlAdjacent);
1569
1713
 
@@ -2011,7 +2155,26 @@ async function cmdPinlogUpdate(token: string, args: PinlogUpdateArgs): Promise<v
2011
2155
  process.exit(1);
2012
2156
  }
2013
2157
  attrEdit.record({ team: self?.team, channel, target: args.target, ts, text: newHead, asBot: args.asBot });
2014
- console.log(`✓ HEAD updated: ${id}`);
2158
+ // Read it back: Slack rewrites text when it stores it, so compare the
2159
+ // normalised forms (ts/readback.ts). A board that does not show what was
2160
+ // sent gets no log line — exit 3, like the earlier thread tool's `state`.
2161
+ let stored: Record<string, Json> | undefined;
2162
+ try {
2163
+ const back = asArray(asRecord((await replies(token, channel, ts, 1, args.cookie)) as Json).messages).map(asRecord);
2164
+ stored = back.find((m) => m.ts === ts);
2165
+ if (!stored) console.error(`⚠ HEAD updated, but the read-back did not return it, so it was not checked`);
2166
+ } catch (e: unknown) {
2167
+ console.error(`⚠ HEAD updated, but it could not be read back to check: ${friendlySlackError(e)}`);
2168
+ }
2169
+ if (stored && !storedAsSent(newHead, stored)) {
2170
+ const names = emojiMap(stored.blocks);
2171
+ console.error(
2172
+ `✗ HEAD was edited, but Slack does not show what was sent (${firstDifference(normText(newHead, names), normText(typeof stored.text === "string" ? stored.text : "", names))}).\n` +
2173
+ ` The log reply was NOT posted. Check it: slack pinlog get ${id}${bot}`,
2174
+ );
2175
+ process.exit(3);
2176
+ }
2177
+ console.log(`✓ HEAD updated${stored ? " (read back: matches)" : ""}: ${id}`);
2015
2178
  }
2016
2179
 
2017
2180
  const attrLog = sentAttribution("send");
@@ -2302,6 +2465,9 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
2302
2465
  const { ref, threadTs } = parseTargetThread(args.target);
2303
2466
  const cookie = args.cookie;
2304
2467
 
2468
+ // Hint only, on the preview: a choice asked in prose makes the reader type.
2469
+ if (!args.code && choiceQuestionLang(args.message)) console.error(choiceHint(args.target));
2470
+
2305
2471
  // Identity of the token that will actually post — the bot token under
2306
2472
  // --as-bot, the user token otherwise.
2307
2473
  const getSelf = selfLookup(token, cookie);
@@ -2643,9 +2809,6 @@ function askExplainDefault(): boolean {
2643
2809
  return /^(1|true|yes|on)$/i.test(process.env.SLACK_TERM_ASK_EXPLAIN ?? "");
2644
2810
  }
2645
2811
 
2646
- const ASK_PHI = 1.618033988749895;
2647
- const ASK_POLL_MIN_MS = 1000;
2648
- const ASK_POLL_MAX_MS = 5000;
2649
2812
  const ASK_MAX_CONSECUTIVE_ERRORS = 8;
2650
2813
 
2651
2814
 
@@ -3336,8 +3499,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3336
3499
  }
3337
3500
 
3338
3501
  const deadline = Date.now() + timeout * 1000;
3502
+ const started = Date.now();
3339
3503
  let errors = 0;
3340
- let delay = ASK_POLL_MIN_MS;
3504
+ let delay: number = ASK_POLL.minMs;
3505
+ /** A 429's Retry-After: the next poll waits at least this long. */
3506
+ let coolMs = 0;
3341
3507
  let unchosen: AskPollState["unchosen"];
3342
3508
  for (;;) {
3343
3509
  let found: AskFound | null = null;
@@ -3345,13 +3511,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3345
3511
  found = await poll();
3346
3512
  errors = 0;
3347
3513
  } catch (e: unknown) {
3348
- // A dropped poll is expected occasionally; only give up if it keeps failing.
3349
- errors++;
3350
- if (errors >= ASK_MAX_CONSECUTIVE_ERRORS) {
3351
- console.error(`Error: Slack への問い合わせに ${errors} 回連続で失敗しました: ${e instanceof Error ? e.message : String(e)}`);
3352
- process.exit(ASK_EXIT_ERROR);
3514
+ if (e instanceof RateLimitError) {
3515
+ // Not a failure: the bucket is shared with every other waiter on this
3516
+ // token. Polling again before Retry-After only keeps it at 429.
3517
+ coolMs = e.retryAfter * 1000;
3518
+ } else {
3519
+ // A dropped poll is expected occasionally; only give up if it keeps failing.
3520
+ errors++;
3521
+ if (errors >= ASK_MAX_CONSECUTIVE_ERRORS) {
3522
+ console.error(`Error: Slack への問い合わせに ${errors} 回連続で失敗しました: ${e instanceof Error ? e.message : String(e)}`);
3523
+ process.exit(ASK_EXIT_ERROR);
3524
+ }
3525
+ console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
3353
3526
  }
3354
- console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
3355
3527
  }
3356
3528
  unchosen = state.unchosen;
3357
3529
  const voided = state.voided;
@@ -3442,8 +3614,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3442
3614
  // Checked after the first poll, never before it: `--timeout 0` means one
3443
3615
  // look, and one look must still be a real look.
3444
3616
  if (Date.now() >= deadline) break;
3445
- await askSleep(Math.min(delay, Math.max(0, deadline - Date.now())));
3446
- delay = Math.min(delay * ASK_PHI, ASK_POLL_MAX_MS);
3617
+ await askSleep(Math.min(Math.max(delay, coolMs), Math.max(0, deadline - Date.now())));
3618
+ coolMs = 0;
3619
+ delay = askNextPollDelay(delay, Date.now() - started);
3447
3620
  }
3448
3621
 
3449
3622
  // A timeout is the moment the caller most needs the collect command: the
@@ -5501,6 +5674,145 @@ async function main(): Promise<void> {
5501
5674
  const tok = (a: W) => resolveToken(a.workspace);
5502
5675
  const ck = (a: W) => resolveCookie(a.workspace);
5503
5676
 
5677
+ /** The `send` handler, shared with `reply` (which resolves its own target,
5678
+ * prefix and thread check first). */
5679
+ type SendCli = {
5680
+ workspace?: string | undefined; target?: string | undefined; message?: string | undefined; code?: string | undefined;
5681
+ "allow-url-adjacent"?: boolean | undefined; "channel-id"?: string | undefined; "user-id"?: string | undefined;
5682
+ "as-bot"?: boolean | undefined; broadcast?: boolean | undefined; mentions?: boolean | undefined;
5683
+ };
5684
+ /** The `edit` handler, shared with `tick`. In a .slack-term/ scope the
5685
+ * lane's tag is kept; in a restricted one the message must sit in one of
5686
+ * its subscriptions. */
5687
+ type EditCli = {
5688
+ workspace?: string | undefined; target?: string | undefined; newText?: string | undefined; code?: string | undefined;
5689
+ force?: boolean | undefined; "allow-url-adjacent"?: boolean | undefined; "channel-id"?: string | undefined;
5690
+ mentions?: boolean | undefined; "as-bot"?: boolean | undefined; tick?: { n?: number } | undefined;
5691
+ };
5692
+ const runEdit = async (argv: EditCli): Promise<void> => {
5693
+ const scope = findScope();
5694
+ const settings = readSettings(scope);
5695
+ if (isRestricted(scope)) {
5696
+ try {
5697
+ const place = await messagePlace(tok(argv as W), argv.target!, ck(argv as W));
5698
+ if (!listAliases(scope).some((a) => a.channel === place.channel && (a.thread_ts === undefined || a.thread_ts === place.root))) {
5699
+ console.error(`Error: that message is not in one of ${scope.dir}'s subscriptions (restrict_to_subscriptions)`);
5700
+ process.exit(2);
5701
+ }
5702
+ } catch (e: unknown) {
5703
+ console.error(friendlySlackError(e));
5704
+ process.exit(1);
5705
+ }
5706
+ }
5707
+ const args: EditArgs = { target: argv.target!, newText: argv.newText ?? "", allowUrlAdjacent: argv["allow-url-adjacent"] === true };
5708
+ if (argv.tick) args.tick = argv.tick;
5709
+ if (settings.prefix) args.lanePrefix = settings.prefix;
5710
+ if (argv.force) args.force = true;
5711
+ if (argv.code) args.code = argv.code;
5712
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
5713
+ if (argv.mentions !== false) args.mentions = true;
5714
+ let editToken: string;
5715
+ if (argv["as-bot"]) {
5716
+ // --as-bot is not a switch on chat.update. It selects a DIFFERENT
5717
+ // resolved token, and until now `edit` never wired one — so every
5718
+ // message the bot posted was uncorrectable, and the workaround people
5719
+ // reached for was RESENDING, which this repo's own etiquette forbids.
5720
+ // Bot identity is what makes a DM notify at all, so "uncorrectable"
5721
+ // applied to exactly the messages that matter most.
5722
+ const resolved = await asBotTarget(args.target, tok(argv as W), ck(argv as W), args.channelId);
5723
+ editToken = resolved.botToken;
5724
+ if (resolved.channelId && !args.channelId) args.channelId = resolved.channelId;
5725
+ args.asBot = true;
5726
+ // Mentions still resolve on the USER token: the bot has no users:read,
5727
+ // and a silently unresolved @handle in a CORRECTION is the same defect
5728
+ // twice.
5729
+ args.mentionToken = tok(argv as W);
5730
+ const mc = ck(argv as W);
5731
+ if (mc) args.mentionCookie = mc;
5732
+ } else {
5733
+ editToken = tok(argv as W);
5734
+ // Not set under --as-bot: a bot token needs no session cookie, and
5735
+ // attaching one is how a working call starts failing.
5736
+ const cookie = ck(argv as W);
5737
+ if (cookie) args.cookie = cookie;
5738
+ }
5739
+ await cmdEdit(editToken, args);
5740
+ };
5741
+
5742
+ const runSend = async (argv: SendCli): Promise<void> => {
5743
+ // An empty body is never a meaningful send. `ask` already refuses one
5744
+ // ("Error: question is empty"); `send` did not, so an empty draft
5745
+ // staged a confirm code and posting it produced a blank message.
5746
+ // Verification cannot catch this: an empty publish reads back as an
5747
+ // exact match, so refusing here is the only mechanism.
5748
+ if (!String(argv.message ?? "").trim()) {
5749
+ console.error("Error: message is empty");
5750
+ process.exit(1);
5751
+ }
5752
+ const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] === true };
5753
+ if (argv.code) args.code = argv.code;
5754
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
5755
+ if (argv["user-id"]) args.userId = argv["user-id"];
5756
+ if (argv.broadcast) args.broadcast = true;
5757
+ // Mentions on by default; `--no-mentions` (yargs negation) sets it false.
5758
+ // A message with no `@token` short-circuits inside encodeMentions, so the
5759
+ // common case pays no extra API call.
5760
+ if (argv.mentions !== false) {
5761
+ args.mentions = true;
5762
+ // Resolve mentions with the user token (has users:read) even when the
5763
+ // message itself is sent via the bot token. Pass its cookie too so an
5764
+ // xoxc- desktop token can reach users.list (rejected without the cookie).
5765
+ args.mentionToken = tok(argv as W);
5766
+ const mc = ck(argv as W);
5767
+ if (mc) args.mentionCookie = mc;
5768
+ }
5769
+ let sendToken: string;
5770
+ if (argv["as-bot"]) {
5771
+ const botToken = resolveBotToken();
5772
+ if (!botToken) {
5773
+ console.error(
5774
+ "Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
5775
+ " Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
5776
+ );
5777
+ process.exit(1);
5778
+ }
5779
+ // Resolve @user → user_id with the *user* token (has users:read), then
5780
+ // let cmdSend open the DM with the bot token (im:write). This avoids
5781
+ // requiring users:read on the bot and guarantees the DM is bot↔user
5782
+ // (not the user's own self-DM).
5783
+ if (!args.channelId && !args.userId && args.target.startsWith("@")) {
5784
+ try {
5785
+ args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
5786
+ } catch (e: unknown) {
5787
+ console.error(
5788
+ `Error: could not resolve ${args.target} to a user for the bot DM.\n` +
5789
+ ` ${e instanceof Error ? e.message : String(e)}\n` +
5790
+ ` Tip: pass --user-id <Uxxxx> to skip the lookup.`,
5791
+ );
5792
+ process.exit(1);
5793
+ }
5794
+ }
5795
+ sendToken = botToken;
5796
+ args.asBot = true;
5797
+ } else {
5798
+ sendToken = tok(argv as W);
5799
+ // Session cookie for `sendToken` itself — needed for an xoxc- desktop
5800
+ // token to be accepted by the public API at all. Not applicable to the
5801
+ // bot token above (bot tokens need no cookie).
5802
+ const sc = ck(argv as W);
5803
+ if (sc) args.cookie = sc;
5804
+ }
5805
+ // Print a clean error instead of yargs' usage dump when a send fails at
5806
+ // runtime (e.g. missing scope, channel not found). The confirm gate
5807
+ // exits via process.exit, so it is unaffected.
5808
+ try {
5809
+ await cmdSend(sendToken, args);
5810
+ } catch (e: unknown) {
5811
+ console.error(friendlySlackError(e));
5812
+ process.exit(1);
5813
+ }
5814
+ };
5815
+
5504
5816
  // `todo ls` and `mytodo ls` are the same listing over two different search
5505
5817
  // scopes — `has:` (anyone's reactions) vs `hasmy:` (only this token's). They
5506
5818
  // share one definition so the flags can never drift apart; `mine` is the only
@@ -5594,7 +5906,7 @@ async function main(): Promise<void> {
5594
5906
  ["read [target]", "msgs [target]"],
5595
5907
  "Browse messages",
5596
5908
  (y) => y
5597
- .positional("target", { type: "string", describe: "#channel, @user, or URL" })
5909
+ .positional("target", { type: "string", describe: "#channel, @user, URL, or an alias (slack alias ls)" })
5598
5910
  .option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" })
5599
5911
  .option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
5600
5912
  .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
@@ -5602,7 +5914,11 @@ async function main(): Promise<void> {
5602
5914
  async (argv) => {
5603
5915
  const token = tok(argv as W);
5604
5916
  const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
5605
- if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format, argv.unreplied, ck(argv as W));
5917
+ // `slack read <alias>`: the thread (or channel) it names.
5918
+ const alias = argv.target ? lookupAlias(argv.target) : undefined;
5919
+ if (alias?.thread_ts) await cmdThread(token, alias.channel, alias.thread_ts, Math.max(argv.limit, 100), format, ck(argv as W));
5920
+ else if (alias) await cmdMsgsTarget(token, alias.channel, argv.limit, format, argv.unreplied, ck(argv as W));
5921
+ else if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format, argv.unreplied, ck(argv as W));
5606
5922
  else await cmdMsgs(token);
5607
5923
  },
5608
5924
  )
@@ -5613,11 +5929,12 @@ async function main(): Promise<void> {
5613
5929
  .positional("target", { type: "string", demandOption: true })
5614
5930
  .positional("ts", { type: "string", demandOption: true })
5615
5931
  .option("limit", { alias: "n", type: "number", default: 100 })
5616
- .option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
5617
- .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
5932
+ .option("format", { type: "string", choices: ["text", "jsonl", "tsv"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs); tsv: <ts> TAB <user|bot id> TAB <text, \\ \\n \\r \\t escaped> [files: N], every page" })
5933
+ .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
5934
+ .option("since", { type: "string", describe: "Only messages after this ts (reads every page)" }),
5618
5935
  async (argv) => {
5619
- const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
5620
- await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format, ck(argv as W));
5936
+ const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl" | "tsv");
5937
+ await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format, ck(argv as W), argv.since);
5621
5938
  },
5622
5939
  )
5623
5940
  .command(
@@ -6011,81 +6328,385 @@ async function main(): Promise<void> {
6011
6328
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
6012
6329
  .option("as-bot", { type: "boolean", default: false, describe: "Send via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient and can be two-way" })
6013
6330
  .option("broadcast", { type: "boolean", default: false, describe: "Also send to channel: broadcast a threaded reply back to the channel (Slack's \"Also send to #channel\" checkbox). Only effective with a thread target." })
6014
- .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real <@USERID> mentions (on by default; resolves via users.list, then channel members for Slack Connect guests). Unresolved handles stay as plain text. The confirm preview shows the converted message before sending. Disable with --no-mentions for literal @text." }),
6331
+ .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real <@USERID> mentions (on by default; resolves via users.list, then channel members for Slack Connect guests). Unresolved handles stay as plain text. The confirm preview shows the converted message before sending. Disable with --no-mentions for literal @text." })
6332
+ .option("to", { type: "string", describe: "Permalink of the message you are answering: refuses when it is not in the target's thread (see also `slack reply --to`)" })
6333
+ .option("any-thread", { type: "boolean", default: false, describe: "With --to: send even when the message is in another thread" }),
6015
6334
  async (argv) => {
6016
- // An empty body is never a meaningful send. `ask` already refuses one
6017
- // ("Error: question is empty"); `send` did not, so an empty draft
6018
- // staged a confirm code and posting it produced a blank message.
6019
- // Verification cannot catch this: an empty publish reads back as an
6020
- // exact match, so refusing here is the only mechanism.
6021
- if (!String(argv.message ?? "").trim()) {
6022
- console.error("Error: message is empty");
6023
- process.exit(1);
6024
- }
6025
- const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] };
6026
- if (argv.code) args.code = argv.code;
6027
- if (argv["channel-id"]) args.channelId = argv["channel-id"];
6028
- if (argv["user-id"]) args.userId = argv["user-id"];
6029
- if (argv.broadcast) args.broadcast = true;
6030
- // Mentions on by default; `--no-mentions` (yargs negation) sets it false.
6031
- // A message with no `@token` short-circuits inside encodeMentions, so the
6032
- // common case pays no extra API call.
6033
- if (argv.mentions !== false) {
6034
- args.mentions = true;
6035
- // Resolve mentions with the user token (has users:read) even when the
6036
- // message itself is sent via the bot token. Pass its cookie too so an
6037
- // xoxc- desktop token can reach users.list (rejected without the cookie).
6038
- args.mentionToken = tok(argv as W);
6039
- const mc = ck(argv as W);
6040
- if (mc) args.mentionCookie = mc;
6041
- }
6042
- let sendToken: string;
6043
- if (argv["as-bot"]) {
6044
- const botToken = resolveBotToken();
6045
- if (!botToken) {
6046
- console.error(
6047
- "Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
6048
- " Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
6049
- );
6335
+ if (argv.to && !argv["any-thread"]) {
6336
+ try {
6337
+ const t = tok(argv as W);
6338
+ const c = ck(argv as W);
6339
+ const answered = await messagePlace(t, argv.to, c);
6340
+ const p = parseSlackPermalink(argv.target) ? await messagePlace(t, argv.target, c) : undefined;
6341
+ const dest = p ? { channel: p.channel, threadTs: p.root } : (() => { const q = parseTargetThread(argv.target); return { ref: q.ref, ...(q.threadTs ? { threadTs: q.threadTs } : {}) }; })();
6342
+ const channel = "channel" in dest ? dest.channel : await resolveChannel(t, dest.ref, c);
6343
+ const why = threadMismatch({ channel, ...(dest.threadTs ? { threadTs: dest.threadTs } : {}) }, answered);
6344
+ if (why) { console.error(`Error: ${why}`); process.exit(2); }
6345
+ } catch (e: unknown) {
6346
+ console.error(friendlySlackError(e));
6050
6347
  process.exit(1);
6051
6348
  }
6052
- // Resolve @user → user_id with the *user* token (has users:read), then
6053
- // let cmdSend open the DM with the bot token (im:write). This avoids
6054
- // requiring users:read on the bot and guarantees the DM is bot↔user
6055
- // (not the user's own self-DM).
6056
- if (!args.channelId && !args.userId && args.target.startsWith("@")) {
6349
+ }
6350
+ await runSend(argv);
6351
+ },
6352
+ )
6353
+ .command(
6354
+ "init [dir]",
6355
+ "Create .slack-term/ in a directory: aliases, settings, inbox/outbox (never committed)",
6356
+ (y) => y
6357
+ .positional("dir", { type: "string", default: ".", describe: "Directory to put .slack-term/ in" })
6358
+ .option("prefix", { type: "string", describe: "Put in front of every `slack reply`, e.g. \"[design]\"" })
6359
+ .option("channel", { type: "string", describe: "Default channel (id)" })
6360
+ .option("as-bot", { type: "boolean", describe: "Reply as the bot by default" }),
6361
+ (argv) => {
6362
+ const { dir, created } = initScope(argv.dir, {
6363
+ ...(argv.prefix ? { prefix: argv.prefix } : {}),
6364
+ ...(argv.channel ? { channel: argv.channel } : {}),
6365
+ ...(argv["as-bot"] !== undefined ? { as_bot: argv["as-bot"] } : {}),
6366
+ });
6367
+ console.log(created.length ? `${dir}: created ${created.join(", ")}` : `${dir}: already set up`);
6368
+ },
6369
+ )
6370
+ .command(
6371
+ "alias",
6372
+ "Name channels and threads for this directory (the nearest .slack-term/)",
6373
+ (y) => y
6374
+ .command(
6375
+ ["ls", "list"],
6376
+ "List aliases: name, channel, thread, file",
6377
+ (yy) => yy.option("json", { type: "boolean", default: false }),
6378
+ (argv) => {
6379
+ const scope = findScope();
6380
+ const all = listAliases(scope);
6381
+ if (argv.json) {
6382
+ console.log(JSON.stringify(all.map(({ scope: _, ...a }) => a)));
6383
+ return;
6384
+ }
6385
+ for (const a of all) console.log(`${a.name}\t${a.channel}\t${a.thread_ts ?? "-"}\t${a.file}${a.note ? `\t${a.note}` : ""}`);
6386
+ if (!all.length) console.error(`no aliases in ${scope.dir}${scope.global ? " (no .slack-term/ above here: `slack init` makes one)" : ""}`);
6387
+ },
6388
+ )
6389
+ .command(
6390
+ "set <name> <target>",
6391
+ "Name a channel or thread: a permalink (a reply's names its thread root), #chan:<ts>, or #chan",
6392
+ (yy) => yy
6393
+ .positional("name", { type: "string", demandOption: true })
6394
+ .positional("target", { type: "string", demandOption: true })
6395
+ .option("note", { type: "string", describe: "Free text shown by `alias ls`" })
6396
+ .option("shared", { type: "boolean", default: false, describe: "Write settings.yml (the allowlist owner's file) instead of settings.local.yml" })
6397
+ .option("dir", { type: "string", describe: "Use the .slack-term/ above this directory instead of the cwd's (with --shared: a lane's dir)" }),
6398
+ async (argv) => {
6399
+ const token = tok(argv as W);
6400
+ const cookie = ck(argv as W);
6401
+ const scope = findScope(argv.dir);
6402
+ const threaded = parseSlackPermalink(argv.target)?.ts !== undefined || parseTargetThread(argv.target).threadTs !== undefined;
6403
+ let channel: string;
6404
+ let thread: string | undefined;
6405
+ let note = "";
6406
+ if (threaded) {
6407
+ const place = await messagePlace(token, argv.target, cookie);
6408
+ channel = place.channel;
6409
+ thread = place.root;
6410
+ if (place.root !== place.ts) note = ` (${place.ts} is a reply: named its thread root)`;
6411
+ } else {
6412
+ channel = await resolveChannel(token, parseTargetThread(argv.target).ref, cookie);
6413
+ }
6414
+ let before;
6057
6415
  try {
6058
- args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
6416
+ before = setAlias(scope, argv.name, { channel, ...(thread ? { thread_ts: thread } : {}), ...(argv.note ? { note: argv.note } : {}) }, argv.shared);
6059
6417
  } catch (e: unknown) {
6060
- console.error(
6061
- `Error: could not resolve ${args.target} to a user for the bot DM.\n` +
6062
- ` ${e instanceof Error ? e.message : String(e)}\n` +
6063
- ` Tip: pass --user-id <Uxxxx> to skip the lookup.`,
6064
- );
6065
- process.exit(1);
6418
+ if (e instanceof RestrictedError) { console.error(`Error: ${e.message}`); process.exit(2); }
6419
+ throw e;
6066
6420
  }
6067
- }
6068
- sendToken = botToken;
6069
- args.asBot = true;
6070
- } else {
6071
- sendToken = tok(argv as W);
6072
- // Session cookie for `sendToken` itself — needed for an xoxc- desktop
6073
- // token to be accepted by the public API at all. Not applicable to the
6074
- // bot token above (bot tokens need no cookie).
6075
- const sc = ck(argv as W);
6076
- if (sc) args.cookie = sc;
6077
- }
6078
- // Print a clean error instead of yargs' usage dump when a send fails at
6079
- // runtime (e.g. missing scope, channel not found). The confirm gate
6080
- // exits via process.exit, so it is unaffected.
6421
+ const was = before && (before.channel !== channel || before.thread_ts !== thread) ? ` (was ${before.channel} ${before.thread_ts ?? "-"})` : "";
6422
+ console.log(`${argv.name} → ${channel} ${thread ?? "-"}${note}${was} [${scope.dir}]`);
6423
+ },
6424
+ )
6425
+ .command(
6426
+ ["unset <name>", "rm <name>"],
6427
+ "Remove an alias (from settings.local.yml; --shared: also from settings.yml)",
6428
+ (yy) => yy
6429
+ .positional("name", { type: "string", demandOption: true })
6430
+ .option("shared", { type: "boolean", default: false, describe: "Allow removing one defined in settings.yml" })
6431
+ .option("dir", { type: "string", describe: "Use the .slack-term/ above this directory" }),
6432
+ (argv) => {
6433
+ const scope = findScope(argv.dir);
6434
+ let r;
6435
+ try {
6436
+ r = unsetAlias(scope, argv.name, argv.shared);
6437
+ } catch (e: unknown) {
6438
+ if (e instanceof RestrictedError) { console.error(`Error: ${e.message}`); process.exit(2); }
6439
+ throw e;
6440
+ }
6441
+ if (r === "removed") console.log(`${argv.name} removed [${scope.dir}]`);
6442
+ else if (r === "absent") { console.error(`no alias "${argv.name}" in ${scope.dir}`); process.exit(2); }
6443
+ else { console.error(`"${argv.name}" is defined in ${scope.dir}/settings.yml: edit that file to remove it`); process.exit(2); }
6444
+ },
6445
+ )
6446
+ .command(
6447
+ "who <channel> <ts>",
6448
+ "Which registered .slack-term/ dirs subscribe this thread (or message): dir, alias",
6449
+ (yy) => yy
6450
+ .positional("channel", { type: "string", demandOption: true, describe: "Channel id" })
6451
+ .positional("ts", { type: "string", demandOption: true, describe: "Thread root ts (a reply's own ts is not its thread)" })
6452
+ .option("json", { type: "boolean", default: false }),
6453
+ (argv) => {
6454
+ const hits = whoSubscribes(argv.channel, argv.ts).map((a) => ({ dir: dirname(a.scope.dir), scope: a.scope.dir, alias: a.name, file: a.file }));
6455
+ if (argv.json) console.log(JSON.stringify(hits));
6456
+ else for (const h of hits) console.log(`${h.dir}\t${h.alias}`);
6457
+ if (!hits.length) process.exitCode = 2;
6458
+ },
6459
+ )
6460
+ .command(
6461
+ "import <tsv>",
6462
+ "One-shot: import a legacy allowlist.tsv (<thread_ts> TAB <lane>) into each lane's settings.yml",
6463
+ (yy) => yy
6464
+ .positional("tsv", { type: "string", demandOption: true })
6465
+ .option("channel", { type: "string", demandOption: true, describe: "The channel every thread is in (for a DM: the DM id)" })
6466
+ .option("lane-dir", { type: "array", string: true, demandOption: true, describe: "<lane>=<dir> (a directory holding .slack-term/, made if missing); repeatable" }),
6467
+ (argv) => {
6468
+ const dirs = new Map<string, string>();
6469
+ for (const kv of argv["lane-dir"] ?? []) {
6470
+ const eq = String(kv).indexOf("=");
6471
+ if (eq <= 0) { console.error(`Error: --lane-dir ${kv}: want <lane>=<dir>`); process.exit(2); }
6472
+ dirs.set(String(kv).slice(0, eq), String(kv).slice(eq + 1));
6473
+ }
6474
+ let added = 0;
6475
+ const skipped = new Map<string, number>();
6476
+ for (const line of readFileSync(argv.tsv, "utf8").split("\n")) {
6477
+ const [ts, lane] = line.trim().split("\t");
6478
+ if (!ts || line.startsWith("#") || !lane) continue;
6479
+ const dir = dirs.get(lane);
6480
+ if (!dir || !/^\d{10}\.\d{6}$/.test(ts)) { skipped.set(lane, (skipped.get(lane) ?? 0) + 1); continue; }
6481
+ const scope = { dir: initScope(dir).dir, global: false };
6482
+ setAlias(scope, `t-${ts}`, { channel: argv.channel, thread_ts: ts, note: "imported from legacy allowlist" }, true);
6483
+ added++;
6484
+ }
6485
+ console.log(`imported ${added} subscription(s)`);
6486
+ for (const [lane, n] of skipped) console.error(`skipped ${n} line(s) for lane "${lane}" (no --lane-dir, or not a ts)`);
6487
+ },
6488
+ )
6489
+ .demandCommand(1, "alias needs a subcommand: ls, set, unset, who, import"),
6490
+ )
6491
+ .command(
6492
+ "reply [dest] [message]",
6493
+ "Reply in a thread — an alias, a permalink or #chan:<ts> — with the directory's prefix (confirm-hash gate, like send)",
6494
+ (y) => y
6495
+ .positional("dest", { type: "string", describe: "alias | permalink | #chan:<ts> | <ts> (in settings' channel). Omit with --to" })
6496
+ .positional("message", { type: "string", describe: "Text, or - for stdin" })
6497
+ .option("to", { type: "string", describe: "Permalink of the message you are answering: replies in ITS thread; with a dest, refuses when they differ" })
6498
+ .option("any-thread", { type: "boolean", default: false, describe: "With --to and a dest: post to dest even when the message is in another thread" })
6499
+ .option("prefix", { type: "string", describe: "Override settings' prefix (\"\" for none)" })
6500
+ .option("code", { type: "string", describe: "Safety hash to confirm send" })
6501
+ .option("as-bot", { type: "boolean", describe: "Send via the bot token (default: settings' as_bot)" })
6502
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
6503
+ .option("broadcast", { type: "boolean", default: false, describe: "Also send to channel" })
6504
+ .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real mentions (--no-mentions for literal text)" }),
6505
+ async (argv) => {
6506
+ let dest = argv.dest;
6507
+ let message = argv.message;
6508
+ // `slack reply --to <link> 'text'`: the one positional is the text.
6509
+ if (argv.to && message === undefined) { message = dest; dest = undefined; }
6510
+ if (message === "-") message = await Bun.stdin.text();
6511
+ if (message === undefined || !message.trim()) { console.error("Error: message is empty"); process.exit(1); }
6512
+ if (!dest && !argv.to) { console.error("Error: reply needs a destination (alias, permalink, #chan:<ts>) or --to <permalink>"); process.exit(2); }
6513
+ const token = tok(argv as W);
6514
+ const cookie = ck(argv as W);
6515
+ const scope = findScope();
6516
+ const settings = readSettings(scope);
6517
+ let where: { channel: string; threadTs: string };
6081
6518
  try {
6082
- await cmdSend(sendToken, args);
6519
+ const r = await resolveReplyTarget(token, cookie, scope, dest, argv.to, argv["any-thread"]);
6520
+ if ("error" in r) { console.error(`Error: ${r.error}`); process.exit(2); }
6521
+ where = r;
6083
6522
  } catch (e: unknown) {
6084
6523
  console.error(friendlySlackError(e));
6085
6524
  process.exit(1);
6086
6525
  }
6526
+ const { channel, threadTs } = where;
6527
+ // A restricted scope's prefix is its owner's: overriding it would let a
6528
+ // lane post as another.
6529
+ if (isRestricted(scope) && (argv.prefix !== undefined || (argv["as-bot"] !== undefined && argv["as-bot"] !== (settings.as_bot ?? false)))) {
6530
+ console.error(`Error: ${scope.dir} is restricted: who it posts as (prefix, as_bot) comes from settings.yml; --prefix / --as-bot are refused`);
6531
+ process.exit(2);
6532
+ }
6533
+ const text = applyPrefix(message, argv.prefix ?? settings.prefix);
6534
+ if (!text.trim()) { console.error("Error: message is empty (only the prefix)"); process.exit(1); }
6535
+ await runSend({
6536
+ workspace: (argv as W).workspace, target: `${channel}:${threadTs}`, message: text, code: argv.code,
6537
+ "allow-url-adjacent": argv["allow-url-adjacent"], "as-bot": argv["as-bot"] ?? settings.as_bot ?? false,
6538
+ broadcast: argv.broadcast, mentions: argv.mentions,
6539
+ });
6087
6540
  },
6088
6541
  )
6542
+ .command(
6543
+ "inbox",
6544
+ "This directory's .slack-term/inbox/: messages routed here by `slack stream --inbox`",
6545
+ (y) => y
6546
+ .command(
6547
+ ["ls", "list"],
6548
+ "Pending messages: ts, from, alias, text (redacted)",
6549
+ (yy) => yy.option("json", { type: "boolean", default: false }),
6550
+ (argv) => {
6551
+ const items = listInbox(findScope());
6552
+ if (argv.json) { console.log(JSON.stringify(items.map((i) => ({ file: i.file, ...i.entry })))); return; }
6553
+ for (const i of items) console.log(`${i.entry.ts}\t${i.entry.user_name ?? i.entry.user}\t${i.entry.alias}\t${i.entry.text.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\t/g, "\\t")}${i.entry.files ? `\t[files: ${i.entry.files}]` : ""}`);
6554
+ },
6555
+ )
6556
+ .command(
6557
+ "listen",
6558
+ "Print each message as it arrives in inbox/ (one JSON line each); pending ones first. Exits 0 at --timeout",
6559
+ (yy) => yy
6560
+ .option("timeout", { type: "number", default: 3600, describe: "Seconds" })
6561
+ .option("new-only", { type: "boolean", default: false, describe: "Skip what is already pending" })
6562
+ .option("thread", { type: "string", describe: "Only messages in this thread (its root ts)" })
6563
+ .option("alias", { type: "string", describe: "Only messages routed by this alias" })
6564
+ .option("from", { type: "array", string: true, describe: "Only messages from these user ids; repeatable" }),
6565
+ async (argv) => {
6566
+ const scope = findScope();
6567
+ const from = argv.from?.length ? new Set(argv.from.map(String)) : undefined;
6568
+ const wanted = (e: { ts: string; thread_ts?: string; alias: string; user: string }): boolean =>
6569
+ (!argv.thread || (e.thread_ts ?? e.ts) === argv.thread) && (!argv.alias || e.alias === argv.alias) && (!from || from.has(e.user));
6570
+ const seen = new Set<string>();
6571
+ if (argv["new-only"]) for (const i of listInbox(scope)) seen.add(i.file);
6572
+ const until = Date.now() + argv.timeout * 1000;
6573
+ for (;;) {
6574
+ for (const i of listInbox(scope)) {
6575
+ if (seen.has(i.file)) continue;
6576
+ seen.add(i.file);
6577
+ if (wanted(i.entry)) console.log(JSON.stringify({ file: i.file, ...i.entry }));
6578
+ }
6579
+ if (Date.now() >= until) break;
6580
+ // Files land by rename, so a short poll is enough and survives
6581
+ // every filesystem (fs.watch does not, on network mounts).
6582
+ await new Promise((r) => setTimeout(r, Math.min(500, Math.max(0, until - Date.now()))));
6583
+ }
6584
+ },
6585
+ )
6586
+ .command(
6587
+ "nudge",
6588
+ "Pending messages older than settings' nudge_after_min (default 30): exit 1 when there are any",
6589
+ (yy) => yy.option("json", { type: "boolean", default: false }),
6590
+ (argv) => {
6591
+ const items = overdue(findScope());
6592
+ if (argv.json) console.log(JSON.stringify(items.map((i) => ({ file: i.file, ts: i.entry.ts, channel: i.entry.channel, permalink: i.entry.permalink, waiting_min: Math.round((Date.now() - i.mtimeMs) / 60_000) }))));
6593
+ else for (const i of items) console.log(`${i.entry.ts}\t${Math.round((Date.now() - i.mtimeMs) / 60_000)} min\t${i.entry.permalink}`);
6594
+ if (items.length) process.exitCode = 1;
6595
+ },
6596
+ )
6597
+ .demandCommand(1, "inbox needs a subcommand: ls, listen, nudge"),
6598
+ )
6599
+ .command(
6600
+ "done <ref..>",
6601
+ "Mark inbox messages handled (moved to archived/) without replying: a file name, path or message ts",
6602
+ (y) => y.positional("ref", { type: "string", array: true, demandOption: true }),
6603
+ (argv) => {
6604
+ const scope = findScope();
6605
+ let missing = 0;
6606
+ for (const ref of argv.ref as string[]) {
6607
+ const to = markDone(scope, ref);
6608
+ if (to) console.log(`archived ${to}`);
6609
+ else { console.error(`not in ${scope.dir}/inbox: ${ref}`); missing++; }
6610
+ }
6611
+ if (missing) process.exit(2);
6612
+ },
6613
+ )
6614
+ .command(
6615
+ "outbox",
6616
+ "This directory's .slack-term/outbox/: replies queued as files, sent by `outbox flush`",
6617
+ (y) => y
6618
+ .command(
6619
+ "add <dest> <message>",
6620
+ "Queue a reply (text redacted before it is written; - reads stdin)",
6621
+ (yy) => yy
6622
+ .positional("dest", { type: "string", demandOption: true, describe: "alias | permalink | #chan:<ts>" })
6623
+ .positional("message", { type: "string", demandOption: true })
6624
+ .option("in-reply-to", { type: "string", describe: "ts of the inbox message this answers: archived once sent" }),
6625
+ async (argv) => {
6626
+ const text = argv.message === "-" ? await Bun.stdin.text() : argv.message;
6627
+ if (!text.trim()) { console.error("Error: message is empty"); process.exit(1); }
6628
+ const r = addOutbox(findScope(), { to: argv.dest, text, ...(argv["in-reply-to"] ? { in_reply_to: argv["in-reply-to"] } : {}) });
6629
+ if (r.redacted.length) console.error(`⚠ redacted before queueing: ${r.redacted.join(", ")}`);
6630
+ console.log(r.file);
6631
+ },
6632
+ )
6633
+ .command(
6634
+ ["ls", "list"],
6635
+ "Queued replies: file, dest, attempts, error",
6636
+ (yy) => yy,
6637
+ () => {
6638
+ for (const i of listOutbox(findScope())) console.log(`${i.file}\t${i.entry.to}${i.entry.attempts ? `\tattempts=${i.entry.attempts}\t${i.entry.error ?? ""}` : ""}`);
6639
+ },
6640
+ )
6641
+ .command(
6642
+ "flush",
6643
+ "Send every queued reply (prefix, thread and restrict checks as `reply`); a --code covers the whole batch",
6644
+ (yy) => yy
6645
+ .option("code", { type: "string", describe: "Safety hash to confirm the batch" })
6646
+ .option("as-bot", { type: "boolean", describe: "Send via the bot token (default: settings' as_bot)" }),
6647
+ async (argv) => {
6648
+ const scope = findScope();
6649
+ const settings = readSettings(scope);
6650
+ const items = listOutbox(scope);
6651
+ if (!items.length) { console.error(`nothing in ${scope.dir}/outbox`); return; }
6652
+ if (isRestricted(scope) && argv["as-bot"] !== undefined && argv["as-bot"] !== (settings.as_bot ?? false)) {
6653
+ console.error(`Error: ${scope.dir} is restricted: as_bot comes from settings.yml; --as-bot is refused`);
6654
+ process.exit(2);
6655
+ }
6656
+ const asBot = argv["as-bot"] ?? settings.as_bot ?? false;
6657
+ const lookTok = tok(argv as W);
6658
+ const lookCk = ck(argv as W);
6659
+ let sendTok: string;
6660
+ let sendCk: string | undefined;
6661
+ if (asBot) {
6662
+ const b = resolveBotToken();
6663
+ if (!b) { console.error("Error: as_bot needs a bot token (SLACK_BOT_TOKEN)"); process.exit(1); }
6664
+ sendTok = b;
6665
+ } else {
6666
+ sendTok = lookTok;
6667
+ sendCk = lookCk;
6668
+ }
6669
+ const plan: { item: (typeof items)[number]; channel: string; threadTs: string; text: string }[] = [];
6670
+ const refused: string[] = [];
6671
+ for (const item of items) {
6672
+ try {
6673
+ const r = await resolveReplyTarget(lookTok, lookCk, scope, item.entry.to, undefined);
6674
+ if ("error" in r) { refused.push(`${item.file}: ${r.error}`); continue; }
6675
+ const text = applyPrefix(item.entry.text, settings.prefix);
6676
+ if (!text.trim()) { refused.push(`${item.file}: empty`); continue; }
6677
+ // send/reply's URL-boundary refusal: a glued URL stays queued, the
6678
+ // reason in `refused`, for the author to fix the file.
6679
+ guardUrlBoundaries(text);
6680
+ plan.push({ item, ...r, text });
6681
+ } catch (e: unknown) {
6682
+ refused.push(`${item.file}: ${friendlySlackError(e)}`);
6683
+ }
6684
+ }
6685
+ for (const r of refused) console.error(`refused (stays queued): ${r}`);
6686
+ if (!plan.length) process.exit(2);
6687
+ const lines = [`OUTBOX — ${plan.length} repl${plan.length === 1 ? "y" : "ies"}${asBot ? " as the bot" : ""}:`];
6688
+ for (const p of plan) lines.push(` → ${p.channel} thread ${p.threadTs}: ${stripTerminalControls(p.text).replace(/\n/g, "⏎").slice(0, 120)}${p.text.includes("[redacted:") ? " (secrets were masked when queued: this is what posts)" : ""}`);
6689
+ const expected = safetyCode("outbox", asBot ? "bot" : "user", ...plan.map((p) => `${p.channel}:${p.threadTs}:${p.text}`));
6690
+ if (argv.code !== expected) requireCode(argv.code, expected, lines);
6691
+ let failed = 0;
6692
+ for (const p of plan) {
6693
+ try {
6694
+ const ts = await slackSend(sendTok, p.channel, p.text, p.threadTs, false, sendCk);
6695
+ let link = "";
6696
+ try { link = await getPermalink(sendTok, p.channel, ts, sendCk); } catch { /* the ts is what matters */ }
6697
+ const to = recordOutboxSent(scope, p.item, { ts, permalink: link, channel: p.channel, thread_ts: p.threadTs });
6698
+ console.log(`✓ ${link || ts} (${to})`);
6699
+ } catch (e: unknown) {
6700
+ recordFailure(p.item, friendlySlackError(e));
6701
+ console.error(`✗ ${p.item.file}: ${friendlySlackError(e)}`);
6702
+ failed++;
6703
+ }
6704
+ }
6705
+ if (failed) process.exit(1);
6706
+ },
6707
+ )
6708
+ .demandCommand(1, "outbox needs a subcommand: add, ls, flush"),
6709
+ )
6089
6710
  .command(
6090
6711
  "ask [target] [question] [choices..]",
6091
6712
  "Ask a question with its choices pre-seeded as 1️⃣..🔟 reactions (confirm-hash safety gate). Lifecycle: --waitFor=<link> collect, --void=<link> 無効化 (verb aliases: `ask collect <link>`, `ask void <link…>`)",
@@ -6745,39 +7366,18 @@ async function main(): Promise<void> {
6745
7366
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
6746
7367
  .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (on by default). Unresolved handles stay as plain text. Disable with --no-mentions for literal @text." })
6747
7368
  .option("as-bot", { type: "boolean", default: false, describe: "Edit as the bot (xoxb / SLACK_BOT_TOKEN). REQUIRED to correct a message the bot posted: Slack only lets a token edit its OWN messages, so a bot-authored message is uneditable by the user token and chat.update returns cant_update_message. Address it by permalink or --channel-id; an @user target resolves against the BOT's DM list, which is where a bot-authored DM actually lives." }),
6748
- async (argv) => {
6749
- const args: EditArgs = { target: argv.target!, newText: argv.newText!, allowUrlAdjacent: argv["allow-url-adjacent"] };
6750
- if (argv.force) args.force = true;
6751
- if (argv.code) args.code = argv.code;
6752
- if (argv["channel-id"]) args.channelId = argv["channel-id"];
6753
- if (argv.mentions !== false) args.mentions = true;
6754
- let editToken: string;
6755
- if (argv["as-bot"]) {
6756
- // --as-bot is not a switch on chat.update. It selects a DIFFERENT
6757
- // resolved token, and until now `edit` never wired one — so every
6758
- // message the bot posted was uncorrectable, and the workaround people
6759
- // reached for was RESENDING, which this repo's own etiquette forbids.
6760
- // Bot identity is what makes a DM notify at all, so "uncorrectable"
6761
- // applied to exactly the messages that matter most.
6762
- const resolved = await asBotTarget(args.target, tok(argv as W), ck(argv as W), args.channelId);
6763
- editToken = resolved.botToken;
6764
- if (resolved.channelId && !args.channelId) args.channelId = resolved.channelId;
6765
- args.asBot = true;
6766
- // Mentions still resolve on the USER token: the bot has no users:read,
6767
- // and a silently unresolved @handle in a CORRECTION is the same defect
6768
- // twice.
6769
- args.mentionToken = tok(argv as W);
6770
- const mc = ck(argv as W);
6771
- if (mc) args.mentionCookie = mc;
6772
- } else {
6773
- editToken = tok(argv as W);
6774
- // Not set under --as-bot: a bot token needs no session cookie, and
6775
- // attaching one is how a working call starts failing.
6776
- const cookie = ck(argv as W);
6777
- if (cookie) args.cookie = cookie;
6778
- }
6779
- await cmdEdit(editToken, args);
6780
- },
7369
+ async (argv) => runEdit(argv),
7370
+ )
7371
+ .command(
7372
+ "tick <target> [n]",
7373
+ "Tick item n of a posted checklist (⬜ n. → ✅ n.), nothing else changed; without n, the one open item (confirm-hash gate, like edit)",
7374
+ (y) => y
7375
+ .positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
7376
+ .positional("n", { type: "number", describe: "Item number" })
7377
+ .option("code", { type: "string", describe: "Safety hash to confirm the edit" })
7378
+ .option("channel-id", { type: "string", describe: "Raw channel ID" })
7379
+ .option("as-bot", { type: "boolean", default: false, describe: "Edit as the bot (needed for a message the bot posted)" }),
7380
+ async (argv) => runEdit({ ...argv, tick: argv.n !== undefined ? { n: argv.n } : {}, mentions: false }),
6781
7381
  )
6782
7382
  .command(
6783
7383
  "delete <target>",
@@ -7698,7 +8298,8 @@ async function main(): Promise<void> {
7698
8298
  .option("state", { type: "string", describe: "Cursor file (default: one per identity+grep+channels under $XDG_STATE_HOME/slack-term/stream/)" })
7699
8299
  .option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
7700
8300
  .option("relay", { type: "boolean", default: true, describe: "Use the doorbell relay (`slack relay init`) when SLACK_RELAY_URL and SLACK_RELAY_TOKEN are set; --no-relay polls only" })
7701
- .option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" }),
8301
+ .option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" })
8302
+ .option("inbox", { type: "boolean", default: false, describe: "Also write each match into the inbox/ of every .slack-term/ that subscribes its thread (redacted, text-only)" }),
7702
8303
  async (argv) => {
7703
8304
  let re: RegExp;
7704
8305
  let reconcileMs: number;
@@ -7804,6 +8405,9 @@ async function main(): Promise<void> {
7804
8405
  const ac = new AbortController();
7805
8406
  process.on("SIGINT", () => ac.abort());
7806
8407
  process.on("SIGTERM", () => ac.abort());
8408
+ // The stream schedules around Retry-After itself (bells and polls take
8409
+ // turns): a call that slept inside would stall both.
8410
+ setRateLimitWait(0);
7807
8411
  const code = await runStream(webClient(token, cookie), {
7808
8412
  grep: re,
7809
8413
  ...(repliesTo ? { repliesTo } : {}),
@@ -7811,6 +8415,18 @@ async function main(): Promise<void> {
7811
8415
  selfUsers,
7812
8416
  selfBots,
7813
8417
  json: argv.json,
8418
+ ...(argv.inbox ? {
8419
+ onMatch: (rec: StreamMatch, files: number) => {
8420
+ try {
8421
+ // Our own credentials, masked by exact value on top of the rules.
8422
+ const known = [token, cookie, process.env.SLACK_RELAY_TOKEN].filter((v): v is string => !!v);
8423
+ const dirs = deliver({ ...rec, ...(files ? { files } : {}) }, undefined, new Date(), known);
8424
+ if (dirs.length) console.error(`slack stream: delivered ${rec.channel.id}:${rec.ts} to ${dirs.length} inbox(es)`);
8425
+ } catch (e: unknown) {
8426
+ console.error(`slack stream: inbox delivery failed: ${e instanceof Error ? e.message : String(e)}`);
8427
+ }
8428
+ },
8429
+ } : {}),
7814
8430
  once: argv.once,
7815
8431
  ...(sinceSec !== undefined ? { sinceSec } : {}),
7816
8432
  intervalMs,