Compare commits
22 Commits
claude/gam
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5ae8543e1 | ||
|
|
949eceb983 | ||
|
|
d1a33e97c5 | ||
|
|
34da126507 | ||
|
|
72d86fd4e5 | ||
|
|
a83f230291 | ||
|
|
0302cea523 | ||
|
|
a334cb7bbe | ||
|
|
c486b1f5c6 | ||
|
|
66407c5a43 | ||
|
|
13cdd6d8b5 | ||
|
|
703f92d46c | ||
|
|
0fd58c32b4 | ||
|
|
19c070405e | ||
|
|
3f7e8196e3 | ||
|
|
c33e46c3cf | ||
|
|
c145d83b0b | ||
|
|
7b05d86dc5 | ||
|
|
b6b674ca0e | ||
|
|
c09ecdbd21 | ||
|
|
aed260f345 | ||
|
|
ba1a3811d6 |
@@ -4,13 +4,20 @@ MATRIX_BOT_USER_ID=@dealsbot:example.com
|
|||||||
MATRIX_BOT_ACCESS_TOKEN=syt_...
|
MATRIX_BOT_ACCESS_TOKEN=syt_...
|
||||||
MATRIX_DEALS_ROOM_ID=!roomid:example.com
|
MATRIX_DEALS_ROOM_ID=!roomid:example.com
|
||||||
|
|
||||||
# IsThereAnyDeal API key (optional — enables historical low detection)
|
# IsThereAnyDeal API key (optional — enables historical low detection and ITAD deals)
|
||||||
ITAD_API_KEY=
|
ITAD_API_KEY=
|
||||||
|
|
||||||
|
# Deal sources: comma-separated list (cheapshark, itad, or both)
|
||||||
|
# Requires ITAD_API_KEY when "itad" is included
|
||||||
|
DEAL_SOURCES=cheapshark
|
||||||
|
|
||||||
# Deal filtering
|
# Deal filtering
|
||||||
MIN_DEAL_RATING=8.0
|
MIN_DEAL_RATING=8.0
|
||||||
MIN_DISCOUNT_PERCENT=50
|
MIN_DISCOUNT_PERCENT=50
|
||||||
MAX_PRICE_USD=20
|
MAX_PRICE_USD=20
|
||||||
|
|
||||||
|
# Send an intro message to the room on startup (true/false)
|
||||||
|
SEND_INTRO_MESSAGE=false
|
||||||
|
|
||||||
# Database path (inside the container, mount a volume for persistence)
|
# Database path (inside the container, mount a volume for persistence)
|
||||||
DATABASE_PATH=/data/deals.db
|
DATABASE_PATH=/data/deals.db
|
||||||
|
|||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
12
README.md
12
README.md
@@ -7,8 +7,10 @@ Deals are sourced from PC/digital storefronts only (Steam, GOG, Humble Store, Gr
|
|||||||
## Data Sources
|
## Data Sources
|
||||||
|
|
||||||
- **CheapShark** — polled every 2 hours for top deals across Steam, GOG, Humble Store, and GreenManGaming
|
- **CheapShark** — polled every 2 hours for top deals across Steam, GOG, Humble Store, and GreenManGaming
|
||||||
|
- **IsThereAnyDeal** — polled every 2 hours for deals across all tracked stores, with built-in historical low detection (requires API key)
|
||||||
- **Epic Games Store** — polled daily for free game promotions
|
- **Epic Games Store** — polled daily for free game promotions
|
||||||
- **IsThereAnyDeal** (optional) — flags deals that are at an all-time historical low price
|
|
||||||
|
CheapShark and IsThereAnyDeal can be used individually or together — configure via the `DEAL_SOURCES` variable. When used as a deal source, IsThereAnyDeal provides historical low flags directly. When only CheapShark is active, IsThereAnyDeal can still optionally enrich deals with historical low info via the `ITAD_API_KEY`.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -59,10 +61,12 @@ All configuration is via environment variables (see `.env.example`):
|
|||||||
| `MATRIX_BOT_USER_ID` | Yes | — | Bot's Matrix user ID |
|
| `MATRIX_BOT_USER_ID` | Yes | — | Bot's Matrix user ID |
|
||||||
| `MATRIX_BOT_ACCESS_TOKEN` | Yes | — | Bot's access token |
|
| `MATRIX_BOT_ACCESS_TOKEN` | Yes | — | Bot's access token |
|
||||||
| `MATRIX_DEALS_ROOM_ID` | Yes | — | Room ID to post deals in |
|
| `MATRIX_DEALS_ROOM_ID` | Yes | — | Room ID to post deals in |
|
||||||
| `ITAD_API_KEY` | No | — | IsThereAnyDeal API key for historical low detection |
|
| `ITAD_API_KEY` | No | — | IsThereAnyDeal API key (required when `itad` is in `DEAL_SOURCES`, optional otherwise for historical low detection) |
|
||||||
|
| `DEAL_SOURCES` | No | cheapshark | Comma-separated deal sources: `cheapshark`, `itad`, or `cheapshark,itad` |
|
||||||
| `MIN_DEAL_RATING` | No | 8.0 | Minimum CheapShark deal rating (0-10) |
|
| `MIN_DEAL_RATING` | No | 8.0 | Minimum CheapShark deal rating (0-10) |
|
||||||
| `MIN_DISCOUNT_PERCENT` | No | 50 | Minimum discount percentage |
|
| `MIN_DISCOUNT_PERCENT` | No | 50 | Minimum discount percentage |
|
||||||
| `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD |
|
| `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD |
|
||||||
|
| `SEND_INTRO_MESSAGE` | No | false | Send "The deals must flow." to the room on startup |
|
||||||
| `DATABASE_PATH` | No | deals.db | Path to SQLite database file |
|
| `DATABASE_PATH` | No | deals.db | Path to SQLite database file |
|
||||||
|
|
||||||
## Preflight Check
|
## Preflight Check
|
||||||
@@ -76,10 +80,10 @@ python -m gaming_deals_bot --check
|
|||||||
This verifies:
|
This verifies:
|
||||||
|
|
||||||
- **Matrix** — authentication token is valid and bot has joined the target room
|
- **Matrix** — authentication token is valid and bot has joined the target room
|
||||||
- **CheapShark** — API is reachable
|
- **CheapShark** — API is reachable (skipped if not in `DEAL_SOURCES`)
|
||||||
- **Epic Games Store** — API is reachable
|
- **Epic Games Store** — API is reachable
|
||||||
- **Frankfurter** — exchange rate API is reachable
|
- **Frankfurter** — exchange rate API is reachable
|
||||||
- **IsThereAnyDeal** — API key is valid (skipped if not configured)
|
- **IsThereAnyDeal** — API key is valid (required when `itad` is in `DEAL_SOURCES`, skipped otherwise if not configured)
|
||||||
|
|
||||||
The command exits with code 0 on success and 1 on failure, so it works in CI and Docker health-checks.
|
The command exits with code 0 on success and 1 on failure, so it works in CI and Docker health-checks.
|
||||||
|
|
||||||
|
|||||||
@@ -48,14 +48,25 @@ async def main():
|
|||||||
|
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
# CheapShark: every 2 hours
|
# CheapShark: every 2 hours (if enabled)
|
||||||
scheduler.add_job(
|
if "cheapshark" in config.deal_sources:
|
||||||
bot.check_cheapshark,
|
scheduler.add_job(
|
||||||
"interval",
|
bot.check_cheapshark,
|
||||||
hours=2,
|
"interval",
|
||||||
id="cheapshark",
|
hours=2,
|
||||||
name="CheapShark deals check",
|
id="cheapshark",
|
||||||
)
|
name="CheapShark deals check",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ITAD deals: every 2 hours (if enabled)
|
||||||
|
if "itad" in config.deal_sources:
|
||||||
|
scheduler.add_job(
|
||||||
|
bot.check_itad_deals,
|
||||||
|
"interval",
|
||||||
|
hours=2,
|
||||||
|
id="itad_deals",
|
||||||
|
name="ITAD deals check",
|
||||||
|
)
|
||||||
|
|
||||||
# Epic free games: once daily
|
# Epic free games: once daily
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
@@ -67,10 +78,16 @@ async def main():
|
|||||||
)
|
)
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("Bot started — scheduler running")
|
logger.info(
|
||||||
|
"Bot started — scheduler running (deal sources: %s)",
|
||||||
|
", ".join(config.deal_sources),
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot.send_intro()
|
||||||
|
|
||||||
# Run initial checks immediately (after first-run population is done)
|
# Run initial checks immediately (after first-run population is done)
|
||||||
await bot.check_cheapshark()
|
await bot.check_cheapshark()
|
||||||
|
await bot.check_itad_deals()
|
||||||
await bot.check_epic_free_games()
|
await bot.check_epic_free_games()
|
||||||
|
|
||||||
# Keep running until signaled to stop
|
# Keep running until signaled to stop
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import logging
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .cheapshark import CheapSharkDeal, fetch_deals
|
from .cheapshark import CheapSharkDeal, fetch_deals as fetch_cheapshark_deals
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .currency import refresh_rates
|
from .currency import refresh_rates
|
||||||
from .database import Database
|
from .database import Database
|
||||||
from .epic import EpicFreeGame, fetch_free_games
|
from .epic import EpicFreeGame, fetch_free_games
|
||||||
from .formatter import format_deal, format_epic_free, format_epic_upcoming
|
from .formatter import format_deal, format_epic_free, format_epic_upcoming, format_itad_deal
|
||||||
from .itad import check_single_historical_low
|
from .itad import check_single_historical_low
|
||||||
|
from .itad_deals import ITADDeal, fetch_deals as fetch_itad_deals
|
||||||
from .matrix_client import MatrixDealsClient
|
from .matrix_client import MatrixDealsClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -53,32 +54,54 @@ class DealsBot:
|
|||||||
await self.db.close()
|
await self.db.close()
|
||||||
|
|
||||||
async def _populate_initial_state(self):
|
async def _populate_initial_state(self):
|
||||||
"""Fetch current deals and record them without posting (avoids spam on first run)."""
|
"""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)
|
Epic free games are intentionally *not* recorded here so that the
|
||||||
for game in current_free:
|
subsequent ``check_epic_free_games`` call will post them. There are
|
||||||
await self.db.mark_posted(game.dedup_id, "epic", game.title)
|
only a handful at any time and they are time-limited, so users should
|
||||||
for game in upcoming:
|
see them immediately rather than waiting for the next cycle.
|
||||||
await self.db.mark_posted(game.dedup_id, "epic", game.title)
|
"""
|
||||||
|
total = 0
|
||||||
|
|
||||||
total = len(deals) + len(current_free) + len(upcoming)
|
if "cheapshark" in self.config.deal_sources:
|
||||||
logger.info("First run: recorded %d existing deals/games", total)
|
deals = await fetch_cheapshark_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)
|
||||||
|
total += len(deals)
|
||||||
|
|
||||||
|
if "itad" in self.config.deal_sources and self.config.itad_api_key:
|
||||||
|
itad_deals = await fetch_itad_deals(
|
||||||
|
self._http,
|
||||||
|
self.config.itad_api_key,
|
||||||
|
max_price=self.config.max_price_usd,
|
||||||
|
min_discount=self.config.min_discount_percent,
|
||||||
|
)
|
||||||
|
for deal in itad_deals:
|
||||||
|
await self.db.mark_posted(deal.dedup_id, "itad", deal.title)
|
||||||
|
total += len(itad_deals)
|
||||||
|
|
||||||
|
logger.info("First run: recorded %d existing deals", 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):
|
async def check_cheapshark(self):
|
||||||
"""Poll CheapShark for deals and post new ones."""
|
"""Poll CheapShark for deals and post new ones."""
|
||||||
if not self._first_run_done:
|
if not self._first_run_done:
|
||||||
return
|
return
|
||||||
|
if "cheapshark" not in self.config.deal_sources:
|
||||||
|
return
|
||||||
|
|
||||||
logger.info("Checking CheapShark for deals...")
|
logger.info("Checking CheapShark for deals...")
|
||||||
deals = await fetch_deals(
|
deals = await fetch_cheapshark_deals(
|
||||||
self._http,
|
self._http,
|
||||||
max_price=self.config.max_price_usd,
|
max_price=self.config.max_price_usd,
|
||||||
min_rating=self.config.min_deal_rating,
|
min_rating=self.config.min_deal_rating,
|
||||||
@@ -91,6 +114,29 @@ class DealsBot:
|
|||||||
# Prune old records
|
# Prune old records
|
||||||
await self.db.prune_old(days=30)
|
await self.db.prune_old(days=30)
|
||||||
|
|
||||||
|
async def check_itad_deals(self):
|
||||||
|
"""Poll IsThereAnyDeal for deals and post new ones."""
|
||||||
|
if not self._first_run_done:
|
||||||
|
return
|
||||||
|
if "itad" not in self.config.deal_sources:
|
||||||
|
return
|
||||||
|
if not self.config.itad_api_key:
|
||||||
|
logger.warning("ITAD deal source enabled but ITAD_API_KEY is not set")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Checking IsThereAnyDeal for deals...")
|
||||||
|
deals = await fetch_itad_deals(
|
||||||
|
self._http,
|
||||||
|
self.config.itad_api_key,
|
||||||
|
max_price=self.config.max_price_usd,
|
||||||
|
min_discount=self.config.min_discount_percent,
|
||||||
|
)
|
||||||
|
|
||||||
|
for deal in deals:
|
||||||
|
await self._process_itad_deal(deal)
|
||||||
|
|
||||||
|
await self.db.prune_old(days=30)
|
||||||
|
|
||||||
async def _process_deal(self, deal: CheapSharkDeal):
|
async def _process_deal(self, deal: CheapSharkDeal):
|
||||||
"""Check dedup, check historical low, format, and post a single deal."""
|
"""Check dedup, check historical low, format, and post a single deal."""
|
||||||
if await self.db.has_been_posted(deal.dedup_id):
|
if await self.db.has_been_posted(deal.dedup_id):
|
||||||
@@ -114,6 +160,20 @@ class DealsBot:
|
|||||||
else:
|
else:
|
||||||
logger.warning("Failed to post deal: %s — will retry next cycle", deal.title)
|
logger.warning("Failed to post deal: %s — will retry next cycle", deal.title)
|
||||||
|
|
||||||
|
async def _process_itad_deal(self, deal: ITADDeal):
|
||||||
|
"""Check dedup, format, and post a single ITAD deal."""
|
||||||
|
if await self.db.has_been_posted(deal.dedup_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
plain_text, html = format_itad_deal(deal)
|
||||||
|
success = await self.matrix.send_deal(plain_text, html)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
await self.db.mark_posted(deal.dedup_id, "itad", deal.title)
|
||||||
|
logger.info("Posted ITAD deal: %s", deal.title)
|
||||||
|
else:
|
||||||
|
logger.warning("Failed to post ITAD deal: %s — will retry next cycle", deal.title)
|
||||||
|
|
||||||
async def check_epic_free_games(self):
|
async def check_epic_free_games(self):
|
||||||
"""Poll Epic Games Store for free games and post new ones."""
|
"""Poll Epic Games Store for free games and post new ones."""
|
||||||
if not self._first_run_done:
|
if not self._first_run_done:
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ async def fetch_deals(
|
|||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
*,
|
*,
|
||||||
max_price: float = 20,
|
max_price: float = 20,
|
||||||
min_rating: int = 80,
|
min_rating: float = 8.0,
|
||||||
min_discount: int = 50,
|
min_discount: float = 50,
|
||||||
page_size: int = 10,
|
page_size: int = 10,
|
||||||
) -> list[CheapSharkDeal]:
|
) -> list[CheapSharkDeal]:
|
||||||
"""Fetch top deals from CheapShark across configured stores."""
|
"""Fetch top deals from CheapShark across configured stores."""
|
||||||
@@ -55,7 +55,7 @@ async def fetch_deals(
|
|||||||
params = {
|
params = {
|
||||||
"storeID": store_ids,
|
"storeID": store_ids,
|
||||||
"upperPrice": str(int(max_price)),
|
"upperPrice": str(int(max_price)),
|
||||||
"sortBy": "Deal Rating",
|
"sortBy": "recent",
|
||||||
"desc": "1",
|
"desc": "1",
|
||||||
"pageSize": str(page_size),
|
"pageSize": str(page_size),
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ async def fetch_deals(
|
|||||||
if savings < min_discount:
|
if savings < min_discount:
|
||||||
logger.debug("Filtered out %s: savings %.1f%% < %.1f%%", title, savings, min_discount)
|
logger.debug("Filtered out %s: savings %.1f%% < %.1f%%", title, savings, min_discount)
|
||||||
continue
|
continue
|
||||||
if rating < min_rating:
|
if rating > 0 and rating < min_rating:
|
||||||
logger.debug("Filtered out %s: rating %.1f < %.1f", title, rating, min_rating)
|
logger.debug("Filtered out %s: rating %.1f < %.1f", title, rating, min_rating)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,22 @@ class Config:
|
|||||||
# ITAD API key (optional — historical low checks disabled without it)
|
# ITAD API key (optional — historical low checks disabled without it)
|
||||||
self.itad_api_key = os.environ.get("ITAD_API_KEY", "")
|
self.itad_api_key = os.environ.get("ITAD_API_KEY", "")
|
||||||
|
|
||||||
|
# Deal sources: comma-separated list of "cheapshark", "itad" (default: cheapshark)
|
||||||
|
raw_sources = os.environ.get("DEAL_SOURCES", "cheapshark")
|
||||||
|
self.deal_sources: list[str] = [
|
||||||
|
s.strip().lower() for s in raw_sources.split(",") if s.strip()
|
||||||
|
]
|
||||||
|
|
||||||
# Deal filtering
|
# Deal filtering
|
||||||
self.min_deal_rating = float(os.environ.get("MIN_DEAL_RATING", "8.0"))
|
self.min_deal_rating = float(os.environ.get("MIN_DEAL_RATING", "8.0"))
|
||||||
self.min_discount_percent = int(os.environ.get("MIN_DISCOUNT_PERCENT", "50"))
|
self.min_discount_percent = int(os.environ.get("MIN_DISCOUNT_PERCENT", "50"))
|
||||||
self.max_price_usd = float(os.environ.get("MAX_PRICE_USD", "20"))
|
self.max_price_usd = float(os.environ.get("MAX_PRICE_USD", "20"))
|
||||||
|
|
||||||
|
# Intro message on startup
|
||||||
|
self.send_intro_message = os.environ.get(
|
||||||
|
"SEND_INTRO_MESSAGE", "false"
|
||||||
|
).lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
self.database_path = os.environ.get("DATABASE_PATH", "deals.db")
|
self.database_path = os.environ.get("DATABASE_PATH", "deals.db")
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
"""Message formatting for Matrix deal posts.
|
"""Message formatting for Matrix deal posts.
|
||||||
|
|
||||||
Produces both a plain-text fallback and HTML formatted body for Matrix messages.
|
Produces both a plain-text fallback and HTML formatted body for Matrix messages.
|
||||||
|
HTML is built directly using the Matrix-supported HTML subset rather than going
|
||||||
|
through a Markdown-to-HTML converter, which collapses line breaks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from html import escape
|
||||||
import markdown
|
|
||||||
|
|
||||||
from .cheapshark import CheapSharkDeal
|
from .cheapshark import CheapSharkDeal
|
||||||
from .currency import format_price
|
from .currency import format_price
|
||||||
from .epic import EpicFreeGame
|
from .epic import EpicFreeGame
|
||||||
|
from .itad_deals import ITADDeal
|
||||||
_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]:
|
def format_deal(deal: CheapSharkDeal, is_historical_low: bool = False) -> tuple[str, str]:
|
||||||
@@ -31,22 +25,18 @@ def format_deal(deal: CheapSharkDeal, is_historical_low: bool = False) -> tuple[
|
|||||||
|
|
||||||
sale_multi = format_price(sale_price)
|
sale_multi = format_price(sale_price)
|
||||||
normal_display = format_price(normal_price)
|
normal_display = format_price(normal_price)
|
||||||
|
title = escape(deal.title)
|
||||||
|
|
||||||
lines = [
|
html_lines = [
|
||||||
f"**🎮 [DEAL] {deal.title}**",
|
f"<strong>🎮 [DEAL] {title}</strong>",
|
||||||
f"> {discount}% off on {deal.store_name} ~~{normal_display}~~",
|
f"{discount}% off on {escape(deal.store_name)} <del>{escape(normal_display)}</del>",
|
||||||
f"> 💰 **{sale_multi}**",
|
f"💰 <strong>{escape(sale_multi)}</strong>",
|
||||||
]
|
]
|
||||||
|
|
||||||
if is_historical_low:
|
if is_historical_low:
|
||||||
lines.append("> 🏆 _All-time low!_")
|
html_lines.append("🏆 <em>All-time low!</em>")
|
||||||
|
html_lines.append(f'🔗 <a href="{escape(deal.deal_url)}">View Deal</a>')
|
||||||
|
html = "<br>\n".join(html_lines)
|
||||||
|
|
||||||
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 = [
|
plain_lines = [
|
||||||
f"🎮 [DEAL] {deal.title}",
|
f"🎮 [DEAL] {deal.title}",
|
||||||
f" {discount}% off on {deal.store_name} (was {normal_display})",
|
f" {discount}% off on {deal.store_name} (was {normal_display})",
|
||||||
@@ -55,7 +45,38 @@ def format_deal(deal: CheapSharkDeal, is_historical_low: bool = False) -> tuple[
|
|||||||
if is_historical_low:
|
if is_historical_low:
|
||||||
plain_lines.append(" 🏆 All-time low!")
|
plain_lines.append(" 🏆 All-time low!")
|
||||||
plain_lines.append(f" 🔗 {deal.deal_url}")
|
plain_lines.append(f" 🔗 {deal.deal_url}")
|
||||||
|
plain_text = "\n".join(plain_lines)
|
||||||
|
|
||||||
|
return plain_text, html
|
||||||
|
|
||||||
|
|
||||||
|
def format_itad_deal(deal: ITADDeal) -> tuple[str, str]:
|
||||||
|
"""Format an ITAD deal into (plain_text, html) for Matrix.
|
||||||
|
|
||||||
|
Returns (body, formatted_body).
|
||||||
|
"""
|
||||||
|
sale_multi = format_price(deal.sale_price)
|
||||||
|
normal_display = format_price(deal.normal_price)
|
||||||
|
title = escape(deal.title)
|
||||||
|
|
||||||
|
html_lines = [
|
||||||
|
f"<strong>🎮 [DEAL] {title}</strong>",
|
||||||
|
f"{deal.discount}% off on {escape(deal.shop_name)} <del>{escape(normal_display)}</del>",
|
||||||
|
f"💰 <strong>{escape(sale_multi)}</strong>",
|
||||||
|
]
|
||||||
|
if deal.is_historical_low:
|
||||||
|
html_lines.append("🏆 <em>All-time low!</em>")
|
||||||
|
html_lines.append(f'🔗 <a href="{escape(deal.url)}">View Deal</a>')
|
||||||
|
html = "<br>\n".join(html_lines)
|
||||||
|
|
||||||
|
plain_lines = [
|
||||||
|
f"🎮 [DEAL] {deal.title}",
|
||||||
|
f" {deal.discount}% off on {deal.shop_name} (was {normal_display})",
|
||||||
|
f" 💰 {sale_multi}",
|
||||||
|
]
|
||||||
|
if deal.is_historical_low:
|
||||||
|
plain_lines.append(" 🏆 All-time low!")
|
||||||
|
plain_lines.append(f" 🔗 {deal.url}")
|
||||||
plain_text = "\n".join(plain_lines)
|
plain_text = "\n".join(plain_lines)
|
||||||
|
|
||||||
return plain_text, html
|
return plain_text, html
|
||||||
@@ -66,26 +87,25 @@ def format_epic_free(game: EpicFreeGame) -> tuple[str, str]:
|
|||||||
|
|
||||||
Returns (body, formatted_body).
|
Returns (body, formatted_body).
|
||||||
"""
|
"""
|
||||||
lines = [
|
title = escape(game.title)
|
||||||
f"**🆓 [FREE] {game.title}**",
|
|
||||||
"> Free on Epic Games Store",
|
html_lines = [
|
||||||
|
f"<strong>🆓 [FREE] {title}</strong>",
|
||||||
|
"Free on Epic Games Store",
|
||||||
]
|
]
|
||||||
|
|
||||||
if game.end_date:
|
if game.end_date:
|
||||||
try:
|
try:
|
||||||
end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00"))
|
end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00"))
|
||||||
date_str = end_dt.strftime("%B %-d")
|
date_str = end_dt.strftime("%B %-d")
|
||||||
lines.append(f"> 📅 _Free until {date_str}_")
|
html_lines.append(f"📅 <em>Free until {date_str}</em>")
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if game.url:
|
if game.url:
|
||||||
lines.append(f"> 🔗 [Claim Now]({game.url})")
|
html_lines.append(f'🔗 <a href="{escape(game.url)}">Claim Now</a>')
|
||||||
|
html = "<br>\n".join(html_lines)
|
||||||
|
|
||||||
md_text = "\n".join(lines)
|
|
||||||
html = _render_html(md_text)
|
|
||||||
|
|
||||||
# Plain text fallback
|
|
||||||
plain_lines = [
|
plain_lines = [
|
||||||
f"🆓 [FREE] {game.title}",
|
f"🆓 [FREE] {game.title}",
|
||||||
" Free on Epic Games Store",
|
" Free on Epic Games Store",
|
||||||
@@ -99,7 +119,6 @@ def format_epic_free(game: EpicFreeGame) -> tuple[str, str]:
|
|||||||
pass
|
pass
|
||||||
if game.url:
|
if game.url:
|
||||||
plain_lines.append(f" 🔗 {game.url}")
|
plain_lines.append(f" 🔗 {game.url}")
|
||||||
|
|
||||||
plain_text = "\n".join(plain_lines)
|
plain_text = "\n".join(plain_lines)
|
||||||
|
|
||||||
return plain_text, html
|
return plain_text, html
|
||||||
@@ -107,24 +126,24 @@ def format_epic_free(game: EpicFreeGame) -> tuple[str, str]:
|
|||||||
|
|
||||||
def format_epic_upcoming(game: EpicFreeGame) -> tuple[str, str]:
|
def format_epic_upcoming(game: EpicFreeGame) -> tuple[str, str]:
|
||||||
"""Format an upcoming Epic free game into (plain_text, html) for Matrix."""
|
"""Format an upcoming Epic free game into (plain_text, html) for Matrix."""
|
||||||
lines = [
|
title = escape(game.title)
|
||||||
f"**📢 [UPCOMING FREE] {game.title}**",
|
|
||||||
"> Coming soon — Free on Epic Games Store",
|
html_lines = [
|
||||||
|
f"<strong>📢 [UPCOMING FREE] {title}</strong>",
|
||||||
|
"Coming soon — Free on Epic Games Store",
|
||||||
]
|
]
|
||||||
|
|
||||||
if game.end_date:
|
if game.end_date:
|
||||||
try:
|
try:
|
||||||
end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00"))
|
end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00"))
|
||||||
date_str = end_dt.strftime("%B %-d")
|
date_str = end_dt.strftime("%B %-d")
|
||||||
lines.append(f"> 📅 _Free until {date_str}_")
|
html_lines.append(f"📅 <em>Free until {date_str}</em>")
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if game.url:
|
if game.url:
|
||||||
lines.append(f"> 🔗 [Store Page]({game.url})")
|
html_lines.append(f'🔗 <a href="{escape(game.url)}">Store Page</a>')
|
||||||
|
html = "<br>\n".join(html_lines)
|
||||||
md_text = "\n".join(lines)
|
|
||||||
html = _render_html(md_text)
|
|
||||||
|
|
||||||
plain_lines = [
|
plain_lines = [
|
||||||
f"📢 [UPCOMING FREE] {game.title}",
|
f"📢 [UPCOMING FREE] {game.title}",
|
||||||
@@ -132,7 +151,6 @@ def format_epic_upcoming(game: EpicFreeGame) -> tuple[str, str]:
|
|||||||
]
|
]
|
||||||
if game.url:
|
if game.url:
|
||||||
plain_lines.append(f" 🔗 {game.url}")
|
plain_lines.append(f" 🔗 {game.url}")
|
||||||
|
|
||||||
plain_text = "\n".join(plain_lines)
|
plain_text = "\n".join(plain_lines)
|
||||||
|
|
||||||
return plain_text, html
|
return plain_text, html
|
||||||
|
|||||||
@@ -7,6 +7,29 @@ logger = logging.getLogger(__name__)
|
|||||||
BASE_URL = "https://api.isthereanydeal.com"
|
BASE_URL = "https://api.isthereanydeal.com"
|
||||||
|
|
||||||
|
|
||||||
|
async def _lookup_game_id(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
api_key: str,
|
||||||
|
steam_app_id: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Look up the ITAD game UUID for a Steam app ID.
|
||||||
|
|
||||||
|
Returns the ITAD game UUID string, or None if not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{BASE_URL}/games/lookup/v1",
|
||||||
|
params={"key": api_key, "appid": steam_app_id},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("found") and data.get("game"):
|
||||||
|
return data["game"]["id"]
|
||||||
|
except (httpx.HTTPError, ValueError, KeyError) as exc:
|
||||||
|
logger.error("ITAD lookup error for app %s: %s", steam_app_id, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def check_historical_lows(
|
async def check_historical_lows(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
@@ -19,18 +42,22 @@ async def check_historical_lows(
|
|||||||
if not api_key or not steam_app_ids:
|
if not api_key or not steam_app_ids:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Build the list of ITAD game IDs from Steam app IDs
|
# Look up ITAD game UUIDs for each Steam app ID
|
||||||
# ITAD uses the format "app/{steam_app_id}" for Steam games
|
itad_id_to_steam: dict[str, str] = {}
|
||||||
game_ids = [f"app/{sid}" for sid in steam_app_ids]
|
for sid in steam_app_ids:
|
||||||
|
itad_id = await _lookup_game_id(client, api_key, sid)
|
||||||
|
if itad_id:
|
||||||
|
itad_id_to_steam[itad_id] = sid
|
||||||
|
|
||||||
|
if not itad_id_to_steam:
|
||||||
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = await client.get(
|
resp = await client.post(
|
||||||
f"{BASE_URL}/games/overview/v2",
|
f"{BASE_URL}/games/overview/v2",
|
||||||
params={"key": api_key, "shops[]": "steam"},
|
params={"key": api_key, "shops": [61]},
|
||||||
headers={"Content-Type": "application/json"},
|
json=list(itad_id_to_steam.keys()),
|
||||||
)
|
)
|
||||||
# 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()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
except (httpx.HTTPError, ValueError) as exc:
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
@@ -38,19 +65,17 @@ async def check_historical_lows(
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
results: dict[str, bool] = {}
|
results: dict[str, bool] = {}
|
||||||
# Parse the response — structure depends on the ITAD API version
|
for price_entry in data.get("prices", []):
|
||||||
# The overview endpoint returns price overview with historical low data
|
itad_id = price_entry.get("id")
|
||||||
if isinstance(data, dict):
|
steam_id = itad_id_to_steam.get(itad_id)
|
||||||
for steam_id in steam_app_ids:
|
if not steam_id:
|
||||||
game_key = f"app/{steam_id}"
|
continue
|
||||||
game_data = data.get(game_key) or data.get(steam_id, {})
|
lowest = price_entry.get("lowest")
|
||||||
if isinstance(game_data, dict):
|
current = price_entry.get("current")
|
||||||
lowest = game_data.get("lowest", {})
|
if lowest and current:
|
||||||
current = game_data.get("current", {})
|
lowest_price = lowest.get("price", {}).get("amount", float("inf"))
|
||||||
if lowest and current:
|
current_price = current.get("price", {}).get("amount", float("inf"))
|
||||||
lowest_price = lowest.get("price", float("inf"))
|
results[steam_id] = current_price <= lowest_price
|
||||||
current_price = current.get("price", float("inf"))
|
|
||||||
results[steam_id] = current_price <= lowest_price
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"ITAD: checked %d games, %d are at historical low",
|
"ITAD: checked %d games, %d are at historical low",
|
||||||
@@ -69,13 +94,15 @@ async def check_single_historical_low(
|
|||||||
if not api_key or not steam_app_id:
|
if not api_key or not steam_app_id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
itad_id = await _lookup_game_id(client, api_key, steam_app_id)
|
||||||
|
if not itad_id:
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = await client.get(
|
resp = await client.post(
|
||||||
f"{BASE_URL}/games/overview/v2",
|
f"{BASE_URL}/games/overview/v2",
|
||||||
params={
|
params={"key": api_key},
|
||||||
"key": api_key,
|
json=[itad_id],
|
||||||
"apps[]": f"app/{steam_app_id}",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -83,10 +110,13 @@ async def check_single_historical_low(
|
|||||||
logger.error("ITAD API error for app %s: %s", steam_app_id, exc)
|
logger.error("ITAD API error for app %s: %s", steam_app_id, exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Try to extract historical low info
|
for price_entry in data.get("prices", []):
|
||||||
if isinstance(data, dict) and "prices" in data:
|
lowest = price_entry.get("lowest")
|
||||||
for price_info in data["prices"]:
|
current = price_entry.get("current")
|
||||||
if price_info.get("isLowest"):
|
if lowest and current:
|
||||||
|
lowest_price = lowest.get("price", {}).get("amount", float("inf"))
|
||||||
|
current_price = current.get("price", {}).get("amount", float("inf"))
|
||||||
|
if current_price <= lowest_price:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|||||||
133
gaming_deals_bot/itad_deals.py
Normal file
133
gaming_deals_bot/itad_deals.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""IsThereAnyDeal deals list — fetch current deals from the ITAD API."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .itad import BASE_URL
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ITAD shop ID for Steam
|
||||||
|
STEAM_SHOP_ID = 61
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ITADDeal:
|
||||||
|
game_id: str # ITAD UUID
|
||||||
|
slug: str
|
||||||
|
title: str
|
||||||
|
sale_price: float # current deal price
|
||||||
|
normal_price: float # regular (non-sale) price
|
||||||
|
discount: int # percentage off (0-100)
|
||||||
|
currency: str
|
||||||
|
shop_name: str
|
||||||
|
shop_id: int
|
||||||
|
url: str # purchase redirect URL
|
||||||
|
is_historical_low: bool
|
||||||
|
timestamp: str # ISO datetime of deal
|
||||||
|
expiry: str | None # ISO datetime when deal expires
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dedup_id(self) -> str:
|
||||||
|
return f"itad-{self.game_id}-{self.shop_id}-{self.discount}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sale_price_usd(self) -> str:
|
||||||
|
return f"{self.sale_price:.2f}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def normal_price_usd(self) -> str:
|
||||||
|
return f"{self.normal_price:.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_deals(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
api_key: str,
|
||||||
|
*,
|
||||||
|
max_price: float = 20,
|
||||||
|
min_discount: float = 50,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> list[ITADDeal]:
|
||||||
|
"""Fetch current deals from IsThereAnyDeal.
|
||||||
|
|
||||||
|
Uses GET /deals/v2 with sorting by highest discount.
|
||||||
|
"""
|
||||||
|
if not api_key:
|
||||||
|
return []
|
||||||
|
|
||||||
|
params: dict = {
|
||||||
|
"key": api_key,
|
||||||
|
"sort": "-cut",
|
||||||
|
"limit": min(limit, 200),
|
||||||
|
"nondeals": "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await client.get(f"{BASE_URL}/deals/v2", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
logger.error("ITAD deals API error: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
raw_list = data.get("list", [])
|
||||||
|
logger.info("ITAD returned %d raw deals (before filtering)", len(raw_list))
|
||||||
|
|
||||||
|
deals: list[ITADDeal] = []
|
||||||
|
for entry in raw_list:
|
||||||
|
deal_data = entry.get("deal", {})
|
||||||
|
if not deal_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Only include actual games and DLC — skip non-game content
|
||||||
|
# (courses, software bundles, etc.)
|
||||||
|
entry_type = entry.get("type")
|
||||||
|
title = entry.get("title", "?")
|
||||||
|
if entry_type not in ("game", "dlc"):
|
||||||
|
logger.debug("Filtered out %s: type=%s", title, entry_type)
|
||||||
|
continue
|
||||||
|
|
||||||
|
cut = deal_data.get("cut", 0)
|
||||||
|
price_amount = deal_data.get("price", {}).get("amount", 0)
|
||||||
|
regular_amount = deal_data.get("regular", {}).get("amount", 0)
|
||||||
|
currency = deal_data.get("price", {}).get("currency", "USD")
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if cut < min_discount:
|
||||||
|
logger.debug(
|
||||||
|
"Filtered out %s: discount %d%% < %d%%", title, cut, int(min_discount)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if price_amount > max_price:
|
||||||
|
logger.debug(
|
||||||
|
"Filtered out %s: price %.2f > %.2f", title, price_amount, max_price
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
flag = deal_data.get("flag")
|
||||||
|
is_historical_low = flag in ("H", "N")
|
||||||
|
|
||||||
|
shop = deal_data.get("shop", {})
|
||||||
|
|
||||||
|
deals.append(
|
||||||
|
ITADDeal(
|
||||||
|
game_id=entry.get("id", ""),
|
||||||
|
slug=entry.get("slug", ""),
|
||||||
|
title=title,
|
||||||
|
sale_price=price_amount,
|
||||||
|
normal_price=regular_amount,
|
||||||
|
discount=cut,
|
||||||
|
currency=currency,
|
||||||
|
shop_name=shop.get("name", "Unknown"),
|
||||||
|
shop_id=shop.get("id", 0),
|
||||||
|
url=deal_data.get("url", ""),
|
||||||
|
is_historical_low=is_historical_low,
|
||||||
|
timestamp=deal_data.get("timestamp", ""),
|
||||||
|
expiry=deal_data.get("expiry"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("ITAD returned %d deals after filtering", len(deals))
|
||||||
|
return deals
|
||||||
@@ -47,5 +47,26 @@ class MatrixDealsClient:
|
|||||||
logger.error("Matrix send error: %s", exc)
|
logger.error("Matrix send error: %s", exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def send_notice(self, text: str) -> bool:
|
||||||
|
"""Send a plain m.notice (non-highlight) message to the configured room."""
|
||||||
|
content = {
|
||||||
|
"msgtype": "m.notice",
|
||||||
|
"body": text,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 notice: %s", resp.message)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Matrix send error: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
await self._client.close()
|
await self._client.close()
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ async def run_preflight(config: Config) -> bool:
|
|||||||
|
|
||||||
# --- CheapShark ---
|
# --- CheapShark ---
|
||||||
print(f"\n{_BOLD}CheapShark{_RESET}")
|
print(f"\n{_BOLD}CheapShark{_RESET}")
|
||||||
all_ok &= await _check_cheapshark(http)
|
if "cheapshark" in config.deal_sources:
|
||||||
|
all_ok &= await _check_cheapshark(http)
|
||||||
|
else:
|
||||||
|
_skip("Skipped", "not in DEAL_SOURCES")
|
||||||
|
|
||||||
# --- Epic Games Store ---
|
# --- Epic Games Store ---
|
||||||
print(f"\n{_BOLD}Epic Games Store{_RESET}")
|
print(f"\n{_BOLD}Epic Games Store{_RESET}")
|
||||||
@@ -62,9 +65,10 @@ async def run_preflight(config: Config) -> bool:
|
|||||||
print(f"\n{_BOLD}Frankfurter (exchange rates){_RESET}")
|
print(f"\n{_BOLD}Frankfurter (exchange rates){_RESET}")
|
||||||
all_ok &= await _check_frankfurter(http)
|
all_ok &= await _check_frankfurter(http)
|
||||||
|
|
||||||
# --- IsThereAnyDeal (optional) ---
|
# --- IsThereAnyDeal ---
|
||||||
|
itad_required = "itad" in config.deal_sources
|
||||||
print(f"\n{_BOLD}IsThereAnyDeal{_RESET}")
|
print(f"\n{_BOLD}IsThereAnyDeal{_RESET}")
|
||||||
all_ok &= await _check_itad(http, config.itad_api_key)
|
all_ok &= await _check_itad(http, config.itad_api_key, required=itad_required)
|
||||||
|
|
||||||
# --- Summary ---
|
# --- Summary ---
|
||||||
print()
|
print()
|
||||||
@@ -164,20 +168,29 @@ async def _check_frankfurter(http: httpx.AsyncClient) -> bool:
|
|||||||
return _fail("API reachable", str(exc))
|
return _fail("API reachable", str(exc))
|
||||||
|
|
||||||
|
|
||||||
async def _check_itad(http: httpx.AsyncClient, api_key: str) -> bool:
|
async def _check_itad(http: httpx.AsyncClient, api_key: str, *, required: bool = False) -> bool:
|
||||||
"""Verify the ITAD API key works (optional — skipped if no key is set)."""
|
"""Verify the ITAD API key works.
|
||||||
|
|
||||||
|
When *required* is True (ITAD is a deal source), a missing key is a failure.
|
||||||
|
Otherwise it's an optional skip.
|
||||||
|
"""
|
||||||
if not api_key:
|
if not api_key:
|
||||||
|
if required:
|
||||||
|
return _fail("API key", "ITAD_API_KEY is required when 'itad' is in DEAL_SOURCES")
|
||||||
return _skip("Skipped", "no ITAD_API_KEY configured (optional)")
|
return _skip("Skipped", "no ITAD_API_KEY configured (optional)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use a lightweight endpoint to validate the key
|
# Use the lookup endpoint (GET) to validate the key with a known game
|
||||||
resp = await http.get(
|
resp = await http.get(
|
||||||
f"{ITAD_URL}/games/overview/v2",
|
f"{ITAD_URL}/games/lookup/v1",
|
||||||
params={"key": api_key, "apps[]": "app/220"}, # Half-Life 2
|
params={"key": api_key, "appid": 220}, # Half-Life 2
|
||||||
)
|
)
|
||||||
if resp.status_code == 401 or resp.status_code == 403:
|
if resp.status_code in (401, 403):
|
||||||
return _fail("API key", "rejected by ITAD (401/403)")
|
return _fail("API key", "rejected by ITAD (401/403)")
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return _pass("API key valid", "ITAD responded successfully")
|
data = resp.json()
|
||||||
|
if data.get("found"):
|
||||||
|
return _pass("API reachable", "ITAD responded successfully")
|
||||||
|
return _pass("API reachable", "ITAD key valid (game not found)")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return _fail("API reachable", str(exc))
|
return _fail("API reachable", str(exc))
|
||||||
|
|||||||
@@ -2,5 +2,4 @@ matrix-nio>=0.21.0
|
|||||||
httpx>=0.25.0
|
httpx>=0.25.0
|
||||||
aiosqlite>=0.19.0
|
aiosqlite>=0.19.0
|
||||||
apscheduler>=3.10.0
|
apscheduler>=3.10.0
|
||||||
markdown>=3.5.0
|
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user