slack-term 1.49.1 → 1.51.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 +26 -0
- package/dist/cli.js +270 -264
- package/package.json +1 -1
- package/ts/ask.ts +5 -5
- package/ts/botIntent.ts +2 -2
- package/ts/cli.ts +175 -119
- package/ts/identity.ts +152 -0
- package/ts/inbox.ts +12 -1
- package/ts/relay.ts +4 -0
- package/ts/relayInit.ts +2 -1
- package/ts/sentlog.ts +3 -3
- package/ts/stream.ts +12 -0
- package/worker/src/slack.ts +12 -0
package/ts/cli.ts
CHANGED
|
@@ -53,9 +53,10 @@ import {
|
|
|
53
53
|
} from "./ask.ts";
|
|
54
54
|
import { applyPrefix, findScope, initScope, isRestricted, listAliases, lookupAlias, readSettings, RestrictedError, setAlias, threadMismatch, unsetAlias, whoSubscribes, type MessagePlace, type Scope } from "./scope.ts";
|
|
55
55
|
import { keepLanePrefix, tickText } from "./tick.ts";
|
|
56
|
+
import { applyIdentity, inAgent, listIdentities, splitWho, userSessionRefusal } from "./identity.ts";
|
|
56
57
|
import { emojiMap, firstDifference, normText, storedAsSent } from "./readback.ts";
|
|
57
58
|
import { choiceHint, choiceQuestionLang } from "./choiceHint.ts";
|
|
58
|
-
import { addOutbox, deliver, listInbox, listOutbox, markDone, overdue, recordFailure, recordSent as recordOutboxSent } from "./inbox.ts";
|
|
59
|
+
import { addOutbox, deliver, listInbox, listOutbox, markDone, markNudged, overdue, recordFailure, recordSent as recordOutboxSent } from "./inbox.ts";
|
|
59
60
|
import {
|
|
60
61
|
POLL_KEYCAPS,
|
|
61
62
|
POLL_MARKER,
|
|
@@ -151,7 +152,7 @@ import {
|
|
|
151
152
|
import { setCacheEnabled } from "./cache.ts";
|
|
152
153
|
import { escapeArg, unescapeArg } from "./escapes.ts";
|
|
153
154
|
import { attachmentLines, parseAttachments } from "./attachments.ts";
|
|
154
|
-
import { botIntentRefusal } from "./botIntent.ts";
|
|
155
|
+
import { botIntentRefusal, isReadMode, isWriteCommand } from "./botIntent.ts";
|
|
155
156
|
import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, maskSecrets, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult, type UnresolvedMention } from "./format.ts";
|
|
156
157
|
import { quietHoursNotice } from "./quietHours.ts";
|
|
157
158
|
import { installSyncConsole } from "./stdout.ts";
|
|
@@ -2634,17 +2635,17 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
2634
2635
|
// a ts split, and `--channel-id` makes the leading "#name" irrelevant to
|
|
2635
2636
|
// resolution, so it never depends on ref's original prefix or an
|
|
2636
2637
|
// embedded thread_ts colliding with the appended message ts.
|
|
2637
|
-
let editCmd = `slack edit "#${channelId}:${lastMsgTs}" "
|
|
2638
|
+
let editCmd = `slack edit "#${channelId}:${lastMsgTs}" "<new text>" --channel-id ${channelId}`;
|
|
2638
2639
|
try {
|
|
2639
2640
|
const permalink = await getPermalink(token, channelId, lastMsgTs, cookie);
|
|
2640
|
-
if (permalink) editCmd = `slack edit "${permalink}" "
|
|
2641
|
+
if (permalink) editCmd = `slack edit "${permalink}" "<new text>"`;
|
|
2641
2642
|
} catch {
|
|
2642
2643
|
// fall back to the --channel-id form above
|
|
2643
2644
|
}
|
|
2644
|
-
console.error(`⚠
|
|
2645
|
-
console.error(`
|
|
2645
|
+
console.error(`⚠ They haven't replied yet (the last message is yours).`);
|
|
2646
|
+
console.error(` To avoid a string of messages, consider editing your last message instead of sending a new one:`);
|
|
2646
2647
|
console.error(` ${editCmd}`);
|
|
2647
|
-
console.error(`
|
|
2648
|
+
console.error(` To send anyway, confirm with --code=XXXX.`);
|
|
2648
2649
|
}
|
|
2649
2650
|
} catch {
|
|
2650
2651
|
// best-effort; a failed self-identity lookup must not block the send
|
|
@@ -3000,7 +3001,7 @@ async function askCollectNotes(token: string, sc: AskNoteScope, after: string |
|
|
|
3000
3001
|
const rep = asRecord((await replies(token, sc.channelId, sc.threadParentTs ?? sc.ts, 100, sc.cookie)) as Json);
|
|
3001
3002
|
for (const m of asArray(rep.messages).map(asRecord)) pool.set(String(m.ts), m);
|
|
3002
3003
|
} catch (e: unknown) {
|
|
3003
|
-
console.error(` (
|
|
3004
|
+
console.error(` (could not read the thread's follow-up notes: ${e instanceof Error ? e.message : String(e)})`);
|
|
3004
3005
|
}
|
|
3005
3006
|
}
|
|
3006
3007
|
if (!sc.threadOnly && !sc.threadParentTs) {
|
|
@@ -3013,7 +3014,7 @@ async function askCollectNotes(token: string, sc: AskNoteScope, after: string |
|
|
|
3013
3014
|
.reduce((min, m) => Math.min(min, Number(m.ts)), Infinity);
|
|
3014
3015
|
for (const m of msgs) if (Number(m.ts) < bound && !pool.has(String(m.ts))) pool.set(String(m.ts), m);
|
|
3015
3016
|
} catch (e: unknown) {
|
|
3016
|
-
console.error(` (DM
|
|
3017
|
+
console.error(` (could not read the DM's follow-up notes: ${e instanceof Error ? e.message : String(e)})`);
|
|
3017
3018
|
}
|
|
3018
3019
|
}
|
|
3019
3020
|
const picked = [...pool.values()]
|
|
@@ -3067,7 +3068,7 @@ async function askEmit(
|
|
|
3067
3068
|
return;
|
|
3068
3069
|
}
|
|
3069
3070
|
if (r.notes.length) {
|
|
3070
|
-
console.error(`
|
|
3071
|
+
console.error(` ${r.notes.length} thread note(s) (separate from the answer):`);
|
|
3071
3072
|
for (const n of r.notes) {
|
|
3072
3073
|
let who = n.user;
|
|
3073
3074
|
try {
|
|
@@ -3081,7 +3082,7 @@ async function askEmit(
|
|
|
3081
3082
|
}
|
|
3082
3083
|
}
|
|
3083
3084
|
if (r.status === "answered") {
|
|
3084
|
-
console.error(`
|
|
3085
|
+
console.error(` To pick up later notes: ${askResumeCommand(o.shown, o.asBot, r.cursor)}`);
|
|
3085
3086
|
}
|
|
3086
3087
|
if (r.answer !== null) console.log(r.answer);
|
|
3087
3088
|
}
|
|
@@ -3178,7 +3179,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
3178
3179
|
await editMessage(token, channelId, ts, next, cookie, true);
|
|
3179
3180
|
} catch (e: unknown) {
|
|
3180
3181
|
// Cosmetic: the poller still refuses to guess, and stderr already said so.
|
|
3181
|
-
console.error(` (
|
|
3182
|
+
console.error(` (could not add the notice to the question text: ${e instanceof Error ? e.message : String(e)})`);
|
|
3182
3183
|
}
|
|
3183
3184
|
}
|
|
3184
3185
|
|
|
@@ -3195,7 +3196,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
3195
3196
|
for (const u of asArray(r.users).filter(isAnswerer)) {
|
|
3196
3197
|
if (otherReported.has(u)) continue;
|
|
3197
3198
|
otherReported.add(u);
|
|
3198
|
-
if (!readOnly) console.error(` (<@${u}>
|
|
3199
|
+
if (!readOnly) console.error(` (<@${u}> pressed ${glyph} other; waiting for their reply)`);
|
|
3199
3200
|
}
|
|
3200
3201
|
}
|
|
3201
3202
|
}
|
|
@@ -3249,14 +3250,14 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
3249
3250
|
if (++pages >= ASK_EXPLAIN_NOTE_MAX_PAGES) return "exists";
|
|
3250
3251
|
}
|
|
3251
3252
|
} catch (e: unknown) {
|
|
3252
|
-
console.error(` (
|
|
3253
|
+
console.error(` (could not read the thread, so the explain-request note has not been posted yet: ${e instanceof Error ? e.message : String(e)})`);
|
|
3253
3254
|
return "unread";
|
|
3254
3255
|
}
|
|
3255
3256
|
try {
|
|
3256
3257
|
await slackSend(token, channelId, copy.explainNote, root, false, cookie, true);
|
|
3257
3258
|
} catch (e: unknown) {
|
|
3258
3259
|
// The press is still reported on stderr; the note is a courtesy.
|
|
3259
|
-
console.error(` (
|
|
3260
|
+
console.error(` (could not post the explain-request note to the thread: ${e instanceof Error ? e.message : String(e)})`);
|
|
3260
3261
|
return "failed";
|
|
3261
3262
|
}
|
|
3262
3263
|
return "posted";
|
|
@@ -3269,7 +3270,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
3269
3270
|
now.push(u);
|
|
3270
3271
|
if (explainReported.has(u)) continue;
|
|
3271
3272
|
explainReported.add(u);
|
|
3272
|
-
if (!readOnly) console.error(` (<@${u}>
|
|
3273
|
+
if (!readOnly) console.error(` (<@${u}> pressed ℹ️ explain — not an answer. Explain in the thread; still waiting for an answer)`);
|
|
3273
3274
|
}
|
|
3274
3275
|
state.explainNow = now;
|
|
3275
3276
|
// Read-only (`--pending`): the press is reported, the note is not posted —
|
|
@@ -3306,8 +3307,8 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
3306
3307
|
for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
|
|
3307
3308
|
const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
|
|
3308
3309
|
if (!readOnly) console.error(multi.length
|
|
3309
|
-
? ` (${glyphs}
|
|
3310
|
-
: ` (
|
|
3310
|
+
? ` (${glyphs} are pressed at once; waiting until it is narrowed to one)`
|
|
3311
|
+
: ` (answers are split: ${glyphs}; waiting until they agree on one)`);
|
|
3311
3312
|
// Whoever is on more than one pill — and ONLY them. Naming everyone on any
|
|
3312
3313
|
// pill told a voter who had pressed exactly one that she had pressed two
|
|
3313
3314
|
// (2026-10-01: a voter on 1️⃣ only was named because someone else was on 1️⃣
|
|
@@ -3467,7 +3468,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3467
3468
|
}
|
|
3468
3469
|
// Who answered never reaches stdout — callers do ANS=$(slack ask --wait ...)
|
|
3469
3470
|
// and must keep getting the answer body alone.
|
|
3470
|
-
if (who) console.error(`
|
|
3471
|
+
if (who) console.error(` Answered by: ${stripTerminalControls(who)}`);
|
|
3471
3472
|
try {
|
|
3472
3473
|
// The body and the chosen option stay; only what no longer applies goes.
|
|
3473
3474
|
// Built from the message as it is NOW (an edit made in Slack after asking
|
|
@@ -3494,7 +3495,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3494
3495
|
// The edit is bookkeeping, not the answer. It fails whenever the resuming
|
|
3495
3496
|
// token is not the one that posted (Slack lets only the author update), and
|
|
3496
3497
|
// that must not cost the caller an answer it already has.
|
|
3497
|
-
console.error(` (
|
|
3498
|
+
console.error(` (failed to update the original message: ${e instanceof Error ? e.message : String(e)})`);
|
|
3498
3499
|
}
|
|
3499
3500
|
}
|
|
3500
3501
|
|
|
@@ -3519,10 +3520,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3519
3520
|
// A dropped poll is expected occasionally; only give up if it keeps failing.
|
|
3520
3521
|
errors++;
|
|
3521
3522
|
if (errors >= ASK_MAX_CONSECUTIVE_ERRORS) {
|
|
3522
|
-
console.error(`Error: Slack
|
|
3523
|
+
console.error(`Error: Slack requests failed ${errors} times in a row: ${e instanceof Error ? e.message : String(e)}`);
|
|
3523
3524
|
process.exit(ASK_EXIT_ERROR);
|
|
3524
3525
|
}
|
|
3525
|
-
console.error(` (Slack
|
|
3526
|
+
console.error(` (Slack error ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, retrying: ${e instanceof Error ? e.message : String(e)})`);
|
|
3526
3527
|
}
|
|
3527
3528
|
}
|
|
3528
3529
|
unchosen = state.unchosen;
|
|
@@ -3565,7 +3566,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3565
3566
|
// only on the run that posted the thread note, so a `--timeout 0` poller is
|
|
3566
3567
|
// woken once per question, not on every look.
|
|
3567
3568
|
if (state.explainPosted && ctx.explainExit) {
|
|
3568
|
-
console.error(`
|
|
3569
|
+
console.error(` An explanation was requested (the question is still open). Explain in the thread, then collect with: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3569
3570
|
if (ctx.json) await askEmit(token, ctx, { status: "explain", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
|
|
3570
3571
|
process.exit(ASK_EXIT_EXPLAIN);
|
|
3571
3572
|
}
|
|
@@ -3581,10 +3582,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3581
3582
|
} catch {
|
|
3582
3583
|
// the id is still a usable answer to "who"
|
|
3583
3584
|
}
|
|
3584
|
-
console.error(` (${stripTerminalControls(who)}
|
|
3585
|
-
console.error(`
|
|
3585
|
+
console.error(` (${stripTerminalControls(who)} replied with something that is not a choice — not a decision. Printing the reply on stdout)`);
|
|
3586
|
+
console.error(` Choices: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
3586
3587
|
console.error(` ${shown}`);
|
|
3587
|
-
console.error(`
|
|
3588
|
+
console.error(` To wait for what comes after this reply: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
|
|
3588
3589
|
// Delivered, not collected: `--pending` will not report THIS reply again,
|
|
3589
3590
|
// but still reports a pill pressed after it.
|
|
3590
3591
|
markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
|
|
@@ -3608,8 +3609,8 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3608
3609
|
// the id is still a usable answer to "who"
|
|
3609
3610
|
}
|
|
3610
3611
|
// Only the ambiguous case gets here — a plain free-text reply exited above.
|
|
3611
|
-
console.error(` (${stripTerminalControls(who)}
|
|
3612
|
-
console.error(`
|
|
3612
|
+
console.error(` (${stripTerminalControls(who)}'s reply matches several choices; waiting until it is narrowed to one)`);
|
|
3613
|
+
console.error(` "${stripTerminalControls(unchosen.text)}"`);
|
|
3613
3614
|
}
|
|
3614
3615
|
// Checked after the first poll, never before it: `--timeout 0` means one
|
|
3615
3616
|
// look, and one look must still be a real look.
|
|
@@ -3630,20 +3631,20 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3630
3631
|
// something to act on.
|
|
3631
3632
|
if (unchosen) {
|
|
3632
3633
|
// Ambiguous only: a plain free-text reply was delivered as exit 5 already.
|
|
3633
|
-
console.error(`Error:
|
|
3634
|
-
console.error(`
|
|
3635
|
-
console.error(`
|
|
3634
|
+
console.error(`Error: there was a reply, but it matches several choices so it cannot be settled (the message is left in place)`);
|
|
3635
|
+
console.error(` Reply: "${stripTerminalControls(unchosen.text)}"`);
|
|
3636
|
+
console.error(` Choices: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
|
|
3636
3637
|
console.error(` ${shown}`);
|
|
3637
|
-
console.error(`
|
|
3638
|
+
console.error(` Reply in the thread, then collect with: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3638
3639
|
if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
|
|
3639
3640
|
process.exit(ASK_EXIT_UNCHOSEN);
|
|
3640
3641
|
}
|
|
3641
3642
|
if (timeout === 0) {
|
|
3642
|
-
console.error(
|
|
3643
|
+
console.error(`No answer yet: ${shown}`);
|
|
3643
3644
|
} else {
|
|
3644
|
-
console.error(`Error: ${timeout}s
|
|
3645
|
+
console.error(`Error: no answer within ${timeout}s (the message is left in place)`);
|
|
3645
3646
|
console.error(` ${shown}`);
|
|
3646
|
-
console.error(`
|
|
3647
|
+
console.error(` To collect later: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3647
3648
|
}
|
|
3648
3649
|
if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
|
|
3649
3650
|
process.exit(ASK_EXIT_TIMEOUT);
|
|
@@ -3827,7 +3828,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3827
3828
|
` Answerable by: ${audienceLabel}`,
|
|
3828
3829
|
` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
|
|
3829
3830
|
...(explain ? [` Explain: ℹ️ pill offered — pressing it asks you to explain; it is never an answer`] : []),
|
|
3830
|
-
...(args.voidAfter ? [` Then
|
|
3831
|
+
...(args.voidAfter ? [` Then void: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) — replaced by this question`] : []),
|
|
3831
3832
|
` Language: ${lang} (${({ flag: "--lang", env: "SLACK_TERM_LANG", readers: "answerers' Slack locale", content: "the question's text", system: "system locale", default: "default" })[picked.source]}; override with --lang)`,
|
|
3832
3833
|
`--------------------------------────────────`,
|
|
3833
3834
|
], recipientTz);
|
|
@@ -3864,8 +3865,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3864
3865
|
const why = e instanceof Error ? e.message : String(e);
|
|
3865
3866
|
// The marker is cosmetic + searchability; a keycap only costs the
|
|
3866
3867
|
// answerer a trip to the emoji picker. Neither abandons a posted question.
|
|
3867
|
-
if (name === seedMarker) console.error(` (marker ${name}
|
|
3868
|
-
else console.error(` (
|
|
3868
|
+
if (name === seedMarker) console.error(` (could not add marker ${name}: ${why})`);
|
|
3869
|
+
else console.error(` (could not add reaction ${name}: ${why})`);
|
|
3869
3870
|
},
|
|
3870
3871
|
);
|
|
3871
3872
|
|
|
@@ -3882,7 +3883,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3882
3883
|
// two open questions, which is reported loudly — never a missing one.
|
|
3883
3884
|
if (args.voidAfter) {
|
|
3884
3885
|
const ok = await askApplyVoid(token, args.voidAfter, args.voidAfter.reason, shown, cookie);
|
|
3885
|
-
if (!ok) console.error(` ⚠
|
|
3886
|
+
if (!ok) console.error(` ⚠ The new question is posted, but the old one could not be voided — do it by hand: slack ask --void='${stripTerminalControls(args.voidAfter.link)}' --superseded-by='${shown}'`);
|
|
3886
3887
|
}
|
|
3887
3888
|
|
|
3888
3889
|
if (!args.wait) {
|
|
@@ -3896,7 +3897,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3896
3897
|
}
|
|
3897
3898
|
|
|
3898
3899
|
const timeout = args.timeout ?? 3600;
|
|
3899
|
-
console.error(`
|
|
3900
|
+
console.error(` Waiting for an answer (up to ${timeout}s)… Ctrl-C to stop`);
|
|
3900
3901
|
|
|
3901
3902
|
const ctx: AskWaitCtx = {
|
|
3902
3903
|
channelId,
|
|
@@ -3927,9 +3928,9 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3927
3928
|
|
|
3928
3929
|
/** Say on stderr that a question is void, and where its replacement is. */
|
|
3929
3930
|
function askReportVoid(p: { reason: string; supersededBy?: string }, shown: string): void {
|
|
3930
|
-
console.error(
|
|
3931
|
+
console.error(`This question is void — it will never be answered. Stop waiting on it: ${stripTerminalControls(shown)}`);
|
|
3931
3932
|
if (p.reason) console.error(` ${stripTerminalControls(p.reason)}`);
|
|
3932
|
-
if (p.supersededBy) console.error(`
|
|
3933
|
+
if (p.supersededBy) console.error(` New question: ${stripTerminalControls(p.supersededBy)}`);
|
|
3933
3934
|
}
|
|
3934
3935
|
|
|
3935
3936
|
/** Find the one message a permalink (or `C…:ts`) names. A question posted into
|
|
@@ -3984,20 +3985,20 @@ async function askLoadOwnOpen(token: string, link: string, self: Self | null, as
|
|
|
3984
3985
|
Promise<(AskVoidTarget & { msg: Record<string, Json>; threadTs?: string }) | "void" | null> {
|
|
3985
3986
|
const shown = stripTerminalControls(link);
|
|
3986
3987
|
const got = await askFetchByLink(token, link, cookie);
|
|
3987
|
-
if (!got) { console.error(`✗ ${shown}: permalink (
|
|
3988
|
-
if (!got.msg) { console.error(`✗ ${shown}:
|
|
3988
|
+
if (!got) { console.error(`✗ ${shown}: not a permalink (or 'C…:1700000000.000100')`); return null; }
|
|
3989
|
+
if (!got.msg) { console.error(`✗ ${shown}: message not found`); return null; }
|
|
3989
3990
|
const msg = got.msg;
|
|
3990
3991
|
const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
|
|
3991
3992
|
if (!own) {
|
|
3992
|
-
console.error(`✗ ${shown}:
|
|
3993
|
-
`${asBot ? "" : " — bot
|
|
3993
|
+
console.error(`✗ ${shown}: not a question posted by you (${senderLabel(self, asBot)})` +
|
|
3994
|
+
`${asBot ? "" : " — if the bot posted it, use --as-bot"}`);
|
|
3994
3995
|
return null;
|
|
3995
3996
|
}
|
|
3996
3997
|
const text = typeof msg.text === "string" ? msg.text : "";
|
|
3997
3998
|
const parsed = askParseMessage(text);
|
|
3998
3999
|
if (parsed.kind === "void") return "void";
|
|
3999
|
-
if (parsed.kind === "resolved") { console.error(`✗ ${shown}:
|
|
4000
|
-
if (parsed.kind !== "open") { console.error(`✗ ${shown}: \`slack ask\`
|
|
4000
|
+
if (parsed.kind === "resolved") { console.error(`✗ ${shown}: already answered (answer: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")}) — cannot be changed`); return null; }
|
|
4001
|
+
if (parsed.kind !== "open") { console.error(`✗ ${shown}: cannot be read as a \`slack ask\` question — ${askExplainReject(text)}`); return null; }
|
|
4001
4002
|
const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, seedMarker: askSeedMarker(text) ?? ASK_MARKER, msg };
|
|
4002
4003
|
if (got.threadTs) out.threadTs = got.threadTs;
|
|
4003
4004
|
return out;
|
|
@@ -4041,7 +4042,7 @@ async function cmdAskEdit(token: string, args: {
|
|
|
4041
4042
|
}): Promise<void> {
|
|
4042
4043
|
const self = await selfIdentity(token, args.cookie);
|
|
4043
4044
|
const t = await askLoadOwnOpen(token, args.link, self, args.asBot, args.cookie);
|
|
4044
|
-
if (t === "void") { console.error(`✗ ${stripTerminalControls(args.link)}:
|
|
4045
|
+
if (t === "void") { console.error(`✗ ${stripTerminalControls(args.link)}: a voided question cannot be edited`); process.exit(ASK_EXIT_ERROR); }
|
|
4045
4046
|
if (!t) process.exit(ASK_EXIT_ERROR);
|
|
4046
4047
|
const old = t.parsed;
|
|
4047
4048
|
const encode = async (raw: string): Promise<string> => {
|
|
@@ -4057,8 +4058,8 @@ async function cmdAskEdit(token: string, args: {
|
|
|
4057
4058
|
const choicesChanged = JSON.stringify(choices.map(askFlatten)) !== JSON.stringify(oldChoices.map(askFlatten));
|
|
4058
4059
|
if (choicesChanged && await askHasResponses(token, t, self, args.cookie)) {
|
|
4059
4060
|
console.error(
|
|
4060
|
-
`✗
|
|
4061
|
-
`
|
|
4061
|
+
`✗ This question already has responses (reactions or replies) — changing the choices would change what the pressed reactions mean.\n` +
|
|
4062
|
+
` The question and body can still be changed. To change the choices, void it and ask again:\n` +
|
|
4062
4063
|
` slack ask --void='${stripTerminalControls(args.link)}' --reask${args.asBot ? " --as-bot" : ""} …`,
|
|
4063
4064
|
);
|
|
4064
4065
|
process.exit(ASK_EXIT_ERROR);
|
|
@@ -4069,7 +4070,7 @@ async function cmdAskEdit(token: string, args: {
|
|
|
4069
4070
|
const tagged = [...`${question}\n${body}`.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].some((m) => m[1] !== self?.userId);
|
|
4070
4071
|
const addressed = tagged || /<!(?:here|channel|everyone)/.test(`${question}\n${body}`) || t.channelId.startsWith("D");
|
|
4071
4072
|
if (!addressed) {
|
|
4072
|
-
console.error(`✗
|
|
4073
|
+
console.error(`✗ The question would be addressed to nobody — keep an @mention of who can answer`);
|
|
4073
4074
|
process.exit(ASK_EXIT_ERROR);
|
|
4074
4075
|
}
|
|
4075
4076
|
const lang = args.lang ? askResolveLang({ flag: args.lang })?.lang ?? old.lang : old.lang;
|
|
@@ -4083,7 +4084,7 @@ async function cmdAskEdit(token: string, args: {
|
|
|
4083
4084
|
if (invalid.length) next = applyInvalidNotice(next, invalid, lang);
|
|
4084
4085
|
guardUrlBoundaries(next, args.allowUrlAdjacent);
|
|
4085
4086
|
if (next === oldText) {
|
|
4086
|
-
console.error("
|
|
4087
|
+
console.error("No changes.");
|
|
4087
4088
|
return;
|
|
4088
4089
|
}
|
|
4089
4090
|
|
|
@@ -4123,7 +4124,7 @@ async function cmdAskEdit(token: string, args: {
|
|
|
4123
4124
|
await seedReactionsInOrder(
|
|
4124
4125
|
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail(seedMarker)],
|
|
4125
4126
|
(name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
|
|
4126
|
-
(name, e) => console.error(` (
|
|
4127
|
+
(name, e) => console.error(` (could not add reaction ${name}: ${e instanceof Error ? e.message : String(e)})`),
|
|
4127
4128
|
);
|
|
4128
4129
|
} else if (oldSeed !== seedMarker) {
|
|
4129
4130
|
// An old ❓ pill edited without touching its choices: the body now lists
|
|
@@ -4133,10 +4134,10 @@ async function cmdAskEdit(token: string, args: {
|
|
|
4133
4134
|
await reactionAdd(token, t.channelId, t.ts, seedMarker, args.cookie);
|
|
4134
4135
|
await reactionRemove(token, t.channelId, t.ts, oldSeed, args.cookie);
|
|
4135
4136
|
} catch (e: unknown) {
|
|
4136
|
-
console.error(` (marker
|
|
4137
|
+
console.error(` (could not swap the marker to ${seedMarker}: ${e instanceof Error ? e.message : String(e)})`);
|
|
4137
4138
|
}
|
|
4138
4139
|
}
|
|
4139
|
-
console.error(`✓
|
|
4140
|
+
console.error(`✓ Edited: ${stripTerminalControls(args.link)}`);
|
|
4140
4141
|
}
|
|
4141
4142
|
|
|
4142
4143
|
/** `slack ask --ls [--stale <dur>] [--state …] [--channel …] [--json]` — the
|
|
@@ -4213,7 +4214,7 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
|
|
|
4213
4214
|
for (const bot of [false, true]) {
|
|
4214
4215
|
const mine = open.filter((it) => it.asBot === bot);
|
|
4215
4216
|
if (!mine.length) continue;
|
|
4216
|
-
console.error(
|
|
4217
|
+
console.error(`To void them (${mine.length}, ${bot ? "bot" : "user"}):\n slack ask ${mine.map((it) => `--void='${it.link}'`).join(" ")} --reason 'stale'${bot ? " --as-bot" : ""}`);
|
|
4217
4218
|
}
|
|
4218
4219
|
}
|
|
4219
4220
|
}
|
|
@@ -4279,7 +4280,7 @@ async function cmdAskPending(
|
|
|
4279
4280
|
const asBot = !!row.as_bot;
|
|
4280
4281
|
const token = asBot ? tokens.bot : tokens.user;
|
|
4281
4282
|
if (!token) {
|
|
4282
|
-
problems.push(`${link}: ${asBot ? "--as-bot
|
|
4283
|
+
problems.push(`${link}: ${asBot ? "asked with --as-bot, but there is no bot token" : "no user token"}`);
|
|
4283
4284
|
continue;
|
|
4284
4285
|
}
|
|
4285
4286
|
const cookie = asBot ? undefined : tokens.cookie;
|
|
@@ -4290,15 +4291,15 @@ async function cmdAskPending(
|
|
|
4290
4291
|
try {
|
|
4291
4292
|
const loc = { channelId: row.channel, ts: row.ts, threadTs: row.thread_ts ?? undefined };
|
|
4292
4293
|
const got = await askFetchAt(token, loc, cookie);
|
|
4293
|
-
if (!got.msg) { problems.push(`${link}:
|
|
4294
|
+
if (!got.msg) { problems.push(`${link}: message not found (deleted?)`); continue; }
|
|
4294
4295
|
const q = await askReadBack(token, { channelId: row.channel, ts: row.ts, msg: got.msg }, cookie);
|
|
4295
4296
|
const parsed = q.parsed;
|
|
4296
4297
|
// ✅ already stamped: some `--wait`/`--waitFor` collected it (another
|
|
4297
4298
|
// machine, another token, or a build before collection was logged).
|
|
4298
4299
|
if (parsed.kind === "resolved") { count.resolved++; continue; }
|
|
4299
4300
|
if (parsed.kind === "void") { count.void++; continue; }
|
|
4300
|
-
if (parsed.kind === "other") { problems.push(`${link}: \`slack ask\`
|
|
4301
|
-
if (!q.audience.size && !q.broadcast) { problems.push(`${link}:
|
|
4301
|
+
if (parsed.kind === "other") { problems.push(`${link}: cannot be read as a \`slack ask\` question — ${askExplainReject(q.text)}`); continue; }
|
|
4302
|
+
if (!q.audience.size && !q.broadcast) { problems.push(`${link}: the question is addressed to nobody`); continue; }
|
|
4302
4303
|
const ctx = askWaitCtxFrom(q, parsed, loc, { timeout: 0, link, asBot, json: false, cookie, after });
|
|
4303
4304
|
const poller = askPoller(token, ctx, true);
|
|
4304
4305
|
const found = await poller.poll();
|
|
@@ -4307,13 +4308,13 @@ async function cmdAskPending(
|
|
|
4307
4308
|
const collect = askResumeCommand(link, asBot, after);
|
|
4308
4309
|
if (poller.state.explainNow.length) {
|
|
4309
4310
|
count.explain++;
|
|
4310
|
-
explainAsked.push(`${link}: ℹ️
|
|
4311
|
+
explainAsked.push(`${link}: ℹ️ an explanation was requested (${poller.state.explainNow.map((u) => `<@${u}>`).join(" ")}) — explain in the thread`);
|
|
4311
4312
|
}
|
|
4312
4313
|
const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
|
|
4313
4314
|
if (found) {
|
|
4314
4315
|
pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
|
|
4315
4316
|
} else if (unchosen && !unchosen.ambiguous && unchosen.text !== row.delivered_answer) {
|
|
4316
|
-
pending.push({ ...base, answer: unchosen.text, how: "
|
|
4317
|
+
pending.push({ ...base, answer: unchosen.text, how: "reply (not a choice)", freeText: true });
|
|
4317
4318
|
} else if (unchosen?.ambiguous) {
|
|
4318
4319
|
count.ambiguous++;
|
|
4319
4320
|
} else {
|
|
@@ -4351,7 +4352,7 @@ async function cmdAskPending(
|
|
|
4351
4352
|
for (const it of items) {
|
|
4352
4353
|
console.log(` ${stripTerminalControls(it.link)} (${formatYmdHm(it.row.sent_at / 1000)})`);
|
|
4353
4354
|
console.log(` Q: ${stripTerminalControls(askFlatten(it.question))}`);
|
|
4354
|
-
console.log(` A: ${stripTerminalControls(askFlatten(it.answer))} — ${stripTerminalControls(it.how)}${it.freeText ? " —
|
|
4355
|
+
console.log(` A: ${stripTerminalControls(askFlatten(it.answer))} — ${stripTerminalControls(it.how)}${it.freeText ? " — not a decision" : ""}`);
|
|
4355
4356
|
console.log(` collect: ${stripTerminalControls(it.collect)}`);
|
|
4356
4357
|
}
|
|
4357
4358
|
}
|
|
@@ -4359,15 +4360,15 @@ async function cmdAskPending(
|
|
|
4359
4360
|
for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
|
|
4360
4361
|
for (const p of explainAsked) console.error(` (${stripTerminalControls(p)})`);
|
|
4361
4362
|
for (const [team, n] of otherTeams) {
|
|
4362
|
-
console.error(` (${n}
|
|
4363
|
+
console.error(` (${n} question(s) belong to another workspace "${stripTerminalControls(team)}" — check with slack -w <name> ask --pending)`);
|
|
4363
4364
|
}
|
|
4364
4365
|
console.error(
|
|
4365
|
-
`${pending.length}
|
|
4366
|
-
(count.ambiguous ? `, ${count.ambiguous}
|
|
4367
|
-
(count.resolved ? `, ${count.resolved}
|
|
4368
|
-
(count.void ? `, ${count.void}
|
|
4369
|
-
(count.explain ? `, ${count.explain}
|
|
4370
|
-
` — ${rows.length}
|
|
4366
|
+
`${pending.length} answered but not collected, ${count.unanswered} unanswered` +
|
|
4367
|
+
(count.ambiguous ? `, ${count.ambiguous} with a reply matching several choices (unsettled)` : "") +
|
|
4368
|
+
(count.resolved ? `, ${count.resolved} ✅ already collected` : "") +
|
|
4369
|
+
(count.void ? `, ${count.void} voided` : "") +
|
|
4370
|
+
(count.explain ? `, ${count.explain} with an ℹ️ explain request` : "") +
|
|
4371
|
+
` — checked ${rows.length} uncollected question(s) (log: ${sentDbPath()})`,
|
|
4371
4372
|
);
|
|
4372
4373
|
}
|
|
4373
4374
|
|
|
@@ -4391,7 +4392,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
|
|
|
4391
4392
|
const next = askBuildVoidText(t.parsed.question, reason, t.parsed.lang, t.parsed.body || undefined, supersededBy);
|
|
4392
4393
|
await editMessage(token, t.channelId, t.ts, next, cookie, true);
|
|
4393
4394
|
} catch (e: unknown) {
|
|
4394
|
-
console.error(`✗ ${shown}:
|
|
4395
|
+
console.error(`✗ ${shown}: failed to rewrite: ${e instanceof Error ? e.message : String(e)}`);
|
|
4395
4396
|
return false;
|
|
4396
4397
|
}
|
|
4397
4398
|
for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), t.seedMarker, ...(t.parsed.explain ? [ASK_EXPLAIN_MARKER] : [])]) {
|
|
@@ -4400,9 +4401,9 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
|
|
|
4400
4401
|
try {
|
|
4401
4402
|
await reactionAdd(token, t.channelId, t.ts, ASK_VOID_MARKER, cookie);
|
|
4402
4403
|
} catch (e: unknown) {
|
|
4403
|
-
console.error(` (${shown}: 🚫
|
|
4404
|
+
console.error(` (${shown}: could not add the 🚫 reaction: ${e instanceof Error ? e.message : String(e)})`);
|
|
4404
4405
|
}
|
|
4405
|
-
console.error(`✓
|
|
4406
|
+
console.error(`✓ Voided: ${shown}`);
|
|
4406
4407
|
return true;
|
|
4407
4408
|
}
|
|
4408
4409
|
|
|
@@ -4442,11 +4443,11 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4442
4443
|
process.exit(ASK_EXIT_ERROR);
|
|
4443
4444
|
}
|
|
4444
4445
|
} catch (e: unknown) {
|
|
4445
|
-
console.error(`Error: --superseded-by
|
|
4446
|
+
console.error(`Error: cannot check --superseded-by: ${shown} (${e instanceof Error ? e.message : String(e)})`);
|
|
4446
4447
|
process.exit(ASK_EXIT_ERROR);
|
|
4447
4448
|
}
|
|
4448
4449
|
if (!found) {
|
|
4449
|
-
console.error(`Error: --superseded-by
|
|
4450
|
+
console.error(`Error: --superseded-by message not found: ${shown}`);
|
|
4450
4451
|
process.exit(ASK_EXIT_ERROR);
|
|
4451
4452
|
}
|
|
4452
4453
|
}
|
|
@@ -4456,21 +4457,21 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4456
4457
|
for (const link of args.links) {
|
|
4457
4458
|
const shown = stripTerminalControls(link);
|
|
4458
4459
|
const got = await askFetchByLink(token, link, args.cookie);
|
|
4459
|
-
if (!got) { console.error(`✗ ${shown}: permalink (
|
|
4460
|
-
if (!got.msg) { console.error(`✗ ${shown}:
|
|
4460
|
+
if (!got) { console.error(`✗ ${shown}: not a permalink (or 'C…:1700000000.000100')`); refused++; continue; }
|
|
4461
|
+
if (!got.msg) { console.error(`✗ ${shown}: message not found`); refused++; continue; }
|
|
4461
4462
|
const msg = got.msg;
|
|
4462
4463
|
const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
|
|
4463
4464
|
if (!own) {
|
|
4464
|
-
console.error(`✗ ${shown}:
|
|
4465
|
-
`${args.asBot ? "" : " — bot
|
|
4465
|
+
console.error(`✗ ${shown}: not a question posted by you (${senderLabel(self, args.asBot)}), so it cannot be voided` +
|
|
4466
|
+
`${args.asBot ? "" : " — if the bot posted it, use --as-bot"}`);
|
|
4466
4467
|
refused++;
|
|
4467
4468
|
continue;
|
|
4468
4469
|
}
|
|
4469
4470
|
const text = typeof msg.text === "string" ? msg.text : "";
|
|
4470
4471
|
let parsed = askParseMessage(text);
|
|
4471
4472
|
let unreadable = false;
|
|
4472
|
-
if (parsed.kind === "void") { console.error(`- ${shown}:
|
|
4473
|
-
if (parsed.kind === "resolved") { console.error(`✗ ${shown}:
|
|
4473
|
+
if (parsed.kind === "void") { console.error(`- ${shown}: already voided (nothing to do)`); continue; }
|
|
4474
|
+
if (parsed.kind === "resolved") { console.error(`✗ ${shown}: an answered question cannot be voided (answer: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})`); refused++; continue; }
|
|
4474
4475
|
if (parsed.kind !== "open") {
|
|
4475
4476
|
// Our own ❓ question whose layout no longer parses (2026-10-01: a
|
|
4476
4477
|
// `slack edit` flattened one; it could then be neither collected nor
|
|
@@ -4478,7 +4479,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4478
4479
|
// with it, so it is voided from a best-effort read — question and body
|
|
4479
4480
|
// kept as they are, our own seeded pills removed — and the gate says so.
|
|
4480
4481
|
const salvage = askSalvageUnreadable(text);
|
|
4481
|
-
if (!salvage) { console.error(`✗ ${shown}: \`slack ask\`
|
|
4482
|
+
if (!salvage) { console.error(`✗ ${shown}: cannot be read as a \`slack ask\` question — ${askExplainReject(text)}`); refused++; continue; }
|
|
4482
4483
|
const seeded = asArray(msg.reactions).map(asRecord)
|
|
4483
4484
|
.filter((r) => asArray(r.users).includes(self?.userId ?? "\u0000"))
|
|
4484
4485
|
.map((r) => ASK_KEYCAPS.findIndex((k) => k.name === r.name))
|
|
@@ -4505,14 +4506,14 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4505
4506
|
targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, seedMarker, pressed, ...(unreadable ? { unreadable } : {}) });
|
|
4506
4507
|
}
|
|
4507
4508
|
if (!targets.length) {
|
|
4508
|
-
console.error(refused ? "
|
|
4509
|
+
console.error(refused ? "No questions can be voided." : "Nothing to do.");
|
|
4509
4510
|
process.exit(refused ? ASK_EXIT_ERROR : 0);
|
|
4510
4511
|
}
|
|
4511
4512
|
|
|
4512
4513
|
const code = safetyCode("ask-void", ...targets.map((t) => `${t.channelId}:${t.ts}`), args.reason, args.supersededBy ?? "", self?.userId ?? "");
|
|
4513
4514
|
if (args.code !== code) {
|
|
4514
4515
|
requireCode(args.code, code, [
|
|
4515
|
-
`---
|
|
4516
|
+
`--- Void -----------------------------------------`,
|
|
4516
4517
|
fromLine(self, { asBot: args.asBot }),
|
|
4517
4518
|
` Reason: ${args.reason ? stripTerminalControls(args.reason) : "(none)"}`,
|
|
4518
4519
|
...(args.supersededBy ? [` Replaced: ${stripTerminalControls(args.supersededBy)}`] : []),
|
|
@@ -4567,7 +4568,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
4567
4568
|
|
|
4568
4569
|
const got = await askFetchAt(token, { channelId, ts, threadTs }, args.cookie);
|
|
4569
4570
|
if (!got.msg) {
|
|
4570
|
-
console.error(`Error:
|
|
4571
|
+
console.error(`Error: message not found: ${stripTerminalControls(args.link)}`);
|
|
4571
4572
|
process.exit(ASK_EXIT_ERROR);
|
|
4572
4573
|
}
|
|
4573
4574
|
const msg = got.msg;
|
|
@@ -4575,12 +4576,12 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
4575
4576
|
const { text, parsed, audience, broadcast, askerUserId, askerBotId } = q;
|
|
4576
4577
|
if (parsed.kind === "other") {
|
|
4577
4578
|
console.error(
|
|
4578
|
-
`Error:
|
|
4579
|
-
`
|
|
4579
|
+
`Error: this cannot be read as a \`slack ask\` question.\n` +
|
|
4580
|
+
` Reason: ${askExplainReject(text)}\n` +
|
|
4580
4581
|
` ${stripTerminalControls(args.link)}\n` +
|
|
4581
4582
|
// The pills are still readable by hand, so a failure here is not a dead
|
|
4582
4583
|
// end — say so, because the reported cost was not knowing that.
|
|
4583
|
-
` (
|
|
4584
|
+
` (the pressed reactions are still there: check with slack read '${stripTerminalControls(args.link)}')`,
|
|
4584
4585
|
);
|
|
4585
4586
|
process.exit(ASK_EXIT_ERROR);
|
|
4586
4587
|
}
|
|
@@ -4612,7 +4613,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
4612
4613
|
// What must not come back is the reply that IS the answer. The ✅ stamp says
|
|
4613
4614
|
// whether the answer was a reply, and which number it picked: the first
|
|
4614
4615
|
// audience reply before the ✅ edit that reads as that answer is it.
|
|
4615
|
-
console.error(`✓
|
|
4616
|
+
console.error(`✓ Answered: ${stripTerminalControls(parsed.question)}`);
|
|
4616
4617
|
const editedTs = typeof asRecord(msg.edited).ts === "string" ? String(asRecord(msg.edited).ts) : undefined;
|
|
4617
4618
|
const how = askResolvedHow(text);
|
|
4618
4619
|
let answerSkipped = false;
|
|
@@ -4645,12 +4646,12 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
4645
4646
|
}
|
|
4646
4647
|
|
|
4647
4648
|
if (!audience.size && !broadcast) {
|
|
4648
|
-
console.error(`Error:
|
|
4649
|
+
console.error(`Error: this question is addressed to nobody, so valid answerers cannot be determined: ${stripTerminalControls(args.link)}`);
|
|
4649
4650
|
process.exit(ASK_EXIT_ERROR);
|
|
4650
4651
|
}
|
|
4651
4652
|
|
|
4652
4653
|
if (args.timeout > 0) {
|
|
4653
|
-
console.error(`
|
|
4654
|
+
console.error(` Waiting for an answer (up to ${args.timeout}s)… Ctrl-C to stop`);
|
|
4654
4655
|
}
|
|
4655
4656
|
await askWaitForAnswer(token, askWaitCtxFrom(q, parsed, { channelId, ts, threadTs }, args));
|
|
4656
4657
|
}
|
|
@@ -4855,9 +4856,9 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
|
|
|
4855
4856
|
fromLine(self, { asBot: args.asBot }),
|
|
4856
4857
|
` → ${dest}${threadTs ? " — THREAD REPLY" : " — NEW top-level message"}`,
|
|
4857
4858
|
` Question: ${question}`,
|
|
4858
|
-
` Rule: ${multi ? "
|
|
4859
|
+
` Rule: ${multi ? "any number of votes per person (--multi)" : "one vote per person"}${until ? ` — closes ${until}` : ""}`,
|
|
4859
4860
|
// Said out loud at the gate: this is the surprising half of the command.
|
|
4860
|
-
` Note:
|
|
4861
|
+
` Note: your own votes are not counted (the pills are seeds you added, so they can't be told apart)`,
|
|
4861
4862
|
`--------------------------------────────────`,
|
|
4862
4863
|
], recipientTz);
|
|
4863
4864
|
}
|
|
@@ -4881,8 +4882,8 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
|
|
|
4881
4882
|
const why = e instanceof Error ? e.message : String(e);
|
|
4882
4883
|
// A missing seed costs a voter a trip to the emoji picker; it does not
|
|
4883
4884
|
// invalidate a poll that is already posted.
|
|
4884
|
-
if (name === POLL_MARKER) console.error(` (marker ${name}
|
|
4885
|
-
else console.error(` (
|
|
4885
|
+
if (name === POLL_MARKER) console.error(` (could not add marker ${name}: ${why})`);
|
|
4886
|
+
else console.error(` (could not add reaction ${name}: ${why})`);
|
|
4886
4887
|
},
|
|
4887
4888
|
);
|
|
4888
4889
|
|
|
@@ -4896,7 +4897,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
|
|
|
4896
4897
|
console.error(`✓ Posted: ${shown}`);
|
|
4897
4898
|
attr.record({ team: self?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
|
|
4898
4899
|
console.log(pollResultsCommand(shown));
|
|
4899
|
-
console.error(`
|
|
4900
|
+
console.error(` To close: slack poll --close='${shown}'${args.asBot ? " --as-bot" : ""}`);
|
|
4900
4901
|
}
|
|
4901
4902
|
|
|
4902
4903
|
/** Locate + read back one posted poll. Shared by `--results` and `--close`,
|
|
@@ -4935,7 +4936,7 @@ async function pollFetch(token: string, link: string, cookie?: string): Promise<
|
|
|
4935
4936
|
msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
|
|
4936
4937
|
}
|
|
4937
4938
|
if (!msg) {
|
|
4938
|
-
console.error(`Error:
|
|
4939
|
+
console.error(`Error: message not found: ${stripTerminalControls(link)}`);
|
|
4939
4940
|
process.exit(POLL_EXIT_ERROR);
|
|
4940
4941
|
}
|
|
4941
4942
|
return { channelId, ts, msg };
|
|
@@ -4959,7 +4960,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
|
|
|
4959
4960
|
const parsed = pollParseMessage(text);
|
|
4960
4961
|
if (parsed.kind === "other") {
|
|
4961
4962
|
console.error(
|
|
4962
|
-
`Error:
|
|
4963
|
+
`Error: this is not a \`slack poll\` poll (don't know how to count its votes).\n` +
|
|
4963
4964
|
` ${stripTerminalControls(args.link)}`,
|
|
4964
4965
|
);
|
|
4965
4966
|
process.exit(POLL_EXIT_ERROR);
|
|
@@ -4967,7 +4968,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
|
|
|
4967
4968
|
if (parsed.kind === "closed") {
|
|
4968
4969
|
// Already closed: report what was RECORDED, never a fresh count. A pill
|
|
4969
4970
|
// pressed after closing would otherwise silently change a settled result.
|
|
4970
|
-
console.error(`✓
|
|
4971
|
+
console.error(`✓ Poll closed: ${stripTerminalControls(parsed.question)}`);
|
|
4971
4972
|
for (const line of pollFormatTallies(parsed.tallies)) console.log(line);
|
|
4972
4973
|
return;
|
|
4973
4974
|
}
|
|
@@ -4980,10 +4981,10 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
|
|
|
4980
4981
|
const reactions = await reactionsGet(token, channelId, ts, args.cookie);
|
|
4981
4982
|
const counted = pollTally(parsed.choices, reactions, exclude, parsed.multi);
|
|
4982
4983
|
|
|
4983
|
-
console.error(`${stripTerminalControls(parsed.question)}${parsed.deadline ? ` (
|
|
4984
|
+
console.error(`${stripTerminalControls(parsed.question)}${parsed.deadline ? ` (closes ${parsed.deadline})` : ""}`);
|
|
4984
4985
|
for (const line of pollFormatTallies(counted.tallies)) console.log(line);
|
|
4985
4986
|
const total = counted.tallies.reduce((n, t) => n + t.count, 0);
|
|
4986
|
-
console.error(`
|
|
4987
|
+
console.error(` ${total} vote(s) total${parsed.multi ? " (multiple choice)" : ""}`);
|
|
4987
4988
|
if (args.names) {
|
|
4988
4989
|
for (let i = 0; i < counted.voters.length; i++) {
|
|
4989
4990
|
if (!counted.voters[i]!.length) continue;
|
|
@@ -5002,8 +5003,8 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
|
|
|
5002
5003
|
await editMessage(args.writeToken ?? token, channelId, ts, nextText, args.writeToken ? args.writeCookie : args.cookie, true);
|
|
5003
5004
|
} catch (e: unknown) {
|
|
5004
5005
|
console.error(
|
|
5005
|
-
` (
|
|
5006
|
-
`
|
|
5006
|
+
` (could not add the notice to the poll text: ${e instanceof Error ? e.message : String(e)})\n` +
|
|
5007
|
+
` Only the poster can rewrite a message — if the bot posted the poll, add --as-bot.`,
|
|
5007
5008
|
);
|
|
5008
5009
|
}
|
|
5009
5010
|
}
|
|
@@ -5011,7 +5012,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
|
|
|
5011
5012
|
// Not counted for anything, and said so out loud: a reaction carries no
|
|
5012
5013
|
// timestamp anywhere in the Slack API, so "the latest one" does not exist.
|
|
5013
5014
|
const names = await Promise.all(counted.ambiguous.map((u) => userName(token, u, args.cookie)));
|
|
5014
|
-
console.error(`⚠
|
|
5015
|
+
console.error(`⚠ One vote per person, but some people pressed several (invalid votes): ${names.map((n) => `@${stripTerminalControls(n)}`).join(", ")}`);
|
|
5015
5016
|
}
|
|
5016
5017
|
|
|
5017
5018
|
if (!args.close) return;
|
|
@@ -5028,7 +5029,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
|
|
|
5028
5029
|
} catch {
|
|
5029
5030
|
// Cosmetic: the 📊 body is what makes it closed.
|
|
5030
5031
|
}
|
|
5031
|
-
console.error(`✓
|
|
5032
|
+
console.error(`✓ Closed the poll: ${stripTerminalControls(args.link)}`);
|
|
5032
5033
|
}
|
|
5033
5034
|
|
|
5034
5035
|
async function cmdDoctor(token: string): Promise<void> {
|
|
@@ -5851,7 +5852,19 @@ async function main(): Promise<void> {
|
|
|
5851
5852
|
await cmdTodoLs(tok(argv), opts);
|
|
5852
5853
|
};
|
|
5853
5854
|
|
|
5854
|
-
|
|
5855
|
+
// `slack @<who> <command> …`: act as a named identity (ts/identity.ts).
|
|
5856
|
+
const { who, rest: cliArgs } = splitWho(hideBin(process.argv));
|
|
5857
|
+
let whoKind: "bot" | "user" | undefined;
|
|
5858
|
+
if (who) {
|
|
5859
|
+
const r = applyIdentity(who);
|
|
5860
|
+
if ("error" in r) {
|
|
5861
|
+
console.error(`Error: ${r.error}`);
|
|
5862
|
+
process.exit(2);
|
|
5863
|
+
}
|
|
5864
|
+
whoKind = r.kind;
|
|
5865
|
+
}
|
|
5866
|
+
|
|
5867
|
+
await yargs(cliArgs)
|
|
5855
5868
|
.scriptName("slack")
|
|
5856
5869
|
// A thrown handler error (e.g. a Slack API failure) is a runtime error, not a usage
|
|
5857
5870
|
// mistake — print just the clean one-line message, never the command's --help block or a
|
|
@@ -5869,8 +5882,15 @@ async function main(): Promise<void> {
|
|
|
5869
5882
|
process.exit(1);
|
|
5870
5883
|
})
|
|
5871
5884
|
.option("workspace", { alias: "w", type: "string", describe: "Workspace name" })
|
|
5885
|
+
.option("as-user", { type: "boolean", describe: "Act as the user session on purpose (needed for writes from inside an agent, or when several identities exist)" })
|
|
5872
5886
|
.middleware(async (argv) => {
|
|
5873
5887
|
const cmd = String((argv._ ?? [])[0] ?? "");
|
|
5888
|
+
// `@bot`: commands that have --as-bot act as the bot; `@user`: never.
|
|
5889
|
+
if (whoKind === "bot" && "as-bot" in argv) { argv["as-bot"] = true; (argv as Record<string, unknown>).asBot = true; }
|
|
5890
|
+
if (whoKind === "user" && argv["as-bot"] === true) {
|
|
5891
|
+
console.error(`Error: @${who} is a user identity, but --as-bot was given`);
|
|
5892
|
+
process.exit(2);
|
|
5893
|
+
}
|
|
5874
5894
|
// `sent` reads only the local log — no token, no network. `relay` writes
|
|
5875
5895
|
// local files (init) or talks to the user's own relay (health): no Slack token.
|
|
5876
5896
|
if (!cmd || cmd === "auth" || cmd === "login" || cmd === "agent" || cmd === "sent" || cmd === "relay") return;
|
|
@@ -5891,6 +5911,17 @@ async function main(): Promise<void> {
|
|
|
5891
5911
|
console.error(refusal);
|
|
5892
5912
|
process.exit(2);
|
|
5893
5913
|
}
|
|
5914
|
+
const user = userSessionRefusal({
|
|
5915
|
+
isWrite: isWriteCommand(argv._ ?? []) && !isReadMode(argv._ ?? [], argv as Record<string, unknown>),
|
|
5916
|
+
who, asBot: argv["as-bot"] === true, asUser: argv["as-user"] === true,
|
|
5917
|
+
activeIsBot: active.startsWith("xoxb-"),
|
|
5918
|
+
agent: inAgent(), identities: [...listIdentities().keys()].sort(),
|
|
5919
|
+
cmd: (argv._ ?? []).slice(0, 2).join(" "),
|
|
5920
|
+
});
|
|
5921
|
+
if (user) {
|
|
5922
|
+
console.error(user);
|
|
5923
|
+
process.exit(2);
|
|
5924
|
+
}
|
|
5894
5925
|
} catch (e) {
|
|
5895
5926
|
const msg = e instanceof Error ? e.message : String(e);
|
|
5896
5927
|
if (msg.startsWith("No profiles configured")) {
|
|
@@ -6585,12 +6616,35 @@ async function main(): Promise<void> {
|
|
|
6585
6616
|
)
|
|
6586
6617
|
.command(
|
|
6587
6618
|
"nudge",
|
|
6588
|
-
"Pending messages older than settings' nudge_after_min (default 30): exit 1 when there are any",
|
|
6589
|
-
(yy) => yy
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6619
|
+
"Pending messages older than settings' nudge_after_min (default 30): exit 1 when there are any; --eyes also puts 👀 on each, once",
|
|
6620
|
+
(yy) => yy
|
|
6621
|
+
.option("json", { type: "boolean", default: false })
|
|
6622
|
+
.option("eyes", { type: "boolean", default: false, describe: "React 👀 on each overdue message (once per message: recorded as nudged_at)" })
|
|
6623
|
+
.option("as-bot", { type: "boolean", describe: "React via the bot token (default: settings' as_bot)" }),
|
|
6624
|
+
async (argv) => {
|
|
6625
|
+
const scope = findScope();
|
|
6626
|
+
const items = overdue(scope);
|
|
6627
|
+
if (argv.eyes) {
|
|
6628
|
+
const asBot = argv["as-bot"] ?? readSettings(scope).as_bot ?? false;
|
|
6629
|
+
const token = asBot ? resolveBotToken() : tok(argv as W);
|
|
6630
|
+
if (!token) { console.error("Error: as_bot needs a bot token (SLACK_BOT_TOKEN)"); process.exit(1); }
|
|
6631
|
+
const cookie = asBot ? undefined : ck(argv as W);
|
|
6632
|
+
for (const i of items) {
|
|
6633
|
+
if (i.entry.nudged_at) continue;
|
|
6634
|
+
try {
|
|
6635
|
+
await reactionAdd(token, i.entry.channel, i.entry.ts, "eyes", cookie);
|
|
6636
|
+
} catch (e: unknown) {
|
|
6637
|
+
if (!/already_reacted/.test(e instanceof Error ? e.message : String(e))) {
|
|
6638
|
+
console.error(`⚠ ${i.entry.channel}:${i.entry.ts}: could not add 👀: ${friendlySlackError(e)}`);
|
|
6639
|
+
continue;
|
|
6640
|
+
}
|
|
6641
|
+
}
|
|
6642
|
+
markNudged(i);
|
|
6643
|
+
i.entry.nudged_at = new Date().toISOString();
|
|
6644
|
+
}
|
|
6645
|
+
}
|
|
6646
|
+
if (argv.json) console.log(JSON.stringify(items.map((i) => ({ file: i.file, ts: i.entry.ts, channel: i.entry.channel, permalink: i.entry.permalink, waiting_min: Math.round((Date.now() - i.mtimeMs) / 60_000), ...(i.entry.nudged_at ? { nudged_at: i.entry.nudged_at } : {}) }))));
|
|
6647
|
+
else for (const i of items) console.log(`${i.entry.ts}\t${Math.round((Date.now() - i.mtimeMs) / 60_000)} min\t${i.entry.permalink}${i.entry.nudged_at ? "\t👀" : ""}`);
|
|
6594
6648
|
if (items.length) process.exitCode = 1;
|
|
6595
6649
|
},
|
|
6596
6650
|
)
|
|
@@ -6709,7 +6763,7 @@ async function main(): Promise<void> {
|
|
|
6709
6763
|
)
|
|
6710
6764
|
.command(
|
|
6711
6765
|
"ask [target] [question] [choices..]",
|
|
6712
|
-
"Ask a question with its choices pre-seeded as 1️⃣..🔟 reactions (confirm-hash safety gate). Lifecycle: --waitFor=<link> collect, --void=<link>
|
|
6766
|
+
"Ask a question with its choices pre-seeded as 1️⃣..🔟 reactions (confirm-hash safety gate). Lifecycle: --waitFor=<link> collect, --void=<link> void (verb aliases: `ask collect <link>`, `ask void <link…>`)",
|
|
6713
6767
|
(y) => y
|
|
6714
6768
|
.positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --waitFor)" })
|
|
6715
6769
|
.positional("question", { type: "string", describe: "The question. Must @tag whoever may answer (@alice), or the whole channel (@here / @channel / @everyone) — only their answer counts. In a 1:1 DM the other party counts automatically." })
|
|
@@ -6718,10 +6772,10 @@ async function main(): Promise<void> {
|
|
|
6718
6772
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
6719
6773
|
.option("body", { type: "string", describe: "Extra context shown under the question" })
|
|
6720
6774
|
.option("lang", { type: "string", choices: [...ASK_LANGS], describe: "Language of the instructions in the posted body. Default: $SLACK_TERM_LANG, else the answerers' Slack locale, else the question's own language, else the system locale, else ja" })
|
|
6721
|
-
.option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = a reply matched several choices (stdout empty), 5 = a free-text reply that picked none of the choices (the reply is on stdout; NOT a decision — the question stays open), 6 = the question was voided (
|
|
6775
|
+
.option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = a reply matched several choices (stdout empty), 5 = a free-text reply that picked none of the choices (the reply is on stdout; NOT a decision — the question stays open), 6 = the question was voided (`slack ask void`) — stop waiting. 7 = only with --explain-exit: ℹ️ pressed, explain and collect again (the question stays open)." })
|
|
6722
6776
|
.option("waitFor", { type: "string", describe: "Collect the answer to a question already posted: pass its permalink. Nothing is posted (except, on an --explain question someone pressed ℹ️ on, one thread note per question). Same stdout/exit contract as --wait; --timeout 0 checks once and exits 2 if still open." })
|
|
6723
6777
|
.option("after", { type: "string", describe: "With --waitFor: the cursor — everything at or before this ts was already delivered. Text replies there are neither answers nor notes again (pass the `cursor` a previous --json result gave you, or the reply ts an exit 5 printed). Reactions still count." })
|
|
6724
|
-
.option("void", { type: "string", array: true, describe: "
|
|
6778
|
+
.option("void", { type: "string", array: true, describe: "Void question(s) you posted that expired or stopped meaning anything — --void=<permalink>, repeatable. The text becomes 🚫 with --reason (body kept, options removed), our pills come off, and --wait/--waitFor on it exits 6. Same --code gate. Only your own questions (--as-bot for the bot's). Alias: `slack ask void <link…>`." })
|
|
6725
6779
|
.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." })
|
|
6726
6780
|
.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." })
|
|
6727
6781
|
.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." })
|
|
@@ -6847,7 +6901,7 @@ async function main(): Promise<void> {
|
|
|
6847
6901
|
const { token, cookie } = askTokenFor(!!argv["as-bot"]);
|
|
6848
6902
|
const self = await selfIdentity(token, cookie);
|
|
6849
6903
|
const t = await askLoadOwnOpen(token, voidFlag[0]!, self, !!argv["as-bot"], cookie);
|
|
6850
|
-
if (t === "void") { console.error(`✗ ${stripTerminalControls(voidFlag[0]!)}:
|
|
6904
|
+
if (t === "void") { console.error(`✗ ${stripTerminalControls(voidFlag[0]!)}: already voided — to ask again, just use slack ask`); process.exit(ASK_EXIT_ERROR); }
|
|
6851
6905
|
if (!t) process.exit(ASK_EXIT_ERROR);
|
|
6852
6906
|
voidAfter = { ...t, reason: unescapeArg(String(argv.reason ?? "")).trim() };
|
|
6853
6907
|
// Same place, same words, unless new ones were given. The create path
|
|
@@ -7046,7 +7100,7 @@ async function main(): Promise<void> {
|
|
|
7046
7100
|
.positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --results/--close)" })
|
|
7047
7101
|
.positional("question", { type: "string", describe: "The question. @tags are optional here — unlike `ask`, a poll needs no addressee: anyone who can see it may vote." })
|
|
7048
7102
|
.positional("choices", { type: "string", array: true, describe: "2..10 choices, seeded as 1️⃣..🔟 reactions for voters to click" })
|
|
7049
|
-
.option("multi", { type: "boolean", default: false, describe: "Let one person pick several choices (default:
|
|
7103
|
+
.option("multi", { type: "boolean", default: false, describe: "Let one person pick several choices (default: one vote per person)" })
|
|
7050
7104
|
.option("until", { type: "string", describe: "Deadline shown in the message (display only — closing is still `--close`)" })
|
|
7051
7105
|
.option("results", { type: "string", describe: "Count an existing poll: pass its permalink. Posts nothing; only edit is the invalid-ballot notice in the poll itself (--no-notify to suppress)." })
|
|
7052
7106
|
.option("close", { type: "string", describe: "Count an existing poll AND freeze the tally into the message. Only the poster can do this." })
|
|
@@ -8299,7 +8353,8 @@ async function main(): Promise<void> {
|
|
|
8299
8353
|
.option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
|
|
8300
8354
|
.option("relay", { type: "boolean", default: true, describe: "Use the doorbell relay (`slack relay init`) when SLACK_RELAY_URL and SLACK_RELAY_TOKEN are set; --no-relay polls only" })
|
|
8301
8355
|
.option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" })
|
|
8302
|
-
.option("inbox", { type: "boolean", default: false, describe: "Also write each match into the inbox/ of every .slack-term/ that subscribes its thread (redacted, text-only)" })
|
|
8356
|
+
.option("inbox", { type: "boolean", default: false, describe: "Also write each match into the inbox/ of every .slack-term/ that subscribes its thread (redacted, text-only)" })
|
|
8357
|
+
.option("reactions", { type: "boolean", default: false, describe: "Relay only: also print reaction_added bells as {type: \"reaction\", channel, ts, user} (the emoji: reactions.get)" }),
|
|
8303
8358
|
async (argv) => {
|
|
8304
8359
|
let re: RegExp;
|
|
8305
8360
|
let reconcileMs: number;
|
|
@@ -8415,6 +8470,7 @@ async function main(): Promise<void> {
|
|
|
8415
8470
|
selfUsers,
|
|
8416
8471
|
selfBots,
|
|
8417
8472
|
json: argv.json,
|
|
8473
|
+
reactions: argv.reactions,
|
|
8418
8474
|
...(argv.inbox ? {
|
|
8419
8475
|
onMatch: (rec: StreamMatch, files: number) => {
|
|
8420
8476
|
try {
|