slack-term 1.49.0 → 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";
@@ -477,7 +478,13 @@ async function cmdThread(token: string, target: string, ts: string, limit: numbe
477
478
  let msgs = all
478
479
  ? await wholeThread(token, channelId, parseInputTs(ts), cookie)
479
480
  : asArray(((await replies(token, channelId, parseInputTs(ts), limit, cookie)) as Record<string, Json>).messages).map(asRecord);
480
- if (since !== undefined) msgs = msgs.filter((m) => Number(m.ts) > Number(since));
481
+ if (since !== undefined) {
482
+ // The same ts forms the thread argument takes (Slack ts or the ISO stamp
483
+ // `read` prints); anything else would compare as NaN and drop every reply.
484
+ const after = Number(parseInputTs(since));
485
+ if (!Number.isFinite(after)) throw new Error(`--since needs a message ts (1700000000.000100) or ISO stamp (got ${stripTerminalControls(since)})`);
486
+ msgs = msgs.filter((m) => Number(m.ts) > after);
487
+ }
481
488
  if (format === "tsv") {
482
489
  for (const m of msgs) console.log(tsvLine(m));
483
490
  return;
@@ -2156,6 +2163,7 @@ async function cmdPinlogUpdate(token: string, args: PinlogUpdateArgs): Promise<v
2156
2163
  try {
2157
2164
  const back = asArray(asRecord((await replies(token, channel, ts, 1, args.cookie)) as Json).messages).map(asRecord);
2158
2165
  stored = back.find((m) => m.ts === ts);
2166
+ if (!stored) console.error(`⚠ HEAD updated, but the read-back did not return it, so it was not checked`);
2159
2167
  } catch (e: unknown) {
2160
2168
  console.error(`⚠ HEAD updated, but it could not be read back to check: ${friendlySlackError(e)}`);
2161
2169
  }
@@ -2627,17 +2635,17 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
2627
2635
  // a ts split, and `--channel-id` makes the leading "#name" irrelevant to
2628
2636
  // resolution, so it never depends on ref's original prefix or an
2629
2637
  // embedded thread_ts colliding with the appended message ts.
2630
- let editCmd = `slack edit "#${channelId}:${lastMsgTs}" "<新しい本文>" --channel-id ${channelId}`;
2638
+ let editCmd = `slack edit "#${channelId}:${lastMsgTs}" "<new text>" --channel-id ${channelId}`;
2631
2639
  try {
2632
2640
  const permalink = await getPermalink(token, channelId, lastMsgTs, cookie);
2633
- if (permalink) editCmd = `slack edit "${permalink}" "<新しい本文>"`;
2641
+ if (permalink) editCmd = `slack edit "${permalink}" "<new text>"`;
2634
2642
  } catch {
2635
2643
  // fall back to the --channel-id form above
2636
2644
  }
2637
- console.error(`⚠ 相手はまだ返信していません(最後のメッセージはあなたのものです)。`);
2638
- 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:`);
2639
2647
  console.error(` ${editCmd}`);
2640
- console.error(` このまま送信する場合は --code=XXXX で確定。`);
2648
+ console.error(` To send anyway, confirm with --code=XXXX.`);
2641
2649
  }
2642
2650
  } catch {
2643
2651
  // best-effort; a failed self-identity lookup must not block the send
@@ -2993,7 +3001,7 @@ async function askCollectNotes(token: string, sc: AskNoteScope, after: string |
2993
3001
  const rep = asRecord((await replies(token, sc.channelId, sc.threadParentTs ?? sc.ts, 100, sc.cookie)) as Json);
2994
3002
  for (const m of asArray(rep.messages).map(asRecord)) pool.set(String(m.ts), m);
2995
3003
  } catch (e: unknown) {
2996
- 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)})`);
2997
3005
  }
2998
3006
  }
2999
3007
  if (!sc.threadOnly && !sc.threadParentTs) {
@@ -3006,7 +3014,7 @@ async function askCollectNotes(token: string, sc: AskNoteScope, after: string |
3006
3014
  .reduce((min, m) => Math.min(min, Number(m.ts)), Infinity);
3007
3015
  for (const m of msgs) if (Number(m.ts) < bound && !pool.has(String(m.ts))) pool.set(String(m.ts), m);
3008
3016
  } catch (e: unknown) {
3009
- 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)})`);
3010
3018
  }
3011
3019
  }
3012
3020
  const picked = [...pool.values()]
@@ -3060,7 +3068,7 @@ async function askEmit(
3060
3068
  return;
3061
3069
  }
3062
3070
  if (r.notes.length) {
3063
- console.error(` スレッドの追記 ${r.notes.length} 件 (回答とは別):`);
3071
+ console.error(` ${r.notes.length} thread note(s) (separate from the answer):`);
3064
3072
  for (const n of r.notes) {
3065
3073
  let who = n.user;
3066
3074
  try {
@@ -3074,7 +3082,7 @@ async function askEmit(
3074
3082
  }
3075
3083
  }
3076
3084
  if (r.status === "answered") {
3077
- console.error(` この後の追記を拾う: ${askResumeCommand(o.shown, o.asBot, r.cursor)}`);
3085
+ console.error(` To pick up later notes: ${askResumeCommand(o.shown, o.asBot, r.cursor)}`);
3078
3086
  }
3079
3087
  if (r.answer !== null) console.log(r.answer);
3080
3088
  }
@@ -3171,7 +3179,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
3171
3179
  await editMessage(token, channelId, ts, next, cookie, true);
3172
3180
  } catch (e: unknown) {
3173
3181
  // Cosmetic: the poller still refuses to guess, and stderr already said so.
3174
- 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)})`);
3175
3183
  }
3176
3184
  }
3177
3185
 
@@ -3188,7 +3196,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
3188
3196
  for (const u of asArray(r.users).filter(isAnswerer)) {
3189
3197
  if (otherReported.has(u)) continue;
3190
3198
  otherReported.add(u);
3191
- if (!readOnly) console.error(` (<@${u}> が ${glyph} その他 を押しました。返信を待ちます)`);
3199
+ if (!readOnly) console.error(` (<@${u}> pressed ${glyph} other; waiting for their reply)`);
3192
3200
  }
3193
3201
  }
3194
3202
  }
@@ -3242,14 +3250,14 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
3242
3250
  if (++pages >= ASK_EXPLAIN_NOTE_MAX_PAGES) return "exists";
3243
3251
  }
3244
3252
  } catch (e: unknown) {
3245
- 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)})`);
3246
3254
  return "unread";
3247
3255
  }
3248
3256
  try {
3249
3257
  await slackSend(token, channelId, copy.explainNote, root, false, cookie, true);
3250
3258
  } catch (e: unknown) {
3251
3259
  // The press is still reported on stderr; the note is a courtesy.
3252
- 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)})`);
3253
3261
  return "failed";
3254
3262
  }
3255
3263
  return "posted";
@@ -3262,7 +3270,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
3262
3270
  now.push(u);
3263
3271
  if (explainReported.has(u)) continue;
3264
3272
  explainReported.add(u);
3265
- 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)`);
3266
3274
  }
3267
3275
  state.explainNow = now;
3268
3276
  // Read-only (`--pending`): the press is reported, the note is not posted —
@@ -3299,8 +3307,8 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
3299
3307
  for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
3300
3308
  const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
3301
3309
  if (!readOnly) console.error(multi.length
3302
- ? ` (${glyphs} が同時に選ばれています。1 つに絞ってもらうまで待ちます)`
3303
- : ` (回答が分かれています: ${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)`);
3304
3312
  // Whoever is on more than one pill — and ONLY them. Naming everyone on any
3305
3313
  // pill told a voter who had pressed exactly one that she had pressed two
3306
3314
  // (2026-10-01: a voter on 1️⃣ only was named because someone else was on 1️⃣
@@ -3460,7 +3468,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3460
3468
  }
3461
3469
  // Who answered never reaches stdout — callers do ANS=$(slack ask --wait ...)
3462
3470
  // and must keep getting the answer body alone.
3463
- if (who) console.error(` 回答者: ${stripTerminalControls(who)}`);
3471
+ if (who) console.error(` Answered by: ${stripTerminalControls(who)}`);
3464
3472
  try {
3465
3473
  // The body and the chosen option stay; only what no longer applies goes.
3466
3474
  // Built from the message as it is NOW (an edit made in Slack after asking
@@ -3487,7 +3495,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3487
3495
  // The edit is bookkeeping, not the answer. It fails whenever the resuming
3488
3496
  // token is not the one that posted (Slack lets only the author update), and
3489
3497
  // that must not cost the caller an answer it already has.
3490
- 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)})`);
3491
3499
  }
3492
3500
  }
3493
3501
 
@@ -3512,10 +3520,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3512
3520
  // A dropped poll is expected occasionally; only give up if it keeps failing.
3513
3521
  errors++;
3514
3522
  if (errors >= ASK_MAX_CONSECUTIVE_ERRORS) {
3515
- 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)}`);
3516
3524
  process.exit(ASK_EXIT_ERROR);
3517
3525
  }
3518
- 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)})`);
3519
3527
  }
3520
3528
  }
3521
3529
  unchosen = state.unchosen;
@@ -3558,7 +3566,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3558
3566
  // only on the run that posted the thread note, so a `--timeout 0` poller is
3559
3567
  // woken once per question, not on every look.
3560
3568
  if (state.explainPosted && ctx.explainExit) {
3561
- 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)}`);
3562
3570
  if (ctx.json) await askEmit(token, ctx, { status: "explain", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3563
3571
  process.exit(ASK_EXIT_EXPLAIN);
3564
3572
  }
@@ -3574,10 +3582,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3574
3582
  } catch {
3575
3583
  // the id is still a usable answer to "who"
3576
3584
  }
3577
- console.error(` (${stripTerminalControls(who)} が選択肢外の返信をしました — 決定ではありません。本文を stdout に出します)`);
3578
- 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(" ")}`);
3579
3587
  console.error(` ${shown}`);
3580
- 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)}`);
3581
3589
  // Delivered, not collected: `--pending` will not report THIS reply again,
3582
3590
  // but still reports a pill pressed after it.
3583
3591
  markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
@@ -3601,8 +3609,8 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3601
3609
  // the id is still a usable answer to "who"
3602
3610
  }
3603
3611
  // Only the ambiguous case gets here — a plain free-text reply exited above.
3604
- console.error(` (${stripTerminalControls(who)} の返信は複数の選択肢に一致します。1 つに絞ってもらうまで待ちます)`);
3605
- 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)}"`);
3606
3614
  }
3607
3615
  // Checked after the first poll, never before it: `--timeout 0` means one
3608
3616
  // look, and one look must still be a real look.
@@ -3623,20 +3631,20 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3623
3631
  // something to act on.
3624
3632
  if (unchosen) {
3625
3633
  // Ambiguous only: a plain free-text reply was delivered as exit 5 already.
3626
- console.error(`Error: 返信はありましたが、複数の選択肢に一致するため確定できません (メッセージはそのまま残っています)`);
3627
- console.error(` 返信: 「${stripTerminalControls(unchosen.text)}」`);
3628
- 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(" ")}`);
3629
3637
  console.error(` ${shown}`);
3630
- console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3638
+ console.error(` Reply in the thread, then collect with: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3631
3639
  if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3632
3640
  process.exit(ASK_EXIT_UNCHOSEN);
3633
3641
  }
3634
3642
  if (timeout === 0) {
3635
- console.error(`まだ回答がありません: ${shown}`);
3643
+ console.error(`No answer yet: ${shown}`);
3636
3644
  } else {
3637
- console.error(`Error: ${timeout}s 以内に回答がありませんでした (メッセージはそのまま残っています)`);
3645
+ console.error(`Error: no answer within ${timeout}s (the message is left in place)`);
3638
3646
  console.error(` ${shown}`);
3639
- console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3647
+ console.error(` To collect later: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
3640
3648
  }
3641
3649
  if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
3642
3650
  process.exit(ASK_EXIT_TIMEOUT);
@@ -3820,7 +3828,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3820
3828
  ` Answerable by: ${audienceLabel}`,
3821
3829
  ` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
3822
3830
  ...(explain ? [` Explain: ℹ️ pill offered — pressing it asks you to explain; it is never an answer`] : []),
3823
- ...(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`] : []),
3824
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)`,
3825
3833
  `--------------------------------────────────`,
3826
3834
  ], recipientTz);
@@ -3857,8 +3865,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3857
3865
  const why = e instanceof Error ? e.message : String(e);
3858
3866
  // The marker is cosmetic + searchability; a keycap only costs the
3859
3867
  // answerer a trip to the emoji picker. Neither abandons a posted question.
3860
- if (name === seedMarker) console.error(` (marker ${name} を付けられませんでした: ${why})`);
3861
- 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})`);
3862
3870
  },
3863
3871
  );
3864
3872
 
@@ -3875,7 +3883,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3875
3883
  // two open questions, which is reported loudly — never a missing one.
3876
3884
  if (args.voidAfter) {
3877
3885
  const ok = await askApplyVoid(token, args.voidAfter, args.voidAfter.reason, shown, cookie);
3878
- 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}'`);
3879
3887
  }
3880
3888
 
3881
3889
  if (!args.wait) {
@@ -3889,7 +3897,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3889
3897
  }
3890
3898
 
3891
3899
  const timeout = args.timeout ?? 3600;
3892
- console.error(` 回答を待っています (最大 ${timeout}s)… Ctrl-C で中断`);
3900
+ console.error(` Waiting for an answer (up to ${timeout}s)… Ctrl-C to stop`);
3893
3901
 
3894
3902
  const ctx: AskWaitCtx = {
3895
3903
  channelId,
@@ -3920,9 +3928,9 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3920
3928
 
3921
3929
  /** Say on stderr that a question is void, and where its replacement is. */
3922
3930
  function askReportVoid(p: { reason: string; supersededBy?: string }, shown: string): void {
3923
- console.error(`この質問は無効です — 回答されることはありません。待つのをやめてください: ${stripTerminalControls(shown)}`);
3931
+ console.error(`This question is void — it will never be answered. Stop waiting on it: ${stripTerminalControls(shown)}`);
3924
3932
  if (p.reason) console.error(` ${stripTerminalControls(p.reason)}`);
3925
- if (p.supersededBy) console.error(` 新しい質問: ${stripTerminalControls(p.supersededBy)}`);
3933
+ if (p.supersededBy) console.error(` New question: ${stripTerminalControls(p.supersededBy)}`);
3926
3934
  }
3927
3935
 
3928
3936
  /** Find the one message a permalink (or `C…:ts`) names. A question posted into
@@ -3977,20 +3985,20 @@ async function askLoadOwnOpen(token: string, link: string, self: Self | null, as
3977
3985
  Promise<(AskVoidTarget & { msg: Record<string, Json>; threadTs?: string }) | "void" | null> {
3978
3986
  const shown = stripTerminalControls(link);
3979
3987
  const got = await askFetchByLink(token, link, cookie);
3980
- if (!got) { console.error(`✗ ${shown}: permalink (または 'C…:1700000000.000100') ではありません`); return null; }
3981
- 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; }
3982
3990
  const msg = got.msg;
3983
3991
  const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
3984
3992
  if (!own) {
3985
- console.error(`✗ ${shown}: 自分(${senderLabel(self, asBot)})が投稿した質問ではありません` +
3986
- `${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"}`);
3987
3995
  return null;
3988
3996
  }
3989
3997
  const text = typeof msg.text === "string" ? msg.text : "";
3990
3998
  const parsed = askParseMessage(text);
3991
3999
  if (parsed.kind === "void") return "void";
3992
- if (parsed.kind === "resolved") { console.error(`✗ ${shown}: 回答済みの質問です(回答: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})— 変更できません`); return null; }
3993
- 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; }
3994
4002
  const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, seedMarker: askSeedMarker(text) ?? ASK_MARKER, msg };
3995
4003
  if (got.threadTs) out.threadTs = got.threadTs;
3996
4004
  return out;
@@ -4034,7 +4042,7 @@ async function cmdAskEdit(token: string, args: {
4034
4042
  }): Promise<void> {
4035
4043
  const self = await selfIdentity(token, args.cookie);
4036
4044
  const t = await askLoadOwnOpen(token, args.link, self, args.asBot, args.cookie);
4037
- 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); }
4038
4046
  if (!t) process.exit(ASK_EXIT_ERROR);
4039
4047
  const old = t.parsed;
4040
4048
  const encode = async (raw: string): Promise<string> => {
@@ -4050,8 +4058,8 @@ async function cmdAskEdit(token: string, args: {
4050
4058
  const choicesChanged = JSON.stringify(choices.map(askFlatten)) !== JSON.stringify(oldChoices.map(askFlatten));
4051
4059
  if (choicesChanged && await askHasResponses(token, t, self, args.cookie)) {
4052
4060
  console.error(
4053
- `✗ この質問にはもう回答(リアクションか返信)があります — 選択肢を変えると、押されたリアクションの意味が変わってしまいます。\n` +
4054
- ` 質問文と本文は変えられます。選択肢を変えるなら無効化して出し直してください:\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` +
4055
4063
  ` slack ask --void='${stripTerminalControls(args.link)}' --reask${args.asBot ? " --as-bot" : ""} …`,
4056
4064
  );
4057
4065
  process.exit(ASK_EXIT_ERROR);
@@ -4062,7 +4070,7 @@ async function cmdAskEdit(token: string, args: {
4062
4070
  const tagged = [...`${question}\n${body}`.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].some((m) => m[1] !== self?.userId);
4063
4071
  const addressed = tagged || /<!(?:here|channel|everyone)/.test(`${question}\n${body}`) || t.channelId.startsWith("D");
4064
4072
  if (!addressed) {
4065
- console.error(`✗ 誰にも宛てられていない質問になります — 回答できる人を @ で残してください`);
4073
+ console.error(`✗ The question would be addressed to nobody — keep an @mention of who can answer`);
4066
4074
  process.exit(ASK_EXIT_ERROR);
4067
4075
  }
4068
4076
  const lang = args.lang ? askResolveLang({ flag: args.lang })?.lang ?? old.lang : old.lang;
@@ -4076,7 +4084,7 @@ async function cmdAskEdit(token: string, args: {
4076
4084
  if (invalid.length) next = applyInvalidNotice(next, invalid, lang);
4077
4085
  guardUrlBoundaries(next, args.allowUrlAdjacent);
4078
4086
  if (next === oldText) {
4079
- console.error("変更はありません。");
4087
+ console.error("No changes.");
4080
4088
  return;
4081
4089
  }
4082
4090
 
@@ -4116,7 +4124,7 @@ async function cmdAskEdit(token: string, args: {
4116
4124
  await seedReactionsInOrder(
4117
4125
  [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail(seedMarker)],
4118
4126
  (name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
4119
- (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)})`),
4120
4128
  );
4121
4129
  } else if (oldSeed !== seedMarker) {
4122
4130
  // An old ❓ pill edited without touching its choices: the body now lists
@@ -4126,10 +4134,10 @@ async function cmdAskEdit(token: string, args: {
4126
4134
  await reactionAdd(token, t.channelId, t.ts, seedMarker, args.cookie);
4127
4135
  await reactionRemove(token, t.channelId, t.ts, oldSeed, args.cookie);
4128
4136
  } catch (e: unknown) {
4129
- 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)})`);
4130
4138
  }
4131
4139
  }
4132
- console.error(`✓ 編集しました: ${stripTerminalControls(args.link)}`);
4140
+ console.error(`✓ Edited: ${stripTerminalControls(args.link)}`);
4133
4141
  }
4134
4142
 
4135
4143
  /** `slack ask --ls [--stale <dur>] [--state …] [--channel …] [--json]` — the
@@ -4206,7 +4214,7 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
4206
4214
  for (const bot of [false, true]) {
4207
4215
  const mine = open.filter((it) => it.asBot === bot);
4208
4216
  if (!mine.length) continue;
4209
- 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" : ""}`);
4210
4218
  }
4211
4219
  }
4212
4220
  }
@@ -4272,7 +4280,7 @@ async function cmdAskPending(
4272
4280
  const asBot = !!row.as_bot;
4273
4281
  const token = asBot ? tokens.bot : tokens.user;
4274
4282
  if (!token) {
4275
- 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"}`);
4276
4284
  continue;
4277
4285
  }
4278
4286
  const cookie = asBot ? undefined : tokens.cookie;
@@ -4283,15 +4291,15 @@ async function cmdAskPending(
4283
4291
  try {
4284
4292
  const loc = { channelId: row.channel, ts: row.ts, threadTs: row.thread_ts ?? undefined };
4285
4293
  const got = await askFetchAt(token, loc, cookie);
4286
- if (!got.msg) { problems.push(`${link}: メッセージが見つかりません (削除?)`); continue; }
4294
+ if (!got.msg) { problems.push(`${link}: message not found (deleted?)`); continue; }
4287
4295
  const q = await askReadBack(token, { channelId: row.channel, ts: row.ts, msg: got.msg }, cookie);
4288
4296
  const parsed = q.parsed;
4289
4297
  // ✅ already stamped: some `--wait`/`--waitFor` collected it (another
4290
4298
  // machine, another token, or a build before collection was logged).
4291
4299
  if (parsed.kind === "resolved") { count.resolved++; continue; }
4292
4300
  if (parsed.kind === "void") { count.void++; continue; }
4293
- if (parsed.kind === "other") { problems.push(`${link}: \`slack ask\` の質問として読めません — ${askExplainReject(q.text)}`); continue; }
4294
- 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; }
4295
4303
  const ctx = askWaitCtxFrom(q, parsed, loc, { timeout: 0, link, asBot, json: false, cookie, after });
4296
4304
  const poller = askPoller(token, ctx, true);
4297
4305
  const found = await poller.poll();
@@ -4300,13 +4308,13 @@ async function cmdAskPending(
4300
4308
  const collect = askResumeCommand(link, asBot, after);
4301
4309
  if (poller.state.explainNow.length) {
4302
4310
  count.explain++;
4303
- 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`);
4304
4312
  }
4305
4313
  const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
4306
4314
  if (found) {
4307
4315
  pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
4308
4316
  } else if (unchosen && !unchosen.ambiguous && unchosen.text !== row.delivered_answer) {
4309
- pending.push({ ...base, answer: unchosen.text, how: "返信 (選択肢外)", freeText: true });
4317
+ pending.push({ ...base, answer: unchosen.text, how: "reply (not a choice)", freeText: true });
4310
4318
  } else if (unchosen?.ambiguous) {
4311
4319
  count.ambiguous++;
4312
4320
  } else {
@@ -4344,7 +4352,7 @@ async function cmdAskPending(
4344
4352
  for (const it of items) {
4345
4353
  console.log(` ${stripTerminalControls(it.link)} (${formatYmdHm(it.row.sent_at / 1000)})`);
4346
4354
  console.log(` Q: ${stripTerminalControls(askFlatten(it.question))}`);
4347
- 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" : ""}`);
4348
4356
  console.log(` collect: ${stripTerminalControls(it.collect)}`);
4349
4357
  }
4350
4358
  }
@@ -4352,15 +4360,15 @@ async function cmdAskPending(
4352
4360
  for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
4353
4361
  for (const p of explainAsked) console.error(` (${stripTerminalControls(p)})`);
4354
4362
  for (const [team, n] of otherTeams) {
4355
- 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)`);
4356
4364
  }
4357
4365
  console.error(
4358
- `${pending.length} 件が回答済み・未回収, ${count.unanswered} 件が未回答` +
4359
- (count.ambiguous ? `, ${count.ambiguous} 件は返信が複数の選択肢に一致 (未確定)` : "") +
4360
- (count.resolved ? `, ${count.resolved} 件は ✅ 回収済み` : "") +
4361
- (count.void ? `, ${count.void} 件は無効化済み` : "") +
4362
- (count.explain ? `, ${count.explain} 件は ℹ️ 説明のリクエストあり` : "") +
4363
- ` — ${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()})`,
4364
4372
  );
4365
4373
  }
4366
4374
 
@@ -4384,7 +4392,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
4384
4392
  const next = askBuildVoidText(t.parsed.question, reason, t.parsed.lang, t.parsed.body || undefined, supersededBy);
4385
4393
  await editMessage(token, t.channelId, t.ts, next, cookie, true);
4386
4394
  } catch (e: unknown) {
4387
- console.error(`✗ ${shown}: 書き換えに失敗しました: ${e instanceof Error ? e.message : String(e)}`);
4395
+ console.error(`✗ ${shown}: failed to rewrite: ${e instanceof Error ? e.message : String(e)}`);
4388
4396
  return false;
4389
4397
  }
4390
4398
  for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), t.seedMarker, ...(t.parsed.explain ? [ASK_EXPLAIN_MARKER] : [])]) {
@@ -4393,9 +4401,9 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
4393
4401
  try {
4394
4402
  await reactionAdd(token, t.channelId, t.ts, ASK_VOID_MARKER, cookie);
4395
4403
  } catch (e: unknown) {
4396
- 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)})`);
4397
4405
  }
4398
- console.error(`✓ 無効化: ${shown}`);
4406
+ console.error(`✓ Voided: ${shown}`);
4399
4407
  return true;
4400
4408
  }
4401
4409
 
@@ -4435,11 +4443,11 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4435
4443
  process.exit(ASK_EXIT_ERROR);
4436
4444
  }
4437
4445
  } catch (e: unknown) {
4438
- 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)})`);
4439
4447
  process.exit(ASK_EXIT_ERROR);
4440
4448
  }
4441
4449
  if (!found) {
4442
- console.error(`Error: --superseded-by のメッセージが見つかりません: ${shown}`);
4450
+ console.error(`Error: --superseded-by message not found: ${shown}`);
4443
4451
  process.exit(ASK_EXIT_ERROR);
4444
4452
  }
4445
4453
  }
@@ -4449,21 +4457,21 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4449
4457
  for (const link of args.links) {
4450
4458
  const shown = stripTerminalControls(link);
4451
4459
  const got = await askFetchByLink(token, link, args.cookie);
4452
- if (!got) { console.error(`✗ ${shown}: permalink (または 'C…:1700000000.000100') ではありません`); refused++; continue; }
4453
- 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; }
4454
4462
  const msg = got.msg;
4455
4463
  const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
4456
4464
  if (!own) {
4457
- console.error(`✗ ${shown}: 自分(${senderLabel(self, args.asBot)})が投稿した質問ではないので無効化できません` +
4458
- `${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"}`);
4459
4467
  refused++;
4460
4468
  continue;
4461
4469
  }
4462
4470
  const text = typeof msg.text === "string" ? msg.text : "";
4463
4471
  let parsed = askParseMessage(text);
4464
4472
  let unreadable = false;
4465
- if (parsed.kind === "void") { console.error(`- ${shown}: すでに無効化済みです(何もしません)`); continue; }
4466
- 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; }
4467
4475
  if (parsed.kind !== "open") {
4468
4476
  // Our own ❓ question whose layout no longer parses (2026-10-01: a
4469
4477
  // `slack edit` flattened one; it could then be neither collected nor
@@ -4471,7 +4479,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4471
4479
  // with it, so it is voided from a best-effort read — question and body
4472
4480
  // kept as they are, our own seeded pills removed — and the gate says so.
4473
4481
  const salvage = askSalvageUnreadable(text);
4474
- 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; }
4475
4483
  const seeded = asArray(msg.reactions).map(asRecord)
4476
4484
  .filter((r) => asArray(r.users).includes(self?.userId ?? "\u0000"))
4477
4485
  .map((r) => ASK_KEYCAPS.findIndex((k) => k.name === r.name))
@@ -4498,14 +4506,14 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4498
4506
  targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, seedMarker, pressed, ...(unreadable ? { unreadable } : {}) });
4499
4507
  }
4500
4508
  if (!targets.length) {
4501
- console.error(refused ? "無効化できる質問がありません。" : "何もすることがありません。");
4509
+ console.error(refused ? "No questions can be voided." : "Nothing to do.");
4502
4510
  process.exit(refused ? ASK_EXIT_ERROR : 0);
4503
4511
  }
4504
4512
 
4505
4513
  const code = safetyCode("ask-void", ...targets.map((t) => `${t.channelId}:${t.ts}`), args.reason, args.supersededBy ?? "", self?.userId ?? "");
4506
4514
  if (args.code !== code) {
4507
4515
  requireCode(args.code, code, [
4508
- `--- 無効化 (void) -------------------------------`,
4516
+ `--- Void -----------------------------------------`,
4509
4517
  fromLine(self, { asBot: args.asBot }),
4510
4518
  ` Reason: ${args.reason ? stripTerminalControls(args.reason) : "(none)"}`,
4511
4519
  ...(args.supersededBy ? [` Replaced: ${stripTerminalControls(args.supersededBy)}`] : []),
@@ -4560,7 +4568,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
4560
4568
 
4561
4569
  const got = await askFetchAt(token, { channelId, ts, threadTs }, args.cookie);
4562
4570
  if (!got.msg) {
4563
- console.error(`Error: そのメッセージが見つかりません: ${stripTerminalControls(args.link)}`);
4571
+ console.error(`Error: message not found: ${stripTerminalControls(args.link)}`);
4564
4572
  process.exit(ASK_EXIT_ERROR);
4565
4573
  }
4566
4574
  const msg = got.msg;
@@ -4568,12 +4576,12 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
4568
4576
  const { text, parsed, audience, broadcast, askerUserId, askerBotId } = q;
4569
4577
  if (parsed.kind === "other") {
4570
4578
  console.error(
4571
- `Error: これは \`slack ask\` の質問として読み取れません。\n` +
4572
- ` 理由: ${askExplainReject(text)}\n` +
4579
+ `Error: this cannot be read as a \`slack ask\` question.\n` +
4580
+ ` Reason: ${askExplainReject(text)}\n` +
4573
4581
  ` ${stripTerminalControls(args.link)}\n` +
4574
4582
  // The pills are still readable by hand, so a failure here is not a dead
4575
4583
  // end — say so, because the reported cost was not knowing that.
4576
- ` (押されたリアクションは残っています: slack read '${stripTerminalControls(args.link)}' で確認できます)`,
4584
+ ` (the pressed reactions are still there: check with slack read '${stripTerminalControls(args.link)}')`,
4577
4585
  );
4578
4586
  process.exit(ASK_EXIT_ERROR);
4579
4587
  }
@@ -4605,7 +4613,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
4605
4613
  // What must not come back is the reply that IS the answer. The ✅ stamp says
4606
4614
  // whether the answer was a reply, and which number it picked: the first
4607
4615
  // audience reply before the ✅ edit that reads as that answer is it.
4608
- console.error(`✓ 回答済み: ${stripTerminalControls(parsed.question)}`);
4616
+ console.error(`✓ Answered: ${stripTerminalControls(parsed.question)}`);
4609
4617
  const editedTs = typeof asRecord(msg.edited).ts === "string" ? String(asRecord(msg.edited).ts) : undefined;
4610
4618
  const how = askResolvedHow(text);
4611
4619
  let answerSkipped = false;
@@ -4638,12 +4646,12 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
4638
4646
  }
4639
4647
 
4640
4648
  if (!audience.size && !broadcast) {
4641
- 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)}`);
4642
4650
  process.exit(ASK_EXIT_ERROR);
4643
4651
  }
4644
4652
 
4645
4653
  if (args.timeout > 0) {
4646
- console.error(` 回答を待っています (最大 ${args.timeout}s)… Ctrl-C で中断`);
4654
+ console.error(` Waiting for an answer (up to ${args.timeout}s)… Ctrl-C to stop`);
4647
4655
  }
4648
4656
  await askWaitForAnswer(token, askWaitCtxFrom(q, parsed, { channelId, ts, threadTs }, args));
4649
4657
  }
@@ -4848,9 +4856,9 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
4848
4856
  fromLine(self, { asBot: args.asBot }),
4849
4857
  ` → ${dest}${threadTs ? " — THREAD REPLY" : " — NEW top-level message"}`,
4850
4858
  ` Question: ${question}`,
4851
- ` 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}` : ""}`,
4852
4860
  // Said out loud at the gate: this is the surprising half of the command.
4853
- ` Note: あなた自身の票は数えません (ピルはあなたが付けた種なので区別できません)`,
4861
+ ` Note: your own votes are not counted (the pills are seeds you added, so they can't be told apart)`,
4854
4862
  `--------------------------------────────────`,
4855
4863
  ], recipientTz);
4856
4864
  }
@@ -4874,8 +4882,8 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
4874
4882
  const why = e instanceof Error ? e.message : String(e);
4875
4883
  // A missing seed costs a voter a trip to the emoji picker; it does not
4876
4884
  // invalidate a poll that is already posted.
4877
- if (name === POLL_MARKER) console.error(` (marker ${name} を付けられませんでした: ${why})`);
4878
- 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})`);
4879
4887
  },
4880
4888
  );
4881
4889
 
@@ -4889,7 +4897,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
4889
4897
  console.error(`✓ Posted: ${shown}`);
4890
4898
  attr.record({ team: self?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
4891
4899
  console.log(pollResultsCommand(shown));
4892
- console.error(` 締め切る: slack poll --close='${shown}'${args.asBot ? " --as-bot" : ""}`);
4900
+ console.error(` To close: slack poll --close='${shown}'${args.asBot ? " --as-bot" : ""}`);
4893
4901
  }
4894
4902
 
4895
4903
  /** Locate + read back one posted poll. Shared by `--results` and `--close`,
@@ -4928,7 +4936,7 @@ async function pollFetch(token: string, link: string, cookie?: string): Promise<
4928
4936
  msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
4929
4937
  }
4930
4938
  if (!msg) {
4931
- console.error(`Error: そのメッセージが見つかりません: ${stripTerminalControls(link)}`);
4939
+ console.error(`Error: message not found: ${stripTerminalControls(link)}`);
4932
4940
  process.exit(POLL_EXIT_ERROR);
4933
4941
  }
4934
4942
  return { channelId, ts, msg };
@@ -4952,7 +4960,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
4952
4960
  const parsed = pollParseMessage(text);
4953
4961
  if (parsed.kind === "other") {
4954
4962
  console.error(
4955
- `Error: これは \`slack poll\` の投票ではありません (票の数え方が分かりません)。\n` +
4963
+ `Error: this is not a \`slack poll\` poll (don't know how to count its votes).\n` +
4956
4964
  ` ${stripTerminalControls(args.link)}`,
4957
4965
  );
4958
4966
  process.exit(POLL_EXIT_ERROR);
@@ -4960,7 +4968,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
4960
4968
  if (parsed.kind === "closed") {
4961
4969
  // Already closed: report what was RECORDED, never a fresh count. A pill
4962
4970
  // pressed after closing would otherwise silently change a settled result.
4963
- console.error(`✓ 投票終了: ${stripTerminalControls(parsed.question)}`);
4971
+ console.error(`✓ Poll closed: ${stripTerminalControls(parsed.question)}`);
4964
4972
  for (const line of pollFormatTallies(parsed.tallies)) console.log(line);
4965
4973
  return;
4966
4974
  }
@@ -4973,10 +4981,10 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
4973
4981
  const reactions = await reactionsGet(token, channelId, ts, args.cookie);
4974
4982
  const counted = pollTally(parsed.choices, reactions, exclude, parsed.multi);
4975
4983
 
4976
- console.error(`${stripTerminalControls(parsed.question)}${parsed.deadline ? ` (締切 ${parsed.deadline})` : ""}`);
4984
+ console.error(`${stripTerminalControls(parsed.question)}${parsed.deadline ? ` (closes ${parsed.deadline})` : ""}`);
4977
4985
  for (const line of pollFormatTallies(counted.tallies)) console.log(line);
4978
4986
  const total = counted.tallies.reduce((n, t) => n + t.count, 0);
4979
- console.error(` 計 ${total} 票${parsed.multi ? " (複数選択可)" : ""}`);
4987
+ console.error(` ${total} vote(s) total${parsed.multi ? " (multiple choice)" : ""}`);
4980
4988
  if (args.names) {
4981
4989
  for (let i = 0; i < counted.voters.length; i++) {
4982
4990
  if (!counted.voters[i]!.length) continue;
@@ -4995,8 +5003,8 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
4995
5003
  await editMessage(args.writeToken ?? token, channelId, ts, nextText, args.writeToken ? args.writeCookie : args.cookie, true);
4996
5004
  } catch (e: unknown) {
4997
5005
  console.error(
4998
- ` (投票文に注意書きを入れられませんでした: ${e instanceof Error ? e.message : String(e)})\n` +
4999
- ` メッセージを書き換えられるのは投稿者だけです — 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.`,
5000
5008
  );
5001
5009
  }
5002
5010
  }
@@ -5004,7 +5012,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
5004
5012
  // Not counted for anything, and said so out loud: a reaction carries no
5005
5013
  // timestamp anywhere in the Slack API, so "the latest one" does not exist.
5006
5014
  const names = await Promise.all(counted.ambiguous.map((u) => userName(token, u, args.cookie)));
5007
- 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(", ")}`);
5008
5016
  }
5009
5017
 
5010
5018
  if (!args.close) return;
@@ -5021,7 +5029,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
5021
5029
  } catch {
5022
5030
  // Cosmetic: the 📊 body is what makes it closed.
5023
5031
  }
5024
- console.error(`✓ 投票を締め切りました: ${stripTerminalControls(args.link)}`);
5032
+ console.error(`✓ Closed the poll: ${stripTerminalControls(args.link)}`);
5025
5033
  }
5026
5034
 
5027
5035
  async function cmdDoctor(token: string): Promise<void> {
@@ -5844,7 +5852,19 @@ async function main(): Promise<void> {
5844
5852
  await cmdTodoLs(tok(argv), opts);
5845
5853
  };
5846
5854
 
5847
- 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)
5848
5868
  .scriptName("slack")
5849
5869
  // A thrown handler error (e.g. a Slack API failure) is a runtime error, not a usage
5850
5870
  // mistake — print just the clean one-line message, never the command's --help block or a
@@ -5862,8 +5882,15 @@ async function main(): Promise<void> {
5862
5882
  process.exit(1);
5863
5883
  })
5864
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)" })
5865
5886
  .middleware(async (argv) => {
5866
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
+ }
5867
5894
  // `sent` reads only the local log — no token, no network. `relay` writes
5868
5895
  // local files (init) or talks to the user's own relay (health): no Slack token.
5869
5896
  if (!cmd || cmd === "auth" || cmd === "login" || cmd === "agent" || cmd === "sent" || cmd === "relay") return;
@@ -5884,6 +5911,17 @@ async function main(): Promise<void> {
5884
5911
  console.error(refusal);
5885
5912
  process.exit(2);
5886
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
+ }
5887
5925
  } catch (e) {
5888
5926
  const msg = e instanceof Error ? e.message : String(e);
5889
5927
  if (msg.startsWith("No profiles configured")) {
@@ -6667,6 +6705,9 @@ async function main(): Promise<void> {
6667
6705
  if ("error" in r) { refused.push(`${item.file}: ${r.error}`); continue; }
6668
6706
  const text = applyPrefix(item.entry.text, settings.prefix);
6669
6707
  if (!text.trim()) { refused.push(`${item.file}: empty`); continue; }
6708
+ // send/reply's URL-boundary refusal: a glued URL stays queued, the
6709
+ // reason in `refused`, for the author to fix the file.
6710
+ guardUrlBoundaries(text);
6670
6711
  plan.push({ item, ...r, text });
6671
6712
  } catch (e: unknown) {
6672
6713
  refused.push(`${item.file}: ${friendlySlackError(e)}`);
@@ -6675,7 +6716,7 @@ async function main(): Promise<void> {
6675
6716
  for (const r of refused) console.error(`refused (stays queued): ${r}`);
6676
6717
  if (!plan.length) process.exit(2);
6677
6718
  const lines = [`OUTBOX — ${plan.length} repl${plan.length === 1 ? "y" : "ies"}${asBot ? " as the bot" : ""}:`];
6678
- for (const p of plan) lines.push(` → ${p.channel} thread ${p.threadTs}: ${stripTerminalControls(p.text).replace(/\n/g, "⏎").slice(0, 120)}`);
6719
+ for (const p of plan) lines.push(` → ${p.channel} thread ${p.threadTs}: ${stripTerminalControls(p.text).replace(/\n/g, "⏎").slice(0, 120)}${p.text.includes("[redacted:") ? " (secrets were masked when queued: this is what posts)" : ""}`);
6679
6720
  const expected = safetyCode("outbox", asBot ? "bot" : "user", ...plan.map((p) => `${p.channel}:${p.threadTs}:${p.text}`));
6680
6721
  if (argv.code !== expected) requireCode(argv.code, expected, lines);
6681
6722
  let failed = 0;
@@ -6699,7 +6740,7 @@ async function main(): Promise<void> {
6699
6740
  )
6700
6741
  .command(
6701
6742
  "ask [target] [question] [choices..]",
6702
- "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…>`)",
6703
6744
  (y) => y
6704
6745
  .positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --waitFor)" })
6705
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." })
@@ -6708,10 +6749,10 @@ async function main(): Promise<void> {
6708
6749
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
6709
6750
  .option("body", { type: "string", describe: "Extra context shown under the question" })
6710
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" })
6711
- .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)." })
6712
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." })
6713
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." })
6714
- .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…>`." })
6715
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." })
6716
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." })
6717
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." })
@@ -6837,7 +6878,7 @@ async function main(): Promise<void> {
6837
6878
  const { token, cookie } = askTokenFor(!!argv["as-bot"]);
6838
6879
  const self = await selfIdentity(token, cookie);
6839
6880
  const t = await askLoadOwnOpen(token, voidFlag[0]!, self, !!argv["as-bot"], cookie);
6840
- 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); }
6841
6882
  if (!t) process.exit(ASK_EXIT_ERROR);
6842
6883
  voidAfter = { ...t, reason: unescapeArg(String(argv.reason ?? "")).trim() };
6843
6884
  // Same place, same words, unless new ones were given. The create path
@@ -7036,7 +7077,7 @@ async function main(): Promise<void> {
7036
7077
  .positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --results/--close)" })
7037
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." })
7038
7079
  .positional("choices", { type: "string", array: true, describe: "2..10 choices, seeded as 1️⃣..🔟 reactions for voters to click" })
7039
- .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)" })
7040
7081
  .option("until", { type: "string", describe: "Deadline shown in the message (display only — closing is still `--close`)" })
7041
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)." })
7042
7083
  .option("close", { type: "string", describe: "Count an existing poll AND freeze the tally into the message. Only the poster can do this." })
@@ -8289,7 +8330,8 @@ async function main(): Promise<void> {
8289
8330
  .option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
8290
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" })
8291
8332
  .option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" })
8292
- .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)" }),
8293
8335
  async (argv) => {
8294
8336
  let re: RegExp;
8295
8337
  let reconcileMs: number;
@@ -8405,6 +8447,7 @@ async function main(): Promise<void> {
8405
8447
  selfUsers,
8406
8448
  selfBots,
8407
8449
  json: argv.json,
8450
+ reactions: argv.reactions,
8408
8451
  ...(argv.inbox ? {
8409
8452
  onMatch: (rec: StreamMatch, files: number) => {
8410
8453
  try {