From c7c1d9307bfcb35250f98b1d5aeab6bdc818df8d Mon Sep 17 00:00:00 2001 From: Hertz Yang Date: Tue, 30 May 2023 00:13:10 +1000 Subject: [PATCH] add telegram message notification support --- README.md | 13 +++-- src/pacroller/config.py | 16 ++++++ src/pacroller/mailer.py | 100 ++++++++++++++++++++++++------------ src/pacroller/main.py | 12 ++--- src/pacroller/telegram.json | 6 +++ 5 files changed, 104 insertions(+), 43 deletions(-) create mode 100644 src/pacroller/telegram.json diff --git a/README.md b/README.md index a946ee9..4a3ce82 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ status [-v --verbose] [-m --max ] print details of a previously successful upgrade reset reset the current failure status -test-smtp - send an test email to the configured address +test-mail + send test mails to all configured notification destinations ``` 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 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 -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). +## Notification +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 - Your favourite package may not be supported, however it's easy to add another set of rules. diff --git a/src/pacroller/config.py b/src/pacroller/config.py index 637856f..30f19ff 100644 --- a/src/pacroller/config.py +++ b/src/pacroller/config.py @@ -8,6 +8,7 @@ from typing import Any CONFIG_DIR = Path('/etc/pacroller') CONFIG_FILE = 'config.json' CONFIG_FILE_SMTP = 'smtp.json' +CONFIG_FILE_TG = 'telegram.json' F_KNOWN_OUTPUT_OVERRIDE = 'known_output_override.py' LIB_DIR = Path('/var/lib/pacroller') DB_FILE = 'db' @@ -34,6 +35,16 @@ if (smtp_cfg := (CONFIG_DIR / CONFIG_FILE_SMTP)).exists(): else: _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: spec = importlib.util.spec_from_file_location(str(fpath).removesuffix('.py').replace('/', '.'), fpath) mod = importlib.util.module_from_spec(spec) @@ -98,3 +109,8 @@ if SMTP_ENABLED: SMTP_AUTH.pop('password_base64') assert SMTP_AUTH['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', "") diff --git a/src/pacroller/mailer.py b/src/pacroller/mailer.py index 1b074a3..9504539 100644 --- a/src/pacroller/mailer.py +++ b/src/pacroller/mailer.py @@ -1,48 +1,82 @@ +import json import smtplib from email.message import EmailMessage from typing import List import logging +import urllib.request, urllib.parse 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() 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"{subject}\n\n{text}", "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: def __init__(self) -> None: - self.host = SMTP_HOST - self.port = SMTP_PORT - self.ssl = SMTP_SSL - self.auth = SMTP_AUTH - self.mailfrom = SMTP_FROM - self.mailto = SMTP_TO.split() - self.smtp_cls = smtplib.SMTP_SSL if self.ssl else smtplib.SMTP - def send_text_plain(self, text: str, subject: str = f"pacroller on {hostname}", mailto: List[str] = list()) -> bool: - if not SMTP_ENABLED: + pass + def send_text_plain(elsf, text: str, subject: str = f"pacroller on {hostname}") -> bool: + if_failed = False + if SMTP_ENABLED: + if not send_email(text, subject, SMTP_TO.split()): + if_failed = True + if TG_ENABLED: + if not send_tg_message(text, subject, TG_RECIPIENT.split()): + if_failed = True + if not SMTP_ENABLED and not TG_ENABLED: return None - for _ in range(NETWORK_RETRY): - 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 + return not if_failed + if __name__ == "__main__": 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.") diff --git a/src/pacroller/main.py b/src/pacroller/main.py index 41af782..228b224 100644 --- a/src/pacroller/main.py +++ b/src/pacroller/main.py @@ -288,8 +288,8 @@ def main() -> None: logger.debug(f'needrestart {p.stdout=}') import argparse parser = argparse.ArgumentParser(description='Unattended Upgrades for Arch Linux') - parser.add_argument('action', choices=['run', 'status', 'reset', 'fail-reset', 'reset-failed', 'test-smtp'], - help="what to do", metavar="run / status / reset / 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-mail") 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('-m', '--max', type=int, default=1, help='Number of upgrades to show') @@ -361,12 +361,12 @@ def main() -> None: if PACMAN_SCC: clear_pkg_cache() - elif args.action == 'test-smtp': + elif args.action == '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") - elif _smtp_result is None: - logger.warning("smtp is disabled") + elif _notification_result is None: + logger.warning("no notification method is enabled") else: logger.error("fail") diff --git a/src/pacroller/telegram.json b/src/pacroller/telegram.json new file mode 100644 index 0000000..f985004 --- /dev/null +++ b/src/pacroller/telegram.json @@ -0,0 +1,6 @@ +{ + "enabled": false, + "recipient": "", + "api_host": "api.telegram.org", + "bot_token": "" +}