Browse Source

Telegram integration.

pull/114/merge
Pēteris Caune 8 years ago
parent
commit
2481aa5a23
18 changed files with 364 additions and 17 deletions
  1. +22
    -1
      hc/api/models.py
  2. +14
    -1
      hc/api/tests/test_notify.py
  3. +24
    -5
      hc/api/transports.py
  4. +23
    -0
      hc/front/schemas.py
  5. +0
    -5
      hc/front/templatetags/hc_extras.py
  6. +76
    -0
      hc/front/tests/test_add_telegram.py
  7. +2
    -0
      hc/front/urls.py
  8. +67
    -5
      hc/front/views.py
  9. +3
    -0
      hc/settings.py
  10. BIN
      static/img/integrations/setup_telegram_1.png
  11. BIN
      static/img/integrations/setup_telegram_2.png
  12. BIN
      static/img/integrations/setup_telegram_3.png
  13. BIN
      static/img/integrations/telegram.png
  14. BIN
      static/img/integrations/victorops.png
  15. +18
    -0
      templates/front/channels.html
  16. +104
    -0
      templates/integrations/add_telegram.html
  17. +5
    -0
      templates/integrations/telegram_invite.html
  18. +6
    -0
      templates/integrations/telegram_message.html

+ 22
- 1
hc/api/models.py View File

@ -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()


+ 14
- 1
hc/api/tests/test_notify.py View File

@ -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"])

+ 24
- 5
hc/api/transports.py View File

@ -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)

+ 23
- 0
hc/front/schemas.py View File

@ -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"]
}

+ 0
- 5
hc/front/templatetags/hc_extras.py View File

@ -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


+ 76
- 0
hc/front/tests/test_add_telegram.py View File

@ -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="[email protected]", 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="[email protected]", 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="[email protected]", 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)

+ 2
- 0
hc/front/urls.py View File

@ -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,


+ 67
- 5
hc/front/views.py View File

@ -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", {})


+ 3
- 0
hc/settings.py View File

@ -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:


BIN
static/img/integrations/setup_telegram_1.png View File

Before After
Width: 338  |  Height: 600  |  Size: 32 KiB

BIN
static/img/integrations/setup_telegram_2.png View File

Before After
Width: 338  |  Height: 600  |  Size: 135 KiB

BIN
static/img/integrations/setup_telegram_3.png View File

Before After
Width: 833  |  Height: 383  |  Size: 26 KiB

BIN
static/img/integrations/telegram.png View File

Before After
Width: 200  |  Height: 200  |  Size: 4.8 KiB

BIN
static/img/integrations/victorops.png View File

Before After
Width: 100  |  Height: 100  |  Size: 7.6 KiB Width: 100  |  Height: 100  |  Size: 3.9 KiB

+ 18
- 0
templates/front/channels.html View File

@ -82,6 +82,13 @@
{{ ch.value }}
{% elif ch.kind == "discord" %}
{{ ch.discord_webhook_id }}
{% elif ch.kind == "telegram" %}
{% if ch.telegram_type == "group" %}
<span class="preposition">chat</span>
{% elif ch.telegram_type == "private" %}
<span class="preposition">user</span>
{% endif %}
{{ ch.telegram_name }}
{% else %}
{{ ch.value }}
{% endif %}
@ -223,6 +230,17 @@
<a href="{% url 'hc-add-discord' %}" class="btn btn-primary">Add Integration</a>
</li>
{% endif %}
{% if enable_telegram %}
<li>
<img src="{% static 'img/integrations/telegram.png' %}"
class="icon" alt="Telegram icon" />
<h2>Telegram</h2>
<p>A messaging app with a focus on speed and security.</p>
<a href="{% url 'hc-add-telegram' %}" class="btn btn-primary">Add Integration</a>
</li>
{% endif %}
<li class="link-to-github">
<img src="{% static 'img/integrations/missing.png' %}"
class="icon" alt="Suggest New Integration" />


+ 104
- 0
templates/integrations/add_telegram.html View File

@ -0,0 +1,104 @@
{% extends "base.html" %}
{% load compress humanize staticfiles hc_extras %}
{% block title %}Notification Channels - {% site_name %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-12">
<h1>Telegram</h1>
{% if chat_id %}
<div class="jumbotron">
<p>
When a check goes <strong>up</strong> or <strong>down</strong>,
healthchecks.io will send notifications to
{% if chat_type == "private" %}
a Telegram user
{% else %}
a Telegram chat
{% endif %}
named <strong>{{ chat_name }}</strong>. Sound good?
</p>
<form method="post" class="text-center">
{% csrf_token %}
<button type="submit" class="btn btn-default">
<img class="ai-icon" src="{% static 'img/integrations/telegram.png' %}" alt="Discord" />
Yes, connect Telegram
</button>
</form>
</div>
{% else %}
<p>If your team uses <a href="https://telegram.org/">Telegram</a>,
you can set up {% site_name %} to post status updates directly to an
appropriate Telegram chat or user.</p>
<h2>Setup Guide</h2>
<div class="row ai-step">
<div class="col-sm-6">
<span class="step-no">1</span>
<p>
From your Telegram client, invite
<strong>HealthchecksBot</strong> to a group. It will get added
as a member with no access to group messages.
</p>
<p>
Alternatively, if you want notifications sent to yourself
directly, start a conversation with
<strong>HealthchecksBot</strong>.
</p>
</div>
<div class="col-sm-6">
<img
class="ai-guide-screenshot"
alt="Screenshot"
src="{% static 'img/integrations/setup_telegram_1.png' %}">
</div>
</div>
<div class="row ai-step">
<div class="col-sm-6">
<span class="step-no">2</span>
<p>Type <strong><code>/start</code></strong> command.
If there are multiple bots in the group, type
<strong><code>/start@HealthchecksBot</code></strong> instead.
</p>
<p>The bot will respond with a confirmation link.</p>
</div>
<div class="col-sm-6">
<img
class="ai-guide-screenshot"
alt="Screenshot"
src="{% static 'img/integrations/setup_telegram_2.png' %}">
</div> </div>
<div class="row ai-step">
<div class="col-sm-6">
<span class="step-no">3</span>
<p>Click or tap on the confirmation link, and
{% site_name %} will open in a browser window asking you to
confirm the new integration.</p>
<p>Confirm the integration, and it's done!</p>
</div>
<div class="col-sm-6">
<img
class="ai-guide-screenshot"
alt="Screenshot"
src="{% static 'img/integrations/setup_telegram_3.png' %}">
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
{% compress js %}
<script src="{% static 'js/jquery-2.1.4.min.js' %}"></script>
<script src="{% static 'js/bootstrap.min.js' %}"></script>
{% endcompress %}
{% endblock %}

+ 5
- 0
templates/integrations/telegram_invite.html View File

@ -0,0 +1,5 @@
{% load hc_extras %}
Please open this link to complete the {% site_name %} integration:
{% site_root %}{% url "hc-add-telegram" %}?{{ qs }}

+ 6
- 0
templates/integrations/telegram_message.html View File

@ -0,0 +1,6 @@
{% load humanize %}
{% if check.status == "down" %}
The check "{{ check.name_then_code }}" is <b>DOWN</b>. Last ping was {{ check.last_ping|naturaltime }}.
{% else %}
The check "{{ check.name_then_code }}" received a ping and is now <b>UP</b>.
{% endif %}

Loading…
Cancel
Save