slack-term 1.27.0 → 1.28.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.27.0",
3
+ "version": "1.28.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
@@ -60,7 +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
- "test": "bun test tests/ --timeout 90000 --isolate",
63
+ "test": "bun test tests/ --timeout 90000 --parallel=2",
64
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",
package/ts/ask.ts CHANGED
@@ -22,11 +22,25 @@ export const ASK_MAX_REACTION_CHOICES = ASK_KEYCAPS.length;
22
22
 
23
23
  export type AskFound = { answer: string; how: string; who?: string };
24
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.
25
+ /** The marker that says "this message is an `ask`". A shortcode, so it is the
26
+ * SAME token in every language — that is the whole point of it.
27
+ *
28
+ * Identity used to live in the trailing Japanese instruction line, which made
29
+ * the format untranslatable: rewording it in any language would have made every
30
+ * in-flight question unparseable. The marker carries identity now, and the
31
+ * instruction is free to be translated.
32
+ *
33
+ * It is also seeded as a REACTION, which is what makes `has:question` list
34
+ * every question the way `has:pushpin` lists every todo. `todo` gave this
35
+ * emoji up (its needs-discussion flag moved to :thinking_face:) precisely so
36
+ * the two would not collide in search. */
37
+ export const ASK_MARKER = "question";
38
+ export const ASK_MARKER_PREFIX = `:${ASK_MARKER}: `;
39
+
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.
30
44
  const ASK_INSTRUCTION_REACTION_THREAD =
31
45
  "_下のリアクションを 1 つ押すと回答になります。当てはまるものがなければ、このメッセージの *スレッド* で返信してください (チャンネルへの通常投稿は回答として拾いません)。_";
32
46
  const ASK_INSTRUCTION_REACTION_HERE =
@@ -37,10 +51,55 @@ const ASK_INSTRUCTION_TEXT_HERE =
37
51
  "_このメッセージに返信してください。本文がそのまま回答になります。_";
38
52
  const ASK_OVERFLOW_NOTE = "_11 番目以降はリアクションがないので、返信で答えてください。_";
39
53
 
54
+ // The invalid-ballot notice, shared by `ask` and `poll` and written INTO the
55
+ // message rather than only onto the collector's terminal. The person who has to
56
+ // fix it is the voter, and the voter is in Slack — a warning that only the
57
+ // collector sees never reaches them.
58
+ //
59
+ // A line in the body, not a thread reply, and that is the whole point: it is
60
+ // rewritten from the CURRENT state on every collection pass, so it cannot
61
+ // duplicate across reruns and it disappears by itself the moment the extra
62
+ // reaction is taken back. A reply, once posted, is a permanent record of a
63
+ // situation that has since been fixed.
64
+ const INVALID_PREFIX = "_:warning: 同時に複数選ばれているため回答として数えていません — どれか 1 つだけ残してください: ";
65
+ const INVALID_SUFFIX = "_";
66
+
67
+ /** The IDs named by a message's invalid-ballot line, or [] if it has none. */
68
+ export function readInvalidNotice(text: string): string[] {
69
+ const lines = text.split("\n");
70
+ 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]!);
73
+ }
74
+
75
+ /** Put `invalid` into the body — inserting, replacing or REMOVING the notice so
76
+ * the result depends only on the current state, never on how many times this
77
+ * has run. Returns the text unchanged when nothing needs to move, which is the
78
+ * caller's signal to skip the edit entirely.
79
+ *
80
+ * The notice sits directly above the instruction line because that line has to
81
+ * stay last: it is what identifies the message as an `ask`/`poll` at all. */
82
+ export function applyInvalidNotice(text: string, invalid: string[]): string {
83
+ const lines = text.split("\n");
84
+ if (lines.length < 2) return text;
85
+ if (readInvalidNotice(text).length) lines.splice(lines.length - 2, 1);
86
+ if (invalid.length) {
87
+ lines.splice(lines.length - 1, 0, `${INVALID_PREFIX}${invalid.map((u) => `<@${u}>`).join(", ")}${INVALID_SUFFIX}`);
88
+ }
89
+ const out = lines.join("\n");
90
+ return out === text ? text : out;
91
+ }
92
+
93
+ /** True if this line is the notice — parsers skip it when walking up. */
94
+ export function isInvalidNotice(line: string | undefined): boolean {
95
+ return line !== undefined && line.startsWith(INVALID_PREFIX) && line.endsWith(INVALID_SUFFIX);
96
+ }
97
+
40
98
  /** Prefix `markResolved` stamps on a settled question. `--waitFor` keys "this
41
99
  * was already answered while nobody was watching" off it, which is the whole
42
100
  * reason a fire-and-forget ask can be collected later at all. */
43
- export const ASK_RESOLVED_PREFIX = ":white_check_mark: ";
101
+ export const ASK_RESOLVED_MARKER = "white_check_mark";
102
+ export const ASK_RESOLVED_PREFIX = `:${ASK_RESOLVED_MARKER}: `;
44
103
 
45
104
  /** One line, always: newlines inside a choice would break the line-based body. */
46
105
  function askFlatten(s: string): string {
@@ -51,7 +110,7 @@ function askFlatten(s: string): string {
51
110
  * and an instruction naming the answer paths this command actually reads.
52
111
  * `askParseMessage` is its inverse — keep the two in step. */
53
112
  export function askBuildText(question: string, body: string, reactable: string[], overflow: string[], threadOnly: boolean): string {
54
- const lines: string[] = [`*${question}*`];
113
+ const lines: string[] = [`${ASK_MARKER_PREFIX}*${question}*`];
55
114
  if (body) lines.push("", body);
56
115
  if (reactable.length) {
57
116
  lines.push("");
@@ -98,6 +157,9 @@ export type AskParsed =
98
157
  export function askParseMessage(text: string): AskParsed {
99
158
  const lines = text.split("\n");
100
159
 
160
+ // Resolved is checked FIRST: a settled question carries the ✅ prefix where an
161
+ // open one carries the ❓ marker, and reading a resolved body as an open one
162
+ // would re-poll a question that already has its answer.
101
163
  if (text.startsWith(ASK_RESOLVED_PREFIX)) {
102
164
  const head = lines[0]!.slice(ASK_RESOLVED_PREFIX.length);
103
165
  const question = head.replace(/^\*/, "").replace(/\*$/, "");
@@ -114,6 +176,10 @@ export function askParseMessage(text: string): AskParsed {
114
176
  const last = lines[lines.length - 1] ?? "";
115
177
  let threadOnly: boolean;
116
178
  let hasChoices: boolean;
179
+ // Old-format fallback. A question posted before the marker existed is
180
+ // identified the only way it can be: by the instruction line it was built
181
+ // with. Never delete these cases — they are what keeps those questions
182
+ // collectable.
117
183
  switch (last) {
118
184
  case ASK_INSTRUCTION_REACTION_THREAD: threadOnly = true; hasChoices = true; break;
119
185
  case ASK_INSTRUCTION_REACTION_HERE: threadOnly = false; hasChoices = true; break;
@@ -125,6 +191,10 @@ export function askParseMessage(text: string): AskParsed {
125
191
  // The question is the leading bold run. Taking it up to the first line that
126
192
  // closes the `*` keeps a multi-line question intact instead of truncating it
127
193
  // to its first line.
194
+ // Strip the marker before reading the question, so the bold run starts at
195
+ // index 0 either way and a pre-marker body still parses.
196
+ const head = lines[0]!.startsWith(ASK_MARKER_PREFIX) ? lines[0]!.slice(ASK_MARKER_PREFIX.length) : lines[0]!;
197
+ lines[0] = head;
128
198
  if (!lines[0]!.startsWith("*")) return { kind: "other" };
129
199
  let end = 0;
130
200
  while (end < lines.length && !(lines[end]!.endsWith("*") && (end > 0 || lines[0]!.length > 1))) end++;
@@ -139,6 +209,7 @@ export function askParseMessage(text: string): AskParsed {
139
209
  // would let a body line that merely starts with a keycap ("1️⃣ これは本文です")
140
210
  // steal a choice slot and shift every number after it.
141
211
  let i = lines.length - 2; // the instruction itself is lines[length-1]
212
+ if (isInvalidNotice(lines[i])) i--;
142
213
  if (lines[i] !== "") return { kind: "other" };
143
214
  i--;
144
215
  if (lines[i] === ASK_OVERFLOW_NOTE) {