From 8277cf37e2782ace01d3b7354f8dbe3d10444e65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 00:51:52 +0000 Subject: [PATCH 1/2] Add multi-currency pricing (USD, CAD, EUR, GBP) for deal posts Fetches live exchange rates from the Frankfurter API (ECB data, free, no API key) and displays converted prices alongside USD in every deal message. Rates are cached in memory and auto-refreshed hourly. https://claude.ai/code/session_01LPpSZFfyh6vdV5HGFWjoQX --- README.md | 1 + gaming_deals_bot/bot.py | 6 ++- gaming_deals_bot/currency.py | 96 +++++++++++++++++++++++++++++++++++ gaming_deals_bot/formatter.py | 13 ++++- 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 gaming_deals_bot/currency.py 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!") From 6fdbc9f9a01aceed0dbf184ef35f5052d9b8846b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 00:56:21 +0000 Subject: [PATCH 2/2] Reduce exchange rate refresh to twice daily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Be respectful of the free Frankfurter API — ECB rates only update once a day anyway, so a 12-hour cache TTL is more than sufficient. https://claude.ai/code/session_01LPpSZFfyh6vdV5HGFWjoQX --- README.md | 2 +- gaming_deals_bot/currency.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5fc947a..58fc7f5 100644 --- a/README.md +++ b/README.md @@ -49,4 +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. +- **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. diff --git a/gaming_deals_bot/currency.py b/gaming_deals_bot/currency.py index 1a9ad8f..b43acd4 100644 --- a/gaming_deals_bot/currency.py +++ b/gaming_deals_bot/currency.py @@ -1,6 +1,6 @@ """Currency conversion using the Frankfurter API (ECB rates, no API key required). -Rates are cached in memory and refreshed at most once per hour. +Rates are cached in memory and refreshed at most twice per day. """ import logging @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) FRANKFURTER_URL = "https://api.frankfurter.dev/v1/latest" TARGET_CURRENCIES = ("CAD", "EUR", "GBP") -CACHE_TTL_SECONDS = 3600 # 1 hour +CACHE_TTL_SECONDS = 43200 # 12 hours — be nice to the free service # Module-level cache _rates: dict[str, float] = {}