slack-term 1.26.7 → 1.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ts/cli.ts CHANGED
@@ -13,6 +13,14 @@ import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
13
13
  import { cmdAuthLogin, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp } from "./auth.ts";
14
14
  import { cmdTail } from "./tail.ts";
15
15
 
16
+ import {
17
+ ASK_KEYCAPS,
18
+ ASK_MAX_REACTION_CHOICES,
19
+ askBuildText,
20
+ askBuildResolvedText,
21
+ askParseMessage,
22
+ type AskFound,
23
+ } from "./ask.ts";
16
24
  import {
17
25
  authTest,
18
26
  authScopes,
@@ -1519,6 +1527,634 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1519
1527
  }
1520
1528
  }
1521
1529
 
1530
+ // --- ask ---
1531
+ //
1532
+ // Post a question with its choices PRE-SEEDED as 1️⃣..🔟 reactions, so answering
1533
+ // costs one tap on an existing pill instead of a trip through the emoji picker.
1534
+ //
1535
+ // Reactions, not Block Kit buttons: `block_actions` are delivered only to a
1536
+ // Request URL or a Socket Mode socket, neither of which a short-lived CLI can
1537
+ // own without losing clicks. A reaction is durable state — any number of
1538
+ // concurrent waiters can read it back from `conversations.history`, repeatedly,
1539
+ // long after it was pressed.
1540
+ //
1541
+ // Exit codes are the contract for `--wait`, and a non-zero one must never be
1542
+ // mistakable for "an answer arrived":
1543
+ // 0 answered (the answer text is on stdout, and nothing else is)
1544
+ // 2 timed out
1545
+ // 3 transport/config failure
1546
+ // Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
1547
+
1548
+ const ASK_EXIT_TIMEOUT = 2;
1549
+ const ASK_EXIT_ERROR = 3;
1550
+
1551
+ const ASK_PHI = 1.618033988749895;
1552
+ const ASK_POLL_MIN_MS = 1000;
1553
+ const ASK_POLL_MAX_MS = 5000;
1554
+ const ASK_MAX_CONSECUTIVE_ERRORS = 8;
1555
+
1556
+
1557
+ interface AskArgs {
1558
+ target: string;
1559
+ question: string;
1560
+ choices: string[];
1561
+ body?: string;
1562
+ code?: string;
1563
+ channelId?: string;
1564
+ userId?: string;
1565
+ asBot?: boolean;
1566
+ wait?: boolean;
1567
+ timeout?: number;
1568
+ cookie?: string;
1569
+ // Token used to resolve the @tags that decide who may answer (needs
1570
+ // users:read). Defaults to the ask token; set to the user token under
1571
+ // --as-bot so the bot token need not carry users:read. There is no
1572
+ // --no-mentions here: the tags ARE the authorization, so leaving them as
1573
+ // plain text would leave the question addressed to nobody.
1574
+ mentionToken?: string;
1575
+ mentionCookie?: string;
1576
+ }
1577
+
1578
+
1579
+ const askSleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
1580
+
1581
+ // A thread reply sent with "also send to channel" ticked arrives as a normal
1582
+ // message carrying this subtype. Dropping every subtype would discard those —
1583
+ // and where thread replies are the only text path, that means discarding real
1584
+ // answers.
1585
+ const ASK_ANSWERABLE_SUBTYPES = new Set(["thread_broadcast"]);
1586
+
1587
+ // `@here` / `@channel` / `@everyone` — Slack's channel-wide tags. Same lookbehind
1588
+ // guard as the user-mention regex (skips emails, `<!here>` already encoded, `@@`),
1589
+ // and a lookahead rather than `\b` so a CJK character right after the tag still
1590
+ // ends it (`\b` is unreliable outside ASCII).
1591
+ const ASK_BROADCAST_RE = /(?<![A-Za-z0-9._@<-])@(here|channel|everyone)(?![A-Za-z0-9._-])/giu;
1592
+
1593
+ /** Rewrite `@here`/`@channel`/`@everyone` to the `<!here>` form Slack actually
1594
+ * broadcasts on, and report which were used. Run BEFORE user-mention encoding
1595
+ * so those tokens are never offered to the directory as if they were handles. */
1596
+ function askEncodeBroadcasts(s: string): { text: string; kinds: Set<string> } {
1597
+ const kinds = new Set<string>();
1598
+ const text = s.replace(ASK_BROADCAST_RE, (_m, k: string) => {
1599
+ const kind = k.toLowerCase();
1600
+ kinds.add(kind);
1601
+ return `<!${kind}>`;
1602
+ });
1603
+ return { text, kinds };
1604
+ }
1605
+
1606
+ /** The other party in a 1:1 DM, or undefined for anything else (including a
1607
+ * failed lookup). Fail-soft on purpose, but note the caller treats "unknown"
1608
+ * as "no implicit audience" — fail-closed on WHO MAY ANSWER, which is the
1609
+ * direction that cannot silently hand a decision to the wrong person. */
1610
+ async function imCounterpart(token: string, channelId: string, cookie?: string): Promise<string | undefined> {
1611
+ if (!channelId.startsWith("D")) return undefined;
1612
+ try {
1613
+ const info = asRecord((await conversationInfo(token, channelId, cookie)) as Json);
1614
+ const ch = asRecord(info.channel);
1615
+ return ch.is_im === true && typeof ch.user === "string" ? ch.user : undefined;
1616
+ } catch {
1617
+ return undefined;
1618
+ }
1619
+ }
1620
+
1621
+ /** Everything the answer poller needs. `--wait` fills it in from what it just
1622
+ * posted; `--waitFor` recovers the same fields from the message itself. */
1623
+ interface AskWaitCtx {
1624
+ channelId: string;
1625
+ ts: string;
1626
+ /** Set only when the question is itself a thread reply. Such a message never
1627
+ * appears in `conversations.history`, so the whole poll has to run through
1628
+ * `conversations.replies` instead. */
1629
+ threadParentTs?: string;
1630
+ question: string;
1631
+ reactable: string[];
1632
+ threadOnly: boolean;
1633
+ /** Who may answer. Empty + broadcast=false would accept nobody, which is why
1634
+ * posting an unaddressed question is refused up front. */
1635
+ audience: Set<string>;
1636
+ broadcast: boolean;
1637
+ /** The asker as the POSTED MESSAGE shows them — not the current token. On
1638
+ * resume the two can differ, and taking the current identity would count the
1639
+ * original asker's seeds as answers. */
1640
+ askerUserId: string;
1641
+ askerBotId: string;
1642
+ timeout: number;
1643
+ cookie?: string;
1644
+ shown: string;
1645
+ /** Only so the resume hint printed on timeout names the right identity — the
1646
+ * ✅ rewrite can be done by the original poster alone. */
1647
+ asBot: boolean;
1648
+ }
1649
+
1650
+ /** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
1651
+ * on timeout. A timeout of 0 means "look exactly once", so a caller can poll
1652
+ * cheaply on its own schedule instead of parking a process on `--wait`. */
1653
+ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never> {
1654
+ const { channelId, ts, question, reactable, threadOnly, audience, broadcast, cookie, timeout, shown } = ctx;
1655
+ const askerUserId = ctx.askerUserId;
1656
+ const askerBotId = ctx.askerBotId;
1657
+
1658
+ // Only the people the question actually addresses. Excluding the asker is what
1659
+ // stops a seed being counted as an answer; restricting to `audience` is what
1660
+ // stops a bystander in a busy channel from deciding it. `@here`/`@channel`
1661
+ // deliberately opens it to everyone — that is what the asker asked for.
1662
+ const isAnswerer = (user: unknown): user is string =>
1663
+ typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
1664
+
1665
+ /** Every seed an answerer is on — not just the first. Changing your mind
1666
+ * leaves BOTH reactions in place (Slack only drops one when you actively
1667
+ * un-react), so taking the lowest index would answer with the abandoned
1668
+ * choice. Array order cannot break the tie either: it is add-order, and we
1669
+ * added every seed before anyone touched it. */
1670
+ function humanChoices(msg: Record<string, Json>): { index: number; users: string[] }[] {
1671
+ const reactions = asArray(msg.reactions).map(asRecord);
1672
+ const out: { index: number; users: string[] }[] = [];
1673
+ for (let i = 0; i < reactable.length; i++) {
1674
+ const r = reactions.find((x) => x.name === ASK_KEYCAPS[i]!.name);
1675
+ const users = r ? asArray(r.users).filter(isAnswerer) : [];
1676
+ if (users.length) out.push({ index: i, users });
1677
+ }
1678
+ return out;
1679
+ }
1680
+
1681
+ // Two reactions means two answers, and this command exists for decisions that
1682
+ // are expensive to get wrong — so an ambiguous state is never resolved by
1683
+ // guessing. Say so in the thread and keep waiting; removing one reaction (or
1684
+ // replying with text) settles it on the next poll.
1685
+ let ambiguityNotified = false;
1686
+
1687
+ async function answerFromReactions(msg: Record<string, Json>): Promise<AskFound | null> {
1688
+ const picks = humanChoices(msg);
1689
+ if (!picks.length) return null;
1690
+ if (picks.length === 1) {
1691
+ const { index, users } = picks[0]!;
1692
+ return { answer: reactable[index]!, how: `リアクション ${ASK_KEYCAPS[index]!.glyph}`, who: users[0]! };
1693
+ }
1694
+ const glyphs = picks.map((p) => ASK_KEYCAPS[p.index]!.glyph).join(" / ");
1695
+ if (!ambiguityNotified) {
1696
+ ambiguityNotified = true;
1697
+ console.error(` (${glyphs} が同時に選ばれています。1 つに絞ってもらうまで待ちます)`);
1698
+ try {
1699
+ await slackSend(token, channelId, `${glyphs} が同時に選ばれています。回答を確定できないので、1 つだけ残るようにリアクションを外すか、このスレッドで返信してください。`, ts, false, cookie);
1700
+ } catch (e: unknown) {
1701
+ console.error(` (確認の返信を送れませんでした: ${e instanceof Error ? e.message : String(e)})`);
1702
+ }
1703
+ }
1704
+ return null;
1705
+ }
1706
+
1707
+ function answerFromMessages(messages: Record<string, Json>[], before = Infinity): AskFound | null {
1708
+ // Oldest first: if someone wrote twice, the first reply is the answer.
1709
+ const ordered = [...messages].sort((a, b) => Number(a.ts) - Number(b.ts));
1710
+ for (const m of ordered) {
1711
+ // Strictly after the question. A question posted INTO an existing thread
1712
+ // sits among replies that predate it, and those answered something else.
1713
+ if (!(Number(m.ts) > Number(ts))) continue;
1714
+ if (!isAnswerer(m.user)) continue;
1715
+ if (typeof m.subtype === "string" && !ASK_ANSWERABLE_SUBTYPES.has(m.subtype)) continue;
1716
+ if (Number(m.ts) >= before) break;
1717
+ const t = typeof m.text === "string" ? m.text.trim() : "";
1718
+ if (t) return { answer: t, how: "返信", who: m.user };
1719
+ }
1720
+ return null;
1721
+ }
1722
+
1723
+ async function poll(): Promise<AskFound | null> {
1724
+ // A question that is itself a thread reply is invisible to
1725
+ // `conversations.history`, so the entire poll — reactions included — has to
1726
+ // come from the thread.
1727
+ if (ctx.threadParentTs) {
1728
+ const rep = asRecord((await replies(token, channelId, ctx.threadParentTs, 100, cookie)) as Json);
1729
+ const messages = asArray(rep.messages).map(asRecord);
1730
+ const own = messages.find((m) => m.ts === ts);
1731
+ if (own) {
1732
+ const byReaction = await answerFromReactions(own);
1733
+ if (byReaction) return byReaction;
1734
+ }
1735
+ return answerFromMessages(messages);
1736
+ }
1737
+
1738
+ // inclusive: `oldest` excludes the message at that exact ts, and that
1739
+ // message IS the question — without this the reactions and reply_count we
1740
+ // are polling for never come back at all.
1741
+ const hist = asRecord((await history(token, channelId, 30, ts, undefined, cookie, true)) as Json);
1742
+ const messages = asArray(hist.messages).map(asRecord);
1743
+ const own = messages.find((m) => m.ts === ts);
1744
+
1745
+ // A reaction is the intended path, so it wins when both are present.
1746
+ if (own) {
1747
+ const byReaction = await answerFromReactions(own);
1748
+ if (byReaction) return byReaction;
1749
+ }
1750
+
1751
+ if (!threadOnly) {
1752
+ // A plain DM reply is ambiguous only while several questions are open at
1753
+ // once: it belongs to whichever was newest when it was written. Bound the
1754
+ // search by the ts of our NEXT question in this conversation, so an older
1755
+ // waiter falls back to reactions/thread replies and never steals the
1756
+ // newer one's answer.
1757
+ const nextQuestionTs = messages
1758
+ .filter((m) => Number(m.ts) > Number(ts) && (
1759
+ (askerBotId && m.bot_id === askerBotId) || (askerUserId && m.user === askerUserId)))
1760
+ .reduce((min, m) => Math.min(min, Number(m.ts)), Infinity);
1761
+ const byMessage = answerFromMessages(messages, nextQuestionTs);
1762
+ if (byMessage) return byMessage;
1763
+ }
1764
+
1765
+ // Thread replies never appear in history; only pay for the extra call once
1766
+ // the parent says there is something to fetch.
1767
+ if (own && Number(own.reply_count) > 0) {
1768
+ const rep = asRecord((await replies(token, channelId, ts, 30, cookie)) as Json);
1769
+ const byThread = answerFromMessages(asArray(rep.messages).map(asRecord));
1770
+ if (byThread) return byThread;
1771
+ }
1772
+ return null;
1773
+ }
1774
+
1775
+ /** Stamp the question resolved. Removing our seeds clears the pills that still
1776
+ * invite an answer; the answerer's own reaction cannot be removed by us and
1777
+ * stays put — which is exactly what leaves the chosen number visible. */
1778
+ async function markResolved(found: AskFound): Promise<void> {
1779
+ for (let i = 0; i < reactable.length; i++) {
1780
+ try {
1781
+ await reactionRemove(token, channelId, ts, ASK_KEYCAPS[i]!.name, cookie);
1782
+ } catch {
1783
+ // Already removed, or someone else's reaction — neither is fatal.
1784
+ }
1785
+ }
1786
+ let who = "";
1787
+ if (found.who) {
1788
+ try {
1789
+ who = await userName(token, found.who, cookie);
1790
+ } catch {
1791
+ who = found.who;
1792
+ }
1793
+ }
1794
+ // Who answered never reaches stdout — callers do ANS=$(slack ask --wait ...)
1795
+ // and must keep getting the answer body alone.
1796
+ if (who) console.error(` 回答者: ${stripTerminalControls(who)}`);
1797
+ try {
1798
+ await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who), cookie);
1799
+ } catch (e: unknown) {
1800
+ // The edit is bookkeeping, not the answer. It fails whenever the resuming
1801
+ // token is not the one that posted (Slack lets only the author update), and
1802
+ // that must not cost the caller an answer it already has.
1803
+ console.error(` (元メッセージの更新に失敗: ${e instanceof Error ? e.message : String(e)})`);
1804
+ }
1805
+ }
1806
+
1807
+ const deadline = Date.now() + timeout * 1000;
1808
+ let errors = 0;
1809
+ let delay = ASK_POLL_MIN_MS;
1810
+ for (;;) {
1811
+ let found: AskFound | null = null;
1812
+ try {
1813
+ found = await poll();
1814
+ errors = 0;
1815
+ } catch (e: unknown) {
1816
+ // A dropped poll is expected occasionally; only give up if it keeps failing.
1817
+ errors++;
1818
+ if (errors >= ASK_MAX_CONSECUTIVE_ERRORS) {
1819
+ console.error(`Error: Slack への問い合わせに ${errors} 回連続で失敗しました: ${e instanceof Error ? e.message : String(e)}`);
1820
+ process.exit(ASK_EXIT_ERROR);
1821
+ }
1822
+ console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
1823
+ }
1824
+ if (found) {
1825
+ await markResolved(found);
1826
+ // stdout gets the answer and nothing else.
1827
+ console.log(found.answer);
1828
+ process.exit(0);
1829
+ }
1830
+ // Checked after the first poll, never before it: `--timeout 0` means one
1831
+ // look, and one look must still be a real look.
1832
+ if (Date.now() >= deadline) break;
1833
+ await askSleep(Math.min(delay, Math.max(0, deadline - Date.now())));
1834
+ delay = Math.min(delay * ASK_PHI, ASK_POLL_MAX_MS);
1835
+ }
1836
+
1837
+ // A timeout is the moment the caller most needs the collect command: the
1838
+ // question is still standing, and they have just decided not to block on it.
1839
+ // Printing only the permalink here leaves them where `ask` used to: holding a
1840
+ // link with nothing that reads a pressed pill back.
1841
+ if (timeout === 0) {
1842
+ console.error(`まだ回答がありません: ${shown}`);
1843
+ } else {
1844
+ console.error(`Error: ${timeout}s 以内に回答がありませんでした (メッセージはそのまま残っています)`);
1845
+ console.error(` ${shown}`);
1846
+ console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot)}`);
1847
+ }
1848
+ process.exit(ASK_EXIT_TIMEOUT);
1849
+ }
1850
+
1851
+ /** The `slack ask --waitFor=…` line handed to a caller that did not block.
1852
+ * Printed as a runnable command rather than a bare permalink: the collect step
1853
+ * is the half everyone forgets, and a link does not tell you how to collect. */
1854
+ function askResumeCommand(shown: string, asBot: boolean): string {
1855
+ return `slack ask --waitFor='${shown}'${asBot ? " --as-bot" : ""}`;
1856
+ }
1857
+
1858
+ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
1859
+ const { ref, threadTs } = parseTargetThread(args.target);
1860
+ const cookie = args.cookie;
1861
+ const getSelf = selfLookup(token, cookie);
1862
+
1863
+ let channelId: string;
1864
+ if (args.channelId) channelId = args.channelId;
1865
+ else if (args.userId) channelId = await openDm(token, args.userId, cookie);
1866
+ else channelId = await resolveChannel(token, ref, cookie);
1867
+
1868
+ const selfId = (await getSelf())?.userId ?? "";
1869
+ const counterpart = await imCounterpart(token, channelId, cookie);
1870
+
1871
+ // Same silent-delivery footgun as `send`: Slack never notifies you about your
1872
+ // own message, so a question asked in your own DM is delivered and then never
1873
+ // seen — and `--wait` would sit there until it timed out.
1874
+ if (!args.asBot && !!selfId && counterpart === selfId) {
1875
+ console.error(
1876
+ `Warning: "${args.target}" is a DM to yourself — Slack will NOT notify you of your own message.\n` +
1877
+ ` To reach yourself with a notification, ask as the bot: slack ask '${args.target}' '...' --as-bot`,
1878
+ );
1879
+ }
1880
+
1881
+ // --- who may answer -------------------------------------------------------
1882
+ //
1883
+ // A question with no addressee is the actual failure mode this guards: post it
1884
+ // into a busy channel and the first person to react has decided something that
1885
+ // was never theirs to decide. So the audience is taken from the @tags in the
1886
+ // text — the same names the reader sees — and an untargeted ask is refused
1887
+ // outright rather than defaulted to "whoever gets there first".
1888
+ const qEnc = askEncodeBroadcasts(args.question);
1889
+ const bEnc = askEncodeBroadcasts(args.body ?? "");
1890
+ const broadcastKinds = new Set([...qEnc.kinds, ...bEnc.kinds]);
1891
+ const mentionToken = args.mentionToken ?? token;
1892
+ const qRep = await encodeMentionsDetailed(mentionToken, qEnc.text, channelId, args.mentionCookie);
1893
+ const bRep = bEnc.text
1894
+ ? await encodeMentionsDetailed(mentionToken, bEnc.text, channelId, args.mentionCookie)
1895
+ : { text: "", resolved: [], unresolved: [] } as MentionEncodeResult;
1896
+ const question = qRep.text;
1897
+ const body = bRep.text;
1898
+ const resolved = [...qRep.resolved, ...bRep.resolved];
1899
+ const unresolved = [...qRep.unresolved, ...bRep.unresolved];
1900
+ // An unresolved tag is not just cosmetic here: it names nobody, so it grants
1901
+ // nobody the right to answer. Say so before the rejection below.
1902
+ for (const line of mentionWarnings(unresolved)) console.error(`⚠ ${stripTerminalControls(line)}`);
1903
+
1904
+ const broadcast = broadcastKinds.size > 0;
1905
+ // Tagging yourself grants nothing — your own reactions are the seeds.
1906
+ const audience = new Set(resolved.map((r) => r.userId).filter((id) => id !== selfId));
1907
+ const audienceNames = new Map(resolved.map((r) => [r.userId, r.display]));
1908
+ let audienceLabel: string;
1909
+ if (broadcast) {
1910
+ audienceLabel = `anyone in ${ref} (${[...broadcastKinds].map((k) => `@${k}`).join(", ")})`;
1911
+ } else {
1912
+ // 1:1 DM: the other party IS the audience — there is no one else it could
1913
+ // be, so requiring a redundant @tag would buy no clarity. Only here; a group
1914
+ // DM or channel still has to name someone.
1915
+ if (!audience.size && counterpart && counterpart !== selfId) {
1916
+ audience.add(counterpart);
1917
+ audienceNames.set(counterpart, await userName(token, counterpart, cookie));
1918
+ }
1919
+ if (!audience.size) {
1920
+ console.error(
1921
+ `Error: \`ask\` will not post a question nobody is addressed to — no one is tagged.\n` +
1922
+ (unresolved.length
1923
+ ? ` (${unresolved.map((u) => stripTerminalControls(u.surface)).join(", ")} matched no one, so it names nobody.)\n`
1924
+ : "") +
1925
+ ` Tag the person who should answer: slack ask '${args.target}' '@alice ${args.question}' ...\n` +
1926
+ ` Or open it to the whole channel: slack ask '${args.target}' '@here ${args.question}' ...\n` +
1927
+ ` (In a 1:1 DM the other person counts automatically.)`,
1928
+ );
1929
+ process.exit(ASK_EXIT_ERROR);
1930
+ }
1931
+ audienceLabel = [...audience]
1932
+ .map((id) => `@${stripTerminalControls(audienceNames.get(id) ?? id)} (${id})`)
1933
+ .join(", ");
1934
+ }
1935
+
1936
+ const reactable = args.choices.slice(0, ASK_MAX_REACTION_CHOICES);
1937
+ const overflow = args.choices.slice(ASK_MAX_REACTION_CHOICES);
1938
+
1939
+ // Where free-text answers may come from. A 1:1 DM carries no unrelated
1940
+ // traffic, so a plain reply there is an answer. A channel does, so only
1941
+ // reactions and thread replies count — both name the message they belong to,
1942
+ // which makes channel mode unambiguous by construction. A question posted
1943
+ // INTO a thread is thread-scoped for the same reason.
1944
+ const threadOnly = !channelId.startsWith("D") || !!threadTs;
1945
+ const message = askBuildText(question, body, reactable, overflow, threadOnly);
1946
+
1947
+ // Preview the destination's last message, exactly as `send` does — the gate's
1948
+ // job is to make you look at where this is going before it goes. Fail-soft:
1949
+ // a token without history scope can still ask; only the preview degrades.
1950
+ let lastText = "";
1951
+ let lastUser = "?";
1952
+ try {
1953
+ const ctx = (await history(token, channelId, 1, undefined, undefined, cookie)) as Record<string, Json>;
1954
+ const lastMsg = asArray(ctx.messages).map(asRecord).filter((m) => m.subtype === undefined || m.subtype === null)[0];
1955
+ lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
1956
+ lastUser = typeof lastMsg?.user === "string"
1957
+ ? await userName(token, lastMsg.user, cookie)
1958
+ : typeof lastMsg?.username === "string" ? lastMsg.username : "?";
1959
+ } catch {
1960
+ // best-effort preview only
1961
+ }
1962
+
1963
+ // Same gate as `send`, deliberately: 追記 4 settled that the two-step code is
1964
+ // not "a human in the loop" but "look again before committing", which a script
1965
+ // satisfies by calling twice. The hash binds destination, thread, the exact
1966
+ // posted body (question AND choices) and the asking identity.
1967
+ const self = await getSelf();
1968
+ const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "");
1969
+ if (args.code !== code) {
1970
+ const dest = await destLabel(token, channelId, ref, cookie);
1971
+ const choiceLines = reactable.length
1972
+ ? [`--- Choices (seeded as reactions) ------------`,
1973
+ ...reactable.map((s, i) => ` ${ASK_KEYCAPS[i]!.glyph} ${s}`),
1974
+ ...overflow.map((s, i) => ` (${i + 11}) ${s} — text answer only`)]
1975
+ : [`--- Choices ----------------------------------`, ` (none — free-text reply)`];
1976
+ requireCode(args.code, code, [
1977
+ `--- Last message in channel ------------------`,
1978
+ ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
1979
+ ...choiceLines,
1980
+ `--- Asking -----------------------------------`,
1981
+ fromLine(self, { asBot: args.asBot }),
1982
+ ` → ${dest}${threadTs ? " — THREAD REPLY" : " — NEW top-level message"}`,
1983
+ ` Question: ${question}`,
1984
+ // The audience is the part that decides whose answer is binding, so it is
1985
+ // shown as its own line rather than left to be inferred from the body.
1986
+ ` Answerable by: ${audienceLabel}`,
1987
+ ` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
1988
+ `--------------------------------────────────`,
1989
+ ]);
1990
+ }
1991
+
1992
+ const ts = await slackSend(token, channelId, message, threadTs, false, cookie);
1993
+
1994
+ // Seeded one at a time and in order: Slack keeps reactions in the order they
1995
+ // were added, so sequential adds are what make the pills read 1,2,3 rather
1996
+ // than shuffled. A parallel add is not an optimization here, it is a bug.
1997
+ for (let i = 0; i < reactable.length; i++) {
1998
+ try {
1999
+ await reactionAdd(token, channelId, ts, ASK_KEYCAPS[i]!.name, cookie);
2000
+ } catch (e: unknown) {
2001
+ // A missing seed only costs the answerer a trip to the emoji picker, so
2002
+ // warn and keep going rather than abandon a question already posted.
2003
+ console.error(` (リアクション ${ASK_KEYCAPS[i]!.name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`);
2004
+ }
2005
+ }
2006
+
2007
+ let permalink = "";
2008
+ try {
2009
+ permalink = await getPermalink(token, channelId, ts, cookie);
2010
+ } catch {
2011
+ // fail-soft, same as send: a permalink lookup failure must not mask success
2012
+ }
2013
+ const shown = permalink || `${channelId}:${ts}`;
2014
+ console.error(`✓ Asked: ${shown}`);
2015
+
2016
+ if (!args.wait) {
2017
+ // Not waiting. stdout is the RESUME COMMAND, not just a link: nothing else
2018
+ // reads a pressed pill back — `tail` only sees `type: "message"` events and
2019
+ // drops `message_changed`, so both the reaction and the ✅ rewrite are
2020
+ // invisible to it. A question posted with no way to collect it is how an
2021
+ // answer gets pressed and nobody ever hears about it.
2022
+ console.log(askResumeCommand(shown, !!args.asBot));
2023
+ return;
2024
+ }
2025
+
2026
+ const timeout = args.timeout ?? 3600;
2027
+ console.error(` 回答を待っています (最大 ${timeout}s)… Ctrl-C で中断`);
2028
+
2029
+ const ctx: AskWaitCtx = {
2030
+ channelId,
2031
+ ts,
2032
+ question,
2033
+ reactable,
2034
+ threadOnly,
2035
+ audience,
2036
+ broadcast,
2037
+ askerUserId: self?.userId ?? "",
2038
+ askerBotId: self?.botId ?? "",
2039
+ timeout,
2040
+ shown,
2041
+ asBot: !!args.asBot,
2042
+ };
2043
+ if (threadTs) ctx.threadParentTs = threadTs;
2044
+ if (cookie) ctx.cookie = cookie;
2045
+ await askWaitForAnswer(token, ctx);
2046
+ }
2047
+
2048
+
2049
+
2050
+ /** `slack ask --waitFor <permalink>` — collect the answer to a question that was
2051
+ * posted without blocking.
2052
+ *
2053
+ * Nothing about the question is stored locally, so everything the poller needs
2054
+ * is read back out of the message itself. That is a deliberate trade: no state
2055
+ * file to go stale, lose, or disagree with Slack, and any machine holding the
2056
+ * permalink can collect — at the cost of the body being a parseable format
2057
+ * (`askBuildText` ⇄ `askParseMessage`). */
2058
+ async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; cookie?: string }): Promise<void> {
2059
+ const url = parseSlackPermalink(args.link);
2060
+ let channelId: string;
2061
+ let ts: string;
2062
+ let threadTs: string | undefined;
2063
+ if (url?.ts) {
2064
+ channelId = url.channel;
2065
+ ts = url.ts;
2066
+ threadTs = url.threadTs && url.threadTs !== url.ts ? url.threadTs : undefined;
2067
+ } else {
2068
+ // `C…:1700000000.000100` — the fallback `ask` prints when the permalink
2069
+ // lookup itself failed, so it has to be collectable too.
2070
+ const m = args.link.match(/^([A-Za-z0-9]+):(\d{10}\.\d{6})$/);
2071
+ if (!m) {
2072
+ console.error(
2073
+ `Error: --waitFor needs the permalink of a question (or 'C…:1700000000.000100').\n` +
2074
+ ` Got: ${stripTerminalControls(args.link)}`,
2075
+ );
2076
+ process.exit(ASK_EXIT_ERROR);
2077
+ }
2078
+ channelId = m[1]!;
2079
+ ts = m[2]!;
2080
+ }
2081
+
2082
+ // Fetch the one message. A question posted into a thread is not in history at
2083
+ // all, so where the permalink says it is a reply, read it from the thread.
2084
+ let msg: Record<string, Json> | undefined;
2085
+ if (threadTs) {
2086
+ const rep = asRecord((await replies(token, channelId, threadTs, 100, args.cookie)) as Json);
2087
+ msg = asArray(rep.messages).map(asRecord).find((m) => m.ts === ts);
2088
+ } else {
2089
+ const hist = asRecord((await history(token, channelId, 1, ts, undefined, args.cookie, true)) as Json);
2090
+ msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
2091
+ }
2092
+ if (!msg) {
2093
+ console.error(`Error: そのメッセージが見つかりません: ${stripTerminalControls(args.link)}`);
2094
+ process.exit(ASK_EXIT_ERROR);
2095
+ }
2096
+
2097
+ const text = typeof msg.text === "string" ? msg.text : "";
2098
+ const parsed = askParseMessage(text);
2099
+ if (parsed.kind === "other") {
2100
+ console.error(
2101
+ `Error: これは \`slack ask\` の質問ではありません (回答の読み取り方が分かりません)。\n` +
2102
+ ` ${stripTerminalControls(args.link)}`,
2103
+ );
2104
+ process.exit(ASK_EXIT_ERROR);
2105
+ }
2106
+ if (parsed.kind === "resolved") {
2107
+ // Answered while nobody was watching — the case that makes fire-and-forget
2108
+ // safe. Report it exactly as `--wait` would have.
2109
+ console.error(`✓ 回答済み: ${stripTerminalControls(parsed.question)}`);
2110
+ console.log(parsed.answer);
2111
+ return;
2112
+ }
2113
+
2114
+ // Who may answer, recovered from the tags the readers can see. They are
2115
+ // already `<@U…>`-encoded in the stored text, so no directory lookup is
2116
+ // needed — and the asker is excluded here for the same reason as when
2117
+ // posting: their tag of themselves grants nothing, and their reactions are
2118
+ // the seeds.
2119
+ const askerUserId = typeof msg.user === "string" ? msg.user : "";
2120
+ const askerBotId = typeof msg.bot_id === "string" ? msg.bot_id : "";
2121
+ const broadcast = /<!(here|channel|everyone)(\^[^>]*)?(\|[^>]*)?>/.test(text);
2122
+ const audience = new Set<string>();
2123
+ for (const m of text.matchAll(/<@([UW][A-Z0-9]+)>/g)) {
2124
+ if (m[1] !== askerUserId) audience.add(m[1]!);
2125
+ }
2126
+ if (!audience.size && !broadcast) {
2127
+ // A 1:1 DM needs no tag — the other party is the only person it could be.
2128
+ const counterpart = await imCounterpart(token, channelId, args.cookie);
2129
+ if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
2130
+ }
2131
+ if (!audience.size && !broadcast) {
2132
+ console.error(`Error: この質問は誰にも宛てられていないため、有効な回答者を判定できません: ${stripTerminalControls(args.link)}`);
2133
+ process.exit(ASK_EXIT_ERROR);
2134
+ }
2135
+
2136
+ if (args.timeout > 0) {
2137
+ console.error(` 回答を待っています (最大 ${args.timeout}s)… Ctrl-C で中断`);
2138
+ }
2139
+ const ctx: AskWaitCtx = {
2140
+ channelId,
2141
+ ts,
2142
+ question: parsed.question,
2143
+ reactable: parsed.reactable,
2144
+ threadOnly: parsed.threadOnly,
2145
+ audience,
2146
+ broadcast,
2147
+ askerUserId,
2148
+ askerBotId,
2149
+ timeout: args.timeout,
2150
+ shown: args.link,
2151
+ asBot: args.asBot,
2152
+ };
2153
+ if (threadTs) ctx.threadParentTs = threadTs;
2154
+ if (args.cookie) ctx.cookie = args.cookie;
2155
+ await askWaitForAnswer(token, ctx);
2156
+ }
2157
+
1522
2158
  async function cmdDoctor(token: string): Promise<void> {
1523
2159
  const diag = await diagnoseBotMessaging(token);
1524
2160
  for (const line of formatDiagnosis(diag, false)) console.log(line);
@@ -2308,6 +2944,131 @@ async function main(): Promise<void> {
2308
2944
  }
2309
2945
  },
2310
2946
  )
2947
+ .command(
2948
+ "ask [target] [question] [choices..]",
2949
+ "Ask a question with its choices pre-seeded as 1️⃣..🔟 reactions (confirm-hash safety gate)",
2950
+ (y) => y
2951
+ .positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --waitFor)" })
2952
+ .positional("question", { type: "string", describe: "The question. Must @tag whoever may answer (@alice), or the whole channel (@here / @channel / @everyone) — only their answer counts. In a 1:1 DM the other party counts automatically." })
2953
+ .positional("choices", { type: "string", array: true, describe: "Up to 10 choices, seeded as 1️⃣..🔟 reactions. Beyond 10 they are listed but answerable only by text. With none, the question asks for a free-text reply." })
2954
+ .option("code", { type: "string", describe: "Safety hash to confirm the ask" })
2955
+ .option("body", { type: "string", describe: "Extra context shown under the question" })
2956
+ .option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = timed out, 3 = transport failure." })
2957
+ .option("waitFor", { type: "string", describe: "Collect the answer to a question already posted: pass its permalink. Nothing is posted. Same stdout/exit contract as --wait; --timeout 0 checks once and exits 2 if still open." })
2958
+ .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
2959
+ .option("channel-id", { type: "string", describe: "Raw channel ID" })
2960
+ .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
2961
+ .option("as-bot", { type: "boolean", default: false, describe: "Ask via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient" }),
2962
+ async (argv) => {
2963
+ const waitFor = argv.waitFor ? String(argv.waitFor) : "";
2964
+ const timeout = Number(argv.timeout);
2965
+ // 0 is a real value only for --waitFor ("look once"). For --wait it would
2966
+ // mean "give up before asking", which is never what anyone meant.
2967
+ if (!Number.isFinite(timeout) || timeout < 0 || (timeout === 0 && !waitFor)) {
2968
+ console.error("Error: --timeout must be a positive number of seconds");
2969
+ process.exit(ASK_EXIT_ERROR);
2970
+ }
2971
+ if (waitFor) {
2972
+ // Collect-only: nothing is posted, so there is no confirm gate and no
2973
+ // question to supply. Anything that would have been posted is a sign
2974
+ // the caller mixed the two modes up.
2975
+ if (argv.target || argv.question || (argv.choices as string[] | undefined)?.length) {
2976
+ console.error("Error: --waitFor collects an existing question — do not also pass a target/question/choices.");
2977
+ process.exit(ASK_EXIT_ERROR);
2978
+ }
2979
+ let waitToken: string;
2980
+ let waitCookie: string | undefined;
2981
+ if (argv["as-bot"]) {
2982
+ const botToken = resolveBotToken();
2983
+ if (!botToken) {
2984
+ console.error(
2985
+ "Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
2986
+ " Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
2987
+ );
2988
+ process.exit(ASK_EXIT_ERROR);
2989
+ }
2990
+ waitToken = botToken;
2991
+ } else {
2992
+ waitToken = tok(argv as W);
2993
+ waitCookie = ck(argv as W);
2994
+ }
2995
+ try {
2996
+ const a: { link: string; timeout: number; asBot: boolean; cookie?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"] };
2997
+ if (waitCookie) a.cookie = waitCookie;
2998
+ await cmdAskWaitFor(waitToken, a);
2999
+ } catch (e: unknown) {
3000
+ console.error(friendlySlackError(e));
3001
+ process.exit(ASK_EXIT_ERROR);
3002
+ }
3003
+ return;
3004
+ }
3005
+ if (!argv.target) {
3006
+ console.error("Error: target is required (or use --waitFor <permalink> to collect an answer)");
3007
+ process.exit(ASK_EXIT_ERROR);
3008
+ }
3009
+ const question = String(argv.question ?? "").trim();
3010
+ if (!question) {
3011
+ console.error("Error: question is empty");
3012
+ process.exit(ASK_EXIT_ERROR);
3013
+ }
3014
+ const args: AskArgs = {
3015
+ target: argv.target!,
3016
+ question,
3017
+ choices: ((argv.choices as string[] | undefined) ?? []).map((s) => String(s).trim()).filter(Boolean),
3018
+ timeout,
3019
+ };
3020
+ if (argv.code) args.code = argv.code;
3021
+ if (argv.body) args.body = argv.body;
3022
+ if (argv.wait) args.wait = true;
3023
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
3024
+ if (argv["user-id"]) args.userId = argv["user-id"];
3025
+ // Always resolve @tags with the user token (it has users:read), even
3026
+ // when the question itself is posted by the bot.
3027
+ args.mentionToken = tok(argv as W);
3028
+ const mc = ck(argv as W);
3029
+ if (mc) args.mentionCookie = mc;
3030
+
3031
+ let askToken: string;
3032
+ if (argv["as-bot"]) {
3033
+ const botToken = resolveBotToken();
3034
+ if (!botToken) {
3035
+ console.error(
3036
+ "Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
3037
+ " Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
3038
+ );
3039
+ process.exit(ASK_EXIT_ERROR);
3040
+ }
3041
+ // Resolve @user with the *user* token (has users:read), then let cmdAsk
3042
+ // open the DM with the bot token — same split as `send --as-bot`.
3043
+ if (!args.channelId && !args.userId && args.target.startsWith("@")) {
3044
+ try {
3045
+ args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
3046
+ } catch (e: unknown) {
3047
+ console.error(
3048
+ `Error: could not resolve ${args.target} to a user for the bot DM.\n` +
3049
+ ` ${e instanceof Error ? e.message : String(e)}\n` +
3050
+ ` Tip: pass --user-id <Uxxxx> to skip the lookup.`,
3051
+ );
3052
+ process.exit(ASK_EXIT_ERROR);
3053
+ }
3054
+ }
3055
+ askToken = botToken;
3056
+ args.asBot = true;
3057
+ } else {
3058
+ askToken = tok(argv as W);
3059
+ const sc = ck(argv as W);
3060
+ if (sc) args.cookie = sc;
3061
+ }
3062
+ try {
3063
+ await cmdAsk(askToken, args);
3064
+ } catch (e: unknown) {
3065
+ console.error(friendlySlackError(e));
3066
+ // 3, not 1: `--wait` callers branch on the exit code, and a transport
3067
+ // failure must be distinguishable from "timed out" (2).
3068
+ process.exit(ASK_EXIT_ERROR);
3069
+ }
3070
+ },
3071
+ )
2311
3072
  .command(
2312
3073
  "doctor",
2313
3074
  "Check the bot token (xoxb) can carry a two-way DM (scopes + Messages Tab)",