slack-term 1.19.1 โ†’ 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.19.1",
3
+ "version": "1.20.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
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 tail = reactions ? `\n ${reactions}` : "";
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
@@ -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,