slack-term 1.48.0 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ts/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,41 @@ 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) msgs = msgs.filter((m) => Number(m.ts) > Number(since));
481
+ if (format === "tsv") {
482
+ for (const m of msgs) console.log(tsvLine(m));
483
+ return;
484
+ }
446
485
  if (format === "jsonl") {
447
486
  for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
448
487
  return;
@@ -1287,9 +1326,89 @@ function parseTargetThread(s: string, permalinkThreads = true): { ref: string; t
1287
1326
  if (/^\d{10}\.\d{6}$/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: maybeTs };
1288
1327
  if (/^\d{4}-\d{2}-\d{2}T/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: isoToSlackTs(maybeTs) };
1289
1328
  }
1329
+ // A bare name (no #, @ or :) that is an alias in the nearest .slack-term/.
1330
+ const alias = lookupAlias(s);
1331
+ if (alias) return alias.thread_ts ? { ref: alias.channel, threadTs: alias.thread_ts } : { ref: alias.channel };
1290
1332
  return { ref: s };
1291
1333
  }
1292
1334
 
1335
+ /** Where `slack reply` (and `outbox flush`) posts: the destination — alias,
1336
+ * permalink (its root), `#chan:<ts>` or a bare ts in settings' channel —
1337
+ * and/or `--to`'s message, which must agree with it unless `anyThread`. A
1338
+ * restricted scope also requires the thread to be one of its subscriptions.
1339
+ * `{ error }` is a refusal (exit 2): nothing was sent. */
1340
+ async function resolveReplyTarget(
1341
+ token: string, cookie: string | undefined, scope: Scope, dest: string | undefined, to: string | undefined, anyThread = false,
1342
+ ): Promise<{ channel: string; threadTs: string } | { error: string }> {
1343
+ const settings = readSettings(scope);
1344
+ let channel: string | undefined;
1345
+ let threadTs: string | undefined;
1346
+ if (dest) {
1347
+ if (parseSlackPermalink(dest)) {
1348
+ const place = await messagePlace(token, dest, cookie);
1349
+ channel = place.channel;
1350
+ threadTs = place.root;
1351
+ } else {
1352
+ let p = parseTargetThread(dest);
1353
+ if (!p.threadTs && /^\d{10}\.\d{6}$/.test(dest)) {
1354
+ if (!settings.channel) return { error: `a bare ts needs a channel: set \`channel\` in .slack-term/settings.yml, or pass #chan:${dest}` };
1355
+ p = { ref: settings.channel, threadTs: dest };
1356
+ }
1357
+ if (!p.threadTs) return { error: `"${dest}" is not a thread — reply only posts in threads. For a top-level post use \`slack send\`.` };
1358
+ channel = await resolveChannel(token, p.ref, cookie);
1359
+ threadTs = p.threadTs;
1360
+ }
1361
+ }
1362
+ if (to) {
1363
+ const answered = await messagePlace(token, to, cookie);
1364
+ if (channel === undefined) {
1365
+ channel = answered.channel;
1366
+ threadTs = answered.root;
1367
+ } else if (!anyThread) {
1368
+ const why = threadMismatch({ channel, ...(threadTs ? { threadTs } : {}) }, answered);
1369
+ if (why) return { error: why };
1370
+ }
1371
+ }
1372
+ if (!channel || !threadTs) return { error: "reply needs a destination (alias, permalink, #chan:<ts>) or --to <permalink>" };
1373
+ if (isRestricted(scope) && !listAliases(scope).some((a) => a.channel === channel && (a.thread_ts === undefined || a.thread_ts === threadTs))) {
1374
+ return { error: `${channel} thread ${threadTs} is not one of ${scope.dir}'s subscriptions (restrict_to_subscriptions)` };
1375
+ }
1376
+ return { channel, threadTs };
1377
+ }
1378
+
1379
+ /** Where the message a permalink (or `#chan:<ts>`) names really sits: its
1380
+ * channel and thread root. A permalink's `?thread_ts=` settles it; otherwise
1381
+ * the thread is read, since a reply's bare permalink looks like a root's. */
1382
+ async function messagePlace(token: string, target: string, cookie?: string): Promise<MessagePlace> {
1383
+ const url = parseSlackPermalink(target);
1384
+ let ref: string;
1385
+ let ts: string | undefined;
1386
+ let root: string | undefined;
1387
+ if (url) {
1388
+ ref = url.channel;
1389
+ ts = url.ts;
1390
+ root = url.threadTs;
1391
+ } else {
1392
+ const p = parseTargetThread(target);
1393
+ ref = p.ref;
1394
+ ts = p.threadTs;
1395
+ }
1396
+ if (!ts) throw new Error(`--to needs a message permalink (or #chan:<ts>), got "${target}"`);
1397
+ const channel = await resolveChannel(token, ref, cookie);
1398
+ if (!root) {
1399
+ try {
1400
+ const msgs = (asRecord((await replies(token, channel, ts, 1, cookie)) as Json).messages ?? []) as Json[];
1401
+ const own = msgs.map(asRecord).find((m) => m.ts === ts) ?? asRecord(msgs[0]);
1402
+ root = typeof own.thread_ts === "string" ? own.thread_ts : ts;
1403
+ } catch (e) {
1404
+ // A top-level message nobody answered has no thread to read.
1405
+ if (!/thread_not_found/.test(e instanceof Error ? e.message : String(e))) throw e;
1406
+ root = ts;
1407
+ }
1408
+ }
1409
+ return { channel, ts, root };
1410
+ }
1411
+
1293
1412
  /** Human-readable destination label for confirm gates, e.g. "#general (C0123456789)".
1294
1413
  * When ref is already a #channel/@user label, keep it; otherwise resolve the channel
1295
1414
  * name via conversations.info (fail-soft: a raw ID is still unambiguous). */
@@ -1489,6 +1608,12 @@ interface EditArgs {
1489
1608
  * `send --as-bot`. */
1490
1609
  mentionToken?: string;
1491
1610
  mentionCookie?: string;
1611
+ /** `slack tick`: the new text is the original with item `n` (or the one open
1612
+ * item) ticked — computed from what is stored, so nothing else changes. */
1613
+ tick?: { n?: number };
1614
+ /** The scope's `[lane]` prefix: an edit keeps the lane tag the original
1615
+ * was posted with. */
1616
+ lanePrefix?: string;
1492
1617
  }
1493
1618
  /** The bot token, plus the bot↔user DM when the target names an `@handle`.
1494
1619
  *
@@ -1559,11 +1684,24 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
1559
1684
  }
1560
1685
  const originalText = typeof original.text === "string" ? original.text : "";
1561
1686
 
1687
+ let wanted = args.newText;
1688
+ if (args.tick) {
1689
+ const r = tickText(originalText, args.tick.n);
1690
+ if (r.kind === "already") { console.log(`already ticked (${r.n}.)`); process.exit(0); }
1691
+ if (r.kind === "none") { console.error("Error: the message has no ⬜ <n>. item"); process.exit(2); }
1692
+ if (r.kind === "missing") { console.error(`Error: the message has no ⬜ ${r.n}. item`); process.exit(2); }
1693
+ 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); }
1694
+ wanted = r.text;
1695
+ } else {
1696
+ wanted = keepLanePrefix(originalText, wanted, args.lanePrefix);
1697
+ }
1698
+
1562
1699
  // Convert @handle → <@USERID> before hashing/editing (unresolved stay as text).
1700
+ // A tick rewrites stored text, whose mentions are encoded already.
1563
1701
  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;
1702
+ const newText = args.mentions && !args.tick
1703
+ ? await encodeMentions(args.mentionToken ?? token, wanted, channelId, mentionCookie ? { cookie: mentionCookie } : {})
1704
+ : wanted;
1567
1705
 
1568
1706
  guardUrlBoundaries(newText, args.allowUrlAdjacent);
1569
1707
 
@@ -2011,7 +2149,25 @@ async function cmdPinlogUpdate(token: string, args: PinlogUpdateArgs): Promise<v
2011
2149
  process.exit(1);
2012
2150
  }
2013
2151
  attrEdit.record({ team: self?.team, channel, target: args.target, ts, text: newHead, asBot: args.asBot });
2014
- console.log(`✓ HEAD updated: ${id}`);
2152
+ // Read it back: Slack rewrites text when it stores it, so compare the
2153
+ // normalised forms (ts/readback.ts). A board that does not show what was
2154
+ // sent gets no log line — exit 3, like the earlier thread tool's `state`.
2155
+ let stored: Record<string, Json> | undefined;
2156
+ try {
2157
+ const back = asArray(asRecord((await replies(token, channel, ts, 1, args.cookie)) as Json).messages).map(asRecord);
2158
+ stored = back.find((m) => m.ts === ts);
2159
+ } catch (e: unknown) {
2160
+ console.error(`⚠ HEAD updated, but it could not be read back to check: ${friendlySlackError(e)}`);
2161
+ }
2162
+ if (stored && !storedAsSent(newHead, stored)) {
2163
+ const names = emojiMap(stored.blocks);
2164
+ console.error(
2165
+ `✗ HEAD was edited, but Slack does not show what was sent (${firstDifference(normText(newHead, names), normText(typeof stored.text === "string" ? stored.text : "", names))}).\n` +
2166
+ ` The log reply was NOT posted. Check it: slack pinlog get ${id}${bot}`,
2167
+ );
2168
+ process.exit(3);
2169
+ }
2170
+ console.log(`✓ HEAD updated${stored ? " (read back: matches)" : ""}: ${id}`);
2015
2171
  }
2016
2172
 
2017
2173
  const attrLog = sentAttribution("send");
@@ -2302,6 +2458,9 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
2302
2458
  const { ref, threadTs } = parseTargetThread(args.target);
2303
2459
  const cookie = args.cookie;
2304
2460
 
2461
+ // Hint only, on the preview: a choice asked in prose makes the reader type.
2462
+ if (!args.code && choiceQuestionLang(args.message)) console.error(choiceHint(args.target));
2463
+
2305
2464
  // Identity of the token that will actually post — the bot token under
2306
2465
  // --as-bot, the user token otherwise.
2307
2466
  const getSelf = selfLookup(token, cookie);
@@ -2643,9 +2802,6 @@ function askExplainDefault(): boolean {
2643
2802
  return /^(1|true|yes|on)$/i.test(process.env.SLACK_TERM_ASK_EXPLAIN ?? "");
2644
2803
  }
2645
2804
 
2646
- const ASK_PHI = 1.618033988749895;
2647
- const ASK_POLL_MIN_MS = 1000;
2648
- const ASK_POLL_MAX_MS = 5000;
2649
2805
  const ASK_MAX_CONSECUTIVE_ERRORS = 8;
2650
2806
 
2651
2807
 
@@ -3336,8 +3492,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3336
3492
  }
3337
3493
 
3338
3494
  const deadline = Date.now() + timeout * 1000;
3495
+ const started = Date.now();
3339
3496
  let errors = 0;
3340
- let delay = ASK_POLL_MIN_MS;
3497
+ let delay: number = ASK_POLL.minMs;
3498
+ /** A 429's Retry-After: the next poll waits at least this long. */
3499
+ let coolMs = 0;
3341
3500
  let unchosen: AskPollState["unchosen"];
3342
3501
  for (;;) {
3343
3502
  let found: AskFound | null = null;
@@ -3345,13 +3504,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3345
3504
  found = await poll();
3346
3505
  errors = 0;
3347
3506
  } 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);
3507
+ if (e instanceof RateLimitError) {
3508
+ // Not a failure: the bucket is shared with every other waiter on this
3509
+ // token. Polling again before Retry-After only keeps it at 429.
3510
+ coolMs = e.retryAfter * 1000;
3511
+ } else {
3512
+ // A dropped poll is expected occasionally; only give up if it keeps failing.
3513
+ errors++;
3514
+ if (errors >= ASK_MAX_CONSECUTIVE_ERRORS) {
3515
+ console.error(`Error: Slack への問い合わせに ${errors} 回連続で失敗しました: ${e instanceof Error ? e.message : String(e)}`);
3516
+ process.exit(ASK_EXIT_ERROR);
3517
+ }
3518
+ console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
3353
3519
  }
3354
- console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
3355
3520
  }
3356
3521
  unchosen = state.unchosen;
3357
3522
  const voided = state.voided;
@@ -3442,8 +3607,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3442
3607
  // Checked after the first poll, never before it: `--timeout 0` means one
3443
3608
  // look, and one look must still be a real look.
3444
3609
  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);
3610
+ await askSleep(Math.min(Math.max(delay, coolMs), Math.max(0, deadline - Date.now())));
3611
+ coolMs = 0;
3612
+ delay = askNextPollDelay(delay, Date.now() - started);
3447
3613
  }
3448
3614
 
3449
3615
  // A timeout is the moment the caller most needs the collect command: the
@@ -5501,6 +5667,145 @@ async function main(): Promise<void> {
5501
5667
  const tok = (a: W) => resolveToken(a.workspace);
5502
5668
  const ck = (a: W) => resolveCookie(a.workspace);
5503
5669
 
5670
+ /** The `send` handler, shared with `reply` (which resolves its own target,
5671
+ * prefix and thread check first). */
5672
+ type SendCli = {
5673
+ workspace?: string | undefined; target?: string | undefined; message?: string | undefined; code?: string | undefined;
5674
+ "allow-url-adjacent"?: boolean | undefined; "channel-id"?: string | undefined; "user-id"?: string | undefined;
5675
+ "as-bot"?: boolean | undefined; broadcast?: boolean | undefined; mentions?: boolean | undefined;
5676
+ };
5677
+ /** The `edit` handler, shared with `tick`. In a .slack-term/ scope the
5678
+ * lane's tag is kept; in a restricted one the message must sit in one of
5679
+ * its subscriptions. */
5680
+ type EditCli = {
5681
+ workspace?: string | undefined; target?: string | undefined; newText?: string | undefined; code?: string | undefined;
5682
+ force?: boolean | undefined; "allow-url-adjacent"?: boolean | undefined; "channel-id"?: string | undefined;
5683
+ mentions?: boolean | undefined; "as-bot"?: boolean | undefined; tick?: { n?: number } | undefined;
5684
+ };
5685
+ const runEdit = async (argv: EditCli): Promise<void> => {
5686
+ const scope = findScope();
5687
+ const settings = readSettings(scope);
5688
+ if (isRestricted(scope)) {
5689
+ try {
5690
+ const place = await messagePlace(tok(argv as W), argv.target!, ck(argv as W));
5691
+ if (!listAliases(scope).some((a) => a.channel === place.channel && (a.thread_ts === undefined || a.thread_ts === place.root))) {
5692
+ console.error(`Error: that message is not in one of ${scope.dir}'s subscriptions (restrict_to_subscriptions)`);
5693
+ process.exit(2);
5694
+ }
5695
+ } catch (e: unknown) {
5696
+ console.error(friendlySlackError(e));
5697
+ process.exit(1);
5698
+ }
5699
+ }
5700
+ const args: EditArgs = { target: argv.target!, newText: argv.newText ?? "", allowUrlAdjacent: argv["allow-url-adjacent"] === true };
5701
+ if (argv.tick) args.tick = argv.tick;
5702
+ if (settings.prefix) args.lanePrefix = settings.prefix;
5703
+ if (argv.force) args.force = true;
5704
+ if (argv.code) args.code = argv.code;
5705
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
5706
+ if (argv.mentions !== false) args.mentions = true;
5707
+ let editToken: string;
5708
+ if (argv["as-bot"]) {
5709
+ // --as-bot is not a switch on chat.update. It selects a DIFFERENT
5710
+ // resolved token, and until now `edit` never wired one — so every
5711
+ // message the bot posted was uncorrectable, and the workaround people
5712
+ // reached for was RESENDING, which this repo's own etiquette forbids.
5713
+ // Bot identity is what makes a DM notify at all, so "uncorrectable"
5714
+ // applied to exactly the messages that matter most.
5715
+ const resolved = await asBotTarget(args.target, tok(argv as W), ck(argv as W), args.channelId);
5716
+ editToken = resolved.botToken;
5717
+ if (resolved.channelId && !args.channelId) args.channelId = resolved.channelId;
5718
+ args.asBot = true;
5719
+ // Mentions still resolve on the USER token: the bot has no users:read,
5720
+ // and a silently unresolved @handle in a CORRECTION is the same defect
5721
+ // twice.
5722
+ args.mentionToken = tok(argv as W);
5723
+ const mc = ck(argv as W);
5724
+ if (mc) args.mentionCookie = mc;
5725
+ } else {
5726
+ editToken = tok(argv as W);
5727
+ // Not set under --as-bot: a bot token needs no session cookie, and
5728
+ // attaching one is how a working call starts failing.
5729
+ const cookie = ck(argv as W);
5730
+ if (cookie) args.cookie = cookie;
5731
+ }
5732
+ await cmdEdit(editToken, args);
5733
+ };
5734
+
5735
+ const runSend = async (argv: SendCli): Promise<void> => {
5736
+ // An empty body is never a meaningful send. `ask` already refuses one
5737
+ // ("Error: question is empty"); `send` did not, so an empty draft
5738
+ // staged a confirm code and posting it produced a blank message.
5739
+ // Verification cannot catch this: an empty publish reads back as an
5740
+ // exact match, so refusing here is the only mechanism.
5741
+ if (!String(argv.message ?? "").trim()) {
5742
+ console.error("Error: message is empty");
5743
+ process.exit(1);
5744
+ }
5745
+ const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] === true };
5746
+ if (argv.code) args.code = argv.code;
5747
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
5748
+ if (argv["user-id"]) args.userId = argv["user-id"];
5749
+ if (argv.broadcast) args.broadcast = true;
5750
+ // Mentions on by default; `--no-mentions` (yargs negation) sets it false.
5751
+ // A message with no `@token` short-circuits inside encodeMentions, so the
5752
+ // common case pays no extra API call.
5753
+ if (argv.mentions !== false) {
5754
+ args.mentions = true;
5755
+ // Resolve mentions with the user token (has users:read) even when the
5756
+ // message itself is sent via the bot token. Pass its cookie too so an
5757
+ // xoxc- desktop token can reach users.list (rejected without the cookie).
5758
+ args.mentionToken = tok(argv as W);
5759
+ const mc = ck(argv as W);
5760
+ if (mc) args.mentionCookie = mc;
5761
+ }
5762
+ let sendToken: string;
5763
+ if (argv["as-bot"]) {
5764
+ const botToken = resolveBotToken();
5765
+ if (!botToken) {
5766
+ console.error(
5767
+ "Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
5768
+ " Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
5769
+ );
5770
+ process.exit(1);
5771
+ }
5772
+ // Resolve @user → user_id with the *user* token (has users:read), then
5773
+ // let cmdSend open the DM with the bot token (im:write). This avoids
5774
+ // requiring users:read on the bot and guarantees the DM is bot↔user
5775
+ // (not the user's own self-DM).
5776
+ if (!args.channelId && !args.userId && args.target.startsWith("@")) {
5777
+ try {
5778
+ args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
5779
+ } catch (e: unknown) {
5780
+ console.error(
5781
+ `Error: could not resolve ${args.target} to a user for the bot DM.\n` +
5782
+ ` ${e instanceof Error ? e.message : String(e)}\n` +
5783
+ ` Tip: pass --user-id <Uxxxx> to skip the lookup.`,
5784
+ );
5785
+ process.exit(1);
5786
+ }
5787
+ }
5788
+ sendToken = botToken;
5789
+ args.asBot = true;
5790
+ } else {
5791
+ sendToken = tok(argv as W);
5792
+ // Session cookie for `sendToken` itself — needed for an xoxc- desktop
5793
+ // token to be accepted by the public API at all. Not applicable to the
5794
+ // bot token above (bot tokens need no cookie).
5795
+ const sc = ck(argv as W);
5796
+ if (sc) args.cookie = sc;
5797
+ }
5798
+ // Print a clean error instead of yargs' usage dump when a send fails at
5799
+ // runtime (e.g. missing scope, channel not found). The confirm gate
5800
+ // exits via process.exit, so it is unaffected.
5801
+ try {
5802
+ await cmdSend(sendToken, args);
5803
+ } catch (e: unknown) {
5804
+ console.error(friendlySlackError(e));
5805
+ process.exit(1);
5806
+ }
5807
+ };
5808
+
5504
5809
  // `todo ls` and `mytodo ls` are the same listing over two different search
5505
5810
  // scopes — `has:` (anyone's reactions) vs `hasmy:` (only this token's). They
5506
5811
  // share one definition so the flags can never drift apart; `mine` is the only
@@ -5594,7 +5899,7 @@ async function main(): Promise<void> {
5594
5899
  ["read [target]", "msgs [target]"],
5595
5900
  "Browse messages",
5596
5901
  (y) => y
5597
- .positional("target", { type: "string", describe: "#channel, @user, or URL" })
5902
+ .positional("target", { type: "string", describe: "#channel, @user, URL, or an alias (slack alias ls)" })
5598
5903
  .option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" })
5599
5904
  .option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
5600
5905
  .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
@@ -5602,7 +5907,11 @@ async function main(): Promise<void> {
5602
5907
  async (argv) => {
5603
5908
  const token = tok(argv as W);
5604
5909
  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));
5910
+ // `slack read <alias>`: the thread (or channel) it names.
5911
+ const alias = argv.target ? lookupAlias(argv.target) : undefined;
5912
+ if (alias?.thread_ts) await cmdThread(token, alias.channel, alias.thread_ts, Math.max(argv.limit, 100), format, ck(argv as W));
5913
+ else if (alias) await cmdMsgsTarget(token, alias.channel, argv.limit, format, argv.unreplied, ck(argv as W));
5914
+ else if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format, argv.unreplied, ck(argv as W));
5606
5915
  else await cmdMsgs(token);
5607
5916
  },
5608
5917
  )
@@ -5613,11 +5922,12 @@ async function main(): Promise<void> {
5613
5922
  .positional("target", { type: "string", demandOption: true })
5614
5923
  .positional("ts", { type: "string", demandOption: true })
5615
5924
  .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" }),
5925
+ .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" })
5926
+ .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
5927
+ .option("since", { type: "string", describe: "Only messages after this ts (reads every page)" }),
5618
5928
  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));
5929
+ const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl" | "tsv");
5930
+ await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format, ck(argv as W), argv.since);
5621
5931
  },
5622
5932
  )
5623
5933
  .command(
@@ -6011,81 +6321,382 @@ async function main(): Promise<void> {
6011
6321
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
6012
6322
  .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
6323
  .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." }),
6324
+ .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." })
6325
+ .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`)" })
6326
+ .option("any-thread", { type: "boolean", default: false, describe: "With --to: send even when the message is in another thread" }),
6015
6327
  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
- );
6328
+ if (argv.to && !argv["any-thread"]) {
6329
+ try {
6330
+ const t = tok(argv as W);
6331
+ const c = ck(argv as W);
6332
+ const answered = await messagePlace(t, argv.to, c);
6333
+ const p = parseSlackPermalink(argv.target) ? await messagePlace(t, argv.target, c) : undefined;
6334
+ const dest = p ? { channel: p.channel, threadTs: p.root } : (() => { const q = parseTargetThread(argv.target); return { ref: q.ref, ...(q.threadTs ? { threadTs: q.threadTs } : {}) }; })();
6335
+ const channel = "channel" in dest ? dest.channel : await resolveChannel(t, dest.ref, c);
6336
+ const why = threadMismatch({ channel, ...(dest.threadTs ? { threadTs: dest.threadTs } : {}) }, answered);
6337
+ if (why) { console.error(`Error: ${why}`); process.exit(2); }
6338
+ } catch (e: unknown) {
6339
+ console.error(friendlySlackError(e));
6050
6340
  process.exit(1);
6051
6341
  }
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("@")) {
6342
+ }
6343
+ await runSend(argv);
6344
+ },
6345
+ )
6346
+ .command(
6347
+ "init [dir]",
6348
+ "Create .slack-term/ in a directory: aliases, settings, inbox/outbox (never committed)",
6349
+ (y) => y
6350
+ .positional("dir", { type: "string", default: ".", describe: "Directory to put .slack-term/ in" })
6351
+ .option("prefix", { type: "string", describe: "Put in front of every `slack reply`, e.g. \"[design]\"" })
6352
+ .option("channel", { type: "string", describe: "Default channel (id)" })
6353
+ .option("as-bot", { type: "boolean", describe: "Reply as the bot by default" }),
6354
+ (argv) => {
6355
+ const { dir, created } = initScope(argv.dir, {
6356
+ ...(argv.prefix ? { prefix: argv.prefix } : {}),
6357
+ ...(argv.channel ? { channel: argv.channel } : {}),
6358
+ ...(argv["as-bot"] !== undefined ? { as_bot: argv["as-bot"] } : {}),
6359
+ });
6360
+ console.log(created.length ? `${dir}: created ${created.join(", ")}` : `${dir}: already set up`);
6361
+ },
6362
+ )
6363
+ .command(
6364
+ "alias",
6365
+ "Name channels and threads for this directory (the nearest .slack-term/)",
6366
+ (y) => y
6367
+ .command(
6368
+ ["ls", "list"],
6369
+ "List aliases: name, channel, thread, file",
6370
+ (yy) => yy.option("json", { type: "boolean", default: false }),
6371
+ (argv) => {
6372
+ const scope = findScope();
6373
+ const all = listAliases(scope);
6374
+ if (argv.json) {
6375
+ console.log(JSON.stringify(all.map(({ scope: _, ...a }) => a)));
6376
+ return;
6377
+ }
6378
+ for (const a of all) console.log(`${a.name}\t${a.channel}\t${a.thread_ts ?? "-"}\t${a.file}${a.note ? `\t${a.note}` : ""}`);
6379
+ if (!all.length) console.error(`no aliases in ${scope.dir}${scope.global ? " (no .slack-term/ above here: `slack init` makes one)" : ""}`);
6380
+ },
6381
+ )
6382
+ .command(
6383
+ "set <name> <target>",
6384
+ "Name a channel or thread: a permalink (a reply's names its thread root), #chan:<ts>, or #chan",
6385
+ (yy) => yy
6386
+ .positional("name", { type: "string", demandOption: true })
6387
+ .positional("target", { type: "string", demandOption: true })
6388
+ .option("note", { type: "string", describe: "Free text shown by `alias ls`" })
6389
+ .option("shared", { type: "boolean", default: false, describe: "Write settings.yml (the allowlist owner's file) instead of settings.local.yml" })
6390
+ .option("dir", { type: "string", describe: "Use the .slack-term/ above this directory instead of the cwd's (with --shared: a lane's dir)" }),
6391
+ async (argv) => {
6392
+ const token = tok(argv as W);
6393
+ const cookie = ck(argv as W);
6394
+ const scope = findScope(argv.dir);
6395
+ const threaded = parseSlackPermalink(argv.target)?.ts !== undefined || parseTargetThread(argv.target).threadTs !== undefined;
6396
+ let channel: string;
6397
+ let thread: string | undefined;
6398
+ let note = "";
6399
+ if (threaded) {
6400
+ const place = await messagePlace(token, argv.target, cookie);
6401
+ channel = place.channel;
6402
+ thread = place.root;
6403
+ if (place.root !== place.ts) note = ` (${place.ts} is a reply: named its thread root)`;
6404
+ } else {
6405
+ channel = await resolveChannel(token, parseTargetThread(argv.target).ref, cookie);
6406
+ }
6407
+ let before;
6057
6408
  try {
6058
- args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
6409
+ before = setAlias(scope, argv.name, { channel, ...(thread ? { thread_ts: thread } : {}), ...(argv.note ? { note: argv.note } : {}) }, argv.shared);
6059
6410
  } 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);
6411
+ if (e instanceof RestrictedError) { console.error(`Error: ${e.message}`); process.exit(2); }
6412
+ throw e;
6066
6413
  }
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.
6414
+ const was = before && (before.channel !== channel || before.thread_ts !== thread) ? ` (was ${before.channel} ${before.thread_ts ?? "-"})` : "";
6415
+ console.log(`${argv.name} → ${channel} ${thread ?? "-"}${note}${was} [${scope.dir}]`);
6416
+ },
6417
+ )
6418
+ .command(
6419
+ ["unset <name>", "rm <name>"],
6420
+ "Remove an alias (from settings.local.yml; --shared: also from settings.yml)",
6421
+ (yy) => yy
6422
+ .positional("name", { type: "string", demandOption: true })
6423
+ .option("shared", { type: "boolean", default: false, describe: "Allow removing one defined in settings.yml" })
6424
+ .option("dir", { type: "string", describe: "Use the .slack-term/ above this directory" }),
6425
+ (argv) => {
6426
+ const scope = findScope(argv.dir);
6427
+ let r;
6428
+ try {
6429
+ r = unsetAlias(scope, argv.name, argv.shared);
6430
+ } catch (e: unknown) {
6431
+ if (e instanceof RestrictedError) { console.error(`Error: ${e.message}`); process.exit(2); }
6432
+ throw e;
6433
+ }
6434
+ if (r === "removed") console.log(`${argv.name} removed [${scope.dir}]`);
6435
+ else if (r === "absent") { console.error(`no alias "${argv.name}" in ${scope.dir}`); process.exit(2); }
6436
+ else { console.error(`"${argv.name}" is defined in ${scope.dir}/settings.yml: edit that file to remove it`); process.exit(2); }
6437
+ },
6438
+ )
6439
+ .command(
6440
+ "who <channel> <ts>",
6441
+ "Which registered .slack-term/ dirs subscribe this thread (or message): dir, alias",
6442
+ (yy) => yy
6443
+ .positional("channel", { type: "string", demandOption: true, describe: "Channel id" })
6444
+ .positional("ts", { type: "string", demandOption: true, describe: "Thread root ts (a reply's own ts is not its thread)" })
6445
+ .option("json", { type: "boolean", default: false }),
6446
+ (argv) => {
6447
+ const hits = whoSubscribes(argv.channel, argv.ts).map((a) => ({ dir: dirname(a.scope.dir), scope: a.scope.dir, alias: a.name, file: a.file }));
6448
+ if (argv.json) console.log(JSON.stringify(hits));
6449
+ else for (const h of hits) console.log(`${h.dir}\t${h.alias}`);
6450
+ if (!hits.length) process.exitCode = 2;
6451
+ },
6452
+ )
6453
+ .command(
6454
+ "import <tsv>",
6455
+ "One-shot: import a legacy allowlist.tsv (<thread_ts> TAB <lane>) into each lane's settings.yml",
6456
+ (yy) => yy
6457
+ .positional("tsv", { type: "string", demandOption: true })
6458
+ .option("channel", { type: "string", demandOption: true, describe: "The channel every thread is in (for a DM: the DM id)" })
6459
+ .option("lane-dir", { type: "array", string: true, demandOption: true, describe: "<lane>=<dir> (a directory holding .slack-term/, made if missing); repeatable" }),
6460
+ (argv) => {
6461
+ const dirs = new Map<string, string>();
6462
+ for (const kv of argv["lane-dir"] ?? []) {
6463
+ const eq = String(kv).indexOf("=");
6464
+ if (eq <= 0) { console.error(`Error: --lane-dir ${kv}: want <lane>=<dir>`); process.exit(2); }
6465
+ dirs.set(String(kv).slice(0, eq), String(kv).slice(eq + 1));
6466
+ }
6467
+ let added = 0;
6468
+ const skipped = new Map<string, number>();
6469
+ for (const line of readFileSync(argv.tsv, "utf8").split("\n")) {
6470
+ const [ts, lane] = line.trim().split("\t");
6471
+ if (!ts || line.startsWith("#") || !lane) continue;
6472
+ const dir = dirs.get(lane);
6473
+ if (!dir || !/^\d{10}\.\d{6}$/.test(ts)) { skipped.set(lane, (skipped.get(lane) ?? 0) + 1); continue; }
6474
+ const scope = { dir: initScope(dir).dir, global: false };
6475
+ setAlias(scope, `t-${ts}`, { channel: argv.channel, thread_ts: ts, note: "imported from legacy allowlist" }, true);
6476
+ added++;
6477
+ }
6478
+ console.log(`imported ${added} subscription(s)`);
6479
+ for (const [lane, n] of skipped) console.error(`skipped ${n} line(s) for lane "${lane}" (no --lane-dir, or not a ts)`);
6480
+ },
6481
+ )
6482
+ .demandCommand(1, "alias needs a subcommand: ls, set, unset, who, import"),
6483
+ )
6484
+ .command(
6485
+ "reply [dest] [message]",
6486
+ "Reply in a thread — an alias, a permalink or #chan:<ts> — with the directory's prefix (confirm-hash gate, like send)",
6487
+ (y) => y
6488
+ .positional("dest", { type: "string", describe: "alias | permalink | #chan:<ts> | <ts> (in settings' channel). Omit with --to" })
6489
+ .positional("message", { type: "string", describe: "Text, or - for stdin" })
6490
+ .option("to", { type: "string", describe: "Permalink of the message you are answering: replies in ITS thread; with a dest, refuses when they differ" })
6491
+ .option("any-thread", { type: "boolean", default: false, describe: "With --to and a dest: post to dest even when the message is in another thread" })
6492
+ .option("prefix", { type: "string", describe: "Override settings' prefix (\"\" for none)" })
6493
+ .option("code", { type: "string", describe: "Safety hash to confirm send" })
6494
+ .option("as-bot", { type: "boolean", describe: "Send via the bot token (default: settings' as_bot)" })
6495
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
6496
+ .option("broadcast", { type: "boolean", default: false, describe: "Also send to channel" })
6497
+ .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real mentions (--no-mentions for literal text)" }),
6498
+ async (argv) => {
6499
+ let dest = argv.dest;
6500
+ let message = argv.message;
6501
+ // `slack reply --to <link> 'text'`: the one positional is the text.
6502
+ if (argv.to && message === undefined) { message = dest; dest = undefined; }
6503
+ if (message === "-") message = await Bun.stdin.text();
6504
+ if (message === undefined || !message.trim()) { console.error("Error: message is empty"); process.exit(1); }
6505
+ if (!dest && !argv.to) { console.error("Error: reply needs a destination (alias, permalink, #chan:<ts>) or --to <permalink>"); process.exit(2); }
6506
+ const token = tok(argv as W);
6507
+ const cookie = ck(argv as W);
6508
+ const scope = findScope();
6509
+ const settings = readSettings(scope);
6510
+ let where: { channel: string; threadTs: string };
6081
6511
  try {
6082
- await cmdSend(sendToken, args);
6512
+ const r = await resolveReplyTarget(token, cookie, scope, dest, argv.to, argv["any-thread"]);
6513
+ if ("error" in r) { console.error(`Error: ${r.error}`); process.exit(2); }
6514
+ where = r;
6083
6515
  } catch (e: unknown) {
6084
6516
  console.error(friendlySlackError(e));
6085
6517
  process.exit(1);
6086
6518
  }
6519
+ const { channel, threadTs } = where;
6520
+ // A restricted scope's prefix is its owner's: overriding it would let a
6521
+ // lane post as another.
6522
+ if (isRestricted(scope) && (argv.prefix !== undefined || (argv["as-bot"] !== undefined && argv["as-bot"] !== (settings.as_bot ?? false)))) {
6523
+ console.error(`Error: ${scope.dir} is restricted: who it posts as (prefix, as_bot) comes from settings.yml; --prefix / --as-bot are refused`);
6524
+ process.exit(2);
6525
+ }
6526
+ const text = applyPrefix(message, argv.prefix ?? settings.prefix);
6527
+ if (!text.trim()) { console.error("Error: message is empty (only the prefix)"); process.exit(1); }
6528
+ await runSend({
6529
+ workspace: (argv as W).workspace, target: `${channel}:${threadTs}`, message: text, code: argv.code,
6530
+ "allow-url-adjacent": argv["allow-url-adjacent"], "as-bot": argv["as-bot"] ?? settings.as_bot ?? false,
6531
+ broadcast: argv.broadcast, mentions: argv.mentions,
6532
+ });
6087
6533
  },
6088
6534
  )
6535
+ .command(
6536
+ "inbox",
6537
+ "This directory's .slack-term/inbox/: messages routed here by `slack stream --inbox`",
6538
+ (y) => y
6539
+ .command(
6540
+ ["ls", "list"],
6541
+ "Pending messages: ts, from, alias, text (redacted)",
6542
+ (yy) => yy.option("json", { type: "boolean", default: false }),
6543
+ (argv) => {
6544
+ const items = listInbox(findScope());
6545
+ if (argv.json) { console.log(JSON.stringify(items.map((i) => ({ file: i.file, ...i.entry })))); return; }
6546
+ 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}]` : ""}`);
6547
+ },
6548
+ )
6549
+ .command(
6550
+ "listen",
6551
+ "Print each message as it arrives in inbox/ (one JSON line each); pending ones first. Exits 0 at --timeout",
6552
+ (yy) => yy
6553
+ .option("timeout", { type: "number", default: 3600, describe: "Seconds" })
6554
+ .option("new-only", { type: "boolean", default: false, describe: "Skip what is already pending" })
6555
+ .option("thread", { type: "string", describe: "Only messages in this thread (its root ts)" })
6556
+ .option("alias", { type: "string", describe: "Only messages routed by this alias" })
6557
+ .option("from", { type: "array", string: true, describe: "Only messages from these user ids; repeatable" }),
6558
+ async (argv) => {
6559
+ const scope = findScope();
6560
+ const from = argv.from?.length ? new Set(argv.from.map(String)) : undefined;
6561
+ const wanted = (e: { ts: string; thread_ts?: string; alias: string; user: string }): boolean =>
6562
+ (!argv.thread || (e.thread_ts ?? e.ts) === argv.thread) && (!argv.alias || e.alias === argv.alias) && (!from || from.has(e.user));
6563
+ const seen = new Set<string>();
6564
+ if (argv["new-only"]) for (const i of listInbox(scope)) seen.add(i.file);
6565
+ const until = Date.now() + argv.timeout * 1000;
6566
+ for (;;) {
6567
+ for (const i of listInbox(scope)) {
6568
+ if (seen.has(i.file)) continue;
6569
+ seen.add(i.file);
6570
+ if (wanted(i.entry)) console.log(JSON.stringify({ file: i.file, ...i.entry }));
6571
+ }
6572
+ if (Date.now() >= until) break;
6573
+ // Files land by rename, so a short poll is enough and survives
6574
+ // every filesystem (fs.watch does not, on network mounts).
6575
+ await new Promise((r) => setTimeout(r, Math.min(500, Math.max(0, until - Date.now()))));
6576
+ }
6577
+ },
6578
+ )
6579
+ .command(
6580
+ "nudge",
6581
+ "Pending messages older than settings' nudge_after_min (default 30): exit 1 when there are any",
6582
+ (yy) => yy.option("json", { type: "boolean", default: false }),
6583
+ (argv) => {
6584
+ const items = overdue(findScope());
6585
+ 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) }))));
6586
+ 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}`);
6587
+ if (items.length) process.exitCode = 1;
6588
+ },
6589
+ )
6590
+ .demandCommand(1, "inbox needs a subcommand: ls, listen, nudge"),
6591
+ )
6592
+ .command(
6593
+ "done <ref..>",
6594
+ "Mark inbox messages handled (moved to archived/) without replying: a file name, path or message ts",
6595
+ (y) => y.positional("ref", { type: "string", array: true, demandOption: true }),
6596
+ (argv) => {
6597
+ const scope = findScope();
6598
+ let missing = 0;
6599
+ for (const ref of argv.ref as string[]) {
6600
+ const to = markDone(scope, ref);
6601
+ if (to) console.log(`archived ${to}`);
6602
+ else { console.error(`not in ${scope.dir}/inbox: ${ref}`); missing++; }
6603
+ }
6604
+ if (missing) process.exit(2);
6605
+ },
6606
+ )
6607
+ .command(
6608
+ "outbox",
6609
+ "This directory's .slack-term/outbox/: replies queued as files, sent by `outbox flush`",
6610
+ (y) => y
6611
+ .command(
6612
+ "add <dest> <message>",
6613
+ "Queue a reply (text redacted before it is written; - reads stdin)",
6614
+ (yy) => yy
6615
+ .positional("dest", { type: "string", demandOption: true, describe: "alias | permalink | #chan:<ts>" })
6616
+ .positional("message", { type: "string", demandOption: true })
6617
+ .option("in-reply-to", { type: "string", describe: "ts of the inbox message this answers: archived once sent" }),
6618
+ async (argv) => {
6619
+ const text = argv.message === "-" ? await Bun.stdin.text() : argv.message;
6620
+ if (!text.trim()) { console.error("Error: message is empty"); process.exit(1); }
6621
+ const r = addOutbox(findScope(), { to: argv.dest, text, ...(argv["in-reply-to"] ? { in_reply_to: argv["in-reply-to"] } : {}) });
6622
+ if (r.redacted.length) console.error(`⚠ redacted before queueing: ${r.redacted.join(", ")}`);
6623
+ console.log(r.file);
6624
+ },
6625
+ )
6626
+ .command(
6627
+ ["ls", "list"],
6628
+ "Queued replies: file, dest, attempts, error",
6629
+ (yy) => yy,
6630
+ () => {
6631
+ 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 ?? ""}` : ""}`);
6632
+ },
6633
+ )
6634
+ .command(
6635
+ "flush",
6636
+ "Send every queued reply (prefix, thread and restrict checks as `reply`); a --code covers the whole batch",
6637
+ (yy) => yy
6638
+ .option("code", { type: "string", describe: "Safety hash to confirm the batch" })
6639
+ .option("as-bot", { type: "boolean", describe: "Send via the bot token (default: settings' as_bot)" }),
6640
+ async (argv) => {
6641
+ const scope = findScope();
6642
+ const settings = readSettings(scope);
6643
+ const items = listOutbox(scope);
6644
+ if (!items.length) { console.error(`nothing in ${scope.dir}/outbox`); return; }
6645
+ if (isRestricted(scope) && argv["as-bot"] !== undefined && argv["as-bot"] !== (settings.as_bot ?? false)) {
6646
+ console.error(`Error: ${scope.dir} is restricted: as_bot comes from settings.yml; --as-bot is refused`);
6647
+ process.exit(2);
6648
+ }
6649
+ const asBot = argv["as-bot"] ?? settings.as_bot ?? false;
6650
+ const lookTok = tok(argv as W);
6651
+ const lookCk = ck(argv as W);
6652
+ let sendTok: string;
6653
+ let sendCk: string | undefined;
6654
+ if (asBot) {
6655
+ const b = resolveBotToken();
6656
+ if (!b) { console.error("Error: as_bot needs a bot token (SLACK_BOT_TOKEN)"); process.exit(1); }
6657
+ sendTok = b;
6658
+ } else {
6659
+ sendTok = lookTok;
6660
+ sendCk = lookCk;
6661
+ }
6662
+ const plan: { item: (typeof items)[number]; channel: string; threadTs: string; text: string }[] = [];
6663
+ const refused: string[] = [];
6664
+ for (const item of items) {
6665
+ try {
6666
+ const r = await resolveReplyTarget(lookTok, lookCk, scope, item.entry.to, undefined);
6667
+ if ("error" in r) { refused.push(`${item.file}: ${r.error}`); continue; }
6668
+ const text = applyPrefix(item.entry.text, settings.prefix);
6669
+ if (!text.trim()) { refused.push(`${item.file}: empty`); continue; }
6670
+ plan.push({ item, ...r, text });
6671
+ } catch (e: unknown) {
6672
+ refused.push(`${item.file}: ${friendlySlackError(e)}`);
6673
+ }
6674
+ }
6675
+ for (const r of refused) console.error(`refused (stays queued): ${r}`);
6676
+ if (!plan.length) process.exit(2);
6677
+ const lines = [`OUTBOX — ${plan.length} repl${plan.length === 1 ? "y" : "ies"}${asBot ? " as the bot" : ""}:`];
6678
+ for (const p of plan) lines.push(` → ${p.channel} thread ${p.threadTs}: ${stripTerminalControls(p.text).replace(/\n/g, "⏎").slice(0, 120)}`);
6679
+ const expected = safetyCode("outbox", asBot ? "bot" : "user", ...plan.map((p) => `${p.channel}:${p.threadTs}:${p.text}`));
6680
+ if (argv.code !== expected) requireCode(argv.code, expected, lines);
6681
+ let failed = 0;
6682
+ for (const p of plan) {
6683
+ try {
6684
+ const ts = await slackSend(sendTok, p.channel, p.text, p.threadTs, false, sendCk);
6685
+ let link = "";
6686
+ try { link = await getPermalink(sendTok, p.channel, ts, sendCk); } catch { /* the ts is what matters */ }
6687
+ const to = recordOutboxSent(scope, p.item, { ts, permalink: link, channel: p.channel, thread_ts: p.threadTs });
6688
+ console.log(`✓ ${link || ts} (${to})`);
6689
+ } catch (e: unknown) {
6690
+ recordFailure(p.item, friendlySlackError(e));
6691
+ console.error(`✗ ${p.item.file}: ${friendlySlackError(e)}`);
6692
+ failed++;
6693
+ }
6694
+ }
6695
+ if (failed) process.exit(1);
6696
+ },
6697
+ )
6698
+ .demandCommand(1, "outbox needs a subcommand: add, ls, flush"),
6699
+ )
6089
6700
  .command(
6090
6701
  "ask [target] [question] [choices..]",
6091
6702
  "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 +7356,18 @@ async function main(): Promise<void> {
6745
7356
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
6746
7357
  .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
7358
  .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
- },
7359
+ async (argv) => runEdit(argv),
7360
+ )
7361
+ .command(
7362
+ "tick <target> [n]",
7363
+ "Tick item n of a posted checklist (⬜ n. → ✅ n.), nothing else changed; without n, the one open item (confirm-hash gate, like edit)",
7364
+ (y) => y
7365
+ .positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
7366
+ .positional("n", { type: "number", describe: "Item number" })
7367
+ .option("code", { type: "string", describe: "Safety hash to confirm the edit" })
7368
+ .option("channel-id", { type: "string", describe: "Raw channel ID" })
7369
+ .option("as-bot", { type: "boolean", default: false, describe: "Edit as the bot (needed for a message the bot posted)" }),
7370
+ async (argv) => runEdit({ ...argv, tick: argv.n !== undefined ? { n: argv.n } : {}, mentions: false }),
6781
7371
  )
6782
7372
  .command(
6783
7373
  "delete <target>",
@@ -7698,7 +8288,8 @@ async function main(): Promise<void> {
7698
8288
  .option("state", { type: "string", describe: "Cursor file (default: one per identity+grep+channels under $XDG_STATE_HOME/slack-term/stream/)" })
7699
8289
  .option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
7700
8290
  .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" }),
8291
+ .option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" })
8292
+ .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
8293
  async (argv) => {
7703
8294
  let re: RegExp;
7704
8295
  let reconcileMs: number;
@@ -7804,6 +8395,9 @@ async function main(): Promise<void> {
7804
8395
  const ac = new AbortController();
7805
8396
  process.on("SIGINT", () => ac.abort());
7806
8397
  process.on("SIGTERM", () => ac.abort());
8398
+ // The stream schedules around Retry-After itself (bells and polls take
8399
+ // turns): a call that slept inside would stall both.
8400
+ setRateLimitWait(0);
7807
8401
  const code = await runStream(webClient(token, cookie), {
7808
8402
  grep: re,
7809
8403
  ...(repliesTo ? { repliesTo } : {}),
@@ -7811,6 +8405,18 @@ async function main(): Promise<void> {
7811
8405
  selfUsers,
7812
8406
  selfBots,
7813
8407
  json: argv.json,
8408
+ ...(argv.inbox ? {
8409
+ onMatch: (rec: StreamMatch, files: number) => {
8410
+ try {
8411
+ // Our own credentials, masked by exact value on top of the rules.
8412
+ const known = [token, cookie, process.env.SLACK_RELAY_TOKEN].filter((v): v is string => !!v);
8413
+ const dirs = deliver({ ...rec, ...(files ? { files } : {}) }, undefined, new Date(), known);
8414
+ if (dirs.length) console.error(`slack stream: delivered ${rec.channel.id}:${rec.ts} to ${dirs.length} inbox(es)`);
8415
+ } catch (e: unknown) {
8416
+ console.error(`slack stream: inbox delivery failed: ${e instanceof Error ? e.message : String(e)}`);
8417
+ }
8418
+ },
8419
+ } : {}),
7814
8420
  once: argv.once,
7815
8421
  ...(sinceSec !== undefined ? { sinceSec } : {}),
7816
8422
  intervalMs,