slack-term 1.19.0 โ 1.20.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/SKILL.md +14 -0
- package/dist/cli.js +79 -77
- package/package.json +1 -1
- package/ts/cli.ts +93 -6
- package/ts/format.ts +113 -2
- package/ts/slack.ts +7 -0
package/package.json
CHANGED
package/ts/cli.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
updateDraft,
|
|
25
25
|
editMessage,
|
|
26
26
|
deleteMessage,
|
|
27
|
+
filesInfo,
|
|
27
28
|
history,
|
|
28
29
|
listConversations,
|
|
29
30
|
listDrafts,
|
|
@@ -48,7 +49,7 @@ import {
|
|
|
48
49
|
getPath,
|
|
49
50
|
type Json,
|
|
50
51
|
} from "./slack.ts";
|
|
51
|
-
import { dayLabel, encodeMentions, formatYmdHm, resolveDateMarkup, resolveMentions } from "./format.ts";
|
|
52
|
+
import { dayLabel, encodeMentions, findUntaggedMentions, formatYmdHm, resolveDateMarkup, resolveMentions } from "./format.ts";
|
|
52
53
|
|
|
53
54
|
function loadDotenv(path: string): void {
|
|
54
55
|
if (!existsSync(path)) return;
|
|
@@ -186,14 +187,44 @@ async function formatMsgLine(
|
|
|
186
187
|
})
|
|
187
188
|
.filter(Boolean)
|
|
188
189
|
.join(" ");
|
|
189
|
-
const
|
|
190
|
+
const attachTail = asArray(m.files)
|
|
191
|
+
.map(asRecord)
|
|
192
|
+
.map((f) => {
|
|
193
|
+
const name = typeof f.name === "string" ? f.name : typeof f.title === "string" ? f.title : "(file)";
|
|
194
|
+
const sz = typeof f.size === "number" ? ` (${fmtSize(f.size)})` : "";
|
|
195
|
+
const id = typeof f.id === "string" ? ` [${f.id}]` : "";
|
|
196
|
+
return `\n ๐ ${name}${sz}${id}`;
|
|
197
|
+
})
|
|
198
|
+
.join("");
|
|
199
|
+
const tail = (reactions ? `\n ${reactions}` : "") + attachTail;
|
|
190
200
|
return `${stamp} ${who}: ${body}${tail}`;
|
|
191
201
|
}
|
|
192
202
|
|
|
203
|
+
// Human-readable byte size, shared by upload/download/attachment rendering.
|
|
204
|
+
function fmtSize(n: number): string {
|
|
205
|
+
return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Slim a message's file attachments to the fields a script needs to detect and
|
|
209
|
+
// fetch them: id (for `slack download`), name, size, mimetype, and the two URLs.
|
|
210
|
+
function slimFiles(m: Record<string, Json>): Record<string, Json>[] {
|
|
211
|
+
return asArray(m.files)
|
|
212
|
+
.map(asRecord)
|
|
213
|
+
.map((f) => ({
|
|
214
|
+
id: f.id ?? null,
|
|
215
|
+
name: f.name ?? f.title ?? null,
|
|
216
|
+
mimetype: f.mimetype ?? null,
|
|
217
|
+
size: f.size ?? null,
|
|
218
|
+
url_private_download: f.url_private_download ?? f.url_private ?? null,
|
|
219
|
+
permalink: f.permalink ?? null,
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
|
|
193
223
|
// Slim a raw message down to the fields a script actually needs. Keeps the
|
|
194
224
|
// author's user ID (incl. external / Slack-Connect guests that never appear in
|
|
195
225
|
// users.list) so callers can resolve mentions without scraping history by hand.
|
|
196
226
|
function slimMsg(m: Record<string, Json>): Record<string, Json> {
|
|
227
|
+
const files = slimFiles(m);
|
|
197
228
|
return {
|
|
198
229
|
ts: m.ts ?? null,
|
|
199
230
|
user: m.user ?? null,
|
|
@@ -201,6 +232,9 @@ function slimMsg(m: Record<string, Json>): Record<string, Json> {
|
|
|
201
232
|
bot_id: m.bot_id ?? null,
|
|
202
233
|
thread_ts: m.thread_ts ?? null,
|
|
203
234
|
text: typeof m.text === "string" ? m.text : "",
|
|
235
|
+
// Present only when the message carries attachments, so scripts can reliably
|
|
236
|
+
// detect files without the key adding noise to every plain-text line.
|
|
237
|
+
...(files.length > 0 ? { files } : {}),
|
|
204
238
|
};
|
|
205
239
|
}
|
|
206
240
|
|
|
@@ -796,6 +830,17 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
796
830
|
? await encodeMentions(args.mentionToken ?? token, args.message, channelId)
|
|
797
831
|
: args.message;
|
|
798
832
|
|
|
833
|
+
// Warn-only lint: names written as plain text that map to a known workspace
|
|
834
|
+
// member but aren't <@USERID>-tagged โ they won't be notified. Never blocks;
|
|
835
|
+
// fail-soft (a missing users:read scope or API error must not stall a send).
|
|
836
|
+
try {
|
|
837
|
+
for (const u of await findUntaggedMentions(args.mentionToken ?? token, message)) {
|
|
838
|
+
console.error(`โ possible untagged mention: ${u.surface} โ @${u.display} won't be notified (did you mean <@${u.userId}>?)`);
|
|
839
|
+
}
|
|
840
|
+
} catch {
|
|
841
|
+
// best-effort; ignore
|
|
842
|
+
}
|
|
843
|
+
|
|
799
844
|
// Fetch last 1 message for context hash. Fail-soft: a bot token without
|
|
800
845
|
// im:history (or channels:history) can still send โ only the preview of the
|
|
801
846
|
// prior message is lost, so default to empty rather than blocking the send.
|
|
@@ -988,10 +1033,6 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
988
1033
|
else if (args.userId) channelId = await openDm(token, args.userId);
|
|
989
1034
|
else channelId = await resolveChannel(token, ref);
|
|
990
1035
|
|
|
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
1036
|
const isBatch = args.filePaths.length > 1;
|
|
996
1037
|
const files = args.filePaths.map((fp) => {
|
|
997
1038
|
const filename = basename(fp);
|
|
@@ -1033,6 +1074,42 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
|
1033
1074
|
}
|
|
1034
1075
|
}
|
|
1035
1076
|
|
|
1077
|
+
// --- download <ref> [dest] โ fetch an attachment's bytes to disk (read-only) ---
|
|
1078
|
+
// ref: a file ID (Fโฆ) or a Slack file permalink (โฆ/files/<UID>/<FID>/<name>).
|
|
1079
|
+
function parseFileId(ref: string): string | undefined {
|
|
1080
|
+
if (/^F[A-Z0-9]+$/i.test(ref)) return ref;
|
|
1081
|
+
return ref.match(/\/files\/[A-Za-z0-9]+\/(F[A-Za-z0-9]+)/)?.[1];
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
async function cmdDownload(token: string, cookie: string | undefined, ref: string, dest?: string): Promise<void> {
|
|
1085
|
+
const fileId = parseFileId(ref);
|
|
1086
|
+
if (!fileId) {
|
|
1087
|
+
throw new Error(
|
|
1088
|
+
`Not a file ID or file permalink: ${ref}\n` +
|
|
1089
|
+
`Expected Fโฆ or https://<ws>.slack.com/files/<UID>/<FID>/<name>`,
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
const info = asRecord((await filesInfo(token, fileId, cookie)) as Json);
|
|
1093
|
+
const f = asRecord(info.file);
|
|
1094
|
+
const name = typeof f.name === "string" ? f.name : typeof f.title === "string" ? f.title : fileId;
|
|
1095
|
+
const url = typeof f.url_private_download === "string" ? f.url_private_download
|
|
1096
|
+
: typeof f.url_private === "string" ? f.url_private : "";
|
|
1097
|
+
if (!url) throw new Error(`files.info returned no download URL for ${fileId}`);
|
|
1098
|
+
|
|
1099
|
+
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
|
|
1100
|
+
if (cookie) headers.Cookie = `d=${cookie}`;
|
|
1101
|
+
const res = await fetch(url, { headers });
|
|
1102
|
+
if (!res.ok) throw new Error(`Download failed: HTTP ${res.status} ${res.statusText}`);
|
|
1103
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1104
|
+
|
|
1105
|
+
const { statSync, existsSync } = await import("node:fs");
|
|
1106
|
+
const { basename } = await import("node:path");
|
|
1107
|
+
let outPath = dest ?? name;
|
|
1108
|
+
if (dest && existsSync(dest) && statSync(dest).isDirectory()) outPath = join(dest, basename(name));
|
|
1109
|
+
writeFileSync(outPath, buf);
|
|
1110
|
+
console.log(`โ Downloaded ${name} (${fmtSize(buf.length)}) โ ${outPath}`);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1036
1113
|
// --- dispatch ---
|
|
1037
1114
|
|
|
1038
1115
|
async function main(): Promise<void> {
|
|
@@ -1493,6 +1570,16 @@ async function main(): Promise<void> {
|
|
|
1493
1570
|
await cmdUpload(tok(argv as W), args);
|
|
1494
1571
|
},
|
|
1495
1572
|
)
|
|
1573
|
+
.command(
|
|
1574
|
+
"download <ref> [dest]",
|
|
1575
|
+
"Download a file attachment (by file ID or file permalink) to disk",
|
|
1576
|
+
(y) => y
|
|
1577
|
+
.positional("ref", { type: "string", demandOption: true, describe: "File ID (Fโฆ) or Slack file permalink" })
|
|
1578
|
+
.positional("dest", { type: "string", describe: "Output path or directory (default: ./<filename>)" }),
|
|
1579
|
+
async (argv) => {
|
|
1580
|
+
await cmdDownload(tok(argv as W), ck(argv as W), argv.ref!, argv.dest);
|
|
1581
|
+
},
|
|
1582
|
+
)
|
|
1496
1583
|
.command(
|
|
1497
1584
|
"dump",
|
|
1498
1585
|
"Bulk export channel history as markdown",
|
package/ts/format.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
import { listConversationMembers, listUsers, userInfo, userName, type Json } from "./slack.ts";
|
|
4
4
|
|
|
5
5
|
/** Match an `@handle` token at a word boundary, capturing the handle.
|
|
6
|
-
* - The negative lookbehind keeps `@` inside emails (`a@b.com`) from matching
|
|
6
|
+
* - The negative lookbehind keeps `@` inside emails (`a@b.com`) from matching,
|
|
7
|
+
* and the `<` keeps already-encoded mentions (`<@U0123>`) from being picked up
|
|
8
|
+
* as a handle named after the user ID (Slack resolves those natively).
|
|
7
9
|
* - Each `.` must be followed by more handle chars, so a trailing sentence dot
|
|
8
10
|
* (`thanks @taku.`) is left out of the capture. */
|
|
9
11
|
function mentionRe(): RegExp {
|
|
10
|
-
return /(?<![A-Za-z0-9._
|
|
12
|
+
return /(?<![A-Za-z0-9._@<-])@([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)/g;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
/** Lowercase + strip hyphens/underscores/whitespace for loose handle matching. */
|
|
@@ -91,6 +93,115 @@ export async function encodeMentions(
|
|
|
91
93
|
});
|
|
92
94
|
}
|
|
93
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
|
+
|
|
94
205
|
export async function resolveMentions(
|
|
95
206
|
token: string,
|
|
96
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,
|