-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Reset manual topic changes in incident channels #260
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
12ab0ce
7773d0e
d1ea60f
2c27ac3
eadf647
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| import logging | ||
| import threading | ||
| from collections import OrderedDict | ||
| from typing import Any | ||
|
|
||
| from firetower.incidents.hooks import build_channel_topic | ||
| from firetower.integrations.services.slack import SlackService, escape_slack_text | ||
| from firetower.slack_app.handlers.utils import get_incident_from_channel | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| _slack_service = SlackService() | ||
|
|
||
| _bot_user_id: str | None = None | ||
|
|
||
| # Bounded set of recently handled channel_topic event ids, used to drop Slack | ||
| # redeliveries. Socket Mode strips the X-Slack-Retry-Num header (it lives on the | ||
| # envelope, not the event payload Bolt forwards), so we dedup on the event's own | ||
| # id/timestamp instead. This in-memory cache is per-process, so it only dedups | ||
| # correctly while the firetower-slack-app Cloud Run service stays single-instance | ||
| # (max_instance_count = 1); scaling it out would split the cache across replicas. | ||
| _RECENT_EVENT_CACHE_SIZE = 256 | ||
| _recent_event_ids: "OrderedDict[str, None]" = OrderedDict() | ||
| # Bolt dispatches event listeners on a thread pool, so guard the cache against | ||
| # concurrent check-then-set/evict races. | ||
| _recent_event_ids_lock = threading.Lock() | ||
|
|
||
|
|
||
| def _get_bot_user_id(client: Any) -> str | None: | ||
| """Return the bot's own Slack user id, caching it after the first lookup.""" | ||
| global _bot_user_id # noqa: PLW0603 | ||
| if _bot_user_id is None: | ||
| try: | ||
| _bot_user_id = client.auth_test()["user_id"] | ||
| except Exception: | ||
| logger.exception("auth_test failed while resolving bot user id") | ||
| return None | ||
| return _bot_user_id | ||
|
|
||
|
|
||
| def _event_id(event: dict) -> str | None: | ||
| return event.get("event_ts") or event.get("ts") | ||
|
|
||
|
|
||
| def _seen_recently(event_id: str) -> bool: | ||
| """Return True if this event id was already handled, recording it otherwise.""" | ||
| with _recent_event_ids_lock: | ||
| if event_id in _recent_event_ids: | ||
| return True | ||
| _recent_event_ids[event_id] = None | ||
| while len(_recent_event_ids) > _RECENT_EVENT_CACHE_SIZE: | ||
| _recent_event_ids.popitem(last=False) | ||
| return False | ||
|
|
||
|
|
||
| def _build_reset_message(attempted: str) -> str: | ||
| lines = [ | ||
| "Channel topics here are managed by Firetower and reflect the incident's " | ||
| "current status, so I reset it.", | ||
| ] | ||
| if attempted: | ||
| quoted = "\n".join( | ||
| f"> {escape_slack_text(line)}" for line in attempted.splitlines() | ||
| ) | ||
| lines.append(f"You tried to set:\n{quoted}") | ||
| lines.append( | ||
| "To change incident details (which update the topic automatically), use " | ||
| "`/ft subject <title>`, `/ft severity <P0-P4>`, or `/ft captain`." | ||
| ) | ||
| return "\n\n".join(lines) | ||
|
|
||
|
|
||
| def handle_channel_topic_change(event: dict, client: Any) -> None: | ||
| """Reset manual incident-channel topic edits and nudge the editor. | ||
|
|
||
| Slack emits a ``channel_topic`` message event whenever a channel's topic | ||
|
Check failure on line 76 in src/firetower/slack_app/handlers/topic_guard.py
|
||
| changes, including when Firetower sets it. The bot-author guard prevents an | ||
| infinite reset loop on our own edits, and the event-id dedup guard prevents a | ||
| second reset and ephemeral when Slack redelivers the event. | ||
| """ | ||
| if event.get("user") == _get_bot_user_id(client): | ||
|
spalmurray marked this conversation as resolved.
|
||
| return | ||
|
spalmurray marked this conversation as resolved.
|
||
|
|
||
| channel_id = event.get("channel") | ||
| if not channel_id: | ||
| return | ||
|
|
||
| event_id = _event_id(event) | ||
| if event_id is not None and _seen_recently(event_id): | ||
| logger.info("Skipping duplicate channel_topic event for channel %s", channel_id) | ||
| return | ||
|
|
||
| incident = get_incident_from_channel(channel_id) | ||
| if incident is None: | ||
| return | ||
|
|
||
| canonical = build_channel_topic(incident) | ||
| attempted = event.get("topic", "") | ||
|
|
||
| logger.info( | ||
| "Resetting manual topic change in incident channel %s (incident %s)", | ||
| channel_id, | ||
| incident.incident_number, | ||
| ) | ||
| _slack_service.set_channel_topic(channel_id, canonical) | ||
|
|
||
| try: | ||
| client.chat_postEphemeral( | ||
| channel=channel_id, | ||
| user=event.get("user"), | ||
| text=_build_reset_message(attempted), | ||
|
spalmurray marked this conversation as resolved.
|
||
| ) | ||
| except Exception: | ||
| logger.exception("Failed to post topic-reset ephemeral to %s", channel_id) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| import pytest | ||
|
|
||
| from firetower.slack_app.handlers import topic_guard | ||
| from firetower.slack_app.handlers.topic_guard import handle_channel_topic_change | ||
|
|
||
| CHANNEL_ID = "C_TEST_CHANNEL" | ||
| BOT_USER_ID = "U0000" | ||
| CANONICAL = "[P2] <https://ft/INC-1|INC-1 Test>" | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def reset_module_state(): | ||
| topic_guard._bot_user_id = None | ||
| topic_guard._recent_event_ids.clear() | ||
| yield | ||
| topic_guard._bot_user_id = None | ||
| topic_guard._recent_event_ids.clear() | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def client(): | ||
| c = MagicMock() | ||
| c.auth_test.return_value = {"user_id": BOT_USER_ID} | ||
| return c | ||
|
|
||
|
|
||
| _TS_COUNTER = [0] | ||
|
|
||
|
|
||
| def _event(user="U_OTHER", topic="Something custom", channel=CHANNEL_ID, ts=None): | ||
| if ts is None: | ||
| _TS_COUNTER[0] += 1 | ||
| ts = f"1700000000.{_TS_COUNTER[0]:06d}" | ||
| return { | ||
| "type": "message", | ||
| "subtype": "channel_topic", | ||
| "channel": channel, | ||
| "user": user, | ||
| "topic": topic, | ||
| "event_ts": ts, | ||
| } | ||
|
|
||
|
|
||
| @patch("firetower.slack_app.handlers.topic_guard._slack_service") | ||
| @patch("firetower.slack_app.handlers.topic_guard.build_channel_topic") | ||
| @patch("firetower.slack_app.handlers.topic_guard.get_incident_from_channel") | ||
| def test_manual_change_resets_and_notifies( | ||
| mock_get_incident, mock_build_topic, mock_slack_service, client | ||
| ): | ||
| mock_get_incident.return_value = MagicMock() | ||
| mock_build_topic.return_value = CANONICAL | ||
|
|
||
| handle_channel_topic_change(_event(topic="My custom topic"), client) | ||
|
|
||
| mock_slack_service.set_channel_topic.assert_called_once_with(CHANNEL_ID, CANONICAL) | ||
| client.chat_postEphemeral.assert_called_once() | ||
| kwargs = client.chat_postEphemeral.call_args[1] | ||
| assert kwargs["channel"] == CHANNEL_ID | ||
| assert kwargs["user"] == "U_OTHER" | ||
| assert "My custom topic" in kwargs["text"] | ||
|
|
||
|
|
||
| @patch("firetower.slack_app.handlers.topic_guard._slack_service") | ||
| @patch("firetower.slack_app.handlers.topic_guard.get_incident_from_channel") | ||
| def test_bot_authored_event_is_ignored(mock_get_incident, mock_slack_service, client): | ||
| handle_channel_topic_change(_event(user=BOT_USER_ID), client) | ||
|
|
||
| mock_get_incident.assert_not_called() | ||
| mock_slack_service.set_channel_topic.assert_not_called() | ||
| client.chat_postEphemeral.assert_not_called() | ||
|
|
||
|
|
||
| @patch("firetower.slack_app.handlers.topic_guard._slack_service") | ||
| @patch("firetower.slack_app.handlers.topic_guard.get_incident_from_channel") | ||
| def test_non_incident_channel_is_noop(mock_get_incident, mock_slack_service, client): | ||
| mock_get_incident.return_value = None | ||
|
|
||
| handle_channel_topic_change(_event(), client) | ||
|
Check failure on line 80 in src/firetower/slack_app/tests/test_topic_guard.py
|
||
|
|
||
| mock_slack_service.set_channel_topic.assert_not_called() | ||
| client.chat_postEphemeral.assert_not_called() | ||
|
|
||
|
|
||
| @patch("firetower.slack_app.handlers.topic_guard._slack_service") | ||
| @patch("firetower.slack_app.handlers.topic_guard.get_incident_from_channel") | ||
| def test_missing_channel_is_noop(mock_get_incident, mock_slack_service, client): | ||
| event = _event() | ||
| del event["channel"] | ||
|
|
||
| handle_channel_topic_change(event, client) | ||
|
|
||
| mock_get_incident.assert_not_called() | ||
| mock_slack_service.set_channel_topic.assert_not_called() | ||
| client.chat_postEphemeral.assert_not_called() | ||
|
|
||
|
|
||
| @patch("firetower.slack_app.handlers.topic_guard._slack_service") | ||
| @patch("firetower.slack_app.handlers.topic_guard.build_channel_topic") | ||
| @patch("firetower.slack_app.handlers.topic_guard.get_incident_from_channel") | ||
| def test_attempted_topic_is_escaped( | ||
| mock_get_incident, mock_build_topic, mock_slack_service, client | ||
| ): | ||
| mock_get_incident.return_value = MagicMock() | ||
| mock_build_topic.return_value = CANONICAL | ||
|
|
||
| handle_channel_topic_change( | ||
| _event(topic="<!channel> see <https://evil|here> & <@U999>"), client | ||
| ) | ||
|
|
||
| text = client.chat_postEphemeral.call_args[1]["text"] | ||
| assert "<!channel>" not in text | ||
| assert "<@U999>" not in text | ||
| assert "<https://evil|here>" not in text | ||
| assert "<!channel>" in text | ||
| assert "&" in text | ||
|
|
||
|
|
||
| @patch("firetower.slack_app.handlers.topic_guard._slack_service") | ||
| @patch("firetower.slack_app.handlers.topic_guard.build_channel_topic") | ||
| @patch("firetower.slack_app.handlers.topic_guard.get_incident_from_channel") | ||
| def test_ephemeral_failure_still_resets( | ||
| mock_get_incident, mock_build_topic, mock_slack_service, client | ||
| ): | ||
| mock_get_incident.return_value = MagicMock() | ||
| mock_build_topic.return_value = CANONICAL | ||
| client.chat_postEphemeral.side_effect = Exception("boom") | ||
|
|
||
| handle_channel_topic_change(_event(), client) | ||
|
|
||
| mock_slack_service.set_channel_topic.assert_called_once_with(CHANNEL_ID, CANONICAL) | ||
| client.chat_postEphemeral.assert_called_once() | ||
|
|
||
|
|
||
| @patch("firetower.slack_app.handlers.topic_guard._slack_service") | ||
| @patch("firetower.slack_app.handlers.topic_guard.build_channel_topic") | ||
| @patch("firetower.slack_app.handlers.topic_guard.get_incident_from_channel") | ||
| def test_bot_id_cache_is_reused( | ||
| mock_get_incident, mock_build_topic, mock_slack_service, client | ||
| ): | ||
| mock_get_incident.return_value = MagicMock() | ||
| mock_build_topic.return_value = CANONICAL | ||
|
|
||
| handle_channel_topic_change(_event(), client) | ||
| handle_channel_topic_change(_event(), client) | ||
|
|
||
| assert client.auth_test.call_count == 1 | ||
|
|
||
|
|
||
| @patch("firetower.slack_app.handlers.topic_guard._slack_service") | ||
| @patch("firetower.slack_app.handlers.topic_guard.get_incident_from_channel") | ||
| def test_auth_test_failure_does_not_crash( | ||
| mock_get_incident, mock_slack_service, client | ||
| ): | ||
| client.auth_test.side_effect = Exception("transient") | ||
|
|
||
| handle_channel_topic_change(_event(), client) | ||
|
|
||
| mock_get_incident.assert_called_once() | ||
|
|
||
|
|
||
| @patch("firetower.slack_app.handlers.topic_guard._slack_service") | ||
| @patch("firetower.slack_app.handlers.topic_guard.build_channel_topic") | ||
| @patch("firetower.slack_app.handlers.topic_guard.get_incident_from_channel") | ||
| def test_duplicate_event_id_is_skipped( | ||
| mock_get_incident, mock_build_topic, mock_slack_service, client | ||
| ): | ||
| mock_get_incident.return_value = MagicMock() | ||
| mock_build_topic.return_value = CANONICAL | ||
| event = _event(ts="1700000000.999999") | ||
|
|
||
| handle_channel_topic_change(event, client) | ||
| handle_channel_topic_change(event, client) | ||
|
|
||
| mock_slack_service.set_channel_topic.assert_called_once() | ||
| client.chat_postEphemeral.assert_called_once() | ||
Uh oh!
There was an error while loading. Please reload this page.