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/dist/cli.js +107 -103
- package/package.json +1 -1
- package/ts/ask.ts +54 -5
- package/ts/cli.ts +57 -14
- package/ts/escapes.ts +48 -0
- package/ts/poll.ts +1 -1
package/package.json
CHANGED
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 (!
|
|
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
|
|
235
|
-
if (
|
|
236
|
-
reactable.push(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1983
|
-
|
|
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
|
-
|
|
2031
|
-
|
|
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
|
-
|
|
2071
|
-
|
|
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\`
|
|
2218
|
-
` ${
|
|
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
|
-
|
|
2351
|
-
|
|
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
|
-
|
|
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
|
|