slack-term 1.15.0 → 1.17.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.15.0",
3
+ "version": "1.17.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/cli.ts CHANGED
@@ -46,7 +46,7 @@ import {
46
46
  getPath,
47
47
  type Json,
48
48
  } from "./slack.ts";
49
- import { dayLabel, formatYmdHm, resolveDateMarkup, resolveMentions } from "./format.ts";
49
+ import { dayLabel, encodeMentions, formatYmdHm, resolveDateMarkup, resolveMentions } from "./format.ts";
50
50
 
51
51
  function loadDotenv(path: string): void {
52
52
  if (!existsSync(path)) return;
@@ -188,31 +188,52 @@ async function formatMsgLine(
188
188
  return `${stamp} ${who}: ${body}${tail}`;
189
189
  }
190
190
 
191
+ // Slim a raw message down to the fields a script actually needs. Keeps the
192
+ // author's user ID (incl. external / Slack-Connect guests that never appear in
193
+ // users.list) so callers can resolve mentions without scraping history by hand.
194
+ function slimMsg(m: Record<string, Json>): Record<string, Json> {
195
+ return {
196
+ ts: m.ts ?? null,
197
+ user: m.user ?? null,
198
+ username: m.username ?? null,
199
+ bot_id: m.bot_id ?? null,
200
+ thread_ts: m.thread_ts ?? null,
201
+ text: typeof m.text === "string" ? m.text : "",
202
+ };
203
+ }
204
+
191
205
  // --- msgs <target> — channel/DM history with timestamps ---
192
- async function cmdMsgsTarget(token: string, target: string, limit: number): Promise<void> {
206
+ async function cmdMsgsTarget(token: string, target: string, limit: number, format: "text" | "jsonl" = "text"): Promise<void> {
193
207
  const parsed = parseSlackPermalink(target);
194
208
  const channelId = await resolveChannel(token, target);
195
209
  const cache = new Map<string, string>();
196
- if (parsed?.threadTs) {
197
- const resp = (await replies(token, channelId, parsed.threadTs, limit)) as Record<string, Json>;
198
- const msgs = asArray(resp.messages).map(asRecord);
199
- for (const m of msgs) {
200
- console.log(await formatMsgLine(token, m, cache));
210
+ const fetchMsgs = async (): Promise<Record<string, Json>[]> => {
211
+ if (parsed?.threadTs) {
212
+ const resp = (await replies(token, channelId, parsed.threadTs, limit)) as Record<string, Json>;
213
+ return asArray(resp.messages).map(asRecord);
201
214
  }
202
- } else {
203
215
  const hist = (await history(token, channelId, limit)) as Record<string, Json>;
204
- const msgs = asArray(hist.messages).map(asRecord);
205
- for (const m of msgs.reverse()) {
206
- console.log(await formatMsgLine(token, m, cache));
207
- }
216
+ return asArray(hist.messages).map(asRecord).reverse();
217
+ };
218
+ const msgs = await fetchMsgs();
219
+ if (format === "jsonl") {
220
+ for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
221
+ return;
222
+ }
223
+ for (const m of msgs) {
224
+ console.log(await formatMsgLine(token, m, cache));
208
225
  }
209
226
  }
210
227
 
211
228
  // --- thread ---
212
- async function cmdThread(token: string, target: string, ts: string, limit: number): Promise<void> {
229
+ async function cmdThread(token: string, target: string, ts: string, limit: number, format: "text" | "jsonl" = "text"): Promise<void> {
213
230
  const channelId = await resolveChannel(token, target);
214
231
  const resp = (await replies(token, channelId, parseInputTs(ts), limit)) as Record<string, Json>;
215
232
  const msgs = asArray(resp.messages).map(asRecord);
233
+ if (format === "jsonl") {
234
+ for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
235
+ return;
236
+ }
216
237
  const cache = new Map<string, string>();
217
238
  for (const m of msgs) {
218
239
  console.log(await formatMsgLine(token, m, cache));
@@ -632,6 +653,7 @@ interface EditArgs {
632
653
  newText: string;
633
654
  code?: string;
634
655
  channelId?: string;
656
+ mentions?: boolean;
635
657
  }
636
658
  async function cmdEdit(token: string, args: EditArgs): Promise<void> {
637
659
  const { ref, ts } = splitRefTs(args.target);
@@ -654,18 +676,23 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
654
676
  }
655
677
  const originalText = typeof original.text === "string" ? original.text : "";
656
678
 
657
- const code = safetyCode(originalText, args.newText);
679
+ // Convert @handle → <@USERID> before hashing/editing (unresolved stay as text).
680
+ const newText = args.mentions
681
+ ? await encodeMentions(token, args.newText, channelId)
682
+ : args.newText;
683
+
684
+ const code = safetyCode(originalText, newText);
658
685
  if (args.code !== code) {
659
686
  requireCode(args.code, code, [
660
687
  `--- Original message -------------------------`,
661
688
  ...originalText.split("\n").map((l) => ` ${l}`),
662
689
  `--- Replacing with ---------------------------`,
663
- ...args.newText.split("\n").map((l) => ` ${l}`),
690
+ ...newText.split("\n").map((l) => ` ${l}`),
664
691
  `--------------------------------────────────`,
665
692
  ]);
666
693
  }
667
694
 
668
- const newTs = await editMessage(token, channelId, ts, args.newText);
695
+ const newTs = await editMessage(token, channelId, ts, newText);
669
696
  console.log(`✓ Edited (ts: ${newTs})`);
670
697
  }
671
698
 
@@ -720,6 +747,11 @@ interface SendArgs {
720
747
  userId?: string;
721
748
  asBot?: boolean;
722
749
  broadcast?: boolean;
750
+ mentions?: boolean;
751
+ // Token used to resolve @handle mentions (needs users:read). Defaults to the
752
+ // send token; set to the user token when sending --as-bot so the bot token
753
+ // need not carry users:read.
754
+ mentionToken?: string;
723
755
  }
724
756
 
725
757
  // Detect the silent-failure footgun: DMing yourself with your own user token.
@@ -756,6 +788,12 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
756
788
  else if (args.userId) channelId = await openDm(token, args.userId);
757
789
  else channelId = await resolveChannel(token, ref);
758
790
 
791
+ // Convert @handle tokens to <@USERID> before hashing/sending so the safety
792
+ // gate covers exactly what will be posted. Unresolved handles stay as text.
793
+ const message = args.mentions
794
+ ? await encodeMentions(args.mentionToken ?? token, args.message, channelId)
795
+ : args.message;
796
+
759
797
  // Fetch last 1 message for context hash. Fail-soft: a bot token without
760
798
  // im:history (or channels:history) can still send — only the preview of the
761
799
  // prior message is lost, so default to empty rather than blocking the send.
@@ -779,7 +817,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
779
817
 
780
818
  // Hash covers the destination too — a code minted for one channel/thread
781
819
  // cannot confirm a send to another.
782
- const code = safetyCode(channelId, threadTs ?? "", lastText, args.message);
820
+ const code = safetyCode(channelId, threadTs ?? "", lastText, message);
783
821
 
784
822
  if (args.code !== code) {
785
823
  const dest = await destLabel(token, channelId, ref);
@@ -791,11 +829,11 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
791
829
  ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
792
830
  `--- Sending ----------------------------------`,
793
831
  destLine,
794
- ` Message: ${args.message}`,
832
+ ` Message: ${message}`,
795
833
  `--------------------------------────────────`,
796
834
  ]);
797
835
  }
798
- const ts = await slackSend(token, channelId, args.message, threadTs, args.broadcast);
836
+ const ts = await slackSend(token, channelId, message, threadTs, args.broadcast);
799
837
  let permalink = "";
800
838
  try {
801
839
  permalink = await getPermalink(token, channelId, ts);
@@ -1025,10 +1063,13 @@ async function main(): Promise<void> {
1025
1063
  "Browse messages",
1026
1064
  (y) => y
1027
1065
  .positional("target", { type: "string", describe: "#channel, @user, or URL" })
1028
- .option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" }),
1066
+ .option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" })
1067
+ .option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
1068
+ .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
1029
1069
  async (argv) => {
1030
1070
  const token = tok(argv as W);
1031
- if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit);
1071
+ const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
1072
+ if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format);
1032
1073
  else await cmdMsgs(token);
1033
1074
  },
1034
1075
  )
@@ -1038,9 +1079,12 @@ async function main(): Promise<void> {
1038
1079
  (y) => y
1039
1080
  .positional("target", { type: "string", demandOption: true })
1040
1081
  .positional("ts", { type: "string", demandOption: true })
1041
- .option("limit", { alias: "n", type: "number", default: 100 }),
1082
+ .option("limit", { alias: "n", type: "number", default: 100 })
1083
+ .option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
1084
+ .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
1042
1085
  async (argv) => {
1043
- await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit);
1086
+ const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
1087
+ await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format);
1044
1088
  },
1045
1089
  )
1046
1090
  .command(
@@ -1219,13 +1263,20 @@ async function main(): Promise<void> {
1219
1263
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
1220
1264
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
1221
1265
  .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." }),
1266
+ .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." })
1267
+ .option("mentions", { type: "boolean", default: false, describe: "Convert @handle tokens in the message to real <@USERID> mentions (resolves via users.list, then channel members for Slack Connect guests; unresolved handles stay as plain text)" }),
1223
1268
  async (argv) => {
1224
1269
  const args: SendArgs = { target: argv.target!, message: argv.message! };
1225
1270
  if (argv.code) args.code = argv.code;
1226
1271
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1227
1272
  if (argv["user-id"]) args.userId = argv["user-id"];
1228
1273
  if (argv.broadcast) args.broadcast = true;
1274
+ if (argv.mentions) {
1275
+ args.mentions = true;
1276
+ // Resolve mentions with the user token (has users:read) even when the
1277
+ // message itself is sent via the bot token.
1278
+ args.mentionToken = tok(argv as W);
1279
+ }
1229
1280
  let sendToken: string;
1230
1281
  if (argv["as-bot"]) {
1231
1282
  const botToken = resolveBotToken();
@@ -1342,11 +1393,13 @@ async function main(): Promise<void> {
1342
1393
  .positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
1343
1394
  .positional("newText", { type: "string", demandOption: true })
1344
1395
  .option("code", { type: "string", describe: "Safety hash to confirm edit" })
1345
- .option("channel-id", { type: "string", describe: "Raw channel ID" }),
1396
+ .option("channel-id", { type: "string", describe: "Raw channel ID" })
1397
+ .option("mentions", { type: "boolean", default: false, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (unresolved handles stay as plain text)" }),
1346
1398
  async (argv) => {
1347
1399
  const args: EditArgs = { target: argv.target!, newText: argv.newText! };
1348
1400
  if (argv.code) args.code = argv.code;
1349
1401
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1402
+ if (argv.mentions) args.mentions = true;
1350
1403
  await cmdEdit(tok(argv as W), args);
1351
1404
  },
1352
1405
  )
package/ts/format.ts CHANGED
@@ -1,6 +1,95 @@
1
1
  // Text-formatting helpers: mention/date-markup resolution, day grouping.
2
2
 
3
- import { userName } from "./slack.ts";
3
+ import { listConversationMembers, listUsers, userInfo, userName, type Json } from "./slack.ts";
4
+
5
+ /** Match an `@handle` token at a word boundary, capturing the handle.
6
+ * - The negative lookbehind keeps `@` inside emails (`a@b.com`) from matching.
7
+ * - Each `.` must be followed by more handle chars, so a trailing sentence dot
8
+ * (`thanks @taku.`) is left out of the capture. */
9
+ function mentionRe(): RegExp {
10
+ return /(?<![A-Za-z0-9._@-])@([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)/g;
11
+ }
12
+
13
+ /** Lowercase + strip hyphens/underscores/whitespace for loose handle matching. */
14
+ function normHandle(s: string): string {
15
+ return s.toLowerCase().replace(/[-_\s]/g, "");
16
+ }
17
+
18
+ function asRecord(v: Json | undefined): Record<string, Json> {
19
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, Json>) : {};
20
+ }
21
+
22
+ /** True when a user record matches `@handle` by name / real_name / display_name / email. */
23
+ function userMatchesHandle(u: Record<string, Json>, handle: string): boolean {
24
+ const nh = normHandle(handle);
25
+ const profile = asRecord(u.profile);
26
+ for (const v of [u.name, u.real_name, profile.display_name, profile.real_name]) {
27
+ if (typeof v === "string" && v && normHandle(v) === nh) return true;
28
+ }
29
+ const email = typeof profile.email === "string" ? profile.email.toLowerCase() : "";
30
+ return email !== "" && email === handle.toLowerCase();
31
+ }
32
+
33
+ /**
34
+ * Rewrite `@handle` tokens in `text` to `<@USERID>` so Slack renders them as
35
+ * real mentions. Resolution order per handle:
36
+ * 1. workspace users.list (name / real_name / display_name / email)
37
+ * 2. the target channel's members (conversations.members → users.info) — this
38
+ * reaches Slack Connect guests absent from the workspace users.list
39
+ * Handles that resolve to nothing are left as plain text (never destroyed) and
40
+ * reported via `warn`. Lookups are cached so each API call runs at most once.
41
+ */
42
+ export async function encodeMentions(
43
+ token: string,
44
+ text: string,
45
+ channelId: string | undefined,
46
+ opts: { warn?: (msg: string) => void } = {},
47
+ ): Promise<string> {
48
+ const warn = opts.warn ?? ((m: string) => process.stderr.write(`${m}\n`));
49
+
50
+ const handles = new Set<string>();
51
+ for (const m of text.matchAll(mentionRe())) handles.add(m[1]!);
52
+ if (handles.size === 0) return text;
53
+
54
+ const resolved = new Map<string, string>(); // handle -> user ID
55
+
56
+ // Step 1: workspace users.list (fetched once).
57
+ const wsResp = (await listUsers(token)) as { members?: Json[] };
58
+ const wsUsers = (wsResp.members ?? []).map(asRecord);
59
+ const unresolved: string[] = [];
60
+ for (const handle of handles) {
61
+ const hit = wsUsers.find((u) => userMatchesHandle(u, handle));
62
+ const id = hit && typeof hit.id === "string" ? hit.id : "";
63
+ if (id) resolved.set(handle, id);
64
+ else unresolved.push(handle);
65
+ }
66
+
67
+ // Step 2: channel members (fetched once) — covers external/Connect guests.
68
+ if (unresolved.length > 0 && channelId) {
69
+ const memberIds = await listConversationMembers(token, channelId);
70
+ const infos: Record<string, Json>[] = [];
71
+ for (const uid of memberIds) {
72
+ const info = asRecord((await userInfo(token, uid)) as Json);
73
+ const u = asRecord(info.user);
74
+ if (typeof u.id !== "string") u.id = uid;
75
+ infos.push(u);
76
+ }
77
+ for (const handle of unresolved) {
78
+ const hit = infos.find((u) => userMatchesHandle(u, handle));
79
+ const id = hit && typeof hit.id === "string" ? hit.id : "";
80
+ if (id) resolved.set(handle, id);
81
+ }
82
+ }
83
+
84
+ for (const handle of handles) {
85
+ if (!resolved.has(handle)) warn(`warn: unresolved mention @${handle} (left as text)`);
86
+ }
87
+
88
+ return text.replace(mentionRe(), (full, handle: string) => {
89
+ const id = resolved.get(handle);
90
+ return id ? `<@${id}>` : full;
91
+ });
92
+ }
4
93
 
5
94
  export async function resolveMentions(
6
95
  token: string,
package/ts/slack.ts CHANGED
@@ -558,6 +558,26 @@ export async function listUsers(token: string): Promise<Json> {
558
558
  return { members: allMembers };
559
559
  }
560
560
 
561
+ /** List the member user IDs of a channel/DM (paginated).
562
+ * Used by mention encoding to reach members — including Slack Connect guests —
563
+ * who do not appear in the workspace-wide users.list. */
564
+ export async function listConversationMembers(token: string, channel: string): Promise<string[]> {
565
+ const all: string[] = [];
566
+ let cursor = "";
567
+ while (true) {
568
+ const params: Record<string, string> = { channel, limit: "200" };
569
+ if (cursor) params.cursor = cursor;
570
+ const resp = (await get(token, "conversations.members", params)) as {
571
+ members?: string[];
572
+ response_metadata?: { next_cursor?: string };
573
+ };
574
+ all.push(...(resp.members ?? []));
575
+ cursor = resp.response_metadata?.next_cursor ?? "";
576
+ if (!cursor) break;
577
+ }
578
+ return all;
579
+ }
580
+
561
581
  // Draft API (internal — requires xoxc session token + xoxd cookie)
562
582
  export async function listDrafts(token: string, cookie?: string): Promise<Json> {
563
583
  return postSession(token, "drafts.list", {}, cookie);