slack-term 1.21.2 → 1.22.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.22.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/cli.ts CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  getPath,
52
52
  type Json,
53
53
  } from "./slack.ts";
54
- import { dayLabel, encodeMentions, findUntaggedMentions, formatYmdHm, resolveDateMarkup, resolveMentions } from "./format.ts";
54
+ import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
55
55
 
56
56
  function loadDotenv(path: string): void {
57
57
  if (!existsSync(path)) return;
@@ -590,6 +590,23 @@ function safetyCode(...parts: string[]): string {
590
590
  return sha256Hex(parts.join("\n")).slice(0, 4);
591
591
  }
592
592
 
593
+ /** Strip ANSI escape sequences and control characters before drawing text the
594
+ * terminal treats as trusted. Slack lets any user pick their own display name,
595
+ * so an attacker could embed color/cursor codes; neutralize them here. */
596
+ function stripTerminalControls(s: string): string {
597
+ return s
598
+ // CSI sequences (colors, cursor moves, …)
599
+ // eslint-disable-next-line no-control-regex
600
+ .replace(/\x1b\[[0-9;:?]*[ -/]*[@-~]/g, "")
601
+ // any remaining control chars, including a lone ESC and C1 range
602
+ // eslint-disable-next-line no-control-regex
603
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, "")
604
+ // Unicode bidi controls (marks, embeddings, overrides, isolates) — a crafted
605
+ // display name could use these to spoof or reorder the rendered line.
606
+ // ALM, LRM/RLM, LRE/RLE/PDF/LRO/RLO, LRI/RLI/FSI/PDI.
607
+ .replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
608
+ }
609
+
593
610
  // Slack API method → the token scope that grants it, for missing_scope guidance.
594
611
  const SCOPE_FOR_METHOD: Record<string, string> = {
595
612
  "reactions.add": "reactions:write",
@@ -912,6 +929,9 @@ interface SendArgs {
912
929
  // send token; set to the user token when sending --as-bot so the bot token
913
930
  // need not carry users:read.
914
931
  mentionToken?: string;
932
+ // Session cookie (xoxd) for the mention token — required for an xoxc- user
933
+ // token to call users.list on the public API (it is rejected without it).
934
+ mentionCookie?: string;
915
935
  }
916
936
 
917
937
  // Detect the silent-failure footgun: DMing yourself with your own user token.
@@ -948,23 +968,50 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
948
968
  else if (args.userId) channelId = await openDm(token, args.userId);
949
969
  else channelId = await resolveChannel(token, ref);
950
970
 
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;
971
+ // Convert @handle / @名前 tokens to <@USERID> before hashing/sending so the
972
+ // safety gate covers exactly what will be posted. Unresolved tokens stay as
973
+ // text. The detailed report drives the confirm-gate mention preview below.
974
+ let message = args.message;
975
+ let mentionReport: MentionEncodeResult | null = null;
976
+ if (args.mentions) {
977
+ mentionReport = await encodeMentionsDetailed(args.mentionToken ?? token, args.message, channelId, args.mentionCookie);
978
+ message = mentionReport.text;
979
+ // Prominent, always-visible warning for any @token that will NOT notify —
980
+ // so a mistyped or unresolved name (esp. a Japanese display name) is caught
981
+ // before it goes out as inert plain text. Shown even when --code is passed.
982
+ // Wording is shared with the library wrapper via mentionWarnings(). Strip
983
+ // control chars — the surfaces embed Slack-controlled display text.
984
+ for (const line of mentionWarnings(mentionReport.unresolved)) console.error(`⚠ ${stripTerminalControls(line)}`);
985
+ }
956
986
 
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).
987
+ // Warn-only lint: names written as plain text (no leading @) that map to a
988
+ // known workspace member but aren't <@USERID>-tagged — they won't be notified.
989
+ // Never blocks; fail-soft (a missing users:read scope or API error must not
990
+ // stall a send).
960
991
  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}>?)`);
992
+ for (const u of await findUntaggedMentions(args.mentionToken ?? token, message, args.mentionCookie)) {
993
+ // u.surface / u.display carry Slack-controlled display text strip control chars.
994
+ console.error(`⚠ possible untagged mention: ${stripTerminalControls(u.surface)} — @${stripTerminalControls(u.display)} won't be notified (did you mean <@${u.userId}>?)`);
963
995
  }
964
996
  } catch {
965
997
  // best-effort; ignore
966
998
  }
967
999
 
1000
+ // Confirm-gate mention preview: exactly who resolves (→ notified) and who
1001
+ // stays literal. Inserted into the dry-run block so the sender eyeballs the
1002
+ // tagging before committing with --code.
1003
+ const mentionLines: string[] = [];
1004
+ if (mentionReport && (mentionReport.resolved.length > 0 || mentionReport.unresolved.length > 0)) {
1005
+ mentionLines.push(`--- Mentions ---------------------------------`);
1006
+ for (const r of mentionReport.resolved) {
1007
+ mentionLines.push(` ✓ ${stripTerminalControls(r.surface)} → @${stripTerminalControls(r.display)} (${r.userId}) — will notify`);
1008
+ }
1009
+ for (const u of mentionReport.unresolved) {
1010
+ const why = u.reason === "ambiguous" ? "ambiguous" : u.reason === "unavailable" ? "lookup unavailable" : "no match";
1011
+ mentionLines.push(` ⚠ ${stripTerminalControls(u.surface)} — plain text, NOT notified (${why})`);
1012
+ }
1013
+ }
1014
+
968
1015
  // Gather context for the confirm gate. For a THREAD reply, pull the thread's
969
1016
  // own recent messages so the preview shows what's already been said there —
970
1017
  // this is what stops accidental duplicate replies (the channel's last message
@@ -1040,6 +1087,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1040
1087
  requireCode(args.code, code, [
1041
1088
  `--- Recent messages in thread ----------------`,
1042
1089
  ...recentLines,
1090
+ ...mentionLines,
1043
1091
  `--- Sending ----------------------------------`,
1044
1092
  ` → ${dest} thread of ${parentLine} — THREAD REPLY`,
1045
1093
  ` Message: ${message}`,
@@ -1049,6 +1097,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1049
1097
  requireCode(args.code, code, [
1050
1098
  `--- Last message in channel ------------------`,
1051
1099
  ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
1100
+ ...mentionLines,
1052
1101
  `--- Sending ----------------------------------`,
1053
1102
  ` → ${dest} — NEW top-level message`,
1054
1103
  ` Message: ${message}`,
@@ -1597,8 +1646,11 @@ async function main(): Promise<void> {
1597
1646
  if (argv.mentions !== false) {
1598
1647
  args.mentions = true;
1599
1648
  // Resolve mentions with the user token (has users:read) even when the
1600
- // message itself is sent via the bot token.
1649
+ // message itself is sent via the bot token. Pass its cookie too so an
1650
+ // xoxc- desktop token can reach users.list (rejected without the cookie).
1601
1651
  args.mentionToken = tok(argv as W);
1652
+ const mc = ck(argv as W);
1653
+ if (mc) args.mentionCookie = mc;
1602
1654
  }
1603
1655
  let sendToken: string;
1604
1656
  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> {