From 4398abd9e6c9c5e25fb79c5829b10221aa0cc42f Mon Sep 17 00:00:00 2001 From: LockeShor <75901583+LockeShor@users.noreply.github.com> Date: Sun, 1 Mar 2026 00:38:00 -0500 Subject: [PATCH] initial --- .dockerignore | 9 + .env.example | 4 + Dockerfile | 15 ++ README.md | 62 ++++++ __pycache__/watcher.cpython-312.pyc | Bin 0 -> 15558 bytes docker-compose.yml | 15 ++ requirements.txt | 2 + watcher.py | 326 ++++++++++++++++++++++++++++ 8 files changed, 433 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 __pycache__/watcher.cpython-312.pyc create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 watcher.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..57b8460 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.log +.venv/ +.git/ +.gitignore +data/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5db2a9f --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +TELEGRAM_BOT_TOKEN=123456:ABCDEF_your_bot_token +TELEGRAM_CHAT_ID=123456789 +CHECK_INTERVAL_SECONDS=1800 +LOG_LEVEL=INFO diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f8a9361 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY watcher.py ./ + +VOLUME ["/data"] + +CMD ["python", "/app/watcher.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..815fc61 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# truenas-catalog-notify + +Lightweight TrueNAS Apps catalog watcher that sends Telegram alerts when the catalog changes. + +## What it tracks + +The watcher pulls `https://apps.truenas.com/catalog` over plain HTTP (no browser/emulation), parses app cards, and compares these fields against a saved snapshot: + +- app name +- app URL +- train +- added date +- summary text + +On changes, it sends Telegram messages with: + +- added apps (`+`) +- removed apps (`-`) +- updated apps (`~`) and field-level diffs + +## Environment variables + +- `TELEGRAM_BOT_TOKEN` (required for notifications) +- `TELEGRAM_CHAT_ID` (required for notifications) +- `CHECK_INTERVAL_SECONDS` (default: `1800`) +- `STATE_PATH` (default: `/data/catalog_state.json`) +- `CATALOG_URL` (default: `https://apps.truenas.com/catalog`) +- `REQUEST_TIMEOUT_SECONDS` (default: `30`) +- `LOG_LEVEL` (default: `INFO`) + +## Build + +```bash +docker build -t truenas-catalog-notify . +``` + +## Run + +```bash +docker run -d \ + --name truenas-catalog-notify \ + -e TELEGRAM_BOT_TOKEN=123456:ABC... \ + -e TELEGRAM_CHAT_ID=123456789 \ + -e CHECK_INTERVAL_SECONDS=1800 \ + -v truenas-catalog-notify-data:/data \ + --restart unless-stopped \ + truenas-catalog-notify +``` + +## Notes + +- First run only stores the initial snapshot (no notification). +- Notifications start when a later check differs from the saved snapshot. +- If Telegram variables are missing, the watcher logs changes but skips sending messages. +- If you hit permissions on `/data/catalog_state.json`, rebuild and recreate the container with the latest image. + +## Rebuild after changes + +```bash +docker compose build --no-cache +docker compose up -d --force-recreate +``` diff --git a/__pycache__/watcher.cpython-312.pyc b/__pycache__/watcher.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..21e1620053224c2fca49378875e0ab4b6d523d2c GIT binary patch literal 15558 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 None: + logging.basicConfig( + level=getattr(logging, LOG_LEVEL, logging.INFO), + format="%(asctime)s %(levelname)s %(message)s", + ) + + +def normalize_text(value: str) -> str: + return " ".join(value.split()) + + +def compute_hash(parts: List[str]) -> str: + digest = hashlib.sha256("||".join(parts).encode("utf-8")).hexdigest() + return digest + + +def fetch_catalog_html(session: requests.Session) -> str: + response = session.get( + CATALOG_URL, + timeout=REQUEST_TIMEOUT_SECONDS, + headers={"User-Agent": USER_AGENT}, + ) + response.raise_for_status() + return response.text + + +def is_catalog_app_link(href: str) -> bool: + if not href: + return False + parsed = urlparse(href) + path = parsed.path.rstrip("/") + return path.startswith("/catalog/") and path != "/catalog" + + +def parse_catalog(html: str) -> Dict[str, AppSnapshot]: + soup = BeautifulSoup(html, "html.parser") + cards_root = soup.find(id="catalog-cards") + candidates = cards_root.find_all("a", href=True) if cards_root else soup.find_all("a", href=True) + + snapshots: Dict[str, AppSnapshot] = {} + for anchor in candidates: + raw_href = anchor.get("href", "") + full_url = urljoin(CATALOG_URL, raw_href) + if not is_catalog_app_link(urlparse(full_url).path): + continue + + text = normalize_text(anchor.get_text(" ", strip=True)) + if not text: + continue + + name = text.split(" Train:")[0].strip() + train = "" + added = "" + summary = "" + + if " Train:" in text: + remainder = text.split(" Train:", 1)[1].strip() + if " Added:" in remainder: + train_part, after_added = remainder.split(" Added:", 1) + train = train_part.strip() + pieces = after_added.split(" ", 1) + added = pieces[0].strip() + summary = pieces[1].strip() if len(pieces) > 1 else "" + else: + train = remainder + else: + summary = text + + app_hash = compute_hash([name, train, added, summary, full_url]) + snapshots[full_url] = AppSnapshot( + name=name, + url=full_url, + train=train, + added=added, + summary=summary, + content_hash=app_hash, + ) + + return snapshots + + +def load_state(path: str) -> Dict[str, AppSnapshot]: + if not os.path.exists(path): + return {} + + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + + apps = data.get("apps", {}) + loaded: Dict[str, AppSnapshot] = {} + for url, value in apps.items(): + loaded[url] = AppSnapshot( + name=value.get("name", ""), + url=value.get("url", url), + train=value.get("train", ""), + added=value.get("added", ""), + summary=value.get("summary", ""), + content_hash=value.get("content_hash", ""), + ) + return loaded + + +def save_state(path: str, apps: Dict[str, AppSnapshot]) -> None: + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + payload = { + "updated_at": datetime.now(timezone.utc).isoformat(), + "apps": {url: asdict(snapshot) for url, snapshot in sorted(apps.items())}, + } + with open(path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, ensure_ascii=False) + + +def format_field_change(label: str, old: str, new: str) -> str: + old_clean = old if old else "(empty)" + new_clean = new if new else "(empty)" + return f"{label}: '{old_clean}' -> '{new_clean}'" + + +def build_diff_message( + previous: Dict[str, AppSnapshot], + current: Dict[str, AppSnapshot], +) -> Tuple[str, List[str], int]: + prev_urls = set(previous.keys()) + curr_urls = set(current.keys()) + + added_urls = sorted(curr_urls - prev_urls) + removed_urls = sorted(prev_urls - curr_urls) + common_urls = sorted(curr_urls & prev_urls) + + changed_lines: List[str] = [] + updated_count = 0 + for url in common_urls: + old = previous[url] + new = current[url] + if old.content_hash == new.content_hash: + continue + updated_count += 1 + + details: List[str] = [] + if old.name != new.name: + details.append(format_field_change("name", old.name, new.name)) + if old.train != new.train: + details.append(format_field_change("train", old.train, new.train)) + if old.added != new.added: + details.append(format_field_change("added", old.added, new.added)) + if old.summary != new.summary: + details.append(format_field_change("summary", old.summary, new.summary)) + + if not details: + details.append("metadata changed") + + changed_lines.append(f"~ {new.name} ({new.url})") + for detail in details: + changed_lines.append(f" - {detail}") + + header = ( + f"TrueNAS catalog changed at {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}\n" + f"Added: {len(added_urls)} | Removed: {len(removed_urls)} | Updated: {updated_count}" + ) + + lines: List[str] = [] + for url in added_urls: + app = current[url] + lines.append(f"+ {app.name} ({app.url})") + + for url in removed_urls: + app = previous[url] + lines.append(f"- {app.name} ({app.url})") + + lines.extend(changed_lines) + return header, lines, updated_count + + +def split_message(header: str, lines: List[str], max_len: int = MAX_MESSAGE_LEN) -> List[str]: + if not lines: + return [header] + + chunks: List[str] = [] + current_chunk = header + + for line in lines: + candidate = f"{current_chunk}\n{line}" + if len(candidate) <= max_len: + current_chunk = candidate + continue + + chunks.append(current_chunk) + current_chunk = f"{header}\n{line}" + + chunks.append(current_chunk) + return chunks + + +def send_telegram_message(session: requests.Session, text: str) -> None: + if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID: + logging.warning("Telegram token/chat id missing; skipping message") + return + + endpoint = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage" + payload = { + "chat_id": TELEGRAM_CHAT_ID, + "text": text, + "disable_web_page_preview": True, + } + + response = session.post(endpoint, json=payload, timeout=REQUEST_TIMEOUT_SECONDS) + response.raise_for_status() + + +def send_startup_notification(session: requests.Session) -> None: + message = ( + "TrueNAS catalog watcher is running ✅\n" + f"Started: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}\n" + f"Catalog: {CATALOG_URL}\n" + f"Interval: {CHECK_INTERVAL_SECONDS}s" + ) + try: + send_telegram_message(session, message) + except requests.RequestException as exc: + logging.error("Failed to send startup Telegram message: %s", exc) + + +def run_once(session: requests.Session, first_run: bool) -> bool: + previous_state = load_state(STATE_PATH) + html = fetch_catalog_html(session) + current_state = parse_catalog(html) + + if not current_state: + raise RuntimeError("Parsed zero catalog entries; aborting to avoid overwriting state") + + if first_run and not previous_state: + save_state(STATE_PATH, current_state) + logging.info("Initial snapshot saved with %d apps", len(current_state)) + return False + + header, diff_lines, _ = build_diff_message(previous_state, current_state) + changed = bool(diff_lines) + + if changed: + logging.info("Catalog change detected with %d line items", len(diff_lines)) + for message in split_message(header, diff_lines): + send_telegram_message(session, message) + else: + logging.info("No catalog changes detected") + + save_state(STATE_PATH, current_state) + return changed + + +def validate_env() -> None: + if CHECK_INTERVAL_SECONDS < 30: + raise ValueError("CHECK_INTERVAL_SECONDS must be >= 30") + + +def main() -> int: + configure_logging() + + try: + validate_env() + except Exception as exc: + logging.error("Invalid environment: %s", exc) + return 2 + + logging.info("Starting TrueNAS catalog watcher") + logging.info("Catalog URL: %s", CATALOG_URL) + logging.info("State file: %s", STATE_PATH) + logging.info("Interval: %ss", CHECK_INTERVAL_SECONDS) + + session = requests.Session() + send_startup_notification(session) + first_loop = True + + while True: + try: + run_once(session, first_loop) + except requests.RequestException as exc: + logging.error("Network error: %s", exc) + except Exception as exc: + logging.exception("Watcher iteration failed: %s", exc) + + first_loop = False + time.sleep(CHECK_INTERVAL_SECONDS) + + +if __name__ == "__main__": + sys.exit(main())