Files
Pastel/gaming_deals_bot/itad_deals.py
Claude 2b1c606a80 Add optional Matrix thread support for per-category deal organization
When MATRIX_USE_THREADS=true, deals are posted into dedicated threads
instead of the room timeline:

  - 🎮 Game Deals — CheapShark + ITAD type=game
  - 🧩 DLC Deals — ITAD type=dlc
  - 🆓 Epic Free Games — current and upcoming Epic free games
  - 📦 Non-Game Deals — ITAD non-game content (software, courses, etc.)

Thread roots are created on first use and persisted in the database.
When threads are disabled (default), behaviour is unchanged.

Key changes:
- New threads.py module with ThreadCategory enum and routing logic
- ITADDeal now carries deal_type; type filter moved from fetcher to bot
- MatrixDealsClient gains send_deal_in_thread() and create_thread_root()
- Database gets thread_roots table for storing root event IDs
- Bot routes each deal to the appropriate thread via _send_to_thread_or_room()

https://claude.ai/code/session_01EfPjktyrF24DBNHWsY1KBP
2026-03-01 00:37:26 +00:00

199 lines
5.9 KiB
Python

"""IsThereAnyDeal deals list — fetch current deals from the ITAD API."""
import logging
from dataclasses import dataclass
import httpx
from .currency import convert_to_usd
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
deal_type: str # ITAD type: "game", "dlc", or other
sale_price: float # current deal price (normalised to USD)
normal_price: float # regular (non-sale) price (normalised to USD)
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,
*,
countries: list[str] | None = None,
max_price: float = 20,
min_discount: float = 50,
limit: int = 200,
) -> list[ITADDeal]:
"""Fetch current deals from IsThereAnyDeal across one or more countries.
Defaults to the API maximum of 200 deals per country so that client-side
filtering (discount, price, type) has the widest possible pool to work
with — this avoids missing deals that wouldn't appear in a smaller window
sorted only by discount.
Deals are fetched per-country, merged (first country in the list wins
when the same game+shop appears in multiple regions), and finally sorted
by timestamp so that the newest deals appear first.
"""
if not api_key:
return []
if countries is None:
countries = ["US"]
seen: set[str] = set() # game_id-shop_id keys for cross-country dedup
all_deals: list[ITADDeal] = []
for country in countries:
country_deals = await _fetch_country_deals(
client,
api_key,
country=country,
max_price=max_price,
min_discount=min_discount,
limit=limit,
)
for deal in country_deals:
key = f"{deal.game_id}-{deal.shop_id}"
if key not in seen:
seen.add(key)
all_deals.append(deal)
else:
logger.debug(
"Skipping duplicate %s from country %s", deal.title, country
)
# Sort newest first — the API only supports sorting by discount or price,
# so we sort client-side by the deal timestamp.
all_deals.sort(key=lambda d: d.timestamp, reverse=True)
logger.info(
"ITAD: %d deals across %d country/countries after dedup",
len(all_deals),
len(countries),
)
return all_deals
async def _fetch_country_deals(
client: httpx.AsyncClient,
api_key: str,
*,
country: str = "US",
max_price: float = 20,
min_discount: float = 50,
limit: int = 200,
) -> list[ITADDeal]:
"""Fetch deals for a single country from the ITAD ``/deals/v2`` endpoint."""
params: dict = {
"key": api_key,
"country": country,
"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 for country %s: %s", country, exc)
return []
raw_list = data.get("list", [])
logger.info(
"ITAD returned %d raw deals for %s (before filtering)", len(raw_list), country
)
deals: list[ITADDeal] = []
for entry in raw_list:
deal_data = entry.get("deal", {})
if not deal_data:
continue
entry_type = entry.get("type", "game")
title = entry.get("title", "?")
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")
# Normalise to USD so prices are comparable across regions
price_usd = convert_to_usd(price_amount, currency)
regular_usd = convert_to_usd(regular_amount, currency)
# Apply ITAD-specific filters
if cut < min_discount:
logger.debug(
"Filtered out %s: discount %d%% < %d%%",
title,
cut,
int(min_discount),
)
continue
if price_usd > max_price:
logger.debug(
"Filtered out %s: price $%.2f > $%.2f", title, price_usd, 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,
deal_type=entry_type,
sale_price=price_usd,
normal_price=regular_usd,
discount=cut,
currency="USD", # normalised
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 for %s after filtering", len(deals), country
)
return deals