slack-term 1.40.0 โ 1.41.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/README.md +19 -3
- package/SKILL.md +49 -1
- package/dist/cli.js +226 -190
- package/package.json +1 -1
- package/ts/ask.ts +377 -45
- package/ts/cli.ts +992 -36
- package/ts/slack.ts +12 -0
package/ts/cli.ts
CHANGED
|
@@ -28,7 +28,18 @@ import {
|
|
|
28
28
|
askMatchChoice,
|
|
29
29
|
applyInvalidNotice,
|
|
30
30
|
readInvalidNotice,
|
|
31
|
+
askResolveLang,
|
|
32
|
+
askResolvedHow,
|
|
33
|
+
askReplyPicked,
|
|
34
|
+
askResolvedKeep,
|
|
35
|
+
askBuildVoidText,
|
|
36
|
+
askSalvageUnreadable,
|
|
37
|
+
askDetectLang,
|
|
38
|
+
ASK_VOID_MARKER,
|
|
39
|
+
ASK_COPY,
|
|
40
|
+
ASK_LANGS,
|
|
31
41
|
type AskFound,
|
|
42
|
+
type AskLang,
|
|
32
43
|
} from "./ask.ts";
|
|
33
44
|
import {
|
|
34
45
|
POLL_KEYCAPS,
|
|
@@ -74,6 +85,7 @@ import {
|
|
|
74
85
|
listDrafts,
|
|
75
86
|
listUsers,
|
|
76
87
|
userInfo,
|
|
88
|
+
userLocale,
|
|
77
89
|
conversationInfo,
|
|
78
90
|
openDm,
|
|
79
91
|
parseSlackPermalink,
|
|
@@ -1291,6 +1303,8 @@ function sumCounts(m: Map<string, number>): number {
|
|
|
1291
1303
|
// --- edit ---
|
|
1292
1304
|
interface EditArgs {
|
|
1293
1305
|
allowUrlAdjacent?: boolean;
|
|
1306
|
+
/** Edit an `ask`/`poll` message anyway. Without it, `edit` refuses one. */
|
|
1307
|
+
force?: boolean;
|
|
1294
1308
|
target: string;
|
|
1295
1309
|
newText: string;
|
|
1296
1310
|
code?: string;
|
|
@@ -1385,14 +1399,47 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
1385
1399
|
|
|
1386
1400
|
guardUrlBoundaries(newText, args.allowUrlAdjacent);
|
|
1387
1401
|
|
|
1402
|
+
// An `ask` or `poll` body is a wire format: collect reads the question, the
|
|
1403
|
+
// pills, the โ
/๐ซ state and the kept body back out of it. What is refused is
|
|
1404
|
+
// an edit that BREAKS that โ new text that no longer reads as one, or an
|
|
1405
|
+
// answered/void question turned back into an open one. An edit that keeps it
|
|
1406
|
+
// readable is fine, and must stay fine: scripts resolve questions exactly this
|
|
1407
|
+
// way (the ask-devteam skill writes the โ
form with `slack edit`, from hosts
|
|
1408
|
+
// that may run older builds โ it cannot be asked to pass a new flag).
|
|
1409
|
+
const askBefore = askParseMessage(originalText).kind;
|
|
1410
|
+
const askAfter = askParseMessage(newText).kind;
|
|
1411
|
+
const isPoll = pollParseMessage(originalText).kind !== "other";
|
|
1412
|
+
const breaks = isPoll
|
|
1413
|
+
? pollParseMessage(newText).kind === "other"
|
|
1414
|
+
: askBefore !== "other" && (askAfter === "other" || (askBefore !== "open" && askAfter === "open"));
|
|
1415
|
+
if (breaks && !args.force) {
|
|
1416
|
+
const what = isPoll ? "a `slack poll`" : `a \`slack ask\` question (${askBefore === "open" ? "open" : askBefore === "resolved" ? "answered" : "void"})`;
|
|
1417
|
+
const how = isPoll || askAfter === "other"
|
|
1418
|
+
? "the new text would no longer read back as one (collect could not find the question, the pills or the answer)"
|
|
1419
|
+
: "the new text would turn it back into an OPEN question";
|
|
1420
|
+
console.error(
|
|
1421
|
+
`Error: this message is ${what}, and ${how}.\n` +
|
|
1422
|
+
(isPoll
|
|
1423
|
+
? ` Edit it anyway with --force.`
|
|
1424
|
+
: ` To retire it: slack ask --void='${args.target}' --reason 'โฆ'\n Edit it anyway with --force.`),
|
|
1425
|
+
);
|
|
1426
|
+
process.exit(1);
|
|
1427
|
+
}
|
|
1428
|
+
const guarded = breaks;
|
|
1429
|
+
|
|
1388
1430
|
// Identity is part of the hash for the same reason it is on `send`: a code
|
|
1389
1431
|
// minted while previewing as one identity must not confirm the write as
|
|
1390
1432
|
// another (Slack only lets you edit your own messages, so a silent profile
|
|
1391
1433
|
// switch between preview and confirm otherwise turns into a bare API error).
|
|
1392
1434
|
const self = await getSelf();
|
|
1393
|
-
|
|
1435
|
+
// Overriding the guard is part of what the code authorizes: a code minted for
|
|
1436
|
+
// an ordinary edit must not confirm a forced rewrite of a question.
|
|
1437
|
+
const code = guarded
|
|
1438
|
+
? safetyCode("force-edit-ask-poll", originalText, newText, self?.userId ?? "")
|
|
1439
|
+
: safetyCode(originalText, newText, self?.userId ?? "");
|
|
1394
1440
|
if (args.code !== code) {
|
|
1395
1441
|
requireCode(args.code, code, [
|
|
1442
|
+
...(guarded ? [`โ --force: rewriting a \`slack ${isPoll ? "poll" : "ask"}\` message by hand โ collect may no longer read it back`] : []),
|
|
1396
1443
|
`--- Editing as -------------------------------`,
|
|
1397
1444
|
fromLine(self, { asBot: args.asBot }),
|
|
1398
1445
|
`--- Original message -------------------------`,
|
|
@@ -1404,7 +1451,13 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
1404
1451
|
}
|
|
1405
1452
|
|
|
1406
1453
|
const attr = sentAttribution("edit");
|
|
1407
|
-
|
|
1454
|
+
// An ask/poll body goes up PLAIN (no blocks): with blocks attached Slack
|
|
1455
|
+
// rewrites the stored text โ every newline becomes a space โ and the message
|
|
1456
|
+
// can never be read back as an ask again (2026-10-01: a release ask edited
|
|
1457
|
+
// this way could neither be collected nor voided). Ordinary messages keep
|
|
1458
|
+
// the blocks they always had.
|
|
1459
|
+
const askish = askBefore !== "other" || askAfter !== "other" || isPoll || pollParseMessage(newText).kind !== "other";
|
|
1460
|
+
const newTs = await editMessage(token, channelId, ts, newText, args.cookie, askish ? true : undefined, attr.metadata);
|
|
1408
1461
|
console.log(`โ Edited (ts: ${newTs})`);
|
|
1409
1462
|
attr.record({ team: self?.team, channel: channelId, target: args.target, ts, text: newText, asBot: args.asBot });
|
|
1410
1463
|
}
|
|
@@ -2384,6 +2437,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
2384
2437
|
// 5 replied in free text, picking none of the offered choices โ the reply
|
|
2385
2438
|
// text is on stdout. NOT a decision: the question is left open, so a pill
|
|
2386
2439
|
// pressed later still settles it (`--waitFor โฆ --after=<reply ts>`).
|
|
2440
|
+
// 6 the question was VOIDED (็กๅนๅ, `slack ask --void`) โ it will never be
|
|
2441
|
+
// answered; stop waiting. stdout stays empty.
|
|
2387
2442
|
// Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
|
|
2388
2443
|
|
|
2389
2444
|
const ASK_EXIT_TIMEOUT = 2;
|
|
@@ -2399,6 +2454,10 @@ const ASK_EXIT_UNCHOSEN = 4;
|
|
|
2399
2454
|
* (measured 2026-09-27: 8 of 71 asks answered this way, none collected). Still
|
|
2400
2455
|
* not 0, so nothing mistakes it for a decision. */
|
|
2401
2456
|
const ASK_EXIT_FREETEXT = 5;
|
|
2457
|
+
/** The question was voided (็กๅนๅ) โ expired, superseded, or overtaken by events.
|
|
2458
|
+
* Neither "open" nor "answered": a monitor that kept waiting on it would wait
|
|
2459
|
+
* forever, and one that read it as answered would act on nothing. */
|
|
2460
|
+
const ASK_EXIT_VOID = 6;
|
|
2402
2461
|
|
|
2403
2462
|
const ASK_PHI = 1.618033988749895;
|
|
2404
2463
|
const ASK_POLL_MIN_MS = 1000;
|
|
@@ -2408,6 +2467,12 @@ const ASK_MAX_CONSECUTIVE_ERRORS = 8;
|
|
|
2408
2467
|
|
|
2409
2468
|
interface AskArgs {
|
|
2410
2469
|
allowUrlAdjacent?: boolean;
|
|
2470
|
+
/** `--void=<old> --reask`: once this question is posted, void `old` with this
|
|
2471
|
+
* question as its replacement. Validated (own, open) by the caller. */
|
|
2472
|
+
voidAfter?: AskVoidTarget & { reason: string };
|
|
2473
|
+
/** `--lang`. Unset = pick from the readers' Slack locale, then the content,
|
|
2474
|
+
* then the system locale โ see `askResolveLang`. */
|
|
2475
|
+
lang?: string;
|
|
2411
2476
|
target: string;
|
|
2412
2477
|
question: string;
|
|
2413
2478
|
choices: string[];
|
|
@@ -2417,6 +2482,8 @@ interface AskArgs {
|
|
|
2417
2482
|
userId?: string;
|
|
2418
2483
|
asBot?: boolean;
|
|
2419
2484
|
wait?: boolean;
|
|
2485
|
+
/** `--json` with `--wait`: one JSON object on stdout (answer, notes, cursor). */
|
|
2486
|
+
json?: boolean;
|
|
2420
2487
|
timeout?: number;
|
|
2421
2488
|
cookie?: string;
|
|
2422
2489
|
// Token used to resolve the @tags that decide who may answer (needs
|
|
@@ -2488,6 +2555,9 @@ interface AskWaitCtx {
|
|
|
2488
2555
|
* nothing". */
|
|
2489
2556
|
overflow: string[];
|
|
2490
2557
|
threadOnly: boolean;
|
|
2558
|
+
/** The language the question's copy is in โ the โ
stamp and the
|
|
2559
|
+
* invalid-ballot line are written in the same one. */
|
|
2560
|
+
lang: AskLang;
|
|
2491
2561
|
/** Who may answer. Empty + broadcast=false would accept nobody, which is why
|
|
2492
2562
|
* posting an unaddressed question is refused up front. */
|
|
2493
2563
|
audience: Set<string>;
|
|
@@ -2503,16 +2573,148 @@ interface AskWaitCtx {
|
|
|
2503
2573
|
/** Only so the resume hint printed on timeout names the right identity โ the
|
|
2504
2574
|
* โ
rewrite can be done by the original poster alone. */
|
|
2505
2575
|
asBot: boolean;
|
|
2506
|
-
/**
|
|
2507
|
-
*
|
|
2576
|
+
/** The CURSOR: everything at or before this ts was already delivered to the
|
|
2577
|
+
* caller. Text replies there are not answers again (the free-text reply an
|
|
2578
|
+
* exit 5 delivered), and not thread notes again. Reactions still count. */
|
|
2508
2579
|
after?: string;
|
|
2580
|
+
/** `--json`: stdout is one JSON object (answer + notes + cursor) instead of
|
|
2581
|
+
* the bare answer. Exit codes are the same either way. */
|
|
2582
|
+
json: boolean;
|
|
2509
2583
|
}
|
|
2510
2584
|
|
|
2585
|
+
/** A reply from the question's audience that is NOT the answer โ an extra
|
|
2586
|
+
* instruction ("also look at X"), a caveat, a question back after a pill.
|
|
2587
|
+
*
|
|
2588
|
+
* Reported from real use 2026-10-02: a pill was pressed and collected, โ
was
|
|
2589
|
+
* written, and the watcher exited; 40 s later the same person added a note in
|
|
2590
|
+
* the thread, and nothing ever delivered it. A pill was the whole answer as far
|
|
2591
|
+
* as `ask` was concerned, so the thread was never read โ and once โ
was
|
|
2592
|
+
* written, `--waitFor` only ever returned the stored answer. */
|
|
2593
|
+
interface AskNote { ts: string; user: string; text: string; permalink: string }
|
|
2594
|
+
|
|
2595
|
+
/** Who and where notes are read from โ the same audience rule as answers. */
|
|
2596
|
+
interface AskNoteScope {
|
|
2597
|
+
channelId: string;
|
|
2598
|
+
ts: string;
|
|
2599
|
+
threadParentTs?: string;
|
|
2600
|
+
threadOnly: boolean;
|
|
2601
|
+
/** The question's own `reply_count`, or undefined when the question was not
|
|
2602
|
+
* in the page we read. Its thread is skipped only on a KNOWN zero: a call
|
|
2603
|
+
* per collection on every threadless question is a cost paid for nothing,
|
|
2604
|
+
* but an unknown count read as zero loses the notes โ in a busy channel the
|
|
2605
|
+
* question falls out of the 30-message history page. */
|
|
2606
|
+
replyCount: number | undefined;
|
|
2607
|
+
isAnswerer: (user: unknown) => user is string;
|
|
2608
|
+
askerUserId: string;
|
|
2609
|
+
askerBotId: string;
|
|
2610
|
+
cookie?: string;
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
/** The audience's replies after `after` (or after the question), oldest first.
|
|
2614
|
+
* `skip` drops the reply that IS the answer.
|
|
2615
|
+
*
|
|
2616
|
+
* Fail-soft: a note is extra information, never the answer, so a failed fetch
|
|
2617
|
+
* is reported on stderr and must not cost the caller the answer it already
|
|
2618
|
+
* has. Thread replies come from the thread; in a 1:1 DM, plain replies count
|
|
2619
|
+
* too โ bounded by our NEXT question there, exactly as answers are, so a note
|
|
2620
|
+
* meant for a newer question is not delivered with an older one. */
|
|
2621
|
+
async function askCollectNotes(token: string, sc: AskNoteScope, after: string | undefined, skip: (m: Record<string, Json>) => boolean): Promise<AskNote[]> {
|
|
2622
|
+
const pool = new Map<string, Record<string, Json>>();
|
|
2623
|
+
if (sc.threadParentTs || sc.replyCount !== 0) {
|
|
2624
|
+
try {
|
|
2625
|
+
const rep = asRecord((await replies(token, sc.channelId, sc.threadParentTs ?? sc.ts, 100, sc.cookie)) as Json);
|
|
2626
|
+
for (const m of asArray(rep.messages).map(asRecord)) pool.set(String(m.ts), m);
|
|
2627
|
+
} catch (e: unknown) {
|
|
2628
|
+
console.error(` (ในใฌใใใฎ่ฟฝ่จใ่ชญใใพใใใงใใ: ${e instanceof Error ? e.message : String(e)})`);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
if (!sc.threadOnly && !sc.threadParentTs) {
|
|
2632
|
+
try {
|
|
2633
|
+
const hist = asRecord((await history(token, sc.channelId, 30, sc.ts, undefined, sc.cookie, true)) as Json);
|
|
2634
|
+
const msgs = asArray(hist.messages).map(asRecord);
|
|
2635
|
+
const bound = msgs
|
|
2636
|
+
.filter((m) => Number(m.ts) > Number(sc.ts) && (
|
|
2637
|
+
(sc.askerBotId && m.bot_id === sc.askerBotId) || (sc.askerUserId && m.user === sc.askerUserId)))
|
|
2638
|
+
.reduce((min, m) => Math.min(min, Number(m.ts)), Infinity);
|
|
2639
|
+
for (const m of msgs) if (Number(m.ts) < bound && !pool.has(String(m.ts))) pool.set(String(m.ts), m);
|
|
2640
|
+
} catch (e: unknown) {
|
|
2641
|
+
console.error(` (DM ใฎ่ฟฝ่จใ่ชญใใพใใใงใใ: ${e instanceof Error ? e.message : String(e)})`);
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
const picked = [...pool.values()]
|
|
2645
|
+
.filter((m) => Number(m.ts) > Number(sc.ts))
|
|
2646
|
+
.filter((m) => !after || Number(m.ts) > Number(after))
|
|
2647
|
+
// The asker is excluded by isAnswerer; any bot post (ours or another
|
|
2648
|
+
// integration's) is excluded here, so nothing echoes back as a "note".
|
|
2649
|
+
.filter((m) => sc.isAnswerer(m.user) && !m.bot_id)
|
|
2650
|
+
.filter((m) => typeof m.subtype !== "string" || ASK_ANSWERABLE_SUBTYPES.has(m.subtype))
|
|
2651
|
+
.filter((m) => typeof m.text === "string" && m.text.trim() !== "")
|
|
2652
|
+
// Sorted BEFORE `skip`, which may be stateful ("the first reply that โฆ").
|
|
2653
|
+
.sort((a, b) => Number(a.ts) - Number(b.ts))
|
|
2654
|
+
.filter((m) => !skip(m));
|
|
2655
|
+
const out: AskNote[] = [];
|
|
2656
|
+
for (const m of picked) {
|
|
2657
|
+
let permalink = "";
|
|
2658
|
+
try {
|
|
2659
|
+
permalink = await getPermalink(token, sc.channelId, String(m.ts), sc.cookie);
|
|
2660
|
+
} catch {
|
|
2661
|
+
// fail-soft: the ts still names it
|
|
2662
|
+
}
|
|
2663
|
+
out.push({ ts: String(m.ts), user: String(m.user), text: (m.text as string).trim(), permalink });
|
|
2664
|
+
}
|
|
2665
|
+
return out;
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
/** The next cursor: the newest thing this result delivered, never older than
|
|
2669
|
+
* the cursor it was given. A caller that passes it back as `--after` gets
|
|
2670
|
+
* nothing twice. */
|
|
2671
|
+
function askNextCursor(...tss: (string | undefined)[]): string {
|
|
2672
|
+
return tss.filter((t): t is string => !!t).reduce((a, b) => (!a || Number(b) > Number(a) ? b : a), "");
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
/** Deliver an answer (or the lack of one). Plain mode keeps the contract โ
|
|
2676
|
+
* stdout is the answer alone โ and puts the notes and the next-cursor hint on
|
|
2677
|
+
* stderr. `--json` puts all of it in one object on stdout. */
|
|
2678
|
+
async function askEmit(
|
|
2679
|
+
token: string,
|
|
2680
|
+
o: { json: boolean; shown: string; asBot: boolean; cookie?: string | undefined },
|
|
2681
|
+
r: { status: "answered" | "freetext" | "ambiguous" | "timeout" | "void"; answer: string | null; how?: string; who?: string; reason?: string; supersededBy?: string; notes: AskNote[]; cursor: string },
|
|
2682
|
+
): Promise<void> {
|
|
2683
|
+
if (o.json) {
|
|
2684
|
+
console.log(JSON.stringify({
|
|
2685
|
+
status: r.status, answer: r.answer, how: r.how ?? null, who: r.who ?? null,
|
|
2686
|
+
...(r.status === "void" ? { reason: r.reason ?? "", supersededBy: r.supersededBy ?? null } : {}),
|
|
2687
|
+
notes: r.notes, cursor: r.cursor, permalink: o.shown,
|
|
2688
|
+
}));
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
if (r.notes.length) {
|
|
2692
|
+
console.error(` ในใฌใใใฎ่ฟฝ่จ ${r.notes.length} ไปถ (ๅ็ญใจใฏๅฅ):`);
|
|
2693
|
+
for (const n of r.notes) {
|
|
2694
|
+
let who = n.user;
|
|
2695
|
+
try {
|
|
2696
|
+
who = await userName(token, n.user, o.cookie);
|
|
2697
|
+
} catch {
|
|
2698
|
+
// the id still says who
|
|
2699
|
+
}
|
|
2700
|
+
for (const [i, line] of n.text.split("\n").entries()) {
|
|
2701
|
+
console.error(` ${i ? " ".repeat(stripTerminalControls(who).length + 2) : `${stripTerminalControls(who)}: `}${stripTerminalControls(line)}`);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
if (r.status === "answered") {
|
|
2706
|
+
console.error(` ใใฎๅพใฎ่ฟฝ่จใๆพใ: ${askResumeCommand(o.shown, o.asBot, r.cursor)}`);
|
|
2707
|
+
}
|
|
2708
|
+
if (r.answer !== null) console.log(r.answer);
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
|
|
2511
2712
|
/** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
|
|
2512
2713
|
* on timeout. A timeout of 0 means "look exactly once", so a caller can poll
|
|
2513
2714
|
* cheaply on its own schedule instead of parking a process on `--wait`. */
|
|
2514
2715
|
async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never> {
|
|
2515
2716
|
const { channelId, ts, question, reactable, threadOnly, audience, broadcast, cookie, timeout, shown } = ctx;
|
|
2717
|
+
const copy = ASK_COPY[ctx.lang];
|
|
2516
2718
|
const askerUserId = ctx.askerUserId;
|
|
2517
2719
|
const askerBotId = ctx.askerBotId;
|
|
2518
2720
|
|
|
@@ -2523,6 +2725,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2523
2725
|
const isAnswerer = (user: unknown): user is string =>
|
|
2524
2726
|
typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
|
|
2525
2727
|
|
|
2728
|
+
/** The question's `reply_count` as last seen โ whether its thread is worth a
|
|
2729
|
+
* fetch when collecting notes. Undefined until the question itself is seen. */
|
|
2730
|
+
let replyCount: number | undefined;
|
|
2731
|
+
/** The question's text as last read โ what the โ
rewrite keeps the body of. */
|
|
2732
|
+
let ownText: string | undefined;
|
|
2733
|
+
/** Set when the question is voided while we wait โ the wait ends there. */
|
|
2734
|
+
let voided: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }> | undefined;
|
|
2735
|
+
const checkVoid = (own: Record<string, Json>): boolean => {
|
|
2736
|
+
const p = typeof own.text === "string" ? askParseMessage(own.text) : undefined;
|
|
2737
|
+
if (p?.kind === "void") voided = p;
|
|
2738
|
+
return !!voided;
|
|
2739
|
+
};
|
|
2740
|
+
|
|
2526
2741
|
/** The earliest reply from an answerer that picked none of the choices. Kept
|
|
2527
2742
|
* across polls so the timeout can report it, and so the operator is told once
|
|
2528
2743
|
* rather than on every tick. */
|
|
@@ -2556,7 +2771,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2556
2771
|
async function noteInvalid(msg: Record<string, Json>, offenders: string[]): Promise<void> {
|
|
2557
2772
|
const text = typeof msg.text === "string" ? msg.text : "";
|
|
2558
2773
|
if (!text) return;
|
|
2559
|
-
const next = applyInvalidNotice(text, offenders);
|
|
2774
|
+
const next = applyInvalidNotice(text, offenders, ctx.lang);
|
|
2560
2775
|
if (next === text) return;
|
|
2561
2776
|
try {
|
|
2562
2777
|
await editMessage(token, channelId, ts, next, cookie, true);
|
|
@@ -2566,7 +2781,22 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2566
2781
|
}
|
|
2567
2782
|
}
|
|
2568
2783
|
|
|
2784
|
+
/** Who has pressed โ ("other"). Not an answer โ โ carries no text โ so it
|
|
2785
|
+
* never resolves anything; the reply that should follow it does. Reported
|
|
2786
|
+
* once per person so the operator knows a reply is on its way rather than
|
|
2787
|
+
* reading the silence as "nobody looked". */
|
|
2788
|
+
const otherReported = new Set<string>();
|
|
2789
|
+
function noteOther(msg: Record<string, Json>): void {
|
|
2790
|
+
const r = asArray(msg.reactions).map(asRecord).find((x) => x.name === ASK_MARKER);
|
|
2791
|
+
for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
|
|
2792
|
+
if (otherReported.has(u)) continue;
|
|
2793
|
+
otherReported.add(u);
|
|
2794
|
+
console.error(` (<@${u}> ใ โ ใใฎไป ใๆผใใพใใใ่ฟไฟกใๅพ
ใกใพใ)`);
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2569
2798
|
async function answerFromReactions(msg: Record<string, Json>): Promise<AskFound | null> {
|
|
2799
|
+
if (reactable.length) noteOther(msg);
|
|
2570
2800
|
const picks = humanChoices(msg);
|
|
2571
2801
|
// Nothing ambiguous any more โ take the notice back down if one is up.
|
|
2572
2802
|
if (picks.length <= 1 && readInvalidNotice(typeof msg.text === "string" ? msg.text : "").length) {
|
|
@@ -2575,15 +2805,26 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2575
2805
|
if (!picks.length) return null;
|
|
2576
2806
|
if (picks.length === 1) {
|
|
2577
2807
|
const { index, users } = picks[0]!;
|
|
2578
|
-
return { answer: reactable[index]!, how:
|
|
2808
|
+
return { answer: reactable[index]!, how: copy.howReaction(ASK_KEYCAPS[index]!.glyph), who: users[0]!, choice: index + 1 };
|
|
2579
2809
|
}
|
|
2580
2810
|
const glyphs = picks.map((p) => ASK_KEYCAPS[p.index]!.glyph).join(" / ");
|
|
2581
|
-
|
|
2582
|
-
//
|
|
2583
|
-
//
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
const
|
|
2811
|
+
// Two cases, both "do not guess": one person on several pills, or
|
|
2812
|
+
// different people on different pills (a split). Only the first is the
|
|
2813
|
+
// voter's to fix, so only those people are named in the message.
|
|
2814
|
+
const pillsPerUser = new Map<string, number>();
|
|
2815
|
+
for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
|
|
2816
|
+
const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
|
|
2817
|
+
console.error(multi.length
|
|
2818
|
+
? ` (${glyphs} ใๅๆใซ้ธใฐใใฆใใพใใ1 ใคใซ็ตใฃใฆใใใใพใงๅพ
ใกใพใ)`
|
|
2819
|
+
: ` (ๅ็ญใๅใใใฆใใพใ: ${glyphs}ใใฉใกใใไธๆนใซใใใใพใงๅพ
ใกใพใ)`);
|
|
2820
|
+
// Whoever is on more than one pill โ and ONLY them. Naming everyone on any
|
|
2821
|
+
// pill told a voter who had pressed exactly one that she had pressed two
|
|
2822
|
+
// (2026-10-01: a voter on 1๏ธโฃ only was named because someone else was on 1๏ธโฃ
|
|
2823
|
+
// and 2๏ธโฃ). Named in the question itself so the person who has to fix it is
|
|
2824
|
+
// the person who sees it; rewritten from the current state each pass, so
|
|
2825
|
+
// reruns cannot stack up notices and the line clears itself once a
|
|
2826
|
+
// reaction is taken back. A pure split names nobody (empty = no notice).
|
|
2827
|
+
const offenders = multi.sort();
|
|
2587
2828
|
await noteInvalid(msg, offenders);
|
|
2588
2829
|
return null;
|
|
2589
2830
|
}
|
|
@@ -2607,13 +2848,13 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2607
2848
|
if (!t) continue;
|
|
2608
2849
|
// A question asked WITHOUT choices is answered by whatever comes back โ
|
|
2609
2850
|
// there is nothing to match against, and every reply is the answer.
|
|
2610
|
-
if (!candidates.length) return { answer: t, how:
|
|
2851
|
+
if (!candidates.length) return { answer: t, how: copy.howReply, who: m.user, ts: String(m.ts) };
|
|
2611
2852
|
const match = askMatchChoice(t, candidates);
|
|
2612
2853
|
if (match.kind === "chosen") {
|
|
2613
2854
|
// Answer with the CHOICE, not with the reply that selected it: "2" and
|
|
2614
2855
|
// "2. ไธญๆญข" have to reach the caller as the same decision a pill would
|
|
2615
2856
|
// have produced, or the same answer arrives in three spellings.
|
|
2616
|
-
return { answer: candidates[match.index - 1]!, how:
|
|
2857
|
+
return { answer: candidates[match.index - 1]!, how: copy.howReplyN(match.index), who: m.user, ts: String(m.ts), choice: match.index };
|
|
2617
2858
|
}
|
|
2618
2859
|
// Replied, but picked nothing. Recorded rather than returned: a later
|
|
2619
2860
|
// reply may still choose, and the first one is what the operator needs to
|
|
@@ -2647,6 +2888,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2647
2888
|
const messages = asArray(rep.messages).map(asRecord);
|
|
2648
2889
|
const own = messages.find((m) => m.ts === ts);
|
|
2649
2890
|
if (own) {
|
|
2891
|
+
replyCount = Number(own.reply_count) || 0;
|
|
2892
|
+
if (typeof own.text === "string") ownText = own.text;
|
|
2893
|
+
if (checkVoid(own)) return null;
|
|
2650
2894
|
const byReaction = await answerFromReactions(own);
|
|
2651
2895
|
if (byReaction) return byReaction;
|
|
2652
2896
|
}
|
|
@@ -2660,6 +2904,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2660
2904
|
const messages = asArray(hist.messages).map(asRecord);
|
|
2661
2905
|
const own = messages.find((m) => m.ts === ts);
|
|
2662
2906
|
|
|
2907
|
+
if (own) {
|
|
2908
|
+
replyCount = Number(own.reply_count) || 0;
|
|
2909
|
+
if (typeof own.text === "string") ownText = own.text;
|
|
2910
|
+
if (checkVoid(own)) return null;
|
|
2911
|
+
}
|
|
2663
2912
|
// A reaction is the intended path, so it wins when both are present.
|
|
2664
2913
|
if (own) {
|
|
2665
2914
|
const byReaction = await answerFromReactions(own);
|
|
@@ -2713,7 +2962,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2713
2962
|
// and must keep getting the answer body alone.
|
|
2714
2963
|
if (who) console.error(` ๅ็ญ่
: ${stripTerminalControls(who)}`);
|
|
2715
2964
|
try {
|
|
2716
|
-
|
|
2965
|
+
// The body and the chosen option stay; only what no longer applies goes.
|
|
2966
|
+
// Built from the message as it is NOW (an edit made in Slack after asking
|
|
2967
|
+
// is kept). Not seen this run โ the bare form, as before.
|
|
2968
|
+
const keep = ownText ? askResolvedKeep(ownText, found.choice) : undefined;
|
|
2969
|
+
await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
|
|
2717
2970
|
// Swap the marker for the resolved one so search reflects reality:
|
|
2718
2971
|
// `has::question:` should list what still needs answering, not everything
|
|
2719
2972
|
// ever asked. Removal last โ a crash between the two leaves the question
|
|
@@ -2749,10 +3002,34 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2749
3002
|
}
|
|
2750
3003
|
console.error(` (Slack ใจใฉใผ ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, ๅ่ฉฆ่กใใพใ: ${e instanceof Error ? e.message : String(e)})`);
|
|
2751
3004
|
}
|
|
3005
|
+
if (voided) {
|
|
3006
|
+
askReportVoid(voided, shown);
|
|
3007
|
+
if (ctx.json) {
|
|
3008
|
+
await askEmit(token, ctx, {
|
|
3009
|
+
status: "void", answer: null, reason: voided.reason,
|
|
3010
|
+
...(voided.supersededBy ? { supersededBy: voided.supersededBy } : {}),
|
|
3011
|
+
notes: [], cursor: askNextCursor(ts, ctx.after),
|
|
3012
|
+
});
|
|
3013
|
+
}
|
|
3014
|
+
process.exit(ASK_EXIT_VOID);
|
|
3015
|
+
}
|
|
2752
3016
|
if (found) {
|
|
3017
|
+
// Notes once the answer is in hand: a pill used to end the poll before
|
|
3018
|
+
// the thread was ever read, which is how a note beside it was lost. Read
|
|
3019
|
+
// BEFORE โ
is written, and delivered right after โ โ
is what tells a
|
|
3020
|
+
// later --waitFor the question is settled.
|
|
3021
|
+
const notes = await askCollectNotes(token, {
|
|
3022
|
+
channelId, ts, threadOnly, replyCount, isAnswerer, askerUserId, askerBotId,
|
|
3023
|
+
...(ctx.threadParentTs ? { threadParentTs: ctx.threadParentTs } : {}),
|
|
3024
|
+
...(cookie ? { cookie } : {}),
|
|
3025
|
+
}, ctx.after, (m) => m.ts === found!.ts);
|
|
2753
3026
|
await markResolved(found);
|
|
2754
|
-
|
|
2755
|
-
|
|
3027
|
+
const r: Parameters<typeof askEmit>[2] = {
|
|
3028
|
+
status: "answered", answer: found.answer, how: found.how, notes,
|
|
3029
|
+
cursor: askNextCursor(ts, ctx.after, found.ts, ...notes.map((n) => n.ts)),
|
|
3030
|
+
};
|
|
3031
|
+
if (found.who) r.who = found.who;
|
|
3032
|
+
await askEmit(token, ctx, r);
|
|
2756
3033
|
process.exit(0);
|
|
2757
3034
|
}
|
|
2758
3035
|
// A free-text reply that picks nothing is delivered NOW, not held until the
|
|
@@ -2771,7 +3048,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2771
3048
|
console.error(` ้ธๆ่ข: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
2772
3049
|
console.error(` ${shown}`);
|
|
2773
3050
|
console.error(` ใใฎ่ฟไฟกใฎๅพใๅพ
ใค: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
|
|
2774
|
-
|
|
3051
|
+
if (ctx.json) {
|
|
3052
|
+
await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
|
|
3053
|
+
} else {
|
|
3054
|
+
console.log(unchosen.text);
|
|
3055
|
+
}
|
|
2775
3056
|
process.exit(ASK_EXIT_FREETEXT);
|
|
2776
3057
|
}
|
|
2777
3058
|
// Said as soon as it is seen, not only at the timeout: the reply is usually
|
|
@@ -2813,6 +3094,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2813
3094
|
console.error(` ้ธๆ่ข: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
2814
3095
|
console.error(` ${shown}`);
|
|
2815
3096
|
console.error(` ่ฟ็ญใใฆใใๅๅใใ: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3097
|
+
if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
|
|
2816
3098
|
process.exit(ASK_EXIT_UNCHOSEN);
|
|
2817
3099
|
}
|
|
2818
3100
|
if (timeout === 0) {
|
|
@@ -2822,6 +3104,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2822
3104
|
console.error(` ${shown}`);
|
|
2823
3105
|
console.error(` ใใจใงๅๅใใ: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
2824
3106
|
}
|
|
3107
|
+
if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
|
|
2825
3108
|
process.exit(ASK_EXIT_TIMEOUT);
|
|
2826
3109
|
}
|
|
2827
3110
|
|
|
@@ -2872,6 +3155,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2872
3155
|
const qEnc = askEncodeBroadcasts(unescapeArg(args.question));
|
|
2873
3156
|
const bEnc = askEncodeBroadcasts(unescapeArg(args.body ?? ""));
|
|
2874
3157
|
const broadcastKinds = new Set([...qEnc.kinds, ...bEnc.kinds]);
|
|
3158
|
+
// Already-encoded broadcasts (`<!here>`) โ what a re-ask of a posted question carries.
|
|
3159
|
+
for (const m of `${qEnc.text}\n${bEnc.text}`.matchAll(/<!(here|channel|everyone)(?:\|[^>]*)?>/g)) broadcastKinds.add(m[1]!);
|
|
2875
3160
|
const mentionToken = args.mentionToken ?? token;
|
|
2876
3161
|
const qRep = await encodeMentionsDetailed(mentionToken, qEnc.text, channelId, args.mentionCookie);
|
|
2877
3162
|
const bRep = bEnc.text
|
|
@@ -2889,6 +3174,14 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2889
3174
|
// Tagging yourself grants nothing โ your own reactions are the seeds.
|
|
2890
3175
|
const audience = new Set(resolved.map((r) => r.userId).filter((id) => id !== selfId));
|
|
2891
3176
|
const audienceNames = new Map(resolved.map((r) => [r.userId, r.display]));
|
|
3177
|
+
// Tags already in Slack's `<@Uโฆ>` form name someone just as surely โ that is
|
|
3178
|
+
// what a re-ask built from a posted question carries. Same self-exclusion.
|
|
3179
|
+
for (const m of `${question}\n${body}`.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)) {
|
|
3180
|
+
const id = m[1]!;
|
|
3181
|
+
if (id === selfId || audience.has(id)) continue;
|
|
3182
|
+
audience.add(id);
|
|
3183
|
+
audienceNames.set(id, await userName(mentionToken, id, args.mentionCookie));
|
|
3184
|
+
}
|
|
2892
3185
|
let audienceLabel: string;
|
|
2893
3186
|
if (broadcast) {
|
|
2894
3187
|
audienceLabel = `anyone in ${ref} (${[...broadcastKinds].map((k) => `@${k}`).join(", ")})`;
|
|
@@ -2931,7 +3224,19 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2931
3224
|
// which makes channel mode unambiguous by construction. A question posted
|
|
2932
3225
|
// INTO a thread is thread-scoped for the same reason.
|
|
2933
3226
|
const threadOnly = !channelId.startsWith("D") || !!threadTs;
|
|
2934
|
-
|
|
3227
|
+
// The copy's language. The readers are the people who have to read it, so
|
|
3228
|
+
// their Slack locale comes first โ but only for a known set of people: an
|
|
3229
|
+
// @here audience is not one, and is left to the content and the system.
|
|
3230
|
+
const readerLocales = broadcast
|
|
3231
|
+
? []
|
|
3232
|
+
: await Promise.all([...audience].map((id) => userLocale(mentionToken, id, args.mentionCookie)));
|
|
3233
|
+
const picked = askResolveLang({ flag: args.lang, readerLocales, content: [question, body, ...reactable, ...overflow] });
|
|
3234
|
+
if (!picked) {
|
|
3235
|
+
console.error(`Error: unsupported language "${stripTerminalControls(args.lang ?? process.env.SLACK_TERM_LANG ?? "")}" โ use one of: ${ASK_LANGS.join(", ")}`);
|
|
3236
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3237
|
+
}
|
|
3238
|
+
const lang = picked.lang;
|
|
3239
|
+
const message = askBuildText(question, body, reactable, overflow, threadOnly, lang);
|
|
2935
3240
|
guardUrlBoundaries(message, args.allowUrlAdjacent);
|
|
2936
3241
|
|
|
2937
3242
|
// Preview the destination's last message, exactly as `send` does โ the gate's
|
|
@@ -2958,7 +3263,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2958
3263
|
// Resolved before the gate so the clock can name the recipient's zone. DM
|
|
2959
3264
|
// only; null means "unknown" and the label says so rather than guessing.
|
|
2960
3265
|
const recipientTz = await recipientTzFor(token, channelId, cookie);
|
|
2961
|
-
const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? ""
|
|
3266
|
+
const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "",
|
|
3267
|
+
...(args.voidAfter ? ["reask", `${args.voidAfter.channelId}:${args.voidAfter.ts}`, args.voidAfter.reason] : []));
|
|
2962
3268
|
if (args.code !== code) {
|
|
2963
3269
|
const dest = await destLabel(token, channelId, ref, cookie);
|
|
2964
3270
|
const choiceLines = reactable.length
|
|
@@ -2981,6 +3287,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2981
3287
|
// shown as its own line rather than left to be inferred from the body.
|
|
2982
3288
|
` Answerable by: ${audienceLabel}`,
|
|
2983
3289
|
` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
|
|
3290
|
+
...(args.voidAfter ? [` Then ็กๅนๅ: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) โ replaced by this question`] : []),
|
|
3291
|
+
` Language: ${lang} (${({ flag: "--lang", env: "SLACK_TERM_LANG", readers: "answerers' Slack locale", content: "the question's text", system: "system locale", default: "default" })[picked.source]}; override with --lang)`,
|
|
2984
3292
|
`--------------------------------โโโโโโโโโโโโ`,
|
|
2985
3293
|
], recipientTz);
|
|
2986
3294
|
}
|
|
@@ -2991,7 +3299,9 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2991
3299
|
const attr = sentAttribution("ask");
|
|
2992
3300
|
const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
|
|
2993
3301
|
|
|
2994
|
-
// The marker goes on
|
|
3302
|
+
// The marker goes on LAST, after 1,2,3, because it doubles as the "other"
|
|
3303
|
+
// choice the body lists under the numbered ones (people were already pressing
|
|
3304
|
+
// โ for "none of these fit"). It is a reaction so
|
|
2995
3305
|
// `has::question:` lists every question the way `has::pushpin:` lists every
|
|
2996
3306
|
// todo. Body text cannot do that job: Slack's index splits on punctuation, so
|
|
2997
3307
|
// a `:question:` written in the text is indexed as the word "question" and
|
|
@@ -3000,12 +3310,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3000
3310
|
// NOTE the colons: the modifier's argument is itself colon-wrapped, and the
|
|
3001
3311
|
// bare `has:question` form does NOT error โ it silently degrades to a
|
|
3002
3312
|
// full-text search and returns plausible-looking counts. See ts/todo.ts.
|
|
3003
|
-
//
|
|
3313
|
+
// 1,2,3, then the marker โ and spaced out. Sequential awaits alone are NOT
|
|
3004
3314
|
// enough: measured on two real questions from the same build, one came back
|
|
3005
3315
|
// `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
|
|
3006
3316
|
// SLACK_REACTION_SEED_GAP_MS.
|
|
3007
3317
|
await seedReactionsInOrder(
|
|
3008
|
-
[
|
|
3318
|
+
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
|
|
3009
3319
|
(name) => reactionAdd(token, channelId, ts, name, cookie),
|
|
3010
3320
|
(name, e) => {
|
|
3011
3321
|
const why = e instanceof Error ? e.message : String(e);
|
|
@@ -3025,6 +3335,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3025
3335
|
const shown = permalink || `${channelId}:${ts}`;
|
|
3026
3336
|
console.error(`โ Asked: ${shown}`);
|
|
3027
3337
|
attr.record({ team: self?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
|
|
3338
|
+
// The replacement is up; now retire what it replaces. A failure here leaves
|
|
3339
|
+
// two open questions, which is reported loudly โ never a missing one.
|
|
3340
|
+
if (args.voidAfter) {
|
|
3341
|
+
const ok = await askApplyVoid(token, args.voidAfter, args.voidAfter.reason, shown, cookie);
|
|
3342
|
+
if (!ok) console.error(` โ ๆฐใใ่ณชๅใฏๅบใพใใใใๅคใ่ณชๅใ็กๅนๅใงใใพใใใงใใ โ ๆใง: slack ask --void='${stripTerminalControls(args.voidAfter.link)}' --superseded-by='${shown}'`);
|
|
3343
|
+
}
|
|
3028
3344
|
|
|
3029
3345
|
if (!args.wait) {
|
|
3030
3346
|
// Not waiting. stdout is the RESUME COMMAND, not just a link: nothing else
|
|
@@ -3046,6 +3362,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3046
3362
|
reactable,
|
|
3047
3363
|
overflow,
|
|
3048
3364
|
threadOnly,
|
|
3365
|
+
lang,
|
|
3049
3366
|
audience,
|
|
3050
3367
|
broadcast,
|
|
3051
3368
|
askerUserId: self?.userId ?? "",
|
|
@@ -3053,6 +3370,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3053
3370
|
timeout,
|
|
3054
3371
|
shown,
|
|
3055
3372
|
asBot: !!args.asBot,
|
|
3373
|
+
json: !!args.json,
|
|
3056
3374
|
};
|
|
3057
3375
|
if (threadTs) ctx.threadParentTs = threadTs;
|
|
3058
3376
|
if (cookie) ctx.cookie = cookie;
|
|
@@ -3061,6 +3379,423 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3061
3379
|
|
|
3062
3380
|
|
|
3063
3381
|
|
|
3382
|
+
/** Say on stderr that a question is void, and where its replacement is. */
|
|
3383
|
+
function askReportVoid(p: { reason: string; supersededBy?: string }, shown: string): void {
|
|
3384
|
+
console.error(`ใใฎ่ณชๅใฏ็กๅนใงใ โ ๅ็ญใใใใใจใฏใใใพใใใๅพ
ใคใฎใใใใฆใใ ใใ: ${stripTerminalControls(shown)}`);
|
|
3385
|
+
if (p.reason) console.error(` ${stripTerminalControls(p.reason)}`);
|
|
3386
|
+
if (p.supersededBy) console.error(` ๆฐใใ่ณชๅ: ${stripTerminalControls(p.supersededBy)}`);
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
/** Find the one message a permalink (or `Cโฆ:ts`) names. A question posted into
|
|
3390
|
+
* a thread is not in history at all, so where the link says it is a reply, it
|
|
3391
|
+
* is read from the thread. */
|
|
3392
|
+
async function askFetchByLink(token: string, link: string, cookie?: string): Promise<
|
|
3393
|
+
{ channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> } | null
|
|
3394
|
+
> {
|
|
3395
|
+
const url = parseSlackPermalink(link);
|
|
3396
|
+
let channelId: string;
|
|
3397
|
+
let ts: string;
|
|
3398
|
+
let threadTs: string | undefined;
|
|
3399
|
+
if (url?.ts) {
|
|
3400
|
+
channelId = url.channel;
|
|
3401
|
+
ts = url.ts;
|
|
3402
|
+
threadTs = url.threadTs && url.threadTs !== url.ts ? url.threadTs : undefined;
|
|
3403
|
+
} else {
|
|
3404
|
+
const m = link.match(/^([A-Za-z0-9]+):(\d{10}\.\d{6})$/);
|
|
3405
|
+
if (!m) return null;
|
|
3406
|
+
channelId = m[1]!;
|
|
3407
|
+
ts = m[2]!;
|
|
3408
|
+
}
|
|
3409
|
+
let msg: Record<string, Json> | undefined;
|
|
3410
|
+
if (threadTs) {
|
|
3411
|
+
const rep = asRecord((await replies(token, channelId, threadTs, 100, cookie)) as Json);
|
|
3412
|
+
msg = asArray(rep.messages).map(asRecord).find((m) => m.ts === ts);
|
|
3413
|
+
} else {
|
|
3414
|
+
const hist = asRecord((await history(token, channelId, 1, ts, undefined, cookie, true)) as Json);
|
|
3415
|
+
msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
|
|
3416
|
+
}
|
|
3417
|
+
const out: { channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> } = { channelId, ts };
|
|
3418
|
+
if (threadTs) out.threadTs = threadTs;
|
|
3419
|
+
if (msg) out.msg = msg;
|
|
3420
|
+
return out;
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
/** Look up a question for a lifecycle action (edit / void / reask): it must
|
|
3424
|
+
* exist, be OUR OWN post (Slack lets only the author edit, and changing
|
|
3425
|
+
* someone else's decision is not a thing this should make possible), and be
|
|
3426
|
+
* open. Says why on stderr and returns null otherwise. */
|
|
3427
|
+
async function askLoadOwnOpen(token: string, link: string, self: Self | null, asBot: boolean, cookie?: string):
|
|
3428
|
+
Promise<(AskVoidTarget & { msg: Record<string, Json>; threadTs?: string }) | "void" | null> {
|
|
3429
|
+
const shown = stripTerminalControls(link);
|
|
3430
|
+
const got = await askFetchByLink(token, link, cookie);
|
|
3431
|
+
if (!got) { console.error(`โ ${shown}: permalink (ใพใใฏ 'Cโฆ:1700000000.000100') ใงใฏใใใพใใ`); return null; }
|
|
3432
|
+
if (!got.msg) { console.error(`โ ${shown}: ใกใใปใผใธใ่ฆใคใใใพใใ`); return null; }
|
|
3433
|
+
const msg = got.msg;
|
|
3434
|
+
const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
|
|
3435
|
+
if (!own) {
|
|
3436
|
+
console.error(`โ ${shown}: ่ชๅ๏ผ${senderLabel(self, asBot)}๏ผใๆ็จฟใใ่ณชๅใงใฏใใใพใใ` +
|
|
3437
|
+
`${asBot ? "" : " โ bot ใๅบใใ่ณชๅใชใ --as-bot ใง"}`);
|
|
3438
|
+
return null;
|
|
3439
|
+
}
|
|
3440
|
+
const text = typeof msg.text === "string" ? msg.text : "";
|
|
3441
|
+
const parsed = askParseMessage(text);
|
|
3442
|
+
if (parsed.kind === "void") return "void";
|
|
3443
|
+
if (parsed.kind === "resolved") { console.error(`โ ${shown}: ๅ็ญๆธใฟใฎ่ณชๅใงใ๏ผๅ็ญ: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")}๏ผโ ๅคๆดใงใใพใใ`); return null; }
|
|
3444
|
+
if (parsed.kind !== "open") { console.error(`โ ${shown}: \`slack ask\` ใฎ่ณชๅใจใใฆ่ชญใฟๅใใพใใ โ ${askExplainReject(text)}`); return null; }
|
|
3445
|
+
const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, msg };
|
|
3446
|
+
if (got.threadTs) out.threadTs = got.threadTs;
|
|
3447
|
+
return out;
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
/** Has anyone but us answered, or started to? A pressed keycap, a reply in the
|
|
3451
|
+
* thread, or (in a 1:1 DM) a plain message after the question. Used to lock the
|
|
3452
|
+
* OPTIONS: after a vote, renumbering them would make the reactions already
|
|
3453
|
+
* given silently mean something else. */
|
|
3454
|
+
async function askHasResponses(token: string, t: { channelId: string; ts: string; msg: Record<string, Json> }, self: Self | null, cookie?: string): Promise<boolean> {
|
|
3455
|
+
const notSelf = (u: unknown) => typeof u === "string" && u !== self?.userId;
|
|
3456
|
+
const pressed = asArray(t.msg.reactions).map(asRecord)
|
|
3457
|
+
.some((r) => ASK_KEYCAPS.some((k) => k.name === r.name) && asArray(r.users).some(notSelf));
|
|
3458
|
+
if (pressed) return true;
|
|
3459
|
+
// A thread counts only if someone OTHER than us is in it โ our own
|
|
3460
|
+
// follow-up note must not lock the options.
|
|
3461
|
+
if (Number(t.msg.reply_count) > 0) {
|
|
3462
|
+
const rep = asRecord((await replies(token, t.channelId, t.ts, 100, cookie)) as Json);
|
|
3463
|
+
if (asArray(rep.messages).map(asRecord).some((m) => Number(m.ts) > Number(t.ts) && notSelf(m.user) && !m.bot_id)) return true;
|
|
3464
|
+
}
|
|
3465
|
+
if (t.channelId.startsWith("D")) {
|
|
3466
|
+
const hist = asRecord((await history(token, t.channelId, 30, t.ts, undefined, cookie, true)) as Json);
|
|
3467
|
+
if (asArray(hist.messages).map(asRecord).some((m) => Number(m.ts) > Number(t.ts) && notSelf(m.user) && !m.bot_id)) return true;
|
|
3468
|
+
}
|
|
3469
|
+
return false;
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
/** `slack ask --edit=<link> ["new question" [choicesโฆ]] [--body โฆ]` โ change an
|
|
3473
|
+
* open question THROUGH the ask builder, so pills, โ, the instructions and the
|
|
3474
|
+
* body stay in the layout collect reads back (a free-hand `slack edit` is what
|
|
3475
|
+
* this replaces; `edit` now refuses ask messages without --force).
|
|
3476
|
+
*
|
|
3477
|
+
* The wording (question, body) can change any time. The OPTIONS can change only
|
|
3478
|
+
* while nobody has answered: after a vote the old reactions would silently mean
|
|
3479
|
+
* the new options. Then it refuses and points at `--void=<link> --reask`, which
|
|
3480
|
+
* retires the question and asks the new one. Answered / void: refused. */
|
|
3481
|
+
async function cmdAskEdit(token: string, args: {
|
|
3482
|
+
link: string; question?: string; body?: string; choices?: string[]; lang?: string;
|
|
3483
|
+
code?: string; asBot: boolean; cookie?: string; mentionToken: string; mentionCookie?: string;
|
|
3484
|
+
allowUrlAdjacent?: boolean;
|
|
3485
|
+
}): Promise<void> {
|
|
3486
|
+
const self = await selfIdentity(token, args.cookie);
|
|
3487
|
+
const t = await askLoadOwnOpen(token, args.link, self, args.asBot, args.cookie);
|
|
3488
|
+
if (t === "void") { console.error(`โ ${stripTerminalControls(args.link)}: ็กๅนๅๆธใฟใฎ่ณชๅใฏ็ทจ้ใงใใพใใ`); process.exit(ASK_EXIT_ERROR); }
|
|
3489
|
+
if (!t) process.exit(ASK_EXIT_ERROR);
|
|
3490
|
+
const old = t.parsed;
|
|
3491
|
+
const encode = async (raw: string): Promise<string> => {
|
|
3492
|
+
const enc = askEncodeBroadcasts(unescapeArg(raw));
|
|
3493
|
+
return (await encodeMentionsDetailed(args.mentionToken, enc.text, t.channelId, args.mentionCookie)).text;
|
|
3494
|
+
};
|
|
3495
|
+
const question = args.question !== undefined ? await encode(args.question) : old.question;
|
|
3496
|
+
const body = args.body !== undefined ? await encode(args.body) : old.body;
|
|
3497
|
+
const oldChoices = [...old.reactable, ...old.overflow];
|
|
3498
|
+
const choices = args.choices ? args.choices.map(unescapeArg) : oldChoices;
|
|
3499
|
+
const choicesChanged = JSON.stringify(choices.map(askFlatten)) !== JSON.stringify(oldChoices.map(askFlatten));
|
|
3500
|
+
if (choicesChanged && await askHasResponses(token, t, self, args.cookie)) {
|
|
3501
|
+
console.error(
|
|
3502
|
+
`โ ใใฎ่ณชๅใซใฏใใๅ็ญ๏ผใชใขใฏใทใงใณใ่ฟไฟก๏ผใใใใพใ โ ้ธๆ่ขใๅคใใใจใๆผใใใใชใขใฏใทใงใณใฎๆๅณใๅคใใฃใฆใใพใใพใใ\n` +
|
|
3503
|
+
` ่ณชๅๆใจๆฌๆใฏๅคใใใใพใใ้ธๆ่ขใๅคใใใชใ็กๅนๅใใฆๅบใ็ดใใฆใใ ใใ:\n` +
|
|
3504
|
+
` slack ask --void='${stripTerminalControls(args.link)}' --reask${args.asBot ? " --as-bot" : ""} โฆ`,
|
|
3505
|
+
);
|
|
3506
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3507
|
+
}
|
|
3508
|
+
// Still addressed to someone: the tags are who may answer, and an edit must
|
|
3509
|
+
// not leave a question nobody may answer (a 1:1 DM needs none).
|
|
3510
|
+
// Tagging yourself grants nothing โ same rule as when asking.
|
|
3511
|
+
const tagged = [...`${question}\n${body}`.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].some((m) => m[1] !== self?.userId);
|
|
3512
|
+
const addressed = tagged || /<!(?:here|channel|everyone)/.test(`${question}\n${body}`) || t.channelId.startsWith("D");
|
|
3513
|
+
if (!addressed) {
|
|
3514
|
+
console.error(`โ ่ชฐใซใๅฎใฆใใใฆใใชใ่ณชๅใซใชใใพใ โ ๅ็ญใงใใไบบใ @ ใงๆฎใใฆใใ ใใ`);
|
|
3515
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3516
|
+
}
|
|
3517
|
+
const lang = args.lang ? askResolveLang({ flag: args.lang })?.lang ?? old.lang : old.lang;
|
|
3518
|
+
const reactable = choices.slice(0, ASK_MAX_REACTION_CHOICES);
|
|
3519
|
+
const overflow = choices.slice(ASK_MAX_REACTION_CHOICES);
|
|
3520
|
+
const oldText = typeof t.msg.text === "string" ? t.msg.text : "";
|
|
3521
|
+
let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang);
|
|
3522
|
+
// An invalid-ballot notice still in force stays (it names the people who
|
|
3523
|
+
// have to fix their vote); it is only possible when the options did not change.
|
|
3524
|
+
const invalid = readInvalidNotice(oldText);
|
|
3525
|
+
if (invalid.length) next = applyInvalidNotice(next, invalid, lang);
|
|
3526
|
+
guardUrlBoundaries(next, args.allowUrlAdjacent);
|
|
3527
|
+
if (next === oldText) {
|
|
3528
|
+
console.error("ๅคๆดใฏใใใพใใใ");
|
|
3529
|
+
return;
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
const code = safetyCode("ask-edit", `${t.channelId}:${t.ts}`, next, self?.userId ?? "");
|
|
3533
|
+
if (args.code !== code) {
|
|
3534
|
+
const show = (label: string, a: string, b: string) => a === b
|
|
3535
|
+
? [` ${label}: (unchanged)`]
|
|
3536
|
+
: [` ${label}:`, ...a.split("\n").map((l) => ` - ${l}`), ...b.split("\n").map((l) => ` + ${l}`)];
|
|
3537
|
+
requireCode(args.code, code, [
|
|
3538
|
+
`--- Editing question --------------------------`,
|
|
3539
|
+
fromLine(self, { asBot: args.asBot }),
|
|
3540
|
+
` ${stripTerminalControls(args.link)}`,
|
|
3541
|
+
...show("Question", old.question, question),
|
|
3542
|
+
...show("Body", old.body, body),
|
|
3543
|
+
...(choicesChanged
|
|
3544
|
+
? [` Choices (nobody has answered yet โ pills are re-seeded):`,
|
|
3545
|
+
...oldChoices.map((c, i) => ` - ${i + 1}. ${askFlatten(c)}`),
|
|
3546
|
+
...choices.map((c, i) => ` + ${i + 1}. ${askFlatten(c)}`)]
|
|
3547
|
+
: [` Choices: (unchanged)`]),
|
|
3548
|
+
...(lang !== old.lang ? [` Language: ${old.lang} โ ${lang} (the instructions are rewritten)`] : []),
|
|
3549
|
+
`----------------------------------------------`,
|
|
3550
|
+
]);
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
await editMessage(token, t.channelId, t.ts, next, args.cookie, true);
|
|
3554
|
+
if (choicesChanged) {
|
|
3555
|
+
// Re-seed in order: 1..n then โ last, as a fresh question has them.
|
|
3556
|
+
for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
|
|
3557
|
+
try { await reactionRemove(token, t.channelId, t.ts, name, args.cookie); } catch { /* already gone */ }
|
|
3558
|
+
}
|
|
3559
|
+
await seedReactionsInOrder(
|
|
3560
|
+
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
|
|
3561
|
+
(name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
|
|
3562
|
+
(name, e) => console.error(` (ใชใขใฏใทใงใณ ${name} ใไปใใใใพใใใงใใ: ${e instanceof Error ? e.message : String(e)})`),
|
|
3563
|
+
);
|
|
3564
|
+
}
|
|
3565
|
+
console.error(`โ ็ทจ้ใใพใใ: ${stripTerminalControls(args.link)}`);
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
/** `slack ask --ls [--stale <dur>] [--state โฆ] [--channel โฆ] [--json]` โ the
|
|
3569
|
+
* questions I posted, with their LIVE state.
|
|
3570
|
+
*
|
|
3571
|
+
* The list comes from the local sent-log (every `ask` is recorded there with
|
|
3572
|
+
* its channel, ts and identity, user or bot); the state comes from Slack, one
|
|
3573
|
+
* read per question, so a question answered, voided or edited elsewhere shows
|
|
3574
|
+
* as it is now. Limitation: only asks sent from THIS machine's log.
|
|
3575
|
+
*
|
|
3576
|
+
* `--stale 24h` = open and older than that โ the morning triage's list. It
|
|
3577
|
+
* prints, on stderr, the one `--void=` command that would retire them all
|
|
3578
|
+
* (behind the usual gate), rather than voiding anything itself. */
|
|
3579
|
+
async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }, args: {
|
|
3580
|
+
stale?: string; state: string; channel?: string; json: boolean; limit: number;
|
|
3581
|
+
}): Promise<void> {
|
|
3582
|
+
const q: Parameters<typeof querySent>[0] = { kind: "ask", limit: args.limit };
|
|
3583
|
+
if (args.channel) q.channel = args.channel;
|
|
3584
|
+
const rows = querySent(q);
|
|
3585
|
+
if (!rows) {
|
|
3586
|
+
console.error(`Error: ${SENT_LOG_UNAVAILABLE}\n (${sentDbPath()})`);
|
|
3587
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3588
|
+
}
|
|
3589
|
+
let staleBefore: number | undefined;
|
|
3590
|
+
if (args.stale) {
|
|
3591
|
+
staleBefore = parseSince(args.stale);
|
|
3592
|
+
if (staleBefore === undefined) {
|
|
3593
|
+
console.error(`Error: --stale needs a duration like 24h / 3d (got ${stripTerminalControls(args.stale)})`);
|
|
3594
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
type Item = { state: string; age_h: number; asBot: boolean; channel: string; target: string; link: string; question: string; answer?: string };
|
|
3598
|
+
const items: Item[] = [];
|
|
3599
|
+
for (const r of rows) {
|
|
3600
|
+
if (staleBefore !== undefined && r.sent_at > staleBefore) continue;
|
|
3601
|
+
const token = r.as_bot ? tokens.bot : tokens.user;
|
|
3602
|
+
const link = r.permalink || `${r.channel}:${r.ts}`;
|
|
3603
|
+
let state = "unknown";
|
|
3604
|
+
let question = (r.text.split("\n")[0] ?? "").replace(/^:question: /, "");
|
|
3605
|
+
let answer: string | undefined;
|
|
3606
|
+
if (token) {
|
|
3607
|
+
try {
|
|
3608
|
+
const got = await askFetchByLink(token, `${r.channel}:${r.ts}`, r.as_bot ? undefined : tokens.cookie);
|
|
3609
|
+
if (!got?.msg) state = "gone";
|
|
3610
|
+
else {
|
|
3611
|
+
const p = askParseMessage(typeof got.msg.text === "string" ? got.msg.text : "");
|
|
3612
|
+
state = p.kind === "resolved" ? "answered" : p.kind === "other" ? "edited" : p.kind;
|
|
3613
|
+
if (p.kind !== "other" && p.question) question = p.question;
|
|
3614
|
+
if (p.kind === "resolved") answer = p.answer;
|
|
3615
|
+
}
|
|
3616
|
+
} catch {
|
|
3617
|
+
state = "unknown";
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
if (args.state !== "all" && state !== args.state) continue;
|
|
3621
|
+
const item: Item = {
|
|
3622
|
+
state, age_h: Math.round((Date.now() - r.sent_at) / 3600e3), asBot: !!r.as_bot,
|
|
3623
|
+
channel: r.channel, target: r.target ?? r.channel, link, question: askFlatten(question),
|
|
3624
|
+
};
|
|
3625
|
+
if (answer !== undefined) item.answer = answer;
|
|
3626
|
+
items.push(item);
|
|
3627
|
+
}
|
|
3628
|
+
if (args.json) {
|
|
3629
|
+
console.log(JSON.stringify(items));
|
|
3630
|
+
} else {
|
|
3631
|
+
for (const it of items) {
|
|
3632
|
+
console.log(`${it.state.padEnd(8)} ${String(it.age_h).padStart(4)}h ${it.asBot ? "bot " : "user"} ${stripTerminalControls(it.target)} ${it.link}\n ${stripTerminalControls(it.question).slice(0, 100)}`);
|
|
3633
|
+
}
|
|
3634
|
+
console.error(`${items.length} question(s)${args.state === "all" ? "" : ` in state "${args.state}"`}${args.stale ? `, older than ${args.stale}` : ""}.`);
|
|
3635
|
+
}
|
|
3636
|
+
// The bulk path: one gated command per identity, never an automatic void.
|
|
3637
|
+
const open = items.filter((it) => it.state === "open");
|
|
3638
|
+
if (args.stale && open.length) {
|
|
3639
|
+
for (const bot of [false, true]) {
|
|
3640
|
+
const mine = open.filter((it) => it.asBot === bot);
|
|
3641
|
+
if (!mine.length) continue;
|
|
3642
|
+
console.error(`็กๅนๅใใใชใ (${mine.length} ไปถ, ${bot ? "bot" : "user"}):\n slack ask ${mine.map((it) => `--void='${it.link}'`).join(" ")} --reason 'stale'${bot ? " --as-bot" : ""}`);
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
|
|
3647
|
+
/** A question about to be voided: where it is, and what it parsed as. */
|
|
3648
|
+
interface AskVoidTarget {
|
|
3649
|
+
link: string;
|
|
3650
|
+
channelId: string;
|
|
3651
|
+
ts: string;
|
|
3652
|
+
parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>;
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
/** Void one question: the ๐ซ text (body kept), our seeds and โ off, ๐ซ on.
|
|
3656
|
+
* The text is what marks it void; the reactions are cleanup + searchability,
|
|
3657
|
+
* so a failure there is reported and does not undo the void. */
|
|
3658
|
+
async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, supersededBy: string | undefined, cookie?: string): Promise<boolean> {
|
|
3659
|
+
const shown = stripTerminalControls(t.link);
|
|
3660
|
+
try {
|
|
3661
|
+
const next = askBuildVoidText(t.parsed.question, reason, t.parsed.lang, t.parsed.body || undefined, supersededBy);
|
|
3662
|
+
await editMessage(token, t.channelId, t.ts, next, cookie, true);
|
|
3663
|
+
} catch (e: unknown) {
|
|
3664
|
+
console.error(`โ ${shown}: ๆธใๆใใซๅคฑๆใใพใใ: ${e instanceof Error ? e.message : String(e)}`);
|
|
3665
|
+
return false;
|
|
3666
|
+
}
|
|
3667
|
+
for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
|
|
3668
|
+
try { await reactionRemove(token, t.channelId, t.ts, name, cookie); } catch { /* not ours / already gone */ }
|
|
3669
|
+
}
|
|
3670
|
+
try {
|
|
3671
|
+
await reactionAdd(token, t.channelId, t.ts, ASK_VOID_MARKER, cookie);
|
|
3672
|
+
} catch (e: unknown) {
|
|
3673
|
+
console.error(` (${shown}: ๐ซ ใชใขใฏใทใงใณใไปใใใใพใใใงใใ: ${e instanceof Error ? e.message : String(e)})`);
|
|
3674
|
+
}
|
|
3675
|
+
console.error(`โ ็กๅนๅ: ${shown}`);
|
|
3676
|
+
return true;
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
/** `slack ask --void <permalink>โฆ` โ ็กๅนๅ: mark questions that have expired or
|
|
3680
|
+
* stopped meaning anything, so nobody answers them and nothing waits on them.
|
|
3681
|
+
*
|
|
3682
|
+
* Case, 2026-10-05: a release ask was pinned to one head SHA; three minutes
|
|
3683
|
+
* later main moved and approving it would have approved a different head. The
|
|
3684
|
+
* bot had to void it by hand (prefix the text, leave the pills), and nothing
|
|
3685
|
+
* machine-readable said so: `--waitFor` still treated it as open, the
|
|
3686
|
+
* stale-pill reminder still counted it, the pills were still pressable.
|
|
3687
|
+
*
|
|
3688
|
+
* What voiding does, per question: the text becomes the ๐ซ form (head, void
|
|
3689
|
+
* stamp with the reason, optional pointer to the replacement, the body); our
|
|
3690
|
+
* seeded pills and the โ marker come off; a ๐ซ reaction goes on. Pills someone
|
|
3691
|
+
* already pressed stay โ Slack lets only their owner remove them โ and are
|
|
3692
|
+
* never collected, because a void is never polled again.
|
|
3693
|
+
*
|
|
3694
|
+
* Only your OWN questions: Slack lets only the author edit a message, and
|
|
3695
|
+
* voiding someone else's decision is not a thing this should make possible.
|
|
3696
|
+
* An answered question is final and is refused; an already-void one is a
|
|
3697
|
+
* no-op. Several links at once โ the morning triage found 38 stale pills โ
|
|
3698
|
+
* all behind ONE confirm code covering every target and the reason. */
|
|
3699
|
+
async function cmdAskVoid(token: string, args: { links: string[]; reason: string; supersededBy?: string; code?: string; asBot: boolean; cookie?: string }): Promise<void> {
|
|
3700
|
+
const self = await selfIdentity(token, args.cookie);
|
|
3701
|
+
// The replacement link is written into every voided question for good, so it
|
|
3702
|
+
// must name a real message before anything is touched โ a typo would point
|
|
3703
|
+
// people at nothing, permanently.
|
|
3704
|
+
if (args.supersededBy) {
|
|
3705
|
+
const shown = stripTerminalControls(args.supersededBy);
|
|
3706
|
+
let found = false;
|
|
3707
|
+
try {
|
|
3708
|
+
const rep = await askFetchByLink(token, args.supersededBy, args.cookie);
|
|
3709
|
+
found = !!rep?.msg;
|
|
3710
|
+
if (!rep) {
|
|
3711
|
+
console.error(`Error: --superseded-by must be a message permalink (or 'Cโฆ:1700000000.000100'): ${shown}`);
|
|
3712
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3713
|
+
}
|
|
3714
|
+
} catch (e: unknown) {
|
|
3715
|
+
console.error(`Error: --superseded-by ใ็ขบ่ชใงใใพใใ: ${shown} (${e instanceof Error ? e.message : String(e)})`);
|
|
3716
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3717
|
+
}
|
|
3718
|
+
if (!found) {
|
|
3719
|
+
console.error(`Error: --superseded-by ใฎใกใใปใผใธใ่ฆใคใใใพใใ: ${shown}`);
|
|
3720
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; pressed: string[]; unreadable?: boolean };
|
|
3724
|
+
const targets: Target[] = [];
|
|
3725
|
+
let refused = 0;
|
|
3726
|
+
for (const link of args.links) {
|
|
3727
|
+
const shown = stripTerminalControls(link);
|
|
3728
|
+
const got = await askFetchByLink(token, link, args.cookie);
|
|
3729
|
+
if (!got) { console.error(`โ ${shown}: permalink (ใพใใฏ 'Cโฆ:1700000000.000100') ใงใฏใใใพใใ`); refused++; continue; }
|
|
3730
|
+
if (!got.msg) { console.error(`โ ${shown}: ใกใใปใผใธใ่ฆใคใใใพใใ`); refused++; continue; }
|
|
3731
|
+
const msg = got.msg;
|
|
3732
|
+
const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
|
|
3733
|
+
if (!own) {
|
|
3734
|
+
console.error(`โ ${shown}: ่ชๅ๏ผ${senderLabel(self, args.asBot)}๏ผใๆ็จฟใใ่ณชๅใงใฏใชใใฎใง็กๅนๅใงใใพใใ` +
|
|
3735
|
+
`${args.asBot ? "" : " โ bot ใๅบใใ่ณชๅใชใ --as-bot ใง"}`);
|
|
3736
|
+
refused++;
|
|
3737
|
+
continue;
|
|
3738
|
+
}
|
|
3739
|
+
const text = typeof msg.text === "string" ? msg.text : "";
|
|
3740
|
+
let parsed = askParseMessage(text);
|
|
3741
|
+
let unreadable = false;
|
|
3742
|
+
if (parsed.kind === "void") { console.error(`- ${shown}: ใใงใซ็กๅนๅๆธใฟใงใ๏ผไฝใใใพใใ๏ผ`); continue; }
|
|
3743
|
+
if (parsed.kind === "resolved") { console.error(`โ ${shown}: ๅ็ญๆธใฟใฎ่ณชๅใฏ็กๅนๅใงใใพใใ๏ผๅ็ญ: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")}๏ผ`); refused++; continue; }
|
|
3744
|
+
if (parsed.kind !== "open") {
|
|
3745
|
+
// Our own โ question whose layout no longer parses (2026-10-01: a
|
|
3746
|
+
// `slack edit` flattened one; it could then be neither collected nor
|
|
3747
|
+
// voided, and stayed โ forever). Voiding is exactly what is left to do
|
|
3748
|
+
// with it, so it is voided from a best-effort read โ question and body
|
|
3749
|
+
// kept as they are, our own seeded pills removed โ and the gate says so.
|
|
3750
|
+
const salvage = askSalvageUnreadable(text);
|
|
3751
|
+
if (!salvage) { console.error(`โ ${shown}: \`slack ask\` ใฎ่ณชๅใจใใฆ่ชญใฟๅใใพใใ โ ${askExplainReject(text)}`); refused++; continue; }
|
|
3752
|
+
const seeded = asArray(msg.reactions).map(asRecord)
|
|
3753
|
+
.filter((r) => asArray(r.users).includes(self?.userId ?? "\u0000"))
|
|
3754
|
+
.map((r) => ASK_KEYCAPS.findIndex((k) => k.name === r.name))
|
|
3755
|
+
.filter((i) => i >= 0);
|
|
3756
|
+
parsed = {
|
|
3757
|
+
kind: "open", question: salvage.question, body: salvage.body,
|
|
3758
|
+
// Placeholders: only the COUNT is used โ which of our keycap seeds to take off.
|
|
3759
|
+
reactable: Array.from({ length: seeded.length ? Math.max(...seeded) + 1 : 0 }, () => ""),
|
|
3760
|
+
overflow: [], threadOnly: true, lang: askDetectLang(text) ?? "ja",
|
|
3761
|
+
};
|
|
3762
|
+
unreadable = true;
|
|
3763
|
+
}
|
|
3764
|
+
const pressed = asArray(msg.reactions).map(asRecord)
|
|
3765
|
+
.filter((r) => typeof r.name === "string" && ASK_KEYCAPS.some((k) => k.name === r.name))
|
|
3766
|
+
.filter((r) => asArray(r.users).some((u) => u !== self?.userId))
|
|
3767
|
+
.map((r) => `:${String(r.name)}:`);
|
|
3768
|
+
targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, pressed, ...(unreadable ? { unreadable } : {}) });
|
|
3769
|
+
}
|
|
3770
|
+
if (!targets.length) {
|
|
3771
|
+
console.error(refused ? "็กๅนๅใงใใ่ณชๅใใใใพใใใ" : "ไฝใใใใใจใใใใพใใใ");
|
|
3772
|
+
process.exit(refused ? ASK_EXIT_ERROR : 0);
|
|
3773
|
+
}
|
|
3774
|
+
|
|
3775
|
+
const code = safetyCode("ask-void", ...targets.map((t) => `${t.channelId}:${t.ts}`), args.reason, args.supersededBy ?? "", self?.userId ?? "");
|
|
3776
|
+
if (args.code !== code) {
|
|
3777
|
+
requireCode(args.code, code, [
|
|
3778
|
+
`--- ็กๅนๅ (void) -------------------------------`,
|
|
3779
|
+
fromLine(self, { asBot: args.asBot }),
|
|
3780
|
+
` Reason: ${args.reason ? stripTerminalControls(args.reason) : "(none)"}`,
|
|
3781
|
+
...(args.supersededBy ? [` Replaced: ${stripTerminalControls(args.supersededBy)}`] : []),
|
|
3782
|
+
` ${targets.length} question(s):`,
|
|
3783
|
+
...targets.map((t) => ` โข ${stripTerminalControls(t.link)}\n ${stripTerminalControls(t.parsed.question.split("\n")[0] ?? "").slice(0, 100)}` +
|
|
3784
|
+
(t.pressed.length ? `\n (already pressed: ${t.pressed.join(" ")} โ left in place, never collected)` : "") +
|
|
3785
|
+
(t.unreadable ? `\n โ its layout no longer reads as an ask (edited by hand?) โ voided from a best-effort read: question and the rest of the text kept as they are` : "")),
|
|
3786
|
+
` Each becomes ๐ซ (body kept, options and instructions removed); our pills and โ come off; a ๐ซ reaction is added.`,
|
|
3787
|
+
...(refused ? [` (${refused} link(s) above were refused and will be skipped)`] : []),
|
|
3788
|
+
`----------------------------------------------`,
|
|
3789
|
+
]);
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3792
|
+
let failed = 0;
|
|
3793
|
+
for (const t of targets) {
|
|
3794
|
+
if (!(await askApplyVoid(token, t, args.reason, args.supersededBy, args.cookie))) failed++;
|
|
3795
|
+
}
|
|
3796
|
+
process.exit(failed || refused ? ASK_EXIT_ERROR : 0);
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3064
3799
|
/** `slack ask --waitFor <permalink>` โ collect the answer to a question that was
|
|
3065
3800
|
* posted without blocking.
|
|
3066
3801
|
*
|
|
@@ -3069,7 +3804,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3069
3804
|
* file to go stale, lose, or disagree with Slack, and any machine holding the
|
|
3070
3805
|
* permalink can collect โ at the cost of the body being a parseable format
|
|
3071
3806
|
* (`askBuildText` โ `askParseMessage`). */
|
|
3072
|
-
async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; cookie?: string; after?: string }): Promise<void> {
|
|
3807
|
+
async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string }): Promise<void> {
|
|
3073
3808
|
const url = parseSlackPermalink(args.link);
|
|
3074
3809
|
let channelId: string;
|
|
3075
3810
|
let ts: string;
|
|
@@ -3121,13 +3856,6 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3121
3856
|
);
|
|
3122
3857
|
process.exit(ASK_EXIT_ERROR);
|
|
3123
3858
|
}
|
|
3124
|
-
if (parsed.kind === "resolved") {
|
|
3125
|
-
// Answered while nobody was watching โ the case that makes fire-and-forget
|
|
3126
|
-
// safe. Report it exactly as `--wait` would have.
|
|
3127
|
-
console.error(`โ ๅ็ญๆธใฟ: ${stripTerminalControls(parsed.question)}`);
|
|
3128
|
-
console.log(parsed.answer);
|
|
3129
|
-
return;
|
|
3130
|
-
}
|
|
3131
3859
|
|
|
3132
3860
|
// Who may answer, recovered from the tags the readers can see. They are
|
|
3133
3861
|
// already `<@Uโฆ>`-encoded in the stored text, so no directory lookup is
|
|
@@ -3146,6 +3874,64 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3146
3874
|
const counterpart = await imCounterpart(token, channelId, args.cookie);
|
|
3147
3875
|
if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
|
|
3148
3876
|
}
|
|
3877
|
+
if (parsed.kind === "void") {
|
|
3878
|
+
askReportVoid(parsed, args.link);
|
|
3879
|
+
if (args.json) {
|
|
3880
|
+
await askEmit(token, { json: true, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
|
|
3881
|
+
status: "void", answer: null, reason: parsed.reason,
|
|
3882
|
+
...(parsed.supersededBy ? { supersededBy: parsed.supersededBy } : {}),
|
|
3883
|
+
notes: [], cursor: askNextCursor(ts, args.after),
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3886
|
+
process.exit(ASK_EXIT_VOID);
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
if (parsed.kind === "resolved") {
|
|
3890
|
+
// Answered while nobody was watching โ the case that makes fire-and-forget
|
|
3891
|
+
// safe. Report it exactly as `--wait` would have, plus any notes the
|
|
3892
|
+
// audience left in the thread AFTER it was answered: that is the note that
|
|
3893
|
+
// was lost in real use (2026-10-02), written 40 s after โ
.
|
|
3894
|
+
//
|
|
3895
|
+
// There is NO default cursor here. The โ
edit time looks like one, but it
|
|
3896
|
+
// would drop every note written before โ
whenever the run that wrote โ
did
|
|
3897
|
+
// not deliver them: a crash between the edit and the output, or โ the case
|
|
3898
|
+
// that matters โ any question resolved by a build that did not collect
|
|
3899
|
+
// notes at all. A caller that wants no repeats passes the cursor back.
|
|
3900
|
+
//
|
|
3901
|
+
// What must not come back is the reply that IS the answer. The โ
stamp says
|
|
3902
|
+
// whether the answer was a reply, and which number it picked: the first
|
|
3903
|
+
// audience reply before the โ
edit that reads as that answer is it.
|
|
3904
|
+
console.error(`โ ๅ็ญๆธใฟ: ${stripTerminalControls(parsed.question)}`);
|
|
3905
|
+
const editedTs = typeof asRecord(msg.edited).ts === "string" ? String(asRecord(msg.edited).ts) : undefined;
|
|
3906
|
+
const how = askResolvedHow(text);
|
|
3907
|
+
let answerSkipped = false;
|
|
3908
|
+
const isAnswerReply = (m: Record<string, Json>): boolean => {
|
|
3909
|
+
if (answerSkipped || !how.byReply) return false;
|
|
3910
|
+
if (editedTs && Number(m.ts) > Number(editedTs)) return false;
|
|
3911
|
+
const t = typeof m.text === "string" ? m.text.trim() : "";
|
|
3912
|
+
const hit = t === parsed.answer || (how.n !== undefined && (new RegExp(`^[(๏ผ]?${how.n}(?![0-9])`).test(t) || askReplyPicked(t, parsed.answer, how.n)));
|
|
3913
|
+
if (hit) answerSkipped = true;
|
|
3914
|
+
return hit;
|
|
3915
|
+
};
|
|
3916
|
+
const after = args.after;
|
|
3917
|
+
let notes: AskNote[] = [];
|
|
3918
|
+
if (audience.size || broadcast) {
|
|
3919
|
+
const isAnswerer = (user: unknown): user is string =>
|
|
3920
|
+
typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
|
|
3921
|
+
notes = await askCollectNotes(token, {
|
|
3922
|
+
channelId, ts, threadOnly: !!threadTs || !channelId.startsWith("D"),
|
|
3923
|
+
replyCount: Number(msg.reply_count) || 0, isAnswerer, askerUserId, askerBotId,
|
|
3924
|
+
...(threadTs ? { threadParentTs: threadTs } : {}),
|
|
3925
|
+
...(args.cookie ? { cookie: args.cookie } : {}),
|
|
3926
|
+
}, after, isAnswerReply);
|
|
3927
|
+
}
|
|
3928
|
+
await askEmit(token, { json: args.json, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
|
|
3929
|
+
status: "answered", answer: parsed.answer, notes,
|
|
3930
|
+
cursor: askNextCursor(ts, after, ...notes.map((n) => n.ts)),
|
|
3931
|
+
});
|
|
3932
|
+
return;
|
|
3933
|
+
}
|
|
3934
|
+
|
|
3149
3935
|
if (!audience.size && !broadcast) {
|
|
3150
3936
|
console.error(`Error: ใใฎ่ณชๅใฏ่ชฐใซใๅฎใฆใใใฆใใชใใใใๆๅนใชๅ็ญ่
ใๅคๅฎใงใใพใใ: ${stripTerminalControls(args.link)}`);
|
|
3151
3937
|
process.exit(ASK_EXIT_ERROR);
|
|
@@ -3161,6 +3947,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3161
3947
|
reactable: parsed.reactable,
|
|
3162
3948
|
overflow: parsed.overflow,
|
|
3163
3949
|
threadOnly: parsed.threadOnly,
|
|
3950
|
+
lang: parsed.lang,
|
|
3164
3951
|
audience,
|
|
3165
3952
|
broadcast,
|
|
3166
3953
|
askerUserId,
|
|
@@ -3168,6 +3955,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3168
3955
|
timeout: args.timeout,
|
|
3169
3956
|
shown: args.link,
|
|
3170
3957
|
asBot: args.asBot,
|
|
3958
|
+
json: args.json,
|
|
3171
3959
|
};
|
|
3172
3960
|
if (threadTs) ctx.threadParentTs = threadTs;
|
|
3173
3961
|
if (args.cookie) ctx.cookie = args.cookie;
|
|
@@ -3319,7 +4107,8 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
|
|
|
3319
4107
|
const attr = sentAttribution("poll");
|
|
3320
4108
|
const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
|
|
3321
4109
|
|
|
3322
|
-
// Marker first
|
|
4110
|
+
// Marker first. (`ask` seeds its marker LAST, because there it doubles as the
|
|
4111
|
+
// "other" choice; a poll has no such choice.) It is what makes
|
|
3323
4112
|
// `has::ballot_box_with_ballot:` list every poll. An unrelated reaction on the message is
|
|
3324
4113
|
// already ignored by the tally (it counts keycap names only), so this costs
|
|
3325
4114
|
// the ballot nothing.
|
|
@@ -4686,7 +5475,7 @@ async function main(): Promise<void> {
|
|
|
4686
5475
|
)
|
|
4687
5476
|
.command(
|
|
4688
5477
|
"ask [target] [question] [choices..]",
|
|
4689
|
-
"Ask a question with its choices pre-seeded as 1๏ธโฃ..๐ reactions (confirm-hash safety gate)",
|
|
5478
|
+
"Ask a question with its choices pre-seeded as 1๏ธโฃ..๐ reactions (confirm-hash safety gate). Lifecycle: --waitFor=<link> collect, --void=<link> ็กๅนๅ (verb aliases: `ask collect <link>`, `ask void <linkโฆ>`)",
|
|
4690
5479
|
(y) => y
|
|
4691
5480
|
.positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --waitFor)" })
|
|
4692
5481
|
.positional("question", { type: "string", describe: "The question. Must @tag whoever may answer (@alice), or the whole channel (@here / @channel / @everyone) โ only their answer counts. In a 1:1 DM the other party counts automatically." })
|
|
@@ -4694,14 +5483,176 @@ async function main(): Promise<void> {
|
|
|
4694
5483
|
.option("code", { type: "string", describe: "Safety hash to confirm the ask" })
|
|
4695
5484
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
4696
5485
|
.option("body", { type: "string", describe: "Extra context shown under the question" })
|
|
4697
|
-
.option("
|
|
5486
|
+
.option("lang", { type: "string", choices: [...ASK_LANGS], describe: "Language of the instructions in the posted body. Default: $SLACK_TERM_LANG, else the answerers' Slack locale, else the question's own language, else the system locale, else ja" })
|
|
5487
|
+
.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 = a reply matched several choices (stdout empty), 5 = a free-text reply that picked none of the choices (the reply is on stdout; NOT a decision โ the question stays open), 6 = the question was voided (็กๅนๅ, `slack ask void`) โ stop waiting." })
|
|
4698
5488
|
.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." })
|
|
4699
|
-
.option("after", { type: "string", describe: "With --waitFor:
|
|
5489
|
+
.option("after", { type: "string", describe: "With --waitFor: the cursor โ everything at or before this ts was already delivered. Text replies there are neither answers nor notes again (pass the `cursor` a previous --json result gave you, or the reply ts an exit 5 printed). Reactions still count." })
|
|
5490
|
+
.option("void", { type: "string", array: true, describe: "็กๅนๅ: void question(s) you posted that expired or stopped meaning anything โ --void=<permalink>, repeatable. The text becomes ๐ซ with --reason (body kept, options removed), our pills come off, and --wait/--waitFor on it exits 6. Same --code gate. Only your own questions (--as-bot for the bot's). Alias: `slack ask void <linkโฆ>`." })
|
|
5491
|
+
.option("reask", { type: "boolean", default: false, describe: "With ONE --void: post the question again (same channel/thread, same text and choices unless you give new ones as positionals / --body) and void the old one as replaced by it โ one gate for both. The way to change the options of a question someone already answered." })
|
|
5492
|
+
.option("edit", { type: "string", describe: "Change an open question you posted: --edit=<permalink> [\"new question\" [choicesโฆ]] [--body โฆ]. Rebuilt with the ask layout so collect still reads it. Options can change only while nobody has answered (then: --void=<link> --reask). Same --code gate." })
|
|
5493
|
+
.option("ls", { type: "boolean", default: false, describe: "List the questions you posted (local sent-log) with their LIVE state: --state open|answered|void|all (default open), --stale 24h, --channel, --json. With --stale it prints the --void command that would retire them." })
|
|
5494
|
+
.option("stale", { type: "string", describe: "With --ls: only open questions older than this (24h, 3d)" })
|
|
5495
|
+
.option("state", { type: "string", default: "open", choices: ["open", "answered", "void", "edited", "gone", "unknown", "all"], describe: "With --ls: which state to list" })
|
|
5496
|
+
.option("channel", { type: "string", describe: "With --ls: only this channel (id, or the target as typed)" })
|
|
5497
|
+
.option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
|
|
5498
|
+
.option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
|
|
5499
|
+
.option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
|
|
5500
|
+
.option("json", { type: "boolean", default: false, describe: "With --wait / --waitFor: print ONE JSON object on stdout โ {status, answer, how, who, notes:[{ts,user,text,permalink}], cursor, permalink} โ instead of the bare answer. `notes` are the audience's replies that are not the answer (thread replies; plain replies too in a 1:1 DM). Exit codes unchanged." })
|
|
4700
5501
|
.option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
|
|
4701
5502
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
4702
5503
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
4703
5504
|
.option("as-bot", { type: "boolean", default: false, describe: "Ask via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient" }),
|
|
4704
5505
|
async (argv) => {
|
|
5506
|
+
// Verbs. A target is always #chan / @user / a permalink / a raw ID, so a
|
|
5507
|
+
// bare word here cannot be one โ `slack ask void โฆ` is unambiguous.
|
|
5508
|
+
// slack ask void <linkโฆ> [--reason โฆ] [--superseded-by <link>] ็กๅนๅ
|
|
5509
|
+
// slack ask collect <link> = --waitFor
|
|
5510
|
+
// Lifecycle flags (maintainer: "maybe slack ask --edit=<target>"), primary;
|
|
5511
|
+
// the bare verbs `void` / `collect` stay as aliases.
|
|
5512
|
+
if (argv.ls) {
|
|
5513
|
+
const user = (() => { try { return tok(argv as W); } catch { return undefined; } })();
|
|
5514
|
+
const tokens: { user?: string; bot?: string; cookie?: string } = {};
|
|
5515
|
+
if (user) tokens.user = user;
|
|
5516
|
+
const bot = resolveBotToken();
|
|
5517
|
+
if (bot) tokens.bot = bot;
|
|
5518
|
+
const c = ck(argv as W);
|
|
5519
|
+
if (c) tokens.cookie = c;
|
|
5520
|
+
const a: { stale?: string; state: string; channel?: string; json: boolean; limit: number } = {
|
|
5521
|
+
state: String(argv.state ?? "open"), json: !!argv.json, limit: Number(argv.limit) || 50,
|
|
5522
|
+
};
|
|
5523
|
+
if (argv.stale) a.stale = String(argv.stale);
|
|
5524
|
+
if (argv.channel) a.channel = String(argv.channel);
|
|
5525
|
+
await cmdAskLs(tokens, a);
|
|
5526
|
+
return;
|
|
5527
|
+
}
|
|
5528
|
+
const askTokenFor = (asBot: boolean): { token: string; cookie?: string } => {
|
|
5529
|
+
if (!asBot) {
|
|
5530
|
+
const c = ck(argv as W);
|
|
5531
|
+
return c ? { token: tok(argv as W), cookie: c } : { token: tok(argv as W) };
|
|
5532
|
+
}
|
|
5533
|
+
const botToken = resolveBotToken();
|
|
5534
|
+
if (!botToken) {
|
|
5535
|
+
console.error(
|
|
5536
|
+
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
5537
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
5538
|
+
);
|
|
5539
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5540
|
+
}
|
|
5541
|
+
return { token: botToken };
|
|
5542
|
+
};
|
|
5543
|
+
// Positionals after a lifecycle flag are the NEW question and choices.
|
|
5544
|
+
const newQuestion = argv.target !== undefined && argv.target !== "void" && argv.target !== "collect" ? String(argv.target) : undefined;
|
|
5545
|
+
const newChoices = [argv.question, ...((argv.choices as string[] | undefined) ?? [])].filter((x) => x !== undefined).map(String);
|
|
5546
|
+
if (argv.edit) {
|
|
5547
|
+
if (argv.void || argv.wait || argv.waitFor) {
|
|
5548
|
+
console.error("Error: --edit changes one open question โ do not combine it with --void/--wait/--waitFor.");
|
|
5549
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5550
|
+
}
|
|
5551
|
+
const { token, cookie } = askTokenFor(!!argv["as-bot"]);
|
|
5552
|
+
const a: Parameters<typeof cmdAskEdit>[1] = {
|
|
5553
|
+
link: String(argv.edit), asBot: !!argv["as-bot"], mentionToken: tok(argv as W),
|
|
5554
|
+
allowUrlAdjacent: !!argv["allow-url-adjacent"],
|
|
5555
|
+
};
|
|
5556
|
+
if (newQuestion !== undefined) a.question = newQuestion;
|
|
5557
|
+
if (newChoices.length) a.choices = newChoices.map((x) => x.trim()).filter(Boolean);
|
|
5558
|
+
if (argv.body !== undefined) a.body = String(argv.body);
|
|
5559
|
+
if (argv.lang) a.lang = String(argv.lang);
|
|
5560
|
+
if (argv.code) a.code = String(argv.code);
|
|
5561
|
+
if (cookie) a.cookie = cookie;
|
|
5562
|
+
const mc = ck(argv as W);
|
|
5563
|
+
if (mc) a.mentionCookie = mc;
|
|
5564
|
+
try {
|
|
5565
|
+
await cmdAskEdit(token, a);
|
|
5566
|
+
} catch (e: unknown) {
|
|
5567
|
+
console.error(friendlySlackError(e));
|
|
5568
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5569
|
+
}
|
|
5570
|
+
return;
|
|
5571
|
+
}
|
|
5572
|
+
// --void=<link> --reask: re-ask through the CREATE path below (same
|
|
5573
|
+
// gate, same audience rules), voiding the old question once it is up.
|
|
5574
|
+
let voidAfter: AskArgs["voidAfter"];
|
|
5575
|
+
const voidFlag = ((argv.void as string[] | undefined) ?? []).map(String).filter(Boolean);
|
|
5576
|
+
if (argv.reask) {
|
|
5577
|
+
if (voidFlag.length !== 1 || argv.target === "void") {
|
|
5578
|
+
console.error("Error: --reask goes with exactly one --void=<permalink> (the question to replace).");
|
|
5579
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5580
|
+
}
|
|
5581
|
+
if (argv.waitFor) {
|
|
5582
|
+
console.error("Error: --reask posts a new question โ --wait works, --waitFor does not.");
|
|
5583
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5584
|
+
}
|
|
5585
|
+
const { token, cookie } = askTokenFor(!!argv["as-bot"]);
|
|
5586
|
+
const self = await selfIdentity(token, cookie);
|
|
5587
|
+
const t = await askLoadOwnOpen(token, voidFlag[0]!, self, !!argv["as-bot"], cookie);
|
|
5588
|
+
if (t === "void") { console.error(`โ ${stripTerminalControls(voidFlag[0]!)}: ใใงใซ็กๅนๅๆธใฟใงใ โ ๆฐใใ่ใใชใๆฎ้ใซ slack ask ใง`); process.exit(ASK_EXIT_ERROR); }
|
|
5589
|
+
if (!t) process.exit(ASK_EXIT_ERROR);
|
|
5590
|
+
voidAfter = { ...t, reason: unescapeArg(String(argv.reason ?? "")).trim() };
|
|
5591
|
+
// Same place, same words, unless new ones were given. The create path
|
|
5592
|
+
// below unescapes its arguments, so stored text goes in escaped: a
|
|
5593
|
+
// literal `\n` in the old question must not turn into a line break.
|
|
5594
|
+
argv.target = t.threadTs ? `${t.channelId}:${t.threadTs}` : t.channelId;
|
|
5595
|
+
argv["channel-id"] = t.channelId;
|
|
5596
|
+
argv.question = newQuestion ?? escapeArg(t.parsed.question);
|
|
5597
|
+
argv.choices = newChoices.length ? newChoices : [...t.parsed.reactable, ...t.parsed.overflow].map(escapeArg);
|
|
5598
|
+
if (argv.body === undefined && t.parsed.body) argv.body = escapeArg(t.parsed.body);
|
|
5599
|
+
if (!argv.lang) argv.lang = t.parsed.lang;
|
|
5600
|
+
argv.void = undefined;
|
|
5601
|
+
}
|
|
5602
|
+
if (!voidAfter && (argv.target === "void" || voidFlag.length)) {
|
|
5603
|
+
const verbLinks = argv.target === "void" ? [argv.question, ...((argv.choices as string[] | undefined) ?? [])] : [];
|
|
5604
|
+
if (argv.target !== "void" && (argv.target || argv.question || (argv.choices as string[] | undefined)?.length)) {
|
|
5605
|
+
console.error("Error: --void voids existing questions โ do not also pass a target/question/choices.");
|
|
5606
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5607
|
+
}
|
|
5608
|
+
const links = [...voidFlag, ...verbLinks.map((x) => String(x ?? ""))].filter(Boolean);
|
|
5609
|
+
if (!links.length) {
|
|
5610
|
+
console.error("Error: --void=<permalink> โ which question(s)?");
|
|
5611
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5612
|
+
}
|
|
5613
|
+
if (argv.wait || argv.waitFor) {
|
|
5614
|
+
console.error("Error: --void voids existing questions โ do not also pass --wait/--waitFor.");
|
|
5615
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5616
|
+
}
|
|
5617
|
+
let voidToken: string;
|
|
5618
|
+
let voidCookie: string | undefined;
|
|
5619
|
+
if (argv["as-bot"]) {
|
|
5620
|
+
const botToken = resolveBotToken();
|
|
5621
|
+
if (!botToken) {
|
|
5622
|
+
console.error(
|
|
5623
|
+
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
5624
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
5625
|
+
);
|
|
5626
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5627
|
+
}
|
|
5628
|
+
voidToken = botToken;
|
|
5629
|
+
} else {
|
|
5630
|
+
voidToken = tok(argv as W);
|
|
5631
|
+
voidCookie = ck(argv as W);
|
|
5632
|
+
}
|
|
5633
|
+
const a: { links: string[]; reason: string; supersededBy?: string; code?: string; asBot: boolean; cookie?: string } = {
|
|
5634
|
+
links, reason: unescapeArg(String(argv.reason ?? "")).trim(), asBot: !!argv["as-bot"],
|
|
5635
|
+
};
|
|
5636
|
+
if (argv["superseded-by"]) a.supersededBy = String(argv["superseded-by"]);
|
|
5637
|
+
if (argv.code) a.code = String(argv.code);
|
|
5638
|
+
if (voidCookie) a.cookie = voidCookie;
|
|
5639
|
+
try {
|
|
5640
|
+
await cmdAskVoid(voidToken, a);
|
|
5641
|
+
} catch (e: unknown) {
|
|
5642
|
+
console.error(friendlySlackError(e));
|
|
5643
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5644
|
+
}
|
|
5645
|
+
return;
|
|
5646
|
+
}
|
|
5647
|
+
if (argv.target === "collect") {
|
|
5648
|
+
if (!argv.question || (argv.choices as string[] | undefined)?.length) {
|
|
5649
|
+
console.error("Error: slack ask collect <permalink> โ exactly one question.");
|
|
5650
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5651
|
+
}
|
|
5652
|
+
argv.waitFor = String(argv.question);
|
|
5653
|
+
argv.target = undefined;
|
|
5654
|
+
argv.question = undefined;
|
|
5655
|
+
}
|
|
4705
5656
|
const waitFor = argv.waitFor ? String(argv.waitFor) : "";
|
|
4706
5657
|
const timeout = Number(argv.timeout);
|
|
4707
5658
|
// 0 is a real value only for --waitFor ("look once"). For --wait it would
|
|
@@ -4735,7 +5686,7 @@ async function main(): Promise<void> {
|
|
|
4735
5686
|
waitCookie = ck(argv as W);
|
|
4736
5687
|
}
|
|
4737
5688
|
try {
|
|
4738
|
-
const a: { link: string; timeout: number; asBot: boolean; cookie?: string; after?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"] };
|
|
5689
|
+
const a: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"], json: !!argv.json };
|
|
4739
5690
|
if (waitCookie) a.cookie = waitCookie;
|
|
4740
5691
|
if (argv.after) {
|
|
4741
5692
|
const after = String(argv.after);
|
|
@@ -4770,7 +5721,10 @@ async function main(): Promise<void> {
|
|
|
4770
5721
|
};
|
|
4771
5722
|
if (argv.code) args.code = argv.code;
|
|
4772
5723
|
if (argv.body) args.body = argv.body;
|
|
5724
|
+
if (argv.lang) args.lang = String(argv.lang);
|
|
4773
5725
|
if (argv.wait) args.wait = true;
|
|
5726
|
+
if (argv.json) args.json = true;
|
|
5727
|
+
if (voidAfter) args.voidAfter = voidAfter;
|
|
4774
5728
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
4775
5729
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
4776
5730
|
// Always resolve @tags with the user token (it has users:read), even
|
|
@@ -5144,12 +6098,14 @@ async function main(): Promise<void> {
|
|
|
5144
6098
|
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
|
|
5145
6099
|
.positional("newText", { type: "string", demandOption: true })
|
|
5146
6100
|
.option("code", { type: "string", describe: "Safety hash to confirm edit" })
|
|
6101
|
+
.option("force", { type: "boolean", default: false, describe: "Apply an edit that BREAKS a `slack ask` / `slack poll` message (new text no longer reads as one, or an answered/void question made open again). Refused by default because collect reads that body back; edits that keep it readable (e.g. writing the โ
form) need no flag. Prefer `slack ask --edit=` / `--void=`." })
|
|
5147
6102
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
5148
6103
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
5149
6104
|
.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." })
|
|
5150
6105
|
.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." }),
|
|
5151
6106
|
async (argv) => {
|
|
5152
6107
|
const args: EditArgs = { target: argv.target!, newText: argv.newText!, allowUrlAdjacent: argv["allow-url-adjacent"] };
|
|
6108
|
+
if (argv.force) args.force = true;
|
|
5153
6109
|
if (argv.code) args.code = argv.code;
|
|
5154
6110
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
5155
6111
|
if (argv.mentions !== false) args.mentions = true;
|