slack-term 1.26.6 → 1.27.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/README.md +36 -1
- package/SKILL.md +39 -0
- package/dist/cli.js +98 -79
- package/package.json +5 -4
- package/ts/ask.ts +176 -0
- package/ts/cli.ts +761 -0
- package/ts/slack.ts +6 -0
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)",
|