16 Commits

Author SHA1 Message Date
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
Claude
f13bfd7b07 Default ITAD_DEALS_LIMIT to 200 (API max) to maximise deal coverage
Since the ITAD API only sorts by discount or price (no time-based sort),
fetching the full 200-deal window per country ensures our client-side
filtering sees every available deal rather than just the top N by discount.
The dedup database already prevents reposting, so the extra data is cheap.

https://claude.ai/code/session_01EfPjktyrF24DBNHWsY1KBP
2026-02-28 23:42:54 +00:00
Claude
e4d90d0331 Add multi-country ITAD deals, configurable currency, and per-source filtering
- ITAD deals can now be fetched from multiple countries simultaneously via
  ITAD_COUNTRIES (e.g. US,CA,GB,DE). Deals are merged across regions and
  deduplicated by game+shop, with the first country taking priority.

- Prices from non-USD regions are normalised to USD using exchange rates
  so filtering (max price) works consistently across countries.

- ITAD results are now sorted by newest deals first (client-side by
  timestamp) instead of only by highest discount, reducing missed deals.

- Default fetch limit raised from 20 to 100 to capture more deals.

- Display currency is now configurable: DEFAULT_CURRENCY sets the primary
  currency shown first, EXTRA_CURRENCIES sets additional ones. All
  Frankfurter-supported currencies are available with proper symbols.

- Filtering is now per-source with dedicated env vars:
  CHEAPSHARK_MIN_DISCOUNT, CHEAPSHARK_MIN_RATING, CHEAPSHARK_MAX_PRICE,
  ITAD_MIN_DISCOUNT, ITAD_MAX_PRICE, ITAD_DEALS_LIMIT. Shared fallbacks
  (MIN_DISCOUNT_PERCENT, MAX_PRICE) are still supported. Legacy
  MAX_PRICE_USD and MIN_DEAL_RATING continue to work as fallbacks.

https://claude.ai/code/session_01EfPjktyrF24DBNHWsY1KBP
2026-02-28 23:40:36 +00:00
prosolis
da957f82ba Merge pull request #13 from prosolis/claude/add-presence-heartbeat-WckxL
Add presence heartbeat to keep bot shown as online
2026-02-28 00:17:05 -08:00
Claude
30c9872d04 Add presence heartbeat to keep bot shown as online
Adds a background asyncio.Task that calls set_presence("online") every
60 seconds to keep the homeserver presence status fresh. On startup the
bot sets itself online and spawns the heartbeat; on shutdown the task is
cancelled and presence is set to offline before closing the client.

https://claude.ai/code/session_01Nu3N2okrfJV3meatEmYdrB
2026-02-28 08:14:46 +00:00
prosolis
123252e197 Merge pull request #12 from prosolis/claude/fix-isthereanydeal-api-W5umt
Fixed post formatting issues
2026-02-27 21:33:00 -08:00
Claude
c5ae8543e1 Fix Matrix formatting: build HTML directly instead of markdown conversion
The markdown library's conversion collapsed blockquote lines into a
single paragraph, causing all deal info to render on one line in Matrix
clients. Build HTML directly with <br> tags for reliable line breaks.
Also adds html.escape() for proper escaping of titles and prices, and
removes the unused markdown dependency.

https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 05:23:37 +00:00
Claude
949eceb983 Filter ITAD deals to only include games and DLC
Non-game content (courses, software bundles) was showing up in deal
posts. Filter on the API's type field to only include entries with
type "game" or "dlc".

https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 05:14:27 +00:00
prosolis
1dcb76a394 Merge pull request #11 from prosolis/claude/fix-isthereanydeal-api-W5umt
CheapShark kinda sucks.. let's move to ITAD instead
2026-02-27 21:06:12 -08:00
Claude
d1a33e97c5 Update README to document ITAD as a deal source and DEAL_SOURCES config
https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 05:04:24 +00:00
Claude
34da126507 Add IsThereAnyDeal as a deal source alongside CheapShark
Users can now choose between CheapShark, ITAD, or both via the
DEAL_SOURCES env var (comma-separated, default: "cheapshark"). When
"itad" is included, the bot polls GET /deals/v2 for current deals with
discount/price filtering and posts them with the same formatting style.
ITAD deals include built-in historical low detection via the API's
deal flags. Preflight checks enforce ITAD_API_KEY when ITAD is a
configured deal source.

https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 04:59:27 +00:00
Claude
72d86fd4e5 Fix IsThereAnyDeal API: use POST for overview endpoint and lookup game UUIDs
The ITAD v2 API requires POST (not GET) for /games/overview/v2, and
expects ITAD game UUIDs in the JSON body rather than Steam app IDs.
Added a lookup step via GET /games/lookup/v1 to convert Steam app IDs
to ITAD UUIDs before calling the overview endpoint. Updated preflight
check to use the lookup endpoint for API key validation.

https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 04:47:00 +00:00
prosolis
a83f230291 Merge pull request #10 from prosolis/claude/fix-cheapshark-rating-bug-pxVJv
Post Epic free games on first run instead of silently recording them
2026-02-27 19:38:10 -08:00
prosolis
a334cb7bbe Merge pull request #9 from prosolis/claude/fix-cheapshark-rating-bug-pxVJv
Add SEND_INTRO_MESSAGE to .env.example
2026-02-27 19:13:29 -08:00
prosolis
66407c5a43 Merge pull request #8 from prosolis/claude/fix-cheapshark-rating-bug-pxVJv
More bugfixes
2026-02-27 19:05:26 -08:00
prosolis
19c070405e Merge pull request #7 from prosolis/claude/fix-cheapshark-rating-bug-pxVJv
Fix cheapshark rating and sorting issue
2026-02-27 18:50:37 -08:00
14 changed files with 969 additions and 149 deletions

View File

@@ -4,13 +4,44 @@ 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

View File

@@ -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,19 +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:
@@ -77,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.

View File

@@ -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,12 +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

View File

@@ -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,23 +61,40 @@ class DealsBot:
await self.db.close()
async def _populate_initial_state(self):
"""Fetch current CheapShark deals and record them without posting (avoids spam on first run).
"""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.
"""
deals = await fetch_deals(
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)
logger.info("First run: recorded %d existing CheapShark deals", 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,
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)
logger.info("First run: recorded %d existing deals", total)
async def send_intro(self):
"""Send an intro message to the Matrix room if configured."""
@@ -77,17 +102,56 @@ class DealsBot:
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:
@@ -111,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)
@@ -119,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:
@@ -143,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)

View File

@@ -14,10 +14,58 @@ 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(

View File

@@ -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": "",
"DKK": "kr",
"HUF": "Ft",
"INR": "",
"JPY": "¥",
"KRW": "",
"MXN": "MX$",
"NOK": "kr",
"NZD": "NZ$",
"PLN": "",
"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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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.
@@ -68,5 +72,113 @@ class MatrixDealsClient:
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()

View File

@@ -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))

View 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

View File

@@ -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