diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 60ce917082f1..9e7bc4b5b3f1 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -261,6 +261,71 @@ def has_scope(self, scope: SlackScope) -> bool: ) return has_scope + def has_history_scope(self, channel_id: str) -> bool: + """ + Returns whether this integration is allowed to access the history in the channel. + + Checks if channels:history is installed for public channels, + and check groups:history for private channels. + + The type of channel is determined by the conversations.info API call. + """ + history_scopes = [SlackScope.CHANNELS_HISTORY, SlackScope.GROUPS_HISTORY] + installed_scope_set = frozenset(self.model.metadata.get("scopes", [])) + + installed_history_scopes = [s in installed_scope_set for s in history_scopes] + + if all(installed_history_scopes): + return True + + conversation_data = self.get_conversations_info(channel_id=channel_id) + + channel_info = conversation_data.get("channel", {}) + is_channel = channel_info.get("is_channel", False) + is_private = channel_info.get("is_private", False) + is_im = channel_info.get("is_im", False) + + # DMs and assistant threads: the bot is a participant and can + # always read its own conversation history. + if is_im: + return True + if is_private: + return SlackScope.GROUPS_HISTORY in installed_scope_set + if is_channel: + return SlackScope.CHANNELS_HISTORY in installed_scope_set + + # Shouldn't reach here unless channel_info is empty (most likely + # an API error or an unrecognized conversation type). + _logger.warning( + "slack.has_history_scope.unrecognized_channel_type", + extra={"channel_id": channel_id, "channel_info": channel_info}, + ) + return False + + def get_conversations_info( + self, + *, + channel_id: str, + ) -> dict: + """ + Fetch conversations info from Slack API. + """ + client = self.get_client() + + try: + conversations = client.conversations_info( + channel=channel_id, + ) + + assert isinstance(conversations.data, dict) + return conversations.data + except (SlackApiError, AssertionError) as e: + _logger.warning( + "slack.get_conversations_info.error", + extra={"channel_id": channel_id, "error": str(e)}, + ) + return {} + def get_thread_history( self, *, @@ -271,7 +336,7 @@ def get_thread_history( Fetch thread replies using the conversations.replies API. Returns a list of message dicts, or an empty list on error. """ - if not self.has_scope(SlackScope.CHANNELS_HISTORY): + if not self.has_history_scope(channel_id): return [] client = self.get_client() diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 19ba95fb33de..5662e450a44b 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -217,6 +217,9 @@ def _render_explorer_response(cls, data: SeerExplorerResponse) -> SlackRenderabl if organization and features.has("organizations:seer-run-id-in-slack", organization): blocks.append(ContextBlock(elements=[PlainTextObject(text=f"Run ID: {data.run_id}")])) + if data.missing_scope_settings_url: + blocks.extend(cls.render_missing_scope_footer(data.missing_scope_settings_url)) + return SlackRenderable(blocks=blocks, text="Seer Explorer has finished") @classmethod @@ -271,6 +274,15 @@ def render_footer_blocks( return blocks + @classmethod + def render_missing_scope_footer(cls, settings_url: str) -> list[Block]: + """Return a context block warning that optional history scopes are missing.""" + footer_text = ( + f"_I am only able to see the message with the mention. I can't read the whole thread. " + f"<{settings_url}|Reinstall me> to change that._" + ) + return [ContextBlock(elements=[MarkdownTextObject(text=footer_text)])] + @classmethod def render_autofix_button(cls, group: Group) -> InteractiveElement: """ diff --git a/src/sentry/notifications/platform/templates/seer.py b/src/sentry/notifications/platform/templates/seer.py index b5ae669e0c5e..671b471a7a49 100644 --- a/src/sentry/notifications/platform/templates/seer.py +++ b/src/sentry/notifications/platform/templates/seer.py @@ -189,6 +189,7 @@ class SeerExplorerResponse(NotificationData): run_id: int organization_id: int summary: str + missing_scope_settings_url: str | None = None source: NotificationSource = NotificationSource.SEER_EXPLORER_RESPONSE diff --git a/src/sentry/seer/entrypoints/slack/entrypoint.py b/src/sentry/seer/entrypoints/slack/entrypoint.py index 04590fd9e64d..25f81fe6d268 100644 --- a/src/sentry/seer/entrypoints/slack/entrypoint.py +++ b/src/sentry/seer/entrypoints/slack/entrypoint.py @@ -5,6 +5,7 @@ from sentry import features from sentry.constants import ENABLE_SEER_ENHANCED_ALERTS_DEFAULT, ObjectStatus +from sentry.integrations.services.integration.service import integration_service from sentry.locks import locks from sentry.models.organization import Organization from sentry.notifications.platform.templates.seer import ( @@ -33,6 +34,7 @@ from sentry.seer.explorer.client_utils import has_seer_explorer_access_with_detail from sentry.sentry_apps.metrics import SentryAppEventType from sentry.utils import metrics +from sentry.utils.cache import cache from sentry.utils.locking import UnableToAcquireLock logger = logging.getLogger(__name__) @@ -68,6 +70,46 @@ class SlackExplorerCachePayload(TypedDict): thread: SlackThreadDetails +MISSING_SCOPE_FOOTER_CACHE_TIMEOUT = 60 * 60 + + +def _get_missing_scope_settings_url( + *, + integration_id: int, + organization_id: int, + channel_id: str, + thread_ts: str, +) -> str | None: + """ + Returns a settings URL if the integration is missing history scopes for the channel, + and we haven't already shown the footer for this thread. Returns None otherwise. + """ + from sentry.integrations.slack.integration import SlackIntegration + + integration = integration_service.get_integration( + integration_id=integration_id, + organization_id=organization_id, + status=ObjectStatus.ACTIVE, + ) + if not integration: + return None + + install = SlackIntegration(model=integration, organization_id=organization_id) + if install.has_history_scope(channel_id): + return None + + try: + org = Organization.objects.get(id=organization_id) + except Organization.DoesNotExist: + return None + + cache_key = f"seer:explorer:scope_footer:{integration_id}:{channel_id}:{thread_ts}" + if not cache.add(cache_key, True, timeout=MISSING_SCOPE_FOOTER_CACHE_TIMEOUT): + return None + + return org.absolute_url(f"/settings/{org.slug}/integrations/slack/") + + class SlackAutofixEntrypoint( SeerAutofixEntrypoint[SlackAutofixCachePayload], ): @@ -410,20 +452,29 @@ def on_explorer_update( run_id: int, ) -> None: organization_id = cache_payload["organization_id"] + integration_id = cache_payload["integration_id"] + thread = cache_payload["thread"] if not summary: data: SeerExplorerError | SeerExplorerResponse = SeerExplorerError( error_message="Seer was unable to generate a response." ) else: + missing_scope_url = _get_missing_scope_settings_url( + integration_id=integration_id, + organization_id=organization_id, + channel_id=thread["channel_id"], + thread_ts=thread["thread_ts"], + ) data = SeerExplorerResponse( run_id=run_id, organization_id=organization_id, summary=summary, + missing_scope_settings_url=missing_scope_url, ) schedule_all_thread_updates( - threads=[cache_payload["thread"]], - integration_id=cache_payload["integration_id"], + threads=[thread], + integration_id=integration_id, organization_id=organization_id, data=data, ) diff --git a/tests/sentry/integrations/slack/test_integration.py b/tests/sentry/integrations/slack/test_integration.py index 48646b7a8a4e..801320f57fe7 100644 --- a/tests/sentry/integrations/slack/test_integration.py +++ b/tests/sentry/integrations/slack/test_integration.py @@ -624,7 +624,13 @@ def test_send_threaded_ephemeral_message_error(self, mock_chat_ephemeral: MagicM slack_user_id=self.slack_user_id, ) - def test_get_thread_history_missing_scope_returns_empty(self) -> None: + @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info") + def test_get_thread_history_missing_scope_returns_empty( + self, mock_conversations_info: MagicMock + ) -> None: + mock_conversations_info.return_value = MagicMock( + data={"ok": True, "channel": {"is_channel": True, "is_private": False}} + ) result = self.installation.get_thread_history( channel_id=self.channel_id, thread_ts=self.thread_ts, @@ -632,8 +638,14 @@ def test_get_thread_history_missing_scope_returns_empty(self) -> None: assert result == [] @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_replies") - def test_get_thread_history_success(self, mock_conversations_replies: MagicMock) -> None: + @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info") + def test_get_thread_history_success( + self, mock_conversations_info: MagicMock, mock_conversations_replies: MagicMock + ) -> None: self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY] + mock_conversations_info.return_value = MagicMock( + data={"ok": True, "channel": {"is_channel": True, "is_private": False}} + ) mock_conversations_replies.return_value = { "ok": True, "messages": [ @@ -653,11 +665,67 @@ def test_get_thread_history_success(self, mock_conversations_replies: MagicMock) ts=self.thread_ts, ) + @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info") + def test_get_thread_history_private_channel_missing_groups_history( + self, mock_conversations_info: MagicMock + ) -> None: + self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY] + mock_conversations_info.return_value = MagicMock( + data={"ok": True, "channel": {"is_channel": True, "is_private": True}} + ) + result = self.installation.get_thread_history( + channel_id="G1234567890", + thread_ts=self.thread_ts, + ) + assert result == [] + + @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_replies") + @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info") + def test_get_thread_history_private_channel_with_groups_history( + self, mock_conversations_info: MagicMock, mock_conversations_replies: MagicMock + ) -> None: + self.integration.metadata["scopes"] = [SlackScope.GROUPS_HISTORY] + mock_conversations_info.return_value = MagicMock( + data={"ok": True, "channel": {"is_channel": True, "is_private": True}} + ) + mock_conversations_replies.return_value = { + "ok": True, + "messages": [{"ts": self.thread_ts, "text": "Private message"}], + } + result = self.installation.get_thread_history( + channel_id="G1234567890", + thread_ts=self.thread_ts, + ) + assert len(result) == 1 + assert result[0]["text"] == "Private message" + + @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info") + def test_has_history_scope_public_channel(self, mock_conversations_info: MagicMock) -> None: + self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY] + mock_conversations_info.return_value = MagicMock( + data={"ok": True, "channel": {"is_channel": True, "is_private": False}} + ) + assert self.installation.has_history_scope("C1234567890") is True + + @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info") + def test_has_history_scope_private_channel_missing( + self, mock_conversations_info: MagicMock + ) -> None: + self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY] + mock_conversations_info.return_value = MagicMock( + data={"ok": True, "channel": {"is_channel": True, "is_private": True}} + ) + assert self.installation.has_history_scope("G1234567890") is False + @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_replies") + @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info") def test_get_thread_history_error_returns_empty_list( - self, mock_conversations_replies: MagicMock + self, mock_conversations_info: MagicMock, mock_conversations_replies: MagicMock ) -> None: self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY] + mock_conversations_info.return_value = MagicMock( + data={"ok": True, "channel": {"is_channel": True, "is_private": False}} + ) mock_conversations_replies.side_effect = SlackApiError("channel_not_found", MagicMock()) result = self.installation.get_thread_history( channel_id=self.channel_id, thread_ts=self.thread_ts diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index 0847b41cea7d..da4041f640e8 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -7,6 +7,7 @@ ContextBlock, LinkButtonElement, MarkdownBlock, + MarkdownTextObject, PlainTextObject, SectionBlock, ) @@ -269,6 +270,34 @@ def test_render_explorer_response_without_run_id_flag(self) -> None: assert isinstance(blocks[0], MarkdownBlock) assert "Found a spike in 500 errors from the auth service." in blocks[0].text + def test_render_explorer_response_with_missing_scope_footer(self) -> None: + data = SeerExplorerResponse( + run_id=MOCK_RUN_ID, + organization_id=self.organization.id, + summary="Some analysis", + missing_scope_settings_url="https://sentry.io/settings/test/integrations/slack/", + ) + renderable = SeerSlackRenderer._render_explorer_response(data) + + blocks = renderable["blocks"] + assert len(blocks) == 2 + last_block = blocks[-1] + assert last_block.type == "context" + assert isinstance(last_block, ContextBlock) + first_element = last_block.elements[0] + assert isinstance(first_element, MarkdownTextObject) + footer_text = first_element.text + assert "Reinstall" in footer_text + assert "I can't read the whole thread" in footer_text + + def test_render_explorer_response_no_footer_when_url_not_set(self) -> None: + data = self._create_explorer_response(summary="Some analysis") + assert data.missing_scope_settings_url is None + renderable = SeerSlackRenderer._render_explorer_response(data) + + for block in renderable["blocks"]: + assert block.type != "context" + def test_render_dispatches_to_explorer_response(self) -> None: data = self._create_explorer_response(summary="Test") from sentry.notifications.platform.types import NotificationRenderedTemplate diff --git a/tests/sentry/seer/entrypoints/slack/test_slack.py b/tests/sentry/seer/entrypoints/slack/test_slack.py index 0940f0ecd6ff..7af462e7f674 100644 --- a/tests/sentry/seer/entrypoints/slack/test_slack.py +++ b/tests/sentry/seer/entrypoints/slack/test_slack.py @@ -6,7 +6,11 @@ from sentry.integrations.slack.message_builder.types import SlackAction from sentry.notifications.platform.service import serialize_notification_data from sentry.notifications.platform.slack.provider import SlackRenderable -from sentry.notifications.platform.templates.seer import SeerAutofixUpdate, SeerExplorerError +from sentry.notifications.platform.templates.seer import ( + SeerAutofixUpdate, + SeerExplorerError, + SeerExplorerResponse, +) from sentry.notifications.utils.actions import BlockKitMessageAction from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.entrypoints.slack.entrypoint import ( @@ -579,7 +583,11 @@ def test_create_explorer_cache_payload(self) -> None: assert payload["thread"]["channel_id"] == self.channel_id @patch("sentry.seer.entrypoints.slack.entrypoint.schedule_all_thread_updates") - def test_on_explorer_update(self, mock_schedule_all_thread_updates): + @patch( + "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", + return_value=True, + ) + def test_on_explorer_update(self, mock_has_history_scope, mock_schedule_all_thread_updates): ep = self._get_entrypoint() cache_payload = ep.create_explorer_cache_payload() run_id = 12345 @@ -638,3 +646,76 @@ def test_on_explorer_update_skips_clear_when_integration_not_found(self) -> None # Still schedules the thread update even without a fresh integration mock_schedule.assert_called_once() + + @patch("sentry.seer.entrypoints.slack.entrypoint.schedule_all_thread_updates") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", + return_value=False, + ) + def test_on_explorer_update_sets_missing_scope_url(self, mock_has_history_scope, mock_schedule): + """When history scope is missing, on_explorer_update sets missing_scope_settings_url.""" + ep = self._get_entrypoint() + cache_payload = ep.create_explorer_cache_payload() + + SlackExplorerEntrypoint.on_explorer_update( + cache_payload=cache_payload, + summary="Test summary", + run_id=12345, + ) + + call_data = mock_schedule.call_args.kwargs["data"] + assert isinstance(call_data, SeerExplorerResponse) + assert call_data.missing_scope_settings_url is not None + assert "/integrations/slack/" in call_data.missing_scope_settings_url + + @patch("sentry.seer.entrypoints.slack.entrypoint.schedule_all_thread_updates") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", + return_value=True, + ) + def test_on_explorer_update_no_missing_scope_url_when_has_scope( + self, mock_has_history_scope, mock_schedule + ): + """When history scope is present, missing_scope_settings_url should be None.""" + ep = self._get_entrypoint() + cache_payload = ep.create_explorer_cache_payload() + + SlackExplorerEntrypoint.on_explorer_update( + cache_payload=cache_payload, + summary="Test summary", + run_id=12345, + ) + + call_data = mock_schedule.call_args.kwargs["data"] + assert isinstance(call_data, SeerExplorerResponse) + assert call_data.missing_scope_settings_url is None + + @patch("sentry.seer.entrypoints.slack.entrypoint.schedule_all_thread_updates") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", + return_value=False, + ) + def test_on_explorer_update_missing_scope_url_cached_per_thread( + self, mock_has_history_scope, mock_schedule + ): + """Second call for the same thread should not set the URL (cached).""" + ep = self._get_entrypoint() + cache_payload = ep.create_explorer_cache_payload() + + SlackExplorerEntrypoint.on_explorer_update( + cache_payload=cache_payload, + summary="First response", + run_id=12345, + ) + first_data = mock_schedule.call_args.kwargs["data"] + assert first_data.missing_scope_settings_url is not None + + mock_schedule.reset_mock() + + SlackExplorerEntrypoint.on_explorer_update( + cache_payload=cache_payload, + summary="Second response", + run_id=12346, + ) + second_data = mock_schedule.call_args.kwargs["data"] + assert second_data.missing_scope_settings_url is None