Files
Pastel/gaming_deals_bot/bot.py
Claude 0fd58c32b4 Add optional intro message on bot startup
When SEND_INTRO_MESSAGE=true, the bot sends "The deals must flow." as
an m.notice to the Matrix room right after startup, before the first
scheduled deal checks. Defaults to off.

https://claude.ai/code/session_017eMsVwUopgmnEyd6JJedpV
2026-02-28 03:01:20 +00:00

156 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 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 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
)