slack-term 1.39.2 → 1.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -4
- package/SKILL.md +66 -2
- package/dist/cli.js +227 -185
- package/package.json +1 -1
- package/ts/ask.ts +377 -45
- package/ts/cli.ts +1197 -38
- package/ts/slack.ts +68 -11
- package/ts/slackTodos.ts +85 -0
- package/ts/urlGuard.ts +162 -10
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,
|
|
@@ -50,6 +61,7 @@ import {
|
|
|
50
61
|
authTestSession,
|
|
51
62
|
conversationInfoSession,
|
|
52
63
|
createChannel,
|
|
64
|
+
renameChannel,
|
|
53
65
|
inviteToChannel,
|
|
54
66
|
createDraft,
|
|
55
67
|
deleteDraft,
|
|
@@ -73,6 +85,7 @@ import {
|
|
|
73
85
|
listDrafts,
|
|
74
86
|
listUsers,
|
|
75
87
|
userInfo,
|
|
88
|
+
userLocale,
|
|
76
89
|
conversationInfo,
|
|
77
90
|
openDm,
|
|
78
91
|
parseSlackPermalink,
|
|
@@ -95,7 +108,9 @@ import {
|
|
|
95
108
|
userName,
|
|
96
109
|
getPath,
|
|
97
110
|
type Json,
|
|
111
|
+
savedList,
|
|
98
112
|
} from "./slack.ts";
|
|
113
|
+
import { isDone, messagePermalink, parseSavedItems, sortTodos, TODO_FILTERS, todoStatus, type TodoFilter, type TodoItem } from "./slackTodos.ts";
|
|
99
114
|
import {
|
|
100
115
|
buildTodoQuery,
|
|
101
116
|
loadTodoConfig,
|
|
@@ -759,6 +774,8 @@ function blocksToText(blocks: Json[]): string {
|
|
|
759
774
|
if (node && typeof node === "object") {
|
|
760
775
|
const obj = node as Record<string, Json>;
|
|
761
776
|
if (typeof obj.text === "string") { parts.push(obj.text); return; }
|
|
777
|
+
// A bare `link` element carries only its url — which IS its visible text.
|
|
778
|
+
if (obj.type === "link" && typeof obj.url === "string") { parts.push(obj.url); return; }
|
|
762
779
|
if (Array.isArray(obj.elements)) walk(obj.elements);
|
|
763
780
|
else if (Array.isArray(obj.content)) walk(obj.content);
|
|
764
781
|
}
|
|
@@ -841,6 +858,65 @@ async function cmdDrafts(token: string, cookie?: string, showAll = false): Promi
|
|
|
841
858
|
}
|
|
842
859
|
}
|
|
843
860
|
|
|
861
|
+
// --- todos (Slack's To-dos tab) ---
|
|
862
|
+
// Read-only. Pages through saved.list up to `limit` items, then shows each
|
|
863
|
+
// to-do with its due date, where it lives, and the message it points at.
|
|
864
|
+
async function cmdTodos(
|
|
865
|
+
token: string,
|
|
866
|
+
cookie: string | undefined,
|
|
867
|
+
opts: { filter: TodoFilter; limit: number; json: boolean },
|
|
868
|
+
): Promise<void> {
|
|
869
|
+
const items: TodoItem[] = [];
|
|
870
|
+
let cursor: string | undefined;
|
|
871
|
+
do {
|
|
872
|
+
const resp = asRecord((await savedList(token, { filter: opts.filter, limit: Math.min(opts.limit, 100), cursor }, cookie)) as Json);
|
|
873
|
+
items.push(...parseSavedItems(resp as Json));
|
|
874
|
+
const next = asRecord(resp.response_metadata).next_cursor;
|
|
875
|
+
cursor = typeof next === "string" && next ? next : undefined;
|
|
876
|
+
} while (cursor && items.length < opts.limit);
|
|
877
|
+
const shown = sortTodos(items).slice(0, opts.limit);
|
|
878
|
+
|
|
879
|
+
if (shown.length === 0) {
|
|
880
|
+
// --json is JSONL on stdout: an empty list is no lines, the note goes to stderr.
|
|
881
|
+
(opts.json ? console.error : console.log)(`No ${opts.filter === "todo" ? "to-dos" : `${opts.filter} items`}.`);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
let teamUrl = "";
|
|
886
|
+
let myUserId = "";
|
|
887
|
+
try { ({ url: teamUrl, userId: myUserId } = await authScopes(token, cookie)); } catch { /* links degrade to channel:ts */ }
|
|
888
|
+
const resolveChLabel = await buildChLabelResolver(token, cookie, myUserId);
|
|
889
|
+
const now = Date.now() / 1000;
|
|
890
|
+
|
|
891
|
+
for (const t of shown) {
|
|
892
|
+
// The message a to-do points at. conversations.replies on its own ts
|
|
893
|
+
// returns it whether or not it has a thread; a thread reply comes back
|
|
894
|
+
// inside its thread, so match by ts. Fail-soft: an unreadable message
|
|
895
|
+
// still lists, with its link.
|
|
896
|
+
let msg: Record<string, Json> | undefined;
|
|
897
|
+
if (t.channel && t.ts) {
|
|
898
|
+
try {
|
|
899
|
+
const r = asRecord((await replies(token, t.channel, t.ts, 1, cookie)) as Json);
|
|
900
|
+
msg = asArray(r.messages).map(asRecord).find((m) => m.ts === t.ts);
|
|
901
|
+
} catch { /* not readable */ }
|
|
902
|
+
}
|
|
903
|
+
const link = t.channel && t.ts
|
|
904
|
+
? (teamUrl ? messagePermalink(teamUrl, t.channel, t.ts) : `${t.channel}:${t.ts}`)
|
|
905
|
+
: "";
|
|
906
|
+
if (opts.json) {
|
|
907
|
+
console.log(JSON.stringify({ ...t, done: isDone(t), permalink: link, user: msg?.user ?? null, text: msg?.text ?? null }));
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
const where = t.channel ? await resolveChLabel(t.channel) : `(${t.itemType || "item"})`;
|
|
911
|
+
const author = typeof msg?.user === "string" ? `@${await userName(token, msg.user, cookie)}: ` : "";
|
|
912
|
+
const raw = typeof msg?.text === "string" ? msg.text.split("\n")[0] ?? "" : "(message not readable)";
|
|
913
|
+
const head = raw.length > 100 ? `${raw.slice(0, 100)}…` : raw;
|
|
914
|
+
console.log(`${todoStatus(t, now, formatYmdHm)} ${stripTerminalControls(where)}`);
|
|
915
|
+
console.log(` ${stripTerminalControls(author + head)}`);
|
|
916
|
+
if (link) console.log(` ${link}`);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
844
920
|
// --- drafts get ---
|
|
845
921
|
async function cmdDraftGet(token: string, cookie: string | undefined, draftId: string): Promise<void> {
|
|
846
922
|
const resp = (await listDrafts(token, cookie)) as Record<string, Json>;
|
|
@@ -904,6 +980,7 @@ const SCOPE_FOR_METHOD: Record<string, string> = {
|
|
|
904
980
|
"slackLists.items.update": "lists:write",
|
|
905
981
|
"slackLists.items.list": "lists:read",
|
|
906
982
|
"slackLists.access.set": "lists:write",
|
|
983
|
+
"conversations.rename": "channels:manage (or groups:write for a private channel)",
|
|
907
984
|
"conversations.replies": "channels:history (or groups:history)",
|
|
908
985
|
"conversations.history": "channels:history (or groups:history)",
|
|
909
986
|
};
|
|
@@ -1226,6 +1303,8 @@ function sumCounts(m: Map<string, number>): number {
|
|
|
1226
1303
|
// --- edit ---
|
|
1227
1304
|
interface EditArgs {
|
|
1228
1305
|
allowUrlAdjacent?: boolean;
|
|
1306
|
+
/** Edit an `ask`/`poll` message anyway. Without it, `edit` refuses one. */
|
|
1307
|
+
force?: boolean;
|
|
1229
1308
|
target: string;
|
|
1230
1309
|
newText: string;
|
|
1231
1310
|
code?: string;
|
|
@@ -1320,14 +1399,47 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
1320
1399
|
|
|
1321
1400
|
guardUrlBoundaries(newText, args.allowUrlAdjacent);
|
|
1322
1401
|
|
|
1402
|
+
// An `ask` or `poll` body is a wire format: collect reads the question, the
|
|
1403
|
+
// pills, the ✅/🚫 state and the kept body back out of it. What is refused is
|
|
1404
|
+
// an edit that BREAKS that — new text that no longer reads as one, or an
|
|
1405
|
+
// answered/void question turned back into an open one. An edit that keeps it
|
|
1406
|
+
// readable is fine, and must stay fine: scripts resolve questions exactly this
|
|
1407
|
+
// way (the ask-devteam skill writes the ✅ form with `slack edit`, from hosts
|
|
1408
|
+
// that may run older builds — it cannot be asked to pass a new flag).
|
|
1409
|
+
const askBefore = askParseMessage(originalText).kind;
|
|
1410
|
+
const askAfter = askParseMessage(newText).kind;
|
|
1411
|
+
const isPoll = pollParseMessage(originalText).kind !== "other";
|
|
1412
|
+
const breaks = isPoll
|
|
1413
|
+
? pollParseMessage(newText).kind === "other"
|
|
1414
|
+
: askBefore !== "other" && (askAfter === "other" || (askBefore !== "open" && askAfter === "open"));
|
|
1415
|
+
if (breaks && !args.force) {
|
|
1416
|
+
const what = isPoll ? "a `slack poll`" : `a \`slack ask\` question (${askBefore === "open" ? "open" : askBefore === "resolved" ? "answered" : "void"})`;
|
|
1417
|
+
const how = isPoll || askAfter === "other"
|
|
1418
|
+
? "the new text would no longer read back as one (collect could not find the question, the pills or the answer)"
|
|
1419
|
+
: "the new text would turn it back into an OPEN question";
|
|
1420
|
+
console.error(
|
|
1421
|
+
`Error: this message is ${what}, and ${how}.\n` +
|
|
1422
|
+
(isPoll
|
|
1423
|
+
? ` Edit it anyway with --force.`
|
|
1424
|
+
: ` To retire it: slack ask --void='${args.target}' --reason '…'\n Edit it anyway with --force.`),
|
|
1425
|
+
);
|
|
1426
|
+
process.exit(1);
|
|
1427
|
+
}
|
|
1428
|
+
const guarded = breaks;
|
|
1429
|
+
|
|
1323
1430
|
// Identity is part of the hash for the same reason it is on `send`: a code
|
|
1324
1431
|
// minted while previewing as one identity must not confirm the write as
|
|
1325
1432
|
// another (Slack only lets you edit your own messages, so a silent profile
|
|
1326
1433
|
// switch between preview and confirm otherwise turns into a bare API error).
|
|
1327
1434
|
const self = await getSelf();
|
|
1328
|
-
|
|
1435
|
+
// Overriding the guard is part of what the code authorizes: a code minted for
|
|
1436
|
+
// an ordinary edit must not confirm a forced rewrite of a question.
|
|
1437
|
+
const code = guarded
|
|
1438
|
+
? safetyCode("force-edit-ask-poll", originalText, newText, self?.userId ?? "")
|
|
1439
|
+
: safetyCode(originalText, newText, self?.userId ?? "");
|
|
1329
1440
|
if (args.code !== code) {
|
|
1330
1441
|
requireCode(args.code, code, [
|
|
1442
|
+
...(guarded ? [`⚠ --force: rewriting a \`slack ${isPoll ? "poll" : "ask"}\` message by hand — collect may no longer read it back`] : []),
|
|
1331
1443
|
`--- Editing as -------------------------------`,
|
|
1332
1444
|
fromLine(self, { asBot: args.asBot }),
|
|
1333
1445
|
`--- Original message -------------------------`,
|
|
@@ -1339,7 +1451,13 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
1339
1451
|
}
|
|
1340
1452
|
|
|
1341
1453
|
const attr = sentAttribution("edit");
|
|
1342
|
-
|
|
1454
|
+
// An ask/poll body goes up PLAIN (no blocks): with blocks attached Slack
|
|
1455
|
+
// rewrites the stored text — every newline becomes a space — and the message
|
|
1456
|
+
// can never be read back as an ask again (2026-10-01: a release ask edited
|
|
1457
|
+
// this way could neither be collected nor voided). Ordinary messages keep
|
|
1458
|
+
// the blocks they always had.
|
|
1459
|
+
const askish = askBefore !== "other" || askAfter !== "other" || isPoll || pollParseMessage(newText).kind !== "other";
|
|
1460
|
+
const newTs = await editMessage(token, channelId, ts, newText, args.cookie, askish ? true : undefined, attr.metadata);
|
|
1343
1461
|
console.log(`✓ Edited (ts: ${newTs})`);
|
|
1344
1462
|
attr.record({ team: self?.team, channel: channelId, target: args.target, ts, text: newText, asBot: args.asBot });
|
|
1345
1463
|
}
|
|
@@ -2319,6 +2437,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
2319
2437
|
// 5 replied in free text, picking none of the offered choices — the reply
|
|
2320
2438
|
// text is on stdout. NOT a decision: the question is left open, so a pill
|
|
2321
2439
|
// pressed later still settles it (`--waitFor … --after=<reply ts>`).
|
|
2440
|
+
// 6 the question was VOIDED (無効化, `slack ask --void`) — it will never be
|
|
2441
|
+
// answered; stop waiting. stdout stays empty.
|
|
2322
2442
|
// Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
|
|
2323
2443
|
|
|
2324
2444
|
const ASK_EXIT_TIMEOUT = 2;
|
|
@@ -2334,6 +2454,10 @@ const ASK_EXIT_UNCHOSEN = 4;
|
|
|
2334
2454
|
* (measured 2026-09-27: 8 of 71 asks answered this way, none collected). Still
|
|
2335
2455
|
* not 0, so nothing mistakes it for a decision. */
|
|
2336
2456
|
const ASK_EXIT_FREETEXT = 5;
|
|
2457
|
+
/** The question was voided (無効化) — expired, superseded, or overtaken by events.
|
|
2458
|
+
* Neither "open" nor "answered": a monitor that kept waiting on it would wait
|
|
2459
|
+
* forever, and one that read it as answered would act on nothing. */
|
|
2460
|
+
const ASK_EXIT_VOID = 6;
|
|
2337
2461
|
|
|
2338
2462
|
const ASK_PHI = 1.618033988749895;
|
|
2339
2463
|
const ASK_POLL_MIN_MS = 1000;
|
|
@@ -2343,6 +2467,12 @@ const ASK_MAX_CONSECUTIVE_ERRORS = 8;
|
|
|
2343
2467
|
|
|
2344
2468
|
interface AskArgs {
|
|
2345
2469
|
allowUrlAdjacent?: boolean;
|
|
2470
|
+
/** `--void=<old> --reask`: once this question is posted, void `old` with this
|
|
2471
|
+
* question as its replacement. Validated (own, open) by the caller. */
|
|
2472
|
+
voidAfter?: AskVoidTarget & { reason: string };
|
|
2473
|
+
/** `--lang`. Unset = pick from the readers' Slack locale, then the content,
|
|
2474
|
+
* then the system locale — see `askResolveLang`. */
|
|
2475
|
+
lang?: string;
|
|
2346
2476
|
target: string;
|
|
2347
2477
|
question: string;
|
|
2348
2478
|
choices: string[];
|
|
@@ -2352,6 +2482,8 @@ interface AskArgs {
|
|
|
2352
2482
|
userId?: string;
|
|
2353
2483
|
asBot?: boolean;
|
|
2354
2484
|
wait?: boolean;
|
|
2485
|
+
/** `--json` with `--wait`: one JSON object on stdout (answer, notes, cursor). */
|
|
2486
|
+
json?: boolean;
|
|
2355
2487
|
timeout?: number;
|
|
2356
2488
|
cookie?: string;
|
|
2357
2489
|
// Token used to resolve the @tags that decide who may answer (needs
|
|
@@ -2423,6 +2555,9 @@ interface AskWaitCtx {
|
|
|
2423
2555
|
* nothing". */
|
|
2424
2556
|
overflow: string[];
|
|
2425
2557
|
threadOnly: boolean;
|
|
2558
|
+
/** The language the question's copy is in — the ✅ stamp and the
|
|
2559
|
+
* invalid-ballot line are written in the same one. */
|
|
2560
|
+
lang: AskLang;
|
|
2426
2561
|
/** Who may answer. Empty + broadcast=false would accept nobody, which is why
|
|
2427
2562
|
* posting an unaddressed question is refused up front. */
|
|
2428
2563
|
audience: Set<string>;
|
|
@@ -2438,16 +2573,148 @@ interface AskWaitCtx {
|
|
|
2438
2573
|
/** Only so the resume hint printed on timeout names the right identity — the
|
|
2439
2574
|
* ✅ rewrite can be done by the original poster alone. */
|
|
2440
2575
|
asBot: boolean;
|
|
2441
|
-
/**
|
|
2442
|
-
*
|
|
2576
|
+
/** The CURSOR: everything at or before this ts was already delivered to the
|
|
2577
|
+
* caller. Text replies there are not answers again (the free-text reply an
|
|
2578
|
+
* exit 5 delivered), and not thread notes again. Reactions still count. */
|
|
2443
2579
|
after?: string;
|
|
2580
|
+
/** `--json`: stdout is one JSON object (answer + notes + cursor) instead of
|
|
2581
|
+
* the bare answer. Exit codes are the same either way. */
|
|
2582
|
+
json: boolean;
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
/** A reply from the question's audience that is NOT the answer — an extra
|
|
2586
|
+
* instruction ("also look at X"), a caveat, a question back after a pill.
|
|
2587
|
+
*
|
|
2588
|
+
* Reported from real use 2026-10-02: a pill was pressed and collected, ✅ was
|
|
2589
|
+
* written, and the watcher exited; 40 s later the same person added a note in
|
|
2590
|
+
* the thread, and nothing ever delivered it. A pill was the whole answer as far
|
|
2591
|
+
* as `ask` was concerned, so the thread was never read — and once ✅ was
|
|
2592
|
+
* written, `--waitFor` only ever returned the stored answer. */
|
|
2593
|
+
interface AskNote { ts: string; user: string; text: string; permalink: string }
|
|
2594
|
+
|
|
2595
|
+
/** Who and where notes are read from — the same audience rule as answers. */
|
|
2596
|
+
interface AskNoteScope {
|
|
2597
|
+
channelId: string;
|
|
2598
|
+
ts: string;
|
|
2599
|
+
threadParentTs?: string;
|
|
2600
|
+
threadOnly: boolean;
|
|
2601
|
+
/** The question's own `reply_count`, or undefined when the question was not
|
|
2602
|
+
* in the page we read. Its thread is skipped only on a KNOWN zero: a call
|
|
2603
|
+
* per collection on every threadless question is a cost paid for nothing,
|
|
2604
|
+
* but an unknown count read as zero loses the notes — in a busy channel the
|
|
2605
|
+
* question falls out of the 30-message history page. */
|
|
2606
|
+
replyCount: number | undefined;
|
|
2607
|
+
isAnswerer: (user: unknown) => user is string;
|
|
2608
|
+
askerUserId: string;
|
|
2609
|
+
askerBotId: string;
|
|
2610
|
+
cookie?: string;
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
/** The audience's replies after `after` (or after the question), oldest first.
|
|
2614
|
+
* `skip` drops the reply that IS the answer.
|
|
2615
|
+
*
|
|
2616
|
+
* Fail-soft: a note is extra information, never the answer, so a failed fetch
|
|
2617
|
+
* is reported on stderr and must not cost the caller the answer it already
|
|
2618
|
+
* has. Thread replies come from the thread; in a 1:1 DM, plain replies count
|
|
2619
|
+
* too — bounded by our NEXT question there, exactly as answers are, so a note
|
|
2620
|
+
* meant for a newer question is not delivered with an older one. */
|
|
2621
|
+
async function askCollectNotes(token: string, sc: AskNoteScope, after: string | undefined, skip: (m: Record<string, Json>) => boolean): Promise<AskNote[]> {
|
|
2622
|
+
const pool = new Map<string, Record<string, Json>>();
|
|
2623
|
+
if (sc.threadParentTs || sc.replyCount !== 0) {
|
|
2624
|
+
try {
|
|
2625
|
+
const rep = asRecord((await replies(token, sc.channelId, sc.threadParentTs ?? sc.ts, 100, sc.cookie)) as Json);
|
|
2626
|
+
for (const m of asArray(rep.messages).map(asRecord)) pool.set(String(m.ts), m);
|
|
2627
|
+
} catch (e: unknown) {
|
|
2628
|
+
console.error(` (スレッドの追記を読めませんでした: ${e instanceof Error ? e.message : String(e)})`);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
if (!sc.threadOnly && !sc.threadParentTs) {
|
|
2632
|
+
try {
|
|
2633
|
+
const hist = asRecord((await history(token, sc.channelId, 30, sc.ts, undefined, sc.cookie, true)) as Json);
|
|
2634
|
+
const msgs = asArray(hist.messages).map(asRecord);
|
|
2635
|
+
const bound = msgs
|
|
2636
|
+
.filter((m) => Number(m.ts) > Number(sc.ts) && (
|
|
2637
|
+
(sc.askerBotId && m.bot_id === sc.askerBotId) || (sc.askerUserId && m.user === sc.askerUserId)))
|
|
2638
|
+
.reduce((min, m) => Math.min(min, Number(m.ts)), Infinity);
|
|
2639
|
+
for (const m of msgs) if (Number(m.ts) < bound && !pool.has(String(m.ts))) pool.set(String(m.ts), m);
|
|
2640
|
+
} catch (e: unknown) {
|
|
2641
|
+
console.error(` (DM の追記を読めませんでした: ${e instanceof Error ? e.message : String(e)})`);
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
const picked = [...pool.values()]
|
|
2645
|
+
.filter((m) => Number(m.ts) > Number(sc.ts))
|
|
2646
|
+
.filter((m) => !after || Number(m.ts) > Number(after))
|
|
2647
|
+
// The asker is excluded by isAnswerer; any bot post (ours or another
|
|
2648
|
+
// integration's) is excluded here, so nothing echoes back as a "note".
|
|
2649
|
+
.filter((m) => sc.isAnswerer(m.user) && !m.bot_id)
|
|
2650
|
+
.filter((m) => typeof m.subtype !== "string" || ASK_ANSWERABLE_SUBTYPES.has(m.subtype))
|
|
2651
|
+
.filter((m) => typeof m.text === "string" && m.text.trim() !== "")
|
|
2652
|
+
// Sorted BEFORE `skip`, which may be stateful ("the first reply that …").
|
|
2653
|
+
.sort((a, b) => Number(a.ts) - Number(b.ts))
|
|
2654
|
+
.filter((m) => !skip(m));
|
|
2655
|
+
const out: AskNote[] = [];
|
|
2656
|
+
for (const m of picked) {
|
|
2657
|
+
let permalink = "";
|
|
2658
|
+
try {
|
|
2659
|
+
permalink = await getPermalink(token, sc.channelId, String(m.ts), sc.cookie);
|
|
2660
|
+
} catch {
|
|
2661
|
+
// fail-soft: the ts still names it
|
|
2662
|
+
}
|
|
2663
|
+
out.push({ ts: String(m.ts), user: String(m.user), text: (m.text as string).trim(), permalink });
|
|
2664
|
+
}
|
|
2665
|
+
return out;
|
|
2444
2666
|
}
|
|
2445
2667
|
|
|
2668
|
+
/** The next cursor: the newest thing this result delivered, never older than
|
|
2669
|
+
* the cursor it was given. A caller that passes it back as `--after` gets
|
|
2670
|
+
* nothing twice. */
|
|
2671
|
+
function askNextCursor(...tss: (string | undefined)[]): string {
|
|
2672
|
+
return tss.filter((t): t is string => !!t).reduce((a, b) => (!a || Number(b) > Number(a) ? b : a), "");
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
/** Deliver an answer (or the lack of one). Plain mode keeps the contract —
|
|
2676
|
+
* stdout is the answer alone — and puts the notes and the next-cursor hint on
|
|
2677
|
+
* stderr. `--json` puts all of it in one object on stdout. */
|
|
2678
|
+
async function askEmit(
|
|
2679
|
+
token: string,
|
|
2680
|
+
o: { json: boolean; shown: string; asBot: boolean; cookie?: string | undefined },
|
|
2681
|
+
r: { status: "answered" | "freetext" | "ambiguous" | "timeout" | "void"; answer: string | null; how?: string; who?: string; reason?: string; supersededBy?: string; notes: AskNote[]; cursor: string },
|
|
2682
|
+
): Promise<void> {
|
|
2683
|
+
if (o.json) {
|
|
2684
|
+
console.log(JSON.stringify({
|
|
2685
|
+
status: r.status, answer: r.answer, how: r.how ?? null, who: r.who ?? null,
|
|
2686
|
+
...(r.status === "void" ? { reason: r.reason ?? "", supersededBy: r.supersededBy ?? null } : {}),
|
|
2687
|
+
notes: r.notes, cursor: r.cursor, permalink: o.shown,
|
|
2688
|
+
}));
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
if (r.notes.length) {
|
|
2692
|
+
console.error(` スレッドの追記 ${r.notes.length} 件 (回答とは別):`);
|
|
2693
|
+
for (const n of r.notes) {
|
|
2694
|
+
let who = n.user;
|
|
2695
|
+
try {
|
|
2696
|
+
who = await userName(token, n.user, o.cookie);
|
|
2697
|
+
} catch {
|
|
2698
|
+
// the id still says who
|
|
2699
|
+
}
|
|
2700
|
+
for (const [i, line] of n.text.split("\n").entries()) {
|
|
2701
|
+
console.error(` ${i ? " ".repeat(stripTerminalControls(who).length + 2) : `${stripTerminalControls(who)}: `}${stripTerminalControls(line)}`);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
if (r.status === "answered") {
|
|
2706
|
+
console.error(` この後の追記を拾う: ${askResumeCommand(o.shown, o.asBot, r.cursor)}`);
|
|
2707
|
+
}
|
|
2708
|
+
if (r.answer !== null) console.log(r.answer);
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
|
|
2446
2712
|
/** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
|
|
2447
2713
|
* on timeout. A timeout of 0 means "look exactly once", so a caller can poll
|
|
2448
2714
|
* cheaply on its own schedule instead of parking a process on `--wait`. */
|
|
2449
2715
|
async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never> {
|
|
2450
2716
|
const { channelId, ts, question, reactable, threadOnly, audience, broadcast, cookie, timeout, shown } = ctx;
|
|
2717
|
+
const copy = ASK_COPY[ctx.lang];
|
|
2451
2718
|
const askerUserId = ctx.askerUserId;
|
|
2452
2719
|
const askerBotId = ctx.askerBotId;
|
|
2453
2720
|
|
|
@@ -2458,6 +2725,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2458
2725
|
const isAnswerer = (user: unknown): user is string =>
|
|
2459
2726
|
typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
|
|
2460
2727
|
|
|
2728
|
+
/** The question's `reply_count` as last seen — whether its thread is worth a
|
|
2729
|
+
* fetch when collecting notes. Undefined until the question itself is seen. */
|
|
2730
|
+
let replyCount: number | undefined;
|
|
2731
|
+
/** The question's text as last read — what the ✅ rewrite keeps the body of. */
|
|
2732
|
+
let ownText: string | undefined;
|
|
2733
|
+
/** Set when the question is voided while we wait — the wait ends there. */
|
|
2734
|
+
let voided: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }> | undefined;
|
|
2735
|
+
const checkVoid = (own: Record<string, Json>): boolean => {
|
|
2736
|
+
const p = typeof own.text === "string" ? askParseMessage(own.text) : undefined;
|
|
2737
|
+
if (p?.kind === "void") voided = p;
|
|
2738
|
+
return !!voided;
|
|
2739
|
+
};
|
|
2740
|
+
|
|
2461
2741
|
/** The earliest reply from an answerer that picked none of the choices. Kept
|
|
2462
2742
|
* across polls so the timeout can report it, and so the operator is told once
|
|
2463
2743
|
* rather than on every tick. */
|
|
@@ -2491,7 +2771,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2491
2771
|
async function noteInvalid(msg: Record<string, Json>, offenders: string[]): Promise<void> {
|
|
2492
2772
|
const text = typeof msg.text === "string" ? msg.text : "";
|
|
2493
2773
|
if (!text) return;
|
|
2494
|
-
const next = applyInvalidNotice(text, offenders);
|
|
2774
|
+
const next = applyInvalidNotice(text, offenders, ctx.lang);
|
|
2495
2775
|
if (next === text) return;
|
|
2496
2776
|
try {
|
|
2497
2777
|
await editMessage(token, channelId, ts, next, cookie, true);
|
|
@@ -2501,7 +2781,22 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2501
2781
|
}
|
|
2502
2782
|
}
|
|
2503
2783
|
|
|
2784
|
+
/** Who has pressed ❓ ("other"). Not an answer — ❓ carries no text — so it
|
|
2785
|
+
* never resolves anything; the reply that should follow it does. Reported
|
|
2786
|
+
* once per person so the operator knows a reply is on its way rather than
|
|
2787
|
+
* reading the silence as "nobody looked". */
|
|
2788
|
+
const otherReported = new Set<string>();
|
|
2789
|
+
function noteOther(msg: Record<string, Json>): void {
|
|
2790
|
+
const r = asArray(msg.reactions).map(asRecord).find((x) => x.name === ASK_MARKER);
|
|
2791
|
+
for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
|
|
2792
|
+
if (otherReported.has(u)) continue;
|
|
2793
|
+
otherReported.add(u);
|
|
2794
|
+
console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2504
2798
|
async function answerFromReactions(msg: Record<string, Json>): Promise<AskFound | null> {
|
|
2799
|
+
if (reactable.length) noteOther(msg);
|
|
2505
2800
|
const picks = humanChoices(msg);
|
|
2506
2801
|
// Nothing ambiguous any more — take the notice back down if one is up.
|
|
2507
2802
|
if (picks.length <= 1 && readInvalidNotice(typeof msg.text === "string" ? msg.text : "").length) {
|
|
@@ -2510,15 +2805,26 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2510
2805
|
if (!picks.length) return null;
|
|
2511
2806
|
if (picks.length === 1) {
|
|
2512
2807
|
const { index, users } = picks[0]!;
|
|
2513
|
-
return { answer: reactable[index]!, how:
|
|
2808
|
+
return { answer: reactable[index]!, how: copy.howReaction(ASK_KEYCAPS[index]!.glyph), who: users[0]!, choice: index + 1 };
|
|
2514
2809
|
}
|
|
2515
2810
|
const glyphs = picks.map((p) => ASK_KEYCAPS[p.index]!.glyph).join(" / ");
|
|
2516
|
-
|
|
2517
|
-
//
|
|
2518
|
-
//
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
const
|
|
2811
|
+
// Two cases, both "do not guess": one person on several pills, or
|
|
2812
|
+
// different people on different pills (a split). Only the first is the
|
|
2813
|
+
// voter's to fix, so only those people are named in the message.
|
|
2814
|
+
const pillsPerUser = new Map<string, number>();
|
|
2815
|
+
for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
|
|
2816
|
+
const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
|
|
2817
|
+
console.error(multi.length
|
|
2818
|
+
? ` (${glyphs} が同時に選ばれています。1 つに絞ってもらうまで待ちます)`
|
|
2819
|
+
: ` (回答が分かれています: ${glyphs}。どちらか一方にそろうまで待ちます)`);
|
|
2820
|
+
// Whoever is on more than one pill — and ONLY them. Naming everyone on any
|
|
2821
|
+
// pill told a voter who had pressed exactly one that she had pressed two
|
|
2822
|
+
// (2026-10-01: a voter on 1️⃣ only was named because someone else was on 1️⃣
|
|
2823
|
+
// and 2️⃣). Named in the question itself so the person who has to fix it is
|
|
2824
|
+
// the person who sees it; rewritten from the current state each pass, so
|
|
2825
|
+
// reruns cannot stack up notices and the line clears itself once a
|
|
2826
|
+
// reaction is taken back. A pure split names nobody (empty = no notice).
|
|
2827
|
+
const offenders = multi.sort();
|
|
2522
2828
|
await noteInvalid(msg, offenders);
|
|
2523
2829
|
return null;
|
|
2524
2830
|
}
|
|
@@ -2542,13 +2848,13 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2542
2848
|
if (!t) continue;
|
|
2543
2849
|
// A question asked WITHOUT choices is answered by whatever comes back —
|
|
2544
2850
|
// there is nothing to match against, and every reply is the answer.
|
|
2545
|
-
if (!candidates.length) return { answer: t, how:
|
|
2851
|
+
if (!candidates.length) return { answer: t, how: copy.howReply, who: m.user, ts: String(m.ts) };
|
|
2546
2852
|
const match = askMatchChoice(t, candidates);
|
|
2547
2853
|
if (match.kind === "chosen") {
|
|
2548
2854
|
// Answer with the CHOICE, not with the reply that selected it: "2" and
|
|
2549
2855
|
// "2. 中止" have to reach the caller as the same decision a pill would
|
|
2550
2856
|
// have produced, or the same answer arrives in three spellings.
|
|
2551
|
-
return { answer: candidates[match.index - 1]!, how:
|
|
2857
|
+
return { answer: candidates[match.index - 1]!, how: copy.howReplyN(match.index), who: m.user, ts: String(m.ts), choice: match.index };
|
|
2552
2858
|
}
|
|
2553
2859
|
// Replied, but picked nothing. Recorded rather than returned: a later
|
|
2554
2860
|
// reply may still choose, and the first one is what the operator needs to
|
|
@@ -2582,6 +2888,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2582
2888
|
const messages = asArray(rep.messages).map(asRecord);
|
|
2583
2889
|
const own = messages.find((m) => m.ts === ts);
|
|
2584
2890
|
if (own) {
|
|
2891
|
+
replyCount = Number(own.reply_count) || 0;
|
|
2892
|
+
if (typeof own.text === "string") ownText = own.text;
|
|
2893
|
+
if (checkVoid(own)) return null;
|
|
2585
2894
|
const byReaction = await answerFromReactions(own);
|
|
2586
2895
|
if (byReaction) return byReaction;
|
|
2587
2896
|
}
|
|
@@ -2595,6 +2904,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2595
2904
|
const messages = asArray(hist.messages).map(asRecord);
|
|
2596
2905
|
const own = messages.find((m) => m.ts === ts);
|
|
2597
2906
|
|
|
2907
|
+
if (own) {
|
|
2908
|
+
replyCount = Number(own.reply_count) || 0;
|
|
2909
|
+
if (typeof own.text === "string") ownText = own.text;
|
|
2910
|
+
if (checkVoid(own)) return null;
|
|
2911
|
+
}
|
|
2598
2912
|
// A reaction is the intended path, so it wins when both are present.
|
|
2599
2913
|
if (own) {
|
|
2600
2914
|
const byReaction = await answerFromReactions(own);
|
|
@@ -2648,7 +2962,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2648
2962
|
// and must keep getting the answer body alone.
|
|
2649
2963
|
if (who) console.error(` 回答者: ${stripTerminalControls(who)}`);
|
|
2650
2964
|
try {
|
|
2651
|
-
|
|
2965
|
+
// The body and the chosen option stay; only what no longer applies goes.
|
|
2966
|
+
// Built from the message as it is NOW (an edit made in Slack after asking
|
|
2967
|
+
// is kept). Not seen this run — the bare form, as before.
|
|
2968
|
+
const keep = ownText ? askResolvedKeep(ownText, found.choice) : undefined;
|
|
2969
|
+
await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
|
|
2652
2970
|
// Swap the marker for the resolved one so search reflects reality:
|
|
2653
2971
|
// `has::question:` should list what still needs answering, not everything
|
|
2654
2972
|
// ever asked. Removal last — a crash between the two leaves the question
|
|
@@ -2684,10 +3002,34 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2684
3002
|
}
|
|
2685
3003
|
console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
|
|
2686
3004
|
}
|
|
3005
|
+
if (voided) {
|
|
3006
|
+
askReportVoid(voided, shown);
|
|
3007
|
+
if (ctx.json) {
|
|
3008
|
+
await askEmit(token, ctx, {
|
|
3009
|
+
status: "void", answer: null, reason: voided.reason,
|
|
3010
|
+
...(voided.supersededBy ? { supersededBy: voided.supersededBy } : {}),
|
|
3011
|
+
notes: [], cursor: askNextCursor(ts, ctx.after),
|
|
3012
|
+
});
|
|
3013
|
+
}
|
|
3014
|
+
process.exit(ASK_EXIT_VOID);
|
|
3015
|
+
}
|
|
2687
3016
|
if (found) {
|
|
3017
|
+
// Notes once the answer is in hand: a pill used to end the poll before
|
|
3018
|
+
// the thread was ever read, which is how a note beside it was lost. Read
|
|
3019
|
+
// BEFORE ✅ is written, and delivered right after — ✅ is what tells a
|
|
3020
|
+
// later --waitFor the question is settled.
|
|
3021
|
+
const notes = await askCollectNotes(token, {
|
|
3022
|
+
channelId, ts, threadOnly, replyCount, isAnswerer, askerUserId, askerBotId,
|
|
3023
|
+
...(ctx.threadParentTs ? { threadParentTs: ctx.threadParentTs } : {}),
|
|
3024
|
+
...(cookie ? { cookie } : {}),
|
|
3025
|
+
}, ctx.after, (m) => m.ts === found!.ts);
|
|
2688
3026
|
await markResolved(found);
|
|
2689
|
-
|
|
2690
|
-
|
|
3027
|
+
const r: Parameters<typeof askEmit>[2] = {
|
|
3028
|
+
status: "answered", answer: found.answer, how: found.how, notes,
|
|
3029
|
+
cursor: askNextCursor(ts, ctx.after, found.ts, ...notes.map((n) => n.ts)),
|
|
3030
|
+
};
|
|
3031
|
+
if (found.who) r.who = found.who;
|
|
3032
|
+
await askEmit(token, ctx, r);
|
|
2691
3033
|
process.exit(0);
|
|
2692
3034
|
}
|
|
2693
3035
|
// A free-text reply that picks nothing is delivered NOW, not held until the
|
|
@@ -2706,7 +3048,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2706
3048
|
console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
2707
3049
|
console.error(` ${shown}`);
|
|
2708
3050
|
console.error(` この返信の後を待つ: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
|
|
2709
|
-
|
|
3051
|
+
if (ctx.json) {
|
|
3052
|
+
await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
|
|
3053
|
+
} else {
|
|
3054
|
+
console.log(unchosen.text);
|
|
3055
|
+
}
|
|
2710
3056
|
process.exit(ASK_EXIT_FREETEXT);
|
|
2711
3057
|
}
|
|
2712
3058
|
// Said as soon as it is seen, not only at the timeout: the reply is usually
|
|
@@ -2748,6 +3094,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2748
3094
|
console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
2749
3095
|
console.error(` ${shown}`);
|
|
2750
3096
|
console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3097
|
+
if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
|
|
2751
3098
|
process.exit(ASK_EXIT_UNCHOSEN);
|
|
2752
3099
|
}
|
|
2753
3100
|
if (timeout === 0) {
|
|
@@ -2757,6 +3104,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2757
3104
|
console.error(` ${shown}`);
|
|
2758
3105
|
console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
2759
3106
|
}
|
|
3107
|
+
if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
|
|
2760
3108
|
process.exit(ASK_EXIT_TIMEOUT);
|
|
2761
3109
|
}
|
|
2762
3110
|
|
|
@@ -2807,6 +3155,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2807
3155
|
const qEnc = askEncodeBroadcasts(unescapeArg(args.question));
|
|
2808
3156
|
const bEnc = askEncodeBroadcasts(unescapeArg(args.body ?? ""));
|
|
2809
3157
|
const broadcastKinds = new Set([...qEnc.kinds, ...bEnc.kinds]);
|
|
3158
|
+
// Already-encoded broadcasts (`<!here>`) — what a re-ask of a posted question carries.
|
|
3159
|
+
for (const m of `${qEnc.text}\n${bEnc.text}`.matchAll(/<!(here|channel|everyone)(?:\|[^>]*)?>/g)) broadcastKinds.add(m[1]!);
|
|
2810
3160
|
const mentionToken = args.mentionToken ?? token;
|
|
2811
3161
|
const qRep = await encodeMentionsDetailed(mentionToken, qEnc.text, channelId, args.mentionCookie);
|
|
2812
3162
|
const bRep = bEnc.text
|
|
@@ -2824,6 +3174,14 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2824
3174
|
// Tagging yourself grants nothing — your own reactions are the seeds.
|
|
2825
3175
|
const audience = new Set(resolved.map((r) => r.userId).filter((id) => id !== selfId));
|
|
2826
3176
|
const audienceNames = new Map(resolved.map((r) => [r.userId, r.display]));
|
|
3177
|
+
// Tags already in Slack's `<@U…>` form name someone just as surely — that is
|
|
3178
|
+
// what a re-ask built from a posted question carries. Same self-exclusion.
|
|
3179
|
+
for (const m of `${question}\n${body}`.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)) {
|
|
3180
|
+
const id = m[1]!;
|
|
3181
|
+
if (id === selfId || audience.has(id)) continue;
|
|
3182
|
+
audience.add(id);
|
|
3183
|
+
audienceNames.set(id, await userName(mentionToken, id, args.mentionCookie));
|
|
3184
|
+
}
|
|
2827
3185
|
let audienceLabel: string;
|
|
2828
3186
|
if (broadcast) {
|
|
2829
3187
|
audienceLabel = `anyone in ${ref} (${[...broadcastKinds].map((k) => `@${k}`).join(", ")})`;
|
|
@@ -2866,7 +3224,19 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2866
3224
|
// which makes channel mode unambiguous by construction. A question posted
|
|
2867
3225
|
// INTO a thread is thread-scoped for the same reason.
|
|
2868
3226
|
const threadOnly = !channelId.startsWith("D") || !!threadTs;
|
|
2869
|
-
|
|
3227
|
+
// The copy's language. The readers are the people who have to read it, so
|
|
3228
|
+
// their Slack locale comes first — but only for a known set of people: an
|
|
3229
|
+
// @here audience is not one, and is left to the content and the system.
|
|
3230
|
+
const readerLocales = broadcast
|
|
3231
|
+
? []
|
|
3232
|
+
: await Promise.all([...audience].map((id) => userLocale(mentionToken, id, args.mentionCookie)));
|
|
3233
|
+
const picked = askResolveLang({ flag: args.lang, readerLocales, content: [question, body, ...reactable, ...overflow] });
|
|
3234
|
+
if (!picked) {
|
|
3235
|
+
console.error(`Error: unsupported language "${stripTerminalControls(args.lang ?? process.env.SLACK_TERM_LANG ?? "")}" — use one of: ${ASK_LANGS.join(", ")}`);
|
|
3236
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3237
|
+
}
|
|
3238
|
+
const lang = picked.lang;
|
|
3239
|
+
const message = askBuildText(question, body, reactable, overflow, threadOnly, lang);
|
|
2870
3240
|
guardUrlBoundaries(message, args.allowUrlAdjacent);
|
|
2871
3241
|
|
|
2872
3242
|
// Preview the destination's last message, exactly as `send` does — the gate's
|
|
@@ -2893,7 +3263,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2893
3263
|
// Resolved before the gate so the clock can name the recipient's zone. DM
|
|
2894
3264
|
// only; null means "unknown" and the label says so rather than guessing.
|
|
2895
3265
|
const recipientTz = await recipientTzFor(token, channelId, cookie);
|
|
2896
|
-
const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? ""
|
|
3266
|
+
const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "",
|
|
3267
|
+
...(args.voidAfter ? ["reask", `${args.voidAfter.channelId}:${args.voidAfter.ts}`, args.voidAfter.reason] : []));
|
|
2897
3268
|
if (args.code !== code) {
|
|
2898
3269
|
const dest = await destLabel(token, channelId, ref, cookie);
|
|
2899
3270
|
const choiceLines = reactable.length
|
|
@@ -2916,6 +3287,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2916
3287
|
// shown as its own line rather than left to be inferred from the body.
|
|
2917
3288
|
` Answerable by: ${audienceLabel}`,
|
|
2918
3289
|
` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
|
|
3290
|
+
...(args.voidAfter ? [` Then 無効化: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) — replaced by this question`] : []),
|
|
3291
|
+
` Language: ${lang} (${({ flag: "--lang", env: "SLACK_TERM_LANG", readers: "answerers' Slack locale", content: "the question's text", system: "system locale", default: "default" })[picked.source]}; override with --lang)`,
|
|
2919
3292
|
`--------------------------------────────────`,
|
|
2920
3293
|
], recipientTz);
|
|
2921
3294
|
}
|
|
@@ -2926,7 +3299,9 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2926
3299
|
const attr = sentAttribution("ask");
|
|
2927
3300
|
const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
|
|
2928
3301
|
|
|
2929
|
-
// The marker goes on
|
|
3302
|
+
// The marker goes on LAST, after 1,2,3, because it doubles as the "other"
|
|
3303
|
+
// choice the body lists under the numbered ones (people were already pressing
|
|
3304
|
+
// ❓ for "none of these fit"). It is a reaction so
|
|
2930
3305
|
// `has::question:` lists every question the way `has::pushpin:` lists every
|
|
2931
3306
|
// todo. Body text cannot do that job: Slack's index splits on punctuation, so
|
|
2932
3307
|
// a `:question:` written in the text is indexed as the word "question" and
|
|
@@ -2935,12 +3310,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2935
3310
|
// NOTE the colons: the modifier's argument is itself colon-wrapped, and the
|
|
2936
3311
|
// bare `has:question` form does NOT error — it silently degrades to a
|
|
2937
3312
|
// full-text search and returns plausible-looking counts. See ts/todo.ts.
|
|
2938
|
-
//
|
|
3313
|
+
// 1,2,3, then the marker — and spaced out. Sequential awaits alone are NOT
|
|
2939
3314
|
// enough: measured on two real questions from the same build, one came back
|
|
2940
3315
|
// `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
|
|
2941
3316
|
// SLACK_REACTION_SEED_GAP_MS.
|
|
2942
3317
|
await seedReactionsInOrder(
|
|
2943
|
-
[
|
|
3318
|
+
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
|
|
2944
3319
|
(name) => reactionAdd(token, channelId, ts, name, cookie),
|
|
2945
3320
|
(name, e) => {
|
|
2946
3321
|
const why = e instanceof Error ? e.message : String(e);
|
|
@@ -2960,6 +3335,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2960
3335
|
const shown = permalink || `${channelId}:${ts}`;
|
|
2961
3336
|
console.error(`✓ Asked: ${shown}`);
|
|
2962
3337
|
attr.record({ team: self?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
|
|
3338
|
+
// The replacement is up; now retire what it replaces. A failure here leaves
|
|
3339
|
+
// two open questions, which is reported loudly — never a missing one.
|
|
3340
|
+
if (args.voidAfter) {
|
|
3341
|
+
const ok = await askApplyVoid(token, args.voidAfter, args.voidAfter.reason, shown, cookie);
|
|
3342
|
+
if (!ok) console.error(` ⚠ 新しい質問は出ましたが、古い質問を無効化できませんでした — 手で: slack ask --void='${stripTerminalControls(args.voidAfter.link)}' --superseded-by='${shown}'`);
|
|
3343
|
+
}
|
|
2963
3344
|
|
|
2964
3345
|
if (!args.wait) {
|
|
2965
3346
|
// Not waiting. stdout is the RESUME COMMAND, not just a link: nothing else
|
|
@@ -2981,6 +3362,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2981
3362
|
reactable,
|
|
2982
3363
|
overflow,
|
|
2983
3364
|
threadOnly,
|
|
3365
|
+
lang,
|
|
2984
3366
|
audience,
|
|
2985
3367
|
broadcast,
|
|
2986
3368
|
askerUserId: self?.userId ?? "",
|
|
@@ -2988,6 +3370,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2988
3370
|
timeout,
|
|
2989
3371
|
shown,
|
|
2990
3372
|
asBot: !!args.asBot,
|
|
3373
|
+
json: !!args.json,
|
|
2991
3374
|
};
|
|
2992
3375
|
if (threadTs) ctx.threadParentTs = threadTs;
|
|
2993
3376
|
if (cookie) ctx.cookie = cookie;
|
|
@@ -2996,6 +3379,423 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
2996
3379
|
|
|
2997
3380
|
|
|
2998
3381
|
|
|
3382
|
+
/** Say on stderr that a question is void, and where its replacement is. */
|
|
3383
|
+
function askReportVoid(p: { reason: string; supersededBy?: string }, shown: string): void {
|
|
3384
|
+
console.error(`この質問は無効です — 回答されることはありません。待つのをやめてください: ${stripTerminalControls(shown)}`);
|
|
3385
|
+
if (p.reason) console.error(` ${stripTerminalControls(p.reason)}`);
|
|
3386
|
+
if (p.supersededBy) console.error(` 新しい質問: ${stripTerminalControls(p.supersededBy)}`);
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
/** Find the one message a permalink (or `C…:ts`) names. A question posted into
|
|
3390
|
+
* a thread is not in history at all, so where the link says it is a reply, it
|
|
3391
|
+
* is read from the thread. */
|
|
3392
|
+
async function askFetchByLink(token: string, link: string, cookie?: string): Promise<
|
|
3393
|
+
{ channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> } | null
|
|
3394
|
+
> {
|
|
3395
|
+
const url = parseSlackPermalink(link);
|
|
3396
|
+
let channelId: string;
|
|
3397
|
+
let ts: string;
|
|
3398
|
+
let threadTs: string | undefined;
|
|
3399
|
+
if (url?.ts) {
|
|
3400
|
+
channelId = url.channel;
|
|
3401
|
+
ts = url.ts;
|
|
3402
|
+
threadTs = url.threadTs && url.threadTs !== url.ts ? url.threadTs : undefined;
|
|
3403
|
+
} else {
|
|
3404
|
+
const m = link.match(/^([A-Za-z0-9]+):(\d{10}\.\d{6})$/);
|
|
3405
|
+
if (!m) return null;
|
|
3406
|
+
channelId = m[1]!;
|
|
3407
|
+
ts = m[2]!;
|
|
3408
|
+
}
|
|
3409
|
+
let msg: Record<string, Json> | undefined;
|
|
3410
|
+
if (threadTs) {
|
|
3411
|
+
const rep = asRecord((await replies(token, channelId, threadTs, 100, cookie)) as Json);
|
|
3412
|
+
msg = asArray(rep.messages).map(asRecord).find((m) => m.ts === ts);
|
|
3413
|
+
} else {
|
|
3414
|
+
const hist = asRecord((await history(token, channelId, 1, ts, undefined, cookie, true)) as Json);
|
|
3415
|
+
msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
|
|
3416
|
+
}
|
|
3417
|
+
const out: { channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> } = { channelId, ts };
|
|
3418
|
+
if (threadTs) out.threadTs = threadTs;
|
|
3419
|
+
if (msg) out.msg = msg;
|
|
3420
|
+
return out;
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
/** Look up a question for a lifecycle action (edit / void / reask): it must
|
|
3424
|
+
* exist, be OUR OWN post (Slack lets only the author edit, and changing
|
|
3425
|
+
* someone else's decision is not a thing this should make possible), and be
|
|
3426
|
+
* open. Says why on stderr and returns null otherwise. */
|
|
3427
|
+
async function askLoadOwnOpen(token: string, link: string, self: Self | null, asBot: boolean, cookie?: string):
|
|
3428
|
+
Promise<(AskVoidTarget & { msg: Record<string, Json>; threadTs?: string }) | "void" | null> {
|
|
3429
|
+
const shown = stripTerminalControls(link);
|
|
3430
|
+
const got = await askFetchByLink(token, link, cookie);
|
|
3431
|
+
if (!got) { console.error(`✗ ${shown}: permalink (または 'C…:1700000000.000100') ではありません`); return null; }
|
|
3432
|
+
if (!got.msg) { console.error(`✗ ${shown}: メッセージが見つかりません`); return null; }
|
|
3433
|
+
const msg = got.msg;
|
|
3434
|
+
const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
|
|
3435
|
+
if (!own) {
|
|
3436
|
+
console.error(`✗ ${shown}: 自分(${senderLabel(self, asBot)})が投稿した質問ではありません` +
|
|
3437
|
+
`${asBot ? "" : " — bot が出した質問なら --as-bot で"}`);
|
|
3438
|
+
return null;
|
|
3439
|
+
}
|
|
3440
|
+
const text = typeof msg.text === "string" ? msg.text : "";
|
|
3441
|
+
const parsed = askParseMessage(text);
|
|
3442
|
+
if (parsed.kind === "void") return "void";
|
|
3443
|
+
if (parsed.kind === "resolved") { console.error(`✗ ${shown}: 回答済みの質問です(回答: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})— 変更できません`); return null; }
|
|
3444
|
+
if (parsed.kind !== "open") { console.error(`✗ ${shown}: \`slack ask\` の質問として読み取れません — ${askExplainReject(text)}`); return null; }
|
|
3445
|
+
const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, msg };
|
|
3446
|
+
if (got.threadTs) out.threadTs = got.threadTs;
|
|
3447
|
+
return out;
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
/** Has anyone but us answered, or started to? A pressed keycap, a reply in the
|
|
3451
|
+
* thread, or (in a 1:1 DM) a plain message after the question. Used to lock the
|
|
3452
|
+
* OPTIONS: after a vote, renumbering them would make the reactions already
|
|
3453
|
+
* given silently mean something else. */
|
|
3454
|
+
async function askHasResponses(token: string, t: { channelId: string; ts: string; msg: Record<string, Json> }, self: Self | null, cookie?: string): Promise<boolean> {
|
|
3455
|
+
const notSelf = (u: unknown) => typeof u === "string" && u !== self?.userId;
|
|
3456
|
+
const pressed = asArray(t.msg.reactions).map(asRecord)
|
|
3457
|
+
.some((r) => ASK_KEYCAPS.some((k) => k.name === r.name) && asArray(r.users).some(notSelf));
|
|
3458
|
+
if (pressed) return true;
|
|
3459
|
+
// A thread counts only if someone OTHER than us is in it — our own
|
|
3460
|
+
// follow-up note must not lock the options.
|
|
3461
|
+
if (Number(t.msg.reply_count) > 0) {
|
|
3462
|
+
const rep = asRecord((await replies(token, t.channelId, t.ts, 100, cookie)) as Json);
|
|
3463
|
+
if (asArray(rep.messages).map(asRecord).some((m) => Number(m.ts) > Number(t.ts) && notSelf(m.user) && !m.bot_id)) return true;
|
|
3464
|
+
}
|
|
3465
|
+
if (t.channelId.startsWith("D")) {
|
|
3466
|
+
const hist = asRecord((await history(token, t.channelId, 30, t.ts, undefined, cookie, true)) as Json);
|
|
3467
|
+
if (asArray(hist.messages).map(asRecord).some((m) => Number(m.ts) > Number(t.ts) && notSelf(m.user) && !m.bot_id)) return true;
|
|
3468
|
+
}
|
|
3469
|
+
return false;
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
/** `slack ask --edit=<link> ["new question" [choices…]] [--body …]` — change an
|
|
3473
|
+
* open question THROUGH the ask builder, so pills, ❓, the instructions and the
|
|
3474
|
+
* body stay in the layout collect reads back (a free-hand `slack edit` is what
|
|
3475
|
+
* this replaces; `edit` now refuses ask messages without --force).
|
|
3476
|
+
*
|
|
3477
|
+
* The wording (question, body) can change any time. The OPTIONS can change only
|
|
3478
|
+
* while nobody has answered: after a vote the old reactions would silently mean
|
|
3479
|
+
* the new options. Then it refuses and points at `--void=<link> --reask`, which
|
|
3480
|
+
* retires the question and asks the new one. Answered / void: refused. */
|
|
3481
|
+
async function cmdAskEdit(token: string, args: {
|
|
3482
|
+
link: string; question?: string; body?: string; choices?: string[]; lang?: string;
|
|
3483
|
+
code?: string; asBot: boolean; cookie?: string; mentionToken: string; mentionCookie?: string;
|
|
3484
|
+
allowUrlAdjacent?: boolean;
|
|
3485
|
+
}): Promise<void> {
|
|
3486
|
+
const self = await selfIdentity(token, args.cookie);
|
|
3487
|
+
const t = await askLoadOwnOpen(token, args.link, self, args.asBot, args.cookie);
|
|
3488
|
+
if (t === "void") { console.error(`✗ ${stripTerminalControls(args.link)}: 無効化済みの質問は編集できません`); process.exit(ASK_EXIT_ERROR); }
|
|
3489
|
+
if (!t) process.exit(ASK_EXIT_ERROR);
|
|
3490
|
+
const old = t.parsed;
|
|
3491
|
+
const encode = async (raw: string): Promise<string> => {
|
|
3492
|
+
const enc = askEncodeBroadcasts(unescapeArg(raw));
|
|
3493
|
+
return (await encodeMentionsDetailed(args.mentionToken, enc.text, t.channelId, args.mentionCookie)).text;
|
|
3494
|
+
};
|
|
3495
|
+
const question = args.question !== undefined ? await encode(args.question) : old.question;
|
|
3496
|
+
const body = args.body !== undefined ? await encode(args.body) : old.body;
|
|
3497
|
+
const oldChoices = [...old.reactable, ...old.overflow];
|
|
3498
|
+
const choices = args.choices ? args.choices.map(unescapeArg) : oldChoices;
|
|
3499
|
+
const choicesChanged = JSON.stringify(choices.map(askFlatten)) !== JSON.stringify(oldChoices.map(askFlatten));
|
|
3500
|
+
if (choicesChanged && await askHasResponses(token, t, self, args.cookie)) {
|
|
3501
|
+
console.error(
|
|
3502
|
+
`✗ この質問にはもう回答(リアクションか返信)があります — 選択肢を変えると、押されたリアクションの意味が変わってしまいます。\n` +
|
|
3503
|
+
` 質問文と本文は変えられます。選択肢を変えるなら無効化して出し直してください:\n` +
|
|
3504
|
+
` slack ask --void='${stripTerminalControls(args.link)}' --reask${args.asBot ? " --as-bot" : ""} …`,
|
|
3505
|
+
);
|
|
3506
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3507
|
+
}
|
|
3508
|
+
// Still addressed to someone: the tags are who may answer, and an edit must
|
|
3509
|
+
// not leave a question nobody may answer (a 1:1 DM needs none).
|
|
3510
|
+
// Tagging yourself grants nothing — same rule as when asking.
|
|
3511
|
+
const tagged = [...`${question}\n${body}`.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].some((m) => m[1] !== self?.userId);
|
|
3512
|
+
const addressed = tagged || /<!(?:here|channel|everyone)/.test(`${question}\n${body}`) || t.channelId.startsWith("D");
|
|
3513
|
+
if (!addressed) {
|
|
3514
|
+
console.error(`✗ 誰にも宛てられていない質問になります — 回答できる人を @ で残してください`);
|
|
3515
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3516
|
+
}
|
|
3517
|
+
const lang = args.lang ? askResolveLang({ flag: args.lang })?.lang ?? old.lang : old.lang;
|
|
3518
|
+
const reactable = choices.slice(0, ASK_MAX_REACTION_CHOICES);
|
|
3519
|
+
const overflow = choices.slice(ASK_MAX_REACTION_CHOICES);
|
|
3520
|
+
const oldText = typeof t.msg.text === "string" ? t.msg.text : "";
|
|
3521
|
+
let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang);
|
|
3522
|
+
// An invalid-ballot notice still in force stays (it names the people who
|
|
3523
|
+
// have to fix their vote); it is only possible when the options did not change.
|
|
3524
|
+
const invalid = readInvalidNotice(oldText);
|
|
3525
|
+
if (invalid.length) next = applyInvalidNotice(next, invalid, lang);
|
|
3526
|
+
guardUrlBoundaries(next, args.allowUrlAdjacent);
|
|
3527
|
+
if (next === oldText) {
|
|
3528
|
+
console.error("変更はありません。");
|
|
3529
|
+
return;
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
const code = safetyCode("ask-edit", `${t.channelId}:${t.ts}`, next, self?.userId ?? "");
|
|
3533
|
+
if (args.code !== code) {
|
|
3534
|
+
const show = (label: string, a: string, b: string) => a === b
|
|
3535
|
+
? [` ${label}: (unchanged)`]
|
|
3536
|
+
: [` ${label}:`, ...a.split("\n").map((l) => ` - ${l}`), ...b.split("\n").map((l) => ` + ${l}`)];
|
|
3537
|
+
requireCode(args.code, code, [
|
|
3538
|
+
`--- Editing question --------------------------`,
|
|
3539
|
+
fromLine(self, { asBot: args.asBot }),
|
|
3540
|
+
` ${stripTerminalControls(args.link)}`,
|
|
3541
|
+
...show("Question", old.question, question),
|
|
3542
|
+
...show("Body", old.body, body),
|
|
3543
|
+
...(choicesChanged
|
|
3544
|
+
? [` Choices (nobody has answered yet — pills are re-seeded):`,
|
|
3545
|
+
...oldChoices.map((c, i) => ` - ${i + 1}. ${askFlatten(c)}`),
|
|
3546
|
+
...choices.map((c, i) => ` + ${i + 1}. ${askFlatten(c)}`)]
|
|
3547
|
+
: [` Choices: (unchanged)`]),
|
|
3548
|
+
...(lang !== old.lang ? [` Language: ${old.lang} → ${lang} (the instructions are rewritten)`] : []),
|
|
3549
|
+
`----------------------------------------------`,
|
|
3550
|
+
]);
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
await editMessage(token, t.channelId, t.ts, next, args.cookie, true);
|
|
3554
|
+
if (choicesChanged) {
|
|
3555
|
+
// Re-seed in order: 1..n then ❓ last, as a fresh question has them.
|
|
3556
|
+
for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
|
|
3557
|
+
try { await reactionRemove(token, t.channelId, t.ts, name, args.cookie); } catch { /* already gone */ }
|
|
3558
|
+
}
|
|
3559
|
+
await seedReactionsInOrder(
|
|
3560
|
+
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
|
|
3561
|
+
(name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
|
|
3562
|
+
(name, e) => console.error(` (リアクション ${name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`),
|
|
3563
|
+
);
|
|
3564
|
+
}
|
|
3565
|
+
console.error(`✓ 編集しました: ${stripTerminalControls(args.link)}`);
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
/** `slack ask --ls [--stale <dur>] [--state …] [--channel …] [--json]` — the
|
|
3569
|
+
* questions I posted, with their LIVE state.
|
|
3570
|
+
*
|
|
3571
|
+
* The list comes from the local sent-log (every `ask` is recorded there with
|
|
3572
|
+
* its channel, ts and identity, user or bot); the state comes from Slack, one
|
|
3573
|
+
* read per question, so a question answered, voided or edited elsewhere shows
|
|
3574
|
+
* as it is now. Limitation: only asks sent from THIS machine's log.
|
|
3575
|
+
*
|
|
3576
|
+
* `--stale 24h` = open and older than that — the morning triage's list. It
|
|
3577
|
+
* prints, on stderr, the one `--void=` command that would retire them all
|
|
3578
|
+
* (behind the usual gate), rather than voiding anything itself. */
|
|
3579
|
+
async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }, args: {
|
|
3580
|
+
stale?: string; state: string; channel?: string; json: boolean; limit: number;
|
|
3581
|
+
}): Promise<void> {
|
|
3582
|
+
const q: Parameters<typeof querySent>[0] = { kind: "ask", limit: args.limit };
|
|
3583
|
+
if (args.channel) q.channel = args.channel;
|
|
3584
|
+
const rows = querySent(q);
|
|
3585
|
+
if (!rows) {
|
|
3586
|
+
console.error(`Error: ${SENT_LOG_UNAVAILABLE}\n (${sentDbPath()})`);
|
|
3587
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3588
|
+
}
|
|
3589
|
+
let staleBefore: number | undefined;
|
|
3590
|
+
if (args.stale) {
|
|
3591
|
+
staleBefore = parseSince(args.stale);
|
|
3592
|
+
if (staleBefore === undefined) {
|
|
3593
|
+
console.error(`Error: --stale needs a duration like 24h / 3d (got ${stripTerminalControls(args.stale)})`);
|
|
3594
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
type Item = { state: string; age_h: number; asBot: boolean; channel: string; target: string; link: string; question: string; answer?: string };
|
|
3598
|
+
const items: Item[] = [];
|
|
3599
|
+
for (const r of rows) {
|
|
3600
|
+
if (staleBefore !== undefined && r.sent_at > staleBefore) continue;
|
|
3601
|
+
const token = r.as_bot ? tokens.bot : tokens.user;
|
|
3602
|
+
const link = r.permalink || `${r.channel}:${r.ts}`;
|
|
3603
|
+
let state = "unknown";
|
|
3604
|
+
let question = (r.text.split("\n")[0] ?? "").replace(/^:question: /, "");
|
|
3605
|
+
let answer: string | undefined;
|
|
3606
|
+
if (token) {
|
|
3607
|
+
try {
|
|
3608
|
+
const got = await askFetchByLink(token, `${r.channel}:${r.ts}`, r.as_bot ? undefined : tokens.cookie);
|
|
3609
|
+
if (!got?.msg) state = "gone";
|
|
3610
|
+
else {
|
|
3611
|
+
const p = askParseMessage(typeof got.msg.text === "string" ? got.msg.text : "");
|
|
3612
|
+
state = p.kind === "resolved" ? "answered" : p.kind === "other" ? "edited" : p.kind;
|
|
3613
|
+
if (p.kind !== "other" && p.question) question = p.question;
|
|
3614
|
+
if (p.kind === "resolved") answer = p.answer;
|
|
3615
|
+
}
|
|
3616
|
+
} catch {
|
|
3617
|
+
state = "unknown";
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
if (args.state !== "all" && state !== args.state) continue;
|
|
3621
|
+
const item: Item = {
|
|
3622
|
+
state, age_h: Math.round((Date.now() - r.sent_at) / 3600e3), asBot: !!r.as_bot,
|
|
3623
|
+
channel: r.channel, target: r.target ?? r.channel, link, question: askFlatten(question),
|
|
3624
|
+
};
|
|
3625
|
+
if (answer !== undefined) item.answer = answer;
|
|
3626
|
+
items.push(item);
|
|
3627
|
+
}
|
|
3628
|
+
if (args.json) {
|
|
3629
|
+
console.log(JSON.stringify(items));
|
|
3630
|
+
} else {
|
|
3631
|
+
for (const it of items) {
|
|
3632
|
+
console.log(`${it.state.padEnd(8)} ${String(it.age_h).padStart(4)}h ${it.asBot ? "bot " : "user"} ${stripTerminalControls(it.target)} ${it.link}\n ${stripTerminalControls(it.question).slice(0, 100)}`);
|
|
3633
|
+
}
|
|
3634
|
+
console.error(`${items.length} question(s)${args.state === "all" ? "" : ` in state "${args.state}"`}${args.stale ? `, older than ${args.stale}` : ""}.`);
|
|
3635
|
+
}
|
|
3636
|
+
// The bulk path: one gated command per identity, never an automatic void.
|
|
3637
|
+
const open = items.filter((it) => it.state === "open");
|
|
3638
|
+
if (args.stale && open.length) {
|
|
3639
|
+
for (const bot of [false, true]) {
|
|
3640
|
+
const mine = open.filter((it) => it.asBot === bot);
|
|
3641
|
+
if (!mine.length) continue;
|
|
3642
|
+
console.error(`無効化するなら (${mine.length} 件, ${bot ? "bot" : "user"}):\n slack ask ${mine.map((it) => `--void='${it.link}'`).join(" ")} --reason 'stale'${bot ? " --as-bot" : ""}`);
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
|
|
3647
|
+
/** A question about to be voided: where it is, and what it parsed as. */
|
|
3648
|
+
interface AskVoidTarget {
|
|
3649
|
+
link: string;
|
|
3650
|
+
channelId: string;
|
|
3651
|
+
ts: string;
|
|
3652
|
+
parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>;
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
/** Void one question: the 🚫 text (body kept), our seeds and ❓ off, 🚫 on.
|
|
3656
|
+
* The text is what marks it void; the reactions are cleanup + searchability,
|
|
3657
|
+
* so a failure there is reported and does not undo the void. */
|
|
3658
|
+
async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, supersededBy: string | undefined, cookie?: string): Promise<boolean> {
|
|
3659
|
+
const shown = stripTerminalControls(t.link);
|
|
3660
|
+
try {
|
|
3661
|
+
const next = askBuildVoidText(t.parsed.question, reason, t.parsed.lang, t.parsed.body || undefined, supersededBy);
|
|
3662
|
+
await editMessage(token, t.channelId, t.ts, next, cookie, true);
|
|
3663
|
+
} catch (e: unknown) {
|
|
3664
|
+
console.error(`✗ ${shown}: 書き換えに失敗しました: ${e instanceof Error ? e.message : String(e)}`);
|
|
3665
|
+
return false;
|
|
3666
|
+
}
|
|
3667
|
+
for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
|
|
3668
|
+
try { await reactionRemove(token, t.channelId, t.ts, name, cookie); } catch { /* not ours / already gone */ }
|
|
3669
|
+
}
|
|
3670
|
+
try {
|
|
3671
|
+
await reactionAdd(token, t.channelId, t.ts, ASK_VOID_MARKER, cookie);
|
|
3672
|
+
} catch (e: unknown) {
|
|
3673
|
+
console.error(` (${shown}: 🚫 リアクションを付けられませんでした: ${e instanceof Error ? e.message : String(e)})`);
|
|
3674
|
+
}
|
|
3675
|
+
console.error(`✓ 無効化: ${shown}`);
|
|
3676
|
+
return true;
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
/** `slack ask --void <permalink>…` — 無効化: mark questions that have expired or
|
|
3680
|
+
* stopped meaning anything, so nobody answers them and nothing waits on them.
|
|
3681
|
+
*
|
|
3682
|
+
* Case, 2026-10-05: a release ask was pinned to one head SHA; three minutes
|
|
3683
|
+
* later main moved and approving it would have approved a different head. The
|
|
3684
|
+
* bot had to void it by hand (prefix the text, leave the pills), and nothing
|
|
3685
|
+
* machine-readable said so: `--waitFor` still treated it as open, the
|
|
3686
|
+
* stale-pill reminder still counted it, the pills were still pressable.
|
|
3687
|
+
*
|
|
3688
|
+
* What voiding does, per question: the text becomes the 🚫 form (head, void
|
|
3689
|
+
* stamp with the reason, optional pointer to the replacement, the body); our
|
|
3690
|
+
* seeded pills and the ❓ marker come off; a 🚫 reaction goes on. Pills someone
|
|
3691
|
+
* already pressed stay — Slack lets only their owner remove them — and are
|
|
3692
|
+
* never collected, because a void is never polled again.
|
|
3693
|
+
*
|
|
3694
|
+
* Only your OWN questions: Slack lets only the author edit a message, and
|
|
3695
|
+
* voiding someone else's decision is not a thing this should make possible.
|
|
3696
|
+
* An answered question is final and is refused; an already-void one is a
|
|
3697
|
+
* no-op. Several links at once — the morning triage found 38 stale pills —
|
|
3698
|
+
* all behind ONE confirm code covering every target and the reason. */
|
|
3699
|
+
async function cmdAskVoid(token: string, args: { links: string[]; reason: string; supersededBy?: string; code?: string; asBot: boolean; cookie?: string }): Promise<void> {
|
|
3700
|
+
const self = await selfIdentity(token, args.cookie);
|
|
3701
|
+
// The replacement link is written into every voided question for good, so it
|
|
3702
|
+
// must name a real message before anything is touched — a typo would point
|
|
3703
|
+
// people at nothing, permanently.
|
|
3704
|
+
if (args.supersededBy) {
|
|
3705
|
+
const shown = stripTerminalControls(args.supersededBy);
|
|
3706
|
+
let found = false;
|
|
3707
|
+
try {
|
|
3708
|
+
const rep = await askFetchByLink(token, args.supersededBy, args.cookie);
|
|
3709
|
+
found = !!rep?.msg;
|
|
3710
|
+
if (!rep) {
|
|
3711
|
+
console.error(`Error: --superseded-by must be a message permalink (or 'C…:1700000000.000100'): ${shown}`);
|
|
3712
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3713
|
+
}
|
|
3714
|
+
} catch (e: unknown) {
|
|
3715
|
+
console.error(`Error: --superseded-by を確認できません: ${shown} (${e instanceof Error ? e.message : String(e)})`);
|
|
3716
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3717
|
+
}
|
|
3718
|
+
if (!found) {
|
|
3719
|
+
console.error(`Error: --superseded-by のメッセージが見つかりません: ${shown}`);
|
|
3720
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; pressed: string[]; unreadable?: boolean };
|
|
3724
|
+
const targets: Target[] = [];
|
|
3725
|
+
let refused = 0;
|
|
3726
|
+
for (const link of args.links) {
|
|
3727
|
+
const shown = stripTerminalControls(link);
|
|
3728
|
+
const got = await askFetchByLink(token, link, args.cookie);
|
|
3729
|
+
if (!got) { console.error(`✗ ${shown}: permalink (または 'C…:1700000000.000100') ではありません`); refused++; continue; }
|
|
3730
|
+
if (!got.msg) { console.error(`✗ ${shown}: メッセージが見つかりません`); refused++; continue; }
|
|
3731
|
+
const msg = got.msg;
|
|
3732
|
+
const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
|
|
3733
|
+
if (!own) {
|
|
3734
|
+
console.error(`✗ ${shown}: 自分(${senderLabel(self, args.asBot)})が投稿した質問ではないので無効化できません` +
|
|
3735
|
+
`${args.asBot ? "" : " — bot が出した質問なら --as-bot で"}`);
|
|
3736
|
+
refused++;
|
|
3737
|
+
continue;
|
|
3738
|
+
}
|
|
3739
|
+
const text = typeof msg.text === "string" ? msg.text : "";
|
|
3740
|
+
let parsed = askParseMessage(text);
|
|
3741
|
+
let unreadable = false;
|
|
3742
|
+
if (parsed.kind === "void") { console.error(`- ${shown}: すでに無効化済みです(何もしません)`); continue; }
|
|
3743
|
+
if (parsed.kind === "resolved") { console.error(`✗ ${shown}: 回答済みの質問は無効化できません(回答: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})`); refused++; continue; }
|
|
3744
|
+
if (parsed.kind !== "open") {
|
|
3745
|
+
// Our own ❓ question whose layout no longer parses (2026-10-01: a
|
|
3746
|
+
// `slack edit` flattened one; it could then be neither collected nor
|
|
3747
|
+
// voided, and stayed ❓ forever). Voiding is exactly what is left to do
|
|
3748
|
+
// with it, so it is voided from a best-effort read — question and body
|
|
3749
|
+
// kept as they are, our own seeded pills removed — and the gate says so.
|
|
3750
|
+
const salvage = askSalvageUnreadable(text);
|
|
3751
|
+
if (!salvage) { console.error(`✗ ${shown}: \`slack ask\` の質問として読み取れません — ${askExplainReject(text)}`); refused++; continue; }
|
|
3752
|
+
const seeded = asArray(msg.reactions).map(asRecord)
|
|
3753
|
+
.filter((r) => asArray(r.users).includes(self?.userId ?? "\u0000"))
|
|
3754
|
+
.map((r) => ASK_KEYCAPS.findIndex((k) => k.name === r.name))
|
|
3755
|
+
.filter((i) => i >= 0);
|
|
3756
|
+
parsed = {
|
|
3757
|
+
kind: "open", question: salvage.question, body: salvage.body,
|
|
3758
|
+
// Placeholders: only the COUNT is used — which of our keycap seeds to take off.
|
|
3759
|
+
reactable: Array.from({ length: seeded.length ? Math.max(...seeded) + 1 : 0 }, () => ""),
|
|
3760
|
+
overflow: [], threadOnly: true, lang: askDetectLang(text) ?? "ja",
|
|
3761
|
+
};
|
|
3762
|
+
unreadable = true;
|
|
3763
|
+
}
|
|
3764
|
+
const pressed = asArray(msg.reactions).map(asRecord)
|
|
3765
|
+
.filter((r) => typeof r.name === "string" && ASK_KEYCAPS.some((k) => k.name === r.name))
|
|
3766
|
+
.filter((r) => asArray(r.users).some((u) => u !== self?.userId))
|
|
3767
|
+
.map((r) => `:${String(r.name)}:`);
|
|
3768
|
+
targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, pressed, ...(unreadable ? { unreadable } : {}) });
|
|
3769
|
+
}
|
|
3770
|
+
if (!targets.length) {
|
|
3771
|
+
console.error(refused ? "無効化できる質問がありません。" : "何もすることがありません。");
|
|
3772
|
+
process.exit(refused ? ASK_EXIT_ERROR : 0);
|
|
3773
|
+
}
|
|
3774
|
+
|
|
3775
|
+
const code = safetyCode("ask-void", ...targets.map((t) => `${t.channelId}:${t.ts}`), args.reason, args.supersededBy ?? "", self?.userId ?? "");
|
|
3776
|
+
if (args.code !== code) {
|
|
3777
|
+
requireCode(args.code, code, [
|
|
3778
|
+
`--- 無効化 (void) -------------------------------`,
|
|
3779
|
+
fromLine(self, { asBot: args.asBot }),
|
|
3780
|
+
` Reason: ${args.reason ? stripTerminalControls(args.reason) : "(none)"}`,
|
|
3781
|
+
...(args.supersededBy ? [` Replaced: ${stripTerminalControls(args.supersededBy)}`] : []),
|
|
3782
|
+
` ${targets.length} question(s):`,
|
|
3783
|
+
...targets.map((t) => ` • ${stripTerminalControls(t.link)}\n ${stripTerminalControls(t.parsed.question.split("\n")[0] ?? "").slice(0, 100)}` +
|
|
3784
|
+
(t.pressed.length ? `\n (already pressed: ${t.pressed.join(" ")} — left in place, never collected)` : "") +
|
|
3785
|
+
(t.unreadable ? `\n ⚠ its layout no longer reads as an ask (edited by hand?) — voided from a best-effort read: question and the rest of the text kept as they are` : "")),
|
|
3786
|
+
` Each becomes 🚫 (body kept, options and instructions removed); our pills and ❓ come off; a 🚫 reaction is added.`,
|
|
3787
|
+
...(refused ? [` (${refused} link(s) above were refused and will be skipped)`] : []),
|
|
3788
|
+
`----------------------------------------------`,
|
|
3789
|
+
]);
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3792
|
+
let failed = 0;
|
|
3793
|
+
for (const t of targets) {
|
|
3794
|
+
if (!(await askApplyVoid(token, t, args.reason, args.supersededBy, args.cookie))) failed++;
|
|
3795
|
+
}
|
|
3796
|
+
process.exit(failed || refused ? ASK_EXIT_ERROR : 0);
|
|
3797
|
+
}
|
|
3798
|
+
|
|
2999
3799
|
/** `slack ask --waitFor <permalink>` — collect the answer to a question that was
|
|
3000
3800
|
* posted without blocking.
|
|
3001
3801
|
*
|
|
@@ -3004,7 +3804,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3004
3804
|
* file to go stale, lose, or disagree with Slack, and any machine holding the
|
|
3005
3805
|
* permalink can collect — at the cost of the body being a parseable format
|
|
3006
3806
|
* (`askBuildText` ⇄ `askParseMessage`). */
|
|
3007
|
-
async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; cookie?: string; after?: string }): Promise<void> {
|
|
3807
|
+
async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string }): Promise<void> {
|
|
3008
3808
|
const url = parseSlackPermalink(args.link);
|
|
3009
3809
|
let channelId: string;
|
|
3010
3810
|
let ts: string;
|
|
@@ -3056,13 +3856,6 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3056
3856
|
);
|
|
3057
3857
|
process.exit(ASK_EXIT_ERROR);
|
|
3058
3858
|
}
|
|
3059
|
-
if (parsed.kind === "resolved") {
|
|
3060
|
-
// Answered while nobody was watching — the case that makes fire-and-forget
|
|
3061
|
-
// safe. Report it exactly as `--wait` would have.
|
|
3062
|
-
console.error(`✓ 回答済み: ${stripTerminalControls(parsed.question)}`);
|
|
3063
|
-
console.log(parsed.answer);
|
|
3064
|
-
return;
|
|
3065
|
-
}
|
|
3066
3859
|
|
|
3067
3860
|
// Who may answer, recovered from the tags the readers can see. They are
|
|
3068
3861
|
// already `<@U…>`-encoded in the stored text, so no directory lookup is
|
|
@@ -3081,6 +3874,64 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3081
3874
|
const counterpart = await imCounterpart(token, channelId, args.cookie);
|
|
3082
3875
|
if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
|
|
3083
3876
|
}
|
|
3877
|
+
if (parsed.kind === "void") {
|
|
3878
|
+
askReportVoid(parsed, args.link);
|
|
3879
|
+
if (args.json) {
|
|
3880
|
+
await askEmit(token, { json: true, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
|
|
3881
|
+
status: "void", answer: null, reason: parsed.reason,
|
|
3882
|
+
...(parsed.supersededBy ? { supersededBy: parsed.supersededBy } : {}),
|
|
3883
|
+
notes: [], cursor: askNextCursor(ts, args.after),
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3886
|
+
process.exit(ASK_EXIT_VOID);
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
if (parsed.kind === "resolved") {
|
|
3890
|
+
// Answered while nobody was watching — the case that makes fire-and-forget
|
|
3891
|
+
// safe. Report it exactly as `--wait` would have, plus any notes the
|
|
3892
|
+
// audience left in the thread AFTER it was answered: that is the note that
|
|
3893
|
+
// was lost in real use (2026-10-02), written 40 s after ✅.
|
|
3894
|
+
//
|
|
3895
|
+
// There is NO default cursor here. The ✅ edit time looks like one, but it
|
|
3896
|
+
// would drop every note written before ✅ whenever the run that wrote ✅ did
|
|
3897
|
+
// not deliver them: a crash between the edit and the output, or — the case
|
|
3898
|
+
// that matters — any question resolved by a build that did not collect
|
|
3899
|
+
// notes at all. A caller that wants no repeats passes the cursor back.
|
|
3900
|
+
//
|
|
3901
|
+
// What must not come back is the reply that IS the answer. The ✅ stamp says
|
|
3902
|
+
// whether the answer was a reply, and which number it picked: the first
|
|
3903
|
+
// audience reply before the ✅ edit that reads as that answer is it.
|
|
3904
|
+
console.error(`✓ 回答済み: ${stripTerminalControls(parsed.question)}`);
|
|
3905
|
+
const editedTs = typeof asRecord(msg.edited).ts === "string" ? String(asRecord(msg.edited).ts) : undefined;
|
|
3906
|
+
const how = askResolvedHow(text);
|
|
3907
|
+
let answerSkipped = false;
|
|
3908
|
+
const isAnswerReply = (m: Record<string, Json>): boolean => {
|
|
3909
|
+
if (answerSkipped || !how.byReply) return false;
|
|
3910
|
+
if (editedTs && Number(m.ts) > Number(editedTs)) return false;
|
|
3911
|
+
const t = typeof m.text === "string" ? m.text.trim() : "";
|
|
3912
|
+
const hit = t === parsed.answer || (how.n !== undefined && (new RegExp(`^[((]?${how.n}(?![0-9])`).test(t) || askReplyPicked(t, parsed.answer, how.n)));
|
|
3913
|
+
if (hit) answerSkipped = true;
|
|
3914
|
+
return hit;
|
|
3915
|
+
};
|
|
3916
|
+
const after = args.after;
|
|
3917
|
+
let notes: AskNote[] = [];
|
|
3918
|
+
if (audience.size || broadcast) {
|
|
3919
|
+
const isAnswerer = (user: unknown): user is string =>
|
|
3920
|
+
typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
|
|
3921
|
+
notes = await askCollectNotes(token, {
|
|
3922
|
+
channelId, ts, threadOnly: !!threadTs || !channelId.startsWith("D"),
|
|
3923
|
+
replyCount: Number(msg.reply_count) || 0, isAnswerer, askerUserId, askerBotId,
|
|
3924
|
+
...(threadTs ? { threadParentTs: threadTs } : {}),
|
|
3925
|
+
...(args.cookie ? { cookie: args.cookie } : {}),
|
|
3926
|
+
}, after, isAnswerReply);
|
|
3927
|
+
}
|
|
3928
|
+
await askEmit(token, { json: args.json, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
|
|
3929
|
+
status: "answered", answer: parsed.answer, notes,
|
|
3930
|
+
cursor: askNextCursor(ts, after, ...notes.map((n) => n.ts)),
|
|
3931
|
+
});
|
|
3932
|
+
return;
|
|
3933
|
+
}
|
|
3934
|
+
|
|
3084
3935
|
if (!audience.size && !broadcast) {
|
|
3085
3936
|
console.error(`Error: この質問は誰にも宛てられていないため、有効な回答者を判定できません: ${stripTerminalControls(args.link)}`);
|
|
3086
3937
|
process.exit(ASK_EXIT_ERROR);
|
|
@@ -3096,6 +3947,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3096
3947
|
reactable: parsed.reactable,
|
|
3097
3948
|
overflow: parsed.overflow,
|
|
3098
3949
|
threadOnly: parsed.threadOnly,
|
|
3950
|
+
lang: parsed.lang,
|
|
3099
3951
|
audience,
|
|
3100
3952
|
broadcast,
|
|
3101
3953
|
askerUserId,
|
|
@@ -3103,6 +3955,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3103
3955
|
timeout: args.timeout,
|
|
3104
3956
|
shown: args.link,
|
|
3105
3957
|
asBot: args.asBot,
|
|
3958
|
+
json: args.json,
|
|
3106
3959
|
};
|
|
3107
3960
|
if (threadTs) ctx.threadParentTs = threadTs;
|
|
3108
3961
|
if (args.cookie) ctx.cookie = args.cookie;
|
|
@@ -3145,6 +3998,7 @@ interface PollArgs {
|
|
|
3145
3998
|
cookie?: string;
|
|
3146
3999
|
mentionToken?: string;
|
|
3147
4000
|
mentionCookie?: string;
|
|
4001
|
+
allowUrlAdjacent?: boolean;
|
|
3148
4002
|
}
|
|
3149
4003
|
|
|
3150
4004
|
/** The `slack poll --results=…` line handed back after posting. Printed as a
|
|
@@ -3208,6 +4062,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
|
|
|
3208
4062
|
const multi = !!args.multi;
|
|
3209
4063
|
const until = args.until ?? "";
|
|
3210
4064
|
const message = pollBuildText(question, body, choices, { multi, deadline: until });
|
|
4065
|
+
guardUrlBoundaries(message, args.allowUrlAdjacent);
|
|
3211
4066
|
|
|
3212
4067
|
// Preview the destination's last message, exactly as `send`/`ask` do. Fail-soft.
|
|
3213
4068
|
let lastText = "";
|
|
@@ -3252,7 +4107,8 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
|
|
|
3252
4107
|
const attr = sentAttribution("poll");
|
|
3253
4108
|
const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
|
|
3254
4109
|
|
|
3255
|
-
// Marker first
|
|
4110
|
+
// Marker first. (`ask` seeds its marker LAST, because there it doubles as the
|
|
4111
|
+
// "other" choice; a poll has no such choice.) It is what makes
|
|
3256
4112
|
// `has::ballot_box_with_ballot:` list every poll. An unrelated reaction on the message is
|
|
3257
4113
|
// already ignored by the tally (it counts keycap names only), so this costs
|
|
3258
4114
|
// the ballot nothing.
|
|
@@ -3492,8 +4348,10 @@ interface ScheduleSendArgs {
|
|
|
3492
4348
|
/** How the caller overrode the target text, e.g. `--user-id U0123`. Set only
|
|
3493
4349
|
* when a FLAG supplied it — never when the command resolved the id itself. */
|
|
3494
4350
|
targetOverride?: string;
|
|
4351
|
+
allowUrlAdjacent?: boolean;
|
|
3495
4352
|
}
|
|
3496
4353
|
async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
|
|
4354
|
+
guardUrlBoundaries(args.message, args.allowUrlAdjacent);
|
|
3497
4355
|
const getSelf = selfLookup(token, args.cookie);
|
|
3498
4356
|
const { ref, threadTs } = parseTargetThread(args.target);
|
|
3499
4357
|
|
|
@@ -3639,8 +4497,10 @@ interface UploadArgs {
|
|
|
3639
4497
|
channelId?: string;
|
|
3640
4498
|
userId?: string;
|
|
3641
4499
|
cookie?: string;
|
|
4500
|
+
allowUrlAdjacent?: boolean;
|
|
3642
4501
|
}
|
|
3643
4502
|
async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
4503
|
+
if (args.comment !== undefined) guardUrlBoundaries(args.comment, args.allowUrlAdjacent);
|
|
3644
4504
|
const getSelf = selfLookup(token, args.cookie);
|
|
3645
4505
|
const { statSync, existsSync } = await import("node:fs");
|
|
3646
4506
|
const { basename } = await import("node:path");
|
|
@@ -4262,6 +5122,89 @@ async function main(): Promise<void> {
|
|
|
4262
5122
|
}
|
|
4263
5123
|
},
|
|
4264
5124
|
)
|
|
5125
|
+
.command(
|
|
5126
|
+
["rename <channel> <new-name>", "mv <channel> <new-name>"],
|
|
5127
|
+
"Rename a channel (confirm-hash safety gate)",
|
|
5128
|
+
(y2) => y2
|
|
5129
|
+
.positional("channel", { type: "string", demandOption: true, describe: "#name, name, or channel ID (C…/G…)" })
|
|
5130
|
+
.positional("new-name", { type: "string", demandOption: true, describe: "New channel name (Slack lowercases it; no spaces)" })
|
|
5131
|
+
.option("dry-run", { type: "boolean", default: false, describe: "Resolve the channel and print old -> new without renaming" })
|
|
5132
|
+
.option("code", { type: "string", describe: "Safety hash to confirm rename" }),
|
|
5133
|
+
async (argv) => {
|
|
5134
|
+
const token = tok(argv as W);
|
|
5135
|
+
const cookie = ck(argv as W);
|
|
5136
|
+
const ref = argv.channel!;
|
|
5137
|
+
if (ref.startsWith("@")) {
|
|
5138
|
+
console.error(`Error: ${ref} is a DM — only channels can be renamed.`);
|
|
5139
|
+
process.exit(1);
|
|
5140
|
+
}
|
|
5141
|
+
// A bare word is a channel name unless it is shaped like an ID.
|
|
5142
|
+
const channelRef = ref.startsWith("#") || /^[CG][A-Z0-9]{8,}$/.test(ref) ? ref : `#${ref}`;
|
|
5143
|
+
const newName = argv.newName!.replace(/^#/, "").toLowerCase();
|
|
5144
|
+
let channelId: string;
|
|
5145
|
+
let oldName: string;
|
|
5146
|
+
try {
|
|
5147
|
+
channelId = await resolveChannel(token, channelRef, cookie);
|
|
5148
|
+
} catch (e: unknown) {
|
|
5149
|
+
console.error(`Error: could not resolve channel ${ref}: ${e instanceof Error ? e.message : String(e)}`);
|
|
5150
|
+
process.exit(1);
|
|
5151
|
+
}
|
|
5152
|
+
try {
|
|
5153
|
+
const ch = asRecord(asRecord((await conversationInfo(token, channelId, cookie)) as Json).channel);
|
|
5154
|
+
if (ch.is_im === true || ch.is_mpim === true) {
|
|
5155
|
+
console.error(`Error: ${channelId} is a DM — only channels can be renamed.`);
|
|
5156
|
+
process.exit(1);
|
|
5157
|
+
}
|
|
5158
|
+
oldName = typeof ch.name === "string" ? ch.name : channelRef.replace(/^#/, "");
|
|
5159
|
+
} catch (e: unknown) {
|
|
5160
|
+
// conversations.info is only for the preview label — a token without
|
|
5161
|
+
// channels:read can still rename. A channel that doesn't exist can't.
|
|
5162
|
+
const raw = e instanceof Error ? e.message : String(e);
|
|
5163
|
+
if (/channel_not_found/.test(raw)) {
|
|
5164
|
+
console.error(`Error: could not resolve channel ${ref}: channel not found (or not visible to this token).`);
|
|
5165
|
+
process.exit(1);
|
|
5166
|
+
}
|
|
5167
|
+
oldName = channelRef.replace(/^#/, "");
|
|
5168
|
+
}
|
|
5169
|
+
if (argv.dryRun === true) {
|
|
5170
|
+
console.log(`#${oldName} -> #${newName} (${channelId})`);
|
|
5171
|
+
console.log(`(dry run — conversations.rename not called)`);
|
|
5172
|
+
return;
|
|
5173
|
+
}
|
|
5174
|
+
// The renamer is the actor of record in the channel's history, so the
|
|
5175
|
+
// gate names them and the code is bound to them.
|
|
5176
|
+
const self = await selfIdentity(token, cookie);
|
|
5177
|
+
const code = safetyCode("rename", channelId, newName, self?.userId ?? "");
|
|
5178
|
+
if (argv.code !== code) requireCode(argv.code, code, [
|
|
5179
|
+
`--- Renaming channel -------------------------`,
|
|
5180
|
+
fromLine(self, { pad: 9 }),
|
|
5181
|
+
` channel: ${channelId}`,
|
|
5182
|
+
` from: #${oldName}`,
|
|
5183
|
+
` to: #${newName}`,
|
|
5184
|
+
`--------------------------------────────────`,
|
|
5185
|
+
]);
|
|
5186
|
+
let renamed: { id: string; name: string };
|
|
5187
|
+
try {
|
|
5188
|
+
renamed = await renameChannel(token, channelId, newName, cookie);
|
|
5189
|
+
} catch (e: unknown) {
|
|
5190
|
+
const raw = e instanceof Error ? e.message : String(e);
|
|
5191
|
+
const errCode = raw.match(/Slack error on conversations\.rename: (\w+)/)?.[1];
|
|
5192
|
+
const hint: Record<string, string> = {
|
|
5193
|
+
name_taken: `Error: a channel named #${newName} already exists.`,
|
|
5194
|
+
invalid_name: `Error: "${newName}" is not a valid channel name (lowercase letters, numbers, hyphens, underscores; max 80 chars).`,
|
|
5195
|
+
invalid_name_specials: `Error: "${newName}" contains characters Slack doesn't allow in channel names.`,
|
|
5196
|
+
invalid_name_maxlength: `Error: "${newName}" is too long (max 80 chars).`,
|
|
5197
|
+
invalid_name_required: `Error: the new channel name can't be empty.`,
|
|
5198
|
+
not_authorized: `Error: this token isn't allowed to rename #${oldName} (workspace settings may restrict renaming to admins or the channel creator).`,
|
|
5199
|
+
not_in_channel: `Error: this token isn't a member of #${oldName} — join it first.`,
|
|
5200
|
+
is_archived: `Error: #${oldName} is archived — unarchive it before renaming.`,
|
|
5201
|
+
};
|
|
5202
|
+
console.error((errCode && hint[errCode]) ?? friendlySlackError(e));
|
|
5203
|
+
process.exit(1);
|
|
5204
|
+
}
|
|
5205
|
+
console.log(`✓ Renamed #${oldName} -> #${renamed.name} (${renamed.id})`);
|
|
5206
|
+
},
|
|
5207
|
+
)
|
|
4265
5208
|
.demandCommand(1, "")
|
|
4266
5209
|
.showHelpOnFail(true),
|
|
4267
5210
|
)
|
|
@@ -4457,6 +5400,15 @@ async function main(): Promise<void> {
|
|
|
4457
5400
|
.option("broadcast", { type: "boolean", default: false, describe: "Also send to channel: broadcast a threaded reply back to the channel (Slack's \"Also send to #channel\" checkbox). Only effective with a thread target." })
|
|
4458
5401
|
.option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real <@USERID> mentions (on by default; resolves via users.list, then channel members for Slack Connect guests). Unresolved handles stay as plain text. The confirm preview shows the converted message before sending. Disable with --no-mentions for literal @text." }),
|
|
4459
5402
|
async (argv) => {
|
|
5403
|
+
// An empty body is never a meaningful send. `ask` already refuses one
|
|
5404
|
+
// ("Error: question is empty"); `send` did not, so an empty draft
|
|
5405
|
+
// staged a confirm code and posting it produced a blank message.
|
|
5406
|
+
// Verification cannot catch this: an empty publish reads back as an
|
|
5407
|
+
// exact match, so refusing here is the only mechanism.
|
|
5408
|
+
if (!String(argv.message ?? "").trim()) {
|
|
5409
|
+
console.error("Error: message is empty");
|
|
5410
|
+
process.exit(1);
|
|
5411
|
+
}
|
|
4460
5412
|
const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] };
|
|
4461
5413
|
if (argv.code) args.code = argv.code;
|
|
4462
5414
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
@@ -4523,7 +5475,7 @@ async function main(): Promise<void> {
|
|
|
4523
5475
|
)
|
|
4524
5476
|
.command(
|
|
4525
5477
|
"ask [target] [question] [choices..]",
|
|
4526
|
-
"Ask a question with its choices pre-seeded as 1️⃣..🔟 reactions (confirm-hash safety gate)",
|
|
5478
|
+
"Ask a question with its choices pre-seeded as 1️⃣..🔟 reactions (confirm-hash safety gate). Lifecycle: --waitFor=<link> collect, --void=<link> 無効化 (verb aliases: `ask collect <link>`, `ask void <link…>`)",
|
|
4527
5479
|
(y) => y
|
|
4528
5480
|
.positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --waitFor)" })
|
|
4529
5481
|
.positional("question", { type: "string", describe: "The question. Must @tag whoever may answer (@alice), or the whole channel (@here / @channel / @everyone) — only their answer counts. In a 1:1 DM the other party counts automatically." })
|
|
@@ -4531,14 +5483,176 @@ async function main(): Promise<void> {
|
|
|
4531
5483
|
.option("code", { type: "string", describe: "Safety hash to confirm the ask" })
|
|
4532
5484
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
4533
5485
|
.option("body", { type: "string", describe: "Extra context shown under the question" })
|
|
4534
|
-
.option("
|
|
5486
|
+
.option("lang", { type: "string", choices: [...ASK_LANGS], describe: "Language of the instructions in the posted body. Default: $SLACK_TERM_LANG, else the answerers' Slack locale, else the question's own language, else the system locale, else ja" })
|
|
5487
|
+
.option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = a reply matched several choices (stdout empty), 5 = a free-text reply that picked none of the choices (the reply is on stdout; NOT a decision — the question stays open), 6 = the question was voided (無効化, `slack ask void`) — stop waiting." })
|
|
4535
5488
|
.option("waitFor", { type: "string", describe: "Collect the answer to a question already posted: pass its permalink. Nothing is posted. Same stdout/exit contract as --wait; --timeout 0 checks once and exits 2 if still open." })
|
|
4536
|
-
.option("after", { type: "string", describe: "With --waitFor:
|
|
5489
|
+
.option("after", { type: "string", describe: "With --waitFor: the cursor — everything at or before this ts was already delivered. Text replies there are neither answers nor notes again (pass the `cursor` a previous --json result gave you, or the reply ts an exit 5 printed). Reactions still count." })
|
|
5490
|
+
.option("void", { type: "string", array: true, describe: "無効化: void question(s) you posted that expired or stopped meaning anything — --void=<permalink>, repeatable. The text becomes 🚫 with --reason (body kept, options removed), our pills come off, and --wait/--waitFor on it exits 6. Same --code gate. Only your own questions (--as-bot for the bot's). Alias: `slack ask void <link…>`." })
|
|
5491
|
+
.option("reask", { type: "boolean", default: false, describe: "With ONE --void: post the question again (same channel/thread, same text and choices unless you give new ones as positionals / --body) and void the old one as replaced by it — one gate for both. The way to change the options of a question someone already answered." })
|
|
5492
|
+
.option("edit", { type: "string", describe: "Change an open question you posted: --edit=<permalink> [\"new question\" [choices…]] [--body …]. Rebuilt with the ask layout so collect still reads it. Options can change only while nobody has answered (then: --void=<link> --reask). Same --code gate." })
|
|
5493
|
+
.option("ls", { type: "boolean", default: false, describe: "List the questions you posted (local sent-log) with their LIVE state: --state open|answered|void|all (default open), --stale 24h, --channel, --json. With --stale it prints the --void command that would retire them." })
|
|
5494
|
+
.option("stale", { type: "string", describe: "With --ls: only open questions older than this (24h, 3d)" })
|
|
5495
|
+
.option("state", { type: "string", default: "open", choices: ["open", "answered", "void", "edited", "gone", "unknown", "all"], describe: "With --ls: which state to list" })
|
|
5496
|
+
.option("channel", { type: "string", describe: "With --ls: only this channel (id, or the target as typed)" })
|
|
5497
|
+
.option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
|
|
5498
|
+
.option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
|
|
5499
|
+
.option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
|
|
5500
|
+
.option("json", { type: "boolean", default: false, describe: "With --wait / --waitFor: print ONE JSON object on stdout — {status, answer, how, who, notes:[{ts,user,text,permalink}], cursor, permalink} — instead of the bare answer. `notes` are the audience's replies that are not the answer (thread replies; plain replies too in a 1:1 DM). Exit codes unchanged." })
|
|
4537
5501
|
.option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
|
|
4538
5502
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
4539
5503
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
4540
5504
|
.option("as-bot", { type: "boolean", default: false, describe: "Ask via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient" }),
|
|
4541
5505
|
async (argv) => {
|
|
5506
|
+
// Verbs. A target is always #chan / @user / a permalink / a raw ID, so a
|
|
5507
|
+
// bare word here cannot be one — `slack ask void …` is unambiguous.
|
|
5508
|
+
// slack ask void <link…> [--reason …] [--superseded-by <link>] 無効化
|
|
5509
|
+
// slack ask collect <link> = --waitFor
|
|
5510
|
+
// Lifecycle flags (maintainer: "maybe slack ask --edit=<target>"), primary;
|
|
5511
|
+
// the bare verbs `void` / `collect` stay as aliases.
|
|
5512
|
+
if (argv.ls) {
|
|
5513
|
+
const user = (() => { try { return tok(argv as W); } catch { return undefined; } })();
|
|
5514
|
+
const tokens: { user?: string; bot?: string; cookie?: string } = {};
|
|
5515
|
+
if (user) tokens.user = user;
|
|
5516
|
+
const bot = resolveBotToken();
|
|
5517
|
+
if (bot) tokens.bot = bot;
|
|
5518
|
+
const c = ck(argv as W);
|
|
5519
|
+
if (c) tokens.cookie = c;
|
|
5520
|
+
const a: { stale?: string; state: string; channel?: string; json: boolean; limit: number } = {
|
|
5521
|
+
state: String(argv.state ?? "open"), json: !!argv.json, limit: Number(argv.limit) || 50,
|
|
5522
|
+
};
|
|
5523
|
+
if (argv.stale) a.stale = String(argv.stale);
|
|
5524
|
+
if (argv.channel) a.channel = String(argv.channel);
|
|
5525
|
+
await cmdAskLs(tokens, a);
|
|
5526
|
+
return;
|
|
5527
|
+
}
|
|
5528
|
+
const askTokenFor = (asBot: boolean): { token: string; cookie?: string } => {
|
|
5529
|
+
if (!asBot) {
|
|
5530
|
+
const c = ck(argv as W);
|
|
5531
|
+
return c ? { token: tok(argv as W), cookie: c } : { token: tok(argv as W) };
|
|
5532
|
+
}
|
|
5533
|
+
const botToken = resolveBotToken();
|
|
5534
|
+
if (!botToken) {
|
|
5535
|
+
console.error(
|
|
5536
|
+
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
5537
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
5538
|
+
);
|
|
5539
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5540
|
+
}
|
|
5541
|
+
return { token: botToken };
|
|
5542
|
+
};
|
|
5543
|
+
// Positionals after a lifecycle flag are the NEW question and choices.
|
|
5544
|
+
const newQuestion = argv.target !== undefined && argv.target !== "void" && argv.target !== "collect" ? String(argv.target) : undefined;
|
|
5545
|
+
const newChoices = [argv.question, ...((argv.choices as string[] | undefined) ?? [])].filter((x) => x !== undefined).map(String);
|
|
5546
|
+
if (argv.edit) {
|
|
5547
|
+
if (argv.void || argv.wait || argv.waitFor) {
|
|
5548
|
+
console.error("Error: --edit changes one open question — do not combine it with --void/--wait/--waitFor.");
|
|
5549
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5550
|
+
}
|
|
5551
|
+
const { token, cookie } = askTokenFor(!!argv["as-bot"]);
|
|
5552
|
+
const a: Parameters<typeof cmdAskEdit>[1] = {
|
|
5553
|
+
link: String(argv.edit), asBot: !!argv["as-bot"], mentionToken: tok(argv as W),
|
|
5554
|
+
allowUrlAdjacent: !!argv["allow-url-adjacent"],
|
|
5555
|
+
};
|
|
5556
|
+
if (newQuestion !== undefined) a.question = newQuestion;
|
|
5557
|
+
if (newChoices.length) a.choices = newChoices.map((x) => x.trim()).filter(Boolean);
|
|
5558
|
+
if (argv.body !== undefined) a.body = String(argv.body);
|
|
5559
|
+
if (argv.lang) a.lang = String(argv.lang);
|
|
5560
|
+
if (argv.code) a.code = String(argv.code);
|
|
5561
|
+
if (cookie) a.cookie = cookie;
|
|
5562
|
+
const mc = ck(argv as W);
|
|
5563
|
+
if (mc) a.mentionCookie = mc;
|
|
5564
|
+
try {
|
|
5565
|
+
await cmdAskEdit(token, a);
|
|
5566
|
+
} catch (e: unknown) {
|
|
5567
|
+
console.error(friendlySlackError(e));
|
|
5568
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5569
|
+
}
|
|
5570
|
+
return;
|
|
5571
|
+
}
|
|
5572
|
+
// --void=<link> --reask: re-ask through the CREATE path below (same
|
|
5573
|
+
// gate, same audience rules), voiding the old question once it is up.
|
|
5574
|
+
let voidAfter: AskArgs["voidAfter"];
|
|
5575
|
+
const voidFlag = ((argv.void as string[] | undefined) ?? []).map(String).filter(Boolean);
|
|
5576
|
+
if (argv.reask) {
|
|
5577
|
+
if (voidFlag.length !== 1 || argv.target === "void") {
|
|
5578
|
+
console.error("Error: --reask goes with exactly one --void=<permalink> (the question to replace).");
|
|
5579
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5580
|
+
}
|
|
5581
|
+
if (argv.waitFor) {
|
|
5582
|
+
console.error("Error: --reask posts a new question — --wait works, --waitFor does not.");
|
|
5583
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5584
|
+
}
|
|
5585
|
+
const { token, cookie } = askTokenFor(!!argv["as-bot"]);
|
|
5586
|
+
const self = await selfIdentity(token, cookie);
|
|
5587
|
+
const t = await askLoadOwnOpen(token, voidFlag[0]!, self, !!argv["as-bot"], cookie);
|
|
5588
|
+
if (t === "void") { console.error(`✗ ${stripTerminalControls(voidFlag[0]!)}: すでに無効化済みです — 新しく聞くなら普通に slack ask で`); process.exit(ASK_EXIT_ERROR); }
|
|
5589
|
+
if (!t) process.exit(ASK_EXIT_ERROR);
|
|
5590
|
+
voidAfter = { ...t, reason: unescapeArg(String(argv.reason ?? "")).trim() };
|
|
5591
|
+
// Same place, same words, unless new ones were given. The create path
|
|
5592
|
+
// below unescapes its arguments, so stored text goes in escaped: a
|
|
5593
|
+
// literal `\n` in the old question must not turn into a line break.
|
|
5594
|
+
argv.target = t.threadTs ? `${t.channelId}:${t.threadTs}` : t.channelId;
|
|
5595
|
+
argv["channel-id"] = t.channelId;
|
|
5596
|
+
argv.question = newQuestion ?? escapeArg(t.parsed.question);
|
|
5597
|
+
argv.choices = newChoices.length ? newChoices : [...t.parsed.reactable, ...t.parsed.overflow].map(escapeArg);
|
|
5598
|
+
if (argv.body === undefined && t.parsed.body) argv.body = escapeArg(t.parsed.body);
|
|
5599
|
+
if (!argv.lang) argv.lang = t.parsed.lang;
|
|
5600
|
+
argv.void = undefined;
|
|
5601
|
+
}
|
|
5602
|
+
if (!voidAfter && (argv.target === "void" || voidFlag.length)) {
|
|
5603
|
+
const verbLinks = argv.target === "void" ? [argv.question, ...((argv.choices as string[] | undefined) ?? [])] : [];
|
|
5604
|
+
if (argv.target !== "void" && (argv.target || argv.question || (argv.choices as string[] | undefined)?.length)) {
|
|
5605
|
+
console.error("Error: --void voids existing questions — do not also pass a target/question/choices.");
|
|
5606
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5607
|
+
}
|
|
5608
|
+
const links = [...voidFlag, ...verbLinks.map((x) => String(x ?? ""))].filter(Boolean);
|
|
5609
|
+
if (!links.length) {
|
|
5610
|
+
console.error("Error: --void=<permalink> — which question(s)?");
|
|
5611
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5612
|
+
}
|
|
5613
|
+
if (argv.wait || argv.waitFor) {
|
|
5614
|
+
console.error("Error: --void voids existing questions — do not also pass --wait/--waitFor.");
|
|
5615
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5616
|
+
}
|
|
5617
|
+
let voidToken: string;
|
|
5618
|
+
let voidCookie: string | undefined;
|
|
5619
|
+
if (argv["as-bot"]) {
|
|
5620
|
+
const botToken = resolveBotToken();
|
|
5621
|
+
if (!botToken) {
|
|
5622
|
+
console.error(
|
|
5623
|
+
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
5624
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
5625
|
+
);
|
|
5626
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5627
|
+
}
|
|
5628
|
+
voidToken = botToken;
|
|
5629
|
+
} else {
|
|
5630
|
+
voidToken = tok(argv as W);
|
|
5631
|
+
voidCookie = ck(argv as W);
|
|
5632
|
+
}
|
|
5633
|
+
const a: { links: string[]; reason: string; supersededBy?: string; code?: string; asBot: boolean; cookie?: string } = {
|
|
5634
|
+
links, reason: unescapeArg(String(argv.reason ?? "")).trim(), asBot: !!argv["as-bot"],
|
|
5635
|
+
};
|
|
5636
|
+
if (argv["superseded-by"]) a.supersededBy = String(argv["superseded-by"]);
|
|
5637
|
+
if (argv.code) a.code = String(argv.code);
|
|
5638
|
+
if (voidCookie) a.cookie = voidCookie;
|
|
5639
|
+
try {
|
|
5640
|
+
await cmdAskVoid(voidToken, a);
|
|
5641
|
+
} catch (e: unknown) {
|
|
5642
|
+
console.error(friendlySlackError(e));
|
|
5643
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5644
|
+
}
|
|
5645
|
+
return;
|
|
5646
|
+
}
|
|
5647
|
+
if (argv.target === "collect") {
|
|
5648
|
+
if (!argv.question || (argv.choices as string[] | undefined)?.length) {
|
|
5649
|
+
console.error("Error: slack ask collect <permalink> — exactly one question.");
|
|
5650
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5651
|
+
}
|
|
5652
|
+
argv.waitFor = String(argv.question);
|
|
5653
|
+
argv.target = undefined;
|
|
5654
|
+
argv.question = undefined;
|
|
5655
|
+
}
|
|
4542
5656
|
const waitFor = argv.waitFor ? String(argv.waitFor) : "";
|
|
4543
5657
|
const timeout = Number(argv.timeout);
|
|
4544
5658
|
// 0 is a real value only for --waitFor ("look once"). For --wait it would
|
|
@@ -4572,7 +5686,7 @@ async function main(): Promise<void> {
|
|
|
4572
5686
|
waitCookie = ck(argv as W);
|
|
4573
5687
|
}
|
|
4574
5688
|
try {
|
|
4575
|
-
const a: { link: string; timeout: number; asBot: boolean; cookie?: string; after?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"] };
|
|
5689
|
+
const a: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"], json: !!argv.json };
|
|
4576
5690
|
if (waitCookie) a.cookie = waitCookie;
|
|
4577
5691
|
if (argv.after) {
|
|
4578
5692
|
const after = String(argv.after);
|
|
@@ -4607,7 +5721,10 @@ async function main(): Promise<void> {
|
|
|
4607
5721
|
};
|
|
4608
5722
|
if (argv.code) args.code = argv.code;
|
|
4609
5723
|
if (argv.body) args.body = argv.body;
|
|
5724
|
+
if (argv.lang) args.lang = String(argv.lang);
|
|
4610
5725
|
if (argv.wait) args.wait = true;
|
|
5726
|
+
if (argv.json) args.json = true;
|
|
5727
|
+
if (voidAfter) args.voidAfter = voidAfter;
|
|
4611
5728
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
4612
5729
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
4613
5730
|
// Always resolve @tags with the user token (it has users:read), even
|
|
@@ -4672,6 +5789,7 @@ async function main(): Promise<void> {
|
|
|
4672
5789
|
.option("no-notify", { type: "boolean", default: false, describe: "With --results: do not write the invalid-ballot notice back into the message (keeps it strictly read-only)" })
|
|
4673
5790
|
.option("code", { type: "string", describe: "Safety hash to confirm the post" })
|
|
4674
5791
|
.option("body", { type: "string", describe: "Extra context shown under the question" })
|
|
5792
|
+
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
4675
5793
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
4676
5794
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
4677
5795
|
.option("as-bot", { type: "boolean", default: false, describe: "Post via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient — and so YOUR OWN vote counts, since the seeds are then the bot's" }),
|
|
@@ -4741,6 +5859,7 @@ async function main(): Promise<void> {
|
|
|
4741
5859
|
if (argv.until) args.until = String(argv.until);
|
|
4742
5860
|
if (argv.code) args.code = argv.code;
|
|
4743
5861
|
if (argv.body) args.body = argv.body;
|
|
5862
|
+
if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
|
|
4744
5863
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
4745
5864
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
4746
5865
|
// Always resolve @tags with the user token (it has users:read), even
|
|
@@ -4857,12 +5976,23 @@ async function main(): Promise<void> {
|
|
|
4857
5976
|
.positional("message", { type: "string", demandOption: true })
|
|
4858
5977
|
.option("at", { type: "string", demandOption: true, describe: "Delivery time (ISO datetime or Unix ts)" })
|
|
4859
5978
|
.option("code", { type: "string", describe: "Safety hash to confirm" })
|
|
5979
|
+
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
4860
5980
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
4861
5981
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
4862
5982
|
.option("as-bot", { type: "boolean", default: false, describe: "Schedule via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient. Without it a scheduled DM to yourself is posted by you, and Slack never notifies you of your own message — it fires into silence." }),
|
|
4863
5983
|
async (argv) => {
|
|
5984
|
+
// An empty body is never a meaningful send. `ask` already refuses one
|
|
5985
|
+
// ("Error: question is empty"); `send` did not, so an empty draft
|
|
5986
|
+
// staged a confirm code and posting it produced a blank message.
|
|
5987
|
+
// Verification cannot catch this: an empty publish reads back as an
|
|
5988
|
+
// exact match, so refusing here is the only mechanism.
|
|
5989
|
+
if (!String(argv.message ?? "").trim()) {
|
|
5990
|
+
console.error("Error: message is empty");
|
|
5991
|
+
process.exit(1);
|
|
5992
|
+
}
|
|
4864
5993
|
const args: ScheduleSendArgs = { target: argv.target!, message: argv.message!, at: argv.at! };
|
|
4865
5994
|
if (argv.code) args.code = argv.code;
|
|
5995
|
+
if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
|
|
4866
5996
|
// Recorded here, not in the command, because only a flag counts as an
|
|
4867
5997
|
// override — under --as-bot the command sets args.userId itself from
|
|
4868
5998
|
// the target, and claiming THAT was an override would be a new lie.
|
|
@@ -4968,12 +6098,14 @@ async function main(): Promise<void> {
|
|
|
4968
6098
|
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
|
|
4969
6099
|
.positional("newText", { type: "string", demandOption: true })
|
|
4970
6100
|
.option("code", { type: "string", describe: "Safety hash to confirm edit" })
|
|
6101
|
+
.option("force", { type: "boolean", default: false, describe: "Apply an edit that BREAKS a `slack ask` / `slack poll` message (new text no longer reads as one, or an answered/void question made open again). Refused by default because collect reads that body back; edits that keep it readable (e.g. writing the ✅ form) need no flag. Prefer `slack ask --edit=` / `--void=`." })
|
|
4971
6102
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
4972
6103
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
4973
6104
|
.option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (on by default). Unresolved handles stay as plain text. Disable with --no-mentions for literal @text." })
|
|
4974
6105
|
.option("as-bot", { type: "boolean", default: false, describe: "Edit as the bot (xoxb / SLACK_BOT_TOKEN). REQUIRED to correct a message the bot posted: Slack only lets a token edit its OWN messages, so a bot-authored message is uneditable by the user token and chat.update returns cant_update_message. Address it by permalink or --channel-id; an @user target resolves against the BOT's DM list, which is where a bot-authored DM actually lives." }),
|
|
4975
6106
|
async (argv) => {
|
|
4976
6107
|
const args: EditArgs = { target: argv.target!, newText: argv.newText!, allowUrlAdjacent: argv["allow-url-adjacent"] };
|
|
6108
|
+
if (argv.force) args.force = true;
|
|
4977
6109
|
if (argv.code) args.code = argv.code;
|
|
4978
6110
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
4979
6111
|
if (argv.mentions !== false) args.mentions = true;
|
|
@@ -5352,6 +6484,7 @@ async function main(): Promise<void> {
|
|
|
5352
6484
|
.positional("file", { type: "string", array: true, demandOption: true, describe: "Path(s) to file(s)" })
|
|
5353
6485
|
.option("title", { type: "string", describe: "Title (single file only)" })
|
|
5354
6486
|
.option("comment", { type: "string", describe: "Initial comment (first file)" })
|
|
6487
|
+
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
5355
6488
|
.option("code", { type: "string", describe: "4-hex safety code to confirm upload" })
|
|
5356
6489
|
.option("channel-id", { type: "string" })
|
|
5357
6490
|
.option("user-id", { type: "string" }),
|
|
@@ -5359,6 +6492,7 @@ async function main(): Promise<void> {
|
|
|
5359
6492
|
const args: UploadArgs = { target: argv.target!, filePaths: argv.file as string[] };
|
|
5360
6493
|
if (argv.title) args.title = argv.title;
|
|
5361
6494
|
if (argv.comment) args.comment = argv.comment;
|
|
6495
|
+
if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
|
|
5362
6496
|
if (argv.code) args.code = argv.code;
|
|
5363
6497
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
5364
6498
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
@@ -5524,6 +6658,25 @@ async function main(): Promise<void> {
|
|
|
5524
6658
|
await cmdDump(tok(argv as W), argv.days, argv.limit, argv.filter);
|
|
5525
6659
|
},
|
|
5526
6660
|
)
|
|
6661
|
+
.command(
|
|
6662
|
+
"todos",
|
|
6663
|
+
"Slack's To-dos tab — your personal to-dos (read-only; requires xoxc desktop token). Not `slack todo` (reaction tasks)",
|
|
6664
|
+
(y) => y.command(
|
|
6665
|
+
["$0", "ls", "list"],
|
|
6666
|
+
"List your To-dos, soonest due first",
|
|
6667
|
+
(y2) => y2
|
|
6668
|
+
.option("filter", { type: "string", choices: [...TODO_FILTERS], default: "todo", describe: "todo = open to-dos; saved = everything saved; completed; archived" })
|
|
6669
|
+
.option("limit", { alias: "n", type: "number", default: 50, describe: "At most this many items" })
|
|
6670
|
+
.option("json", { type: "boolean", default: false, describe: "One JSON object per item (raw fields + permalink + message text)" }),
|
|
6671
|
+
async (argv) => {
|
|
6672
|
+
if (!Number.isInteger(argv.limit) || argv.limit < 1) {
|
|
6673
|
+
console.error(`Error: --limit must be a whole number of at least 1 (got ${argv.limit}).`);
|
|
6674
|
+
process.exit(2);
|
|
6675
|
+
}
|
|
6676
|
+
await cmdTodos(tok(argv as W), ck(argv as W), { filter: argv.filter as TodoFilter, limit: argv.limit, json: argv.json });
|
|
6677
|
+
},
|
|
6678
|
+
),
|
|
6679
|
+
)
|
|
5527
6680
|
.command(
|
|
5528
6681
|
"drafts",
|
|
5529
6682
|
"Manage message drafts (requires xoxc desktop token)",
|
|
@@ -5541,11 +6694,15 @@ async function main(): Promise<void> {
|
|
|
5541
6694
|
"Create a draft",
|
|
5542
6695
|
(y2) => y2
|
|
5543
6696
|
.positional("channel", { type: "string", demandOption: true })
|
|
5544
|
-
.positional("text", { type: "string", array: true, default: [] })
|
|
6697
|
+
.positional("text", { type: "string", array: true, default: [] })
|
|
6698
|
+
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing a bare URL that runs into non-ASCII text (it would link only up to that point)" }),
|
|
5545
6699
|
async (argv) => {
|
|
5546
6700
|
const token = tok(argv as W); const cookie = ck(argv as W);
|
|
5547
6701
|
const text = (argv.text as string[]).join(" ");
|
|
5548
6702
|
if (!text) { console.error("Usage: slack drafts new <#channel|@user> <text>"); process.exit(2); }
|
|
6703
|
+
// Drafts emit explicit link elements, so a URL glued BEFORE is fine
|
|
6704
|
+
// here; one that runs into text AFTER would be cut at a guess.
|
|
6705
|
+
guardUrlBoundaries(text, argv["allow-url-adjacent"], undefined, { sides: ["after"], markdown: false });
|
|
5549
6706
|
const channelId = await resolveChannel(token, argv.channel!, cookie);
|
|
5550
6707
|
const resp = (await createDraft(token, channelId, text, cookie)) as Record<string, Json>;
|
|
5551
6708
|
console.log(`✓ Draft created (id: ${asRecord(resp.draft).id ?? "?"})`);
|
|
@@ -5565,11 +6722,13 @@ async function main(): Promise<void> {
|
|
|
5565
6722
|
(y2) => y2
|
|
5566
6723
|
.positional("id", { type: "string", demandOption: true })
|
|
5567
6724
|
.positional("text", { type: "string", array: true, default: [] })
|
|
5568
|
-
.option("code", { type: "string" })
|
|
6725
|
+
.option("code", { type: "string" })
|
|
6726
|
+
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing a bare URL that runs into non-ASCII text (it would link only up to that point)" }),
|
|
5569
6727
|
async (argv) => {
|
|
5570
6728
|
const token = tok(argv as W); const cookie = ck(argv as W);
|
|
5571
6729
|
const text = (argv.text as string[]).join(" ");
|
|
5572
6730
|
if (!text) { console.error("Usage: slack drafts edit <id> <new-text>"); process.exit(2); }
|
|
6731
|
+
guardUrlBoundaries(text, argv["allow-url-adjacent"], undefined, { sides: ["after"], markdown: false });
|
|
5573
6732
|
const listResp = (await listDrafts(token, cookie)) as Record<string, Json>;
|
|
5574
6733
|
const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === argv.id);
|
|
5575
6734
|
if (!d) { console.error(`Draft not found: ${argv.id}`); process.exit(1); }
|