slack-term 1.43.0 → 1.45.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 +18 -6
- package/SKILL.md +25 -7
- package/dist/cli.js +211 -210
- package/package.json +1 -1
- package/ts/ask.ts +137 -25
- package/ts/cli.ts +247 -43
package/ts/cli.ts
CHANGED
|
@@ -20,11 +20,16 @@ import { agentCommands } from "./agent.ts";
|
|
|
20
20
|
import {
|
|
21
21
|
ASK_KEYCAPS,
|
|
22
22
|
ASK_MARKER,
|
|
23
|
+
ASK_OTHER_MARKER,
|
|
24
|
+
ASK_OTHER_MARKERS,
|
|
25
|
+
ASK_EXPLAIN_MARKER,
|
|
23
26
|
ASK_RESOLVED_MARKER,
|
|
24
27
|
ASK_MAX_REACTION_CHOICES,
|
|
25
28
|
askBuildText,
|
|
29
|
+
askIsExplainNote,
|
|
26
30
|
askBuildResolvedText,
|
|
27
31
|
askParseMessage,
|
|
32
|
+
askSeedMarker,
|
|
28
33
|
askExplainReject,
|
|
29
34
|
askFlatten,
|
|
30
35
|
askMatchChoice,
|
|
@@ -93,6 +98,7 @@ import {
|
|
|
93
98
|
openDm,
|
|
94
99
|
parseSlackPermalink,
|
|
95
100
|
replies,
|
|
101
|
+
repliesPage,
|
|
96
102
|
resolveChannel,
|
|
97
103
|
resolveUserId,
|
|
98
104
|
search,
|
|
@@ -2457,6 +2463,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
2457
2463
|
// pressed later still settles it (`--waitFor … --after=<reply ts>`).
|
|
2458
2464
|
// 6 the question was VOIDED (無効化, `slack ask --void`) — it will never be
|
|
2459
2465
|
// answered; stop waiting. stdout stays empty.
|
|
2466
|
+
// 7 only with --explain-exit: someone pressed ℹ️ (explain) — the question is
|
|
2467
|
+
// STILL OPEN; explain in its thread, then collect again. stdout stays empty.
|
|
2460
2468
|
// Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
|
|
2461
2469
|
|
|
2462
2470
|
const ASK_EXIT_TIMEOUT = 2;
|
|
@@ -2476,6 +2484,20 @@ const ASK_EXIT_FREETEXT = 5;
|
|
|
2476
2484
|
* Neither "open" nor "answered": a monitor that kept waiting on it would wait
|
|
2477
2485
|
* forever, and one that read it as answered would act on nothing. */
|
|
2478
2486
|
const ASK_EXIT_VOID = 6;
|
|
2487
|
+
/** `--explain-exit` only: an audience member pressed ℹ️ and asked for an
|
|
2488
|
+
* explanation. Not an answer and not a state of the question — it stays open —
|
|
2489
|
+
* but a caller parked on `--waitFor` cannot explain anything until it hears.
|
|
2490
|
+
* Opt-in, so no existing caller meets a code it does not know. */
|
|
2491
|
+
const ASK_EXIT_EXPLAIN = 7;
|
|
2492
|
+
/** How many 200-reply pages the explain-note lookup reads before giving up. */
|
|
2493
|
+
const ASK_EXPLAIN_NOTE_MAX_PAGES = 10;
|
|
2494
|
+
|
|
2495
|
+
/** Whether a new question offers the ℹ️ explain pill when neither --explain nor
|
|
2496
|
+
* --no-explain is given: `SLACK_TERM_ASK_EXPLAIN=1` turns it on for every ask
|
|
2497
|
+
* from a fleet, without every caller having to remember the flag. */
|
|
2498
|
+
function askExplainDefault(): boolean {
|
|
2499
|
+
return /^(1|true|yes|on)$/i.test(process.env.SLACK_TERM_ASK_EXPLAIN ?? "");
|
|
2500
|
+
}
|
|
2479
2501
|
|
|
2480
2502
|
const ASK_PHI = 1.618033988749895;
|
|
2481
2503
|
const ASK_POLL_MIN_MS = 1000;
|
|
@@ -2500,6 +2522,10 @@ interface AskArgs {
|
|
|
2500
2522
|
userId?: string;
|
|
2501
2523
|
asBot?: boolean;
|
|
2502
2524
|
wait?: boolean;
|
|
2525
|
+
/** `--explain`: offer the ℹ️ pill — "explain this first", never an answer. */
|
|
2526
|
+
explain?: boolean;
|
|
2527
|
+
/** `--explain-exit` with `--wait`: exit 7 when an explanation is requested. */
|
|
2528
|
+
explainExit?: boolean;
|
|
2503
2529
|
/** `--json` with `--wait`: one JSON object on stdout (answer, notes, cursor). */
|
|
2504
2530
|
json?: boolean;
|
|
2505
2531
|
timeout?: number;
|
|
@@ -2589,6 +2615,9 @@ interface AskWaitCtx {
|
|
|
2589
2615
|
* nothing". */
|
|
2590
2616
|
overflow: string[];
|
|
2591
2617
|
threadOnly: boolean;
|
|
2618
|
+
/** The marker reaction this question was seeded with (➕, or ❓ on a
|
|
2619
|
+
* free-text question or one posted before ➕) — what the ✅ swap takes off. */
|
|
2620
|
+
seedMarker: string;
|
|
2592
2621
|
/** The language the question's copy is in — the ✅ stamp and the
|
|
2593
2622
|
* invalid-ballot line are written in the same one. */
|
|
2594
2623
|
lang: AskLang;
|
|
@@ -2614,6 +2643,11 @@ interface AskWaitCtx {
|
|
|
2614
2643
|
/** `--json`: stdout is one JSON object (answer + notes + cursor) instead of
|
|
2615
2644
|
* the bare answer. Exit codes are the same either way. */
|
|
2616
2645
|
json: boolean;
|
|
2646
|
+
/** `--explain-exit`: stop with exit 7 the first time an explanation is
|
|
2647
|
+
* requested (the run that posts the thread note), instead of waiting on. */
|
|
2648
|
+
explainExit?: boolean;
|
|
2649
|
+
/** The question offers the ℹ️ pill — so the ✅ rewrite takes our seed off. */
|
|
2650
|
+
explain?: boolean;
|
|
2617
2651
|
}
|
|
2618
2652
|
|
|
2619
2653
|
/** A reply from the question's audience that is NOT the answer — an extra
|
|
@@ -2682,7 +2716,7 @@ async function askCollectNotes(token: string, sc: AskNoteScope, after: string |
|
|
|
2682
2716
|
// integration's) is excluded here, so nothing echoes back as a "note".
|
|
2683
2717
|
.filter((m) => sc.isAnswerer(m.user) && !m.bot_id)
|
|
2684
2718
|
.filter((m) => typeof m.subtype !== "string" || ASK_ANSWERABLE_SUBTYPES.has(m.subtype))
|
|
2685
|
-
.filter((m) => typeof m.text === "string" && m.text.trim() !== "")
|
|
2719
|
+
.filter((m) => typeof m.text === "string" && m.text.trim() !== "" && !askIsExplainNote(m.text))
|
|
2686
2720
|
// Sorted BEFORE `skip`, which may be stateful ("the first reply that …").
|
|
2687
2721
|
.sort((a, b) => Number(a.ts) - Number(b.ts))
|
|
2688
2722
|
.filter((m) => !skip(m));
|
|
@@ -2712,12 +2746,15 @@ function askNextCursor(...tss: (string | undefined)[]): string {
|
|
|
2712
2746
|
async function askEmit(
|
|
2713
2747
|
token: string,
|
|
2714
2748
|
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 },
|
|
2749
|
+
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
2750
|
): Promise<void> {
|
|
2717
2751
|
if (o.json) {
|
|
2718
2752
|
console.log(JSON.stringify({
|
|
2719
2753
|
status: r.status, answer: r.answer, how: r.how ?? null, who: r.who ?? null,
|
|
2720
2754
|
...(r.status === "void" ? { reason: r.reason ?? "", supersededBy: r.supersededBy ?? null } : {}),
|
|
2755
|
+
// Who is asking for an explanation right now. Only when someone is, so
|
|
2756
|
+
// the object every existing caller reads is unchanged otherwise.
|
|
2757
|
+
...(r.explain?.length ? { explain: r.explain } : {}),
|
|
2721
2758
|
notes: r.notes, cursor: r.cursor, permalink: o.shown,
|
|
2722
2759
|
}));
|
|
2723
2760
|
return;
|
|
@@ -2754,6 +2791,13 @@ interface AskPollState {
|
|
|
2754
2791
|
replyCount?: number;
|
|
2755
2792
|
/** The question's text as last read — what the ✅ rewrite keeps the body of. */
|
|
2756
2793
|
ownText?: string;
|
|
2794
|
+
/** Who is on ℹ️ (explain) right now — audience only, never a bot, never the
|
|
2795
|
+
* asker. Recomputed every pass, so a press taken back stops counting. Only
|
|
2796
|
+
* on a question that offers ℹ️ (`ctx.explain`). */
|
|
2797
|
+
explainNow: string[];
|
|
2798
|
+
/** THIS run posted the explain thread note — the one run `--explain-exit`
|
|
2799
|
+
* stops on. Never set read-only (`--pending` posts nothing). */
|
|
2800
|
+
explainPosted: boolean;
|
|
2757
2801
|
}
|
|
2758
2802
|
|
|
2759
2803
|
/** The polling half of `--wait`: one `poll()` is one look at the question,
|
|
@@ -2769,7 +2813,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
2769
2813
|
} {
|
|
2770
2814
|
const { channelId, ts, reactable, threadOnly, audience, broadcast, cookie } = ctx;
|
|
2771
2815
|
const copy = ASK_COPY[ctx.lang];
|
|
2772
|
-
const state: AskPollState = {};
|
|
2816
|
+
const state: AskPollState = { explainNow: [], explainPosted: false };
|
|
2773
2817
|
const askerUserId = ctx.askerUserId;
|
|
2774
2818
|
const askerBotId = ctx.askerBotId;
|
|
2775
2819
|
|
|
@@ -2831,21 +2875,111 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
2831
2875
|
}
|
|
2832
2876
|
}
|
|
2833
2877
|
|
|
2834
|
-
/** Who has pressed
|
|
2835
|
-
*
|
|
2836
|
-
*
|
|
2837
|
-
*
|
|
2878
|
+
/** Who has pressed ➕ ("other") — or ❓, the other pill on questions posted
|
|
2879
|
+
* before ➕, which is still pressed out of habit. Not an answer — neither
|
|
2880
|
+
* carries text — so it never resolves anything; the reply that should
|
|
2881
|
+
* follow it does. Reported once per person so the operator knows a reply is
|
|
2882
|
+
* on its way rather than reading the silence as "nobody looked". */
|
|
2838
2883
|
const otherReported = new Set<string>();
|
|
2839
2884
|
function noteOther(msg: Record<string, Json>): void {
|
|
2840
|
-
const r
|
|
2885
|
+
for (const r of asArray(msg.reactions).map(asRecord)) {
|
|
2886
|
+
if (typeof r.name !== "string" || !ASK_OTHER_MARKERS.includes(r.name)) continue;
|
|
2887
|
+
const glyph = r.name === ASK_OTHER_MARKER ? "➕" : "❓";
|
|
2888
|
+
for (const u of asArray(r.users).filter(isAnswerer)) {
|
|
2889
|
+
if (otherReported.has(u)) continue;
|
|
2890
|
+
otherReported.add(u);
|
|
2891
|
+
if (!readOnly) console.error(` (<@${u}> が ${glyph} その他 を押しました。返信を待ちます)`);
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
const explainReported = new Set<string>();
|
|
2897
|
+
const botCache = new Map<string, boolean>();
|
|
2898
|
+
/** The thread note is settled for this run (found already there, or posted). */
|
|
2899
|
+
let explainNoted = false;
|
|
2900
|
+
/** Null when it cannot be told — not cached, so the next pass asks again.
|
|
2901
|
+
* Until then the press does not count: a bot must never post the note or
|
|
2902
|
+
* wake an --explain-exit caller, and a person is only delayed a tick. */
|
|
2903
|
+
async function isBotUser(u: string): Promise<boolean | null> {
|
|
2904
|
+
if (u === "USLACKBOT") return true;
|
|
2905
|
+
const hit = botCache.get(u);
|
|
2906
|
+
if (hit !== undefined) return hit;
|
|
2907
|
+
try {
|
|
2908
|
+
// A reply with no boolean is_bot tells us nothing — unknown, not human.
|
|
2909
|
+
const bot = asRecord(asRecord((await userInfo(token, u, cookie)) as Json).user).is_bot;
|
|
2910
|
+
if (typeof bot !== "boolean") return null;
|
|
2911
|
+
botCache.set(u, bot);
|
|
2912
|
+
return bot;
|
|
2913
|
+
} catch {
|
|
2914
|
+
return null;
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
/** One thread note per QUESTION, however many presses and runs: the thread
|
|
2918
|
+
* itself is the record, so there is no local state to lose. The note is
|
|
2919
|
+
* recognised by its exact copy (either language), whoever posted it — the
|
|
2920
|
+
* identity that collects now need not be the one that collected before, and
|
|
2921
|
+
* under @here it may even be in the audience.
|
|
2922
|
+
*
|
|
2923
|
+
* The whole thread after the question is read (paged), not one page: a
|
|
2924
|
+
* question posted into a long thread may have more than a page before its
|
|
2925
|
+
* note. Past the page cap it is not posted at all ("exists") rather than
|
|
2926
|
+
* risk a duplicate.
|
|
2927
|
+
*
|
|
2928
|
+
* "unread": the thread could not be read — nothing is posted, and the next
|
|
2929
|
+
* pass tries again rather than risk a duplicate. "failed": the post itself
|
|
2930
|
+
* failed — not retried this run (no error every tick) and NOT a reason for
|
|
2931
|
+
* --explain-exit, which promises a note is up; the next run tries again. */
|
|
2932
|
+
async function postExplainNote(): Promise<"exists" | "posted" | "unread" | "failed"> {
|
|
2933
|
+
const root = ctx.threadParentTs ?? ts;
|
|
2934
|
+
try {
|
|
2935
|
+
let cursor: string | undefined;
|
|
2936
|
+
let pages = 0;
|
|
2937
|
+
for (;;) {
|
|
2938
|
+
const rep = asRecord((await repliesPage(token, channelId, root, { oldest: ts, ...(cursor ? { cursor } : {}) }, cookie)) as Json);
|
|
2939
|
+
if (asArray(rep.messages).map(asRecord).some((m) => Number(m.ts) > Number(ts) && askIsExplainNote(m.text))) return "exists";
|
|
2940
|
+
cursor = String(asRecord(rep.response_metadata).next_cursor ?? "") || undefined;
|
|
2941
|
+
if (!cursor) break;
|
|
2942
|
+
if (++pages >= ASK_EXPLAIN_NOTE_MAX_PAGES) return "exists";
|
|
2943
|
+
}
|
|
2944
|
+
} catch (e: unknown) {
|
|
2945
|
+
console.error(` (スレッドを読めないため、説明リクエストの案内はまだ出していません: ${e instanceof Error ? e.message : String(e)})`);
|
|
2946
|
+
return "unread";
|
|
2947
|
+
}
|
|
2948
|
+
try {
|
|
2949
|
+
await slackSend(token, channelId, copy.explainNote, root, false, cookie, true);
|
|
2950
|
+
} catch (e: unknown) {
|
|
2951
|
+
// The press is still reported on stderr; the note is a courtesy.
|
|
2952
|
+
console.error(` (説明リクエストの案内をスレッドに出せませんでした: ${e instanceof Error ? e.message : String(e)})`);
|
|
2953
|
+
return "failed";
|
|
2954
|
+
}
|
|
2955
|
+
return "posted";
|
|
2956
|
+
}
|
|
2957
|
+
async function noteExplain(msg: Record<string, Json>): Promise<void> {
|
|
2958
|
+
const r = asArray(msg.reactions).map(asRecord).find((x) => x.name === ASK_EXPLAIN_MARKER);
|
|
2959
|
+
const now: string[] = [];
|
|
2841
2960
|
for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
|
|
2842
|
-
if (
|
|
2843
|
-
|
|
2844
|
-
if (
|
|
2961
|
+
if ((await isBotUser(u)) !== false) continue;
|
|
2962
|
+
now.push(u);
|
|
2963
|
+
if (explainReported.has(u)) continue;
|
|
2964
|
+
explainReported.add(u);
|
|
2965
|
+
if (!readOnly) console.error(` (<@${u}> が ℹ️ 説明して を押しました — 回答ではありません。スレッドで説明してください。回答は待ち続けます)`);
|
|
2966
|
+
}
|
|
2967
|
+
state.explainNow = now;
|
|
2968
|
+
// Read-only (`--pending`): the press is reported, the note is not posted —
|
|
2969
|
+
// that is the waiting session's to do.
|
|
2970
|
+
if (now.length && !explainNoted && !readOnly) {
|
|
2971
|
+
const r = await postExplainNote();
|
|
2972
|
+
if (r !== "unread") {
|
|
2973
|
+
explainNoted = true;
|
|
2974
|
+
state.explainPosted = r === "posted";
|
|
2975
|
+
}
|
|
2845
2976
|
}
|
|
2846
2977
|
}
|
|
2847
2978
|
|
|
2848
2979
|
async function answerFromReactions(msg: Record<string, Json>): Promise<AskFound | null> {
|
|
2980
|
+
// Only a question that offers ℹ️ treats it as a request — on any other,
|
|
2981
|
+
// ℹ️ is just an emoji somebody added.
|
|
2982
|
+
if (ctx.explain) await noteExplain(msg);
|
|
2849
2983
|
if (reactable.length) noteOther(msg);
|
|
2850
2984
|
const picks = humanChoices(msg);
|
|
2851
2985
|
// Nothing ambiguous any more — take the notice back down if one is up.
|
|
@@ -2895,7 +3029,9 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
2895
3029
|
if (typeof m.subtype === "string" && !ASK_ANSWERABLE_SUBTYPES.has(m.subtype)) continue;
|
|
2896
3030
|
if (Number(m.ts) >= before) break;
|
|
2897
3031
|
const t = typeof m.text === "string" ? m.text.trim() : "";
|
|
2898
|
-
|
|
3032
|
+
// Our ℹ️ note is bookkeeping — only on a question that offers ℹ️, so an
|
|
3033
|
+
// older question's reply is never second-guessed.
|
|
3034
|
+
if (!t || (ctx.explain && askIsExplainNote(t))) continue;
|
|
2899
3035
|
// A question asked WITHOUT choices is answered by whatever comes back —
|
|
2900
3036
|
// there is nothing to match against, and every reply is the answer.
|
|
2901
3037
|
if (!candidates.length) return { answer: t, how: copy.howReply, who: m.user, ts: String(m.ts) };
|
|
@@ -3032,12 +3168,18 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3032
3168
|
const keep = state.ownText ? askResolvedKeep(state.ownText, found.choice) : undefined;
|
|
3033
3169
|
await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
|
|
3034
3170
|
// Swap the marker for the resolved one so search reflects reality:
|
|
3035
|
-
// `has::question:` should list what still needs
|
|
3036
|
-
// ever asked. Removal last — a crash between the two leaves the question
|
|
3171
|
+
// `has::question: OR has::heavy_plus_sign:` should list what still needs
|
|
3172
|
+
// answering, not everything ever asked. Removal last — a crash between the two leaves the question
|
|
3037
3173
|
// findable under BOTH, which is recoverable; the reverse loses it.
|
|
3038
3174
|
try {
|
|
3039
3175
|
await reactionAdd(token, channelId, ts, ASK_RESOLVED_MARKER, cookie);
|
|
3040
|
-
await reactionRemove(token, channelId, ts,
|
|
3176
|
+
await reactionRemove(token, channelId, ts, ctx.seedMarker, cookie);
|
|
3177
|
+
} catch {
|
|
3178
|
+
// Cosmetic: the ✅ in the body is what actually marks it resolved.
|
|
3179
|
+
}
|
|
3180
|
+
try {
|
|
3181
|
+
// Asking for an explanation is over once it is answered.
|
|
3182
|
+
if (ctx.explain) await reactionRemove(token, channelId, ts, ASK_EXPLAIN_MARKER, cookie);
|
|
3041
3183
|
} catch {
|
|
3042
3184
|
// Cosmetic: the ✅ in the body is what actually marks it resolved.
|
|
3043
3185
|
}
|
|
@@ -3102,6 +3244,15 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3102
3244
|
await askEmit(token, ctx, r);
|
|
3103
3245
|
process.exit(0);
|
|
3104
3246
|
}
|
|
3247
|
+
// ℹ️ is never an answer: the question stays open, ✅ is not written, the
|
|
3248
|
+
// seeds stay. Only a caller that asked to hear about it stops here — and
|
|
3249
|
+
// only on the run that posted the thread note, so a `--timeout 0` poller is
|
|
3250
|
+
// woken once per question, not on every look.
|
|
3251
|
+
if (state.explainPosted && ctx.explainExit) {
|
|
3252
|
+
console.error(` 説明を求められています (質問はまだ開いています)。スレッドで説明してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3253
|
+
if (ctx.json) await askEmit(token, ctx, { status: "explain", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
|
|
3254
|
+
process.exit(ASK_EXIT_EXPLAIN);
|
|
3255
|
+
}
|
|
3105
3256
|
// A free-text reply that picks nothing is delivered NOW, not held until the
|
|
3106
3257
|
// deadline: it is usually an instruction or a question back, and the only
|
|
3107
3258
|
// one who can act on it is the caller. Left unstamped — a pill pressed
|
|
@@ -3122,7 +3273,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3122
3273
|
// but still reports a pill pressed after it.
|
|
3123
3274
|
markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
|
|
3124
3275
|
if (ctx.json) {
|
|
3125
|
-
await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
|
|
3276
|
+
await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts), explain: state.explainNow });
|
|
3126
3277
|
} else {
|
|
3127
3278
|
console.log(unchosen.text);
|
|
3128
3279
|
}
|
|
@@ -3167,7 +3318,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3167
3318
|
console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
3168
3319
|
console.error(` ${shown}`);
|
|
3169
3320
|
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) });
|
|
3321
|
+
if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
|
|
3171
3322
|
process.exit(ASK_EXIT_UNCHOSEN);
|
|
3172
3323
|
}
|
|
3173
3324
|
if (timeout === 0) {
|
|
@@ -3177,7 +3328,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3177
3328
|
console.error(` ${shown}`);
|
|
3178
3329
|
console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3179
3330
|
}
|
|
3180
|
-
if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after) });
|
|
3331
|
+
if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
|
|
3181
3332
|
process.exit(ASK_EXIT_TIMEOUT);
|
|
3182
3333
|
}
|
|
3183
3334
|
|
|
@@ -3306,7 +3457,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3306
3457
|
process.exit(ASK_EXIT_ERROR);
|
|
3307
3458
|
}
|
|
3308
3459
|
const lang = picked.lang;
|
|
3309
|
-
const
|
|
3460
|
+
const explain = args.explain ?? askExplainDefault();
|
|
3461
|
+
const message = askBuildText(question, body, reactable, overflow, threadOnly, lang, explain);
|
|
3310
3462
|
guardUrlBoundaries(message, args.allowUrlAdjacent);
|
|
3311
3463
|
|
|
3312
3464
|
// Preview the destination's last message, exactly as `send` does — the gate's
|
|
@@ -3357,6 +3509,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3357
3509
|
// shown as its own line rather than left to be inferred from the body.
|
|
3358
3510
|
` Answerable by: ${audienceLabel}`,
|
|
3359
3511
|
` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
|
|
3512
|
+
...(explain ? [` Explain: ℹ️ pill offered — pressing it asks you to explain; it is never an answer`] : []),
|
|
3360
3513
|
...(args.voidAfter ? [` Then 無効化: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) — replaced by this question`] : []),
|
|
3361
3514
|
` 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
3515
|
`--------------------------------────────────`,
|
|
@@ -3370,10 +3523,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3370
3523
|
const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
|
|
3371
3524
|
|
|
3372
3525
|
// The marker goes on LAST, after 1,2,3, because it doubles as the "other"
|
|
3373
|
-
// choice the body lists under the numbered ones (
|
|
3374
|
-
//
|
|
3375
|
-
// `has::question:` lists every question the
|
|
3376
|
-
//
|
|
3526
|
+
// choice the body lists under the numbered ones: ➕ (ASK_OTHER_MARKER). A
|
|
3527
|
+
// free-text question has no other choice and seeds ❓. It is a reaction so
|
|
3528
|
+
// `has::question: OR has::heavy_plus_sign:` lists every open question the
|
|
3529
|
+
// way `has::pushpin:` lists every todo — though ➕ is a common emoji, so that
|
|
3530
|
+
// search can also match unrelated messages (`slack ask --ls` reads the local
|
|
3531
|
+
// sent-log and is unaffected). Body text cannot do that job: Slack's index splits on punctuation, so
|
|
3377
3532
|
// a `:question:` written in the text is indexed as the word "question" and
|
|
3378
3533
|
// matches unrelated messages (measured). Only a real reaction is searchable.
|
|
3379
3534
|
//
|
|
@@ -3384,14 +3539,15 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3384
3539
|
// enough: measured on two real questions from the same build, one came back
|
|
3385
3540
|
// `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
|
|
3386
3541
|
// SLACK_REACTION_SEED_GAP_MS.
|
|
3542
|
+
const seedMarker = reactable.length ? ASK_OTHER_MARKER : ASK_MARKER;
|
|
3387
3543
|
await seedReactionsInOrder(
|
|
3388
|
-
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name),
|
|
3544
|
+
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), seedMarker, ...(explain ? [ASK_EXPLAIN_MARKER] : [])],
|
|
3389
3545
|
(name) => reactionAdd(token, channelId, ts, name, cookie),
|
|
3390
3546
|
(name, e) => {
|
|
3391
3547
|
const why = e instanceof Error ? e.message : String(e);
|
|
3392
3548
|
// The marker is cosmetic + searchability; a keycap only costs the
|
|
3393
3549
|
// answerer a trip to the emoji picker. Neither abandons a posted question.
|
|
3394
|
-
if (name ===
|
|
3550
|
+
if (name === seedMarker) console.error(` (marker ${name} を付けられませんでした: ${why})`);
|
|
3395
3551
|
else console.error(` (リアクション ${name} を付けられませんでした: ${why})`);
|
|
3396
3552
|
},
|
|
3397
3553
|
);
|
|
@@ -3432,6 +3588,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3432
3588
|
reactable,
|
|
3433
3589
|
overflow,
|
|
3434
3590
|
threadOnly,
|
|
3591
|
+
seedMarker,
|
|
3435
3592
|
lang,
|
|
3436
3593
|
audience,
|
|
3437
3594
|
broadcast,
|
|
@@ -3441,6 +3598,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3441
3598
|
shown,
|
|
3442
3599
|
asBot: !!args.asBot,
|
|
3443
3600
|
json: !!args.json,
|
|
3601
|
+
explain,
|
|
3602
|
+
explainExit: !!args.explainExit,
|
|
3444
3603
|
};
|
|
3445
3604
|
if (threadTs) ctx.threadParentTs = threadTs;
|
|
3446
3605
|
if (cookie) ctx.cookie = cookie;
|
|
@@ -3522,7 +3681,7 @@ async function askLoadOwnOpen(token: string, link: string, self: Self | null, as
|
|
|
3522
3681
|
if (parsed.kind === "void") return "void";
|
|
3523
3682
|
if (parsed.kind === "resolved") { console.error(`✗ ${shown}: 回答済みの質問です(回答: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})— 変更できません`); return null; }
|
|
3524
3683
|
if (parsed.kind !== "open") { console.error(`✗ ${shown}: \`slack ask\` の質問として読み取れません — ${askExplainReject(text)}`); return null; }
|
|
3525
|
-
const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, msg };
|
|
3684
|
+
const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, seedMarker: askSeedMarker(text) ?? ASK_MARKER, msg };
|
|
3526
3685
|
if (got.threadTs) out.threadTs = got.threadTs;
|
|
3527
3686
|
return out;
|
|
3528
3687
|
}
|
|
@@ -3550,7 +3709,7 @@ async function askHasResponses(token: string, t: { channelId: string; ts: string
|
|
|
3550
3709
|
}
|
|
3551
3710
|
|
|
3552
3711
|
/** `slack ask --edit=<link> ["new question" [choices…]] [--body …]` — change an
|
|
3553
|
-
* open question THROUGH the ask builder, so pills,
|
|
3712
|
+
* open question THROUGH the ask builder, so pills, ➕, the instructions and the
|
|
3554
3713
|
* body stay in the layout collect reads back (a free-hand `slack edit` is what
|
|
3555
3714
|
* this replaces; `edit` now refuses ask messages without --force).
|
|
3556
3715
|
*
|
|
@@ -3600,7 +3759,7 @@ async function cmdAskEdit(token: string, args: {
|
|
|
3600
3759
|
const reactable = choices.slice(0, ASK_MAX_REACTION_CHOICES);
|
|
3601
3760
|
const overflow = choices.slice(ASK_MAX_REACTION_CHOICES);
|
|
3602
3761
|
const oldText = typeof t.msg.text === "string" ? t.msg.text : "";
|
|
3603
|
-
let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang);
|
|
3762
|
+
let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang, old.explain);
|
|
3604
3763
|
// An invalid-ballot notice still in force stays (it names the people who
|
|
3605
3764
|
// have to fix their vote); it is only possible when the options did not change.
|
|
3606
3765
|
const invalid = readInvalidNotice(oldText);
|
|
@@ -3633,16 +3792,32 @@ async function cmdAskEdit(token: string, args: {
|
|
|
3633
3792
|
}
|
|
3634
3793
|
|
|
3635
3794
|
await editMessage(token, t.channelId, t.ts, next, args.cookie, true);
|
|
3795
|
+
// The body was rebuilt, so its other line now says ➕ (or there is none, on
|
|
3796
|
+
// a free-text question) — the marker seed follows it.
|
|
3797
|
+
const seedMarker = reactable.length ? ASK_OTHER_MARKER : ASK_MARKER;
|
|
3798
|
+
const oldSeed = askSeedMarker(oldText) ?? ASK_MARKER;
|
|
3636
3799
|
if (choicesChanged) {
|
|
3637
|
-
// Re-seed in order: 1..n then
|
|
3638
|
-
|
|
3800
|
+
// Re-seed in order: 1..n then ➕ last, as a fresh question has them. The
|
|
3801
|
+
// old marker comes off as whatever it was seeded as (❓ on an old pill).
|
|
3802
|
+
const tail = (m: string) => old.explain ? [m, ASK_EXPLAIN_MARKER] : [m];
|
|
3803
|
+
for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail(oldSeed)]) {
|
|
3639
3804
|
try { await reactionRemove(token, t.channelId, t.ts, name, args.cookie); } catch { /* already gone */ }
|
|
3640
3805
|
}
|
|
3641
3806
|
await seedReactionsInOrder(
|
|
3642
|
-
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name),
|
|
3807
|
+
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail(seedMarker)],
|
|
3643
3808
|
(name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
|
|
3644
3809
|
(name, e) => console.error(` (リアクション ${name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`),
|
|
3645
3810
|
);
|
|
3811
|
+
} else if (oldSeed !== seedMarker) {
|
|
3812
|
+
// An old ❓ pill edited without touching its choices: the body now lists
|
|
3813
|
+
// ➕, so swap the seed to match. Add before remove, as the ✅ swap does —
|
|
3814
|
+
// a failure in between leaves both, which still collects.
|
|
3815
|
+
try {
|
|
3816
|
+
await reactionAdd(token, t.channelId, t.ts, seedMarker, args.cookie);
|
|
3817
|
+
await reactionRemove(token, t.channelId, t.ts, oldSeed, args.cookie);
|
|
3818
|
+
} catch (e: unknown) {
|
|
3819
|
+
console.error(` (marker を ${seedMarker} に替えられませんでした: ${e instanceof Error ? e.message : String(e)})`);
|
|
3820
|
+
}
|
|
3646
3821
|
}
|
|
3647
3822
|
console.error(`✓ 編集しました: ${stripTerminalControls(args.link)}`);
|
|
3648
3823
|
}
|
|
@@ -3765,7 +3940,10 @@ async function cmdAskPending(
|
|
|
3765
3940
|
}
|
|
3766
3941
|
const rows = opts.channel ? all.filter((r) => r.channel === opts.channel || r.target === opts.channel) : all;
|
|
3767
3942
|
const pending: AskPendingItem[] = [];
|
|
3768
|
-
const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0 };
|
|
3943
|
+
const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0, explain: 0 };
|
|
3944
|
+
/** Open questions someone pressed ℹ️ on — reported only: the thread note is
|
|
3945
|
+
* the waiting session's to post (`--pending` writes nothing). */
|
|
3946
|
+
const explainAsked: string[] = [];
|
|
3769
3947
|
const problems: string[] = [];
|
|
3770
3948
|
// The log is machine-wide, but a token reads one workspace. Asks recorded
|
|
3771
3949
|
// under another workspace are counted and named, not probed with the wrong
|
|
@@ -3810,6 +3988,10 @@ async function cmdAskPending(
|
|
|
3810
3988
|
const unchosen = poller.state.unchosen;
|
|
3811
3989
|
if (poller.state.voided) { count.void++; continue; }
|
|
3812
3990
|
const collect = askResumeCommand(link, asBot, after);
|
|
3991
|
+
if (poller.state.explainNow.length) {
|
|
3992
|
+
count.explain++;
|
|
3993
|
+
explainAsked.push(`${link}: ℹ️ 説明を求められています (${poller.state.explainNow.map((u) => `<@${u}>`).join(" ")}) — スレッドで説明してください`);
|
|
3994
|
+
}
|
|
3813
3995
|
const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
|
|
3814
3996
|
if (found) {
|
|
3815
3997
|
pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
|
|
@@ -3858,6 +4040,7 @@ async function cmdAskPending(
|
|
|
3858
4040
|
}
|
|
3859
4041
|
}
|
|
3860
4042
|
for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
|
|
4043
|
+
for (const p of explainAsked) console.error(` (${stripTerminalControls(p)})`);
|
|
3861
4044
|
for (const [team, n] of otherTeams) {
|
|
3862
4045
|
console.error(` (${n} 件は別のワークスペース「${stripTerminalControls(team)}」の質問です — slack -w <name> ask --pending で確認)`);
|
|
3863
4046
|
}
|
|
@@ -3866,6 +4049,7 @@ async function cmdAskPending(
|
|
|
3866
4049
|
(count.ambiguous ? `, ${count.ambiguous} 件は返信が複数の選択肢に一致 (未確定)` : "") +
|
|
3867
4050
|
(count.resolved ? `, ${count.resolved} 件は ✅ 回収済み` : "") +
|
|
3868
4051
|
(count.void ? `, ${count.void} 件は無効化済み` : "") +
|
|
4052
|
+
(count.explain ? `, ${count.explain} 件は ℹ️ 説明のリクエストあり` : "") +
|
|
3869
4053
|
` — ${rows.length} 件の未回収の質問を確認 (log: ${sentDbPath()})`,
|
|
3870
4054
|
);
|
|
3871
4055
|
}
|
|
@@ -3876,9 +4060,12 @@ interface AskVoidTarget {
|
|
|
3876
4060
|
channelId: string;
|
|
3877
4061
|
ts: string;
|
|
3878
4062
|
parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>;
|
|
4063
|
+
/** The marker reaction it was seeded with (➕, or ❓ on an old pill / free
|
|
4064
|
+
* text) — what the void takes off. */
|
|
4065
|
+
seedMarker: string;
|
|
3879
4066
|
}
|
|
3880
4067
|
|
|
3881
|
-
/** Void one question: the 🚫 text (body kept), our seeds and ❓ off, 🚫 on.
|
|
4068
|
+
/** Void one question: the 🚫 text (body kept), our seeds and marker (➕, or ❓) off, 🚫 on.
|
|
3882
4069
|
* The text is what marks it void; the reactions are cleanup + searchability,
|
|
3883
4070
|
* so a failure there is reported and does not undo the void. */
|
|
3884
4071
|
async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, supersededBy: string | undefined, cookie?: string): Promise<boolean> {
|
|
@@ -3890,7 +4077,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
|
|
|
3890
4077
|
console.error(`✗ ${shown}: 書き換えに失敗しました: ${e instanceof Error ? e.message : String(e)}`);
|
|
3891
4078
|
return false;
|
|
3892
4079
|
}
|
|
3893
|
-
for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name),
|
|
4080
|
+
for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), t.seedMarker, ...(t.parsed.explain ? [ASK_EXPLAIN_MARKER] : [])]) {
|
|
3894
4081
|
try { await reactionRemove(token, t.channelId, t.ts, name, cookie); } catch { /* not ours / already gone */ }
|
|
3895
4082
|
}
|
|
3896
4083
|
try {
|
|
@@ -3913,7 +4100,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
|
|
|
3913
4100
|
*
|
|
3914
4101
|
* What voiding does, per question: the text becomes the 🚫 form (head, void
|
|
3915
4102
|
* stamp with the reason, optional pointer to the replacement, the body); our
|
|
3916
|
-
* seeded pills and the ❓
|
|
4103
|
+
* seeded pills and the marker (➕, or ❓) come off; a 🚫 reaction goes on. Pills someone
|
|
3917
4104
|
* already pressed stay — Slack lets only their owner remove them — and are
|
|
3918
4105
|
* never collected, because a void is never polled again.
|
|
3919
4106
|
*
|
|
@@ -3946,7 +4133,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
3946
4133
|
process.exit(ASK_EXIT_ERROR);
|
|
3947
4134
|
}
|
|
3948
4135
|
}
|
|
3949
|
-
type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; pressed: string[]; unreadable?: boolean };
|
|
4136
|
+
type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; seedMarker: string; pressed: string[]; unreadable?: boolean };
|
|
3950
4137
|
const targets: Target[] = [];
|
|
3951
4138
|
let refused = 0;
|
|
3952
4139
|
for (const link of args.links) {
|
|
@@ -3984,14 +4171,21 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
3984
4171
|
// Placeholders: only the COUNT is used — which of our keycap seeds to take off.
|
|
3985
4172
|
reactable: Array.from({ length: seeded.length ? Math.max(...seeded) + 1 : 0 }, () => ""),
|
|
3986
4173
|
overflow: [], threadOnly: true, lang: askDetectLang(text) ?? "ja",
|
|
4174
|
+
// Our ℹ️ seed comes off too, if we put one on.
|
|
4175
|
+
explain: asArray(msg.reactions).map(asRecord).some((r) => r.name === ASK_EXPLAIN_MARKER && asArray(r.users).includes(self?.userId ?? "\u0000")),
|
|
3987
4176
|
};
|
|
3988
4177
|
unreadable = true;
|
|
3989
4178
|
}
|
|
4179
|
+
// Our marker: what the body names, or — for an unreadable body — ➕ if we
|
|
4180
|
+
// seeded one, else ❓ (an old pill, or free text).
|
|
4181
|
+
const seedMarker = unreadable
|
|
4182
|
+
? (asArray(msg.reactions).map(asRecord).some((r) => r.name === ASK_OTHER_MARKER && asArray(r.users).includes(self?.userId ?? "\u0000")) ? ASK_OTHER_MARKER : ASK_MARKER)
|
|
4183
|
+
: askSeedMarker(text) ?? ASK_MARKER;
|
|
3990
4184
|
const pressed = asArray(msg.reactions).map(asRecord)
|
|
3991
4185
|
.filter((r) => typeof r.name === "string" && ASK_KEYCAPS.some((k) => k.name === r.name))
|
|
3992
4186
|
.filter((r) => asArray(r.users).some((u) => u !== self?.userId))
|
|
3993
4187
|
.map((r) => `:${String(r.name)}:`);
|
|
3994
|
-
targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, pressed, ...(unreadable ? { unreadable } : {}) });
|
|
4188
|
+
targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, seedMarker, pressed, ...(unreadable ? { unreadable } : {}) });
|
|
3995
4189
|
}
|
|
3996
4190
|
if (!targets.length) {
|
|
3997
4191
|
console.error(refused ? "無効化できる質問がありません。" : "何もすることがありません。");
|
|
@@ -4009,7 +4203,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4009
4203
|
...targets.map((t) => ` • ${stripTerminalControls(t.link)}\n ${stripTerminalControls(t.parsed.question.split("\n")[0] ?? "").slice(0, 100)}` +
|
|
4010
4204
|
(t.pressed.length ? `\n (already pressed: ${t.pressed.join(" ")} — left in place, never collected)` : "") +
|
|
4011
4205
|
(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` : "")),
|
|
4012
|
-
` Each becomes 🚫 (body kept, options and instructions removed); our pills and ❓ come off; a 🚫 reaction is added.`,
|
|
4206
|
+
` Each becomes 🚫 (body kept, options and instructions removed); our pills and marker (➕, or ❓) come off; a 🚫 reaction is added.`,
|
|
4013
4207
|
...(refused ? [` (${refused} link(s) above were refused and will be skipped)`] : []),
|
|
4014
4208
|
`----------------------------------------------`,
|
|
4015
4209
|
]);
|
|
@@ -4030,7 +4224,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4030
4224
|
* file to go stale, lose, or disagree with Slack, and any machine holding the
|
|
4031
4225
|
* permalink can collect — at the cost of the body being a parseable format
|
|
4032
4226
|
* (`askBuildText` ⇄ `askParseMessage`). */
|
|
4033
|
-
async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string }): Promise<void> {
|
|
4227
|
+
async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; json: boolean; cookie?: string; after?: string; explainExit?: boolean }): Promise<void> {
|
|
4034
4228
|
const url = parseSlackPermalink(args.link);
|
|
4035
4229
|
let channelId: string;
|
|
4036
4230
|
let ts: string;
|
|
@@ -4184,7 +4378,7 @@ function askWaitCtxFrom(
|
|
|
4184
4378
|
q: AskReadBack,
|
|
4185
4379
|
parsed: Extract<AskReadBack["parsed"], { kind: "open" }>,
|
|
4186
4380
|
loc: { channelId: string; ts: string; threadTs?: string | undefined },
|
|
4187
|
-
o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined },
|
|
4381
|
+
o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined; explainExit?: boolean | undefined },
|
|
4188
4382
|
): AskWaitCtx {
|
|
4189
4383
|
const ctx: AskWaitCtx = {
|
|
4190
4384
|
channelId: loc.channelId,
|
|
@@ -4193,6 +4387,7 @@ function askWaitCtxFrom(
|
|
|
4193
4387
|
reactable: parsed.reactable,
|
|
4194
4388
|
overflow: parsed.overflow,
|
|
4195
4389
|
threadOnly: parsed.threadOnly,
|
|
4390
|
+
seedMarker: askSeedMarker(q.text) ?? ASK_MARKER,
|
|
4196
4391
|
lang: parsed.lang,
|
|
4197
4392
|
audience: q.audience,
|
|
4198
4393
|
broadcast: q.broadcast,
|
|
@@ -4202,6 +4397,8 @@ function askWaitCtxFrom(
|
|
|
4202
4397
|
shown: o.link,
|
|
4203
4398
|
asBot: o.asBot,
|
|
4204
4399
|
json: o.json,
|
|
4400
|
+
explain: parsed.explain,
|
|
4401
|
+
explainExit: !!o.explainExit,
|
|
4205
4402
|
};
|
|
4206
4403
|
if (loc.threadTs) ctx.threadParentTs = loc.threadTs;
|
|
4207
4404
|
if (o.cookie) ctx.cookie = o.cookie;
|
|
@@ -5745,8 +5942,8 @@ async function main(): Promise<void> {
|
|
|
5745
5942
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
5746
5943
|
.option("body", { type: "string", describe: "Extra context shown under the question" })
|
|
5747
5944
|
.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." })
|
|
5945
|
+
.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)." })
|
|
5946
|
+
.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
5947
|
.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
5948
|
.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
5949
|
.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 +5957,8 @@ async function main(): Promise<void> {
|
|
|
5760
5957
|
.option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
|
|
5761
5958
|
.option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
|
|
5762
5959
|
.option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
|
|
5960
|
+
.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." })
|
|
5961
|
+
.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
5962
|
.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
5963
|
.option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
|
|
5765
5964
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
@@ -5884,6 +6083,8 @@ async function main(): Promise<void> {
|
|
|
5884
6083
|
argv.choices = newChoices.length ? newChoices : [...t.parsed.reactable, ...t.parsed.overflow].map(escapeArg);
|
|
5885
6084
|
if (argv.body === undefined && t.parsed.body) argv.body = escapeArg(t.parsed.body);
|
|
5886
6085
|
if (!argv.lang) argv.lang = t.parsed.lang;
|
|
6086
|
+
// A re-ask offers what the old question offered, unless told otherwise.
|
|
6087
|
+
if (argv.explain === undefined && t.parsed.explain) argv.explain = true;
|
|
5887
6088
|
argv.void = undefined;
|
|
5888
6089
|
}
|
|
5889
6090
|
if (!voidAfter && (argv.target === "void" || voidFlag.length)) {
|
|
@@ -5973,7 +6174,8 @@ async function main(): Promise<void> {
|
|
|
5973
6174
|
waitCookie = ck(argv as W);
|
|
5974
6175
|
}
|
|
5975
6176
|
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 };
|
|
6177
|
+
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 };
|
|
6178
|
+
if (argv["explain-exit"]) a.explainExit = true;
|
|
5977
6179
|
if (waitCookie) a.cookie = waitCookie;
|
|
5978
6180
|
if (argv.after) {
|
|
5979
6181
|
const after = String(argv.after);
|
|
@@ -6011,6 +6213,8 @@ async function main(): Promise<void> {
|
|
|
6011
6213
|
if (argv.lang) args.lang = String(argv.lang);
|
|
6012
6214
|
if (argv.wait) args.wait = true;
|
|
6013
6215
|
if (argv.json) args.json = true;
|
|
6216
|
+
if (typeof argv.explain === "boolean") args.explain = argv.explain;
|
|
6217
|
+
if (argv["explain-exit"]) args.explainExit = true;
|
|
6014
6218
|
if (voidAfter) args.voidAfter = voidAfter;
|
|
6015
6219
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
6016
6220
|
if (argv["user-id"]) args.userId = argv["user-id"];
|