Skip to content
7 changes: 4 additions & 3 deletions src/sentry/features/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from sentry.features.manager import FeatureCheckBatch
from sentry.models.organization import Organization
from sentry.models.project import Project
from sentry.organizations.services.organization.model import RpcOrganization
from sentry.users.models.user import User
from sentry.users.services.user import RpcUser

Expand Down Expand Up @@ -59,15 +60,15 @@ def batch_has(
feature_names: Sequence[str],
actor: User | RpcUser | AnonymousUser | None,
projects: Sequence[Project] | None = None,
organization: Organization | None = None,
organization: Organization | RpcOrganization | None = None,
batch: bool = True,
) -> dict[str, dict[str, bool | None]] | None:
raise NotImplementedError

def batch_has_for_organizations(
self,
feature_name: str,
organizations: Sequence[Organization],
organizations: Sequence[Organization | RpcOrganization],
) -> dict[str, bool] | None:
raise NotImplementedError

Expand Down Expand Up @@ -97,7 +98,7 @@ class BatchFeatureHandler(FeatureHandler):
def _check_for_batch(
self,
feature_name: str,
entity: Organization | User | RpcUser | AnonymousUser | None,
entity: Organization | RpcOrganization | User | RpcUser | AnonymousUser | None,
actor: User | RpcUser | AnonymousUser | None,
) -> bool | None:
raise NotImplementedError
Expand Down
3 changes: 2 additions & 1 deletion src/sentry/features/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from sentry.features.handler import FeatureHandler
from sentry.models.organization import Organization
from sentry.models.project import Project
from sentry.organizations.services.organization.model import RpcOrganization
from sentry.users.models.user import User


Expand Down Expand Up @@ -321,7 +322,7 @@ def batch_has(
feature_names: Sequence[str],
actor: User | RpcUser | AnonymousUser | None = None,
projects: Sequence[Project] | None = None,
organization: Organization | None = None,
organization: RpcOrganization | Organization | None = None,
) -> dict[str, dict[str, bool | None]] | None:
"""
Determine if multiple features are enabled. Unhandled flags will not be in
Expand Down
75 changes: 74 additions & 1 deletion src/sentry/integrations/slack/requests/event.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
from __future__ import annotations

from collections.abc import Mapping
from typing import Any
from typing import Any, NamedTuple

from sentry.constants import ObjectStatus
from sentry.integrations.messaging.metrics import SeerSlackHaltReason
from sentry.integrations.services.integration import integration_service
from sentry.integrations.slack.requests.base import SlackDMRequest, SlackRequestError
from sentry.integrations.slack.unfurl.handlers import match_link
from sentry.integrations.slack.unfurl.types import LinkType
from sentry.integrations.slack.utils.constants import SlackScope
from sentry.integrations.types import IntegrationProviderSlug
from sentry.models.organization import OrganizationStatus
from sentry.organizations.services.organization.service import organization_service
from sentry.seer.entrypoints.slack.entrypoint import SlackExplorerEntrypoint
from sentry.silo.base import all_silo_function

COMMANDS = ["link", "unlink", "link team", "unlink team"]
SLACK_PROVIDERS = [IntegrationProviderSlug.SLACK, IntegrationProviderSlug.SLACK_STAGING]


def has_discover_links(links: list[str]) -> bool:
Expand All @@ -23,6 +32,11 @@ def is_event_challenge(data: Mapping[str, Any]) -> bool:
return data.get("type", "") == "url_verification"


class SeerResolutionResult(NamedTuple):
organization_id: int | None
error_reason: SeerSlackHaltReason | None


class SlackEventRequest(SlackDMRequest):
"""
An Event request sent from Slack.
Expand Down Expand Up @@ -55,6 +69,65 @@ def is_challenge(self) -> bool:
"""We need to call this before validation."""
return is_event_challenge(self.request.data)

@property
def is_seer_agent_request(self) -> bool:
return (
self.type == "app_mention"
or self.type == "assistant_thread_started"
or (self.dm_data.get("type") == "message" and self.has_assistant_scope)
)

@all_silo_function
def resolve_seer_organization(self) -> SeerResolutionResult:
"""
Resolve and validate an organization/user for a Seer Slack event.

We require a linked identity, then search for an active, organization they belong to with
Seer Agent access.

Note: There is a limitation here of only grabbing the first organization belonging to the user
with access to Seer. If a Slack installation corresponds to multiple organizations with Seer
access, this will not work as expected. This will be revisited.
"""
identity_user = self.get_identity_user()
if not identity_user:
return SeerResolutionResult(
organization_id=None, error_reason=SeerSlackHaltReason.IDENTITY_NOT_LINKED
)

ois = integration_service.get_organization_integrations(
integration_id=self.integration.id,
status=ObjectStatus.ACTIVE,
providers=SLACK_PROVIDERS,
)
if not ois:
return SeerResolutionResult(
organization_id=None, error_reason=SeerSlackHaltReason.NO_VALID_INTEGRATION
)

for oi in ois:
organization_id = oi.organization_id
ctx = organization_service.get_organization_by_id(
id=oi.organization_id, user_id=identity_user.id
)
if ctx is None:
continue

if ctx.organization.status != OrganizationStatus.ACTIVE:
continue

if not SlackExplorerEntrypoint.has_access(ctx.organization):
continue

if ctx.member is None:
continue

return SeerResolutionResult(organization_id=organization_id, error_reason=None)

return SeerResolutionResult(
organization_id=None, error_reason=SeerSlackHaltReason.NO_VALID_ORGANIZATION
)

@property
def dm_data(self) -> Mapping[str, Any]:
return self.data.get("event", {})
Expand Down
126 changes: 35 additions & 91 deletions src/sentry/integrations/slack/webhooks/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
from collections import defaultdict
from collections.abc import Mapping
from typing import Any, TypedDict
from typing import Any

import orjson
import sentry_sdk
Expand All @@ -15,7 +15,6 @@
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import all_silo_endpoint
from sentry.constants import ObjectStatus
from sentry.integrations.messaging.metrics import (
MessagingInteractionEvent,
MessagingInteractionType,
Expand All @@ -33,11 +32,8 @@
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.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

Expand Down Expand Up @@ -74,13 +70,6 @@
"Hold on, I've seen this one before...",
"It worked on my machine...",
]
SLACK_PROVIDERS = [IntegrationProviderSlug.SLACK, IntegrationProviderSlug.SLACK_STAGING]


class SeerResolutionResult(TypedDict):
Comment thread
leeandher marked this conversation as resolved.
organization_id: int | None
installation: SlackIntegration | None
error_reason: SeerSlackHaltReason | None


@all_silo_endpoint # Only challenge verification is handled at control
Expand Down Expand Up @@ -368,74 +357,6 @@ def on_link_shared(self, request: Request, slack_request: SlackDMRequest) -> boo

return True

def _resolve_seer_organization(self, slack_request: SlackEventRequest) -> SeerResolutionResult:
"""
Resolve and validate an organization/user for a Seer Slack event.

If the initiating user is not linked, we will reply with a prompt to link their identity.

Then we search for an active, organization with Seer Explorer access. If the user does not
belong to any matched organization, their request will be dropped.

Note: There is a limitation here of only grabbing the first organization belonging to the user
with access to Seer. If a Slack installation corresponds to multiple organizations with Seer
access, this will not work as expected. This will be revisited.
"""
result: SeerResolutionResult = {
"organization_id": None,
"installation": None,
"error_reason": None,
}

identity_user = slack_request.get_identity_user()
if not identity_user:
result["error_reason"] = SeerSlackHaltReason.IDENTITY_NOT_LINKED
send_identity_link_prompt(
integration=slack_request.integration,
slack_user_id=slack_request.user_id,
channel_id=slack_request.channel_id,
thread_ts=slack_request.thread_ts or None,
is_welcome_message=slack_request.is_assistant_thread_event,
)
return result

ois = integration_service.get_organization_integrations(
integration_id=slack_request.integration.id,
status=ObjectStatus.ACTIVE,
providers=SLACK_PROVIDERS,
)
if not ois:
result["error_reason"] = SeerSlackHaltReason.NO_VALID_INTEGRATION
return result

for oi in ois:
organization_id = oi.organization_id
try:
organization = Organization.objects.get_from_cache(id=organization_id)
except Organization.DoesNotExist:
continue

if organization.status != OrganizationStatus.ACTIVE:
continue

if not SlackExplorerEntrypoint.has_access(organization):
continue

if not organization.has_access(identity_user):
continue

installation = slack_request.integration.get_installation(
organization_id=organization_id
)
assert isinstance(installation, SlackIntegration)

result["organization_id"] = organization_id
result["installation"] = installation
return result

result["error_reason"] = SeerSlackHaltReason.NO_VALID_ORGANIZATION
return result

def _handle_seer_prompt(
self,
slack_request: SlackEventRequest,
Expand All @@ -461,16 +382,26 @@ def _handle_seer_prompt(
}
)

result = self._resolve_seer_organization(slack_request)
if result["error_reason"]:
lifecycle.record_halt(result["error_reason"])
organization_id, error_reason = slack_request.resolve_seer_organization()
if error_reason:
lifecycle.record_halt(error_reason)
if 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,
)
return self.respond()

if not result["organization_id"] or not result["installation"]:
if not organization_id:
return self.respond()

organization_id = result["organization_id"]
installation = result["installation"]
installation = slack_request.integration.get_installation(
organization_id=organization_id
)
if not isinstance(installation, SlackIntegration):
return self.respond()

if not channel_id or not text or not ts or not slack_request.user_id:
lifecycle.record_halt(SeerSlackHaltReason.MISSING_EVENT_DATA)
Expand Down Expand Up @@ -523,15 +454,27 @@ def on_assistant_thread_started(self, slack_request: SlackEventRequest) -> Respo
spec=SlackMessagingSpec(),
).capture() as lifecycle:
lifecycle.add_extra("integration_id", slack_request.integration.id)
result = self._resolve_seer_organization(slack_request)
if result["error_reason"]:
lifecycle.record_halt(result["error_reason"])
organization_id, error_reason = slack_request.resolve_seer_organization()
if error_reason:
lifecycle.record_halt(error_reason)
if 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=True,
)
return self.respond()

if not result["installation"]:
if not organization_id:
return self.respond()

installation = result["installation"]
installation = slack_request.integration.get_installation(
organization_id=organization_id
)
if not isinstance(installation, SlackIntegration):
return self.respond()

channel_id = slack_request.channel_id
thread_ts = slack_request.thread_ts
Expand All @@ -542,6 +485,7 @@ def on_assistant_thread_started(self, slack_request: SlackEventRequest) -> Respo
"channel_id": channel_id,
"thread_ts": thread_ts,
"context": assistant_thread.get("context"),
"organization_id": organization_id,
}
)

Expand Down
Loading
Loading