slack-term 1.30.2 → 1.31.1

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.30.2",
3
+ "version": "1.31.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/ask.ts CHANGED
@@ -101,8 +101,34 @@ export function isInvalidNotice(line: string | undefined): boolean {
101
101
  export const ASK_RESOLVED_MARKER = "white_check_mark";
102
102
  export const ASK_RESOLVED_PREFIX = `:${ASK_RESOLVED_MARKER}: `;
103
103
 
104
+ /** How a keycap is WRITTEN into the body, and how it is read back.
105
+ *
106
+ * Slack NORMALISES a unicode keycap in the stored `text`: post `1️⃣` and
107
+ * `conversations.history` returns `:one:`. So a body built with glyphs never
108
+ * reads back as what was sent, and `--waitFor` cannot recognise its own
109
+ * question. `poll` already handled this; `ask` did not, which made every
110
+ * pill-answered question uncollectable (reported from real use 2026-08-31:
111
+ * four questions answered, nothing collected for ~20 minutes).
112
+ *
113
+ * Both spellings are accepted on the way in: the shortcode is what Slack
114
+ * stores, and the glyph is what a body hand-edited in the Slack UI can be. */
115
+ function askStripPill(line: string, i: number): string | null {
116
+ const k = ASK_KEYCAPS[i]!;
117
+ for (const pre of [`:${k.name}:`, k.glyph]) {
118
+ if (line.startsWith(`${pre} `)) return line.slice(pre.length + 1);
119
+ if (line === pre) return "";
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /** True if the line opens with ANY keycap — used only to find where the choice
125
+ * block starts; WHICH keycap it is gets checked afterwards, in order. */
126
+ function askIsPillLine(line: string): boolean {
127
+ return ASK_KEYCAPS.some((_k, i) => askStripPill(line, i) !== null);
128
+ }
129
+
104
130
  /** One line, always: newlines inside a choice would break the line-based body. */
105
- function askFlatten(s: string): string {
131
+ export function askFlatten(s: string): string {
106
132
  return s.replace(/\s*\n\s*/g, " ");
107
133
  }
108
134
 
@@ -154,6 +180,29 @@ export type AskParsed =
154
180
  * by a loose heuristic: it is the one part of the body this command wrote
155
181
  * verbatim, so matching it exactly is what keeps an unrelated message from
156
182
  * being polled as if it were an ask. */
183
+ /** Why `askParseMessage` rejected a body, in the reader's terms.
184
+ *
185
+ * The single generic "this is not an ask" line sent a real user re-checking
186
+ * permalinks that were fine, and cost ~20 minutes while pressed answers went
187
+ * uncollected (2026-08-31). A rejection has to say WHICH check failed, because
188
+ * the three causes have completely different fixes: wrong link, a body someone
189
+ * edited, or a bug here. */
190
+ export function askExplainReject(text: string): string {
191
+ if (!text.trim()) return "本文が空です (bot 投稿や添付のみのメッセージかもしれません)";
192
+ const lines = text.split("\n");
193
+ const last = lines[lines.length - 1] ?? "";
194
+ if (text.startsWith(ASK_RESOLVED_PREFIX)) {
195
+ return "回答済みマーク付きですが、引用された回答本文が見つかりません";
196
+ }
197
+ if (!/_$/.test(last)) {
198
+ return "末尾が `ask` の案内文ではありません — このメッセージは `slack ask` で投稿されたものではないか、本文が編集されています";
199
+ }
200
+ if (!lines[0]!.startsWith(ASK_MARKER_PREFIX) && !lines[0]!.startsWith("*")) {
201
+ return "先頭が質問文 (太字) ではありません — 本文が編集されている可能性があります";
202
+ }
203
+ return "選択肢の並びを読み取れません — 番号が 1 から連番になっていないか、本文が編集されています";
204
+ }
205
+
157
206
  export function askParseMessage(text: string): AskParsed {
158
207
  const lines = text.split("\n");
159
208
 
@@ -221,7 +270,7 @@ export function askParseMessage(text: string): AskParsed {
221
270
  const raw: string[] = [];
222
271
  while (i >= 0) {
223
272
  const line = lines[i]!;
224
- if (!ASK_KEYCAPS.some((k) => line.startsWith(`${k.glyph} `) || line === k.glyph)) break;
273
+ if (!askIsPillLine(line)) break;
225
274
  raw.unshift(line);
226
275
  i--;
227
276
  }
@@ -231,9 +280,9 @@ export function askParseMessage(text: string): AskParsed {
231
280
  // block starting at 2️⃣ would have it waiting on a pill nobody can press.
232
281
  if (!raw.length || raw.length > ASK_MAX_REACTION_CHOICES) return { kind: "other" };
233
282
  for (let k = 0; k < raw.length; k++) {
234
- const glyph = ASK_KEYCAPS[k]!.glyph;
235
- if (!(raw[k]!.startsWith(`${glyph} `) || raw[k] === glyph)) return { kind: "other" };
236
- reactable.push(raw[k]!.slice(glyph.length + 1));
283
+ const rest = askStripPill(raw[k]!, k);
284
+ if (rest === null) return { kind: "other" };
285
+ reactable.push(rest);
237
286
  }
238
287
  if (lines[i] !== "") return { kind: "other" };
239
288
  if (i < end + 1) return { kind: "other" };
package/ts/cli.ts CHANGED
@@ -21,6 +21,8 @@ import {
21
21
  askBuildText,
22
22
  askBuildResolvedText,
23
23
  askParseMessage,
24
+ askExplainReject,
25
+ askFlatten,
24
26
  applyInvalidNotice,
25
27
  readInvalidNotice,
26
28
  type AskFound,
@@ -32,6 +34,7 @@ import {
32
34
  POLL_MAX_CHOICES,
33
35
  pollBuildText,
34
36
  pollBuildClosedText,
37
+ pollFlatten,
35
38
  pollParseMessage,
36
39
  pollTally,
37
40
  } from "./poll.ts";
@@ -95,6 +98,7 @@ import {
95
98
  type ProgressState,
96
99
  } from "./todo.ts";
97
100
  import { setCacheEnabled } from "./cache.ts";
101
+ import { unescapeArg } from "./escapes.ts";
98
102
  import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
99
103
  import { quietHoursNotice } from "./quietHours.ts";
100
104
 
@@ -292,6 +296,17 @@ function slimMsg(m: Record<string, Json>): Record<string, Json> {
292
296
  // Thread stats, present only on thread parents. Without these a caller
293
297
  // reading top-level history cannot tell a post with replies from one
294
298
  // without, and silently misses everything said inside the thread.
299
+ // Reactions, when the message carries any. `ask` and `poll` record ANSWERS
300
+ // as reactions, so omitting them made `--json` useless as the fallback
301
+ // people reach for when a poller fails — the pressed pills were right there
302
+ // in the API response and the CLI dropped them (reported 2026-08-31).
303
+ ...(Array.isArray(m.reactions) && m.reactions.length
304
+ ? { reactions: asArray(m.reactions).map(asRecord).map((r) => ({
305
+ name: r.name ?? null,
306
+ count: r.count ?? null,
307
+ users: Array.isArray(r.users) ? r.users : [],
308
+ })) }
309
+ : {}),
295
310
  ...(m.reply_count !== undefined ? { reply_count: m.reply_count } : {}),
296
311
  ...(m.reply_users_count !== undefined ? { reply_users_count: m.reply_users_count } : {}),
297
312
  ...(m.latest_reply !== undefined ? { latest_reply: m.latest_reply } : {}),
@@ -1351,10 +1366,15 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1351
1366
  // Convert @handle / @名前 tokens to <@USERID> before hashing/sending so the
1352
1367
  // safety gate covers exactly what will be posted. Unresolved tokens stay as
1353
1368
  // text. The detailed report drives the confirm-gate mention preview below.
1354
- let message = args.message;
1369
+ // Escapes first, before mentions and before the safety hash: the hash has to
1370
+ // cover the text that will actually be POSTED, and the gate preview has to
1371
+ // show the real line breaks so "how many lines is this?" is answered by
1372
+ // looking rather than by guessing.
1373
+ const rawMessage = unescapeArg(args.message);
1374
+ let message = rawMessage;
1355
1375
  let mentionReport: MentionEncodeResult | null = null;
1356
1376
  if (args.mentions) {
1357
- mentionReport = await encodeMentionsDetailed(args.mentionToken ?? token, args.message, channelId, args.mentionCookie);
1377
+ mentionReport = await encodeMentionsDetailed(args.mentionToken ?? token, rawMessage, channelId, args.mentionCookie);
1358
1378
  message = mentionReport.text;
1359
1379
  // Prominent, always-visible warning for any @token that will NOT notify —
1360
1380
  // so a mistyped or unresolved name (esp. a Japanese display name) is caught
@@ -1979,8 +1999,13 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
1979
1999
  // was never theirs to decide. So the audience is taken from the @tags in the
1980
2000
  // text — the same names the reader sees — and an untargeted ask is refused
1981
2001
  // outright rather than defaulted to "whoever gets there first".
1982
- const qEnc = askEncodeBroadcasts(args.question);
1983
- const bEnc = askEncodeBroadcasts(args.body ?? "");
2002
+ // Unescaped BEFORE broadcast/mention encoding and before the body is built,
2003
+ // which is what makes it work for choices too: `askBuildText` flattens a
2004
+ // choice's newlines to spaces (a newline there would split the pill into a
2005
+ // line the parser cannot read back), so unescaping afterwards would be a
2006
+ // no-op that silently did nothing.
2007
+ const qEnc = askEncodeBroadcasts(unescapeArg(args.question));
2008
+ const bEnc = askEncodeBroadcasts(unescapeArg(args.body ?? ""));
1984
2009
  const broadcastKinds = new Set([...qEnc.kinds, ...bEnc.kinds]);
1985
2010
  const mentionToken = args.mentionToken ?? token;
1986
2011
  const qRep = await encodeMentionsDetailed(mentionToken, qEnc.text, channelId, args.mentionCookie);
@@ -2027,8 +2052,13 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2027
2052
  .join(", ");
2028
2053
  }
2029
2054
 
2030
- const reactable = args.choices.slice(0, ASK_MAX_REACTION_CHOICES);
2031
- const overflow = args.choices.slice(ASK_MAX_REACTION_CHOICES);
2055
+ // Choices too: a `\n` a caller put in a pill label is far more likely to be a
2056
+ // typo than intent (the label is flattened to one line either way), but
2057
+ // leaving it as the two characters `\` `n` in a button someone has to read is
2058
+ // worse than turning it into the space it becomes.
2059
+ const escChoices = args.choices.map(unescapeArg);
2060
+ const reactable = escChoices.slice(0, ASK_MAX_REACTION_CHOICES);
2061
+ const overflow = escChoices.slice(ASK_MAX_REACTION_CHOICES);
2032
2062
 
2033
2063
  // Where free-text answers may come from. A 1:1 DM carries no unrelated
2034
2064
  // traffic, so a plain reply there is an answer. A channel does, so only
@@ -2067,8 +2097,11 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2067
2097
  const dest = await destLabel(token, channelId, ref, cookie);
2068
2098
  const choiceLines = reactable.length
2069
2099
  ? [`--- Choices (seeded as reactions) ------------`,
2070
- ...reactable.map((s, i) => ` ${ASK_KEYCAPS[i]!.glyph} ${s}`),
2071
- ...overflow.map((s, i) => ` (${i + 11}) ${s} — text answer only`)]
2100
+ // Flattened here for the same reason the body is: the pill WILL be one
2101
+ // line, so a preview showing two would be previewing something that
2102
+ // never gets posted.
2103
+ ...reactable.map((s, i) => ` ${ASK_KEYCAPS[i]!.glyph} ${askFlatten(s)}`),
2104
+ ...overflow.map((s, i) => ` (${i + 11}) ${askFlatten(s)} — text answer only`)]
2072
2105
  : [`--- Choices ----------------------------------`, ` (none — free-text reply)`];
2073
2106
  requireCode(args.code, code, [
2074
2107
  `--- Last message in channel ------------------`,
@@ -2214,8 +2247,12 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
2214
2247
  const parsed = askParseMessage(text);
2215
2248
  if (parsed.kind === "other") {
2216
2249
  console.error(
2217
- `Error: これは \`slack ask\` の質問ではありません (回答の読み取り方が分かりません)。\n` +
2218
- ` ${stripTerminalControls(args.link)}`,
2250
+ `Error: これは \`slack ask\` の質問として読み取れません。\n` +
2251
+ ` 理由: ${askExplainReject(text)}\n` +
2252
+ ` ${stripTerminalControls(args.link)}\n` +
2253
+ // The pills are still readable by hand, so a failure here is not a dead
2254
+ // end — say so, because the reported cost was not knowing that.
2255
+ ` (押されたリアクションは残っています: slack read '${stripTerminalControls(args.link)}' で確認できます)`,
2219
2256
  );
2220
2257
  process.exit(ASK_EXIT_ERROR);
2221
2258
  }
@@ -2347,8 +2384,13 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2347
2384
  // something, so it matters whose it is. A poll reports a distribution: an
2348
2385
  // extra voter changes the numbers, never the outcome's legitimacy. Tags are
2349
2386
  // still encoded so a poll CAN be pointed at people; they just are not required.
2350
- const qEnc = askEncodeBroadcasts(args.question);
2351
- const bEnc = askEncodeBroadcasts(args.body ?? "");
2387
+ // Unescaped BEFORE broadcast/mention encoding and before the body is built,
2388
+ // which is what makes it work for choices too: `askBuildText` flattens a
2389
+ // choice's newlines to spaces (a newline there would split the pill into a
2390
+ // line the parser cannot read back), so unescaping afterwards would be a
2391
+ // no-op that silently did nothing.
2392
+ const qEnc = askEncodeBroadcasts(unescapeArg(args.question));
2393
+ const bEnc = askEncodeBroadcasts(unescapeArg(args.body ?? ""));
2352
2394
  const mentionToken = args.mentionToken ?? token;
2353
2395
  const qRep = await encodeMentionsDetailed(mentionToken, qEnc.text, channelId, args.mentionCookie);
2354
2396
  const bRep = bEnc.text
@@ -2360,7 +2402,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2360
2402
  console.error(`⚠ ${stripTerminalControls(line)}`);
2361
2403
  }
2362
2404
 
2363
- const choices = args.choices;
2405
+ const choices = args.choices.map(unescapeArg);
2364
2406
  const multi = !!args.multi;
2365
2407
  const until = args.until ?? "";
2366
2408
  const message = pollBuildText(question, body, choices, { multi, deadline: until });
@@ -2390,7 +2432,8 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2390
2432
  `--- Last message in channel ------------------`,
2391
2433
  ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
2392
2434
  `--- Ballot (seeded as reactions) -------------`,
2393
- ...choices.map((s, i) => ` ${POLL_KEYCAPS[i]!.glyph} ${s}`),
2435
+ // As posted: one line per pill (see the ask gate).
2436
+ ...choices.map((s, i) => ` ${POLL_KEYCAPS[i]!.glyph} ${pollFlatten(s)}`),
2394
2437
  `--- Posting ----------------------------------`,
2395
2438
  fromLine(self, { asBot: args.asBot }),
2396
2439
  ` → ${dest}${threadTs ? " — THREAD REPLY" : " — NEW top-level message"}`,
package/ts/escapes.ts ADDED
@@ -0,0 +1,48 @@
1
+ // Turning a literal `\n` in an argument into a real newline.
2
+ //
3
+ // WHY this exists at all: this CLI is driven by agents at least as often as by
4
+ // people, and an agent composing a message emits `\n` the way it would in JSON —
5
+ // it has no shell to interpret it. A person typing into a shell can produce a
6
+ // real newline ($'a\nb', or just quoting across lines); an agent building an
7
+ // argv array cannot. Without this, every multi-line message an agent sends
8
+ // arrives as one line with `\n` printed in it.
9
+ //
10
+ // WHY `¥n` too: on a Japanese keyboard the ¥ key produces U+00A5 YEN SIGN, not
11
+ // U+005C REVERSE SOLIDUS — they are different characters that merely LOOK
12
+ // interchangeable, a holdover from Shift-JIS where 0x5C rendered as ¥. Someone
13
+ // intending an escape reliably types the one that does nothing. Supporting it
14
+ // costs one character in a regex and removes a whole class of "why is my
15
+ // message on one line" confusion.
16
+ //
17
+ // The full-width ¥ (U+FFE5) is deliberately NOT accepted: it is a genuine
18
+ // currency glyph that no keyboard produces where a backslash is meant.
19
+
20
+ /** `\` U+005C, `¥` U+00A5. Not `¥` U+FFE5 — see above. */
21
+ const ESCAPE_LEAD = "\\\\\u00A5";
22
+
23
+ /** Interpret `\n`, `\t` and `\\` (and their ¥ spellings) in text the user
24
+ * supplied as a command-line argument.
25
+ *
26
+ * Consumes the lead character in ALL cases, which is what makes `\\n` an
27
+ * escape hatch for a literal backslash-n rather than a special case: the pair
28
+ * `\\` collapses to one `\`, and the following `n` is then ordinary text.
29
+ * A lead followed by anything else is left alone, so `C:\path` and `¥1000`
30
+ * pass through untouched — the latter matters, because a yen sign in this
31
+ * workspace is overwhelmingly a price, not an escape. */
32
+ export function unescapeArg(s: string): string {
33
+ return s.replace(new RegExp(`[${ESCAPE_LEAD}](.)`, "gs"), (whole, next: string) => {
34
+ switch (next) {
35
+ case "n": return "\n";
36
+ case "t": return "\t";
37
+ case "\\": case "\u00A5": return next === "\u00A5" ? "\u00A5" : "\\";
38
+ default: return whole;
39
+ }
40
+ });
41
+ }
42
+
43
+ /** True if `unescapeArg` would change this string — i.e. it carries an escape
44
+ * the sender probably meant. Used to decide whether the confirm gate needs to
45
+ * say so out loud. */
46
+ export function hasEscapes(s: string): boolean {
47
+ return unescapeArg(s) !== s;
48
+ }
package/ts/poll.ts CHANGED
@@ -71,7 +71,7 @@ function pollIsPillLine(line: string): boolean {
71
71
 
72
72
  /** One line, always: a newline inside a choice would split it into a line the
73
73
  * parser cannot tell from body text. */
74
- function pollFlatten(s: string): string {
74
+ export function pollFlatten(s: string): string {
75
75
  return s.replace(/\s*\n\s*/g, " ");
76
76
  }
77
77