slack-term 1.42.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 +81 -0
- package/SKILL.md +22 -2
- package/dist/cli.js +223 -209
- package/package.json +2 -1
- package/ts/ask.ts +61 -4
- package/ts/cli.ts +336 -20
- package/ts/newlines.ts +158 -0
- package/ts/relay.ts +105 -0
- package/ts/slack.ts +52 -4
- package/ts/stream.ts +1048 -0
package/ts/cli.ts
CHANGED
|
@@ -12,15 +12,19 @@ import { guardUrlBoundaries } from "./urlGuard.ts";
|
|
|
12
12
|
import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile, type Profile } from "./profiles.ts";
|
|
13
13
|
import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
|
|
14
14
|
import { cmdAuthLogin, cmdAuthLoginChrome, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp, cmdAuthSave, cmdAuthTokens } from "./auth.ts";
|
|
15
|
-
import { cmdTail } from "./tail.ts";
|
|
15
|
+
import { cmdTail, parseSince as parseDuration } from "./tail.ts";
|
|
16
|
+
import { runStream, webClient } from "./stream.ts";
|
|
17
|
+
import { relaySubscriber } from "./relay.ts";
|
|
16
18
|
import { agentCommands } from "./agent.ts";
|
|
17
19
|
|
|
18
20
|
import {
|
|
19
21
|
ASK_KEYCAPS,
|
|
20
22
|
ASK_MARKER,
|
|
23
|
+
ASK_EXPLAIN_MARKER,
|
|
21
24
|
ASK_RESOLVED_MARKER,
|
|
22
25
|
ASK_MAX_REACTION_CHOICES,
|
|
23
26
|
askBuildText,
|
|
27
|
+
askIsExplainNote,
|
|
24
28
|
askBuildResolvedText,
|
|
25
29
|
askParseMessage,
|
|
26
30
|
askExplainReject,
|
|
@@ -58,6 +62,7 @@ import { attributionEnabled, attributionMetadata, captureAttribution, markAskCol
|
|
|
58
62
|
import {
|
|
59
63
|
authTest,
|
|
60
64
|
authScopes,
|
|
65
|
+
botsInfo,
|
|
61
66
|
authTestSession,
|
|
62
67
|
conversationInfoSession,
|
|
63
68
|
createChannel,
|
|
@@ -90,6 +95,7 @@ import {
|
|
|
90
95
|
openDm,
|
|
91
96
|
parseSlackPermalink,
|
|
92
97
|
replies,
|
|
98
|
+
repliesPage,
|
|
93
99
|
resolveChannel,
|
|
94
100
|
resolveUserId,
|
|
95
101
|
search,
|
|
@@ -2454,6 +2460,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
2454
2460
|
// pressed later still settles it (`--waitFor … --after=<reply ts>`).
|
|
2455
2461
|
// 6 the question was VOIDED (無効化, `slack ask --void`) — it will never be
|
|
2456
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.
|
|
2457
2465
|
// Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
|
|
2458
2466
|
|
|
2459
2467
|
const ASK_EXIT_TIMEOUT = 2;
|
|
@@ -2473,6 +2481,20 @@ const ASK_EXIT_FREETEXT = 5;
|
|
|
2473
2481
|
* Neither "open" nor "answered": a monitor that kept waiting on it would wait
|
|
2474
2482
|
* forever, and one that read it as answered would act on nothing. */
|
|
2475
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
|
+
}
|
|
2476
2498
|
|
|
2477
2499
|
const ASK_PHI = 1.618033988749895;
|
|
2478
2500
|
const ASK_POLL_MIN_MS = 1000;
|
|
@@ -2497,6 +2519,10 @@ interface AskArgs {
|
|
|
2497
2519
|
userId?: string;
|
|
2498
2520
|
asBot?: boolean;
|
|
2499
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;
|
|
2500
2526
|
/** `--json` with `--wait`: one JSON object on stdout (answer, notes, cursor). */
|
|
2501
2527
|
json?: boolean;
|
|
2502
2528
|
timeout?: number;
|
|
@@ -2611,6 +2637,11 @@ interface AskWaitCtx {
|
|
|
2611
2637
|
/** `--json`: stdout is one JSON object (answer + notes + cursor) instead of
|
|
2612
2638
|
* the bare answer. Exit codes are the same either way. */
|
|
2613
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;
|
|
2614
2645
|
}
|
|
2615
2646
|
|
|
2616
2647
|
/** A reply from the question's audience that is NOT the answer — an extra
|
|
@@ -2679,7 +2710,7 @@ async function askCollectNotes(token: string, sc: AskNoteScope, after: string |
|
|
|
2679
2710
|
// integration's) is excluded here, so nothing echoes back as a "note".
|
|
2680
2711
|
.filter((m) => sc.isAnswerer(m.user) && !m.bot_id)
|
|
2681
2712
|
.filter((m) => typeof m.subtype !== "string" || ASK_ANSWERABLE_SUBTYPES.has(m.subtype))
|
|
2682
|
-
.filter((m) => typeof m.text === "string" && m.text.trim() !== "")
|
|
2713
|
+
.filter((m) => typeof m.text === "string" && m.text.trim() !== "" && !askIsExplainNote(m.text))
|
|
2683
2714
|
// Sorted BEFORE `skip`, which may be stateful ("the first reply that …").
|
|
2684
2715
|
.sort((a, b) => Number(a.ts) - Number(b.ts))
|
|
2685
2716
|
.filter((m) => !skip(m));
|
|
@@ -2709,12 +2740,15 @@ function askNextCursor(...tss: (string | undefined)[]): string {
|
|
|
2709
2740
|
async function askEmit(
|
|
2710
2741
|
token: string,
|
|
2711
2742
|
o: { json: boolean; shown: string; asBot: boolean; cookie?: string | undefined },
|
|
2712
|
-
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[] },
|
|
2713
2744
|
): Promise<void> {
|
|
2714
2745
|
if (o.json) {
|
|
2715
2746
|
console.log(JSON.stringify({
|
|
2716
2747
|
status: r.status, answer: r.answer, how: r.how ?? null, who: r.who ?? null,
|
|
2717
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 } : {}),
|
|
2718
2752
|
notes: r.notes, cursor: r.cursor, permalink: o.shown,
|
|
2719
2753
|
}));
|
|
2720
2754
|
return;
|
|
@@ -2751,6 +2785,13 @@ interface AskPollState {
|
|
|
2751
2785
|
replyCount?: number;
|
|
2752
2786
|
/** The question's text as last read — what the ✅ rewrite keeps the body of. */
|
|
2753
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;
|
|
2754
2795
|
}
|
|
2755
2796
|
|
|
2756
2797
|
/** The polling half of `--wait`: one `poll()` is one look at the question,
|
|
@@ -2766,7 +2807,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
2766
2807
|
} {
|
|
2767
2808
|
const { channelId, ts, reactable, threadOnly, audience, broadcast, cookie } = ctx;
|
|
2768
2809
|
const copy = ASK_COPY[ctx.lang];
|
|
2769
|
-
const state: AskPollState = {};
|
|
2810
|
+
const state: AskPollState = { explainNow: [], explainPosted: false };
|
|
2770
2811
|
const askerUserId = ctx.askerUserId;
|
|
2771
2812
|
const askerBotId = ctx.askerBotId;
|
|
2772
2813
|
|
|
@@ -2842,7 +2883,93 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
2842
2883
|
}
|
|
2843
2884
|
}
|
|
2844
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
|
+
|
|
2845
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);
|
|
2846
2973
|
if (reactable.length) noteOther(msg);
|
|
2847
2974
|
const picks = humanChoices(msg);
|
|
2848
2975
|
// Nothing ambiguous any more — take the notice back down if one is up.
|
|
@@ -2892,7 +3019,9 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
2892
3019
|
if (typeof m.subtype === "string" && !ASK_ANSWERABLE_SUBTYPES.has(m.subtype)) continue;
|
|
2893
3020
|
if (Number(m.ts) >= before) break;
|
|
2894
3021
|
const t = typeof m.text === "string" ? m.text.trim() : "";
|
|
2895
|
-
|
|
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;
|
|
2896
3025
|
// A question asked WITHOUT choices is answered by whatever comes back —
|
|
2897
3026
|
// there is nothing to match against, and every reply is the answer.
|
|
2898
3027
|
if (!candidates.length) return { answer: t, how: copy.howReply, who: m.user, ts: String(m.ts) };
|
|
@@ -3038,6 +3167,12 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3038
3167
|
} catch {
|
|
3039
3168
|
// Cosmetic: the ✅ in the body is what actually marks it resolved.
|
|
3040
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
|
+
}
|
|
3041
3176
|
} catch (e: unknown) {
|
|
3042
3177
|
// The edit is bookkeeping, not the answer. It fails whenever the resuming
|
|
3043
3178
|
// token is not the one that posted (Slack lets only the author update), and
|
|
@@ -3099,6 +3234,15 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3099
3234
|
await askEmit(token, ctx, r);
|
|
3100
3235
|
process.exit(0);
|
|
3101
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
|
+
}
|
|
3102
3246
|
// A free-text reply that picks nothing is delivered NOW, not held until the
|
|
3103
3247
|
// deadline: it is usually an instruction or a question back, and the only
|
|
3104
3248
|
// one who can act on it is the caller. Left unstamped — a pill pressed
|
|
@@ -3119,7 +3263,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3119
3263
|
// but still reports a pill pressed after it.
|
|
3120
3264
|
markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
|
|
3121
3265
|
if (ctx.json) {
|
|
3122
|
-
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 });
|
|
3123
3267
|
} else {
|
|
3124
3268
|
console.log(unchosen.text);
|
|
3125
3269
|
}
|
|
@@ -3164,7 +3308,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3164
3308
|
console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
3165
3309
|
console.error(` ${shown}`);
|
|
3166
3310
|
console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3167
|
-
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 });
|
|
3168
3312
|
process.exit(ASK_EXIT_UNCHOSEN);
|
|
3169
3313
|
}
|
|
3170
3314
|
if (timeout === 0) {
|
|
@@ -3174,7 +3318,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3174
3318
|
console.error(` ${shown}`);
|
|
3175
3319
|
console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3176
3320
|
}
|
|
3177
|
-
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 });
|
|
3178
3322
|
process.exit(ASK_EXIT_TIMEOUT);
|
|
3179
3323
|
}
|
|
3180
3324
|
|
|
@@ -3303,7 +3447,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3303
3447
|
process.exit(ASK_EXIT_ERROR);
|
|
3304
3448
|
}
|
|
3305
3449
|
const lang = picked.lang;
|
|
3306
|
-
const
|
|
3450
|
+
const explain = args.explain ?? askExplainDefault();
|
|
3451
|
+
const message = askBuildText(question, body, reactable, overflow, threadOnly, lang, explain);
|
|
3307
3452
|
guardUrlBoundaries(message, args.allowUrlAdjacent);
|
|
3308
3453
|
|
|
3309
3454
|
// Preview the destination's last message, exactly as `send` does — the gate's
|
|
@@ -3354,6 +3499,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3354
3499
|
// shown as its own line rather than left to be inferred from the body.
|
|
3355
3500
|
` Answerable by: ${audienceLabel}`,
|
|
3356
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`] : []),
|
|
3357
3503
|
...(args.voidAfter ? [` Then 無効化: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) — replaced by this question`] : []),
|
|
3358
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)`,
|
|
3359
3505
|
`--------------------------------────────────`,
|
|
@@ -3382,7 +3528,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3382
3528
|
// `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
|
|
3383
3529
|
// SLACK_REACTION_SEED_GAP_MS.
|
|
3384
3530
|
await seedReactionsInOrder(
|
|
3385
|
-
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER],
|
|
3531
|
+
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER, ...(explain ? [ASK_EXPLAIN_MARKER] : [])],
|
|
3386
3532
|
(name) => reactionAdd(token, channelId, ts, name, cookie),
|
|
3387
3533
|
(name, e) => {
|
|
3388
3534
|
const why = e instanceof Error ? e.message : String(e);
|
|
@@ -3438,6 +3584,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3438
3584
|
shown,
|
|
3439
3585
|
asBot: !!args.asBot,
|
|
3440
3586
|
json: !!args.json,
|
|
3587
|
+
explain,
|
|
3588
|
+
explainExit: !!args.explainExit,
|
|
3441
3589
|
};
|
|
3442
3590
|
if (threadTs) ctx.threadParentTs = threadTs;
|
|
3443
3591
|
if (cookie) ctx.cookie = cookie;
|
|
@@ -3597,7 +3745,7 @@ async function cmdAskEdit(token: string, args: {
|
|
|
3597
3745
|
const reactable = choices.slice(0, ASK_MAX_REACTION_CHOICES);
|
|
3598
3746
|
const overflow = choices.slice(ASK_MAX_REACTION_CHOICES);
|
|
3599
3747
|
const oldText = typeof t.msg.text === "string" ? t.msg.text : "";
|
|
3600
|
-
let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang);
|
|
3748
|
+
let next = askBuildText(question, body, reactable, overflow, old.threadOnly, lang, old.explain);
|
|
3601
3749
|
// An invalid-ballot notice still in force stays (it names the people who
|
|
3602
3750
|
// have to fix their vote); it is only possible when the options did not change.
|
|
3603
3751
|
const invalid = readInvalidNotice(oldText);
|
|
@@ -3632,11 +3780,12 @@ async function cmdAskEdit(token: string, args: {
|
|
|
3632
3780
|
await editMessage(token, t.channelId, t.ts, next, args.cookie, true);
|
|
3633
3781
|
if (choicesChanged) {
|
|
3634
3782
|
// Re-seed in order: 1..n then ❓ last, as a fresh question has them.
|
|
3635
|
-
|
|
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]) {
|
|
3636
3785
|
try { await reactionRemove(token, t.channelId, t.ts, name, args.cookie); } catch { /* already gone */ }
|
|
3637
3786
|
}
|
|
3638
3787
|
await seedReactionsInOrder(
|
|
3639
|
-
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name),
|
|
3788
|
+
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail],
|
|
3640
3789
|
(name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
|
|
3641
3790
|
(name, e) => console.error(` (リアクション ${name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`),
|
|
3642
3791
|
);
|
|
@@ -3762,7 +3911,10 @@ async function cmdAskPending(
|
|
|
3762
3911
|
}
|
|
3763
3912
|
const rows = opts.channel ? all.filter((r) => r.channel === opts.channel || r.target === opts.channel) : all;
|
|
3764
3913
|
const pending: AskPendingItem[] = [];
|
|
3765
|
-
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[] = [];
|
|
3766
3918
|
const problems: string[] = [];
|
|
3767
3919
|
// The log is machine-wide, but a token reads one workspace. Asks recorded
|
|
3768
3920
|
// under another workspace are counted and named, not probed with the wrong
|
|
@@ -3807,6 +3959,10 @@ async function cmdAskPending(
|
|
|
3807
3959
|
const unchosen = poller.state.unchosen;
|
|
3808
3960
|
if (poller.state.voided) { count.void++; continue; }
|
|
3809
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
|
+
}
|
|
3810
3966
|
const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
|
|
3811
3967
|
if (found) {
|
|
3812
3968
|
pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
|
|
@@ -3855,6 +4011,7 @@ async function cmdAskPending(
|
|
|
3855
4011
|
}
|
|
3856
4012
|
}
|
|
3857
4013
|
for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
|
|
4014
|
+
for (const p of explainAsked) console.error(` (${stripTerminalControls(p)})`);
|
|
3858
4015
|
for (const [team, n] of otherTeams) {
|
|
3859
4016
|
console.error(` (${n} 件は別のワークスペース「${stripTerminalControls(team)}」の質問です — slack -w <name> ask --pending で確認)`);
|
|
3860
4017
|
}
|
|
@@ -3863,6 +4020,7 @@ async function cmdAskPending(
|
|
|
3863
4020
|
(count.ambiguous ? `, ${count.ambiguous} 件は返信が複数の選択肢に一致 (未確定)` : "") +
|
|
3864
4021
|
(count.resolved ? `, ${count.resolved} 件は ✅ 回収済み` : "") +
|
|
3865
4022
|
(count.void ? `, ${count.void} 件は無効化済み` : "") +
|
|
4023
|
+
(count.explain ? `, ${count.explain} 件は ℹ️ 説明のリクエストあり` : "") +
|
|
3866
4024
|
` — ${rows.length} 件の未回収の質問を確認 (log: ${sentDbPath()})`,
|
|
3867
4025
|
);
|
|
3868
4026
|
}
|
|
@@ -3887,7 +4045,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
|
|
|
3887
4045
|
console.error(`✗ ${shown}: 書き換えに失敗しました: ${e instanceof Error ? e.message : String(e)}`);
|
|
3888
4046
|
return false;
|
|
3889
4047
|
}
|
|
3890
|
-
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] : [])]) {
|
|
3891
4049
|
try { await reactionRemove(token, t.channelId, t.ts, name, cookie); } catch { /* not ours / already gone */ }
|
|
3892
4050
|
}
|
|
3893
4051
|
try {
|
|
@@ -3981,6 +4139,8 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
3981
4139
|
// Placeholders: only the COUNT is used — which of our keycap seeds to take off.
|
|
3982
4140
|
reactable: Array.from({ length: seeded.length ? Math.max(...seeded) + 1 : 0 }, () => ""),
|
|
3983
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")),
|
|
3984
4144
|
};
|
|
3985
4145
|
unreadable = true;
|
|
3986
4146
|
}
|
|
@@ -4027,7 +4187,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4027
4187
|
* file to go stale, lose, or disagree with Slack, and any machine holding the
|
|
4028
4188
|
* permalink can collect — at the cost of the body being a parseable format
|
|
4029
4189
|
* (`askBuildText` ⇄ `askParseMessage`). */
|
|
4030
|
-
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> {
|
|
4031
4191
|
const url = parseSlackPermalink(args.link);
|
|
4032
4192
|
let channelId: string;
|
|
4033
4193
|
let ts: string;
|
|
@@ -4181,7 +4341,7 @@ function askWaitCtxFrom(
|
|
|
4181
4341
|
q: AskReadBack,
|
|
4182
4342
|
parsed: Extract<AskReadBack["parsed"], { kind: "open" }>,
|
|
4183
4343
|
loc: { channelId: string; ts: string; threadTs?: string | undefined },
|
|
4184
|
-
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 },
|
|
4185
4345
|
): AskWaitCtx {
|
|
4186
4346
|
const ctx: AskWaitCtx = {
|
|
4187
4347
|
channelId: loc.channelId,
|
|
@@ -4199,6 +4359,8 @@ function askWaitCtxFrom(
|
|
|
4199
4359
|
shown: o.link,
|
|
4200
4360
|
asBot: o.asBot,
|
|
4201
4361
|
json: o.json,
|
|
4362
|
+
explain: parsed.explain,
|
|
4363
|
+
explainExit: !!o.explainExit,
|
|
4202
4364
|
};
|
|
4203
4365
|
if (loc.threadTs) ctx.threadParentTs = loc.threadTs;
|
|
4204
4366
|
if (o.cookie) ctx.cookie = o.cookie;
|
|
@@ -5742,8 +5904,8 @@ async function main(): Promise<void> {
|
|
|
5742
5904
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
5743
5905
|
.option("body", { type: "string", describe: "Extra context shown under the question" })
|
|
5744
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" })
|
|
5745
|
-
.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." })
|
|
5746
|
-
.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." })
|
|
5747
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." })
|
|
5748
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…>`." })
|
|
5749
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." })
|
|
@@ -5757,6 +5919,8 @@ async function main(): Promise<void> {
|
|
|
5757
5919
|
.option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
|
|
5758
5920
|
.option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
|
|
5759
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." })
|
|
5760
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." })
|
|
5761
5925
|
.option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
|
|
5762
5926
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
@@ -5881,6 +6045,8 @@ async function main(): Promise<void> {
|
|
|
5881
6045
|
argv.choices = newChoices.length ? newChoices : [...t.parsed.reactable, ...t.parsed.overflow].map(escapeArg);
|
|
5882
6046
|
if (argv.body === undefined && t.parsed.body) argv.body = escapeArg(t.parsed.body);
|
|
5883
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;
|
|
5884
6050
|
argv.void = undefined;
|
|
5885
6051
|
}
|
|
5886
6052
|
if (!voidAfter && (argv.target === "void" || voidFlag.length)) {
|
|
@@ -5970,7 +6136,8 @@ async function main(): Promise<void> {
|
|
|
5970
6136
|
waitCookie = ck(argv as W);
|
|
5971
6137
|
}
|
|
5972
6138
|
try {
|
|
5973
|
-
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;
|
|
5974
6141
|
if (waitCookie) a.cookie = waitCookie;
|
|
5975
6142
|
if (argv.after) {
|
|
5976
6143
|
const after = String(argv.after);
|
|
@@ -6008,6 +6175,8 @@ async function main(): Promise<void> {
|
|
|
6008
6175
|
if (argv.lang) args.lang = String(argv.lang);
|
|
6009
6176
|
if (argv.wait) args.wait = true;
|
|
6010
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;
|
|
6011
6180
|
if (voidAfter) args.voidAfter = voidAfter;
|
|
6012
6181
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
6013
6182
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
@@ -7226,6 +7395,153 @@ async function main(): Promise<void> {
|
|
|
7226
7395
|
)
|
|
7227
7396
|
.command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
|
|
7228
7397
|
)
|
|
7398
|
+
.command(
|
|
7399
|
+
"stream",
|
|
7400
|
+
"Print every new message matching --grep, across all channels the identity is in (threads included); resumable",
|
|
7401
|
+
(y) => y
|
|
7402
|
+
.option("grep", { alias: "e", type: "string", demandOption: true, describe: "Regex matched against each message's text (raw, e.g. <@U00000001>). Non-matching messages are never printed or logged" })
|
|
7403
|
+
.option("ignore-case", { alias: "i", type: "boolean", default: false, describe: "Case-insensitive --grep" })
|
|
7404
|
+
.option("channel", { type: "array", string: true, describe: "Only these channels (#name, id or permalink); repeatable" })
|
|
7405
|
+
.option("replies-to", { type: "array", string: true, describe: "Also print replies in threads this id started or replied in (user U…/bot B…; `self` = the streaming identity), --grep or not; repeatable" })
|
|
7406
|
+
.option("json", { type: "boolean", default: false, describe: "One JSON object per match: type, channel{id,name}, ts, thread_ts, user{id,name}, text, permalink, match (grep | replies-to)" })
|
|
7407
|
+
.option("once", { type: "boolean", default: false, describe: "Scan once from the saved cursor (or --since) and exit: 0 = matches printed, 2 = none, 3 = failure" })
|
|
7408
|
+
.option("since", { type: "string", describe: "Replay from this long ago (e.g. 30m, 2h), ignoring the saved cursor" })
|
|
7409
|
+
.option("interval", { type: "string", default: "45s", describe: "Poll interval (e.g. 30s, 2m)" })
|
|
7410
|
+
.option("thread-window", { type: "string", default: "3d", describe: "Watch replies in threads whose parent is at most this old" })
|
|
7411
|
+
.option("thread-scan", { type: "string", default: "3m", describe: "Polling: re-read each channel's whole --thread-window this often (cycles in between read only what is new)" })
|
|
7412
|
+
.option("calls-per-min", { type: "number", default: 40, describe: "Polling: pace conversations.history/replies to this many calls per minute (0 = unpaced)" })
|
|
7413
|
+
.option("state", { type: "string", describe: "Cursor file (default: one per identity+grep+channels under $XDG_STATE_HOME/slack-term/stream/)" })
|
|
7414
|
+
.option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
|
|
7415
|
+
.option("relay", { type: "boolean", default: true, describe: "Use the doorbell relay (worker/) when SLACK_RELAY_URL and SLACK_RELAY_TOKEN are set; --no-relay polls only" })
|
|
7416
|
+
.option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" }),
|
|
7417
|
+
async (argv) => {
|
|
7418
|
+
let re: RegExp;
|
|
7419
|
+
let reconcileMs: number;
|
|
7420
|
+
let intervalMs: number;
|
|
7421
|
+
let windowSec: number;
|
|
7422
|
+
let threadScanSec: number;
|
|
7423
|
+
let sinceSec: number | undefined;
|
|
7424
|
+
try {
|
|
7425
|
+
re = new RegExp(argv.grep, argv["ignore-case"] ? "i" : "");
|
|
7426
|
+
intervalMs = parseDuration(argv.interval) * 1000;
|
|
7427
|
+
windowSec = parseDuration(argv["thread-window"]);
|
|
7428
|
+
threadScanSec = parseDuration(argv["thread-scan"]);
|
|
7429
|
+
sinceSec = argv.since !== undefined ? parseDuration(argv.since) : undefined;
|
|
7430
|
+
reconcileMs = parseDuration(argv.reconcile) * 1000;
|
|
7431
|
+
} catch (e) {
|
|
7432
|
+
console.error(`slack stream: ${e instanceof Error ? e.message : String(e)}`);
|
|
7433
|
+
process.exit(1);
|
|
7434
|
+
}
|
|
7435
|
+
// Privacy-safe default: the bot sees only the channels it was invited
|
|
7436
|
+
// to, never the user's DMs. The user identity is an explicit opt-in.
|
|
7437
|
+
const bot = resolveBotToken();
|
|
7438
|
+
const asUser = argv["as-user"] === true || !bot;
|
|
7439
|
+
const token = asUser ? tok(argv as W) : bot!;
|
|
7440
|
+
const cookie = asUser ? ck(argv as W) : undefined;
|
|
7441
|
+
if (!bot && argv["as-user"] !== true) {
|
|
7442
|
+
console.error("slack stream: no bot token (SLACK_BOT_TOKEN) — streaming as the USER identity, which includes its DMs.");
|
|
7443
|
+
}
|
|
7444
|
+
let me: Awaited<ReturnType<typeof authScopes>>;
|
|
7445
|
+
try {
|
|
7446
|
+
me = await authScopes(token, cookie);
|
|
7447
|
+
} catch (e) {
|
|
7448
|
+
console.error(`slack stream: fatal: ${e instanceof Error ? e.message : String(e)}`);
|
|
7449
|
+
process.exit(3);
|
|
7450
|
+
}
|
|
7451
|
+
// Self-echo exclusion is by sender id: the identity's own user id, its
|
|
7452
|
+
// bot id, and — when streaming as the user — the bot's posts too.
|
|
7453
|
+
const selfUsers = new Set([me.userId].filter(Boolean));
|
|
7454
|
+
const selfBots = new Set([me.botId].filter(Boolean));
|
|
7455
|
+
if (asUser && bot) {
|
|
7456
|
+
try {
|
|
7457
|
+
const b = await authScopes(bot);
|
|
7458
|
+
if (b.userId) selfUsers.add(b.userId);
|
|
7459
|
+
if (b.botId) selfBots.add(b.botId);
|
|
7460
|
+
} catch { /* a dead bot token posts nothing to exclude */ }
|
|
7461
|
+
}
|
|
7462
|
+
// Slack names a bot in a thread either way: `parent_user_id` holds its
|
|
7463
|
+
// bot USER id (auth.test user_id), `reply_users` sometimes its B… bot
|
|
7464
|
+
// id — so `self` stands for both.
|
|
7465
|
+
let repliesTo: Set<string> | undefined;
|
|
7466
|
+
if (argv["replies-to"]?.length) {
|
|
7467
|
+
repliesTo = new Set();
|
|
7468
|
+
for (const r of argv["replies-to"].map(String)) {
|
|
7469
|
+
if (r === "self") {
|
|
7470
|
+
for (const id of [me.userId, me.botId]) if (id) repliesTo.add(id);
|
|
7471
|
+
continue;
|
|
7472
|
+
}
|
|
7473
|
+
if (!/^[UWB][A-Z0-9]{2,}$/.test(r)) {
|
|
7474
|
+
console.error(`slack stream: --replies-to: "${r}" is not a user or bot id (U…/W…/B…) or "self"`);
|
|
7475
|
+
process.exit(1);
|
|
7476
|
+
}
|
|
7477
|
+
repliesTo.add(r);
|
|
7478
|
+
// A bot can be listed under its user id too: add that alias.
|
|
7479
|
+
if (r.startsWith("B")) {
|
|
7480
|
+
try {
|
|
7481
|
+
const { userId } = await botsInfo(token, r, cookie);
|
|
7482
|
+
if (userId) repliesTo.add(userId);
|
|
7483
|
+
} catch (e) {
|
|
7484
|
+
console.error(`slack stream: --replies-to ${r}: cannot resolve its bot user id (${e instanceof Error ? e.message : String(e)}); threads where it shows as a user id are missed`);
|
|
7485
|
+
}
|
|
7486
|
+
}
|
|
7487
|
+
}
|
|
7488
|
+
}
|
|
7489
|
+
let channels: string[] | undefined;
|
|
7490
|
+
if (argv.channel?.length) {
|
|
7491
|
+
try {
|
|
7492
|
+
channels = [];
|
|
7493
|
+
for (const c of argv.channel) channels.push(await resolveChannel(token, String(c), cookie));
|
|
7494
|
+
} catch (e) {
|
|
7495
|
+
console.error(`slack stream: --channel: ${e instanceof Error ? e.message : String(e)}`);
|
|
7496
|
+
process.exit(1);
|
|
7497
|
+
}
|
|
7498
|
+
}
|
|
7499
|
+
const key = createHash("sha256")
|
|
7500
|
+
.update(JSON.stringify([me.url, me.userId, re.source, re.flags, [...(channels ?? [])].sort(),
|
|
7501
|
+
// As given, not as resolved: a failed bots.info lookup must not
|
|
7502
|
+
// move the stream to a fresh state file.
|
|
7503
|
+
...(argv["replies-to"]?.length ? [[...argv["replies-to"].map(String)].sort()] : [])]))
|
|
7504
|
+
.digest("hex").slice(0, 16);
|
|
7505
|
+
const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
|
|
7506
|
+
const statePath = argv.state ?? join(stateHome, "slack-term", "stream", `${key}.json`);
|
|
7507
|
+
console.error(`slack stream: as ${asUser ? "user" : "bot"} @${me.user} (${me.userId}); state ${statePath}`);
|
|
7508
|
+
// The relay rings for what the Slack app's bot can see, so it only
|
|
7509
|
+
// stands in for polling when streaming as that bot.
|
|
7510
|
+
const relayUrl = process.env.SLACK_RELAY_URL;
|
|
7511
|
+
const relayToken = process.env.SLACK_RELAY_TOKEN;
|
|
7512
|
+
const relay = argv.relay && !asUser && !argv.once && relayUrl && relayToken
|
|
7513
|
+
? { url: relayUrl, subscribe: relaySubscriber(relayUrl, relayToken), reconcileMs }
|
|
7514
|
+
: undefined;
|
|
7515
|
+
if (argv.relay && asUser && relayUrl) console.error("slack stream: relay not used with --as-user (it only sees the bot's channels)");
|
|
7516
|
+
const pollReason = !argv.relay ? "--no-relay"
|
|
7517
|
+
: asUser ? (relayUrl ? "--as-user: the relay only rings for the bot's channels" : "streaming as the user; the relay serves the bot identity")
|
|
7518
|
+
: "SLACK_RELAY_URL / SLACK_RELAY_TOKEN not set";
|
|
7519
|
+
const ac = new AbortController();
|
|
7520
|
+
process.on("SIGINT", () => ac.abort());
|
|
7521
|
+
process.on("SIGTERM", () => ac.abort());
|
|
7522
|
+
const code = await runStream(webClient(token, cookie), {
|
|
7523
|
+
grep: re,
|
|
7524
|
+
...(repliesTo ? { repliesTo } : {}),
|
|
7525
|
+
...(channels ? { channels } : {}),
|
|
7526
|
+
selfUsers,
|
|
7527
|
+
selfBots,
|
|
7528
|
+
json: argv.json,
|
|
7529
|
+
once: argv.once,
|
|
7530
|
+
...(sinceSec !== undefined ? { sinceSec } : {}),
|
|
7531
|
+
intervalMs,
|
|
7532
|
+
threadWindowSec: windowSec,
|
|
7533
|
+
threadScanSec,
|
|
7534
|
+
callsPerMin: argv["calls-per-min"],
|
|
7535
|
+
...(relay ? {} : { pollReason }),
|
|
7536
|
+
statePath,
|
|
7537
|
+
teamUrl: me.url,
|
|
7538
|
+
identity: me.userId,
|
|
7539
|
+
signal: ac.signal,
|
|
7540
|
+
...(relay ? { relay } : {}),
|
|
7541
|
+
});
|
|
7542
|
+
process.exit(code);
|
|
7543
|
+
},
|
|
7544
|
+
)
|
|
7229
7545
|
.command(
|
|
7230
7546
|
"tail [target]",
|
|
7231
7547
|
"Stream new messages in real time (like tail -f)",
|