slack-term 1.41.2 → 1.43.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 +82 -0
- package/SKILL.md +18 -0
- package/dist/cli.js +228 -208
- package/package.json +2 -1
- package/ts/cli.ts +472 -83
- package/ts/newlines.ts +158 -0
- package/ts/relay.ts +105 -0
- package/ts/sentlog.ts +106 -2
- package/ts/slack.ts +52 -4
- package/ts/stream.ts +1048 -0
package/ts/cli.ts
CHANGED
|
@@ -12,7 +12,9 @@ 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 {
|
|
@@ -54,10 +56,11 @@ import {
|
|
|
54
56
|
} from "./poll.ts";
|
|
55
57
|
import { seedReactionsInOrder } from "./reactionSeed.ts";
|
|
56
58
|
import { buildListSchema, cellValue, completedColumn, findColumn, parseColumnSpec, parseListSchema, parseSets, type ListColumn } from "./lists.ts";
|
|
57
|
-
import { attributionEnabled, attributionMetadata, captureAttribution, parseSince, querySent, recordSent, SENT_LOG_UNAVAILABLE, sentDbPath, type RecordSent, type SentKind, type SentRow } from "./sentlog.ts";
|
|
59
|
+
import { attributionEnabled, attributionMetadata, captureAttribution, markAskCollected, markAskDelivered, parseSince, querySent, recordSent, SENT_LOG_UNAVAILABLE, sentDbPath, senderAlive, uncollectedAsks, type RecordSent, type SentKind, type SentRow } from "./sentlog.ts";
|
|
58
60
|
import {
|
|
59
61
|
authTest,
|
|
60
62
|
authScopes,
|
|
63
|
+
botsInfo,
|
|
61
64
|
authTestSession,
|
|
62
65
|
conversationInfoSession,
|
|
63
66
|
createChannel,
|
|
@@ -2740,12 +2743,33 @@ async function askEmit(
|
|
|
2740
2743
|
}
|
|
2741
2744
|
|
|
2742
2745
|
|
|
2743
|
-
/**
|
|
2744
|
-
*
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2746
|
+
/** What one look at a question found besides the answer — read back by the
|
|
2747
|
+
* `--wait` loop between polls, and by `ask --pending` after its single look. */
|
|
2748
|
+
interface AskPollState {
|
|
2749
|
+
/** The earliest audience reply that picked none of the choices. */
|
|
2750
|
+
unchosen?: { text: string; who: string; ts: string; ambiguous: boolean };
|
|
2751
|
+
/** Set when the question has been voided. */
|
|
2752
|
+
voided?: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }>;
|
|
2753
|
+
/** The question's `reply_count` as last seen — undefined until seen. */
|
|
2754
|
+
replyCount?: number;
|
|
2755
|
+
/** The question's text as last read — what the ✅ rewrite keeps the body of. */
|
|
2756
|
+
ownText?: string;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
/** The polling half of `--wait`: one `poll()` is one look at the question,
|
|
2760
|
+
* shared with `ask --pending`. `readOnly` drops the one WRITE a look can make
|
|
2761
|
+
* (the invalid-ballot notice edited into the question) and the progress lines
|
|
2762
|
+
* on stderr — `--pending` inspects questions other sessions are responsible
|
|
2763
|
+
* for, and must neither touch them nor narrate their state as if waiting. */
|
|
2764
|
+
function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
2765
|
+
poll: () => Promise<AskFound | null>;
|
|
2766
|
+
state: AskPollState;
|
|
2767
|
+
isAnswerer: (user: unknown) => user is string;
|
|
2768
|
+
candidates: string[];
|
|
2769
|
+
} {
|
|
2770
|
+
const { channelId, ts, reactable, threadOnly, audience, broadcast, cookie } = ctx;
|
|
2748
2771
|
const copy = ASK_COPY[ctx.lang];
|
|
2772
|
+
const state: AskPollState = {};
|
|
2749
2773
|
const askerUserId = ctx.askerUserId;
|
|
2750
2774
|
const askerBotId = ctx.askerBotId;
|
|
2751
2775
|
|
|
@@ -2756,25 +2780,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2756
2780
|
const isAnswerer = (user: unknown): user is string =>
|
|
2757
2781
|
typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
|
|
2758
2782
|
|
|
2759
|
-
/** The question
|
|
2760
|
-
* fetch when collecting notes
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2783
|
+
/** The question as last seen: its `reply_count` (whether its thread is worth
|
|
2784
|
+
* a fetch when collecting notes) and its text (what the ✅ rewrite keeps). */
|
|
2785
|
+
const seen = (own: Record<string, Json>): void => {
|
|
2786
|
+
state.replyCount = Number(own.reply_count) || 0;
|
|
2787
|
+
if (typeof own.text === "string") state.ownText = own.text;
|
|
2788
|
+
};
|
|
2764
2789
|
/** Set when the question is voided while we wait — the wait ends there. */
|
|
2765
|
-
let voided: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }> | undefined;
|
|
2766
2790
|
const checkVoid = (own: Record<string, Json>): boolean => {
|
|
2767
2791
|
const p = typeof own.text === "string" ? askParseMessage(own.text) : undefined;
|
|
2768
|
-
if (p?.kind === "void") voided = p;
|
|
2769
|
-
return !!voided;
|
|
2792
|
+
if (p?.kind === "void") state.voided = p;
|
|
2793
|
+
return !!state.voided;
|
|
2770
2794
|
};
|
|
2771
2795
|
|
|
2772
|
-
/** The earliest reply from an answerer that picked none of the choices. Kept
|
|
2773
|
-
* across polls so the timeout can report it, and so the operator is told once
|
|
2774
|
-
* rather than on every tick. */
|
|
2775
|
-
let unchosen: { text: string; who: string; ts: string; ambiguous: boolean } | undefined;
|
|
2776
|
-
let unchosenReported = "";
|
|
2777
|
-
|
|
2778
2796
|
/** Every seed an answerer is on — not just the first. Changing your mind
|
|
2779
2797
|
* leaves BOTH reactions in place (Slack only drops one when you actively
|
|
2780
2798
|
* un-react), so taking the lowest index would answer with the abandoned
|
|
@@ -2800,6 +2818,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2800
2818
|
* the API call when nothing changed, which is what makes this safe to run on
|
|
2801
2819
|
* every poll tick rather than once per process. */
|
|
2802
2820
|
async function noteInvalid(msg: Record<string, Json>, offenders: string[]): Promise<void> {
|
|
2821
|
+
if (readOnly) return;
|
|
2803
2822
|
const text = typeof msg.text === "string" ? msg.text : "";
|
|
2804
2823
|
if (!text) return;
|
|
2805
2824
|
const next = applyInvalidNotice(text, offenders, ctx.lang);
|
|
@@ -2822,7 +2841,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2822
2841
|
for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
|
|
2823
2842
|
if (otherReported.has(u)) continue;
|
|
2824
2843
|
otherReported.add(u);
|
|
2825
|
-
console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
|
|
2844
|
+
if (!readOnly) console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
|
|
2826
2845
|
}
|
|
2827
2846
|
}
|
|
2828
2847
|
|
|
@@ -2845,7 +2864,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2845
2864
|
const pillsPerUser = new Map<string, number>();
|
|
2846
2865
|
for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
|
|
2847
2866
|
const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
|
|
2848
|
-
console.error(multi.length
|
|
2867
|
+
if (!readOnly) console.error(multi.length
|
|
2849
2868
|
? ` (${glyphs} が同時に選ばれています。1 つに絞ってもらうまで待ちます)`
|
|
2850
2869
|
: ` (回答が分かれています: ${glyphs}。どちらか一方にそろうまで待ちます)`);
|
|
2851
2870
|
// Whoever is on more than one pill — and ONLY them. Naming everyone on any
|
|
@@ -2895,9 +2914,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2895
2914
|
// must not hide it behind the refuse-to-guess path. Within a class, the
|
|
2896
2915
|
// earliest wins.
|
|
2897
2916
|
const ambiguous = match.kind === "ambiguous";
|
|
2917
|
+
const unchosen = state.unchosen;
|
|
2898
2918
|
if (!unchosen || (unchosen.ambiguous && !ambiguous) ||
|
|
2899
2919
|
(unchosen.ambiguous === ambiguous && Number(m.ts) < Number(unchosen.ts))) {
|
|
2900
|
-
unchosen = { text: t, who: typeof m.user === "string" ? m.user : "", ts: String(m.ts), ambiguous };
|
|
2920
|
+
state.unchosen = { text: t, who: typeof m.user === "string" ? m.user : "", ts: String(m.ts), ambiguous };
|
|
2901
2921
|
}
|
|
2902
2922
|
}
|
|
2903
2923
|
return null;
|
|
@@ -2910,7 +2930,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2910
2930
|
// longer there, and exit 4 where exit 2 is the truth. `unchosenReported`
|
|
2911
2931
|
// persists on purpose — it is what keeps the operator from being told the
|
|
2912
2932
|
// same thing on every tick.
|
|
2913
|
-
unchosen
|
|
2933
|
+
delete state.unchosen;
|
|
2914
2934
|
// A question that is itself a thread reply is invisible to
|
|
2915
2935
|
// `conversations.history`, so the entire poll — reactions included — has to
|
|
2916
2936
|
// come from the thread.
|
|
@@ -2919,8 +2939,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2919
2939
|
const messages = asArray(rep.messages).map(asRecord);
|
|
2920
2940
|
const own = messages.find((m) => m.ts === ts);
|
|
2921
2941
|
if (own) {
|
|
2922
|
-
|
|
2923
|
-
if (typeof own.text === "string") ownText = own.text;
|
|
2942
|
+
seen(own);
|
|
2924
2943
|
if (checkVoid(own)) return null;
|
|
2925
2944
|
const byReaction = await answerFromReactions(own);
|
|
2926
2945
|
if (byReaction) return byReaction;
|
|
@@ -2936,8 +2955,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2936
2955
|
const own = messages.find((m) => m.ts === ts);
|
|
2937
2956
|
|
|
2938
2957
|
if (own) {
|
|
2939
|
-
|
|
2940
|
-
if (typeof own.text === "string") ownText = own.text;
|
|
2958
|
+
seen(own);
|
|
2941
2959
|
if (checkVoid(own)) return null;
|
|
2942
2960
|
}
|
|
2943
2961
|
// A reaction is the intended path, so it wins when both are present.
|
|
@@ -2970,6 +2988,21 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2970
2988
|
return null;
|
|
2971
2989
|
}
|
|
2972
2990
|
|
|
2991
|
+
return { poll, state, isAnswerer, candidates };
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
/** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
|
|
2995
|
+
* on timeout. A timeout of 0 means "look exactly once", so a caller can poll
|
|
2996
|
+
* cheaply on its own schedule instead of parking a process on `--wait`. */
|
|
2997
|
+
async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never> {
|
|
2998
|
+
const { channelId, ts, question, reactable, threadOnly, cookie, timeout, shown } = ctx;
|
|
2999
|
+
const askerUserId = ctx.askerUserId;
|
|
3000
|
+
const askerBotId = ctx.askerBotId;
|
|
3001
|
+
const { poll, state, isAnswerer, candidates } = askPoller(token, ctx);
|
|
3002
|
+
/** Kept across polls so the operator is told about one reply once, rather
|
|
3003
|
+
* than on every tick. */
|
|
3004
|
+
let unchosenReported = "";
|
|
3005
|
+
|
|
2973
3006
|
/** Stamp the question resolved. Removing our seeds clears the pills that still
|
|
2974
3007
|
* invite an answer; the answerer's own reaction cannot be removed by us and
|
|
2975
3008
|
* stays put — which is exactly what leaves the chosen number visible. */
|
|
@@ -2996,7 +3029,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2996
3029
|
// The body and the chosen option stay; only what no longer applies goes.
|
|
2997
3030
|
// Built from the message as it is NOW (an edit made in Slack after asking
|
|
2998
3031
|
// is kept). Not seen this run — the bare form, as before.
|
|
2999
|
-
const keep = ownText ? askResolvedKeep(ownText, found.choice) : undefined;
|
|
3032
|
+
const keep = state.ownText ? askResolvedKeep(state.ownText, found.choice) : undefined;
|
|
3000
3033
|
await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
|
|
3001
3034
|
// Swap the marker for the resolved one so search reflects reality:
|
|
3002
3035
|
// `has::question:` should list what still needs answering, not everything
|
|
@@ -3019,6 +3052,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3019
3052
|
const deadline = Date.now() + timeout * 1000;
|
|
3020
3053
|
let errors = 0;
|
|
3021
3054
|
let delay = ASK_POLL_MIN_MS;
|
|
3055
|
+
let unchosen: AskPollState["unchosen"];
|
|
3022
3056
|
for (;;) {
|
|
3023
3057
|
let found: AskFound | null = null;
|
|
3024
3058
|
try {
|
|
@@ -3033,6 +3067,8 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3033
3067
|
}
|
|
3034
3068
|
console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
|
|
3035
3069
|
}
|
|
3070
|
+
unchosen = state.unchosen;
|
|
3071
|
+
const voided = state.voided;
|
|
3036
3072
|
if (voided) {
|
|
3037
3073
|
askReportVoid(voided, shown);
|
|
3038
3074
|
if (ctx.json) {
|
|
@@ -3050,7 +3086,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3050
3086
|
// BEFORE ✅ is written, and delivered right after — ✅ is what tells a
|
|
3051
3087
|
// later --waitFor the question is settled.
|
|
3052
3088
|
const notes = await askCollectNotes(token, {
|
|
3053
|
-
channelId, ts, threadOnly, replyCount, isAnswerer, askerUserId, askerBotId,
|
|
3089
|
+
channelId, ts, threadOnly, replyCount: state.replyCount, isAnswerer, askerUserId, askerBotId,
|
|
3054
3090
|
...(ctx.threadParentTs ? { threadParentTs: ctx.threadParentTs } : {}),
|
|
3055
3091
|
...(cookie ? { cookie } : {}),
|
|
3056
3092
|
}, ctx.after, (m) => m.ts === found!.ts);
|
|
@@ -3059,6 +3095,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3059
3095
|
status: "answered", answer: found.answer, how: found.how, notes,
|
|
3060
3096
|
cursor: askNextCursor(ts, ctx.after, found.ts, ...notes.map((n) => n.ts)),
|
|
3061
3097
|
};
|
|
3098
|
+
// Collected: the local sent-log row (if this machine asked it) drops off
|
|
3099
|
+
// `ask --pending`. Only here, where the answer actually reaches a caller.
|
|
3100
|
+
markAskCollected(channelId, ts, found.answer, r.cursor);
|
|
3062
3101
|
if (found.who) r.who = found.who;
|
|
3063
3102
|
await askEmit(token, ctx, r);
|
|
3064
3103
|
process.exit(0);
|
|
@@ -3079,6 +3118,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3079
3118
|
console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
3080
3119
|
console.error(` ${shown}`);
|
|
3081
3120
|
console.error(` この返信の後を待つ: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
|
|
3121
|
+
// Delivered, not collected: `--pending` will not report THIS reply again,
|
|
3122
|
+
// but still reports a pill pressed after it.
|
|
3123
|
+
markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
|
|
3082
3124
|
if (ctx.json) {
|
|
3083
3125
|
await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
|
|
3084
3126
|
} else {
|
|
@@ -3434,6 +3476,16 @@ async function askFetchByLink(token: string, link: string, cookie?: string): Pro
|
|
|
3434
3476
|
channelId = m[1]!;
|
|
3435
3477
|
ts = m[2]!;
|
|
3436
3478
|
}
|
|
3479
|
+
return askFetchAt(token, { channelId, ts, threadTs }, cookie);
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3482
|
+
/** `askFetchByLink` for a location already known — e.g. a sent-log row, whose
|
|
3483
|
+
* `thread_ts` says where a question posted into a thread lives. */
|
|
3484
|
+
async function askFetchAt(token: string, loc: { channelId: string; ts: string; threadTs?: string | undefined }, cookie?: string): Promise<
|
|
3485
|
+
{ channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> }
|
|
3486
|
+
> {
|
|
3487
|
+
const { channelId, ts } = loc;
|
|
3488
|
+
const threadTs = loc.threadTs && loc.threadTs !== loc.ts ? loc.threadTs : undefined;
|
|
3437
3489
|
let msg: Record<string, Json> | undefined;
|
|
3438
3490
|
if (threadTs) {
|
|
3439
3491
|
const rep = asRecord((await replies(token, channelId, threadTs, 100, cookie)) as Json);
|
|
@@ -3635,8 +3687,8 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
|
|
|
3635
3687
|
let answer: string | undefined;
|
|
3636
3688
|
if (token) {
|
|
3637
3689
|
try {
|
|
3638
|
-
const got = await
|
|
3639
|
-
if (!got
|
|
3690
|
+
const got = await askFetchAt(token, { channelId: r.channel, ts: r.ts, threadTs: r.thread_ts ?? undefined }, r.as_bot ? undefined : tokens.cookie);
|
|
3691
|
+
if (!got.msg) state = "gone";
|
|
3640
3692
|
else {
|
|
3641
3693
|
const p = askParseMessage(typeof got.msg.text === "string" ? got.msg.text : "");
|
|
3642
3694
|
state = p.kind === "resolved" ? "answered" : p.kind === "other" ? "edited" : p.kind;
|
|
@@ -3674,6 +3726,150 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
|
|
|
3674
3726
|
}
|
|
3675
3727
|
}
|
|
3676
3728
|
|
|
3729
|
+
/** One answered-but-uncollected ask, as `--pending` reports it. */
|
|
3730
|
+
interface AskPendingItem {
|
|
3731
|
+
row: SentRow;
|
|
3732
|
+
link: string;
|
|
3733
|
+
question: string;
|
|
3734
|
+
answer: string;
|
|
3735
|
+
how: string;
|
|
3736
|
+
/** A free-text reply that picked none of the choices — not a decision. */
|
|
3737
|
+
freeText: boolean;
|
|
3738
|
+
alive: boolean;
|
|
3739
|
+
/** The ready-to-run `--waitFor` line, cursor included. */
|
|
3740
|
+
collect: string;
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
/** `slack ask --pending [--since 7d] [--json]` — questions THIS machine asked
|
|
3744
|
+
* that somebody answered and nobody collected, grouped by the asking session,
|
|
3745
|
+
* with whether that agent is still alive.
|
|
3746
|
+
*
|
|
3747
|
+
* Why it exists: of 71 asks over 8 days, 13 had a pill pressed that nobody
|
|
3748
|
+
* ever read back (2026-09-27). The answer was sitting in Slack; the agent that
|
|
3749
|
+
* asked had moved on or died, and nothing connected the two.
|
|
3750
|
+
*
|
|
3751
|
+
* Read-only, against Slack AND the local log: each open question gets one look
|
|
3752
|
+
* through the same poller `--wait` uses, with its one write disabled. It only
|
|
3753
|
+
* REPORTS — collecting is `slack ask --waitFor=<link>`, printed per item,
|
|
3754
|
+
* which is also what stamps ✅ and takes the ask off this list. Nothing is
|
|
3755
|
+
* relayed into another process: whoever reads the list (the asking agent, or
|
|
3756
|
+
* a person) runs the collect line. */
|
|
3757
|
+
async function cmdAskPending(
|
|
3758
|
+
tokens: { user?: string; bot?: string; cookie?: string },
|
|
3759
|
+
opts: { sinceMs: number; json: boolean; channel?: string },
|
|
3760
|
+
): Promise<void> {
|
|
3761
|
+
const all = uncollectedAsks(opts.sinceMs);
|
|
3762
|
+
if (!all) {
|
|
3763
|
+
console.error(`Error: ${SENT_LOG_UNAVAILABLE}\n (${sentDbPath()})`);
|
|
3764
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3765
|
+
}
|
|
3766
|
+
const rows = opts.channel ? all.filter((r) => r.channel === opts.channel || r.target === opts.channel) : all;
|
|
3767
|
+
const pending: AskPendingItem[] = [];
|
|
3768
|
+
const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0 };
|
|
3769
|
+
const problems: string[] = [];
|
|
3770
|
+
// The log is machine-wide, but a token reads one workspace. Asks recorded
|
|
3771
|
+
// under another workspace are counted and named, not probed with the wrong
|
|
3772
|
+
// token (which would only fail as channel_not_found).
|
|
3773
|
+
const currentTeam = tokens.user
|
|
3774
|
+
? (await selfIdentity(tokens.user, tokens.cookie))?.team ?? ""
|
|
3775
|
+
: tokens.bot ? (await selfIdentity(tokens.bot))?.team ?? "" : "";
|
|
3776
|
+
const otherTeams = new Map<string, number>();
|
|
3777
|
+
|
|
3778
|
+
for (const row of rows) {
|
|
3779
|
+
if (row.team && currentTeam && row.team !== currentTeam) {
|
|
3780
|
+
otherTeams.set(row.team, (otherTeams.get(row.team) ?? 0) + 1);
|
|
3781
|
+
continue;
|
|
3782
|
+
}
|
|
3783
|
+
const link = row.permalink || `${row.channel}:${row.ts}`;
|
|
3784
|
+
const asBot = !!row.as_bot;
|
|
3785
|
+
const token = asBot ? tokens.bot : tokens.user;
|
|
3786
|
+
if (!token) {
|
|
3787
|
+
problems.push(`${link}: ${asBot ? "--as-bot で聞いた質問ですが bot token がありません" : "user token がありません"}`);
|
|
3788
|
+
continue;
|
|
3789
|
+
}
|
|
3790
|
+
const cookie = asBot ? undefined : tokens.cookie;
|
|
3791
|
+
// Resume from where the last delivery to a caller ended (an exit 5
|
|
3792
|
+
// free-text reply), so that reply is not reported again — and the collect
|
|
3793
|
+
// line carries the same cursor, so `--waitFor` does not re-deliver it.
|
|
3794
|
+
const after = row.delivered_cursor ?? undefined;
|
|
3795
|
+
try {
|
|
3796
|
+
const loc = { channelId: row.channel, ts: row.ts, threadTs: row.thread_ts ?? undefined };
|
|
3797
|
+
const got = await askFetchAt(token, loc, cookie);
|
|
3798
|
+
if (!got.msg) { problems.push(`${link}: メッセージが見つかりません (削除?)`); continue; }
|
|
3799
|
+
const q = await askReadBack(token, { channelId: row.channel, ts: row.ts, msg: got.msg }, cookie);
|
|
3800
|
+
const parsed = q.parsed;
|
|
3801
|
+
// ✅ already stamped: some `--wait`/`--waitFor` collected it (another
|
|
3802
|
+
// machine, another token, or a build before collection was logged).
|
|
3803
|
+
if (parsed.kind === "resolved") { count.resolved++; continue; }
|
|
3804
|
+
if (parsed.kind === "void") { count.void++; continue; }
|
|
3805
|
+
if (parsed.kind === "other") { problems.push(`${link}: \`slack ask\` の質問として読めません — ${askExplainReject(q.text)}`); continue; }
|
|
3806
|
+
if (!q.audience.size && !q.broadcast) { problems.push(`${link}: 誰にも宛てられていない質問です`); continue; }
|
|
3807
|
+
const ctx = askWaitCtxFrom(q, parsed, loc, { timeout: 0, link, asBot, json: false, cookie, after });
|
|
3808
|
+
const poller = askPoller(token, ctx, true);
|
|
3809
|
+
const found = await poller.poll();
|
|
3810
|
+
const unchosen = poller.state.unchosen;
|
|
3811
|
+
if (poller.state.voided) { count.void++; continue; }
|
|
3812
|
+
const collect = askResumeCommand(link, asBot, after);
|
|
3813
|
+
const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
|
|
3814
|
+
if (found) {
|
|
3815
|
+
pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
|
|
3816
|
+
} else if (unchosen && !unchosen.ambiguous && unchosen.text !== row.delivered_answer) {
|
|
3817
|
+
pending.push({ ...base, answer: unchosen.text, how: "返信 (選択肢外)", freeText: true });
|
|
3818
|
+
} else if (unchosen?.ambiguous) {
|
|
3819
|
+
count.ambiguous++;
|
|
3820
|
+
} else {
|
|
3821
|
+
count.unanswered++;
|
|
3822
|
+
}
|
|
3823
|
+
} catch (e: unknown) {
|
|
3824
|
+
problems.push(`${link}: ${e instanceof Error ? e.message : String(e)}`);
|
|
3825
|
+
}
|
|
3826
|
+
}
|
|
3827
|
+
|
|
3828
|
+
if (opts.json) {
|
|
3829
|
+
for (const it of pending) {
|
|
3830
|
+
const r = it.row;
|
|
3831
|
+
console.log(JSON.stringify({
|
|
3832
|
+
id: r.id, link: it.link, channel: r.channel, ts: r.ts, asked_at: r.sent_at,
|
|
3833
|
+
question: it.question, answer: it.answer, how: it.how, free_text: it.freeText,
|
|
3834
|
+
sender: { session_id: r.session_id ?? null, cli: r.cli ?? null, agent_pid: r.agent_pid ?? null, pid: r.pid, alive: it.alive, cwd: r.cwd, git_branch: r.git_branch ?? null, host: r.host ?? null },
|
|
3835
|
+
delivered_at: r.delivered_at ?? null,
|
|
3836
|
+
collect: it.collect,
|
|
3837
|
+
}));
|
|
3838
|
+
}
|
|
3839
|
+
} else {
|
|
3840
|
+
// Grouped by the session that asked — the unit that has to be told.
|
|
3841
|
+
const groups = new Map<string, AskPendingItem[]>();
|
|
3842
|
+
for (const it of pending) {
|
|
3843
|
+
const key = it.row.session_id ? `session ${it.row.session_id}` : `pid ${it.row.agent_pid ?? it.row.pid}`;
|
|
3844
|
+
groups.set(key, [...(groups.get(key) ?? []), it]);
|
|
3845
|
+
}
|
|
3846
|
+
for (const [key, items] of groups) {
|
|
3847
|
+
const r = items[0]!.row;
|
|
3848
|
+
console.log(
|
|
3849
|
+
`${stripTerminalControls(key)} ${stripTerminalControls(r.cli ?? "?")} pid ${r.agent_pid ?? r.pid} ${items[0]!.alive ? "(alive)" : "(gone)"}` +
|
|
3850
|
+
`${r.git_branch ? ` branch=${stripTerminalControls(r.git_branch)}` : ""} cwd=${stripTerminalControls(r.cwd)}`,
|
|
3851
|
+
);
|
|
3852
|
+
for (const it of items) {
|
|
3853
|
+
console.log(` ${stripTerminalControls(it.link)} (${formatYmdHm(it.row.sent_at / 1000)})`);
|
|
3854
|
+
console.log(` Q: ${stripTerminalControls(askFlatten(it.question))}`);
|
|
3855
|
+
console.log(` A: ${stripTerminalControls(askFlatten(it.answer))} — ${stripTerminalControls(it.how)}${it.freeText ? " — 決定ではありません" : ""}`);
|
|
3856
|
+
console.log(` collect: ${stripTerminalControls(it.collect)}`);
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
}
|
|
3860
|
+
for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
|
|
3861
|
+
for (const [team, n] of otherTeams) {
|
|
3862
|
+
console.error(` (${n} 件は別のワークスペース「${stripTerminalControls(team)}」の質問です — slack -w <name> ask --pending で確認)`);
|
|
3863
|
+
}
|
|
3864
|
+
console.error(
|
|
3865
|
+
`${pending.length} 件が回答済み・未回収, ${count.unanswered} 件が未回答` +
|
|
3866
|
+
(count.ambiguous ? `, ${count.ambiguous} 件は返信が複数の選択肢に一致 (未確定)` : "") +
|
|
3867
|
+
(count.resolved ? `, ${count.resolved} 件は ✅ 回収済み` : "") +
|
|
3868
|
+
(count.void ? `, ${count.void} 件は無効化済み` : "") +
|
|
3869
|
+
` — ${rows.length} 件の未回収の質問を確認 (log: ${sentDbPath()})`,
|
|
3870
|
+
);
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3677
3873
|
/** A question about to be voided: where it is, and what it parsed as. */
|
|
3678
3874
|
interface AskVoidTarget {
|
|
3679
3875
|
link: string;
|
|
@@ -3858,23 +4054,14 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3858
4054
|
ts = m[2]!;
|
|
3859
4055
|
}
|
|
3860
4056
|
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
let msg: Record<string, Json> | undefined;
|
|
3864
|
-
if (threadTs) {
|
|
3865
|
-
const rep = asRecord((await replies(token, channelId, threadTs, 100, args.cookie)) as Json);
|
|
3866
|
-
msg = asArray(rep.messages).map(asRecord).find((m) => m.ts === ts);
|
|
3867
|
-
} else {
|
|
3868
|
-
const hist = asRecord((await history(token, channelId, 1, ts, undefined, args.cookie, true)) as Json);
|
|
3869
|
-
msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
|
|
3870
|
-
}
|
|
3871
|
-
if (!msg) {
|
|
4057
|
+
const got = await askFetchAt(token, { channelId, ts, threadTs }, args.cookie);
|
|
4058
|
+
if (!got.msg) {
|
|
3872
4059
|
console.error(`Error: そのメッセージが見つかりません: ${stripTerminalControls(args.link)}`);
|
|
3873
4060
|
process.exit(ASK_EXIT_ERROR);
|
|
3874
4061
|
}
|
|
3875
|
-
|
|
3876
|
-
const
|
|
3877
|
-
const parsed =
|
|
4062
|
+
const msg = got.msg;
|
|
4063
|
+
const q = await askReadBack(token, { channelId, ts, msg }, args.cookie);
|
|
4064
|
+
const { text, parsed, audience, broadcast, askerUserId, askerBotId } = q;
|
|
3878
4065
|
if (parsed.kind === "other") {
|
|
3879
4066
|
console.error(
|
|
3880
4067
|
`Error: これは \`slack ask\` の質問として読み取れません。\n` +
|
|
@@ -3887,23 +4074,6 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3887
4074
|
process.exit(ASK_EXIT_ERROR);
|
|
3888
4075
|
}
|
|
3889
4076
|
|
|
3890
|
-
// Who may answer, recovered from the tags the readers can see. They are
|
|
3891
|
-
// already `<@U…>`-encoded in the stored text, so no directory lookup is
|
|
3892
|
-
// needed — and the asker is excluded here for the same reason as when
|
|
3893
|
-
// posting: their tag of themselves grants nothing, and their reactions are
|
|
3894
|
-
// the seeds.
|
|
3895
|
-
const askerUserId = typeof msg.user === "string" ? msg.user : "";
|
|
3896
|
-
const askerBotId = typeof msg.bot_id === "string" ? msg.bot_id : "";
|
|
3897
|
-
const broadcast = /<!(here|channel|everyone)(\^[^>]*)?(\|[^>]*)?>/.test(text);
|
|
3898
|
-
const audience = new Set<string>();
|
|
3899
|
-
for (const m of text.matchAll(/<@([UW][A-Z0-9]+)>/g)) {
|
|
3900
|
-
if (m[1] !== askerUserId) audience.add(m[1]!);
|
|
3901
|
-
}
|
|
3902
|
-
if (!audience.size && !broadcast) {
|
|
3903
|
-
// A 1:1 DM needs no tag — the other party is the only person it could be.
|
|
3904
|
-
const counterpart = await imCounterpart(token, channelId, args.cookie);
|
|
3905
|
-
if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
|
|
3906
|
-
}
|
|
3907
4077
|
if (parsed.kind === "void") {
|
|
3908
4078
|
askReportVoid(parsed, args.link);
|
|
3909
4079
|
if (args.json) {
|
|
@@ -3955,10 +4125,11 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3955
4125
|
...(args.cookie ? { cookie: args.cookie } : {}),
|
|
3956
4126
|
}, after, isAnswerReply);
|
|
3957
4127
|
}
|
|
4128
|
+
const cursor = askNextCursor(ts, after, ...notes.map((n) => n.ts));
|
|
3958
4129
|
await askEmit(token, { json: args.json, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
|
|
3959
|
-
status: "answered", answer: parsed.answer, notes,
|
|
3960
|
-
cursor: askNextCursor(ts, after, ...notes.map((n) => n.ts)),
|
|
4130
|
+
status: "answered", answer: parsed.answer, notes, cursor,
|
|
3961
4131
|
});
|
|
4132
|
+
markAskCollected(channelId, ts, parsed.answer, cursor);
|
|
3962
4133
|
return;
|
|
3963
4134
|
}
|
|
3964
4135
|
|
|
@@ -3970,27 +4141,72 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3970
4141
|
if (args.timeout > 0) {
|
|
3971
4142
|
console.error(` 回答を待っています (最大 ${args.timeout}s)… Ctrl-C で中断`);
|
|
3972
4143
|
}
|
|
4144
|
+
await askWaitForAnswer(token, askWaitCtxFrom(q, parsed, { channelId, ts, threadTs }, args));
|
|
4145
|
+
}
|
|
4146
|
+
|
|
4147
|
+
/** A posted question read back: what it parsed as, and who may answer it. */
|
|
4148
|
+
interface AskReadBack {
|
|
4149
|
+
text: string;
|
|
4150
|
+
parsed: ReturnType<typeof askParseMessage>;
|
|
4151
|
+
audience: Set<string>;
|
|
4152
|
+
broadcast: boolean;
|
|
4153
|
+
askerUserId: string;
|
|
4154
|
+
askerBotId: string;
|
|
4155
|
+
}
|
|
4156
|
+
|
|
4157
|
+
/** Recover who may answer from the message alone. Shared by `--waitFor` and
|
|
4158
|
+
* `--pending` — nothing about a question is stored, so both read it back. */
|
|
4159
|
+
async function askReadBack(token: string, t: { channelId: string; ts: string; msg: Record<string, Json> }, cookie?: string): Promise<AskReadBack> {
|
|
4160
|
+
const { msg } = t;
|
|
4161
|
+
const text = typeof msg.text === "string" ? msg.text : "";
|
|
4162
|
+
const parsed = askParseMessage(text);
|
|
4163
|
+
// Who may answer, recovered from the tags the readers can see. They are
|
|
4164
|
+
// already `<@U…>`-encoded in the stored text, so no directory lookup is
|
|
4165
|
+
// needed — and the asker is excluded here for the same reason as when
|
|
4166
|
+
// posting: their tag of themselves grants nothing, and their reactions are
|
|
4167
|
+
// the seeds.
|
|
4168
|
+
const askerUserId = typeof msg.user === "string" ? msg.user : "";
|
|
4169
|
+
const askerBotId = typeof msg.bot_id === "string" ? msg.bot_id : "";
|
|
4170
|
+
const broadcast = /<!(here|channel|everyone)(\^[^>]*)?(\|[^>]*)?>/.test(text);
|
|
4171
|
+
const audience = new Set<string>();
|
|
4172
|
+
for (const m of text.matchAll(/<@([UW][A-Z0-9]+)>/g)) {
|
|
4173
|
+
if (m[1] !== askerUserId) audience.add(m[1]!);
|
|
4174
|
+
}
|
|
4175
|
+
if (parsed.kind !== "other" && !audience.size && !broadcast) {
|
|
4176
|
+
// A 1:1 DM needs no tag — the other party is the only person it could be.
|
|
4177
|
+
const counterpart = await imCounterpart(token, t.channelId, cookie);
|
|
4178
|
+
if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
|
|
4179
|
+
}
|
|
4180
|
+
return { text, parsed, audience, broadcast, askerUserId, askerBotId };
|
|
4181
|
+
}
|
|
4182
|
+
|
|
4183
|
+
function askWaitCtxFrom(
|
|
4184
|
+
q: AskReadBack,
|
|
4185
|
+
parsed: Extract<AskReadBack["parsed"], { kind: "open" }>,
|
|
4186
|
+
loc: { channelId: string; ts: string; threadTs?: string | undefined },
|
|
4187
|
+
o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined },
|
|
4188
|
+
): AskWaitCtx {
|
|
3973
4189
|
const ctx: AskWaitCtx = {
|
|
3974
|
-
channelId,
|
|
3975
|
-
ts,
|
|
4190
|
+
channelId: loc.channelId,
|
|
4191
|
+
ts: loc.ts,
|
|
3976
4192
|
question: parsed.question,
|
|
3977
4193
|
reactable: parsed.reactable,
|
|
3978
4194
|
overflow: parsed.overflow,
|
|
3979
4195
|
threadOnly: parsed.threadOnly,
|
|
3980
4196
|
lang: parsed.lang,
|
|
3981
|
-
audience,
|
|
3982
|
-
broadcast,
|
|
3983
|
-
askerUserId,
|
|
3984
|
-
askerBotId,
|
|
3985
|
-
timeout:
|
|
3986
|
-
shown:
|
|
3987
|
-
asBot:
|
|
3988
|
-
json:
|
|
4197
|
+
audience: q.audience,
|
|
4198
|
+
broadcast: q.broadcast,
|
|
4199
|
+
askerUserId: q.askerUserId,
|
|
4200
|
+
askerBotId: q.askerBotId,
|
|
4201
|
+
timeout: o.timeout,
|
|
4202
|
+
shown: o.link,
|
|
4203
|
+
asBot: o.asBot,
|
|
4204
|
+
json: o.json,
|
|
3989
4205
|
};
|
|
3990
|
-
if (threadTs) ctx.threadParentTs = threadTs;
|
|
3991
|
-
if (
|
|
3992
|
-
if (
|
|
3993
|
-
|
|
4206
|
+
if (loc.threadTs) ctx.threadParentTs = loc.threadTs;
|
|
4207
|
+
if (o.cookie) ctx.cookie = o.cookie;
|
|
4208
|
+
if (o.after) ctx.after = o.after;
|
|
4209
|
+
return ctx;
|
|
3994
4210
|
}
|
|
3995
4211
|
|
|
3996
4212
|
// --- poll ---
|
|
@@ -5536,13 +5752,15 @@ async function main(): Promise<void> {
|
|
|
5536
5752
|
.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." })
|
|
5537
5753
|
.option("edit", { type: "string", describe: "Change an open question you posted: --edit=<permalink> [\"new question\" [choices…]] [--body …]. Rebuilt with the ask layout so collect still reads it. Options can change only while nobody has answered (then: --void=<link> --reask). Same --code gate." })
|
|
5538
5754
|
.option("ls", { type: "boolean", default: false, describe: "List the questions you posted (local sent-log) with their LIVE state: --state open|answered|void|all (default open), --stale 24h, --channel, --json. With --stale it prints the --void command that would retire them." })
|
|
5755
|
+
.option("pending", { type: "boolean", default: false, describe: "List questions THIS machine asked (local sent-log) that somebody answered but nobody collected, grouped by the asking session, with whether that agent is still alive and the --waitFor line that collects each. Read-only: --since 7d, --channel, --json." })
|
|
5756
|
+
.option("since", { type: "string", default: "7d", describe: "With --pending: how far back to look (30m, 2h, 7d, or an ISO date)" })
|
|
5539
5757
|
.option("stale", { type: "string", describe: "With --ls: only open questions older than this (24h, 3d)" })
|
|
5540
5758
|
.option("state", { type: "string", default: "open", choices: ["open", "answered", "void", "edited", "gone", "unknown", "all"], describe: "With --ls: which state to list" })
|
|
5541
|
-
.option("channel", { type: "string", describe: "With --ls: only this channel (id, or the target as typed)" })
|
|
5759
|
+
.option("channel", { type: "string", describe: "With --ls / --pending: only this channel (id, or the target as typed)" })
|
|
5542
5760
|
.option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
|
|
5543
5761
|
.option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
|
|
5544
5762
|
.option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
|
|
5545
|
-
.option("json", { type: "boolean", default: false, describe: "With --wait / --waitFor: print ONE JSON object on stdout — {status, answer, how, who, notes:[{ts,user,text,permalink}], cursor, permalink} — instead of the bare answer. `notes` are the audience's replies that are not the answer (thread replies; plain replies too in a 1:1 DM). Exit codes unchanged." })
|
|
5763
|
+
.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." })
|
|
5546
5764
|
.option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
|
|
5547
5765
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
5548
5766
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
@@ -5554,7 +5772,11 @@ async function main(): Promise<void> {
|
|
|
5554
5772
|
// slack ask collect <link> = --waitFor
|
|
5555
5773
|
// Lifecycle flags (maintainer: "maybe slack ask --edit=<target>"), primary;
|
|
5556
5774
|
// the bare verbs `void` / `collect` stay as aliases.
|
|
5557
|
-
if (argv.ls) {
|
|
5775
|
+
if (argv.ls || argv.pending) {
|
|
5776
|
+
if (argv.ls && argv.pending) {
|
|
5777
|
+
console.error("Error: --ls and --pending are separate lists — pick one.");
|
|
5778
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5779
|
+
}
|
|
5558
5780
|
const user = (() => { try { return tok(argv as W); } catch { return undefined; } })();
|
|
5559
5781
|
const tokens: { user?: string; bot?: string; cookie?: string } = {};
|
|
5560
5782
|
if (user) tokens.user = user;
|
|
@@ -5562,6 +5784,26 @@ async function main(): Promise<void> {
|
|
|
5562
5784
|
if (bot) tokens.bot = bot;
|
|
5563
5785
|
const c = ck(argv as W);
|
|
5564
5786
|
if (c) tokens.cookie = c;
|
|
5787
|
+
if (argv.pending) {
|
|
5788
|
+
if (argv.target || argv.question || argv.waitFor || argv.wait || argv.void || argv.edit) {
|
|
5789
|
+
console.error("Error: --pending reads the local log — do not also pass a target/question or another ask action.");
|
|
5790
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5791
|
+
}
|
|
5792
|
+
const sinceMs = parseSince(String(argv.since));
|
|
5793
|
+
if (sinceMs === undefined) {
|
|
5794
|
+
console.error(`Error: --since needs 30m / 2h / 7d / 1w or an ISO date (got ${stripTerminalControls(String(argv.since))})`);
|
|
5795
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5796
|
+
}
|
|
5797
|
+
const p: { sinceMs: number; json: boolean; channel?: string } = { sinceMs, json: !!argv.json };
|
|
5798
|
+
if (argv.channel) p.channel = String(argv.channel);
|
|
5799
|
+
try {
|
|
5800
|
+
await cmdAskPending(tokens, p);
|
|
5801
|
+
} catch (e: unknown) {
|
|
5802
|
+
console.error(friendlySlackError(e));
|
|
5803
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5804
|
+
}
|
|
5805
|
+
return;
|
|
5806
|
+
}
|
|
5565
5807
|
const a: { stale?: string; state: string; channel?: string; json: boolean; limit: number } = {
|
|
5566
5808
|
state: String(argv.state ?? "open"), json: !!argv.json, limit: Number(argv.limit) || 50,
|
|
5567
5809
|
};
|
|
@@ -6987,6 +7229,153 @@ async function main(): Promise<void> {
|
|
|
6987
7229
|
)
|
|
6988
7230
|
.command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
|
|
6989
7231
|
)
|
|
7232
|
+
.command(
|
|
7233
|
+
"stream",
|
|
7234
|
+
"Print every new message matching --grep, across all channels the identity is in (threads included); resumable",
|
|
7235
|
+
(y) => y
|
|
7236
|
+
.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" })
|
|
7237
|
+
.option("ignore-case", { alias: "i", type: "boolean", default: false, describe: "Case-insensitive --grep" })
|
|
7238
|
+
.option("channel", { type: "array", string: true, describe: "Only these channels (#name, id or permalink); repeatable" })
|
|
7239
|
+
.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" })
|
|
7240
|
+
.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)" })
|
|
7241
|
+
.option("once", { type: "boolean", default: false, describe: "Scan once from the saved cursor (or --since) and exit: 0 = matches printed, 2 = none, 3 = failure" })
|
|
7242
|
+
.option("since", { type: "string", describe: "Replay from this long ago (e.g. 30m, 2h), ignoring the saved cursor" })
|
|
7243
|
+
.option("interval", { type: "string", default: "45s", describe: "Poll interval (e.g. 30s, 2m)" })
|
|
7244
|
+
.option("thread-window", { type: "string", default: "3d", describe: "Watch replies in threads whose parent is at most this old" })
|
|
7245
|
+
.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)" })
|
|
7246
|
+
.option("calls-per-min", { type: "number", default: 40, describe: "Polling: pace conversations.history/replies to this many calls per minute (0 = unpaced)" })
|
|
7247
|
+
.option("state", { type: "string", describe: "Cursor file (default: one per identity+grep+channels under $XDG_STATE_HOME/slack-term/stream/)" })
|
|
7248
|
+
.option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
|
|
7249
|
+
.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" })
|
|
7250
|
+
.option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" }),
|
|
7251
|
+
async (argv) => {
|
|
7252
|
+
let re: RegExp;
|
|
7253
|
+
let reconcileMs: number;
|
|
7254
|
+
let intervalMs: number;
|
|
7255
|
+
let windowSec: number;
|
|
7256
|
+
let threadScanSec: number;
|
|
7257
|
+
let sinceSec: number | undefined;
|
|
7258
|
+
try {
|
|
7259
|
+
re = new RegExp(argv.grep, argv["ignore-case"] ? "i" : "");
|
|
7260
|
+
intervalMs = parseDuration(argv.interval) * 1000;
|
|
7261
|
+
windowSec = parseDuration(argv["thread-window"]);
|
|
7262
|
+
threadScanSec = parseDuration(argv["thread-scan"]);
|
|
7263
|
+
sinceSec = argv.since !== undefined ? parseDuration(argv.since) : undefined;
|
|
7264
|
+
reconcileMs = parseDuration(argv.reconcile) * 1000;
|
|
7265
|
+
} catch (e) {
|
|
7266
|
+
console.error(`slack stream: ${e instanceof Error ? e.message : String(e)}`);
|
|
7267
|
+
process.exit(1);
|
|
7268
|
+
}
|
|
7269
|
+
// Privacy-safe default: the bot sees only the channels it was invited
|
|
7270
|
+
// to, never the user's DMs. The user identity is an explicit opt-in.
|
|
7271
|
+
const bot = resolveBotToken();
|
|
7272
|
+
const asUser = argv["as-user"] === true || !bot;
|
|
7273
|
+
const token = asUser ? tok(argv as W) : bot!;
|
|
7274
|
+
const cookie = asUser ? ck(argv as W) : undefined;
|
|
7275
|
+
if (!bot && argv["as-user"] !== true) {
|
|
7276
|
+
console.error("slack stream: no bot token (SLACK_BOT_TOKEN) — streaming as the USER identity, which includes its DMs.");
|
|
7277
|
+
}
|
|
7278
|
+
let me: Awaited<ReturnType<typeof authScopes>>;
|
|
7279
|
+
try {
|
|
7280
|
+
me = await authScopes(token, cookie);
|
|
7281
|
+
} catch (e) {
|
|
7282
|
+
console.error(`slack stream: fatal: ${e instanceof Error ? e.message : String(e)}`);
|
|
7283
|
+
process.exit(3);
|
|
7284
|
+
}
|
|
7285
|
+
// Self-echo exclusion is by sender id: the identity's own user id, its
|
|
7286
|
+
// bot id, and — when streaming as the user — the bot's posts too.
|
|
7287
|
+
const selfUsers = new Set([me.userId].filter(Boolean));
|
|
7288
|
+
const selfBots = new Set([me.botId].filter(Boolean));
|
|
7289
|
+
if (asUser && bot) {
|
|
7290
|
+
try {
|
|
7291
|
+
const b = await authScopes(bot);
|
|
7292
|
+
if (b.userId) selfUsers.add(b.userId);
|
|
7293
|
+
if (b.botId) selfBots.add(b.botId);
|
|
7294
|
+
} catch { /* a dead bot token posts nothing to exclude */ }
|
|
7295
|
+
}
|
|
7296
|
+
// Slack names a bot in a thread either way: `parent_user_id` holds its
|
|
7297
|
+
// bot USER id (auth.test user_id), `reply_users` sometimes its B… bot
|
|
7298
|
+
// id — so `self` stands for both.
|
|
7299
|
+
let repliesTo: Set<string> | undefined;
|
|
7300
|
+
if (argv["replies-to"]?.length) {
|
|
7301
|
+
repliesTo = new Set();
|
|
7302
|
+
for (const r of argv["replies-to"].map(String)) {
|
|
7303
|
+
if (r === "self") {
|
|
7304
|
+
for (const id of [me.userId, me.botId]) if (id) repliesTo.add(id);
|
|
7305
|
+
continue;
|
|
7306
|
+
}
|
|
7307
|
+
if (!/^[UWB][A-Z0-9]{2,}$/.test(r)) {
|
|
7308
|
+
console.error(`slack stream: --replies-to: "${r}" is not a user or bot id (U…/W…/B…) or "self"`);
|
|
7309
|
+
process.exit(1);
|
|
7310
|
+
}
|
|
7311
|
+
repliesTo.add(r);
|
|
7312
|
+
// A bot can be listed under its user id too: add that alias.
|
|
7313
|
+
if (r.startsWith("B")) {
|
|
7314
|
+
try {
|
|
7315
|
+
const { userId } = await botsInfo(token, r, cookie);
|
|
7316
|
+
if (userId) repliesTo.add(userId);
|
|
7317
|
+
} catch (e) {
|
|
7318
|
+
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`);
|
|
7319
|
+
}
|
|
7320
|
+
}
|
|
7321
|
+
}
|
|
7322
|
+
}
|
|
7323
|
+
let channels: string[] | undefined;
|
|
7324
|
+
if (argv.channel?.length) {
|
|
7325
|
+
try {
|
|
7326
|
+
channels = [];
|
|
7327
|
+
for (const c of argv.channel) channels.push(await resolveChannel(token, String(c), cookie));
|
|
7328
|
+
} catch (e) {
|
|
7329
|
+
console.error(`slack stream: --channel: ${e instanceof Error ? e.message : String(e)}`);
|
|
7330
|
+
process.exit(1);
|
|
7331
|
+
}
|
|
7332
|
+
}
|
|
7333
|
+
const key = createHash("sha256")
|
|
7334
|
+
.update(JSON.stringify([me.url, me.userId, re.source, re.flags, [...(channels ?? [])].sort(),
|
|
7335
|
+
// As given, not as resolved: a failed bots.info lookup must not
|
|
7336
|
+
// move the stream to a fresh state file.
|
|
7337
|
+
...(argv["replies-to"]?.length ? [[...argv["replies-to"].map(String)].sort()] : [])]))
|
|
7338
|
+
.digest("hex").slice(0, 16);
|
|
7339
|
+
const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
|
|
7340
|
+
const statePath = argv.state ?? join(stateHome, "slack-term", "stream", `${key}.json`);
|
|
7341
|
+
console.error(`slack stream: as ${asUser ? "user" : "bot"} @${me.user} (${me.userId}); state ${statePath}`);
|
|
7342
|
+
// The relay rings for what the Slack app's bot can see, so it only
|
|
7343
|
+
// stands in for polling when streaming as that bot.
|
|
7344
|
+
const relayUrl = process.env.SLACK_RELAY_URL;
|
|
7345
|
+
const relayToken = process.env.SLACK_RELAY_TOKEN;
|
|
7346
|
+
const relay = argv.relay && !asUser && !argv.once && relayUrl && relayToken
|
|
7347
|
+
? { url: relayUrl, subscribe: relaySubscriber(relayUrl, relayToken), reconcileMs }
|
|
7348
|
+
: undefined;
|
|
7349
|
+
if (argv.relay && asUser && relayUrl) console.error("slack stream: relay not used with --as-user (it only sees the bot's channels)");
|
|
7350
|
+
const pollReason = !argv.relay ? "--no-relay"
|
|
7351
|
+
: asUser ? (relayUrl ? "--as-user: the relay only rings for the bot's channels" : "streaming as the user; the relay serves the bot identity")
|
|
7352
|
+
: "SLACK_RELAY_URL / SLACK_RELAY_TOKEN not set";
|
|
7353
|
+
const ac = new AbortController();
|
|
7354
|
+
process.on("SIGINT", () => ac.abort());
|
|
7355
|
+
process.on("SIGTERM", () => ac.abort());
|
|
7356
|
+
const code = await runStream(webClient(token, cookie), {
|
|
7357
|
+
grep: re,
|
|
7358
|
+
...(repliesTo ? { repliesTo } : {}),
|
|
7359
|
+
...(channels ? { channels } : {}),
|
|
7360
|
+
selfUsers,
|
|
7361
|
+
selfBots,
|
|
7362
|
+
json: argv.json,
|
|
7363
|
+
once: argv.once,
|
|
7364
|
+
...(sinceSec !== undefined ? { sinceSec } : {}),
|
|
7365
|
+
intervalMs,
|
|
7366
|
+
threadWindowSec: windowSec,
|
|
7367
|
+
threadScanSec,
|
|
7368
|
+
callsPerMin: argv["calls-per-min"],
|
|
7369
|
+
...(relay ? {} : { pollReason }),
|
|
7370
|
+
statePath,
|
|
7371
|
+
teamUrl: me.url,
|
|
7372
|
+
identity: me.userId,
|
|
7373
|
+
signal: ac.signal,
|
|
7374
|
+
...(relay ? { relay } : {}),
|
|
7375
|
+
});
|
|
7376
|
+
process.exit(code);
|
|
7377
|
+
},
|
|
7378
|
+
)
|
|
6990
7379
|
.command(
|
|
6991
7380
|
"tail [target]",
|
|
6992
7381
|
"Stream new messages in real time (like tail -f)",
|