slack-term 1.21.2 → 1.23.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.21.2",
3
+ "version": "1.23.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/cli.ts CHANGED
@@ -15,6 +15,7 @@ import { cmdTail } from "./tail.ts";
15
15
 
16
16
  import {
17
17
  authTest,
18
+ authScopes,
18
19
  authTestSession,
19
20
  conversationInfoSession,
20
21
  createChannel,
@@ -51,7 +52,7 @@ import {
51
52
  getPath,
52
53
  type Json,
53
54
  } from "./slack.ts";
54
- import { dayLabel, encodeMentions, findUntaggedMentions, formatYmdHm, resolveDateMarkup, resolveMentions } from "./format.ts";
55
+ import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
55
56
 
56
57
  function loadDotenv(path: string): void {
57
58
  if (!existsSync(path)) return;
@@ -590,6 +591,23 @@ function safetyCode(...parts: string[]): string {
590
591
  return sha256Hex(parts.join("\n")).slice(0, 4);
591
592
  }
592
593
 
594
+ /** Strip ANSI escape sequences and control characters before drawing text the
595
+ * terminal treats as trusted. Slack lets any user pick their own display name,
596
+ * so an attacker could embed color/cursor codes; neutralize them here. */
597
+ function stripTerminalControls(s: string): string {
598
+ return s
599
+ // CSI sequences (colors, cursor moves, …)
600
+ // eslint-disable-next-line no-control-regex
601
+ .replace(/\x1b\[[0-9;:?]*[ -/]*[@-~]/g, "")
602
+ // any remaining control chars, including a lone ESC and C1 range
603
+ // eslint-disable-next-line no-control-regex
604
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, "")
605
+ // Unicode bidi controls (marks, embeddings, overrides, isolates) — a crafted
606
+ // display name could use these to spoof or reorder the rendered line.
607
+ // ALM, LRM/RLM, LRE/RLE/PDF/LRO/RLO, LRI/RLI/FSI/PDI.
608
+ .replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
609
+ }
610
+
593
611
  // Slack API method → the token scope that grants it, for missing_scope guidance.
594
612
  const SCOPE_FOR_METHOD: Record<string, string> = {
595
613
  "reactions.add": "reactions:write",
@@ -912,6 +930,9 @@ interface SendArgs {
912
930
  // send token; set to the user token when sending --as-bot so the bot token
913
931
  // need not carry users:read.
914
932
  mentionToken?: string;
933
+ // Session cookie (xoxd) for the mention token — required for an xoxc- user
934
+ // token to call users.list on the public API (it is rejected without it).
935
+ mentionCookie?: string;
915
936
  }
916
937
 
917
938
  // Detect the silent-failure footgun: DMing yourself with your own user token.
@@ -948,23 +969,50 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
948
969
  else if (args.userId) channelId = await openDm(token, args.userId);
949
970
  else channelId = await resolveChannel(token, ref);
950
971
 
951
- // Convert @handle tokens to <@USERID> before hashing/sending so the safety
952
- // gate covers exactly what will be posted. Unresolved handles stay as text.
953
- const message = args.mentions
954
- ? await encodeMentions(args.mentionToken ?? token, args.message, channelId)
955
- : args.message;
972
+ // Convert @handle / @名前 tokens to <@USERID> before hashing/sending so the
973
+ // safety gate covers exactly what will be posted. Unresolved tokens stay as
974
+ // text. The detailed report drives the confirm-gate mention preview below.
975
+ let message = args.message;
976
+ let mentionReport: MentionEncodeResult | null = null;
977
+ if (args.mentions) {
978
+ mentionReport = await encodeMentionsDetailed(args.mentionToken ?? token, args.message, channelId, args.mentionCookie);
979
+ message = mentionReport.text;
980
+ // Prominent, always-visible warning for any @token that will NOT notify —
981
+ // so a mistyped or unresolved name (esp. a Japanese display name) is caught
982
+ // before it goes out as inert plain text. Shown even when --code is passed.
983
+ // Wording is shared with the library wrapper via mentionWarnings(). Strip
984
+ // control chars — the surfaces embed Slack-controlled display text.
985
+ for (const line of mentionWarnings(mentionReport.unresolved)) console.error(`⚠ ${stripTerminalControls(line)}`);
986
+ }
956
987
 
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).
988
+ // Warn-only lint: names written as plain text (no leading @) that map to a
989
+ // known workspace member but aren't <@USERID>-tagged — they won't be notified.
990
+ // Never blocks; fail-soft (a missing users:read scope or API error must not
991
+ // stall a send).
960
992
  try {
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}>?)`);
993
+ for (const u of await findUntaggedMentions(args.mentionToken ?? token, message, args.mentionCookie)) {
994
+ // u.surface / u.display carry Slack-controlled display text strip control chars.
995
+ console.error(`⚠ possible untagged mention: ${stripTerminalControls(u.surface)} — @${stripTerminalControls(u.display)} won't be notified (did you mean <@${u.userId}>?)`);
963
996
  }
964
997
  } catch {
965
998
  // best-effort; ignore
966
999
  }
967
1000
 
1001
+ // Confirm-gate mention preview: exactly who resolves (→ notified) and who
1002
+ // stays literal. Inserted into the dry-run block so the sender eyeballs the
1003
+ // tagging before committing with --code.
1004
+ const mentionLines: string[] = [];
1005
+ if (mentionReport && (mentionReport.resolved.length > 0 || mentionReport.unresolved.length > 0)) {
1006
+ mentionLines.push(`--- Mentions ---------------------------------`);
1007
+ for (const r of mentionReport.resolved) {
1008
+ mentionLines.push(` ✓ ${stripTerminalControls(r.surface)} → @${stripTerminalControls(r.display)} (${r.userId}) — will notify`);
1009
+ }
1010
+ for (const u of mentionReport.unresolved) {
1011
+ const why = u.reason === "ambiguous" ? "ambiguous" : u.reason === "unavailable" ? "lookup unavailable" : "no match";
1012
+ mentionLines.push(` ⚠ ${stripTerminalControls(u.surface)} — plain text, NOT notified (${why})`);
1013
+ }
1014
+ }
1015
+
968
1016
  // Gather context for the confirm gate. For a THREAD reply, pull the thread's
969
1017
  // own recent messages so the preview shows what's already been said there —
970
1018
  // this is what stops accidental duplicate replies (the channel's last message
@@ -974,6 +1022,15 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
974
1022
  let lastText = "";
975
1023
  let lastUser = "?";
976
1024
  let threadMsgs: Record<string, Json>[] = [];
1025
+ // Author of the destination's TRUE most recent message (thread-scoped for a
1026
+ // thread reply, channel-scoped for a top-level send) — feeds the unreplied-warn
1027
+ // check below. Tracks `user` (normal message) and `bot_id` (a `bot_message`
1028
+ // subtype post — e.g. `send --as-bot` — which carries bot_id/username but no
1029
+ // `user`) so ownership can be determined either way. Undefined when the
1030
+ // preview fetch failed (fail-soft).
1031
+ let lastMsgUserId: string | undefined;
1032
+ let lastMsgBotId: string | undefined;
1033
+ let lastMsgTs: string | undefined;
977
1034
  if (threadTs) {
978
1035
  try {
979
1036
  // Fetch a generous window and preview its tail. conversations.replies is
@@ -985,14 +1042,23 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
985
1042
  // Bind the hash to the thread's most recent message: if someone replies
986
1043
  // between preview and confirm, the code invalidates and re-previews.
987
1044
  lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
1045
+ lastMsgUserId = typeof lastMsg?.user === "string" ? lastMsg.user : undefined;
1046
+ lastMsgBotId = typeof lastMsg?.bot_id === "string" ? lastMsg.bot_id : undefined;
1047
+ lastMsgTs = typeof lastMsg?.ts === "string" ? lastMsg.ts : undefined;
988
1048
  } catch {
989
1049
  // best-effort preview only
990
1050
  }
991
1051
  } else {
992
1052
  try {
993
1053
  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];
1054
+ // limit=1 already narrows this to exactly the channel's true last message
1055
+ // (subtype or not) take it raw for the ownership check below. The
1056
+ // subtype filter is only for the human-readable preview text/author: it
1057
+ // deliberately hides system events (channel_join etc.), but a bot_message
1058
+ // post is a real message and must NOT be dropped here, or a bot's own
1059
+ // last post would silently disable the unreplied-warn for --as-bot sends.
1060
+ const rawMsgs = asArray(ctx.messages).map(asRecord);
1061
+ const lastMsg = rawMsgs.filter((m) => m.subtype === undefined || m.subtype === null)[0];
996
1062
  lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
997
1063
  // Resolve the author's user ID to a display name for the preview; fall back
998
1064
  // to the bot username, then the raw ID. userName() is fail-soft.
@@ -1001,11 +1067,54 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1001
1067
  : typeof lastMsg?.username === "string"
1002
1068
  ? lastMsg.username
1003
1069
  : "?";
1070
+ const rawLast = rawMsgs[0];
1071
+ lastMsgUserId = typeof rawLast?.user === "string" ? rawLast.user : undefined;
1072
+ lastMsgBotId = typeof rawLast?.bot_id === "string" ? rawLast.bot_id : undefined;
1073
+ lastMsgTs = typeof rawLast?.ts === "string" ? rawLast.ts : undefined;
1004
1074
  } catch {
1005
1075
  // best-effort preview only
1006
1076
  }
1007
1077
  }
1008
1078
 
1079
+ // Unreplied guard: the destination's last message is our own and no one has
1080
+ // replied since (by definition, since it's still the most recent message) —
1081
+ // sending another new message risks spamming the same person twice. Warn
1082
+ // (never block) and point at `slack edit` on that message instead. Reply-status
1083
+ // based, not time-based, so it still fires even long after the last send.
1084
+ // Self-identity covers both a normal post (`user` = our user id) and a
1085
+ // `bot_message`-subtype post (`bot_id` = our app's bot id, no `user`), the
1086
+ // shape `send --as-bot` produces. Opt out with SLACK_UNREPLIED_WARN=0.
1087
+ if (process.env.SLACK_UNREPLIED_WARN !== "0" && lastMsgTs && (lastMsgUserId || lastMsgBotId)) {
1088
+ try {
1089
+ const self = await authScopes(token);
1090
+ const isSelfAuthor =
1091
+ (!!lastMsgUserId && !!self.userId && self.userId === lastMsgUserId) ||
1092
+ (!!lastMsgBotId && !!self.botId && self.botId === lastMsgBotId);
1093
+ if (isSelfAuthor) {
1094
+ // Prefer a real permalink (exact, copy-pasteable). If that lookup fails,
1095
+ // fall back to a form guaranteed parseable by `slack edit` regardless of
1096
+ // what shape `ref` is in (a bare channel/user name, a raw ID resolved
1097
+ // from a permalink, or a thread ref) — `#<channelId>:<ts>` always yields
1098
+ // a ts split, and `--channel-id` makes the leading "#name" irrelevant to
1099
+ // resolution, so it never depends on ref's original prefix or an
1100
+ // embedded thread_ts colliding with the appended message ts.
1101
+ let editCmd = `slack edit "#${channelId}:${lastMsgTs}" "<新しい本文>" --channel-id ${channelId}`;
1102
+ try {
1103
+ const permalink = await getPermalink(token, channelId, lastMsgTs);
1104
+ if (permalink) editCmd = `slack edit "${permalink}" "<新しい本文>"`;
1105
+ } catch {
1106
+ // fall back to the --channel-id form above
1107
+ }
1108
+ console.error(`⚠ 相手はまだ返信していません(最後のメッセージはあなたのものです)。`);
1109
+ console.error(` 連投を避けるため、新規送信でなく直前のメッセージの edit を検討してください:`);
1110
+ console.error(` ${editCmd}`);
1111
+ console.error(` このまま送信する場合は --code=XXXX で確定。`);
1112
+ }
1113
+ } catch {
1114
+ // best-effort; a failed self-identity lookup must not block the send
1115
+ }
1116
+ }
1117
+
1009
1118
  // Duplicate guard: if the outgoing message closely matches something already
1010
1119
  // in the thread, warn (never block). Catches re-posting a reply you already
1011
1120
  // sent. Threshold is deliberately high so only near-identical text trips it.
@@ -1040,6 +1149,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1040
1149
  requireCode(args.code, code, [
1041
1150
  `--- Recent messages in thread ----------------`,
1042
1151
  ...recentLines,
1152
+ ...mentionLines,
1043
1153
  `--- Sending ----------------------------------`,
1044
1154
  ` → ${dest} thread of ${parentLine} — THREAD REPLY`,
1045
1155
  ` Message: ${message}`,
@@ -1049,6 +1159,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1049
1159
  requireCode(args.code, code, [
1050
1160
  `--- Last message in channel ------------------`,
1051
1161
  ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
1162
+ ...mentionLines,
1052
1163
  `--- Sending ----------------------------------`,
1053
1164
  ` → ${dest} — NEW top-level message`,
1054
1165
  ` Message: ${message}`,
@@ -1597,8 +1708,11 @@ async function main(): Promise<void> {
1597
1708
  if (argv.mentions !== false) {
1598
1709
  args.mentions = true;
1599
1710
  // Resolve mentions with the user token (has users:read) even when the
1600
- // message itself is sent via the bot token.
1711
+ // message itself is sent via the bot token. Pass its cookie too so an
1712
+ // xoxc- desktop token can reach users.list (rejected without the cookie).
1601
1713
  args.mentionToken = tok(argv as W);
1714
+ const mc = ck(argv as W);
1715
+ if (mc) args.mentionCookie = mc;
1602
1716
  }
1603
1717
  let sendToken: string;
1604
1718
  if (argv["as-bot"]) {
package/ts/format.ts CHANGED
@@ -2,16 +2,6 @@
2
2
 
3
3
  import { listConversationMembers, listUsers, userInfo, userName, type Json } from "./slack.ts";
4
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
- * and the `<` keeps already-encoded mentions (`<@U0123>`) from being picked up
8
- * as a handle named after the user ID (Slack resolves those natively).
9
- * - Each `.` must be followed by more handle chars, so a trailing sentence dot
10
- * (`thanks @taku.`) is left out of the capture. */
11
- function mentionRe(): RegExp {
12
- return /(?<![A-Za-z0-9._@<-])@([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)/g;
13
- }
14
-
15
5
  /** Lowercase + strip hyphens/underscores/whitespace for loose handle matching. */
16
6
  function normHandle(s: string): string {
17
7
  return s.toLowerCase().replace(/[-_\s]/g, "");
@@ -32,65 +22,260 @@ function userMatchesHandle(u: Record<string, Json>, handle: string): boolean {
32
22
  return email !== "" && email === handle.toLowerCase();
33
23
  }
34
24
 
25
+ export type MentionResolution = { surface: string; display: string; userId: string };
26
+ // "no-match": the full intended lookup ran and the token genuinely matched no one.
27
+ // "unavailable": a directory source needed to resolve it could not be consulted
28
+ // (users:read missing, or an API/connection error) — so we cannot claim it is a
29
+ // non-user. "ambiguous": several users matched exactly.
30
+ export type UnresolvedMention = { surface: string; reason: "no-match" | "ambiguous" | "unavailable" };
31
+ /** Structured outcome of mention encoding, so callers (the send confirm gate)
32
+ * can preview exactly who will — and won't — be notified. */
33
+ export type MentionEncodeResult = {
34
+ text: string;
35
+ resolved: MentionResolution[];
36
+ unresolved: UnresolvedMention[];
37
+ };
38
+
39
+ /** Full normalized name-forms of a member — the WHOLE display_name / real_name /
40
+ * name, never split into parts. An explicit `@name` mention must match a full
41
+ * name (`@柏原大空`), never a bare surname fragment (`@柏原`), which would be
42
+ * ambiguous. Length ≥ 2. */
43
+ function fullNameForms(u: Record<string, Json>): string[] {
44
+ const profile = asRecord(u.profile);
45
+ const forms = new Set<string>();
46
+ for (const v of [profile.display_name, u.real_name, u.name, profile.real_name]) {
47
+ if (typeof v === "string" && v) {
48
+ const n = normHandle(v);
49
+ if (n.length >= 2) forms.add(n);
50
+ }
51
+ }
52
+ return [...forms];
53
+ }
54
+
55
+ type CjkMatch = { userId: string; matchLen: number; display: string } | "ambiguous" | undefined;
56
+
57
+ /** Directory match for a CJK `@名前` token. `run` is the raw token after `@`
58
+ * (e.g. "柏原大空さん"). A user resolves only when the token equals their full
59
+ * name exactly, or equals their full name followed by exactly one trailing
60
+ * honorific ("さん", "様", …) which is then left in the message text rather than
61
+ * swallowed into the tag. Deliberately NOT a partial-prefix match: it would
62
+ * mis-tag a surname-only member from a longer given name. `matchLen` is a RAW
63
+ * index into `run` (the length of the name part), computed with raw string ops
64
+ * so it stays correct even when the name contains characters `normHandle` drops
65
+ * (spaces, `-`, `_`). Returns "ambiguous" when two distinct users tie on the
66
+ * longest name match. */
67
+ function matchCjkRun(run: string, dir: Record<string, Json>[]): CjkMatch {
68
+ const nr = normHandle(run);
69
+ let best: { userId: string; matchLen: number; formLen: number; display: string } | undefined;
70
+ let ambiguous = false;
71
+ for (const u of dir) {
72
+ const id = typeof u.id === "string" ? u.id : "";
73
+ if (!id) continue;
74
+ for (const form of fullNameForms(u)) {
75
+ // Raw length of the name part matching `form`, or -1. Either the whole token
76
+ // is the name (exact), or the token ends with exactly one honorific and the
77
+ // remainder (raw) normalizes to the name. No particle / partial-prefix match:
78
+ // Japanese cannot distinguish "山田は…" (particle) from "山田はるか" (given
79
+ // name), so under-tag rather than risk notifying the wrong person.
80
+ let rawLen = -1;
81
+ if (nr === form) {
82
+ rawLen = run.length;
83
+ } else {
84
+ for (const h of HONORIFIC_SUFFIXES) {
85
+ if (run.length > h.length && run.endsWith(h) && normHandle(run.slice(0, run.length - h.length)) === form) {
86
+ rawLen = run.length - h.length;
87
+ break;
88
+ }
89
+ }
90
+ }
91
+ if (rawLen < 0) continue;
92
+ if (!best || form.length > best.formLen) {
93
+ best = { userId: id, matchLen: rawLen, formLen: form.length, display: displayNameOf(u) };
94
+ ambiguous = false;
95
+ } else if (form.length === best.formLen && best.userId !== id) {
96
+ ambiguous = true;
97
+ }
98
+ }
99
+ }
100
+ if (!best) return undefined;
101
+ return ambiguous ? "ambiguous" : { userId: best.userId, matchLen: best.matchLen, display: best.display };
102
+ }
103
+
35
104
  /**
36
- * Rewrite `@handle` tokens in `text` to `<@USERID>` so Slack renders them as
37
- * real mentions. Resolution order per handle:
38
- * 1. workspace users.list (name / real_name / display_name / email)
39
- * 2. the target channel's members (conversations.members → users.info) — this
40
- * reaches Slack Connect guests absent from the workspace users.list
41
- * Handles that resolve to nothing are left as plain text (never destroyed) and
42
- * reported via `warn`. Lookups are cached so each API call runs at most once.
105
+ * Rewrite `@handle` / `@名前` tokens in `text` to `<@USERID>` so Slack renders
106
+ * them as real mentions, returning a structured report of what did and did not
107
+ * resolve. Two kinds of token are recognized:
108
+ * - ASCII handles (`@alice`, `@t.matsuda19790127`) — matched whole against
109
+ * name / real_name / display_name / email.
110
+ * - CJK names (`@柏原大空`, `@柏原大空さん`) matched against the directory by
111
+ * exact full name, or full name + one trailing honorific (kept outside the
112
+ * tag). A display name written in kanji/kana (which the old ASCII-only regex
113
+ * could not even capture) resolves; anything only partially matching stays
114
+ * literal, to never notify the wrong person.
115
+ * Resolution order: (1) workspace users.list, then (2) the target channel's
116
+ * members (reaches Slack Connect guests absent from users.list). users.list is
117
+ * fail-soft: a lookup that cannot run (missing `users:read`, API error) yields an
118
+ * "unavailable" report entry instead of aborting the send, and is never reported
119
+ * as a definite "no-match". A name matching several users exactly is always left
120
+ * literal + reported "ambiguous" (fail-safe) — channel membership is never used
121
+ * to guess which duplicate was meant. Unresolved tokens are never destroyed.
43
122
  */
44
- export async function encodeMentions(
123
+ export async function encodeMentionsDetailed(
45
124
  token: string,
46
125
  text: string,
47
126
  channelId: string | undefined,
48
- opts: { warn?: (msg: string) => void } = {},
49
- ): Promise<string> {
50
- const warn = opts.warn ?? ((m: string) => process.stderr.write(`${m}\n`));
51
-
52
- const handles = new Set<string>();
53
- for (const m of text.matchAll(mentionRe())) handles.add(m[1]!);
54
- if (handles.size === 0) return text;
55
-
56
- const resolved = new Map<string, string>(); // handle -> user ID
57
-
58
- // Step 1: workspace users.list (fetched once).
59
- const wsResp = (await listUsers(token)) as { members?: Json[] };
60
- const wsUsers = (wsResp.members ?? []).map(asRecord);
61
- const unresolved: string[] = [];
62
- for (const handle of handles) {
63
- const hit = wsUsers.find((u) => userMatchesHandle(u, handle));
64
- const id = hit && typeof hit.id === "string" ? hit.id : "";
65
- if (id) resolved.set(handle, id);
66
- else unresolved.push(handle);
127
+ cookie?: string,
128
+ ): Promise<MentionEncodeResult> {
129
+ const NAME_RUN = `[${NAME_CHARS}ー]+`;
130
+ // One mention token = runs of name characters (any script) joined by internal
131
+ // `. _ -`, so a mixed-script / dotted handle is captured WHOLE: `@田中Taro`,
132
+ // `@佐藤.dev`, `@t.matsuda19790127`, `@Alice-A`. It never ends on a separator,
133
+ // so a trailing sentence dot ("@taku.") is left out. Capturing the FULL token —
134
+ // never a script-boundary prefix — is what lets the fail-closed exact match
135
+ // refuse to tag "田中" from "@田中Taro". Lookbehind guard skips emails (`a@b`),
136
+ // already-encoded `<@U…>`, and doubled `@@`.
137
+ const TOKEN = `${NAME_RUN}(?:[._-]+${NAME_RUN})*`;
138
+ const tokenRe = new RegExp(`(?<![A-Za-z0-9._@<-])@(${TOKEN})`, "gu");
139
+
140
+ const tokens = new Set<string>();
141
+ for (const m of text.matchAll(tokenRe)) tokens.add(m[1]!);
142
+ if (tokens.size === 0) return { text, resolved: [], unresolved: [] };
143
+
144
+ type R =
145
+ | { status: "resolved"; userId: string; matchLen: number; display: string }
146
+ | { status: "ambiguous" }
147
+ | { status: "pending" };
148
+ const res = new Map<string, R>();
149
+ for (const t of tokens) res.set(t, { status: "pending" });
150
+
151
+ const resolveAgainst = (dir: Record<string, Json>[]): void => {
152
+ for (const t of tokens) {
153
+ if (res.get(t)!.status !== "pending") continue;
154
+ if (isCjk(t)) {
155
+ const m = matchCjkRun(t, dir);
156
+ if (m === "ambiguous") res.set(t, { status: "ambiguous" });
157
+ else if (m) res.set(t, { status: "resolved", userId: m.userId, matchLen: m.matchLen, display: m.display });
158
+ } else {
159
+ // Fail-closed like the CJK path: userMatchesHandle also matches loosely on
160
+ // real_name / display_name / email, so if two distinct users match the same
161
+ // handle, resolve nothing and report ambiguous rather than notify the wrong
162
+ // person on a first-match win.
163
+ const hits = dir.filter((u) => userMatchesHandle(u, t));
164
+ const ids = new Set(hits.map((u) => (typeof u.id === "string" ? u.id : "")).filter(Boolean));
165
+ if (ids.size === 1) {
166
+ const hit = hits.find((u) => typeof u.id === "string")!;
167
+ res.set(t, { status: "resolved", userId: hit.id as string, matchLen: t.length, display: displayNameOf(hit) });
168
+ } else if (ids.size > 1) {
169
+ res.set(t, { status: "ambiguous" });
170
+ }
171
+ // ids.size === 0 → stays pending; may still resolve via the channel pass.
172
+ }
173
+ }
174
+ };
175
+
176
+ // Track each directory source's availability separately, so a still-unresolved
177
+ // token is only reported as a genuine "no-match" when every source that could
178
+ // have resolved it was actually consulted. A failed source (missing users:read,
179
+ // API error) makes the outcome "unavailable" (indeterminate) — never a false
180
+ // "not a user". Fail-soft throughout: a lookup error must never abort the send.
181
+
182
+ // Step 1: workspace users.list.
183
+ let wsOk = false;
184
+ try {
185
+ const wsResp = (await listUsers(token, cookie)) as { members?: Json[] };
186
+ resolveAgainst((wsResp.members ?? []).map(asRecord));
187
+ wsOk = true;
188
+ } catch {
189
+ wsOk = false;
67
190
  }
68
191
 
69
192
  // Step 2: channel members (fetched once) — covers external/Connect guests.
70
- if (unresolved.length > 0 && channelId) {
71
- const memberIds = await listConversationMembers(token, channelId);
72
- const infos: Record<string, Json>[] = [];
73
- for (const uid of memberIds) {
74
- const info = asRecord((await userInfo(token, uid)) as Json);
75
- const u = asRecord(info.user);
76
- if (typeof u.id !== "string") u.id = uid;
77
- infos.push(u);
78
- }
79
- for (const handle of unresolved) {
80
- const hit = infos.find((u) => userMatchesHandle(u, handle));
81
- const id = hit && typeof hit.id === "string" ? hit.id : "";
82
- if (id) resolved.set(handle, id);
193
+ // Only ever resolves tokens still "pending"; an ambiguous token stays ambiguous
194
+ // (fail-safe: we never let channel membership guess which duplicate was meant).
195
+ const stillPending = [...tokens].some((t) => res.get(t)!.status === "pending");
196
+ let channelAttempted = false;
197
+ let channelOk = true;
198
+ if (stillPending && channelId) {
199
+ channelAttempted = true;
200
+ try {
201
+ const memberIds = await listConversationMembers(token, channelId, cookie);
202
+ const infos: Record<string, Json>[] = [];
203
+ for (const uid of memberIds) {
204
+ const info = asRecord((await userInfo(token, uid, cookie)) as Json);
205
+ const u = asRecord(info.user);
206
+ if (typeof u.id !== "string") u.id = uid;
207
+ infos.push(u);
208
+ }
209
+ resolveAgainst(infos);
210
+ } catch {
211
+ // best-effort; a preview degrading is fine, aborting a send is not.
212
+ channelOk = false;
83
213
  }
84
214
  }
85
215
 
86
- for (const handle of handles) {
87
- if (!resolved.has(handle)) warn(`warn: unresolved mention @${handle} (left as text)`);
216
+ // A token is a true no-match only if the workspace list was read AND — when a
217
+ // channel exists to check for Connect guests — that channel was actually
218
+ // consulted and succeeded. If the channel lookup was skipped or failed we can't
219
+ // claim the token is a non-user, so it stays "unavailable" (indeterminate).
220
+ const searchedFully = wsOk && (channelId ? (channelAttempted && channelOk) : true);
221
+
222
+ const resolved: MentionResolution[] = [];
223
+ const unresolved: UnresolvedMention[] = [];
224
+ for (const t of tokens) {
225
+ const r = res.get(t)!;
226
+ if (r.status === "resolved") {
227
+ resolved.push({ surface: `@${t.slice(0, r.matchLen)}`, display: r.display, userId: r.userId });
228
+ } else if (r.status === "ambiguous") {
229
+ unresolved.push({ surface: `@${t}`, reason: "ambiguous" });
230
+ } else {
231
+ unresolved.push({ surface: `@${t}`, reason: searchedFully ? "no-match" : "unavailable" });
232
+ }
88
233
  }
89
234
 
90
- return text.replace(mentionRe(), (full, handle: string) => {
91
- const id = resolved.get(handle);
92
- return id ? `<@${id}>` : full;
235
+ const out = text.replace(tokenRe, (full, tk: string) => {
236
+ const r = res.get(tk);
237
+ // For a CJK match, keep the un-matched tail (a trailing honorific) as text.
238
+ if (r && r.status === "resolved") return `<@${r.userId}>${tk.slice(r.matchLen)}`;
239
+ return full;
93
240
  });
241
+
242
+ return { text: out, resolved, unresolved };
243
+ }
244
+
245
+ /** User-facing warning lines for mentions that will NOT notify, deduped (the
246
+ * "unavailable" cause is shared, so it is reported once). The single source of
247
+ * this wording — the encodeMentions wrapper and the CLI send/edit flows both use
248
+ * it, so their messages never drift. Each caller adds its own prefix. */
249
+ export function mentionWarnings(unresolved: UnresolvedMention[]): string[] {
250
+ const lines: string[] = [];
251
+ let saidUnavailable = false;
252
+ for (const u of unresolved) {
253
+ if (u.reason === "ambiguous") {
254
+ lines.push(`${u.surface} matches multiple people — not tagged, sent as plain text (use <@USERID> to tag)`);
255
+ } else if (u.reason === "unavailable") {
256
+ if (saidUnavailable) continue;
257
+ lines.push(`could not fetch the user list (users:read missing or an API/connection error) — @mentions not tagged, sent as plain text`);
258
+ saidUnavailable = true;
259
+ } else {
260
+ lines.push(`${u.surface} matched no one — not tagged, sent as plain text`);
261
+ }
262
+ }
263
+ return lines;
264
+ }
265
+
266
+ /** String-returning wrapper over {@link encodeMentionsDetailed}: rewrites tokens
267
+ * and emits a `warn` line per token that stayed literal. Preserved for callers
268
+ * (and tests) that only need the encoded text. */
269
+ export async function encodeMentions(
270
+ token: string,
271
+ text: string,
272
+ channelId: string | undefined,
273
+ opts: { warn?: (msg: string) => void; cookie?: string } = {},
274
+ ): Promise<string> {
275
+ const warn = opts.warn ?? ((m: string) => process.stderr.write(`${m}\n`));
276
+ const rep = await encodeMentionsDetailed(token, text, channelId, opts.cookie);
277
+ for (const line of mentionWarnings(rep.unresolved)) warn(`warn: ${line}`);
278
+ return rep.text;
94
279
  }
95
280
 
96
281
  // --- untagged-mention lint (warn-only) -------------------------------------
@@ -173,14 +358,14 @@ export type UntaggedMention = { surface: string; display: string; userId: string
173
358
  /** Warn-only lint: person-references in `text` that resolve to a workspace
174
359
  * member but carry no <@USERID> tag. Returns [] cheaply (no users.list fetch)
175
360
  * when the text has no person-reference cue. */
176
- export async function findUntaggedMentions(token: string, text: string): Promise<UntaggedMention[]> {
361
+ export async function findUntaggedMentions(token: string, text: string, cookie?: string): Promise<UntaggedMention[]> {
177
362
  const refs = extractPersonRefs(text);
178
363
  if (refs.length === 0) return [];
179
364
 
180
365
  const taggedIds = new Set<string>();
181
366
  for (const m of text.matchAll(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g)) taggedIds.add(m[1]!);
182
367
 
183
- const wsResp = (await listUsers(token)) as { members?: Json[] };
368
+ const wsResp = (await listUsers(token, cookie)) as { members?: Json[] };
184
369
  const pool = (wsResp.members ?? [])
185
370
  .map(asRecord)
186
371
  .filter((u) => u.deleted !== true && u.is_bot !== true && u.id !== "USLACKBOT")
package/ts/slack.ts CHANGED
@@ -607,13 +607,13 @@ export async function userName(token: string, userId: string): Promise<string> {
607
607
  }
608
608
  }
609
609
 
610
- export async function listUsers(token: string): Promise<Json> {
610
+ export async function listUsers(token: string, cookie?: string): Promise<Json> {
611
611
  const allMembers: Json[] = [];
612
612
  let cursor = "";
613
613
  while (true) {
614
614
  const params: Record<string, string> = { limit: "200" };
615
615
  if (cursor) params.cursor = cursor;
616
- const resp = (await get(token, "users.list", params)) as {
616
+ const resp = (await get(token, "users.list", params, cookie)) as {
617
617
  members?: Json[];
618
618
  response_metadata?: { next_cursor?: string };
619
619
  };
@@ -627,13 +627,13 @@ export async function listUsers(token: string): Promise<Json> {
627
627
  /** List the member user IDs of a channel/DM (paginated).
628
628
  * Used by mention encoding to reach members — including Slack Connect guests —
629
629
  * who do not appear in the workspace-wide users.list. */
630
- export async function listConversationMembers(token: string, channel: string): Promise<string[]> {
630
+ export async function listConversationMembers(token: string, channel: string, cookie?: string): Promise<string[]> {
631
631
  const all: string[] = [];
632
632
  let cursor = "";
633
633
  while (true) {
634
634
  const params: Record<string, string> = { channel, limit: "200" };
635
635
  if (cursor) params.cursor = cursor;
636
- const resp = (await get(token, "conversations.members", params)) as {
636
+ const resp = (await get(token, "conversations.members", params, cookie)) as {
637
637
  members?: string[];
638
638
  response_metadata?: { next_cursor?: string };
639
639
  };
@@ -696,8 +696,8 @@ export async function conversationInfoSession(token: string, channelId: string,
696
696
  return postSession(token, "conversations.info", { channel: channelId }, cookie);
697
697
  }
698
698
 
699
- export async function userInfo(token: string, userId: string): Promise<Json> {
700
- return get(token, "users.info", { user: userId });
699
+ export async function userInfo(token: string, userId: string, cookie?: string): Promise<Json> {
700
+ return get(token, "users.info", { user: userId }, cookie);
701
701
  }
702
702
 
703
703
  export async function conversationInfo(token: string, channelId: string): Promise<Json> {