Merge pull request #9 from hertzyang/master

add telegram message notification support
This commit is contained in:
JerryXiao 2023-05-29 22:33:11 +08:00 committed by GitHub
commit a321dd5284
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
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,40 +1,32 @@
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"
class MailSender:
def __init__(self) -> None: def send_email(text: str, subject: str, mailto: List[str]) -> bool:
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:
return None
for _ in range(NETWORK_RETRY): for _ in range(NETWORK_RETRY):
try: try:
server = self.smtp_cls(self.host, self.port) smtp_cls = smtplib.SMTP_SSL if SMTP_SSL else smtplib.SMTP
if self.auth: server = smtp_cls(SMTP_HOST, SMTP_PORT)
server.login(self.auth["username"], self.auth["password"]) if SMTP_AUTH:
mailto = mailto if mailto else self.mailto server.login(SMTP_AUTH["username"], SMTP_AUTH["password"])
msg = EmailMessage() msg = EmailMessage()
msg.set_content(f"from pacroller running on {hostname=}:\n\n{text}") msg.set_content(f"from pacroller running on {hostname=}:\n\n{text}")
msg['Subject'] = subject msg['Subject'] = subject
msg['From'] = self.mailfrom msg['From'] = SMTP_FROM
msg['To'] = ', '.join(mailto) msg['To'] = ', '.join(mailto)
server.send_message(msg) server.send_message(msg)
server.quit() server.quit()
except Exception: except Exception:
logger.exception("error while smtp send_message") logger.exception("error while send_email")
else: else:
logger.debug(f"smtp sent {text=}") logger.debug(f"smtp sent {text=}")
break break
@ -43,6 +35,48 @@ class MailSender:
return False return False
return True 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:
def __init__(self) -> None:
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
return not if_failed
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": ""
}