slack-term 1.14.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 +82 -64
- package/package.json +6 -3
- package/ts/auth.ts +268 -13
- package/ts/botdoctor.ts +96 -0
- package/ts/cli.ts +357 -52
- 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,7 +175,17 @@ 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 ---
|
|
@@ -548,6 +563,69 @@ function splitRefTs(s: string): { ref: string; ts?: string } {
|
|
|
548
563
|
return { ref: s };
|
|
549
564
|
}
|
|
550
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
|
+
|
|
551
629
|
// --- edit ---
|
|
552
630
|
interface EditArgs {
|
|
553
631
|
target: string;
|
|
@@ -591,48 +669,172 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
591
669
|
console.log(`✓ Edited (ts: ${newTs})`);
|
|
592
670
|
}
|
|
593
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
|
+
|
|
594
714
|
// --- send ---
|
|
595
715
|
interface SendArgs {
|
|
596
716
|
target: string;
|
|
597
717
|
message: string;
|
|
598
|
-
thread?: string;
|
|
599
718
|
code?: string;
|
|
600
719
|
channelId?: string;
|
|
601
720
|
userId?: string;
|
|
721
|
+
asBot?: boolean;
|
|
722
|
+
broadcast?: boolean;
|
|
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
|
+
}
|
|
602
740
|
}
|
|
741
|
+
|
|
603
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
|
+
|
|
604
754
|
let channelId: string;
|
|
605
755
|
if (args.channelId) channelId = args.channelId;
|
|
606
756
|
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
|
-
}
|
|
757
|
+
else channelId = await resolveChannel(token, ref);
|
|
614
758
|
|
|
615
|
-
// Fetch last 1 message for context hash
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
+
}
|
|
621
779
|
|
|
622
|
-
|
|
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);
|
|
623
783
|
|
|
624
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`;
|
|
625
789
|
requireCode(args.code, code, [
|
|
626
790
|
`--- Last message in channel ------------------`,
|
|
627
791
|
` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
|
|
628
792
|
`--- Sending ----------------------------------`,
|
|
629
|
-
|
|
793
|
+
destLine,
|
|
630
794
|
` Message: ${args.message}`,
|
|
631
795
|
`--------------------------------────────────`,
|
|
632
796
|
]);
|
|
633
797
|
}
|
|
634
|
-
const ts = await slackSend(token, channelId, args.message, args.
|
|
635
|
-
|
|
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);
|
|
636
838
|
}
|
|
637
839
|
|
|
638
840
|
// --- schedule ---
|
|
@@ -647,21 +849,17 @@ interface ScheduleSendArgs {
|
|
|
647
849
|
target: string;
|
|
648
850
|
message: string;
|
|
649
851
|
at: string;
|
|
650
|
-
thread?: string;
|
|
651
852
|
code?: string;
|
|
652
853
|
channelId?: string;
|
|
653
854
|
userId?: string;
|
|
654
855
|
}
|
|
655
856
|
async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
|
|
857
|
+
const { ref, threadTs } = parseTargetThread(args.target);
|
|
858
|
+
|
|
656
859
|
let channelId: string;
|
|
657
860
|
if (args.channelId) channelId = args.channelId;
|
|
658
861
|
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
|
-
}
|
|
862
|
+
else channelId = await resolveChannel(token, ref);
|
|
665
863
|
|
|
666
864
|
const postAt = parsePostAt(args.at);
|
|
667
865
|
const postAtDate = new Date(postAt * 1000).toISOString();
|
|
@@ -670,13 +868,13 @@ async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<v
|
|
|
670
868
|
if (args.code !== code) {
|
|
671
869
|
requireCode(args.code, code, [
|
|
672
870
|
`--- Scheduling message -----------------------`,
|
|
673
|
-
` To: ${
|
|
871
|
+
` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`,
|
|
674
872
|
` At: ${postAtDate} (Unix: ${postAt})`,
|
|
675
873
|
` Message: ${args.message}`,
|
|
676
874
|
`---------------------------------------------`,
|
|
677
875
|
]);
|
|
678
876
|
}
|
|
679
|
-
const id = await scheduleMessage(token, channelId, args.message, postAt,
|
|
877
|
+
const id = await scheduleMessage(token, channelId, args.message, postAt, threadTs);
|
|
680
878
|
console.log(`✓ Scheduled (id: ${id}, at: ${postAtDate})`);
|
|
681
879
|
}
|
|
682
880
|
|
|
@@ -727,7 +925,6 @@ interface UploadArgs {
|
|
|
727
925
|
target: string;
|
|
728
926
|
filePaths: string[];
|
|
729
927
|
title?: string;
|
|
730
|
-
thread?: string;
|
|
731
928
|
comment?: string;
|
|
732
929
|
code?: string;
|
|
733
930
|
channelId?: string;
|
|
@@ -744,15 +941,12 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
744
941
|
}
|
|
745
942
|
}
|
|
746
943
|
|
|
944
|
+
const { ref, threadTs } = parseTargetThread(args.target);
|
|
945
|
+
|
|
747
946
|
let channelId: string;
|
|
748
947
|
if (args.channelId) channelId = args.channelId;
|
|
749
948
|
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
|
-
}
|
|
949
|
+
else channelId = await resolveChannel(token, ref);
|
|
756
950
|
|
|
757
951
|
function fmtSize(n: number): string {
|
|
758
952
|
return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
@@ -768,7 +962,7 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
768
962
|
// Safety code covers the full batch — single-file code is identical to the old formula.
|
|
769
963
|
const code = safetyCode(channelId, ...files.flatMap((f) => [f.fp, f.title]));
|
|
770
964
|
if (args.code !== code) {
|
|
771
|
-
const destLine = ` To: ${
|
|
965
|
+
const destLine = ` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`;
|
|
772
966
|
const lines = isBatch
|
|
773
967
|
? [
|
|
774
968
|
`--- Uploading ${files.length} files ------------------------`,
|
|
@@ -791,7 +985,7 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
791
985
|
for (let i = 0; i < total; i++) {
|
|
792
986
|
const f = files[i]!;
|
|
793
987
|
const uploadOpts: { title?: string; threadTs?: string; initialComment?: string } = { title: f.title };
|
|
794
|
-
if (
|
|
988
|
+
if (threadTs !== undefined) uploadOpts.threadTs = threadTs;
|
|
795
989
|
if (args.comment !== undefined && i === 0) uploadOpts.initialComment = args.comment;
|
|
796
990
|
const { fileId, permalink } = await uploadFile(token, channelId, f.fp, uploadOpts);
|
|
797
991
|
const prefix = total > 1 ? `[${i + 1}/${total}] ` : "";
|
|
@@ -1019,19 +1213,75 @@ async function main(): Promise<void> {
|
|
|
1019
1213
|
"send <target> <message>",
|
|
1020
1214
|
"Send a message (confirm-hash safety gate)",
|
|
1021
1215
|
(y) => y
|
|
1022
|
-
.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)" })
|
|
1023
1217
|
.positional("message", { type: "string", demandOption: true })
|
|
1024
|
-
.option("thread", { type: "string", describe: "Thread timestamp" })
|
|
1025
1218
|
.option("code", { type: "string", describe: "Safety hash to confirm send" })
|
|
1026
1219
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
1027
|
-
.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." }),
|
|
1028
1223
|
async (argv) => {
|
|
1029
1224
|
const args: SendArgs = { target: argv.target!, message: argv.message! };
|
|
1030
|
-
if (argv.thread) args.thread = argv.thread;
|
|
1031
1225
|
if (argv.code) args.code = argv.code;
|
|
1032
1226
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
1033
1227
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
1034
|
-
|
|
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);
|
|
1035
1285
|
},
|
|
1036
1286
|
)
|
|
1037
1287
|
.command(
|
|
@@ -1042,16 +1292,14 @@ async function main(): Promise<void> {
|
|
|
1042
1292
|
"send <target> <message>",
|
|
1043
1293
|
"Schedule a message for later delivery",
|
|
1044
1294
|
(y2) => y2
|
|
1045
|
-
.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)" })
|
|
1046
1296
|
.positional("message", { type: "string", demandOption: true })
|
|
1047
1297
|
.option("at", { type: "string", demandOption: true, describe: "Delivery time (ISO datetime or Unix ts)" })
|
|
1048
|
-
.option("thread", { type: "string", describe: "Thread timestamp" })
|
|
1049
1298
|
.option("code", { type: "string", describe: "Safety hash to confirm" })
|
|
1050
1299
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
1051
1300
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" }),
|
|
1052
1301
|
async (argv) => {
|
|
1053
1302
|
const args: ScheduleSendArgs = { target: argv.target!, message: argv.message!, at: argv.at! };
|
|
1054
|
-
if (argv.thread) args.thread = argv.thread;
|
|
1055
1303
|
if (argv.code) args.code = argv.code;
|
|
1056
1304
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
1057
1305
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
@@ -1102,14 +1350,27 @@ async function main(): Promise<void> {
|
|
|
1102
1350
|
await cmdEdit(tok(argv as W), args);
|
|
1103
1351
|
},
|
|
1104
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
|
+
)
|
|
1105
1367
|
.command(
|
|
1106
1368
|
"upload <target> <file..>",
|
|
1107
1369
|
"Upload one or more files to a channel or DM",
|
|
1108
1370
|
(y) => y
|
|
1109
|
-
.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)" })
|
|
1110
1372
|
.positional("file", { type: "string", array: true, demandOption: true, describe: "Path(s) to file(s)" })
|
|
1111
1373
|
.option("title", { type: "string", describe: "Title (single file only)" })
|
|
1112
|
-
.option("thread", { type: "string", describe: "Thread timestamp" })
|
|
1113
1374
|
.option("comment", { type: "string", describe: "Initial comment (first file)" })
|
|
1114
1375
|
.option("code", { type: "string", describe: "4-hex safety code to confirm upload" })
|
|
1115
1376
|
.option("channel-id", { type: "string" })
|
|
@@ -1117,7 +1378,6 @@ async function main(): Promise<void> {
|
|
|
1117
1378
|
async (argv) => {
|
|
1118
1379
|
const args: UploadArgs = { target: argv.target!, filePaths: argv.file as string[] };
|
|
1119
1380
|
if (argv.title) args.title = argv.title;
|
|
1120
|
-
if (argv.thread) args.thread = argv.thread;
|
|
1121
1381
|
if (argv.comment) args.comment = argv.comment;
|
|
1122
1382
|
if (argv.code) args.code = argv.code;
|
|
1123
1383
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
@@ -1226,9 +1486,30 @@ async function main(): Promise<void> {
|
|
|
1226
1486
|
"auth",
|
|
1227
1487
|
"Authentication and workspace management",
|
|
1228
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
|
+
)
|
|
1229
1510
|
.command(
|
|
1230
1511
|
"login",
|
|
1231
|
-
"
|
|
1512
|
+
"Interactive auth wizard (all auth methods: desktop app, token, new app)",
|
|
1232
1513
|
(y2) => y2
|
|
1233
1514
|
.option("token", { type: "string", describe: "Token to save directly (non-interactive)" })
|
|
1234
1515
|
.option("name", { type: "string", describe: "Workspace name (used with --token)" }),
|
|
@@ -1258,7 +1539,7 @@ async function main(): Promise<void> {
|
|
|
1258
1539
|
},
|
|
1259
1540
|
)
|
|
1260
1541
|
.command(
|
|
1261
|
-
["rm <name>", "remove <name>"],
|
|
1542
|
+
["logout <name>", "rm <name>", "remove <name>"],
|
|
1262
1543
|
"Remove a workspace",
|
|
1263
1544
|
(y2) => y2.positional("name", { type: "string", demandOption: true }),
|
|
1264
1545
|
(argv) => {
|
|
@@ -1266,6 +1547,24 @@ async function main(): Promise<void> {
|
|
|
1266
1547
|
console.log(`Removed workspace "${argv.name}"`);
|
|
1267
1548
|
},
|
|
1268
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
|
+
)
|
|
1269
1568
|
.command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
|
|
1270
1569
|
)
|
|
1271
1570
|
.command(
|
|
@@ -1275,8 +1574,11 @@ async function main(): Promise<void> {
|
|
|
1275
1574
|
.positional("target", { type: "string", describe: "#channel or @user to follow" })
|
|
1276
1575
|
.option("since", { type: "string", describe: "Backfill from N ago (e.g. 10m, 2h, 1d)" })
|
|
1277
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)" })
|
|
1278
1578
|
.option("me", { type: "boolean", default: false, describe: "Filter to messages that mention you" })
|
|
1279
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)" })
|
|
1280
1582
|
.option("rtm", { type: "boolean", default: true, describe: "Use RTM WebSocket when available (xoxc + cookie); pass --no-rtm to force polling" }),
|
|
1281
1583
|
async (argv) => {
|
|
1282
1584
|
const token = tok(argv as W);
|
|
@@ -1286,8 +1588,11 @@ async function main(): Promise<void> {
|
|
|
1286
1588
|
await cmdTail(token, argv.target, {
|
|
1287
1589
|
...(argv.since !== undefined ? { since: argv.since } : {}),
|
|
1288
1590
|
...(argv.thread !== undefined ? { thread: argv.thread } : {}),
|
|
1591
|
+
...(argv["watch-thread"] !== undefined ? { watchThread: argv["watch-thread"] as string } : {}),
|
|
1289
1592
|
me: argv.me,
|
|
1290
1593
|
interval: argv.interval,
|
|
1594
|
+
...(argv.timeout !== undefined ? { timeout: argv.timeout } : {}),
|
|
1595
|
+
...(argv["exit-on-message"] === true ? { exitOnMessage: true } : {}),
|
|
1291
1596
|
...(cookie !== undefined ? { cookie } : {}),
|
|
1292
1597
|
...(argv.rtm === false ? { noRtm: true } : {}),
|
|
1293
1598
|
}, signal.signal);
|