Merge pull request #2 from prosolis/claude/gaming-deals-matrix-bot-ONjS7
Display all the main currencies
This commit is contained in:
@@ -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 twice daily.
|
||||
|
||||
@@ -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")
|
||||
|
||||
96
gaming_deals_bot/currency.py
Normal file
96
gaming_deals_bot/currency.py
Normal file
@@ -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 twice per day.
|
||||
"""
|
||||
|
||||
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 = 43200 # 12 hours — be nice to the free service
|
||||
|
||||
# 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)
|
||||
@@ -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!")
|
||||
|
||||
Reference in New Issue
Block a user