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/dist/cli.js +80 -80
- package/package.json +1 -1
- package/ts/cli.ts +64 -12
- package/ts/format.ts +242 -57
- package/ts/slack.ts +6 -6
package/package.json
CHANGED
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
|
|
952
|
-
// gate covers exactly what will be posted. Unresolved
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
|
958
|
-
// member but aren't <@USERID>-tagged — they won't be notified.
|
|
959
|
-
// fail-soft (a missing users:read scope or API error must not
|
|
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
|
-
|
|
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
|
|
37
|
-
* real mentions
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
123
|
+
export async function encodeMentionsDetailed(
|
|
45
124
|
token: string,
|
|
46
125
|
text: string,
|
|
47
126
|
channelId: string | undefined,
|
|
48
|
-
|
|
49
|
-
): Promise<
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
for (const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
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> {
|