From a59eb0955ab84d72f10110460564e50c94b78a92 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Wed, 15 Apr 2026 16:23:53 -0400 Subject: [PATCH 1/2] initial pass, moving routing to control --- src/sentry/features/manager.py | 3 +- .../integrations/slack/requests/event.py | 85 +++++++++++++++- .../integrations/slack/webhooks/event.py | 98 +++---------------- .../middleware/integrations/parsers/slack.py | 54 +++++++++- .../seer/entrypoints/slack/entrypoint.py | 3 +- .../seer/entrypoints/slack/messaging.py | 2 + src/sentry/seer/explorer/client_utils.py | 4 +- src/sentry/seer/seer_setup.py | 7 +- 8 files changed, 165 insertions(+), 91 deletions(-) diff --git a/src/sentry/features/manager.py b/src/sentry/features/manager.py index e539ed580bcbd3..2f6dbc1015d50a 100644 --- a/src/sentry/features/manager.py +++ b/src/sentry/features/manager.py @@ -14,6 +14,7 @@ from sentry import options from sentry.options.rollout import in_random_rollout +from sentry.organizations.services.organization.model import RpcOrganization from sentry.users.services.user.model import RpcUser from sentry.utils import metrics from sentry.utils.flag import record_feature_flag @@ -321,7 +322,7 @@ def batch_has( feature_names: Sequence[str], actor: User | RpcUser | AnonymousUser | None = None, projects: Sequence[Project] | None = None, - organization: Organization | None = None, + organization: RpcOrganization | Organization | None = None, ) -> dict[str, dict[str, bool | None]] | None: """ Determine if multiple features are enabled. Unhandled flags will not be in diff --git a/src/sentry/integrations/slack/requests/event.py b/src/sentry/integrations/slack/requests/event.py index fa80982f7a6eb5..b392f42c45d814 100644 --- a/src/sentry/integrations/slack/requests/event.py +++ b/src/sentry/integrations/slack/requests/event.py @@ -1,14 +1,24 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, NamedTuple +from sentry.constants import ObjectStatus +from sentry.integrations.messaging.metrics import SeerSlackHaltReason +from sentry.integrations.services.integration import integration_service 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 +from sentry.integrations.types import IntegrationProviderSlug +from sentry.models.organization import OrganizationStatus +from sentry.organizations.services.organization.service import organization_service +from sentry.seer.entrypoints.slack.entrypoint import SlackExplorerEntrypoint +from sentry.seer.entrypoints.slack.messaging import send_identity_link_prompt +from sentry.silo.base import all_silo_function COMMANDS = ["link", "unlink", "link team", "unlink team"] +SLACK_PROVIDERS = [IntegrationProviderSlug.SLACK, IntegrationProviderSlug.SLACK_STAGING] def has_discover_links(links: list[str]) -> bool: @@ -23,6 +33,11 @@ def is_event_challenge(data: Mapping[str, Any]) -> bool: return data.get("type", "") == "url_verification" +class SeerResolutionResult(NamedTuple): + organization_id: int | None + error_reason: SeerSlackHaltReason | None + + class SlackEventRequest(SlackDMRequest): """ An Event request sent from Slack. @@ -55,6 +70,74 @@ def is_challenge(self) -> bool: """We need to call this before validation.""" return is_event_challenge(self.request.data) + @property + def is_seer_explorer_request(self) -> bool: + return ( + self.type == "app_mention" + or self.type == "assistant_thread_started" + or (self.dm_data.get("type") == "message" and self.has_assistant_scope) + ) + + @all_silo_function + def resolve_seer_organization(self) -> SeerResolutionResult: + """ + Resolve and validate an organization/user for a Seer Slack event. + + If the initiating user is not linked, we will reply with a prompt to link their identity. + + 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 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. + """ + identity_user = self.get_identity_user() + if not identity_user: + send_identity_link_prompt( + integration=self.integration, + slack_user_id=self.user_id, + channel_id=self.channel_id, + thread_ts=self.thread_ts or None, + is_welcome_message=self.is_assistant_thread_event, + ) + return SeerResolutionResult( + organization_id=None, error_reason=SeerSlackHaltReason.IDENTITY_NOT_LINKED + ) + + ois = integration_service.get_organization_integrations( + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + providers=SLACK_PROVIDERS, + ) + if not ois: + return SeerResolutionResult( + organization_id=None, error_reason=SeerSlackHaltReason.NO_VALID_INTEGRATION + ) + + for oi in ois: + organization_id = oi.organization_id + ctx = organization_service.get_organization_by_id( + id=oi.organization_id, user_id=identity_user.id + ) + if ctx is None: + continue + + if ctx.organization.status != OrganizationStatus.ACTIVE: + continue + + if not SlackExplorerEntrypoint.has_access(ctx.organization): + continue + + if ctx.member is None: + continue + + return SeerResolutionResult(organization_id=organization_id, error_reason=None) + + return SeerResolutionResult( + organization_id=None, error_reason=SeerSlackHaltReason.NO_VALID_ORGANIZATION + ) + @property def dm_data(self) -> Mapping[str, Any]: return self.data.get("event", {}) diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index fa560e4423f231..3c9cb6ed5395a7 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -15,7 +15,6 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import all_silo_endpoint -from sentry.constants import ObjectStatus from sentry.integrations.messaging.metrics import ( MessagingInteractionEvent, MessagingInteractionType, @@ -34,11 +33,8 @@ from sentry.integrations.slack.unfurl.types import LinkType, UnfurlableUrl from sentry.integrations.slack.views.link_identity import build_linking_url 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.messaging import send_identity_link_prompt from sentry.seer.entrypoints.slack.tasks import process_mention_for_slack from .base import SlackDMEndpoint @@ -368,74 +364,6 @@ def on_link_shared(self, request: Request, slack_request: SlackDMRequest) -> boo return True - def _resolve_seer_organization(self, slack_request: SlackEventRequest) -> SeerResolutionResult: - """ - Resolve and validate an organization/user for a Seer Slack event. - - If the initiating user is not linked, we will reply with a prompt to link their identity. - - 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 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, - slack_user_id=slack_request.user_id, - channel_id=slack_request.channel_id, - thread_ts=slack_request.thread_ts or None, - is_welcome_message=slack_request.is_assistant_thread_event, - ) - return result - - ois = integration_service.get_organization_integrations( - integration_id=slack_request.integration.id, - status=ObjectStatus.ACTIVE, - providers=SLACK_PROVIDERS, - ) - if not ois: - result["error_reason"] = SeerSlackHaltReason.NO_VALID_INTEGRATION - return result - - for oi in ois: - organization_id = oi.organization_id - try: - organization = Organization.objects.get_from_cache(id=organization_id) - except Organization.DoesNotExist: - continue - - if organization.status != OrganizationStatus.ACTIVE: - continue - - if not SlackExplorerEntrypoint.has_access(organization): - continue - - if not organization.has_access(identity_user): - continue - - installation = slack_request.integration.get_installation( - organization_id=organization_id - ) - assert isinstance(installation, SlackIntegration) - - result["organization_id"] = organization_id - result["installation"] = installation - return result - - result["error_reason"] = SeerSlackHaltReason.NO_VALID_ORGANIZATION - return result - def _handle_seer_prompt( self, slack_request: SlackEventRequest, @@ -461,16 +389,17 @@ def _handle_seer_prompt( } ) - result = self._resolve_seer_organization(slack_request) - if result["error_reason"]: - lifecycle.record_halt(result["error_reason"]) + organization_id, error_reason = slack_request.resolve_seer_organization() + if error_reason: + lifecycle.record_halt(error_reason) return self.respond() - if not result["organization_id"] or not result["installation"]: + if not organization_id: return self.respond() - organization_id = result["organization_id"] - installation = result["installation"] + installation: SlackIntegration = slack_request.integration.get_installation( + organization_id=organization_id + ) if not channel_id or not text or not ts or not slack_request.user_id: lifecycle.record_halt(SeerSlackHaltReason.MISSING_EVENT_DATA) @@ -523,15 +452,17 @@ def on_assistant_thread_started(self, slack_request: SlackEventRequest) -> Respo spec=SlackMessagingSpec(), ).capture() as lifecycle: 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"]) + organization_id, error_reason = slack_request.resolve_seer_organization() + if error_reason: + lifecycle.record_halt(error_reason) return self.respond() - if not result["installation"]: + if not organization_id: return self.respond() - installation = result["installation"] + installation: SlackIntegration = slack_request.integration.get_installation( + organization_id=organization_id + ) channel_id = slack_request.channel_id thread_ts = slack_request.thread_ts @@ -542,6 +473,7 @@ def on_assistant_thread_started(self, slack_request: SlackEventRequest) -> Respo "channel_id": channel_id, "thread_ts": thread_ts, "context": assistant_thread.get("context"), + "organization_id": organization_id, } ) diff --git a/src/sentry/middleware/integrations/parsers/slack.py b/src/sentry/middleware/integrations/parsers/slack.py index 06f26baf8273c4..9202e565f2235b 100644 --- a/src/sentry/middleware/integrations/parsers/slack.py +++ b/src/sentry/middleware/integrations/parsers/slack.py @@ -23,7 +23,7 @@ from sentry.integrations.models.integration import Integration from sentry.integrations.slack.message_builder.routing import SlackRoutingData, decode_action_id from sentry.integrations.slack.requests.base import SlackRequestError -from sentry.integrations.slack.requests.event import is_event_challenge +from sentry.integrations.slack.requests.event import SlackEventRequest, is_event_challenge from sentry.integrations.slack.sdk_client import SlackSdkClient from sentry.integrations.slack.views import SALT from sentry.integrations.slack.views.link_identity import SlackLinkIdentityView @@ -41,7 +41,8 @@ from sentry.integrations.slack.webhooks.options_load import SlackOptionsLoadEndpoint from sentry.integrations.types import EXTERNAL_PROVIDERS, ExternalProviders from sentry.middleware.integrations.tasks import convert_to_async_slack_response -from sentry.types.cell import Cell +from sentry.models.organizationmapping import OrganizationMapping +from sentry.types.cell import Cell, CellResolutionError, get_cell_by_name from sentry.utils import json from sentry.utils.signing import unsign @@ -293,6 +294,50 @@ def filter_organizations_from_request( ) return organizations + def _maybe_get_response_from_event_request(self) -> HttpResponseBase | None: + if self.view_class != SlackEventEndpoint: + return None + drf_request: Request = SlackDMEndpoint().initialize_request(self.request) + slack_request: SlackEventRequest = self.view_class.slack_request_class(drf_request) + + if not slack_request.is_seer_explorer_request: + return None + + organization, error_reason = slack_request.resolve_seer_organization() + if not organization or error_reason: + logger.info( + "slack.control.seer_event.organization.not_found", + extra={ + "organization_id": organization.id, + "error_reason": error_reason, + }, + ) + return None + + try: + mapping = OrganizationMapping.objects.get(organization_id=organization.id) + except OrganizationMapping.DoesNotExist: + logger.info( + "slack.control.seer_event.organization.mapping.not_found", + extra={ + "organization_id": organization.id, + }, + ) + return None + + try: + cell = get_cell_by_name(mapping.cell_name) + except CellResolutionError: + logger.info( + "slack.control.seer_event.organization.cell.not_found", + extra={ + "organization_id": organization.id, + "cell_name": mapping.cell_name, + }, + ) + return None + return self.get_response_from_cell_silo(cell=cell) + def get_response(self) -> HttpResponseBase: """ Slack Webhook Requests all require synchronous responses. @@ -322,6 +367,7 @@ def get_response(self) -> HttpResponseBase: # this request until it succeeds. return HttpResponse(status=status.HTTP_202_ACCEPTED) + drf_request: Request if self.view_class == SlackActionEndpoint: drf_request: Request = SlackDMEndpoint().initialize_request(self.request) slack_request = self.view_class.slack_request_class(drf_request) @@ -337,6 +383,10 @@ def get_response(self) -> HttpResponseBase: else self.get_response_from_all_cells() ) + event_response = self._maybe_get_response_from_event_request() + if event_response: + return event_response + # Slack webhooks can only receive one synchronous call/response, as there are many # places where we post to slack on their webhook request. This would cause multiple # calls back to slack for every cell we forward to. diff --git a/src/sentry/seer/entrypoints/slack/entrypoint.py b/src/sentry/seer/entrypoints/slack/entrypoint.py index 25f81fe6d268e6..5f5e132df841a2 100644 --- a/src/sentry/seer/entrypoints/slack/entrypoint.py +++ b/src/sentry/seer/entrypoints/slack/entrypoint.py @@ -15,6 +15,7 @@ SeerExplorerResponse, ) from sentry.notifications.utils.actions import BlockKitMessageAction +from sentry.organizations.services.organization.model import RpcOrganization from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.entrypoints.cache import SeerOperatorAutofixCache from sentry.seer.entrypoints.registry import ( @@ -420,7 +421,7 @@ def __init__( self.slack_user_id = slack_user_id @staticmethod - def has_access(organization: Organization) -> bool: + def has_access(organization: Organization | RpcOrganization) -> bool: has_seer_slack_feature_flag = features.has( "organizations:seer-slack-explorer", organization ) diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index 4521eb421d8ea2..e312439132fa50 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -29,6 +29,7 @@ SlackEntrypointInteractionType, ) from sentry.shared_integrations.exceptions import IntegrationConfigurationError, IntegrationError +from sentry.silo.base import all_silo_function from sentry.tasks.base import instrumented_task from sentry.taskworker.namespaces import integrations_tasks from sentry.utils.registry import NoRegistrationExistsError @@ -265,6 +266,7 @@ def remove_all_buttons_transformer(_elem: dict[str, Any]) -> dict[str, Any] | No lifecycle.record_halt(halt_reason=e) +@all_silo_function def send_identity_link_prompt( *, integration: RpcIntegration, diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index 53b7f10a236053..e6f315ed454fb0 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -24,6 +24,7 @@ from sentry.models.organizationmember import OrganizationMember from sentry.models.project import Project from sentry.net.http import connection_from_url +from sentry.organizations.services.organization.model import RpcOrganization from sentry.seer.explorer.client_models import SeerRunState from sentry.seer.models import SeerApiError from sentry.seer.seer_setup import has_seer_access_with_detail @@ -184,7 +185,8 @@ def get_explorer_state_from_pr_id( def has_seer_explorer_access_with_detail( - organization: Organization, actor: SentryUser | AnonymousUser | RpcUser | None = None + organization: Organization | RpcOrganization, + actor: SentryUser | AnonymousUser | RpcUser | None = None, ) -> tuple[bool, str | None]: """ Check if the actor has access to Seer Explorer. diff --git a/src/sentry/seer/seer_setup.py b/src/sentry/seer/seer_setup.py index 3bcba3541fec01..944375854f9c12 100644 --- a/src/sentry/seer/seer_setup.py +++ b/src/sentry/seer/seer_setup.py @@ -2,12 +2,14 @@ from sentry import features from sentry.models.organization import Organization +from sentry.organizations.services.organization.model import RpcOrganization from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser def has_seer_access( - organization: Organization, actor: User | AnonymousUser | RpcUser | None = None + organization: Organization | RpcOrganization, + actor: User | AnonymousUser | RpcUser | None = None, ) -> bool: return features.has("organizations:gen-ai-features", organization, actor=actor) and not bool( organization.get_option("sentry:hide_ai_features") @@ -15,7 +17,8 @@ def has_seer_access( def has_seer_access_with_detail( - organization: Organization, actor: User | AnonymousUser | RpcUser | None = None + organization: Organization | RpcOrganization, + actor: User | AnonymousUser | RpcUser | None = None, ) -> tuple[bool, str | None]: if not features.has("organizations:gen-ai-features", organization, actor=actor): return False, "Feature flag not enabled" From aae2e09d37c7a18e5918cc368922b77badef8477 Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Wed, 15 Apr 2026 16:26:29 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=9A=A7=20fix=20imports,=20add=20some?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sentry/features/manager.py | 2 +- .../middleware/integrations/parsers/slack.py | 12 +- .../slack/webhooks/events/test_app_mention.py | 4 +- .../events/test_assistant_thread_started.py | 4 +- .../webhooks/events/test_direct_message.py | 4 +- .../integrations/parsers/test_slack.py | 117 ++++++++++++++++++ 6 files changed, 130 insertions(+), 13 deletions(-) diff --git a/src/sentry/features/manager.py b/src/sentry/features/manager.py index 2f6dbc1015d50a..6c30432325c87d 100644 --- a/src/sentry/features/manager.py +++ b/src/sentry/features/manager.py @@ -14,7 +14,6 @@ from sentry import options from sentry.options.rollout import in_random_rollout -from sentry.organizations.services.organization.model import RpcOrganization from sentry.users.services.user.model import RpcUser from sentry.utils import metrics from sentry.utils.flag import record_feature_flag @@ -29,6 +28,7 @@ from sentry.features.handler import FeatureHandler from sentry.models.organization import Organization from sentry.models.project import Project + from sentry.organizations.services.organization.model import RpcOrganization from sentry.users.models.user import User diff --git a/src/sentry/middleware/integrations/parsers/slack.py b/src/sentry/middleware/integrations/parsers/slack.py index 9202e565f2235b..7784da09ca4ba7 100644 --- a/src/sentry/middleware/integrations/parsers/slack.py +++ b/src/sentry/middleware/integrations/parsers/slack.py @@ -303,24 +303,24 @@ def _maybe_get_response_from_event_request(self) -> HttpResponseBase | None: if not slack_request.is_seer_explorer_request: return None - organization, error_reason = slack_request.resolve_seer_organization() - if not organization or error_reason: + organization_id, error_reason = slack_request.resolve_seer_organization() + if not organization_id or error_reason: logger.info( "slack.control.seer_event.organization.not_found", extra={ - "organization_id": organization.id, + "organization_id": organization_id, "error_reason": error_reason, }, ) return None try: - mapping = OrganizationMapping.objects.get(organization_id=organization.id) + mapping = OrganizationMapping.objects.get(organization_id=organization_id) except OrganizationMapping.DoesNotExist: logger.info( "slack.control.seer_event.organization.mapping.not_found", extra={ - "organization_id": organization.id, + "organization_id": organization_id, }, ) return None @@ -331,7 +331,7 @@ def _maybe_get_response_from_event_request(self) -> HttpResponseBase | None: logger.info( "slack.control.seer_event.organization.cell.not_found", extra={ - "organization_id": organization.id, + "organization_id": organization_id, "cell_name": mapping.cell_name, }, ) 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 0795fd0a6314ab..c79df31c169cb1 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py @@ -80,7 +80,7 @@ def test_app_mention_empty_text(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", + "sentry.integrations.slack.requests.event.integration_service.get_organization_integrations", return_value=[], ): with self.feature(SEER_EXPLORER_FEATURES): @@ -100,7 +100,7 @@ def test_app_mention_no_explorer_access(self, mock_apply_async, mock_record): assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_ORGANIZATION) @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - @patch("sentry.integrations.slack.webhooks.event.send_identity_link_prompt") + @patch("sentry.integrations.slack.requests.event.send_identity_link_prompt") @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_app_mention_no_identity_prompt_linkage( self, mock_apply_async, mock_send_link, mock_record 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 473b6f292816a6..6162343148fc56 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 @@ -54,7 +54,7 @@ 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.webhooks.event.send_identity_link_prompt") + @patch("sentry.integrations.slack.requests.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): self.unlink_identity() @@ -79,7 +79,7 @@ def test_feature_flag_disabled(self, mock_set_prompts, mock_record): @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") def test_no_integration(self, mock_set_prompts, mock_record): with patch( - "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", + "sentry.integrations.slack.requests.event.integration_service.get_organization_integrations", return_value=[], ): with self.feature(SEER_EXPLORER_FEATURES): diff --git a/tests/sentry/integrations/slack/webhooks/events/test_direct_message.py b/tests/sentry/integrations/slack/webhooks/events/test_direct_message.py index e7f5b623ea4b7c..a03c937fce9375 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_direct_message.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_direct_message.py @@ -73,7 +73,7 @@ def test_dm_threaded_dispatches_task(self, mock_apply_async): assert kwargs["thread_ts"] == THREADED_MESSAGE_DM_EVENT["thread_ts"] @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - @patch("sentry.integrations.slack.webhooks.event.send_identity_link_prompt") + @patch("sentry.integrations.slack.requests.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.""" @@ -100,7 +100,7 @@ def test_dm_feature_flag_disabled(self, mock_apply_async, mock_record): @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") def test_dm_no_integration(self, mock_apply_async, mock_record): with patch( - "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", + "sentry.integrations.slack.requests.event.integration_service.get_organization_integrations", return_value=[], ): with self.feature(SEER_EXPLORER_FEATURES): diff --git a/tests/sentry/middleware/integrations/parsers/test_slack.py b/tests/sentry/middleware/integrations/parsers/test_slack.py index b2946f1df2ea53..a29199ac5d7e90 100644 --- a/tests/sentry/middleware/integrations/parsers/test_slack.py +++ b/tests/sentry/middleware/integrations/parsers/test_slack.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch from urllib.parse import urlencode +import orjson import responses from django.db import router, transaction from django.http import HttpRequest, HttpResponse @@ -11,13 +12,16 @@ from rest_framework import status from sentry.hybridcloud.models.outbox import outbox_context +from sentry.integrations.messaging.metrics import SeerSlackHaltReason from sentry.integrations.middleware.hybrid_cloud.parser import create_async_request_payload from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.slack.message_builder.routing import encode_action_id from sentry.integrations.slack.message_builder.types import SlackAction +from sentry.integrations.slack.requests.event import SeerResolutionResult from sentry.integrations.slack.utils.auth import _encode_data from sentry.integrations.slack.views import SALT from sentry.middleware.integrations.parsers.slack import SlackRequestParser +from sentry.models.organizationmapping import OrganizationMapping from sentry.testutils.cases import TestCase from sentry.testutils.outbox import assert_no_webhook_payloads from sentry.testutils.silo import assume_test_silo_mode_of, control_silo_test, create_test_cells @@ -344,3 +348,116 @@ def test_targeting_issue_actions(self) -> None: assert len(organization_ids) == 2 assert self.organization.id in organization_ids assert other_organization.id in organization_ids + + +@control_silo_test(cells=create_test_cells("us")) +class SlackRequestParserSeerEventRoutingTest(TestCase): + """Tests for _maybe_get_response_from_event_request cell routing.""" + + factory = RequestFactory() + + def setUp(self) -> None: + self.user = self.create_user() + self.organization = self.create_organization(owner=self.user) + self.integration = self.create_integration( + organization=self.organization, external_id="TXXXXXXX1", provider="slack" + ) + + def get_response(self, request: HttpRequest) -> HttpResponse: + return HttpResponse(status=200, content="passthrough") + + def _make_event_request(self, event_type: str = "app_mention"): + data = { + "type": "event_callback", + "team_id": self.integration.external_id, + "api_app_id": "AXXXXXXXX1", + "event_id": "E1", + "event": { + "type": event_type, + "channel": "C1234567890", + "user": "U1234567890", + "text": "hello", + "ts": "1234567890.123456", + }, + } + return self.factory.post( + reverse("sentry-integration-slack-event"), + data=orjson.dumps(data), + content_type="application/json", + ) + + def test_returns_none_for_non_event_endpoint(self): + data = urlencode({"team_id": self.integration.external_id}).encode("utf-8") + request = self.factory.post( + path=reverse("sentry-integration-slack-commands"), + data=data, + content_type="application/x-www-form-urlencoded", + ) + parser = SlackRequestParser(request, self.get_response) + assert parser._maybe_get_response_from_event_request() is None + + @patch( + "sentry.integrations.slack.requests.base.SlackRequest._check_signing_secret", + return_value=True, + ) + def test_returns_none_for_non_seer_event(self, mock_signing): + request = self._make_event_request(event_type="link_shared") + parser = SlackRequestParser(request, self.get_response) + assert parser._maybe_get_response_from_event_request() is None + + @patch( + "sentry.integrations.slack.requests.base.SlackRequest._check_signing_secret", + return_value=True, + ) + @patch( + "sentry.integrations.slack.requests.event.SlackEventRequest.resolve_seer_organization", + ) + def test_routes_seer_event_to_correct_cell(self, mock_resolve, mock_signing): + mock_resolve.return_value = SeerResolutionResult( + organization_id=self.organization.id, error_reason=None + ) + mapping = OrganizationMapping.objects.get(organization_id=self.organization.id) + assert mapping.cell_name == "us" + + cell_response = HttpResponse(status=200, content="cell_response") + request = self._make_event_request(event_type="app_mention") + parser = SlackRequestParser(request, self.get_response) + + with patch.object( + parser, "get_response_from_cell_silo", return_value=cell_response + ) as mock_cell: + result = parser._maybe_get_response_from_event_request() + + assert result is not None + assert result.status_code == 200 + mock_cell.assert_called_once() + cell_arg = mock_cell.call_args[1]["cell"] + assert cell_arg.name == "us" + + @patch( + "sentry.integrations.slack.requests.base.SlackRequest._check_signing_secret", + return_value=True, + ) + @patch( + "sentry.integrations.slack.requests.event.SlackEventRequest.resolve_seer_organization", + ) + def test_returns_none_on_org_resolution_error(self, mock_resolve, mock_signing): + mock_resolve.return_value = SeerResolutionResult( + organization_id=None, error_reason=SeerSlackHaltReason.NO_VALID_ORGANIZATION + ) + request = self._make_event_request(event_type="app_mention") + parser = SlackRequestParser(request, self.get_response) + assert parser._maybe_get_response_from_event_request() is None + + @patch( + "sentry.integrations.slack.requests.base.SlackRequest._check_signing_secret", + return_value=True, + ) + @patch( + "sentry.integrations.slack.requests.event.SlackEventRequest.resolve_seer_organization", + ) + def test_returns_none_on_missing_org_mapping(self, mock_resolve, mock_signing): + mock_resolve.return_value = SeerResolutionResult(organization_id=99999, error_reason=None) + request = self._make_event_request(event_type="app_mention") + parser = SlackRequestParser(request, self.get_response) + assert parser._maybe_get_response_from_event_request() is None