mirror of
https://github.com/jlengrand/bugsink.git
synced 2026-03-09 23:51:20 +00:00
Add Discord backend support to MessagingServiceConfig and related tests
Fix #121
This commit is contained in:
committed by
Klaas van Schelven
parent
3f8c77784a
commit
c8792e5c84
@@ -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"""
|
||||
|
||||
221
alerts/service_backends/discord.py
Normal file
221
alerts/service_backends/discord.py
Normal file
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
|
||||
import requests
|
||||
from django import forms
|
||||
from django.template.defaultfilters import truncatechars
|
||||
from django.utils import timezone
|
||||
|
||||
from bugsink.app_settings import get_settings
|
||||
from bugsink.transaction import immediate_atomic
|
||||
from issues.models import Issue
|
||||
from snappea.decorators import shared_task
|
||||
|
||||
|
||||
class DiscordConfigForm(forms.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
|
||||
):
|
||||
discord_backend_send_alert.delay(
|
||||
json.loads(self.service_config.config)["webhook_url"],
|
||||
issue_id,
|
||||
state_description,
|
||||
alert_article,
|
||||
alert_reason,
|
||||
self.service_config.id,
|
||||
**kwargs,
|
||||
)
|
||||
165
alerts/tests.py
165
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())
|
||||
|
||||
Reference in New Issue
Block a user