diff --git a/hc/api/models.py b/hc/api/models.py index e2441fb7..d1c019a4 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -34,7 +34,8 @@ CHANNEL_KINDS = (("email", "Email"), ("po", "Pushover"), ("pushbullet", "Pushbullet"), ("opsgenie", "OpsGenie"), - ("victorops", "VictorOps")) + ("victorops", "VictorOps"), + ("discord", "Discord")) PO_PRIORITIES = { -2: "lowest", @@ -243,6 +244,8 @@ class Channel(models.Model): return transports.Pushover(self) elif self.kind == "opsgenie": return transports.OpsGenie(self) + elif self.kind == "discord": + return transports.Discord(self) else: raise NotImplementedError("Unknown channel kind: %s" % self.kind) @@ -310,6 +313,24 @@ class Channel(models.Model): doc = json.loads(self.value) return doc["incoming_webhook"]["url"] + @property + def discord_webhook_url(self): + assert self.kind == "discord" + if not self.value.startswith("{"): + return self.value + + doc = json.loads(self.value) + return doc["webhook"]["url"] + + @property + def discord_webhook_id(self): + assert self.kind == "discord" + if not self.value.startswith("{"): + return self.value + + doc = json.loads(self.value) + return doc["webhook"]["id"] + def latest_notification(self): return Notification.objects.filter(channel=self).latest() diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index b65374a6..7fa09135 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -155,8 +155,8 @@ class NotifyTestCase(BaseTestCase): assert Notification.objects.count() == 1 args, kwargs = mock_post.call_args - json = kwargs["json"] - self.assertEqual(json["event_type"], "trigger") + payload = kwargs["json"] + self.assertEqual(payload["event_type"], "trigger") @patch("hc.api.transports.requests.request") def test_slack(self, mock_post): @@ -167,8 +167,8 @@ class NotifyTestCase(BaseTestCase): assert Notification.objects.count() == 1 args, kwargs = mock_post.call_args - json = kwargs["json"] - attachment = json["attachments"][0] + payload = kwargs["json"] + attachment = payload["attachments"][0] fields = {f["title"]: f["value"] for f in attachment["fields"]} self.assertEqual(fields["Last Ping"], "Never") @@ -213,8 +213,8 @@ class NotifyTestCase(BaseTestCase): self.assertEqual(n.error, "") args, kwargs = mock_post.call_args - json = kwargs["json"] - self.assertIn("DOWN", json["message"]) + payload = kwargs["json"] + self.assertIn("DOWN", payload["message"]) @patch("hc.api.transports.requests.request") def test_opsgenie(self, mock_post): @@ -226,8 +226,8 @@ class NotifyTestCase(BaseTestCase): self.assertEqual(n.error, "") args, kwargs = mock_post.call_args - json = kwargs["json"] - self.assertIn("DOWN", json["message"]) + payload = kwargs["json"] + self.assertIn("DOWN", payload["message"]) @patch("hc.api.transports.requests.request") def test_pushover(self, mock_post): @@ -238,8 +238,8 @@ class NotifyTestCase(BaseTestCase): assert Notification.objects.count() == 1 args, kwargs = mock_post.call_args - json = kwargs["data"] - self.assertIn("DOWN", json["title"]) + payload = kwargs["data"] + self.assertIn("DOWN", payload["title"]) @patch("hc.api.transports.requests.request") def test_victorops(self, mock_post): @@ -250,5 +250,20 @@ class NotifyTestCase(BaseTestCase): assert Notification.objects.count() == 1 args, kwargs = mock_post.call_args - json = kwargs["json"] - self.assertEqual(json["message_type"], "CRITICAL") + payload = kwargs["json"] + self.assertEqual(payload["message_type"], "CRITICAL") + + @patch("hc.api.transports.requests.request") + def test_discord(self, mock_post): + v = json.dumps({"webhook": {"url": "123"}}) + self._setup_data("discord", v) + mock_post.return_value.status_code = 200 + + self.channel.notify(self.check) + assert Notification.objects.count() == 1 + + args, kwargs = mock_post.call_args + payload = kwargs["json"] + attachment = payload["attachments"][0] + fields = {f["title"]: f["value"] for f in attachment["fields"]} + self.assertEqual(fields["Last Ping"], "Never") diff --git a/hc/api/transports.py b/hc/api/transports.py index 53e0a4e6..4f04d7a1 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -237,3 +237,10 @@ class VictorOps(HttpTransport): } return self.post(self.channel.value, payload) + + +class Discord(HttpTransport): + def notify(self, check): + text = tmpl("slack_message.json", check=check) + payload = json.loads(text) + return self.post(self.channel.discord_webhook_url + "/slack", payload) diff --git a/hc/front/tests/test_add_discord.py b/hc/front/tests/test_add_discord.py new file mode 100644 index 00000000..4eab11fe --- /dev/null +++ b/hc/front/tests/test_add_discord.py @@ -0,0 +1,46 @@ +import json + +from django.test.utils import override_settings +from hc.api.models import Channel +from hc.test import BaseTestCase +from mock import patch + + +@override_settings(DISCORD_CLIENT_ID="t1", DISCORD_CLIENT_SECRET="s1") +class AddDiscordTestCase(BaseTestCase): + url = "/integrations/add_discord/" + + def test_instructions_work(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertContains(r, "Connect Discord", status_code=200) + self.assertContains(r, "discordapp.com/api/oauth2/authorize") + + @override_settings(DISCORD_CLIENT_ID=None) + def test_it_requires_client_id(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertEqual(r.status_code, 404) + + @patch("hc.front.views.requests.post") + def test_it_handles_oauth_response(self, mock_post): + oauth_response = { + "access_token": "test-token", + "webhook": { + "url": "foo", + "id": "bar" + } + } + + mock_post.return_value.text = json.dumps(oauth_response) + mock_post.return_value.json.return_value = oauth_response + + url = self.url + "?code=12345678" + + self.client.login(username="alice@example.org", password="password") + r = self.client.get(url, follow=True) + self.assertRedirects(r, "/integrations/") + self.assertContains(r, "The Discord integration has been added!") + + ch = Channel.objects.get() + self.assertEqual(ch.discord_webhook_url, "foo") diff --git a/hc/front/tests/test_add_pushbullet.py b/hc/front/tests/test_add_pushbullet.py index cc31925b..c7ab0211 100644 --- a/hc/front/tests/test_add_pushbullet.py +++ b/hc/front/tests/test_add_pushbullet.py @@ -11,14 +11,10 @@ class AddPushbulletTestCase(BaseTestCase): url = "/integrations/add_pushbullet/" def test_instructions_work(self): - self.client.login(username="alice@example.org", password="password") - r = self.client.get(self.url) - self.assertContains(r, "Connect Pushbullet") - - def test_it_shows_instructions(self): self.client.login(username="alice@example.org", password="password") r = self.client.get(self.url) self.assertContains(r, "www.pushbullet.com/authorize", status_code=200) + self.assertContains(r, "Connect Pushbullet") @override_settings(PUSHBULLET_CLIENT_ID=None) def test_it_requires_client_id(self): diff --git a/hc/front/urls.py b/hc/front/urls.py index 88bf9038..0147e80f 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -19,6 +19,7 @@ channel_urls = [ url(r'^add_slack_btn/$', views.add_slack_btn, name="hc-add-slack-btn"), url(r'^add_hipchat/$', views.add_hipchat, name="hc-add-hipchat"), url(r'^add_pushbullet/$', views.add_pushbullet, name="hc-add-pushbullet"), + url(r'^add_discord/$', views.add_discord, name="hc-add-discord"), url(r'^add_pushover/$', views.add_pushover, name="hc-add-pushover"), url(r'^add_opsgenie/$', views.add_opsgenie, name="hc-add-opsgenie"), url(r'^add_victorops/$', views.add_victorops, name="hc-add-victorops"), diff --git a/hc/front/views.py b/hc/front/views.py index 774f10d8..fc58a973 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -312,7 +312,8 @@ def channels(request): channel.checks = new_checks return redirect("hc-channels") - channels = Channel.objects.filter(user=request.team.user).order_by("created") + channels = Channel.objects.filter(user=request.team.user) + channels = channels.order_by("created") channels = channels.annotate(n_checks=Count("checks")) num_checks = Check.objects.filter(user=request.team.user).count() @@ -322,7 +323,8 @@ def channels(request): "channels": channels, "num_checks": num_checks, "enable_pushbullet": settings.PUSHBULLET_CLIENT_ID is not None, - "enable_pushover": settings.PUSHOVER_API_TOKEN is not None + "enable_pushover": settings.PUSHOVER_API_TOKEN is not None, + "enable_discord": settings.DISCORD_CLIENT_ID is not None } return render(request, "front/channels.html", ctx) @@ -544,6 +546,53 @@ def add_pushbullet(request): return render(request, "integrations/add_pushbullet.html", ctx) +@login_required +def add_discord(request): + if settings.DISCORD_CLIENT_ID is None: + raise Http404("discord integration is not available") + + redirect_uri = settings.SITE_ROOT + reverse("hc-add-discord") + if "code" in request.GET: + code = request.GET.get("code", "") + if len(code) < 8: + return HttpResponseBadRequest() + + result = requests.post("https://discordapp.com/api/oauth2/token", { + "client_id": settings.DISCORD_CLIENT_ID, + "client_secret": settings.DISCORD_CLIENT_SECRET, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri + }) + + doc = result.json() + if "access_token" in doc: + channel = Channel(kind="discord") + channel.user = request.team.user + channel.value = result.text + channel.save() + channel.assign_all_checks() + messages.success(request, + "The Discord integration has been added!") + else: + messages.warning(request, "Something went wrong") + + return redirect("hc-channels") + + authorize_url = "https://discordapp.com/api/oauth2/authorize?" + urlencode({ + "client_id": settings.DISCORD_CLIENT_ID, + "scope": "webhook.incoming", + "redirect_uri": redirect_uri, + "response_type": "code" + }) + + ctx = { + "page": "channels", + "authorize_url": authorize_url + } + return render(request, "integrations/add_discord.html", ctx) + + @login_required def add_pushover(request): if settings.PUSHOVER_API_TOKEN is None or settings.PUSHOVER_SUBSCRIPTION_URL is None: diff --git a/hc/settings.py b/hc/settings.py index ef68610e..22342e20 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -136,6 +136,10 @@ COMPRESS_OFFLINE = True EMAIL_BACKEND = "djmail.backends.default.EmailBackend" +# Discord integration -- override these in local_settings +DISCORD_CLIENT_ID = None +DISCORD_CLIENT_SECRET = None + # Slack integration -- override these in local_settings SLACK_CLIENT_ID = None SLACK_CLIENT_SECRET = None diff --git a/static/img/integrations/discord.png b/static/img/integrations/discord.png new file mode 100644 index 00000000..20251643 Binary files /dev/null and b/static/img/integrations/discord.png differ diff --git a/templates/front/channels.html b/templates/front/channels.html index a06cad1f..9b427dcf 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -27,15 +27,16 @@ {% for ch in channels %} - {% if ch.kind == "email" %} Email {% endif %} - {% if ch.kind == "webhook" %} Webhook {% endif %} - {% if ch.kind == "slack" %} Slack {% endif %} - {% if ch.kind == "hipchat" %} HipChat {% endif %} - {% if ch.kind == "pd" %} PagerDuty {% endif %} - {% if ch.kind == "po" %} Pushover {% endif %} - {% if ch.kind == "victorops" %} VictorOps {% endif %} - {% if ch.kind == "pushbullet" %} Pushbullet {% endif %} - {% if ch.kind == "opsgenie" %} OpsGenie {% endif %} + {% if ch.kind == "email" %} Email + {% elif ch.kind == "webhook" %} Webhook + {% elif ch.kind == "slack" %} Slack + {% elif ch.kind == "hipchat" %} HipChat + {% elif ch.kind == "pd" %} PagerDuty + {% elif ch.kind == "po" %} Pushover + {% elif ch.kind == "victorops" %} VictorOps + {% elif ch.kind == "pushbullet" %} Pushbullet + {% elif ch.kind == "opsgenie" %} OpsGenie + {% elif ch.kind == "discord" %} Discord {% endif %} {% if ch.kind == "email" %} @@ -84,6 +85,8 @@ {% elif ch.kind == "pushbullet" %} API key {{ ch.value }} + {% elif ch.kind == "discord" %} + {{ ch.discord_webhook_id }} {% else %} {{ ch.value }} {% endif %} @@ -214,6 +217,17 @@ Add Integration {% endif %} + {% if enable_discord %} +
  • + Discord icon + +

    Discord

    +

    Cross-platform voice and text chat app designed for gamers.

    + + Add Integration +
  • + {% endif %}