Compare commits
29 Commits
claude/gam
...
claude/mul
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b1c606a80 | ||
|
|
f13bfd7b07 | ||
|
|
e4d90d0331 | ||
|
|
da957f82ba | ||
|
|
30c9872d04 | ||
|
|
123252e197 | ||
|
|
c5ae8543e1 | ||
|
|
949eceb983 | ||
|
|
1dcb76a394 | ||
|
|
d1a33e97c5 | ||
|
|
34da126507 | ||
|
|
72d86fd4e5 | ||
|
|
a83f230291 | ||
|
|
0302cea523 | ||
|
|
a334cb7bbe | ||
|
|
c486b1f5c6 | ||
|
|
66407c5a43 | ||
|
|
13cdd6d8b5 | ||
|
|
703f92d46c | ||
|
|
0fd58c32b4 | ||
|
|
19c070405e | ||
|
|
3f7e8196e3 | ||
|
|
c33e46c3cf | ||
|
|
c145d83b0b | ||
|
|
7b05d86dc5 | ||
|
|
b6b674ca0e | ||
|
|
c09ecdbd21 | ||
|
|
aed260f345 | ||
|
|
ba1a3811d6 |
42
.env.example
42
.env.example
@@ -4,13 +4,47 @@ 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)
|
||||
# IsThereAnyDeal API key (optional — enables historical low detection and ITAD deals)
|
||||
ITAD_API_KEY=
|
||||
|
||||
# Deal filtering
|
||||
MIN_DEAL_RATING=8.0
|
||||
# Deal sources: comma-separated list (cheapshark, itad, or both)
|
||||
# Requires ITAD_API_KEY when "itad" is included
|
||||
DEAL_SOURCES=cheapshark
|
||||
|
||||
# ITAD countries: comma-separated ISO 3166-1 alpha-2 country codes
|
||||
# Deals are fetched for each country and merged (duplicates removed).
|
||||
# Examples: US, CA, GB, DE, FR, AU, BR, JP
|
||||
ITAD_COUNTRIES=US
|
||||
|
||||
# Currency display
|
||||
# DEFAULT_CURRENCY is shown first; EXTRA_CURRENCIES are shown after it.
|
||||
DEFAULT_CURRENCY=USD
|
||||
EXTRA_CURRENCIES=CAD,EUR,GBP
|
||||
|
||||
# CheapShark-specific filtering
|
||||
# (Falls back to shared MIN_DISCOUNT_PERCENT / MAX_PRICE if not set)
|
||||
#CHEAPSHARK_MIN_DISCOUNT=50
|
||||
#CHEAPSHARK_MIN_RATING=8.0
|
||||
#CHEAPSHARK_MAX_PRICE=20
|
||||
|
||||
# ITAD-specific filtering
|
||||
# (Falls back to shared MIN_DISCOUNT_PERCENT / MAX_PRICE if not set)
|
||||
#ITAD_MIN_DISCOUNT=50
|
||||
#ITAD_MAX_PRICE=20
|
||||
#ITAD_DEALS_LIMIT=200
|
||||
|
||||
# Shared filter defaults (used when source-specific values are not set)
|
||||
MIN_DISCOUNT_PERCENT=50
|
||||
MAX_PRICE_USD=20
|
||||
MAX_PRICE=20
|
||||
|
||||
# Matrix threads — post deals into per-category threads instead of the room
|
||||
# Categories: Game Deals, DLC Deals, Epic Free Games, Non-Game Deals
|
||||
# When disabled (default), all deals post directly to the room timeline
|
||||
# and non-game ITAD content is excluded (original behaviour).
|
||||
#MATRIX_USE_THREADS=false
|
||||
|
||||
# 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=/data/deals.db
|
||||
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
54
README.md
54
README.md
@@ -7,8 +7,10 @@ Deals are sourced from PC/digital storefronts only (Steam, GOG, Humble Store, Gr
|
||||
## Data Sources
|
||||
|
||||
- **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
|
||||
- **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
|
||||
|
||||
@@ -53,18 +55,38 @@ python -m gaming_deals_bot
|
||||
|
||||
All configuration is via environment variables (see `.env.example`):
|
||||
|
||||
### Core
|
||||
|
||||
| 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 | 8.0 | Minimum CheapShark deal rating (0-10) |
|
||||
| `MIN_DISCOUNT_PERCENT` | No | 50 | Minimum discount percentage |
|
||||
| `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD |
|
||||
| `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` |
|
||||
| `ITAD_COUNTRIES` | No | US | Comma-separated ISO 3166-1 alpha-2 country codes to fetch ITAD deals from (e.g. `US,CA,GB,DE`) |
|
||||
| `DEFAULT_CURRENCY` | No | USD | Primary display currency shown first in price strings |
|
||||
| `EXTRA_CURRENCIES` | No | CAD,EUR,GBP | Additional currencies shown after the default (comma-separated) |
|
||||
| `MATRIX_USE_THREADS` | No | false | Post deals into per-category threads (see Threads section below) |
|
||||
| `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 |
|
||||
|
||||
### Filtering
|
||||
|
||||
Each deal source has its own filter settings. Source-specific values take priority; when not set they fall back to the shared defaults.
|
||||
|
||||
| Variable | Source | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `CHEAPSHARK_MIN_DISCOUNT` | CheapShark | 50 | Minimum discount percentage |
|
||||
| `CHEAPSHARK_MIN_RATING` | CheapShark | 8.0 | Minimum deal rating (0-10, 0 = unrated allowed) |
|
||||
| `CHEAPSHARK_MAX_PRICE` | CheapShark | 20 | Maximum sale price (USD) |
|
||||
| `ITAD_MIN_DISCOUNT` | ITAD | 50 | Minimum discount percentage |
|
||||
| `ITAD_MAX_PRICE` | ITAD | 20 | Maximum sale price (USD, prices from other regions are converted) |
|
||||
| `ITAD_DEALS_LIMIT` | ITAD | 200 | Number of deals to fetch per country (max 200) |
|
||||
| `MIN_DISCOUNT_PERCENT` | Shared | 50 | Fallback minimum discount when source-specific value is not set |
|
||||
| `MAX_PRICE` | Shared | 20 | Fallback maximum price when source-specific value is not set |
|
||||
|
||||
## Preflight Check
|
||||
|
||||
Run `--check` to validate your configuration and test connectivity to all services before starting the bot:
|
||||
@@ -76,17 +98,33 @@ python -m gaming_deals_bot --check
|
||||
This verifies:
|
||||
|
||||
- **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
|
||||
- **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.
|
||||
|
||||
## Threads
|
||||
|
||||
When `MATRIX_USE_THREADS=true`, deals are posted inside per-category threads instead of directly into the room timeline. This keeps the room organized and lets users follow only the categories they care about.
|
||||
|
||||
| Thread | Content |
|
||||
|---|---|
|
||||
| 🎮 Game Deals | CheapShark deals + ITAD deals with type `game` |
|
||||
| 🧩 DLC Deals | ITAD deals with type `dlc` |
|
||||
| 🆓 Epic Free Games | Current and upcoming free games from the Epic Games Store |
|
||||
| 📦 Non-Game Deals | ITAD deals that aren't games or DLC (software, courses, etc.) |
|
||||
|
||||
Thread root messages are created automatically the first time a deal in that category appears. The root event IDs are stored in the database so subsequent deals are posted into the same threads.
|
||||
|
||||
When threads are **disabled** (default), the bot behaves as before — all deals post directly to the room and non-game ITAD content is excluded.
|
||||
|
||||
## 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
|
||||
- **Multi-currency pricing**: deal prices are shown in USD, CAD, EUR, and GBP using live exchange rates from the [Frankfurter API](https://api.frankfurter.dev) (ECB data, no API key required). Rates are cached and refreshed twice daily.
|
||||
- **Multi-currency pricing**: deal prices are shown in your configured currencies (default: USD, CAD, EUR, GBP) using live exchange rates from the [Frankfurter API](https://api.frankfurter.dev) (ECB data, no API key required). Set `DEFAULT_CURRENCY` to change the primary display currency and `EXTRA_CURRENCIES` for additional ones. Rates are cached and refreshed twice daily.
|
||||
- **Multi-country ITAD deals**: when using IsThereAnyDeal, deals can be fetched from multiple countries simultaneously via `ITAD_COUNTRIES` (e.g. `US,CA,GB,DE`). Deals are merged and deduplicated, with the first country in the list taking priority for duplicate games.
|
||||
|
||||
@@ -48,7 +48,8 @@ async def main():
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
# CheapShark: every 2 hours
|
||||
# CheapShark: every 2 hours (if enabled)
|
||||
if "cheapshark" in config.deal_sources:
|
||||
scheduler.add_job(
|
||||
bot.check_cheapshark,
|
||||
"interval",
|
||||
@@ -57,6 +58,16 @@ async def main():
|
||||
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
|
||||
scheduler.add_job(
|
||||
bot.check_epic_free_games,
|
||||
@@ -67,10 +78,16 @@ async def main():
|
||||
)
|
||||
|
||||
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)
|
||||
await bot.check_cheapshark()
|
||||
await bot.check_itad_deals()
|
||||
await bot.check_epic_free_games()
|
||||
|
||||
# Keep running until signaled to stop
|
||||
|
||||
@@ -5,14 +5,16 @@ import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from .cheapshark import CheapSharkDeal, fetch_deals
|
||||
from .cheapshark import CheapSharkDeal, fetch_deals as fetch_cheapshark_deals
|
||||
from .config import Config
|
||||
from .currency import refresh_rates
|
||||
from .currency import configure as configure_currency, refresh_rates
|
||||
from .database import Database
|
||||
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_deals import ITADDeal, fetch_deals as fetch_itad_deals
|
||||
from .matrix_client import MatrixDealsClient
|
||||
from .threads import ThreadCategory, format_thread_root, itad_type_to_category
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,6 +36,9 @@ class DealsBot:
|
||||
"""Initialize database, fetch exchange rates, and run first-run population."""
|
||||
await self.db.connect()
|
||||
|
||||
# Configure currency display before fetching rates
|
||||
configure_currency(self.config.default_currency, self.config.extra_currencies)
|
||||
|
||||
# Pre-fetch exchange rates so the first deal post has conversions
|
||||
await refresh_rates(self._http)
|
||||
|
||||
@@ -46,6 +51,9 @@ class DealsBot:
|
||||
else:
|
||||
self._first_run_done = True
|
||||
|
||||
# Start presence heartbeat so the bot shows as "online"
|
||||
await self.matrix.start_presence_heartbeat()
|
||||
|
||||
async def stop(self):
|
||||
"""Clean shutdown."""
|
||||
await self._http.aclose()
|
||||
@@ -53,36 +61,97 @@ class DealsBot:
|
||||
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(
|
||||
"""Fetch current deals and record them without posting (avoids spam on first run).
|
||||
|
||||
Epic free games are intentionally *not* recorded here so that the
|
||||
subsequent ``check_epic_free_games`` call will post them. There are
|
||||
only a handful at any time and they are time-limited, so users should
|
||||
see them immediately rather than waiting for the next cycle.
|
||||
"""
|
||||
total = 0
|
||||
|
||||
if "cheapshark" in self.config.deal_sources:
|
||||
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,
|
||||
max_price=self.config.cheapshark_max_price,
|
||||
min_rating=self.config.cheapshark_min_rating,
|
||||
min_discount=self.config.cheapshark_min_discount,
|
||||
)
|
||||
for deal in deals:
|
||||
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
|
||||
total += len(deals)
|
||||
|
||||
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)
|
||||
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,
|
||||
countries=self.config.itad_countries,
|
||||
max_price=self.config.itad_max_price,
|
||||
min_discount=self.config.itad_min_discount,
|
||||
limit=self.config.itad_deals_limit,
|
||||
)
|
||||
for deal in itad_deals:
|
||||
await self.db.mark_posted(deal.dedup_id, "itad", deal.title)
|
||||
total += len(itad_deals)
|
||||
|
||||
total = len(deals) + len(current_free) + len(upcoming)
|
||||
logger.info("First run: recorded %d existing deals/games", total)
|
||||
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.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Thread helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _get_or_create_thread(self, category: ThreadCategory) -> str | None:
|
||||
"""Return the event ID of the thread root for *category*, creating it if needed."""
|
||||
event_id = await self.db.get_thread_root(category.value)
|
||||
if event_id:
|
||||
return event_id
|
||||
|
||||
plain_text, html = format_thread_root(category)
|
||||
event_id = await self.matrix.create_thread_root(plain_text, html)
|
||||
if event_id:
|
||||
await self.db.set_thread_root(category.value, event_id)
|
||||
logger.info("Created thread root for %s: %s", category.value, event_id)
|
||||
return event_id
|
||||
|
||||
async def _send_to_thread_or_room(
|
||||
self, plain_text: str, html: str, category: ThreadCategory
|
||||
) -> bool:
|
||||
"""Send a message — into a thread if threads are enabled, otherwise to the room."""
|
||||
if not self.config.matrix_use_threads:
|
||||
return await self.matrix.send_deal(plain_text, html)
|
||||
|
||||
thread_root_id = await self._get_or_create_thread(category)
|
||||
if not thread_root_id:
|
||||
logger.warning(
|
||||
"Could not obtain thread root for %s — falling back to room", category.value
|
||||
)
|
||||
return await self.matrix.send_deal(plain_text, html)
|
||||
|
||||
return await self.matrix.send_deal_in_thread(plain_text, html, thread_root_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CheapShark
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def check_cheapshark(self):
|
||||
"""Poll CheapShark for deals and post new ones."""
|
||||
if not self._first_run_done:
|
||||
return
|
||||
if "cheapshark" not in self.config.deal_sources:
|
||||
return
|
||||
|
||||
logger.info("Checking CheapShark for deals...")
|
||||
deals = await fetch_deals(
|
||||
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,
|
||||
max_price=self.config.cheapshark_max_price,
|
||||
min_rating=self.config.cheapshark_min_rating,
|
||||
min_discount=self.config.cheapshark_min_discount,
|
||||
)
|
||||
|
||||
for deal in deals:
|
||||
@@ -106,7 +175,9 @@ class DealsBot:
|
||||
)
|
||||
|
||||
plain_text, html = format_deal(deal, is_historical_low)
|
||||
success = await self.matrix.send_deal(plain_text, html)
|
||||
success = await self._send_to_thread_or_room(
|
||||
plain_text, html, ThreadCategory.GAME_DEALS
|
||||
)
|
||||
|
||||
if success:
|
||||
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
|
||||
@@ -114,6 +185,60 @@ class DealsBot:
|
||||
else:
|
||||
logger.warning("Failed to post deal: %s — will retry next cycle", deal.title)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# IsThereAnyDeal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
countries=self.config.itad_countries,
|
||||
max_price=self.config.itad_max_price,
|
||||
min_discount=self.config.itad_min_discount,
|
||||
limit=self.config.itad_deals_limit,
|
||||
)
|
||||
|
||||
for deal in deals:
|
||||
await self._process_itad_deal(deal)
|
||||
|
||||
await self.db.prune_old(days=30)
|
||||
|
||||
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
|
||||
|
||||
category = itad_type_to_category(deal.deal_type)
|
||||
|
||||
# When threads are off, preserve original behaviour: skip non-game content
|
||||
if not self.config.matrix_use_threads and category == ThreadCategory.NON_GAME_DEALS:
|
||||
logger.debug("Skipping non-game ITAD deal (threads disabled): %s", deal.title)
|
||||
return
|
||||
|
||||
plain_text, html = format_itad_deal(deal)
|
||||
success = await self._send_to_thread_or_room(plain_text, html, category)
|
||||
|
||||
if success:
|
||||
await self.db.mark_posted(deal.dedup_id, "itad", deal.title)
|
||||
logger.info("Posted ITAD deal: %s [%s]", deal.title, category.value)
|
||||
else:
|
||||
logger.warning("Failed to post ITAD deal: %s — will retry next cycle", deal.title)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Epic Games Store
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def check_epic_free_games(self):
|
||||
"""Poll Epic Games Store for free games and post new ones."""
|
||||
if not self._first_run_done:
|
||||
@@ -138,7 +263,9 @@ class DealsBot:
|
||||
else:
|
||||
plain_text, html = format_epic_free(game)
|
||||
|
||||
success = await self.matrix.send_deal(plain_text, html)
|
||||
success = await self._send_to_thread_or_room(
|
||||
plain_text, html, ThreadCategory.EPIC_FREE
|
||||
)
|
||||
|
||||
if success:
|
||||
await self.db.mark_posted(game.dedup_id, "epic", game.title)
|
||||
|
||||
@@ -46,8 +46,8 @@ async def fetch_deals(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
max_price: float = 20,
|
||||
min_rating: int = 80,
|
||||
min_discount: int = 50,
|
||||
min_rating: float = 8.0,
|
||||
min_discount: float = 50,
|
||||
page_size: int = 10,
|
||||
) -> list[CheapSharkDeal]:
|
||||
"""Fetch top deals from CheapShark across configured stores."""
|
||||
@@ -55,7 +55,7 @@ async def fetch_deals(
|
||||
params = {
|
||||
"storeID": store_ids,
|
||||
"upperPrice": str(int(max_price)),
|
||||
"sortBy": "Deal Rating",
|
||||
"sortBy": "recent",
|
||||
"desc": "1",
|
||||
"pageSize": str(page_size),
|
||||
}
|
||||
@@ -81,7 +81,7 @@ async def fetch_deals(
|
||||
if savings < min_discount:
|
||||
logger.debug("Filtered out %s: savings %.1f%% < %.1f%%", title, savings, min_discount)
|
||||
continue
|
||||
if rating < min_rating:
|
||||
if rating > 0 and rating < min_rating:
|
||||
logger.debug("Filtered out %s: rating %.1f < %.1f", title, rating, min_rating)
|
||||
continue
|
||||
|
||||
|
||||
@@ -14,10 +14,63 @@ class Config:
|
||||
# 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 = float(os.environ.get("MIN_DEAL_RATING", "8.0"))
|
||||
self.min_discount_percent = int(os.environ.get("MIN_DISCOUNT_PERCENT", "50"))
|
||||
self.max_price_usd = float(os.environ.get("MAX_PRICE_USD", "20"))
|
||||
# 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()
|
||||
]
|
||||
|
||||
# ITAD countries: comma-separated ISO 3166-1 alpha-2 country codes
|
||||
# Deals are fetched for each country and merged (first country has priority
|
||||
# when the same game appears in multiple regions).
|
||||
raw_countries = os.environ.get("ITAD_COUNTRIES", "US")
|
||||
self.itad_countries: list[str] = [
|
||||
c.strip().upper() for c in raw_countries.split(",") if c.strip()
|
||||
]
|
||||
|
||||
# Currency display
|
||||
self.default_currency = os.environ.get("DEFAULT_CURRENCY", "USD").upper()
|
||||
raw_extra = os.environ.get("EXTRA_CURRENCIES", "CAD,EUR,GBP")
|
||||
self.extra_currencies: list[str] = [
|
||||
c.strip().upper() for c in raw_extra.split(",") if c.strip()
|
||||
]
|
||||
|
||||
# Shared filter fallbacks (support legacy env vars)
|
||||
_max_price = os.environ.get(
|
||||
"MAX_PRICE", os.environ.get("MAX_PRICE_USD", "20")
|
||||
)
|
||||
_min_discount = os.environ.get("MIN_DISCOUNT_PERCENT", "50")
|
||||
_min_rating = os.environ.get("MIN_DEAL_RATING", "8.0")
|
||||
|
||||
# CheapShark-specific filtering (falls back to shared values)
|
||||
self.cheapshark_min_discount = int(
|
||||
os.environ.get("CHEAPSHARK_MIN_DISCOUNT", _min_discount)
|
||||
)
|
||||
self.cheapshark_min_rating = float(
|
||||
os.environ.get("CHEAPSHARK_MIN_RATING", _min_rating)
|
||||
)
|
||||
self.cheapshark_max_price = float(
|
||||
os.environ.get("CHEAPSHARK_MAX_PRICE", _max_price)
|
||||
)
|
||||
|
||||
# ITAD-specific filtering (falls back to shared values)
|
||||
self.itad_min_discount = int(
|
||||
os.environ.get("ITAD_MIN_DISCOUNT", _min_discount)
|
||||
)
|
||||
self.itad_max_price = float(
|
||||
os.environ.get("ITAD_MAX_PRICE", _max_price)
|
||||
)
|
||||
self.itad_deals_limit = int(os.environ.get("ITAD_DEALS_LIMIT", "200"))
|
||||
|
||||
# Matrix threads — post deals into per-category threads
|
||||
self.matrix_use_threads = os.environ.get(
|
||||
"MATRIX_USE_THREADS", "false"
|
||||
).lower() in ("true", "1", "yes")
|
||||
|
||||
# Intro message on startup
|
||||
self.send_intro_message = os.environ.get(
|
||||
"SEND_INTRO_MESSAGE", "false"
|
||||
).lower() in ("true", "1", "yes")
|
||||
|
||||
# Database
|
||||
self.database_path = os.environ.get("DATABASE_PATH", "deals.db")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Currency conversion using the Frankfurter API (ECB rates, no API key required).
|
||||
|
||||
Rates are cached in memory and refreshed at most twice per day.
|
||||
Call ``configure()`` before ``refresh_rates()`` to set the display currencies.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -11,13 +12,72 @@ import httpx
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FRANKFURTER_URL = "https://api.frankfurter.dev/v1/latest"
|
||||
TARGET_CURRENCIES = ("CAD", "EUR", "GBP")
|
||||
CACHE_TTL_SECONDS = 43200 # 12 hours — be nice to the free service
|
||||
|
||||
# Module-level cache
|
||||
# Module-level state -------------------------------------------------------
|
||||
_rates: dict[str, float] = {}
|
||||
_last_fetched: float = 0.0
|
||||
|
||||
# Display preferences (set via configure())
|
||||
_default_currency: str = "USD"
|
||||
_extra_currencies: list[str] = ["CAD", "EUR", "GBP"]
|
||||
|
||||
# Currency display symbols
|
||||
SYMBOLS: dict[str, str] = {
|
||||
"USD": "$",
|
||||
"CAD": "C$",
|
||||
"EUR": "€",
|
||||
"GBP": "£",
|
||||
"AUD": "A$",
|
||||
"BRL": "R$",
|
||||
"CHF": "CHF ",
|
||||
"CNY": "¥",
|
||||
"CZK": "Kč",
|
||||
"DKK": "kr",
|
||||
"HUF": "Ft",
|
||||
"INR": "₹",
|
||||
"JPY": "¥",
|
||||
"KRW": "₩",
|
||||
"MXN": "MX$",
|
||||
"NOK": "kr",
|
||||
"NZD": "NZ$",
|
||||
"PLN": "zł",
|
||||
"SEK": "kr",
|
||||
"TRY": "₺",
|
||||
"ZAR": "R",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def configure(default_currency: str = "USD", extra_currencies: list[str] | None = None) -> None:
|
||||
"""Set the primary display currency and additional currencies.
|
||||
|
||||
Must be called **before** ``refresh_rates()`` so the correct symbols are
|
||||
requested from Frankfurter.
|
||||
"""
|
||||
global _default_currency, _extra_currencies
|
||||
_default_currency = default_currency.upper()
|
||||
if extra_currencies is not None:
|
||||
_extra_currencies = [c.upper() for c in extra_currencies]
|
||||
|
||||
|
||||
def _all_target_currencies() -> list[str]:
|
||||
"""Return every non-USD currency we need exchange rates for."""
|
||||
currencies: set[str] = set()
|
||||
if _default_currency != "USD":
|
||||
currencies.add(_default_currency)
|
||||
for c in _extra_currencies:
|
||||
if c != "USD":
|
||||
currencies.add(c)
|
||||
return sorted(currencies)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate fetching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def refresh_rates(client: httpx.AsyncClient) -> bool:
|
||||
"""Fetch latest USD-based exchange rates from Frankfurter.
|
||||
@@ -26,7 +86,13 @@ async def refresh_rates(client: httpx.AsyncClient) -> bool:
|
||||
"""
|
||||
global _rates, _last_fetched
|
||||
|
||||
symbols = ",".join(TARGET_CURRENCIES)
|
||||
needed = _all_target_currencies()
|
||||
if not needed:
|
||||
# Only USD display — no conversion needed
|
||||
_last_fetched = time.monotonic()
|
||||
return True
|
||||
|
||||
symbols = ",".join(needed)
|
||||
try:
|
||||
resp = await client.get(
|
||||
FRANKFURTER_URL,
|
||||
@@ -51,42 +117,69 @@ async def refresh_rates(client: httpx.AsyncClient) -> bool:
|
||||
|
||||
async def _ensure_rates(client: httpx.AsyncClient) -> None:
|
||||
"""Refresh rates if the cache is stale or empty."""
|
||||
if not _rates or (time.monotonic() - _last_fetched) > CACHE_TTL_SECONDS:
|
||||
if _all_target_currencies() and (
|
||||
not _rates or (time.monotonic() - _last_fetched) > CACHE_TTL_SECONDS
|
||||
):
|
||||
await refresh_rates(client)
|
||||
|
||||
|
||||
def _convert(usd_amount: float, currency: str) -> float | None:
|
||||
"""Convert a USD amount to the target currency using cached rates."""
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conversion helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _convert_from_usd(usd_amount: float, currency: str) -> float | None:
|
||||
"""Convert a USD amount to *currency* using cached rates."""
|
||||
if currency == "USD":
|
||||
return usd_amount
|
||||
rate = _rates.get(currency)
|
||||
if rate is None:
|
||||
return None
|
||||
return round(usd_amount * rate, 2)
|
||||
|
||||
|
||||
# Currency display symbols
|
||||
_SYMBOLS = {
|
||||
"USD": "$",
|
||||
"CAD": "C$",
|
||||
"EUR": "€",
|
||||
"GBP": "£",
|
||||
}
|
||||
def convert_to_usd(amount: float, source_currency: str) -> float:
|
||||
"""Convert *amount* from *source_currency* to USD using cached rates.
|
||||
|
||||
Falls back to a 1:1 conversion with a warning when rates are unavailable.
|
||||
"""
|
||||
if source_currency == "USD":
|
||||
return amount
|
||||
rate = _rates.get(source_currency)
|
||||
if rate is None or rate == 0:
|
||||
logger.warning("No rate for %s → USD, assuming 1:1", source_currency)
|
||||
return amount
|
||||
return round(amount / rate, 2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def format_price(usd_amount: float) -> str:
|
||||
"""Return a formatted multi-currency price string.
|
||||
|
||||
Example: ``$14.99 · C$20.54 · €13.78 · £11.98``
|
||||
The configured *default_currency* is shown first, followed by each
|
||||
*extra_currency*. Example with defaults::
|
||||
|
||||
$14.99 · C$20.54 · €13.78 · £11.98
|
||||
|
||||
Falls back to USD-only if rates aren't available.
|
||||
"""
|
||||
parts = [f"${usd_amount:.2f}"]
|
||||
display_order = [_default_currency] + [
|
||||
c for c in _extra_currencies if c != _default_currency
|
||||
]
|
||||
|
||||
for cur in TARGET_CURRENCIES:
|
||||
converted = _convert(usd_amount, cur)
|
||||
parts: list[str] = []
|
||||
for cur in display_order:
|
||||
converted = _convert_from_usd(usd_amount, cur)
|
||||
if converted is not None:
|
||||
symbol = _SYMBOLS.get(cur, cur)
|
||||
symbol = SYMBOLS.get(cur, f"{cur} ")
|
||||
parts.append(f"{symbol}{converted:.2f}")
|
||||
|
||||
if not parts:
|
||||
# Fallback when no rates are loaded yet
|
||||
parts = [f"${usd_amount:.2f}"]
|
||||
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS thread_roots (
|
||||
category TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
@@ -77,3 +83,33 @@ class Database:
|
||||
(key, value),
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Thread root management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_thread_root(self, category: str) -> str | None:
|
||||
"""Return the Matrix event ID for a thread root, or None."""
|
||||
assert self._db is not None
|
||||
cursor = await self._db.execute(
|
||||
"SELECT event_id FROM thread_roots WHERE category = ?", (category,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
async def set_thread_root(self, category: str, event_id: str):
|
||||
"""Store the Matrix event ID for a thread root."""
|
||||
assert self._db is not None
|
||||
await self._db.execute(
|
||||
"INSERT OR REPLACE INTO thread_roots (category, event_id) VALUES (?, ?)",
|
||||
(category, event_id),
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
async def clear_thread_root(self, category: str):
|
||||
"""Remove a stored thread root (e.g. if the event was redacted)."""
|
||||
assert self._db is not None
|
||||
await self._db.execute(
|
||||
"DELETE FROM thread_roots WHERE category = ?", (category,)
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
"""Message formatting for Matrix deal posts.
|
||||
|
||||
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
|
||||
|
||||
import markdown
|
||||
from html import escape
|
||||
|
||||
from .cheapshark import CheapSharkDeal
|
||||
from .currency import format_price
|
||||
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)
|
||||
from .itad_deals import ITADDeal
|
||||
|
||||
|
||||
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)
|
||||
normal_display = format_price(normal_price)
|
||||
title = escape(deal.title)
|
||||
|
||||
lines = [
|
||||
f"**🎮 [DEAL] {deal.title}**",
|
||||
f"> {discount}% off on {deal.store_name} ~~{normal_display}~~",
|
||||
f"> 💰 **{sale_multi}**",
|
||||
html_lines = [
|
||||
f"<strong>🎮 [DEAL] {title}</strong>",
|
||||
f"{discount}% off on {escape(deal.store_name)} <del>{escape(normal_display)}</del>",
|
||||
f"💰 <strong>{escape(sale_multi)}</strong>",
|
||||
]
|
||||
|
||||
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 = [
|
||||
f"🎮 [DEAL] {deal.title}",
|
||||
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:
|
||||
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_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)
|
||||
|
||||
return plain_text, html
|
||||
@@ -66,26 +87,25 @@ def format_epic_free(game: EpicFreeGame) -> tuple[str, str]:
|
||||
|
||||
Returns (body, formatted_body).
|
||||
"""
|
||||
lines = [
|
||||
f"**🆓 [FREE] {game.title}**",
|
||||
"> Free on Epic Games Store",
|
||||
title = escape(game.title)
|
||||
|
||||
html_lines = [
|
||||
f"<strong>🆓 [FREE] {title}</strong>",
|
||||
"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}_")
|
||||
html_lines.append(f"📅 <em>Free until {date_str}</em>")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
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 = [
|
||||
f"🆓 [FREE] {game.title}",
|
||||
" Free on Epic Games Store",
|
||||
@@ -99,7 +119,6 @@ def format_epic_free(game: EpicFreeGame) -> tuple[str, str]:
|
||||
pass
|
||||
if game.url:
|
||||
plain_lines.append(f" 🔗 {game.url}")
|
||||
|
||||
plain_text = "\n".join(plain_lines)
|
||||
|
||||
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]:
|
||||
"""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",
|
||||
title = escape(game.title)
|
||||
|
||||
html_lines = [
|
||||
f"<strong>📢 [UPCOMING FREE] {title}</strong>",
|
||||
"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}_")
|
||||
html_lines.append(f"📅 <em>Free until {date_str}</em>")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if game.url:
|
||||
lines.append(f"> 🔗 [Store Page]({game.url})")
|
||||
|
||||
md_text = "\n".join(lines)
|
||||
html = _render_html(md_text)
|
||||
html_lines.append(f'🔗 <a href="{escape(game.url)}">Store Page</a>')
|
||||
html = "<br>\n".join(html_lines)
|
||||
|
||||
plain_lines = [
|
||||
f"📢 [UPCOMING FREE] {game.title}",
|
||||
@@ -132,7 +151,6 @@ def format_epic_upcoming(game: EpicFreeGame) -> tuple[str, str]:
|
||||
]
|
||||
if game.url:
|
||||
plain_lines.append(f" 🔗 {game.url}")
|
||||
|
||||
plain_text = "\n".join(plain_lines)
|
||||
|
||||
return plain_text, html
|
||||
|
||||
@@ -7,6 +7,29 @@ logger = logging.getLogger(__name__)
|
||||
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(
|
||||
client: httpx.AsyncClient,
|
||||
api_key: str,
|
||||
@@ -19,18 +42,22 @@ async def check_historical_lows(
|
||||
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]
|
||||
# Look up ITAD game UUIDs for each Steam app ID
|
||||
itad_id_to_steam: dict[str, str] = {}
|
||||
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:
|
||||
resp = await client.get(
|
||||
resp = await client.post(
|
||||
f"{BASE_URL}/games/overview/v2",
|
||||
params={"key": api_key, "shops[]": "steam"},
|
||||
headers={"Content-Type": "application/json"},
|
||||
params={"key": api_key, "shops": [61]},
|
||||
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()
|
||||
data = resp.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
@@ -38,18 +65,16 @@ async def check_historical_lows(
|
||||
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", {})
|
||||
for price_entry in data.get("prices", []):
|
||||
itad_id = price_entry.get("id")
|
||||
steam_id = itad_id_to_steam.get(itad_id)
|
||||
if not steam_id:
|
||||
continue
|
||||
lowest = price_entry.get("lowest")
|
||||
current = price_entry.get("current")
|
||||
if lowest and current:
|
||||
lowest_price = lowest.get("price", float("inf"))
|
||||
current_price = current.get("price", float("inf"))
|
||||
lowest_price = lowest.get("price", {}).get("amount", float("inf"))
|
||||
current_price = current.get("price", {}).get("amount", float("inf"))
|
||||
results[steam_id] = current_price <= lowest_price
|
||||
|
||||
logger.info(
|
||||
@@ -69,13 +94,15 @@ async def check_single_historical_low(
|
||||
if not api_key or not steam_app_id:
|
||||
return False
|
||||
|
||||
itad_id = await _lookup_game_id(client, api_key, steam_app_id)
|
||||
if not itad_id:
|
||||
return False
|
||||
|
||||
try:
|
||||
resp = await client.get(
|
||||
resp = await client.post(
|
||||
f"{BASE_URL}/games/overview/v2",
|
||||
params={
|
||||
"key": api_key,
|
||||
"apps[]": f"app/{steam_app_id}",
|
||||
},
|
||||
params={"key": api_key},
|
||||
json=[itad_id],
|
||||
)
|
||||
resp.raise_for_status()
|
||||
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)
|
||||
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"):
|
||||
for price_entry in data.get("prices", []):
|
||||
lowest = price_entry.get("lowest")
|
||||
current = price_entry.get("current")
|
||||
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 False
|
||||
|
||||
198
gaming_deals_bot/itad_deals.py
Normal file
198
gaming_deals_bot/itad_deals.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""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
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Matrix client wrapper for sending deal messages."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from nio import AsyncClient, RoomSendError
|
||||
from nio import AsyncClient, PresenceSetError, RoomSendError, RoomSendResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PRESENCE_HEARTBEAT_INTERVAL = 60 # seconds
|
||||
|
||||
|
||||
class MatrixDealsClient:
|
||||
def __init__(
|
||||
@@ -19,6 +22,7 @@ class MatrixDealsClient:
|
||||
self._client = AsyncClient(homeserver_url, user_id)
|
||||
self._client.access_token = access_token
|
||||
self._client.user_id = user_id
|
||||
self._heartbeat_task: asyncio.Task | None = None
|
||||
|
||||
async def send_deal(self, plain_text: str, html: str) -> bool:
|
||||
"""Send a deal message to the configured room.
|
||||
@@ -47,5 +51,134 @@ class MatrixDealsClient:
|
||||
logger.error("Matrix send error: %s", exc)
|
||||
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 send_deal_in_thread(
|
||||
self, plain_text: str, html: str, thread_root_id: str
|
||||
) -> bool:
|
||||
"""Send a deal message as a reply inside a Matrix thread.
|
||||
|
||||
``thread_root_id`` is the ``$event_id`` of the thread root message.
|
||||
"""
|
||||
content = {
|
||||
"msgtype": "m.text",
|
||||
"format": "org.matrix.custom.html",
|
||||
"body": plain_text,
|
||||
"formatted_body": html,
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": thread_root_id,
|
||||
# Fallback for clients that don't support threads
|
||||
"is_falling_back": True,
|
||||
"m.in_reply_to": {"event_id": thread_root_id},
|
||||
},
|
||||
}
|
||||
|
||||
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 threaded message: %s", resp.message)
|
||||
return False
|
||||
logger.debug("Threaded message sent: %s (thread %s)", resp.event_id, thread_root_id)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Matrix threaded send error: %s", exc)
|
||||
return False
|
||||
|
||||
async def create_thread_root(self, plain_text: str, html: str) -> str | None:
|
||||
"""Send a message that will serve as a thread root.
|
||||
|
||||
Returns the ``$event_id`` on success, or ``None`` 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 create thread root: %s", resp.message)
|
||||
return None
|
||||
assert isinstance(resp, RoomSendResponse)
|
||||
logger.info("Thread root created: %s", resp.event_id)
|
||||
return resp.event_id
|
||||
except Exception as exc:
|
||||
logger.error("Matrix thread root error: %s", exc)
|
||||
return None
|
||||
|
||||
async def set_presence(self, presence: str) -> bool:
|
||||
"""Set the bot's presence status on the homeserver.
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
try:
|
||||
resp = await self._client.set_presence(presence)
|
||||
if isinstance(resp, PresenceSetError):
|
||||
logger.error("Failed to set presence to %s: %s", presence, resp.message)
|
||||
return False
|
||||
logger.debug("Presence set to %s", presence)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Presence set error: %s", exc)
|
||||
return False
|
||||
|
||||
async def start_presence_heartbeat(self):
|
||||
"""Set presence to online and spawn a background task to keep it alive."""
|
||||
await self.set_presence("online")
|
||||
self._heartbeat_task = asyncio.create_task(self._presence_heartbeat_loop())
|
||||
logger.info("Presence heartbeat started (every %ds)", PRESENCE_HEARTBEAT_INTERVAL)
|
||||
|
||||
async def _presence_heartbeat_loop(self):
|
||||
"""Re-send 'online' presence every PRESENCE_HEARTBEAT_INTERVAL seconds."""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(PRESENCE_HEARTBEAT_INTERVAL)
|
||||
await self.set_presence("online")
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def stop_presence_heartbeat(self):
|
||||
"""Cancel the heartbeat task and set presence to offline."""
|
||||
if self._heartbeat_task is not None:
|
||||
self._heartbeat_task.cancel()
|
||||
try:
|
||||
await self._heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._heartbeat_task = None
|
||||
await self.set_presence("offline")
|
||||
logger.info("Presence heartbeat stopped, set to offline")
|
||||
|
||||
async def close(self):
|
||||
await self.stop_presence_heartbeat()
|
||||
await self._client.close()
|
||||
|
||||
@@ -8,7 +8,7 @@ from nio import AsyncClient, LoginError, RoomResolveAliasError
|
||||
|
||||
from .config import Config
|
||||
from .cheapshark import BASE_URL as CHEAPSHARK_URL
|
||||
from .currency import FRANKFURTER_URL, TARGET_CURRENCIES
|
||||
from .currency import FRANKFURTER_URL, configure as configure_currency, _all_target_currencies
|
||||
from .epic import FREE_GAMES_URL
|
||||
from .itad import BASE_URL as ITAD_URL
|
||||
|
||||
@@ -45,6 +45,9 @@ async def run_preflight(config: Config) -> bool:
|
||||
print(f"\n{_BOLD}Pastel — preflight checks{_RESET}\n")
|
||||
all_ok = True
|
||||
|
||||
# Configure currency display so the Frankfurter check fetches the right symbols
|
||||
configure_currency(config.default_currency, config.extra_currencies)
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as http:
|
||||
# --- Matrix ---
|
||||
print(f"{_BOLD}Matrix{_RESET}")
|
||||
@@ -52,7 +55,10 @@ async def run_preflight(config: Config) -> bool:
|
||||
|
||||
# --- CheapShark ---
|
||||
print(f"\n{_BOLD}CheapShark{_RESET}")
|
||||
if "cheapshark" in config.deal_sources:
|
||||
all_ok &= await _check_cheapshark(http)
|
||||
else:
|
||||
_skip("Skipped", "not in DEAL_SOURCES")
|
||||
|
||||
# --- Epic Games Store ---
|
||||
print(f"\n{_BOLD}Epic Games Store{_RESET}")
|
||||
@@ -60,11 +66,12 @@ async def run_preflight(config: Config) -> bool:
|
||||
|
||||
# --- Frankfurter (exchange rates) ---
|
||||
print(f"\n{_BOLD}Frankfurter (exchange rates){_RESET}")
|
||||
all_ok &= await _check_frankfurter(http)
|
||||
all_ok &= await _check_frankfurter(http, config)
|
||||
|
||||
# --- IsThereAnyDeal (optional) ---
|
||||
# --- IsThereAnyDeal ---
|
||||
itad_required = "itad" in config.deal_sources
|
||||
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 ---
|
||||
print()
|
||||
@@ -149,10 +156,16 @@ async def _check_epic(http: httpx.AsyncClient) -> bool:
|
||||
return _fail("API reachable", str(exc))
|
||||
|
||||
|
||||
async def _check_frankfurter(http: httpx.AsyncClient) -> bool:
|
||||
async def _check_frankfurter(http: httpx.AsyncClient, config: Config) -> bool:
|
||||
"""Fetch exchange rates to confirm Frankfurter is reachable."""
|
||||
needed = _all_target_currencies()
|
||||
if not needed:
|
||||
return _pass(
|
||||
"Skipped",
|
||||
f"only {config.default_currency} configured — no conversion needed",
|
||||
)
|
||||
try:
|
||||
symbols = ",".join(TARGET_CURRENCIES)
|
||||
symbols = ",".join(needed)
|
||||
resp = await http.get(FRANKFURTER_URL, params={"base": "USD", "symbols": symbols})
|
||||
resp.raise_for_status()
|
||||
rates = resp.json().get("rates", {})
|
||||
@@ -164,20 +177,29 @@ async def _check_frankfurter(http: httpx.AsyncClient) -> bool:
|
||||
return _fail("API reachable", str(exc))
|
||||
|
||||
|
||||
async def _check_itad(http: httpx.AsyncClient, api_key: str) -> bool:
|
||||
"""Verify the ITAD API key works (optional — skipped if no key is set)."""
|
||||
async def _check_itad(http: httpx.AsyncClient, api_key: str, *, required: bool = False) -> bool:
|
||||
"""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 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)")
|
||||
|
||||
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(
|
||||
f"{ITAD_URL}/games/overview/v2",
|
||||
params={"key": api_key, "apps[]": "app/220"}, # Half-Life 2
|
||||
f"{ITAD_URL}/games/lookup/v1",
|
||||
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)")
|
||||
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:
|
||||
return _fail("API reachable", str(exc))
|
||||
|
||||
59
gaming_deals_bot/threads.py
Normal file
59
gaming_deals_bot/threads.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Thread category definitions for Matrix room threads.
|
||||
|
||||
When ``MATRIX_USE_THREADS=true``, deal messages are posted inside
|
||||
per-category threads rather than directly into the room timeline.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from html import escape
|
||||
|
||||
|
||||
class ThreadCategory(str, Enum):
|
||||
"""Categories that map to distinct Matrix threads."""
|
||||
|
||||
GAME_DEALS = "game_deals"
|
||||
DLC_DEALS = "dlc_deals"
|
||||
EPIC_FREE = "epic_free"
|
||||
NON_GAME_DEALS = "non_game_deals"
|
||||
|
||||
|
||||
# Display metadata for each thread category.
|
||||
# ``label`` is the thread root title; ``description`` appears below it.
|
||||
THREAD_META: dict[ThreadCategory, dict[str, str]] = {
|
||||
ThreadCategory.GAME_DEALS: {
|
||||
"label": "🎮 Game Deals",
|
||||
"description": "PC game deals from CheapShark and IsThereAnyDeal",
|
||||
},
|
||||
ThreadCategory.DLC_DEALS: {
|
||||
"label": "🧩 DLC Deals",
|
||||
"description": "DLC and expansion deals from IsThereAnyDeal",
|
||||
},
|
||||
ThreadCategory.EPIC_FREE: {
|
||||
"label": "🆓 Epic Free Games",
|
||||
"description": "Weekly free games from the Epic Games Store",
|
||||
},
|
||||
ThreadCategory.NON_GAME_DEALS: {
|
||||
"label": "📦 Non-Game Deals",
|
||||
"description": "Software, courses, and other non-game deals",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def itad_type_to_category(deal_type: str) -> ThreadCategory:
|
||||
"""Map an ITAD ``type`` value to a thread category."""
|
||||
if deal_type == "game":
|
||||
return ThreadCategory.GAME_DEALS
|
||||
if deal_type == "dlc":
|
||||
return ThreadCategory.DLC_DEALS
|
||||
return ThreadCategory.NON_GAME_DEALS
|
||||
|
||||
|
||||
def format_thread_root(category: ThreadCategory) -> tuple[str, str]:
|
||||
"""Return ``(plain_text, html)`` for a thread root message."""
|
||||
meta = THREAD_META[category]
|
||||
label = meta["label"]
|
||||
desc = meta["description"]
|
||||
|
||||
plain_text = f"{label}\n{desc}"
|
||||
html = f"<strong>{escape(label)}</strong><br>\n<em>{escape(desc)}</em>"
|
||||
return plain_text, html
|
||||
@@ -2,5 +2,4 @@ matrix-nio>=0.21.0
|
||||
httpx>=0.25.0
|
||||
aiosqlite>=0.19.0
|
||||
apscheduler>=3.10.0
|
||||
markdown>=3.5.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
Reference in New Issue
Block a user