slack-term 1.40.0 → 1.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -3
- package/SKILL.md +50 -1
- package/dist/cli.js +227 -190
- package/package.json +1 -1
- package/ts/ask.ts +377 -45
- package/ts/botIntent.ts +68 -0
- package/ts/cli.ts +1026 -40
- 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,
|
|
@@ -118,11 +130,21 @@ import {
|
|
|
118
130
|
import { setCacheEnabled } from "./cache.ts";
|
|
119
131
|
import { escapeArg, unescapeArg } from "./escapes.ts";
|
|
120
132
|
import { attachmentLines, parseAttachments } from "./attachments.ts";
|
|
133
|
+
import { botIntentRefusal } from "./botIntent.ts";
|
|
121
134
|
import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, maskSecrets, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
|
|
122
135
|
import { quietHoursNotice } from "./quietHours.ts";
|
|
123
136
|
import { installSyncConsole } from "./stdout.ts";
|
|
124
137
|
import { LockBusyError, PINLOG_MARKER, acquireLock, composeHead, formatJst, headUpdatedAt, isPinlogHead, parsePinlogId, pinlogId, stripPinlogFooter } from "./pinlog.ts";
|
|
125
138
|
|
|
139
|
+
// Which dotenv file supplied each variable (absent = the shell's own env).
|
|
140
|
+
const dotenvSource = new Map<string, string>();
|
|
141
|
+
// Every value a GLOBAL dotenv holds, even when the shell already had the key.
|
|
142
|
+
// A child of `slack agent` (or any process the CLI spawned) inherits the
|
|
143
|
+
// global SLACK_BOT_TOKEN as a plain env var; matching it back to its global
|
|
144
|
+
// file keeps that inherited copy from reading as an explicit export.
|
|
145
|
+
const globalDotenvValue = new Map<string, { path: string; val: string }[]>();
|
|
146
|
+
const GLOBAL_DOTENVS = [join(homedir(), ".slack-cli", ".env.local"), join(homedir(), ".config/slack-cli", ".env")];
|
|
147
|
+
|
|
126
148
|
function loadDotenv(path: string): void {
|
|
127
149
|
if (!existsSync(path)) return;
|
|
128
150
|
const text = readFileSync(path, "utf8");
|
|
@@ -136,7 +158,13 @@ function loadDotenv(path: string): void {
|
|
|
136
158
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
137
159
|
val = val.slice(1, -1);
|
|
138
160
|
}
|
|
139
|
-
if (
|
|
161
|
+
if (GLOBAL_DOTENVS.includes(path)) {
|
|
162
|
+
globalDotenvValue.set(key, [...(globalDotenvValue.get(key) ?? []), { path, val }]);
|
|
163
|
+
}
|
|
164
|
+
if (!(key in process.env)) {
|
|
165
|
+
process.env[key] = val;
|
|
166
|
+
dotenvSource.set(key, path);
|
|
167
|
+
}
|
|
140
168
|
}
|
|
141
169
|
}
|
|
142
170
|
|
|
@@ -148,8 +176,7 @@ function ensureSlackCliDir(dir: string): void {
|
|
|
148
176
|
|
|
149
177
|
function loadDotenvFiles(): void {
|
|
150
178
|
// Global home config
|
|
151
|
-
|
|
152
|
-
loadDotenv(join(homedir(), ".config/slack-cli", ".env"));
|
|
179
|
+
for (const p of GLOBAL_DOTENVS) loadDotenv(p);
|
|
153
180
|
// Local project overrides (highest priority — loaded last so they win)
|
|
154
181
|
loadDotenv(join(process.cwd(), ".slack-cli", ".env.local"));
|
|
155
182
|
loadDotenv(join(process.cwd(), ".env.local"));
|
|
@@ -1291,6 +1318,8 @@ function sumCounts(m: Map<string, number>): number {
|
|
|
1291
1318
|
// --- edit ---
|
|
1292
1319
|
interface EditArgs {
|
|
1293
1320
|
allowUrlAdjacent?: boolean;
|
|
1321
|
+
/** Edit an `ask`/`poll` message anyway. Without it, `edit` refuses one. */
|
|
1322
|
+
force?: boolean;
|
|
1294
1323
|
target: string;
|
|
1295
1324
|
newText: string;
|
|
1296
1325
|
code?: string;
|
|
@@ -1385,14 +1414,47 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
1385
1414
|
|
|
1386
1415
|
guardUrlBoundaries(newText, args.allowUrlAdjacent);
|
|
1387
1416
|
|
|
1417
|
+
// An `ask` or `poll` body is a wire format: collect reads the question, the
|
|
1418
|
+
// pills, the ✅/🚫 state and the kept body back out of it. What is refused is
|
|
1419
|
+
// an edit that BREAKS that — new text that no longer reads as one, or an
|
|
1420
|
+
// answered/void question turned back into an open one. An edit that keeps it
|
|
1421
|
+
// readable is fine, and must stay fine: scripts resolve questions exactly this
|
|
1422
|
+
// way (the ask-devteam skill writes the ✅ form with `slack edit`, from hosts
|
|
1423
|
+
// that may run older builds — it cannot be asked to pass a new flag).
|
|
1424
|
+
const askBefore = askParseMessage(originalText).kind;
|
|
1425
|
+
const askAfter = askParseMessage(newText).kind;
|
|
1426
|
+
const isPoll = pollParseMessage(originalText).kind !== "other";
|
|
1427
|
+
const breaks = isPoll
|
|
1428
|
+
? pollParseMessage(newText).kind === "other"
|
|
1429
|
+
: askBefore !== "other" && (askAfter === "other" || (askBefore !== "open" && askAfter === "open"));
|
|
1430
|
+
if (breaks && !args.force) {
|
|
1431
|
+
const what = isPoll ? "a `slack poll`" : `a \`slack ask\` question (${askBefore === "open" ? "open" : askBefore === "resolved" ? "answered" : "void"})`;
|
|
1432
|
+
const how = isPoll || askAfter === "other"
|
|
1433
|
+
? "the new text would no longer read back as one (collect could not find the question, the pills or the answer)"
|
|
1434
|
+
: "the new text would turn it back into an OPEN question";
|
|
1435
|
+
console.error(
|
|
1436
|
+
`Error: this message is ${what}, and ${how}.\n` +
|
|
1437
|
+
(isPoll
|
|
1438
|
+
? ` Edit it anyway with --force.`
|
|
1439
|
+
: ` To retire it: slack ask --void='${args.target}' --reason '…'\n Edit it anyway with --force.`),
|
|
1440
|
+
);
|
|
1441
|
+
process.exit(1);
|
|
1442
|
+
}
|
|
1443
|
+
const guarded = breaks;
|
|
1444
|
+
|
|
1388
1445
|
// Identity is part of the hash for the same reason it is on `send`: a code
|
|
1389
1446
|
// minted while previewing as one identity must not confirm the write as
|
|
1390
1447
|
// another (Slack only lets you edit your own messages, so a silent profile
|
|
1391
1448
|
// switch between preview and confirm otherwise turns into a bare API error).
|
|
1392
1449
|
const self = await getSelf();
|
|
1393
|
-
|
|
1450
|
+
// Overriding the guard is part of what the code authorizes: a code minted for
|
|
1451
|
+
// an ordinary edit must not confirm a forced rewrite of a question.
|
|
1452
|
+
const code = guarded
|
|
1453
|
+
? safetyCode("force-edit-ask-poll", originalText, newText, self?.userId ?? "")
|
|
1454
|
+
: safetyCode(originalText, newText, self?.userId ?? "");
|
|
1394
1455
|
if (args.code !== code) {
|
|
1395
1456
|
requireCode(args.code, code, [
|
|
1457
|
+
...(guarded ? [`⚠ --force: rewriting a \`slack ${isPoll ? "poll" : "ask"}\` message by hand — collect may no longer read it back`] : []),
|
|
1396
1458
|
`--- Editing as -------------------------------`,
|
|
1397
1459
|
fromLine(self, { asBot: args.asBot }),
|
|
1398
1460
|
`--- Original message -------------------------`,
|
|
@@ -1404,7 +1466,13 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
1404
1466
|
}
|
|
1405
1467
|
|
|
1406
1468
|
const attr = sentAttribution("edit");
|
|
1407
|
-
|
|
1469
|
+
// An ask/poll body goes up PLAIN (no blocks): with blocks attached Slack
|
|
1470
|
+
// rewrites the stored text — every newline becomes a space — and the message
|
|
1471
|
+
// can never be read back as an ask again (2026-10-01: a release ask edited
|
|
1472
|
+
// this way could neither be collected nor voided). Ordinary messages keep
|
|
1473
|
+
// the blocks they always had.
|
|
1474
|
+
const askish = askBefore !== "other" || askAfter !== "other" || isPoll || pollParseMessage(newText).kind !== "other";
|
|
1475
|
+
const newTs = await editMessage(token, channelId, ts, newText, args.cookie, askish ? true : undefined, attr.metadata);
|
|
1408
1476
|
console.log(`✓ Edited (ts: ${newTs})`);
|
|
1409
1477
|
attr.record({ team: self?.team, channel: channelId, target: args.target, ts, text: newText, asBot: args.asBot });
|
|
1410
1478
|
}
|
|
@@ -2384,6 +2452,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
2384
2452
|
// 5 replied in free text, picking none of the offered choices — the reply
|
|
2385
2453
|
// text is on stdout. NOT a decision: the question is left open, so a pill
|
|
2386
2454
|
// pressed later still settles it (`--waitFor … --after=<reply ts>`).
|
|
2455
|
+
// 6 the question was VOIDED (無効化, `slack ask --void`) — it will never be
|
|
2456
|
+
// answered; stop waiting. stdout stays empty.
|
|
2387
2457
|
// Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
|
|
2388
2458
|
|
|
2389
2459
|
const ASK_EXIT_TIMEOUT = 2;
|
|
@@ -2399,6 +2469,10 @@ const ASK_EXIT_UNCHOSEN = 4;
|
|
|
2399
2469
|
* (measured 2026-09-27: 8 of 71 asks answered this way, none collected). Still
|
|
2400
2470
|
* not 0, so nothing mistakes it for a decision. */
|
|
2401
2471
|
const ASK_EXIT_FREETEXT = 5;
|
|
2472
|
+
/** The question was voided (無効化) — expired, superseded, or overtaken by events.
|
|
2473
|
+
* Neither "open" nor "answered": a monitor that kept waiting on it would wait
|
|
2474
|
+
* forever, and one that read it as answered would act on nothing. */
|
|
2475
|
+
const ASK_EXIT_VOID = 6;
|
|
2402
2476
|
|
|
2403
2477
|
const ASK_PHI = 1.618033988749895;
|
|
2404
2478
|
const ASK_POLL_MIN_MS = 1000;
|
|
@@ -2408,6 +2482,12 @@ const ASK_MAX_CONSECUTIVE_ERRORS = 8;
|
|
|
2408
2482
|
|
|
2409
2483
|
interface AskArgs {
|
|
2410
2484
|
allowUrlAdjacent?: boolean;
|
|
2485
|
+
/** `--void=<old> --reask`: once this question is posted, void `old` with this
|
|
2486
|
+
* question as its replacement. Validated (own, open) by the caller. */
|
|
2487
|
+
voidAfter?: AskVoidTarget & { reason: string };
|
|
2488
|
+
/** `--lang`. Unset = pick from the readers' Slack locale, then the content,
|
|
2489
|
+
* then the system locale — see `askResolveLang`. */
|
|
2490
|
+
lang?: string;
|
|
2411
2491
|
target: string;
|
|
2412
2492
|
question: string;
|
|
2413
2493
|
choices: string[];
|
|
@@ -2417,6 +2497,8 @@ interface AskArgs {
|
|
|
2417
2497
|
userId?: string;
|
|
2418
2498
|
asBot?: boolean;
|
|
2419
2499
|
wait?: boolean;
|
|
2500
|
+
/** `--json` with `--wait`: one JSON object on stdout (answer, notes, cursor). */
|
|
2501
|
+
json?: boolean;
|
|
2420
2502
|
timeout?: number;
|
|
2421
2503
|
cookie?: string;
|
|
2422
2504
|
// Token used to resolve the @tags that decide who may answer (needs
|
|
@@ -2488,6 +2570,9 @@ interface AskWaitCtx {
|
|
|
2488
2570
|
* nothing". */
|
|
2489
2571
|
overflow: string[];
|
|
2490
2572
|
threadOnly: boolean;
|
|
2573
|
+
/** The language the question's copy is in — the ✅ stamp and the
|
|
2574
|
+
* invalid-ballot line are written in the same one. */
|
|
2575
|
+
lang: AskLang;
|
|
2491
2576
|
/** Who may answer. Empty + broadcast=false would accept nobody, which is why
|
|
2492
2577
|
* posting an unaddressed question is refused up front. */
|
|
2493
2578
|
audience: Set<string>;
|
|
@@ -2503,16 +2588,148 @@ interface AskWaitCtx {
|
|
|
2503
2588
|
/** Only so the resume hint printed on timeout names the right identity — the
|
|
2504
2589
|
* ✅ rewrite can be done by the original poster alone. */
|
|
2505
2590
|
asBot: boolean;
|
|
2506
|
-
/**
|
|
2507
|
-
*
|
|
2591
|
+
/** The CURSOR: everything at or before this ts was already delivered to the
|
|
2592
|
+
* caller. Text replies there are not answers again (the free-text reply an
|
|
2593
|
+
* exit 5 delivered), and not thread notes again. Reactions still count. */
|
|
2508
2594
|
after?: string;
|
|
2595
|
+
/** `--json`: stdout is one JSON object (answer + notes + cursor) instead of
|
|
2596
|
+
* the bare answer. Exit codes are the same either way. */
|
|
2597
|
+
json: boolean;
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
/** A reply from the question's audience that is NOT the answer — an extra
|
|
2601
|
+
* instruction ("also look at X"), a caveat, a question back after a pill.
|
|
2602
|
+
*
|
|
2603
|
+
* Reported from real use 2026-10-02: a pill was pressed and collected, ✅ was
|
|
2604
|
+
* written, and the watcher exited; 40 s later the same person added a note in
|
|
2605
|
+
* the thread, and nothing ever delivered it. A pill was the whole answer as far
|
|
2606
|
+
* as `ask` was concerned, so the thread was never read — and once ✅ was
|
|
2607
|
+
* written, `--waitFor` only ever returned the stored answer. */
|
|
2608
|
+
interface AskNote { ts: string; user: string; text: string; permalink: string }
|
|
2609
|
+
|
|
2610
|
+
/** Who and where notes are read from — the same audience rule as answers. */
|
|
2611
|
+
interface AskNoteScope {
|
|
2612
|
+
channelId: string;
|
|
2613
|
+
ts: string;
|
|
2614
|
+
threadParentTs?: string;
|
|
2615
|
+
threadOnly: boolean;
|
|
2616
|
+
/** The question's own `reply_count`, or undefined when the question was not
|
|
2617
|
+
* in the page we read. Its thread is skipped only on a KNOWN zero: a call
|
|
2618
|
+
* per collection on every threadless question is a cost paid for nothing,
|
|
2619
|
+
* but an unknown count read as zero loses the notes — in a busy channel the
|
|
2620
|
+
* question falls out of the 30-message history page. */
|
|
2621
|
+
replyCount: number | undefined;
|
|
2622
|
+
isAnswerer: (user: unknown) => user is string;
|
|
2623
|
+
askerUserId: string;
|
|
2624
|
+
askerBotId: string;
|
|
2625
|
+
cookie?: string;
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
/** The audience's replies after `after` (or after the question), oldest first.
|
|
2629
|
+
* `skip` drops the reply that IS the answer.
|
|
2630
|
+
*
|
|
2631
|
+
* Fail-soft: a note is extra information, never the answer, so a failed fetch
|
|
2632
|
+
* is reported on stderr and must not cost the caller the answer it already
|
|
2633
|
+
* has. Thread replies come from the thread; in a 1:1 DM, plain replies count
|
|
2634
|
+
* too — bounded by our NEXT question there, exactly as answers are, so a note
|
|
2635
|
+
* meant for a newer question is not delivered with an older one. */
|
|
2636
|
+
async function askCollectNotes(token: string, sc: AskNoteScope, after: string | undefined, skip: (m: Record<string, Json>) => boolean): Promise<AskNote[]> {
|
|
2637
|
+
const pool = new Map<string, Record<string, Json>>();
|
|
2638
|
+
if (sc.threadParentTs || sc.replyCount !== 0) {
|
|
2639
|
+
try {
|
|
2640
|
+
const rep = asRecord((await replies(token, sc.channelId, sc.threadParentTs ?? sc.ts, 100, sc.cookie)) as Json);
|
|
2641
|
+
for (const m of asArray(rep.messages).map(asRecord)) pool.set(String(m.ts), m);
|
|
2642
|
+
} catch (e: unknown) {
|
|
2643
|
+
console.error(` (スレッドの追記を読めませんでした: ${e instanceof Error ? e.message : String(e)})`);
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
if (!sc.threadOnly && !sc.threadParentTs) {
|
|
2647
|
+
try {
|
|
2648
|
+
const hist = asRecord((await history(token, sc.channelId, 30, sc.ts, undefined, sc.cookie, true)) as Json);
|
|
2649
|
+
const msgs = asArray(hist.messages).map(asRecord);
|
|
2650
|
+
const bound = msgs
|
|
2651
|
+
.filter((m) => Number(m.ts) > Number(sc.ts) && (
|
|
2652
|
+
(sc.askerBotId && m.bot_id === sc.askerBotId) || (sc.askerUserId && m.user === sc.askerUserId)))
|
|
2653
|
+
.reduce((min, m) => Math.min(min, Number(m.ts)), Infinity);
|
|
2654
|
+
for (const m of msgs) if (Number(m.ts) < bound && !pool.has(String(m.ts))) pool.set(String(m.ts), m);
|
|
2655
|
+
} catch (e: unknown) {
|
|
2656
|
+
console.error(` (DM の追記を読めませんでした: ${e instanceof Error ? e.message : String(e)})`);
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
const picked = [...pool.values()]
|
|
2660
|
+
.filter((m) => Number(m.ts) > Number(sc.ts))
|
|
2661
|
+
.filter((m) => !after || Number(m.ts) > Number(after))
|
|
2662
|
+
// The asker is excluded by isAnswerer; any bot post (ours or another
|
|
2663
|
+
// integration's) is excluded here, so nothing echoes back as a "note".
|
|
2664
|
+
.filter((m) => sc.isAnswerer(m.user) && !m.bot_id)
|
|
2665
|
+
.filter((m) => typeof m.subtype !== "string" || ASK_ANSWERABLE_SUBTYPES.has(m.subtype))
|
|
2666
|
+
.filter((m) => typeof m.text === "string" && m.text.trim() !== "")
|
|
2667
|
+
// Sorted BEFORE `skip`, which may be stateful ("the first reply that …").
|
|
2668
|
+
.sort((a, b) => Number(a.ts) - Number(b.ts))
|
|
2669
|
+
.filter((m) => !skip(m));
|
|
2670
|
+
const out: AskNote[] = [];
|
|
2671
|
+
for (const m of picked) {
|
|
2672
|
+
let permalink = "";
|
|
2673
|
+
try {
|
|
2674
|
+
permalink = await getPermalink(token, sc.channelId, String(m.ts), sc.cookie);
|
|
2675
|
+
} catch {
|
|
2676
|
+
// fail-soft: the ts still names it
|
|
2677
|
+
}
|
|
2678
|
+
out.push({ ts: String(m.ts), user: String(m.user), text: (m.text as string).trim(), permalink });
|
|
2679
|
+
}
|
|
2680
|
+
return out;
|
|
2509
2681
|
}
|
|
2510
2682
|
|
|
2683
|
+
/** The next cursor: the newest thing this result delivered, never older than
|
|
2684
|
+
* the cursor it was given. A caller that passes it back as `--after` gets
|
|
2685
|
+
* nothing twice. */
|
|
2686
|
+
function askNextCursor(...tss: (string | undefined)[]): string {
|
|
2687
|
+
return tss.filter((t): t is string => !!t).reduce((a, b) => (!a || Number(b) > Number(a) ? b : a), "");
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
/** Deliver an answer (or the lack of one). Plain mode keeps the contract —
|
|
2691
|
+
* stdout is the answer alone — and puts the notes and the next-cursor hint on
|
|
2692
|
+
* stderr. `--json` puts all of it in one object on stdout. */
|
|
2693
|
+
async function askEmit(
|
|
2694
|
+
token: string,
|
|
2695
|
+
o: { json: boolean; shown: string; asBot: boolean; cookie?: string | undefined },
|
|
2696
|
+
r: { status: "answered" | "freetext" | "ambiguous" | "timeout" | "void"; answer: string | null; how?: string; who?: string; reason?: string; supersededBy?: string; notes: AskNote[]; cursor: string },
|
|
2697
|
+
): Promise<void> {
|
|
2698
|
+
if (o.json) {
|
|
2699
|
+
console.log(JSON.stringify({
|
|
2700
|
+
status: r.status, answer: r.answer, how: r.how ?? null, who: r.who ?? null,
|
|
2701
|
+
...(r.status === "void" ? { reason: r.reason ?? "", supersededBy: r.supersededBy ?? null } : {}),
|
|
2702
|
+
notes: r.notes, cursor: r.cursor, permalink: o.shown,
|
|
2703
|
+
}));
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2706
|
+
if (r.notes.length) {
|
|
2707
|
+
console.error(` スレッドの追記 ${r.notes.length} 件 (回答とは別):`);
|
|
2708
|
+
for (const n of r.notes) {
|
|
2709
|
+
let who = n.user;
|
|
2710
|
+
try {
|
|
2711
|
+
who = await userName(token, n.user, o.cookie);
|
|
2712
|
+
} catch {
|
|
2713
|
+
// the id still says who
|
|
2714
|
+
}
|
|
2715
|
+
for (const [i, line] of n.text.split("\n").entries()) {
|
|
2716
|
+
console.error(` ${i ? " ".repeat(stripTerminalControls(who).length + 2) : `${stripTerminalControls(who)}: `}${stripTerminalControls(line)}`);
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
if (r.status === "answered") {
|
|
2721
|
+
console.error(` この後の追記を拾う: ${askResumeCommand(o.shown, o.asBot, r.cursor)}`);
|
|
2722
|
+
}
|
|
2723
|
+
if (r.answer !== null) console.log(r.answer);
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
|
|
2511
2727
|
/** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
|
|
2512
2728
|
* on timeout. A timeout of 0 means "look exactly once", so a caller can poll
|
|
2513
2729
|
* cheaply on its own schedule instead of parking a process on `--wait`. */
|
|
2514
2730
|
async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never> {
|
|
2515
2731
|
const { channelId, ts, question, reactable, threadOnly, audience, broadcast, cookie, timeout, shown } = ctx;
|
|
2732
|
+
const copy = ASK_COPY[ctx.lang];
|
|
2516
2733
|
const askerUserId = ctx.askerUserId;
|
|
2517
2734
|
const askerBotId = ctx.askerBotId;
|
|
2518
2735
|
|
|
@@ -2523,6 +2740,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2523
2740
|
const isAnswerer = (user: unknown): user is string =>
|
|
2524
2741
|
typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
|
|
2525
2742
|
|
|
2743
|
+
/** The question's `reply_count` as last seen — whether its thread is worth a
|
|
2744
|
+
* fetch when collecting notes. Undefined until the question itself is seen. */
|
|
2745
|
+
let replyCount: number | undefined;
|
|
2746
|
+
/** The question's text as last read — what the ✅ rewrite keeps the body of. */
|
|
2747
|
+
let ownText: string | undefined;
|
|
2748
|
+
/** Set when the question is voided while we wait — the wait ends there. */
|
|
2749
|
+
let voided: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }> | undefined;
|
|
2750
|
+
const checkVoid = (own: Record<string, Json>): boolean => {
|
|
2751
|
+
const p = typeof own.text === "string" ? askParseMessage(own.text) : undefined;
|
|
2752
|
+
if (p?.kind === "void") voided = p;
|
|
2753
|
+
return !!voided;
|
|
2754
|
+
};
|
|
2755
|
+
|
|
2526
2756
|
/** The earliest reply from an answerer that picked none of the choices. Kept
|
|
2527
2757
|
* across polls so the timeout can report it, and so the operator is told once
|
|
2528
2758
|
* rather than on every tick. */
|
|
@@ -2556,7 +2786,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2556
2786
|
async function noteInvalid(msg: Record<string, Json>, offenders: string[]): Promise<void> {
|
|
2557
2787
|
const text = typeof msg.text === "string" ? msg.text : "";
|
|
2558
2788
|
if (!text) return;
|
|
2559
|
-
const next = applyInvalidNotice(text, offenders);
|
|
2789
|
+
const next = applyInvalidNotice(text, offenders, ctx.lang);
|
|
2560
2790
|
if (next === text) return;
|
|
2561
2791
|
try {
|
|
2562
2792
|
await editMessage(token, channelId, ts, next, cookie, true);
|
|
@@ -2566,7 +2796,22 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2566
2796
|
}
|
|
2567
2797
|
}
|
|
2568
2798
|
|
|
2799
|
+
/** Who has pressed ❓ ("other"). Not an answer — ❓ carries no text — so it
|
|
2800
|
+
* never resolves anything; the reply that should follow it does. Reported
|
|
2801
|
+
* once per person so the operator knows a reply is on its way rather than
|
|
2802
|
+
* reading the silence as "nobody looked". */
|
|
2803
|
+
const otherReported = new Set<string>();
|
|
2804
|
+
function noteOther(msg: Record<string, Json>): void {
|
|
2805
|
+
const r = asArray(msg.reactions).map(asRecord).find((x) => x.name === ASK_MARKER);
|
|
2806
|
+
for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
|
|
2807
|
+
if (otherReported.has(u)) continue;
|
|
2808
|
+
otherReported.add(u);
|
|
2809
|
+
console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2569
2813
|
async function answerFromReactions(msg: Record<string, Json>): Promise<AskFound | null> {
|
|
2814
|
+
if (reactable.length) noteOther(msg);
|
|
2570
2815
|
const picks = humanChoices(msg);
|
|
2571
2816
|
// Nothing ambiguous any more — take the notice back down if one is up.
|
|
2572
2817
|
if (picks.length <= 1 && readInvalidNotice(typeof msg.text === "string" ? msg.text : "").length) {
|
|
@@ -2575,15 +2820,26 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2575
2820
|
if (!picks.length) return null;
|
|
2576
2821
|
if (picks.length === 1) {
|
|
2577
2822
|
const { index, users } = picks[0]!;
|
|
2578
|
-
return { answer: reactable[index]!, how:
|
|
2823
|
+
return { answer: reactable[index]!, how: copy.howReaction(ASK_KEYCAPS[index]!.glyph), who: users[0]!, choice: index + 1 };
|
|
2579
2824
|
}
|
|
2580
2825
|
const glyphs = picks.map((p) => ASK_KEYCAPS[p.index]!.glyph).join(" / ");
|
|
2581
|
-
|
|
2582
|
-
//
|
|
2583
|
-
//
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
const
|
|
2826
|
+
// Two cases, both "do not guess": one person on several pills, or
|
|
2827
|
+
// different people on different pills (a split). Only the first is the
|
|
2828
|
+
// voter's to fix, so only those people are named in the message.
|
|
2829
|
+
const pillsPerUser = new Map<string, number>();
|
|
2830
|
+
for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
|
|
2831
|
+
const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
|
|
2832
|
+
console.error(multi.length
|
|
2833
|
+
? ` (${glyphs} が同時に選ばれています。1 つに絞ってもらうまで待ちます)`
|
|
2834
|
+
: ` (回答が分かれています: ${glyphs}。どちらか一方にそろうまで待ちます)`);
|
|
2835
|
+
// Whoever is on more than one pill — and ONLY them. Naming everyone on any
|
|
2836
|
+
// pill told a voter who had pressed exactly one that she had pressed two
|
|
2837
|
+
// (2026-10-01: a voter on 1️⃣ only was named because someone else was on 1️⃣
|
|
2838
|
+
// and 2️⃣). Named in the question itself so the person who has to fix it is
|
|
2839
|
+
// the person who sees it; rewritten from the current state each pass, so
|
|
2840
|
+
// reruns cannot stack up notices and the line clears itself once a
|
|
2841
|
+
// reaction is taken back. A pure split names nobody (empty = no notice).
|
|
2842
|
+
const offenders = multi.sort();
|
|
2587
2843
|
await noteInvalid(msg, offenders);
|
|
2588
2844
|
return null;
|
|
2589
2845
|
}
|
|
@@ -2607,13 +2863,13 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2607
2863
|
if (!t) continue;
|
|
2608
2864
|
// A question asked WITHOUT choices is answered by whatever comes back —
|
|
2609
2865
|
// there is nothing to match against, and every reply is the answer.
|
|
2610
|
-
if (!candidates.length) return { answer: t, how:
|
|
2866
|
+
if (!candidates.length) return { answer: t, how: copy.howReply, who: m.user, ts: String(m.ts) };
|
|
2611
2867
|
const match = askMatchChoice(t, candidates);
|
|
2612
2868
|
if (match.kind === "chosen") {
|
|
2613
2869
|
// Answer with the CHOICE, not with the reply that selected it: "2" and
|
|
2614
2870
|
// "2. 中止" have to reach the caller as the same decision a pill would
|
|
2615
2871
|
// have produced, or the same answer arrives in three spellings.
|
|
2616
|
-
return { answer: candidates[match.index - 1]!, how:
|
|
2872
|
+
return { answer: candidates[match.index - 1]!, how: copy.howReplyN(match.index), who: m.user, ts: String(m.ts), choice: match.index };
|
|
2617
2873
|
}
|
|
2618
2874
|
// Replied, but picked nothing. Recorded rather than returned: a later
|
|
2619
2875
|
// reply may still choose, and the first one is what the operator needs to
|
|
@@ -2647,6 +2903,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2647
2903
|
const messages = asArray(rep.messages).map(asRecord);
|
|
2648
2904
|
const own = messages.find((m) => m.ts === ts);
|
|
2649
2905
|
if (own) {
|
|
2906
|
+
replyCount = Number(own.reply_count) || 0;
|
|
2907
|
+
if (typeof own.text === "string") ownText = own.text;
|
|
2908
|
+
if (checkVoid(own)) return null;
|
|
2650
2909
|
const byReaction = await answerFromReactions(own);
|
|
2651
2910
|
if (byReaction) return byReaction;
|
|
2652
2911
|
}
|
|
@@ -2660,6 +2919,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2660
2919
|
const messages = asArray(hist.messages).map(asRecord);
|
|
2661
2920
|
const own = messages.find((m) => m.ts === ts);
|
|
2662
2921
|
|
|
2922
|
+
if (own) {
|
|
2923
|
+
replyCount = Number(own.reply_count) || 0;
|
|
2924
|
+
if (typeof own.text === "string") ownText = own.text;
|
|
2925
|
+
if (checkVoid(own)) return null;
|
|
2926
|
+
}
|
|
2663
2927
|
// A reaction is the intended path, so it wins when both are present.
|
|
2664
2928
|
if (own) {
|
|
2665
2929
|
const byReaction = await answerFromReactions(own);
|
|
@@ -2713,7 +2977,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2713
2977
|
// and must keep getting the answer body alone.
|
|
2714
2978
|
if (who) console.error(` 回答者: ${stripTerminalControls(who)}`);
|
|
2715
2979
|
try {
|
|
2716
|
-
|
|
2980
|
+
// The body and the chosen option stay; only what no longer applies goes.
|
|
2981
|
+
// Built from the message as it is NOW (an edit made in Slack after asking
|
|
2982
|
+
// is kept). Not seen this run — the bare form, as before.
|
|
2983
|
+
const keep = ownText ? askResolvedKeep(ownText, found.choice) : undefined;
|
|
2984
|
+
await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
|
|
2717
2985
|
// Swap the marker for the resolved one so search reflects reality:
|
|
2718
2986
|
// `has::question:` should list what still needs answering, not everything
|
|
2719
2987
|
// ever asked. Removal last — a crash between the two leaves the question
|
|
@@ -2749,10 +3017,34 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2749
3017
|
}
|
|
2750
3018
|
console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
|
|
2751
3019
|
}
|
|
3020
|
+
if (voided) {
|
|
3021
|
+
askReportVoid(voided, shown);
|
|
3022
|
+
if (ctx.json) {
|
|
3023
|
+
await askEmit(token, ctx, {
|
|
3024
|
+
status: "void", answer: null, reason: voided.reason,
|
|
3025
|
+
...(voided.supersededBy ? { supersededBy: voided.supersededBy } : {}),
|
|
3026
|
+
notes: [], cursor: askNextCursor(ts, ctx.after),
|
|
3027
|
+
});
|
|
3028
|
+
}
|
|
3029
|
+
process.exit(ASK_EXIT_VOID);
|
|
3030
|
+
}
|
|
2752
3031
|
if (found) {
|
|
3032
|
+
// Notes once the answer is in hand: a pill used to end the poll before
|
|
3033
|
+
// the thread was ever read, which is how a note beside it was lost. Read
|
|
3034
|
+
// BEFORE ✅ is written, and delivered right after — ✅ is what tells a
|
|
3035
|
+
// later --waitFor the question is settled.
|
|
3036
|
+
const notes = await askCollectNotes(token, {
|
|
3037
|
+
channelId, ts, threadOnly, replyCount, isAnswerer, askerUserId, askerBotId,
|
|
3038
|
+
...(ctx.threadParentTs ? { threadParentTs: ctx.threadParentTs } : {}),
|
|
3039
|
+
...(cookie ? { cookie } : {}),
|
|
3040
|
+
}, ctx.after, (m) => m.ts === found!.ts);
|
|
2753
3041
|
await markResolved(found);
|
|
2754
|
-
|
|
2755
|
-
|
|
3042
|
+
const r: Parameters<typeof askEmit>[2] = {
|
|
3043
|
+
status: "answered", answer: found.answer, how: found.how, notes,
|
|
3044
|
+
cursor: askNextCursor(ts, ctx.after, found.ts, ...notes.map((n) => n.ts)),
|
|
3045
|
+
};
|
|
3046
|
+
if (found.who) r.who = found.who;
|
|
3047
|
+
await askEmit(token, ctx, r);
|
|
2756
3048
|
process.exit(0);
|
|
2757
3049
|
}
|
|
2758
3050
|
// A free-text reply that picks nothing is delivered NOW, not held until the
|
|
@@ -2771,7 +3063,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2771
3063
|
console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
2772
3064
|
console.error(` ${shown}`);
|
|
2773
3065
|
console.error(` この返信の後を待つ: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
|
|
2774
|
-
|
|
3066
|
+
if (ctx.json) {
|
|
3067
|
+
await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
|
|
3068
|
+
} else {
|
|
3069
|
+
console.log(unchosen.text);
|
|
3070
|
+
}
|
|
2775
3071
|
process.exit(ASK_EXIT_FREETEXT);
|
|
2776
3072
|
}
|
|
2777
3073
|
// Said as soon as it is seen, not only at the timeout: the reply is usually
|
|
@@ -2813,6 +3109,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2813
3109
|
console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
2814
3110
|
console.error(` ${shown}`);
|
|
2815
3111
|
console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3112
|
+
if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
|
|
2816
3113
|
process.exit(ASK_EXIT_UNCHOSEN);
|
|
2817
3114
|
}
|
|
2818
3115
|
if (timeout === 0) {
|
|
@@ -2822,6 +3119,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2822
3119
|
console.error(` ${shown}`);
|
|
2823
3120
|
console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
2824
3121
|
}
|
|
3122
|
+
if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
|
|
2825
3123
|
process.exit(ASK_EXIT_TIMEOUT);
|
|
2826
3124
|
}
|
|
2827
3125
|
|
|
@@ -2872,6 +3170,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2872
3170
|
const qEnc = askEncodeBroadcasts(unescapeArg(args.question));
|
|
2873
3171
|
const bEnc = askEncodeBroadcasts(unescapeArg(args.body ?? ""));
|
|
2874
3172
|
const broadcastKinds = new Set([...qEnc.kinds, ...bEnc.kinds]);
|
|
3173
|
+
// Already-encoded broadcasts (`<!here>`) — what a re-ask of a posted question carries.
|
|
3174
|
+
for (const m of `${qEnc.text}\n${bEnc.text}`.matchAll(/<!(here|channel|everyone)(?:\|[^>]*)?>/g)) broadcastKinds.add(m[1]!);
|
|
2875
3175
|
const mentionToken = args.mentionToken ?? token;
|
|
2876
3176
|
const qRep = await encodeMentionsDetailed(mentionToken, qEnc.text, channelId, args.mentionCookie);
|
|
2877
3177
|
const bRep = bEnc.text
|
|
@@ -2889,6 +3189,14 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2889
3189
|
// Tagging yourself grants nothing — your own reactions are the seeds.
|
|
2890
3190
|
const audience = new Set(resolved.map((r) => r.userId).filter((id) => id !== selfId));
|
|
2891
3191
|
const audienceNames = new Map(resolved.map((r) => [r.userId, r.display]));
|
|
3192
|
+
// Tags already in Slack's `<@U…>` form name someone just as surely — that is
|
|
3193
|
+
// what a re-ask built from a posted question carries. Same self-exclusion.
|
|
3194
|
+
for (const m of `${question}\n${body}`.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)) {
|
|
3195
|
+
const id = m[1]!;
|
|
3196
|
+
if (id === selfId || audience.has(id)) continue;
|
|
3197
|
+
audience.add(id);
|
|
3198
|
+
audienceNames.set(id, await userName(mentionToken, id, args.mentionCookie));
|
|
3199
|
+
}
|
|
2892
3200
|
let audienceLabel: string;
|
|
2893
3201
|
if (broadcast) {
|
|
2894
3202
|
audienceLabel = `anyone in ${ref} (${[...broadcastKinds].map((k) => `@${k}`).join(", ")})`;
|
|
@@ -2931,7 +3239,19 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2931
3239
|
// which makes channel mode unambiguous by construction. A question posted
|
|
2932
3240
|
// INTO a thread is thread-scoped for the same reason.
|
|
2933
3241
|
const threadOnly = !channelId.startsWith("D") || !!threadTs;
|
|
2934
|
-
|
|
3242
|
+
// The copy's language. The readers are the people who have to read it, so
|
|
3243
|
+
// their Slack locale comes first — but only for a known set of people: an
|
|
3244
|
+
// @here audience is not one, and is left to the content and the system.
|
|
3245
|
+
const readerLocales = broadcast
|
|
3246
|
+
? []
|
|
3247
|
+
: await Promise.all([...audience].map((id) => userLocale(mentionToken, id, args.mentionCookie)));
|
|
3248
|
+
const picked = askResolveLang({ flag: args.lang, readerLocales, content: [question, body, ...reactable, ...overflow] });
|
|
3249
|
+
if (!picked) {
|
|
3250
|
+
console.error(`Error: unsupported language "${stripTerminalControls(args.lang ?? process.env.SLACK_TERM_LANG ?? "")}" — use one of: ${ASK_LANGS.join(", ")}`);
|
|
3251
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3252
|
+
}
|
|
3253
|
+
const lang = picked.lang;
|
|
3254
|
+
const message = askBuildText(question, body, reactable, overflow, threadOnly, lang);
|
|
2935
3255
|
guardUrlBoundaries(message, args.allowUrlAdjacent);
|
|
2936
3256
|
|
|
2937
3257
|
// Preview the destination's last message, exactly as `send` does — the gate's
|
|
@@ -2958,7 +3278,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2958
3278
|
// Resolved before the gate so the clock can name the recipient's zone. DM
|
|
2959
3279
|
// only; null means "unknown" and the label says so rather than guessing.
|
|
2960
3280
|
const recipientTz = await recipientTzFor(token, channelId, cookie);
|
|
2961
|
-
const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? ""
|
|
3281
|
+
const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "",
|
|
3282
|
+
...(args.voidAfter ? ["reask", `${args.voidAfter.channelId}:${args.voidAfter.ts}`, args.voidAfter.reason] : []));
|
|
2962
3283
|
if (args.code !== code) {
|
|
2963
3284
|
const dest = await destLabel(token, channelId, ref, cookie);
|
|
2964
3285
|
const choiceLines = reactable.length
|
|
@@ -2981,6 +3302,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2981
3302
|
// shown as its own line rather than left to be inferred from the body.
|
|
2982
3303
|
` Answerable by: ${audienceLabel}`,
|
|
2983
3304
|
` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
|
|
3305
|
+
...(args.voidAfter ? [` Then 無効化: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) — replaced by this question`] : []),
|
|
3306
|
+
` 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
3307
|
`--------------------------------────────────`,
|
|
2985
3308
|
], recipientTz);
|
|
2986
3309
|
}
|
|
@@ -2991,7 +3314,9 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2991
3314
|
const attr = sentAttribution("ask");
|
|
2992
3315
|
const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
|
|
2993
3316
|
|
|
2994
|
-
// The marker goes on
|
|
3317
|
+
// The marker goes on LAST, after 1,2,3, because it doubles as the "other"
|
|
3318
|
+
// choice the body lists under the numbered ones (people were already pressing
|
|
3319
|
+
// ❓ for "none of these fit"). It is a reaction so
|
|
2995
3320
|
// `has::question:` lists every question the way `has::pushpin:` lists every
|
|
2996
3321
|
// todo. Body text cannot do that job: Slack's index splits on punctuation, so
|
|
2997
3322
|
// a `:question:` written in the text is indexed as the word "question" and
|
|
@@ -3000,12 +3325,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3000
3325
|
// NOTE the colons: the modifier's argument is itself colon-wrapped, and the
|
|
3001
3326
|
// bare `has:question` form does NOT error — it silently degrades to a
|
|
3002
3327
|
// full-text search and returns plausible-looking counts. See ts/todo.ts.
|
|
3003
|
-
//
|
|
3328
|
+
// 1,2,3, then the marker — and spaced out. Sequential awaits alone are NOT
|
|
3004
3329
|
// enough: measured on two real questions from the same build, one came back
|
|
3005
3330
|
// `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
|
|
3006
3331
|
// SLACK_REACTION_SEED_GAP_MS.
|
|
3007
3332
|
await seedReactionsInOrder(
|
|
3008
|
-
[
|
|
3333
|
+
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
|
|
3009
3334
|
(name) => reactionAdd(token, channelId, ts, name, cookie),
|
|
3010
3335
|
(name, e) => {
|
|
3011
3336
|
const why = e instanceof Error ? e.message : String(e);
|
|
@@ -3025,6 +3350,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3025
3350
|
const shown = permalink || `${channelId}:${ts}`;
|
|
3026
3351
|
console.error(`✓ Asked: ${shown}`);
|
|
3027
3352
|
attr.record({ team: self?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
|
|
3353
|
+
// The replacement is up; now retire what it replaces. A failure here leaves
|
|
3354
|
+
// two open questions, which is reported loudly — never a missing one.
|
|
3355
|
+
if (args.voidAfter) {
|
|
3356
|
+
const ok = await askApplyVoid(token, args.voidAfter, args.voidAfter.reason, shown, cookie);
|
|
3357
|
+
if (!ok) console.error(` ⚠ 新しい質問は出ましたが、古い質問を無効化できませんでした — 手で: slack ask --void='${stripTerminalControls(args.voidAfter.link)}' --superseded-by='${shown}'`);
|
|
3358
|
+
}
|
|
3028
3359
|
|
|
3029
3360
|
if (!args.wait) {
|
|
3030
3361
|
// Not waiting. stdout is the RESUME COMMAND, not just a link: nothing else
|
|
@@ -3046,6 +3377,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3046
3377
|
reactable,
|
|
3047
3378
|
overflow,
|
|
3048
3379
|
threadOnly,
|
|
3380
|
+
lang,
|
|
3049
3381
|
audience,
|
|
3050
3382
|
broadcast,
|
|
3051
3383
|
askerUserId: self?.userId ?? "",
|
|
@@ -3053,6 +3385,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3053
3385
|
timeout,
|
|
3054
3386
|
shown,
|
|
3055
3387
|
asBot: !!args.asBot,
|
|
3388
|
+
json: !!args.json,
|
|
3056
3389
|
};
|
|
3057
3390
|
if (threadTs) ctx.threadParentTs = threadTs;
|
|
3058
3391
|
if (cookie) ctx.cookie = cookie;
|
|
@@ -3061,6 +3394,423 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3061
3394
|
|
|
3062
3395
|
|
|
3063
3396
|
|
|
3397
|
+
/** Say on stderr that a question is void, and where its replacement is. */
|
|
3398
|
+
function askReportVoid(p: { reason: string; supersededBy?: string }, shown: string): void {
|
|
3399
|
+
console.error(`この質問は無効です — 回答されることはありません。待つのをやめてください: ${stripTerminalControls(shown)}`);
|
|
3400
|
+
if (p.reason) console.error(` ${stripTerminalControls(p.reason)}`);
|
|
3401
|
+
if (p.supersededBy) console.error(` 新しい質問: ${stripTerminalControls(p.supersededBy)}`);
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
/** Find the one message a permalink (or `C…:ts`) names. A question posted into
|
|
3405
|
+
* a thread is not in history at all, so where the link says it is a reply, it
|
|
3406
|
+
* is read from the thread. */
|
|
3407
|
+
async function askFetchByLink(token: string, link: string, cookie?: string): Promise<
|
|
3408
|
+
{ channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> } | null
|
|
3409
|
+
> {
|
|
3410
|
+
const url = parseSlackPermalink(link);
|
|
3411
|
+
let channelId: string;
|
|
3412
|
+
let ts: string;
|
|
3413
|
+
let threadTs: string | undefined;
|
|
3414
|
+
if (url?.ts) {
|
|
3415
|
+
channelId = url.channel;
|
|
3416
|
+
ts = url.ts;
|
|
3417
|
+
threadTs = url.threadTs && url.threadTs !== url.ts ? url.threadTs : undefined;
|
|
3418
|
+
} else {
|
|
3419
|
+
const m = link.match(/^([A-Za-z0-9]+):(\d{10}\.\d{6})$/);
|
|
3420
|
+
if (!m) return null;
|
|
3421
|
+
channelId = m[1]!;
|
|
3422
|
+
ts = m[2]!;
|
|
3423
|
+
}
|
|
3424
|
+
let msg: Record<string, Json> | undefined;
|
|
3425
|
+
if (threadTs) {
|
|
3426
|
+
const rep = asRecord((await replies(token, channelId, threadTs, 100, cookie)) as Json);
|
|
3427
|
+
msg = asArray(rep.messages).map(asRecord).find((m) => m.ts === ts);
|
|
3428
|
+
} else {
|
|
3429
|
+
const hist = asRecord((await history(token, channelId, 1, ts, undefined, cookie, true)) as Json);
|
|
3430
|
+
msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
|
|
3431
|
+
}
|
|
3432
|
+
const out: { channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> } = { channelId, ts };
|
|
3433
|
+
if (threadTs) out.threadTs = threadTs;
|
|
3434
|
+
if (msg) out.msg = msg;
|
|
3435
|
+
return out;
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
/** Look up a question for a lifecycle action (edit / void / reask): it must
|
|
3439
|
+
* exist, be OUR OWN post (Slack lets only the author edit, and changing
|
|
3440
|
+
* someone else's decision is not a thing this should make possible), and be
|
|
3441
|
+
* open. Says why on stderr and returns null otherwise. */
|
|
3442
|
+
async function askLoadOwnOpen(token: string, link: string, self: Self | null, asBot: boolean, cookie?: string):
|
|
3443
|
+
Promise<(AskVoidTarget & { msg: Record<string, Json>; threadTs?: string }) | "void" | null> {
|
|
3444
|
+
const shown = stripTerminalControls(link);
|
|
3445
|
+
const got = await askFetchByLink(token, link, cookie);
|
|
3446
|
+
if (!got) { console.error(`✗ ${shown}: permalink (または 'C…:1700000000.000100') ではありません`); return null; }
|
|
3447
|
+
if (!got.msg) { console.error(`✗ ${shown}: メッセージが見つかりません`); return null; }
|
|
3448
|
+
const msg = got.msg;
|
|
3449
|
+
const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
|
|
3450
|
+
if (!own) {
|
|
3451
|
+
console.error(`✗ ${shown}: 自分(${senderLabel(self, asBot)})が投稿した質問ではありません` +
|
|
3452
|
+
`${asBot ? "" : " — bot が出した質問なら --as-bot で"}`);
|
|
3453
|
+
return null;
|
|
3454
|
+
}
|
|
3455
|
+
const text = typeof msg.text === "string" ? msg.text : "";
|
|
3456
|
+
const parsed = askParseMessage(text);
|
|
3457
|
+
if (parsed.kind === "void") return "void";
|
|
3458
|
+
if (parsed.kind === "resolved") { console.error(`✗ ${shown}: 回答済みの質問です(回答: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})— 変更できません`); return null; }
|
|
3459
|
+
if (parsed.kind !== "open") { console.error(`✗ ${shown}: \`slack ask\` の質問として読み取れません — ${askExplainReject(text)}`); return null; }
|
|
3460
|
+
const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, msg };
|
|
3461
|
+
if (got.threadTs) out.threadTs = got.threadTs;
|
|
3462
|
+
return out;
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
/** Has anyone but us answered, or started to? A pressed keycap, a reply in the
|
|
3466
|
+
* thread, or (in a 1:1 DM) a plain message after the question. Used to lock the
|
|
3467
|
+
* OPTIONS: after a vote, renumbering them would make the reactions already
|
|
3468
|
+
* given silently mean something else. */
|
|
3469
|
+
async function askHasResponses(token: string, t: { channelId: string; ts: string; msg: Record<string, Json> }, self: Self | null, cookie?: string): Promise<boolean> {
|
|
3470
|
+
const notSelf = (u: unknown) => typeof u === "string" && u !== self?.userId;
|
|
3471
|
+
const pressed = asArray(t.msg.reactions).map(asRecord)
|
|
3472
|
+
.some((r) => ASK_KEYCAPS.some((k) => k.name === r.name) && asArray(r.users).some(notSelf));
|
|
3473
|
+
if (pressed) return true;
|
|
3474
|
+
// A thread counts only if someone OTHER than us is in it — our own
|
|
3475
|
+
// follow-up note must not lock the options.
|
|
3476
|
+
if (Number(t.msg.reply_count) > 0) {
|
|
3477
|
+
const rep = asRecord((await replies(token, t.channelId, t.ts, 100, cookie)) as Json);
|
|
3478
|
+
if (asArray(rep.messages).map(asRecord).some((m) => Number(m.ts) > Number(t.ts) && notSelf(m.user) && !m.bot_id)) return true;
|
|
3479
|
+
}
|
|
3480
|
+
if (t.channelId.startsWith("D")) {
|
|
3481
|
+
const hist = asRecord((await history(token, t.channelId, 30, t.ts, undefined, cookie, true)) as Json);
|
|
3482
|
+
if (asArray(hist.messages).map(asRecord).some((m) => Number(m.ts) > Number(t.ts) && notSelf(m.user) && !m.bot_id)) return true;
|
|
3483
|
+
}
|
|
3484
|
+
return false;
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
/** `slack ask --edit=<link> ["new question" [choices…]] [--body …]` — change an
|
|
3488
|
+
* open question THROUGH the ask builder, so pills, ❓, the instructions and the
|
|
3489
|
+
* body stay in the layout collect reads back (a free-hand `slack edit` is what
|
|
3490
|
+
* this replaces; `edit` now refuses ask messages without --force).
|
|
3491
|
+
*
|
|
3492
|
+
* The wording (question, body) can change any time. The OPTIONS can change only
|
|
3493
|
+
* while nobody has answered: after a vote the old reactions would silently mean
|
|
3494
|
+
* the new options. Then it refuses and points at `--void=<link> --reask`, which
|
|
3495
|
+
* retires the question and asks the new one. Answered / void: refused. */
|
|
3496
|
+
async function cmdAskEdit(token: string, args: {
|
|
3497
|
+
link: string; question?: string; body?: string; choices?: string[]; lang?: string;
|
|
3498
|
+
code?: string; asBot: boolean; cookie?: string; mentionToken: string; mentionCookie?: string;
|
|
3499
|
+
allowUrlAdjacent?: boolean;
|
|
3500
|
+
}): Promise<void> {
|
|
3501
|
+
const self = await selfIdentity(token, args.cookie);
|
|
3502
|
+
const t = await askLoadOwnOpen(token, args.link, self, args.asBot, args.cookie);
|
|
3503
|
+
if (t === "void") { console.error(`✗ ${stripTerminalControls(args.link)}: 無効化済みの質問は編集できません`); process.exit(ASK_EXIT_ERROR); }
|
|
3504
|
+
if (!t) process.exit(ASK_EXIT_ERROR);
|
|
3505
|
+
const old = t.parsed;
|
|
3506
|
+
const encode = async (raw: string): Promise<string> => {
|
|
3507
|
+
const enc = askEncodeBroadcasts(unescapeArg(raw));
|
|
3508
|
+
return (await encodeMentionsDetailed(args.mentionToken, enc.text, t.channelId, args.mentionCookie)).text;
|
|
3509
|
+
};
|
|
3510
|
+
const question = args.question !== undefined ? await encode(args.question) : old.question;
|
|
3511
|
+
const body = args.body !== undefined ? await encode(args.body) : old.body;
|
|
3512
|
+
const oldChoices = [...old.reactable, ...old.overflow];
|
|
3513
|
+
const choices = args.choices ? args.choices.map(unescapeArg) : oldChoices;
|
|
3514
|
+
const choicesChanged = JSON.stringify(choices.map(askFlatten)) !== JSON.stringify(oldChoices.map(askFlatten));
|
|
3515
|
+
if (choicesChanged && await askHasResponses(token, t, self, args.cookie)) {
|
|
3516
|
+
console.error(
|
|
3517
|
+
`✗ この質問にはもう回答(リアクションか返信)があります — 選択肢を変えると、押されたリアクションの意味が変わってしまいます。\n` +
|
|
3518
|
+
` 質問文と本文は変えられます。選択肢を変えるなら無効化して出し直してください:\n` +
|
|
3519
|
+
` slack ask --void='${stripTerminalControls(args.link)}' --reask${args.asBot ? " --as-bot" : ""} …`,
|
|
3520
|
+
);
|
|
3521
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3522
|
+
}
|
|
3523
|
+
// Still addressed to someone: the tags are who may answer, and an edit must
|
|
3524
|
+
// not leave a question nobody may answer (a 1:1 DM needs none).
|
|
3525
|
+
// Tagging yourself grants nothing — same rule as when asking.
|
|
3526
|
+
const tagged = [...`${question}\n${body}`.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].some((m) => m[1] !== self?.userId);
|
|
3527
|
+
const addressed = tagged || /<!(?:here|channel|everyone)/.test(`${question}\n${body}`) || t.channelId.startsWith("D");
|
|
3528
|
+
if (!addressed) {
|
|
3529
|
+
console.error(`✗ 誰にも宛てられていない質問になります — 回答できる人を @ で残してください`);
|
|
3530
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3531
|
+
}
|
|
3532
|
+
const lang = args.lang ? askResolveLang({ flag: args.lang })?.lang ?? old.lang : old.lang;
|
|
3533
|
+
const reactable = choices.slice(0, ASK_MAX_REACTION_CHOICES);
|
|
3534
|
+
const overflow = choices.slice(ASK_MAX_REACTION_CHOICES);
|
|
3535
|
+
const oldText = typeof t.msg.text === "string" ? t.msg.text : "";
|
|
3536
|
+
let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang);
|
|
3537
|
+
// An invalid-ballot notice still in force stays (it names the people who
|
|
3538
|
+
// have to fix their vote); it is only possible when the options did not change.
|
|
3539
|
+
const invalid = readInvalidNotice(oldText);
|
|
3540
|
+
if (invalid.length) next = applyInvalidNotice(next, invalid, lang);
|
|
3541
|
+
guardUrlBoundaries(next, args.allowUrlAdjacent);
|
|
3542
|
+
if (next === oldText) {
|
|
3543
|
+
console.error("変更はありません。");
|
|
3544
|
+
return;
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
const code = safetyCode("ask-edit", `${t.channelId}:${t.ts}`, next, self?.userId ?? "");
|
|
3548
|
+
if (args.code !== code) {
|
|
3549
|
+
const show = (label: string, a: string, b: string) => a === b
|
|
3550
|
+
? [` ${label}: (unchanged)`]
|
|
3551
|
+
: [` ${label}:`, ...a.split("\n").map((l) => ` - ${l}`), ...b.split("\n").map((l) => ` + ${l}`)];
|
|
3552
|
+
requireCode(args.code, code, [
|
|
3553
|
+
`--- Editing question --------------------------`,
|
|
3554
|
+
fromLine(self, { asBot: args.asBot }),
|
|
3555
|
+
` ${stripTerminalControls(args.link)}`,
|
|
3556
|
+
...show("Question", old.question, question),
|
|
3557
|
+
...show("Body", old.body, body),
|
|
3558
|
+
...(choicesChanged
|
|
3559
|
+
? [` Choices (nobody has answered yet — pills are re-seeded):`,
|
|
3560
|
+
...oldChoices.map((c, i) => ` - ${i + 1}. ${askFlatten(c)}`),
|
|
3561
|
+
...choices.map((c, i) => ` + ${i + 1}. ${askFlatten(c)}`)]
|
|
3562
|
+
: [` Choices: (unchanged)`]),
|
|
3563
|
+
...(lang !== old.lang ? [` Language: ${old.lang} → ${lang} (the instructions are rewritten)`] : []),
|
|
3564
|
+
`----------------------------------------------`,
|
|
3565
|
+
]);
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
await editMessage(token, t.channelId, t.ts, next, args.cookie, true);
|
|
3569
|
+
if (choicesChanged) {
|
|
3570
|
+
// Re-seed in order: 1..n then ❓ last, as a fresh question has them.
|
|
3571
|
+
for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
|
|
3572
|
+
try { await reactionRemove(token, t.channelId, t.ts, name, args.cookie); } catch { /* already gone */ }
|
|
3573
|
+
}
|
|
3574
|
+
await seedReactionsInOrder(
|
|
3575
|
+
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
|
|
3576
|
+
(name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
|
|
3577
|
+
(name, e) => console.error(` (リアクション ${name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`),
|
|
3578
|
+
);
|
|
3579
|
+
}
|
|
3580
|
+
console.error(`✓ 編集しました: ${stripTerminalControls(args.link)}`);
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
/** `slack ask --ls [--stale <dur>] [--state …] [--channel …] [--json]` — the
|
|
3584
|
+
* questions I posted, with their LIVE state.
|
|
3585
|
+
*
|
|
3586
|
+
* The list comes from the local sent-log (every `ask` is recorded there with
|
|
3587
|
+
* its channel, ts and identity, user or bot); the state comes from Slack, one
|
|
3588
|
+
* read per question, so a question answered, voided or edited elsewhere shows
|
|
3589
|
+
* as it is now. Limitation: only asks sent from THIS machine's log.
|
|
3590
|
+
*
|
|
3591
|
+
* `--stale 24h` = open and older than that — the morning triage's list. It
|
|
3592
|
+
* prints, on stderr, the one `--void=` command that would retire them all
|
|
3593
|
+
* (behind the usual gate), rather than voiding anything itself. */
|
|
3594
|
+
async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }, args: {
|
|
3595
|
+
stale?: string; state: string; channel?: string; json: boolean; limit: number;
|
|
3596
|
+
}): Promise<void> {
|
|
3597
|
+
const q: Parameters<typeof querySent>[0] = { kind: "ask", limit: args.limit };
|
|
3598
|
+
if (args.channel) q.channel = args.channel;
|
|
3599
|
+
const rows = querySent(q);
|
|
3600
|
+
if (!rows) {
|
|
3601
|
+
console.error(`Error: ${SENT_LOG_UNAVAILABLE}\n (${sentDbPath()})`);
|
|
3602
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3603
|
+
}
|
|
3604
|
+
let staleBefore: number | undefined;
|
|
3605
|
+
if (args.stale) {
|
|
3606
|
+
staleBefore = parseSince(args.stale);
|
|
3607
|
+
if (staleBefore === undefined) {
|
|
3608
|
+
console.error(`Error: --stale needs a duration like 24h / 3d (got ${stripTerminalControls(args.stale)})`);
|
|
3609
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
type Item = { state: string; age_h: number; asBot: boolean; channel: string; target: string; link: string; question: string; answer?: string };
|
|
3613
|
+
const items: Item[] = [];
|
|
3614
|
+
for (const r of rows) {
|
|
3615
|
+
if (staleBefore !== undefined && r.sent_at > staleBefore) continue;
|
|
3616
|
+
const token = r.as_bot ? tokens.bot : tokens.user;
|
|
3617
|
+
const link = r.permalink || `${r.channel}:${r.ts}`;
|
|
3618
|
+
let state = "unknown";
|
|
3619
|
+
let question = (r.text.split("\n")[0] ?? "").replace(/^:question: /, "");
|
|
3620
|
+
let answer: string | undefined;
|
|
3621
|
+
if (token) {
|
|
3622
|
+
try {
|
|
3623
|
+
const got = await askFetchByLink(token, `${r.channel}:${r.ts}`, r.as_bot ? undefined : tokens.cookie);
|
|
3624
|
+
if (!got?.msg) state = "gone";
|
|
3625
|
+
else {
|
|
3626
|
+
const p = askParseMessage(typeof got.msg.text === "string" ? got.msg.text : "");
|
|
3627
|
+
state = p.kind === "resolved" ? "answered" : p.kind === "other" ? "edited" : p.kind;
|
|
3628
|
+
if (p.kind !== "other" && p.question) question = p.question;
|
|
3629
|
+
if (p.kind === "resolved") answer = p.answer;
|
|
3630
|
+
}
|
|
3631
|
+
} catch {
|
|
3632
|
+
state = "unknown";
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
if (args.state !== "all" && state !== args.state) continue;
|
|
3636
|
+
const item: Item = {
|
|
3637
|
+
state, age_h: Math.round((Date.now() - r.sent_at) / 3600e3), asBot: !!r.as_bot,
|
|
3638
|
+
channel: r.channel, target: r.target ?? r.channel, link, question: askFlatten(question),
|
|
3639
|
+
};
|
|
3640
|
+
if (answer !== undefined) item.answer = answer;
|
|
3641
|
+
items.push(item);
|
|
3642
|
+
}
|
|
3643
|
+
if (args.json) {
|
|
3644
|
+
console.log(JSON.stringify(items));
|
|
3645
|
+
} else {
|
|
3646
|
+
for (const it of items) {
|
|
3647
|
+
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)}`);
|
|
3648
|
+
}
|
|
3649
|
+
console.error(`${items.length} question(s)${args.state === "all" ? "" : ` in state "${args.state}"`}${args.stale ? `, older than ${args.stale}` : ""}.`);
|
|
3650
|
+
}
|
|
3651
|
+
// The bulk path: one gated command per identity, never an automatic void.
|
|
3652
|
+
const open = items.filter((it) => it.state === "open");
|
|
3653
|
+
if (args.stale && open.length) {
|
|
3654
|
+
for (const bot of [false, true]) {
|
|
3655
|
+
const mine = open.filter((it) => it.asBot === bot);
|
|
3656
|
+
if (!mine.length) continue;
|
|
3657
|
+
console.error(`無効化するなら (${mine.length} 件, ${bot ? "bot" : "user"}):\n slack ask ${mine.map((it) => `--void='${it.link}'`).join(" ")} --reason 'stale'${bot ? " --as-bot" : ""}`);
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
|
|
3662
|
+
/** A question about to be voided: where it is, and what it parsed as. */
|
|
3663
|
+
interface AskVoidTarget {
|
|
3664
|
+
link: string;
|
|
3665
|
+
channelId: string;
|
|
3666
|
+
ts: string;
|
|
3667
|
+
parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>;
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
/** Void one question: the 🚫 text (body kept), our seeds and ❓ off, 🚫 on.
|
|
3671
|
+
* The text is what marks it void; the reactions are cleanup + searchability,
|
|
3672
|
+
* so a failure there is reported and does not undo the void. */
|
|
3673
|
+
async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, supersededBy: string | undefined, cookie?: string): Promise<boolean> {
|
|
3674
|
+
const shown = stripTerminalControls(t.link);
|
|
3675
|
+
try {
|
|
3676
|
+
const next = askBuildVoidText(t.parsed.question, reason, t.parsed.lang, t.parsed.body || undefined, supersededBy);
|
|
3677
|
+
await editMessage(token, t.channelId, t.ts, next, cookie, true);
|
|
3678
|
+
} catch (e: unknown) {
|
|
3679
|
+
console.error(`✗ ${shown}: 書き換えに失敗しました: ${e instanceof Error ? e.message : String(e)}`);
|
|
3680
|
+
return false;
|
|
3681
|
+
}
|
|
3682
|
+
for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
|
|
3683
|
+
try { await reactionRemove(token, t.channelId, t.ts, name, cookie); } catch { /* not ours / already gone */ }
|
|
3684
|
+
}
|
|
3685
|
+
try {
|
|
3686
|
+
await reactionAdd(token, t.channelId, t.ts, ASK_VOID_MARKER, cookie);
|
|
3687
|
+
} catch (e: unknown) {
|
|
3688
|
+
console.error(` (${shown}: 🚫 リアクションを付けられませんでした: ${e instanceof Error ? e.message : String(e)})`);
|
|
3689
|
+
}
|
|
3690
|
+
console.error(`✓ 無効化: ${shown}`);
|
|
3691
|
+
return true;
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
/** `slack ask --void <permalink>…` — 無効化: mark questions that have expired or
|
|
3695
|
+
* stopped meaning anything, so nobody answers them and nothing waits on them.
|
|
3696
|
+
*
|
|
3697
|
+
* Case, 2026-10-05: a release ask was pinned to one head SHA; three minutes
|
|
3698
|
+
* later main moved and approving it would have approved a different head. The
|
|
3699
|
+
* bot had to void it by hand (prefix the text, leave the pills), and nothing
|
|
3700
|
+
* machine-readable said so: `--waitFor` still treated it as open, the
|
|
3701
|
+
* stale-pill reminder still counted it, the pills were still pressable.
|
|
3702
|
+
*
|
|
3703
|
+
* What voiding does, per question: the text becomes the 🚫 form (head, void
|
|
3704
|
+
* stamp with the reason, optional pointer to the replacement, the body); our
|
|
3705
|
+
* seeded pills and the ❓ marker come off; a 🚫 reaction goes on. Pills someone
|
|
3706
|
+
* already pressed stay — Slack lets only their owner remove them — and are
|
|
3707
|
+
* never collected, because a void is never polled again.
|
|
3708
|
+
*
|
|
3709
|
+
* Only your OWN questions: Slack lets only the author edit a message, and
|
|
3710
|
+
* voiding someone else's decision is not a thing this should make possible.
|
|
3711
|
+
* An answered question is final and is refused; an already-void one is a
|
|
3712
|
+
* no-op. Several links at once — the morning triage found 38 stale pills —
|
|
3713
|
+
* all behind ONE confirm code covering every target and the reason. */
|
|
3714
|
+
async function cmdAskVoid(token: string, args: { links: string[]; reason: string; supersededBy?: string; code?: string; asBot: boolean; cookie?: string }): Promise<void> {
|
|
3715
|
+
const self = await selfIdentity(token, args.cookie);
|
|
3716
|
+
// The replacement link is written into every voided question for good, so it
|
|
3717
|
+
// must name a real message before anything is touched — a typo would point
|
|
3718
|
+
// people at nothing, permanently.
|
|
3719
|
+
if (args.supersededBy) {
|
|
3720
|
+
const shown = stripTerminalControls(args.supersededBy);
|
|
3721
|
+
let found = false;
|
|
3722
|
+
try {
|
|
3723
|
+
const rep = await askFetchByLink(token, args.supersededBy, args.cookie);
|
|
3724
|
+
found = !!rep?.msg;
|
|
3725
|
+
if (!rep) {
|
|
3726
|
+
console.error(`Error: --superseded-by must be a message permalink (or 'C…:1700000000.000100'): ${shown}`);
|
|
3727
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3728
|
+
}
|
|
3729
|
+
} catch (e: unknown) {
|
|
3730
|
+
console.error(`Error: --superseded-by を確認できません: ${shown} (${e instanceof Error ? e.message : String(e)})`);
|
|
3731
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3732
|
+
}
|
|
3733
|
+
if (!found) {
|
|
3734
|
+
console.error(`Error: --superseded-by のメッセージが見つかりません: ${shown}`);
|
|
3735
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; pressed: string[]; unreadable?: boolean };
|
|
3739
|
+
const targets: Target[] = [];
|
|
3740
|
+
let refused = 0;
|
|
3741
|
+
for (const link of args.links) {
|
|
3742
|
+
const shown = stripTerminalControls(link);
|
|
3743
|
+
const got = await askFetchByLink(token, link, args.cookie);
|
|
3744
|
+
if (!got) { console.error(`✗ ${shown}: permalink (または 'C…:1700000000.000100') ではありません`); refused++; continue; }
|
|
3745
|
+
if (!got.msg) { console.error(`✗ ${shown}: メッセージが見つかりません`); refused++; continue; }
|
|
3746
|
+
const msg = got.msg;
|
|
3747
|
+
const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
|
|
3748
|
+
if (!own) {
|
|
3749
|
+
console.error(`✗ ${shown}: 自分(${senderLabel(self, args.asBot)})が投稿した質問ではないので無効化できません` +
|
|
3750
|
+
`${args.asBot ? "" : " — bot が出した質問なら --as-bot で"}`);
|
|
3751
|
+
refused++;
|
|
3752
|
+
continue;
|
|
3753
|
+
}
|
|
3754
|
+
const text = typeof msg.text === "string" ? msg.text : "";
|
|
3755
|
+
let parsed = askParseMessage(text);
|
|
3756
|
+
let unreadable = false;
|
|
3757
|
+
if (parsed.kind === "void") { console.error(`- ${shown}: すでに無効化済みです(何もしません)`); continue; }
|
|
3758
|
+
if (parsed.kind === "resolved") { console.error(`✗ ${shown}: 回答済みの質問は無効化できません(回答: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})`); refused++; continue; }
|
|
3759
|
+
if (parsed.kind !== "open") {
|
|
3760
|
+
// Our own ❓ question whose layout no longer parses (2026-10-01: a
|
|
3761
|
+
// `slack edit` flattened one; it could then be neither collected nor
|
|
3762
|
+
// voided, and stayed ❓ forever). Voiding is exactly what is left to do
|
|
3763
|
+
// with it, so it is voided from a best-effort read — question and body
|
|
3764
|
+
// kept as they are, our own seeded pills removed — and the gate says so.
|
|
3765
|
+
const salvage = askSalvageUnreadable(text);
|
|
3766
|
+
if (!salvage) { console.error(`✗ ${shown}: \`slack ask\` の質問として読み取れません — ${askExplainReject(text)}`); refused++; continue; }
|
|
3767
|
+
const seeded = asArray(msg.reactions).map(asRecord)
|
|
3768
|
+
.filter((r) => asArray(r.users).includes(self?.userId ?? "\u0000"))
|
|
3769
|
+
.map((r) => ASK_KEYCAPS.findIndex((k) => k.name === r.name))
|
|
3770
|
+
.filter((i) => i >= 0);
|
|
3771
|
+
parsed = {
|
|
3772
|
+
kind: "open", question: salvage.question, body: salvage.body,
|
|
3773
|
+
// Placeholders: only the COUNT is used — which of our keycap seeds to take off.
|
|
3774
|
+
reactable: Array.from({ length: seeded.length ? Math.max(...seeded) + 1 : 0 }, () => ""),
|
|
3775
|
+
overflow: [], threadOnly: true, lang: askDetectLang(text) ?? "ja",
|
|
3776
|
+
};
|
|
3777
|
+
unreadable = true;
|
|
3778
|
+
}
|
|
3779
|
+
const pressed = asArray(msg.reactions).map(asRecord)
|
|
3780
|
+
.filter((r) => typeof r.name === "string" && ASK_KEYCAPS.some((k) => k.name === r.name))
|
|
3781
|
+
.filter((r) => asArray(r.users).some((u) => u !== self?.userId))
|
|
3782
|
+
.map((r) => `:${String(r.name)}:`);
|
|
3783
|
+
targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, pressed, ...(unreadable ? { unreadable } : {}) });
|
|
3784
|
+
}
|
|
3785
|
+
if (!targets.length) {
|
|
3786
|
+
console.error(refused ? "無効化できる質問がありません。" : "何もすることがありません。");
|
|
3787
|
+
process.exit(refused ? ASK_EXIT_ERROR : 0);
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
const code = safetyCode("ask-void", ...targets.map((t) => `${t.channelId}:${t.ts}`), args.reason, args.supersededBy ?? "", self?.userId ?? "");
|
|
3791
|
+
if (args.code !== code) {
|
|
3792
|
+
requireCode(args.code, code, [
|
|
3793
|
+
`--- 無効化 (void) -------------------------------`,
|
|
3794
|
+
fromLine(self, { asBot: args.asBot }),
|
|
3795
|
+
` Reason: ${args.reason ? stripTerminalControls(args.reason) : "(none)"}`,
|
|
3796
|
+
...(args.supersededBy ? [` Replaced: ${stripTerminalControls(args.supersededBy)}`] : []),
|
|
3797
|
+
` ${targets.length} question(s):`,
|
|
3798
|
+
...targets.map((t) => ` • ${stripTerminalControls(t.link)}\n ${stripTerminalControls(t.parsed.question.split("\n")[0] ?? "").slice(0, 100)}` +
|
|
3799
|
+
(t.pressed.length ? `\n (already pressed: ${t.pressed.join(" ")} — left in place, never collected)` : "") +
|
|
3800
|
+
(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` : "")),
|
|
3801
|
+
` Each becomes 🚫 (body kept, options and instructions removed); our pills and ❓ come off; a 🚫 reaction is added.`,
|
|
3802
|
+
...(refused ? [` (${refused} link(s) above were refused and will be skipped)`] : []),
|
|
3803
|
+
`----------------------------------------------`,
|
|
3804
|
+
]);
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
let failed = 0;
|
|
3808
|
+
for (const t of targets) {
|
|
3809
|
+
if (!(await askApplyVoid(token, t, args.reason, args.supersededBy, args.cookie))) failed++;
|
|
3810
|
+
}
|
|
3811
|
+
process.exit(failed || refused ? ASK_EXIT_ERROR : 0);
|
|
3812
|
+
}
|
|
3813
|
+
|
|
3064
3814
|
/** `slack ask --waitFor <permalink>` — collect the answer to a question that was
|
|
3065
3815
|
* posted without blocking.
|
|
3066
3816
|
*
|
|
@@ -3069,7 +3819,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3069
3819
|
* file to go stale, lose, or disagree with Slack, and any machine holding the
|
|
3070
3820
|
* permalink can collect — at the cost of the body being a parseable format
|
|
3071
3821
|
* (`askBuildText` ⇄ `askParseMessage`). */
|
|
3072
|
-
async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; cookie?: string; after?: string }): Promise<void> {
|
|
3822
|
+
async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string }): Promise<void> {
|
|
3073
3823
|
const url = parseSlackPermalink(args.link);
|
|
3074
3824
|
let channelId: string;
|
|
3075
3825
|
let ts: string;
|
|
@@ -3121,13 +3871,6 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3121
3871
|
);
|
|
3122
3872
|
process.exit(ASK_EXIT_ERROR);
|
|
3123
3873
|
}
|
|
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
3874
|
|
|
3132
3875
|
// Who may answer, recovered from the tags the readers can see. They are
|
|
3133
3876
|
// already `<@U…>`-encoded in the stored text, so no directory lookup is
|
|
@@ -3146,6 +3889,64 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3146
3889
|
const counterpart = await imCounterpart(token, channelId, args.cookie);
|
|
3147
3890
|
if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
|
|
3148
3891
|
}
|
|
3892
|
+
if (parsed.kind === "void") {
|
|
3893
|
+
askReportVoid(parsed, args.link);
|
|
3894
|
+
if (args.json) {
|
|
3895
|
+
await askEmit(token, { json: true, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
|
|
3896
|
+
status: "void", answer: null, reason: parsed.reason,
|
|
3897
|
+
...(parsed.supersededBy ? { supersededBy: parsed.supersededBy } : {}),
|
|
3898
|
+
notes: [], cursor: askNextCursor(ts, args.after),
|
|
3899
|
+
});
|
|
3900
|
+
}
|
|
3901
|
+
process.exit(ASK_EXIT_VOID);
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3904
|
+
if (parsed.kind === "resolved") {
|
|
3905
|
+
// Answered while nobody was watching — the case that makes fire-and-forget
|
|
3906
|
+
// safe. Report it exactly as `--wait` would have, plus any notes the
|
|
3907
|
+
// audience left in the thread AFTER it was answered: that is the note that
|
|
3908
|
+
// was lost in real use (2026-10-02), written 40 s after ✅.
|
|
3909
|
+
//
|
|
3910
|
+
// There is NO default cursor here. The ✅ edit time looks like one, but it
|
|
3911
|
+
// would drop every note written before ✅ whenever the run that wrote ✅ did
|
|
3912
|
+
// not deliver them: a crash between the edit and the output, or — the case
|
|
3913
|
+
// that matters — any question resolved by a build that did not collect
|
|
3914
|
+
// notes at all. A caller that wants no repeats passes the cursor back.
|
|
3915
|
+
//
|
|
3916
|
+
// What must not come back is the reply that IS the answer. The ✅ stamp says
|
|
3917
|
+
// whether the answer was a reply, and which number it picked: the first
|
|
3918
|
+
// audience reply before the ✅ edit that reads as that answer is it.
|
|
3919
|
+
console.error(`✓ 回答済み: ${stripTerminalControls(parsed.question)}`);
|
|
3920
|
+
const editedTs = typeof asRecord(msg.edited).ts === "string" ? String(asRecord(msg.edited).ts) : undefined;
|
|
3921
|
+
const how = askResolvedHow(text);
|
|
3922
|
+
let answerSkipped = false;
|
|
3923
|
+
const isAnswerReply = (m: Record<string, Json>): boolean => {
|
|
3924
|
+
if (answerSkipped || !how.byReply) return false;
|
|
3925
|
+
if (editedTs && Number(m.ts) > Number(editedTs)) return false;
|
|
3926
|
+
const t = typeof m.text === "string" ? m.text.trim() : "";
|
|
3927
|
+
const hit = t === parsed.answer || (how.n !== undefined && (new RegExp(`^[((]?${how.n}(?![0-9])`).test(t) || askReplyPicked(t, parsed.answer, how.n)));
|
|
3928
|
+
if (hit) answerSkipped = true;
|
|
3929
|
+
return hit;
|
|
3930
|
+
};
|
|
3931
|
+
const after = args.after;
|
|
3932
|
+
let notes: AskNote[] = [];
|
|
3933
|
+
if (audience.size || broadcast) {
|
|
3934
|
+
const isAnswerer = (user: unknown): user is string =>
|
|
3935
|
+
typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
|
|
3936
|
+
notes = await askCollectNotes(token, {
|
|
3937
|
+
channelId, ts, threadOnly: !!threadTs || !channelId.startsWith("D"),
|
|
3938
|
+
replyCount: Number(msg.reply_count) || 0, isAnswerer, askerUserId, askerBotId,
|
|
3939
|
+
...(threadTs ? { threadParentTs: threadTs } : {}),
|
|
3940
|
+
...(args.cookie ? { cookie: args.cookie } : {}),
|
|
3941
|
+
}, after, isAnswerReply);
|
|
3942
|
+
}
|
|
3943
|
+
await askEmit(token, { json: args.json, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
|
|
3944
|
+
status: "answered", answer: parsed.answer, notes,
|
|
3945
|
+
cursor: askNextCursor(ts, after, ...notes.map((n) => n.ts)),
|
|
3946
|
+
});
|
|
3947
|
+
return;
|
|
3948
|
+
}
|
|
3949
|
+
|
|
3149
3950
|
if (!audience.size && !broadcast) {
|
|
3150
3951
|
console.error(`Error: この質問は誰にも宛てられていないため、有効な回答者を判定できません: ${stripTerminalControls(args.link)}`);
|
|
3151
3952
|
process.exit(ASK_EXIT_ERROR);
|
|
@@ -3161,6 +3962,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3161
3962
|
reactable: parsed.reactable,
|
|
3162
3963
|
overflow: parsed.overflow,
|
|
3163
3964
|
threadOnly: parsed.threadOnly,
|
|
3965
|
+
lang: parsed.lang,
|
|
3164
3966
|
audience,
|
|
3165
3967
|
broadcast,
|
|
3166
3968
|
askerUserId,
|
|
@@ -3168,6 +3970,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3168
3970
|
timeout: args.timeout,
|
|
3169
3971
|
shown: args.link,
|
|
3170
3972
|
asBot: args.asBot,
|
|
3973
|
+
json: args.json,
|
|
3171
3974
|
};
|
|
3172
3975
|
if (threadTs) ctx.threadParentTs = threadTs;
|
|
3173
3976
|
if (args.cookie) ctx.cookie = args.cookie;
|
|
@@ -3319,7 +4122,8 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
|
|
|
3319
4122
|
const attr = sentAttribution("poll");
|
|
3320
4123
|
const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
|
|
3321
4124
|
|
|
3322
|
-
// Marker first
|
|
4125
|
+
// Marker first. (`ask` seeds its marker LAST, because there it doubles as the
|
|
4126
|
+
// "other" choice; a poll has no such choice.) It is what makes
|
|
3323
4127
|
// `has::ballot_box_with_ballot:` list every poll. An unrelated reaction on the message is
|
|
3324
4128
|
// already ignored by the tally (it counts keycap names only), so this costs
|
|
3325
4129
|
// the ballot nothing.
|
|
@@ -4176,7 +4980,22 @@ async function main(): Promise<void> {
|
|
|
4176
4980
|
// `sent` reads only the local log — no token, no network.
|
|
4177
4981
|
if (!cmd || cmd === "auth" || cmd === "login" || cmd === "agent" || cmd === "sent") return;
|
|
4178
4982
|
try {
|
|
4179
|
-
resolveToken((argv as W).workspace);
|
|
4983
|
+
const active = resolveToken((argv as W).workspace);
|
|
4984
|
+
const bot = resolveBotToken();
|
|
4985
|
+
const refusal = botIntentRefusal(argv._ ?? [], argv as Record<string, unknown>, {
|
|
4986
|
+
botTokenSource: bot
|
|
4987
|
+
? dotenvSource.get("SLACK_BOT_TOKEN")
|
|
4988
|
+
?? globalDotenvValue.get("SLACK_BOT_TOKEN")?.find((g) => g.val === bot)?.path
|
|
4989
|
+
?? "shell"
|
|
4990
|
+
: undefined,
|
|
4991
|
+
globalPaths: GLOBAL_DOTENVS,
|
|
4992
|
+
slackTokenSet: !!process.env.SLACK_TOKEN,
|
|
4993
|
+
activeTokenIsBot: !!bot && active === bot,
|
|
4994
|
+
});
|
|
4995
|
+
if (refusal) {
|
|
4996
|
+
console.error(refusal);
|
|
4997
|
+
process.exit(2);
|
|
4998
|
+
}
|
|
4180
4999
|
} catch (e) {
|
|
4181
5000
|
const msg = e instanceof Error ? e.message : String(e);
|
|
4182
5001
|
if (msg.startsWith("No profiles configured")) {
|
|
@@ -4686,7 +5505,7 @@ async function main(): Promise<void> {
|
|
|
4686
5505
|
)
|
|
4687
5506
|
.command(
|
|
4688
5507
|
"ask [target] [question] [choices..]",
|
|
4689
|
-
"Ask a question with its choices pre-seeded as 1️⃣..🔟 reactions (confirm-hash safety gate)",
|
|
5508
|
+
"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
5509
|
(y) => y
|
|
4691
5510
|
.positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --waitFor)" })
|
|
4692
5511
|
.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 +5513,176 @@ async function main(): Promise<void> {
|
|
|
4694
5513
|
.option("code", { type: "string", describe: "Safety hash to confirm the ask" })
|
|
4695
5514
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
4696
5515
|
.option("body", { type: "string", describe: "Extra context shown under the question" })
|
|
4697
|
-
.option("
|
|
5516
|
+
.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" })
|
|
5517
|
+
.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
5518
|
.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:
|
|
5519
|
+
.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." })
|
|
5520
|
+
.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…>`." })
|
|
5521
|
+
.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." })
|
|
5522
|
+
.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." })
|
|
5523
|
+
.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." })
|
|
5524
|
+
.option("stale", { type: "string", describe: "With --ls: only open questions older than this (24h, 3d)" })
|
|
5525
|
+
.option("state", { type: "string", default: "open", choices: ["open", "answered", "void", "edited", "gone", "unknown", "all"], describe: "With --ls: which state to list" })
|
|
5526
|
+
.option("channel", { type: "string", describe: "With --ls: only this channel (id, or the target as typed)" })
|
|
5527
|
+
.option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
|
|
5528
|
+
.option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
|
|
5529
|
+
.option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
|
|
5530
|
+
.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
5531
|
.option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
|
|
4701
5532
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
4702
5533
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
4703
5534
|
.option("as-bot", { type: "boolean", default: false, describe: "Ask via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient" }),
|
|
4704
5535
|
async (argv) => {
|
|
5536
|
+
// Verbs. A target is always #chan / @user / a permalink / a raw ID, so a
|
|
5537
|
+
// bare word here cannot be one — `slack ask void …` is unambiguous.
|
|
5538
|
+
// slack ask void <link…> [--reason …] [--superseded-by <link>] 無効化
|
|
5539
|
+
// slack ask collect <link> = --waitFor
|
|
5540
|
+
// Lifecycle flags (maintainer: "maybe slack ask --edit=<target>"), primary;
|
|
5541
|
+
// the bare verbs `void` / `collect` stay as aliases.
|
|
5542
|
+
if (argv.ls) {
|
|
5543
|
+
const user = (() => { try { return tok(argv as W); } catch { return undefined; } })();
|
|
5544
|
+
const tokens: { user?: string; bot?: string; cookie?: string } = {};
|
|
5545
|
+
if (user) tokens.user = user;
|
|
5546
|
+
const bot = resolveBotToken();
|
|
5547
|
+
if (bot) tokens.bot = bot;
|
|
5548
|
+
const c = ck(argv as W);
|
|
5549
|
+
if (c) tokens.cookie = c;
|
|
5550
|
+
const a: { stale?: string; state: string; channel?: string; json: boolean; limit: number } = {
|
|
5551
|
+
state: String(argv.state ?? "open"), json: !!argv.json, limit: Number(argv.limit) || 50,
|
|
5552
|
+
};
|
|
5553
|
+
if (argv.stale) a.stale = String(argv.stale);
|
|
5554
|
+
if (argv.channel) a.channel = String(argv.channel);
|
|
5555
|
+
await cmdAskLs(tokens, a);
|
|
5556
|
+
return;
|
|
5557
|
+
}
|
|
5558
|
+
const askTokenFor = (asBot: boolean): { token: string; cookie?: string } => {
|
|
5559
|
+
if (!asBot) {
|
|
5560
|
+
const c = ck(argv as W);
|
|
5561
|
+
return c ? { token: tok(argv as W), cookie: c } : { token: tok(argv as W) };
|
|
5562
|
+
}
|
|
5563
|
+
const botToken = resolveBotToken();
|
|
5564
|
+
if (!botToken) {
|
|
5565
|
+
console.error(
|
|
5566
|
+
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
5567
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
5568
|
+
);
|
|
5569
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5570
|
+
}
|
|
5571
|
+
return { token: botToken };
|
|
5572
|
+
};
|
|
5573
|
+
// Positionals after a lifecycle flag are the NEW question and choices.
|
|
5574
|
+
const newQuestion = argv.target !== undefined && argv.target !== "void" && argv.target !== "collect" ? String(argv.target) : undefined;
|
|
5575
|
+
const newChoices = [argv.question, ...((argv.choices as string[] | undefined) ?? [])].filter((x) => x !== undefined).map(String);
|
|
5576
|
+
if (argv.edit) {
|
|
5577
|
+
if (argv.void || argv.wait || argv.waitFor) {
|
|
5578
|
+
console.error("Error: --edit changes one open question — do not combine it with --void/--wait/--waitFor.");
|
|
5579
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5580
|
+
}
|
|
5581
|
+
const { token, cookie } = askTokenFor(!!argv["as-bot"]);
|
|
5582
|
+
const a: Parameters<typeof cmdAskEdit>[1] = {
|
|
5583
|
+
link: String(argv.edit), asBot: !!argv["as-bot"], mentionToken: tok(argv as W),
|
|
5584
|
+
allowUrlAdjacent: !!argv["allow-url-adjacent"],
|
|
5585
|
+
};
|
|
5586
|
+
if (newQuestion !== undefined) a.question = newQuestion;
|
|
5587
|
+
if (newChoices.length) a.choices = newChoices.map((x) => x.trim()).filter(Boolean);
|
|
5588
|
+
if (argv.body !== undefined) a.body = String(argv.body);
|
|
5589
|
+
if (argv.lang) a.lang = String(argv.lang);
|
|
5590
|
+
if (argv.code) a.code = String(argv.code);
|
|
5591
|
+
if (cookie) a.cookie = cookie;
|
|
5592
|
+
const mc = ck(argv as W);
|
|
5593
|
+
if (mc) a.mentionCookie = mc;
|
|
5594
|
+
try {
|
|
5595
|
+
await cmdAskEdit(token, a);
|
|
5596
|
+
} catch (e: unknown) {
|
|
5597
|
+
console.error(friendlySlackError(e));
|
|
5598
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5599
|
+
}
|
|
5600
|
+
return;
|
|
5601
|
+
}
|
|
5602
|
+
// --void=<link> --reask: re-ask through the CREATE path below (same
|
|
5603
|
+
// gate, same audience rules), voiding the old question once it is up.
|
|
5604
|
+
let voidAfter: AskArgs["voidAfter"];
|
|
5605
|
+
const voidFlag = ((argv.void as string[] | undefined) ?? []).map(String).filter(Boolean);
|
|
5606
|
+
if (argv.reask) {
|
|
5607
|
+
if (voidFlag.length !== 1 || argv.target === "void") {
|
|
5608
|
+
console.error("Error: --reask goes with exactly one --void=<permalink> (the question to replace).");
|
|
5609
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5610
|
+
}
|
|
5611
|
+
if (argv.waitFor) {
|
|
5612
|
+
console.error("Error: --reask posts a new question — --wait works, --waitFor does not.");
|
|
5613
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5614
|
+
}
|
|
5615
|
+
const { token, cookie } = askTokenFor(!!argv["as-bot"]);
|
|
5616
|
+
const self = await selfIdentity(token, cookie);
|
|
5617
|
+
const t = await askLoadOwnOpen(token, voidFlag[0]!, self, !!argv["as-bot"], cookie);
|
|
5618
|
+
if (t === "void") { console.error(`✗ ${stripTerminalControls(voidFlag[0]!)}: すでに無効化済みです — 新しく聞くなら普通に slack ask で`); process.exit(ASK_EXIT_ERROR); }
|
|
5619
|
+
if (!t) process.exit(ASK_EXIT_ERROR);
|
|
5620
|
+
voidAfter = { ...t, reason: unescapeArg(String(argv.reason ?? "")).trim() };
|
|
5621
|
+
// Same place, same words, unless new ones were given. The create path
|
|
5622
|
+
// below unescapes its arguments, so stored text goes in escaped: a
|
|
5623
|
+
// literal `\n` in the old question must not turn into a line break.
|
|
5624
|
+
argv.target = t.threadTs ? `${t.channelId}:${t.threadTs}` : t.channelId;
|
|
5625
|
+
argv["channel-id"] = t.channelId;
|
|
5626
|
+
argv.question = newQuestion ?? escapeArg(t.parsed.question);
|
|
5627
|
+
argv.choices = newChoices.length ? newChoices : [...t.parsed.reactable, ...t.parsed.overflow].map(escapeArg);
|
|
5628
|
+
if (argv.body === undefined && t.parsed.body) argv.body = escapeArg(t.parsed.body);
|
|
5629
|
+
if (!argv.lang) argv.lang = t.parsed.lang;
|
|
5630
|
+
argv.void = undefined;
|
|
5631
|
+
}
|
|
5632
|
+
if (!voidAfter && (argv.target === "void" || voidFlag.length)) {
|
|
5633
|
+
const verbLinks = argv.target === "void" ? [argv.question, ...((argv.choices as string[] | undefined) ?? [])] : [];
|
|
5634
|
+
if (argv.target !== "void" && (argv.target || argv.question || (argv.choices as string[] | undefined)?.length)) {
|
|
5635
|
+
console.error("Error: --void voids existing questions — do not also pass a target/question/choices.");
|
|
5636
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5637
|
+
}
|
|
5638
|
+
const links = [...voidFlag, ...verbLinks.map((x) => String(x ?? ""))].filter(Boolean);
|
|
5639
|
+
if (!links.length) {
|
|
5640
|
+
console.error("Error: --void=<permalink> — which question(s)?");
|
|
5641
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5642
|
+
}
|
|
5643
|
+
if (argv.wait || argv.waitFor) {
|
|
5644
|
+
console.error("Error: --void voids existing questions — do not also pass --wait/--waitFor.");
|
|
5645
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5646
|
+
}
|
|
5647
|
+
let voidToken: string;
|
|
5648
|
+
let voidCookie: string | undefined;
|
|
5649
|
+
if (argv["as-bot"]) {
|
|
5650
|
+
const botToken = resolveBotToken();
|
|
5651
|
+
if (!botToken) {
|
|
5652
|
+
console.error(
|
|
5653
|
+
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
5654
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
5655
|
+
);
|
|
5656
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5657
|
+
}
|
|
5658
|
+
voidToken = botToken;
|
|
5659
|
+
} else {
|
|
5660
|
+
voidToken = tok(argv as W);
|
|
5661
|
+
voidCookie = ck(argv as W);
|
|
5662
|
+
}
|
|
5663
|
+
const a: { links: string[]; reason: string; supersededBy?: string; code?: string; asBot: boolean; cookie?: string } = {
|
|
5664
|
+
links, reason: unescapeArg(String(argv.reason ?? "")).trim(), asBot: !!argv["as-bot"],
|
|
5665
|
+
};
|
|
5666
|
+
if (argv["superseded-by"]) a.supersededBy = String(argv["superseded-by"]);
|
|
5667
|
+
if (argv.code) a.code = String(argv.code);
|
|
5668
|
+
if (voidCookie) a.cookie = voidCookie;
|
|
5669
|
+
try {
|
|
5670
|
+
await cmdAskVoid(voidToken, a);
|
|
5671
|
+
} catch (e: unknown) {
|
|
5672
|
+
console.error(friendlySlackError(e));
|
|
5673
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5674
|
+
}
|
|
5675
|
+
return;
|
|
5676
|
+
}
|
|
5677
|
+
if (argv.target === "collect") {
|
|
5678
|
+
if (!argv.question || (argv.choices as string[] | undefined)?.length) {
|
|
5679
|
+
console.error("Error: slack ask collect <permalink> — exactly one question.");
|
|
5680
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5681
|
+
}
|
|
5682
|
+
argv.waitFor = String(argv.question);
|
|
5683
|
+
argv.target = undefined;
|
|
5684
|
+
argv.question = undefined;
|
|
5685
|
+
}
|
|
4705
5686
|
const waitFor = argv.waitFor ? String(argv.waitFor) : "";
|
|
4706
5687
|
const timeout = Number(argv.timeout);
|
|
4707
5688
|
// 0 is a real value only for --waitFor ("look once"). For --wait it would
|
|
@@ -4735,7 +5716,7 @@ async function main(): Promise<void> {
|
|
|
4735
5716
|
waitCookie = ck(argv as W);
|
|
4736
5717
|
}
|
|
4737
5718
|
try {
|
|
4738
|
-
const a: { link: string; timeout: number; asBot: boolean; cookie?: string; after?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"] };
|
|
5719
|
+
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
5720
|
if (waitCookie) a.cookie = waitCookie;
|
|
4740
5721
|
if (argv.after) {
|
|
4741
5722
|
const after = String(argv.after);
|
|
@@ -4770,7 +5751,10 @@ async function main(): Promise<void> {
|
|
|
4770
5751
|
};
|
|
4771
5752
|
if (argv.code) args.code = argv.code;
|
|
4772
5753
|
if (argv.body) args.body = argv.body;
|
|
5754
|
+
if (argv.lang) args.lang = String(argv.lang);
|
|
4773
5755
|
if (argv.wait) args.wait = true;
|
|
5756
|
+
if (argv.json) args.json = true;
|
|
5757
|
+
if (voidAfter) args.voidAfter = voidAfter;
|
|
4774
5758
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
4775
5759
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
4776
5760
|
// Always resolve @tags with the user token (it has users:read), even
|
|
@@ -5144,12 +6128,14 @@ async function main(): Promise<void> {
|
|
|
5144
6128
|
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
|
|
5145
6129
|
.positional("newText", { type: "string", demandOption: true })
|
|
5146
6130
|
.option("code", { type: "string", describe: "Safety hash to confirm edit" })
|
|
6131
|
+
.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
6132
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
5148
6133
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
5149
6134
|
.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
6135
|
.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
6136
|
async (argv) => {
|
|
5152
6137
|
const args: EditArgs = { target: argv.target!, newText: argv.newText!, allowUrlAdjacent: argv["allow-url-adjacent"] };
|
|
6138
|
+
if (argv.force) args.force = true;
|
|
5153
6139
|
if (argv.code) args.code = argv.code;
|
|
5154
6140
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
5155
6141
|
if (argv.mentions !== false) args.mentions = true;
|