slack-term 1.30.2 → 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/dist/cli.js +106 -105
- package/package.json +1 -1
- package/ts/ask.ts +1 -1
- package/ts/cli.ts +39 -12
- package/ts/escapes.ts +48 -0
- package/ts/poll.ts +1 -1
package/package.json
CHANGED
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
|
-
|
|
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,
|
|
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
|
-
|
|
1983
|
-
|
|
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
|
-
|
|
2031
|
-
|
|
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
|
-
|
|
2071
|
-
|
|
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
|
-
|
|
2351
|
-
|
|
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
|
-
|
|
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
|
|