slack-term 1.16.0 → 1.18.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.16.0",
3
+ "version": "1.18.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;
@@ -653,6 +653,7 @@ interface EditArgs {
653
653
  newText: string;
654
654
  code?: string;
655
655
  channelId?: string;
656
+ mentions?: boolean;
656
657
  }
657
658
  async function cmdEdit(token: string, args: EditArgs): Promise<void> {
658
659
  const { ref, ts } = splitRefTs(args.target);
@@ -675,18 +676,23 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
675
676
  }
676
677
  const originalText = typeof original.text === "string" ? original.text : "";
677
678
 
678
- 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);
679
685
  if (args.code !== code) {
680
686
  requireCode(args.code, code, [
681
687
  `--- Original message -------------------------`,
682
688
  ...originalText.split("\n").map((l) => ` ${l}`),
683
689
  `--- Replacing with ---------------------------`,
684
- ...args.newText.split("\n").map((l) => ` ${l}`),
690
+ ...newText.split("\n").map((l) => ` ${l}`),
685
691
  `--------------------------------────────────`,
686
692
  ]);
687
693
  }
688
694
 
689
- const newTs = await editMessage(token, channelId, ts, args.newText);
695
+ const newTs = await editMessage(token, channelId, ts, newText);
690
696
  console.log(`✓ Edited (ts: ${newTs})`);
691
697
  }
692
698
 
@@ -741,6 +747,11 @@ interface SendArgs {
741
747
  userId?: string;
742
748
  asBot?: boolean;
743
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;
744
755
  }
745
756
 
746
757
  // Detect the silent-failure footgun: DMing yourself with your own user token.
@@ -777,6 +788,12 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
777
788
  else if (args.userId) channelId = await openDm(token, args.userId);
778
789
  else channelId = await resolveChannel(token, ref);
779
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
+
780
797
  // Fetch last 1 message for context hash. Fail-soft: a bot token without
781
798
  // im:history (or channels:history) can still send — only the preview of the
782
799
  // prior message is lost, so default to empty rather than blocking the send.
@@ -800,7 +817,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
800
817
 
801
818
  // Hash covers the destination too — a code minted for one channel/thread
802
819
  // cannot confirm a send to another.
803
- const code = safetyCode(channelId, threadTs ?? "", lastText, args.message);
820
+ const code = safetyCode(channelId, threadTs ?? "", lastText, message);
804
821
 
805
822
  if (args.code !== code) {
806
823
  const dest = await destLabel(token, channelId, ref);
@@ -812,11 +829,11 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
812
829
  ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
813
830
  `--- Sending ----------------------------------`,
814
831
  destLine,
815
- ` Message: ${args.message}`,
832
+ ` Message: ${message}`,
816
833
  `--------------------------------────────────`,
817
834
  ]);
818
835
  }
819
- const ts = await slackSend(token, channelId, args.message, threadTs, args.broadcast);
836
+ const ts = await slackSend(token, channelId, message, threadTs, args.broadcast);
820
837
  let permalink = "";
821
838
  try {
822
839
  permalink = await getPermalink(token, channelId, ts);
@@ -1246,13 +1263,23 @@ async function main(): Promise<void> {
1246
1263
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
1247
1264
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
1248
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" })
1249
- .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: true, describe: "Convert @handle tokens to real <@USERID> mentions (on by default; resolves via users.list, then channel members for Slack Connect guests). Unresolved handles stay as plain text. The confirm preview shows the converted message before sending. Disable with --no-mentions for literal @text." }),
1250
1268
  async (argv) => {
1251
1269
  const args: SendArgs = { target: argv.target!, message: argv.message! };
1252
1270
  if (argv.code) args.code = argv.code;
1253
1271
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1254
1272
  if (argv["user-id"]) args.userId = argv["user-id"];
1255
1273
  if (argv.broadcast) args.broadcast = true;
1274
+ // Mentions on by default; `--no-mentions` (yargs negation) sets it false.
1275
+ // A message with no `@token` short-circuits inside encodeMentions, so the
1276
+ // common case pays no extra API call.
1277
+ if (argv.mentions !== false) {
1278
+ args.mentions = true;
1279
+ // Resolve mentions with the user token (has users:read) even when the
1280
+ // message itself is sent via the bot token.
1281
+ args.mentionToken = tok(argv as W);
1282
+ }
1256
1283
  let sendToken: string;
1257
1284
  if (argv["as-bot"]) {
1258
1285
  const botToken = resolveBotToken();
@@ -1369,11 +1396,13 @@ async function main(): Promise<void> {
1369
1396
  .positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
1370
1397
  .positional("newText", { type: "string", demandOption: true })
1371
1398
  .option("code", { type: "string", describe: "Safety hash to confirm edit" })
1372
- .option("channel-id", { type: "string", describe: "Raw channel ID" }),
1399
+ .option("channel-id", { type: "string", describe: "Raw channel ID" })
1400
+ .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (on by default). Unresolved handles stay as plain text. Disable with --no-mentions for literal @text." }),
1373
1401
  async (argv) => {
1374
1402
  const args: EditArgs = { target: argv.target!, newText: argv.newText! };
1375
1403
  if (argv.code) args.code = argv.code;
1376
1404
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1405
+ if (argv.mentions !== false) args.mentions = true;
1377
1406
  await cmdEdit(tok(argv as W), args);
1378
1407
  },
1379
1408
  )
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);