Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
9260c79
🚧 initial pass
leeandher Apr 7, 2026
be4faf2
🚧 unreviewed claude output for thread start, need to review
leeandher Apr 7, 2026
0bfe302
feat(slack): Add assistant:write scope for Slack Agent support
leeandher Apr 8, 2026
99699e3
ref(slack): Consolidate duplicate Seer event handlers and halt enums
leeandher Apr 8, 2026
b0f7e2c
test(slack): Update tests for Seer org resolution and halt reason cha…
leeandher Apr 8, 2026
df63ce0
feat(seer-slack): Add channel-aware history scope checks and missing-…
alexsohn1126 Apr 2, 2026
6b2065e
fixing outdated private channel checks
alexsohn1126 Apr 7, 2026
7d4484c
fix(seer-slack): Remove debug logging and fix tests to mock conversat…
alexsohn1126 Apr 7, 2026
08a1c38
fix(seer-slack): Assert conversations_info returns dict, not binary data
alexsohn1126 Apr 7, 2026
e01f3c1
fix(seer-slack): Update missing scope footer copy
alexsohn1126 Apr 7, 2026
78f49b6
feat(seer-slack): Only show missing scope footer on first thread resp…
alexsohn1126 Apr 7, 2026
efe4857
fix(seer-slack): Skip Slack API call when no history scopes are insta…
alexsohn1126 Apr 9, 2026
fed458f
ref(seer-slack): Reduce missing scope footer cache TTL to 1 hour
alexsohn1126 Apr 9, 2026
68ea1ea
fix(seer-slack): Allow history access for DMs and assistant threads
alexsohn1126 Apr 9, 2026
693fb1c
🚧 initial pass
leeandher Apr 7, 2026
727e078
🚧 unreviewed claude output for thread start, need to review
leeandher Apr 7, 2026
db819cf
feat(slack): Add assistant:write scope for Slack Agent support
leeandher Apr 8, 2026
58aa620
ref(slack): Consolidate duplicate Seer event handlers and halt enums
leeandher Apr 8, 2026
6a28028
test(slack): Update tests for Seer org resolution and halt reason cha…
leeandher Apr 8, 2026
6f4a9d9
fix(slack): Validate user org membership in Seer org resolution
leeandher Apr 10, 2026
a3e0075
Merge branch 'leanderrodrigues/iswf-2388-explore-slack-dms-agent-inte…
alexsohn1126 Apr 10, 2026
2b6828c
Merge remote-tracking branch 'origin/master' into alexsohn/iswf-2353-…
alexsohn1126 Apr 13, 2026
e0a5532
ref(slack): Remove duplicate MessageIMDmAgentTest superseded by test_…
alexsohn1126 Apr 13, 2026
be64318
ref(slack): Move missing-scope footer logic from send_thread_update t…
alexsohn1126 Apr 13, 2026
e8fe855
ref(slack): Revert unintended whitespace changes in messaging.py
alexsohn1126 Apr 13, 2026
475285a
fix(slack): Add isinstance check for ContextBlock to fix mypy attr-de…
alexsohn1126 Apr 13, 2026
5a2238f
fix(slack): Narrow ContextBlock element type to fix mypy union-attr e…
alexsohn1126 Apr 13, 2026
8c39193
fix(slack): Move org lookup before cache.add in missing-scope footer …
alexsohn1126 Apr 13, 2026
ed6579f
Merge branch 'master' into alexsohn/iswf-2353-ensure-history-scopes-b…
alexsohn1126 Apr 13, 2026
e9476fe
Merge branch 'master' into alexsohn/iswf-2353-ensure-history-scopes-b…
alexsohn1126 Apr 14, 2026
bf83740
make the prompt to reinstall less technical
alexsohn1126 Apr 14, 2026
eb603bd
remove early exit if all installed history scopes are available
alexsohn1126 Apr 14, 2026
d0b0951
remove is_channel check for private groups for backwards compatibilit…
alexsohn1126 Apr 14, 2026
f9d824d
ref(slack): Add warning log for unrecognized channel types in has_his…
alexsohn1126 Apr 14, 2026
48ab446
docs(slack): Expand has_history_scope docstring
alexsohn1126 Apr 14, 2026
555218e
fix(slack): Fix failing tests for has_history_scope changes
alexsohn1126 Apr 14, 2026
aedb432
fix(slack): Default to True in has_history_scope on API failure
alexsohn1126 Apr 14, 2026
4911b68
Revert "fix(slack): Default to True in has_history_scope on API failure"
alexsohn1126 Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion src/sentry/integrations/slack/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,71 @@ def has_scope(self, scope: SlackScope) -> bool:
)
return has_scope

def has_history_scope(self, channel_id: str) -> bool:
"""
Returns whether this integration is allowed to access the history in the channel.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth adding the detail here that the check is different depending on the type of channel. And that type of channel will be determined by an API call in the function.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, added more detail in:

48ab446


Checks if channels:history is installed for public channels,
and check groups:history for private channels.

The type of channel is determined by the conversations.info API call.
"""
history_scopes = [SlackScope.CHANNELS_HISTORY, SlackScope.GROUPS_HISTORY]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the bots got a point, I think you need im:history here, and it will make that not any(... check redundant since all installs have im:history

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh it's handled below, then maybe just slot this all under the is_im check 👍

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bots were right, even if someone doesn't have neither SlackScope.CHANNELS_HISTORY and SlackScope.GROUPS_HISTORY, they should still be able to DM the bot.

Removed the early exit if neither of those history scopes are available

eb603bd

installed_scope_set = frozenset(self.model.metadata.get("scopes", []))

installed_history_scopes = [s in installed_scope_set for s in history_scopes]

if all(installed_history_scopes):
return True
Comment thread
cursor[bot] marked this conversation as resolved.

conversation_data = self.get_conversations_info(channel_id=channel_id)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd try/catch this in case this ever gets used somewhere else where users control what channel_id we provide. If that's the case it's possible the channel doesn't exist 🤷

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It already is try/catched in get_conversations_info method:

def get_conversations_info(
self,
*,
channel_id: str,
) -> dict:
"""
Fetch conversations info from Slack API.
"""
client = self.get_client()
try:
conversations = client.conversations_info(
channel=channel_id,
)
assert isinstance(conversations.data, dict)
return conversations.data
except (SlackApiError, AssertionError) as e:
_logger.warning(
"slack.get_conversations_info.error",
extra={"channel_id": channel_id, "error": str(e)},
)
return {}


channel_info = conversation_data.get("channel", {})
is_channel = channel_info.get("is_channel", False)
is_private = channel_info.get("is_private", False)
is_im = channel_info.get("is_im", False)

# DMs and assistant threads: the bot is a participant and can
# always read its own conversation history.
if is_im:
return True
if is_private:
return SlackScope.GROUPS_HISTORY in installed_scope_set
if is_channel:
return SlackScope.CHANNELS_HISTORY in installed_scope_set
Comment thread
cursor[bot] marked this conversation as resolved.

# Shouldn't reach here unless channel_info is empty (most likely
Comment on lines +273 to +297

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's worthwhile to add a logger.warning or something similar just in case! we might catch users trying multi person DMs, and missing the mpim:history scope for example

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a logger warning:

f9d824d

# an API error or an unrecognized conversation type).
_logger.warning(
"slack.has_history_scope.unrecognized_channel_type",
extra={"channel_id": channel_id, "channel_info": channel_info},
)
return False

def get_conversations_info(
self,
*,
channel_id: str,
) -> dict:
"""
Fetch conversations info from Slack API.
"""
client = self.get_client()

try:
conversations = client.conversations_info(
channel=channel_id,
)

assert isinstance(conversations.data, dict)
Comment thread
sentry[bot] marked this conversation as resolved.
return conversations.data
except (SlackApiError, AssertionError) as e:
_logger.warning(
"slack.get_conversations_info.error",
extra={"channel_id": channel_id, "error": str(e)},
)
return {}

def get_thread_history(
self,
*,
Expand All @@ -271,7 +336,7 @@ def get_thread_history(
Fetch thread replies using the conversations.replies API.
Returns a list of message dicts, or an empty list on error.
"""
if not self.has_scope(SlackScope.CHANNELS_HISTORY):
if not self.has_history_scope(channel_id):
return []

client = self.get_client()
Expand Down
12 changes: 12 additions & 0 deletions src/sentry/notifications/platform/slack/renderers/seer.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,9 @@ def _render_explorer_response(cls, data: SeerExplorerResponse) -> SlackRenderabl
if organization and features.has("organizations:seer-run-id-in-slack", organization):
blocks.append(ContextBlock(elements=[PlainTextObject(text=f"Run ID: {data.run_id}")]))

if data.missing_scope_settings_url:
blocks.extend(cls.render_missing_scope_footer(data.missing_scope_settings_url))

return SlackRenderable(blocks=blocks, text="Seer Explorer has finished")

@classmethod
Expand Down Expand Up @@ -271,6 +274,15 @@ def render_footer_blocks(

return blocks

@classmethod
def render_missing_scope_footer(cls, settings_url: str) -> list[Block]:
"""Return a context block warning that optional history scopes are missing."""
footer_text = (
f"_I am only able to see the message with the mention. I can't read the whole thread. "
f"<{settings_url}|Reinstall me> to change that._"
)
return [ContextBlock(elements=[MarkdownTextObject(text=footer_text)])]

@classmethod
def render_autofix_button(cls, group: Group) -> InteractiveElement:
"""
Expand Down
1 change: 1 addition & 0 deletions src/sentry/notifications/platform/templates/seer.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ class SeerExplorerResponse(NotificationData):
run_id: int
organization_id: int
summary: str
missing_scope_settings_url: str | None = None
source: NotificationSource = NotificationSource.SEER_EXPLORER_RESPONSE


Expand Down
55 changes: 53 additions & 2 deletions src/sentry/seer/entrypoints/slack/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from sentry import features
from sentry.constants import ENABLE_SEER_ENHANCED_ALERTS_DEFAULT, ObjectStatus
from sentry.integrations.services.integration.service import integration_service
from sentry.locks import locks
from sentry.models.organization import Organization
from sentry.notifications.platform.templates.seer import (
Expand Down Expand Up @@ -33,6 +34,7 @@
from sentry.seer.explorer.client_utils import has_seer_explorer_access_with_detail
from sentry.sentry_apps.metrics import SentryAppEventType
from sentry.utils import metrics
from sentry.utils.cache import cache
from sentry.utils.locking import UnableToAcquireLock

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -68,6 +70,46 @@ class SlackExplorerCachePayload(TypedDict):
thread: SlackThreadDetails


MISSING_SCOPE_FOOTER_CACHE_TIMEOUT = 60 * 60


def _get_missing_scope_settings_url(
*,
integration_id: int,
organization_id: int,
channel_id: str,
thread_ts: str,
) -> str | None:
"""
Returns a settings URL if the integration is missing history scopes for the channel,
and we haven't already shown the footer for this thread. Returns None otherwise.
"""
from sentry.integrations.slack.integration import SlackIntegration

integration = integration_service.get_integration(
integration_id=integration_id,
organization_id=organization_id,
status=ObjectStatus.ACTIVE,
)
if not integration:
return None

install = SlackIntegration(model=integration, organization_id=organization_id)
if install.has_history_scope(channel_id):
return None
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.

try:
org = Organization.objects.get(id=organization_id)
except Organization.DoesNotExist:
return None

cache_key = f"seer:explorer:scope_footer:{integration_id}:{channel_id}:{thread_ts}"
if not cache.add(cache_key, True, timeout=MISSING_SCOPE_FOOTER_CACHE_TIMEOUT):
return None

return org.absolute_url(f"/settings/{org.slug}/integrations/slack/")
Comment thread
alexsohn1126 marked this conversation as resolved.


class SlackAutofixEntrypoint(
SeerAutofixEntrypoint[SlackAutofixCachePayload],
):
Expand Down Expand Up @@ -410,20 +452,29 @@ def on_explorer_update(
run_id: int,
) -> None:
organization_id = cache_payload["organization_id"]
integration_id = cache_payload["integration_id"]
thread = cache_payload["thread"]

if not summary:
data: SeerExplorerError | SeerExplorerResponse = SeerExplorerError(
error_message="Seer was unable to generate a response."
)
else:
missing_scope_url = _get_missing_scope_settings_url(
integration_id=integration_id,
organization_id=organization_id,
channel_id=thread["channel_id"],
thread_ts=thread["thread_ts"],
)
Comment thread
alexsohn1126 marked this conversation as resolved.
data = SeerExplorerResponse(
run_id=run_id,
organization_id=organization_id,
summary=summary,
missing_scope_settings_url=missing_scope_url,
)
schedule_all_thread_updates(
threads=[cache_payload["thread"]],
integration_id=cache_payload["integration_id"],
threads=[thread],
integration_id=integration_id,
organization_id=organization_id,
data=data,
)
Expand Down
74 changes: 71 additions & 3 deletions tests/sentry/integrations/slack/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,16 +624,28 @@ def test_send_threaded_ephemeral_message_error(self, mock_chat_ephemeral: MagicM
slack_user_id=self.slack_user_id,
)

def test_get_thread_history_missing_scope_returns_empty(self) -> None:
@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info")
def test_get_thread_history_missing_scope_returns_empty(
self, mock_conversations_info: MagicMock
) -> None:
mock_conversations_info.return_value = MagicMock(
data={"ok": True, "channel": {"is_channel": True, "is_private": False}}
)
result = self.installation.get_thread_history(
channel_id=self.channel_id,
thread_ts=self.thread_ts,
)
assert result == []

@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_replies")
def test_get_thread_history_success(self, mock_conversations_replies: MagicMock) -> None:
@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info")
def test_get_thread_history_success(
self, mock_conversations_info: MagicMock, mock_conversations_replies: MagicMock
) -> None:
self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY]
mock_conversations_info.return_value = MagicMock(
data={"ok": True, "channel": {"is_channel": True, "is_private": False}}
)
mock_conversations_replies.return_value = {
"ok": True,
"messages": [
Expand All @@ -653,11 +665,67 @@ def test_get_thread_history_success(self, mock_conversations_replies: MagicMock)
ts=self.thread_ts,
)

@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info")
def test_get_thread_history_private_channel_missing_groups_history(
self, mock_conversations_info: MagicMock
) -> None:
self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY]
mock_conversations_info.return_value = MagicMock(
data={"ok": True, "channel": {"is_channel": True, "is_private": True}}
)
result = self.installation.get_thread_history(
channel_id="G1234567890",
thread_ts=self.thread_ts,
)
assert result == []

@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_replies")
@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info")
def test_get_thread_history_private_channel_with_groups_history(
self, mock_conversations_info: MagicMock, mock_conversations_replies: MagicMock
) -> None:
self.integration.metadata["scopes"] = [SlackScope.GROUPS_HISTORY]
mock_conversations_info.return_value = MagicMock(
data={"ok": True, "channel": {"is_channel": True, "is_private": True}}
)
mock_conversations_replies.return_value = {
"ok": True,
"messages": [{"ts": self.thread_ts, "text": "Private message"}],
}
result = self.installation.get_thread_history(
channel_id="G1234567890",
thread_ts=self.thread_ts,
)
assert len(result) == 1
assert result[0]["text"] == "Private message"

@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info")
def test_has_history_scope_public_channel(self, mock_conversations_info: MagicMock) -> None:
self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY]
mock_conversations_info.return_value = MagicMock(
data={"ok": True, "channel": {"is_channel": True, "is_private": False}}
)
assert self.installation.has_history_scope("C1234567890") is True

@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info")
def test_has_history_scope_private_channel_missing(
self, mock_conversations_info: MagicMock
) -> None:
self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY]
mock_conversations_info.return_value = MagicMock(
data={"ok": True, "channel": {"is_channel": True, "is_private": True}}
)
assert self.installation.has_history_scope("G1234567890") is False

@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_replies")
@patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_info")
def test_get_thread_history_error_returns_empty_list(
self, mock_conversations_replies: MagicMock
self, mock_conversations_info: MagicMock, mock_conversations_replies: MagicMock
) -> None:
self.integration.metadata["scopes"] = [SlackScope.CHANNELS_HISTORY]
mock_conversations_info.return_value = MagicMock(
data={"ok": True, "channel": {"is_channel": True, "is_private": False}}
)
mock_conversations_replies.side_effect = SlackApiError("channel_not_found", MagicMock())
result = self.installation.get_thread_history(
channel_id=self.channel_id, thread_ts=self.thread_ts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
ContextBlock,
LinkButtonElement,
MarkdownBlock,
MarkdownTextObject,
PlainTextObject,
SectionBlock,
)
Expand Down Expand Up @@ -269,6 +270,34 @@ def test_render_explorer_response_without_run_id_flag(self) -> None:
assert isinstance(blocks[0], MarkdownBlock)
assert "Found a spike in 500 errors from the auth service." in blocks[0].text

def test_render_explorer_response_with_missing_scope_footer(self) -> None:
data = SeerExplorerResponse(
run_id=MOCK_RUN_ID,
organization_id=self.organization.id,
summary="Some analysis",
missing_scope_settings_url="https://sentry.io/settings/test/integrations/slack/",
)
renderable = SeerSlackRenderer._render_explorer_response(data)

blocks = renderable["blocks"]
assert len(blocks) == 2
last_block = blocks[-1]
assert last_block.type == "context"
assert isinstance(last_block, ContextBlock)
first_element = last_block.elements[0]
assert isinstance(first_element, MarkdownTextObject)
footer_text = first_element.text
assert "Reinstall" in footer_text
assert "I can't read the whole thread" in footer_text

def test_render_explorer_response_no_footer_when_url_not_set(self) -> None:
data = self._create_explorer_response(summary="Some analysis")
assert data.missing_scope_settings_url is None
renderable = SeerSlackRenderer._render_explorer_response(data)

for block in renderable["blocks"]:
assert block.type != "context"

def test_render_dispatches_to_explorer_response(self) -> None:
data = self._create_explorer_response(summary="Test")
from sentry.notifications.platform.types import NotificationRenderedTemplate
Expand Down
Loading
Loading