slack-term 1.42.0 → 1.44.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
@@ -12,15 +12,19 @@ import { guardUrlBoundaries } from "./urlGuard.ts";
12
12
  import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile, type Profile } from "./profiles.ts";
13
13
  import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
14
14
  import { cmdAuthLogin, cmdAuthLoginChrome, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp, cmdAuthSave, cmdAuthTokens } from "./auth.ts";
15
- import { cmdTail } from "./tail.ts";
15
+ import { cmdTail, parseSince as parseDuration } from "./tail.ts";
16
+ import { runStream, webClient } from "./stream.ts";
17
+ import { relaySubscriber } from "./relay.ts";
16
18
  import { agentCommands } from "./agent.ts";
17
19
 
18
20
  import {
19
21
  ASK_KEYCAPS,
20
22
  ASK_MARKER,
23
+ ASK_EXPLAIN_MARKER,
21
24
  ASK_RESOLVED_MARKER,
22
25
  ASK_MAX_REACTION_CHOICES,
23
26
  askBuildText,
27
+ askIsExplainNote,
24
28
  askBuildResolvedText,
25
29
  askParseMessage,
26
30
  askExplainReject,
@@ -58,6 +62,7 @@ import { attributionEnabled, attributionMetadata, captureAttribution, markAskCol
58
62
  import {
59
63
  authTest,
60
64
  authScopes,
65
+ botsInfo,
61
66
  authTestSession,
62
67
  conversationInfoSession,
63
68
  createChannel,
@@ -90,6 +95,7 @@ import {
90
95
  openDm,
91
96
  parseSlackPermalink,
92
97
  replies,
98
+ repliesPage,
93
99
  resolveChannel,
94
100
  resolveUserId,
95
101
  search,
@@ -2454,6 +2460,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
2454
2460
  // pressed later still settles it (`--waitFor … --after=<reply ts>`).
2455
2461
  // 6 the question was VOIDED (無効化, `slack ask --void`) — it will never be
2456
2462
  // answered; stop waiting. stdout stays empty.
2463
+ // 7 only with --explain-exit: someone pressed ℹ️ (explain) — the question is
2464
+ // STILL OPEN; explain in its thread, then collect again. stdout stays empty.
2457
2465
  // Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
2458
2466
 
2459
2467
  const ASK_EXIT_TIMEOUT = 2;
@@ -2473,6 +2481,20 @@ const ASK_EXIT_FREETEXT = 5;
2473
2481
  * Neither "open" nor "answered": a monitor that kept waiting on it would wait
2474
2482
  * forever, and one that read it as answered would act on nothing. */
2475
2483
  const ASK_EXIT_VOID = 6;
2484
+ /** `--explain-exit` only: an audience member pressed ℹ️ and asked for an
2485
+ * explanation. Not an answer and not a state of the question — it stays open —
2486
+ * but a caller parked on `--waitFor` cannot explain anything until it hears.
2487
+ * Opt-in, so no existing caller meets a code it does not know. */
2488
+ const ASK_EXIT_EXPLAIN = 7;
2489
+ /** How many 200-reply pages the explain-note lookup reads before giving up. */
2490
+ const ASK_EXPLAIN_NOTE_MAX_PAGES = 10;
2491
+
2492
+ /** Whether a new question offers the ℹ️ explain pill when neither --explain nor
2493
+ * --no-explain is given: `SLACK_TERM_ASK_EXPLAIN=1` turns it on for every ask
2494
+ * from a fleet, without every caller having to remember the flag. */
2495
+ function askExplainDefault(): boolean {
2496
+ return /^(1|true|yes|on)$/i.test(process.env.SLACK_TERM_ASK_EXPLAIN ?? "");
2497
+ }
2476
2498
 
2477
2499
  const ASK_PHI = 1.618033988749895;
2478
2500
  const ASK_POLL_MIN_MS = 1000;
@@ -2497,6 +2519,10 @@ interface AskArgs {
2497
2519
  userId?: string;
2498
2520
  asBot?: boolean;
2499
2521
  wait?: boolean;
2522
+ /** `--explain`: offer the ℹ️ pill — "explain this first", never an answer. */
2523
+ explain?: boolean;
2524
+ /** `--explain-exit` with `--wait`: exit 7 when an explanation is requested. */
2525
+ explainExit?: boolean;
2500
2526
  /** `--json` with `--wait`: one JSON object on stdout (answer, notes, cursor). */
2501
2527
  json?: boolean;
2502
2528
  timeout?: number;
@@ -2611,6 +2637,11 @@ interface AskWaitCtx {
2611
2637
  /** `--json`: stdout is one JSON object (answer + notes + cursor) instead of
2612
2638
  * the bare answer. Exit codes are the same either way. */
2613
2639
  json: boolean;
2640
+ /** `--explain-exit`: stop with exit 7 the first time an explanation is
2641
+ * requested (the run that posts the thread note), instead of waiting on. */
2642
+ explainExit?: boolean;
2643
+ /** The question offers the ℹ️ pill — so the ✅ rewrite takes our seed off. */
2644
+ explain?: boolean;
2614
2645
  }
2615
2646
 
2616
2647
  /** A reply from the question's audience that is NOT the answer — an extra
@@ -2679,7 +2710,7 @@ async function askCollectNotes(token: string, sc: AskNoteScope, after: string |
2679
2710
  // integration's) is excluded here, so nothing echoes back as a "note".
2680
2711
  .filter((m) => sc.isAnswerer(m.user) && !m.bot_id)
2681
2712
  .filter((m) => typeof m.subtype !== "string" || ASK_ANSWERABLE_SUBTYPES.has(m.subtype))
2682
- .filter((m) => typeof m.text === "string" && m.text.trim() !== "")
2713
+ .filter((m) => typeof m.text === "string" && m.text.trim() !== "" && !askIsExplainNote(m.text))
2683
2714
  // Sorted BEFORE `skip`, which may be stateful ("the first reply that …").
2684
2715
  .sort((a, b) => Number(a.ts) - Number(b.ts))
2685
2716
  .filter((m) => !skip(m));
@@ -2709,12 +2740,15 @@ function askNextCursor(...tss: (string | undefined)[]): string {
2709
2740
  async function askEmit(
2710
2741
  token: string,
2711
2742
  o: { json: boolean; shown: string; asBot: boolean; cookie?: string | undefined },
2712
- r: { status: "answered" | "freetext" | "ambiguous" | "timeout" | "void"; answer: string | null; how?: string; who?: string; reason?: string; supersededBy?: string; notes: AskNote[]; cursor: string },
2743
+ r: { status: "answered" | "freetext" | "ambiguous" | "timeout" | "void" | "explain"; answer: string | null; how?: string; who?: string; reason?: string; supersededBy?: string; notes: AskNote[]; cursor: string; explain?: string[] },
2713
2744
  ): Promise<void> {
2714
2745
  if (o.json) {
2715
2746
  console.log(JSON.stringify({
2716
2747
  status: r.status, answer: r.answer, how: r.how ?? null, who: r.who ?? null,
2717
2748
  ...(r.status === "void" ? { reason: r.reason ?? "", supersededBy: r.supersededBy ?? null } : {}),
2749
+ // Who is asking for an explanation right now. Only when someone is, so
2750
+ // the object every existing caller reads is unchanged otherwise.
2751
+ ...(r.explain?.length ? { explain: r.explain } : {}),
2718
2752
  notes: r.notes, cursor: r.cursor, permalink: o.shown,
2719
2753
  }));
2720
2754
  return;
@@ -2751,6 +2785,13 @@ interface AskPollState {
2751
2785
  replyCount?: number;
2752
2786
  /** The question's text as last read — what the ✅ rewrite keeps the body of. */
2753
2787
  ownText?: string;
2788
+ /** Who is on ℹ️ (explain) right now — audience only, never a bot, never the
2789
+ * asker. Recomputed every pass, so a press taken back stops counting. Only
2790
+ * on a question that offers ℹ️ (`ctx.explain`). */
2791
+ explainNow: string[];
2792
+ /** THIS run posted the explain thread note — the one run `--explain-exit`
2793
+ * stops on. Never set read-only (`--pending` posts nothing). */
2794
+ explainPosted: boolean;
2754
2795
  }
2755
2796
 
2756
2797
  /** The polling half of `--wait`: one `poll()` is one look at the question,
@@ -2766,7 +2807,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
2766
2807
  } {
2767
2808
  const { channelId, ts, reactable, threadOnly, audience, broadcast, cookie } = ctx;
2768
2809
  const copy = ASK_COPY[ctx.lang];
2769
- const state: AskPollState = {};
2810
+ const state: AskPollState = { explainNow: [], explainPosted: false };
2770
2811
  const askerUserId = ctx.askerUserId;
2771
2812
  const askerBotId = ctx.askerBotId;
2772
2813
 
@@ -2842,7 +2883,93 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
2842
2883
  }
2843
2884
  }
2844
2885
 
2886
+ const explainReported = new Set<string>();
2887
+ const botCache = new Map<string, boolean>();
2888
+ /** The thread note is settled for this run (found already there, or posted). */
2889
+ let explainNoted = false;
2890
+ /** Null when it cannot be told — not cached, so the next pass asks again.
2891
+ * Until then the press does not count: a bot must never post the note or
2892
+ * wake an --explain-exit caller, and a person is only delayed a tick. */
2893
+ async function isBotUser(u: string): Promise<boolean | null> {
2894
+ if (u === "USLACKBOT") return true;
2895
+ const hit = botCache.get(u);
2896
+ if (hit !== undefined) return hit;
2897
+ try {
2898
+ // A reply with no boolean is_bot tells us nothing — unknown, not human.
2899
+ const bot = asRecord(asRecord((await userInfo(token, u, cookie)) as Json).user).is_bot;
2900
+ if (typeof bot !== "boolean") return null;
2901
+ botCache.set(u, bot);
2902
+ return bot;
2903
+ } catch {
2904
+ return null;
2905
+ }
2906
+ }
2907
+ /** One thread note per QUESTION, however many presses and runs: the thread
2908
+ * itself is the record, so there is no local state to lose. The note is
2909
+ * recognised by its exact copy (either language), whoever posted it — the
2910
+ * identity that collects now need not be the one that collected before, and
2911
+ * under @here it may even be in the audience.
2912
+ *
2913
+ * The whole thread after the question is read (paged), not one page: a
2914
+ * question posted into a long thread may have more than a page before its
2915
+ * note. Past the page cap it is not posted at all ("exists") rather than
2916
+ * risk a duplicate.
2917
+ *
2918
+ * "unread": the thread could not be read — nothing is posted, and the next
2919
+ * pass tries again rather than risk a duplicate. "failed": the post itself
2920
+ * failed — not retried this run (no error every tick) and NOT a reason for
2921
+ * --explain-exit, which promises a note is up; the next run tries again. */
2922
+ async function postExplainNote(): Promise<"exists" | "posted" | "unread" | "failed"> {
2923
+ const root = ctx.threadParentTs ?? ts;
2924
+ try {
2925
+ let cursor: string | undefined;
2926
+ let pages = 0;
2927
+ for (;;) {
2928
+ const rep = asRecord((await repliesPage(token, channelId, root, { oldest: ts, ...(cursor ? { cursor } : {}) }, cookie)) as Json);
2929
+ if (asArray(rep.messages).map(asRecord).some((m) => Number(m.ts) > Number(ts) && askIsExplainNote(m.text))) return "exists";
2930
+ cursor = String(asRecord(rep.response_metadata).next_cursor ?? "") || undefined;
2931
+ if (!cursor) break;
2932
+ if (++pages >= ASK_EXPLAIN_NOTE_MAX_PAGES) return "exists";
2933
+ }
2934
+ } catch (e: unknown) {
2935
+ console.error(` (スレッドを読めないため、説明リクエストの案内はまだ出していません: ${e instanceof Error ? e.message : String(e)})`);
2936
+ return "unread";
2937
+ }
2938
+ try {
2939
+ await slackSend(token, channelId, copy.explainNote, root, false, cookie, true);
2940
+ } catch (e: unknown) {
2941
+ // The press is still reported on stderr; the note is a courtesy.
2942
+ console.error(` (説明リクエストの案内をスレッドに出せませんでした: ${e instanceof Error ? e.message : String(e)})`);
2943
+ return "failed";
2944
+ }
2945
+ return "posted";
2946
+ }
2947
+ async function noteExplain(msg: Record<string, Json>): Promise<void> {
2948
+ const r = asArray(msg.reactions).map(asRecord).find((x) => x.name === ASK_EXPLAIN_MARKER);
2949
+ const now: string[] = [];
2950
+ for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
2951
+ if ((await isBotUser(u)) !== false) continue;
2952
+ now.push(u);
2953
+ if (explainReported.has(u)) continue;
2954
+ explainReported.add(u);
2955
+ if (!readOnly) console.error(` (<@${u}> が ℹ️ 説明して を押しました — 回答ではありません。スレッドで説明してください。回答は待ち続けます)`);
2956
+ }
2957
+ state.explainNow = now;
2958
+ // Read-only (`--pending`): the press is reported, the note is not posted —
2959
+ // that is the waiting session's to do.
2960
+ if (now.length && !explainNoted && !readOnly) {
2961
+ const r = await postExplainNote();
2962
+ if (r !== "unread") {
2963
+ explainNoted = true;
2964
+ state.explainPosted = r === "posted";
2965
+ }
2966
+ }
2967
+ }
2968
+
2845
2969
  async function answerFromReactions(msg: Record<string, Json>): Promise<AskFound | null> {
2970
+ // Only a question that offers ℹ️ treats it as a request — on any other,
2971
+ // ℹ️ is just an emoji somebody added.
2972
+ if (ctx.explain) await noteExplain(msg);
2846
2973
  if (reactable.length) noteOther(msg);
2847
2974
  const picks = humanChoices(msg);
2848
2975
  // Nothing ambiguous any more — take the notice back down if one is up.
@@ -2892,7 +3019,9 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
2892
3019
  if (typeof m.subtype === "string" && !ASK_ANSWERABLE_SUBTYPES.has(m.subtype)) continue;
2893
3020
  if (Number(m.ts) >= before) break;
2894
3021
  const t = typeof m.text === "string" ? m.text.trim() : "";
2895
- if (!t) continue;
3022
+ // Our ℹ️ note is bookkeeping — only on a question that offers ℹ️, so an
3023
+ // older question's reply is never second-guessed.
3024
+ if (!t || (ctx.explain && askIsExplainNote(t))) continue;
2896
3025
  // A question asked WITHOUT choices is answered by whatever comes back —
2897
3026
  // there is nothing to match against, and every reply is the answer.
2898
3027
  if (!candidates.length) return { answer: t, how: copy.howReply, who: m.user, ts: String(m.ts) };
@@ -3038,6 +3167,12 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3038
3167
  } catch {
3039
3168
  // Cosmetic: the ✅ in the body is what actually marks it resolved.
3040
3169
  }
3170
+ try {
3171
+ // Asking for an explanation is over once it is answered.
3172
+ if (ctx.explain) await reactionRemove(token, channelId, ts, ASK_EXPLAIN_MARKER, cookie);
3173
+ } catch {
3174
+ // Cosmetic: the ✅ in the body is what actually marks it resolved.
3175
+ }
3041
3176
  } catch (e: unknown) {
3042
3177
  // The edit is bookkeeping, not the answer. It fails whenever the resuming
3043
3178
  // token is not the one that posted (Slack lets only the author update), and
@@ -3099,6 +3234,15 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3099
3234
  await askEmit(token, ctx, r);
3100
3235
  process.exit(0);
3101
3236
  }
3237
+ // ℹ️ is never an answer: the question stays open, ✅ is not written, the
3238
+ // seeds stay. Only a caller that asked to hear about it stops here — and
3239
+ // only on the run that posted the thread note, so a `--timeout 0` poller is
3240
+ // woken once per question, not on every look.
3241
+ if (state.explainPosted && ctx.explainExit) {
3242
+ console.error(` 説明を求められています (質問はまだ開いています)。スレッドで説明してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3243
+ if (ctx.json) await askEmit(token, ctx, { status: "explain", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3244
+ process.exit(ASK_EXIT_EXPLAIN);
3245
+ }
3102
3246
  // A free-text reply that picks nothing is delivered NOW, not held until the
3103
3247
  // deadline: it is usually an instruction or a question back, and the only
3104
3248
  // one who can act on it is the caller. Left unstamped — a pill pressed
@@ -3119,7 +3263,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3119
3263
  // but still reports a pill pressed after it.
3120
3264
  markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
3121
3265
  if (ctx.json) {
3122
- await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
3266
+ await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts), explain: state.explainNow });
3123
3267
  } else {
3124
3268
  console.log(unchosen.text);
3125
3269
  }
@@ -3164,7 +3308,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3164
3308
  console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
3165
3309
  console.error(` ${shown}`);
3166
3310
  console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3167
- if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
3311
+ if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3168
3312
  process.exit(ASK_EXIT_UNCHOSEN);
3169
3313
  }
3170
3314
  if (timeout === 0) {
@@ -3174,7 +3318,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3174
3318
  console.error(` ${shown}`);
3175
3319
  console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3176
3320
  }
3177
- if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
3321
+ if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3178
3322
  process.exit(ASK_EXIT_TIMEOUT);
3179
3323
  }
3180
3324
 
@@ -3303,7 +3447,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3303
3447
  process.exit(ASK_EXIT_ERROR);
3304
3448
  }
3305
3449
  const lang = picked.lang;
3306
- const message = askBuildText(question, body, reactable, overflow, threadOnly, lang);
3450
+ const explain = args.explain ?? askExplainDefault();
3451
+ const message = askBuildText(question, body, reactable, overflow, threadOnly, lang, explain);
3307
3452
  guardUrlBoundaries(message, args.allowUrlAdjacent);
3308
3453
 
3309
3454
  // Preview the destination's last message, exactly as `send` does — the gate's
@@ -3354,6 +3499,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3354
3499
  // shown as its own line rather than left to be inferred from the body.
3355
3500
  ` Answerable by: ${audienceLabel}`,
3356
3501
  ` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
3502
+ ...(explain ? [` Explain: ℹ️ pill offered — pressing it asks you to explain; it is never an answer`] : []),
3357
3503
  ...(args.voidAfter ? [` Then 無効化: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) — replaced by this question`] : []),
3358
3504
  ` 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)`,
3359
3505
  `--------------------------------────────────`,
@@ -3382,7 +3528,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3382
3528
  // `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
3383
3529
  // SLACK_REACTION_SEED_GAP_MS.
3384
3530
  await seedReactionsInOrder(
3385
- [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
3531
+ [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER, ...(explain ? [ASK_EXPLAIN_MARKER] : [])],
3386
3532
  (name) => reactionAdd(token, channelId, ts, name, cookie),
3387
3533
  (name, e) => {
3388
3534
  const why = e instanceof Error ? e.message : String(e);
@@ -3438,6 +3584,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3438
3584
  shown,
3439
3585
  asBot: !!args.asBot,
3440
3586
  json: !!args.json,
3587
+ explain,
3588
+ explainExit: !!args.explainExit,
3441
3589
  };
3442
3590
  if (threadTs) ctx.threadParentTs = threadTs;
3443
3591
  if (cookie) ctx.cookie = cookie;
@@ -3597,7 +3745,7 @@ async function cmdAskEdit(token: string, args: {
3597
3745
  const reactable = choices.slice(0, ASK_MAX_REACTION_CHOICES);
3598
3746
  const overflow = choices.slice(ASK_MAX_REACTION_CHOICES);
3599
3747
  const oldText = typeof t.msg.text === "string" ? t.msg.text : "";
3600
- let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang);
3748
+ let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang, old.explain);
3601
3749
  // An invalid-ballot notice still in force stays (it names the people who
3602
3750
  // have to fix their vote); it is only possible when the options did not change.
3603
3751
  const invalid = readInvalidNotice(oldText);
@@ -3632,11 +3780,12 @@ async function cmdAskEdit(token: string, args: {
3632
3780
  await editMessage(token, t.channelId, t.ts, next, args.cookie, true);
3633
3781
  if (choicesChanged) {
3634
3782
  // Re-seed in order: 1..n then ❓ last, as a fresh question has them.
3635
- for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
3783
+ const tail = old.explain ? [ASK_MARKER, ASK_EXPLAIN_MARKER] : [ASK_MARKER];
3784
+ for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail]) {
3636
3785
  try { await reactionRemove(token, t.channelId, t.ts, name, args.cookie); } catch { /* already gone */ }
3637
3786
  }
3638
3787
  await seedReactionsInOrder(
3639
- [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
3788
+ [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail],
3640
3789
  (name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
3641
3790
  (name, e) => console.error(` (リアクション ${name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`),
3642
3791
  );
@@ -3762,7 +3911,10 @@ async function cmdAskPending(
3762
3911
  }
3763
3912
  const rows = opts.channel ? all.filter((r) => r.channel === opts.channel || r.target === opts.channel) : all;
3764
3913
  const pending: AskPendingItem[] = [];
3765
- const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0 };
3914
+ const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0, explain: 0 };
3915
+ /** Open questions someone pressed ℹ️ on — reported only: the thread note is
3916
+ * the waiting session's to post (`--pending` writes nothing). */
3917
+ const explainAsked: string[] = [];
3766
3918
  const problems: string[] = [];
3767
3919
  // The log is machine-wide, but a token reads one workspace. Asks recorded
3768
3920
  // under another workspace are counted and named, not probed with the wrong
@@ -3807,6 +3959,10 @@ async function cmdAskPending(
3807
3959
  const unchosen = poller.state.unchosen;
3808
3960
  if (poller.state.voided) { count.void++; continue; }
3809
3961
  const collect = askResumeCommand(link, asBot, after);
3962
+ if (poller.state.explainNow.length) {
3963
+ count.explain++;
3964
+ explainAsked.push(`${link}: ℹ️ 説明を求められています (${poller.state.explainNow.map((u) => `<@${u}>`).join(" ")}) — スレッドで説明してください`);
3965
+ }
3810
3966
  const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
3811
3967
  if (found) {
3812
3968
  pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
@@ -3855,6 +4011,7 @@ async function cmdAskPending(
3855
4011
  }
3856
4012
  }
3857
4013
  for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
4014
+ for (const p of explainAsked) console.error(` (${stripTerminalControls(p)})`);
3858
4015
  for (const [team, n] of otherTeams) {
3859
4016
  console.error(` (${n} 件は別のワークスペース「${stripTerminalControls(team)}」の質問です — slack -w <name> ask --pending で確認)`);
3860
4017
  }
@@ -3863,6 +4020,7 @@ async function cmdAskPending(
3863
4020
  (count.ambiguous ? `, ${count.ambiguous} 件は返信が複数の選択肢に一致 (未確定)` : "") +
3864
4021
  (count.resolved ? `, ${count.resolved} 件は ✅ 回収済み` : "") +
3865
4022
  (count.void ? `, ${count.void} 件は無効化済み` : "") +
4023
+ (count.explain ? `, ${count.explain} 件は ℹ️ 説明のリクエストあり` : "") +
3866
4024
  ` — ${rows.length} 件の未回収の質問を確認 (log: ${sentDbPath()})`,
3867
4025
  );
3868
4026
  }
@@ -3887,7 +4045,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
3887
4045
  console.error(`✗ ${shown}: 書き換えに失敗しました: ${e instanceof Error ? e.message : String(e)}`);
3888
4046
  return false;
3889
4047
  }
3890
- for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
4048
+ for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER, ...(t.parsed.explain ? [ASK_EXPLAIN_MARKER] : [])]) {
3891
4049
  try { await reactionRemove(token, t.channelId, t.ts, name, cookie); } catch { /* not ours / already gone */ }
3892
4050
  }
3893
4051
  try {
@@ -3981,6 +4139,8 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
3981
4139
  // Placeholders: only the COUNT is used — which of our keycap seeds to take off.
3982
4140
  reactable: Array.from({ length: seeded.length ? Math.max(...seeded) + 1 : 0 }, () => ""),
3983
4141
  overflow: [], threadOnly: true, lang: askDetectLang(text) ?? "ja",
4142
+ // Our ℹ️ seed comes off too, if we put one on.
4143
+ explain: asArray(msg.reactions).map(asRecord).some((r) => r.name === ASK_EXPLAIN_MARKER && asArray(r.users).includes(self?.userId ?? "\u0000")),
3984
4144
  };
3985
4145
  unreadable = true;
3986
4146
  }
@@ -4027,7 +4187,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4027
4187
  * file to go stale, lose, or disagree with Slack, and any machine holding the
4028
4188
  * permalink can collect — at the cost of the body being a parseable format
4029
4189
  * (`askBuildText` ⇄ `askParseMessage`). */
4030
- async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string }): Promise<void> {
4190
+ async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string; explainExit?: boolean }): Promise<void> {
4031
4191
  const url = parseSlackPermalink(args.link);
4032
4192
  let channelId: string;
4033
4193
  let ts: string;
@@ -4181,7 +4341,7 @@ function askWaitCtxFrom(
4181
4341
  q: AskReadBack,
4182
4342
  parsed: Extract<AskReadBack["parsed"], { kind: "open" }>,
4183
4343
  loc: { channelId: string; ts: string; threadTs?: string | undefined },
4184
- o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined },
4344
+ o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined; explainExit?: boolean | undefined },
4185
4345
  ): AskWaitCtx {
4186
4346
  const ctx: AskWaitCtx = {
4187
4347
  channelId: loc.channelId,
@@ -4199,6 +4359,8 @@ function askWaitCtxFrom(
4199
4359
  shown: o.link,
4200
4360
  asBot: o.asBot,
4201
4361
  json: o.json,
4362
+ explain: parsed.explain,
4363
+ explainExit: !!o.explainExit,
4202
4364
  };
4203
4365
  if (loc.threadTs) ctx.threadParentTs = loc.threadTs;
4204
4366
  if (o.cookie) ctx.cookie = o.cookie;
@@ -5742,8 +5904,8 @@ async function main(): Promise<void> {
5742
5904
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
5743
5905
  .option("body", { type: "string", describe: "Extra context shown under the question" })
5744
5906
  .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" })
5745
- .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." })
5746
- .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." })
5907
+ .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. 7 = only with --explain-exit: ℹ️ pressed, explain and collect again (the question stays open)." })
5908
+ .option("waitFor", { type: "string", describe: "Collect the answer to a question already posted: pass its permalink. Nothing is posted (except, on an --explain question someone pressed ℹ️ on, one thread note per question). Same stdout/exit contract as --wait; --timeout 0 checks once and exits 2 if still open." })
5747
5909
  .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." })
5748
5910
  .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…>`." })
5749
5911
  .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." })
@@ -5757,6 +5919,8 @@ async function main(): Promise<void> {
5757
5919
  .option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
5758
5920
  .option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
5759
5921
  .option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
5922
+ .option("explain", { type: "boolean", describe: "Also offer an ℹ️ 'explain' pill (seeded after ❓): pressing it asks YOU to explain the question in its thread and is never an answer — the question stays open. --wait/--waitFor report it on stderr (and as `explain` in --json), and post one thread note per question. Default: $SLACK_TERM_ASK_EXPLAIN=1 turns it on; --no-explain turns it off." })
5923
+ .option("explain-exit", { type: "boolean", default: false, describe: "With --wait / --waitFor: exit 7 (stdout empty; --json status \"explain\") when someone presses ℹ️ — once per question, on the run that posts the thread note. The question stays open: explain, then collect again." })
5760
5924
  .option("json", { type: "boolean", default: false, describe: "With --pending: one JSON object per answered question (sender, answer, collect command). 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." })
5761
5925
  .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
5762
5926
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
@@ -5881,6 +6045,8 @@ async function main(): Promise<void> {
5881
6045
  argv.choices = newChoices.length ? newChoices : [...t.parsed.reactable, ...t.parsed.overflow].map(escapeArg);
5882
6046
  if (argv.body === undefined && t.parsed.body) argv.body = escapeArg(t.parsed.body);
5883
6047
  if (!argv.lang) argv.lang = t.parsed.lang;
6048
+ // A re-ask offers what the old question offered, unless told otherwise.
6049
+ if (argv.explain === undefined && t.parsed.explain) argv.explain = true;
5884
6050
  argv.void = undefined;
5885
6051
  }
5886
6052
  if (!voidAfter && (argv.target === "void" || voidFlag.length)) {
@@ -5970,7 +6136,8 @@ async function main(): Promise<void> {
5970
6136
  waitCookie = ck(argv as W);
5971
6137
  }
5972
6138
  try {
5973
- const a: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"], json: !!argv.json };
6139
+ const a: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string; explainExit?: boolean } = { link: waitFor, timeout, asBot: !!argv["as-bot"], json: !!argv.json };
6140
+ if (argv["explain-exit"]) a.explainExit = true;
5974
6141
  if (waitCookie) a.cookie = waitCookie;
5975
6142
  if (argv.after) {
5976
6143
  const after = String(argv.after);
@@ -6008,6 +6175,8 @@ async function main(): Promise<void> {
6008
6175
  if (argv.lang) args.lang = String(argv.lang);
6009
6176
  if (argv.wait) args.wait = true;
6010
6177
  if (argv.json) args.json = true;
6178
+ if (typeof argv.explain === "boolean") args.explain = argv.explain;
6179
+ if (argv["explain-exit"]) args.explainExit = true;
6011
6180
  if (voidAfter) args.voidAfter = voidAfter;
6012
6181
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
6013
6182
  if (argv["user-id"]) args.userId = argv["user-id"];
@@ -7226,6 +7395,153 @@ async function main(): Promise<void> {
7226
7395
  )
7227
7396
  .command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
7228
7397
  )
7398
+ .command(
7399
+ "stream",
7400
+ "Print every new message matching --grep, across all channels the identity is in (threads included); resumable",
7401
+ (y) => y
7402
+ .option("grep", { alias: "e", type: "string", demandOption: true, describe: "Regex matched against each message's text (raw, e.g. <@U00000001>). Non-matching messages are never printed or logged" })
7403
+ .option("ignore-case", { alias: "i", type: "boolean", default: false, describe: "Case-insensitive --grep" })
7404
+ .option("channel", { type: "array", string: true, describe: "Only these channels (#name, id or permalink); repeatable" })
7405
+ .option("replies-to", { type: "array", string: true, describe: "Also print replies in threads this id started or replied in (user U…/bot B…; `self` = the streaming identity), --grep or not; repeatable" })
7406
+ .option("json", { type: "boolean", default: false, describe: "One JSON object per match: type, channel{id,name}, ts, thread_ts, user{id,name}, text, permalink, match (grep | replies-to)" })
7407
+ .option("once", { type: "boolean", default: false, describe: "Scan once from the saved cursor (or --since) and exit: 0 = matches printed, 2 = none, 3 = failure" })
7408
+ .option("since", { type: "string", describe: "Replay from this long ago (e.g. 30m, 2h), ignoring the saved cursor" })
7409
+ .option("interval", { type: "string", default: "45s", describe: "Poll interval (e.g. 30s, 2m)" })
7410
+ .option("thread-window", { type: "string", default: "3d", describe: "Watch replies in threads whose parent is at most this old" })
7411
+ .option("thread-scan", { type: "string", default: "3m", describe: "Polling: re-read each channel's whole --thread-window this often (cycles in between read only what is new)" })
7412
+ .option("calls-per-min", { type: "number", default: 40, describe: "Polling: pace conversations.history/replies to this many calls per minute (0 = unpaced)" })
7413
+ .option("state", { type: "string", describe: "Cursor file (default: one per identity+grep+channels under $XDG_STATE_HOME/slack-term/stream/)" })
7414
+ .option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
7415
+ .option("relay", { type: "boolean", default: true, describe: "Use the doorbell relay (worker/) when SLACK_RELAY_URL and SLACK_RELAY_TOKEN are set; --no-relay polls only" })
7416
+ .option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" }),
7417
+ async (argv) => {
7418
+ let re: RegExp;
7419
+ let reconcileMs: number;
7420
+ let intervalMs: number;
7421
+ let windowSec: number;
7422
+ let threadScanSec: number;
7423
+ let sinceSec: number | undefined;
7424
+ try {
7425
+ re = new RegExp(argv.grep, argv["ignore-case"] ? "i" : "");
7426
+ intervalMs = parseDuration(argv.interval) * 1000;
7427
+ windowSec = parseDuration(argv["thread-window"]);
7428
+ threadScanSec = parseDuration(argv["thread-scan"]);
7429
+ sinceSec = argv.since !== undefined ? parseDuration(argv.since) : undefined;
7430
+ reconcileMs = parseDuration(argv.reconcile) * 1000;
7431
+ } catch (e) {
7432
+ console.error(`slack stream: ${e instanceof Error ? e.message : String(e)}`);
7433
+ process.exit(1);
7434
+ }
7435
+ // Privacy-safe default: the bot sees only the channels it was invited
7436
+ // to, never the user's DMs. The user identity is an explicit opt-in.
7437
+ const bot = resolveBotToken();
7438
+ const asUser = argv["as-user"] === true || !bot;
7439
+ const token = asUser ? tok(argv as W) : bot!;
7440
+ const cookie = asUser ? ck(argv as W) : undefined;
7441
+ if (!bot && argv["as-user"] !== true) {
7442
+ console.error("slack stream: no bot token (SLACK_BOT_TOKEN) — streaming as the USER identity, which includes its DMs.");
7443
+ }
7444
+ let me: Awaited<ReturnType<typeof authScopes>>;
7445
+ try {
7446
+ me = await authScopes(token, cookie);
7447
+ } catch (e) {
7448
+ console.error(`slack stream: fatal: ${e instanceof Error ? e.message : String(e)}`);
7449
+ process.exit(3);
7450
+ }
7451
+ // Self-echo exclusion is by sender id: the identity's own user id, its
7452
+ // bot id, and — when streaming as the user — the bot's posts too.
7453
+ const selfUsers = new Set([me.userId].filter(Boolean));
7454
+ const selfBots = new Set([me.botId].filter(Boolean));
7455
+ if (asUser && bot) {
7456
+ try {
7457
+ const b = await authScopes(bot);
7458
+ if (b.userId) selfUsers.add(b.userId);
7459
+ if (b.botId) selfBots.add(b.botId);
7460
+ } catch { /* a dead bot token posts nothing to exclude */ }
7461
+ }
7462
+ // Slack names a bot in a thread either way: `parent_user_id` holds its
7463
+ // bot USER id (auth.test user_id), `reply_users` sometimes its B… bot
7464
+ // id — so `self` stands for both.
7465
+ let repliesTo: Set<string> | undefined;
7466
+ if (argv["replies-to"]?.length) {
7467
+ repliesTo = new Set();
7468
+ for (const r of argv["replies-to"].map(String)) {
7469
+ if (r === "self") {
7470
+ for (const id of [me.userId, me.botId]) if (id) repliesTo.add(id);
7471
+ continue;
7472
+ }
7473
+ if (!/^[UWB][A-Z0-9]{2,}$/.test(r)) {
7474
+ console.error(`slack stream: --replies-to: "${r}" is not a user or bot id (U…/W…/B…) or "self"`);
7475
+ process.exit(1);
7476
+ }
7477
+ repliesTo.add(r);
7478
+ // A bot can be listed under its user id too: add that alias.
7479
+ if (r.startsWith("B")) {
7480
+ try {
7481
+ const { userId } = await botsInfo(token, r, cookie);
7482
+ if (userId) repliesTo.add(userId);
7483
+ } catch (e) {
7484
+ console.error(`slack stream: --replies-to ${r}: cannot resolve its bot user id (${e instanceof Error ? e.message : String(e)}); threads where it shows as a user id are missed`);
7485
+ }
7486
+ }
7487
+ }
7488
+ }
7489
+ let channels: string[] | undefined;
7490
+ if (argv.channel?.length) {
7491
+ try {
7492
+ channels = [];
7493
+ for (const c of argv.channel) channels.push(await resolveChannel(token, String(c), cookie));
7494
+ } catch (e) {
7495
+ console.error(`slack stream: --channel: ${e instanceof Error ? e.message : String(e)}`);
7496
+ process.exit(1);
7497
+ }
7498
+ }
7499
+ const key = createHash("sha256")
7500
+ .update(JSON.stringify([me.url, me.userId, re.source, re.flags, [...(channels ?? [])].sort(),
7501
+ // As given, not as resolved: a failed bots.info lookup must not
7502
+ // move the stream to a fresh state file.
7503
+ ...(argv["replies-to"]?.length ? [[...argv["replies-to"].map(String)].sort()] : [])]))
7504
+ .digest("hex").slice(0, 16);
7505
+ const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
7506
+ const statePath = argv.state ?? join(stateHome, "slack-term", "stream", `${key}.json`);
7507
+ console.error(`slack stream: as ${asUser ? "user" : "bot"} @${me.user} (${me.userId}); state ${statePath}`);
7508
+ // The relay rings for what the Slack app's bot can see, so it only
7509
+ // stands in for polling when streaming as that bot.
7510
+ const relayUrl = process.env.SLACK_RELAY_URL;
7511
+ const relayToken = process.env.SLACK_RELAY_TOKEN;
7512
+ const relay = argv.relay && !asUser && !argv.once && relayUrl && relayToken
7513
+ ? { url: relayUrl, subscribe: relaySubscriber(relayUrl, relayToken), reconcileMs }
7514
+ : undefined;
7515
+ if (argv.relay && asUser && relayUrl) console.error("slack stream: relay not used with --as-user (it only sees the bot's channels)");
7516
+ const pollReason = !argv.relay ? "--no-relay"
7517
+ : asUser ? (relayUrl ? "--as-user: the relay only rings for the bot's channels" : "streaming as the user; the relay serves the bot identity")
7518
+ : "SLACK_RELAY_URL / SLACK_RELAY_TOKEN not set";
7519
+ const ac = new AbortController();
7520
+ process.on("SIGINT", () => ac.abort());
7521
+ process.on("SIGTERM", () => ac.abort());
7522
+ const code = await runStream(webClient(token, cookie), {
7523
+ grep: re,
7524
+ ...(repliesTo ? { repliesTo } : {}),
7525
+ ...(channels ? { channels } : {}),
7526
+ selfUsers,
7527
+ selfBots,
7528
+ json: argv.json,
7529
+ once: argv.once,
7530
+ ...(sinceSec !== undefined ? { sinceSec } : {}),
7531
+ intervalMs,
7532
+ threadWindowSec: windowSec,
7533
+ threadScanSec,
7534
+ callsPerMin: argv["calls-per-min"],
7535
+ ...(relay ? {} : { pollReason }),
7536
+ statePath,
7537
+ teamUrl: me.url,
7538
+ identity: me.userId,
7539
+ signal: ac.signal,
7540
+ ...(relay ? { relay } : {}),
7541
+ });
7542
+ process.exit(code);
7543
+ },
7544
+ )
7229
7545
  .command(
7230
7546
  "tail [target]",
7231
7547
  "Stream new messages in real time (like tail -f)",