Browse Source

Implement Telegram group to supergroup migration

Fixes: #132
pull/595/head
Pēteris Caune 2 years ago
parent
commit
d8f1659e45
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
  1. 1
      CHANGELOG.md
  2. 6
      README.md
  3. 13
      hc/api/schemas.py
  4. 20
      hc/api/tests/test_notify_opsgenie.py
  5. 2
      hc/api/tests/test_notify_pushover.py
  6. 2
      hc/api/tests/test_notify_signal.py
  7. 38
      hc/api/tests/test_notify_telegram.py
  8. 17
      hc/api/tests/test_notify_zulip.py
  9. 116
      hc/api/transports.py
  10. 2
      templates/front/channels.html
  11. 4
      templates/integrations/add_telegram.html

1
CHANGELOG.md

@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file.
- Add "The following checks are also down" section in Signal notifications
- Upgrade to django-compressor 3.0
- Add support for Telegram channels (#592)
- Implement Telegram group to supergroup migration (#132)
### Bug Fixes
- Fix report templates to not show the "started" status (show UP or DOWN instead)

6
README.md

@ -307,7 +307,9 @@ To enable the Signal integration:
* Create a Telegram bot by talking to the
[BotFather](https://core.telegram.org/bots#6-botfather). Set the bot's name,
description, user picture, and add a "/start" command.
description, user picture, and add a "/start" command. To avoid user confusion,
please do not use the Healthchecks.io logo as your bot's user picture, use
your own logo.
* After creating the bot you will have the bot's name and token. Put them
in `TELEGRAM_BOT_NAME` and `TELEGRAM_TOKEN` environment variables.
* Run `settelegramwebhook` management command. This command tells Telegram
@ -319,7 +321,7 @@ where to forward channel messages by invoking Telegram's
Done, Telegram's webhook set to: https://my-monitoring-project.com/integrations/telegram/bot/
```
For this to work, your `SITE_ROOT` needs to be correct and use "https://"
For this to work, your `SITE_ROOT` must be correct and must use the "https://"
scheme.
### Apprise

13
hc/api/schemas.py

@ -17,3 +17,16 @@ check = {
},
},
}
telegram_migration = {
"type": "object",
"properties": {
"description": {"type": "string"},
"parameters": {
"type": "object",
"properties": {"migrate_to_chat_id": {"type": "number"}},
"required": ["migrate_to_chat_id"],
},
},
"required": ["description", "parameters"],
}

20
hc/api/tests/test_notify_opsgenie.py

@ -2,7 +2,7 @@
from datetime import timedelta as td
import json
from unittest.mock import patch
from unittest.mock import Mock, patch
from django.test.utils import override_settings
from django.utils.timezone import now
@ -30,7 +30,7 @@ class NotifyOpsGenieTestCase(BaseTestCase):
mock_post.return_value.status_code = 202
self.channel.notify(self.check)
n = Notification.objects.first()
n = Notification.objects.get()
self.assertEqual(n.error, "")
self.assertEqual(mock_post.call_count, 1)
@ -45,7 +45,7 @@ class NotifyOpsGenieTestCase(BaseTestCase):
mock_post.return_value.status_code = 202
self.channel.notify(self.check)
n = Notification.objects.first()
n = Notification.objects.get()
self.assertEqual(n.error, "")
self.assertEqual(mock_post.call_count, 1)
@ -59,7 +59,7 @@ class NotifyOpsGenieTestCase(BaseTestCase):
mock_post.return_value.status_code = 202
self.channel.notify(self.check)
n = Notification.objects.first()
n = Notification.objects.get()
self.assertEqual(n.error, "")
self.assertEqual(mock_post.call_count, 1)
@ -73,9 +73,19 @@ class NotifyOpsGenieTestCase(BaseTestCase):
mock_post.return_value.json.return_value = {"message": "Nice try"}
self.channel.notify(self.check)
n = Notification.objects.first()
n = Notification.objects.get()
self.assertEqual(n.error, 'Received status code 403 with a message: "Nice try"')
@patch("hc.api.transports.requests.request")
def test_it_handles_non_json_error_response(self, mock_post):
self._setup_data("123")
mock_post.return_value.status_code = 403
mock_post.return_value.json = Mock(side_effect=ValueError)
self.channel.notify(self.check)
n = Notification.objects.get()
self.assertEqual(n.error, "Received status code 403")
@override_settings(OPSGENIE_ENABLED=False)
def test_it_requires_opsgenie_enabled(self):
self._setup_data("123")

2
hc/api/tests/test_notify_pushover.py

@ -70,7 +70,7 @@ class NotifyPushoverTestCase(BaseTestCase):
obj.save()
self.channel.notify(self.check)
n = Notification.objects.first()
n = Notification.objects.get()
self.assertEqual(n.error, "Rate limit exceeded")
@patch("hc.api.transports.requests.request")

2
hc/api/tests/test_notify_signal.py

@ -88,7 +88,7 @@ class NotifySignalTestCase(BaseTestCase):
obj.save()
self.channel.notify(self.check)
n = Notification.objects.first()
n = Notification.objects.get()
self.assertEqual(n.error, "Rate limit exceeded")
self.assertFalse(mock_bus.SysemBus.called)

38
hc/api/tests/test_notify_telegram.py

@ -2,7 +2,7 @@
from datetime import timedelta as td
import json
from unittest.mock import patch
from unittest.mock import Mock, patch
from django.utils.timezone import now
from hc.api.models import Channel, Check, Notification, TokenBucket
@ -42,19 +42,49 @@ class NotifyTelegramTestCase(BaseTestCase):
self.assertNotIn("All the other checks are up.", payload["text"])
@patch("hc.api.transports.requests.request")
def test_telegram_returns_error(self, mock_post):
def test_it_returns_error(self, mock_post):
mock_post.return_value.status_code = 400
mock_post.return_value.json.return_value = {"description": "Hi"}
self.channel.notify(self.check)
n = Notification.objects.first()
n = Notification.objects.get()
self.assertEqual(n.error, 'Received status code 400 with a message: "Hi"')
@patch("hc.api.transports.requests.request")
def test_it_handles_non_json_error(self, mock_post):
mock_post.return_value.status_code = 400
mock_post.return_value.json = Mock(side_effect=ValueError)
self.channel.notify(self.check)
n = Notification.objects.get()
self.assertEqual(n.error, "Received status code 400")
@patch("hc.api.transports.requests.request")
def test_it_handles_group_supergroup_migration(self, mock_post):
error_response = Mock(status_code=400)
error_response.json.return_value = {
"description": "Hello",
"parameters": {"migrate_to_chat_id": -234},
}
mock_post.side_effect = [error_response, Mock(status_code=200)]
self.channel.notify(self.check)
self.assertEqual(mock_post.call_count, 2)
# The chat id should have been updated
self.channel.refresh_from_db()
self.assertEqual(self.channel.telegram_id, -234)
# There should be no logged error
n = Notification.objects.get()
self.assertEqual(n.error, "")
def test_telegram_obeys_rate_limit(self):
TokenBucket.objects.create(value="tg-123", tokens=0)
self.channel.notify(self.check)
n = Notification.objects.first()
n = Notification.objects.get()
self.assertEqual(n.error, "Rate limit exceeded")
@patch("hc.api.transports.requests.request")

17
hc/api/tests/test_notify_zulip.py

@ -2,7 +2,7 @@
from datetime import timedelta as td
import json
from unittest.mock import patch
from unittest.mock import Mock, patch
from django.test.utils import override_settings
from django.utils.timezone import now
@ -52,17 +52,26 @@ class NotifyZulipTestCase(BaseTestCase):
self.assertNotIn(str(self.check.code), serialized)
@patch("hc.api.transports.requests.request")
def test_zulip_returns_error(self, mock_post):
def test_it_returns_error(self, mock_post):
mock_post.return_value.status_code = 403
mock_post.return_value.json.return_value = {"msg": "Nice try"}
self.channel.notify(self.check)
n = Notification.objects.first()
n = Notification.objects.get()
self.assertEqual(n.error, 'Received status code 403 with a message: "Nice try"')
@patch("hc.api.transports.requests.request")
def test_zulip_uses_site_parameter(self, mock_post):
def test_it_handles_non_json_error_response(self, mock_post):
mock_post.return_value.status_code = 403
mock_post.return_value.json = Mock(side_effect=ValueError)
self.channel.notify(self.check)
n = Notification.objects.get()
self.assertEqual(n.error, "Received status code 403")
@patch("hc.api.transports.requests.request")
def test_it_uses_site_parameter(self, mock_post):
mock_post.return_value.status_code = 200
definition = {
"bot_email": "bot@example.org",

116
hc/api/transports.py

@ -1,19 +1,21 @@
import os
import json
import time
from django.conf import settings
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.html import escape
import json
import requests
from urllib.parse import quote, urlencode
from hc.accounts.models import Profile
from hc.api.schemas import telegram_migration
from hc.front.templatetags.hc_extras import sortchecks
from hc.lib import emails
from hc.lib import emails, jsonschema
from hc.lib.string import replace
try:
import apprise
except ImportError:
@ -163,27 +165,28 @@ class Shell(Transport):
class HttpTransport(Transport):
@classmethod
def get_error(cls, response):
# Override in subclasses: look for a specific error message in the
# response and return it.
return None
# Subclasses can override this method and return a more specific message.
return f"Received status code {response.status_code}"
@classmethod
def is_retryable(cls, error):
# Subclasses can override this method and return False in cases when retrying
# would be pointless (e.g. because the we got a "Permission Denied" response)
return True
@classmethod
def _request(cls, method, url, **kwargs):
try:
options = dict(kwargs)
options["timeout"] = 10
if "headers" not in options:
options["headers"] = {}
if "User-Agent" not in options["headers"]:
options["headers"]["User-Agent"] = "healthchecks.io"
options = dict(kwargs)
options["timeout"] = 10
if "headers" not in options:
options["headers"] = {}
if "User-Agent" not in options["headers"]:
options["headers"]["User-Agent"] = "healthchecks.io"
try:
r = requests.request(method, url, **options)
if r.status_code not in (200, 201, 202, 204):
m = cls.get_error(r)
if m:
return f'Received status code {r.status_code} with a message: "{m}"'
return f"Received status code {r.status_code}"
return cls.get_error(r)
except requests.exceptions.Timeout:
# Well, we tried
@ -197,13 +200,15 @@ class HttpTransport(Transport):
error = cls._request(method, url, **kwargs)
# 2nd try
if error and use_retries:
if error and cls.is_retryable(error) and use_retries:
error = cls._request(method, url, **kwargs)
# 3rd try. Only do the 3rd try if we have spent 10s or less in first two
# tries. Otherwise we risk overshooting the 20s total time budget.
if error and use_retries and time.time() - start < 10:
error = cls._request(method, url, **kwargs)
# 3rd try.
if error and cls.is_retryable(error) and use_retries:
# Only do the 3rd try if we have spent 10s or less in first two
# tries. Otherwise we risk overshooting the 20s total time budget.
if time.time() - start < 10:
error = cls._request(method, url, **kwargs)
return error
@ -304,11 +309,17 @@ class HipChat(HttpTransport):
class Opsgenie(HttpTransport):
@classmethod
def get_error(cls, response):
result = f"Received status code {response.status_code}"
try:
return response.json().get("message")
message = response.json().get("message")
if isinstance(message, str):
result += f' with a message: "{message}"'
except ValueError:
pass
return result
def notify(self, check):
if not settings.OPSGENIE_ENABLED:
return "Opsgenie notifications are not enabled."
@ -495,11 +506,34 @@ class Telegram(HttpTransport):
@classmethod
def get_error(cls, response):
result = f"Received status code {response.status_code}"
try:
return response.json().get("description")
doc = response.json()
except ValueError:
return result
try:
# If the error payload contains the migrate_to_chat_id field,
# prepare a special error message, that the
# `handle_supergroup_migration` will later recognize.
jsonschema.validate(doc, telegram_migration)
chat_id = doc["parameters"]["migrate_to_chat_id"]
return f"migrate_to_chat_id: {chat_id}"
except jsonschema.ValidationError:
pass
description = doc.get("description")
if isinstance(description, str):
result += f' with a message: "{description}"'
return result
@classmethod
def is_retryable(cls, error):
# No point retrying if Telegram wants us to use a different chat_id
return False if error.startswith("migrate_to_chat_id:") else True
@classmethod
def send(cls, chat_id, text):
# Telegram.send is a separate method because it is also used in
@ -508,6 +542,24 @@ class Telegram(HttpTransport):
cls.SM, json={"chat_id": chat_id, "text": text, "parse_mode": "html"}
)
def handle_supergroup_migration(self, error):
"""Update channel's chat_id if the error message contains a new chat_id.
Return True if the chat id was updated, False otherwise.
"""
if error.startswith("migrate_to_chat_id:"):
_, chat_id_str = error.split(":")
doc = self.channel.json
doc["id"] = int(chat_id_str)
self.channel.value = json.dumps(doc)
self.channel.save()
return True
return False
def notify(self, check):
from hc.api.models import TokenBucket
@ -516,7 +568,13 @@ class Telegram(HttpTransport):
ctx = {"check": check, "down_checks": self.down_checks(check)}
text = tmpl("telegram_message.html", **ctx)
return self.send(self.channel.telegram_id, text)
error = self.send(self.channel.telegram_id, text)
if error and self.handle_supergroup_migration(error):
# Performed supergroup migration, now let's try sending again:
error = self.send(self.channel.telegram_id, text)
return error
class Sms(HttpTransport):
@ -686,11 +744,17 @@ class MsTeams(HttpTransport):
class Zulip(HttpTransport):
@classmethod
def get_error(cls, response):
result = f"Received status code {response.status_code}"
try:
return response.json().get("msg")
message = response.json().get("msg")
if isinstance(message, str):
result += f' with a message: "{message}"'
except ValueError:
pass
return result
def notify(self, check):
if not settings.ZULIP_ENABLED:
return "Zulip notifications are not enabled."

2
templates/front/channels.html

@ -59,7 +59,7 @@
{% elif ch.kind == "telegram" %}
Telegram
{% if ch.telegram_type == "group" %}
chat <span>{{ ch.telegram_name }}</span>
group <span>{{ ch.telegram_name }}</span>
{% elif ch.telegram_type == "private" %}
user <span>{{ ch.telegram_name }}</span>
{% elif ch.telegram_type == "channel" %}

4
templates/integrations/add_telegram.html

@ -4,7 +4,7 @@
{% block title %}Telegram Integration for {{ site_name }}{% endblock %}
{% block description %}
<meta name="description" content="Use {{ site_name }} with Telegram: configure {{ site_name }} to post status updates to a Telegram chat or user.">
<meta name="description" content="Use {{ site_name }} with Telegram: configure {{ site_name }} to post status updates to a Telegram user, group or channel.">
{% endblock %}
{% block content %}
@ -55,7 +55,7 @@
{% 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 user, chat or channel.</p>
appropriate Telegram user, group or channel.</p>
<h2>Setup Guide</h2>
<div class="row ai-step">

Loading…
Cancel
Save