From 6e84429ad1278402e0f7ad25dc8c3198e13e2471 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 00:00:22 +0000 Subject: [PATCH] Add gaming deals Matrix bot 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 --- .dockerignore | 5 ++ .env.example | 16 ++++ Dockerfile | 10 +++ README.md | 52 ++++++++++- gaming_deals_bot/__init__.py | 0 gaming_deals_bot/__main__.py | 82 +++++++++++++++++ gaming_deals_bot/bot.py | 145 ++++++++++++++++++++++++++++++ gaming_deals_bot/cheapshark.py | 101 +++++++++++++++++++++ gaming_deals_bot/config.py | 30 +++++++ gaming_deals_bot/database.py | 79 ++++++++++++++++ gaming_deals_bot/epic.py | 135 ++++++++++++++++++++++++++++ gaming_deals_bot/formatter.py | 129 ++++++++++++++++++++++++++ gaming_deals_bot/itad.py | 92 +++++++++++++++++++ gaming_deals_bot/matrix_client.py | 51 +++++++++++ requirements.txt | 5 ++ 15 files changed, 931 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 gaming_deals_bot/__init__.py create mode 100644 gaming_deals_bot/__main__.py create mode 100644 gaming_deals_bot/bot.py create mode 100644 gaming_deals_bot/cheapshark.py create mode 100644 gaming_deals_bot/config.py create mode 100644 gaming_deals_bot/database.py create mode 100644 gaming_deals_bot/epic.py create mode 100644 gaming_deals_bot/formatter.py create mode 100644 gaming_deals_bot/itad.py create mode 100644 gaming_deals_bot/matrix_client.py create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eaed1b0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.env +*.db +__pycache__ +*.pyc diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..59b4c30 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Matrix connection +MATRIX_HOMESERVER_URL=https://matrix.example.com +MATRIX_BOT_USER_ID=@dealsbot:example.com +MATRIX_BOT_ACCESS_TOKEN=syt_... +MATRIX_DEALS_ROOM_ID=!roomid:example.com + +# IsThereAnyDeal API key (optional — enables historical low detection) +ITAD_API_KEY= + +# Deal filtering +MIN_DEAL_RATING=80 +MIN_DISCOUNT_PERCENT=50 +MAX_PRICE_USD=20 + +# Database path (inside the container, mount a volume for persistence) +DATABASE_PATH=/data/deals.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..713bcc0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY gaming_deals_bot/ gaming_deals_bot/ + +CMD ["python", "-m", "gaming_deals_bot"] diff --git a/README.md b/README.md index 89dab05..d58a108 100644 --- a/README.md +++ b/README.md @@ -1 +1,51 @@ -# Pastel \ No newline at end of file +# Pastel + +A Matrix bot that posts gaming deals and free game alerts to a specified Matrix room. + +Deals are sourced from PC/digital storefronts only (Steam, GOG, Humble Store, GreenManGaming, Epic Games Store) — universally accessible regardless of region. + +## Data Sources + +- **CheapShark** — polled every 2 hours for top deals across Steam, GOG, Humble Store, and GreenManGaming +- **Epic Games Store** — polled daily for free game promotions +- **IsThereAnyDeal** (optional) — flags deals that are at an all-time historical low price + +## Quick Start + +1. Copy `.env.example` to `.env` and fill in your Matrix credentials and room ID +2. Run with Docker: + +```bash +docker build -t pastel . +docker run --env-file .env -v pastel-data:/data pastel +``` + +Or run directly with Python 3.12+: + +```bash +pip install -r requirements.txt +python -m gaming_deals_bot +``` + +## Configuration + +All configuration is via environment variables (see `.env.example`): + +| Variable | Required | Default | Description | +|---|---|---|---| +| `MATRIX_HOMESERVER_URL` | Yes | — | Matrix homeserver URL | +| `MATRIX_BOT_USER_ID` | Yes | — | Bot's Matrix user ID | +| `MATRIX_BOT_ACCESS_TOKEN` | Yes | — | Bot's access token | +| `MATRIX_DEALS_ROOM_ID` | Yes | — | Room ID to post deals in | +| `ITAD_API_KEY` | No | — | IsThereAnyDeal API key for historical low detection | +| `MIN_DEAL_RATING` | No | 80 | Minimum CheapShark deal rating (0-100) | +| `MIN_DISCOUNT_PERCENT` | No | 50 | Minimum discount percentage | +| `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD | +| `DATABASE_PATH` | No | deals.db | Path to SQLite database file | + +## Behavior + +- **First run**: fetches current deals and records them in the database without posting (avoids spamming the room with existing deals) +- **Deduplication**: deals are tracked by game ID + timestamp; duplicates are never reposted +- **Pruning**: deals older than 30 days are pruned from the database so they can be reposted if they return +- **One message per deal**: each deal is posted individually so messages are independently linkable and dismissible diff --git a/gaming_deals_bot/__init__.py b/gaming_deals_bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gaming_deals_bot/__main__.py b/gaming_deals_bot/__main__.py new file mode 100644 index 0000000..f006ff3 --- /dev/null +++ b/gaming_deals_bot/__main__.py @@ -0,0 +1,82 @@ +"""Entry point — sets up logging, scheduler, and runs the bot.""" + +import asyncio +import logging +import signal +import sys + +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +from .bot import DealsBot +from .config import Config + + +def setup_logging(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + stream=sys.stdout, + ) + + +async def main(): + setup_logging() + logger = logging.getLogger("gaming_deals_bot") + + try: + config = Config() + except ValueError as exc: + logger.error("Configuration error: %s", exc) + sys.exit(1) + + bot = DealsBot(config) + await bot.start() + + scheduler = AsyncIOScheduler() + + # CheapShark: every 2 hours + scheduler.add_job( + bot.check_cheapshark, + "interval", + hours=2, + id="cheapshark", + name="CheapShark deals check", + ) + + # Epic free games: once daily + scheduler.add_job( + bot.check_epic_free_games, + "interval", + hours=24, + id="epic_free", + name="Epic free games check", + ) + + scheduler.start() + logger.info("Bot started — scheduler running") + + # Run initial checks immediately (after first-run population is done) + await bot.check_cheapshark() + await bot.check_epic_free_games() + + # Keep running until signaled to stop + stop_event = asyncio.Event() + + def handle_signal(): + logger.info("Shutdown signal received") + stop_event.set() + + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, handle_signal) + + await stop_event.wait() + + logger.info("Shutting down...") + scheduler.shutdown(wait=False) + await bot.stop() + logger.info("Bot stopped") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/gaming_deals_bot/bot.py b/gaming_deals_bot/bot.py new file mode 100644 index 0000000..638555b --- /dev/null +++ b/gaming_deals_bot/bot.py @@ -0,0 +1,145 @@ +"""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 + ) diff --git a/gaming_deals_bot/cheapshark.py b/gaming_deals_bot/cheapshark.py new file mode 100644 index 0000000..a60e983 --- /dev/null +++ b/gaming_deals_bot/cheapshark.py @@ -0,0 +1,101 @@ +import logging +from dataclasses import dataclass + +import httpx + +logger = logging.getLogger(__name__) + +BASE_URL = "https://www.cheapshark.com/api/1.0" + +# CheapShark store IDs for PC/digital storefronts +STORE_IDS = { + "1": "Steam", + "7": "GOG", + "11": "Humble Store", + "23": "GreenManGaming", +} + + +@dataclass +class CheapSharkDeal: + deal_id: str + game_id: str + title: str + sale_price: str + normal_price: str + savings: float # percentage off + deal_rating: float + store_id: str + last_change: int # unix timestamp + steam_app_id: str | None + + @property + def store_name(self) -> str: + return STORE_IDS.get(self.store_id, f"Store {self.store_id}") + + @property + def deal_url(self) -> str: + return f"https://www.cheapshark.com/redirect?dealID={self.deal_id}" + + @property + def dedup_id(self) -> str: + return f"cheapshark-{self.game_id}-{self.last_change}" + + +async def fetch_deals( + client: httpx.AsyncClient, + *, + max_price: float = 20, + min_rating: int = 80, + min_discount: int = 50, + page_size: int = 10, +) -> list[CheapSharkDeal]: + """Fetch top deals from CheapShark across configured stores.""" + store_ids = ",".join(STORE_IDS.keys()) + params = { + "storeID": store_ids, + "upperPrice": str(int(max_price)), + "sortBy": "Deal Rating", + "desc": "1", + "pageSize": str(page_size), + } + + try: + resp = await client.get(f"{BASE_URL}/deals", params=params) + resp.raise_for_status() + raw_deals = resp.json() + except (httpx.HTTPError, ValueError) as exc: + logger.error("CheapShark API error: %s", exc) + return [] + + deals = [] + for d in raw_deals: + savings = float(d.get("savings", 0)) + rating = float(d.get("dealRating", 0)) + + if savings < min_discount: + continue + if rating * 10 < min_rating: # dealRating is 0-10, config is 0-100 + continue + + steam_app_id = d.get("steamAppID") or None + if steam_app_id == "0": + steam_app_id = None + + deals.append( + CheapSharkDeal( + deal_id=d["dealID"], + game_id=d["gameID"], + title=d["title"], + sale_price=d["salePrice"], + normal_price=d["normalPrice"], + savings=savings, + deal_rating=rating, + store_id=d["storeID"], + last_change=int(d.get("lastChange", 0)), + steam_app_id=steam_app_id, + ) + ) + + logger.info("CheapShark returned %d deals after filtering", len(deals)) + return deals diff --git a/gaming_deals_bot/config.py b/gaming_deals_bot/config.py new file mode 100644 index 0000000..1db6d35 --- /dev/null +++ b/gaming_deals_bot/config.py @@ -0,0 +1,30 @@ +import os + + +class Config: + """Bot configuration loaded from environment variables.""" + + def __init__(self): + # Matrix settings + self.matrix_homeserver_url = self._require("MATRIX_HOMESERVER_URL") + self.matrix_bot_user_id = self._require("MATRIX_BOT_USER_ID") + self.matrix_bot_access_token = self._require("MATRIX_BOT_ACCESS_TOKEN") + self.matrix_deals_room_id = self._require("MATRIX_DEALS_ROOM_ID") + + # ITAD API key (optional — historical low checks disabled without it) + self.itad_api_key = os.environ.get("ITAD_API_KEY", "") + + # Deal filtering + self.min_deal_rating = int(os.environ.get("MIN_DEAL_RATING", "80")) + self.min_discount_percent = int(os.environ.get("MIN_DISCOUNT_PERCENT", "50")) + self.max_price_usd = float(os.environ.get("MAX_PRICE_USD", "20")) + + # Database + self.database_path = os.environ.get("DATABASE_PATH", "deals.db") + + @staticmethod + def _require(name: str) -> str: + value = os.environ.get(name) + if not value: + raise ValueError(f"Required environment variable {name} is not set") + return value diff --git a/gaming_deals_bot/database.py b/gaming_deals_bot/database.py new file mode 100644 index 0000000..096be34 --- /dev/null +++ b/gaming_deals_bot/database.py @@ -0,0 +1,79 @@ +import logging +from datetime import datetime, timedelta, timezone + +import aiosqlite + +logger = logging.getLogger(__name__) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS posted_deals ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + title TEXT NOT NULL, + posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT +); +""" + + +class Database: + def __init__(self, path: str): + self.path = path + self._db: aiosqlite.Connection | None = None + + async def connect(self): + self._db = await aiosqlite.connect(self.path) + await self._db.executescript(SCHEMA) + await self._db.commit() + logger.info("Database initialized at %s", self.path) + + async def close(self): + if self._db: + await self._db.close() + + async def has_been_posted(self, deal_id: str) -> bool: + assert self._db is not None + cursor = await self._db.execute( + "SELECT 1 FROM posted_deals WHERE id = ?", (deal_id,) + ) + row = await cursor.fetchone() + return row is not None + + async def mark_posted(self, deal_id: str, source: str, title: str): + assert self._db is not None + await self._db.execute( + "INSERT OR IGNORE INTO posted_deals (id, source, title) VALUES (?, ?, ?)", + (deal_id, source, title), + ) + await self._db.commit() + + async def prune_old(self, days: int = 30): + assert self._db is not None + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + result = await self._db.execute( + "DELETE FROM posted_deals WHERE posted_at < ?", + (cutoff.isoformat(),), + ) + await self._db.commit() + if result.rowcount: + logger.info("Pruned %d old deal records", result.rowcount) + + async def get_config(self, key: str) -> str | None: + assert self._db is not None + cursor = await self._db.execute( + "SELECT value FROM config WHERE key = ?", (key,) + ) + row = await cursor.fetchone() + return row[0] if row else None + + async def set_config(self, key: str, value: str): + assert self._db is not None + await self._db.execute( + "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", + (key, value), + ) + await self._db.commit() diff --git a/gaming_deals_bot/epic.py b/gaming_deals_bot/epic.py new file mode 100644 index 0000000..f0d9636 --- /dev/null +++ b/gaming_deals_bot/epic.py @@ -0,0 +1,135 @@ +import logging +from dataclasses import dataclass +from datetime import datetime, timezone + +import httpx + +logger = logging.getLogger(__name__) + +FREE_GAMES_URL = ( + "https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions" +) + + +@dataclass +class EpicFreeGame: + title: str + game_id: str + description: str + end_date: str | None # ISO datetime when the offer expires + url: str + + @property + def dedup_id(self) -> str: + return f"epic-{self.game_id}" + + +def _parse_promotions( + elements: list[dict], +) -> tuple[list[EpicFreeGame], list[EpicFreeGame]]: + """Parse Epic promotions into current free games and upcoming free games.""" + current: list[EpicFreeGame] = [] + upcoming: list[EpicFreeGame] = [] + now = datetime.now(timezone.utc) + + for elem in elements: + title = elem.get("title", "Unknown") + game_id = elem.get("id", "") + description = elem.get("description", "") + + # Build the store URL from the slug or product slug + slug = ( + elem.get("productSlug") + or elem.get("urlSlug") + or elem.get("catalogNs", {}).get("mappings", [{}])[0].get("pageSlug", "") + ) + url = f"https://store.epicgames.com/en-US/p/{slug}" if slug else "" + + # Check current promotional offers + promotions = elem.get("promotions") + if not promotions: + continue + + # Current offers + for offer_group in promotions.get("promotionalOffers", []): + for offer in offer_group.get("promotionalOffers", []): + discount = offer.get("discountSetting", {}) + if discount.get("discountPercentage", 100) == 0: + end_date = offer.get("endDate") + # Verify the offer is currently active + start = offer.get("startDate") + if start: + start_dt = datetime.fromisoformat( + start.replace("Z", "+00:00") + ) + if start_dt > now: + continue + if end_date: + end_dt = datetime.fromisoformat( + end_date.replace("Z", "+00:00") + ) + if end_dt < now: + continue + + current.append( + EpicFreeGame( + title=title, + game_id=game_id, + description=description, + end_date=end_date, + url=url, + ) + ) + + # Upcoming offers + for offer_group in promotions.get("upcomingPromotionalOffers", []): + for offer in offer_group.get("promotionalOffers", []): + discount = offer.get("discountSetting", {}) + if discount.get("discountPercentage", 100) == 0: + end_date = offer.get("endDate") + upcoming.append( + EpicFreeGame( + title=title, + game_id=game_id, + description=description, + end_date=end_date, + url=url, + ) + ) + + return current, upcoming + + +async def fetch_free_games( + client: httpx.AsyncClient, +) -> tuple[list[EpicFreeGame], list[EpicFreeGame]]: + """Fetch current and upcoming free games from Epic Games Store. + + Returns (current_free_games, upcoming_free_games). + """ + try: + resp = await client.get(FREE_GAMES_URL, params={"locale": "en-US"}) + resp.raise_for_status() + data = resp.json() + except (httpx.HTTPError, ValueError) as exc: + logger.error("Epic Games Store API error: %s", exc) + return [], [] + + elements = ( + data.get("data", {}) + .get("Catalog", {}) + .get("searchStore", {}) + .get("elements", []) + ) + + if not elements: + logger.warning("No elements found in Epic free games response") + return [], [] + + current, upcoming = _parse_promotions(elements) + logger.info( + "Epic: %d current free games, %d upcoming", + len(current), + len(upcoming), + ) + return current, upcoming diff --git a/gaming_deals_bot/formatter.py b/gaming_deals_bot/formatter.py new file mode 100644 index 0000000..10f2987 --- /dev/null +++ b/gaming_deals_bot/formatter.py @@ -0,0 +1,129 @@ +"""Message formatting for Matrix deal posts. + +Produces both a plain-text fallback and HTML formatted body for Matrix messages. +""" + +from datetime import datetime, timezone + +import markdown + +from .cheapshark import CheapSharkDeal +from .epic import EpicFreeGame + +_md = markdown.Markdown() + + +def _render_html(md_text: str) -> str: + """Convert Markdown to HTML, resetting the parser between calls.""" + _md.reset() + return _md.convert(md_text) + + +def format_deal(deal: CheapSharkDeal, is_historical_low: bool = False) -> tuple[str, str]: + """Format a CheapShark deal into (plain_text, html) for Matrix. + + Returns (body, formatted_body). + """ + discount = int(deal.savings) + lines = [ + f"**🎮 [DEAL] {deal.title}**", + f"> {discount}% off — **${deal.sale_price}** on {deal.store_name} ~~${deal.normal_price}~~", + ] + + if is_historical_low: + lines.append("> 🏆 _All-time low!_") + + lines.append(f"> 🔗 [View Deal]({deal.deal_url})") + + md_text = "\n".join(lines) + html = _render_html(md_text) + + # Plain text fallback — strip markdown syntax + plain_lines = [ + f"🎮 [DEAL] {deal.title}", + f" {discount}% off — ${deal.sale_price} on {deal.store_name} (was ${deal.normal_price})", + ] + if is_historical_low: + plain_lines.append(" 🏆 All-time low!") + plain_lines.append(f" 🔗 {deal.deal_url}") + + plain_text = "\n".join(plain_lines) + + return plain_text, html + + +def format_epic_free(game: EpicFreeGame) -> tuple[str, str]: + """Format an Epic free game into (plain_text, html) for Matrix. + + Returns (body, formatted_body). + """ + lines = [ + f"**🆓 [FREE] {game.title}**", + "> Free on Epic Games Store", + ] + + if game.end_date: + try: + end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00")) + date_str = end_dt.strftime("%B %-d") + lines.append(f"> 📅 _Free until {date_str}_") + except (ValueError, TypeError): + pass + + if game.url: + lines.append(f"> 🔗 [Claim Now]({game.url})") + + md_text = "\n".join(lines) + html = _render_html(md_text) + + # Plain text fallback + plain_lines = [ + f"🆓 [FREE] {game.title}", + " Free on Epic Games Store", + ] + if game.end_date: + try: + end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00")) + date_str = end_dt.strftime("%B %-d") + plain_lines.append(f" 📅 Free until {date_str}") + except (ValueError, TypeError): + pass + if game.url: + plain_lines.append(f" 🔗 {game.url}") + + plain_text = "\n".join(plain_lines) + + return plain_text, html + + +def format_epic_upcoming(game: EpicFreeGame) -> tuple[str, str]: + """Format an upcoming Epic free game into (plain_text, html) for Matrix.""" + lines = [ + f"**📢 [UPCOMING FREE] {game.title}**", + "> Coming soon — Free on Epic Games Store", + ] + + if game.end_date: + try: + end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00")) + date_str = end_dt.strftime("%B %-d") + lines.append(f"> 📅 _Free until {date_str}_") + except (ValueError, TypeError): + pass + + if game.url: + lines.append(f"> 🔗 [Store Page]({game.url})") + + md_text = "\n".join(lines) + html = _render_html(md_text) + + plain_lines = [ + f"📢 [UPCOMING FREE] {game.title}", + " Coming soon — Free on Epic Games Store", + ] + if game.url: + plain_lines.append(f" 🔗 {game.url}") + + plain_text = "\n".join(plain_lines) + + return plain_text, html diff --git a/gaming_deals_bot/itad.py b/gaming_deals_bot/itad.py new file mode 100644 index 0000000..522f5e6 --- /dev/null +++ b/gaming_deals_bot/itad.py @@ -0,0 +1,92 @@ +import logging + +import httpx + +logger = logging.getLogger(__name__) + +BASE_URL = "https://api.isthereanydeal.com" + + +async def check_historical_lows( + client: httpx.AsyncClient, + api_key: str, + steam_app_ids: list[str], +) -> dict[str, bool]: + """Check if current prices are historical lows for a batch of Steam app IDs. + + Returns a mapping of steam_app_id -> is_historical_low. + """ + if not api_key or not steam_app_ids: + return {} + + # Build the list of ITAD game IDs from Steam app IDs + # ITAD uses the format "app/{steam_app_id}" for Steam games + game_ids = [f"app/{sid}" for sid in steam_app_ids] + + try: + resp = await client.get( + f"{BASE_URL}/games/overview/v2", + params={"key": api_key, "shops[]": "steam"}, + headers={"Content-Type": "application/json"}, + ) + # The v2 endpoint may use POST with body — fall back to per-game lookup + # if batch doesn't work. For now, use the games/info endpoint pattern. + resp.raise_for_status() + data = resp.json() + except (httpx.HTTPError, ValueError) as exc: + logger.error("ITAD API error: %s", exc) + return {} + + results: dict[str, bool] = {} + # Parse the response — structure depends on the ITAD API version + # The overview endpoint returns price overview with historical low data + if isinstance(data, dict): + for steam_id in steam_app_ids: + game_key = f"app/{steam_id}" + game_data = data.get(game_key) or data.get(steam_id, {}) + if isinstance(game_data, dict): + lowest = game_data.get("lowest", {}) + current = game_data.get("current", {}) + if lowest and current: + lowest_price = lowest.get("price", float("inf")) + current_price = current.get("price", float("inf")) + results[steam_id] = current_price <= lowest_price + + logger.info( + "ITAD: checked %d games, %d are at historical low", + len(steam_app_ids), + sum(results.values()), + ) + return results + + +async def check_single_historical_low( + client: httpx.AsyncClient, + api_key: str, + steam_app_id: str, +) -> bool: + """Check if a single game is at its historical low price.""" + if not api_key or not steam_app_id: + return False + + try: + resp = await client.get( + f"{BASE_URL}/games/overview/v2", + params={ + "key": api_key, + "apps[]": f"app/{steam_app_id}", + }, + ) + resp.raise_for_status() + data = resp.json() + except (httpx.HTTPError, ValueError) as exc: + logger.error("ITAD API error for app %s: %s", steam_app_id, exc) + return False + + # Try to extract historical low info + if isinstance(data, dict) and "prices" in data: + for price_info in data["prices"]: + if price_info.get("isLowest"): + return True + + return False diff --git a/gaming_deals_bot/matrix_client.py b/gaming_deals_bot/matrix_client.py new file mode 100644 index 0000000..be01e89 --- /dev/null +++ b/gaming_deals_bot/matrix_client.py @@ -0,0 +1,51 @@ +"""Matrix client wrapper for sending deal messages.""" + +import logging + +from nio import AsyncClient, RoomSendError + +logger = logging.getLogger(__name__) + + +class MatrixDealsClient: + def __init__( + self, + homeserver_url: str, + user_id: str, + access_token: str, + room_id: str, + ): + self.room_id = room_id + self._client = AsyncClient(homeserver_url, user_id) + self._client.access_token = access_token + self._client.user_id = user_id + + async def send_deal(self, plain_text: str, html: str) -> bool: + """Send a deal message to the configured room. + + Returns True on success, False on failure. + """ + content = { + "msgtype": "m.text", + "format": "org.matrix.custom.html", + "body": plain_text, + "formatted_body": html, + } + + try: + resp = await self._client.room_send( + room_id=self.room_id, + message_type="m.room.message", + content=content, + ) + if isinstance(resp, RoomSendError): + logger.error("Failed to send message: %s", resp.message) + return False + logger.debug("Message sent: %s", resp.event_id) + return True + except Exception as exc: + logger.error("Matrix send error: %s", exc) + return False + + async def close(self): + await self._client.close() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fc96547 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +matrix-nio>=0.21.0 +httpx>=0.25.0 +aiosqlite>=0.19.0 +apscheduler>=3.10.0 +markdown>=3.5.0