slack-term 1.19.1 โ 1.21.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 +14 -0
- package/SKILL.md +34 -0
- package/dist/cli.js +80 -78
- package/package.json +1 -1
- package/ts/cli.ts +338 -53
- package/ts/format.ts +109 -0
- package/ts/slack.ts +29 -0
package/package.json
CHANGED
package/ts/cli.ts
CHANGED
|
@@ -24,6 +24,9 @@ import {
|
|
|
24
24
|
updateDraft,
|
|
25
25
|
editMessage,
|
|
26
26
|
deleteMessage,
|
|
27
|
+
reactionAdd,
|
|
28
|
+
reactionRemove,
|
|
29
|
+
filesInfo,
|
|
27
30
|
history,
|
|
28
31
|
listConversations,
|
|
29
32
|
listDrafts,
|
|
@@ -48,7 +51,7 @@ import {
|
|
|
48
51
|
getPath,
|
|
49
52
|
type Json,
|
|
50
53
|
} from "./slack.ts";
|
|
51
|
-
import { dayLabel, encodeMentions, formatYmdHm, resolveDateMarkup, resolveMentions } from "./format.ts";
|
|
54
|
+
import { dayLabel, encodeMentions, findUntaggedMentions, formatYmdHm, resolveDateMarkup, resolveMentions } from "./format.ts";
|
|
52
55
|
|
|
53
56
|
function loadDotenv(path: string): void {
|
|
54
57
|
if (!existsSync(path)) return;
|
|
@@ -186,14 +189,44 @@ async function formatMsgLine(
|
|
|
186
189
|
})
|
|
187
190
|
.filter(Boolean)
|
|
188
191
|
.join(" ");
|
|
189
|
-
const
|
|
192
|
+
const attachTail = asArray(m.files)
|
|
193
|
+
.map(asRecord)
|
|
194
|
+
.map((f) => {
|
|
195
|
+
const name = typeof f.name === "string" ? f.name : typeof f.title === "string" ? f.title : "(file)";
|
|
196
|
+
const sz = typeof f.size === "number" ? ` (${fmtSize(f.size)})` : "";
|
|
197
|
+
const id = typeof f.id === "string" ? ` [${f.id}]` : "";
|
|
198
|
+
return `\n ๐ ${name}${sz}${id}`;
|
|
199
|
+
})
|
|
200
|
+
.join("");
|
|
201
|
+
const tail = (reactions ? `\n ${reactions}` : "") + attachTail;
|
|
190
202
|
return `${stamp} ${who}: ${body}${tail}`;
|
|
191
203
|
}
|
|
192
204
|
|
|
205
|
+
// Human-readable byte size, shared by upload/download/attachment rendering.
|
|
206
|
+
function fmtSize(n: number): string {
|
|
207
|
+
return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Slim a message's file attachments to the fields a script needs to detect and
|
|
211
|
+
// fetch them: id (for `slack download`), name, size, mimetype, and the two URLs.
|
|
212
|
+
function slimFiles(m: Record<string, Json>): Record<string, Json>[] {
|
|
213
|
+
return asArray(m.files)
|
|
214
|
+
.map(asRecord)
|
|
215
|
+
.map((f) => ({
|
|
216
|
+
id: f.id ?? null,
|
|
217
|
+
name: f.name ?? f.title ?? null,
|
|
218
|
+
mimetype: f.mimetype ?? null,
|
|
219
|
+
size: f.size ?? null,
|
|
220
|
+
url_private_download: f.url_private_download ?? f.url_private ?? null,
|
|
221
|
+
permalink: f.permalink ?? null,
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
|
|
193
225
|
// Slim a raw message down to the fields a script actually needs. Keeps the
|
|
194
226
|
// author's user ID (incl. external / Slack-Connect guests that never appear in
|
|
195
227
|
// users.list) so callers can resolve mentions without scraping history by hand.
|
|
196
228
|
function slimMsg(m: Record<string, Json>): Record<string, Json> {
|
|
229
|
+
const files = slimFiles(m);
|
|
197
230
|
return {
|
|
198
231
|
ts: m.ts ?? null,
|
|
199
232
|
user: m.user ?? null,
|
|
@@ -201,6 +234,9 @@ function slimMsg(m: Record<string, Json>): Record<string, Json> {
|
|
|
201
234
|
bot_id: m.bot_id ?? null,
|
|
202
235
|
thread_ts: m.thread_ts ?? null,
|
|
203
236
|
text: typeof m.text === "string" ? m.text : "",
|
|
237
|
+
// Present only when the message carries attachments, so scripts can reliably
|
|
238
|
+
// detect files without the key adding noise to every plain-text line.
|
|
239
|
+
...(files.length > 0 ? { files } : {}),
|
|
204
240
|
};
|
|
205
241
|
}
|
|
206
242
|
|
|
@@ -554,6 +590,49 @@ function safetyCode(...parts: string[]): string {
|
|
|
554
590
|
return sha256Hex(parts.join("\n")).slice(0, 4);
|
|
555
591
|
}
|
|
556
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
|
+
|
|
557
636
|
/** Dry-run gate: print context, print required --code=, exit 1.
|
|
558
637
|
* Call this when --code is absent or wrong. */
|
|
559
638
|
function requireCode(provided: string | undefined, expected: string, contextLines: string[]): void {
|
|
@@ -627,26 +706,73 @@ async function destLabel(token: string, channelId: string, ref: string): Promise
|
|
|
627
706
|
return name === channelId ? channelId : `${name} (${channelId})`;
|
|
628
707
|
}
|
|
629
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
|
+
|
|
630
724
|
/** One-line thread-parent description for confirm gates: "2026-06-11 08:05 @handle: headโฆ".
|
|
631
|
-
* Fail-soft: returns the raw ts if the parent cannot be
|
|
632
|
-
async function threadParentLine(token: string,
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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);
|
|
649
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;
|
|
650
776
|
}
|
|
651
777
|
|
|
652
778
|
// --- edit ---
|
|
@@ -740,6 +866,38 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
|
|
|
740
866
|
console.log(`โ Deleted (ts: ${ts})`);
|
|
741
867
|
}
|
|
742
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
|
+
|
|
743
901
|
// --- send ---
|
|
744
902
|
interface SendArgs {
|
|
745
903
|
target: string;
|
|
@@ -796,25 +954,73 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
796
954
|
? await encodeMentions(args.mentionToken ?? token, args.message, channelId)
|
|
797
955
|
: args.message;
|
|
798
956
|
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
let lastText = "";
|
|
803
|
-
let lastUser = "?";
|
|
957
|
+
// Warn-only lint: names written as plain text that map to a known workspace
|
|
958
|
+
// member but aren't <@USERID>-tagged โ they won't be notified. Never blocks;
|
|
959
|
+
// fail-soft (a missing users:read scope or API error must not stall a send).
|
|
804
960
|
try {
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
|
|
809
|
-
// Resolve the author's user ID to a display name for the preview; fall back
|
|
810
|
-
// to the bot username, then the raw ID. userName() is fail-soft.
|
|
811
|
-
lastUser = typeof lastMsg?.user === "string"
|
|
812
|
-
? await userName(token, lastMsg.user)
|
|
813
|
-
: typeof lastMsg?.username === "string"
|
|
814
|
-
? lastMsg.username
|
|
815
|
-
: "?";
|
|
961
|
+
for (const u of await findUntaggedMentions(args.mentionToken ?? token, message)) {
|
|
962
|
+
console.error(`โ possible untagged mention: ${u.surface} โ @${u.display} won't be notified (did you mean <@${u.userId}>?)`);
|
|
963
|
+
}
|
|
816
964
|
} catch {
|
|
817
|
-
// best-effort
|
|
965
|
+
// best-effort; ignore
|
|
966
|
+
}
|
|
967
|
+
|
|
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.
|
|
974
|
+
let lastText = "";
|
|
975
|
+
let lastUser = "?";
|
|
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
|
+
}
|
|
818
1024
|
}
|
|
819
1025
|
|
|
820
1026
|
// Hash covers the destination too โ a code minted for one channel/thread
|
|
@@ -823,17 +1029,32 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
823
1029
|
|
|
824
1030
|
if (args.code !== code) {
|
|
825
1031
|
const dest = await destLabel(token, channelId, ref);
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
+
}
|
|
837
1058
|
}
|
|
838
1059
|
const ts = await slackSend(token, channelId, message, threadTs, args.broadcast);
|
|
839
1060
|
let permalink = "";
|
|
@@ -988,10 +1209,6 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
988
1209
|
else if (args.userId) channelId = await openDm(token, args.userId);
|
|
989
1210
|
else channelId = await resolveChannel(token, ref);
|
|
990
1211
|
|
|
991
|
-
function fmtSize(n: number): string {
|
|
992
|
-
return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
1212
|
const isBatch = args.filePaths.length > 1;
|
|
996
1213
|
const files = args.filePaths.map((fp) => {
|
|
997
1214
|
const filename = basename(fp);
|
|
@@ -1033,6 +1250,42 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
1033
1250
|
}
|
|
1034
1251
|
}
|
|
1035
1252
|
|
|
1253
|
+
// --- download <ref> [dest] โ fetch an attachment's bytes to disk (read-only) ---
|
|
1254
|
+
// ref: a file ID (Fโฆ) or a Slack file permalink (โฆ/files/<UID>/<FID>/<name>).
|
|
1255
|
+
function parseFileId(ref: string): string | undefined {
|
|
1256
|
+
if (/^F[A-Z0-9]+$/i.test(ref)) return ref;
|
|
1257
|
+
return ref.match(/\/files\/[A-Za-z0-9]+\/(F[A-Za-z0-9]+)/)?.[1];
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
async function cmdDownload(token: string, cookie: string | undefined, ref: string, dest?: string): Promise<void> {
|
|
1261
|
+
const fileId = parseFileId(ref);
|
|
1262
|
+
if (!fileId) {
|
|
1263
|
+
throw new Error(
|
|
1264
|
+
`Not a file ID or file permalink: ${ref}\n` +
|
|
1265
|
+
`Expected Fโฆ or https://<ws>.slack.com/files/<UID>/<FID>/<name>`,
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
const info = asRecord((await filesInfo(token, fileId, cookie)) as Json);
|
|
1269
|
+
const f = asRecord(info.file);
|
|
1270
|
+
const name = typeof f.name === "string" ? f.name : typeof f.title === "string" ? f.title : fileId;
|
|
1271
|
+
const url = typeof f.url_private_download === "string" ? f.url_private_download
|
|
1272
|
+
: typeof f.url_private === "string" ? f.url_private : "";
|
|
1273
|
+
if (!url) throw new Error(`files.info returned no download URL for ${fileId}`);
|
|
1274
|
+
|
|
1275
|
+
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
|
|
1276
|
+
if (cookie) headers.Cookie = `d=${cookie}`;
|
|
1277
|
+
const res = await fetch(url, { headers });
|
|
1278
|
+
if (!res.ok) throw new Error(`Download failed: HTTP ${res.status} ${res.statusText}`);
|
|
1279
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1280
|
+
|
|
1281
|
+
const { statSync, existsSync } = await import("node:fs");
|
|
1282
|
+
const { basename } = await import("node:path");
|
|
1283
|
+
let outPath = dest ?? name;
|
|
1284
|
+
if (dest && existsSync(dest) && statSync(dest).isDirectory()) outPath = join(dest, basename(name));
|
|
1285
|
+
writeFileSync(outPath, buf);
|
|
1286
|
+
console.log(`โ Downloaded ${name} (${fmtSize(buf.length)}) โ ${outPath}`);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1036
1289
|
// --- dispatch ---
|
|
1037
1290
|
|
|
1038
1291
|
async function main(): Promise<void> {
|
|
@@ -1369,7 +1622,7 @@ async function main(): Promise<void> {
|
|
|
1369
1622
|
try {
|
|
1370
1623
|
await cmdSend(sendToken, args);
|
|
1371
1624
|
} catch (e: unknown) {
|
|
1372
|
-
console.error(
|
|
1625
|
+
console.error(friendlySlackError(e));
|
|
1373
1626
|
process.exit(1);
|
|
1374
1627
|
}
|
|
1375
1628
|
},
|
|
@@ -1472,6 +1725,28 @@ async function main(): Promise<void> {
|
|
|
1472
1725
|
await cmdDelete(tok(argv as W), args);
|
|
1473
1726
|
},
|
|
1474
1727
|
)
|
|
1728
|
+
.command(
|
|
1729
|
+
"react <target> <emoji>",
|
|
1730
|
+
"Add (or --remove) an emoji reaction โ a lightweight ack that doesn't grow the thread",
|
|
1731
|
+
(y) => y
|
|
1732
|
+
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
|
|
1733
|
+
.positional("emoji", { type: "string", demandOption: true, describe: "Emoji shortcode without colons (e.g. eyes, white_check_mark, hourglass)" })
|
|
1734
|
+
.option("remove", { type: "boolean", default: false, describe: "Remove the reaction instead of adding it" })
|
|
1735
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" }),
|
|
1736
|
+
async (argv) => {
|
|
1737
|
+
const args: ReactArgs = { target: argv.target!, emoji: argv.emoji! };
|
|
1738
|
+
if (argv.remove) args.remove = true;
|
|
1739
|
+
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
1740
|
+
// Print a clean one-line error instead of yargs' usage dump + stack when
|
|
1741
|
+
// the reaction fails at runtime (missing_scope, invalid_name, etc.).
|
|
1742
|
+
try {
|
|
1743
|
+
await cmdReact(tok(argv as W), args);
|
|
1744
|
+
} catch (e: unknown) {
|
|
1745
|
+
console.error(friendlySlackError(e));
|
|
1746
|
+
process.exit(1);
|
|
1747
|
+
}
|
|
1748
|
+
},
|
|
1749
|
+
)
|
|
1475
1750
|
.command(
|
|
1476
1751
|
"upload <target> <file..>",
|
|
1477
1752
|
"Upload one or more files to a channel or DM",
|
|
@@ -1493,6 +1768,16 @@ async function main(): Promise<void> {
|
|
|
1493
1768
|
await cmdUpload(tok(argv as W), args);
|
|
1494
1769
|
},
|
|
1495
1770
|
)
|
|
1771
|
+
.command(
|
|
1772
|
+
"download <ref> [dest]",
|
|
1773
|
+
"Download a file attachment (by file ID or file permalink) to disk",
|
|
1774
|
+
(y) => y
|
|
1775
|
+
.positional("ref", { type: "string", demandOption: true, describe: "File ID (Fโฆ) or Slack file permalink" })
|
|
1776
|
+
.positional("dest", { type: "string", describe: "Output path or directory (default: ./<filename>)" }),
|
|
1777
|
+
async (argv) => {
|
|
1778
|
+
await cmdDownload(tok(argv as W), ck(argv as W), argv.ref!, argv.dest);
|
|
1779
|
+
},
|
|
1780
|
+
)
|
|
1496
1781
|
.command(
|
|
1497
1782
|
"dump",
|
|
1498
1783
|
"Bulk export channel history as markdown",
|
package/ts/format.ts
CHANGED
|
@@ -93,6 +93,115 @@ export async function encodeMentions(
|
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// --- untagged-mention lint (warn-only) -------------------------------------
|
|
97
|
+
// Flags names written as plain text that map to a known workspace member but
|
|
98
|
+
// carry no <@USERID> tag in the same message โ the footgun where "ๆพ็ฐใใ"
|
|
99
|
+
// reads fine to a human but never actually notifies ๆพ็ฐ. Never blocks a send.
|
|
100
|
+
|
|
101
|
+
// Person-reference cues. Honorific *suffixes* follow a name (JP + ZH); greeting
|
|
102
|
+
// *prefixes* precede one (EN, handled by regex below). Extend freely.
|
|
103
|
+
const HONORIFIC_SUFFIXES = [
|
|
104
|
+
"ใใ", "ใใพ", "ๆง", "ๅ", "ใใ", "ใกใใ", "ๆฐ", "ๅ
็", "ๅ
่ผฉ", "ๆฎฟ", // JP
|
|
105
|
+
"่ๅธ", "่ๅธซ", "ๅ
", "ๅง", "ๅฅ", "ๅงๅฆน", // ZH
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
const NAME_CHARS = "\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}A-Za-z0-9";
|
|
109
|
+
|
|
110
|
+
function isCjk(s: string): boolean {
|
|
111
|
+
return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(s);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type PersonRef = { ref: string; honorific: string };
|
|
115
|
+
|
|
116
|
+
/** Extract candidate person-references from plain text. CJK matches by honorific
|
|
117
|
+
* suffix; EN by "Dear/Hi/Hello/Hey <Name>" and a leading "<Name>," salutation.
|
|
118
|
+
* Over-capture on the CJK side is harmless: a ref matching no member is dropped,
|
|
119
|
+
* and the warning's surface is rebuilt from the matched member name. */
|
|
120
|
+
function extractPersonRefs(text: string): PersonRef[] {
|
|
121
|
+
const refs: PersonRef[] = [];
|
|
122
|
+
const honRe = new RegExp(`([${NAME_CHARS}]{1,12})(${HONORIFIC_SUFFIXES.join("|")})`, "gu");
|
|
123
|
+
for (const m of text.matchAll(honRe)) refs.push({ ref: m[1]!, honorific: m[2]! });
|
|
124
|
+
for (const m of text.matchAll(/\b(?:dear|hi|hello|hey)\b[ \t]+([A-Z][A-Za-z'โ-]{1,20})/gi)) {
|
|
125
|
+
refs.push({ ref: m[1]!, honorific: "" });
|
|
126
|
+
}
|
|
127
|
+
for (const m of text.matchAll(/(?:^|\n)[ \t]*([A-Z][A-Za-z'โ-]{1,20}),/g)) {
|
|
128
|
+
refs.push({ ref: m[1]!, honorific: "" });
|
|
129
|
+
}
|
|
130
|
+
return refs;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Normalized name forms of a member (name / real_name / display_name plus their
|
|
134
|
+
* whitespace-split parts), lowercased+stripped, length โฅ 2. */
|
|
135
|
+
function nameFormsOf(u: Record<string, Json>): string[] {
|
|
136
|
+
const profile = asRecord(u.profile);
|
|
137
|
+
const forms = new Set<string>();
|
|
138
|
+
for (const v of [u.name, u.real_name, profile.display_name, profile.real_name]) {
|
|
139
|
+
if (typeof v !== "string" || !v) continue;
|
|
140
|
+
for (const piece of [v, ...v.split(/\s+/)]) {
|
|
141
|
+
const n = normHandle(piece);
|
|
142
|
+
if (n.length >= 2) forms.add(n);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return [...forms];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The member name-form that a ref matches, or undefined. CJK allows substring
|
|
149
|
+
* either direction (tolerates over-capture / compound names); Latin requires an
|
|
150
|
+
* exact form match to avoid "Ai" โ "Aiden" style false positives. */
|
|
151
|
+
function matchMemberForm(ref: string, forms: string[]): string | undefined {
|
|
152
|
+
const nr = normHandle(ref);
|
|
153
|
+
if (nr.length < 2) return undefined;
|
|
154
|
+
// Exact first so the surface rebuilds to the tightest name (e.g. an
|
|
155
|
+
// over-captured "ใใ ใใๆพ็ฐ" still reports "ๆพ็ฐ", not the whole run).
|
|
156
|
+
for (const f of forms) if (nr === f) return f;
|
|
157
|
+
if (isCjk(ref)) {
|
|
158
|
+
for (const f of forms) if (nr.includes(f) || f.includes(nr)) return f;
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function displayNameOf(u: Record<string, Json>): string {
|
|
164
|
+
const profile = asRecord(u.profile);
|
|
165
|
+
const names = [profile.display_name, u.real_name, u.name].filter(
|
|
166
|
+
(v): v is string => typeof v === "string" && v !== "",
|
|
167
|
+
);
|
|
168
|
+
return names[0] ?? String(u.id ?? "?");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type UntaggedMention = { surface: string; display: string; userId: string };
|
|
172
|
+
|
|
173
|
+
/** Warn-only lint: person-references in `text` that resolve to a workspace
|
|
174
|
+
* member but carry no <@USERID> tag. Returns [] cheaply (no users.list fetch)
|
|
175
|
+
* when the text has no person-reference cue. */
|
|
176
|
+
export async function findUntaggedMentions(token: string, text: string): Promise<UntaggedMention[]> {
|
|
177
|
+
const refs = extractPersonRefs(text);
|
|
178
|
+
if (refs.length === 0) return [];
|
|
179
|
+
|
|
180
|
+
const taggedIds = new Set<string>();
|
|
181
|
+
for (const m of text.matchAll(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g)) taggedIds.add(m[1]!);
|
|
182
|
+
|
|
183
|
+
const wsResp = (await listUsers(token)) as { members?: Json[] };
|
|
184
|
+
const pool = (wsResp.members ?? [])
|
|
185
|
+
.map(asRecord)
|
|
186
|
+
.filter((u) => u.deleted !== true && u.is_bot !== true && u.id !== "USLACKBOT")
|
|
187
|
+
.map((u) => ({ id: typeof u.id === "string" ? u.id : "", forms: nameFormsOf(u), display: displayNameOf(u) }))
|
|
188
|
+
.filter((p) => p.id !== "");
|
|
189
|
+
|
|
190
|
+
const out: UntaggedMention[] = [];
|
|
191
|
+
const seen = new Set<string>();
|
|
192
|
+
for (const { ref, honorific } of refs) {
|
|
193
|
+
for (const p of pool) {
|
|
194
|
+
const form = matchMemberForm(ref, p.forms);
|
|
195
|
+
if (!form) continue;
|
|
196
|
+
if (taggedIds.has(p.id) || seen.has(p.id)) break;
|
|
197
|
+
seen.add(p.id);
|
|
198
|
+
out.push({ surface: isCjk(ref) ? `${form}${honorific}` : ref, display: p.display, userId: p.id });
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
96
205
|
export async function resolveMentions(
|
|
97
206
|
token: string,
|
|
98
207
|
text: string,
|
package/ts/slack.ts
CHANGED
|
@@ -181,6 +181,13 @@ export async function replies(
|
|
|
181
181
|
return get(token, "conversations.replies", { channel, ts, limit: String(limit) });
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// Metadata for a single uploaded file (files.info). Carries url_private_download,
|
|
185
|
+
// which requires an Authorization: Bearer token (+ xoxd cookie for session tokens)
|
|
186
|
+
// to actually fetch the bytes.
|
|
187
|
+
export async function filesInfo(token: string, fileId: string, cookie?: string): Promise<Json> {
|
|
188
|
+
return get(token, "files.info", { file: fileId }, cookie);
|
|
189
|
+
}
|
|
190
|
+
|
|
184
191
|
export async function searchPage(
|
|
185
192
|
token: string,
|
|
186
193
|
query: string,
|
|
@@ -314,6 +321,28 @@ export async function deleteMessage(
|
|
|
314
321
|
await post(token, "chat.delete", { channel, ts });
|
|
315
322
|
}
|
|
316
323
|
|
|
324
|
+
// Add or remove an emoji reaction on a message. `name` is the emoji shortcode
|
|
325
|
+
// without colons (e.g. "eyes", "white_check_mark"). Slack's reactions.add
|
|
326
|
+
// returns already_reacted when the reaction exists; reactions.remove returns
|
|
327
|
+
// no_reaction when it doesn't โ both surface to the caller as API errors.
|
|
328
|
+
export async function reactionAdd(
|
|
329
|
+
token: string,
|
|
330
|
+
channel: string,
|
|
331
|
+
ts: string,
|
|
332
|
+
name: string,
|
|
333
|
+
): Promise<void> {
|
|
334
|
+
await post(token, "reactions.add", { channel, timestamp: ts, name });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function reactionRemove(
|
|
338
|
+
token: string,
|
|
339
|
+
channel: string,
|
|
340
|
+
ts: string,
|
|
341
|
+
name: string,
|
|
342
|
+
): Promise<void> {
|
|
343
|
+
await post(token, "reactions.remove", { channel, timestamp: ts, name });
|
|
344
|
+
}
|
|
345
|
+
|
|
317
346
|
// Create a public (or private) channel via conversations.create. Slack
|
|
318
347
|
// lowercases the name and rejects spaces/most punctuation; the raw API error
|
|
319
348
|
// (e.g. name_taken, invalid_name_specials) surfaces to the caller. Returns the
|