Skip to content

Commit 061bc94

Browse files
authored
Feat/webhook resolve slack user (#953)
* Feat: add Slack email mapping functionality for payloads * Feat: add Slack user mention replacement functionality * Feat: integrate email mapping to Slack users in webhook handling * fix: remove unused async
1 parent b21a77b commit 061bc94

File tree

6 files changed

+344
-1
lines changed

6 files changed

+344
-1
lines changed

app/api/v1/routes/webhooks.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
)
1111
from modules.slack import webhooks
1212
from modules.webhooks.base import handle_webhook_payload
13+
from modules.webhooks.slack import map_emails_to_slack_users
1314

1415

1516
logger = get_module_logger()
@@ -71,6 +72,7 @@ def handle_webhook(
7172
webhook_result.payload, WebhookPayload
7273
):
7374
webhook_payload = webhook_result.payload
75+
webhook_payload = map_emails_to_slack_users(webhook_payload)
7476
webhook_payload.channel = webhook["channel"]["S"]
7577
hook_type = webhook.get("hook_type", {}).get(
7678
"S", "alert"

app/integrations/slack/users.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import re
77
from slack_sdk import WebClient
88
from core.logging import get_module_logger
9+
from integrations.slack.client import SlackClientManager
910

1011
SLACK_USER_ID_REGEX = r"^[A-Z0-9]+$"
1112

@@ -146,3 +147,45 @@ def replace_with_handle(match):
146147
# Use re.sub() with the callback function to replace all matches
147148
updated_message = re.sub(user_id_pattern, replace_with_handle, message)
148149
return updated_message
150+
151+
152+
def replace_users_emails_with_mention(text: str) -> str:
153+
"""Replace email addresses in the given text with Slack user mentions when resolvable.
154+
155+
Args:
156+
text (str): The input text potentially containing email addresses.
157+
Returns:
158+
str: The modified text with email addresses replaced by Slack user mentions.
159+
"""
160+
161+
client = SlackClientManager.get_client()
162+
if not client:
163+
return text
164+
email_pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
165+
matches = re.findall(email_pattern, text)
166+
for email in matches:
167+
response = client.users_lookupByEmail(email=email)
168+
if response:
169+
user: dict = response.get("user", {})
170+
user_handle = user.get("id")
171+
if user_handle:
172+
text = text.replace(email, f"<@{user_handle}>")
173+
return text
174+
175+
176+
def replace_users_emails_in_dict(data):
177+
"""Recursively replace email addresses in all string values within a nested dictionary or list.
178+
179+
Args:
180+
data (dict or list): The input data structure potentially containing email addresses.
181+
Returns:
182+
dict or list: The modified data structure with email addresses replaced by Slack user mentions.
183+
"""
184+
if isinstance(data, dict):
185+
return {k: replace_users_emails_in_dict(v) for k, v in data.items()}
186+
elif isinstance(data, list):
187+
return [replace_users_emails_in_dict(item) for item in data]
188+
elif isinstance(data, str):
189+
return replace_users_emails_with_mention(data)
190+
else:
191+
return data

app/modules/webhooks/slack.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from integrations.slack.users import (
2+
replace_users_emails_in_dict,
3+
replace_users_emails_with_mention,
4+
)
5+
from models.webhooks import WebhookPayload
6+
7+
8+
def map_emails_to_slack_users(webhook_payload: WebhookPayload) -> WebhookPayload:
9+
"""Replace email addresses in a Slack webhook payload's 'blocks' or top-level 'text'
10+
with Slack user mentions when resolvable; return the modified payload."""
11+
if webhook_payload.text:
12+
webhook_payload.text = replace_users_emails_with_mention(webhook_payload.text)
13+
if webhook_payload.blocks:
14+
webhook_payload.blocks = replace_users_emails_in_dict(webhook_payload.blocks)
15+
return webhook_payload

app/tests/api/v1/test_webhooks.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from unittest.mock import patch, MagicMock, PropertyMock, call, ANY
22

3-
# from urllib import response
43
import pytest
54
import httpx
65
from fastapi.testclient import TestClient
@@ -25,6 +24,7 @@ def test_client(bot_mock):
2524
return TestClient(test_app)
2625

2726

27+
@patch("api.v1.routes.webhooks.map_emails_to_slack_users")
2828
@patch("api.v1.routes.webhooks.log_to_sentinel")
2929
@patch("api.v1.routes.webhooks.append_incident_buttons")
3030
@patch("api.v1.routes.webhooks.handle_webhook_payload")
@@ -36,6 +36,7 @@ def test_handle_webhook(
3636
mock_handle_webhook_payload,
3737
mock_append_incident_buttons,
3838
mock_log_to_sentinel,
39+
mock_map_emails_to_slack_users,
3940
test_client,
4041
):
4142
payload = {"text": "some text"}
@@ -66,6 +67,7 @@ def test_handle_webhook(
6667
mock_handle_webhook_payload.assert_called_once_with(payload, ANY)
6768
mock_append_incident_buttons.assert_called_once()
6869
mock_log_to_sentinel.assert_called_once()
70+
mock_map_emails_to_slack_users.assert_called_once()
6971

7072

7173
def test_handle_webhook_malformed_json_string(test_client):

app/tests/integrations/slack/test_users.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,215 @@ def test_get_user_email_from_handle(mock_get_all_users):
291291
292292
)
293293
assert users.get_user_email_from_handle(client, "@unknown_name") is None
294+
295+
296+
@patch("integrations.slack.users.SlackClientManager")
297+
def test_replace_users_emails_with_mention_success(mock_slack_client_manager):
298+
mock_client = MagicMock()
299+
mock_slack_client_manager.get_client.return_value = mock_client
300+
mock_client.users_lookupByEmail.return_value = {"user": {"id": "U12345"}}
301+
302+
text = "Please contact [email protected] for more information."
303+
result = users.replace_users_emails_with_mention(text)
304+
305+
assert result == "Please contact <@U12345> for more information."
306+
mock_client.users_lookupByEmail.assert_called_once_with(
307+
308+
)
309+
310+
311+
@patch("integrations.slack.users.SlackClientManager")
312+
def test_replace_users_emails_with_mention_multiple_emails(mock_slack_client_manager):
313+
mock_client = MagicMock()
314+
mock_slack_client_manager.get_client.return_value = mock_client
315+
316+
def mock_lookup_by_email(email):
317+
if email == "[email protected]":
318+
return {"user": {"id": "U12345"}}
319+
elif email == "[email protected]":
320+
return {"user": {"id": "U67890"}}
321+
return None
322+
323+
mock_client.users_lookupByEmail.side_effect = mock_lookup_by_email
324+
325+
text = "Contact [email protected] or [email protected] for help."
326+
result = users.replace_users_emails_with_mention(text)
327+
328+
assert result == "Contact <@U12345> or <@U67890> for help."
329+
assert mock_client.users_lookupByEmail.call_count == 2
330+
331+
332+
@patch("integrations.slack.users.SlackClientManager")
333+
def test_replace_users_emails_with_mention_no_user_found(mock_slack_client_manager):
334+
mock_client = MagicMock()
335+
mock_slack_client_manager.get_client.return_value = mock_client
336+
mock_client.users_lookupByEmail.return_value = None
337+
338+
text = "Please contact [email protected] for more information."
339+
result = users.replace_users_emails_with_mention(text)
340+
341+
assert result == "Please contact [email protected] for more information."
342+
mock_client.users_lookupByEmail.assert_called_once_with(email="[email protected]")
343+
344+
345+
@patch("integrations.slack.users.SlackClientManager")
346+
def test_replace_users_emails_with_mention_no_user_id(mock_slack_client_manager):
347+
mock_client = MagicMock()
348+
mock_slack_client_manager.get_client.return_value = mock_client
349+
mock_client.users_lookupByEmail.return_value = {"user": {}}
350+
351+
text = "Please contact [email protected] for more information."
352+
result = users.replace_users_emails_with_mention(text)
353+
354+
assert result == "Please contact [email protected] for more information."
355+
mock_client.users_lookupByEmail.assert_called_once_with(
356+
357+
)
358+
359+
360+
@patch("integrations.slack.users.SlackClientManager")
361+
def test_replace_users_emails_with_mention_no_client(mock_slack_client_manager):
362+
mock_slack_client_manager.get_client.return_value = None
363+
364+
text = "Please contact [email protected] for more information."
365+
result = users.replace_users_emails_with_mention(text)
366+
367+
assert result == "Please contact [email protected] for more information."
368+
369+
370+
@patch("integrations.slack.users.SlackClientManager")
371+
def test_replace_users_emails_with_mention_no_emails(mock_slack_client_manager):
372+
mock_client = MagicMock()
373+
mock_slack_client_manager.get_client.return_value = mock_client
374+
375+
text = "This text has no email addresses in it."
376+
result = users.replace_users_emails_with_mention(text)
377+
378+
assert result == "This text has no email addresses in it."
379+
mock_client.users_lookupByEmail.assert_not_called()
380+
381+
382+
@patch("integrations.slack.users.SlackClientManager")
383+
def test_replace_users_emails_with_mention_empty_text(mock_slack_client_manager):
384+
mock_client = MagicMock()
385+
mock_slack_client_manager.get_client.return_value = mock_client
386+
387+
text = ""
388+
result = users.replace_users_emails_with_mention(text)
389+
390+
assert result == ""
391+
mock_client.users_lookupByEmail.assert_not_called()
392+
393+
394+
@patch("integrations.slack.users.replace_users_emails_with_mention")
395+
def test_replace_users_emails_in_dict_string_value(mock_replace_function):
396+
mock_replace_function.return_value = "replaced text"
397+
398+
399+
result = users.replace_users_emails_in_dict(data)
400+
401+
assert result == "replaced text"
402+
mock_replace_function.assert_called_once_with("[email protected]")
403+
404+
405+
@patch("integrations.slack.users.replace_users_emails_with_mention")
406+
def test_replace_users_emails_in_dict_simple_dict(mock_replace_function):
407+
mock_replace_function.side_effect = lambda x: x.replace(
408+
"[email protected]", "<@U12345>"
409+
)
410+
411+
data = {"message": "Contact [email protected]", "title": "Important"}
412+
result = users.replace_users_emails_in_dict(data)
413+
414+
expected = {"message": "Contact <@U12345>", "title": "Important"}
415+
assert result == expected
416+
417+
418+
@patch("integrations.slack.users.replace_users_emails_with_mention")
419+
def test_replace_users_emails_in_dict_nested_dict(mock_replace_function):
420+
mock_replace_function.side_effect = lambda x: (
421+
x.replace("[email protected]", "<@U67890>") if "[email protected]" in x else x
422+
)
423+
424+
data = {
425+
"user": {"contact": "[email protected]", "name": "Jane"},
426+
"metadata": {"created_by": "system"},
427+
}
428+
result = users.replace_users_emails_in_dict(data)
429+
430+
expected = {
431+
"user": {"contact": "<@U67890>", "name": "Jane"},
432+
"metadata": {"created_by": "system"},
433+
}
434+
assert result == expected
435+
436+
437+
@patch("integrations.slack.users.replace_users_emails_with_mention")
438+
def test_replace_users_emails_in_dict_list_of_strings(mock_replace_function):
439+
mock_replace_function.side_effect = lambda x: (
440+
x.replace("[email protected]", "<@U11111>") if "[email protected]" in x else x
441+
)
442+
443+
data = ["Contact [email protected]", "No email here", "Another message"]
444+
result = users.replace_users_emails_in_dict(data)
445+
446+
expected = ["Contact <@U11111>", "No email here", "Another message"]
447+
assert result == expected
448+
449+
450+
@patch("integrations.slack.users.replace_users_emails_with_mention")
451+
def test_replace_users_emails_in_dict_list_of_dicts(mock_replace_function):
452+
mock_replace_function.side_effect = lambda x: (
453+
x.replace("[email protected]", "<@U22222>") if "[email protected]" in x else x
454+
)
455+
456+
data = [
457+
{"message": "Contact [email protected]", "priority": "high"},
458+
{"message": "No email", "priority": "low"},
459+
]
460+
result = users.replace_users_emails_in_dict(data)
461+
462+
expected = [
463+
{"message": "Contact <@U22222>", "priority": "high"},
464+
{"message": "No email", "priority": "low"},
465+
]
466+
assert result == expected
467+
468+
469+
@patch("integrations.slack.users.replace_users_emails_with_mention")
470+
def test_replace_users_emails_in_dict_mixed_types(mock_replace_function):
471+
def mock_replacement(x):
472+
if "[email protected]" in x:
473+
return x.replace("[email protected]", "<@U33333>")
474+
return x
475+
476+
mock_replace_function.side_effect = mock_replacement
477+
478+
data = {
479+
"text": "Contact [email protected]",
480+
"number": 42,
481+
"boolean": True,
482+
"null_value": None,
483+
"list": ["[email protected]", 123, False],
484+
}
485+
result = users.replace_users_emails_in_dict(data)
486+
487+
expected = {
488+
"text": "Contact <@U33333>",
489+
"number": 42,
490+
"boolean": True,
491+
"null_value": None,
492+
"list": ["<@U33333>", 123, False],
493+
}
494+
assert result == expected
495+
496+
497+
@patch("integrations.slack.users.replace_users_emails_with_mention")
498+
def test_replace_users_emails_in_dict_non_string_non_dict_non_list(
499+
mock_replace_function,
500+
):
501+
data = 42
502+
result = users.replace_users_emails_in_dict(data)
503+
504+
assert result == 42
505+
mock_replace_function.assert_not_called()

0 commit comments

Comments
 (0)