slack-term 1.32.1 → 1.32.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.32.1",
3
+ "version": "1.32.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/ask.ts CHANGED
@@ -305,3 +305,109 @@ export function askParseMessage(text: string): AskParsed {
305
305
 
306
306
  return { kind: "open", question, reactable, overflow, threadOnly };
307
307
  }
308
+
309
+ /** Did a free-text reply actually PICK one of the offered choices?
310
+ *
311
+ * Reported from real use 2026-09-04: three options were offered, the answerer
312
+ * replied 「没懂,能给我讲前因后果吗」 ("I don't follow — can you explain the
313
+ * background?"), and `--waitFor` exited 0 with that stored AS the decision. Any
314
+ * reply from an addressed person counted, because nothing compared the reply to
315
+ * the candidates. A lane harvesting that unparks the work and proceeds with
316
+ * nothing behind it, and the recorded answer reads as though he had chosen.
317
+ *
318
+ * Deliberately NARROW. Every loosening here re-creates the same defect one step
319
+ * further out, and the two error directions are not symmetric: calling a
320
+ * non-answer an answer makes an automated flow act on a decision nobody took,
321
+ * while calling an answer a non-answer parks it in front of a human who can
322
+ * settle it with one reaction. So: an exact choice, a bare number, a keycap, or
323
+ * a number introducing its own choice text. Nothing fuzzy, nothing partial —
324
+ * "the second one" and "option B" are NOT matches, on purpose.
325
+ *
326
+ * `choices` is the full candidate list, reactable and overflow together, in
327
+ * presentation order. The overflow ones (11+) can only ever be answered as
328
+ * text, so a matcher that ignored them would call every legitimate answer to a
329
+ * long question "not chosen" — the failure the caller cannot see. */
330
+ export type AskChoiceMatch =
331
+ | { kind: "chosen"; index: number } // 1-based, matching the printed numbering
332
+ | { kind: "none" }
333
+ | { kind: "ambiguous"; indexes: number[] };
334
+
335
+ /** Lowercase, NFKC-fold (full-width 7 and 7.become 7), strip decoration and
336
+ * one trailing sentence mark, collapse runs of space. NFKC is what lets a reply
337
+ * typed on a Japanese IME match a choice typed on an ASCII keyboard. */
338
+ function askNormalizeChoice(s: string): string {
339
+ return s
340
+ .normalize("NFKC")
341
+ .replace(/[*_`~]/g, "")
342
+ .trim()
343
+ // Brackets and sentence marks are stripped in ONE class at each end rather
344
+ // than in two passes: 「中止」。 puts the full stop OUTSIDE the quote, so
345
+ // stripping quotes and then punctuation leaves the closing 」 behind and the
346
+ // reply stops matching a choice it plainly names.
347
+ .replace(/^[\s"'`「『((\[]+/, "")
348
+ .replace(/[\s"'`」』))\]。..!!??、,]+$/, "")
349
+ .replace(/\s+/g, " ")
350
+ .trim()
351
+ .toLowerCase();
352
+ }
353
+
354
+ export function askMatchChoice(reply: string, choices: string[]): AskChoiceMatch {
355
+ const norm = askNormalizeChoice(reply);
356
+ if (!norm) return { kind: "none" };
357
+ const normChoices = choices.map(askNormalizeChoice);
358
+
359
+ const hits = new Set<number>();
360
+
361
+ // 1. The reply IS the choice text.
362
+ normChoices.forEach((c, i) => { if (c && c === norm) hits.add(i + 1); });
363
+
364
+ // 2. A keycap glyph, which is what someone copies out of the message when they
365
+ // cannot react (a thread on mobile, or a choice past the tenth).
366
+ ASK_KEYCAPS.forEach((k, i) => {
367
+ if (i < choices.length && reply.trim() === k.glyph) hits.add(i + 1);
368
+ });
369
+
370
+ // 3. A number: bare, or introducing the choice it names. `1` and `1. foo` are
371
+ // the same act. A number followed by DIFFERENT text is not a match — that
372
+ // is someone writing prose that happens to start with a digit.
373
+ //
374
+ // The separator is REQUIRED once text follows, and matched as its own
375
+ // alternative rather than as an optional group. With it optional, `100`
376
+ // parsed as choice 10 followed by "0" — so a ten-option question whose
377
+ // tenth choice is "0" matched a reply nobody meant as a choice. That is the
378
+ // expensive direction of the error, invented while fixing the same one.
379
+ // (cross-vendor review, 2026-09-05)
380
+ let n = 0;
381
+ let rest = "";
382
+ let m = norm.match(/^(\d{1,2})$/);
383
+ if (m) {
384
+ n = Number(m[1]);
385
+ } else if ((m = norm.match(/^(\d{1,2})(?:\s*[.)::、。\-\]]\s*|\s+)(.*)$/))) {
386
+ n = Number(m[1]);
387
+ // Normalised AGAIN, not merely trimmed: `1. (Release)` reaches here as
388
+ // `(release` — the number kept the opening bracket from being stripped at
389
+ // the head of the string while the closing one went at the tail, leaving an
390
+ // unbalanced remainder that matched nothing. Re-running the same normaliser
391
+ // on the remainder is what makes a bracketed choice answerable by number.
392
+ rest = askNormalizeChoice(m[2]!);
393
+ // A separator with NOTHING after it is not a choice: `1 -` is someone who
394
+ // started typing and stopped, and selecting option 1 for them is the
395
+ // expensive error. `1.` and `1)` still work — their punctuation is stripped
396
+ // at the tail, so they arrive at the bare-number branch above.
397
+ if (!rest) return { kind: "none" };
398
+ // An all-digit remainder is a range or a score (`1-2`), not a numbered
399
+ // choice. Without this, a question whose options are themselves numbers
400
+ // matched `1-2` as "option 1, whose text is 2". Rejecting it fails to the
401
+ // CHEAP side: a genuine `1. 2` re-asks, a false selection does not.
402
+ if (/^\d+$/.test(rest)) return { kind: "none" };
403
+ }
404
+ if (n >= 1 && n <= choices.length && (rest === "" || rest === normChoices[n - 1])) hits.add(n);
405
+
406
+ const indexes = [...hits].sort((a, b) => a - b);
407
+ if (!indexes.length) return { kind: "none" };
408
+ // Two distinct choices matched — the same refusal-to-guess the poller already
409
+ // applies when someone presses two pills. Identical choice TEXT is the usual
410
+ // cause, and picking the lower index would answer with a coin flip.
411
+ if (indexes.length > 1) return { kind: "ambiguous", indexes };
412
+ return { kind: "chosen", index: indexes[0]! };
413
+ }
package/ts/cli.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  askParseMessage,
24
24
  askExplainReject,
25
25
  askFlatten,
26
+ askMatchChoice,
26
27
  applyInvalidNotice,
27
28
  readInvalidNotice,
28
29
  type AskFound,
@@ -1642,12 +1643,18 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1642
1643
  // Exit codes are the contract for `--wait`, and a non-zero one must never be
1643
1644
  // mistakable for "an answer arrived":
1644
1645
  // 0 answered (the answer text is on stdout, and nothing else is)
1645
- // 2 timed out
1646
+ // 2 timed out (nobody replied)
1646
1647
  // 3 transport/config failure
1648
+ // 4 replied, but chose none of the offered choices — stdout stays EMPTY
1647
1649
  // Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
1648
1650
 
1649
1651
  const ASK_EXIT_TIMEOUT = 2;
1650
1652
  const ASK_EXIT_ERROR = 3;
1653
+ /** Somebody replied, and what they wrote picks none of the offered choices.
1654
+ * A THIRD state, distinct from both 0 and 2 on purpose: exit 0 would have an
1655
+ * automated caller act on a decision nobody took, and exit 2 ("nobody
1656
+ * answered") would hide that a human is standing there waiting for something. */
1657
+ const ASK_EXIT_UNCHOSEN = 4;
1651
1658
 
1652
1659
  const ASK_PHI = 1.618033988749895;
1653
1660
  const ASK_POLL_MIN_MS = 1000;
@@ -1730,6 +1737,11 @@ interface AskWaitCtx {
1730
1737
  threadParentTs?: string;
1731
1738
  question: string;
1732
1739
  reactable: string[];
1740
+ /** Choices past the tenth. They carry NO reaction — text is the only way to
1741
+ * answer them — so they must be in the candidate list the reply is matched
1742
+ * against, or every legitimate answer to a long question reads as "chose
1743
+ * nothing". */
1744
+ overflow: string[];
1733
1745
  threadOnly: boolean;
1734
1746
  /** Who may answer. Empty + broadcast=false would accept nobody, which is why
1735
1747
  * posting an unaddressed question is refused up front. */
@@ -1763,6 +1775,12 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
1763
1775
  const isAnswerer = (user: unknown): user is string =>
1764
1776
  typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
1765
1777
 
1778
+ /** The earliest reply from an answerer that picked none of the choices. Kept
1779
+ * across polls so the timeout can report it, and so the operator is told once
1780
+ * rather than on every tick. */
1781
+ let unchosen: { text: string; who: string; ts: string; ambiguous: boolean } | undefined;
1782
+ let unchosenReported = "";
1783
+
1766
1784
  /** Every seed an answerer is on — not just the first. Changing your mind
1767
1785
  * leaves BOTH reactions in place (Slack only drops one when you actively
1768
1786
  * un-react), so taking the lowest index would answer with the abandoned
@@ -1822,6 +1840,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
1822
1840
  return null;
1823
1841
  }
1824
1842
 
1843
+ /** The full candidate list — reactable pills AND the text-only overflow —
1844
+ * empty when the question was asked free-text. */
1845
+ const candidates = [...reactable, ...ctx.overflow];
1846
+
1825
1847
  function answerFromMessages(messages: Record<string, Json>[], before = Infinity): AskFound | null {
1826
1848
  // Oldest first: if someone wrote twice, the first reply is the answer.
1827
1849
  const ordered = [...messages].sort((a, b) => Number(a.ts) - Number(b.ts));
@@ -1833,12 +1855,35 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
1833
1855
  if (typeof m.subtype === "string" && !ASK_ANSWERABLE_SUBTYPES.has(m.subtype)) continue;
1834
1856
  if (Number(m.ts) >= before) break;
1835
1857
  const t = typeof m.text === "string" ? m.text.trim() : "";
1836
- if (t) return { answer: t, how: "返信", who: m.user };
1858
+ if (!t) continue;
1859
+ // A question asked WITHOUT choices is answered by whatever comes back —
1860
+ // there is nothing to match against, and every reply is the answer.
1861
+ if (!candidates.length) return { answer: t, how: "返信", who: m.user };
1862
+ const match = askMatchChoice(t, candidates);
1863
+ if (match.kind === "chosen") {
1864
+ // Answer with the CHOICE, not with the reply that selected it: "2" and
1865
+ // "2. 中止" have to reach the caller as the same decision a pill would
1866
+ // have produced, or the same answer arrives in three spellings.
1867
+ return { answer: candidates[match.index - 1]!, how: `返信 (${match.index})`, who: m.user };
1868
+ }
1869
+ // Replied, but picked nothing. Recorded rather than returned: a later
1870
+ // reply may still choose, and the first one is what the operator needs to
1871
+ // see — it is usually a question back.
1872
+ if (!unchosen || Number(m.ts) < Number(unchosen.ts)) {
1873
+ unchosen = { text: t, who: typeof m.user === "string" ? m.user : "", ts: String(m.ts), ambiguous: match.kind === "ambiguous" };
1874
+ }
1837
1875
  }
1838
1876
  return null;
1839
1877
  }
1840
1878
 
1841
1879
  async function poll(): Promise<AskFound | null> {
1880
+ // Recomputed every pass rather than accumulated. A reply that has since been
1881
+ // DELETED must stop counting: carrying it forward would let the timeout
1882
+ // report "somebody replied and chose nothing" about a message that is no
1883
+ // longer there, and exit 4 where exit 2 is the truth. `unchosenReported`
1884
+ // persists on purpose — it is what keeps the operator from being told the
1885
+ // same thing on every tick.
1886
+ unchosen = undefined;
1842
1887
  // A question that is itself a thread reply is invisible to
1843
1888
  // `conversations.history`, so the entire poll — reactions included — has to
1844
1889
  // come from the thread.
@@ -1955,6 +2000,25 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
1955
2000
  console.log(found.answer);
1956
2001
  process.exit(0);
1957
2002
  }
2003
+ // Said as soon as it is seen, not only at the timeout: the reply is usually
2004
+ // a question BACK, and the person who can unblock it is the one watching
2005
+ // this command. Once per reply — a poller that repeated itself every tick
2006
+ // would be noise nobody reads.
2007
+ if (unchosen && unchosen.ts !== unchosenReported) {
2008
+ unchosenReported = unchosen.ts;
2009
+ let who = unchosen.who;
2010
+ try {
2011
+ if (who) who = await userName(token, who, cookie);
2012
+ } catch {
2013
+ // the id is still a usable answer to "who"
2014
+ }
2015
+ console.error(
2016
+ unchosen.ambiguous
2017
+ ? ` (${stripTerminalControls(who)} の返信は複数の選択肢に一致します。1 つに絞ってもらうまで待ちます)`
2018
+ : ` (${stripTerminalControls(who)} が返信しましたが、選択肢のどれでもありません。待機を続けます)`,
2019
+ );
2020
+ console.error(` 「${stripTerminalControls(unchosen.text)}」`);
2021
+ }
1958
2022
  // Checked after the first poll, never before it: `--timeout 0` means one
1959
2023
  // look, and one look must still be a real look.
1960
2024
  if (Date.now() >= deadline) break;
@@ -1966,6 +2030,23 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
1966
2030
  // question is still standing, and they have just decided not to block on it.
1967
2031
  // Printing only the permalink here leaves them where `ask` used to: holding a
1968
2032
  // link with nothing that reads a pressed pill back.
2033
+ // Three states, not two. "Nobody replied" and "somebody replied and chose
2034
+ // nothing" need different reactions from whoever reads this — the first waits,
2035
+ // the second answers a person — and collapsing them into exit 2 hides a human
2036
+ // standing there. stdout stays empty in both: the caller must not be handed
2037
+ // something to act on.
2038
+ if (unchosen) {
2039
+ console.error(
2040
+ unchosen.ambiguous
2041
+ ? `Error: 返信はありましたが、複数の選択肢に一致するため確定できません (メッセージはそのまま残っています)`
2042
+ : `Error: 返信はありましたが、選択肢のどれも選ばれていません (メッセージはそのまま残っています)`,
2043
+ );
2044
+ console.error(` 返信: 「${stripTerminalControls(unchosen.text)}」`);
2045
+ console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
2046
+ console.error(` ${shown}`);
2047
+ console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot)}`);
2048
+ process.exit(ASK_EXIT_UNCHOSEN);
2049
+ }
1969
2050
  if (timeout === 0) {
1970
2051
  console.error(`まだ回答がありません: ${shown}`);
1971
2052
  } else {
@@ -2190,6 +2271,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2190
2271
  ts,
2191
2272
  question,
2192
2273
  reactable,
2274
+ overflow,
2193
2275
  threadOnly,
2194
2276
  audience,
2195
2277
  broadcast,
@@ -2304,6 +2386,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
2304
2386
  ts,
2305
2387
  question: parsed.question,
2306
2388
  reactable: parsed.reactable,
2389
+ overflow: parsed.overflow,
2307
2390
  threadOnly: parsed.threadOnly,
2308
2391
  audience,
2309
2392
  broadcast,
@@ -3560,7 +3643,7 @@ async function main(): Promise<void> {
3560
3643
  .positional("choices", { type: "string", array: true, describe: "Up to 10 choices, seeded as 1️⃣..🔟 reactions. Beyond 10 they are listed but answerable only by text. With none, the question asks for a free-text reply." })
3561
3644
  .option("code", { type: "string", describe: "Safety hash to confirm the ask" })
3562
3645
  .option("body", { type: "string", describe: "Extra context shown under the question" })
3563
- .option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = timed out, 3 = transport failure." })
3646
+ .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 = somebody replied but picked none of the choices (stdout empty — do not act on it)." })
3564
3647
  .option("waitFor", { type: "string", describe: "Collect the answer to a question already posted: pass its permalink. Nothing is posted. Same stdout/exit contract as --wait; --timeout 0 checks once and exits 2 if still open." })
3565
3648
  .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
3566
3649
  .option("channel-id", { type: "string", describe: "Raw channel ID" })