From b0e56c0bc21a561a2b8518c1daea1b7bddd6ffd4 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 19 Feb 2026 17:37:46 -0500 Subject: [PATCH 01/14] Add slack-bolt dependency --- pyproject.toml | 1 + uv.lock | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0c9ee6c..86228ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "jira>=3.5.0", "psycopg[binary]>=3.2.11", "pyserde[toml]>=0.28.0", + "slack-bolt>=1.27.0", "slack-sdk>=3.31.0", ] diff --git a/uv.lock b/uv.lock index 7517980..3c83ae3 100644 --- a/uv.lock +++ b/uv.lock @@ -528,6 +528,7 @@ dependencies = [ { name = "jira" }, { name = "psycopg", extra = ["binary"] }, { name = "pyserde", extra = ["toml"] }, + { name = "slack-bolt" }, { name = "slack-sdk" }, ] @@ -562,6 +563,7 @@ requires-dist = [ { name = "jira", specifier = ">=3.5.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.11" }, { name = "pyserde", extras = ["toml"], specifier = ">=0.28.0" }, + { name = "slack-bolt", specifier = ">=1.27.0" }, { name = "slack-sdk", specifier = ">=3.31.0" }, ] @@ -1294,13 +1296,25 @@ django = [ { name = "django" }, ] +[[package]] +name = "slack-bolt" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "slack-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/28/50ed0b86e48b48e6ddcc71de93b91c8ac14a55d1249e4bff0586494a2f90/slack_bolt-1.27.0.tar.gz", hash = "sha256:3db91d64e277e176a565c574ae82748aa8554f19e41a4fceadca4d65374ce1e0", size = 129101, upload-time = "2025-11-13T20:17:46.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/a8/1acb355759747ba4da5f45c1a33d641994b9e04b914908c9434f18bd97e8/slack_bolt-1.27.0-py2.py3-none-any.whl", hash = "sha256:c43c94bf34740f2adeb9b55566c83f1e73fed6ba2878bd346cdfd6fd8ad22360", size = 230428, upload-time = "2025-11-13T20:17:45.465Z" }, +] + [[package]] name = "slack-sdk" -version = "3.37.0" +version = "3.40.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/c2/0a174a155623d7dc3ed4d1360cdf755590acdc2c3fc9ce0d2340f468909f/slack_sdk-3.37.0.tar.gz", hash = "sha256:242d6cffbd9e843af807487ff04853189b812081aeaa22f90a8f159f20220ed9", size = 241612, upload-time = "2025-10-06T23:07:20.856Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/18/784859b33a3f9c8cdaa1eda4115eb9fe72a0a37304718887d12991eeb2fd/slack_sdk-3.40.1.tar.gz", hash = "sha256:a215333bc251bc90abf5f5110899497bf61a3b5184b6d9ee35d73ebf09ec3fd0", size = 250379, upload-time = "2026-02-18T22:11:01.819Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/fd/a502ee24d8c7d12a8f749878ae0949b8eeb50aeac22dc5a613d417a256d0/slack_sdk-3.37.0-py2.py3-none-any.whl", hash = "sha256:e108a0836eafda74d8a95e76c12c2bcb010e645d504d8497451e4c7ebb229c87", size = 302751, upload-time = "2025-10-06T23:07:19.542Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/bb81f93c9f403e3b573c429dd4838ec9b44e4ef35f3b0759eb49557ab6e3/slack_sdk-3.40.1-py2.py3-none-any.whl", hash = "sha256:cd8902252979aa248092b0d77f3a9ea3cc605bc5d53663ad728e892e26e14a65", size = 313687, upload-time = "2026-02-18T22:11:00.027Z" }, ] [[package]] From ca5e90dc1b77341e5e9abadb8a80d5382f88897f Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 19 Feb 2026 17:37:50 -0500 Subject: [PATCH 02/14] Add signing_secret to Slack config --- config.example.toml | 1 + src/firetower/config.py | 2 ++ src/firetower/settings.py | 2 ++ 3 files changed, 5 insertions(+) diff --git a/config.example.toml b/config.example.toml index 3866f80..c0c2535 100644 --- a/config.example.toml +++ b/config.example.toml @@ -18,6 +18,7 @@ severity_field = "customfield_11023" bot_token = "" team_id = "" participant_sync_throttle_seconds = 300 +signing_secret = "" [auth] iap_enabled = false diff --git a/src/firetower/config.py b/src/firetower/config.py index 1f53250..4376e2d 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -38,6 +38,7 @@ class SlackConfig: bot_token: str team_id: str participant_sync_throttle_seconds: int + signing_secret: str @deserialize @@ -113,6 +114,7 @@ def __init__(self) -> None: bot_token="", team_id="", participant_sync_throttle_seconds=0, + signing_secret="", ) self.auth = AuthConfig( iap_enabled=False, diff --git a/src/firetower/settings.py b/src/firetower/settings.py index 8ecb019..b46f06d 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -104,6 +104,7 @@ def cmd_needs_dummy_config() -> bool: "firetower.auth", "firetower.incidents", "firetower.integrations", + "firetower.slack_app", ] MIDDLEWARE = [ @@ -208,6 +209,7 @@ def cmd_needs_dummy_config() -> bool: SLACK = { "BOT_TOKEN": config.slack.bot_token, "TEAM_ID": config.slack.team_id, + "SIGNING_SECRET": config.slack.signing_secret, } PARTICIPANT_SYNC_THROTTLE_SECONDS = int(config.slack.participant_sync_throttle_seconds) From 10773d20206c42dc0ef4af1081d365baee176bfe Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 19 Feb 2026 17:38:51 -0500 Subject: [PATCH 03/14] Add slack_app Django app with Bolt wiring and /inc help command --- src/firetower/slack_app/__init__.py | 0 src/firetower/slack_app/apps.py | 6 ++ src/firetower/slack_app/authentication.py | 67 +++++++++++++ src/firetower/slack_app/block_kits.py | 0 src/firetower/slack_app/bolt.py | 30 ++++++ src/firetower/slack_app/handlers/__init__.py | 0 src/firetower/slack_app/handlers/help.py | 12 +++ src/firetower/slack_app/tests/__init__.py | 0 .../slack_app/tests/test_handlers.py | 64 +++++++++++++ src/firetower/slack_app/tests/test_views.py | 93 +++++++++++++++++++ src/firetower/slack_app/urls.py | 7 ++ src/firetower/slack_app/views.py | 24 +++++ src/firetower/urls.py | 1 + 13 files changed, 304 insertions(+) create mode 100644 src/firetower/slack_app/__init__.py create mode 100644 src/firetower/slack_app/apps.py create mode 100644 src/firetower/slack_app/authentication.py create mode 100644 src/firetower/slack_app/block_kits.py create mode 100644 src/firetower/slack_app/bolt.py create mode 100644 src/firetower/slack_app/handlers/__init__.py create mode 100644 src/firetower/slack_app/handlers/help.py create mode 100644 src/firetower/slack_app/tests/__init__.py create mode 100644 src/firetower/slack_app/tests/test_handlers.py create mode 100644 src/firetower/slack_app/tests/test_views.py create mode 100644 src/firetower/slack_app/urls.py create mode 100644 src/firetower/slack_app/views.py diff --git a/src/firetower/slack_app/__init__.py b/src/firetower/slack_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/firetower/slack_app/apps.py b/src/firetower/slack_app/apps.py new file mode 100644 index 0000000..9365c12 --- /dev/null +++ b/src/firetower/slack_app/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SlackAppConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "firetower.slack_app" diff --git a/src/firetower/slack_app/authentication.py b/src/firetower/slack_app/authentication.py new file mode 100644 index 0000000..d8ee2c0 --- /dev/null +++ b/src/firetower/slack_app/authentication.py @@ -0,0 +1,67 @@ +import hashlib +import hmac +import time + +from django.conf import settings +from django.contrib.auth import get_user_model +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed +from rest_framework.request import Request + +User = get_user_model() + +SERVICE_USERNAME = "firetower-slack-app" + + +class SlackSigningSecretAuthentication(BaseAuthentication): + """ + DRF authentication class that verifies Slack request signatures. + + Validates the X-Slack-Signature header using HMAC-SHA256 with the + configured signing secret. On success, returns a service user. + """ + + MAX_TIMESTAMP_AGE_SECONDS = 120 + + def authenticate(self, request: Request) -> tuple | None: + django_request = request._request + + timestamp = django_request.META.get("HTTP_X_SLACK_REQUEST_TIMESTAMP") + signature = django_request.META.get("HTTP_X_SLACK_SIGNATURE") + + if not timestamp or not signature: + return None + + try: + ts = int(timestamp) + except ValueError: + raise AuthenticationFailed("Invalid timestamp") + + if abs(time.time() - ts) > self.MAX_TIMESTAMP_AGE_SECONDS: + raise AuthenticationFailed("Request timestamp too old") + + signing_secret = settings.SLACK.get("SIGNING_SECRET", "") + raw_body = django_request.body + sig_basestring = f"v0:{timestamp}:{raw_body.decode('utf-8')}" + + computed = ( + "v0=" + + hmac.new( + signing_secret.encode("utf-8"), + sig_basestring.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + ) + + if not hmac.compare_digest(computed, signature): + raise AuthenticationFailed("Invalid signature") + + user, _ = User.objects.get_or_create( + username=SERVICE_USERNAME, + defaults={"is_active": True}, + ) + if not user.has_usable_password(): + user.set_unusable_password() + user.save(update_fields=["password"]) + + return (user, None) diff --git a/src/firetower/slack_app/block_kits.py b/src/firetower/slack_app/block_kits.py new file mode 100644 index 0000000..e69de29 diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py new file mode 100644 index 0000000..08daca0 --- /dev/null +++ b/src/firetower/slack_app/bolt.py @@ -0,0 +1,30 @@ +import logging +from typing import Any + +from django.conf import settings +from slack_bolt import App + +from firetower.slack_app.handlers.help import handle_help_command + +logger = logging.getLogger(__name__) + +slack_config = settings.SLACK + +bolt_app = App( + token=slack_config["BOT_TOKEN"], + signing_secret=slack_config["SIGNING_SECRET"], + token_verification_enabled=False, +) + + +@bolt_app.command("/inc") +@bolt_app.command("/testinc") +def handle_inc(ack: Any, body: dict, command: dict, respond: Any) -> None: + subcommand = (body.get("text") or "").strip().lower() + + if subcommand in ("help", ""): + handle_help_command(ack, command, respond) + else: + ack() + cmd = command.get("command", "/inc") + respond(f"Unknown command: `{cmd} {subcommand}`. Try `{cmd} help`.") diff --git a/src/firetower/slack_app/handlers/__init__.py b/src/firetower/slack_app/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/firetower/slack_app/handlers/help.py b/src/firetower/slack_app/handlers/help.py new file mode 100644 index 0000000..dbd0213 --- /dev/null +++ b/src/firetower/slack_app/handlers/help.py @@ -0,0 +1,12 @@ +from typing import Any + + +def handle_help_command(ack: Any, command: dict, respond: Any) -> None: + ack() + cmd = command.get("command", "/inc") + respond( + f"*Firetower Incident Bot*\n" + f"Usage: `{cmd} `\n\n" + f"Available commands:\n" + f" `{cmd} help` - Show this help message\n" + ) diff --git a/src/firetower/slack_app/tests/__init__.py b/src/firetower/slack_app/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/firetower/slack_app/tests/test_handlers.py b/src/firetower/slack_app/tests/test_handlers.py new file mode 100644 index 0000000..845a0f7 --- /dev/null +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -0,0 +1,64 @@ +from unittest.mock import MagicMock + +from firetower.slack_app.bolt import handle_inc + + +class TestHandleInc: + def _make_body(self, text="", command="/inc"): + return {"text": text, "command": command} + + def _make_command(self, command="/inc", text=""): + return {"command": command, "text": text} + + def test_help_returns_help_text(self): + ack = MagicMock() + respond = MagicMock() + body = self._make_body(text="help") + command = self._make_command() + + handle_inc(ack=ack, body=body, command=command, respond=respond) + + ack.assert_called_once() + respond.assert_called_once() + response_text = respond.call_args[0][0] + assert "Firetower Incident Bot" in response_text + assert "/inc help" in response_text + + def test_empty_text_returns_help(self): + ack = MagicMock() + respond = MagicMock() + body = self._make_body(text="") + command = self._make_command() + + handle_inc(ack=ack, body=body, command=command, respond=respond) + + ack.assert_called_once() + respond.assert_called_once() + response_text = respond.call_args[0][0] + assert "Firetower Incident Bot" in response_text + + def test_unknown_subcommand_returns_error(self): + ack = MagicMock() + respond = MagicMock() + body = self._make_body(text="unknown") + command = self._make_command() + + handle_inc(ack=ack, body=body, command=command, respond=respond) + + ack.assert_called_once() + respond.assert_called_once() + response_text = respond.call_args[0][0] + assert "Unknown command" in response_text + assert "/inc unknown" in response_text + + def test_help_uses_testinc_command(self): + ack = MagicMock() + respond = MagicMock() + body = self._make_body(text="help", command="/testinc") + command = self._make_command(command="/testinc") + + handle_inc(ack=ack, body=body, command=command, respond=respond) + + ack.assert_called_once() + response_text = respond.call_args[0][0] + assert "/testinc help" in response_text diff --git a/src/firetower/slack_app/tests/test_views.py b/src/firetower/slack_app/tests/test_views.py new file mode 100644 index 0000000..8e2d14f --- /dev/null +++ b/src/firetower/slack_app/tests/test_views.py @@ -0,0 +1,93 @@ +import hashlib +import hmac +import time +from unittest.mock import patch + +import pytest +from django.conf import settings +from django.http import HttpResponse +from rest_framework.test import APIClient + + +@pytest.mark.django_db +class TestSlackEventsEndpoint: + def setup_method(self): + self.client = APIClient() + self.url = "/slack/events" + self.signing_secret = settings.SLACK["SIGNING_SECRET"] + + def _sign_request(self, body: str, timestamp: str | None = None): + ts = timestamp or str(int(time.time())) + sig_basestring = f"v0:{ts}:{body}" + signature = ( + "v0=" + + hmac.new( + self.signing_secret.encode("utf-8"), + sig_basestring.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + ) + return ts, signature + + def test_missing_auth_headers_returns_403(self): + response = self.client.post( + self.url, + data="command=/inc&text=help", + content_type="application/x-www-form-urlencoded", + ) + assert response.status_code == 403 + + def test_invalid_signature_returns_403(self): + ts = str(int(time.time())) + response = self.client.post( + self.url, + data="command=/inc&text=help", + content_type="application/x-www-form-urlencoded", + HTTP_X_SLACK_REQUEST_TIMESTAMP=ts, + HTTP_X_SLACK_SIGNATURE="v0=invalidsignature", + ) + assert response.status_code == 403 + + def test_expired_timestamp_returns_403(self): + old_ts = str(int(time.time()) - 300) + body = "command=/inc&text=help" + _, signature = self._sign_request(body, old_ts) + response = self.client.post( + self.url, + data=body, + content_type="application/x-www-form-urlencoded", + HTTP_X_SLACK_REQUEST_TIMESTAMP=old_ts, + HTTP_X_SLACK_SIGNATURE=signature, + ) + assert response.status_code == 403 + + @patch("firetower.slack_app.views.handler") + def test_valid_signature_returns_200(self, mock_handler): + mock_handler.handle.return_value = HttpResponse(status=200) + + body = "command=/inc&text=help" + ts, signature = self._sign_request(body) + response = self.client.post( + self.url, + data=body, + content_type="application/x-www-form-urlencoded", + HTTP_X_SLACK_REQUEST_TIMESTAMP=ts, + HTTP_X_SLACK_SIGNATURE=signature, + ) + assert response.status_code == 200 + mock_handler.handle.assert_called_once() + + @patch("firetower.slack_app.views.handler") + def test_csrf_not_enforced(self, mock_handler): + mock_handler.handle.return_value = HttpResponse(status=200) + + body = "command=/inc&text=help" + ts, signature = self._sign_request(body) + response = self.client.post( + self.url, + data=body, + content_type="application/x-www-form-urlencoded", + HTTP_X_SLACK_REQUEST_TIMESTAMP=ts, + HTTP_X_SLACK_SIGNATURE=signature, + ) + assert response.status_code == 200 diff --git a/src/firetower/slack_app/urls.py b/src/firetower/slack_app/urls.py new file mode 100644 index 0000000..d4b58a4 --- /dev/null +++ b/src/firetower/slack_app/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from firetower.slack_app.views import slack_events + +urlpatterns = [ + path("events", slack_events), +] diff --git a/src/firetower/slack_app/views.py b/src/firetower/slack_app/views.py new file mode 100644 index 0000000..30c116f --- /dev/null +++ b/src/firetower/slack_app/views.py @@ -0,0 +1,24 @@ +from django.http import HttpResponse +from rest_framework.decorators import ( + api_view, + authentication_classes, + parser_classes, + permission_classes, +) +from rest_framework.parsers import FormParser, JSONParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from slack_bolt.adapter.django import SlackRequestHandler + +from firetower.slack_app.authentication import SlackSigningSecretAuthentication +from firetower.slack_app.bolt import bolt_app + +handler = SlackRequestHandler(app=bolt_app) + + +@api_view(["POST"]) +@authentication_classes([SlackSigningSecretAuthentication]) +@permission_classes([IsAuthenticated]) +@parser_classes([FormParser, JSONParser]) +def slack_events(request: Request) -> HttpResponse: + return handler.handle(request._request) diff --git a/src/firetower/urls.py b/src/firetower/urls.py index b211cee..84d76f1 100644 --- a/src/firetower/urls.py +++ b/src/firetower/urls.py @@ -24,6 +24,7 @@ path("admin/", admin.site.urls), path("api/", include("firetower.auth.urls")), path("api/", include("firetower.incidents.urls")), + path("slack/", include("firetower.slack_app.urls")), # Health check endpoints with Datadog metrics path("readyz/", health.readiness_check, name="readiness"), path("livez/", health.liveness_check, name="liveness"), From 19360904999ba3331160d712bc55ec64d351828b Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 19 Feb 2026 17:39:20 -0500 Subject: [PATCH 04/14] Add Datadog metrics instrumentation for slash commands --- src/firetower/slack_app/bolt.py | 24 ++++++--- .../slack_app/tests/test_handlers.py | 49 +++++++++++++++++-- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py index 08daca0..bb40032 100644 --- a/src/firetower/slack_app/bolt.py +++ b/src/firetower/slack_app/bolt.py @@ -1,6 +1,7 @@ import logging from typing import Any +from datadog import statsd from django.conf import settings from slack_bolt import App @@ -8,6 +9,8 @@ logger = logging.getLogger(__name__) +METRICS_PREFIX = "slack_app.commands" + slack_config = settings.SLACK bolt_app = App( @@ -21,10 +24,17 @@ @bolt_app.command("/testinc") def handle_inc(ack: Any, body: dict, command: dict, respond: Any) -> None: subcommand = (body.get("text") or "").strip().lower() - - if subcommand in ("help", ""): - handle_help_command(ack, command, respond) - else: - ack() - cmd = command.get("command", "/inc") - respond(f"Unknown command: `{cmd} {subcommand}`. Try `{cmd} help`.") + tags = [f"subcommand:{subcommand or 'help'}"] + statsd.increment(f"{METRICS_PREFIX}.submitted", tags=tags) + + try: + if subcommand in ("help", ""): + handle_help_command(ack, command, respond) + else: + ack() + cmd = command.get("command", "/inc") + respond(f"Unknown command: `{cmd} {subcommand}`. Try `{cmd} help`.") + statsd.increment(f"{METRICS_PREFIX}.completed", tags=tags) + except Exception: + statsd.increment(f"{METRICS_PREFIX}.failed", tags=tags) + raise diff --git a/src/firetower/slack_app/tests/test_handlers.py b/src/firetower/slack_app/tests/test_handlers.py index 845a0f7..d878b38 100644 --- a/src/firetower/slack_app/tests/test_handlers.py +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call, patch from firetower.slack_app.bolt import handle_inc @@ -10,7 +10,8 @@ def _make_body(self, text="", command="/inc"): def _make_command(self, command="/inc", text=""): return {"command": command, "text": text} - def test_help_returns_help_text(self): + @patch("firetower.slack_app.bolt.statsd") + def test_help_returns_help_text(self, mock_statsd): ack = MagicMock() respond = MagicMock() body = self._make_body(text="help") @@ -24,7 +25,8 @@ def test_help_returns_help_text(self): assert "Firetower Incident Bot" in response_text assert "/inc help" in response_text - def test_empty_text_returns_help(self): + @patch("firetower.slack_app.bolt.statsd") + def test_empty_text_returns_help(self, mock_statsd): ack = MagicMock() respond = MagicMock() body = self._make_body(text="") @@ -37,7 +39,8 @@ def test_empty_text_returns_help(self): response_text = respond.call_args[0][0] assert "Firetower Incident Bot" in response_text - def test_unknown_subcommand_returns_error(self): + @patch("firetower.slack_app.bolt.statsd") + def test_unknown_subcommand_returns_error(self, mock_statsd): ack = MagicMock() respond = MagicMock() body = self._make_body(text="unknown") @@ -51,7 +54,8 @@ def test_unknown_subcommand_returns_error(self): assert "Unknown command" in response_text assert "/inc unknown" in response_text - def test_help_uses_testinc_command(self): + @patch("firetower.slack_app.bolt.statsd") + def test_help_uses_testinc_command(self, mock_statsd): ack = MagicMock() respond = MagicMock() body = self._make_body(text="help", command="/testinc") @@ -62,3 +66,38 @@ def test_help_uses_testinc_command(self): ack.assert_called_once() response_text = respond.call_args[0][0] assert "/testinc help" in response_text + + @patch("firetower.slack_app.bolt.statsd") + def test_emits_submitted_and_completed_metrics(self, mock_statsd): + ack = MagicMock() + respond = MagicMock() + body = self._make_body(text="help") + command = self._make_command() + + handle_inc(ack=ack, body=body, command=command, respond=respond) + + mock_statsd.increment.assert_has_calls( + [ + call("slack_app.commands.submitted", tags=["subcommand:help"]), + call("slack_app.commands.completed", tags=["subcommand:help"]), + ] + ) + + @patch("firetower.slack_app.bolt.statsd") + def test_emits_failed_metric_on_error(self, mock_statsd): + ack = MagicMock() + respond = MagicMock(side_effect=RuntimeError("boom")) + body = self._make_body(text="help") + command = self._make_command() + + try: + handle_inc(ack=ack, body=body, command=command, respond=respond) + except RuntimeError: + pass + + mock_statsd.increment.assert_any_call( + "slack_app.commands.submitted", tags=["subcommand:help"] + ) + mock_statsd.increment.assert_any_call( + "slack_app.commands.failed", tags=["subcommand:help"] + ) From d35354d6cb4be887a08dee379fb809a5d2fa33fc Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Fri, 20 Feb 2026 11:00:40 -0500 Subject: [PATCH 05/14] Add signing_secret to CI config and fix ruff lint --- config.ci.toml | 1 + src/firetower/slack_app/authentication.py | 3 --- src/firetower/slack_app/block_kits.py | 0 src/firetower/slack_app/bolt.py | 3 +++ src/firetower/slack_app/tests/test_views.py | 4 +++- 5 files changed, 7 insertions(+), 4 deletions(-) delete mode 100644 src/firetower/slack_app/block_kits.py diff --git a/config.ci.toml b/config.ci.toml index 6cd7a16..e5a7309 100644 --- a/config.ci.toml +++ b/config.ci.toml @@ -19,6 +19,7 @@ severity_field = "" bot_token = "" team_id = "" participant_sync_throttle_seconds = 300 +signing_secret = "test-signing-secret" [auth] iap_enabled = false diff --git a/src/firetower/slack_app/authentication.py b/src/firetower/slack_app/authentication.py index d8ee2c0..e1cb028 100644 --- a/src/firetower/slack_app/authentication.py +++ b/src/firetower/slack_app/authentication.py @@ -60,8 +60,5 @@ def authenticate(self, request: Request) -> tuple | None: username=SERVICE_USERNAME, defaults={"is_active": True}, ) - if not user.has_usable_password(): - user.set_unusable_password() - user.save(update_fields=["password"]) return (user, None) diff --git a/src/firetower/slack_app/block_kits.py b/src/firetower/slack_app/block_kits.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py index bb40032..2e72c19 100644 --- a/src/firetower/slack_app/bolt.py +++ b/src/firetower/slack_app/bolt.py @@ -36,5 +36,8 @@ def handle_inc(ack: Any, body: dict, command: dict, respond: Any) -> None: respond(f"Unknown command: `{cmd} {subcommand}`. Try `{cmd} help`.") statsd.increment(f"{METRICS_PREFIX}.completed", tags=tags) except Exception: + logger.exception( + "Slash command failed: %s %s", command.get("command", "/inc"), subcommand + ) statsd.increment(f"{METRICS_PREFIX}.failed", tags=tags) raise diff --git a/src/firetower/slack_app/tests/test_views.py b/src/firetower/slack_app/tests/test_views.py index 8e2d14f..77adfab 100644 --- a/src/firetower/slack_app/tests/test_views.py +++ b/src/firetower/slack_app/tests/test_views.py @@ -6,6 +6,7 @@ import pytest from django.conf import settings from django.http import HttpResponse +from django.test import Client from rest_framework.test import APIClient @@ -81,9 +82,10 @@ def test_valid_signature_returns_200(self, mock_handler): def test_csrf_not_enforced(self, mock_handler): mock_handler.handle.return_value = HttpResponse(status=200) + csrf_client = Client(enforce_csrf_checks=True) body = "command=/inc&text=help" ts, signature = self._sign_request(body) - response = self.client.post( + response = csrf_client.post( self.url, data=body, content_type="application/x-www-form-urlencoded", From bac305025b0896bd7d74704a826c28b9956a4514 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Fri, 20 Feb 2026 11:41:28 -0500 Subject: [PATCH 06/14] Add test values for slack bot --- config.ci.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.ci.toml b/config.ci.toml index e5a7309..6a5df6a 100644 --- a/config.ci.toml +++ b/config.ci.toml @@ -16,8 +16,8 @@ api_key = "" severity_field = "" [slack] -bot_token = "" -team_id = "" +bot_token = "test-bot-token" +team_id = "test-bot-id" participant_sync_throttle_seconds = 300 signing_secret = "test-signing-secret" From 7bd4189cedbfd8a6389d94d52a807500bb6f962b Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Fri, 20 Feb 2026 11:59:24 -0500 Subject: [PATCH 07/14] Fix TZ warnings --- src/firetower/incidents/tests/test_views.py | 26 ++++++++++----------- src/firetower/incidents/views.py | 17 ++++++++------ 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/firetower/incidents/tests/test_views.py b/src/firetower/incidents/tests/test_views.py index 233bfa2..77cec96 100644 --- a/src/firetower/incidents/tests/test_views.py +++ b/src/firetower/incidents/tests/test_views.py @@ -183,10 +183,10 @@ def test_list_incidents_filter_by_created_after(self): ) # Manually set created_at to test the filter Incident.objects.filter(pk=inc1.pk).update( - created_at=datetime(2024, 1, 1, 0, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0)) ) Incident.objects.filter(pk=inc2.pk).update( - created_at=datetime(2024, 6, 15, 12, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0)) ) self.client.force_authenticate(user=self.user) @@ -209,10 +209,10 @@ def test_list_incidents_filter_by_created_before(self): severity=IncidentSeverity.P1, ) Incident.objects.filter(pk=inc1.pk).update( - created_at=datetime(2024, 1, 1, 0, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0)) ) Incident.objects.filter(pk=inc2.pk).update( - created_at=datetime(2024, 6, 15, 12, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0)) ) self.client.force_authenticate(user=self.user) @@ -240,13 +240,13 @@ def test_list_incidents_filter_by_date_range(self): severity=IncidentSeverity.P1, ) Incident.objects.filter(pk=inc1.pk).update( - created_at=datetime(2024, 1, 1, 0, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0)) ) Incident.objects.filter(pk=inc2.pk).update( - created_at=datetime(2024, 6, 15, 12, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0)) ) Incident.objects.filter(pk=inc3.pk).update( - created_at=datetime(2024, 12, 1, 0, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 12, 1, 0, 0, 0)) ) self.client.force_authenticate(user=self.user) @@ -266,7 +266,7 @@ def test_list_incidents_filter_by_datetime_with_time(self): severity=IncidentSeverity.P1, ) Incident.objects.filter(pk=inc.pk).update( - created_at=datetime(2024, 6, 15, 14, 30, 0) + created_at=django_timezone.make_aware(datetime(2024, 6, 15, 14, 30, 0)) ) self.client.force_authenticate(user=self.user) @@ -831,10 +831,10 @@ def test_list_api_incidents_filter_by_date_range(self): severity=IncidentSeverity.P1, ) Incident.objects.filter(pk=inc1.pk).update( - created_at=datetime(2024, 1, 1, 0, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0)) ) Incident.objects.filter(pk=inc2.pk).update( - created_at=datetime(2024, 6, 15, 12, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0)) ) self.client.force_authenticate(user=self.user) @@ -938,13 +938,13 @@ def test_list_api_incidents_filter_by_severity_and_date(self): severity=IncidentSeverity.P2, ) Incident.objects.filter(pk=Incident.objects.get(title="P1 Old").pk).update( - created_at=datetime(2024, 1, 1, 0, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0)) ) Incident.objects.filter(pk=Incident.objects.get(title="P2 Old").pk).update( - created_at=datetime(2024, 1, 1, 0, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0)) ) Incident.objects.filter(pk=Incident.objects.get(title="P1 New").pk).update( - created_at=datetime(2024, 6, 15, 12, 0, 0) + created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0)) ) self.client.force_authenticate(user=self.user) diff --git a/src/firetower/incidents/views.py b/src/firetower/incidents/views.py index 5592fb2..6893175 100644 --- a/src/firetower/incidents/views.py +++ b/src/firetower/incidents/views.py @@ -6,6 +6,7 @@ from django.conf import settings from django.db.models import Count, QuerySet from django.shortcuts import get_object_or_404 +from django.utils import timezone as django_timezone from django.utils.dateparse import parse_datetime from rest_framework import generics, serializers from rest_framework.exceptions import ValidationError @@ -40,13 +41,15 @@ def parse_date_param(value: str) -> datetime | None: if not value: return None dt = parse_datetime(value) - if dt: - return dt - # Try parsing as date-only (YYYY-MM-DD) - try: - return datetime.fromisoformat(value) - except ValueError: - return None + if dt is None: + # Try parsing as date-only (YYYY-MM-DD) + try: + dt = datetime.fromisoformat(value) + except ValueError: + return None + if django_timezone.is_naive(dt): + dt = django_timezone.make_aware(dt) + return dt def filter_by_date_range( From e3d74002d95d669e3e506bd81706cef682342ee8 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Fri, 20 Feb 2026 12:10:55 -0500 Subject: [PATCH 08/14] tweaks --- src/firetower/slack_app/authentication.py | 2 +- src/firetower/slack_app/tests/test_handlers.py | 6 +++--- src/firetower/slack_app/tests/test_views.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/firetower/slack_app/authentication.py b/src/firetower/slack_app/authentication.py index e1cb028..5bc941d 100644 --- a/src/firetower/slack_app/authentication.py +++ b/src/firetower/slack_app/authentication.py @@ -21,7 +21,7 @@ class SlackSigningSecretAuthentication(BaseAuthentication): configured signing secret. On success, returns a service user. """ - MAX_TIMESTAMP_AGE_SECONDS = 120 + MAX_TIMESTAMP_AGE_SECONDS = 300 def authenticate(self, request: Request) -> tuple | None: django_request = request._request diff --git a/src/firetower/slack_app/tests/test_handlers.py b/src/firetower/slack_app/tests/test_handlers.py index d878b38..3c46f47 100644 --- a/src/firetower/slack_app/tests/test_handlers.py +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock, call, patch +import pytest + from firetower.slack_app.bolt import handle_inc @@ -90,10 +92,8 @@ def test_emits_failed_metric_on_error(self, mock_statsd): body = self._make_body(text="help") command = self._make_command() - try: + with pytest.raises(RuntimeError): handle_inc(ack=ack, body=body, command=command, respond=respond) - except RuntimeError: - pass mock_statsd.increment.assert_any_call( "slack_app.commands.submitted", tags=["subcommand:help"] diff --git a/src/firetower/slack_app/tests/test_views.py b/src/firetower/slack_app/tests/test_views.py index 77adfab..6eed3ed 100644 --- a/src/firetower/slack_app/tests/test_views.py +++ b/src/firetower/slack_app/tests/test_views.py @@ -50,7 +50,7 @@ def test_invalid_signature_returns_403(self): assert response.status_code == 403 def test_expired_timestamp_returns_403(self): - old_ts = str(int(time.time()) - 300) + old_ts = str(int(time.time()) - 600) body = "command=/inc&text=help" _, signature = self._sign_request(body, old_ts) response = self.client.post( From d6f469afc5210e2fa8d00f94709d6985cee8ff6b Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Fri, 20 Feb 2026 13:44:31 -0500 Subject: [PATCH 09/14] Rename slack app in help command --- src/firetower/slack_app/handlers/help.py | 2 +- src/firetower/slack_app/tests/test_handlers.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/firetower/slack_app/handlers/help.py b/src/firetower/slack_app/handlers/help.py index dbd0213..56476c2 100644 --- a/src/firetower/slack_app/handlers/help.py +++ b/src/firetower/slack_app/handlers/help.py @@ -5,7 +5,7 @@ def handle_help_command(ack: Any, command: dict, respond: Any) -> None: ack() cmd = command.get("command", "/inc") respond( - f"*Firetower Incident Bot*\n" + f"*Firetower Slack App*\n" f"Usage: `{cmd} `\n\n" f"Available commands:\n" f" `{cmd} help` - Show this help message\n" diff --git a/src/firetower/slack_app/tests/test_handlers.py b/src/firetower/slack_app/tests/test_handlers.py index 3c46f47..a7a930f 100644 --- a/src/firetower/slack_app/tests/test_handlers.py +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -24,7 +24,7 @@ def test_help_returns_help_text(self, mock_statsd): ack.assert_called_once() respond.assert_called_once() response_text = respond.call_args[0][0] - assert "Firetower Incident Bot" in response_text + assert "Firetower Slack App" in response_text assert "/inc help" in response_text @patch("firetower.slack_app.bolt.statsd") @@ -39,7 +39,7 @@ def test_empty_text_returns_help(self, mock_statsd): ack.assert_called_once() respond.assert_called_once() response_text = respond.call_args[0][0] - assert "Firetower Incident Bot" in response_text + assert "Firetower Slack App" in response_text @patch("firetower.slack_app.bolt.statsd") def test_unknown_subcommand_returns_error(self, mock_statsd): From 1bf93a9d277f4a5b1dfa23febd2250c805355c26 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Tue, 24 Feb 2026 12:38:57 -0500 Subject: [PATCH 10/14] Remove HTTP-based slack event handling in favor of Socket Mode --- src/firetower/slack_app/authentication.py | 64 -------------- src/firetower/slack_app/bolt.py | 6 +- src/firetower/slack_app/tests/test_views.py | 95 --------------------- src/firetower/slack_app/urls.py | 7 -- src/firetower/slack_app/views.py | 24 ------ src/firetower/urls.py | 1 - 6 files changed, 1 insertion(+), 196 deletions(-) delete mode 100644 src/firetower/slack_app/authentication.py delete mode 100644 src/firetower/slack_app/tests/test_views.py delete mode 100644 src/firetower/slack_app/urls.py delete mode 100644 src/firetower/slack_app/views.py diff --git a/src/firetower/slack_app/authentication.py b/src/firetower/slack_app/authentication.py deleted file mode 100644 index 5bc941d..0000000 --- a/src/firetower/slack_app/authentication.py +++ /dev/null @@ -1,64 +0,0 @@ -import hashlib -import hmac -import time - -from django.conf import settings -from django.contrib.auth import get_user_model -from rest_framework.authentication import BaseAuthentication -from rest_framework.exceptions import AuthenticationFailed -from rest_framework.request import Request - -User = get_user_model() - -SERVICE_USERNAME = "firetower-slack-app" - - -class SlackSigningSecretAuthentication(BaseAuthentication): - """ - DRF authentication class that verifies Slack request signatures. - - Validates the X-Slack-Signature header using HMAC-SHA256 with the - configured signing secret. On success, returns a service user. - """ - - MAX_TIMESTAMP_AGE_SECONDS = 300 - - def authenticate(self, request: Request) -> tuple | None: - django_request = request._request - - timestamp = django_request.META.get("HTTP_X_SLACK_REQUEST_TIMESTAMP") - signature = django_request.META.get("HTTP_X_SLACK_SIGNATURE") - - if not timestamp or not signature: - return None - - try: - ts = int(timestamp) - except ValueError: - raise AuthenticationFailed("Invalid timestamp") - - if abs(time.time() - ts) > self.MAX_TIMESTAMP_AGE_SECONDS: - raise AuthenticationFailed("Request timestamp too old") - - signing_secret = settings.SLACK.get("SIGNING_SECRET", "") - raw_body = django_request.body - sig_basestring = f"v0:{timestamp}:{raw_body.decode('utf-8')}" - - computed = ( - "v0=" - + hmac.new( - signing_secret.encode("utf-8"), - sig_basestring.encode("utf-8"), - hashlib.sha256, - ).hexdigest() - ) - - if not hmac.compare_digest(computed, signature): - raise AuthenticationFailed("Invalid signature") - - user, _ = User.objects.get_or_create( - username=SERVICE_USERNAME, - defaults={"is_active": True}, - ) - - return (user, None) diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py index 2e72c19..e703a34 100644 --- a/src/firetower/slack_app/bolt.py +++ b/src/firetower/slack_app/bolt.py @@ -13,11 +13,7 @@ slack_config = settings.SLACK -bolt_app = App( - token=slack_config["BOT_TOKEN"], - signing_secret=slack_config["SIGNING_SECRET"], - token_verification_enabled=False, -) +bolt_app = App(token=slack_config["BOT_TOKEN"]) @bolt_app.command("/inc") diff --git a/src/firetower/slack_app/tests/test_views.py b/src/firetower/slack_app/tests/test_views.py deleted file mode 100644 index 6eed3ed..0000000 --- a/src/firetower/slack_app/tests/test_views.py +++ /dev/null @@ -1,95 +0,0 @@ -import hashlib -import hmac -import time -from unittest.mock import patch - -import pytest -from django.conf import settings -from django.http import HttpResponse -from django.test import Client -from rest_framework.test import APIClient - - -@pytest.mark.django_db -class TestSlackEventsEndpoint: - def setup_method(self): - self.client = APIClient() - self.url = "/slack/events" - self.signing_secret = settings.SLACK["SIGNING_SECRET"] - - def _sign_request(self, body: str, timestamp: str | None = None): - ts = timestamp or str(int(time.time())) - sig_basestring = f"v0:{ts}:{body}" - signature = ( - "v0=" - + hmac.new( - self.signing_secret.encode("utf-8"), - sig_basestring.encode("utf-8"), - hashlib.sha256, - ).hexdigest() - ) - return ts, signature - - def test_missing_auth_headers_returns_403(self): - response = self.client.post( - self.url, - data="command=/inc&text=help", - content_type="application/x-www-form-urlencoded", - ) - assert response.status_code == 403 - - def test_invalid_signature_returns_403(self): - ts = str(int(time.time())) - response = self.client.post( - self.url, - data="command=/inc&text=help", - content_type="application/x-www-form-urlencoded", - HTTP_X_SLACK_REQUEST_TIMESTAMP=ts, - HTTP_X_SLACK_SIGNATURE="v0=invalidsignature", - ) - assert response.status_code == 403 - - def test_expired_timestamp_returns_403(self): - old_ts = str(int(time.time()) - 600) - body = "command=/inc&text=help" - _, signature = self._sign_request(body, old_ts) - response = self.client.post( - self.url, - data=body, - content_type="application/x-www-form-urlencoded", - HTTP_X_SLACK_REQUEST_TIMESTAMP=old_ts, - HTTP_X_SLACK_SIGNATURE=signature, - ) - assert response.status_code == 403 - - @patch("firetower.slack_app.views.handler") - def test_valid_signature_returns_200(self, mock_handler): - mock_handler.handle.return_value = HttpResponse(status=200) - - body = "command=/inc&text=help" - ts, signature = self._sign_request(body) - response = self.client.post( - self.url, - data=body, - content_type="application/x-www-form-urlencoded", - HTTP_X_SLACK_REQUEST_TIMESTAMP=ts, - HTTP_X_SLACK_SIGNATURE=signature, - ) - assert response.status_code == 200 - mock_handler.handle.assert_called_once() - - @patch("firetower.slack_app.views.handler") - def test_csrf_not_enforced(self, mock_handler): - mock_handler.handle.return_value = HttpResponse(status=200) - - csrf_client = Client(enforce_csrf_checks=True) - body = "command=/inc&text=help" - ts, signature = self._sign_request(body) - response = csrf_client.post( - self.url, - data=body, - content_type="application/x-www-form-urlencoded", - HTTP_X_SLACK_REQUEST_TIMESTAMP=ts, - HTTP_X_SLACK_SIGNATURE=signature, - ) - assert response.status_code == 200 diff --git a/src/firetower/slack_app/urls.py b/src/firetower/slack_app/urls.py deleted file mode 100644 index d4b58a4..0000000 --- a/src/firetower/slack_app/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import path - -from firetower.slack_app.views import slack_events - -urlpatterns = [ - path("events", slack_events), -] diff --git a/src/firetower/slack_app/views.py b/src/firetower/slack_app/views.py deleted file mode 100644 index 30c116f..0000000 --- a/src/firetower/slack_app/views.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.http import HttpResponse -from rest_framework.decorators import ( - api_view, - authentication_classes, - parser_classes, - permission_classes, -) -from rest_framework.parsers import FormParser, JSONParser -from rest_framework.permissions import IsAuthenticated -from rest_framework.request import Request -from slack_bolt.adapter.django import SlackRequestHandler - -from firetower.slack_app.authentication import SlackSigningSecretAuthentication -from firetower.slack_app.bolt import bolt_app - -handler = SlackRequestHandler(app=bolt_app) - - -@api_view(["POST"]) -@authentication_classes([SlackSigningSecretAuthentication]) -@permission_classes([IsAuthenticated]) -@parser_classes([FormParser, JSONParser]) -def slack_events(request: Request) -> HttpResponse: - return handler.handle(request._request) diff --git a/src/firetower/urls.py b/src/firetower/urls.py index 84d76f1..b211cee 100644 --- a/src/firetower/urls.py +++ b/src/firetower/urls.py @@ -24,7 +24,6 @@ path("admin/", admin.site.urls), path("api/", include("firetower.auth.urls")), path("api/", include("firetower.incidents.urls")), - path("slack/", include("firetower.slack_app.urls")), # Health check endpoints with Datadog metrics path("readyz/", health.readiness_check, name="readiness"), path("livez/", health.liveness_check, name="liveness"), From 0ab61df7614cae5c0b2a71b23719f810b01204db Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Tue, 24 Feb 2026 12:39:07 -0500 Subject: [PATCH 11/14] Add app_token config for Slack Socket Mode --- config.ci.toml | 1 + config.example.toml | 1 + src/firetower/config.py | 2 ++ src/firetower/settings.py | 1 + 4 files changed, 5 insertions(+) diff --git a/config.ci.toml b/config.ci.toml index 6a5df6a..2a4953e 100644 --- a/config.ci.toml +++ b/config.ci.toml @@ -20,6 +20,7 @@ bot_token = "test-bot-token" team_id = "test-bot-id" participant_sync_throttle_seconds = 300 signing_secret = "test-signing-secret" +app_token = "xapp-test-token" [auth] iap_enabled = false diff --git a/config.example.toml b/config.example.toml index c0c2535..74e63e2 100644 --- a/config.example.toml +++ b/config.example.toml @@ -19,6 +19,7 @@ bot_token = "" team_id = "" participant_sync_throttle_seconds = 300 signing_secret = "" +app_token = "" [auth] iap_enabled = false diff --git a/src/firetower/config.py b/src/firetower/config.py index 4376e2d..172c665 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -39,6 +39,7 @@ class SlackConfig: team_id: str participant_sync_throttle_seconds: int signing_secret: str + app_token: str @deserialize @@ -115,6 +116,7 @@ def __init__(self) -> None: team_id="", participant_sync_throttle_seconds=0, signing_secret="", + app_token="", ) self.auth = AuthConfig( iap_enabled=False, diff --git a/src/firetower/settings.py b/src/firetower/settings.py index b46f06d..2960d56 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -210,6 +210,7 @@ def cmd_needs_dummy_config() -> bool: "BOT_TOKEN": config.slack.bot_token, "TEAM_ID": config.slack.team_id, "SIGNING_SECRET": config.slack.signing_secret, + "APP_TOKEN": config.slack.app_token, } PARTICIPANT_SYNC_THROTTLE_SECONDS = int(config.slack.participant_sync_throttle_seconds) From cfe0808fb274af186aed1ff1344fe63fccbc1550 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Tue, 24 Feb 2026 12:39:16 -0500 Subject: [PATCH 12/14] Add run_slack_bot management command and Docker entrypoint --- docker/entrypoint.sh | 4 +++- .../slack_app/management/__init__.py | 0 .../slack_app/management/commands/__init__.py | 0 .../management/commands/run_slack_bot.py | 20 +++++++++++++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 src/firetower/slack_app/management/__init__.py create mode 100644 src/firetower/slack_app/management/commands/__init__.py create mode 100644 src/firetower/slack_app/management/commands/run_slack_bot.py diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index e01da76..b250e3a 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -8,8 +8,10 @@ if [ z"$1" = "zmigrate" ]; then COMMAND="/app/.venv/bin/django-admin migrate --settings firetower.settings" elif [ z"$1" = "zserver" ]; then COMMAND="/app/.venv/bin/granian --interface wsgi --host 0.0.0.0 --port $PORT firetower.wsgi:application" +elif [ z"$1" = "zslack-bot" ]; then + COMMAND="/app/.venv/bin/django-admin run_slack_bot --settings firetower.settings" else - echo "Usage: $0 (migrate|server)" + echo "Usage: $0 (migrate|server|slack-bot)" exit 1 fi diff --git a/src/firetower/slack_app/management/__init__.py b/src/firetower/slack_app/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/firetower/slack_app/management/commands/__init__.py b/src/firetower/slack_app/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/firetower/slack_app/management/commands/run_slack_bot.py b/src/firetower/slack_app/management/commands/run_slack_bot.py new file mode 100644 index 0000000..b734ba3 --- /dev/null +++ b/src/firetower/slack_app/management/commands/run_slack_bot.py @@ -0,0 +1,20 @@ +import logging +from typing import Any + +from django.conf import settings +from django.core.management.base import BaseCommand +from slack_bolt.adapter.socket_mode import SocketModeHandler + +from firetower.slack_app.bolt import bolt_app + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Start the Slack bot in Socket Mode" + + def handle(self, *args: Any, **options: Any) -> None: + app_token = settings.SLACK["APP_TOKEN"] + handler = SocketModeHandler(app=bolt_app, app_token=app_token) + logger.info("Starting Slack bot in Socket Mode") + handler.start() From 3101d49ad616ad3885074dda706ffec4459cdae2 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Tue, 24 Feb 2026 12:39:23 -0500 Subject: [PATCH 13/14] Add slack bot deploy step to GitHub Actions --- .github/workflows/deploy.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ed160b1..a7a6941 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -82,6 +82,15 @@ jobs: --container nginx --image "${{ env.STATIC_IMAGE_REF }}" --port 80 --container firetower-backend --image "${{ env.BACKEND_IMAGE_REF }}" + - name: deploy-test-slack-bot + if: ${{ inputs.environment == 'test' }} + uses: "google-github-actions/deploy-cloudrun@v3" + with: + service: firetower-slack-app-test + project_id: ${{ secrets.GCP_PROJECT_SLUG }} + region: us-west1 + image: ${{ env.BACKEND_IMAGE_REF }} + - name: deploy-prod-db-migration if: ${{ github.ref == 'refs/heads/main' && ((!inputs.environment) || inputs.environment == 'prod') }} uses: "google-github-actions/deploy-cloudrun@v3" From 97ffeafdf76b69389034bc41c850829e8f56777d Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Tue, 24 Feb 2026 12:53:42 -0500 Subject: [PATCH 14/14] Disable token verification in Bolt for Socket Mode --- src/firetower/slack_app/bolt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/firetower/slack_app/bolt.py b/src/firetower/slack_app/bolt.py index e703a34..1aa6676 100644 --- a/src/firetower/slack_app/bolt.py +++ b/src/firetower/slack_app/bolt.py @@ -13,7 +13,7 @@ slack_config = settings.SLACK -bolt_app = App(token=slack_config["BOT_TOKEN"]) +bolt_app = App(token=slack_config["BOT_TOKEN"], token_verification_enabled=False) @bolt_app.command("/inc")