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/README.md +19 -3
- package/SKILL.md +49 -1
- package/dist/cli.js +226 -190
- package/package.json +1 -1
- package/ts/ask.ts +377 -45
- package/ts/cli.ts +992 -36
- package/ts/slack.ts +12 -0
package/package.json
CHANGED
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
|
-
|
|
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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 (
|
|
72
|
-
return [...line
|
|
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, `${
|
|
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 !==
|
|
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(
|
|
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 ?
|
|
321
|
+
lines.push(threadOnly ? c.instructionReactionThread : c.instructionReactionHere);
|
|
167
322
|
} else {
|
|
168
323
|
lines.push("");
|
|
169
|
-
lines.push(threadOnly ?
|
|
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
|
-
|
|
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
|
-
|
|
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("> "));
|
|
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 `&`
|
|
@@ -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 (
|
|
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
|
// `> `, not only `> `: Slack HTML-escapes `&`, `<` and `>` in the text it
|
|
233
527
|
// hands back, so the quote we wrote as `> ` is STORED as `> `. 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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
530
|
+
const unquote = (l: string) => (l.startsWith("> ") ? l.slice(2) : l.startsWith("> ") ? 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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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] ===
|
|
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
|
-
|
|
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" };
|