4 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
10 changed files with 584 additions and 91 deletions

View File

@@ -11,10 +11,37 @@ ITAD_API_KEY=
# Requires ITAD_API_KEY when "itad" is included # Requires ITAD_API_KEY when "itad" is included
DEAL_SOURCES=cheapshark DEAL_SOURCES=cheapshark
# Deal filtering # ITAD countries: comma-separated ISO 3166-1 alpha-2 country codes
MIN_DEAL_RATING=8.0 # 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 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 an intro message to the room on startup (true/false)
SEND_INTRO_MESSAGE=false SEND_INTRO_MESSAGE=false

View File

@@ -55,6 +55,8 @@ python -m gaming_deals_bot
All configuration is via environment variables (see `.env.example`): All configuration is via environment variables (see `.env.example`):
### Core
| Variable | Required | Default | Description | | Variable | Required | Default | Description |
|---|---|---|---| |---|---|---|---|
| `MATRIX_HOMESERVER_URL` | Yes | — | Matrix homeserver URL | | `MATRIX_HOMESERVER_URL` | Yes | — | Matrix homeserver URL |
@@ -63,12 +65,28 @@ All configuration is via environment variables (see `.env.example`):
| `MATRIX_DEALS_ROOM_ID` | Yes | — | Room ID to post deals in | | `MATRIX_DEALS_ROOM_ID` | Yes | — | Room ID to post deals in |
| `ITAD_API_KEY` | No | — | IsThereAnyDeal API key (required when `itad` is in `DEAL_SOURCES`, optional otherwise for historical low detection) | | `ITAD_API_KEY` | No | — | IsThereAnyDeal API key (required when `itad` is in `DEAL_SOURCES`, optional otherwise for historical low detection) |
| `DEAL_SOURCES` | No | cheapshark | Comma-separated deal sources: `cheapshark`, `itad`, or `cheapshark,itad` | | `DEAL_SOURCES` | No | cheapshark | Comma-separated deal sources: `cheapshark`, `itad`, or `cheapshark,itad` |
| `MIN_DEAL_RATING` | No | 8.0 | Minimum CheapShark deal rating (0-10) | | `ITAD_COUNTRIES` | No | US | Comma-separated ISO 3166-1 alpha-2 country codes to fetch ITAD deals from (e.g. `US,CA,GB,DE`) |
| `MIN_DISCOUNT_PERCENT` | No | 50 | Minimum discount percentage | | `DEFAULT_CURRENCY` | No | USD | Primary display currency shown first in price strings |
| `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD | | `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 | | `SEND_INTRO_MESSAGE` | No | false | Send "The deals must flow." to the room on startup |
| `DATABASE_PATH` | No | deals.db | Path to SQLite database file | | `DATABASE_PATH` | No | deals.db | Path to SQLite database file |
### 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 ## Preflight Check
Run `--check` to validate your configuration and test connectivity to all services before starting the bot: Run `--check` to validate your configuration and test connectivity to all services before starting the bot:
@@ -87,10 +105,26 @@ This verifies:
The command exits with code 0 on success and 1 on failure, so it works in CI and Docker health-checks. The command exits with code 0 on success and 1 on failure, so it works in CI and Docker health-checks.
## 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 ## Behavior
- **First run**: fetches current deals and records them in the database without posting (avoids spamming the room with existing deals) - **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 - **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 - **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 - **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

@@ -7,13 +7,14 @@ import httpx
from .cheapshark import CheapSharkDeal, fetch_deals as fetch_cheapshark_deals from .cheapshark import CheapSharkDeal, fetch_deals as fetch_cheapshark_deals
from .config import Config from .config import Config
from .currency import refresh_rates from .currency import configure as configure_currency, refresh_rates
from .database import Database from .database import Database
from .epic import EpicFreeGame, fetch_free_games from .epic import EpicFreeGame, fetch_free_games
from .formatter import format_deal, format_epic_free, format_epic_upcoming, format_itad_deal from .formatter import format_deal, format_epic_free, format_epic_upcoming, format_itad_deal
from .itad import check_single_historical_low from .itad import check_single_historical_low
from .itad_deals import ITADDeal, fetch_deals as fetch_itad_deals from .itad_deals import ITADDeal, fetch_deals as fetch_itad_deals
from .matrix_client import MatrixDealsClient from .matrix_client import MatrixDealsClient
from .threads import ThreadCategory, format_thread_root, itad_type_to_category
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -35,6 +36,9 @@ class DealsBot:
"""Initialize database, fetch exchange rates, and run first-run population.""" """Initialize database, fetch exchange rates, and run first-run population."""
await self.db.connect() 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 # Pre-fetch exchange rates so the first deal post has conversions
await refresh_rates(self._http) await refresh_rates(self._http)
@@ -69,9 +73,9 @@ class DealsBot:
if "cheapshark" in self.config.deal_sources: if "cheapshark" in self.config.deal_sources:
deals = await fetch_cheapshark_deals( deals = await fetch_cheapshark_deals(
self._http, self._http,
max_price=self.config.max_price_usd, max_price=self.config.cheapshark_max_price,
min_rating=self.config.min_deal_rating, min_rating=self.config.cheapshark_min_rating,
min_discount=self.config.min_discount_percent, min_discount=self.config.cheapshark_min_discount,
) )
for deal in deals: for deal in deals:
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title) await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
@@ -81,8 +85,10 @@ class DealsBot:
itad_deals = await fetch_itad_deals( itad_deals = await fetch_itad_deals(
self._http, self._http,
self.config.itad_api_key, self.config.itad_api_key,
max_price=self.config.max_price_usd, countries=self.config.itad_countries,
min_discount=self.config.min_discount_percent, 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: for deal in itad_deals:
await self.db.mark_posted(deal.dedup_id, "itad", deal.title) await self.db.mark_posted(deal.dedup_id, "itad", deal.title)
@@ -96,6 +102,43 @@ class DealsBot:
return return
await self.matrix.send_notice("The deals must flow.") 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): async def check_cheapshark(self):
"""Poll CheapShark for deals and post new ones.""" """Poll CheapShark for deals and post new ones."""
if not self._first_run_done: if not self._first_run_done:
@@ -106,9 +149,9 @@ class DealsBot:
logger.info("Checking CheapShark for deals...") logger.info("Checking CheapShark for deals...")
deals = await fetch_cheapshark_deals( deals = await fetch_cheapshark_deals(
self._http, self._http,
max_price=self.config.max_price_usd, max_price=self.config.cheapshark_max_price,
min_rating=self.config.min_deal_rating, min_rating=self.config.cheapshark_min_rating,
min_discount=self.config.min_discount_percent, min_discount=self.config.cheapshark_min_discount,
) )
for deal in deals: for deal in deals:
@@ -117,29 +160,6 @@ class DealsBot:
# Prune old records # Prune old records
await self.db.prune_old(days=30) await self.db.prune_old(days=30)
async def check_itad_deals(self):
"""Poll IsThereAnyDeal for deals and post new ones."""
if not self._first_run_done:
return
if "itad" not in self.config.deal_sources:
return
if not self.config.itad_api_key:
logger.warning("ITAD deal source enabled but ITAD_API_KEY is not set")
return
logger.info("Checking IsThereAnyDeal for deals...")
deals = await fetch_itad_deals(
self._http,
self.config.itad_api_key,
max_price=self.config.max_price_usd,
min_discount=self.config.min_discount_percent,
)
for deal in deals:
await self._process_itad_deal(deal)
await self.db.prune_old(days=30)
async def _process_deal(self, deal: CheapSharkDeal): async def _process_deal(self, deal: CheapSharkDeal):
"""Check dedup, check historical low, format, and post a single deal.""" """Check dedup, check historical low, format, and post a single deal."""
if await self.db.has_been_posted(deal.dedup_id): if await self.db.has_been_posted(deal.dedup_id):
@@ -155,7 +175,9 @@ class DealsBot:
) )
plain_text, html = format_deal(deal, is_historical_low) 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: if success:
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title) await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
@@ -163,20 +185,60 @@ class DealsBot:
else: else:
logger.warning("Failed to post deal: %s — will retry next cycle", deal.title) logger.warning("Failed to post deal: %s — will retry next cycle", deal.title)
# ------------------------------------------------------------------
# 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): async def _process_itad_deal(self, deal: ITADDeal):
"""Check dedup, format, and post a single ITAD deal.""" """Check dedup, format, and post a single ITAD deal."""
if await self.db.has_been_posted(deal.dedup_id): if await self.db.has_been_posted(deal.dedup_id):
return 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) plain_text, html = format_itad_deal(deal)
success = await self.matrix.send_deal(plain_text, html) success = await self._send_to_thread_or_room(plain_text, html, category)
if success: if success:
await self.db.mark_posted(deal.dedup_id, "itad", deal.title) await self.db.mark_posted(deal.dedup_id, "itad", deal.title)
logger.info("Posted ITAD deal: %s", deal.title) logger.info("Posted ITAD deal: %s [%s]", deal.title, category.value)
else: else:
logger.warning("Failed to post ITAD deal: %s — will retry next cycle", deal.title) logger.warning("Failed to post ITAD deal: %s — will retry next cycle", deal.title)
# ------------------------------------------------------------------
# Epic Games Store
# ------------------------------------------------------------------
async def check_epic_free_games(self): async def check_epic_free_games(self):
"""Poll Epic Games Store for free games and post new ones.""" """Poll Epic Games Store for free games and post new ones."""
if not self._first_run_done: if not self._first_run_done:
@@ -201,7 +263,9 @@ class DealsBot:
else: else:
plain_text, html = format_epic_free(game) 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: if success:
await self.db.mark_posted(game.dedup_id, "epic", game.title) await self.db.mark_posted(game.dedup_id, "epic", game.title)

View File

@@ -20,10 +20,52 @@ class Config:
s.strip().lower() for s in raw_sources.split(",") if s.strip() s.strip().lower() for s in raw_sources.split(",") if s.strip()
] ]
# Deal filtering # ITAD countries: comma-separated ISO 3166-1 alpha-2 country codes
self.min_deal_rating = float(os.environ.get("MIN_DEAL_RATING", "8.0")) # Deals are fetched for each country and merged (first country has priority
self.min_discount_percent = int(os.environ.get("MIN_DISCOUNT_PERCENT", "50")) # when the same game appears in multiple regions).
self.max_price_usd = float(os.environ.get("MAX_PRICE_USD", "20")) 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 # Intro message on startup
self.send_intro_message = os.environ.get( 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). """Currency conversion using the Frankfurter API (ECB rates, no API key required).
Rates are cached in memory and refreshed at most twice per day. Rates are cached in memory and refreshed at most twice per day.
Call ``configure()`` before ``refresh_rates()`` to set the display currencies.
""" """
import logging import logging
@@ -11,13 +12,72 @@ import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
FRANKFURTER_URL = "https://api.frankfurter.dev/v1/latest" 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 CACHE_TTL_SECONDS = 43200 # 12 hours — be nice to the free service
# Module-level cache # Module-level state -------------------------------------------------------
_rates: dict[str, float] = {} _rates: dict[str, float] = {}
_last_fetched: float = 0.0 _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: async def refresh_rates(client: httpx.AsyncClient) -> bool:
"""Fetch latest USD-based exchange rates from Frankfurter. """Fetch latest USD-based exchange rates from Frankfurter.
@@ -26,7 +86,13 @@ async def refresh_rates(client: httpx.AsyncClient) -> bool:
""" """
global _rates, _last_fetched 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: try:
resp = await client.get( resp = await client.get(
FRANKFURTER_URL, FRANKFURTER_URL,
@@ -51,42 +117,69 @@ async def refresh_rates(client: httpx.AsyncClient) -> bool:
async def _ensure_rates(client: httpx.AsyncClient) -> None: async def _ensure_rates(client: httpx.AsyncClient) -> None:
"""Refresh rates if the cache is stale or empty.""" """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) 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) rate = _rates.get(currency)
if rate is None: if rate is None:
return None return None
return round(usd_amount * rate, 2) return round(usd_amount * rate, 2)
# Currency display symbols def convert_to_usd(amount: float, source_currency: str) -> float:
_SYMBOLS = { """Convert *amount* from *source_currency* to USD using cached rates.
"USD": "$",
"CAD": "C$",
"EUR": "",
"GBP": "£",
}
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: def format_price(usd_amount: float) -> str:
"""Return a formatted multi-currency price string. """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. 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: parts: list[str] = []
converted = _convert(usd_amount, cur) for cur in display_order:
converted = _convert_from_usd(usd_amount, cur)
if converted is not None: if converted is not None:
symbol = _SYMBOLS.get(cur, cur) symbol = SYMBOLS.get(cur, f"{cur} ")
parts.append(f"{symbol}{converted:.2f}") parts.append(f"{symbol}{converted:.2f}")
if not parts:
# Fallback when no rates are loaded yet
parts = [f"${usd_amount:.2f}"]
return " · ".join(parts) return " · ".join(parts)

View File

@@ -17,6 +17,12 @@ CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT 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), (key, value),
) )
await self._db.commit() 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

@@ -5,6 +5,7 @@ from dataclasses import dataclass
import httpx import httpx
from .currency import convert_to_usd
from .itad import BASE_URL from .itad import BASE_URL
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,8 +19,9 @@ class ITADDeal:
game_id: str # ITAD UUID game_id: str # ITAD UUID
slug: str slug: str
title: str title: str
sale_price: float # current deal price deal_type: str # ITAD type: "game", "dlc", or other
normal_price: float # regular (non-sale) price 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) discount: int # percentage off (0-100)
currency: str currency: str
shop_name: str shop_name: str
@@ -46,19 +48,75 @@ async def fetch_deals(
client: httpx.AsyncClient, client: httpx.AsyncClient,
api_key: str, api_key: str,
*, *,
countries: list[str] | None = None,
max_price: float = 20, max_price: float = 20,
min_discount: float = 50, min_discount: float = 50,
limit: int = 20, limit: int = 200,
) -> list[ITADDeal]: ) -> list[ITADDeal]:
"""Fetch current deals from IsThereAnyDeal. """Fetch current deals from IsThereAnyDeal across one or more countries.
Uses GET /deals/v2 with sorting by highest discount. 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: if not api_key:
return [] 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 = { params: dict = {
"key": api_key, "key": api_key,
"country": country,
"sort": "-cut", "sort": "-cut",
"limit": min(limit, 200), "limit": min(limit, 200),
"nondeals": "false", "nondeals": "false",
@@ -69,11 +127,13 @@ async def fetch_deals(
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
except (httpx.HTTPError, ValueError) as exc: except (httpx.HTTPError, ValueError) as exc:
logger.error("ITAD deals API error: %s", exc) logger.error("ITAD deals API error for country %s: %s", country, exc)
return [] return []
raw_list = data.get("list", []) raw_list = data.get("list", [])
logger.info("ITAD returned %d raw deals (before filtering)", len(raw_list)) logger.info(
"ITAD returned %d raw deals for %s (before filtering)", len(raw_list), country
)
deals: list[ITADDeal] = [] deals: list[ITADDeal] = []
for entry in raw_list: for entry in raw_list:
@@ -81,28 +141,30 @@ async def fetch_deals(
if not deal_data: if not deal_data:
continue continue
# Only include actual games and DLC — skip non-game content entry_type = entry.get("type", "game")
# (courses, software bundles, etc.)
entry_type = entry.get("type")
title = entry.get("title", "?") title = entry.get("title", "?")
if entry_type not in ("game", "dlc"):
logger.debug("Filtered out %s: type=%s", title, entry_type)
continue
cut = deal_data.get("cut", 0) cut = deal_data.get("cut", 0)
price_amount = deal_data.get("price", {}).get("amount", 0) price_amount = deal_data.get("price", {}).get("amount", 0)
regular_amount = deal_data.get("regular", {}).get("amount", 0) regular_amount = deal_data.get("regular", {}).get("amount", 0)
currency = deal_data.get("price", {}).get("currency", "USD") currency = deal_data.get("price", {}).get("currency", "USD")
# Apply filters # 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: if cut < min_discount:
logger.debug( logger.debug(
"Filtered out %s: discount %d%% < %d%%", title, cut, int(min_discount) "Filtered out %s: discount %d%% < %d%%",
title,
cut,
int(min_discount),
) )
continue continue
if price_amount > max_price: if price_usd > max_price:
logger.debug( logger.debug(
"Filtered out %s: price %.2f > %.2f", title, price_amount, max_price "Filtered out %s: price $%.2f > $%.2f", title, price_usd, max_price
) )
continue continue
@@ -116,10 +178,11 @@ async def fetch_deals(
game_id=entry.get("id", ""), game_id=entry.get("id", ""),
slug=entry.get("slug", ""), slug=entry.get("slug", ""),
title=title, title=title,
sale_price=price_amount, deal_type=entry_type,
normal_price=regular_amount, sale_price=price_usd,
normal_price=regular_usd,
discount=cut, discount=cut,
currency=currency, currency="USD", # normalised
shop_name=shop.get("name", "Unknown"), shop_name=shop.get("name", "Unknown"),
shop_id=shop.get("id", 0), shop_id=shop.get("id", 0),
url=deal_data.get("url", ""), url=deal_data.get("url", ""),
@@ -129,5 +192,7 @@ async def fetch_deals(
) )
) )
logger.info("ITAD returned %d deals after filtering", len(deals)) logger.info(
"ITAD returned %d deals for %s after filtering", len(deals), country
)
return deals return deals

View File

@@ -3,7 +3,7 @@
import asyncio import asyncio
import logging import logging
from nio import AsyncClient, PresenceSetError, RoomSendError from nio import AsyncClient, PresenceSetError, RoomSendError, RoomSendResponse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -72,6 +72,70 @@ class MatrixDealsClient:
logger.error("Matrix send error: %s", exc) logger.error("Matrix send error: %s", exc)
return False 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: async def set_presence(self, presence: str) -> bool:
"""Set the bot's presence status on the homeserver. """Set the bot's presence status on the homeserver.

View File

@@ -8,7 +8,7 @@ from nio import AsyncClient, LoginError, RoomResolveAliasError
from .config import Config from .config import Config
from .cheapshark import BASE_URL as CHEAPSHARK_URL 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 .epic import FREE_GAMES_URL
from .itad import BASE_URL as ITAD_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") print(f"\n{_BOLD}Pastel — preflight checks{_RESET}\n")
all_ok = True 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: async with httpx.AsyncClient(timeout=15) as http:
# --- Matrix --- # --- Matrix ---
print(f"{_BOLD}Matrix{_RESET}") print(f"{_BOLD}Matrix{_RESET}")
@@ -63,7 +66,7 @@ async def run_preflight(config: Config) -> bool:
# --- Frankfurter (exchange rates) --- # --- Frankfurter (exchange rates) ---
print(f"\n{_BOLD}Frankfurter (exchange rates){_RESET}") print(f"\n{_BOLD}Frankfurter (exchange rates){_RESET}")
all_ok &= await _check_frankfurter(http) all_ok &= await _check_frankfurter(http, config)
# --- IsThereAnyDeal --- # --- IsThereAnyDeal ---
itad_required = "itad" in config.deal_sources itad_required = "itad" in config.deal_sources
@@ -153,10 +156,16 @@ async def _check_epic(http: httpx.AsyncClient) -> bool:
return _fail("API reachable", str(exc)) 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.""" """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: try:
symbols = ",".join(TARGET_CURRENCIES) symbols = ",".join(needed)
resp = await http.get(FRANKFURTER_URL, params={"base": "USD", "symbols": symbols}) resp = await http.get(FRANKFURTER_URL, params={"base": "USD", "symbols": symbols})
resp.raise_for_status() resp.raise_for_status()
rates = resp.json().get("rates", {}) rates = resp.json().get("rates", {})

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