slack-term 1.20.0 → 1.21.1
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 +14 -0
- package/SKILL.md +20 -0
- package/dist/cli.js +82 -73
- package/package.json +1 -1
- package/ts/auth.ts +23 -0
- package/ts/cli.ts +260 -47
- package/ts/slack-app.ts +27 -4
- package/ts/slack.ts +35 -0
package/package.json
CHANGED
package/ts/auth.ts
CHANGED
|
@@ -263,6 +263,29 @@ export async function cmdAuthChrome(opts: { workspace?: string } = {}): Promise<
|
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
// A Chrome xoxd session cookie only augments a desktop *user* session token (xoxc-); it has
|
|
267
|
+
// no effect on a bot token (xoxb-). Attaching it to a bot profile silently does nothing and
|
|
268
|
+
// user-level actions (tail @user, DMs, users.list) still fail with missing_scope /
|
|
269
|
+
// cannot_dm_bot. Stop early with a pointer to a user-token workspace instead.
|
|
270
|
+
const selected = profiles.find((p) => p.name === profileName)!;
|
|
271
|
+
if (selected.profile.token.startsWith("xoxb-")) {
|
|
272
|
+
console.error(
|
|
273
|
+
`\nWorkspace "${profileName}" uses a bot token (xoxb-). A Chrome session cookie only\n` +
|
|
274
|
+
`augments a user session token (xoxc-) — it has no effect on a bot token, so user-level\n` +
|
|
275
|
+
`actions (tail @user, DMs, listing users) would still fail.`,
|
|
276
|
+
);
|
|
277
|
+
const userProfiles = profiles.filter(
|
|
278
|
+
(p) => p.profile.token.startsWith("xoxc-") || p.profile.token.startsWith("xoxp-"),
|
|
279
|
+
);
|
|
280
|
+
if (userProfiles.length > 0) {
|
|
281
|
+
console.error(` Target a user-token workspace instead: ${userProfiles.map((p) => p.name).join(", ")}`);
|
|
282
|
+
console.error(` slack auth chrome -w ${userProfiles[0]!.name}`);
|
|
283
|
+
} else {
|
|
284
|
+
console.error(` Import your Slack desktop user session instead: slack workspace import`);
|
|
285
|
+
}
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
|
|
266
289
|
console.log("Scanning Chrome profiles for Slack session...");
|
|
267
290
|
console.log("macOS may show a dialog asking for your login password — click Allow.");
|
|
268
291
|
|
package/ts/cli.ts
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
updateDraft,
|
|
25
25
|
editMessage,
|
|
26
26
|
deleteMessage,
|
|
27
|
+
reactionAdd,
|
|
28
|
+
reactionRemove,
|
|
27
29
|
filesInfo,
|
|
28
30
|
history,
|
|
29
31
|
listConversations,
|
|
@@ -588,6 +590,49 @@ function safetyCode(...parts: string[]): string {
|
|
|
588
590
|
return sha256Hex(parts.join("\n")).slice(0, 4);
|
|
589
591
|
}
|
|
590
592
|
|
|
593
|
+
// Slack API method → the token scope that grants it, for missing_scope guidance.
|
|
594
|
+
const SCOPE_FOR_METHOD: Record<string, string> = {
|
|
595
|
+
"reactions.add": "reactions:write",
|
|
596
|
+
"reactions.remove": "reactions:write",
|
|
597
|
+
"chat.postMessage": "chat:write",
|
|
598
|
+
"chat.update": "chat:write",
|
|
599
|
+
"chat.delete": "chat:write",
|
|
600
|
+
"conversations.replies": "channels:history (or groups:history)",
|
|
601
|
+
"conversations.history": "channels:history (or groups:history)",
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
/** Turn a raw Slack API error into a single clean line of guidance, hiding the
|
|
605
|
+
* yargs usage dump / stack trace that a thrown handler would otherwise print.
|
|
606
|
+
* Recognizes the common reaction/message failure codes; falls back to the
|
|
607
|
+
* original message for anything unmapped. */
|
|
608
|
+
function friendlySlackError(e: unknown): string {
|
|
609
|
+
const raw = e instanceof Error ? e.message : String(e);
|
|
610
|
+
const m = raw.match(/Slack error on (\S+): (\w+)/);
|
|
611
|
+
if (!m) return raw;
|
|
612
|
+
const [, method, code] = m;
|
|
613
|
+
switch (code) {
|
|
614
|
+
case "missing_scope": {
|
|
615
|
+
const scope = SCOPE_FOR_METHOD[method!] ?? "the required";
|
|
616
|
+
// Note the token-type gotcha: the CLI uses the USER token (xoxp) by default,
|
|
617
|
+
// so the scope must be in "User Token Scopes" — adding it only to "Bot Token
|
|
618
|
+
// Scopes" fixes `--as-bot`/`doctor` but not the default path. Then reinstall.
|
|
619
|
+
return `Error: token lacks the ${scope} scope (needed for ${method}). Add it to your Slack App's User Token Scopes (or Bot Token Scopes if you use a bot token / --as-bot) — it must be on the token type the CLI actually uses — then reinstall to the workspace.`;
|
|
620
|
+
}
|
|
621
|
+
case "invalid_name":
|
|
622
|
+
return `Error: not a valid emoji shortcode — use the name without colons (e.g. white_check_mark), and make sure it exists in the workspace.`;
|
|
623
|
+
case "already_reacted":
|
|
624
|
+
return `Error: you've already added that reaction to the message.`;
|
|
625
|
+
case "no_reaction":
|
|
626
|
+
return `Error: that reaction isn't on the message — nothing to remove.`;
|
|
627
|
+
case "message_not_found":
|
|
628
|
+
return `Error: no message found at that timestamp in the channel — check the target ts/permalink.`;
|
|
629
|
+
case "channel_not_found":
|
|
630
|
+
return `Error: channel not found — check the target.`;
|
|
631
|
+
default:
|
|
632
|
+
return raw;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
591
636
|
/** Dry-run gate: print context, print required --code=, exit 1.
|
|
592
637
|
* Call this when --code is absent or wrong. */
|
|
593
638
|
function requireCode(provided: string | undefined, expected: string, contextLines: string[]): void {
|
|
@@ -661,26 +706,73 @@ async function destLabel(token: string, channelId: string, ref: string): Promise
|
|
|
661
706
|
return name === channelId ? channelId : `${name} (${channelId})`;
|
|
662
707
|
}
|
|
663
708
|
|
|
709
|
+
/** One-line description of a single thread message for confirm gates:
|
|
710
|
+
* "2026-06-11 08:05 @handle: head…". `headLen` bounds the text preview. */
|
|
711
|
+
async function threadMsgLine(token: string, m: Record<string, Json>, headLen = 60): Promise<string> {
|
|
712
|
+
const author = typeof m.user === "string"
|
|
713
|
+
? await userName(token, m.user)
|
|
714
|
+
: typeof m.username === "string"
|
|
715
|
+
? m.username
|
|
716
|
+
: "?";
|
|
717
|
+
const head = (typeof m.text === "string" ? m.text : "").split("\n")[0] ?? "";
|
|
718
|
+
const headShort = head.length > headLen ? `${head.slice(0, headLen)}…` : head;
|
|
719
|
+
const ts = typeof m.ts === "string" ? m.ts : "";
|
|
720
|
+
const stamp = ts ? slackTsToIso(ts).slice(0, 16).replace("T", " ") : "";
|
|
721
|
+
return `${stamp} @${author}${headShort ? `: ${headShort}` : ""}`;
|
|
722
|
+
}
|
|
723
|
+
|
|
664
724
|
/** One-line thread-parent description for confirm gates: "2026-06-11 08:05 @handle: head…".
|
|
665
|
-
* Fail-soft: returns the raw ts if the parent cannot be
|
|
666
|
-
async function threadParentLine(token: string,
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
725
|
+
* Fail-soft: returns the raw ts if the parent cannot be found. */
|
|
726
|
+
async function threadParentLine(token: string, threadTs: string, threadMsgs: Record<string, Json>[]): Promise<string> {
|
|
727
|
+
const parent = threadMsgs.find((m) => String(m.ts) === threadTs) ?? threadMsgs[0];
|
|
728
|
+
if (!parent) return threadTs;
|
|
729
|
+
return threadMsgLine(token, parent, 40);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Normalize a message for duplicate comparison: lowercase, collapse whitespace,
|
|
733
|
+
* strip <@U…>/<#C…> markup and surrounding punctuation so cosmetic differences
|
|
734
|
+
* (a trailing period, an added mention) don't hide a re-post. */
|
|
735
|
+
function normalizeForDup(s: string): string {
|
|
736
|
+
return s
|
|
737
|
+
.toLowerCase()
|
|
738
|
+
.replace(/<[@#!][^>]+>/g, " ")
|
|
739
|
+
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
|
740
|
+
.replace(/\s+/g, " ")
|
|
741
|
+
.trim();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Character-bigram Dice coefficient (0..1) over normalized text — robust to
|
|
745
|
+
* minor edits, good at catching near-identical re-posts. Identical strings →1;
|
|
746
|
+
* a shared word or two in otherwise different messages stays low. */
|
|
747
|
+
function textSimilarity(a: string, b: string): number {
|
|
748
|
+
const na = normalizeForDup(a);
|
|
749
|
+
const nb = normalizeForDup(b);
|
|
750
|
+
if (!na || !nb) return 0;
|
|
751
|
+
if (na === nb) return 1;
|
|
752
|
+
const bigrams = (s: string): Map<string, number> => {
|
|
753
|
+
const m = new Map<string, number>();
|
|
754
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
755
|
+
const g = s.slice(i, i + 2);
|
|
756
|
+
m.set(g, (m.get(g) ?? 0) + 1);
|
|
757
|
+
}
|
|
758
|
+
return m;
|
|
759
|
+
};
|
|
760
|
+
const ba = bigrams(na);
|
|
761
|
+
const bb = bigrams(nb);
|
|
762
|
+
if (ba.size === 0 || bb.size === 0) return 0;
|
|
763
|
+
let overlap = 0;
|
|
764
|
+
for (const [g, count] of ba) {
|
|
765
|
+
const other = bb.get(g);
|
|
766
|
+
if (other) overlap += Math.min(count, other);
|
|
683
767
|
}
|
|
768
|
+
const total = sumCounts(ba) + sumCounts(bb);
|
|
769
|
+
return total === 0 ? 0 : (2 * overlap) / total;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function sumCounts(m: Map<string, number>): number {
|
|
773
|
+
let n = 0;
|
|
774
|
+
for (const v of m.values()) n += v;
|
|
775
|
+
return n;
|
|
684
776
|
}
|
|
685
777
|
|
|
686
778
|
// --- edit ---
|
|
@@ -774,6 +866,38 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
|
|
|
774
866
|
console.log(`✓ Deleted (ts: ${ts})`);
|
|
775
867
|
}
|
|
776
868
|
|
|
869
|
+
// --- react ---
|
|
870
|
+
interface ReactArgs {
|
|
871
|
+
target: string;
|
|
872
|
+
emoji: string;
|
|
873
|
+
remove?: boolean;
|
|
874
|
+
channelId?: string;
|
|
875
|
+
}
|
|
876
|
+
// Add (or --remove) an emoji reaction. No confirm gate: a reaction is trivial
|
|
877
|
+
// and fully reversible (`--remove`), and the whole point is a lightweight ack
|
|
878
|
+
// that doesn't grow the thread. The emoji is a shortcode without colons
|
|
879
|
+
// (e.g. "eyes", "white_check_mark", "hourglass").
|
|
880
|
+
async function cmdReact(token: string, args: ReactArgs): Promise<void> {
|
|
881
|
+
const { ref, ts } = splitRefTs(args.target);
|
|
882
|
+
if (!ts) {
|
|
883
|
+
console.error("Error: target must embed a message ts (e.g. #chan:2026-05-11T06:01:04.000100 or a Slack permalink URL)");
|
|
884
|
+
process.exit(2);
|
|
885
|
+
}
|
|
886
|
+
const emoji = args.emoji.replace(/^:|:$/g, "");
|
|
887
|
+
|
|
888
|
+
let channelId: string;
|
|
889
|
+
if (args.channelId) channelId = args.channelId;
|
|
890
|
+
else channelId = await resolveChannel(token, ref);
|
|
891
|
+
|
|
892
|
+
if (args.remove) {
|
|
893
|
+
await reactionRemove(token, channelId, ts, emoji);
|
|
894
|
+
console.log(`✓ Removed :${emoji}: (ts: ${ts})`);
|
|
895
|
+
} else {
|
|
896
|
+
await reactionAdd(token, channelId, ts, emoji);
|
|
897
|
+
console.log(`✓ Reacted :${emoji}: (ts: ${ts})`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
777
901
|
// --- send ---
|
|
778
902
|
interface SendArgs {
|
|
779
903
|
target: string;
|
|
@@ -841,25 +965,62 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
841
965
|
// best-effort; ignore
|
|
842
966
|
}
|
|
843
967
|
|
|
844
|
-
//
|
|
845
|
-
//
|
|
846
|
-
//
|
|
968
|
+
// Gather context for the confirm gate. For a THREAD reply, pull the thread's
|
|
969
|
+
// own recent messages so the preview shows what's already been said there —
|
|
970
|
+
// this is what stops accidental duplicate replies (the channel's last message
|
|
971
|
+
// is irrelevant when you're deep in a thread). For a top-level send, fall back
|
|
972
|
+
// to the channel's last message. Fail-soft throughout: a bot token without
|
|
973
|
+
// history scope can still send; only the preview degrades.
|
|
847
974
|
let lastText = "";
|
|
848
975
|
let lastUser = "?";
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
:
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
976
|
+
let threadMsgs: Record<string, Json>[] = [];
|
|
977
|
+
if (threadTs) {
|
|
978
|
+
try {
|
|
979
|
+
// Fetch a generous window and preview its tail. conversations.replies is
|
|
980
|
+
// oldest-first from the parent, so a very long thread's true tail may lie
|
|
981
|
+
// beyond this window — 100 covers essentially all real threads.
|
|
982
|
+
const resp = (await replies(token, channelId, threadTs, 100)) as Record<string, Json>;
|
|
983
|
+
threadMsgs = asArray(resp.messages).map(asRecord);
|
|
984
|
+
const lastMsg = threadMsgs[threadMsgs.length - 1];
|
|
985
|
+
// Bind the hash to the thread's most recent message: if someone replies
|
|
986
|
+
// between preview and confirm, the code invalidates and re-previews.
|
|
987
|
+
lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
|
|
988
|
+
} catch {
|
|
989
|
+
// best-effort preview only
|
|
990
|
+
}
|
|
991
|
+
} else {
|
|
992
|
+
try {
|
|
993
|
+
const ctx = (await history(token, channelId, 1)) as Record<string, Json>;
|
|
994
|
+
const lastMsg = asArray(ctx.messages).map(asRecord)
|
|
995
|
+
.filter((m) => m.subtype === undefined || m.subtype === null)[0];
|
|
996
|
+
lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
|
|
997
|
+
// Resolve the author's user ID to a display name for the preview; fall back
|
|
998
|
+
// to the bot username, then the raw ID. userName() is fail-soft.
|
|
999
|
+
lastUser = typeof lastMsg?.user === "string"
|
|
1000
|
+
? await userName(token, lastMsg.user)
|
|
1001
|
+
: typeof lastMsg?.username === "string"
|
|
1002
|
+
? lastMsg.username
|
|
1003
|
+
: "?";
|
|
1004
|
+
} catch {
|
|
1005
|
+
// best-effort preview only
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Duplicate guard: if the outgoing message closely matches something already
|
|
1010
|
+
// in the thread, warn (never block). Catches re-posting a reply you already
|
|
1011
|
+
// sent. Threshold is deliberately high so only near-identical text trips it.
|
|
1012
|
+
if (threadTs && threadMsgs.length) {
|
|
1013
|
+
let best = { sim: 0, msg: null as Record<string, Json> | null };
|
|
1014
|
+
for (const m of threadMsgs) {
|
|
1015
|
+
const t = typeof m.text === "string" ? m.text : "";
|
|
1016
|
+
if (!t) continue;
|
|
1017
|
+
const sim = textSimilarity(message, t);
|
|
1018
|
+
if (sim > best.sim) best = { sim, msg: m };
|
|
1019
|
+
}
|
|
1020
|
+
if (best.msg && best.sim >= 0.82) {
|
|
1021
|
+
const pct = Math.round(best.sim * 100);
|
|
1022
|
+
console.error(`⚠ possible duplicate: ${pct}% similar to an existing thread message — ${await threadMsgLine(token, best.msg)}`);
|
|
1023
|
+
}
|
|
863
1024
|
}
|
|
864
1025
|
|
|
865
1026
|
// Hash covers the destination too — a code minted for one channel/thread
|
|
@@ -868,17 +1029,32 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
868
1029
|
|
|
869
1030
|
if (args.code !== code) {
|
|
870
1031
|
const dest = await destLabel(token, channelId, ref);
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
1032
|
+
if (threadTs) {
|
|
1033
|
+
const parentLine = await threadParentLine(token, threadTs, threadMsgs);
|
|
1034
|
+
// Preview the tail of the thread (up to 3 most recent messages) so the
|
|
1035
|
+
// sender can see what's already been said and avoid repeating it.
|
|
1036
|
+
const recent = threadMsgs.slice(-3);
|
|
1037
|
+
const recentLines = recent.length
|
|
1038
|
+
? await Promise.all(recent.map(async (m) => ` ${await threadMsgLine(token, m)}`))
|
|
1039
|
+
: [" (thread context unavailable)"];
|
|
1040
|
+
requireCode(args.code, code, [
|
|
1041
|
+
`--- Recent messages in thread ----------------`,
|
|
1042
|
+
...recentLines,
|
|
1043
|
+
`--- Sending ----------------------------------`,
|
|
1044
|
+
` → ${dest} thread of ${parentLine} — THREAD REPLY`,
|
|
1045
|
+
` Message: ${message}`,
|
|
1046
|
+
`--------------------------------────────────`,
|
|
1047
|
+
]);
|
|
1048
|
+
} else {
|
|
1049
|
+
requireCode(args.code, code, [
|
|
1050
|
+
`--- Last message in channel ------------------`,
|
|
1051
|
+
` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
|
|
1052
|
+
`--- Sending ----------------------------------`,
|
|
1053
|
+
` → ${dest} — NEW top-level message`,
|
|
1054
|
+
` Message: ${message}`,
|
|
1055
|
+
`--------------------------------────────────`,
|
|
1056
|
+
]);
|
|
1057
|
+
}
|
|
882
1058
|
}
|
|
883
1059
|
const ts = await slackSend(token, channelId, message, threadTs, args.broadcast);
|
|
884
1060
|
let permalink = "";
|
|
@@ -1121,6 +1297,21 @@ async function main(): Promise<void> {
|
|
|
1121
1297
|
|
|
1122
1298
|
await yargs(hideBin(process.argv))
|
|
1123
1299
|
.scriptName("slack")
|
|
1300
|
+
// A thrown handler error (e.g. a Slack API failure) is a runtime error, not a usage
|
|
1301
|
+
// mistake — print just the clean one-line message, never the command's --help block or a
|
|
1302
|
+
// minified source stack. Reserve the usage/help output for actual argument-parse errors
|
|
1303
|
+
// (err is undefined then). showHelpOnFail(false) stops yargs' default help dump.
|
|
1304
|
+
.showHelpOnFail(false)
|
|
1305
|
+
.fail((msg, err, y) => {
|
|
1306
|
+
if (err) {
|
|
1307
|
+
console.error(err.message);
|
|
1308
|
+
process.exit(1);
|
|
1309
|
+
}
|
|
1310
|
+
console.error(msg);
|
|
1311
|
+
console.error("");
|
|
1312
|
+
y.showHelp();
|
|
1313
|
+
process.exit(1);
|
|
1314
|
+
})
|
|
1124
1315
|
.option("workspace", { alias: "w", type: "string", describe: "Workspace name" })
|
|
1125
1316
|
.middleware(async (argv) => {
|
|
1126
1317
|
const cmd = String((argv._ ?? [])[0] ?? "");
|
|
@@ -1446,7 +1637,7 @@ async function main(): Promise<void> {
|
|
|
1446
1637
|
try {
|
|
1447
1638
|
await cmdSend(sendToken, args);
|
|
1448
1639
|
} catch (e: unknown) {
|
|
1449
|
-
console.error(
|
|
1640
|
+
console.error(friendlySlackError(e));
|
|
1450
1641
|
process.exit(1);
|
|
1451
1642
|
}
|
|
1452
1643
|
},
|
|
@@ -1549,6 +1740,28 @@ async function main(): Promise<void> {
|
|
|
1549
1740
|
await cmdDelete(tok(argv as W), args);
|
|
1550
1741
|
},
|
|
1551
1742
|
)
|
|
1743
|
+
.command(
|
|
1744
|
+
"react <target> <emoji>",
|
|
1745
|
+
"Add (or --remove) an emoji reaction — a lightweight ack that doesn't grow the thread",
|
|
1746
|
+
(y) => y
|
|
1747
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
|
|
1748
|
+
.positional("emoji", { type: "string", demandOption: true, describe: "Emoji shortcode without colons (e.g. eyes, white_check_mark, hourglass)" })
|
|
1749
|
+
.option("remove", { type: "boolean", default: false, describe: "Remove the reaction instead of adding it" })
|
|
1750
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" }),
|
|
1751
|
+
async (argv) => {
|
|
1752
|
+
const args: ReactArgs = { target: argv.target!, emoji: argv.emoji! };
|
|
1753
|
+
if (argv.remove) args.remove = true;
|
|
1754
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
1755
|
+
// Print a clean one-line error instead of yargs' usage dump + stack when
|
|
1756
|
+
// the reaction fails at runtime (missing_scope, invalid_name, etc.).
|
|
1757
|
+
try {
|
|
1758
|
+
await cmdReact(tok(argv as W), args);
|
|
1759
|
+
} catch (e: unknown) {
|
|
1760
|
+
console.error(friendlySlackError(e));
|
|
1761
|
+
process.exit(1);
|
|
1762
|
+
}
|
|
1763
|
+
},
|
|
1764
|
+
)
|
|
1552
1765
|
.command(
|
|
1553
1766
|
"upload <target> <file..>",
|
|
1554
1767
|
"Upload one or more files to a channel or DM",
|
package/ts/slack-app.ts
CHANGED
|
@@ -271,16 +271,39 @@ export type ChromeCookieCandidate = {
|
|
|
271
271
|
cookie: string; // decrypted xoxd-...
|
|
272
272
|
};
|
|
273
273
|
|
|
274
|
-
/**
|
|
274
|
+
/**
|
|
275
|
+
* Read a human-friendly display name (nickname + email) for a Chrome profile so a bare
|
|
276
|
+
* "Default"/"Profile N" is never shown alone.
|
|
277
|
+
*
|
|
278
|
+
* Prefers Chrome's `Local State` → profile.info_cache, which reliably carries both the
|
|
279
|
+
* nickname (`name`) and account email (`user_name`); the per-profile Preferences file
|
|
280
|
+
* often has neither. Only nickname + email are surfaced — the gaia real name is never read.
|
|
281
|
+
*/
|
|
275
282
|
function chromeProfileName(userDataDir: string, profileDir: string): string {
|
|
283
|
+
const label = (name: string, email: string): string => {
|
|
284
|
+
if (name && email) return `${name} (${email})`;
|
|
285
|
+
return name || email || profileDir;
|
|
286
|
+
};
|
|
287
|
+
try {
|
|
288
|
+
const localState = JSON.parse(
|
|
289
|
+
readFileSync(join(userDataDir, "Local State"), "utf8"),
|
|
290
|
+
) as { profile?: { info_cache?: Record<string, { name?: string; user_name?: string }> } };
|
|
291
|
+
const info = localState.profile?.info_cache?.[profileDir];
|
|
292
|
+
if (info && (info.name || info.user_name)) {
|
|
293
|
+
return label(info.name ?? "", info.user_name ?? "");
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
// fall through to Preferences
|
|
297
|
+
}
|
|
276
298
|
try {
|
|
277
299
|
const prefs = JSON.parse(
|
|
278
300
|
readFileSync(join(userDataDir, profileDir, "Preferences"), "utf8"),
|
|
279
301
|
) as Record<string, unknown>;
|
|
280
302
|
const profile = prefs.profile as Record<string, unknown> | undefined;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
303
|
+
return label(
|
|
304
|
+
(profile?.name as string | undefined) ?? "",
|
|
305
|
+
(profile?.user_name as string | undefined) ?? "",
|
|
306
|
+
);
|
|
284
307
|
} catch {
|
|
285
308
|
return profileDir;
|
|
286
309
|
}
|
package/ts/slack.ts
CHANGED
|
@@ -47,6 +47,19 @@ async function call(token: string, method: string, init: RequestInit, cookie?: s
|
|
|
47
47
|
` slack workspace set-token <name> <xoxp-token>`,
|
|
48
48
|
);
|
|
49
49
|
}
|
|
50
|
+
// Bot tokens (xoxb-) can't act as a user: they lack user scopes (missing_scope) and can't
|
|
51
|
+
// open a DM with themselves (cannot_dm_bot, e.g. `tail @you`). Point at a user-token workspace.
|
|
52
|
+
if ((err === "missing_scope" || err === "cannot_dm_bot") && token.startsWith("xoxb-")) {
|
|
53
|
+
const why = err === "cannot_dm_bot"
|
|
54
|
+
? `you're authenticated as a bot, so "@you" is the bot itself and it can't DM itself`
|
|
55
|
+
: `this bot token lacks the user scope this action needs`;
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Slack error on ${method}: ${err} — ${why}.\n` +
|
|
58
|
+
`Tailing/DMing people and listing users need a user session, not a bot token.\n` +
|
|
59
|
+
` Switch workspace: slack workspace ls then slack workspace use <name>\n` +
|
|
60
|
+
` Or import your Slack desktop session: slack workspace import`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
50
63
|
throw new Error(`Slack error on ${method}: ${err}`);
|
|
51
64
|
}
|
|
52
65
|
return body as Json;
|
|
@@ -321,6 +334,28 @@ export async function deleteMessage(
|
|
|
321
334
|
await post(token, "chat.delete", { channel, ts });
|
|
322
335
|
}
|
|
323
336
|
|
|
337
|
+
// Add or remove an emoji reaction on a message. `name` is the emoji shortcode
|
|
338
|
+
// without colons (e.g. "eyes", "white_check_mark"). Slack's reactions.add
|
|
339
|
+
// returns already_reacted when the reaction exists; reactions.remove returns
|
|
340
|
+
// no_reaction when it doesn't — both surface to the caller as API errors.
|
|
341
|
+
export async function reactionAdd(
|
|
342
|
+
token: string,
|
|
343
|
+
channel: string,
|
|
344
|
+
ts: string,
|
|
345
|
+
name: string,
|
|
346
|
+
): Promise<void> {
|
|
347
|
+
await post(token, "reactions.add", { channel, timestamp: ts, name });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function reactionRemove(
|
|
351
|
+
token: string,
|
|
352
|
+
channel: string,
|
|
353
|
+
ts: string,
|
|
354
|
+
name: string,
|
|
355
|
+
): Promise<void> {
|
|
356
|
+
await post(token, "reactions.remove", { channel, timestamp: ts, name });
|
|
357
|
+
}
|
|
358
|
+
|
|
324
359
|
// Create a public (or private) channel via conversations.create. Slack
|
|
325
360
|
// lowercases the name and rejects spaces/most punctuation; the raw API error
|
|
326
361
|
// (e.g. name_taken, invalid_name_specials) surfaces to the caller. Returns the
|