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/SKILL.md +5 -0
- package/dist/cli.js +78 -78
- package/package.json +1 -1
- package/ts/cli.ts +128 -14
- package/ts/format.ts +242 -57
- package/ts/slack.ts +6 -6
package/package.json
CHANGED
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
|
|
952
|
-
// gate covers exactly what will be posted. Unresolved
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
995
|
-
|
|
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
|
|
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> {
|