diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ed160b1b..a7a6941b 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" diff --git a/config.ci.toml b/config.ci.toml index 6cd7a164..2a4953e0 100644 --- a/config.ci.toml +++ b/config.ci.toml @@ -16,9 +16,11 @@ 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" +app_token = "xapp-test-token" [auth] iap_enabled = false diff --git a/config.example.toml b/config.example.toml index 3866f805..74e63e2c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -18,6 +18,8 @@ severity_field = "customfield_11023" bot_token = "" team_id = "" participant_sync_throttle_seconds = 300 +signing_secret = "" +app_token = "" [auth] iap_enabled = false diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index e01da76e..b250e3a6 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/pyproject.toml b/pyproject.toml index 0c9ee6c0..86228cad 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/src/firetower/config.py b/src/firetower/config.py index 1f53250d..172c665f 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -38,6 +38,8 @@ class SlackConfig: bot_token: str team_id: str participant_sync_throttle_seconds: int + signing_secret: str + app_token: str @deserialize @@ -113,6 +115,8 @@ def __init__(self) -> None: bot_token="", team_id="", participant_sync_throttle_seconds=0, + signing_secret="", + app_token="", ) self.auth = AuthConfig( iap_enabled=False, diff --git a/src/firetower/incidents/tests/test_views.py b/src/firetower/incidents/tests/test_views.py index 233bfa24..77cec966 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 5592fb2a..6893175b 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( diff --git a/src/firetower/settings.py b/src/firetower/settings.py index 8ecb0197..2960d56a 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,8 @@ 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, + "APP_TOKEN": config.slack.app_token, } PARTICIPANT_SYNC_THROTTLE_SECONDS = int(config.slack.participant_sync_throttle_seconds) diff --git a/src/firetower/slack_app/__init__.py b/src/firetower/slack_app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/firetower/slack_app/apps.py b/src/firetower/slack_app/apps.py new file mode 100644 index 00000000..9365c122 --- /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/bolt.py b/src/firetower/slack_app/bolt.py new file mode 100644 index 00000000..1aa6676e --- /dev/null +++ b/src/firetower/slack_app/bolt.py @@ -0,0 +1,39 @@ +import logging +from typing import Any + +from datadog import statsd +from django.conf import settings +from slack_bolt import App + +from firetower.slack_app.handlers.help import handle_help_command + +logger = logging.getLogger(__name__) + +METRICS_PREFIX = "slack_app.commands" + +slack_config = settings.SLACK + +bolt_app = App(token=slack_config["BOT_TOKEN"], 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() + 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: + 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/handlers/__init__.py b/src/firetower/slack_app/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/firetower/slack_app/handlers/help.py b/src/firetower/slack_app/handlers/help.py new file mode 100644 index 00000000..56476c24 --- /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 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/management/__init__.py b/src/firetower/slack_app/management/__init__.py new file mode 100644 index 00000000..e69de29b 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 00000000..e69de29b 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 00000000..b734ba3b --- /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() diff --git a/src/firetower/slack_app/tests/__init__.py b/src/firetower/slack_app/tests/__init__.py new file mode 100644 index 00000000..e69de29b 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 00000000..a7a930fa --- /dev/null +++ b/src/firetower/slack_app/tests/test_handlers.py @@ -0,0 +1,103 @@ +from unittest.mock import MagicMock, call, patch + +import pytest + +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} + + @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") + 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 Slack App" in response_text + assert "/inc help" in response_text + + @patch("firetower.slack_app.bolt.statsd") + def test_empty_text_returns_help(self, mock_statsd): + 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 Slack App" in response_text + + @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") + 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 + + @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") + 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 + + @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() + + with pytest.raises(RuntimeError): + handle_inc(ack=ack, body=body, command=command, respond=respond) + + 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"] + ) diff --git a/uv.lock b/uv.lock index 75179806..3c83ae34 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]]