slack-term 1.49.0 → 1.50.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 +272 -266
- package/package.json +1 -1
- package/ts/ask.ts +5 -5
- package/ts/botIntent.ts +1 -1
- package/ts/cli.ts +157 -114
- package/ts/identity.ts +152 -0
- 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,6 +53,7 @@ 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
59
|
import { addOutbox, deliver, listInbox, listOutbox, markDone, overdue, recordFailure, recordSent as recordOutboxSent } from "./inbox.ts";
|
|
@@ -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";
|
|
@@ -477,7 +478,13 @@ async function cmdThread(token: string, target: string, ts: string, limit: numbe
|
|
|
477
478
|
let msgs = all
|
|
478
479
|
? await wholeThread(token, channelId, parseInputTs(ts), cookie)
|
|
479
480
|
: asArray(((await replies(token, channelId, parseInputTs(ts), limit, cookie)) as Record<string, Json>).messages).map(asRecord);
|
|
480
|
-
if (since !== undefined)
|
|
481
|
+
if (since !== undefined) {
|
|
482
|
+
// The same ts forms the thread argument takes (Slack ts or the ISO stamp
|
|
483
|
+
// `read` prints); anything else would compare as NaN and drop every reply.
|
|
484
|
+
const after = Number(parseInputTs(since));
|
|
485
|
+
if (!Number.isFinite(after)) throw new Error(`--since needs a message ts (1700000000.000100) or ISO stamp (got ${stripTerminalControls(since)})`);
|
|
486
|
+
msgs = msgs.filter((m) => Number(m.ts) > after);
|
|
487
|
+
}
|
|
481
488
|
if (format === "tsv") {
|
|
482
489
|
for (const m of msgs) console.log(tsvLine(m));
|
|
483
490
|
return;
|
|
@@ -2156,6 +2163,7 @@ async function cmdPinlogUpdate(token: string, args: PinlogUpdateArgs): Promise<v
|
|
|
2156
2163
|
try {
|
|
2157
2164
|
const back = asArray(asRecord((await replies(token, channel, ts, 1, args.cookie)) as Json).messages).map(asRecord);
|
|
2158
2165
|
stored = back.find((m) => m.ts === ts);
|
|
2166
|
+
if (!stored) console.error(`⚠ HEAD updated, but the read-back did not return it, so it was not checked`);
|
|
2159
2167
|
} catch (e: unknown) {
|
|
2160
2168
|
console.error(`⚠ HEAD updated, but it could not be read back to check: ${friendlySlackError(e)}`);
|
|
2161
2169
|
}
|
|
@@ -2627,17 +2635,17 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
2627
2635
|
// a ts split, and `--channel-id` makes the leading "#name" irrelevant to
|
|
2628
2636
|
// resolution, so it never depends on ref's original prefix or an
|
|
2629
2637
|
// embedded thread_ts colliding with the appended message ts.
|
|
2630
|
-
let editCmd = `slack edit "#${channelId}:${lastMsgTs}" "
|
|
2638
|
+
let editCmd = `slack edit "#${channelId}:${lastMsgTs}" "<new text>" --channel-id ${channelId}`;
|
|
2631
2639
|
try {
|
|
2632
2640
|
const permalink = await getPermalink(token, channelId, lastMsgTs, cookie);
|
|
2633
|
-
if (permalink) editCmd = `slack edit "${permalink}" "
|
|
2641
|
+
if (permalink) editCmd = `slack edit "${permalink}" "<new text>"`;
|
|
2634
2642
|
} catch {
|
|
2635
2643
|
// fall back to the --channel-id form above
|
|
2636
2644
|
}
|
|
2637
|
-
console.error(`⚠
|
|
2638
|
-
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:`);
|
|
2639
2647
|
console.error(` ${editCmd}`);
|
|
2640
|
-
console.error(`
|
|
2648
|
+
console.error(` To send anyway, confirm with --code=XXXX.`);
|
|
2641
2649
|
}
|
|
2642
2650
|
} catch {
|
|
2643
2651
|
// best-effort; a failed self-identity lookup must not block the send
|
|
@@ -2993,7 +3001,7 @@ async function askCollectNotes(token: string, sc: AskNoteScope, after: string |
|
|
|
2993
3001
|
const rep = asRecord((await replies(token, sc.channelId, sc.threadParentTs ?? sc.ts, 100, sc.cookie)) as Json);
|
|
2994
3002
|
for (const m of asArray(rep.messages).map(asRecord)) pool.set(String(m.ts), m);
|
|
2995
3003
|
} catch (e: unknown) {
|
|
2996
|
-
console.error(` (
|
|
3004
|
+
console.error(` (could not read the thread's follow-up notes: ${e instanceof Error ? e.message : String(e)})`);
|
|
2997
3005
|
}
|
|
2998
3006
|
}
|
|
2999
3007
|
if (!sc.threadOnly && !sc.threadParentTs) {
|
|
@@ -3006,7 +3014,7 @@ async function askCollectNotes(token: string, sc: AskNoteScope, after: string |
|
|
|
3006
3014
|
.reduce((min, m) => Math.min(min, Number(m.ts)), Infinity);
|
|
3007
3015
|
for (const m of msgs) if (Number(m.ts) < bound && !pool.has(String(m.ts))) pool.set(String(m.ts), m);
|
|
3008
3016
|
} catch (e: unknown) {
|
|
3009
|
-
console.error(` (DM
|
|
3017
|
+
console.error(` (could not read the DM's follow-up notes: ${e instanceof Error ? e.message : String(e)})`);
|
|
3010
3018
|
}
|
|
3011
3019
|
}
|
|
3012
3020
|
const picked = [...pool.values()]
|
|
@@ -3060,7 +3068,7 @@ async function askEmit(
|
|
|
3060
3068
|
return;
|
|
3061
3069
|
}
|
|
3062
3070
|
if (r.notes.length) {
|
|
3063
|
-
console.error(`
|
|
3071
|
+
console.error(` ${r.notes.length} thread note(s) (separate from the answer):`);
|
|
3064
3072
|
for (const n of r.notes) {
|
|
3065
3073
|
let who = n.user;
|
|
3066
3074
|
try {
|
|
@@ -3074,7 +3082,7 @@ async function askEmit(
|
|
|
3074
3082
|
}
|
|
3075
3083
|
}
|
|
3076
3084
|
if (r.status === "answered") {
|
|
3077
|
-
console.error(`
|
|
3085
|
+
console.error(` To pick up later notes: ${askResumeCommand(o.shown, o.asBot, r.cursor)}`);
|
|
3078
3086
|
}
|
|
3079
3087
|
if (r.answer !== null) console.log(r.answer);
|
|
3080
3088
|
}
|
|
@@ -3171,7 +3179,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
3171
3179
|
await editMessage(token, channelId, ts, next, cookie, true);
|
|
3172
3180
|
} catch (e: unknown) {
|
|
3173
3181
|
// Cosmetic: the poller still refuses to guess, and stderr already said so.
|
|
3174
|
-
console.error(` (
|
|
3182
|
+
console.error(` (could not add the notice to the question text: ${e instanceof Error ? e.message : String(e)})`);
|
|
3175
3183
|
}
|
|
3176
3184
|
}
|
|
3177
3185
|
|
|
@@ -3188,7 +3196,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
3188
3196
|
for (const u of asArray(r.users).filter(isAnswerer)) {
|
|
3189
3197
|
if (otherReported.has(u)) continue;
|
|
3190
3198
|
otherReported.add(u);
|
|
3191
|
-
if (!readOnly) console.error(` (<@${u}>
|
|
3199
|
+
if (!readOnly) console.error(` (<@${u}> pressed ${glyph} other; waiting for their reply)`);
|
|
3192
3200
|
}
|
|
3193
3201
|
}
|
|
3194
3202
|
}
|
|
@@ -3242,14 +3250,14 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
3242
3250
|
if (++pages >= ASK_EXPLAIN_NOTE_MAX_PAGES) return "exists";
|
|
3243
3251
|
}
|
|
3244
3252
|
} catch (e: unknown) {
|
|
3245
|
-
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)})`);
|
|
3246
3254
|
return "unread";
|
|
3247
3255
|
}
|
|
3248
3256
|
try {
|
|
3249
3257
|
await slackSend(token, channelId, copy.explainNote, root, false, cookie, true);
|
|
3250
3258
|
} catch (e: unknown) {
|
|
3251
3259
|
// The press is still reported on stderr; the note is a courtesy.
|
|
3252
|
-
console.error(` (
|
|
3260
|
+
console.error(` (could not post the explain-request note to the thread: ${e instanceof Error ? e.message : String(e)})`);
|
|
3253
3261
|
return "failed";
|
|
3254
3262
|
}
|
|
3255
3263
|
return "posted";
|
|
@@ -3262,7 +3270,7 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
3262
3270
|
now.push(u);
|
|
3263
3271
|
if (explainReported.has(u)) continue;
|
|
3264
3272
|
explainReported.add(u);
|
|
3265
|
-
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)`);
|
|
3266
3274
|
}
|
|
3267
3275
|
state.explainNow = now;
|
|
3268
3276
|
// Read-only (`--pending`): the press is reported, the note is not posted —
|
|
@@ -3299,8 +3307,8 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
3299
3307
|
for (const p of picks) for (const u of p.users) pillsPerUser.set(u, (pillsPerUser.get(u) ?? 0) + 1);
|
|
3300
3308
|
const multi = [...pillsPerUser].filter(([, n]) => n > 1).map(([u]) => u);
|
|
3301
3309
|
if (!readOnly) console.error(multi.length
|
|
3302
|
-
? ` (${glyphs}
|
|
3303
|
-
: ` (
|
|
3310
|
+
? ` (${glyphs} are pressed at once; waiting until it is narrowed to one)`
|
|
3311
|
+
: ` (answers are split: ${glyphs}; waiting until they agree on one)`);
|
|
3304
3312
|
// Whoever is on more than one pill — and ONLY them. Naming everyone on any
|
|
3305
3313
|
// pill told a voter who had pressed exactly one that she had pressed two
|
|
3306
3314
|
// (2026-10-01: a voter on 1️⃣ only was named because someone else was on 1️⃣
|
|
@@ -3460,7 +3468,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3460
3468
|
}
|
|
3461
3469
|
// Who answered never reaches stdout — callers do ANS=$(slack ask --wait ...)
|
|
3462
3470
|
// and must keep getting the answer body alone.
|
|
3463
|
-
if (who) console.error(`
|
|
3471
|
+
if (who) console.error(` Answered by: ${stripTerminalControls(who)}`);
|
|
3464
3472
|
try {
|
|
3465
3473
|
// The body and the chosen option stay; only what no longer applies goes.
|
|
3466
3474
|
// Built from the message as it is NOW (an edit made in Slack after asking
|
|
@@ -3487,7 +3495,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3487
3495
|
// The edit is bookkeeping, not the answer. It fails whenever the resuming
|
|
3488
3496
|
// token is not the one that posted (Slack lets only the author update), and
|
|
3489
3497
|
// that must not cost the caller an answer it already has.
|
|
3490
|
-
console.error(` (
|
|
3498
|
+
console.error(` (failed to update the original message: ${e instanceof Error ? e.message : String(e)})`);
|
|
3491
3499
|
}
|
|
3492
3500
|
}
|
|
3493
3501
|
|
|
@@ -3512,10 +3520,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3512
3520
|
// A dropped poll is expected occasionally; only give up if it keeps failing.
|
|
3513
3521
|
errors++;
|
|
3514
3522
|
if (errors >= ASK_MAX_CONSECUTIVE_ERRORS) {
|
|
3515
|
-
console.error(`Error: Slack
|
|
3523
|
+
console.error(`Error: Slack requests failed ${errors} times in a row: ${e instanceof Error ? e.message : String(e)}`);
|
|
3516
3524
|
process.exit(ASK_EXIT_ERROR);
|
|
3517
3525
|
}
|
|
3518
|
-
console.error(` (Slack
|
|
3526
|
+
console.error(` (Slack error ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, retrying: ${e instanceof Error ? e.message : String(e)})`);
|
|
3519
3527
|
}
|
|
3520
3528
|
}
|
|
3521
3529
|
unchosen = state.unchosen;
|
|
@@ -3558,7 +3566,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3558
3566
|
// only on the run that posted the thread note, so a `--timeout 0` poller is
|
|
3559
3567
|
// woken once per question, not on every look.
|
|
3560
3568
|
if (state.explainPosted && ctx.explainExit) {
|
|
3561
|
-
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)}`);
|
|
3562
3570
|
if (ctx.json) await askEmit(token, ctx, { status: "explain", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
|
|
3563
3571
|
process.exit(ASK_EXIT_EXPLAIN);
|
|
3564
3572
|
}
|
|
@@ -3574,10 +3582,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3574
3582
|
} catch {
|
|
3575
3583
|
// the id is still a usable answer to "who"
|
|
3576
3584
|
}
|
|
3577
|
-
console.error(` (${stripTerminalControls(who)}
|
|
3578
|
-
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(" ")}`);
|
|
3579
3587
|
console.error(` ${shown}`);
|
|
3580
|
-
console.error(`
|
|
3588
|
+
console.error(` To wait for what comes after this reply: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
|
|
3581
3589
|
// Delivered, not collected: `--pending` will not report THIS reply again,
|
|
3582
3590
|
// but still reports a pill pressed after it.
|
|
3583
3591
|
markAskDelivered(channelId, ts, unchosen.text, askNextCursor(ts, ctx.after, unchosen.ts));
|
|
@@ -3601,8 +3609,8 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3601
3609
|
// the id is still a usable answer to "who"
|
|
3602
3610
|
}
|
|
3603
3611
|
// Only the ambiguous case gets here — a plain free-text reply exited above.
|
|
3604
|
-
console.error(` (${stripTerminalControls(who)}
|
|
3605
|
-
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)}"`);
|
|
3606
3614
|
}
|
|
3607
3615
|
// Checked after the first poll, never before it: `--timeout 0` means one
|
|
3608
3616
|
// look, and one look must still be a real look.
|
|
@@ -3623,20 +3631,20 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3623
3631
|
// something to act on.
|
|
3624
3632
|
if (unchosen) {
|
|
3625
3633
|
// Ambiguous only: a plain free-text reply was delivered as exit 5 already.
|
|
3626
|
-
console.error(`Error:
|
|
3627
|
-
console.error(`
|
|
3628
|
-
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(" ")}`);
|
|
3629
3637
|
console.error(` ${shown}`);
|
|
3630
|
-
console.error(`
|
|
3638
|
+
console.error(` Reply in the thread, then collect with: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3631
3639
|
if (ctx.json) await askEmit(token, ctx, { status: "ambiguous", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
|
|
3632
3640
|
process.exit(ASK_EXIT_UNCHOSEN);
|
|
3633
3641
|
}
|
|
3634
3642
|
if (timeout === 0) {
|
|
3635
|
-
console.error(
|
|
3643
|
+
console.error(`No answer yet: ${shown}`);
|
|
3636
3644
|
} else {
|
|
3637
|
-
console.error(`Error: ${timeout}s
|
|
3645
|
+
console.error(`Error: no answer within ${timeout}s (the message is left in place)`);
|
|
3638
3646
|
console.error(` ${shown}`);
|
|
3639
|
-
console.error(`
|
|
3647
|
+
console.error(` To collect later: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
|
|
3640
3648
|
}
|
|
3641
3649
|
if (ctx.json) await askEmit(token, ctx, { status: "timeout", answer: null, notes: [], cursor: askNextCursor(ts, ctx.after), explain: state.explainNow });
|
|
3642
3650
|
process.exit(ASK_EXIT_TIMEOUT);
|
|
@@ -3820,7 +3828,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3820
3828
|
` Answerable by: ${audienceLabel}`,
|
|
3821
3829
|
` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
|
|
3822
3830
|
...(explain ? [` Explain: ℹ️ pill offered — pressing it asks you to explain; it is never an answer`] : []),
|
|
3823
|
-
...(args.voidAfter ? [` Then
|
|
3831
|
+
...(args.voidAfter ? [` Then void: ${stripTerminalControls(args.voidAfter.link)} (reason: ${stripTerminalControls(args.voidAfter.reason || "none")}) — replaced by this question`] : []),
|
|
3824
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)`,
|
|
3825
3833
|
`--------------------------------────────────`,
|
|
3826
3834
|
], recipientTz);
|
|
@@ -3857,8 +3865,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3857
3865
|
const why = e instanceof Error ? e.message : String(e);
|
|
3858
3866
|
// The marker is cosmetic + searchability; a keycap only costs the
|
|
3859
3867
|
// answerer a trip to the emoji picker. Neither abandons a posted question.
|
|
3860
|
-
if (name === seedMarker) console.error(` (marker ${name}
|
|
3861
|
-
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})`);
|
|
3862
3870
|
},
|
|
3863
3871
|
);
|
|
3864
3872
|
|
|
@@ -3875,7 +3883,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3875
3883
|
// two open questions, which is reported loudly — never a missing one.
|
|
3876
3884
|
if (args.voidAfter) {
|
|
3877
3885
|
const ok = await askApplyVoid(token, args.voidAfter, args.voidAfter.reason, shown, cookie);
|
|
3878
|
-
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}'`);
|
|
3879
3887
|
}
|
|
3880
3888
|
|
|
3881
3889
|
if (!args.wait) {
|
|
@@ -3889,7 +3897,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3889
3897
|
}
|
|
3890
3898
|
|
|
3891
3899
|
const timeout = args.timeout ?? 3600;
|
|
3892
|
-
console.error(`
|
|
3900
|
+
console.error(` Waiting for an answer (up to ${timeout}s)… Ctrl-C to stop`);
|
|
3893
3901
|
|
|
3894
3902
|
const ctx: AskWaitCtx = {
|
|
3895
3903
|
channelId,
|
|
@@ -3920,9 +3928,9 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3920
3928
|
|
|
3921
3929
|
/** Say on stderr that a question is void, and where its replacement is. */
|
|
3922
3930
|
function askReportVoid(p: { reason: string; supersededBy?: string }, shown: string): void {
|
|
3923
|
-
console.error(
|
|
3931
|
+
console.error(`This question is void — it will never be answered. Stop waiting on it: ${stripTerminalControls(shown)}`);
|
|
3924
3932
|
if (p.reason) console.error(` ${stripTerminalControls(p.reason)}`);
|
|
3925
|
-
if (p.supersededBy) console.error(`
|
|
3933
|
+
if (p.supersededBy) console.error(` New question: ${stripTerminalControls(p.supersededBy)}`);
|
|
3926
3934
|
}
|
|
3927
3935
|
|
|
3928
3936
|
/** Find the one message a permalink (or `C…:ts`) names. A question posted into
|
|
@@ -3977,20 +3985,20 @@ async function askLoadOwnOpen(token: string, link: string, self: Self | null, as
|
|
|
3977
3985
|
Promise<(AskVoidTarget & { msg: Record<string, Json>; threadTs?: string }) | "void" | null> {
|
|
3978
3986
|
const shown = stripTerminalControls(link);
|
|
3979
3987
|
const got = await askFetchByLink(token, link, cookie);
|
|
3980
|
-
if (!got) { console.error(`✗ ${shown}: permalink (
|
|
3981
|
-
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; }
|
|
3982
3990
|
const msg = got.msg;
|
|
3983
3991
|
const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
|
|
3984
3992
|
if (!own) {
|
|
3985
|
-
console.error(`✗ ${shown}:
|
|
3986
|
-
`${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"}`);
|
|
3987
3995
|
return null;
|
|
3988
3996
|
}
|
|
3989
3997
|
const text = typeof msg.text === "string" ? msg.text : "";
|
|
3990
3998
|
const parsed = askParseMessage(text);
|
|
3991
3999
|
if (parsed.kind === "void") return "void";
|
|
3992
|
-
if (parsed.kind === "resolved") { console.error(`✗ ${shown}:
|
|
3993
|
-
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; }
|
|
3994
4002
|
const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, seedMarker: askSeedMarker(text) ?? ASK_MARKER, msg };
|
|
3995
4003
|
if (got.threadTs) out.threadTs = got.threadTs;
|
|
3996
4004
|
return out;
|
|
@@ -4034,7 +4042,7 @@ async function cmdAskEdit(token: string, args: {
|
|
|
4034
4042
|
}): Promise<void> {
|
|
4035
4043
|
const self = await selfIdentity(token, args.cookie);
|
|
4036
4044
|
const t = await askLoadOwnOpen(token, args.link, self, args.asBot, args.cookie);
|
|
4037
|
-
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); }
|
|
4038
4046
|
if (!t) process.exit(ASK_EXIT_ERROR);
|
|
4039
4047
|
const old = t.parsed;
|
|
4040
4048
|
const encode = async (raw: string): Promise<string> => {
|
|
@@ -4050,8 +4058,8 @@ async function cmdAskEdit(token: string, args: {
|
|
|
4050
4058
|
const choicesChanged = JSON.stringify(choices.map(askFlatten)) !== JSON.stringify(oldChoices.map(askFlatten));
|
|
4051
4059
|
if (choicesChanged && await askHasResponses(token, t, self, args.cookie)) {
|
|
4052
4060
|
console.error(
|
|
4053
|
-
`✗
|
|
4054
|
-
`
|
|
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` +
|
|
4055
4063
|
` slack ask --void='${stripTerminalControls(args.link)}' --reask${args.asBot ? " --as-bot" : ""} …`,
|
|
4056
4064
|
);
|
|
4057
4065
|
process.exit(ASK_EXIT_ERROR);
|
|
@@ -4062,7 +4070,7 @@ async function cmdAskEdit(token: string, args: {
|
|
|
4062
4070
|
const tagged = [...`${question}\n${body}`.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].some((m) => m[1] !== self?.userId);
|
|
4063
4071
|
const addressed = tagged || /<!(?:here|channel|everyone)/.test(`${question}\n${body}`) || t.channelId.startsWith("D");
|
|
4064
4072
|
if (!addressed) {
|
|
4065
|
-
console.error(`✗
|
|
4073
|
+
console.error(`✗ The question would be addressed to nobody — keep an @mention of who can answer`);
|
|
4066
4074
|
process.exit(ASK_EXIT_ERROR);
|
|
4067
4075
|
}
|
|
4068
4076
|
const lang = args.lang ? askResolveLang({ flag: args.lang })?.lang ?? old.lang : old.lang;
|
|
@@ -4076,7 +4084,7 @@ async function cmdAskEdit(token: string, args: {
|
|
|
4076
4084
|
if (invalid.length) next = applyInvalidNotice(next, invalid, lang);
|
|
4077
4085
|
guardUrlBoundaries(next, args.allowUrlAdjacent);
|
|
4078
4086
|
if (next === oldText) {
|
|
4079
|
-
console.error("
|
|
4087
|
+
console.error("No changes.");
|
|
4080
4088
|
return;
|
|
4081
4089
|
}
|
|
4082
4090
|
|
|
@@ -4116,7 +4124,7 @@ async function cmdAskEdit(token: string, args: {
|
|
|
4116
4124
|
await seedReactionsInOrder(
|
|
4117
4125
|
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail(seedMarker)],
|
|
4118
4126
|
(name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
|
|
4119
|
-
(name, e) => console.error(` (
|
|
4127
|
+
(name, e) => console.error(` (could not add reaction ${name}: ${e instanceof Error ? e.message : String(e)})`),
|
|
4120
4128
|
);
|
|
4121
4129
|
} else if (oldSeed !== seedMarker) {
|
|
4122
4130
|
// An old ❓ pill edited without touching its choices: the body now lists
|
|
@@ -4126,10 +4134,10 @@ async function cmdAskEdit(token: string, args: {
|
|
|
4126
4134
|
await reactionAdd(token, t.channelId, t.ts, seedMarker, args.cookie);
|
|
4127
4135
|
await reactionRemove(token, t.channelId, t.ts, oldSeed, args.cookie);
|
|
4128
4136
|
} catch (e: unknown) {
|
|
4129
|
-
console.error(` (marker
|
|
4137
|
+
console.error(` (could not swap the marker to ${seedMarker}: ${e instanceof Error ? e.message : String(e)})`);
|
|
4130
4138
|
}
|
|
4131
4139
|
}
|
|
4132
|
-
console.error(`✓
|
|
4140
|
+
console.error(`✓ Edited: ${stripTerminalControls(args.link)}`);
|
|
4133
4141
|
}
|
|
4134
4142
|
|
|
4135
4143
|
/** `slack ask --ls [--stale <dur>] [--state …] [--channel …] [--json]` — the
|
|
@@ -4206,7 +4214,7 @@ async function cmdAskLs(tokens: { user?: string; bot?: string; cookie?: string }
|
|
|
4206
4214
|
for (const bot of [false, true]) {
|
|
4207
4215
|
const mine = open.filter((it) => it.asBot === bot);
|
|
4208
4216
|
if (!mine.length) continue;
|
|
4209
|
-
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" : ""}`);
|
|
4210
4218
|
}
|
|
4211
4219
|
}
|
|
4212
4220
|
}
|
|
@@ -4272,7 +4280,7 @@ async function cmdAskPending(
|
|
|
4272
4280
|
const asBot = !!row.as_bot;
|
|
4273
4281
|
const token = asBot ? tokens.bot : tokens.user;
|
|
4274
4282
|
if (!token) {
|
|
4275
|
-
problems.push(`${link}: ${asBot ? "--as-bot
|
|
4283
|
+
problems.push(`${link}: ${asBot ? "asked with --as-bot, but there is no bot token" : "no user token"}`);
|
|
4276
4284
|
continue;
|
|
4277
4285
|
}
|
|
4278
4286
|
const cookie = asBot ? undefined : tokens.cookie;
|
|
@@ -4283,15 +4291,15 @@ async function cmdAskPending(
|
|
|
4283
4291
|
try {
|
|
4284
4292
|
const loc = { channelId: row.channel, ts: row.ts, threadTs: row.thread_ts ?? undefined };
|
|
4285
4293
|
const got = await askFetchAt(token, loc, cookie);
|
|
4286
|
-
if (!got.msg) { problems.push(`${link}:
|
|
4294
|
+
if (!got.msg) { problems.push(`${link}: message not found (deleted?)`); continue; }
|
|
4287
4295
|
const q = await askReadBack(token, { channelId: row.channel, ts: row.ts, msg: got.msg }, cookie);
|
|
4288
4296
|
const parsed = q.parsed;
|
|
4289
4297
|
// ✅ already stamped: some `--wait`/`--waitFor` collected it (another
|
|
4290
4298
|
// machine, another token, or a build before collection was logged).
|
|
4291
4299
|
if (parsed.kind === "resolved") { count.resolved++; continue; }
|
|
4292
4300
|
if (parsed.kind === "void") { count.void++; continue; }
|
|
4293
|
-
if (parsed.kind === "other") { problems.push(`${link}: \`slack ask\`
|
|
4294
|
-
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; }
|
|
4295
4303
|
const ctx = askWaitCtxFrom(q, parsed, loc, { timeout: 0, link, asBot, json: false, cookie, after });
|
|
4296
4304
|
const poller = askPoller(token, ctx, true);
|
|
4297
4305
|
const found = await poller.poll();
|
|
@@ -4300,13 +4308,13 @@ async function cmdAskPending(
|
|
|
4300
4308
|
const collect = askResumeCommand(link, asBot, after);
|
|
4301
4309
|
if (poller.state.explainNow.length) {
|
|
4302
4310
|
count.explain++;
|
|
4303
|
-
explainAsked.push(`${link}: ℹ️
|
|
4311
|
+
explainAsked.push(`${link}: ℹ️ an explanation was requested (${poller.state.explainNow.map((u) => `<@${u}>`).join(" ")}) — explain in the thread`);
|
|
4304
4312
|
}
|
|
4305
4313
|
const base = { row, link, question: parsed.question, collect, alive: senderAlive(row) };
|
|
4306
4314
|
if (found) {
|
|
4307
4315
|
pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
|
|
4308
4316
|
} else if (unchosen && !unchosen.ambiguous && unchosen.text !== row.delivered_answer) {
|
|
4309
|
-
pending.push({ ...base, answer: unchosen.text, how: "
|
|
4317
|
+
pending.push({ ...base, answer: unchosen.text, how: "reply (not a choice)", freeText: true });
|
|
4310
4318
|
} else if (unchosen?.ambiguous) {
|
|
4311
4319
|
count.ambiguous++;
|
|
4312
4320
|
} else {
|
|
@@ -4344,7 +4352,7 @@ async function cmdAskPending(
|
|
|
4344
4352
|
for (const it of items) {
|
|
4345
4353
|
console.log(` ${stripTerminalControls(it.link)} (${formatYmdHm(it.row.sent_at / 1000)})`);
|
|
4346
4354
|
console.log(` Q: ${stripTerminalControls(askFlatten(it.question))}`);
|
|
4347
|
-
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" : ""}`);
|
|
4348
4356
|
console.log(` collect: ${stripTerminalControls(it.collect)}`);
|
|
4349
4357
|
}
|
|
4350
4358
|
}
|
|
@@ -4352,15 +4360,15 @@ async function cmdAskPending(
|
|
|
4352
4360
|
for (const p of problems) console.error(` (${stripTerminalControls(p)})`);
|
|
4353
4361
|
for (const p of explainAsked) console.error(` (${stripTerminalControls(p)})`);
|
|
4354
4362
|
for (const [team, n] of otherTeams) {
|
|
4355
|
-
console.error(` (${n}
|
|
4363
|
+
console.error(` (${n} question(s) belong to another workspace "${stripTerminalControls(team)}" — check with slack -w <name> ask --pending)`);
|
|
4356
4364
|
}
|
|
4357
4365
|
console.error(
|
|
4358
|
-
`${pending.length}
|
|
4359
|
-
(count.ambiguous ? `, ${count.ambiguous}
|
|
4360
|
-
(count.resolved ? `, ${count.resolved}
|
|
4361
|
-
(count.void ? `, ${count.void}
|
|
4362
|
-
(count.explain ? `, ${count.explain}
|
|
4363
|
-
` — ${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()})`,
|
|
4364
4372
|
);
|
|
4365
4373
|
}
|
|
4366
4374
|
|
|
@@ -4384,7 +4392,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
|
|
|
4384
4392
|
const next = askBuildVoidText(t.parsed.question, reason, t.parsed.lang, t.parsed.body || undefined, supersededBy);
|
|
4385
4393
|
await editMessage(token, t.channelId, t.ts, next, cookie, true);
|
|
4386
4394
|
} catch (e: unknown) {
|
|
4387
|
-
console.error(`✗ ${shown}:
|
|
4395
|
+
console.error(`✗ ${shown}: failed to rewrite: ${e instanceof Error ? e.message : String(e)}`);
|
|
4388
4396
|
return false;
|
|
4389
4397
|
}
|
|
4390
4398
|
for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), t.seedMarker, ...(t.parsed.explain ? [ASK_EXPLAIN_MARKER] : [])]) {
|
|
@@ -4393,9 +4401,9 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
|
|
|
4393
4401
|
try {
|
|
4394
4402
|
await reactionAdd(token, t.channelId, t.ts, ASK_VOID_MARKER, cookie);
|
|
4395
4403
|
} catch (e: unknown) {
|
|
4396
|
-
console.error(` (${shown}: 🚫
|
|
4404
|
+
console.error(` (${shown}: could not add the 🚫 reaction: ${e instanceof Error ? e.message : String(e)})`);
|
|
4397
4405
|
}
|
|
4398
|
-
console.error(`✓
|
|
4406
|
+
console.error(`✓ Voided: ${shown}`);
|
|
4399
4407
|
return true;
|
|
4400
4408
|
}
|
|
4401
4409
|
|
|
@@ -4435,11 +4443,11 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4435
4443
|
process.exit(ASK_EXIT_ERROR);
|
|
4436
4444
|
}
|
|
4437
4445
|
} catch (e: unknown) {
|
|
4438
|
-
console.error(`Error: --superseded-by
|
|
4446
|
+
console.error(`Error: cannot check --superseded-by: ${shown} (${e instanceof Error ? e.message : String(e)})`);
|
|
4439
4447
|
process.exit(ASK_EXIT_ERROR);
|
|
4440
4448
|
}
|
|
4441
4449
|
if (!found) {
|
|
4442
|
-
console.error(`Error: --superseded-by
|
|
4450
|
+
console.error(`Error: --superseded-by message not found: ${shown}`);
|
|
4443
4451
|
process.exit(ASK_EXIT_ERROR);
|
|
4444
4452
|
}
|
|
4445
4453
|
}
|
|
@@ -4449,21 +4457,21 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4449
4457
|
for (const link of args.links) {
|
|
4450
4458
|
const shown = stripTerminalControls(link);
|
|
4451
4459
|
const got = await askFetchByLink(token, link, args.cookie);
|
|
4452
|
-
if (!got) { console.error(`✗ ${shown}: permalink (
|
|
4453
|
-
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; }
|
|
4454
4462
|
const msg = got.msg;
|
|
4455
4463
|
const own = (!!self?.userId && msg.user === self.userId) || (!!self?.botId && msg.bot_id === self.botId);
|
|
4456
4464
|
if (!own) {
|
|
4457
|
-
console.error(`✗ ${shown}:
|
|
4458
|
-
`${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"}`);
|
|
4459
4467
|
refused++;
|
|
4460
4468
|
continue;
|
|
4461
4469
|
}
|
|
4462
4470
|
const text = typeof msg.text === "string" ? msg.text : "";
|
|
4463
4471
|
let parsed = askParseMessage(text);
|
|
4464
4472
|
let unreadable = false;
|
|
4465
|
-
if (parsed.kind === "void") { console.error(`- ${shown}:
|
|
4466
|
-
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; }
|
|
4467
4475
|
if (parsed.kind !== "open") {
|
|
4468
4476
|
// Our own ❓ question whose layout no longer parses (2026-10-01: a
|
|
4469
4477
|
// `slack edit` flattened one; it could then be neither collected nor
|
|
@@ -4471,7 +4479,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4471
4479
|
// with it, so it is voided from a best-effort read — question and body
|
|
4472
4480
|
// kept as they are, our own seeded pills removed — and the gate says so.
|
|
4473
4481
|
const salvage = askSalvageUnreadable(text);
|
|
4474
|
-
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; }
|
|
4475
4483
|
const seeded = asArray(msg.reactions).map(asRecord)
|
|
4476
4484
|
.filter((r) => asArray(r.users).includes(self?.userId ?? "\u0000"))
|
|
4477
4485
|
.map((r) => ASK_KEYCAPS.findIndex((k) => k.name === r.name))
|
|
@@ -4498,14 +4506,14 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4498
4506
|
targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, seedMarker, pressed, ...(unreadable ? { unreadable } : {}) });
|
|
4499
4507
|
}
|
|
4500
4508
|
if (!targets.length) {
|
|
4501
|
-
console.error(refused ? "
|
|
4509
|
+
console.error(refused ? "No questions can be voided." : "Nothing to do.");
|
|
4502
4510
|
process.exit(refused ? ASK_EXIT_ERROR : 0);
|
|
4503
4511
|
}
|
|
4504
4512
|
|
|
4505
4513
|
const code = safetyCode("ask-void", ...targets.map((t) => `${t.channelId}:${t.ts}`), args.reason, args.supersededBy ?? "", self?.userId ?? "");
|
|
4506
4514
|
if (args.code !== code) {
|
|
4507
4515
|
requireCode(args.code, code, [
|
|
4508
|
-
`---
|
|
4516
|
+
`--- Void -----------------------------------------`,
|
|
4509
4517
|
fromLine(self, { asBot: args.asBot }),
|
|
4510
4518
|
` Reason: ${args.reason ? stripTerminalControls(args.reason) : "(none)"}`,
|
|
4511
4519
|
...(args.supersededBy ? [` Replaced: ${stripTerminalControls(args.supersededBy)}`] : []),
|
|
@@ -4560,7 +4568,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
4560
4568
|
|
|
4561
4569
|
const got = await askFetchAt(token, { channelId, ts, threadTs }, args.cookie);
|
|
4562
4570
|
if (!got.msg) {
|
|
4563
|
-
console.error(`Error:
|
|
4571
|
+
console.error(`Error: message not found: ${stripTerminalControls(args.link)}`);
|
|
4564
4572
|
process.exit(ASK_EXIT_ERROR);
|
|
4565
4573
|
}
|
|
4566
4574
|
const msg = got.msg;
|
|
@@ -4568,12 +4576,12 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
4568
4576
|
const { text, parsed, audience, broadcast, askerUserId, askerBotId } = q;
|
|
4569
4577
|
if (parsed.kind === "other") {
|
|
4570
4578
|
console.error(
|
|
4571
|
-
`Error:
|
|
4572
|
-
`
|
|
4579
|
+
`Error: this cannot be read as a \`slack ask\` question.\n` +
|
|
4580
|
+
` Reason: ${askExplainReject(text)}\n` +
|
|
4573
4581
|
` ${stripTerminalControls(args.link)}\n` +
|
|
4574
4582
|
// The pills are still readable by hand, so a failure here is not a dead
|
|
4575
4583
|
// end — say so, because the reported cost was not knowing that.
|
|
4576
|
-
` (
|
|
4584
|
+
` (the pressed reactions are still there: check with slack read '${stripTerminalControls(args.link)}')`,
|
|
4577
4585
|
);
|
|
4578
4586
|
process.exit(ASK_EXIT_ERROR);
|
|
4579
4587
|
}
|
|
@@ -4605,7 +4613,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
4605
4613
|
// What must not come back is the reply that IS the answer. The ✅ stamp says
|
|
4606
4614
|
// whether the answer was a reply, and which number it picked: the first
|
|
4607
4615
|
// audience reply before the ✅ edit that reads as that answer is it.
|
|
4608
|
-
console.error(`✓
|
|
4616
|
+
console.error(`✓ Answered: ${stripTerminalControls(parsed.question)}`);
|
|
4609
4617
|
const editedTs = typeof asRecord(msg.edited).ts === "string" ? String(asRecord(msg.edited).ts) : undefined;
|
|
4610
4618
|
const how = askResolvedHow(text);
|
|
4611
4619
|
let answerSkipped = false;
|
|
@@ -4638,12 +4646,12 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
|
|
|
4638
4646
|
}
|
|
4639
4647
|
|
|
4640
4648
|
if (!audience.size && !broadcast) {
|
|
4641
|
-
console.error(`Error:
|
|
4649
|
+
console.error(`Error: this question is addressed to nobody, so valid answerers cannot be determined: ${stripTerminalControls(args.link)}`);
|
|
4642
4650
|
process.exit(ASK_EXIT_ERROR);
|
|
4643
4651
|
}
|
|
4644
4652
|
|
|
4645
4653
|
if (args.timeout > 0) {
|
|
4646
|
-
console.error(`
|
|
4654
|
+
console.error(` Waiting for an answer (up to ${args.timeout}s)… Ctrl-C to stop`);
|
|
4647
4655
|
}
|
|
4648
4656
|
await askWaitForAnswer(token, askWaitCtxFrom(q, parsed, { channelId, ts, threadTs }, args));
|
|
4649
4657
|
}
|
|
@@ -4848,9 +4856,9 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
|
|
|
4848
4856
|
fromLine(self, { asBot: args.asBot }),
|
|
4849
4857
|
` → ${dest}${threadTs ? " — THREAD REPLY" : " — NEW top-level message"}`,
|
|
4850
4858
|
` Question: ${question}`,
|
|
4851
|
-
` Rule: ${multi ? "
|
|
4859
|
+
` Rule: ${multi ? "any number of votes per person (--multi)" : "one vote per person"}${until ? ` — closes ${until}` : ""}`,
|
|
4852
4860
|
// Said out loud at the gate: this is the surprising half of the command.
|
|
4853
|
-
` Note:
|
|
4861
|
+
` Note: your own votes are not counted (the pills are seeds you added, so they can't be told apart)`,
|
|
4854
4862
|
`--------------------------------────────────`,
|
|
4855
4863
|
], recipientTz);
|
|
4856
4864
|
}
|
|
@@ -4874,8 +4882,8 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
|
|
|
4874
4882
|
const why = e instanceof Error ? e.message : String(e);
|
|
4875
4883
|
// A missing seed costs a voter a trip to the emoji picker; it does not
|
|
4876
4884
|
// invalidate a poll that is already posted.
|
|
4877
|
-
if (name === POLL_MARKER) console.error(` (marker ${name}
|
|
4878
|
-
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})`);
|
|
4879
4887
|
},
|
|
4880
4888
|
);
|
|
4881
4889
|
|
|
@@ -4889,7 +4897,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
|
|
|
4889
4897
|
console.error(`✓ Posted: ${shown}`);
|
|
4890
4898
|
attr.record({ team: self?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
|
|
4891
4899
|
console.log(pollResultsCommand(shown));
|
|
4892
|
-
console.error(`
|
|
4900
|
+
console.error(` To close: slack poll --close='${shown}'${args.asBot ? " --as-bot" : ""}`);
|
|
4893
4901
|
}
|
|
4894
4902
|
|
|
4895
4903
|
/** Locate + read back one posted poll. Shared by `--results` and `--close`,
|
|
@@ -4928,7 +4936,7 @@ async function pollFetch(token: string, link: string, cookie?: string): Promise<
|
|
|
4928
4936
|
msg = asArray(hist.messages).map(asRecord).find((m) => m.ts === ts);
|
|
4929
4937
|
}
|
|
4930
4938
|
if (!msg) {
|
|
4931
|
-
console.error(`Error:
|
|
4939
|
+
console.error(`Error: message not found: ${stripTerminalControls(link)}`);
|
|
4932
4940
|
process.exit(POLL_EXIT_ERROR);
|
|
4933
4941
|
}
|
|
4934
4942
|
return { channelId, ts, msg };
|
|
@@ -4952,7 +4960,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
|
|
|
4952
4960
|
const parsed = pollParseMessage(text);
|
|
4953
4961
|
if (parsed.kind === "other") {
|
|
4954
4962
|
console.error(
|
|
4955
|
-
`Error:
|
|
4963
|
+
`Error: this is not a \`slack poll\` poll (don't know how to count its votes).\n` +
|
|
4956
4964
|
` ${stripTerminalControls(args.link)}`,
|
|
4957
4965
|
);
|
|
4958
4966
|
process.exit(POLL_EXIT_ERROR);
|
|
@@ -4960,7 +4968,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
|
|
|
4960
4968
|
if (parsed.kind === "closed") {
|
|
4961
4969
|
// Already closed: report what was RECORDED, never a fresh count. A pill
|
|
4962
4970
|
// pressed after closing would otherwise silently change a settled result.
|
|
4963
|
-
console.error(`✓
|
|
4971
|
+
console.error(`✓ Poll closed: ${stripTerminalControls(parsed.question)}`);
|
|
4964
4972
|
for (const line of pollFormatTallies(parsed.tallies)) console.log(line);
|
|
4965
4973
|
return;
|
|
4966
4974
|
}
|
|
@@ -4973,10 +4981,10 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
|
|
|
4973
4981
|
const reactions = await reactionsGet(token, channelId, ts, args.cookie);
|
|
4974
4982
|
const counted = pollTally(parsed.choices, reactions, exclude, parsed.multi);
|
|
4975
4983
|
|
|
4976
|
-
console.error(`${stripTerminalControls(parsed.question)}${parsed.deadline ? ` (
|
|
4984
|
+
console.error(`${stripTerminalControls(parsed.question)}${parsed.deadline ? ` (closes ${parsed.deadline})` : ""}`);
|
|
4977
4985
|
for (const line of pollFormatTallies(counted.tallies)) console.log(line);
|
|
4978
4986
|
const total = counted.tallies.reduce((n, t) => n + t.count, 0);
|
|
4979
|
-
console.error(`
|
|
4987
|
+
console.error(` ${total} vote(s) total${parsed.multi ? " (multiple choice)" : ""}`);
|
|
4980
4988
|
if (args.names) {
|
|
4981
4989
|
for (let i = 0; i < counted.voters.length; i++) {
|
|
4982
4990
|
if (!counted.voters[i]!.length) continue;
|
|
@@ -4995,8 +5003,8 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
|
|
|
4995
5003
|
await editMessage(args.writeToken ?? token, channelId, ts, nextText, args.writeToken ? args.writeCookie : args.cookie, true);
|
|
4996
5004
|
} catch (e: unknown) {
|
|
4997
5005
|
console.error(
|
|
4998
|
-
` (
|
|
4999
|
-
`
|
|
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.`,
|
|
5000
5008
|
);
|
|
5001
5009
|
}
|
|
5002
5010
|
}
|
|
@@ -5004,7 +5012,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
|
|
|
5004
5012
|
// Not counted for anything, and said so out loud: a reaction carries no
|
|
5005
5013
|
// timestamp anywhere in the Slack API, so "the latest one" does not exist.
|
|
5006
5014
|
const names = await Promise.all(counted.ambiguous.map((u) => userName(token, u, args.cookie)));
|
|
5007
|
-
console.error(`⚠
|
|
5015
|
+
console.error(`⚠ One vote per person, but some people pressed several (invalid votes): ${names.map((n) => `@${stripTerminalControls(n)}`).join(", ")}`);
|
|
5008
5016
|
}
|
|
5009
5017
|
|
|
5010
5018
|
if (!args.close) return;
|
|
@@ -5021,7 +5029,7 @@ async function cmdPollResults(token: string, args: { link: string; close?: boole
|
|
|
5021
5029
|
} catch {
|
|
5022
5030
|
// Cosmetic: the 📊 body is what makes it closed.
|
|
5023
5031
|
}
|
|
5024
|
-
console.error(`✓
|
|
5032
|
+
console.error(`✓ Closed the poll: ${stripTerminalControls(args.link)}`);
|
|
5025
5033
|
}
|
|
5026
5034
|
|
|
5027
5035
|
async function cmdDoctor(token: string): Promise<void> {
|
|
@@ -5844,7 +5852,19 @@ async function main(): Promise<void> {
|
|
|
5844
5852
|
await cmdTodoLs(tok(argv), opts);
|
|
5845
5853
|
};
|
|
5846
5854
|
|
|
5847
|
-
|
|
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)
|
|
5848
5868
|
.scriptName("slack")
|
|
5849
5869
|
// A thrown handler error (e.g. a Slack API failure) is a runtime error, not a usage
|
|
5850
5870
|
// mistake — print just the clean one-line message, never the command's --help block or a
|
|
@@ -5862,8 +5882,15 @@ async function main(): Promise<void> {
|
|
|
5862
5882
|
process.exit(1);
|
|
5863
5883
|
})
|
|
5864
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)" })
|
|
5865
5886
|
.middleware(async (argv) => {
|
|
5866
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
|
+
}
|
|
5867
5894
|
// `sent` reads only the local log — no token, no network. `relay` writes
|
|
5868
5895
|
// local files (init) or talks to the user's own relay (health): no Slack token.
|
|
5869
5896
|
if (!cmd || cmd === "auth" || cmd === "login" || cmd === "agent" || cmd === "sent" || cmd === "relay") return;
|
|
@@ -5884,6 +5911,17 @@ async function main(): Promise<void> {
|
|
|
5884
5911
|
console.error(refusal);
|
|
5885
5912
|
process.exit(2);
|
|
5886
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
|
+
}
|
|
5887
5925
|
} catch (e) {
|
|
5888
5926
|
const msg = e instanceof Error ? e.message : String(e);
|
|
5889
5927
|
if (msg.startsWith("No profiles configured")) {
|
|
@@ -6667,6 +6705,9 @@ async function main(): Promise<void> {
|
|
|
6667
6705
|
if ("error" in r) { refused.push(`${item.file}: ${r.error}`); continue; }
|
|
6668
6706
|
const text = applyPrefix(item.entry.text, settings.prefix);
|
|
6669
6707
|
if (!text.trim()) { refused.push(`${item.file}: empty`); continue; }
|
|
6708
|
+
// send/reply's URL-boundary refusal: a glued URL stays queued, the
|
|
6709
|
+
// reason in `refused`, for the author to fix the file.
|
|
6710
|
+
guardUrlBoundaries(text);
|
|
6670
6711
|
plan.push({ item, ...r, text });
|
|
6671
6712
|
} catch (e: unknown) {
|
|
6672
6713
|
refused.push(`${item.file}: ${friendlySlackError(e)}`);
|
|
@@ -6675,7 +6716,7 @@ async function main(): Promise<void> {
|
|
|
6675
6716
|
for (const r of refused) console.error(`refused (stays queued): ${r}`);
|
|
6676
6717
|
if (!plan.length) process.exit(2);
|
|
6677
6718
|
const lines = [`OUTBOX — ${plan.length} repl${plan.length === 1 ? "y" : "ies"}${asBot ? " as the bot" : ""}:`];
|
|
6678
|
-
for (const p of plan) lines.push(` → ${p.channel} thread ${p.threadTs}: ${stripTerminalControls(p.text).replace(/\n/g, "⏎").slice(0, 120)}`);
|
|
6719
|
+
for (const p of plan) lines.push(` → ${p.channel} thread ${p.threadTs}: ${stripTerminalControls(p.text).replace(/\n/g, "⏎").slice(0, 120)}${p.text.includes("[redacted:") ? " (secrets were masked when queued: this is what posts)" : ""}`);
|
|
6679
6720
|
const expected = safetyCode("outbox", asBot ? "bot" : "user", ...plan.map((p) => `${p.channel}:${p.threadTs}:${p.text}`));
|
|
6680
6721
|
if (argv.code !== expected) requireCode(argv.code, expected, lines);
|
|
6681
6722
|
let failed = 0;
|
|
@@ -6699,7 +6740,7 @@ async function main(): Promise<void> {
|
|
|
6699
6740
|
)
|
|
6700
6741
|
.command(
|
|
6701
6742
|
"ask [target] [question] [choices..]",
|
|
6702
|
-
"Ask a question with its choices pre-seeded as 1️⃣..🔟 reactions (confirm-hash safety gate). Lifecycle: --waitFor=<link> collect, --void=<link>
|
|
6743
|
+
"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…>`)",
|
|
6703
6744
|
(y) => y
|
|
6704
6745
|
.positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --waitFor)" })
|
|
6705
6746
|
.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." })
|
|
@@ -6708,10 +6749,10 @@ async function main(): Promise<void> {
|
|
|
6708
6749
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
6709
6750
|
.option("body", { type: "string", describe: "Extra context shown under the question" })
|
|
6710
6751
|
.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" })
|
|
6711
|
-
.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 (
|
|
6752
|
+
.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)." })
|
|
6712
6753
|
.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." })
|
|
6713
6754
|
.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." })
|
|
6714
|
-
.option("void", { type: "string", array: true, describe: "
|
|
6755
|
+
.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…>`." })
|
|
6715
6756
|
.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." })
|
|
6716
6757
|
.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." })
|
|
6717
6758
|
.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." })
|
|
@@ -6837,7 +6878,7 @@ async function main(): Promise<void> {
|
|
|
6837
6878
|
const { token, cookie } = askTokenFor(!!argv["as-bot"]);
|
|
6838
6879
|
const self = await selfIdentity(token, cookie);
|
|
6839
6880
|
const t = await askLoadOwnOpen(token, voidFlag[0]!, self, !!argv["as-bot"], cookie);
|
|
6840
|
-
if (t === "void") { console.error(`✗ ${stripTerminalControls(voidFlag[0]!)}:
|
|
6881
|
+
if (t === "void") { console.error(`✗ ${stripTerminalControls(voidFlag[0]!)}: already voided — to ask again, just use slack ask`); process.exit(ASK_EXIT_ERROR); }
|
|
6841
6882
|
if (!t) process.exit(ASK_EXIT_ERROR);
|
|
6842
6883
|
voidAfter = { ...t, reason: unescapeArg(String(argv.reason ?? "")).trim() };
|
|
6843
6884
|
// Same place, same words, unless new ones were given. The create path
|
|
@@ -7036,7 +7077,7 @@ async function main(): Promise<void> {
|
|
|
7036
7077
|
.positional("target", { type: "string", describe: "#chan, @user, #chan:thread_ts, or permalink (omit with --results/--close)" })
|
|
7037
7078
|
.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." })
|
|
7038
7079
|
.positional("choices", { type: "string", array: true, describe: "2..10 choices, seeded as 1️⃣..🔟 reactions for voters to click" })
|
|
7039
|
-
.option("multi", { type: "boolean", default: false, describe: "Let one person pick several choices (default:
|
|
7080
|
+
.option("multi", { type: "boolean", default: false, describe: "Let one person pick several choices (default: one vote per person)" })
|
|
7040
7081
|
.option("until", { type: "string", describe: "Deadline shown in the message (display only — closing is still `--close`)" })
|
|
7041
7082
|
.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)." })
|
|
7042
7083
|
.option("close", { type: "string", describe: "Count an existing poll AND freeze the tally into the message. Only the poster can do this." })
|
|
@@ -8289,7 +8330,8 @@ async function main(): Promise<void> {
|
|
|
8289
8330
|
.option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
|
|
8290
8331
|
.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" })
|
|
8291
8332
|
.option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" })
|
|
8292
|
-
.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)" })
|
|
8333
|
+
.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)" })
|
|
8334
|
+
.option("reactions", { type: "boolean", default: false, describe: "Relay only: also print reaction_added bells as {type: \"reaction\", channel, ts, user} (the emoji: reactions.get)" }),
|
|
8293
8335
|
async (argv) => {
|
|
8294
8336
|
let re: RegExp;
|
|
8295
8337
|
let reconcileMs: number;
|
|
@@ -8405,6 +8447,7 @@ async function main(): Promise<void> {
|
|
|
8405
8447
|
selfUsers,
|
|
8406
8448
|
selfBots,
|
|
8407
8449
|
json: argv.json,
|
|
8450
|
+
reactions: argv.reactions,
|
|
8408
8451
|
...(argv.inbox ? {
|
|
8409
8452
|
onMatch: (rec: StreamMatch, files: number) => {
|
|
8410
8453
|
try {
|