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
150 lines
5.4 KiB
Python
150 lines
5.4 KiB
Python
"""Main bot orchestration — ties together API clients, database, and Matrix posting."""
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
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
|
|
from .itad import check_single_historical_low
|
|
from .matrix_client import MatrixDealsClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class DealsBot:
|
|
def __init__(self, config: Config):
|
|
self.config = config
|
|
self.db = Database(config.database_path)
|
|
self.matrix = MatrixDealsClient(
|
|
homeserver_url=config.matrix_homeserver_url,
|
|
user_id=config.matrix_bot_user_id,
|
|
access_token=config.matrix_bot_access_token,
|
|
room_id=config.matrix_deals_room_id,
|
|
)
|
|
self._http = httpx.AsyncClient(timeout=30)
|
|
self._first_run_done = False
|
|
|
|
async def start(self):
|
|
"""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")
|
|
await self._populate_initial_state()
|
|
await self.db.set_config("first_run_done", "true")
|
|
self._first_run_done = True
|
|
else:
|
|
self._first_run_done = True
|
|
|
|
async def stop(self):
|
|
"""Clean shutdown."""
|
|
await self._http.aclose()
|
|
await self.matrix.close()
|
|
await self.db.close()
|
|
|
|
async def _populate_initial_state(self):
|
|
"""Fetch current deals and record them without posting (avoids spam on first run)."""
|
|
deals = await fetch_deals(
|
|
self._http,
|
|
max_price=self.config.max_price_usd,
|
|
min_rating=self.config.min_deal_rating,
|
|
min_discount=self.config.min_discount_percent,
|
|
)
|
|
for deal in deals:
|
|
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
|
|
|
|
current_free, upcoming = await fetch_free_games(self._http)
|
|
for game in current_free:
|
|
await self.db.mark_posted(game.dedup_id, "epic", game.title)
|
|
for game in upcoming:
|
|
await self.db.mark_posted(game.dedup_id, "epic", game.title)
|
|
|
|
total = len(deals) + len(current_free) + len(upcoming)
|
|
logger.info("First run: recorded %d existing deals/games", total)
|
|
|
|
async def check_cheapshark(self):
|
|
"""Poll CheapShark for deals and post new ones."""
|
|
if not self._first_run_done:
|
|
return
|
|
|
|
logger.info("Checking CheapShark for deals...")
|
|
deals = await fetch_deals(
|
|
self._http,
|
|
max_price=self.config.max_price_usd,
|
|
min_rating=self.config.min_deal_rating,
|
|
min_discount=self.config.min_discount_percent,
|
|
)
|
|
|
|
for deal in deals:
|
|
await self._process_deal(deal)
|
|
|
|
# Prune old records
|
|
await self.db.prune_old(days=30)
|
|
|
|
async def _process_deal(self, deal: CheapSharkDeal):
|
|
"""Check dedup, check historical low, format, and post a single deal."""
|
|
if await self.db.has_been_posted(deal.dedup_id):
|
|
return
|
|
|
|
# Check if this is a historical low via ITAD
|
|
is_historical_low = False
|
|
if self.config.itad_api_key and deal.steam_app_id:
|
|
is_historical_low = await check_single_historical_low(
|
|
self._http,
|
|
self.config.itad_api_key,
|
|
deal.steam_app_id,
|
|
)
|
|
|
|
plain_text, html = format_deal(deal, is_historical_low)
|
|
success = await self.matrix.send_deal(plain_text, html)
|
|
|
|
if success:
|
|
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
|
|
logger.info("Posted deal: %s", deal.title)
|
|
else:
|
|
logger.warning("Failed to post deal: %s — will retry next cycle", deal.title)
|
|
|
|
async def check_epic_free_games(self):
|
|
"""Poll Epic Games Store for free games and post new ones."""
|
|
if not self._first_run_done:
|
|
return
|
|
|
|
logger.info("Checking Epic Games Store for free games...")
|
|
current_free, upcoming = await fetch_free_games(self._http)
|
|
|
|
for game in current_free:
|
|
await self._process_epic_game(game, is_upcoming=False)
|
|
|
|
for game in upcoming:
|
|
await self._process_epic_game(game, is_upcoming=True)
|
|
|
|
async def _process_epic_game(self, game: EpicFreeGame, *, is_upcoming: bool):
|
|
"""Check dedup, format, and post a single Epic free game."""
|
|
if await self.db.has_been_posted(game.dedup_id):
|
|
return
|
|
|
|
if is_upcoming:
|
|
plain_text, html = format_epic_upcoming(game)
|
|
else:
|
|
plain_text, html = format_epic_free(game)
|
|
|
|
success = await self.matrix.send_deal(plain_text, html)
|
|
|
|
if success:
|
|
await self.db.mark_posted(game.dedup_id, "epic", game.title)
|
|
logger.info("Posted Epic game: %s (upcoming=%s)", game.title, is_upcoming)
|
|
else:
|
|
logger.warning(
|
|
"Failed to post Epic game: %s — will retry next cycle", game.title
|
|
)
|