diff --git a/README.md b/README.md index 3b9fa0b..eb50c8d 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,24 @@ All configuration is via environment variables (see `.env.example`): | `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD | | `DATABASE_PATH` | No | deals.db | Path to SQLite database file | +## Preflight Check + +Run `--check` to validate your configuration and test connectivity to all services before starting the bot: + +```bash +python -m gaming_deals_bot --check +``` + +This verifies: + +- **Matrix** — authentication token is valid and bot has joined the target room +- **CheapShark** — API is reachable +- **Epic Games Store** — API is reachable +- **Frankfurter** — exchange rate API is reachable +- **IsThereAnyDeal** — API key is valid (skipped if not configured) + +The command exits with code 0 on success and 1 on failure, so it works in CI and Docker health-checks. + ## Behavior - **First run**: fetches current deals and records them in the database without posting (avoids spamming the room with existing deals) diff --git a/gaming_deals_bot/__main__.py b/gaming_deals_bot/__main__.py index f006ff3..0e2762d 100644 --- a/gaming_deals_bot/__main__.py +++ b/gaming_deals_bot/__main__.py @@ -9,6 +9,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from .bot import DealsBot from .config import Config +from .preflight import run_preflight def setup_logging(): @@ -23,12 +24,18 @@ async def main(): setup_logging() logger = logging.getLogger("gaming_deals_bot") + check_mode = "--check" in sys.argv + try: config = Config() except ValueError as exc: logger.error("Configuration error: %s", exc) sys.exit(1) + if check_mode: + ok = await run_preflight(config) + sys.exit(0 if ok else 1) + bot = DealsBot(config) await bot.start() diff --git a/gaming_deals_bot/preflight.py b/gaming_deals_bot/preflight.py new file mode 100644 index 0000000..e86b6c9 --- /dev/null +++ b/gaming_deals_bot/preflight.py @@ -0,0 +1,183 @@ +"""Preflight checks — validate configuration and connectivity before running the bot.""" + +import logging +import sys + +import httpx +from nio import AsyncClient, LoginError, RoomResolveAliasError + +from .config import Config +from .cheapshark import BASE_URL as CHEAPSHARK_URL +from .currency import FRANKFURTER_URL, TARGET_CURRENCIES +from .epic import FREE_GAMES_URL +from .itad import BASE_URL as ITAD_URL + +logger = logging.getLogger(__name__) + +# ANSI colours for terminal output +_GREEN = "\033[32m" +_RED = "\033[31m" +_YELLOW = "\033[33m" +_BOLD = "\033[1m" +_RESET = "\033[0m" + + +def _pass(label: str, detail: str = "") -> bool: + suffix = f" — {detail}" if detail else "" + print(f" {_GREEN}✓{_RESET} {label}{suffix}") + return True + + +def _fail(label: str, detail: str = "") -> bool: + suffix = f" — {detail}" if detail else "" + print(f" {_RED}✗{_RESET} {label}{suffix}") + return False + + +def _skip(label: str, detail: str = "") -> bool: + suffix = f" — {detail}" if detail else "" + print(f" {_YELLOW}–{_RESET} {label}{suffix}") + return True # skips don't count as failures + + +async def run_preflight(config: Config) -> bool: + """Run all preflight checks. Returns True if everything critical passes.""" + print(f"\n{_BOLD}Pastel — preflight checks{_RESET}\n") + all_ok = True + + async with httpx.AsyncClient(timeout=15) as http: + # --- Matrix --- + print(f"{_BOLD}Matrix{_RESET}") + all_ok &= await _check_matrix(config) + + # --- CheapShark --- + print(f"\n{_BOLD}CheapShark{_RESET}") + all_ok &= await _check_cheapshark(http) + + # --- Epic Games Store --- + print(f"\n{_BOLD}Epic Games Store{_RESET}") + all_ok &= await _check_epic(http) + + # --- Frankfurter (exchange rates) --- + print(f"\n{_BOLD}Frankfurter (exchange rates){_RESET}") + all_ok &= await _check_frankfurter(http) + + # --- IsThereAnyDeal (optional) --- + print(f"\n{_BOLD}IsThereAnyDeal{_RESET}") + all_ok &= await _check_itad(http, config.itad_api_key) + + # --- Summary --- + print() + if all_ok: + print(f"{_GREEN}{_BOLD}All checks passed.{_RESET} The bot is ready to run.") + else: + print(f"{_RED}{_BOLD}Some checks failed.{_RESET} Review the errors above before starting the bot.") + print() + + return all_ok + + +# --------------------------------------------------------------------------- +# Individual checks +# --------------------------------------------------------------------------- + + +async def _check_matrix(config: Config) -> bool: + """Verify the access token is valid and the bot can see the target room.""" + ok = True + client = AsyncClient(config.matrix_homeserver_url, config.matrix_bot_user_id) + client.access_token = config.matrix_bot_access_token + client.user_id = config.matrix_bot_user_id + + try: + # whoami — validates the token + resp = await client.whoami() + if hasattr(resp, "user_id"): + ok &= _pass("Authentication", f"logged in as {resp.user_id}") + else: + ok &= _fail("Authentication", f"token rejected: {resp}") + await client.close() + return False + + # joined_rooms — check that the bot is in the target room + rooms_resp = await client.joined_rooms() + if hasattr(rooms_resp, "rooms"): + if config.matrix_deals_room_id in rooms_resp.rooms: + ok &= _pass("Room access", f"bot is a member of {config.matrix_deals_room_id}") + else: + ok &= _fail( + "Room access", + f"bot is NOT in {config.matrix_deals_room_id} — invite the bot first", + ) + else: + ok &= _fail("Room access", f"could not list joined rooms: {rooms_resp}") + except Exception as exc: + ok &= _fail("Homeserver connection", str(exc)) + finally: + await client.close() + + return ok + + +async def _check_cheapshark(http: httpx.AsyncClient) -> bool: + """Hit the CheapShark deals endpoint to confirm it's reachable.""" + try: + resp = await http.get(f"{CHEAPSHARK_URL}/deals", params={"pageSize": "1"}) + resp.raise_for_status() + deals = resp.json() + if isinstance(deals, list): + return _pass("API reachable", f"{len(deals)} deal(s) in response") + return _fail("API reachable", "unexpected response format") + except Exception as exc: + return _fail("API reachable", str(exc)) + + +async def _check_epic(http: httpx.AsyncClient) -> bool: + """Hit the Epic free-games endpoint.""" + try: + resp = await http.get(FREE_GAMES_URL, params={"locale": "en-US"}) + resp.raise_for_status() + data = resp.json() + elements = ( + data.get("data", {}) + .get("Catalog", {}) + .get("searchStore", {}) + .get("elements", []) + ) + return _pass("API reachable", f"{len(elements)} game(s) in catalog") + except Exception as exc: + return _fail("API reachable", str(exc)) + + +async def _check_frankfurter(http: httpx.AsyncClient) -> bool: + """Fetch exchange rates to confirm Frankfurter is reachable.""" + try: + symbols = ",".join(TARGET_CURRENCIES) + resp = await http.get(FRANKFURTER_URL, params={"base": "USD", "symbols": symbols}) + resp.raise_for_status() + rates = resp.json().get("rates", {}) + if rates: + parts = [f"{k}: {v}" for k, v in rates.items()] + return _pass("API reachable", ", ".join(parts)) + return _fail("API reachable", "response contained no rates") + except Exception as exc: + return _fail("API reachable", str(exc)) + + +async def _check_itad(http: httpx.AsyncClient, api_key: str) -> bool: + """Verify the ITAD API key works (optional — skipped if no key is set).""" + if not api_key: + return _skip("Skipped", "no ITAD_API_KEY configured (optional)") + + try: + # Use a lightweight endpoint to validate the key + resp = await http.get( + f"{ITAD_URL}/games/overview/v2", + params={"key": api_key, "apps[]": "app/220"}, # Half-Life 2 + ) + if resp.status_code == 401 or resp.status_code == 403: + return _fail("API key", "rejected by ITAD (401/403)") + resp.raise_for_status() + return _pass("API key valid", "ITAD responded successfully") + except Exception as exc: + return _fail("API reachable", str(exc))