slack-term 1.47.0 → 1.49.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 +86 -2
- package/SKILL.md +3 -2
- package/dist/cli.js +346 -242
- package/locales/en.json +32 -0
- package/locales/ja.json +31 -0
- package/package.json +15 -1
- package/ts/ask.ts +47 -46
- package/ts/botIntent.ts +1 -1
- package/ts/choiceHint.ts +30 -0
- package/ts/cli.ts +774 -126
- 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/relayInit.ts +209 -0
- package/ts/scope.ts +472 -0
- package/ts/slack.ts +40 -2
- package/ts/stream.ts +4 -1
- package/ts/tick.ts +47 -0
- package/ts/vendor/gitleaks.LICENSE +21 -0
- package/ts/vendor/gitleaks.toml +3209 -0
- package/worker/README.md +216 -0
- package/worker/src/hub.ts +203 -0
- package/worker/src/index.ts +308 -0
- package/worker/src/slack.ts +127 -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,8 +13,9 @@ 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
|
+
import { DEFAULT_DIR, initRelay, initReport, relayHealth } from "./relayInit.ts";
|
|
18
19
|
import { agentCommands } from "./agent.ts";
|
|
19
20
|
|
|
20
21
|
import {
|
|
@@ -47,7 +48,14 @@ import {
|
|
|
47
48
|
ASK_LANGS,
|
|
48
49
|
type AskFound,
|
|
49
50
|
type AskLang,
|
|
51
|
+
ASK_POLL,
|
|
52
|
+
askNextPollDelay,
|
|
50
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";
|
|
51
59
|
import {
|
|
52
60
|
POLL_KEYCAPS,
|
|
53
61
|
POLL_MARKER,
|
|
@@ -106,6 +114,7 @@ import {
|
|
|
106
114
|
send as slackSend,
|
|
107
115
|
pinsAdd,
|
|
108
116
|
RateLimitError,
|
|
117
|
+
setRateLimitWait,
|
|
109
118
|
repliesCursor,
|
|
110
119
|
type MessageMetadata,
|
|
111
120
|
getPermalink,
|
|
@@ -438,10 +447,41 @@ async function cmdMsgsTarget(token: string, target: string, limit: number, forma
|
|
|
438
447
|
}
|
|
439
448
|
|
|
440
449
|
// --- thread ---
|
|
441
|
-
|
|
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> {
|
|
442
475
|
const channelId = await resolveChannel(token, target, cookie);
|
|
443
|
-
const
|
|
444
|
-
|
|
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) msgs = msgs.filter((m) => Number(m.ts) > Number(since));
|
|
481
|
+
if (format === "tsv") {
|
|
482
|
+
for (const m of msgs) console.log(tsvLine(m));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
445
485
|
if (format === "jsonl") {
|
|
446
486
|
for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
|
|
447
487
|
return;
|
|
@@ -1286,9 +1326,89 @@ function parseTargetThread(s: string, permalinkThreads = true): { ref: string; t
|
|
|
1286
1326
|
if (/^\d{10}\.\d{6}$/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: maybeTs };
|
|
1287
1327
|
if (/^\d{4}-\d{2}-\d{2}T/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: isoToSlackTs(maybeTs) };
|
|
1288
1328
|
}
|
|
1329
|
+
// A bare name (no #, @ or :) that is an alias in the nearest .slack-term/.
|
|
1330
|
+
const alias = lookupAlias(s);
|
|
1331
|
+
if (alias) return alias.thread_ts ? { ref: alias.channel, threadTs: alias.thread_ts } : { ref: alias.channel };
|
|
1289
1332
|
return { ref: s };
|
|
1290
1333
|
}
|
|
1291
1334
|
|
|
1335
|
+
/** Where `slack reply` (and `outbox flush`) posts: the destination — alias,
|
|
1336
|
+
* permalink (its root), `#chan:<ts>` or a bare ts in settings' channel —
|
|
1337
|
+
* and/or `--to`'s message, which must agree with it unless `anyThread`. A
|
|
1338
|
+
* restricted scope also requires the thread to be one of its subscriptions.
|
|
1339
|
+
* `{ error }` is a refusal (exit 2): nothing was sent. */
|
|
1340
|
+
async function resolveReplyTarget(
|
|
1341
|
+
token: string, cookie: string | undefined, scope: Scope, dest: string | undefined, to: string | undefined, anyThread = false,
|
|
1342
|
+
): Promise<{ channel: string; threadTs: string } | { error: string }> {
|
|
1343
|
+
const settings = readSettings(scope);
|
|
1344
|
+
let channel: string | undefined;
|
|
1345
|
+
let threadTs: string | undefined;
|
|
1346
|
+
if (dest) {
|
|
1347
|
+
if (parseSlackPermalink(dest)) {
|
|
1348
|
+
const place = await messagePlace(token, dest, cookie);
|
|
1349
|
+
channel = place.channel;
|
|
1350
|
+
threadTs = place.root;
|
|
1351
|
+
} else {
|
|
1352
|
+
let p = parseTargetThread(dest);
|
|
1353
|
+
if (!p.threadTs && /^\d{10}\.\d{6}$/.test(dest)) {
|
|
1354
|
+
if (!settings.channel) return { error: `a bare ts needs a channel: set \`channel\` in .slack-term/settings.yml, or pass #chan:${dest}` };
|
|
1355
|
+
p = { ref: settings.channel, threadTs: dest };
|
|
1356
|
+
}
|
|
1357
|
+
if (!p.threadTs) return { error: `"${dest}" is not a thread — reply only posts in threads. For a top-level post use \`slack send\`.` };
|
|
1358
|
+
channel = await resolveChannel(token, p.ref, cookie);
|
|
1359
|
+
threadTs = p.threadTs;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
if (to) {
|
|
1363
|
+
const answered = await messagePlace(token, to, cookie);
|
|
1364
|
+
if (channel === undefined) {
|
|
1365
|
+
channel = answered.channel;
|
|
1366
|
+
threadTs = answered.root;
|
|
1367
|
+
} else if (!anyThread) {
|
|
1368
|
+
const why = threadMismatch({ channel, ...(threadTs ? { threadTs } : {}) }, answered);
|
|
1369
|
+
if (why) return { error: why };
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
if (!channel || !threadTs) return { error: "reply needs a destination (alias, permalink, #chan:<ts>) or --to <permalink>" };
|
|
1373
|
+
if (isRestricted(scope) && !listAliases(scope).some((a) => a.channel === channel && (a.thread_ts === undefined || a.thread_ts === threadTs))) {
|
|
1374
|
+
return { error: `${channel} thread ${threadTs} is not one of ${scope.dir}'s subscriptions (restrict_to_subscriptions)` };
|
|
1375
|
+
}
|
|
1376
|
+
return { channel, threadTs };
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/** Where the message a permalink (or `#chan:<ts>`) names really sits: its
|
|
1380
|
+
* channel and thread root. A permalink's `?thread_ts=` settles it; otherwise
|
|
1381
|
+
* the thread is read, since a reply's bare permalink looks like a root's. */
|
|
1382
|
+
async function messagePlace(token: string, target: string, cookie?: string): Promise<MessagePlace> {
|
|
1383
|
+
const url = parseSlackPermalink(target);
|
|
1384
|
+
let ref: string;
|
|
1385
|
+
let ts: string | undefined;
|
|
1386
|
+
let root: string | undefined;
|
|
1387
|
+
if (url) {
|
|
1388
|
+
ref = url.channel;
|
|
1389
|
+
ts = url.ts;
|
|
1390
|
+
root = url.threadTs;
|
|
1391
|
+
} else {
|
|
1392
|
+
const p = parseTargetThread(target);
|
|
1393
|
+
ref = p.ref;
|
|
1394
|
+
ts = p.threadTs;
|
|
1395
|
+
}
|
|
1396
|
+
if (!ts) throw new Error(`--to needs a message permalink (or #chan:<ts>), got "${target}"`);
|
|
1397
|
+
const channel = await resolveChannel(token, ref, cookie);
|
|
1398
|
+
if (!root) {
|
|
1399
|
+
try {
|
|
1400
|
+
const msgs = (asRecord((await replies(token, channel, ts, 1, cookie)) as Json).messages ?? []) as Json[];
|
|
1401
|
+
const own = msgs.map(asRecord).find((m) => m.ts === ts) ?? asRecord(msgs[0]);
|
|
1402
|
+
root = typeof own.thread_ts === "string" ? own.thread_ts : ts;
|
|
1403
|
+
} catch (e) {
|
|
1404
|
+
// A top-level message nobody answered has no thread to read.
|
|
1405
|
+
if (!/thread_not_found/.test(e instanceof Error ? e.message : String(e))) throw e;
|
|
1406
|
+
root = ts;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
return { channel, ts, root };
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1292
1412
|
/** Human-readable destination label for confirm gates, e.g. "#general (C0123456789)".
|
|
1293
1413
|
* When ref is already a #channel/@user label, keep it; otherwise resolve the channel
|
|
1294
1414
|
* name via conversations.info (fail-soft: a raw ID is still unambiguous). */
|
|
@@ -1488,6 +1608,12 @@ interface EditArgs {
|
|
|
1488
1608
|
* `send --as-bot`. */
|
|
1489
1609
|
mentionToken?: string;
|
|
1490
1610
|
mentionCookie?: string;
|
|
1611
|
+
/** `slack tick`: the new text is the original with item `n` (or the one open
|
|
1612
|
+
* item) ticked — computed from what is stored, so nothing else changes. */
|
|
1613
|
+
tick?: { n?: number };
|
|
1614
|
+
/** The scope's `[lane]` prefix: an edit keeps the lane tag the original
|
|
1615
|
+
* was posted with. */
|
|
1616
|
+
lanePrefix?: string;
|
|
1491
1617
|
}
|
|
1492
1618
|
/** The bot token, plus the bot↔user DM when the target names an `@handle`.
|
|
1493
1619
|
*
|
|
@@ -1558,11 +1684,24 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
1558
1684
|
}
|
|
1559
1685
|
const originalText = typeof original.text === "string" ? original.text : "";
|
|
1560
1686
|
|
|
1687
|
+
let wanted = args.newText;
|
|
1688
|
+
if (args.tick) {
|
|
1689
|
+
const r = tickText(originalText, args.tick.n);
|
|
1690
|
+
if (r.kind === "already") { console.log(`already ticked (${r.n}.)`); process.exit(0); }
|
|
1691
|
+
if (r.kind === "none") { console.error("Error: the message has no ⬜ <n>. item"); process.exit(2); }
|
|
1692
|
+
if (r.kind === "missing") { console.error(`Error: the message has no ⬜ ${r.n}. item`); process.exit(2); }
|
|
1693
|
+
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); }
|
|
1694
|
+
wanted = r.text;
|
|
1695
|
+
} else {
|
|
1696
|
+
wanted = keepLanePrefix(originalText, wanted, args.lanePrefix);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1561
1699
|
// Convert @handle → <@USERID> before hashing/editing (unresolved stay as text).
|
|
1700
|
+
// A tick rewrites stored text, whose mentions are encoded already.
|
|
1562
1701
|
const mentionCookie = args.mentionCookie ?? args.cookie;
|
|
1563
|
-
const newText = args.mentions
|
|
1564
|
-
? await encodeMentions(args.mentionToken ?? token,
|
|
1565
|
-
:
|
|
1702
|
+
const newText = args.mentions && !args.tick
|
|
1703
|
+
? await encodeMentions(args.mentionToken ?? token, wanted, channelId, mentionCookie ? { cookie: mentionCookie } : {})
|
|
1704
|
+
: wanted;
|
|
1566
1705
|
|
|
1567
1706
|
guardUrlBoundaries(newText, args.allowUrlAdjacent);
|
|
1568
1707
|
|
|
@@ -2010,7 +2149,25 @@ async function cmdPinlogUpdate(token: string, args: PinlogUpdateArgs): Promise<v
|
|
|
2010
2149
|
process.exit(1);
|
|
2011
2150
|
}
|
|
2012
2151
|
attrEdit.record({ team: self?.team, channel, target: args.target, ts, text: newHead, asBot: args.asBot });
|
|
2013
|
-
|
|
2152
|
+
// Read it back: Slack rewrites text when it stores it, so compare the
|
|
2153
|
+
// normalised forms (ts/readback.ts). A board that does not show what was
|
|
2154
|
+
// sent gets no log line — exit 3, like the earlier thread tool's `state`.
|
|
2155
|
+
let stored: Record<string, Json> | undefined;
|
|
2156
|
+
try {
|
|
2157
|
+
const back = asArray(asRecord((await replies(token, channel, ts, 1, args.cookie)) as Json).messages).map(asRecord);
|
|
2158
|
+
stored = back.find((m) => m.ts === ts);
|
|
2159
|
+
} catch (e: unknown) {
|
|
2160
|
+
console.error(`⚠ HEAD updated, but it could not be read back to check: ${friendlySlackError(e)}`);
|
|
2161
|
+
}
|
|
2162
|
+
if (stored && !storedAsSent(newHead, stored)) {
|
|
2163
|
+
const names = emojiMap(stored.blocks);
|
|
2164
|
+
console.error(
|
|
2165
|
+
`✗ HEAD was edited, but Slack does not show what was sent (${firstDifference(normText(newHead, names), normText(typeof stored.text === "string" ? stored.text : "", names))}).\n` +
|
|
2166
|
+
` The log reply was NOT posted. Check it: slack pinlog get ${id}${bot}`,
|
|
2167
|
+
);
|
|
2168
|
+
process.exit(3);
|
|
2169
|
+
}
|
|
2170
|
+
console.log(`✓ HEAD updated${stored ? " (read back: matches)" : ""}: ${id}`);
|
|
2014
2171
|
}
|
|
2015
2172
|
|
|
2016
2173
|
const attrLog = sentAttribution("send");
|
|
@@ -2301,6 +2458,9 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
2301
2458
|
const { ref, threadTs } = parseTargetThread(args.target);
|
|
2302
2459
|
const cookie = args.cookie;
|
|
2303
2460
|
|
|
2461
|
+
// Hint only, on the preview: a choice asked in prose makes the reader type.
|
|
2462
|
+
if (!args.code && choiceQuestionLang(args.message)) console.error(choiceHint(args.target));
|
|
2463
|
+
|
|
2304
2464
|
// Identity of the token that will actually post — the bot token under
|
|
2305
2465
|
// --as-bot, the user token otherwise.
|
|
2306
2466
|
const getSelf = selfLookup(token, cookie);
|
|
@@ -2642,9 +2802,6 @@ function askExplainDefault(): boolean {
|
|
|
2642
2802
|
return /^(1|true|yes|on)$/i.test(process.env.SLACK_TERM_ASK_EXPLAIN ?? "");
|
|
2643
2803
|
}
|
|
2644
2804
|
|
|
2645
|
-
const ASK_PHI = 1.618033988749895;
|
|
2646
|
-
const ASK_POLL_MIN_MS = 1000;
|
|
2647
|
-
const ASK_POLL_MAX_MS = 5000;
|
|
2648
2805
|
const ASK_MAX_CONSECUTIVE_ERRORS = 8;
|
|
2649
2806
|
|
|
2650
2807
|
|
|
@@ -3335,8 +3492,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3335
3492
|
}
|
|
3336
3493
|
|
|
3337
3494
|
const deadline = Date.now() + timeout * 1000;
|
|
3495
|
+
const started = Date.now();
|
|
3338
3496
|
let errors = 0;
|
|
3339
|
-
let delay =
|
|
3497
|
+
let delay: number = ASK_POLL.minMs;
|
|
3498
|
+
/** A 429's Retry-After: the next poll waits at least this long. */
|
|
3499
|
+
let coolMs = 0;
|
|
3340
3500
|
let unchosen: AskPollState["unchosen"];
|
|
3341
3501
|
for (;;) {
|
|
3342
3502
|
let found: AskFound | null = null;
|
|
@@ -3344,13 +3504,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3344
3504
|
found = await poll();
|
|
3345
3505
|
errors = 0;
|
|
3346
3506
|
} catch (e: unknown) {
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3507
|
+
if (e instanceof RateLimitError) {
|
|
3508
|
+
// Not a failure: the bucket is shared with every other waiter on this
|
|
3509
|
+
// token. Polling again before Retry-After only keeps it at 429.
|
|
3510
|
+
coolMs = e.retryAfter * 1000;
|
|
3511
|
+
} else {
|
|
3512
|
+
// A dropped poll is expected occasionally; only give up if it keeps failing.
|
|
3513
|
+
errors++;
|
|
3514
|
+
if (errors >= ASK_MAX_CONSECUTIVE_ERRORS) {
|
|
3515
|
+
console.error(`Error: Slack への問い合わせに ${errors} 回連続で失敗しました: ${e instanceof Error ? e.message : String(e)}`);
|
|
3516
|
+
process.exit(ASK_EXIT_ERROR);
|
|
3517
|
+
}
|
|
3518
|
+
console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
|
|
3352
3519
|
}
|
|
3353
|
-
console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
|
|
3354
3520
|
}
|
|
3355
3521
|
unchosen = state.unchosen;
|
|
3356
3522
|
const voided = state.voided;
|
|
@@ -3441,8 +3607,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3441
3607
|
// Checked after the first poll, never before it: `--timeout 0` means one
|
|
3442
3608
|
// look, and one look must still be a real look.
|
|
3443
3609
|
if (Date.now() >= deadline) break;
|
|
3444
|
-
await askSleep(Math.min(delay, Math.max(0, deadline - Date.now())));
|
|
3445
|
-
|
|
3610
|
+
await askSleep(Math.min(Math.max(delay, coolMs), Math.max(0, deadline - Date.now())));
|
|
3611
|
+
coolMs = 0;
|
|
3612
|
+
delay = askNextPollDelay(delay, Date.now() - started);
|
|
3446
3613
|
}
|
|
3447
3614
|
|
|
3448
3615
|
// A timeout is the moment the caller most needs the collect command: the
|
|
@@ -5500,6 +5667,145 @@ async function main(): Promise<void> {
|
|
|
5500
5667
|
const tok = (a: W) => resolveToken(a.workspace);
|
|
5501
5668
|
const ck = (a: W) => resolveCookie(a.workspace);
|
|
5502
5669
|
|
|
5670
|
+
/** The `send` handler, shared with `reply` (which resolves its own target,
|
|
5671
|
+
* prefix and thread check first). */
|
|
5672
|
+
type SendCli = {
|
|
5673
|
+
workspace?: string | undefined; target?: string | undefined; message?: string | undefined; code?: string | undefined;
|
|
5674
|
+
"allow-url-adjacent"?: boolean | undefined; "channel-id"?: string | undefined; "user-id"?: string | undefined;
|
|
5675
|
+
"as-bot"?: boolean | undefined; broadcast?: boolean | undefined; mentions?: boolean | undefined;
|
|
5676
|
+
};
|
|
5677
|
+
/** The `edit` handler, shared with `tick`. In a .slack-term/ scope the
|
|
5678
|
+
* lane's tag is kept; in a restricted one the message must sit in one of
|
|
5679
|
+
* its subscriptions. */
|
|
5680
|
+
type EditCli = {
|
|
5681
|
+
workspace?: string | undefined; target?: string | undefined; newText?: string | undefined; code?: string | undefined;
|
|
5682
|
+
force?: boolean | undefined; "allow-url-adjacent"?: boolean | undefined; "channel-id"?: string | undefined;
|
|
5683
|
+
mentions?: boolean | undefined; "as-bot"?: boolean | undefined; tick?: { n?: number } | undefined;
|
|
5684
|
+
};
|
|
5685
|
+
const runEdit = async (argv: EditCli): Promise<void> => {
|
|
5686
|
+
const scope = findScope();
|
|
5687
|
+
const settings = readSettings(scope);
|
|
5688
|
+
if (isRestricted(scope)) {
|
|
5689
|
+
try {
|
|
5690
|
+
const place = await messagePlace(tok(argv as W), argv.target!, ck(argv as W));
|
|
5691
|
+
if (!listAliases(scope).some((a) => a.channel === place.channel && (a.thread_ts === undefined || a.thread_ts === place.root))) {
|
|
5692
|
+
console.error(`Error: that message is not in one of ${scope.dir}'s subscriptions (restrict_to_subscriptions)`);
|
|
5693
|
+
process.exit(2);
|
|
5694
|
+
}
|
|
5695
|
+
} catch (e: unknown) {
|
|
5696
|
+
console.error(friendlySlackError(e));
|
|
5697
|
+
process.exit(1);
|
|
5698
|
+
}
|
|
5699
|
+
}
|
|
5700
|
+
const args: EditArgs = { target: argv.target!, newText: argv.newText ?? "", allowUrlAdjacent: argv["allow-url-adjacent"] === true };
|
|
5701
|
+
if (argv.tick) args.tick = argv.tick;
|
|
5702
|
+
if (settings.prefix) args.lanePrefix = settings.prefix;
|
|
5703
|
+
if (argv.force) args.force = true;
|
|
5704
|
+
if (argv.code) args.code = argv.code;
|
|
5705
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
5706
|
+
if (argv.mentions !== false) args.mentions = true;
|
|
5707
|
+
let editToken: string;
|
|
5708
|
+
if (argv["as-bot"]) {
|
|
5709
|
+
// --as-bot is not a switch on chat.update. It selects a DIFFERENT
|
|
5710
|
+
// resolved token, and until now `edit` never wired one — so every
|
|
5711
|
+
// message the bot posted was uncorrectable, and the workaround people
|
|
5712
|
+
// reached for was RESENDING, which this repo's own etiquette forbids.
|
|
5713
|
+
// Bot identity is what makes a DM notify at all, so "uncorrectable"
|
|
5714
|
+
// applied to exactly the messages that matter most.
|
|
5715
|
+
const resolved = await asBotTarget(args.target, tok(argv as W), ck(argv as W), args.channelId);
|
|
5716
|
+
editToken = resolved.botToken;
|
|
5717
|
+
if (resolved.channelId && !args.channelId) args.channelId = resolved.channelId;
|
|
5718
|
+
args.asBot = true;
|
|
5719
|
+
// Mentions still resolve on the USER token: the bot has no users:read,
|
|
5720
|
+
// and a silently unresolved @handle in a CORRECTION is the same defect
|
|
5721
|
+
// twice.
|
|
5722
|
+
args.mentionToken = tok(argv as W);
|
|
5723
|
+
const mc = ck(argv as W);
|
|
5724
|
+
if (mc) args.mentionCookie = mc;
|
|
5725
|
+
} else {
|
|
5726
|
+
editToken = tok(argv as W);
|
|
5727
|
+
// Not set under --as-bot: a bot token needs no session cookie, and
|
|
5728
|
+
// attaching one is how a working call starts failing.
|
|
5729
|
+
const cookie = ck(argv as W);
|
|
5730
|
+
if (cookie) args.cookie = cookie;
|
|
5731
|
+
}
|
|
5732
|
+
await cmdEdit(editToken, args);
|
|
5733
|
+
};
|
|
5734
|
+
|
|
5735
|
+
const runSend = async (argv: SendCli): Promise<void> => {
|
|
5736
|
+
// An empty body is never a meaningful send. `ask` already refuses one
|
|
5737
|
+
// ("Error: question is empty"); `send` did not, so an empty draft
|
|
5738
|
+
// staged a confirm code and posting it produced a blank message.
|
|
5739
|
+
// Verification cannot catch this: an empty publish reads back as an
|
|
5740
|
+
// exact match, so refusing here is the only mechanism.
|
|
5741
|
+
if (!String(argv.message ?? "").trim()) {
|
|
5742
|
+
console.error("Error: message is empty");
|
|
5743
|
+
process.exit(1);
|
|
5744
|
+
}
|
|
5745
|
+
const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] === true };
|
|
5746
|
+
if (argv.code) args.code = argv.code;
|
|
5747
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
5748
|
+
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
5749
|
+
if (argv.broadcast) args.broadcast = true;
|
|
5750
|
+
// Mentions on by default; `--no-mentions` (yargs negation) sets it false.
|
|
5751
|
+
// A message with no `@token` short-circuits inside encodeMentions, so the
|
|
5752
|
+
// common case pays no extra API call.
|
|
5753
|
+
if (argv.mentions !== false) {
|
|
5754
|
+
args.mentions = true;
|
|
5755
|
+
// Resolve mentions with the user token (has users:read) even when the
|
|
5756
|
+
// message itself is sent via the bot token. Pass its cookie too so an
|
|
5757
|
+
// xoxc- desktop token can reach users.list (rejected without the cookie).
|
|
5758
|
+
args.mentionToken = tok(argv as W);
|
|
5759
|
+
const mc = ck(argv as W);
|
|
5760
|
+
if (mc) args.mentionCookie = mc;
|
|
5761
|
+
}
|
|
5762
|
+
let sendToken: string;
|
|
5763
|
+
if (argv["as-bot"]) {
|
|
5764
|
+
const botToken = resolveBotToken();
|
|
5765
|
+
if (!botToken) {
|
|
5766
|
+
console.error(
|
|
5767
|
+
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
5768
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
5769
|
+
);
|
|
5770
|
+
process.exit(1);
|
|
5771
|
+
}
|
|
5772
|
+
// Resolve @user → user_id with the *user* token (has users:read), then
|
|
5773
|
+
// let cmdSend open the DM with the bot token (im:write). This avoids
|
|
5774
|
+
// requiring users:read on the bot and guarantees the DM is bot↔user
|
|
5775
|
+
// (not the user's own self-DM).
|
|
5776
|
+
if (!args.channelId && !args.userId && args.target.startsWith("@")) {
|
|
5777
|
+
try {
|
|
5778
|
+
args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
|
|
5779
|
+
} catch (e: unknown) {
|
|
5780
|
+
console.error(
|
|
5781
|
+
`Error: could not resolve ${args.target} to a user for the bot DM.\n` +
|
|
5782
|
+
` ${e instanceof Error ? e.message : String(e)}\n` +
|
|
5783
|
+
` Tip: pass --user-id <Uxxxx> to skip the lookup.`,
|
|
5784
|
+
);
|
|
5785
|
+
process.exit(1);
|
|
5786
|
+
}
|
|
5787
|
+
}
|
|
5788
|
+
sendToken = botToken;
|
|
5789
|
+
args.asBot = true;
|
|
5790
|
+
} else {
|
|
5791
|
+
sendToken = tok(argv as W);
|
|
5792
|
+
// Session cookie for `sendToken` itself — needed for an xoxc- desktop
|
|
5793
|
+
// token to be accepted by the public API at all. Not applicable to the
|
|
5794
|
+
// bot token above (bot tokens need no cookie).
|
|
5795
|
+
const sc = ck(argv as W);
|
|
5796
|
+
if (sc) args.cookie = sc;
|
|
5797
|
+
}
|
|
5798
|
+
// Print a clean error instead of yargs' usage dump when a send fails at
|
|
5799
|
+
// runtime (e.g. missing scope, channel not found). The confirm gate
|
|
5800
|
+
// exits via process.exit, so it is unaffected.
|
|
5801
|
+
try {
|
|
5802
|
+
await cmdSend(sendToken, args);
|
|
5803
|
+
} catch (e: unknown) {
|
|
5804
|
+
console.error(friendlySlackError(e));
|
|
5805
|
+
process.exit(1);
|
|
5806
|
+
}
|
|
5807
|
+
};
|
|
5808
|
+
|
|
5503
5809
|
// `todo ls` and `mytodo ls` are the same listing over two different search
|
|
5504
5810
|
// scopes — `has:` (anyone's reactions) vs `hasmy:` (only this token's). They
|
|
5505
5811
|
// share one definition so the flags can never drift apart; `mine` is the only
|
|
@@ -5558,8 +5864,9 @@ async function main(): Promise<void> {
|
|
|
5558
5864
|
.option("workspace", { alias: "w", type: "string", describe: "Workspace name" })
|
|
5559
5865
|
.middleware(async (argv) => {
|
|
5560
5866
|
const cmd = String((argv._ ?? [])[0] ?? "");
|
|
5561
|
-
// `sent` reads only the local log — no token, no network.
|
|
5562
|
-
|
|
5867
|
+
// `sent` reads only the local log — no token, no network. `relay` writes
|
|
5868
|
+
// local files (init) or talks to the user's own relay (health): no Slack token.
|
|
5869
|
+
if (!cmd || cmd === "auth" || cmd === "login" || cmd === "agent" || cmd === "sent" || cmd === "relay") return;
|
|
5563
5870
|
try {
|
|
5564
5871
|
const active = resolveToken((argv as W).workspace);
|
|
5565
5872
|
const bot = resolveBotToken();
|
|
@@ -5592,7 +5899,7 @@ async function main(): Promise<void> {
|
|
|
5592
5899
|
["read [target]", "msgs [target]"],
|
|
5593
5900
|
"Browse messages",
|
|
5594
5901
|
(y) => y
|
|
5595
|
-
.positional("target", { type: "string", describe: "#channel, @user, or
|
|
5902
|
+
.positional("target", { type: "string", describe: "#channel, @user, URL, or an alias (slack alias ls)" })
|
|
5596
5903
|
.option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" })
|
|
5597
5904
|
.option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
|
|
5598
5905
|
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
|
|
@@ -5600,7 +5907,11 @@ async function main(): Promise<void> {
|
|
|
5600
5907
|
async (argv) => {
|
|
5601
5908
|
const token = tok(argv as W);
|
|
5602
5909
|
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
|
|
5603
|
-
|
|
5910
|
+
// `slack read <alias>`: the thread (or channel) it names.
|
|
5911
|
+
const alias = argv.target ? lookupAlias(argv.target) : undefined;
|
|
5912
|
+
if (alias?.thread_ts) await cmdThread(token, alias.channel, alias.thread_ts, Math.max(argv.limit, 100), format, ck(argv as W));
|
|
5913
|
+
else if (alias) await cmdMsgsTarget(token, alias.channel, argv.limit, format, argv.unreplied, ck(argv as W));
|
|
5914
|
+
else if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format, argv.unreplied, ck(argv as W));
|
|
5604
5915
|
else await cmdMsgs(token);
|
|
5605
5916
|
},
|
|
5606
5917
|
)
|
|
@@ -5611,11 +5922,12 @@ async function main(): Promise<void> {
|
|
|
5611
5922
|
.positional("target", { type: "string", demandOption: true })
|
|
5612
5923
|
.positional("ts", { type: "string", demandOption: true })
|
|
5613
5924
|
.option("limit", { alias: "n", type: "number", default: 100 })
|
|
5614
|
-
.option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
|
|
5615
|
-
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
|
|
5925
|
+
.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" })
|
|
5926
|
+
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
|
|
5927
|
+
.option("since", { type: "string", describe: "Only messages after this ts (reads every page)" }),
|
|
5616
5928
|
async (argv) => {
|
|
5617
|
-
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
|
|
5618
|
-
await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format, ck(argv as W));
|
|
5929
|
+
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl" | "tsv");
|
|
5930
|
+
await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format, ck(argv as W), argv.since);
|
|
5619
5931
|
},
|
|
5620
5932
|
)
|
|
5621
5933
|
.command(
|
|
@@ -6009,81 +6321,382 @@ async function main(): Promise<void> {
|
|
|
6009
6321
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
6010
6322
|
.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" })
|
|
6011
6323
|
.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." })
|
|
6012
|
-
.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." })
|
|
6324
|
+
.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." })
|
|
6325
|
+
.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`)" })
|
|
6326
|
+
.option("any-thread", { type: "boolean", default: false, describe: "With --to: send even when the message is in another thread" }),
|
|
6013
6327
|
async (argv) => {
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
|
|
6022
|
-
|
|
6023
|
-
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
6027
|
-
if (argv.broadcast) args.broadcast = true;
|
|
6028
|
-
// Mentions on by default; `--no-mentions` (yargs negation) sets it false.
|
|
6029
|
-
// A message with no `@token` short-circuits inside encodeMentions, so the
|
|
6030
|
-
// common case pays no extra API call.
|
|
6031
|
-
if (argv.mentions !== false) {
|
|
6032
|
-
args.mentions = true;
|
|
6033
|
-
// Resolve mentions with the user token (has users:read) even when the
|
|
6034
|
-
// message itself is sent via the bot token. Pass its cookie too so an
|
|
6035
|
-
// xoxc- desktop token can reach users.list (rejected without the cookie).
|
|
6036
|
-
args.mentionToken = tok(argv as W);
|
|
6037
|
-
const mc = ck(argv as W);
|
|
6038
|
-
if (mc) args.mentionCookie = mc;
|
|
6039
|
-
}
|
|
6040
|
-
let sendToken: string;
|
|
6041
|
-
if (argv["as-bot"]) {
|
|
6042
|
-
const botToken = resolveBotToken();
|
|
6043
|
-
if (!botToken) {
|
|
6044
|
-
console.error(
|
|
6045
|
-
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
6046
|
-
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
6047
|
-
);
|
|
6328
|
+
if (argv.to && !argv["any-thread"]) {
|
|
6329
|
+
try {
|
|
6330
|
+
const t = tok(argv as W);
|
|
6331
|
+
const c = ck(argv as W);
|
|
6332
|
+
const answered = await messagePlace(t, argv.to, c);
|
|
6333
|
+
const p = parseSlackPermalink(argv.target) ? await messagePlace(t, argv.target, c) : undefined;
|
|
6334
|
+
const dest = p ? { channel: p.channel, threadTs: p.root } : (() => { const q = parseTargetThread(argv.target); return { ref: q.ref, ...(q.threadTs ? { threadTs: q.threadTs } : {}) }; })();
|
|
6335
|
+
const channel = "channel" in dest ? dest.channel : await resolveChannel(t, dest.ref, c);
|
|
6336
|
+
const why = threadMismatch({ channel, ...(dest.threadTs ? { threadTs: dest.threadTs } : {}) }, answered);
|
|
6337
|
+
if (why) { console.error(`Error: ${why}`); process.exit(2); }
|
|
6338
|
+
} catch (e: unknown) {
|
|
6339
|
+
console.error(friendlySlackError(e));
|
|
6048
6340
|
process.exit(1);
|
|
6049
6341
|
}
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6342
|
+
}
|
|
6343
|
+
await runSend(argv);
|
|
6344
|
+
},
|
|
6345
|
+
)
|
|
6346
|
+
.command(
|
|
6347
|
+
"init [dir]",
|
|
6348
|
+
"Create .slack-term/ in a directory: aliases, settings, inbox/outbox (never committed)",
|
|
6349
|
+
(y) => y
|
|
6350
|
+
.positional("dir", { type: "string", default: ".", describe: "Directory to put .slack-term/ in" })
|
|
6351
|
+
.option("prefix", { type: "string", describe: "Put in front of every `slack reply`, e.g. \"[design]\"" })
|
|
6352
|
+
.option("channel", { type: "string", describe: "Default channel (id)" })
|
|
6353
|
+
.option("as-bot", { type: "boolean", describe: "Reply as the bot by default" }),
|
|
6354
|
+
(argv) => {
|
|
6355
|
+
const { dir, created } = initScope(argv.dir, {
|
|
6356
|
+
...(argv.prefix ? { prefix: argv.prefix } : {}),
|
|
6357
|
+
...(argv.channel ? { channel: argv.channel } : {}),
|
|
6358
|
+
...(argv["as-bot"] !== undefined ? { as_bot: argv["as-bot"] } : {}),
|
|
6359
|
+
});
|
|
6360
|
+
console.log(created.length ? `${dir}: created ${created.join(", ")}` : `${dir}: already set up`);
|
|
6361
|
+
},
|
|
6362
|
+
)
|
|
6363
|
+
.command(
|
|
6364
|
+
"alias",
|
|
6365
|
+
"Name channels and threads for this directory (the nearest .slack-term/)",
|
|
6366
|
+
(y) => y
|
|
6367
|
+
.command(
|
|
6368
|
+
["ls", "list"],
|
|
6369
|
+
"List aliases: name, channel, thread, file",
|
|
6370
|
+
(yy) => yy.option("json", { type: "boolean", default: false }),
|
|
6371
|
+
(argv) => {
|
|
6372
|
+
const scope = findScope();
|
|
6373
|
+
const all = listAliases(scope);
|
|
6374
|
+
if (argv.json) {
|
|
6375
|
+
console.log(JSON.stringify(all.map(({ scope: _, ...a }) => a)));
|
|
6376
|
+
return;
|
|
6377
|
+
}
|
|
6378
|
+
for (const a of all) console.log(`${a.name}\t${a.channel}\t${a.thread_ts ?? "-"}\t${a.file}${a.note ? `\t${a.note}` : ""}`);
|
|
6379
|
+
if (!all.length) console.error(`no aliases in ${scope.dir}${scope.global ? " (no .slack-term/ above here: `slack init` makes one)" : ""}`);
|
|
6380
|
+
},
|
|
6381
|
+
)
|
|
6382
|
+
.command(
|
|
6383
|
+
"set <name> <target>",
|
|
6384
|
+
"Name a channel or thread: a permalink (a reply's names its thread root), #chan:<ts>, or #chan",
|
|
6385
|
+
(yy) => yy
|
|
6386
|
+
.positional("name", { type: "string", demandOption: true })
|
|
6387
|
+
.positional("target", { type: "string", demandOption: true })
|
|
6388
|
+
.option("note", { type: "string", describe: "Free text shown by `alias ls`" })
|
|
6389
|
+
.option("shared", { type: "boolean", default: false, describe: "Write settings.yml (the allowlist owner's file) instead of settings.local.yml" })
|
|
6390
|
+
.option("dir", { type: "string", describe: "Use the .slack-term/ above this directory instead of the cwd's (with --shared: a lane's dir)" }),
|
|
6391
|
+
async (argv) => {
|
|
6392
|
+
const token = tok(argv as W);
|
|
6393
|
+
const cookie = ck(argv as W);
|
|
6394
|
+
const scope = findScope(argv.dir);
|
|
6395
|
+
const threaded = parseSlackPermalink(argv.target)?.ts !== undefined || parseTargetThread(argv.target).threadTs !== undefined;
|
|
6396
|
+
let channel: string;
|
|
6397
|
+
let thread: string | undefined;
|
|
6398
|
+
let note = "";
|
|
6399
|
+
if (threaded) {
|
|
6400
|
+
const place = await messagePlace(token, argv.target, cookie);
|
|
6401
|
+
channel = place.channel;
|
|
6402
|
+
thread = place.root;
|
|
6403
|
+
if (place.root !== place.ts) note = ` (${place.ts} is a reply: named its thread root)`;
|
|
6404
|
+
} else {
|
|
6405
|
+
channel = await resolveChannel(token, parseTargetThread(argv.target).ref, cookie);
|
|
6406
|
+
}
|
|
6407
|
+
let before;
|
|
6055
6408
|
try {
|
|
6056
|
-
|
|
6409
|
+
before = setAlias(scope, argv.name, { channel, ...(thread ? { thread_ts: thread } : {}), ...(argv.note ? { note: argv.note } : {}) }, argv.shared);
|
|
6057
6410
|
} catch (e: unknown) {
|
|
6058
|
-
console.error(
|
|
6059
|
-
|
|
6060
|
-
` ${e instanceof Error ? e.message : String(e)}\n` +
|
|
6061
|
-
` Tip: pass --user-id <Uxxxx> to skip the lookup.`,
|
|
6062
|
-
);
|
|
6063
|
-
process.exit(1);
|
|
6411
|
+
if (e instanceof RestrictedError) { console.error(`Error: ${e.message}`); process.exit(2); }
|
|
6412
|
+
throw e;
|
|
6064
6413
|
}
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
|
|
6075
|
-
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
|
|
6414
|
+
const was = before && (before.channel !== channel || before.thread_ts !== thread) ? ` (was ${before.channel} ${before.thread_ts ?? "-"})` : "";
|
|
6415
|
+
console.log(`${argv.name} → ${channel} ${thread ?? "-"}${note}${was} [${scope.dir}]`);
|
|
6416
|
+
},
|
|
6417
|
+
)
|
|
6418
|
+
.command(
|
|
6419
|
+
["unset <name>", "rm <name>"],
|
|
6420
|
+
"Remove an alias (from settings.local.yml; --shared: also from settings.yml)",
|
|
6421
|
+
(yy) => yy
|
|
6422
|
+
.positional("name", { type: "string", demandOption: true })
|
|
6423
|
+
.option("shared", { type: "boolean", default: false, describe: "Allow removing one defined in settings.yml" })
|
|
6424
|
+
.option("dir", { type: "string", describe: "Use the .slack-term/ above this directory" }),
|
|
6425
|
+
(argv) => {
|
|
6426
|
+
const scope = findScope(argv.dir);
|
|
6427
|
+
let r;
|
|
6428
|
+
try {
|
|
6429
|
+
r = unsetAlias(scope, argv.name, argv.shared);
|
|
6430
|
+
} catch (e: unknown) {
|
|
6431
|
+
if (e instanceof RestrictedError) { console.error(`Error: ${e.message}`); process.exit(2); }
|
|
6432
|
+
throw e;
|
|
6433
|
+
}
|
|
6434
|
+
if (r === "removed") console.log(`${argv.name} removed [${scope.dir}]`);
|
|
6435
|
+
else if (r === "absent") { console.error(`no alias "${argv.name}" in ${scope.dir}`); process.exit(2); }
|
|
6436
|
+
else { console.error(`"${argv.name}" is defined in ${scope.dir}/settings.yml: edit that file to remove it`); process.exit(2); }
|
|
6437
|
+
},
|
|
6438
|
+
)
|
|
6439
|
+
.command(
|
|
6440
|
+
"who <channel> <ts>",
|
|
6441
|
+
"Which registered .slack-term/ dirs subscribe this thread (or message): dir, alias",
|
|
6442
|
+
(yy) => yy
|
|
6443
|
+
.positional("channel", { type: "string", demandOption: true, describe: "Channel id" })
|
|
6444
|
+
.positional("ts", { type: "string", demandOption: true, describe: "Thread root ts (a reply's own ts is not its thread)" })
|
|
6445
|
+
.option("json", { type: "boolean", default: false }),
|
|
6446
|
+
(argv) => {
|
|
6447
|
+
const hits = whoSubscribes(argv.channel, argv.ts).map((a) => ({ dir: dirname(a.scope.dir), scope: a.scope.dir, alias: a.name, file: a.file }));
|
|
6448
|
+
if (argv.json) console.log(JSON.stringify(hits));
|
|
6449
|
+
else for (const h of hits) console.log(`${h.dir}\t${h.alias}`);
|
|
6450
|
+
if (!hits.length) process.exitCode = 2;
|
|
6451
|
+
},
|
|
6452
|
+
)
|
|
6453
|
+
.command(
|
|
6454
|
+
"import <tsv>",
|
|
6455
|
+
"One-shot: import a legacy allowlist.tsv (<thread_ts> TAB <lane>) into each lane's settings.yml",
|
|
6456
|
+
(yy) => yy
|
|
6457
|
+
.positional("tsv", { type: "string", demandOption: true })
|
|
6458
|
+
.option("channel", { type: "string", demandOption: true, describe: "The channel every thread is in (for a DM: the DM id)" })
|
|
6459
|
+
.option("lane-dir", { type: "array", string: true, demandOption: true, describe: "<lane>=<dir> (a directory holding .slack-term/, made if missing); repeatable" }),
|
|
6460
|
+
(argv) => {
|
|
6461
|
+
const dirs = new Map<string, string>();
|
|
6462
|
+
for (const kv of argv["lane-dir"] ?? []) {
|
|
6463
|
+
const eq = String(kv).indexOf("=");
|
|
6464
|
+
if (eq <= 0) { console.error(`Error: --lane-dir ${kv}: want <lane>=<dir>`); process.exit(2); }
|
|
6465
|
+
dirs.set(String(kv).slice(0, eq), String(kv).slice(eq + 1));
|
|
6466
|
+
}
|
|
6467
|
+
let added = 0;
|
|
6468
|
+
const skipped = new Map<string, number>();
|
|
6469
|
+
for (const line of readFileSync(argv.tsv, "utf8").split("\n")) {
|
|
6470
|
+
const [ts, lane] = line.trim().split("\t");
|
|
6471
|
+
if (!ts || line.startsWith("#") || !lane) continue;
|
|
6472
|
+
const dir = dirs.get(lane);
|
|
6473
|
+
if (!dir || !/^\d{10}\.\d{6}$/.test(ts)) { skipped.set(lane, (skipped.get(lane) ?? 0) + 1); continue; }
|
|
6474
|
+
const scope = { dir: initScope(dir).dir, global: false };
|
|
6475
|
+
setAlias(scope, `t-${ts}`, { channel: argv.channel, thread_ts: ts, note: "imported from legacy allowlist" }, true);
|
|
6476
|
+
added++;
|
|
6477
|
+
}
|
|
6478
|
+
console.log(`imported ${added} subscription(s)`);
|
|
6479
|
+
for (const [lane, n] of skipped) console.error(`skipped ${n} line(s) for lane "${lane}" (no --lane-dir, or not a ts)`);
|
|
6480
|
+
},
|
|
6481
|
+
)
|
|
6482
|
+
.demandCommand(1, "alias needs a subcommand: ls, set, unset, who, import"),
|
|
6483
|
+
)
|
|
6484
|
+
.command(
|
|
6485
|
+
"reply [dest] [message]",
|
|
6486
|
+
"Reply in a thread — an alias, a permalink or #chan:<ts> — with the directory's prefix (confirm-hash gate, like send)",
|
|
6487
|
+
(y) => y
|
|
6488
|
+
.positional("dest", { type: "string", describe: "alias | permalink | #chan:<ts> | <ts> (in settings' channel). Omit with --to" })
|
|
6489
|
+
.positional("message", { type: "string", describe: "Text, or - for stdin" })
|
|
6490
|
+
.option("to", { type: "string", describe: "Permalink of the message you are answering: replies in ITS thread; with a dest, refuses when they differ" })
|
|
6491
|
+
.option("any-thread", { type: "boolean", default: false, describe: "With --to and a dest: post to dest even when the message is in another thread" })
|
|
6492
|
+
.option("prefix", { type: "string", describe: "Override settings' prefix (\"\" for none)" })
|
|
6493
|
+
.option("code", { type: "string", describe: "Safety hash to confirm send" })
|
|
6494
|
+
.option("as-bot", { type: "boolean", describe: "Send via the bot token (default: settings' as_bot)" })
|
|
6495
|
+
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
6496
|
+
.option("broadcast", { type: "boolean", default: false, describe: "Also send to channel" })
|
|
6497
|
+
.option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real mentions (--no-mentions for literal text)" }),
|
|
6498
|
+
async (argv) => {
|
|
6499
|
+
let dest = argv.dest;
|
|
6500
|
+
let message = argv.message;
|
|
6501
|
+
// `slack reply --to <link> 'text'`: the one positional is the text.
|
|
6502
|
+
if (argv.to && message === undefined) { message = dest; dest = undefined; }
|
|
6503
|
+
if (message === "-") message = await Bun.stdin.text();
|
|
6504
|
+
if (message === undefined || !message.trim()) { console.error("Error: message is empty"); process.exit(1); }
|
|
6505
|
+
if (!dest && !argv.to) { console.error("Error: reply needs a destination (alias, permalink, #chan:<ts>) or --to <permalink>"); process.exit(2); }
|
|
6506
|
+
const token = tok(argv as W);
|
|
6507
|
+
const cookie = ck(argv as W);
|
|
6508
|
+
const scope = findScope();
|
|
6509
|
+
const settings = readSettings(scope);
|
|
6510
|
+
let where: { channel: string; threadTs: string };
|
|
6079
6511
|
try {
|
|
6080
|
-
await
|
|
6512
|
+
const r = await resolveReplyTarget(token, cookie, scope, dest, argv.to, argv["any-thread"]);
|
|
6513
|
+
if ("error" in r) { console.error(`Error: ${r.error}`); process.exit(2); }
|
|
6514
|
+
where = r;
|
|
6081
6515
|
} catch (e: unknown) {
|
|
6082
6516
|
console.error(friendlySlackError(e));
|
|
6083
6517
|
process.exit(1);
|
|
6084
6518
|
}
|
|
6519
|
+
const { channel, threadTs } = where;
|
|
6520
|
+
// A restricted scope's prefix is its owner's: overriding it would let a
|
|
6521
|
+
// lane post as another.
|
|
6522
|
+
if (isRestricted(scope) && (argv.prefix !== undefined || (argv["as-bot"] !== undefined && argv["as-bot"] !== (settings.as_bot ?? false)))) {
|
|
6523
|
+
console.error(`Error: ${scope.dir} is restricted: who it posts as (prefix, as_bot) comes from settings.yml; --prefix / --as-bot are refused`);
|
|
6524
|
+
process.exit(2);
|
|
6525
|
+
}
|
|
6526
|
+
const text = applyPrefix(message, argv.prefix ?? settings.prefix);
|
|
6527
|
+
if (!text.trim()) { console.error("Error: message is empty (only the prefix)"); process.exit(1); }
|
|
6528
|
+
await runSend({
|
|
6529
|
+
workspace: (argv as W).workspace, target: `${channel}:${threadTs}`, message: text, code: argv.code,
|
|
6530
|
+
"allow-url-adjacent": argv["allow-url-adjacent"], "as-bot": argv["as-bot"] ?? settings.as_bot ?? false,
|
|
6531
|
+
broadcast: argv.broadcast, mentions: argv.mentions,
|
|
6532
|
+
});
|
|
6085
6533
|
},
|
|
6086
6534
|
)
|
|
6535
|
+
.command(
|
|
6536
|
+
"inbox",
|
|
6537
|
+
"This directory's .slack-term/inbox/: messages routed here by `slack stream --inbox`",
|
|
6538
|
+
(y) => y
|
|
6539
|
+
.command(
|
|
6540
|
+
["ls", "list"],
|
|
6541
|
+
"Pending messages: ts, from, alias, text (redacted)",
|
|
6542
|
+
(yy) => yy.option("json", { type: "boolean", default: false }),
|
|
6543
|
+
(argv) => {
|
|
6544
|
+
const items = listInbox(findScope());
|
|
6545
|
+
if (argv.json) { console.log(JSON.stringify(items.map((i) => ({ file: i.file, ...i.entry })))); return; }
|
|
6546
|
+
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}]` : ""}`);
|
|
6547
|
+
},
|
|
6548
|
+
)
|
|
6549
|
+
.command(
|
|
6550
|
+
"listen",
|
|
6551
|
+
"Print each message as it arrives in inbox/ (one JSON line each); pending ones first. Exits 0 at --timeout",
|
|
6552
|
+
(yy) => yy
|
|
6553
|
+
.option("timeout", { type: "number", default: 3600, describe: "Seconds" })
|
|
6554
|
+
.option("new-only", { type: "boolean", default: false, describe: "Skip what is already pending" })
|
|
6555
|
+
.option("thread", { type: "string", describe: "Only messages in this thread (its root ts)" })
|
|
6556
|
+
.option("alias", { type: "string", describe: "Only messages routed by this alias" })
|
|
6557
|
+
.option("from", { type: "array", string: true, describe: "Only messages from these user ids; repeatable" }),
|
|
6558
|
+
async (argv) => {
|
|
6559
|
+
const scope = findScope();
|
|
6560
|
+
const from = argv.from?.length ? new Set(argv.from.map(String)) : undefined;
|
|
6561
|
+
const wanted = (e: { ts: string; thread_ts?: string; alias: string; user: string }): boolean =>
|
|
6562
|
+
(!argv.thread || (e.thread_ts ?? e.ts) === argv.thread) && (!argv.alias || e.alias === argv.alias) && (!from || from.has(e.user));
|
|
6563
|
+
const seen = new Set<string>();
|
|
6564
|
+
if (argv["new-only"]) for (const i of listInbox(scope)) seen.add(i.file);
|
|
6565
|
+
const until = Date.now() + argv.timeout * 1000;
|
|
6566
|
+
for (;;) {
|
|
6567
|
+
for (const i of listInbox(scope)) {
|
|
6568
|
+
if (seen.has(i.file)) continue;
|
|
6569
|
+
seen.add(i.file);
|
|
6570
|
+
if (wanted(i.entry)) console.log(JSON.stringify({ file: i.file, ...i.entry }));
|
|
6571
|
+
}
|
|
6572
|
+
if (Date.now() >= until) break;
|
|
6573
|
+
// Files land by rename, so a short poll is enough and survives
|
|
6574
|
+
// every filesystem (fs.watch does not, on network mounts).
|
|
6575
|
+
await new Promise((r) => setTimeout(r, Math.min(500, Math.max(0, until - Date.now()))));
|
|
6576
|
+
}
|
|
6577
|
+
},
|
|
6578
|
+
)
|
|
6579
|
+
.command(
|
|
6580
|
+
"nudge",
|
|
6581
|
+
"Pending messages older than settings' nudge_after_min (default 30): exit 1 when there are any",
|
|
6582
|
+
(yy) => yy.option("json", { type: "boolean", default: false }),
|
|
6583
|
+
(argv) => {
|
|
6584
|
+
const items = overdue(findScope());
|
|
6585
|
+
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) }))));
|
|
6586
|
+
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}`);
|
|
6587
|
+
if (items.length) process.exitCode = 1;
|
|
6588
|
+
},
|
|
6589
|
+
)
|
|
6590
|
+
.demandCommand(1, "inbox needs a subcommand: ls, listen, nudge"),
|
|
6591
|
+
)
|
|
6592
|
+
.command(
|
|
6593
|
+
"done <ref..>",
|
|
6594
|
+
"Mark inbox messages handled (moved to archived/) without replying: a file name, path or message ts",
|
|
6595
|
+
(y) => y.positional("ref", { type: "string", array: true, demandOption: true }),
|
|
6596
|
+
(argv) => {
|
|
6597
|
+
const scope = findScope();
|
|
6598
|
+
let missing = 0;
|
|
6599
|
+
for (const ref of argv.ref as string[]) {
|
|
6600
|
+
const to = markDone(scope, ref);
|
|
6601
|
+
if (to) console.log(`archived ${to}`);
|
|
6602
|
+
else { console.error(`not in ${scope.dir}/inbox: ${ref}`); missing++; }
|
|
6603
|
+
}
|
|
6604
|
+
if (missing) process.exit(2);
|
|
6605
|
+
},
|
|
6606
|
+
)
|
|
6607
|
+
.command(
|
|
6608
|
+
"outbox",
|
|
6609
|
+
"This directory's .slack-term/outbox/: replies queued as files, sent by `outbox flush`",
|
|
6610
|
+
(y) => y
|
|
6611
|
+
.command(
|
|
6612
|
+
"add <dest> <message>",
|
|
6613
|
+
"Queue a reply (text redacted before it is written; - reads stdin)",
|
|
6614
|
+
(yy) => yy
|
|
6615
|
+
.positional("dest", { type: "string", demandOption: true, describe: "alias | permalink | #chan:<ts>" })
|
|
6616
|
+
.positional("message", { type: "string", demandOption: true })
|
|
6617
|
+
.option("in-reply-to", { type: "string", describe: "ts of the inbox message this answers: archived once sent" }),
|
|
6618
|
+
async (argv) => {
|
|
6619
|
+
const text = argv.message === "-" ? await Bun.stdin.text() : argv.message;
|
|
6620
|
+
if (!text.trim()) { console.error("Error: message is empty"); process.exit(1); }
|
|
6621
|
+
const r = addOutbox(findScope(), { to: argv.dest, text, ...(argv["in-reply-to"] ? { in_reply_to: argv["in-reply-to"] } : {}) });
|
|
6622
|
+
if (r.redacted.length) console.error(`⚠ redacted before queueing: ${r.redacted.join(", ")}`);
|
|
6623
|
+
console.log(r.file);
|
|
6624
|
+
},
|
|
6625
|
+
)
|
|
6626
|
+
.command(
|
|
6627
|
+
["ls", "list"],
|
|
6628
|
+
"Queued replies: file, dest, attempts, error",
|
|
6629
|
+
(yy) => yy,
|
|
6630
|
+
() => {
|
|
6631
|
+
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 ?? ""}` : ""}`);
|
|
6632
|
+
},
|
|
6633
|
+
)
|
|
6634
|
+
.command(
|
|
6635
|
+
"flush",
|
|
6636
|
+
"Send every queued reply (prefix, thread and restrict checks as `reply`); a --code covers the whole batch",
|
|
6637
|
+
(yy) => yy
|
|
6638
|
+
.option("code", { type: "string", describe: "Safety hash to confirm the batch" })
|
|
6639
|
+
.option("as-bot", { type: "boolean", describe: "Send via the bot token (default: settings' as_bot)" }),
|
|
6640
|
+
async (argv) => {
|
|
6641
|
+
const scope = findScope();
|
|
6642
|
+
const settings = readSettings(scope);
|
|
6643
|
+
const items = listOutbox(scope);
|
|
6644
|
+
if (!items.length) { console.error(`nothing in ${scope.dir}/outbox`); return; }
|
|
6645
|
+
if (isRestricted(scope) && argv["as-bot"] !== undefined && argv["as-bot"] !== (settings.as_bot ?? false)) {
|
|
6646
|
+
console.error(`Error: ${scope.dir} is restricted: as_bot comes from settings.yml; --as-bot is refused`);
|
|
6647
|
+
process.exit(2);
|
|
6648
|
+
}
|
|
6649
|
+
const asBot = argv["as-bot"] ?? settings.as_bot ?? false;
|
|
6650
|
+
const lookTok = tok(argv as W);
|
|
6651
|
+
const lookCk = ck(argv as W);
|
|
6652
|
+
let sendTok: string;
|
|
6653
|
+
let sendCk: string | undefined;
|
|
6654
|
+
if (asBot) {
|
|
6655
|
+
const b = resolveBotToken();
|
|
6656
|
+
if (!b) { console.error("Error: as_bot needs a bot token (SLACK_BOT_TOKEN)"); process.exit(1); }
|
|
6657
|
+
sendTok = b;
|
|
6658
|
+
} else {
|
|
6659
|
+
sendTok = lookTok;
|
|
6660
|
+
sendCk = lookCk;
|
|
6661
|
+
}
|
|
6662
|
+
const plan: { item: (typeof items)[number]; channel: string; threadTs: string; text: string }[] = [];
|
|
6663
|
+
const refused: string[] = [];
|
|
6664
|
+
for (const item of items) {
|
|
6665
|
+
try {
|
|
6666
|
+
const r = await resolveReplyTarget(lookTok, lookCk, scope, item.entry.to, undefined);
|
|
6667
|
+
if ("error" in r) { refused.push(`${item.file}: ${r.error}`); continue; }
|
|
6668
|
+
const text = applyPrefix(item.entry.text, settings.prefix);
|
|
6669
|
+
if (!text.trim()) { refused.push(`${item.file}: empty`); continue; }
|
|
6670
|
+
plan.push({ item, ...r, text });
|
|
6671
|
+
} catch (e: unknown) {
|
|
6672
|
+
refused.push(`${item.file}: ${friendlySlackError(e)}`);
|
|
6673
|
+
}
|
|
6674
|
+
}
|
|
6675
|
+
for (const r of refused) console.error(`refused (stays queued): ${r}`);
|
|
6676
|
+
if (!plan.length) process.exit(2);
|
|
6677
|
+
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)}`);
|
|
6679
|
+
const expected = safetyCode("outbox", asBot ? "bot" : "user", ...plan.map((p) => `${p.channel}:${p.threadTs}:${p.text}`));
|
|
6680
|
+
if (argv.code !== expected) requireCode(argv.code, expected, lines);
|
|
6681
|
+
let failed = 0;
|
|
6682
|
+
for (const p of plan) {
|
|
6683
|
+
try {
|
|
6684
|
+
const ts = await slackSend(sendTok, p.channel, p.text, p.threadTs, false, sendCk);
|
|
6685
|
+
let link = "";
|
|
6686
|
+
try { link = await getPermalink(sendTok, p.channel, ts, sendCk); } catch { /* the ts is what matters */ }
|
|
6687
|
+
const to = recordOutboxSent(scope, p.item, { ts, permalink: link, channel: p.channel, thread_ts: p.threadTs });
|
|
6688
|
+
console.log(`✓ ${link || ts} (${to})`);
|
|
6689
|
+
} catch (e: unknown) {
|
|
6690
|
+
recordFailure(p.item, friendlySlackError(e));
|
|
6691
|
+
console.error(`✗ ${p.item.file}: ${friendlySlackError(e)}`);
|
|
6692
|
+
failed++;
|
|
6693
|
+
}
|
|
6694
|
+
}
|
|
6695
|
+
if (failed) process.exit(1);
|
|
6696
|
+
},
|
|
6697
|
+
)
|
|
6698
|
+
.demandCommand(1, "outbox needs a subcommand: add, ls, flush"),
|
|
6699
|
+
)
|
|
6087
6700
|
.command(
|
|
6088
6701
|
"ask [target] [question] [choices..]",
|
|
6089
6702
|
"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…>`)",
|
|
@@ -6743,39 +7356,18 @@ async function main(): Promise<void> {
|
|
|
6743
7356
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
6744
7357
|
.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." })
|
|
6745
7358
|
.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." }),
|
|
6746
|
-
async (argv) =>
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
|
|
6757
|
-
|
|
6758
|
-
// Bot identity is what makes a DM notify at all, so "uncorrectable"
|
|
6759
|
-
// applied to exactly the messages that matter most.
|
|
6760
|
-
const resolved = await asBotTarget(args.target, tok(argv as W), ck(argv as W), args.channelId);
|
|
6761
|
-
editToken = resolved.botToken;
|
|
6762
|
-
if (resolved.channelId && !args.channelId) args.channelId = resolved.channelId;
|
|
6763
|
-
args.asBot = true;
|
|
6764
|
-
// Mentions still resolve on the USER token: the bot has no users:read,
|
|
6765
|
-
// and a silently unresolved @handle in a CORRECTION is the same defect
|
|
6766
|
-
// twice.
|
|
6767
|
-
args.mentionToken = tok(argv as W);
|
|
6768
|
-
const mc = ck(argv as W);
|
|
6769
|
-
if (mc) args.mentionCookie = mc;
|
|
6770
|
-
} else {
|
|
6771
|
-
editToken = tok(argv as W);
|
|
6772
|
-
// Not set under --as-bot: a bot token needs no session cookie, and
|
|
6773
|
-
// attaching one is how a working call starts failing.
|
|
6774
|
-
const cookie = ck(argv as W);
|
|
6775
|
-
if (cookie) args.cookie = cookie;
|
|
6776
|
-
}
|
|
6777
|
-
await cmdEdit(editToken, args);
|
|
6778
|
-
},
|
|
7359
|
+
async (argv) => runEdit(argv),
|
|
7360
|
+
)
|
|
7361
|
+
.command(
|
|
7362
|
+
"tick <target> [n]",
|
|
7363
|
+
"Tick item n of a posted checklist (⬜ n. → ✅ n.), nothing else changed; without n, the one open item (confirm-hash gate, like edit)",
|
|
7364
|
+
(y) => y
|
|
7365
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
|
|
7366
|
+
.positional("n", { type: "number", describe: "Item number" })
|
|
7367
|
+
.option("code", { type: "string", describe: "Safety hash to confirm the edit" })
|
|
7368
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
7369
|
+
.option("as-bot", { type: "boolean", default: false, describe: "Edit as the bot (needed for a message the bot posted)" }),
|
|
7370
|
+
async (argv) => runEdit({ ...argv, tick: argv.n !== undefined ? { n: argv.n } : {}, mentions: false }),
|
|
6779
7371
|
)
|
|
6780
7372
|
.command(
|
|
6781
7373
|
"delete <target>",
|
|
@@ -7638,6 +8230,46 @@ async function main(): Promise<void> {
|
|
|
7638
8230
|
)
|
|
7639
8231
|
.command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
|
|
7640
8232
|
)
|
|
8233
|
+
.command(
|
|
8234
|
+
"relay",
|
|
8235
|
+
"Set up / check the real-time relay for `slack stream` (a Cloudflare Worker)",
|
|
8236
|
+
(y) => y
|
|
8237
|
+
.command(
|
|
8238
|
+
"init [dir]",
|
|
8239
|
+
"Scaffold a deployable relay Worker folder (local files only; prints a fresh RELAY_TOKEN and the next steps)",
|
|
8240
|
+
(y2) => y2
|
|
8241
|
+
.positional("dir", { type: "string", default: DEFAULT_DIR, describe: "Folder to create" })
|
|
8242
|
+
.option("name", { type: "string", describe: "Worker name (default: the folder's name)" })
|
|
8243
|
+
.option("force", { type: "boolean", default: false, describe: "Overwrite files that already exist" })
|
|
8244
|
+
.option("slack-term", { type: "string", describe: "slack-term dependency spec (default: this CLI's version), e.g. latest or file:../slack-term" }),
|
|
8245
|
+
(argv) => {
|
|
8246
|
+
const r = initRelay(argv.dir, {
|
|
8247
|
+
force: argv.force,
|
|
8248
|
+
...(argv.name !== undefined ? { name: argv.name } : {}),
|
|
8249
|
+
...(argv["slack-term"] !== undefined ? { slackTerm: argv["slack-term"] } : {}),
|
|
8250
|
+
});
|
|
8251
|
+
if (!r.ok) {
|
|
8252
|
+
console.error(`slack relay init: ${r.error}`);
|
|
8253
|
+
for (const f of r.existing ?? []) console.error(` exists: ${f}`);
|
|
8254
|
+
process.exit(2);
|
|
8255
|
+
}
|
|
8256
|
+
console.log(initReport(r));
|
|
8257
|
+
},
|
|
8258
|
+
)
|
|
8259
|
+
.command(
|
|
8260
|
+
"health",
|
|
8261
|
+
"GET /health on the relay (SLACK_RELAY_URL / SLACK_RELAY_TOKEN); exit 0 = ok",
|
|
8262
|
+
(y2) => y2
|
|
8263
|
+
.option("url", { type: "string", describe: "Relay URL (default: $SLACK_RELAY_URL)" })
|
|
8264
|
+
.option("token", { type: "string", describe: "Relay token (default: $SLACK_RELAY_TOKEN)" }),
|
|
8265
|
+
async (argv) => {
|
|
8266
|
+
const r = await relayHealth(argv.url ?? process.env.SLACK_RELAY_URL, argv.token ?? process.env.SLACK_RELAY_TOKEN);
|
|
8267
|
+
(r.code === 0 ? console.log : console.error)(r.out);
|
|
8268
|
+
process.exit(r.code);
|
|
8269
|
+
},
|
|
8270
|
+
)
|
|
8271
|
+
.demandCommand(1, "Specify a relay subcommand (init, health)"),
|
|
8272
|
+
)
|
|
7641
8273
|
.command(
|
|
7642
8274
|
"stream",
|
|
7643
8275
|
"Print every new message matching --grep, across all channels the identity is in (threads included); resumable",
|
|
@@ -7655,8 +8287,9 @@ async function main(): Promise<void> {
|
|
|
7655
8287
|
.option("calls-per-min", { type: "number", default: 40, describe: "Polling: pace conversations.history/replies to this many calls per minute (0 = unpaced)" })
|
|
7656
8288
|
.option("state", { type: "string", describe: "Cursor file (default: one per identity+grep+channels under $XDG_STATE_HOME/slack-term/stream/)" })
|
|
7657
8289
|
.option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
|
|
7658
|
-
.option("relay", { type: "boolean", default: true, describe: "Use the doorbell relay (
|
|
7659
|
-
.option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" })
|
|
8290
|
+
.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
|
+
.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)" }),
|
|
7660
8293
|
async (argv) => {
|
|
7661
8294
|
let re: RegExp;
|
|
7662
8295
|
let reconcileMs: number;
|
|
@@ -7762,6 +8395,9 @@ async function main(): Promise<void> {
|
|
|
7762
8395
|
const ac = new AbortController();
|
|
7763
8396
|
process.on("SIGINT", () => ac.abort());
|
|
7764
8397
|
process.on("SIGTERM", () => ac.abort());
|
|
8398
|
+
// The stream schedules around Retry-After itself (bells and polls take
|
|
8399
|
+
// turns): a call that slept inside would stall both.
|
|
8400
|
+
setRateLimitWait(0);
|
|
7765
8401
|
const code = await runStream(webClient(token, cookie), {
|
|
7766
8402
|
grep: re,
|
|
7767
8403
|
...(repliesTo ? { repliesTo } : {}),
|
|
@@ -7769,6 +8405,18 @@ async function main(): Promise<void> {
|
|
|
7769
8405
|
selfUsers,
|
|
7770
8406
|
selfBots,
|
|
7771
8407
|
json: argv.json,
|
|
8408
|
+
...(argv.inbox ? {
|
|
8409
|
+
onMatch: (rec: StreamMatch, files: number) => {
|
|
8410
|
+
try {
|
|
8411
|
+
// Our own credentials, masked by exact value on top of the rules.
|
|
8412
|
+
const known = [token, cookie, process.env.SLACK_RELAY_TOKEN].filter((v): v is string => !!v);
|
|
8413
|
+
const dirs = deliver({ ...rec, ...(files ? { files } : {}) }, undefined, new Date(), known);
|
|
8414
|
+
if (dirs.length) console.error(`slack stream: delivered ${rec.channel.id}:${rec.ts} to ${dirs.length} inbox(es)`);
|
|
8415
|
+
} catch (e: unknown) {
|
|
8416
|
+
console.error(`slack stream: inbox delivery failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
8417
|
+
}
|
|
8418
|
+
},
|
|
8419
|
+
} : {}),
|
|
7772
8420
|
once: argv.once,
|
|
7773
8421
|
...(sinceSec !== undefined ? { sinceSec } : {}),
|
|
7774
8422
|
intervalMs,
|