slack-term 1.27.1 → 1.28.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
@@ -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
- function parseTargetThread(s: string): { ref: string; threadTs?: string } {
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
- if (!ref.startsWith("#") && !ref.startsWith("@")) {
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 in the thread and keep waiting; removing one reaction (or
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
- let ambiguityNotified = false;
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
- 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
- }
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
- const ts = await slackSend(token, channelId, message, threadTs, false, cookie);
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 resp = (await listUsers(tok(argv as W))) as Record<string, Json>;
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
- const members = asArray(resp.members)
2765
- .map(asRecord)
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)",