slack-term 1.41.2 → 1.43.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,7 +12,9 @@ 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 {
@@ -54,10 +56,11 @@ import {
54
56
  } from "./poll.ts";
55
57
  import { seedReactionsInOrder } from "./reactionSeed.ts";
56
58
  import { buildListSchema, cellValue, completedColumn, findColumn, parseColumnSpec, parseListSchema, parseSets, type ListColumn } from "./lists.ts";
57
- import { attributionEnabled, attributionMetadata, captureAttribution, parseSince, querySent, recordSent, SENT_LOG_UNAVAILABLE, sentDbPath, type RecordSent, type SentKind, type SentRow } from "./sentlog.ts";
59
+ import { attributionEnabled, attributionMetadata, captureAttribution, markAskCollected, markAskDelivered, parseSince, querySent, recordSent, SENT_LOG_UNAVAILABLE, sentDbPath, senderAlive, uncollectedAsks, type RecordSent, type SentKind, type SentRow } from "./sentlog.ts";
58
60
  import {
59
61
  authTest,
60
62
  authScopes,
63
+ botsInfo,
61
64
  authTestSession,
62
65
  conversationInfoSession,
63
66
  createChannel,
@@ -2740,12 +2743,33 @@ async function askEmit(
2740
2743
  }
2741
2744
 
2742
2745
 
2743
- /** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
2744
- * on timeout. A timeout of 0 means "look exactly once", so a caller can poll
2745
- * cheaply on its own schedule instead of parking a process on `--wait`. */
2746
- async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never> {
2747
- const { channelId, ts, question, reactable, threadOnly, audience, broadcast, cookie, timeout, shown } = ctx;
2746
+ /** What one look at a question found besides the answer — read back by the
2747
+ * `--wait` loop between polls, and by `ask --pending` after its single look. */
2748
+ interface AskPollState {
2749
+ /** The earliest audience reply that picked none of the choices. */
2750
+ unchosen?: { text: string; who: string; ts: string; ambiguous: boolean };
2751
+ /** Set when the question has been voided. */
2752
+ voided?: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }>;
2753
+ /** The question's `reply_count` as last seen — undefined until seen. */
2754
+ replyCount?: number;
2755
+ /** The question's text as last read — what the ✅ rewrite keeps the body of. */
2756
+ ownText?: string;
2757
+ }
2758
+
2759
+ /** The polling half of `--wait`: one `poll()` is one look at the question,
2760
+ * shared with `ask --pending`. `readOnly` drops the one WRITE a look can make
2761
+ * (the invalid-ballot notice edited into the question) and the progress lines
2762
+ * on stderr — `--pending` inspects questions other sessions are responsible
2763
+ * for, and must neither touch them nor narrate their state as if waiting. */
2764
+ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
2765
+ poll: () => Promise<AskFound | null>;
2766
+ state: AskPollState;
2767
+ isAnswerer: (user: unknown) => user is string;
2768
+ candidates: string[];
2769
+ } {
2770
+ const { channelId, ts, reactable, threadOnly, audience, broadcast, cookie } = ctx;
2748
2771
  const copy = ASK_COPY[ctx.lang];
2772
+ const state: AskPollState = {};
2749
2773
  const askerUserId = ctx.askerUserId;
2750
2774
  const askerBotId = ctx.askerBotId;
2751
2775
 
@@ -2756,25 +2780,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2756
2780
  const isAnswerer = (user: unknown): user is string =>
2757
2781
  typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
2758
2782
 
2759
- /** The question's `reply_count` as last seen — whether its thread is worth a
2760
- * fetch when collecting notes. Undefined until the question itself is seen. */
2761
- let replyCount: number | undefined;
2762
- /** The question's text as last read — what the ✅ rewrite keeps the body of. */
2763
- let ownText: string | undefined;
2783
+ /** The question as last seen: its `reply_count` (whether its thread is worth
2784
+ * a fetch when collecting notes) and its text (what the ✅ rewrite keeps). */
2785
+ const seen = (own: Record<string, Json>): void => {
2786
+ state.replyCount = Number(own.reply_count) || 0;
2787
+ if (typeof own.text === "string") state.ownText = own.text;
2788
+ };
2764
2789
  /** Set when the question is voided while we wait — the wait ends there. */
2765
- let voided: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }> | undefined;
2766
2790
  const checkVoid = (own: Record<string, Json>): boolean => {
2767
2791
  const p = typeof own.text === "string" ? askParseMessage(own.text) : undefined;
2768
- if (p?.kind === "void") voided = p;
2769
- return !!voided;
2792
+ if (p?.kind === "void") state.voided = p;
2793
+ return !!state.voided;
2770
2794
  };
2771
2795
 
2772
- /** The earliest reply from an answerer that picked none of the choices. Kept
2773
- * across polls so the timeout can report it, and so the operator is told once
2774
- * rather than on every tick. */
2775
- let unchosen: { text: string; who: string; ts: string; ambiguous: boolean } | undefined;
2776
- let unchosenReported = "";
2777
-
2778
2796
  /** Every seed an answerer is on — not just the first. Changing your mind
2779
2797
  * leaves BOTH reactions in place (Slack only drops one when you actively
2780
2798
  * un-react), so taking the lowest index would answer with the abandoned
@@ -2800,6 +2818,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2800
2818
  * the API call when nothing changed, which is what makes this safe to run on
2801
2819
  * every poll tick rather than once per process. */
2802
2820
  async function noteInvalid(msg: Record<string, Json>, offenders: string[]): Promise<void> {
2821
+ if (readOnly) return;
2803
2822
  const text = typeof msg.text === "string" ? msg.text : "";
2804
2823
  if (!text) return;
2805
2824
  const next = applyInvalidNotice(text, offenders, ctx.lang);
@@ -2822,7 +2841,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2822
2841
  for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
2823
2842
  if (otherReported.has(u)) continue;
2824
2843
  otherReported.add(u);
2825
- console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
2844
+ if (!readOnly) console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
2826
2845
  }
2827
2846
  }
2828
2847
 
@@ -2845,7 +2864,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2845
2864
  const pillsPerUser = new Map<string, number>();
2846
2865
  for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
2847
2866
  const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
2848
- console.error(multi.length
2867
+ if (!readOnly) console.error(multi.length
2849
2868
  ? ` (${glyphs} が同時に選ばれています。1 つに絞ってもらうまで待ちます)`
2850
2869
  : ` (回答が分かれています: ${glyphs}。どちらか一方にそろうまで待ちます)`);
2851
2870
  // Whoever is on more than one pill — and ONLY them. Naming everyone on any
@@ -2895,9 +2914,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2895
2914
  // must not hide it behind the refuse-to-guess path. Within a class, the
2896
2915
  // earliest wins.
2897
2916
  const ambiguous = match.kind === "ambiguous";
2917
+ const unchosen = state.unchosen;
2898
2918
  if (!unchosen || (unchosen.ambiguous && !ambiguous) ||
2899
2919
  (unchosen.ambiguous === ambiguous && Number(m.ts) < Number(unchosen.ts))) {
2900
- unchosen = { text: t, who: typeof m.user === "string" ? m.user : "", ts: String(m.ts), ambiguous };
2920
+ state.unchosen = { text: t, who: typeof m.user === "string" ? m.user : "", ts: String(m.ts), ambiguous };
2901
2921
  }
2902
2922
  }
2903
2923
  return null;
@@ -2910,7 +2930,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2910
2930
  // longer there, and exit 4 where exit 2 is the truth. `unchosenReported`
2911
2931
  // persists on purpose — it is what keeps the operator from being told the
2912
2932
  // same thing on every tick.
2913
- unchosen = undefined;
2933
+ delete state.unchosen;
2914
2934
  // A question that is itself a thread reply is invisible to
2915
2935
  // `conversations.history`, so the entire poll — reactions included — has to
2916
2936
  // come from the thread.
@@ -2919,8 +2939,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2919
2939
  const messages = asArray(rep.messages).map(asRecord);
2920
2940
  const own = messages.find((m) => m.ts === ts);
2921
2941
  if (own) {
2922
- replyCount = Number(own.reply_count) || 0;
2923
- if (typeof own.text === "string") ownText = own.text;
2942
+ seen(own);
2924
2943
  if (checkVoid(own)) return null;
2925
2944
  const byReaction = await answerFromReactions(own);
2926
2945
  if (byReaction) return byReaction;
@@ -2936,8 +2955,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2936
2955
  const own = messages.find((m) => m.ts === ts);
2937
2956
 
2938
2957
  if (own) {
2939
- replyCount = Number(own.reply_count) || 0;
2940
- if (typeof own.text === "string") ownText = own.text;
2958
+ seen(own);
2941
2959
  if (checkVoid(own)) return null;
2942
2960
  }
2943
2961
  // A reaction is the intended path, so it wins when both are present.
@@ -2970,6 +2988,21 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2970
2988
  return null;
2971
2989
  }
2972
2990
 
2991
+ return { poll, state, isAnswerer, candidates };
2992
+ }
2993
+
2994
+ /** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
2995
+ * on timeout. A timeout of 0 means "look exactly once", so a caller can poll
2996
+ * cheaply on its own schedule instead of parking a process on `--wait`. */
2997
+ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never> {
2998
+ const { channelId, ts, question, reactable, threadOnly, cookie, timeout, shown } = ctx;
2999
+ const askerUserId = ctx.askerUserId;
3000
+ const askerBotId = ctx.askerBotId;
3001
+ const { poll, state, isAnswerer, candidates } = askPoller(token, ctx);
3002
+ /** Kept across polls so the operator is told about one reply once, rather
3003
+ * than on every tick. */
3004
+ let unchosenReported = "";
3005
+
2973
3006
  /** Stamp the question resolved. Removing our seeds clears the pills that still
2974
3007
  * invite an answer; the answerer's own reaction cannot be removed by us and
2975
3008
  * stays put — which is exactly what leaves the chosen number visible. */
@@ -2996,7 +3029,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2996
3029
  // The body and the chosen option stay; only what no longer applies goes.
2997
3030
  // Built from the message as it is NOW (an edit made in Slack after asking
2998
3031
  // is kept). Not seen this run — the bare form, as before.
2999
- const keep = ownText ? askResolvedKeep(ownText, found.choice) : undefined;
3032
+ const keep = state.ownText ? askResolvedKeep(state.ownText, found.choice) : undefined;
3000
3033
  await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
3001
3034
  // Swap the marker for the resolved one so search reflects reality:
3002
3035
  // `has::question:` should list what still needs answering, not everything
@@ -3019,6 +3052,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3019
3052
  const deadline = Date.now() + timeout * 1000;
3020
3053
  let errors = 0;
3021
3054
  let delay = ASK_POLL_MIN_MS;
3055
+ let unchosen: AskPollState["unchosen"];
3022
3056
  for (;;) {
3023
3057
  let found: AskFound | null = null;
3024
3058
  try {
@@ -3033,6 +3067,8 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3033
3067
  }
3034
3068
  console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
3035
3069
  }
3070
+ unchosen = state.unchosen;
3071
+ const voided = state.voided;
3036
3072
  if (voided) {
3037
3073
  askReportVoid(voided, shown);
3038
3074
  if (ctx.json) {
@@ -3050,7 +3086,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3050
3086
  // BEFORE ✅ is written, and delivered right after — ✅ is what tells a
3051
3087
  // later --waitFor the question is settled.
3052
3088
  const notes = await askCollectNotes(token, {
3053
- channelId, ts, threadOnly, replyCount, isAnswerer, askerUserId, askerBotId,
3089
+ channelId, ts, threadOnly, replyCount: state.replyCount, isAnswerer, askerUserId, askerBotId,
3054
3090
  ...(ctx.threadParentTs ? { threadParentTs: ctx.threadParentTs } : {}),
3055
3091
  ...(cookie ? { cookie } : {}),
3056
3092
  }, ctx.after, (m) => m.ts === found!.ts);
@@ -3059,6 +3095,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3059
3095
  status: "answered", answer: found.answer, how: found.how, notes,
3060
3096
  cursor: askNextCursor(ts, ctx.after, found.ts, ...notes.map((n) => n.ts)),
3061
3097
  };
3098
+ // Collected: the local sent-log row (if this machine asked it) drops off
3099
+ // `ask --pending`. Only here, where the answer actually reaches a caller.
3100
+ markAskCollected(channelId, ts, found.answer, r.cursor);
3062
3101
  if (found.who) r.who = found.who;
3063
3102
  await askEmit(token, ctx, r);
3064
3103
  process.exit(0);
@@ -3079,6 +3118,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3079
3118
  console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
3080
3119
  console.error(` ${shown}`);
3081
3120
  console.error(` この返信の後を待つ: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
3121
+ // Delivered, not collected: `--pending` will not report THIS reply again,
3122
+ // but still reports a pill pressed after it.
3123
+ markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
3082
3124
  if (ctx.json) {
3083
3125
  await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
3084
3126
  } else {
@@ -3434,6 +3476,16 @@ async function askFetchByLink(token: string, link: string, cookie?: string): Pro
3434
3476
  channelId = m[1]!;
3435
3477
  ts = m[2]!;
3436
3478
  }
3479
+ return askFetchAt(token, { channelId, ts, threadTs }, cookie);
3480
+ }
3481
+
3482
+ /** `askFetchByLink` for a location already known — e.g. a sent-log row, whose
3483
+ * `thread_ts` says where a question posted into a thread lives. */
3484
+ async function askFetchAt(token: string, loc: { channelId: string; ts: string; threadTs?: string | undefined }, cookie?: string): Promise<
3485
+ { channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> }
3486
+ > {
3487
+ const { channelId, ts } = loc;
3488
+ const threadTs = loc.threadTs && loc.threadTs !== loc.ts ? loc.threadTs : undefined;
3437
3489
  let msg: Record<string, Json> | undefined;
3438
3490
  if (threadTs) {
3439
3491
  const rep = asRecord((await replies(token, channelId, threadTs, 100, cookie)) as Json);
@@ -3635,8 +3687,8 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
3635
3687
  let answer: string | undefined;
3636
3688
  if (token) {
3637
3689
  try {
3638
- const got = await askFetchByLink(token, `${r.channel}:${r.ts}`, r.as_bot ? undefined : tokens.cookie);
3639
- if (!got?.msg) state = "gone";
3690
+ const got = await askFetchAt(token, { channelId: r.channel, ts: r.ts, threadTs: r.thread_ts ?? undefined }, r.as_bot ? undefined : tokens.cookie);
3691
+ if (!got.msg) state = "gone";
3640
3692
  else {
3641
3693
  const p = askParseMessage(typeof got.msg.text === "string" ? got.msg.text : "");
3642
3694
  state = p.kind === "resolved" ? "answered" : p.kind === "other" ? "edited" : p.kind;
@@ -3674,6 +3726,150 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
3674
3726
  }
3675
3727
  }
3676
3728
 
3729
+ /** One answered-but-uncollected ask, as `--pending` reports it. */
3730
+ interface AskPendingItem {
3731
+ row: SentRow;
3732
+ link: string;
3733
+ question: string;
3734
+ answer: string;
3735
+ how: string;
3736
+ /** A free-text reply that picked none of the choices — not a decision. */
3737
+ freeText: boolean;
3738
+ alive: boolean;
3739
+ /** The ready-to-run `--waitFor` line, cursor included. */
3740
+ collect: string;
3741
+ }
3742
+
3743
+ /** `slack ask --pending [--since 7d] [--json]` — questions THIS machine asked
3744
+ * that somebody answered and nobody collected, grouped by the asking session,
3745
+ * with whether that agent is still alive.
3746
+ *
3747
+ * Why it exists: of 71 asks over 8 days, 13 had a pill pressed that nobody
3748
+ * ever read back (2026-09-27). The answer was sitting in Slack; the agent that
3749
+ * asked had moved on or died, and nothing connected the two.
3750
+ *
3751
+ * Read-only, against Slack AND the local log: each open question gets one look
3752
+ * through the same poller `--wait` uses, with its one write disabled. It only
3753
+ * REPORTS — collecting is `slack ask --waitFor=<link>`, printed per item,
3754
+ * which is also what stamps ✅ and takes the ask off this list. Nothing is
3755
+ * relayed into another process: whoever reads the list (the asking agent, or
3756
+ * a person) runs the collect line. */
3757
+ async function cmdAskPending(
3758
+ tokens: { user?: string; bot?: string; cookie?: string },
3759
+ opts: { sinceMs: number; json: boolean; channel?: string },
3760
+ ): Promise<void> {
3761
+ const all = uncollectedAsks(opts.sinceMs);
3762
+ if (!all) {
3763
+ console.error(`Error: ${SENT_LOG_UNAVAILABLE}\n (${sentDbPath()})`);
3764
+ process.exit(ASK_EXIT_ERROR);
3765
+ }
3766
+ const rows = opts.channel ? all.filter((r) => r.channel === opts.channel || r.target === opts.channel) : all;
3767
+ const pending: AskPendingItem[] = [];
3768
+ const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0 };
3769
+ const problems: string[] = [];
3770
+ // The log is machine-wide, but a token reads one workspace. Asks recorded
3771
+ // under another workspace are counted and named, not probed with the wrong
3772
+ // token (which would only fail as channel_not_found).
3773
+ const currentTeam = tokens.user
3774
+ ? (await selfIdentity(tokens.user, tokens.cookie))?.team ?? ""
3775
+ : tokens.bot ? (await selfIdentity(tokens.bot))?.team ?? "" : "";
3776
+ const otherTeams = new Map<string, number>();
3777
+
3778
+ for (const row of rows) {
3779
+ if (row.team && currentTeam && row.team !== currentTeam) {
3780
+ otherTeams.set(row.team, (otherTeams.get(row.team) ?? 0) + 1);
3781
+ continue;
3782
+ }
3783
+ const link = row.permalink || `${row.channel}:${row.ts}`;
3784
+ const asBot = !!row.as_bot;
3785
+ const token = asBot ? tokens.bot : tokens.user;
3786
+ if (!token) {
3787
+ problems.push(`${link}: ${asBot ? "--as-bot で聞いた質問ですが bot token がありません" : "user token がありません"}`);
3788
+ continue;
3789
+ }
3790
+ const cookie = asBot ? undefined : tokens.cookie;
3791
+ // Resume from where the last delivery to a caller ended (an exit 5
3792
+ // free-text reply), so that reply is not reported again — and the collect
3793
+ // line carries the same cursor, so `--waitFor` does not re-deliver it.
3794
+ const after = row.delivered_cursor ?? undefined;
3795
+ try {
3796
+ const loc = { channelId: row.channel, ts: row.ts, threadTs: row.thread_ts ?? undefined };
3797
+ const got = await askFetchAt(token, loc, cookie);
3798
+ if (!got.msg) { problems.push(`${link}: メッセージが見つかりません (削除?)`); continue; }
3799
+ const q = await askReadBack(token, { channelId: row.channel, ts: row.ts, msg: got.msg }, cookie);
3800
+ const parsed = q.parsed;
3801
+ // ✅ already stamped: some `--wait`/`--waitFor` collected it (another
3802
+ // machine, another token, or a build before collection was logged).
3803
+ if (parsed.kind === "resolved") { count.resolved++; continue; }
3804
+ if (parsed.kind === "void") { count.void++; continue; }
3805
+ if (parsed.kind === "other") { problems.push(`${link}: \`slack ask\` の質問として読めません — ${askExplainReject(q.text)}`); continue; }
3806
+ if (!q.audience.size && !q.broadcast) { problems.push(`${link}: 誰にも宛てられていない質問です`); continue; }
3807
+ const ctx = askWaitCtxFrom(q, parsed, loc, { timeout: 0, link, asBot, json: false, cookie, after });
3808
+ const poller = askPoller(token, ctx, true);
3809
+ const found = await poller.poll();
3810
+ const unchosen = poller.state.unchosen;
3811
+ if (poller.state.voided) { count.void++; continue; }
3812
+ const collect = askResumeCommand(link, asBot, after);
3813
+ const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
3814
+ if (found) {
3815
+ pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
3816
+ } else if (unchosen && !unchosen.ambiguous && unchosen.text !== row.delivered_answer) {
3817
+ pending.push({ ...base, answer: unchosen.text, how: "返信 (選択肢外)", freeText: true });
3818
+ } else if (unchosen?.ambiguous) {
3819
+ count.ambiguous++;
3820
+ } else {
3821
+ count.unanswered++;
3822
+ }
3823
+ } catch (e: unknown) {
3824
+ problems.push(`${link}: ${e instanceof Error ? e.message : String(e)}`);
3825
+ }
3826
+ }
3827
+
3828
+ if (opts.json) {
3829
+ for (const it of pending) {
3830
+ const r = it.row;
3831
+ console.log(JSON.stringify({
3832
+ id: r.id, link: it.link, channel: r.channel, ts: r.ts, asked_at: r.sent_at,
3833
+ question: it.question, answer: it.answer, how: it.how, free_text: it.freeText,
3834
+ sender: { session_id: r.session_id ?? null, cli: r.cli ?? null, agent_pid: r.agent_pid ?? null, pid: r.pid, alive: it.alive, cwd: r.cwd, git_branch: r.git_branch ?? null, host: r.host ?? null },
3835
+ delivered_at: r.delivered_at ?? null,
3836
+ collect: it.collect,
3837
+ }));
3838
+ }
3839
+ } else {
3840
+ // Grouped by the session that asked — the unit that has to be told.
3841
+ const groups = new Map<string, AskPendingItem[]>();
3842
+ for (const it of pending) {
3843
+ const key = it.row.session_id ? `session ${it.row.session_id}` : `pid ${it.row.agent_pid ?? it.row.pid}`;
3844
+ groups.set(key, [...(groups.get(key) ?? []), it]);
3845
+ }
3846
+ for (const [key, items] of groups) {
3847
+ const r = items[0]!.row;
3848
+ console.log(
3849
+ `${stripTerminalControls(key)} ${stripTerminalControls(r.cli ?? "?")} pid ${r.agent_pid ?? r.pid} ${items[0]!.alive ? "(alive)" : "(gone)"}` +
3850
+ `${r.git_branch ? ` branch=${stripTerminalControls(r.git_branch)}` : ""} cwd=${stripTerminalControls(r.cwd)}`,
3851
+ );
3852
+ for (const it of items) {
3853
+ console.log(` ${stripTerminalControls(it.link)} (${formatYmdHm(it.row.sent_at / 1000)})`);
3854
+ console.log(` Q: ${stripTerminalControls(askFlatten(it.question))}`);
3855
+ console.log(` A: ${stripTerminalControls(askFlatten(it.answer))} — ${stripTerminalControls(it.how)}${it.freeText ? " — 決定ではありません" : ""}`);
3856
+ console.log(` collect: ${stripTerminalControls(it.collect)}`);
3857
+ }
3858
+ }
3859
+ }
3860
+ for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
3861
+ for (const [team, n] of otherTeams) {
3862
+ console.error(` (${n} 件は別のワークスペース「${stripTerminalControls(team)}」の質問です — slack -w <name> ask --pending で確認)`);
3863
+ }
3864
+ console.error(
3865
+ `${pending.length} 件が回答済み・未回収, ${count.unanswered} 件が未回答` +
3866
+ (count.ambiguous ? `, ${count.ambiguous} 件は返信が複数の選択肢に一致 (未確定)` : "") +
3867
+ (count.resolved ? `, ${count.resolved} 件は ✅ 回収済み` : "") +
3868
+ (count.void ? `, ${count.void} 件は無効化済み` : "") +
3869
+ ` — ${rows.length} 件の未回収の質問を確認 (log: ${sentDbPath()})`,
3870
+ );
3871
+ }
3872
+
3677
3873
  /** A question about to be voided: where it is, and what it parsed as. */
3678
3874
  interface AskVoidTarget {
3679
3875
  link: string;
@@ -3858,23 +4054,14 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3858
4054
  ts = m[2]!;
3859
4055
  }
3860
4056
 
3861
- // Fetch the one message. A question posted into a thread is not in history at
3862
- // all, so where the permalink says it is a reply, read it from the thread.
3863
- let msg: Record<string, Json> | undefined;
3864
- if (threadTs) {
3865
- const rep = asRecord((await replies(token, channelId, threadTs, 100, args.cookie)) as Json);
3866
- msg = asArray(rep.messages).map(asRecord).find((m) => m.ts === ts);
3867
- } else {
3868
- const hist = asRecord((await history(token, channelId, 1, ts, undefined, args.cookie, true)) as Json);
3869
- msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
3870
- }
3871
- if (!msg) {
4057
+ const got = await askFetchAt(token, { channelId, ts, threadTs }, args.cookie);
4058
+ if (!got.msg) {
3872
4059
  console.error(`Error: そのメッセージが見つかりません: ${stripTerminalControls(args.link)}`);
3873
4060
  process.exit(ASK_EXIT_ERROR);
3874
4061
  }
3875
-
3876
- const text = typeof msg.text === "string" ? msg.text : "";
3877
- const parsed = askParseMessage(text);
4062
+ const msg = got.msg;
4063
+ const q = await askReadBack(token, { channelId, ts, msg }, args.cookie);
4064
+ const { text, parsed, audience, broadcast, askerUserId, askerBotId } = q;
3878
4065
  if (parsed.kind === "other") {
3879
4066
  console.error(
3880
4067
  `Error: これは \`slack ask\` の質問として読み取れません。\n` +
@@ -3887,23 +4074,6 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3887
4074
  process.exit(ASK_EXIT_ERROR);
3888
4075
  }
3889
4076
 
3890
- // Who may answer, recovered from the tags the readers can see. They are
3891
- // already `<@U…>`-encoded in the stored text, so no directory lookup is
3892
- // needed — and the asker is excluded here for the same reason as when
3893
- // posting: their tag of themselves grants nothing, and their reactions are
3894
- // the seeds.
3895
- const askerUserId = typeof msg.user === "string" ? msg.user : "";
3896
- const askerBotId = typeof msg.bot_id === "string" ? msg.bot_id : "";
3897
- const broadcast = /<!(here|channel|everyone)(\^[^>]*)?(\|[^>]*)?>/.test(text);
3898
- const audience = new Set<string>();
3899
- for (const m of text.matchAll(/<@([UW][A-Z0-9]+)>/g)) {
3900
- if (m[1] !== askerUserId) audience.add(m[1]!);
3901
- }
3902
- if (!audience.size && !broadcast) {
3903
- // A 1:1 DM needs no tag — the other party is the only person it could be.
3904
- const counterpart = await imCounterpart(token, channelId, args.cookie);
3905
- if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
3906
- }
3907
4077
  if (parsed.kind === "void") {
3908
4078
  askReportVoid(parsed, args.link);
3909
4079
  if (args.json) {
@@ -3955,10 +4125,11 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3955
4125
  ...(args.cookie ? { cookie: args.cookie } : {}),
3956
4126
  }, after, isAnswerReply);
3957
4127
  }
4128
+ const cursor = askNextCursor(ts, after, ...notes.map((n) => n.ts));
3958
4129
  await askEmit(token, { json: args.json, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
3959
- status: "answered", answer: parsed.answer, notes,
3960
- cursor: askNextCursor(ts, after, ...notes.map((n) => n.ts)),
4130
+ status: "answered", answer: parsed.answer, notes, cursor,
3961
4131
  });
4132
+ markAskCollected(channelId, ts, parsed.answer, cursor);
3962
4133
  return;
3963
4134
  }
3964
4135
 
@@ -3970,27 +4141,72 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3970
4141
  if (args.timeout > 0) {
3971
4142
  console.error(` 回答を待っています (最大 ${args.timeout}s)… Ctrl-C で中断`);
3972
4143
  }
4144
+ await askWaitForAnswer(token, askWaitCtxFrom(q, parsed, { channelId, ts, threadTs }, args));
4145
+ }
4146
+
4147
+ /** A posted question read back: what it parsed as, and who may answer it. */
4148
+ interface AskReadBack {
4149
+ text: string;
4150
+ parsed: ReturnType<typeof askParseMessage>;
4151
+ audience: Set<string>;
4152
+ broadcast: boolean;
4153
+ askerUserId: string;
4154
+ askerBotId: string;
4155
+ }
4156
+
4157
+ /** Recover who may answer from the message alone. Shared by `--waitFor` and
4158
+ * `--pending` — nothing about a question is stored, so both read it back. */
4159
+ async function askReadBack(token: string, t: { channelId: string; ts: string; msg: Record<string, Json> }, cookie?: string): Promise<AskReadBack> {
4160
+ const { msg } = t;
4161
+ const text = typeof msg.text === "string" ? msg.text : "";
4162
+ const parsed = askParseMessage(text);
4163
+ // Who may answer, recovered from the tags the readers can see. They are
4164
+ // already `<@U…>`-encoded in the stored text, so no directory lookup is
4165
+ // needed — and the asker is excluded here for the same reason as when
4166
+ // posting: their tag of themselves grants nothing, and their reactions are
4167
+ // the seeds.
4168
+ const askerUserId = typeof msg.user === "string" ? msg.user : "";
4169
+ const askerBotId = typeof msg.bot_id === "string" ? msg.bot_id : "";
4170
+ const broadcast = /<!(here|channel|everyone)(\^[^>]*)?(\|[^>]*)?>/.test(text);
4171
+ const audience = new Set<string>();
4172
+ for (const m of text.matchAll(/<@([UW][A-Z0-9]+)>/g)) {
4173
+ if (m[1] !== askerUserId) audience.add(m[1]!);
4174
+ }
4175
+ if (parsed.kind !== "other" && !audience.size && !broadcast) {
4176
+ // A 1:1 DM needs no tag — the other party is the only person it could be.
4177
+ const counterpart = await imCounterpart(token, t.channelId, cookie);
4178
+ if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
4179
+ }
4180
+ return { text, parsed, audience, broadcast, askerUserId, askerBotId };
4181
+ }
4182
+
4183
+ function askWaitCtxFrom(
4184
+ q: AskReadBack,
4185
+ parsed: Extract<AskReadBack["parsed"], { kind: "open" }>,
4186
+ loc: { channelId: string; ts: string; threadTs?: string | undefined },
4187
+ o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined },
4188
+ ): AskWaitCtx {
3973
4189
  const ctx: AskWaitCtx = {
3974
- channelId,
3975
- ts,
4190
+ channelId: loc.channelId,
4191
+ ts: loc.ts,
3976
4192
  question: parsed.question,
3977
4193
  reactable: parsed.reactable,
3978
4194
  overflow: parsed.overflow,
3979
4195
  threadOnly: parsed.threadOnly,
3980
4196
  lang: parsed.lang,
3981
- audience,
3982
- broadcast,
3983
- askerUserId,
3984
- askerBotId,
3985
- timeout: args.timeout,
3986
- shown: args.link,
3987
- asBot: args.asBot,
3988
- json: args.json,
4197
+ audience: q.audience,
4198
+ broadcast: q.broadcast,
4199
+ askerUserId: q.askerUserId,
4200
+ askerBotId: q.askerBotId,
4201
+ timeout: o.timeout,
4202
+ shown: o.link,
4203
+ asBot: o.asBot,
4204
+ json: o.json,
3989
4205
  };
3990
- if (threadTs) ctx.threadParentTs = threadTs;
3991
- if (args.cookie) ctx.cookie = args.cookie;
3992
- if (args.after) ctx.after = args.after;
3993
- await askWaitForAnswer(token, ctx);
4206
+ if (loc.threadTs) ctx.threadParentTs = loc.threadTs;
4207
+ if (o.cookie) ctx.cookie = o.cookie;
4208
+ if (o.after) ctx.after = o.after;
4209
+ return ctx;
3994
4210
  }
3995
4211
 
3996
4212
  // --- poll ---
@@ -5536,13 +5752,15 @@ async function main(): Promise<void> {
5536
5752
  .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." })
5537
5753
  .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." })
5538
5754
  .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." })
5755
+ .option("pending", { type: "boolean", default: false, describe: "List questions THIS machine asked (local sent-log) that somebody answered but nobody collected, grouped by the asking session, with whether that agent is still alive and the --waitFor line that collects each. Read-only: --since 7d, --channel, --json." })
5756
+ .option("since", { type: "string", default: "7d", describe: "With --pending: how far back to look (30m, 2h, 7d, or an ISO date)" })
5539
5757
  .option("stale", { type: "string", describe: "With --ls: only open questions older than this (24h, 3d)" })
5540
5758
  .option("state", { type: "string", default: "open", choices: ["open", "answered", "void", "edited", "gone", "unknown", "all"], describe: "With --ls: which state to list" })
5541
- .option("channel", { type: "string", describe: "With --ls: only this channel (id, or the target as typed)" })
5759
+ .option("channel", { type: "string", describe: "With --ls / --pending: only this channel (id, or the target as typed)" })
5542
5760
  .option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
5543
5761
  .option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
5544
5762
  .option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
5545
- .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." })
5763
+ .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." })
5546
5764
  .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
5547
5765
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
5548
5766
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
@@ -5554,7 +5772,11 @@ async function main(): Promise<void> {
5554
5772
  // slack ask collect <link> = --waitFor
5555
5773
  // Lifecycle flags (maintainer: "maybe slack ask --edit=<target>"), primary;
5556
5774
  // the bare verbs `void` / `collect` stay as aliases.
5557
- if (argv.ls) {
5775
+ if (argv.ls || argv.pending) {
5776
+ if (argv.ls && argv.pending) {
5777
+ console.error("Error: --ls and --pending are separate lists — pick one.");
5778
+ process.exit(ASK_EXIT_ERROR);
5779
+ }
5558
5780
  const user = (() => { try { return tok(argv as W); } catch { return undefined; } })();
5559
5781
  const tokens: { user?: string; bot?: string; cookie?: string } = {};
5560
5782
  if (user) tokens.user = user;
@@ -5562,6 +5784,26 @@ async function main(): Promise<void> {
5562
5784
  if (bot) tokens.bot = bot;
5563
5785
  const c = ck(argv as W);
5564
5786
  if (c) tokens.cookie = c;
5787
+ if (argv.pending) {
5788
+ if (argv.target || argv.question || argv.waitFor || argv.wait || argv.void || argv.edit) {
5789
+ console.error("Error: --pending reads the local log — do not also pass a target/question or another ask action.");
5790
+ process.exit(ASK_EXIT_ERROR);
5791
+ }
5792
+ const sinceMs = parseSince(String(argv.since));
5793
+ if (sinceMs === undefined) {
5794
+ console.error(`Error: --since needs 30m / 2h / 7d / 1w or an ISO date (got ${stripTerminalControls(String(argv.since))})`);
5795
+ process.exit(ASK_EXIT_ERROR);
5796
+ }
5797
+ const p: { sinceMs: number; json: boolean; channel?: string } = { sinceMs, json: !!argv.json };
5798
+ if (argv.channel) p.channel = String(argv.channel);
5799
+ try {
5800
+ await cmdAskPending(tokens, p);
5801
+ } catch (e: unknown) {
5802
+ console.error(friendlySlackError(e));
5803
+ process.exit(ASK_EXIT_ERROR);
5804
+ }
5805
+ return;
5806
+ }
5565
5807
  const a: { stale?: string; state: string; channel?: string; json: boolean; limit: number } = {
5566
5808
  state: String(argv.state ?? "open"), json: !!argv.json, limit: Number(argv.limit) || 50,
5567
5809
  };
@@ -6987,6 +7229,153 @@ async function main(): Promise<void> {
6987
7229
  )
6988
7230
  .command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
6989
7231
  )
7232
+ .command(
7233
+ "stream",
7234
+ "Print every new message matching --grep, across all channels the identity is in (threads included); resumable",
7235
+ (y) => y
7236
+ .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" })
7237
+ .option("ignore-case", { alias: "i", type: "boolean", default: false, describe: "Case-insensitive --grep" })
7238
+ .option("channel", { type: "array", string: true, describe: "Only these channels (#name, id or permalink); repeatable" })
7239
+ .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" })
7240
+ .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)" })
7241
+ .option("once", { type: "boolean", default: false, describe: "Scan once from the saved cursor (or --since) and exit: 0 = matches printed, 2 = none, 3 = failure" })
7242
+ .option("since", { type: "string", describe: "Replay from this long ago (e.g. 30m, 2h), ignoring the saved cursor" })
7243
+ .option("interval", { type: "string", default: "45s", describe: "Poll interval (e.g. 30s, 2m)" })
7244
+ .option("thread-window", { type: "string", default: "3d", describe: "Watch replies in threads whose parent is at most this old" })
7245
+ .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)" })
7246
+ .option("calls-per-min", { type: "number", default: 40, describe: "Polling: pace conversations.history/replies to this many calls per minute (0 = unpaced)" })
7247
+ .option("state", { type: "string", describe: "Cursor file (default: one per identity+grep+channels under $XDG_STATE_HOME/slack-term/stream/)" })
7248
+ .option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
7249
+ .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" })
7250
+ .option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" }),
7251
+ async (argv) => {
7252
+ let re: RegExp;
7253
+ let reconcileMs: number;
7254
+ let intervalMs: number;
7255
+ let windowSec: number;
7256
+ let threadScanSec: number;
7257
+ let sinceSec: number | undefined;
7258
+ try {
7259
+ re = new RegExp(argv.grep, argv["ignore-case"] ? "i" : "");
7260
+ intervalMs = parseDuration(argv.interval) * 1000;
7261
+ windowSec = parseDuration(argv["thread-window"]);
7262
+ threadScanSec = parseDuration(argv["thread-scan"]);
7263
+ sinceSec = argv.since !== undefined ? parseDuration(argv.since) : undefined;
7264
+ reconcileMs = parseDuration(argv.reconcile) * 1000;
7265
+ } catch (e) {
7266
+ console.error(`slack stream: ${e instanceof Error ? e.message : String(e)}`);
7267
+ process.exit(1);
7268
+ }
7269
+ // Privacy-safe default: the bot sees only the channels it was invited
7270
+ // to, never the user's DMs. The user identity is an explicit opt-in.
7271
+ const bot = resolveBotToken();
7272
+ const asUser = argv["as-user"] === true || !bot;
7273
+ const token = asUser ? tok(argv as W) : bot!;
7274
+ const cookie = asUser ? ck(argv as W) : undefined;
7275
+ if (!bot && argv["as-user"] !== true) {
7276
+ console.error("slack stream: no bot token (SLACK_BOT_TOKEN) — streaming as the USER identity, which includes its DMs.");
7277
+ }
7278
+ let me: Awaited<ReturnType<typeof authScopes>>;
7279
+ try {
7280
+ me = await authScopes(token, cookie);
7281
+ } catch (e) {
7282
+ console.error(`slack stream: fatal: ${e instanceof Error ? e.message : String(e)}`);
7283
+ process.exit(3);
7284
+ }
7285
+ // Self-echo exclusion is by sender id: the identity's own user id, its
7286
+ // bot id, and — when streaming as the user — the bot's posts too.
7287
+ const selfUsers = new Set([me.userId].filter(Boolean));
7288
+ const selfBots = new Set([me.botId].filter(Boolean));
7289
+ if (asUser && bot) {
7290
+ try {
7291
+ const b = await authScopes(bot);
7292
+ if (b.userId) selfUsers.add(b.userId);
7293
+ if (b.botId) selfBots.add(b.botId);
7294
+ } catch { /* a dead bot token posts nothing to exclude */ }
7295
+ }
7296
+ // Slack names a bot in a thread either way: `parent_user_id` holds its
7297
+ // bot USER id (auth.test user_id), `reply_users` sometimes its B… bot
7298
+ // id — so `self` stands for both.
7299
+ let repliesTo: Set<string> | undefined;
7300
+ if (argv["replies-to"]?.length) {
7301
+ repliesTo = new Set();
7302
+ for (const r of argv["replies-to"].map(String)) {
7303
+ if (r === "self") {
7304
+ for (const id of [me.userId, me.botId]) if (id) repliesTo.add(id);
7305
+ continue;
7306
+ }
7307
+ if (!/^[UWB][A-Z0-9]{2,}$/.test(r)) {
7308
+ console.error(`slack stream: --replies-to: "${r}" is not a user or bot id (U…/W…/B…) or "self"`);
7309
+ process.exit(1);
7310
+ }
7311
+ repliesTo.add(r);
7312
+ // A bot can be listed under its user id too: add that alias.
7313
+ if (r.startsWith("B")) {
7314
+ try {
7315
+ const { userId } = await botsInfo(token, r, cookie);
7316
+ if (userId) repliesTo.add(userId);
7317
+ } catch (e) {
7318
+ 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`);
7319
+ }
7320
+ }
7321
+ }
7322
+ }
7323
+ let channels: string[] | undefined;
7324
+ if (argv.channel?.length) {
7325
+ try {
7326
+ channels = [];
7327
+ for (const c of argv.channel) channels.push(await resolveChannel(token, String(c), cookie));
7328
+ } catch (e) {
7329
+ console.error(`slack stream: --channel: ${e instanceof Error ? e.message : String(e)}`);
7330
+ process.exit(1);
7331
+ }
7332
+ }
7333
+ const key = createHash("sha256")
7334
+ .update(JSON.stringify([me.url, me.userId, re.source, re.flags, [...(channels ?? [])].sort(),
7335
+ // As given, not as resolved: a failed bots.info lookup must not
7336
+ // move the stream to a fresh state file.
7337
+ ...(argv["replies-to"]?.length ? [[...argv["replies-to"].map(String)].sort()] : [])]))
7338
+ .digest("hex").slice(0, 16);
7339
+ const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
7340
+ const statePath = argv.state ?? join(stateHome, "slack-term", "stream", `${key}.json`);
7341
+ console.error(`slack stream: as ${asUser ? "user" : "bot"} @${me.user} (${me.userId}); state ${statePath}`);
7342
+ // The relay rings for what the Slack app's bot can see, so it only
7343
+ // stands in for polling when streaming as that bot.
7344
+ const relayUrl = process.env.SLACK_RELAY_URL;
7345
+ const relayToken = process.env.SLACK_RELAY_TOKEN;
7346
+ const relay = argv.relay && !asUser && !argv.once && relayUrl && relayToken
7347
+ ? { url: relayUrl, subscribe: relaySubscriber(relayUrl, relayToken), reconcileMs }
7348
+ : undefined;
7349
+ if (argv.relay && asUser && relayUrl) console.error("slack stream: relay not used with --as-user (it only sees the bot's channels)");
7350
+ const pollReason = !argv.relay ? "--no-relay"
7351
+ : asUser ? (relayUrl ? "--as-user: the relay only rings for the bot's channels" : "streaming as the user; the relay serves the bot identity")
7352
+ : "SLACK_RELAY_URL / SLACK_RELAY_TOKEN not set";
7353
+ const ac = new AbortController();
7354
+ process.on("SIGINT", () => ac.abort());
7355
+ process.on("SIGTERM", () => ac.abort());
7356
+ const code = await runStream(webClient(token, cookie), {
7357
+ grep: re,
7358
+ ...(repliesTo ? { repliesTo } : {}),
7359
+ ...(channels ? { channels } : {}),
7360
+ selfUsers,
7361
+ selfBots,
7362
+ json: argv.json,
7363
+ once: argv.once,
7364
+ ...(sinceSec !== undefined ? { sinceSec } : {}),
7365
+ intervalMs,
7366
+ threadWindowSec: windowSec,
7367
+ threadScanSec,
7368
+ callsPerMin: argv["calls-per-min"],
7369
+ ...(relay ? {} : { pollReason }),
7370
+ statePath,
7371
+ teamUrl: me.url,
7372
+ identity: me.userId,
7373
+ signal: ac.signal,
7374
+ ...(relay ? { relay } : {}),
7375
+ });
7376
+ process.exit(code);
7377
+ },
7378
+ )
6990
7379
  .command(
6991
7380
  "tail [target]",
6992
7381
  "Stream new messages in real time (like tail -f)",