On first startup, _populate_initial_state() was marking current Epic free games as "posted" without actually sending them to the room. This meant users had to wait until the next cycle of new games before seeing anything. Now only CheapShark deals are silently recorded on first run (there can be dozens). Epic free games are left for check_epic_free_games() to pick up and post immediately, since they are few in number and time-limited. https://claude.ai/code/session_017eMsVwUopgmnEyd6JJedpV
155 lines
5.6 KiB
Python
155 lines
5.6 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 CheapShark deals and record them without posting (avoids spam on first run).
|
|
|
|
Epic free games are intentionally *not* recorded here so that the
|
|
subsequent ``check_epic_free_games`` call will post them. There are
|
|
only a handful at any time and they are time-limited, so users should
|
|
see them immediately rather than waiting for the next cycle.
|
|
"""
|
|
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)
|
|
|
|
logger.info("First run: recorded %d existing CheapShark deals", len(deals))
|
|
|
|
async def send_intro(self):
|
|
"""Send an intro message to the Matrix room if configured."""
|
|
if not self.config.send_intro_message:
|
|
return
|
|
await self.matrix.send_notice("The deals must flow.")
|
|
|
|
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
|
|
)
|