slack-term 1.41.2 → 1.42.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 +6 -0
- package/SKILL.md +9 -0
- package/dist/cli.js +212 -206
- package/package.json +1 -1
- package/ts/cli.ts +321 -82
- package/ts/sentlog.ts +106 -2
package/package.json
CHANGED
package/ts/cli.ts
CHANGED
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
} from "./poll.ts";
|
|
55
55
|
import { seedReactionsInOrder } from "./reactionSeed.ts";
|
|
56
56
|
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";
|
|
57
|
+
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
58
|
import {
|
|
59
59
|
authTest,
|
|
60
60
|
authScopes,
|
|
@@ -2740,12 +2740,33 @@ async function askEmit(
|
|
|
2740
2740
|
}
|
|
2741
2741
|
|
|
2742
2742
|
|
|
2743
|
-
/**
|
|
2744
|
-
*
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2743
|
+
/** What one look at a question found besides the answer — read back by the
|
|
2744
|
+
* `--wait` loop between polls, and by `ask --pending` after its single look. */
|
|
2745
|
+
interface AskPollState {
|
|
2746
|
+
/** The earliest audience reply that picked none of the choices. */
|
|
2747
|
+
unchosen?: { text: string; who: string; ts: string; ambiguous: boolean };
|
|
2748
|
+
/** Set when the question has been voided. */
|
|
2749
|
+
voided?: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }>;
|
|
2750
|
+
/** The question's `reply_count` as last seen — undefined until seen. */
|
|
2751
|
+
replyCount?: number;
|
|
2752
|
+
/** The question's text as last read — what the ✅ rewrite keeps the body of. */
|
|
2753
|
+
ownText?: string;
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
/** The polling half of `--wait`: one `poll()` is one look at the question,
|
|
2757
|
+
* shared with `ask --pending`. `readOnly` drops the one WRITE a look can make
|
|
2758
|
+
* (the invalid-ballot notice edited into the question) and the progress lines
|
|
2759
|
+
* on stderr — `--pending` inspects questions other sessions are responsible
|
|
2760
|
+
* for, and must neither touch them nor narrate their state as if waiting. */
|
|
2761
|
+
function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
2762
|
+
poll: () => Promise<AskFound | null>;
|
|
2763
|
+
state: AskPollState;
|
|
2764
|
+
isAnswerer: (user: unknown) => user is string;
|
|
2765
|
+
candidates: string[];
|
|
2766
|
+
} {
|
|
2767
|
+
const { channelId, ts, reactable, threadOnly, audience, broadcast, cookie } = ctx;
|
|
2748
2768
|
const copy = ASK_COPY[ctx.lang];
|
|
2769
|
+
const state: AskPollState = {};
|
|
2749
2770
|
const askerUserId = ctx.askerUserId;
|
|
2750
2771
|
const askerBotId = ctx.askerBotId;
|
|
2751
2772
|
|
|
@@ -2756,25 +2777,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2756
2777
|
const isAnswerer = (user: unknown): user is string =>
|
|
2757
2778
|
typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
|
|
2758
2779
|
|
|
2759
|
-
/** The question
|
|
2760
|
-
* fetch when collecting notes
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2780
|
+
/** The question as last seen: its `reply_count` (whether its thread is worth
|
|
2781
|
+
* a fetch when collecting notes) and its text (what the ✅ rewrite keeps). */
|
|
2782
|
+
const seen = (own: Record<string, Json>): void => {
|
|
2783
|
+
state.replyCount = Number(own.reply_count) || 0;
|
|
2784
|
+
if (typeof own.text === "string") state.ownText = own.text;
|
|
2785
|
+
};
|
|
2764
2786
|
/** Set when the question is voided while we wait — the wait ends there. */
|
|
2765
|
-
let voided: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }> | undefined;
|
|
2766
2787
|
const checkVoid = (own: Record<string, Json>): boolean => {
|
|
2767
2788
|
const p = typeof own.text === "string" ? askParseMessage(own.text) : undefined;
|
|
2768
|
-
if (p?.kind === "void") voided = p;
|
|
2769
|
-
return !!voided;
|
|
2789
|
+
if (p?.kind === "void") state.voided = p;
|
|
2790
|
+
return !!state.voided;
|
|
2770
2791
|
};
|
|
2771
2792
|
|
|
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
2793
|
/** Every seed an answerer is on — not just the first. Changing your mind
|
|
2779
2794
|
* leaves BOTH reactions in place (Slack only drops one when you actively
|
|
2780
2795
|
* un-react), so taking the lowest index would answer with the abandoned
|
|
@@ -2800,6 +2815,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2800
2815
|
* the API call when nothing changed, which is what makes this safe to run on
|
|
2801
2816
|
* every poll tick rather than once per process. */
|
|
2802
2817
|
async function noteInvalid(msg: Record<string, Json>, offenders: string[]): Promise<void> {
|
|
2818
|
+
if (readOnly) return;
|
|
2803
2819
|
const text = typeof msg.text === "string" ? msg.text : "";
|
|
2804
2820
|
if (!text) return;
|
|
2805
2821
|
const next = applyInvalidNotice(text, offenders, ctx.lang);
|
|
@@ -2822,7 +2838,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2822
2838
|
for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
|
|
2823
2839
|
if (otherReported.has(u)) continue;
|
|
2824
2840
|
otherReported.add(u);
|
|
2825
|
-
console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
|
|
2841
|
+
if (!readOnly) console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
|
|
2826
2842
|
}
|
|
2827
2843
|
}
|
|
2828
2844
|
|
|
@@ -2845,7 +2861,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2845
2861
|
const pillsPerUser = new Map<string, number>();
|
|
2846
2862
|
for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
|
|
2847
2863
|
const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
|
|
2848
|
-
console.error(multi.length
|
|
2864
|
+
if (!readOnly) console.error(multi.length
|
|
2849
2865
|
? ` (${glyphs} が同時に選ばれています。1 つに絞ってもらうまで待ちます)`
|
|
2850
2866
|
: ` (回答が分かれています: ${glyphs}。どちらか一方にそろうまで待ちます)`);
|
|
2851
2867
|
// Whoever is on more than one pill — and ONLY them. Naming everyone on any
|
|
@@ -2895,9 +2911,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2895
2911
|
// must not hide it behind the refuse-to-guess path. Within a class, the
|
|
2896
2912
|
// earliest wins.
|
|
2897
2913
|
const ambiguous = match.kind === "ambiguous";
|
|
2914
|
+
const unchosen = state.unchosen;
|
|
2898
2915
|
if (!unchosen || (unchosen.ambiguous && !ambiguous) ||
|
|
2899
2916
|
(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 };
|
|
2917
|
+
state.unchosen = { text: t, who: typeof m.user === "string" ? m.user : "", ts: String(m.ts), ambiguous };
|
|
2901
2918
|
}
|
|
2902
2919
|
}
|
|
2903
2920
|
return null;
|
|
@@ -2910,7 +2927,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2910
2927
|
// longer there, and exit 4 where exit 2 is the truth. `unchosenReported`
|
|
2911
2928
|
// persists on purpose — it is what keeps the operator from being told the
|
|
2912
2929
|
// same thing on every tick.
|
|
2913
|
-
unchosen
|
|
2930
|
+
delete state.unchosen;
|
|
2914
2931
|
// A question that is itself a thread reply is invisible to
|
|
2915
2932
|
// `conversations.history`, so the entire poll — reactions included — has to
|
|
2916
2933
|
// come from the thread.
|
|
@@ -2919,8 +2936,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2919
2936
|
const messages = asArray(rep.messages).map(asRecord);
|
|
2920
2937
|
const own = messages.find((m) => m.ts === ts);
|
|
2921
2938
|
if (own) {
|
|
2922
|
-
|
|
2923
|
-
if (typeof own.text === "string") ownText = own.text;
|
|
2939
|
+
seen(own);
|
|
2924
2940
|
if (checkVoid(own)) return null;
|
|
2925
2941
|
const byReaction = await answerFromReactions(own);
|
|
2926
2942
|
if (byReaction) return byReaction;
|
|
@@ -2936,8 +2952,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2936
2952
|
const own = messages.find((m) => m.ts === ts);
|
|
2937
2953
|
|
|
2938
2954
|
if (own) {
|
|
2939
|
-
|
|
2940
|
-
if (typeof own.text === "string") ownText = own.text;
|
|
2955
|
+
seen(own);
|
|
2941
2956
|
if (checkVoid(own)) return null;
|
|
2942
2957
|
}
|
|
2943
2958
|
// A reaction is the intended path, so it wins when both are present.
|
|
@@ -2970,6 +2985,21 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2970
2985
|
return null;
|
|
2971
2986
|
}
|
|
2972
2987
|
|
|
2988
|
+
return { poll, state, isAnswerer, candidates };
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
/** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
|
|
2992
|
+
* on timeout. A timeout of 0 means "look exactly once", so a caller can poll
|
|
2993
|
+
* cheaply on its own schedule instead of parking a process on `--wait`. */
|
|
2994
|
+
async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never> {
|
|
2995
|
+
const { channelId, ts, question, reactable, threadOnly, cookie, timeout, shown } = ctx;
|
|
2996
|
+
const askerUserId = ctx.askerUserId;
|
|
2997
|
+
const askerBotId = ctx.askerBotId;
|
|
2998
|
+
const { poll, state, isAnswerer, candidates } = askPoller(token, ctx);
|
|
2999
|
+
/** Kept across polls so the operator is told about one reply once, rather
|
|
3000
|
+
* than on every tick. */
|
|
3001
|
+
let unchosenReported = "";
|
|
3002
|
+
|
|
2973
3003
|
/** Stamp the question resolved. Removing our seeds clears the pills that still
|
|
2974
3004
|
* invite an answer; the answerer's own reaction cannot be removed by us and
|
|
2975
3005
|
* stays put — which is exactly what leaves the chosen number visible. */
|
|
@@ -2996,7 +3026,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2996
3026
|
// The body and the chosen option stay; only what no longer applies goes.
|
|
2997
3027
|
// Built from the message as it is NOW (an edit made in Slack after asking
|
|
2998
3028
|
// is kept). Not seen this run — the bare form, as before.
|
|
2999
|
-
const keep = ownText ? askResolvedKeep(ownText, found.choice) : undefined;
|
|
3029
|
+
const keep = state.ownText ? askResolvedKeep(state.ownText, found.choice) : undefined;
|
|
3000
3030
|
await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
|
|
3001
3031
|
// Swap the marker for the resolved one so search reflects reality:
|
|
3002
3032
|
// `has::question:` should list what still needs answering, not everything
|
|
@@ -3019,6 +3049,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3019
3049
|
const deadline = Date.now() + timeout * 1000;
|
|
3020
3050
|
let errors = 0;
|
|
3021
3051
|
let delay = ASK_POLL_MIN_MS;
|
|
3052
|
+
let unchosen: AskPollState["unchosen"];
|
|
3022
3053
|
for (;;) {
|
|
3023
3054
|
let found: AskFound | null = null;
|
|
3024
3055
|
try {
|
|
@@ -3033,6 +3064,8 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3033
3064
|
}
|
|
3034
3065
|
console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
|
|
3035
3066
|
}
|
|
3067
|
+
unchosen = state.unchosen;
|
|
3068
|
+
const voided = state.voided;
|
|
3036
3069
|
if (voided) {
|
|
3037
3070
|
askReportVoid(voided, shown);
|
|
3038
3071
|
if (ctx.json) {
|
|
@@ -3050,7 +3083,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3050
3083
|
// BEFORE ✅ is written, and delivered right after — ✅ is what tells a
|
|
3051
3084
|
// later --waitFor the question is settled.
|
|
3052
3085
|
const notes = await askCollectNotes(token, {
|
|
3053
|
-
channelId, ts, threadOnly, replyCount, isAnswerer, askerUserId, askerBotId,
|
|
3086
|
+
channelId, ts, threadOnly, replyCount: state.replyCount, isAnswerer, askerUserId, askerBotId,
|
|
3054
3087
|
...(ctx.threadParentTs ? { threadParentTs: ctx.threadParentTs } : {}),
|
|
3055
3088
|
...(cookie ? { cookie } : {}),
|
|
3056
3089
|
}, ctx.after, (m) => m.ts === found!.ts);
|
|
@@ -3059,6 +3092,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3059
3092
|
status: "answered", answer: found.answer, how: found.how, notes,
|
|
3060
3093
|
cursor: askNextCursor(ts, ctx.after, found.ts, ...notes.map((n) => n.ts)),
|
|
3061
3094
|
};
|
|
3095
|
+
// Collected: the local sent-log row (if this machine asked it) drops off
|
|
3096
|
+
// `ask --pending`. Only here, where the answer actually reaches a caller.
|
|
3097
|
+
markAskCollected(channelId, ts, found.answer, r.cursor);
|
|
3062
3098
|
if (found.who) r.who = found.who;
|
|
3063
3099
|
await askEmit(token, ctx, r);
|
|
3064
3100
|
process.exit(0);
|
|
@@ -3079,6 +3115,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3079
3115
|
console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
3080
3116
|
console.error(` ${shown}`);
|
|
3081
3117
|
console.error(` この返信の後を待つ: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
|
|
3118
|
+
// Delivered, not collected: `--pending` will not report THIS reply again,
|
|
3119
|
+
// but still reports a pill pressed after it.
|
|
3120
|
+
markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
|
|
3082
3121
|
if (ctx.json) {
|
|
3083
3122
|
await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
|
|
3084
3123
|
} else {
|
|
@@ -3434,6 +3473,16 @@ async function askFetchByLink(token: string, link: string, cookie?: string): Pro
|
|
|
3434
3473
|
channelId = m[1]!;
|
|
3435
3474
|
ts = m[2]!;
|
|
3436
3475
|
}
|
|
3476
|
+
return askFetchAt(token, { channelId, ts, threadTs }, cookie);
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
/** `askFetchByLink` for a location already known — e.g. a sent-log row, whose
|
|
3480
|
+
* `thread_ts` says where a question posted into a thread lives. */
|
|
3481
|
+
async function askFetchAt(token: string, loc: { channelId: string; ts: string; threadTs?: string | undefined }, cookie?: string): Promise<
|
|
3482
|
+
{ channelId: string; ts: string; threadTs?: string; msg?: Record<string, Json> }
|
|
3483
|
+
> {
|
|
3484
|
+
const { channelId, ts } = loc;
|
|
3485
|
+
const threadTs = loc.threadTs && loc.threadTs !== loc.ts ? loc.threadTs : undefined;
|
|
3437
3486
|
let msg: Record<string, Json> | undefined;
|
|
3438
3487
|
if (threadTs) {
|
|
3439
3488
|
const rep = asRecord((await replies(token, channelId, threadTs, 100, cookie)) as Json);
|
|
@@ -3635,8 +3684,8 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
|
|
|
3635
3684
|
let answer: string | undefined;
|
|
3636
3685
|
if (token) {
|
|
3637
3686
|
try {
|
|
3638
|
-
const got = await
|
|
3639
|
-
if (!got
|
|
3687
|
+
const got = await askFetchAt(token, { channelId: r.channel, ts: r.ts, threadTs: r.thread_ts ?? undefined }, r.as_bot ? undefined : tokens.cookie);
|
|
3688
|
+
if (!got.msg) state = "gone";
|
|
3640
3689
|
else {
|
|
3641
3690
|
const p = askParseMessage(typeof got.msg.text === "string" ? got.msg.text : "");
|
|
3642
3691
|
state = p.kind === "resolved" ? "answered" : p.kind === "other" ? "edited" : p.kind;
|
|
@@ -3674,6 +3723,150 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
|
|
|
3674
3723
|
}
|
|
3675
3724
|
}
|
|
3676
3725
|
|
|
3726
|
+
/** One answered-but-uncollected ask, as `--pending` reports it. */
|
|
3727
|
+
interface AskPendingItem {
|
|
3728
|
+
row: SentRow;
|
|
3729
|
+
link: string;
|
|
3730
|
+
question: string;
|
|
3731
|
+
answer: string;
|
|
3732
|
+
how: string;
|
|
3733
|
+
/** A free-text reply that picked none of the choices — not a decision. */
|
|
3734
|
+
freeText: boolean;
|
|
3735
|
+
alive: boolean;
|
|
3736
|
+
/** The ready-to-run `--waitFor` line, cursor included. */
|
|
3737
|
+
collect: string;
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
/** `slack ask --pending [--since 7d] [--json]` — questions THIS machine asked
|
|
3741
|
+
* that somebody answered and nobody collected, grouped by the asking session,
|
|
3742
|
+
* with whether that agent is still alive.
|
|
3743
|
+
*
|
|
3744
|
+
* Why it exists: of 71 asks over 8 days, 13 had a pill pressed that nobody
|
|
3745
|
+
* ever read back (2026-09-27). The answer was sitting in Slack; the agent that
|
|
3746
|
+
* asked had moved on or died, and nothing connected the two.
|
|
3747
|
+
*
|
|
3748
|
+
* Read-only, against Slack AND the local log: each open question gets one look
|
|
3749
|
+
* through the same poller `--wait` uses, with its one write disabled. It only
|
|
3750
|
+
* REPORTS — collecting is `slack ask --waitFor=<link>`, printed per item,
|
|
3751
|
+
* which is also what stamps ✅ and takes the ask off this list. Nothing is
|
|
3752
|
+
* relayed into another process: whoever reads the list (the asking agent, or
|
|
3753
|
+
* a person) runs the collect line. */
|
|
3754
|
+
async function cmdAskPending(
|
|
3755
|
+
tokens: { user?: string; bot?: string; cookie?: string },
|
|
3756
|
+
opts: { sinceMs: number; json: boolean; channel?: string },
|
|
3757
|
+
): Promise<void> {
|
|
3758
|
+
const all = uncollectedAsks(opts.sinceMs);
|
|
3759
|
+
if (!all) {
|
|
3760
|
+
console.error(`Error: ${SENT_LOG_UNAVAILABLE}\n (${sentDbPath()})`);
|
|
3761
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3762
|
+
}
|
|
3763
|
+
const rows = opts.channel ? all.filter((r) => r.channel === opts.channel || r.target === opts.channel) : all;
|
|
3764
|
+
const pending: AskPendingItem[] = [];
|
|
3765
|
+
const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0 };
|
|
3766
|
+
const problems: string[] = [];
|
|
3767
|
+
// The log is machine-wide, but a token reads one workspace. Asks recorded
|
|
3768
|
+
// under another workspace are counted and named, not probed with the wrong
|
|
3769
|
+
// token (which would only fail as channel_not_found).
|
|
3770
|
+
const currentTeam = tokens.user
|
|
3771
|
+
? (await selfIdentity(tokens.user, tokens.cookie))?.team ?? ""
|
|
3772
|
+
: tokens.bot ? (await selfIdentity(tokens.bot))?.team ?? "" : "";
|
|
3773
|
+
const otherTeams = new Map<string, number>();
|
|
3774
|
+
|
|
3775
|
+
for (const row of rows) {
|
|
3776
|
+
if (row.team && currentTeam && row.team !== currentTeam) {
|
|
3777
|
+
otherTeams.set(row.team, (otherTeams.get(row.team) ?? 0) + 1);
|
|
3778
|
+
continue;
|
|
3779
|
+
}
|
|
3780
|
+
const link = row.permalink || `${row.channel}:${row.ts}`;
|
|
3781
|
+
const asBot = !!row.as_bot;
|
|
3782
|
+
const token = asBot ? tokens.bot : tokens.user;
|
|
3783
|
+
if (!token) {
|
|
3784
|
+
problems.push(`${link}: ${asBot ? "--as-bot で聞いた質問ですが bot token がありません" : "user token がありません"}`);
|
|
3785
|
+
continue;
|
|
3786
|
+
}
|
|
3787
|
+
const cookie = asBot ? undefined : tokens.cookie;
|
|
3788
|
+
// Resume from where the last delivery to a caller ended (an exit 5
|
|
3789
|
+
// free-text reply), so that reply is not reported again — and the collect
|
|
3790
|
+
// line carries the same cursor, so `--waitFor` does not re-deliver it.
|
|
3791
|
+
const after = row.delivered_cursor ?? undefined;
|
|
3792
|
+
try {
|
|
3793
|
+
const loc = { channelId: row.channel, ts: row.ts, threadTs: row.thread_ts ?? undefined };
|
|
3794
|
+
const got = await askFetchAt(token, loc, cookie);
|
|
3795
|
+
if (!got.msg) { problems.push(`${link}: メッセージが見つかりません (削除?)`); continue; }
|
|
3796
|
+
const q = await askReadBack(token, { channelId: row.channel, ts: row.ts, msg: got.msg }, cookie);
|
|
3797
|
+
const parsed = q.parsed;
|
|
3798
|
+
// ✅ already stamped: some `--wait`/`--waitFor` collected it (another
|
|
3799
|
+
// machine, another token, or a build before collection was logged).
|
|
3800
|
+
if (parsed.kind === "resolved") { count.resolved++; continue; }
|
|
3801
|
+
if (parsed.kind === "void") { count.void++; continue; }
|
|
3802
|
+
if (parsed.kind === "other") { problems.push(`${link}: \`slack ask\` の質問として読めません — ${askExplainReject(q.text)}`); continue; }
|
|
3803
|
+
if (!q.audience.size && !q.broadcast) { problems.push(`${link}: 誰にも宛てられていない質問です`); continue; }
|
|
3804
|
+
const ctx = askWaitCtxFrom(q, parsed, loc, { timeout: 0, link, asBot, json: false, cookie, after });
|
|
3805
|
+
const poller = askPoller(token, ctx, true);
|
|
3806
|
+
const found = await poller.poll();
|
|
3807
|
+
const unchosen = poller.state.unchosen;
|
|
3808
|
+
if (poller.state.voided) { count.void++; continue; }
|
|
3809
|
+
const collect = askResumeCommand(link, asBot, after);
|
|
3810
|
+
const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
|
|
3811
|
+
if (found) {
|
|
3812
|
+
pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
|
|
3813
|
+
} else if (unchosen && !unchosen.ambiguous && unchosen.text !== row.delivered_answer) {
|
|
3814
|
+
pending.push({ ...base, answer: unchosen.text, how: "返信 (選択肢外)", freeText: true });
|
|
3815
|
+
} else if (unchosen?.ambiguous) {
|
|
3816
|
+
count.ambiguous++;
|
|
3817
|
+
} else {
|
|
3818
|
+
count.unanswered++;
|
|
3819
|
+
}
|
|
3820
|
+
} catch (e: unknown) {
|
|
3821
|
+
problems.push(`${link}: ${e instanceof Error ? e.message : String(e)}`);
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
|
|
3825
|
+
if (opts.json) {
|
|
3826
|
+
for (const it of pending) {
|
|
3827
|
+
const r = it.row;
|
|
3828
|
+
console.log(JSON.stringify({
|
|
3829
|
+
id: r.id, link: it.link, channel: r.channel, ts: r.ts, asked_at: r.sent_at,
|
|
3830
|
+
question: it.question, answer: it.answer, how: it.how, free_text: it.freeText,
|
|
3831
|
+
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 },
|
|
3832
|
+
delivered_at: r.delivered_at ?? null,
|
|
3833
|
+
collect: it.collect,
|
|
3834
|
+
}));
|
|
3835
|
+
}
|
|
3836
|
+
} else {
|
|
3837
|
+
// Grouped by the session that asked — the unit that has to be told.
|
|
3838
|
+
const groups = new Map<string, AskPendingItem[]>();
|
|
3839
|
+
for (const it of pending) {
|
|
3840
|
+
const key = it.row.session_id ? `session ${it.row.session_id}` : `pid ${it.row.agent_pid ?? it.row.pid}`;
|
|
3841
|
+
groups.set(key, [...(groups.get(key) ?? []), it]);
|
|
3842
|
+
}
|
|
3843
|
+
for (const [key, items] of groups) {
|
|
3844
|
+
const r = items[0]!.row;
|
|
3845
|
+
console.log(
|
|
3846
|
+
`${stripTerminalControls(key)} ${stripTerminalControls(r.cli ?? "?")} pid ${r.agent_pid ?? r.pid} ${items[0]!.alive ? "(alive)" : "(gone)"}` +
|
|
3847
|
+
`${r.git_branch ? ` branch=${stripTerminalControls(r.git_branch)}` : ""} cwd=${stripTerminalControls(r.cwd)}`,
|
|
3848
|
+
);
|
|
3849
|
+
for (const it of items) {
|
|
3850
|
+
console.log(` ${stripTerminalControls(it.link)} (${formatYmdHm(it.row.sent_at / 1000)})`);
|
|
3851
|
+
console.log(` Q: ${stripTerminalControls(askFlatten(it.question))}`);
|
|
3852
|
+
console.log(` A: ${stripTerminalControls(askFlatten(it.answer))} — ${stripTerminalControls(it.how)}${it.freeText ? " — 決定ではありません" : ""}`);
|
|
3853
|
+
console.log(` collect: ${stripTerminalControls(it.collect)}`);
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
}
|
|
3857
|
+
for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
|
|
3858
|
+
for (const [team, n] of otherTeams) {
|
|
3859
|
+
console.error(` (${n} 件は別のワークスペース「${stripTerminalControls(team)}」の質問です — slack -w <name> ask --pending で確認)`);
|
|
3860
|
+
}
|
|
3861
|
+
console.error(
|
|
3862
|
+
`${pending.length} 件が回答済み・未回収, ${count.unanswered} 件が未回答` +
|
|
3863
|
+
(count.ambiguous ? `, ${count.ambiguous} 件は返信が複数の選択肢に一致 (未確定)` : "") +
|
|
3864
|
+
(count.resolved ? `, ${count.resolved} 件は ✅ 回収済み` : "") +
|
|
3865
|
+
(count.void ? `, ${count.void} 件は無効化済み` : "") +
|
|
3866
|
+
` — ${rows.length} 件の未回収の質問を確認 (log: ${sentDbPath()})`,
|
|
3867
|
+
);
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3677
3870
|
/** A question about to be voided: where it is, and what it parsed as. */
|
|
3678
3871
|
interface AskVoidTarget {
|
|
3679
3872
|
link: string;
|
|
@@ -3858,23 +4051,14 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3858
4051
|
ts = m[2]!;
|
|
3859
4052
|
}
|
|
3860
4053
|
|
|
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) {
|
|
4054
|
+
const got = await askFetchAt(token, { channelId, ts, threadTs }, args.cookie);
|
|
4055
|
+
if (!got.msg) {
|
|
3872
4056
|
console.error(`Error: そのメッセージが見つかりません: ${stripTerminalControls(args.link)}`);
|
|
3873
4057
|
process.exit(ASK_EXIT_ERROR);
|
|
3874
4058
|
}
|
|
3875
|
-
|
|
3876
|
-
const
|
|
3877
|
-
const parsed =
|
|
4059
|
+
const msg = got.msg;
|
|
4060
|
+
const q = await askReadBack(token, { channelId, ts, msg }, args.cookie);
|
|
4061
|
+
const { text, parsed, audience, broadcast, askerUserId, askerBotId } = q;
|
|
3878
4062
|
if (parsed.kind === "other") {
|
|
3879
4063
|
console.error(
|
|
3880
4064
|
`Error: これは \`slack ask\` の質問として読み取れません。\n` +
|
|
@@ -3887,23 +4071,6 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3887
4071
|
process.exit(ASK_EXIT_ERROR);
|
|
3888
4072
|
}
|
|
3889
4073
|
|
|
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
4074
|
if (parsed.kind === "void") {
|
|
3908
4075
|
askReportVoid(parsed, args.link);
|
|
3909
4076
|
if (args.json) {
|
|
@@ -3955,10 +4122,11 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3955
4122
|
...(args.cookie ? { cookie: args.cookie } : {}),
|
|
3956
4123
|
}, after, isAnswerReply);
|
|
3957
4124
|
}
|
|
4125
|
+
const cursor = askNextCursor(ts, after, ...notes.map((n) => n.ts));
|
|
3958
4126
|
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)),
|
|
4127
|
+
status: "answered", answer: parsed.answer, notes, cursor,
|
|
3961
4128
|
});
|
|
4129
|
+
markAskCollected(channelId, ts, parsed.answer, cursor);
|
|
3962
4130
|
return;
|
|
3963
4131
|
}
|
|
3964
4132
|
|
|
@@ -3970,27 +4138,72 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3970
4138
|
if (args.timeout > 0) {
|
|
3971
4139
|
console.error(` 回答を待っています (最大 ${args.timeout}s)… Ctrl-C で中断`);
|
|
3972
4140
|
}
|
|
4141
|
+
await askWaitForAnswer(token, askWaitCtxFrom(q, parsed, { channelId, ts, threadTs }, args));
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
/** A posted question read back: what it parsed as, and who may answer it. */
|
|
4145
|
+
interface AskReadBack {
|
|
4146
|
+
text: string;
|
|
4147
|
+
parsed: ReturnType<typeof askParseMessage>;
|
|
4148
|
+
audience: Set<string>;
|
|
4149
|
+
broadcast: boolean;
|
|
4150
|
+
askerUserId: string;
|
|
4151
|
+
askerBotId: string;
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4154
|
+
/** Recover who may answer from the message alone. Shared by `--waitFor` and
|
|
4155
|
+
* `--pending` — nothing about a question is stored, so both read it back. */
|
|
4156
|
+
async function askReadBack(token: string, t: { channelId: string; ts: string; msg: Record<string, Json> }, cookie?: string): Promise<AskReadBack> {
|
|
4157
|
+
const { msg } = t;
|
|
4158
|
+
const text = typeof msg.text === "string" ? msg.text : "";
|
|
4159
|
+
const parsed = askParseMessage(text);
|
|
4160
|
+
// Who may answer, recovered from the tags the readers can see. They are
|
|
4161
|
+
// already `<@U…>`-encoded in the stored text, so no directory lookup is
|
|
4162
|
+
// needed — and the asker is excluded here for the same reason as when
|
|
4163
|
+
// posting: their tag of themselves grants nothing, and their reactions are
|
|
4164
|
+
// the seeds.
|
|
4165
|
+
const askerUserId = typeof msg.user === "string" ? msg.user : "";
|
|
4166
|
+
const askerBotId = typeof msg.bot_id === "string" ? msg.bot_id : "";
|
|
4167
|
+
const broadcast = /<!(here|channel|everyone)(\^[^>]*)?(\|[^>]*)?>/.test(text);
|
|
4168
|
+
const audience = new Set<string>();
|
|
4169
|
+
for (const m of text.matchAll(/<@([UW][A-Z0-9]+)>/g)) {
|
|
4170
|
+
if (m[1] !== askerUserId) audience.add(m[1]!);
|
|
4171
|
+
}
|
|
4172
|
+
if (parsed.kind !== "other" && !audience.size && !broadcast) {
|
|
4173
|
+
// A 1:1 DM needs no tag — the other party is the only person it could be.
|
|
4174
|
+
const counterpart = await imCounterpart(token, t.channelId, cookie);
|
|
4175
|
+
if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
|
|
4176
|
+
}
|
|
4177
|
+
return { text, parsed, audience, broadcast, askerUserId, askerBotId };
|
|
4178
|
+
}
|
|
4179
|
+
|
|
4180
|
+
function askWaitCtxFrom(
|
|
4181
|
+
q: AskReadBack,
|
|
4182
|
+
parsed: Extract<AskReadBack["parsed"], { kind: "open" }>,
|
|
4183
|
+
loc: { channelId: string; ts: string; threadTs?: string | undefined },
|
|
4184
|
+
o: { timeout: number; link: string; asBot: boolean; json: boolean; cookie?: string | undefined; after?: string | undefined },
|
|
4185
|
+
): AskWaitCtx {
|
|
3973
4186
|
const ctx: AskWaitCtx = {
|
|
3974
|
-
channelId,
|
|
3975
|
-
ts,
|
|
4187
|
+
channelId: loc.channelId,
|
|
4188
|
+
ts: loc.ts,
|
|
3976
4189
|
question: parsed.question,
|
|
3977
4190
|
reactable: parsed.reactable,
|
|
3978
4191
|
overflow: parsed.overflow,
|
|
3979
4192
|
threadOnly: parsed.threadOnly,
|
|
3980
4193
|
lang: parsed.lang,
|
|
3981
|
-
audience,
|
|
3982
|
-
broadcast,
|
|
3983
|
-
askerUserId,
|
|
3984
|
-
askerBotId,
|
|
3985
|
-
timeout:
|
|
3986
|
-
shown:
|
|
3987
|
-
asBot:
|
|
3988
|
-
json:
|
|
4194
|
+
audience: q.audience,
|
|
4195
|
+
broadcast: q.broadcast,
|
|
4196
|
+
askerUserId: q.askerUserId,
|
|
4197
|
+
askerBotId: q.askerBotId,
|
|
4198
|
+
timeout: o.timeout,
|
|
4199
|
+
shown: o.link,
|
|
4200
|
+
asBot: o.asBot,
|
|
4201
|
+
json: o.json,
|
|
3989
4202
|
};
|
|
3990
|
-
if (threadTs) ctx.threadParentTs = threadTs;
|
|
3991
|
-
if (
|
|
3992
|
-
if (
|
|
3993
|
-
|
|
4203
|
+
if (loc.threadTs) ctx.threadParentTs = loc.threadTs;
|
|
4204
|
+
if (o.cookie) ctx.cookie = o.cookie;
|
|
4205
|
+
if (o.after) ctx.after = o.after;
|
|
4206
|
+
return ctx;
|
|
3994
4207
|
}
|
|
3995
4208
|
|
|
3996
4209
|
// --- poll ---
|
|
@@ -5536,13 +5749,15 @@ async function main(): Promise<void> {
|
|
|
5536
5749
|
.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
5750
|
.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
5751
|
.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." })
|
|
5752
|
+
.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." })
|
|
5753
|
+
.option("since", { type: "string", default: "7d", describe: "With --pending: how far back to look (30m, 2h, 7d, or an ISO date)" })
|
|
5539
5754
|
.option("stale", { type: "string", describe: "With --ls: only open questions older than this (24h, 3d)" })
|
|
5540
5755
|
.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)" })
|
|
5756
|
+
.option("channel", { type: "string", describe: "With --ls / --pending: only this channel (id, or the target as typed)" })
|
|
5542
5757
|
.option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
|
|
5543
5758
|
.option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
|
|
5544
5759
|
.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." })
|
|
5760
|
+
.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
5761
|
.option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
|
|
5547
5762
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
5548
5763
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
@@ -5554,7 +5769,11 @@ async function main(): Promise<void> {
|
|
|
5554
5769
|
// slack ask collect <link> = --waitFor
|
|
5555
5770
|
// Lifecycle flags (maintainer: "maybe slack ask --edit=<target>"), primary;
|
|
5556
5771
|
// the bare verbs `void` / `collect` stay as aliases.
|
|
5557
|
-
if (argv.ls) {
|
|
5772
|
+
if (argv.ls || argv.pending) {
|
|
5773
|
+
if (argv.ls && argv.pending) {
|
|
5774
|
+
console.error("Error: --ls and --pending are separate lists — pick one.");
|
|
5775
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5776
|
+
}
|
|
5558
5777
|
const user = (() => { try { return tok(argv as W); } catch { return undefined; } })();
|
|
5559
5778
|
const tokens: { user?: string; bot?: string; cookie?: string } = {};
|
|
5560
5779
|
if (user) tokens.user = user;
|
|
@@ -5562,6 +5781,26 @@ async function main(): Promise<void> {
|
|
|
5562
5781
|
if (bot) tokens.bot = bot;
|
|
5563
5782
|
const c = ck(argv as W);
|
|
5564
5783
|
if (c) tokens.cookie = c;
|
|
5784
|
+
if (argv.pending) {
|
|
5785
|
+
if (argv.target || argv.question || argv.waitFor || argv.wait || argv.void || argv.edit) {
|
|
5786
|
+
console.error("Error: --pending reads the local log — do not also pass a target/question or another ask action.");
|
|
5787
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5788
|
+
}
|
|
5789
|
+
const sinceMs = parseSince(String(argv.since));
|
|
5790
|
+
if (sinceMs === undefined) {
|
|
5791
|
+
console.error(`Error: --since needs 30m / 2h / 7d / 1w or an ISO date (got ${stripTerminalControls(String(argv.since))})`);
|
|
5792
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5793
|
+
}
|
|
5794
|
+
const p: { sinceMs: number; json: boolean; channel?: string } = { sinceMs, json: !!argv.json };
|
|
5795
|
+
if (argv.channel) p.channel = String(argv.channel);
|
|
5796
|
+
try {
|
|
5797
|
+
await cmdAskPending(tokens, p);
|
|
5798
|
+
} catch (e: unknown) {
|
|
5799
|
+
console.error(friendlySlackError(e));
|
|
5800
|
+
process.exit(ASK_EXIT_ERROR);
|
|
5801
|
+
}
|
|
5802
|
+
return;
|
|
5803
|
+
}
|
|
5565
5804
|
const a: { stale?: string; state: string; channel?: string; json: boolean; limit: number } = {
|
|
5566
5805
|
state: String(argv.state ?? "open"), json: !!argv.json, limit: Number(argv.limit) || 50,
|
|
5567
5806
|
};
|