slack-term 1.48.0 → 1.49.1
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 +56 -0
- package/dist/cli.js +289 -249
- package/locales/en.json +32 -0
- package/locales/ja.json +31 -0
- package/package.json +2 -1
- package/ts/ask.ts +47 -46
- package/ts/botIntent.ts +1 -1
- package/ts/choiceHint.ts +30 -0
- package/ts/cli.ts +739 -123
- package/ts/inbox.ts +191 -0
- package/ts/locales.ts +113 -0
- package/ts/readback.ts +102 -0
- package/ts/redact.ts +225 -0
- package/ts/scope.ts +472 -0
- package/ts/slack.ts +40 -2
- package/ts/stream.ts +3 -0
- package/ts/tick.ts +47 -0
- package/ts/vendor/gitleaks.LICENSE +21 -0
- package/ts/vendor/gitleaks.toml +3209 -0
package/ts/cli.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { existsSync, readFileSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
|
-
import { join, resolve } from "node:path";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
8
|
|
|
9
9
|
import yargs, { type Options } from "yargs";
|
|
10
10
|
import { hideBin } from "yargs/helpers";
|
|
@@ -13,7 +13,7 @@ import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveTok
|
|
|
13
13
|
import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
|
|
14
14
|
import { cmdAuthLogin, cmdAuthLoginChrome, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp, cmdAuthSave, cmdAuthTokens } from "./auth.ts";
|
|
15
15
|
import { cmdTail, parseSince as parseDuration } from "./tail.ts";
|
|
16
|
-
import { runStream, webClient } from "./stream.ts";
|
|
16
|
+
import { runStream, webClient, type StreamMatch } from "./stream.ts";
|
|
17
17
|
import { relaySubscriber, relayTransport } from "./relay.ts";
|
|
18
18
|
import { DEFAULT_DIR, initRelay, initReport, relayHealth } from "./relayInit.ts";
|
|
19
19
|
import { agentCommands } from "./agent.ts";
|
|
@@ -48,7 +48,14 @@ import {
|
|
|
48
48
|
ASK_LANGS,
|
|
49
49
|
type AskFound,
|
|
50
50
|
type AskLang,
|
|
51
|
+
ASK_POLL,
|
|
52
|
+
askNextPollDelay,
|
|
51
53
|
} from "./ask.ts";
|
|
54
|
+
import { applyPrefix, findScope, initScope, isRestricted, listAliases, lookupAlias, readSettings, RestrictedError, setAlias, threadMismatch, unsetAlias, whoSubscribes, type MessagePlace, type Scope } from "./scope.ts";
|
|
55
|
+
import { keepLanePrefix, tickText } from "./tick.ts";
|
|
56
|
+
import { emojiMap, firstDifference, normText, storedAsSent } from "./readback.ts";
|
|
57
|
+
import { choiceHint, choiceQuestionLang } from "./choiceHint.ts";
|
|
58
|
+
import { addOutbox, deliver, listInbox, listOutbox, markDone, overdue, recordFailure, recordSent as recordOutboxSent } from "./inbox.ts";
|
|
52
59
|
import {
|
|
53
60
|
POLL_KEYCAPS,
|
|
54
61
|
POLL_MARKER,
|
|
@@ -107,6 +114,7 @@ import {
|
|
|
107
114
|
send as slackSend,
|
|
108
115
|
pinsAdd,
|
|
109
116
|
RateLimitError,
|
|
117
|
+
setRateLimitWait,
|
|
110
118
|
repliesCursor,
|
|
111
119
|
type MessageMetadata,
|
|
112
120
|
getPermalink,
|
|
@@ -439,10 +447,47 @@ async function cmdMsgsTarget(token: string, target: string, limit: number, forma
|
|
|
439
447
|
}
|
|
440
448
|
|
|
441
449
|
// --- thread ---
|
|
442
|
-
|
|
450
|
+
/** One message as `<ts> TAB <author> TAB <text>`: the author is the user or
|
|
451
|
+
* bot id, the text Slack's stored form with `\` newline CR TAB escaped, and
|
|
452
|
+
* attachments counted as ` [files: N]` (the earlier thread tool's read format). */
|
|
453
|
+
function tsvLine(m: Record<string, Json>): string {
|
|
454
|
+
const esc = (t: string): string => t.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
455
|
+
const author = typeof m.user === "string" ? m.user : typeof m.bot_id === "string" ? m.bot_id : "";
|
|
456
|
+
const files = Array.isArray(m.files) && m.files.length ? ` [files: ${m.files.length}]` : "";
|
|
457
|
+
return `${String(m.ts)}\t${author}\t${esc(typeof m.text === "string" ? m.text : "")}${files}`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Every message of a thread, every page — or an error: a thread that cannot
|
|
461
|
+
* be read whole is never returned as if it were complete. */
|
|
462
|
+
async function wholeThread(token: string, channelId: string, ts: string, cookie?: string): Promise<Record<string, Json>[]> {
|
|
463
|
+
const out: Record<string, Json>[] = [];
|
|
464
|
+
let cursor: string | undefined;
|
|
465
|
+
for (let page = 0; page < 200; page++) {
|
|
466
|
+
const r = asRecord((await repliesPage(token, channelId, ts, cursor ? { cursor } : {}, cookie)) as Json);
|
|
467
|
+
for (const m of asArray(r.messages).map(asRecord)) if (!out.some((o) => o.ts === m.ts)) out.push(m);
|
|
468
|
+
cursor = String(asRecord(r.response_metadata).next_cursor ?? "") || undefined;
|
|
469
|
+
if (!cursor) return out;
|
|
470
|
+
}
|
|
471
|
+
throw new Error("thread has more than 200 pages: refusing to return it partially");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function cmdThread(token: string, target: string, ts: string, limit: number, format: "text" | "jsonl" | "tsv" = "text", cookie?: string, since?: string): Promise<void> {
|
|
443
475
|
const channelId = await resolveChannel(token, target, cookie);
|
|
444
|
-
const
|
|
445
|
-
|
|
476
|
+
const all = since !== undefined || format === "tsv";
|
|
477
|
+
let msgs = all
|
|
478
|
+
? await wholeThread(token, channelId, parseInputTs(ts), cookie)
|
|
479
|
+
: asArray(((await replies(token, channelId, parseInputTs(ts), limit, cookie)) as Record<string, Json>).messages).map(asRecord);
|
|
480
|
+
if (since !== undefined) {
|
|
481
|
+
// The same ts forms the thread argument takes (Slack ts or the ISO stamp
|
|
482
|
+
// `read` prints); anything else would compare as NaN and drop every reply.
|
|
483
|
+
const after = Number(parseInputTs(since));
|
|
484
|
+
if (!Number.isFinite(after)) throw new Error(`--since needs a message ts (1700000000.000100) or ISO stamp (got ${stripTerminalControls(since)})`);
|
|
485
|
+
msgs = msgs.filter((m) => Number(m.ts) > after);
|
|
486
|
+
}
|
|
487
|
+
if (format === "tsv") {
|
|
488
|
+
for (const m of msgs) console.log(tsvLine(m));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
446
491
|
if (format === "jsonl") {
|
|
447
492
|
for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
|
|
448
493
|
return;
|
|
@@ -1287,9 +1332,89 @@ function parseTargetThread(s: string, permalinkThreads = true): { ref: string; t
|
|
|
1287
1332
|
if (/^\d{10}\.\d{6}$/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: maybeTs };
|
|
1288
1333
|
if (/^\d{4}-\d{2}-\d{2}T/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: isoToSlackTs(maybeTs) };
|
|
1289
1334
|
}
|
|
1335
|
+
// A bare name (no #, @ or :) that is an alias in the nearest .slack-term/.
|
|
1336
|
+
const alias = lookupAlias(s);
|
|
1337
|
+
if (alias) return alias.thread_ts ? { ref: alias.channel, threadTs: alias.thread_ts } : { ref: alias.channel };
|
|
1290
1338
|
return { ref: s };
|
|
1291
1339
|
}
|
|
1292
1340
|
|
|
1341
|
+
/** Where `slack reply` (and `outbox flush`) posts: the destination — alias,
|
|
1342
|
+
* permalink (its root), `#chan:<ts>` or a bare ts in settings' channel —
|
|
1343
|
+
* and/or `--to`'s message, which must agree with it unless `anyThread`. A
|
|
1344
|
+
* restricted scope also requires the thread to be one of its subscriptions.
|
|
1345
|
+
* `{ error }` is a refusal (exit 2): nothing was sent. */
|
|
1346
|
+
async function resolveReplyTarget(
|
|
1347
|
+
token: string, cookie: string | undefined, scope: Scope, dest: string | undefined, to: string | undefined, anyThread = false,
|
|
1348
|
+
): Promise<{ channel: string; threadTs: string } | { error: string }> {
|
|
1349
|
+
const settings = readSettings(scope);
|
|
1350
|
+
let channel: string | undefined;
|
|
1351
|
+
let threadTs: string | undefined;
|
|
1352
|
+
if (dest) {
|
|
1353
|
+
if (parseSlackPermalink(dest)) {
|
|
1354
|
+
const place = await messagePlace(token, dest, cookie);
|
|
1355
|
+
channel = place.channel;
|
|
1356
|
+
threadTs = place.root;
|
|
1357
|
+
} else {
|
|
1358
|
+
let p = parseTargetThread(dest);
|
|
1359
|
+
if (!p.threadTs && /^\d{10}\.\d{6}$/.test(dest)) {
|
|
1360
|
+
if (!settings.channel) return { error: `a bare ts needs a channel: set \`channel\` in .slack-term/settings.yml, or pass #chan:${dest}` };
|
|
1361
|
+
p = { ref: settings.channel, threadTs: dest };
|
|
1362
|
+
}
|
|
1363
|
+
if (!p.threadTs) return { error: `"${dest}" is not a thread — reply only posts in threads. For a top-level post use \`slack send\`.` };
|
|
1364
|
+
channel = await resolveChannel(token, p.ref, cookie);
|
|
1365
|
+
threadTs = p.threadTs;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
if (to) {
|
|
1369
|
+
const answered = await messagePlace(token, to, cookie);
|
|
1370
|
+
if (channel === undefined) {
|
|
1371
|
+
channel = answered.channel;
|
|
1372
|
+
threadTs = answered.root;
|
|
1373
|
+
} else if (!anyThread) {
|
|
1374
|
+
const why = threadMismatch({ channel, ...(threadTs ? { threadTs } : {}) }, answered);
|
|
1375
|
+
if (why) return { error: why };
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
if (!channel || !threadTs) return { error: "reply needs a destination (alias, permalink, #chan:<ts>) or --to <permalink>" };
|
|
1379
|
+
if (isRestricted(scope) && !listAliases(scope).some((a) => a.channel === channel && (a.thread_ts === undefined || a.thread_ts === threadTs))) {
|
|
1380
|
+
return { error: `${channel} thread ${threadTs} is not one of ${scope.dir}'s subscriptions (restrict_to_subscriptions)` };
|
|
1381
|
+
}
|
|
1382
|
+
return { channel, threadTs };
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** Where the message a permalink (or `#chan:<ts>`) names really sits: its
|
|
1386
|
+
* channel and thread root. A permalink's `?thread_ts=` settles it; otherwise
|
|
1387
|
+
* the thread is read, since a reply's bare permalink looks like a root's. */
|
|
1388
|
+
async function messagePlace(token: string, target: string, cookie?: string): Promise<MessagePlace> {
|
|
1389
|
+
const url = parseSlackPermalink(target);
|
|
1390
|
+
let ref: string;
|
|
1391
|
+
let ts: string | undefined;
|
|
1392
|
+
let root: string | undefined;
|
|
1393
|
+
if (url) {
|
|
1394
|
+
ref = url.channel;
|
|
1395
|
+
ts = url.ts;
|
|
1396
|
+
root = url.threadTs;
|
|
1397
|
+
} else {
|
|
1398
|
+
const p = parseTargetThread(target);
|
|
1399
|
+
ref = p.ref;
|
|
1400
|
+
ts = p.threadTs;
|
|
1401
|
+
}
|
|
1402
|
+
if (!ts) throw new Error(`--to needs a message permalink (or #chan:<ts>), got "${target}"`);
|
|
1403
|
+
const channel = await resolveChannel(token, ref, cookie);
|
|
1404
|
+
if (!root) {
|
|
1405
|
+
try {
|
|
1406
|
+
const msgs = (asRecord((await replies(token, channel, ts, 1, cookie)) as Json).messages ?? []) as Json[];
|
|
1407
|
+
const own = msgs.map(asRecord).find((m) => m.ts === ts) ?? asRecord(msgs[0]);
|
|
1408
|
+
root = typeof own.thread_ts === "string" ? own.thread_ts : ts;
|
|
1409
|
+
} catch (e) {
|
|
1410
|
+
// A top-level message nobody answered has no thread to read.
|
|
1411
|
+
if (!/thread_not_found/.test(e instanceof Error ? e.message : String(e))) throw e;
|
|
1412
|
+
root = ts;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return { channel, ts, root };
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1293
1418
|
/** Human-readable destination label for confirm gates, e.g. "#general (C0123456789)".
|
|
1294
1419
|
* When ref is already a #channel/@user label, keep it; otherwise resolve the channel
|
|
1295
1420
|
* name via conversations.info (fail-soft: a raw ID is still unambiguous). */
|
|
@@ -1489,6 +1614,12 @@ interface EditArgs {
|
|
|
1489
1614
|
* `send --as-bot`. */
|
|
1490
1615
|
mentionToken?: string;
|
|
1491
1616
|
mentionCookie?: string;
|
|
1617
|
+
/** `slack tick`: the new text is the original with item `n` (or the one open
|
|
1618
|
+
* item) ticked — computed from what is stored, so nothing else changes. */
|
|
1619
|
+
tick?: { n?: number };
|
|
1620
|
+
/** The scope's `[lane]` prefix: an edit keeps the lane tag the original
|
|
1621
|
+
* was posted with. */
|
|
1622
|
+
lanePrefix?: string;
|
|
1492
1623
|
}
|
|
1493
1624
|
/** The bot token, plus the bot↔user DM when the target names an `@handle`.
|
|
1494
1625
|
*
|
|
@@ -1559,11 +1690,24 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
1559
1690
|
}
|
|
1560
1691
|
const originalText = typeof original.text === "string" ? original.text : "";
|
|
1561
1692
|
|
|
1693
|
+
let wanted = args.newText;
|
|
1694
|
+
if (args.tick) {
|
|
1695
|
+
const r = tickText(originalText, args.tick.n);
|
|
1696
|
+
if (r.kind === "already") { console.log(`already ticked (${r.n}.)`); process.exit(0); }
|
|
1697
|
+
if (r.kind === "none") { console.error("Error: the message has no ⬜ <n>. item"); process.exit(2); }
|
|
1698
|
+
if (r.kind === "missing") { console.error(`Error: the message has no ⬜ ${r.n}. item`); process.exit(2); }
|
|
1699
|
+
if (r.kind === "ambiguous") { console.error(`Error: several open items (${r.open.join(", ")}): name one, e.g. slack tick <target> ${r.open[0]}`); process.exit(2); }
|
|
1700
|
+
wanted = r.text;
|
|
1701
|
+
} else {
|
|
1702
|
+
wanted = keepLanePrefix(originalText, wanted, args.lanePrefix);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1562
1705
|
// Convert @handle → <@USERID> before hashing/editing (unresolved stay as text).
|
|
1706
|
+
// A tick rewrites stored text, whose mentions are encoded already.
|
|
1563
1707
|
const mentionCookie = args.mentionCookie ?? args.cookie;
|
|
1564
|
-
const newText = args.mentions
|
|
1565
|
-
? await encodeMentions(args.mentionToken ?? token,
|
|
1566
|
-
:
|
|
1708
|
+
const newText = args.mentions && !args.tick
|
|
1709
|
+
? await encodeMentions(args.mentionToken ?? token, wanted, channelId, mentionCookie ? { cookie: mentionCookie } : {})
|
|
1710
|
+
: wanted;
|
|
1567
1711
|
|
|
1568
1712
|
guardUrlBoundaries(newText, args.allowUrlAdjacent);
|
|
1569
1713
|
|
|
@@ -2011,7 +2155,26 @@ async function cmdPinlogUpdate(token: string, args: PinlogUpdateArgs): Promise<v
|
|
|
2011
2155
|
process.exit(1);
|
|
2012
2156
|
}
|
|
2013
2157
|
attrEdit.record({ team: self?.team, channel, target: args.target, ts, text: newHead, asBot: args.asBot });
|
|
2014
|
-
|
|
2158
|
+
// Read it back: Slack rewrites text when it stores it, so compare the
|
|
2159
|
+
// normalised forms (ts/readback.ts). A board that does not show what was
|
|
2160
|
+
// sent gets no log line — exit 3, like the earlier thread tool's `state`.
|
|
2161
|
+
let stored: Record<string, Json> | undefined;
|
|
2162
|
+
try {
|
|
2163
|
+
const back = asArray(asRecord((await replies(token, channel, ts, 1, args.cookie)) as Json).messages).map(asRecord);
|
|
2164
|
+
stored = back.find((m) => m.ts === ts);
|
|
2165
|
+
if (!stored) console.error(`⚠ HEAD updated, but the read-back did not return it, so it was not checked`);
|
|
2166
|
+
} catch (e: unknown) {
|
|
2167
|
+
console.error(`⚠ HEAD updated, but it could not be read back to check: ${friendlySlackError(e)}`);
|
|
2168
|
+
}
|
|
2169
|
+
if (stored && !storedAsSent(newHead, stored)) {
|
|
2170
|
+
const names = emojiMap(stored.blocks);
|
|
2171
|
+
console.error(
|
|
2172
|
+
`✗ HEAD was edited, but Slack does not show what was sent (${firstDifference(normText(newHead, names), normText(typeof stored.text === "string" ? stored.text : "", names))}).\n` +
|
|
2173
|
+
` The log reply was NOT posted. Check it: slack pinlog get ${id}${bot}`,
|
|
2174
|
+
);
|
|
2175
|
+
process.exit(3);
|
|
2176
|
+
}
|
|
2177
|
+
console.log(`✓ HEAD updated${stored ? " (read back: matches)" : ""}: ${id}`);
|
|
2015
2178
|
}
|
|
2016
2179
|
|
|
2017
2180
|
const attrLog = sentAttribution("send");
|
|
@@ -2302,6 +2465,9 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
2302
2465
|
const { ref, threadTs } = parseTargetThread(args.target);
|
|
2303
2466
|
const cookie = args.cookie;
|
|
2304
2467
|
|
|
2468
|
+
// Hint only, on the preview: a choice asked in prose makes the reader type.
|
|
2469
|
+
if (!args.code && choiceQuestionLang(args.message)) console.error(choiceHint(args.target));
|
|
2470
|
+
|
|
2305
2471
|
// Identity of the token that will actually post — the bot token under
|
|
2306
2472
|
// --as-bot, the user token otherwise.
|
|
2307
2473
|
const getSelf = selfLookup(token, cookie);
|
|
@@ -2643,9 +2809,6 @@ function askExplainDefault(): boolean {
|
|
|
2643
2809
|
return /^(1|true|yes|on)$/i.test(process.env.SLACK_TERM_ASK_EXPLAIN ?? "");
|
|
2644
2810
|
}
|
|
2645
2811
|
|
|
2646
|
-
const ASK_PHI = 1.618033988749895;
|
|
2647
|
-
const ASK_POLL_MIN_MS = 1000;
|
|
2648
|
-
const ASK_POLL_MAX_MS = 5000;
|
|
2649
2812
|
const ASK_MAX_CONSECUTIVE_ERRORS = 8;
|
|
2650
2813
|
|
|
2651
2814
|
|
|
@@ -3336,8 +3499,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3336
3499
|
}
|
|
3337
3500
|
|
|
3338
3501
|
const deadline = Date.now() + timeout * 1000;
|
|
3502
|
+
const started = Date.now();
|
|
3339
3503
|
let errors = 0;
|
|
3340
|
-
let delay =
|
|
3504
|
+
let delay: number = ASK_POLL.minMs;
|
|
3505
|
+
/** A 429's Retry-After: the next poll waits at least this long. */
|
|
3506
|
+
let coolMs = 0;
|
|
3341
3507
|
let unchosen: AskPollState["unchosen"];
|
|
3342
3508
|
for (;;) {
|
|
3343
3509
|
let found: AskFound | null = null;
|
|
@@ -3345,13 +3511,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3345
3511
|
found = await poll();
|
|
3346
3512
|
errors = 0;
|
|
3347
3513
|
} catch (e: unknown) {
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3514
|
+
if (e instanceof RateLimitError) {
|
|
3515
|
+
// Not a failure: the bucket is shared with every other waiter on this
|
|
3516
|
+
// token. Polling again before Retry-After only keeps it at 429.
|
|
3517
|
+
coolMs = e.retryAfter * 1000;
|
|
3518
|
+
} else {
|
|
3519
|
+
// A dropped poll is expected occasionally; only give up if it keeps failing.
|
|
3520
|
+
errors++;
|
|
3521
|
+
if (errors >= ASK_MAX_CONSECUTIVE_ERRORS) {
|
|
3522
|
+
console.error(`Error: Slack への問い合わせに ${errors} 回連続で失敗しました: ${e instanceof Error ? e.message : String(e)}`);
|
|
3523
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3524
|
+
}
|
|
3525
|
+
console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
|
|
3353
3526
|
}
|
|
3354
|
-
console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
|
|
3355
3527
|
}
|
|
3356
3528
|
unchosen = state.unchosen;
|
|
3357
3529
|
const voided = state.voided;
|
|
@@ -3442,8 +3614,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3442
3614
|
// Checked after the first poll, never before it: `--timeout 0` means one
|
|
3443
3615
|
// look, and one look must still be a real look.
|
|
3444
3616
|
if (Date.now() >= deadline) break;
|
|
3445
|
-
await askSleep(Math.min(delay, Math.max(0, deadline - Date.now())));
|
|
3446
|
-
|
|
3617
|
+
await askSleep(Math.min(Math.max(delay, coolMs), Math.max(0, deadline - Date.now())));
|
|
3618
|
+
coolMs = 0;
|
|
3619
|
+
delay = askNextPollDelay(delay, Date.now() - started);
|
|
3447
3620
|
}
|
|
3448
3621
|
|
|
3449
3622
|
// A timeout is the moment the caller most needs the collect command: the
|
|
@@ -5501,6 +5674,145 @@ async function main(): Promise<void> {
|
|
|
5501
5674
|
const tok = (a: W) => resolveToken(a.workspace);
|
|
5502
5675
|
const ck = (a: W) => resolveCookie(a.workspace);
|
|
5503
5676
|
|
|
5677
|
+
/** The `send` handler, shared with `reply` (which resolves its own target,
|
|
5678
|
+
* prefix and thread check first). */
|
|
5679
|
+
type SendCli = {
|
|
5680
|
+
workspace?: string | undefined; target?: string | undefined; message?: string | undefined; code?: string | undefined;
|
|
5681
|
+
"allow-url-adjacent"?: boolean | undefined; "channel-id"?: string | undefined; "user-id"?: string | undefined;
|
|
5682
|
+
"as-bot"?: boolean | undefined; broadcast?: boolean | undefined; mentions?: boolean | undefined;
|
|
5683
|
+
};
|
|
5684
|
+
/** The `edit` handler, shared with `tick`. In a .slack-term/ scope the
|
|
5685
|
+
* lane's tag is kept; in a restricted one the message must sit in one of
|
|
5686
|
+
* its subscriptions. */
|
|
5687
|
+
type EditCli = {
|
|
5688
|
+
workspace?: string | undefined; target?: string | undefined; newText?: string | undefined; code?: string | undefined;
|
|
5689
|
+
force?: boolean | undefined; "allow-url-adjacent"?: boolean | undefined; "channel-id"?: string | undefined;
|
|
5690
|
+
mentions?: boolean | undefined; "as-bot"?: boolean | undefined; tick?: { n?: number } | undefined;
|
|
5691
|
+
};
|
|
5692
|
+
const runEdit = async (argv: EditCli): Promise<void> => {
|
|
5693
|
+
const scope = findScope();
|
|
5694
|
+
const settings = readSettings(scope);
|
|
5695
|
+
if (isRestricted(scope)) {
|
|
5696
|
+
try {
|
|
5697
|
+
const place = await messagePlace(tok(argv as W), argv.target!, ck(argv as W));
|
|
5698
|
+
if (!listAliases(scope).some((a) => a.channel === place.channel && (a.thread_ts === undefined || a.thread_ts === place.root))) {
|
|
5699
|
+
console.error(`Error: that message is not in one of ${scope.dir}'s subscriptions (restrict_to_subscriptions)`);
|
|
5700
|
+
process.exit(2);
|
|
5701
|
+
}
|
|
5702
|
+
} catch (e: unknown) {
|
|
5703
|
+
console.error(friendlySlackError(e));
|
|
5704
|
+
process.exit(1);
|
|
5705
|
+
}
|
|
5706
|
+
}
|
|
5707
|
+
const args: EditArgs = { target: argv.target!, newText: argv.newText ?? "", allowUrlAdjacent: argv["allow-url-adjacent"] === true };
|
|
5708
|
+
if (argv.tick) args.tick = argv.tick;
|
|
5709
|
+
if (settings.prefix) args.lanePrefix = settings.prefix;
|
|
5710
|
+
if (argv.force) args.force = true;
|
|
5711
|
+
if (argv.code) args.code = argv.code;
|
|
5712
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
5713
|
+
if (argv.mentions !== false) args.mentions = true;
|
|
5714
|
+
let editToken: string;
|
|
5715
|
+
if (argv["as-bot"]) {
|
|
5716
|
+
// --as-bot is not a switch on chat.update. It selects a DIFFERENT
|
|
5717
|
+
// resolved token, and until now `edit` never wired one — so every
|
|
5718
|
+
// message the bot posted was uncorrectable, and the workaround people
|
|
5719
|
+
// reached for was RESENDING, which this repo's own etiquette forbids.
|
|
5720
|
+
// Bot identity is what makes a DM notify at all, so "uncorrectable"
|
|
5721
|
+
// applied to exactly the messages that matter most.
|
|
5722
|
+
const resolved = await asBotTarget(args.target, tok(argv as W), ck(argv as W), args.channelId);
|
|
5723
|
+
editToken = resolved.botToken;
|
|
5724
|
+
if (resolved.channelId && !args.channelId) args.channelId = resolved.channelId;
|
|
5725
|
+
args.asBot = true;
|
|
5726
|
+
// Mentions still resolve on the USER token: the bot has no users:read,
|
|
5727
|
+
// and a silently unresolved @handle in a CORRECTION is the same defect
|
|
5728
|
+
// twice.
|
|
5729
|
+
args.mentionToken = tok(argv as W);
|
|
5730
|
+
const mc = ck(argv as W);
|
|
5731
|
+
if (mc) args.mentionCookie = mc;
|
|
5732
|
+
} else {
|
|
5733
|
+
editToken = tok(argv as W);
|
|
5734
|
+
// Not set under --as-bot: a bot token needs no session cookie, and
|
|
5735
|
+
// attaching one is how a working call starts failing.
|
|
5736
|
+
const cookie = ck(argv as W);
|
|
5737
|
+
if (cookie) args.cookie = cookie;
|
|
5738
|
+
}
|
|
5739
|
+
await cmdEdit(editToken, args);
|
|
5740
|
+
};
|
|
5741
|
+
|
|
5742
|
+
const runSend = async (argv: SendCli): Promise<void> => {
|
|
5743
|
+
// An empty body is never a meaningful send. `ask` already refuses one
|
|
5744
|
+
// ("Error: question is empty"); `send` did not, so an empty draft
|
|
5745
|
+
// staged a confirm code and posting it produced a blank message.
|
|
5746
|
+
// Verification cannot catch this: an empty publish reads back as an
|
|
5747
|
+
// exact match, so refusing here is the only mechanism.
|
|
5748
|
+
if (!String(argv.message ?? "").trim()) {
|
|
5749
|
+
console.error("Error: message is empty");
|
|
5750
|
+
process.exit(1);
|
|
5751
|
+
}
|
|
5752
|
+
const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] === true };
|
|
5753
|
+
if (argv.code) args.code = argv.code;
|
|
5754
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
5755
|
+
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
5756
|
+
if (argv.broadcast) args.broadcast = true;
|
|
5757
|
+
// Mentions on by default; `--no-mentions` (yargs negation) sets it false.
|
|
5758
|
+
// A message with no `@token` short-circuits inside encodeMentions, so the
|
|
5759
|
+
// common case pays no extra API call.
|
|
5760
|
+
if (argv.mentions !== false) {
|
|
5761
|
+
args.mentions = true;
|
|
5762
|
+
// Resolve mentions with the user token (has users:read) even when the
|
|
5763
|
+
// message itself is sent via the bot token. Pass its cookie too so an
|
|
5764
|
+
// xoxc- desktop token can reach users.list (rejected without the cookie).
|
|
5765
|
+
args.mentionToken = tok(argv as W);
|
|
5766
|
+
const mc = ck(argv as W);
|
|
5767
|
+
if (mc) args.mentionCookie = mc;
|
|
5768
|
+
}
|
|
5769
|
+
let sendToken: string;
|
|
5770
|
+
if (argv["as-bot"]) {
|
|
5771
|
+
const botToken = resolveBotToken();
|
|
5772
|
+
if (!botToken) {
|
|
5773
|
+
console.error(
|
|
5774
|
+
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
5775
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
5776
|
+
);
|
|
5777
|
+
process.exit(1);
|
|
5778
|
+
}
|
|
5779
|
+
// Resolve @user → user_id with the *user* token (has users:read), then
|
|
5780
|
+
// let cmdSend open the DM with the bot token (im:write). This avoids
|
|
5781
|
+
// requiring users:read on the bot and guarantees the DM is bot↔user
|
|
5782
|
+
// (not the user's own self-DM).
|
|
5783
|
+
if (!args.channelId && !args.userId && args.target.startsWith("@")) {
|
|
5784
|
+
try {
|
|
5785
|
+
args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
|
|
5786
|
+
} catch (e: unknown) {
|
|
5787
|
+
console.error(
|
|
5788
|
+
`Error: could not resolve ${args.target} to a user for the bot DM.\n` +
|
|
5789
|
+
` ${e instanceof Error ? e.message : String(e)}\n` +
|
|
5790
|
+
` Tip: pass --user-id <Uxxxx> to skip the lookup.`,
|
|
5791
|
+
);
|
|
5792
|
+
process.exit(1);
|
|
5793
|
+
}
|
|
5794
|
+
}
|
|
5795
|
+
sendToken = botToken;
|
|
5796
|
+
args.asBot = true;
|
|
5797
|
+
} else {
|
|
5798
|
+
sendToken = tok(argv as W);
|
|
5799
|
+
// Session cookie for `sendToken` itself — needed for an xoxc- desktop
|
|
5800
|
+
// token to be accepted by the public API at all. Not applicable to the
|
|
5801
|
+
// bot token above (bot tokens need no cookie).
|
|
5802
|
+
const sc = ck(argv as W);
|
|
5803
|
+
if (sc) args.cookie = sc;
|
|
5804
|
+
}
|
|
5805
|
+
// Print a clean error instead of yargs' usage dump when a send fails at
|
|
5806
|
+
// runtime (e.g. missing scope, channel not found). The confirm gate
|
|
5807
|
+
// exits via process.exit, so it is unaffected.
|
|
5808
|
+
try {
|
|
5809
|
+
await cmdSend(sendToken, args);
|
|
5810
|
+
} catch (e: unknown) {
|
|
5811
|
+
console.error(friendlySlackError(e));
|
|
5812
|
+
process.exit(1);
|
|
5813
|
+
}
|
|
5814
|
+
};
|
|
5815
|
+
|
|
5504
5816
|
// `todo ls` and `mytodo ls` are the same listing over two different search
|
|
5505
5817
|
// scopes — `has:` (anyone's reactions) vs `hasmy:` (only this token's). They
|
|
5506
5818
|
// share one definition so the flags can never drift apart; `mine` is the only
|
|
@@ -5594,7 +5906,7 @@ async function main(): Promise<void> {
|
|
|
5594
5906
|
["read [target]", "msgs [target]"],
|
|
5595
5907
|
"Browse messages",
|
|
5596
5908
|
(y) => y
|
|
5597
|
-
.positional("target", { type: "string", describe: "#channel, @user, or
|
|
5909
|
+
.positional("target", { type: "string", describe: "#channel, @user, URL, or an alias (slack alias ls)" })
|
|
5598
5910
|
.option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" })
|
|
5599
5911
|
.option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
|
|
5600
5912
|
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
|
|
@@ -5602,7 +5914,11 @@ async function main(): Promise<void> {
|
|
|
5602
5914
|
async (argv) => {
|
|
5603
5915
|
const token = tok(argv as W);
|
|
5604
5916
|
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
|
|
5605
|
-
|
|
5917
|
+
// `slack read <alias>`: the thread (or channel) it names.
|
|
5918
|
+
const alias = argv.target ? lookupAlias(argv.target) : undefined;
|
|
5919
|
+
if (alias?.thread_ts) await cmdThread(token, alias.channel, alias.thread_ts, Math.max(argv.limit, 100), format, ck(argv as W));
|
|
5920
|
+
else if (alias) await cmdMsgsTarget(token, alias.channel, argv.limit, format, argv.unreplied, ck(argv as W));
|
|
5921
|
+
else if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format, argv.unreplied, ck(argv as W));
|
|
5606
5922
|
else await cmdMsgs(token);
|
|
5607
5923
|
},
|
|
5608
5924
|
)
|
|
@@ -5613,11 +5929,12 @@ async function main(): Promise<void> {
|
|
|
5613
5929
|
.positional("target", { type: "string", demandOption: true })
|
|
5614
5930
|
.positional("ts", { type: "string", demandOption: true })
|
|
5615
5931
|
.option("limit", { alias: "n", type: "number", default: 100 })
|
|
5616
|
-
.option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
|
|
5617
|
-
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
|
|
5932
|
+
.option("format", { type: "string", choices: ["text", "jsonl", "tsv"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs); tsv: <ts> TAB <user|bot id> TAB <text, \\ \\n \\r \\t escaped> [files: N], every page" })
|
|
5933
|
+
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
|
|
5934
|
+
.option("since", { type: "string", describe: "Only messages after this ts (reads every page)" }),
|
|
5618
5935
|
async (argv) => {
|
|
5619
|
-
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
|
|
5620
|
-
await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format, ck(argv as W));
|
|
5936
|
+
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl" | "tsv");
|
|
5937
|
+
await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format, ck(argv as W), argv.since);
|
|
5621
5938
|
},
|
|
5622
5939
|
)
|
|
5623
5940
|
.command(
|
|
@@ -6011,81 +6328,385 @@ async function main(): Promise<void> {
|
|
|
6011
6328
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
6012
6329
|
.option("as-bot", { type: "boolean", default: false, describe: "Send via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient and can be two-way" })
|
|
6013
6330
|
.option("broadcast", { type: "boolean", default: false, describe: "Also send to channel: broadcast a threaded reply back to the channel (Slack's \"Also send to #channel\" checkbox). Only effective with a thread target." })
|
|
6014
|
-
.option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real <@USERID> mentions (on by default; resolves via users.list, then channel members for Slack Connect guests). Unresolved handles stay as plain text. The confirm preview shows the converted message before sending. Disable with --no-mentions for literal @text." })
|
|
6331
|
+
.option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real <@USERID> mentions (on by default; resolves via users.list, then channel members for Slack Connect guests). Unresolved handles stay as plain text. The confirm preview shows the converted message before sending. Disable with --no-mentions for literal @text." })
|
|
6332
|
+
.option("to", { type: "string", describe: "Permalink of the message you are answering: refuses when it is not in the target's thread (see also `slack reply --to`)" })
|
|
6333
|
+
.option("any-thread", { type: "boolean", default: false, describe: "With --to: send even when the message is in another thread" }),
|
|
6015
6334
|
async (argv) => {
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
|
|
6022
|
-
|
|
6023
|
-
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
6029
|
-
if (argv.broadcast) args.broadcast = true;
|
|
6030
|
-
// Mentions on by default; `--no-mentions` (yargs negation) sets it false.
|
|
6031
|
-
// A message with no `@token` short-circuits inside encodeMentions, so the
|
|
6032
|
-
// common case pays no extra API call.
|
|
6033
|
-
if (argv.mentions !== false) {
|
|
6034
|
-
args.mentions = true;
|
|
6035
|
-
// Resolve mentions with the user token (has users:read) even when the
|
|
6036
|
-
// message itself is sent via the bot token. Pass its cookie too so an
|
|
6037
|
-
// xoxc- desktop token can reach users.list (rejected without the cookie).
|
|
6038
|
-
args.mentionToken = tok(argv as W);
|
|
6039
|
-
const mc = ck(argv as W);
|
|
6040
|
-
if (mc) args.mentionCookie = mc;
|
|
6041
|
-
}
|
|
6042
|
-
let sendToken: string;
|
|
6043
|
-
if (argv["as-bot"]) {
|
|
6044
|
-
const botToken = resolveBotToken();
|
|
6045
|
-
if (!botToken) {
|
|
6046
|
-
console.error(
|
|
6047
|
-
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
6048
|
-
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
6049
|
-
);
|
|
6335
|
+
if (argv.to && !argv["any-thread"]) {
|
|
6336
|
+
try {
|
|
6337
|
+
const t = tok(argv as W);
|
|
6338
|
+
const c = ck(argv as W);
|
|
6339
|
+
const answered = await messagePlace(t, argv.to, c);
|
|
6340
|
+
const p = parseSlackPermalink(argv.target) ? await messagePlace(t, argv.target, c) : undefined;
|
|
6341
|
+
const dest = p ? { channel: p.channel, threadTs: p.root } : (() => { const q = parseTargetThread(argv.target); return { ref: q.ref, ...(q.threadTs ? { threadTs: q.threadTs } : {}) }; })();
|
|
6342
|
+
const channel = "channel" in dest ? dest.channel : await resolveChannel(t, dest.ref, c);
|
|
6343
|
+
const why = threadMismatch({ channel, ...(dest.threadTs ? { threadTs: dest.threadTs } : {}) }, answered);
|
|
6344
|
+
if (why) { console.error(`Error: ${why}`); process.exit(2); }
|
|
6345
|
+
} catch (e: unknown) {
|
|
6346
|
+
console.error(friendlySlackError(e));
|
|
6050
6347
|
process.exit(1);
|
|
6051
6348
|
}
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
6349
|
+
}
|
|
6350
|
+
await runSend(argv);
|
|
6351
|
+
},
|
|
6352
|
+
)
|
|
6353
|
+
.command(
|
|
6354
|
+
"init [dir]",
|
|
6355
|
+
"Create .slack-term/ in a directory: aliases, settings, inbox/outbox (never committed)",
|
|
6356
|
+
(y) => y
|
|
6357
|
+
.positional("dir", { type: "string", default: ".", describe: "Directory to put .slack-term/ in" })
|
|
6358
|
+
.option("prefix", { type: "string", describe: "Put in front of every `slack reply`, e.g. \"[design]\"" })
|
|
6359
|
+
.option("channel", { type: "string", describe: "Default channel (id)" })
|
|
6360
|
+
.option("as-bot", { type: "boolean", describe: "Reply as the bot by default" }),
|
|
6361
|
+
(argv) => {
|
|
6362
|
+
const { dir, created } = initScope(argv.dir, {
|
|
6363
|
+
...(argv.prefix ? { prefix: argv.prefix } : {}),
|
|
6364
|
+
...(argv.channel ? { channel: argv.channel } : {}),
|
|
6365
|
+
...(argv["as-bot"] !== undefined ? { as_bot: argv["as-bot"] } : {}),
|
|
6366
|
+
});
|
|
6367
|
+
console.log(created.length ? `${dir}: created ${created.join(", ")}` : `${dir}: already set up`);
|
|
6368
|
+
},
|
|
6369
|
+
)
|
|
6370
|
+
.command(
|
|
6371
|
+
"alias",
|
|
6372
|
+
"Name channels and threads for this directory (the nearest .slack-term/)",
|
|
6373
|
+
(y) => y
|
|
6374
|
+
.command(
|
|
6375
|
+
["ls", "list"],
|
|
6376
|
+
"List aliases: name, channel, thread, file",
|
|
6377
|
+
(yy) => yy.option("json", { type: "boolean", default: false }),
|
|
6378
|
+
(argv) => {
|
|
6379
|
+
const scope = findScope();
|
|
6380
|
+
const all = listAliases(scope);
|
|
6381
|
+
if (argv.json) {
|
|
6382
|
+
console.log(JSON.stringify(all.map(({ scope: _, ...a }) => a)));
|
|
6383
|
+
return;
|
|
6384
|
+
}
|
|
6385
|
+
for (const a of all) console.log(`${a.name}\t${a.channel}\t${a.thread_ts ?? "-"}\t${a.file}${a.note ? `\t${a.note}` : ""}`);
|
|
6386
|
+
if (!all.length) console.error(`no aliases in ${scope.dir}${scope.global ? " (no .slack-term/ above here: `slack init` makes one)" : ""}`);
|
|
6387
|
+
},
|
|
6388
|
+
)
|
|
6389
|
+
.command(
|
|
6390
|
+
"set <name> <target>",
|
|
6391
|
+
"Name a channel or thread: a permalink (a reply's names its thread root), #chan:<ts>, or #chan",
|
|
6392
|
+
(yy) => yy
|
|
6393
|
+
.positional("name", { type: "string", demandOption: true })
|
|
6394
|
+
.positional("target", { type: "string", demandOption: true })
|
|
6395
|
+
.option("note", { type: "string", describe: "Free text shown by `alias ls`" })
|
|
6396
|
+
.option("shared", { type: "boolean", default: false, describe: "Write settings.yml (the allowlist owner's file) instead of settings.local.yml" })
|
|
6397
|
+
.option("dir", { type: "string", describe: "Use the .slack-term/ above this directory instead of the cwd's (with --shared: a lane's dir)" }),
|
|
6398
|
+
async (argv) => {
|
|
6399
|
+
const token = tok(argv as W);
|
|
6400
|
+
const cookie = ck(argv as W);
|
|
6401
|
+
const scope = findScope(argv.dir);
|
|
6402
|
+
const threaded = parseSlackPermalink(argv.target)?.ts !== undefined || parseTargetThread(argv.target).threadTs !== undefined;
|
|
6403
|
+
let channel: string;
|
|
6404
|
+
let thread: string | undefined;
|
|
6405
|
+
let note = "";
|
|
6406
|
+
if (threaded) {
|
|
6407
|
+
const place = await messagePlace(token, argv.target, cookie);
|
|
6408
|
+
channel = place.channel;
|
|
6409
|
+
thread = place.root;
|
|
6410
|
+
if (place.root !== place.ts) note = ` (${place.ts} is a reply: named its thread root)`;
|
|
6411
|
+
} else {
|
|
6412
|
+
channel = await resolveChannel(token, parseTargetThread(argv.target).ref, cookie);
|
|
6413
|
+
}
|
|
6414
|
+
let before;
|
|
6057
6415
|
try {
|
|
6058
|
-
|
|
6416
|
+
before = setAlias(scope, argv.name, { channel, ...(thread ? { thread_ts: thread } : {}), ...(argv.note ? { note: argv.note } : {}) }, argv.shared);
|
|
6059
6417
|
} catch (e: unknown) {
|
|
6060
|
-
console.error(
|
|
6061
|
-
|
|
6062
|
-
` ${e instanceof Error ? e.message : String(e)}\n` +
|
|
6063
|
-
` Tip: pass --user-id <Uxxxx> to skip the lookup.`,
|
|
6064
|
-
);
|
|
6065
|
-
process.exit(1);
|
|
6418
|
+
if (e instanceof RestrictedError) { console.error(`Error: ${e.message}`); process.exit(2); }
|
|
6419
|
+
throw e;
|
|
6066
6420
|
}
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
|
|
6075
|
-
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
6421
|
+
const was = before && (before.channel !== channel || before.thread_ts !== thread) ? ` (was ${before.channel} ${before.thread_ts ?? "-"})` : "";
|
|
6422
|
+
console.log(`${argv.name} → ${channel} ${thread ?? "-"}${note}${was} [${scope.dir}]`);
|
|
6423
|
+
},
|
|
6424
|
+
)
|
|
6425
|
+
.command(
|
|
6426
|
+
["unset <name>", "rm <name>"],
|
|
6427
|
+
"Remove an alias (from settings.local.yml; --shared: also from settings.yml)",
|
|
6428
|
+
(yy) => yy
|
|
6429
|
+
.positional("name", { type: "string", demandOption: true })
|
|
6430
|
+
.option("shared", { type: "boolean", default: false, describe: "Allow removing one defined in settings.yml" })
|
|
6431
|
+
.option("dir", { type: "string", describe: "Use the .slack-term/ above this directory" }),
|
|
6432
|
+
(argv) => {
|
|
6433
|
+
const scope = findScope(argv.dir);
|
|
6434
|
+
let r;
|
|
6435
|
+
try {
|
|
6436
|
+
r = unsetAlias(scope, argv.name, argv.shared);
|
|
6437
|
+
} catch (e: unknown) {
|
|
6438
|
+
if (e instanceof RestrictedError) { console.error(`Error: ${e.message}`); process.exit(2); }
|
|
6439
|
+
throw e;
|
|
6440
|
+
}
|
|
6441
|
+
if (r === "removed") console.log(`${argv.name} removed [${scope.dir}]`);
|
|
6442
|
+
else if (r === "absent") { console.error(`no alias "${argv.name}" in ${scope.dir}`); process.exit(2); }
|
|
6443
|
+
else { console.error(`"${argv.name}" is defined in ${scope.dir}/settings.yml: edit that file to remove it`); process.exit(2); }
|
|
6444
|
+
},
|
|
6445
|
+
)
|
|
6446
|
+
.command(
|
|
6447
|
+
"who <channel> <ts>",
|
|
6448
|
+
"Which registered .slack-term/ dirs subscribe this thread (or message): dir, alias",
|
|
6449
|
+
(yy) => yy
|
|
6450
|
+
.positional("channel", { type: "string", demandOption: true, describe: "Channel id" })
|
|
6451
|
+
.positional("ts", { type: "string", demandOption: true, describe: "Thread root ts (a reply's own ts is not its thread)" })
|
|
6452
|
+
.option("json", { type: "boolean", default: false }),
|
|
6453
|
+
(argv) => {
|
|
6454
|
+
const hits = whoSubscribes(argv.channel, argv.ts).map((a) => ({ dir: dirname(a.scope.dir), scope: a.scope.dir, alias: a.name, file: a.file }));
|
|
6455
|
+
if (argv.json) console.log(JSON.stringify(hits));
|
|
6456
|
+
else for (const h of hits) console.log(`${h.dir}\t${h.alias}`);
|
|
6457
|
+
if (!hits.length) process.exitCode = 2;
|
|
6458
|
+
},
|
|
6459
|
+
)
|
|
6460
|
+
.command(
|
|
6461
|
+
"import <tsv>",
|
|
6462
|
+
"One-shot: import a legacy allowlist.tsv (<thread_ts> TAB <lane>) into each lane's settings.yml",
|
|
6463
|
+
(yy) => yy
|
|
6464
|
+
.positional("tsv", { type: "string", demandOption: true })
|
|
6465
|
+
.option("channel", { type: "string", demandOption: true, describe: "The channel every thread is in (for a DM: the DM id)" })
|
|
6466
|
+
.option("lane-dir", { type: "array", string: true, demandOption: true, describe: "<lane>=<dir> (a directory holding .slack-term/, made if missing); repeatable" }),
|
|
6467
|
+
(argv) => {
|
|
6468
|
+
const dirs = new Map<string, string>();
|
|
6469
|
+
for (const kv of argv["lane-dir"] ?? []) {
|
|
6470
|
+
const eq = String(kv).indexOf("=");
|
|
6471
|
+
if (eq <= 0) { console.error(`Error: --lane-dir ${kv}: want <lane>=<dir>`); process.exit(2); }
|
|
6472
|
+
dirs.set(String(kv).slice(0, eq), String(kv).slice(eq + 1));
|
|
6473
|
+
}
|
|
6474
|
+
let added = 0;
|
|
6475
|
+
const skipped = new Map<string, number>();
|
|
6476
|
+
for (const line of readFileSync(argv.tsv, "utf8").split("\n")) {
|
|
6477
|
+
const [ts, lane] = line.trim().split("\t");
|
|
6478
|
+
if (!ts || line.startsWith("#") || !lane) continue;
|
|
6479
|
+
const dir = dirs.get(lane);
|
|
6480
|
+
if (!dir || !/^\d{10}\.\d{6}$/.test(ts)) { skipped.set(lane, (skipped.get(lane) ?? 0) + 1); continue; }
|
|
6481
|
+
const scope = { dir: initScope(dir).dir, global: false };
|
|
6482
|
+
setAlias(scope, `t-${ts}`, { channel: argv.channel, thread_ts: ts, note: "imported from legacy allowlist" }, true);
|
|
6483
|
+
added++;
|
|
6484
|
+
}
|
|
6485
|
+
console.log(`imported ${added} subscription(s)`);
|
|
6486
|
+
for (const [lane, n] of skipped) console.error(`skipped ${n} line(s) for lane "${lane}" (no --lane-dir, or not a ts)`);
|
|
6487
|
+
},
|
|
6488
|
+
)
|
|
6489
|
+
.demandCommand(1, "alias needs a subcommand: ls, set, unset, who, import"),
|
|
6490
|
+
)
|
|
6491
|
+
.command(
|
|
6492
|
+
"reply [dest] [message]",
|
|
6493
|
+
"Reply in a thread — an alias, a permalink or #chan:<ts> — with the directory's prefix (confirm-hash gate, like send)",
|
|
6494
|
+
(y) => y
|
|
6495
|
+
.positional("dest", { type: "string", describe: "alias | permalink | #chan:<ts> | <ts> (in settings' channel). Omit with --to" })
|
|
6496
|
+
.positional("message", { type: "string", describe: "Text, or - for stdin" })
|
|
6497
|
+
.option("to", { type: "string", describe: "Permalink of the message you are answering: replies in ITS thread; with a dest, refuses when they differ" })
|
|
6498
|
+
.option("any-thread", { type: "boolean", default: false, describe: "With --to and a dest: post to dest even when the message is in another thread" })
|
|
6499
|
+
.option("prefix", { type: "string", describe: "Override settings' prefix (\"\" for none)" })
|
|
6500
|
+
.option("code", { type: "string", describe: "Safety hash to confirm send" })
|
|
6501
|
+
.option("as-bot", { type: "boolean", describe: "Send via the bot token (default: settings' as_bot)" })
|
|
6502
|
+
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
6503
|
+
.option("broadcast", { type: "boolean", default: false, describe: "Also send to channel" })
|
|
6504
|
+
.option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real mentions (--no-mentions for literal text)" }),
|
|
6505
|
+
async (argv) => {
|
|
6506
|
+
let dest = argv.dest;
|
|
6507
|
+
let message = argv.message;
|
|
6508
|
+
// `slack reply --to <link> 'text'`: the one positional is the text.
|
|
6509
|
+
if (argv.to && message === undefined) { message = dest; dest = undefined; }
|
|
6510
|
+
if (message === "-") message = await Bun.stdin.text();
|
|
6511
|
+
if (message === undefined || !message.trim()) { console.error("Error: message is empty"); process.exit(1); }
|
|
6512
|
+
if (!dest && !argv.to) { console.error("Error: reply needs a destination (alias, permalink, #chan:<ts>) or --to <permalink>"); process.exit(2); }
|
|
6513
|
+
const token = tok(argv as W);
|
|
6514
|
+
const cookie = ck(argv as W);
|
|
6515
|
+
const scope = findScope();
|
|
6516
|
+
const settings = readSettings(scope);
|
|
6517
|
+
let where: { channel: string; threadTs: string };
|
|
6081
6518
|
try {
|
|
6082
|
-
await
|
|
6519
|
+
const r = await resolveReplyTarget(token, cookie, scope, dest, argv.to, argv["any-thread"]);
|
|
6520
|
+
if ("error" in r) { console.error(`Error: ${r.error}`); process.exit(2); }
|
|
6521
|
+
where = r;
|
|
6083
6522
|
} catch (e: unknown) {
|
|
6084
6523
|
console.error(friendlySlackError(e));
|
|
6085
6524
|
process.exit(1);
|
|
6086
6525
|
}
|
|
6526
|
+
const { channel, threadTs } = where;
|
|
6527
|
+
// A restricted scope's prefix is its owner's: overriding it would let a
|
|
6528
|
+
// lane post as another.
|
|
6529
|
+
if (isRestricted(scope) && (argv.prefix !== undefined || (argv["as-bot"] !== undefined && argv["as-bot"] !== (settings.as_bot ?? false)))) {
|
|
6530
|
+
console.error(`Error: ${scope.dir} is restricted: who it posts as (prefix, as_bot) comes from settings.yml; --prefix / --as-bot are refused`);
|
|
6531
|
+
process.exit(2);
|
|
6532
|
+
}
|
|
6533
|
+
const text = applyPrefix(message, argv.prefix ?? settings.prefix);
|
|
6534
|
+
if (!text.trim()) { console.error("Error: message is empty (only the prefix)"); process.exit(1); }
|
|
6535
|
+
await runSend({
|
|
6536
|
+
workspace: (argv as W).workspace, target: `${channel}:${threadTs}`, message: text, code: argv.code,
|
|
6537
|
+
"allow-url-adjacent": argv["allow-url-adjacent"], "as-bot": argv["as-bot"] ?? settings.as_bot ?? false,
|
|
6538
|
+
broadcast: argv.broadcast, mentions: argv.mentions,
|
|
6539
|
+
});
|
|
6087
6540
|
},
|
|
6088
6541
|
)
|
|
6542
|
+
.command(
|
|
6543
|
+
"inbox",
|
|
6544
|
+
"This directory's .slack-term/inbox/: messages routed here by `slack stream --inbox`",
|
|
6545
|
+
(y) => y
|
|
6546
|
+
.command(
|
|
6547
|
+
["ls", "list"],
|
|
6548
|
+
"Pending messages: ts, from, alias, text (redacted)",
|
|
6549
|
+
(yy) => yy.option("json", { type: "boolean", default: false }),
|
|
6550
|
+
(argv) => {
|
|
6551
|
+
const items = listInbox(findScope());
|
|
6552
|
+
if (argv.json) { console.log(JSON.stringify(items.map((i) => ({ file: i.file, ...i.entry })))); return; }
|
|
6553
|
+
for (const i of items) console.log(`${i.entry.ts}\t${i.entry.user_name ?? i.entry.user}\t${i.entry.alias}\t${i.entry.text.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\t/g, "\\t")}${i.entry.files ? `\t[files: ${i.entry.files}]` : ""}`);
|
|
6554
|
+
},
|
|
6555
|
+
)
|
|
6556
|
+
.command(
|
|
6557
|
+
"listen",
|
|
6558
|
+
"Print each message as it arrives in inbox/ (one JSON line each); pending ones first. Exits 0 at --timeout",
|
|
6559
|
+
(yy) => yy
|
|
6560
|
+
.option("timeout", { type: "number", default: 3600, describe: "Seconds" })
|
|
6561
|
+
.option("new-only", { type: "boolean", default: false, describe: "Skip what is already pending" })
|
|
6562
|
+
.option("thread", { type: "string", describe: "Only messages in this thread (its root ts)" })
|
|
6563
|
+
.option("alias", { type: "string", describe: "Only messages routed by this alias" })
|
|
6564
|
+
.option("from", { type: "array", string: true, describe: "Only messages from these user ids; repeatable" }),
|
|
6565
|
+
async (argv) => {
|
|
6566
|
+
const scope = findScope();
|
|
6567
|
+
const from = argv.from?.length ? new Set(argv.from.map(String)) : undefined;
|
|
6568
|
+
const wanted = (e: { ts: string; thread_ts?: string; alias: string; user: string }): boolean =>
|
|
6569
|
+
(!argv.thread || (e.thread_ts ?? e.ts) === argv.thread) && (!argv.alias || e.alias === argv.alias) && (!from || from.has(e.user));
|
|
6570
|
+
const seen = new Set<string>();
|
|
6571
|
+
if (argv["new-only"]) for (const i of listInbox(scope)) seen.add(i.file);
|
|
6572
|
+
const until = Date.now() + argv.timeout * 1000;
|
|
6573
|
+
for (;;) {
|
|
6574
|
+
for (const i of listInbox(scope)) {
|
|
6575
|
+
if (seen.has(i.file)) continue;
|
|
6576
|
+
seen.add(i.file);
|
|
6577
|
+
if (wanted(i.entry)) console.log(JSON.stringify({ file: i.file, ...i.entry }));
|
|
6578
|
+
}
|
|
6579
|
+
if (Date.now() >= until) break;
|
|
6580
|
+
// Files land by rename, so a short poll is enough and survives
|
|
6581
|
+
// every filesystem (fs.watch does not, on network mounts).
|
|
6582
|
+
await new Promise((r) => setTimeout(r, Math.min(500, Math.max(0, until - Date.now()))));
|
|
6583
|
+
}
|
|
6584
|
+
},
|
|
6585
|
+
)
|
|
6586
|
+
.command(
|
|
6587
|
+
"nudge",
|
|
6588
|
+
"Pending messages older than settings' nudge_after_min (default 30): exit 1 when there are any",
|
|
6589
|
+
(yy) => yy.option("json", { type: "boolean", default: false }),
|
|
6590
|
+
(argv) => {
|
|
6591
|
+
const items = overdue(findScope());
|
|
6592
|
+
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) }))));
|
|
6593
|
+
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}`);
|
|
6594
|
+
if (items.length) process.exitCode = 1;
|
|
6595
|
+
},
|
|
6596
|
+
)
|
|
6597
|
+
.demandCommand(1, "inbox needs a subcommand: ls, listen, nudge"),
|
|
6598
|
+
)
|
|
6599
|
+
.command(
|
|
6600
|
+
"done <ref..>",
|
|
6601
|
+
"Mark inbox messages handled (moved to archived/) without replying: a file name, path or message ts",
|
|
6602
|
+
(y) => y.positional("ref", { type: "string", array: true, demandOption: true }),
|
|
6603
|
+
(argv) => {
|
|
6604
|
+
const scope = findScope();
|
|
6605
|
+
let missing = 0;
|
|
6606
|
+
for (const ref of argv.ref as string[]) {
|
|
6607
|
+
const to = markDone(scope, ref);
|
|
6608
|
+
if (to) console.log(`archived ${to}`);
|
|
6609
|
+
else { console.error(`not in ${scope.dir}/inbox: ${ref}`); missing++; }
|
|
6610
|
+
}
|
|
6611
|
+
if (missing) process.exit(2);
|
|
6612
|
+
},
|
|
6613
|
+
)
|
|
6614
|
+
.command(
|
|
6615
|
+
"outbox",
|
|
6616
|
+
"This directory's .slack-term/outbox/: replies queued as files, sent by `outbox flush`",
|
|
6617
|
+
(y) => y
|
|
6618
|
+
.command(
|
|
6619
|
+
"add <dest> <message>",
|
|
6620
|
+
"Queue a reply (text redacted before it is written; - reads stdin)",
|
|
6621
|
+
(yy) => yy
|
|
6622
|
+
.positional("dest", { type: "string", demandOption: true, describe: "alias | permalink | #chan:<ts>" })
|
|
6623
|
+
.positional("message", { type: "string", demandOption: true })
|
|
6624
|
+
.option("in-reply-to", { type: "string", describe: "ts of the inbox message this answers: archived once sent" }),
|
|
6625
|
+
async (argv) => {
|
|
6626
|
+
const text = argv.message === "-" ? await Bun.stdin.text() : argv.message;
|
|
6627
|
+
if (!text.trim()) { console.error("Error: message is empty"); process.exit(1); }
|
|
6628
|
+
const r = addOutbox(findScope(), { to: argv.dest, text, ...(argv["in-reply-to"] ? { in_reply_to: argv["in-reply-to"] } : {}) });
|
|
6629
|
+
if (r.redacted.length) console.error(`⚠ redacted before queueing: ${r.redacted.join(", ")}`);
|
|
6630
|
+
console.log(r.file);
|
|
6631
|
+
},
|
|
6632
|
+
)
|
|
6633
|
+
.command(
|
|
6634
|
+
["ls", "list"],
|
|
6635
|
+
"Queued replies: file, dest, attempts, error",
|
|
6636
|
+
(yy) => yy,
|
|
6637
|
+
() => {
|
|
6638
|
+
for (const i of listOutbox(findScope())) console.log(`${i.file}\t${i.entry.to}${i.entry.attempts ? `\tattempts=${i.entry.attempts}\t${i.entry.error ?? ""}` : ""}`);
|
|
6639
|
+
},
|
|
6640
|
+
)
|
|
6641
|
+
.command(
|
|
6642
|
+
"flush",
|
|
6643
|
+
"Send every queued reply (prefix, thread and restrict checks as `reply`); a --code covers the whole batch",
|
|
6644
|
+
(yy) => yy
|
|
6645
|
+
.option("code", { type: "string", describe: "Safety hash to confirm the batch" })
|
|
6646
|
+
.option("as-bot", { type: "boolean", describe: "Send via the bot token (default: settings' as_bot)" }),
|
|
6647
|
+
async (argv) => {
|
|
6648
|
+
const scope = findScope();
|
|
6649
|
+
const settings = readSettings(scope);
|
|
6650
|
+
const items = listOutbox(scope);
|
|
6651
|
+
if (!items.length) { console.error(`nothing in ${scope.dir}/outbox`); return; }
|
|
6652
|
+
if (isRestricted(scope) && argv["as-bot"] !== undefined && argv["as-bot"] !== (settings.as_bot ?? false)) {
|
|
6653
|
+
console.error(`Error: ${scope.dir} is restricted: as_bot comes from settings.yml; --as-bot is refused`);
|
|
6654
|
+
process.exit(2);
|
|
6655
|
+
}
|
|
6656
|
+
const asBot = argv["as-bot"] ?? settings.as_bot ?? false;
|
|
6657
|
+
const lookTok = tok(argv as W);
|
|
6658
|
+
const lookCk = ck(argv as W);
|
|
6659
|
+
let sendTok: string;
|
|
6660
|
+
let sendCk: string | undefined;
|
|
6661
|
+
if (asBot) {
|
|
6662
|
+
const b = resolveBotToken();
|
|
6663
|
+
if (!b) { console.error("Error: as_bot needs a bot token (SLACK_BOT_TOKEN)"); process.exit(1); }
|
|
6664
|
+
sendTok = b;
|
|
6665
|
+
} else {
|
|
6666
|
+
sendTok = lookTok;
|
|
6667
|
+
sendCk = lookCk;
|
|
6668
|
+
}
|
|
6669
|
+
const plan: { item: (typeof items)[number]; channel: string; threadTs: string; text: string }[] = [];
|
|
6670
|
+
const refused: string[] = [];
|
|
6671
|
+
for (const item of items) {
|
|
6672
|
+
try {
|
|
6673
|
+
const r = await resolveReplyTarget(lookTok, lookCk, scope, item.entry.to, undefined);
|
|
6674
|
+
if ("error" in r) { refused.push(`${item.file}: ${r.error}`); continue; }
|
|
6675
|
+
const text = applyPrefix(item.entry.text, settings.prefix);
|
|
6676
|
+
if (!text.trim()) { refused.push(`${item.file}: empty`); continue; }
|
|
6677
|
+
// send/reply's URL-boundary refusal: a glued URL stays queued, the
|
|
6678
|
+
// reason in `refused`, for the author to fix the file.
|
|
6679
|
+
guardUrlBoundaries(text);
|
|
6680
|
+
plan.push({ item, ...r, text });
|
|
6681
|
+
} catch (e: unknown) {
|
|
6682
|
+
refused.push(`${item.file}: ${friendlySlackError(e)}`);
|
|
6683
|
+
}
|
|
6684
|
+
}
|
|
6685
|
+
for (const r of refused) console.error(`refused (stays queued): ${r}`);
|
|
6686
|
+
if (!plan.length) process.exit(2);
|
|
6687
|
+
const lines = [`OUTBOX — ${plan.length} repl${plan.length === 1 ? "y" : "ies"}${asBot ? " as the bot" : ""}:`];
|
|
6688
|
+
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)" : ""}`);
|
|
6689
|
+
const expected = safetyCode("outbox", asBot ? "bot" : "user", ...plan.map((p) => `${p.channel}:${p.threadTs}:${p.text}`));
|
|
6690
|
+
if (argv.code !== expected) requireCode(argv.code, expected, lines);
|
|
6691
|
+
let failed = 0;
|
|
6692
|
+
for (const p of plan) {
|
|
6693
|
+
try {
|
|
6694
|
+
const ts = await slackSend(sendTok, p.channel, p.text, p.threadTs, false, sendCk);
|
|
6695
|
+
let link = "";
|
|
6696
|
+
try { link = await getPermalink(sendTok, p.channel, ts, sendCk); } catch { /* the ts is what matters */ }
|
|
6697
|
+
const to = recordOutboxSent(scope, p.item, { ts, permalink: link, channel: p.channel, thread_ts: p.threadTs });
|
|
6698
|
+
console.log(`✓ ${link || ts} (${to})`);
|
|
6699
|
+
} catch (e: unknown) {
|
|
6700
|
+
recordFailure(p.item, friendlySlackError(e));
|
|
6701
|
+
console.error(`✗ ${p.item.file}: ${friendlySlackError(e)}`);
|
|
6702
|
+
failed++;
|
|
6703
|
+
}
|
|
6704
|
+
}
|
|
6705
|
+
if (failed) process.exit(1);
|
|
6706
|
+
},
|
|
6707
|
+
)
|
|
6708
|
+
.demandCommand(1, "outbox needs a subcommand: add, ls, flush"),
|
|
6709
|
+
)
|
|
6089
6710
|
.command(
|
|
6090
6711
|
"ask [target] [question] [choices..]",
|
|
6091
6712
|
"Ask a question with its choices pre-seeded as 1️⃣..🔟 reactions (confirm-hash safety gate). Lifecycle: --waitFor=<link> collect, --void=<link> 無効化 (verb aliases: `ask collect <link>`, `ask void <link…>`)",
|
|
@@ -6745,39 +7366,18 @@ async function main(): Promise<void> {
|
|
|
6745
7366
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
6746
7367
|
.option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (on by default). Unresolved handles stay as plain text. Disable with --no-mentions for literal @text." })
|
|
6747
7368
|
.option("as-bot", { type: "boolean", default: false, describe: "Edit as the bot (xoxb / SLACK_BOT_TOKEN). REQUIRED to correct a message the bot posted: Slack only lets a token edit its OWN messages, so a bot-authored message is uneditable by the user token and chat.update returns cant_update_message. Address it by permalink or --channel-id; an @user target resolves against the BOT's DM list, which is where a bot-authored DM actually lives." }),
|
|
6748
|
-
async (argv) =>
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
|
|
6757
|
-
|
|
6758
|
-
|
|
6759
|
-
|
|
6760
|
-
// Bot identity is what makes a DM notify at all, so "uncorrectable"
|
|
6761
|
-
// applied to exactly the messages that matter most.
|
|
6762
|
-
const resolved = await asBotTarget(args.target, tok(argv as W), ck(argv as W), args.channelId);
|
|
6763
|
-
editToken = resolved.botToken;
|
|
6764
|
-
if (resolved.channelId && !args.channelId) args.channelId = resolved.channelId;
|
|
6765
|
-
args.asBot = true;
|
|
6766
|
-
// Mentions still resolve on the USER token: the bot has no users:read,
|
|
6767
|
-
// and a silently unresolved @handle in a CORRECTION is the same defect
|
|
6768
|
-
// twice.
|
|
6769
|
-
args.mentionToken = tok(argv as W);
|
|
6770
|
-
const mc = ck(argv as W);
|
|
6771
|
-
if (mc) args.mentionCookie = mc;
|
|
6772
|
-
} else {
|
|
6773
|
-
editToken = tok(argv as W);
|
|
6774
|
-
// Not set under --as-bot: a bot token needs no session cookie, and
|
|
6775
|
-
// attaching one is how a working call starts failing.
|
|
6776
|
-
const cookie = ck(argv as W);
|
|
6777
|
-
if (cookie) args.cookie = cookie;
|
|
6778
|
-
}
|
|
6779
|
-
await cmdEdit(editToken, args);
|
|
6780
|
-
},
|
|
7369
|
+
async (argv) => runEdit(argv),
|
|
7370
|
+
)
|
|
7371
|
+
.command(
|
|
7372
|
+
"tick <target> [n]",
|
|
7373
|
+
"Tick item n of a posted checklist (⬜ n. → ✅ n.), nothing else changed; without n, the one open item (confirm-hash gate, like edit)",
|
|
7374
|
+
(y) => y
|
|
7375
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
|
|
7376
|
+
.positional("n", { type: "number", describe: "Item number" })
|
|
7377
|
+
.option("code", { type: "string", describe: "Safety hash to confirm the edit" })
|
|
7378
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
7379
|
+
.option("as-bot", { type: "boolean", default: false, describe: "Edit as the bot (needed for a message the bot posted)" }),
|
|
7380
|
+
async (argv) => runEdit({ ...argv, tick: argv.n !== undefined ? { n: argv.n } : {}, mentions: false }),
|
|
6781
7381
|
)
|
|
6782
7382
|
.command(
|
|
6783
7383
|
"delete <target>",
|
|
@@ -7698,7 +8298,8 @@ async function main(): Promise<void> {
|
|
|
7698
8298
|
.option("state", { type: "string", describe: "Cursor file (default: one per identity+grep+channels under $XDG_STATE_HOME/slack-term/stream/)" })
|
|
7699
8299
|
.option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
|
|
7700
8300
|
.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" })
|
|
7701
|
-
.option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" })
|
|
8301
|
+
.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)" }),
|
|
7702
8303
|
async (argv) => {
|
|
7703
8304
|
let re: RegExp;
|
|
7704
8305
|
let reconcileMs: number;
|
|
@@ -7804,6 +8405,9 @@ async function main(): Promise<void> {
|
|
|
7804
8405
|
const ac = new AbortController();
|
|
7805
8406
|
process.on("SIGINT", () => ac.abort());
|
|
7806
8407
|
process.on("SIGTERM", () => ac.abort());
|
|
8408
|
+
// The stream schedules around Retry-After itself (bells and polls take
|
|
8409
|
+
// turns): a call that slept inside would stall both.
|
|
8410
|
+
setRateLimitWait(0);
|
|
7807
8411
|
const code = await runStream(webClient(token, cookie), {
|
|
7808
8412
|
grep: re,
|
|
7809
8413
|
...(repliesTo ? { repliesTo } : {}),
|
|
@@ -7811,6 +8415,18 @@ async function main(): Promise<void> {
|
|
|
7811
8415
|
selfUsers,
|
|
7812
8416
|
selfBots,
|
|
7813
8417
|
json: argv.json,
|
|
8418
|
+
...(argv.inbox ? {
|
|
8419
|
+
onMatch: (rec: StreamMatch, files: number) => {
|
|
8420
|
+
try {
|
|
8421
|
+
// Our own credentials, masked by exact value on top of the rules.
|
|
8422
|
+
const known = [token, cookie, process.env.SLACK_RELAY_TOKEN].filter((v): v is string => !!v);
|
|
8423
|
+
const dirs = deliver({ ...rec, ...(files ? { files } : {}) }, undefined, new Date(), known);
|
|
8424
|
+
if (dirs.length) console.error(`slack stream: delivered ${rec.channel.id}:${rec.ts} to ${dirs.length} inbox(es)`);
|
|
8425
|
+
} catch (e: unknown) {
|
|
8426
|
+
console.error(`slack stream: inbox delivery failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
8427
|
+
}
|
|
8428
|
+
},
|
|
8429
|
+
} : {}),
|
|
7814
8430
|
once: argv.once,
|
|
7815
8431
|
...(sinceSec !== undefined ? { sinceSec } : {}),
|
|
7816
8432
|
intervalMs,
|