diff --git a/CHANGELOG.md b/CHANGELOG.md index acc520cb..933abdc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - Don't store user's current project in DB, put it explicitly in page URLs (#336) - API reference in Markdown - Use Selectize.js for entering tags (#324) +- Zulip integration (#202) ### Bug Fixes - The "render_docs" command checks if markdown and pygments is installed (#329) diff --git a/hc/api/models.py b/hc/api/models.py index 013d745e..3f980b2c 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -48,6 +48,7 @@ CHANNEL_KINDS = ( ("mattermost", "Mattermost"), ("msteams", "Microsoft Teams"), ("shell", "Shell Command"), + ("zulip", "Zulip"), ) PO_PRIORITIES = {-2: "lowest", -1: "low", 0: "normal", 1: "high", 2: "emergency"} @@ -359,6 +360,11 @@ class Channel(models.Model): return "Slack %s" % self.slack_channel elif self.kind == "telegram": return "Telegram %s" % self.telegram_name + elif self.kind == "zulip": + if self.zulip_type == "stream": + return "Zulip stream %s" % self.zulip_to + if self.zulip_type == "private": + return "Zulip user %s" % self.zulip_to return self.get_kind_display() @@ -429,6 +435,8 @@ class Channel(models.Model): return transports.MsTeams(self) elif self.kind == "shell": return transports.Shell(self) + elif self.kind == "zulip": + return transports.Zulip(self) else: raise NotImplementedError("Unknown channel kind: %s" % self.kind) @@ -674,6 +682,30 @@ class Channel(models.Model): doc = json.loads(self.value) return doc["region"] + @property + def zulip_bot_email(self): + assert self.kind == "zulip" + doc = json.loads(self.value) + return doc["bot_email"] + + @property + def zulip_api_key(self): + assert self.kind == "zulip" + doc = json.loads(self.value) + return doc["api_key"] + + @property + def zulip_type(self): + assert self.kind == "zulip" + doc = json.loads(self.value) + return doc["mtype"] + + @property + def zulip_to(self): + assert self.kind == "zulip" + doc = json.loads(self.value) + return doc["to"] + class Notification(models.Model): class Meta: diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index b804c054..8ae686ae 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -798,3 +798,21 @@ class NotifyTestCase(BaseTestCase): n = Notification.objects.get() self.assertEqual(n.error, "Shell commands are not enabled") + + @patch("hc.api.transports.requests.request") + def test_zulip(self, mock_post): + definition = { + "bot_email": "bot@example.org", + "api_key": "fake-key", + "mtype": "stream", + "to": "general", + } + self._setup_data("zulip", json.dumps(definition)) + 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["data"] + self.assertIn("DOWN", payload["topic"]) diff --git a/hc/api/transports.py b/hc/api/transports.py index 749bc0cf..39ef0e8f 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -140,6 +140,10 @@ class Shell(Transport): class HttpTransport(Transport): + @classmethod + def get_error(cls, r): + return "Received status code %d" % r.status_code + @classmethod def _request(cls, method, url, **kwargs): try: @@ -152,7 +156,7 @@ class HttpTransport(Transport): r = requests.request(method, url, **options) if r.status_code not in (200, 201, 202, 204): - return "Received status code %d" % r.status_code + return cls.get_error(r) except requests.exceptions.Timeout: # Well, we tried return "Connection timed out" @@ -538,3 +542,29 @@ class MsTeams(HttpTransport): text = tmpl("msteams_message.json", check=check) payload = json.loads(text) return self.post(self.channel.value, json=payload) + + +class Zulip(HttpTransport): + @classmethod + def get_error(cls, r): + try: + doc = r.json() + if "msg" in doc: + return doc["msg"] + except ValueError: + pass + + return super().get_error(r) + + def notify(self, check): + _, domain = self.channel.zulip_bot_email.split("@") + url = "https://%s/api/v1/messages" % domain + auth = (self.channel.zulip_bot_email, self.channel.zulip_api_key) + data = { + "type": self.channel.zulip_type, + "to": self.channel.zulip_to, + "topic": tmpl("zulip_topic.html", check=check), + "content": tmpl("zulip_content.html", check=check), + } + + return self.post(url, data=data, auth=auth) diff --git a/hc/front/forms.py b/hc/front/forms.py index 901bf06f..febca0d0 100644 --- a/hc/front/forms.py +++ b/hc/front/forms.py @@ -221,3 +221,17 @@ class AddAppriseForm(forms.Form): class AddPdForm(forms.Form): error_css_class = "has-error" value = forms.CharField(max_length=32) + + +ZULIP_TARGETS = (("stream", "Stream"), ("private", "Private")) + + +class AddZulipForm(forms.Form): + error_css_class = "has-error" + bot_email = forms.EmailField(max_length=100) + api_key = forms.CharField(max_length=50) + mtype = forms.ChoiceField(choices=ZULIP_TARGETS) + to = forms.CharField(max_length=100) + + def get_value(self): + return json.dumps(dict(self.cleaned_data), sort_keys=True) diff --git a/hc/front/tests/test_add_zulip.py b/hc/front/tests/test_add_zulip.py new file mode 100644 index 00000000..9c0d319f --- /dev/null +++ b/hc/front/tests/test_add_zulip.py @@ -0,0 +1,80 @@ +from hc.api.models import Channel +from hc.test import BaseTestCase + + +class AddZulipTestCase(BaseTestCase): + def setUp(self): + super(AddZulipTestCase, self).setUp() + self.url = "/projects/%s/add_zulip/" % self.project.code + + def test_instructions_work(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertContains(r, "open-source group chat app") + + def test_it_works(self): + form = { + "bot_email": "foo@example.org", + "api_key": "fake-key", + "mtype": "stream", + "to": "general", + } + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertRedirects(r, self.channels_url) + + c = Channel.objects.get() + self.assertEqual(c.kind, "zulip") + self.assertEqual(c.zulip_bot_email, "foo@example.org") + self.assertEqual(c.zulip_api_key, "fake-key") + self.assertEqual(c.zulip_type, "stream") + self.assertEqual(c.zulip_to, "general") + + def test_it_rejects_bad_email(self): + form = { + "bot_email": "not@an@email", + "api_key": "fake-key", + "mtype": "stream", + "to": "general", + } + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertContains(r, "Enter a valid email address.") + + def test_it_rejects_missing_api_key(self): + form = { + "bot_email": "foo@example.org", + "api_key": "", + "mtype": "stream", + "to": "general", + } + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertContains(r, "This field is required.") + + def test_it_rejects_bad_mtype(self): + form = { + "bot_email": "foo@example.org", + "api_key": "fake-key", + "mtype": "this-should-not-work", + "to": "general", + } + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertEqual(r.status_code, 200) + + def test_it_rejects_missing_stream_name(self): + form = { + "bot_email": "foo@example.org", + "api_key": "fake-key", + "mtype": "stream", + "to": "", + } + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertContains(r, "This field is required.") diff --git a/hc/front/urls.py b/hc/front/urls.py index 5fdff5d1..0b344300 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -75,6 +75,7 @@ project_urls = [ path("add_victorops/", views.add_victorops, name="hc-add-victorops"), path("add_webhook/", views.add_webhook, name="hc-add-webhook"), path("add_whatsapp/", views.add_whatsapp, name="hc-add-whatsapp"), + path("add_zulip/", views.add_zulip, name="hc-add-zulip"), path("badges/", views.badges, name="hc-badges"), path("checks/", views.my_checks, name="hc-checks"), path("checks/add/", views.add_check, name="hc-add-check"), diff --git a/hc/front/views.py b/hc/front/views.py index 1bcb6c3a..e247ee0a 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -1353,6 +1353,26 @@ def add_victorops(request, code): return render(request, "integrations/add_victorops.html", ctx) +@login_required +def add_zulip(request, code): + project = _get_project_for_user(request, code) + + if request.method == "POST": + form = forms.AddZulipForm(request.POST) + if form.is_valid(): + channel = Channel(project=project, kind="zulip") + channel.value = form.get_value() + channel.save() + + channel.assign_all_checks() + return redirect("hc-p-channels", project.code) + else: + form = forms.AddZulipForm() + + ctx = {"page": "channels", "project": project, "form": form} + return render(request, "integrations/add_zulip.html", ctx) + + @csrf_exempt @require_POST def telegram_bot(request): diff --git a/static/css/channels.css b/static/css/channels.css index d18c947a..a7e0122a 100644 --- a/static/css/channels.css +++ b/static/css/channels.css @@ -226,6 +226,10 @@ table.channels-table > tbody > tr > th { animation: marker-ripple 1.2s ease-out infinite; } +.ai-step p { + margin-left: 80px; +} + @keyframes marker-ripple { 0%, 35% { transform: scale(0); diff --git a/static/css/icomoon.css b/static/css/icomoon.css index 57b353b3..8bd39cc0 100644 --- a/static/css/icomoon.css +++ b/static/css/icomoon.css @@ -1,10 +1,10 @@ @font-face { font-family: 'icomoon'; - src: url('../fonts/icomoon.eot?pl16ut'); - src: url('../fonts/icomoon.eot?pl16ut#iefix') format('embedded-opentype'), - url('../fonts/icomoon.ttf?pl16ut') format('truetype'), - url('../fonts/icomoon.woff?pl16ut') format('woff'), - url('../fonts/icomoon.svg?pl16ut#icomoon') format('svg'); + src: url('../fonts/icomoon.eot?agj6xa'); + src: url('../fonts/icomoon.eot?agj6xa#iefix') format('embedded-opentype'), + url('../fonts/icomoon.ttf?agj6xa') format('truetype'), + url('../fonts/icomoon.woff?agj6xa') format('woff'), + url('../fonts/icomoon.svg?agj6xa#icomoon') format('svg'); font-weight: normal; font-style: normal; } @@ -24,6 +24,10 @@ -moz-osx-font-smoothing: grayscale; } +.icon-zulip:before { + content: "\e918"; + color: #1e9459; +} .icon-pd:before { content: "\e90b"; color: #04ac38; diff --git a/static/fonts/icomoon.eot b/static/fonts/icomoon.eot index b5de171a..51cf9fc6 100644 Binary files a/static/fonts/icomoon.eot and b/static/fonts/icomoon.eot differ diff --git a/static/fonts/icomoon.svg b/static/fonts/icomoon.svg index 3577b899..eae5b94a 100644 --- a/static/fonts/icomoon.svg +++ b/static/fonts/icomoon.svg @@ -42,4 +42,5 @@ + \ No newline at end of file diff --git a/static/fonts/icomoon.ttf b/static/fonts/icomoon.ttf index 62c037f6..fc07fa48 100644 Binary files a/static/fonts/icomoon.ttf and b/static/fonts/icomoon.ttf differ diff --git a/static/fonts/icomoon.woff b/static/fonts/icomoon.woff index 8fb5cbe8..6c9b6176 100644 Binary files a/static/fonts/icomoon.woff and b/static/fonts/icomoon.woff differ diff --git a/static/img/integrations/setup_zulip_1.png b/static/img/integrations/setup_zulip_1.png new file mode 100644 index 00000000..4eda9efe Binary files /dev/null and b/static/img/integrations/setup_zulip_1.png differ diff --git a/static/img/integrations/setup_zulip_2.png b/static/img/integrations/setup_zulip_2.png new file mode 100644 index 00000000..f46ef43e Binary files /dev/null and b/static/img/integrations/setup_zulip_2.png differ diff --git a/static/img/integrations/setup_zulip_3.png b/static/img/integrations/setup_zulip_3.png new file mode 100644 index 00000000..f5507731 Binary files /dev/null and b/static/img/integrations/setup_zulip_3.png differ diff --git a/static/img/integrations/zulip.png b/static/img/integrations/zulip.png new file mode 100644 index 00000000..a42b855e Binary files /dev/null and b/static/img/integrations/zulip.png differ diff --git a/static/js/add_zulip.js b/static/js/add_zulip.js new file mode 100644 index 00000000..0c18e6a4 --- /dev/null +++ b/static/js/add_zulip.js @@ -0,0 +1,17 @@ +$(function() { + function updateForm() { + var mType = $('input[name=mtype]:checked').val(); + if (mType == "stream") { + $("#z-to-label").text("Stream Name"); + $("#z-to-help").text('Example: "general"'); + } + if (mType == "private") { + $("#z-to-label").text("User's Email"); + $("#z-to-help").text('Example: "alice@example.org"'); + } + } + + // Update form labels when user clicks on radio buttons + $('input[type=radio][name=mtype]').change(updateForm); + +}); diff --git a/templates/front/channels.html b/templates/front/channels.html index 498abb73..5ecbe4a9 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -82,6 +82,13 @@ {% if ch.whatsapp_notify_up and not ch.whatsapp_notify_down %} (up only) {% endif %} + {% elif ch.kind == "zulip" %} + Zulip + {% if ch.zulip_type == "stream" %} + stream {{ ch.zulip_to}} + {% elif ch.zulip_type == "private" %} + user {{ ch.zulip_to}} + {% endif %} {% else %} {{ ch.get_kind_display }} {% endif %} @@ -380,6 +387,15 @@ {% endif %} +
  • + Zulip icon + +

    Zulip

    +

    Open-source group chat.

    + Add Integration +
  • +