slack-term 1.43.0 → 1.45.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.43.0",
3
+ "version": "1.45.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/ask.ts CHANGED
@@ -34,13 +34,45 @@ export type AskFound = { answer: string; how: string; who?: string; ts?: string;
34
34
  * in-flight question unparseable. The marker carries identity now, and the
35
35
  * instruction is free to be translated.
36
36
  *
37
- * It is also seeded as a REACTION, which is what makes `has:question` list
38
- * every question the way `has:pushpin` lists every todo. `todo` gave this
39
- * emoji up (its needs-discussion flag moved to :thinking_face:) precisely so
40
- * the two would not collide in search. */
37
+ * It is also seeded as a REACTION on free-text questions and on pills posted
38
+ * before ➕; a question with choices now seeds ➕ (`ASK_OTHER_MARKER`) in its
39
+ * place. So `has::question: OR has::heavy_plus_sign:`-style search lists the
40
+ * open questions the way `has::pushpin:` lists every todo — with the trade-off
41
+ * that ➕ is a common emoji, so that search can also match unrelated messages
42
+ * (`slack ask --ls` reads the local sent-log and is unaffected). `todo` gave
43
+ * ❓ up (its needs-discussion flag moved to :thinking_face:) precisely so the
44
+ * two would not collide in search. */
41
45
  export const ASK_MARKER = "question";
42
46
  export const ASK_MARKER_PREFIX = `:${ASK_MARKER}: `;
43
47
 
48
+ /** The "other" pill — "none of these fit, my answer follows as a reply" —
49
+ * seeded after the numbered pills and listed under them in the body. ➕, not
50
+ * ❓ (taku 2026-10-14): ❓ read as "I have a question", not "add my own answer".
51
+ *
52
+ * ❓ stays the HEAD marker (`ASK_MARKER_PREFIX` on the first line): that is the
53
+ * wire format that identifies an ask, and every reader keys on it. Only the
54
+ * pill moved. A free-text question has no other line and still seeds ❓.
55
+ *
56
+ * Pills posted before this carry ❓ as the other pill, both as the seed and
57
+ * as the body line. They stay fully working: the parser accepts either line
58
+ * (`askSeedMarker(text)` says which one a question was seeded with), a press
59
+ * on either counts as "other", and void / edit / resolve take off exactly the
60
+ * seed that is there. */
61
+ export const ASK_OTHER_MARKER = "heavy_plus_sign";
62
+ export const ASK_OTHER_PREFIX = `:${ASK_OTHER_MARKER}: `;
63
+ /** Every reaction that means "other" on some open pill: ➕ now, ❓ on the
64
+ * pills posted before ➕. */
65
+ export const ASK_OTHER_MARKERS: readonly string[] = [ASK_OTHER_MARKER, ASK_MARKER];
66
+
67
+ /** The "explain" pill (`--explain`): pressing it asks the asker to explain the
68
+ * question, and is NEVER an answer. Reported 2026-10-14: an "explain" offered
69
+ * as a numbered choice was collected as the decision and closed the question.
70
+ * Not ➕: ➕ means "none of these fit — my answer follows as a reply", and that
71
+ * reply resolves the question; ℹ️ means "I cannot answer yet", and the question
72
+ * stays open for a real answer after the explanation. */
73
+ export const ASK_EXPLAIN_MARKER = "information_source";
74
+ export const ASK_EXPLAIN_PREFIX = `:${ASK_EXPLAIN_MARKER}: `;
75
+
44
76
  // The copy a reader sees, per language. Japanese is the default and its
45
77
  // strings are a WIRE FORMAT: questions already in flight were posted with them
46
78
  // and are recognised by them, so they must never be reworded. Adding a language
@@ -118,6 +150,14 @@ interface AskCopy {
118
150
  overflowNote: string;
119
151
  otherThread: string;
120
152
  otherHere: string;
153
+ /** The free-text parser matches this exactly (`askIsExplainCopy`), so it is
154
+ * a wire format too: questions in flight carry it. */
155
+ explainLine: string;
156
+ /** WIRE FORMAT — never reword. `--wait`/`--waitFor` find a question's
157
+ * thread note by this exact copy (`askIsExplainNote`), so a reworded note
158
+ * is not found by later runs, which would post a second one, and the old
159
+ * note would stop being excluded from answers and notes. */
160
+ explainNote: string;
121
161
  invalidPrefix: string;
122
162
  answeredVia: (how: string, who: string) => string;
123
163
  howReaction: (glyph: string) => string;
@@ -128,10 +168,10 @@ interface AskCopy {
128
168
  }
129
169
 
130
170
  /** The standing "none of these" choice (`otherThread` / `otherHere`), listed
131
- * after the numbered ones. It is the ❓ marker pill, which is seeded LAST so
132
- * the reaction row reads the same as the body: 1️⃣ 2️⃣ 3️⃣ ❓. People already
133
- * pressed ❓ to mean "none of these fit" — the line makes that an explicit
134
- * choice, and says where the actual answer has to go: ❓ itself carries no
171
+ * after the numbered ones. It is the ➕ pill (`ASK_OTHER_MARKER`; ❓ on pills
172
+ * posted before 2026-10-14), seeded LAST so the reaction row reads the same as
173
+ * the body: 1️⃣ 2️⃣ 3️⃣ ➕. The line makes "none of these fit" an explicit
174
+ * choice, and says where the actual answer has to go: ➕ itself carries no
135
175
  * answer text, so it never resolves the question; the reply that follows it
136
176
  * does (exit 5, delivered as free text). Optional on the way in: questions
137
177
  * posted before it existed have no such line and must stay collectable.
@@ -147,8 +187,10 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
147
187
  "_このメッセージの *スレッド* で返信してください。本文がそのまま回答になります (チャンネルへの通常投稿は回答として拾いません)。_",
148
188
  instructionTextHere: "_このメッセージに返信してください。本文がそのまま回答になります。_",
149
189
  overflowNote: "_11 番目以降はリアクションがないので、返信で答えてください。_",
150
- otherThread: `${ASK_MARKER_PREFIX}その他 — スレッドで返信してください`,
151
- otherHere: `${ASK_MARKER_PREFIX}その他 — このメッセージに返信してください`,
190
+ otherThread: `${ASK_OTHER_PREFIX}その他 — スレッドで返信してください`,
191
+ otherHere: `${ASK_OTHER_PREFIX}その他 — このメッセージに返信してください`,
192
+ explainLine: `${ASK_EXPLAIN_PREFIX}説明して — 押すと質問者に説明を頼みます (回答にはなりません)`,
193
+ explainNote: `${ASK_EXPLAIN_PREFIX}説明のリクエストを受け付けました — このスレッドで説明します。質問はまだ開いているので、回答はそのあとで大丈夫です。`,
152
194
  invalidPrefix: "_:warning: 同時に複数選ばれているため回答として数えていません — どれか 1 つだけ残してください: ",
153
195
  answeredVia: (how, who) => `_${how}で回答済み${who ? ` (${who})` : ""}_`,
154
196
  howReaction: (glyph) => `リアクション ${glyph}`,
@@ -166,8 +208,10 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
166
208
  "_Reply in this message's *thread* — your reply is the answer (ordinary channel posts are not picked up as answers)._",
167
209
  instructionTextHere: "_Reply to this message — your reply is the answer._",
168
210
  overflowNote: "_Choices 11 and up have no reaction — answer them with a reply._",
169
- otherThread: `${ASK_MARKER_PREFIX}Other — reply in the thread`,
170
- otherHere: `${ASK_MARKER_PREFIX}Other — reply to this message`,
211
+ otherThread: `${ASK_OTHER_PREFIX}Other — reply in the thread`,
212
+ otherHere: `${ASK_OTHER_PREFIX}Other — reply to this message`,
213
+ explainLine: `${ASK_EXPLAIN_PREFIX}Explain — press to ask the asker for more detail (not an answer)`,
214
+ explainNote: `${ASK_EXPLAIN_PREFIX}Explanation requested — it will follow in this thread. The question stays open; answer it after that.`,
171
215
  invalidPrefix: "_:warning: Several choices are selected, so this is not counted as an answer — keep only one: ",
172
216
  answeredVia: (how, who) => `_Answered by ${how}${who ? ` (${who})` : ""}_`,
173
217
  howReaction: (glyph) => `reaction ${glyph}`,
@@ -178,11 +222,36 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
178
222
  },
179
223
  };
180
224
 
181
- /** True for the "other" line in either spelling — the shortcode we write, or the
182
- * glyph a body hand-edited in the Slack UI can carry. Matched by prefix, not
183
- * verbatim, so the copy stays free to change. */
184
- function askIsOtherLine(line: string | undefined): boolean {
185
- return line !== undefined && (line.startsWith(ASK_MARKER_PREFIX) || line.startsWith("❓ "));
225
+ /** The "other" copy pills posted before ➕ carried — the same words under ❓.
226
+ * Kept so `askSalvageUnreadable` can still strip it from an old flattened body. */
227
+ const ASK_LEGACY_OTHER_LINES: string[] = ASK_LANGS.flatMap((l) =>
228
+ [ASK_COPY[l].otherThread, ASK_COPY[l].otherHere].map((s) => ASK_MARKER_PREFIX + s.slice(ASK_OTHER_PREFIX.length)));
229
+
230
+ /** Which reaction the "other" line names, or null if the line is not one: ➕ or
231
+ * ❓ (pills posted before ➕), each as the shortcode we write or the glyph a
232
+ * body hand-edited in the Slack UI can carry. Matched by prefix, not verbatim,
233
+ * so the copy stays free to change. */
234
+ function askOtherLineMarker(line: string | undefined): string | null {
235
+ if (line === undefined) return null;
236
+ if (line.startsWith(ASK_OTHER_PREFIX) || line.startsWith("➕ ")) return ASK_OTHER_MARKER;
237
+ if (line.startsWith(ASK_MARKER_PREFIX) || line.startsWith("❓ ")) return ASK_MARKER;
238
+ return null;
239
+ }
240
+
241
+ /** True for the explain line, in either spelling. Matched by prefix, like the
242
+ * other line — used where position alone identifies it (under ❓). */
243
+ function askIsExplainLine(line: string | undefined): boolean {
244
+ return line !== undefined && (line.startsWith(ASK_EXPLAIN_PREFIX) || line.startsWith("ℹ️ "));
245
+ }
246
+
247
+ /** True only for this language's explain line AS WRITTEN (either spelling of
248
+ * the marker). For a free-text question, where nothing but position separates
249
+ * the explain line from the body above it: a body whose own last line starts
250
+ * with ℹ️ must not be read as the pill, so the copy has to match exactly. */
251
+ function askIsExplainCopy(line: string | undefined, c: AskCopy): boolean {
252
+ if (line === undefined) return false;
253
+ const norm = line.startsWith("ℹ️ ") ? ASK_EXPLAIN_PREFIX + line.slice("ℹ️ ".length) : line;
254
+ return norm === c.explainLine;
186
255
  }
187
256
 
188
257
  // The invalid-ballot notice, shared by `ask` and `poll` and written INTO the
@@ -228,6 +297,14 @@ export function applyInvalidNotice(text: string, invalid: string[], lang: AskLan
228
297
  return out === text ? text : out;
229
298
  }
230
299
 
300
+ /** True for the thread note `--wait`/`--waitFor` posts when ℹ️ is pressed, in
301
+ * either language. Our own bookkeeping: never an answer, never a note — even
302
+ * when the identity that posted it is in the audience (an @here question
303
+ * collected with another token). */
304
+ export function askIsExplainNote(text: unknown): boolean {
305
+ return typeof text === "string" && ASK_LANGS.some((l) => ASK_COPY[l].explainNote === text.trim());
306
+ }
307
+
231
308
  /** True if this line is the notice — parsers skip it when walking up. */
232
309
  export function isInvalidNotice(line: string | undefined): boolean {
233
310
  return invalidLang(line) !== null;
@@ -242,7 +319,7 @@ export const ASK_RESOLVED_PREFIX = `:${ASK_RESOLVED_MARKER}: `;
242
319
  /** Prefix `--void` stamps on a question that should no longer be answered —
243
320
  * expired, superseded, or overtaken by events (無効化). A THIRD state beside open
244
321
  * (`:question:`) and answered (`:white_check_mark:`), on purpose: anything that
245
- * keys on "starts with :question:" (the stale-pill reminder, `has::question:`)
322
+ * keys on "starts with :question:" (the stale-pill reminder, `has::question: OR has::heavy_plus_sign:`)
246
323
  * stops counting it the moment it is voided, and nothing reads it as answered.
247
324
  * Also seeded as a reaction, so `has::no_entry_sign:` lists every void. */
248
325
  export const ASK_VOID_MARKER = "no_entry_sign";
@@ -293,7 +370,7 @@ export function askFlatten(s: string): string {
293
370
  /** Question body: the prompt, the numbered choices matching the seeded pills,
294
371
  * and an instruction naming the answer paths this command actually reads.
295
372
  * `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 {
373
+ export function askBuildText(question: string, body: string, reactable: string[], overflow: string[], threadOnly: boolean, lang: AskLang = "ja", explain = false): string {
297
374
  const c = ASK_COPY[lang];
298
375
  const lines: string[] = [`${ASK_MARKER_PREFIX}*${question}*`];
299
376
  if (body) lines.push("", body);
@@ -307,8 +384,10 @@ export function askBuildText(question: string, body: string, reactable: string[]
307
384
  // form keeps posted and read-back bytes identical, which is what the
308
385
  // round-trip test can actually check. (`poll` already did this.)
309
386
  lines.push(...reactable.map((s, i) => `${askPill(i)} ${askFlatten(s)}`));
310
- // Directly under the pills, matching the ❓ seeded right after them.
387
+ // Directly under the pills, matching the ➕ seeded right after them.
311
388
  lines.push(threadOnly ? c.otherThread : c.otherHere);
389
+ // Under ➕, matching the ℹ️ seeded right after it.
390
+ if (explain) lines.push(c.explainLine);
312
391
  if (overflow.length) {
313
392
  lines.push("");
314
393
  lines.push(...overflow.map((s, i) => `(${i + 11}) ${askFlatten(s)}`));
@@ -321,6 +400,7 @@ export function askBuildText(question: string, body: string, reactable: string[]
321
400
  lines.push(threadOnly ? c.instructionReactionThread : c.instructionReactionHere);
322
401
  } else {
323
402
  lines.push("");
403
+ if (explain) lines.push(c.explainLine, "");
324
404
  lines.push(threadOnly ? c.instructionTextThread : c.instructionTextHere);
325
405
  }
326
406
  return lines.join("\n");
@@ -334,7 +414,7 @@ export function askBuildText(question: string, body: string, reactable: string[]
334
414
  * decision was made against) and the option line that was chosen. Reported
335
415
  * from real use 2026-10-05: collecting an answer replaced the whole message
336
416
  * with question + answer, and the context of the decision was gone. Only what
337
- * no longer applies goes — the options NOT chosen, the ❓ line, the answering
417
+ * no longer applies goes — the options NOT chosen, the ➕ (or ❓) line, the answering
338
418
  * instructions.
339
419
  *
340
420
  * The head is unchanged on purpose: ✅ prefix, question, stamp, blank, quoted
@@ -405,7 +485,7 @@ export function askSalvageUnreadable(text: string): { question: string; body: st
405
485
  for (const instr of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere]) {
406
486
  if (body.includes(instr)) wasAsk = true;
407
487
  }
408
- for (const fixed of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere, c.overflowNote, c.otherThread, c.otherHere]) {
488
+ for (const fixed of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere, c.overflowNote, c.otherThread, c.otherHere, ...ASK_LEGACY_OTHER_LINES, c.explainLine]) {
409
489
  body = body.split(fixed).join(" ");
410
490
  }
411
491
  const at = body.indexOf(c.invalidPrefix);
@@ -454,7 +534,8 @@ function askDecodeEntities(s: string): string {
454
534
  }
455
535
 
456
536
  export type AskParsed =
457
- | { kind: "open"; question: string; body: string; reactable: string[]; overflow: string[]; threadOnly: boolean; lang: AskLang }
537
+ /** `explain`: the body offers the ℹ️ explain pill (`--explain`). */
538
+ | { kind: "open"; question: string; body: string; reactable: string[]; overflow: string[]; threadOnly: boolean; lang: AskLang; explain: boolean }
458
539
  | { kind: "resolved"; question: string; answer: string }
459
540
  /** Voided (無効化). `reason` is the stamp line as written; `supersededBy` the
460
541
  * replacement's link when one was given. */
@@ -594,6 +675,7 @@ export function askParseMessage(text: string): AskParsed {
594
675
  // there is the body — kept by the ✅ rewrite.
595
676
  let bodyEnd = lines.length - 1;
596
677
  if (isInvalidNotice(lines[bodyEnd - 1])) bodyEnd--;
678
+ let explain = false;
597
679
  if (hasChoices) {
598
680
  // Walk UP from the instruction line rather than down from the question. The
599
681
  // choice block is anchored to the bottom of the body, and scanning downward
@@ -609,7 +691,8 @@ export function askParseMessage(text: string): AskParsed {
609
691
  if (lines[i] !== "") return { kind: "other" };
610
692
  i--;
611
693
  }
612
- if (askIsOtherLine(lines[i])) i--;
694
+ if (askIsExplainLine(lines[i])) { explain = true; i--; }
695
+ if (askOtherLineMarker(lines[i])) i--;
613
696
  const raw: string[] = [];
614
697
  while (i >= 0) {
615
698
  const line = lines[i]!;
@@ -634,10 +717,39 @@ export function askParseMessage(text: string): AskParsed {
634
717
  for (let n = 0; n < overflow.length; n++) {
635
718
  if (!lines.includes(`(${n + 11}) ${overflow[n]}`)) return { kind: "other" };
636
719
  }
720
+ } else if (lines[bodyEnd - 1] === "" && askIsExplainCopy(lines[bodyEnd - 2], c) && lines[bodyEnd - 3] === "" && bodyEnd - 3 >= end) {
721
+ // A free-text question's explain line: blank, the line, blank, instruction
722
+ // — and the line is our copy verbatim. A body that merely ENDS on a line
723
+ // starting with ℹ️ (a question built without --explain) stays body.
724
+ explain = true;
725
+ bodyEnd -= 2;
637
726
  }
638
727
 
639
728
  const body = lines.slice(end + 1, bodyEnd).join("\n").replace(/^\n+|\n+$/g, "");
640
- return { kind: "open", question, body, reactable, overflow, threadOnly, lang };
729
+ return { kind: "open", question, body, reactable, overflow, threadOnly, lang, explain };
730
+ }
731
+
732
+ /** The marker reaction `ask` seeded on an open question: ➕ when its other line
733
+ * says ➕, else ❓ (a free-text question, or a pill posted before ➕ — with a ❓
734
+ * other line or none at all). What void / edit / the ✅ swap take off. Read
735
+ * from the line directly under the last numbered pill, the one place the
736
+ * other line can be; null when the text is not an open ask.
737
+ *
738
+ * Separate from `askParseMessage` so the parse result keeps its shape. */
739
+ export function askSeedMarker(text: string): string | null {
740
+ const p = askParseMessage(text);
741
+ if (p.kind !== "open") return null;
742
+ if (!p.reactable.length) return ASK_MARKER;
743
+ const lines = text.split("\n");
744
+ const last = askPill(p.reactable.length - 1);
745
+ const k = ASK_KEYCAPS[p.reactable.length - 1]!.glyph;
746
+ // The last pill line is the LAST line opening with that keycap: the block is
747
+ // anchored to the bottom, and a body line can start with the same keycap.
748
+ let at = -1;
749
+ for (let i = lines.length - 1; i >= 0; i--) {
750
+ if (lines[i]!.startsWith(`${last} `) || lines[i] === last || lines[i]!.startsWith(`${k} `) || lines[i] === k) { at = i; break; }
751
+ }
752
+ return (at >= 0 ? askOtherLineMarker(lines[at + 1]) : null) ?? ASK_MARKER;
641
753
  }
642
754
 
643
755
  /** Did a free-text reply actually PICK one of the offered choices?