slack-term 1.23.1 → 1.25.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 +108 -7
- package/SKILL.md +76 -5
- package/dist/cli.js +92 -89
- package/package.json +3 -1
- package/ts/cache.ts +120 -0
- package/ts/cli.ts +586 -34
- package/ts/format.ts +13 -10
- package/ts/slack.ts +63 -2
- package/ts/tail.ts +11 -7
- package/ts/todo.ts +483 -0
package/ts/cli.ts
CHANGED
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
reactionAdd,
|
|
29
29
|
reactionRemove,
|
|
30
30
|
filesInfo,
|
|
31
|
+
filesList,
|
|
32
|
+
listRecords,
|
|
31
33
|
history,
|
|
32
34
|
listConversations,
|
|
33
35
|
listDrafts,
|
|
@@ -52,6 +54,22 @@ import {
|
|
|
52
54
|
getPath,
|
|
53
55
|
type Json,
|
|
54
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";
|
|
55
73
|
import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
|
|
56
74
|
|
|
57
75
|
function loadDotenv(path: string): void {
|
|
@@ -161,6 +179,9 @@ async function formatMsgLine(
|
|
|
161
179
|
m: Record<string, Json>,
|
|
162
180
|
cache: Map<string, string>,
|
|
163
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,
|
|
164
185
|
): Promise<string> {
|
|
165
186
|
const rawTs = typeof m.ts === "string" ? m.ts : `${tsNum(m)}.000000`;
|
|
166
187
|
const stamp = slackTsToIso(rawTs);
|
|
@@ -168,8 +189,11 @@ async function formatMsgLine(
|
|
|
168
189
|
if (typeof m.user === "string") {
|
|
169
190
|
const uid = m.user;
|
|
170
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.
|
|
171
195
|
if (!cache.has(handleKey)) {
|
|
172
|
-
const
|
|
196
|
+
const h = await userHandleCached(token, uid, async (t, u, c) => (await userInfoPair(t, u, c))[1], cookie);
|
|
173
197
|
cache.set(handleKey, h);
|
|
174
198
|
}
|
|
175
199
|
handle = cache.get(handleKey) ?? uid;
|
|
@@ -177,7 +201,7 @@ async function formatMsgLine(
|
|
|
177
201
|
handle = m.username;
|
|
178
202
|
}
|
|
179
203
|
const raw = typeof m.text === "string" ? m.text : "";
|
|
180
|
-
const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache));
|
|
204
|
+
const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache, cookie));
|
|
181
205
|
const lines = resolved.split("\n");
|
|
182
206
|
const body = lines[0] + (lines.length > 1 ? "\n" + lines.slice(1).map(l => ` ${l}`).join("\n") : "");
|
|
183
207
|
const who = chLabel ? `${chLabel} @${handle}` : `@${handle}`;
|
|
@@ -199,8 +223,12 @@ async function formatMsgLine(
|
|
|
199
223
|
return `\n 📎 ${name}${sz}${id}`;
|
|
200
224
|
})
|
|
201
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"}]` : "";
|
|
202
230
|
const tail = (reactions ? `\n ${reactions}` : "") + attachTail;
|
|
203
|
-
return `${stamp} ${who}: ${body}${tail}`;
|
|
231
|
+
return `${stamp} ${who}: ${body}${threadMark}${tail}`;
|
|
204
232
|
}
|
|
205
233
|
|
|
206
234
|
// Human-readable byte size, shared by upload/download/attachment rendering.
|
|
@@ -235,39 +263,73 @@ function slimMsg(m: Record<string, Json>): Record<string, Json> {
|
|
|
235
263
|
bot_id: m.bot_id ?? null,
|
|
236
264
|
thread_ts: m.thread_ts ?? null,
|
|
237
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 } : {}),
|
|
238
272
|
// Present only when the message carries attachments, so scripts can reliably
|
|
239
273
|
// detect files without the key adding noise to every plain-text line.
|
|
240
274
|
...(files.length > 0 ? { files } : {}),
|
|
241
275
|
};
|
|
242
276
|
}
|
|
243
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
|
+
|
|
244
302
|
// --- msgs <target> — channel/DM history with timestamps ---
|
|
245
|
-
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> {
|
|
246
304
|
const parsed = parseSlackPermalink(target);
|
|
247
|
-
const channelId = await resolveChannel(token, target);
|
|
305
|
+
const channelId = await resolveChannel(token, target, cookie);
|
|
248
306
|
const cache = new Map<string, string>();
|
|
249
307
|
const fetchMsgs = async (): Promise<Record<string, Json>[]> => {
|
|
250
308
|
if (parsed?.threadTs) {
|
|
251
|
-
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>;
|
|
252
310
|
return asArray(resp.messages).map(asRecord);
|
|
253
311
|
}
|
|
254
|
-
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>;
|
|
255
313
|
return asArray(hist.messages).map(asRecord).reverse();
|
|
256
314
|
};
|
|
257
|
-
|
|
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
|
+
}
|
|
258
320
|
if (format === "jsonl") {
|
|
259
321
|
for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
|
|
260
322
|
return;
|
|
261
323
|
}
|
|
262
324
|
for (const m of msgs) {
|
|
263
|
-
console.log(await formatMsgLine(token, m, cache));
|
|
325
|
+
console.log(await formatMsgLine(token, m, cache, undefined, cookie));
|
|
264
326
|
}
|
|
265
327
|
}
|
|
266
328
|
|
|
267
329
|
// --- thread ---
|
|
268
|
-
async function cmdThread(token: string, target: string, ts: string, limit: number, format: "text" | "jsonl" = "text"): Promise<void> {
|
|
269
|
-
const channelId = await resolveChannel(token, target);
|
|
270
|
-
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>;
|
|
271
333
|
const msgs = asArray(resp.messages).map(asRecord);
|
|
272
334
|
if (format === "jsonl") {
|
|
273
335
|
for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
|
|
@@ -275,7 +337,7 @@ async function cmdThread(token: string, target: string, ts: string, limit: numbe
|
|
|
275
337
|
}
|
|
276
338
|
const cache = new Map<string, string>();
|
|
277
339
|
for (const m of msgs) {
|
|
278
|
-
console.log(await formatMsgLine(token, m, cache));
|
|
340
|
+
console.log(await formatMsgLine(token, m, cache, undefined, cookie));
|
|
279
341
|
}
|
|
280
342
|
}
|
|
281
343
|
|
|
@@ -456,7 +518,104 @@ async function cmdSearch(token: string, query: string, count: number, json: bool
|
|
|
456
518
|
} else {
|
|
457
519
|
chLabel = `#${rawName}`;
|
|
458
520
|
}
|
|
459
|
-
|
|
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; shared?: boolean; count: number; json: boolean; cookie?: string },
|
|
549
|
+
): Promise<void> {
|
|
550
|
+
const cfg = loadTodoConfig();
|
|
551
|
+
const queryOpts: LsQueryOpts = {};
|
|
552
|
+
if (opts.shared) queryOpts.shared = true;
|
|
553
|
+
if (opts.in) queryOpts.in = opts.in;
|
|
554
|
+
if (opts.from) queryOpts.from = opts.from;
|
|
555
|
+
const query = buildTodoQuery(opts.state, cfg, queryOpts);
|
|
556
|
+
if (!opts.json) console.error(`(query: ${query})`);
|
|
557
|
+
// One search per invocation — search.messages is Tier 2 (~20 req/min), so the
|
|
558
|
+
// priority rule is encoded as negations in the query, not as extra searches.
|
|
559
|
+
await withRateLimitRetry("search.messages", () => cmdSearch(token, query, opts.count, opts.json, opts.cookie));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function cmdTodoSet(
|
|
563
|
+
token: string,
|
|
564
|
+
args: { target: string; state: ProgressState; channelId?: string; cookie?: string },
|
|
565
|
+
): Promise<void> {
|
|
566
|
+
const cfg = loadTodoConfig();
|
|
567
|
+
const { channelId, ts } = await todoTarget(token, args.target, args.channelId, args.cookie);
|
|
568
|
+
const res = await todoSet(token, channelId, ts, args.state, cfg, args.cookie);
|
|
569
|
+
if (res.noop) {
|
|
570
|
+
console.log(`= Already ${args.state} (ts: ${ts})`);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const removedNote = res.removed.length ? `, removed ${res.removed.map((e) => `:${e}:`).join(" ")}` : "";
|
|
574
|
+
console.log(`✓ ${args.state} :${cfg.progress[args.state]}: (ts: ${ts})${removedNote}`);
|
|
575
|
+
if (res.leftover.length) {
|
|
576
|
+
console.error(
|
|
577
|
+
`⚠ ${res.leftover.map((e) => `:${e}:`).join(" ")} could not be removed — this message now carries more than one ` +
|
|
578
|
+
`progress reaction. Readers collapse by priority so nothing is lost; repair with: slack todo doctor --in <#chan> --fix`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function cmdTodoFlag(
|
|
584
|
+
token: string,
|
|
585
|
+
args: { target: string; flag: FlagName; remove?: boolean; channelId?: string; cookie?: string },
|
|
586
|
+
): Promise<void> {
|
|
587
|
+
const cfg = loadTodoConfig();
|
|
588
|
+
const { channelId, ts } = await todoTarget(token, args.target, args.channelId, args.cookie);
|
|
589
|
+
const emoji = await todoFlag(token, channelId, ts, args.flag, cfg, args.remove === true, args.cookie);
|
|
590
|
+
console.log(`✓ ${args.remove ? "Cleared" : "Flagged"} ${args.flag} :${emoji}: (ts: ${ts})`);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function cmdTodoDoctor(
|
|
594
|
+
token: string,
|
|
595
|
+
args: { channel: string; fix?: boolean; limit: number; channelId?: string; cookie?: string },
|
|
596
|
+
): Promise<void> {
|
|
597
|
+
const cfg = loadTodoConfig();
|
|
598
|
+
const channelId = args.channelId ?? (await resolveChannelCached(token, args.channel, resolveChannel, args.cookie));
|
|
599
|
+
const doctorOpts: { fix?: boolean; limit: number; cookie?: string } = { limit: args.limit };
|
|
600
|
+
if (args.fix) doctorOpts.fix = true;
|
|
601
|
+
if (args.cookie) doctorOpts.cookie = args.cookie;
|
|
602
|
+
const report = await todoDoctor(token, channelId, cfg, doctorOpts);
|
|
603
|
+
if (report.findings.length === 0) {
|
|
604
|
+
console.log(`✓ No multi-state tasks in ${report.scanned} message(s).`);
|
|
605
|
+
} else {
|
|
606
|
+
for (const f of report.findings) {
|
|
607
|
+
const status = f.fixed === true ? "fixed" : f.fixed === false ? `FAILED: ${f.error ?? "?"}` : "needs --fix";
|
|
608
|
+
console.log(
|
|
609
|
+
`${f.ts} ${f.emoji.map((e) => `:${e}:`).join(" ")} → keep :${f.keep}: [${status}] ` +
|
|
610
|
+
`${stripTerminalControls(f.text).slice(0, 60)}`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
console.log(`${report.findings.length} multi-state task(s) in ${report.scanned} message(s).`);
|
|
614
|
+
}
|
|
615
|
+
if (report.truncated) {
|
|
616
|
+
console.error(
|
|
617
|
+
`⚠ Scan limit reached (${args.limit} messages) — older history was NOT checked. Raise it with --limit N.`,
|
|
618
|
+
);
|
|
460
619
|
}
|
|
461
620
|
}
|
|
462
621
|
|
|
@@ -746,7 +905,7 @@ function parseTargetThread(s: string): { ref: string; threadTs?: string } {
|
|
|
746
905
|
return { ref: s };
|
|
747
906
|
}
|
|
748
907
|
|
|
749
|
-
/** Human-readable destination label for confirm gates, e.g. "#
|
|
908
|
+
/** Human-readable destination label for confirm gates, e.g. "#general (C0123456789)".
|
|
750
909
|
* When ref is already a #channel/@user label, keep it; otherwise resolve the channel
|
|
751
910
|
* name via conversations.info (fail-soft: a raw ID is still unambiguous). */
|
|
752
911
|
async function destLabel(token: string, channelId: string, ref: string, cookie?: string): Promise<string> {
|
|
@@ -768,6 +927,55 @@ async function destLabel(token: string, channelId: string, ref: string, cookie?:
|
|
|
768
927
|
return name === channelId ? channelId : `${name} (${channelId})`;
|
|
769
928
|
}
|
|
770
929
|
|
|
930
|
+
/** Identity behind the token a write command will act with. */
|
|
931
|
+
type Self = { userId: string; user: string; botId: string; team: string };
|
|
932
|
+
|
|
933
|
+
/** `auth.test` for the token that will perform a write. Every confirm gate names
|
|
934
|
+
* the acting identity and binds it into the safety hash. Resolves to null
|
|
935
|
+
* (never throws) when the lookup fails, so a gate still renders and the write
|
|
936
|
+
* still goes through. */
|
|
937
|
+
async function selfIdentity(token: string, cookie?: string): Promise<Self | null> {
|
|
938
|
+
try {
|
|
939
|
+
return await authScopes(token, cookie);
|
|
940
|
+
} catch {
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Memoized `selfIdentity`, for commands that consult it more than once (`send`
|
|
946
|
+
* needs it for the unreplied guard as well as the gate) — a command must never
|
|
947
|
+
* pay for more than one lookup. */
|
|
948
|
+
function selfLookup(token: string, cookie?: string): () => Promise<Self | null> {
|
|
949
|
+
let cache: Self | null | undefined;
|
|
950
|
+
return async () => {
|
|
951
|
+
if (cache === undefined) cache = await selfIdentity(token, cookie);
|
|
952
|
+
return cache;
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Name the identity a write will be performed AS, for confirm gates:
|
|
957
|
+
* "@snomiao (U0123ABC) — Acme". Slack shows only the author on the resulting
|
|
958
|
+
* message, so a wrong profile / stale SLACK_TOKEN / unintended --as-bot is
|
|
959
|
+
* otherwise invisible until after it lands — handle, user id and workspace pin
|
|
960
|
+
* all three down. `self` is null when the auth.test lookup failed (fail-soft:
|
|
961
|
+
* the gate still renders, it just can't name the actor). */
|
|
962
|
+
function senderLabel(self: Self | null, asBot?: boolean): string {
|
|
963
|
+
const suffix = asBot ? " [as bot]" : "";
|
|
964
|
+
if (!self) return `(unknown — auth.test failed)${suffix}`;
|
|
965
|
+
// Display text is workspace-controlled; strip control chars like the other gates.
|
|
966
|
+
const name = stripTerminalControls(self.user || self.userId || "?");
|
|
967
|
+
const id = self.userId ? ` (${self.userId})` : "";
|
|
968
|
+
const team = self.team ? ` — ${stripTerminalControls(self.team)}` : "";
|
|
969
|
+
return `@${name}${id}${team}${suffix}`;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** The `From:` line every write gate opens its action block with. `pad` is the
|
|
973
|
+
* label field width, for gates whose other labels sit on a wider column
|
|
974
|
+
* (`To:`/`At:`/`Message:` on schedule, `Title:`/`Size:` on upload). */
|
|
975
|
+
function fromLine(self: Self | null, opts: { asBot?: boolean | undefined; pad?: number } = {}): string {
|
|
976
|
+
return ` ${"From:".padEnd(opts.pad ?? 6)}${senderLabel(self, opts.asBot)}`;
|
|
977
|
+
}
|
|
978
|
+
|
|
771
979
|
/** One-line description of a single thread message for confirm gates:
|
|
772
980
|
* "2026-06-11 08:05 @handle: head…". `headLen` bounds the text preview. */
|
|
773
981
|
async function threadMsgLine(token: string, m: Record<string, Json>, headLen = 60, cookie?: string): Promise<string> {
|
|
@@ -849,6 +1057,7 @@ interface EditArgs {
|
|
|
849
1057
|
cookie?: string;
|
|
850
1058
|
}
|
|
851
1059
|
async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
1060
|
+
const getSelf = selfLookup(token, args.cookie);
|
|
852
1061
|
const { ref, ts } = splitRefTs(args.target);
|
|
853
1062
|
if (!ts) {
|
|
854
1063
|
console.error("Error: target must embed a message ts (e.g. #chan:2026-05-11T06:01:04.000100 or a Slack permalink URL)");
|
|
@@ -874,9 +1083,16 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
874
1083
|
? await encodeMentions(token, args.newText, channelId, args.cookie ? { cookie: args.cookie } : {})
|
|
875
1084
|
: args.newText;
|
|
876
1085
|
|
|
877
|
-
|
|
1086
|
+
// Identity is part of the hash for the same reason it is on `send`: a code
|
|
1087
|
+
// minted while previewing as one identity must not confirm the write as
|
|
1088
|
+
// another (Slack only lets you edit your own messages, so a silent profile
|
|
1089
|
+
// switch between preview and confirm otherwise turns into a bare API error).
|
|
1090
|
+
const self = await getSelf();
|
|
1091
|
+
const code = safetyCode(originalText, newText, self?.userId ?? "");
|
|
878
1092
|
if (args.code !== code) {
|
|
879
1093
|
requireCode(args.code, code, [
|
|
1094
|
+
`--- Editing as -------------------------------`,
|
|
1095
|
+
fromLine(self),
|
|
880
1096
|
`--- Original message -------------------------`,
|
|
881
1097
|
...originalText.split("\n").map((l) => ` ${l}`),
|
|
882
1098
|
`--- Replacing with ---------------------------`,
|
|
@@ -897,6 +1113,7 @@ interface DeleteArgs {
|
|
|
897
1113
|
cookie?: string;
|
|
898
1114
|
}
|
|
899
1115
|
async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
|
|
1116
|
+
const getSelf = selfLookup(token, args.cookie);
|
|
900
1117
|
const { ref, ts } = splitRefTs(args.target);
|
|
901
1118
|
if (!ts) {
|
|
902
1119
|
console.error("Error: target must embed a message ts (e.g. #chan:2026-05-11T06:01:04.000100 or a Slack permalink URL)");
|
|
@@ -917,11 +1134,15 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
|
|
|
917
1134
|
}
|
|
918
1135
|
const originalText = typeof original.text === "string" ? original.text : "";
|
|
919
1136
|
|
|
920
|
-
|
|
1137
|
+
// Identity in the hash: same rule as send/edit — a code minted as one
|
|
1138
|
+
// identity can't confirm the delete as another.
|
|
1139
|
+
const self = await getSelf();
|
|
1140
|
+
const code = safetyCode(channelId, ts, originalText, self?.userId ?? "");
|
|
921
1141
|
if (args.code !== code) {
|
|
922
1142
|
const dest = await destLabel(token, channelId, ref, args.cookie);
|
|
923
1143
|
requireCode(args.code, code, [
|
|
924
1144
|
`--- Deleting message -------------------------`,
|
|
1145
|
+
fromLine(self),
|
|
925
1146
|
` → ${dest} at ${slackTsToIso(ts)}`,
|
|
926
1147
|
...originalText.split("\n").map((l) => ` ${l}`),
|
|
927
1148
|
`---------------------------------------------`,
|
|
@@ -1009,6 +1230,10 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
1009
1230
|
const { ref, threadTs } = parseTargetThread(args.target);
|
|
1010
1231
|
const cookie = args.cookie;
|
|
1011
1232
|
|
|
1233
|
+
// Identity of the token that will actually post — the bot token under
|
|
1234
|
+
// --as-bot, the user token otherwise.
|
|
1235
|
+
const getSelf = selfLookup(token, cookie);
|
|
1236
|
+
|
|
1012
1237
|
// Guard the self-DM footgun before doing anything else, unless sending as the
|
|
1013
1238
|
// bot (which delivers a notifiable DM from a different identity).
|
|
1014
1239
|
if (!args.asBot && !args.channelId && !args.userId && await isSelfDm(token, ref, cookie)) {
|
|
@@ -1140,10 +1365,12 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
1140
1365
|
// shape `send --as-bot` produces. Opt out with SLACK_UNREPLIED_WARN=0.
|
|
1141
1366
|
if (process.env.SLACK_UNREPLIED_WARN !== "0" && lastMsgTs && (lastMsgUserId || lastMsgBotId)) {
|
|
1142
1367
|
try {
|
|
1143
|
-
const self = await
|
|
1144
|
-
|
|
1368
|
+
const self = await getSelf();
|
|
1369
|
+
// `self` is null when the identity lookup failed — can't tell whose
|
|
1370
|
+
// message it is, so stay quiet rather than guess.
|
|
1371
|
+
const isSelfAuthor = !!self && (
|
|
1145
1372
|
(!!lastMsgUserId && !!self.userId && self.userId === lastMsgUserId) ||
|
|
1146
|
-
(!!lastMsgBotId && !!self.botId && self.botId === lastMsgBotId);
|
|
1373
|
+
(!!lastMsgBotId && !!self.botId && self.botId === lastMsgBotId));
|
|
1147
1374
|
if (isSelfAuthor) {
|
|
1148
1375
|
// Prefer a real permalink (exact, copy-pasteable). If that lookup fails,
|
|
1149
1376
|
// fall back to a form guaranteed parseable by `slack edit` regardless of
|
|
@@ -1186,12 +1413,17 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
1186
1413
|
}
|
|
1187
1414
|
}
|
|
1188
1415
|
|
|
1189
|
-
// Hash covers the destination too — a code minted
|
|
1190
|
-
// cannot confirm a send to another
|
|
1191
|
-
|
|
1416
|
+
// Hash covers the destination and the sending identity too — a code minted
|
|
1417
|
+
// for one channel/thread cannot confirm a send to another, and one minted
|
|
1418
|
+
// while previewing as identity A cannot confirm a send as identity B (a
|
|
1419
|
+
// profile / SLACK_TOKEN / --as-bot switch between preview and confirm
|
|
1420
|
+
// re-previews instead). Empty on lookup failure, same fail-soft as lastText.
|
|
1421
|
+
const self = await getSelf();
|
|
1422
|
+
const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "");
|
|
1192
1423
|
|
|
1193
1424
|
if (args.code !== code) {
|
|
1194
1425
|
const dest = await destLabel(token, channelId, ref, cookie);
|
|
1426
|
+
const from = fromLine(self, { asBot: args.asBot });
|
|
1195
1427
|
if (threadTs) {
|
|
1196
1428
|
const parentLine = await threadParentLine(token, threadTs, threadMsgs, cookie);
|
|
1197
1429
|
// Preview the tail of the thread (up to 3 most recent messages) so the
|
|
@@ -1205,6 +1437,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
1205
1437
|
...recentLines,
|
|
1206
1438
|
...mentionLines,
|
|
1207
1439
|
`--- Sending ----------------------------------`,
|
|
1440
|
+
from,
|
|
1208
1441
|
` → ${dest} thread of ${parentLine} — THREAD REPLY`,
|
|
1209
1442
|
` Message: ${message}`,
|
|
1210
1443
|
`--------------------------------────────────`,
|
|
@@ -1215,6 +1448,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
1215
1448
|
` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
|
|
1216
1449
|
...mentionLines,
|
|
1217
1450
|
`--- Sending ----------------------------------`,
|
|
1451
|
+
from,
|
|
1218
1452
|
` → ${dest} — NEW top-level message`,
|
|
1219
1453
|
` Message: ${message}`,
|
|
1220
1454
|
`--------------------------------────────────`,
|
|
@@ -1271,6 +1505,26 @@ function parsePostAt(at: string): number {
|
|
|
1271
1505
|
return Math.floor(d.getTime() / 1000);
|
|
1272
1506
|
}
|
|
1273
1507
|
|
|
1508
|
+
/** Render a scheduled post time as local AND UTC: "2026-08-10 18:00:00 GMT+9
|
|
1509
|
+
* (local) / 2026-08-10T09:00:00.000Z (Unix: 1786352400)". A schedule gate is
|
|
1510
|
+
* exactly where an off-by-a-timezone mistake gets locked in — `--at` accepts
|
|
1511
|
+
* both bare local times and `Z`-suffixed UTC, so showing only one form leaves
|
|
1512
|
+
* the reader doing the conversion in their head. Local is first: it's the form
|
|
1513
|
+
* the sender thinks in. */
|
|
1514
|
+
function fmtPostAt(postAt: number): string {
|
|
1515
|
+
const d = new Date(postAt * 1000);
|
|
1516
|
+
const local = new Intl.DateTimeFormat("sv-SE", {
|
|
1517
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
1518
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
1519
|
+
hour12: false, timeZoneName: "short",
|
|
1520
|
+
})
|
|
1521
|
+
.format(d)
|
|
1522
|
+
// sv-SE renders a west-of-UTC offset with U+2212 MINUS SIGN ("GMT−4"), which
|
|
1523
|
+
// survives copy/paste into a shell or a grep as a non-ASCII byte. Plain hyphen.
|
|
1524
|
+
.replace(/−/g, "-");
|
|
1525
|
+
return `${local} (local) / ${d.toISOString()} (Unix: ${postAt})`;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1274
1528
|
interface ScheduleSendArgs {
|
|
1275
1529
|
target: string;
|
|
1276
1530
|
message: string;
|
|
@@ -1281,6 +1535,7 @@ interface ScheduleSendArgs {
|
|
|
1281
1535
|
cookie?: string;
|
|
1282
1536
|
}
|
|
1283
1537
|
async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
|
|
1538
|
+
const getSelf = selfLookup(token, args.cookie);
|
|
1284
1539
|
const { ref, threadTs } = parseTargetThread(args.target);
|
|
1285
1540
|
|
|
1286
1541
|
let channelId: string;
|
|
@@ -1290,13 +1545,18 @@ async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<v
|
|
|
1290
1545
|
|
|
1291
1546
|
const postAt = parsePostAt(args.at);
|
|
1292
1547
|
const postAtDate = new Date(postAt * 1000).toISOString();
|
|
1293
|
-
|
|
1548
|
+
// Identity in the hash, same as the other gated writes. It matters more here
|
|
1549
|
+
// than anywhere else: the message goes out later, unattended, under whichever
|
|
1550
|
+
// identity was current at schedule time.
|
|
1551
|
+
const self = await getSelf();
|
|
1552
|
+
const code = safetyCode(channelId, args.message, String(postAt), self?.userId ?? "");
|
|
1294
1553
|
|
|
1295
1554
|
if (args.code !== code) {
|
|
1296
1555
|
requireCode(args.code, code, [
|
|
1297
1556
|
`--- Scheduling message -----------------------`,
|
|
1557
|
+
fromLine(self, { pad: 9 }),
|
|
1298
1558
|
` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`,
|
|
1299
|
-
` At: ${
|
|
1559
|
+
` At: ${fmtPostAt(postAt)}`,
|
|
1300
1560
|
` Message: ${args.message}`,
|
|
1301
1561
|
`---------------------------------------------`,
|
|
1302
1562
|
]);
|
|
@@ -1335,10 +1595,12 @@ async function cmdScheduleRm(token: string, args: ScheduleRmArgs): Promise<void>
|
|
|
1335
1595
|
if (args.channelId) channelId = args.channelId;
|
|
1336
1596
|
else channelId = await resolveChannel(token, args.target, args.cookie);
|
|
1337
1597
|
|
|
1338
|
-
const
|
|
1598
|
+
const self = await selfIdentity(token, args.cookie);
|
|
1599
|
+
const code = safetyCode(channelId, args.id, self?.userId ?? "");
|
|
1339
1600
|
if (args.code !== code) {
|
|
1340
1601
|
requireCode(args.code, code, [
|
|
1341
1602
|
`--- Deleting scheduled message ---------------`,
|
|
1603
|
+
fromLine(self, { pad: 9 }),
|
|
1342
1604
|
` Channel: ${args.target}`,
|
|
1343
1605
|
` ID: ${args.id}`,
|
|
1344
1606
|
`---------------------------------------------`,
|
|
@@ -1360,6 +1622,7 @@ interface UploadArgs {
|
|
|
1360
1622
|
cookie?: string;
|
|
1361
1623
|
}
|
|
1362
1624
|
async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
1625
|
+
const getSelf = selfLookup(token, args.cookie);
|
|
1363
1626
|
const { statSync, existsSync } = await import("node:fs");
|
|
1364
1627
|
const { basename } = await import("node:path");
|
|
1365
1628
|
|
|
@@ -1384,19 +1647,23 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
1384
1647
|
return { fp, filename, title, sizeFmt: fmtSize(statSync(fp).size) };
|
|
1385
1648
|
});
|
|
1386
1649
|
|
|
1387
|
-
// Safety code covers the full batch
|
|
1388
|
-
|
|
1650
|
+
// Safety code covers the full batch, plus the uploading identity (same rule
|
|
1651
|
+
// as send/edit/delete — the file lands under whoever the token is).
|
|
1652
|
+
const self = await getSelf();
|
|
1653
|
+
const code = safetyCode(channelId, ...files.flatMap((f) => [f.fp, f.title]), self?.userId ?? "");
|
|
1389
1654
|
if (args.code !== code) {
|
|
1390
1655
|
const destLine = ` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`;
|
|
1391
1656
|
const lines = isBatch
|
|
1392
1657
|
? [
|
|
1393
1658
|
`--- Uploading ${files.length} files ------------------------`,
|
|
1659
|
+
fromLine(self, { pad: 7 }),
|
|
1394
1660
|
destLine,
|
|
1395
1661
|
...files.map((f) => ` ${f.filename} (${f.sizeFmt})`),
|
|
1396
1662
|
`--------------------------------────────────`,
|
|
1397
1663
|
]
|
|
1398
1664
|
: [
|
|
1399
1665
|
`--- Uploading file ---------------------------`,
|
|
1666
|
+
fromLine(self, { pad: 7 }),
|
|
1400
1667
|
destLine,
|
|
1401
1668
|
` File: ${files[0]!.fp}`,
|
|
1402
1669
|
` Title: ${files[0]!.title}`,
|
|
@@ -1418,6 +1685,122 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
1418
1685
|
}
|
|
1419
1686
|
}
|
|
1420
1687
|
|
|
1688
|
+
// --- files ls — list files visible to the token (files.list, read-only) ---
|
|
1689
|
+
// Walks files.list pages until it has `limit` rows or runs out. Canvases and
|
|
1690
|
+
// Slack Lists from the web "unified files" view mostly don't appear here (the
|
|
1691
|
+
// token can't reach those internal APIs) — this lists real file uploads.
|
|
1692
|
+
async function cmdFilesList(
|
|
1693
|
+
token: string,
|
|
1694
|
+
cookie: string | undefined,
|
|
1695
|
+
opts: { limit: number; types?: string; channel?: string; user?: string; format: string },
|
|
1696
|
+
): Promise<void> {
|
|
1697
|
+
const channelId = opts.channel ? await resolveChannel(token, opts.channel, cookie) : undefined;
|
|
1698
|
+
const files: Record<string, Json>[] = [];
|
|
1699
|
+
let page = 1;
|
|
1700
|
+
let pages = 1;
|
|
1701
|
+
do {
|
|
1702
|
+
const listOpts: { page: number; count: number; types?: string; channel?: string; user?: string } = { page, count: 200 };
|
|
1703
|
+
if (opts.types) listOpts.types = opts.types;
|
|
1704
|
+
if (channelId) listOpts.channel = channelId;
|
|
1705
|
+
if (opts.user) listOpts.user = opts.user;
|
|
1706
|
+
const resp = asRecord((await filesList(token, listOpts, cookie)) as Json);
|
|
1707
|
+
for (const f of asArray(resp.files)) files.push(asRecord(f));
|
|
1708
|
+
pages = Number(asRecord(resp.paging).pages ?? 1);
|
|
1709
|
+
page++;
|
|
1710
|
+
} while (files.length < opts.limit && page <= pages);
|
|
1711
|
+
const shown = files.slice(0, opts.limit);
|
|
1712
|
+
|
|
1713
|
+
if (opts.format === "jsonl") {
|
|
1714
|
+
for (const f of shown) {
|
|
1715
|
+
console.log(JSON.stringify({
|
|
1716
|
+
id: f.id ?? null,
|
|
1717
|
+
name: f.name ?? f.title ?? null,
|
|
1718
|
+
filetype: f.filetype ?? null,
|
|
1719
|
+
size: f.size ?? null,
|
|
1720
|
+
user: f.user ?? null,
|
|
1721
|
+
created: f.created ?? null,
|
|
1722
|
+
permalink: f.permalink ?? null,
|
|
1723
|
+
}));
|
|
1724
|
+
}
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
for (const f of shown) {
|
|
1728
|
+
const id = String(f.id ?? "");
|
|
1729
|
+
const name = typeof f.name === "string" ? f.name : typeof f.title === "string" ? f.title : id;
|
|
1730
|
+
const ft = typeof f.filetype === "string" ? f.filetype : "?";
|
|
1731
|
+
const size = typeof f.size === "number" ? fmtSize(f.size) : "";
|
|
1732
|
+
console.log(`${id} ${ft.padEnd(7)} ${size.padStart(9)} ${name}`);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// --- lists <ref> — read a Slack List's rows (read-only) ---
|
|
1737
|
+
// ref: a list URL (…/lists/<TEAM>/<F-id> or …/lists/<F-id>) or a bare F-id.
|
|
1738
|
+
// There is no list-enumeration API, so the id must come from the list's URL.
|
|
1739
|
+
function parseListId(ref: string): string | undefined {
|
|
1740
|
+
if (/^F[A-Z0-9]+$/i.test(ref)) return ref;
|
|
1741
|
+
return ref.match(/\/lists\/[A-Za-z0-9]+\/(F[A-Za-z0-9]+)/i)?.[1]
|
|
1742
|
+
?? ref.match(/\/lists\/(F[A-Za-z0-9]+)/i)?.[1];
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// Best-effort display of a Slack List cell across the value shapes the API uses
|
|
1746
|
+
// (plain text, typed value, arrays of {text|name|value}, typed sub-objects).
|
|
1747
|
+
function listFieldValue(f: Record<string, Json>): string {
|
|
1748
|
+
if (typeof f.text === "string" && f.text) return f.text;
|
|
1749
|
+
const v = f.value;
|
|
1750
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return String(v);
|
|
1751
|
+
if (Array.isArray(v)) {
|
|
1752
|
+
return v.map((x) => typeof x === "string" ? x : String(asRecord(x).text ?? asRecord(x).name ?? asRecord(x).value ?? "")).filter(Boolean).join(", ");
|
|
1753
|
+
}
|
|
1754
|
+
for (const k of ["rich_text", "select", "user", "date", "number", "checkbox", "email", "phone"]) {
|
|
1755
|
+
const tv = f[k];
|
|
1756
|
+
if (tv != null) return typeof tv === "object" ? JSON.stringify(tv) : String(tv);
|
|
1757
|
+
}
|
|
1758
|
+
return "";
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
async function cmdLists(
|
|
1762
|
+
token: string,
|
|
1763
|
+
cookie: string | undefined,
|
|
1764
|
+
ref: string,
|
|
1765
|
+
opts: { limit: number; format: string },
|
|
1766
|
+
): Promise<void> {
|
|
1767
|
+
const listId = parseListId(ref);
|
|
1768
|
+
if (!listId) {
|
|
1769
|
+
throw new Error(
|
|
1770
|
+
`Not a list ID or list URL: ${ref}\n` +
|
|
1771
|
+
`Expected F… or https://<ws>.slack.com/lists/<TEAM>/<F-id>`,
|
|
1772
|
+
);
|
|
1773
|
+
}
|
|
1774
|
+
const records: Record<string, Json>[] = [];
|
|
1775
|
+
let cursor: string | undefined;
|
|
1776
|
+
do {
|
|
1777
|
+
const listOpts: { limit: number; cursor?: string } = { limit: 200 };
|
|
1778
|
+
if (cursor) listOpts.cursor = cursor;
|
|
1779
|
+
const resp = asRecord((await listRecords(token, listId, listOpts, cookie)) as Json);
|
|
1780
|
+
for (const it of asArray(resp.items ?? resp.records)) records.push(asRecord(it));
|
|
1781
|
+
const next = asRecord(resp.response_metadata).next_cursor;
|
|
1782
|
+
cursor = typeof next === "string" && next ? next : undefined;
|
|
1783
|
+
} while (cursor && records.length < opts.limit);
|
|
1784
|
+
const shown = records.slice(0, opts.limit);
|
|
1785
|
+
|
|
1786
|
+
if (opts.format === "jsonl") {
|
|
1787
|
+
for (const r of shown) console.log(JSON.stringify(r));
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
for (const r of shown) {
|
|
1791
|
+
const id = String(r.id ?? "");
|
|
1792
|
+
const cells = asArray(r.fields)
|
|
1793
|
+
.map(asRecord)
|
|
1794
|
+
.map((f) => {
|
|
1795
|
+
const col = String(f.key ?? f.column_id ?? "?");
|
|
1796
|
+
const val = listFieldValue(f);
|
|
1797
|
+
return val ? `${col}=${val}` : "";
|
|
1798
|
+
})
|
|
1799
|
+
.filter(Boolean);
|
|
1800
|
+
console.log(`${id} ${cells.join(" | ")}`);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1421
1804
|
// --- download <ref> [dest] — fetch an attachment's bytes to disk (read-only) ---
|
|
1422
1805
|
// ref: a file ID (F…) or a Slack file permalink (…/files/<UID>/<FID>/<name>).
|
|
1423
1806
|
function parseFileId(ref: string): string | undefined {
|
|
@@ -1503,11 +1886,12 @@ async function main(): Promise<void> {
|
|
|
1503
1886
|
.positional("target", { type: "string", describe: "#channel, @user, or URL" })
|
|
1504
1887
|
.option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" })
|
|
1505
1888
|
.option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
|
|
1506
|
-
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
|
|
1889
|
+
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
|
|
1890
|
+
.option("unreplied", { type: "boolean", default: false, describe: "Only messages whose last word (incl. thread replies) is not yours — i.e. awaiting your answer" }),
|
|
1507
1891
|
async (argv) => {
|
|
1508
1892
|
const token = tok(argv as W);
|
|
1509
1893
|
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
|
|
1510
|
-
if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format);
|
|
1894
|
+
if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format, argv.unreplied, ck(argv as W));
|
|
1511
1895
|
else await cmdMsgs(token);
|
|
1512
1896
|
},
|
|
1513
1897
|
)
|
|
@@ -1522,7 +1906,7 @@ async function main(): Promise<void> {
|
|
|
1522
1906
|
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
|
|
1523
1907
|
async (argv) => {
|
|
1524
1908
|
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
|
|
1525
|
-
await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format);
|
|
1909
|
+
await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format, ck(argv as W));
|
|
1526
1910
|
},
|
|
1527
1911
|
)
|
|
1528
1912
|
.command(
|
|
@@ -1582,9 +1966,13 @@ async function main(): Promise<void> {
|
|
|
1582
1966
|
const name = argv.name!.replace(/^#/, "").toLowerCase();
|
|
1583
1967
|
const isPrivate = argv.private === true;
|
|
1584
1968
|
const invites = (argv.invite as string[]).filter(Boolean);
|
|
1585
|
-
|
|
1969
|
+
// The creator becomes the channel's owner and the inviter of record —
|
|
1970
|
+
// so the gate names them, and the code is bound to them.
|
|
1971
|
+
const self = await selfIdentity(token, cookie);
|
|
1972
|
+
const code = safetyCode(name, String(isPrivate), invites.join(","), self?.userId ?? "");
|
|
1586
1973
|
if (argv.code !== code) requireCode(argv.code, code, [
|
|
1587
1974
|
`--- Creating channel -------------------------`,
|
|
1975
|
+
fromLine(self, { pad: 9 }),
|
|
1588
1976
|
` name: ${isPrivate ? "🔒 " : "#"}${name}`,
|
|
1589
1977
|
` private: ${isPrivate}`,
|
|
1590
1978
|
...(invites.length ? [` invite: ${invites.join(", ")}`] : []),
|
|
@@ -1968,6 +2356,118 @@ async function main(): Promise<void> {
|
|
|
1968
2356
|
}
|
|
1969
2357
|
},
|
|
1970
2358
|
)
|
|
2359
|
+
.command(
|
|
2360
|
+
"todo",
|
|
2361
|
+
"Reaction-backed task tracking (:pushpin: marks a task, progress lives in a second reaction)",
|
|
2362
|
+
(y) => y
|
|
2363
|
+
// yargs turns this into --no-cache automatically (boolean negation).
|
|
2364
|
+
.option("cache", { type: "boolean", default: true, describe: "Use ~/.config/slack-cli/cache.json for channel/user lookups (--no-cache to bypass)" })
|
|
2365
|
+
.middleware((argv) => { if (argv.cache === false) setCacheEnabled(false); })
|
|
2366
|
+
.command(
|
|
2367
|
+
["ls", "list"],
|
|
2368
|
+
"List tasks (read-only; one search per run)",
|
|
2369
|
+
(y2) => y2
|
|
2370
|
+
.option("state", {
|
|
2371
|
+
type: "string",
|
|
2372
|
+
choices: ["open", "untriaged", "pending", "doing", "done", "dropped", "stuck"],
|
|
2373
|
+
default: "open",
|
|
2374
|
+
describe: "open (default) = marked and not done/dropped; untriaged = marked with no progress; stuck = unfinished with a reason flag",
|
|
2375
|
+
})
|
|
2376
|
+
.option("in", { type: "string", describe: "Limit to a channel (#chan)" })
|
|
2377
|
+
.option("from", { type: "string", describe: "Limit to a sender (@user, or 'me') — usually the person a task is waiting on" })
|
|
2378
|
+
.option("shared", { type: "boolean", default: false, describe: "Match anyone's reactions (has:) instead of only yours (hasmy:)" })
|
|
2379
|
+
.option("n", { alias: "count", type: "number", default: 50, describe: "Max results" })
|
|
2380
|
+
.option("json", { type: "boolean", default: false }),
|
|
2381
|
+
async (argv) => {
|
|
2382
|
+
const opts: { state: LsState; in?: string; from?: string; shared?: boolean; count: number; json: boolean; cookie?: string } = {
|
|
2383
|
+
state: argv.state as LsState,
|
|
2384
|
+
count: argv.n,
|
|
2385
|
+
json: argv.json,
|
|
2386
|
+
};
|
|
2387
|
+
if (argv.in) opts.in = argv.in;
|
|
2388
|
+
if (argv.from) opts.from = argv.from;
|
|
2389
|
+
if (argv.shared) opts.shared = true;
|
|
2390
|
+
const cookie = ck(argv as W);
|
|
2391
|
+
if (cookie) opts.cookie = cookie;
|
|
2392
|
+
await cmdTodoLs(tok(argv as W), opts);
|
|
2393
|
+
},
|
|
2394
|
+
)
|
|
2395
|
+
.command(
|
|
2396
|
+
"set <target> <state>",
|
|
2397
|
+
"Move a task to a progress state (adds the new reaction before removing the old one)",
|
|
2398
|
+
(y2) => y2
|
|
2399
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts or permalink" })
|
|
2400
|
+
.positional("state", { type: "string", demandOption: true, choices: ["pending", "doing", "done", "dropped"] })
|
|
2401
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" }),
|
|
2402
|
+
async (argv) => {
|
|
2403
|
+
const args: { target: string; state: ProgressState; channelId?: string; cookie?: string } = {
|
|
2404
|
+
target: argv.target!,
|
|
2405
|
+
state: argv.state as ProgressState,
|
|
2406
|
+
};
|
|
2407
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
2408
|
+
const cookie = ck(argv as W);
|
|
2409
|
+
if (cookie) args.cookie = cookie;
|
|
2410
|
+
try {
|
|
2411
|
+
await cmdTodoSet(tok(argv as W), args);
|
|
2412
|
+
} catch (e: unknown) {
|
|
2413
|
+
console.error(friendlySlackError(e));
|
|
2414
|
+
process.exit(1);
|
|
2415
|
+
}
|
|
2416
|
+
},
|
|
2417
|
+
)
|
|
2418
|
+
.command(
|
|
2419
|
+
"flag <target> <flag>",
|
|
2420
|
+
"Add (or --remove) a reason flag (alert | needs-discussion | waiting | blocked) — flags stack, independent of progress",
|
|
2421
|
+
(y2) => y2
|
|
2422
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts or permalink" })
|
|
2423
|
+
.positional("flag", {
|
|
2424
|
+
type: "string",
|
|
2425
|
+
demandOption: true,
|
|
2426
|
+
choices: ["alert", "needs-discussion", "waiting", "blocked"],
|
|
2427
|
+
describe: "waiting = the other party in this conversation owes a reply; blocked = stuck on something outside it",
|
|
2428
|
+
})
|
|
2429
|
+
.option("remove", { type: "boolean", default: false, describe: "Remove the flag instead of adding it" })
|
|
2430
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" }),
|
|
2431
|
+
async (argv) => {
|
|
2432
|
+
const args: { target: string; flag: FlagName; remove?: boolean; channelId?: string; cookie?: string } = {
|
|
2433
|
+
target: argv.target!,
|
|
2434
|
+
flag: argv.flag as FlagName,
|
|
2435
|
+
};
|
|
2436
|
+
if (argv.remove) args.remove = true;
|
|
2437
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
2438
|
+
const cookie = ck(argv as W);
|
|
2439
|
+
if (cookie) args.cookie = cookie;
|
|
2440
|
+
try {
|
|
2441
|
+
await cmdTodoFlag(tok(argv as W), args);
|
|
2442
|
+
} catch (e: unknown) {
|
|
2443
|
+
console.error(friendlySlackError(e));
|
|
2444
|
+
process.exit(1);
|
|
2445
|
+
}
|
|
2446
|
+
},
|
|
2447
|
+
)
|
|
2448
|
+
.command(
|
|
2449
|
+
"doctor",
|
|
2450
|
+
"Find messages carrying two or more progress reactions (--fix repairs them)",
|
|
2451
|
+
(y2) => y2
|
|
2452
|
+
.option("in", { type: "string", demandOption: true, describe: "Channel to scan (#chan)" })
|
|
2453
|
+
.option("fix", { type: "boolean", default: false, describe: "Repair findings (keeps the highest-priority state)" })
|
|
2454
|
+
.option("limit", { type: "number", default: DOCTOR_DEFAULT_LIMIT, describe: "Max messages to scan" })
|
|
2455
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" }),
|
|
2456
|
+
async (argv) => {
|
|
2457
|
+
const args: { channel: string; fix?: boolean; limit: number; channelId?: string; cookie?: string } = {
|
|
2458
|
+
channel: argv.in!,
|
|
2459
|
+
limit: argv.limit,
|
|
2460
|
+
};
|
|
2461
|
+
if (argv.fix) args.fix = true;
|
|
2462
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
2463
|
+
const cookie = ck(argv as W);
|
|
2464
|
+
if (cookie) args.cookie = cookie;
|
|
2465
|
+
await cmdTodoDoctor(tok(argv as W), args);
|
|
2466
|
+
},
|
|
2467
|
+
)
|
|
2468
|
+
.demandCommand(1, "Specify a todo subcommand (ls, set, flag, doctor)"),
|
|
2469
|
+
() => {},
|
|
2470
|
+
)
|
|
1971
2471
|
.command(
|
|
1972
2472
|
"upload <target> <file..>",
|
|
1973
2473
|
"Upload one or more files to a channel or DM",
|
|
@@ -1991,6 +2491,53 @@ async function main(): Promise<void> {
|
|
|
1991
2491
|
await cmdUpload(tok(argv as W), args);
|
|
1992
2492
|
},
|
|
1993
2493
|
)
|
|
2494
|
+
.command(
|
|
2495
|
+
"files",
|
|
2496
|
+
"File commands",
|
|
2497
|
+
(y) => y
|
|
2498
|
+
.command(
|
|
2499
|
+
["ls", "list"],
|
|
2500
|
+
"List files visible to you (files.list)",
|
|
2501
|
+
(y2) => y2
|
|
2502
|
+
.option("limit", { alias: "l", type: "number", default: 100, describe: "Max files to list" })
|
|
2503
|
+
.option("type", { alias: "t", type: "string", describe: "Slack type filter: images, pdfs, gdocs, snippets, zips, spaces, all" })
|
|
2504
|
+
.option("channel", { alias: "c", type: "string", describe: "Only files in this channel (#name, @user, ID, or permalink)" })
|
|
2505
|
+
.option("user", { alias: "u", type: "string", describe: "Only files from this raw user ID" })
|
|
2506
|
+
.option("format", { type: "string", choices: ["text", "jsonl"], default: "text" })
|
|
2507
|
+
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
|
|
2508
|
+
async (argv) => {
|
|
2509
|
+
const o: { limit: number; types?: string; channel?: string; user?: string; format: string } = {
|
|
2510
|
+
limit: argv.limit as number,
|
|
2511
|
+
format: argv.json ? "jsonl" : (argv.format as string),
|
|
2512
|
+
};
|
|
2513
|
+
if (argv.type) o.types = argv.type as string;
|
|
2514
|
+
if (argv.channel) o.channel = argv.channel as string;
|
|
2515
|
+
if (argv.user) o.user = argv.user as string;
|
|
2516
|
+
await cmdFilesList(tok(argv as W), ck(argv as W), o);
|
|
2517
|
+
},
|
|
2518
|
+
)
|
|
2519
|
+
.demandCommand(1, "")
|
|
2520
|
+
.showHelpOnFail(true),
|
|
2521
|
+
() => {},
|
|
2522
|
+
)
|
|
2523
|
+
.command(
|
|
2524
|
+
"lists <ref>",
|
|
2525
|
+
"Read a Slack List's rows (by list URL or F-id) — read-only",
|
|
2526
|
+
(y) => y
|
|
2527
|
+
.positional("ref", { type: "string", demandOption: true, describe: "List URL (…/lists/<TEAM>/<F-id>) or bare F-id" })
|
|
2528
|
+
.option("limit", { alias: "l", type: "number", default: 100, describe: "Max rows to show" })
|
|
2529
|
+
.option("format", { type: "string", choices: ["text", "jsonl"], default: "text" })
|
|
2530
|
+
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl (raw records)" }),
|
|
2531
|
+
async (argv) => {
|
|
2532
|
+
const format = argv.json ? "jsonl" : (argv.format as string);
|
|
2533
|
+
try {
|
|
2534
|
+
await cmdLists(tok(argv as W), ck(argv as W), argv.ref!, { limit: argv.limit as number, format });
|
|
2535
|
+
} catch (e: unknown) {
|
|
2536
|
+
console.error(friendlySlackError(e));
|
|
2537
|
+
process.exit(1);
|
|
2538
|
+
}
|
|
2539
|
+
},
|
|
2540
|
+
)
|
|
1994
2541
|
.command(
|
|
1995
2542
|
"download <ref> [dest]",
|
|
1996
2543
|
"Download a file attachment (by file ID or file permalink) to disk",
|
|
@@ -2062,8 +2609,11 @@ async function main(): Promise<void> {
|
|
|
2062
2609
|
const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === argv.id);
|
|
2063
2610
|
if (!d) { console.error(`Draft not found: ${argv.id}`); process.exit(1); }
|
|
2064
2611
|
const prevText = draftText(d);
|
|
2065
|
-
const
|
|
2612
|
+
const self = await selfIdentity(token, cookie);
|
|
2613
|
+
const code = safetyCode(prevText, text, self?.userId ?? "");
|
|
2066
2614
|
if (argv.code !== code) requireCode(argv.code, code, [
|
|
2615
|
+
`--- Editing draft as -------------------------`,
|
|
2616
|
+
fromLine(self),
|
|
2067
2617
|
`--- Current draft ----------------------------`,
|
|
2068
2618
|
...prevText.split("\n").map((l) => ` ${l}`),
|
|
2069
2619
|
`--- Replacing with ---------------------------`,
|
|
@@ -2086,9 +2636,11 @@ async function main(): Promise<void> {
|
|
|
2086
2636
|
const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === argv.id);
|
|
2087
2637
|
if (!d) { console.error(`Draft not found: ${argv.id}`); process.exit(1); }
|
|
2088
2638
|
const prevText = draftText(d);
|
|
2089
|
-
const
|
|
2639
|
+
const self = await selfIdentity(token, cookie);
|
|
2640
|
+
const code = safetyCode(argv.id!, prevText, self?.userId ?? "");
|
|
2090
2641
|
if (argv.code !== code) requireCode(argv.code, code, [
|
|
2091
2642
|
`─-- Deleting draft ───────────────────────────`,
|
|
2643
|
+
fromLine(self),
|
|
2092
2644
|
` id: ${argv.id}`,
|
|
2093
2645
|
...prevText.split("\n").map((l) => ` ${l}`),
|
|
2094
2646
|
`--------------------------------────────────`,
|