slack-term 1.49.1 → 1.50.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
@@ -53,6 +53,7 @@ import {
53
53
  } from "./ask.ts";
54
54
  import { applyPrefix, findScope, initScope, isRestricted, listAliases, lookupAlias, readSettings, RestrictedError, setAlias, threadMismatch, unsetAlias, whoSubscribes, type MessagePlace, type Scope } from "./scope.ts";
55
55
  import { keepLanePrefix, tickText } from "./tick.ts";
56
+ import { applyIdentity, inAgent, listIdentities, splitWho, userSessionRefusal } from "./identity.ts";
56
57
  import { emojiMap, firstDifference, normText, storedAsSent } from "./readback.ts";
57
58
  import { choiceHint, choiceQuestionLang } from "./choiceHint.ts";
58
59
  import { addOutbox, deliver, listInbox, listOutbox, markDone, overdue, recordFailure, recordSent as recordOutboxSent } from "./inbox.ts";
@@ -151,7 +152,7 @@ import {
151
152
  import { setCacheEnabled } from "./cache.ts";
152
153
  import { escapeArg, unescapeArg } from "./escapes.ts";
153
154
  import { attachmentLines, parseAttachments } from "./attachments.ts";
154
- import { botIntentRefusal } from "./botIntent.ts";
155
+ import { botIntentRefusal, isReadMode, isWriteCommand } from "./botIntent.ts";
155
156
  import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, maskSecrets, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult, type UnresolvedMention } from "./format.ts";
156
157
  import { quietHoursNotice } from "./quietHours.ts";
157
158
  import { installSyncConsole } from "./stdout.ts";
@@ -2634,17 +2635,17 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
2634
2635
  // a ts split, and `--channel-id` makes the leading "#name" irrelevant to
2635
2636
  // resolution, so it never depends on ref's original prefix or an
2636
2637
  // embedded thread_ts colliding with the appended message ts.
2637
- let editCmd = `slack edit "#${channelId}:${lastMsgTs}" "<新しい本文>" --channel-id ${channelId}`;
2638
+ let editCmd = `slack edit "#${channelId}:${lastMsgTs}" "<new text>" --channel-id ${channelId}`;
2638
2639
  try {
2639
2640
  const permalink = await getPermalink(token, channelId, lastMsgTs, cookie);
2640
- if (permalink) editCmd = `slack edit "${permalink}" "<新しい本文>"`;
2641
+ if (permalink) editCmd = `slack edit "${permalink}" "<new text>"`;
2641
2642
  } catch {
2642
2643
  // fall back to the --channel-id form above
2643
2644
  }
2644
- console.error(`⚠ 相手はまだ返信していません(最後のメッセージはあなたのものです)。`);
2645
- console.error(` 連投を避けるため、新規送信でなく直前のメッセージの edit を検討してください:`);
2645
+ console.error(`⚠ They haven't replied yet (the last message is yours).`);
2646
+ console.error(` To avoid a string of messages, consider editing your last message instead of sending a new one:`);
2646
2647
  console.error(` ${editCmd}`);
2647
- console.error(` このまま送信する場合は --code=XXXX で確定。`);
2648
+ console.error(` To send anyway, confirm with --code=XXXX.`);
2648
2649
  }
2649
2650
  } catch {
2650
2651
  // best-effort; a failed self-identity lookup must not block the send
@@ -3000,7 +3001,7 @@ async function askCollectNotes(token: string, sc: AskNoteScope, after: string |
3000
3001
  const rep = asRecord((await replies(token, sc.channelId, sc.threadParentTs ?? sc.ts, 100, sc.cookie)) as Json);
3001
3002
  for (const m of asArray(rep.messages).map(asRecord)) pool.set(String(m.ts), m);
3002
3003
  } catch (e: unknown) {
3003
- console.error(` (スレッドの追記を読めませんでした: ${e instanceof Error ? e.message : String(e)})`);
3004
+ console.error(` (could not read the thread's follow-up notes: ${e instanceof Error ? e.message : String(e)})`);
3004
3005
  }
3005
3006
  }
3006
3007
  if (!sc.threadOnly && !sc.threadParentTs) {
@@ -3013,7 +3014,7 @@ async function askCollectNotes(token: string, sc: AskNoteScope, after: string |
3013
3014
  .reduce((min, m) => Math.min(min, Number(m.ts)), Infinity);
3014
3015
  for (const m of msgs) if (Number(m.ts) < bound && !pool.has(String(m.ts))) pool.set(String(m.ts), m);
3015
3016
  } catch (e: unknown) {
3016
- console.error(` (DM の追記を読めませんでした: ${e instanceof Error ? e.message : String(e)})`);
3017
+ console.error(` (could not read the DM's follow-up notes: ${e instanceof Error ? e.message : String(e)})`);
3017
3018
  }
3018
3019
  }
3019
3020
  const picked = [...pool.values()]
@@ -3067,7 +3068,7 @@ async function askEmit(
3067
3068
  return;
3068
3069
  }
3069
3070
  if (r.notes.length) {
3070
- console.error(` スレッドの追記 ${r.notes.length} 件 (回答とは別):`);
3071
+ console.error(` ${r.notes.length} thread note(s) (separate from the answer):`);
3071
3072
  for (const n of r.notes) {
3072
3073
  let who = n.user;
3073
3074
  try {
@@ -3081,7 +3082,7 @@ async function askEmit(
3081
3082
  }
3082
3083
  }
3083
3084
  if (r.status === "answered") {
3084
- console.error(` この後の追記を拾う: ${askResumeCommand(o.shown, o.asBot, r.cursor)}`);
3085
+ console.error(` To pick up later notes: ${askResumeCommand(o.shown, o.asBot, r.cursor)}`);
3085
3086
  }
3086
3087
  if (r.answer !== null) console.log(r.answer);
3087
3088
  }
@@ -3178,7 +3179,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
3178
3179
  await editMessage(token, channelId, ts, next, cookie, true);
3179
3180
  } catch (e: unknown) {
3180
3181
  // Cosmetic: the poller still refuses to guess, and stderr already said so.
3181
- console.error(` (質問文に注意書きを入れられませんでした: ${e instanceof Error ? e.message : String(e)})`);
3182
+ console.error(` (could not add the notice to the question text: ${e instanceof Error ? e.message : String(e)})`);
3182
3183
  }
3183
3184
  }
3184
3185
 
@@ -3195,7 +3196,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
3195
3196
  for (const u of asArray(r.users).filter(isAnswerer)) {
3196
3197
  if (otherReported.has(u)) continue;
3197
3198
  otherReported.add(u);
3198
- if (!readOnly) console.error(` (<@${u}> が ${glyph} その他 を押しました。返信を待ちます)`);
3199
+ if (!readOnly) console.error(` (<@${u}> pressed ${glyph} other; waiting for their reply)`);
3199
3200
  }
3200
3201
  }
3201
3202
  }
@@ -3249,14 +3250,14 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
3249
3250
  if (++pages >= ASK_EXPLAIN_NOTE_MAX_PAGES) return "exists";
3250
3251
  }
3251
3252
  } catch (e: unknown) {
3252
- console.error(` (スレッドを読めないため、説明リクエストの案内はまだ出していません: ${e instanceof Error ? e.message : String(e)})`);
3253
+ console.error(` (could not read the thread, so the explain-request note has not been posted yet: ${e instanceof Error ? e.message : String(e)})`);
3253
3254
  return "unread";
3254
3255
  }
3255
3256
  try {
3256
3257
  await slackSend(token, channelId, copy.explainNote, root, false, cookie, true);
3257
3258
  } catch (e: unknown) {
3258
3259
  // The press is still reported on stderr; the note is a courtesy.
3259
- console.error(` (説明リクエストの案内をスレッドに出せませんでした: ${e instanceof Error ? e.message : String(e)})`);
3260
+ console.error(` (could not post the explain-request note to the thread: ${e instanceof Error ? e.message : String(e)})`);
3260
3261
  return "failed";
3261
3262
  }
3262
3263
  return "posted";
@@ -3269,7 +3270,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
3269
3270
  now.push(u);
3270
3271
  if (explainReported.has(u)) continue;
3271
3272
  explainReported.add(u);
3272
- if (!readOnly) console.error(` (<@${u}> が ℹ️ 説明して を押しました — 回答ではありません。スレッドで説明してください。回答は待ち続けます)`);
3273
+ if (!readOnly) console.error(` (<@${u}> pressed ℹ️ explain — not an answer. Explain in the thread; still waiting for an answer)`);
3273
3274
  }
3274
3275
  state.explainNow = now;
3275
3276
  // Read-only (`--pending`): the press is reported, the note is not posted —
@@ -3306,8 +3307,8 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
3306
3307
  for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
3307
3308
  const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
3308
3309
  if (!readOnly) console.error(multi.length
3309
- ? ` (${glyphs} が同時に選ばれています。1 つに絞ってもらうまで待ちます)`
3310
- : ` (回答が分かれています: ${glyphs}。どちらか一方にそろうまで待ちます)`);
3310
+ ? ` (${glyphs} are pressed at once; waiting until it is narrowed to one)`
3311
+ : ` (answers are split: ${glyphs}; waiting until they agree on one)`);
3311
3312
  // Whoever is on more than one pill — and ONLY them. Naming everyone on any
3312
3313
  // pill told a voter who had pressed exactly one that she had pressed two
3313
3314
  // (2026-10-01: a voter on 1️⃣ only was named because someone else was on 1️⃣
@@ -3467,7 +3468,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3467
3468
  }
3468
3469
  // Who answered never reaches stdout — callers do ANS=$(slack ask --wait ...)
3469
3470
  // and must keep getting the answer body alone.
3470
- if (who) console.error(` 回答者: ${stripTerminalControls(who)}`);
3471
+ if (who) console.error(` Answered by: ${stripTerminalControls(who)}`);
3471
3472
  try {
3472
3473
  // The body and the chosen option stay; only what no longer applies goes.
3473
3474
  // Built from the message as it is NOW (an edit made in Slack after asking
@@ -3494,7 +3495,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3494
3495
  // The edit is bookkeeping, not the answer. It fails whenever the resuming
3495
3496
  // token is not the one that posted (Slack lets only the author update), and
3496
3497
  // that must not cost the caller an answer it already has.
3497
- console.error(` (元メッセージの更新に失敗: ${e instanceof Error ? e.message : String(e)})`);
3498
+ console.error(` (failed to update the original message: ${e instanceof Error ? e.message : String(e)})`);
3498
3499
  }
3499
3500
  }
3500
3501
 
@@ -3519,10 +3520,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3519
3520
  // A dropped poll is expected occasionally; only give up if it keeps failing.
3520
3521
  errors++;
3521
3522
  if (errors >= ASK_MAX_CONSECUTIVE_ERRORS) {
3522
- console.error(`Error: Slack への問い合わせに ${errors} 回連続で失敗しました: ${e instanceof Error ? e.message : String(e)}`);
3523
+ console.error(`Error: Slack requests failed ${errors} times in a row: ${e instanceof Error ? e.message : String(e)}`);
3523
3524
  process.exit(ASK_EXIT_ERROR);
3524
3525
  }
3525
- console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
3526
+ console.error(` (Slack error ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, retrying: ${e instanceof Error ? e.message : String(e)})`);
3526
3527
  }
3527
3528
  }
3528
3529
  unchosen = state.unchosen;
@@ -3565,7 +3566,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3565
3566
  // only on the run that posted the thread note, so a `--timeout 0` poller is
3566
3567
  // woken once per question, not on every look.
3567
3568
  if (state.explainPosted && ctx.explainExit) {
3568
- console.error(` 説明を求められています (質問はまだ開いています)。スレッドで説明してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3569
+ console.error(` An explanation was requested (the question is still open). Explain in the thread, then collect with: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3569
3570
  if (ctx.json) await askEmit(token, ctx, { status: "explain", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3570
3571
  process.exit(ASK_EXIT_EXPLAIN);
3571
3572
  }
@@ -3581,10 +3582,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3581
3582
  } catch {
3582
3583
  // the id is still a usable answer to "who"
3583
3584
  }
3584
- console.error(` (${stripTerminalControls(who)} が選択肢外の返信をしました — 決定ではありません。本文を stdout に出します)`);
3585
- console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
3585
+ console.error(` (${stripTerminalControls(who)} replied with something that is not a choice — not a decision. Printing the reply on stdout)`);
3586
+ console.error(` Choices: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
3586
3587
  console.error(` ${shown}`);
3587
- console.error(` この返信の後を待つ: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
3588
+ console.error(` To wait for what comes after this reply: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
3588
3589
  // Delivered, not collected: `--pending` will not report THIS reply again,
3589
3590
  // but still reports a pill pressed after it.
3590
3591
  markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
@@ -3608,8 +3609,8 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3608
3609
  // the id is still a usable answer to "who"
3609
3610
  }
3610
3611
  // Only the ambiguous case gets here — a plain free-text reply exited above.
3611
- console.error(` (${stripTerminalControls(who)} の返信は複数の選択肢に一致します。1 つに絞ってもらうまで待ちます)`);
3612
- console.error(` 「${stripTerminalControls(unchosen.text)}」`);
3612
+ console.error(` (${stripTerminalControls(who)}'s reply matches several choices; waiting until it is narrowed to one)`);
3613
+ console.error(` "${stripTerminalControls(unchosen.text)}"`);
3613
3614
  }
3614
3615
  // Checked after the first poll, never before it: `--timeout 0` means one
3615
3616
  // look, and one look must still be a real look.
@@ -3630,20 +3631,20 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3630
3631
  // something to act on.
3631
3632
  if (unchosen) {
3632
3633
  // Ambiguous only: a plain free-text reply was delivered as exit 5 already.
3633
- console.error(`Error: 返信はありましたが、複数の選択肢に一致するため確定できません (メッセージはそのまま残っています)`);
3634
- console.error(` 返信: 「${stripTerminalControls(unchosen.text)}」`);
3635
- console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
3634
+ console.error(`Error: there was a reply, but it matches several choices so it cannot be settled (the message is left in place)`);
3635
+ console.error(` Reply: "${stripTerminalControls(unchosen.text)}"`);
3636
+ console.error(` Choices: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
3636
3637
  console.error(` ${shown}`);
3637
- console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3638
+ console.error(` Reply in the thread, then collect with: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3638
3639
  if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3639
3640
  process.exit(ASK_EXIT_UNCHOSEN);
3640
3641
  }
3641
3642
  if (timeout === 0) {
3642
- console.error(`まだ回答がありません: ${shown}`);
3643
+ console.error(`No answer yet: ${shown}`);
3643
3644
  } else {
3644
- console.error(`Error: ${timeout}s 以内に回答がありませんでした (メッセージはそのまま残っています)`);
3645
+ console.error(`Error: no answer within ${timeout}s (the message is left in place)`);
3645
3646
  console.error(` ${shown}`);
3646
- console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3647
+ console.error(` To collect later: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3647
3648
  }
3648
3649
  if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3649
3650
  process.exit(ASK_EXIT_TIMEOUT);
@@ -3827,7 +3828,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3827
3828
  ` Answerable by: ${audienceLabel}`,
3828
3829
  ` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
3829
3830
  ...(explain ? [` Explain: ℹ️ pill offered — pressing it asks you to explain; it is never an answer`] : []),
3830
- ...(args.voidAfter ? [` Then 無効化: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) — replaced by this question`] : []),
3831
+ ...(args.voidAfter ? [` Then void: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) — replaced by this question`] : []),
3831
3832
  ` Language: ${lang} (${({ flag: "--lang", env: "SLACK_TERM_LANG", readers: "answerers' Slack locale", content: "the question's text", system: "system locale", default: "default" })[picked.source]}; override with --lang)`,
3832
3833
  `--------------------------------────────────`,
3833
3834
  ], recipientTz);
@@ -3864,8 +3865,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3864
3865
  const why = e instanceof Error ? e.message : String(e);
3865
3866
  // The marker is cosmetic + searchability; a keycap only costs the
3866
3867
  // answerer a trip to the emoji picker. Neither abandons a posted question.
3867
- if (name === seedMarker) console.error(` (marker ${name} を付けられませんでした: ${why})`);
3868
- else console.error(` (リアクション ${name} を付けられませんでした: ${why})`);
3868
+ if (name === seedMarker) console.error(` (could not add marker ${name}: ${why})`);
3869
+ else console.error(` (could not add reaction ${name}: ${why})`);
3869
3870
  },
3870
3871
  );
3871
3872
 
@@ -3882,7 +3883,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3882
3883
  // two open questions, which is reported loudly — never a missing one.
3883
3884
  if (args.voidAfter) {
3884
3885
  const ok = await askApplyVoid(token, args.voidAfter, args.voidAfter.reason, shown, cookie);
3885
- if (!ok) console.error(` ⚠ 新しい質問は出ましたが、古い質問を無効化できませんでした — 手で: slack ask --void='${stripTerminalControls(args.voidAfter.link)}' --superseded-by='${shown}'`);
3886
+ if (!ok) console.error(` ⚠ The new question is posted, but the old one could not be voided — do it by hand: slack ask --void='${stripTerminalControls(args.voidAfter.link)}' --superseded-by='${shown}'`);
3886
3887
  }
3887
3888
 
3888
3889
  if (!args.wait) {
@@ -3896,7 +3897,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3896
3897
  }
3897
3898
 
3898
3899
  const timeout = args.timeout ?? 3600;
3899
- console.error(` 回答を待っています (最大 ${timeout}s)… Ctrl-C で中断`);
3900
+ console.error(` Waiting for an answer (up to ${timeout}s)… Ctrl-C to stop`);
3900
3901
 
3901
3902
  const ctx: AskWaitCtx = {
3902
3903
  channelId,
@@ -3927,9 +3928,9 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3927
3928
 
3928
3929
  /** Say on stderr that a question is void, and where its replacement is. */
3929
3930
  function askReportVoid(p: { reason: string; supersededBy?: string }, shown: string): void {
3930
- console.error(`この質問は無効です — 回答されることはありません。待つのをやめてください: ${stripTerminalControls(shown)}`);
3931
+ console.error(`This question is void — it will never be answered. Stop waiting on it: ${stripTerminalControls(shown)}`);
3931
3932
  if (p.reason) console.error(` ${stripTerminalControls(p.reason)}`);
3932
- if (p.supersededBy) console.error(` 新しい質問: ${stripTerminalControls(p.supersededBy)}`);
3933
+ if (p.supersededBy) console.error(` New question: ${stripTerminalControls(p.supersededBy)}`);
3933
3934
  }
3934
3935
 
3935
3936
  /** Find the one message a permalink (or `C…:ts`) names. A question posted into
@@ -3984,20 +3985,20 @@ async function askLoadOwnOpen(token: string, link: string, self: Self | null, as
3984
3985
  Promise<(AskVoidTarget & { msg: Record<string, Json>; threadTs?: string }) | "void" | null> {
3985
3986
  const shown = stripTerminalControls(link);
3986
3987
  const got = await askFetchByLink(token, link, cookie);
3987
- if (!got) { console.error(`✗ ${shown}: permalink (または 'C…:1700000000.000100') ではありません`); return null; }
3988
- if (!got.msg) { console.error(`✗ ${shown}: メッセージが見つかりません`); return null; }
3988
+ if (!got) { console.error(`✗ ${shown}: not a permalink (or 'C…:1700000000.000100')`); return null; }
3989
+ if (!got.msg) { console.error(`✗ ${shown}: message not found`); return null; }
3989
3990
  const msg = got.msg;
3990
3991
  const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
3991
3992
  if (!own) {
3992
- console.error(`✗ ${shown}: 自分(${senderLabel(self, asBot)})が投稿した質問ではありません` +
3993
- `${asBot ? "" : " — bot が出した質問なら --as-bot で"}`);
3993
+ console.error(`✗ ${shown}: not a question posted by you (${senderLabel(self, asBot)})` +
3994
+ `${asBot ? "" : " — if the bot posted it, use --as-bot"}`);
3994
3995
  return null;
3995
3996
  }
3996
3997
  const text = typeof msg.text === "string" ? msg.text : "";
3997
3998
  const parsed = askParseMessage(text);
3998
3999
  if (parsed.kind === "void") return "void";
3999
- if (parsed.kind === "resolved") { console.error(`✗ ${shown}: 回答済みの質問です(回答: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})— 変更できません`); return null; }
4000
- if (parsed.kind !== "open") { console.error(`✗ ${shown}: \`slack ask\` の質問として読み取れません — ${askExplainReject(text)}`); return null; }
4000
+ if (parsed.kind === "resolved") { console.error(`✗ ${shown}: already answered (answer: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")}) — cannot be changed`); return null; }
4001
+ if (parsed.kind !== "open") { console.error(`✗ ${shown}: cannot be read as a \`slack ask\` question — ${askExplainReject(text)}`); return null; }
4001
4002
  const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, seedMarker: askSeedMarker(text) ?? ASK_MARKER, msg };
4002
4003
  if (got.threadTs) out.threadTs = got.threadTs;
4003
4004
  return out;
@@ -4041,7 +4042,7 @@ async function cmdAskEdit(token: string, args: {
4041
4042
  }): Promise<void> {
4042
4043
  const self = await selfIdentity(token, args.cookie);
4043
4044
  const t = await askLoadOwnOpen(token, args.link, self, args.asBot, args.cookie);
4044
- if (t === "void") { console.error(`✗ ${stripTerminalControls(args.link)}: 無効化済みの質問は編集できません`); process.exit(ASK_EXIT_ERROR); }
4045
+ if (t === "void") { console.error(`✗ ${stripTerminalControls(args.link)}: a voided question cannot be edited`); process.exit(ASK_EXIT_ERROR); }
4045
4046
  if (!t) process.exit(ASK_EXIT_ERROR);
4046
4047
  const old = t.parsed;
4047
4048
  const encode = async (raw: string): Promise<string> => {
@@ -4057,8 +4058,8 @@ async function cmdAskEdit(token: string, args: {
4057
4058
  const choicesChanged = JSON.stringify(choices.map(askFlatten)) !== JSON.stringify(oldChoices.map(askFlatten));
4058
4059
  if (choicesChanged && await askHasResponses(token, t, self, args.cookie)) {
4059
4060
  console.error(
4060
- `✗ この質問にはもう回答(リアクションか返信)があります — 選択肢を変えると、押されたリアクションの意味が変わってしまいます。\n` +
4061
- ` 質問文と本文は変えられます。選択肢を変えるなら無効化して出し直してください:\n` +
4061
+ `✗ This question already has responses (reactions or replies) — changing the choices would change what the pressed reactions mean.\n` +
4062
+ ` The question and body can still be changed. To change the choices, void it and ask again:\n` +
4062
4063
  ` slack ask --void='${stripTerminalControls(args.link)}' --reask${args.asBot ? " --as-bot" : ""} …`,
4063
4064
  );
4064
4065
  process.exit(ASK_EXIT_ERROR);
@@ -4069,7 +4070,7 @@ async function cmdAskEdit(token: string, args: {
4069
4070
  const tagged = [...`${question}\n${body}`.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].some((m) => m[1] !== self?.userId);
4070
4071
  const addressed = tagged || /<!(?:here|channel|everyone)/.test(`${question}\n${body}`) || t.channelId.startsWith("D");
4071
4072
  if (!addressed) {
4072
- console.error(`✗ 誰にも宛てられていない質問になります — 回答できる人を @ で残してください`);
4073
+ console.error(`✗ The question would be addressed to nobody — keep an @mention of who can answer`);
4073
4074
  process.exit(ASK_EXIT_ERROR);
4074
4075
  }
4075
4076
  const lang = args.lang ? askResolveLang({ flag: args.lang })?.lang ?? old.lang : old.lang;
@@ -4083,7 +4084,7 @@ async function cmdAskEdit(token: string, args: {
4083
4084
  if (invalid.length) next = applyInvalidNotice(next, invalid, lang);
4084
4085
  guardUrlBoundaries(next, args.allowUrlAdjacent);
4085
4086
  if (next === oldText) {
4086
- console.error("変更はありません。");
4087
+ console.error("No changes.");
4087
4088
  return;
4088
4089
  }
4089
4090
 
@@ -4123,7 +4124,7 @@ async function cmdAskEdit(token: string, args: {
4123
4124
  await seedReactionsInOrder(
4124
4125
  [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail(seedMarker)],
4125
4126
  (name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
4126
- (name, e) => console.error(` (リアクション ${name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`),
4127
+ (name, e) => console.error(` (could not add reaction ${name}: ${e instanceof Error ? e.message : String(e)})`),
4127
4128
  );
4128
4129
  } else if (oldSeed !== seedMarker) {
4129
4130
  // An old ❓ pill edited without touching its choices: the body now lists
@@ -4133,10 +4134,10 @@ async function cmdAskEdit(token: string, args: {
4133
4134
  await reactionAdd(token, t.channelId, t.ts, seedMarker, args.cookie);
4134
4135
  await reactionRemove(token, t.channelId, t.ts, oldSeed, args.cookie);
4135
4136
  } catch (e: unknown) {
4136
- console.error(` (marker を ${seedMarker} に替えられませんでした: ${e instanceof Error ? e.message : String(e)})`);
4137
+ console.error(` (could not swap the marker to ${seedMarker}: ${e instanceof Error ? e.message : String(e)})`);
4137
4138
  }
4138
4139
  }
4139
- console.error(`✓ 編集しました: ${stripTerminalControls(args.link)}`);
4140
+ console.error(`✓ Edited: ${stripTerminalControls(args.link)}`);
4140
4141
  }
4141
4142
 
4142
4143
  /** `slack ask --ls [--stale <dur>] [--state …] [--channel …] [--json]` — the
@@ -4213,7 +4214,7 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
4213
4214
  for (const bot of [false, true]) {
4214
4215
  const mine = open.filter((it) => it.asBot === bot);
4215
4216
  if (!mine.length) continue;
4216
- console.error(`無効化するなら (${mine.length} 件, ${bot ? "bot" : "user"}):\n slack ask ${mine.map((it) => `--void='${it.link}'`).join(" ")} --reason 'stale'${bot ? " --as-bot" : ""}`);
4217
+ console.error(`To void them (${mine.length}, ${bot ? "bot" : "user"}):\n slack ask ${mine.map((it) => `--void='${it.link}'`).join(" ")} --reason 'stale'${bot ? " --as-bot" : ""}`);
4217
4218
  }
4218
4219
  }
4219
4220
  }
@@ -4279,7 +4280,7 @@ async function cmdAskPending(
4279
4280
  const asBot = !!row.as_bot;
4280
4281
  const token = asBot ? tokens.bot : tokens.user;
4281
4282
  if (!token) {
4282
- problems.push(`${link}: ${asBot ? "--as-bot で聞いた質問ですが bot token がありません" : "user token がありません"}`);
4283
+ problems.push(`${link}: ${asBot ? "asked with --as-bot, but there is no bot token" : "no user token"}`);
4283
4284
  continue;
4284
4285
  }
4285
4286
  const cookie = asBot ? undefined : tokens.cookie;
@@ -4290,15 +4291,15 @@ async function cmdAskPending(
4290
4291
  try {
4291
4292
  const loc = { channelId: row.channel, ts: row.ts, threadTs: row.thread_ts ?? undefined };
4292
4293
  const got = await askFetchAt(token, loc, cookie);
4293
- if (!got.msg) { problems.push(`${link}: メッセージが見つかりません (削除?)`); continue; }
4294
+ if (!got.msg) { problems.push(`${link}: message not found (deleted?)`); continue; }
4294
4295
  const q = await askReadBack(token, { channelId: row.channel, ts: row.ts, msg: got.msg }, cookie);
4295
4296
  const parsed = q.parsed;
4296
4297
  // ✅ already stamped: some `--wait`/`--waitFor` collected it (another
4297
4298
  // machine, another token, or a build before collection was logged).
4298
4299
  if (parsed.kind === "resolved") { count.resolved++; continue; }
4299
4300
  if (parsed.kind === "void") { count.void++; continue; }
4300
- if (parsed.kind === "other") { problems.push(`${link}: \`slack ask\` の質問として読めません — ${askExplainReject(q.text)}`); continue; }
4301
- if (!q.audience.size && !q.broadcast) { problems.push(`${link}: 誰にも宛てられていない質問です`); continue; }
4301
+ if (parsed.kind === "other") { problems.push(`${link}: cannot be read as a \`slack ask\` question — ${askExplainReject(q.text)}`); continue; }
4302
+ if (!q.audience.size && !q.broadcast) { problems.push(`${link}: the question is addressed to nobody`); continue; }
4302
4303
  const ctx = askWaitCtxFrom(q, parsed, loc, { timeout: 0, link, asBot, json: false, cookie, after });
4303
4304
  const poller = askPoller(token, ctx, true);
4304
4305
  const found = await poller.poll();
@@ -4307,13 +4308,13 @@ async function cmdAskPending(
4307
4308
  const collect = askResumeCommand(link, asBot, after);
4308
4309
  if (poller.state.explainNow.length) {
4309
4310
  count.explain++;
4310
- explainAsked.push(`${link}: ℹ️ 説明を求められています (${poller.state.explainNow.map((u) => `<@${u}>`).join(" ")}) — スレッドで説明してください`);
4311
+ explainAsked.push(`${link}: ℹ️ an explanation was requested (${poller.state.explainNow.map((u) => `<@${u}>`).join(" ")}) — explain in the thread`);
4311
4312
  }
4312
4313
  const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
4313
4314
  if (found) {
4314
4315
  pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
4315
4316
  } else if (unchosen && !unchosen.ambiguous && unchosen.text !== row.delivered_answer) {
4316
- pending.push({ ...base, answer: unchosen.text, how: "返信 (選択肢外)", freeText: true });
4317
+ pending.push({ ...base, answer: unchosen.text, how: "reply (not a choice)", freeText: true });
4317
4318
  } else if (unchosen?.ambiguous) {
4318
4319
  count.ambiguous++;
4319
4320
  } else {
@@ -4351,7 +4352,7 @@ async function cmdAskPending(
4351
4352
  for (const it of items) {
4352
4353
  console.log(` ${stripTerminalControls(it.link)} (${formatYmdHm(it.row.sent_at / 1000)})`);
4353
4354
  console.log(` Q: ${stripTerminalControls(askFlatten(it.question))}`);
4354
- console.log(` A: ${stripTerminalControls(askFlatten(it.answer))} — ${stripTerminalControls(it.how)}${it.freeText ? " — 決定ではありません" : ""}`);
4355
+ console.log(` A: ${stripTerminalControls(askFlatten(it.answer))} — ${stripTerminalControls(it.how)}${it.freeText ? " — not a decision" : ""}`);
4355
4356
  console.log(` collect: ${stripTerminalControls(it.collect)}`);
4356
4357
  }
4357
4358
  }
@@ -4359,15 +4360,15 @@ async function cmdAskPending(
4359
4360
  for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
4360
4361
  for (const p of explainAsked) console.error(` (${stripTerminalControls(p)})`);
4361
4362
  for (const [team, n] of otherTeams) {
4362
- console.error(` (${n} 件は別のワークスペース「${stripTerminalControls(team)}」の質問です — slack -w <name> ask --pending で確認)`);
4363
+ console.error(` (${n} question(s) belong to another workspace "${stripTerminalControls(team)}" — check with slack -w <name> ask --pending)`);
4363
4364
  }
4364
4365
  console.error(
4365
- `${pending.length} 件が回答済み・未回収, ${count.unanswered} 件が未回答` +
4366
- (count.ambiguous ? `, ${count.ambiguous} 件は返信が複数の選択肢に一致 (未確定)` : "") +
4367
- (count.resolved ? `, ${count.resolved} 件は ✅ 回収済み` : "") +
4368
- (count.void ? `, ${count.void} 件は無効化済み` : "") +
4369
- (count.explain ? `, ${count.explain} 件は ℹ️ 説明のリクエストあり` : "") +
4370
- ` — ${rows.length} 件の未回収の質問を確認 (log: ${sentDbPath()})`,
4366
+ `${pending.length} answered but not collected, ${count.unanswered} unanswered` +
4367
+ (count.ambiguous ? `, ${count.ambiguous} with a reply matching several choices (unsettled)` : "") +
4368
+ (count.resolved ? `, ${count.resolved} ✅ already collected` : "") +
4369
+ (count.void ? `, ${count.void} voided` : "") +
4370
+ (count.explain ? `, ${count.explain} with an ℹ️ explain request` : "") +
4371
+ ` — checked ${rows.length} uncollected question(s) (log: ${sentDbPath()})`,
4371
4372
  );
4372
4373
  }
4373
4374
 
@@ -4391,7 +4392,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
4391
4392
  const next = askBuildVoidText(t.parsed.question, reason, t.parsed.lang, t.parsed.body || undefined, supersededBy);
4392
4393
  await editMessage(token, t.channelId, t.ts, next, cookie, true);
4393
4394
  } catch (e: unknown) {
4394
- console.error(`✗ ${shown}: 書き換えに失敗しました: ${e instanceof Error ? e.message : String(e)}`);
4395
+ console.error(`✗ ${shown}: failed to rewrite: ${e instanceof Error ? e.message : String(e)}`);
4395
4396
  return false;
4396
4397
  }
4397
4398
  for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), t.seedMarker, ...(t.parsed.explain ? [ASK_EXPLAIN_MARKER] : [])]) {
@@ -4400,9 +4401,9 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
4400
4401
  try {
4401
4402
  await reactionAdd(token, t.channelId, t.ts, ASK_VOID_MARKER, cookie);
4402
4403
  } catch (e: unknown) {
4403
- console.error(` (${shown}: 🚫 リアクションを付けられませんでした: ${e instanceof Error ? e.message : String(e)})`);
4404
+ console.error(` (${shown}: could not add the 🚫 reaction: ${e instanceof Error ? e.message : String(e)})`);
4404
4405
  }
4405
- console.error(`✓ 無効化: ${shown}`);
4406
+ console.error(`✓ Voided: ${shown}`);
4406
4407
  return true;
4407
4408
  }
4408
4409
 
@@ -4442,11 +4443,11 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4442
4443
  process.exit(ASK_EXIT_ERROR);
4443
4444
  }
4444
4445
  } catch (e: unknown) {
4445
- console.error(`Error: --superseded-by を確認できません: ${shown} (${e instanceof Error ? e.message : String(e)})`);
4446
+ console.error(`Error: cannot check --superseded-by: ${shown} (${e instanceof Error ? e.message : String(e)})`);
4446
4447
  process.exit(ASK_EXIT_ERROR);
4447
4448
  }
4448
4449
  if (!found) {
4449
- console.error(`Error: --superseded-by のメッセージが見つかりません: ${shown}`);
4450
+ console.error(`Error: --superseded-by message not found: ${shown}`);
4450
4451
  process.exit(ASK_EXIT_ERROR);
4451
4452
  }
4452
4453
  }
@@ -4456,21 +4457,21 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4456
4457
  for (const link of args.links) {
4457
4458
  const shown = stripTerminalControls(link);
4458
4459
  const got = await askFetchByLink(token, link, args.cookie);
4459
- if (!got) { console.error(`✗ ${shown}: permalink (または 'C…:1700000000.000100') ではありません`); refused++; continue; }
4460
- if (!got.msg) { console.error(`✗ ${shown}: メッセージが見つかりません`); refused++; continue; }
4460
+ if (!got) { console.error(`✗ ${shown}: not a permalink (or 'C…:1700000000.000100')`); refused++; continue; }
4461
+ if (!got.msg) { console.error(`✗ ${shown}: message not found`); refused++; continue; }
4461
4462
  const msg = got.msg;
4462
4463
  const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
4463
4464
  if (!own) {
4464
- console.error(`✗ ${shown}: 自分(${senderLabel(self, args.asBot)})が投稿した質問ではないので無効化できません` +
4465
- `${args.asBot ? "" : " — bot が出した質問なら --as-bot で"}`);
4465
+ console.error(`✗ ${shown}: not a question posted by you (${senderLabel(self, args.asBot)}), so it cannot be voided` +
4466
+ `${args.asBot ? "" : " — if the bot posted it, use --as-bot"}`);
4466
4467
  refused++;
4467
4468
  continue;
4468
4469
  }
4469
4470
  const text = typeof msg.text === "string" ? msg.text : "";
4470
4471
  let parsed = askParseMessage(text);
4471
4472
  let unreadable = false;
4472
- if (parsed.kind === "void") { console.error(`- ${shown}: すでに無効化済みです(何もしません)`); continue; }
4473
- if (parsed.kind === "resolved") { console.error(`✗ ${shown}: 回答済みの質問は無効化できません(回答: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})`); refused++; continue; }
4473
+ if (parsed.kind === "void") { console.error(`- ${shown}: already voided (nothing to do)`); continue; }
4474
+ if (parsed.kind === "resolved") { console.error(`✗ ${shown}: an answered question cannot be voided (answer: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})`); refused++; continue; }
4474
4475
  if (parsed.kind !== "open") {
4475
4476
  // Our own ❓ question whose layout no longer parses (2026-10-01: a
4476
4477
  // `slack edit` flattened one; it could then be neither collected nor
@@ -4478,7 +4479,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4478
4479
  // with it, so it is voided from a best-effort read — question and body
4479
4480
  // kept as they are, our own seeded pills removed — and the gate says so.
4480
4481
  const salvage = askSalvageUnreadable(text);
4481
- if (!salvage) { console.error(`✗ ${shown}: \`slack ask\` の質問として読み取れません — ${askExplainReject(text)}`); refused++; continue; }
4482
+ if (!salvage) { console.error(`✗ ${shown}: cannot be read as a \`slack ask\` question — ${askExplainReject(text)}`); refused++; continue; }
4482
4483
  const seeded = asArray(msg.reactions).map(asRecord)
4483
4484
  .filter((r) => asArray(r.users).includes(self?.userId ?? "\u0000"))
4484
4485
  .map((r) => ASK_KEYCAPS.findIndex((k) => k.name === r.name))
@@ -4505,14 +4506,14 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4505
4506
  targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, seedMarker, pressed, ...(unreadable ? { unreadable } : {}) });
4506
4507
  }
4507
4508
  if (!targets.length) {
4508
- console.error(refused ? "無効化できる質問がありません。" : "何もすることがありません。");
4509
+ console.error(refused ? "No questions can be voided." : "Nothing to do.");
4509
4510
  process.exit(refused ? ASK_EXIT_ERROR : 0);
4510
4511
  }
4511
4512
 
4512
4513
  const code = safetyCode("ask-void", ...targets.map((t) => `${t.channelId}:${t.ts}`), args.reason, args.supersededBy ?? "", self?.userId ?? "");
4513
4514
  if (args.code !== code) {
4514
4515
  requireCode(args.code, code, [
4515
- `--- 無効化 (void) -------------------------------`,
4516
+ `--- Void -----------------------------------------`,
4516
4517
  fromLine(self, { asBot: args.asBot }),
4517
4518
  ` Reason: ${args.reason ? stripTerminalControls(args.reason) : "(none)"}`,
4518
4519
  ...(args.supersededBy ? [` Replaced: ${stripTerminalControls(args.supersededBy)}`] : []),
@@ -4567,7 +4568,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
4567
4568
 
4568
4569
  const got = await askFetchAt(token, { channelId, ts, threadTs }, args.cookie);
4569
4570
  if (!got.msg) {
4570
- console.error(`Error: そのメッセージが見つかりません: ${stripTerminalControls(args.link)}`);
4571
+ console.error(`Error: message not found: ${stripTerminalControls(args.link)}`);
4571
4572
  process.exit(ASK_EXIT_ERROR);
4572
4573
  }
4573
4574
  const msg = got.msg;
@@ -4575,12 +4576,12 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
4575
4576
  const { text, parsed, audience, broadcast, askerUserId, askerBotId } = q;
4576
4577
  if (parsed.kind === "other") {
4577
4578
  console.error(
4578
- `Error: これは \`slack ask\` の質問として読み取れません。\n` +
4579
- ` 理由: ${askExplainReject(text)}\n` +
4579
+ `Error: this cannot be read as a \`slack ask\` question.\n` +
4580
+ ` Reason: ${askExplainReject(text)}\n` +
4580
4581
  ` ${stripTerminalControls(args.link)}\n` +
4581
4582
  // The pills are still readable by hand, so a failure here is not a dead
4582
4583
  // end — say so, because the reported cost was not knowing that.
4583
- ` (押されたリアクションは残っています: slack read '${stripTerminalControls(args.link)}' で確認できます)`,
4584
+ ` (the pressed reactions are still there: check with slack read '${stripTerminalControls(args.link)}')`,
4584
4585
  );
4585
4586
  process.exit(ASK_EXIT_ERROR);
4586
4587
  }
@@ -4612,7 +4613,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
4612
4613
  // What must not come back is the reply that IS the answer. The ✅ stamp says
4613
4614
  // whether the answer was a reply, and which number it picked: the first
4614
4615
  // audience reply before the ✅ edit that reads as that answer is it.
4615
- console.error(`✓ 回答済み: ${stripTerminalControls(parsed.question)}`);
4616
+ console.error(`✓ Answered: ${stripTerminalControls(parsed.question)}`);
4616
4617
  const editedTs = typeof asRecord(msg.edited).ts === "string" ? String(asRecord(msg.edited).ts) : undefined;
4617
4618
  const how = askResolvedHow(text);
4618
4619
  let answerSkipped = false;
@@ -4645,12 +4646,12 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
4645
4646
  }
4646
4647
 
4647
4648
  if (!audience.size && !broadcast) {
4648
- console.error(`Error: この質問は誰にも宛てられていないため、有効な回答者を判定できません: ${stripTerminalControls(args.link)}`);
4649
+ console.error(`Error: this question is addressed to nobody, so valid answerers cannot be determined: ${stripTerminalControls(args.link)}`);
4649
4650
  process.exit(ASK_EXIT_ERROR);
4650
4651
  }
4651
4652
 
4652
4653
  if (args.timeout > 0) {
4653
- console.error(` 回答を待っています (最大 ${args.timeout}s)… Ctrl-C で中断`);
4654
+ console.error(` Waiting for an answer (up to ${args.timeout}s)… Ctrl-C to stop`);
4654
4655
  }
4655
4656
  await askWaitForAnswer(token, askWaitCtxFrom(q, parsed, { channelId, ts, threadTs }, args));
4656
4657
  }
@@ -4855,9 +4856,9 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
4855
4856
  fromLine(self, { asBot: args.asBot }),
4856
4857
  ` → ${dest}${threadTs ? " — THREAD REPLY" : " — NEW top-level message"}`,
4857
4858
  ` Question: ${question}`,
4858
- ` Rule: ${multi ? "1 人 何票でも (--multi)" : "1 人 1 票"}${until ? ` — 締切 ${until}` : ""}`,
4859
+ ` Rule: ${multi ? "any number of votes per person (--multi)" : "one vote per person"}${until ? ` — closes ${until}` : ""}`,
4859
4860
  // Said out loud at the gate: this is the surprising half of the command.
4860
- ` Note: あなた自身の票は数えません (ピルはあなたが付けた種なので区別できません)`,
4861
+ ` Note: your own votes are not counted (the pills are seeds you added, so they can't be told apart)`,
4861
4862
  `--------------------------------────────────`,
4862
4863
  ], recipientTz);
4863
4864
  }
@@ -4881,8 +4882,8 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
4881
4882
  const why = e instanceof Error ? e.message : String(e);
4882
4883
  // A missing seed costs a voter a trip to the emoji picker; it does not
4883
4884
  // invalidate a poll that is already posted.
4884
- if (name === POLL_MARKER) console.error(` (marker ${name} を付けられませんでした: ${why})`);
4885
- else console.error(` (リアクション ${name} を付けられませんでした: ${why})`);
4885
+ if (name === POLL_MARKER) console.error(` (could not add marker ${name}: ${why})`);
4886
+ else console.error(` (could not add reaction ${name}: ${why})`);
4886
4887
  },
4887
4888
  );
4888
4889
 
@@ -4896,7 +4897,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
4896
4897
  console.error(`✓ Posted: ${shown}`);
4897
4898
  attr.record({ team: self?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
4898
4899
  console.log(pollResultsCommand(shown));
4899
- console.error(` 締め切る: slack poll --close='${shown}'${args.asBot ? " --as-bot" : ""}`);
4900
+ console.error(` To close: slack poll --close='${shown}'${args.asBot ? " --as-bot" : ""}`);
4900
4901
  }
4901
4902
 
4902
4903
  /** Locate + read back one posted poll. Shared by `--results` and `--close`,
@@ -4935,7 +4936,7 @@ async function pollFetch(token: string, link: string, cookie?: string): Promise<
4935
4936
  msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
4936
4937
  }
4937
4938
  if (!msg) {
4938
- console.error(`Error: そのメッセージが見つかりません: ${stripTerminalControls(link)}`);
4939
+ console.error(`Error: message not found: ${stripTerminalControls(link)}`);
4939
4940
  process.exit(POLL_EXIT_ERROR);
4940
4941
  }
4941
4942
  return { channelId, ts, msg };
@@ -4959,7 +4960,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
4959
4960
  const parsed = pollParseMessage(text);
4960
4961
  if (parsed.kind === "other") {
4961
4962
  console.error(
4962
- `Error: これは \`slack poll\` の投票ではありません (票の数え方が分かりません)。\n` +
4963
+ `Error: this is not a \`slack poll\` poll (don't know how to count its votes).\n` +
4963
4964
  ` ${stripTerminalControls(args.link)}`,
4964
4965
  );
4965
4966
  process.exit(POLL_EXIT_ERROR);
@@ -4967,7 +4968,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
4967
4968
  if (parsed.kind === "closed") {
4968
4969
  // Already closed: report what was RECORDED, never a fresh count. A pill
4969
4970
  // pressed after closing would otherwise silently change a settled result.
4970
- console.error(`✓ 投票終了: ${stripTerminalControls(parsed.question)}`);
4971
+ console.error(`✓ Poll closed: ${stripTerminalControls(parsed.question)}`);
4971
4972
  for (const line of pollFormatTallies(parsed.tallies)) console.log(line);
4972
4973
  return;
4973
4974
  }
@@ -4980,10 +4981,10 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
4980
4981
  const reactions = await reactionsGet(token, channelId, ts, args.cookie);
4981
4982
  const counted = pollTally(parsed.choices, reactions, exclude, parsed.multi);
4982
4983
 
4983
- console.error(`${stripTerminalControls(parsed.question)}${parsed.deadline ? ` (締切 ${parsed.deadline})` : ""}`);
4984
+ console.error(`${stripTerminalControls(parsed.question)}${parsed.deadline ? ` (closes ${parsed.deadline})` : ""}`);
4984
4985
  for (const line of pollFormatTallies(counted.tallies)) console.log(line);
4985
4986
  const total = counted.tallies.reduce((n, t) => n + t.count, 0);
4986
- console.error(` 計 ${total} 票${parsed.multi ? " (複数選択可)" : ""}`);
4987
+ console.error(` ${total} vote(s) total${parsed.multi ? " (multiple choice)" : ""}`);
4987
4988
  if (args.names) {
4988
4989
  for (let i = 0; i < counted.voters.length; i++) {
4989
4990
  if (!counted.voters[i]!.length) continue;
@@ -5002,8 +5003,8 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
5002
5003
  await editMessage(args.writeToken ?? token, channelId, ts, nextText, args.writeToken ? args.writeCookie : args.cookie, true);
5003
5004
  } catch (e: unknown) {
5004
5005
  console.error(
5005
- ` (投票文に注意書きを入れられませんでした: ${e instanceof Error ? e.message : String(e)})\n` +
5006
- ` メッセージを書き換えられるのは投稿者だけです — bot が投稿した投票なら --as-bot を付けてください。`,
5006
+ ` (could not add the notice to the poll text: ${e instanceof Error ? e.message : String(e)})\n` +
5007
+ ` Only the poster can rewrite a message — if the bot posted the poll, add --as-bot.`,
5007
5008
  );
5008
5009
  }
5009
5010
  }
@@ -5011,7 +5012,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
5011
5012
  // Not counted for anything, and said so out loud: a reaction carries no
5012
5013
  // timestamp anywhere in the Slack API, so "the latest one" does not exist.
5013
5014
  const names = await Promise.all(counted.ambiguous.map((u) => userName(token, u, args.cookie)));
5014
- console.error(`⚠ 1 人 1 票なのに複数押している人がいます (無効票): ${names.map((n) => `@${stripTerminalControls(n)}`).join(", ")}`);
5015
+ console.error(`⚠ One vote per person, but some people pressed several (invalid votes): ${names.map((n) => `@${stripTerminalControls(n)}`).join(", ")}`);
5015
5016
  }
5016
5017
 
5017
5018
  if (!args.close) return;
@@ -5028,7 +5029,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
5028
5029
  } catch {
5029
5030
  // Cosmetic: the 📊 body is what makes it closed.
5030
5031
  }
5031
- console.error(`✓ 投票を締め切りました: ${stripTerminalControls(args.link)}`);
5032
+ console.error(`✓ Closed the poll: ${stripTerminalControls(args.link)}`);
5032
5033
  }
5033
5034
 
5034
5035
  async function cmdDoctor(token: string): Promise<void> {
@@ -5851,7 +5852,19 @@ async function main(): Promise<void> {
5851
5852
  await cmdTodoLs(tok(argv), opts);
5852
5853
  };
5853
5854
 
5854
- await yargs(hideBin(process.argv))
5855
+ // `slack @<who> <command> …`: act as a named identity (ts/identity.ts).
5856
+ const { who, rest: cliArgs } = splitWho(hideBin(process.argv));
5857
+ let whoKind: "bot" | "user" | undefined;
5858
+ if (who) {
5859
+ const r = applyIdentity(who);
5860
+ if ("error" in r) {
5861
+ console.error(`Error: ${r.error}`);
5862
+ process.exit(2);
5863
+ }
5864
+ whoKind = r.kind;
5865
+ }
5866
+
5867
+ await yargs(cliArgs)
5855
5868
  .scriptName("slack")
5856
5869
  // A thrown handler error (e.g. a Slack API failure) is a runtime error, not a usage
5857
5870
  // mistake — print just the clean one-line message, never the command's --help block or a
@@ -5869,8 +5882,15 @@ async function main(): Promise<void> {
5869
5882
  process.exit(1);
5870
5883
  })
5871
5884
  .option("workspace", { alias: "w", type: "string", describe: "Workspace name" })
5885
+ .option("as-user", { type: "boolean", describe: "Act as the user session on purpose (needed for writes from inside an agent, or when several identities exist)" })
5872
5886
  .middleware(async (argv) => {
5873
5887
  const cmd = String((argv._ ?? [])[0] ?? "");
5888
+ // `@bot`: commands that have --as-bot act as the bot; `@user`: never.
5889
+ if (whoKind === "bot" && "as-bot" in argv) { argv["as-bot"] = true; (argv as Record<string, unknown>).asBot = true; }
5890
+ if (whoKind === "user" && argv["as-bot"] === true) {
5891
+ console.error(`Error: @${who} is a user identity, but --as-bot was given`);
5892
+ process.exit(2);
5893
+ }
5874
5894
  // `sent` reads only the local log — no token, no network. `relay` writes
5875
5895
  // local files (init) or talks to the user's own relay (health): no Slack token.
5876
5896
  if (!cmd || cmd === "auth" || cmd === "login" || cmd === "agent" || cmd === "sent" || cmd === "relay") return;
@@ -5891,6 +5911,17 @@ async function main(): Promise<void> {
5891
5911
  console.error(refusal);
5892
5912
  process.exit(2);
5893
5913
  }
5914
+ const user = userSessionRefusal({
5915
+ isWrite: isWriteCommand(argv._ ?? []) && !isReadMode(argv._ ?? [], argv as Record<string, unknown>),
5916
+ who, asBot: argv["as-bot"] === true, asUser: argv["as-user"] === true,
5917
+ activeIsBot: active.startsWith("xoxb-"),
5918
+ agent: inAgent(), identities: [...listIdentities().keys()].sort(),
5919
+ cmd: (argv._ ?? []).slice(0, 2).join(" "),
5920
+ });
5921
+ if (user) {
5922
+ console.error(user);
5923
+ process.exit(2);
5924
+ }
5894
5925
  } catch (e) {
5895
5926
  const msg = e instanceof Error ? e.message : String(e);
5896
5927
  if (msg.startsWith("No profiles configured")) {
@@ -6709,7 +6740,7 @@ async function main(): Promise<void> {
6709
6740
  )
6710
6741
  .command(
6711
6742
  "ask [target] [question] [choices..]",
6712
- "Ask a question with its choices pre-seeded as 1️⃣..🔟 reactions (confirm-hash safety gate). Lifecycle: --waitFor=<link> collect, --void=<link> 無効化 (verb aliases: `ask collect <link>`, `ask void <link…>`)",
6743
+ "Ask a question with its choices pre-seeded as 1️⃣..🔟 reactions (confirm-hash safety gate). Lifecycle: --waitFor=<link> collect, --void=<link> void (verb aliases: `ask collect <link>`, `ask void <link…>`)",
6713
6744
  (y) => y
6714
6745
  .positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --waitFor)" })
6715
6746
  .positional("question", { type: "string", describe: "The question. Must @tag whoever may answer (@alice), or the whole channel (@here / @channel / @everyone) — only their answer counts. In a 1:1 DM the other party counts automatically." })
@@ -6718,10 +6749,10 @@ async function main(): Promise<void> {
6718
6749
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
6719
6750
  .option("body", { type: "string", describe: "Extra context shown under the question" })
6720
6751
  .option("lang", { type: "string", choices: [...ASK_LANGS], describe: "Language of the instructions in the posted body. Default: $SLACK_TERM_LANG, else the answerers' Slack locale, else the question's own language, else the system locale, else ja" })
6721
- .option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = a reply matched several choices (stdout empty), 5 = a free-text reply that picked none of the choices (the reply is on stdout; NOT a decision — the question stays open), 6 = the question was voided (無効化, `slack ask void`) — stop waiting. 7 = only with --explain-exit: ℹ️ pressed, explain and collect again (the question stays open)." })
6752
+ .option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = a reply matched several choices (stdout empty), 5 = a free-text reply that picked none of the choices (the reply is on stdout; NOT a decision — the question stays open), 6 = the question was voided (`slack ask void`) — stop waiting. 7 = only with --explain-exit: ℹ️ pressed, explain and collect again (the question stays open)." })
6722
6753
  .option("waitFor", { type: "string", describe: "Collect the answer to a question already posted: pass its permalink. Nothing is posted (except, on an --explain question someone pressed ℹ️ on, one thread note per question). Same stdout/exit contract as --wait; --timeout 0 checks once and exits 2 if still open." })
6723
6754
  .option("after", { type: "string", describe: "With --waitFor: the cursor — everything at or before this ts was already delivered. Text replies there are neither answers nor notes again (pass the `cursor` a previous --json result gave you, or the reply ts an exit 5 printed). Reactions still count." })
6724
- .option("void", { type: "string", array: true, describe: "無効化: void question(s) you posted that expired or stopped meaning anything — --void=<permalink>, repeatable. The text becomes 🚫 with --reason (body kept, options removed), our pills come off, and --wait/--waitFor on it exits 6. Same --code gate. Only your own questions (--as-bot for the bot's). Alias: `slack ask void <link…>`." })
6755
+ .option("void", { type: "string", array: true, describe: "Void question(s) you posted that expired or stopped meaning anything — --void=<permalink>, repeatable. The text becomes 🚫 with --reason (body kept, options removed), our pills come off, and --wait/--waitFor on it exits 6. Same --code gate. Only your own questions (--as-bot for the bot's). Alias: `slack ask void <link…>`." })
6725
6756
  .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." })
6726
6757
  .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." })
6727
6758
  .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." })
@@ -6847,7 +6878,7 @@ async function main(): Promise<void> {
6847
6878
  const { token, cookie } = askTokenFor(!!argv["as-bot"]);
6848
6879
  const self = await selfIdentity(token, cookie);
6849
6880
  const t = await askLoadOwnOpen(token, voidFlag[0]!, self, !!argv["as-bot"], cookie);
6850
- if (t === "void") { console.error(`✗ ${stripTerminalControls(voidFlag[0]!)}: すでに無効化済みです — 新しく聞くなら普通に slack ask で`); process.exit(ASK_EXIT_ERROR); }
6881
+ if (t === "void") { console.error(`✗ ${stripTerminalControls(voidFlag[0]!)}: already voided — to ask again, just use slack ask`); process.exit(ASK_EXIT_ERROR); }
6851
6882
  if (!t) process.exit(ASK_EXIT_ERROR);
6852
6883
  voidAfter = { ...t, reason: unescapeArg(String(argv.reason ?? "")).trim() };
6853
6884
  // Same place, same words, unless new ones were given. The create path
@@ -7046,7 +7077,7 @@ async function main(): Promise<void> {
7046
7077
  .positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --results/--close)" })
7047
7078
  .positional("question", { type: "string", describe: "The question. @tags are optional here — unlike `ask`, a poll needs no addressee: anyone who can see it may vote." })
7048
7079
  .positional("choices", { type: "string", array: true, describe: "2..10 choices, seeded as 1️⃣..🔟 reactions for voters to click" })
7049
- .option("multi", { type: "boolean", default: false, describe: "Let one person pick several choices (default: 1 人 1 票)" })
7080
+ .option("multi", { type: "boolean", default: false, describe: "Let one person pick several choices (default: one vote per person)" })
7050
7081
  .option("until", { type: "string", describe: "Deadline shown in the message (display only — closing is still `--close`)" })
7051
7082
  .option("results", { type: "string", describe: "Count an existing poll: pass its permalink. Posts nothing; only edit is the invalid-ballot notice in the poll itself (--no-notify to suppress)." })
7052
7083
  .option("close", { type: "string", describe: "Count an existing poll AND freeze the tally into the message. Only the poster can do this." })
@@ -8299,7 +8330,8 @@ async function main(): Promise<void> {
8299
8330
  .option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
8300
8331
  .option("relay", { type: "boolean", default: true, describe: "Use the doorbell relay (`slack relay init`) when SLACK_RELAY_URL and SLACK_RELAY_TOKEN are set; --no-relay polls only" })
8301
8332
  .option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" })
8302
- .option("inbox", { type: "boolean", default: false, describe: "Also write each match into the inbox/ of every .slack-term/ that subscribes its thread (redacted, text-only)" }),
8333
+ .option("inbox", { type: "boolean", default: false, describe: "Also write each match into the inbox/ of every .slack-term/ that subscribes its thread (redacted, text-only)" })
8334
+ .option("reactions", { type: "boolean", default: false, describe: "Relay only: also print reaction_added bells as {type: \"reaction\", channel, ts, user} (the emoji: reactions.get)" }),
8303
8335
  async (argv) => {
8304
8336
  let re: RegExp;
8305
8337
  let reconcileMs: number;
@@ -8415,6 +8447,7 @@ async function main(): Promise<void> {
8415
8447
  selfUsers,
8416
8448
  selfBots,
8417
8449
  json: argv.json,
8450
+ reactions: argv.reactions,
8418
8451
  ...(argv.inbox ? {
8419
8452
  onMatch: (rec: StreamMatch, files: number) => {
8420
8453
  try {