Files
Pastel/gaming_deals_bot/bot.py
Claude 8277cf37e2 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
2026-02-28 00:51:52 +00:00

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
)