slack-term 1.26.6 → 1.27.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.26.6",
3
+ "version": "1.27.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
@@ -60,15 +60,16 @@
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
- "test": "vitest run --coverage",
64
- "test:watch": "vitest",
63
+ "test": "bun test tests/ --timeout 90000 --isolate",
64
+ "test:watch": "bun test tests/ --timeout 90000 --isolate --watch",
65
65
  "test:parity": "bash tests/parity.sh",
66
66
  "record": "bun run tests/record.ts",
67
67
  "anonymize": "bun run tests/anonymize.ts",
68
68
  "scan-pii": "bash .githooks/pre-commit",
69
69
  "scan-pii:all": "bash .githooks/pre-commit --all",
70
70
  "prepare": "git config core.hooksPath .githooks 2>/dev/null || true",
71
- "prepublishOnly": "bun run scan-pii:all && bun run typecheck && bun run build"
71
+ "prepublishOnly": "bun run scan-pii:all && bun run typecheck && bun run build",
72
+ "test:coverage": "vitest run --coverage --exclude tests/parity.test.ts --no-file-parallelism"
72
73
  },
73
74
  "type": "module",
74
75
  "dependencies": {
package/ts/ask.ts ADDED
@@ -0,0 +1,176 @@
1
+ // The `ask` message format — builder and parser in one place, on purpose.
2
+ //
3
+ // `ask` keeps no local record of a question, so `--waitFor` recovers everything
4
+ // it needs by reading the posted message back. That makes this body a wire
5
+ // format: `askParseMessage` must be the exact inverse of `askBuildText`, and the
6
+ // round-trip test in tests/ask-format.test.ts is what holds the two together.
7
+ // Answer order. Ten is the ceiling because keycap emoji stop there; choice 11+
8
+ // is listed in the body but can only be answered with text.
9
+ export const ASK_KEYCAPS = [
10
+ { name: "one", glyph: "1️⃣" },
11
+ { name: "two", glyph: "2️⃣" },
12
+ { name: "three", glyph: "3️⃣" },
13
+ { name: "four", glyph: "4️⃣" },
14
+ { name: "five", glyph: "5️⃣" },
15
+ { name: "six", glyph: "6️⃣" },
16
+ { name: "seven", glyph: "7️⃣" },
17
+ { name: "eight", glyph: "8️⃣" },
18
+ { name: "nine", glyph: "9️⃣" },
19
+ { name: "keycap_ten", glyph: "🔟" },
20
+ ] as const;
21
+ export const ASK_MAX_REACTION_CHOICES = ASK_KEYCAPS.length;
22
+
23
+ export type AskFound = { answer: string; how: string; who?: string };
24
+
25
+ // The instruction line is the wire contract. `ask` keeps no local record of a
26
+ // question, so `--waitFor` has to recover everything from the posted message —
27
+ // and this line is what identifies it as an ask at all, and which answer paths
28
+ // it promised to read. Change the wording and old in-flight questions become
29
+ // unparseable, so treat these strings as a format version, not as copy.
30
+ const ASK_INSTRUCTION_REACTION_THREAD =
31
+ "_下のリアクションを 1 つ押すと回答になります。当てはまるものがなければ、このメッセージの *スレッド* で返信してください (チャンネルへの通常投稿は回答として拾いません)。_";
32
+ const ASK_INSTRUCTION_REACTION_HERE =
33
+ "_下のリアクションを 1 つ押すと回答になります。当てはまるものがなければ、このメッセージに返信してください。_";
34
+ const ASK_INSTRUCTION_TEXT_THREAD =
35
+ "_このメッセージの *スレッド* で返信してください。本文がそのまま回答になります (チャンネルへの通常投稿は回答として拾いません)。_";
36
+ const ASK_INSTRUCTION_TEXT_HERE =
37
+ "_このメッセージに返信してください。本文がそのまま回答になります。_";
38
+ const ASK_OVERFLOW_NOTE = "_11 番目以降はリアクションがないので、返信で答えてください。_";
39
+
40
+ /** Prefix `markResolved` stamps on a settled question. `--waitFor` keys "this
41
+ * was already answered while nobody was watching" off it, which is the whole
42
+ * reason a fire-and-forget ask can be collected later at all. */
43
+ export const ASK_RESOLVED_PREFIX = ":white_check_mark: ";
44
+
45
+ /** One line, always: newlines inside a choice would break the line-based body. */
46
+ function askFlatten(s: string): string {
47
+ return s.replace(/\s*\n\s*/g, " ");
48
+ }
49
+
50
+ /** Question body: the prompt, the numbered choices matching the seeded pills,
51
+ * and an instruction naming the answer paths this command actually reads.
52
+ * `askParseMessage` is its inverse — keep the two in step. */
53
+ export function askBuildText(question: string, body: string, reactable: string[], overflow: string[], threadOnly: boolean): string {
54
+ const lines: string[] = [`*${question}*`];
55
+ if (body) lines.push("", body);
56
+ if (reactable.length) {
57
+ lines.push("");
58
+ // Flattened: a choice is a pill label, and a newline in one would split it
59
+ // into a line the parser cannot tell from body text.
60
+ lines.push(...reactable.map((s, i) => `${ASK_KEYCAPS[i]!.glyph} ${askFlatten(s)}`));
61
+ if (overflow.length) {
62
+ lines.push("");
63
+ lines.push(...overflow.map((s, i) => `(${i + 11}) ${askFlatten(s)}`));
64
+ lines.push(ASK_OVERFLOW_NOTE);
65
+ }
66
+ lines.push("");
67
+ // The instruction must name the exact path the poller reads. Where only
68
+ // thread replies are picked up, telling people to "reply to this message"
69
+ // would get in-channel answers silently ignored.
70
+ lines.push(threadOnly ? ASK_INSTRUCTION_REACTION_THREAD : ASK_INSTRUCTION_REACTION_HERE);
71
+ } else {
72
+ lines.push("");
73
+ lines.push(threadOnly ? ASK_INSTRUCTION_TEXT_THREAD : ASK_INSTRUCTION_TEXT_HERE);
74
+ }
75
+ return lines.join("\n");
76
+ }
77
+
78
+ /** Resolved-question body. Every answer line is quoted, not just the first —
79
+ * a multi-line reply left unquoted after the first line cannot be told back
80
+ * apart from the surrounding text when `--waitFor` reads it. */
81
+ export function askBuildResolvedText(question: string, found: AskFound, who: string): string {
82
+ const quoted = found.answer.split("\n").map((l) => `> ${l}`).join("\n");
83
+ return `${ASK_RESOLVED_PREFIX}*${question}*\n_${found.how}で回答済み${who ? ` (${who})` : ""}_\n\n${quoted}`;
84
+ }
85
+
86
+ export type AskParsed =
87
+ | { kind: "open"; question: string; reactable: string[]; overflow: string[]; threadOnly: boolean }
88
+ | { kind: "resolved"; question: string; answer: string }
89
+ | { kind: "other" };
90
+
91
+ /** Read an `ask` message back out of Slack. The inverse of `askBuildText` /
92
+ * `askBuildResolvedText`, and the reason `--waitFor` needs nothing on disk.
93
+ *
94
+ * "Is this even a question?" is decided by the trailing instruction line, not
95
+ * by a loose heuristic: it is the one part of the body this command wrote
96
+ * verbatim, so matching it exactly is what keeps an unrelated message from
97
+ * being polled as if it were an ask. */
98
+ export function askParseMessage(text: string): AskParsed {
99
+ const lines = text.split("\n");
100
+
101
+ if (text.startsWith(ASK_RESOLVED_PREFIX)) {
102
+ const head = lines[0]!.slice(ASK_RESOLVED_PREFIX.length);
103
+ const question = head.replace(/^\*/, "").replace(/\*$/, "");
104
+ const answer = lines
105
+ .filter((l) => l.startsWith("> "))
106
+ .map((l) => l.slice(2))
107
+ .join("\n");
108
+ // A ✅-stamped body with no quoted answer is not a settled question we can
109
+ // report; treat it as unknown rather than answer with an empty string.
110
+ if (!answer) return { kind: "other" };
111
+ return { kind: "resolved", question, answer };
112
+ }
113
+
114
+ const last = lines[lines.length - 1] ?? "";
115
+ let threadOnly: boolean;
116
+ let hasChoices: boolean;
117
+ switch (last) {
118
+ case ASK_INSTRUCTION_REACTION_THREAD: threadOnly = true; hasChoices = true; break;
119
+ case ASK_INSTRUCTION_REACTION_HERE: threadOnly = false; hasChoices = true; break;
120
+ case ASK_INSTRUCTION_TEXT_THREAD: threadOnly = true; hasChoices = false; break;
121
+ case ASK_INSTRUCTION_TEXT_HERE: threadOnly = false; hasChoices = false; break;
122
+ default: return { kind: "other" };
123
+ }
124
+
125
+ // The question is the leading bold run. Taking it up to the first line that
126
+ // closes the `*` keeps a multi-line question intact instead of truncating it
127
+ // to its first line.
128
+ if (!lines[0]!.startsWith("*")) return { kind: "other" };
129
+ let end = 0;
130
+ while (end < lines.length && !(lines[end]!.endsWith("*") && (end > 0 || lines[0]!.length > 1))) end++;
131
+ if (end >= lines.length) return { kind: "other" };
132
+ const question = lines.slice(0, end + 1).join("\n").replace(/^\*/, "").replace(/\*$/, "");
133
+
134
+ const reactable: string[] = [];
135
+ const overflow: string[] = [];
136
+ if (hasChoices) {
137
+ // Walk UP from the instruction line rather than down from the question. The
138
+ // choice block is anchored to the bottom of the body, and scanning downward
139
+ // would let a body line that merely starts with a keycap ("1️⃣ これは本文です")
140
+ // steal a choice slot and shift every number after it.
141
+ let i = lines.length - 2; // the instruction itself is lines[length-1]
142
+ if (lines[i] !== "") return { kind: "other" };
143
+ i--;
144
+ if (lines[i] === ASK_OVERFLOW_NOTE) {
145
+ i--;
146
+ while (i >= 0 && /^\(\d+\) /.test(lines[i]!)) { overflow.unshift(lines[i]!.replace(/^\(\d+\) /, "")); i--; }
147
+ if (lines[i] !== "") return { kind: "other" };
148
+ i--;
149
+ }
150
+ const raw: string[] = [];
151
+ while (i >= 0) {
152
+ const line = lines[i]!;
153
+ if (!ASK_KEYCAPS.some((k) => line.startsWith(`${k.glyph} `) || line === k.glyph)) break;
154
+ raw.unshift(line);
155
+ i--;
156
+ }
157
+ // The block has to be keycaps 1..n IN ORDER and separated from the body by a
158
+ // blank line — anything else is a body that only resembles one. Order is not
159
+ // cosmetic: each pill's position is the reaction the poller watches, so a
160
+ // block starting at 2️⃣ would have it waiting on a pill nobody can press.
161
+ if (!raw.length || raw.length > ASK_MAX_REACTION_CHOICES) return { kind: "other" };
162
+ for (let k = 0; k < raw.length; k++) {
163
+ const glyph = ASK_KEYCAPS[k]!.glyph;
164
+ if (!(raw[k]!.startsWith(`${glyph} `) || raw[k] === glyph)) return { kind: "other" };
165
+ reactable.push(raw[k]!.slice(glyph.length + 1));
166
+ }
167
+ if (lines[i] !== "") return { kind: "other" };
168
+ if (i < end + 1) return { kind: "other" };
169
+ // Overflow numbering starts at 11 and runs contiguously.
170
+ for (let n = 0; n < overflow.length; n++) {
171
+ if (!lines.includes(`(${n + 11}) ${overflow[n]}`)) return { kind: "other" };
172
+ }
173
+ }
174
+
175
+ return { kind: "open", question, reactable, overflow, threadOnly };
176
+ }