diff --git a/.gitignore b/.gitignore
index 3ca8039..87d9b6f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,3 @@
-__pycache__/
-*.pyc
.env
*.db
*.db.bak
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 713bcc0..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,10 +0,0 @@
-FROM python:3.12-slim
-
-WORKDIR /app
-
-COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
-
-COPY gaming_deals_bot/ gaming_deals_bot/
-
-CMD ["python", "-m", "gaming_deals_bot"]
diff --git a/gaming_deals_bot/__init__.py b/gaming_deals_bot/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/gaming_deals_bot/__main__.py b/gaming_deals_bot/__main__.py
deleted file mode 100644
index 17974ac..0000000
--- a/gaming_deals_bot/__main__.py
+++ /dev/null
@@ -1,113 +0,0 @@
-"""Entry point — sets up logging, scheduler, and runs the bot."""
-
-import asyncio
-import logging
-import signal
-import sys
-
-from apscheduler.schedulers.asyncio import AsyncIOScheduler
-from dotenv import load_dotenv
-
-from .bot import DealsBot
-from .config import Config
-from .preflight import run_preflight
-
-
-def setup_logging(*, debug: bool = False):
- logging.basicConfig(
- level=logging.DEBUG if debug else logging.INFO,
- format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
- stream=sys.stdout,
- )
- if not debug:
- # Keep noisy third-party loggers quiet even in debug mode
- logging.getLogger("httpx").setLevel(logging.WARNING)
- logging.getLogger("httpcore").setLevel(logging.WARNING)
-
-
-async def main():
- load_dotenv()
- debug_mode = "--debug" in sys.argv
- setup_logging(debug=debug_mode)
- 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()
-
- scheduler = AsyncIOScheduler()
-
- # CheapShark: every 2 hours (if enabled)
- if "cheapshark" in config.deal_sources:
- scheduler.add_job(
- bot.check_cheapshark,
- "interval",
- hours=2,
- id="cheapshark",
- name="CheapShark deals check",
- )
-
- # ITAD deals: every 2 hours (if enabled)
- if "itad" in config.deal_sources:
- scheduler.add_job(
- bot.check_itad_deals,
- "interval",
- hours=2,
- id="itad_deals",
- name="ITAD deals check",
- )
-
- # Epic free games: once daily
- scheduler.add_job(
- bot.check_epic_free_games,
- "interval",
- hours=24,
- id="epic_free",
- name="Epic free games check",
- )
-
- scheduler.start()
- logger.info(
- "Bot started — scheduler running (deal sources: %s)",
- ", ".join(config.deal_sources),
- )
-
- await bot.send_intro()
-
- # Run initial checks immediately (after first-run population is done)
- await bot.check_cheapshark()
- await bot.check_itad_deals()
- await bot.check_epic_free_games()
-
- # Keep running until signaled to stop
- stop_event = asyncio.Event()
-
- def handle_signal():
- logger.info("Shutdown signal received")
- stop_event.set()
-
- loop = asyncio.get_running_loop()
- for sig in (signal.SIGINT, signal.SIGTERM):
- loop.add_signal_handler(sig, handle_signal)
-
- await stop_event.wait()
-
- logger.info("Shutting down...")
- scheduler.shutdown(wait=False)
- await bot.stop()
- logger.info("Bot stopped")
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/gaming_deals_bot/bot.py b/gaming_deals_bot/bot.py
deleted file mode 100644
index 91aed1b..0000000
--- a/gaming_deals_bot/bot.py
+++ /dev/null
@@ -1,276 +0,0 @@
-"""Main bot orchestration — ties together API clients, database, and Matrix posting."""
-
-import asyncio
-import logging
-
-import httpx
-
-from .cheapshark import CheapSharkDeal, fetch_deals as fetch_cheapshark_deals
-from .config import Config
-from .currency import configure as configure_currency, refresh_rates
-from .database import Database
-from .epic import EpicFreeGame, fetch_free_games
-from .formatter import format_deal, format_epic_free, format_epic_upcoming, format_itad_deal
-from .itad import check_single_historical_low
-from .itad_deals import ITADDeal, fetch_deals as fetch_itad_deals
-from .matrix_client import MatrixDealsClient
-from .threads import ThreadCategory, format_thread_root, itad_type_to_category
-
-logger = logging.getLogger(__name__)
-
-
-class DealsBot:
- def __init__(self, config: Config):
- self.config = config
- self.db = Database(config.database_path)
- self.matrix = MatrixDealsClient(
- homeserver_url=config.matrix_homeserver_url,
- user_id=config.matrix_bot_user_id,
- access_token=config.matrix_bot_access_token,
- room_id=config.matrix_deals_room_id,
- )
- self._http = httpx.AsyncClient(timeout=30)
- self._first_run_done = False
-
- async def start(self):
- """Initialize database, fetch exchange rates, and run first-run population."""
- await self.db.connect()
-
- # Configure currency display before fetching rates
- configure_currency(self.config.default_currency, self.config.extra_currencies)
-
- # Pre-fetch exchange rates so the first deal post has conversions
- await refresh_rates(self._http)
-
- first_run = await self.db.get_config("first_run_done")
- if first_run != "true":
- logger.info("First run detected — populating database without posting")
- await self._populate_initial_state()
- await self.db.set_config("first_run_done", "true")
- self._first_run_done = True
- else:
- self._first_run_done = True
-
- # Start presence heartbeat so the bot shows as "online"
- await self.matrix.start_presence_heartbeat()
-
- async def stop(self):
- """Clean shutdown."""
- await self._http.aclose()
- await self.matrix.close()
- await self.db.close()
-
- async def _populate_initial_state(self):
- """Fetch current deals and record them without posting (avoids spam on first run).
-
- Epic free games are intentionally *not* recorded here so that the
- subsequent ``check_epic_free_games`` call will post them. There are
- only a handful at any time and they are time-limited, so users should
- see them immediately rather than waiting for the next cycle.
- """
- total = 0
-
- if "cheapshark" in self.config.deal_sources:
- deals = await fetch_cheapshark_deals(
- self._http,
- max_price=self.config.cheapshark_max_price,
- min_rating=self.config.cheapshark_min_rating,
- min_discount=self.config.cheapshark_min_discount,
- )
- for deal in deals:
- await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
- total += len(deals)
-
- if "itad" in self.config.deal_sources and self.config.itad_api_key:
- itad_deals = await fetch_itad_deals(
- self._http,
- self.config.itad_api_key,
- countries=self.config.itad_countries,
- max_price=self.config.itad_max_price,
- min_discount=self.config.itad_min_discount,
- limit=self.config.itad_deals_limit,
- )
- for deal in itad_deals:
- await self.db.mark_posted(deal.dedup_id, "itad", deal.title)
- total += len(itad_deals)
-
- logger.info("First run: recorded %d existing deals", total)
-
- async def send_intro(self):
- """Send an intro message to the Matrix room if configured."""
- if not self.config.send_intro_message:
- return
- await self.matrix.send_notice("The deals must flow.")
-
- # ------------------------------------------------------------------
- # Thread helpers
- # ------------------------------------------------------------------
-
- async def _get_or_create_thread(self, category: ThreadCategory) -> str | None:
- """Return the event ID of the thread root for *category*, creating it if needed."""
- event_id = await self.db.get_thread_root(category.value)
- if event_id:
- return event_id
-
- plain_text, html = format_thread_root(category)
- event_id = await self.matrix.create_thread_root(plain_text, html)
- if event_id:
- await self.db.set_thread_root(category.value, event_id)
- logger.info("Created thread root for %s: %s", category.value, event_id)
- return event_id
-
- async def _send_to_thread_or_room(
- self, plain_text: str, html: str, category: ThreadCategory
- ) -> bool:
- """Send a message — into a thread if threads are enabled, otherwise to the room."""
- if not self.config.matrix_use_threads:
- return await self.matrix.send_deal(plain_text, html)
-
- thread_root_id = await self._get_or_create_thread(category)
- if not thread_root_id:
- logger.warning(
- "Could not obtain thread root for %s — falling back to room", category.value
- )
- return await self.matrix.send_deal(plain_text, html)
-
- return await self.matrix.send_deal_in_thread(plain_text, html, thread_root_id)
-
- # ------------------------------------------------------------------
- # CheapShark
- # ------------------------------------------------------------------
-
- async def check_cheapshark(self):
- """Poll CheapShark for deals and post new ones."""
- if not self._first_run_done:
- return
- if "cheapshark" not in self.config.deal_sources:
- return
-
- logger.info("Checking CheapShark for deals...")
- deals = await fetch_cheapshark_deals(
- self._http,
- max_price=self.config.cheapshark_max_price,
- min_rating=self.config.cheapshark_min_rating,
- min_discount=self.config.cheapshark_min_discount,
- )
-
- for deal in deals:
- await self._process_deal(deal)
-
- # Prune old records
- await self.db.prune_old(days=30)
-
- async def _process_deal(self, deal: CheapSharkDeal):
- """Check dedup, check historical low, format, and post a single deal."""
- if await self.db.has_been_posted(deal.dedup_id):
- return
-
- # Check if this is a historical low via ITAD
- is_historical_low = False
- if self.config.itad_api_key and deal.steam_app_id:
- is_historical_low = await check_single_historical_low(
- self._http,
- self.config.itad_api_key,
- deal.steam_app_id,
- )
-
- plain_text, html = format_deal(deal, is_historical_low)
- success = await self._send_to_thread_or_room(
- plain_text, html, ThreadCategory.GAME_DEALS
- )
-
- if success:
- await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
- logger.info("Posted deal: %s", deal.title)
- else:
- logger.warning("Failed to post deal: %s — will retry next cycle", deal.title)
-
- # ------------------------------------------------------------------
- # IsThereAnyDeal
- # ------------------------------------------------------------------
-
- async def check_itad_deals(self):
- """Poll IsThereAnyDeal for deals and post new ones."""
- if not self._first_run_done:
- return
- if "itad" not in self.config.deal_sources:
- return
- if not self.config.itad_api_key:
- logger.warning("ITAD deal source enabled but ITAD_API_KEY is not set")
- return
-
- logger.info("Checking IsThereAnyDeal for deals...")
- deals = await fetch_itad_deals(
- self._http,
- self.config.itad_api_key,
- countries=self.config.itad_countries,
- max_price=self.config.itad_max_price,
- min_discount=self.config.itad_min_discount,
- limit=self.config.itad_deals_limit,
- )
-
- for deal in deals:
- await self._process_itad_deal(deal)
-
- await self.db.prune_old(days=30)
-
- async def _process_itad_deal(self, deal: ITADDeal):
- """Check dedup, format, and post a single ITAD deal."""
- if await self.db.has_been_posted(deal.dedup_id):
- return
-
- category = itad_type_to_category(deal.deal_type)
-
- # When threads are off, preserve original behaviour: skip non-game content
- if not self.config.matrix_use_threads and category == ThreadCategory.NON_GAME_DEALS:
- logger.debug("Skipping non-game ITAD deal (threads disabled): %s", deal.title)
- return
-
- plain_text, html = format_itad_deal(deal)
- success = await self._send_to_thread_or_room(plain_text, html, category)
-
- if success:
- await self.db.mark_posted(deal.dedup_id, "itad", deal.title)
- logger.info("Posted ITAD deal: %s [%s]", deal.title, category.value)
- else:
- logger.warning("Failed to post ITAD deal: %s — will retry next cycle", deal.title)
-
- # ------------------------------------------------------------------
- # Epic Games Store
- # ------------------------------------------------------------------
-
- async def check_epic_free_games(self):
- """Poll Epic Games Store for free games and post new ones."""
- if not self._first_run_done:
- return
-
- logger.info("Checking Epic Games Store for free games...")
- current_free, upcoming = await fetch_free_games(self._http)
-
- for game in current_free:
- await self._process_epic_game(game, is_upcoming=False)
-
- for game in upcoming:
- await self._process_epic_game(game, is_upcoming=True)
-
- async def _process_epic_game(self, game: EpicFreeGame, *, is_upcoming: bool):
- """Check dedup, format, and post a single Epic free game."""
- if await self.db.has_been_posted(game.dedup_id):
- return
-
- if is_upcoming:
- plain_text, html = format_epic_upcoming(game)
- else:
- plain_text, html = format_epic_free(game)
-
- success = await self._send_to_thread_or_room(
- plain_text, html, ThreadCategory.EPIC_FREE
- )
-
- if success:
- await self.db.mark_posted(game.dedup_id, "epic", game.title)
- logger.info("Posted Epic game: %s (upcoming=%s)", game.title, is_upcoming)
- else:
- logger.warning(
- "Failed to post Epic game: %s — will retry next cycle", game.title
- )
diff --git a/gaming_deals_bot/cheapshark.py b/gaming_deals_bot/cheapshark.py
deleted file mode 100644
index 1879175..0000000
--- a/gaming_deals_bot/cheapshark.py
+++ /dev/null
@@ -1,108 +0,0 @@
-import logging
-from dataclasses import dataclass
-
-import httpx
-
-logger = logging.getLogger(__name__)
-
-BASE_URL = "https://www.cheapshark.com/api/1.0"
-
-# CheapShark store IDs for PC/digital storefronts
-STORE_IDS = {
- "1": "Steam",
- "7": "GOG",
- "11": "Humble Store",
- "23": "GreenManGaming",
-}
-
-
-@dataclass
-class CheapSharkDeal:
- deal_id: str
- game_id: str
- title: str
- sale_price: str
- normal_price: str
- savings: float # percentage off
- deal_rating: float
- store_id: str
- last_change: int # unix timestamp
- steam_app_id: str | None
-
- @property
- def store_name(self) -> str:
- return STORE_IDS.get(self.store_id, f"Store {self.store_id}")
-
- @property
- def deal_url(self) -> str:
- return f"https://www.cheapshark.com/redirect?dealID={self.deal_id}"
-
- @property
- def dedup_id(self) -> str:
- return f"cheapshark-{self.game_id}-{self.last_change}"
-
-
-async def fetch_deals(
- client: httpx.AsyncClient,
- *,
- max_price: float = 20,
- min_rating: float = 8.0,
- min_discount: float = 50,
- page_size: int = 10,
-) -> list[CheapSharkDeal]:
- """Fetch top deals from CheapShark across configured stores."""
- store_ids = ",".join(STORE_IDS.keys())
- params = {
- "storeID": store_ids,
- "upperPrice": str(int(max_price)),
- "sortBy": "recent",
- "desc": "1",
- "pageSize": str(page_size),
- }
-
- try:
- resp = await client.get(f"{BASE_URL}/deals", params=params)
- resp.raise_for_status()
- raw_deals = resp.json()
- except (httpx.HTTPError, ValueError) as exc:
- logger.error("CheapShark API error: %s", exc)
- return []
-
- logger.info(
- "CheapShark returned %d raw deals (before filtering)", len(raw_deals),
- )
-
- deals = []
- for d in raw_deals:
- savings = float(d.get("savings", 0))
- rating = float(d.get("dealRating", 0))
- title = d.get("title", "?")
-
- if savings < min_discount:
- logger.debug("Filtered out %s: savings %.1f%% < %.1f%%", title, savings, min_discount)
- continue
- if rating > 0 and rating < min_rating:
- logger.debug("Filtered out %s: rating %.1f < %.1f", title, rating, min_rating)
- continue
-
- steam_app_id = d.get("steamAppID") or None
- if steam_app_id == "0":
- steam_app_id = None
-
- deals.append(
- CheapSharkDeal(
- deal_id=d["dealID"],
- game_id=d["gameID"],
- title=d["title"],
- sale_price=d["salePrice"],
- normal_price=d["normalPrice"],
- savings=savings,
- deal_rating=rating,
- store_id=d["storeID"],
- last_change=int(d.get("lastChange", 0)),
- steam_app_id=steam_app_id,
- )
- )
-
- logger.info("CheapShark returned %d deals after filtering", len(deals))
- return deals
diff --git a/gaming_deals_bot/config.py b/gaming_deals_bot/config.py
deleted file mode 100644
index c9d1f27..0000000
--- a/gaming_deals_bot/config.py
+++ /dev/null
@@ -1,83 +0,0 @@
-import os
-
-
-class Config:
- """Bot configuration loaded from environment variables."""
-
- def __init__(self):
- # Matrix settings
- self.matrix_homeserver_url = self._require("MATRIX_HOMESERVER_URL")
- self.matrix_bot_user_id = self._require("MATRIX_BOT_USER_ID")
- self.matrix_bot_access_token = self._require("MATRIX_BOT_ACCESS_TOKEN")
- self.matrix_deals_room_id = self._require("MATRIX_DEALS_ROOM_ID")
-
- # ITAD API key (optional — historical low checks disabled without it)
- self.itad_api_key = os.environ.get("ITAD_API_KEY", "")
-
- # Deal sources: comma-separated list of "cheapshark", "itad" (default: cheapshark)
- raw_sources = os.environ.get("DEAL_SOURCES", "cheapshark")
- self.deal_sources: list[str] = [
- s.strip().lower() for s in raw_sources.split(",") if s.strip()
- ]
-
- # ITAD countries: comma-separated ISO 3166-1 alpha-2 country codes
- # Deals are fetched for each country and merged (first country has priority
- # when the same game appears in multiple regions).
- raw_countries = os.environ.get("ITAD_COUNTRIES", "US")
- self.itad_countries: list[str] = [
- c.strip().upper() for c in raw_countries.split(",") if c.strip()
- ]
-
- # Currency display
- self.default_currency = os.environ.get("DEFAULT_CURRENCY", "USD").upper()
- raw_extra = os.environ.get("EXTRA_CURRENCIES", "CAD,EUR,GBP")
- self.extra_currencies: list[str] = [
- c.strip().upper() for c in raw_extra.split(",") if c.strip()
- ]
-
- # Shared filter fallbacks (support legacy env vars)
- _max_price = os.environ.get(
- "MAX_PRICE", os.environ.get("MAX_PRICE_USD", "20")
- )
- _min_discount = os.environ.get("MIN_DISCOUNT_PERCENT", "50")
- _min_rating = os.environ.get("MIN_DEAL_RATING", "8.0")
-
- # CheapShark-specific filtering (falls back to shared values)
- self.cheapshark_min_discount = int(
- os.environ.get("CHEAPSHARK_MIN_DISCOUNT", _min_discount)
- )
- self.cheapshark_min_rating = float(
- os.environ.get("CHEAPSHARK_MIN_RATING", _min_rating)
- )
- self.cheapshark_max_price = float(
- os.environ.get("CHEAPSHARK_MAX_PRICE", _max_price)
- )
-
- # ITAD-specific filtering (falls back to shared values)
- self.itad_min_discount = int(
- os.environ.get("ITAD_MIN_DISCOUNT", _min_discount)
- )
- self.itad_max_price = float(
- os.environ.get("ITAD_MAX_PRICE", _max_price)
- )
- self.itad_deals_limit = int(os.environ.get("ITAD_DEALS_LIMIT", "200"))
-
- # Matrix threads — post deals into per-category threads
- self.matrix_use_threads = os.environ.get(
- "MATRIX_USE_THREADS", "false"
- ).lower() in ("true", "1", "yes")
-
- # Intro message on startup
- self.send_intro_message = os.environ.get(
- "SEND_INTRO_MESSAGE", "false"
- ).lower() in ("true", "1", "yes")
-
- # Database
- self.database_path = os.environ.get("DATABASE_PATH", "deals.db")
-
- @staticmethod
- def _require(name: str) -> str:
- value = os.environ.get(name)
- if not value:
- raise ValueError(f"Required environment variable {name} is not set")
- return value
diff --git a/gaming_deals_bot/currency.py b/gaming_deals_bot/currency.py
deleted file mode 100644
index f6babbf..0000000
--- a/gaming_deals_bot/currency.py
+++ /dev/null
@@ -1,189 +0,0 @@
-"""Currency conversion using the Frankfurter API (ECB rates, no API key required).
-
-Rates are cached in memory and refreshed at most twice per day.
-Call ``configure()`` before ``refresh_rates()`` to set the display currencies.
-"""
-
-import logging
-import time
-
-import httpx
-
-logger = logging.getLogger(__name__)
-
-FRANKFURTER_URL = "https://api.frankfurter.dev/v1/latest"
-CACHE_TTL_SECONDS = 43200 # 12 hours — be nice to the free service
-
-# Module-level state -------------------------------------------------------
-_rates: dict[str, float] = {}
-_last_fetched: float = 0.0
-
-# Display preferences (set via configure())
-_default_currency: str = "USD"
-_extra_currencies: list[str] = ["CAD", "EUR", "GBP"]
-
-# Currency display symbols
-SYMBOLS: dict[str, str] = {
- "USD": "$",
- "CAD": "C$",
- "EUR": "€",
- "GBP": "£",
- "AUD": "A$",
- "BRL": "R$",
- "CHF": "CHF ",
- "CNY": "¥",
- "CZK": "Kč",
- "DKK": "kr",
- "HUF": "Ft",
- "INR": "₹",
- "JPY": "¥",
- "KRW": "₩",
- "MXN": "MX$",
- "NOK": "kr",
- "NZD": "NZ$",
- "PLN": "zł",
- "SEK": "kr",
- "TRY": "₺",
- "ZAR": "R",
-}
-
-
-# ---------------------------------------------------------------------------
-# Configuration
-# ---------------------------------------------------------------------------
-
-def configure(default_currency: str = "USD", extra_currencies: list[str] | None = None) -> None:
- """Set the primary display currency and additional currencies.
-
- Must be called **before** ``refresh_rates()`` so the correct symbols are
- requested from Frankfurter.
- """
- global _default_currency, _extra_currencies
- _default_currency = default_currency.upper()
- if extra_currencies is not None:
- _extra_currencies = [c.upper() for c in extra_currencies]
-
-
-def _all_target_currencies() -> list[str]:
- """Return every non-USD currency we need exchange rates for."""
- currencies: set[str] = set()
- if _default_currency != "USD":
- currencies.add(_default_currency)
- for c in _extra_currencies:
- if c != "USD":
- currencies.add(c)
- return sorted(currencies)
-
-
-# ---------------------------------------------------------------------------
-# Rate fetching
-# ---------------------------------------------------------------------------
-
-async def refresh_rates(client: httpx.AsyncClient) -> bool:
- """Fetch latest USD-based exchange rates from Frankfurter.
-
- Returns True if rates were successfully updated.
- """
- global _rates, _last_fetched
-
- needed = _all_target_currencies()
- if not needed:
- # Only USD display — no conversion needed
- _last_fetched = time.monotonic()
- return True
-
- symbols = ",".join(needed)
- try:
- resp = await client.get(
- FRANKFURTER_URL,
- params={"base": "USD", "symbols": symbols},
- )
- resp.raise_for_status()
- data = resp.json()
- except (httpx.HTTPError, ValueError) as exc:
- logger.warning("Failed to fetch exchange rates: %s", exc)
- return False
-
- new_rates = data.get("rates", {})
- if not new_rates:
- logger.warning("Frankfurter returned empty rates")
- return False
-
- _rates = {k: float(v) for k, v in new_rates.items()}
- _last_fetched = time.monotonic()
- logger.info("Exchange rates updated: %s", _rates)
- return True
-
-
-async def _ensure_rates(client: httpx.AsyncClient) -> None:
- """Refresh rates if the cache is stale or empty."""
- if _all_target_currencies() and (
- not _rates or (time.monotonic() - _last_fetched) > CACHE_TTL_SECONDS
- ):
- await refresh_rates(client)
-
-
-# ---------------------------------------------------------------------------
-# Conversion helpers
-# ---------------------------------------------------------------------------
-
-def _convert_from_usd(usd_amount: float, currency: str) -> float | None:
- """Convert a USD amount to *currency* using cached rates."""
- if currency == "USD":
- return usd_amount
- rate = _rates.get(currency)
- if rate is None:
- return None
- return round(usd_amount * rate, 2)
-
-
-def convert_to_usd(amount: float, source_currency: str) -> float:
- """Convert *amount* from *source_currency* to USD using cached rates.
-
- Falls back to a 1:1 conversion with a warning when rates are unavailable.
- """
- if source_currency == "USD":
- return amount
- rate = _rates.get(source_currency)
- if rate is None or rate == 0:
- logger.warning("No rate for %s → USD, assuming 1:1", source_currency)
- return amount
- return round(amount / rate, 2)
-
-
-# ---------------------------------------------------------------------------
-# Price formatting
-# ---------------------------------------------------------------------------
-
-def format_price(usd_amount: float) -> str:
- """Return a formatted multi-currency price string.
-
- The configured *default_currency* is shown first, followed by each
- *extra_currency*. Example with defaults::
-
- $14.99 · C$20.54 · €13.78 · £11.98
-
- Falls back to USD-only if rates aren't available.
- """
- display_order = [_default_currency] + [
- c for c in _extra_currencies if c != _default_currency
- ]
-
- parts: list[str] = []
- for cur in display_order:
- converted = _convert_from_usd(usd_amount, cur)
- if converted is not None:
- symbol = SYMBOLS.get(cur, f"{cur} ")
- parts.append(f"{symbol}{converted:.2f}")
-
- if not parts:
- # Fallback when no rates are loaded yet
- parts = [f"${usd_amount:.2f}"]
-
- return " · ".join(parts)
-
-
-async def format_price_async(client: httpx.AsyncClient, usd_amount: float) -> str:
- """Ensure rates are fresh, then format a multi-currency price string."""
- await _ensure_rates(client)
- return format_price(usd_amount)
diff --git a/gaming_deals_bot/database.py b/gaming_deals_bot/database.py
deleted file mode 100644
index 6e5a83b..0000000
--- a/gaming_deals_bot/database.py
+++ /dev/null
@@ -1,115 +0,0 @@
-import logging
-from datetime import datetime, timedelta, timezone
-
-import aiosqlite
-
-logger = logging.getLogger(__name__)
-
-SCHEMA = """
-CREATE TABLE IF NOT EXISTS posted_deals (
- id TEXT PRIMARY KEY,
- source TEXT NOT NULL,
- title TEXT NOT NULL,
- posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-);
-
-CREATE TABLE IF NOT EXISTS config (
- key TEXT PRIMARY KEY,
- value TEXT
-);
-
-CREATE TABLE IF NOT EXISTS thread_roots (
- category TEXT PRIMARY KEY,
- event_id TEXT NOT NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-);
-"""
-
-
-class Database:
- def __init__(self, path: str):
- self.path = path
- self._db: aiosqlite.Connection | None = None
-
- async def connect(self):
- self._db = await aiosqlite.connect(self.path)
- await self._db.executescript(SCHEMA)
- await self._db.commit()
- logger.info("Database initialized at %s", self.path)
-
- async def close(self):
- if self._db:
- await self._db.close()
-
- async def has_been_posted(self, deal_id: str) -> bool:
- assert self._db is not None
- cursor = await self._db.execute(
- "SELECT 1 FROM posted_deals WHERE id = ?", (deal_id,)
- )
- row = await cursor.fetchone()
- return row is not None
-
- async def mark_posted(self, deal_id: str, source: str, title: str):
- assert self._db is not None
- await self._db.execute(
- "INSERT OR IGNORE INTO posted_deals (id, source, title) VALUES (?, ?, ?)",
- (deal_id, source, title),
- )
- await self._db.commit()
-
- async def prune_old(self, days: int = 30):
- assert self._db is not None
- cutoff = datetime.now(timezone.utc) - timedelta(days=days)
- result = await self._db.execute(
- "DELETE FROM posted_deals WHERE posted_at < ?",
- (cutoff.isoformat(),),
- )
- await self._db.commit()
- if result.rowcount:
- logger.info("Pruned %d old deal records", result.rowcount)
-
- async def get_config(self, key: str) -> str | None:
- assert self._db is not None
- cursor = await self._db.execute(
- "SELECT value FROM config WHERE key = ?", (key,)
- )
- row = await cursor.fetchone()
- return row[0] if row else None
-
- async def set_config(self, key: str, value: str):
- assert self._db is not None
- await self._db.execute(
- "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)",
- (key, value),
- )
- await self._db.commit()
-
- # ------------------------------------------------------------------
- # Thread root management
- # ------------------------------------------------------------------
-
- async def get_thread_root(self, category: str) -> str | None:
- """Return the Matrix event ID for a thread root, or None."""
- assert self._db is not None
- cursor = await self._db.execute(
- "SELECT event_id FROM thread_roots WHERE category = ?", (category,)
- )
- row = await cursor.fetchone()
- return row[0] if row else None
-
- async def set_thread_root(self, category: str, event_id: str):
- """Store the Matrix event ID for a thread root."""
- assert self._db is not None
- await self._db.execute(
- "INSERT OR REPLACE INTO thread_roots (category, event_id) VALUES (?, ?)",
- (category, event_id),
- )
- await self._db.commit()
-
- async def clear_thread_root(self, category: str):
- """Remove a stored thread root (e.g. if the event was redacted)."""
- assert self._db is not None
- await self._db.execute(
- "DELETE FROM thread_roots WHERE category = ?", (category,)
- )
- await self._db.commit()
diff --git a/gaming_deals_bot/epic.py b/gaming_deals_bot/epic.py
deleted file mode 100644
index f0d9636..0000000
--- a/gaming_deals_bot/epic.py
+++ /dev/null
@@ -1,135 +0,0 @@
-import logging
-from dataclasses import dataclass
-from datetime import datetime, timezone
-
-import httpx
-
-logger = logging.getLogger(__name__)
-
-FREE_GAMES_URL = (
- "https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions"
-)
-
-
-@dataclass
-class EpicFreeGame:
- title: str
- game_id: str
- description: str
- end_date: str | None # ISO datetime when the offer expires
- url: str
-
- @property
- def dedup_id(self) -> str:
- return f"epic-{self.game_id}"
-
-
-def _parse_promotions(
- elements: list[dict],
-) -> tuple[list[EpicFreeGame], list[EpicFreeGame]]:
- """Parse Epic promotions into current free games and upcoming free games."""
- current: list[EpicFreeGame] = []
- upcoming: list[EpicFreeGame] = []
- now = datetime.now(timezone.utc)
-
- for elem in elements:
- title = elem.get("title", "Unknown")
- game_id = elem.get("id", "")
- description = elem.get("description", "")
-
- # Build the store URL from the slug or product slug
- slug = (
- elem.get("productSlug")
- or elem.get("urlSlug")
- or elem.get("catalogNs", {}).get("mappings", [{}])[0].get("pageSlug", "")
- )
- url = f"https://store.epicgames.com/en-US/p/{slug}" if slug else ""
-
- # Check current promotional offers
- promotions = elem.get("promotions")
- if not promotions:
- continue
-
- # Current offers
- for offer_group in promotions.get("promotionalOffers", []):
- for offer in offer_group.get("promotionalOffers", []):
- discount = offer.get("discountSetting", {})
- if discount.get("discountPercentage", 100) == 0:
- end_date = offer.get("endDate")
- # Verify the offer is currently active
- start = offer.get("startDate")
- if start:
- start_dt = datetime.fromisoformat(
- start.replace("Z", "+00:00")
- )
- if start_dt > now:
- continue
- if end_date:
- end_dt = datetime.fromisoformat(
- end_date.replace("Z", "+00:00")
- )
- if end_dt < now:
- continue
-
- current.append(
- EpicFreeGame(
- title=title,
- game_id=game_id,
- description=description,
- end_date=end_date,
- url=url,
- )
- )
-
- # Upcoming offers
- for offer_group in promotions.get("upcomingPromotionalOffers", []):
- for offer in offer_group.get("promotionalOffers", []):
- discount = offer.get("discountSetting", {})
- if discount.get("discountPercentage", 100) == 0:
- end_date = offer.get("endDate")
- upcoming.append(
- EpicFreeGame(
- title=title,
- game_id=game_id,
- description=description,
- end_date=end_date,
- url=url,
- )
- )
-
- return current, upcoming
-
-
-async def fetch_free_games(
- client: httpx.AsyncClient,
-) -> tuple[list[EpicFreeGame], list[EpicFreeGame]]:
- """Fetch current and upcoming free games from Epic Games Store.
-
- Returns (current_free_games, upcoming_free_games).
- """
- try:
- resp = await client.get(FREE_GAMES_URL, params={"locale": "en-US"})
- resp.raise_for_status()
- data = resp.json()
- except (httpx.HTTPError, ValueError) as exc:
- logger.error("Epic Games Store API error: %s", exc)
- return [], []
-
- elements = (
- data.get("data", {})
- .get("Catalog", {})
- .get("searchStore", {})
- .get("elements", [])
- )
-
- if not elements:
- logger.warning("No elements found in Epic free games response")
- return [], []
-
- current, upcoming = _parse_promotions(elements)
- logger.info(
- "Epic: %d current free games, %d upcoming",
- len(current),
- len(upcoming),
- )
- return current, upcoming
diff --git a/gaming_deals_bot/formatter.py b/gaming_deals_bot/formatter.py
deleted file mode 100644
index 626d7a7..0000000
--- a/gaming_deals_bot/formatter.py
+++ /dev/null
@@ -1,156 +0,0 @@
-"""Message formatting for Matrix deal posts.
-
-Produces both a plain-text fallback and HTML formatted body for Matrix messages.
-HTML is built directly using the Matrix-supported HTML subset rather than going
-through a Markdown-to-HTML converter, which collapses line breaks.
-"""
-
-from datetime import datetime, timezone
-from html import escape
-
-from .cheapshark import CheapSharkDeal
-from .currency import format_price
-from .epic import EpicFreeGame
-from .itad_deals import ITADDeal
-
-
-def format_deal(deal: CheapSharkDeal, is_historical_low: bool = False) -> tuple[str, str]:
- """Format a CheapShark deal into (plain_text, html) for Matrix.
-
- Returns (body, formatted_body).
- """
- discount = int(deal.savings)
- sale_price = float(deal.sale_price)
- normal_price = float(deal.normal_price)
-
- sale_multi = format_price(sale_price)
- normal_display = format_price(normal_price)
- title = escape(deal.title)
-
- html_lines = [
- f"🎮 [DEAL] {title}",
- f"{discount}% off on {escape(deal.store_name)} {escape(normal_display)}",
- f"💰 {escape(sale_multi)}",
- ]
- if is_historical_low:
- html_lines.append("🏆 All-time low!")
- html_lines.append(f'🔗 View Deal')
- html = "
\n".join(html_lines)
-
- plain_lines = [
- f"🎮 [DEAL] {deal.title}",
- f" {discount}% off on {deal.store_name} (was {normal_display})",
- f" 💰 {sale_multi}",
- ]
- if is_historical_low:
- plain_lines.append(" 🏆 All-time low!")
- plain_lines.append(f" 🔗 {deal.deal_url}")
- plain_text = "\n".join(plain_lines)
-
- return plain_text, html
-
-
-def format_itad_deal(deal: ITADDeal) -> tuple[str, str]:
- """Format an ITAD deal into (plain_text, html) for Matrix.
-
- Returns (body, formatted_body).
- """
- sale_multi = format_price(deal.sale_price)
- normal_display = format_price(deal.normal_price)
- title = escape(deal.title)
-
- html_lines = [
- f"🎮 [DEAL] {title}",
- f"{deal.discount}% off on {escape(deal.shop_name)} {escape(normal_display)}",
- f"💰 {escape(sale_multi)}",
- ]
- if deal.is_historical_low:
- html_lines.append("🏆 All-time low!")
- html_lines.append(f'🔗 View Deal')
- html = "
\n".join(html_lines)
-
- plain_lines = [
- f"🎮 [DEAL] {deal.title}",
- f" {deal.discount}% off on {deal.shop_name} (was {normal_display})",
- f" 💰 {sale_multi}",
- ]
- if deal.is_historical_low:
- plain_lines.append(" 🏆 All-time low!")
- plain_lines.append(f" 🔗 {deal.url}")
- plain_text = "\n".join(plain_lines)
-
- return plain_text, html
-
-
-def format_epic_free(game: EpicFreeGame) -> tuple[str, str]:
- """Format an Epic free game into (plain_text, html) for Matrix.
-
- Returns (body, formatted_body).
- """
- title = escape(game.title)
-
- html_lines = [
- f"🆓 [FREE] {title}",
- "Free on Epic Games Store",
- ]
-
- if game.end_date:
- try:
- end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00"))
- date_str = end_dt.strftime("%B %-d")
- html_lines.append(f"📅 Free until {date_str}")
- except (ValueError, TypeError):
- pass
-
- if game.url:
- html_lines.append(f'🔗 Claim Now')
- html = "
\n".join(html_lines)
-
- plain_lines = [
- f"🆓 [FREE] {game.title}",
- " Free on Epic Games Store",
- ]
- if game.end_date:
- try:
- end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00"))
- date_str = end_dt.strftime("%B %-d")
- plain_lines.append(f" 📅 Free until {date_str}")
- except (ValueError, TypeError):
- pass
- if game.url:
- plain_lines.append(f" 🔗 {game.url}")
- plain_text = "\n".join(plain_lines)
-
- return plain_text, html
-
-
-def format_epic_upcoming(game: EpicFreeGame) -> tuple[str, str]:
- """Format an upcoming Epic free game into (plain_text, html) for Matrix."""
- title = escape(game.title)
-
- html_lines = [
- f"📢 [UPCOMING FREE] {title}",
- "Coming soon — Free on Epic Games Store",
- ]
-
- if game.end_date:
- try:
- end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00"))
- date_str = end_dt.strftime("%B %-d")
- html_lines.append(f"📅 Free until {date_str}")
- except (ValueError, TypeError):
- pass
-
- if game.url:
- html_lines.append(f'🔗 Store Page')
- html = "
\n".join(html_lines)
-
- plain_lines = [
- f"📢 [UPCOMING FREE] {game.title}",
- " Coming soon — Free on Epic Games Store",
- ]
- if game.url:
- plain_lines.append(f" 🔗 {game.url}")
- plain_text = "\n".join(plain_lines)
-
- return plain_text, html
diff --git a/gaming_deals_bot/itad.py b/gaming_deals_bot/itad.py
deleted file mode 100644
index 09f50a8..0000000
--- a/gaming_deals_bot/itad.py
+++ /dev/null
@@ -1,122 +0,0 @@
-import logging
-
-import httpx
-
-logger = logging.getLogger(__name__)
-
-BASE_URL = "https://api.isthereanydeal.com"
-
-
-async def _lookup_game_id(
- client: httpx.AsyncClient,
- api_key: str,
- steam_app_id: str,
-) -> str | None:
- """Look up the ITAD game UUID for a Steam app ID.
-
- Returns the ITAD game UUID string, or None if not found.
- """
- try:
- resp = await client.get(
- f"{BASE_URL}/games/lookup/v1",
- params={"key": api_key, "appid": steam_app_id},
- )
- resp.raise_for_status()
- data = resp.json()
- if data.get("found") and data.get("game"):
- return data["game"]["id"]
- except (httpx.HTTPError, ValueError, KeyError) as exc:
- logger.error("ITAD lookup error for app %s: %s", steam_app_id, exc)
- return None
-
-
-async def check_historical_lows(
- client: httpx.AsyncClient,
- api_key: str,
- steam_app_ids: list[str],
-) -> dict[str, bool]:
- """Check if current prices are historical lows for a batch of Steam app IDs.
-
- Returns a mapping of steam_app_id -> is_historical_low.
- """
- if not api_key or not steam_app_ids:
- return {}
-
- # Look up ITAD game UUIDs for each Steam app ID
- itad_id_to_steam: dict[str, str] = {}
- for sid in steam_app_ids:
- itad_id = await _lookup_game_id(client, api_key, sid)
- if itad_id:
- itad_id_to_steam[itad_id] = sid
-
- if not itad_id_to_steam:
- return {}
-
- try:
- resp = await client.post(
- f"{BASE_URL}/games/overview/v2",
- params={"key": api_key, "shops": [61]},
- json=list(itad_id_to_steam.keys()),
- )
- resp.raise_for_status()
- data = resp.json()
- except (httpx.HTTPError, ValueError) as exc:
- logger.error("ITAD API error: %s", exc)
- return {}
-
- results: dict[str, bool] = {}
- for price_entry in data.get("prices", []):
- itad_id = price_entry.get("id")
- steam_id = itad_id_to_steam.get(itad_id)
- if not steam_id:
- continue
- lowest = price_entry.get("lowest")
- current = price_entry.get("current")
- if lowest and current:
- lowest_price = lowest.get("price", {}).get("amount", float("inf"))
- current_price = current.get("price", {}).get("amount", float("inf"))
- results[steam_id] = current_price <= lowest_price
-
- logger.info(
- "ITAD: checked %d games, %d are at historical low",
- len(steam_app_ids),
- sum(results.values()),
- )
- return results
-
-
-async def check_single_historical_low(
- client: httpx.AsyncClient,
- api_key: str,
- steam_app_id: str,
-) -> bool:
- """Check if a single game is at its historical low price."""
- if not api_key or not steam_app_id:
- return False
-
- itad_id = await _lookup_game_id(client, api_key, steam_app_id)
- if not itad_id:
- return False
-
- try:
- resp = await client.post(
- f"{BASE_URL}/games/overview/v2",
- params={"key": api_key},
- json=[itad_id],
- )
- resp.raise_for_status()
- data = resp.json()
- except (httpx.HTTPError, ValueError) as exc:
- logger.error("ITAD API error for app %s: %s", steam_app_id, exc)
- return False
-
- for price_entry in data.get("prices", []):
- lowest = price_entry.get("lowest")
- current = price_entry.get("current")
- if lowest and current:
- lowest_price = lowest.get("price", {}).get("amount", float("inf"))
- current_price = current.get("price", {}).get("amount", float("inf"))
- if current_price <= lowest_price:
- return True
-
- return False
diff --git a/gaming_deals_bot/itad_deals.py b/gaming_deals_bot/itad_deals.py
deleted file mode 100644
index 7d25547..0000000
--- a/gaming_deals_bot/itad_deals.py
+++ /dev/null
@@ -1,198 +0,0 @@
-"""IsThereAnyDeal deals list — fetch current deals from the ITAD API."""
-
-import logging
-from dataclasses import dataclass
-
-import httpx
-
-from .currency import convert_to_usd
-from .itad import BASE_URL
-
-logger = logging.getLogger(__name__)
-
-# ITAD shop ID for Steam
-STEAM_SHOP_ID = 61
-
-
-@dataclass
-class ITADDeal:
- game_id: str # ITAD UUID
- slug: str
- title: str
- deal_type: str # ITAD type: "game", "dlc", or other
- sale_price: float # current deal price (normalised to USD)
- normal_price: float # regular (non-sale) price (normalised to USD)
- discount: int # percentage off (0-100)
- currency: str
- shop_name: str
- shop_id: int
- url: str # purchase redirect URL
- is_historical_low: bool
- timestamp: str # ISO datetime of deal
- expiry: str | None # ISO datetime when deal expires
-
- @property
- def dedup_id(self) -> str:
- return f"itad-{self.game_id}-{self.shop_id}-{self.discount}"
-
- @property
- def sale_price_usd(self) -> str:
- return f"{self.sale_price:.2f}"
-
- @property
- def normal_price_usd(self) -> str:
- return f"{self.normal_price:.2f}"
-
-
-async def fetch_deals(
- client: httpx.AsyncClient,
- api_key: str,
- *,
- countries: list[str] | None = None,
- max_price: float = 20,
- min_discount: float = 50,
- limit: int = 200,
-) -> list[ITADDeal]:
- """Fetch current deals from IsThereAnyDeal across one or more countries.
-
- Defaults to the API maximum of 200 deals per country so that client-side
- filtering (discount, price, type) has the widest possible pool to work
- with — this avoids missing deals that wouldn't appear in a smaller window
- sorted only by discount.
-
- Deals are fetched per-country, merged (first country in the list wins
- when the same game+shop appears in multiple regions), and finally sorted
- by timestamp so that the newest deals appear first.
- """
- if not api_key:
- return []
-
- if countries is None:
- countries = ["US"]
-
- seen: set[str] = set() # game_id-shop_id keys for cross-country dedup
- all_deals: list[ITADDeal] = []
-
- for country in countries:
- country_deals = await _fetch_country_deals(
- client,
- api_key,
- country=country,
- max_price=max_price,
- min_discount=min_discount,
- limit=limit,
- )
- for deal in country_deals:
- key = f"{deal.game_id}-{deal.shop_id}"
- if key not in seen:
- seen.add(key)
- all_deals.append(deal)
- else:
- logger.debug(
- "Skipping duplicate %s from country %s", deal.title, country
- )
-
- # Sort newest first — the API only supports sorting by discount or price,
- # so we sort client-side by the deal timestamp.
- all_deals.sort(key=lambda d: d.timestamp, reverse=True)
-
- logger.info(
- "ITAD: %d deals across %d country/countries after dedup",
- len(all_deals),
- len(countries),
- )
- return all_deals
-
-
-async def _fetch_country_deals(
- client: httpx.AsyncClient,
- api_key: str,
- *,
- country: str = "US",
- max_price: float = 20,
- min_discount: float = 50,
- limit: int = 200,
-) -> list[ITADDeal]:
- """Fetch deals for a single country from the ITAD ``/deals/v2`` endpoint."""
- params: dict = {
- "key": api_key,
- "country": country,
- "sort": "-cut",
- "limit": min(limit, 200),
- "nondeals": "false",
- }
-
- try:
- resp = await client.get(f"{BASE_URL}/deals/v2", params=params)
- resp.raise_for_status()
- data = resp.json()
- except (httpx.HTTPError, ValueError) as exc:
- logger.error("ITAD deals API error for country %s: %s", country, exc)
- return []
-
- raw_list = data.get("list", [])
- logger.info(
- "ITAD returned %d raw deals for %s (before filtering)", len(raw_list), country
- )
-
- deals: list[ITADDeal] = []
- for entry in raw_list:
- deal_data = entry.get("deal", {})
- if not deal_data:
- continue
-
- entry_type = entry.get("type", "game")
- title = entry.get("title", "?")
-
- cut = deal_data.get("cut", 0)
- price_amount = deal_data.get("price", {}).get("amount", 0)
- regular_amount = deal_data.get("regular", {}).get("amount", 0)
- currency = deal_data.get("price", {}).get("currency", "USD")
-
- # Normalise to USD so prices are comparable across regions
- price_usd = convert_to_usd(price_amount, currency)
- regular_usd = convert_to_usd(regular_amount, currency)
-
- # Apply ITAD-specific filters
- if cut < min_discount:
- logger.debug(
- "Filtered out %s: discount %d%% < %d%%",
- title,
- cut,
- int(min_discount),
- )
- continue
- if price_usd > max_price:
- logger.debug(
- "Filtered out %s: price $%.2f > $%.2f", title, price_usd, max_price
- )
- continue
-
- flag = deal_data.get("flag")
- is_historical_low = flag in ("H", "N")
-
- shop = deal_data.get("shop", {})
-
- deals.append(
- ITADDeal(
- game_id=entry.get("id", ""),
- slug=entry.get("slug", ""),
- title=title,
- deal_type=entry_type,
- sale_price=price_usd,
- normal_price=regular_usd,
- discount=cut,
- currency="USD", # normalised
- shop_name=shop.get("name", "Unknown"),
- shop_id=shop.get("id", 0),
- url=deal_data.get("url", ""),
- is_historical_low=is_historical_low,
- timestamp=deal_data.get("timestamp", ""),
- expiry=deal_data.get("expiry"),
- )
- )
-
- logger.info(
- "ITAD returned %d deals for %s after filtering", len(deals), country
- )
- return deals
diff --git a/gaming_deals_bot/matrix_client.py b/gaming_deals_bot/matrix_client.py
deleted file mode 100644
index c9d7aa4..0000000
--- a/gaming_deals_bot/matrix_client.py
+++ /dev/null
@@ -1,184 +0,0 @@
-"""Matrix client wrapper for sending deal messages."""
-
-import asyncio
-import logging
-
-from nio import AsyncClient, PresenceSetError, RoomSendError, RoomSendResponse
-
-logger = logging.getLogger(__name__)
-
-PRESENCE_HEARTBEAT_INTERVAL = 60 # seconds
-
-
-class MatrixDealsClient:
- def __init__(
- self,
- homeserver_url: str,
- user_id: str,
- access_token: str,
- room_id: str,
- ):
- self.room_id = room_id
- self._client = AsyncClient(homeserver_url, user_id)
- self._client.access_token = access_token
- self._client.user_id = user_id
- self._heartbeat_task: asyncio.Task | None = None
-
- async def send_deal(self, plain_text: str, html: str) -> bool:
- """Send a deal message to the configured room.
-
- Returns True on success, False on failure.
- """
- content = {
- "msgtype": "m.text",
- "format": "org.matrix.custom.html",
- "body": plain_text,
- "formatted_body": html,
- }
-
- try:
- resp = await self._client.room_send(
- room_id=self.room_id,
- message_type="m.room.message",
- content=content,
- )
- if isinstance(resp, RoomSendError):
- logger.error("Failed to send message: %s", resp.message)
- return False
- logger.debug("Message sent: %s", resp.event_id)
- return True
- except Exception as exc:
- logger.error("Matrix send error: %s", exc)
- return False
-
- async def send_notice(self, text: str) -> bool:
- """Send a plain m.notice (non-highlight) message to the configured room."""
- content = {
- "msgtype": "m.notice",
- "body": text,
- }
-
- try:
- resp = await self._client.room_send(
- room_id=self.room_id,
- message_type="m.room.message",
- content=content,
- )
- if isinstance(resp, RoomSendError):
- logger.error("Failed to send notice: %s", resp.message)
- return False
- return True
- except Exception as exc:
- logger.error("Matrix send error: %s", exc)
- return False
-
- async def send_deal_in_thread(
- self, plain_text: str, html: str, thread_root_id: str
- ) -> bool:
- """Send a deal message as a reply inside a Matrix thread.
-
- ``thread_root_id`` is the ``$event_id`` of the thread root message.
- """
- content = {
- "msgtype": "m.text",
- "format": "org.matrix.custom.html",
- "body": plain_text,
- "formatted_body": html,
- "m.relates_to": {
- "rel_type": "m.thread",
- "event_id": thread_root_id,
- # Fallback for clients that don't support threads
- "is_falling_back": True,
- "m.in_reply_to": {"event_id": thread_root_id},
- },
- }
-
- try:
- resp = await self._client.room_send(
- room_id=self.room_id,
- message_type="m.room.message",
- content=content,
- )
- if isinstance(resp, RoomSendError):
- logger.error("Failed to send threaded message: %s", resp.message)
- return False
- logger.debug("Threaded message sent: %s (thread %s)", resp.event_id, thread_root_id)
- return True
- except Exception as exc:
- logger.error("Matrix threaded send error: %s", exc)
- return False
-
- async def create_thread_root(self, plain_text: str, html: str) -> str | None:
- """Send a message that will serve as a thread root.
-
- Returns the ``$event_id`` on success, or ``None`` on failure.
- """
- content = {
- "msgtype": "m.text",
- "format": "org.matrix.custom.html",
- "body": plain_text,
- "formatted_body": html,
- }
-
- try:
- resp = await self._client.room_send(
- room_id=self.room_id,
- message_type="m.room.message",
- content=content,
- )
- if isinstance(resp, RoomSendError):
- logger.error("Failed to create thread root: %s", resp.message)
- return None
- assert isinstance(resp, RoomSendResponse)
- logger.info("Thread root created: %s", resp.event_id)
- return resp.event_id
- except Exception as exc:
- logger.error("Matrix thread root error: %s", exc)
- return None
-
- async def set_presence(self, presence: str) -> bool:
- """Set the bot's presence status on the homeserver.
-
- Returns True on success, False on failure.
- """
- try:
- resp = await self._client.set_presence(presence)
- if isinstance(resp, PresenceSetError):
- logger.error("Failed to set presence to %s: %s", presence, resp.message)
- return False
- logger.debug("Presence set to %s", presence)
- return True
- except Exception as exc:
- logger.error("Presence set error: %s", exc)
- return False
-
- async def start_presence_heartbeat(self):
- """Set presence to online and spawn a background task to keep it alive."""
- await self.set_presence("online")
- self._heartbeat_task = asyncio.create_task(self._presence_heartbeat_loop())
- logger.info("Presence heartbeat started (every %ds)", PRESENCE_HEARTBEAT_INTERVAL)
-
- async def _presence_heartbeat_loop(self):
- """Re-send 'online' presence every PRESENCE_HEARTBEAT_INTERVAL seconds."""
- try:
- while True:
- await asyncio.sleep(PRESENCE_HEARTBEAT_INTERVAL)
- await self.set_presence("online")
- except asyncio.CancelledError:
- pass
-
- async def stop_presence_heartbeat(self):
- """Cancel the heartbeat task and set presence to offline."""
- if self._heartbeat_task is not None:
- self._heartbeat_task.cancel()
- try:
- await self._heartbeat_task
- except asyncio.CancelledError:
- pass
- self._heartbeat_task = None
- await self.set_presence("offline")
- logger.info("Presence heartbeat stopped, set to offline")
-
- async def close(self):
- await self.stop_presence_heartbeat()
- await self._client.close()
diff --git a/gaming_deals_bot/preflight.py b/gaming_deals_bot/preflight.py
deleted file mode 100644
index c8099aa..0000000
--- a/gaming_deals_bot/preflight.py
+++ /dev/null
@@ -1,205 +0,0 @@
-"""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, configure as configure_currency, _all_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
-
- # Configure currency display so the Frankfurter check fetches the right symbols
- configure_currency(config.default_currency, config.extra_currencies)
-
- 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}")
- if "cheapshark" in config.deal_sources:
- all_ok &= await _check_cheapshark(http)
- else:
- _skip("Skipped", "not in DEAL_SOURCES")
-
- # --- 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, config)
-
- # --- IsThereAnyDeal ---
- itad_required = "itad" in config.deal_sources
- print(f"\n{_BOLD}IsThereAnyDeal{_RESET}")
- all_ok &= await _check_itad(http, config.itad_api_key, required=itad_required)
-
- # --- 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, config: Config) -> bool:
- """Fetch exchange rates to confirm Frankfurter is reachable."""
- needed = _all_target_currencies()
- if not needed:
- return _pass(
- "Skipped",
- f"only {config.default_currency} configured — no conversion needed",
- )
- try:
- symbols = ",".join(needed)
- 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, *, required: bool = False) -> bool:
- """Verify the ITAD API key works.
-
- When *required* is True (ITAD is a deal source), a missing key is a failure.
- Otherwise it's an optional skip.
- """
- if not api_key:
- if required:
- return _fail("API key", "ITAD_API_KEY is required when 'itad' is in DEAL_SOURCES")
- return _skip("Skipped", "no ITAD_API_KEY configured (optional)")
-
- try:
- # Use the lookup endpoint (GET) to validate the key with a known game
- resp = await http.get(
- f"{ITAD_URL}/games/lookup/v1",
- params={"key": api_key, "appid": 220}, # Half-Life 2
- )
- if resp.status_code in (401, 403):
- return _fail("API key", "rejected by ITAD (401/403)")
- resp.raise_for_status()
- data = resp.json()
- if data.get("found"):
- return _pass("API reachable", "ITAD responded successfully")
- return _pass("API reachable", "ITAD key valid (game not found)")
- except Exception as exc:
- return _fail("API reachable", str(exc))
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 944872d..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-matrix-nio>=0.21.0
-httpx>=0.25.0
-aiosqlite>=0.19.0
-apscheduler>=3.10.0
-python-dotenv>=1.0.0