slack-term 1.27.1 → 1.28.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/dist/cli.js +130 -113
- package/package.json +1 -1
- package/ts/ask.ts +78 -7
- package/ts/cli.ts +574 -22
- package/ts/poll.ts +244 -0
- package/ts/slack.ts +27 -12
- package/ts/todo.ts +10 -2
package/ts/cli.ts
CHANGED
|
@@ -15,12 +15,26 @@ import { cmdTail } from "./tail.ts";
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
ASK_KEYCAPS,
|
|
18
|
+
ASK_MARKER,
|
|
19
|
+
ASK_RESOLVED_MARKER,
|
|
18
20
|
ASK_MAX_REACTION_CHOICES,
|
|
19
21
|
askBuildText,
|
|
20
22
|
askBuildResolvedText,
|
|
21
23
|
askParseMessage,
|
|
24
|
+
applyInvalidNotice,
|
|
25
|
+
readInvalidNotice,
|
|
22
26
|
type AskFound,
|
|
23
27
|
} from "./ask.ts";
|
|
28
|
+
import {
|
|
29
|
+
POLL_KEYCAPS,
|
|
30
|
+
POLL_MARKER,
|
|
31
|
+
POLL_CLOSED_MARKER,
|
|
32
|
+
POLL_MAX_CHOICES,
|
|
33
|
+
pollBuildText,
|
|
34
|
+
pollBuildClosedText,
|
|
35
|
+
pollParseMessage,
|
|
36
|
+
pollTally,
|
|
37
|
+
} from "./poll.ts";
|
|
24
38
|
import {
|
|
25
39
|
authTest,
|
|
26
40
|
authScopes,
|
|
@@ -35,11 +49,13 @@ import {
|
|
|
35
49
|
deleteMessage,
|
|
36
50
|
reactionAdd,
|
|
37
51
|
reactionRemove,
|
|
52
|
+
reactionsGet,
|
|
38
53
|
filesInfo,
|
|
39
54
|
filesList,
|
|
40
55
|
listRecords,
|
|
41
56
|
history,
|
|
42
57
|
listConversations,
|
|
58
|
+
listConversationMembers,
|
|
43
59
|
listDrafts,
|
|
44
60
|
listUsers,
|
|
45
61
|
userInfo,
|
|
@@ -909,9 +925,23 @@ function splitRefTs(s: string): { ref: string; ts?: string } {
|
|
|
909
925
|
* Accepts: `#chan:1700000000.000100`, `@user:ts`, `RAWID:ts`, Slack permalink, or plain ref.
|
|
910
926
|
* A message permalink targets that message's thread — `?thread_ts=` (the parent) wins when
|
|
911
927
|
* present, otherwise the message's own ts. A channel-only permalink stays top-level. */
|
|
912
|
-
|
|
928
|
+
/** Split a target into "where" and "which thread".
|
|
929
|
+
*
|
|
930
|
+
* `permalinkThreads` is what separates `send` from `ask`/`poll`. For `send`,
|
|
931
|
+
* pasting a permalink means "reply to THAT message" — the message you pasted is
|
|
932
|
+
* the subject. For `ask`/`poll` it does not: the permalink is how you name a DM
|
|
933
|
+
* or channel you have no handle for, and a question is a new thing being
|
|
934
|
+
* raised, not a comment on the message that happened to be at hand. Inheriting
|
|
935
|
+
* the thread there buries a question in an unrelated conversation, where the
|
|
936
|
+
* people it is addressed to will not see it.
|
|
937
|
+
*
|
|
938
|
+
* Threading `ask`/`poll` deliberately is still possible — with the explicit
|
|
939
|
+
* `#chan:<ts>` form, which says so rather than depending on the shape of what
|
|
940
|
+
* you pasted. */
|
|
941
|
+
function parseTargetThread(s: string, permalinkThreads = true): { ref: string; threadTs?: string } {
|
|
913
942
|
const url = parseSlackPermalink(s);
|
|
914
943
|
if (url) {
|
|
944
|
+
if (!permalinkThreads) return { ref: url.channel };
|
|
915
945
|
const threadTs = url.threadTs ?? url.ts;
|
|
916
946
|
return threadTs ? { ref: url.channel, threadTs } : { ref: url.channel };
|
|
917
947
|
}
|
|
@@ -929,7 +959,12 @@ function parseTargetThread(s: string): { ref: string; threadTs?: string } {
|
|
|
929
959
|
* name via conversations.info (fail-soft: a raw ID is still unambiguous). */
|
|
930
960
|
async function destLabel(token: string, channelId: string, ref: string, cookie?: string): Promise<string> {
|
|
931
961
|
let name = ref;
|
|
932
|
-
|
|
962
|
+
// `#C0123…` is an ID wearing a `#`, not a channel name — resolveChannel
|
|
963
|
+
// accepts it, so the gate has to look it up like any other ID. Trusting the
|
|
964
|
+
// `#` here would print "#D00000001" for a DM, which tells the reader
|
|
965
|
+
// nothing about who is on the other end.
|
|
966
|
+
const refIsId = /^#?[CDG][A-Z0-9]{8,}$/.test(ref);
|
|
967
|
+
if (refIsId || (!ref.startsWith("#") && !ref.startsWith("@"))) {
|
|
933
968
|
try {
|
|
934
969
|
const info = asRecord((await conversationInfo(token, channelId, cookie)) as Json);
|
|
935
970
|
const ch = asRecord(info.channel);
|
|
@@ -1680,27 +1715,44 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
1680
1715
|
|
|
1681
1716
|
// Two reactions means two answers, and this command exists for decisions that
|
|
1682
1717
|
// are expensive to get wrong — so an ambiguous state is never resolved by
|
|
1683
|
-
// guessing. Say so
|
|
1718
|
+
// guessing. Say so IN THE QUESTION and keep waiting; removing one reaction (or
|
|
1684
1719
|
// replying with text) settles it on the next poll.
|
|
1685
|
-
|
|
1720
|
+
|
|
1721
|
+
/** Bring the question's invalid-ballot line in line with `offenders`. Skips
|
|
1722
|
+
* the API call when nothing changed, which is what makes this safe to run on
|
|
1723
|
+
* every poll tick rather than once per process. */
|
|
1724
|
+
async function noteInvalid(msg: Record<string, Json>, offenders: string[]): Promise<void> {
|
|
1725
|
+
const text = typeof msg.text === "string" ? msg.text : "";
|
|
1726
|
+
if (!text) return;
|
|
1727
|
+
const next = applyInvalidNotice(text, offenders);
|
|
1728
|
+
if (next === text) return;
|
|
1729
|
+
try {
|
|
1730
|
+
await editMessage(token, channelId, ts, next, cookie, true);
|
|
1731
|
+
} catch (e: unknown) {
|
|
1732
|
+
// Cosmetic: the poller still refuses to guess, and stderr already said so.
|
|
1733
|
+
console.error(` (質問文に注意書きを入れられませんでした: ${e instanceof Error ? e.message : String(e)})`);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1686
1736
|
|
|
1687
1737
|
async function answerFromReactions(msg: Record<string, Json>): Promise<AskFound | null> {
|
|
1688
1738
|
const picks = humanChoices(msg);
|
|
1739
|
+
// Nothing ambiguous any more — take the notice back down if one is up.
|
|
1740
|
+
if (picks.length <= 1 && readInvalidNotice(typeof msg.text === "string" ? msg.text : "").length) {
|
|
1741
|
+
await noteInvalid(msg, []);
|
|
1742
|
+
}
|
|
1689
1743
|
if (!picks.length) return null;
|
|
1690
1744
|
if (picks.length === 1) {
|
|
1691
1745
|
const { index, users } = picks[0]!;
|
|
1692
1746
|
return { answer: reactable[index]!, how: `リアクション ${ASK_KEYCAPS[index]!.glyph}`, who: users[0]! };
|
|
1693
1747
|
}
|
|
1694
1748
|
const glyphs = picks.map((p) => ASK_KEYCAPS[p.index]!.glyph).join(" / ");
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
}
|
|
1703
|
-
}
|
|
1749
|
+
console.error(` (${glyphs} が同時に選ばれています。1 つに絞ってもらうまで待ちます)`);
|
|
1750
|
+
// Whoever is on more than one pill. Named in the question itself so the
|
|
1751
|
+
// person who has to fix it is the person who sees it — and rewritten from
|
|
1752
|
+
// the current state each pass, so reruns cannot stack up notices and the
|
|
1753
|
+
// line clears itself once a reaction is taken back.
|
|
1754
|
+
const offenders = [...new Set(picks.flatMap((p) => p.users))].sort();
|
|
1755
|
+
await noteInvalid(msg, offenders);
|
|
1704
1756
|
return null;
|
|
1705
1757
|
}
|
|
1706
1758
|
|
|
@@ -1795,7 +1847,17 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
1795
1847
|
// and must keep getting the answer body alone.
|
|
1796
1848
|
if (who) console.error(` 回答者: ${stripTerminalControls(who)}`);
|
|
1797
1849
|
try {
|
|
1798
|
-
await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who), cookie);
|
|
1850
|
+
await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who), cookie, true);
|
|
1851
|
+
// Swap the marker for the resolved one so search reflects reality:
|
|
1852
|
+
// `has::question:` should list what still needs answering, not everything
|
|
1853
|
+
// ever asked. Removal last — a crash between the two leaves the question
|
|
1854
|
+
// findable under BOTH, which is recoverable; the reverse loses it.
|
|
1855
|
+
try {
|
|
1856
|
+
await reactionAdd(token, channelId, ts, ASK_RESOLVED_MARKER, cookie);
|
|
1857
|
+
await reactionRemove(token, channelId, ts, ASK_MARKER, cookie);
|
|
1858
|
+
} catch {
|
|
1859
|
+
// Cosmetic: the ✅ in the body is what actually marks it resolved.
|
|
1860
|
+
}
|
|
1799
1861
|
} catch (e: unknown) {
|
|
1800
1862
|
// The edit is bookkeeping, not the answer. It fails whenever the resuming
|
|
1801
1863
|
// token is not the one that posted (Slack lets only the author update), and
|
|
@@ -1856,7 +1918,7 @@ function askResumeCommand(shown: string, asBot: boolean): string {
|
|
|
1856
1918
|
}
|
|
1857
1919
|
|
|
1858
1920
|
async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
1859
|
-
const { ref, threadTs } = parseTargetThread(args.target);
|
|
1921
|
+
const { ref, threadTs } = parseTargetThread(args.target, false);
|
|
1860
1922
|
const cookie = args.cookie;
|
|
1861
1923
|
const getSelf = selfLookup(token, cookie);
|
|
1862
1924
|
|
|
@@ -1989,7 +2051,26 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
1989
2051
|
]);
|
|
1990
2052
|
}
|
|
1991
2053
|
|
|
1992
|
-
|
|
2054
|
+
// `plain`: with blocks attached Slack rewrites the stored text (newlines to
|
|
2055
|
+
// spaces, emoji to `:one:`) and `--waitFor` can no longer read the question
|
|
2056
|
+
// back out of it — which is the entire recovery path.
|
|
2057
|
+
const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true);
|
|
2058
|
+
|
|
2059
|
+
// The marker goes on FIRST so it sits left of the pills, and as a reaction so
|
|
2060
|
+
// `has::question:` lists every question the way `has::pushpin:` lists every
|
|
2061
|
+
// todo. Body text cannot do that job: Slack's index splits on punctuation, so
|
|
2062
|
+
// a `:question:` written in the text is indexed as the word "question" and
|
|
2063
|
+
// matches unrelated messages (measured). Only a real reaction is searchable.
|
|
2064
|
+
//
|
|
2065
|
+
// NOTE the colons: the modifier's argument is itself colon-wrapped, and the
|
|
2066
|
+
// bare `has:question` form does NOT error — it silently degrades to a
|
|
2067
|
+
// full-text search and returns plausible-looking counts. See ts/todo.ts.
|
|
2068
|
+
try {
|
|
2069
|
+
await reactionAdd(token, channelId, ts, ASK_MARKER, cookie);
|
|
2070
|
+
} catch (e: unknown) {
|
|
2071
|
+
// Cosmetic + searchability, not correctness: the body still identifies it.
|
|
2072
|
+
console.error(` (marker ${ASK_MARKER} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`);
|
|
2073
|
+
}
|
|
1993
2074
|
|
|
1994
2075
|
// Seeded one at a time and in order: Slack keeps reactions in the order they
|
|
1995
2076
|
// were added, so sequential adds are what make the pills read 1,2,3 rather
|
|
@@ -2155,6 +2236,305 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
2155
2236
|
await askWaitForAnswer(token, ctx);
|
|
2156
2237
|
}
|
|
2157
2238
|
|
|
2239
|
+
// --- poll ---
|
|
2240
|
+
//
|
|
2241
|
+
// `ask` decides something: the FIRST answer wins and settles the question.
|
|
2242
|
+
// `poll` measures something: every pill is counted and nobody's press is
|
|
2243
|
+
// binding. That difference in termination is why this is its own command and
|
|
2244
|
+
// not a flag on `ask` — the two cannot share a "when is this done?".
|
|
2245
|
+
//
|
|
2246
|
+
// Same seeded-pill trick, for the same reason: answering costs one tap on a
|
|
2247
|
+
// reaction that is already there, not a trip through the emoji picker.
|
|
2248
|
+
//
|
|
2249
|
+
// THE POSTER IS NEVER COUNTED. Their reaction sits on every choice because they
|
|
2250
|
+
// seeded it, so it cannot be told apart from a vote — there is no flag to opt
|
|
2251
|
+
// back in, because there is nothing coherent to opt into. Someone who wants
|
|
2252
|
+
// their own preference on the record posts `--as-bot` and votes as themselves,
|
|
2253
|
+
// or just says it in the thread.
|
|
2254
|
+
//
|
|
2255
|
+
// Exit codes: 0 ok, 3 transport/config failure (no timeout path — `poll` never
|
|
2256
|
+
// blocks; `--results` is a single read).
|
|
2257
|
+
|
|
2258
|
+
const POLL_EXIT_ERROR = 3;
|
|
2259
|
+
|
|
2260
|
+
interface PollArgs {
|
|
2261
|
+
target: string;
|
|
2262
|
+
question: string;
|
|
2263
|
+
choices: string[];
|
|
2264
|
+
body?: string;
|
|
2265
|
+
multi?: boolean;
|
|
2266
|
+
until?: string;
|
|
2267
|
+
code?: string;
|
|
2268
|
+
channelId?: string;
|
|
2269
|
+
userId?: string;
|
|
2270
|
+
asBot?: boolean;
|
|
2271
|
+
cookie?: string;
|
|
2272
|
+
mentionToken?: string;
|
|
2273
|
+
mentionCookie?: string;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
/** The `slack poll --results=…` line handed back after posting. Printed as a
|
|
2277
|
+
* runnable command rather than a bare permalink for the same reason `ask`
|
|
2278
|
+
* does it: nothing else reads pressed pills back, and a link does not tell you
|
|
2279
|
+
* how to count them. */
|
|
2280
|
+
function pollResultsCommand(shown: string): string {
|
|
2281
|
+
// No `--as-bot` here even for a poll the bot posted: counting is a READ, and
|
|
2282
|
+
// the user token is the one that carries `reactions:read`. `--close` is the
|
|
2283
|
+
// half that needs the bot back (only the poster may rewrite the message), and
|
|
2284
|
+
// it takes care of that split itself.
|
|
2285
|
+
return `slack poll --results='${shown}'`;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
async function cmdPoll(token: string, args: PollArgs): Promise<void> {
|
|
2289
|
+
const { ref, threadTs } = parseTargetThread(args.target, false);
|
|
2290
|
+
const cookie = args.cookie;
|
|
2291
|
+
const getSelf = selfLookup(token, cookie);
|
|
2292
|
+
|
|
2293
|
+
let channelId: string;
|
|
2294
|
+
if (args.channelId) channelId = args.channelId;
|
|
2295
|
+
else if (args.userId) channelId = await openDm(token, args.userId, cookie);
|
|
2296
|
+
else channelId = await resolveChannel(token, ref, cookie);
|
|
2297
|
+
|
|
2298
|
+
const selfId = (await getSelf())?.userId ?? "";
|
|
2299
|
+
const counterpart = await imCounterpart(token, channelId, cookie);
|
|
2300
|
+
|
|
2301
|
+
// Same silent-delivery footgun as `send`/`ask`: Slack never notifies you about
|
|
2302
|
+
// your own message, so a poll posted into your own DM reaches nobody.
|
|
2303
|
+
if (!args.asBot && !!selfId && counterpart === selfId) {
|
|
2304
|
+
console.error(
|
|
2305
|
+
`Warning: "${args.target}" is a DM to yourself — Slack will NOT notify you of your own message.\n` +
|
|
2306
|
+
` To reach yourself with a notification, post as the bot: slack poll '${args.target}' '...' --as-bot`,
|
|
2307
|
+
);
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
// No addressee gate here, deliberately — this is where `poll` and `ask` part.
|
|
2311
|
+
// `ask` refuses an unaddressed question because the first answer DECIDES
|
|
2312
|
+
// something, so it matters whose it is. A poll reports a distribution: an
|
|
2313
|
+
// extra voter changes the numbers, never the outcome's legitimacy. Tags are
|
|
2314
|
+
// still encoded so a poll CAN be pointed at people; they just are not required.
|
|
2315
|
+
const qEnc = askEncodeBroadcasts(args.question);
|
|
2316
|
+
const bEnc = askEncodeBroadcasts(args.body ?? "");
|
|
2317
|
+
const mentionToken = args.mentionToken ?? token;
|
|
2318
|
+
const qRep = await encodeMentionsDetailed(mentionToken, qEnc.text, channelId, args.mentionCookie);
|
|
2319
|
+
const bRep = bEnc.text
|
|
2320
|
+
? await encodeMentionsDetailed(mentionToken, bEnc.text, channelId, args.mentionCookie)
|
|
2321
|
+
: { text: "", resolved: [], unresolved: [] } as MentionEncodeResult;
|
|
2322
|
+
const question = qRep.text;
|
|
2323
|
+
const body = bRep.text;
|
|
2324
|
+
for (const line of mentionWarnings([...qRep.unresolved, ...bRep.unresolved])) {
|
|
2325
|
+
console.error(`⚠ ${stripTerminalControls(line)}`);
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
const choices = args.choices;
|
|
2329
|
+
const multi = !!args.multi;
|
|
2330
|
+
const until = args.until ?? "";
|
|
2331
|
+
const message = pollBuildText(question, body, choices, { multi, deadline: until });
|
|
2332
|
+
|
|
2333
|
+
// Preview the destination's last message, exactly as `send`/`ask` do. Fail-soft.
|
|
2334
|
+
let lastText = "";
|
|
2335
|
+
let lastUser = "?";
|
|
2336
|
+
try {
|
|
2337
|
+
const ctx = (await history(token, channelId, 1, undefined, undefined, cookie)) as Record<string, Json>;
|
|
2338
|
+
const lastMsg = asArray(ctx.messages).map(asRecord).filter((m) => m.subtype === undefined || m.subtype === null)[0];
|
|
2339
|
+
lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
|
|
2340
|
+
lastUser = typeof lastMsg?.user === "string"
|
|
2341
|
+
? await userName(token, lastMsg.user, cookie)
|
|
2342
|
+
: typeof lastMsg?.username === "string" ? lastMsg.username : "?";
|
|
2343
|
+
} catch {
|
|
2344
|
+
// best-effort preview only
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
const self = await getSelf();
|
|
2348
|
+
const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "");
|
|
2349
|
+
if (args.code !== code) {
|
|
2350
|
+
const dest = await destLabel(token, channelId, ref, cookie);
|
|
2351
|
+
requireCode(args.code, code, [
|
|
2352
|
+
`--- Last message in channel ------------------`,
|
|
2353
|
+
` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
|
|
2354
|
+
`--- Ballot (seeded as reactions) -------------`,
|
|
2355
|
+
...choices.map((s, i) => ` ${POLL_KEYCAPS[i]!.glyph} ${s}`),
|
|
2356
|
+
`--- Posting ----------------------------------`,
|
|
2357
|
+
fromLine(self, { asBot: args.asBot }),
|
|
2358
|
+
` → ${dest}${threadTs ? " — THREAD REPLY" : " — NEW top-level message"}`,
|
|
2359
|
+
` Question: ${question}`,
|
|
2360
|
+
` Rule: ${multi ? "1 人 何票でも (--multi)" : "1 人 1 票"}${until ? ` — 締切 ${until}` : ""}`,
|
|
2361
|
+
// Said out loud at the gate: this is the surprising half of the command.
|
|
2362
|
+
` Note: あなた自身の票は数えません (ピルはあなたが付けた種なので区別できません)`,
|
|
2363
|
+
`--------------------------------────────────`,
|
|
2364
|
+
]);
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
// `plain`: no blocks, or Slack rewrites the stored text and the ballot
|
|
2368
|
+
// stops parsing on the way back.
|
|
2369
|
+
const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true);
|
|
2370
|
+
|
|
2371
|
+
// Marker first, for the same reason as `ask`: it is what makes
|
|
2372
|
+
// `has::ballot_box_with_ballot:` list every poll. An unrelated reaction on the message is
|
|
2373
|
+
// already ignored by the tally (it counts keycap names only), so this costs
|
|
2374
|
+
// the ballot nothing.
|
|
2375
|
+
try {
|
|
2376
|
+
await reactionAdd(token, channelId, ts, POLL_MARKER, cookie);
|
|
2377
|
+
} catch (e: unknown) {
|
|
2378
|
+
console.error(` (marker ${POLL_MARKER} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`);
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
// Sequential and in order: Slack keeps reactions in the order they were added,
|
|
2382
|
+
// so this is what makes the pills read 1,2,3 rather than shuffled. Parallel
|
|
2383
|
+
// adds are not an optimization here, they are a bug.
|
|
2384
|
+
for (let i = 0; i < choices.length; i++) {
|
|
2385
|
+
try {
|
|
2386
|
+
await reactionAdd(token, channelId, ts, POLL_KEYCAPS[i]!.name, cookie);
|
|
2387
|
+
} catch (e: unknown) {
|
|
2388
|
+
// A missing seed costs a voter a trip to the emoji picker; it does not
|
|
2389
|
+
// invalidate a poll that is already posted.
|
|
2390
|
+
console.error(` (リアクション ${POLL_KEYCAPS[i]!.name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`);
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
let permalink = "";
|
|
2395
|
+
try {
|
|
2396
|
+
permalink = await getPermalink(token, channelId, ts, cookie);
|
|
2397
|
+
} catch {
|
|
2398
|
+
// fail-soft, same as send
|
|
2399
|
+
}
|
|
2400
|
+
const shown = permalink || `${channelId}:${ts}`;
|
|
2401
|
+
console.error(`✓ Posted: ${shown}`);
|
|
2402
|
+
console.log(pollResultsCommand(shown));
|
|
2403
|
+
console.error(` 締め切る: slack poll --close='${shown}'${args.asBot ? " --as-bot" : ""}`);
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
/** Locate + read back one posted poll. Shared by `--results` and `--close`,
|
|
2407
|
+
* which differ only in whether they write the tally back into the message. */
|
|
2408
|
+
async function pollFetch(token: string, link: string, cookie?: string): Promise<{ channelId: string; ts: string; msg: Record<string, Json> }> {
|
|
2409
|
+
const url = parseSlackPermalink(link);
|
|
2410
|
+
let channelId: string;
|
|
2411
|
+
let ts: string;
|
|
2412
|
+
let threadTs: string | undefined;
|
|
2413
|
+
if (url?.ts) {
|
|
2414
|
+
channelId = url.channel;
|
|
2415
|
+
ts = url.ts;
|
|
2416
|
+
threadTs = url.threadTs && url.threadTs !== url.ts ? url.threadTs : undefined;
|
|
2417
|
+
} else {
|
|
2418
|
+
// `C…:1700000000.000100` — the fallback printed when the permalink lookup
|
|
2419
|
+
// itself failed, so it has to be readable too.
|
|
2420
|
+
const m = link.match(/^([A-Za-z0-9]+):(\d{10}\.\d{6})$/);
|
|
2421
|
+
if (!m) {
|
|
2422
|
+
console.error(
|
|
2423
|
+
`Error: --results/--close needs the permalink of a poll (or 'C…:1700000000.000100').\n` +
|
|
2424
|
+
` Got: ${stripTerminalControls(link)}`,
|
|
2425
|
+
);
|
|
2426
|
+
process.exit(POLL_EXIT_ERROR);
|
|
2427
|
+
}
|
|
2428
|
+
channelId = m[1]!;
|
|
2429
|
+
ts = m[2]!;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// A poll posted into a thread is not in history at all.
|
|
2433
|
+
let msg: Record<string, Json> | undefined;
|
|
2434
|
+
if (threadTs) {
|
|
2435
|
+
const rep = asRecord((await replies(token, channelId, threadTs, 100, cookie)) as Json);
|
|
2436
|
+
msg = asArray(rep.messages).map(asRecord).find((m) => m.ts === ts);
|
|
2437
|
+
} else {
|
|
2438
|
+
const hist = asRecord((await history(token, channelId, 1, ts, undefined, cookie, true)) as Json);
|
|
2439
|
+
msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
|
|
2440
|
+
}
|
|
2441
|
+
if (!msg) {
|
|
2442
|
+
console.error(`Error: そのメッセージが見つかりません: ${stripTerminalControls(link)}`);
|
|
2443
|
+
process.exit(POLL_EXIT_ERROR);
|
|
2444
|
+
}
|
|
2445
|
+
return { channelId, ts, msg };
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/** Render a tally. Bars are proportional to the leader, not to the total, so a
|
|
2449
|
+
* three-way split still reads as three different lengths. */
|
|
2450
|
+
function pollFormatTallies(tallies: { choice: string; count: number }[]): string[] {
|
|
2451
|
+
const max = Math.max(1, ...tallies.map((t) => t.count));
|
|
2452
|
+
return tallies.map((t, i) =>
|
|
2453
|
+
`${POLL_KEYCAPS[i]!.glyph} ${String(t.count).padStart(3)} ${"█".repeat(Math.round((t.count / max) * 20))}${t.count ? " " : ""}${t.choice}`,
|
|
2454
|
+
);
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
/** `slack poll --results <permalink>` — count the pills. READ-ONLY: it posts
|
|
2458
|
+
* nothing and edits nothing, so it is safe to run on a schedule, and it is the
|
|
2459
|
+
* main path (`--close` is the one-time end of a poll, not how you check it). */
|
|
2460
|
+
async function cmdPollResults(token: string, args: { link: string; close?: boolean; cookie?: string; names?: boolean; writeToken?: string; writeCookie?: string; noNotify?: boolean }): Promise<void> {
|
|
2461
|
+
const { channelId, ts, msg } = await pollFetch(token, args.link, args.cookie);
|
|
2462
|
+
const text = typeof msg.text === "string" ? msg.text : "";
|
|
2463
|
+
const parsed = pollParseMessage(text);
|
|
2464
|
+
if (parsed.kind === "other") {
|
|
2465
|
+
console.error(
|
|
2466
|
+
`Error: これは \`slack poll\` の投票ではありません (票の数え方が分かりません)。\n` +
|
|
2467
|
+
` ${stripTerminalControls(args.link)}`,
|
|
2468
|
+
);
|
|
2469
|
+
process.exit(POLL_EXIT_ERROR);
|
|
2470
|
+
}
|
|
2471
|
+
if (parsed.kind === "closed") {
|
|
2472
|
+
// Already closed: report what was RECORDED, never a fresh count. A pill
|
|
2473
|
+
// pressed after closing would otherwise silently change a settled result.
|
|
2474
|
+
console.error(`✓ 投票終了: ${stripTerminalControls(parsed.question)}`);
|
|
2475
|
+
for (const line of pollFormatTallies(parsed.tallies)) console.log(line);
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
// The seeder is whoever POSTED — not whoever is running this command. On a
|
|
2480
|
+
// later run the two differ (and under --as-bot they always do), so taking the
|
|
2481
|
+
// current identity would count the seeds as votes.
|
|
2482
|
+
const exclude = new Set<string>();
|
|
2483
|
+
if (typeof msg.user === "string" && msg.user) exclude.add(msg.user);
|
|
2484
|
+
const reactions = await reactionsGet(token, channelId, ts, args.cookie);
|
|
2485
|
+
const counted = pollTally(parsed.choices, reactions, exclude, parsed.multi);
|
|
2486
|
+
|
|
2487
|
+
console.error(`${stripTerminalControls(parsed.question)}${parsed.deadline ? ` (締切 ${parsed.deadline})` : ""}`);
|
|
2488
|
+
for (const line of pollFormatTallies(counted.tallies)) console.log(line);
|
|
2489
|
+
const total = counted.tallies.reduce((n, t) => n + t.count, 0);
|
|
2490
|
+
console.error(` 計 ${total} 票${parsed.multi ? " (複数選択可)" : ""}`);
|
|
2491
|
+
if (args.names) {
|
|
2492
|
+
for (let i = 0; i < counted.voters.length; i++) {
|
|
2493
|
+
if (!counted.voters[i]!.length) continue;
|
|
2494
|
+
const names = await Promise.all(counted.voters[i]!.map((u) => userName(token, u, args.cookie)));
|
|
2495
|
+
console.error(` ${POLL_KEYCAPS[i]!.glyph} ${names.map((n) => `@${stripTerminalControls(n)}`).join(", ")}`);
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
// Same notice as `ask`, for the same reason: the voter is the one who has to
|
|
2499
|
+
// fix it, and the voter is in Slack. Idempotent — rebuilt from this count, so
|
|
2500
|
+
// running `--results` on a schedule neither stacks notices up nor leaves a
|
|
2501
|
+
// stale one behind once the extra pill is taken back.
|
|
2502
|
+
const wanted = parsed.multi ? [] : counted.ambiguous;
|
|
2503
|
+
const nextText = applyInvalidNotice(text, wanted);
|
|
2504
|
+
if (nextText !== text && !args.noNotify) {
|
|
2505
|
+
try {
|
|
2506
|
+
await editMessage(args.writeToken ?? token, channelId, ts, nextText, args.writeToken ? args.writeCookie : args.cookie, true);
|
|
2507
|
+
} catch (e: unknown) {
|
|
2508
|
+
console.error(
|
|
2509
|
+
` (投票文に注意書きを入れられませんでした: ${e instanceof Error ? e.message : String(e)})\n` +
|
|
2510
|
+
` メッセージを書き換えられるのは投稿者だけです — bot が投稿した投票なら --as-bot を付けてください。`,
|
|
2511
|
+
);
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
if (counted.ambiguous.length) {
|
|
2515
|
+
// Not counted for anything, and said so out loud: a reaction carries no
|
|
2516
|
+
// timestamp anywhere in the Slack API, so "the latest one" does not exist.
|
|
2517
|
+
const names = await Promise.all(counted.ambiguous.map((u) => userName(token, u, args.cookie)));
|
|
2518
|
+
console.error(`⚠ 1 人 1 票なのに複数押している人がいます (無効票): ${names.map((n) => `@${stripTerminalControls(n)}`).join(", ")}`);
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
if (!args.close) return;
|
|
2522
|
+
|
|
2523
|
+
// Freeze it. The edit is what makes the result final — after this the body no
|
|
2524
|
+
// longer parses as an open poll, so a late pill changes nothing.
|
|
2525
|
+
// Counting and closing can need DIFFERENT tokens: `reactions.get` lives on
|
|
2526
|
+
// the user token, but only the poster may rewrite the message — and for a
|
|
2527
|
+
// `--as-bot` poll the poster is the bot. Same split as `ask`'s mention token.
|
|
2528
|
+
await editMessage(args.writeToken ?? token, channelId, ts, pollBuildClosedText(parsed.question, counted.tallies), args.writeToken ? args.writeCookie : args.cookie, true);
|
|
2529
|
+
try {
|
|
2530
|
+
await reactionAdd(args.writeToken ?? token, channelId, ts, POLL_CLOSED_MARKER, args.writeToken ? args.writeCookie : args.cookie);
|
|
2531
|
+
await reactionRemove(args.writeToken ?? token, channelId, ts, POLL_MARKER, args.writeToken ? args.writeCookie : args.cookie);
|
|
2532
|
+
} catch {
|
|
2533
|
+
// Cosmetic: the 📊 body is what makes it closed.
|
|
2534
|
+
}
|
|
2535
|
+
console.error(`✓ 投票を締め切りました: ${stripTerminalControls(args.link)}`);
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2158
2538
|
async function cmdDoctor(token: string): Promise<void> {
|
|
2159
2539
|
const diag = await diagnoseBotMessaging(token);
|
|
2160
2540
|
for (const line of formatDiagnosis(diag, false)) console.log(line);
|
|
@@ -2756,13 +3136,48 @@ async function main(): Promise<void> {
|
|
|
2756
3136
|
.option("limit", { alias: "n", type: "number", default: 200 })
|
|
2757
3137
|
.option("filter", { alias: "f", type: "string" })
|
|
2758
3138
|
.option("format", { type: "string", choices: ["text", "jsonl", "yaml"] as const, default: "text" })
|
|
2759
|
-
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
|
|
3139
|
+
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
|
|
3140
|
+
.option("external", { type: "boolean", default: false, describe: "Also include people reachable only through Slack Connect shared channels — they are in ANOTHER workspace, so users.list never returns them (costs one API call per shared channel)" }),
|
|
2760
3141
|
async (argv) => {
|
|
2761
3142
|
const format = argv.json ? "jsonl" : argv.format;
|
|
2762
|
-
const
|
|
3143
|
+
const lsToken = tok(argv as W);
|
|
3144
|
+
const lsCookie = ck(argv as W);
|
|
3145
|
+
// The cookie is not optional on a desktop (xoxc-) token: `users.list` is a
|
|
3146
|
+
// public Web API call, and without it Slack answers `invalid_auth`.
|
|
3147
|
+
const resp = (await listUsers(lsToken, lsCookie)) as Record<string, Json>;
|
|
2763
3148
|
const filter = argv.filter?.toLowerCase();
|
|
2764
|
-
|
|
2765
|
-
|
|
3149
|
+
let all = asArray(resp.members).map(asRecord);
|
|
3150
|
+
if (argv.external) {
|
|
3151
|
+
// `users.list` is scoped to YOUR workspace. A Slack Connect
|
|
3152
|
+
// counterpart belongs to a different team, so they are absent from
|
|
3153
|
+
// it entirely — no flag on the call changes that. The only place
|
|
3154
|
+
// they exist is the membership of the channel you share, which is
|
|
3155
|
+
// why this walks the externally-shared channels instead.
|
|
3156
|
+
const known = new Set(all.map((u) => String(u.id)));
|
|
3157
|
+
const conv = asRecord((await listConversations(lsToken, lsCookie)) as Json);
|
|
3158
|
+
const shared = asArray(conv.channels).map(asRecord).filter((c) => c.is_ext_shared === true && !!c.name);
|
|
3159
|
+
const extra = new Set<string>();
|
|
3160
|
+
for (const c of shared) {
|
|
3161
|
+
try {
|
|
3162
|
+
for (const id of await listConversationMembers(lsToken, String(c.id), lsCookie)) {
|
|
3163
|
+
if (!known.has(id)) extra.add(id);
|
|
3164
|
+
}
|
|
3165
|
+
} catch {
|
|
3166
|
+
// A channel we cannot read the roster of costs us its members,
|
|
3167
|
+
// not the whole listing.
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
for (const id of extra) {
|
|
3171
|
+
try {
|
|
3172
|
+
const info = asRecord((await userInfo(lsToken, id, lsCookie)) as Json);
|
|
3173
|
+
const u = asRecord(info.user);
|
|
3174
|
+
if (u.id) all.push(u);
|
|
3175
|
+
} catch {
|
|
3176
|
+
// ditto: skip the one we cannot resolve
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
const members = all
|
|
2766
3181
|
.filter((u) => u.deleted !== true && u.is_bot !== true && String(u.id) !== "USLACKBOT")
|
|
2767
3182
|
.filter((u) => {
|
|
2768
3183
|
if (!filter) return true;
|
|
@@ -2792,6 +3207,12 @@ async function main(): Promise<void> {
|
|
|
2792
3207
|
return;
|
|
2793
3208
|
}
|
|
2794
3209
|
const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
3210
|
+
// Whose workspace this is, so a Connect counterpart can be told from
|
|
3211
|
+
// a guest: both are "not a normal colleague", but only one of them
|
|
3212
|
+
// is someone your admin can actually manage.
|
|
3213
|
+
const myTeam = members.length
|
|
3214
|
+
? String(asRecord(asArray(resp.members).map(asRecord)[0] ?? {}).team_id ?? "")
|
|
3215
|
+
: "";
|
|
2795
3216
|
for (const u of members) {
|
|
2796
3217
|
const profile = asRecord(u.profile);
|
|
2797
3218
|
const handle = String(u.name ?? u.id);
|
|
@@ -2803,8 +3224,17 @@ async function main(): Promise<void> {
|
|
|
2803
3224
|
const names = [...new Set([display, real].filter((s) => s && s !== handle))];
|
|
2804
3225
|
const parts = names.join(" / ");
|
|
2805
3226
|
const tzPart = tz && tz !== localTz ? tz : "";
|
|
3227
|
+
// Said out loud, because it changes what you may assume about the
|
|
3228
|
+
// person: a single-channel guest cannot see anything but the one
|
|
3229
|
+
// channel, and an external user is not in your workspace at all.
|
|
3230
|
+
const team = String(u.team_id ?? "");
|
|
3231
|
+
const kind = u.is_stranger === true || (myTeam && team && team !== myTeam)
|
|
3232
|
+
? `external:${team || "?"}`
|
|
3233
|
+
: u.is_ultra_restricted === true ? "single-channel guest"
|
|
3234
|
+
: u.is_restricted === true ? "guest"
|
|
3235
|
+
: "";
|
|
2806
3236
|
const meta = [email, tzPart].filter(Boolean).join(" ");
|
|
2807
|
-
console.log(`@${handle} ${id} ${parts}${meta ? " " + meta : ""}`);
|
|
3237
|
+
console.log(`@${handle} ${id} ${parts}${meta ? " " + meta : ""}${kind ? ` [${kind}]` : ""}`);
|
|
2808
3238
|
}
|
|
2809
3239
|
},
|
|
2810
3240
|
)
|
|
@@ -2818,7 +3248,7 @@ async function main(): Promise<void> {
|
|
|
2818
3248
|
// If not a Slack user ID (U + alphanumeric), resolve from users list by handle
|
|
2819
3249
|
let userId = ref;
|
|
2820
3250
|
if (!/^U[A-Z0-9]+$/.test(ref)) {
|
|
2821
|
-
const listResp = (await listUsers(token)) as Record<string, Json>;
|
|
3251
|
+
const listResp = (await listUsers(token, ck(argv as W))) as Record<string, Json>;
|
|
2822
3252
|
const match = asArray(listResp.members).map(asRecord)
|
|
2823
3253
|
.find((u) => String(u.name ?? "") === ref);
|
|
2824
3254
|
if (!match) { console.error(`User not found: @${ref}`); process.exit(1); }
|
|
@@ -3069,6 +3499,128 @@ async function main(): Promise<void> {
|
|
|
3069
3499
|
}
|
|
3070
3500
|
},
|
|
3071
3501
|
)
|
|
3502
|
+
.command(
|
|
3503
|
+
["poll [target] [question] [choices..]", "vote [target] [question] [choices..]"],
|
|
3504
|
+
"Post a poll with its choices pre-seeded as 1️⃣..🔟 reactions, then count them (confirm-hash safety gate)",
|
|
3505
|
+
(y) => y
|
|
3506
|
+
.positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --results/--close)" })
|
|
3507
|
+
.positional("question", { type: "string", describe: "The question. @tags are optional here — unlike `ask`, a poll needs no addressee: anyone who can see it may vote." })
|
|
3508
|
+
.positional("choices", { type: "string", array: true, describe: "2..10 choices, seeded as 1️⃣..🔟 reactions for voters to click" })
|
|
3509
|
+
.option("multi", { type: "boolean", default: false, describe: "Let one person pick several choices (default: 1 人 1 票)" })
|
|
3510
|
+
.option("until", { type: "string", describe: "Deadline shown in the message (display only — closing is still `--close`)" })
|
|
3511
|
+
.option("results", { type: "string", describe: "Count an existing poll: pass its permalink. Posts nothing; only edit is the invalid-ballot notice in the poll itself (--no-notify to suppress)." })
|
|
3512
|
+
.option("close", { type: "string", describe: "Count an existing poll AND freeze the tally into the message. Only the poster can do this." })
|
|
3513
|
+
.option("names", { type: "boolean", default: false, describe: "Also list who voted for what (stderr)" })
|
|
3514
|
+
.option("no-notify", { type: "boolean", default: false, describe: "With --results: do not write the invalid-ballot notice back into the message (keeps it strictly read-only)" })
|
|
3515
|
+
.option("code", { type: "string", describe: "Safety hash to confirm the post" })
|
|
3516
|
+
.option("body", { type: "string", describe: "Extra context shown under the question" })
|
|
3517
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
3518
|
+
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
3519
|
+
.option("as-bot", { type: "boolean", default: false, describe: "Post via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient — and so YOUR OWN vote counts, since the seeds are then the bot's" }),
|
|
3520
|
+
async (argv) => {
|
|
3521
|
+
const readLink = argv.close ? String(argv.close) : argv.results ? String(argv.results) : "";
|
|
3522
|
+
if (argv.results && argv.close) {
|
|
3523
|
+
console.error("Error: pass --results or --close, not both (--close counts as well).");
|
|
3524
|
+
process.exit(POLL_EXIT_ERROR);
|
|
3525
|
+
}
|
|
3526
|
+
const botTokenOrExit = (): string => {
|
|
3527
|
+
const botToken = resolveBotToken();
|
|
3528
|
+
if (!botToken) {
|
|
3529
|
+
console.error(
|
|
3530
|
+
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
3531
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
3532
|
+
);
|
|
3533
|
+
process.exit(POLL_EXIT_ERROR);
|
|
3534
|
+
}
|
|
3535
|
+
return botToken;
|
|
3536
|
+
};
|
|
3537
|
+
if (readLink) {
|
|
3538
|
+
if (argv.target || argv.question || (argv.choices as string[] | undefined)?.length) {
|
|
3539
|
+
console.error("Error: --results/--close reads an existing poll — do not also pass a target/question/choices.");
|
|
3540
|
+
process.exit(POLL_EXIT_ERROR);
|
|
3541
|
+
}
|
|
3542
|
+
// Counting always runs on the USER token: `reactions.get` is a user
|
|
3543
|
+
// scope, and a bot that can post a poll usually cannot read its pills.
|
|
3544
|
+
// `--as-bot` here means only "the bot is the poster", which matters
|
|
3545
|
+
// solely for the `--close` rewrite.
|
|
3546
|
+
const readToken = tok(argv as W);
|
|
3547
|
+
const readCookie = ck(argv as W);
|
|
3548
|
+
try {
|
|
3549
|
+
const a: { link: string; close?: boolean; cookie?: string; names?: boolean; writeToken?: string; writeCookie?: string; noNotify?: boolean } = { link: readLink };
|
|
3550
|
+
if (argv.close) a.close = true;
|
|
3551
|
+
if (argv.names) a.names = true;
|
|
3552
|
+
if (argv["no-notify"]) a.noNotify = true;
|
|
3553
|
+
if (readCookie) a.cookie = readCookie;
|
|
3554
|
+
// The tally is READ with the user token but WRITTEN by the poster —
|
|
3555
|
+
// for a `--as-bot` poll those are two different identities.
|
|
3556
|
+
if (argv["as-bot"]) a.writeToken = botTokenOrExit();
|
|
3557
|
+
await cmdPollResults(readToken, a);
|
|
3558
|
+
} catch (e: unknown) {
|
|
3559
|
+
console.error(friendlySlackError(e));
|
|
3560
|
+
process.exit(POLL_EXIT_ERROR);
|
|
3561
|
+
}
|
|
3562
|
+
return;
|
|
3563
|
+
}
|
|
3564
|
+
if (!argv.target) {
|
|
3565
|
+
console.error("Error: target is required (or use --results <permalink> to count a poll)");
|
|
3566
|
+
process.exit(POLL_EXIT_ERROR);
|
|
3567
|
+
}
|
|
3568
|
+
const question = String(argv.question ?? "").trim();
|
|
3569
|
+
if (!question) {
|
|
3570
|
+
console.error("Error: question is empty");
|
|
3571
|
+
process.exit(POLL_EXIT_ERROR);
|
|
3572
|
+
}
|
|
3573
|
+
const choices = ((argv.choices as string[] | undefined) ?? []).map((s) => String(s).trim()).filter(Boolean);
|
|
3574
|
+
// Two is the floor: a one-choice ballot measures nothing, and the ten
|
|
3575
|
+
// ceiling is where the keycap emoji stop — there is no text path here to
|
|
3576
|
+
// overflow into, so an eleventh choice would be unvotable.
|
|
3577
|
+
if (choices.length < 2 || choices.length > POLL_MAX_CHOICES) {
|
|
3578
|
+
console.error(`Error: poll needs 2..${POLL_MAX_CHOICES} choices (got ${choices.length}). For a free-text question use \`slack ask\`.`);
|
|
3579
|
+
process.exit(POLL_EXIT_ERROR);
|
|
3580
|
+
}
|
|
3581
|
+
const args: PollArgs = { target: argv.target!, question, choices };
|
|
3582
|
+
if (argv.multi) args.multi = true;
|
|
3583
|
+
if (argv.until) args.until = String(argv.until);
|
|
3584
|
+
if (argv.code) args.code = argv.code;
|
|
3585
|
+
if (argv.body) args.body = argv.body;
|
|
3586
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
3587
|
+
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
3588
|
+
// Always resolve @tags with the user token (it has users:read), even
|
|
3589
|
+
// when the poll itself is posted by the bot.
|
|
3590
|
+
args.mentionToken = tok(argv as W);
|
|
3591
|
+
const mc = ck(argv as W);
|
|
3592
|
+
if (mc) args.mentionCookie = mc;
|
|
3593
|
+
|
|
3594
|
+
let pollToken: string;
|
|
3595
|
+
if (argv["as-bot"]) {
|
|
3596
|
+
const botToken = botTokenOrExit();
|
|
3597
|
+
if (!args.channelId && !args.userId && args.target.startsWith("@")) {
|
|
3598
|
+
try {
|
|
3599
|
+
args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
|
|
3600
|
+
} catch (e: unknown) {
|
|
3601
|
+
console.error(
|
|
3602
|
+
`Error: could not resolve ${args.target} to a user for the bot DM.\n` +
|
|
3603
|
+
` ${e instanceof Error ? e.message : String(e)}\n` +
|
|
3604
|
+
` Tip: pass --user-id <Uxxxx> to skip the lookup.`,
|
|
3605
|
+
);
|
|
3606
|
+
process.exit(POLL_EXIT_ERROR);
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
pollToken = botToken;
|
|
3610
|
+
args.asBot = true;
|
|
3611
|
+
} else {
|
|
3612
|
+
pollToken = tok(argv as W);
|
|
3613
|
+
const sc = ck(argv as W);
|
|
3614
|
+
if (sc) args.cookie = sc;
|
|
3615
|
+
}
|
|
3616
|
+
try {
|
|
3617
|
+
await cmdPoll(pollToken, args);
|
|
3618
|
+
} catch (e: unknown) {
|
|
3619
|
+
console.error(friendlySlackError(e));
|
|
3620
|
+
process.exit(POLL_EXIT_ERROR);
|
|
3621
|
+
}
|
|
3622
|
+
},
|
|
3623
|
+
)
|
|
3072
3624
|
.command(
|
|
3073
3625
|
"doctor",
|
|
3074
3626
|
"Check the bot token (xoxb) can carry a two-way DM (scopes + Messages Tab)",
|