slack-term 1.39.2 → 1.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ts/cli.ts CHANGED
@@ -28,7 +28,18 @@ import {
28
28
  askMatchChoice,
29
29
  applyInvalidNotice,
30
30
  readInvalidNotice,
31
+ askResolveLang,
32
+ askResolvedHow,
33
+ askReplyPicked,
34
+ askResolvedKeep,
35
+ askBuildVoidText,
36
+ askSalvageUnreadable,
37
+ askDetectLang,
38
+ ASK_VOID_MARKER,
39
+ ASK_COPY,
40
+ ASK_LANGS,
31
41
  type AskFound,
42
+ type AskLang,
32
43
  } from "./ask.ts";
33
44
  import {
34
45
  POLL_KEYCAPS,
@@ -50,6 +61,7 @@ import {
50
61
  authTestSession,
51
62
  conversationInfoSession,
52
63
  createChannel,
64
+ renameChannel,
53
65
  inviteToChannel,
54
66
  createDraft,
55
67
  deleteDraft,
@@ -73,6 +85,7 @@ import {
73
85
  listDrafts,
74
86
  listUsers,
75
87
  userInfo,
88
+ userLocale,
76
89
  conversationInfo,
77
90
  openDm,
78
91
  parseSlackPermalink,
@@ -95,7 +108,9 @@ import {
95
108
  userName,
96
109
  getPath,
97
110
  type Json,
111
+ savedList,
98
112
  } from "./slack.ts";
113
+ import { isDone, messagePermalink, parseSavedItems, sortTodos, TODO_FILTERS, todoStatus, type TodoFilter, type TodoItem } from "./slackTodos.ts";
99
114
  import {
100
115
  buildTodoQuery,
101
116
  loadTodoConfig,
@@ -759,6 +774,8 @@ function blocksToText(blocks: Json[]): string {
759
774
  if (node && typeof node === "object") {
760
775
  const obj = node as Record<string, Json>;
761
776
  if (typeof obj.text === "string") { parts.push(obj.text); return; }
777
+ // A bare `link` element carries only its url — which IS its visible text.
778
+ if (obj.type === "link" && typeof obj.url === "string") { parts.push(obj.url); return; }
762
779
  if (Array.isArray(obj.elements)) walk(obj.elements);
763
780
  else if (Array.isArray(obj.content)) walk(obj.content);
764
781
  }
@@ -841,6 +858,65 @@ async function cmdDrafts(token: string, cookie?: string, showAll = false): Promi
841
858
  }
842
859
  }
843
860
 
861
+ // --- todos (Slack's To-dos tab) ---
862
+ // Read-only. Pages through saved.list up to `limit` items, then shows each
863
+ // to-do with its due date, where it lives, and the message it points at.
864
+ async function cmdTodos(
865
+ token: string,
866
+ cookie: string | undefined,
867
+ opts: { filter: TodoFilter; limit: number; json: boolean },
868
+ ): Promise<void> {
869
+ const items: TodoItem[] = [];
870
+ let cursor: string | undefined;
871
+ do {
872
+ const resp = asRecord((await savedList(token, { filter: opts.filter, limit: Math.min(opts.limit, 100), cursor }, cookie)) as Json);
873
+ items.push(...parseSavedItems(resp as Json));
874
+ const next = asRecord(resp.response_metadata).next_cursor;
875
+ cursor = typeof next === "string" && next ? next : undefined;
876
+ } while (cursor && items.length < opts.limit);
877
+ const shown = sortTodos(items).slice(0, opts.limit);
878
+
879
+ if (shown.length === 0) {
880
+ // --json is JSONL on stdout: an empty list is no lines, the note goes to stderr.
881
+ (opts.json ? console.error : console.log)(`No ${opts.filter === "todo" ? "to-dos" : `${opts.filter} items`}.`);
882
+ return;
883
+ }
884
+
885
+ let teamUrl = "";
886
+ let myUserId = "";
887
+ try { ({ url: teamUrl, userId: myUserId } = await authScopes(token, cookie)); } catch { /* links degrade to channel:ts */ }
888
+ const resolveChLabel = await buildChLabelResolver(token, cookie, myUserId);
889
+ const now = Date.now() / 1000;
890
+
891
+ for (const t of shown) {
892
+ // The message a to-do points at. conversations.replies on its own ts
893
+ // returns it whether or not it has a thread; a thread reply comes back
894
+ // inside its thread, so match by ts. Fail-soft: an unreadable message
895
+ // still lists, with its link.
896
+ let msg: Record<string, Json> | undefined;
897
+ if (t.channel && t.ts) {
898
+ try {
899
+ const r = asRecord((await replies(token, t.channel, t.ts, 1, cookie)) as Json);
900
+ msg = asArray(r.messages).map(asRecord).find((m) => m.ts === t.ts);
901
+ } catch { /* not readable */ }
902
+ }
903
+ const link = t.channel && t.ts
904
+ ? (teamUrl ? messagePermalink(teamUrl, t.channel, t.ts) : `${t.channel}:${t.ts}`)
905
+ : "";
906
+ if (opts.json) {
907
+ console.log(JSON.stringify({ ...t, done: isDone(t), permalink: link, user: msg?.user ?? null, text: msg?.text ?? null }));
908
+ continue;
909
+ }
910
+ const where = t.channel ? await resolveChLabel(t.channel) : `(${t.itemType || "item"})`;
911
+ const author = typeof msg?.user === "string" ? `@${await userName(token, msg.user, cookie)}: ` : "";
912
+ const raw = typeof msg?.text === "string" ? msg.text.split("\n")[0] ?? "" : "(message not readable)";
913
+ const head = raw.length > 100 ? `${raw.slice(0, 100)}…` : raw;
914
+ console.log(`${todoStatus(t, now, formatYmdHm)} ${stripTerminalControls(where)}`);
915
+ console.log(` ${stripTerminalControls(author + head)}`);
916
+ if (link) console.log(` ${link}`);
917
+ }
918
+ }
919
+
844
920
  // --- drafts get ---
845
921
  async function cmdDraftGet(token: string, cookie: string | undefined, draftId: string): Promise<void> {
846
922
  const resp = (await listDrafts(token, cookie)) as Record<string, Json>;
@@ -904,6 +980,7 @@ const SCOPE_FOR_METHOD: Record<string, string> = {
904
980
  "slackLists.items.update": "lists:write",
905
981
  "slackLists.items.list": "lists:read",
906
982
  "slackLists.access.set": "lists:write",
983
+ "conversations.rename": "channels:manage (or groups:write for a private channel)",
907
984
  "conversations.replies": "channels:history (or groups:history)",
908
985
  "conversations.history": "channels:history (or groups:history)",
909
986
  };
@@ -1226,6 +1303,8 @@ function sumCounts(m: Map<string, number>): number {
1226
1303
  // --- edit ---
1227
1304
  interface EditArgs {
1228
1305
  allowUrlAdjacent?: boolean;
1306
+ /** Edit an `ask`/`poll` message anyway. Without it, `edit` refuses one. */
1307
+ force?: boolean;
1229
1308
  target: string;
1230
1309
  newText: string;
1231
1310
  code?: string;
@@ -1320,14 +1399,47 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
1320
1399
 
1321
1400
  guardUrlBoundaries(newText, args.allowUrlAdjacent);
1322
1401
 
1402
+ // An `ask` or `poll` body is a wire format: collect reads the question, the
1403
+ // pills, the ✅/🚫 state and the kept body back out of it. What is refused is
1404
+ // an edit that BREAKS that — new text that no longer reads as one, or an
1405
+ // answered/void question turned back into an open one. An edit that keeps it
1406
+ // readable is fine, and must stay fine: scripts resolve questions exactly this
1407
+ // way (the ask-devteam skill writes the ✅ form with `slack edit`, from hosts
1408
+ // that may run older builds — it cannot be asked to pass a new flag).
1409
+ const askBefore = askParseMessage(originalText).kind;
1410
+ const askAfter = askParseMessage(newText).kind;
1411
+ const isPoll = pollParseMessage(originalText).kind !== "other";
1412
+ const breaks = isPoll
1413
+ ? pollParseMessage(newText).kind === "other"
1414
+ : askBefore !== "other" && (askAfter === "other" || (askBefore !== "open" && askAfter === "open"));
1415
+ if (breaks && !args.force) {
1416
+ const what = isPoll ? "a `slack poll`" : `a \`slack ask\` question (${askBefore === "open" ? "open" : askBefore === "resolved" ? "answered" : "void"})`;
1417
+ const how = isPoll || askAfter === "other"
1418
+ ? "the new text would no longer read back as one (collect could not find the question, the pills or the answer)"
1419
+ : "the new text would turn it back into an OPEN question";
1420
+ console.error(
1421
+ `Error: this message is ${what}, and ${how}.\n` +
1422
+ (isPoll
1423
+ ? ` Edit it anyway with --force.`
1424
+ : ` To retire it: slack ask --void='${args.target}' --reason '…'\n Edit it anyway with --force.`),
1425
+ );
1426
+ process.exit(1);
1427
+ }
1428
+ const guarded = breaks;
1429
+
1323
1430
  // Identity is part of the hash for the same reason it is on `send`: a code
1324
1431
  // minted while previewing as one identity must not confirm the write as
1325
1432
  // another (Slack only lets you edit your own messages, so a silent profile
1326
1433
  // switch between preview and confirm otherwise turns into a bare API error).
1327
1434
  const self = await getSelf();
1328
- const code = safetyCode(originalText, newText, self?.userId ?? "");
1435
+ // Overriding the guard is part of what the code authorizes: a code minted for
1436
+ // an ordinary edit must not confirm a forced rewrite of a question.
1437
+ const code = guarded
1438
+ ? safetyCode("force-edit-ask-poll", originalText, newText, self?.userId ?? "")
1439
+ : safetyCode(originalText, newText, self?.userId ?? "");
1329
1440
  if (args.code !== code) {
1330
1441
  requireCode(args.code, code, [
1442
+ ...(guarded ? [`⚠ --force: rewriting a \`slack ${isPoll ? "poll" : "ask"}\` message by hand — collect may no longer read it back`] : []),
1331
1443
  `--- Editing as -------------------------------`,
1332
1444
  fromLine(self, { asBot: args.asBot }),
1333
1445
  `--- Original message -------------------------`,
@@ -1339,7 +1451,13 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
1339
1451
  }
1340
1452
 
1341
1453
  const attr = sentAttribution("edit");
1342
- const newTs = await editMessage(token, channelId, ts, newText, args.cookie, undefined, attr.metadata);
1454
+ // An ask/poll body goes up PLAIN (no blocks): with blocks attached Slack
1455
+ // rewrites the stored text — every newline becomes a space — and the message
1456
+ // can never be read back as an ask again (2026-10-01: a release ask edited
1457
+ // this way could neither be collected nor voided). Ordinary messages keep
1458
+ // the blocks they always had.
1459
+ const askish = askBefore !== "other" || askAfter !== "other" || isPoll || pollParseMessage(newText).kind !== "other";
1460
+ const newTs = await editMessage(token, channelId, ts, newText, args.cookie, askish ? true : undefined, attr.metadata);
1343
1461
  console.log(`✓ Edited (ts: ${newTs})`);
1344
1462
  attr.record({ team: self?.team, channel: channelId, target: args.target, ts, text: newText, asBot: args.asBot });
1345
1463
  }
@@ -2319,6 +2437,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
2319
2437
  // 5 replied in free text, picking none of the offered choices — the reply
2320
2438
  // text is on stdout. NOT a decision: the question is left open, so a pill
2321
2439
  // pressed later still settles it (`--waitFor … --after=<reply ts>`).
2440
+ // 6 the question was VOIDED (無効化, `slack ask --void`) — it will never be
2441
+ // answered; stop waiting. stdout stays empty.
2322
2442
  // Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
2323
2443
 
2324
2444
  const ASK_EXIT_TIMEOUT = 2;
@@ -2334,6 +2454,10 @@ const ASK_EXIT_UNCHOSEN = 4;
2334
2454
  * (measured 2026-09-27: 8 of 71 asks answered this way, none collected). Still
2335
2455
  * not 0, so nothing mistakes it for a decision. */
2336
2456
  const ASK_EXIT_FREETEXT = 5;
2457
+ /** The question was voided (無効化) — expired, superseded, or overtaken by events.
2458
+ * Neither "open" nor "answered": a monitor that kept waiting on it would wait
2459
+ * forever, and one that read it as answered would act on nothing. */
2460
+ const ASK_EXIT_VOID = 6;
2337
2461
 
2338
2462
  const ASK_PHI = 1.618033988749895;
2339
2463
  const ASK_POLL_MIN_MS = 1000;
@@ -2343,6 +2467,12 @@ const ASK_MAX_CONSECUTIVE_ERRORS = 8;
2343
2467
 
2344
2468
  interface AskArgs {
2345
2469
  allowUrlAdjacent?: boolean;
2470
+ /** `--void=<old> --reask`: once this question is posted, void `old` with this
2471
+ * question as its replacement. Validated (own, open) by the caller. */
2472
+ voidAfter?: AskVoidTarget & { reason: string };
2473
+ /** `--lang`. Unset = pick from the readers' Slack locale, then the content,
2474
+ * then the system locale — see `askResolveLang`. */
2475
+ lang?: string;
2346
2476
  target: string;
2347
2477
  question: string;
2348
2478
  choices: string[];
@@ -2352,6 +2482,8 @@ interface AskArgs {
2352
2482
  userId?: string;
2353
2483
  asBot?: boolean;
2354
2484
  wait?: boolean;
2485
+ /** `--json` with `--wait`: one JSON object on stdout (answer, notes, cursor). */
2486
+ json?: boolean;
2355
2487
  timeout?: number;
2356
2488
  cookie?: string;
2357
2489
  // Token used to resolve the @tags that decide who may answer (needs
@@ -2423,6 +2555,9 @@ interface AskWaitCtx {
2423
2555
  * nothing". */
2424
2556
  overflow: string[];
2425
2557
  threadOnly: boolean;
2558
+ /** The language the question's copy is in — the ✅ stamp and the
2559
+ * invalid-ballot line are written in the same one. */
2560
+ lang: AskLang;
2426
2561
  /** Who may answer. Empty + broadcast=false would accept nobody, which is why
2427
2562
  * posting an unaddressed question is refused up front. */
2428
2563
  audience: Set<string>;
@@ -2438,16 +2573,148 @@ interface AskWaitCtx {
2438
2573
  /** Only so the resume hint printed on timeout names the right identity — the
2439
2574
  * ✅ rewrite can be done by the original poster alone. */
2440
2575
  asBot: boolean;
2441
- /** Text replies at or before this ts are ignored — the free-text reply a
2442
- * previous exit 5 already delivered. Reactions still count. */
2576
+ /** The CURSOR: everything at or before this ts was already delivered to the
2577
+ * caller. Text replies there are not answers again (the free-text reply an
2578
+ * exit 5 delivered), and not thread notes again. Reactions still count. */
2443
2579
  after?: string;
2580
+ /** `--json`: stdout is one JSON object (answer + notes + cursor) instead of
2581
+ * the bare answer. Exit codes are the same either way. */
2582
+ json: boolean;
2583
+ }
2584
+
2585
+ /** A reply from the question's audience that is NOT the answer — an extra
2586
+ * instruction ("also look at X"), a caveat, a question back after a pill.
2587
+ *
2588
+ * Reported from real use 2026-10-02: a pill was pressed and collected, ✅ was
2589
+ * written, and the watcher exited; 40 s later the same person added a note in
2590
+ * the thread, and nothing ever delivered it. A pill was the whole answer as far
2591
+ * as `ask` was concerned, so the thread was never read — and once ✅ was
2592
+ * written, `--waitFor` only ever returned the stored answer. */
2593
+ interface AskNote { ts: string; user: string; text: string; permalink: string }
2594
+
2595
+ /** Who and where notes are read from — the same audience rule as answers. */
2596
+ interface AskNoteScope {
2597
+ channelId: string;
2598
+ ts: string;
2599
+ threadParentTs?: string;
2600
+ threadOnly: boolean;
2601
+ /** The question's own `reply_count`, or undefined when the question was not
2602
+ * in the page we read. Its thread is skipped only on a KNOWN zero: a call
2603
+ * per collection on every threadless question is a cost paid for nothing,
2604
+ * but an unknown count read as zero loses the notes — in a busy channel the
2605
+ * question falls out of the 30-message history page. */
2606
+ replyCount: number | undefined;
2607
+ isAnswerer: (user: unknown) => user is string;
2608
+ askerUserId: string;
2609
+ askerBotId: string;
2610
+ cookie?: string;
2611
+ }
2612
+
2613
+ /** The audience's replies after `after` (or after the question), oldest first.
2614
+ * `skip` drops the reply that IS the answer.
2615
+ *
2616
+ * Fail-soft: a note is extra information, never the answer, so a failed fetch
2617
+ * is reported on stderr and must not cost the caller the answer it already
2618
+ * has. Thread replies come from the thread; in a 1:1 DM, plain replies count
2619
+ * too — bounded by our NEXT question there, exactly as answers are, so a note
2620
+ * meant for a newer question is not delivered with an older one. */
2621
+ async function askCollectNotes(token: string, sc: AskNoteScope, after: string | undefined, skip: (m: Record<string, Json>) => boolean): Promise<AskNote[]> {
2622
+ const pool = new Map<string, Record<string, Json>>();
2623
+ if (sc.threadParentTs || sc.replyCount !== 0) {
2624
+ try {
2625
+ const rep = asRecord((await replies(token, sc.channelId, sc.threadParentTs ?? sc.ts, 100, sc.cookie)) as Json);
2626
+ for (const m of asArray(rep.messages).map(asRecord)) pool.set(String(m.ts), m);
2627
+ } catch (e: unknown) {
2628
+ console.error(` (スレッドの追記を読めませんでした: ${e instanceof Error ? e.message : String(e)})`);
2629
+ }
2630
+ }
2631
+ if (!sc.threadOnly && !sc.threadParentTs) {
2632
+ try {
2633
+ const hist = asRecord((await history(token, sc.channelId, 30, sc.ts, undefined, sc.cookie, true)) as Json);
2634
+ const msgs = asArray(hist.messages).map(asRecord);
2635
+ const bound = msgs
2636
+ .filter((m) => Number(m.ts) > Number(sc.ts) && (
2637
+ (sc.askerBotId && m.bot_id === sc.askerBotId) || (sc.askerUserId && m.user === sc.askerUserId)))
2638
+ .reduce((min, m) => Math.min(min, Number(m.ts)), Infinity);
2639
+ for (const m of msgs) if (Number(m.ts) < bound && !pool.has(String(m.ts))) pool.set(String(m.ts), m);
2640
+ } catch (e: unknown) {
2641
+ console.error(` (DM の追記を読めませんでした: ${e instanceof Error ? e.message : String(e)})`);
2642
+ }
2643
+ }
2644
+ const picked = [...pool.values()]
2645
+ .filter((m) => Number(m.ts) > Number(sc.ts))
2646
+ .filter((m) => !after || Number(m.ts) > Number(after))
2647
+ // The asker is excluded by isAnswerer; any bot post (ours or another
2648
+ // integration's) is excluded here, so nothing echoes back as a "note".
2649
+ .filter((m) => sc.isAnswerer(m.user) && !m.bot_id)
2650
+ .filter((m) => typeof m.subtype !== "string" || ASK_ANSWERABLE_SUBTYPES.has(m.subtype))
2651
+ .filter((m) => typeof m.text === "string" && m.text.trim() !== "")
2652
+ // Sorted BEFORE `skip`, which may be stateful ("the first reply that …").
2653
+ .sort((a, b) => Number(a.ts) - Number(b.ts))
2654
+ .filter((m) => !skip(m));
2655
+ const out: AskNote[] = [];
2656
+ for (const m of picked) {
2657
+ let permalink = "";
2658
+ try {
2659
+ permalink = await getPermalink(token, sc.channelId, String(m.ts), sc.cookie);
2660
+ } catch {
2661
+ // fail-soft: the ts still names it
2662
+ }
2663
+ out.push({ ts: String(m.ts), user: String(m.user), text: (m.text as string).trim(), permalink });
2664
+ }
2665
+ return out;
2444
2666
  }
2445
2667
 
2668
+ /** The next cursor: the newest thing this result delivered, never older than
2669
+ * the cursor it was given. A caller that passes it back as `--after` gets
2670
+ * nothing twice. */
2671
+ function askNextCursor(...tss: (string | undefined)[]): string {
2672
+ return tss.filter((t): t is string => !!t).reduce((a, b) => (!a || Number(b) > Number(a) ? b : a), "");
2673
+ }
2674
+
2675
+ /** Deliver an answer (or the lack of one). Plain mode keeps the contract —
2676
+ * stdout is the answer alone — and puts the notes and the next-cursor hint on
2677
+ * stderr. `--json` puts all of it in one object on stdout. */
2678
+ async function askEmit(
2679
+ token: string,
2680
+ o: { json: boolean; shown: string; asBot: boolean; cookie?: string | undefined },
2681
+ r: { status: "answered" | "freetext" | "ambiguous" | "timeout" | "void"; answer: string | null; how?: string; who?: string; reason?: string; supersededBy?: string; notes: AskNote[]; cursor: string },
2682
+ ): Promise<void> {
2683
+ if (o.json) {
2684
+ console.log(JSON.stringify({
2685
+ status: r.status, answer: r.answer, how: r.how ?? null, who: r.who ?? null,
2686
+ ...(r.status === "void" ? { reason: r.reason ?? "", supersededBy: r.supersededBy ?? null } : {}),
2687
+ notes: r.notes, cursor: r.cursor, permalink: o.shown,
2688
+ }));
2689
+ return;
2690
+ }
2691
+ if (r.notes.length) {
2692
+ console.error(` スレッドの追記 ${r.notes.length} 件 (回答とは別):`);
2693
+ for (const n of r.notes) {
2694
+ let who = n.user;
2695
+ try {
2696
+ who = await userName(token, n.user, o.cookie);
2697
+ } catch {
2698
+ // the id still says who
2699
+ }
2700
+ for (const [i, line] of n.text.split("\n").entries()) {
2701
+ console.error(` ${i ? " ".repeat(stripTerminalControls(who).length + 2) : `${stripTerminalControls(who)}: `}${stripTerminalControls(line)}`);
2702
+ }
2703
+ }
2704
+ }
2705
+ if (r.status === "answered") {
2706
+ console.error(` この後の追記を拾う: ${askResumeCommand(o.shown, o.asBot, r.cursor)}`);
2707
+ }
2708
+ if (r.answer !== null) console.log(r.answer);
2709
+ }
2710
+
2711
+
2446
2712
  /** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
2447
2713
  * on timeout. A timeout of 0 means "look exactly once", so a caller can poll
2448
2714
  * cheaply on its own schedule instead of parking a process on `--wait`. */
2449
2715
  async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never> {
2450
2716
  const { channelId, ts, question, reactable, threadOnly, audience, broadcast, cookie, timeout, shown } = ctx;
2717
+ const copy = ASK_COPY[ctx.lang];
2451
2718
  const askerUserId = ctx.askerUserId;
2452
2719
  const askerBotId = ctx.askerBotId;
2453
2720
 
@@ -2458,6 +2725,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2458
2725
  const isAnswerer = (user: unknown): user is string =>
2459
2726
  typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
2460
2727
 
2728
+ /** The question's `reply_count` as last seen — whether its thread is worth a
2729
+ * fetch when collecting notes. Undefined until the question itself is seen. */
2730
+ let replyCount: number | undefined;
2731
+ /** The question's text as last read — what the ✅ rewrite keeps the body of. */
2732
+ let ownText: string | undefined;
2733
+ /** Set when the question is voided while we wait — the wait ends there. */
2734
+ let voided: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }> | undefined;
2735
+ const checkVoid = (own: Record<string, Json>): boolean => {
2736
+ const p = typeof own.text === "string" ? askParseMessage(own.text) : undefined;
2737
+ if (p?.kind === "void") voided = p;
2738
+ return !!voided;
2739
+ };
2740
+
2461
2741
  /** The earliest reply from an answerer that picked none of the choices. Kept
2462
2742
  * across polls so the timeout can report it, and so the operator is told once
2463
2743
  * rather than on every tick. */
@@ -2491,7 +2771,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2491
2771
  async function noteInvalid(msg: Record<string, Json>, offenders: string[]): Promise<void> {
2492
2772
  const text = typeof msg.text === "string" ? msg.text : "";
2493
2773
  if (!text) return;
2494
- const next = applyInvalidNotice(text, offenders);
2774
+ const next = applyInvalidNotice(text, offenders, ctx.lang);
2495
2775
  if (next === text) return;
2496
2776
  try {
2497
2777
  await editMessage(token, channelId, ts, next, cookie, true);
@@ -2501,7 +2781,22 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2501
2781
  }
2502
2782
  }
2503
2783
 
2784
+ /** Who has pressed ❓ ("other"). Not an answer — ❓ carries no text — so it
2785
+ * never resolves anything; the reply that should follow it does. Reported
2786
+ * once per person so the operator knows a reply is on its way rather than
2787
+ * reading the silence as "nobody looked". */
2788
+ const otherReported = new Set<string>();
2789
+ function noteOther(msg: Record<string, Json>): void {
2790
+ const r = asArray(msg.reactions).map(asRecord).find((x) => x.name === ASK_MARKER);
2791
+ for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
2792
+ if (otherReported.has(u)) continue;
2793
+ otherReported.add(u);
2794
+ console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
2795
+ }
2796
+ }
2797
+
2504
2798
  async function answerFromReactions(msg: Record<string, Json>): Promise<AskFound | null> {
2799
+ if (reactable.length) noteOther(msg);
2505
2800
  const picks = humanChoices(msg);
2506
2801
  // Nothing ambiguous any more — take the notice back down if one is up.
2507
2802
  if (picks.length <= 1 && readInvalidNotice(typeof msg.text === "string" ? msg.text : "").length) {
@@ -2510,15 +2805,26 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2510
2805
  if (!picks.length) return null;
2511
2806
  if (picks.length === 1) {
2512
2807
  const { index, users } = picks[0]!;
2513
- return { answer: reactable[index]!, how: `リアクション ${ASK_KEYCAPS[index]!.glyph}`, who: users[0]! };
2808
+ return { answer: reactable[index]!, how: copy.howReaction(ASK_KEYCAPS[index]!.glyph), who: users[0]!, choice: index + 1 };
2514
2809
  }
2515
2810
  const glyphs = picks.map((p) => ASK_KEYCAPS[p.index]!.glyph).join(" / ");
2516
- console.error(` (${glyphs} が同時に選ばれています。1 つに絞ってもらうまで待ちます)`);
2517
- // Whoever is on more than one pill. Named in the question itself so the
2518
- // person who has to fix it is the person who sees it — and rewritten from
2519
- // the current state each pass, so reruns cannot stack up notices and the
2520
- // line clears itself once a reaction is taken back.
2521
- const offenders = [...new Set(picks.flatMap((p) => p.users))].sort();
2811
+ // Two cases, both "do not guess": one person on several pills, or
2812
+ // different people on different pills (a split). Only the first is the
2813
+ // voter's to fix, so only those people are named in the message.
2814
+ const pillsPerUser = new Map<string, number>();
2815
+ for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
2816
+ const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
2817
+ console.error(multi.length
2818
+ ? ` (${glyphs} が同時に選ばれています。1 つに絞ってもらうまで待ちます)`
2819
+ : ` (回答が分かれています: ${glyphs}。どちらか一方にそろうまで待ちます)`);
2820
+ // Whoever is on more than one pill — and ONLY them. Naming everyone on any
2821
+ // pill told a voter who had pressed exactly one that she had pressed two
2822
+ // (2026-10-01: a voter on 1️⃣ only was named because someone else was on 1️⃣
2823
+ // and 2️⃣). Named in the question itself so the person who has to fix it is
2824
+ // the person who sees it; rewritten from the current state each pass, so
2825
+ // reruns cannot stack up notices and the line clears itself once a
2826
+ // reaction is taken back. A pure split names nobody (empty = no notice).
2827
+ const offenders = multi.sort();
2522
2828
  await noteInvalid(msg, offenders);
2523
2829
  return null;
2524
2830
  }
@@ -2542,13 +2848,13 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2542
2848
  if (!t) continue;
2543
2849
  // A question asked WITHOUT choices is answered by whatever comes back —
2544
2850
  // there is nothing to match against, and every reply is the answer.
2545
- if (!candidates.length) return { answer: t, how: "返信", who: m.user };
2851
+ if (!candidates.length) return { answer: t, how: copy.howReply, who: m.user, ts: String(m.ts) };
2546
2852
  const match = askMatchChoice(t, candidates);
2547
2853
  if (match.kind === "chosen") {
2548
2854
  // Answer with the CHOICE, not with the reply that selected it: "2" and
2549
2855
  // "2. 中止" have to reach the caller as the same decision a pill would
2550
2856
  // have produced, or the same answer arrives in three spellings.
2551
- return { answer: candidates[match.index - 1]!, how: `返信 (${match.index})`, who: m.user };
2857
+ return { answer: candidates[match.index - 1]!, how: copy.howReplyN(match.index), who: m.user, ts: String(m.ts), choice: match.index };
2552
2858
  }
2553
2859
  // Replied, but picked nothing. Recorded rather than returned: a later
2554
2860
  // reply may still choose, and the first one is what the operator needs to
@@ -2582,6 +2888,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2582
2888
  const messages = asArray(rep.messages).map(asRecord);
2583
2889
  const own = messages.find((m) => m.ts === ts);
2584
2890
  if (own) {
2891
+ replyCount = Number(own.reply_count) || 0;
2892
+ if (typeof own.text === "string") ownText = own.text;
2893
+ if (checkVoid(own)) return null;
2585
2894
  const byReaction = await answerFromReactions(own);
2586
2895
  if (byReaction) return byReaction;
2587
2896
  }
@@ -2595,6 +2904,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2595
2904
  const messages = asArray(hist.messages).map(asRecord);
2596
2905
  const own = messages.find((m) => m.ts === ts);
2597
2906
 
2907
+ if (own) {
2908
+ replyCount = Number(own.reply_count) || 0;
2909
+ if (typeof own.text === "string") ownText = own.text;
2910
+ if (checkVoid(own)) return null;
2911
+ }
2598
2912
  // A reaction is the intended path, so it wins when both are present.
2599
2913
  if (own) {
2600
2914
  const byReaction = await answerFromReactions(own);
@@ -2648,7 +2962,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2648
2962
  // and must keep getting the answer body alone.
2649
2963
  if (who) console.error(` 回答者: ${stripTerminalControls(who)}`);
2650
2964
  try {
2651
- await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who), cookie, true);
2965
+ // The body and the chosen option stay; only what no longer applies goes.
2966
+ // Built from the message as it is NOW (an edit made in Slack after asking
2967
+ // is kept). Not seen this run — the bare form, as before.
2968
+ const keep = ownText ? askResolvedKeep(ownText, found.choice) : undefined;
2969
+ await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
2652
2970
  // Swap the marker for the resolved one so search reflects reality:
2653
2971
  // `has::question:` should list what still needs answering, not everything
2654
2972
  // ever asked. Removal last — a crash between the two leaves the question
@@ -2684,10 +3002,34 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2684
3002
  }
2685
3003
  console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
2686
3004
  }
3005
+ if (voided) {
3006
+ askReportVoid(voided, shown);
3007
+ if (ctx.json) {
3008
+ await askEmit(token, ctx, {
3009
+ status: "void", answer: null, reason: voided.reason,
3010
+ ...(voided.supersededBy ? { supersededBy: voided.supersededBy } : {}),
3011
+ notes: [], cursor: askNextCursor(ts, ctx.after),
3012
+ });
3013
+ }
3014
+ process.exit(ASK_EXIT_VOID);
3015
+ }
2687
3016
  if (found) {
3017
+ // Notes once the answer is in hand: a pill used to end the poll before
3018
+ // the thread was ever read, which is how a note beside it was lost. Read
3019
+ // BEFORE ✅ is written, and delivered right after — ✅ is what tells a
3020
+ // later --waitFor the question is settled.
3021
+ const notes = await askCollectNotes(token, {
3022
+ channelId, ts, threadOnly, replyCount, isAnswerer, askerUserId, askerBotId,
3023
+ ...(ctx.threadParentTs ? { threadParentTs: ctx.threadParentTs } : {}),
3024
+ ...(cookie ? { cookie } : {}),
3025
+ }, ctx.after, (m) => m.ts === found!.ts);
2688
3026
  await markResolved(found);
2689
- // stdout gets the answer and nothing else.
2690
- console.log(found.answer);
3027
+ const r: Parameters<typeof askEmit>[2] = {
3028
+ status: "answered", answer: found.answer, how: found.how, notes,
3029
+ cursor: askNextCursor(ts, ctx.after, found.ts, ...notes.map((n) => n.ts)),
3030
+ };
3031
+ if (found.who) r.who = found.who;
3032
+ await askEmit(token, ctx, r);
2691
3033
  process.exit(0);
2692
3034
  }
2693
3035
  // A free-text reply that picks nothing is delivered NOW, not held until the
@@ -2706,7 +3048,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2706
3048
  console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
2707
3049
  console.error(` ${shown}`);
2708
3050
  console.error(` この返信の後を待つ: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
2709
- console.log(unchosen.text);
3051
+ if (ctx.json) {
3052
+ await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
3053
+ } else {
3054
+ console.log(unchosen.text);
3055
+ }
2710
3056
  process.exit(ASK_EXIT_FREETEXT);
2711
3057
  }
2712
3058
  // Said as soon as it is seen, not only at the timeout: the reply is usually
@@ -2748,6 +3094,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2748
3094
  console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
2749
3095
  console.error(` ${shown}`);
2750
3096
  console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3097
+ if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
2751
3098
  process.exit(ASK_EXIT_UNCHOSEN);
2752
3099
  }
2753
3100
  if (timeout === 0) {
@@ -2757,6 +3104,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2757
3104
  console.error(` ${shown}`);
2758
3105
  console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
2759
3106
  }
3107
+ if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
2760
3108
  process.exit(ASK_EXIT_TIMEOUT);
2761
3109
  }
2762
3110
 
@@ -2807,6 +3155,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2807
3155
  const qEnc = askEncodeBroadcasts(unescapeArg(args.question));
2808
3156
  const bEnc = askEncodeBroadcasts(unescapeArg(args.body ?? ""));
2809
3157
  const broadcastKinds = new Set([...qEnc.kinds, ...bEnc.kinds]);
3158
+ // Already-encoded broadcasts (`<!here>`) — what a re-ask of a posted question carries.
3159
+ for (const m of `${qEnc.text}\n${bEnc.text}`.matchAll(/<!(here|channel|everyone)(?:\|[^>]*)?>/g)) broadcastKinds.add(m[1]!);
2810
3160
  const mentionToken = args.mentionToken ?? token;
2811
3161
  const qRep = await encodeMentionsDetailed(mentionToken, qEnc.text, channelId, args.mentionCookie);
2812
3162
  const bRep = bEnc.text
@@ -2824,6 +3174,14 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2824
3174
  // Tagging yourself grants nothing — your own reactions are the seeds.
2825
3175
  const audience = new Set(resolved.map((r) => r.userId).filter((id) => id !== selfId));
2826
3176
  const audienceNames = new Map(resolved.map((r) => [r.userId, r.display]));
3177
+ // Tags already in Slack's `<@U…>` form name someone just as surely — that is
3178
+ // what a re-ask built from a posted question carries. Same self-exclusion.
3179
+ for (const m of `${question}\n${body}`.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)) {
3180
+ const id = m[1]!;
3181
+ if (id === selfId || audience.has(id)) continue;
3182
+ audience.add(id);
3183
+ audienceNames.set(id, await userName(mentionToken, id, args.mentionCookie));
3184
+ }
2827
3185
  let audienceLabel: string;
2828
3186
  if (broadcast) {
2829
3187
  audienceLabel = `anyone in ${ref} (${[...broadcastKinds].map((k) => `@${k}`).join(", ")})`;
@@ -2866,7 +3224,19 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2866
3224
  // which makes channel mode unambiguous by construction. A question posted
2867
3225
  // INTO a thread is thread-scoped for the same reason.
2868
3226
  const threadOnly = !channelId.startsWith("D") || !!threadTs;
2869
- const message = askBuildText(question, body, reactable, overflow, threadOnly);
3227
+ // The copy's language. The readers are the people who have to read it, so
3228
+ // their Slack locale comes first — but only for a known set of people: an
3229
+ // @here audience is not one, and is left to the content and the system.
3230
+ const readerLocales = broadcast
3231
+ ? []
3232
+ : await Promise.all([...audience].map((id) => userLocale(mentionToken, id, args.mentionCookie)));
3233
+ const picked = askResolveLang({ flag: args.lang, readerLocales, content: [question, body, ...reactable, ...overflow] });
3234
+ if (!picked) {
3235
+ console.error(`Error: unsupported language "${stripTerminalControls(args.lang ?? process.env.SLACK_TERM_LANG ?? "")}" — use one of: ${ASK_LANGS.join(", ")}`);
3236
+ process.exit(ASK_EXIT_ERROR);
3237
+ }
3238
+ const lang = picked.lang;
3239
+ const message = askBuildText(question, body, reactable, overflow, threadOnly, lang);
2870
3240
  guardUrlBoundaries(message, args.allowUrlAdjacent);
2871
3241
 
2872
3242
  // Preview the destination's last message, exactly as `send` does — the gate's
@@ -2893,7 +3263,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2893
3263
  // Resolved before the gate so the clock can name the recipient's zone. DM
2894
3264
  // only; null means "unknown" and the label says so rather than guessing.
2895
3265
  const recipientTz = await recipientTzFor(token, channelId, cookie);
2896
- const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "");
3266
+ const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "",
3267
+ ...(args.voidAfter ? ["reask", `${args.voidAfter.channelId}:${args.voidAfter.ts}`, args.voidAfter.reason] : []));
2897
3268
  if (args.code !== code) {
2898
3269
  const dest = await destLabel(token, channelId, ref, cookie);
2899
3270
  const choiceLines = reactable.length
@@ -2916,6 +3287,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2916
3287
  // shown as its own line rather than left to be inferred from the body.
2917
3288
  ` Answerable by: ${audienceLabel}`,
2918
3289
  ` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
3290
+ ...(args.voidAfter ? [` Then 無効化: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) — replaced by this question`] : []),
3291
+ ` Language: ${lang} (${({ flag: "--lang", env: "SLACK_TERM_LANG", readers: "answerers' Slack locale", content: "the question's text", system: "system locale", default: "default" })[picked.source]}; override with --lang)`,
2919
3292
  `--------------------------------────────────`,
2920
3293
  ], recipientTz);
2921
3294
  }
@@ -2926,7 +3299,9 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2926
3299
  const attr = sentAttribution("ask");
2927
3300
  const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
2928
3301
 
2929
- // The marker goes on FIRST so it sits left of the pills, and as a reaction so
3302
+ // The marker goes on LAST, after 1,2,3, because it doubles as the "other"
3303
+ // choice the body lists under the numbered ones (people were already pressing
3304
+ // ❓ for "none of these fit"). It is a reaction so
2930
3305
  // `has::question:` lists every question the way `has::pushpin:` lists every
2931
3306
  // todo. Body text cannot do that job: Slack's index splits on punctuation, so
2932
3307
  // a `:question:` written in the text is indexed as the word "question" and
@@ -2935,12 +3310,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2935
3310
  // NOTE the colons: the modifier's argument is itself colon-wrapped, and the
2936
3311
  // bare `has:question` form does NOT error — it silently degrades to a
2937
3312
  // full-text search and returns plausible-looking counts. See ts/todo.ts.
2938
- // Marker first, then 1,2,3 — and spaced out. Sequential awaits alone are NOT
3313
+ // 1,2,3, then the marker — and spaced out. Sequential awaits alone are NOT
2939
3314
  // enough: measured on two real questions from the same build, one came back
2940
3315
  // `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
2941
3316
  // SLACK_REACTION_SEED_GAP_MS.
2942
3317
  await seedReactionsInOrder(
2943
- [ASK_MARKER, ...reactable.map((_, i) => ASK_KEYCAPS[i]!.name)],
3318
+ [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
2944
3319
  (name) => reactionAdd(token, channelId, ts, name, cookie),
2945
3320
  (name, e) => {
2946
3321
  const why = e instanceof Error ? e.message : String(e);
@@ -2960,6 +3335,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2960
3335
  const shown = permalink || `${channelId}:${ts}`;
2961
3336
  console.error(`✓ Asked: ${shown}`);
2962
3337
  attr.record({ team: self?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
3338
+ // The replacement is up; now retire what it replaces. A failure here leaves
3339
+ // two open questions, which is reported loudly — never a missing one.
3340
+ if (args.voidAfter) {
3341
+ const ok = await askApplyVoid(token, args.voidAfter, args.voidAfter.reason, shown, cookie);
3342
+ if (!ok) console.error(` ⚠ 新しい質問は出ましたが、古い質問を無効化できませんでした — 手で: slack ask --void='${stripTerminalControls(args.voidAfter.link)}' --superseded-by='${shown}'`);
3343
+ }
2963
3344
 
2964
3345
  if (!args.wait) {
2965
3346
  // Not waiting. stdout is the RESUME COMMAND, not just a link: nothing else
@@ -2981,6 +3362,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2981
3362
  reactable,
2982
3363
  overflow,
2983
3364
  threadOnly,
3365
+ lang,
2984
3366
  audience,
2985
3367
  broadcast,
2986
3368
  askerUserId: self?.userId ?? "",
@@ -2988,6 +3370,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2988
3370
  timeout,
2989
3371
  shown,
2990
3372
  asBot: !!args.asBot,
3373
+ json: !!args.json,
2991
3374
  };
2992
3375
  if (threadTs) ctx.threadParentTs = threadTs;
2993
3376
  if (cookie) ctx.cookie = cookie;
@@ -2996,6 +3379,423 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2996
3379
 
2997
3380
 
2998
3381
 
3382
+ /** Say on stderr that a question is void, and where its replacement is. */
3383
+ function askReportVoid(p: { reason: string; supersededBy?: string }, shown: string): void {
3384
+ console.error(`この質問は無効です — 回答されることはありません。待つのをやめてください: ${stripTerminalControls(shown)}`);
3385
+ if (p.reason) console.error(` ${stripTerminalControls(p.reason)}`);
3386
+ if (p.supersededBy) console.error(` 新しい質問: ${stripTerminalControls(p.supersededBy)}`);
3387
+ }
3388
+
3389
+ /** Find the one message a permalink (or `C…:ts`) names. A question posted into
3390
+ * a thread is not in history at all, so where the link says it is a reply, it
3391
+ * is read from the thread. */
3392
+ async function askFetchByLink(token: string, link: string, cookie?: string): Promise<
3393
+ { channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> } | null
3394
+ > {
3395
+ const url = parseSlackPermalink(link);
3396
+ let channelId: string;
3397
+ let ts: string;
3398
+ let threadTs: string | undefined;
3399
+ if (url?.ts) {
3400
+ channelId = url.channel;
3401
+ ts = url.ts;
3402
+ threadTs = url.threadTs && url.threadTs !== url.ts ? url.threadTs : undefined;
3403
+ } else {
3404
+ const m = link.match(/^([A-Za-z0-9]+):(\d{10}\.\d{6})$/);
3405
+ if (!m) return null;
3406
+ channelId = m[1]!;
3407
+ ts = m[2]!;
3408
+ }
3409
+ let msg: Record<string, Json> | undefined;
3410
+ if (threadTs) {
3411
+ const rep = asRecord((await replies(token, channelId, threadTs, 100, cookie)) as Json);
3412
+ msg = asArray(rep.messages).map(asRecord).find((m) => m.ts === ts);
3413
+ } else {
3414
+ const hist = asRecord((await history(token, channelId, 1, ts, undefined, cookie, true)) as Json);
3415
+ msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
3416
+ }
3417
+ const out: { channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> } = { channelId, ts };
3418
+ if (threadTs) out.threadTs = threadTs;
3419
+ if (msg) out.msg = msg;
3420
+ return out;
3421
+ }
3422
+
3423
+ /** Look up a question for a lifecycle action (edit / void / reask): it must
3424
+ * exist, be OUR OWN post (Slack lets only the author edit, and changing
3425
+ * someone else's decision is not a thing this should make possible), and be
3426
+ * open. Says why on stderr and returns null otherwise. */
3427
+ async function askLoadOwnOpen(token: string, link: string, self: Self | null, asBot: boolean, cookie?: string):
3428
+ Promise<(AskVoidTarget & { msg: Record<string, Json>; threadTs?: string }) | "void" | null> {
3429
+ const shown = stripTerminalControls(link);
3430
+ const got = await askFetchByLink(token, link, cookie);
3431
+ if (!got) { console.error(`✗ ${shown}: permalink (または 'C…:1700000000.000100') ではありません`); return null; }
3432
+ if (!got.msg) { console.error(`✗ ${shown}: メッセージが見つかりません`); return null; }
3433
+ const msg = got.msg;
3434
+ const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
3435
+ if (!own) {
3436
+ console.error(`✗ ${shown}: 自分(${senderLabel(self, asBot)})が投稿した質問ではありません` +
3437
+ `${asBot ? "" : " — bot が出した質問なら --as-bot で"}`);
3438
+ return null;
3439
+ }
3440
+ const text = typeof msg.text === "string" ? msg.text : "";
3441
+ const parsed = askParseMessage(text);
3442
+ if (parsed.kind === "void") return "void";
3443
+ if (parsed.kind === "resolved") { console.error(`✗ ${shown}: 回答済みの質問です(回答: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})— 変更できません`); return null; }
3444
+ if (parsed.kind !== "open") { console.error(`✗ ${shown}: \`slack ask\` の質問として読み取れません — ${askExplainReject(text)}`); return null; }
3445
+ const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, msg };
3446
+ if (got.threadTs) out.threadTs = got.threadTs;
3447
+ return out;
3448
+ }
3449
+
3450
+ /** Has anyone but us answered, or started to? A pressed keycap, a reply in the
3451
+ * thread, or (in a 1:1 DM) a plain message after the question. Used to lock the
3452
+ * OPTIONS: after a vote, renumbering them would make the reactions already
3453
+ * given silently mean something else. */
3454
+ async function askHasResponses(token: string, t: { channelId: string; ts: string; msg: Record<string, Json> }, self: Self | null, cookie?: string): Promise<boolean> {
3455
+ const notSelf = (u: unknown) => typeof u === "string" && u !== self?.userId;
3456
+ const pressed = asArray(t.msg.reactions).map(asRecord)
3457
+ .some((r) => ASK_KEYCAPS.some((k) => k.name === r.name) && asArray(r.users).some(notSelf));
3458
+ if (pressed) return true;
3459
+ // A thread counts only if someone OTHER than us is in it — our own
3460
+ // follow-up note must not lock the options.
3461
+ if (Number(t.msg.reply_count) > 0) {
3462
+ const rep = asRecord((await replies(token, t.channelId, t.ts, 100, cookie)) as Json);
3463
+ if (asArray(rep.messages).map(asRecord).some((m) => Number(m.ts) > Number(t.ts) && notSelf(m.user) && !m.bot_id)) return true;
3464
+ }
3465
+ if (t.channelId.startsWith("D")) {
3466
+ const hist = asRecord((await history(token, t.channelId, 30, t.ts, undefined, cookie, true)) as Json);
3467
+ if (asArray(hist.messages).map(asRecord).some((m) => Number(m.ts) > Number(t.ts) && notSelf(m.user) && !m.bot_id)) return true;
3468
+ }
3469
+ return false;
3470
+ }
3471
+
3472
+ /** `slack ask --edit=<link> ["new question" [choices…]] [--body …]` — change an
3473
+ * open question THROUGH the ask builder, so pills, ❓, the instructions and the
3474
+ * body stay in the layout collect reads back (a free-hand `slack edit` is what
3475
+ * this replaces; `edit` now refuses ask messages without --force).
3476
+ *
3477
+ * The wording (question, body) can change any time. The OPTIONS can change only
3478
+ * while nobody has answered: after a vote the old reactions would silently mean
3479
+ * the new options. Then it refuses and points at `--void=<link> --reask`, which
3480
+ * retires the question and asks the new one. Answered / void: refused. */
3481
+ async function cmdAskEdit(token: string, args: {
3482
+ link: string; question?: string; body?: string; choices?: string[]; lang?: string;
3483
+ code?: string; asBot: boolean; cookie?: string; mentionToken: string; mentionCookie?: string;
3484
+ allowUrlAdjacent?: boolean;
3485
+ }): Promise<void> {
3486
+ const self = await selfIdentity(token, args.cookie);
3487
+ const t = await askLoadOwnOpen(token, args.link, self, args.asBot, args.cookie);
3488
+ if (t === "void") { console.error(`✗ ${stripTerminalControls(args.link)}: 無効化済みの質問は編集できません`); process.exit(ASK_EXIT_ERROR); }
3489
+ if (!t) process.exit(ASK_EXIT_ERROR);
3490
+ const old = t.parsed;
3491
+ const encode = async (raw: string): Promise<string> => {
3492
+ const enc = askEncodeBroadcasts(unescapeArg(raw));
3493
+ return (await encodeMentionsDetailed(args.mentionToken, enc.text, t.channelId, args.mentionCookie)).text;
3494
+ };
3495
+ const question = args.question !== undefined ? await encode(args.question) : old.question;
3496
+ const body = args.body !== undefined ? await encode(args.body) : old.body;
3497
+ const oldChoices = [...old.reactable, ...old.overflow];
3498
+ const choices = args.choices ? args.choices.map(unescapeArg) : oldChoices;
3499
+ const choicesChanged = JSON.stringify(choices.map(askFlatten)) !== JSON.stringify(oldChoices.map(askFlatten));
3500
+ if (choicesChanged && await askHasResponses(token, t, self, args.cookie)) {
3501
+ console.error(
3502
+ `✗ この質問にはもう回答(リアクションか返信)があります — 選択肢を変えると、押されたリアクションの意味が変わってしまいます。\n` +
3503
+ ` 質問文と本文は変えられます。選択肢を変えるなら無効化して出し直してください:\n` +
3504
+ ` slack ask --void='${stripTerminalControls(args.link)}' --reask${args.asBot ? " --as-bot" : ""} …`,
3505
+ );
3506
+ process.exit(ASK_EXIT_ERROR);
3507
+ }
3508
+ // Still addressed to someone: the tags are who may answer, and an edit must
3509
+ // not leave a question nobody may answer (a 1:1 DM needs none).
3510
+ // Tagging yourself grants nothing — same rule as when asking.
3511
+ const tagged = [...`${question}\n${body}`.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].some((m) => m[1] !== self?.userId);
3512
+ const addressed = tagged || /<!(?:here|channel|everyone)/.test(`${question}\n${body}`) || t.channelId.startsWith("D");
3513
+ if (!addressed) {
3514
+ console.error(`✗ 誰にも宛てられていない質問になります — 回答できる人を @ で残してください`);
3515
+ process.exit(ASK_EXIT_ERROR);
3516
+ }
3517
+ const lang = args.lang ? askResolveLang({ flag: args.lang })?.lang ?? old.lang : old.lang;
3518
+ const reactable = choices.slice(0, ASK_MAX_REACTION_CHOICES);
3519
+ const overflow = choices.slice(ASK_MAX_REACTION_CHOICES);
3520
+ const oldText = typeof t.msg.text === "string" ? t.msg.text : "";
3521
+ let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang);
3522
+ // An invalid-ballot notice still in force stays (it names the people who
3523
+ // have to fix their vote); it is only possible when the options did not change.
3524
+ const invalid = readInvalidNotice(oldText);
3525
+ if (invalid.length) next = applyInvalidNotice(next, invalid, lang);
3526
+ guardUrlBoundaries(next, args.allowUrlAdjacent);
3527
+ if (next === oldText) {
3528
+ console.error("変更はありません。");
3529
+ return;
3530
+ }
3531
+
3532
+ const code = safetyCode("ask-edit", `${t.channelId}:${t.ts}`, next, self?.userId ?? "");
3533
+ if (args.code !== code) {
3534
+ const show = (label: string, a: string, b: string) => a === b
3535
+ ? [` ${label}: (unchanged)`]
3536
+ : [` ${label}:`, ...a.split("\n").map((l) => ` - ${l}`), ...b.split("\n").map((l) => ` + ${l}`)];
3537
+ requireCode(args.code, code, [
3538
+ `--- Editing question --------------------------`,
3539
+ fromLine(self, { asBot: args.asBot }),
3540
+ ` ${stripTerminalControls(args.link)}`,
3541
+ ...show("Question", old.question, question),
3542
+ ...show("Body", old.body, body),
3543
+ ...(choicesChanged
3544
+ ? [` Choices (nobody has answered yet — pills are re-seeded):`,
3545
+ ...oldChoices.map((c, i) => ` - ${i + 1}. ${askFlatten(c)}`),
3546
+ ...choices.map((c, i) => ` + ${i + 1}. ${askFlatten(c)}`)]
3547
+ : [` Choices: (unchanged)`]),
3548
+ ...(lang !== old.lang ? [` Language: ${old.lang} → ${lang} (the instructions are rewritten)`] : []),
3549
+ `----------------------------------------------`,
3550
+ ]);
3551
+ }
3552
+
3553
+ await editMessage(token, t.channelId, t.ts, next, args.cookie, true);
3554
+ if (choicesChanged) {
3555
+ // Re-seed in order: 1..n then ❓ last, as a fresh question has them.
3556
+ for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
3557
+ try { await reactionRemove(token, t.channelId, t.ts, name, args.cookie); } catch { /* already gone */ }
3558
+ }
3559
+ await seedReactionsInOrder(
3560
+ [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
3561
+ (name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
3562
+ (name, e) => console.error(` (リアクション ${name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`),
3563
+ );
3564
+ }
3565
+ console.error(`✓ 編集しました: ${stripTerminalControls(args.link)}`);
3566
+ }
3567
+
3568
+ /** `slack ask --ls [--stale <dur>] [--state …] [--channel …] [--json]` — the
3569
+ * questions I posted, with their LIVE state.
3570
+ *
3571
+ * The list comes from the local sent-log (every `ask` is recorded there with
3572
+ * its channel, ts and identity, user or bot); the state comes from Slack, one
3573
+ * read per question, so a question answered, voided or edited elsewhere shows
3574
+ * as it is now. Limitation: only asks sent from THIS machine's log.
3575
+ *
3576
+ * `--stale 24h` = open and older than that — the morning triage's list. It
3577
+ * prints, on stderr, the one `--void=` command that would retire them all
3578
+ * (behind the usual gate), rather than voiding anything itself. */
3579
+ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }, args: {
3580
+ stale?: string; state: string; channel?: string; json: boolean; limit: number;
3581
+ }): Promise<void> {
3582
+ const q: Parameters<typeof querySent>[0] = { kind: "ask", limit: args.limit };
3583
+ if (args.channel) q.channel = args.channel;
3584
+ const rows = querySent(q);
3585
+ if (!rows) {
3586
+ console.error(`Error: ${SENT_LOG_UNAVAILABLE}\n (${sentDbPath()})`);
3587
+ process.exit(ASK_EXIT_ERROR);
3588
+ }
3589
+ let staleBefore: number | undefined;
3590
+ if (args.stale) {
3591
+ staleBefore = parseSince(args.stale);
3592
+ if (staleBefore === undefined) {
3593
+ console.error(`Error: --stale needs a duration like 24h / 3d (got ${stripTerminalControls(args.stale)})`);
3594
+ process.exit(ASK_EXIT_ERROR);
3595
+ }
3596
+ }
3597
+ type Item = { state: string; age_h: number; asBot: boolean; channel: string; target: string; link: string; question: string; answer?: string };
3598
+ const items: Item[] = [];
3599
+ for (const r of rows) {
3600
+ if (staleBefore !== undefined && r.sent_at > staleBefore) continue;
3601
+ const token = r.as_bot ? tokens.bot : tokens.user;
3602
+ const link = r.permalink || `${r.channel}:${r.ts}`;
3603
+ let state = "unknown";
3604
+ let question = (r.text.split("\n")[0] ?? "").replace(/^:question: /, "");
3605
+ let answer: string | undefined;
3606
+ if (token) {
3607
+ try {
3608
+ const got = await askFetchByLink(token, `${r.channel}:${r.ts}`, r.as_bot ? undefined : tokens.cookie);
3609
+ if (!got?.msg) state = "gone";
3610
+ else {
3611
+ const p = askParseMessage(typeof got.msg.text === "string" ? got.msg.text : "");
3612
+ state = p.kind === "resolved" ? "answered" : p.kind === "other" ? "edited" : p.kind;
3613
+ if (p.kind !== "other" && p.question) question = p.question;
3614
+ if (p.kind === "resolved") answer = p.answer;
3615
+ }
3616
+ } catch {
3617
+ state = "unknown";
3618
+ }
3619
+ }
3620
+ if (args.state !== "all" && state !== args.state) continue;
3621
+ const item: Item = {
3622
+ state, age_h: Math.round((Date.now() - r.sent_at) / 3600e3), asBot: !!r.as_bot,
3623
+ channel: r.channel, target: r.target ?? r.channel, link, question: askFlatten(question),
3624
+ };
3625
+ if (answer !== undefined) item.answer = answer;
3626
+ items.push(item);
3627
+ }
3628
+ if (args.json) {
3629
+ console.log(JSON.stringify(items));
3630
+ } else {
3631
+ for (const it of items) {
3632
+ console.log(`${it.state.padEnd(8)} ${String(it.age_h).padStart(4)}h ${it.asBot ? "bot " : "user"} ${stripTerminalControls(it.target)} ${it.link}\n ${stripTerminalControls(it.question).slice(0, 100)}`);
3633
+ }
3634
+ console.error(`${items.length} question(s)${args.state === "all" ? "" : ` in state "${args.state}"`}${args.stale ? `, older than ${args.stale}` : ""}.`);
3635
+ }
3636
+ // The bulk path: one gated command per identity, never an automatic void.
3637
+ const open = items.filter((it) => it.state === "open");
3638
+ if (args.stale && open.length) {
3639
+ for (const bot of [false, true]) {
3640
+ const mine = open.filter((it) => it.asBot === bot);
3641
+ if (!mine.length) continue;
3642
+ console.error(`無効化するなら (${mine.length} 件, ${bot ? "bot" : "user"}):\n slack ask ${mine.map((it) => `--void='${it.link}'`).join(" ")} --reason 'stale'${bot ? " --as-bot" : ""}`);
3643
+ }
3644
+ }
3645
+ }
3646
+
3647
+ /** A question about to be voided: where it is, and what it parsed as. */
3648
+ interface AskVoidTarget {
3649
+ link: string;
3650
+ channelId: string;
3651
+ ts: string;
3652
+ parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>;
3653
+ }
3654
+
3655
+ /** Void one question: the 🚫 text (body kept), our seeds and ❓ off, 🚫 on.
3656
+ * The text is what marks it void; the reactions are cleanup + searchability,
3657
+ * so a failure there is reported and does not undo the void. */
3658
+ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, supersededBy: string | undefined, cookie?: string): Promise<boolean> {
3659
+ const shown = stripTerminalControls(t.link);
3660
+ try {
3661
+ const next = askBuildVoidText(t.parsed.question, reason, t.parsed.lang, t.parsed.body || undefined, supersededBy);
3662
+ await editMessage(token, t.channelId, t.ts, next, cookie, true);
3663
+ } catch (e: unknown) {
3664
+ console.error(`✗ ${shown}: 書き換えに失敗しました: ${e instanceof Error ? e.message : String(e)}`);
3665
+ return false;
3666
+ }
3667
+ for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
3668
+ try { await reactionRemove(token, t.channelId, t.ts, name, cookie); } catch { /* not ours / already gone */ }
3669
+ }
3670
+ try {
3671
+ await reactionAdd(token, t.channelId, t.ts, ASK_VOID_MARKER, cookie);
3672
+ } catch (e: unknown) {
3673
+ console.error(` (${shown}: 🚫 リアクションを付けられませんでした: ${e instanceof Error ? e.message : String(e)})`);
3674
+ }
3675
+ console.error(`✓ 無効化: ${shown}`);
3676
+ return true;
3677
+ }
3678
+
3679
+ /** `slack ask --void <permalink>…` — 無効化: mark questions that have expired or
3680
+ * stopped meaning anything, so nobody answers them and nothing waits on them.
3681
+ *
3682
+ * Case, 2026-10-05: a release ask was pinned to one head SHA; three minutes
3683
+ * later main moved and approving it would have approved a different head. The
3684
+ * bot had to void it by hand (prefix the text, leave the pills), and nothing
3685
+ * machine-readable said so: `--waitFor` still treated it as open, the
3686
+ * stale-pill reminder still counted it, the pills were still pressable.
3687
+ *
3688
+ * What voiding does, per question: the text becomes the 🚫 form (head, void
3689
+ * stamp with the reason, optional pointer to the replacement, the body); our
3690
+ * seeded pills and the ❓ marker come off; a 🚫 reaction goes on. Pills someone
3691
+ * already pressed stay — Slack lets only their owner remove them — and are
3692
+ * never collected, because a void is never polled again.
3693
+ *
3694
+ * Only your OWN questions: Slack lets only the author edit a message, and
3695
+ * voiding someone else's decision is not a thing this should make possible.
3696
+ * An answered question is final and is refused; an already-void one is a
3697
+ * no-op. Several links at once — the morning triage found 38 stale pills —
3698
+ * all behind ONE confirm code covering every target and the reason. */
3699
+ async function cmdAskVoid(token: string, args: { links: string[]; reason: string; supersededBy?: string; code?: string; asBot: boolean; cookie?: string }): Promise<void> {
3700
+ const self = await selfIdentity(token, args.cookie);
3701
+ // The replacement link is written into every voided question for good, so it
3702
+ // must name a real message before anything is touched — a typo would point
3703
+ // people at nothing, permanently.
3704
+ if (args.supersededBy) {
3705
+ const shown = stripTerminalControls(args.supersededBy);
3706
+ let found = false;
3707
+ try {
3708
+ const rep = await askFetchByLink(token, args.supersededBy, args.cookie);
3709
+ found = !!rep?.msg;
3710
+ if (!rep) {
3711
+ console.error(`Error: --superseded-by must be a message permalink (or 'C…:1700000000.000100'): ${shown}`);
3712
+ process.exit(ASK_EXIT_ERROR);
3713
+ }
3714
+ } catch (e: unknown) {
3715
+ console.error(`Error: --superseded-by を確認できません: ${shown} (${e instanceof Error ? e.message : String(e)})`);
3716
+ process.exit(ASK_EXIT_ERROR);
3717
+ }
3718
+ if (!found) {
3719
+ console.error(`Error: --superseded-by のメッセージが見つかりません: ${shown}`);
3720
+ process.exit(ASK_EXIT_ERROR);
3721
+ }
3722
+ }
3723
+ type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; pressed: string[]; unreadable?: boolean };
3724
+ const targets: Target[] = [];
3725
+ let refused = 0;
3726
+ for (const link of args.links) {
3727
+ const shown = stripTerminalControls(link);
3728
+ const got = await askFetchByLink(token, link, args.cookie);
3729
+ if (!got) { console.error(`✗ ${shown}: permalink (または 'C…:1700000000.000100') ではありません`); refused++; continue; }
3730
+ if (!got.msg) { console.error(`✗ ${shown}: メッセージが見つかりません`); refused++; continue; }
3731
+ const msg = got.msg;
3732
+ const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
3733
+ if (!own) {
3734
+ console.error(`✗ ${shown}: 自分(${senderLabel(self, args.asBot)})が投稿した質問ではないので無効化できません` +
3735
+ `${args.asBot ? "" : " — bot が出した質問なら --as-bot で"}`);
3736
+ refused++;
3737
+ continue;
3738
+ }
3739
+ const text = typeof msg.text === "string" ? msg.text : "";
3740
+ let parsed = askParseMessage(text);
3741
+ let unreadable = false;
3742
+ if (parsed.kind === "void") { console.error(`- ${shown}: すでに無効化済みです(何もしません)`); continue; }
3743
+ if (parsed.kind === "resolved") { console.error(`✗ ${shown}: 回答済みの質問は無効化できません(回答: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})`); refused++; continue; }
3744
+ if (parsed.kind !== "open") {
3745
+ // Our own ❓ question whose layout no longer parses (2026-10-01: a
3746
+ // `slack edit` flattened one; it could then be neither collected nor
3747
+ // voided, and stayed ❓ forever). Voiding is exactly what is left to do
3748
+ // with it, so it is voided from a best-effort read — question and body
3749
+ // kept as they are, our own seeded pills removed — and the gate says so.
3750
+ const salvage = askSalvageUnreadable(text);
3751
+ if (!salvage) { console.error(`✗ ${shown}: \`slack ask\` の質問として読み取れません — ${askExplainReject(text)}`); refused++; continue; }
3752
+ const seeded = asArray(msg.reactions).map(asRecord)
3753
+ .filter((r) => asArray(r.users).includes(self?.userId ?? "\u0000"))
3754
+ .map((r) => ASK_KEYCAPS.findIndex((k) => k.name === r.name))
3755
+ .filter((i) => i >= 0);
3756
+ parsed = {
3757
+ kind: "open", question: salvage.question, body: salvage.body,
3758
+ // Placeholders: only the COUNT is used — which of our keycap seeds to take off.
3759
+ reactable: Array.from({ length: seeded.length ? Math.max(...seeded) + 1 : 0 }, () => ""),
3760
+ overflow: [], threadOnly: true, lang: askDetectLang(text) ?? "ja",
3761
+ };
3762
+ unreadable = true;
3763
+ }
3764
+ const pressed = asArray(msg.reactions).map(asRecord)
3765
+ .filter((r) => typeof r.name === "string" && ASK_KEYCAPS.some((k) => k.name === r.name))
3766
+ .filter((r) => asArray(r.users).some((u) => u !== self?.userId))
3767
+ .map((r) => `:${String(r.name)}:`);
3768
+ targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, pressed, ...(unreadable ? { unreadable } : {}) });
3769
+ }
3770
+ if (!targets.length) {
3771
+ console.error(refused ? "無効化できる質問がありません。" : "何もすることがありません。");
3772
+ process.exit(refused ? ASK_EXIT_ERROR : 0);
3773
+ }
3774
+
3775
+ const code = safetyCode("ask-void", ...targets.map((t) => `${t.channelId}:${t.ts}`), args.reason, args.supersededBy ?? "", self?.userId ?? "");
3776
+ if (args.code !== code) {
3777
+ requireCode(args.code, code, [
3778
+ `--- 無効化 (void) -------------------------------`,
3779
+ fromLine(self, { asBot: args.asBot }),
3780
+ ` Reason: ${args.reason ? stripTerminalControls(args.reason) : "(none)"}`,
3781
+ ...(args.supersededBy ? [` Replaced: ${stripTerminalControls(args.supersededBy)}`] : []),
3782
+ ` ${targets.length} question(s):`,
3783
+ ...targets.map((t) => ` • ${stripTerminalControls(t.link)}\n ${stripTerminalControls(t.parsed.question.split("\n")[0] ?? "").slice(0, 100)}` +
3784
+ (t.pressed.length ? `\n (already pressed: ${t.pressed.join(" ")} — left in place, never collected)` : "") +
3785
+ (t.unreadable ? `\n ⚠ its layout no longer reads as an ask (edited by hand?) — voided from a best-effort read: question and the rest of the text kept as they are` : "")),
3786
+ ` Each becomes 🚫 (body kept, options and instructions removed); our pills and ❓ come off; a 🚫 reaction is added.`,
3787
+ ...(refused ? [` (${refused} link(s) above were refused and will be skipped)`] : []),
3788
+ `----------------------------------------------`,
3789
+ ]);
3790
+ }
3791
+
3792
+ let failed = 0;
3793
+ for (const t of targets) {
3794
+ if (!(await askApplyVoid(token, t, args.reason, args.supersededBy, args.cookie))) failed++;
3795
+ }
3796
+ process.exit(failed || refused ? ASK_EXIT_ERROR : 0);
3797
+ }
3798
+
2999
3799
  /** `slack ask --waitFor <permalink>` — collect the answer to a question that was
3000
3800
  * posted without blocking.
3001
3801
  *
@@ -3004,7 +3804,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3004
3804
  * file to go stale, lose, or disagree with Slack, and any machine holding the
3005
3805
  * permalink can collect — at the cost of the body being a parseable format
3006
3806
  * (`askBuildText` ⇄ `askParseMessage`). */
3007
- async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; cookie?: string; after?: string }): Promise<void> {
3807
+ async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string }): Promise<void> {
3008
3808
  const url = parseSlackPermalink(args.link);
3009
3809
  let channelId: string;
3010
3810
  let ts: string;
@@ -3056,13 +3856,6 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3056
3856
  );
3057
3857
  process.exit(ASK_EXIT_ERROR);
3058
3858
  }
3059
- if (parsed.kind === "resolved") {
3060
- // Answered while nobody was watching — the case that makes fire-and-forget
3061
- // safe. Report it exactly as `--wait` would have.
3062
- console.error(`✓ 回答済み: ${stripTerminalControls(parsed.question)}`);
3063
- console.log(parsed.answer);
3064
- return;
3065
- }
3066
3859
 
3067
3860
  // Who may answer, recovered from the tags the readers can see. They are
3068
3861
  // already `<@U…>`-encoded in the stored text, so no directory lookup is
@@ -3081,6 +3874,64 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3081
3874
  const counterpart = await imCounterpart(token, channelId, args.cookie);
3082
3875
  if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
3083
3876
  }
3877
+ if (parsed.kind === "void") {
3878
+ askReportVoid(parsed, args.link);
3879
+ if (args.json) {
3880
+ await askEmit(token, { json: true, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
3881
+ status: "void", answer: null, reason: parsed.reason,
3882
+ ...(parsed.supersededBy ? { supersededBy: parsed.supersededBy } : {}),
3883
+ notes: [], cursor: askNextCursor(ts, args.after),
3884
+ });
3885
+ }
3886
+ process.exit(ASK_EXIT_VOID);
3887
+ }
3888
+
3889
+ if (parsed.kind === "resolved") {
3890
+ // Answered while nobody was watching — the case that makes fire-and-forget
3891
+ // safe. Report it exactly as `--wait` would have, plus any notes the
3892
+ // audience left in the thread AFTER it was answered: that is the note that
3893
+ // was lost in real use (2026-10-02), written 40 s after ✅.
3894
+ //
3895
+ // There is NO default cursor here. The ✅ edit time looks like one, but it
3896
+ // would drop every note written before ✅ whenever the run that wrote ✅ did
3897
+ // not deliver them: a crash between the edit and the output, or — the case
3898
+ // that matters — any question resolved by a build that did not collect
3899
+ // notes at all. A caller that wants no repeats passes the cursor back.
3900
+ //
3901
+ // What must not come back is the reply that IS the answer. The ✅ stamp says
3902
+ // whether the answer was a reply, and which number it picked: the first
3903
+ // audience reply before the ✅ edit that reads as that answer is it.
3904
+ console.error(`✓ 回答済み: ${stripTerminalControls(parsed.question)}`);
3905
+ const editedTs = typeof asRecord(msg.edited).ts === "string" ? String(asRecord(msg.edited).ts) : undefined;
3906
+ const how = askResolvedHow(text);
3907
+ let answerSkipped = false;
3908
+ const isAnswerReply = (m: Record<string, Json>): boolean => {
3909
+ if (answerSkipped || !how.byReply) return false;
3910
+ if (editedTs && Number(m.ts) > Number(editedTs)) return false;
3911
+ const t = typeof m.text === "string" ? m.text.trim() : "";
3912
+ const hit = t === parsed.answer || (how.n !== undefined && (new RegExp(`^[((]?${how.n}(?![0-9])`).test(t) || askReplyPicked(t, parsed.answer, how.n)));
3913
+ if (hit) answerSkipped = true;
3914
+ return hit;
3915
+ };
3916
+ const after = args.after;
3917
+ let notes: AskNote[] = [];
3918
+ if (audience.size || broadcast) {
3919
+ const isAnswerer = (user: unknown): user is string =>
3920
+ typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
3921
+ notes = await askCollectNotes(token, {
3922
+ channelId, ts, threadOnly: !!threadTs || !channelId.startsWith("D"),
3923
+ replyCount: Number(msg.reply_count) || 0, isAnswerer, askerUserId, askerBotId,
3924
+ ...(threadTs ? { threadParentTs: threadTs } : {}),
3925
+ ...(args.cookie ? { cookie: args.cookie } : {}),
3926
+ }, after, isAnswerReply);
3927
+ }
3928
+ await askEmit(token, { json: args.json, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
3929
+ status: "answered", answer: parsed.answer, notes,
3930
+ cursor: askNextCursor(ts, after, ...notes.map((n) => n.ts)),
3931
+ });
3932
+ return;
3933
+ }
3934
+
3084
3935
  if (!audience.size && !broadcast) {
3085
3936
  console.error(`Error: この質問は誰にも宛てられていないため、有効な回答者を判定できません: ${stripTerminalControls(args.link)}`);
3086
3937
  process.exit(ASK_EXIT_ERROR);
@@ -3096,6 +3947,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3096
3947
  reactable: parsed.reactable,
3097
3948
  overflow: parsed.overflow,
3098
3949
  threadOnly: parsed.threadOnly,
3950
+ lang: parsed.lang,
3099
3951
  audience,
3100
3952
  broadcast,
3101
3953
  askerUserId,
@@ -3103,6 +3955,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3103
3955
  timeout: args.timeout,
3104
3956
  shown: args.link,
3105
3957
  asBot: args.asBot,
3958
+ json: args.json,
3106
3959
  };
3107
3960
  if (threadTs) ctx.threadParentTs = threadTs;
3108
3961
  if (args.cookie) ctx.cookie = args.cookie;
@@ -3145,6 +3998,7 @@ interface PollArgs {
3145
3998
  cookie?: string;
3146
3999
  mentionToken?: string;
3147
4000
  mentionCookie?: string;
4001
+ allowUrlAdjacent?: boolean;
3148
4002
  }
3149
4003
 
3150
4004
  /** The `slack poll --results=…` line handed back after posting. Printed as a
@@ -3208,6 +4062,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
3208
4062
  const multi = !!args.multi;
3209
4063
  const until = args.until ?? "";
3210
4064
  const message = pollBuildText(question, body, choices, { multi, deadline: until });
4065
+ guardUrlBoundaries(message, args.allowUrlAdjacent);
3211
4066
 
3212
4067
  // Preview the destination's last message, exactly as `send`/`ask` do. Fail-soft.
3213
4068
  let lastText = "";
@@ -3252,7 +4107,8 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
3252
4107
  const attr = sentAttribution("poll");
3253
4108
  const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
3254
4109
 
3255
- // Marker first, for the same reason as `ask`: it is what makes
4110
+ // Marker first. (`ask` seeds its marker LAST, because there it doubles as the
4111
+ // "other" choice; a poll has no such choice.) It is what makes
3256
4112
  // `has::ballot_box_with_ballot:` list every poll. An unrelated reaction on the message is
3257
4113
  // already ignored by the tally (it counts keycap names only), so this costs
3258
4114
  // the ballot nothing.
@@ -3492,8 +4348,10 @@ interface ScheduleSendArgs {
3492
4348
  /** How the caller overrode the target text, e.g. `--user-id U0123`. Set only
3493
4349
  * when a FLAG supplied it — never when the command resolved the id itself. */
3494
4350
  targetOverride?: string;
4351
+ allowUrlAdjacent?: boolean;
3495
4352
  }
3496
4353
  async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
4354
+ guardUrlBoundaries(args.message, args.allowUrlAdjacent);
3497
4355
  const getSelf = selfLookup(token, args.cookie);
3498
4356
  const { ref, threadTs } = parseTargetThread(args.target);
3499
4357
 
@@ -3639,8 +4497,10 @@ interface UploadArgs {
3639
4497
  channelId?: string;
3640
4498
  userId?: string;
3641
4499
  cookie?: string;
4500
+ allowUrlAdjacent?: boolean;
3642
4501
  }
3643
4502
  async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
4503
+ if (args.comment !== undefined) guardUrlBoundaries(args.comment, args.allowUrlAdjacent);
3644
4504
  const getSelf = selfLookup(token, args.cookie);
3645
4505
  const { statSync, existsSync } = await import("node:fs");
3646
4506
  const { basename } = await import("node:path");
@@ -4262,6 +5122,89 @@ async function main(): Promise<void> {
4262
5122
  }
4263
5123
  },
4264
5124
  )
5125
+ .command(
5126
+ ["rename <channel> <new-name>", "mv <channel> <new-name>"],
5127
+ "Rename a channel (confirm-hash safety gate)",
5128
+ (y2) => y2
5129
+ .positional("channel", { type: "string", demandOption: true, describe: "#name, name, or channel ID (C…/G…)" })
5130
+ .positional("new-name", { type: "string", demandOption: true, describe: "New channel name (Slack lowercases it; no spaces)" })
5131
+ .option("dry-run", { type: "boolean", default: false, describe: "Resolve the channel and print old -> new without renaming" })
5132
+ .option("code", { type: "string", describe: "Safety hash to confirm rename" }),
5133
+ async (argv) => {
5134
+ const token = tok(argv as W);
5135
+ const cookie = ck(argv as W);
5136
+ const ref = argv.channel!;
5137
+ if (ref.startsWith("@")) {
5138
+ console.error(`Error: ${ref} is a DM — only channels can be renamed.`);
5139
+ process.exit(1);
5140
+ }
5141
+ // A bare word is a channel name unless it is shaped like an ID.
5142
+ const channelRef = ref.startsWith("#") || /^[CG][A-Z0-9]{8,}$/.test(ref) ? ref : `#${ref}`;
5143
+ const newName = argv.newName!.replace(/^#/, "").toLowerCase();
5144
+ let channelId: string;
5145
+ let oldName: string;
5146
+ try {
5147
+ channelId = await resolveChannel(token, channelRef, cookie);
5148
+ } catch (e: unknown) {
5149
+ console.error(`Error: could not resolve channel ${ref}: ${e instanceof Error ? e.message : String(e)}`);
5150
+ process.exit(1);
5151
+ }
5152
+ try {
5153
+ const ch = asRecord(asRecord((await conversationInfo(token, channelId, cookie)) as Json).channel);
5154
+ if (ch.is_im === true || ch.is_mpim === true) {
5155
+ console.error(`Error: ${channelId} is a DM — only channels can be renamed.`);
5156
+ process.exit(1);
5157
+ }
5158
+ oldName = typeof ch.name === "string" ? ch.name : channelRef.replace(/^#/, "");
5159
+ } catch (e: unknown) {
5160
+ // conversations.info is only for the preview label — a token without
5161
+ // channels:read can still rename. A channel that doesn't exist can't.
5162
+ const raw = e instanceof Error ? e.message : String(e);
5163
+ if (/channel_not_found/.test(raw)) {
5164
+ console.error(`Error: could not resolve channel ${ref}: channel not found (or not visible to this token).`);
5165
+ process.exit(1);
5166
+ }
5167
+ oldName = channelRef.replace(/^#/, "");
5168
+ }
5169
+ if (argv.dryRun === true) {
5170
+ console.log(`#${oldName} -> #${newName} (${channelId})`);
5171
+ console.log(`(dry run — conversations.rename not called)`);
5172
+ return;
5173
+ }
5174
+ // The renamer is the actor of record in the channel's history, so the
5175
+ // gate names them and the code is bound to them.
5176
+ const self = await selfIdentity(token, cookie);
5177
+ const code = safetyCode("rename", channelId, newName, self?.userId ?? "");
5178
+ if (argv.code !== code) requireCode(argv.code, code, [
5179
+ `--- Renaming channel -------------------------`,
5180
+ fromLine(self, { pad: 9 }),
5181
+ ` channel: ${channelId}`,
5182
+ ` from: #${oldName}`,
5183
+ ` to: #${newName}`,
5184
+ `--------------------------------────────────`,
5185
+ ]);
5186
+ let renamed: { id: string; name: string };
5187
+ try {
5188
+ renamed = await renameChannel(token, channelId, newName, cookie);
5189
+ } catch (e: unknown) {
5190
+ const raw = e instanceof Error ? e.message : String(e);
5191
+ const errCode = raw.match(/Slack error on conversations\.rename: (\w+)/)?.[1];
5192
+ const hint: Record<string, string> = {
5193
+ name_taken: `Error: a channel named #${newName} already exists.`,
5194
+ invalid_name: `Error: "${newName}" is not a valid channel name (lowercase letters, numbers, hyphens, underscores; max 80 chars).`,
5195
+ invalid_name_specials: `Error: "${newName}" contains characters Slack doesn't allow in channel names.`,
5196
+ invalid_name_maxlength: `Error: "${newName}" is too long (max 80 chars).`,
5197
+ invalid_name_required: `Error: the new channel name can't be empty.`,
5198
+ not_authorized: `Error: this token isn't allowed to rename #${oldName} (workspace settings may restrict renaming to admins or the channel creator).`,
5199
+ not_in_channel: `Error: this token isn't a member of #${oldName} — join it first.`,
5200
+ is_archived: `Error: #${oldName} is archived — unarchive it before renaming.`,
5201
+ };
5202
+ console.error((errCode && hint[errCode]) ?? friendlySlackError(e));
5203
+ process.exit(1);
5204
+ }
5205
+ console.log(`✓ Renamed #${oldName} -> #${renamed.name} (${renamed.id})`);
5206
+ },
5207
+ )
4265
5208
  .demandCommand(1, "")
4266
5209
  .showHelpOnFail(true),
4267
5210
  )
@@ -4457,6 +5400,15 @@ async function main(): Promise<void> {
4457
5400
  .option("broadcast", { type: "boolean", default: false, describe: "Also send to channel: broadcast a threaded reply back to the channel (Slack's \"Also send to #channel\" checkbox). Only effective with a thread target." })
4458
5401
  .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real <@USERID> mentions (on by default; resolves via users.list, then channel members for Slack Connect guests). Unresolved handles stay as plain text. The confirm preview shows the converted message before sending. Disable with --no-mentions for literal @text." }),
4459
5402
  async (argv) => {
5403
+ // An empty body is never a meaningful send. `ask` already refuses one
5404
+ // ("Error: question is empty"); `send` did not, so an empty draft
5405
+ // staged a confirm code and posting it produced a blank message.
5406
+ // Verification cannot catch this: an empty publish reads back as an
5407
+ // exact match, so refusing here is the only mechanism.
5408
+ if (!String(argv.message ?? "").trim()) {
5409
+ console.error("Error: message is empty");
5410
+ process.exit(1);
5411
+ }
4460
5412
  const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] };
4461
5413
  if (argv.code) args.code = argv.code;
4462
5414
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
@@ -4523,7 +5475,7 @@ async function main(): Promise<void> {
4523
5475
  )
4524
5476
  .command(
4525
5477
  "ask [target] [question] [choices..]",
4526
- "Ask a question with its choices pre-seeded as 1️⃣..🔟 reactions (confirm-hash safety gate)",
5478
+ "Ask a question with its choices pre-seeded as 1️⃣..🔟 reactions (confirm-hash safety gate). Lifecycle: --waitFor=<link> collect, --void=<link> 無効化 (verb aliases: `ask collect <link>`, `ask void <link…>`)",
4527
5479
  (y) => y
4528
5480
  .positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --waitFor)" })
4529
5481
  .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." })
@@ -4531,14 +5483,176 @@ async function main(): Promise<void> {
4531
5483
  .option("code", { type: "string", describe: "Safety hash to confirm the ask" })
4532
5484
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
4533
5485
  .option("body", { type: "string", describe: "Extra context shown under the question" })
4534
- .option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = a reply matched several choices (stdout empty), 5 = a free-text reply that picked none of the choices (the reply is on stdout; NOT a decision — the question stays open)." })
5486
+ .option("lang", { type: "string", choices: [...ASK_LANGS], describe: "Language of the instructions in the posted body. Default: $SLACK_TERM_LANG, else the answerers' Slack locale, else the question's own language, else the system locale, else ja" })
5487
+ .option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = a reply matched several choices (stdout empty), 5 = a free-text reply that picked none of the choices (the reply is on stdout; NOT a decision — the question stays open), 6 = the question was voided (無効化, `slack ask void`) — stop waiting." })
4535
5488
  .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." })
4536
- .option("after", { type: "string", describe: "With --waitFor: ignore text replies at or before this ts (the reply an exit 5 already delivered). Reactions still count." })
5489
+ .option("after", { type: "string", describe: "With --waitFor: the cursor — everything at or before this ts was already delivered. Text replies there are neither answers nor notes again (pass the `cursor` a previous --json result gave you, or the reply ts an exit 5 printed). Reactions still count." })
5490
+ .option("void", { type: "string", array: true, describe: "無効化: void question(s) you posted that expired or stopped meaning anything — --void=<permalink>, repeatable. The text becomes 🚫 with --reason (body kept, options removed), our pills come off, and --wait/--waitFor on it exits 6. Same --code gate. Only your own questions (--as-bot for the bot's). Alias: `slack ask void <link…>`." })
5491
+ .option("reask", { type: "boolean", default: false, describe: "With ONE --void: post the question again (same channel/thread, same text and choices unless you give new ones as positionals / --body) and void the old one as replaced by it — one gate for both. The way to change the options of a question someone already answered." })
5492
+ .option("edit", { type: "string", describe: "Change an open question you posted: --edit=<permalink> [\"new question\" [choices…]] [--body …]. Rebuilt with the ask layout so collect still reads it. Options can change only while nobody has answered (then: --void=<link> --reask). Same --code gate." })
5493
+ .option("ls", { type: "boolean", default: false, describe: "List the questions you posted (local sent-log) with their LIVE state: --state open|answered|void|all (default open), --stale 24h, --channel, --json. With --stale it prints the --void command that would retire them." })
5494
+ .option("stale", { type: "string", describe: "With --ls: only open questions older than this (24h, 3d)" })
5495
+ .option("state", { type: "string", default: "open", choices: ["open", "answered", "void", "edited", "gone", "unknown", "all"], describe: "With --ls: which state to list" })
5496
+ .option("channel", { type: "string", describe: "With --ls: only this channel (id, or the target as typed)" })
5497
+ .option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
5498
+ .option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
5499
+ .option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
5500
+ .option("json", { type: "boolean", default: false, describe: "With --wait / --waitFor: print ONE JSON object on stdout — {status, answer, how, who, notes:[{ts,user,text,permalink}], cursor, permalink} — instead of the bare answer. `notes` are the audience's replies that are not the answer (thread replies; plain replies too in a 1:1 DM). Exit codes unchanged." })
4537
5501
  .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
4538
5502
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
4539
5503
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
4540
5504
  .option("as-bot", { type: "boolean", default: false, describe: "Ask via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient" }),
4541
5505
  async (argv) => {
5506
+ // Verbs. A target is always #chan / @user / a permalink / a raw ID, so a
5507
+ // bare word here cannot be one — `slack ask void …` is unambiguous.
5508
+ // slack ask void <link…> [--reason …] [--superseded-by <link>] 無効化
5509
+ // slack ask collect <link> = --waitFor
5510
+ // Lifecycle flags (maintainer: "maybe slack ask --edit=<target>"), primary;
5511
+ // the bare verbs `void` / `collect` stay as aliases.
5512
+ if (argv.ls) {
5513
+ const user = (() => { try { return tok(argv as W); } catch { return undefined; } })();
5514
+ const tokens: { user?: string; bot?: string; cookie?: string } = {};
5515
+ if (user) tokens.user = user;
5516
+ const bot = resolveBotToken();
5517
+ if (bot) tokens.bot = bot;
5518
+ const c = ck(argv as W);
5519
+ if (c) tokens.cookie = c;
5520
+ const a: { stale?: string; state: string; channel?: string; json: boolean; limit: number } = {
5521
+ state: String(argv.state ?? "open"), json: !!argv.json, limit: Number(argv.limit) || 50,
5522
+ };
5523
+ if (argv.stale) a.stale = String(argv.stale);
5524
+ if (argv.channel) a.channel = String(argv.channel);
5525
+ await cmdAskLs(tokens, a);
5526
+ return;
5527
+ }
5528
+ const askTokenFor = (asBot: boolean): { token: string; cookie?: string } => {
5529
+ if (!asBot) {
5530
+ const c = ck(argv as W);
5531
+ return c ? { token: tok(argv as W), cookie: c } : { token: tok(argv as W) };
5532
+ }
5533
+ const botToken = resolveBotToken();
5534
+ if (!botToken) {
5535
+ console.error(
5536
+ "Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
5537
+ " Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
5538
+ );
5539
+ process.exit(ASK_EXIT_ERROR);
5540
+ }
5541
+ return { token: botToken };
5542
+ };
5543
+ // Positionals after a lifecycle flag are the NEW question and choices.
5544
+ const newQuestion = argv.target !== undefined && argv.target !== "void" && argv.target !== "collect" ? String(argv.target) : undefined;
5545
+ const newChoices = [argv.question, ...((argv.choices as string[] | undefined) ?? [])].filter((x) => x !== undefined).map(String);
5546
+ if (argv.edit) {
5547
+ if (argv.void || argv.wait || argv.waitFor) {
5548
+ console.error("Error: --edit changes one open question — do not combine it with --void/--wait/--waitFor.");
5549
+ process.exit(ASK_EXIT_ERROR);
5550
+ }
5551
+ const { token, cookie } = askTokenFor(!!argv["as-bot"]);
5552
+ const a: Parameters<typeof cmdAskEdit>[1] = {
5553
+ link: String(argv.edit), asBot: !!argv["as-bot"], mentionToken: tok(argv as W),
5554
+ allowUrlAdjacent: !!argv["allow-url-adjacent"],
5555
+ };
5556
+ if (newQuestion !== undefined) a.question = newQuestion;
5557
+ if (newChoices.length) a.choices = newChoices.map((x) => x.trim()).filter(Boolean);
5558
+ if (argv.body !== undefined) a.body = String(argv.body);
5559
+ if (argv.lang) a.lang = String(argv.lang);
5560
+ if (argv.code) a.code = String(argv.code);
5561
+ if (cookie) a.cookie = cookie;
5562
+ const mc = ck(argv as W);
5563
+ if (mc) a.mentionCookie = mc;
5564
+ try {
5565
+ await cmdAskEdit(token, a);
5566
+ } catch (e: unknown) {
5567
+ console.error(friendlySlackError(e));
5568
+ process.exit(ASK_EXIT_ERROR);
5569
+ }
5570
+ return;
5571
+ }
5572
+ // --void=<link> --reask: re-ask through the CREATE path below (same
5573
+ // gate, same audience rules), voiding the old question once it is up.
5574
+ let voidAfter: AskArgs["voidAfter"];
5575
+ const voidFlag = ((argv.void as string[] | undefined) ?? []).map(String).filter(Boolean);
5576
+ if (argv.reask) {
5577
+ if (voidFlag.length !== 1 || argv.target === "void") {
5578
+ console.error("Error: --reask goes with exactly one --void=<permalink> (the question to replace).");
5579
+ process.exit(ASK_EXIT_ERROR);
5580
+ }
5581
+ if (argv.waitFor) {
5582
+ console.error("Error: --reask posts a new question — --wait works, --waitFor does not.");
5583
+ process.exit(ASK_EXIT_ERROR);
5584
+ }
5585
+ const { token, cookie } = askTokenFor(!!argv["as-bot"]);
5586
+ const self = await selfIdentity(token, cookie);
5587
+ const t = await askLoadOwnOpen(token, voidFlag[0]!, self, !!argv["as-bot"], cookie);
5588
+ if (t === "void") { console.error(`✗ ${stripTerminalControls(voidFlag[0]!)}: すでに無効化済みです — 新しく聞くなら普通に slack ask で`); process.exit(ASK_EXIT_ERROR); }
5589
+ if (!t) process.exit(ASK_EXIT_ERROR);
5590
+ voidAfter = { ...t, reason: unescapeArg(String(argv.reason ?? "")).trim() };
5591
+ // Same place, same words, unless new ones were given. The create path
5592
+ // below unescapes its arguments, so stored text goes in escaped: a
5593
+ // literal `\n` in the old question must not turn into a line break.
5594
+ argv.target = t.threadTs ? `${t.channelId}:${t.threadTs}` : t.channelId;
5595
+ argv["channel-id"] = t.channelId;
5596
+ argv.question = newQuestion ?? escapeArg(t.parsed.question);
5597
+ argv.choices = newChoices.length ? newChoices : [...t.parsed.reactable, ...t.parsed.overflow].map(escapeArg);
5598
+ if (argv.body === undefined && t.parsed.body) argv.body = escapeArg(t.parsed.body);
5599
+ if (!argv.lang) argv.lang = t.parsed.lang;
5600
+ argv.void = undefined;
5601
+ }
5602
+ if (!voidAfter && (argv.target === "void" || voidFlag.length)) {
5603
+ const verbLinks = argv.target === "void" ? [argv.question, ...((argv.choices as string[] | undefined) ?? [])] : [];
5604
+ if (argv.target !== "void" && (argv.target || argv.question || (argv.choices as string[] | undefined)?.length)) {
5605
+ console.error("Error: --void voids existing questions — do not also pass a target/question/choices.");
5606
+ process.exit(ASK_EXIT_ERROR);
5607
+ }
5608
+ const links = [...voidFlag, ...verbLinks.map((x) => String(x ?? ""))].filter(Boolean);
5609
+ if (!links.length) {
5610
+ console.error("Error: --void=<permalink> — which question(s)?");
5611
+ process.exit(ASK_EXIT_ERROR);
5612
+ }
5613
+ if (argv.wait || argv.waitFor) {
5614
+ console.error("Error: --void voids existing questions — do not also pass --wait/--waitFor.");
5615
+ process.exit(ASK_EXIT_ERROR);
5616
+ }
5617
+ let voidToken: string;
5618
+ let voidCookie: string | undefined;
5619
+ if (argv["as-bot"]) {
5620
+ const botToken = resolveBotToken();
5621
+ if (!botToken) {
5622
+ console.error(
5623
+ "Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
5624
+ " Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
5625
+ );
5626
+ process.exit(ASK_EXIT_ERROR);
5627
+ }
5628
+ voidToken = botToken;
5629
+ } else {
5630
+ voidToken = tok(argv as W);
5631
+ voidCookie = ck(argv as W);
5632
+ }
5633
+ const a: { links: string[]; reason: string; supersededBy?: string; code?: string; asBot: boolean; cookie?: string } = {
5634
+ links, reason: unescapeArg(String(argv.reason ?? "")).trim(), asBot: !!argv["as-bot"],
5635
+ };
5636
+ if (argv["superseded-by"]) a.supersededBy = String(argv["superseded-by"]);
5637
+ if (argv.code) a.code = String(argv.code);
5638
+ if (voidCookie) a.cookie = voidCookie;
5639
+ try {
5640
+ await cmdAskVoid(voidToken, a);
5641
+ } catch (e: unknown) {
5642
+ console.error(friendlySlackError(e));
5643
+ process.exit(ASK_EXIT_ERROR);
5644
+ }
5645
+ return;
5646
+ }
5647
+ if (argv.target === "collect") {
5648
+ if (!argv.question || (argv.choices as string[] | undefined)?.length) {
5649
+ console.error("Error: slack ask collect <permalink> — exactly one question.");
5650
+ process.exit(ASK_EXIT_ERROR);
5651
+ }
5652
+ argv.waitFor = String(argv.question);
5653
+ argv.target = undefined;
5654
+ argv.question = undefined;
5655
+ }
4542
5656
  const waitFor = argv.waitFor ? String(argv.waitFor) : "";
4543
5657
  const timeout = Number(argv.timeout);
4544
5658
  // 0 is a real value only for --waitFor ("look once"). For --wait it would
@@ -4572,7 +5686,7 @@ async function main(): Promise<void> {
4572
5686
  waitCookie = ck(argv as W);
4573
5687
  }
4574
5688
  try {
4575
- const a: { link: string; timeout: number; asBot: boolean; cookie?: string; after?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"] };
5689
+ const a: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"], json: !!argv.json };
4576
5690
  if (waitCookie) a.cookie = waitCookie;
4577
5691
  if (argv.after) {
4578
5692
  const after = String(argv.after);
@@ -4607,7 +5721,10 @@ async function main(): Promise<void> {
4607
5721
  };
4608
5722
  if (argv.code) args.code = argv.code;
4609
5723
  if (argv.body) args.body = argv.body;
5724
+ if (argv.lang) args.lang = String(argv.lang);
4610
5725
  if (argv.wait) args.wait = true;
5726
+ if (argv.json) args.json = true;
5727
+ if (voidAfter) args.voidAfter = voidAfter;
4611
5728
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
4612
5729
  if (argv["user-id"]) args.userId = argv["user-id"];
4613
5730
  // Always resolve @tags with the user token (it has users:read), even
@@ -4672,6 +5789,7 @@ async function main(): Promise<void> {
4672
5789
  .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)" })
4673
5790
  .option("code", { type: "string", describe: "Safety hash to confirm the post" })
4674
5791
  .option("body", { type: "string", describe: "Extra context shown under the question" })
5792
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
4675
5793
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
4676
5794
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
4677
5795
  .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" }),
@@ -4741,6 +5859,7 @@ async function main(): Promise<void> {
4741
5859
  if (argv.until) args.until = String(argv.until);
4742
5860
  if (argv.code) args.code = argv.code;
4743
5861
  if (argv.body) args.body = argv.body;
5862
+ if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
4744
5863
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
4745
5864
  if (argv["user-id"]) args.userId = argv["user-id"];
4746
5865
  // Always resolve @tags with the user token (it has users:read), even
@@ -4857,12 +5976,23 @@ async function main(): Promise<void> {
4857
5976
  .positional("message", { type: "string", demandOption: true })
4858
5977
  .option("at", { type: "string", demandOption: true, describe: "Delivery time (ISO datetime or Unix ts)" })
4859
5978
  .option("code", { type: "string", describe: "Safety hash to confirm" })
5979
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
4860
5980
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
4861
5981
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
4862
5982
  .option("as-bot", { type: "boolean", default: false, describe: "Schedule via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient. Without it a scheduled DM to yourself is posted by you, and Slack never notifies you of your own message — it fires into silence." }),
4863
5983
  async (argv) => {
5984
+ // An empty body is never a meaningful send. `ask` already refuses one
5985
+ // ("Error: question is empty"); `send` did not, so an empty draft
5986
+ // staged a confirm code and posting it produced a blank message.
5987
+ // Verification cannot catch this: an empty publish reads back as an
5988
+ // exact match, so refusing here is the only mechanism.
5989
+ if (!String(argv.message ?? "").trim()) {
5990
+ console.error("Error: message is empty");
5991
+ process.exit(1);
5992
+ }
4864
5993
  const args: ScheduleSendArgs = { target: argv.target!, message: argv.message!, at: argv.at! };
4865
5994
  if (argv.code) args.code = argv.code;
5995
+ if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
4866
5996
  // Recorded here, not in the command, because only a flag counts as an
4867
5997
  // override — under --as-bot the command sets args.userId itself from
4868
5998
  // the target, and claiming THAT was an override would be a new lie.
@@ -4968,12 +6098,14 @@ async function main(): Promise<void> {
4968
6098
  .positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
4969
6099
  .positional("newText", { type: "string", demandOption: true })
4970
6100
  .option("code", { type: "string", describe: "Safety hash to confirm edit" })
6101
+ .option("force", { type: "boolean", default: false, describe: "Apply an edit that BREAKS a `slack ask` / `slack poll` message (new text no longer reads as one, or an answered/void question made open again). Refused by default because collect reads that body back; edits that keep it readable (e.g. writing the ✅ form) need no flag. Prefer `slack ask --edit=` / `--void=`." })
4971
6102
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
4972
6103
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
4973
6104
  .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (on by default). Unresolved handles stay as plain text. Disable with --no-mentions for literal @text." })
4974
6105
  .option("as-bot", { type: "boolean", default: false, describe: "Edit as the bot (xoxb / SLACK_BOT_TOKEN). REQUIRED to correct a message the bot posted: Slack only lets a token edit its OWN messages, so a bot-authored message is uneditable by the user token and chat.update returns cant_update_message. Address it by permalink or --channel-id; an @user target resolves against the BOT's DM list, which is where a bot-authored DM actually lives." }),
4975
6106
  async (argv) => {
4976
6107
  const args: EditArgs = { target: argv.target!, newText: argv.newText!, allowUrlAdjacent: argv["allow-url-adjacent"] };
6108
+ if (argv.force) args.force = true;
4977
6109
  if (argv.code) args.code = argv.code;
4978
6110
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
4979
6111
  if (argv.mentions !== false) args.mentions = true;
@@ -5352,6 +6484,7 @@ async function main(): Promise<void> {
5352
6484
  .positional("file", { type: "string", array: true, demandOption: true, describe: "Path(s) to file(s)" })
5353
6485
  .option("title", { type: "string", describe: "Title (single file only)" })
5354
6486
  .option("comment", { type: "string", describe: "Initial comment (first file)" })
6487
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
5355
6488
  .option("code", { type: "string", describe: "4-hex safety code to confirm upload" })
5356
6489
  .option("channel-id", { type: "string" })
5357
6490
  .option("user-id", { type: "string" }),
@@ -5359,6 +6492,7 @@ async function main(): Promise<void> {
5359
6492
  const args: UploadArgs = { target: argv.target!, filePaths: argv.file as string[] };
5360
6493
  if (argv.title) args.title = argv.title;
5361
6494
  if (argv.comment) args.comment = argv.comment;
6495
+ if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
5362
6496
  if (argv.code) args.code = argv.code;
5363
6497
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
5364
6498
  if (argv["user-id"]) args.userId = argv["user-id"];
@@ -5524,6 +6658,25 @@ async function main(): Promise<void> {
5524
6658
  await cmdDump(tok(argv as W), argv.days, argv.limit, argv.filter);
5525
6659
  },
5526
6660
  )
6661
+ .command(
6662
+ "todos",
6663
+ "Slack's To-dos tab — your personal to-dos (read-only; requires xoxc desktop token). Not `slack todo` (reaction tasks)",
6664
+ (y) => y.command(
6665
+ ["$0", "ls", "list"],
6666
+ "List your To-dos, soonest due first",
6667
+ (y2) => y2
6668
+ .option("filter", { type: "string", choices: [...TODO_FILTERS], default: "todo", describe: "todo = open to-dos; saved = everything saved; completed; archived" })
6669
+ .option("limit", { alias: "n", type: "number", default: 50, describe: "At most this many items" })
6670
+ .option("json", { type: "boolean", default: false, describe: "One JSON object per item (raw fields + permalink + message text)" }),
6671
+ async (argv) => {
6672
+ if (!Number.isInteger(argv.limit) || argv.limit < 1) {
6673
+ console.error(`Error: --limit must be a whole number of at least 1 (got ${argv.limit}).`);
6674
+ process.exit(2);
6675
+ }
6676
+ await cmdTodos(tok(argv as W), ck(argv as W), { filter: argv.filter as TodoFilter, limit: argv.limit, json: argv.json });
6677
+ },
6678
+ ),
6679
+ )
5527
6680
  .command(
5528
6681
  "drafts",
5529
6682
  "Manage message drafts (requires xoxc desktop token)",
@@ -5541,11 +6694,15 @@ async function main(): Promise<void> {
5541
6694
  "Create a draft",
5542
6695
  (y2) => y2
5543
6696
  .positional("channel", { type: "string", demandOption: true })
5544
- .positional("text", { type: "string", array: true, default: [] }),
6697
+ .positional("text", { type: "string", array: true, default: [] })
6698
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing a bare URL that runs into non-ASCII text (it would link only up to that point)" }),
5545
6699
  async (argv) => {
5546
6700
  const token = tok(argv as W); const cookie = ck(argv as W);
5547
6701
  const text = (argv.text as string[]).join(" ");
5548
6702
  if (!text) { console.error("Usage: slack drafts new <#channel|@user> <text>"); process.exit(2); }
6703
+ // Drafts emit explicit link elements, so a URL glued BEFORE is fine
6704
+ // here; one that runs into text AFTER would be cut at a guess.
6705
+ guardUrlBoundaries(text, argv["allow-url-adjacent"], undefined, { sides: ["after"], markdown: false });
5549
6706
  const channelId = await resolveChannel(token, argv.channel!, cookie);
5550
6707
  const resp = (await createDraft(token, channelId, text, cookie)) as Record<string, Json>;
5551
6708
  console.log(`✓ Draft created (id: ${asRecord(resp.draft).id ?? "?"})`);
@@ -5565,11 +6722,13 @@ async function main(): Promise<void> {
5565
6722
  (y2) => y2
5566
6723
  .positional("id", { type: "string", demandOption: true })
5567
6724
  .positional("text", { type: "string", array: true, default: [] })
5568
- .option("code", { type: "string" }),
6725
+ .option("code", { type: "string" })
6726
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing a bare URL that runs into non-ASCII text (it would link only up to that point)" }),
5569
6727
  async (argv) => {
5570
6728
  const token = tok(argv as W); const cookie = ck(argv as W);
5571
6729
  const text = (argv.text as string[]).join(" ");
5572
6730
  if (!text) { console.error("Usage: slack drafts edit <id> <new-text>"); process.exit(2); }
6731
+ guardUrlBoundaries(text, argv["allow-url-adjacent"], undefined, { sides: ["after"], markdown: false });
5573
6732
  const listResp = (await listDrafts(token, cookie)) as Record<string, Json>;
5574
6733
  const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === argv.id);
5575
6734
  if (!d) { console.error(`Draft not found: ${argv.id}`); process.exit(1); }