mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-06 21:58:48 +00:00
Add bold and monospace text formatting in Signal notifications
This commit is contained in:
parent
200a2d1dd7
commit
897cf0088b
10 changed files with 111 additions and 27 deletions
|
@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
|
||||||
- Add Channel.last_notify_duration field, use it in "sendalerts" for prioritization
|
- Add Channel.last_notify_duration field, use it in "sendalerts" for prioritization
|
||||||
- Update Telegram integration to treat "bot was blocked by the user" as permanent error
|
- Update Telegram integration to treat "bot was blocked by the user" as permanent error
|
||||||
- Add "Time Zone" field in notifications that use the "Schedule" field (#863)
|
- Add "Time Zone" field in notifications that use the "Schedule" field (#863)
|
||||||
|
- Add bold and monospace text formatting in Signal notifications
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
- Fix "senddeletionnotices" to recognize "Supporter" subscriptions
|
- Fix "senddeletionnotices" to recognize "Supporter" subscriptions
|
||||||
|
|
|
@ -762,12 +762,13 @@ class Channel(models.Model):
|
||||||
"""
|
"""
|
||||||
mail_admins(subject, message, html_message=html_message)
|
mail_admins(subject, message, html_message=html_message)
|
||||||
|
|
||||||
def send_signal_rate_limited_notice(self, message):
|
def send_signal_rate_limited_notice(self, message: str, plaintext: str):
|
||||||
email = self.project.owner.email
|
email = self.project.owner.email
|
||||||
ctx = {
|
ctx = {
|
||||||
"recipient": self.phone_number,
|
"recipient": self.phone_number,
|
||||||
"subject": message.split("\n")[0],
|
"subject": plaintext.split("\n")[0],
|
||||||
"message": message,
|
"message": message,
|
||||||
|
"plaintext": plaintext,
|
||||||
}
|
}
|
||||||
emails.signal_rate_limited(email, ctx)
|
emails.signal_rate_limited(email, ctx)
|
||||||
|
|
||||||
|
|
|
@ -95,7 +95,9 @@ class NotifySignalTestCase(BaseTestCase):
|
||||||
self.assertEqual(n.error, "")
|
self.assertEqual(n.error, "")
|
||||||
|
|
||||||
params = socketobj.req["params"]
|
params = socketobj.req["params"]
|
||||||
self.assertIn("“Daily Backup” is DOWN", params["message"])
|
self.assertIn("Daily Backup is DOWN", params["message"])
|
||||||
|
self.assertEqual(params["textStyle"][0], "10:12:BOLD")
|
||||||
|
|
||||||
self.assertIn("Project: Alices Project", params["message"])
|
self.assertIn("Project: Alices Project", params["message"])
|
||||||
self.assertIn("Tags: foo, bar", params["message"])
|
self.assertIn("Tags: foo, bar", params["message"])
|
||||||
self.assertIn("Period: 1 day", params["message"])
|
self.assertIn("Period: 1 day", params["message"])
|
||||||
|
@ -138,7 +140,7 @@ class NotifySignalTestCase(BaseTestCase):
|
||||||
self.assertEqual(n.error, "")
|
self.assertEqual(n.error, "")
|
||||||
|
|
||||||
params = socketobj.req["params"]
|
params = socketobj.req["params"]
|
||||||
self.assertIn("“Foo & Co” is DOWN", params["message"])
|
self.assertIn("Foo & Co is DOWN", params["message"])
|
||||||
self.assertIn("Project: Alice & Friends", params["message"])
|
self.assertIn("Project: Alice & Friends", params["message"])
|
||||||
self.assertIn("Tags: foo, a&b", params["message"])
|
self.assertIn("Tags: foo, a&b", params["message"])
|
||||||
|
|
||||||
|
@ -328,6 +330,9 @@ class NotifySignalTestCase(BaseTestCase):
|
||||||
}
|
}
|
||||||
setup_mock(socket, msg)
|
setup_mock(socket, msg)
|
||||||
|
|
||||||
|
self.check.name = "Foo & Co"
|
||||||
|
self.check.save()
|
||||||
|
|
||||||
self.channel.notify(self.check)
|
self.channel.notify(self.check)
|
||||||
|
|
||||||
n = Notification.objects.get()
|
n = Notification.objects.get()
|
||||||
|
@ -343,8 +348,15 @@ class NotifySignalTestCase(BaseTestCase):
|
||||||
email = emails["alice@example.org"]
|
email = emails["alice@example.org"]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
email.subject,
|
email.subject,
|
||||||
"Signal notification failed: The check “Daily Backup” is DOWN.",
|
"Signal notification failed: The check Foo & Co is DOWN.",
|
||||||
)
|
)
|
||||||
|
# The plaintext version should have no HTML markup, and should
|
||||||
|
# have no &, < > stuff:
|
||||||
|
self.assertIn("The check Foo & Co is DOWN.", email.body)
|
||||||
|
# The HTML version should retain styling, and escape special characters
|
||||||
|
# in project name, check name, etc.:
|
||||||
|
html = email.alternatives[0][0]
|
||||||
|
self.assertIn("The check <b>Foo & Co</b> is <b>DOWN</b>.", html)
|
||||||
|
|
||||||
@patch("hc.api.transports.socket.socket")
|
@patch("hc.api.transports.socket.socket")
|
||||||
def test_it_handles_null_data(self, socket):
|
def test_it_handles_null_data(self, socket):
|
||||||
|
|
|
@ -23,6 +23,7 @@ from hc.front.templatetags.hc_extras import (
|
||||||
)
|
)
|
||||||
from hc.lib import curl, emails, jsonschema
|
from hc.lib import curl, emails, jsonschema
|
||||||
from hc.lib.date import format_duration
|
from hc.lib.date import format_duration
|
||||||
|
from hc.lib.html import extract_signal_styles
|
||||||
from hc.lib.signing import sign_bounce_id
|
from hc.lib.signing import sign_bounce_id
|
||||||
from hc.lib.string import replace
|
from hc.lib.string import replace
|
||||||
|
|
||||||
|
@ -1047,10 +1048,15 @@ class Signal(Transport):
|
||||||
return not self.channel.signal_notify_up
|
return not self.channel.signal_notify_up
|
||||||
|
|
||||||
def send(self, recipient: str, message: str) -> None:
|
def send(self, recipient: str, message: str) -> None:
|
||||||
|
plaintext, styles = extract_signal_styles(message)
|
||||||
payload = {
|
payload = {
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"method": "send",
|
"method": "send",
|
||||||
"params": {"recipient": [recipient], "message": message},
|
"params": {
|
||||||
|
"recipient": [recipient],
|
||||||
|
"message": plaintext,
|
||||||
|
"textStyle": styles,
|
||||||
|
},
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1077,7 +1083,9 @@ class Signal(Transport):
|
||||||
if self.channel:
|
if self.channel:
|
||||||
raw = reply_bytes.decode()
|
raw = reply_bytes.decode()
|
||||||
self.channel.send_signal_captcha_alert(result["token"], raw)
|
self.channel.send_signal_captcha_alert(result["token"], raw)
|
||||||
self.channel.send_signal_rate_limited_notice(message)
|
self.channel.send_signal_rate_limited_notice(
|
||||||
|
message, plaintext
|
||||||
|
)
|
||||||
raise TransportError("CAPTCHA proof required")
|
raise TransportError("CAPTCHA proof required")
|
||||||
|
|
||||||
code = reply["error"].get("code")
|
code = reply["error"].get("code")
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from html import unescape
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
|
||||||
|
@ -34,3 +36,42 @@ def html2text(html, skip_pre=False):
|
||||||
|
|
||||||
parser.feed(html)
|
parser.feed(html)
|
||||||
return parser.get_text()
|
return parser.get_text()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_signal_styles(markup: str) -> tuple[str, list[str]]:
|
||||||
|
"""Convert HTML syntax to Signal text styles.
|
||||||
|
|
||||||
|
This implementation has limited functionality, and only supports the features
|
||||||
|
we do use:
|
||||||
|
* only supports <b> and <code> tags
|
||||||
|
* does not support nested (<b><code>text</code></b>) tags
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
>>> extract_signal_styles("<b>foo</b> bar")
|
||||||
|
"foo bar", ["0:3:BOLD"]
|
||||||
|
|
||||||
|
"""
|
||||||
|
text = ""
|
||||||
|
styles: list[str] = []
|
||||||
|
tag, tag_idx = "", 0
|
||||||
|
|
||||||
|
for part in re.split(r"(</?(?:b|code)>)", markup):
|
||||||
|
if part == "<b>":
|
||||||
|
tag = "BOLD"
|
||||||
|
tag_idx = len(text)
|
||||||
|
elif part == "</b>":
|
||||||
|
assert tag == "BOLD"
|
||||||
|
len_tagged = len(text) - tag_idx
|
||||||
|
styles.append(f"{tag_idx}:{len_tagged}:{tag}")
|
||||||
|
elif part == "<code>":
|
||||||
|
tag = "MONOSPACE"
|
||||||
|
tag_idx = len(text)
|
||||||
|
elif part == "</code>":
|
||||||
|
assert tag == "MONOSPACE"
|
||||||
|
len_tagged = len(text) - tag_idx
|
||||||
|
styles.append(f"{tag_idx}:{len_tagged}:{tag}")
|
||||||
|
else:
|
||||||
|
text += unescape(part)
|
||||||
|
|
||||||
|
return text, styles
|
||||||
|
|
|
@ -2,10 +2,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
|
||||||
from hc.lib.html import html2text
|
from hc.lib.html import extract_signal_styles, html2text
|
||||||
|
|
||||||
|
|
||||||
class HtmlTestCase(TestCase):
|
class Html2TextTestCase(TestCase):
|
||||||
def test_it_works(self):
|
def test_it_works(self):
|
||||||
sample = """
|
sample = """
|
||||||
<style>css goes here</style>
|
<style>css goes here</style>
|
||||||
|
@ -19,3 +19,24 @@ class HtmlTestCase(TestCase):
|
||||||
def test_it_does_not_inject_whitespace(self):
|
def test_it_does_not_inject_whitespace(self):
|
||||||
sample = """<b>S</b>UCCESS"""
|
sample = """<b>S</b>UCCESS"""
|
||||||
self.assertEqual(html2text(sample), "SUCCESS")
|
self.assertEqual(html2text(sample), "SUCCESS")
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractSignalTestCase(TestCase):
|
||||||
|
def test_b_works(self):
|
||||||
|
text, styles = extract_signal_styles("<b>foo</b> bar")
|
||||||
|
self.assertEqual(text, "foo bar")
|
||||||
|
self.assertEqual(styles, ["0:3:BOLD"])
|
||||||
|
|
||||||
|
def test_code_works(self):
|
||||||
|
text, styles = extract_signal_styles("foo <code>bar</code>")
|
||||||
|
self.assertEqual(text, "foo bar")
|
||||||
|
self.assertEqual(styles, ["4:3:MONOSPACE"])
|
||||||
|
|
||||||
|
def test_it_rejects_mismatched_tags(self):
|
||||||
|
with self.assertRaises(AssertionError):
|
||||||
|
extract_signal_styles("<b>foo</code>")
|
||||||
|
|
||||||
|
def test_it_unescapes_html(self):
|
||||||
|
text, styles = extract_signal_styles("<b>5 < 10</b>")
|
||||||
|
self.assertEqual(text, "5 < 10")
|
||||||
|
self.assertEqual(styles, ["0:6:BOLD"])
|
||||||
|
|
|
@ -10,7 +10,7 @@ instead:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="background: #F2F4F6; padding: 20px;">
|
<div style="background: #F2F4F6; padding: 20px;">
|
||||||
{{ message|linebreaksbr }}
|
{{ message|safe|linebreaksbr }}
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -5,7 +5,7 @@ hitting a rate-limit on the Signal network, so we are sending it via email
|
||||||
instead:
|
instead:
|
||||||
|
|
||||||
***
|
***
|
||||||
{{ message }}
|
{{ plaintext|safe }}
|
||||||
***
|
***
|
||||||
|
|
||||||
Regards,
|
Regards,
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Signal notification failed: {{ subject }}
|
Signal notification failed: {{ subject|safe }}
|
|
@ -1,43 +1,43 @@
|
||||||
{% load hc_extras humanize linemode %}{% linemode %}
|
{% load hc_extras humanize linemode %}{% linemode %}
|
||||||
{% if check.status == "down" %}
|
{% if check.status == "down" %}
|
||||||
{% line %}The check “{{ check.name_then_code|safe }}” is DOWN.{% endline %}
|
{% line %}The check <b>{{ check.name_then_code }}</b> is <b>DOWN</b>.{% endline %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% line %}The check “{{ check.name_then_code|safe }}” is now UP.{% endline %}
|
{% line %}The check <b>{{ check.name_then_code }}</b> is now <b>UP</b>.{% endline %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% line %}{% endline %}
|
{% line %}{% endline %}
|
||||||
|
|
||||||
{% if check.project.name %}
|
{% if check.project.name %}
|
||||||
{% line %}Project: {{ check.project.name|safe }}{% endline %}
|
{% line %}<b>Project:</b> {{ check.project.name }}{% endline %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if check.tags_list %}
|
{% if check.tags_list %}
|
||||||
{% line %}Tags: {{ check.tags_list|safeseq|join:", " }}{% endline %}
|
{% line %}<b>Tags:</b> {{ check.tags_list|join:", " }}{% endline %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if check.kind == "simple" %}
|
{% if check.kind == "simple" %}
|
||||||
{% line %}Period: {{ check.timeout|hc_duration }}{% endline %}
|
{% line %}<b>Period:</b> {{ check.timeout|hc_duration }}{% endline %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if check.kind == "cron" %}
|
{% if check.kind == "cron" %}
|
||||||
{% line %}Schedule: {{ check.schedule }}{% endline %}
|
{% line %}<b>Schedule:</b> <code>{{ check.schedule }}</code>{% endline %}
|
||||||
{% line %}Time Zone: {{ check.tz }}{% endline %}
|
{% line %}<b>Time Zone:</b> {{ check.tz }}{% endline %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% line %}Total Pings: {{ check.n_pings }}{% endline %}
|
{% line %}<b>Total Pings:</b> {{ check.n_pings }}{% endline %}
|
||||||
|
|
||||||
{% if ping is None %}
|
{% if ping is None %}
|
||||||
{% line %}Last Ping: Never{% endline %}
|
{% line %}<b>Last Ping:</b> Never{% endline %}
|
||||||
{% elif ping.kind == "ign" %}
|
{% elif ping.kind == "ign" %}
|
||||||
{% line %}Last Ping: Ignored, {{ ping.created|naturaltime }}{% endline %}
|
{% line %}<b>Last Ping:</b> Ignored, {{ ping.created|naturaltime }}{% endline %}
|
||||||
{% elif ping.kind == "fail" or ping.exitstatus > 0 %}
|
{% elif ping.kind == "fail" or ping.exitstatus > 0 %}
|
||||||
{% line %}Last Ping: Failure, {{ ping.created|naturaltime }}{% endline %}
|
{% line %}<b>Last Ping:</b> Failure, {{ ping.created|naturaltime }}{% endline %}
|
||||||
{% elif ping.kind == "start" %}
|
{% elif ping.kind == "start" %}
|
||||||
{% line %}Last Ping: Start, {{ ping.created|naturaltime }}{% endline %}
|
{% line %}<b>Last Ping:</b> Start, {{ ping.created|naturaltime }}{% endline %}
|
||||||
{% elif ping.kind == "log" %}
|
{% elif ping.kind == "log" %}
|
||||||
{% line %}Last Ping: Log, {{ ping.created|naturaltime }}{% endline %}
|
{% line %}<b>Last Ping:</b> Log, {{ ping.created|naturaltime }}{% endline %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% line %}Last Ping: Success, {{ ping.created|naturaltime }}{% endline %}
|
{% line %}<b>Last Ping:</b> Success, {{ ping.created|naturaltime }}{% endline %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if down_checks is not None %}
|
{% if down_checks is not None %}
|
||||||
|
@ -48,7 +48,7 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
{% line %}The following checks are {% if check.status == "down" %}also{% else %}still{% endif %} down:{% endline %}
|
{% line %}The following checks are {% if check.status == "down" %}also{% else %}still{% endif %} down:{% endline %}
|
||||||
{% for c in down_checks %}
|
{% for c in down_checks %}
|
||||||
{% line %}- “{{ c.name_then_code|safe }}” (last ping: {{ c.last_ping|naturaltime }}){% endline %}
|
{% line %}• <b>{{ c.name_then_code|safe }}</b> (last ping: {{ c.last_ping|naturaltime }}){% endline %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
Loading…
Add table
Reference in a new issue