diff --git a/src/sentry/integrations/messaging/metrics.py b/src/sentry/integrations/messaging/metrics.py index a055eb9562a382..a17831baeb9bf2 100644 --- a/src/sentry/integrations/messaging/metrics.py +++ b/src/sentry/integrations/messaging/metrics.py @@ -37,6 +37,8 @@ class MessagingInteractionType(StrEnum): VIEW_SUBMISSION = "VIEW_SUBMISSION" SEER_AUTOFIX_START = "SEER_AUTOFIX_START" APP_MENTION = "APP_MENTION" + DIRECT_MESSAGE = "DIRECT_MESSAGE" + ASSISTANT_THREAD_STARTED = "ASSISTANT_THREAD_STARTED" # Automatic behaviors PROCESS_SHARED_LINK = "PROCESS_SHARED_LINK" @@ -112,11 +114,10 @@ class MessageInteractionFailureReason(StrEnum): MISSING_ACTION = "missing_action" -class AppMentionHaltReason(StrEnum): - """Reasons why an app mention 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" - ORGANIZATION_NOT_ACTIVE = "organization_not_active" - FEATURE_NOT_ENABLED = "feature_not_enabled" + 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 0108de382649da..60ce917082f191 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, + renderable: SlackRenderable, + slack_user_id: str, + thread_ts: str | None, + ) -> 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 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) + 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, *, @@ -286,6 +316,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[dict[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 @@ -319,6 +377,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/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..fa80982f7a6eb5 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"] @@ -60,12 +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: + if self.is_assistant_thread_event: + return self.dm_data.get("assistant_thread", {}).get("user_id", "") return self.dm_data.get("user", "") + @property + 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", "") + + @property + def has_assistant_scope(self) -> bool: + return SlackScope.ASSISTANT_WRITE in self.integration.metadata.get("scopes", []) + + @property + def is_assistant_thread_event(self) -> bool: + 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/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.""" 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 d43eaf046bc2d3..37baa79a00bb0d 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -3,11 +3,10 @@ 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 -from rest_framework.exceptions import NotFound from rest_framework.request import Request from rest_framework.response import Response from slack_sdk.errors import SlackApiError @@ -18,9 +17,9 @@ from sentry.api.base import all_silo_endpoint from sentry.constants import ObjectStatus from sentry.integrations.messaging.metrics import ( - AppMentionHaltReason, MessagingInteractionEvent, MessagingInteractionType, + SeerSlackHaltReason, ) from sentry.integrations.services.integration import integration_service from sentry.integrations.slack.analytics import SlackIntegrationChartUnfurl @@ -34,9 +33,12 @@ 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.messaging import send_identity_link_prompt from sentry.seer.entrypoints.slack.tasks import process_mention_for_slack from .base import SlackDMEndpoint @@ -44,6 +46,42 @@ _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...", + "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...", +] +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): @@ -330,68 +368,112 @@ 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"), - } - ) + def _resolve_seer_organization(self, slack_request: SlackEventRequest) -> SeerResolutionResult: + """ + Resolve and validate an organization/user for a Seer Slack event. - ois = integration_service.get_organization_integrations( - integration_id=slack_request.integration.id, - status=ObjectStatus.ACTIVE, - limit=1, + 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, ) - if not ois: - lifecycle.record_halt(AppMentionHaltReason.NO_ORGANIZATION) - return self.respond() + 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 - organization_id = ois[0].organization_id - lifecycle.add_extra("organization_id", organization_id) + 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) - 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() + result["organization_id"] = organization_id + result["installation"] = installation + return result - if not features.has("organizations:seer-slack-explorer", organization): - lifecycle.record_halt(AppMentionHaltReason.FEATURE_NOT_ENABLED) - return self.respond() + result["error_reason"] = SeerSlackHaltReason.NO_VALID_ORGANIZATION + return result + def _handle_seer_prompt( + self, + slack_request: SlackEventRequest, + interaction_type: MessagingInteractionType, + ) -> Response: + """Shared handler for app mentions and DMs that trigger the Seer Explorer agent.""" + with MessagingInteractionEvent( + interaction_type=interaction_type, + spec=SlackMessagingSpec(), + ).capture() as lifecycle: + data = slack_request.data.get("event", {}) channel_id = data.get("channel") text = data.get("text") - ts = data.get("ts") - thread_ts = data.get("thread_ts") # None for top-level messages - + ts = data.get("ts") or data.get("message_ts") + thread_ts = slack_request.thread_ts or None lifecycle.add_extras( { + "integration_id": slack_request.integration.id, + "thread_ts": thread_ts, "channel_id": channel_id, "text": text, "ts": ts, - "thread_ts": thread_ts, - "user_id": slack_request.user_id, } ) + result = self._resolve_seer_organization(slack_request) + if result["error_reason"]: + lifecycle.record_halt(result["error_reason"]) + return self.respond() + + if not result["organization_id"] or not result["installation"]: + return self.respond() + + organization_id = result["organization_id"] + installation = result["installation"] + if not channel_id or not text or not ts or not slack_request.user_id: - lifecycle.record_halt(AppMentionHaltReason.MISSING_EVENT_DATA) + lifecycle.record_halt(SeerSlackHaltReason.MISSING_EVENT_DATA) return self.respond() try: @@ -399,16 +481,7 @@ def on_app_mention(self, slack_request: SlackDMRequest) -> Response: 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( @@ -437,10 +510,68 @@ def on_app_mention(self, slack_request: SlackDMRequest) -> Response: ) return self.respond() + def on_app_mention(self, slack_request: SlackEventRequest) -> Response: + return self._handle_seer_prompt(slack_request, MessagingInteractionType.APP_MENTION) + + 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: + """Handle assistant_thread_started events by sending suggested prompts.""" + with MessagingInteractionEvent( + interaction_type=MessagingInteractionType.ASSISTANT_THREAD_STARTED, + 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"]) + return self.respond() + + if not result["installation"]: + return self.respond() + + installation = result["installation"] + + 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( + { + "channel_id": channel_id, + "thread_ts": thread_ts, + "context": assistant_thread.get("context"), + } + ) + + if not channel_id or not thread_ts: + lifecycle.record_halt(SeerSlackHaltReason.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=_SEER_STARTING_PROMPTS, + ) + 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: - 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) @@ -457,18 +588,23 @@ 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() command, _ = slack_request.get_command_and_args() - if command in COMMANDS: + 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: resp = super().post_dispatcher(slack_request) - else: resp = 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/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index 5ed6c48d2c1942..4521eb421d8ea2 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,69 @@ 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, + slack_user_id: str, + channel_id: str, + thread_ts: str | None, + is_welcome_message: bool = False, +) -> 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 + + # 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. + # 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, + channel_id=channel_id, + response_url=None, + ) + 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), + ActionsBlock( + elements=[ + ButtonElement(text="Cancel", value="ignore"), + LinkButtonElement( + text="Link", + url=associate_url, + style="primary", + action_id=SlackAction.LINK_IDENTITY.value, + ), + ] + ), + ], + text=message, + ) + 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, + }, + ) diff --git a/tests/sentry/integrations/slack/webhooks/events/__init__.py b/tests/sentry/integrations/slack/webhooks/events/__init__.py index 660c17593e1594..26ac492f40b81c 100644 --- a/tests/sentry/integrations/slack/webhooks/events/__init__.py +++ b/tests/sentry/integrations/slack/webhooks/events/__init__.py @@ -1,13 +1,24 @@ +from typing import Any from unittest.mock import patch import orjson from sentry.testutils.cases import APITestCase from sentry.testutils.helpers import install_slack +from sentry.testutils.silo import assume_test_silo_mode_of +from sentry.users.models.identity import Identity +from sentry.users.models.user import User UNSET = object() -LINK_SHARED_EVENT = """{ +SEER_EXPLORER_FEATURES = { + "organizations:seer-slack-explorer": True, + "organizations:gen-ai-features": True, + "organizations:seer-explorer": True, +} + + +LINK_SHARED_EVENT: dict[str, Any] = { "type": "link_shared", "channel": "Cxxxxxx", "channel_name": "general", @@ -15,20 +26,17 @@ "message_ts": "123456789.9875", "team_id": "TXXXXXXX1", "links": [ + {"domain": "example.com", "url": "http://testserver/organizations/test-org/issues/foo/"}, { "domain": "example.com", - "url": "http://testserver/organizations/test-org/issues/foo/" + "url": "http://testserver/organizations/test-org/issues/bar/baz/", }, { "domain": "example.com", - "url": "http://testserver/organizations/test-org/issues/bar/baz/" + "url": "http://testserver/organizations/test-org/issues/bar/baz/", }, - { - "domain": "example.com", - "url": "http://testserver/organizations/test-org/issues/bar/baz/" - } - ] -}""" + ], +} def build_test_block(link): @@ -51,6 +59,18 @@ class BaseEventTest(APITestCase): def setUp(self) -> None: super().setUp() self.integration = install_slack(self.organization) + self.idp = self.create_identity_provider( + type="slack", external_id=self.integration.external_id + ) + + def link_identity(self, slack_user_id: str, user: User | None = None): + self.create_identity( + user=user or self.user, identity_provider=self.idp, external_id=slack_user_id + ) + + def unlink_identity(self, user=None): + with assume_test_silo_mode_of(Identity): + Identity.objects.filter(user=user or self.user).delete() @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 a04cdc769cea21..0795fd0a6314ab 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,9 @@ 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 +from . import SEER_EXPLORER_FEATURES, BaseEventTest APP_MENTION_EVENT = { "type": "app_mention", @@ -13,21 +13,19 @@ "ts": "1234567890.123456", "event_ts": "1234567890.123456", } - -AUTHORIZATIONS_DATA = { - "authorizations": [{"user_id": "U0BOT", "is_bot": True}], -} - -THREADED_APP_MENTION_EVENT = { - **APP_MENTION_EVENT, - "thread_ts": "1234567890.000001", -} +THREADED_APP_MENTION_EVENT = {**APP_MENTION_EVENT, "thread_ts": "1234567890.000001"} +AUTHORIZATIONS_DATA = {"authorizations": [{"user_id": "U0BOT", "is_bot": True}]} class AppMentionEventTest(BaseEventTest): + def setUp(self): + super().setUp() + # Tests default to having the mention come from a linked user. + self.link_identity(user=self.user, slack_user_id=APP_MENTION_EVENT["user"]) + @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 ) @@ -37,16 +35,16 @@ def test_app_mention_dispatches_task(self, mock_apply_async): 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"] == "C1234567890" - assert kwargs["ts"] == "1234567890.123456" - assert kwargs["thread_ts"] == "1234567890.000001" + assert kwargs["channel_id"] == THREADED_APP_MENTION_EVENT["channel"] + assert kwargs["ts"] == THREADED_APP_MENTION_EVENT["ts"] + assert kwargs["thread_ts"] == THREADED_APP_MENTION_EVENT["thread_ts"] assert kwargs["text"] == THREADED_APP_MENTION_EVENT["text"] - assert kwargs["slack_user_id"] == "U1234567890" - assert kwargs["bot_user_id"] == "U0BOT" + assert kwargs["slack_user_id"] == THREADED_APP_MENTION_EVENT["user"] + assert kwargs["bot_user_id"] == AUTHORIZATIONS_DATA["authorizations"][0]["user_id"] @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 +55,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 @@ -66,51 +64,69 @@ 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.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") - def test_app_mention_feature_flag_disabled(self, mock_apply_async, mock_record): - 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, AppMentionHaltReason.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_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 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") - 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, AppMentionHaltReason.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 self.feature("organizations:seer-slack-explorer"): - resp = self.post_webhook(event_data=APP_MENTION_EVENT) + def test_app_mention_no_explorer_access(self, mock_apply_async, mock_record): + 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.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_no_identity_prompt_linkage( + self, mock_apply_async, mock_send_link, mock_record + ): + self.unlink_identity() + + 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_linked_user_not_org_member(self, mock_apply_async, mock_record): + self.unlink_identity() + + other_user = self.create_user() + self.link_identity(user=other_user, slack_user_id=APP_MENTION_EVENT["user"]) + + 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, AppMentionHaltReason.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 new file mode 100644 index 00000000000000..473b6f292816a6 --- /dev/null +++ b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py @@ -0,0 +1,136 @@ +from typing import Any +from unittest.mock import patch + +from sentry.integrations.messaging.metrics import SeerSlackHaltReason +from sentry.testutils.asserts import assert_halt_metric + +from . import SEER_EXPLORER_FEATURES, BaseEventTest + +ASSISTANT_THREAD: dict[str, Any] = { + "user_id": "U1234567890", + "context": { + "channel_id": "C1234567890", + "team_id": "T0123456789", + "enterprise_id": "E1234567890", + }, + "channel_id": "D1234567890", + "thread_ts": "1234567890.123456", +} +ASSISTANT_THREAD_STARTED_EVENT = { + "type": "assistant_thread_started", + "assistant_thread": ASSISTANT_THREAD, + "event_ts": "1234567890.123456", +} + + +class AssistantThreadStartedEventTest(BaseEventTest): + def setUp(self): + super().setUp() + self.link_identity(slack_user_id=ASSISTANT_THREAD["user_id"]) + + @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") + def test_sends_suggested_prompts(self, mock_set_prompts): + 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_called_once() + kwargs = mock_set_prompts.call_args[1] + assert kwargs["channel_id"] == ASSISTANT_THREAD["channel_id"] + assert kwargs["thread_ts"] == ASSISTANT_THREAD["thread_ts"] + assert len(kwargs["prompts"]) == 4 + assert kwargs["title"] + + @patch("sentry.integrations.slack.integration.SlackIntegration.set_suggested_prompts") + def test_prompt_titles_and_messages(self, mock_set_prompts): + with self.feature(SEER_EXPLORER_FEATURES): + 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.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): + self.unlink_identity() + 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): + 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_VALID_ORGANIZATION) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @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", + return_value=[], + ): + 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_VALID_INTEGRATION) + + @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(SEER_EXPLORER_FEATURES): + resp = self.post_webhook(event_data=event_data) + + assert resp.status_code == 200 + mock_set_prompts.assert_not_called() + 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") + 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(SEER_EXPLORER_FEATURES): + resp = self.post_webhook(event_data=event_data) + + assert resp.status_code == 200 + mock_set_prompts.assert_not_called() + assert_halt_metric(mock_record, SeerSlackHaltReason.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(SEER_EXPLORER_FEATURES): + resp = self.post_webhook(event_data=ASSISTANT_THREAD_STARTED_EVENT) + + assert resp.status_code == 200 + mock_set_prompts.assert_called_once() diff --git a/tests/sentry/integrations/slack/webhooks/events/test_direct_message.py b/tests/sentry/integrations/slack/webhooks/events/test_direct_message.py new file mode 100644 index 00000000000000..e7f5b623ea4b7c --- /dev/null +++ b/tests/sentry/integrations/slack/webhooks/events/test_direct_message.py @@ -0,0 +1,122 @@ +from unittest.mock import patch + +import pytest + +from sentry.integrations.messaging.metrics import SeerSlackHaltReason +from sentry.integrations.models.integration import Integration +from sentry.integrations.slack.utils.constants import SlackScope +from sentry.testutils.asserts import assert_halt_metric +from sentry.testutils.silo import assume_test_silo_mode_of + +from . import SEER_EXPLORER_FEATURES, BaseEventTest + +MESSAGE_DM_EVENT = { + "type": "message", + "channel": "D1234567890", + "user": "U1234567890", + "text": "What is causing errors in my project?", + "ts": "1234567890.123456", +} +THREADED_MESSAGE_DM_EVENT = {**MESSAGE_DM_EVENT, "thread_ts": "1234567890.000001"} +AUTHORIZATIONS_DATA = {"authorizations": [{"user_id": "U0BOT", "is_bot": True}]} + + +class DirectMessageTest(BaseEventTest): + """ + 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_direct_message instead of the help message handler. + """ + + def setUp(self): + super().setUp() + with assume_test_silo_mode_of(Integration): + self.integration.metadata["scopes"] = [SlackScope.ASSISTANT_WRITE] + self.integration.save() + + self.link_identity(slack_user_id=MESSAGE_DM_EVENT["user"]) + + @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.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_dispatches_task(self, mock_apply_async): + with self.feature(SEER_EXPLORER_FEATURES): + resp = self.post_webhook(event_data=MESSAGE_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"] == MESSAGE_DM_EVENT["channel"] + assert kwargs["ts"] == MESSAGE_DM_EVENT["ts"] + assert kwargs["thread_ts"] is None + assert kwargs["text"] == MESSAGE_DM_EVENT["text"] + assert kwargs["slack_user_id"] == MESSAGE_DM_EVENT["user"] + assert kwargs["bot_user_id"] == AUTHORIZATIONS_DATA["authorizations"][0]["user_id"] + + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_threaded_dispatches_task(self, mock_apply_async): + with self.feature(SEER_EXPLORER_FEATURES): + resp = self.post_webhook(event_data=THREADED_MESSAGE_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["ts"] == MESSAGE_DM_EVENT["ts"] + 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.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.""" + self.unlink_identity() + with self.feature(SEER_EXPLORER_FEATURES): + resp = self.post_webhook(event_data=MESSAGE_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"] == MESSAGE_DM_EVENT["user"] + 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(self, mock_apply_async, mock_record): + resp = self.post_webhook(event_data=MESSAGE_DM_EVENT) + + assert resp.status_code == 200 + mock_apply_async.assert_not_called() + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_ORGANIZATION) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_no_integration(self, mock_apply_async, mock_record): + with patch( + "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", + return_value=[], + ): + with self.feature(SEER_EXPLORER_FEATURES): + resp = self.post_webhook(event_data=MESSAGE_DM_EVENT) + + assert resp.status_code == 200 + mock_apply_async.assert_not_called() + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_INTEGRATION) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_empty_text(self, mock_apply_async, mock_record): + event_data = {**MESSAGE_DM_EVENT, "text": ""} + with self.feature(SEER_EXPLORER_FEATURES): + resp = self.post_webhook(event_data=event_data) + + assert resp.status_code == 200 + mock_apply_async.assert_not_called() + assert_halt_metric(mock_record, SeerSlackHaltReason.MISSING_EVENT_DATA) diff --git a/tests/sentry/integrations/slack/webhooks/events/test_discover_link_shared.py b/tests/sentry/integrations/slack/webhooks/events/test_discover_link_shared.py index 797f7d890b3402..2b0788e8ba0800 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_discover_link_shared.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_discover_link_shared.py @@ -1,4 +1,5 @@ import re +from typing import Any from unittest.mock import Mock, patch from urllib.parse import parse_qsl @@ -8,33 +9,27 @@ from slack_sdk.web import SlackResponse from sentry.integrations.slack.unfurl.types import Handler, LinkType, make_type_coercer -from sentry.silo.base import SiloMode -from sentry.testutils.silo import assume_test_silo_mode -from sentry.users.models.identity import Identity, IdentityStatus from . import LINK_SHARED_EVENT, BaseEventTest -LINK_SHARED_EVENT_NO_CHANNEL_NAME = """{ +LINK_SHARED_EVENT_NO_CHANNEL_NAME: dict[str, Any] = { "type": "link_shared", "channel": "Cxxxxxx", "user": "Uxxxxxxx", "message_ts": "123456789.9875", "team_id": "TXXXXXXX1", "links": [ + {"domain": "example.com", "url": "http://testserver/organizations/test-org/issues/foo/"}, { "domain": "example.com", - "url": "http://testserver/organizations/test-org/issues/foo/" + "url": "http://testserver/organizations/test-org/issues/bar/baz/", }, { "domain": "example.com", - "url": "http://testserver/organizations/test-org/issues/bar/baz/" + "url": "http://testserver/organizations/test-org/issues/bar/baz/", }, - { - "domain": "example.com", - "url": "http://testserver/organizations/test-org/issues/bar/baz/" - } - ] -}""" + ], +} class DiscoverLinkSharedEvent(BaseEventTest): @@ -96,7 +91,7 @@ def share_discover_links(self, mock_match_link, mock_): responses.add(responses.POST, "https://slack.com/api/chat.postEphemeral", json={"ok": True}) responses.add(responses.POST, "https://slack.com/api/chat.unfurl", json={"ok": True}) - resp = self.post_webhook(event_data=orjson.loads(LINK_SHARED_EVENT)) + resp = self.post_webhook(event_data=LINK_SHARED_EVENT) assert resp.status_code == 200, resp.content data = responses.calls[0].request.body @@ -124,7 +119,7 @@ def share_discover_links(self, mock_match_link, mock_): }, ) def share_discover_links_sdk(self, mock_match_link, mock_): - resp = self.post_webhook(event_data=orjson.loads(LINK_SHARED_EVENT)) + resp = self.post_webhook(event_data=LINK_SHARED_EVENT) assert resp.status_code == 200, resp.content return self.mock_unfurl.call_args[1] @@ -151,14 +146,12 @@ def share_discover_links_sdk(self, mock_match_link, mock_): }, ) def share_discover_links_ephermeral_sdk(self, mock_match_link, mock_): - resp = self.post_webhook(event_data=orjson.loads(LINK_SHARED_EVENT)) + resp = self.post_webhook(event_data=LINK_SHARED_EVENT) assert resp.status_code == 200, resp.content return self.mock_post.call_args[1] def test_share_discover_links_unlinked_user_sdk(self) -> None: - with assume_test_silo_mode(SiloMode.CONTROL): - self.create_identity_provider(type="slack", external_id="TXXXXXXX1") with self.feature("organizations:discover-basic"): data = self.share_discover_links_ephermeral_sdk() @@ -176,36 +169,24 @@ def test_share_discover_links_unlinked_user_sdk(self) -> None: @responses.activate def test_share_discover_links_unlinked_user_no_channel(self) -> None: - with assume_test_silo_mode(SiloMode.CONTROL): - self.create_identity_provider(type="slack", external_id="TXXXXXXX1") with self.feature("organizations:discover-basic"): responses.add( responses.POST, "https://slack.com/api/chat.postEphemeral", json={"ok": True} ) responses.add(responses.POST, "https://slack.com/api/chat.unfurl", json={"ok": True}) - resp = self.post_webhook(event_data=orjson.loads(LINK_SHARED_EVENT_NO_CHANNEL_NAME)) + resp = self.post_webhook(event_data=LINK_SHARED_EVENT_NO_CHANNEL_NAME) assert resp.status_code == 200, resp.content assert len(responses.calls) == 0 def test_share_discover_links_unlinked_user_no_channel_sdk(self) -> None: - with assume_test_silo_mode(SiloMode.CONTROL): - self.create_identity_provider(type="slack", external_id="TXXXXXXX1") with self.feature("organizations:discover-basic"): - resp = self.post_webhook(event_data=orjson.loads(LINK_SHARED_EVENT_NO_CHANNEL_NAME)) + resp = self.post_webhook(event_data=LINK_SHARED_EVENT_NO_CHANNEL_NAME) assert resp.status_code == 200, resp.content assert len(self.mock_post.mock_calls) == 0 def test_share_discover_links_linked_user_sdk(self) -> None: - with assume_test_silo_mode(SiloMode.CONTROL): - idp = self.create_identity_provider(type="slack", external_id="TXXXXXXX1") - Identity.objects.create( - external_id="Uxxxxxxx", - idp=idp, - user=self.user, - status=IdentityStatus.VALID, - scopes=[], - ) + self.link_identity(slack_user_id=LINK_SHARED_EVENT_NO_CHANNEL_NAME["user"]) data = self.share_discover_links_sdk() unfurls = data["unfurls"] diff --git a/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py b/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py index 189944cc9c538e..ec7e6eba104ace 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py @@ -6,9 +6,6 @@ from slack_sdk.web import SlackResponse from sentry.integrations.slack.unfurl.types import Handler, LinkType, make_type_coercer -from sentry.silo.base import SiloMode -from sentry.testutils.silo import assume_test_silo_mode -from sentry.users.models.identity import Identity, IdentityStatus from . import LINK_SHARED_EVENT, BaseEventTest @@ -66,7 +63,7 @@ def mock_chat_unfurl(self): }, ) def share_explore_links_sdk(self, mock_match_link, mock_): - resp = self.post_webhook(event_data=orjson.loads(LINK_SHARED_EVENT)) + resp = self.post_webhook(event_data=LINK_SHARED_EVENT) assert resp.status_code == 200, resp.content return self.mock_unfurl.call_args[1] @@ -90,13 +87,11 @@ def share_explore_links_sdk(self, mock_match_link, mock_): }, ) def share_explore_links_ephemeral_sdk(self, mock_match_link, mock_): - resp = self.post_webhook(event_data=orjson.loads(LINK_SHARED_EVENT)) + resp = self.post_webhook(event_data=LINK_SHARED_EVENT) assert resp.status_code == 200, resp.content return self.mock_post.call_args[1] def test_share_explore_links_unlinked_user(self) -> None: - with assume_test_silo_mode(SiloMode.CONTROL): - self.create_identity_provider(type="slack", external_id="TXXXXXXX1") with self.feature("organizations:data-browsing-widget-unfurl"): data = self.share_explore_links_ephemeral_sdk() @@ -113,15 +108,7 @@ def test_share_explore_links_unlinked_user(self) -> None: assert [button["text"]["text"] for button in blocks[1]["elements"]] == ["Link", "Cancel"] def test_share_explore_links_linked_user(self) -> None: - with assume_test_silo_mode(SiloMode.CONTROL): - idp = self.create_identity_provider(type="slack", external_id="TXXXXXXX1") - Identity.objects.create( - external_id="Uxxxxxxx", - idp=idp, - user=self.user, - status=IdentityStatus.VALID, - scopes=[], - ) + self.link_identity(slack_user_id=LINK_SHARED_EVENT["user"]) data = self.share_explore_links_sdk() unfurls = data["unfurls"] diff --git a/tests/sentry/integrations/slack/webhooks/events/test_link_shared.py b/tests/sentry/integrations/slack/webhooks/events/test_link_shared.py index 3ccf5b333bf59f..caf5d341a1699e 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_link_shared.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_link_shared.py @@ -1,7 +1,6 @@ import re from unittest.mock import Mock, patch -import orjson import pytest import responses from slack_sdk.errors import SlackApiError @@ -54,7 +53,7 @@ def mock_chat_unfurlMessage(self): }, ) def test_share_links_sdk(self, mock_match_link, mock_record) -> None: - resp = self.post_webhook(event_data=orjson.loads(LINK_SHARED_EVENT)) + resp = self.post_webhook(event_data=LINK_SHARED_EVENT) assert resp.status_code == 200, resp.content assert len(mock_match_link.mock_calls) == 3 @@ -84,15 +83,15 @@ def test_share_links_sdk(self, mock_match_link, mock_record) -> None: arg_mapper=make_type_coercer({}), fn=Mock( return_value={ - "link1": build_test_block(LINK_SHARED_EVENT[0]), - "link2": build_test_block(LINK_SHARED_EVENT[1]), + "link1": build_test_block(LINK_SHARED_EVENT["links"][0]["url"]), + "link2": build_test_block(LINK_SHARED_EVENT["links"][1]["url"]), } ), ) }, ) def test_share_links_block_kit_sdk(self, mock_match_link, mock_record) -> None: - resp = self.post_webhook(event_data=orjson.loads(LINK_SHARED_EVENT)) + resp = self.post_webhook(event_data=LINK_SHARED_EVENT) assert resp.status_code == 200, resp.content assert len(mock_match_link.mock_calls) == 3 @@ -101,9 +100,9 @@ def test_share_links_block_kit_sdk(self, mock_match_link, mock_record) -> None: # We only have two unfurls since one link was duplicated assert len(unfurls) == 2 - result1 = build_test_block(LINK_SHARED_EVENT[0]) + result1 = build_test_block(LINK_SHARED_EVENT["links"][0]["url"]) del result1["text"] - result2 = build_test_block(LINK_SHARED_EVENT[1]) + result2 = build_test_block(LINK_SHARED_EVENT["links"][1]["url"]) del result2["text"] assert unfurls["link1"] == result1 assert unfurls["link2"] == result2 @@ -128,8 +127,8 @@ def test_share_links_block_kit_sdk(self, mock_match_link, mock_record) -> None: arg_mapper=make_type_coercer({}), fn=Mock( return_value={ - "link1": build_test_block(LINK_SHARED_EVENT[0]), - "link2": build_test_block(LINK_SHARED_EVENT[1]), + "link1": build_test_block(LINK_SHARED_EVENT["links"][0]["url"]), + "link2": build_test_block(LINK_SHARED_EVENT["links"][1]["url"]), } ), ) @@ -150,14 +149,14 @@ def test_share_links_failure(self, mock_match_link, mock_record) -> None: status_code=200, ), ) - resp = self.post_webhook(event_data=orjson.loads(LINK_SHARED_EVENT)) + resp = self.post_webhook(event_data=LINK_SHARED_EVENT) assert resp.status_code == 200, resp.content assert_slo_metric_calls(mock_record.mock_calls[-2:], EventLifecycleOutcome.FAILURE) def test_share_links_no_matches(self, mock_record) -> None: - event_data = orjson.loads(LINK_SHARED_EVENT) + event_data = {**LINK_SHARED_EVENT} event_data["links"] = [{}] resp = self.post_webhook(event_data=event_data) assert resp.status_code == 200, resp.content 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..cfe2131c06e831 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py @@ -5,12 +5,9 @@ from slack_sdk.web import SlackResponse from sentry.integrations.types import EventLifecycleOutcome -from sentry.silo.base import SiloMode from sentry.testutils.asserts import 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 -from sentry.users.models.identity import Identity, IdentityStatus from . import BaseEventTest @@ -56,6 +53,11 @@ class MessageIMEventTest(BaseEventTest, IntegratedApiTestCase): + """ + Tests for legacy messages to bot that would be interpreted as commands. + This will be superceded by the explorer agentic workflow. + """ + def get_block_section_text(self, data): blocks = data["blocks"] return blocks[0]["text"]["text"], blocks[1]["text"]["text"] @@ -99,9 +101,6 @@ def test_user_message_link(self, mock_record: MagicMock) -> None: """ Test that when a user types in "link" to the DM we reply with the correct response. """ - with assume_test_silo_mode(SiloMode.CONTROL): - self.create_identity_provider(type="slack", external_id="TXXXXXXX1") - resp = self.post_webhook(event_data=orjson.loads(MESSAGE_IM_EVENT_LINK)) assert resp.status_code == 200, resp.content @@ -115,15 +114,7 @@ def test_user_message_already_linked_sdk(self) -> None: Test that when a user who has already linked their identity types in "link" to the DM we reply with the correct response. """ - with assume_test_silo_mode(SiloMode.CONTROL): - idp = self.create_identity_provider(type="slack", external_id="TXXXXXXX1") - Identity.objects.create( - external_id="UXXXXXXX1", - idp=idp, - user=self.user, - status=IdentityStatus.VALID, - scopes=[], - ) + self.create_identity(user=self.user, identity_provider=self.idp, external_id="UXXXXXXX1") resp = self.post_webhook(event_data=orjson.loads(MESSAGE_IM_EVENT_LINK)) assert resp.status_code == 200, resp.content @@ -135,15 +126,7 @@ def test_user_message_unlink(self) -> None: """ Test that when a user types in "unlink" to the DM we reply with the correct response. """ - with assume_test_silo_mode(SiloMode.CONTROL): - idp = self.create_identity_provider(type="slack", external_id="TXXXXXXX1") - Identity.objects.create( - external_id="UXXXXXXX1", - idp=idp, - user=self.user, - status=IdentityStatus.VALID, - scopes=[], - ) + self.create_identity(user=self.user, identity_provider=self.idp, external_id="UXXXXXXX1") resp = self.post_webhook(event_data=orjson.loads(MESSAGE_IM_EVENT_UNLINK)) assert resp.status_code == 200, resp.content @@ -156,9 +139,6 @@ def test_user_message_already_unlinked(self) -> None: Test that when a user without an Identity types in "unlink" to the DM we reply with the correct response. """ - with assume_test_silo_mode(SiloMode.CONTROL): - self.create_identity_provider(type="slack", external_id="TXXXXXXX1") - resp = self.post_webhook(event_data=orjson.loads(MESSAGE_IM_EVENT_UNLINK)) assert resp.status_code == 200, resp.content 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)