Implements a Matrix bot that posts PC gaming deals and free game alerts: - CheapShark API integration (Steam, GOG, Humble, GMG) polled every 2 hours - Epic Games Store free games detection polled daily - IsThereAnyDeal historical low price flagging (optional) - SQLite deduplication with 30-day pruning - First-run population (records existing deals without posting) - Matrix-flavored HTML + plain text fallback messages - Dockerfile for containerized deployment https://claude.ai/code/session_01LPpSZFfyh6vdV5HGFWjoQX
146 lines
5.2 KiB
Python
146 lines
5.2 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 .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 and run first-run population."""
|
|
await self.db.connect()
|
|
|
|
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
|
|
)
|