diff --git a/.env.example b/.env.example index 333a009..4ad0663 100644 --- a/.env.example +++ b/.env.example @@ -11,10 +11,31 @@ ITAD_API_KEY= # Requires ITAD_API_KEY when "itad" is included DEAL_SOURCES=cheapshark -# Deal filtering -MIN_DEAL_RATING=8.0 +# ITAD countries: comma-separated ISO 3166-1 alpha-2 country codes +# Deals are fetched for each country and merged (duplicates removed). +# Examples: US, CA, GB, DE, FR, AU, BR, JP +ITAD_COUNTRIES=US + +# Currency display +# DEFAULT_CURRENCY is shown first; EXTRA_CURRENCIES are shown after it. +DEFAULT_CURRENCY=USD +EXTRA_CURRENCIES=CAD,EUR,GBP + +# CheapShark-specific filtering +# (Falls back to shared MIN_DISCOUNT_PERCENT / MAX_PRICE if not set) +#CHEAPSHARK_MIN_DISCOUNT=50 +#CHEAPSHARK_MIN_RATING=8.0 +#CHEAPSHARK_MAX_PRICE=20 + +# ITAD-specific filtering +# (Falls back to shared MIN_DISCOUNT_PERCENT / MAX_PRICE if not set) +#ITAD_MIN_DISCOUNT=50 +#ITAD_MAX_PRICE=20 +#ITAD_DEALS_LIMIT=100 + +# Shared filter defaults (used when source-specific values are not set) MIN_DISCOUNT_PERCENT=50 -MAX_PRICE_USD=20 +MAX_PRICE=20 # Send an intro message to the room on startup (true/false) SEND_INTRO_MESSAGE=false diff --git a/README.md b/README.md index 7a0850e..92ae4f8 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ python -m gaming_deals_bot All configuration is via environment variables (see `.env.example`): +### Core + | Variable | Required | Default | Description | |---|---|---|---| | `MATRIX_HOMESERVER_URL` | Yes | — | Matrix homeserver URL | @@ -63,12 +65,27 @@ All configuration is via environment variables (see `.env.example`): | `MATRIX_DEALS_ROOM_ID` | Yes | — | Room ID to post deals in | | `ITAD_API_KEY` | No | — | IsThereAnyDeal API key (required when `itad` is in `DEAL_SOURCES`, optional otherwise for historical low detection) | | `DEAL_SOURCES` | No | cheapshark | Comma-separated deal sources: `cheapshark`, `itad`, or `cheapshark,itad` | -| `MIN_DEAL_RATING` | No | 8.0 | Minimum CheapShark deal rating (0-10) | -| `MIN_DISCOUNT_PERCENT` | No | 50 | Minimum discount percentage | -| `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD | +| `ITAD_COUNTRIES` | No | US | Comma-separated ISO 3166-1 alpha-2 country codes to fetch ITAD deals from (e.g. `US,CA,GB,DE`) | +| `DEFAULT_CURRENCY` | No | USD | Primary display currency shown first in price strings | +| `EXTRA_CURRENCIES` | No | CAD,EUR,GBP | Additional currencies shown after the default (comma-separated) | | `SEND_INTRO_MESSAGE` | No | false | Send "The deals must flow." to the room on startup | | `DATABASE_PATH` | No | deals.db | Path to SQLite database file | +### Filtering + +Each deal source has its own filter settings. Source-specific values take priority; when not set they fall back to the shared defaults. + +| Variable | Source | Default | Description | +|---|---|---|---| +| `CHEAPSHARK_MIN_DISCOUNT` | CheapShark | 50 | Minimum discount percentage | +| `CHEAPSHARK_MIN_RATING` | CheapShark | 8.0 | Minimum deal rating (0-10, 0 = unrated allowed) | +| `CHEAPSHARK_MAX_PRICE` | CheapShark | 20 | Maximum sale price (USD) | +| `ITAD_MIN_DISCOUNT` | ITAD | 50 | Minimum discount percentage | +| `ITAD_MAX_PRICE` | ITAD | 20 | Maximum sale price (USD, prices from other regions are converted) | +| `ITAD_DEALS_LIMIT` | ITAD | 100 | Number of deals to fetch per country (max 200) | +| `MIN_DISCOUNT_PERCENT` | Shared | 50 | Fallback minimum discount when source-specific value is not set | +| `MAX_PRICE` | Shared | 20 | Fallback maximum price when source-specific value is not set | + ## Preflight Check Run `--check` to validate your configuration and test connectivity to all services before starting the bot: @@ -93,4 +110,5 @@ The command exits with code 0 on success and 1 on failure, so it works in CI and - **Deduplication**: deals are tracked by game ID + timestamp; duplicates are never reposted - **Pruning**: deals older than 30 days are pruned from the database so they can be reposted if they return - **One message per deal**: each deal is posted individually so messages are independently linkable and dismissible -- **Multi-currency pricing**: deal prices are shown in USD, CAD, EUR, and GBP using live exchange rates from the [Frankfurter API](https://api.frankfurter.dev) (ECB data, no API key required). Rates are cached and refreshed twice daily. +- **Multi-currency pricing**: deal prices are shown in your configured currencies (default: USD, CAD, EUR, GBP) using live exchange rates from the [Frankfurter API](https://api.frankfurter.dev) (ECB data, no API key required). Set `DEFAULT_CURRENCY` to change the primary display currency and `EXTRA_CURRENCIES` for additional ones. Rates are cached and refreshed twice daily. +- **Multi-country ITAD deals**: when using IsThereAnyDeal, deals can be fetched from multiple countries simultaneously via `ITAD_COUNTRIES` (e.g. `US,CA,GB,DE`). Deals are merged and deduplicated, with the first country in the list taking priority for duplicate games. diff --git a/gaming_deals_bot/bot.py b/gaming_deals_bot/bot.py index 7d4b9a4..e55764c 100644 --- a/gaming_deals_bot/bot.py +++ b/gaming_deals_bot/bot.py @@ -7,7 +7,7 @@ import httpx from .cheapshark import CheapSharkDeal, fetch_deals as fetch_cheapshark_deals from .config import Config -from .currency import refresh_rates +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 @@ -35,6 +35,9 @@ class DealsBot: """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) @@ -69,9 +72,9 @@ class DealsBot: if "cheapshark" in self.config.deal_sources: deals = await fetch_cheapshark_deals( self._http, - max_price=self.config.max_price_usd, - min_rating=self.config.min_deal_rating, - min_discount=self.config.min_discount_percent, + 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) @@ -81,8 +84,10 @@ class DealsBot: itad_deals = await fetch_itad_deals( self._http, self.config.itad_api_key, - max_price=self.config.max_price_usd, - min_discount=self.config.min_discount_percent, + 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) @@ -106,9 +111,9 @@ class DealsBot: logger.info("Checking CheapShark for deals...") deals = await fetch_cheapshark_deals( self._http, - max_price=self.config.max_price_usd, - min_rating=self.config.min_deal_rating, - min_discount=self.config.min_discount_percent, + 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: @@ -131,8 +136,10 @@ class DealsBot: deals = await fetch_itad_deals( self._http, self.config.itad_api_key, - max_price=self.config.max_price_usd, - min_discount=self.config.min_discount_percent, + 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: diff --git a/gaming_deals_bot/config.py b/gaming_deals_bot/config.py index a571843..c65947e 100644 --- a/gaming_deals_bot/config.py +++ b/gaming_deals_bot/config.py @@ -20,10 +20,47 @@ class Config: s.strip().lower() for s in raw_sources.split(",") if s.strip() ] - # Deal filtering - self.min_deal_rating = float(os.environ.get("MIN_DEAL_RATING", "8.0")) - self.min_discount_percent = int(os.environ.get("MIN_DISCOUNT_PERCENT", "50")) - self.max_price_usd = float(os.environ.get("MAX_PRICE_USD", "20")) + # 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", "100")) # Intro message on startup self.send_intro_message = os.environ.get( diff --git a/gaming_deals_bot/currency.py b/gaming_deals_bot/currency.py index b43acd4..f6babbf 100644 --- a/gaming_deals_bot/currency.py +++ b/gaming_deals_bot/currency.py @@ -1,6 +1,7 @@ """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 @@ -11,13 +12,72 @@ import httpx logger = logging.getLogger(__name__) FRANKFURTER_URL = "https://api.frankfurter.dev/v1/latest" -TARGET_CURRENCIES = ("CAD", "EUR", "GBP") CACHE_TTL_SECONDS = 43200 # 12 hours — be nice to the free service -# Module-level cache +# 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. @@ -26,7 +86,13 @@ async def refresh_rates(client: httpx.AsyncClient) -> bool: """ global _rates, _last_fetched - symbols = ",".join(TARGET_CURRENCIES) + 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, @@ -51,42 +117,69 @@ async def refresh_rates(client: httpx.AsyncClient) -> bool: async def _ensure_rates(client: httpx.AsyncClient) -> None: """Refresh rates if the cache is stale or empty.""" - if not _rates or (time.monotonic() - _last_fetched) > CACHE_TTL_SECONDS: + if _all_target_currencies() and ( + not _rates or (time.monotonic() - _last_fetched) > CACHE_TTL_SECONDS + ): await refresh_rates(client) -def _convert(usd_amount: float, currency: str) -> float | None: - """Convert a USD amount to the target currency using cached rates.""" +# --------------------------------------------------------------------------- +# 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) -# Currency display symbols -_SYMBOLS = { - "USD": "$", - "CAD": "C$", - "EUR": "€", - "GBP": "£", -} +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. - Example: ``$14.99 · C$20.54 · €13.78 · £11.98`` + 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. """ - parts = [f"${usd_amount:.2f}"] + display_order = [_default_currency] + [ + c for c in _extra_currencies if c != _default_currency + ] - for cur in TARGET_CURRENCIES: - converted = _convert(usd_amount, cur) + parts: list[str] = [] + for cur in display_order: + converted = _convert_from_usd(usd_amount, cur) if converted is not None: - symbol = _SYMBOLS.get(cur, cur) + 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) diff --git a/gaming_deals_bot/itad_deals.py b/gaming_deals_bot/itad_deals.py index c9bde98..37d1e7f 100644 --- a/gaming_deals_bot/itad_deals.py +++ b/gaming_deals_bot/itad_deals.py @@ -5,6 +5,7 @@ from dataclasses import dataclass import httpx +from .currency import convert_to_usd from .itad import BASE_URL logger = logging.getLogger(__name__) @@ -18,8 +19,8 @@ class ITADDeal: game_id: str # ITAD UUID slug: str title: str - sale_price: float # current deal price - normal_price: float # regular (non-sale) price + 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 @@ -46,19 +47,70 @@ 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 = 20, + limit: int = 100, ) -> list[ITADDeal]: - """Fetch current deals from IsThereAnyDeal. + """Fetch current deals from IsThereAnyDeal across one or more countries. - Uses GET /deals/v2 with sorting by highest 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 = 100, +) -> 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", @@ -69,11 +121,13 @@ async def fetch_deals( resp.raise_for_status() data = resp.json() except (httpx.HTTPError, ValueError) as exc: - logger.error("ITAD deals API error: %s", 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 (before filtering)", len(raw_list)) + logger.info( + "ITAD returned %d raw deals for %s (before filtering)", len(raw_list), country + ) deals: list[ITADDeal] = [] for entry in raw_list: @@ -94,15 +148,22 @@ async def fetch_deals( regular_amount = deal_data.get("regular", {}).get("amount", 0) currency = deal_data.get("price", {}).get("currency", "USD") - # Apply filters + # 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) + "Filtered out %s: discount %d%% < %d%%", + title, + cut, + int(min_discount), ) continue - if price_amount > max_price: + if price_usd > max_price: logger.debug( - "Filtered out %s: price %.2f > %.2f", title, price_amount, max_price + "Filtered out %s: price $%.2f > $%.2f", title, price_usd, max_price ) continue @@ -116,10 +177,10 @@ async def fetch_deals( game_id=entry.get("id", ""), slug=entry.get("slug", ""), title=title, - sale_price=price_amount, - normal_price=regular_amount, + sale_price=price_usd, + normal_price=regular_usd, discount=cut, - currency=currency, + currency="USD", # normalised shop_name=shop.get("name", "Unknown"), shop_id=shop.get("id", 0), url=deal_data.get("url", ""), @@ -129,5 +190,7 @@ async def fetch_deals( ) ) - logger.info("ITAD returned %d deals after filtering", len(deals)) + logger.info( + "ITAD returned %d deals for %s after filtering", len(deals), country + ) return deals diff --git a/gaming_deals_bot/preflight.py b/gaming_deals_bot/preflight.py index d173bc0..c8099aa 100644 --- a/gaming_deals_bot/preflight.py +++ b/gaming_deals_bot/preflight.py @@ -8,7 +8,7 @@ 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 .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 @@ -45,6 +45,9 @@ async def run_preflight(config: Config) -> bool: 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}") @@ -63,7 +66,7 @@ async def run_preflight(config: Config) -> bool: # --- Frankfurter (exchange rates) --- print(f"\n{_BOLD}Frankfurter (exchange rates){_RESET}") - all_ok &= await _check_frankfurter(http) + all_ok &= await _check_frankfurter(http, config) # --- IsThereAnyDeal --- itad_required = "itad" in config.deal_sources @@ -153,10 +156,16 @@ async def _check_epic(http: httpx.AsyncClient) -> bool: return _fail("API reachable", str(exc)) -async def _check_frankfurter(http: httpx.AsyncClient) -> bool: +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(TARGET_CURRENCIES) + symbols = ",".join(needed) resp = await http.get(FRANKFURTER_URL, params={"base": "USD", "symbols": symbols}) resp.raise_for_status() rates = resp.json().get("rates", {})