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
|
- **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
|
- **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
|
- **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 .cheapshark import CheapSharkDeal, fetch_deals
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
from .currency import refresh_rates
|
||||||
from .database import Database
|
from .database import Database
|
||||||
from .epic import EpicFreeGame, fetch_free_games
|
from .epic import EpicFreeGame, fetch_free_games
|
||||||
from .formatter import format_deal, format_epic_free, format_epic_upcoming
|
from .formatter import format_deal, format_epic_free, format_epic_upcoming
|
||||||
@@ -30,9 +31,12 @@ class DealsBot:
|
|||||||
self._first_run_done = False
|
self._first_run_done = False
|
||||||
|
|
||||||
async def start(self):
|
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()
|
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")
|
first_run = await self.db.get_config("first_run_done")
|
||||||
if first_run != "true":
|
if first_run != "true":
|
||||||
logger.info("First run detected — populating database without posting")
|
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
|
import markdown
|
||||||
|
|
||||||
from .cheapshark import CheapSharkDeal
|
from .cheapshark import CheapSharkDeal
|
||||||
|
from .currency import format_price
|
||||||
from .epic import EpicFreeGame
|
from .epic import EpicFreeGame
|
||||||
|
|
||||||
_md = markdown.Markdown()
|
_md = markdown.Markdown()
|
||||||
@@ -25,9 +26,16 @@ def format_deal(deal: CheapSharkDeal, is_historical_low: bool = False) -> tuple[
|
|||||||
Returns (body, formatted_body).
|
Returns (body, formatted_body).
|
||||||
"""
|
"""
|
||||||
discount = int(deal.savings)
|
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 = [
|
lines = [
|
||||||
f"**🎮 [DEAL] {deal.title}**",
|
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:
|
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 text fallback — strip markdown syntax
|
||||||
plain_lines = [
|
plain_lines = [
|
||||||
f"🎮 [DEAL] {deal.title}",
|
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:
|
if is_historical_low:
|
||||||
plain_lines.append(" 🏆 All-time low!")
|
plain_lines.append(" 🏆 All-time low!")
|
||||||
|
|||||||
Reference in New Issue
Block a user