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
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
16
.env.example
Normal file
16
.env.example
Normal file
@@ -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
|
||||||
10
Dockerfile
Normal file
10
Dockerfile
Normal file
@@ -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"]
|
||||||
52
README.md
52
README.md
@@ -1 +1,51 @@
|
|||||||
# Pastel
|
# 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
|
||||||
|
|||||||
0
gaming_deals_bot/__init__.py
Normal file
0
gaming_deals_bot/__init__.py
Normal file
82
gaming_deals_bot/__main__.py
Normal file
82
gaming_deals_bot/__main__.py
Normal file
@@ -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())
|
||||||
145
gaming_deals_bot/bot.py
Normal file
145
gaming_deals_bot/bot.py
Normal file
@@ -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
|
||||||
|
)
|
||||||
101
gaming_deals_bot/cheapshark.py
Normal file
101
gaming_deals_bot/cheapshark.py
Normal file
@@ -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
|
||||||
30
gaming_deals_bot/config.py
Normal file
30
gaming_deals_bot/config.py
Normal file
@@ -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
|
||||||
79
gaming_deals_bot/database.py
Normal file
79
gaming_deals_bot/database.py
Normal file
@@ -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()
|
||||||
135
gaming_deals_bot/epic.py
Normal file
135
gaming_deals_bot/epic.py
Normal file
@@ -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
|
||||||
129
gaming_deals_bot/formatter.py
Normal file
129
gaming_deals_bot/formatter.py
Normal file
@@ -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
|
||||||
92
gaming_deals_bot/itad.py
Normal file
92
gaming_deals_bot/itad.py
Normal file
@@ -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
|
||||||
51
gaming_deals_bot/matrix_client.py
Normal file
51
gaming_deals_bot/matrix_client.py
Normal file
@@ -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()
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user