slack-term 1.42.0 → 1.44.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.42.0",
3
+ "version": "1.44.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
@@ -60,6 +60,7 @@
60
60
  "build": "bun build ts/cli.ts --target=node --outfile dist/cli.js --minify",
61
61
  "typecheck": "tsgo --noEmit",
62
62
  "typecheck:tsc": "tsc --noEmit",
63
+ "typecheck:worker": "bun install --cwd worker --frozen-lockfile && tsgo --noEmit -p worker",
63
64
  "test": "bun test tests/ --timeout 90000 --parallel=2",
64
65
  "test:watch": "bun test tests/ --timeout 90000 --isolate --watch",
65
66
  "test:parity": "bash tests/parity.sh",
package/ts/ask.ts CHANGED
@@ -41,6 +41,15 @@ export type AskFound = { answer: string; how: string; who?: string; ts?: string;
41
41
  export const ASK_MARKER = "question";
42
42
  export const ASK_MARKER_PREFIX = `:${ASK_MARKER}: `;
43
43
 
44
+ /** The "explain" pill (`--explain`): pressing it asks the asker to explain the
45
+ * question, and is NEVER an answer. Reported 2026-10-14: an "explain" offered
46
+ * as a numbered choice was collected as the decision and closed the question.
47
+ * Not ❓: ❓ means "none of these fit — my answer follows as a reply", and that
48
+ * reply resolves the question; ℹ️ means "I cannot answer yet", and the question
49
+ * stays open for a real answer after the explanation. */
50
+ export const ASK_EXPLAIN_MARKER = "information_source";
51
+ export const ASK_EXPLAIN_PREFIX = `:${ASK_EXPLAIN_MARKER}: `;
52
+
44
53
  // The copy a reader sees, per language. Japanese is the default and its
45
54
  // strings are a WIRE FORMAT: questions already in flight were posted with them
46
55
  // and are recognised by them, so they must never be reworded. Adding a language
@@ -118,6 +127,14 @@ interface AskCopy {
118
127
  overflowNote: string;
119
128
  otherThread: string;
120
129
  otherHere: string;
130
+ /** The free-text parser matches this exactly (`askIsExplainCopy`), so it is
131
+ * a wire format too: questions in flight carry it. */
132
+ explainLine: string;
133
+ /** WIRE FORMAT — never reword. `--wait`/`--waitFor` find a question's
134
+ * thread note by this exact copy (`askIsExplainNote`), so a reworded note
135
+ * is not found by later runs, which would post a second one, and the old
136
+ * note would stop being excluded from answers and notes. */
137
+ explainNote: string;
121
138
  invalidPrefix: string;
122
139
  answeredVia: (how: string, who: string) => string;
123
140
  howReaction: (glyph: string) => string;
@@ -149,6 +166,8 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
149
166
  overflowNote: "_11 番目以降はリアクションがないので、返信で答えてください。_",
150
167
  otherThread: `${ASK_MARKER_PREFIX}その他 — スレッドで返信してください`,
151
168
  otherHere: `${ASK_MARKER_PREFIX}その他 — このメッセージに返信してください`,
169
+ explainLine: `${ASK_EXPLAIN_PREFIX}説明して — 押すと質問者に説明を頼みます (回答にはなりません)`,
170
+ explainNote: `${ASK_EXPLAIN_PREFIX}説明のリクエストを受け付けました — このスレッドで説明します。質問はまだ開いているので、回答はそのあとで大丈夫です。`,
152
171
  invalidPrefix: "_:warning: 同時に複数選ばれているため回答として数えていません — どれか 1 つだけ残してください: ",
153
172
  answeredVia: (how, who) => `_${how}で回答済み${who ? ` (${who})` : ""}_`,
154
173
  howReaction: (glyph) => `リアクション ${glyph}`,
@@ -168,6 +187,8 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
168
187
  overflowNote: "_Choices 11 and up have no reaction — answer them with a reply._",
169
188
  otherThread: `${ASK_MARKER_PREFIX}Other — reply in the thread`,
170
189
  otherHere: `${ASK_MARKER_PREFIX}Other — reply to this message`,
190
+ explainLine: `${ASK_EXPLAIN_PREFIX}Explain — press to ask the asker for more detail (not an answer)`,
191
+ explainNote: `${ASK_EXPLAIN_PREFIX}Explanation requested — it will follow in this thread. The question stays open; answer it after that.`,
171
192
  invalidPrefix: "_:warning: Several choices are selected, so this is not counted as an answer — keep only one: ",
172
193
  answeredVia: (how, who) => `_Answered by ${how}${who ? ` (${who})` : ""}_`,
173
194
  howReaction: (glyph) => `reaction ${glyph}`,
@@ -185,6 +206,22 @@ function askIsOtherLine(line: string | undefined): boolean {
185
206
  return line !== undefined && (line.startsWith(ASK_MARKER_PREFIX) || line.startsWith("❓ "));
186
207
  }
187
208
 
209
+ /** True for the explain line, in either spelling. Matched by prefix, like the
210
+ * other line — used where position alone identifies it (under ❓). */
211
+ function askIsExplainLine(line: string | undefined): boolean {
212
+ return line !== undefined && (line.startsWith(ASK_EXPLAIN_PREFIX) || line.startsWith("ℹ️ "));
213
+ }
214
+
215
+ /** True only for this language's explain line AS WRITTEN (either spelling of
216
+ * the marker). For a free-text question, where nothing but position separates
217
+ * the explain line from the body above it: a body whose own last line starts
218
+ * with ℹ️ must not be read as the pill, so the copy has to match exactly. */
219
+ function askIsExplainCopy(line: string | undefined, c: AskCopy): boolean {
220
+ if (line === undefined) return false;
221
+ const norm = line.startsWith("ℹ️ ") ? ASK_EXPLAIN_PREFIX + line.slice("ℹ️ ".length) : line;
222
+ return norm === c.explainLine;
223
+ }
224
+
188
225
  // The invalid-ballot notice, shared by `ask` and `poll` and written INTO the
189
226
  // message rather than only onto the collector's terminal. The person who has to
190
227
  // fix it is the voter, and the voter is in Slack — a warning that only the
@@ -228,6 +265,14 @@ export function applyInvalidNotice(text: string, invalid: string[], lang: AskLan
228
265
  return out === text ? text : out;
229
266
  }
230
267
 
268
+ /** True for the thread note `--wait`/`--waitFor` posts when ℹ️ is pressed, in
269
+ * either language. Our own bookkeeping: never an answer, never a note — even
270
+ * when the identity that posted it is in the audience (an @here question
271
+ * collected with another token). */
272
+ export function askIsExplainNote(text: unknown): boolean {
273
+ return typeof text === "string" && ASK_LANGS.some((l) => ASK_COPY[l].explainNote === text.trim());
274
+ }
275
+
231
276
  /** True if this line is the notice — parsers skip it when walking up. */
232
277
  export function isInvalidNotice(line: string | undefined): boolean {
233
278
  return invalidLang(line) !== null;
@@ -293,7 +338,7 @@ export function askFlatten(s: string): string {
293
338
  /** Question body: the prompt, the numbered choices matching the seeded pills,
294
339
  * and an instruction naming the answer paths this command actually reads.
295
340
  * `askParseMessage` is its inverse — keep the two in step. */
296
- export function askBuildText(question: string, body: string, reactable: string[], overflow: string[], threadOnly: boolean, lang: AskLang = "ja"): string {
341
+ export function askBuildText(question: string, body: string, reactable: string[], overflow: string[], threadOnly: boolean, lang: AskLang = "ja", explain = false): string {
297
342
  const c = ASK_COPY[lang];
298
343
  const lines: string[] = [`${ASK_MARKER_PREFIX}*${question}*`];
299
344
  if (body) lines.push("", body);
@@ -309,6 +354,8 @@ export function askBuildText(question: string, body: string, reactable: string[]
309
354
  lines.push(...reactable.map((s, i) => `${askPill(i)} ${askFlatten(s)}`));
310
355
  // Directly under the pills, matching the ❓ seeded right after them.
311
356
  lines.push(threadOnly ? c.otherThread : c.otherHere);
357
+ // Under ❓, matching the ℹ️ seeded right after it.
358
+ if (explain) lines.push(c.explainLine);
312
359
  if (overflow.length) {
313
360
  lines.push("");
314
361
  lines.push(...overflow.map((s, i) => `(${i + 11}) ${askFlatten(s)}`));
@@ -321,6 +368,7 @@ export function askBuildText(question: string, body: string, reactable: string[]
321
368
  lines.push(threadOnly ? c.instructionReactionThread : c.instructionReactionHere);
322
369
  } else {
323
370
  lines.push("");
371
+ if (explain) lines.push(c.explainLine, "");
324
372
  lines.push(threadOnly ? c.instructionTextThread : c.instructionTextHere);
325
373
  }
326
374
  return lines.join("\n");
@@ -405,7 +453,7 @@ export function askSalvageUnreadable(text: string): { question: string; body: st
405
453
  for (const instr of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere]) {
406
454
  if (body.includes(instr)) wasAsk = true;
407
455
  }
408
- for (const fixed of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere, c.overflowNote, c.otherThread, c.otherHere]) {
456
+ for (const fixed of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere, c.overflowNote, c.otherThread, c.otherHere, c.explainLine]) {
409
457
  body = body.split(fixed).join(" ");
410
458
  }
411
459
  const at = body.indexOf(c.invalidPrefix);
@@ -454,7 +502,8 @@ function askDecodeEntities(s: string): string {
454
502
  }
455
503
 
456
504
  export type AskParsed =
457
- | { kind: "open"; question: string; body: string; reactable: string[]; overflow: string[]; threadOnly: boolean; lang: AskLang }
505
+ /** `explain`: the body offers the ℹ️ explain pill (`--explain`). */
506
+ | { kind: "open"; question: string; body: string; reactable: string[]; overflow: string[]; threadOnly: boolean; lang: AskLang; explain: boolean }
458
507
  | { kind: "resolved"; question: string; answer: string }
459
508
  /** Voided (無効化). `reason` is the stamp line as written; `supersededBy` the
460
509
  * replacement's link when one was given. */
@@ -594,6 +643,7 @@ export function askParseMessage(text: string): AskParsed {
594
643
  // there is the body — kept by the ✅ rewrite.
595
644
  let bodyEnd = lines.length - 1;
596
645
  if (isInvalidNotice(lines[bodyEnd - 1])) bodyEnd--;
646
+ let explain = false;
597
647
  if (hasChoices) {
598
648
  // Walk UP from the instruction line rather than down from the question. The
599
649
  // choice block is anchored to the bottom of the body, and scanning downward
@@ -609,6 +659,7 @@ export function askParseMessage(text: string): AskParsed {
609
659
  if (lines[i] !== "") return { kind: "other" };
610
660
  i--;
611
661
  }
662
+ if (askIsExplainLine(lines[i])) { explain = true; i--; }
612
663
  if (askIsOtherLine(lines[i])) i--;
613
664
  const raw: string[] = [];
614
665
  while (i >= 0) {
@@ -634,10 +685,16 @@ export function askParseMessage(text: string): AskParsed {
634
685
  for (let n = 0; n < overflow.length; n++) {
635
686
  if (!lines.includes(`(${n + 11}) ${overflow[n]}`)) return { kind: "other" };
636
687
  }
688
+ } else if (lines[bodyEnd - 1] === "" && askIsExplainCopy(lines[bodyEnd - 2], c) && lines[bodyEnd - 3] === "" && bodyEnd - 3 >= end) {
689
+ // A free-text question's explain line: blank, the line, blank, instruction
690
+ // — and the line is our copy verbatim. A body that merely ENDS on a line
691
+ // starting with ℹ️ (a question built without --explain) stays body.
692
+ explain = true;
693
+ bodyEnd -= 2;
637
694
  }
638
695
 
639
696
  const body = lines.slice(end + 1, bodyEnd).join("\n").replace(/^\n+|\n+$/g, "");
640
- return { kind: "open", question, body, reactable, overflow, threadOnly, lang };
697
+ return { kind: "open", question, body, reactable, overflow, threadOnly, lang, explain };
641
698
  }
642
699
 
643
700
  /** Did a free-text reply actually PICK one of the offered choices?