slack-term 1.37.1 → 1.38.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
@@ -76,6 +76,9 @@ import {
76
76
  search,
77
77
  searchAll,
78
78
  send as slackSend,
79
+ pinsAdd,
80
+ RateLimitError,
81
+ repliesCursor,
79
82
  type MessageMetadata,
80
83
  getPermalink,
81
84
  scheduleMessage,
@@ -104,9 +107,10 @@ import {
104
107
  type ProgressState,
105
108
  } from "./todo.ts";
106
109
  import { setCacheEnabled } from "./cache.ts";
107
- import { unescapeArg } from "./escapes.ts";
110
+ import { escapeArg, unescapeArg } from "./escapes.ts";
108
111
  import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
109
112
  import { quietHoursNotice } from "./quietHours.ts";
113
+ import { LockBusyError, PINLOG_MARKER, acquireLock, composeHead, formatJst, headUpdatedAt, isPinlogHead, parsePinlogId, pinlogId, stripPinlogFooter } from "./pinlog.ts";
110
114
 
111
115
  function loadDotenv(path: string): void {
112
116
  if (!existsSync(path)) return;
@@ -876,6 +880,7 @@ const SCOPE_FOR_METHOD: Record<string, string> = {
876
880
  "chat.postMessage": "chat:write",
877
881
  "chat.update": "chat:write",
878
882
  "chat.delete": "chat:write",
883
+ "pins.add": "pins:write",
879
884
  "conversations.replies": "channels:history (or groups:history)",
880
885
  "conversations.history": "channels:history (or groups:history)",
881
886
  };
@@ -1364,6 +1369,513 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
1364
1369
  console.log(`✓ Deleted (ts: ${ts})`);
1365
1370
  }
1366
1371
 
1372
+ // --- pinlog ---
1373
+ // A pinned status board: the pinned top-level message is the CURRENT state
1374
+ // (HEAD, edited in place — silent), and each change is one short thread reply
1375
+ // under it (the log — notifies). Pure helpers live in pinlog.ts; the commands
1376
+ // live here because they share the confirm gate with send/edit.
1377
+
1378
+ /** Single-quote `s` for a POSIX shell, for the retry commands we print. */
1379
+ function shQuote(s: string): string {
1380
+ return `'${s.replace(/'/g, `'\\''`)}'`;
1381
+ }
1382
+
1383
+ /** Which board a subcommand was pointed at: `C…:ts` (what `new` prints), or
1384
+ * the usual `#chan:ts` / permalink forms. A board IS its message — there is
1385
+ * no name and nothing local to look up. */
1386
+ async function resolvePinlogTarget(
1387
+ token: string, target: string, cookie?: string,
1388
+ ): Promise<{ channel: string; ts: string; ref: string }> {
1389
+ const id = parsePinlogId(target);
1390
+ if (id) return { ...id, ref: id.channel };
1391
+ const { ref, ts } = splitRefTs(target);
1392
+ if (!ts) {
1393
+ console.error(
1394
+ `Error: ${stripTerminalControls(target)} is a channel, not a board — pass the board's permalink, ` +
1395
+ `#chan:ts, or the id \`new\` printed (C…:ts). Boards in a channel: slack pinlog ls ${shQuote(target)}`,
1396
+ );
1397
+ process.exit(2);
1398
+ }
1399
+ return { channel: await resolveChannel(token, ref, cookie), ts, ref };
1400
+ }
1401
+
1402
+ /** The HEAD message itself, or a clean exit when there is none. */
1403
+ async function fetchPinlogHead(
1404
+ token: string, channel: string, ts: string, cookie?: string,
1405
+ ): Promise<Record<string, Json>> {
1406
+ const resp = asRecord((await replies(token, channel, ts, 1, cookie)) as Json);
1407
+ const head = asArray(resp.messages).map(asRecord).find((m) => String(m.ts) === ts);
1408
+ if (!head) {
1409
+ console.error(`Error: no message at ts=${ts} in ${channel}.`);
1410
+ process.exit(1);
1411
+ }
1412
+ return head;
1413
+ }
1414
+
1415
+ /** The state text for create/update: positional, or `--file` (`-` = stdin).
1416
+ * A board is many lines, and an agent quoting twenty lines into one argv
1417
+ * string is where a stray quote eats half of it. */
1418
+ function pinlogStateArg(positional: string | undefined, file: string | undefined): string | undefined {
1419
+ if (file !== undefined) {
1420
+ try {
1421
+ return readFileSync(file === "-" ? 0 : file, "utf8").replace(/\n+$/, "");
1422
+ } catch (e: unknown) {
1423
+ console.error(`Error: cannot read --file ${file}: ${e instanceof Error ? e.message : String(e)}`);
1424
+ process.exit(2);
1425
+ }
1426
+ }
1427
+ return positional === undefined ? undefined : unescapeArg(positional);
1428
+ }
1429
+
1430
+ /** Slack truncates `text` beyond 40,000 characters — which would cut the footer
1431
+ * off a board, after which `list` cannot find it and `update` refuses it. */
1432
+ const PINLOG_MAX_TEXT = 40_000;
1433
+ function pinlogCheckSize(what: string, text: string): void {
1434
+ if (text.length > PINLOG_MAX_TEXT) {
1435
+ console.error(`Error: the ${what} is ${text.length} characters; Slack truncates past ${PINLOG_MAX_TEXT}. Shorten it — move detail to a linked page.`);
1436
+ process.exit(2);
1437
+ }
1438
+ }
1439
+
1440
+ /** Did this failed write DEFINITELY not land? Only a Slack error response
1441
+ * names a rejection. A network failure, or Slack's own internal_error /
1442
+ * fatal_error (documented as possibly-partial), means the write MAY have
1443
+ * landed — and advising a blind retry would then duplicate a board or a
1444
+ * notifying log line. */
1445
+ function pinlogWriteRejected(e: unknown): boolean {
1446
+ const raw = e instanceof Error ? e.message : String(e);
1447
+ const m = raw.match(/Slack error on \S+: (\w+)/);
1448
+ if (e instanceof RateLimitError) return true;
1449
+ return !!m && !["internal_error", "fatal_error", "request_timeout", "service_unavailable"].includes(m[1]!);
1450
+ }
1451
+
1452
+ /** The quiet-hours line as a warning, or null outside them. The confirm gate
1453
+ * already prints the clock; this repeats it on the CONFIRMED run too, because
1454
+ * that is the run whose output an agent actually reads. */
1455
+ function pinlogQuietWarning(now: Date, what: string): string | null {
1456
+ const n = quietHoursNotice(now);
1457
+ return n && n.includes("QUIET HOURS") ? `⚠ Quiet hours (JST): ${what}.` : null;
1458
+ }
1459
+
1460
+ interface PinlogCreateArgs {
1461
+ target: string;
1462
+ /** `-w`, carried into the printed recovery commands. */
1463
+ workspace?: string;
1464
+ state: string;
1465
+ /** Optional first reason, posted as the new board's first thread reply. */
1466
+ log?: string;
1467
+ code?: string;
1468
+ cookie?: string;
1469
+ asBot?: boolean;
1470
+ allowUrlAdjacent?: boolean;
1471
+ }
1472
+
1473
+ async function cmdPinlogCreate(token: string, args: PinlogCreateArgs): Promise<void> {
1474
+ const { ref, ts } = splitRefTs(args.target);
1475
+ if (ts || parsePinlogId(args.target)) {
1476
+ console.error(
1477
+ "Error: `pinlog new` posts a NEW board — pass a channel (#chan or C…), not a message.\n" +
1478
+ ` To change an existing board: slack pinlog set ${shQuote(args.target)} '<new state>' '<reason>'`,
1479
+ );
1480
+ process.exit(2);
1481
+ }
1482
+ if (!args.state.trim()) {
1483
+ console.error("Error: the initial state is empty — a board has to say something.");
1484
+ process.exit(2);
1485
+ }
1486
+ guardUrlBoundaries(args.state, args.allowUrlAdjacent);
1487
+ if (args.log !== undefined) {
1488
+ guardUrlBoundaries(args.log, args.allowUrlAdjacent);
1489
+ pinlogCheckSize("reason", args.log);
1490
+ }
1491
+
1492
+ // Every recovery command printed below must act on the SAME workspace: a
1493
+ // `list` that silently checks the default one finds nothing, and the next
1494
+ // step is a duplicate board.
1495
+ const bot = `${args.workspace ? ` -w ${shQuote(args.workspace)}` : ""}${args.asBot ? " --as-bot" : ""}`;
1496
+ const getSelf = selfLookup(token, args.cookie);
1497
+ const channelId = await resolveChannel(token, ref, args.cookie);
1498
+ const self = await getSelf();
1499
+ // The clock is NOT in the hash: the footer's minute moves between preview and
1500
+ // confirm, and a code that expires every minute is a code people stop reading.
1501
+ const code = safetyCode("pinlog-create", channelId, args.state, args.log ?? "", self?.userId ?? "");
1502
+ const now = new Date();
1503
+ const head = composeHead(args.state, now);
1504
+ pinlogCheckSize("HEAD (state + footer)", head);
1505
+ if (args.code !== code) {
1506
+ const dest = await destLabel(token, channelId, ref, args.cookie);
1507
+ // The situation, not just the text: boards already here are the usual
1508
+ // sign that this should be a `set` on one of them, not a second board.
1509
+ const existing = (await pinlogsIn(token, channelId, 200, args.cookie)).found;
1510
+ requireCode(args.code, code, [
1511
+ `--- New pinlog -------------------------------`,
1512
+ fromLine(self, { asBot: args.asBot }),
1513
+ ` → ${dest} — NEW top-level message, then pinned`,
1514
+ ...(existing.length
1515
+ ? [`⚠ ${existing.length} board(s) already in this channel — to change one, use \`slack pinlog set <board> …\` instead:`,
1516
+ ...existing.slice(0, 5).map((m) => ` ${pinlogId(channelId, String(m.ts))} ${pinlogFirstLine(String(m.text))}`)]
1517
+ : [` (no other boards in the last 200 messages of this channel)`]),
1518
+ `--- State (the top-level message) ------------`,
1519
+ ...head.split("\n").map((l) => ` ${stripTerminalControls(l)}`),
1520
+ ...(args.log ? [`--- Reason (first thread reply — notifies) ---`, ...args.log.split("\n").map((l) => ` ${stripTerminalControls(l)}`)] : []),
1521
+ `---------------------------------------------`,
1522
+ ]);
1523
+ }
1524
+
1525
+ const attr = sentAttribution("send");
1526
+ let headTs: string;
1527
+ try {
1528
+ // plain: the HEAD must read back verbatim — `update` and `list` find the
1529
+ // footer in the stored text, which Slack rewrites when blocks are attached.
1530
+ headTs = await slackSend(token, channelId, head, undefined, false, args.cookie, true, attr.metadata);
1531
+ } catch (e: unknown) {
1532
+ if (pinlogWriteRejected(e)) {
1533
+ console.error(`✗ HEAD was NOT posted: ${friendlySlackError(e)}`);
1534
+ } else {
1535
+ console.error(
1536
+ `⚠ UNKNOWN whether the HEAD was posted: ${friendlySlackError(e)}\n` +
1537
+ ` Check before retrying, or you will have two boards: slack pinlog ls ${shQuote(args.target)}${bot}`,
1538
+ );
1539
+ }
1540
+ process.exit(1);
1541
+ }
1542
+ if (!headTs) {
1543
+ console.error("✗ Slack accepted the post but returned no ts — check the channel before retrying, it may have landed.");
1544
+ process.exit(1);
1545
+ }
1546
+ attr.record({ team: self?.team, channel: channelId, target: args.target, ts: headTs, text: head, asBot: args.asBot });
1547
+ const id = pinlogId(channelId, headTs);
1548
+ console.log(`✓ Posted HEAD: ${id}`);
1549
+ const quiet = pinlogQuietWarning(now, "this new top-level post notified everyone who follows the channel");
1550
+ if (quiet) console.error(quiet);
1551
+
1552
+ try {
1553
+ await pinsAdd(token, channelId, headTs, args.cookie);
1554
+ console.log("✓ Pinned");
1555
+ } catch (e: unknown) {
1556
+ // The board EXISTS now. Failing the command here would invite a retry of
1557
+ // `create`, i.e. a second board — so this is reported loudly, but the exit
1558
+ // code stays 0 and the id is already on stdout.
1559
+ const raw = e instanceof Error ? e.message : String(e);
1560
+ const why = /missing_scope/.test(raw)
1561
+ ? `the ${args.asBot ? "bot" : "user"} token lacks the pins:write scope`
1562
+ : friendlySlackError(e);
1563
+ console.error(`⚠ NOT pinned: ${why}.`);
1564
+ console.error(` The board works unpinned; pin it later with: slack pinlog pin ${id}${bot}`);
1565
+ }
1566
+
1567
+ if (args.log) {
1568
+ // The board exists either way: a failed first reason is reported with its
1569
+ // exact retry, never by failing `new` (a retry of that would be a 2nd board).
1570
+ try {
1571
+ const logTs = await slackSend(token, channelId, args.log, headTs, false, args.cookie, true);
1572
+ console.log(`✓ Reason posted in the thread (ts: ${logTs})`);
1573
+ } catch (e: unknown) {
1574
+ const noteCode = safetyCode("pinlog-log", channelId, headTs, args.log, self?.userId ?? "");
1575
+ console.error(
1576
+ `${pinlogWriteRejected(e) ? "✗ The reason was NOT posted" : "⚠ UNKNOWN whether the reason was posted"}: ${friendlySlackError(e)}\n` +
1577
+ ` The board IS posted. Retry just the reason (check the thread first):\n` +
1578
+ ` slack pinlog note ${id} ${shQuote(escapeArg(args.log))}${args.allowUrlAdjacent ? " --allow-url-adjacent" : ""}${bot} --code=${noteCode}`,
1579
+ );
1580
+ process.exit(1);
1581
+ }
1582
+ }
1583
+ }
1584
+
1585
+ interface PinlogUpdateArgs {
1586
+ target: string;
1587
+ state?: string;
1588
+ log: string;
1589
+ logOnly?: boolean;
1590
+ /** Turn an existing message (no footer yet) into a board: the explicit
1591
+ * opt-out of the marker check, for a board people already ran by hand. */
1592
+ adopt?: boolean;
1593
+ /** `-w`, carried into the printed retry so it acts on the same workspace. */
1594
+ workspace?: string;
1595
+ code?: string;
1596
+ cookie?: string;
1597
+ asBot?: boolean;
1598
+ allowUrlAdjacent?: boolean;
1599
+ }
1600
+
1601
+ async function cmdPinlogUpdate(token: string, args: PinlogUpdateArgs): Promise<void> {
1602
+ if (!args.log.trim()) {
1603
+ console.error("Error: the reason is required — one short line saying what changed. The thread log is the point of a pinlog.");
1604
+ process.exit(2);
1605
+ }
1606
+ if (!args.logOnly && (args.state === undefined || !args.state.trim())) {
1607
+ console.error("Error: the new state is required (positional or --file). To post only a reason: slack pinlog note <board> <reason>");
1608
+ process.exit(2);
1609
+ }
1610
+ guardUrlBoundaries(args.log, args.allowUrlAdjacent);
1611
+ if (args.state !== undefined) guardUrlBoundaries(args.state, args.allowUrlAdjacent);
1612
+ pinlogCheckSize("log line", args.log);
1613
+
1614
+ const getSelf = selfLookup(token, args.cookie);
1615
+ const { channel, ts, ref } = await resolvePinlogTarget(token, args.target, args.cookie);
1616
+ // Held from the HEAD fetch through the log post (released on exit): Slack has
1617
+ // no compare-and-swap on chat.update, so without it two confirmed updates can
1618
+ // both read the same HEAD and the second silently erases the first's change.
1619
+ try {
1620
+ acquireLock(`pinlog-${channel}-${ts}`);
1621
+ } catch (e: unknown) {
1622
+ if (!(e instanceof LockBusyError)) throw e;
1623
+ console.error(`Error: another update of ${pinlogId(channel, ts)} is running (${e.holder}). Re-run when it has finished — then re-read the board, it has changed.`);
1624
+ process.exit(1);
1625
+ }
1626
+ const head = await fetchPinlogHead(token, channel, ts, args.cookie);
1627
+ const currentText = typeof head.text === "string" ? head.text : "";
1628
+ if (args.adopt && args.logOnly) {
1629
+ console.error("Error: --adopt rewrites the state with a footer — it does not apply to note.");
1630
+ process.exit(2);
1631
+ }
1632
+ if (!isPinlogHead(currentText) && !args.adopt) {
1633
+ console.error(
1634
+ `Error: the message at ${pinlogId(channel, ts)} is not a pinlog HEAD (no "${PINLOG_MARKER}" footer).\n` +
1635
+ ` Refusing to overwrite it — check the id. To turn this message into a board on purpose, add --adopt.`,
1636
+ );
1637
+ process.exit(1);
1638
+ }
1639
+ const self = await getSelf();
1640
+ const id = pinlogId(channel, ts);
1641
+ const now = new Date();
1642
+ const bot = `${args.workspace ? ` -w ${shQuote(args.workspace)}` : ""}${args.asBot ? " --as-bot" : ""}`;
1643
+ // The retry command for a failed log post is printed with its code already
1644
+ // in it, so the hash for `note` must be computable here without a gate.
1645
+ const logOnlyCode = safetyCode("pinlog-log", channel, ts, args.log, self?.userId ?? "");
1646
+ const logOnlyRetry = `slack pinlog note ${id} ${shQuote(escapeArg(args.log))}${args.allowUrlAdjacent ? " --allow-url-adjacent" : ""}${bot} --code=${logOnlyCode}`;
1647
+
1648
+ const quiet = pinlogQuietWarning(now, "the log reply notified everyone following this thread — hold non-urgent updates until 08:00");
1649
+ if (args.logOnly) {
1650
+ if (args.code !== logOnlyCode) {
1651
+ const dest = await destLabel(token, channel, ref, args.cookie);
1652
+ requireCode(args.code, logOnlyCode, [
1653
+ `--- Pinlog log reply (HEAD unchanged) --------`,
1654
+ fromLine(self, { asBot: args.asBot }),
1655
+ ` → ${dest}, thread of ${id}`,
1656
+ ...args.log.split("\n").map((l) => ` ${stripTerminalControls(l)}`),
1657
+ `---------------------------------------------`,
1658
+ ]);
1659
+ }
1660
+ } else {
1661
+ const newHead = composeHead(args.state!, now);
1662
+ pinlogCheckSize("new HEAD (state + footer)", newHead);
1663
+ // The CURRENT text is in the hash: if someone else updated the board
1664
+ // between your preview and your confirm, the code you were shown no longer
1665
+ // matches, and you re-read before overwriting their change.
1666
+ const adopting = !isPinlogHead(currentText);
1667
+ const code = safetyCode("pinlog-update", channel, ts, currentText, args.state!, args.log, self?.userId ?? "");
1668
+ if (args.code !== code) {
1669
+ const dest = await destLabel(token, channel, ref, args.cookie);
1670
+ const unchanged = stripPinlogFooter(currentText).trim() === stripPinlogFooter(args.state!).trim();
1671
+ requireCode(args.code, code, [
1672
+ `--- Updating pinlog --------------------------`,
1673
+ fromLine(self, { asBot: args.asBot }),
1674
+ ` → ${dest}, ${id}`,
1675
+ `--- Current HEAD -----------------------------`,
1676
+ // Shown, not sent: the HEAD comes from Slack, and an escape sequence in
1677
+ // it could repaint this very preview.
1678
+ ...currentText.split("\n").map((l) => ` ${stripTerminalControls(l)}`),
1679
+ `--- New HEAD (edited in place — silent) ------`,
1680
+ ...newHead.split("\n").map((l) => ` ${stripTerminalControls(l)}`),
1681
+ `--- Log reply (in the thread — notifies) -----`,
1682
+ ...args.log.split("\n").map((l) => ` ${stripTerminalControls(l)}`),
1683
+ `---------------------------------------------`,
1684
+ ...(unchanged ? [`⚠ The state text is unchanged — only the footer time moves. Replies are for real changes.`] : []),
1685
+ ...(adopting ? [`⚠ Adopting: this message has no pinlog footer yet — it becomes a board now.`] : []),
1686
+ ]);
1687
+ }
1688
+
1689
+ const attrEdit = sentAttribution("edit");
1690
+ try {
1691
+ await editMessage(token, channel, ts, newHead, args.cookie, true, attrEdit.metadata);
1692
+ } catch (e: unknown) {
1693
+ const raw = e instanceof Error ? e.message : String(e);
1694
+ const hint = /cant_update_message/.test(raw)
1695
+ ? `\n Only the HEAD's author can edit it${args.asBot ? "" : " — if the bot created this board, add --as-bot"}.`
1696
+ : "";
1697
+ const what = pinlogWriteRejected(e)
1698
+ ? `✗ HEAD was NOT updated: ${friendlySlackError(e)}${hint}`
1699
+ : `⚠ UNKNOWN whether the HEAD was updated: ${friendlySlackError(e)}\n Check it first: slack pinlog get ${id}${bot}`;
1700
+ console.error(`${what}\n The log reply was NOT posted (a log line for a change the board may not show would be a lie).`);
1701
+ process.exit(1);
1702
+ }
1703
+ attrEdit.record({ team: self?.team, channel, target: args.target, ts, text: newHead, asBot: args.asBot });
1704
+ console.log(`✓ HEAD updated: ${id}`);
1705
+ }
1706
+
1707
+ const attrLog = sentAttribution("send");
1708
+ let logTs: string;
1709
+ try {
1710
+ logTs = await slackSend(token, channel, args.log, ts, false, args.cookie, true, attrLog.metadata);
1711
+ } catch (e: unknown) {
1712
+ const rejected = pinlogWriteRejected(e);
1713
+ console.error(
1714
+ (rejected
1715
+ ? `✗ Log reply was NOT posted: ${friendlySlackError(e)}\n`
1716
+ : `⚠ UNKNOWN whether the log reply was posted: ${friendlySlackError(e)}\n`) +
1717
+ (args.logOnly ? "" : ` The HEAD IS updated — only the log line ${rejected ? "is" : "may be"} missing.\n`) +
1718
+ (rejected ? "" : ` Check the thread first (retrying a reply that landed notifies everyone twice): slack pinlog get ${id}${bot}\n`) +
1719
+ ` Retry just the log line${rejected ? "" : " (only if it is not there)"}:\n ${logOnlyRetry}`,
1720
+ );
1721
+ process.exit(1);
1722
+ }
1723
+ attrLog.record({ team: self?.team, channel, target: args.target, ts: logTs, text: args.log, asBot: args.asBot });
1724
+ console.log(`✓ Logged (ts: ${logTs})`);
1725
+ if (quiet) console.error(quiet);
1726
+ }
1727
+
1728
+ /** Slack stores `&`, `<`, `>` escaped; `show` is for reading, so undo that. */
1729
+ function slackUnescape(s: string): string {
1730
+ return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
1731
+ }
1732
+
1733
+ /** `2026-10-08 15:10 JST` for a Slack ts. */
1734
+ function tsJst(ts: string): string {
1735
+ return formatJst(new Date(Number(ts.split(".")[0]) * 1000));
1736
+ }
1737
+
1738
+ async function pinlogAuthor(token: string, m: Record<string, Json>, cache: Map<string, string>, cookie?: string): Promise<string> {
1739
+ if (typeof m.user === "string") {
1740
+ const hit = cache.get(m.user);
1741
+ if (hit !== undefined) return hit;
1742
+ let name: string;
1743
+ try {
1744
+ name = await userName(token, m.user, cookie);
1745
+ } catch {
1746
+ name = m.user;
1747
+ }
1748
+ cache.set(m.user, name);
1749
+ return name;
1750
+ }
1751
+ const prof = asRecord(m.bot_profile);
1752
+ if (typeof prof.name === "string") return prof.name;
1753
+ return typeof m.username === "string" ? m.username : "bot";
1754
+ }
1755
+
1756
+ async function cmdPinlogShow(token: string, target: string, cookie?: string, json?: boolean): Promise<void> {
1757
+ const { channel, ts } = await resolvePinlogTarget(token, target, cookie);
1758
+ // Page through the whole thread. Slack returns the oldest replies first, so a
1759
+ // single page silently drops the NEWEST log entries — the ones a reader came
1760
+ // for. Bounded; past the bound, `complete` says so.
1761
+ const msgs: Record<string, Json>[] = [];
1762
+ let cursor: string | undefined;
1763
+ let pages = 0;
1764
+ do {
1765
+ const page = asRecord((await repliesCursor(token, channel, ts, cursor, 200, cookie)) as Json);
1766
+ for (const m of asArray(page.messages).map(asRecord)) {
1767
+ // Every page repeats the parent; keep it once.
1768
+ if (String(m.ts) === ts && msgs.some((x) => String(x.ts) === ts)) continue;
1769
+ msgs.push(m);
1770
+ }
1771
+ const next = asRecord(page.response_metadata).next_cursor;
1772
+ cursor = typeof next === "string" && next ? next : undefined;
1773
+ pages++;
1774
+ } while (cursor && pages < 50);
1775
+ const complete = cursor === undefined;
1776
+ const head = msgs.find((m) => String(m.ts) === ts);
1777
+ if (!head) {
1778
+ console.error(`Error: no message at ts=${ts} in ${channel}.`);
1779
+ process.exit(1);
1780
+ }
1781
+ const text = typeof head.text === "string" ? head.text : "";
1782
+ const log = msgs.filter((m) => String(m.ts) !== ts);
1783
+ if (json) {
1784
+ console.log(JSON.stringify({
1785
+ id: pinlogId(channel, ts),
1786
+ isPinlog: isPinlogHead(text),
1787
+ updated: headUpdatedAt(text),
1788
+ complete,
1789
+ head: text,
1790
+ log: log.map((m) => ({ ts: String(m.ts), user: m.user ?? null, text: typeof m.text === "string" ? m.text : "" })),
1791
+ }));
1792
+ return;
1793
+ }
1794
+ if (!isPinlogHead(text)) {
1795
+ console.error(`⚠ ${pinlogId(channel, ts)} has no "${PINLOG_MARKER}" footer — showing it anyway, but it is not a pinlog HEAD.`);
1796
+ }
1797
+ console.log(`=== HEAD ${pinlogId(channel, ts)} ===`);
1798
+ for (const l of slackUnescape(text).split("\n")) console.log(stripTerminalControls(l));
1799
+ const more = complete ? "" : " — INCOMPLETE: the newest entries are not shown";
1800
+ console.log(`=== log (${log.length}${more}) ===`);
1801
+ // One lookup per author, not per entry: a board is mostly one writer.
1802
+ const authors = new Map<string, string>();
1803
+ for (const m of log) {
1804
+ const body = typeof m.text === "string" ? m.text : "";
1805
+ const author = stripTerminalControls(await pinlogAuthor(token, m, authors, cookie));
1806
+ const lines = slackUnescape(body).split("\n").map((l) => stripTerminalControls(l));
1807
+ console.log(`${tsJst(String(m.ts))} @${author}: ${lines[0] ?? ""}`);
1808
+ for (const l of lines.slice(1)) console.log(` ${l}`);
1809
+ }
1810
+ }
1811
+
1812
+ /** Boards among the last `limit` messages of a channel, newest first — found
1813
+ * by their footer, which is the only place a board is recorded. */
1814
+ async function pinlogsIn(
1815
+ token: string, channelId: string, limit: number, cookie?: string,
1816
+ ): Promise<{ found: Record<string, Json>[]; scanned: number }> {
1817
+ let scanned = 0;
1818
+ let cursor: string | undefined;
1819
+ const found: Record<string, Json>[] = [];
1820
+ do {
1821
+ const page = asRecord((await history(token, channelId, Math.min(200, limit - scanned), undefined, cursor, cookie)) as Json);
1822
+ const msgs = asArray(page.messages).map(asRecord);
1823
+ scanned += msgs.length;
1824
+ for (const m of msgs) if (typeof m.text === "string" && isPinlogHead(m.text)) found.push(m);
1825
+ const next = asRecord(page.response_metadata).next_cursor;
1826
+ cursor = typeof next === "string" && next ? next : undefined;
1827
+ } while (cursor && scanned < limit);
1828
+ return { found, scanned };
1829
+ }
1830
+
1831
+ /** A board's first non-empty state line, for one-line listings. */
1832
+ function pinlogFirstLine(text: string): string {
1833
+ return stripTerminalControls(stripPinlogFooter(text).split("\n").find((l) => l.trim()) ?? "").slice(0, 80);
1834
+ }
1835
+
1836
+ async function cmdPinlogList(token: string, target: string, limit: number, cookie?: string): Promise<void> {
1837
+ const { ref, ts } = splitRefTs(target);
1838
+ if (ts || parsePinlogId(target)) {
1839
+ console.error(`Error: \`pinlog ls\` takes a channel. For one board: slack pinlog get ${shQuote(target)}`);
1840
+ process.exit(2);
1841
+ }
1842
+ const channelId = await resolveChannel(token, ref, cookie);
1843
+ const { found, scanned } = await pinlogsIn(token, channelId, limit, cookie);
1844
+
1845
+ const dest = await destLabel(token, channelId, ref, cookie);
1846
+ if (found.length === 0) {
1847
+ // "None in what we looked at" — not "none exist". Say how far we looked.
1848
+ console.log(`No pinlogs in the last ${scanned} messages of ${dest}.${scanned >= limit ? ` (Older ones: --limit ${limit * 2})` : ""}`);
1849
+ return;
1850
+ }
1851
+ for (const m of found) {
1852
+ const ts = String(m.ts);
1853
+ const id = pinlogId(channelId, ts);
1854
+ const text = String(m.text);
1855
+ const pinned = asArray(m.pinned_to).some((c) => c === channelId) ? "📌" : " ";
1856
+ const first = pinlogFirstLine(text);
1857
+ const replies = typeof m.reply_count === "number" ? m.reply_count : 0;
1858
+ console.log(`${pinned} ${id} updated ${headUpdatedAt(text) ?? "?"} log ${replies}`);
1859
+ console.log(` ${first}`);
1860
+ }
1861
+ }
1862
+
1863
+ async function cmdPinlogPin(token: string, target: string, cookie?: string): Promise<void> {
1864
+ const { channel, ts } = await resolvePinlogTarget(token, target, cookie);
1865
+ try {
1866
+ await pinsAdd(token, channel, ts, cookie);
1867
+ console.log(`✓ Pinned ${pinlogId(channel, ts)}`);
1868
+ } catch (e: unknown) {
1869
+ const raw = e instanceof Error ? e.message : String(e);
1870
+ if (/already_pinned/.test(raw)) {
1871
+ console.log(`✓ Already pinned: ${pinlogId(channel, ts)}`);
1872
+ return;
1873
+ }
1874
+ console.error(`✗ NOT pinned: ${friendlySlackError(e)}`);
1875
+ process.exit(1);
1876
+ }
1877
+ }
1878
+
1367
1879
  // --- react ---
1368
1880
  interface ReactArgs {
1369
1881
  target: string;
@@ -4294,6 +4806,179 @@ async function main(): Promise<void> {
4294
4806
  await cmdDelete(deleteToken, args);
4295
4807
  },
4296
4808
  )
4809
+ .command(
4810
+ "pinlog",
4811
+ "Pinned status board: the pinned top-level message is the current state (edited in place, silent); each change is one thread reply (the log, notifies)",
4812
+ (y) => {
4813
+ // Every pinlog subcommand acts as ONE identity for both reads and
4814
+ // writes: the bot can only edit what the bot posted, and reading with a
4815
+ // different token than the one that will write is how a preview shows
4816
+ // a message the write then cannot touch.
4817
+ const asBotOpt = { type: "boolean", default: false, describe: "Act via the bot token (xoxb / SLACK_BOT_TOKEN). A board the bot created can only be updated --as-bot: Slack lets a token edit only its own messages." } as const;
4818
+ const pick = async (argv: W & { "as-bot"?: boolean }): Promise<{ token: string; cookie?: string; asBot: boolean }> => {
4819
+ if (argv["as-bot"]) {
4820
+ const bot = requireBotToken();
4821
+ // The bot token is SLACK_BOT_TOKEN whatever -w says. A -w naming a
4822
+ // different workspace would read and write there with the wrong
4823
+ // identity — or in the wrong workspace altogether — so refuse it.
4824
+ if (typeof argv.workspace === "string") {
4825
+ const cookie = ck(argv);
4826
+ const [u, b] = await Promise.all([authScopes(tok(argv), cookie), authScopes(bot)]);
4827
+ if (u.url !== b.url) {
4828
+ console.error(
4829
+ `Error: --as-bot acts with SLACK_BOT_TOKEN, which belongs to ${b.url}, but -w ${argv.workspace} is ${u.url}.\n` +
4830
+ " Drop -w, or set SLACK_BOT_TOKEN to that workspace's bot token.",
4831
+ );
4832
+ process.exit(2);
4833
+ }
4834
+ }
4835
+ return { token: bot, asBot: true };
4836
+ }
4837
+ const cookie = ck(argv);
4838
+ return { token: tok(argv), ...(cookie ? { cookie } : {}), asBot: false };
4839
+ };
4840
+ const fail = (e: unknown): never => {
4841
+ console.error(friendlySlackError(e));
4842
+ process.exit(1);
4843
+ };
4844
+ return y
4845
+ .command(
4846
+ "new <channel> [state] [reason]",
4847
+ "Post a NEW board in a channel and pin it — the state is the top-level message; prints the board's id (C…:ts)",
4848
+ (y2) => y2
4849
+ .positional("channel", { type: "string", demandOption: true, describe: "#chan or channel ID" })
4850
+ .positional("state", { type: "string", describe: "The whole state (\\n for newlines), or use --file" })
4851
+ .positional("reason", { type: "string", describe: "Optional: why the board exists — its first thread reply" })
4852
+ .option("file", { type: "string", describe: "Read the state from a file (- = stdin); then the only positional after <channel> is the reason" })
4853
+ .option("code", { type: "string", describe: "Safety hash to confirm (printed by the preview)" })
4854
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
4855
+ .option("as-bot", asBotOpt)
4856
+ .example('slack pinlog new "#gtm" "販売ブロッカー\\n1. 見積テンプレ — 未"', "preview, then rerun with --code"),
4857
+ async (argv) => {
4858
+ // As with `set`: with --file the state comes from the file, so the
4859
+ // one positional after <channel> is the reason.
4860
+ const fromFile = argv.file !== undefined;
4861
+ if (fromFile && argv.state !== undefined && argv.reason !== undefined) {
4862
+ console.error("Error: with --file, pass only the reason after <channel> (the state comes from the file).");
4863
+ process.exit(2);
4864
+ }
4865
+ const state = fromFile ? pinlogStateArg(undefined, argv.file) : pinlogStateArg(argv.state, undefined);
4866
+ const reason = fromFile ? argv.state : argv.reason;
4867
+ if (state === undefined) {
4868
+ console.error("Error: the state is required (positional or --file).");
4869
+ process.exit(2);
4870
+ }
4871
+ const t = await pick(argv as W & { "as-bot"?: boolean });
4872
+ const args: PinlogCreateArgs = { target: argv.channel!, state, asBot: t.asBot, allowUrlAdjacent: argv["allow-url-adjacent"] };
4873
+ if (reason !== undefined) args.log = unescapeArg(reason);
4874
+ if (t.cookie) args.cookie = t.cookie;
4875
+ if (typeof argv.workspace === "string") args.workspace = argv.workspace;
4876
+ if (argv.code) args.code = argv.code;
4877
+ await cmdPinlogCreate(t.token, args).catch(fail);
4878
+ },
4879
+ )
4880
+ .command(
4881
+ "set <board> [state] [reason]",
4882
+ "Change a board: replace its state (silent edit) AND post the reason in its thread (notifies)",
4883
+ (y2) => y2
4884
+ .positional("board", { type: "string", demandOption: true, describe: "The board: permalink, #chan:ts, or the id `new` printed" })
4885
+ .positional("state", { type: "string", describe: "The WHOLE new state, not a diff (or --file)" })
4886
+ .positional("reason", { type: "string", describe: "One short line: what changed and why (required)" })
4887
+ .option("file", { type: "string", describe: "Read the state from a file (- = stdin); then the only positional after <board> is the reason" })
4888
+ .option("adopt", { type: "boolean", default: false, describe: "Allow a message WITHOUT the pinlog footer: turn a hand-run board into one" })
4889
+ .option("code", { type: "string", describe: "Safety hash to confirm (printed by the preview)" })
4890
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
4891
+ .option("as-bot", asBotOpt)
4892
+ .example('slack pinlog set <board-link> "販売ブロッカー\\n1. 見積テンプレ — 済" "見積テンプレ: 未 → 済"', "")
4893
+ .example('slack pinlog set <board-link> --file board.md "見積テンプレ: 未 → 済"', "state from a file"),
4894
+ async (argv) => {
4895
+ // With --file the state comes from the file, so the one positional
4896
+ // after <board> is the reason.
4897
+ const fromFile = argv.file !== undefined;
4898
+ if (fromFile && argv.state !== undefined && argv.reason !== undefined) {
4899
+ console.error("Error: with --file, pass only the reason after <board> (the state comes from the file).");
4900
+ process.exit(2);
4901
+ }
4902
+ const state = fromFile ? pinlogStateArg(undefined, argv.file) : pinlogStateArg(argv.state, undefined);
4903
+ const reasonRaw = fromFile ? argv.state : argv.reason;
4904
+ if (state === undefined || reasonRaw === undefined) {
4905
+ console.error(
4906
+ "Error: `pinlog set` needs the whole new state AND the reason: slack pinlog set <board> '<state>' '<reason>'\n" +
4907
+ " Only a reason, state unchanged: slack pinlog note <board> '<reason>'",
4908
+ );
4909
+ process.exit(2);
4910
+ }
4911
+ const t = await pick(argv as W & { "as-bot"?: boolean });
4912
+ const args: PinlogUpdateArgs = {
4913
+ target: argv.board!, state, log: unescapeArg(reasonRaw), adopt: argv.adopt, asBot: t.asBot,
4914
+ allowUrlAdjacent: argv["allow-url-adjacent"],
4915
+ };
4916
+ if (typeof argv.workspace === "string") args.workspace = argv.workspace;
4917
+ if (t.cookie) args.cookie = t.cookie;
4918
+ if (argv.code) args.code = argv.code;
4919
+ await cmdPinlogUpdate(t.token, args).catch(fail);
4920
+ },
4921
+ )
4922
+ .command(
4923
+ "note <board> <reason>",
4924
+ "Post only a reason in a board's thread (notifies); the state stays as is",
4925
+ (y2) => y2
4926
+ .positional("board", { type: "string", demandOption: true, describe: "The board: permalink, #chan:ts, or the id `new` printed" })
4927
+ .positional("reason", { type: "string", demandOption: true, describe: "One short line, e.g. still blocked — chased X" })
4928
+ .option("code", { type: "string", describe: "Safety hash to confirm (printed by the preview)" })
4929
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
4930
+ .option("as-bot", asBotOpt),
4931
+ async (argv) => {
4932
+ const t = await pick(argv as W & { "as-bot"?: boolean });
4933
+ const args: PinlogUpdateArgs = {
4934
+ target: argv.board!, log: unescapeArg(argv.reason!), logOnly: true, asBot: t.asBot,
4935
+ allowUrlAdjacent: argv["allow-url-adjacent"],
4936
+ };
4937
+ if (typeof argv.workspace === "string") args.workspace = argv.workspace;
4938
+ if (t.cookie) args.cookie = t.cookie;
4939
+ if (argv.code) args.code = argv.code;
4940
+ await cmdPinlogUpdate(t.token, args).catch(fail);
4941
+ },
4942
+ )
4943
+ .command(
4944
+ ["get <board>", "show <board>"],
4945
+ "Show a board's state and its log (read-only)",
4946
+ (y2) => y2
4947
+ .positional("board", { type: "string", demandOption: true, describe: "The board: permalink, #chan:ts, or C…:ts" })
4948
+ .option("json", { type: "boolean", default: false, describe: "One JSON object: {id, isPinlog, updated, head, log[]}" })
4949
+ .option("as-bot", asBotOpt),
4950
+ async (argv) => {
4951
+ const t = await pick(argv as W & { "as-bot"?: boolean });
4952
+ await cmdPinlogShow(t.token, argv.board!, t.cookie, argv.json).catch(fail);
4953
+ },
4954
+ )
4955
+ .command(
4956
+ ["ls <channel>", "list <channel>"],
4957
+ "List the boards in a channel, found by their footer (read-only)",
4958
+ (y2) => y2
4959
+ .positional("channel", { type: "string", demandOption: true })
4960
+ .option("limit", { alias: "n", type: "number", default: 500, describe: "How many recent channel messages to scan" })
4961
+ .option("as-bot", asBotOpt),
4962
+ async (argv) => {
4963
+ const t = await pick(argv as W & { "as-bot"?: boolean });
4964
+ await cmdPinlogList(t.token, argv.channel!, argv.limit, t.cookie).catch(fail);
4965
+ },
4966
+ )
4967
+ .command(
4968
+ "pin <board>",
4969
+ false as unknown as string, // recovery only: `new` prints it when pinning failed
4970
+ (y2) => y2
4971
+ .positional("board", { type: "string", demandOption: true })
4972
+ .option("as-bot", asBotOpt),
4973
+ async (argv) => {
4974
+ const t = await pick(argv as W & { "as-bot"?: boolean });
4975
+ await cmdPinlogPin(t.token, argv.board!, t.cookie).catch(fail);
4976
+ },
4977
+ )
4978
+ .demandCommand(1, "Pick one: ls | get | new | set | note");
4979
+ },
4980
+ () => {},
4981
+ )
4297
4982
  .command(
4298
4983
  "react <target> <emoji>",
4299
4984
  "Add (or --remove) an emoji reaction — a lightweight ack that doesn't grow the thread",