From 693fb1c5d2436b632f4d93be77de2491babf5de4 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Tue, 7 Apr 2026 11:21:41 -0400 Subject: [PATCH 01/14] =?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 a055eb9562a382..82a1276715f5f6 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 d43eaf046bc2d3..413da97e5de9de 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 afb5b9c7c0089c..73dfbdcd930bf8 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 02/14] =?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 82a1276715f5f6..0aae193ef8b051 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 0108de382649da..65370a710934df 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 413da97e5de9de..d298c46168b604 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 00000000000000..66380a26dc869a --- /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 03/14] 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 65370a710934df..ca16e0012efee5 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 2d6293003c449c..b3e63eb22db492 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 04/14] 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 0aae193ef8b051..ea2fefda8eebf5 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 d298c46168b604..7475022c663ece 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 a04cdc769cea21..ed37dcece3466e 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 66380a26dc869a..e991634c59b2a4 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 73dfbdcd930bf8..f5b5afc78ae9b6 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 05/14] 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 ea2fefda8eebf5..c261cd24d6e598 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 ca16e0012efee5..fcd706fbe93fbd 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 7475022c663ece..55ac9de56b0f17 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 1e84f2b031a0f9..04590fd9e64d86 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 660c17593e1594..48b2b84eeb5d38 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 ed37dcece3466e..6b63ac09569692 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 e991634c59b2a4..d76c8f0c36a634 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 f5b5afc78ae9b6..d35838b1f33b70 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 3df290364832cf..0940f0ecd6ffe1 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 06/14] 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 55ac9de56b0f17..02d9a6d8a7bc5a 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 6b63ac09569692..cd5d725a17b88b 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 08e693ae9ba48807c509136a21471da452c8b8b8 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Fri, 10 Apr 2026 11:57:21 -0400 Subject: [PATCH 07/14] feat(slack): Require identity linking before Seer processes Slack requests Move identity verification to the start of org resolution so unlinked users receive an ephemeral prompt with a link button instead of silently failing downstream. Introduce SeerResolutionResult TypedDict to replace the tuple-or-None return pattern, add a static ephemeral message helper on SlackIntegration, and simplify DM dispatch by routing assistant-scoped integrations through the mention handler. Co-Authored-By: Claude Opus 4.6 --- src/sentry/integrations/messaging/metrics.py | 1 + src/sentry/integrations/slack/integration.py | 30 +++++ .../slack/message_builder/types.py | 2 + .../integrations/slack/requests/event.py | 8 ++ .../integrations/slack/webhooks/action.py | 1 + .../integrations/slack/webhooks/event.py | 118 ++++++++++-------- .../seer/entrypoints/slack/messaging.py | 43 +++++++ 7 files changed, 152 insertions(+), 51 deletions(-) diff --git a/src/sentry/integrations/messaging/metrics.py b/src/sentry/integrations/messaging/metrics.py index c261cd24d6e598..3de6bd2e711cd9 100644 --- a/src/sentry/integrations/messaging/metrics.py +++ b/src/sentry/integrations/messaging/metrics.py @@ -119,4 +119,5 @@ class SeerSlackHaltReason(StrEnum): NO_VALID_INTEGRATION = "no_valid_integration" NO_VALID_ORGANIZATION = "no_valid_organization" + IDENTITY_NOT_LINKED = "identity_not_linked" MISSING_EVENT_DATA = "missing_event_data" diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index fcd706fbe93fbd..a85555662c2564 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -197,6 +197,36 @@ def send_threaded_ephemeral_message( except SlackApiError as e: translate_slack_api_error(e) + @staticmethod + def send_threaded_ephemeral_message_static( + *, + integration_id: int, + channel_id: str, + thread_ts: str, + renderable: SlackRenderable, + slack_user_id: str, + ) -> None: + """ + In most cases, you should use the instance method instead, so an organization is associated + with the message. + + In rare cases where we cannot infer an organization, but need to invoke a Slack API, use this. + For example, when linking an Slack identity to a Sentry user, there could be multiple organizations + attached to the Slack Workspace. We cannot infer which the user may link to. + """ + client = SlackSdkClient(integration_id=integration_id) + try: + client.chat_postEphemeral( + channel=channel_id, + blocks=renderable["blocks"] if len(renderable["blocks"]) > 0 else None, + attachments=renderable.get("attachments"), + text=renderable["text"], + thread_ts=thread_ts, + user=slack_user_id, + ) + except SlackApiError as e: + translate_slack_api_error(e) + def update_message( self, *, diff --git a/src/sentry/integrations/slack/message_builder/types.py b/src/sentry/integrations/slack/message_builder/types.py index a3a17857033881..44b14a6b17f0be 100644 --- a/src/sentry/integrations/slack/message_builder/types.py +++ b/src/sentry/integrations/slack/message_builder/types.py @@ -20,6 +20,8 @@ class SlackAction(StrEnum): RESOLVE_DIALOG = "resolve_dialog" ARCHIVE_DIALOG = "archive_dialog" ASSIGN = "assign" + # Older, /sentry link workflows send a hyperlink. Newer ones use a button block. + LINK_IDENTITY = "link_identity" SEER_AUTOFIX_START = "seer_autofix_start" SEER_AUTOFIX_VIEW_IN_SENTRY = "seer_autofix_view_in_sentry" SEER_AUTOFIX_VIEW_PR = "seer_autofix_view_pr" diff --git a/src/sentry/integrations/slack/requests/event.py b/src/sentry/integrations/slack/requests/event.py index 7c0c06fb1bd2cd..dd812abffc5b0d 100644 --- a/src/sentry/integrations/slack/requests/event.py +++ b/src/sentry/integrations/slack/requests/event.py @@ -6,6 +6,7 @@ from sentry.integrations.slack.requests.base import SlackDMRequest, SlackRequestError from sentry.integrations.slack.unfurl.handlers import match_link from sentry.integrations.slack.unfurl.types import LinkType +from sentry.integrations.slack.utils.constants import SlackScope COMMANDS = ["link", "unlink", "link team", "unlink team"] @@ -64,8 +65,15 @@ def channel_id(self) -> str: @property def user_id(self) -> str: + # The location for the Slack User ID in `assistant_thread_started` events differs from the rest + if self.dm_data.get("type") == "assistant_thread_started": + return self.dm_data.get("assistant_thread", {}).get("user_id", "") return self.dm_data.get("user", "") + @property + def is_assistant(self): + return SlackScope.ASSISTANT_WRITE in self.integration.metadata.get("scopes", []) + @property def links(self) -> list[str]: return [link["url"] for link in self.dm_data.get("links", []) if "url" in link] diff --git a/src/sentry/integrations/slack/webhooks/action.py b/src/sentry/integrations/slack/webhooks/action.py index 259759504f5187..b09fdb4af0e316 100644 --- a/src/sentry/integrations/slack/webhooks/action.py +++ b/src/sentry/integrations/slack/webhooks/action.py @@ -709,6 +709,7 @@ def post(self, request: Request) -> Response: if action_id in { SlackAction.SEER_AUTOFIX_VIEW_IN_SENTRY.value, SlackAction.SEER_AUTOFIX_VIEW_PR.value, + SlackAction.LINK_IDENTITY.value, }: return self.respond() diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index 02d9a6d8a7bc5a..4e83166cc0f730 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -3,7 +3,7 @@ import logging from collections import defaultdict from collections.abc import Mapping -from typing import Any +from typing import Any, TypedDict import orjson import sentry_sdk @@ -38,6 +38,7 @@ 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.messaging import send_identity_link_prompt from sentry.seer.entrypoints.slack.tasks import process_mention_for_slack from .base import SlackDMEndpoint @@ -58,6 +59,12 @@ SLACK_PROVIDERS = [IntegrationProviderSlug.SLACK, IntegrationProviderSlug.SLACK_STAGING] +class SeerResolutionResult(TypedDict): + organization_id: int | None + installation: SlackIntegration | None + error_reason: SeerSlackHaltReason | None + + @all_silo_endpoint # Only challenge verification is handled at control class SlackEventEndpoint(SlackDMEndpoint): owner = ApiOwner.ECOSYSTEM @@ -343,32 +350,45 @@ def on_link_shared(self, request: Request, slack_request: SlackDMRequest) -> boo return True - def _resolve_seer_organization( - self, slack_request, lifecycle - ) -> tuple[int, SlackIntegration] | None: - """Resolve and validate an organization for a Seer Slack event. + def _resolve_seer_organization(self, slack_request: SlackDMRequest) -> SeerResolutionResult: + """ + Resolve and validate an organization/user for a Seer Slack event. - Returns ``(organization_id, installation)`` or ``None`` when the - event should be halted (the halt reason is already recorded). + If the initiating user is not linked, we will reply with a prompt to link their identity. - We also check that the requesting user is a member of the organization that Seer is accessing. + Then we search for an active, organization with Seer Explorer access. If the user does not + belong to any matched organization, their request will be dropped. - 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. + Note: There is a limitation here of only grabbing the first organization belonging to the user + with access to Seer. If a Slack installation corresponds to multiple organizations with Seer + access, this will not work as expected. This will be revisited. """ + result: SeerResolutionResult = { + "organization_id": None, + "installation": None, + "error_reason": None, + } + + identity_user = slack_request.get_identity_user() + if not identity_user: + result["error_reason"] = SeerSlackHaltReason.IDENTITY_NOT_LINKED + send_identity_link_prompt( + integration=slack_request.integration, + channel_id=slack_request.channel_id, + thread_ts=slack_request.thread_ts, + slack_user_id=slack_request.user_id, + ) + return result + ois = integration_service.get_organization_integrations( integration_id=slack_request.integration.id, status=ObjectStatus.ACTIVE, providers=SLACK_PROVIDERS, ) if not ois: - lifecycle.record_halt(SeerSlackHaltReason.NO_VALID_INTEGRATION) - return None + result["error_reason"] = SeerSlackHaltReason.NO_VALID_INTEGRATION + return result - 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 try: @@ -382,8 +402,6 @@ def _resolve_seer_organization( 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 @@ -392,20 +410,19 @@ def _resolve_seer_organization( ) assert isinstance(installation, SlackIntegration) - return organization_id, installation - lifecycle.record_halt(SeerSlackHaltReason.NO_VALID_ORGANIZATION) - return None + result["organization_id"] = organization_id + result["installation"] = installation + return result + + result["error_reason"] = SeerSlackHaltReason.NO_VALID_ORGANIZATION + return result 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. - - Returns ``None`` when org resolution fails (DM messages only), - allowing the caller to fall back to alternative handling. - """ + ) -> Response: + """Shared handler for app mentions and DMs that trigger the Seer workflow.""" with MessagingInteractionEvent( interaction_type=interaction_type, spec=SlackMessagingSpec(), @@ -418,14 +435,16 @@ def _handle_seer_mention( } ) - result = self._resolve_seer_organization(slack_request, lifecycle) - if result is None: - # 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 + result = self._resolve_seer_organization(slack_request) + if result["error_reason"]: + lifecycle.record_halt(result["error_reason"]) return self.respond() - organization_id, installation = result + + if not result["organization_id"] or not result["installation"]: + return self.respond() + + organization_id = result["organization_id"] + installation = result["installation"] channel_id = data.get("channel") text = data.get("text") @@ -482,14 +501,7 @@ 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) - 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.""" - return self._handle_seer_mention(slack_request, MessagingInteractionType.DM_MESSAGE) + return self._handle_seer_mention(slack_request, MessagingInteractionType.APP_MENTION) def on_assistant_thread_started(self, slack_request: SlackDMRequest) -> Response: """Handle assistant_thread_started events by sending suggested prompts.""" @@ -500,11 +512,15 @@ def on_assistant_thread_started(self, slack_request: SlackDMRequest) -> Response data = slack_request.data.get("event", {}) assistant_thread = data.get("assistant_thread", {}) lifecycle.add_extra("integration_id", slack_request.integration.id) + result = self._resolve_seer_organization(slack_request) + if result["error_reason"]: + lifecycle.record_halt(result["error_reason"]) + return self.respond() - result = self._resolve_seer_organization(slack_request, lifecycle) - if result is None: + if not result["installation"]: return self.respond() - _organization_id, installation = result + + installation = result["installation"] channel_id = assistant_thread.get("channel_id") thread_ts = assistant_thread.get("thread_ts") @@ -540,8 +556,8 @@ def on_assistant_thread_started(self, slack_request: SlackDMRequest) -> Response "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?", + "title": "Find performance bottlenecks", + "message": "What are the slowest endpoints or pages in my projects?", }, ], ) @@ -587,12 +603,12 @@ def post(self, request: Request) -> Response: command, _ = slack_request.get_command_and_args() resp: Response | None - if command in COMMANDS: + if slack_request.is_assistant: + resp = self.on_app_mention(slack_request) + elif 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) or self.on_message(request, slack_request) - + resp = self.on_message(request, slack_request) if resp: return resp diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index 5ed6c48d2c1942..d60514c7cd9b61 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -5,10 +5,12 @@ from typing import TYPE_CHECKING, Any from pydantic import ValidationError +from slack_sdk.models.blocks import ActionsBlock, ButtonElement, LinkButtonElement, MarkdownBlock from slack_sdk.models.blocks.blocks import Block from taskbroker_client.retry import Retry from sentry.constants import ObjectStatus +from sentry.integrations.services.integration.model import RpcIntegration from sentry.integrations.services.integration.service import integration_service from sentry.notifications.platform.registry import provider_registry, template_registry from sentry.notifications.platform.service import ( @@ -258,5 +260,46 @@ def remove_all_buttons_transformer(_elem: dict[str, Any]) -> dict[str, Any] | No install.update_message( channel_id=channel_id, message_ts=message_ts, renderable=renderable ) + except (IntegrationError, IntegrationConfigurationError) as e: lifecycle.record_halt(halt_reason=e) + + +def send_identity_link_prompt( + *, integration: RpcIntegration, channel_id: str, thread_ts: str, slack_user_id: str +) -> None: + from sentry.integrations.slack.integration import SlackIntegration + from sentry.integrations.slack.message_builder.types import SlackAction + from sentry.integrations.slack.views.link_identity import build_linking_url + + associate_url = build_linking_url( + integration=integration, + slack_id=slack_user_id, + channel_id=channel_id, + response_url=None, + ) + message = "Link your Slack account to Sentry — so bugs find you, not the other way around." + renderable = SlackRenderable( + blocks=[ + MarkdownBlock(text=message), + ActionsBlock( + elements=[ + ButtonElement(text="Cancel", value="ignore"), + LinkButtonElement( + text="Link", + url=associate_url, + style="primary", + action_id=SlackAction.LINK_IDENTITY.value, + ), + ] + ), + ], + text=message, + ) + SlackIntegration.send_threaded_ephemeral_message_static( + integration_id=integration.id, + channel_id=channel_id, + thread_ts=thread_ts, + renderable=renderable, + slack_user_id=slack_user_id, + ) From 65c57c3841f9c9ec24fcd60abd70c2bb5815dbb6 Mon Sep 17 00:00:00 2001 From: Alex Sohn Date: Fri, 10 Apr 2026 12:22:04 -0400 Subject: [PATCH 08/14] feat(seer-slack): Refresh Slack thread status to prevent 2-min auto-clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack's assistant_threads.setStatus auto-clears after 2 minutes of no message. When Seer processing takes longer, the "Thinking..." indicator silently disappears while the user is still waiting for a response. Add a self-chaining Celery task that re-sends the thread status every 90 seconds. After trigger_explorer() returns a run_id, the first refresh is scheduled. Each refresh checks the SeerOperatorExplorerCache — if the cache entry still exists, the run is in progress and the status is refreshed. When the completion hook fires, it deletes the cache before sending the reply, so subsequent refreshes become no-ops. The refresh chain is capped at 6 iterations (~11 min total coverage). The completion hook now also deletes the explorer cache entry after use so the refresh task can detect completion. --- .../integrations/slack/webhooks/event.py | 4 +- src/sentry/seer/entrypoints/cache.py | 5 ++ src/sentry/seer/entrypoints/operator.py | 5 ++ src/sentry/seer/entrypoints/slack/tasks.py | 90 ++++++++++++++++++- .../seer/entrypoints/slack/test_tasks.py | 3 + 5 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index 02d9a6d8a7bc5a..8dee2912085eb8 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -45,7 +45,7 @@ _logger = logging.getLogger(__name__) -_SEER_LOADING_MESSAGES = [ +SEER_LOADING_MESSAGES = [ "Digging through your errors...", "Sifting through stack traces...", "Blaming the right code...", @@ -451,7 +451,7 @@ def _handle_seer_mention( channel_id=channel_id, thread_ts=thread_ts or ts, status="Thinking...", - loading_messages=_SEER_LOADING_MESSAGES, + loading_messages=SEER_LOADING_MESSAGES, ) except Exception: _logger.exception( diff --git a/src/sentry/seer/entrypoints/cache.py b/src/sentry/seer/entrypoints/cache.py index 5dd3e86131408b..f7a66cc0f69d68 100644 --- a/src/sentry/seer/entrypoints/cache.py +++ b/src/sentry/seer/entrypoints/cache.py @@ -233,3 +233,8 @@ def get(cls, *, entrypoint_key: str, run_id: int) -> CachePayloadT | None: lifecycle.record_halt(halt_reason=CacheHaltReason.CACHE_MISS) return None return cache_payload + + @classmethod + def delete(cls, *, entrypoint_key: str, run_id: int) -> None: + cache_key = cls._get_cache_key(entrypoint_key=entrypoint_key, run_id=run_id) + cache.delete(cache_key) diff --git a/src/sentry/seer/entrypoints/operator.py b/src/sentry/seer/entrypoints/operator.py index 0bcf26d79213b9..e2e8ae050d893f 100644 --- a/src/sentry/seer/entrypoints/operator.py +++ b/src/sentry/seer/entrypoints/operator.py @@ -819,6 +819,11 @@ def execute(cls, organization: Organization, run_id: int) -> None: lifecycle.record_failure(failure_reason="org_mismatch") return + SeerOperatorExplorerCache.delete( + entrypoint_key=str(entrypoint_key), + run_id=run_id, + ) + with SeerOperatorEventLifecycleMetric( interaction_type=SeerOperatorInteractionType.ENTRYPOINT_ON_EXPLORER_UPDATE, entrypoint_key=str(entrypoint_key), diff --git a/src/sentry/seer/entrypoints/slack/tasks.py b/src/sentry/seer/entrypoints/slack/tasks.py index fd30fd0b226521..4fd88c5c862d38 100644 --- a/src/sentry/seer/entrypoints/slack/tasks.py +++ b/src/sentry/seer/entrypoints/slack/tasks.py @@ -5,11 +5,14 @@ from slack_sdk.models.blocks import ActionsBlock, ButtonElement, LinkButtonElement, MarkdownBlock from taskbroker_client.retry import Retry +from sentry.constants import ObjectStatus from sentry.identity.services.identity import identity_service +from sentry.integrations.services.integration import integration_service from sentry.integrations.services.integration.model import RpcIntegration from sentry.integrations.slack.views.link_identity import build_linking_url from sentry.models.organization import Organization from sentry.notifications.platform.slack.provider import SlackRenderable +from sentry.seer.entrypoints.cache import SeerOperatorExplorerCache from sentry.seer.entrypoints.metrics import ( SlackEntrypointEventLifecycleMetric, SlackEntrypointInteractionType, @@ -21,6 +24,7 @@ ProcessMentionFailureReason, ProcessMentionHaltReason, ) +from sentry.seer.entrypoints.types import SeerEntrypointKey from sentry.tasks.base import instrumented_task from sentry.taskworker.namespaces import integrations_tasks from sentry.users.services.user import RpcUser @@ -28,6 +32,15 @@ logger = logging.getLogger(__name__) +# How often to refresh the Slack thread status (seconds). +# Slack auto-clears the status after 2 minutes of no message, so we refresh +# before that window expires. +THREAD_STATUS_REFRESH_INTERVAL_SECS = 90 + +# Maximum number of status refreshes to schedule. 6 refreshes at 90s intervals +# gives ~9 min of refresh coverage, plus the initial 2-min window = ~11 min total. +MAX_STATUS_REFRESHES = 6 + @instrumented_task( name="sentry.seer.entrypoints.slack.process_mention_for_slack", @@ -140,7 +153,7 @@ def process_mention_for_slack( thread_context = build_thread_context(messages) or None operator = SeerExplorerOperator(entrypoint=entrypoint) - operator.trigger_explorer( + run_id = operator.trigger_explorer( organization=organization, user=user, prompt=prompt, @@ -149,6 +162,18 @@ def process_mention_for_slack( category_value=f"{channel_id}:{entrypoint.thread_ts}", ) + if run_id is not None: + refresh_slack_thread_status.apply_async( + kwargs={ + "integration_id": integration_id, + "organization_id": organization_id, + "channel_id": channel_id, + "thread_ts": entrypoint.thread_ts, + "run_id": run_id, + }, + countdown=THREAD_STATUS_REFRESH_INTERVAL_SECS, + ) + def _resolve_user( *, @@ -231,3 +256,66 @@ def _send_not_org_member_message( renderable=renderable, thread_ts=thread_ts, ) + + +@instrumented_task( + name="sentry.seer.entrypoints.slack.refresh_slack_thread_status", + namespace=integrations_tasks, + processing_deadline_duration=30, + retry=None, +) +def refresh_slack_thread_status( + *, + integration_id: int, + organization_id: int, + channel_id: str, + thread_ts: str, + run_id: int, + remaining_refreshes: int = MAX_STATUS_REFRESHES, +) -> None: + """ + Refresh the Slack thread status indicator to prevent it from auto-clearing. + + Slack's assistant_threads.setStatus auto-clears after 2 minutes of no message. + This task re-sends the status and chains another delayed task until the Explorer + run completes (explorer cache deleted) or the refresh budget is exhausted. + """ + from sentry.integrations.slack.integration import SlackIntegration + from sentry.integrations.slack.webhooks.event import SEER_LOADING_MESSAGES + from sentry.seer.entrypoints.slack.entrypoint import SlackExplorerCachePayload + + cache_payload = SeerOperatorExplorerCache[SlackExplorerCachePayload].get( + entrypoint_key=str(SeerEntrypointKey.SLACK), + run_id=run_id, + ) + if not cache_payload: + return + + integration = integration_service.get_integration( + integration_id=integration_id, + organization_id=organization_id, + status=ObjectStatus.ACTIVE, + ) + if not integration: + return + + install = SlackIntegration(model=integration, organization_id=organization_id) + install.set_thread_status( + channel_id=channel_id, + thread_ts=thread_ts, + status="Thinking...", + loading_messages=SEER_LOADING_MESSAGES, + ) + + if remaining_refreshes > 1: + refresh_slack_thread_status.apply_async( + kwargs={ + "integration_id": integration_id, + "organization_id": organization_id, + "channel_id": channel_id, + "thread_ts": thread_ts, + "run_id": run_id, + "remaining_refreshes": remaining_refreshes - 1, + }, + countdown=THREAD_STATUS_REFRESH_INTERVAL_SECS, + ) diff --git a/tests/sentry/seer/entrypoints/slack/test_tasks.py b/tests/sentry/seer/entrypoints/slack/test_tasks.py index edd0a0b070d898..4f12341619acff 100644 --- a/tests/sentry/seer/entrypoints/slack/test_tasks.py +++ b/tests/sentry/seer/entrypoints/slack/test_tasks.py @@ -42,6 +42,7 @@ def test_happy_path(self, mock_resolve_user, mock_explorer_cls, mock_operator_cl mock_explorer_cls.return_value = mock_entrypoint mock_operator = MagicMock() + mock_operator.trigger_explorer.return_value = 42 mock_operator_cls.return_value = mock_operator self._run_task() @@ -170,6 +171,7 @@ def test_with_thread_context(self, mock_resolve_user, mock_explorer_cls, mock_op mock_explorer_cls.return_value = mock_entrypoint mock_operator = MagicMock() + mock_operator.trigger_explorer.return_value = 42 mock_operator_cls.return_value = mock_operator self._run_task(thread_ts="1234567890.000001") @@ -195,6 +197,7 @@ def test_without_thread_context(self, mock_resolve_user, mock_explorer_cls, mock mock_explorer_cls.return_value = mock_entrypoint mock_operator = MagicMock() + mock_operator.trigger_explorer.return_value = 42 mock_operator_cls.return_value = mock_operator self._run_task(thread_ts=None) From d9bca7a9a3b514ce9875799d27f11285a17b07b2 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Fri, 10 Apr 2026 12:24:55 -0400 Subject: [PATCH 09/14] ref(slack): Tighten Seer handler types and improve identity link UX Narrow handler signatures from SlackDMRequest to SlackEventRequest for better type safety. Extract channel_id, user_id, and thread_ts properties on SlackEventRequest to handle assistant_thread_started event structure differences in one place. Rename on_app_mention to on_prompt and is_assistant to has_assistant_scope for clarity. Add contextual messaging to the identity link prompt based on event type. Co-Authored-By: Claude Opus 4.6 --- .../integrations/slack/requests/event.py | 17 ++++++++++++++--- .../integrations/slack/webhooks/event.py | 19 ++++++++++--------- .../seer/entrypoints/slack/messaging.py | 13 +++++++++++-- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/sentry/integrations/slack/requests/event.py b/src/sentry/integrations/slack/requests/event.py index dd812abffc5b0d..b6069d42ea4e66 100644 --- a/src/sentry/integrations/slack/requests/event.py +++ b/src/sentry/integrations/slack/requests/event.py @@ -61,19 +61,30 @@ def dm_data(self) -> Mapping[str, Any]: @property def channel_id(self) -> str: + if self.is_assistant_thread_event: + return self.dm_data.get("assistant_thread", {}).get("channel_id", "") return self.dm_data.get("channel", "") @property def user_id(self) -> str: - # The location for the Slack User ID in `assistant_thread_started` events differs from the rest - if self.dm_data.get("type") == "assistant_thread_started": + if self.is_assistant_thread_event: return self.dm_data.get("assistant_thread", {}).get("user_id", "") return self.dm_data.get("user", "") @property - def is_assistant(self): + def thread_ts(self) -> str: + if self.is_assistant_thread_event: + return self.dm_data.get("assistant_thread", {}).get("thread_ts", "") + return self.dm_data.get("ts", "") + + @property + def has_assistant_scope(self): return SlackScope.ASSISTANT_WRITE in self.integration.metadata.get("scopes", []) + @property + def is_assistant_thread_event(self): + return self.dm_data.get("type") == "assistant_thread_started" + @property def links(self) -> list[str]: return [link["url"] for link in self.dm_data.get("links", []) if "url" in link] diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index 4e83166cc0f730..fa2ec0cc22f57d 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -350,7 +350,7 @@ def on_link_shared(self, request: Request, slack_request: SlackDMRequest) -> boo return True - def _resolve_seer_organization(self, slack_request: SlackDMRequest) -> SeerResolutionResult: + def _resolve_seer_organization(self, slack_request: SlackEventRequest) -> SeerResolutionResult: """ Resolve and validate an organization/user for a Seer Slack event. @@ -377,6 +377,7 @@ def _resolve_seer_organization(self, slack_request: SlackDMRequest) -> SeerResol channel_id=slack_request.channel_id, thread_ts=slack_request.thread_ts, slack_user_id=slack_request.user_id, + is_welcome_message=slack_request.is_assistant_thread_event, ) return result @@ -419,7 +420,7 @@ def _resolve_seer_organization(self, slack_request: SlackDMRequest) -> SeerResol def _handle_seer_mention( self, - slack_request: SlackDMRequest, + slack_request: SlackEventRequest, interaction_type: MessagingInteractionType, ) -> Response: """Shared handler for app mentions and DMs that trigger the Seer workflow.""" @@ -499,11 +500,11 @@ def _handle_seer_mention( ) return self.respond() - def on_app_mention(self, slack_request: SlackDMRequest) -> Response: - """Handle @mention events for Seer Explorer.""" + def on_prompt(self, slack_request: SlackEventRequest) -> Response: + """Handle @mention and DM events for Seer Explorer.""" return self._handle_seer_mention(slack_request, MessagingInteractionType.APP_MENTION) - def on_assistant_thread_started(self, slack_request: SlackDMRequest) -> Response: + def on_assistant_thread_started(self, slack_request: SlackEventRequest) -> Response: """Handle assistant_thread_started events by sending suggested prompts.""" with MessagingInteractionEvent( interaction_type=MessagingInteractionType.ASSISTANT_THREAD_STARTED, @@ -576,7 +577,7 @@ def on_assistant_thread_started(self, slack_request: SlackDMRequest) -> Response # TODO(dcramer): implement app_uninstalled and tokens_revoked def post(self, request: Request) -> Response: try: - slack_request = self.slack_request_class(request) + slack_request: SlackEventRequest = self.slack_request_class(request) slack_request.validate() except SlackRequestError as e: return self.respond(status=e.status) @@ -591,7 +592,7 @@ def post(self, request: Request) -> Response: return self.respond() if slack_request.type == "app_mention": - return self.on_app_mention(slack_request) + return self.on_prompt(slack_request) if slack_request.type == "assistant_thread_started": return self.on_assistant_thread_started(slack_request) @@ -603,8 +604,8 @@ def post(self, request: Request) -> Response: command, _ = slack_request.get_command_and_args() resp: Response | None - if slack_request.is_assistant: - resp = self.on_app_mention(slack_request) + if slack_request.has_assistant_scope: + resp = self.on_prompt(slack_request) elif command in COMMANDS: resp = super().post_dispatcher(slack_request) else: diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index d60514c7cd9b61..5b46a9d8ea8767 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -266,7 +266,12 @@ def remove_all_buttons_transformer(_elem: dict[str, Any]) -> dict[str, Any] | No def send_identity_link_prompt( - *, integration: RpcIntegration, channel_id: str, thread_ts: str, slack_user_id: str + *, + integration: RpcIntegration, + channel_id: str, + thread_ts: str, + slack_user_id: str, + is_welcome_message: bool = False, ) -> None: from sentry.integrations.slack.integration import SlackIntegration from sentry.integrations.slack.message_builder.types import SlackAction @@ -278,7 +283,11 @@ def send_identity_link_prompt( channel_id=channel_id, response_url=None, ) - message = "Link your Slack account to Sentry — so bugs find you, not the other way around." + message = ( + "Link your Slack account to Sentry — so bugs find you, not the other way around." + if is_welcome_message + else "I'd love to help, but I don't know you like that — link your Slack account to Sentry first." + ) renderable = SlackRenderable( blocks=[ MarkdownBlock(text=message), From e72039ef39a5ef9c0dbcc2f4eef102a8a5b09a5a Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Fri, 10 Apr 2026 13:07:48 -0400 Subject: [PATCH 10/14] =?UTF-8?q?=E2=9C=A8=20revamp=20linking=20prompts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sentry/integrations/messaging/metrics.py | 2 +- .../integrations/slack/webhooks/event.py | 25 ++++---- .../seer/entrypoints/slack/messaging.py | 2 + .../slack/webhooks/events/__init__.py | 17 ++++++ .../slack/webhooks/events/test_app_mention.py | 59 ++++++++----------- .../events/test_assistant_thread_started.py | 46 +++++++++------ .../slack/webhooks/events/test_message_im.py | 57 +++++++++++------- 7 files changed, 119 insertions(+), 89 deletions(-) diff --git a/src/sentry/integrations/messaging/metrics.py b/src/sentry/integrations/messaging/metrics.py index 3de6bd2e711cd9..a17831baeb9bf2 100644 --- a/src/sentry/integrations/messaging/metrics.py +++ b/src/sentry/integrations/messaging/metrics.py @@ -37,7 +37,7 @@ class MessagingInteractionType(StrEnum): VIEW_SUBMISSION = "VIEW_SUBMISSION" SEER_AUTOFIX_START = "SEER_AUTOFIX_START" APP_MENTION = "APP_MENTION" - DM_MESSAGE = "DM_MESSAGE" + DIRECT_MESSAGE = "DIRECT_MESSAGE" ASSISTANT_THREAD_STARTED = "ASSISTANT_THREAD_STARTED" # Automatic behaviors diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index fa2ec0cc22f57d..657f1f04cd7efc 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -403,7 +403,7 @@ def _resolve_seer_organization(self, slack_request: SlackEventRequest) -> SeerRe if not SlackExplorerEntrypoint.has_access(organization): continue - if identity_user and not organization.has_access(identity_user): + if not organization.has_access(identity_user): continue installation = slack_request.integration.get_installation( @@ -418,12 +418,12 @@ def _resolve_seer_organization(self, slack_request: SlackEventRequest) -> SeerRe result["error_reason"] = SeerSlackHaltReason.NO_VALID_ORGANIZATION return result - def _handle_seer_mention( + def _handle_seer_prompt( self, slack_request: SlackEventRequest, interaction_type: MessagingInteractionType, ) -> Response: - """Shared handler for app mentions and DMs that trigger the Seer workflow.""" + """Shared handler for app mentions and DMs that trigger the Seer Explorer agent.""" with MessagingInteractionEvent( interaction_type=interaction_type, spec=SlackMessagingSpec(), @@ -500,9 +500,11 @@ def _handle_seer_mention( ) return self.respond() - def on_prompt(self, slack_request: SlackEventRequest) -> Response: - """Handle @mention and DM events for Seer Explorer.""" - return self._handle_seer_mention(slack_request, MessagingInteractionType.APP_MENTION) + def on_app_mention(self, slack_request: SlackEventRequest) -> Response: + return self._handle_seer_prompt(slack_request, MessagingInteractionType.APP_MENTION) + + def on_dm(self, slack_request: SlackEventRequest) -> Response: + return self._handle_seer_prompt(slack_request, MessagingInteractionType.DIRECT_MESSAGE) def on_assistant_thread_started(self, slack_request: SlackEventRequest) -> Response: """Handle assistant_thread_started events by sending suggested prompts.""" @@ -510,8 +512,6 @@ def on_assistant_thread_started(self, slack_request: SlackEventRequest) -> Respo 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) result = self._resolve_seer_organization(slack_request) if result["error_reason"]: @@ -523,8 +523,9 @@ def on_assistant_thread_started(self, slack_request: SlackEventRequest) -> Respo installation = result["installation"] - channel_id = assistant_thread.get("channel_id") - thread_ts = assistant_thread.get("thread_ts") + channel_id = slack_request.channel_id + thread_ts = slack_request.thread_ts + assistant_thread = slack_request.data.get("event", {}).get("assistant_thread", {}) lifecycle.add_extras( { @@ -592,7 +593,7 @@ def post(self, request: Request) -> Response: return self.respond() if slack_request.type == "app_mention": - return self.on_prompt(slack_request) + return self.on_app_mention(slack_request) if slack_request.type == "assistant_thread_started": return self.on_assistant_thread_started(slack_request) @@ -605,7 +606,7 @@ def post(self, request: Request) -> Response: resp: Response | None if slack_request.has_assistant_scope: - resp = self.on_prompt(slack_request) + resp = self.on_direct_message(slack_request) elif command in COMMANDS: resp = super().post_dispatcher(slack_request) else: diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index 5b46a9d8ea8767..17b0f4d478d0a1 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -277,6 +277,8 @@ def send_identity_link_prompt( from sentry.integrations.slack.message_builder.types import SlackAction from sentry.integrations.slack.views.link_identity import build_linking_url + # TODO(leander): We'll need to revisit the UX around linking. We can't pass threads and the messages + # are not ephemeral but we don't want to start new 'Conversations' with a success message. associate_url = build_linking_url( integration=integration, slack_id=slack_user_id, diff --git a/tests/sentry/integrations/slack/webhooks/events/__init__.py b/tests/sentry/integrations/slack/webhooks/events/__init__.py index 48b2b84eeb5d38..4a3a90a12e0a39 100644 --- a/tests/sentry/integrations/slack/webhooks/events/__init__.py +++ b/tests/sentry/integrations/slack/webhooks/events/__init__.py @@ -2,8 +2,11 @@ import orjson +from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase from sentry.testutils.helpers import install_slack +from sentry.testutils.silo import assume_test_silo_mode +from sentry.users.models.identity import Identity, IdentityStatus UNSET = object() @@ -58,6 +61,20 @@ def setUp(self) -> None: super().setUp() self.integration = install_slack(self.organization) + def link_identity(self, user=None, slack_user_id="U1234567890"): + """Link a Slack identity for identity resolution in Seer handlers.""" + with assume_test_silo_mode(SiloMode.CONTROL): + idp = self.create_identity_provider( + type="slack", external_id=self.integration.external_id + ) + Identity.objects.create( + external_id=slack_user_id, + idp=idp, + user=user or self.user, + status=IdentityStatus.VALID, + scopes=[], + ) + @patch( "sentry.integrations.slack.requests.SlackRequest._check_signing_secret", return_value=True ) 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 cd5d725a17b88b..9b72c2123d83f9 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py @@ -2,10 +2,7 @@ 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 @@ -31,6 +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): + self.link_identity() with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook( event_data=THREADED_APP_MENTION_EVENT, data=AUTHORIZATIONS_DATA @@ -50,6 +48,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): + self.link_identity() with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=THREADED_APP_MENTION_EVENT) @@ -61,6 +60,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.""" + self.link_identity() with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=APP_MENTION_EVENT) @@ -70,9 +70,25 @@ def test_app_mention_non_threaded_dispatches_task(self, mock_apply_async): assert kwargs["ts"] == APP_MENTION_EVENT["ts"] assert kwargs["thread_ts"] is None + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("sentry.integrations.slack.webhooks.event.send_identity_link_prompt") + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_app_mention_identity_not_linked(self, mock_apply_async, mock_send_link, mock_record): + """When no Slack identity is linked, send a link prompt and halt.""" + 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() + mock_send_link.assert_called_once() + assert mock_send_link.call_args[1]["slack_user_id"] == "U1234567890" + assert mock_send_link.call_args[1]["is_welcome_message"] is False + assert_halt_metric(mock_record, SeerSlackHaltReason.IDENTITY_NOT_LINKED) + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_app_mention_feature_flag_disabled(self, mock_apply_async, mock_record): + self.link_identity() resp = self.post_webhook(event_data=APP_MENTION_EVENT) assert resp.status_code == 200 @@ -82,6 +98,7 @@ def test_app_mention_feature_flag_disabled(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_empty_text(self, mock_apply_async, mock_record): + self.link_identity() event_data = {**APP_MENTION_EVENT, "text": ""} with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data) @@ -94,6 +111,7 @@ def test_app_mention_empty_text(self, mock_apply_async, mock_record): @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_app_mention_no_integration(self, mock_apply_async, mock_record): """When the integration has no org integrations, we should not dispatch.""" + self.link_identity() with patch( "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", return_value=[], @@ -108,6 +126,7 @@ def test_app_mention_no_integration(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_org_not_found(self, mock_apply_async, mock_record): + self.link_identity() with patch.object( Organization.objects, "get_from_cache", @@ -126,15 +145,7 @@ def test_app_mention_linked_user_not_org_member(self, mock_apply_async, mock_rec """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=[], - ) + self.link_identity(user=other_user) with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=APP_MENTION_EVENT) @@ -142,27 +153,3 @@ def test_app_mention_linked_user_not_org_member(self, mock_apply_async, mock_rec 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 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 d76c8f0c36a634..be9a38123ce837 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 @@ -22,10 +22,9 @@ class AssistantThreadStartedEventTest(BaseEventTest): - @patch( - "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", - ) + @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") def test_sends_suggested_prompts(self, mock_set_prompts): + self.link_identity() with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) @@ -35,12 +34,11 @@ def test_sends_suggested_prompts(self, mock_set_prompts): assert kwargs["channel_id"] == "D1234567890" assert kwargs["thread_ts"] == "1729999327.187299" assert len(kwargs["prompts"]) == 4 - assert kwargs["title"] # non-empty welcome title + assert kwargs["title"] - @patch( - "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", - ) + @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") def test_prompt_titles_and_messages(self, mock_set_prompts): + self.link_identity() with self.feature(SEER_EXPLORER_FEATURES): self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) @@ -52,10 +50,22 @@ def test_prompt_titles_and_messages(self, mock_set_prompts): assert prompt["message"] @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - @patch( - "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", - ) + @patch("sentry.integrations.slack.webhooks.event.send_identity_link_prompt") + @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") + def test_identity_not_linked(self, mock_set_prompts, mock_send_link, mock_record): + """When no identity is linked, send a welcome link prompt and halt.""" + resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) + + assert resp.status_code == 200 + mock_set_prompts.assert_not_called() + mock_send_link.assert_called_once() + assert mock_send_link.call_args[1]["is_welcome_message"] is True + assert_halt_metric(mock_record, SeerSlackHaltReason.IDENTITY_NOT_LINKED) + + @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): + self.link_identity() resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) assert resp.status_code == 200 @@ -63,10 +73,9 @@ def test_feature_flag_disabled(self, mock_set_prompts, mock_record): 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", - ) + @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") def test_no_integration(self, mock_set_prompts, mock_record): + self.link_identity() with patch( "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", return_value=[], @@ -79,10 +88,9 @@ def test_no_integration(self, mock_set_prompts, mock_record): assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_INTEGRATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - @patch( - "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", - ) + @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") def test_missing_channel_id(self, mock_set_prompts, mock_record): + self.link_identity() event_data = { "type": "assistant_thread_started", "assistant_thread": { @@ -98,10 +106,9 @@ def test_missing_channel_id(self, mock_set_prompts, mock_record): assert_halt_metric(mock_record, SeerSlackHaltReason.MISSING_EVENT_DATA) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - @patch( - "sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts", - ) + @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") def test_missing_thread_ts(self, mock_set_prompts, mock_record): + self.link_identity() event_data = { "type": "assistant_thread_started", "assistant_thread": { @@ -122,6 +129,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.""" + self.link_identity() with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) 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 d35838b1f33b70..2f2c38c609c592 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py @@ -195,26 +195,28 @@ def test_user_message_im_no_text(self) -> None: class MessageIMDmAgentTest(BaseEventTest): - """Tests for DM messages triggering the Seer Explorer agentic workflow.""" + """Tests for DM messages triggering the Seer Explorer agentic workflow. + + These tests require the integration to have the assistant:write scope so + that DMs are routed to on_prompt instead of the help message handler. + """ + + def setUp(self): + super().setUp() + with assume_test_silo_mode(SiloMode.CONTROL): + self.integration.metadata["scopes"] = ["assistant:write"] + self.integration.save() @pytest.fixture(autouse=True) - def mock_chat_postMessage(self): + def mock_set_thread_status(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: + "sentry.integrations.slack.integration.SlackIntegration.set_thread_status", + ) as self.mock_status: yield @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_dm_dispatches_task(self, mock_apply_async): + self.link_identity(slack_user_id="Uxxxxxxx") with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT, data=AUTHORIZATIONS_DATA) @@ -232,6 +234,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): + self.link_identity(slack_user_id="Uxxxxxxx") with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook( event_data=MESSAGE_IM_DM_EVENT_THREADED, data=AUTHORIZATIONS_DATA @@ -245,6 +248,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): + self.link_identity(slack_user_id="Uxxxxxxx") with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) @@ -253,17 +257,26 @@ def test_dm_no_authorizations(self, mock_apply_async): 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.integrations.slack.webhooks.event.send_identity_link_prompt") + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_identity_not_linked(self, mock_apply_async, mock_send_link, mock_record): + """When no identity is linked, send a link prompt and halt.""" + 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() + mock_send_link.assert_called_once() + assert mock_send_link.call_args[1]["slack_user_id"] == "Uxxxxxxx" + assert_halt_metric(mock_record, SeerSlackHaltReason.IDENTITY_NOT_LINKED) @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.""" + def test_dm_feature_flag_disabled(self, mock_apply_async, mock_record): + """With assistant scope, DMs route to on_prompt even without the feature flag. + The org resolution halts because no org has Seer access enabled.""" + self.link_identity(slack_user_id="Uxxxxxxx") resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) assert resp.status_code == 200 @@ -273,6 +286,7 @@ def test_dm_feature_flag_disabled_falls_back_to_help(self, mock_apply_async, moc @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): + self.link_identity(slack_user_id="Uxxxxxxx") with patch( "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", return_value=[], @@ -287,6 +301,7 @@ def test_dm_no_integration(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_dm_empty_text(self, mock_apply_async, mock_record): + self.link_identity(slack_user_id="Uxxxxxxx") event_data = {**MESSAGE_IM_DM_EVENT, "text": ""} with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=event_data) From 86ee72e419fdc69bf0b49c5fb0f0e48477a7262c Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Fri, 10 Apr 2026 13:21:04 -0400 Subject: [PATCH 11/14] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20review=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sentry/integrations/slack/integration.py | 2 +- .../integrations/slack/webhooks/event.py | 2 +- .../seer/entrypoints/slack/messaging.py | 30 +++++++++++++------ 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index a85555662c2564..e3f33675bff0c0 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -211,7 +211,7 @@ def send_threaded_ephemeral_message_static( with the message. In rare cases where we cannot infer an organization, but need to invoke a Slack API, use this. - For example, when linking an Slack identity to a Sentry user, there could be multiple organizations + For example, when linking a Slack identity to a Sentry user, there could be multiple organizations attached to the Slack Workspace. We cannot infer which the user may link to. """ client = SlackSdkClient(integration_id=integration_id) diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index 657f1f04cd7efc..3d1af6233749a8 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -503,7 +503,7 @@ def _handle_seer_prompt( def on_app_mention(self, slack_request: SlackEventRequest) -> Response: return self._handle_seer_prompt(slack_request, MessagingInteractionType.APP_MENTION) - def on_dm(self, slack_request: SlackEventRequest) -> Response: + def on_direct_message(self, slack_request: SlackEventRequest) -> Response: return self._handle_seer_prompt(slack_request, MessagingInteractionType.DIRECT_MESSAGE) def on_assistant_thread_started(self, slack_request: SlackEventRequest) -> Response: diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index 17b0f4d478d0a1..b744d0de2102ff 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -277,8 +277,9 @@ def send_identity_link_prompt( from sentry.integrations.slack.message_builder.types import SlackAction from sentry.integrations.slack.views.link_identity import build_linking_url - # TODO(leander): We'll need to revisit the UX around linking. We can't pass threads and the messages - # are not ephemeral but we don't want to start new 'Conversations' with a success message. + # TODO(leander): We'll need to revisit the UX around linking. We can't pass threads here so while + # the linking start message is correctly located and ephemeral, the success message afterwards is not.abs + # By omitting the response_url here, it will arrive as a DM, but it doesn't accept threads so this is the best we can do for now. associate_url = build_linking_url( integration=integration, slack_id=slack_user_id, @@ -307,10 +308,21 @@ def send_identity_link_prompt( ], text=message, ) - SlackIntegration.send_threaded_ephemeral_message_static( - integration_id=integration.id, - channel_id=channel_id, - thread_ts=thread_ts, - renderable=renderable, - slack_user_id=slack_user_id, - ) + try: + SlackIntegration.send_threaded_ephemeral_message_static( + integration_id=integration.id, + channel_id=channel_id, + thread_ts=thread_ts, + renderable=renderable, + slack_user_id=slack_user_id, + ) + except Exception: + logger.exception( + "send_identity_link_prompt.error", + extra={ + "integration_id": integration.id, + "channel_id": channel_id, + "thread_ts": thread_ts, + "slack_user_id": slack_user_id, + }, + ) From b7049b370e32d1531442d3459c0ce7a6f9ac28c8 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Fri, 10 Apr 2026 13:33:16 -0400 Subject: [PATCH 12/14] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20more=20review=20fixe?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integrations/slack/requests/event.py | 2 +- .../integrations/slack/webhooks/event.py | 37 ++++++++++--------- .../seer/entrypoints/slack/messaging.py | 2 +- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/sentry/integrations/slack/requests/event.py b/src/sentry/integrations/slack/requests/event.py index b6069d42ea4e66..1ea5a0ae21c928 100644 --- a/src/sentry/integrations/slack/requests/event.py +++ b/src/sentry/integrations/slack/requests/event.py @@ -75,7 +75,7 @@ def user_id(self) -> str: def thread_ts(self) -> str: if self.is_assistant_thread_event: return self.dm_data.get("assistant_thread", {}).get("thread_ts", "") - return self.dm_data.get("ts", "") + return self.dm_data.get("thread_ts") or self.dm_data.get("ts", "") @property def has_assistant_scope(self): diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index 3d1af6233749a8..47b2a726f169dc 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -46,6 +46,24 @@ _logger = logging.getLogger(__name__) +_SEER_STARTING_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 performance bottlenecks", + "message": "What are the slowest endpoints or pages in my projects?", + }, +] _SEER_LOADING_MESSAGES = [ "Digging through your errors...", "Sifting through stack traces...", @@ -544,24 +562,7 @@ def on_assistant_thread_started(self, slack_request: SlackEventRequest) -> Respo 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 performance bottlenecks", - "message": "What are the slowest endpoints or pages in my projects?", - }, - ], + prompts=_SEER_STARTING_PROMPTS, ) except Exception: _logger.exception( diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index b744d0de2102ff..b17d72db9f8dfc 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -278,7 +278,7 @@ def send_identity_link_prompt( from sentry.integrations.slack.views.link_identity import build_linking_url # TODO(leander): We'll need to revisit the UX around linking. We can't pass threads here so while - # the linking start message is correctly located and ephemeral, the success message afterwards is not.abs + # the linking start message is correctly located and ephemeral, the success message afterwards is not. # By omitting the response_url here, it will arrive as a DM, but it doesn't accept threads so this is the best we can do for now. associate_url = build_linking_url( integration=integration, From 5211e7dc0847e3d48ace073a70db1f4ed4453602 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Fri, 10 Apr 2026 13:50:23 -0400 Subject: [PATCH 13/14] ref(slack): Fix thread_ts handling and add missing type annotations Remove the ts fallback from SlackEventRequest.thread_ts so top-level messages correctly yield None instead of collapsing into the message ts. Add missing return type annotations to has_assistant_scope and is_assistant_thread_event. Move variable extraction before org resolution in _handle_seer_prompt for earlier lifecycle context. --- .../integrations/slack/requests/event.py | 6 ++--- .../integrations/slack/webhooks/event.py | 25 +++++++------------ 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/sentry/integrations/slack/requests/event.py b/src/sentry/integrations/slack/requests/event.py index 1ea5a0ae21c928..2c9e3ccf390683 100644 --- a/src/sentry/integrations/slack/requests/event.py +++ b/src/sentry/integrations/slack/requests/event.py @@ -75,14 +75,14 @@ def user_id(self) -> str: def thread_ts(self) -> str: if self.is_assistant_thread_event: return self.dm_data.get("assistant_thread", {}).get("thread_ts", "") - return self.dm_data.get("thread_ts") or self.dm_data.get("ts", "") + return self.dm_data.get("thread_ts") @property - def has_assistant_scope(self): + def has_assistant_scope(self) -> bool: return SlackScope.ASSISTANT_WRITE in self.integration.metadata.get("scopes", []) @property - def is_assistant_thread_event(self): + def is_assistant_thread_event(self) -> bool: return self.dm_data.get("type") == "assistant_thread_started" @property diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index 47b2a726f169dc..5e1569451837d4 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -447,10 +447,17 @@ def _handle_seer_prompt( spec=SlackMessagingSpec(), ).capture() as lifecycle: data = slack_request.data.get("event", {}) + channel_id = data.get("channel") + text = data.get("text") + ts = data.get("ts") or data.get("message_ts") + thread_ts = slack_request.thread_ts lifecycle.add_extras( { "integration_id": slack_request.integration.id, - "thread_ts": data.get("thread_ts"), + "thread_ts": thread_ts, + "channel_id": channel_id, + "text": text, + "ts": ts, } ) @@ -465,21 +472,6 @@ def _handle_seer_prompt( organization_id = result["organization_id"] installation = result["installation"] - 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(SeerSlackHaltReason.MISSING_EVENT_DATA) return self.respond() @@ -606,6 +598,7 @@ def post(self, request: Request) -> Response: command, _ = slack_request.get_command_and_args() resp: Response | None + # If we have the assistant scope, we don't want to fallback to commands anymore. if slack_request.has_assistant_scope: resp = self.on_direct_message(slack_request) elif command in COMMANDS: From 84a74383c10b799ae1c697afc088d647a93c2457 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Fri, 10 Apr 2026 13:56:00 -0400 Subject: [PATCH 14/14] fix(slack): Fix thread_ts return type to satisfy mypy --- src/sentry/integrations/slack/requests/event.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/integrations/slack/requests/event.py b/src/sentry/integrations/slack/requests/event.py index 2c9e3ccf390683..7ceaf0f0cae67a 100644 --- a/src/sentry/integrations/slack/requests/event.py +++ b/src/sentry/integrations/slack/requests/event.py @@ -72,10 +72,10 @@ def user_id(self) -> str: return self.dm_data.get("user", "") @property - def thread_ts(self) -> str: + def thread_ts(self) -> str | None: if self.is_assistant_thread_event: return self.dm_data.get("assistant_thread", {}).get("thread_ts", "") - return self.dm_data.get("thread_ts") + return self.dm_data.get("thread_ts", "") @property def has_assistant_scope(self) -> bool: