diff --git a/CHANGELOG.md b/CHANGELOG.md index ee94d87d..fd9576b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - 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 bold and monospace text formatting in Signal notifications ### Bug Fixes - Fix "senddeletionnotices" to recognize "Supporter" subscriptions diff --git a/hc/api/models.py b/hc/api/models.py index 071747f5..8bac3065 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -762,12 +762,13 @@ class Channel(models.Model): """ 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 ctx = { "recipient": self.phone_number, - "subject": message.split("\n")[0], + "subject": plaintext.split("\n")[0], "message": message, + "plaintext": plaintext, } emails.signal_rate_limited(email, ctx) diff --git a/hc/api/tests/test_notify_signal.py b/hc/api/tests/test_notify_signal.py index b90aee4c..738c384b 100644 --- a/hc/api/tests/test_notify_signal.py +++ b/hc/api/tests/test_notify_signal.py @@ -95,7 +95,9 @@ class NotifySignalTestCase(BaseTestCase): self.assertEqual(n.error, "") 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("Tags: foo, bar", params["message"]) self.assertIn("Period: 1 day", params["message"]) @@ -138,7 +140,7 @@ class NotifySignalTestCase(BaseTestCase): self.assertEqual(n.error, "") 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("Tags: foo, a&b", params["message"]) @@ -328,6 +330,9 @@ class NotifySignalTestCase(BaseTestCase): } setup_mock(socket, msg) + self.check.name = "Foo & Co" + self.check.save() + self.channel.notify(self.check) n = Notification.objects.get() @@ -343,8 +348,15 @@ class NotifySignalTestCase(BaseTestCase): email = emails["alice@example.org"] self.assertEqual( 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") def test_it_handles_null_data(self, socket): diff --git a/hc/api/transports.py b/hc/api/transports.py index 7f4035b4..e53d61cb 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -23,6 +23,7 @@ from hc.front.templatetags.hc_extras import ( ) from hc.lib import curl, emails, jsonschema 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.string import replace @@ -1047,10 +1048,15 @@ class Signal(Transport): return not self.channel.signal_notify_up def send(self, recipient: str, message: str) -> None: + plaintext, styles = extract_signal_styles(message) payload = { "jsonrpc": "2.0", "method": "send", - "params": {"recipient": [recipient], "message": message}, + "params": { + "recipient": [recipient], + "message": plaintext, + "textStyle": styles, + }, "id": str(uuid.uuid4()), } @@ -1077,7 +1083,9 @@ class Signal(Transport): if self.channel: raw = reply_bytes.decode() 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") code = reply["error"].get("code") diff --git a/hc/lib/html.py b/hc/lib/html.py index 1fa4c1ca..1607a8a7 100644 --- a/hc/lib/html.py +++ b/hc/lib/html.py @@ -1,5 +1,7 @@ from __future__ import annotations +import re +from html import unescape from html.parser import HTMLParser @@ -34,3 +36,42 @@ def html2text(html, skip_pre=False): parser.feed(html) 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 diff --git a/hc/lib/tests/test_html.py b/hc/lib/tests/test_html.py index 96bb4992..61c745d2 100644 --- a/hc/lib/tests/test_html.py +++ b/hc/lib/tests/test_html.py @@ -2,10 +2,10 @@ from __future__ import annotations 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): sample = """ <style>css goes here</style> @@ -19,3 +19,24 @@ class HtmlTestCase(TestCase): def test_it_does_not_inject_whitespace(self): sample = """<b>S</b>UCCESS""" 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"]) diff --git a/templates/emails/signal-rate-limited-body-html.html b/templates/emails/signal-rate-limited-body-html.html index 8659c0f7..62cae1ca 100644 --- a/templates/emails/signal-rate-limited-body-html.html +++ b/templates/emails/signal-rate-limited-body-html.html @@ -10,7 +10,7 @@ instead: </p> <div style="background: #F2F4F6; padding: 20px;"> - {{ message|linebreaksbr }} + {{ message|safe|linebreaksbr }} </div> <br /> {% endblock %} diff --git a/templates/emails/signal-rate-limited-body-text.html b/templates/emails/signal-rate-limited-body-text.html index b854ac8c..ecc25efb 100644 --- a/templates/emails/signal-rate-limited-body-text.html +++ b/templates/emails/signal-rate-limited-body-text.html @@ -5,7 +5,7 @@ hitting a rate-limit on the Signal network, so we are sending it via email instead: *** -{{ message }} +{{ plaintext|safe }} *** Regards, diff --git a/templates/emails/signal-rate-limited-subject.html b/templates/emails/signal-rate-limited-subject.html index dcf1889e..30a42dc3 100644 --- a/templates/emails/signal-rate-limited-subject.html +++ b/templates/emails/signal-rate-limited-subject.html @@ -1 +1 @@ -Signal notification failed: {{ subject }} \ No newline at end of file +Signal notification failed: {{ subject|safe }} \ No newline at end of file diff --git a/templates/integrations/signal_message.html b/templates/integrations/signal_message.html index b1115ed1..d1d6437e 100644 --- a/templates/integrations/signal_message.html +++ b/templates/integrations/signal_message.html @@ -1,43 +1,43 @@ {% load hc_extras humanize linemode %}{% linemode %} {% 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 %} - {% 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 %} {% line %}{% endline %} {% if check.project.name %} -{% line %}Project: {{ check.project.name|safe }}{% endline %} +{% line %}<b>Project:</b> {{ check.project.name }}{% endline %} {% endif %} {% if check.tags_list %} -{% line %}Tags: {{ check.tags_list|safeseq|join:", " }}{% endline %} +{% line %}<b>Tags:</b> {{ check.tags_list|join:", " }}{% endline %} {% endif %} {% if check.kind == "simple" %} -{% line %}Period: {{ check.timeout|hc_duration }}{% endline %} +{% line %}<b>Period:</b> {{ check.timeout|hc_duration }}{% endline %} {% endif %} {% if check.kind == "cron" %} -{% line %}Schedule: {{ check.schedule }}{% endline %} -{% line %}Time Zone: {{ check.tz }}{% endline %} +{% line %}<b>Schedule:</b> <code>{{ check.schedule }}</code>{% endline %} +{% line %}<b>Time Zone:</b> {{ check.tz }}{% endline %} {% endif %} -{% line %}Total Pings: {{ check.n_pings }}{% endline %} +{% line %}<b>Total Pings:</b> {{ check.n_pings }}{% endline %} {% if ping is None %} -{% line %}Last Ping: Never{% endline %} +{% line %}<b>Last Ping:</b> Never{% endline %} {% 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 %} -{% line %}Last Ping: Failure, {{ ping.created|naturaltime }}{% endline %} +{% line %}<b>Last Ping:</b> Failure, {{ ping.created|naturaltime }}{% endline %} {% 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" %} -{% line %}Last Ping: Log, {{ ping.created|naturaltime }}{% endline %} +{% line %}<b>Last Ping:</b> Log, {{ ping.created|naturaltime }}{% endline %} {% else %} -{% line %}Last Ping: Success, {{ ping.created|naturaltime }}{% endline %} +{% line %}<b>Last Ping:</b> Success, {{ ping.created|naturaltime }}{% endline %} {% endif %} {% if down_checks is not None %} @@ -48,7 +48,7 @@ {% else %} {% line %}The following checks are {% if check.status == "down" %}also{% else %}still{% endif %} down:{% endline %} {% 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 %} {% endif %} {% else %}