From 2b1c606a805ba66594a073a362b80810094930b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 00:37:26 +0000 Subject: [PATCH] Add optional Matrix thread support for per-category deal organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 6 ++ README.md | 16 +++++ gaming_deals_bot/bot.py | 109 +++++++++++++++++++++++------- gaming_deals_bot/config.py | 5 ++ gaming_deals_bot/database.py | 36 ++++++++++ gaming_deals_bot/itad_deals.py | 9 +-- gaming_deals_bot/matrix_client.py | 66 +++++++++++++++++- gaming_deals_bot/threads.py | 59 ++++++++++++++++ 8 files changed, 273 insertions(+), 33 deletions(-) create mode 100644 gaming_deals_bot/threads.py diff --git a/.env.example b/.env.example index f70b443..1e26def 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,12 @@ EXTRA_CURRENCIES=CAD,EUR,GBP MIN_DISCOUNT_PERCENT=50 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 diff --git a/README.md b/README.md index f66e29b..060e1a1 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ All configuration is via environment variables (see `.env.example`): | `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 | @@ -104,6 +105,21 @@ This verifies: 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) diff --git a/gaming_deals_bot/bot.py b/gaming_deals_bot/bot.py index e55764c..91aed1b 100644 --- a/gaming_deals_bot/bot.py +++ b/gaming_deals_bot/bot.py @@ -14,6 +14,7 @@ from .formatter import format_deal, format_epic_free, format_epic_upcoming, form 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__) @@ -101,6 +102,43 @@ 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: @@ -122,6 +160,35 @@ class DealsBot: # Prune old records await self.db.prune_old(days=30) + async def _process_deal(self, deal: CheapSharkDeal): + """Check dedup, check historical low, format, and post a single deal.""" + if await self.db.has_been_posted(deal.dedup_id): + return + + # Check if this is a historical low via ITAD + is_historical_low = False + if self.config.itad_api_key and deal.steam_app_id: + is_historical_low = await check_single_historical_low( + self._http, + self.config.itad_api_key, + deal.steam_app_id, + ) + + plain_text, html = format_deal(deal, is_historical_low) + 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) + logger.info("Posted deal: %s", deal.title) + 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: @@ -147,43 +214,31 @@ class DealsBot: await self.db.prune_old(days=30) - async def _process_deal(self, deal: CheapSharkDeal): - """Check dedup, check historical low, format, and post a single deal.""" - if await self.db.has_been_posted(deal.dedup_id): - return - - # Check if this is a historical low via ITAD - is_historical_low = False - if self.config.itad_api_key and deal.steam_app_id: - is_historical_low = await check_single_historical_low( - self._http, - self.config.itad_api_key, - deal.steam_app_id, - ) - - plain_text, html = format_deal(deal, is_historical_low) - success = await self.matrix.send_deal(plain_text, html) - - if success: - await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title) - logger.info("Posted deal: %s", deal.title) - else: - logger.warning("Failed to post deal: %s — will retry next cycle", deal.title) - async def _process_itad_deal(self, deal: ITADDeal): """Check dedup, format, and post a single ITAD deal.""" if await self.db.has_been_posted(deal.dedup_id): return + 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.matrix.send_deal(plain_text, html) + 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", 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: @@ -208,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) diff --git a/gaming_deals_bot/config.py b/gaming_deals_bot/config.py index 98c424f..c9d1f27 100644 --- a/gaming_deals_bot/config.py +++ b/gaming_deals_bot/config.py @@ -62,6 +62,11 @@ class Config: ) self.itad_deals_limit = int(os.environ.get("ITAD_DEALS_LIMIT", "200")) + # Matrix threads — post deals into per-category threads + self.matrix_use_threads = os.environ.get( + "MATRIX_USE_THREADS", "false" + ).lower() in ("true", "1", "yes") + # Intro message on startup self.send_intro_message = os.environ.get( "SEND_INTRO_MESSAGE", "false" diff --git a/gaming_deals_bot/database.py b/gaming_deals_bot/database.py index 096be34..6e5a83b 100644 --- a/gaming_deals_bot/database.py +++ b/gaming_deals_bot/database.py @@ -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() diff --git a/gaming_deals_bot/itad_deals.py b/gaming_deals_bot/itad_deals.py index 19f7528..7d25547 100644 --- a/gaming_deals_bot/itad_deals.py +++ b/gaming_deals_bot/itad_deals.py @@ -19,6 +19,7 @@ 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) @@ -140,13 +141,8 @@ async def _fetch_country_deals( if not deal_data: continue - # Only include actual games and DLC — skip non-game content - # (courses, software bundles, etc.) - entry_type = entry.get("type") + entry_type = entry.get("type", "game") title = entry.get("title", "?") - if entry_type not in ("game", "dlc"): - logger.debug("Filtered out %s: type=%s", title, entry_type) - continue cut = deal_data.get("cut", 0) price_amount = deal_data.get("price", {}).get("amount", 0) @@ -182,6 +178,7 @@ async def _fetch_country_deals( 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, diff --git a/gaming_deals_bot/matrix_client.py b/gaming_deals_bot/matrix_client.py index 1b969ab..c9d7aa4 100644 --- a/gaming_deals_bot/matrix_client.py +++ b/gaming_deals_bot/matrix_client.py @@ -3,7 +3,7 @@ import asyncio import logging -from nio import AsyncClient, PresenceSetError, RoomSendError +from nio import AsyncClient, PresenceSetError, RoomSendError, RoomSendResponse logger = logging.getLogger(__name__) @@ -72,6 +72,70 @@ 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. diff --git a/gaming_deals_bot/threads.py b/gaming_deals_bot/threads.py new file mode 100644 index 0000000..b48eb6c --- /dev/null +++ b/gaming_deals_bot/threads.py @@ -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"{escape(label)}
\n{escape(desc)}" + return plain_text, html