slack-term 1.41.1 → 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 +9 -0
- package/SKILL.md +12 -0
- package/dist/cli.js +210 -203
- package/package.json +1 -1
- package/ts/cli.ts +362 -112
- package/ts/sentlog.ts +106 -2
- package/ts/slack.ts +3 -2
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,
|
|
@@ -131,7 +131,7 @@ import { setCacheEnabled } from "./cache.ts";
|
|
|
131
131
|
import { escapeArg, unescapeArg } from "./escapes.ts";
|
|
132
132
|
import { attachmentLines, parseAttachments } from "./attachments.ts";
|
|
133
133
|
import { botIntentRefusal } from "./botIntent.ts";
|
|
134
|
-
import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, maskSecrets, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
|
|
134
|
+
import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, maskSecrets, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult, type UnresolvedMention } from "./format.ts";
|
|
135
135
|
import { quietHoursNotice } from "./quietHours.ts";
|
|
136
136
|
import { installSyncConsole } from "./stdout.ts";
|
|
137
137
|
import { LockBusyError, PINLOG_MARKER, acquireLock, composeHead, formatJst, headUpdatedAt, isPinlogHead, parsePinlogId, pinlogId, stripPinlogFooter } from "./pinlog.ts";
|
|
@@ -2525,6 +2525,22 @@ const ASK_ANSWERABLE_SUBTYPES = new Set(["thread_broadcast"]);
|
|
|
2525
2525
|
// ends it (`\b` is unreliable outside ASCII).
|
|
2526
2526
|
const ASK_BROADCAST_RE = /(?<![A-Za-z0-9._@<-])@(here|channel|everyone)(?![A-Za-z0-9._-])/giu;
|
|
2527
2527
|
|
|
2528
|
+
/** `ask` cannot degrade an unresolved @tag to plain text the way `send` does:
|
|
2529
|
+
* the tags ARE who may answer, so a tag that named nobody would silently drop
|
|
2530
|
+
* an intended answerer (or, in a 1:1 DM, hand the decision to the counterpart
|
|
2531
|
+
* the caller meant to bypass). Refuse instead, before anything is posted. */
|
|
2532
|
+
function askRefuseUnresolved(unresolved: UnresolvedMention[]): void {
|
|
2533
|
+
if (!unresolved.length) return;
|
|
2534
|
+
const reasons = unresolved.map((u) => {
|
|
2535
|
+
const tag = stripTerminalControls(u.surface);
|
|
2536
|
+
if (u.reason === "unavailable") return `${tag}: could not fetch the user list or channel members (users:read missing or an API/connection error)`;
|
|
2537
|
+
if (u.reason === "ambiguous") return `${tag}: matches multiple people — use an explicit <@USERID> tag`;
|
|
2538
|
+
return `${tag}: matched no one`;
|
|
2539
|
+
});
|
|
2540
|
+
console.error(`Error: \`ask\` will not post with unresolved answerer tags.\n ${reasons.join("\n ")}`);
|
|
2541
|
+
process.exit(ASK_EXIT_ERROR);
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2528
2544
|
/** Rewrite `@here`/`@channel`/`@everyone` to the `<!here>` form Slack actually
|
|
2529
2545
|
* broadcasts on, and report which were used. Run BEFORE user-mention encoding
|
|
2530
2546
|
* so those tokens are never offered to the directory as if they were handles. */
|
|
@@ -2724,12 +2740,33 @@ async function askEmit(
|
|
|
2724
2740
|
}
|
|
2725
2741
|
|
|
2726
2742
|
|
|
2727
|
-
/**
|
|
2728
|
-
*
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
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;
|
|
2732
2768
|
const copy = ASK_COPY[ctx.lang];
|
|
2769
|
+
const state: AskPollState = {};
|
|
2733
2770
|
const askerUserId = ctx.askerUserId;
|
|
2734
2771
|
const askerBotId = ctx.askerBotId;
|
|
2735
2772
|
|
|
@@ -2740,25 +2777,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2740
2777
|
const isAnswerer = (user: unknown): user is string =>
|
|
2741
2778
|
typeof user === "string" && !!user && user !== askerUserId && (broadcast || audience.has(user));
|
|
2742
2779
|
|
|
2743
|
-
/** The question
|
|
2744
|
-
* fetch when collecting notes
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
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
|
+
};
|
|
2748
2786
|
/** Set when the question is voided while we wait — the wait ends there. */
|
|
2749
|
-
let voided: Extract<ReturnType<typeof askParseMessage>, { kind: "void" }> | undefined;
|
|
2750
2787
|
const checkVoid = (own: Record<string, Json>): boolean => {
|
|
2751
2788
|
const p = typeof own.text === "string" ? askParseMessage(own.text) : undefined;
|
|
2752
|
-
if (p?.kind === "void") voided = p;
|
|
2753
|
-
return !!voided;
|
|
2789
|
+
if (p?.kind === "void") state.voided = p;
|
|
2790
|
+
return !!state.voided;
|
|
2754
2791
|
};
|
|
2755
2792
|
|
|
2756
|
-
/** The earliest reply from an answerer that picked none of the choices. Kept
|
|
2757
|
-
* across polls so the timeout can report it, and so the operator is told once
|
|
2758
|
-
* rather than on every tick. */
|
|
2759
|
-
let unchosen: { text: string; who: string; ts: string; ambiguous: boolean } | undefined;
|
|
2760
|
-
let unchosenReported = "";
|
|
2761
|
-
|
|
2762
2793
|
/** Every seed an answerer is on — not just the first. Changing your mind
|
|
2763
2794
|
* leaves BOTH reactions in place (Slack only drops one when you actively
|
|
2764
2795
|
* un-react), so taking the lowest index would answer with the abandoned
|
|
@@ -2784,6 +2815,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2784
2815
|
* the API call when nothing changed, which is what makes this safe to run on
|
|
2785
2816
|
* every poll tick rather than once per process. */
|
|
2786
2817
|
async function noteInvalid(msg: Record<string, Json>, offenders: string[]): Promise<void> {
|
|
2818
|
+
if (readOnly) return;
|
|
2787
2819
|
const text = typeof msg.text === "string" ? msg.text : "";
|
|
2788
2820
|
if (!text) return;
|
|
2789
2821
|
const next = applyInvalidNotice(text, offenders, ctx.lang);
|
|
@@ -2806,7 +2838,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2806
2838
|
for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
|
|
2807
2839
|
if (otherReported.has(u)) continue;
|
|
2808
2840
|
otherReported.add(u);
|
|
2809
|
-
console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
|
|
2841
|
+
if (!readOnly) console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
|
|
2810
2842
|
}
|
|
2811
2843
|
}
|
|
2812
2844
|
|
|
@@ -2829,7 +2861,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2829
2861
|
const pillsPerUser = new Map<string, number>();
|
|
2830
2862
|
for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
|
|
2831
2863
|
const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
|
|
2832
|
-
console.error(multi.length
|
|
2864
|
+
if (!readOnly) console.error(multi.length
|
|
2833
2865
|
? ` (${glyphs} が同時に選ばれています。1 つに絞ってもらうまで待ちます)`
|
|
2834
2866
|
: ` (回答が分かれています: ${glyphs}。どちらか一方にそろうまで待ちます)`);
|
|
2835
2867
|
// Whoever is on more than one pill — and ONLY them. Naming everyone on any
|
|
@@ -2879,9 +2911,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2879
2911
|
// must not hide it behind the refuse-to-guess path. Within a class, the
|
|
2880
2912
|
// earliest wins.
|
|
2881
2913
|
const ambiguous = match.kind === "ambiguous";
|
|
2914
|
+
const unchosen = state.unchosen;
|
|
2882
2915
|
if (!unchosen || (unchosen.ambiguous && !ambiguous) ||
|
|
2883
2916
|
(unchosen.ambiguous === ambiguous && Number(m.ts) < Number(unchosen.ts))) {
|
|
2884
|
-
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 };
|
|
2885
2918
|
}
|
|
2886
2919
|
}
|
|
2887
2920
|
return null;
|
|
@@ -2894,7 +2927,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2894
2927
|
// longer there, and exit 4 where exit 2 is the truth. `unchosenReported`
|
|
2895
2928
|
// persists on purpose — it is what keeps the operator from being told the
|
|
2896
2929
|
// same thing on every tick.
|
|
2897
|
-
unchosen
|
|
2930
|
+
delete state.unchosen;
|
|
2898
2931
|
// A question that is itself a thread reply is invisible to
|
|
2899
2932
|
// `conversations.history`, so the entire poll — reactions included — has to
|
|
2900
2933
|
// come from the thread.
|
|
@@ -2903,8 +2936,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2903
2936
|
const messages = asArray(rep.messages).map(asRecord);
|
|
2904
2937
|
const own = messages.find((m) => m.ts === ts);
|
|
2905
2938
|
if (own) {
|
|
2906
|
-
|
|
2907
|
-
if (typeof own.text === "string") ownText = own.text;
|
|
2939
|
+
seen(own);
|
|
2908
2940
|
if (checkVoid(own)) return null;
|
|
2909
2941
|
const byReaction = await answerFromReactions(own);
|
|
2910
2942
|
if (byReaction) return byReaction;
|
|
@@ -2920,8 +2952,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2920
2952
|
const own = messages.find((m) => m.ts === ts);
|
|
2921
2953
|
|
|
2922
2954
|
if (own) {
|
|
2923
|
-
|
|
2924
|
-
if (typeof own.text === "string") ownText = own.text;
|
|
2955
|
+
seen(own);
|
|
2925
2956
|
if (checkVoid(own)) return null;
|
|
2926
2957
|
}
|
|
2927
2958
|
// A reaction is the intended path, so it wins when both are present.
|
|
@@ -2954,6 +2985,21 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2954
2985
|
return null;
|
|
2955
2986
|
}
|
|
2956
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
|
+
|
|
2957
3003
|
/** Stamp the question resolved. Removing our seeds clears the pills that still
|
|
2958
3004
|
* invite an answer; the answerer's own reaction cannot be removed by us and
|
|
2959
3005
|
* stays put — which is exactly what leaves the chosen number visible. */
|
|
@@ -2980,7 +3026,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
2980
3026
|
// The body and the chosen option stay; only what no longer applies goes.
|
|
2981
3027
|
// Built from the message as it is NOW (an edit made in Slack after asking
|
|
2982
3028
|
// is kept). Not seen this run — the bare form, as before.
|
|
2983
|
-
const keep = ownText ? askResolvedKeep(ownText, found.choice) : undefined;
|
|
3029
|
+
const keep = state.ownText ? askResolvedKeep(state.ownText, found.choice) : undefined;
|
|
2984
3030
|
await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
|
|
2985
3031
|
// Swap the marker for the resolved one so search reflects reality:
|
|
2986
3032
|
// `has::question:` should list what still needs answering, not everything
|
|
@@ -3003,6 +3049,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3003
3049
|
const deadline = Date.now() + timeout * 1000;
|
|
3004
3050
|
let errors = 0;
|
|
3005
3051
|
let delay = ASK_POLL_MIN_MS;
|
|
3052
|
+
let unchosen: AskPollState["unchosen"];
|
|
3006
3053
|
for (;;) {
|
|
3007
3054
|
let found: AskFound | null = null;
|
|
3008
3055
|
try {
|
|
@@ -3017,6 +3064,8 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3017
3064
|
}
|
|
3018
3065
|
console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
|
|
3019
3066
|
}
|
|
3067
|
+
unchosen = state.unchosen;
|
|
3068
|
+
const voided = state.voided;
|
|
3020
3069
|
if (voided) {
|
|
3021
3070
|
askReportVoid(voided, shown);
|
|
3022
3071
|
if (ctx.json) {
|
|
@@ -3034,7 +3083,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3034
3083
|
// BEFORE ✅ is written, and delivered right after — ✅ is what tells a
|
|
3035
3084
|
// later --waitFor the question is settled.
|
|
3036
3085
|
const notes = await askCollectNotes(token, {
|
|
3037
|
-
channelId, ts, threadOnly, replyCount, isAnswerer, askerUserId, askerBotId,
|
|
3086
|
+
channelId, ts, threadOnly, replyCount: state.replyCount, isAnswerer, askerUserId, askerBotId,
|
|
3038
3087
|
...(ctx.threadParentTs ? { threadParentTs: ctx.threadParentTs } : {}),
|
|
3039
3088
|
...(cookie ? { cookie } : {}),
|
|
3040
3089
|
}, ctx.after, (m) => m.ts === found!.ts);
|
|
@@ -3043,6 +3092,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3043
3092
|
status: "answered", answer: found.answer, how: found.how, notes,
|
|
3044
3093
|
cursor: askNextCursor(ts, ctx.after, found.ts, ...notes.map((n) => n.ts)),
|
|
3045
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);
|
|
3046
3098
|
if (found.who) r.who = found.who;
|
|
3047
3099
|
await askEmit(token, ctx, r);
|
|
3048
3100
|
process.exit(0);
|
|
@@ -3063,6 +3115,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3063
3115
|
console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
3064
3116
|
console.error(` ${shown}`);
|
|
3065
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));
|
|
3066
3121
|
if (ctx.json) {
|
|
3067
3122
|
await askEmit(token, ctx, { status: "freetext", answer: unchosen.text, who: unchosen.who, notes: [], cursor: askNextCursor(ts, ctx.after, unchosen.ts) });
|
|
3068
3123
|
} else {
|
|
@@ -3181,9 +3236,9 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3181
3236
|
const body = bRep.text;
|
|
3182
3237
|
const resolved = [...qRep.resolved, ...bRep.resolved];
|
|
3183
3238
|
const unresolved = [...qRep.unresolved, ...bRep.unresolved];
|
|
3184
|
-
// An unresolved tag
|
|
3185
|
-
//
|
|
3186
|
-
|
|
3239
|
+
// An unresolved tag names nobody, so it grants nobody the right to answer —
|
|
3240
|
+
// and posting anyway would silently drop someone the caller meant to ask.
|
|
3241
|
+
askRefuseUnresolved(unresolved);
|
|
3187
3242
|
|
|
3188
3243
|
const broadcast = broadcastKinds.size > 0;
|
|
3189
3244
|
// Tagging yourself grants nothing — your own reactions are the seeds.
|
|
@@ -3211,9 +3266,6 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3211
3266
|
if (!audience.size) {
|
|
3212
3267
|
console.error(
|
|
3213
3268
|
`Error: \`ask\` will not post a question nobody is addressed to — no one is tagged.\n` +
|
|
3214
|
-
(unresolved.length
|
|
3215
|
-
? ` (${unresolved.map((u) => stripTerminalControls(u.surface)).join(", ")} matched no one, so it names nobody.)\n`
|
|
3216
|
-
: "") +
|
|
3217
3269
|
` Tag the person who should answer: slack ask '${args.target}' '@alice ${args.question}' ...\n` +
|
|
3218
3270
|
` Or open it to the whole channel: slack ask '${args.target}' '@here ${args.question}' ...\n` +
|
|
3219
3271
|
` (In a 1:1 DM the other person counts automatically.)`,
|
|
@@ -3421,6 +3473,16 @@ async function askFetchByLink(token: string, link: string, cookie?: string): Pro
|
|
|
3421
3473
|
channelId = m[1]!;
|
|
3422
3474
|
ts = m[2]!;
|
|
3423
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;
|
|
3424
3486
|
let msg: Record<string, Json> | undefined;
|
|
3425
3487
|
if (threadTs) {
|
|
3426
3488
|
const rep = asRecord((await replies(token, channelId, threadTs, 100, cookie)) as Json);
|
|
@@ -3505,7 +3567,9 @@ async function cmdAskEdit(token: string, args: {
|
|
|
3505
3567
|
const old = t.parsed;
|
|
3506
3568
|
const encode = async (raw: string): Promise<string> => {
|
|
3507
3569
|
const enc = askEncodeBroadcasts(unescapeArg(raw));
|
|
3508
|
-
|
|
3570
|
+
const rep = await encodeMentionsDetailed(args.mentionToken, enc.text, t.channelId, args.mentionCookie);
|
|
3571
|
+
askRefuseUnresolved(rep.unresolved);
|
|
3572
|
+
return rep.text;
|
|
3509
3573
|
};
|
|
3510
3574
|
const question = args.question !== undefined ? await encode(args.question) : old.question;
|
|
3511
3575
|
const body = args.body !== undefined ? await encode(args.body) : old.body;
|
|
@@ -3620,8 +3684,8 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
|
|
|
3620
3684
|
let answer: string | undefined;
|
|
3621
3685
|
if (token) {
|
|
3622
3686
|
try {
|
|
3623
|
-
const got = await
|
|
3624
|
-
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";
|
|
3625
3689
|
else {
|
|
3626
3690
|
const p = askParseMessage(typeof got.msg.text === "string" ? got.msg.text : "");
|
|
3627
3691
|
state = p.kind === "resolved" ? "answered" : p.kind === "other" ? "edited" : p.kind;
|
|
@@ -3659,6 +3723,150 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
|
|
|
3659
3723
|
}
|
|
3660
3724
|
}
|
|
3661
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
|
+
|
|
3662
3870
|
/** A question about to be voided: where it is, and what it parsed as. */
|
|
3663
3871
|
interface AskVoidTarget {
|
|
3664
3872
|
link: string;
|
|
@@ -3843,23 +4051,14 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3843
4051
|
ts = m[2]!;
|
|
3844
4052
|
}
|
|
3845
4053
|
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
let msg: Record<string, Json> | undefined;
|
|
3849
|
-
if (threadTs) {
|
|
3850
|
-
const rep = asRecord((await replies(token, channelId, threadTs, 100, args.cookie)) as Json);
|
|
3851
|
-
msg = asArray(rep.messages).map(asRecord).find((m) => m.ts === ts);
|
|
3852
|
-
} else {
|
|
3853
|
-
const hist = asRecord((await history(token, channelId, 1, ts, undefined, args.cookie, true)) as Json);
|
|
3854
|
-
msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
|
|
3855
|
-
}
|
|
3856
|
-
if (!msg) {
|
|
4054
|
+
const got = await askFetchAt(token, { channelId, ts, threadTs }, args.cookie);
|
|
4055
|
+
if (!got.msg) {
|
|
3857
4056
|
console.error(`Error: そのメッセージが見つかりません: ${stripTerminalControls(args.link)}`);
|
|
3858
4057
|
process.exit(ASK_EXIT_ERROR);
|
|
3859
4058
|
}
|
|
3860
|
-
|
|
3861
|
-
const
|
|
3862
|
-
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;
|
|
3863
4062
|
if (parsed.kind === "other") {
|
|
3864
4063
|
console.error(
|
|
3865
4064
|
`Error: これは \`slack ask\` の質問として読み取れません。\n` +
|
|
@@ -3872,23 +4071,6 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3872
4071
|
process.exit(ASK_EXIT_ERROR);
|
|
3873
4072
|
}
|
|
3874
4073
|
|
|
3875
|
-
// Who may answer, recovered from the tags the readers can see. They are
|
|
3876
|
-
// already `<@U…>`-encoded in the stored text, so no directory lookup is
|
|
3877
|
-
// needed — and the asker is excluded here for the same reason as when
|
|
3878
|
-
// posting: their tag of themselves grants nothing, and their reactions are
|
|
3879
|
-
// the seeds.
|
|
3880
|
-
const askerUserId = typeof msg.user === "string" ? msg.user : "";
|
|
3881
|
-
const askerBotId = typeof msg.bot_id === "string" ? msg.bot_id : "";
|
|
3882
|
-
const broadcast = /<!(here|channel|everyone)(\^[^>]*)?(\|[^>]*)?>/.test(text);
|
|
3883
|
-
const audience = new Set<string>();
|
|
3884
|
-
for (const m of text.matchAll(/<@([UW][A-Z0-9]+)>/g)) {
|
|
3885
|
-
if (m[1] !== askerUserId) audience.add(m[1]!);
|
|
3886
|
-
}
|
|
3887
|
-
if (!audience.size && !broadcast) {
|
|
3888
|
-
// A 1:1 DM needs no tag — the other party is the only person it could be.
|
|
3889
|
-
const counterpart = await imCounterpart(token, channelId, args.cookie);
|
|
3890
|
-
if (counterpart && counterpart !== askerUserId) audience.add(counterpart);
|
|
3891
|
-
}
|
|
3892
4074
|
if (parsed.kind === "void") {
|
|
3893
4075
|
askReportVoid(parsed, args.link);
|
|
3894
4076
|
if (args.json) {
|
|
@@ -3940,10 +4122,11 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3940
4122
|
...(args.cookie ? { cookie: args.cookie } : {}),
|
|
3941
4123
|
}, after, isAnswerReply);
|
|
3942
4124
|
}
|
|
4125
|
+
const cursor = askNextCursor(ts, after, ...notes.map((n) => n.ts));
|
|
3943
4126
|
await askEmit(token, { json: args.json, shown: args.link, asBot: args.asBot, cookie: args.cookie }, {
|
|
3944
|
-
status: "answered", answer: parsed.answer, notes,
|
|
3945
|
-
cursor: askNextCursor(ts, after, ...notes.map((n) => n.ts)),
|
|
4127
|
+
status: "answered", answer: parsed.answer, notes, cursor,
|
|
3946
4128
|
});
|
|
4129
|
+
markAskCollected(channelId, ts, parsed.answer, cursor);
|
|
3947
4130
|
return;
|
|
3948
4131
|
}
|
|
3949
4132
|
|
|
@@ -3955,27 +4138,72 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
3955
4138
|
if (args.timeout > 0) {
|
|
3956
4139
|
console.error(` 回答を待っています (最大 ${args.timeout}s)… Ctrl-C で中断`);
|
|
3957
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 {
|
|
3958
4186
|
const ctx: AskWaitCtx = {
|
|
3959
|
-
channelId,
|
|
3960
|
-
ts,
|
|
4187
|
+
channelId: loc.channelId,
|
|
4188
|
+
ts: loc.ts,
|
|
3961
4189
|
question: parsed.question,
|
|
3962
4190
|
reactable: parsed.reactable,
|
|
3963
4191
|
overflow: parsed.overflow,
|
|
3964
4192
|
threadOnly: parsed.threadOnly,
|
|
3965
4193
|
lang: parsed.lang,
|
|
3966
|
-
audience,
|
|
3967
|
-
broadcast,
|
|
3968
|
-
askerUserId,
|
|
3969
|
-
askerBotId,
|
|
3970
|
-
timeout:
|
|
3971
|
-
shown:
|
|
3972
|
-
asBot:
|
|
3973
|
-
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,
|
|
3974
4202
|
};
|
|
3975
|
-
if (threadTs) ctx.threadParentTs = threadTs;
|
|
3976
|
-
if (
|
|
3977
|
-
if (
|
|
3978
|
-
|
|
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;
|
|
3979
4207
|
}
|
|
3980
4208
|
|
|
3981
4209
|
// --- poll ---
|
|
@@ -5521,17 +5749,19 @@ async function main(): Promise<void> {
|
|
|
5521
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." })
|
|
5522
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." })
|
|
5523
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)" })
|
|
5524
5754
|
.option("stale", { type: "string", describe: "With --ls: only open questions older than this (24h, 3d)" })
|
|
5525
5755
|
.option("state", { type: "string", default: "open", choices: ["open", "answered", "void", "edited", "gone", "unknown", "all"], describe: "With --ls: which state to list" })
|
|
5526
|
-
.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)" })
|
|
5527
5757
|
.option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
|
|
5528
5758
|
.option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
|
|
5529
5759
|
.option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
|
|
5530
|
-
.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." })
|
|
5531
5761
|
.option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
|
|
5532
5762
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
5533
5763
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
5534
|
-
.option("as-bot", { type: "boolean", default: false, describe: "Ask via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient" }),
|
|
5764
|
+
.option("as-bot", { type: "boolean", default: false, describe: "Ask via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient. @tags and an @user target then resolve with the bot token too (needs users:read)." }),
|
|
5535
5765
|
async (argv) => {
|
|
5536
5766
|
// Verbs. A target is always #chan / @user / a permalink / a raw ID, so a
|
|
5537
5767
|
// bare word here cannot be one — `slack ask void …` is unambiguous.
|
|
@@ -5539,7 +5769,11 @@ async function main(): Promise<void> {
|
|
|
5539
5769
|
// slack ask collect <link> = --waitFor
|
|
5540
5770
|
// Lifecycle flags (maintainer: "maybe slack ask --edit=<target>"), primary;
|
|
5541
5771
|
// the bare verbs `void` / `collect` stay as aliases.
|
|
5542
|
-
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
|
+
}
|
|
5543
5777
|
const user = (() => { try { return tok(argv as W); } catch { return undefined; } })();
|
|
5544
5778
|
const tokens: { user?: string; bot?: string; cookie?: string } = {};
|
|
5545
5779
|
if (user) tokens.user = user;
|
|
@@ -5547,6 +5781,26 @@ async function main(): Promise<void> {
|
|
|
5547
5781
|
if (bot) tokens.bot = bot;
|
|
5548
5782
|
const c = ck(argv as W);
|
|
5549
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
|
+
}
|
|
5550
5804
|
const a: { stale?: string; state: string; channel?: string; json: boolean; limit: number } = {
|
|
5551
5805
|
state: String(argv.state ?? "open"), json: !!argv.json, limit: Number(argv.limit) || 50,
|
|
5552
5806
|
};
|
|
@@ -5580,7 +5834,9 @@ async function main(): Promise<void> {
|
|
|
5580
5834
|
}
|
|
5581
5835
|
const { token, cookie } = askTokenFor(!!argv["as-bot"]);
|
|
5582
5836
|
const a: Parameters<typeof cmdAskEdit>[1] = {
|
|
5583
|
-
|
|
5837
|
+
// Tags resolve as whoever posts the question — the bot under --as-bot,
|
|
5838
|
+
// so a dead or absent user token cannot break a bot edit.
|
|
5839
|
+
link: String(argv.edit), asBot: !!argv["as-bot"], mentionToken: token,
|
|
5584
5840
|
allowUrlAdjacent: !!argv["allow-url-adjacent"],
|
|
5585
5841
|
};
|
|
5586
5842
|
if (newQuestion !== undefined) a.question = newQuestion;
|
|
@@ -5588,9 +5844,7 @@ async function main(): Promise<void> {
|
|
|
5588
5844
|
if (argv.body !== undefined) a.body = String(argv.body);
|
|
5589
5845
|
if (argv.lang) a.lang = String(argv.lang);
|
|
5590
5846
|
if (argv.code) a.code = String(argv.code);
|
|
5591
|
-
if (cookie) a.cookie = cookie;
|
|
5592
|
-
const mc = ck(argv as W);
|
|
5593
|
-
if (mc) a.mentionCookie = mc;
|
|
5847
|
+
if (cookie) { a.cookie = cookie; a.mentionCookie = cookie; }
|
|
5594
5848
|
try {
|
|
5595
5849
|
await cmdAskEdit(token, a);
|
|
5596
5850
|
} catch (e: unknown) {
|
|
@@ -5757,11 +6011,6 @@ async function main(): Promise<void> {
|
|
|
5757
6011
|
if (voidAfter) args.voidAfter = voidAfter;
|
|
5758
6012
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
5759
6013
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
5760
|
-
// Always resolve @tags with the user token (it has users:read), even
|
|
5761
|
-
// when the question itself is posted by the bot.
|
|
5762
|
-
args.mentionToken = tok(argv as W);
|
|
5763
|
-
const mc = ck(argv as W);
|
|
5764
|
-
if (mc) args.mentionCookie = mc;
|
|
5765
6014
|
|
|
5766
6015
|
let askToken: string;
|
|
5767
6016
|
if (argv["as-bot"]) {
|
|
@@ -5773,11 +6022,12 @@ async function main(): Promise<void> {
|
|
|
5773
6022
|
);
|
|
5774
6023
|
process.exit(ASK_EXIT_ERROR);
|
|
5775
6024
|
}
|
|
5776
|
-
// Resolve @user
|
|
5777
|
-
//
|
|
6025
|
+
// Resolve the @user destination AND the answerer tags as the posting
|
|
6026
|
+
// bot: the bot's directory is the one the question is read against, and
|
|
6027
|
+
// a revoked user credential must not break an otherwise valid bot ask.
|
|
5778
6028
|
if (!args.channelId && !args.userId && args.target.startsWith("@")) {
|
|
5779
6029
|
try {
|
|
5780
|
-
args.userId = await resolveUserId(
|
|
6030
|
+
args.userId = await resolveUserId(botToken, args.target);
|
|
5781
6031
|
} catch (e: unknown) {
|
|
5782
6032
|
console.error(
|
|
5783
6033
|
`Error: could not resolve ${args.target} to a user for the bot DM.\n` +
|
|
@@ -5788,11 +6038,13 @@ async function main(): Promise<void> {
|
|
|
5788
6038
|
}
|
|
5789
6039
|
}
|
|
5790
6040
|
askToken = botToken;
|
|
6041
|
+
args.mentionToken = botToken;
|
|
5791
6042
|
args.asBot = true;
|
|
5792
6043
|
} else {
|
|
5793
6044
|
askToken = tok(argv as W);
|
|
6045
|
+
args.mentionToken = askToken;
|
|
5794
6046
|
const sc = ck(argv as W);
|
|
5795
|
-
if (sc) args.cookie = sc;
|
|
6047
|
+
if (sc) { args.cookie = sc; args.mentionCookie = sc; }
|
|
5796
6048
|
}
|
|
5797
6049
|
try {
|
|
5798
6050
|
await cmdAsk(askToken, args);
|
|
@@ -5822,7 +6074,7 @@ async function main(): Promise<void> {
|
|
|
5822
6074
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
5823
6075
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
5824
6076
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
5825
|
-
.option("as-bot", { type: "boolean", default: false, describe: "Post via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient — and so YOUR OWN vote counts, since the seeds are then the bot's" }),
|
|
6077
|
+
.option("as-bot", { type: "boolean", default: false, describe: "Post via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient — and so YOUR OWN vote counts, since the seeds are then the bot's. @tags and an @user target then resolve with the bot token too (needs users:read)." }),
|
|
5826
6078
|
async (argv) => {
|
|
5827
6079
|
const readLink = argv.close ? String(argv.close) : argv.results ? String(argv.results) : "";
|
|
5828
6080
|
if (argv.results && argv.close) {
|
|
@@ -5892,18 +6144,14 @@ async function main(): Promise<void> {
|
|
|
5892
6144
|
if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
|
|
5893
6145
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
5894
6146
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
5895
|
-
// Always resolve @tags with the user token (it has users:read), even
|
|
5896
|
-
// when the poll itself is posted by the bot.
|
|
5897
|
-
args.mentionToken = tok(argv as W);
|
|
5898
|
-
const mc = ck(argv as W);
|
|
5899
|
-
if (mc) args.mentionCookie = mc;
|
|
5900
6147
|
|
|
5901
6148
|
let pollToken: string;
|
|
5902
6149
|
if (argv["as-bot"]) {
|
|
5903
6150
|
const botToken = botTokenOrExit();
|
|
6151
|
+
// Resolve the @user destination and @tags as the posting bot, same as `ask --as-bot`.
|
|
5904
6152
|
if (!args.channelId && !args.userId && args.target.startsWith("@")) {
|
|
5905
6153
|
try {
|
|
5906
|
-
args.userId = await resolveUserId(
|
|
6154
|
+
args.userId = await resolveUserId(botToken, args.target);
|
|
5907
6155
|
} catch (e: unknown) {
|
|
5908
6156
|
console.error(
|
|
5909
6157
|
`Error: could not resolve ${args.target} to a user for the bot DM.\n` +
|
|
@@ -5914,11 +6162,13 @@ async function main(): Promise<void> {
|
|
|
5914
6162
|
}
|
|
5915
6163
|
}
|
|
5916
6164
|
pollToken = botToken;
|
|
6165
|
+
args.mentionToken = botToken;
|
|
5917
6166
|
args.asBot = true;
|
|
5918
6167
|
} else {
|
|
5919
6168
|
pollToken = tok(argv as W);
|
|
6169
|
+
args.mentionToken = pollToken;
|
|
5920
6170
|
const sc = ck(argv as W);
|
|
5921
|
-
if (sc) args.cookie = sc;
|
|
6171
|
+
if (sc) { args.cookie = sc; args.mentionCookie = sc; }
|
|
5922
6172
|
}
|
|
5923
6173
|
try {
|
|
5924
6174
|
await cmdPoll(pollToken, args);
|