From 9260c7990de2bd4204e31d16f6d0d49e3c3afd67 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Tue, 7 Apr 2026 11:21:41 -0400 Subject: [PATCH 01/34] =?UTF-8?q?=F0=9F=9A=A7=20initial=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sentry/integrations/messaging/metrics.py | 11 ++ .../integrations/slack/webhooks/event.py | 114 +++++++++++++++- .../slack/webhooks/events/test_message_im.py | 123 +++++++++++++++++- 3 files changed, 245 insertions(+), 3 deletions(-) diff --git a/src/sentry/integrations/messaging/metrics.py b/src/sentry/integrations/messaging/metrics.py index b8bf47f8d87a..51398ccfb7c3 100644 --- a/src/sentry/integrations/messaging/metrics.py +++ b/src/sentry/integrations/messaging/metrics.py @@ -37,6 +37,7 @@ class MessagingInteractionType(StrEnum): VIEW_SUBMISSION = "VIEW_SUBMISSION" SEER_AUTOFIX_START = "SEER_AUTOFIX_START" APP_MENTION = "APP_MENTION" + DM_MESSAGE = "DM_MESSAGE" # Automatic behaviors PROCESS_SHARED_LINK = "PROCESS_SHARED_LINK" @@ -119,3 +120,13 @@ class AppMentionHaltReason(StrEnum): ORGANIZATION_NOT_ACTIVE = "organization_not_active" FEATURE_NOT_ENABLED = "feature_not_enabled" MISSING_EVENT_DATA = "missing_event_data" + + +class DmMessageHaltReason(StrEnum): + """Reasons why a DM message event may halt without processing.""" + + NO_ORGANIZATION = "no_organization" + ORGANIZATION_NOT_FOUND = "organization_not_found" + ORGANIZATION_NOT_ACTIVE = "organization_not_active" + FEATURE_NOT_ENABLED = "feature_not_enabled" + MISSING_EVENT_DATA = "missing_event_data" diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index b5f1adc94d37..b2dfeff0ff95 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -19,6 +19,7 @@ from sentry.constants import ObjectStatus from sentry.integrations.messaging.metrics import ( AppMentionHaltReason, + DmMessageHaltReason, MessagingInteractionEvent, MessagingInteractionType, ) @@ -428,6 +429,113 @@ def on_app_mention(self, slack_request: SlackDMRequest) -> Response: ) return self.respond() + def on_dm(self, slack_request: SlackDMRequest) -> Response | None: + """Handle DM messages by kicking off the Seer Explorer agentic workflow.""" + with MessagingInteractionEvent( + interaction_type=MessagingInteractionType.DM_MESSAGE, + spec=SlackMessagingSpec(), + ).capture() as lifecycle: + data = slack_request.data.get("event", {}) + lifecycle.add_extras( + { + "integration_id": slack_request.integration.id, + "thread_ts": data.get("thread_ts"), + } + ) + + ois = integration_service.get_organization_integrations( + integration_id=slack_request.integration.id, + status=ObjectStatus.ACTIVE, + limit=1, + ) + if not ois: + lifecycle.record_halt(DmMessageHaltReason.NO_ORGANIZATION) + return self.respond() + + organization_id = ois[0].organization_id + lifecycle.add_extra("organization_id", organization_id) + + installation = slack_request.integration.get_installation( + organization_id=organization_id + ) + assert isinstance(installation, SlackIntegration) + try: + organization = installation.organization + except NotFound: + lifecycle.record_halt(DmMessageHaltReason.ORGANIZATION_NOT_FOUND) + return self.respond() + + if organization.status != OrganizationStatus.ACTIVE: + lifecycle.add_extra("status", organization.status) + lifecycle.record_halt(DmMessageHaltReason.ORGANIZATION_NOT_ACTIVE) + return self.respond() + + if not features.has("organizations:seer-slack-explorer", organization): + lifecycle.record_halt(DmMessageHaltReason.FEATURE_NOT_ENABLED) + return None + + channel_id = data.get("channel") + text = data.get("text") + ts = data.get("ts") or data.get("message_ts") + thread_ts = data.get("thread_ts") + + lifecycle.add_extras( + { + "channel_id": channel_id, + "text": text, + "ts": ts, + "thread_ts": thread_ts, + "user_id": slack_request.user_id, + } + ) + + if not channel_id or not text or not ts or not slack_request.user_id: + lifecycle.record_halt(DmMessageHaltReason.MISSING_EVENT_DATA) + return self.respond() + + try: + installation.set_thread_status( + channel_id=channel_id, + thread_ts=thread_ts or ts, + status="Thinking...", + loading_messages=[ + "Digging through your errors...", + "Sifting through stack traces...", + "Blaming the right code...", + "Following the breadcrumbs...", + "Asking the stack trace nicely...", + "Reading between the stack frames...", + "Hold on, I've seen this one before...", + "It worked on my machine...", + ], + ) + except Exception: + _logger.exception( + "slack.assistant_threads_setStatus.failed", + extra={ + "integration_id": slack_request.integration.id, + "channel_id": channel_id, + "thread_ts": thread_ts or ts, + }, + ) + + authorizations = slack_request.data.get("authorizations") or [] + bot_user_id = authorizations[0].get("user_id", "") if authorizations else "" + + process_mention_for_slack.apply_async( + kwargs={ + "integration_id": slack_request.integration.id, + "organization_id": organization_id, + "channel_id": channel_id, + "ts": ts, + "thread_ts": thread_ts, + "text": text, + "slack_user_id": slack_request.user_id, + "bot_user_id": bot_user_id, + } + ) + return self.respond() + # TODO(dcramer): implement app_uninstalled and tokens_revoked def post(self, request: Request) -> Response: try: @@ -456,9 +564,11 @@ def post(self, request: Request) -> Response: if command in COMMANDS: resp = super().post_dispatcher(slack_request) - else: - resp = self.on_message(request, slack_request) + # Try the agentic workflow first; falls back to help if feature is off. + resp = self.on_dm(slack_request) + if resp is None: + resp = self.on_message(request, slack_request) if resp: return resp diff --git a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py index afb5b9c7c008..73dfbdcd930b 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py @@ -4,9 +4,10 @@ import pytest from slack_sdk.web import SlackResponse +from sentry.integrations.messaging.metrics import DmMessageHaltReason from sentry.integrations.types import EventLifecycleOutcome from sentry.silo.base import SiloMode -from sentry.testutils.asserts import assert_slo_metric +from sentry.testutils.asserts import assert_halt_metric, assert_slo_metric from sentry.testutils.cases import IntegratedApiTestCase from sentry.testutils.helpers import get_response_text from sentry.testutils.silo import assume_test_silo_mode @@ -173,3 +174,123 @@ def test_user_message_im_no_text(self) -> None: resp = self.post_webhook(event_data=orjson.loads(MESSAGE_IM_EVENT_NO_TEXT)) assert resp.status_code == 200, resp.content assert not self.mock_post.called + + +MESSAGE_IM_DM_EVENT = { + "type": "message", + "channel": "DOxxxxxx", + "user": "Uxxxxxxx", + "text": "What is causing errors in my project?", + "ts": "123456789.9875", +} + +MESSAGE_IM_DM_EVENT_THREADED = { + **MESSAGE_IM_DM_EVENT, + "thread_ts": "123456789.0001", +} + +AUTHORIZATIONS_DATA = { + "authorizations": [{"user_id": "U0BOT", "is_bot": True}], +} + + +class MessageIMDmAgentTest(BaseEventTest): + """Tests for DM messages triggering the Seer Explorer agentic workflow.""" + + @pytest.fixture(autouse=True) + def mock_chat_postMessage(self): + with patch( + "slack_sdk.web.client.WebClient.chat_postMessage", + return_value=SlackResponse( + client=None, + http_verb="POST", + api_url="https://slack.com/api/chat.postMessage", + req_args={}, + data={"ok": True}, + headers={}, + status_code=200, + ), + ) as self.mock_post: + yield + + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_dispatches_task(self, mock_apply_async): + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT, data=AUTHORIZATIONS_DATA) + + assert resp.status_code == 200 + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["integration_id"] == self.integration.id + assert kwargs["organization_id"] == self.organization.id + assert kwargs["channel_id"] == "DOxxxxxx" + assert kwargs["ts"] == "123456789.9875" + assert kwargs["thread_ts"] is None + assert kwargs["text"] == MESSAGE_IM_DM_EVENT["text"] + assert kwargs["slack_user_id"] == "Uxxxxxxx" + assert kwargs["bot_user_id"] == "U0BOT" + + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_threaded_dispatches_task(self, mock_apply_async): + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook( + event_data=MESSAGE_IM_DM_EVENT_THREADED, data=AUTHORIZATIONS_DATA + ) + + assert resp.status_code == 200 + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["ts"] == "123456789.9875" + assert kwargs["thread_ts"] == "123456789.0001" + + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_no_authorizations(self, mock_apply_async): + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) + + assert resp.status_code == 200 + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["bot_user_id"] == "" + + @pytest.fixture(autouse=True) + def mock_set_thread_status(self): + with patch( + "sentry.integrations.slack.integration.SlackIntegration.set_thread_status", + ) as self.mock_status: + yield + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_feature_flag_disabled_falls_back_to_help(self, mock_apply_async, mock_record): + """When feature flag is off, DM should fall back to help message.""" + resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) + + assert resp.status_code == 200 + mock_apply_async.assert_not_called() + assert_halt_metric(mock_record, DmMessageHaltReason.FEATURE_NOT_ENABLED) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_no_organization(self, mock_apply_async, mock_record): + with patch( + "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", + return_value=[], + ): + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) + + assert resp.status_code == 200 + mock_apply_async.assert_not_called() + assert_halt_metric(mock_record, DmMessageHaltReason.NO_ORGANIZATION) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_empty_text(self, mock_apply_async, mock_record): + event_data = {**MESSAGE_IM_DM_EVENT, "text": ""} + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=event_data) + + assert resp.status_code == 200 + mock_apply_async.assert_not_called() + assert_halt_metric(mock_record, DmMessageHaltReason.MISSING_EVENT_DATA) From be4faf2ebc285d2e303453782d2ba1dbf09cf6e5 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Tue, 7 Apr 2026 16:32:04 -0400 Subject: [PATCH 02/34] =?UTF-8?q?=F0=9F=9A=A7=20unreviewed=20claude=20outp?= =?UTF-8?q?ut=20for=20thread=20start,=20need=20to=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sentry/integrations/messaging/metrics.py | 11 ++ src/sentry/integrations/slack/integration.py | 28 ++++ .../integrations/slack/webhooks/event.py | 96 +++++++++++++ .../events/test_assistant_thread_started.py | 129 ++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py diff --git a/src/sentry/integrations/messaging/metrics.py b/src/sentry/integrations/messaging/metrics.py index 51398ccfb7c3..6e5ed1ada179 100644 --- a/src/sentry/integrations/messaging/metrics.py +++ b/src/sentry/integrations/messaging/metrics.py @@ -38,6 +38,7 @@ class MessagingInteractionType(StrEnum): SEER_AUTOFIX_START = "SEER_AUTOFIX_START" APP_MENTION = "APP_MENTION" DM_MESSAGE = "DM_MESSAGE" + ASSISTANT_THREAD_STARTED = "ASSISTANT_THREAD_STARTED" # Automatic behaviors PROCESS_SHARED_LINK = "PROCESS_SHARED_LINK" @@ -130,3 +131,13 @@ class DmMessageHaltReason(StrEnum): ORGANIZATION_NOT_ACTIVE = "organization_not_active" FEATURE_NOT_ENABLED = "feature_not_enabled" MISSING_EVENT_DATA = "missing_event_data" + + +class AssistantThreadHaltReason(StrEnum): + """Reasons why an assistant_thread_started event may halt without processing.""" + + NO_ORGANIZATION = "no_organization" + ORGANIZATION_NOT_FOUND = "organization_not_found" + ORGANIZATION_NOT_ACTIVE = "organization_not_active" + FEATURE_NOT_ENABLED = "feature_not_enabled" + MISSING_EVENT_DATA = "missing_event_data" diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 5a3adf7d141d..ceea7516d3f5 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -272,6 +272,34 @@ def set_thread_status( extra={"channel_id": channel_id, "thread_ts": thread_ts}, ) + def set_suggested_prompts( + self, + *, + channel_id: str, + thread_ts: str, + prompts: Sequence[Mapping[str, str]], + title: str = "", + ) -> None: + """ + Set suggested prompts in a Slack assistant thread. + + Each prompt is a dict with ``title`` (display label) and ``message`` + (the text sent when clicked). Slack allows a maximum of 4 prompts. + """ + client = self.get_client() + try: + client.assistant_threads_setSuggestedPrompts( + channel_id=channel_id, + thread_ts=thread_ts, + title=title, + prompts=list(prompts), + ) + except SlackApiError: + _logger.warning( + "slack.set_suggested_prompts.error", + extra={"channel_id": channel_id, "thread_ts": thread_ts}, + ) + class SlackIntegrationProvider(IntegrationProvider): key = IntegrationProviderSlug.SLACK.value diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index b2dfeff0ff95..1b787877a82e 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -19,6 +19,7 @@ from sentry.constants import ObjectStatus from sentry.integrations.messaging.metrics import ( AppMentionHaltReason, + AssistantThreadHaltReason, DmMessageHaltReason, MessagingInteractionEvent, MessagingInteractionType, @@ -536,6 +537,98 @@ def on_dm(self, slack_request: SlackDMRequest) -> Response | None: ) return self.respond() + def on_assistant_thread_started(self, slack_request: SlackDMRequest) -> Response: + """Handle assistant_thread_started events by sending suggested prompts.""" + with MessagingInteractionEvent( + interaction_type=MessagingInteractionType.ASSISTANT_THREAD_STARTED, + spec=SlackMessagingSpec(), + ).capture() as lifecycle: + data = slack_request.data.get("event", {}) + assistant_thread = data.get("assistant_thread", {}) + lifecycle.add_extra("integration_id", slack_request.integration.id) + + ois = integration_service.get_organization_integrations( + integration_id=slack_request.integration.id, + status=ObjectStatus.ACTIVE, + limit=1, + ) + if not ois: + lifecycle.record_halt(AssistantThreadHaltReason.NO_ORGANIZATION) + return self.respond() + + organization_id = ois[0].organization_id + lifecycle.add_extra("organization_id", organization_id) + + installation = slack_request.integration.get_installation( + organization_id=organization_id + ) + assert isinstance(installation, SlackIntegration) + try: + organization = installation.organization + except NotFound: + lifecycle.record_halt(AssistantThreadHaltReason.ORGANIZATION_NOT_FOUND) + return self.respond() + + if organization.status != OrganizationStatus.ACTIVE: + lifecycle.add_extra("status", organization.status) + lifecycle.record_halt(AssistantThreadHaltReason.ORGANIZATION_NOT_ACTIVE) + return self.respond() + + if not features.has("organizations:seer-slack-explorer", organization): + lifecycle.record_halt(AssistantThreadHaltReason.FEATURE_NOT_ENABLED) + return self.respond() + + channel_id = assistant_thread.get("channel_id") + thread_ts = assistant_thread.get("thread_ts") + + lifecycle.add_extras( + { + "channel_id": channel_id, + "thread_ts": thread_ts, + "context": assistant_thread.get("context"), + } + ) + + if not channel_id or not thread_ts: + lifecycle.record_halt(AssistantThreadHaltReason.MISSING_EVENT_DATA) + return self.respond() + + try: + installation.set_suggested_prompts( + channel_id=channel_id, + thread_ts=thread_ts, + title="Hi there! I'm Seer, Sentry's AI assistant. How can I help?", + prompts=[ + { + "title": "Summarize recent issues", + "message": "What are the most important unresolved issues in my projects right now?", + }, + { + "title": "Investigate an error", + "message": "Help me investigate what's causing errors in my project.", + }, + { + "title": "Explain a stack trace", + "message": "Can you explain the root cause of this stack trace?", + }, + { + "title": "Find similar issues", + "message": "Are there any similar issues that might be related to each other?", + }, + ], + ) + except Exception: + _logger.exception( + "slack.assistant_thread_started.set_suggested_prompts_failed", + extra={ + "integration_id": slack_request.integration.id, + "channel_id": channel_id, + "thread_ts": thread_ts, + }, + ) + + return self.respond() + # TODO(dcramer): implement app_uninstalled and tokens_revoked def post(self, request: Request) -> Response: try: @@ -556,6 +649,9 @@ def post(self, request: Request) -> Response: if slack_request.type == "app_mention": return self.on_app_mention(slack_request) + if slack_request.type == "assistant_thread_started": + return self.on_assistant_thread_started(slack_request) + if slack_request.type == "message": if slack_request.is_bot(): return self.respond() diff --git a/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py new file mode 100644 index 000000000000..66380a26dc86 --- /dev/null +++ b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py @@ -0,0 +1,129 @@ +from unittest.mock import patch + +from sentry.integrations.messaging.metrics import AssistantThreadHaltReason +from sentry.testutils.asserts import assert_halt_metric + +from . import BaseEventTest + +ASSISTANT_THREAD_STARTED_EVENT = { + "type": "assistant_thread_started", + "assistant_thread": { + "user_id": "U1234567890", + "context": { + "channel_id": "C1234567890", + "team_id": "T07XY8FPJ5C", + "enterprise_id": "E480293PS82", + }, + "channel_id": "D1234567890", + "thread_ts": "1729999327.187299", + }, + "event_ts": "1715873754.429808", +} + + +class AssistantThreadStartedEventTest(BaseEventTest): + @patch( + "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", + ) + def test_sends_suggested_prompts(self, mock_set_prompts): + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) + + assert resp.status_code == 200 + mock_set_prompts.assert_called_once() + kwargs = mock_set_prompts.call_args[1] + assert kwargs["channel_id"] == "D1234567890" + assert kwargs["thread_ts"] == "1729999327.187299" + assert len(kwargs["prompts"]) == 4 + assert kwargs["title"] # non-empty welcome title + + @patch( + "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", + ) + def test_prompt_titles_and_messages(self, mock_set_prompts): + with self.feature("organizations:seer-slack-explorer"): + self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) + + prompts = mock_set_prompts.call_args[1]["prompts"] + for prompt in prompts: + assert "title" in prompt + assert "message" in prompt + assert prompt["title"] + assert prompt["message"] + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", + ) + def test_feature_flag_disabled(self, mock_set_prompts, mock_record): + resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) + + assert resp.status_code == 200 + mock_set_prompts.assert_not_called() + assert_halt_metric(mock_record, AssistantThreadHaltReason.FEATURE_NOT_ENABLED) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", + ) + def test_no_organization(self, mock_set_prompts, mock_record): + with patch( + "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", + return_value=[], + ): + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) + + assert resp.status_code == 200 + mock_set_prompts.assert_not_called() + assert_halt_metric(mock_record, AssistantThreadHaltReason.NO_ORGANIZATION) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", + ) + def test_missing_channel_id(self, mock_set_prompts, mock_record): + event_data = { + "type": "assistant_thread_started", + "assistant_thread": { + "user_id": "U1234567890", + "thread_ts": "1729999327.187299", + }, + } + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=event_data) + + assert resp.status_code == 200 + mock_set_prompts.assert_not_called() + assert_halt_metric(mock_record, AssistantThreadHaltReason.MISSING_EVENT_DATA) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", + ) + def test_missing_thread_ts(self, mock_set_prompts, mock_record): + event_data = { + "type": "assistant_thread_started", + "assistant_thread": { + "user_id": "U1234567890", + "channel_id": "D1234567890", + }, + } + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=event_data) + + assert resp.status_code == 200 + mock_set_prompts.assert_not_called() + assert_halt_metric(mock_record, AssistantThreadHaltReason.MISSING_EVENT_DATA) + + @patch( + "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", + side_effect=Exception("API error"), + ) + def test_set_prompts_failure_does_not_raise(self, mock_set_prompts): + """If set_suggested_prompts fails, we still return 200.""" + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) + + assert resp.status_code == 200 + mock_set_prompts.assert_called_once() From 0bfe302d305b87ecfef2cc96f5fd008a5679c0bb Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Wed, 8 Apr 2026 13:30:20 -0400 Subject: [PATCH 03/34] feat(slack): Add assistant:write scope for Slack Agent support Add the assistant:write scope to the Slack integration to enable the bot to act as a Slack Agent, supporting DM-based agent interfaces. Refs ISWF-2388 Co-Authored-By: Claude Opus 4.6 --- src/sentry/integrations/slack/integration.py | 1 + src/sentry/integrations/slack/utils/constants.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index ceea7516d3f5..dee3edcb43a5 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -333,6 +333,7 @@ class SlackIntegrationProvider(IntegrationProvider): SlackScope.CHANNELS_HISTORY, SlackScope.GROUPS_HISTORY, SlackScope.APP_MENTIONS_READ, + SlackScope.ASSISTANT_WRITE, ] ) user_scopes = frozenset( diff --git a/src/sentry/integrations/slack/utils/constants.py b/src/sentry/integrations/slack/utils/constants.py index 2d6293003c44..b3e63eb22db4 100644 --- a/src/sentry/integrations/slack/utils/constants.py +++ b/src/sentry/integrations/slack/utils/constants.py @@ -14,3 +14,5 @@ class SlackScope(StrEnum): """Allows the bot to read message history in private groups.""" APP_MENTIONS_READ = "app_mentions:read" """Allows the bot to read mentions in app messages.""" + ASSISTANT_WRITE = "assistant:write" + """Allows the bot to act as a Slack Agent.""" From 99699e3a75395b76de3ca2f0d0acb44ba7ede125 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Wed, 8 Apr 2026 13:59:24 -0400 Subject: [PATCH 04/34] ref(slack): Consolidate duplicate Seer event handlers and halt enums Extract shared org-resolution logic into _resolve_seer_organization helper and merge on_app_mention/on_dm into a single _handle_seer_mention method. Replace three identical halt reason enums with unified SeerSlackHaltReason. Extract duplicated loading messages list into a module-level constant. Refs ISWF-2388 Co-Authored-By: Claude Opus 4.6 --- src/sentry/integrations/messaging/metrics.py | 24 +- .../integrations/slack/webhooks/event.py | 248 ++++++------------ .../slack/webhooks/events/test_app_mention.py | 10 +- .../events/test_assistant_thread_started.py | 10 +- .../slack/webhooks/events/test_message_im.py | 8 +- 5 files changed, 89 insertions(+), 211 deletions(-) diff --git a/src/sentry/integrations/messaging/metrics.py b/src/sentry/integrations/messaging/metrics.py index 6e5ed1ada179..e3d4d8df133d 100644 --- a/src/sentry/integrations/messaging/metrics.py +++ b/src/sentry/integrations/messaging/metrics.py @@ -113,28 +113,8 @@ class MessageInteractionFailureReason(StrEnum): MISSING_ACTION = "missing_action" -class AppMentionHaltReason(StrEnum): - """Reasons why an app mention event may halt without processing.""" - - NO_ORGANIZATION = "no_organization" - ORGANIZATION_NOT_FOUND = "organization_not_found" - ORGANIZATION_NOT_ACTIVE = "organization_not_active" - FEATURE_NOT_ENABLED = "feature_not_enabled" - MISSING_EVENT_DATA = "missing_event_data" - - -class DmMessageHaltReason(StrEnum): - """Reasons why a DM message event may halt without processing.""" - - NO_ORGANIZATION = "no_organization" - ORGANIZATION_NOT_FOUND = "organization_not_found" - ORGANIZATION_NOT_ACTIVE = "organization_not_active" - FEATURE_NOT_ENABLED = "feature_not_enabled" - MISSING_EVENT_DATA = "missing_event_data" - - -class AssistantThreadHaltReason(StrEnum): - """Reasons why an assistant_thread_started event may halt without processing.""" +class SeerSlackHaltReason(StrEnum): + """Reasons why a Seer Slack event (app mention, DM, assistant thread) may halt.""" NO_ORGANIZATION = "no_organization" ORGANIZATION_NOT_FOUND = "organization_not_found" diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index 1b787877a82e..9cb0bf38cae6 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -18,11 +18,9 @@ from sentry.api.base import all_silo_endpoint from sentry.constants import ObjectStatus from sentry.integrations.messaging.metrics import ( - AppMentionHaltReason, - AssistantThreadHaltReason, - DmMessageHaltReason, MessagingInteractionEvent, MessagingInteractionType, + SeerSlackHaltReason, ) from sentry.integrations.services.integration import integration_service from sentry.integrations.slack.analytics import SlackIntegrationChartUnfurl @@ -46,6 +44,17 @@ _logger = logging.getLogger(__name__) +_SEER_LOADING_MESSAGES = [ + "Digging through your errors...", + "Sifting through stack traces...", + "Blaming the right code...", + "Following the breadcrumbs...", + "Asking the stack trace nicely...", + "Reading between the stack frames...", + "Hold on, I've seen this one before...", + "It worked on my machine...", +] + @all_silo_endpoint # Only challenge verification is handled at control class SlackEventEndpoint(SlackDMEndpoint): @@ -323,117 +332,55 @@ def on_link_shared(self, request: Request, slack_request: SlackDMRequest) -> boo return True - def on_app_mention(self, slack_request: SlackDMRequest) -> Response: - """Handle @mention events for Seer Explorer.""" - with MessagingInteractionEvent( - interaction_type=MessagingInteractionType.APP_MENTION, - spec=SlackMessagingSpec(), - ).capture() as lifecycle: - data = slack_request.data.get("event", {}) - lifecycle.add_extras( - { - "integration_id": slack_request.integration.id, - "thread_ts": data.get("thread_ts"), - } - ) - - ois = integration_service.get_organization_integrations( - integration_id=slack_request.integration.id, - status=ObjectStatus.ACTIVE, - limit=1, - ) - if not ois: - lifecycle.record_halt(AppMentionHaltReason.NO_ORGANIZATION) - return self.respond() + def _resolve_seer_organization(self, slack_request, lifecycle): + """Resolve and validate the organization for a Seer Slack event. - organization_id = ois[0].organization_id - lifecycle.add_extra("organization_id", organization_id) - - installation = slack_request.integration.get_installation( - organization_id=organization_id - ) - assert isinstance(installation, SlackIntegration) - try: - organization = installation.organization - except NotFound: - lifecycle.record_halt(AppMentionHaltReason.ORGANIZATION_NOT_FOUND) - return self.respond() - - if organization.status != OrganizationStatus.ACTIVE: - lifecycle.add_extra("status", organization.status) - lifecycle.record_halt(AppMentionHaltReason.ORGANIZATION_NOT_ACTIVE) - return self.respond() - - if not features.has("organizations:seer-slack-explorer", organization): - lifecycle.record_halt(AppMentionHaltReason.FEATURE_NOT_ENABLED) - return self.respond() + Returns ``(organization_id, installation)`` or ``None`` when the + event should be halted (the halt reason is already recorded). + """ + ois = integration_service.get_organization_integrations( + integration_id=slack_request.integration.id, + status=ObjectStatus.ACTIVE, + limit=1, + ) + if not ois: + lifecycle.record_halt(SeerSlackHaltReason.NO_ORGANIZATION) + return None - channel_id = data.get("channel") - text = data.get("text") - ts = data.get("ts") - thread_ts = data.get("thread_ts") # None for top-level messages + organization_id = ois[0].organization_id + lifecycle.add_extra("organization_id", organization_id) - lifecycle.add_extras( - { - "channel_id": channel_id, - "text": text, - "ts": ts, - "thread_ts": thread_ts, - "user_id": slack_request.user_id, - } - ) + installation = slack_request.integration.get_installation(organization_id=organization_id) + assert isinstance(installation, SlackIntegration) + try: + organization = installation.organization + except NotFound: + lifecycle.record_halt(SeerSlackHaltReason.ORGANIZATION_NOT_FOUND) + return None - if not channel_id or not text or not ts or not slack_request.user_id: - lifecycle.record_halt(AppMentionHaltReason.MISSING_EVENT_DATA) - return self.respond() + if organization.status != OrganizationStatus.ACTIVE: + lifecycle.add_extra("status", organization.status) + lifecycle.record_halt(SeerSlackHaltReason.ORGANIZATION_NOT_ACTIVE) + return None - try: - installation.set_thread_status( - channel_id=channel_id, - thread_ts=thread_ts or ts, - status="Thinking...", - loading_messages=[ - "Digging through your errors...", - "Sifting through stack traces...", - "Blaming the right code...", - "Following the breadcrumbs...", - "Asking the stack trace nicely...", - "Reading between the stack frames...", - "Hold on, I've seen this one before...", - "It worked on my machine...", - ], - ) - except Exception: - _logger.exception( - "slack.assistant_threads_setStatus.failed", - extra={ - "integration_id": slack_request.integration.id, - "channel_id": channel_id, - "thread_ts": thread_ts or ts, - }, - ) + if not features.has("organizations:seer-slack-explorer", organization): + lifecycle.record_halt(SeerSlackHaltReason.FEATURE_NOT_ENABLED) + return None - authorizations = slack_request.data.get("authorizations") or [] - bot_user_id = authorizations[0].get("user_id", "") if authorizations else "" + return organization_id, installation - process_mention_for_slack.apply_async( - kwargs={ - "integration_id": slack_request.integration.id, - "organization_id": organization_id, - "channel_id": channel_id, - "ts": ts, - "thread_ts": thread_ts, - "text": text, - "slack_user_id": slack_request.user_id, - "bot_user_id": bot_user_id, - } - ) - return self.respond() + def _handle_seer_mention( + self, + slack_request: SlackDMRequest, + interaction_type: MessagingInteractionType, + ) -> Response | None: + """Shared handler for app mentions and DMs that trigger the Seer workflow. - def on_dm(self, slack_request: SlackDMRequest) -> Response | None: - """Handle DM messages by kicking off the Seer Explorer agentic workflow.""" + Returns ``None`` when the feature flag is off (DM messages only), + allowing the caller to fall back to alternative handling. + """ with MessagingInteractionEvent( - interaction_type=MessagingInteractionType.DM_MESSAGE, + interaction_type=interaction_type, spec=SlackMessagingSpec(), ).capture() as lifecycle: data = slack_request.data.get("event", {}) @@ -444,36 +391,14 @@ def on_dm(self, slack_request: SlackDMRequest) -> Response | None: } ) - ois = integration_service.get_organization_integrations( - integration_id=slack_request.integration.id, - status=ObjectStatus.ACTIVE, - limit=1, - ) - if not ois: - lifecycle.record_halt(DmMessageHaltReason.NO_ORGANIZATION) + result = self._resolve_seer_organization(slack_request, lifecycle) + if result is None: + # For DMs, return None on feature-flag halt so caller can + # fall back to the help message. + if interaction_type == MessagingInteractionType.DM_MESSAGE: + return None return self.respond() - - organization_id = ois[0].organization_id - lifecycle.add_extra("organization_id", organization_id) - - installation = slack_request.integration.get_installation( - organization_id=organization_id - ) - assert isinstance(installation, SlackIntegration) - try: - organization = installation.organization - except NotFound: - lifecycle.record_halt(DmMessageHaltReason.ORGANIZATION_NOT_FOUND) - return self.respond() - - if organization.status != OrganizationStatus.ACTIVE: - lifecycle.add_extra("status", organization.status) - lifecycle.record_halt(DmMessageHaltReason.ORGANIZATION_NOT_ACTIVE) - return self.respond() - - if not features.has("organizations:seer-slack-explorer", organization): - lifecycle.record_halt(DmMessageHaltReason.FEATURE_NOT_ENABLED) - return None + organization_id, installation = result channel_id = data.get("channel") text = data.get("text") @@ -491,7 +416,7 @@ def on_dm(self, slack_request: SlackDMRequest) -> Response | None: ) if not channel_id or not text or not ts or not slack_request.user_id: - lifecycle.record_halt(DmMessageHaltReason.MISSING_EVENT_DATA) + lifecycle.record_halt(SeerSlackHaltReason.MISSING_EVENT_DATA) return self.respond() try: @@ -499,16 +424,7 @@ def on_dm(self, slack_request: SlackDMRequest) -> Response | None: channel_id=channel_id, thread_ts=thread_ts or ts, status="Thinking...", - loading_messages=[ - "Digging through your errors...", - "Sifting through stack traces...", - "Blaming the right code...", - "Following the breadcrumbs...", - "Asking the stack trace nicely...", - "Reading between the stack frames...", - "Hold on, I've seen this one before...", - "It worked on my machine...", - ], + loading_messages=_SEER_LOADING_MESSAGES, ) except Exception: _logger.exception( @@ -537,6 +453,14 @@ def on_dm(self, slack_request: SlackDMRequest) -> Response | None: ) return self.respond() + def on_app_mention(self, slack_request: SlackDMRequest) -> Response: + """Handle @mention events for Seer Explorer.""" + return self._handle_seer_mention(slack_request, MessagingInteractionType.APP_MENTION) + + def on_dm(self, slack_request: SlackDMRequest) -> Response | None: + """Handle DM messages via the Seer workflow; returns None to fall back to help.""" + return self._handle_seer_mention(slack_request, MessagingInteractionType.DM_MESSAGE) + def on_assistant_thread_started(self, slack_request: SlackDMRequest) -> Response: """Handle assistant_thread_started events by sending suggested prompts.""" with MessagingInteractionEvent( @@ -547,36 +471,10 @@ def on_assistant_thread_started(self, slack_request: SlackDMRequest) -> Response assistant_thread = data.get("assistant_thread", {}) lifecycle.add_extra("integration_id", slack_request.integration.id) - ois = integration_service.get_organization_integrations( - integration_id=slack_request.integration.id, - status=ObjectStatus.ACTIVE, - limit=1, - ) - if not ois: - lifecycle.record_halt(AssistantThreadHaltReason.NO_ORGANIZATION) - return self.respond() - - organization_id = ois[0].organization_id - lifecycle.add_extra("organization_id", organization_id) - - installation = slack_request.integration.get_installation( - organization_id=organization_id - ) - assert isinstance(installation, SlackIntegration) - try: - organization = installation.organization - except NotFound: - lifecycle.record_halt(AssistantThreadHaltReason.ORGANIZATION_NOT_FOUND) - return self.respond() - - if organization.status != OrganizationStatus.ACTIVE: - lifecycle.add_extra("status", organization.status) - lifecycle.record_halt(AssistantThreadHaltReason.ORGANIZATION_NOT_ACTIVE) - return self.respond() - - if not features.has("organizations:seer-slack-explorer", organization): - lifecycle.record_halt(AssistantThreadHaltReason.FEATURE_NOT_ENABLED) + result = self._resolve_seer_organization(slack_request, lifecycle) + if result is None: return self.respond() + _organization_id, installation = result channel_id = assistant_thread.get("channel_id") thread_ts = assistant_thread.get("thread_ts") @@ -590,7 +488,7 @@ def on_assistant_thread_started(self, slack_request: SlackDMRequest) -> Response ) if not channel_id or not thread_ts: - lifecycle.record_halt(AssistantThreadHaltReason.MISSING_EVENT_DATA) + lifecycle.record_halt(SeerSlackHaltReason.MISSING_EVENT_DATA) return self.respond() try: diff --git a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py index a04cdc769cea..ed37dcece346 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py @@ -1,6 +1,6 @@ from unittest.mock import patch -from sentry.integrations.messaging.metrics import AppMentionHaltReason +from sentry.integrations.messaging.metrics import SeerSlackHaltReason from sentry.testutils.asserts import assert_halt_metric from . import BaseEventTest @@ -73,7 +73,7 @@ def test_app_mention_feature_flag_disabled(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, AppMentionHaltReason.FEATURE_NOT_ENABLED) + assert_halt_metric(mock_record, SeerSlackHaltReason.FEATURE_NOT_ENABLED) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") @@ -84,7 +84,7 @@ def test_app_mention_empty_text(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, AppMentionHaltReason.MISSING_EVENT_DATA) + assert_halt_metric(mock_record, SeerSlackHaltReason.MISSING_EVENT_DATA) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") @@ -99,7 +99,7 @@ def test_app_mention_no_organization(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, AppMentionHaltReason.NO_ORGANIZATION) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_ORGANIZATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") @@ -113,4 +113,4 @@ def test_app_mention_org_not_found(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, AppMentionHaltReason.ORGANIZATION_NOT_FOUND) + assert_halt_metric(mock_record, SeerSlackHaltReason.ORGANIZATION_NOT_FOUND) diff --git a/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py index 66380a26dc86..e991634c59b2 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py @@ -1,6 +1,6 @@ from unittest.mock import patch -from sentry.integrations.messaging.metrics import AssistantThreadHaltReason +from sentry.integrations.messaging.metrics import SeerSlackHaltReason from sentry.testutils.asserts import assert_halt_metric from . import BaseEventTest @@ -60,7 +60,7 @@ def test_feature_flag_disabled(self, mock_set_prompts, mock_record): assert resp.status_code == 200 mock_set_prompts.assert_not_called() - assert_halt_metric(mock_record, AssistantThreadHaltReason.FEATURE_NOT_ENABLED) + assert_halt_metric(mock_record, SeerSlackHaltReason.FEATURE_NOT_ENABLED) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch( @@ -76,7 +76,7 @@ def test_no_organization(self, mock_set_prompts, mock_record): assert resp.status_code == 200 mock_set_prompts.assert_not_called() - assert_halt_metric(mock_record, AssistantThreadHaltReason.NO_ORGANIZATION) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_ORGANIZATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch( @@ -95,7 +95,7 @@ def test_missing_channel_id(self, mock_set_prompts, mock_record): assert resp.status_code == 200 mock_set_prompts.assert_not_called() - assert_halt_metric(mock_record, AssistantThreadHaltReason.MISSING_EVENT_DATA) + assert_halt_metric(mock_record, SeerSlackHaltReason.MISSING_EVENT_DATA) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch( @@ -114,7 +114,7 @@ def test_missing_thread_ts(self, mock_set_prompts, mock_record): assert resp.status_code == 200 mock_set_prompts.assert_not_called() - assert_halt_metric(mock_record, AssistantThreadHaltReason.MISSING_EVENT_DATA) + assert_halt_metric(mock_record, SeerSlackHaltReason.MISSING_EVENT_DATA) @patch( "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", diff --git a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py index 73dfbdcd930b..f5b5afc78ae9 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py @@ -4,7 +4,7 @@ import pytest from slack_sdk.web import SlackResponse -from sentry.integrations.messaging.metrics import DmMessageHaltReason +from sentry.integrations.messaging.metrics import SeerSlackHaltReason from sentry.integrations.types import EventLifecycleOutcome from sentry.silo.base import SiloMode from sentry.testutils.asserts import assert_halt_metric, assert_slo_metric @@ -268,7 +268,7 @@ def test_dm_feature_flag_disabled_falls_back_to_help(self, mock_apply_async, moc assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, DmMessageHaltReason.FEATURE_NOT_ENABLED) + assert_halt_metric(mock_record, SeerSlackHaltReason.FEATURE_NOT_ENABLED) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") @@ -282,7 +282,7 @@ def test_dm_no_organization(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, DmMessageHaltReason.NO_ORGANIZATION) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_ORGANIZATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") @@ -293,4 +293,4 @@ def test_dm_empty_text(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, DmMessageHaltReason.MISSING_EVENT_DATA) + assert_halt_metric(mock_record, SeerSlackHaltReason.MISSING_EVENT_DATA) From b0f7e2c3b6f560911b9bac196e26ee0e6dfa3482 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Wed, 8 Apr 2026 17:38:55 -0400 Subject: [PATCH 05/34] test(slack): Update tests for Seer org resolution and halt reason changes Update tests to match the refactored _resolve_seer_organization which now iterates org integrations and uses SlackExplorerEntrypoint.has_access instead of checking a single feature flag. Align halt reasons with the consolidated enum values (NO_VALID_INTEGRATION, NO_VALID_ORGANIZATION) and update the has_access test for the seer-slack-explorer flag rename. Co-Authored-By: Claude Opus 4.6 --- src/sentry/integrations/messaging/metrics.py | 6 +- src/sentry/integrations/slack/integration.py | 2 +- .../integrations/slack/webhooks/event.py | 65 +++++++++++-------- .../seer/entrypoints/slack/entrypoint.py | 2 +- .../slack/webhooks/events/__init__.py | 6 ++ .../slack/webhooks/events/test_app_mention.py | 30 +++++---- .../events/test_assistant_thread_started.py | 20 +++--- .../slack/webhooks/events/test_message_im.py | 18 ++--- .../seer/entrypoints/slack/test_slack.py | 4 +- 9 files changed, 85 insertions(+), 68 deletions(-) diff --git a/src/sentry/integrations/messaging/metrics.py b/src/sentry/integrations/messaging/metrics.py index e3d4d8df133d..17c4bbe6b4e5 100644 --- a/src/sentry/integrations/messaging/metrics.py +++ b/src/sentry/integrations/messaging/metrics.py @@ -116,8 +116,6 @@ class MessageInteractionFailureReason(StrEnum): class SeerSlackHaltReason(StrEnum): """Reasons why a Seer Slack event (app mention, DM, assistant thread) may halt.""" - NO_ORGANIZATION = "no_organization" - ORGANIZATION_NOT_FOUND = "organization_not_found" - ORGANIZATION_NOT_ACTIVE = "organization_not_active" - FEATURE_NOT_ENABLED = "feature_not_enabled" + NO_VALID_INTEGRATION = "no_valid_integration" + NO_VALID_ORGANIZATION = "no_valid_organization" MISSING_EVENT_DATA = "missing_event_data" diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index dee3edcb43a5..a7f5b244303a 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -277,7 +277,7 @@ def set_suggested_prompts( *, channel_id: str, thread_ts: str, - prompts: Sequence[Mapping[str, str]], + prompts: Sequence[dict[str, str]], title: str = "", ) -> None: """ diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index 9cb0bf38cae6..823f702e533f 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -7,7 +7,6 @@ import orjson import sentry_sdk -from rest_framework.exceptions import NotFound from rest_framework.request import Request from rest_framework.response import Response from slack_sdk.errors import SlackApiError @@ -34,9 +33,11 @@ from sentry.integrations.slack.unfurl.handlers import link_handlers, match_link from sentry.integrations.slack.unfurl.types import LinkType, UnfurlableUrl from sentry.integrations.slack.views.link_identity import build_linking_url -from sentry.models.organization import OrganizationStatus +from sentry.integrations.types import IntegrationProviderSlug +from sentry.models.organization import Organization, OrganizationStatus from sentry.organizations.services.organization import organization_service from sentry.organizations.services.organization.model import RpcOrganization +from sentry.seer.entrypoints.slack.entrypoint import SlackExplorerEntrypoint from sentry.seer.entrypoints.slack.tasks import process_mention_for_slack from .base import SlackDMEndpoint @@ -54,6 +55,7 @@ "Hold on, I've seen this one before...", "It worked on my machine...", ] +SLACK_PROVIDERS = {IntegrationProviderSlug.SLACK, IntegrationProviderSlug.SLACK_STAGING} @all_silo_endpoint # Only challenge verification is handled at control @@ -332,42 +334,49 @@ def on_link_shared(self, request: Request, slack_request: SlackDMRequest) -> boo return True - def _resolve_seer_organization(self, slack_request, lifecycle): - """Resolve and validate the organization for a Seer Slack event. + def _resolve_seer_organization( + self, slack_request, lifecycle + ) -> tuple[int, SlackIntegration] | None: + """Resolve and validate an organization for a Seer Slack event. Returns ``(organization_id, installation)`` or ``None`` when the event should be halted (the halt reason is already recorded). + + Note: There is a limitation here of only grabbing the first organization with access to Seer. + If a Slack installation corresponds to multiple organizations with Seer access, this will not work, + and must be revisited. """ ois = integration_service.get_organization_integrations( integration_id=slack_request.integration.id, status=ObjectStatus.ACTIVE, - limit=1, + providers=SLACK_PROVIDERS, ) if not ois: - lifecycle.record_halt(SeerSlackHaltReason.NO_ORGANIZATION) + lifecycle.record_halt(SeerSlackHaltReason.NO_VALID_INTEGRATION) return None - organization_id = ois[0].organization_id - lifecycle.add_extra("organization_id", organization_id) + lifecycle.add_extra("organization_ids", [oi.organization_id for oi in ois]) + for oi in ois: + organization_id = oi.organization_id + try: + organization = Organization.objects.get_from_cache(id=organization_id) + except Organization.DoesNotExist: + continue - installation = slack_request.integration.get_installation(organization_id=organization_id) - assert isinstance(installation, SlackIntegration) - try: - organization = installation.organization - except NotFound: - lifecycle.record_halt(SeerSlackHaltReason.ORGANIZATION_NOT_FOUND) - return None + installation = slack_request.integration.get_installation( + organization_id=organization_id + ) + assert isinstance(installation, SlackIntegration) - if organization.status != OrganizationStatus.ACTIVE: - lifecycle.add_extra("status", organization.status) - lifecycle.record_halt(SeerSlackHaltReason.ORGANIZATION_NOT_ACTIVE) - return None + if organization.status != OrganizationStatus.ACTIVE: + continue - if not features.has("organizations:seer-slack-explorer", organization): - lifecycle.record_halt(SeerSlackHaltReason.FEATURE_NOT_ENABLED) - return None + if not SlackExplorerEntrypoint.has_access(organization): + continue - return organization_id, installation + return organization_id, installation + lifecycle.record_halt(SeerSlackHaltReason.NO_VALID_ORGANIZATION) + return None def _handle_seer_mention( self, @@ -455,7 +464,10 @@ def _handle_seer_mention( def on_app_mention(self, slack_request: SlackDMRequest) -> Response: """Handle @mention events for Seer Explorer.""" - return self._handle_seer_mention(slack_request, MessagingInteractionType.APP_MENTION) + return ( + self._handle_seer_mention(slack_request, MessagingInteractionType.APP_MENTION) + or self.respond() + ) def on_dm(self, slack_request: SlackDMRequest) -> Response | None: """Handle DM messages via the Seer workflow; returns None to fall back to help.""" @@ -556,13 +568,12 @@ def post(self, request: Request) -> Response: command, _ = slack_request.get_command_and_args() + resp: Response | None if command in COMMANDS: resp = super().post_dispatcher(slack_request) else: # Try the agentic workflow first; falls back to help if feature is off. - resp = self.on_dm(slack_request) - if resp is None: - resp = self.on_message(request, slack_request) + resp = self.on_dm(slack_request) or self.on_message(request, slack_request) if resp: return resp diff --git a/src/sentry/seer/entrypoints/slack/entrypoint.py b/src/sentry/seer/entrypoints/slack/entrypoint.py index 1e84f2b031a0..04590fd9e64d 100644 --- a/src/sentry/seer/entrypoints/slack/entrypoint.py +++ b/src/sentry/seer/entrypoints/slack/entrypoint.py @@ -380,7 +380,7 @@ def __init__( @staticmethod def has_access(organization: Organization) -> bool: has_seer_slack_feature_flag = features.has( - "organizations:seer-slack-workflows", organization + "organizations:seer-slack-explorer", organization ) has_explorer_access, _ = has_seer_explorer_access_with_detail(organization, None) return has_seer_slack_feature_flag and has_explorer_access diff --git a/tests/sentry/integrations/slack/webhooks/events/__init__.py b/tests/sentry/integrations/slack/webhooks/events/__init__.py index 660c17593e15..48b2b84eeb5d 100644 --- a/tests/sentry/integrations/slack/webhooks/events/__init__.py +++ b/tests/sentry/integrations/slack/webhooks/events/__init__.py @@ -7,6 +7,12 @@ UNSET = object() +SEER_EXPLORER_FEATURES = { + "organizations:seer-slack-explorer": True, + "organizations:gen-ai-features": True, + "organizations:seer-explorer": True, +} + LINK_SHARED_EVENT = """{ "type": "link_shared", "channel": "Cxxxxxx", diff --git a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py index ed37dcece346..6b63ac095696 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py @@ -1,9 +1,10 @@ from unittest.mock import patch from sentry.integrations.messaging.metrics import SeerSlackHaltReason +from sentry.models.organization import Organization from sentry.testutils.asserts import assert_halt_metric -from . import BaseEventTest +from . import SEER_EXPLORER_FEATURES, BaseEventTest APP_MENTION_EVENT = { "type": "app_mention", @@ -27,7 +28,7 @@ class AppMentionEventTest(BaseEventTest): @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_app_mention_dispatches_task(self, mock_apply_async): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook( event_data=THREADED_APP_MENTION_EVENT, data=AUTHORIZATIONS_DATA ) @@ -46,7 +47,7 @@ def test_app_mention_dispatches_task(self, mock_apply_async): @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_app_mention_dispatches_task_no_authorizations(self, mock_apply_async): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=THREADED_APP_MENTION_EVENT) assert resp.status_code == 200 @@ -57,7 +58,7 @@ def test_app_mention_dispatches_task_no_authorizations(self, mock_apply_async): @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_app_mention_non_threaded_dispatches_task(self, mock_apply_async): """Non-threaded mentions dispatch with ts set and thread_ts as None.""" - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=APP_MENTION_EVENT) assert resp.status_code == 200 @@ -73,13 +74,13 @@ def test_app_mention_feature_flag_disabled(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.FEATURE_NOT_ENABLED) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_ORGANIZATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_app_mention_empty_text(self, mock_apply_async, mock_record): event_data = {**APP_MENTION_EVENT, "text": ""} - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data) assert resp.status_code == 200 @@ -88,29 +89,30 @@ def test_app_mention_empty_text(self, mock_apply_async, mock_record): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_no_organization(self, mock_apply_async, mock_record): + def test_app_mention_no_integration(self, mock_apply_async, mock_record): """When the integration has no org integrations, we should not dispatch.""" with patch( "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", return_value=[], ): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=APP_MENTION_EVENT) assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.NO_ORGANIZATION) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_INTEGRATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_app_mention_org_not_found(self, mock_apply_async, mock_record): - with patch( - "sentry.organizations.services.organization.impl.DatabaseBackedOrganizationService.get", - return_value=None, + with patch.object( + Organization.objects, + "get_from_cache", + side_effect=Organization.DoesNotExist, ): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=APP_MENTION_EVENT) assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.ORGANIZATION_NOT_FOUND) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_ORGANIZATION) diff --git a/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py index e991634c59b2..d76c8f0c36a6 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py @@ -3,7 +3,7 @@ from sentry.integrations.messaging.metrics import SeerSlackHaltReason from sentry.testutils.asserts import assert_halt_metric -from . import BaseEventTest +from . import SEER_EXPLORER_FEATURES, BaseEventTest ASSISTANT_THREAD_STARTED_EVENT = { "type": "assistant_thread_started", @@ -26,7 +26,7 @@ class AssistantThreadStartedEventTest(BaseEventTest): "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", ) def test_sends_suggested_prompts(self, mock_set_prompts): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) assert resp.status_code == 200 @@ -41,7 +41,7 @@ def test_sends_suggested_prompts(self, mock_set_prompts): "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", ) def test_prompt_titles_and_messages(self, mock_set_prompts): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) prompts = mock_set_prompts.call_args[1]["prompts"] @@ -60,23 +60,23 @@ def test_feature_flag_disabled(self, mock_set_prompts, mock_record): assert resp.status_code == 200 mock_set_prompts.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.FEATURE_NOT_ENABLED) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_ORGANIZATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch( "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", ) - def test_no_organization(self, mock_set_prompts, mock_record): + def test_no_integration(self, mock_set_prompts, mock_record): with patch( "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", return_value=[], ): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) assert resp.status_code == 200 mock_set_prompts.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.NO_ORGANIZATION) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_INTEGRATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch( @@ -90,7 +90,7 @@ def test_missing_channel_id(self, mock_set_prompts, mock_record): "thread_ts": "1729999327.187299", }, } - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data) assert resp.status_code == 200 @@ -109,7 +109,7 @@ def test_missing_thread_ts(self, mock_set_prompts, mock_record): "channel_id": "D1234567890", }, } - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data) assert resp.status_code == 200 @@ -122,7 +122,7 @@ def test_missing_thread_ts(self, mock_set_prompts, mock_record): ) def test_set_prompts_failure_does_not_raise(self, mock_set_prompts): """If set_suggested_prompts fails, we still return 200.""" - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) assert resp.status_code == 200 diff --git a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py index f5b5afc78ae9..d35838b1f33b 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py @@ -13,7 +13,7 @@ from sentry.testutils.silo import assume_test_silo_mode from sentry.users.models.identity import Identity, IdentityStatus -from . import BaseEventTest +from . import SEER_EXPLORER_FEATURES, BaseEventTest MESSAGE_IM_EVENT = """{ "type": "message", @@ -215,7 +215,7 @@ def mock_chat_postMessage(self): @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_dm_dispatches_task(self, mock_apply_async): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT, data=AUTHORIZATIONS_DATA) assert resp.status_code == 200 @@ -232,7 +232,7 @@ def test_dm_dispatches_task(self, mock_apply_async): @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_dm_threaded_dispatches_task(self, mock_apply_async): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook( event_data=MESSAGE_IM_DM_EVENT_THREADED, data=AUTHORIZATIONS_DATA ) @@ -245,7 +245,7 @@ def test_dm_threaded_dispatches_task(self, mock_apply_async): @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_dm_no_authorizations(self, mock_apply_async): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) assert resp.status_code == 200 @@ -268,27 +268,27 @@ def test_dm_feature_flag_disabled_falls_back_to_help(self, mock_apply_async, moc assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.FEATURE_NOT_ENABLED) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_ORGANIZATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_dm_no_organization(self, mock_apply_async, mock_record): + def test_dm_no_integration(self, mock_apply_async, mock_record): with patch( "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", return_value=[], ): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.NO_ORGANIZATION) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_INTEGRATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_dm_empty_text(self, mock_apply_async, mock_record): event_data = {**MESSAGE_IM_DM_EVENT, "text": ""} - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data) assert resp.status_code == 200 diff --git a/tests/sentry/seer/entrypoints/slack/test_slack.py b/tests/sentry/seer/entrypoints/slack/test_slack.py index 3df290364832..0940f0ecd6ff 100644 --- a/tests/sentry/seer/entrypoints/slack/test_slack.py +++ b/tests/sentry/seer/entrypoints/slack/test_slack.py @@ -544,9 +544,9 @@ def test_has_access(self) -> None: "organizations:gen-ai-features": True, "organizations:seer-explorer": True, } - with self.feature({"organizations:seer-slack-workflows": False, **explorer_flags}): + with self.feature({"organizations:seer-slack-explorer": False, **explorer_flags}): assert not SlackExplorerEntrypoint.has_access(self.organization) - with self.feature({"organizations:seer-slack-workflows": True, **explorer_flags}): + with self.feature({"organizations:seer-slack-explorer": True, **explorer_flags}): assert SlackExplorerEntrypoint.has_access(self.organization) self.organization.update_option("sentry:hide_ai_features", True) assert not SlackExplorerEntrypoint.has_access(self.organization) From df63ce0ec1123f44bdaa9832c66a670881bf8897 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 2 Apr 2026 14:33:38 -0400 Subject: [PATCH 06/34] feat(seer-slack): Add channel-aware history scope checks and missing-scope footer Check the correct OAuth scope (channels:history vs groups:history) based on channel type before calling conversations.replies, and append a reinstall footer to Seer Explorer responses when the integration lacks the required history scope. Co-Authored-By: Claude Opus 4.6 --- src/sentry/integrations/slack/integration.py | 28 ++++++- .../seer/entrypoints/slack/messaging.py | 28 ++++++- .../integrations/slack/test_integration.py | 50 +++++++++++++ .../seer/entrypoints/slack/test_slack.py | 75 ++++++++++++++++++- 4 files changed, 178 insertions(+), 3 deletions(-) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index a7f5b244303a..1fabd43d1d36 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -217,6 +217,31 @@ def has_scope(self, scope: SlackScope) -> bool: ) return has_scope + def has_history_scope(self, channel_id: str) -> bool: + """Check whether this integration has the history scope needed for the given channel. + + Unlike ``has_scope``, this does **not** log a warning so it can be + called on every outgoing message without noise. + """ + required_scope = self._get_required_history_scope(channel_id) + if required_scope is None: + return True + return required_scope in self.model.metadata.get("scopes", []) + + @staticmethod + def _get_required_history_scope(channel_id: str) -> SlackScope | None: + """Return the OAuth scope needed to read history in *channel_id*. + + Returns ``None`` when no extended scope is required (e.g. DMs, which + are covered by the base ``im:history`` scope). + """ + if channel_id.startswith("C"): + return SlackScope.CHANNELS_HISTORY + elif channel_id.startswith("G"): + return SlackScope.GROUPS_HISTORY + # DMs ("D" prefix) use im:history which is in the base scope set. + return None + def get_thread_history( self, *, @@ -227,7 +252,8 @@ 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): + required_scope = self._get_required_history_scope(channel_id) + if required_scope is not None and not self.has_scope(required_scope): return [] client = self.get_client() diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index 5ed6c48d2c19..a90b42829cbd 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -19,7 +19,7 @@ ) from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.slack.renderers.seer import SeerSlackRenderer -from sentry.notifications.platform.templates.seer import SeerAutofixUpdate +from sentry.notifications.platform.templates.seer import SeerAutofixUpdate, SeerExplorerResponse from sentry.notifications.platform.types import NotificationData, NotificationProviderKey from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.entrypoints.metrics import ( @@ -40,6 +40,26 @@ logger = logging.getLogger(__name__) +def _append_missing_scope_footer( + renderable: SlackRenderable, install: SlackIntegration +) -> SlackRenderable: + """Append a context block warning that optional history scopes are missing.""" + from slack_sdk.models.blocks import ContextBlock, MarkdownTextObject + + settings_url = install.organization.absolute_url( + f"/settings/{install.organization.slug}/integrations/slack/" + ) + footer_text = ( + f"_Thread context is unavailable \u2014 optional scopes are disabled. " + f"<{settings_url}|Reinstall Sentry's Slack app> to enable this feature._" + ) + footer_block = ContextBlock(elements=[MarkdownTextObject(text=footer_text)]) + return SlackRenderable( + blocks=[*renderable["blocks"], footer_block], + text=renderable["text"], + ) + + def send_thread_update( *, install: SlackIntegration, @@ -66,6 +86,12 @@ def send_thread_update( renderable = NotificationService.render_template( data=data, template=template_cls(), provider=provider ) + + if isinstance(data, SeerExplorerResponse) and not install.has_history_scope( + thread["channel_id"] + ): + renderable = _append_missing_scope_footer(renderable, install) + try: if ephemeral_user_id: install.send_threaded_ephemeral_message( diff --git a/tests/sentry/integrations/slack/test_integration.py b/tests/sentry/integrations/slack/test_integration.py index d924ac5d9fb8..c532cb531913 100644 --- a/tests/sentry/integrations/slack/test_integration.py +++ b/tests/sentry/integrations/slack/test_integration.py @@ -643,6 +643,56 @@ def test_get_thread_history_success(self, mock_conversations_replies: MagicMock) ts=self.thread_ts, ) + def test_get_thread_history_private_channel_missing_groups_history(self) -> None: + self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY] + 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") + def test_get_thread_history_private_channel_with_groups_history( + self, mock_conversations_replies: MagicMock + ) -> None: + self.integration.metadata["scopes"] = [SlackScope.GROUPS_HISTORY] + 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_replies") + def test_get_thread_history_dm_always_allowed( + self, mock_conversations_replies: MagicMock + ) -> None: + mock_conversations_replies.return_value = { + "ok": True, + "messages": [{"ts": self.thread_ts, "text": "DM message"}], + } + result = self.installation.get_thread_history( + channel_id="D1234567890", + thread_ts=self.thread_ts, + ) + assert len(result) == 1 + assert result[0]["text"] == "DM message" + + def test_has_history_scope_public_channel(self) -> None: + self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY] + assert self.installation.has_history_scope("C1234567890") is True + + def test_has_history_scope_private_channel_missing(self) -> None: + self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY] + assert self.installation.has_history_scope("G1234567890") is False + + def test_has_history_scope_dm_always_true(self) -> None: + assert self.installation.has_history_scope("D1234567890") is True + @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_replies") def test_get_thread_history_error_returns_empty_list( self, mock_conversations_replies: MagicMock diff --git a/tests/sentry/seer/entrypoints/slack/test_slack.py b/tests/sentry/seer/entrypoints/slack/test_slack.py index 0940f0ecd6ff..1e09f21a1ac1 100644 --- a/tests/sentry/seer/entrypoints/slack/test_slack.py +++ b/tests/sentry/seer/entrypoints/slack/test_slack.py @@ -4,9 +4,14 @@ from fixtures.seer.webhooks import MOCK_RUN_ID, MOCK_SEER_WEBHOOKS from sentry.integrations.slack.message_builder.types import SlackAction +from sentry.integrations.slack.utils.constants import SlackScope 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 ( @@ -638,3 +643,71 @@ 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.integrations.slack.integration.SlackIntegration.send_threaded_message") + def test_send_thread_update_explorer_response_missing_scope_has_footer( + self, mock_send_threaded_message + ): + data = SeerExplorerResponse( + run_id=12345, + organization_id=self.organization.id, + summary="Test summary", + ) + install = self.integration.get_installation(organization_id=self.organization.id) + thread = SlackThreadDetails(thread_ts=self.thread_ts, channel_id=self.channel_id) + + send_thread_update(install=install, thread=thread, data=data) + + mock_send_threaded_message.assert_called_once() + renderable = mock_send_threaded_message.call_args.kwargs["renderable"] + last_block = renderable["blocks"][-1] + assert last_block.type == "context" + footer_text = last_block.elements[0].text + assert "Reinstall" in footer_text + assert "Thread context is unavailable" in footer_text + + @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_message") + def test_send_thread_update_explorer_response_with_scope_no_footer( + self, mock_send_threaded_message + ): + from sentry.integrations.slack.integration import SlackIntegration + + self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY] + data = SeerExplorerResponse( + run_id=12345, + organization_id=self.organization.id, + summary="Test summary", + ) + install = SlackIntegration(self.integration, self.organization.id) + thread = SlackThreadDetails(thread_ts=self.thread_ts, channel_id=self.channel_id) + + send_thread_update(install=install, thread=thread, data=data) + + mock_send_threaded_message.assert_called_once() + renderable = mock_send_threaded_message.call_args.kwargs["renderable"] + for block in renderable["blocks"]: + assert block.type != "context" + + @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_message") + def test_send_thread_update_autofix_no_footer_regardless(self, mock_send_threaded_message): + data = SeerAutofixUpdate( + run_id=MOCK_RUN_ID, + organization_id=self.organization.id, + project_id=self.project.id, + group_id=self.group.id, + current_point=AutofixStoppingPoint.ROOT_CAUSE, + group_link=self.group.get_absolute_url(), + ) + install = self.integration.get_installation(organization_id=self.organization.id) + thread = SlackThreadDetails(thread_ts=self.thread_ts, channel_id=self.channel_id) + + send_thread_update(install=install, thread=thread, data=data) + + mock_send_threaded_message.assert_called_once() + renderable = mock_send_threaded_message.call_args.kwargs["renderable"] + for block in renderable["blocks"]: + if block.type == "context": + # Autofix context blocks (like debug run IDs) are fine; + # ensure none contain the missing-scope text. + for elem in block.elements: + assert "Thread context is unavailable" not in getattr(elem, "text", "") From 6b2065eea93df49f0cef243eae492f170935de42 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 7 Apr 2026 11:36:20 -0400 Subject: [PATCH 07/34] fixing outdated private channel checks --- src/sentry/integrations/slack/integration.py | 68 +++++++++++++------ .../platform/slack/renderers/seer.py | 9 +++ .../seer/entrypoints/slack/messaging.py | 25 ++----- 3 files changed, 61 insertions(+), 41 deletions(-) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 1fabd43d1d36..c7052788d56a 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -218,29 +218,58 @@ def has_scope(self, scope: SlackScope) -> bool: return has_scope def has_history_scope(self, channel_id: str) -> bool: - """Check whether this integration has the history scope needed for the given channel. - - Unlike ``has_scope``, this does **not** log a warning so it can be - called on every outgoing message without noise. """ - required_scope = self._get_required_history_scope(channel_id) - if required_scope is None: + Returns whether this integration is allowed to access the history in the channel. + """ + history_scopes = [SlackScope.CHANNELS_HISTORY, SlackScope.GROUPS_HISTORY] + installed_scope_set = frozenset(self.model.metadata.get("scopes", [])) + _logger.info(f"installed scope set: {installed_scope_set}") + + if all(s in installed_scope_set for s in history_scopes): return True - return required_scope in self.model.metadata.get("scopes", []) - @staticmethod - def _get_required_history_scope(channel_id: str) -> SlackScope | None: - """Return the OAuth scope needed to read history in *channel_id*. + conversation_data = self.get_conversations_info(channel_id=channel_id) + _logger.info(f"conversation data: {conversation_data}") + + channel_info = conversation_data.get("channel", {}) + is_channel = channel_info.get("is_channel", False) + is_private = channel_info.get("is_private", False) + + if is_channel and is_private: + _logger.warning( + f"Allowed to access channel history: {SlackScope.GROUPS_HISTORY in installed_scope_set}" + ) + return SlackScope.GROUPS_HISTORY in installed_scope_set + if is_channel: + _logger.warning( + f"Allowed to access channel history: {SlackScope.CHANNELS_HISTORY in installed_scope_set}" + ) + return SlackScope.CHANNELS_HISTORY in installed_scope_set + + # shouldn't reach here unless channel_info is empty (most likely an api error), since a mention webhook should only come from channels + return False - Returns ``None`` when no extended scope is required (e.g. DMs, which - are covered by the base ``im:history`` scope). + def get_conversations_info( + self, + *, + channel_id: str, + ) -> dict[str, Any]: + """ + Fetch conversations info from Slack API. """ - if channel_id.startswith("C"): - return SlackScope.CHANNELS_HISTORY - elif channel_id.startswith("G"): - return SlackScope.GROUPS_HISTORY - # DMs ("D" prefix) use im:history which is in the base scope set. - return None + client = self.get_client() + + try: + conversations = client.conversations_info( + channel=channel_id, + ) + return conversations.data + except SlackApiError as e: + _logger.warning( + "slack.get_conversations_info.error", + extra={"channel_id": channel_id, "error": str(e)}, + ) + return {} def get_thread_history( self, @@ -252,8 +281,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. """ - required_scope = self._get_required_history_scope(channel_id) - if required_scope is not None and not self.has_scope(required_scope): + 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 bba5db870d41..208f0a4fc853 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -262,6 +262,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"_Thread context is unavailable \u2014 optional scopes are disabled. " + f"<{settings_url}|Reinstall Sentry's Slack app> to enable this feature._" + ) + return [ContextBlock(elements=[MarkdownTextObject(text=footer_text)])] + @classmethod def render_autofix_button(cls, group: Group) -> InteractiveElement: """ diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index a90b42829cbd..f934f8ff2ac5 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -40,26 +40,6 @@ logger = logging.getLogger(__name__) -def _append_missing_scope_footer( - renderable: SlackRenderable, install: SlackIntegration -) -> SlackRenderable: - """Append a context block warning that optional history scopes are missing.""" - from slack_sdk.models.blocks import ContextBlock, MarkdownTextObject - - settings_url = install.organization.absolute_url( - f"/settings/{install.organization.slug}/integrations/slack/" - ) - footer_text = ( - f"_Thread context is unavailable \u2014 optional scopes are disabled. " - f"<{settings_url}|Reinstall Sentry's Slack app> to enable this feature._" - ) - footer_block = ContextBlock(elements=[MarkdownTextObject(text=footer_text)]) - return SlackRenderable( - blocks=[*renderable["blocks"], footer_block], - text=renderable["text"], - ) - - def send_thread_update( *, install: SlackIntegration, @@ -90,7 +70,10 @@ def send_thread_update( if isinstance(data, SeerExplorerResponse) and not install.has_history_scope( thread["channel_id"] ): - renderable = _append_missing_scope_footer(renderable, install) + settings_url = install.organization.absolute_url( + f"/settings/{install.organization.slug}/integrations/slack/" + ) + renderable["blocks"].extend(SeerSlackRenderer.render_missing_scope_footer(settings_url)) try: if ephemeral_user_id: From 7d4484c76275e7304a7b3ed7e78976be92dc84cc Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 7 Apr 2026 12:02:41 -0400 Subject: [PATCH 08/34] fix(seer-slack): Remove debug logging and fix tests to mock conversations_info Remove debug _logger.info/warning calls from has_history_scope that were left from local testing. Update all tests that go through has_history_scope to mock the conversations_info API call, and remove DM tests with incorrect expectations. --- src/sentry/integrations/slack/integration.py | 8 --- .../integrations/slack/test_integration.py | 68 ++++++++++++------- .../seer/entrypoints/slack/test_slack.py | 18 +++-- 3 files changed, 54 insertions(+), 40 deletions(-) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index c7052788d56a..2fa90509fca7 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -223,27 +223,19 @@ def has_history_scope(self, channel_id: str) -> bool: """ history_scopes = [SlackScope.CHANNELS_HISTORY, SlackScope.GROUPS_HISTORY] installed_scope_set = frozenset(self.model.metadata.get("scopes", [])) - _logger.info(f"installed scope set: {installed_scope_set}") if all(s in installed_scope_set for s in history_scopes): return True conversation_data = self.get_conversations_info(channel_id=channel_id) - _logger.info(f"conversation data: {conversation_data}") channel_info = conversation_data.get("channel", {}) is_channel = channel_info.get("is_channel", False) is_private = channel_info.get("is_private", False) if is_channel and is_private: - _logger.warning( - f"Allowed to access channel history: {SlackScope.GROUPS_HISTORY in installed_scope_set}" - ) return SlackScope.GROUPS_HISTORY in installed_scope_set if is_channel: - _logger.warning( - f"Allowed to access channel history: {SlackScope.CHANNELS_HISTORY in installed_scope_set}" - ) return SlackScope.CHANNELS_HISTORY in installed_scope_set # shouldn't reach here unless channel_info is empty (most likely an api error), since a mention webhook should only come from channels diff --git a/tests/sentry/integrations/slack/test_integration.py b/tests/sentry/integrations/slack/test_integration.py index c532cb531913..e03ac6513a55 100644 --- a/tests/sentry/integrations/slack/test_integration.py +++ b/tests/sentry/integrations/slack/test_integration.py @@ -614,7 +614,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, @@ -622,8 +628,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": [ @@ -643,8 +655,14 @@ def test_get_thread_history_success(self, mock_conversations_replies: MagicMock) ts=self.thread_ts, ) - def test_get_thread_history_private_channel_missing_groups_history(self) -> None: + @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, @@ -652,10 +670,14 @@ def test_get_thread_history_private_channel_missing_groups_history(self) -> None 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_replies: MagicMock + 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"}], @@ -667,37 +689,33 @@ def test_get_thread_history_private_channel_with_groups_history( assert len(result) == 1 assert result[0]["text"] == "Private message" - @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_replies") - def test_get_thread_history_dm_always_allowed( - self, mock_conversations_replies: MagicMock - ) -> None: - mock_conversations_replies.return_value = { - "ok": True, - "messages": [{"ts": self.thread_ts, "text": "DM message"}], - } - result = self.installation.get_thread_history( - channel_id="D1234567890", - thread_ts=self.thread_ts, - ) - assert len(result) == 1 - assert result[0]["text"] == "DM message" - - def test_has_history_scope_public_channel(self) -> None: + @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 - def test_has_history_scope_private_channel_missing(self) -> None: + @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 - def test_has_history_scope_dm_always_true(self) -> None: - assert self.installation.has_history_scope("D1234567890") is True - @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/seer/entrypoints/slack/test_slack.py b/tests/sentry/seer/entrypoints/slack/test_slack.py index 1e09f21a1ac1..64f5acc6be92 100644 --- a/tests/sentry/seer/entrypoints/slack/test_slack.py +++ b/tests/sentry/seer/entrypoints/slack/test_slack.py @@ -4,7 +4,6 @@ from fixtures.seer.webhooks import MOCK_RUN_ID, MOCK_SEER_WEBHOOKS from sentry.integrations.slack.message_builder.types import SlackAction -from sentry.integrations.slack.utils.constants import SlackScope from sentry.notifications.platform.service import serialize_notification_data from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.seer import ( @@ -645,8 +644,12 @@ def test_on_explorer_update_skips_clear_when_integration_not_found(self) -> None mock_schedule.assert_called_once() @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_message") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", + return_value=False, + ) def test_send_thread_update_explorer_response_missing_scope_has_footer( - self, mock_send_threaded_message + self, mock_has_history_scope, mock_send_threaded_message ): data = SeerExplorerResponse( run_id=12345, @@ -667,18 +670,19 @@ def test_send_thread_update_explorer_response_missing_scope_has_footer( assert "Thread context is unavailable" in footer_text @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_message") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", + return_value=True, + ) def test_send_thread_update_explorer_response_with_scope_no_footer( - self, mock_send_threaded_message + self, mock_has_history_scope, mock_send_threaded_message ): - from sentry.integrations.slack.integration import SlackIntegration - - self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY] data = SeerExplorerResponse( run_id=12345, organization_id=self.organization.id, summary="Test summary", ) - install = SlackIntegration(self.integration, self.organization.id) + install = self.integration.get_installation(organization_id=self.organization.id) thread = SlackThreadDetails(thread_ts=self.thread_ts, channel_id=self.channel_id) send_thread_update(install=install, thread=thread, data=data) From 08a1c3803594cb854110a078a9dc79266cbddeed Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 7 Apr 2026 12:18:12 -0400 Subject: [PATCH 09/34] fix(seer-slack): Assert conversations_info returns dict, not binary data --- src/sentry/integrations/slack/integration.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 2fa90509fca7..dcfac2588619 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -245,7 +245,7 @@ def get_conversations_info( self, *, channel_id: str, - ) -> dict[str, Any]: + ) -> dict: """ Fetch conversations info from Slack API. """ @@ -255,6 +255,8 @@ def get_conversations_info( conversations = client.conversations_info( channel=channel_id, ) + + assert isinstance(conversations.data, dict) return conversations.data except SlackApiError as e: _logger.warning( From e01f3c122e77d9ca57feddd09318a3fa6c8c4f99 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 7 Apr 2026 13:45:58 -0400 Subject: [PATCH 10/34] fix(seer-slack): Update missing scope footer copy --- src/sentry/notifications/platform/slack/renderers/seer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 208f0a4fc853..0ba10878a9a1 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -266,7 +266,7 @@ def render_footer_blocks( 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"_Thread context is unavailable \u2014 optional scopes are disabled. " + f"_Thread context is unavailable because optional scopes are disabled. " f"<{settings_url}|Reinstall Sentry's Slack app> to enable this feature._" ) return [ContextBlock(elements=[MarkdownTextObject(text=footer_text)])] From 78f49b66312749ab51b509ed0bf3f607a27dcad4 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 7 Apr 2026 16:04:03 -0400 Subject: [PATCH 11/34] feat(seer-slack): Only show missing scope footer on first thread response Use cache.add() to atomically track whether the missing-scope footer has already been shown in a given thread. Subsequent Seer Explorer responses in the same thread no longer repeat the warning. --- .../seer/entrypoints/slack/messaging.py | 21 ++++++- .../seer/entrypoints/slack/test_slack.py | 58 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index f934f8ff2ac5..73c79e052537 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -29,6 +29,7 @@ from sentry.shared_integrations.exceptions import IntegrationConfigurationError, IntegrationError from sentry.tasks.base import instrumented_task from sentry.taskworker.namespaces import integrations_tasks +from sentry.utils.cache import cache from sentry.utils.registry import NoRegistrationExistsError if TYPE_CHECKING: @@ -37,9 +38,16 @@ from sentry.seer.entrypoints.slack.entrypoint import SlackThreadDetails +# TTL for the "missing scope footer already shown" cache key (24 hours). +MISSING_SCOPE_FOOTER_CACHE_TIMEOUT = 60 * 60 * 24 + logger = logging.getLogger(__name__) +def _missing_scope_footer_cache_key(integration_id: int, channel_id: str, thread_ts: str) -> str: + return f"seer:explorer:scope_footer:{integration_id}:{channel_id}:{thread_ts}" + + def send_thread_update( *, install: SlackIntegration, @@ -70,10 +78,17 @@ def send_thread_update( if isinstance(data, SeerExplorerResponse) and not install.has_history_scope( thread["channel_id"] ): - settings_url = install.organization.absolute_url( - f"/settings/{install.organization.slug}/integrations/slack/" + footer_key = _missing_scope_footer_cache_key( + install.model.id, thread["channel_id"], thread["thread_ts"] ) - renderable["blocks"].extend(SeerSlackRenderer.render_missing_scope_footer(settings_url)) + # cache.add is atomic: returns True only the first time (key didn't exist). + if cache.add(footer_key, True, timeout=MISSING_SCOPE_FOOTER_CACHE_TIMEOUT): + settings_url = install.organization.absolute_url( + f"/settings/{install.organization.slug}/integrations/slack/" + ) + renderable["blocks"].extend( + SeerSlackRenderer.render_missing_scope_footer(settings_url) + ) try: if ephemeral_user_id: diff --git a/tests/sentry/seer/entrypoints/slack/test_slack.py b/tests/sentry/seer/entrypoints/slack/test_slack.py index 64f5acc6be92..63956eb0417c 100644 --- a/tests/sentry/seer/entrypoints/slack/test_slack.py +++ b/tests/sentry/seer/entrypoints/slack/test_slack.py @@ -669,6 +669,64 @@ def test_send_thread_update_explorer_response_missing_scope_has_footer( assert "Reinstall" in footer_text assert "Thread context is unavailable" in footer_text + @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_message") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", + return_value=False, + ) + def test_send_thread_update_explorer_response_missing_scope_no_footer_on_second_message( + self, mock_has_history_scope, mock_send_threaded_message + ): + """Subsequent explorer responses in the same thread should not repeat the footer.""" + data = SeerExplorerResponse( + run_id=12345, + organization_id=self.organization.id, + summary="Test summary", + ) + install = self.integration.get_installation(organization_id=self.organization.id) + thread = SlackThreadDetails(thread_ts=self.thread_ts, channel_id=self.channel_id) + + # First call — footer should be present + send_thread_update(install=install, thread=thread, data=data) + renderable_first = mock_send_threaded_message.call_args.kwargs["renderable"] + assert renderable_first["blocks"][-1].type == "context" + + mock_send_threaded_message.reset_mock() + + # Second call in same thread — footer should be absent + send_thread_update(install=install, thread=thread, data=data) + renderable_second = mock_send_threaded_message.call_args.kwargs["renderable"] + for block in renderable_second["blocks"]: + assert block.type != "context" + + @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_message") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", + return_value=False, + ) + def test_send_thread_update_explorer_response_missing_scope_different_thread_gets_footer( + self, mock_has_history_scope, mock_send_threaded_message + ): + """Different threads should each get their own first-time footer.""" + data = SeerExplorerResponse( + run_id=12345, + organization_id=self.organization.id, + summary="Test summary", + ) + install = self.integration.get_installation(organization_id=self.organization.id) + thread_a = SlackThreadDetails(thread_ts=self.thread_ts, channel_id=self.channel_id) + thread_b = SlackThreadDetails(thread_ts="9999999999.999999", channel_id=self.channel_id) + + send_thread_update(install=install, thread=thread_a, data=data) + renderable_a = mock_send_threaded_message.call_args.kwargs["renderable"] + assert renderable_a["blocks"][-1].type == "context" + + mock_send_threaded_message.reset_mock() + + send_thread_update(install=install, thread=thread_b, data=data) + renderable_b = mock_send_threaded_message.call_args.kwargs["renderable"] + assert renderable_b["blocks"][-1].type == "context" + @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_message") @patch( "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", From efe485740c35515f1c340446ae7a0bdc64e272cf Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 9 Apr 2026 11:56:10 -0400 Subject: [PATCH 12/34] fix(seer-slack): Skip Slack API call when no history scopes are installed Add early return in has_history_scope when neither channels:history nor groups:history is installed, avoiding an unnecessary network round-trip to conversations_info on every bot mention. Also catch AssertionError in get_conversations_info so a binary response from the Slack SDK returns an empty dict instead of crashing the caller. Co-Authored-By: Claude Opus 4.6 --- src/sentry/integrations/slack/integration.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index dcfac2588619..fb0894d7de78 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -224,9 +224,14 @@ def has_history_scope(self, channel_id: str) -> bool: history_scopes = [SlackScope.CHANNELS_HISTORY, SlackScope.GROUPS_HISTORY] installed_scope_set = frozenset(self.model.metadata.get("scopes", [])) - if all(s in installed_scope_set for s in history_scopes): + installed_history_scopes = [s in installed_scope_set for s in history_scopes] + + if all(installed_history_scopes): return True + if not any(installed_history_scopes): + return False + conversation_data = self.get_conversations_info(channel_id=channel_id) channel_info = conversation_data.get("channel", {}) @@ -258,7 +263,7 @@ def get_conversations_info( assert isinstance(conversations.data, dict) return conversations.data - except SlackApiError as e: + except (SlackApiError, AssertionError) as e: _logger.warning( "slack.get_conversations_info.error", extra={"channel_id": channel_id, "error": str(e)}, From fed458f1a8a7d0cddd32ef1cc72c07cf6e2df897 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 9 Apr 2026 11:58:23 -0400 Subject: [PATCH 13/34] ref(seer-slack): Reduce missing scope footer cache TTL to 1 hour Lower from 24 hours so users see the reinstall prompt more frequently until they act on it. --- src/sentry/seer/entrypoints/slack/messaging.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index 73c79e052537..5ee1e28316b7 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -38,8 +38,8 @@ from sentry.seer.entrypoints.slack.entrypoint import SlackThreadDetails -# TTL for the "missing scope footer already shown" cache key (24 hours). -MISSING_SCOPE_FOOTER_CACHE_TIMEOUT = 60 * 60 * 24 +# TTL for the "missing scope footer already shown" cache key (1 hour). +MISSING_SCOPE_FOOTER_CACHE_TIMEOUT = 60 * 60 logger = logging.getLogger(__name__) From 68ea1ea92723aba1d3de392ba4f0e298132bc941 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Thu, 9 Apr 2026 13:40:13 -0400 Subject: [PATCH 14/34] fix(seer-slack): Allow history access for DMs and assistant threads Assistant threads are DMs under the hood, so the bot can always read its own conversation history without channels:history or groups:history. Without this, has_history_scope returned False for DMs, blocking thread context fetching and showing a misleading missing-scope footer. Co-Authored-By: Claude Opus 4.6 --- src/sentry/integrations/slack/integration.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index fb0894d7de78..8b3ced9da87b 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -237,13 +237,20 @@ def has_history_scope(self, channel_id: str) -> bool: 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_channel and 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), since a mention webhook should only come from channels + # Shouldn't reach here unless channel_info is empty (most likely + # an API error or an unrecognized conversation type). return False def get_conversations_info( From 693fb1c5d2436b632f4d93be77de2491babf5de4 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Tue, 7 Apr 2026 11:21:41 -0400 Subject: [PATCH 15/34] =?UTF-8?q?=F0=9F=9A=A7=20initial=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sentry/integrations/messaging/metrics.py | 11 ++ .../integrations/slack/webhooks/event.py | 114 +++++++++++++++- .../slack/webhooks/events/test_message_im.py | 123 +++++++++++++++++- 3 files changed, 245 insertions(+), 3 deletions(-) diff --git a/src/sentry/integrations/messaging/metrics.py b/src/sentry/integrations/messaging/metrics.py index a055eb9562a3..82a1276715f5 100644 --- a/src/sentry/integrations/messaging/metrics.py +++ b/src/sentry/integrations/messaging/metrics.py @@ -37,6 +37,7 @@ class MessagingInteractionType(StrEnum): VIEW_SUBMISSION = "VIEW_SUBMISSION" SEER_AUTOFIX_START = "SEER_AUTOFIX_START" APP_MENTION = "APP_MENTION" + DM_MESSAGE = "DM_MESSAGE" # Automatic behaviors PROCESS_SHARED_LINK = "PROCESS_SHARED_LINK" @@ -120,3 +121,13 @@ class AppMentionHaltReason(StrEnum): ORGANIZATION_NOT_ACTIVE = "organization_not_active" FEATURE_NOT_ENABLED = "feature_not_enabled" MISSING_EVENT_DATA = "missing_event_data" + + +class DmMessageHaltReason(StrEnum): + """Reasons why a DM message event may halt without processing.""" + + NO_ORGANIZATION = "no_organization" + ORGANIZATION_NOT_FOUND = "organization_not_found" + ORGANIZATION_NOT_ACTIVE = "organization_not_active" + FEATURE_NOT_ENABLED = "feature_not_enabled" + MISSING_EVENT_DATA = "missing_event_data" diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index d43eaf046bc2..413da97e5de9 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -19,6 +19,7 @@ from sentry.constants import ObjectStatus from sentry.integrations.messaging.metrics import ( AppMentionHaltReason, + DmMessageHaltReason, MessagingInteractionEvent, MessagingInteractionType, ) @@ -437,6 +438,113 @@ def on_app_mention(self, slack_request: SlackDMRequest) -> Response: ) return self.respond() + def on_dm(self, slack_request: SlackDMRequest) -> Response | None: + """Handle DM messages by kicking off the Seer Explorer agentic workflow.""" + with MessagingInteractionEvent( + interaction_type=MessagingInteractionType.DM_MESSAGE, + spec=SlackMessagingSpec(), + ).capture() as lifecycle: + data = slack_request.data.get("event", {}) + lifecycle.add_extras( + { + "integration_id": slack_request.integration.id, + "thread_ts": data.get("thread_ts"), + } + ) + + ois = integration_service.get_organization_integrations( + integration_id=slack_request.integration.id, + status=ObjectStatus.ACTIVE, + limit=1, + ) + if not ois: + lifecycle.record_halt(DmMessageHaltReason.NO_ORGANIZATION) + return self.respond() + + organization_id = ois[0].organization_id + lifecycle.add_extra("organization_id", organization_id) + + installation = slack_request.integration.get_installation( + organization_id=organization_id + ) + assert isinstance(installation, SlackIntegration) + try: + organization = installation.organization + except NotFound: + lifecycle.record_halt(DmMessageHaltReason.ORGANIZATION_NOT_FOUND) + return self.respond() + + if organization.status != OrganizationStatus.ACTIVE: + lifecycle.add_extra("status", organization.status) + lifecycle.record_halt(DmMessageHaltReason.ORGANIZATION_NOT_ACTIVE) + return self.respond() + + if not features.has("organizations:seer-slack-explorer", organization): + lifecycle.record_halt(DmMessageHaltReason.FEATURE_NOT_ENABLED) + return None + + channel_id = data.get("channel") + text = data.get("text") + ts = data.get("ts") or data.get("message_ts") + thread_ts = data.get("thread_ts") + + lifecycle.add_extras( + { + "channel_id": channel_id, + "text": text, + "ts": ts, + "thread_ts": thread_ts, + "user_id": slack_request.user_id, + } + ) + + if not channel_id or not text or not ts or not slack_request.user_id: + lifecycle.record_halt(DmMessageHaltReason.MISSING_EVENT_DATA) + return self.respond() + + try: + installation.set_thread_status( + channel_id=channel_id, + thread_ts=thread_ts or ts, + status="Thinking...", + loading_messages=[ + "Digging through your errors...", + "Sifting through stack traces...", + "Blaming the right code...", + "Following the breadcrumbs...", + "Asking the stack trace nicely...", + "Reading between the stack frames...", + "Hold on, I've seen this one before...", + "It worked on my machine...", + ], + ) + except Exception: + _logger.exception( + "slack.assistant_threads_setStatus.failed", + extra={ + "integration_id": slack_request.integration.id, + "channel_id": channel_id, + "thread_ts": thread_ts or ts, + }, + ) + + authorizations = slack_request.data.get("authorizations") or [] + bot_user_id = authorizations[0].get("user_id", "") if authorizations else "" + + process_mention_for_slack.apply_async( + kwargs={ + "integration_id": slack_request.integration.id, + "organization_id": organization_id, + "channel_id": channel_id, + "ts": ts, + "thread_ts": thread_ts, + "text": text, + "slack_user_id": slack_request.user_id, + "bot_user_id": bot_user_id, + } + ) + return self.respond() + # TODO(dcramer): implement app_uninstalled and tokens_revoked def post(self, request: Request) -> Response: try: @@ -465,9 +573,11 @@ def post(self, request: Request) -> Response: if command in COMMANDS: resp = super().post_dispatcher(slack_request) - else: - resp = self.on_message(request, slack_request) + # Try the agentic workflow first; falls back to help if feature is off. + resp = self.on_dm(slack_request) + if resp is None: + resp = self.on_message(request, slack_request) if resp: return resp diff --git a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py index afb5b9c7c008..73dfbdcd930b 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py @@ -4,9 +4,10 @@ import pytest from slack_sdk.web import SlackResponse +from sentry.integrations.messaging.metrics import DmMessageHaltReason from sentry.integrations.types import EventLifecycleOutcome from sentry.silo.base import SiloMode -from sentry.testutils.asserts import assert_slo_metric +from sentry.testutils.asserts import assert_halt_metric, assert_slo_metric from sentry.testutils.cases import IntegratedApiTestCase from sentry.testutils.helpers import get_response_text from sentry.testutils.silo import assume_test_silo_mode @@ -173,3 +174,123 @@ def test_user_message_im_no_text(self) -> None: resp = self.post_webhook(event_data=orjson.loads(MESSAGE_IM_EVENT_NO_TEXT)) assert resp.status_code == 200, resp.content assert not self.mock_post.called + + +MESSAGE_IM_DM_EVENT = { + "type": "message", + "channel": "DOxxxxxx", + "user": "Uxxxxxxx", + "text": "What is causing errors in my project?", + "ts": "123456789.9875", +} + +MESSAGE_IM_DM_EVENT_THREADED = { + **MESSAGE_IM_DM_EVENT, + "thread_ts": "123456789.0001", +} + +AUTHORIZATIONS_DATA = { + "authorizations": [{"user_id": "U0BOT", "is_bot": True}], +} + + +class MessageIMDmAgentTest(BaseEventTest): + """Tests for DM messages triggering the Seer Explorer agentic workflow.""" + + @pytest.fixture(autouse=True) + def mock_chat_postMessage(self): + with patch( + "slack_sdk.web.client.WebClient.chat_postMessage", + return_value=SlackResponse( + client=None, + http_verb="POST", + api_url="https://slack.com/api/chat.postMessage", + req_args={}, + data={"ok": True}, + headers={}, + status_code=200, + ), + ) as self.mock_post: + yield + + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_dispatches_task(self, mock_apply_async): + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT, data=AUTHORIZATIONS_DATA) + + assert resp.status_code == 200 + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["integration_id"] == self.integration.id + assert kwargs["organization_id"] == self.organization.id + assert kwargs["channel_id"] == "DOxxxxxx" + assert kwargs["ts"] == "123456789.9875" + assert kwargs["thread_ts"] is None + assert kwargs["text"] == MESSAGE_IM_DM_EVENT["text"] + assert kwargs["slack_user_id"] == "Uxxxxxxx" + assert kwargs["bot_user_id"] == "U0BOT" + + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_threaded_dispatches_task(self, mock_apply_async): + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook( + event_data=MESSAGE_IM_DM_EVENT_THREADED, data=AUTHORIZATIONS_DATA + ) + + assert resp.status_code == 200 + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["ts"] == "123456789.9875" + assert kwargs["thread_ts"] == "123456789.0001" + + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_no_authorizations(self, mock_apply_async): + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) + + assert resp.status_code == 200 + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["bot_user_id"] == "" + + @pytest.fixture(autouse=True) + def mock_set_thread_status(self): + with patch( + "sentry.integrations.slack.integration.SlackIntegration.set_thread_status", + ) as self.mock_status: + yield + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_feature_flag_disabled_falls_back_to_help(self, mock_apply_async, mock_record): + """When feature flag is off, DM should fall back to help message.""" + resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) + + assert resp.status_code == 200 + mock_apply_async.assert_not_called() + assert_halt_metric(mock_record, DmMessageHaltReason.FEATURE_NOT_ENABLED) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_no_organization(self, mock_apply_async, mock_record): + with patch( + "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", + return_value=[], + ): + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) + + assert resp.status_code == 200 + mock_apply_async.assert_not_called() + assert_halt_metric(mock_record, DmMessageHaltReason.NO_ORGANIZATION) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_empty_text(self, mock_apply_async, mock_record): + event_data = {**MESSAGE_IM_DM_EVENT, "text": ""} + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=event_data) + + assert resp.status_code == 200 + mock_apply_async.assert_not_called() + assert_halt_metric(mock_record, DmMessageHaltReason.MISSING_EVENT_DATA) From 727e078eecdaf4145726a72486af16f58302e98e Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Tue, 7 Apr 2026 16:32:04 -0400 Subject: [PATCH 16/34] =?UTF-8?q?=F0=9F=9A=A7=20unreviewed=20claude=20outp?= =?UTF-8?q?ut=20for=20thread=20start,=20need=20to=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sentry/integrations/messaging/metrics.py | 11 ++ src/sentry/integrations/slack/integration.py | 28 ++++ .../integrations/slack/webhooks/event.py | 96 +++++++++++++ .../events/test_assistant_thread_started.py | 129 ++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py diff --git a/src/sentry/integrations/messaging/metrics.py b/src/sentry/integrations/messaging/metrics.py index 82a1276715f5..0aae193ef8b0 100644 --- a/src/sentry/integrations/messaging/metrics.py +++ b/src/sentry/integrations/messaging/metrics.py @@ -38,6 +38,7 @@ class MessagingInteractionType(StrEnum): SEER_AUTOFIX_START = "SEER_AUTOFIX_START" APP_MENTION = "APP_MENTION" DM_MESSAGE = "DM_MESSAGE" + ASSISTANT_THREAD_STARTED = "ASSISTANT_THREAD_STARTED" # Automatic behaviors PROCESS_SHARED_LINK = "PROCESS_SHARED_LINK" @@ -131,3 +132,13 @@ class DmMessageHaltReason(StrEnum): ORGANIZATION_NOT_ACTIVE = "organization_not_active" FEATURE_NOT_ENABLED = "feature_not_enabled" MISSING_EVENT_DATA = "missing_event_data" + + +class AssistantThreadHaltReason(StrEnum): + """Reasons why an assistant_thread_started event may halt without processing.""" + + NO_ORGANIZATION = "no_organization" + ORGANIZATION_NOT_FOUND = "organization_not_found" + ORGANIZATION_NOT_ACTIVE = "organization_not_active" + FEATURE_NOT_ENABLED = "feature_not_enabled" + MISSING_EVENT_DATA = "missing_event_data" diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 0108de382649..65370a710934 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -286,6 +286,34 @@ def set_thread_status( extra={"channel_id": channel_id, "thread_ts": thread_ts}, ) + def set_suggested_prompts( + self, + *, + channel_id: str, + thread_ts: str, + prompts: Sequence[Mapping[str, str]], + title: str = "", + ) -> None: + """ + Set suggested prompts in a Slack assistant thread. + + Each prompt is a dict with ``title`` (display label) and ``message`` + (the text sent when clicked). Slack allows a maximum of 4 prompts. + """ + client = self.get_client() + try: + client.assistant_threads_setSuggestedPrompts( + channel_id=channel_id, + thread_ts=thread_ts, + title=title, + prompts=list(prompts), + ) + except SlackApiError: + _logger.warning( + "slack.set_suggested_prompts.error", + extra={"channel_id": channel_id, "thread_ts": thread_ts}, + ) + class SlackIntegrationProvider(IntegrationProvider): key = IntegrationProviderSlug.SLACK.value diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index 413da97e5de9..d298c46168b6 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -19,6 +19,7 @@ from sentry.constants import ObjectStatus from sentry.integrations.messaging.metrics import ( AppMentionHaltReason, + AssistantThreadHaltReason, DmMessageHaltReason, MessagingInteractionEvent, MessagingInteractionType, @@ -545,6 +546,98 @@ def on_dm(self, slack_request: SlackDMRequest) -> Response | None: ) return self.respond() + def on_assistant_thread_started(self, slack_request: SlackDMRequest) -> Response: + """Handle assistant_thread_started events by sending suggested prompts.""" + with MessagingInteractionEvent( + interaction_type=MessagingInteractionType.ASSISTANT_THREAD_STARTED, + spec=SlackMessagingSpec(), + ).capture() as lifecycle: + data = slack_request.data.get("event", {}) + assistant_thread = data.get("assistant_thread", {}) + lifecycle.add_extra("integration_id", slack_request.integration.id) + + ois = integration_service.get_organization_integrations( + integration_id=slack_request.integration.id, + status=ObjectStatus.ACTIVE, + limit=1, + ) + if not ois: + lifecycle.record_halt(AssistantThreadHaltReason.NO_ORGANIZATION) + return self.respond() + + organization_id = ois[0].organization_id + lifecycle.add_extra("organization_id", organization_id) + + installation = slack_request.integration.get_installation( + organization_id=organization_id + ) + assert isinstance(installation, SlackIntegration) + try: + organization = installation.organization + except NotFound: + lifecycle.record_halt(AssistantThreadHaltReason.ORGANIZATION_NOT_FOUND) + return self.respond() + + if organization.status != OrganizationStatus.ACTIVE: + lifecycle.add_extra("status", organization.status) + lifecycle.record_halt(AssistantThreadHaltReason.ORGANIZATION_NOT_ACTIVE) + return self.respond() + + if not features.has("organizations:seer-slack-explorer", organization): + lifecycle.record_halt(AssistantThreadHaltReason.FEATURE_NOT_ENABLED) + return self.respond() + + channel_id = assistant_thread.get("channel_id") + thread_ts = assistant_thread.get("thread_ts") + + lifecycle.add_extras( + { + "channel_id": channel_id, + "thread_ts": thread_ts, + "context": assistant_thread.get("context"), + } + ) + + if not channel_id or not thread_ts: + lifecycle.record_halt(AssistantThreadHaltReason.MISSING_EVENT_DATA) + return self.respond() + + try: + installation.set_suggested_prompts( + channel_id=channel_id, + thread_ts=thread_ts, + title="Hi there! I'm Seer, Sentry's AI assistant. How can I help?", + prompts=[ + { + "title": "Summarize recent issues", + "message": "What are the most important unresolved issues in my projects right now?", + }, + { + "title": "Investigate an error", + "message": "Help me investigate what's causing errors in my project.", + }, + { + "title": "Explain a stack trace", + "message": "Can you explain the root cause of this stack trace?", + }, + { + "title": "Find similar issues", + "message": "Are there any similar issues that might be related to each other?", + }, + ], + ) + except Exception: + _logger.exception( + "slack.assistant_thread_started.set_suggested_prompts_failed", + extra={ + "integration_id": slack_request.integration.id, + "channel_id": channel_id, + "thread_ts": thread_ts, + }, + ) + + return self.respond() + # TODO(dcramer): implement app_uninstalled and tokens_revoked def post(self, request: Request) -> Response: try: @@ -565,6 +658,9 @@ def post(self, request: Request) -> Response: if slack_request.type == "app_mention": return self.on_app_mention(slack_request) + if slack_request.type == "assistant_thread_started": + return self.on_assistant_thread_started(slack_request) + if slack_request.type == "message": if slack_request.is_bot(): return self.respond() diff --git a/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py new file mode 100644 index 000000000000..66380a26dc86 --- /dev/null +++ b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py @@ -0,0 +1,129 @@ +from unittest.mock import patch + +from sentry.integrations.messaging.metrics import AssistantThreadHaltReason +from sentry.testutils.asserts import assert_halt_metric + +from . import BaseEventTest + +ASSISTANT_THREAD_STARTED_EVENT = { + "type": "assistant_thread_started", + "assistant_thread": { + "user_id": "U1234567890", + "context": { + "channel_id": "C1234567890", + "team_id": "T07XY8FPJ5C", + "enterprise_id": "E480293PS82", + }, + "channel_id": "D1234567890", + "thread_ts": "1729999327.187299", + }, + "event_ts": "1715873754.429808", +} + + +class AssistantThreadStartedEventTest(BaseEventTest): + @patch( + "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", + ) + def test_sends_suggested_prompts(self, mock_set_prompts): + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) + + assert resp.status_code == 200 + mock_set_prompts.assert_called_once() + kwargs = mock_set_prompts.call_args[1] + assert kwargs["channel_id"] == "D1234567890" + assert kwargs["thread_ts"] == "1729999327.187299" + assert len(kwargs["prompts"]) == 4 + assert kwargs["title"] # non-empty welcome title + + @patch( + "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", + ) + def test_prompt_titles_and_messages(self, mock_set_prompts): + with self.feature("organizations:seer-slack-explorer"): + self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) + + prompts = mock_set_prompts.call_args[1]["prompts"] + for prompt in prompts: + assert "title" in prompt + assert "message" in prompt + assert prompt["title"] + assert prompt["message"] + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", + ) + def test_feature_flag_disabled(self, mock_set_prompts, mock_record): + resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) + + assert resp.status_code == 200 + mock_set_prompts.assert_not_called() + assert_halt_metric(mock_record, AssistantThreadHaltReason.FEATURE_NOT_ENABLED) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", + ) + def test_no_organization(self, mock_set_prompts, mock_record): + with patch( + "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", + return_value=[], + ): + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) + + assert resp.status_code == 200 + mock_set_prompts.assert_not_called() + assert_halt_metric(mock_record, AssistantThreadHaltReason.NO_ORGANIZATION) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", + ) + def test_missing_channel_id(self, mock_set_prompts, mock_record): + event_data = { + "type": "assistant_thread_started", + "assistant_thread": { + "user_id": "U1234567890", + "thread_ts": "1729999327.187299", + }, + } + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=event_data) + + assert resp.status_code == 200 + mock_set_prompts.assert_not_called() + assert_halt_metric(mock_record, AssistantThreadHaltReason.MISSING_EVENT_DATA) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch( + "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", + ) + def test_missing_thread_ts(self, mock_set_prompts, mock_record): + event_data = { + "type": "assistant_thread_started", + "assistant_thread": { + "user_id": "U1234567890", + "channel_id": "D1234567890", + }, + } + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=event_data) + + assert resp.status_code == 200 + mock_set_prompts.assert_not_called() + assert_halt_metric(mock_record, AssistantThreadHaltReason.MISSING_EVENT_DATA) + + @patch( + "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", + side_effect=Exception("API error"), + ) + def test_set_prompts_failure_does_not_raise(self, mock_set_prompts): + """If set_suggested_prompts fails, we still return 200.""" + with self.feature("organizations:seer-slack-explorer"): + resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) + + assert resp.status_code == 200 + mock_set_prompts.assert_called_once() From db819cf87181778f6a1d9c5c5fc5036d16d3b9b2 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Wed, 8 Apr 2026 13:30:20 -0400 Subject: [PATCH 17/34] feat(slack): Add assistant:write scope for Slack Agent support Add the assistant:write scope to the Slack integration to enable the bot to act as a Slack Agent, supporting DM-based agent interfaces. Refs ISWF-2388 Co-Authored-By: Claude Opus 4.6 --- src/sentry/integrations/slack/integration.py | 1 + src/sentry/integrations/slack/utils/constants.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 65370a710934..ca16e0012efe 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -347,6 +347,7 @@ class SlackIntegrationProvider(IntegrationProvider): SlackScope.CHANNELS_HISTORY, SlackScope.GROUPS_HISTORY, SlackScope.APP_MENTIONS_READ, + SlackScope.ASSISTANT_WRITE, ] ) user_scopes = frozenset( diff --git a/src/sentry/integrations/slack/utils/constants.py b/src/sentry/integrations/slack/utils/constants.py index 2d6293003c44..b3e63eb22db4 100644 --- a/src/sentry/integrations/slack/utils/constants.py +++ b/src/sentry/integrations/slack/utils/constants.py @@ -14,3 +14,5 @@ class SlackScope(StrEnum): """Allows the bot to read message history in private groups.""" APP_MENTIONS_READ = "app_mentions:read" """Allows the bot to read mentions in app messages.""" + ASSISTANT_WRITE = "assistant:write" + """Allows the bot to act as a Slack Agent.""" From 58aa620f8005dee89a66bae4519f1d3157a45be6 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Wed, 8 Apr 2026 13:59:24 -0400 Subject: [PATCH 18/34] ref(slack): Consolidate duplicate Seer event handlers and halt enums Extract shared org-resolution logic into _resolve_seer_organization helper and merge on_app_mention/on_dm into a single _handle_seer_mention method. Replace three identical halt reason enums with unified SeerSlackHaltReason. Extract duplicated loading messages list into a module-level constant. Refs ISWF-2388 Co-Authored-By: Claude Opus 4.6 --- src/sentry/integrations/messaging/metrics.py | 24 +- .../integrations/slack/webhooks/event.py | 248 ++++++------------ .../slack/webhooks/events/test_app_mention.py | 10 +- .../events/test_assistant_thread_started.py | 10 +- .../slack/webhooks/events/test_message_im.py | 8 +- 5 files changed, 89 insertions(+), 211 deletions(-) diff --git a/src/sentry/integrations/messaging/metrics.py b/src/sentry/integrations/messaging/metrics.py index 0aae193ef8b0..ea2fefda8eeb 100644 --- a/src/sentry/integrations/messaging/metrics.py +++ b/src/sentry/integrations/messaging/metrics.py @@ -114,28 +114,8 @@ class MessageInteractionFailureReason(StrEnum): MISSING_ACTION = "missing_action" -class AppMentionHaltReason(StrEnum): - """Reasons why an app mention event may halt without processing.""" - - NO_ORGANIZATION = "no_organization" - ORGANIZATION_NOT_FOUND = "organization_not_found" - ORGANIZATION_NOT_ACTIVE = "organization_not_active" - FEATURE_NOT_ENABLED = "feature_not_enabled" - MISSING_EVENT_DATA = "missing_event_data" - - -class DmMessageHaltReason(StrEnum): - """Reasons why a DM message event may halt without processing.""" - - NO_ORGANIZATION = "no_organization" - ORGANIZATION_NOT_FOUND = "organization_not_found" - ORGANIZATION_NOT_ACTIVE = "organization_not_active" - FEATURE_NOT_ENABLED = "feature_not_enabled" - MISSING_EVENT_DATA = "missing_event_data" - - -class AssistantThreadHaltReason(StrEnum): - """Reasons why an assistant_thread_started event may halt without processing.""" +class SeerSlackHaltReason(StrEnum): + """Reasons why a Seer Slack event (app mention, DM, assistant thread) may halt.""" NO_ORGANIZATION = "no_organization" ORGANIZATION_NOT_FOUND = "organization_not_found" diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index d298c46168b6..7475022c663e 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -18,11 +18,9 @@ from sentry.api.base import all_silo_endpoint from sentry.constants import ObjectStatus from sentry.integrations.messaging.metrics import ( - AppMentionHaltReason, - AssistantThreadHaltReason, - DmMessageHaltReason, MessagingInteractionEvent, MessagingInteractionType, + SeerSlackHaltReason, ) from sentry.integrations.services.integration import integration_service from sentry.integrations.slack.analytics import SlackIntegrationChartUnfurl @@ -46,6 +44,17 @@ _logger = logging.getLogger(__name__) +_SEER_LOADING_MESSAGES = [ + "Digging through your errors...", + "Sifting through stack traces...", + "Blaming the right code...", + "Following the breadcrumbs...", + "Asking the stack trace nicely...", + "Reading between the stack frames...", + "Hold on, I've seen this one before...", + "It worked on my machine...", +] + @all_silo_endpoint # Only challenge verification is handled at control class SlackEventEndpoint(SlackDMEndpoint): @@ -332,117 +341,55 @@ def on_link_shared(self, request: Request, slack_request: SlackDMRequest) -> boo return True - def on_app_mention(self, slack_request: SlackDMRequest) -> Response: - """Handle @mention events for Seer Explorer.""" - with MessagingInteractionEvent( - interaction_type=MessagingInteractionType.APP_MENTION, - spec=SlackMessagingSpec(), - ).capture() as lifecycle: - data = slack_request.data.get("event", {}) - lifecycle.add_extras( - { - "integration_id": slack_request.integration.id, - "thread_ts": data.get("thread_ts"), - } - ) - - ois = integration_service.get_organization_integrations( - integration_id=slack_request.integration.id, - status=ObjectStatus.ACTIVE, - limit=1, - ) - if not ois: - lifecycle.record_halt(AppMentionHaltReason.NO_ORGANIZATION) - return self.respond() + def _resolve_seer_organization(self, slack_request, lifecycle): + """Resolve and validate the organization for a Seer Slack event. - organization_id = ois[0].organization_id - lifecycle.add_extra("organization_id", organization_id) - - installation = slack_request.integration.get_installation( - organization_id=organization_id - ) - assert isinstance(installation, SlackIntegration) - try: - organization = installation.organization - except NotFound: - lifecycle.record_halt(AppMentionHaltReason.ORGANIZATION_NOT_FOUND) - return self.respond() - - if organization.status != OrganizationStatus.ACTIVE: - lifecycle.add_extra("status", organization.status) - lifecycle.record_halt(AppMentionHaltReason.ORGANIZATION_NOT_ACTIVE) - return self.respond() - - if not features.has("organizations:seer-slack-explorer", organization): - lifecycle.record_halt(AppMentionHaltReason.FEATURE_NOT_ENABLED) - return self.respond() + Returns ``(organization_id, installation)`` or ``None`` when the + event should be halted (the halt reason is already recorded). + """ + ois = integration_service.get_organization_integrations( + integration_id=slack_request.integration.id, + status=ObjectStatus.ACTIVE, + limit=1, + ) + if not ois: + lifecycle.record_halt(SeerSlackHaltReason.NO_ORGANIZATION) + return None - channel_id = data.get("channel") - text = data.get("text") - ts = data.get("ts") - thread_ts = data.get("thread_ts") # None for top-level messages + organization_id = ois[0].organization_id + lifecycle.add_extra("organization_id", organization_id) - lifecycle.add_extras( - { - "channel_id": channel_id, - "text": text, - "ts": ts, - "thread_ts": thread_ts, - "user_id": slack_request.user_id, - } - ) + installation = slack_request.integration.get_installation(organization_id=organization_id) + assert isinstance(installation, SlackIntegration) + try: + organization = installation.organization + except NotFound: + lifecycle.record_halt(SeerSlackHaltReason.ORGANIZATION_NOT_FOUND) + return None - if not channel_id or not text or not ts or not slack_request.user_id: - lifecycle.record_halt(AppMentionHaltReason.MISSING_EVENT_DATA) - return self.respond() + if organization.status != OrganizationStatus.ACTIVE: + lifecycle.add_extra("status", organization.status) + lifecycle.record_halt(SeerSlackHaltReason.ORGANIZATION_NOT_ACTIVE) + return None - try: - installation.set_thread_status( - channel_id=channel_id, - thread_ts=thread_ts or ts, - status="Thinking...", - loading_messages=[ - "Digging through your errors...", - "Sifting through stack traces...", - "Blaming the right code...", - "Following the breadcrumbs...", - "Asking the stack trace nicely...", - "Reading between the stack frames...", - "Hold on, I've seen this one before...", - "It worked on my machine...", - ], - ) - except Exception: - _logger.exception( - "slack.assistant_threads_setStatus.failed", - extra={ - "integration_id": slack_request.integration.id, - "channel_id": channel_id, - "thread_ts": thread_ts or ts, - }, - ) + if not features.has("organizations:seer-slack-explorer", organization): + lifecycle.record_halt(SeerSlackHaltReason.FEATURE_NOT_ENABLED) + return None - authorizations = slack_request.data.get("authorizations") or [] - bot_user_id = authorizations[0].get("user_id", "") if authorizations else "" + return organization_id, installation - process_mention_for_slack.apply_async( - kwargs={ - "integration_id": slack_request.integration.id, - "organization_id": organization_id, - "channel_id": channel_id, - "ts": ts, - "thread_ts": thread_ts, - "text": text, - "slack_user_id": slack_request.user_id, - "bot_user_id": bot_user_id, - } - ) - return self.respond() + def _handle_seer_mention( + self, + slack_request: SlackDMRequest, + interaction_type: MessagingInteractionType, + ) -> Response | None: + """Shared handler for app mentions and DMs that trigger the Seer workflow. - def on_dm(self, slack_request: SlackDMRequest) -> Response | None: - """Handle DM messages by kicking off the Seer Explorer agentic workflow.""" + Returns ``None`` when the feature flag is off (DM messages only), + allowing the caller to fall back to alternative handling. + """ with MessagingInteractionEvent( - interaction_type=MessagingInteractionType.DM_MESSAGE, + interaction_type=interaction_type, spec=SlackMessagingSpec(), ).capture() as lifecycle: data = slack_request.data.get("event", {}) @@ -453,36 +400,14 @@ def on_dm(self, slack_request: SlackDMRequest) -> Response | None: } ) - ois = integration_service.get_organization_integrations( - integration_id=slack_request.integration.id, - status=ObjectStatus.ACTIVE, - limit=1, - ) - if not ois: - lifecycle.record_halt(DmMessageHaltReason.NO_ORGANIZATION) + result = self._resolve_seer_organization(slack_request, lifecycle) + if result is None: + # For DMs, return None on feature-flag halt so caller can + # fall back to the help message. + if interaction_type == MessagingInteractionType.DM_MESSAGE: + return None return self.respond() - - organization_id = ois[0].organization_id - lifecycle.add_extra("organization_id", organization_id) - - installation = slack_request.integration.get_installation( - organization_id=organization_id - ) - assert isinstance(installation, SlackIntegration) - try: - organization = installation.organization - except NotFound: - lifecycle.record_halt(DmMessageHaltReason.ORGANIZATION_NOT_FOUND) - return self.respond() - - if organization.status != OrganizationStatus.ACTIVE: - lifecycle.add_extra("status", organization.status) - lifecycle.record_halt(DmMessageHaltReason.ORGANIZATION_NOT_ACTIVE) - return self.respond() - - if not features.has("organizations:seer-slack-explorer", organization): - lifecycle.record_halt(DmMessageHaltReason.FEATURE_NOT_ENABLED) - return None + organization_id, installation = result channel_id = data.get("channel") text = data.get("text") @@ -500,7 +425,7 @@ def on_dm(self, slack_request: SlackDMRequest) -> Response | None: ) if not channel_id or not text or not ts or not slack_request.user_id: - lifecycle.record_halt(DmMessageHaltReason.MISSING_EVENT_DATA) + lifecycle.record_halt(SeerSlackHaltReason.MISSING_EVENT_DATA) return self.respond() try: @@ -508,16 +433,7 @@ def on_dm(self, slack_request: SlackDMRequest) -> Response | None: channel_id=channel_id, thread_ts=thread_ts or ts, status="Thinking...", - loading_messages=[ - "Digging through your errors...", - "Sifting through stack traces...", - "Blaming the right code...", - "Following the breadcrumbs...", - "Asking the stack trace nicely...", - "Reading between the stack frames...", - "Hold on, I've seen this one before...", - "It worked on my machine...", - ], + loading_messages=_SEER_LOADING_MESSAGES, ) except Exception: _logger.exception( @@ -546,6 +462,14 @@ def on_dm(self, slack_request: SlackDMRequest) -> Response | None: ) return self.respond() + def on_app_mention(self, slack_request: SlackDMRequest) -> Response: + """Handle @mention events for Seer Explorer.""" + return self._handle_seer_mention(slack_request, MessagingInteractionType.APP_MENTION) + + def on_dm(self, slack_request: SlackDMRequest) -> Response | None: + """Handle DM messages via the Seer workflow; returns None to fall back to help.""" + return self._handle_seer_mention(slack_request, MessagingInteractionType.DM_MESSAGE) + def on_assistant_thread_started(self, slack_request: SlackDMRequest) -> Response: """Handle assistant_thread_started events by sending suggested prompts.""" with MessagingInteractionEvent( @@ -556,36 +480,10 @@ def on_assistant_thread_started(self, slack_request: SlackDMRequest) -> Response assistant_thread = data.get("assistant_thread", {}) lifecycle.add_extra("integration_id", slack_request.integration.id) - ois = integration_service.get_organization_integrations( - integration_id=slack_request.integration.id, - status=ObjectStatus.ACTIVE, - limit=1, - ) - if not ois: - lifecycle.record_halt(AssistantThreadHaltReason.NO_ORGANIZATION) - return self.respond() - - organization_id = ois[0].organization_id - lifecycle.add_extra("organization_id", organization_id) - - installation = slack_request.integration.get_installation( - organization_id=organization_id - ) - assert isinstance(installation, SlackIntegration) - try: - organization = installation.organization - except NotFound: - lifecycle.record_halt(AssistantThreadHaltReason.ORGANIZATION_NOT_FOUND) - return self.respond() - - if organization.status != OrganizationStatus.ACTIVE: - lifecycle.add_extra("status", organization.status) - lifecycle.record_halt(AssistantThreadHaltReason.ORGANIZATION_NOT_ACTIVE) - return self.respond() - - if not features.has("organizations:seer-slack-explorer", organization): - lifecycle.record_halt(AssistantThreadHaltReason.FEATURE_NOT_ENABLED) + result = self._resolve_seer_organization(slack_request, lifecycle) + if result is None: return self.respond() + _organization_id, installation = result channel_id = assistant_thread.get("channel_id") thread_ts = assistant_thread.get("thread_ts") @@ -599,7 +497,7 @@ def on_assistant_thread_started(self, slack_request: SlackDMRequest) -> Response ) if not channel_id or not thread_ts: - lifecycle.record_halt(AssistantThreadHaltReason.MISSING_EVENT_DATA) + lifecycle.record_halt(SeerSlackHaltReason.MISSING_EVENT_DATA) return self.respond() try: diff --git a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py index a04cdc769cea..ed37dcece346 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py @@ -1,6 +1,6 @@ from unittest.mock import patch -from sentry.integrations.messaging.metrics import AppMentionHaltReason +from sentry.integrations.messaging.metrics import SeerSlackHaltReason from sentry.testutils.asserts import assert_halt_metric from . import BaseEventTest @@ -73,7 +73,7 @@ def test_app_mention_feature_flag_disabled(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, AppMentionHaltReason.FEATURE_NOT_ENABLED) + assert_halt_metric(mock_record, SeerSlackHaltReason.FEATURE_NOT_ENABLED) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") @@ -84,7 +84,7 @@ def test_app_mention_empty_text(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, AppMentionHaltReason.MISSING_EVENT_DATA) + assert_halt_metric(mock_record, SeerSlackHaltReason.MISSING_EVENT_DATA) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") @@ -99,7 +99,7 @@ def test_app_mention_no_organization(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, AppMentionHaltReason.NO_ORGANIZATION) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_ORGANIZATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") @@ -113,4 +113,4 @@ def test_app_mention_org_not_found(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, AppMentionHaltReason.ORGANIZATION_NOT_FOUND) + assert_halt_metric(mock_record, SeerSlackHaltReason.ORGANIZATION_NOT_FOUND) diff --git a/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py index 66380a26dc86..e991634c59b2 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py @@ -1,6 +1,6 @@ from unittest.mock import patch -from sentry.integrations.messaging.metrics import AssistantThreadHaltReason +from sentry.integrations.messaging.metrics import SeerSlackHaltReason from sentry.testutils.asserts import assert_halt_metric from . import BaseEventTest @@ -60,7 +60,7 @@ def test_feature_flag_disabled(self, mock_set_prompts, mock_record): assert resp.status_code == 200 mock_set_prompts.assert_not_called() - assert_halt_metric(mock_record, AssistantThreadHaltReason.FEATURE_NOT_ENABLED) + assert_halt_metric(mock_record, SeerSlackHaltReason.FEATURE_NOT_ENABLED) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch( @@ -76,7 +76,7 @@ def test_no_organization(self, mock_set_prompts, mock_record): assert resp.status_code == 200 mock_set_prompts.assert_not_called() - assert_halt_metric(mock_record, AssistantThreadHaltReason.NO_ORGANIZATION) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_ORGANIZATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch( @@ -95,7 +95,7 @@ def test_missing_channel_id(self, mock_set_prompts, mock_record): assert resp.status_code == 200 mock_set_prompts.assert_not_called() - assert_halt_metric(mock_record, AssistantThreadHaltReason.MISSING_EVENT_DATA) + assert_halt_metric(mock_record, SeerSlackHaltReason.MISSING_EVENT_DATA) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch( @@ -114,7 +114,7 @@ def test_missing_thread_ts(self, mock_set_prompts, mock_record): assert resp.status_code == 200 mock_set_prompts.assert_not_called() - assert_halt_metric(mock_record, AssistantThreadHaltReason.MISSING_EVENT_DATA) + assert_halt_metric(mock_record, SeerSlackHaltReason.MISSING_EVENT_DATA) @patch( "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", diff --git a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py index 73dfbdcd930b..f5b5afc78ae9 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py @@ -4,7 +4,7 @@ import pytest from slack_sdk.web import SlackResponse -from sentry.integrations.messaging.metrics import DmMessageHaltReason +from sentry.integrations.messaging.metrics import SeerSlackHaltReason from sentry.integrations.types import EventLifecycleOutcome from sentry.silo.base import SiloMode from sentry.testutils.asserts import assert_halt_metric, assert_slo_metric @@ -268,7 +268,7 @@ def test_dm_feature_flag_disabled_falls_back_to_help(self, mock_apply_async, moc assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, DmMessageHaltReason.FEATURE_NOT_ENABLED) + assert_halt_metric(mock_record, SeerSlackHaltReason.FEATURE_NOT_ENABLED) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") @@ -282,7 +282,7 @@ def test_dm_no_organization(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, DmMessageHaltReason.NO_ORGANIZATION) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_ORGANIZATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") @@ -293,4 +293,4 @@ def test_dm_empty_text(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, DmMessageHaltReason.MISSING_EVENT_DATA) + assert_halt_metric(mock_record, SeerSlackHaltReason.MISSING_EVENT_DATA) From 6a28028b08d41f637b84118efc0cd001b064c1bc Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Wed, 8 Apr 2026 17:38:55 -0400 Subject: [PATCH 19/34] test(slack): Update tests for Seer org resolution and halt reason changes Update tests to match the refactored _resolve_seer_organization which now iterates org integrations and uses SlackExplorerEntrypoint.has_access instead of checking a single feature flag. Align halt reasons with the consolidated enum values (NO_VALID_INTEGRATION, NO_VALID_ORGANIZATION) and update the has_access test for the seer-slack-explorer flag rename. Co-Authored-By: Claude Opus 4.6 --- src/sentry/integrations/messaging/metrics.py | 6 +- src/sentry/integrations/slack/integration.py | 2 +- .../integrations/slack/webhooks/event.py | 65 +++++++++++-------- .../seer/entrypoints/slack/entrypoint.py | 2 +- .../slack/webhooks/events/__init__.py | 6 ++ .../slack/webhooks/events/test_app_mention.py | 30 +++++---- .../events/test_assistant_thread_started.py | 20 +++--- .../slack/webhooks/events/test_message_im.py | 18 ++--- .../seer/entrypoints/slack/test_slack.py | 4 +- 9 files changed, 85 insertions(+), 68 deletions(-) diff --git a/src/sentry/integrations/messaging/metrics.py b/src/sentry/integrations/messaging/metrics.py index ea2fefda8eeb..c261cd24d6e5 100644 --- a/src/sentry/integrations/messaging/metrics.py +++ b/src/sentry/integrations/messaging/metrics.py @@ -117,8 +117,6 @@ class MessageInteractionFailureReason(StrEnum): class SeerSlackHaltReason(StrEnum): """Reasons why a Seer Slack event (app mention, DM, assistant thread) may halt.""" - NO_ORGANIZATION = "no_organization" - ORGANIZATION_NOT_FOUND = "organization_not_found" - ORGANIZATION_NOT_ACTIVE = "organization_not_active" - FEATURE_NOT_ENABLED = "feature_not_enabled" + NO_VALID_INTEGRATION = "no_valid_integration" + NO_VALID_ORGANIZATION = "no_valid_organization" MISSING_EVENT_DATA = "missing_event_data" diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index ca16e0012efe..fcd706fbe93f 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -291,7 +291,7 @@ def set_suggested_prompts( *, channel_id: str, thread_ts: str, - prompts: Sequence[Mapping[str, str]], + prompts: Sequence[dict[str, str]], title: str = "", ) -> None: """ diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index 7475022c663e..55ac9de56b0f 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -7,7 +7,6 @@ import orjson import sentry_sdk -from rest_framework.exceptions import NotFound from rest_framework.request import Request from rest_framework.response import Response from slack_sdk.errors import SlackApiError @@ -34,9 +33,11 @@ from sentry.integrations.slack.unfurl.handlers import link_handlers, match_link from sentry.integrations.slack.unfurl.types import LinkType, UnfurlableUrl from sentry.integrations.slack.views.link_identity import build_linking_url -from sentry.models.organization import OrganizationStatus +from sentry.integrations.types import IntegrationProviderSlug +from sentry.models.organization import Organization, OrganizationStatus from sentry.organizations.services.organization import organization_service from sentry.organizations.services.organization.model import RpcOrganization +from sentry.seer.entrypoints.slack.entrypoint import SlackExplorerEntrypoint from sentry.seer.entrypoints.slack.tasks import process_mention_for_slack from .base import SlackDMEndpoint @@ -54,6 +55,7 @@ "Hold on, I've seen this one before...", "It worked on my machine...", ] +SLACK_PROVIDERS = {IntegrationProviderSlug.SLACK, IntegrationProviderSlug.SLACK_STAGING} @all_silo_endpoint # Only challenge verification is handled at control @@ -341,42 +343,49 @@ def on_link_shared(self, request: Request, slack_request: SlackDMRequest) -> boo return True - def _resolve_seer_organization(self, slack_request, lifecycle): - """Resolve and validate the organization for a Seer Slack event. + def _resolve_seer_organization( + self, slack_request, lifecycle + ) -> tuple[int, SlackIntegration] | None: + """Resolve and validate an organization for a Seer Slack event. Returns ``(organization_id, installation)`` or ``None`` when the event should be halted (the halt reason is already recorded). + + Note: There is a limitation here of only grabbing the first organization with access to Seer. + If a Slack installation corresponds to multiple organizations with Seer access, this will not work, + and must be revisited. """ ois = integration_service.get_organization_integrations( integration_id=slack_request.integration.id, status=ObjectStatus.ACTIVE, - limit=1, + providers=SLACK_PROVIDERS, ) if not ois: - lifecycle.record_halt(SeerSlackHaltReason.NO_ORGANIZATION) + lifecycle.record_halt(SeerSlackHaltReason.NO_VALID_INTEGRATION) return None - organization_id = ois[0].organization_id - lifecycle.add_extra("organization_id", organization_id) + lifecycle.add_extra("organization_ids", [oi.organization_id for oi in ois]) + for oi in ois: + organization_id = oi.organization_id + try: + organization = Organization.objects.get_from_cache(id=organization_id) + except Organization.DoesNotExist: + continue - installation = slack_request.integration.get_installation(organization_id=organization_id) - assert isinstance(installation, SlackIntegration) - try: - organization = installation.organization - except NotFound: - lifecycle.record_halt(SeerSlackHaltReason.ORGANIZATION_NOT_FOUND) - return None + installation = slack_request.integration.get_installation( + organization_id=organization_id + ) + assert isinstance(installation, SlackIntegration) - if organization.status != OrganizationStatus.ACTIVE: - lifecycle.add_extra("status", organization.status) - lifecycle.record_halt(SeerSlackHaltReason.ORGANIZATION_NOT_ACTIVE) - return None + if organization.status != OrganizationStatus.ACTIVE: + continue - if not features.has("organizations:seer-slack-explorer", organization): - lifecycle.record_halt(SeerSlackHaltReason.FEATURE_NOT_ENABLED) - return None + if not SlackExplorerEntrypoint.has_access(organization): + continue - return organization_id, installation + return organization_id, installation + lifecycle.record_halt(SeerSlackHaltReason.NO_VALID_ORGANIZATION) + return None def _handle_seer_mention( self, @@ -464,7 +473,10 @@ def _handle_seer_mention( def on_app_mention(self, slack_request: SlackDMRequest) -> Response: """Handle @mention events for Seer Explorer.""" - return self._handle_seer_mention(slack_request, MessagingInteractionType.APP_MENTION) + return ( + self._handle_seer_mention(slack_request, MessagingInteractionType.APP_MENTION) + or self.respond() + ) def on_dm(self, slack_request: SlackDMRequest) -> Response | None: """Handle DM messages via the Seer workflow; returns None to fall back to help.""" @@ -565,13 +577,12 @@ def post(self, request: Request) -> Response: command, _ = slack_request.get_command_and_args() + resp: Response | None if command in COMMANDS: resp = super().post_dispatcher(slack_request) else: # Try the agentic workflow first; falls back to help if feature is off. - resp = self.on_dm(slack_request) - if resp is None: - resp = self.on_message(request, slack_request) + resp = self.on_dm(slack_request) or self.on_message(request, slack_request) if resp: return resp diff --git a/src/sentry/seer/entrypoints/slack/entrypoint.py b/src/sentry/seer/entrypoints/slack/entrypoint.py index 1e84f2b031a0..04590fd9e64d 100644 --- a/src/sentry/seer/entrypoints/slack/entrypoint.py +++ b/src/sentry/seer/entrypoints/slack/entrypoint.py @@ -380,7 +380,7 @@ def __init__( @staticmethod def has_access(organization: Organization) -> bool: has_seer_slack_feature_flag = features.has( - "organizations:seer-slack-workflows", organization + "organizations:seer-slack-explorer", organization ) has_explorer_access, _ = has_seer_explorer_access_with_detail(organization, None) return has_seer_slack_feature_flag and has_explorer_access diff --git a/tests/sentry/integrations/slack/webhooks/events/__init__.py b/tests/sentry/integrations/slack/webhooks/events/__init__.py index 660c17593e15..48b2b84eeb5d 100644 --- a/tests/sentry/integrations/slack/webhooks/events/__init__.py +++ b/tests/sentry/integrations/slack/webhooks/events/__init__.py @@ -7,6 +7,12 @@ UNSET = object() +SEER_EXPLORER_FEATURES = { + "organizations:seer-slack-explorer": True, + "organizations:gen-ai-features": True, + "organizations:seer-explorer": True, +} + LINK_SHARED_EVENT = """{ "type": "link_shared", "channel": "Cxxxxxx", diff --git a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py index ed37dcece346..6b63ac095696 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py @@ -1,9 +1,10 @@ from unittest.mock import patch from sentry.integrations.messaging.metrics import SeerSlackHaltReason +from sentry.models.organization import Organization from sentry.testutils.asserts import assert_halt_metric -from . import BaseEventTest +from . import SEER_EXPLORER_FEATURES, BaseEventTest APP_MENTION_EVENT = { "type": "app_mention", @@ -27,7 +28,7 @@ class AppMentionEventTest(BaseEventTest): @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_app_mention_dispatches_task(self, mock_apply_async): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook( event_data=THREADED_APP_MENTION_EVENT, data=AUTHORIZATIONS_DATA ) @@ -46,7 +47,7 @@ def test_app_mention_dispatches_task(self, mock_apply_async): @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_app_mention_dispatches_task_no_authorizations(self, mock_apply_async): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=THREADED_APP_MENTION_EVENT) assert resp.status_code == 200 @@ -57,7 +58,7 @@ def test_app_mention_dispatches_task_no_authorizations(self, mock_apply_async): @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_app_mention_non_threaded_dispatches_task(self, mock_apply_async): """Non-threaded mentions dispatch with ts set and thread_ts as None.""" - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=APP_MENTION_EVENT) assert resp.status_code == 200 @@ -73,13 +74,13 @@ def test_app_mention_feature_flag_disabled(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.FEATURE_NOT_ENABLED) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_ORGANIZATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_app_mention_empty_text(self, mock_apply_async, mock_record): event_data = {**APP_MENTION_EVENT, "text": ""} - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data) assert resp.status_code == 200 @@ -88,29 +89,30 @@ def test_app_mention_empty_text(self, mock_apply_async, mock_record): @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_no_organization(self, mock_apply_async, mock_record): + def test_app_mention_no_integration(self, mock_apply_async, mock_record): """When the integration has no org integrations, we should not dispatch.""" with patch( "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", return_value=[], ): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=APP_MENTION_EVENT) assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.NO_ORGANIZATION) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_INTEGRATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_app_mention_org_not_found(self, mock_apply_async, mock_record): - with patch( - "sentry.organizations.services.organization.impl.DatabaseBackedOrganizationService.get", - return_value=None, + with patch.object( + Organization.objects, + "get_from_cache", + side_effect=Organization.DoesNotExist, ): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=APP_MENTION_EVENT) assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.ORGANIZATION_NOT_FOUND) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_ORGANIZATION) diff --git a/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py index e991634c59b2..d76c8f0c36a6 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py @@ -3,7 +3,7 @@ from sentry.integrations.messaging.metrics import SeerSlackHaltReason from sentry.testutils.asserts import assert_halt_metric -from . import BaseEventTest +from . import SEER_EXPLORER_FEATURES, BaseEventTest ASSISTANT_THREAD_STARTED_EVENT = { "type": "assistant_thread_started", @@ -26,7 +26,7 @@ class AssistantThreadStartedEventTest(BaseEventTest): "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", ) def test_sends_suggested_prompts(self, mock_set_prompts): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) assert resp.status_code == 200 @@ -41,7 +41,7 @@ def test_sends_suggested_prompts(self, mock_set_prompts): "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", ) def test_prompt_titles_and_messages(self, mock_set_prompts): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) prompts = mock_set_prompts.call_args[1]["prompts"] @@ -60,23 +60,23 @@ def test_feature_flag_disabled(self, mock_set_prompts, mock_record): assert resp.status_code == 200 mock_set_prompts.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.FEATURE_NOT_ENABLED) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_ORGANIZATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch( "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", ) - def test_no_organization(self, mock_set_prompts, mock_record): + def test_no_integration(self, mock_set_prompts, mock_record): with patch( "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", return_value=[], ): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) assert resp.status_code == 200 mock_set_prompts.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.NO_ORGANIZATION) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_INTEGRATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch( @@ -90,7 +90,7 @@ def test_missing_channel_id(self, mock_set_prompts, mock_record): "thread_ts": "1729999327.187299", }, } - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data) assert resp.status_code == 200 @@ -109,7 +109,7 @@ def test_missing_thread_ts(self, mock_set_prompts, mock_record): "channel_id": "D1234567890", }, } - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data) assert resp.status_code == 200 @@ -122,7 +122,7 @@ def test_missing_thread_ts(self, mock_set_prompts, mock_record): ) def test_set_prompts_failure_does_not_raise(self, mock_set_prompts): """If set_suggested_prompts fails, we still return 200.""" - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) assert resp.status_code == 200 diff --git a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py index f5b5afc78ae9..d35838b1f33b 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py @@ -13,7 +13,7 @@ from sentry.testutils.silo import assume_test_silo_mode from sentry.users.models.identity import Identity, IdentityStatus -from . import BaseEventTest +from . import SEER_EXPLORER_FEATURES, BaseEventTest MESSAGE_IM_EVENT = """{ "type": "message", @@ -215,7 +215,7 @@ def mock_chat_postMessage(self): @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_dm_dispatches_task(self, mock_apply_async): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT, data=AUTHORIZATIONS_DATA) assert resp.status_code == 200 @@ -232,7 +232,7 @@ def test_dm_dispatches_task(self, mock_apply_async): @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_dm_threaded_dispatches_task(self, mock_apply_async): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook( event_data=MESSAGE_IM_DM_EVENT_THREADED, data=AUTHORIZATIONS_DATA ) @@ -245,7 +245,7 @@ def test_dm_threaded_dispatches_task(self, mock_apply_async): @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_dm_no_authorizations(self, mock_apply_async): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) assert resp.status_code == 200 @@ -268,27 +268,27 @@ def test_dm_feature_flag_disabled_falls_back_to_help(self, mock_apply_async, moc assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.FEATURE_NOT_ENABLED) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_ORGANIZATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_dm_no_organization(self, mock_apply_async, mock_record): + def test_dm_no_integration(self, mock_apply_async, mock_record): with patch( "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", return_value=[], ): - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) assert resp.status_code == 200 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.NO_ORGANIZATION) + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_INTEGRATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_dm_empty_text(self, mock_apply_async, mock_record): event_data = {**MESSAGE_IM_DM_EVENT, "text": ""} - with self.feature("organizations:seer-slack-explorer"): + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data) assert resp.status_code == 200 diff --git a/tests/sentry/seer/entrypoints/slack/test_slack.py b/tests/sentry/seer/entrypoints/slack/test_slack.py index 3df290364832..0940f0ecd6ff 100644 --- a/tests/sentry/seer/entrypoints/slack/test_slack.py +++ b/tests/sentry/seer/entrypoints/slack/test_slack.py @@ -544,9 +544,9 @@ def test_has_access(self) -> None: "organizations:gen-ai-features": True, "organizations:seer-explorer": True, } - with self.feature({"organizations:seer-slack-workflows": False, **explorer_flags}): + with self.feature({"organizations:seer-slack-explorer": False, **explorer_flags}): assert not SlackExplorerEntrypoint.has_access(self.organization) - with self.feature({"organizations:seer-slack-workflows": True, **explorer_flags}): + with self.feature({"organizations:seer-slack-explorer": True, **explorer_flags}): assert SlackExplorerEntrypoint.has_access(self.organization) self.organization.update_option("sentry:hide_ai_features", True) assert not SlackExplorerEntrypoint.has_access(self.organization) From 6f4a9d92bc77400a129f2cb7b46190fd7fb31c83 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Fri, 10 Apr 2026 09:35:00 -0400 Subject: [PATCH 20/34] fix(slack): Validate user org membership in Seer org resolution Address PR review feedback: - Change SLACK_PROVIDERS from set to list to match the RPC method's expected `list[str]` parameter type, preventing serialization errors - Check org status before calling get_installation to avoid unnecessary queries for inactive orgs - Verify the requesting Slack user belongs to the resolved org when their identity is linked, preventing cross-org data access when multiple orgs share a Slack workspace - Fix inaccurate comments about DM fallback behavior Refs ISWF-2388 Co-Authored-By: Claude Opus 4.6 --- .../integrations/slack/webhooks/event.py | 27 ++++++---- .../slack/webhooks/events/test_app_mention.py | 50 +++++++++++++++++++ 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index 55ac9de56b0f..02d9a6d8a7bc 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -55,7 +55,7 @@ "Hold on, I've seen this one before...", "It worked on my machine...", ] -SLACK_PROVIDERS = {IntegrationProviderSlug.SLACK, IntegrationProviderSlug.SLACK_STAGING} +SLACK_PROVIDERS = [IntegrationProviderSlug.SLACK, IntegrationProviderSlug.SLACK_STAGING] @all_silo_endpoint # Only challenge verification is handled at control @@ -351,6 +351,8 @@ def _resolve_seer_organization( Returns ``(organization_id, installation)`` or ``None`` when the event should be halted (the halt reason is already recorded). + We also check that the requesting user is a member of the organization that Seer is accessing. + Note: There is a limitation here of only grabbing the first organization with access to Seer. If a Slack installation corresponds to multiple organizations with Seer access, this will not work, and must be revisited. @@ -364,6 +366,8 @@ def _resolve_seer_organization( lifecycle.record_halt(SeerSlackHaltReason.NO_VALID_INTEGRATION) return None + identity_user = slack_request.get_identity_user() + lifecycle.add_extra("organization_ids", [oi.organization_id for oi in ois]) for oi in ois: organization_id = oi.organization_id @@ -372,17 +376,22 @@ def _resolve_seer_organization( except Organization.DoesNotExist: continue - installation = slack_request.integration.get_installation( - organization_id=organization_id - ) - assert isinstance(installation, SlackIntegration) - if organization.status != OrganizationStatus.ACTIVE: continue if not SlackExplorerEntrypoint.has_access(organization): continue + # When the user's identity is linked, verify they belong to this + # org. If not linked the downstream task will prompt to link. + if identity_user and not organization.has_access(identity_user): + continue + + installation = slack_request.integration.get_installation( + organization_id=organization_id + ) + assert isinstance(installation, SlackIntegration) + return organization_id, installation lifecycle.record_halt(SeerSlackHaltReason.NO_VALID_ORGANIZATION) return None @@ -394,7 +403,7 @@ def _handle_seer_mention( ) -> Response | None: """Shared handler for app mentions and DMs that trigger the Seer workflow. - Returns ``None`` when the feature flag is off (DM messages only), + Returns ``None`` when org resolution fails (DM messages only), allowing the caller to fall back to alternative handling. """ with MessagingInteractionEvent( @@ -411,8 +420,8 @@ def _handle_seer_mention( result = self._resolve_seer_organization(slack_request, lifecycle) if result is None: - # For DMs, return None on feature-flag halt so caller can - # fall back to the help message. + # For DMs, return None on org resolution failure so caller + # can fall back to the help message. if interaction_type == MessagingInteractionType.DM_MESSAGE: return None return self.respond() diff --git a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py index 6b63ac095696..cd5d725a17b8 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py @@ -2,7 +2,10 @@ from sentry.integrations.messaging.metrics import SeerSlackHaltReason from sentry.models.organization import Organization +from sentry.silo.base import SiloMode from sentry.testutils.asserts import assert_halt_metric +from sentry.testutils.silo import assume_test_silo_mode +from sentry.users.models.identity import Identity, IdentityStatus from . import SEER_EXPLORER_FEATURES, BaseEventTest @@ -116,3 +119,50 @@ def test_app_mention_org_not_found(self, mock_apply_async, mock_record): assert resp.status_code == 200 mock_apply_async.assert_not_called() assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_ORGANIZATION) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_app_mention_linked_user_not_org_member(self, mock_apply_async, mock_record): + """When the Slack user has a linked identity but is not a member of the + org with Seer access, the task should not be dispatched.""" + other_user = self.create_user() + with assume_test_silo_mode(SiloMode.CONTROL): + idp = self.create_identity_provider(type="slack", external_id="TXXXXXXX1") + Identity.objects.create( + external_id="U1234567890", + idp=idp, + user=other_user, + status=IdentityStatus.VALID, + scopes=[], + ) + + with self.feature(SEER_EXPLORER_FEATURES): + resp = self.post_webhook(event_data=APP_MENTION_EVENT) + + assert resp.status_code == 200 + mock_apply_async.assert_not_called() + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_ORGANIZATION) + + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_app_mention_linked_user_is_org_member(self, mock_apply_async): + """When the Slack user has a linked identity and IS a member of the + org with Seer access, the task should be dispatched normally.""" + with assume_test_silo_mode(SiloMode.CONTROL): + idp = self.create_identity_provider(type="slack", external_id="TXXXXXXX1") + Identity.objects.create( + external_id="U1234567890", + idp=idp, + user=self.user, + status=IdentityStatus.VALID, + scopes=[], + ) + + with self.feature(SEER_EXPLORER_FEATURES): + resp = self.post_webhook( + event_data=THREADED_APP_MENTION_EVENT, data=AUTHORIZATIONS_DATA + ) + + assert resp.status_code == 200 + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["organization_id"] == self.organization.id From e0a553249559599fbc04bb7ab50df716211720cd Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 13 Apr 2026 12:35:39 -0400 Subject: [PATCH 21/34] ref(slack): Remove duplicate MessageIMDmAgentTest superseded by test_direct_message.py --- .../slack/webhooks/events/test_message_im.py | 125 +----------------- 1 file changed, 2 insertions(+), 123 deletions(-) diff --git a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py index e2de23aa1a29..cfe2131c06e8 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py @@ -4,13 +4,12 @@ import pytest from slack_sdk.web import SlackResponse -from sentry.integrations.messaging.metrics import SeerSlackHaltReason from sentry.integrations.types import EventLifecycleOutcome -from sentry.testutils.asserts import assert_halt_metric, assert_slo_metric +from sentry.testutils.asserts import assert_slo_metric from sentry.testutils.cases import IntegratedApiTestCase from sentry.testutils.helpers import get_response_text -from . import SEER_EXPLORER_FEATURES, BaseEventTest +from . import BaseEventTest MESSAGE_IM_EVENT = """{ "type": "message", @@ -154,123 +153,3 @@ def test_user_message_im_no_text(self) -> None: resp = self.post_webhook(event_data=orjson.loads(MESSAGE_IM_EVENT_NO_TEXT)) assert resp.status_code == 200, resp.content assert not self.mock_post.called - - -MESSAGE_IM_DM_EVENT = { - "type": "message", - "channel": "DOxxxxxx", - "user": "Uxxxxxxx", - "text": "What is causing errors in my project?", - "ts": "123456789.9875", -} - -MESSAGE_IM_DM_EVENT_THREADED = { - **MESSAGE_IM_DM_EVENT, - "thread_ts": "123456789.0001", -} - -AUTHORIZATIONS_DATA = { - "authorizations": [{"user_id": "U0BOT", "is_bot": True}], -} - - -class MessageIMDmAgentTest(BaseEventTest): - """Tests for DM messages triggering the Seer Explorer agentic workflow.""" - - @pytest.fixture(autouse=True) - def mock_chat_postMessage(self): - with patch( - "slack_sdk.web.client.WebClient.chat_postMessage", - return_value=SlackResponse( - client=None, - http_verb="POST", - api_url="https://slack.com/api/chat.postMessage", - req_args={}, - data={"ok": True}, - headers={}, - status_code=200, - ), - ) as self.mock_post: - yield - - @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_dm_dispatches_task(self, mock_apply_async): - with self.feature(SEER_EXPLORER_FEATURES): - resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT, data=AUTHORIZATIONS_DATA) - - assert resp.status_code == 200 - mock_apply_async.assert_called_once() - kwargs = mock_apply_async.call_args[1]["kwargs"] - assert kwargs["integration_id"] == self.integration.id - assert kwargs["organization_id"] == self.organization.id - assert kwargs["channel_id"] == "DOxxxxxx" - assert kwargs["ts"] == "123456789.9875" - assert kwargs["thread_ts"] is None - assert kwargs["text"] == MESSAGE_IM_DM_EVENT["text"] - assert kwargs["slack_user_id"] == "Uxxxxxxx" - assert kwargs["bot_user_id"] == "U0BOT" - - @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_dm_threaded_dispatches_task(self, mock_apply_async): - with self.feature(SEER_EXPLORER_FEATURES): - resp = self.post_webhook( - event_data=MESSAGE_IM_DM_EVENT_THREADED, data=AUTHORIZATIONS_DATA - ) - - assert resp.status_code == 200 - mock_apply_async.assert_called_once() - kwargs = mock_apply_async.call_args[1]["kwargs"] - assert kwargs["ts"] == "123456789.9875" - assert kwargs["thread_ts"] == "123456789.0001" - - @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_dm_no_authorizations(self, mock_apply_async): - with self.feature(SEER_EXPLORER_FEATURES): - resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) - - assert resp.status_code == 200 - mock_apply_async.assert_called_once() - kwargs = mock_apply_async.call_args[1]["kwargs"] - assert kwargs["bot_user_id"] == "" - - @pytest.fixture(autouse=True) - def mock_set_thread_status(self): - with patch( - "sentry.integrations.slack.integration.SlackIntegration.set_thread_status", - ) as self.mock_status: - yield - - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_dm_feature_flag_disabled_falls_back_to_help(self, mock_apply_async, mock_record): - """When feature flag is off, DM should fall back to help message.""" - resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) - - assert resp.status_code == 200 - mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_ORGANIZATION) - - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_dm_no_integration(self, mock_apply_async, mock_record): - with patch( - "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", - return_value=[], - ): - with self.feature(SEER_EXPLORER_FEATURES): - resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) - - assert resp.status_code == 200 - mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_INTEGRATION) - - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_dm_empty_text(self, mock_apply_async, mock_record): - event_data = {**MESSAGE_IM_DM_EVENT, "text": ""} - with self.feature(SEER_EXPLORER_FEATURES): - resp = self.post_webhook(event_data=event_data) - - assert resp.status_code == 200 - mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, SeerSlackHaltReason.MISSING_EVENT_DATA) From be643183418e4b21b88abded28750cff63299f5d Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 13 Apr 2026 13:26:04 -0400 Subject: [PATCH 22/34] ref(slack): Move missing-scope footer logic from send_thread_update to renderer Move the isinstance(SeerExplorerResponse) check out of send_thread_update so it stays type-agnostic. The scope check + cache now lives in _get_missing_scope_settings_url called from on_explorer_update, and the renderer appends the footer based on the data field. --- .../platform/slack/renderers/seer.py | 3 + .../notifications/platform/templates/seer.py | 1 + .../seer/entrypoints/slack/entrypoint.py | 51 +++++- .../seer/entrypoints/slack/messaging.py | 26 +-- .../platform/slack/renderers/test_seer.py | 24 +++ .../seer/entrypoints/slack/test_slack.py | 148 ++++++------------ 6 files changed, 123 insertions(+), 130 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 0ba10878a9a1..c2f2d9d2a569 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -208,6 +208,9 @@ def _render_explorer_error(cls, data: SeerExplorerError) -> SlackRenderable: def _render_explorer_response(cls, data: SeerExplorerResponse) -> SlackRenderable: blocks: list[Block] = [MarkdownBlock(text=data.summary)] + 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 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..6905329f5f38 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,42 @@ 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 + + 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 + + org = Organization.objects.get(id=organization_id) + return org.absolute_url(f"/settings/{org.slug}/integrations/slack/") + + class SlackAutofixEntrypoint( SeerAutofixEntrypoint[SlackAutofixCachePayload], ): @@ -410,20 +448,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/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index 403945ace45d..050e779773df 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -21,7 +21,7 @@ ) from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.slack.renderers.seer import SeerSlackRenderer -from sentry.notifications.platform.templates.seer import SeerAutofixUpdate, SeerExplorerResponse +from sentry.notifications.platform.templates.seer import SeerAutofixUpdate from sentry.notifications.platform.types import NotificationData, NotificationProviderKey from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.entrypoints.metrics import ( @@ -31,7 +31,6 @@ from sentry.shared_integrations.exceptions import IntegrationConfigurationError, IntegrationError from sentry.tasks.base import instrumented_task from sentry.taskworker.namespaces import integrations_tasks -from sentry.utils.cache import cache from sentry.utils.registry import NoRegistrationExistsError if TYPE_CHECKING: @@ -40,16 +39,9 @@ from sentry.seer.entrypoints.slack.entrypoint import SlackThreadDetails -# TTL for the "missing scope footer already shown" cache key (1 hour). -MISSING_SCOPE_FOOTER_CACHE_TIMEOUT = 60 * 60 - logger = logging.getLogger(__name__) -def _missing_scope_footer_cache_key(integration_id: int, channel_id: str, thread_ts: str) -> str: - return f"seer:explorer:scope_footer:{integration_id}:{channel_id}:{thread_ts}" - - def send_thread_update( *, install: SlackIntegration, @@ -77,21 +69,6 @@ def send_thread_update( data=data, template=template_cls(), provider=provider ) - if isinstance(data, SeerExplorerResponse) and not install.has_history_scope( - thread["channel_id"] - ): - footer_key = _missing_scope_footer_cache_key( - install.model.id, thread["channel_id"], thread["thread_ts"] - ) - # cache.add is atomic: returns True only the first time (key didn't exist). - if cache.add(footer_key, True, timeout=MISSING_SCOPE_FOOTER_CACHE_TIMEOUT): - settings_url = install.organization.absolute_url( - f"/settings/{install.organization.slug}/integrations/slack/" - ) - renderable["blocks"].extend( - SeerSlackRenderer.render_missing_scope_footer(settings_url) - ) - try: if ephemeral_user_id: install.send_threaded_ephemeral_message( @@ -110,7 +87,6 @@ def send_thread_update( lifecycle.record_halt(halt_reason=e) except IntegrationError as e: lifecycle.record_failure(failure_reason=e) - # Retry, hopefully it's transient raise diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index c665dc3af1f1..770c0a4faa09 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -251,6 +251,30 @@ def test_render_explorer_response_with_summary(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 + assert blocks[-1].type == "context" + footer_text = blocks[-1].elements[0].text + assert "Reinstall" in footer_text + assert "Thread context is unavailable" 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 63956eb0417c..fe8de1f9832d 100644 --- a/tests/sentry/seer/entrypoints/slack/test_slack.py +++ b/tests/sentry/seer/entrypoints/slack/test_slack.py @@ -643,133 +643,75 @@ 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.integrations.slack.integration.SlackIntegration.send_threaded_message") + @patch("sentry.seer.entrypoints.slack.entrypoint.schedule_all_thread_updates") @patch( "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", return_value=False, ) - def test_send_thread_update_explorer_response_missing_scope_has_footer( - self, mock_has_history_scope, mock_send_threaded_message - ): - data = SeerExplorerResponse( - run_id=12345, - organization_id=self.organization.id, + 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, ) - install = self.integration.get_installation(organization_id=self.organization.id) - thread = SlackThreadDetails(thread_ts=self.thread_ts, channel_id=self.channel_id) - send_thread_update(install=install, thread=thread, data=data) + 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 - mock_send_threaded_message.assert_called_once() - renderable = mock_send_threaded_message.call_args.kwargs["renderable"] - last_block = renderable["blocks"][-1] - assert last_block.type == "context" - footer_text = last_block.elements[0].text - assert "Reinstall" in footer_text - assert "Thread context is unavailable" in footer_text - - @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_message") + @patch("sentry.seer.entrypoints.slack.entrypoint.schedule_all_thread_updates") @patch( "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", - return_value=False, + return_value=True, ) - def test_send_thread_update_explorer_response_missing_scope_no_footer_on_second_message( - self, mock_has_history_scope, mock_send_threaded_message + def test_on_explorer_update_no_missing_scope_url_when_has_scope( + self, mock_has_history_scope, mock_schedule ): - """Subsequent explorer responses in the same thread should not repeat the footer.""" - data = SeerExplorerResponse( - run_id=12345, - organization_id=self.organization.id, + """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, ) - install = self.integration.get_installation(organization_id=self.organization.id) - thread = SlackThreadDetails(thread_ts=self.thread_ts, channel_id=self.channel_id) - # First call — footer should be present - send_thread_update(install=install, thread=thread, data=data) - renderable_first = mock_send_threaded_message.call_args.kwargs["renderable"] - assert renderable_first["blocks"][-1].type == "context" - - mock_send_threaded_message.reset_mock() + call_data = mock_schedule.call_args.kwargs["data"] + assert isinstance(call_data, SeerExplorerResponse) + assert call_data.missing_scope_settings_url is None - # Second call in same thread — footer should be absent - send_thread_update(install=install, thread=thread, data=data) - renderable_second = mock_send_threaded_message.call_args.kwargs["renderable"] - for block in renderable_second["blocks"]: - assert block.type != "context" - - @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_message") + @patch("sentry.seer.entrypoints.slack.entrypoint.schedule_all_thread_updates") @patch( "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", return_value=False, ) - def test_send_thread_update_explorer_response_missing_scope_different_thread_gets_footer( - self, mock_has_history_scope, mock_send_threaded_message + def test_on_explorer_update_missing_scope_url_cached_per_thread( + self, mock_has_history_scope, mock_schedule ): - """Different threads should each get their own first-time footer.""" - data = SeerExplorerResponse( - run_id=12345, - organization_id=self.organization.id, - summary="Test summary", - ) - install = self.integration.get_installation(organization_id=self.organization.id) - thread_a = SlackThreadDetails(thread_ts=self.thread_ts, channel_id=self.channel_id) - thread_b = SlackThreadDetails(thread_ts="9999999999.999999", channel_id=self.channel_id) - - send_thread_update(install=install, thread=thread_a, data=data) - renderable_a = mock_send_threaded_message.call_args.kwargs["renderable"] - assert renderable_a["blocks"][-1].type == "context" - - mock_send_threaded_message.reset_mock() - - send_thread_update(install=install, thread=thread_b, data=data) - renderable_b = mock_send_threaded_message.call_args.kwargs["renderable"] - assert renderable_b["blocks"][-1].type == "context" + """Second call for the same thread should not set the URL (cached).""" + ep = self._get_entrypoint() + cache_payload = ep.create_explorer_cache_payload() - @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_message") - @patch( - "sentry.integrations.slack.integration.SlackIntegration.has_history_scope", - return_value=True, - ) - def test_send_thread_update_explorer_response_with_scope_no_footer( - self, mock_has_history_scope, mock_send_threaded_message - ): - data = SeerExplorerResponse( + SlackExplorerEntrypoint.on_explorer_update( + cache_payload=cache_payload, + summary="First response", run_id=12345, - organization_id=self.organization.id, - summary="Test summary", ) - install = self.integration.get_installation(organization_id=self.organization.id) - thread = SlackThreadDetails(thread_ts=self.thread_ts, channel_id=self.channel_id) + first_data = mock_schedule.call_args.kwargs["data"] + assert first_data.missing_scope_settings_url is not None - send_thread_update(install=install, thread=thread, data=data) + mock_schedule.reset_mock() - mock_send_threaded_message.assert_called_once() - renderable = mock_send_threaded_message.call_args.kwargs["renderable"] - for block in renderable["blocks"]: - assert block.type != "context" - - @patch("sentry.integrations.slack.integration.SlackIntegration.send_threaded_message") - def test_send_thread_update_autofix_no_footer_regardless(self, mock_send_threaded_message): - data = SeerAutofixUpdate( - run_id=MOCK_RUN_ID, - organization_id=self.organization.id, - project_id=self.project.id, - group_id=self.group.id, - current_point=AutofixStoppingPoint.ROOT_CAUSE, - group_link=self.group.get_absolute_url(), + SlackExplorerEntrypoint.on_explorer_update( + cache_payload=cache_payload, + summary="Second response", + run_id=12346, ) - install = self.integration.get_installation(organization_id=self.organization.id) - thread = SlackThreadDetails(thread_ts=self.thread_ts, channel_id=self.channel_id) - - send_thread_update(install=install, thread=thread, data=data) - - mock_send_threaded_message.assert_called_once() - renderable = mock_send_threaded_message.call_args.kwargs["renderable"] - for block in renderable["blocks"]: - if block.type == "context": - # Autofix context blocks (like debug run IDs) are fine; - # ensure none contain the missing-scope text. - for elem in block.elements: - assert "Thread context is unavailable" not in getattr(elem, "text", "") + second_data = mock_schedule.call_args.kwargs["data"] + assert second_data.missing_scope_settings_url is None From e8fe8555693e7a8f0230ce3ded5c54e3607e75b7 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 13 Apr 2026 13:32:14 -0400 Subject: [PATCH 23/34] ref(slack): Revert unintended whitespace changes in messaging.py --- src/sentry/seer/entrypoints/slack/messaging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index 050e779773df..4521eb421d8e 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -68,7 +68,6 @@ def send_thread_update( renderable = NotificationService.render_template( data=data, template=template_cls(), provider=provider ) - try: if ephemeral_user_id: install.send_threaded_ephemeral_message( @@ -87,6 +86,7 @@ def send_thread_update( lifecycle.record_halt(halt_reason=e) except IntegrationError as e: lifecycle.record_failure(failure_reason=e) + # Retry, hopefully it's transient raise From 475285a1e8348c92c1123cdc2282031ef57cbfba Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 13 Apr 2026 14:03:17 -0400 Subject: [PATCH 24/34] fix(slack): Add isinstance check for ContextBlock to fix mypy attr-defined error --- .../notifications/platform/slack/renderers/test_seer.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index 770c0a4faa09..51b4891beb18 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -262,8 +262,10 @@ def test_render_explorer_response_with_missing_scope_footer(self) -> None: blocks = renderable["blocks"] assert len(blocks) == 2 - assert blocks[-1].type == "context" - footer_text = blocks[-1].elements[0].text + last_block = blocks[-1] + assert last_block.type == "context" + assert isinstance(last_block, ContextBlock) + footer_text = last_block.elements[0].text assert "Reinstall" in footer_text assert "Thread context is unavailable" in footer_text From 5a2238f664a2b7924ca86a6f3eccbde511ad9050 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 13 Apr 2026 14:15:14 -0400 Subject: [PATCH 25/34] fix(slack): Narrow ContextBlock element type to fix mypy union-attr error --- .../notifications/platform/slack/renderers/test_seer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index 51b4891beb18..19473a753f0f 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, ) @@ -265,7 +266,9 @@ def test_render_explorer_response_with_missing_scope_footer(self) -> None: last_block = blocks[-1] assert last_block.type == "context" assert isinstance(last_block, ContextBlock) - footer_text = last_block.elements[0].text + first_element = last_block.elements[0] + assert isinstance(first_element, MarkdownTextObject) + footer_text = first_element.text assert "Reinstall" in footer_text assert "Thread context is unavailable" in footer_text From 8c39193281313a0936d7bde904283c88e08e35a9 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Mon, 13 Apr 2026 14:53:17 -0400 Subject: [PATCH 26/34] fix(slack): Move org lookup before cache.add in missing-scope footer helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If Organization.objects.get raised DoesNotExist, the cache key was already consumed by cache.add, preventing the footer on any future call for the thread. More critically, the unhandled exception propagated into on_explorer_update and prevented schedule_all_thread_updates from executing, blocking delivery of the Seer Explorer response entirely. Move the org lookup before cache.add so the key is never consumed on failure, and wrap it in try/except to return None gracefully — consistent with the integration lookup above. Co-Authored-By: Claude Opus 4.6 --- src/sentry/seer/entrypoints/slack/entrypoint.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sentry/seer/entrypoints/slack/entrypoint.py b/src/sentry/seer/entrypoints/slack/entrypoint.py index 6905329f5f38..25f81fe6d268 100644 --- a/src/sentry/seer/entrypoints/slack/entrypoint.py +++ b/src/sentry/seer/entrypoints/slack/entrypoint.py @@ -98,11 +98,15 @@ def _get_missing_scope_settings_url( 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 - org = Organization.objects.get(id=organization_id) return org.absolute_url(f"/settings/{org.slug}/integrations/slack/") From bf83740a7c8c22751a989a920c5b2fcdb4b5124b Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 14 Apr 2026 10:10:39 -0400 Subject: [PATCH 27/34] make the prompt to reinstall less technical --- src/sentry/notifications/platform/slack/renderers/seer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 74ec70bd7fd9..5662e450a44b 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -278,8 +278,8 @@ def render_footer_blocks( 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"_Thread context is unavailable because optional scopes are disabled. " - f"<{settings_url}|Reinstall Sentry's Slack app> to enable this feature._" + 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)])] From eb603bd53996014ea41284d3166a09a2e068b2c2 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 14 Apr 2026 10:23:56 -0400 Subject: [PATCH 28/34] remove early exit if all installed history scopes are available --- src/sentry/integrations/slack/integration.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 8199c53796be..d6e3eefa9733 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -273,9 +273,6 @@ def has_history_scope(self, channel_id: str) -> bool: if all(installed_history_scopes): return True - if not any(installed_history_scopes): - return False - conversation_data = self.get_conversations_info(channel_id=channel_id) channel_info = conversation_data.get("channel", {}) From d0b09514f65b24327cc7929e0ad4256a9d6a6e36 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 14 Apr 2026 10:27:57 -0400 Subject: [PATCH 29/34] remove is_channel check for private groups for backwards compatibility for old slack groups --- src/sentry/integrations/slack/integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index d6e3eefa9733..cc3ad4056db2 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -285,7 +285,7 @@ def has_history_scope(self, channel_id: str) -> bool: if is_im: return True - if is_channel and is_private: + if is_private: return SlackScope.GROUPS_HISTORY in installed_scope_set if is_channel: return SlackScope.CHANNELS_HISTORY in installed_scope_set From f9d824d2323aaaa109385405f53285cb0ba2edad Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 14 Apr 2026 10:36:29 -0400 Subject: [PATCH 30/34] ref(slack): Add warning log for unrecognized channel types in has_history_scope Log a warning when has_history_scope falls through to the default return without matching any known channel type, making it easier to diagnose unexpected conversation types from the Slack API. --- src/sentry/integrations/slack/integration.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index cc3ad4056db2..8770ca351a47 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -284,7 +284,6 @@ def has_history_scope(self, channel_id: str) -> bool: # 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: @@ -292,6 +291,10 @@ def has_history_scope(self, channel_id: str) -> bool: # 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( From 48ab44605fed0e4eea6ed04db6db80a0297f8201 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 14 Apr 2026 10:42:04 -0400 Subject: [PATCH 31/34] docs(slack): Expand has_history_scope docstring Clarify which scope applies to which channel type and that conversations.info is used to determine the channel type. --- src/sentry/integrations/slack/integration.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 8770ca351a47..9e7bc4b5b3f1 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -264,6 +264,11 @@ def has_scope(self, scope: SlackScope) -> bool: 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", [])) From 555218ea997ff1a07b2f77d7fbc84d5f653458cf Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 14 Apr 2026 10:55:36 -0400 Subject: [PATCH 32/34] fix(slack): Fix failing tests for has_history_scope changes Update assertion to match new missing-scope footer copy and add missing has_history_scope mock to test_on_explorer_update so it does not hit the Slack API. Co-Authored-By: Claude Opus 4.6 --- .../notifications/platform/slack/renderers/test_seer.py | 2 +- tests/sentry/seer/entrypoints/slack/test_slack.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index 40da710ad993..da4041f640e8 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -288,7 +288,7 @@ def test_render_explorer_response_with_missing_scope_footer(self) -> None: assert isinstance(first_element, MarkdownTextObject) footer_text = first_element.text assert "Reinstall" in footer_text - assert "Thread context is unavailable" 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") diff --git a/tests/sentry/seer/entrypoints/slack/test_slack.py b/tests/sentry/seer/entrypoints/slack/test_slack.py index fe8de1f9832d..7af462e7f674 100644 --- a/tests/sentry/seer/entrypoints/slack/test_slack.py +++ b/tests/sentry/seer/entrypoints/slack/test_slack.py @@ -583,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 From aedb432ad2a9eaa6e5164111beb29782755eecbc Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 14 Apr 2026 11:01:30 -0400 Subject: [PATCH 33/34] fix(slack): Default to True in has_history_scope on API failure A transient Slack conversations.info failure returned an empty dict, causing has_history_scope to incorrectly report missing scopes and show a misleading "reinstall" footer. Return True on the fall-through path so API errors don't trigger false prompts. Co-Authored-By: Claude Opus 4.6 --- src/sentry/integrations/slack/integration.py | 5 +++-- tests/sentry/integrations/slack/test_integration.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 9e7bc4b5b3f1..50b577276800 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -295,12 +295,13 @@ def has_history_scope(self, channel_id: str) -> bool: 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). + # an API error or an unrecognized conversation type). Default to + # True to avoid showing a misleading "reinstall" prompt. _logger.warning( "slack.has_history_scope.unrecognized_channel_type", extra={"channel_id": channel_id, "channel_info": channel_info}, ) - return False + return True def get_conversations_info( self, diff --git a/tests/sentry/integrations/slack/test_integration.py b/tests/sentry/integrations/slack/test_integration.py index 801320f57fe7..28575958f7ff 100644 --- a/tests/sentry/integrations/slack/test_integration.py +++ b/tests/sentry/integrations/slack/test_integration.py @@ -717,6 +717,16 @@ def test_has_history_scope_private_channel_missing( ) assert self.installation.has_history_scope("G1234567890") is False + @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info") + def test_has_history_scope_api_error_defaults_to_true( + self, mock_conversations_info: MagicMock + ) -> None: + self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY] + mock_conversations_info.side_effect = SlackApiError( + message="error", response=MagicMock(status_code=500) + ) + assert self.installation.has_history_scope("C1234567890") is True + @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( From 4911b68d9480a46e1934e3abe9315abbe7d27d43 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Tue, 14 Apr 2026 11:03:19 -0400 Subject: [PATCH 34/34] Revert "fix(slack): Default to True in has_history_scope on API failure" This reverts commit aedb432ad2a9eaa6e5164111beb29782755eecbc. --- src/sentry/integrations/slack/integration.py | 5 ++--- tests/sentry/integrations/slack/test_integration.py | 10 ---------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 50b577276800..9e7bc4b5b3f1 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -295,13 +295,12 @@ def has_history_scope(self, channel_id: str) -> bool: 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). Default to - # True to avoid showing a misleading "reinstall" prompt. + # 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 True + return False def get_conversations_info( self, diff --git a/tests/sentry/integrations/slack/test_integration.py b/tests/sentry/integrations/slack/test_integration.py index 28575958f7ff..801320f57fe7 100644 --- a/tests/sentry/integrations/slack/test_integration.py +++ b/tests/sentry/integrations/slack/test_integration.py @@ -717,16 +717,6 @@ def test_has_history_scope_private_channel_missing( ) assert self.installation.has_history_scope("G1234567890") is False - @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info") - def test_has_history_scope_api_error_defaults_to_true( - self, mock_conversations_info: MagicMock - ) -> None: - self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY] - mock_conversations_info.side_effect = SlackApiError( - message="error", response=MagicMock(status_code=500) - ) - assert self.installation.has_history_scope("C1234567890") is True - @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(