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..e3f33675bff0c0 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 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..7ceaf0f0cae67a 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 | None: + 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..54d8ba73be9394 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, + 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, ) - 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 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/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/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..b17d72db9f8dfc 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, + 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 + 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/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/integrations/slack/webhooks/events/__init__.py b/tests/sentry/integrations/slack/webhooks/events/__init__.py index 660c17593e1594..4a3a90a12e0a39 100644 --- a/tests/sentry/integrations/slack/webhooks/events/__init__.py +++ b/tests/sentry/integrations/slack/webhooks/events/__init__.py @@ -2,11 +2,20 @@ 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() +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", @@ -52,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 a04cdc769cea21..9b72c2123d83f9 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 AppMentionHaltReason +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,8 @@ 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"): + self.link_identity() + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook( event_data=THREADED_APP_MENTION_EVENT, data=AUTHORIZATIONS_DATA ) @@ -46,7 +48,8 @@ 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"): + self.link_identity() + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=THREADED_APP_MENTION_EVENT) assert resp.status_code == 200 @@ -57,7 +60,8 @@ 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"): + self.link_identity() + with self.feature(SEER_EXPLORER_FEATURES): resp = self.post_webhook(event_data=APP_MENTION_EVENT) assert resp.status_code == 200 @@ -66,51 +70,86 @@ 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 mock_apply_async.assert_not_called() - assert_halt_metric(mock_record, AppMentionHaltReason.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): + self.link_identity() 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.""" + self.link_identity() 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, + self.link_identity() + 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, AppMentionHaltReason.ORGANIZATION_NOT_FOUND) + 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() + self.link_identity(user=other_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, 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..be9a38123ce837 --- /dev/null +++ b/tests/sentry/integrations/slack/webhooks/events/test_assistant_thread_started.py @@ -0,0 +1,137 @@ +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_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): + self.link_identity() + 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"] == "D1234567890" + assert kwargs["thread_ts"] == "1729999327.187299" + 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): + self.link_identity() + 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): + """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 + 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): + self.link_identity() + 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): + self.link_identity() + 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): + self.link_identity() + 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.""" + self.link_identity() + 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_message_im.py b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py index afb5b9c7c0089c..2f2c38c609c592 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_message_im.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_message_im.py @@ -4,15 +4,16 @@ import pytest from slack_sdk.web import SlackResponse +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_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 from sentry.users.models.identity import Identity, IdentityStatus -from . import BaseEventTest +from . import SEER_EXPLORER_FEATURES, BaseEventTest MESSAGE_IM_EVENT = """{ "type": "message", @@ -173,3 +174,138 @@ 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. + + 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_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): + 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) + + 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): + 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 + ) + + 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): + self.link_identity(slack_user_id="Uxxxxxxx") + with self.feature(SEER_EXPLORER_FEATURES): + resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) + + assert resp.status_code == 200 + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["bot_user_id"] == "" + + @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(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 + 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): + self.link_identity(slack_user_id="Uxxxxxxx") + with patch( + "sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations", + return_value=[], + ): + with self.feature(SEER_EXPLORER_FEATURES): + resp = self.post_webhook(event_data=MESSAGE_IM_DM_EVENT) + + assert resp.status_code == 200 + mock_apply_async.assert_not_called() + assert_halt_metric(mock_record, SeerSlackHaltReason.NO_VALID_INTEGRATION) + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async") + def test_dm_empty_text(self, mock_apply_async, mock_record): + 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) + + 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/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) 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)