Skip to content

Commit 2e93e2c

Browse files
feat: resolve Slack @mentions by display name
Add SlackMentionResolver for bot-token mode to translate display-name mentions to Slack user/subteam IDs via users.list and usergroups.list, with caching, ambiguity warnings, and webhook graceful degradation. Closes #46 Generated by Codex Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 909fd63 commit 2e93e2c

5 files changed

Lines changed: 613 additions & 0 deletions

File tree

docs/getting-started/setup.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ nf-slack supports two authentication methods. **Bot User is recommended** — it
2727
- `chat:write.public` — Post to channels without joining *(optional)*
2828
- `reactions:write` — Add emoji reactions *(optional — required for [emoji reactions](../usage/guide.md#emoji-reactions))*
2929
- `files:write` — Upload files *(optional — required for [file uploads](../usage/guide.md#file-uploads))*
30+
- `users:read` — Resolve @mentions by display name *(optional — required for [display-name @mentions](../usage/guide.md#mentioning-users))*
31+
- `usergroups:read` — Resolve user group mentions *(optional — required for subteam mentions)*
3032

3133
**Install and copy token:**
3234

docs/usage/guide.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,30 @@ slack {
214214

215215
Progress messages are posted as replies in the pipeline's thread.
216216

217+
## Mentioning Users
218+
219+
<!-- prettier-ignore -->
220+
!!! note "Bot User only"
221+
Display-name @mentions require a Bot User with the `users:read` scope. Webhooks cannot resolve mentions.
222+
223+
Mention Slack users by display name instead of internal user IDs:
224+
225+
```groovy
226+
slack {
227+
bot {
228+
token = System.getenv('SLACK_BOT_TOKEN')
229+
channel = 'general'
230+
}
231+
onStart {
232+
message = [text: ':wave: Hi <@Jane>!']
233+
}
234+
}
235+
```
236+
237+
The plugin resolves `<@DisplayName>` to Slack's `<@U1234567890>` format before sending. Matching uses username, display name, then real name (case-insensitive). If multiple users match, the mention is left unresolved and a warning is logged.
238+
239+
User group mentions work the same way: `<!subteam^Bioinformatics>` is resolved to `<!subteam^S1234567890>` (requires `usergroups:read` scope).
240+
217241
## File Uploads
218242

219243
<!-- prettier-ignore -->
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
/*
2+
* Copyright 2025, Seqera Labs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package nextflow.slack
18+
19+
import groovy.json.JsonBuilder
20+
import groovy.json.JsonSlurper
21+
import groovy.transform.CompileStatic
22+
import groovy.util.logging.Slf4j
23+
24+
/**
25+
* Resolves Slack @mentions by display name to internal user/subteam IDs.
26+
*
27+
* Only used with bot tokens (requires users:read and usergroups:read scopes).
28+
* User and usergroup lists are cached for the lifetime of the resolver instance.
29+
*
30+
* @author Adam Talbot <adam.talbot@seqera.io>
31+
*/
32+
@Slf4j
33+
@CompileStatic
34+
class SlackMentionResolver {
35+
36+
private static final String USERS_LIST_URL = 'https://slack.com/api/users.list'
37+
private static final String USERGROUPS_LIST_URL = 'https://slack.com/api/usergroups.list'
38+
39+
/** Matches unresolved user mentions; skips Slack user IDs (U...) and workspace IDs (W...). */
40+
private static final java.util.regex.Pattern USER_MENTION_PATTERN =
41+
~/<@(?!U[A-Z0-9]+(?:\|[^>]+)?>)(?!W[A-Z0-9]+(?:\|[^>]+)?>)([^>]+)>/
42+
43+
/** Matches unresolved subteam mentions; skips Slack subteam IDs (S...). */
44+
private static final java.util.regex.Pattern SUBTEAM_MENTION_PATTERN =
45+
~/<!subteam\^(?!S[A-Z0-9]+(?:\|[^>]+)?>)([^>|]+)(?:\|[^>]+)?>/
46+
47+
private final String botToken
48+
private List<Map> cachedUsers
49+
private List<Map> cachedUsergroups
50+
private final Set<String> loggedWarnings = Collections.synchronizedSet(new HashSet<String>())
51+
52+
SlackMentionResolver(String botToken) {
53+
this.botToken = botToken
54+
}
55+
56+
String resolveInJson(String jsonPayload) {
57+
if (!jsonPayload || !hasResolvableMentions(jsonPayload)) {
58+
return jsonPayload
59+
}
60+
61+
try {
62+
def payload = new JsonSlurper().parseText(jsonPayload)
63+
resolveValue(payload)
64+
return new JsonBuilder(payload).toString()
65+
}
66+
catch (Exception e) {
67+
log.debug "Slack plugin: Could not resolve mentions in message payload: ${e.message}"
68+
return jsonPayload
69+
}
70+
}
71+
72+
String resolveInText(String text) {
73+
if (!text) {
74+
return text
75+
}
76+
return resolveSubteamMentions(resolveUserMentions(text))
77+
}
78+
79+
static boolean hasResolvableMentions(String text) {
80+
if (!text) {
81+
return false
82+
}
83+
return USER_MENTION_PATTERN.matcher(text).find() ||
84+
SUBTEAM_MENTION_PATTERN.matcher(text).find()
85+
}
86+
87+
private void resolveValue(Object value) {
88+
if (value instanceof Map) {
89+
def map = value as Map
90+
for (Object key : new ArrayList(map.keySet())) {
91+
def child = map[key]
92+
if (child instanceof String) {
93+
map[key] = resolveInText(child as String)
94+
} else {
95+
resolveValue(child)
96+
}
97+
}
98+
}
99+
else if (value instanceof List) {
100+
def list = value as List
101+
for (int i = 0; i < list.size(); i++) {
102+
def child = list[i]
103+
if (child instanceof String) {
104+
list[i] = resolveInText(child as String)
105+
} else {
106+
resolveValue(child)
107+
}
108+
}
109+
}
110+
}
111+
112+
private String resolveUserMentions(String text) {
113+
def matcher = USER_MENTION_PATTERN.matcher(text)
114+
if (!matcher.find()) {
115+
return text
116+
}
117+
118+
ensureUsersLoaded()
119+
def buffer = new StringBuffer()
120+
matcher.reset()
121+
while (matcher.find()) {
122+
def displayName = matcher.group(1)
123+
def userId = resolveUserId(displayName)
124+
String replacement = userId ? "<@${userId}>" : matcher.group(0)
125+
matcher.appendReplacement(buffer, java.util.regex.Matcher.quoteReplacement(replacement))
126+
}
127+
matcher.appendTail(buffer)
128+
return buffer.toString()
129+
}
130+
131+
private String resolveSubteamMentions(String text) {
132+
def matcher = SUBTEAM_MENTION_PATTERN.matcher(text)
133+
if (!matcher.find()) {
134+
return text
135+
}
136+
137+
ensureUsergroupsLoaded()
138+
def buffer = new StringBuffer()
139+
matcher.reset()
140+
while (matcher.find()) {
141+
def teamName = matcher.group(1)
142+
def subteamId = resolveSubteamId(teamName)
143+
String replacement = subteamId ? "<!subteam^${subteamId}>" : matcher.group(0)
144+
matcher.appendReplacement(buffer, java.util.regex.Matcher.quoteReplacement(replacement))
145+
}
146+
matcher.appendTail(buffer)
147+
return buffer.toString()
148+
}
149+
150+
private String resolveUserId(String query) {
151+
def normalizedQuery = normalize(query)
152+
if (!normalizedQuery) {
153+
return null
154+
}
155+
156+
for (String field : ['name', 'display_name', 'real_name']) {
157+
def matches = findUsersMatchingField(field, normalizedQuery)
158+
if (matches.size() == 1) {
159+
return matches[0]
160+
}
161+
if (matches.size() > 1) {
162+
warnOnce("Slack plugin: Ambiguous @mention '<@${query}>': multiple users match ${describeMatches(matches)} — leaving unresolved")
163+
return null
164+
}
165+
}
166+
167+
warnOnce("Slack plugin: Could not resolve @mention '<@${query}>' — no matching Slack user found")
168+
return null
169+
}
170+
171+
private String resolveSubteamId(String query) {
172+
def normalizedQuery = normalize(query)
173+
if (!normalizedQuery) {
174+
return null
175+
}
176+
177+
for (String field : ['name', 'handle']) {
178+
def matches = findUsergroupsMatchingField(field, normalizedQuery)
179+
if (matches.size() == 1) {
180+
return matches[0]
181+
}
182+
if (matches.size() > 1) {
183+
warnOnce("Slack plugin: Ambiguous subteam mention '<!subteam^${query}>': multiple groups match ${describeMatches(matches)} — leaving unresolved")
184+
return null
185+
}
186+
}
187+
188+
warnOnce("Slack plugin: Could not resolve subteam mention '<!subteam^${query}>' — no matching Slack user group found")
189+
return null
190+
}
191+
192+
private List<String> findUsersMatchingField(String field, String normalizedQuery) {
193+
def matches = [] as List<String>
194+
for (Map user : cachedUsers) {
195+
def value = getUserField(user, field)
196+
if (value && normalize(value) == normalizedQuery) {
197+
matches << (user.id as String)
198+
}
199+
}
200+
return matches
201+
}
202+
203+
private List<String> findUsergroupsMatchingField(String field, String normalizedQuery) {
204+
def matches = [] as List<String>
205+
for (Map group : cachedUsergroups) {
206+
def raw = group[field] as String
207+
def value = field == 'handle' ? stripAtPrefix(raw) : raw
208+
if (value && normalize(value) == normalizedQuery) {
209+
matches << (group.id as String)
210+
}
211+
}
212+
return matches
213+
}
214+
215+
private static String getUserField(Map user, String field) {
216+
if (field == 'name') {
217+
return user.name as String
218+
}
219+
def profile = user.profile as Map
220+
if (!profile) {
221+
return null
222+
}
223+
return profile[field] as String
224+
}
225+
226+
private static String normalize(String value) {
227+
return value?.trim()?.toLowerCase()
228+
}
229+
230+
private static String stripAtPrefix(String value) {
231+
return value?.startsWith('@') ? value.substring(1) : value
232+
}
233+
234+
private static String describeMatches(List<String> ids) {
235+
return ids.collect { "'${it}'" }.join(', ')
236+
}
237+
238+
private void warnOnce(String message) {
239+
if (loggedWarnings.add(message)) {
240+
log.warn message
241+
}
242+
}
243+
244+
private void ensureUsersLoaded() {
245+
if (cachedUsers != null) {
246+
return
247+
}
248+
cachedUsers = fetchAllUsers()
249+
}
250+
251+
private void ensureUsergroupsLoaded() {
252+
if (cachedUsergroups != null) {
253+
return
254+
}
255+
cachedUsergroups = fetchUsergroups()
256+
}
257+
258+
private List<Map> fetchAllUsers() {
259+
def users = [] as List<Map>
260+
String cursor = null
261+
262+
while (true) {
263+
Map page = fetchUsersPage(cursor)
264+
if (!page) {
265+
if (users.isEmpty()) {
266+
warnOnce('Slack plugin: Could not load Slack users for @mention resolution (requires users:read scope)')
267+
}
268+
break
269+
}
270+
271+
def members = page.members as List<Map> ?: []
272+
for (Map member : members) {
273+
if (member.deleted) {
274+
continue
275+
}
276+
if (member.is_bot) {
277+
continue
278+
}
279+
users << member
280+
}
281+
282+
def metadata = page.response_metadata as Map
283+
def nextCursor = metadata?.get('next_cursor') as String
284+
if (!nextCursor) {
285+
break
286+
}
287+
cursor = nextCursor
288+
}
289+
290+
return users
291+
}
292+
293+
protected Map fetchUsersPage(String cursor) {
294+
HttpURLConnection connection = null
295+
try {
296+
def query = cursor ? "?cursor=${URLEncoder.encode(cursor, 'UTF-8')}&limit=200" : '?limit=200'
297+
def url = new URL("${USERS_LIST_URL}${query}")
298+
connection = url.openConnection() as HttpURLConnection
299+
connection.requestMethod = 'GET'
300+
connection.setRequestProperty('Authorization', "Bearer ${botToken}")
301+
302+
if (connection.responseCode != 200) {
303+
return null
304+
}
305+
306+
def response = new JsonSlurper().parseText(connection.inputStream.text) as Map
307+
return response.ok ? response : null
308+
}
309+
catch (Exception e) {
310+
log.debug "Slack plugin: Error fetching users.list: ${e.message}"
311+
return null
312+
}
313+
finally {
314+
connection?.disconnect()
315+
}
316+
}
317+
318+
protected List<Map> fetchUsergroups() {
319+
HttpURLConnection connection = null
320+
try {
321+
def url = new URL(USERGROUPS_LIST_URL)
322+
connection = url.openConnection() as HttpURLConnection
323+
connection.requestMethod = 'GET'
324+
connection.setRequestProperty('Authorization', "Bearer ${botToken}")
325+
326+
if (connection.responseCode != 200) {
327+
warnOnce('Slack plugin: Could not load Slack user groups for subteam mention resolution (requires usergroups:read scope)')
328+
return [] as List<Map>
329+
}
330+
331+
def response = new JsonSlurper().parseText(connection.inputStream.text) as Map
332+
if (!response.ok) {
333+
warnOnce("Slack plugin: Could not load Slack user groups: ${response.error}")
334+
return [] as List<Map>
335+
}
336+
337+
return (response.usergroups as List<Map>) ?: [] as List<Map>
338+
}
339+
catch (Exception e) {
340+
log.debug "Slack plugin: Error fetching usergroups.list: ${e.message}"
341+
warnOnce('Slack plugin: Could not load Slack user groups for subteam mention resolution')
342+
return [] as List<Map>
343+
}
344+
finally {
345+
connection?.disconnect()
346+
}
347+
}
348+
}

0 commit comments

Comments
 (0)