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/README.md +81 -0
- package/SKILL.md +22 -2
- package/dist/cli.js +223 -209
- package/package.json +2 -1
- package/ts/ask.ts +61 -4
- package/ts/cli.ts +336 -20
- package/ts/newlines.ts +158 -0
- package/ts/relay.ts +105 -0
- package/ts/slack.ts +52 -4
- package/ts/stream.ts +1048 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slack-term",
|
|
3
|
-
"version": "1.
|
|
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
|
-
|
|
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?
|