From d636b9ed0ec78df2a87d608dfd05ad620e343cef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 08:06:24 +0000 Subject: [PATCH] Add --check command for configuration and connectivity validation Adds `python -m app --check` which verifies: - All required environment variables are set - Matrix homeserver is reachable - Bot access token is valid - Bot is a member of the announcements room - Database path is writable Uses color-coded pass/fail output with actionable hints on failure. Configures nio with a 10-second timeout and no retries so checks complete quickly even against unreachable servers. https://claude.ai/code/session_01DuzWyMMXvLMB4VxEwJyV4X --- README.md | 19 +++++- app/__main__.py | 35 ++++++++++ app/check.py | 178 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 app/__main__.py create mode 100644 app/check.py diff --git a/README.md b/README.md index dcbb4fe..34a3681 100644 --- a/README.md +++ b/README.md @@ -30,17 +30,30 @@ cp .env.example .env Edit `.env` with your actual values (see [Environment Variables](#environment-variables) below). -### 2. Run locally +### 2. Verify your configuration ```bash pip install -r requirements.txt +python -m app --check +``` + +This validates that all required environment variables are set, the Matrix homeserver is reachable, the bot token is valid, the bot has joined the announcements room, and the database path is writable. Fix any failing checks before starting the server. + +### 3. Run locally + +```bash uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` -### 3. Run with Docker +### 4. Run with Docker ```bash docker build -t melora . + +# Verify configuration first +docker run --rm --env-file .env melora python -m app --check + +# Start the service docker run -d \ --name melora \ --env-file .env \ @@ -220,7 +233,9 @@ Messages include both plain text and HTML (Matrix-flavored Markdown). New additi Melora/ ├── app/ │ ├── __init__.py +│ ├── __main__.py # CLI entry point (--check flag) │ ├── main.py # FastAPI app, lifespan, startup +│ ├── check.py # Configuration and connectivity checker │ ├── config.py # Environment variable loading │ ├── database.py # Async SQLite for thread roots and dedup │ ├── matrix.py # matrix-nio posting and thread management diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..eb7e387 --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,35 @@ +"""CLI entry point for Melora. + +Usage: + python -m app --check Verify configuration and connectivity + python -m app Start the server +""" + +import argparse +import sys + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="melora", + description="Melora — *arr media arrival webhook bridge for Matrix", + ) + parser.add_argument( + "--check", + action="store_true", + help="verify configuration and connectivity, then exit", + ) + args = parser.parse_args() + + if args.check: + from app.check import run_checks + + raise SystemExit(run_checks()) + + import uvicorn + + uvicorn.run("app.main:app", host="0.0.0.0", port=8000) + + +if __name__ == "__main__": + main() diff --git a/app/check.py b/app/check.py new file mode 100644 index 0000000..8f164ab --- /dev/null +++ b/app/check.py @@ -0,0 +1,178 @@ +"""Melora configuration checker. + +Validates environment variables, Matrix connectivity, and database access. +Run with: python -m app --check +""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path + +from dotenv import load_dotenv + +_PASS = "\033[32m✓\033[0m" +_FAIL = "\033[31m✗\033[0m" +_failures = 0 + + +def _ok(msg: str) -> None: + print(f" [{_PASS}] {msg}") + + +def _fail(msg: str) -> None: + global _failures + _failures += 1 + print(f" [{_FAIL}] {msg}") + + +def _hint(msg: str) -> None: + print(f" {msg}") + + +def _section(title: str) -> None: + print(f"\n{title}") + print("─" * len(title)) + + +# ── Environment variables ──────────────────────────────────────────── + +def _check_env() -> dict[str, str]: + """Validate required env vars and return their values.""" + _section("Environment variables") + + required = [ + "MATRIX_HOMESERVER_URL", + "MATRIX_BOT_USER_ID", + "MATRIX_BOT_ACCESS_TOKEN", + "MATRIX_ARRIVALS_ROOM_ID", + "WEBHOOK_SECRET", + ] + vals: dict[str, str] = {} + for var in required: + raw = os.environ.get(var, "").strip() + if raw: + if "TOKEN" in var or "SECRET" in var: + shown = raw[:4] + "····" + raw[-4:] if len(raw) > 12 else "····" + else: + shown = raw + _ok(f"{var} = {shown}") + vals[var] = raw + else: + _fail(f"{var} is missing or empty") + + db = os.environ.get("DATABASE_PATH", "").strip() + if db: + _ok(f"DATABASE_PATH = {db}") + vals["DATABASE_PATH"] = db + else: + _ok("DATABASE_PATH = melora.db (default)") + vals["DATABASE_PATH"] = "melora.db" + + return vals + + +# ── Matrix connectivity ────────────────────────────────────────────── + +async def _check_matrix(vals: dict[str, str]) -> None: + """Verify homeserver reachability, token validity, and room membership.""" + _section("Matrix connectivity") + + hs = vals.get("MATRIX_HOMESERVER_URL") + uid = vals.get("MATRIX_BOT_USER_ID") + token = vals.get("MATRIX_BOT_ACCESS_TOKEN") + room = vals.get("MATRIX_ARRIVALS_ROOM_ID") + + if not all([hs, uid, token, room]): + _fail("Skipping — required environment variables are missing") + return + + from nio import AsyncClient, AsyncClientConfig, JoinedRoomsResponse + + cfg = AsyncClientConfig(max_timeouts=0, request_timeout=10) + client = AsyncClient(hs, uid, config=cfg) + client.access_token = token + + try: + resp = await client.joined_rooms() + except Exception as exc: + _fail(f"Cannot reach homeserver at {hs}") + _hint(f"Error: {exc}") + _hint("Check the URL, port, and that the server is running.") + return + finally: + await client.close() + + if isinstance(resp, JoinedRoomsResponse): + _ok(f"Homeserver is reachable ({hs})") + _ok(f"Bot access token is valid ({uid})") + if room in resp.rooms: + _ok(f"Bot is a member of room {room}") + else: + _fail(f"Bot is NOT a member of room {room}") + _hint("Invite the bot to the room, or verify MATRIX_ARRIVALS_ROOM_ID.") + else: + status = getattr(resp, "status_code", None) + message = getattr(resp, "message", str(resp)) + _ok(f"Homeserver is reachable ({hs})") + if str(status) in ("401", "M_UNKNOWN_TOKEN"): + _fail("Bot access token is invalid (401 Unauthorized)") + _hint("Generate a new token or check MATRIX_BOT_ACCESS_TOKEN.") + else: + _fail(f"Matrix API error — {message}") + + +# ── Database ───────────────────────────────────────────────────────── + +async def _check_database(vals: dict[str, str]) -> None: + """Verify the database path is usable.""" + _section("Database") + + db_path = Path(vals.get("DATABASE_PATH", "melora.db")) + parent = db_path.parent.resolve() + + if db_path.exists(): + import aiosqlite + + try: + async with aiosqlite.connect(str(db_path)) as conn: + cur = await conn.execute("PRAGMA integrity_check") + row = await cur.fetchone() + if row and row[0] == "ok": + _ok(f"Database exists and passes integrity check ({db_path})") + else: + _fail(f"Database integrity check failed ({db_path})") + except Exception as exc: + _fail(f"Cannot open database at {db_path} — {exc}") + elif parent.exists() and os.access(str(parent), os.W_OK): + _ok(f"Database will be created at {db_path} (directory is writable)") + else: + _fail(f"Cannot write to database directory ({parent})") + + +# ── Entry point ────────────────────────────────────────────────────── + +async def _run_async(vals: dict[str, str]) -> None: + await _check_matrix(vals) + await _check_database(vals) + + +def run_checks() -> int: + """Run all configuration checks. Returns 0 on success, 1 on failure.""" + global _failures + _failures = 0 + + print("\nMelora configuration check") + print("══════════════════════════") + + load_dotenv() + vals = _check_env() + asyncio.run(_run_async(vals)) + + print() + if _failures: + print(f" {_failures} check(s) failed.\n") + return 1 + print(" All checks passed!\n") + return 0