From 22f82740f6a92e1ace6f6d33989f5a3981bf866e Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 9 Nov 2026 11:54:58 -0500 Subject: [PATCH 01/10] feat(slack): Add a fake Slack workspace for the devserver Testing Slack notifications locally needs a real Slack app and workspace. With SENTRY_USE_FAKE_SLACK on, the devserver starts a fake Slack Web API on port 8099 and sends Sentry's Slack API calls to it. Messages show up in a Slack-like viewer at http://localhost:8099. Run `sentry django fake_slack_setup` once to connect an organization to the fake workspace. Production behavior is unchanged. The new SENTRY_SLACK_API_BASE_URL setting stays empty unless the flag is on, and while it's empty the Slack client uses https://slack.com/api/. The fake only records what Sentry sends. It validates nothing, and it doesn't send interactions such as button clicks back to Sentry yet. --- src/sentry/conf/server.py | 7 + src/sentry/integrations/slack/integration.py | 4 +- src/sentry/integrations/slack/sdk_client.py | 7 +- .../management/commands/fake_slack_setup.py | 61 ++++ src/sentry/runner/commands/devserver.py | 20 +- src/sentry/runner/initializer.py | 5 + src/sentry/testutils/fake_slack/__init__.py | 0 src/sentry/testutils/fake_slack/__main__.py | 30 ++ src/sentry/testutils/fake_slack/server.py | 280 +++++++++++++++ src/sentry/testutils/fake_slack/viewer.html | 330 ++++++++++++++++++ .../commands/test_fake_slack_setup.py | 42 +++ tests/sentry/testutils/fake_slack/__init__.py | 0 .../testutils/fake_slack/test_server.py | 81 +++++ 13 files changed, 863 insertions(+), 4 deletions(-) create mode 100644 src/sentry/management/commands/fake_slack_setup.py create mode 100644 src/sentry/testutils/fake_slack/__init__.py create mode 100644 src/sentry/testutils/fake_slack/__main__.py create mode 100644 src/sentry/testutils/fake_slack/server.py create mode 100644 src/sentry/testutils/fake_slack/viewer.html create mode 100644 tests/sentry/management/commands/test_fake_slack_setup.py create mode 100644 tests/sentry/testutils/fake_slack/__init__.py create mode 100644 tests/sentry/testutils/fake_slack/test_server.py diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index d449e790f083..ef79028d25c2 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -2612,6 +2612,11 @@ def custom_parameter_sort(parameter: _OperationParameter) -> tuple[str, int]: # This flag activates the objectstore in devservices SENTRY_USE_OBJECTSTORE = False +# This flag starts a fake Slack workspace with the devserver and sends Slack API calls to it. +# Run `sentry django fake_slack_setup` once to connect an organization to it. +SENTRY_USE_FAKE_SLACK = False +SENTRY_FAKE_SLACK_PORT = 8099 + # Max file size for serialized file uploads in API SENTRY_MAX_SERIALIZED_FILE_SIZE = 5000000 @@ -2819,6 +2824,8 @@ def custom_parameter_sort(parameter: _OperationParameter) -> tuple[str, int]: SENTRY_GITHUB_CONSOLE_SDK_APP_ID: int = 0 SENTRY_SLACK_CLIENT_ID: str = "" SENTRY_SLACK_STAGING_CLIENT_ID: str = "" +# Replaces https://slack.com/api/ for Slack Web API calls. +SENTRY_SLACK_API_BASE_URL: str | None = None SENTRY_MSTEAMS_CLIENT_ID: str = "" SENTRY_MSTEAMS_TENANT_ID: str = "" SENTRY_VERCEL_CLIENT_ID: str = "" diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 4e997d7bad93..83c801eba25c 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -24,7 +24,7 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.slack import workspace from sentry.integrations.slack.metrics import translate_slack_api_error -from sentry.integrations.slack.sdk_client import SlackSdkClient +from sentry.integrations.slack.sdk_client import SlackSdkClient, slack_api_base_url from sentry.integrations.slack.tasks.link_slack_user_identities import link_slack_user_identities from sentry.integrations.slack.utils.constants import SlackScope from sentry.integrations.slack.utils.errors import SLACK_SDK_MESSAGE_GONE_ERROR_CATEGORIES @@ -388,7 +388,7 @@ def _get_team_info(self, access_token: str) -> Any: # first try with new SDK client (not attached to integration) try: - client = WebClient(token=access_token) + client = WebClient(token=access_token, base_url=slack_api_base_url()) sdk_response = client.team_info() return sdk_response.get("team") diff --git a/src/sentry/integrations/slack/sdk_client.py b/src/sentry/integrations/slack/sdk_client.py index c0307419ef82..d374c5ebd58f 100644 --- a/src/sentry/integrations/slack/sdk_client.py +++ b/src/sentry/integrations/slack/sdk_client.py @@ -3,6 +3,7 @@ from functools import wraps from types import FunctionType +from django.conf import settings from slack_sdk import WebClient from slack_sdk.errors import SlackApiError from slack_sdk.web import SlackResponse @@ -43,6 +44,10 @@ def track_response_data(response: SlackResponse, method: str, error: str | None logger.info("integration.http_response", extra=extra) +def slack_api_base_url() -> str: + return settings.SENTRY_SLACK_API_BASE_URL or WebClient.BASE_URL + + def is_response_fatal(response: SlackResponse) -> bool: if not response.get("ok"): if "account_inactive" == response.get("error", ""): @@ -120,4 +125,4 @@ def __init__(self, integration_id: int): raise ValueError(f"Missing token for integration with id {integration_id}") # TODO: missing from old SlackClient: verify_ssl, logging_context - super().__init__(token=access_token) + super().__init__(token=access_token, base_url=slack_api_base_url()) diff --git a/src/sentry/management/commands/fake_slack_setup.py b/src/sentry/management/commands/fake_slack_setup.py new file mode 100644 index 000000000000..735d7f526684 --- /dev/null +++ b/src/sentry/management/commands/fake_slack_setup.py @@ -0,0 +1,61 @@ +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError, CommandParser + +from sentry.constants import ObjectStatus +from sentry.integrations.models.integration import Integration +from sentry.integrations.slack.integration import SlackIntegrationProvider +from sentry.integrations.types import IntegrationProviderSlug +from sentry.models.organization import Organization +from sentry.testutils.fake_slack.server import TEAM # noqa: S007 + + +class Command(BaseCommand): + help = "Connects an organization to the fake Slack workspace started by SENTRY_USE_FAKE_SLACK." + + def add_arguments(self, parser: CommandParser) -> None: + parser.add_argument( + "--org", help="Organization slug. Defaults to the only organization, if there is one." + ) + + def handle(self, **options: str | None) -> None: + if not settings.SENTRY_USE_FAKE_SLACK: + raise CommandError( + "Set SENTRY_USE_FAKE_SLACK = True in your Sentry config first, " + "or Slack messages will go to the real Slack." + ) + + organization = self._get_organization(options["org"]) + integration, _ = Integration.objects.update_or_create( + provider=IntegrationProviderSlug.SLACK.value, + external_id=TEAM["id"], + defaults={ + "name": TEAM["name"], + "status": ObjectStatus.ACTIVE, + "metadata": { + "access_token": "xoxb-fake-slack", + "installation_type": "born_as_bot", + "domain_name": f"{TEAM['domain']}.slack.com", + "scopes": sorted(SlackIntegrationProvider.identity_oauth_scopes), + }, + }, + ) + integration.add_organization(organization) + + port = settings.SENTRY_FAKE_SLACK_PORT + self.stdout.write( + f"Connected {organization.slug} to the {TEAM['name']} workspace " + f"(integration {integration.id}).\n" + f"Send Slack notifications to any channel, like #alerts, and see them at " + f"http://localhost:{port} while the devserver runs." + ) + + def _get_organization(self, slug: str | None) -> Organization: + if slug: + try: + return Organization.objects.get(slug=slug) + except Organization.DoesNotExist: + raise CommandError(f"No organization with slug {slug!r}") + organizations = list(Organization.objects.all()[:2]) + if len(organizations) != 1: + raise CommandError("Pass --org to choose an organization.") + return organizations[0] diff --git a/src/sentry/runner/commands/devserver.py b/src/sentry/runner/commands/devserver.py index 0e524fab267d..e34f4f8adde3 100644 --- a/src/sentry/runner/commands/devserver.py +++ b/src/sentry/runner/commands/devserver.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import sys import threading from collections.abc import MutableSequence, Sequence from pathlib import Path @@ -299,6 +300,24 @@ def devserver( click.echo("--ingest was provided, implicitly enabling --workers") workers = True + if settings.SENTRY_USE_FAKE_SLACK and silo != "region": + daemons.append( + ( + "fake-slack", + [ + sys.executable, + "-m", + "sentry.testutils.fake_slack", + "--host", + host, + "--port", + str(settings.SENTRY_FAKE_SLACK_PORT), + "--state-file", + ".artifacts/fake-slack.json", + ], + ) + ) + if task_scheduler and silo != "control": daemons.append(_get_daemon("taskworker-scheduler")) @@ -444,7 +463,6 @@ def devserver( if not daemons and not silo: server.run() - import sys from subprocess import list2cmdline from honcho.manager import Manager diff --git a/src/sentry/runner/initializer.py b/src/sentry/runner/initializer.py index 90e106132ceb..cc6d2c01aa16 100644 --- a/src/sentry/runner/initializer.py +++ b/src/sentry/runner/initializer.py @@ -435,6 +435,11 @@ def _trace(step: str) -> None: if settings.SENTRY_SINGLE_ORGANIZATION: settings.SENTRY_FEATURES["organizations:create"] = False + if settings.SENTRY_USE_FAKE_SLACK and not settings.SENTRY_SLACK_API_BASE_URL: + settings.SENTRY_SLACK_API_BASE_URL = ( + f"http://127.0.0.1:{settings.SENTRY_FAKE_SLACK_PORT}/api/" + ) + if not hasattr(settings, "SUDO_COOKIE_SECURE"): settings.SUDO_COOKIE_SECURE = getattr(settings, "SESSION_COOKIE_SECURE", False) if not hasattr(settings, "SUDO_COOKIE_DOMAIN"): diff --git a/src/sentry/testutils/fake_slack/__init__.py b/src/sentry/testutils/fake_slack/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/sentry/testutils/fake_slack/__main__.py b/src/sentry/testutils/fake_slack/__main__.py new file mode 100644 index 000000000000..6c5dd601952a --- /dev/null +++ b/src/sentry/testutils/fake_slack/__main__.py @@ -0,0 +1,30 @@ +import argparse +import os +import sys +from pathlib import Path + +from sentry.testutils.fake_slack.server import FakeSlack, make_server + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run a fake Slack Web API for local development.") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8099) + parser.add_argument("--state-file", type=Path, default=Path(".artifacts/fake-slack.json")) + parser.add_argument( + "--public-url", + default=os.environ.get("FAKE_SLACK_PUBLIC_URL"), + help="The URL people open the viewer at, used in message links. " + "Defaults to $FAKE_SLACK_PUBLIC_URL, or http://localhost:.", + ) + args = parser.parse_args() + + slack = FakeSlack(args.state_file, args.public_url or f"http://localhost:{args.port}") + server = make_server(args.host, args.port, slack) + sys.stdout.write(f"Fake Slack is running at {slack.public_url}\n") + sys.stdout.flush() + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/src/sentry/testutils/fake_slack/server.py b/src/sentry/testutils/fake_slack/server.py new file mode 100644 index 000000000000..7dc0f11ff15e --- /dev/null +++ b/src/sentry/testutils/fake_slack/server.py @@ -0,0 +1,280 @@ +"""A fake Slack Web API for local development. + +With SENTRY_USE_FAKE_SLACK on, the devserver starts this server and Sentry's Slack client sends its +API calls here. Messages are kept in a JSON file and shown like a Slack workspace at GET /. + +It accepts every call and validates nothing, so it shows what Sentry sends, not whether Slack would +accept it. It uses only the standard library, so it starts without loading Sentry. +""" + +from __future__ import annotations + +import hashlib +import json # noqa: S003 +import sys +import threading +import time +from collections.abc import Mapping +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlsplit + +TEAM = {"id": "T0FAKE", "name": "Fake Slack", "domain": "fake-slack"} +DEFAULT_CHANNELS = { + "C0ALERTS": "alerts", + "C0SEER": "seer", +} +VIEWER = Path(__file__).with_name("viewer.html") + +Response = dict[str, Any] + + +def _maybe_json(value: Any) -> Any: + if isinstance(value, str): + try: + return json.loads(value) + except ValueError: + return value + return value + + +class FakeSlack: + def __init__(self, state_file: Path | None, public_url: str) -> None: + self.state_file = state_file + self.public_url = public_url.rstrip("/") + self.lock = threading.Lock() + self.state: dict[str, Any] = self._load() + + def _load(self) -> dict[str, Any]: + if self.state_file and self.state_file.exists(): + return json.loads(self.state_file.read_text()) + return self._empty() + + @staticmethod + def _empty() -> dict[str, Any]: + return {"channels": dict(DEFAULT_CHANNELS), "messages": []} + + def _save(self) -> None: + if self.state_file: + self.state_file.parent.mkdir(parents=True, exist_ok=True) + self.state_file.write_text(json.dumps(self.state, indent=1)) + + def reset(self) -> None: + with self.lock: + self.state = self._empty() + self._save() + + def snapshot(self) -> dict[str, Any]: + with self.lock: + return {"team": TEAM, **json.loads(json.dumps(self.state))} + + def _channel_id(self, channel: str) -> str: + channel = channel.lstrip("#") + channels: dict[str, str] = self.state["channels"] + if channel in channels: + return channel + for channel_id, name in channels.items(): + if name == channel: + return channel_id + channel_id = "C" + hashlib.sha1(channel.encode()).hexdigest()[:9].upper() + channels[channel_id] = channel + return channel_id + + def _next_ts(self) -> str: + messages = self.state["messages"] + last = float(messages[-1]["ts"]) if messages else 0 + return f"{max(time.time(), last + 0.000001):.6f}" + + def _find(self, channel: str, ts: str) -> dict[str, Any] | None: + return next( + (m for m in self.state["messages"] if m["channel"] == channel and m["ts"] == ts), + None, + ) + + def call(self, method: str, args: Mapping[str, Any]) -> Response: + with self.lock: + handler = getattr(self, "_" + method.replace(".", "_"), None) + if handler is None: + return {"ok": True, "fake_slack": f"{method} is not simulated"} + result: Response = handler(args) + return result + + def _chat_postMessage(self, args: Mapping[str, Any], ephemeral: bool = False) -> Response: + channel = self._channel_id(args.get("channel") or "general") + message = { + "channel": channel, + "ts": self._next_ts(), + "thread_ts": args.get("thread_ts"), + "text": args.get("text") or "", + "blocks": _maybe_json(args.get("blocks")) or [], + "attachments": _maybe_json(args.get("attachments")) or [], + "reply_broadcast": bool(args.get("reply_broadcast")), + "ephemeral_user": args.get("user") if ephemeral else None, + "edited": None, + } + self.state["messages"].append(message) + self._save() + if ephemeral: + return {"ok": True, "message_ts": message["ts"]} + return {"ok": True, "channel": channel, "ts": message["ts"], "message": message} + + def _chat_postEphemeral(self, args: Mapping[str, Any]) -> Response: + return self._chat_postMessage(args, ephemeral=True) + + def _chat_scheduleMessage(self, args: Mapping[str, Any]) -> Response: + result = self._chat_postMessage(args) + return {**result, "scheduled_message_id": "Q" + result["ts"].replace(".", "")} + + def _chat_update(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "") + message = self._find(channel, args.get("ts") or "") + if message is None: + return {"ok": False, "error": "message_not_found"} + for key in ("text", "blocks", "attachments"): + if args.get(key) is not None: + message[key] = _maybe_json(args[key]) + message["edited"] = time.time() + self._save() + return {"ok": True, "channel": channel, "ts": message["ts"], "text": message["text"]} + + def _chat_delete(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "") + ts = args.get("ts") + self.state["messages"] = [ + m for m in self.state["messages"] if not (m["channel"] == channel and m["ts"] == ts) + ] + self._save() + return {"ok": True, "channel": channel, "ts": ts} + + def _chat_getPermalink(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "") + ts = args.get("message_ts") or "" + message = self._find(channel, ts) + thread = (message or {}).get("thread_ts") or ts + return { + "ok": True, + "channel": channel, + "permalink": f"{self.public_url}/#{channel}/{thread}/{ts}", + } + + def _channels(self) -> list[dict[str, Any]]: + return [ + {"id": cid, "name": name, "is_channel": True, "is_private": False, "is_member": True} + for cid, name in self.state["channels"].items() + ] + + def _conversations_list(self, args: Mapping[str, Any]) -> Response: + return {"ok": True, "channels": self._channels(), "response_metadata": {"next_cursor": ""}} + + def _users_conversations(self, args: Mapping[str, Any]) -> Response: + return self._conversations_list(args) + + def _conversations_info(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "") + return { + "ok": True, + "channel": {"id": channel, "name": self.state["channels"][channel], "is_member": True}, + } + + def _conversations_history(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "") + messages = [m for m in self.state["messages"] if m["channel"] == channel] + if latest := args.get("latest"): + messages = [m for m in messages if m["ts"] == latest] + return {"ok": True, "messages": messages, "has_more": False} + + def _conversations_replies(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "") + ts = args.get("ts") + messages = [ + m + for m in self.state["messages"] + if m["channel"] == channel and (m["ts"] == ts or m["thread_ts"] == ts) + ] + return {"ok": True, "messages": messages, "has_more": False} + + def _auth_test(self, args: Mapping[str, Any]) -> Response: + return { + "ok": True, + "team": TEAM["name"], + "team_id": TEAM["id"], + "user_id": "U0SENTRY", + "bot_id": "B0SENTRY", + } + + def _team_info(self, args: Mapping[str, Any]) -> Response: + return {"ok": True, "team": TEAM} + + def _users_info(self, args: Mapping[str, Any]) -> Response: + user = args.get("user") or "U0FAKE" + return { + "ok": True, + "user": { + "id": user, + "name": user, + "real_name": user, + "profile": {"display_name": user}, + }, + } + + def _users_list(self, args: Mapping[str, Any]) -> Response: + return {"ok": True, "members": [], "response_metadata": {"next_cursor": ""}} + + +def _flatten(params: Mapping[str, list[str]]) -> dict[str, Any]: + return {key: values[0] if len(values) == 1 else values for key, values in params.items()} + + +def make_handler(slack: FakeSlack) -> type[BaseHTTPRequestHandler]: + class Handler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: Any) -> None: + pass + + def _send(self, status: int, body: bytes, content_type: str) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _send_json(self, data: Mapping[str, Any]) -> None: + self._send(200, json.dumps(data).encode(), "application/json") + + def do_GET(self) -> None: + url = urlsplit(self.path) + if url.path == "/state.json": + self._send_json(slack.snapshot()) + elif url.path.startswith("/api/"): + self._api(url.path, _flatten(parse_qs(url.query))) + else: + self._send(200, VIEWER.read_bytes(), "text/html; charset=utf-8") + + def do_POST(self) -> None: + url = urlsplit(self.path) + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length).decode() if length else "" + if url.path == "/reset": + slack.reset() + self._send_json({"ok": True}) + return + if "json" in self.headers.get("Content-Type", ""): + args = json.loads(raw or "{}") + else: + args = _flatten(parse_qs(raw)) + self._api(url.path, {**_flatten(parse_qs(url.query)), **args}) + + def _api(self, path: str, args: dict[str, Any]) -> None: + method = path.removeprefix("/api/") + result = slack.call(method, args) + channel = args.get("channel") or "" + sys.stdout.write(f"{method} {channel} -> ok={result.get('ok')}\n") + sys.stdout.flush() + self._send_json(result) + + return Handler + + +def make_server(host: str, port: int, slack: FakeSlack) -> ThreadingHTTPServer: + return ThreadingHTTPServer((host, port), make_handler(slack)) diff --git a/src/sentry/testutils/fake_slack/viewer.html b/src/sentry/testutils/fake_slack/viewer.html new file mode 100644 index 000000000000..184d28c94d3f --- /dev/null +++ b/src/sentry/testutils/fake_slack/viewer.html @@ -0,0 +1,330 @@ + + + + + +Fake Slack + + + + +
+ +
+
+
+
+ +
+ + + diff --git a/tests/sentry/management/commands/test_fake_slack_setup.py b/tests/sentry/management/commands/test_fake_slack_setup.py new file mode 100644 index 000000000000..0d0f7e3e7f84 --- /dev/null +++ b/tests/sentry/management/commands/test_fake_slack_setup.py @@ -0,0 +1,42 @@ +from io import StringIO + +import pytest +from django.core.management import CommandError, call_command +from django.test import override_settings + +from sentry.integrations.models.integration import Integration +from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.testutils.cases import TestCase +from sentry.testutils.silo import no_silo_test + + +@no_silo_test +class FakeSlackSetupTest(TestCase): + def run_command(self, *args: str) -> str: + out = StringIO() + call_command("fake_slack_setup", *args, stdout=out, stderr=StringIO()) + return out.getvalue() + + def test_requires_fake_slack(self) -> None: + with pytest.raises(CommandError, match="SENTRY_USE_FAKE_SLACK"): + self.run_command("--org", self.organization.slug) + assert not Integration.objects.filter(provider="slack").exists() + + @override_settings(SENTRY_USE_FAKE_SLACK=True) + def test_connects_organization(self) -> None: + output = self.run_command("--org", self.organization.slug) + self.run_command("--org", self.organization.slug) + + integration = Integration.objects.get(provider="slack") + assert integration.external_id == "T0FAKE" + assert integration.metadata["access_token"] == "xoxb-fake-slack" + assert OrganizationIntegration.objects.filter( + integration=integration, organization_id=self.organization.id + ).exists() + assert f"Connected {self.organization.slug}" in output + + self.create_organization() + with pytest.raises(CommandError, match="--org"): + self.run_command() + with pytest.raises(CommandError, match="nope"): + self.run_command("--org", "nope") diff --git a/tests/sentry/testutils/fake_slack/__init__.py b/tests/sentry/testutils/fake_slack/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/sentry/testutils/fake_slack/test_server.py b/tests/sentry/testutils/fake_slack/test_server.py new file mode 100644 index 000000000000..aced7a42cef6 --- /dev/null +++ b/tests/sentry/testutils/fake_slack/test_server.py @@ -0,0 +1,81 @@ +import threading +from collections.abc import Generator +from pathlib import Path +from urllib.request import Request, urlopen + +import pytest +from django.test import override_settings +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from sentry.integrations.slack.sdk_client import SlackSdkClient, slack_api_base_url +from sentry.testutils.cases import TestCase +from sentry.testutils.fake_slack.server import FakeSlack, make_server +from sentry.utils import json + + +class FakeSlackServerTest(TestCase): + @pytest.fixture(autouse=True) + def _fake_slack(self, tmp_path: Path) -> Generator[None]: + self.state_file = tmp_path / "fake-slack.json" + self.slack = FakeSlack(self.state_file, "https://fake-slack.example.com") + server = make_server("127.0.0.1", 0, self.slack) + thread = threading.Thread(target=server.serve_forever) + thread.start() + self.url = f"http://127.0.0.1:{server.server_address[1]}" + try: + yield + finally: + server.shutdown() + server.server_close() + thread.join() + + def test_slack_client_posts_to_fake_slack(self) -> None: + assert slack_api_base_url() == WebClient.BASE_URL + + integration = self.create_integration( + organization=self.organization, + provider="slack", + external_id="T0FAKE", + metadata={"access_token": "xoxb-fake-slack"}, + ) + blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": "*Issue* alert"}}] + with override_settings(SENTRY_SLACK_API_BASE_URL=f"{self.url}/api/"): + client = SlackSdkClient(integration_id=integration.id) + assert client.base_url == f"{self.url}/api/" + + posted = client.chat_postMessage(channel="#alerts", text="Issue alert", blocks=blocks) + reply = client.chat_postMessage(channel="C0ALERTS", thread_ts=posted["ts"], text="Re") + client.chat_update(channel="#alerts", ts=posted["ts"], text="Issue resolved") + permalink = client.chat_getPermalink(channel="#alerts", message_ts=reply["ts"]) + new_channel = client.chat_postMessage(channel="#new-channel", text="Hello") + with pytest.raises(SlackApiError, match="message_not_found"): + client.chat_update(channel="#alerts", ts="1.0", text="Missing") + + assert posted["channel"] == "C0ALERTS" + assert permalink["permalink"] == ( + f"https://fake-slack.example.com/#C0ALERTS/{posted['ts']}/{reply['ts']}" + ) + + with urlopen(f"{self.url}/state.json") as response: + state = json.loads(response.read()) + assert state["team"]["id"] == "T0FAKE" + assert state["channels"][new_channel["channel"]] == "new-channel" + root, thread_reply, hello = state["messages"] + assert root["text"] == "Issue resolved" + assert root["blocks"] == blocks + assert root["edited"] is not None + assert thread_reply["thread_ts"] == posted["ts"] + assert hello["channel"] == new_channel["channel"] + assert FakeSlack(self.state_file, self.url).snapshot() == state + + with urlopen(self.url) as response: + assert b"Fake Slack" in response.read() + urlopen(Request(f"{self.url}/reset", method="POST")).close() + assert self.slack.snapshot()["messages"] == [] + + def test_unsimulated_methods_succeed(self) -> None: + assert self.slack.call("views.open", {"trigger_id": "123"}) == { + "ok": True, + "fake_slack": "views.open is not simulated", + } From bc95e55b67259d847d4010e572c2b7e93181d811 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 9 Nov 2026 12:03:22 -0500 Subject: [PATCH 02/10] fix(slack): Keep fake Slack's scheduled messages out of the viewer Sentry checks a channel exists by scheduling a message and deleting it. Fake Slack posted scheduled messages as visible ones and ignored the delete, so every channel check left a message in the viewer. Scheduled messages are now kept apart and removed on delete. --- src/sentry/testutils/fake_slack/server.py | 23 +++++++++++++++++-- .../testutils/fake_slack/test_server.py | 11 +++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/sentry/testutils/fake_slack/server.py b/src/sentry/testutils/fake_slack/server.py index 7dc0f11ff15e..cb3c99f55b0b 100644 --- a/src/sentry/testutils/fake_slack/server.py +++ b/src/sentry/testutils/fake_slack/server.py @@ -10,6 +10,7 @@ from __future__ import annotations import hashlib +import itertools import json # noqa: S003 import sys import threading @@ -45,6 +46,8 @@ def __init__(self, state_file: Path | None, public_url: str) -> None: self.public_url = public_url.rstrip("/") self.lock = threading.Lock() self.state: dict[str, Any] = self._load() + self.scheduled: dict[str, str] = {} + self._scheduled_ids = itertools.count(1) def _load(self) -> dict[str, Any]: if self.state_file and self.state_file.exists(): @@ -123,8 +126,24 @@ def _chat_postEphemeral(self, args: Mapping[str, Any]) -> Response: return self._chat_postMessage(args, ephemeral=True) def _chat_scheduleMessage(self, args: Mapping[str, Any]) -> Response: - result = self._chat_postMessage(args) - return {**result, "scheduled_message_id": "Q" + result["ts"].replace(".", "")} + channel = self._channel_id(args.get("channel") or "general") + scheduled_message_id = f"Q{next(self._scheduled_ids):09d}" + self.scheduled[scheduled_message_id] = channel + self._save() + return { + "ok": True, + "channel": channel, + "scheduled_message_id": scheduled_message_id, + "post_at": args.get("post_at"), + } + + def _chat_deleteScheduledMessage(self, args: Mapping[str, Any]) -> Response: + channel = self._channel_id(args.get("channel") or "") + scheduled_message_id = args.get("scheduled_message_id") or "" + if self.scheduled.get(scheduled_message_id) != channel: + return {"ok": False, "error": "invalid_scheduled_message_id"} + del self.scheduled[scheduled_message_id] + return {"ok": True} def _chat_update(self, args: Mapping[str, Any]) -> Response: channel = self._channel_id(args.get("channel") or "") diff --git a/tests/sentry/testutils/fake_slack/test_server.py b/tests/sentry/testutils/fake_slack/test_server.py index aced7a42cef6..e9e1a2f6672a 100644 --- a/tests/sentry/testutils/fake_slack/test_server.py +++ b/tests/sentry/testutils/fake_slack/test_server.py @@ -49,10 +49,21 @@ def test_slack_client_posts_to_fake_slack(self) -> None: client.chat_update(channel="#alerts", ts=posted["ts"], text="Issue resolved") permalink = client.chat_getPermalink(channel="#alerts", message_ts=reply["ts"]) new_channel = client.chat_postMessage(channel="#new-channel", text="Hello") + scheduled = client.chat_scheduleMessage(channel="#seer", text="Check", post_at=1) + client.chat_deleteScheduledMessage( + channel=scheduled["channel"], + scheduled_message_id=scheduled["scheduled_message_id"], + ) + with pytest.raises(SlackApiError, match="invalid_scheduled_message_id"): + client.chat_deleteScheduledMessage( + channel=scheduled["channel"], + scheduled_message_id=scheduled["scheduled_message_id"], + ) with pytest.raises(SlackApiError, match="message_not_found"): client.chat_update(channel="#alerts", ts="1.0", text="Missing") assert posted["channel"] == "C0ALERTS" + assert scheduled["channel"] == "C0SEER" assert permalink["permalink"] == ( f"https://fake-slack.example.com/#C0ALERTS/{posted['ts']}/{reply['ts']}" ) From c2903bf5908f0fb2f8d09be0d9e1690cc89d4eb8 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 9 Nov 2026 12:12:41 -0500 Subject: [PATCH 03/10] ref(slack): Simplify the fake Slack server Trim comments, fold the request handler factory into make_server, and merge the unsimulated-method check into the existing flow test. --- src/sentry/conf/server.py | 4 +-- src/sentry/testutils/fake_slack/__main__.py | 3 +-- src/sentry/testutils/fake_slack/server.py | 27 +++++++------------ src/sentry/testutils/fake_slack/viewer.html | 2 -- .../testutils/fake_slack/test_server.py | 8 ++---- 5 files changed, 13 insertions(+), 31 deletions(-) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index ef79028d25c2..a708fac92dd6 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -2612,8 +2612,7 @@ def custom_parameter_sort(parameter: _OperationParameter) -> tuple[str, int]: # This flag activates the objectstore in devservices SENTRY_USE_OBJECTSTORE = False -# This flag starts a fake Slack workspace with the devserver and sends Slack API calls to it. -# Run `sentry django fake_slack_setup` once to connect an organization to it. +# This flag starts a fake Slack workspace with the devserver; see `sentry django fake_slack_setup` SENTRY_USE_FAKE_SLACK = False SENTRY_FAKE_SLACK_PORT = 8099 @@ -2824,7 +2823,6 @@ def custom_parameter_sort(parameter: _OperationParameter) -> tuple[str, int]: SENTRY_GITHUB_CONSOLE_SDK_APP_ID: int = 0 SENTRY_SLACK_CLIENT_ID: str = "" SENTRY_SLACK_STAGING_CLIENT_ID: str = "" -# Replaces https://slack.com/api/ for Slack Web API calls. SENTRY_SLACK_API_BASE_URL: str | None = None SENTRY_MSTEAMS_CLIENT_ID: str = "" SENTRY_MSTEAMS_TENANT_ID: str = "" diff --git a/src/sentry/testutils/fake_slack/__main__.py b/src/sentry/testutils/fake_slack/__main__.py index 6c5dd601952a..6c4e5385a320 100644 --- a/src/sentry/testutils/fake_slack/__main__.py +++ b/src/sentry/testutils/fake_slack/__main__.py @@ -14,8 +14,7 @@ def main() -> None: parser.add_argument( "--public-url", default=os.environ.get("FAKE_SLACK_PUBLIC_URL"), - help="The URL people open the viewer at, used in message links. " - "Defaults to $FAKE_SLACK_PUBLIC_URL, or http://localhost:.", + help="Viewer URL used in message links. Defaults to http://localhost:.", ) args = parser.parse_args() diff --git a/src/sentry/testutils/fake_slack/server.py b/src/sentry/testutils/fake_slack/server.py index cb3c99f55b0b..00009238e67b 100644 --- a/src/sentry/testutils/fake_slack/server.py +++ b/src/sentry/testutils/fake_slack/server.py @@ -1,14 +1,11 @@ -"""A fake Slack Web API for local development. +"""A fake Slack Web API for SENTRY_USE_FAKE_SLACK. It accepts every call and validates nothing. -With SENTRY_USE_FAKE_SLACK on, the devserver starts this server and Sentry's Slack client sends its -API calls here. Messages are kept in a JSON file and shown like a Slack workspace at GET /. - -It accepts every call and validates nothing, so it shows what Sentry sends, not whether Slack would -accept it. It uses only the standard library, so it starts without loading Sentry. +Standard library only, so the devserver can start it without loading Sentry. """ from __future__ import annotations +import copy import hashlib import itertools import json # noqa: S003 @@ -70,7 +67,7 @@ def reset(self) -> None: def snapshot(self) -> dict[str, Any]: with self.lock: - return {"team": TEAM, **json.loads(json.dumps(self.state))} + return {"team": TEAM, **copy.deepcopy(self.state)} def _channel_id(self, channel: str) -> str: channel = channel.lstrip("#") @@ -177,14 +174,12 @@ def _chat_getPermalink(self, args: Mapping[str, Any]) -> Response: "permalink": f"{self.public_url}/#{channel}/{thread}/{ts}", } - def _channels(self) -> list[dict[str, Any]]: - return [ + def _conversations_list(self, args: Mapping[str, Any]) -> Response: + channels = [ {"id": cid, "name": name, "is_channel": True, "is_private": False, "is_member": True} for cid, name in self.state["channels"].items() ] - - def _conversations_list(self, args: Mapping[str, Any]) -> Response: - return {"ok": True, "channels": self._channels(), "response_metadata": {"next_cursor": ""}} + return {"ok": True, "channels": channels, "response_metadata": {"next_cursor": ""}} def _users_conversations(self, args: Mapping[str, Any]) -> Response: return self._conversations_list(args) @@ -245,7 +240,7 @@ def _flatten(params: Mapping[str, list[str]]) -> dict[str, Any]: return {key: values[0] if len(values) == 1 else values for key, values in params.items()} -def make_handler(slack: FakeSlack) -> type[BaseHTTPRequestHandler]: +def make_server(host: str, port: int, slack: FakeSlack) -> ThreadingHTTPServer: class Handler(BaseHTTPRequestHandler): def log_message(self, format: str, *args: Any) -> None: pass @@ -292,8 +287,4 @@ def _api(self, path: str, args: dict[str, Any]) -> None: sys.stdout.flush() self._send_json(result) - return Handler - - -def make_server(host: str, port: int, slack: FakeSlack) -> ThreadingHTTPServer: - return ThreadingHTTPServer((host, port), make_handler(slack)) + return ThreadingHTTPServer((host, port), Handler) diff --git a/src/sentry/testutils/fake_slack/viewer.html b/src/sentry/testutils/fake_slack/viewer.html index 184d28c94d3f..8697224e8eea 100644 --- a/src/sentry/testutils/fake_slack/viewer.html +++ b/src/sentry/testutils/fake_slack/viewer.html @@ -113,7 +113,6 @@ let current = null, openThread = null, focusTs = null, lastSignature = ''; const seen = new Set(); let initialized = false; -// Links to other messages in this viewer stay in the tab, like Slack permalinks do. function linkAttrs(url) { url = String(url ?? ''); const internal = url.startsWith(location.origin + '/#') || url.startsWith('#'); @@ -168,7 +167,6 @@ } function markdown(text) { - // "markdown" blocks use standard markdown. let s = esc(text); s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => `${label}`); s = s.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1'); diff --git a/tests/sentry/testutils/fake_slack/test_server.py b/tests/sentry/testutils/fake_slack/test_server.py index e9e1a2f6672a..e581523b5bb8 100644 --- a/tests/sentry/testutils/fake_slack/test_server.py +++ b/tests/sentry/testutils/fake_slack/test_server.py @@ -59,11 +59,13 @@ def test_slack_client_posts_to_fake_slack(self) -> None: channel=scheduled["channel"], scheduled_message_id=scheduled["scheduled_message_id"], ) + unsimulated = client.api_call("views.open", json={"trigger_id": "1"}) with pytest.raises(SlackApiError, match="message_not_found"): client.chat_update(channel="#alerts", ts="1.0", text="Missing") assert posted["channel"] == "C0ALERTS" assert scheduled["channel"] == "C0SEER" + assert unsimulated["fake_slack"] == "views.open is not simulated" assert permalink["permalink"] == ( f"https://fake-slack.example.com/#C0ALERTS/{posted['ts']}/{reply['ts']}" ) @@ -84,9 +86,3 @@ def test_slack_client_posts_to_fake_slack(self) -> None: assert b"Fake Slack" in response.read() urlopen(Request(f"{self.url}/reset", method="POST")).close() assert self.slack.snapshot()["messages"] == [] - - def test_unsimulated_methods_succeed(self) -> None: - assert self.slack.call("views.open", {"trigger_id": "123"}) == { - "ok": True, - "fake_slack": "views.open is not simulated", - } From 4b51456e7831e18908e27089089c0ef27cbe1cdd Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 9 Nov 2026 12:27:22 -0500 Subject: [PATCH 04/10] fix(slack): Decode Slack-escaped text once in the fake Slack viewer The viewer decoded & before < and >, so a literal "<" in a message rendered as "<". Decode in the right order, and pull markdown links out before escaping instead of partially unescaping their URLs. --- src/sentry/testutils/fake_slack/viewer.html | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/sentry/testutils/fake_slack/viewer.html b/src/sentry/testutils/fake_slack/viewer.html index 8697224e8eea..bb3a9f498805 100644 --- a/src/sentry/testutils/fake_slack/viewer.html +++ b/src/sentry/testutils/fake_slack/viewer.html @@ -121,6 +121,8 @@ return `href="${esc(url)}" target="_blank" rel="noopener"`; } +function unescapeSlack(s) { return String(s ?? '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); } + function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function mrkdwn(text) { @@ -148,13 +150,12 @@ html = `#${esc(name ?? state.channels[id] ?? id)}`; } else { const [url, label] = inner.split('|'); - const text = esc(label ?? url).replace(/^\*(.+)\*$/, '$1').replace(/^_(.+)_$/, '$1'); - html = `${text}`; + const text = esc(unescapeSlack(label ?? url)).replace(/^\*(.+)\*$/, '$1').replace(/^_(.+)_$/, '$1'); + html = `${text}`; } tokens.push(html); return `\u0000${tokens.length - 1}\u0000`; }); - s = s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); - s = esc(s); + s = esc(unescapeSlack(s)); s = s.replace(/```([\s\S]*?)```/g, (_, c) => `
${c}
`); s = s.replace(/`([^`\n]+)`/g, '$1'); s = s.replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,:!?])/g, '$1$2'); @@ -167,10 +168,12 @@ } function markdown(text) { - let s = esc(text); - s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => `${label}`); - s = s.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1'); - return s.replace(/\n/g, '
'); + const links = []; + let s = String(text ?? '').replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => { + links.push(`${esc(label)}`); return `\u0000${links.length - 1}\u0000`; + }); + s = esc(s).replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1'); + return s.replace(/\n/g, '
').replace(/\u0000(\d+)\u0000/g, (_, i) => links[+i]); } function textObj(obj) { From d658baaf26cc4ecff961e174a72a56a33b5b6a2b Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 9 Nov 2026 12:41:58 -0500 Subject: [PATCH 05/10] fix(slack): Escape button styles in the fake Slack viewer A button's style went into its class attribute unescaped, so a crafted message could inject markup into the viewer. Escape it, along with message timestamps and channel IDs. --- src/sentry/testutils/fake_slack/viewer.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sentry/testutils/fake_slack/viewer.html b/src/sentry/testutils/fake_slack/viewer.html index bb3a9f498805..a79640f8b57e 100644 --- a/src/sentry/testutils/fake_slack/viewer.html +++ b/src/sentry/testutils/fake_slack/viewer.html @@ -185,7 +185,7 @@ function element(el) { if (!el) return ''; if (el.type === 'button') { - const cls = `btn ${el.style ?? ''}`; + const cls = `btn ${esc(el.style)}`; return el.url ? `${esc(el.text?.text)}` : `${esc(el.text?.text)}`; } @@ -255,7 +255,7 @@ const blocks = (m.blocks ?? []).map(block).join(''); const body = blocks || (m.text ? `
${mrkdwn(m.text)}
` : ''); const isNew = initialized && !seen.has(m.ts); - return `
+ return `
S
SentryAPP${fmtTime(m.ts)}${m.edited ? '(edited)' : ''}
@@ -263,7 +263,7 @@ ${m.reply_broadcast && inThread !== 'root' ? '
Also sent to the channel
' : ''} ${body} ${(m.attachments ?? []).map(attachment).join('')} - ${!inThread && replies.length ? `
💬 ${replies.length} ${replies.length === 1 ? 'reply' : 'replies'} Last reply ${fmtTime(replies[replies.length - 1].ts)}
` : ''} + ${!inThread && replies.length ? `
💬 ${replies.length} ${replies.length === 1 ? 'reply' : 'replies'} Last reply ${fmtTime(replies[replies.length - 1].ts)}
` : ''}
`; } @@ -280,7 +280,7 @@ const roots = id => state.messages.filter(m => m.channel === id && (!m.thread_ts || m.thread_ts === m.ts || m.reply_broadcast)); document.getElementById('channels').innerHTML = Object.entries(state.channels).map(([id, name]) => { const count = state.messages.filter(m => m.channel === id).length; - return `
# ${esc(name)}${count ? `${count}` : ''}
`; + return `
# ${esc(name)}${count ? `${count}` : ''}
`; }).join(''); document.getElementById('channel-header').innerHTML = `# ${esc(state.channels[current] ?? '')}${state.messages.filter(m => m.channel === current).length} messages from Sentry`; const feed = document.getElementById('feed'); From f12efdcf38569cff351864e0c0833eaebfd78c80 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 9 Nov 2026 15:20:19 -0500 Subject: [PATCH 06/10] feat(slack): Flag what Fake Slack doesn't support Clicking a button or menu in the viewer now explains that Fake Slack doesn't support it, instead of doing nothing. Slack methods Sentry calls that Fake Slack doesn't simulate are recorded and listed in a viewer banner, linked to the related message when there is one. --- src/sentry/testutils/fake_slack/server.py | 37 +++++++++++---- src/sentry/testutils/fake_slack/viewer.html | 47 ++++++++++++++++++- .../testutils/fake_slack/test_server.py | 10 ++++ 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/src/sentry/testutils/fake_slack/server.py b/src/sentry/testutils/fake_slack/server.py index 00009238e67b..475c4d01d019 100644 --- a/src/sentry/testutils/fake_slack/server.py +++ b/src/sentry/testutils/fake_slack/server.py @@ -24,6 +24,7 @@ "C0SEER": "seer", } VIEWER = Path(__file__).with_name("viewer.html") +MAX_UNSUPPORTED_CALLS = 100 Response = dict[str, Any] @@ -48,12 +49,12 @@ def __init__(self, state_file: Path | None, public_url: str) -> None: def _load(self) -> dict[str, Any]: if self.state_file and self.state_file.exists(): - return json.loads(self.state_file.read_text()) + return {**self._empty(), **json.loads(self.state_file.read_text())} return self._empty() @staticmethod def _empty() -> dict[str, Any]: - return {"channels": dict(DEFAULT_CHANNELS), "messages": []} + return {"channels": dict(DEFAULT_CHANNELS), "messages": [], "unsupported": []} def _save(self) -> None: if self.state_file: @@ -69,16 +70,19 @@ def snapshot(self) -> dict[str, Any]: with self.lock: return {"team": TEAM, **copy.deepcopy(self.state)} - def _channel_id(self, channel: str) -> str: + def _existing_channel_id(self, channel: str) -> str | None: channel = channel.lstrip("#") channels: dict[str, str] = self.state["channels"] if channel in channels: return channel - for channel_id, name in channels.items(): - if name == channel: - return channel_id - channel_id = "C" + hashlib.sha1(channel.encode()).hexdigest()[:9].upper() - channels[channel_id] = channel + return next((cid for cid, name in channels.items() if name == channel), None) + + def _channel_id(self, channel: str) -> str: + if channel_id := self._existing_channel_id(channel): + return channel_id + name = channel.lstrip("#") + channel_id = "C" + hashlib.sha1(name.encode()).hexdigest()[:9].upper() + self.state["channels"][channel_id] = name return channel_id def _next_ts(self) -> str: @@ -96,10 +100,27 @@ def call(self, method: str, args: Mapping[str, Any]) -> Response: with self.lock: handler = getattr(self, "_" + method.replace(".", "_"), None) if handler is None: + self._record_unsupported(method, args) return {"ok": True, "fake_slack": f"{method} is not simulated"} result: Response = handler(args) return result + def _record_unsupported(self, method: str, args: Mapping[str, Any]) -> None: + channel = self._existing_channel_id(args.get("channel") or "") + ts = args.get("ts") or args.get("timestamp") or args.get("thread_ts") or "" + message = self._find(channel, ts) if channel else None + calls = self.state["unsupported"] + calls.append( + { + "method": method, + "at": time.time(), + "channel": channel if message else None, + "ts": ts if message else None, + } + ) + del calls[:-MAX_UNSUPPORTED_CALLS] + self._save() + def _chat_postMessage(self, args: Mapping[str, Any], ephemeral: bool = False) -> Response: channel = self._channel_id(args.get("channel") or "general") message = { diff --git a/src/sentry/testutils/fake_slack/viewer.html b/src/sentry/testutils/fake_slack/viewer.html index a79640f8b57e..af81d26bd354 100644 --- a/src/sentry/testutils/fake_slack/viewer.html +++ b/src/sentry/testutils/fake_slack/viewer.html @@ -82,6 +82,11 @@ blockquote { border-left: 4px solid #ddd; margin: 4px 0; padding-left: 10px; color: var(--muted); } .mrkdwn { white-space: normal; overflow-wrap: anywhere; } .raw { font-family: monospace; font-size: 11px; color: var(--muted); } + .unsupported { background: #fdecec; border-bottom: 1px solid #f3b3b3; padding: 6px 16px; font-size: 13px; } + .unsupported[hidden], .toast[hidden] { display: none; } + .unsupported code { font-size: 12px; } + [data-unsupported] { cursor: pointer; } + .toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); background: #1d1c1d; color: #fff; padding: 10px 16px; border-radius: 6px; font-size: 13px; max-width: 90vw; box-shadow: 0 4px 12px rgba(0,0,0,.25); z-index: 20; } @media (max-width: 800px) { .sidebar { display: none; } .thread { position: fixed; inset: 0; width: auto; max-width: none; background: var(--bg); z-index: 10; top: 33px; } @@ -93,6 +98,7 @@ 🧪 Fake Slack. Every message this Sentry devserver sends to Slack lands here instead of a real workspace.
+