Merge pull request #279 from bugsink/hijacked-pr-265

Add discord backend support
This commit is contained in:
Klaas van Schelven
2025-11-25 13:40:09 +01:00
committed by GitHub
3 changed files with 387 additions and 5 deletions

View File

@@ -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"""

View File

@@ -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,
)

View File

@@ -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())