Merge pull request #1 from prosolis/claude/gaming-deals-matrix-bot-ONjS7

Add gaming deals Matrix bot
This commit is contained in:
prosolis
2026-02-27 16:07:45 -08:00
committed by GitHub
15 changed files with 931 additions and 1 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.git
.env
*.db
__pycache__
*.pyc

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Matrix connection
MATRIX_HOMESERVER_URL=https://matrix.example.com
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)
ITAD_API_KEY=
# Deal filtering
MIN_DEAL_RATING=80
MIN_DISCOUNT_PERCENT=50
MAX_PRICE_USD=20
# Database path (inside the container, mount a volume for persistence)
DATABASE_PATH=/data/deals.db

10
Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY gaming_deals_bot/ gaming_deals_bot/
CMD ["python", "-m", "gaming_deals_bot"]

View File

@@ -1 +1,51 @@
# Pastel
A Matrix bot that posts gaming deals and free game alerts to a specified Matrix room.
Deals are sourced from PC/digital storefronts only (Steam, GOG, Humble Store, GreenManGaming, Epic Games Store) — universally accessible regardless of region.
## Data Sources
- **CheapShark** — polled every 2 hours for top deals across Steam, GOG, Humble Store, and GreenManGaming
- **Epic Games Store** — polled daily for free game promotions
- **IsThereAnyDeal** (optional) — flags deals that are at an all-time historical low price
## Quick Start
1. Copy `.env.example` to `.env` and fill in your Matrix credentials and room ID
2. Run with Docker:
```bash
docker build -t pastel .
docker run --env-file .env -v pastel-data:/data pastel
```
Or run directly with Python 3.12+:
```bash
pip install -r requirements.txt
python -m gaming_deals_bot
```
## Configuration
All configuration is via environment variables (see `.env.example`):
| 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 | 80 | Minimum CheapShark deal rating (0-100) |
| `MIN_DISCOUNT_PERCENT` | No | 50 | Minimum discount percentage |
| `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD |
| `DATABASE_PATH` | No | deals.db | Path to SQLite database file |
## 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

View File

View File

@@ -0,0 +1,82 @@
"""Entry point — sets up logging, scheduler, and runs the bot."""
import asyncio
import logging
import signal
import sys
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from .bot import DealsBot
from .config import Config
def setup_logging():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
stream=sys.stdout,
)
async def main():
setup_logging()
logger = logging.getLogger("gaming_deals_bot")
try:
config = Config()
except ValueError as exc:
logger.error("Configuration error: %s", exc)
sys.exit(1)
bot = DealsBot(config)
await bot.start()
scheduler = AsyncIOScheduler()
# CheapShark: every 2 hours
scheduler.add_job(
bot.check_cheapshark,
"interval",
hours=2,
id="cheapshark",
name="CheapShark deals check",
)
# Epic free games: once daily
scheduler.add_job(
bot.check_epic_free_games,
"interval",
hours=24,
id="epic_free",
name="Epic free games check",
)
scheduler.start()
logger.info("Bot started — scheduler running")
# Run initial checks immediately (after first-run population is done)
await bot.check_cheapshark()
await bot.check_epic_free_games()
# Keep running until signaled to stop
stop_event = asyncio.Event()
def handle_signal():
logger.info("Shutdown signal received")
stop_event.set()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, handle_signal)
await stop_event.wait()
logger.info("Shutting down...")
scheduler.shutdown(wait=False)
await bot.stop()
logger.info("Bot stopped")
if __name__ == "__main__":
asyncio.run(main())

145
gaming_deals_bot/bot.py Normal file
View File

@@ -0,0 +1,145 @@
"""Main bot orchestration — ties together API clients, database, and Matrix posting."""
import asyncio
import logging
import httpx
from .cheapshark import CheapSharkDeal, fetch_deals
from .config import Config
from .database import Database
from .epic import EpicFreeGame, fetch_free_games
from .formatter import format_deal, format_epic_free, format_epic_upcoming
from .itad import check_single_historical_low
from .matrix_client import MatrixDealsClient
logger = logging.getLogger(__name__)
class DealsBot:
def __init__(self, config: Config):
self.config = config
self.db = Database(config.database_path)
self.matrix = MatrixDealsClient(
homeserver_url=config.matrix_homeserver_url,
user_id=config.matrix_bot_user_id,
access_token=config.matrix_bot_access_token,
room_id=config.matrix_deals_room_id,
)
self._http = httpx.AsyncClient(timeout=30)
self._first_run_done = False
async def start(self):
"""Initialize database and run first-run population."""
await self.db.connect()
first_run = await self.db.get_config("first_run_done")
if first_run != "true":
logger.info("First run detected — populating database without posting")
await self._populate_initial_state()
await self.db.set_config("first_run_done", "true")
self._first_run_done = True
else:
self._first_run_done = True
async def stop(self):
"""Clean shutdown."""
await self._http.aclose()
await self.matrix.close()
await self.db.close()
async def _populate_initial_state(self):
"""Fetch current deals and record them without posting (avoids spam on first run)."""
deals = await fetch_deals(
self._http,
max_price=self.config.max_price_usd,
min_rating=self.config.min_deal_rating,
min_discount=self.config.min_discount_percent,
)
for deal in deals:
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
current_free, upcoming = await fetch_free_games(self._http)
for game in current_free:
await self.db.mark_posted(game.dedup_id, "epic", game.title)
for game in upcoming:
await self.db.mark_posted(game.dedup_id, "epic", game.title)
total = len(deals) + len(current_free) + len(upcoming)
logger.info("First run: recorded %d existing deals/games", total)
async def check_cheapshark(self):
"""Poll CheapShark for deals and post new ones."""
if not self._first_run_done:
return
logger.info("Checking CheapShark for deals...")
deals = await fetch_deals(
self._http,
max_price=self.config.max_price_usd,
min_rating=self.config.min_deal_rating,
min_discount=self.config.min_discount_percent,
)
for deal in deals:
await self._process_deal(deal)
# 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.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 check_epic_free_games(self):
"""Poll Epic Games Store for free games and post new ones."""
if not self._first_run_done:
return
logger.info("Checking Epic Games Store for free games...")
current_free, upcoming = await fetch_free_games(self._http)
for game in current_free:
await self._process_epic_game(game, is_upcoming=False)
for game in upcoming:
await self._process_epic_game(game, is_upcoming=True)
async def _process_epic_game(self, game: EpicFreeGame, *, is_upcoming: bool):
"""Check dedup, format, and post a single Epic free game."""
if await self.db.has_been_posted(game.dedup_id):
return
if is_upcoming:
plain_text, html = format_epic_upcoming(game)
else:
plain_text, html = format_epic_free(game)
success = await self.matrix.send_deal(plain_text, html)
if success:
await self.db.mark_posted(game.dedup_id, "epic", game.title)
logger.info("Posted Epic game: %s (upcoming=%s)", game.title, is_upcoming)
else:
logger.warning(
"Failed to post Epic game: %s — will retry next cycle", game.title
)

View File

@@ -0,0 +1,101 @@
import logging
from dataclasses import dataclass
import httpx
logger = logging.getLogger(__name__)
BASE_URL = "https://www.cheapshark.com/api/1.0"
# CheapShark store IDs for PC/digital storefronts
STORE_IDS = {
"1": "Steam",
"7": "GOG",
"11": "Humble Store",
"23": "GreenManGaming",
}
@dataclass
class CheapSharkDeal:
deal_id: str
game_id: str
title: str
sale_price: str
normal_price: str
savings: float # percentage off
deal_rating: float
store_id: str
last_change: int # unix timestamp
steam_app_id: str | None
@property
def store_name(self) -> str:
return STORE_IDS.get(self.store_id, f"Store {self.store_id}")
@property
def deal_url(self) -> str:
return f"https://www.cheapshark.com/redirect?dealID={self.deal_id}"
@property
def dedup_id(self) -> str:
return f"cheapshark-{self.game_id}-{self.last_change}"
async def fetch_deals(
client: httpx.AsyncClient,
*,
max_price: float = 20,
min_rating: int = 80,
min_discount: int = 50,
page_size: int = 10,
) -> list[CheapSharkDeal]:
"""Fetch top deals from CheapShark across configured stores."""
store_ids = ",".join(STORE_IDS.keys())
params = {
"storeID": store_ids,
"upperPrice": str(int(max_price)),
"sortBy": "Deal Rating",
"desc": "1",
"pageSize": str(page_size),
}
try:
resp = await client.get(f"{BASE_URL}/deals", params=params)
resp.raise_for_status()
raw_deals = resp.json()
except (httpx.HTTPError, ValueError) as exc:
logger.error("CheapShark API error: %s", exc)
return []
deals = []
for d in raw_deals:
savings = float(d.get("savings", 0))
rating = float(d.get("dealRating", 0))
if savings < min_discount:
continue
if rating * 10 < min_rating: # dealRating is 0-10, config is 0-100
continue
steam_app_id = d.get("steamAppID") or None
if steam_app_id == "0":
steam_app_id = None
deals.append(
CheapSharkDeal(
deal_id=d["dealID"],
game_id=d["gameID"],
title=d["title"],
sale_price=d["salePrice"],
normal_price=d["normalPrice"],
savings=savings,
deal_rating=rating,
store_id=d["storeID"],
last_change=int(d.get("lastChange", 0)),
steam_app_id=steam_app_id,
)
)
logger.info("CheapShark returned %d deals after filtering", len(deals))
return deals

View File

@@ -0,0 +1,30 @@
import os
class Config:
"""Bot configuration loaded from environment variables."""
def __init__(self):
# Matrix settings
self.matrix_homeserver_url = self._require("MATRIX_HOMESERVER_URL")
self.matrix_bot_user_id = self._require("MATRIX_BOT_USER_ID")
self.matrix_bot_access_token = self._require("MATRIX_BOT_ACCESS_TOKEN")
self.matrix_deals_room_id = self._require("MATRIX_DEALS_ROOM_ID")
# 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 = int(os.environ.get("MIN_DEAL_RATING", "80"))
self.min_discount_percent = int(os.environ.get("MIN_DISCOUNT_PERCENT", "50"))
self.max_price_usd = float(os.environ.get("MAX_PRICE_USD", "20"))
# Database
self.database_path = os.environ.get("DATABASE_PATH", "deals.db")
@staticmethod
def _require(name: str) -> str:
value = os.environ.get(name)
if not value:
raise ValueError(f"Required environment variable {name} is not set")
return value

View File

@@ -0,0 +1,79 @@
import logging
from datetime import datetime, timedelta, timezone
import aiosqlite
logger = logging.getLogger(__name__)
SCHEMA = """
CREATE TABLE IF NOT EXISTS posted_deals (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
title TEXT NOT NULL,
posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT
);
"""
class Database:
def __init__(self, path: str):
self.path = path
self._db: aiosqlite.Connection | None = None
async def connect(self):
self._db = await aiosqlite.connect(self.path)
await self._db.executescript(SCHEMA)
await self._db.commit()
logger.info("Database initialized at %s", self.path)
async def close(self):
if self._db:
await self._db.close()
async def has_been_posted(self, deal_id: str) -> bool:
assert self._db is not None
cursor = await self._db.execute(
"SELECT 1 FROM posted_deals WHERE id = ?", (deal_id,)
)
row = await cursor.fetchone()
return row is not None
async def mark_posted(self, deal_id: str, source: str, title: str):
assert self._db is not None
await self._db.execute(
"INSERT OR IGNORE INTO posted_deals (id, source, title) VALUES (?, ?, ?)",
(deal_id, source, title),
)
await self._db.commit()
async def prune_old(self, days: int = 30):
assert self._db is not None
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = await self._db.execute(
"DELETE FROM posted_deals WHERE posted_at < ?",
(cutoff.isoformat(),),
)
await self._db.commit()
if result.rowcount:
logger.info("Pruned %d old deal records", result.rowcount)
async def get_config(self, key: str) -> str | None:
assert self._db is not None
cursor = await self._db.execute(
"SELECT value FROM config WHERE key = ?", (key,)
)
row = await cursor.fetchone()
return row[0] if row else None
async def set_config(self, key: str, value: str):
assert self._db is not None
await self._db.execute(
"INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)",
(key, value),
)
await self._db.commit()

135
gaming_deals_bot/epic.py Normal file
View File

@@ -0,0 +1,135 @@
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
import httpx
logger = logging.getLogger(__name__)
FREE_GAMES_URL = (
"https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions"
)
@dataclass
class EpicFreeGame:
title: str
game_id: str
description: str
end_date: str | None # ISO datetime when the offer expires
url: str
@property
def dedup_id(self) -> str:
return f"epic-{self.game_id}"
def _parse_promotions(
elements: list[dict],
) -> tuple[list[EpicFreeGame], list[EpicFreeGame]]:
"""Parse Epic promotions into current free games and upcoming free games."""
current: list[EpicFreeGame] = []
upcoming: list[EpicFreeGame] = []
now = datetime.now(timezone.utc)
for elem in elements:
title = elem.get("title", "Unknown")
game_id = elem.get("id", "")
description = elem.get("description", "")
# Build the store URL from the slug or product slug
slug = (
elem.get("productSlug")
or elem.get("urlSlug")
or elem.get("catalogNs", {}).get("mappings", [{}])[0].get("pageSlug", "")
)
url = f"https://store.epicgames.com/en-US/p/{slug}" if slug else ""
# Check current promotional offers
promotions = elem.get("promotions")
if not promotions:
continue
# Current offers
for offer_group in promotions.get("promotionalOffers", []):
for offer in offer_group.get("promotionalOffers", []):
discount = offer.get("discountSetting", {})
if discount.get("discountPercentage", 100) == 0:
end_date = offer.get("endDate")
# Verify the offer is currently active
start = offer.get("startDate")
if start:
start_dt = datetime.fromisoformat(
start.replace("Z", "+00:00")
)
if start_dt > now:
continue
if end_date:
end_dt = datetime.fromisoformat(
end_date.replace("Z", "+00:00")
)
if end_dt < now:
continue
current.append(
EpicFreeGame(
title=title,
game_id=game_id,
description=description,
end_date=end_date,
url=url,
)
)
# Upcoming offers
for offer_group in promotions.get("upcomingPromotionalOffers", []):
for offer in offer_group.get("promotionalOffers", []):
discount = offer.get("discountSetting", {})
if discount.get("discountPercentage", 100) == 0:
end_date = offer.get("endDate")
upcoming.append(
EpicFreeGame(
title=title,
game_id=game_id,
description=description,
end_date=end_date,
url=url,
)
)
return current, upcoming
async def fetch_free_games(
client: httpx.AsyncClient,
) -> tuple[list[EpicFreeGame], list[EpicFreeGame]]:
"""Fetch current and upcoming free games from Epic Games Store.
Returns (current_free_games, upcoming_free_games).
"""
try:
resp = await client.get(FREE_GAMES_URL, params={"locale": "en-US"})
resp.raise_for_status()
data = resp.json()
except (httpx.HTTPError, ValueError) as exc:
logger.error("Epic Games Store API error: %s", exc)
return [], []
elements = (
data.get("data", {})
.get("Catalog", {})
.get("searchStore", {})
.get("elements", [])
)
if not elements:
logger.warning("No elements found in Epic free games response")
return [], []
current, upcoming = _parse_promotions(elements)
logger.info(
"Epic: %d current free games, %d upcoming",
len(current),
len(upcoming),
)
return current, upcoming

View File

@@ -0,0 +1,129 @@
"""Message formatting for Matrix deal posts.
Produces both a plain-text fallback and HTML formatted body for Matrix messages.
"""
from datetime import datetime, timezone
import markdown
from .cheapshark import CheapSharkDeal
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)
def format_deal(deal: CheapSharkDeal, is_historical_low: bool = False) -> tuple[str, str]:
"""Format a CheapShark deal into (plain_text, html) for Matrix.
Returns (body, formatted_body).
"""
discount = int(deal.savings)
lines = [
f"**🎮 [DEAL] {deal.title}**",
f"> {discount}% off — **${deal.sale_price}** on {deal.store_name} ~~${deal.normal_price}~~",
]
if is_historical_low:
lines.append("> 🏆 _All-time low!_")
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 — ${deal.sale_price} on {deal.store_name} (was ${deal.normal_price})",
]
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_epic_free(game: EpicFreeGame) -> tuple[str, str]:
"""Format an Epic free game into (plain_text, html) for Matrix.
Returns (body, formatted_body).
"""
lines = [
f"**🆓 [FREE] {game.title}**",
"> 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}_")
except (ValueError, TypeError):
pass
if game.url:
lines.append(f"> 🔗 [Claim Now]({game.url})")
md_text = "\n".join(lines)
html = _render_html(md_text)
# Plain text fallback
plain_lines = [
f"🆓 [FREE] {game.title}",
" 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")
plain_lines.append(f" 📅 Free until {date_str}")
except (ValueError, TypeError):
pass
if game.url:
plain_lines.append(f" 🔗 {game.url}")
plain_text = "\n".join(plain_lines)
return plain_text, html
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",
]
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}_")
except (ValueError, TypeError):
pass
if game.url:
lines.append(f"> 🔗 [Store Page]({game.url})")
md_text = "\n".join(lines)
html = _render_html(md_text)
plain_lines = [
f"📢 [UPCOMING FREE] {game.title}",
" Coming soon — Free on Epic Games Store",
]
if game.url:
plain_lines.append(f" 🔗 {game.url}")
plain_text = "\n".join(plain_lines)
return plain_text, html

92
gaming_deals_bot/itad.py Normal file
View File

@@ -0,0 +1,92 @@
import logging
import httpx
logger = logging.getLogger(__name__)
BASE_URL = "https://api.isthereanydeal.com"
async def check_historical_lows(
client: httpx.AsyncClient,
api_key: str,
steam_app_ids: list[str],
) -> dict[str, bool]:
"""Check if current prices are historical lows for a batch of Steam app IDs.
Returns a mapping of steam_app_id -> is_historical_low.
"""
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]
try:
resp = await client.get(
f"{BASE_URL}/games/overview/v2",
params={"key": api_key, "shops[]": "steam"},
headers={"Content-Type": "application/json"},
)
# 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:
logger.error("ITAD API error: %s", exc)
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", {})
if lowest and current:
lowest_price = lowest.get("price", float("inf"))
current_price = current.get("price", float("inf"))
results[steam_id] = current_price <= lowest_price
logger.info(
"ITAD: checked %d games, %d are at historical low",
len(steam_app_ids),
sum(results.values()),
)
return results
async def check_single_historical_low(
client: httpx.AsyncClient,
api_key: str,
steam_app_id: str,
) -> bool:
"""Check if a single game is at its historical low price."""
if not api_key or not steam_app_id:
return False
try:
resp = await client.get(
f"{BASE_URL}/games/overview/v2",
params={
"key": api_key,
"apps[]": f"app/{steam_app_id}",
},
)
resp.raise_for_status()
data = resp.json()
except (httpx.HTTPError, ValueError) as exc:
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"):
return True
return False

View File

@@ -0,0 +1,51 @@
"""Matrix client wrapper for sending deal messages."""
import logging
from nio import AsyncClient, RoomSendError
logger = logging.getLogger(__name__)
class MatrixDealsClient:
def __init__(
self,
homeserver_url: str,
user_id: str,
access_token: str,
room_id: str,
):
self.room_id = room_id
self._client = AsyncClient(homeserver_url, user_id)
self._client.access_token = access_token
self._client.user_id = user_id
async def send_deal(self, plain_text: str, html: str) -> bool:
"""Send a deal message to the configured room.
Returns True on success, False 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 send message: %s", resp.message)
return False
logger.debug("Message sent: %s", resp.event_id)
return True
except Exception as exc:
logger.error("Matrix send error: %s", exc)
return False
async def close(self):
await self._client.close()

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
matrix-nio>=0.21.0
httpx>=0.25.0
aiosqlite>=0.19.0
apscheduler>=3.10.0
markdown>=3.5.0