Skip to content

Commit 39ed203

Browse files
committed
feat: added Telegram notification plugin
1 parent f314a08 commit 39ed203

File tree

5 files changed

+258
-0
lines changed

5 files changed

+258
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
try:
2+
from importlib.metadata import version
3+
VERSION = version(__name__)
4+
except Exception as e:
5+
VERSION = "unknown"
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""
2+
.. module: lemur.plugins.lemur_telegram.plugin
3+
:platform: Unix
4+
:copyright: (c) 2025 by Fedor S
5+
:license: Apache, see LICENSE for more details.
6+
7+
.. moduleauthor:: Fedor S <[email protected]>
8+
"""
9+
import arrow
10+
import requests
11+
from flask import current_app
12+
13+
from lemur.common.utils import check_validation
14+
from lemur.plugins import lemur_telegram as telegram
15+
from lemur.plugins.bases import ExpirationNotificationPlugin
16+
17+
18+
def escape(text):
19+
special = r'_*[]()~`>#+-=|{}.!'
20+
return ''.join('\\' + c if c in special else c for c in text)
21+
22+
23+
def create_certificate_url(name):
24+
return "https://{hostname}/#/certificates/{name}".format(
25+
hostname=current_app.config.get("LEMUR_HOSTNAME"), name=name
26+
)
27+
28+
29+
def create_expiration_attachments(certificates):
30+
attachments = []
31+
for certificate in certificates:
32+
name = escape(certificate["name"])
33+
owner = escape(certificate["owner"])
34+
url = create_certificate_url(certificate["name"])
35+
expires = arrow.get(certificate["validityEnd"]).format("dddd, MMMM D, YYYY")
36+
endpoints = len(certificate["endpoints"])
37+
attachments.append(
38+
f"*Certificate:* [{name}]({url})\n*Owner:* {owner}\n*Expires:* {expires}\n*Endpoints:* {endpoints}\n\n"
39+
)
40+
return attachments
41+
42+
43+
def create_rotation_attachments(certificate):
44+
name = escape(certificate["name"])
45+
owner = escape(certificate["owner"])
46+
url = create_certificate_url(certificate["name"])
47+
expires = arrow.get(certificate["validityEnd"]).format("dddd, MMMM D, YYYY")
48+
endpoints = len(certificate["endpoints"])
49+
return f"*Certificate:* [{name}]({url})\n*Owner:* {owner}\n*Expires:* {expires}\n*Endpoints rotated:* {endpoints}\n\n"
50+
51+
52+
class TelegramNotificationPlugin(ExpirationNotificationPlugin):
53+
title = "Telegram"
54+
slug = "tg-notification"
55+
description = "Sends certificate expiration notifications to Telegram"
56+
version = telegram.VERSION
57+
58+
author = "Fedor S"
59+
author_url = "https://github.com/netflix/lemur"
60+
61+
additional_options = [
62+
{
63+
"name": "chat",
64+
"type": "str",
65+
"required": True,
66+
"validation": check_validation("^[+-]?\d+(\.\d+)?$"),
67+
"helpMessage": "The chat id to send notification to",
68+
},
69+
{
70+
"name": "token",
71+
"type": "str",
72+
"required": True,
73+
"validation": check_validation("^\d+:[A-Za-z0-9_]+$"),
74+
"helpMessage": "Bot API Token",
75+
},
76+
]
77+
78+
def send(self, notification_type, message, targets, options, **kwargs):
79+
"""
80+
A typical check can be performed using the notify command:
81+
`lemur notify`
82+
83+
While we receive a `targets` parameter here, it is unused, plugin currently supports sending to only one chat.
84+
"""
85+
attachments = None
86+
if notification_type == "expiration":
87+
attachments = create_expiration_attachments(message)
88+
89+
elif notification_type == "rotation":
90+
attachments = create_rotation_attachments(message)
91+
92+
if not attachments:
93+
raise Exception("Unable to create message attachments")
94+
95+
data = {
96+
"parse_mode": "MarkdownV2",
97+
"chat_id": self.get_option("chat", options),
98+
"text": "*Lemur {} Notification*\n\n{}".format(notification_type.capitalize(), *attachments),
99+
}
100+
101+
r = requests.post("https://api.telegram.org/bot{}/sendMessage".format(self.get_option("token", options)),
102+
data=data)
103+
104+
if r.status_code not in [200]:
105+
raise Exception(f"Failed to send message. Telegram response: {r.status_code} {data}")
106+
107+
current_app.logger.info(
108+
f"Telegram response: {r.status_code} Message Body: {data}"
109+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from lemur.tests.conftest import * # noqa
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from datetime import timedelta
2+
3+
import arrow
4+
import pytest
5+
from moto import mock_ses
6+
7+
from lemur.certificates.schemas import certificate_notification_output_schema
8+
from lemur.tests.factories import NotificationFactory, CertificateFactory
9+
from lemur.tests.test_messaging import verify_sender_email
10+
11+
12+
def test_formatting(certificate):
13+
from lemur.plugins.lemur_telegram.plugin import create_expiration_attachments
14+
data = [certificate_notification_output_schema.dump(certificate).data]
15+
attachments = create_expiration_attachments(data)
16+
body = attachments[0]
17+
assert certificate.name in body
18+
assert "Owner:" in body
19+
assert "Expires:" in body
20+
assert "Endpoints:" in body
21+
assert "https://" in body
22+
assert certificate.name in body.split("(")[1]
23+
24+
25+
def get_options():
26+
return [
27+
{"name": "interval", "value": 10},
28+
{"name": "unit", "value": "days"},
29+
{"name": "chat", "value": "12345"},
30+
{"name": "token", "value": "999:TESTTOKEN"},
31+
]
32+
33+
34+
def prepare_test():
35+
verify_sender_email()
36+
notification = NotificationFactory(plugin_name="tg-notification")
37+
notification.options = get_options()
38+
now = arrow.utcnow()
39+
in_ten_days = now + timedelta(days=10, hours=1)
40+
certificate = CertificateFactory()
41+
certificate.not_after = in_ten_days
42+
certificate.notifications.append(notification)
43+
44+
45+
@mock_ses()
46+
def test_send_expiration_notification(mocker):
47+
from lemur.notifications.messaging import send_expiration_notifications
48+
# Telegram API request mock
49+
mock_post = mocker.patch(
50+
"lemur.plugins.lemur_telegram.plugin.requests.post"
51+
)
52+
mock_post.return_value.status_code = 200
53+
54+
prepare_test()
55+
56+
sent, failed = send_expiration_notifications([], [])
57+
58+
# Why 3:
59+
# - owner email
60+
# - security email
61+
# - telegram notification
62+
assert (sent, failed) == (3, 0)
63+
64+
# Ensure Telegram was hit
65+
assert mock_post.called
66+
data = mock_post.call_args[1]["data"]
67+
assert "Lemur Expiration Notification" in data["text"]
68+
assert data["chat_id"] == "12345"
69+
70+
71+
@mock_ses()
72+
def test_send_expiration_notification_telegram_disabled(mocker):
73+
from lemur.notifications.messaging import send_expiration_notifications
74+
75+
mocker.patch(
76+
"lemur.plugins.lemur_telegram.plugin.requests.post"
77+
)
78+
79+
prepare_test()
80+
81+
# Disabling telegram means: owner+security emails SHOULD NOT be skipped,
82+
# but messaging rules say: if the *main* plugin (tg-notification) disabled → skip all
83+
assert send_expiration_notifications([], ["tg-notification"]) == (0, 0)
84+
85+
86+
@mock_ses()
87+
def test_send_expiration_notification_email_disabled(mocker):
88+
from lemur.notifications.messaging import send_expiration_notifications
89+
90+
mocker.patch(
91+
"lemur.plugins.lemur_telegram.plugin.requests.post"
92+
)
93+
94+
prepare_test()
95+
96+
# Email disabled → Telegram still fires
97+
# sent, failed =
98+
assert send_expiration_notifications([], ["email-notification"]) == (0, 1)
99+
100+
101+
@mock_ses()
102+
def test_send_expiration_notification_both_disabled(mocker):
103+
from lemur.notifications.messaging import send_expiration_notifications
104+
105+
mocker.patch(
106+
"lemur.plugins.lemur_telegram.plugin.requests.post"
107+
)
108+
109+
prepare_test()
110+
111+
assert send_expiration_notifications([], ["tg-notification", "email-notification"]) == (0, 0)
112+
113+
114+
def test_send_failure_on_bad_status(mocker, certificate):
115+
from lemur.plugins.lemur_telegram.plugin import TelegramNotificationPlugin
116+
117+
plugin = TelegramNotificationPlugin()
118+
119+
mock_post = mocker.patch(
120+
"lemur.plugins.lemur_telegram.plugin.requests.post"
121+
)
122+
mock_post.return_value.status_code = 403
123+
124+
options = {"chat": "12345", "token": "999:BAD"}
125+
126+
cert_data = [certificate_notification_output_schema.dump(certificate).data]
127+
128+
with pytest.raises(Exception):
129+
plugin.send("expiration", cert_data, None, options)
130+
131+
132+
def test_unsupported_notification_type_raises(mocker):
133+
from lemur.plugins.lemur_telegram.plugin import TelegramNotificationPlugin
134+
135+
plugin = TelegramNotificationPlugin()
136+
137+
mocker.patch("lemur.plugins.lemur_telegram.plugin.requests.post")
138+
139+
with pytest.raises(Exception) as exc:
140+
plugin.send("unknown", {}, None, {"chat": "1", "token": "999:X"})
141+
142+
assert "Unable to create message attachments" in str(exc.value)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ entrust_issuer = "lemur.plugins.lemur_entrust.plugin:EntrustIssuerPlugin"
6767
entrust_source = "lemur.plugins.lemur_entrust.plugin:EntrustSourcePlugin"
6868
azure_destination = "lemur.plugins.lemur_azure_dest.plugin:AzureDestinationPlugin"
6969
google_ca_issuer = "lemur.plugins.lemur_google_ca.plugin:GoogleCaIssuerPlugin"
70+
telegram_notification = "lemur.plugins.lemur_telegram.plugin:TelegramNotificationPlugin"
7071

7172
[tool.setuptools]
7273
include-package-data = true

0 commit comments

Comments
 (0)