diff --git a/README.md b/README.md index d58a108..5fc947a 100644 --- a/README.md +++ b/README.md @@ -49,3 +49,4 @@ All configuration is via environment variables (see `.env.example`): - **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 hourly. diff --git a/gaming_deals_bot/bot.py b/gaming_deals_bot/bot.py index 638555b..99a53b4 100644 --- a/gaming_deals_bot/bot.py +++ b/gaming_deals_bot/bot.py @@ -7,6 +7,7 @@ import httpx from .cheapshark import CheapSharkDeal, fetch_deals from .config import Config +from .currency import refresh_rates from .database import Database from .epic import EpicFreeGame, fetch_free_games from .formatter import format_deal, format_epic_free, format_epic_upcoming @@ -30,9 +31,12 @@ class DealsBot: self._first_run_done = False async def start(self): - """Initialize database and run first-run population.""" + """Initialize database, fetch exchange rates, and run first-run population.""" await self.db.connect() + # 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") diff --git a/gaming_deals_bot/currency.py b/gaming_deals_bot/currency.py new file mode 100644 index 0000000..1a9ad8f --- /dev/null +++ b/gaming_deals_bot/currency.py @@ -0,0 +1,96 @@ +"""Currency conversion using the Frankfurter API (ECB rates, no API key required). + +Rates are cached in memory and refreshed at most once per hour. +""" + +import logging +import time + +import httpx + +logger = logging.getLogger(__name__) + +FRANKFURTER_URL = "https://api.frankfurter.dev/v1/latest" +TARGET_CURRENCIES = ("CAD", "EUR", "GBP") +CACHE_TTL_SECONDS = 3600 # 1 hour + +# Module-level cache +_rates: dict[str, float] = {} +_last_fetched: float = 0.0 + + +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 + + symbols = ",".join(TARGET_CURRENCIES) + 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 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.""" + 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 format_price(usd_amount: float) -> str: + """Return a formatted multi-currency price string. + + Example: ``$14.99 · C$20.54 · €13.78 · £11.98`` + + Falls back to USD-only if rates aren't available. + """ + parts = [f"${usd_amount:.2f}"] + + for cur in TARGET_CURRENCIES: + converted = _convert(usd_amount, cur) + if converted is not None: + symbol = _SYMBOLS.get(cur, cur) + parts.append(f"{symbol}{converted:.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/formatter.py b/gaming_deals_bot/formatter.py index 10f2987..0a5999b 100644 --- a/gaming_deals_bot/formatter.py +++ b/gaming_deals_bot/formatter.py @@ -8,6 +8,7 @@ from datetime import datetime, timezone import markdown from .cheapshark import CheapSharkDeal +from .currency import format_price from .epic import EpicFreeGame _md = markdown.Markdown() @@ -25,9 +26,16 @@ def format_deal(deal: CheapSharkDeal, is_historical_low: bool = False) -> tuple[ 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) + lines = [ f"**🎮 [DEAL] {deal.title}**", - f"> {discount}% off — **${deal.sale_price}** on {deal.store_name} ~~${deal.normal_price}~~", + f"> {discount}% off on {deal.store_name} ~~{normal_display}~~", + f"> 💰 **{sale_multi}**", ] if is_historical_low: @@ -41,7 +49,8 @@ def format_deal(deal: CheapSharkDeal, is_historical_low: bool = False) -> tuple[ # Plain text fallback — strip markdown syntax plain_lines = [ f"🎮 [DEAL] {deal.title}", - f" {discount}% off — ${deal.sale_price} on {deal.store_name} (was ${deal.normal_price})", + f" {discount}% off on {deal.store_name} (was {normal_display})", + f" 💰 {sale_multi}", ] if is_historical_low: plain_lines.append(" 🏆 All-time low!")