diff --git a/matrix_alertbot/alert.py b/matrix_alertbot/alert.py
index 066adb2..aec4cd8 100644
--- a/matrix_alertbot/alert.py
+++ b/matrix_alertbot/alert.py
@@ -1,7 +1,15 @@
from __future__ import annotations
import logging
-from typing import Dict
+from typing import Dict, Optional
+
+from jinja2 import (
+ BaseLoader,
+ ChoiceLoader,
+ Environment,
+ FileSystemLoader,
+ PackageLoader,
+)
logger = logging.getLogger(__name__)
@@ -24,6 +32,7 @@ class Alert:
self.labels = labels
self.annotations = annotations
+ self.description = annotations["description"]
if self.firing:
self.status = self.labels["severity"]
@@ -48,20 +57,20 @@ class Alert:
def color(self) -> str:
return self.COLORS[self.status]
- def plaintext(self) -> str:
- alertname = self.labels["alertname"]
- description = self.annotations["description"]
- return f"[{self.emoji} {self.status.upper()}] {alertname}: {description}"
- def html(self) -> str:
- alertname = self.labels["alertname"]
+class AlertRenderer:
+ def __init__(self, template_dir: Optional[str] = None) -> None:
+ loader: BaseLoader = PackageLoader("matrix_alertbot", "resources/templates")
+ if template_dir is not None:
+ loader = ChoiceLoader([FileSystemLoader(template_dir), loader])
+ env = Environment(loader=loader, autoescape=True)
- job = self.labels.get("job", "")
- if job:
- job = f"({job})"
+ self.html_template = env.get_template("alert.html.j2")
+ self.text_template = env.get_template("alert.txt.j2")
- description = self.annotations["description"]
- return (
- f"[{self.emoji} {self.status.upper()}] "
- f"{alertname} {job}
{description}"
- )
+ def render(self, alert: Alert, html: bool = True) -> str:
+ if html:
+ template = self.html_template
+ else:
+ template = self.text_template
+ return template.render(vars(alert), color=alert.color, emoji=alert.emoji)
diff --git a/matrix_alertbot/callback.py b/matrix_alertbot/callback.py
index 20dbfc7..f984e35 100644
--- a/matrix_alertbot/callback.py
+++ b/matrix_alertbot/callback.py
@@ -21,9 +21,6 @@ from matrix_alertbot.config import Config
logger = logging.getLogger(__name__)
-REACTIONS = {"🤫", "😶", "🤐", "🙊", "🔇", "🔕"}
-
-
class Callbacks:
def __init__(
self,
@@ -176,7 +173,7 @@ class Callbacks:
reaction = event.source.get("content", {}).get("m.relates_to", {}).get("key")
logger.debug(f"Got reaction {reaction} to {room.room_id} from {event.sender}.")
- if reaction not in REACTIONS:
+ if reaction not in self.config.allowed_reactions:
logger.warning(f"Uknown duration reaction {reaction}")
return
diff --git a/matrix_alertbot/command.py b/matrix_alertbot/command.py
index 183d573..cc42784 100644
--- a/matrix_alertbot/command.py
+++ b/matrix_alertbot/command.py
@@ -104,7 +104,9 @@ class AckAlertCommand(BaseAlertCommand):
)
return
elif duration_seconds < 0:
- logger.error(f"Unable to create silence: Duration must be positive, got '{duration}'")
+ logger.error(
+ f"Unable to create silence: Duration must be positive, got '{duration}'"
+ )
await send_text_to_room(
self.client,
self.room.room_id,
diff --git a/matrix_alertbot/config.py b/matrix_alertbot/config.py
index 9b46062..46a0d57 100644
--- a/matrix_alertbot/config.py
+++ b/matrix_alertbot/config.py
@@ -19,6 +19,9 @@ logging.getLogger("peewee").setLevel(
) # Prevent debug messages from peewee lib
+DEFAULT_REACTIONS = {"🤫", "😶", "🤐", "🙊", "🔇", "🔕"}
+
+
class Config:
"""Creates a Config object from a YAML-encoded config file from a given filepath"""
@@ -75,6 +78,9 @@ class Config:
f"storage.path '{self.store_dir}' is not a directory"
)
+ # Template setup
+ self.template_dir: str = self._get_cfg(["template", "path"], required=False)
+
# Cache setup
self.cache_dir: str = self._get_cfg(["cache", "path"], required=True)
expire_time: str = self._get_cfg(["cache", "expire_time"], default="1w")
@@ -105,6 +111,9 @@ class Config:
self.allowed_rooms: list = self._get_cfg(
["matrix", "allowed_rooms"], required=True
)
+ self.allowed_reactions = set(
+ self._get_cfg(["matrix", "allowed_reactions"], default=DEFAULT_REACTIONS)
+ )
self.address: str = self._get_cfg(["webhook", "address"], required=False)
self.port: int = self._get_cfg(["webhook", "port"], required=False)
diff --git a/matrix_alertbot/resources/templates/alert.html.j2 b/matrix_alertbot/resources/templates/alert.html.j2
new file mode 100644
index 0000000..79d9880
--- /dev/null
+++ b/matrix_alertbot/resources/templates/alert.html.j2
@@ -0,0 +1,5 @@
+
+ [{{ emoji }} {{ status | upper }}]
+ {{ labels.alertname }}
+{% if 'job' in labels %} ({{ labels.job }}){% endif %}
+{{ description }}
diff --git a/matrix_alertbot/resources/templates/alert.txt.j2 b/matrix_alertbot/resources/templates/alert.txt.j2
new file mode 100644
index 0000000..c4552ea
--- /dev/null
+++ b/matrix_alertbot/resources/templates/alert.txt.j2
@@ -0,0 +1 @@
+[{{ emoji }} {{ status | upper }}] {{ labels.alertname }}: {{ description }}
diff --git a/matrix_alertbot/webhook.py b/matrix_alertbot/webhook.py
index 4fe11b2..d1541ed 100644
--- a/matrix_alertbot/webhook.py
+++ b/matrix_alertbot/webhook.py
@@ -9,7 +9,7 @@ from aiohttp_prometheus_exporter.middleware import prometheus_middleware_factory
from diskcache import Cache
from nio import AsyncClient, LocalProtocolError
-from matrix_alertbot.alert import Alert
+from matrix_alertbot.alert import Alert, AlertRenderer
from matrix_alertbot.chat_functions import send_text_to_room
from matrix_alertbot.config import Config
@@ -31,10 +31,13 @@ async def create_alerts(request: web_request.Request) -> web.Response:
client: AsyncClient = request.app["client"]
config: Config = request.app["config"]
cache: Cache = request.app["cache"]
+ alert_renderer: AlertRenderer = request.app["alert_renderer"]
if room_id not in config.allowed_rooms:
logger.error("Cannot send alerts to room ID {room_id}.")
- return web.Response(status=401, body=f"Cannot send alerts to room ID {room_id}.")
+ return web.Response(
+ status=401, body=f"Cannot send alerts to room ID {room_id}."
+ )
if "alerts" not in data:
logger.error("Received data without 'alerts' key")
@@ -61,8 +64,8 @@ async def create_alerts(request: web_request.Request) -> web.Response:
logger.error(f"Cannot parse alert dict: {e}")
return web.Response(status=400, body=f"Invalid alert: {alert}.")
- plaintext = alert.plaintext()
- html = alert.html()
+ plaintext = alert_renderer.render(alert, html=False)
+ html = alert_renderer.render(alert, html=True)
try:
event = await send_text_to_room(
@@ -91,6 +94,7 @@ class Webhook:
self.app["client"] = client
self.app["config"] = config
self.app["cache"] = cache
+ self.app["alert_renderer"] = AlertRenderer(config.template_dir)
self.app.add_routes(routes)
prometheus_registry = prometheus_client.CollectorRegistry(auto_describe=True)
@@ -112,10 +116,10 @@ class Webhook:
site: web.BaseSite
if self.address and self.port:
site = web.TCPSite(self.runner, self.address, self.port)
- logger.info(f"Listenning on {self.address}:{self.port}")
+ logger.info(f"Listening on {self.address}:{self.port}")
elif self.socket:
site = web.UnixSite(self.runner, self.socket)
- logger.info(f"Listenning on unix://{self.socket}")
+ logger.info(f"Listening on unix://{self.socket}")
await site.start()
diff --git a/setup.py b/setup.py
index 91075ca..2fdc3b5 100644
--- a/setup.py
+++ b/setup.py
@@ -29,6 +29,7 @@ setup(
"aiohttp-prometheus-exporter>=0.2.4",
"aiotools>=1.5.9",
"diskcache>=5.4.0",
+ "jinja2>=3.1.2",
"matrix-nio>=0.19.0",
"Markdown>=3.3.7",
"pytimeparse2>=1.4.0",
diff --git a/tests/resources/config/config.full.yml b/tests/resources/config/config.full.yml
index a6e1522..79a6536 100644
--- a/tests/resources/config/config.full.yml
+++ b/tests/resources/config/config.full.yml
@@ -22,6 +22,7 @@ matrix:
device_name: fake_device_name
allowed_rooms:
- "!abcdefgh:matrix.example.com"
+ allowed_reactions: [🤫, 😶, 🤐]
webhook:
socket: matrix-alertbot.socket
@@ -38,6 +39,9 @@ storage:
# containing encryption keys, sync tokens, etc.
path: "data/store"
+template:
+ path: "data/templates"
+
# Logging setup
logging:
# Logging level
diff --git a/tests/resources/templates/alert.html.j2 b/tests/resources/templates/alert.html.j2
new file mode 100644
index 0000000..46d7514
--- /dev/null
+++ b/tests/resources/templates/alert.html.j2
@@ -0,0 +1 @@
+hello world
diff --git a/tests/resources/templates/alert.txt.j2 b/tests/resources/templates/alert.txt.j2
new file mode 100644
index 0000000..3b18e51
--- /dev/null
+++ b/tests/resources/templates/alert.txt.j2
@@ -0,0 +1 @@
+hello world
diff --git a/tests/test_alert.py b/tests/test_alert.py
index dd1476c..e3e6c31 100644
--- a/tests/test_alert.py
+++ b/tests/test_alert.py
@@ -1,7 +1,10 @@
+import os
import unittest
from typing import Dict
-from matrix_alertbot.alert import Alert
+from matrix_alertbot.alert import Alert, AlertRenderer
+
+TESTS_DIR = os.path.dirname(__file__)
class AlertTestCase(unittest.TestCase):
@@ -40,62 +43,121 @@ class AlertTestCase(unittest.TestCase):
self.assertEqual("resolved", alert.status)
self.assertFalse(alert.firing)
- def test_display_firing_critical_alert(self) -> None:
+
+class AlertRendererTestCase(unittest.TestCase):
+ def setUp(self) -> None:
+ self.alert_dict: Dict = {
+ "fingerprint": "fingerprint1",
+ "generatorURL": "http://example.com",
+ "status": "unknown",
+ "labels": {"alertname": "alert1", "severity": "critical", "job": "job1"},
+ "annotations": {"description": "some description"},
+ }
+ self.renderer = AlertRenderer()
+
+ def test_render_firing_critical_alert(self) -> None:
self.alert_dict["status"] = "firing"
alert = Alert.from_dict(self.alert_dict)
alert.labels["severity"] = "critical"
- html = alert.html()
+ html = self.renderer.render(alert, html=True)
self.assertEqual(
- "[🔥 CRITICAL] "
- "alert1 (job1)
"
+ '\n [🔥 CRITICAL]\n '
+ 'alert1\n (job1)
\n'
"some description",
html,
)
- plaintext = alert.plaintext()
+ plaintext = self.renderer.render(alert, html=False)
self.assertEqual("[🔥 CRITICAL] alert1: some description", plaintext)
- def test_display_firing_warning_alert(self) -> None:
+ def test_render_firing_warning_alert(self) -> None:
self.alert_dict["status"] = "firing"
self.alert_dict["labels"]["severity"] = "warning"
alert = Alert.from_dict(self.alert_dict)
- html = alert.html()
+ html = self.renderer.render(alert, html=True)
self.assertEqual(
- "[⚠️ WARNING] "
- "alert1 (job1)
"
+ '\n [⚠️ WARNING]\n '
+ 'alert1\n (job1)
\n'
"some description",
html,
)
- plaintext = alert.plaintext()
+ plaintext = self.renderer.render(alert, html=False)
self.assertEqual("[⚠️ WARNING] alert1: some description", plaintext)
- def test_display_firing_unknown_alert(self) -> None:
+ def test_render_firing_unknown_alert(self) -> None:
self.alert_dict["status"] = "firing"
self.alert_dict["labels"]["severity"] = "unknown"
alert = Alert.from_dict(self.alert_dict)
with self.assertRaisesRegex(KeyError, "unknown"):
- alert.html()
+ self.renderer.render(alert, html=True)
with self.assertRaisesRegex(KeyError, "unknown"):
- alert.plaintext()
+ self.renderer.render(alert, html=False)
- def test_display_resolved_alert(self) -> None:
+ def test_render_resolved_alert(self) -> None:
self.alert_dict["status"] = "resolved"
alert = Alert.from_dict(self.alert_dict)
- html = alert.html()
+ html = self.renderer.render(alert, html=True)
self.assertEqual(
- "[🥦 RESOLVED] "
- "alert1 (job1)
"
+ '\n [🥦 RESOLVED]\n '
+ 'alert1\n (job1)
\n'
"some description",
html,
)
- plaintext = alert.plaintext()
+ plaintext = self.renderer.render(alert, html=False)
+ self.assertEqual("[🥦 RESOLVED] alert1: some description", plaintext)
+
+ def test_render_resolved_alert_without_job(self) -> None:
+ self.alert_dict["status"] = "resolved"
+ del self.alert_dict["labels"]["job"]
+ alert = Alert.from_dict(self.alert_dict)
+
+ html = self.renderer.render(alert, html=True)
+ self.assertEqual(
+ '\n [🥦 RESOLVED]\n '
+ 'alert1\n
\n'
+ "some description",
+ html,
+ )
+
+ plaintext = self.renderer.render(alert, html=False)
+ self.assertEqual("[🥦 RESOLVED] alert1: some description", plaintext)
+
+ def test_render_with_existing_filesystem_template(self) -> None:
+ alert = Alert.from_dict(self.alert_dict)
+
+ template_dir = os.path.join(TESTS_DIR, "resources/templates")
+ renderer = AlertRenderer(template_dir)
+
+ html = renderer.render(alert, html=True)
+ self.assertEqual(
+ "hello world",
+ html,
+ )
+
+ plaintext = renderer.render(alert, html=False)
+ self.assertEqual("hello world", plaintext)
+
+ def test_render_with_inexistent_filesystem_template(self) -> None:
+ self.alert_dict["status"] = "resolved"
+ alert = Alert.from_dict(self.alert_dict)
+
+ renderer = AlertRenderer(TESTS_DIR)
+ html = renderer.render(alert, html=True)
+ self.assertEqual(
+ '\n [🥦 RESOLVED]\n '
+ 'alert1\n (job1)
\n'
+ "some description",
+ html,
+ )
+
+ plaintext = renderer.render(alert, html=False)
self.assertEqual("[🥦 RESOLVED] alert1: some description", plaintext)
diff --git a/tests/test_callback.py b/tests/test_callback.py
index df5544f..31d8273 100644
--- a/tests/test_callback.py
+++ b/tests/test_callback.py
@@ -31,6 +31,7 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
# We don't spec config, as it doesn't currently have well defined attributes
self.fake_config = Mock()
self.fake_config.allowed_rooms = [self.fake_room.room_id]
+ self.fake_config.allowed_reactions = ["🤫"]
self.fake_config.command_prefix = "!alert "
self.callbacks = Callbacks(
diff --git a/tests/test_config.py b/tests/test_config.py
index 08219d0..286707b 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -5,7 +5,7 @@ from unittest.mock import Mock, patch
import yaml
-from matrix_alertbot.config import Config
+from matrix_alertbot.config import DEFAULT_REACTIONS, Config
from matrix_alertbot.errors import (
InvalidConfigError,
ParseConfigError,
@@ -58,6 +58,7 @@ class ConfigTestCase(unittest.TestCase):
self.assertEqual("matrix-alertbot", config.device_name)
self.assertEqual("https://matrix.example.com", config.homeserver_url)
self.assertEqual(["!abcdefgh:matrix.example.com"], config.allowed_rooms)
+ self.assertEqual(DEFAULT_REACTIONS, config.allowed_reactions)
self.assertEqual("0.0.0.0", config.address)
self.assertEqual(8080, config.port)
@@ -71,6 +72,8 @@ class ConfigTestCase(unittest.TestCase):
self.assertEqual("data/store", config.store_dir)
+ self.assertIsNone(config.template_dir)
+
self.assertEqual("!alert ", config.command_prefix)
@patch("os.path.isdir")
@@ -96,6 +99,7 @@ class ConfigTestCase(unittest.TestCase):
self.assertEqual("fake_device_name", config.device_name)
self.assertEqual("https://matrix.example.com", config.homeserver_url)
self.assertEqual(["!abcdefgh:matrix.example.com"], config.allowed_rooms)
+ self.assertEqual({"🤫", "😶", "🤐"}, config.allowed_reactions)
self.assertIsNone(config.address)
self.assertIsNone(config.port)
@@ -109,6 +113,8 @@ class ConfigTestCase(unittest.TestCase):
self.assertEqual("data/store", config.store_dir)
+ self.assertEqual("data/templates", config.template_dir)
+
self.assertEqual("!alert ", config.command_prefix)
def test_read_config_raise_config_error(self) -> None:
diff --git a/tests/test_webhook.py b/tests/test_webhook.py
index 5ae75f3..fbb4368 100644
--- a/tests/test_webhook.py
+++ b/tests/test_webhook.py
@@ -32,6 +32,7 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase):
self.fake_config.socket = "webhook.sock"
self.fake_config.allowed_rooms = [self.fake_room_id]
self.fake_config.cache_expire_time = 0
+ self.fake_config.template_dir = None
self.fake_alerts = {
"alerts": [
@@ -77,8 +78,8 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase):
self.fake_client,
self.fake_room_id,
"[🔥 CRITICAL] alert1: some description1",
- "[🔥 CRITICAL] "
- "alert1 (job1)
"
+ '\n [🔥 CRITICAL]\n '
+ 'alert1\n (job1)
\n'
"some description1",
notice=False,
),
@@ -86,8 +87,8 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase):
self.fake_client,
self.fake_room_id,
"[🥦 RESOLVED] alert2: some description2",
- "[🥦 RESOLVED] "
- "alert2 (job2)
"
+ '\n [🥦 RESOLVED]\n '
+ 'alert2\n (job2)
\n'
"some description2",
notice=False,
),
@@ -212,6 +213,7 @@ class WebhookServerTestCase(unittest.IsolatedAsyncioTestCase):
self.fake_config.address = "localhost"
self.fake_config.socket = "webhook.sock"
self.fake_config.cache_expire_time = 0
+ self.fake_config.template_dir = None
@patch.object(matrix_alertbot.webhook.web, "TCPSite", autospec=True)
async def test_webhook_start_address_port(self, fake_tcp_site: Mock) -> None: