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 @@
Open-source group chat.
+ Add Integration ++ Zulip is an open-source group chat app + with an email threading model. If you use or plan on using Zulip, + you can can integrate it + with your {% site_name %} account in few simple steps. +
+ ++ Log into your Zulip account, + click on the gear icon in the upper right corner, + and select Settings. +
++ Got to Your bots › Add a new bot and fill + out the fields. +
++ For Bot Type, + select "Incoming webhook". You can choose your own preferred values + for bot's name and email. +
++ For the profile picture, feel free to use the {% site_name %} logo: +
++ +
++ After you have filled out the values, + click on Create Bot. +
++ Copy the displayed bot's credentials into the form below. + Also specify the stream or the private user you want {% site_name %} + to post notifications to. +
++ Save the integration and you are done! +
+