Files
Pastel/gaming_deals_bot/preflight.py
Claude 016d2c1973 Add --check preflight to validate config and connectivity
`python -m gaming_deals_bot --check` tests each external dependency
before starting the bot:

- Matrix: verifies access token (whoami) and room membership
- CheapShark: hits the deals endpoint
- Epic Games Store: hits the free-games endpoint
- Frankfurter: fetches exchange rates
- IsThereAnyDeal: validates the API key (skipped if not set)

Exits 0/1 so it works in CI pipelines and Docker health-checks.

https://claude.ai/code/session_01LPpSZFfyh6vdV5HGFWjoQX
2026-02-28 01:52:48 +00:00

184 lines
6.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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))