diff --git a/hc/api/models.py b/hc/api/models.py index bb528f38..4b69ad85 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -35,7 +35,8 @@ CHANNEL_KINDS = (("email", "Email"), ("pushbullet", "Pushbullet"), ("opsgenie", "OpsGenie"), ("victorops", "VictorOps"), - ("discord", "Discord")) + ("discord", "Discord"), + ("telegram", "Telegram")) PO_PRIORITIES = { -2: "lowest", @@ -262,6 +263,8 @@ class Channel(models.Model): return transports.OpsGenie(self) elif self.kind == "discord": return transports.Discord(self) + elif self.kind == "telegram": + return transports.Telegram(self) else: raise NotImplementedError("Unknown channel kind: %s" % self.kind) @@ -348,6 +351,24 @@ class Channel(models.Model): doc = json.loads(self.value) return doc["webhook"]["id"] + @property + def telegram_id(self): + assert self.kind == "telegram" + doc = json.loads(self.value) + return doc.get("id") + + @property + def telegram_type(self): + assert self.kind == "telegram" + doc = json.loads(self.value) + return doc.get("type") + + @property + def telegram_name(self): + assert self.kind == "telegram" + doc = json.loads(self.value) + return doc.get("name") + 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 9509f46a..3e9c9c87 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -1,7 +1,6 @@ import json from django.core import mail -from django.test import override_settings from hc.api.models import Channel, Check, Notification from hc.test import BaseTestCase from mock import patch @@ -282,3 +281,17 @@ class NotifyTestCase(BaseTestCase): _, kwargs = mock_post.call_args self.assertEqual(kwargs["json"]["type"], "note") self.assertEqual(kwargs["headers"]["Access-Token"], "fake-token") + + @patch("hc.api.transports.requests.request") + def test_telegram(self, mock_post): + v = json.dumps({"id": 123}) + self._setup_data("telegram", 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"] + self.assertEqual(payload["chat_id"], 123) + self.assertTrue("The check" in payload["text"]) diff --git a/hc/api/transports.py b/hc/api/transports.py index 0612b845..b32ef980 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -61,7 +61,8 @@ class Email(Transport): class HttpTransport(Transport): - def _request(self, method, url, **kwargs): + @classmethod + def _request(cls, method, url, **kwargs): try: options = dict(kwargs) if "headers" not in options: @@ -79,19 +80,21 @@ class HttpTransport(Transport): except requests.exceptions.ConnectionError: return "Connection failed" - def get(self, url): + @classmethod + def get(cls, url): # Make 3 attempts-- for x in range(0, 3): - error = self._request("get", url) + error = cls._request("get", url) if error is None: break return error - def post(self, url, **kwargs): + @classmethod + def post(cls, url, **kwargs): # Make 3 attempts-- for x in range(0, 3): - error = self._request("post", url, **kwargs) + error = cls._request("post", url, **kwargs) if error is None: break @@ -277,3 +280,19 @@ class Discord(HttpTransport): payload = json.loads(text) url = self.channel.discord_webhook_url + "/slack" return self.post(url, json=payload) + + +class Telegram(HttpTransport): + SM = "https://api.telegram.org/bot%s/sendMessage" % settings.TELEGRAM_TOKEN + + @classmethod + def send(cls, chat_id, text): + return cls.post(cls.SM, json={ + "chat_id": chat_id, + "text": text, + "parse_mode": "html" + }) + + def notify(self, check): + text = tmpl("telegram_message.html", check=check) + return self.send(self.channel.telegram_id, text) diff --git a/hc/front/schemas.py b/hc/front/schemas.py new file mode 100644 index 00000000..61b0a1c5 --- /dev/null +++ b/hc/front/schemas.py @@ -0,0 +1,23 @@ +telegram_callback = { + "type": "object", + "properties": { + "message": { + "type": "object", + "properties": { + "chat": { + "type": "object", + "properties": { + "id": {"type": "number"}, + "type": {"enum": ["group", "private"]}, + "title": {"type": "string"}, + "username": {"type": "string"} + }, + "required": ["id", "type"] + }, + "text": {"type": "string"} + }, + "required": ["chat", "text"] + } + }, + "required": ["message"] +} diff --git a/hc/front/templatetags/hc_extras.py b/hc/front/templatetags/hc_extras.py index 4a61d181..14a54bc5 100644 --- a/hc/front/templatetags/hc_extras.py +++ b/hc/front/templatetags/hc_extras.py @@ -12,11 +12,6 @@ def hc_duration(td): return format_duration(td) -@register.simple_tag -def settings_value(name): - return getattr(settings, name, "") - - @register.simple_tag def site_name(): return settings.SITE_NAME diff --git a/hc/front/tests/test_add_telegram.py b/hc/front/tests/test_add_telegram.py new file mode 100644 index 00000000..dd79529b --- /dev/null +++ b/hc/front/tests/test_add_telegram.py @@ -0,0 +1,76 @@ +import json + +from django.core import signing +from hc.api.models import Channel +from hc.test import BaseTestCase +from mock import patch + + +class AddTelegramTestCase(BaseTestCase): + url = "/integrations/add_telegram/" + + def test_instructions_work(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertContains(r, "start@HealthchecksBot") + + def test_it_shows_confirmation(self): + payload = signing.dumps((123, "group", "My Group")) + + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url + "?" + payload) + self.assertContains(r, "My Group") + + def test_it_works(self): + payload = signing.dumps((123, "group", "My Group")) + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url + "?" + payload, {}) + self.assertRedirects(r, "/integrations/") + + c = Channel.objects.get() + self.assertEqual(c.kind, "telegram") + self.assertEqual(c.telegram_id, 123) + self.assertEqual(c.telegram_type, "group") + self.assertEqual(c.telegram_name, "My Group") + + @patch("hc.api.transports.requests.request") + def test_it_sends_invite(self, mock_get): + data = { + "message": { + "chat": { + "id": 123, + "title": "My Group", + "type": "group" + }, + "text": "/start" + } + } + r = self.client.post("/integrations/telegram/bot/", json.dumps(data), + content_type="application/json") + + self.assertEqual(r.status_code, 200) + self.assertTrue(mock_get.called) + + @patch("hc.api.transports.requests.request") + def test_bot_handles_bad_message(self, mock_get): + samples = ["", "{}"] + + # text is missing + samples.append(json.dumps({ + "message": {"chat": {"id": 123, "type": "group"}} + })) + + # bad type + samples.append(json.dumps({ + "message": { + "chat": {"id": 123, "type": "invalid"}, + "text": "/start" + } + })) + + for sample in samples: + r = self.client.post("/integrations/telegram/bot/", sample, + content_type="application/json") + + self.assertEqual(r.status_code, 400) diff --git a/hc/front/urls.py b/hc/front/urls.py index 609a266b..a6ce7df1 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -24,6 +24,8 @@ channel_urls = [ 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"), + url(r'^telegram/bot/$', views.telegram_bot), + url(r'^add_telegram/$', views.add_telegram, name="hc-add-telegram"), url(r'^([\w-]+)/checks/$', views.channel_checks, name="hc-channel-checks"), url(r'^([\w-]+)/remove/$', views.remove_channel, name="hc-remove-channel"), url(r'^([\w-]+)/verify/([\w-]+)/$', views.verify_email, diff --git a/hc/front/views.py b/hc/front/views.py index 59368afe..1b2a428f 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -1,29 +1,36 @@ from collections import Counter -from croniter import croniter from datetime import datetime, timedelta as td from itertools import tee +import json -import requests +from croniter import croniter from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.core import signing from django.db.models import Count -from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden +from django.http import (Http404, HttpResponse, HttpResponseBadRequest, + HttpResponseForbidden) from django.shortcuts import get_object_or_404, redirect, render +from django.template.loader import render_to_string from django.urls import reverse from django.utils import timezone from django.utils.crypto import get_random_string +from django.utils.six.moves.urllib.parse import urlencode from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST -from django.utils.six.moves.urllib.parse import urlencode from hc.api.decorators import uuid_or_400 from hc.api.models import (DEFAULT_GRACE, DEFAULT_TIMEOUT, Channel, Check, Ping, Notification) +from hc.api.transports import Telegram from hc.front.forms import (AddWebhookForm, NameTagsForm, TimeoutForm, AddUrlForm, AddPdForm, AddEmailForm, AddOpsGenieForm, CronForm) +from hc.front.schemas import telegram_callback +from hc.lib import jsonschema from pytz import all_timezones from pytz.exceptions import UnknownTimeZoneError +import requests # from itertools recipes: @@ -341,7 +348,8 @@ def channels(request): "num_checks": num_checks, "enable_pushbullet": settings.PUSHBULLET_CLIENT_ID is not None, "enable_pushover": settings.PUSHOVER_API_TOKEN is not None, - "enable_discord": settings.DISCORD_CLIENT_ID is not None + "enable_discord": settings.DISCORD_CLIENT_ID is not None, + "enable_telegram": settings.TELEGRAM_TOKEN is not None } return render(request, "front/channels.html", ctx) @@ -747,6 +755,60 @@ def add_victorops(request): return render(request, "integrations/add_victorops.html", ctx) +@csrf_exempt +@require_POST +def telegram_bot(request): + try: + doc = json.loads(request.body.decode("utf-8")) + jsonschema.validate(doc, telegram_callback) + except json.decoder.JSONDecodeError: + return HttpResponseBadRequest() + except jsonschema.ValidationError: + return HttpResponseBadRequest() + + if "/start" not in doc["message"]["text"]: + return HttpResponse() + + chat = doc["message"]["chat"] + name = max(chat.get("title", ""), chat.get("username", "")) + + invite = render_to_string("integrations/telegram_invite.html", { + "qs": signing.dumps((chat["id"], chat["type"], name)) + }) + + Telegram.send(chat["id"], invite) + return HttpResponse() + + +@login_required +def add_telegram(request): + chat_id, chat_type, chat_name = None, None, None + qs = request.META["QUERY_STRING"] + if qs: + chat_id, chat_type, chat_name = signing.loads(qs, max_age=600) + + if request.method == "POST": + channel = Channel(user=request.team.user, kind="telegram") + channel.value = json.dumps({ + "id": chat_id, + "type": chat_type, + "name": chat_name + }) + channel.save() + + channel.assign_all_checks() + messages.success(request, "The Telegram integration has been added!") + return redirect("hc-channels") + + ctx = { + "chat_id": chat_id, + "chat_type": chat_type, + "chat_name": chat_name + } + + return render(request, "integrations/add_telegram.html", ctx) + + def privacy(request): return render(request, "front/privacy.html", {}) diff --git a/hc/settings.py b/hc/settings.py index 94bfae5a..9bd00a4b 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -153,6 +153,9 @@ PUSHOVER_EMERGENCY_EXPIRATION = 86400 PUSHBULLET_CLIENT_ID = None PUSHBULLET_CLIENT_SECRET = None +# Telegram integration -- override in local_settings.py +TELEGRAM_TOKEN = None + if os.path.exists(os.path.join(BASE_DIR, "hc/local_settings.py")): from .local_settings import * else: diff --git a/static/img/integrations/setup_telegram_1.png b/static/img/integrations/setup_telegram_1.png new file mode 100644 index 00000000..ac73d922 Binary files /dev/null and b/static/img/integrations/setup_telegram_1.png differ diff --git a/static/img/integrations/setup_telegram_2.png b/static/img/integrations/setup_telegram_2.png new file mode 100644 index 00000000..d9cff510 Binary files /dev/null and b/static/img/integrations/setup_telegram_2.png differ diff --git a/static/img/integrations/setup_telegram_3.png b/static/img/integrations/setup_telegram_3.png new file mode 100644 index 00000000..f334d4b9 Binary files /dev/null and b/static/img/integrations/setup_telegram_3.png differ diff --git a/static/img/integrations/telegram.png b/static/img/integrations/telegram.png new file mode 100644 index 00000000..617d04c3 Binary files /dev/null and b/static/img/integrations/telegram.png differ diff --git a/static/img/integrations/victorops.png b/static/img/integrations/victorops.png index a219c15e..461204fa 100644 Binary files a/static/img/integrations/victorops.png and b/static/img/integrations/victorops.png differ diff --git a/templates/front/channels.html b/templates/front/channels.html index 2656b4d5..dd1c0425 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -82,6 +82,13 @@ {{ ch.value }} {% elif ch.kind == "discord" %} {{ ch.discord_webhook_id }} + {% elif ch.kind == "telegram" %} + {% if ch.telegram_type == "group" %} + chat + {% elif ch.telegram_type == "private" %} + user + {% endif %} + {{ ch.telegram_name }} {% else %} {{ ch.value }} {% endif %} @@ -223,6 +230,17 @@ Add Integration {% endif %} + {% if enable_telegram %} +
A messaging app with a focus on speed and security.
+ + Add Integration ++ When a check goes up or down, + healthchecks.io will send notifications to + {% if chat_type == "private" %} + a Telegram user + {% else %} + a Telegram chat + {% endif %} + named {{ chat_name }}. Sound good? +
+ + +If your team uses Telegram, + you can set up {% site_name %} to post status updates directly to an + appropriate Telegram chat or user.
+ ++ From your Telegram client, invite + HealthchecksBot to a group. It will get added + as a member with no access to group messages. +
++ Alternatively, if you want notifications sent to yourself + directly, start a conversation with + HealthchecksBot. +
+Type /start
command.
+ If there are multiple bots in the group, type
+ /start@HealthchecksBot
instead.
+
The bot will respond with a confirmation link.
+Click or tap on the confirmation link, and + {% site_name %} will open in a browser window asking you to + confirm the new integration.
+Confirm the integration, and it's done!
+