slack-term 1.13.0 → 1.15.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 +87 -60
- package/package.json +6 -3
- package/ts/auth.ts +268 -13
- package/ts/botdoctor.ts +96 -0
- package/ts/cli.ts +375 -57
- package/ts/profiles.ts +165 -36
- package/ts/rtm.ts +197 -0
- package/ts/slack-app.ts +207 -3
- package/ts/slack.ts +138 -4
- package/ts/tail.ts +120 -10
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,17 +175,36 @@ 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}`;
|
|
174
189
|
}
|
|
175
190
|
|
|
176
191
|
// --- msgs <target> — channel/DM history with timestamps ---
|
|
177
192
|
async function cmdMsgsTarget(token: string, target: string, limit: number): Promise<void> {
|
|
193
|
+
const parsed = parseSlackPermalink(target);
|
|
178
194
|
const channelId = await resolveChannel(token, target);
|
|
179
|
-
const hist = (await history(token, channelId, limit)) as Record<string, Json>;
|
|
180
|
-
const msgs = asArray(hist.messages).map(asRecord);
|
|
181
195
|
const cache = new Map<string, string>();
|
|
182
|
-
|
|
183
|
-
|
|
196
|
+
if (parsed?.threadTs) {
|
|
197
|
+
const resp = (await replies(token, channelId, parsed.threadTs, limit)) as Record<string, Json>;
|
|
198
|
+
const msgs = asArray(resp.messages).map(asRecord);
|
|
199
|
+
for (const m of msgs) {
|
|
200
|
+
console.log(await formatMsgLine(token, m, cache));
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
const hist = (await history(token, channelId, limit)) as Record<string, Json>;
|
|
204
|
+
const msgs = asArray(hist.messages).map(asRecord);
|
|
205
|
+
for (const m of msgs.reverse()) {
|
|
206
|
+
console.log(await formatMsgLine(token, m, cache));
|
|
207
|
+
}
|
|
184
208
|
}
|
|
185
209
|
}
|
|
186
210
|
|
|
@@ -539,6 +563,69 @@ function splitRefTs(s: string): { ref: string; ts?: string } {
|
|
|
539
563
|
return { ref: s };
|
|
540
564
|
}
|
|
541
565
|
|
|
566
|
+
/** Parse a send/upload target that may embed a thread_ts after `:`.
|
|
567
|
+
* Accepts: `#chan:1700000000.000100`, `@user:ts`, `RAWID:ts`, Slack permalink, or plain ref.
|
|
568
|
+
* A message permalink targets that message's thread — `?thread_ts=` (the parent) wins when
|
|
569
|
+
* present, otherwise the message's own ts. A channel-only permalink stays top-level. */
|
|
570
|
+
function parseTargetThread(s: string): { ref: string; threadTs?: string } {
|
|
571
|
+
const url = parseSlackPermalink(s);
|
|
572
|
+
if (url) {
|
|
573
|
+
const threadTs = url.threadTs ?? url.ts;
|
|
574
|
+
return threadTs ? { ref: url.channel, threadTs } : { ref: url.channel };
|
|
575
|
+
}
|
|
576
|
+
const colon = s.indexOf(":");
|
|
577
|
+
if (colon > 0) {
|
|
578
|
+
const maybeTs = s.slice(colon + 1);
|
|
579
|
+
if (/^\d{10}\.\d{6}$/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: maybeTs };
|
|
580
|
+
if (/^\d{4}-\d{2}-\d{2}T/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: isoToSlackTs(maybeTs) };
|
|
581
|
+
}
|
|
582
|
+
return { ref: s };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Human-readable destination label for confirm gates, e.g. "#symval (C0B0L6SSAMD)".
|
|
586
|
+
* When ref is already a #channel/@user label, keep it; otherwise resolve the channel
|
|
587
|
+
* name via conversations.info (fail-soft: a raw ID is still unambiguous). */
|
|
588
|
+
async function destLabel(token: string, channelId: string, ref: string): Promise<string> {
|
|
589
|
+
let name = ref;
|
|
590
|
+
if (!ref.startsWith("#") && !ref.startsWith("@")) {
|
|
591
|
+
try {
|
|
592
|
+
const info = asRecord((await conversationInfo(token, channelId)) as Json);
|
|
593
|
+
const ch = asRecord(info.channel);
|
|
594
|
+
if (ch.is_im === true) {
|
|
595
|
+
const uid = typeof ch.user === "string" ? ch.user : "";
|
|
596
|
+
name = uid ? `@${await userName(token, uid)}` : channelId;
|
|
597
|
+
} else {
|
|
598
|
+
name = typeof ch.name === "string" ? `#${ch.name}` : channelId;
|
|
599
|
+
}
|
|
600
|
+
} catch {
|
|
601
|
+
name = channelId;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return name === channelId ? channelId : `${name} (${channelId})`;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** One-line thread-parent description for confirm gates: "2026-06-11 08:05 @handle: head…".
|
|
608
|
+
* Fail-soft: returns the raw ts if the parent cannot be fetched. */
|
|
609
|
+
async function threadParentLine(token: string, channelId: string, threadTs: string): Promise<string> {
|
|
610
|
+
try {
|
|
611
|
+
const resp = (await replies(token, channelId, threadTs, 1)) as Record<string, Json>;
|
|
612
|
+
const msgs = asArray(resp.messages).map(asRecord);
|
|
613
|
+
const parent = msgs.find((m) => String(m.ts) === threadTs) ?? msgs[0];
|
|
614
|
+
if (!parent) return threadTs;
|
|
615
|
+
const author = typeof parent.user === "string"
|
|
616
|
+
? await userName(token, parent.user)
|
|
617
|
+
: typeof parent.username === "string"
|
|
618
|
+
? parent.username
|
|
619
|
+
: "?";
|
|
620
|
+
const head = (typeof parent.text === "string" ? parent.text : "").split("\n")[0] ?? "";
|
|
621
|
+
const headShort = head.length > 40 ? `${head.slice(0, 40)}…` : head;
|
|
622
|
+
const stamp = slackTsToIso(threadTs).slice(0, 16).replace("T", " ");
|
|
623
|
+
return `${stamp} @${author}${headShort ? `: ${headShort}` : ""}`;
|
|
624
|
+
} catch {
|
|
625
|
+
return threadTs;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
542
629
|
// --- edit ---
|
|
543
630
|
interface EditArgs {
|
|
544
631
|
target: string;
|
|
@@ -582,48 +669,172 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
582
669
|
console.log(`✓ Edited (ts: ${newTs})`);
|
|
583
670
|
}
|
|
584
671
|
|
|
672
|
+
// --- delete ---
|
|
673
|
+
interface DeleteArgs {
|
|
674
|
+
target: string;
|
|
675
|
+
code?: string;
|
|
676
|
+
channelId?: string;
|
|
677
|
+
}
|
|
678
|
+
async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
|
|
679
|
+
const { ref, ts } = splitRefTs(args.target);
|
|
680
|
+
if (!ts) {
|
|
681
|
+
console.error("Error: target must embed a message ts (e.g. #chan:2026-05-11T06:01:04.000100 or a Slack permalink URL)");
|
|
682
|
+
process.exit(2);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
let channelId: string;
|
|
686
|
+
if (args.channelId) channelId = args.channelId;
|
|
687
|
+
else channelId = await resolveChannel(token, ref);
|
|
688
|
+
|
|
689
|
+
// Fetch the message to display what is being deleted and compute the safety hash.
|
|
690
|
+
const resp = (await replies(token, channelId, ts, 1)) as Record<string, Json>;
|
|
691
|
+
const msgs = asArray(resp.messages).map(asRecord);
|
|
692
|
+
const original = msgs.find((m) => String(m.ts) === ts);
|
|
693
|
+
if (!original) {
|
|
694
|
+
console.error(`Message not found at ts=${ts} in channel ${channelId}`);
|
|
695
|
+
process.exit(1);
|
|
696
|
+
}
|
|
697
|
+
const originalText = typeof original.text === "string" ? original.text : "";
|
|
698
|
+
|
|
699
|
+
const code = safetyCode(channelId, ts, originalText);
|
|
700
|
+
if (args.code !== code) {
|
|
701
|
+
const dest = await destLabel(token, channelId, ref);
|
|
702
|
+
requireCode(args.code, code, [
|
|
703
|
+
`--- Deleting message -------------------------`,
|
|
704
|
+
` → ${dest} at ${slackTsToIso(ts)}`,
|
|
705
|
+
...originalText.split("\n").map((l) => ` ${l}`),
|
|
706
|
+
`---------------------------------------------`,
|
|
707
|
+
]);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
await deleteMessage(token, channelId, ts);
|
|
711
|
+
console.log(`✓ Deleted (ts: ${ts})`);
|
|
712
|
+
}
|
|
713
|
+
|
|
585
714
|
// --- send ---
|
|
586
715
|
interface SendArgs {
|
|
587
716
|
target: string;
|
|
588
717
|
message: string;
|
|
589
|
-
thread?: string;
|
|
590
718
|
code?: string;
|
|
591
719
|
channelId?: string;
|
|
592
720
|
userId?: string;
|
|
721
|
+
asBot?: boolean;
|
|
722
|
+
broadcast?: boolean;
|
|
593
723
|
}
|
|
724
|
+
|
|
725
|
+
// Detect the silent-failure footgun: DMing yourself with your own user token.
|
|
726
|
+
// Slack does not notify you about messages you sent to yourself, so an
|
|
727
|
+
// escalation DM via `send @me` (or @your-own-handle) is delivered but never
|
|
728
|
+
// surfaces. Returns true when ref names the token's own user.
|
|
729
|
+
async function isSelfDm(token: string, ref: string): Promise<boolean> {
|
|
730
|
+
if (!ref.startsWith("@")) return false;
|
|
731
|
+
const name = ref.slice(1).toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
732
|
+
if (name === "me" || name === "you") return true;
|
|
733
|
+
try {
|
|
734
|
+
const self = await authTest(token);
|
|
735
|
+
const selfName = (self.user ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
736
|
+
return selfName !== "" && selfName === name;
|
|
737
|
+
} catch {
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
594
742
|
async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
743
|
+
const { ref, threadTs } = parseTargetThread(args.target);
|
|
744
|
+
|
|
745
|
+
// Guard the self-DM footgun before doing anything else, unless sending as the
|
|
746
|
+
// bot (which delivers a notifiable DM from a different identity).
|
|
747
|
+
if (!args.asBot && !args.channelId && !args.userId && await isSelfDm(token, ref)) {
|
|
748
|
+
console.error(
|
|
749
|
+
`Warning: "${ref}" is a DM to yourself — Slack will NOT notify you of your own message.\n` +
|
|
750
|
+
` To reach yourself with a notification, send as the bot: slack send '${ref}' '...' --as-bot`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
595
754
|
let channelId: string;
|
|
596
755
|
if (args.channelId) channelId = args.channelId;
|
|
597
756
|
else if (args.userId) channelId = await openDm(token, args.userId);
|
|
598
|
-
else
|
|
599
|
-
channelId = await resolveChannel(token, args.target);
|
|
600
|
-
} else {
|
|
601
|
-
console.error(`Error: target must be #channel-name or @username (got: ${args.target})`);
|
|
602
|
-
console.error("Use --channel-id=<ID> or --user-id=<ID> to send by raw ID.");
|
|
603
|
-
process.exit(1);
|
|
604
|
-
}
|
|
757
|
+
else channelId = await resolveChannel(token, ref);
|
|
605
758
|
|
|
606
|
-
// Fetch last 1 message for context hash
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
759
|
+
// Fetch last 1 message for context hash. Fail-soft: a bot token without
|
|
760
|
+
// im:history (or channels:history) can still send — only the preview of the
|
|
761
|
+
// prior message is lost, so default to empty rather than blocking the send.
|
|
762
|
+
let lastText = "";
|
|
763
|
+
let lastUser = "?";
|
|
764
|
+
try {
|
|
765
|
+
const ctx = (await history(token, channelId, 1)) as Record<string, Json>;
|
|
766
|
+
const lastMsg = asArray(ctx.messages).map(asRecord)
|
|
767
|
+
.filter((m) => m.subtype === undefined || m.subtype === null)[0];
|
|
768
|
+
lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
|
|
769
|
+
// Resolve the author's user ID to a display name for the preview; fall back
|
|
770
|
+
// to the bot username, then the raw ID. userName() is fail-soft.
|
|
771
|
+
lastUser = typeof lastMsg?.user === "string"
|
|
772
|
+
? await userName(token, lastMsg.user)
|
|
773
|
+
: typeof lastMsg?.username === "string"
|
|
774
|
+
? lastMsg.username
|
|
775
|
+
: "?";
|
|
776
|
+
} catch {
|
|
777
|
+
// best-effort preview only
|
|
778
|
+
}
|
|
612
779
|
|
|
613
|
-
|
|
780
|
+
// Hash covers the destination too — a code minted for one channel/thread
|
|
781
|
+
// cannot confirm a send to another.
|
|
782
|
+
const code = safetyCode(channelId, threadTs ?? "", lastText, args.message);
|
|
614
783
|
|
|
615
784
|
if (args.code !== code) {
|
|
785
|
+
const dest = await destLabel(token, channelId, ref);
|
|
786
|
+
const destLine = threadTs
|
|
787
|
+
? ` → ${dest} thread of ${await threadParentLine(token, channelId, threadTs)} — THREAD REPLY`
|
|
788
|
+
: ` → ${dest} — NEW top-level message`;
|
|
616
789
|
requireCode(args.code, code, [
|
|
617
790
|
`--- Last message in channel ------------------`,
|
|
618
791
|
` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
|
|
619
792
|
`--- Sending ----------------------------------`,
|
|
620
|
-
|
|
793
|
+
destLine,
|
|
621
794
|
` Message: ${args.message}`,
|
|
622
795
|
`--------------------------------────────────`,
|
|
623
796
|
]);
|
|
624
797
|
}
|
|
625
|
-
const ts = await slackSend(token, channelId, args.message, args.
|
|
626
|
-
|
|
798
|
+
const ts = await slackSend(token, channelId, args.message, threadTs, args.broadcast);
|
|
799
|
+
let permalink = "";
|
|
800
|
+
try {
|
|
801
|
+
permalink = await getPermalink(token, channelId, ts);
|
|
802
|
+
} catch {
|
|
803
|
+
// fail-soft: getPermalink failure (rate limit, network, etc.) should not
|
|
804
|
+
// mask send success — fall back to ts-only output below.
|
|
805
|
+
}
|
|
806
|
+
if (permalink) {
|
|
807
|
+
console.log(`✓ Sent: ${permalink}`);
|
|
808
|
+
} else {
|
|
809
|
+
console.log(`✓ Sent (ts: ${ts})`);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// After a bot DM, check the app can actually carry a two-way conversation —
|
|
813
|
+
// an escalation the user can't reply to (Messages Tab off / missing scopes)
|
|
814
|
+
// is worse than useless. Non-fatal: the send already succeeded.
|
|
815
|
+
if (args.asBot && channelId.startsWith("D")) {
|
|
816
|
+
try {
|
|
817
|
+
const diag = await diagnoseBotMessaging(token);
|
|
818
|
+
if (!diag.ok) {
|
|
819
|
+
for (const line of formatDiagnosis(diag, true)) console.error(line);
|
|
820
|
+
} else {
|
|
821
|
+
// Scopes are fine, but the Messages Tab toggle isn't API-detectable.
|
|
822
|
+
const appUrl = diag.appId ? `https://api.slack.com/apps/${diag.appId}` : "https://api.slack.com/apps";
|
|
823
|
+
console.error(
|
|
824
|
+
`Note: if ${args.target} can't reply (app messaging may be off), ` +
|
|
825
|
+
`enable Messages Tab: ${appUrl} → App Home.`,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
} catch {
|
|
829
|
+
// diagnosis is best-effort; never turn a successful send into a failure.
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function cmdDoctor(token: string): Promise<void> {
|
|
835
|
+
const diag = await diagnoseBotMessaging(token);
|
|
836
|
+
for (const line of formatDiagnosis(diag, false)) console.log(line);
|
|
837
|
+
if (!diag.ok) process.exit(1);
|
|
627
838
|
}
|
|
628
839
|
|
|
629
840
|
// --- schedule ---
|
|
@@ -638,21 +849,17 @@ interface ScheduleSendArgs {
|
|
|
638
849
|
target: string;
|
|
639
850
|
message: string;
|
|
640
851
|
at: string;
|
|
641
|
-
thread?: string;
|
|
642
852
|
code?: string;
|
|
643
853
|
channelId?: string;
|
|
644
854
|
userId?: string;
|
|
645
855
|
}
|
|
646
856
|
async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
|
|
857
|
+
const { ref, threadTs } = parseTargetThread(args.target);
|
|
858
|
+
|
|
647
859
|
let channelId: string;
|
|
648
860
|
if (args.channelId) channelId = args.channelId;
|
|
649
861
|
else if (args.userId) channelId = await openDm(token, args.userId);
|
|
650
|
-
else
|
|
651
|
-
channelId = await resolveChannel(token, args.target);
|
|
652
|
-
} else {
|
|
653
|
-
console.error(`Error: target must be #channel-name or @username (got: ${args.target})`);
|
|
654
|
-
process.exit(1);
|
|
655
|
-
}
|
|
862
|
+
else channelId = await resolveChannel(token, ref);
|
|
656
863
|
|
|
657
864
|
const postAt = parsePostAt(args.at);
|
|
658
865
|
const postAtDate = new Date(postAt * 1000).toISOString();
|
|
@@ -661,13 +868,13 @@ async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<v
|
|
|
661
868
|
if (args.code !== code) {
|
|
662
869
|
requireCode(args.code, code, [
|
|
663
870
|
`--- Scheduling message -----------------------`,
|
|
664
|
-
` To: ${
|
|
871
|
+
` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`,
|
|
665
872
|
` At: ${postAtDate} (Unix: ${postAt})`,
|
|
666
873
|
` Message: ${args.message}`,
|
|
667
874
|
`---------------------------------------------`,
|
|
668
875
|
]);
|
|
669
876
|
}
|
|
670
|
-
const id = await scheduleMessage(token, channelId, args.message, postAt,
|
|
877
|
+
const id = await scheduleMessage(token, channelId, args.message, postAt, threadTs);
|
|
671
878
|
console.log(`✓ Scheduled (id: ${id}, at: ${postAtDate})`);
|
|
672
879
|
}
|
|
673
880
|
|
|
@@ -718,7 +925,6 @@ interface UploadArgs {
|
|
|
718
925
|
target: string;
|
|
719
926
|
filePaths: string[];
|
|
720
927
|
title?: string;
|
|
721
|
-
thread?: string;
|
|
722
928
|
comment?: string;
|
|
723
929
|
code?: string;
|
|
724
930
|
channelId?: string;
|
|
@@ -735,15 +941,12 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
735
941
|
}
|
|
736
942
|
}
|
|
737
943
|
|
|
944
|
+
const { ref, threadTs } = parseTargetThread(args.target);
|
|
945
|
+
|
|
738
946
|
let channelId: string;
|
|
739
947
|
if (args.channelId) channelId = args.channelId;
|
|
740
948
|
else if (args.userId) channelId = await openDm(token, args.userId);
|
|
741
|
-
else
|
|
742
|
-
channelId = await resolveChannel(token, args.target);
|
|
743
|
-
} else {
|
|
744
|
-
console.error(`Error: target must be #channel-name or @username (got: ${args.target})`);
|
|
745
|
-
process.exit(1);
|
|
746
|
-
}
|
|
949
|
+
else channelId = await resolveChannel(token, ref);
|
|
747
950
|
|
|
748
951
|
function fmtSize(n: number): string {
|
|
749
952
|
return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
@@ -759,7 +962,7 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
759
962
|
// Safety code covers the full batch — single-file code is identical to the old formula.
|
|
760
963
|
const code = safetyCode(channelId, ...files.flatMap((f) => [f.fp, f.title]));
|
|
761
964
|
if (args.code !== code) {
|
|
762
|
-
const destLine = ` To: ${
|
|
965
|
+
const destLine = ` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`;
|
|
763
966
|
const lines = isBatch
|
|
764
967
|
? [
|
|
765
968
|
`--- Uploading ${files.length} files ------------------------`,
|
|
@@ -782,7 +985,7 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
782
985
|
for (let i = 0; i < total; i++) {
|
|
783
986
|
const f = files[i]!;
|
|
784
987
|
const uploadOpts: { title?: string; threadTs?: string; initialComment?: string } = { title: f.title };
|
|
785
|
-
if (
|
|
988
|
+
if (threadTs !== undefined) uploadOpts.threadTs = threadTs;
|
|
786
989
|
if (args.comment !== undefined && i === 0) uploadOpts.initialComment = args.comment;
|
|
787
990
|
const { fileId, permalink } = await uploadFile(token, channelId, f.fp, uploadOpts);
|
|
788
991
|
const prefix = total > 1 ? `[${i + 1}/${total}] ` : "";
|
|
@@ -1010,19 +1213,75 @@ async function main(): Promise<void> {
|
|
|
1010
1213
|
"send <target> <message>",
|
|
1011
1214
|
"Send a message (confirm-hash safety gate)",
|
|
1012
1215
|
(y) => y
|
|
1013
|
-
.positional("target", { type: "string", demandOption: true })
|
|
1216
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan, @user, #chan:thread_ts, or permalink (a message permalink replies in its thread)" })
|
|
1014
1217
|
.positional("message", { type: "string", demandOption: true })
|
|
1015
|
-
.option("thread", { type: "string", describe: "Thread timestamp" })
|
|
1016
1218
|
.option("code", { type: "string", describe: "Safety hash to confirm send" })
|
|
1017
1219
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
1018
|
-
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
1220
|
+
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
1221
|
+
.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" })
|
|
1222
|
+
.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." }),
|
|
1019
1223
|
async (argv) => {
|
|
1020
1224
|
const args: SendArgs = { target: argv.target!, message: argv.message! };
|
|
1021
|
-
if (argv.thread) args.thread = argv.thread;
|
|
1022
1225
|
if (argv.code) args.code = argv.code;
|
|
1023
1226
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
1024
1227
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
1025
|
-
|
|
1228
|
+
if (argv.broadcast) args.broadcast = true;
|
|
1229
|
+
let sendToken: string;
|
|
1230
|
+
if (argv["as-bot"]) {
|
|
1231
|
+
const botToken = resolveBotToken();
|
|
1232
|
+
if (!botToken) {
|
|
1233
|
+
console.error(
|
|
1234
|
+
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
1235
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
1236
|
+
);
|
|
1237
|
+
process.exit(1);
|
|
1238
|
+
}
|
|
1239
|
+
// Resolve @user → user_id with the *user* token (has users:read), then
|
|
1240
|
+
// let cmdSend open the DM with the bot token (im:write). This avoids
|
|
1241
|
+
// requiring users:read on the bot and guarantees the DM is bot↔user
|
|
1242
|
+
// (not the user's own self-DM).
|
|
1243
|
+
if (!args.channelId && !args.userId && args.target.startsWith("@")) {
|
|
1244
|
+
try {
|
|
1245
|
+
args.userId = await resolveUserId(tok(argv as W), args.target);
|
|
1246
|
+
} catch (e: unknown) {
|
|
1247
|
+
console.error(
|
|
1248
|
+
`Error: could not resolve ${args.target} to a user for the bot DM.\n` +
|
|
1249
|
+
` ${e instanceof Error ? e.message : String(e)}\n` +
|
|
1250
|
+
` Tip: pass --user-id <Uxxxx> to skip the lookup.`,
|
|
1251
|
+
);
|
|
1252
|
+
process.exit(1);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
sendToken = botToken;
|
|
1256
|
+
args.asBot = true;
|
|
1257
|
+
} else {
|
|
1258
|
+
sendToken = tok(argv as W);
|
|
1259
|
+
}
|
|
1260
|
+
// Print a clean error instead of yargs' usage dump when a send fails at
|
|
1261
|
+
// runtime (e.g. missing scope, channel not found). The confirm gate
|
|
1262
|
+
// exits via process.exit, so it is unaffected.
|
|
1263
|
+
try {
|
|
1264
|
+
await cmdSend(sendToken, args);
|
|
1265
|
+
} catch (e: unknown) {
|
|
1266
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
1267
|
+
process.exit(1);
|
|
1268
|
+
}
|
|
1269
|
+
},
|
|
1270
|
+
)
|
|
1271
|
+
.command(
|
|
1272
|
+
"doctor",
|
|
1273
|
+
"Check the bot token (xoxb) can carry a two-way DM (scopes + Messages Tab)",
|
|
1274
|
+
(y) => y,
|
|
1275
|
+
async () => {
|
|
1276
|
+
const botToken = resolveBotToken();
|
|
1277
|
+
if (!botToken) {
|
|
1278
|
+
console.error(
|
|
1279
|
+
"Error: slack doctor checks the bot token, but no xoxb- token was found.\n" +
|
|
1280
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
1281
|
+
);
|
|
1282
|
+
process.exit(1);
|
|
1283
|
+
}
|
|
1284
|
+
await cmdDoctor(botToken);
|
|
1026
1285
|
},
|
|
1027
1286
|
)
|
|
1028
1287
|
.command(
|
|
@@ -1033,16 +1292,14 @@ async function main(): Promise<void> {
|
|
|
1033
1292
|
"send <target> <message>",
|
|
1034
1293
|
"Schedule a message for later delivery",
|
|
1035
1294
|
(y2) => y2
|
|
1036
|
-
.positional("target", { type: "string", demandOption: true })
|
|
1295
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan, @user, #chan:thread_ts, or permalink (a message permalink replies in its thread)" })
|
|
1037
1296
|
.positional("message", { type: "string", demandOption: true })
|
|
1038
1297
|
.option("at", { type: "string", demandOption: true, describe: "Delivery time (ISO datetime or Unix ts)" })
|
|
1039
|
-
.option("thread", { type: "string", describe: "Thread timestamp" })
|
|
1040
1298
|
.option("code", { type: "string", describe: "Safety hash to confirm" })
|
|
1041
1299
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
1042
1300
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" }),
|
|
1043
1301
|
async (argv) => {
|
|
1044
1302
|
const args: ScheduleSendArgs = { target: argv.target!, message: argv.message!, at: argv.at! };
|
|
1045
|
-
if (argv.thread) args.thread = argv.thread;
|
|
1046
1303
|
if (argv.code) args.code = argv.code;
|
|
1047
1304
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
1048
1305
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
@@ -1093,14 +1350,27 @@ async function main(): Promise<void> {
|
|
|
1093
1350
|
await cmdEdit(tok(argv as W), args);
|
|
1094
1351
|
},
|
|
1095
1352
|
)
|
|
1353
|
+
.command(
|
|
1354
|
+
"delete <target>",
|
|
1355
|
+
"Delete a sent message (confirm-hash safety gate)",
|
|
1356
|
+
(y) => y
|
|
1357
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
|
|
1358
|
+
.option("code", { type: "string", describe: "Safety hash to confirm delete" })
|
|
1359
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" }),
|
|
1360
|
+
async (argv) => {
|
|
1361
|
+
const args: DeleteArgs = { target: argv.target! };
|
|
1362
|
+
if (argv.code) args.code = argv.code;
|
|
1363
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
1364
|
+
await cmdDelete(tok(argv as W), args);
|
|
1365
|
+
},
|
|
1366
|
+
)
|
|
1096
1367
|
.command(
|
|
1097
1368
|
"upload <target> <file..>",
|
|
1098
1369
|
"Upload one or more files to a channel or DM",
|
|
1099
1370
|
(y) => y
|
|
1100
|
-
.positional("target", { type: "string", demandOption: true })
|
|
1371
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan, @user, #chan:thread_ts, or permalink (a message permalink replies in its thread)" })
|
|
1101
1372
|
.positional("file", { type: "string", array: true, demandOption: true, describe: "Path(s) to file(s)" })
|
|
1102
1373
|
.option("title", { type: "string", describe: "Title (single file only)" })
|
|
1103
|
-
.option("thread", { type: "string", describe: "Thread timestamp" })
|
|
1104
1374
|
.option("comment", { type: "string", describe: "Initial comment (first file)" })
|
|
1105
1375
|
.option("code", { type: "string", describe: "4-hex safety code to confirm upload" })
|
|
1106
1376
|
.option("channel-id", { type: "string" })
|
|
@@ -1108,7 +1378,6 @@ async function main(): Promise<void> {
|
|
|
1108
1378
|
async (argv) => {
|
|
1109
1379
|
const args: UploadArgs = { target: argv.target!, filePaths: argv.file as string[] };
|
|
1110
1380
|
if (argv.title) args.title = argv.title;
|
|
1111
|
-
if (argv.thread) args.thread = argv.thread;
|
|
1112
1381
|
if (argv.comment) args.comment = argv.comment;
|
|
1113
1382
|
if (argv.code) args.code = argv.code;
|
|
1114
1383
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
@@ -1217,9 +1486,30 @@ async function main(): Promise<void> {
|
|
|
1217
1486
|
"auth",
|
|
1218
1487
|
"Authentication and workspace management",
|
|
1219
1488
|
(y) => y
|
|
1489
|
+
.command(
|
|
1490
|
+
"token",
|
|
1491
|
+
"Add a workspace — paste an existing xoxp-/xoxb- token",
|
|
1492
|
+
(y2) => y2
|
|
1493
|
+
.option("token", { type: "string", describe: "Token to save directly (non-interactive)" })
|
|
1494
|
+
.option("name", { type: "string", describe: "Workspace name (used with --token)" }),
|
|
1495
|
+
async (argv) => {
|
|
1496
|
+
await cmdAuthToken({
|
|
1497
|
+
...(argv.token !== undefined ? { token: argv.token } : {}),
|
|
1498
|
+
...(argv.name !== undefined ? { name: argv.name } : {}),
|
|
1499
|
+
});
|
|
1500
|
+
},
|
|
1501
|
+
)
|
|
1502
|
+
.command(
|
|
1503
|
+
"app",
|
|
1504
|
+
"Create a new Slack app and obtain a token (guided wizard)",
|
|
1505
|
+
(y2) => y2.option("bot", { type: "boolean", describe: "Create a bot token (xoxb-) instead of user (xoxp-)" }),
|
|
1506
|
+
async (argv) => {
|
|
1507
|
+
await cmdAuthApp({ ...(argv.bot !== undefined ? { bot: argv.bot } : {}) });
|
|
1508
|
+
},
|
|
1509
|
+
)
|
|
1220
1510
|
.command(
|
|
1221
1511
|
"login",
|
|
1222
|
-
"
|
|
1512
|
+
"Interactive auth wizard (all auth methods: desktop app, token, new app)",
|
|
1223
1513
|
(y2) => y2
|
|
1224
1514
|
.option("token", { type: "string", describe: "Token to save directly (non-interactive)" })
|
|
1225
1515
|
.option("name", { type: "string", describe: "Workspace name (used with --token)" }),
|
|
@@ -1249,7 +1539,7 @@ async function main(): Promise<void> {
|
|
|
1249
1539
|
},
|
|
1250
1540
|
)
|
|
1251
1541
|
.command(
|
|
1252
|
-
["rm <name>", "remove <name>"],
|
|
1542
|
+
["logout <name>", "rm <name>", "remove <name>"],
|
|
1253
1543
|
"Remove a workspace",
|
|
1254
1544
|
(y2) => y2.positional("name", { type: "string", demandOption: true }),
|
|
1255
1545
|
(argv) => {
|
|
@@ -1257,6 +1547,24 @@ async function main(): Promise<void> {
|
|
|
1257
1547
|
console.log(`Removed workspace "${argv.name}"`);
|
|
1258
1548
|
},
|
|
1259
1549
|
)
|
|
1550
|
+
.command(
|
|
1551
|
+
["chrome", "cookie"],
|
|
1552
|
+
"Attach Chrome browser xoxd cookie to a workspace (macOS, interactive)",
|
|
1553
|
+
(y2) => y2
|
|
1554
|
+
.option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" }),
|
|
1555
|
+
async (argv) => {
|
|
1556
|
+
await cmdAuthChrome({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) });
|
|
1557
|
+
},
|
|
1558
|
+
)
|
|
1559
|
+
.command(
|
|
1560
|
+
"firefox",
|
|
1561
|
+
"Attach Firefox browser xoxd cookie to a workspace (all platforms)",
|
|
1562
|
+
(y2) => y2
|
|
1563
|
+
.option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" }),
|
|
1564
|
+
async (argv) => {
|
|
1565
|
+
await cmdAuthFirefox({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) });
|
|
1566
|
+
},
|
|
1567
|
+
)
|
|
1260
1568
|
.command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
|
|
1261
1569
|
)
|
|
1262
1570
|
.command(
|
|
@@ -1266,17 +1574,27 @@ async function main(): Promise<void> {
|
|
|
1266
1574
|
.positional("target", { type: "string", describe: "#channel or @user to follow" })
|
|
1267
1575
|
.option("since", { type: "string", describe: "Backfill from N ago (e.g. 10m, 2h, 1d)" })
|
|
1268
1576
|
.option("thread", { type: "string", describe: "Follow a single thread by timestamp" })
|
|
1577
|
+
.option("watch-thread", { type: "string", describe: "Watch the channel's top-level timeline AND one thread's replies together (ts or permalink)" })
|
|
1269
1578
|
.option("me", { type: "boolean", default: false, describe: "Filter to messages that mention you" })
|
|
1270
|
-
.option("interval", { type: "number", default: 60000, describe: "Poll interval in ms (default 60s; use --interval=3000 for near-real-time)" })
|
|
1579
|
+
.option("interval", { type: "number", default: 60000, describe: "Poll interval in ms (default 60s; use --interval=3000 for near-real-time)" })
|
|
1580
|
+
.option("timeout", { type: "string", describe: "Auto-stop after this long (e.g. 30m, 2h). Exit code 0 even if nothing arrived." })
|
|
1581
|
+
.option("exit-on-message", { type: "boolean", default: false, describe: "Stop as soon as the first new message from someone else arrives (wait-for-reply)" })
|
|
1582
|
+
.option("rtm", { type: "boolean", default: true, describe: "Use RTM WebSocket when available (xoxc + cookie); pass --no-rtm to force polling" }),
|
|
1271
1583
|
async (argv) => {
|
|
1272
1584
|
const token = tok(argv as W);
|
|
1585
|
+
const cookie = ck(argv as W);
|
|
1273
1586
|
const signal = new AbortController();
|
|
1274
1587
|
process.on("SIGINT", () => { signal.abort(); process.exit(0); });
|
|
1275
1588
|
await cmdTail(token, argv.target, {
|
|
1276
1589
|
...(argv.since !== undefined ? { since: argv.since } : {}),
|
|
1277
1590
|
...(argv.thread !== undefined ? { thread: argv.thread } : {}),
|
|
1591
|
+
...(argv["watch-thread"] !== undefined ? { watchThread: argv["watch-thread"] as string } : {}),
|
|
1278
1592
|
me: argv.me,
|
|
1279
1593
|
interval: argv.interval,
|
|
1594
|
+
...(argv.timeout !== undefined ? { timeout: argv.timeout } : {}),
|
|
1595
|
+
...(argv["exit-on-message"] === true ? { exitOnMessage: true } : {}),
|
|
1596
|
+
...(cookie !== undefined ? { cookie } : {}),
|
|
1597
|
+
...(argv.rtm === false ? { noRtm: true } : {}),
|
|
1280
1598
|
}, signal.signal);
|
|
1281
1599
|
},
|
|
1282
1600
|
)
|