slack-term 1.48.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 +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 +729 -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,41 @@ 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) 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
|
+
}
|
|
446
485
|
if (format === "jsonl") {
|
|
447
486
|
for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
|
|
448
487
|
return;
|
|
@@ -1287,9 +1326,89 @@ function parseTargetThread(s: string, permalinkThreads = true): { ref: string; t
|
|
|
1287
1326
|
if (/^\d{10}\.\d{6}$/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: maybeTs };
|
|
1288
1327
|
if (/^\d{4}-\d{2}-\d{2}T/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: isoToSlackTs(maybeTs) };
|
|
1289
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 };
|
|
1290
1332
|
return { ref: s };
|
|
1291
1333
|
}
|
|
1292
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
|
+
|
|
1293
1412
|
/** Human-readable destination label for confirm gates, e.g. "#general (C0123456789)".
|
|
1294
1413
|
* When ref is already a #channel/@user label, keep it; otherwise resolve the channel
|
|
1295
1414
|
* name via conversations.info (fail-soft: a raw ID is still unambiguous). */
|
|
@@ -1489,6 +1608,12 @@ interface EditArgs {
|
|
|
1489
1608
|
* `send --as-bot`. */
|
|
1490
1609
|
mentionToken?: string;
|
|
1491
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;
|
|
1492
1617
|
}
|
|
1493
1618
|
/** The bot token, plus the bot↔user DM when the target names an `@handle`.
|
|
1494
1619
|
*
|
|
@@ -1559,11 +1684,24 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
1559
1684
|
}
|
|
1560
1685
|
const originalText = typeof original.text === "string" ? original.text : "";
|
|
1561
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
|
+
|
|
1562
1699
|
// Convert @handle → <@USERID> before hashing/editing (unresolved stay as text).
|
|
1700
|
+
// A tick rewrites stored text, whose mentions are encoded already.
|
|
1563
1701
|
const mentionCookie = args.mentionCookie ?? args.cookie;
|
|
1564
|
-
const newText = args.mentions
|
|
1565
|
-
? await encodeMentions(args.mentionToken ?? token,
|
|
1566
|
-
:
|
|
1702
|
+
const newText = args.mentions && !args.tick
|
|
1703
|
+
? await encodeMentions(args.mentionToken ?? token, wanted, channelId, mentionCookie ? { cookie: mentionCookie } : {})
|
|
1704
|
+
: wanted;
|
|
1567
1705
|
|
|
1568
1706
|
guardUrlBoundaries(newText, args.allowUrlAdjacent);
|
|
1569
1707
|
|
|
@@ -2011,7 +2149,25 @@ async function cmdPinlogUpdate(token: string, args: PinlogUpdateArgs): Promise<v
|
|
|
2011
2149
|
process.exit(1);
|
|
2012
2150
|
}
|
|
2013
2151
|
attrEdit.record({ team: self?.team, channel, target: args.target, ts, text: newHead, asBot: args.asBot });
|
|
2014
|
-
|
|
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}`);
|
|
2015
2171
|
}
|
|
2016
2172
|
|
|
2017
2173
|
const attrLog = sentAttribution("send");
|
|
@@ -2302,6 +2458,9 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
2302
2458
|
const { ref, threadTs } = parseTargetThread(args.target);
|
|
2303
2459
|
const cookie = args.cookie;
|
|
2304
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
|
+
|
|
2305
2464
|
// Identity of the token that will actually post — the bot token under
|
|
2306
2465
|
// --as-bot, the user token otherwise.
|
|
2307
2466
|
const getSelf = selfLookup(token, cookie);
|
|
@@ -2643,9 +2802,6 @@ function askExplainDefault(): boolean {
|
|
|
2643
2802
|
return /^(1|true|yes|on)$/i.test(process.env.SLACK_TERM_ASK_EXPLAIN ?? "");
|
|
2644
2803
|
}
|
|
2645
2804
|
|
|
2646
|
-
const ASK_PHI = 1.618033988749895;
|
|
2647
|
-
const ASK_POLL_MIN_MS = 1000;
|
|
2648
|
-
const ASK_POLL_MAX_MS = 5000;
|
|
2649
2805
|
const ASK_MAX_CONSECUTIVE_ERRORS = 8;
|
|
2650
2806
|
|
|
2651
2807
|
|
|
@@ -3336,8 +3492,11 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3336
3492
|
}
|
|
3337
3493
|
|
|
3338
3494
|
const deadline = Date.now() + timeout * 1000;
|
|
3495
|
+
const started = Date.now();
|
|
3339
3496
|
let errors = 0;
|
|
3340
|
-
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;
|
|
3341
3500
|
let unchosen: AskPollState["unchosen"];
|
|
3342
3501
|
for (;;) {
|
|
3343
3502
|
let found: AskFound | null = null;
|
|
@@ -3345,13 +3504,19 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3345
3504
|
found = await poll();
|
|
3346
3505
|
errors = 0;
|
|
3347
3506
|
} catch (e: unknown) {
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
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)})`);
|
|
3353
3519
|
}
|
|
3354
|
-
console.error(` (Slack エラー ${errors}/${ASK_MAX_CONSECUTIVE_ERRORS}, 再試行します: ${e instanceof Error ? e.message : String(e)})`);
|
|
3355
3520
|
}
|
|
3356
3521
|
unchosen = state.unchosen;
|
|
3357
3522
|
const voided = state.voided;
|
|
@@ -3442,8 +3607,9 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3442
3607
|
// Checked after the first poll, never before it: `--timeout 0` means one
|
|
3443
3608
|
// look, and one look must still be a real look.
|
|
3444
3609
|
if (Date.now() >= deadline) break;
|
|
3445
|
-
await askSleep(Math.min(delay, Math.max(0, deadline - Date.now())));
|
|
3446
|
-
|
|
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);
|
|
3447
3613
|
}
|
|
3448
3614
|
|
|
3449
3615
|
// A timeout is the moment the caller most needs the collect command: the
|
|
@@ -5501,6 +5667,145 @@ async function main(): Promise<void> {
|
|
|
5501
5667
|
const tok = (a: W) => resolveToken(a.workspace);
|
|
5502
5668
|
const ck = (a: W) => resolveCookie(a.workspace);
|
|
5503
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
|
+
|
|
5504
5809
|
// `todo ls` and `mytodo ls` are the same listing over two different search
|
|
5505
5810
|
// scopes — `has:` (anyone's reactions) vs `hasmy:` (only this token's). They
|
|
5506
5811
|
// share one definition so the flags can never drift apart; `mine` is the only
|
|
@@ -5594,7 +5899,7 @@ async function main(): Promise<void> {
|
|
|
5594
5899
|
["read [target]", "msgs [target]"],
|
|
5595
5900
|
"Browse messages",
|
|
5596
5901
|
(y) => y
|
|
5597
|
-
.positional("target", { type: "string", describe: "#channel, @user, or
|
|
5902
|
+
.positional("target", { type: "string", describe: "#channel, @user, URL, or an alias (slack alias ls)" })
|
|
5598
5903
|
.option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" })
|
|
5599
5904
|
.option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
|
|
5600
5905
|
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
|
|
@@ -5602,7 +5907,11 @@ async function main(): Promise<void> {
|
|
|
5602
5907
|
async (argv) => {
|
|
5603
5908
|
const token = tok(argv as W);
|
|
5604
5909
|
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
|
|
5605
|
-
|
|
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));
|
|
5606
5915
|
else await cmdMsgs(token);
|
|
5607
5916
|
},
|
|
5608
5917
|
)
|
|
@@ -5613,11 +5922,12 @@ async function main(): Promise<void> {
|
|
|
5613
5922
|
.positional("target", { type: "string", demandOption: true })
|
|
5614
5923
|
.positional("ts", { type: "string", demandOption: true })
|
|
5615
5924
|
.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" })
|
|
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)" }),
|
|
5618
5928
|
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));
|
|
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);
|
|
5621
5931
|
},
|
|
5622
5932
|
)
|
|
5623
5933
|
.command(
|
|
@@ -6011,81 +6321,382 @@ async function main(): Promise<void> {
|
|
|
6011
6321
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
6012
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" })
|
|
6013
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." })
|
|
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." })
|
|
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" }),
|
|
6015
6327
|
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
|
-
);
|
|
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));
|
|
6050
6340
|
process.exit(1);
|
|
6051
6341
|
}
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
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;
|
|
6057
6408
|
try {
|
|
6058
|
-
|
|
6409
|
+
before = setAlias(scope, argv.name, { channel, ...(thread ? { thread_ts: thread } : {}), ...(argv.note ? { note: argv.note } : {}) }, argv.shared);
|
|
6059
6410
|
} 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);
|
|
6411
|
+
if (e instanceof RestrictedError) { console.error(`Error: ${e.message}`); process.exit(2); }
|
|
6412
|
+
throw e;
|
|
6066
6413
|
}
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
|
|
6075
|
-
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
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 };
|
|
6081
6511
|
try {
|
|
6082
|
-
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;
|
|
6083
6515
|
} catch (e: unknown) {
|
|
6084
6516
|
console.error(friendlySlackError(e));
|
|
6085
6517
|
process.exit(1);
|
|
6086
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
|
+
});
|
|
6087
6533
|
},
|
|
6088
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
|
+
)
|
|
6089
6700
|
.command(
|
|
6090
6701
|
"ask [target] [question] [choices..]",
|
|
6091
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…>`)",
|
|
@@ -6745,39 +7356,18 @@ async function main(): Promise<void> {
|
|
|
6745
7356
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
6746
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." })
|
|
6747
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." }),
|
|
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
|
-
},
|
|
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 }),
|
|
6781
7371
|
)
|
|
6782
7372
|
.command(
|
|
6783
7373
|
"delete <target>",
|
|
@@ -7698,7 +8288,8 @@ async function main(): Promise<void> {
|
|
|
7698
8288
|
.option("state", { type: "string", describe: "Cursor file (default: one per identity+grep+channels under $XDG_STATE_HOME/slack-term/stream/)" })
|
|
7699
8289
|
.option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
|
|
7700
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" })
|
|
7701
|
-
.option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" })
|
|
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)" }),
|
|
7702
8293
|
async (argv) => {
|
|
7703
8294
|
let re: RegExp;
|
|
7704
8295
|
let reconcileMs: number;
|
|
@@ -7804,6 +8395,9 @@ async function main(): Promise<void> {
|
|
|
7804
8395
|
const ac = new AbortController();
|
|
7805
8396
|
process.on("SIGINT", () => ac.abort());
|
|
7806
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);
|
|
7807
8401
|
const code = await runStream(webClient(token, cookie), {
|
|
7808
8402
|
grep: re,
|
|
7809
8403
|
...(repliesTo ? { repliesTo } : {}),
|
|
@@ -7811,6 +8405,18 @@ async function main(): Promise<void> {
|
|
|
7811
8405
|
selfUsers,
|
|
7812
8406
|
selfBots,
|
|
7813
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
|
+
} : {}),
|
|
7814
8420
|
once: argv.once,
|
|
7815
8421
|
...(sinceSec !== undefined ? { sinceSec } : {}),
|
|
7816
8422
|
intervalMs,
|