diff --git a/alerts/models.py b/alerts/models.py index 2ee439a..7d5e623 100644 --- a/alerts/models.py +++ b/alerts/models.py @@ -3,14 +3,16 @@ from projects.models import Project from .service_backends.slack import SlackBackend from .service_backends.mattermost import MattermostBackend +from .service_backends.discord import DiscordBackend def kind_choices(): # As a callable to avoid non-DB-affecting migrations for adding new kinds. # Messaging backends don't need translations since they are brand names. return [ - ("slack", "Slack"), + ("discord", "Discord"), ("mattermost", "Mattermost"), + ("slack", "Slack"), ] @@ -38,12 +40,13 @@ class MessagingServiceConfig(models.Model): help_text="Error message from the exception") def get_backend(self): + if self.kind == "discord": + return DiscordBackend(self) + if self.kind == "mattermost": + return MattermostBackend(self) if self.kind == "slack": return SlackBackend(self) - elif self.kind == "mattermost": - return MattermostBackend(self) - else: - raise ValueError(f"Unknown backend kind: {self.kind}") + raise ValueError(f"Unknown backend kind: {self.kind}") def clear_failure_status(self): """Clear all failure tracking fields on successful operation""" diff --git a/alerts/service_backends/discord.py b/alerts/service_backends/discord.py new file mode 100644 index 0000000..c4d67bb --- /dev/null +++ b/alerts/service_backends/discord.py @@ -0,0 +1,214 @@ +import json +import requests +from django.utils import timezone + +from django import forms +from django.template.defaultfilters import truncatechars + +from snappea.decorators import shared_task +from bugsink.app_settings import get_settings +from bugsink.transaction import immediate_atomic + +from issues.models import Issue + + +class DiscordConfigForm(forms.Form): + # NOTE: As of yet this code isn't plugged into the UI (because it requires dynamic loading of the config-specific + # form) + webhook_url = forms.URLField(required=True) + + def __init__(self, *args, **kwargs): + config = kwargs.pop("config", None) + + super().__init__(*args, **kwargs) + if config: + self.fields["webhook_url"].initial = config.get("webhook_url", "") + + def get_config(self): + return { + "webhook_url": self.cleaned_data.get("webhook_url"), + } + + +def _store_failure_info(service_config_id, exception, response=None): + """Store failure information in the MessagingServiceConfig with immediate_atomic""" + from alerts.models import MessagingServiceConfig + + with immediate_atomic(only_if_needed=True): + try: + config = MessagingServiceConfig.objects.get(id=service_config_id) + + config.last_failure_timestamp = timezone.now() + config.last_failure_error_type = type(exception).__name__ + config.last_failure_error_message = str(exception) + + # Handle requests-specific errors + if response is not None: + config.last_failure_status_code = response.status_code + config.last_failure_response_text = response.text[:2000] # Limit response text size + + # Check if response is JSON + try: + json.loads(response.text) + config.last_failure_is_json = True + except (json.JSONDecodeError, ValueError): + config.last_failure_is_json = False + else: + # Non-HTTP errors + config.last_failure_status_code = None + config.last_failure_response_text = None + config.last_failure_is_json = None + + config.save() + except MessagingServiceConfig.DoesNotExist: + # Config was deleted while task was running + pass + + +def _store_success_info(service_config_id): + """Clear failure information on successful operation""" + from alerts.models import MessagingServiceConfig + + with immediate_atomic(only_if_needed=True): + try: + config = MessagingServiceConfig.objects.get(id=service_config_id) + config.clear_failure_status() + config.save() + except MessagingServiceConfig.DoesNotExist: + # Config was deleted while task was running + pass + + +@shared_task +def discord_backend_send_test_message( + webhook_url, project_name, display_name, service_config_id +): + # Discord uses embeds for rich formatting + # Color: 0x7289DA is Discord's blurple color + + data = { + "embeds": [ + { + "title": "TEST issue", + "description": "Test message by Bugsink to test the webhook setup.", + "color": 0x7289DA, + "fields": [ + {"name": "Project", "value": project_name, "inline": True}, + {"name": "Message Backend", "value": display_name, "inline": True}, + ], + } + ] + } + + try: + result = requests.post( + webhook_url, + data=json.dumps(data), + headers={"Content-Type": "application/json"}, + timeout=5, + ) + + result.raise_for_status() + + _store_success_info(service_config_id) + except requests.RequestException as e: + response = getattr(e, 'response', None) + _store_failure_info(service_config_id, e, response) + + except Exception as e: + _store_failure_info(service_config_id, e) + + +@shared_task +def discord_backend_send_alert( + webhook_url, issue_id, state_description, alert_article, alert_reason, service_config_id, unmute_reason=None): + + issue = Issue.objects.get(id=issue_id) + + issue_url = get_settings().BASE_URL + issue.get_absolute_url() + issue_title = truncatechars(issue.title(), 256) # Discord title limit + + # Color coding based on alert reason + # Red for new issues, orange for recurring, blue for unmuted + color_map = { + "NEW": 0xE74C3C, # Red + "RECURRING": 0xE67E22, # Orange + "UNMUTED": 0x3498DB, # Blue + } + color = color_map.get(alert_reason, 0x95A5A6) # Gray as default + + embed = { + "title": issue_title, + "url": issue_url, + "description": f"{alert_reason} issue", + "color": color, + "fields": [{"name": "Project", "value": issue.project.name, "inline": True}], + } + + if unmute_reason: + embed["fields"].append( + {"name": "Unmute Reason", "value": unmute_reason, "inline": False} + ) + + # left as a (possible) TODO, because the amount of refactoring (passing event to this function) is too big for now + # if event.release: + # embed["fields"].append({ + # "name": "Release", + # "value": event.release, + # "inline": True + # }) + # if event.environment: + # embed["fields"].append({ + # "name": "Environment", + # "value": event.environment, + # "inline": True + # }) + + data = {"embeds": [embed]} + + try: + result = requests.post( + webhook_url, + data=json.dumps(data), + headers={"Content-Type": "application/json"}, + timeout=5, + ) + + result.raise_for_status() + + _store_success_info(service_config_id) + except requests.RequestException as e: + response = getattr(e, 'response', None) + _store_failure_info(service_config_id, e, response) + + except Exception as e: + _store_failure_info(service_config_id, e) + + +class DiscordBackend: + + def __init__(self, service_config): + self.service_config = service_config + + def get_form_class(self): + return DiscordConfigForm + + def send_test_message(self): + discord_backend_send_test_message.delay( + json.loads(self.service_config.config)["webhook_url"], + self.service_config.project.name, + self.service_config.display_name, + self.service_config.id, + ) + + def send_alert(self, issue_id, state_description, alert_article, alert_reason, **kwargs): + config = json.loads(self.service_config.config) + discord_backend_send_alert.delay( + config["webhook_url"], + issue_id, + state_description, + alert_article, + alert_reason, + self.service_config.id, + **kwargs, + ) diff --git a/alerts/tests.py b/alerts/tests.py index f5f8c7f..f38f1f8 100644 --- a/alerts/tests.py +++ b/alerts/tests.py @@ -15,6 +15,7 @@ from teams.models import Team, TeamMembership from .models import MessagingServiceConfig from .service_backends.slack import slack_backend_send_test_message, slack_backend_send_alert +from .service_backends.discord import discord_backend_send_test_message, discord_backend_send_alert from .tasks import send_new_issue_alert, send_regression_alert, send_unmute_alert, _get_users_for_email_alert from .views import DEBUG_CONTEXTS @@ -302,3 +303,167 @@ class TestSlackBackendErrorHandling(DjangoTestCase): self.config.clear_failure_status() self.config.save() self.assertFalse(self.config.has_recent_failure()) + + +class TestDiscordBackendErrorHandling(DjangoTestCase): + def setUp(self): + self.project = Project.objects.create(name="Test project") + self.config = MessagingServiceConfig.objects.create( + project=self.project, + display_name="Test Discord", + kind="discord", + config=json.dumps({"webhook_url": "https://discord.com/api/webhooks/test"}), + ) + + @patch('alerts.service_backends.discord.requests.post') + def test_discord_test_message_success_clears_failure_status(self, mock_post): + # Set up existing failure status + self.config.last_failure_timestamp = timezone.now() + self.config.last_failure_status_code = 500 + self.config.last_failure_response_text = "Server Error" + self.config.save() + + # Mock successful response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + # Send test message + discord_backend_send_test_message( + "https://discord.com/api/webhooks/test", + "Test project", + "Test Discord", + self.config.id + ) + + # Verify failure status was cleared + self.config.refresh_from_db() + self.assertIsNone(self.config.last_failure_timestamp) + self.assertIsNone(self.config.last_failure_status_code) + self.assertIsNone(self.config.last_failure_response_text) + + @patch('alerts.service_backends.discord.requests.post') + def test_discord_test_message_http_error_stores_failure(self, mock_post): + # Mock HTTP error response + mock_response = Mock() + mock_response.status_code = 404 + mock_response.text = '{"message": "Unknown Webhook", "code": 10015}' + + # Create the HTTPError with response attached + http_error = requests.HTTPError() + http_error.response = mock_response + mock_response.raise_for_status.side_effect = http_error + + mock_post.return_value = mock_response + + # Send test message + discord_backend_send_test_message( + "https://discord.com/api/webhooks/test", + "Test project", + "Test Discord", + self.config.id + ) + + # Verify failure status was stored + self.config.refresh_from_db() + self.assertIsNotNone(self.config.last_failure_timestamp) + self.assertEqual(self.config.last_failure_status_code, 404) + self.assertEqual(self.config.last_failure_response_text, '{"message": "Unknown Webhook", "code": 10015}') + self.assertTrue(self.config.last_failure_is_json) + self.assertEqual(self.config.last_failure_error_type, "HTTPError") + + @patch('alerts.service_backends.discord.requests.post') + def test_discord_test_message_non_json_error_stores_failure(self, mock_post): + # Mock HTTP error response with non-JSON text + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = 'Internal Server Error' + + # Create the HTTPError with response attached + http_error = requests.HTTPError() + http_error.response = mock_response + mock_response.raise_for_status.side_effect = http_error + + mock_post.return_value = mock_response + + # Send test message + discord_backend_send_test_message( + "https://discord.com/api/webhooks/test", + "Test project", + "Test Discord", + self.config.id + ) + + # Verify failure status was stored + self.config.refresh_from_db() + self.assertIsNotNone(self.config.last_failure_timestamp) + self.assertEqual(self.config.last_failure_status_code, 500) + self.assertEqual(self.config.last_failure_response_text, 'Internal Server Error') + self.assertFalse(self.config.last_failure_is_json) + + @patch('alerts.service_backends.discord.requests.post') + def test_discord_test_message_connection_error_stores_failure(self, mock_post): + # Mock connection error + mock_post.side_effect = requests.ConnectionError("Connection failed") + + # Send test message + discord_backend_send_test_message( + "https://discord.com/api/webhooks/test", + "Test project", + "Test Discord", + self.config.id + ) + + # Verify failure status was stored + self.config.refresh_from_db() + self.assertIsNotNone(self.config.last_failure_timestamp) + self.assertIsNone(self.config.last_failure_status_code) # No HTTP response + self.assertIsNone(self.config.last_failure_response_text) + self.assertIsNone(self.config.last_failure_is_json) + self.assertEqual(self.config.last_failure_error_type, "ConnectionError") + self.assertEqual(self.config.last_failure_error_message, "Connection failed") + + @patch('alerts.service_backends.discord.requests.post') + def test_discord_alert_message_success_clears_failure_status(self, mock_post): + # Set up existing failure status + self.config.last_failure_timestamp = timezone.now() + self.config.last_failure_status_code = 500 + self.config.save() + + # Create issue + issue, _ = get_or_create_issue(project=self.project) + + # Mock successful response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + # Send alert message + discord_backend_send_alert( + "https://discord.com/api/webhooks/test", + issue.id, + "New issue", + "a", + "NEW", + self.config.id + ) + + # Verify failure status was cleared + self.config.refresh_from_db() + self.assertIsNone(self.config.last_failure_timestamp) + + def test_has_recent_failure_method(self): + # Initially no failure + self.assertFalse(self.config.has_recent_failure()) + + # Set failure + self.config.last_failure_timestamp = timezone.now() + self.config.save() + self.assertTrue(self.config.has_recent_failure()) + + # Clear failure + self.config.clear_failure_status() + self.config.save() + self.assertFalse(self.config.has_recent_failure())