slack-term 1.14.0 → 1.16.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 +22 -3
- package/SKILL.md +15 -4
- package/dist/cli.js +82 -64
- package/package.json +6 -3
- package/ts/auth.ts +268 -13
- package/ts/botdoctor.ts +96 -0
- package/ts/cli.ts +400 -68
- package/ts/profiles.ts +165 -36
- package/ts/rtm.ts +18 -1
- package/ts/slack-app.ts +207 -3
- package/ts/slack.ts +112 -0
- package/ts/tail.ts +111 -12
package/ts/cli.ts
CHANGED
|
@@ -8,17 +8,20 @@ import { join } from "node:path";
|
|
|
8
8
|
|
|
9
9
|
import yargs from "yargs";
|
|
10
10
|
import { hideBin } from "yargs/helpers";
|
|
11
|
-
import { listProfiles, removeProfile, resolveCookie, resolveToken, useProfile } from "./profiles.ts";
|
|
12
|
-
import {
|
|
11
|
+
import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile } from "./profiles.ts";
|
|
12
|
+
import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
|
|
13
|
+
import { cmdAuthLogin, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp } from "./auth.ts";
|
|
13
14
|
import { cmdTail } from "./tail.ts";
|
|
14
15
|
|
|
15
16
|
import {
|
|
17
|
+
authTest,
|
|
16
18
|
authTestSession,
|
|
17
19
|
conversationInfoSession,
|
|
18
20
|
createDraft,
|
|
19
21
|
deleteDraft,
|
|
20
22
|
updateDraft,
|
|
21
23
|
editMessage,
|
|
24
|
+
deleteMessage,
|
|
22
25
|
history,
|
|
23
26
|
listConversations,
|
|
24
27
|
listDrafts,
|
|
@@ -29,9 +32,11 @@ import {
|
|
|
29
32
|
parseSlackPermalink,
|
|
30
33
|
replies,
|
|
31
34
|
resolveChannel,
|
|
35
|
+
resolveUserId,
|
|
32
36
|
search,
|
|
33
37
|
searchAll,
|
|
34
38
|
send as slackSend,
|
|
39
|
+
getPermalink,
|
|
35
40
|
scheduleMessage,
|
|
36
41
|
listScheduledMessages,
|
|
37
42
|
deleteScheduledMessage,
|
|
@@ -170,34 +175,65 @@ async function formatMsgLine(
|
|
|
170
175
|
const lines = resolved.split("\n");
|
|
171
176
|
const body = lines[0] + (lines.length > 1 ? "\n" + lines.slice(1).map(l => ` ${l}`).join("\n") : "");
|
|
172
177
|
const who = chLabel ? `${chLabel} @${handle}` : `@${handle}`;
|
|
173
|
-
|
|
178
|
+
const reactions = asArray(m.reactions)
|
|
179
|
+
.map(asRecord)
|
|
180
|
+
.map((r) => {
|
|
181
|
+
const name = typeof r.name === "string" ? r.name : "";
|
|
182
|
+
const count = Number(r.count ?? asArray(r.users).length ?? 0);
|
|
183
|
+
return name ? `:${name}:×${count}` : "";
|
|
184
|
+
})
|
|
185
|
+
.filter(Boolean)
|
|
186
|
+
.join(" ");
|
|
187
|
+
const tail = reactions ? `\n ${reactions}` : "";
|
|
188
|
+
return `${stamp} ${who}: ${body}${tail}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Slim a raw message down to the fields a script actually needs. Keeps the
|
|
192
|
+
// author's user ID (incl. external / Slack-Connect guests that never appear in
|
|
193
|
+
// users.list) so callers can resolve mentions without scraping history by hand.
|
|
194
|
+
function slimMsg(m: Record<string, Json>): Record<string, Json> {
|
|
195
|
+
return {
|
|
196
|
+
ts: m.ts ?? null,
|
|
197
|
+
user: m.user ?? null,
|
|
198
|
+
username: m.username ?? null,
|
|
199
|
+
bot_id: m.bot_id ?? null,
|
|
200
|
+
thread_ts: m.thread_ts ?? null,
|
|
201
|
+
text: typeof m.text === "string" ? m.text : "",
|
|
202
|
+
};
|
|
174
203
|
}
|
|
175
204
|
|
|
176
205
|
// --- msgs <target> — channel/DM history with timestamps ---
|
|
177
|
-
async function cmdMsgsTarget(token: string, target: string, limit: number): Promise<void> {
|
|
206
|
+
async function cmdMsgsTarget(token: string, target: string, limit: number, format: "text" | "jsonl" = "text"): Promise<void> {
|
|
178
207
|
const parsed = parseSlackPermalink(target);
|
|
179
208
|
const channelId = await resolveChannel(token, target);
|
|
180
209
|
const cache = new Map<string, string>();
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
console.log(await formatMsgLine(token, m, cache));
|
|
210
|
+
const fetchMsgs = async (): Promise<Record<string, Json>[]> => {
|
|
211
|
+
if (parsed?.threadTs) {
|
|
212
|
+
const resp = (await replies(token, channelId, parsed.threadTs, limit)) as Record<string, Json>;
|
|
213
|
+
return asArray(resp.messages).map(asRecord);
|
|
186
214
|
}
|
|
187
|
-
} else {
|
|
188
215
|
const hist = (await history(token, channelId, limit)) as Record<string, Json>;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
216
|
+
return asArray(hist.messages).map(asRecord).reverse();
|
|
217
|
+
};
|
|
218
|
+
const msgs = await fetchMsgs();
|
|
219
|
+
if (format === "jsonl") {
|
|
220
|
+
for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
for (const m of msgs) {
|
|
224
|
+
console.log(await formatMsgLine(token, m, cache));
|
|
193
225
|
}
|
|
194
226
|
}
|
|
195
227
|
|
|
196
228
|
// --- thread ---
|
|
197
|
-
async function cmdThread(token: string, target: string, ts: string, limit: number): Promise<void> {
|
|
229
|
+
async function cmdThread(token: string, target: string, ts: string, limit: number, format: "text" | "jsonl" = "text"): Promise<void> {
|
|
198
230
|
const channelId = await resolveChannel(token, target);
|
|
199
231
|
const resp = (await replies(token, channelId, parseInputTs(ts), limit)) as Record<string, Json>;
|
|
200
232
|
const msgs = asArray(resp.messages).map(asRecord);
|
|
233
|
+
if (format === "jsonl") {
|
|
234
|
+
for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
201
237
|
const cache = new Map<string, string>();
|
|
202
238
|
for (const m of msgs) {
|
|
203
239
|
console.log(await formatMsgLine(token, m, cache));
|
|
@@ -548,6 +584,69 @@ function splitRefTs(s: string): { ref: string; ts?: string } {
|
|
|
548
584
|
return { ref: s };
|
|
549
585
|
}
|
|
550
586
|
|
|
587
|
+
/** Parse a send/upload target that may embed a thread_ts after `:`.
|
|
588
|
+
* Accepts: `#chan:1700000000.000100`, `@user:ts`, `RAWID:ts`, Slack permalink, or plain ref.
|
|
589
|
+
* A message permalink targets that message's thread — `?thread_ts=` (the parent) wins when
|
|
590
|
+
* present, otherwise the message's own ts. A channel-only permalink stays top-level. */
|
|
591
|
+
function parseTargetThread(s: string): { ref: string; threadTs?: string } {
|
|
592
|
+
const url = parseSlackPermalink(s);
|
|
593
|
+
if (url) {
|
|
594
|
+
const threadTs = url.threadTs ?? url.ts;
|
|
595
|
+
return threadTs ? { ref: url.channel, threadTs } : { ref: url.channel };
|
|
596
|
+
}
|
|
597
|
+
const colon = s.indexOf(":");
|
|
598
|
+
if (colon > 0) {
|
|
599
|
+
const maybeTs = s.slice(colon + 1);
|
|
600
|
+
if (/^\d{10}\.\d{6}$/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: maybeTs };
|
|
601
|
+
if (/^\d{4}-\d{2}-\d{2}T/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: isoToSlackTs(maybeTs) };
|
|
602
|
+
}
|
|
603
|
+
return { ref: s };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Human-readable destination label for confirm gates, e.g. "#symval (C0B0L6SSAMD)".
|
|
607
|
+
* When ref is already a #channel/@user label, keep it; otherwise resolve the channel
|
|
608
|
+
* name via conversations.info (fail-soft: a raw ID is still unambiguous). */
|
|
609
|
+
async function destLabel(token: string, channelId: string, ref: string): Promise<string> {
|
|
610
|
+
let name = ref;
|
|
611
|
+
if (!ref.startsWith("#") && !ref.startsWith("@")) {
|
|
612
|
+
try {
|
|
613
|
+
const info = asRecord((await conversationInfo(token, channelId)) as Json);
|
|
614
|
+
const ch = asRecord(info.channel);
|
|
615
|
+
if (ch.is_im === true) {
|
|
616
|
+
const uid = typeof ch.user === "string" ? ch.user : "";
|
|
617
|
+
name = uid ? `@${await userName(token, uid)}` : channelId;
|
|
618
|
+
} else {
|
|
619
|
+
name = typeof ch.name === "string" ? `#${ch.name}` : channelId;
|
|
620
|
+
}
|
|
621
|
+
} catch {
|
|
622
|
+
name = channelId;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return name === channelId ? channelId : `${name} (${channelId})`;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** One-line thread-parent description for confirm gates: "2026-06-11 08:05 @handle: head…".
|
|
629
|
+
* Fail-soft: returns the raw ts if the parent cannot be fetched. */
|
|
630
|
+
async function threadParentLine(token: string, channelId: string, threadTs: string): Promise<string> {
|
|
631
|
+
try {
|
|
632
|
+
const resp = (await replies(token, channelId, threadTs, 1)) as Record<string, Json>;
|
|
633
|
+
const msgs = asArray(resp.messages).map(asRecord);
|
|
634
|
+
const parent = msgs.find((m) => String(m.ts) === threadTs) ?? msgs[0];
|
|
635
|
+
if (!parent) return threadTs;
|
|
636
|
+
const author = typeof parent.user === "string"
|
|
637
|
+
? await userName(token, parent.user)
|
|
638
|
+
: typeof parent.username === "string"
|
|
639
|
+
? parent.username
|
|
640
|
+
: "?";
|
|
641
|
+
const head = (typeof parent.text === "string" ? parent.text : "").split("\n")[0] ?? "";
|
|
642
|
+
const headShort = head.length > 40 ? `${head.slice(0, 40)}…` : head;
|
|
643
|
+
const stamp = slackTsToIso(threadTs).slice(0, 16).replace("T", " ");
|
|
644
|
+
return `${stamp} @${author}${headShort ? `: ${headShort}` : ""}`;
|
|
645
|
+
} catch {
|
|
646
|
+
return threadTs;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
551
650
|
// --- edit ---
|
|
552
651
|
interface EditArgs {
|
|
553
652
|
target: string;
|
|
@@ -591,48 +690,172 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
591
690
|
console.log(`✓ Edited (ts: ${newTs})`);
|
|
592
691
|
}
|
|
593
692
|
|
|
693
|
+
// --- delete ---
|
|
694
|
+
interface DeleteArgs {
|
|
695
|
+
target: string;
|
|
696
|
+
code?: string;
|
|
697
|
+
channelId?: string;
|
|
698
|
+
}
|
|
699
|
+
async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
|
|
700
|
+
const { ref, ts } = splitRefTs(args.target);
|
|
701
|
+
if (!ts) {
|
|
702
|
+
console.error("Error: target must embed a message ts (e.g. #chan:2026-05-11T06:01:04.000100 or a Slack permalink URL)");
|
|
703
|
+
process.exit(2);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
let channelId: string;
|
|
707
|
+
if (args.channelId) channelId = args.channelId;
|
|
708
|
+
else channelId = await resolveChannel(token, ref);
|
|
709
|
+
|
|
710
|
+
// Fetch the message to display what is being deleted and compute the safety hash.
|
|
711
|
+
const resp = (await replies(token, channelId, ts, 1)) as Record<string, Json>;
|
|
712
|
+
const msgs = asArray(resp.messages).map(asRecord);
|
|
713
|
+
const original = msgs.find((m) => String(m.ts) === ts);
|
|
714
|
+
if (!original) {
|
|
715
|
+
console.error(`Message not found at ts=${ts} in channel ${channelId}`);
|
|
716
|
+
process.exit(1);
|
|
717
|
+
}
|
|
718
|
+
const originalText = typeof original.text === "string" ? original.text : "";
|
|
719
|
+
|
|
720
|
+
const code = safetyCode(channelId, ts, originalText);
|
|
721
|
+
if (args.code !== code) {
|
|
722
|
+
const dest = await destLabel(token, channelId, ref);
|
|
723
|
+
requireCode(args.code, code, [
|
|
724
|
+
`--- Deleting message -------------------------`,
|
|
725
|
+
` → ${dest} at ${slackTsToIso(ts)}`,
|
|
726
|
+
...originalText.split("\n").map((l) => ` ${l}`),
|
|
727
|
+
`---------------------------------------------`,
|
|
728
|
+
]);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
await deleteMessage(token, channelId, ts);
|
|
732
|
+
console.log(`✓ Deleted (ts: ${ts})`);
|
|
733
|
+
}
|
|
734
|
+
|
|
594
735
|
// --- send ---
|
|
595
736
|
interface SendArgs {
|
|
596
737
|
target: string;
|
|
597
738
|
message: string;
|
|
598
|
-
thread?: string;
|
|
599
739
|
code?: string;
|
|
600
740
|
channelId?: string;
|
|
601
741
|
userId?: string;
|
|
742
|
+
asBot?: boolean;
|
|
743
|
+
broadcast?: boolean;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Detect the silent-failure footgun: DMing yourself with your own user token.
|
|
747
|
+
// Slack does not notify you about messages you sent to yourself, so an
|
|
748
|
+
// escalation DM via `send @me` (or @your-own-handle) is delivered but never
|
|
749
|
+
// surfaces. Returns true when ref names the token's own user.
|
|
750
|
+
async function isSelfDm(token: string, ref: string): Promise<boolean> {
|
|
751
|
+
if (!ref.startsWith("@")) return false;
|
|
752
|
+
const name = ref.slice(1).toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
753
|
+
if (name === "me" || name === "you") return true;
|
|
754
|
+
try {
|
|
755
|
+
const self = await authTest(token);
|
|
756
|
+
const selfName = (self.user ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
757
|
+
return selfName !== "" && selfName === name;
|
|
758
|
+
} catch {
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
602
761
|
}
|
|
762
|
+
|
|
603
763
|
async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
764
|
+
const { ref, threadTs } = parseTargetThread(args.target);
|
|
765
|
+
|
|
766
|
+
// Guard the self-DM footgun before doing anything else, unless sending as the
|
|
767
|
+
// bot (which delivers a notifiable DM from a different identity).
|
|
768
|
+
if (!args.asBot && !args.channelId && !args.userId && await isSelfDm(token, ref)) {
|
|
769
|
+
console.error(
|
|
770
|
+
`Warning: "${ref}" is a DM to yourself — Slack will NOT notify you of your own message.\n` +
|
|
771
|
+
` To reach yourself with a notification, send as the bot: slack send '${ref}' '...' --as-bot`,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
604
775
|
let channelId: string;
|
|
605
776
|
if (args.channelId) channelId = args.channelId;
|
|
606
777
|
else if (args.userId) channelId = await openDm(token, args.userId);
|
|
607
|
-
else
|
|
608
|
-
channelId = await resolveChannel(token, args.target);
|
|
609
|
-
} else {
|
|
610
|
-
console.error(`Error: target must be #channel-name or @username (got: ${args.target})`);
|
|
611
|
-
console.error("Use --channel-id=<ID> or --user-id=<ID> to send by raw ID.");
|
|
612
|
-
process.exit(1);
|
|
613
|
-
}
|
|
778
|
+
else channelId = await resolveChannel(token, ref);
|
|
614
779
|
|
|
615
|
-
// Fetch last 1 message for context hash
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
780
|
+
// Fetch last 1 message for context hash. Fail-soft: a bot token without
|
|
781
|
+
// im:history (or channels:history) can still send — only the preview of the
|
|
782
|
+
// prior message is lost, so default to empty rather than blocking the send.
|
|
783
|
+
let lastText = "";
|
|
784
|
+
let lastUser = "?";
|
|
785
|
+
try {
|
|
786
|
+
const ctx = (await history(token, channelId, 1)) as Record<string, Json>;
|
|
787
|
+
const lastMsg = asArray(ctx.messages).map(asRecord)
|
|
788
|
+
.filter((m) => m.subtype === undefined || m.subtype === null)[0];
|
|
789
|
+
lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
|
|
790
|
+
// Resolve the author's user ID to a display name for the preview; fall back
|
|
791
|
+
// to the bot username, then the raw ID. userName() is fail-soft.
|
|
792
|
+
lastUser = typeof lastMsg?.user === "string"
|
|
793
|
+
? await userName(token, lastMsg.user)
|
|
794
|
+
: typeof lastMsg?.username === "string"
|
|
795
|
+
? lastMsg.username
|
|
796
|
+
: "?";
|
|
797
|
+
} catch {
|
|
798
|
+
// best-effort preview only
|
|
799
|
+
}
|
|
621
800
|
|
|
622
|
-
|
|
801
|
+
// Hash covers the destination too — a code minted for one channel/thread
|
|
802
|
+
// cannot confirm a send to another.
|
|
803
|
+
const code = safetyCode(channelId, threadTs ?? "", lastText, args.message);
|
|
623
804
|
|
|
624
805
|
if (args.code !== code) {
|
|
806
|
+
const dest = await destLabel(token, channelId, ref);
|
|
807
|
+
const destLine = threadTs
|
|
808
|
+
? ` → ${dest} thread of ${await threadParentLine(token, channelId, threadTs)} — THREAD REPLY`
|
|
809
|
+
: ` → ${dest} — NEW top-level message`;
|
|
625
810
|
requireCode(args.code, code, [
|
|
626
811
|
`--- Last message in channel ------------------`,
|
|
627
812
|
` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
|
|
628
813
|
`--- Sending ----------------------------------`,
|
|
629
|
-
|
|
814
|
+
destLine,
|
|
630
815
|
` Message: ${args.message}`,
|
|
631
816
|
`--------------------------------────────────`,
|
|
632
817
|
]);
|
|
633
818
|
}
|
|
634
|
-
const ts = await slackSend(token, channelId, args.message, args.
|
|
635
|
-
|
|
819
|
+
const ts = await slackSend(token, channelId, args.message, threadTs, args.broadcast);
|
|
820
|
+
let permalink = "";
|
|
821
|
+
try {
|
|
822
|
+
permalink = await getPermalink(token, channelId, ts);
|
|
823
|
+
} catch {
|
|
824
|
+
// fail-soft: getPermalink failure (rate limit, network, etc.) should not
|
|
825
|
+
// mask send success — fall back to ts-only output below.
|
|
826
|
+
}
|
|
827
|
+
if (permalink) {
|
|
828
|
+
console.log(`✓ Sent: ${permalink}`);
|
|
829
|
+
} else {
|
|
830
|
+
console.log(`✓ Sent (ts: ${ts})`);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// After a bot DM, check the app can actually carry a two-way conversation —
|
|
834
|
+
// an escalation the user can't reply to (Messages Tab off / missing scopes)
|
|
835
|
+
// is worse than useless. Non-fatal: the send already succeeded.
|
|
836
|
+
if (args.asBot && channelId.startsWith("D")) {
|
|
837
|
+
try {
|
|
838
|
+
const diag = await diagnoseBotMessaging(token);
|
|
839
|
+
if (!diag.ok) {
|
|
840
|
+
for (const line of formatDiagnosis(diag, true)) console.error(line);
|
|
841
|
+
} else {
|
|
842
|
+
// Scopes are fine, but the Messages Tab toggle isn't API-detectable.
|
|
843
|
+
const appUrl = diag.appId ? `https://api.slack.com/apps/${diag.appId}` : "https://api.slack.com/apps";
|
|
844
|
+
console.error(
|
|
845
|
+
`Note: if ${args.target} can't reply (app messaging may be off), ` +
|
|
846
|
+
`enable Messages Tab: ${appUrl} → App Home.`,
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
} catch {
|
|
850
|
+
// diagnosis is best-effort; never turn a successful send into a failure.
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async function cmdDoctor(token: string): Promise<void> {
|
|
856
|
+
const diag = await diagnoseBotMessaging(token);
|
|
857
|
+
for (const line of formatDiagnosis(diag, false)) console.log(line);
|
|
858
|
+
if (!diag.ok) process.exit(1);
|
|
636
859
|
}
|
|
637
860
|
|
|
638
861
|
// --- schedule ---
|
|
@@ -647,21 +870,17 @@ interface ScheduleSendArgs {
|
|
|
647
870
|
target: string;
|
|
648
871
|
message: string;
|
|
649
872
|
at: string;
|
|
650
|
-
thread?: string;
|
|
651
873
|
code?: string;
|
|
652
874
|
channelId?: string;
|
|
653
875
|
userId?: string;
|
|
654
876
|
}
|
|
655
877
|
async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
|
|
878
|
+
const { ref, threadTs } = parseTargetThread(args.target);
|
|
879
|
+
|
|
656
880
|
let channelId: string;
|
|
657
881
|
if (args.channelId) channelId = args.channelId;
|
|
658
882
|
else if (args.userId) channelId = await openDm(token, args.userId);
|
|
659
|
-
else
|
|
660
|
-
channelId = await resolveChannel(token, args.target);
|
|
661
|
-
} else {
|
|
662
|
-
console.error(`Error: target must be #channel-name or @username (got: ${args.target})`);
|
|
663
|
-
process.exit(1);
|
|
664
|
-
}
|
|
883
|
+
else channelId = await resolveChannel(token, ref);
|
|
665
884
|
|
|
666
885
|
const postAt = parsePostAt(args.at);
|
|
667
886
|
const postAtDate = new Date(postAt * 1000).toISOString();
|
|
@@ -670,13 +889,13 @@ async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<v
|
|
|
670
889
|
if (args.code !== code) {
|
|
671
890
|
requireCode(args.code, code, [
|
|
672
891
|
`--- Scheduling message -----------------------`,
|
|
673
|
-
` To: ${
|
|
892
|
+
` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`,
|
|
674
893
|
` At: ${postAtDate} (Unix: ${postAt})`,
|
|
675
894
|
` Message: ${args.message}`,
|
|
676
895
|
`---------------------------------------------`,
|
|
677
896
|
]);
|
|
678
897
|
}
|
|
679
|
-
const id = await scheduleMessage(token, channelId, args.message, postAt,
|
|
898
|
+
const id = await scheduleMessage(token, channelId, args.message, postAt, threadTs);
|
|
680
899
|
console.log(`✓ Scheduled (id: ${id}, at: ${postAtDate})`);
|
|
681
900
|
}
|
|
682
901
|
|
|
@@ -727,7 +946,6 @@ interface UploadArgs {
|
|
|
727
946
|
target: string;
|
|
728
947
|
filePaths: string[];
|
|
729
948
|
title?: string;
|
|
730
|
-
thread?: string;
|
|
731
949
|
comment?: string;
|
|
732
950
|
code?: string;
|
|
733
951
|
channelId?: string;
|
|
@@ -744,15 +962,12 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
744
962
|
}
|
|
745
963
|
}
|
|
746
964
|
|
|
965
|
+
const { ref, threadTs } = parseTargetThread(args.target);
|
|
966
|
+
|
|
747
967
|
let channelId: string;
|
|
748
968
|
if (args.channelId) channelId = args.channelId;
|
|
749
969
|
else if (args.userId) channelId = await openDm(token, args.userId);
|
|
750
|
-
else
|
|
751
|
-
channelId = await resolveChannel(token, args.target);
|
|
752
|
-
} else {
|
|
753
|
-
console.error(`Error: target must be #channel-name or @username (got: ${args.target})`);
|
|
754
|
-
process.exit(1);
|
|
755
|
-
}
|
|
970
|
+
else channelId = await resolveChannel(token, ref);
|
|
756
971
|
|
|
757
972
|
function fmtSize(n: number): string {
|
|
758
973
|
return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
@@ -768,7 +983,7 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
768
983
|
// Safety code covers the full batch — single-file code is identical to the old formula.
|
|
769
984
|
const code = safetyCode(channelId, ...files.flatMap((f) => [f.fp, f.title]));
|
|
770
985
|
if (args.code !== code) {
|
|
771
|
-
const destLine = ` To: ${
|
|
986
|
+
const destLine = ` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`;
|
|
772
987
|
const lines = isBatch
|
|
773
988
|
? [
|
|
774
989
|
`--- Uploading ${files.length} files ------------------------`,
|
|
@@ -791,7 +1006,7 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
791
1006
|
for (let i = 0; i < total; i++) {
|
|
792
1007
|
const f = files[i]!;
|
|
793
1008
|
const uploadOpts: { title?: string; threadTs?: string; initialComment?: string } = { title: f.title };
|
|
794
|
-
if (
|
|
1009
|
+
if (threadTs !== undefined) uploadOpts.threadTs = threadTs;
|
|
795
1010
|
if (args.comment !== undefined && i === 0) uploadOpts.initialComment = args.comment;
|
|
796
1011
|
const { fileId, permalink } = await uploadFile(token, channelId, f.fp, uploadOpts);
|
|
797
1012
|
const prefix = total > 1 ? `[${i + 1}/${total}] ` : "";
|
|
@@ -831,10 +1046,13 @@ async function main(): Promise<void> {
|
|
|
831
1046
|
"Browse messages",
|
|
832
1047
|
(y) => y
|
|
833
1048
|
.positional("target", { type: "string", describe: "#channel, @user, or URL" })
|
|
834
|
-
.option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" })
|
|
1049
|
+
.option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" })
|
|
1050
|
+
.option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
|
|
1051
|
+
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
|
|
835
1052
|
async (argv) => {
|
|
836
1053
|
const token = tok(argv as W);
|
|
837
|
-
|
|
1054
|
+
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
|
|
1055
|
+
if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format);
|
|
838
1056
|
else await cmdMsgs(token);
|
|
839
1057
|
},
|
|
840
1058
|
)
|
|
@@ -844,9 +1062,12 @@ async function main(): Promise<void> {
|
|
|
844
1062
|
(y) => y
|
|
845
1063
|
.positional("target", { type: "string", demandOption: true })
|
|
846
1064
|
.positional("ts", { type: "string", demandOption: true })
|
|
847
|
-
.option("limit", { alias: "n", type: "number", default: 100 })
|
|
1065
|
+
.option("limit", { alias: "n", type: "number", default: 100 })
|
|
1066
|
+
.option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
|
|
1067
|
+
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
|
|
848
1068
|
async (argv) => {
|
|
849
|
-
|
|
1069
|
+
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
|
|
1070
|
+
await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format);
|
|
850
1071
|
},
|
|
851
1072
|
)
|
|
852
1073
|
.command(
|
|
@@ -1019,19 +1240,75 @@ async function main(): Promise<void> {
|
|
|
1019
1240
|
"send <target> <message>",
|
|
1020
1241
|
"Send a message (confirm-hash safety gate)",
|
|
1021
1242
|
(y) => y
|
|
1022
|
-
.positional("target", { type: "string", demandOption: true })
|
|
1243
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan, @user, #chan:thread_ts, or permalink (a message permalink replies in its thread)" })
|
|
1023
1244
|
.positional("message", { type: "string", demandOption: true })
|
|
1024
|
-
.option("thread", { type: "string", describe: "Thread timestamp" })
|
|
1025
1245
|
.option("code", { type: "string", describe: "Safety hash to confirm send" })
|
|
1026
1246
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
1027
|
-
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
1247
|
+
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
1248
|
+
.option("as-bot", { type: "boolean", default: false, describe: "Send via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient and can be two-way" })
|
|
1249
|
+
.option("broadcast", { type: "boolean", default: false, describe: "Also send to channel: broadcast a threaded reply back to the channel (Slack's \"Also send to #channel\" checkbox). Only effective with a thread target." }),
|
|
1028
1250
|
async (argv) => {
|
|
1029
1251
|
const args: SendArgs = { target: argv.target!, message: argv.message! };
|
|
1030
|
-
if (argv.thread) args.thread = argv.thread;
|
|
1031
1252
|
if (argv.code) args.code = argv.code;
|
|
1032
1253
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
1033
1254
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
1034
|
-
|
|
1255
|
+
if (argv.broadcast) args.broadcast = true;
|
|
1256
|
+
let sendToken: string;
|
|
1257
|
+
if (argv["as-bot"]) {
|
|
1258
|
+
const botToken = resolveBotToken();
|
|
1259
|
+
if (!botToken) {
|
|
1260
|
+
console.error(
|
|
1261
|
+
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
1262
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
1263
|
+
);
|
|
1264
|
+
process.exit(1);
|
|
1265
|
+
}
|
|
1266
|
+
// Resolve @user → user_id with the *user* token (has users:read), then
|
|
1267
|
+
// let cmdSend open the DM with the bot token (im:write). This avoids
|
|
1268
|
+
// requiring users:read on the bot and guarantees the DM is bot↔user
|
|
1269
|
+
// (not the user's own self-DM).
|
|
1270
|
+
if (!args.channelId && !args.userId && args.target.startsWith("@")) {
|
|
1271
|
+
try {
|
|
1272
|
+
args.userId = await resolveUserId(tok(argv as W), args.target);
|
|
1273
|
+
} catch (e: unknown) {
|
|
1274
|
+
console.error(
|
|
1275
|
+
`Error: could not resolve ${args.target} to a user for the bot DM.\n` +
|
|
1276
|
+
` ${e instanceof Error ? e.message : String(e)}\n` +
|
|
1277
|
+
` Tip: pass --user-id <Uxxxx> to skip the lookup.`,
|
|
1278
|
+
);
|
|
1279
|
+
process.exit(1);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
sendToken = botToken;
|
|
1283
|
+
args.asBot = true;
|
|
1284
|
+
} else {
|
|
1285
|
+
sendToken = tok(argv as W);
|
|
1286
|
+
}
|
|
1287
|
+
// Print a clean error instead of yargs' usage dump when a send fails at
|
|
1288
|
+
// runtime (e.g. missing scope, channel not found). The confirm gate
|
|
1289
|
+
// exits via process.exit, so it is unaffected.
|
|
1290
|
+
try {
|
|
1291
|
+
await cmdSend(sendToken, args);
|
|
1292
|
+
} catch (e: unknown) {
|
|
1293
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
1294
|
+
process.exit(1);
|
|
1295
|
+
}
|
|
1296
|
+
},
|
|
1297
|
+
)
|
|
1298
|
+
.command(
|
|
1299
|
+
"doctor",
|
|
1300
|
+
"Check the bot token (xoxb) can carry a two-way DM (scopes + Messages Tab)",
|
|
1301
|
+
(y) => y,
|
|
1302
|
+
async () => {
|
|
1303
|
+
const botToken = resolveBotToken();
|
|
1304
|
+
if (!botToken) {
|
|
1305
|
+
console.error(
|
|
1306
|
+
"Error: slack doctor checks the bot token, but no xoxb- token was found.\n" +
|
|
1307
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
1308
|
+
);
|
|
1309
|
+
process.exit(1);
|
|
1310
|
+
}
|
|
1311
|
+
await cmdDoctor(botToken);
|
|
1035
1312
|
},
|
|
1036
1313
|
)
|
|
1037
1314
|
.command(
|
|
@@ -1042,16 +1319,14 @@ async function main(): Promise<void> {
|
|
|
1042
1319
|
"send <target> <message>",
|
|
1043
1320
|
"Schedule a message for later delivery",
|
|
1044
1321
|
(y2) => y2
|
|
1045
|
-
.positional("target", { type: "string", demandOption: true })
|
|
1322
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan, @user, #chan:thread_ts, or permalink (a message permalink replies in its thread)" })
|
|
1046
1323
|
.positional("message", { type: "string", demandOption: true })
|
|
1047
1324
|
.option("at", { type: "string", demandOption: true, describe: "Delivery time (ISO datetime or Unix ts)" })
|
|
1048
|
-
.option("thread", { type: "string", describe: "Thread timestamp" })
|
|
1049
1325
|
.option("code", { type: "string", describe: "Safety hash to confirm" })
|
|
1050
1326
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
1051
1327
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" }),
|
|
1052
1328
|
async (argv) => {
|
|
1053
1329
|
const args: ScheduleSendArgs = { target: argv.target!, message: argv.message!, at: argv.at! };
|
|
1054
|
-
if (argv.thread) args.thread = argv.thread;
|
|
1055
1330
|
if (argv.code) args.code = argv.code;
|
|
1056
1331
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
1057
1332
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
@@ -1102,14 +1377,27 @@ async function main(): Promise<void> {
|
|
|
1102
1377
|
await cmdEdit(tok(argv as W), args);
|
|
1103
1378
|
},
|
|
1104
1379
|
)
|
|
1380
|
+
.command(
|
|
1381
|
+
"delete <target>",
|
|
1382
|
+
"Delete a sent message (confirm-hash safety gate)",
|
|
1383
|
+
(y) => y
|
|
1384
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
|
|
1385
|
+
.option("code", { type: "string", describe: "Safety hash to confirm delete" })
|
|
1386
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" }),
|
|
1387
|
+
async (argv) => {
|
|
1388
|
+
const args: DeleteArgs = { target: argv.target! };
|
|
1389
|
+
if (argv.code) args.code = argv.code;
|
|
1390
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
1391
|
+
await cmdDelete(tok(argv as W), args);
|
|
1392
|
+
},
|
|
1393
|
+
)
|
|
1105
1394
|
.command(
|
|
1106
1395
|
"upload <target> <file..>",
|
|
1107
1396
|
"Upload one or more files to a channel or DM",
|
|
1108
1397
|
(y) => y
|
|
1109
|
-
.positional("target", { type: "string", demandOption: true })
|
|
1398
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan, @user, #chan:thread_ts, or permalink (a message permalink replies in its thread)" })
|
|
1110
1399
|
.positional("file", { type: "string", array: true, demandOption: true, describe: "Path(s) to file(s)" })
|
|
1111
1400
|
.option("title", { type: "string", describe: "Title (single file only)" })
|
|
1112
|
-
.option("thread", { type: "string", describe: "Thread timestamp" })
|
|
1113
1401
|
.option("comment", { type: "string", describe: "Initial comment (first file)" })
|
|
1114
1402
|
.option("code", { type: "string", describe: "4-hex safety code to confirm upload" })
|
|
1115
1403
|
.option("channel-id", { type: "string" })
|
|
@@ -1117,7 +1405,6 @@ async function main(): Promise<void> {
|
|
|
1117
1405
|
async (argv) => {
|
|
1118
1406
|
const args: UploadArgs = { target: argv.target!, filePaths: argv.file as string[] };
|
|
1119
1407
|
if (argv.title) args.title = argv.title;
|
|
1120
|
-
if (argv.thread) args.thread = argv.thread;
|
|
1121
1408
|
if (argv.comment) args.comment = argv.comment;
|
|
1122
1409
|
if (argv.code) args.code = argv.code;
|
|
1123
1410
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
@@ -1226,9 +1513,30 @@ async function main(): Promise<void> {
|
|
|
1226
1513
|
"auth",
|
|
1227
1514
|
"Authentication and workspace management",
|
|
1228
1515
|
(y) => y
|
|
1516
|
+
.command(
|
|
1517
|
+
"token",
|
|
1518
|
+
"Add a workspace — paste an existing xoxp-/xoxb- token",
|
|
1519
|
+
(y2) => y2
|
|
1520
|
+
.option("token", { type: "string", describe: "Token to save directly (non-interactive)" })
|
|
1521
|
+
.option("name", { type: "string", describe: "Workspace name (used with --token)" }),
|
|
1522
|
+
async (argv) => {
|
|
1523
|
+
await cmdAuthToken({
|
|
1524
|
+
...(argv.token !== undefined ? { token: argv.token } : {}),
|
|
1525
|
+
...(argv.name !== undefined ? { name: argv.name } : {}),
|
|
1526
|
+
});
|
|
1527
|
+
},
|
|
1528
|
+
)
|
|
1529
|
+
.command(
|
|
1530
|
+
"app",
|
|
1531
|
+
"Create a new Slack app and obtain a token (guided wizard)",
|
|
1532
|
+
(y2) => y2.option("bot", { type: "boolean", describe: "Create a bot token (xoxb-) instead of user (xoxp-)" }),
|
|
1533
|
+
async (argv) => {
|
|
1534
|
+
await cmdAuthApp({ ...(argv.bot !== undefined ? { bot: argv.bot } : {}) });
|
|
1535
|
+
},
|
|
1536
|
+
)
|
|
1229
1537
|
.command(
|
|
1230
1538
|
"login",
|
|
1231
|
-
"
|
|
1539
|
+
"Interactive auth wizard (all auth methods: desktop app, token, new app)",
|
|
1232
1540
|
(y2) => y2
|
|
1233
1541
|
.option("token", { type: "string", describe: "Token to save directly (non-interactive)" })
|
|
1234
1542
|
.option("name", { type: "string", describe: "Workspace name (used with --token)" }),
|
|
@@ -1258,7 +1566,7 @@ async function main(): Promise<void> {
|
|
|
1258
1566
|
},
|
|
1259
1567
|
)
|
|
1260
1568
|
.command(
|
|
1261
|
-
["rm <name>", "remove <name>"],
|
|
1569
|
+
["logout <name>", "rm <name>", "remove <name>"],
|
|
1262
1570
|
"Remove a workspace",
|
|
1263
1571
|
(y2) => y2.positional("name", { type: "string", demandOption: true }),
|
|
1264
1572
|
(argv) => {
|
|
@@ -1266,6 +1574,24 @@ async function main(): Promise<void> {
|
|
|
1266
1574
|
console.log(`Removed workspace "${argv.name}"`);
|
|
1267
1575
|
},
|
|
1268
1576
|
)
|
|
1577
|
+
.command(
|
|
1578
|
+
["chrome", "cookie"],
|
|
1579
|
+
"Attach Chrome browser xoxd cookie to a workspace (macOS, interactive)",
|
|
1580
|
+
(y2) => y2
|
|
1581
|
+
.option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" }),
|
|
1582
|
+
async (argv) => {
|
|
1583
|
+
await cmdAuthChrome({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) });
|
|
1584
|
+
},
|
|
1585
|
+
)
|
|
1586
|
+
.command(
|
|
1587
|
+
"firefox",
|
|
1588
|
+
"Attach Firefox browser xoxd cookie to a workspace (all platforms)",
|
|
1589
|
+
(y2) => y2
|
|
1590
|
+
.option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" }),
|
|
1591
|
+
async (argv) => {
|
|
1592
|
+
await cmdAuthFirefox({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) });
|
|
1593
|
+
},
|
|
1594
|
+
)
|
|
1269
1595
|
.command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
|
|
1270
1596
|
)
|
|
1271
1597
|
.command(
|
|
@@ -1275,8 +1601,11 @@ async function main(): Promise<void> {
|
|
|
1275
1601
|
.positional("target", { type: "string", describe: "#channel or @user to follow" })
|
|
1276
1602
|
.option("since", { type: "string", describe: "Backfill from N ago (e.g. 10m, 2h, 1d)" })
|
|
1277
1603
|
.option("thread", { type: "string", describe: "Follow a single thread by timestamp" })
|
|
1604
|
+
.option("watch-thread", { type: "string", describe: "Watch the channel's top-level timeline AND one thread's replies together (ts or permalink)" })
|
|
1278
1605
|
.option("me", { type: "boolean", default: false, describe: "Filter to messages that mention you" })
|
|
1279
1606
|
.option("interval", { type: "number", default: 60000, describe: "Poll interval in ms (default 60s; use --interval=3000 for near-real-time)" })
|
|
1607
|
+
.option("timeout", { type: "string", describe: "Auto-stop after this long (e.g. 30m, 2h). Exit code 0 even if nothing arrived." })
|
|
1608
|
+
.option("exit-on-message", { type: "boolean", default: false, describe: "Stop as soon as the first new message from someone else arrives (wait-for-reply)" })
|
|
1280
1609
|
.option("rtm", { type: "boolean", default: true, describe: "Use RTM WebSocket when available (xoxc + cookie); pass --no-rtm to force polling" }),
|
|
1281
1610
|
async (argv) => {
|
|
1282
1611
|
const token = tok(argv as W);
|
|
@@ -1286,8 +1615,11 @@ async function main(): Promise<void> {
|
|
|
1286
1615
|
await cmdTail(token, argv.target, {
|
|
1287
1616
|
...(argv.since !== undefined ? { since: argv.since } : {}),
|
|
1288
1617
|
...(argv.thread !== undefined ? { thread: argv.thread } : {}),
|
|
1618
|
+
...(argv["watch-thread"] !== undefined ? { watchThread: argv["watch-thread"] as string } : {}),
|
|
1289
1619
|
me: argv.me,
|
|
1290
1620
|
interval: argv.interval,
|
|
1621
|
+
...(argv.timeout !== undefined ? { timeout: argv.timeout } : {}),
|
|
1622
|
+
...(argv["exit-on-message"] === true ? { exitOnMessage: true } : {}),
|
|
1291
1623
|
...(cookie !== undefined ? { cookie } : {}),
|
|
1292
1624
|
...(argv.rtm === false ? { noRtm: true } : {}),
|
|
1293
1625
|
}, signal.signal);
|