slack-term 1.32.1 → 1.32.3
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/README.md +2 -1
- package/SKILL.md +9 -2
- package/dist/cli.js +122 -119
- package/package.json +1 -1
- package/ts/ask.ts +106 -0
- package/ts/cli.ts +192 -14
package/package.json
CHANGED
package/ts/ask.ts
CHANGED
|
@@ -305,3 +305,109 @@ export function askParseMessage(text: string): AskParsed {
|
|
|
305
305
|
|
|
306
306
|
return { kind: "open", question, reactable, overflow, threadOnly };
|
|
307
307
|
}
|
|
308
|
+
|
|
309
|
+
/** Did a free-text reply actually PICK one of the offered choices?
|
|
310
|
+
*
|
|
311
|
+
* Reported from real use 2026-09-04: three options were offered, the answerer
|
|
312
|
+
* replied 「没懂,能给我讲前因后果吗」 ("I don't follow — can you explain the
|
|
313
|
+
* background?"), and `--waitFor` exited 0 with that stored AS the decision. Any
|
|
314
|
+
* reply from an addressed person counted, because nothing compared the reply to
|
|
315
|
+
* the candidates. A lane harvesting that unparks the work and proceeds with
|
|
316
|
+
* nothing behind it, and the recorded answer reads as though he had chosen.
|
|
317
|
+
*
|
|
318
|
+
* Deliberately NARROW. Every loosening here re-creates the same defect one step
|
|
319
|
+
* further out, and the two error directions are not symmetric: calling a
|
|
320
|
+
* non-answer an answer makes an automated flow act on a decision nobody took,
|
|
321
|
+
* while calling an answer a non-answer parks it in front of a human who can
|
|
322
|
+
* settle it with one reaction. So: an exact choice, a bare number, a keycap, or
|
|
323
|
+
* a number introducing its own choice text. Nothing fuzzy, nothing partial —
|
|
324
|
+
* "the second one" and "option B" are NOT matches, on purpose.
|
|
325
|
+
*
|
|
326
|
+
* `choices` is the full candidate list, reactable and overflow together, in
|
|
327
|
+
* presentation order. The overflow ones (11+) can only ever be answered as
|
|
328
|
+
* text, so a matcher that ignored them would call every legitimate answer to a
|
|
329
|
+
* long question "not chosen" — the failure the caller cannot see. */
|
|
330
|
+
export type AskChoiceMatch =
|
|
331
|
+
| { kind: "chosen"; index: number } // 1-based, matching the printed numbering
|
|
332
|
+
| { kind: "none" }
|
|
333
|
+
| { kind: "ambiguous"; indexes: number[] };
|
|
334
|
+
|
|
335
|
+
/** Lowercase, NFKC-fold (full-width 7 and 7.become 7), strip decoration and
|
|
336
|
+
* one trailing sentence mark, collapse runs of space. NFKC is what lets a reply
|
|
337
|
+
* typed on a Japanese IME match a choice typed on an ASCII keyboard. */
|
|
338
|
+
function askNormalizeChoice(s: string): string {
|
|
339
|
+
return s
|
|
340
|
+
.normalize("NFKC")
|
|
341
|
+
.replace(/[*_`~]/g, "")
|
|
342
|
+
.trim()
|
|
343
|
+
// Brackets and sentence marks are stripped in ONE class at each end rather
|
|
344
|
+
// than in two passes: 「中止」。 puts the full stop OUTSIDE the quote, so
|
|
345
|
+
// stripping quotes and then punctuation leaves the closing 」 behind and the
|
|
346
|
+
// reply stops matching a choice it plainly names.
|
|
347
|
+
.replace(/^[\s"'`「『((\[]+/, "")
|
|
348
|
+
.replace(/[\s"'`」』))\]。..!!??、,]+$/, "")
|
|
349
|
+
.replace(/\s+/g, " ")
|
|
350
|
+
.trim()
|
|
351
|
+
.toLowerCase();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function askMatchChoice(reply: string, choices: string[]): AskChoiceMatch {
|
|
355
|
+
const norm = askNormalizeChoice(reply);
|
|
356
|
+
if (!norm) return { kind: "none" };
|
|
357
|
+
const normChoices = choices.map(askNormalizeChoice);
|
|
358
|
+
|
|
359
|
+
const hits = new Set<number>();
|
|
360
|
+
|
|
361
|
+
// 1. The reply IS the choice text.
|
|
362
|
+
normChoices.forEach((c, i) => { if (c && c === norm) hits.add(i + 1); });
|
|
363
|
+
|
|
364
|
+
// 2. A keycap glyph, which is what someone copies out of the message when they
|
|
365
|
+
// cannot react (a thread on mobile, or a choice past the tenth).
|
|
366
|
+
ASK_KEYCAPS.forEach((k, i) => {
|
|
367
|
+
if (i < choices.length && reply.trim() === k.glyph) hits.add(i + 1);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// 3. A number: bare, or introducing the choice it names. `1` and `1. foo` are
|
|
371
|
+
// the same act. A number followed by DIFFERENT text is not a match — that
|
|
372
|
+
// is someone writing prose that happens to start with a digit.
|
|
373
|
+
//
|
|
374
|
+
// The separator is REQUIRED once text follows, and matched as its own
|
|
375
|
+
// alternative rather than as an optional group. With it optional, `100`
|
|
376
|
+
// parsed as choice 10 followed by "0" — so a ten-option question whose
|
|
377
|
+
// tenth choice is "0" matched a reply nobody meant as a choice. That is the
|
|
378
|
+
// expensive direction of the error, invented while fixing the same one.
|
|
379
|
+
// (cross-vendor review, 2026-09-05)
|
|
380
|
+
let n = 0;
|
|
381
|
+
let rest = "";
|
|
382
|
+
let m = norm.match(/^(\d{1,2})$/);
|
|
383
|
+
if (m) {
|
|
384
|
+
n = Number(m[1]);
|
|
385
|
+
} else if ((m = norm.match(/^(\d{1,2})(?:\s*[.)::、。\-\]]\s*|\s+)(.*)$/))) {
|
|
386
|
+
n = Number(m[1]);
|
|
387
|
+
// Normalised AGAIN, not merely trimmed: `1. (Release)` reaches here as
|
|
388
|
+
// `(release` — the number kept the opening bracket from being stripped at
|
|
389
|
+
// the head of the string while the closing one went at the tail, leaving an
|
|
390
|
+
// unbalanced remainder that matched nothing. Re-running the same normaliser
|
|
391
|
+
// on the remainder is what makes a bracketed choice answerable by number.
|
|
392
|
+
rest = askNormalizeChoice(m[2]!);
|
|
393
|
+
// A separator with NOTHING after it is not a choice: `1 -` is someone who
|
|
394
|
+
// started typing and stopped, and selecting option 1 for them is the
|
|
395
|
+
// expensive error. `1.` and `1)` still work — their punctuation is stripped
|
|
396
|
+
// at the tail, so they arrive at the bare-number branch above.
|
|
397
|
+
if (!rest) return { kind: "none" };
|
|
398
|
+
// An all-digit remainder is a range or a score (`1-2`), not a numbered
|
|
399
|
+
// choice. Without this, a question whose options are themselves numbers
|
|
400
|
+
// matched `1-2` as "option 1, whose text is 2". Rejecting it fails to the
|
|
401
|
+
// CHEAP side: a genuine `1. 2` re-asks, a false selection does not.
|
|
402
|
+
if (/^\d+$/.test(rest)) return { kind: "none" };
|
|
403
|
+
}
|
|
404
|
+
if (n >= 1 && n <= choices.length && (rest === "" || rest === normChoices[n - 1])) hits.add(n);
|
|
405
|
+
|
|
406
|
+
const indexes = [...hits].sort((a, b) => a - b);
|
|
407
|
+
if (!indexes.length) return { kind: "none" };
|
|
408
|
+
// Two distinct choices matched — the same refusal-to-guess the poller already
|
|
409
|
+
// applies when someone presses two pills. Identical choice TEXT is the usual
|
|
410
|
+
// cause, and picking the lower index would answer with a coin flip.
|
|
411
|
+
if (indexes.length > 1) return { kind: "ambiguous", indexes };
|
|
412
|
+
return { kind: "chosen", index: indexes[0]! };
|
|
413
|
+
}
|
package/ts/cli.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
askParseMessage,
|
|
24
24
|
askExplainReject,
|
|
25
25
|
askFlatten,
|
|
26
|
+
askMatchChoice,
|
|
26
27
|
applyInvalidNotice,
|
|
27
28
|
readInvalidNotice,
|
|
28
29
|
type AskFound,
|
|
@@ -1167,7 +1168,62 @@ interface EditArgs {
|
|
|
1167
1168
|
// Session cookie (xoxd) for `token` — required for an xoxc- desktop session
|
|
1168
1169
|
// token to be accepted by the public Slack API at all.
|
|
1169
1170
|
cookie?: string;
|
|
1171
|
+
asBot?: boolean;
|
|
1172
|
+
/** The USER token, kept for mention resolution when the edit itself runs as
|
|
1173
|
+
* the bot. A bot token has no users:read, and an unresolved @handle degrades
|
|
1174
|
+
* to plain text SILENTLY — which is the precise failure `edit` exists to
|
|
1175
|
+
* correct, so re-introducing it here would be circular. Same split as
|
|
1176
|
+
* `send --as-bot`. */
|
|
1177
|
+
mentionToken?: string;
|
|
1178
|
+
mentionCookie?: string;
|
|
1170
1179
|
}
|
|
1180
|
+
/** The bot token, plus the bot↔user DM when the target names an `@handle`.
|
|
1181
|
+
*
|
|
1182
|
+
* Both halves are load-bearing. A bot token has no `users:read`, so resolving
|
|
1183
|
+
* `@bob` with it does not merely pick the wrong channel — it fails outright with
|
|
1184
|
+
* `missing_scope`, before the confirm gate is ever printed. And even where it
|
|
1185
|
+
* could resolve, a BOT-authored DM lives in the bot's conversation with that
|
|
1186
|
+
* person, not in the user's: looking the handle up as the user and opening the
|
|
1187
|
+
* DM as the bot is the only combination that names the message being corrected.
|
|
1188
|
+
* Same split `send --as-bot` makes.
|
|
1189
|
+
*
|
|
1190
|
+
* Found by the cross-vendor review of this change, which also named why the
|
|
1191
|
+
* first round of tests missed it: they all targeted a permalink, and
|
|
1192
|
+
* `resolveChannel` parses those locally without touching the API. */
|
|
1193
|
+
async function asBotTarget(
|
|
1194
|
+
target: string,
|
|
1195
|
+
userToken: string,
|
|
1196
|
+
userCookie: string | undefined,
|
|
1197
|
+
/** Already known from `--channel-id`. Passed in so the DM lookup is SKIPPED
|
|
1198
|
+
* rather than performed and discarded: `conversations.open` is a side effect,
|
|
1199
|
+
* and opening a DM the caller did not ask for is not undone by ignoring the
|
|
1200
|
+
* result. */
|
|
1201
|
+
knownChannelId?: string,
|
|
1202
|
+
): Promise<{ botToken: string; channelId?: string }> {
|
|
1203
|
+
const botToken = resolveBotToken();
|
|
1204
|
+
if (!botToken) {
|
|
1205
|
+
console.error(
|
|
1206
|
+
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
1207
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
1208
|
+
);
|
|
1209
|
+
process.exit(1);
|
|
1210
|
+
}
|
|
1211
|
+
if (knownChannelId) return { botToken };
|
|
1212
|
+
const { ref } = splitRefTs(target);
|
|
1213
|
+
if (!ref.startsWith("@")) return { botToken };
|
|
1214
|
+
try {
|
|
1215
|
+
const userId = await resolveUserId(userToken, ref, userCookie);
|
|
1216
|
+
return { botToken, channelId: await openDm(botToken, userId) };
|
|
1217
|
+
} catch (e: unknown) {
|
|
1218
|
+
console.error(
|
|
1219
|
+
`Error: could not resolve ${stripTerminalControls(ref)} to the bot's DM.\n` +
|
|
1220
|
+
` ${e instanceof Error ? e.message : String(e)}\n` +
|
|
1221
|
+
` Tip: pass the message permalink, or --channel-id <D…>, to skip the lookup.`,
|
|
1222
|
+
);
|
|
1223
|
+
process.exit(1);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1171
1227
|
async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
1172
1228
|
const getSelf = selfLookup(token, args.cookie);
|
|
1173
1229
|
const { ref, ts } = splitRefTs(args.target);
|
|
@@ -1191,8 +1247,9 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
1191
1247
|
const originalText = typeof original.text === "string" ? original.text : "";
|
|
1192
1248
|
|
|
1193
1249
|
// Convert @handle → <@USERID> before hashing/editing (unresolved stay as text).
|
|
1250
|
+
const mentionCookie = args.mentionCookie ?? args.cookie;
|
|
1194
1251
|
const newText = args.mentions
|
|
1195
|
-
? await encodeMentions(token, args.newText, channelId,
|
|
1252
|
+
? await encodeMentions(args.mentionToken ?? token, args.newText, channelId, mentionCookie ? { cookie: mentionCookie } : {})
|
|
1196
1253
|
: args.newText;
|
|
1197
1254
|
|
|
1198
1255
|
// Identity is part of the hash for the same reason it is on `send`: a code
|
|
@@ -1204,7 +1261,7 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
1204
1261
|
if (args.code !== code) {
|
|
1205
1262
|
requireCode(args.code, code, [
|
|
1206
1263
|
`--- Editing as -------------------------------`,
|
|
1207
|
-
fromLine(self),
|
|
1264
|
+
fromLine(self, { asBot: args.asBot }),
|
|
1208
1265
|
`--- Original message -------------------------`,
|
|
1209
1266
|
...originalText.split("\n").map((l) => ` ${l}`),
|
|
1210
1267
|
`--- Replacing with ---------------------------`,
|
|
@@ -1223,6 +1280,7 @@ interface DeleteArgs {
|
|
|
1223
1280
|
code?: string;
|
|
1224
1281
|
channelId?: string;
|
|
1225
1282
|
cookie?: string;
|
|
1283
|
+
asBot?: boolean;
|
|
1226
1284
|
}
|
|
1227
1285
|
async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
|
|
1228
1286
|
const getSelf = selfLookup(token, args.cookie);
|
|
@@ -1254,7 +1312,7 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
|
|
|
1254
1312
|
const dest = await destLabel(token, channelId, ref, args.cookie);
|
|
1255
1313
|
requireCode(args.code, code, [
|
|
1256
1314
|
`--- Deleting message -------------------------`,
|
|
1257
|
-
fromLine(self),
|
|
1315
|
+
fromLine(self, { asBot: args.asBot }),
|
|
1258
1316
|
` → ${dest} at ${slackTsToIso(ts)}`,
|
|
1259
1317
|
...originalText.split("\n").map((l) => ` ${l}`),
|
|
1260
1318
|
`---------------------------------------------`,
|
|
@@ -1642,12 +1700,18 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
1642
1700
|
// Exit codes are the contract for `--wait`, and a non-zero one must never be
|
|
1643
1701
|
// mistakable for "an answer arrived":
|
|
1644
1702
|
// 0 answered (the answer text is on stdout, and nothing else is)
|
|
1645
|
-
// 2 timed out
|
|
1703
|
+
// 2 timed out (nobody replied)
|
|
1646
1704
|
// 3 transport/config failure
|
|
1705
|
+
// 4 replied, but chose none of the offered choices — stdout stays EMPTY
|
|
1647
1706
|
// Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
|
|
1648
1707
|
|
|
1649
1708
|
const ASK_EXIT_TIMEOUT = 2;
|
|
1650
1709
|
const ASK_EXIT_ERROR = 3;
|
|
1710
|
+
/** Somebody replied, and what they wrote picks none of the offered choices.
|
|
1711
|
+
* A THIRD state, distinct from both 0 and 2 on purpose: exit 0 would have an
|
|
1712
|
+
* automated caller act on a decision nobody took, and exit 2 ("nobody
|
|
1713
|
+
* answered") would hide that a human is standing there waiting for something. */
|
|
1714
|
+
const ASK_EXIT_UNCHOSEN = 4;
|
|
1651
1715
|
|
|
1652
1716
|
const ASK_PHI = 1.618033988749895;
|
|
1653
1717
|
const ASK_POLL_MIN_MS = 1000;
|
|
@@ -1730,6 +1794,11 @@ interface AskWaitCtx {
|
|
|
1730
1794
|
threadParentTs?: string;
|
|
1731
1795
|
question: string;
|
|
1732
1796
|
reactable: string[];
|
|
1797
|
+
/** Choices past the tenth. They carry NO reaction — text is the only way to
|
|
1798
|
+
* answer them — so they must be in the candidate list the reply is matched
|
|
1799
|
+
* against, or every legitimate answer to a long question reads as "chose
|
|
1800
|
+
* nothing". */
|
|
1801
|
+
overflow: string[];
|
|
1733
1802
|
threadOnly: boolean;
|
|
1734
1803
|
/** Who may answer. Empty + broadcast=false would accept nobody, which is why
|
|
1735
1804
|
* posting an unaddressed question is refused up front. */
|
|
@@ -1763,6 +1832,12 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
1763
1832
|
const isAnswerer = (user: unknown): user is string =>
|
|
1764
1833
|
typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
|
|
1765
1834
|
|
|
1835
|
+
/** The earliest reply from an answerer that picked none of the choices. Kept
|
|
1836
|
+
* across polls so the timeout can report it, and so the operator is told once
|
|
1837
|
+
* rather than on every tick. */
|
|
1838
|
+
let unchosen: { text: string; who: string; ts: string; ambiguous: boolean } | undefined;
|
|
1839
|
+
let unchosenReported = "";
|
|
1840
|
+
|
|
1766
1841
|
/** Every seed an answerer is on — not just the first. Changing your mind
|
|
1767
1842
|
* leaves BOTH reactions in place (Slack only drops one when you actively
|
|
1768
1843
|
* un-react), so taking the lowest index would answer with the abandoned
|
|
@@ -1822,6 +1897,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
1822
1897
|
return null;
|
|
1823
1898
|
}
|
|
1824
1899
|
|
|
1900
|
+
/** The full candidate list — reactable pills AND the text-only overflow —
|
|
1901
|
+
* empty when the question was asked free-text. */
|
|
1902
|
+
const candidates = [...reactable, ...ctx.overflow];
|
|
1903
|
+
|
|
1825
1904
|
function answerFromMessages(messages: Record<string, Json>[], before = Infinity): AskFound | null {
|
|
1826
1905
|
// Oldest first: if someone wrote twice, the first reply is the answer.
|
|
1827
1906
|
const ordered = [...messages].sort((a, b) => Number(a.ts) - Number(b.ts));
|
|
@@ -1833,12 +1912,35 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
1833
1912
|
if (typeof m.subtype === "string" && !ASK_ANSWERABLE_SUBTYPES.has(m.subtype)) continue;
|
|
1834
1913
|
if (Number(m.ts) >= before) break;
|
|
1835
1914
|
const t = typeof m.text === "string" ? m.text.trim() : "";
|
|
1836
|
-
if (t)
|
|
1915
|
+
if (!t) continue;
|
|
1916
|
+
// A question asked WITHOUT choices is answered by whatever comes back —
|
|
1917
|
+
// there is nothing to match against, and every reply is the answer.
|
|
1918
|
+
if (!candidates.length) return { answer: t, how: "返信", who: m.user };
|
|
1919
|
+
const match = askMatchChoice(t, candidates);
|
|
1920
|
+
if (match.kind === "chosen") {
|
|
1921
|
+
// Answer with the CHOICE, not with the reply that selected it: "2" and
|
|
1922
|
+
// "2. 中止" have to reach the caller as the same decision a pill would
|
|
1923
|
+
// have produced, or the same answer arrives in three spellings.
|
|
1924
|
+
return { answer: candidates[match.index - 1]!, how: `返信 (${match.index})`, who: m.user };
|
|
1925
|
+
}
|
|
1926
|
+
// Replied, but picked nothing. Recorded rather than returned: a later
|
|
1927
|
+
// reply may still choose, and the first one is what the operator needs to
|
|
1928
|
+
// see — it is usually a question back.
|
|
1929
|
+
if (!unchosen || Number(m.ts) < Number(unchosen.ts)) {
|
|
1930
|
+
unchosen = { text: t, who: typeof m.user === "string" ? m.user : "", ts: String(m.ts), ambiguous: match.kind === "ambiguous" };
|
|
1931
|
+
}
|
|
1837
1932
|
}
|
|
1838
1933
|
return null;
|
|
1839
1934
|
}
|
|
1840
1935
|
|
|
1841
1936
|
async function poll(): Promise<AskFound | null> {
|
|
1937
|
+
// Recomputed every pass rather than accumulated. A reply that has since been
|
|
1938
|
+
// DELETED must stop counting: carrying it forward would let the timeout
|
|
1939
|
+
// report "somebody replied and chose nothing" about a message that is no
|
|
1940
|
+
// longer there, and exit 4 where exit 2 is the truth. `unchosenReported`
|
|
1941
|
+
// persists on purpose — it is what keeps the operator from being told the
|
|
1942
|
+
// same thing on every tick.
|
|
1943
|
+
unchosen = undefined;
|
|
1842
1944
|
// A question that is itself a thread reply is invisible to
|
|
1843
1945
|
// `conversations.history`, so the entire poll — reactions included — has to
|
|
1844
1946
|
// come from the thread.
|
|
@@ -1955,6 +2057,25 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
1955
2057
|
console.log(found.answer);
|
|
1956
2058
|
process.exit(0);
|
|
1957
2059
|
}
|
|
2060
|
+
// Said as soon as it is seen, not only at the timeout: the reply is usually
|
|
2061
|
+
// a question BACK, and the person who can unblock it is the one watching
|
|
2062
|
+
// this command. Once per reply — a poller that repeated itself every tick
|
|
2063
|
+
// would be noise nobody reads.
|
|
2064
|
+
if (unchosen && unchosen.ts !== unchosenReported) {
|
|
2065
|
+
unchosenReported = unchosen.ts;
|
|
2066
|
+
let who = unchosen.who;
|
|
2067
|
+
try {
|
|
2068
|
+
if (who) who = await userName(token, who, cookie);
|
|
2069
|
+
} catch {
|
|
2070
|
+
// the id is still a usable answer to "who"
|
|
2071
|
+
}
|
|
2072
|
+
console.error(
|
|
2073
|
+
unchosen.ambiguous
|
|
2074
|
+
? ` (${stripTerminalControls(who)} の返信は複数の選択肢に一致します。1 つに絞ってもらうまで待ちます)`
|
|
2075
|
+
: ` (${stripTerminalControls(who)} が返信しましたが、選択肢のどれでもありません。待機を続けます)`,
|
|
2076
|
+
);
|
|
2077
|
+
console.error(` 「${stripTerminalControls(unchosen.text)}」`);
|
|
2078
|
+
}
|
|
1958
2079
|
// Checked after the first poll, never before it: `--timeout 0` means one
|
|
1959
2080
|
// look, and one look must still be a real look.
|
|
1960
2081
|
if (Date.now() >= deadline) break;
|
|
@@ -1966,6 +2087,23 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
1966
2087
|
// question is still standing, and they have just decided not to block on it.
|
|
1967
2088
|
// Printing only the permalink here leaves them where `ask` used to: holding a
|
|
1968
2089
|
// link with nothing that reads a pressed pill back.
|
|
2090
|
+
// Three states, not two. "Nobody replied" and "somebody replied and chose
|
|
2091
|
+
// nothing" need different reactions from whoever reads this — the first waits,
|
|
2092
|
+
// the second answers a person — and collapsing them into exit 2 hides a human
|
|
2093
|
+
// standing there. stdout stays empty in both: the caller must not be handed
|
|
2094
|
+
// something to act on.
|
|
2095
|
+
if (unchosen) {
|
|
2096
|
+
console.error(
|
|
2097
|
+
unchosen.ambiguous
|
|
2098
|
+
? `Error: 返信はありましたが、複数の選択肢に一致するため確定できません (メッセージはそのまま残っています)`
|
|
2099
|
+
: `Error: 返信はありましたが、選択肢のどれも選ばれていません (メッセージはそのまま残っています)`,
|
|
2100
|
+
);
|
|
2101
|
+
console.error(` 返信: 「${stripTerminalControls(unchosen.text)}」`);
|
|
2102
|
+
console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
2103
|
+
console.error(` ${shown}`);
|
|
2104
|
+
console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot)}`);
|
|
2105
|
+
process.exit(ASK_EXIT_UNCHOSEN);
|
|
2106
|
+
}
|
|
1969
2107
|
if (timeout === 0) {
|
|
1970
2108
|
console.error(`まだ回答がありません: ${shown}`);
|
|
1971
2109
|
} else {
|
|
@@ -2190,6 +2328,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2190
2328
|
ts,
|
|
2191
2329
|
question,
|
|
2192
2330
|
reactable,
|
|
2331
|
+
overflow,
|
|
2193
2332
|
threadOnly,
|
|
2194
2333
|
audience,
|
|
2195
2334
|
broadcast,
|
|
@@ -2304,6 +2443,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
2304
2443
|
ts,
|
|
2305
2444
|
question: parsed.question,
|
|
2306
2445
|
reactable: parsed.reactable,
|
|
2446
|
+
overflow: parsed.overflow,
|
|
2307
2447
|
threadOnly: parsed.threadOnly,
|
|
2308
2448
|
audience,
|
|
2309
2449
|
broadcast,
|
|
@@ -3560,7 +3700,7 @@ async function main(): Promise<void> {
|
|
|
3560
3700
|
.positional("choices", { type: "string", array: true, describe: "Up to 10 choices, seeded as 1️⃣..🔟 reactions. Beyond 10 they are listed but answerable only by text. With none, the question asks for a free-text reply." })
|
|
3561
3701
|
.option("code", { type: "string", describe: "Safety hash to confirm the ask" })
|
|
3562
3702
|
.option("body", { type: "string", describe: "Extra context shown under the question" })
|
|
3563
|
-
.option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 =
|
|
3703
|
+
.option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = somebody replied but picked none of the choices (stdout empty — do not act on it)." })
|
|
3564
3704
|
.option("waitFor", { type: "string", describe: "Collect the answer to a question already posted: pass its permalink. Nothing is posted. Same stdout/exit contract as --wait; --timeout 0 checks once and exits 2 if still open." })
|
|
3565
3705
|
.option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
|
|
3566
3706
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
@@ -3938,15 +4078,39 @@ async function main(): Promise<void> {
|
|
|
3938
4078
|
.positional("newText", { type: "string", demandOption: true })
|
|
3939
4079
|
.option("code", { type: "string", describe: "Safety hash to confirm edit" })
|
|
3940
4080
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
3941
|
-
.option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (on by default). Unresolved handles stay as plain text. Disable with --no-mentions for literal @text." })
|
|
4081
|
+
.option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (on by default). Unresolved handles stay as plain text. Disable with --no-mentions for literal @text." })
|
|
4082
|
+
.option("as-bot", { type: "boolean", default: false, describe: "Edit as the bot (xoxb / SLACK_BOT_TOKEN). REQUIRED to correct a message the bot posted: Slack only lets a token edit its OWN messages, so a bot-authored message is uneditable by the user token and chat.update returns cant_update_message. Address it by permalink or --channel-id; an @user target resolves against the BOT's DM list, which is where a bot-authored DM actually lives." }),
|
|
3942
4083
|
async (argv) => {
|
|
3943
4084
|
const args: EditArgs = { target: argv.target!, newText: argv.newText! };
|
|
3944
4085
|
if (argv.code) args.code = argv.code;
|
|
3945
4086
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
3946
4087
|
if (argv.mentions !== false) args.mentions = true;
|
|
3947
|
-
|
|
3948
|
-
if (
|
|
3949
|
-
|
|
4088
|
+
let editToken: string;
|
|
4089
|
+
if (argv["as-bot"]) {
|
|
4090
|
+
// --as-bot is not a switch on chat.update. It selects a DIFFERENT
|
|
4091
|
+
// resolved token, and until now `edit` never wired one — so every
|
|
4092
|
+
// message the bot posted was uncorrectable, and the workaround people
|
|
4093
|
+
// reached for was RESENDING, which this repo's own etiquette forbids.
|
|
4094
|
+
// Bot identity is what makes a DM notify at all, so "uncorrectable"
|
|
4095
|
+
// applied to exactly the messages that matter most.
|
|
4096
|
+
const resolved = await asBotTarget(args.target, tok(argv as W), ck(argv as W), args.channelId);
|
|
4097
|
+
editToken = resolved.botToken;
|
|
4098
|
+
if (resolved.channelId && !args.channelId) args.channelId = resolved.channelId;
|
|
4099
|
+
args.asBot = true;
|
|
4100
|
+
// Mentions still resolve on the USER token: the bot has no users:read,
|
|
4101
|
+
// and a silently unresolved @handle in a CORRECTION is the same defect
|
|
4102
|
+
// twice.
|
|
4103
|
+
args.mentionToken = tok(argv as W);
|
|
4104
|
+
const mc = ck(argv as W);
|
|
4105
|
+
if (mc) args.mentionCookie = mc;
|
|
4106
|
+
} else {
|
|
4107
|
+
editToken = tok(argv as W);
|
|
4108
|
+
// Not set under --as-bot: a bot token needs no session cookie, and
|
|
4109
|
+
// attaching one is how a working call starts failing.
|
|
4110
|
+
const cookie = ck(argv as W);
|
|
4111
|
+
if (cookie) args.cookie = cookie;
|
|
4112
|
+
}
|
|
4113
|
+
await cmdEdit(editToken, args);
|
|
3950
4114
|
},
|
|
3951
4115
|
)
|
|
3952
4116
|
.command(
|
|
@@ -3955,14 +4119,28 @@ async function main(): Promise<void> {
|
|
|
3955
4119
|
(y) => y
|
|
3956
4120
|
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
|
|
3957
4121
|
.option("code", { type: "string", describe: "Safety hash to confirm delete" })
|
|
3958
|
-
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
4122
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
4123
|
+
.option("as-bot", { type: "boolean", default: false, describe: "Delete as the bot (xoxb / SLACK_BOT_TOKEN). REQUIRED for a message the bot posted: chat.delete refuses another token's message exactly as chat.update does." }),
|
|
3959
4124
|
async (argv) => {
|
|
3960
4125
|
const args: DeleteArgs = { target: argv.target! };
|
|
3961
4126
|
if (argv.code) args.code = argv.code;
|
|
3962
4127
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
3963
|
-
|
|
3964
|
-
if (
|
|
3965
|
-
|
|
4128
|
+
let deleteToken: string;
|
|
4129
|
+
if (argv["as-bot"]) {
|
|
4130
|
+
// The SECOND call site of one defect, not a second defect: `delete`
|
|
4131
|
+
// had the identical unconditional `tok(argv)`, and chat.delete refuses
|
|
4132
|
+
// another token's message exactly as chat.update does. Fixing one and
|
|
4133
|
+
// leaving the other is how the next person finds it the hard way.
|
|
4134
|
+
const resolved = await asBotTarget(args.target, tok(argv as W), ck(argv as W), args.channelId);
|
|
4135
|
+
deleteToken = resolved.botToken;
|
|
4136
|
+
if (resolved.channelId && !args.channelId) args.channelId = resolved.channelId;
|
|
4137
|
+
args.asBot = true;
|
|
4138
|
+
} else {
|
|
4139
|
+
deleteToken = tok(argv as W);
|
|
4140
|
+
const cookie = ck(argv as W);
|
|
4141
|
+
if (cookie) args.cookie = cookie;
|
|
4142
|
+
}
|
|
4143
|
+
await cmdDelete(deleteToken, args);
|
|
3966
4144
|
},
|
|
3967
4145
|
)
|
|
3968
4146
|
.command(
|