slack-term 1.43.0 → 1.44.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 +5 -0
- package/SKILL.md +13 -2
- package/dist/cli.js +208 -208
- package/package.json +1 -1
- package/ts/ask.ts +61 -4
- package/ts/cli.ts +185 -19
package/package.json
CHANGED
package/ts/ask.ts
CHANGED
|
@@ -41,6 +41,15 @@ export type AskFound = { answer: string; how: string; who?: string; ts?: string;
|
|
|
41
41
|
export const ASK_MARKER = "question";
|
|
42
42
|
export const ASK_MARKER_PREFIX = `:${ASK_MARKER}: `;
|
|
43
43
|
|
|
44
|
+
/** The "explain" pill (`--explain`): pressing it asks the asker to explain the
|
|
45
|
+
* question, and is NEVER an answer. Reported 2026-10-14: an "explain" offered
|
|
46
|
+
* as a numbered choice was collected as the decision and closed the question.
|
|
47
|
+
* Not ❓: ❓ means "none of these fit — my answer follows as a reply", and that
|
|
48
|
+
* reply resolves the question; ℹ️ means "I cannot answer yet", and the question
|
|
49
|
+
* stays open for a real answer after the explanation. */
|
|
50
|
+
export const ASK_EXPLAIN_MARKER = "information_source";
|
|
51
|
+
export const ASK_EXPLAIN_PREFIX = `:${ASK_EXPLAIN_MARKER}: `;
|
|
52
|
+
|
|
44
53
|
// The copy a reader sees, per language. Japanese is the default and its
|
|
45
54
|
// strings are a WIRE FORMAT: questions already in flight were posted with them
|
|
46
55
|
// and are recognised by them, so they must never be reworded. Adding a language
|
|
@@ -118,6 +127,14 @@ interface AskCopy {
|
|
|
118
127
|
overflowNote: string;
|
|
119
128
|
otherThread: string;
|
|
120
129
|
otherHere: string;
|
|
130
|
+
/** The free-text parser matches this exactly (`askIsExplainCopy`), so it is
|
|
131
|
+
* a wire format too: questions in flight carry it. */
|
|
132
|
+
explainLine: string;
|
|
133
|
+
/** WIRE FORMAT — never reword. `--wait`/`--waitFor` find a question's
|
|
134
|
+
* thread note by this exact copy (`askIsExplainNote`), so a reworded note
|
|
135
|
+
* is not found by later runs, which would post a second one, and the old
|
|
136
|
+
* note would stop being excluded from answers and notes. */
|
|
137
|
+
explainNote: string;
|
|
121
138
|
invalidPrefix: string;
|
|
122
139
|
answeredVia: (how: string, who: string) => string;
|
|
123
140
|
howReaction: (glyph: string) => string;
|
|
@@ -149,6 +166,8 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
|
|
|
149
166
|
overflowNote: "_11 番目以降はリアクションがないので、返信で答えてください。_",
|
|
150
167
|
otherThread: `${ASK_MARKER_PREFIX}その他 — スレッドで返信してください`,
|
|
151
168
|
otherHere: `${ASK_MARKER_PREFIX}その他 — このメッセージに返信してください`,
|
|
169
|
+
explainLine: `${ASK_EXPLAIN_PREFIX}説明して — 押すと質問者に説明を頼みます (回答にはなりません)`,
|
|
170
|
+
explainNote: `${ASK_EXPLAIN_PREFIX}説明のリクエストを受け付けました — このスレッドで説明します。質問はまだ開いているので、回答はそのあとで大丈夫です。`,
|
|
152
171
|
invalidPrefix: "_:warning: 同時に複数選ばれているため回答として数えていません — どれか 1 つだけ残してください: ",
|
|
153
172
|
answeredVia: (how, who) => `_${how}で回答済み${who ? ` (${who})` : ""}_`,
|
|
154
173
|
howReaction: (glyph) => `リアクション ${glyph}`,
|
|
@@ -168,6 +187,8 @@ export const ASK_COPY: Record<AskLang, AskCopy> = {
|
|
|
168
187
|
overflowNote: "_Choices 11 and up have no reaction — answer them with a reply._",
|
|
169
188
|
otherThread: `${ASK_MARKER_PREFIX}Other — reply in the thread`,
|
|
170
189
|
otherHere: `${ASK_MARKER_PREFIX}Other — reply to this message`,
|
|
190
|
+
explainLine: `${ASK_EXPLAIN_PREFIX}Explain — press to ask the asker for more detail (not an answer)`,
|
|
191
|
+
explainNote: `${ASK_EXPLAIN_PREFIX}Explanation requested — it will follow in this thread. The question stays open; answer it after that.`,
|
|
171
192
|
invalidPrefix: "_:warning: Several choices are selected, so this is not counted as an answer — keep only one: ",
|
|
172
193
|
answeredVia: (how, who) => `_Answered by ${how}${who ? ` (${who})` : ""}_`,
|
|
173
194
|
howReaction: (glyph) => `reaction ${glyph}`,
|
|
@@ -185,6 +206,22 @@ function askIsOtherLine(line: string | undefined): boolean {
|
|
|
185
206
|
return line !== undefined && (line.startsWith(ASK_MARKER_PREFIX) || line.startsWith("❓ "));
|
|
186
207
|
}
|
|
187
208
|
|
|
209
|
+
/** True for the explain line, in either spelling. Matched by prefix, like the
|
|
210
|
+
* other line — used where position alone identifies it (under ❓). */
|
|
211
|
+
function askIsExplainLine(line: string | undefined): boolean {
|
|
212
|
+
return line !== undefined && (line.startsWith(ASK_EXPLAIN_PREFIX) || line.startsWith("ℹ️ "));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** True only for this language's explain line AS WRITTEN (either spelling of
|
|
216
|
+
* the marker). For a free-text question, where nothing but position separates
|
|
217
|
+
* the explain line from the body above it: a body whose own last line starts
|
|
218
|
+
* with ℹ️ must not be read as the pill, so the copy has to match exactly. */
|
|
219
|
+
function askIsExplainCopy(line: string | undefined, c: AskCopy): boolean {
|
|
220
|
+
if (line === undefined) return false;
|
|
221
|
+
const norm = line.startsWith("ℹ️ ") ? ASK_EXPLAIN_PREFIX + line.slice("ℹ️ ".length) : line;
|
|
222
|
+
return norm === c.explainLine;
|
|
223
|
+
}
|
|
224
|
+
|
|
188
225
|
// The invalid-ballot notice, shared by `ask` and `poll` and written INTO the
|
|
189
226
|
// message rather than only onto the collector's terminal. The person who has to
|
|
190
227
|
// fix it is the voter, and the voter is in Slack — a warning that only the
|
|
@@ -228,6 +265,14 @@ export function applyInvalidNotice(text: string, invalid: string[], lang: AskLan
|
|
|
228
265
|
return out === text ? text : out;
|
|
229
266
|
}
|
|
230
267
|
|
|
268
|
+
/** True for the thread note `--wait`/`--waitFor` posts when ℹ️ is pressed, in
|
|
269
|
+
* either language. Our own bookkeeping: never an answer, never a note — even
|
|
270
|
+
* when the identity that posted it is in the audience (an @here question
|
|
271
|
+
* collected with another token). */
|
|
272
|
+
export function askIsExplainNote(text: unknown): boolean {
|
|
273
|
+
return typeof text === "string" && ASK_LANGS.some((l) => ASK_COPY[l].explainNote === text.trim());
|
|
274
|
+
}
|
|
275
|
+
|
|
231
276
|
/** True if this line is the notice — parsers skip it when walking up. */
|
|
232
277
|
export function isInvalidNotice(line: string | undefined): boolean {
|
|
233
278
|
return invalidLang(line) !== null;
|
|
@@ -293,7 +338,7 @@ export function askFlatten(s: string): string {
|
|
|
293
338
|
/** Question body: the prompt, the numbered choices matching the seeded pills,
|
|
294
339
|
* and an instruction naming the answer paths this command actually reads.
|
|
295
340
|
* `askParseMessage` is its inverse — keep the two in step. */
|
|
296
|
-
export function askBuildText(question: string, body: string, reactable: string[], overflow: string[], threadOnly: boolean, lang: AskLang = "ja"): string {
|
|
341
|
+
export function askBuildText(question: string, body: string, reactable: string[], overflow: string[], threadOnly: boolean, lang: AskLang = "ja", explain = false): string {
|
|
297
342
|
const c = ASK_COPY[lang];
|
|
298
343
|
const lines: string[] = [`${ASK_MARKER_PREFIX}*${question}*`];
|
|
299
344
|
if (body) lines.push("", body);
|
|
@@ -309,6 +354,8 @@ export function askBuildText(question: string, body: string, reactable: string[]
|
|
|
309
354
|
lines.push(...reactable.map((s, i) => `${askPill(i)} ${askFlatten(s)}`));
|
|
310
355
|
// Directly under the pills, matching the ❓ seeded right after them.
|
|
311
356
|
lines.push(threadOnly ? c.otherThread : c.otherHere);
|
|
357
|
+
// Under ❓, matching the ℹ️ seeded right after it.
|
|
358
|
+
if (explain) lines.push(c.explainLine);
|
|
312
359
|
if (overflow.length) {
|
|
313
360
|
lines.push("");
|
|
314
361
|
lines.push(...overflow.map((s, i) => `(${i + 11}) ${askFlatten(s)}`));
|
|
@@ -321,6 +368,7 @@ export function askBuildText(question: string, body: string, reactable: string[]
|
|
|
321
368
|
lines.push(threadOnly ? c.instructionReactionThread : c.instructionReactionHere);
|
|
322
369
|
} else {
|
|
323
370
|
lines.push("");
|
|
371
|
+
if (explain) lines.push(c.explainLine, "");
|
|
324
372
|
lines.push(threadOnly ? c.instructionTextThread : c.instructionTextHere);
|
|
325
373
|
}
|
|
326
374
|
return lines.join("\n");
|
|
@@ -405,7 +453,7 @@ export function askSalvageUnreadable(text: string): { question: string; body: st
|
|
|
405
453
|
for (const instr of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere]) {
|
|
406
454
|
if (body.includes(instr)) wasAsk = true;
|
|
407
455
|
}
|
|
408
|
-
for (const fixed of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere, c.overflowNote, c.otherThread, c.otherHere]) {
|
|
456
|
+
for (const fixed of [c.instructionReactionThread, c.instructionReactionHere, c.instructionTextThread, c.instructionTextHere, c.overflowNote, c.otherThread, c.otherHere, c.explainLine]) {
|
|
409
457
|
body = body.split(fixed).join(" ");
|
|
410
458
|
}
|
|
411
459
|
const at = body.indexOf(c.invalidPrefix);
|
|
@@ -454,7 +502,8 @@ function askDecodeEntities(s: string): string {
|
|
|
454
502
|
}
|
|
455
503
|
|
|
456
504
|
export type AskParsed =
|
|
457
|
-
|
|
505
|
+
/** `explain`: the body offers the ℹ️ explain pill (`--explain`). */
|
|
506
|
+
| { kind: "open"; question: string; body: string; reactable: string[]; overflow: string[]; threadOnly: boolean; lang: AskLang; explain: boolean }
|
|
458
507
|
| { kind: "resolved"; question: string; answer: string }
|
|
459
508
|
/** Voided (無効化). `reason` is the stamp line as written; `supersededBy` the
|
|
460
509
|
* replacement's link when one was given. */
|
|
@@ -594,6 +643,7 @@ export function askParseMessage(text: string): AskParsed {
|
|
|
594
643
|
// there is the body — kept by the ✅ rewrite.
|
|
595
644
|
let bodyEnd = lines.length - 1;
|
|
596
645
|
if (isInvalidNotice(lines[bodyEnd - 1])) bodyEnd--;
|
|
646
|
+
let explain = false;
|
|
597
647
|
if (hasChoices) {
|
|
598
648
|
// Walk UP from the instruction line rather than down from the question. The
|
|
599
649
|
// choice block is anchored to the bottom of the body, and scanning downward
|
|
@@ -609,6 +659,7 @@ export function askParseMessage(text: string): AskParsed {
|
|
|
609
659
|
if (lines[i] !== "") return { kind: "other" };
|
|
610
660
|
i--;
|
|
611
661
|
}
|
|
662
|
+
if (askIsExplainLine(lines[i])) { explain = true; i--; }
|
|
612
663
|
if (askIsOtherLine(lines[i])) i--;
|
|
613
664
|
const raw: string[] = [];
|
|
614
665
|
while (i >= 0) {
|
|
@@ -634,10 +685,16 @@ export function askParseMessage(text: string): AskParsed {
|
|
|
634
685
|
for (let n = 0; n < overflow.length; n++) {
|
|
635
686
|
if (!lines.includes(`(${n + 11}) ${overflow[n]}`)) return { kind: "other" };
|
|
636
687
|
}
|
|
688
|
+
} else if (lines[bodyEnd - 1] === "" && askIsExplainCopy(lines[bodyEnd - 2], c) && lines[bodyEnd - 3] === "" && bodyEnd - 3 >= end) {
|
|
689
|
+
// A free-text question's explain line: blank, the line, blank, instruction
|
|
690
|
+
// — and the line is our copy verbatim. A body that merely ENDS on a line
|
|
691
|
+
// starting with ℹ️ (a question built without --explain) stays body.
|
|
692
|
+
explain = true;
|
|
693
|
+
bodyEnd -= 2;
|
|
637
694
|
}
|
|
638
695
|
|
|
639
696
|
const body = lines.slice(end + 1, bodyEnd).join("\n").replace(/^\n+|\n+$/g, "");
|
|
640
|
-
return { kind: "open", question, body, reactable, overflow, threadOnly, lang };
|
|
697
|
+
return { kind: "open", question, body, reactable, overflow, threadOnly, lang, explain };
|
|
641
698
|
}
|
|
642
699
|
|
|
643
700
|
/** Did a free-text reply actually PICK one of the offered choices?
|
package/ts/cli.ts
CHANGED
|
@@ -20,9 +20,11 @@ import { agentCommands } from "./agent.ts";
|
|
|
20
20
|
import {
|
|
21
21
|
ASK_KEYCAPS,
|
|
22
22
|
ASK_MARKER,
|
|
23
|
+
ASK_EXPLAIN_MARKER,
|
|
23
24
|
ASK_RESOLVED_MARKER,
|
|
24
25
|
ASK_MAX_REACTION_CHOICES,
|
|
25
26
|
askBuildText,
|
|
27
|
+
askIsExplainNote,
|
|
26
28
|
askBuildResolvedText,
|
|
27
29
|
askParseMessage,
|
|
28
30
|
askExplainReject,
|
|
@@ -93,6 +95,7 @@ import {
|
|
|
93
95
|
openDm,
|
|
94
96
|
parseSlackPermalink,
|
|
95
97
|
replies,
|
|
98
|
+
repliesPage,
|
|
96
99
|
resolveChannel,
|
|
97
100
|
resolveUserId,
|
|
98
101
|
search,
|
|
@@ -2457,6 +2460,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
2457
2460
|
// pressed later still settles it (`--waitFor … --after=<reply ts>`).
|
|
2458
2461
|
// 6 the question was VOIDED (無効化, `slack ask --void`) — it will never be
|
|
2459
2462
|
// answered; stop waiting. stdout stays empty.
|
|
2463
|
+
// 7 only with --explain-exit: someone pressed ℹ️ (explain) — the question is
|
|
2464
|
+
// STILL OPEN; explain in its thread, then collect again. stdout stays empty.
|
|
2460
2465
|
// Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
|
|
2461
2466
|
|
|
2462
2467
|
const ASK_EXIT_TIMEOUT = 2;
|
|
@@ -2476,6 +2481,20 @@ const ASK_EXIT_FREETEXT = 5;
|
|
|
2476
2481
|
* Neither "open" nor "answered": a monitor that kept waiting on it would wait
|
|
2477
2482
|
* forever, and one that read it as answered would act on nothing. */
|
|
2478
2483
|
const ASK_EXIT_VOID = 6;
|
|
2484
|
+
/** `--explain-exit` only: an audience member pressed ℹ️ and asked for an
|
|
2485
|
+
* explanation. Not an answer and not a state of the question — it stays open —
|
|
2486
|
+
* but a caller parked on `--waitFor` cannot explain anything until it hears.
|
|
2487
|
+
* Opt-in, so no existing caller meets a code it does not know. */
|
|
2488
|
+
const ASK_EXIT_EXPLAIN = 7;
|
|
2489
|
+
/** How many 200-reply pages the explain-note lookup reads before giving up. */
|
|
2490
|
+
const ASK_EXPLAIN_NOTE_MAX_PAGES = 10;
|
|
2491
|
+
|
|
2492
|
+
/** Whether a new question offers the ℹ️ explain pill when neither --explain nor
|
|
2493
|
+
* --no-explain is given: `SLACK_TERM_ASK_EXPLAIN=1` turns it on for every ask
|
|
2494
|
+
* from a fleet, without every caller having to remember the flag. */
|
|
2495
|
+
function askExplainDefault(): boolean {
|
|
2496
|
+
return /^(1|true|yes|on)$/i.test(process.env.SLACK_TERM_ASK_EXPLAIN ?? "");
|
|
2497
|
+
}
|
|
2479
2498
|
|
|
2480
2499
|
const ASK_PHI = 1.618033988749895;
|
|
2481
2500
|
const ASK_POLL_MIN_MS = 1000;
|
|
@@ -2500,6 +2519,10 @@ interface AskArgs {
|
|
|
2500
2519
|
userId?: string;
|
|
2501
2520
|
asBot?: boolean;
|
|
2502
2521
|
wait?: boolean;
|
|
2522
|
+
/** `--explain`: offer the ℹ️ pill — "explain this first", never an answer. */
|
|
2523
|
+
explain?: boolean;
|
|
2524
|
+
/** `--explain-exit` with `--wait`: exit 7 when an explanation is requested. */
|
|
2525
|
+
explainExit?: boolean;
|
|
2503
2526
|
/** `--json` with `--wait`: one JSON object on stdout (answer, notes, cursor). */
|
|
2504
2527
|
json?: boolean;
|
|
2505
2528
|
timeout?: number;
|
|
@@ -2614,6 +2637,11 @@ interface AskWaitCtx {
|
|
|
2614
2637
|
/** `--json`: stdout is one JSON object (answer + notes + cursor) instead of
|
|
2615
2638
|
* the bare answer. Exit codes are the same either way. */
|
|
2616
2639
|
json: boolean;
|
|
2640
|
+
/** `--explain-exit`: stop with exit 7 the first time an explanation is
|
|
2641
|
+
* requested (the run that posts the thread note), instead of waiting on. */
|
|
2642
|
+
explainExit?: boolean;
|
|
2643
|
+
/** The question offers the ℹ️ pill — so the ✅ rewrite takes our seed off. */
|
|
2644
|
+
explain?: boolean;
|
|
2617
2645
|
}
|
|
2618
2646
|
|
|
2619
2647
|
/** A reply from the question's audience that is NOT the answer — an extra
|
|
@@ -2682,7 +2710,7 @@ async function askCollectNotes(token: string, sc: AskNoteScope, after: string |
|
|
|
2682
2710
|
// integration's) is excluded here, so nothing echoes back as a "note".
|
|
2683
2711
|
.filter((m) => sc.isAnswerer(m.user) && !m.bot_id)
|
|
2684
2712
|
.filter((m) => typeof m.subtype !== "string" || ASK_ANSWERABLE_SUBTYPES.has(m.subtype))
|
|
2685
|
-
.filter((m) => typeof m.text === "string" && m.text.trim() !== "")
|
|
2713
|
+
.filter((m) => typeof m.text === "string" && m.text.trim() !== "" && !askIsExplainNote(m.text))
|
|
2686
2714
|
// Sorted BEFORE `skip`, which may be stateful ("the first reply that …").
|
|
2687
2715
|
.sort((a, b) => Number(a.ts) - Number(b.ts))
|
|
2688
2716
|
.filter((m) => !skip(m));
|
|
@@ -2712,12 +2740,15 @@ function askNextCursor(...tss: (string | undefined)[]): string {
|
|
|
2712
2740
|
async function askEmit(
|
|
2713
2741
|
token: string,
|
|
2714
2742
|
o: { json: boolean; shown: string; asBot: boolean; cookie?: string | undefined },
|
|
2715
|
-
r: { status: "answered" | "freetext" | "ambiguous" | "timeout" | "void"; answer: string | null; how?: string; who?: string; reason?: string; supersededBy?: string; notes: AskNote[]; cursor: string },
|
|
2743
|
+
r: { status: "answered" | "freetext" | "ambiguous" | "timeout" | "void" | "explain"; answer: string | null; how?: string; who?: string; reason?: string; supersededBy?: string; notes: AskNote[]; cursor: string; explain?: string[] },
|
|
2716
2744
|
): Promise<void> {
|
|
2717
2745
|
if (o.json) {
|
|
2718
2746
|
console.log(JSON.stringify({
|
|
2719
2747
|
status: r.status, answer: r.answer, how: r.how ?? null, who: r.who ?? null,
|
|
2720
2748
|
...(r.status === "void" ? { reason: r.reason ?? "", supersededBy: r.supersededBy ?? null } : {}),
|
|
2749
|
+
// Who is asking for an explanation right now. Only when someone is, so
|
|
2750
|
+
// the object every existing caller reads is unchanged otherwise.
|
|
2751
|
+
...(r.explain?.length ? { explain: r.explain } : {}),
|
|
2721
2752
|
notes: r.notes, cursor: r.cursor, permalink: o.shown,
|
|
2722
2753
|
}));
|
|
2723
2754
|
return;
|
|
@@ -2754,6 +2785,13 @@ interface AskPollState {
|
|
|
2754
2785
|
replyCount?: number;
|
|
2755
2786
|
/** The question's text as last read — what the ✅ rewrite keeps the body of. */
|
|
2756
2787
|
ownText?: string;
|
|
2788
|
+
/** Who is on ℹ️ (explain) right now — audience only, never a bot, never the
|
|
2789
|
+
* asker. Recomputed every pass, so a press taken back stops counting. Only
|
|
2790
|
+
* on a question that offers ℹ️ (`ctx.explain`). */
|
|
2791
|
+
explainNow: string[];
|
|
2792
|
+
/** THIS run posted the explain thread note — the one run `--explain-exit`
|
|
2793
|
+
* stops on. Never set read-only (`--pending` posts nothing). */
|
|
2794
|
+
explainPosted: boolean;
|
|
2757
2795
|
}
|
|
2758
2796
|
|
|
2759
2797
|
/** The polling half of `--wait`: one `poll()` is one look at the question,
|
|
@@ -2769,7 +2807,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
2769
2807
|
} {
|
|
2770
2808
|
const { channelId, ts, reactable, threadOnly, audience, broadcast, cookie } = ctx;
|
|
2771
2809
|
const copy = ASK_COPY[ctx.lang];
|
|
2772
|
-
const state: AskPollState = {};
|
|
2810
|
+
const state: AskPollState = { explainNow: [], explainPosted: false };
|
|
2773
2811
|
const askerUserId = ctx.askerUserId;
|
|
2774
2812
|
const askerBotId = ctx.askerBotId;
|
|
2775
2813
|
|
|
@@ -2845,7 +2883,93 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
2845
2883
|
}
|
|
2846
2884
|
}
|
|
2847
2885
|
|
|
2886
|
+
const explainReported = new Set<string>();
|
|
2887
|
+
const botCache = new Map<string, boolean>();
|
|
2888
|
+
/** The thread note is settled for this run (found already there, or posted). */
|
|
2889
|
+
let explainNoted = false;
|
|
2890
|
+
/** Null when it cannot be told — not cached, so the next pass asks again.
|
|
2891
|
+
* Until then the press does not count: a bot must never post the note or
|
|
2892
|
+
* wake an --explain-exit caller, and a person is only delayed a tick. */
|
|
2893
|
+
async function isBotUser(u: string): Promise<boolean | null> {
|
|
2894
|
+
if (u === "USLACKBOT") return true;
|
|
2895
|
+
const hit = botCache.get(u);
|
|
2896
|
+
if (hit !== undefined) return hit;
|
|
2897
|
+
try {
|
|
2898
|
+
// A reply with no boolean is_bot tells us nothing — unknown, not human.
|
|
2899
|
+
const bot = asRecord(asRecord((await userInfo(token, u, cookie)) as Json).user).is_bot;
|
|
2900
|
+
if (typeof bot !== "boolean") return null;
|
|
2901
|
+
botCache.set(u, bot);
|
|
2902
|
+
return bot;
|
|
2903
|
+
} catch {
|
|
2904
|
+
return null;
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
/** One thread note per QUESTION, however many presses and runs: the thread
|
|
2908
|
+
* itself is the record, so there is no local state to lose. The note is
|
|
2909
|
+
* recognised by its exact copy (either language), whoever posted it — the
|
|
2910
|
+
* identity that collects now need not be the one that collected before, and
|
|
2911
|
+
* under @here it may even be in the audience.
|
|
2912
|
+
*
|
|
2913
|
+
* The whole thread after the question is read (paged), not one page: a
|
|
2914
|
+
* question posted into a long thread may have more than a page before its
|
|
2915
|
+
* note. Past the page cap it is not posted at all ("exists") rather than
|
|
2916
|
+
* risk a duplicate.
|
|
2917
|
+
*
|
|
2918
|
+
* "unread": the thread could not be read — nothing is posted, and the next
|
|
2919
|
+
* pass tries again rather than risk a duplicate. "failed": the post itself
|
|
2920
|
+
* failed — not retried this run (no error every tick) and NOT a reason for
|
|
2921
|
+
* --explain-exit, which promises a note is up; the next run tries again. */
|
|
2922
|
+
async function postExplainNote(): Promise<"exists" | "posted" | "unread" | "failed"> {
|
|
2923
|
+
const root = ctx.threadParentTs ?? ts;
|
|
2924
|
+
try {
|
|
2925
|
+
let cursor: string | undefined;
|
|
2926
|
+
let pages = 0;
|
|
2927
|
+
for (;;) {
|
|
2928
|
+
const rep = asRecord((await repliesPage(token, channelId, root, { oldest: ts, ...(cursor ? { cursor } : {}) }, cookie)) as Json);
|
|
2929
|
+
if (asArray(rep.messages).map(asRecord).some((m) => Number(m.ts) > Number(ts) && askIsExplainNote(m.text))) return "exists";
|
|
2930
|
+
cursor = String(asRecord(rep.response_metadata).next_cursor ?? "") || undefined;
|
|
2931
|
+
if (!cursor) break;
|
|
2932
|
+
if (++pages >= ASK_EXPLAIN_NOTE_MAX_PAGES) return "exists";
|
|
2933
|
+
}
|
|
2934
|
+
} catch (e: unknown) {
|
|
2935
|
+
console.error(` (スレッドを読めないため、説明リクエストの案内はまだ出していません: ${e instanceof Error ? e.message : String(e)})`);
|
|
2936
|
+
return "unread";
|
|
2937
|
+
}
|
|
2938
|
+
try {
|
|
2939
|
+
await slackSend(token, channelId, copy.explainNote, root, false, cookie, true);
|
|
2940
|
+
} catch (e: unknown) {
|
|
2941
|
+
// The press is still reported on stderr; the note is a courtesy.
|
|
2942
|
+
console.error(` (説明リクエストの案内をスレッドに出せませんでした: ${e instanceof Error ? e.message : String(e)})`);
|
|
2943
|
+
return "failed";
|
|
2944
|
+
}
|
|
2945
|
+
return "posted";
|
|
2946
|
+
}
|
|
2947
|
+
async function noteExplain(msg: Record<string, Json>): Promise<void> {
|
|
2948
|
+
const r = asArray(msg.reactions).map(asRecord).find((x) => x.name === ASK_EXPLAIN_MARKER);
|
|
2949
|
+
const now: string[] = [];
|
|
2950
|
+
for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
|
|
2951
|
+
if ((await isBotUser(u)) !== false) continue;
|
|
2952
|
+
now.push(u);
|
|
2953
|
+
if (explainReported.has(u)) continue;
|
|
2954
|
+
explainReported.add(u);
|
|
2955
|
+
if (!readOnly) console.error(` (<@${u}> が ℹ️ 説明して を押しました — 回答ではありません。スレッドで説明してください。回答は待ち続けます)`);
|
|
2956
|
+
}
|
|
2957
|
+
state.explainNow = now;
|
|
2958
|
+
// Read-only (`--pending`): the press is reported, the note is not posted —
|
|
2959
|
+
// that is the waiting session's to do.
|
|
2960
|
+
if (now.length && !explainNoted && !readOnly) {
|
|
2961
|
+
const r = await postExplainNote();
|
|
2962
|
+
if (r !== "unread") {
|
|
2963
|
+
explainNoted = true;
|
|
2964
|
+
state.explainPosted = r === "posted";
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2848
2969
|
async function answerFromReactions(msg: Record<string, Json>): Promise<AskFound | null> {
|
|
2970
|
+
// Only a question that offers ℹ️ treats it as a request — on any other,
|
|
2971
|
+
// ℹ️ is just an emoji somebody added.
|
|
2972
|
+
if (ctx.explain) await noteExplain(msg);
|
|
2849
2973
|
if (reactable.length) noteOther(msg);
|
|
2850
2974
|
const picks = humanChoices(msg);
|
|
2851
2975
|
// Nothing ambiguous any more — take the notice back down if one is up.
|
|
@@ -2895,7 +3019,9 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
2895
3019
|
if (typeof m.subtype === "string" && !ASK_ANSWERABLE_SUBTYPES.has(m.subtype)) continue;
|
|
2896
3020
|
if (Number(m.ts) >= before) break;
|
|
2897
3021
|
const t = typeof m.text === "string" ? m.text.trim() : "";
|
|
2898
|
-
|
|
3022
|
+
// Our ℹ️ note is bookkeeping — only on a question that offers ℹ️, so an
|
|
3023
|
+
// older question's reply is never second-guessed.
|
|
3024
|
+
if (!t || (ctx.explain && askIsExplainNote(t))) continue;
|
|
2899
3025
|
// A question asked WITHOUT choices is answered by whatever comes back —
|
|
2900
3026
|
// there is nothing to match against, and every reply is the answer.
|
|
2901
3027
|
if (!candidates.length) return { answer: t, how: copy.howReply, who: m.user, ts: String(m.ts) };
|
|
@@ -3041,6 +3167,12 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3041
3167
|
} catch {
|
|
3042
3168
|
// Cosmetic: the ✅ in the body is what actually marks it resolved.
|
|
3043
3169
|
}
|
|
3170
|
+
try {
|
|
3171
|
+
// Asking for an explanation is over once it is answered.
|
|
3172
|
+
if (ctx.explain) await reactionRemove(token, channelId, ts, ASK_EXPLAIN_MARKER, cookie);
|
|
3173
|
+
} catch {
|
|
3174
|
+
// Cosmetic: the ✅ in the body is what actually marks it resolved.
|
|
3175
|
+
}
|
|
3044
3176
|
} catch (e: unknown) {
|
|
3045
3177
|
// The edit is bookkeeping, not the answer. It fails whenever the resuming
|
|
3046
3178
|
// token is not the one that posted (Slack lets only the author update), and
|
|
@@ -3102,6 +3234,15 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3102
3234
|
await askEmit(token, ctx, r);
|
|
3103
3235
|
process.exit(0);
|
|
3104
3236
|
}
|
|
3237
|
+
// ℹ️ is never an answer: the question stays open, ✅ is not written, the
|
|
3238
|
+
// seeds stay. Only a caller that asked to hear about it stops here — and
|
|
3239
|
+
// only on the run that posted the thread note, so a `--timeout 0` poller is
|
|
3240
|
+
// woken once per question, not on every look.
|
|
3241
|
+
if (state.explainPosted && ctx.explainExit) {
|
|
3242
|
+
console.error(` 説明を求められています (質問はまだ開いています)。スレッドで説明してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3243
|
+
if (ctx.json) await askEmit(token, ctx, { status: "explain", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
|
|
3244
|
+
process.exit(ASK_EXIT_EXPLAIN);
|
|
3245
|
+
}
|
|
3105
3246
|
// A free-text reply that picks nothing is delivered NOW, not held until the
|
|
3106
3247
|
// deadline: it is usually an instruction or a question back, and the only
|
|
3107
3248
|
// one who can act on it is the caller. Left unstamped — a pill pressed
|
|
@@ -3122,7 +3263,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3122
3263
|
// but still reports a pill pressed after it.
|
|
3123
3264
|
markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
|
|
3124
3265
|
if (ctx.json) {
|
|
3125
|
-
await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
|
|
3266
|
+
await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts), explain: state.explainNow });
|
|
3126
3267
|
} else {
|
|
3127
3268
|
console.log(unchosen.text);
|
|
3128
3269
|
}
|
|
@@ -3167,7 +3308,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3167
3308
|
console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
3168
3309
|
console.error(` ${shown}`);
|
|
3169
3310
|
console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3170
|
-
if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
|
|
3311
|
+
if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
|
|
3171
3312
|
process.exit(ASK_EXIT_UNCHOSEN);
|
|
3172
3313
|
}
|
|
3173
3314
|
if (timeout === 0) {
|
|
@@ -3177,7 +3318,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3177
3318
|
console.error(` ${shown}`);
|
|
3178
3319
|
console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3179
3320
|
}
|
|
3180
|
-
if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
|
|
3321
|
+
if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
|
|
3181
3322
|
process.exit(ASK_EXIT_TIMEOUT);
|
|
3182
3323
|
}
|
|
3183
3324
|
|
|
@@ -3306,7 +3447,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3306
3447
|
process.exit(ASK_EXIT_ERROR);
|
|
3307
3448
|
}
|
|
3308
3449
|
const lang = picked.lang;
|
|
3309
|
-
const
|
|
3450
|
+
const explain = args.explain ?? askExplainDefault();
|
|
3451
|
+
const message = askBuildText(question, body, reactable, overflow, threadOnly, lang, explain);
|
|
3310
3452
|
guardUrlBoundaries(message, args.allowUrlAdjacent);
|
|
3311
3453
|
|
|
3312
3454
|
// Preview the destination's last message, exactly as `send` does — the gate's
|
|
@@ -3357,6 +3499,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3357
3499
|
// shown as its own line rather than left to be inferred from the body.
|
|
3358
3500
|
` Answerable by: ${audienceLabel}`,
|
|
3359
3501
|
` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
|
|
3502
|
+
...(explain ? [` Explain: ℹ️ pill offered — pressing it asks you to explain; it is never an answer`] : []),
|
|
3360
3503
|
...(args.voidAfter ? [` Then 無効化: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) — replaced by this question`] : []),
|
|
3361
3504
|
` 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)`,
|
|
3362
3505
|
`--------------------------------────────────`,
|
|
@@ -3385,7 +3528,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3385
3528
|
// `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
|
|
3386
3529
|
// SLACK_REACTION_SEED_GAP_MS.
|
|
3387
3530
|
await seedReactionsInOrder(
|
|
3388
|
-
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
|
|
3531
|
+
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER, ...(explain ? [ASK_EXPLAIN_MARKER] : [])],
|
|
3389
3532
|
(name) => reactionAdd(token, channelId, ts, name, cookie),
|
|
3390
3533
|
(name, e) => {
|
|
3391
3534
|
const why = e instanceof Error ? e.message : String(e);
|
|
@@ -3441,6 +3584,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3441
3584
|
shown,
|
|
3442
3585
|
asBot: !!args.asBot,
|
|
3443
3586
|
json: !!args.json,
|
|
3587
|
+
explain,
|
|
3588
|
+
explainExit: !!args.explainExit,
|
|
3444
3589
|
};
|
|
3445
3590
|
if (threadTs) ctx.threadParentTs = threadTs;
|
|
3446
3591
|
if (cookie) ctx.cookie = cookie;
|
|
@@ -3600,7 +3745,7 @@ async function cmdAskEdit(token: string, args: {
|
|
|
3600
3745
|
const reactable = choices.slice(0, ASK_MAX_REACTION_CHOICES);
|
|
3601
3746
|
const overflow = choices.slice(ASK_MAX_REACTION_CHOICES);
|
|
3602
3747
|
const oldText = typeof t.msg.text === "string" ? t.msg.text : "";
|
|
3603
|
-
let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang);
|
|
3748
|
+
let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang, old.explain);
|
|
3604
3749
|
// An invalid-ballot notice still in force stays (it names the people who
|
|
3605
3750
|
// have to fix their vote); it is only possible when the options did not change.
|
|
3606
3751
|
const invalid = readInvalidNotice(oldText);
|
|
@@ -3635,11 +3780,12 @@ async function cmdAskEdit(token: string, args: {
|
|
|
3635
3780
|
await editMessage(token, t.channelId, t.ts, next, args.cookie, true);
|
|
3636
3781
|
if (choicesChanged) {
|
|
3637
3782
|
// Re-seed in order: 1..n then ❓ last, as a fresh question has them.
|
|
3638
|
-
|
|
3783
|
+
const tail = old.explain ? [ASK_MARKER, ASK_EXPLAIN_MARKER] : [ASK_MARKER];
|
|
3784
|
+
for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail]) {
|
|
3639
3785
|
try { await reactionRemove(token, t.channelId, t.ts, name, args.cookie); } catch { /* already gone */ }
|
|
3640
3786
|
}
|
|
3641
3787
|
await seedReactionsInOrder(
|
|
3642
|
-
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name),
|
|
3788
|
+
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail],
|
|
3643
3789
|
(name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
|
|
3644
3790
|
(name, e) => console.error(` (リアクション ${name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`),
|
|
3645
3791
|
);
|
|
@@ -3765,7 +3911,10 @@ async function cmdAskPending(
|
|
|
3765
3911
|
}
|
|
3766
3912
|
const rows = opts.channel ? all.filter((r) => r.channel === opts.channel || r.target === opts.channel) : all;
|
|
3767
3913
|
const pending: AskPendingItem[] = [];
|
|
3768
|
-
const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0 };
|
|
3914
|
+
const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0, explain: 0 };
|
|
3915
|
+
/** Open questions someone pressed ℹ️ on — reported only: the thread note is
|
|
3916
|
+
* the waiting session's to post (`--pending` writes nothing). */
|
|
3917
|
+
const explainAsked: string[] = [];
|
|
3769
3918
|
const problems: string[] = [];
|
|
3770
3919
|
// The log is machine-wide, but a token reads one workspace. Asks recorded
|
|
3771
3920
|
// under another workspace are counted and named, not probed with the wrong
|
|
@@ -3810,6 +3959,10 @@ async function cmdAskPending(
|
|
|
3810
3959
|
const unchosen = poller.state.unchosen;
|
|
3811
3960
|
if (poller.state.voided) { count.void++; continue; }
|
|
3812
3961
|
const collect = askResumeCommand(link, asBot, after);
|
|
3962
|
+
if (poller.state.explainNow.length) {
|
|
3963
|
+
count.explain++;
|
|
3964
|
+
explainAsked.push(`${link}: ℹ️ 説明を求められています (${poller.state.explainNow.map((u) => `<@${u}>`).join(" ")}) — スレッドで説明してください`);
|
|
3965
|
+
}
|
|
3813
3966
|
const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
|
|
3814
3967
|
if (found) {
|
|
3815
3968
|
pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
|
|
@@ -3858,6 +4011,7 @@ async function cmdAskPending(
|
|
|
3858
4011
|
}
|
|
3859
4012
|
}
|
|
3860
4013
|
for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
|
|
4014
|
+
for (const p of explainAsked) console.error(` (${stripTerminalControls(p)})`);
|
|
3861
4015
|
for (const [team, n] of otherTeams) {
|
|
3862
4016
|
console.error(` (${n} 件は別のワークスペース「${stripTerminalControls(team)}」の質問です — slack -w <name> ask --pending で確認)`);
|
|
3863
4017
|
}
|
|
@@ -3866,6 +4020,7 @@ async function cmdAskPending(
|
|
|
3866
4020
|
(count.ambiguous ? `, ${count.ambiguous} 件は返信が複数の選択肢に一致 (未確定)` : "") +
|
|
3867
4021
|
(count.resolved ? `, ${count.resolved} 件は ✅ 回収済み` : "") +
|
|
3868
4022
|
(count.void ? `, ${count.void} 件は無効化済み` : "") +
|
|
4023
|
+
(count.explain ? `, ${count.explain} 件は ℹ️ 説明のリクエストあり` : "") +
|
|
3869
4024
|
` — ${rows.length} 件の未回収の質問を確認 (log: ${sentDbPath()})`,
|
|
3870
4025
|
);
|
|
3871
4026
|
}
|
|
@@ -3890,7 +4045,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
|
|
|
3890
4045
|
console.error(`✗ ${shown}: 書き換えに失敗しました: ${e instanceof Error ? e.message : String(e)}`);
|
|
3891
4046
|
return false;
|
|
3892
4047
|
}
|
|
3893
|
-
for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER]) {
|
|
4048
|
+
for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER, ...(t.parsed.explain ? [ASK_EXPLAIN_MARKER] : [])]) {
|
|
3894
4049
|
try { await reactionRemove(token, t.channelId, t.ts, name, cookie); } catch { /* not ours / already gone */ }
|
|
3895
4050
|
}
|
|
3896
4051
|
try {
|
|
@@ -3984,6 +4139,8 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
3984
4139
|
// Placeholders: only the COUNT is used — which of our keycap seeds to take off.
|
|
3985
4140
|
reactable: Array.from({ length: seeded.length ? Math.max(...seeded) + 1 : 0 }, () => ""),
|
|
3986
4141
|
overflow: [], threadOnly: true, lang: askDetectLang(text) ?? "ja",
|
|
4142
|
+
// Our ℹ️ seed comes off too, if we put one on.
|
|
4143
|
+
explain: asArray(msg.reactions).map(asRecord).some((r) => r.name === ASK_EXPLAIN_MARKER && asArray(r.users).includes(self?.userId ?? "\u0000")),
|
|
3987
4144
|
};
|
|
3988
4145
|
unreadable = true;
|
|
3989
4146
|
}
|
|
@@ -4030,7 +4187,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4030
4187
|
* file to go stale, lose, or disagree with Slack, and any machine holding the
|
|
4031
4188
|
* permalink can collect — at the cost of the body being a parseable format
|
|
4032
4189
|
* (`askBuildText` ⇄ `askParseMessage`). */
|
|
4033
|
-
async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string }): Promise<void> {
|
|
4190
|
+
async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string; explainExit?: boolean }): Promise<void> {
|
|
4034
4191
|
const url = parseSlackPermalink(args.link);
|
|
4035
4192
|
let channelId: string;
|
|
4036
4193
|
let ts: string;
|
|
@@ -4184,7 +4341,7 @@ function askWaitCtxFrom(
|
|
|
4184
4341
|
q: AskReadBack,
|
|
4185
4342
|
parsed: Extract<AskReadBack["parsed"], { kind: "open" }>,
|
|
4186
4343
|
loc: { channelId: string; ts: string; threadTs?: string | undefined },
|
|
4187
|
-
o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined },
|
|
4344
|
+
o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined; explainExit?: boolean | undefined },
|
|
4188
4345
|
): AskWaitCtx {
|
|
4189
4346
|
const ctx: AskWaitCtx = {
|
|
4190
4347
|
channelId: loc.channelId,
|
|
@@ -4202,6 +4359,8 @@ function askWaitCtxFrom(
|
|
|
4202
4359
|
shown: o.link,
|
|
4203
4360
|
asBot: o.asBot,
|
|
4204
4361
|
json: o.json,
|
|
4362
|
+
explain: parsed.explain,
|
|
4363
|
+
explainExit: !!o.explainExit,
|
|
4205
4364
|
};
|
|
4206
4365
|
if (loc.threadTs) ctx.threadParentTs = loc.threadTs;
|
|
4207
4366
|
if (o.cookie) ctx.cookie = o.cookie;
|
|
@@ -5745,8 +5904,8 @@ async function main(): Promise<void> {
|
|
|
5745
5904
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
5746
5905
|
.option("body", { type: "string", describe: "Extra context shown under the question" })
|
|
5747
5906
|
.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" })
|
|
5748
|
-
.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." })
|
|
5749
|
-
.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." })
|
|
5907
|
+
.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. 7 = only with --explain-exit: ℹ️ pressed, explain and collect again (the question stays open)." })
|
|
5908
|
+
.option("waitFor", { type: "string", describe: "Collect the answer to a question already posted: pass its permalink. Nothing is posted (except, on an --explain question someone pressed ℹ️ on, one thread note per question). Same stdout/exit contract as --wait; --timeout 0 checks once and exits 2 if still open." })
|
|
5750
5909
|
.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." })
|
|
5751
5910
|
.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…>`." })
|
|
5752
5911
|
.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." })
|
|
@@ -5760,6 +5919,8 @@ async function main(): Promise<void> {
|
|
|
5760
5919
|
.option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
|
|
5761
5920
|
.option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
|
|
5762
5921
|
.option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
|
|
5922
|
+
.option("explain", { type: "boolean", describe: "Also offer an ℹ️ 'explain' pill (seeded after ❓): pressing it asks YOU to explain the question in its thread and is never an answer — the question stays open. --wait/--waitFor report it on stderr (and as `explain` in --json), and post one thread note per question. Default: $SLACK_TERM_ASK_EXPLAIN=1 turns it on; --no-explain turns it off." })
|
|
5923
|
+
.option("explain-exit", { type: "boolean", default: false, describe: "With --wait / --waitFor: exit 7 (stdout empty; --json status \"explain\") when someone presses ℹ️ — once per question, on the run that posts the thread note. The question stays open: explain, then collect again." })
|
|
5763
5924
|
.option("json", { type: "boolean", default: false, describe: "With --pending: one JSON object per answered question (sender, answer, collect command). 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." })
|
|
5764
5925
|
.option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
|
|
5765
5926
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
@@ -5884,6 +6045,8 @@ async function main(): Promise<void> {
|
|
|
5884
6045
|
argv.choices = newChoices.length ? newChoices : [...t.parsed.reactable, ...t.parsed.overflow].map(escapeArg);
|
|
5885
6046
|
if (argv.body === undefined && t.parsed.body) argv.body = escapeArg(t.parsed.body);
|
|
5886
6047
|
if (!argv.lang) argv.lang = t.parsed.lang;
|
|
6048
|
+
// A re-ask offers what the old question offered, unless told otherwise.
|
|
6049
|
+
if (argv.explain === undefined && t.parsed.explain) argv.explain = true;
|
|
5887
6050
|
argv.void = undefined;
|
|
5888
6051
|
}
|
|
5889
6052
|
if (!voidAfter && (argv.target === "void" || voidFlag.length)) {
|
|
@@ -5973,7 +6136,8 @@ async function main(): Promise<void> {
|
|
|
5973
6136
|
waitCookie = ck(argv as W);
|
|
5974
6137
|
}
|
|
5975
6138
|
try {
|
|
5976
|
-
const a: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"], json: !!argv.json };
|
|
6139
|
+
const a: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string; explainExit?: boolean } = { link: waitFor, timeout, asBot: !!argv["as-bot"], json: !!argv.json };
|
|
6140
|
+
if (argv["explain-exit"]) a.explainExit = true;
|
|
5977
6141
|
if (waitCookie) a.cookie = waitCookie;
|
|
5978
6142
|
if (argv.after) {
|
|
5979
6143
|
const after = String(argv.after);
|
|
@@ -6011,6 +6175,8 @@ async function main(): Promise<void> {
|
|
|
6011
6175
|
if (argv.lang) args.lang = String(argv.lang);
|
|
6012
6176
|
if (argv.wait) args.wait = true;
|
|
6013
6177
|
if (argv.json) args.json = true;
|
|
6178
|
+
if (typeof argv.explain === "boolean") args.explain = argv.explain;
|
|
6179
|
+
if (argv["explain-exit"]) args.explainExit = true;
|
|
6014
6180
|
if (voidAfter) args.voidAfter = voidAfter;
|
|
6015
6181
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
6016
6182
|
if (argv["user-id"]) args.userId = argv["user-id"];
|