Merge pull request #15 from prosolis/claude/multi-country-currency-deals-2nZGh
Add optional Matrix thread support for per-category deal organization
This commit is contained in:
@@ -37,6 +37,12 @@ EXTRA_CURRENCIES=CAD,EUR,GBP
|
|||||||
MIN_DISCOUNT_PERCENT=50
|
MIN_DISCOUNT_PERCENT=50
|
||||||
MAX_PRICE=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
|
||||||
|
|
||||||
|
|||||||
16
README.md
16
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`) |
|
| `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 |
|
| `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) |
|
| `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 |
|
||||||
|
|
||||||
@@ -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.
|
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)
|
||||||
|
|||||||
@@ -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 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__)
|
||||||
|
|
||||||
@@ -101,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:
|
||||||
@@ -122,6 +160,35 @@ class DealsBot:
|
|||||||
# Prune old records
|
# Prune old records
|
||||||
await self.db.prune_old(days=30)
|
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):
|
async def check_itad_deals(self):
|
||||||
"""Poll IsThereAnyDeal for deals and post new ones."""
|
"""Poll IsThereAnyDeal for deals and post new ones."""
|
||||||
if not self._first_run_done:
|
if not self._first_run_done:
|
||||||
@@ -147,43 +214,31 @@ class DealsBot:
|
|||||||
|
|
||||||
await self.db.prune_old(days=30)
|
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):
|
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:
|
||||||
@@ -208,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)
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ class Config:
|
|||||||
)
|
)
|
||||||
self.itad_deals_limit = int(os.environ.get("ITAD_DEALS_LIMIT", "200"))
|
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(
|
||||||
"SEND_INTRO_MESSAGE", "false"
|
"SEND_INTRO_MESSAGE", "false"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class ITADDeal:
|
|||||||
game_id: str # ITAD UUID
|
game_id: str # ITAD UUID
|
||||||
slug: str
|
slug: str
|
||||||
title: str
|
title: str
|
||||||
|
deal_type: str # ITAD type: "game", "dlc", or other
|
||||||
sale_price: float # current deal price (normalised to USD)
|
sale_price: float # current deal price (normalised to USD)
|
||||||
normal_price: float # regular (non-sale) 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)
|
||||||
@@ -140,13 +141,8 @@ async def _fetch_country_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)
|
||||||
@@ -182,6 +178,7 @@ async def _fetch_country_deals(
|
|||||||
game_id=entry.get("id", ""),
|
game_id=entry.get("id", ""),
|
||||||
slug=entry.get("slug", ""),
|
slug=entry.get("slug", ""),
|
||||||
title=title,
|
title=title,
|
||||||
|
deal_type=entry_type,
|
||||||
sale_price=price_usd,
|
sale_price=price_usd,
|
||||||
normal_price=regular_usd,
|
normal_price=regular_usd,
|
||||||
discount=cut,
|
discount=cut,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
59
gaming_deals_bot/threads.py
Normal file
59
gaming_deals_bot/threads.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Thread category definitions for Matrix room threads.
|
||||||
|
|
||||||
|
When ``MATRIX_USE_THREADS=true``, deal messages are posted inside
|
||||||
|
per-category threads rather than directly into the room timeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadCategory(str, Enum):
|
||||||
|
"""Categories that map to distinct Matrix threads."""
|
||||||
|
|
||||||
|
GAME_DEALS = "game_deals"
|
||||||
|
DLC_DEALS = "dlc_deals"
|
||||||
|
EPIC_FREE = "epic_free"
|
||||||
|
NON_GAME_DEALS = "non_game_deals"
|
||||||
|
|
||||||
|
|
||||||
|
# Display metadata for each thread category.
|
||||||
|
# ``label`` is the thread root title; ``description`` appears below it.
|
||||||
|
THREAD_META: dict[ThreadCategory, dict[str, str]] = {
|
||||||
|
ThreadCategory.GAME_DEALS: {
|
||||||
|
"label": "🎮 Game Deals",
|
||||||
|
"description": "PC game deals from CheapShark and IsThereAnyDeal",
|
||||||
|
},
|
||||||
|
ThreadCategory.DLC_DEALS: {
|
||||||
|
"label": "🧩 DLC Deals",
|
||||||
|
"description": "DLC and expansion deals from IsThereAnyDeal",
|
||||||
|
},
|
||||||
|
ThreadCategory.EPIC_FREE: {
|
||||||
|
"label": "🆓 Epic Free Games",
|
||||||
|
"description": "Weekly free games from the Epic Games Store",
|
||||||
|
},
|
||||||
|
ThreadCategory.NON_GAME_DEALS: {
|
||||||
|
"label": "📦 Non-Game Deals",
|
||||||
|
"description": "Software, courses, and other non-game deals",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def itad_type_to_category(deal_type: str) -> ThreadCategory:
|
||||||
|
"""Map an ITAD ``type`` value to a thread category."""
|
||||||
|
if deal_type == "game":
|
||||||
|
return ThreadCategory.GAME_DEALS
|
||||||
|
if deal_type == "dlc":
|
||||||
|
return ThreadCategory.DLC_DEALS
|
||||||
|
return ThreadCategory.NON_GAME_DEALS
|
||||||
|
|
||||||
|
|
||||||
|
def format_thread_root(category: ThreadCategory) -> tuple[str, str]:
|
||||||
|
"""Return ``(plain_text, html)`` for a thread root message."""
|
||||||
|
meta = THREAD_META[category]
|
||||||
|
label = meta["label"]
|
||||||
|
desc = meta["description"]
|
||||||
|
|
||||||
|
plain_text = f"{label}\n{desc}"
|
||||||
|
html = f"<strong>{escape(label)}</strong><br>\n<em>{escape(desc)}</em>"
|
||||||
|
return plain_text, html
|
||||||
Reference in New Issue
Block a user