slack-term 1.24.0 → 1.26.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 +130 -7
- package/SKILL.md +92 -8
- package/dist/cli.js +93 -90
- package/package.json +5 -2
- package/ts/cache.ts +120 -0
- package/ts/cli.ts +558 -35
- package/ts/format.ts +13 -10
- package/ts/slack.ts +42 -2
- package/ts/tail.ts +11 -7
- package/ts/todo.ts +483 -0
package/ts/cli.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
|
|
9
|
-
import yargs from "yargs";
|
|
9
|
+
import yargs, { type Options } from "yargs";
|
|
10
10
|
import { hideBin } from "yargs/helpers";
|
|
11
11
|
import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile, type Profile } from "./profiles.ts";
|
|
12
12
|
import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
reactionRemove,
|
|
30
30
|
filesInfo,
|
|
31
31
|
filesList,
|
|
32
|
+
listRecords,
|
|
32
33
|
history,
|
|
33
34
|
listConversations,
|
|
34
35
|
listDrafts,
|
|
@@ -53,6 +54,22 @@ import {
|
|
|
53
54
|
getPath,
|
|
54
55
|
type Json,
|
|
55
56
|
} from "./slack.ts";
|
|
57
|
+
import {
|
|
58
|
+
buildTodoQuery,
|
|
59
|
+
loadTodoConfig,
|
|
60
|
+
resolveChannelCached,
|
|
61
|
+
todoDoctor,
|
|
62
|
+
todoFlag,
|
|
63
|
+
userHandleCached,
|
|
64
|
+
todoSet,
|
|
65
|
+
withRateLimitRetry,
|
|
66
|
+
DOCTOR_DEFAULT_LIMIT,
|
|
67
|
+
type FlagName,
|
|
68
|
+
type LsQueryOpts,
|
|
69
|
+
type LsState,
|
|
70
|
+
type ProgressState,
|
|
71
|
+
} from "./todo.ts";
|
|
72
|
+
import { setCacheEnabled } from "./cache.ts";
|
|
56
73
|
import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
|
|
57
74
|
|
|
58
75
|
function loadDotenv(path: string): void {
|
|
@@ -162,6 +179,9 @@ async function formatMsgLine(
|
|
|
162
179
|
m: Record<string, Json>,
|
|
163
180
|
cache: Map<string, string>,
|
|
164
181
|
chLabel?: string,
|
|
182
|
+
// Session cookie (xoxd) for `token` — required for an xoxc- desktop session
|
|
183
|
+
// token; without it users.info 401s and every author renders as a raw ID.
|
|
184
|
+
cookie?: string,
|
|
165
185
|
): Promise<string> {
|
|
166
186
|
const rawTs = typeof m.ts === "string" ? m.ts : `${tsNum(m)}.000000`;
|
|
167
187
|
const stamp = slackTsToIso(rawTs);
|
|
@@ -169,8 +189,11 @@ async function formatMsgLine(
|
|
|
169
189
|
if (typeof m.user === "string") {
|
|
170
190
|
const uid = m.user;
|
|
171
191
|
const handleKey = "@" + uid;
|
|
192
|
+
// Two-tier: this call's in-process Map → ~/.config/slack-cli/cache.json
|
|
193
|
+
// (1h TTL, workspace-scoped) → users.info. Handles barely change, and a
|
|
194
|
+
// long listing re-resolves the same few authors on every run.
|
|
172
195
|
if (!cache.has(handleKey)) {
|
|
173
|
-
const
|
|
196
|
+
const h = await userHandleCached(token, uid, async (t, u, c) => (await userInfoPair(t, u, c))[1], cookie);
|
|
174
197
|
cache.set(handleKey, h);
|
|
175
198
|
}
|
|
176
199
|
handle = cache.get(handleKey) ?? uid;
|
|
@@ -178,7 +201,7 @@ async function formatMsgLine(
|
|
|
178
201
|
handle = m.username;
|
|
179
202
|
}
|
|
180
203
|
const raw = typeof m.text === "string" ? m.text : "";
|
|
181
|
-
const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache));
|
|
204
|
+
const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache, cookie));
|
|
182
205
|
const lines = resolved.split("\n");
|
|
183
206
|
const body = lines[0] + (lines.length > 1 ? "\n" + lines.slice(1).map(l => ` ${l}`).join("\n") : "");
|
|
184
207
|
const who = chLabel ? `${chLabel} @${handle}` : `@${handle}`;
|
|
@@ -200,8 +223,12 @@ async function formatMsgLine(
|
|
|
200
223
|
return `\n 📎 ${name}${sz}${id}`;
|
|
201
224
|
})
|
|
202
225
|
.join("");
|
|
226
|
+
// Thread parents carry reply_count; surface it inline so a reader of
|
|
227
|
+
// top-level history can see there is more to read without opening each ts.
|
|
228
|
+
const replyCount = Number(m.reply_count ?? 0);
|
|
229
|
+
const threadMark = replyCount > 0 ? ` [+${replyCount} ${replyCount === 1 ? "reply" : "replies"}]` : "";
|
|
203
230
|
const tail = (reactions ? `\n ${reactions}` : "") + attachTail;
|
|
204
|
-
return `${stamp} ${who}: ${body}${tail}`;
|
|
231
|
+
return `${stamp} ${who}: ${body}${threadMark}${tail}`;
|
|
205
232
|
}
|
|
206
233
|
|
|
207
234
|
// Human-readable byte size, shared by upload/download/attachment rendering.
|
|
@@ -236,39 +263,73 @@ function slimMsg(m: Record<string, Json>): Record<string, Json> {
|
|
|
236
263
|
bot_id: m.bot_id ?? null,
|
|
237
264
|
thread_ts: m.thread_ts ?? null,
|
|
238
265
|
text: typeof m.text === "string" ? m.text : "",
|
|
266
|
+
// Thread stats, present only on thread parents. Without these a caller
|
|
267
|
+
// reading top-level history cannot tell a post with replies from one
|
|
268
|
+
// without, and silently misses everything said inside the thread.
|
|
269
|
+
...(m.reply_count !== undefined ? { reply_count: m.reply_count } : {}),
|
|
270
|
+
...(m.reply_users_count !== undefined ? { reply_users_count: m.reply_users_count } : {}),
|
|
271
|
+
...(m.latest_reply !== undefined ? { latest_reply: m.latest_reply } : {}),
|
|
239
272
|
// Present only when the message carries attachments, so scripts can reliably
|
|
240
273
|
// detect files without the key adding noise to every plain-text line.
|
|
241
274
|
...(files.length > 0 ? { files } : {}),
|
|
242
275
|
};
|
|
243
276
|
}
|
|
244
277
|
|
|
278
|
+
// Keep only conversations whose last word is somebody else's — i.e. still
|
|
279
|
+
// awaiting our answer. For a thread parent that means the newest reply, which
|
|
280
|
+
// costs one conversations.replies call per thread (latest_reply gives the ts but
|
|
281
|
+
// not its author, so it cannot answer this on its own).
|
|
282
|
+
async function filterUnreplied(
|
|
283
|
+
token: string,
|
|
284
|
+
channelId: string,
|
|
285
|
+
msgs: Record<string, Json>[],
|
|
286
|
+
selfId: string,
|
|
287
|
+
cookie?: string,
|
|
288
|
+
): Promise<Record<string, Json>[]> {
|
|
289
|
+
const out: Record<string, Json>[] = [];
|
|
290
|
+
for (const m of msgs) {
|
|
291
|
+
let last = m;
|
|
292
|
+
if (Number(m.reply_count ?? 0) > 0 && typeof m.ts === "string") {
|
|
293
|
+
const resp = (await replies(token, channelId, m.ts, 200, cookie)) as Record<string, Json>;
|
|
294
|
+
const all = asArray(resp.messages).map(asRecord);
|
|
295
|
+
last = all[all.length - 1] ?? m;
|
|
296
|
+
}
|
|
297
|
+
if (last.user !== selfId) out.push(m);
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
245
302
|
// --- msgs <target> — channel/DM history with timestamps ---
|
|
246
|
-
async function cmdMsgsTarget(token: string, target: string, limit: number, format: "text" | "jsonl" = "text"): Promise<void> {
|
|
303
|
+
async function cmdMsgsTarget(token: string, target: string, limit: number, format: "text" | "jsonl" = "text", unreplied = false, cookie?: string): Promise<void> {
|
|
247
304
|
const parsed = parseSlackPermalink(target);
|
|
248
|
-
const channelId = await resolveChannel(token, target);
|
|
305
|
+
const channelId = await resolveChannel(token, target, cookie);
|
|
249
306
|
const cache = new Map<string, string>();
|
|
250
307
|
const fetchMsgs = async (): Promise<Record<string, Json>[]> => {
|
|
251
308
|
if (parsed?.threadTs) {
|
|
252
|
-
const resp = (await replies(token, channelId, parsed.threadTs, limit)) as Record<string, Json>;
|
|
309
|
+
const resp = (await replies(token, channelId, parsed.threadTs, limit, cookie)) as Record<string, Json>;
|
|
253
310
|
return asArray(resp.messages).map(asRecord);
|
|
254
311
|
}
|
|
255
|
-
const hist = (await history(token, channelId, limit)) as Record<string, Json>;
|
|
312
|
+
const hist = (await history(token, channelId, limit, undefined, undefined, cookie)) as Record<string, Json>;
|
|
256
313
|
return asArray(hist.messages).map(asRecord).reverse();
|
|
257
314
|
};
|
|
258
|
-
|
|
315
|
+
let msgs = await fetchMsgs();
|
|
316
|
+
if (unreplied) {
|
|
317
|
+
const { userId } = await authTest(token, cookie);
|
|
318
|
+
msgs = await filterUnreplied(token, channelId, msgs, userId, cookie);
|
|
319
|
+
}
|
|
259
320
|
if (format === "jsonl") {
|
|
260
321
|
for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
|
|
261
322
|
return;
|
|
262
323
|
}
|
|
263
324
|
for (const m of msgs) {
|
|
264
|
-
console.log(await formatMsgLine(token, m, cache));
|
|
325
|
+
console.log(await formatMsgLine(token, m, cache, undefined, cookie));
|
|
265
326
|
}
|
|
266
327
|
}
|
|
267
328
|
|
|
268
329
|
// --- thread ---
|
|
269
|
-
async function cmdThread(token: string, target: string, ts: string, limit: number, format: "text" | "jsonl" = "text"): Promise<void> {
|
|
270
|
-
const channelId = await resolveChannel(token, target);
|
|
271
|
-
const resp = (await replies(token, channelId, parseInputTs(ts), limit)) as Record<string, Json>;
|
|
330
|
+
async function cmdThread(token: string, target: string, ts: string, limit: number, format: "text" | "jsonl" = "text", cookie?: string): Promise<void> {
|
|
331
|
+
const channelId = await resolveChannel(token, target, cookie);
|
|
332
|
+
const resp = (await replies(token, channelId, parseInputTs(ts), limit, cookie)) as Record<string, Json>;
|
|
272
333
|
const msgs = asArray(resp.messages).map(asRecord);
|
|
273
334
|
if (format === "jsonl") {
|
|
274
335
|
for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
|
|
@@ -276,7 +337,7 @@ async function cmdThread(token: string, target: string, ts: string, limit: numbe
|
|
|
276
337
|
}
|
|
277
338
|
const cache = new Map<string, string>();
|
|
278
339
|
for (const m of msgs) {
|
|
279
|
-
console.log(await formatMsgLine(token, m, cache));
|
|
340
|
+
console.log(await formatMsgLine(token, m, cache, undefined, cookie));
|
|
280
341
|
}
|
|
281
342
|
}
|
|
282
343
|
|
|
@@ -457,7 +518,115 @@ async function cmdSearch(token: string, query: string, count: number, json: bool
|
|
|
457
518
|
} else {
|
|
458
519
|
chLabel = `#${rawName}`;
|
|
459
520
|
}
|
|
460
|
-
|
|
521
|
+
// Pass searchCookie: an xoxc- desktop token is rejected by users.info without
|
|
522
|
+
// its session cookie, and both the author handle and every <@UID> mention
|
|
523
|
+
// silently degrade to raw IDs. The DM-label lookup above already forwards it.
|
|
524
|
+
console.log(await formatMsgLine(searchToken, m, cache, chLabel, searchCookie));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// --- todo — reaction-backed task tracking (see ts/todo.ts for the state model) ---
|
|
529
|
+
|
|
530
|
+
/** Resolve a `#chan:<ts>` / permalink target to (channelId, ts) for todo writes. */
|
|
531
|
+
async function todoTarget(
|
|
532
|
+
token: string,
|
|
533
|
+
target: string,
|
|
534
|
+
channelIdOpt: string | undefined,
|
|
535
|
+
cookie: string | undefined,
|
|
536
|
+
): Promise<{ channelId: string; ts: string }> {
|
|
537
|
+
const { ref, ts } = splitRefTs(target);
|
|
538
|
+
if (!ts) {
|
|
539
|
+
console.error("Error: target must embed a message ts (e.g. #chan:1700000000.000100 or a Slack permalink URL)");
|
|
540
|
+
process.exit(2);
|
|
541
|
+
}
|
|
542
|
+
const channelId = channelIdOpt ?? (await resolveChannelCached(token, ref, resolveChannel, cookie));
|
|
543
|
+
return { channelId, ts };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function cmdTodoLs(
|
|
547
|
+
token: string,
|
|
548
|
+
opts: { state: LsState; in?: string; from?: string; mine: boolean; count: number; json: boolean; cookie?: string },
|
|
549
|
+
): Promise<void> {
|
|
550
|
+
const cfg = loadTodoConfig();
|
|
551
|
+
const queryOpts: LsQueryOpts = {};
|
|
552
|
+
// `mine` is the narrow view (`hasmy:` — only reactions this token's user made).
|
|
553
|
+
// Anything else searches anyone's reactions (`has:`).
|
|
554
|
+
if (!opts.mine) queryOpts.shared = true;
|
|
555
|
+
if (opts.in) queryOpts.in = opts.in;
|
|
556
|
+
if (opts.from) queryOpts.from = opts.from;
|
|
557
|
+
const query = buildTodoQuery(opts.state, cfg, queryOpts);
|
|
558
|
+
if (!opts.json) {
|
|
559
|
+
// Name the identity the listing is relative to. `hasmy:` resolves against
|
|
560
|
+
// whoever the token is, so "my tasks" is only meaningful once you know who
|
|
561
|
+
// "my" is — a wrong profile silently lists a different person's tasks.
|
|
562
|
+
// Same From: line the write gates print, for the same reason.
|
|
563
|
+
const self = await selfIdentity(token, opts.cookie);
|
|
564
|
+
const scope = opts.mine ? "your reactions only [hasmy:]" : "anyone's reactions [has:]";
|
|
565
|
+
console.error(`(From: ${senderLabel(self)} · matching ${scope})`);
|
|
566
|
+
console.error(`(query: ${query})`);
|
|
567
|
+
}
|
|
568
|
+
// One search per invocation — search.messages is Tier 2 (~20 req/min), so the
|
|
569
|
+
// priority rule is encoded as negations in the query, not as extra searches.
|
|
570
|
+
await withRateLimitRetry("search.messages", () => cmdSearch(token, query, opts.count, opts.json, opts.cookie));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function cmdTodoSet(
|
|
574
|
+
token: string,
|
|
575
|
+
args: { target: string; state: ProgressState; channelId?: string; cookie?: string },
|
|
576
|
+
): Promise<void> {
|
|
577
|
+
const cfg = loadTodoConfig();
|
|
578
|
+
const { channelId, ts } = await todoTarget(token, args.target, args.channelId, args.cookie);
|
|
579
|
+
const res = await todoSet(token, channelId, ts, args.state, cfg, args.cookie);
|
|
580
|
+
if (res.noop) {
|
|
581
|
+
console.log(`= Already ${args.state} (ts: ${ts})`);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const removedNote = res.removed.length ? `, removed ${res.removed.map((e) => `:${e}:`).join(" ")}` : "";
|
|
585
|
+
console.log(`✓ ${args.state} :${cfg.progress[args.state]}: (ts: ${ts})${removedNote}`);
|
|
586
|
+
if (res.leftover.length) {
|
|
587
|
+
console.error(
|
|
588
|
+
`⚠ ${res.leftover.map((e) => `:${e}:`).join(" ")} could not be removed — this message now carries more than one ` +
|
|
589
|
+
`progress reaction. Readers collapse by priority so nothing is lost; repair with: slack todo doctor --in <#chan> --fix`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function cmdTodoFlag(
|
|
595
|
+
token: string,
|
|
596
|
+
args: { target: string; flag: FlagName; remove?: boolean; channelId?: string; cookie?: string },
|
|
597
|
+
): Promise<void> {
|
|
598
|
+
const cfg = loadTodoConfig();
|
|
599
|
+
const { channelId, ts } = await todoTarget(token, args.target, args.channelId, args.cookie);
|
|
600
|
+
const emoji = await todoFlag(token, channelId, ts, args.flag, cfg, args.remove === true, args.cookie);
|
|
601
|
+
console.log(`✓ ${args.remove ? "Cleared" : "Flagged"} ${args.flag} :${emoji}: (ts: ${ts})`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function cmdTodoDoctor(
|
|
605
|
+
token: string,
|
|
606
|
+
args: { channel: string; fix?: boolean; limit: number; channelId?: string; cookie?: string },
|
|
607
|
+
): Promise<void> {
|
|
608
|
+
const cfg = loadTodoConfig();
|
|
609
|
+
const channelId = args.channelId ?? (await resolveChannelCached(token, args.channel, resolveChannel, args.cookie));
|
|
610
|
+
const doctorOpts: { fix?: boolean; limit: number; cookie?: string } = { limit: args.limit };
|
|
611
|
+
if (args.fix) doctorOpts.fix = true;
|
|
612
|
+
if (args.cookie) doctorOpts.cookie = args.cookie;
|
|
613
|
+
const report = await todoDoctor(token, channelId, cfg, doctorOpts);
|
|
614
|
+
if (report.findings.length === 0) {
|
|
615
|
+
console.log(`✓ No multi-state tasks in ${report.scanned} message(s).`);
|
|
616
|
+
} else {
|
|
617
|
+
for (const f of report.findings) {
|
|
618
|
+
const status = f.fixed === true ? "fixed" : f.fixed === false ? `FAILED: ${f.error ?? "?"}` : "needs --fix";
|
|
619
|
+
console.log(
|
|
620
|
+
`${f.ts} ${f.emoji.map((e) => `:${e}:`).join(" ")} → keep :${f.keep}: [${status}] ` +
|
|
621
|
+
`${stripTerminalControls(f.text).slice(0, 60)}`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
console.log(`${report.findings.length} multi-state task(s) in ${report.scanned} message(s).`);
|
|
625
|
+
}
|
|
626
|
+
if (report.truncated) {
|
|
627
|
+
console.error(
|
|
628
|
+
`⚠ Scan limit reached (${args.limit} messages) — older history was NOT checked. Raise it with --limit N.`,
|
|
629
|
+
);
|
|
461
630
|
}
|
|
462
631
|
}
|
|
463
632
|
|
|
@@ -747,7 +916,7 @@ function parseTargetThread(s: string): { ref: string; threadTs?: string } {
|
|
|
747
916
|
return { ref: s };
|
|
748
917
|
}
|
|
749
918
|
|
|
750
|
-
/** Human-readable destination label for confirm gates, e.g. "#
|
|
919
|
+
/** Human-readable destination label for confirm gates, e.g. "#general (C0123456789)".
|
|
751
920
|
* When ref is already a #channel/@user label, keep it; otherwise resolve the channel
|
|
752
921
|
* name via conversations.info (fail-soft: a raw ID is still unambiguous). */
|
|
753
922
|
async function destLabel(token: string, channelId: string, ref: string, cookie?: string): Promise<string> {
|
|
@@ -769,6 +938,55 @@ async function destLabel(token: string, channelId: string, ref: string, cookie?:
|
|
|
769
938
|
return name === channelId ? channelId : `${name} (${channelId})`;
|
|
770
939
|
}
|
|
771
940
|
|
|
941
|
+
/** Identity behind the token a write command will act with. */
|
|
942
|
+
type Self = { userId: string; user: string; botId: string; team: string };
|
|
943
|
+
|
|
944
|
+
/** `auth.test` for the token that will perform a write. Every confirm gate names
|
|
945
|
+
* the acting identity and binds it into the safety hash. Resolves to null
|
|
946
|
+
* (never throws) when the lookup fails, so a gate still renders and the write
|
|
947
|
+
* still goes through. */
|
|
948
|
+
async function selfIdentity(token: string, cookie?: string): Promise<Self | null> {
|
|
949
|
+
try {
|
|
950
|
+
return await authScopes(token, cookie);
|
|
951
|
+
} catch {
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Memoized `selfIdentity`, for commands that consult it more than once (`send`
|
|
957
|
+
* needs it for the unreplied guard as well as the gate) — a command must never
|
|
958
|
+
* pay for more than one lookup. */
|
|
959
|
+
function selfLookup(token: string, cookie?: string): () => Promise<Self | null> {
|
|
960
|
+
let cache: Self | null | undefined;
|
|
961
|
+
return async () => {
|
|
962
|
+
if (cache === undefined) cache = await selfIdentity(token, cookie);
|
|
963
|
+
return cache;
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Name the identity a write will be performed AS, for confirm gates:
|
|
968
|
+
* "@snomiao (U0123ABC) — Acme". Slack shows only the author on the resulting
|
|
969
|
+
* message, so a wrong profile / stale SLACK_TOKEN / unintended --as-bot is
|
|
970
|
+
* otherwise invisible until after it lands — handle, user id and workspace pin
|
|
971
|
+
* all three down. `self` is null when the auth.test lookup failed (fail-soft:
|
|
972
|
+
* the gate still renders, it just can't name the actor). */
|
|
973
|
+
function senderLabel(self: Self | null, asBot?: boolean): string {
|
|
974
|
+
const suffix = asBot ? " [as bot]" : "";
|
|
975
|
+
if (!self) return `(unknown — auth.test failed)${suffix}`;
|
|
976
|
+
// Display text is workspace-controlled; strip control chars like the other gates.
|
|
977
|
+
const name = stripTerminalControls(self.user || self.userId || "?");
|
|
978
|
+
const id = self.userId ? ` (${self.userId})` : "";
|
|
979
|
+
const team = self.team ? ` — ${stripTerminalControls(self.team)}` : "";
|
|
980
|
+
return `@${name}${id}${team}${suffix}`;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** The `From:` line every write gate opens its action block with. `pad` is the
|
|
984
|
+
* label field width, for gates whose other labels sit on a wider column
|
|
985
|
+
* (`To:`/`At:`/`Message:` on schedule, `Title:`/`Size:` on upload). */
|
|
986
|
+
function fromLine(self: Self | null, opts: { asBot?: boolean | undefined; pad?: number } = {}): string {
|
|
987
|
+
return ` ${"From:".padEnd(opts.pad ?? 6)}${senderLabel(self, opts.asBot)}`;
|
|
988
|
+
}
|
|
989
|
+
|
|
772
990
|
/** One-line description of a single thread message for confirm gates:
|
|
773
991
|
* "2026-06-11 08:05 @handle: head…". `headLen` bounds the text preview. */
|
|
774
992
|
async function threadMsgLine(token: string, m: Record<string, Json>, headLen = 60, cookie?: string): Promise<string> {
|
|
@@ -850,6 +1068,7 @@ interface EditArgs {
|
|
|
850
1068
|
cookie?: string;
|
|
851
1069
|
}
|
|
852
1070
|
async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
1071
|
+
const getSelf = selfLookup(token, args.cookie);
|
|
853
1072
|
const { ref, ts } = splitRefTs(args.target);
|
|
854
1073
|
if (!ts) {
|
|
855
1074
|
console.error("Error: target must embed a message ts (e.g. #chan:2026-05-11T06:01:04.000100 or a Slack permalink URL)");
|
|
@@ -875,9 +1094,16 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
875
1094
|
? await encodeMentions(token, args.newText, channelId, args.cookie ? { cookie: args.cookie } : {})
|
|
876
1095
|
: args.newText;
|
|
877
1096
|
|
|
878
|
-
|
|
1097
|
+
// Identity is part of the hash for the same reason it is on `send`: a code
|
|
1098
|
+
// minted while previewing as one identity must not confirm the write as
|
|
1099
|
+
// another (Slack only lets you edit your own messages, so a silent profile
|
|
1100
|
+
// switch between preview and confirm otherwise turns into a bare API error).
|
|
1101
|
+
const self = await getSelf();
|
|
1102
|
+
const code = safetyCode(originalText, newText, self?.userId ?? "");
|
|
879
1103
|
if (args.code !== code) {
|
|
880
1104
|
requireCode(args.code, code, [
|
|
1105
|
+
`--- Editing as -------------------------------`,
|
|
1106
|
+
fromLine(self),
|
|
881
1107
|
`--- Original message -------------------------`,
|
|
882
1108
|
...originalText.split("\n").map((l) => ` ${l}`),
|
|
883
1109
|
`--- Replacing with ---------------------------`,
|
|
@@ -898,6 +1124,7 @@ interface DeleteArgs {
|
|
|
898
1124
|
cookie?: string;
|
|
899
1125
|
}
|
|
900
1126
|
async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
|
|
1127
|
+
const getSelf = selfLookup(token, args.cookie);
|
|
901
1128
|
const { ref, ts } = splitRefTs(args.target);
|
|
902
1129
|
if (!ts) {
|
|
903
1130
|
console.error("Error: target must embed a message ts (e.g. #chan:2026-05-11T06:01:04.000100 or a Slack permalink URL)");
|
|
@@ -918,11 +1145,15 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
|
|
|
918
1145
|
}
|
|
919
1146
|
const originalText = typeof original.text === "string" ? original.text : "";
|
|
920
1147
|
|
|
921
|
-
|
|
1148
|
+
// Identity in the hash: same rule as send/edit — a code minted as one
|
|
1149
|
+
// identity can't confirm the delete as another.
|
|
1150
|
+
const self = await getSelf();
|
|
1151
|
+
const code = safetyCode(channelId, ts, originalText, self?.userId ?? "");
|
|
922
1152
|
if (args.code !== code) {
|
|
923
1153
|
const dest = await destLabel(token, channelId, ref, args.cookie);
|
|
924
1154
|
requireCode(args.code, code, [
|
|
925
1155
|
`--- Deleting message -------------------------`,
|
|
1156
|
+
fromLine(self),
|
|
926
1157
|
` → ${dest} at ${slackTsToIso(ts)}`,
|
|
927
1158
|
...originalText.split("\n").map((l) => ` ${l}`),
|
|
928
1159
|
`---------------------------------------------`,
|
|
@@ -1010,6 +1241,10 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
1010
1241
|
const { ref, threadTs } = parseTargetThread(args.target);
|
|
1011
1242
|
const cookie = args.cookie;
|
|
1012
1243
|
|
|
1244
|
+
// Identity of the token that will actually post — the bot token under
|
|
1245
|
+
// --as-bot, the user token otherwise.
|
|
1246
|
+
const getSelf = selfLookup(token, cookie);
|
|
1247
|
+
|
|
1013
1248
|
// Guard the self-DM footgun before doing anything else, unless sending as the
|
|
1014
1249
|
// bot (which delivers a notifiable DM from a different identity).
|
|
1015
1250
|
if (!args.asBot && !args.channelId && !args.userId && await isSelfDm(token, ref, cookie)) {
|
|
@@ -1141,10 +1376,12 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
1141
1376
|
// shape `send --as-bot` produces. Opt out with SLACK_UNREPLIED_WARN=0.
|
|
1142
1377
|
if (process.env.SLACK_UNREPLIED_WARN !== "0" && lastMsgTs && (lastMsgUserId || lastMsgBotId)) {
|
|
1143
1378
|
try {
|
|
1144
|
-
const self = await
|
|
1145
|
-
|
|
1379
|
+
const self = await getSelf();
|
|
1380
|
+
// `self` is null when the identity lookup failed — can't tell whose
|
|
1381
|
+
// message it is, so stay quiet rather than guess.
|
|
1382
|
+
const isSelfAuthor = !!self && (
|
|
1146
1383
|
(!!lastMsgUserId && !!self.userId && self.userId === lastMsgUserId) ||
|
|
1147
|
-
(!!lastMsgBotId && !!self.botId && self.botId === lastMsgBotId);
|
|
1384
|
+
(!!lastMsgBotId && !!self.botId && self.botId === lastMsgBotId));
|
|
1148
1385
|
if (isSelfAuthor) {
|
|
1149
1386
|
// Prefer a real permalink (exact, copy-pasteable). If that lookup fails,
|
|
1150
1387
|
// fall back to a form guaranteed parseable by `slack edit` regardless of
|
|
@@ -1187,12 +1424,17 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
1187
1424
|
}
|
|
1188
1425
|
}
|
|
1189
1426
|
|
|
1190
|
-
// Hash covers the destination too — a code minted
|
|
1191
|
-
// cannot confirm a send to another
|
|
1192
|
-
|
|
1427
|
+
// Hash covers the destination and the sending identity too — a code minted
|
|
1428
|
+
// for one channel/thread cannot confirm a send to another, and one minted
|
|
1429
|
+
// while previewing as identity A cannot confirm a send as identity B (a
|
|
1430
|
+
// profile / SLACK_TOKEN / --as-bot switch between preview and confirm
|
|
1431
|
+
// re-previews instead). Empty on lookup failure, same fail-soft as lastText.
|
|
1432
|
+
const self = await getSelf();
|
|
1433
|
+
const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "");
|
|
1193
1434
|
|
|
1194
1435
|
if (args.code !== code) {
|
|
1195
1436
|
const dest = await destLabel(token, channelId, ref, cookie);
|
|
1437
|
+
const from = fromLine(self, { asBot: args.asBot });
|
|
1196
1438
|
if (threadTs) {
|
|
1197
1439
|
const parentLine = await threadParentLine(token, threadTs, threadMsgs, cookie);
|
|
1198
1440
|
// Preview the tail of the thread (up to 3 most recent messages) so the
|
|
@@ -1206,6 +1448,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
1206
1448
|
...recentLines,
|
|
1207
1449
|
...mentionLines,
|
|
1208
1450
|
`--- Sending ----------------------------------`,
|
|
1451
|
+
from,
|
|
1209
1452
|
` → ${dest} thread of ${parentLine} — THREAD REPLY`,
|
|
1210
1453
|
` Message: ${message}`,
|
|
1211
1454
|
`--------------------------------────────────`,
|
|
@@ -1216,6 +1459,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
1216
1459
|
` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
|
|
1217
1460
|
...mentionLines,
|
|
1218
1461
|
`--- Sending ----------------------------------`,
|
|
1462
|
+
from,
|
|
1219
1463
|
` → ${dest} — NEW top-level message`,
|
|
1220
1464
|
` Message: ${message}`,
|
|
1221
1465
|
`--------------------------------────────────`,
|
|
@@ -1272,6 +1516,26 @@ function parsePostAt(at: string): number {
|
|
|
1272
1516
|
return Math.floor(d.getTime() / 1000);
|
|
1273
1517
|
}
|
|
1274
1518
|
|
|
1519
|
+
/** Render a scheduled post time as local AND UTC: "2026-08-10 18:00:00 GMT+9
|
|
1520
|
+
* (local) / 2026-08-10T09:00:00.000Z (Unix: 1786352400)". A schedule gate is
|
|
1521
|
+
* exactly where an off-by-a-timezone mistake gets locked in — `--at` accepts
|
|
1522
|
+
* both bare local times and `Z`-suffixed UTC, so showing only one form leaves
|
|
1523
|
+
* the reader doing the conversion in their head. Local is first: it's the form
|
|
1524
|
+
* the sender thinks in. */
|
|
1525
|
+
function fmtPostAt(postAt: number): string {
|
|
1526
|
+
const d = new Date(postAt * 1000);
|
|
1527
|
+
const local = new Intl.DateTimeFormat("sv-SE", {
|
|
1528
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
1529
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
1530
|
+
hour12: false, timeZoneName: "short",
|
|
1531
|
+
})
|
|
1532
|
+
.format(d)
|
|
1533
|
+
// sv-SE renders a west-of-UTC offset with U+2212 MINUS SIGN ("GMT−4"), which
|
|
1534
|
+
// survives copy/paste into a shell or a grep as a non-ASCII byte. Plain hyphen.
|
|
1535
|
+
.replace(/−/g, "-");
|
|
1536
|
+
return `${local} (local) / ${d.toISOString()} (Unix: ${postAt})`;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1275
1539
|
interface ScheduleSendArgs {
|
|
1276
1540
|
target: string;
|
|
1277
1541
|
message: string;
|
|
@@ -1282,6 +1546,7 @@ interface ScheduleSendArgs {
|
|
|
1282
1546
|
cookie?: string;
|
|
1283
1547
|
}
|
|
1284
1548
|
async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
|
|
1549
|
+
const getSelf = selfLookup(token, args.cookie);
|
|
1285
1550
|
const { ref, threadTs } = parseTargetThread(args.target);
|
|
1286
1551
|
|
|
1287
1552
|
let channelId: string;
|
|
@@ -1291,13 +1556,18 @@ async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<v
|
|
|
1291
1556
|
|
|
1292
1557
|
const postAt = parsePostAt(args.at);
|
|
1293
1558
|
const postAtDate = new Date(postAt * 1000).toISOString();
|
|
1294
|
-
|
|
1559
|
+
// Identity in the hash, same as the other gated writes. It matters more here
|
|
1560
|
+
// than anywhere else: the message goes out later, unattended, under whichever
|
|
1561
|
+
// identity was current at schedule time.
|
|
1562
|
+
const self = await getSelf();
|
|
1563
|
+
const code = safetyCode(channelId, args.message, String(postAt), self?.userId ?? "");
|
|
1295
1564
|
|
|
1296
1565
|
if (args.code !== code) {
|
|
1297
1566
|
requireCode(args.code, code, [
|
|
1298
1567
|
`--- Scheduling message -----------------------`,
|
|
1568
|
+
fromLine(self, { pad: 9 }),
|
|
1299
1569
|
` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`,
|
|
1300
|
-
` At: ${
|
|
1570
|
+
` At: ${fmtPostAt(postAt)}`,
|
|
1301
1571
|
` Message: ${args.message}`,
|
|
1302
1572
|
`---------------------------------------------`,
|
|
1303
1573
|
]);
|
|
@@ -1336,10 +1606,12 @@ async function cmdScheduleRm(token: string, args: ScheduleRmArgs): Promise<void>
|
|
|
1336
1606
|
if (args.channelId) channelId = args.channelId;
|
|
1337
1607
|
else channelId = await resolveChannel(token, args.target, args.cookie);
|
|
1338
1608
|
|
|
1339
|
-
const
|
|
1609
|
+
const self = await selfIdentity(token, args.cookie);
|
|
1610
|
+
const code = safetyCode(channelId, args.id, self?.userId ?? "");
|
|
1340
1611
|
if (args.code !== code) {
|
|
1341
1612
|
requireCode(args.code, code, [
|
|
1342
1613
|
`--- Deleting scheduled message ---------------`,
|
|
1614
|
+
fromLine(self, { pad: 9 }),
|
|
1343
1615
|
` Channel: ${args.target}`,
|
|
1344
1616
|
` ID: ${args.id}`,
|
|
1345
1617
|
`---------------------------------------------`,
|
|
@@ -1361,6 +1633,7 @@ interface UploadArgs {
|
|
|
1361
1633
|
cookie?: string;
|
|
1362
1634
|
}
|
|
1363
1635
|
async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
1636
|
+
const getSelf = selfLookup(token, args.cookie);
|
|
1364
1637
|
const { statSync, existsSync } = await import("node:fs");
|
|
1365
1638
|
const { basename } = await import("node:path");
|
|
1366
1639
|
|
|
@@ -1385,19 +1658,23 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
1385
1658
|
return { fp, filename, title, sizeFmt: fmtSize(statSync(fp).size) };
|
|
1386
1659
|
});
|
|
1387
1660
|
|
|
1388
|
-
// Safety code covers the full batch
|
|
1389
|
-
|
|
1661
|
+
// Safety code covers the full batch, plus the uploading identity (same rule
|
|
1662
|
+
// as send/edit/delete — the file lands under whoever the token is).
|
|
1663
|
+
const self = await getSelf();
|
|
1664
|
+
const code = safetyCode(channelId, ...files.flatMap((f) => [f.fp, f.title]), self?.userId ?? "");
|
|
1390
1665
|
if (args.code !== code) {
|
|
1391
1666
|
const destLine = ` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`;
|
|
1392
1667
|
const lines = isBatch
|
|
1393
1668
|
? [
|
|
1394
1669
|
`--- Uploading ${files.length} files ------------------------`,
|
|
1670
|
+
fromLine(self, { pad: 7 }),
|
|
1395
1671
|
destLine,
|
|
1396
1672
|
...files.map((f) => ` ${f.filename} (${f.sizeFmt})`),
|
|
1397
1673
|
`--------------------------------────────────`,
|
|
1398
1674
|
]
|
|
1399
1675
|
: [
|
|
1400
1676
|
`--- Uploading file ---------------------------`,
|
|
1677
|
+
fromLine(self, { pad: 7 }),
|
|
1401
1678
|
destLine,
|
|
1402
1679
|
` File: ${files[0]!.fp}`,
|
|
1403
1680
|
` Title: ${files[0]!.title}`,
|
|
@@ -1467,6 +1744,74 @@ async function cmdFilesList(
|
|
|
1467
1744
|
}
|
|
1468
1745
|
}
|
|
1469
1746
|
|
|
1747
|
+
// --- lists <ref> — read a Slack List's rows (read-only) ---
|
|
1748
|
+
// ref: a list URL (…/lists/<TEAM>/<F-id> or …/lists/<F-id>) or a bare F-id.
|
|
1749
|
+
// There is no list-enumeration API, so the id must come from the list's URL.
|
|
1750
|
+
function parseListId(ref: string): string | undefined {
|
|
1751
|
+
if (/^F[A-Z0-9]+$/i.test(ref)) return ref;
|
|
1752
|
+
return ref.match(/\/lists\/[A-Za-z0-9]+\/(F[A-Za-z0-9]+)/i)?.[1]
|
|
1753
|
+
?? ref.match(/\/lists\/(F[A-Za-z0-9]+)/i)?.[1];
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// Best-effort display of a Slack List cell across the value shapes the API uses
|
|
1757
|
+
// (plain text, typed value, arrays of {text|name|value}, typed sub-objects).
|
|
1758
|
+
function listFieldValue(f: Record<string, Json>): string {
|
|
1759
|
+
if (typeof f.text === "string" && f.text) return f.text;
|
|
1760
|
+
const v = f.value;
|
|
1761
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return String(v);
|
|
1762
|
+
if (Array.isArray(v)) {
|
|
1763
|
+
return v.map((x) => typeof x === "string" ? x : String(asRecord(x).text ?? asRecord(x).name ?? asRecord(x).value ?? "")).filter(Boolean).join(", ");
|
|
1764
|
+
}
|
|
1765
|
+
for (const k of ["rich_text", "select", "user", "date", "number", "checkbox", "email", "phone"]) {
|
|
1766
|
+
const tv = f[k];
|
|
1767
|
+
if (tv != null) return typeof tv === "object" ? JSON.stringify(tv) : String(tv);
|
|
1768
|
+
}
|
|
1769
|
+
return "";
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
async function cmdLists(
|
|
1773
|
+
token: string,
|
|
1774
|
+
cookie: string | undefined,
|
|
1775
|
+
ref: string,
|
|
1776
|
+
opts: { limit: number; format: string },
|
|
1777
|
+
): Promise<void> {
|
|
1778
|
+
const listId = parseListId(ref);
|
|
1779
|
+
if (!listId) {
|
|
1780
|
+
throw new Error(
|
|
1781
|
+
`Not a list ID or list URL: ${ref}\n` +
|
|
1782
|
+
`Expected F… or https://<ws>.slack.com/lists/<TEAM>/<F-id>`,
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
const records: Record<string, Json>[] = [];
|
|
1786
|
+
let cursor: string | undefined;
|
|
1787
|
+
do {
|
|
1788
|
+
const listOpts: { limit: number; cursor?: string } = { limit: 200 };
|
|
1789
|
+
if (cursor) listOpts.cursor = cursor;
|
|
1790
|
+
const resp = asRecord((await listRecords(token, listId, listOpts, cookie)) as Json);
|
|
1791
|
+
for (const it of asArray(resp.items ?? resp.records)) records.push(asRecord(it));
|
|
1792
|
+
const next = asRecord(resp.response_metadata).next_cursor;
|
|
1793
|
+
cursor = typeof next === "string" && next ? next : undefined;
|
|
1794
|
+
} while (cursor && records.length < opts.limit);
|
|
1795
|
+
const shown = records.slice(0, opts.limit);
|
|
1796
|
+
|
|
1797
|
+
if (opts.format === "jsonl") {
|
|
1798
|
+
for (const r of shown) console.log(JSON.stringify(r));
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
for (const r of shown) {
|
|
1802
|
+
const id = String(r.id ?? "");
|
|
1803
|
+
const cells = asArray(r.fields)
|
|
1804
|
+
.map(asRecord)
|
|
1805
|
+
.map((f) => {
|
|
1806
|
+
const col = String(f.key ?? f.column_id ?? "?");
|
|
1807
|
+
const val = listFieldValue(f);
|
|
1808
|
+
return val ? `${col}=${val}` : "";
|
|
1809
|
+
})
|
|
1810
|
+
.filter(Boolean);
|
|
1811
|
+
console.log(`${id} ${cells.join(" | ")}`);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1470
1815
|
// --- download <ref> [dest] — fetch an attachment's bytes to disk (read-only) ---
|
|
1471
1816
|
// ref: a file ID (F…) or a Slack file permalink (…/files/<UID>/<FID>/<name>).
|
|
1472
1817
|
function parseFileId(ref: string): string | undefined {
|
|
@@ -1512,6 +1857,44 @@ async function main(): Promise<void> {
|
|
|
1512
1857
|
const tok = (a: W) => resolveToken(a.workspace);
|
|
1513
1858
|
const ck = (a: W) => resolveCookie(a.workspace);
|
|
1514
1859
|
|
|
1860
|
+
// `todo ls` and `mytodo ls` are the same listing over two different search
|
|
1861
|
+
// scopes — `has:` (anyone's reactions) vs `hasmy:` (only this token's). They
|
|
1862
|
+
// share one definition so the flags can never drift apart; `mine` is the only
|
|
1863
|
+
// difference. Splitting them into separate commands is deliberate: the old
|
|
1864
|
+
// single `todo ls` defaulted to `hasmy:` with a `--shared` opt-in, and "is
|
|
1865
|
+
// this everyone's list or just mine?" is not a question a flag answers at a
|
|
1866
|
+
// glance.
|
|
1867
|
+
const TODO_LS_OPTIONS: Record<string, Options> = {
|
|
1868
|
+
state: {
|
|
1869
|
+
type: "string",
|
|
1870
|
+
choices: ["open", "untriaged", "pending", "doing", "done", "dropped", "stuck"],
|
|
1871
|
+
default: "open",
|
|
1872
|
+
describe: "open (default) = marked and not done/dropped; untriaged = marked with no progress; stuck = unfinished with a reason flag",
|
|
1873
|
+
},
|
|
1874
|
+
in: { type: "string", describe: "Limit to a channel (#chan)" },
|
|
1875
|
+
from: { type: "string", describe: "Limit to a sender (@user, or 'me') — usually the person a task is waiting on" },
|
|
1876
|
+
n: { alias: "count", type: "number", default: 50, describe: "Max results" },
|
|
1877
|
+
json: { type: "boolean", default: false },
|
|
1878
|
+
};
|
|
1879
|
+
// Runs both listings. `mine` fixes the search scope per command; `--mine` on
|
|
1880
|
+
// `todo ls` is the one-off escape hatch to the narrow view.
|
|
1881
|
+
const runTodoLs = async (
|
|
1882
|
+
argv: W & { state?: string; in?: string; from?: string; mine?: boolean; n?: number; json?: boolean },
|
|
1883
|
+
mine: boolean,
|
|
1884
|
+
) => {
|
|
1885
|
+
const opts: { state: LsState; in?: string; from?: string; mine: boolean; count: number; json: boolean; cookie?: string } = {
|
|
1886
|
+
state: (argv.state ?? "open") as LsState,
|
|
1887
|
+
mine: mine || argv.mine === true,
|
|
1888
|
+
count: argv.n ?? 50,
|
|
1889
|
+
json: argv.json === true,
|
|
1890
|
+
};
|
|
1891
|
+
if (argv.in) opts.in = argv.in;
|
|
1892
|
+
if (argv.from) opts.from = argv.from;
|
|
1893
|
+
const cookie = ck(argv);
|
|
1894
|
+
if (cookie) opts.cookie = cookie;
|
|
1895
|
+
await cmdTodoLs(tok(argv), opts);
|
|
1896
|
+
};
|
|
1897
|
+
|
|
1515
1898
|
await yargs(hideBin(process.argv))
|
|
1516
1899
|
.scriptName("slack")
|
|
1517
1900
|
// A thrown handler error (e.g. a Slack API failure) is a runtime error, not a usage
|
|
@@ -1552,11 +1935,12 @@ async function main(): Promise<void> {
|
|
|
1552
1935
|
.positional("target", { type: "string", describe: "#channel, @user, or URL" })
|
|
1553
1936
|
.option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" })
|
|
1554
1937
|
.option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
|
|
1555
|
-
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
|
|
1938
|
+
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
|
|
1939
|
+
.option("unreplied", { type: "boolean", default: false, describe: "Only messages whose last word (incl. thread replies) is not yours — i.e. awaiting your answer" }),
|
|
1556
1940
|
async (argv) => {
|
|
1557
1941
|
const token = tok(argv as W);
|
|
1558
1942
|
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
|
|
1559
|
-
if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format);
|
|
1943
|
+
if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format, argv.unreplied, ck(argv as W));
|
|
1560
1944
|
else await cmdMsgs(token);
|
|
1561
1945
|
},
|
|
1562
1946
|
)
|
|
@@ -1571,7 +1955,7 @@ async function main(): Promise<void> {
|
|
|
1571
1955
|
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
|
|
1572
1956
|
async (argv) => {
|
|
1573
1957
|
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
|
|
1574
|
-
await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format);
|
|
1958
|
+
await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format, ck(argv as W));
|
|
1575
1959
|
},
|
|
1576
1960
|
)
|
|
1577
1961
|
.command(
|
|
@@ -1631,9 +2015,13 @@ async function main(): Promise<void> {
|
|
|
1631
2015
|
const name = argv.name!.replace(/^#/, "").toLowerCase();
|
|
1632
2016
|
const isPrivate = argv.private === true;
|
|
1633
2017
|
const invites = (argv.invite as string[]).filter(Boolean);
|
|
1634
|
-
|
|
2018
|
+
// The creator becomes the channel's owner and the inviter of record —
|
|
2019
|
+
// so the gate names them, and the code is bound to them.
|
|
2020
|
+
const self = await selfIdentity(token, cookie);
|
|
2021
|
+
const code = safetyCode(name, String(isPrivate), invites.join(","), self?.userId ?? "");
|
|
1635
2022
|
if (argv.code !== code) requireCode(argv.code, code, [
|
|
1636
2023
|
`--- Creating channel -------------------------`,
|
|
2024
|
+
fromLine(self, { pad: 9 }),
|
|
1637
2025
|
` name: ${isPrivate ? "🔒 " : "#"}${name}`,
|
|
1638
2026
|
` private: ${isPrivate}`,
|
|
1639
2027
|
...(invites.length ? [` invite: ${invites.join(", ")}`] : []),
|
|
@@ -2017,6 +2405,118 @@ async function main(): Promise<void> {
|
|
|
2017
2405
|
}
|
|
2018
2406
|
},
|
|
2019
2407
|
)
|
|
2408
|
+
.command(
|
|
2409
|
+
"todo",
|
|
2410
|
+
"Reaction-backed task tracking (:pushpin: marks a task, progress lives in a second reaction)",
|
|
2411
|
+
(y) => y
|
|
2412
|
+
// yargs turns this into --no-cache automatically (boolean negation).
|
|
2413
|
+
.option("cache", { type: "boolean", default: true, describe: "Use ~/.config/slack-cli/cache.json for channel/user lookups (--no-cache to bypass)" })
|
|
2414
|
+
.middleware((argv) => { if (argv.cache === false) setCacheEnabled(false); })
|
|
2415
|
+
.command(
|
|
2416
|
+
["ls", "list"],
|
|
2417
|
+
"List everyone's tasks (has: — read-only, one search per run)",
|
|
2418
|
+
(y2) => y2
|
|
2419
|
+
.options(TODO_LS_OPTIONS)
|
|
2420
|
+
.option("mine", { type: "boolean", default: false, describe: "Only your own reactions (hasmy:) — same as `slack mytodo ls`" })
|
|
2421
|
+
// Accepted for compatibility: it used to opt in to `has:`, which is
|
|
2422
|
+
// now the default here. .strict() rejects unknown flags, so scripts
|
|
2423
|
+
// still passing --shared keep working instead of hard-failing.
|
|
2424
|
+
.option("shared", { type: "boolean", default: false, hidden: true, describe: "Deprecated — anyone's reactions is the default for `todo ls`" }),
|
|
2425
|
+
async (argv) => { await runTodoLs(argv as W & { state?: string }, false); },
|
|
2426
|
+
)
|
|
2427
|
+
.command(
|
|
2428
|
+
"set <target> <state>",
|
|
2429
|
+
"Move a task to a progress state (adds the new reaction before removing the old one)",
|
|
2430
|
+
(y2) => y2
|
|
2431
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts or permalink" })
|
|
2432
|
+
.positional("state", { type: "string", demandOption: true, choices: ["pending", "doing", "done", "dropped"] })
|
|
2433
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" }),
|
|
2434
|
+
async (argv) => {
|
|
2435
|
+
const args: { target: string; state: ProgressState; channelId?: string; cookie?: string } = {
|
|
2436
|
+
target: argv.target!,
|
|
2437
|
+
state: argv.state as ProgressState,
|
|
2438
|
+
};
|
|
2439
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
2440
|
+
const cookie = ck(argv as W);
|
|
2441
|
+
if (cookie) args.cookie = cookie;
|
|
2442
|
+
try {
|
|
2443
|
+
await cmdTodoSet(tok(argv as W), args);
|
|
2444
|
+
} catch (e: unknown) {
|
|
2445
|
+
console.error(friendlySlackError(e));
|
|
2446
|
+
process.exit(1);
|
|
2447
|
+
}
|
|
2448
|
+
},
|
|
2449
|
+
)
|
|
2450
|
+
.command(
|
|
2451
|
+
"flag <target> <flag>",
|
|
2452
|
+
"Add (or --remove) a reason flag (alert | needs-discussion | waiting | blocked) — flags stack, independent of progress",
|
|
2453
|
+
(y2) => y2
|
|
2454
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts or permalink" })
|
|
2455
|
+
.positional("flag", {
|
|
2456
|
+
type: "string",
|
|
2457
|
+
demandOption: true,
|
|
2458
|
+
choices: ["alert", "needs-discussion", "waiting", "blocked"],
|
|
2459
|
+
describe: "waiting = the other party in this conversation owes a reply; blocked = stuck on something outside it",
|
|
2460
|
+
})
|
|
2461
|
+
.option("remove", { type: "boolean", default: false, describe: "Remove the flag instead of adding it" })
|
|
2462
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" }),
|
|
2463
|
+
async (argv) => {
|
|
2464
|
+
const args: { target: string; flag: FlagName; remove?: boolean; channelId?: string; cookie?: string } = {
|
|
2465
|
+
target: argv.target!,
|
|
2466
|
+
flag: argv.flag as FlagName,
|
|
2467
|
+
};
|
|
2468
|
+
if (argv.remove) args.remove = true;
|
|
2469
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
2470
|
+
const cookie = ck(argv as W);
|
|
2471
|
+
if (cookie) args.cookie = cookie;
|
|
2472
|
+
try {
|
|
2473
|
+
await cmdTodoFlag(tok(argv as W), args);
|
|
2474
|
+
} catch (e: unknown) {
|
|
2475
|
+
console.error(friendlySlackError(e));
|
|
2476
|
+
process.exit(1);
|
|
2477
|
+
}
|
|
2478
|
+
},
|
|
2479
|
+
)
|
|
2480
|
+
.command(
|
|
2481
|
+
"doctor",
|
|
2482
|
+
"Find messages carrying two or more progress reactions (--fix repairs them)",
|
|
2483
|
+
(y2) => y2
|
|
2484
|
+
.option("in", { type: "string", demandOption: true, describe: "Channel to scan (#chan)" })
|
|
2485
|
+
.option("fix", { type: "boolean", default: false, describe: "Repair findings (keeps the highest-priority state)" })
|
|
2486
|
+
.option("limit", { type: "number", default: DOCTOR_DEFAULT_LIMIT, describe: "Max messages to scan" })
|
|
2487
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" }),
|
|
2488
|
+
async (argv) => {
|
|
2489
|
+
const args: { channel: string; fix?: boolean; limit: number; channelId?: string; cookie?: string } = {
|
|
2490
|
+
channel: argv.in!,
|
|
2491
|
+
limit: argv.limit,
|
|
2492
|
+
};
|
|
2493
|
+
if (argv.fix) args.fix = true;
|
|
2494
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
2495
|
+
const cookie = ck(argv as W);
|
|
2496
|
+
if (cookie) args.cookie = cookie;
|
|
2497
|
+
await cmdTodoDoctor(tok(argv as W), args);
|
|
2498
|
+
},
|
|
2499
|
+
)
|
|
2500
|
+
.demandCommand(1, "Specify a todo subcommand (ls, set, flag, doctor)"),
|
|
2501
|
+
() => {},
|
|
2502
|
+
)
|
|
2503
|
+
.command(
|
|
2504
|
+
"mytodo",
|
|
2505
|
+
"Tasks carrying YOUR reactions (hasmy:) — the my-only half of `todo`",
|
|
2506
|
+
(y) => y
|
|
2507
|
+
.option("cache", { type: "boolean", default: true, describe: "Use ~/.config/slack-cli/cache.json for channel/user lookups (--no-cache to bypass)" })
|
|
2508
|
+
.middleware((argv) => { if (argv.cache === false) setCacheEnabled(false); })
|
|
2509
|
+
.command(
|
|
2510
|
+
["ls", "list"],
|
|
2511
|
+
"List tasks you have reacted to (hasmy: — read-only, one search per run)",
|
|
2512
|
+
(y2) => y2.options(TODO_LS_OPTIONS),
|
|
2513
|
+
async (argv) => { await runTodoLs(argv as W & { state?: string }, true); },
|
|
2514
|
+
)
|
|
2515
|
+
// Only `ls` is split. set/flag/doctor act on one identified message, so
|
|
2516
|
+
// "mine vs everyone's" does not apply to them — they stay under `todo`.
|
|
2517
|
+
.demandCommand(1, "Specify a mytodo subcommand (ls)"),
|
|
2518
|
+
() => {},
|
|
2519
|
+
)
|
|
2020
2520
|
.command(
|
|
2021
2521
|
"upload <target> <file..>",
|
|
2022
2522
|
"Upload one or more files to a channel or DM",
|
|
@@ -2069,6 +2569,24 @@ async function main(): Promise<void> {
|
|
|
2069
2569
|
.showHelpOnFail(true),
|
|
2070
2570
|
() => {},
|
|
2071
2571
|
)
|
|
2572
|
+
.command(
|
|
2573
|
+
"lists <ref>",
|
|
2574
|
+
"Read a Slack List's rows (by list URL or F-id) — read-only",
|
|
2575
|
+
(y) => y
|
|
2576
|
+
.positional("ref", { type: "string", demandOption: true, describe: "List URL (…/lists/<TEAM>/<F-id>) or bare F-id" })
|
|
2577
|
+
.option("limit", { alias: "l", type: "number", default: 100, describe: "Max rows to show" })
|
|
2578
|
+
.option("format", { type: "string", choices: ["text", "jsonl"], default: "text" })
|
|
2579
|
+
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl (raw records)" }),
|
|
2580
|
+
async (argv) => {
|
|
2581
|
+
const format = argv.json ? "jsonl" : (argv.format as string);
|
|
2582
|
+
try {
|
|
2583
|
+
await cmdLists(tok(argv as W), ck(argv as W), argv.ref!, { limit: argv.limit as number, format });
|
|
2584
|
+
} catch (e: unknown) {
|
|
2585
|
+
console.error(friendlySlackError(e));
|
|
2586
|
+
process.exit(1);
|
|
2587
|
+
}
|
|
2588
|
+
},
|
|
2589
|
+
)
|
|
2072
2590
|
.command(
|
|
2073
2591
|
"download <ref> [dest]",
|
|
2074
2592
|
"Download a file attachment (by file ID or file permalink) to disk",
|
|
@@ -2140,8 +2658,11 @@ async function main(): Promise<void> {
|
|
|
2140
2658
|
const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === argv.id);
|
|
2141
2659
|
if (!d) { console.error(`Draft not found: ${argv.id}`); process.exit(1); }
|
|
2142
2660
|
const prevText = draftText(d);
|
|
2143
|
-
const
|
|
2661
|
+
const self = await selfIdentity(token, cookie);
|
|
2662
|
+
const code = safetyCode(prevText, text, self?.userId ?? "");
|
|
2144
2663
|
if (argv.code !== code) requireCode(argv.code, code, [
|
|
2664
|
+
`--- Editing draft as -------------------------`,
|
|
2665
|
+
fromLine(self),
|
|
2145
2666
|
`--- Current draft ----------------------------`,
|
|
2146
2667
|
...prevText.split("\n").map((l) => ` ${l}`),
|
|
2147
2668
|
`--- Replacing with ---------------------------`,
|
|
@@ -2164,9 +2685,11 @@ async function main(): Promise<void> {
|
|
|
2164
2685
|
const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === argv.id);
|
|
2165
2686
|
if (!d) { console.error(`Draft not found: ${argv.id}`); process.exit(1); }
|
|
2166
2687
|
const prevText = draftText(d);
|
|
2167
|
-
const
|
|
2688
|
+
const self = await selfIdentity(token, cookie);
|
|
2689
|
+
const code = safetyCode(argv.id!, prevText, self?.userId ?? "");
|
|
2168
2690
|
if (argv.code !== code) requireCode(argv.code, code, [
|
|
2169
2691
|
`─-- Deleting draft ───────────────────────────`,
|
|
2692
|
+
fromLine(self),
|
|
2170
2693
|
` id: ${argv.id}`,
|
|
2171
2694
|
...prevText.split("\n").map((l) => ` ${l}`),
|
|
2172
2695
|
`--------------------------------────────────`,
|