Merge pull request #4 from prosolis/claude/gaming-deals-matrix-bot-ONjS7
Add --check preflight to validate config and connectivity
This commit is contained in:
18
README.md
18
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 |
|
| `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD |
|
||||||
| `DATABASE_PATH` | No | deals.db | Path to SQLite database file |
|
| `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
|
## Behavior
|
||||||
|
|
||||||
- **First run**: fetches current deals and records them in the database without posting (avoids spamming the room with existing deals)
|
- **First run**: fetches current deals and records them in the database without posting (avoids spamming the room with existing deals)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
|
|
||||||
from .bot import DealsBot
|
from .bot import DealsBot
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
from .preflight import run_preflight
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging():
|
||||||
@@ -23,12 +24,18 @@ async def main():
|
|||||||
setup_logging()
|
setup_logging()
|
||||||
logger = logging.getLogger("gaming_deals_bot")
|
logger = logging.getLogger("gaming_deals_bot")
|
||||||
|
|
||||||
|
check_mode = "--check" in sys.argv
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = Config()
|
config = Config()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.error("Configuration error: %s", exc)
|
logger.error("Configuration error: %s", exc)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
if check_mode:
|
||||||
|
ok = await run_preflight(config)
|
||||||
|
sys.exit(0 if ok else 1)
|
||||||
|
|
||||||
bot = DealsBot(config)
|
bot = DealsBot(config)
|
||||||
await bot.start()
|
await bot.start()
|
||||||
|
|
||||||
|
|||||||
183
gaming_deals_bot/preflight.py
Normal file
183
gaming_deals_bot/preflight.py
Normal file
@@ -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))
|
||||||
Reference in New Issue
Block a user