diff --git a/.env.sample b/.env.sample index 591c9a2..625d17e 100644 --- a/.env.sample +++ b/.env.sample @@ -7,3 +7,8 @@ GITHUB_TOKEN=optional-github-token # FreshRSS login for authenticated About page FRESHRSS_USERNAME=your-username FRESHRSS_PASSWORD=your-password + +# Matrix bot notifications +MATRIX_HOMESERVER=https://matrix.example.net +MATRIX_ROOM_ID=!roomid:example.net +MATRIX_ACCESS_TOKEN=replace-with-access-token diff --git a/BACKLOG.md b/BACKLOG.md index 33123f7..3365e29 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -125,3 +125,12 @@ Acceptance criteria: - The script can log in to FreshRSS using credentials from environment variables. - FreshRSS version is extracted from the About page after authentication. - `.env.sample` documents the FreshRSS credentials required. + +## US-14 - Matrix Notifications + +As a maintainer, I want the script to post update alerts to a Matrix room so that I can track service changes and failures without email. + +Acceptance criteria: +- The script posts a message to a Matrix room when updates are detected or errors occur. +- Matrix credentials (homeserver, room id, access token) are read from `.env`. +- The message includes actionable service names and version details. diff --git a/README.md b/README.md index 8f4938f..505e84e 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,15 @@ Copy `.env.sample` to `.env` and fill required values. Export the variables befo export PAPERLESS_API_TOKEN=... export FRESHRSS_USERNAME=... export FRESHRSS_PASSWORD=... +export MATRIX_HOMESERVER=... +export MATRIX_ROOM_ID=... +export MATRIX_ACCESS_TOKEN=... ``` The script also reads `.env` automatically if present. +The Matrix bot will attempt to join the configured room automatically if it is not already a member. + ## Usage ```bash diff --git a/check_updates.py b/check_updates.py index e4ad30a..c86fdc4 100644 --- a/check_updates.py +++ b/check_updates.py @@ -4,10 +4,11 @@ import json import os import re import sys +import time from dataclasses import dataclass from typing import Any, Dict, Optional from http.cookiejar import CookieJar -from urllib.parse import urlencode +from urllib.parse import urlencode, quote from urllib.request import HTTPCookieProcessor, Request, build_opener, urlopen import bcrypt @@ -483,6 +484,87 @@ def check_service(service: ServiceConfig, timeout: float, user_agent: str) -> Di return result +def build_summary(results: list[Dict[str, Any]]) -> tuple[str, bool]: + updates = [] + errors = [] + for result in results: + comparison = compare_versions(result["current"], result["latest"]) + if comparison == 1: + updates.append(result) + if result["current_error"] or result["latest_error"]: + errors.append(result) + + lines = [] + if updates: + lines.append("Updates available:") + for result in updates: + current = result["current"] or "unknown" + latest = result["latest"] or "unknown" + lines.append(f"- {result['name']}: {current} -> {latest}") + + if errors: + lines.append("Errors:") + for result in errors: + if result["current_error"]: + lines.append(f"- {result['name']}: current version error") + if result["latest_error"]: + lines.append(f"- {result['name']}: latest version error") + + if not lines: + return "All services up to date.", False + return "\n".join(lines), True + + +def send_matrix_message(message: str, timeout: float) -> None: + homeserver = os.getenv("MATRIX_HOMESERVER") + room_id = os.getenv("MATRIX_ROOM_ID") + token = os.getenv("MATRIX_ACCESS_TOKEN") + if not homeserver or not room_id or not token: + return + join_matrix_room(homeserver, room_id, token, timeout) + txn_id = str(int(time.time() * 1000)) + url = f"{homeserver}/_matrix/client/v3/rooms/{room_id}/send/m.room.message/{txn_id}" + payload = json.dumps({ + "msgtype": "m.text", + "body": message, + }).encode("utf-8") + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + try: + fetch_response( + url, + timeout=timeout, + user_agent="check-for-updates", + extra_headers=headers, + data=payload, + method="PUT", + ) + except Exception as exc: + print(f"Matrix notification failed: {exc}", file=sys.stderr) + + +def join_matrix_room(homeserver: str, room_id: str, token: str, timeout: float) -> None: + encoded_room = quote(room_id, safe="") + url = f"{homeserver}/_matrix/client/v3/join/{encoded_room}" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + try: + fetch_response( + url, + timeout=timeout, + user_agent="check-for-updates", + extra_headers=headers, + data=b"{}", + method="POST", + ) + except Exception: + return + + def main() -> int: parser = argparse.ArgumentParser(description="Check for webservice updates") parser.add_argument("--config", default="services.yaml", help="Path to services YAML") @@ -512,7 +594,6 @@ def main() -> int: continue current = result["current"] or "unknown" latest = result["latest"] or "unknown" - upstream = result["upstream_url"] or "unknown" notes = [] if comparison is None and result["current"] and result["latest"]: notes.append("unparseable") @@ -521,7 +602,7 @@ def main() -> int: if result["latest_error"]: notes.append("latest error") suffix = f" [{' '.join(notes)}]" if notes else "" - output_lines.append(f"{result['name']}: {current} -> {latest} ({upstream}){suffix}") + output_lines.append(f"{result['name']}: {current} -> {latest}{suffix}") if output_lines: print("\n".join(output_lines)) @@ -537,6 +618,10 @@ def main() -> int: f"{result['name']}: latest version error: {result['latest_error']}", file=sys.stderr, ) + + summary, should_notify = build_summary(results) + if should_notify: + send_matrix_message(summary, args.timeout) return 0