slack-term 1.41.1 → 1.42.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
@@ -54,7 +54,7 @@ import {
54
54
  } from "./poll.ts";
55
55
  import { seedReactionsInOrder } from "./reactionSeed.ts";
56
56
  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";
57
+ 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
58
  import {
59
59
  authTest,
60
60
  authScopes,
@@ -131,7 +131,7 @@ import { setCacheEnabled } from "./cache.ts";
131
131
  import { escapeArg, unescapeArg } from "./escapes.ts";
132
132
  import { attachmentLines, parseAttachments } from "./attachments.ts";
133
133
  import { botIntentRefusal } from "./botIntent.ts";
134
- import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, maskSecrets, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
134
+ import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, maskSecrets, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult, type UnresolvedMention } from "./format.ts";
135
135
  import { quietHoursNotice } from "./quietHours.ts";
136
136
  import { installSyncConsole } from "./stdout.ts";
137
137
  import { LockBusyError, PINLOG_MARKER, acquireLock, composeHead, formatJst, headUpdatedAt, isPinlogHead, parsePinlogId, pinlogId, stripPinlogFooter } from "./pinlog.ts";
@@ -2525,6 +2525,22 @@ const ASK_ANSWERABLE_SUBTYPES = new Set(["thread_broadcast"]);
2525
2525
  // ends it (`\b` is unreliable outside ASCII).
2526
2526
  const ASK_BROADCAST_RE = /(?<![A-Za-z0-9._@<-])@(here|channel|everyone)(?![A-Za-z0-9._-])/giu;
2527
2527
 
2528
+ /** `ask` cannot degrade an unresolved @tag to plain text the way `send` does:
2529
+ * the tags ARE who may answer, so a tag that named nobody would silently drop
2530
+ * an intended answerer (or, in a 1:1 DM, hand the decision to the counterpart
2531
+ * the caller meant to bypass). Refuse instead, before anything is posted. */
2532
+ function askRefuseUnresolved(unresolved: UnresolvedMention[]): void {
2533
+ if (!unresolved.length) return;
2534
+ const reasons = unresolved.map((u) => {
2535
+ const tag = stripTerminalControls(u.surface);
2536
+ if (u.reason === "unavailable") return `${tag}: could not fetch the user list or channel members (users:read missing or an API/connection error)`;
2537
+ if (u.reason === "ambiguous") return `${tag}: matches multiple people — use an explicit <@USERID> tag`;
2538
+ return `${tag}: matched no one`;
2539
+ });
2540
+ console.error(`Error: \`ask\` will not post with unresolved answerer tags.\n ${reasons.join("\n ")}`);
2541
+ process.exit(ASK_EXIT_ERROR);
2542
+ }
2543
+
2528
2544
  /** Rewrite `@here`/`@channel`/`@everyone` to the `<!here>` form Slack actually
2529
2545
  * broadcasts on, and report which were used. Run BEFORE user-mention encoding
2530
2546
  * so those tokens are never offered to the directory as if they were handles. */
@@ -2724,12 +2740,33 @@ async function askEmit(
2724
2740
  }
2725
2741
 
2726
2742
 
2727
- /** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
2728
- * on timeout. A timeout of 0 means "look exactly once", so a caller can poll
2729
- * cheaply on its own schedule instead of parking a process on `--wait`. */
2730
- async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never> {
2731
- const { channelId, ts, question, reactable, threadOnly, audience, broadcast, cookie, timeout, shown } = ctx;
2743
+ /** What one look at a question found besides the answer — read back by the
2744
+ * `--wait` loop between polls, and by `ask --pending` after its single look. */
2745
+ interface AskPollState {
2746
+ /** The earliest audience reply that picked none of the choices. */
2747
+ unchosen?: { text: string; who: string; ts: string; ambiguous: boolean };
2748
+ /** Set when the question has been voided. */
2749
+ voided?: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }>;
2750
+ /** The question's `reply_count` as last seen — undefined until seen. */
2751
+ replyCount?: number;
2752
+ /** The question's text as last read — what the ✅ rewrite keeps the body of. */
2753
+ ownText?: string;
2754
+ }
2755
+
2756
+ /** The polling half of `--wait`: one `poll()` is one look at the question,
2757
+ * shared with `ask --pending`. `readOnly` drops the one WRITE a look can make
2758
+ * (the invalid-ballot notice edited into the question) and the progress lines
2759
+ * on stderr — `--pending` inspects questions other sessions are responsible
2760
+ * for, and must neither touch them nor narrate their state as if waiting. */
2761
+ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
2762
+ poll: () => Promise<AskFound | null>;
2763
+ state: AskPollState;
2764
+ isAnswerer: (user: unknown) => user is string;
2765
+ candidates: string[];
2766
+ } {
2767
+ const { channelId, ts, reactable, threadOnly, audience, broadcast, cookie } = ctx;
2732
2768
  const copy = ASK_COPY[ctx.lang];
2769
+ const state: AskPollState = {};
2733
2770
  const askerUserId = ctx.askerUserId;
2734
2771
  const askerBotId = ctx.askerBotId;
2735
2772
 
@@ -2740,25 +2777,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2740
2777
  const isAnswerer = (user: unknown): user is string =>
2741
2778
  typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
2742
2779
 
2743
- /** The question's `reply_count` as last seen — whether its thread is worth a
2744
- * fetch when collecting notes. Undefined until the question itself is seen. */
2745
- let replyCount: number | undefined;
2746
- /** The question's text as last read — what the ✅ rewrite keeps the body of. */
2747
- let ownText: string | undefined;
2780
+ /** The question as last seen: its `reply_count` (whether its thread is worth
2781
+ * a fetch when collecting notes) and its text (what the ✅ rewrite keeps). */
2782
+ const seen = (own: Record<string, Json>): void => {
2783
+ state.replyCount = Number(own.reply_count) || 0;
2784
+ if (typeof own.text === "string") state.ownText = own.text;
2785
+ };
2748
2786
  /** Set when the question is voided while we wait — the wait ends there. */
2749
- let voided: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }> | undefined;
2750
2787
  const checkVoid = (own: Record<string, Json>): boolean => {
2751
2788
  const p = typeof own.text === "string" ? askParseMessage(own.text) : undefined;
2752
- if (p?.kind === "void") voided = p;
2753
- return !!voided;
2789
+ if (p?.kind === "void") state.voided = p;
2790
+ return !!state.voided;
2754
2791
  };
2755
2792
 
2756
- /** The earliest reply from an answerer that picked none of the choices. Kept
2757
- * across polls so the timeout can report it, and so the operator is told once
2758
- * rather than on every tick. */
2759
- let unchosen: { text: string; who: string; ts: string; ambiguous: boolean } | undefined;
2760
- let unchosenReported = "";
2761
-
2762
2793
  /** Every seed an answerer is on — not just the first. Changing your mind
2763
2794
  * leaves BOTH reactions in place (Slack only drops one when you actively
2764
2795
  * un-react), so taking the lowest index would answer with the abandoned
@@ -2784,6 +2815,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2784
2815
  * the API call when nothing changed, which is what makes this safe to run on
2785
2816
  * every poll tick rather than once per process. */
2786
2817
  async function noteInvalid(msg: Record<string, Json>, offenders: string[]): Promise<void> {
2818
+ if (readOnly) return;
2787
2819
  const text = typeof msg.text === "string" ? msg.text : "";
2788
2820
  if (!text) return;
2789
2821
  const next = applyInvalidNotice(text, offenders, ctx.lang);
@@ -2806,7 +2838,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2806
2838
  for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
2807
2839
  if (otherReported.has(u)) continue;
2808
2840
  otherReported.add(u);
2809
- console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
2841
+ if (!readOnly) console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
2810
2842
  }
2811
2843
  }
2812
2844
 
@@ -2829,7 +2861,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2829
2861
  const pillsPerUser = new Map<string, number>();
2830
2862
  for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
2831
2863
  const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
2832
- console.error(multi.length
2864
+ if (!readOnly) console.error(multi.length
2833
2865
  ? ` (${glyphs} が同時に選ばれています。1 つに絞ってもらうまで待ちます)`
2834
2866
  : ` (回答が分かれています: ${glyphs}。どちらか一方にそろうまで待ちます)`);
2835
2867
  // Whoever is on more than one pill — and ONLY them. Naming everyone on any
@@ -2879,9 +2911,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2879
2911
  // must not hide it behind the refuse-to-guess path. Within a class, the
2880
2912
  // earliest wins.
2881
2913
  const ambiguous = match.kind === "ambiguous";
2914
+ const unchosen = state.unchosen;
2882
2915
  if (!unchosen || (unchosen.ambiguous && !ambiguous) ||
2883
2916
  (unchosen.ambiguous === ambiguous && Number(m.ts) < Number(unchosen.ts))) {
2884
- unchosen = { text: t, who: typeof m.user === "string" ? m.user : "", ts: String(m.ts), ambiguous };
2917
+ state.unchosen = { text: t, who: typeof m.user === "string" ? m.user : "", ts: String(m.ts), ambiguous };
2885
2918
  }
2886
2919
  }
2887
2920
  return null;
@@ -2894,7 +2927,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2894
2927
  // longer there, and exit 4 where exit 2 is the truth. `unchosenReported`
2895
2928
  // persists on purpose — it is what keeps the operator from being told the
2896
2929
  // same thing on every tick.
2897
- unchosen = undefined;
2930
+ delete state.unchosen;
2898
2931
  // A question that is itself a thread reply is invisible to
2899
2932
  // `conversations.history`, so the entire poll — reactions included — has to
2900
2933
  // come from the thread.
@@ -2903,8 +2936,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2903
2936
  const messages = asArray(rep.messages).map(asRecord);
2904
2937
  const own = messages.find((m) => m.ts === ts);
2905
2938
  if (own) {
2906
- replyCount = Number(own.reply_count) || 0;
2907
- if (typeof own.text === "string") ownText = own.text;
2939
+ seen(own);
2908
2940
  if (checkVoid(own)) return null;
2909
2941
  const byReaction = await answerFromReactions(own);
2910
2942
  if (byReaction) return byReaction;
@@ -2920,8 +2952,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2920
2952
  const own = messages.find((m) => m.ts === ts);
2921
2953
 
2922
2954
  if (own) {
2923
- replyCount = Number(own.reply_count) || 0;
2924
- if (typeof own.text === "string") ownText = own.text;
2955
+ seen(own);
2925
2956
  if (checkVoid(own)) return null;
2926
2957
  }
2927
2958
  // A reaction is the intended path, so it wins when both are present.
@@ -2954,6 +2985,21 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2954
2985
  return null;
2955
2986
  }
2956
2987
 
2988
+ return { poll, state, isAnswerer, candidates };
2989
+ }
2990
+
2991
+ /** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
2992
+ * on timeout. A timeout of 0 means "look exactly once", so a caller can poll
2993
+ * cheaply on its own schedule instead of parking a process on `--wait`. */
2994
+ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never> {
2995
+ const { channelId, ts, question, reactable, threadOnly, cookie, timeout, shown } = ctx;
2996
+ const askerUserId = ctx.askerUserId;
2997
+ const askerBotId = ctx.askerBotId;
2998
+ const { poll, state, isAnswerer, candidates } = askPoller(token, ctx);
2999
+ /** Kept across polls so the operator is told about one reply once, rather
3000
+ * than on every tick. */
3001
+ let unchosenReported = "";
3002
+
2957
3003
  /** Stamp the question resolved. Removing our seeds clears the pills that still
2958
3004
  * invite an answer; the answerer's own reaction cannot be removed by us and
2959
3005
  * stays put — which is exactly what leaves the chosen number visible. */
@@ -2980,7 +3026,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2980
3026
  // The body and the chosen option stay; only what no longer applies goes.
2981
3027
  // Built from the message as it is NOW (an edit made in Slack after asking
2982
3028
  // is kept). Not seen this run — the bare form, as before.
2983
- const keep = ownText ? askResolvedKeep(ownText, found.choice) : undefined;
3029
+ const keep = state.ownText ? askResolvedKeep(state.ownText, found.choice) : undefined;
2984
3030
  await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
2985
3031
  // Swap the marker for the resolved one so search reflects reality:
2986
3032
  // `has::question:` should list what still needs answering, not everything
@@ -3003,6 +3049,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3003
3049
  const deadline = Date.now() + timeout * 1000;
3004
3050
  let errors = 0;
3005
3051
  let delay = ASK_POLL_MIN_MS;
3052
+ let unchosen: AskPollState["unchosen"];
3006
3053
  for (;;) {
3007
3054
  let found: AskFound | null = null;
3008
3055
  try {
@@ -3017,6 +3064,8 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3017
3064
  }
3018
3065
  console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
3019
3066
  }
3067
+ unchosen = state.unchosen;
3068
+ const voided = state.voided;
3020
3069
  if (voided) {
3021
3070
  askReportVoid(voided, shown);
3022
3071
  if (ctx.json) {
@@ -3034,7 +3083,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3034
3083
  // BEFORE ✅ is written, and delivered right after — ✅ is what tells a
3035
3084
  // later --waitFor the question is settled.
3036
3085
  const notes = await askCollectNotes(token, {
3037
- channelId, ts, threadOnly, replyCount, isAnswerer, askerUserId, askerBotId,
3086
+ channelId, ts, threadOnly, replyCount: state.replyCount, isAnswerer, askerUserId, askerBotId,
3038
3087
  ...(ctx.threadParentTs ? { threadParentTs: ctx.threadParentTs } : {}),
3039
3088
  ...(cookie ? { cookie } : {}),
3040
3089
  }, ctx.after, (m) => m.ts === found!.ts);
@@ -3043,6 +3092,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3043
3092
  status: "answered", answer: found.answer, how: found.how, notes,
3044
3093
  cursor: askNextCursor(ts, ctx.after, found.ts, ...notes.map((n) => n.ts)),
3045
3094
  };
3095
+ // Collected: the local sent-log row (if this machine asked it) drops off
3096
+ // `ask --pending`. Only here, where the answer actually reaches a caller.
3097
+ markAskCollected(channelId, ts, found.answer, r.cursor);
3046
3098
  if (found.who) r.who = found.who;
3047
3099
  await askEmit(token, ctx, r);
3048
3100
  process.exit(0);
@@ -3063,6 +3115,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3063
3115
  console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
3064
3116
  console.error(` ${shown}`);
3065
3117
  console.error(` この返信の後を待つ: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
3118
+ // Delivered, not collected: `--pending` will not report THIS reply again,
3119
+ // but still reports a pill pressed after it.
3120
+ markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
3066
3121
  if (ctx.json) {
3067
3122
  await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
3068
3123
  } else {
@@ -3181,9 +3236,9 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3181
3236
  const body = bRep.text;
3182
3237
  const resolved = [...qRep.resolved, ...bRep.resolved];
3183
3238
  const unresolved = [...qRep.unresolved, ...bRep.unresolved];
3184
- // An unresolved tag is not just cosmetic here: it names nobody, so it grants
3185
- // nobody the right to answer. Say so before the rejection below.
3186
- for (const line of mentionWarnings(unresolved)) console.error(`⚠ ${stripTerminalControls(line)}`);
3239
+ // An unresolved tag names nobody, so it grants nobody the right to answer —
3240
+ // and posting anyway would silently drop someone the caller meant to ask.
3241
+ askRefuseUnresolved(unresolved);
3187
3242
 
3188
3243
  const broadcast = broadcastKinds.size > 0;
3189
3244
  // Tagging yourself grants nothing — your own reactions are the seeds.
@@ -3211,9 +3266,6 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3211
3266
  if (!audience.size) {
3212
3267
  console.error(
3213
3268
  `Error: \`ask\` will not post a question nobody is addressed to — no one is tagged.\n` +
3214
- (unresolved.length
3215
- ? ` (${unresolved.map((u) => stripTerminalControls(u.surface)).join(", ")} matched no one, so it names nobody.)\n`
3216
- : "") +
3217
3269
  ` Tag the person who should answer: slack ask '${args.target}' '@alice ${args.question}' ...\n` +
3218
3270
  ` Or open it to the whole channel: slack ask '${args.target}' '@here ${args.question}' ...\n` +
3219
3271
  ` (In a 1:1 DM the other person counts automatically.)`,
@@ -3421,6 +3473,16 @@ async function askFetchByLink(token: string, link: string, cookie?: string): Pro
3421
3473
  channelId = m[1]!;
3422
3474
  ts = m[2]!;
3423
3475
  }
3476
+ return askFetchAt(token, { channelId, ts, threadTs }, cookie);
3477
+ }
3478
+
3479
+ /** `askFetchByLink` for a location already known — e.g. a sent-log row, whose
3480
+ * `thread_ts` says where a question posted into a thread lives. */
3481
+ async function askFetchAt(token: string, loc: { channelId: string; ts: string; threadTs?: string | undefined }, cookie?: string): Promise<
3482
+ { channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> }
3483
+ > {
3484
+ const { channelId, ts } = loc;
3485
+ const threadTs = loc.threadTs && loc.threadTs !== loc.ts ? loc.threadTs : undefined;
3424
3486
  let msg: Record<string, Json> | undefined;
3425
3487
  if (threadTs) {
3426
3488
  const rep = asRecord((await replies(token, channelId, threadTs, 100, cookie)) as Json);
@@ -3505,7 +3567,9 @@ async function cmdAskEdit(token: string, args: {
3505
3567
  const old = t.parsed;
3506
3568
  const encode = async (raw: string): Promise<string> => {
3507
3569
  const enc = askEncodeBroadcasts(unescapeArg(raw));
3508
- return (await encodeMentionsDetailed(args.mentionToken, enc.text, t.channelId, args.mentionCookie)).text;
3570
+ const rep = await encodeMentionsDetailed(args.mentionToken, enc.text, t.channelId, args.mentionCookie);
3571
+ askRefuseUnresolved(rep.unresolved);
3572
+ return rep.text;
3509
3573
  };
3510
3574
  const question = args.question !== undefined ? await encode(args.question) : old.question;
3511
3575
  const body = args.body !== undefined ? await encode(args.body) : old.body;
@@ -3620,8 +3684,8 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
3620
3684
  let answer: string | undefined;
3621
3685
  if (token) {
3622
3686
  try {
3623
- const got = await askFetchByLink(token, `${r.channel}:${r.ts}`, r.as_bot ? undefined : tokens.cookie);
3624
- if (!got?.msg) state = "gone";
3687
+ const got = await askFetchAt(token, { channelId: r.channel, ts: r.ts, threadTs: r.thread_ts ?? undefined }, r.as_bot ? undefined : tokens.cookie);
3688
+ if (!got.msg) state = "gone";
3625
3689
  else {
3626
3690
  const p = askParseMessage(typeof got.msg.text === "string" ? got.msg.text : "");
3627
3691
  state = p.kind === "resolved" ? "answered" : p.kind === "other" ? "edited" : p.kind;
@@ -3659,6 +3723,150 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
3659
3723
  }
3660
3724
  }
3661
3725
 
3726
+ /** One answered-but-uncollected ask, as `--pending` reports it. */
3727
+ interface AskPendingItem {
3728
+ row: SentRow;
3729
+ link: string;
3730
+ question: string;
3731
+ answer: string;
3732
+ how: string;
3733
+ /** A free-text reply that picked none of the choices — not a decision. */
3734
+ freeText: boolean;
3735
+ alive: boolean;
3736
+ /** The ready-to-run `--waitFor` line, cursor included. */
3737
+ collect: string;
3738
+ }
3739
+
3740
+ /** `slack ask --pending [--since 7d] [--json]` — questions THIS machine asked
3741
+ * that somebody answered and nobody collected, grouped by the asking session,
3742
+ * with whether that agent is still alive.
3743
+ *
3744
+ * Why it exists: of 71 asks over 8 days, 13 had a pill pressed that nobody
3745
+ * ever read back (2026-09-27). The answer was sitting in Slack; the agent that
3746
+ * asked had moved on or died, and nothing connected the two.
3747
+ *
3748
+ * Read-only, against Slack AND the local log: each open question gets one look
3749
+ * through the same poller `--wait` uses, with its one write disabled. It only
3750
+ * REPORTS — collecting is `slack ask --waitFor=<link>`, printed per item,
3751
+ * which is also what stamps ✅ and takes the ask off this list. Nothing is
3752
+ * relayed into another process: whoever reads the list (the asking agent, or
3753
+ * a person) runs the collect line. */
3754
+ async function cmdAskPending(
3755
+ tokens: { user?: string; bot?: string; cookie?: string },
3756
+ opts: { sinceMs: number; json: boolean; channel?: string },
3757
+ ): Promise<void> {
3758
+ const all = uncollectedAsks(opts.sinceMs);
3759
+ if (!all) {
3760
+ console.error(`Error: ${SENT_LOG_UNAVAILABLE}\n (${sentDbPath()})`);
3761
+ process.exit(ASK_EXIT_ERROR);
3762
+ }
3763
+ const rows = opts.channel ? all.filter((r) => r.channel === opts.channel || r.target === opts.channel) : all;
3764
+ const pending: AskPendingItem[] = [];
3765
+ const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0 };
3766
+ const problems: string[] = [];
3767
+ // The log is machine-wide, but a token reads one workspace. Asks recorded
3768
+ // under another workspace are counted and named, not probed with the wrong
3769
+ // token (which would only fail as channel_not_found).
3770
+ const currentTeam = tokens.user
3771
+ ? (await selfIdentity(tokens.user, tokens.cookie))?.team ?? ""
3772
+ : tokens.bot ? (await selfIdentity(tokens.bot))?.team ?? "" : "";
3773
+ const otherTeams = new Map<string, number>();
3774
+
3775
+ for (const row of rows) {
3776
+ if (row.team && currentTeam && row.team !== currentTeam) {
3777
+ otherTeams.set(row.team, (otherTeams.get(row.team) ?? 0) + 1);
3778
+ continue;
3779
+ }
3780
+ const link = row.permalink || `${row.channel}:${row.ts}`;
3781
+ const asBot = !!row.as_bot;
3782
+ const token = asBot ? tokens.bot : tokens.user;
3783
+ if (!token) {
3784
+ problems.push(`${link}: ${asBot ? "--as-bot で聞いた質問ですが bot token がありません" : "user token がありません"}`);
3785
+ continue;
3786
+ }
3787
+ const cookie = asBot ? undefined : tokens.cookie;
3788
+ // Resume from where the last delivery to a caller ended (an exit 5
3789
+ // free-text reply), so that reply is not reported again — and the collect
3790
+ // line carries the same cursor, so `--waitFor` does not re-deliver it.
3791
+ const after = row.delivered_cursor ?? undefined;
3792
+ try {
3793
+ const loc = { channelId: row.channel, ts: row.ts, threadTs: row.thread_ts ?? undefined };
3794
+ const got = await askFetchAt(token, loc, cookie);
3795
+ if (!got.msg) { problems.push(`${link}: メッセージが見つかりません (削除?)`); continue; }
3796
+ const q = await askReadBack(token, { channelId: row.channel, ts: row.ts, msg: got.msg }, cookie);
3797
+ const parsed = q.parsed;
3798
+ // ✅ already stamped: some `--wait`/`--waitFor` collected it (another
3799
+ // machine, another token, or a build before collection was logged).
3800
+ if (parsed.kind === "resolved") { count.resolved++; continue; }
3801
+ if (parsed.kind === "void") { count.void++; continue; }
3802
+ if (parsed.kind === "other") { problems.push(`${link}: \`slack ask\` の質問として読めません — ${askExplainReject(q.text)}`); continue; }
3803
+ if (!q.audience.size && !q.broadcast) { problems.push(`${link}: 誰にも宛てられていない質問です`); continue; }
3804
+ const ctx = askWaitCtxFrom(q, parsed, loc, { timeout: 0, link, asBot, json: false, cookie, after });
3805
+ const poller = askPoller(token, ctx, true);
3806
+ const found = await poller.poll();
3807
+ const unchosen = poller.state.unchosen;
3808
+ if (poller.state.voided) { count.void++; continue; }
3809
+ const collect = askResumeCommand(link, asBot, after);
3810
+ const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
3811
+ if (found) {
3812
+ pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
3813
+ } else if (unchosen && !unchosen.ambiguous && unchosen.text !== row.delivered_answer) {
3814
+ pending.push({ ...base, answer: unchosen.text, how: "返信 (選択肢外)", freeText: true });
3815
+ } else if (unchosen?.ambiguous) {
3816
+ count.ambiguous++;
3817
+ } else {
3818
+ count.unanswered++;
3819
+ }
3820
+ } catch (e: unknown) {
3821
+ problems.push(`${link}: ${e instanceof Error ? e.message : String(e)}`);
3822
+ }
3823
+ }
3824
+
3825
+ if (opts.json) {
3826
+ for (const it of pending) {
3827
+ const r = it.row;
3828
+ console.log(JSON.stringify({
3829
+ id: r.id, link: it.link, channel: r.channel, ts: r.ts, asked_at: r.sent_at,
3830
+ question: it.question, answer: it.answer, how: it.how, free_text: it.freeText,
3831
+ 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 },
3832
+ delivered_at: r.delivered_at ?? null,
3833
+ collect: it.collect,
3834
+ }));
3835
+ }
3836
+ } else {
3837
+ // Grouped by the session that asked — the unit that has to be told.
3838
+ const groups = new Map<string, AskPendingItem[]>();
3839
+ for (const it of pending) {
3840
+ const key = it.row.session_id ? `session ${it.row.session_id}` : `pid ${it.row.agent_pid ?? it.row.pid}`;
3841
+ groups.set(key, [...(groups.get(key) ?? []), it]);
3842
+ }
3843
+ for (const [key, items] of groups) {
3844
+ const r = items[0]!.row;
3845
+ console.log(
3846
+ `${stripTerminalControls(key)} ${stripTerminalControls(r.cli ?? "?")} pid ${r.agent_pid ?? r.pid} ${items[0]!.alive ? "(alive)" : "(gone)"}` +
3847
+ `${r.git_branch ? ` branch=${stripTerminalControls(r.git_branch)}` : ""} cwd=${stripTerminalControls(r.cwd)}`,
3848
+ );
3849
+ for (const it of items) {
3850
+ console.log(` ${stripTerminalControls(it.link)} (${formatYmdHm(it.row.sent_at / 1000)})`);
3851
+ console.log(` Q: ${stripTerminalControls(askFlatten(it.question))}`);
3852
+ console.log(` A: ${stripTerminalControls(askFlatten(it.answer))} — ${stripTerminalControls(it.how)}${it.freeText ? " — 決定ではありません" : ""}`);
3853
+ console.log(` collect: ${stripTerminalControls(it.collect)}`);
3854
+ }
3855
+ }
3856
+ }
3857
+ for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
3858
+ for (const [team, n] of otherTeams) {
3859
+ console.error(` (${n} 件は別のワークスペース「${stripTerminalControls(team)}」の質問です — slack -w <name> ask --pending で確認)`);
3860
+ }
3861
+ console.error(
3862
+ `${pending.length} 件が回答済み・未回収, ${count.unanswered} 件が未回答` +
3863
+ (count.ambiguous ? `, ${count.ambiguous} 件は返信が複数の選択肢に一致 (未確定)` : "") +
3864
+ (count.resolved ? `, ${count.resolved} 件は ✅ 回収済み` : "") +
3865
+ (count.void ? `, ${count.void} 件は無効化済み` : "") +
3866
+ ` — ${rows.length} 件の未回収の質問を確認 (log: ${sentDbPath()})`,
3867
+ );
3868
+ }
3869
+
3662
3870
  /** A question about to be voided: where it is, and what it parsed as. */
3663
3871
  interface AskVoidTarget {
3664
3872
  link: string;
@@ -3843,23 +4051,14 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3843
4051
  ts = m[2]!;
3844
4052
  }
3845
4053
 
3846
- // Fetch the one message. A question posted into a thread is not in history at
3847
- // all, so where the permalink says it is a reply, read it from the thread.
3848
- let msg: Record<string, Json> | undefined;
3849
- if (threadTs) {
3850
- const rep = asRecord((await replies(token, channelId, threadTs, 100, args.cookie)) as Json);
3851
- msg = asArray(rep.messages).map(asRecord).find((m) => m.ts === ts);
3852
- } else {
3853
- const hist = asRecord((await history(token, channelId, 1, ts, undefined, args.cookie, true)) as Json);
3854
- msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
3855
- }
3856
- if (!msg) {
4054
+ const got = await askFetchAt(token, { channelId, ts, threadTs }, args.cookie);
4055
+ if (!got.msg) {
3857
4056
  console.error(`Error: そのメッセージが見つかりません: ${stripTerminalControls(args.link)}`);
3858
4057
  process.exit(ASK_EXIT_ERROR);
3859
4058
  }
3860
-
3861
- const text = typeof msg.text === "string" ? msg.text : "";
3862
- const parsed = askParseMessage(text);
4059
+ const msg = got.msg;
4060
+ const q = await askReadBack(token, { channelId, ts, msg }, args.cookie);
4061
+ const { text, parsed, audience, broadcast, askerUserId, askerBotId } = q;
3863
4062
  if (parsed.kind === "other") {
3864
4063
  console.error(
3865
4064
  `Error: これは \`slack ask\` の質問として読み取れません。\n` +
@@ -3872,23 +4071,6 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3872
4071
  process.exit(ASK_EXIT_ERROR);
3873
4072
  }
3874
4073
 
3875
- // Who may answer, recovered from the tags the readers can see. They are
3876
- // already `<@U…>`-encoded in the stored text, so no directory lookup is
3877
- // needed — and the asker is excluded here for the same reason as when
3878
- // posting: their tag of themselves grants nothing, and their reactions are
3879
- // the seeds.
3880
- const askerUserId = typeof msg.user === "string" ? msg.user : "";
3881
- const askerBotId = typeof msg.bot_id === "string" ? msg.bot_id : "";
3882
- const broadcast = /<!(here|channel|everyone)(\^[^>]*)?(\|[^>]*)?>/.test(text);
3883
- const audience = new Set<string>();
3884
- for (const m of text.matchAll(/<@([UW][A-Z0-9]+)>/g)) {
3885
- if (m[1] !== askerUserId) audience.add(m[1]!);
3886
- }
3887
- if (!audience.size && !broadcast) {
3888
- // A 1:1 DM needs no tag — the other party is the only person it could be.
3889
- const counterpart = await imCounterpart(token, channelId, args.cookie);
3890
- if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
3891
- }
3892
4074
  if (parsed.kind === "void") {
3893
4075
  askReportVoid(parsed, args.link);
3894
4076
  if (args.json) {
@@ -3940,10 +4122,11 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3940
4122
  ...(args.cookie ? { cookie: args.cookie } : {}),
3941
4123
  }, after, isAnswerReply);
3942
4124
  }
4125
+ const cursor = askNextCursor(ts, after, ...notes.map((n) => n.ts));
3943
4126
  await askEmit(token, { json: args.json, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
3944
- status: "answered", answer: parsed.answer, notes,
3945
- cursor: askNextCursor(ts, after, ...notes.map((n) => n.ts)),
4127
+ status: "answered", answer: parsed.answer, notes, cursor,
3946
4128
  });
4129
+ markAskCollected(channelId, ts, parsed.answer, cursor);
3947
4130
  return;
3948
4131
  }
3949
4132
 
@@ -3955,27 +4138,72 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
3955
4138
  if (args.timeout > 0) {
3956
4139
  console.error(` 回答を待っています (最大 ${args.timeout}s)… Ctrl-C で中断`);
3957
4140
  }
4141
+ await askWaitForAnswer(token, askWaitCtxFrom(q, parsed, { channelId, ts, threadTs }, args));
4142
+ }
4143
+
4144
+ /** A posted question read back: what it parsed as, and who may answer it. */
4145
+ interface AskReadBack {
4146
+ text: string;
4147
+ parsed: ReturnType<typeof askParseMessage>;
4148
+ audience: Set<string>;
4149
+ broadcast: boolean;
4150
+ askerUserId: string;
4151
+ askerBotId: string;
4152
+ }
4153
+
4154
+ /** Recover who may answer from the message alone. Shared by `--waitFor` and
4155
+ * `--pending` — nothing about a question is stored, so both read it back. */
4156
+ async function askReadBack(token: string, t: { channelId: string; ts: string; msg: Record<string, Json> }, cookie?: string): Promise<AskReadBack> {
4157
+ const { msg } = t;
4158
+ const text = typeof msg.text === "string" ? msg.text : "";
4159
+ const parsed = askParseMessage(text);
4160
+ // Who may answer, recovered from the tags the readers can see. They are
4161
+ // already `<@U…>`-encoded in the stored text, so no directory lookup is
4162
+ // needed — and the asker is excluded here for the same reason as when
4163
+ // posting: their tag of themselves grants nothing, and their reactions are
4164
+ // the seeds.
4165
+ const askerUserId = typeof msg.user === "string" ? msg.user : "";
4166
+ const askerBotId = typeof msg.bot_id === "string" ? msg.bot_id : "";
4167
+ const broadcast = /<!(here|channel|everyone)(\^[^>]*)?(\|[^>]*)?>/.test(text);
4168
+ const audience = new Set<string>();
4169
+ for (const m of text.matchAll(/<@([UW][A-Z0-9]+)>/g)) {
4170
+ if (m[1] !== askerUserId) audience.add(m[1]!);
4171
+ }
4172
+ if (parsed.kind !== "other" && !audience.size && !broadcast) {
4173
+ // A 1:1 DM needs no tag — the other party is the only person it could be.
4174
+ const counterpart = await imCounterpart(token, t.channelId, cookie);
4175
+ if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
4176
+ }
4177
+ return { text, parsed, audience, broadcast, askerUserId, askerBotId };
4178
+ }
4179
+
4180
+ function askWaitCtxFrom(
4181
+ q: AskReadBack,
4182
+ parsed: Extract<AskReadBack["parsed"], { kind: "open" }>,
4183
+ loc: { channelId: string; ts: string; threadTs?: string | undefined },
4184
+ o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined },
4185
+ ): AskWaitCtx {
3958
4186
  const ctx: AskWaitCtx = {
3959
- channelId,
3960
- ts,
4187
+ channelId: loc.channelId,
4188
+ ts: loc.ts,
3961
4189
  question: parsed.question,
3962
4190
  reactable: parsed.reactable,
3963
4191
  overflow: parsed.overflow,
3964
4192
  threadOnly: parsed.threadOnly,
3965
4193
  lang: parsed.lang,
3966
- audience,
3967
- broadcast,
3968
- askerUserId,
3969
- askerBotId,
3970
- timeout: args.timeout,
3971
- shown: args.link,
3972
- asBot: args.asBot,
3973
- json: args.json,
4194
+ audience: q.audience,
4195
+ broadcast: q.broadcast,
4196
+ askerUserId: q.askerUserId,
4197
+ askerBotId: q.askerBotId,
4198
+ timeout: o.timeout,
4199
+ shown: o.link,
4200
+ asBot: o.asBot,
4201
+ json: o.json,
3974
4202
  };
3975
- if (threadTs) ctx.threadParentTs = threadTs;
3976
- if (args.cookie) ctx.cookie = args.cookie;
3977
- if (args.after) ctx.after = args.after;
3978
- await askWaitForAnswer(token, ctx);
4203
+ if (loc.threadTs) ctx.threadParentTs = loc.threadTs;
4204
+ if (o.cookie) ctx.cookie = o.cookie;
4205
+ if (o.after) ctx.after = o.after;
4206
+ return ctx;
3979
4207
  }
3980
4208
 
3981
4209
  // --- poll ---
@@ -5521,17 +5749,19 @@ async function main(): Promise<void> {
5521
5749
  .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." })
5522
5750
  .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." })
5523
5751
  .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." })
5752
+ .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." })
5753
+ .option("since", { type: "string", default: "7d", describe: "With --pending: how far back to look (30m, 2h, 7d, or an ISO date)" })
5524
5754
  .option("stale", { type: "string", describe: "With --ls: only open questions older than this (24h, 3d)" })
5525
5755
  .option("state", { type: "string", default: "open", choices: ["open", "answered", "void", "edited", "gone", "unknown", "all"], describe: "With --ls: which state to list" })
5526
- .option("channel", { type: "string", describe: "With --ls: only this channel (id, or the target as typed)" })
5756
+ .option("channel", { type: "string", describe: "With --ls / --pending: only this channel (id, or the target as typed)" })
5527
5757
  .option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
5528
5758
  .option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
5529
5759
  .option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
5530
- .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." })
5760
+ .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." })
5531
5761
  .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
5532
5762
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
5533
5763
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
5534
- .option("as-bot", { type: "boolean", default: false, describe: "Ask via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient" }),
5764
+ .option("as-bot", { type: "boolean", default: false, describe: "Ask via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient. @tags and an @user target then resolve with the bot token too (needs users:read)." }),
5535
5765
  async (argv) => {
5536
5766
  // Verbs. A target is always #chan / @user / a permalink / a raw ID, so a
5537
5767
  // bare word here cannot be one — `slack ask void …` is unambiguous.
@@ -5539,7 +5769,11 @@ async function main(): Promise<void> {
5539
5769
  // slack ask collect <link> = --waitFor
5540
5770
  // Lifecycle flags (maintainer: "maybe slack ask --edit=<target>"), primary;
5541
5771
  // the bare verbs `void` / `collect` stay as aliases.
5542
- if (argv.ls) {
5772
+ if (argv.ls || argv.pending) {
5773
+ if (argv.ls && argv.pending) {
5774
+ console.error("Error: --ls and --pending are separate lists — pick one.");
5775
+ process.exit(ASK_EXIT_ERROR);
5776
+ }
5543
5777
  const user = (() => { try { return tok(argv as W); } catch { return undefined; } })();
5544
5778
  const tokens: { user?: string; bot?: string; cookie?: string } = {};
5545
5779
  if (user) tokens.user = user;
@@ -5547,6 +5781,26 @@ async function main(): Promise<void> {
5547
5781
  if (bot) tokens.bot = bot;
5548
5782
  const c = ck(argv as W);
5549
5783
  if (c) tokens.cookie = c;
5784
+ if (argv.pending) {
5785
+ if (argv.target || argv.question || argv.waitFor || argv.wait || argv.void || argv.edit) {
5786
+ console.error("Error: --pending reads the local log — do not also pass a target/question or another ask action.");
5787
+ process.exit(ASK_EXIT_ERROR);
5788
+ }
5789
+ const sinceMs = parseSince(String(argv.since));
5790
+ if (sinceMs === undefined) {
5791
+ console.error(`Error: --since needs 30m / 2h / 7d / 1w or an ISO date (got ${stripTerminalControls(String(argv.since))})`);
5792
+ process.exit(ASK_EXIT_ERROR);
5793
+ }
5794
+ const p: { sinceMs: number; json: boolean; channel?: string } = { sinceMs, json: !!argv.json };
5795
+ if (argv.channel) p.channel = String(argv.channel);
5796
+ try {
5797
+ await cmdAskPending(tokens, p);
5798
+ } catch (e: unknown) {
5799
+ console.error(friendlySlackError(e));
5800
+ process.exit(ASK_EXIT_ERROR);
5801
+ }
5802
+ return;
5803
+ }
5550
5804
  const a: { stale?: string; state: string; channel?: string; json: boolean; limit: number } = {
5551
5805
  state: String(argv.state ?? "open"), json: !!argv.json, limit: Number(argv.limit) || 50,
5552
5806
  };
@@ -5580,7 +5834,9 @@ async function main(): Promise<void> {
5580
5834
  }
5581
5835
  const { token, cookie } = askTokenFor(!!argv["as-bot"]);
5582
5836
  const a: Parameters<typeof cmdAskEdit>[1] = {
5583
- link: String(argv.edit), asBot: !!argv["as-bot"], mentionToken: tok(argv as W),
5837
+ // Tags resolve as whoever posts the question — the bot under --as-bot,
5838
+ // so a dead or absent user token cannot break a bot edit.
5839
+ link: String(argv.edit), asBot: !!argv["as-bot"], mentionToken: token,
5584
5840
  allowUrlAdjacent: !!argv["allow-url-adjacent"],
5585
5841
  };
5586
5842
  if (newQuestion !== undefined) a.question = newQuestion;
@@ -5588,9 +5844,7 @@ async function main(): Promise<void> {
5588
5844
  if (argv.body !== undefined) a.body = String(argv.body);
5589
5845
  if (argv.lang) a.lang = String(argv.lang);
5590
5846
  if (argv.code) a.code = String(argv.code);
5591
- if (cookie) a.cookie = cookie;
5592
- const mc = ck(argv as W);
5593
- if (mc) a.mentionCookie = mc;
5847
+ if (cookie) { a.cookie = cookie; a.mentionCookie = cookie; }
5594
5848
  try {
5595
5849
  await cmdAskEdit(token, a);
5596
5850
  } catch (e: unknown) {
@@ -5757,11 +6011,6 @@ async function main(): Promise<void> {
5757
6011
  if (voidAfter) args.voidAfter = voidAfter;
5758
6012
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
5759
6013
  if (argv["user-id"]) args.userId = argv["user-id"];
5760
- // Always resolve @tags with the user token (it has users:read), even
5761
- // when the question itself is posted by the bot.
5762
- args.mentionToken = tok(argv as W);
5763
- const mc = ck(argv as W);
5764
- if (mc) args.mentionCookie = mc;
5765
6014
 
5766
6015
  let askToken: string;
5767
6016
  if (argv["as-bot"]) {
@@ -5773,11 +6022,12 @@ async function main(): Promise<void> {
5773
6022
  );
5774
6023
  process.exit(ASK_EXIT_ERROR);
5775
6024
  }
5776
- // Resolve @user with the *user* token (has users:read), then let cmdAsk
5777
- // open the DM with the bot token — same split as `send --as-bot`.
6025
+ // Resolve the @user destination AND the answerer tags as the posting
6026
+ // bot: the bot's directory is the one the question is read against, and
6027
+ // a revoked user credential must not break an otherwise valid bot ask.
5778
6028
  if (!args.channelId && !args.userId && args.target.startsWith("@")) {
5779
6029
  try {
5780
- args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
6030
+ args.userId = await resolveUserId(botToken, args.target);
5781
6031
  } catch (e: unknown) {
5782
6032
  console.error(
5783
6033
  `Error: could not resolve ${args.target} to a user for the bot DM.\n` +
@@ -5788,11 +6038,13 @@ async function main(): Promise<void> {
5788
6038
  }
5789
6039
  }
5790
6040
  askToken = botToken;
6041
+ args.mentionToken = botToken;
5791
6042
  args.asBot = true;
5792
6043
  } else {
5793
6044
  askToken = tok(argv as W);
6045
+ args.mentionToken = askToken;
5794
6046
  const sc = ck(argv as W);
5795
- if (sc) args.cookie = sc;
6047
+ if (sc) { args.cookie = sc; args.mentionCookie = sc; }
5796
6048
  }
5797
6049
  try {
5798
6050
  await cmdAsk(askToken, args);
@@ -5822,7 +6074,7 @@ async function main(): Promise<void> {
5822
6074
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
5823
6075
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
5824
6076
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
5825
- .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" }),
6077
+ .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. @tags and an @user target then resolve with the bot token too (needs users:read)." }),
5826
6078
  async (argv) => {
5827
6079
  const readLink = argv.close ? String(argv.close) : argv.results ? String(argv.results) : "";
5828
6080
  if (argv.results && argv.close) {
@@ -5892,18 +6144,14 @@ async function main(): Promise<void> {
5892
6144
  if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
5893
6145
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
5894
6146
  if (argv["user-id"]) args.userId = argv["user-id"];
5895
- // Always resolve @tags with the user token (it has users:read), even
5896
- // when the poll itself is posted by the bot.
5897
- args.mentionToken = tok(argv as W);
5898
- const mc = ck(argv as W);
5899
- if (mc) args.mentionCookie = mc;
5900
6147
 
5901
6148
  let pollToken: string;
5902
6149
  if (argv["as-bot"]) {
5903
6150
  const botToken = botTokenOrExit();
6151
+ // Resolve the @user destination and @tags as the posting bot, same as `ask --as-bot`.
5904
6152
  if (!args.channelId && !args.userId && args.target.startsWith("@")) {
5905
6153
  try {
5906
- args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
6154
+ args.userId = await resolveUserId(botToken, args.target);
5907
6155
  } catch (e: unknown) {
5908
6156
  console.error(
5909
6157
  `Error: could not resolve ${args.target} to a user for the bot DM.\n` +
@@ -5914,11 +6162,13 @@ async function main(): Promise<void> {
5914
6162
  }
5915
6163
  }
5916
6164
  pollToken = botToken;
6165
+ args.mentionToken = botToken;
5917
6166
  args.asBot = true;
5918
6167
  } else {
5919
6168
  pollToken = tok(argv as W);
6169
+ args.mentionToken = pollToken;
5920
6170
  const sc = ck(argv as W);
5921
- if (sc) args.cookie = sc;
6171
+ if (sc) { args.cookie = sc; args.mentionCookie = sc; }
5922
6172
  }
5923
6173
  try {
5924
6174
  await cmdPoll(pollToken, args);