slack-term 1.30.1 → 1.31.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.30.1",
3
+ "version": "1.31.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/ask.ts CHANGED
@@ -102,7 +102,7 @@ export const ASK_RESOLVED_MARKER = "white_check_mark";
102
102
  export const ASK_RESOLVED_PREFIX = `:${ASK_RESOLVED_MARKER}: `;
103
103
 
104
104
  /** One line, always: newlines inside a choice would break the line-based body. */
105
- function askFlatten(s: string): string {
105
+ export function askFlatten(s: string): string {
106
106
  return s.replace(/\s*\n\s*/g, " ");
107
107
  }
108
108
 
package/ts/cli.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  askBuildText,
22
22
  askBuildResolvedText,
23
23
  askParseMessage,
24
+ askFlatten,
24
25
  applyInvalidNotice,
25
26
  readInvalidNotice,
26
27
  type AskFound,
@@ -32,6 +33,7 @@ import {
32
33
  POLL_MAX_CHOICES,
33
34
  pollBuildText,
34
35
  pollBuildClosedText,
36
+ pollFlatten,
35
37
  pollParseMessage,
36
38
  pollTally,
37
39
  } from "./poll.ts";
@@ -95,6 +97,7 @@ import {
95
97
  type ProgressState,
96
98
  } from "./todo.ts";
97
99
  import { setCacheEnabled } from "./cache.ts";
100
+ import { unescapeArg } from "./escapes.ts";
98
101
  import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
99
102
  import { quietHoursNotice } from "./quietHours.ts";
100
103
 
@@ -1351,10 +1354,15 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1351
1354
  // Convert @handle / @名前 tokens to <@USERID> before hashing/sending so the
1352
1355
  // safety gate covers exactly what will be posted. Unresolved tokens stay as
1353
1356
  // text. The detailed report drives the confirm-gate mention preview below.
1354
- let message = args.message;
1357
+ // Escapes first, before mentions and before the safety hash: the hash has to
1358
+ // cover the text that will actually be POSTED, and the gate preview has to
1359
+ // show the real line breaks so "how many lines is this?" is answered by
1360
+ // looking rather than by guessing.
1361
+ const rawMessage = unescapeArg(args.message);
1362
+ let message = rawMessage;
1355
1363
  let mentionReport: MentionEncodeResult | null = null;
1356
1364
  if (args.mentions) {
1357
- mentionReport = await encodeMentionsDetailed(args.mentionToken ?? token, args.message, channelId, args.mentionCookie);
1365
+ mentionReport = await encodeMentionsDetailed(args.mentionToken ?? token, rawMessage, channelId, args.mentionCookie);
1358
1366
  message = mentionReport.text;
1359
1367
  // Prominent, always-visible warning for any @token that will NOT notify —
1360
1368
  // so a mistyped or unresolved name (esp. a Japanese display name) is caught
@@ -1979,8 +1987,13 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
1979
1987
  // was never theirs to decide. So the audience is taken from the @tags in the
1980
1988
  // text — the same names the reader sees — and an untargeted ask is refused
1981
1989
  // outright rather than defaulted to "whoever gets there first".
1982
- const qEnc = askEncodeBroadcasts(args.question);
1983
- const bEnc = askEncodeBroadcasts(args.body ?? "");
1990
+ // Unescaped BEFORE broadcast/mention encoding and before the body is built,
1991
+ // which is what makes it work for choices too: `askBuildText` flattens a
1992
+ // choice's newlines to spaces (a newline there would split the pill into a
1993
+ // line the parser cannot read back), so unescaping afterwards would be a
1994
+ // no-op that silently did nothing.
1995
+ const qEnc = askEncodeBroadcasts(unescapeArg(args.question));
1996
+ const bEnc = askEncodeBroadcasts(unescapeArg(args.body ?? ""));
1984
1997
  const broadcastKinds = new Set([...qEnc.kinds, ...bEnc.kinds]);
1985
1998
  const mentionToken = args.mentionToken ?? token;
1986
1999
  const qRep = await encodeMentionsDetailed(mentionToken, qEnc.text, channelId, args.mentionCookie);
@@ -2027,8 +2040,13 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2027
2040
  .join(", ");
2028
2041
  }
2029
2042
 
2030
- const reactable = args.choices.slice(0, ASK_MAX_REACTION_CHOICES);
2031
- const overflow = args.choices.slice(ASK_MAX_REACTION_CHOICES);
2043
+ // Choices too: a `\n` a caller put in a pill label is far more likely to be a
2044
+ // typo than intent (the label is flattened to one line either way), but
2045
+ // leaving it as the two characters `\` `n` in a button someone has to read is
2046
+ // worse than turning it into the space it becomes.
2047
+ const escChoices = args.choices.map(unescapeArg);
2048
+ const reactable = escChoices.slice(0, ASK_MAX_REACTION_CHOICES);
2049
+ const overflow = escChoices.slice(ASK_MAX_REACTION_CHOICES);
2032
2050
 
2033
2051
  // Where free-text answers may come from. A 1:1 DM carries no unrelated
2034
2052
  // traffic, so a plain reply there is an answer. A channel does, so only
@@ -2067,8 +2085,11 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2067
2085
  const dest = await destLabel(token, channelId, ref, cookie);
2068
2086
  const choiceLines = reactable.length
2069
2087
  ? [`--- 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`)]
2088
+ // Flattened here for the same reason the body is: the pill WILL be one
2089
+ // line, so a preview showing two would be previewing something that
2090
+ // never gets posted.
2091
+ ...reactable.map((s, i) => ` ${ASK_KEYCAPS[i]!.glyph} ${askFlatten(s)}`),
2092
+ ...overflow.map((s, i) => ` (${i + 11}) ${askFlatten(s)} — text answer only`)]
2072
2093
  : [`--- Choices ----------------------------------`, ` (none — free-text reply)`];
2073
2094
  requireCode(args.code, code, [
2074
2095
  `--- Last message in channel ------------------`,
@@ -2347,8 +2368,13 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2347
2368
  // something, so it matters whose it is. A poll reports a distribution: an
2348
2369
  // extra voter changes the numbers, never the outcome's legitimacy. Tags are
2349
2370
  // 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 ?? "");
2371
+ // Unescaped BEFORE broadcast/mention encoding and before the body is built,
2372
+ // which is what makes it work for choices too: `askBuildText` flattens a
2373
+ // choice's newlines to spaces (a newline there would split the pill into a
2374
+ // line the parser cannot read back), so unescaping afterwards would be a
2375
+ // no-op that silently did nothing.
2376
+ const qEnc = askEncodeBroadcasts(unescapeArg(args.question));
2377
+ const bEnc = askEncodeBroadcasts(unescapeArg(args.body ?? ""));
2352
2378
  const mentionToken = args.mentionToken ?? token;
2353
2379
  const qRep = await encodeMentionsDetailed(mentionToken, qEnc.text, channelId, args.mentionCookie);
2354
2380
  const bRep = bEnc.text
@@ -2360,7 +2386,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2360
2386
  console.error(`⚠ ${stripTerminalControls(line)}`);
2361
2387
  }
2362
2388
 
2363
- const choices = args.choices;
2389
+ const choices = args.choices.map(unescapeArg);
2364
2390
  const multi = !!args.multi;
2365
2391
  const until = args.until ?? "";
2366
2392
  const message = pollBuildText(question, body, choices, { multi, deadline: until });
@@ -2390,7 +2416,8 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2390
2416
  `--- Last message in channel ------------------`,
2391
2417
  ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
2392
2418
  `--- Ballot (seeded as reactions) -------------`,
2393
- ...choices.map((s, i) => ` ${POLL_KEYCAPS[i]!.glyph} ${s}`),
2419
+ // As posted: one line per pill (see the ask gate).
2420
+ ...choices.map((s, i) => ` ${POLL_KEYCAPS[i]!.glyph} ${pollFlatten(s)}`),
2394
2421
  `--- Posting ----------------------------------`,
2395
2422
  fromLine(self, { asBot: args.asBot }),
2396
2423
  ` → ${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