slack-term 1.40.0 → 1.41.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.40.0",
3
+ "version": "1.41.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/ask.ts CHANGED
@@ -20,7 +20,11 @@ export const ASK_KEYCAPS = [
20
20
  ] as const;
21
21
  export const ASK_MAX_REACTION_CHOICES = ASK_KEYCAPS.length;
22
22
 
23
- export type AskFound = { answer: string; how: string; who?: string };
23
+ /** `choice` is the 1-based number of the option picked, when one was (a pill,
24
+ * or a reply naming it) — the one option line the ✅ body keeps.
25
+ * `ts` is set when the answer is a REPLY — so that reply is not delivered again
26
+ * as a thread note alongside the answer it already is. */
27
+ export type AskFound = { answer: string; how: string; who?: string; ts?: string; choice?: number };
24
28
 
25
29
  /** The marker that says "this message is an `ask`". A shortcode, so it is the
26
30
  * SAME token in every language — that is the whole point of it.
@@ -37,19 +41,149 @@ export type AskFound = { answer: string; how: string; who?: string };
37
41
  export const ASK_MARKER = "question";
38
42
  export const ASK_MARKER_PREFIX = `:${ASK_MARKER}: `;
39
43
 
40
- // The instruction lines. These are ordinary copy now — translate them freely.
41
- // They are still matched verbatim when reading a message posted BEFORE the
42
- // marker existed, which is the only reason the exact old strings survive here:
43
- // deleting them would strand every question still in flight.
44
- const ASK_INSTRUCTION_REACTION_THREAD =
45
- "_下のリアクションを 1 つ押すと回答になります。当てはまるものがなければ、このメッセージの *スレッド* で返信してください (チャンネルへの通常投稿は回答として拾いません)。_";
46
- const ASK_INSTRUCTION_REACTION_HERE =
47
- "_下のリアクションを 1 つ押すと回答になります。当てはまるものがなければ、このメッセージに返信してください。_";
48
- const ASK_INSTRUCTION_TEXT_THREAD =
49
- "_このメッセージの *スレッド* で返信してください。本文がそのまま回答になります (チャンネルへの通常投稿は回答として拾いません)。_";
50
- const ASK_INSTRUCTION_TEXT_HERE =
51
- "_このメッセージに返信してください。本文がそのまま回答になります。_";
52
- const ASK_OVERFLOW_NOTE = "_11 番目以降はリアクションがないので、返信で答えてください。_";
44
+ // The copy a reader sees, per language. Japanese is the default and its
45
+ // strings are a WIRE FORMAT: questions already in flight were posted with them
46
+ // and are recognised by them, so they must never be reworded. Adding a language
47
+ // is safe — the parser accepts every language's instruction lines — but once a
48
+ // language has shipped, its instruction and overflow lines are frozen the same
49
+ // way. Everything else here (the other line, the answered stamp) is matched by
50
+ // prefix or not at all, and is free to change.
51
+ export const ASK_LANGS = ["ja", "en"] as const;
52
+ export type AskLang = (typeof ASK_LANGS)[number];
53
+
54
+ /** Where the language came from — shown on the confirm gate, so a surprising
55
+ * choice can be traced and overridden before anything is posted. */
56
+ export type AskLangSource = "flag" | "env" | "readers" | "content" | "system" | "default";
57
+
58
+ /** Pick the language the body's own copy is written in. In order:
59
+ *
60
+ * 1. `--lang`, else `SLACK_TERM_LANG` — explicit. A value naming no supported
61
+ * language is an error (null): a typo must not quietly post in the wrong one.
62
+ * 2. The READERS' Slack locale (`users.info?include_locale`) — the people the
63
+ * question is addressed to, who are the ones who have to read the copy.
64
+ * Slack exposes no workspace-wide language to an ordinary token, but it does
65
+ * expose each person's. Used only when every reader maps to the SAME
66
+ * supported language: a mixed group has no single right answer, and an
67
+ * `@here` audience is not a known set of people (the caller passes none).
68
+ * 3. The CONTENT — what the asker wrote (question, body, choices). Instructions
69
+ * in a different language from the question they sit under read as noise.
70
+ * 4. The system locale (`LC_ALL` → `LC_MESSAGES` → `LANG`), for content with no
71
+ * letters at all (emoji, numbers).
72
+ * 5. `ja`, the copy `ask` has always posted — so a machine nobody configured,
73
+ * asking someone whose locale cannot be read, keeps doing what it did. */
74
+ export function askResolveLang(
75
+ opts: { flag?: string | undefined; readerLocales?: (string | undefined)[]; content?: string[] },
76
+ env: NodeJS.ProcessEnv = process.env,
77
+ ): { lang: AskLang; source: AskLangSource } | null {
78
+ for (const [raw, source] of [[opts.flag, "flag"], [env.SLACK_TERM_LANG, "env"]] as const) {
79
+ if (raw === undefined || raw.trim() === "") continue;
80
+ const v = raw.trim().toLowerCase();
81
+ return (ASK_LANGS as readonly string[]).includes(v) ? { lang: v as AskLang, source } : null;
82
+ }
83
+ const readers = (opts.readerLocales ?? []).map(askLangOfLocale);
84
+ if (readers.length && readers[0] && readers.every((l) => l === readers[0])) return { lang: readers[0], source: "readers" };
85
+ const fromContent = askDetectLang((opts.content ?? []).join("\n"));
86
+ if (fromContent) return { lang: fromContent, source: "content" };
87
+ const sys = askLangOfLocale(env.LC_ALL || env.LC_MESSAGES || env.LANG);
88
+ if (sys) return { lang: sys, source: "system" };
89
+ return { lang: "ja", source: "default" };
90
+ }
91
+
92
+ /** `ja-JP` (Slack), `ja_JP.UTF-8` (POSIX), `en` → the language we have copy
93
+ * for, or null (C/POSIX, unset, or a language not listed). */
94
+ export function askLangOfLocale(locale: string | undefined): AskLang | null {
95
+ const v = (locale ?? "").toLowerCase();
96
+ return ASK_LANGS.find((l) => v === l || /^[_.\-@]/.test(v.slice(l.length)) && v.startsWith(l)) ?? null;
97
+ }
98
+
99
+ /** The language `text` is written in, or null when it has no letters to tell
100
+ * by. Two languages only, so a script check is exact enough: kana or kanji
101
+ * means Japanese (kanji alone could be Chinese, but `ja` is the nearest copy
102
+ * we have), and Latin letters with neither mean English. */
103
+ export function askDetectLang(text: string): AskLang | null {
104
+ const words = text
105
+ .replace(/<[^>\s]*>/g, " ") // <@U…>, <#C…|name>, <!here>, <https://…|label>
106
+ .replace(/https?:\/\/\S+/g, " ")
107
+ .replace(/:[a-z0-9_+-]+:/g, " "); // :shortcode: emoji
108
+ if (/[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/u.test(words)) return "ja";
109
+ if (/\p{Script=Latin}/u.test(words)) return "en";
110
+ return null;
111
+ }
112
+
113
+ interface AskCopy {
114
+ instructionReactionThread: string;
115
+ instructionReactionHere: string;
116
+ instructionTextThread: string;
117
+ instructionTextHere: string;
118
+ overflowNote: string;
119
+ otherThread: string;
120
+ otherHere: string;
121
+ invalidPrefix: string;
122
+ answeredVia: (how: string, who: string) => string;
123
+ howReaction: (glyph: string) => string;
124
+ howReply: string;
125
+ howReplyN: (n: number) => string;
126
+ voidStamp: (reason: string) => string;
127
+ supersededBy: (link: string) => string;
128
+ }
129
+
130
+ /** 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
135
+ * answer text, so it never resolves the question; the reply that follows it
136
+ * does (exit 5, delivered as free text). Optional on the way in: questions
137
+ * posted before it existed have no such line and must stay collectable.
138
+ *
139
+ * The invalid-ballot prefix is shared with `poll`, which posts it in Japanese. */
140
+ export const ASK_COPY: Record<AskLang, AskCopy> = {
141
+ ja: {
142
+ instructionReactionThread:
143
+ "_下のリアクションを 1 つ押すと回答になります。当てはまるものがなければ、このメッセージの *スレッド* で返信してください (チャンネルへの通常投稿は回答として拾いません)。_",
144
+ instructionReactionHere:
145
+ "_下のリアクションを 1 つ押すと回答になります。当てはまるものがなければ、このメッセージに返信してください。_",
146
+ instructionTextThread:
147
+ "_このメッセージの *スレッド* で返信してください。本文がそのまま回答になります (チャンネルへの通常投稿は回答として拾いません)。_",
148
+ instructionTextHere: "_このメッセージに返信してください。本文がそのまま回答になります。_",
149
+ overflowNote: "_11 番目以降はリアクションがないので、返信で答えてください。_",
150
+ otherThread: `${ASK_MARKER_PREFIX}その他 — スレッドで返信してください`,
151
+ otherHere: `${ASK_MARKER_PREFIX}その他 — このメッセージに返信してください`,
152
+ invalidPrefix: "_:warning: 同時に複数選ばれているため回答として数えていません — どれか 1 つだけ残してください: ",
153
+ answeredVia: (how, who) => `_${how}で回答済み${who ? ` (${who})` : ""}_`,
154
+ howReaction: (glyph) => `リアクション ${glyph}`,
155
+ howReply: "返信",
156
+ howReplyN: (n) => `返信 (${n})`,
157
+ voidStamp: (reason) => `_無効${reason ? `: ${reason}` : ""} — この質問への回答は受け付けません。_`,
158
+ supersededBy: (link) => `↪ 新しい質問: ${link}`,
159
+ },
160
+ en: {
161
+ instructionReactionThread:
162
+ "_Press one of the reactions below to answer. If none fits, reply in this message's *thread* (ordinary channel posts are not picked up as answers)._",
163
+ instructionReactionHere:
164
+ "_Press one of the reactions below to answer. If none fits, reply to this message._",
165
+ instructionTextThread:
166
+ "_Reply in this message's *thread* — your reply is the answer (ordinary channel posts are not picked up as answers)._",
167
+ instructionTextHere: "_Reply to this message — your reply is the answer._",
168
+ 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`,
171
+ invalidPrefix: "_:warning: Several choices are selected, so this is not counted as an answer — keep only one: ",
172
+ answeredVia: (how, who) => `_Answered by ${how}${who ? ` (${who})` : ""}_`,
173
+ howReaction: (glyph) => `reaction ${glyph}`,
174
+ howReply: "reply",
175
+ howReplyN: (n) => `reply (${n})`,
176
+ voidStamp: (reason) => `_Void${reason ? `: ${reason}` : ""} — this question no longer takes answers._`,
177
+ supersededBy: (link) => `↪ Replaced by: ${link}`,
178
+ },
179
+ };
180
+
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("❓ "));
186
+ }
53
187
 
54
188
  // The invalid-ballot notice, shared by `ask` and `poll` and written INTO the
55
189
  // message rather than only onto the collector's terminal. The person who has to
@@ -61,15 +195,19 @@ const ASK_OVERFLOW_NOTE = "_11 番目以降はリアクションがないので
61
195
  // duplicate across reruns and it disappears by itself the moment the extra
62
196
  // reaction is taken back. A reply, once posted, is a permanent record of a
63
197
  // situation that has since been fixed.
64
- const INVALID_PREFIX = "_:warning: 同時に複数選ばれているため回答として数えていません — どれか 1 つだけ残してください: ";
65
198
  const INVALID_SUFFIX = "_";
66
199
 
200
+ function invalidLang(line: string | undefined): AskLang | null {
201
+ if (line === undefined || !line.endsWith(INVALID_SUFFIX)) return null;
202
+ return ASK_LANGS.find((l) => line.startsWith(ASK_COPY[l].invalidPrefix)) ?? null;
203
+ }
204
+
67
205
  /** The IDs named by a message's invalid-ballot line, or [] if it has none. */
68
206
  export function readInvalidNotice(text: string): string[] {
69
207
  const lines = text.split("\n");
70
208
  const line = lines[lines.length - 2];
71
- if (line === undefined || !line.startsWith(INVALID_PREFIX) || !line.endsWith(INVALID_SUFFIX)) return [];
72
- return [...line.matchAll(/<@([UW][A-Z0-9]+)>/g)].map((m) => m[1]!);
209
+ if (!invalidLang(line)) return [];
210
+ return [...line!.matchAll(/<@([UW][A-Z0-9]+)>/g)].map((m) => m[1]!);
73
211
  }
74
212
 
75
213
  /** Put `invalid` into the body — inserting, replacing or REMOVING the notice so
@@ -79,12 +217,12 @@ export function readInvalidNotice(text: string): string[] {
79
217
  *
80
218
  * The notice sits directly above the instruction line because that line has to
81
219
  * stay last: it is what identifies the message as an `ask`/`poll` at all. */
82
- export function applyInvalidNotice(text: string, invalid: string[]): string {
220
+ export function applyInvalidNotice(text: string, invalid: string[], lang: AskLang = "ja"): string {
83
221
  const lines = text.split("\n");
84
222
  if (lines.length < 2) return text;
85
223
  if (readInvalidNotice(text).length) lines.splice(lines.length - 2, 1);
86
224
  if (invalid.length) {
87
- lines.splice(lines.length - 1, 0, `${INVALID_PREFIX}${invalid.map((u) => `<@${u}>`).join(", ")}${INVALID_SUFFIX}`);
225
+ lines.splice(lines.length - 1, 0, `${ASK_COPY[lang].invalidPrefix}${invalid.map((u) => `<@${u}>`).join(", ")}${INVALID_SUFFIX}`);
88
226
  }
89
227
  const out = lines.join("\n");
90
228
  return out === text ? text : out;
@@ -92,7 +230,7 @@ export function applyInvalidNotice(text: string, invalid: string[]): string {
92
230
 
93
231
  /** True if this line is the notice — parsers skip it when walking up. */
94
232
  export function isInvalidNotice(line: string | undefined): boolean {
95
- return line !== undefined && line.startsWith(INVALID_PREFIX) && line.endsWith(INVALID_SUFFIX);
233
+ return invalidLang(line) !== null;
96
234
  }
97
235
 
98
236
  /** Prefix `markResolved` stamps on a settled question. `--waitFor` keys "this
@@ -101,6 +239,20 @@ export function isInvalidNotice(line: string | undefined): boolean {
101
239
  export const ASK_RESOLVED_MARKER = "white_check_mark";
102
240
  export const ASK_RESOLVED_PREFIX = `:${ASK_RESOLVED_MARKER}: `;
103
241
 
242
+ /** Prefix `--void` stamps on a question that should no longer be answered —
243
+ * expired, superseded, or overtaken by events (無効化). A THIRD state beside open
244
+ * (`:question:`) and answered (`:white_check_mark:`), on purpose: anything that
245
+ * keys on "starts with :question:" (the stale-pill reminder, `has::question:`)
246
+ * stops counting it the moment it is voided, and nothing reads it as answered.
247
+ * Also seeded as a reaction, so `has::no_entry_sign:` lists every void. */
248
+ export const ASK_VOID_MARKER = "no_entry_sign";
249
+ export const ASK_VOID_PREFIX = `:${ASK_VOID_MARKER}: `;
250
+
251
+ /** A question voided BY HAND before `--void` existed: the release bot prefixed
252
+ * the body with 「【superseded / 作废】」. Read as void too, so monitors stop on
253
+ * those as well instead of rejecting them as "not an ask". */
254
+ const ASK_LEGACY_VOID_RE = /^【[^】]*(?:作废|作廢|superseded|void)[^】]*】/i;
255
+
104
256
  /** How a keycap is WRITTEN into the body, and how it is read back.
105
257
  *
106
258
  * Slack NORMALISES a unicode keycap in the stored `text`: post `1️⃣` and
@@ -141,7 +293,8 @@ export function askFlatten(s: string): string {
141
293
  /** Question body: the prompt, the numbered choices matching the seeded pills,
142
294
  * and an instruction naming the answer paths this command actually reads.
143
295
  * `askParseMessage` is its inverse — keep the two in step. */
144
- export function askBuildText(question: string, body: string, reactable: string[], overflow: string[], threadOnly: boolean): string {
296
+ export function askBuildText(question: string, body: string, reactable: string[], overflow: string[], threadOnly: boolean, lang: AskLang = "ja"): string {
297
+ const c = ASK_COPY[lang];
145
298
  const lines: string[] = [`${ASK_MARKER_PREFIX}*${question}*`];
146
299
  if (body) lines.push("", body);
147
300
  if (reactable.length) {
@@ -154,29 +307,144 @@ export function askBuildText(question: string, body: string, reactable: string[]
154
307
  // form keeps posted and read-back bytes identical, which is what the
155
308
  // round-trip test can actually check. (`poll` already did this.)
156
309
  lines.push(...reactable.map((s, i) => `${askPill(i)} ${askFlatten(s)}`));
310
+ // Directly under the pills, matching the ❓ seeded right after them.
311
+ lines.push(threadOnly ? c.otherThread : c.otherHere);
157
312
  if (overflow.length) {
158
313
  lines.push("");
159
314
  lines.push(...overflow.map((s, i) => `(${i + 11}) ${askFlatten(s)}`));
160
- lines.push(ASK_OVERFLOW_NOTE);
315
+ lines.push(c.overflowNote);
161
316
  }
162
317
  lines.push("");
163
318
  // The instruction must name the exact path the poller reads. Where only
164
319
  // thread replies are picked up, telling people to "reply to this message"
165
320
  // would get in-channel answers silently ignored.
166
- lines.push(threadOnly ? ASK_INSTRUCTION_REACTION_THREAD : ASK_INSTRUCTION_REACTION_HERE);
321
+ lines.push(threadOnly ? c.instructionReactionThread : c.instructionReactionHere);
167
322
  } else {
168
323
  lines.push("");
169
- lines.push(threadOnly ? ASK_INSTRUCTION_TEXT_THREAD : ASK_INSTRUCTION_TEXT_HERE);
324
+ lines.push(threadOnly ? c.instructionTextThread : c.instructionTextHere);
170
325
  }
171
326
  return lines.join("\n");
172
327
  }
173
328
 
174
329
  /** Resolved-question body. Every answer line is quoted, not just the first —
175
330
  * a multi-line reply left unquoted after the first line cannot be told back
176
- * apart from the surrounding text when `--waitFor` reads it. */
177
- export function askBuildResolvedText(question: string, found: AskFound, who: string): string {
331
+ * apart from the surrounding text when `--waitFor` reads it.
332
+ *
333
+ * `keep` carries what the question was asked WITH: its body (the background a
334
+ * decision was made against) and the option line that was chosen. Reported
335
+ * from real use 2026-10-05: collecting an answer replaced the whole message
336
+ * 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
338
+ * instructions.
339
+ *
340
+ * The head is unchanged on purpose: ✅ prefix, question, stamp, blank, quoted
341
+ * answer. The body follows the answer, so everything that reads a ✅ message
342
+ * — scripts keyed on the prefix, `askParseMessage`, `askResolvedHow` — finds
343
+ * what it always found where it always found it. */
344
+ export function askBuildResolvedText(
345
+ question: string, found: AskFound, who: string, lang: AskLang = "ja",
346
+ keep?: { body?: string; chosenLine?: string },
347
+ ): string {
178
348
  const quoted = found.answer.split("\n").map((l) => `> ${l}`).join("\n");
179
- return `${ASK_RESOLVED_PREFIX}*${question}*\n_${found.how}で回答済み${who ? ` (${who})` : ""}_\n\n${quoted}`;
349
+ let out = `${ASK_RESOLVED_PREFIX}*${question}*\n${ASK_COPY[lang].answeredVia(found.how, who)}\n\n${quoted}`;
350
+ if (keep?.body) out += `\n\n${keep.body}`;
351
+ if (keep?.chosenLine) out += `\n\n${keep.chosenLine}`;
352
+ return out;
353
+ }
354
+
355
+ /** What a ✅ rewrite keeps from the OPEN message: its body, and the line of the
356
+ * option that was chosen (pill or overflow), written as the open body wrote it.
357
+ * Read from the message itself, so it is whatever is posted now — including
358
+ * an edit made in the Slack UI after asking. Undefined when the text is not an
359
+ * open ask, or there is nothing to keep (the bare form, as before). */
360
+ export function askResolvedKeep(openText: string, choice?: number): { body?: string; chosenLine?: string } | undefined {
361
+ const p = askParseMessage(openText);
362
+ if (p.kind !== "open") return undefined;
363
+ const keep: { body?: string; chosenLine?: string } = {};
364
+ if (p.body) keep.body = p.body;
365
+ if (choice !== undefined && choice >= 1) {
366
+ if (choice <= p.reactable.length) keep.chosenLine = `${askPill(choice - 1)} ${askFlatten(p.reactable[choice - 1]!)}`;
367
+ else if (choice - ASK_MAX_REACTION_CHOICES <= p.overflow.length && choice > ASK_MAX_REACTION_CHOICES) {
368
+ keep.chosenLine = `(${choice}) ${askFlatten(p.overflow[choice - ASK_MAX_REACTION_CHOICES - 1]!)}`;
369
+ }
370
+ }
371
+ return keep.body || keep.chosenLine ? keep : undefined;
372
+ }
373
+
374
+ /** Voided-question body: the 🚫 head, the void stamp (with the reason), an
375
+ * optional pointer to the question that replaces it, then the body — kept for
376
+ * the same reason the ✅ body keeps it: the context outlives the question.
377
+ * The options and the answering instructions go; none of them apply now. */
378
+ export function askBuildVoidText(question: string, reason: string, lang: AskLang = "ja", body?: string, supersededBy?: string): string {
379
+ const c = ASK_COPY[lang];
380
+ let out = `${ASK_VOID_PREFIX}*${question}*\n${c.voidStamp(askFlatten(reason.trim()))}`;
381
+ if (supersededBy) out += `\n${c.supersededBy(supersededBy)}`;
382
+ if (body) out += `\n\n${body}`;
383
+ return out;
384
+ }
385
+
386
+ /** Best-effort read of an `ask` whose layout no longer parses — typically one
387
+ * whose stored text lost its newlines (a `slack edit` that attached blocks made
388
+ * Slack rewrite every newline to a space; 2026-10-01). Only what `--void` needs
389
+ * to retire it: the question (the leading bold run after the ❓ marker) and the
390
+ * rest as the body, minus the answering instructions and the invalid-ballot
391
+ * line, which no longer apply. Null unless it starts with the ❓ marker and a
392
+ * bold run AND still carries one of `ask`'s answering instructions verbatim. The options stay in the body as text: they are part of what was
393
+ * asked, and voiding is no time to guess where they start. */
394
+ export function askSalvageUnreadable(text: string): { question: string; body: string } | null {
395
+ if (!text.startsWith(ASK_MARKER_PREFIX)) return null;
396
+ const rest = text.slice(ASK_MARKER_PREFIX.length);
397
+ const m = rest.match(/^\*([\s\S]+?)\*(?=\s|$)/);
398
+ if (!m) return null;
399
+ let body = rest.slice(m[0].length);
400
+ // Evidence that this WAS an ask, not just any message that opens with ❓ and
401
+ // a bold line: one of the answering instructions `ask` writes, verbatim.
402
+ let wasAsk = false;
403
+ for (const l of ASK_LANGS) {
404
+ const c = ASK_COPY[l];
405
+ for (const instr of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere]) {
406
+ if (body.includes(instr)) wasAsk = true;
407
+ }
408
+ for (const fixed of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere, c.overflowNote, c.otherThread, c.otherHere]) {
409
+ body = body.split(fixed).join(" ");
410
+ }
411
+ const at = body.indexOf(c.invalidPrefix);
412
+ if (at >= 0) {
413
+ const end = body.indexOf(INVALID_SUFFIX, at + c.invalidPrefix.length);
414
+ body = body.slice(0, at) + " " + (end >= 0 ? body.slice(end + INVALID_SUFFIX.length) : "");
415
+ }
416
+ }
417
+ if (!wasAsk) return null;
418
+ return { question: m[1]!, body: body.replace(/[ \t]{3,}/g, " ").trim() };
419
+ }
420
+
421
+ /** Where a ✅ body's stamp line is, found by its SHAPE: the first line after
422
+ * the head that is `_…_`, followed by a blank line and a quoted line — the
423
+ * layout `askBuildResolvedText` writes. Not by the end of the question's bold
424
+ * run: a question's own first line can end in `*` ("*重要*\n…"), which ends
425
+ * the run early, and reading `lines[1]` blindly took a multi-line question's
426
+ * second line for the stamp. -1 when there is no such line. */
427
+ function askResolvedStampIndex(lines: string[]): number {
428
+ if (!lines[0]?.startsWith(ASK_RESOLVED_PREFIX)) return -1;
429
+ const quoted = (l: string | undefined) => !!l && (l.startsWith("> ") || l.startsWith("&gt; "));
430
+ for (let i = 1; i + 2 < lines.length; i++) {
431
+ const l = lines[i]!;
432
+ if (l.length > 1 && l.startsWith("_") && l.endsWith("_") && lines[i + 1] === "" && quoted(lines[i + 2])) return i;
433
+ }
434
+ return -1;
435
+ }
436
+
437
+ /** How a ✅-stamped question was answered, read back from its stamp line —
438
+ * whether by a reply, and if the reply picked a numbered choice, which number.
439
+ * `--waitFor` uses it to recognise the reply that IS the answer, so that reply
440
+ * is not handed back again as a thread note. Both languages' stamps are read. */
441
+ export function askResolvedHow(text: string): { byReply: boolean; n?: number } {
442
+ const lines = text.split("\n");
443
+ const at = askResolvedStampIndex(lines);
444
+ const stamp = (at > 0 ? lines[at] : lines[1]) ?? "";
445
+ const m = stamp.match(/^_(?:返信|Answered by reply)(?: \((\d+)\))?/);
446
+ if (!m) return { byReply: false };
447
+ return m[1] ? { byReply: true, n: Number(m[1]) } : { byReply: true };
180
448
  }
181
449
 
182
450
  /** Undo Slack's storage escaping. Only these three are ever escaped, and `&amp;`
@@ -186,8 +454,11 @@ function askDecodeEntities(s: string): string {
186
454
  }
187
455
 
188
456
  export type AskParsed =
189
- | { kind: "open"; question: string; reactable: string[]; overflow: string[]; threadOnly: boolean }
457
+ | { kind: "open"; question: string; body: string; reactable: string[]; overflow: string[]; threadOnly: boolean; lang: AskLang }
190
458
  | { kind: "resolved"; question: string; answer: string }
459
+ /** Voided (無効化). `reason` is the stamp line as written; `supersededBy` the
460
+ * replacement's link when one was given. */
461
+ | { kind: "void"; question: string; reason: string; supersededBy?: string }
191
462
  | { kind: "other" };
192
463
 
193
464
  /** Read an `ask` message back out of Slack. The inverse of `askBuildText` /
@@ -223,21 +494,61 @@ export function askExplainReject(text: string): string {
223
494
  export function askParseMessage(text: string): AskParsed {
224
495
  const lines = text.split("\n");
225
496
 
497
+ // Every ✅ / 🚫 body this command has ever written is `<marker> *question*`
498
+ // followed by a `_…_` stamp line. Requiring that shape — not just the leading
499
+ // emoji — is what keeps an ordinary "🚫 deploy frozen" or "✅ Deployed\n> v1.2"
500
+ // from reading as a question (`slack edit` refuses to break one).
501
+ const stamped = (prefix: string) =>
502
+ text.startsWith(`${prefix}*`) && lines.some((l, i) => i > 0 && l.length > 1 && l.startsWith("_") && l.endsWith("_"));
503
+
504
+ // Void before anything else: a voided question is neither open nor answered,
505
+ // and must never be polled as one.
506
+ if (stamped(ASK_VOID_PREFIX)) {
507
+ const stampAt = lines.findIndex((l, i) => i > 0 && l.startsWith("_") && l.endsWith("_"));
508
+ const question = askDecodeEntities(
509
+ lines.slice(0, stampAt > 0 ? stampAt : 1).join("\n").slice(ASK_VOID_PREFIX.length).replace(/^\*/, "").replace(/\*$/, ""),
510
+ );
511
+ const reason = stampAt > 0 ? lines[stampAt]!.replace(/^_|_$/g, "") : "";
512
+ const link = stampAt > 0 ? lines[stampAt + 1]?.match(/^↪ [^::]*[::] (.+)$/)?.[1] : undefined;
513
+ return link ? { kind: "void", question, reason, supersededBy: link } : { kind: "void", question, reason };
514
+ }
515
+ // A hand-voided body is 【…無効化…】 in front of the original `:question: *…*` —
516
+ // without that question it is just a message that happens to say "void".
517
+ const legacyQ = lines[0]!.match(ASK_LEGACY_VOID_RE) ? text.match(/:question: \*([^\n]*?)\*/) : null;
518
+ if (legacyQ) {
519
+ return { kind: "void", question: askDecodeEntities(legacyQ[1]!), reason: askDecodeEntities(lines[0]!) };
520
+ }
521
+
226
522
  // Resolved is checked FIRST: a settled question carries the ✅ prefix where an
227
523
  // open one carries the ❓ marker, and reading a resolved body as an open one
228
524
  // would re-poll a question that already has its answer.
229
- if (text.startsWith(ASK_RESOLVED_PREFIX)) {
230
- const head = lines[0]!.slice(ASK_RESOLVED_PREFIX.length);
231
- const question = askDecodeEntities(head.replace(/^\*/, "").replace(/\*$/, ""));
525
+ if (stamped(ASK_RESOLVED_PREFIX)) {
232
526
  // `&gt; `, not only `> `: Slack HTML-escapes `&`, `<` and `>` in the text it
233
527
  // hands back, so the quote we wrote as `> ` is STORED as `&gt; `. Matching
234
528
  // only the raw form made every collected question unreadable — re-running
235
529
  // `--waitFor` on one exited 3 (measured 2026-09-27: 40 of 71 asks).
236
- const answer = lines
237
- .map((l) => (l.startsWith("> ") ? l.slice(2) : l.startsWith("&gt; ") ? l.slice(5) : null))
238
- .filter((l): l is string => l !== null)
239
- .map(askDecodeEntities)
240
- .join("\n");
530
+ const unquote = (l: string) => (l.startsWith("> ") ? l.slice(2) : l.startsWith("&gt; ") ? l.slice(5) : null);
531
+ // The answer is the quote block right under the stamp — NOT every quoted
532
+ // line in the message: the kept body can quote things too, and a `> ` line
533
+ // in the background must not be read back as part of the answer. Only when
534
+ // no stamp can be located at all does the old reading apply — every quoted
535
+ // line — which is all an old ✅ body ever had.
536
+ const stampAt = askResolvedStampIndex(lines);
537
+ let answerLines: string[];
538
+ if (stampAt > 0) {
539
+ answerLines = [];
540
+ for (let i = stampAt + 2; i < lines.length; i++) {
541
+ const u = unquote(lines[i]!);
542
+ if (u === null) break;
543
+ answerLines.push(u);
544
+ }
545
+ } else {
546
+ answerLines = lines.map(unquote).filter((l): l is string => l !== null);
547
+ }
548
+ const question = askDecodeEntities(
549
+ lines.slice(0, stampAt > 0 ? stampAt : 1).join("\n").slice(ASK_RESOLVED_PREFIX.length).replace(/^\*/, "").replace(/\*$/, ""),
550
+ );
551
+ const answer = answerLines.map(askDecodeEntities).join("\n");
241
552
  // A ✅-stamped body with no quoted answer is not a settled question we can
242
553
  // report; treat it as unknown rather than answer with an empty string.
243
554
  if (!answer) return { kind: "other" };
@@ -251,13 +562,17 @@ export function askParseMessage(text: string): AskParsed {
251
562
  // identified the only way it can be: by the instruction line it was built
252
563
  // with. Never delete these cases — they are what keeps those questions
253
564
  // collectable.
254
- switch (last) {
255
- case ASK_INSTRUCTION_REACTION_THREAD: threadOnly = true; hasChoices = true; break;
256
- case ASK_INSTRUCTION_REACTION_HERE: threadOnly = false; hasChoices = true; break;
257
- case ASK_INSTRUCTION_TEXT_THREAD: threadOnly = true; hasChoices = false; break;
258
- case ASK_INSTRUCTION_TEXT_HERE: threadOnly = false; hasChoices = false; break;
259
- default: return { kind: "other" };
260
- }
565
+ // Every language's lines are accepted, whatever this machine would post in:
566
+ // the question may have been asked from somewhere else.
567
+ const lang = ASK_LANGS.find((l) => [
568
+ ASK_COPY[l].instructionReactionThread, ASK_COPY[l].instructionReactionHere,
569
+ ASK_COPY[l].instructionTextThread, ASK_COPY[l].instructionTextHere,
570
+ ].includes(last));
571
+ if (!lang) return { kind: "other" };
572
+ const c = ASK_COPY[lang];
573
+ // `last` is one of this language's four instruction lines (checked above).
574
+ threadOnly = last === c.instructionReactionThread || last === c.instructionTextThread;
575
+ hasChoices = last === c.instructionReactionThread || last === c.instructionReactionHere;
261
576
 
262
577
  // The question is the leading bold run. Taking it up to the first line that
263
578
  // closes the `*` keeps a multi-line question intact instead of truncating it
@@ -274,6 +589,11 @@ export function askParseMessage(text: string): AskParsed {
274
589
 
275
590
  const reactable: string[] = [];
276
591
  const overflow: string[] = [];
592
+ // Where the body ends: the blank line above the choice block, or above the
593
+ // instruction for a free-text question. What lies between the question and
594
+ // there is the body — kept by the ✅ rewrite.
595
+ let bodyEnd = lines.length - 1;
596
+ if (isInvalidNotice(lines[bodyEnd - 1])) bodyEnd--;
277
597
  if (hasChoices) {
278
598
  // Walk UP from the instruction line rather than down from the question. The
279
599
  // choice block is anchored to the bottom of the body, and scanning downward
@@ -283,12 +603,13 @@ export function askParseMessage(text: string): AskParsed {
283
603
  if (isInvalidNotice(lines[i])) i--;
284
604
  if (lines[i] !== "") return { kind: "other" };
285
605
  i--;
286
- if (lines[i] === ASK_OVERFLOW_NOTE) {
606
+ if (lines[i] === c.overflowNote) {
287
607
  i--;
288
608
  while (i >= 0 && /^\(\d+\) /.test(lines[i]!)) { overflow.unshift(lines[i]!.replace(/^\(\d+\) /, "")); i--; }
289
609
  if (lines[i] !== "") return { kind: "other" };
290
610
  i--;
291
611
  }
612
+ if (askIsOtherLine(lines[i])) i--;
292
613
  const raw: string[] = [];
293
614
  while (i >= 0) {
294
615
  const line = lines[i]!;
@@ -308,13 +629,15 @@ export function askParseMessage(text: string): AskParsed {
308
629
  }
309
630
  if (lines[i] !== "") return { kind: "other" };
310
631
  if (i < end + 1) return { kind: "other" };
632
+ bodyEnd = i;
311
633
  // Overflow numbering starts at 11 and runs contiguously.
312
634
  for (let n = 0; n < overflow.length; n++) {
313
635
  if (!lines.includes(`(${n + 11}) ${overflow[n]}`)) return { kind: "other" };
314
636
  }
315
637
  }
316
638
 
317
- return { kind: "open", question, reactable, overflow, threadOnly };
639
+ const body = lines.slice(end + 1, bodyEnd).join("\n").replace(/^\n+|\n+$/g, "");
640
+ return { kind: "open", question, body, reactable, overflow, threadOnly, lang };
318
641
  }
319
642
 
320
643
  /** Did a free-text reply actually PICK one of the offered choices?
@@ -362,6 +685,15 @@ function askNormalizeChoice(s: string): string {
362
685
  .toLowerCase();
363
686
  }
364
687
 
688
+ /** Is `reply` the one that picked choice `n` (text `answer`)? The same matcher
689
+ * that chose it, run against a list where only slot `n` is known — so "2",
690
+ * "2. 中止", 2️⃣ and 「中止」。 all count, exactly as they did when collected. */
691
+ export function askReplyPicked(reply: string, answer: string, n: number): boolean {
692
+ const choices = Array.from({ length: n }, (_, i) => (i === n - 1 ? answer : ""));
693
+ const m = askMatchChoice(reply, choices);
694
+ return m.kind === "chosen" && m.index === n;
695
+ }
696
+
365
697
  export function askMatchChoice(reply: string, choices: string[]): AskChoiceMatch {
366
698
  const norm = askNormalizeChoice(reply);
367
699
  if (!norm) return { kind: "none" };