Skip to content

Commit 407f7ac

Browse files
authored
feat(seer-slack): Check channel type for correct history scope before API call (#112371)
### Motivation Right now, we require every Sentry Slack app integration to require every scope that we request. However, with Seer Explorer requiring `channels:history` and `groups:history` (public and private channels history read scopes), some organization may not want to allow us to have access to those kinds of data. Therefore we must check whether the bot mention happened in a public or a private channel, and whether we have the appropriate scopes to read the history from those channels. ### Details Use Slack's `conversations.info` API to determine channel type (public vs private) and check the appropriate OAuth scope (`channels:history` vs `groups:history`) before calling `conversations.replies`. Previously we only checked `channels:history` regardless of channel type, which meant private channel threads were always blocked. Also adds a missing-scope footer to the **first** Seer Explorer response in a thread when the integration lacks the required history scope. Subsequent responses in the same thread do not repeat the warning. If an installation does not have the correct permissions and is not able to fetch the whole thread context and pass it onto Seer, this footer will appear on the first response: <img width="557" height="416" alt="image" src="https://github.com/user-attachments/assets/74de7b25-0e45-4cb5-b45d-95de1c5e4b5f" /> If the installation has the correct scopes and is able to grab the thread context, it won't show the footer: <img width="546" height="387" alt="image" src="https://github.com/user-attachments/assets/6b099570-b464-4e66-8c9b-73765f2e7d6a" /> Refs ISWF-2353
1 parent 3d880ff commit 407f7ac

File tree

7 files changed

+315
-8
lines changed

7 files changed

+315
-8
lines changed

src/sentry/integrations/slack/integration.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,71 @@ def has_scope(self, scope: SlackScope) -> bool:
261261
)
262262
return has_scope
263263

264+
def has_history_scope(self, channel_id: str) -> bool:
265+
"""
266+
Returns whether this integration is allowed to access the history in the channel.
267+
268+
Checks if channels:history is installed for public channels,
269+
and check groups:history for private channels.
270+
271+
The type of channel is determined by the conversations.info API call.
272+
"""
273+
history_scopes = [SlackScope.CHANNELS_HISTORY, SlackScope.GROUPS_HISTORY]
274+
installed_scope_set = frozenset(self.model.metadata.get("scopes", []))
275+
276+
installed_history_scopes = [s in installed_scope_set for s in history_scopes]
277+
278+
if all(installed_history_scopes):
279+
return True
280+
281+
conversation_data = self.get_conversations_info(channel_id=channel_id)
282+
283+
channel_info = conversation_data.get("channel", {})
284+
is_channel = channel_info.get("is_channel", False)
285+
is_private = channel_info.get("is_private", False)
286+
is_im = channel_info.get("is_im", False)
287+
288+
# DMs and assistant threads: the bot is a participant and can
289+
# always read its own conversation history.
290+
if is_im:
291+
return True
292+
if is_private:
293+
return SlackScope.GROUPS_HISTORY in installed_scope_set
294+
if is_channel:
295+
return SlackScope.CHANNELS_HISTORY in installed_scope_set
296+
297+
# Shouldn't reach here unless channel_info is empty (most likely
298+
# an API error or an unrecognized conversation type).
299+
_logger.warning(
300+
"slack.has_history_scope.unrecognized_channel_type",
301+
extra={"channel_id": channel_id, "channel_info": channel_info},
302+
)
303+
return False
304+
305+
def get_conversations_info(
306+
self,
307+
*,
308+
channel_id: str,
309+
) -> dict:
310+
"""
311+
Fetch conversations info from Slack API.
312+
"""
313+
client = self.get_client()
314+
315+
try:
316+
conversations = client.conversations_info(
317+
channel=channel_id,
318+
)
319+
320+
assert isinstance(conversations.data, dict)
321+
return conversations.data
322+
except (SlackApiError, AssertionError) as e:
323+
_logger.warning(
324+
"slack.get_conversations_info.error",
325+
extra={"channel_id": channel_id, "error": str(e)},
326+
)
327+
return {}
328+
264329
def get_thread_history(
265330
self,
266331
*,
@@ -271,7 +336,7 @@ def get_thread_history(
271336
Fetch thread replies using the conversations.replies API.
272337
Returns a list of message dicts, or an empty list on error.
273338
"""
274-
if not self.has_scope(SlackScope.CHANNELS_HISTORY):
339+
if not self.has_history_scope(channel_id):
275340
return []
276341

277342
client = self.get_client()

src/sentry/notifications/platform/slack/renderers/seer.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,9 @@ def _render_explorer_response(cls, data: SeerExplorerResponse) -> SlackRenderabl
217217
if organization and features.has("organizations:seer-run-id-in-slack", organization):
218218
blocks.append(ContextBlock(elements=[PlainTextObject(text=f"Run ID: {data.run_id}")]))
219219

220+
if data.missing_scope_settings_url:
221+
blocks.extend(cls.render_missing_scope_footer(data.missing_scope_settings_url))
222+
220223
return SlackRenderable(blocks=blocks, text="Seer Explorer has finished")
221224

222225
@classmethod
@@ -271,6 +274,15 @@ def render_footer_blocks(
271274

272275
return blocks
273276

277+
@classmethod
278+
def render_missing_scope_footer(cls, settings_url: str) -> list[Block]:
279+
"""Return a context block warning that optional history scopes are missing."""
280+
footer_text = (
281+
f"_I am only able to see the message with the mention. I can't read the whole thread. "
282+
f"<{settings_url}|Reinstall me> to change that._"
283+
)
284+
return [ContextBlock(elements=[MarkdownTextObject(text=footer_text)])]
285+
274286
@classmethod
275287
def render_autofix_button(cls, group: Group) -> InteractiveElement:
276288
"""

src/sentry/notifications/platform/templates/seer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ class SeerExplorerResponse(NotificationData):
189189
run_id: int
190190
organization_id: int
191191
summary: str
192+
missing_scope_settings_url: str | None = None
192193
source: NotificationSource = NotificationSource.SEER_EXPLORER_RESPONSE
193194

194195

src/sentry/seer/entrypoints/slack/entrypoint.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from sentry import features
77
from sentry.constants import ENABLE_SEER_ENHANCED_ALERTS_DEFAULT, ObjectStatus
8+
from sentry.integrations.services.integration.service import integration_service
89
from sentry.locks import locks
910
from sentry.models.organization import Organization
1011
from sentry.notifications.platform.templates.seer import (
@@ -33,6 +34,7 @@
3334
from sentry.seer.explorer.client_utils import has_seer_explorer_access_with_detail
3435
from sentry.sentry_apps.metrics import SentryAppEventType
3536
from sentry.utils import metrics
37+
from sentry.utils.cache import cache
3638
from sentry.utils.locking import UnableToAcquireLock
3739

3840
logger = logging.getLogger(__name__)
@@ -68,6 +70,46 @@ class SlackExplorerCachePayload(TypedDict):
6870
thread: SlackThreadDetails
6971

7072

73+
MISSING_SCOPE_FOOTER_CACHE_TIMEOUT = 60 * 60
74+
75+
76+
def _get_missing_scope_settings_url(
77+
*,
78+
integration_id: int,
79+
organization_id: int,
80+
channel_id: str,
81+
thread_ts: str,
82+
) -> str | None:
83+
"""
84+
Returns a settings URL if the integration is missing history scopes for the channel,
85+
and we haven't already shown the footer for this thread. Returns None otherwise.
86+
"""
87+
from sentry.integrations.slack.integration import SlackIntegration
88+
89+
integration = integration_service.get_integration(
90+
integration_id=integration_id,
91+
organization_id=organization_id,
92+
status=ObjectStatus.ACTIVE,
93+
)
94+
if not integration:
95+
return None
96+
97+
install = SlackIntegration(model=integration, organization_id=organization_id)
98+
if install.has_history_scope(channel_id):
99+
return None
100+
101+
try:
102+
org = Organization.objects.get(id=organization_id)
103+
except Organization.DoesNotExist:
104+
return None
105+
106+
cache_key = f"seer:explorer:scope_footer:{integration_id}:{channel_id}:{thread_ts}"
107+
if not cache.add(cache_key, True, timeout=MISSING_SCOPE_FOOTER_CACHE_TIMEOUT):
108+
return None
109+
110+
return org.absolute_url(f"/settings/{org.slug}/integrations/slack/")
111+
112+
71113
class SlackAutofixEntrypoint(
72114
SeerAutofixEntrypoint[SlackAutofixCachePayload],
73115
):
@@ -410,20 +452,29 @@ def on_explorer_update(
410452
run_id: int,
411453
) -> None:
412454
organization_id = cache_payload["organization_id"]
455+
integration_id = cache_payload["integration_id"]
456+
thread = cache_payload["thread"]
413457

414458
if not summary:
415459
data: SeerExplorerError | SeerExplorerResponse = SeerExplorerError(
416460
error_message="Seer was unable to generate a response."
417461
)
418462
else:
463+
missing_scope_url = _get_missing_scope_settings_url(
464+
integration_id=integration_id,
465+
organization_id=organization_id,
466+
channel_id=thread["channel_id"],
467+
thread_ts=thread["thread_ts"],
468+
)
419469
data = SeerExplorerResponse(
420470
run_id=run_id,
421471
organization_id=organization_id,
422472
summary=summary,
473+
missing_scope_settings_url=missing_scope_url,
423474
)
424475
schedule_all_thread_updates(
425-
threads=[cache_payload["thread"]],
426-
integration_id=cache_payload["integration_id"],
476+
threads=[thread],
477+
integration_id=integration_id,
427478
organization_id=organization_id,
428479
data=data,
429480
)

tests/sentry/integrations/slack/test_integration.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -624,16 +624,28 @@ def test_send_threaded_ephemeral_message_error(self, mock_chat_ephemeral: MagicM
624624
slack_user_id=self.slack_user_id,
625625
)
626626

627-
def test_get_thread_history_missing_scope_returns_empty(self) -> None:
627+
@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info")
628+
def test_get_thread_history_missing_scope_returns_empty(
629+
self, mock_conversations_info: MagicMock
630+
) -> None:
631+
mock_conversations_info.return_value = MagicMock(
632+
data={"ok": True, "channel": {"is_channel": True, "is_private": False}}
633+
)
628634
result = self.installation.get_thread_history(
629635
channel_id=self.channel_id,
630636
thread_ts=self.thread_ts,
631637
)
632638
assert result == []
633639

634640
@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_replies")
635-
def test_get_thread_history_success(self, mock_conversations_replies: MagicMock) -> None:
641+
@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info")
642+
def test_get_thread_history_success(
643+
self, mock_conversations_info: MagicMock, mock_conversations_replies: MagicMock
644+
) -> None:
636645
self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY]
646+
mock_conversations_info.return_value = MagicMock(
647+
data={"ok": True, "channel": {"is_channel": True, "is_private": False}}
648+
)
637649
mock_conversations_replies.return_value = {
638650
"ok": True,
639651
"messages": [
@@ -653,11 +665,67 @@ def test_get_thread_history_success(self, mock_conversations_replies: MagicMock)
653665
ts=self.thread_ts,
654666
)
655667

668+
@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info")
669+
def test_get_thread_history_private_channel_missing_groups_history(
670+
self, mock_conversations_info: MagicMock
671+
) -> None:
672+
self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY]
673+
mock_conversations_info.return_value = MagicMock(
674+
data={"ok": True, "channel": {"is_channel": True, "is_private": True}}
675+
)
676+
result = self.installation.get_thread_history(
677+
channel_id="G1234567890",
678+
thread_ts=self.thread_ts,
679+
)
680+
assert result == []
681+
682+
@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_replies")
683+
@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info")
684+
def test_get_thread_history_private_channel_with_groups_history(
685+
self, mock_conversations_info: MagicMock, mock_conversations_replies: MagicMock
686+
) -> None:
687+
self.integration.metadata["scopes"] = [SlackScope.GROUPS_HISTORY]
688+
mock_conversations_info.return_value = MagicMock(
689+
data={"ok": True, "channel": {"is_channel": True, "is_private": True}}
690+
)
691+
mock_conversations_replies.return_value = {
692+
"ok": True,
693+
"messages": [{"ts": self.thread_ts, "text": "Private message"}],
694+
}
695+
result = self.installation.get_thread_history(
696+
channel_id="G1234567890",
697+
thread_ts=self.thread_ts,
698+
)
699+
assert len(result) == 1
700+
assert result[0]["text"] == "Private message"
701+
702+
@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info")
703+
def test_has_history_scope_public_channel(self, mock_conversations_info: MagicMock) -> None:
704+
self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY]
705+
mock_conversations_info.return_value = MagicMock(
706+
data={"ok": True, "channel": {"is_channel": True, "is_private": False}}
707+
)
708+
assert self.installation.has_history_scope("C1234567890") is True
709+
710+
@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info")
711+
def test_has_history_scope_private_channel_missing(
712+
self, mock_conversations_info: MagicMock
713+
) -> None:
714+
self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY]
715+
mock_conversations_info.return_value = MagicMock(
716+
data={"ok": True, "channel": {"is_channel": True, "is_private": True}}
717+
)
718+
assert self.installation.has_history_scope("G1234567890") is False
719+
656720
@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_replies")
721+
@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info")
657722
def test_get_thread_history_error_returns_empty_list(
658-
self, mock_conversations_replies: MagicMock
723+
self, mock_conversations_info: MagicMock, mock_conversations_replies: MagicMock
659724
) -> None:
660725
self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY]
726+
mock_conversations_info.return_value = MagicMock(
727+
data={"ok": True, "channel": {"is_channel": True, "is_private": False}}
728+
)
661729
mock_conversations_replies.side_effect = SlackApiError("channel_not_found", MagicMock())
662730
result = self.installation.get_thread_history(
663731
channel_id=self.channel_id, thread_ts=self.thread_ts

tests/sentry/notifications/platform/slack/renderers/test_seer.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
ContextBlock,
88
LinkButtonElement,
99
MarkdownBlock,
10+
MarkdownTextObject,
1011
PlainTextObject,
1112
SectionBlock,
1213
)
@@ -269,6 +270,34 @@ def test_render_explorer_response_without_run_id_flag(self) -> None:
269270
assert isinstance(blocks[0], MarkdownBlock)
270271
assert "Found a spike in 500 errors from the auth service." in blocks[0].text
271272

273+
def test_render_explorer_response_with_missing_scope_footer(self) -> None:
274+
data = SeerExplorerResponse(
275+
run_id=MOCK_RUN_ID,
276+
organization_id=self.organization.id,
277+
summary="Some analysis",
278+
missing_scope_settings_url="https://sentry.io/settings/test/integrations/slack/",
279+
)
280+
renderable = SeerSlackRenderer._render_explorer_response(data)
281+
282+
blocks = renderable["blocks"]
283+
assert len(blocks) == 2
284+
last_block = blocks[-1]
285+
assert last_block.type == "context"
286+
assert isinstance(last_block, ContextBlock)
287+
first_element = last_block.elements[0]
288+
assert isinstance(first_element, MarkdownTextObject)
289+
footer_text = first_element.text
290+
assert "Reinstall" in footer_text
291+
assert "I can't read the whole thread" in footer_text
292+
293+
def test_render_explorer_response_no_footer_when_url_not_set(self) -> None:
294+
data = self._create_explorer_response(summary="Some analysis")
295+
assert data.missing_scope_settings_url is None
296+
renderable = SeerSlackRenderer._render_explorer_response(data)
297+
298+
for block in renderable["blocks"]:
299+
assert block.type != "context"
300+
272301
def test_render_dispatches_to_explorer_response(self) -> None:
273302
data = self._create_explorer_response(summary="Test")
274303
from sentry.notifications.platform.types import NotificationRenderedTemplate

0 commit comments

Comments
 (0)