mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-10 23:40:11 +00:00
Telegram integration.
This commit is contained in:
parent
eece7c7551
commit
2481aa5a23
18 changed files with 364 additions and 17 deletions
|
@ -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()
|
||||
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -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
hc/front/schemas.py
Normal file
23
hc/front/schemas.py
Normal 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"]
|
||||
}
|
|
@ -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
hc/front/tests/test_add_telegram.py
Normal file
76
hc/front/tests/test_add_telegram.py
Normal 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="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)
|
|
@ -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,
|
||||
|
|
|
@ -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", {})
|
||||
|
||||
|
|
|
@ -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
Normal file
BIN
static/img/integrations/setup_telegram_1.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 32 KiB |
BIN
static/img/integrations/setup_telegram_2.png
Normal file
BIN
static/img/integrations/setup_telegram_2.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 135 KiB |
BIN
static/img/integrations/setup_telegram_3.png
Normal file
BIN
static/img/integrations/setup_telegram_3.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 26 KiB |
BIN
static/img/integrations/telegram.png
Normal file
BIN
static/img/integrations/telegram.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 4.8 KiB |
Binary file not shown.
Before ![]() (image error) Size: 7.6 KiB After ![]() (image error) Size: 3.9 KiB ![]() ![]() |
|
@ -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
templates/integrations/add_telegram.html
Normal file
104
templates/integrations/add_telegram.html
Normal 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
templates/integrations/telegram_invite.html
Normal file
5
templates/integrations/telegram_invite.html
Normal 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
templates/integrations/telegram_message.html
Normal file
6
templates/integrations/telegram_message.html
Normal 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…
Add table
Reference in a new issue