add telegram message notification support

This commit is contained in:
Hertz Yang 2023-05-30 00:13:10 +10:00
parent b685ec00a4
commit c7c1d9307b
5 changed files with 104 additions and 43 deletions

View file

@ -20,8 +20,8 @@ status [-v --verbose] [-m --max <number>]
print details of a previously successful upgrade print details of a previously successful upgrade
reset reset
reset the current failure status reset the current failure status
test-smtp test-mail
send an test email to the configured address send test mails to all configured notification destinations
``` ```
There is also a systemd timer for scheduled automatic upgrades. There is also a systemd timer for scheduled automatic upgrades.
@ -47,8 +47,13 @@ Pacroller wipes /var/cache/pacman/pkg after a successful upgrade if the option "
### save pacman output ### save pacman output
Every time an upgrade is performed, the pacman output is stored into /var/log/pacroller. This can be configured via the "save_stdout" keyword. Every time an upgrade is performed, the pacman output is stored into /var/log/pacroller. This can be configured via the "save_stdout" keyword.
## Smtp ## Notification
Configure `/etc/pacroller/smtp.json` to receive an email notification when an upgrade fails. Note that pacroller will not send any email if stdin is a tty (can be overridden by the `--interactive` switch). When configuring your notification system, please note that pacroller will not send any notification if stdin is a tty (can be overridden by the `--interactive` switch).
Notification will be sent through all configured methods when it requires manual inspection. Currently, two notification methods are supported: SMTP and telegram
### SMTP
Configure `/etc/pacroller/smtp.json` to receive email notifications.
### Telegram
Configure `/etc/pacroller/telegram.json` to receive telegram notifications.
## Limitations ## Limitations
- Your favourite package may not be supported, however it's easy to add another set of rules. - Your favourite package may not be supported, however it's easy to add another set of rules.

View file

@ -8,6 +8,7 @@ from typing import Any
CONFIG_DIR = Path('/etc/pacroller') CONFIG_DIR = Path('/etc/pacroller')
CONFIG_FILE = 'config.json' CONFIG_FILE = 'config.json'
CONFIG_FILE_SMTP = 'smtp.json' CONFIG_FILE_SMTP = 'smtp.json'
CONFIG_FILE_TG = 'telegram.json'
F_KNOWN_OUTPUT_OVERRIDE = 'known_output_override.py' F_KNOWN_OUTPUT_OVERRIDE = 'known_output_override.py'
LIB_DIR = Path('/var/lib/pacroller') LIB_DIR = Path('/var/lib/pacroller')
DB_FILE = 'db' DB_FILE = 'db'
@ -34,6 +35,16 @@ if (smtp_cfg := (CONFIG_DIR / CONFIG_FILE_SMTP)).exists():
else: else:
_smtp_config = dict() _smtp_config = dict()
if (tg_cfg := (CONFIG_DIR / CONFIG_FILE_TG)).exists():
try:
_tg_cfg_text = tg_cfg.read_text()
except PermissionError:
_tg_config = dict()
else:
_tg_config: dict = json.loads(_tg_cfg_text)
else:
_tg_config = dict()
def _import_module(fpath: Path) -> Any: def _import_module(fpath: Path) -> Any:
spec = importlib.util.spec_from_file_location(str(fpath).removesuffix('.py').replace('/', '.'), fpath) spec = importlib.util.spec_from_file_location(str(fpath).removesuffix('.py').replace('/', '.'), fpath)
mod = importlib.util.module_from_spec(spec) mod = importlib.util.module_from_spec(spec)
@ -98,3 +109,8 @@ if SMTP_ENABLED:
SMTP_AUTH.pop('password_base64') SMTP_AUTH.pop('password_base64')
assert SMTP_AUTH['password'] assert SMTP_AUTH['password']
SMTP_AUTH = {k:v for k, v in SMTP_AUTH.items() if k in {'username', 'password'}} SMTP_AUTH = {k:v for k, v in SMTP_AUTH.items() if k in {'username', 'password'}}
TG_ENABLED = bool(_tg_config.get('enabled', False))
TG_BOT_TOKEN = _tg_config.get('bot_token', "")
TG_API_HOST = _tg_config.get('api_host', 'api.telegram.org')
TG_RECIPIENT = _tg_config.get('recipient', "")

View file

@ -1,48 +1,82 @@
import json
import smtplib import smtplib
from email.message import EmailMessage from email.message import EmailMessage
from typing import List from typing import List
import logging import logging
import urllib.request, urllib.parse
from platform import node from platform import node
from pacroller.config import NETWORK_RETRY, SMTP_ENABLED, SMTP_SSL, SMTP_HOST, SMTP_PORT, SMTP_FROM, SMTP_TO, SMTP_AUTH from pacroller.config import NETWORK_RETRY, SMTP_ENABLED, SMTP_SSL, SMTP_HOST, SMTP_PORT, SMTP_FROM, SMTP_TO, SMTP_AUTH, TG_ENABLED, TG_BOT_TOKEN, TG_API_HOST, TG_RECIPIENT
logger = logging.getLogger() logger = logging.getLogger()
hostname = node() or "unknown-host" hostname = node() or "unknown-host"
def send_email(text: str, subject: str, mailto: List[str]) -> bool:
for _ in range(NETWORK_RETRY):
try:
smtp_cls = smtplib.SMTP_SSL if SMTP_SSL else smtplib.SMTP
server = smtp_cls(SMTP_HOST, SMTP_PORT)
if SMTP_AUTH:
server.login(SMTP_AUTH["username"], SMTP_AUTH["password"])
msg = EmailMessage()
msg.set_content(f"from pacroller running on {hostname=}:\n\n{text}")
msg['Subject'] = subject
msg['From'] = SMTP_FROM
msg['To'] = ', '.join(mailto)
server.send_message(msg)
server.quit()
except Exception:
logger.exception("error while send_email")
else:
logger.debug(f"smtp sent {text=}")
break
else:
logger.error(f"unable to send email after {NETWORK_RETRY} attempts {text=}")
return False
return True
def send_tg_message(text: str, subject: str, mailto: List[str]) -> bool:
for _ in range(NETWORK_RETRY):
all_succeeded = True
try:
for recipient in mailto:
url = f'https://{TG_API_HOST}/bot{TG_BOT_TOKEN}/sendMessage'
headers = {'User-Agent': 'Mozilla/5.0 (compatible; Pacroller/0.1; +https://github.com/isjerryxiao/pacroller)'}
data = urllib.parse.urlencode({"chat_id": recipient, "text": f"<b>{subject}</b>\n\n<code>{text}</code>", "parse_mode": "HTML"})
req = urllib.request.Request(url, data=data.encode(), headers=headers)
resp = urllib.request.urlopen(req).read().decode('utf-8')
content = json.loads(resp)
if not content["ok"]:
all_succeeded = False
logger.error(f"unable to send telegram message to {recipient}: %s" % content['description'])
except Exception:
logger.exception("error while send_tg_message")
else:
logger.debug(f"telegram message sent {text=}")
break
else:
logger.error(f"unable to send telegram message after {NETWORK_RETRY} attempts {text=}")
return False
return all_succeeded
class MailSender: class MailSender:
def __init__(self) -> None: def __init__(self) -> None:
self.host = SMTP_HOST pass
self.port = SMTP_PORT def send_text_plain(elsf, text: str, subject: str = f"pacroller on {hostname}") -> bool:
self.ssl = SMTP_SSL if_failed = False
self.auth = SMTP_AUTH if SMTP_ENABLED:
self.mailfrom = SMTP_FROM if not send_email(text, subject, SMTP_TO.split()):
self.mailto = SMTP_TO.split() if_failed = True
self.smtp_cls = smtplib.SMTP_SSL if self.ssl else smtplib.SMTP if TG_ENABLED:
def send_text_plain(self, text: str, subject: str = f"pacroller on {hostname}", mailto: List[str] = list()) -> bool: if not send_tg_message(text, subject, TG_RECIPIENT.split()):
if not SMTP_ENABLED: if_failed = True
if not SMTP_ENABLED and not TG_ENABLED:
return None return None
for _ in range(NETWORK_RETRY): return not if_failed
try:
server = self.smtp_cls(self.host, self.port)
if self.auth:
server.login(self.auth["username"], self.auth["password"])
mailto = mailto if mailto else self.mailto
msg = EmailMessage()
msg.set_content(f"from pacroller running on {hostname=}:\n\n{text}")
msg['Subject'] = subject
msg['From'] = self.mailfrom
msg['To'] = ', '.join(mailto)
server.send_message(msg)
server.quit()
except Exception:
logger.exception("error while smtp send_message")
else:
logger.debug(f"smtp sent {text=}")
break
else:
logger.error(f"unable to send email after {NETWORK_RETRY} attempts {text=}")
return False
return True
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(module)s - %(funcName)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(module)s - %(funcName)s - %(levelname)s - %(message)s')
MailSender().send_text_plain("This is a test mail\nIf you see this email, your smtp config is working.") MailSender().send_text_plain("This is a test mail\nIf you see this mail, your notification config is working.")

View file

@ -288,8 +288,8 @@ def main() -> None:
logger.debug(f'needrestart {p.stdout=}') logger.debug(f'needrestart {p.stdout=}')
import argparse import argparse
parser = argparse.ArgumentParser(description='Unattended Upgrades for Arch Linux') parser = argparse.ArgumentParser(description='Unattended Upgrades for Arch Linux')
parser.add_argument('action', choices=['run', 'status', 'reset', 'fail-reset', 'reset-failed', 'test-smtp'], parser.add_argument('action', choices=['run', 'status', 'reset', 'fail-reset', 'reset-failed', 'test-mail'],
help="what to do", metavar="run / status / reset / test-smtp") help="what to do", metavar="run / status / reset / test-mail")
parser.add_argument('-d', '--debug', action='store_true', help='enable debug mode') parser.add_argument('-d', '--debug', action='store_true', help='enable debug mode')
parser.add_argument('-v', '--verbose', action='store_true', help='show verbose report') parser.add_argument('-v', '--verbose', action='store_true', help='show verbose report')
parser.add_argument('-m', '--max', type=int, default=1, help='Number of upgrades to show') parser.add_argument('-m', '--max', type=int, default=1, help='Number of upgrades to show')
@ -361,12 +361,12 @@ def main() -> None:
if PACMAN_SCC: if PACMAN_SCC:
clear_pkg_cache() clear_pkg_cache()
elif args.action == 'test-smtp': elif args.action == 'test-mail':
logger.info('sending test mail...') logger.info('sending test mail...')
if _smtp_result := MailSender().send_text_plain("This is a test mail\nIf you see this email, your smtp config is working."): if _notification_result := MailSender().send_text_plain("This is a test mail\nIf you see this mail, your notification config is working."):
logger.info("success") logger.info("success")
elif _smtp_result is None: elif _notification_result is None:
logger.warning("smtp is disabled") logger.warning("no notification method is enabled")
else: else:
logger.error("fail") logger.error("fail")

View file

@ -0,0 +1,6 @@
{
"enabled": false,
"recipient": "",
"api_host": "api.telegram.org",
"bot_token": ""
}