Files
Pastel/gaming_deals_bot/bot.py
Claude 0302cea523 Post Epic free games on first run instead of silently recording them
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
2026-02-28 03:32:25 +00:00

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
)