7 Commits

Author SHA1 Message Date
prosolis
373939fedb Improve error handling across deal sources and increase CheapShark page size
Add proper error handling for price parsing, date parsing, and HTTP
status codes in CheapShark, Epic, and ITAD integrations. Fix Epic dedup
IDs to distinguish current vs upcoming offers. Bump CheapShark fetch
page size from 10 to 60.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 01:49:21 -07:00
prosolis
e452e36a63 Fix deployment issues and add threads, numbered watchlist, search URL encoding
- Fix SQLite connection string (drop query param, use explicit PRAGMA)
- Register sqlite3-fk-wal driver for mautrix cryptohelper (pure Go, no CGO)
- Fetch DeviceID via /whoami when using bare access token
- Log cross-signing results at Warn/Info instead of Debug
- Post deals into Matrix threads: Game Deals, DLC Deals, Epic Free Games
- Add numbered watchlist; !extend and !unwatch accept list numbers
- URL-encode search queries for multi-word !search commands
- Add GetConfig/SetConfig helpers for persistent thread event IDs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 20:56:26 -07:00
prosolis
d6c8d4f599 Add token auto-refresh, device persistence, and cross-signing
Following gogobee's auth pattern: if MATRIX_BOT_PASSWORD is set, the bot
persists device credentials to device.json, validates tokens on startup,
re-logs in automatically when tokens expire, and bootstraps cross-signing
so the device shows as verified without manual emoji verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 20:56:26 -07:00
prosolis
0cfed504a1 Remove Python codebase and clean up gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 20:56:26 -07:00
prosolis
b8a0fd5f35 Rewrite Pastel from Python to Go with watchlist feature
Full rewrite of the gaming deals Matrix bot in Go for better performance
and a single static binary. Includes all original functionality (CheapShark,
ITAD, Epic free games, multi-currency pricing, dedup, preflight checks,
presence heartbeat) plus a new watchlist feature where users can DM the bot
to watch for specific game deals with 180-day auto-expiry.

New: systemd service file, DB migration script for Python-to-Go transition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 20:56:15 -07:00
prosolis
24fc9240fe Merge pull request #15 from prosolis/claude/multi-country-currency-deals-2nZGh
Add optional Matrix thread support for per-category deal organization
2026-02-28 18:33:14 -08:00
prosolis
d55f33af25 Merge pull request #14 from prosolis/claude/multi-country-currency-deals-2nZGh
Increased number of pulls by default / added multi-country deal pulling
2026-02-28 15:47:03 -08:00
35 changed files with 3158 additions and 1936 deletions

View File

@@ -2,6 +2,8 @@
MATRIX_HOMESERVER_URL=https://matrix.example.com
MATRIX_BOT_USER_ID=@dealsbot:example.com
MATRIX_BOT_ACCESS_TOKEN=syt_...
# Optional: enables token auto-refresh, cross-signing, and device persistence
# MATRIX_BOT_PASSWORD=
MATRIX_DEALS_ROOM_ID=!roomid:example.com
# IsThereAnyDeal API key (optional — enables historical low detection and ITAD deals)
@@ -46,5 +48,5 @@ MAX_PRICE=20
# Send an intro message to the room on startup (true/false)
SEND_INTRO_MESSAGE=false
# Database path (inside the container, mount a volume for persistence)
DATABASE_PATH=/data/deals.db
# Database path
DATABASE_PATH=deals.db

8
.gitignore vendored
View File

@@ -1,2 +1,6 @@
__pycache__/
*.pyc
.env
*.db
*.db.bak
device.json
/pastel
/migrate

View File

@@ -1,10 +0,0 @@
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"]

119
README.md
View File

@@ -1,6 +1,6 @@
# Pastel
A Matrix bot that posts gaming deals and free game alerts to a specified Matrix room.
A Matrix bot that posts gaming deals and free game alerts to a specified Matrix room, with a personal watchlist feature via DMs.
Deals are sourced from PC/digital storefronts only (Steam, GOG, Humble Store, GreenManGaming, Epic Games Store) — universally accessible regardless of region.
@@ -15,19 +15,39 @@ CheapShark and IsThereAnyDeal can be used individually or together — configure
## Quick Start
1. Copy `.env.example` to `.env` and fill in your Matrix credentials and room ID
2. Run with Docker:
2. Build and run with Go 1.25+:
```bash
go build -tags goolm -o pastel ./cmd/pastel
./pastel
```
Or 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+:
## Watchlist
```bash
pip install -r requirements.txt
python -m gaming_deals_bot
```
Users can DM the bot to set up personal deal alerts. When a matching deal appears, the bot sends a DM notification.
| Command | Description |
|---|---|
| `!search <game name>` | Search for current deals on a game |
| `!watch <game name>` | Watch for deals on a game |
| `!unwatch <# or game name>` | Remove a game from your watchlist |
| `!extend <# or game name>` | Reset the 180-day expiry timer |
| `!watchlist` | Show your numbered watchlist |
| `!help` | List available commands |
- Watches expire after **180 days** to prevent stale entries from accumulating
- The bot sends a reminder **7 days before expiry** — reply with `!extend` to keep it
- Matching uses normalized substring search, so watching "elden ring" will match "ELDEN RING: Shadow of the Erdtree"
- Notifications are sent via encrypted DMs (E2EE)
- `!unwatch` and `!extend` accept either the game name or a number from `!watchlist`
- `!search` is rate-limited to 5 searches per 10 minutes per user
## Obtaining a Matrix Bot Access Token
@@ -62,37 +82,51 @@ All configuration is via environment variables (see `.env.example`):
| `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_BOT_PASSWORD` | No | — | Bot's password (enables auto-refresh, cross-signing, and device persistence) |
| `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) |
| `DEAL_SOURCES` | No | cheapshark | Comma-separated deal sources: `cheapshark`, `itad`, or `cheapshark,itad` |
| `ITAD_COUNTRIES` | No | US | Comma-separated ISO 3166-1 alpha-2 country codes to fetch ITAD deals from (e.g. `US,CA,GB,DE`) |
| `DEFAULT_CURRENCY` | No | USD | Primary display currency shown first in price strings |
| `EXTRA_CURRENCIES` | No | CAD,EUR,GBP | Additional currencies shown after the default (comma-separated) |
| `MATRIX_USE_THREADS` | No | false | Post deals into per-category threads (see Threads section below) |
| `MIN_DEAL_RATING` | No | 8.0 | Minimum CheapShark deal rating (0-10) |
| `MIN_DISCOUNT_PERCENT` | No | 50 | Minimum discount percentage |
| `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD |
| `SEND_INTRO_MESSAGE` | No | false | Send "The deals must flow." to the room on startup |
| `DATABASE_PATH` | No | deals.db | Path to SQLite database file |
### Filtering
## Deployment
Each deal source has its own filter settings. Source-specific values take priority; when not set they fall back to the shared defaults.
### systemd
| 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 |
A service file is included for systemd deployments:
```bash
# Build and install
go build -tags goolm -o pastel ./cmd/pastel
sudo mkdir -p /opt/pastel
sudo cp pastel /opt/pastel/
sudo cp .env /opt/pastel/
# Install and start the service
sudo cp pastel.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now pastel
```
The service runs as a hardened unit with `ProtectSystem=strict`, restricting writes to `/opt/pastel` only. Adjust `WorkingDirectory` in the service file if deploying elsewhere.
```bash
# Check status
sudo systemctl status pastel
# View logs
sudo journalctl -u pastel -f
```
## Preflight Check
Run `--check` to validate your configuration and test connectivity to all services before starting the bot:
```bash
python -m gaming_deals_bot --check
./pastel --check
```
This verifies:
@@ -107,24 +141,43 @@ The command exits with code 0 on success and 1 on failure, so it works in CI and
## 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.
Deals are posted inside per-category threads to keep the room organized:
| 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.) |
| 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 |
Thread root messages are created automatically the first time a deal in that category appears. The root event IDs are stored in the database so subsequent deals are posted into the same threads.
When threads are **disabled** (default), the bot behaves as before — all deals post directly to the room and non-game ITAD content is excluded.
## Behavior
- **First run**: fetches current deals and records them in the database without posting (avoids spamming the room with existing deals)
- **Deduplication**: deals are tracked by game ID + timestamp; duplicates are never reposted
- **Pruning**: deals older than 30 days are pruned from the database so they can be reposted if they return
- **One message per deal**: each deal is posted individually so messages are independently linkable and dismissible
- **Multi-currency pricing**: deal prices are shown in 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.
- **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.
- **E2EE support**: persistent crypto store via mautrix CryptoHelper for encrypted room and DM support
- **Auto-refresh**: when `MATRIX_BOT_PASSWORD` is set, the bot persists device credentials and automatically re-authenticates if the token expires
- **Cross-signing**: the bot bootstraps cross-signing on startup so its device is automatically verified
- **Presence heartbeat**: keeps the bot shown as online in Matrix clients
## Migrating from the Python Version
The Go version is compatible with the existing Python `deals.db`. A migration script is included to convert timestamp formats and create the new watchlist table:
```bash
# Build the migration tool
go build -o migrate ./cmd/migrate
# Migrate (creates a .bak backup automatically)
./migrate deals.db
```
The script:
- Creates a backup at `deals.db.bak`
- Converts Python's timestamp formats (`YYYY-MM-DD HH:MM:SS`, `YYYY-MM-DDTHH:MM:SS+00:00`) to RFC 3339
- Creates the `watchlist` table
All posted deals and the first-run flag carry over — no duplicate posts on switchover.

174
cmd/migrate/main.go Normal file
View File

@@ -0,0 +1,174 @@
// Command migrate converts a Pastel Python database to the Go format.
//
// The Python bot writes posted_at timestamps using SQLite's CURRENT_TIMESTAMP
// ("YYYY-MM-DD HH:MM:SS") and prunes with Python's isoformat()
// ("YYYY-MM-DDTHH:MM:SS+00:00"). The Go bot uses RFC 3339 ("YYYY-MM-DDTHH:MM:SSZ").
//
// This script normalizes all posted_at values to RFC 3339 so the Go bot can use
// its native format consistently. It also creates any new tables (watchlist) that
// the Python version didn't have.
//
// Usage:
//
// go run ./cmd/migrate [path-to-deals.db]
//
// The database is modified in place. A backup is created at <path>.bak first.
package main
import (
"database/sql"
"fmt"
"io"
"os"
"strings"
"time"
_ "modernc.org/sqlite"
)
// Formats the Python bot may have written.
var parseFormats = []string{
"2006-01-02 15:04:05", // SQLite CURRENT_TIMESTAMP
"2006-01-02T15:04:05+00:00", // Python datetime.isoformat()
"2006-01-02T15:04:05Z", // RFC 3339 (already correct)
"2006-01-02T15:04:05-07:00", // RFC 3339 with offset
}
func main() {
dbPath := "deals.db"
if len(os.Args) > 1 {
dbPath = os.Args[1]
}
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Database not found: %s\n", dbPath)
os.Exit(1)
}
// Create backup
backupPath := dbPath + ".bak"
if err := copyFile(dbPath, backupPath); err != nil {
fmt.Fprintf(os.Stderr, "Failed to create backup: %v\n", err)
os.Exit(1)
}
fmt.Printf("Backup created: %s\n", backupPath)
db, err := sql.Open("sqlite", dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to open database: %v\n", err)
os.Exit(1)
}
defer db.Close()
// Ensure new tables exist (watchlist, etc.)
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS watchlist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
game_name TEXT NOT NULL,
game_name_normalized TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
expiry_warned INTEGER DEFAULT 0,
UNIQUE(user_id, game_name_normalized)
);
CREATE INDEX IF NOT EXISTS idx_watchlist_normalized ON watchlist(game_name_normalized);
CREATE INDEX IF NOT EXISTS idx_watchlist_expires ON watchlist(expires_at);
`); err != nil {
fmt.Fprintf(os.Stderr, "Failed to create new tables: %v\n", err)
os.Exit(1)
}
// Convert posted_at timestamps
rows, err := db.Query("SELECT id, posted_at FROM posted_deals")
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to query deals: %v\n", err)
os.Exit(1)
}
type update struct {
id string
converted string
}
var updates []update
for rows.Next() {
var id, raw string
if err := rows.Scan(&id, &raw); err != nil {
continue
}
converted, changed := normalizeTimestamp(raw)
if changed {
updates = append(updates, update{id, converted})
}
}
rows.Close()
if len(updates) == 0 {
fmt.Println("No timestamps need conversion. Database is already in Go format.")
return
}
tx, err := db.Begin()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to begin transaction: %v\n", err)
os.Exit(1)
}
stmt, err := tx.Prepare("UPDATE posted_deals SET posted_at = ? WHERE id = ?")
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to prepare statement: %v\n", err)
os.Exit(1)
}
for _, u := range updates {
if _, err := stmt.Exec(u.converted, u.id); err != nil {
tx.Rollback()
fmt.Fprintf(os.Stderr, "Failed to update %s: %v\n", u.id, err)
os.Exit(1)
}
}
stmt.Close()
if err := tx.Commit(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to commit: %v\n", err)
os.Exit(1)
}
fmt.Printf("Converted %d timestamps to RFC 3339.\n", len(updates))
fmt.Println("Migration complete.")
}
func normalizeTimestamp(raw string) (string, bool) {
raw = strings.TrimSpace(raw)
for _, layout := range parseFormats {
if t, err := time.Parse(layout, raw); err == nil {
rfc := t.UTC().Format(time.RFC3339)
return rfc, rfc != raw
}
}
// Unparseable — leave as-is
return raw, false
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}

506
cmd/pastel/main.go Normal file
View File

@@ -0,0 +1,506 @@
package main
import (
"flag"
"fmt"
"log/slog"
"math"
"os"
"os/signal"
"strings"
"syscall"
"time"
"maunium.net/go/mautrix/id"
"github.com/prosolis/Pastel/internal/config"
"github.com/prosolis/Pastel/internal/currency"
"github.com/prosolis/Pastel/internal/database"
"github.com/prosolis/Pastel/internal/deals"
"github.com/prosolis/Pastel/internal/formatter"
"github.com/prosolis/Pastel/internal/matrix"
"github.com/prosolis/Pastel/internal/preflight"
"github.com/prosolis/Pastel/internal/watchlist"
)
const (
threadKeyGameDeals = "thread_game_deals"
threadKeyDLCDeals = "thread_dlc_deals"
threadKeyEpicFree = "thread_epic_free"
)
// getOrCreateThread retrieves an existing thread event ID from the DB,
// or creates a new thread root message and stores its ID.
func getOrCreateThread(db *database.DB, mx *matrix.Client, roomID, dbKey, title string) (string, error) {
eventID, err := db.GetConfig(dbKey)
if err == nil && eventID != "" {
return eventID, nil
}
eventID, err = mx.CreateThread(roomID, title)
if err != nil {
return "", err
}
if err := db.SetConfig(dbKey, eventID); err != nil {
slog.Warn("failed to persist thread event ID", "key", dbKey, "error", err)
}
slog.Info("created thread", "title", title, "event_id", eventID)
return eventID, nil
}
func main() {
checkFlag := flag.Bool("check", false, "Run preflight checks and exit")
debugFlag := flag.Bool("debug", false, "Enable debug logging")
flag.Parse()
level := slog.LevelInfo
if *debugFlag {
level = slog.LevelDebug
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
cfg, err := config.Load()
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
if *checkFlag {
fmt.Println("Running preflight checks...")
results := preflight.Run(cfg)
if !preflight.PrintResults(results) {
os.Exit(1)
}
fmt.Println("All checks passed.")
return
}
// Open database
db, err := database.Open(cfg.DatabasePath)
if err != nil {
slog.Error("failed to open database", "error", err)
os.Exit(1)
}
defer db.Close()
// Initialize watchlist store
watchStore := watchlist.NewStore(db.RawDB())
// Initialize Matrix client with E2EE and auto-refresh
mx, err := matrix.New(matrix.ClientConfig{
HomeserverURL: cfg.MatrixHomeserverURL,
UserID: cfg.MatrixBotUserID,
AccessToken: cfg.MatrixBotAccessToken,
Password: cfg.MatrixBotPassword,
CryptoDBPath: "crypto.db",
DevicePath: "device.json",
})
if err != nil {
slog.Error("failed to create matrix client", "error", err)
os.Exit(1)
}
defer mx.Stop()
// Pre-fetch exchange rates
conv := currency.NewConverter()
conv.EnsureRates()
// Set up watchlist command handler and register DM event handler
cmdHandler := watchlist.NewCommandHandler(watchStore, mx, conv)
dealsRoomID := id.RoomID(cfg.MatrixDealsRoomID)
mx.RegisterMessageHandler(func(senderID id.UserID, roomID id.RoomID, body string) {
// Only handle DMs, not messages in the deals room
if roomID == dealsRoomID {
return
}
cmdHandler.HandleMessage(string(senderID), body)
})
// Start sync loop after handlers are registered
mx.StartSync()
// First-run: populate DB without posting
firstRunDone, _ := db.IsFirstRunDone()
if !firstRunDone {
slog.Info("first run detected — populating database without posting")
populateInitialState(cfg, db)
if err := db.SetFirstRunDone(); err != nil {
slog.Error("failed to set first run done", "error", err)
}
}
// Send intro message
if cfg.SendIntroMessage {
if err := mx.SendNotice(cfg.MatrixDealsRoomID, "The deals must flow."); err != nil {
slog.Warn("failed to send intro message", "error", err)
}
}
// Start presence heartbeat
mx.StartPresenceHeartbeat()
// Run initial checks
slog.Info("running initial deal checks")
if cfg.HasSource("cheapshark") {
checkCheapShark(cfg, db, mx, conv, watchStore)
}
if cfg.HasSource("itad") {
checkITADDeals(cfg, db, mx, conv, watchStore)
}
checkEpicFreeGames(cfg, db, mx, watchStore)
// Run initial expiry check
checkWatchlistExpiry(watchStore, mx)
// Start ticker goroutines
stop := make(chan struct{})
if cfg.HasSource("cheapshark") {
go func() {
ticker := time.NewTicker(2 * time.Hour)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
checkCheapShark(cfg, db, mx, conv, watchStore)
}
}
}()
}
if cfg.HasSource("itad") {
go func() {
ticker := time.NewTicker(2 * time.Hour)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
checkITADDeals(cfg, db, mx, conv, watchStore)
}
}
}()
}
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
checkEpicFreeGames(cfg, db, mx, watchStore)
}
}
}()
// Watchlist expiry check — daily
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
checkWatchlistExpiry(watchStore, mx)
}
}
}()
slog.Info("bot is running", "sources", cfg.DealSources)
// Wait for OS signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
slog.Info("shutting down")
close(stop)
}
func populateInitialState(cfg *config.Config, db *database.DB) {
// Record CheapShark deals without posting
if cfg.HasSource("cheapshark") {
rawDeals, err := deals.FetchCheapSharkDeals(cfg.MaxPriceUSD, 60)
if err != nil {
slog.Warn("first run: cheapshark fetch failed", "error", err)
} else {
filtered := deals.FilterCheapSharkDeals(rawDeals, cfg.MinDealRating, cfg.MinDiscountPercent, cfg.MaxPriceUSD)
for _, d := range filtered {
_ = db.MarkPosted(d.DedupID, "cheapshark", d.Title)
}
slog.Info("first run: populated cheapshark deals", "count", len(filtered))
}
}
// Record ITAD deals without posting
if cfg.HasSource("itad") && cfg.ITADAPIKey != "" {
itadDeals, err := deals.FetchITADDeals(cfg.ITADAPIKey, 20)
if err != nil {
slog.Warn("first run: itad fetch failed", "error", err)
} else {
filtered := deals.FilterITADDeals(itadDeals, cfg.MinDiscountPercent, cfg.MaxPriceUSD)
for _, d := range filtered {
_ = db.MarkPosted(d.DedupID, "itad", d.Title)
}
slog.Info("first run: populated itad deals", "count", len(filtered))
}
}
// Epic games are intentionally NOT recorded on first run
// so they post immediately (few games, time-limited)
}
func notifyWatchlistMatches(ws *watchlist.Store, mx *matrix.Client, title, url, price string, discount int) {
matches, err := ws.FindMatchingUsers(title)
if err != nil {
slog.Error("watchlist match failed", "error", err)
return
}
for _, m := range matches {
msg := formatter.FormatWatchlistNotification(m.GameName, title, url, price, discount)
if err := mx.SendDM(id.UserID(m.UserID), msg); err != nil {
slog.Error("failed to send watchlist DM", "user", m.UserID, "error", err)
}
}
}
func notifyWatchlistFreeMatches(ws *watchlist.Store, mx *matrix.Client, title, url string) {
matches, err := ws.FindMatchingUsers(title)
if err != nil {
slog.Error("watchlist match failed", "error", err)
return
}
for _, m := range matches {
msg := formatter.FormatWatchlistFreeNotification(m.GameName, title, url)
if err := mx.SendDM(id.UserID(m.UserID), msg); err != nil {
slog.Error("failed to send watchlist DM", "user", m.UserID, "error", err)
}
}
}
func checkCheapShark(cfg *config.Config, db *database.DB, mx *matrix.Client, conv *currency.Converter, ws *watchlist.Store) {
slog.Debug("checking cheapshark deals")
conv.EnsureRates()
rawDeals, err := deals.FetchCheapSharkDeals(cfg.MaxPriceUSD, 60)
if err != nil {
slog.Error("cheapshark fetch failed", "error", err)
return
}
filtered := deals.FilterCheapSharkDeals(rawDeals, cfg.MinDealRating, cfg.MinDiscountPercent, cfg.MaxPriceUSD)
// Look up historical lows if ITAD key is available
if cfg.ITADAPIKey != "" {
var steamIDs []string
steamIDSet := make(map[string]bool)
for _, d := range filtered {
if d.SteamAppID != "" && d.SteamAppID != "0" && !steamIDSet[d.SteamAppID] {
steamIDs = append(steamIDs, d.SteamAppID)
steamIDSet[d.SteamAppID] = true
}
}
if len(steamIDs) > 0 {
lows, err := deals.LookupHistoricalLows(cfg.ITADAPIKey, steamIDs)
if err != nil {
slog.Warn("historical low lookup failed", "error", err)
} else {
for i := range filtered {
if isLow, ok := lows[filtered[i].SteamAppID]; ok && isLow {
filtered[i].IsHistLow = true
}
}
}
}
}
threadID, err := getOrCreateThread(db, mx, cfg.MatrixDealsRoomID, threadKeyGameDeals, "Game Deals")
if err != nil {
slog.Error("failed to get/create game deals thread", "error", err)
return
}
posted := 0
for _, d := range filtered {
already, err := db.IsPosted(d.DedupID)
if err != nil {
slog.Error("db check failed", "error", err)
continue
}
if already {
continue
}
msg := formatter.FormatCheapSharkDeal(d, conv)
if err := mx.SendDealInThread(cfg.MatrixDealsRoomID, threadID, msg.Plain, msg.HTML); err != nil {
slog.Error("failed to send cheapshark deal", "title", d.Title, "error", err)
continue
}
if err := db.MarkPosted(d.DedupID, "cheapshark", d.Title); err != nil {
slog.Error("failed to mark deal posted", "error", err)
}
posted++
// Notify watchlist matches
notifyWatchlistMatches(ws, mx, d.Title, d.DealURL, conv.FormatPrice(d.SalePrice), int(math.Floor(d.Savings)))
}
if posted > 0 {
slog.Info("posted cheapshark deals", "count", posted)
}
if err := db.PruneOldDeals(30); err != nil {
slog.Warn("failed to prune old deals", "error", err)
}
}
func checkITADDeals(cfg *config.Config, db *database.DB, mx *matrix.Client, conv *currency.Converter, ws *watchlist.Store) {
if cfg.ITADAPIKey == "" {
return
}
slog.Debug("checking itad deals")
conv.EnsureRates()
itadDeals, err := deals.FetchITADDeals(cfg.ITADAPIKey, 20)
if err != nil {
slog.Error("itad fetch failed", "error", err)
return
}
filtered := deals.FilterITADDeals(itadDeals, cfg.MinDiscountPercent, cfg.MaxPriceUSD)
gameThreadID, err := getOrCreateThread(db, mx, cfg.MatrixDealsRoomID, threadKeyGameDeals, "Game Deals")
if err != nil {
slog.Error("failed to get/create game deals thread", "error", err)
return
}
dlcThreadID, err := getOrCreateThread(db, mx, cfg.MatrixDealsRoomID, threadKeyDLCDeals, "DLC Deals")
if err != nil {
slog.Error("failed to get/create dlc deals thread", "error", err)
return
}
posted := 0
for _, d := range filtered {
already, err := db.IsPosted(d.DedupID)
if err != nil {
slog.Error("db check failed", "error", err)
continue
}
if already {
continue
}
threadID := gameThreadID
if strings.EqualFold(d.Type, "dlc") {
threadID = dlcThreadID
}
msg := formatter.FormatITADDeal(d, conv)
if err := mx.SendDealInThread(cfg.MatrixDealsRoomID, threadID, msg.Plain, msg.HTML); err != nil {
slog.Error("failed to send itad deal", "title", d.Title, "error", err)
continue
}
if err := db.MarkPosted(d.DedupID, "itad", d.Title); err != nil {
slog.Error("failed to mark deal posted", "error", err)
}
posted++
// Notify watchlist matches
notifyWatchlistMatches(ws, mx, d.Title, d.URL, conv.FormatPrice(d.Price), d.Discount)
}
if posted > 0 {
slog.Info("posted itad deals", "count", posted)
}
if err := db.PruneOldDeals(30); err != nil {
slog.Warn("failed to prune old deals", "error", err)
}
}
func checkEpicFreeGames(cfg *config.Config, db *database.DB, mx *matrix.Client, ws *watchlist.Store) {
slog.Debug("checking epic free games")
games, err := deals.FetchEpicFreeGames()
if err != nil {
slog.Error("epic fetch failed", "error", err)
return
}
threadID, err := getOrCreateThread(db, mx, cfg.MatrixDealsRoomID, threadKeyEpicFree, "Epic Free Games")
if err != nil {
slog.Error("failed to get/create epic free games thread", "error", err)
return
}
posted := 0
for _, g := range games {
already, err := db.IsPosted(g.DedupID)
if err != nil {
slog.Error("db check failed", "error", err)
continue
}
if already {
continue
}
msg := formatter.FormatEpicFreeGame(g)
if err := mx.SendDealInThread(cfg.MatrixDealsRoomID, threadID, msg.Plain, msg.HTML); err != nil {
slog.Error("failed to send epic game", "title", g.Title, "error", err)
continue
}
if err := db.MarkPosted(g.DedupID, "epic", g.Title); err != nil {
slog.Error("failed to mark epic game posted", "error", err)
}
posted++
// Notify watchlist matches
notifyWatchlistFreeMatches(ws, mx, g.Title, g.URL)
}
if posted > 0 {
slog.Info("posted epic games", "count", posted)
}
}
func checkWatchlistExpiry(ws *watchlist.Store, mx *matrix.Client) {
// Warn users about entries expiring within 7 days
expiring, err := ws.GetExpiringWatches(7)
if err != nil {
slog.Error("failed to check expiring watches", "error", err)
return
}
for _, e := range expiring {
msg := fmt.Sprintf("Your watch for \"%s\" expires on %s. Send !extend %s to keep it for another 180 days.",
e.GameName, e.ExpiresAt.Format("January 2, 2006"), e.GameName)
if err := mx.SendDM(id.UserID(e.UserID), msg); err != nil {
slog.Error("failed to send expiry warning", "user", e.UserID, "error", err)
continue
}
if err := ws.MarkExpiryWarned(e.ID); err != nil {
slog.Error("failed to mark expiry warned", "error", err)
}
}
// Purge fully expired entries
purged, err := ws.PurgeExpired()
if err != nil {
slog.Error("failed to purge expired watches", "error", err)
} else if purged > 0 {
slog.Info("purged expired watchlist entries", "count", purged)
}
}

View File

@@ -1,113 +0,0 @@
"""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 dotenv import load_dotenv
from .bot import DealsBot
from .config import Config
from .preflight import run_preflight
def setup_logging(*, debug: bool = False):
logging.basicConfig(
level=logging.DEBUG if debug else logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
stream=sys.stdout,
)
if not debug:
# Keep noisy third-party loggers quiet even in debug mode
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
async def main():
load_dotenv()
debug_mode = "--debug" in sys.argv
setup_logging(debug=debug_mode)
logger = logging.getLogger("gaming_deals_bot")
check_mode = "--check" in sys.argv
try:
config = Config()
except ValueError as exc:
logger.error("Configuration error: %s", exc)
sys.exit(1)
if check_mode:
ok = await run_preflight(config)
sys.exit(0 if ok else 1)
bot = DealsBot(config)
await bot.start()
scheduler = AsyncIOScheduler()
# CheapShark: every 2 hours (if enabled)
if "cheapshark" in config.deal_sources:
scheduler.add_job(
bot.check_cheapshark,
"interval",
hours=2,
id="cheapshark",
name="CheapShark deals check",
)
# ITAD deals: every 2 hours (if enabled)
if "itad" in config.deal_sources:
scheduler.add_job(
bot.check_itad_deals,
"interval",
hours=2,
id="itad_deals",
name="ITAD deals check",
)
# Epic free games: once daily
scheduler.add_job(
bot.check_epic_free_games,
"interval",
hours=24,
id="epic_free",
name="Epic free games check",
)
scheduler.start()
logger.info(
"Bot started — scheduler running (deal sources: %s)",
", ".join(config.deal_sources),
)
await bot.send_intro()
# Run initial checks immediately (after first-run population is done)
await bot.check_cheapshark()
await bot.check_itad_deals()
await bot.check_epic_free_games()
# Keep running until signaled to stop
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())

View File

@@ -1,276 +0,0 @@
"""Main bot orchestration — ties together API clients, database, and Matrix posting."""
import asyncio
import logging
import httpx
from .cheapshark import CheapSharkDeal, fetch_deals as fetch_cheapshark_deals
from .config import Config
from .currency import configure as configure_currency, refresh_rates
from .database import Database
from .epic import EpicFreeGame, fetch_free_games
from .formatter import format_deal, format_epic_free, format_epic_upcoming, format_itad_deal
from .itad import check_single_historical_low
from .itad_deals import ITADDeal, fetch_deals as fetch_itad_deals
from .matrix_client import MatrixDealsClient
from .threads import ThreadCategory, format_thread_root, itad_type_to_category
logger = logging.getLogger(__name__)
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, fetch exchange rates, and run first-run population."""
await self.db.connect()
# Configure currency display before fetching rates
configure_currency(self.config.default_currency, self.config.extra_currencies)
# Pre-fetch exchange rates so the first deal post has conversions
await refresh_rates(self._http)
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
# Start presence heartbeat so the bot shows as "online"
await self.matrix.start_presence_heartbeat()
async def stop(self):
"""Clean shutdown."""
await self._http.aclose()
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).
Epic free games are intentionally *not* recorded here so that the
subsequent ``check_epic_free_games`` call will post them. There are
only a handful at any time and they are time-limited, so users should
see them immediately rather than waiting for the next cycle.
"""
total = 0
if "cheapshark" in self.config.deal_sources:
deals = await fetch_cheapshark_deals(
self._http,
max_price=self.config.cheapshark_max_price,
min_rating=self.config.cheapshark_min_rating,
min_discount=self.config.cheapshark_min_discount,
)
for deal in deals:
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
total += len(deals)
if "itad" in self.config.deal_sources and self.config.itad_api_key:
itad_deals = await fetch_itad_deals(
self._http,
self.config.itad_api_key,
countries=self.config.itad_countries,
max_price=self.config.itad_max_price,
min_discount=self.config.itad_min_discount,
limit=self.config.itad_deals_limit,
)
for deal in itad_deals:
await self.db.mark_posted(deal.dedup_id, "itad", deal.title)
total += len(itad_deals)
logger.info("First run: recorded %d existing deals", total)
async def send_intro(self):
"""Send an intro message to the Matrix room if configured."""
if not self.config.send_intro_message:
return
await self.matrix.send_notice("The deals must flow.")
# ------------------------------------------------------------------
# Thread helpers
# ------------------------------------------------------------------
async def _get_or_create_thread(self, category: ThreadCategory) -> str | None:
"""Return the event ID of the thread root for *category*, creating it if needed."""
event_id = await self.db.get_thread_root(category.value)
if event_id:
return event_id
plain_text, html = format_thread_root(category)
event_id = await self.matrix.create_thread_root(plain_text, html)
if event_id:
await self.db.set_thread_root(category.value, event_id)
logger.info("Created thread root for %s: %s", category.value, event_id)
return event_id
async def _send_to_thread_or_room(
self, plain_text: str, html: str, category: ThreadCategory
) -> bool:
"""Send a message — into a thread if threads are enabled, otherwise to the room."""
if not self.config.matrix_use_threads:
return await self.matrix.send_deal(plain_text, html)
thread_root_id = await self._get_or_create_thread(category)
if not thread_root_id:
logger.warning(
"Could not obtain thread root for %s — falling back to room", category.value
)
return await self.matrix.send_deal(plain_text, html)
return await self.matrix.send_deal_in_thread(plain_text, html, thread_root_id)
# ------------------------------------------------------------------
# CheapShark
# ------------------------------------------------------------------
async def check_cheapshark(self):
"""Poll CheapShark for deals and post new ones."""
if not self._first_run_done:
return
if "cheapshark" not in self.config.deal_sources:
return
logger.info("Checking CheapShark for deals...")
deals = await fetch_cheapshark_deals(
self._http,
max_price=self.config.cheapshark_max_price,
min_rating=self.config.cheapshark_min_rating,
min_discount=self.config.cheapshark_min_discount,
)
for deal in deals:
await self._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._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:
return
if "itad" not in self.config.deal_sources:
return
if not self.config.itad_api_key:
logger.warning("ITAD deal source enabled but ITAD_API_KEY is not set")
return
logger.info("Checking IsThereAnyDeal for deals...")
deals = await fetch_itad_deals(
self._http,
self.config.itad_api_key,
countries=self.config.itad_countries,
max_price=self.config.itad_max_price,
min_discount=self.config.itad_min_discount,
limit=self.config.itad_deals_limit,
)
for deal in deals:
await self._process_itad_deal(deal)
await self.db.prune_old(days=30)
async def _process_itad_deal(self, deal: ITADDeal):
"""Check dedup, format, and post a single ITAD deal."""
if await self.db.has_been_posted(deal.dedup_id):
return
category = itad_type_to_category(deal.deal_type)
# When threads are off, preserve original behaviour: skip non-game content
if not self.config.matrix_use_threads and category == ThreadCategory.NON_GAME_DEALS:
logger.debug("Skipping non-game ITAD deal (threads disabled): %s", deal.title)
return
plain_text, html = format_itad_deal(deal)
success = await self._send_to_thread_or_room(plain_text, html, category)
if success:
await self.db.mark_posted(deal.dedup_id, "itad", deal.title)
logger.info("Posted ITAD deal: %s [%s]", deal.title, category.value)
else:
logger.warning("Failed to post ITAD deal: %s — will retry next cycle", deal.title)
# ------------------------------------------------------------------
# Epic Games Store
# ------------------------------------------------------------------
async def check_epic_free_games(self):
"""Poll Epic Games Store for free games and post new ones."""
if not self._first_run_done:
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._send_to_thread_or_room(
plain_text, html, ThreadCategory.EPIC_FREE
)
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

@@ -1,108 +0,0 @@
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: float = 8.0,
min_discount: float = 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": "recent",
"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 []
logger.info(
"CheapShark returned %d raw deals (before filtering)", len(raw_deals),
)
deals = []
for d in raw_deals:
savings = float(d.get("savings", 0))
rating = float(d.get("dealRating", 0))
title = d.get("title", "?")
if savings < min_discount:
logger.debug("Filtered out %s: savings %.1f%% < %.1f%%", title, savings, min_discount)
continue
if rating > 0 and rating < min_rating:
logger.debug("Filtered out %s: rating %.1f < %.1f", title, rating, min_rating)
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

@@ -1,83 +0,0 @@
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 sources: comma-separated list of "cheapshark", "itad" (default: cheapshark)
raw_sources = os.environ.get("DEAL_SOURCES", "cheapshark")
self.deal_sources: list[str] = [
s.strip().lower() for s in raw_sources.split(",") if s.strip()
]
# ITAD countries: comma-separated ISO 3166-1 alpha-2 country codes
# Deals are fetched for each country and merged (first country has priority
# when the same game appears in multiple regions).
raw_countries = os.environ.get("ITAD_COUNTRIES", "US")
self.itad_countries: list[str] = [
c.strip().upper() for c in raw_countries.split(",") if c.strip()
]
# Currency display
self.default_currency = os.environ.get("DEFAULT_CURRENCY", "USD").upper()
raw_extra = os.environ.get("EXTRA_CURRENCIES", "CAD,EUR,GBP")
self.extra_currencies: list[str] = [
c.strip().upper() for c in raw_extra.split(",") if c.strip()
]
# Shared filter fallbacks (support legacy env vars)
_max_price = os.environ.get(
"MAX_PRICE", os.environ.get("MAX_PRICE_USD", "20")
)
_min_discount = os.environ.get("MIN_DISCOUNT_PERCENT", "50")
_min_rating = os.environ.get("MIN_DEAL_RATING", "8.0")
# CheapShark-specific filtering (falls back to shared values)
self.cheapshark_min_discount = int(
os.environ.get("CHEAPSHARK_MIN_DISCOUNT", _min_discount)
)
self.cheapshark_min_rating = float(
os.environ.get("CHEAPSHARK_MIN_RATING", _min_rating)
)
self.cheapshark_max_price = float(
os.environ.get("CHEAPSHARK_MAX_PRICE", _max_price)
)
# ITAD-specific filtering (falls back to shared values)
self.itad_min_discount = int(
os.environ.get("ITAD_MIN_DISCOUNT", _min_discount)
)
self.itad_max_price = float(
os.environ.get("ITAD_MAX_PRICE", _max_price)
)
self.itad_deals_limit = int(os.environ.get("ITAD_DEALS_LIMIT", "200"))
# Matrix threads — post deals into per-category threads
self.matrix_use_threads = os.environ.get(
"MATRIX_USE_THREADS", "false"
).lower() in ("true", "1", "yes")
# Intro message on startup
self.send_intro_message = os.environ.get(
"SEND_INTRO_MESSAGE", "false"
).lower() in ("true", "1", "yes")
# 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

@@ -1,189 +0,0 @@
"""Currency conversion using the Frankfurter API (ECB rates, no API key required).
Rates are cached in memory and refreshed at most twice per day.
Call ``configure()`` before ``refresh_rates()`` to set the display currencies.
"""
import logging
import time
import httpx
logger = logging.getLogger(__name__)
FRANKFURTER_URL = "https://api.frankfurter.dev/v1/latest"
CACHE_TTL_SECONDS = 43200 # 12 hours — be nice to the free service
# Module-level state -------------------------------------------------------
_rates: dict[str, float] = {}
_last_fetched: float = 0.0
# Display preferences (set via configure())
_default_currency: str = "USD"
_extra_currencies: list[str] = ["CAD", "EUR", "GBP"]
# Currency display symbols
SYMBOLS: dict[str, str] = {
"USD": "$",
"CAD": "C$",
"EUR": "",
"GBP": "£",
"AUD": "A$",
"BRL": "R$",
"CHF": "CHF ",
"CNY": "¥",
"CZK": "",
"DKK": "kr",
"HUF": "Ft",
"INR": "",
"JPY": "¥",
"KRW": "",
"MXN": "MX$",
"NOK": "kr",
"NZD": "NZ$",
"PLN": "",
"SEK": "kr",
"TRY": "",
"ZAR": "R",
}
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
def configure(default_currency: str = "USD", extra_currencies: list[str] | None = None) -> None:
"""Set the primary display currency and additional currencies.
Must be called **before** ``refresh_rates()`` so the correct symbols are
requested from Frankfurter.
"""
global _default_currency, _extra_currencies
_default_currency = default_currency.upper()
if extra_currencies is not None:
_extra_currencies = [c.upper() for c in extra_currencies]
def _all_target_currencies() -> list[str]:
"""Return every non-USD currency we need exchange rates for."""
currencies: set[str] = set()
if _default_currency != "USD":
currencies.add(_default_currency)
for c in _extra_currencies:
if c != "USD":
currencies.add(c)
return sorted(currencies)
# ---------------------------------------------------------------------------
# Rate fetching
# ---------------------------------------------------------------------------
async def refresh_rates(client: httpx.AsyncClient) -> bool:
"""Fetch latest USD-based exchange rates from Frankfurter.
Returns True if rates were successfully updated.
"""
global _rates, _last_fetched
needed = _all_target_currencies()
if not needed:
# Only USD display — no conversion needed
_last_fetched = time.monotonic()
return True
symbols = ",".join(needed)
try:
resp = await client.get(
FRANKFURTER_URL,
params={"base": "USD", "symbols": symbols},
)
resp.raise_for_status()
data = resp.json()
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Failed to fetch exchange rates: %s", exc)
return False
new_rates = data.get("rates", {})
if not new_rates:
logger.warning("Frankfurter returned empty rates")
return False
_rates = {k: float(v) for k, v in new_rates.items()}
_last_fetched = time.monotonic()
logger.info("Exchange rates updated: %s", _rates)
return True
async def _ensure_rates(client: httpx.AsyncClient) -> None:
"""Refresh rates if the cache is stale or empty."""
if _all_target_currencies() and (
not _rates or (time.monotonic() - _last_fetched) > CACHE_TTL_SECONDS
):
await refresh_rates(client)
# ---------------------------------------------------------------------------
# Conversion helpers
# ---------------------------------------------------------------------------
def _convert_from_usd(usd_amount: float, currency: str) -> float | None:
"""Convert a USD amount to *currency* using cached rates."""
if currency == "USD":
return usd_amount
rate = _rates.get(currency)
if rate is None:
return None
return round(usd_amount * rate, 2)
def convert_to_usd(amount: float, source_currency: str) -> float:
"""Convert *amount* from *source_currency* to USD using cached rates.
Falls back to a 1:1 conversion with a warning when rates are unavailable.
"""
if source_currency == "USD":
return amount
rate = _rates.get(source_currency)
if rate is None or rate == 0:
logger.warning("No rate for %s → USD, assuming 1:1", source_currency)
return amount
return round(amount / rate, 2)
# ---------------------------------------------------------------------------
# Price formatting
# ---------------------------------------------------------------------------
def format_price(usd_amount: float) -> str:
"""Return a formatted multi-currency price string.
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.
"""
display_order = [_default_currency] + [
c for c in _extra_currencies if c != _default_currency
]
parts: list[str] = []
for cur in display_order:
converted = _convert_from_usd(usd_amount, cur)
if converted is not None:
symbol = SYMBOLS.get(cur, f"{cur} ")
parts.append(f"{symbol}{converted:.2f}")
if not parts:
# Fallback when no rates are loaded yet
parts = [f"${usd_amount:.2f}"]
return " · ".join(parts)
async def format_price_async(client: httpx.AsyncClient, usd_amount: float) -> str:
"""Ensure rates are fresh, then format a multi-currency price string."""
await _ensure_rates(client)
return format_price(usd_amount)

View File

@@ -1,115 +0,0 @@
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
);
CREATE TABLE IF NOT EXISTS thread_roots (
category TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"""
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()
# ------------------------------------------------------------------
# Thread root management
# ------------------------------------------------------------------
async def get_thread_root(self, category: str) -> str | None:
"""Return the Matrix event ID for a thread root, or None."""
assert self._db is not None
cursor = await self._db.execute(
"SELECT event_id FROM thread_roots WHERE category = ?", (category,)
)
row = await cursor.fetchone()
return row[0] if row else None
async def set_thread_root(self, category: str, event_id: str):
"""Store the Matrix event ID for a thread root."""
assert self._db is not None
await self._db.execute(
"INSERT OR REPLACE INTO thread_roots (category, event_id) VALUES (?, ?)",
(category, event_id),
)
await self._db.commit()
async def clear_thread_root(self, category: str):
"""Remove a stored thread root (e.g. if the event was redacted)."""
assert self._db is not None
await self._db.execute(
"DELETE FROM thread_roots WHERE category = ?", (category,)
)
await self._db.commit()

View File

@@ -1,135 +0,0 @@
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

@@ -1,156 +0,0 @@
"""Message formatting for Matrix deal posts.
Produces both a plain-text fallback and HTML formatted body for Matrix messages.
HTML is built directly using the Matrix-supported HTML subset rather than going
through a Markdown-to-HTML converter, which collapses line breaks.
"""
from datetime import datetime, timezone
from html import escape
from .cheapshark import CheapSharkDeal
from .currency import format_price
from .epic import EpicFreeGame
from .itad_deals import ITADDeal
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)
sale_price = float(deal.sale_price)
normal_price = float(deal.normal_price)
sale_multi = format_price(sale_price)
normal_display = format_price(normal_price)
title = escape(deal.title)
html_lines = [
f"<strong>🎮 [DEAL] {title}</strong>",
f"{discount}% off on {escape(deal.store_name)} <del>{escape(normal_display)}</del>",
f"💰 <strong>{escape(sale_multi)}</strong>",
]
if is_historical_low:
html_lines.append("🏆 <em>All-time low!</em>")
html_lines.append(f'🔗 <a href="{escape(deal.deal_url)}">View Deal</a>')
html = "<br>\n".join(html_lines)
plain_lines = [
f"🎮 [DEAL] {deal.title}",
f" {discount}% off on {deal.store_name} (was {normal_display})",
f" 💰 {sale_multi}",
]
if is_historical_low:
plain_lines.append(" 🏆 All-time low!")
plain_lines.append(f" 🔗 {deal.deal_url}")
plain_text = "\n".join(plain_lines)
return plain_text, html
def format_itad_deal(deal: ITADDeal) -> tuple[str, str]:
"""Format an ITAD deal into (plain_text, html) for Matrix.
Returns (body, formatted_body).
"""
sale_multi = format_price(deal.sale_price)
normal_display = format_price(deal.normal_price)
title = escape(deal.title)
html_lines = [
f"<strong>🎮 [DEAL] {title}</strong>",
f"{deal.discount}% off on {escape(deal.shop_name)} <del>{escape(normal_display)}</del>",
f"💰 <strong>{escape(sale_multi)}</strong>",
]
if deal.is_historical_low:
html_lines.append("🏆 <em>All-time low!</em>")
html_lines.append(f'🔗 <a href="{escape(deal.url)}">View Deal</a>')
html = "<br>\n".join(html_lines)
plain_lines = [
f"🎮 [DEAL] {deal.title}",
f" {deal.discount}% off on {deal.shop_name} (was {normal_display})",
f" 💰 {sale_multi}",
]
if deal.is_historical_low:
plain_lines.append(" 🏆 All-time low!")
plain_lines.append(f" 🔗 {deal.url}")
plain_text = "\n".join(plain_lines)
return plain_text, html
def format_epic_free(game: EpicFreeGame) -> tuple[str, str]:
"""Format an Epic free game into (plain_text, html) for Matrix.
Returns (body, formatted_body).
"""
title = escape(game.title)
html_lines = [
f"<strong>🆓 [FREE] {title}</strong>",
"Free on Epic Games Store",
]
if game.end_date:
try:
end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00"))
date_str = end_dt.strftime("%B %-d")
html_lines.append(f"📅 <em>Free until {date_str}</em>")
except (ValueError, TypeError):
pass
if game.url:
html_lines.append(f'🔗 <a href="{escape(game.url)}">Claim Now</a>')
html = "<br>\n".join(html_lines)
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."""
title = escape(game.title)
html_lines = [
f"<strong>📢 [UPCOMING FREE] {title}</strong>",
"Coming soon — Free on Epic Games Store",
]
if game.end_date:
try:
end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00"))
date_str = end_dt.strftime("%B %-d")
html_lines.append(f"📅 <em>Free until {date_str}</em>")
except (ValueError, TypeError):
pass
if game.url:
html_lines.append(f'🔗 <a href="{escape(game.url)}">Store Page</a>')
html = "<br>\n".join(html_lines)
plain_lines = [
f"📢 [UPCOMING FREE] {game.title}",
" 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

View File

@@ -1,122 +0,0 @@
import logging
import httpx
logger = logging.getLogger(__name__)
BASE_URL = "https://api.isthereanydeal.com"
async def _lookup_game_id(
client: httpx.AsyncClient,
api_key: str,
steam_app_id: str,
) -> str | None:
"""Look up the ITAD game UUID for a Steam app ID.
Returns the ITAD game UUID string, or None if not found.
"""
try:
resp = await client.get(
f"{BASE_URL}/games/lookup/v1",
params={"key": api_key, "appid": steam_app_id},
)
resp.raise_for_status()
data = resp.json()
if data.get("found") and data.get("game"):
return data["game"]["id"]
except (httpx.HTTPError, ValueError, KeyError) as exc:
logger.error("ITAD lookup error for app %s: %s", steam_app_id, exc)
return None
async def check_historical_lows(
client: httpx.AsyncClient,
api_key: str,
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 {}
# Look up ITAD game UUIDs for each Steam app ID
itad_id_to_steam: dict[str, str] = {}
for sid in steam_app_ids:
itad_id = await _lookup_game_id(client, api_key, sid)
if itad_id:
itad_id_to_steam[itad_id] = sid
if not itad_id_to_steam:
return {}
try:
resp = await client.post(
f"{BASE_URL}/games/overview/v2",
params={"key": api_key, "shops": [61]},
json=list(itad_id_to_steam.keys()),
)
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] = {}
for price_entry in data.get("prices", []):
itad_id = price_entry.get("id")
steam_id = itad_id_to_steam.get(itad_id)
if not steam_id:
continue
lowest = price_entry.get("lowest")
current = price_entry.get("current")
if lowest and current:
lowest_price = lowest.get("price", {}).get("amount", float("inf"))
current_price = current.get("price", {}).get("amount", 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
itad_id = await _lookup_game_id(client, api_key, steam_app_id)
if not itad_id:
return False
try:
resp = await client.post(
f"{BASE_URL}/games/overview/v2",
params={"key": api_key},
json=[itad_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
for price_entry in data.get("prices", []):
lowest = price_entry.get("lowest")
current = price_entry.get("current")
if lowest and current:
lowest_price = lowest.get("price", {}).get("amount", float("inf"))
current_price = current.get("price", {}).get("amount", float("inf"))
if current_price <= lowest_price:
return True
return False

View File

@@ -1,198 +0,0 @@
"""IsThereAnyDeal deals list — fetch current deals from the ITAD API."""
import logging
from dataclasses import dataclass
import httpx
from .currency import convert_to_usd
from .itad import BASE_URL
logger = logging.getLogger(__name__)
# ITAD shop ID for Steam
STEAM_SHOP_ID = 61
@dataclass
class ITADDeal:
game_id: str # ITAD UUID
slug: str
title: str
deal_type: str # ITAD type: "game", "dlc", or other
sale_price: float # current deal price (normalised to USD)
normal_price: float # regular (non-sale) price (normalised to USD)
discount: int # percentage off (0-100)
currency: str
shop_name: str
shop_id: int
url: str # purchase redirect URL
is_historical_low: bool
timestamp: str # ISO datetime of deal
expiry: str | None # ISO datetime when deal expires
@property
def dedup_id(self) -> str:
return f"itad-{self.game_id}-{self.shop_id}-{self.discount}"
@property
def sale_price_usd(self) -> str:
return f"{self.sale_price:.2f}"
@property
def normal_price_usd(self) -> str:
return f"{self.normal_price:.2f}"
async def fetch_deals(
client: httpx.AsyncClient,
api_key: str,
*,
countries: list[str] | None = None,
max_price: float = 20,
min_discount: float = 50,
limit: int = 200,
) -> list[ITADDeal]:
"""Fetch current deals from IsThereAnyDeal across one or more countries.
Defaults to the API maximum of 200 deals per country so that client-side
filtering (discount, price, type) has the widest possible pool to work
with — this avoids missing deals that wouldn't appear in a smaller window
sorted only by discount.
Deals are fetched per-country, merged (first country in the list wins
when the same game+shop appears in multiple regions), and finally sorted
by timestamp so that the newest deals appear first.
"""
if not api_key:
return []
if countries is None:
countries = ["US"]
seen: set[str] = set() # game_id-shop_id keys for cross-country dedup
all_deals: list[ITADDeal] = []
for country in countries:
country_deals = await _fetch_country_deals(
client,
api_key,
country=country,
max_price=max_price,
min_discount=min_discount,
limit=limit,
)
for deal in country_deals:
key = f"{deal.game_id}-{deal.shop_id}"
if key not in seen:
seen.add(key)
all_deals.append(deal)
else:
logger.debug(
"Skipping duplicate %s from country %s", deal.title, country
)
# Sort newest first — the API only supports sorting by discount or price,
# so we sort client-side by the deal timestamp.
all_deals.sort(key=lambda d: d.timestamp, reverse=True)
logger.info(
"ITAD: %d deals across %d country/countries after dedup",
len(all_deals),
len(countries),
)
return all_deals
async def _fetch_country_deals(
client: httpx.AsyncClient,
api_key: str,
*,
country: str = "US",
max_price: float = 20,
min_discount: float = 50,
limit: int = 200,
) -> list[ITADDeal]:
"""Fetch deals for a single country from the ITAD ``/deals/v2`` endpoint."""
params: dict = {
"key": api_key,
"country": country,
"sort": "-cut",
"limit": min(limit, 200),
"nondeals": "false",
}
try:
resp = await client.get(f"{BASE_URL}/deals/v2", params=params)
resp.raise_for_status()
data = resp.json()
except (httpx.HTTPError, ValueError) as exc:
logger.error("ITAD deals API error for country %s: %s", country, exc)
return []
raw_list = data.get("list", [])
logger.info(
"ITAD returned %d raw deals for %s (before filtering)", len(raw_list), country
)
deals: list[ITADDeal] = []
for entry in raw_list:
deal_data = entry.get("deal", {})
if not deal_data:
continue
entry_type = entry.get("type", "game")
title = entry.get("title", "?")
cut = deal_data.get("cut", 0)
price_amount = deal_data.get("price", {}).get("amount", 0)
regular_amount = deal_data.get("regular", {}).get("amount", 0)
currency = deal_data.get("price", {}).get("currency", "USD")
# Normalise to USD so prices are comparable across regions
price_usd = convert_to_usd(price_amount, currency)
regular_usd = convert_to_usd(regular_amount, currency)
# Apply ITAD-specific filters
if cut < min_discount:
logger.debug(
"Filtered out %s: discount %d%% < %d%%",
title,
cut,
int(min_discount),
)
continue
if price_usd > max_price:
logger.debug(
"Filtered out %s: price $%.2f > $%.2f", title, price_usd, max_price
)
continue
flag = deal_data.get("flag")
is_historical_low = flag in ("H", "N")
shop = deal_data.get("shop", {})
deals.append(
ITADDeal(
game_id=entry.get("id", ""),
slug=entry.get("slug", ""),
title=title,
deal_type=entry_type,
sale_price=price_usd,
normal_price=regular_usd,
discount=cut,
currency="USD", # normalised
shop_name=shop.get("name", "Unknown"),
shop_id=shop.get("id", 0),
url=deal_data.get("url", ""),
is_historical_low=is_historical_low,
timestamp=deal_data.get("timestamp", ""),
expiry=deal_data.get("expiry"),
)
)
logger.info(
"ITAD returned %d deals for %s after filtering", len(deals), country
)
return deals

View File

@@ -1,184 +0,0 @@
"""Matrix client wrapper for sending deal messages."""
import asyncio
import logging
from nio import AsyncClient, PresenceSetError, RoomSendError, RoomSendResponse
logger = logging.getLogger(__name__)
PRESENCE_HEARTBEAT_INTERVAL = 60 # seconds
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
self._heartbeat_task: asyncio.Task | None = None
async def send_deal(self, plain_text: str, html: str) -> bool:
"""Send a deal message to the configured room.
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 send_notice(self, text: str) -> bool:
"""Send a plain m.notice (non-highlight) message to the configured room."""
content = {
"msgtype": "m.notice",
"body": text,
}
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 notice: %s", resp.message)
return False
return True
except Exception as exc:
logger.error("Matrix send error: %s", exc)
return False
async def send_deal_in_thread(
self, plain_text: str, html: str, thread_root_id: str
) -> bool:
"""Send a deal message as a reply inside a Matrix thread.
``thread_root_id`` is the ``$event_id`` of the thread root message.
"""
content = {
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"body": plain_text,
"formatted_body": html,
"m.relates_to": {
"rel_type": "m.thread",
"event_id": thread_root_id,
# Fallback for clients that don't support threads
"is_falling_back": True,
"m.in_reply_to": {"event_id": thread_root_id},
},
}
try:
resp = await self._client.room_send(
room_id=self.room_id,
message_type="m.room.message",
content=content,
)
if isinstance(resp, RoomSendError):
logger.error("Failed to send threaded message: %s", resp.message)
return False
logger.debug("Threaded message sent: %s (thread %s)", resp.event_id, thread_root_id)
return True
except Exception as exc:
logger.error("Matrix threaded send error: %s", exc)
return False
async def create_thread_root(self, plain_text: str, html: str) -> str | None:
"""Send a message that will serve as a thread root.
Returns the ``$event_id`` on success, or ``None`` on failure.
"""
content = {
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"body": plain_text,
"formatted_body": html,
}
try:
resp = await self._client.room_send(
room_id=self.room_id,
message_type="m.room.message",
content=content,
)
if isinstance(resp, RoomSendError):
logger.error("Failed to create thread root: %s", resp.message)
return None
assert isinstance(resp, RoomSendResponse)
logger.info("Thread root created: %s", resp.event_id)
return resp.event_id
except Exception as exc:
logger.error("Matrix thread root error: %s", exc)
return None
async def set_presence(self, presence: str) -> bool:
"""Set the bot's presence status on the homeserver.
Returns True on success, False on failure.
"""
try:
resp = await self._client.set_presence(presence)
if isinstance(resp, PresenceSetError):
logger.error("Failed to set presence to %s: %s", presence, resp.message)
return False
logger.debug("Presence set to %s", presence)
return True
except Exception as exc:
logger.error("Presence set error: %s", exc)
return False
async def start_presence_heartbeat(self):
"""Set presence to online and spawn a background task to keep it alive."""
await self.set_presence("online")
self._heartbeat_task = asyncio.create_task(self._presence_heartbeat_loop())
logger.info("Presence heartbeat started (every %ds)", PRESENCE_HEARTBEAT_INTERVAL)
async def _presence_heartbeat_loop(self):
"""Re-send 'online' presence every PRESENCE_HEARTBEAT_INTERVAL seconds."""
try:
while True:
await asyncio.sleep(PRESENCE_HEARTBEAT_INTERVAL)
await self.set_presence("online")
except asyncio.CancelledError:
pass
async def stop_presence_heartbeat(self):
"""Cancel the heartbeat task and set presence to offline."""
if self._heartbeat_task is not None:
self._heartbeat_task.cancel()
try:
await self._heartbeat_task
except asyncio.CancelledError:
pass
self._heartbeat_task = None
await self.set_presence("offline")
logger.info("Presence heartbeat stopped, set to offline")
async def close(self):
await self.stop_presence_heartbeat()
await self._client.close()

View File

@@ -1,205 +0,0 @@
"""Preflight checks — validate configuration and connectivity before running the bot."""
import logging
import sys
import httpx
from nio import AsyncClient, LoginError, RoomResolveAliasError
from .config import Config
from .cheapshark import BASE_URL as CHEAPSHARK_URL
from .currency import FRANKFURTER_URL, configure as configure_currency, _all_target_currencies
from .epic import FREE_GAMES_URL
from .itad import BASE_URL as ITAD_URL
logger = logging.getLogger(__name__)
# ANSI colours for terminal output
_GREEN = "\033[32m"
_RED = "\033[31m"
_YELLOW = "\033[33m"
_BOLD = "\033[1m"
_RESET = "\033[0m"
def _pass(label: str, detail: str = "") -> bool:
suffix = f"{detail}" if detail else ""
print(f" {_GREEN}{_RESET} {label}{suffix}")
return True
def _fail(label: str, detail: str = "") -> bool:
suffix = f"{detail}" if detail else ""
print(f" {_RED}{_RESET} {label}{suffix}")
return False
def _skip(label: str, detail: str = "") -> bool:
suffix = f"{detail}" if detail else ""
print(f" {_YELLOW}{_RESET} {label}{suffix}")
return True # skips don't count as failures
async def run_preflight(config: Config) -> bool:
"""Run all preflight checks. Returns True if everything critical passes."""
print(f"\n{_BOLD}Pastel — preflight checks{_RESET}\n")
all_ok = True
# Configure currency display so the Frankfurter check fetches the right symbols
configure_currency(config.default_currency, config.extra_currencies)
async with httpx.AsyncClient(timeout=15) as http:
# --- Matrix ---
print(f"{_BOLD}Matrix{_RESET}")
all_ok &= await _check_matrix(config)
# --- CheapShark ---
print(f"\n{_BOLD}CheapShark{_RESET}")
if "cheapshark" in config.deal_sources:
all_ok &= await _check_cheapshark(http)
else:
_skip("Skipped", "not in DEAL_SOURCES")
# --- Epic Games Store ---
print(f"\n{_BOLD}Epic Games Store{_RESET}")
all_ok &= await _check_epic(http)
# --- Frankfurter (exchange rates) ---
print(f"\n{_BOLD}Frankfurter (exchange rates){_RESET}")
all_ok &= await _check_frankfurter(http, config)
# --- IsThereAnyDeal ---
itad_required = "itad" in config.deal_sources
print(f"\n{_BOLD}IsThereAnyDeal{_RESET}")
all_ok &= await _check_itad(http, config.itad_api_key, required=itad_required)
# --- Summary ---
print()
if all_ok:
print(f"{_GREEN}{_BOLD}All checks passed.{_RESET} The bot is ready to run.")
else:
print(f"{_RED}{_BOLD}Some checks failed.{_RESET} Review the errors above before starting the bot.")
print()
return all_ok
# ---------------------------------------------------------------------------
# Individual checks
# ---------------------------------------------------------------------------
async def _check_matrix(config: Config) -> bool:
"""Verify the access token is valid and the bot can see the target room."""
ok = True
client = AsyncClient(config.matrix_homeserver_url, config.matrix_bot_user_id)
client.access_token = config.matrix_bot_access_token
client.user_id = config.matrix_bot_user_id
try:
# whoami — validates the token
resp = await client.whoami()
if hasattr(resp, "user_id"):
ok &= _pass("Authentication", f"logged in as {resp.user_id}")
else:
ok &= _fail("Authentication", f"token rejected: {resp}")
await client.close()
return False
# joined_rooms — check that the bot is in the target room
rooms_resp = await client.joined_rooms()
if hasattr(rooms_resp, "rooms"):
if config.matrix_deals_room_id in rooms_resp.rooms:
ok &= _pass("Room access", f"bot is a member of {config.matrix_deals_room_id}")
else:
ok &= _fail(
"Room access",
f"bot is NOT in {config.matrix_deals_room_id} — invite the bot first",
)
else:
ok &= _fail("Room access", f"could not list joined rooms: {rooms_resp}")
except Exception as exc:
ok &= _fail("Homeserver connection", str(exc))
finally:
await client.close()
return ok
async def _check_cheapshark(http: httpx.AsyncClient) -> bool:
"""Hit the CheapShark deals endpoint to confirm it's reachable."""
try:
resp = await http.get(f"{CHEAPSHARK_URL}/deals", params={"pageSize": "1"})
resp.raise_for_status()
deals = resp.json()
if isinstance(deals, list):
return _pass("API reachable", f"{len(deals)} deal(s) in response")
return _fail("API reachable", "unexpected response format")
except Exception as exc:
return _fail("API reachable", str(exc))
async def _check_epic(http: httpx.AsyncClient) -> bool:
"""Hit the Epic free-games endpoint."""
try:
resp = await http.get(FREE_GAMES_URL, params={"locale": "en-US"})
resp.raise_for_status()
data = resp.json()
elements = (
data.get("data", {})
.get("Catalog", {})
.get("searchStore", {})
.get("elements", [])
)
return _pass("API reachable", f"{len(elements)} game(s) in catalog")
except Exception as exc:
return _fail("API reachable", str(exc))
async def _check_frankfurter(http: httpx.AsyncClient, config: Config) -> bool:
"""Fetch exchange rates to confirm Frankfurter is reachable."""
needed = _all_target_currencies()
if not needed:
return _pass(
"Skipped",
f"only {config.default_currency} configured — no conversion needed",
)
try:
symbols = ",".join(needed)
resp = await http.get(FRANKFURTER_URL, params={"base": "USD", "symbols": symbols})
resp.raise_for_status()
rates = resp.json().get("rates", {})
if rates:
parts = [f"{k}: {v}" for k, v in rates.items()]
return _pass("API reachable", ", ".join(parts))
return _fail("API reachable", "response contained no rates")
except Exception as exc:
return _fail("API reachable", str(exc))
async def _check_itad(http: httpx.AsyncClient, api_key: str, *, required: bool = False) -> bool:
"""Verify the ITAD API key works.
When *required* is True (ITAD is a deal source), a missing key is a failure.
Otherwise it's an optional skip.
"""
if not api_key:
if required:
return _fail("API key", "ITAD_API_KEY is required when 'itad' is in DEAL_SOURCES")
return _skip("Skipped", "no ITAD_API_KEY configured (optional)")
try:
# Use the lookup endpoint (GET) to validate the key with a known game
resp = await http.get(
f"{ITAD_URL}/games/lookup/v1",
params={"key": api_key, "appid": 220}, # Half-Life 2
)
if resp.status_code in (401, 403):
return _fail("API key", "rejected by ITAD (401/403)")
resp.raise_for_status()
data = resp.json()
if data.get("found"):
return _pass("API reachable", "ITAD responded successfully")
return _pass("API reachable", "ITAD key valid (game not found)")
except Exception as exc:
return _fail("API reachable", str(exc))

36
go.mod Normal file
View File

@@ -0,0 +1,36 @@
module github.com/prosolis/Pastel
go 1.25.0
require (
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
maunium.net/go/mautrix v0.26.3
modernc.org/sqlite v1.46.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
go.mau.fi/util v0.9.6 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

112
go.sum Normal file
View File

@@ -0,0 +1,112 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

118
internal/config/config.go Normal file
View File

@@ -0,0 +1,118 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
type Config struct {
MatrixHomeserverURL string
MatrixBotUserID string
MatrixBotAccessToken string
MatrixBotPassword string
MatrixDealsRoomID string
ITADAPIKey string
DealSources []string
MinDealRating float64
MinDiscountPercent int
MaxPriceUSD float64
SendIntroMessage bool
DatabasePath string
}
func Load() (*Config, error) {
// Load .env file if present (ignore error if missing)
_ = godotenv.Load()
c := &Config{}
var err error
c.MatrixHomeserverURL, err = require("MATRIX_HOMESERVER_URL")
if err != nil {
return nil, err
}
c.MatrixBotUserID, err = require("MATRIX_BOT_USER_ID")
if err != nil {
return nil, err
}
c.MatrixBotAccessToken, err = require("MATRIX_BOT_ACCESS_TOKEN")
if err != nil {
return nil, err
}
c.MatrixBotPassword = os.Getenv("MATRIX_BOT_PASSWORD")
c.MatrixDealsRoomID, err = require("MATRIX_DEALS_ROOM_ID")
if err != nil {
return nil, err
}
c.ITADAPIKey = os.Getenv("ITAD_API_KEY")
rawSources := os.Getenv("DEAL_SOURCES")
if rawSources == "" {
rawSources = "cheapshark"
}
for _, s := range strings.Split(rawSources, ",") {
s = strings.TrimSpace(strings.ToLower(s))
if s != "" {
c.DealSources = append(c.DealSources, s)
}
}
c.MinDealRating = envFloat("MIN_DEAL_RATING", 8.0)
c.MinDiscountPercent = envInt("MIN_DISCOUNT_PERCENT", 50)
c.MaxPriceUSD = envFloat("MAX_PRICE_USD", 20)
c.DatabasePath = envStr("DATABASE_PATH", "deals.db")
intro := strings.ToLower(os.Getenv("SEND_INTRO_MESSAGE"))
c.SendIntroMessage = intro == "true" || intro == "1" || intro == "yes"
return c, nil
}
// HasSource checks if a deal source is enabled.
func (c *Config) HasSource(name string) bool {
for _, s := range c.DealSources {
if s == name {
return true
}
}
return false
}
func require(key string) (string, error) {
v := os.Getenv(key)
if v == "" {
return "", fmt.Errorf("required environment variable %s is not set", key)
}
return v, nil
}
func envStr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envFloat(key string, def float64) float64 {
if v := os.Getenv(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return def
}
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return def
}

View File

@@ -0,0 +1,96 @@
package currency
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
)
const (
apiURL = "https://api.frankfurter.dev/v1/latest?base=USD&symbols=CAD,EUR,GBP"
cacheTTL = 12 * time.Hour
)
var symbols = map[string]string{
"USD": "$",
"CAD": "C$",
"EUR": "€",
"GBP": "£",
}
var currencies = []string{"CAD", "EUR", "GBP"}
type Converter struct {
mu sync.Mutex
rates map[string]float64
lastFetched time.Time
}
func NewConverter() *Converter {
return &Converter{}
}
func (c *Converter) fetchRates() error {
resp, err := http.Get(apiURL)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("frankfurter API returned status %d", resp.StatusCode)
}
var result struct {
Rates map[string]float64 `json:"rates"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
c.rates = result.Rates
c.lastFetched = time.Now()
return nil
}
// EnsureRates fetches exchange rates if the cache is stale or empty.
func (c *Converter) EnsureRates() {
c.mu.Lock()
defer c.mu.Unlock()
if time.Since(c.lastFetched) < cacheTTL && c.rates != nil {
return
}
if err := c.fetchRates(); err != nil {
slog.Warn("failed to fetch exchange rates, using USD only", "error", err)
}
}
// FormatPrice converts a USD amount to multi-currency display string.
func (c *Converter) FormatPrice(usd float64) string {
c.mu.Lock()
defer c.mu.Unlock()
parts := []string{fmt.Sprintf("$%.2f", usd)}
for _, cur := range currencies {
if rate, ok := c.rates[cur]; ok {
sym := symbols[cur]
parts = append(parts, fmt.Sprintf("%s%.2f", sym, usd*rate))
}
}
return strings.Join(parts, " · ")
}
// HasRates returns true if exchange rates are available.
func (c *Converter) HasRates() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.rates != nil && len(c.rates) > 0
}

View File

@@ -0,0 +1,124 @@
package database
import (
"time"
"github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
)
type DB struct {
db *sqlx.DB
}
func Open(path string) (*DB, error) {
db, err := sqlx.Open("sqlite", path)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
return nil, err
}
d := &DB{db: db}
if err := d.migrate(); err != nil {
return nil, err
}
return d, nil
}
func (d *DB) Close() error {
return d.db.Close()
}
// RawDB exposes the underlying sqlx.DB for packages that need direct access.
func (d *DB) RawDB() *sqlx.DB {
return d.db
}
func (d *DB) migrate() error {
_, err := d.db.Exec(`
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
);
CREATE TABLE IF NOT EXISTS watchlist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
game_name TEXT NOT NULL,
game_name_normalized TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
expiry_warned INTEGER DEFAULT 0,
UNIQUE(user_id, game_name_normalized)
);
CREATE INDEX IF NOT EXISTS idx_watchlist_normalized ON watchlist(game_name_normalized);
CREATE INDEX IF NOT EXISTS idx_watchlist_expires ON watchlist(expires_at);
`)
return err
}
// IsPosted checks if a deal has already been posted.
func (d *DB) IsPosted(id string) (bool, error) {
var count int
err := d.db.Get(&count, "SELECT COUNT(1) FROM posted_deals WHERE id = ?", id)
return count > 0, err
}
// MarkPosted records a deal as posted.
func (d *DB) MarkPosted(id, source, title string) error {
_, err := d.db.Exec(
"INSERT OR IGNORE INTO posted_deals (id, source, title) VALUES (?, ?, ?)",
id, source, title,
)
return err
}
// IsFirstRunDone checks if the initial population has been completed.
func (d *DB) IsFirstRunDone() (bool, error) {
var val string
err := d.db.Get(&val, "SELECT value FROM config WHERE key = 'first_run_done'")
if err != nil {
return false, nil // not found = not done
}
return val == "true", nil
}
// SetFirstRunDone marks the initial population as complete.
func (d *DB) SetFirstRunDone() error {
_, err := d.db.Exec(
"INSERT OR REPLACE INTO config (key, value) VALUES ('first_run_done', 'true')",
)
return err
}
// GetConfig retrieves a value from the config table.
func (d *DB) GetConfig(key string) (string, error) {
var val string
err := d.db.Get(&val, "SELECT value FROM config WHERE key = ?", key)
return val, err
}
// SetConfig sets a value in the config table.
func (d *DB) SetConfig(key, value string) error {
_, err := d.db.Exec(
"INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)",
key, value,
)
return err
}
// PruneOldDeals removes deals older than the given number of days.
func (d *DB) PruneOldDeals(days int) error {
cutoff := time.Now().AddDate(0, 0, -days).UTC().Format(time.RFC3339)
_, err := d.db.Exec("DELETE FROM posted_deals WHERE posted_at < ?", cutoff)
return err
}

View File

@@ -0,0 +1,188 @@
package deals
import (
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"net/url"
"strconv"
)
var storeNames = map[string]string{
"1": "Steam",
"7": "GOG",
"11": "Humble Store",
"23": "GreenManGaming",
}
type CheapSharkDeal struct {
DealID string
GameID string
Title string
SalePrice float64
NormalPrice float64
Savings float64
DealRating float64
StoreID string
StoreName string
LastChange int64
SteamAppID string
DealURL string
IsHistLow bool
DedupID string
}
type cheapSharkRaw struct {
DealID string `json:"dealID"`
GameID string `json:"gameID"`
Title string `json:"title"`
SalePrice string `json:"salePrice"`
NormalPrice string `json:"normalPrice"`
Savings string `json:"savings"`
DealRating string `json:"dealRating"`
StoreID string `json:"storeID"`
LastChange int64 `json:"lastChange"`
SteamAppID string `json:"steamAppID"`
}
// FetchCheapSharkDeals fetches deals from CheapShark API.
func FetchCheapSharkDeals(maxPrice float64, pageSize int) ([]CheapSharkDeal, error) {
reqURL := fmt.Sprintf(
"https://www.cheapshark.com/api/1.0/deals?storeID=1,7,11,23&upperPrice=%d&sortBy=recent&desc=1&pageSize=%d",
int(maxPrice), pageSize,
)
resp, err := http.Get(reqURL)
if err != nil {
return nil, fmt.Errorf("cheapshark request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cheapshark returned status %d", resp.StatusCode)
}
var raw []cheapSharkRaw
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("cheapshark decode failed: %w", err)
}
var deals []CheapSharkDeal
for _, d := range raw {
sale, err1 := strconv.ParseFloat(d.SalePrice, 64)
normal, err2 := strconv.ParseFloat(d.NormalPrice, 64)
savings, err3 := strconv.ParseFloat(d.Savings, 64)
rating, _ := strconv.ParseFloat(d.DealRating, 64)
if err1 != nil || err2 != nil || err3 != nil {
slog.Warn("cheapshark: skipping deal with unparseable prices", "title", d.Title,
"salePrice", d.SalePrice, "normalPrice", d.NormalPrice, "savings", d.Savings)
continue
}
storeName := storeNames[d.StoreID]
if storeName == "" {
storeName = "Unknown"
}
deals = append(deals, CheapSharkDeal{
DealID: d.DealID,
GameID: d.GameID,
Title: d.Title,
SalePrice: sale,
NormalPrice: normal,
Savings: savings,
DealRating: rating,
StoreID: d.StoreID,
StoreName: storeName,
LastChange: d.LastChange,
SteamAppID: d.SteamAppID,
DealURL: fmt.Sprintf("https://www.cheapshark.com/redirect?dealID=%s", d.DealID),
DedupID: fmt.Sprintf("cheapshark-%s-%d", d.GameID, d.LastChange),
})
}
return deals, nil
}
// SearchCheapSharkDeals searches for current deals matching a title query.
func SearchCheapSharkDeals(query string, maxResults int) ([]CheapSharkDeal, error) {
reqURL := fmt.Sprintf(
"https://www.cheapshark.com/api/1.0/deals?storeID=1,7,11,23&sortBy=savings&desc=1&pageSize=%d&title=%s",
maxResults, url.QueryEscape(query),
)
resp, err := http.Get(reqURL)
if err != nil {
return nil, fmt.Errorf("cheapshark search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cheapshark returned status %d", resp.StatusCode)
}
var raw []cheapSharkRaw
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("cheapshark decode failed: %w", err)
}
var results []CheapSharkDeal
for _, d := range raw {
sale, err1 := strconv.ParseFloat(d.SalePrice, 64)
normal, err2 := strconv.ParseFloat(d.NormalPrice, 64)
savings, err3 := strconv.ParseFloat(d.Savings, 64)
if err1 != nil || err2 != nil || err3 != nil {
slog.Warn("cheapshark: skipping search result with unparseable prices", "title", d.Title)
continue
}
if savings <= 0 {
continue
}
storeName := storeNames[d.StoreID]
if storeName == "" {
storeName = "Unknown"
}
results = append(results, CheapSharkDeal{
DealID: d.DealID,
GameID: d.GameID,
Title: d.Title,
SalePrice: sale,
NormalPrice: normal,
Savings: savings,
StoreID: d.StoreID,
StoreName: storeName,
DealURL: fmt.Sprintf("https://www.cheapshark.com/redirect?dealID=%s", d.DealID),
})
}
return results, nil
}
// FilterCheapSharkDeals filters deals by rating, discount, and price thresholds.
func FilterCheapSharkDeals(deals []CheapSharkDeal, minRating float64, minDiscount int, maxPrice float64) []CheapSharkDeal {
var filtered []CheapSharkDeal
for _, d := range deals {
discount := int(math.Floor(d.Savings))
if discount < minDiscount {
slog.Debug("cheapshark: skipping (low discount)", "title", d.Title, "discount", discount)
continue
}
if d.DealRating < minRating && d.DealRating != 0 {
slog.Debug("cheapshark: skipping (low rating)", "title", d.Title, "rating", d.DealRating)
continue
}
if d.SalePrice > maxPrice {
slog.Debug("cheapshark: skipping (too expensive)", "title", d.Title, "price", d.SalePrice)
continue
}
filtered = append(filtered, d)
}
return filtered
}

156
internal/deals/epic.go Normal file
View File

@@ -0,0 +1,156 @@
package deals
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
)
type EpicFreeGame struct {
ID string
Title string
Desc string
URL string
EndDate *time.Time
Upcoming bool
DedupID string
}
type epicResponse struct {
Data struct {
Catalog struct {
SearchStore struct {
Elements []epicElement `json:"elements"`
} `json:"searchStore"`
} `json:"Catalog"`
} `json:"data"`
}
type epicElement struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
ProductSlug string `json:"productSlug"`
URLSlug string `json:"urlSlug"`
CatalogNs struct {
Mappings []struct {
PageSlug string `json:"pageSlug"`
} `json:"mappings"`
} `json:"catalogNs"`
Promotions *struct {
PromotionalOffers []epicOfferGroup `json:"promotionalOffers"`
UpcomingPromotionalOffers []epicOfferGroup `json:"upcomingPromotionalOffers"`
} `json:"promotions"`
}
type epicOfferGroup struct {
PromotionalOffers []epicOffer `json:"promotionalOffers"`
}
type epicOffer struct {
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
DiscountSetting struct {
DiscountPercentage int `json:"discountPercentage"`
} `json:"discountSetting"`
}
// FetchEpicFreeGames fetches current and upcoming free games from Epic Games Store.
func FetchEpicFreeGames() ([]EpicFreeGame, error) {
resp, err := http.Get("https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions?locale=en-US")
if err != nil {
return nil, fmt.Errorf("epic request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("epic returned status %d", resp.StatusCode)
}
var result epicResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("epic decode failed: %w", err)
}
now := time.Now()
var games []EpicFreeGame
for _, elem := range result.Data.Catalog.SearchStore.Elements {
if elem.Promotions == nil {
continue
}
slug := elem.ProductSlug
if slug == "" {
slug = elem.URLSlug
}
if slug == "" && len(elem.CatalogNs.Mappings) > 0 {
slug = elem.CatalogNs.Mappings[0].PageSlug
}
if slug == "" {
slog.Warn("epic: skipping game with no slug", "title", elem.Title, "id", elem.ID)
continue
}
storeURL := fmt.Sprintf("https://store.epicgames.com/en-US/p/%s", slug)
// Check current free offers
for _, group := range elem.Promotions.PromotionalOffers {
for _, offer := range group.PromotionalOffers {
if offer.DiscountSetting.DiscountPercentage != 0 {
continue
}
start, errS := time.Parse(time.RFC3339, offer.StartDate)
end, errE := time.Parse(time.RFC3339, offer.EndDate)
if errS != nil || errE != nil {
slog.Warn("epic: failed to parse offer dates", "title", elem.Title, "start", offer.StartDate, "end", offer.EndDate)
continue
}
if start.After(now) || end.Before(now) {
continue
}
game := EpicFreeGame{
ID: elem.ID,
Title: elem.Title,
Desc: elem.Description,
URL: storeURL,
Upcoming: false,
DedupID: fmt.Sprintf("epic-current-%s", elem.ID),
}
if !end.IsZero() {
game.EndDate = &end
}
games = append(games, game)
}
}
// Check upcoming free offers
for _, group := range elem.Promotions.UpcomingPromotionalOffers {
for _, offer := range group.PromotionalOffers {
if offer.DiscountSetting.DiscountPercentage != 0 {
continue
}
game := EpicFreeGame{
ID: elem.ID,
Title: elem.Title,
Desc: elem.Description,
URL: storeURL,
Upcoming: true,
DedupID: fmt.Sprintf("epic-upcoming-%s", elem.ID),
}
if offer.EndDate != "" {
if end, err := time.Parse(time.RFC3339, offer.EndDate); err == nil {
game.EndDate = &end
}
}
games = append(games, game)
}
}
}
return games, nil
}

245
internal/deals/itad.go Normal file
View File

@@ -0,0 +1,245 @@
package deals
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
)
type ITADDeal struct {
GameID string
Slug string
Title string
Type string
Discount int
Price float64
Regular float64
Currency string
ShopName string
ShopID int
URL string
Flag string // "H" = historical low, "N" = new low
Timestamp time.Time
Expiry *time.Time
DedupID string
IsHistLow bool
}
type itadResponse struct {
List []itadEntry `json:"list"`
}
type itadEntry struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Type string `json:"type"`
Deal struct {
Shop struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"shop"`
Price struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
} `json:"price"`
Regular struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
} `json:"regular"`
Cut int `json:"cut"`
URL string `json:"url"`
Flag string `json:"flag"`
Timestamp string `json:"timestamp"`
Expiry *string `json:"expiry"`
} `json:"deal"`
}
// FetchITADDeals fetches deals from ITAD API v2.
func FetchITADDeals(apiKey string, limit int) ([]ITADDeal, error) {
if limit > 200 {
limit = 200
}
url := fmt.Sprintf(
"https://api.isthereanydeal.com/deals/v2?key=%s&sort=-cut&limit=%d&nondeals=false",
apiKey, limit,
)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("itad request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("itad returned status %d", resp.StatusCode)
}
var result itadResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("itad decode failed: %w", err)
}
var deals []ITADDeal
for _, e := range result.List {
// Only include games and DLC
t := strings.ToLower(e.Type)
if t != "game" && t != "dlc" {
continue
}
deal := ITADDeal{
GameID: e.ID,
Slug: e.Slug,
Title: e.Title,
Type: e.Type,
Discount: e.Deal.Cut,
Price: e.Deal.Price.Amount,
Regular: e.Deal.Regular.Amount,
Currency: e.Deal.Price.Currency,
ShopName: e.Deal.Shop.Name,
ShopID: e.Deal.Shop.ID,
URL: e.Deal.URL,
Flag: e.Deal.Flag,
IsHistLow: e.Deal.Flag == "H" || e.Deal.Flag == "N",
DedupID: fmt.Sprintf("itad-%s-%d-%d", e.ID, e.Deal.Shop.ID, e.Deal.Cut),
}
if ts, err := time.Parse(time.RFC3339, e.Deal.Timestamp); err == nil {
deal.Timestamp = ts
}
if e.Deal.Expiry != nil {
if exp, err := time.Parse(time.RFC3339, *e.Deal.Expiry); err == nil {
deal.Expiry = &exp
}
}
deals = append(deals, deal)
}
return deals, nil
}
// FilterITADDeals filters ITAD deals by discount and price thresholds.
func FilterITADDeals(deals []ITADDeal, minDiscount int, maxPrice float64) []ITADDeal {
var filtered []ITADDeal
for _, d := range deals {
if d.Discount < minDiscount {
slog.Debug("itad: skipping (low discount)", "title", d.Title, "discount", d.Discount)
continue
}
if d.Price > maxPrice {
slog.Debug("itad: skipping (too expensive)", "title", d.Title, "price", d.Price)
continue
}
filtered = append(filtered, d)
}
return filtered
}
// LookupHistoricalLows checks if games are at their historical low price via ITAD.
// Takes a map of steamAppID -> deal index, returns map of steamAppID -> isHistoricalLow.
func LookupHistoricalLows(apiKey string, steamAppIDs []string) (map[string]bool, error) {
result := make(map[string]bool)
if apiKey == "" || len(steamAppIDs) == 0 {
return result, nil
}
// Step 1: Look up ITAD game IDs from Steam app IDs
itadIDs := make(map[string]string) // steamAppID -> itadID
for _, appID := range steamAppIDs {
if appID == "" || appID == "0" {
continue
}
url := fmt.Sprintf(
"https://api.isthereanydeal.com/games/lookup/v1?key=%s&appid=%s",
apiKey, appID,
)
resp, err := http.Get(url)
if err != nil {
slog.Warn("itad lookup failed", "appid", appID, "error", err)
continue
}
if resp.StatusCode != http.StatusOK {
slog.Warn("itad lookup returned non-200", "appid", appID, "status", resp.StatusCode)
resp.Body.Close()
continue
}
var lookup struct {
Found bool `json:"found"`
Game struct {
ID string `json:"id"`
} `json:"game"`
}
if err := json.NewDecoder(resp.Body).Decode(&lookup); err != nil {
resp.Body.Close()
continue
}
resp.Body.Close()
if lookup.Found {
itadIDs[appID] = lookup.Game.ID
}
}
if len(itadIDs) == 0 {
return result, nil
}
// Step 2: Get price overview for all looked-up games
var gameIDs []string
itadToSteam := make(map[string]string)
for steamID, itadID := range itadIDs {
gameIDs = append(gameIDs, itadID)
itadToSteam[itadID] = steamID
}
body, err := json.Marshal(gameIDs)
if err != nil {
return result, fmt.Errorf("itad overview marshal failed: %w", err)
}
url := fmt.Sprintf("https://api.isthereanydeal.com/games/overview/v2?key=%s", apiKey)
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return result, fmt.Errorf("itad overview request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return result, fmt.Errorf("itad overview returned status %d", resp.StatusCode)
}
var overview struct {
Prices []struct {
ID string `json:"id"`
Current struct {
Price struct {
Amount float64 `json:"amount"`
} `json:"price"`
} `json:"current"`
Lowest struct {
Price struct {
Amount float64 `json:"amount"`
} `json:"price"`
} `json:"lowest"`
} `json:"prices"`
}
if err := json.NewDecoder(resp.Body).Decode(&overview); err != nil {
return result, fmt.Errorf("itad overview decode failed: %w", err)
}
for _, p := range overview.Prices {
if steamID, ok := itadToSteam[p.ID]; ok {
result[steamID] = p.Current.Price.Amount <= p.Lowest.Price.Amount
}
}
return result, nil
}

View File

@@ -0,0 +1,126 @@
package formatter
import (
"fmt"
"html"
"math"
"strings"
"github.com/prosolis/Pastel/internal/currency"
"github.com/prosolis/Pastel/internal/deals"
)
// Message holds both plain-text and HTML versions of a formatted message.
type Message struct {
Plain string
HTML string
}
// FormatCheapSharkDeal formats a CheapShark deal for Matrix.
func FormatCheapSharkDeal(d deals.CheapSharkDeal, conv *currency.Converter) Message {
discount := int(math.Floor(d.Savings))
saleMulti := conv.FormatPrice(d.SalePrice)
normalMulti := conv.FormatPrice(d.NormalPrice)
var plain, htmlB strings.Builder
plain.WriteString(fmt.Sprintf("🎮 [DEAL] %s\n", d.Title))
plain.WriteString(fmt.Sprintf(" %d%% off on %s (was %s)\n", discount, d.StoreName, normalMulti))
plain.WriteString(fmt.Sprintf(" 💰 %s\n", saleMulti))
htmlB.WriteString(fmt.Sprintf("<strong>🎮 [DEAL] %s</strong><br>\n", html.EscapeString(d.Title)))
htmlB.WriteString(fmt.Sprintf("%d%% off on %s <del>%s</del><br>\n",
discount, html.EscapeString(d.StoreName), html.EscapeString(normalMulti)))
htmlB.WriteString(fmt.Sprintf("💰 <strong>%s</strong><br>\n", html.EscapeString(saleMulti)))
if d.IsHistLow {
plain.WriteString(" 🏆 All-time low!\n")
htmlB.WriteString("🏆 <em>All-time low!</em><br>\n")
}
plain.WriteString(fmt.Sprintf(" 🔗 %s", d.DealURL))
htmlB.WriteString(fmt.Sprintf("🔗 <a href=\"%s\">View Deal</a>", html.EscapeString(d.DealURL)))
return Message{Plain: plain.String(), HTML: htmlB.String()}
}
// FormatITADDeal formats an ITAD deal for Matrix.
func FormatITADDeal(d deals.ITADDeal, conv *currency.Converter) Message {
saleMulti := conv.FormatPrice(d.Price)
normalMulti := conv.FormatPrice(d.Regular)
var plain, htmlB strings.Builder
plain.WriteString(fmt.Sprintf("🎮 [DEAL] %s\n", d.Title))
plain.WriteString(fmt.Sprintf(" %d%% off on %s (was %s)\n", d.Discount, d.ShopName, normalMulti))
plain.WriteString(fmt.Sprintf(" 💰 %s\n", saleMulti))
htmlB.WriteString(fmt.Sprintf("<strong>🎮 [DEAL] %s</strong><br>\n", html.EscapeString(d.Title)))
htmlB.WriteString(fmt.Sprintf("%d%% off on %s <del>%s</del><br>\n",
d.Discount, html.EscapeString(d.ShopName), html.EscapeString(normalMulti)))
htmlB.WriteString(fmt.Sprintf("💰 <strong>%s</strong><br>\n", html.EscapeString(saleMulti)))
if d.IsHistLow {
plain.WriteString(" 🏆 All-time low!\n")
htmlB.WriteString("🏆 <em>All-time low!</em><br>\n")
}
plain.WriteString(fmt.Sprintf(" 🔗 %s", d.URL))
htmlB.WriteString(fmt.Sprintf("🔗 <a href=\"%s\">View Deal</a>", html.EscapeString(d.URL)))
return Message{Plain: plain.String(), HTML: htmlB.String()}
}
// FormatEpicFreeGame formats an Epic free game for Matrix.
func FormatEpicFreeGame(g deals.EpicFreeGame) Message {
var plain, htmlB strings.Builder
if g.Upcoming {
plain.WriteString(fmt.Sprintf("📢 [UPCOMING FREE] %s\n", g.Title))
plain.WriteString(" Coming soon — Free on Epic Games Store\n")
htmlB.WriteString(fmt.Sprintf("<strong>📢 [UPCOMING FREE] %s</strong><br>\n", html.EscapeString(g.Title)))
htmlB.WriteString("Coming soon — Free on Epic Games Store<br>\n")
} else {
plain.WriteString(fmt.Sprintf("🆓 [FREE] %s\n", g.Title))
plain.WriteString(" Free on Epic Games Store\n")
htmlB.WriteString(fmt.Sprintf("<strong>🆓 [FREE] %s</strong><br>\n", html.EscapeString(g.Title)))
htmlB.WriteString("Free on Epic Games Store<br>\n")
}
if g.EndDate != nil {
dateStr := g.EndDate.Format("January 2")
plain.WriteString(fmt.Sprintf(" 📅 Free until %s\n", dateStr))
htmlB.WriteString(fmt.Sprintf("📅 <em>Free until %s</em><br>\n", html.EscapeString(dateStr)))
}
linkText := "Claim Now"
if g.Upcoming {
linkText = "Store Page"
}
plain.WriteString(fmt.Sprintf(" 🔗 %s", g.URL))
htmlB.WriteString(fmt.Sprintf("🔗 <a href=\"%s\">%s</a>", html.EscapeString(g.URL), linkText))
return Message{Plain: plain.String(), HTML: htmlB.String()}
}
// FormatWatchlistNotification formats a DM notification for a watchlist match.
func FormatWatchlistNotification(watchedName, dealTitle, dealURL string, price string, discount int) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🔔 Deal alert for \"%s\"!\n", watchedName))
sb.WriteString(fmt.Sprintf(" %s — %d%% off\n", dealTitle, discount))
sb.WriteString(fmt.Sprintf(" 💰 %s\n", price))
sb.WriteString(fmt.Sprintf(" 🔗 %s", dealURL))
return sb.String()
}
// FormatWatchlistFreeNotification formats a DM notification for a free game watchlist match.
func FormatWatchlistFreeNotification(watchedName, dealTitle, dealURL string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🔔 Free game alert for \"%s\"!\n", watchedName))
sb.WriteString(fmt.Sprintf(" %s — Free on Epic Games Store\n", dealTitle))
sb.WriteString(fmt.Sprintf(" 🔗 %s", dealURL))
return sb.String()
}

506
internal/matrix/client.go Normal file
View File

@@ -0,0 +1,506 @@
package matrix
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"sync"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// DeviceInfo holds persisted device credentials for stable device identity
// across restarts.
type DeviceInfo struct {
AccessToken string `json:"access_token"`
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
}
// ClientConfig holds the parameters needed to create a Matrix client.
type ClientConfig struct {
HomeserverURL string
UserID string
AccessToken string
Password string // optional: enables auto-refresh and cross-signing
CryptoDBPath string // e.g. "crypto.db", empty to skip E2EE
DevicePath string // e.g. "device.json", empty to skip persistence
}
type Client struct {
client *mautrix.Client
crypto *cryptohelper.CryptoHelper
cancel context.CancelFunc
syncCancel context.CancelFunc
startTime time.Time
cfg ClientConfig
dmMu sync.Mutex
dmCache map[id.UserID]id.RoomID
}
// New creates a new Matrix client. If a password is provided, the client will:
// - Persist device credentials to DevicePath for stable device identity
// - Validate existing tokens on startup and re-login if expired
// - Set LoginAs on the cryptohelper so it can auto-refresh mid-operation
// - Bootstrap cross-signing so the bot's device is automatically verified
//
// If only an access token is provided (no password), the client works but
// cannot auto-refresh — the token must be manually replaced if it expires.
func New(cfg ClientConfig) (*Client, error) {
uid := id.UserID(cfg.UserID)
accessToken := cfg.AccessToken
c := &Client{
cfg: cfg,
dmCache: make(map[id.UserID]id.RoomID),
startTime: time.Now(),
}
// If we have a device file and password, try to load persisted credentials
if cfg.DevicePath != "" && cfg.Password != "" {
device, err := loadDevice(cfg.DevicePath)
if err == nil && device.AccessToken != "" {
if isTokenValid(cfg.HomeserverURL, device.AccessToken) {
slog.Info("existing device credentials valid", "device_id", device.DeviceID)
accessToken = device.AccessToken
client, err := mautrix.NewClient(cfg.HomeserverURL, id.UserID(device.UserID), device.AccessToken)
if err != nil {
return nil, fmt.Errorf("create client with existing token: %w", err)
}
client.DeviceID = id.DeviceID(device.DeviceID)
c.client = client
} else {
slog.Warn("existing device credentials expired, logging in again")
}
}
}
// If we don't have a valid client yet, try password login or fall back to token
if c.client == nil && cfg.Password != "" {
loginResp, err := loginWithPassword(cfg.HomeserverURL, cfg.UserID, cfg.Password)
if err != nil {
return nil, fmt.Errorf("login: %w", err)
}
client, err := mautrix.NewClient(cfg.HomeserverURL, id.UserID(loginResp.UserID), loginResp.AccessToken)
if err != nil {
return nil, fmt.Errorf("create client after login: %w", err)
}
client.DeviceID = id.DeviceID(loginResp.DeviceID)
c.client = client
accessToken = loginResp.AccessToken
// Save device credentials for next restart
if cfg.DevicePath != "" {
if err := saveDevice(cfg.DevicePath, &DeviceInfo{
AccessToken: loginResp.AccessToken,
DeviceID: loginResp.DeviceID,
UserID: loginResp.UserID,
}); err != nil {
slog.Warn("failed to save device info", "error", err)
}
}
slog.Info("logged in with password", "user_id", loginResp.UserID, "device_id", loginResp.DeviceID)
}
// Fall back to bare access token (no password, no auto-refresh)
if c.client == nil {
client, err := mautrix.NewClient(cfg.HomeserverURL, uid, accessToken)
if err != nil {
return nil, fmt.Errorf("create client: %w", err)
}
c.client = client
}
// Ensure DeviceID is set (required by cryptohelper)
if c.client.DeviceID == "" {
resp, err := c.client.Whoami(context.Background())
if err != nil {
return nil, fmt.Errorf("whoami failed (needed for device ID): %w", err)
}
c.client.DeviceID = resp.DeviceID
}
// Set up E2EE
if cfg.CryptoDBPath != "" {
ch, err := cryptohelper.NewCryptoHelper(c.client, []byte("pastel_pickle_key"), cfg.CryptoDBPath)
if err != nil {
return nil, fmt.Errorf("init crypto helper: %w", err)
}
// If password is available, enable auto-refresh on token expiry
if cfg.Password != "" {
ch.LoginAs = &mautrix.ReqLogin{
Type: mautrix.AuthTypePassword,
Identifier: mautrix.UserIdentifier{
Type: mautrix.IdentifierTypeUser,
User: cfg.UserID,
},
Password: cfg.Password,
}
}
if err := ch.Init(context.Background()); err != nil {
return nil, fmt.Errorf("crypto helper init: %w", err)
}
c.client.Crypto = ch
c.crypto = ch
// Bootstrap cross-signing if password is available
if cfg.Password != "" {
c.bootstrapCrossSigning()
}
slog.Info("E2EE initialized", "device_id", c.client.DeviceID)
}
return c, nil
}
func (c *Client) bootstrapCrossSigning() {
mach := c.crypto.Machine()
_, _, err := mach.GenerateAndUploadCrossSigningKeys(context.Background(), func(ui *mautrix.RespUserInteractive) interface{} {
return map[string]interface{}{
"type": mautrix.AuthTypePassword,
"identifier": map[string]interface{}{
"type": mautrix.IdentifierTypeUser,
"user": c.cfg.UserID,
},
"password": c.cfg.Password,
"session": ui.Session,
}
}, "")
if err != nil {
slog.Warn("cross-signing: key upload failed (may already exist)", "error", err)
} else {
slog.Info("cross-signing: keys uploaded")
}
if err := mach.SignOwnDevice(context.Background(), mach.OwnIdentity()); err != nil {
slog.Warn("cross-signing: sign own device failed", "error", err)
} else {
slog.Info("cross-signing: own device signed")
}
if err := mach.SignOwnMasterKey(context.Background()); err != nil {
slog.Warn("cross-signing: sign master key failed", "error", err)
} else {
slog.Info("cross-signing: master key signed")
}
}
// RegisterMessageHandler registers a callback for incoming messages.
// Must be called before StartSync. Ignores the bot's own messages and
// messages from before the bot started (avoids replaying history).
func (c *Client) RegisterMessageHandler(fn func(senderID id.UserID, roomID id.RoomID, body string)) {
syncer := c.client.Syncer.(*mautrix.DefaultSyncer)
syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) {
if evt.Sender == c.client.UserID {
return
}
evtTime := time.UnixMilli(evt.Timestamp)
if evtTime.Before(c.startTime) {
return
}
msg := evt.Content.AsMessage()
if msg == nil || msg.Body == "" {
return
}
fn(evt.Sender, evt.RoomID, msg.Body)
})
}
// StartSync launches the background sync loop. Must be called after handler
// registration so events are dispatched to registered callbacks.
func (c *Client) StartSync() {
syncCtx, syncCancel := context.WithCancel(context.Background())
c.syncCancel = syncCancel
go func() {
for {
if err := c.client.SyncWithContext(syncCtx); err != nil {
if syncCtx.Err() != nil {
return
}
slog.Warn("sync error, retrying in 10s", "error", err)
time.Sleep(10 * time.Second)
}
}
}()
}
// Whoami validates the access token by calling /whoami.
func (c *Client) Whoami() (string, error) {
resp, err := c.client.Whoami(context.Background())
if err != nil {
return "", err
}
return resp.UserID.String(), nil
}
// JoinedRooms returns the list of rooms the bot has joined.
func (c *Client) JoinedRooms() ([]string, error) {
resp, err := c.client.JoinedRooms(context.Background())
if err != nil {
return nil, err
}
var rooms []string
for _, r := range resp.JoinedRooms {
rooms = append(rooms, r.String())
}
return rooms, nil
}
// SendDeal sends a formatted deal message to a room (top-level, no thread).
func (c *Client) SendDeal(roomID, plainText, html string) error {
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: plainText,
Format: event.FormatHTML,
FormattedBody: html,
}
_, err := c.client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return fmt.Errorf("failed to send deal message: %w", err)
}
return nil
}
// CreateThread sends a root message and returns its event ID for use as a thread root.
func (c *Client) CreateThread(roomID, text string) (string, error) {
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: text,
}
resp, err := c.client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", fmt.Errorf("failed to create thread: %w", err)
}
return resp.EventID.String(), nil
}
// SendDealInThread sends a formatted deal message as a reply in a thread.
func (c *Client) SendDealInThread(roomID, threadEventID, plainText, html string) error {
evtID := id.EventID(threadEventID)
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: plainText,
Format: event.FormatHTML,
FormattedBody: html,
RelatesTo: &event.RelatesTo{
Type: event.RelThread,
EventID: evtID,
InReplyTo: &event.InReplyTo{
EventID: evtID,
},
IsFallingBack: true,
},
}
_, err := c.client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return fmt.Errorf("failed to send threaded deal: %w", err)
}
return nil
}
// SendNotice sends a notice message to a room (non-highlighting).
func (c *Client) SendNotice(roomID, text string) error {
content := &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: text,
}
_, err := c.client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return fmt.Errorf("failed to send notice: %w", err)
}
return nil
}
// GetDMRoom returns the DM room for a user, reusing an existing one if available.
func (c *Client) GetDMRoom(userID id.UserID) (id.RoomID, error) {
c.dmMu.Lock()
if roomID, ok := c.dmCache[userID]; ok {
c.dmMu.Unlock()
return roomID, nil
}
c.dmMu.Unlock()
var dmRooms map[id.UserID][]id.RoomID
err := c.client.GetAccountData(context.Background(), "m.direct", &dmRooms)
if err == nil {
if rooms, ok := dmRooms[userID]; ok && len(rooms) > 0 {
roomID := rooms[len(rooms)-1]
c.dmMu.Lock()
c.dmCache[userID] = roomID
c.dmMu.Unlock()
return roomID, nil
}
}
resp, err := c.client.CreateRoom(context.Background(), &mautrix.ReqCreateRoom{
Preset: "trusted_private_chat",
Invite: []id.UserID{userID},
IsDirect: true,
InitialState: []*event.Event{
{
Type: event.StateEncryption,
Content: event.Content{
Parsed: &event.EncryptionEventContent{
Algorithm: id.AlgorithmMegolmV1,
},
},
},
},
})
if err != nil {
return "", fmt.Errorf("create DM room: %w", err)
}
c.dmMu.Lock()
c.dmCache[userID] = resp.RoomID
c.dmMu.Unlock()
slog.Info("created DM room", "user", userID, "room", resp.RoomID)
return resp.RoomID, nil
}
// SendDM sends a direct message to a user. Reuses existing DM room if available.
func (c *Client) SendDM(userID id.UserID, text string) error {
roomID, err := c.GetDMRoom(userID)
if err != nil {
return err
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: text,
}
_, err = c.client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
return err
}
// StartPresenceHeartbeat starts a goroutine that sends online presence every 60 seconds.
func (c *Client) StartPresenceHeartbeat() {
ctx, cancel := context.WithCancel(context.Background())
c.cancel = cancel
go func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
c.sendPresence()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.sendPresence()
}
}
}()
}
func (c *Client) sendPresence() {
err := c.client.SetPresence(context.Background(), mautrix.ReqPresence{Presence: event.PresenceOnline})
if err != nil {
slog.Warn("failed to set presence", "error", err)
}
}
// Stop cancels the presence heartbeat and sync loop.
func (c *Client) Stop() {
if c.cancel != nil {
c.cancel()
}
if c.syncCancel != nil {
c.syncCancel()
}
if c.crypto != nil {
c.crypto.Close()
}
}
// --- auth helpers ---
type loginResponse struct {
AccessToken string `json:"access_token"`
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
}
func loginWithPassword(homeserver, user, password string) (*loginResponse, error) {
body, _ := json.Marshal(map[string]any{
"type": "m.login.password",
"identifier": map[string]string{
"type": "m.id.user",
"user": user,
},
"password": password,
})
resp, err := (&http.Client{Timeout: 30 * time.Second}).Post(
homeserver+"/_matrix/client/v3/login", "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("login request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("login failed (HTTP %d): %s", resp.StatusCode, respBody)
}
var result loginResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("parse login response: %w", err)
}
return &result, nil
}
func isTokenValid(homeserver, accessToken string) bool {
req, err := http.NewRequest("GET", homeserver+"/_matrix/client/v3/account/whoami", nil)
if err != nil {
return false
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == 200
}
func loadDevice(path string) (*DeviceInfo, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var info DeviceInfo
if err := json.Unmarshal(data, &info); err != nil {
return nil, err
}
return &info, nil
}
func saveDevice(path string, info *DeviceInfo) error {
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}

View File

@@ -0,0 +1,36 @@
package matrix
import (
"database/sql"
"database/sql/driver"
sqlite "modernc.org/sqlite"
)
type fkWALDriver struct {
inner *sqlite.Driver
}
func (d *fkWALDriver) Open(name string) (driver.Conn, error) {
conn, err := d.inner.Open(name)
if err != nil {
return nil, err
}
execer := conn.(driver.Execer)
for _, pragma := range []string{
"PRAGMA foreign_keys = ON",
"PRAGMA journal_mode = WAL",
"PRAGMA synchronous = NORMAL",
"PRAGMA busy_timeout = 5000",
} {
if _, err := execer.Exec(pragma, nil); err != nil {
conn.Close()
return nil, err
}
}
return conn, nil
}
func init() {
sql.Register("sqlite3-fk-wal", &fkWALDriver{inner: &sqlite.Driver{}})
}

View File

@@ -0,0 +1,164 @@
package preflight
import (
"encoding/json"
"fmt"
"net/http"
"github.com/prosolis/Pastel/internal/config"
"github.com/prosolis/Pastel/internal/matrix"
)
type Result struct {
Name string
Status string // "pass", "fail", "skip"
Detail string
}
// Run executes all preflight checks and returns results.
func Run(cfg *config.Config) []Result {
var results []Result
results = append(results, checkMatrix(cfg))
results = append(results, checkFrankfurter())
if cfg.HasSource("cheapshark") {
results = append(results, checkCheapShark())
} else {
results = append(results, Result{"CheapShark", "skip", "not in DEAL_SOURCES"})
}
results = append(results, checkEpic())
if cfg.ITADAPIKey != "" || cfg.HasSource("itad") {
results = append(results, checkITAD(cfg))
} else {
results = append(results, Result{"IsThereAnyDeal", "skip", "no API key and not in DEAL_SOURCES"})
}
return results
}
// PrintResults prints preflight results and returns true if all passed.
func PrintResults(results []Result) bool {
allPass := true
for _, r := range results {
var icon string
switch r.Status {
case "pass":
icon = "✓"
case "fail":
icon = "✗"
allPass = false
case "skip":
icon = ""
}
fmt.Printf(" %s %s: %s\n", icon, r.Name, r.Detail)
}
return allPass
}
func checkMatrix(cfg *config.Config) Result {
client, err := matrix.New(matrix.ClientConfig{
HomeserverURL: cfg.MatrixHomeserverURL,
UserID: cfg.MatrixBotUserID,
AccessToken: cfg.MatrixBotAccessToken,
})
if err != nil {
return Result{"Matrix", "fail", fmt.Sprintf("client error: %v", err)}
}
who, err := client.Whoami()
if err != nil {
return Result{"Matrix", "fail", fmt.Sprintf("whoami failed: %v", err)}
}
rooms, err := client.JoinedRooms()
if err != nil {
return Result{"Matrix", "fail", fmt.Sprintf("joined_rooms failed: %v", err)}
}
inRoom := false
for _, r := range rooms {
if r == cfg.MatrixDealsRoomID {
inRoom = true
break
}
}
if !inRoom {
return Result{"Matrix", "fail", fmt.Sprintf("bot %s is not in room %s", who, cfg.MatrixDealsRoomID)}
}
return Result{"Matrix", "pass", fmt.Sprintf("authenticated as %s, in target room", who)}
}
func checkCheapShark() Result {
resp, err := http.Get("https://www.cheapshark.com/api/1.0/deals?pageSize=1")
if err != nil {
return Result{"CheapShark", "fail", fmt.Sprintf("request failed: %v", err)}
}
defer resp.Body.Close()
var deals []json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&deals); err != nil || len(deals) == 0 {
return Result{"CheapShark", "fail", "unexpected response format"}
}
return Result{"CheapShark", "pass", "API reachable"}
}
func checkEpic() Result {
resp, err := http.Get("https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions?locale=en-US")
if err != nil {
return Result{"Epic Games", "fail", fmt.Sprintf("request failed: %v", err)}
}
defer resp.Body.Close()
var result struct {
Data struct {
Catalog struct {
SearchStore struct {
Elements []json.RawMessage `json:"elements"`
} `json:"searchStore"`
} `json:"Catalog"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return Result{"Epic Games", "fail", "unexpected response format"}
}
return Result{"Epic Games", "pass", fmt.Sprintf("API reachable, %d elements", len(result.Data.Catalog.SearchStore.Elements))}
}
func checkFrankfurter() Result {
resp, err := http.Get("https://api.frankfurter.dev/v1/latest?base=USD&symbols=CAD,EUR,GBP")
if err != nil {
return Result{"Frankfurter", "fail", fmt.Sprintf("request failed: %v", err)}
}
defer resp.Body.Close()
var result struct {
Rates map[string]float64 `json:"rates"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || len(result.Rates) == 0 {
return Result{"Frankfurter", "fail", "no rates returned"}
}
return Result{"Frankfurter", "pass", fmt.Sprintf("rates: %v", result.Rates)}
}
func checkITAD(cfg *config.Config) Result {
url := fmt.Sprintf("https://api.isthereanydeal.com/games/lookup/v1?key=%s&appid=220", cfg.ITADAPIKey)
resp, err := http.Get(url)
if err != nil {
return Result{"IsThereAnyDeal", "fail", fmt.Sprintf("request failed: %v", err)}
}
defer resp.Body.Close()
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return Result{"IsThereAnyDeal", "fail", "invalid API key"}
}
return Result{"IsThereAnyDeal", "pass", "API key valid"}
}

View File

@@ -0,0 +1,283 @@
package watchlist
import (
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"sync"
"time"
"maunium.net/go/mautrix/id"
"github.com/prosolis/Pastel/internal/currency"
"github.com/prosolis/Pastel/internal/deals"
)
const (
searchRateLimit = 5 // max searches per window
searchRateWindow = 10 * time.Minute // sliding window
)
// DMSender is the interface for sending DMs to users.
type DMSender interface {
SendDM(userID id.UserID, text string) error
}
// CommandHandler handles watchlist commands received via DM.
type CommandHandler struct {
store *Store
sender DMSender
conv *currency.Converter
rateMu sync.Mutex
rateLimit map[id.UserID][]time.Time // search timestamps per user
}
// NewCommandHandler creates a new command handler.
func NewCommandHandler(store *Store, sender DMSender, conv *currency.Converter) *CommandHandler {
return &CommandHandler{
store: store,
sender: sender,
conv: conv,
rateLimit: make(map[id.UserID][]time.Time),
}
}
// checkSearchRate returns true if the user is within the rate limit.
func (h *CommandHandler) checkSearchRate(userID id.UserID) bool {
h.rateMu.Lock()
defer h.rateMu.Unlock()
now := time.Now()
cutoff := now.Add(-searchRateWindow)
// Prune old entries
recent := h.rateLimit[userID]
filtered := recent[:0]
for _, t := range recent {
if t.After(cutoff) {
filtered = append(filtered, t)
}
}
if len(filtered) >= searchRateLimit {
h.rateLimit[userID] = filtered
return false
}
h.rateLimit[userID] = append(filtered, now)
return true
}
// HandleMessage parses and dispatches a DM command.
func (h *CommandHandler) HandleMessage(senderID, body string) {
body = strings.TrimSpace(body)
if body == "" {
return
}
var cmd, args string
if idx := strings.IndexByte(body, ' '); idx > 0 {
cmd = strings.ToLower(body[:idx])
args = strings.TrimSpace(body[idx+1:])
} else {
cmd = strings.ToLower(body)
}
uid := id.UserID(senderID)
switch cmd {
case "!watch":
h.handleWatch(uid, args)
case "!unwatch":
h.handleUnwatch(uid, args)
case "!extend":
h.handleExtend(uid, args)
case "!search":
h.handleSearch(uid, args)
case "!watchlist":
h.handleList(uid)
case "!help":
h.handleHelp(uid)
}
}
func (h *CommandHandler) handleWatch(userID id.UserID, gameName string) {
if gameName == "" {
h.reply(userID, "Usage: !watch <game name>")
return
}
added, err := h.store.AddWatch(string(userID), gameName)
if err != nil {
slog.Error("watchlist: add failed", "user", userID, "game", gameName, "error", err)
h.reply(userID, "Something went wrong. Please try again.")
return
}
if !added {
h.reply(userID, fmt.Sprintf("You're already watching \"%s\".", gameName))
return
}
expires := time.Now().Add(watchDuration)
h.reply(userID, fmt.Sprintf("Added \"%s\" to your watchlist. I'll DM you when a deal appears. Expires %s.",
gameName, expires.Format("January 2, 2006")))
}
func (h *CommandHandler) handleUnwatch(userID id.UserID, args string) {
if args == "" {
h.reply(userID, "Usage: !unwatch <# or game name>")
return
}
gameName, err := h.resolveGameArg(userID, args)
if err != nil {
h.reply(userID, err.Error())
return
}
removed, err := h.store.RemoveWatch(string(userID), gameName)
if err != nil {
slog.Error("watchlist: remove failed", "user", userID, "game", gameName, "error", err)
h.reply(userID, "Something went wrong. Please try again.")
return
}
if !removed {
h.reply(userID, fmt.Sprintf("No watch found for \"%s\".", gameName))
return
}
h.reply(userID, fmt.Sprintf("Removed \"%s\" from your watchlist.", gameName))
}
func (h *CommandHandler) handleExtend(userID id.UserID, args string) {
if args == "" {
h.reply(userID, "Usage: !extend <# or game name>")
return
}
gameName, err := h.resolveGameArg(userID, args)
if err != nil {
h.reply(userID, err.Error())
return
}
extended, err := h.store.ExtendWatch(string(userID), gameName)
if err != nil {
slog.Error("watchlist: extend failed", "user", userID, "game", gameName, "error", err)
h.reply(userID, "Something went wrong. Please try again.")
return
}
if !extended {
h.reply(userID, fmt.Sprintf("No watch found for \"%s\".", gameName))
return
}
expires := time.Now().Add(watchDuration)
h.reply(userID, fmt.Sprintf("Extended \"%s\" — now expires %s.",
gameName, expires.Format("January 2, 2006")))
}
func (h *CommandHandler) handleList(userID id.UserID) {
entries, err := h.store.ListWatches(string(userID))
if err != nil {
slog.Error("watchlist: list failed", "user", userID, "error", err)
h.reply(userID, "Something went wrong. Please try again.")
return
}
if len(entries) == 0 {
h.reply(userID, "Your watchlist is empty. Use !watch <game name> to add one.")
return
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Your watchlist (%d):\n", len(entries)))
for i, e := range entries {
sb.WriteString(fmt.Sprintf(" %d. %s (expires %s)\n", i+1, e.GameName, e.ExpiresAt.Format("January 2, 2006")))
}
sb.WriteString("\nUse !extend <#> or !unwatch <#> with the number above.")
h.reply(userID, sb.String())
}
func (h *CommandHandler) handleSearch(userID id.UserID, query string) {
if query == "" {
h.reply(userID, "Usage: !search <game name>")
return
}
if !h.checkSearchRate(userID) {
h.reply(userID, fmt.Sprintf("Rate limited — max %d searches per %d minutes. Try again shortly.",
searchRateLimit, int(searchRateWindow.Minutes())))
return
}
h.conv.EnsureRates()
results, err := deals.SearchCheapSharkDeals(query, 5)
if err != nil {
slog.Error("search: cheapshark failed", "query", query, "error", err)
h.reply(userID, "Search failed. Please try again later.")
return
}
if len(results) == 0 {
h.reply(userID, fmt.Sprintf("No current deals found for \"%s\".", query))
return
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Deals matching \"%s\":\n\n", query))
for _, d := range results {
discount := int(math.Floor(d.Savings))
price := h.conv.FormatPrice(d.SalePrice)
sb.WriteString(fmt.Sprintf(" %s\n", d.Title))
sb.WriteString(fmt.Sprintf(" %d%% off on %s — %s\n", discount, d.StoreName, price))
sb.WriteString(fmt.Sprintf(" %s\n\n", d.DealURL))
}
h.reply(userID, sb.String())
}
func (h *CommandHandler) handleHelp(userID id.UserID) {
h.reply(userID, "Commands:\n"+
" !search <game name> — Search for current deals\n"+
" !watch <game name> — Watch for deals on a game\n"+
" !unwatch <# or game name> — Remove a game from your watchlist\n"+
" !extend <# or game name> — Reset the 180-day expiry timer\n"+
" !watchlist — Show your numbered watchlist\n"+
" !help — Show this message\n\n"+
"Watches expire after 180 days. You'll get a reminder 7 days before.")
}
// resolveGameArg resolves an argument that is either a list number (from !watchlist)
// or a game name. Returns the game name.
func (h *CommandHandler) resolveGameArg(userID id.UserID, arg string) (string, error) {
num, err := strconv.Atoi(strings.TrimSpace(arg))
if err != nil {
return arg, nil // not a number, treat as game name
}
entries, err := h.store.ListWatches(string(userID))
if err != nil {
return "", fmt.Errorf("Something went wrong. Please try again.")
}
if num < 1 || num > len(entries) {
return "", fmt.Errorf("Invalid number. Use !watchlist to see your list (1-%d).", len(entries))
}
return entries[num-1].GameName, nil
}
func (h *CommandHandler) reply(userID id.UserID, text string) {
if err := h.sender.SendDM(userID, text); err != nil {
slog.Error("watchlist: failed to send DM", "user", userID, "error", err)
}
}

View File

@@ -0,0 +1,170 @@
package watchlist
import (
"strings"
"time"
"unicode"
"github.com/jmoiron/sqlx"
)
const watchDuration = 180 * 24 * time.Hour
// WatchEntry represents a single watchlist entry.
type WatchEntry struct {
ID int64 `db:"id"`
UserID string `db:"user_id"`
GameName string `db:"game_name"`
GameNameNormalized string `db:"game_name_normalized"`
CreatedAt time.Time `db:"created_at"`
ExpiresAt time.Time `db:"expires_at"`
ExpiryWarned int `db:"expiry_warned"`
}
// Match represents a user whose watchlist entry matched a deal.
type Match struct {
UserID string
GameName string // original user-provided name for display
}
// Store handles watchlist database operations.
type Store struct {
db *sqlx.DB
}
// NewStore creates a new watchlist store.
func NewStore(db *sqlx.DB) *Store {
return &Store{db: db}
}
// Normalize lowercases, strips non-alphanumeric (keeping spaces), and collapses whitespace.
func Normalize(s string) string {
s = strings.ToLower(s)
var b strings.Builder
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' {
b.WriteRune(r)
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
// AddWatch adds a game to a user's watchlist. Returns false if already watched.
func (s *Store) AddWatch(userID, gameName string) (bool, error) {
normalized := Normalize(gameName)
if normalized == "" {
return false, nil
}
expiresAt := time.Now().Add(watchDuration).UTC().Format(time.RFC3339)
result, err := s.db.Exec(
`INSERT OR IGNORE INTO watchlist (user_id, game_name, game_name_normalized, expires_at)
VALUES (?, ?, ?, ?)`,
userID, gameName, normalized, expiresAt,
)
if err != nil {
return false, err
}
rows, _ := result.RowsAffected()
return rows > 0, nil
}
// RemoveWatch removes a game from a user's watchlist. Returns false if not found.
func (s *Store) RemoveWatch(userID, gameName string) (bool, error) {
normalized := Normalize(gameName)
result, err := s.db.Exec(
"DELETE FROM watchlist WHERE user_id = ? AND game_name_normalized = ?",
userID, normalized,
)
if err != nil {
return false, err
}
rows, _ := result.RowsAffected()
return rows > 0, nil
}
// ExtendWatch resets the expiry to 180 days from now. Returns false if not found.
func (s *Store) ExtendWatch(userID, gameName string) (bool, error) {
normalized := Normalize(gameName)
expiresAt := time.Now().Add(watchDuration).UTC().Format(time.RFC3339)
result, err := s.db.Exec(
`UPDATE watchlist SET expires_at = ?, expiry_warned = 0
WHERE user_id = ? AND game_name_normalized = ?`,
expiresAt, userID, normalized,
)
if err != nil {
return false, err
}
rows, _ := result.RowsAffected()
return rows > 0, nil
}
// ListWatches returns all active watches for a user.
func (s *Store) ListWatches(userID string) ([]WatchEntry, error) {
var entries []WatchEntry
err := s.db.Select(&entries,
`SELECT id, user_id, game_name, game_name_normalized, created_at, expires_at, expiry_warned
FROM watchlist WHERE user_id = ? AND expires_at > CURRENT_TIMESTAMP
ORDER BY created_at`,
userID,
)
return entries, err
}
// FindMatchingUsers finds all users whose watchlist entries match the given deal title.
// Loads all active entries and checks normalized substring containment in Go.
func (s *Store) FindMatchingUsers(dealTitle string) ([]Match, error) {
normalizedTitle := Normalize(dealTitle)
var entries []WatchEntry
err := s.db.Select(&entries,
`SELECT user_id, game_name, game_name_normalized
FROM watchlist WHERE expires_at > CURRENT_TIMESTAMP`,
)
if err != nil {
return nil, err
}
var matches []Match
for _, e := range entries {
if strings.Contains(normalizedTitle, e.GameNameNormalized) {
matches = append(matches, Match{
UserID: e.UserID,
GameName: e.GameName,
})
}
}
return matches, nil
}
// GetExpiringWatches returns entries expiring within the given number of days
// that haven't been warned yet.
func (s *Store) GetExpiringWatches(withinDays int) ([]WatchEntry, error) {
deadline := time.Now().AddDate(0, 0, withinDays).UTC().Format(time.RFC3339)
now := time.Now().UTC().Format(time.RFC3339)
var entries []WatchEntry
err := s.db.Select(&entries,
`SELECT id, user_id, game_name, game_name_normalized, created_at, expires_at, expiry_warned
FROM watchlist
WHERE expires_at > ? AND expires_at <= ? AND expiry_warned = 0`,
now, deadline,
)
return entries, err
}
// MarkExpiryWarned sets the expiry_warned flag on an entry.
func (s *Store) MarkExpiryWarned(id int64) error {
_, err := s.db.Exec("UPDATE watchlist SET expiry_warned = 1 WHERE id = ?", id)
return err
}
// PurgeExpired deletes entries past their expiry date. Returns count deleted.
func (s *Store) PurgeExpired() (int, error) {
now := time.Now().UTC().Format(time.RFC3339)
result, err := s.db.Exec("DELETE FROM watchlist WHERE expires_at <= ?", now)
if err != nil {
return 0, err
}
rows, _ := result.RowsAffected()
return int(rows), nil
}

22
pastel.service Normal file
View File

@@ -0,0 +1,22 @@
[Unit]
Description=Pastel — Matrix gaming deals bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/pastel
ExecStart=/opt/pastel/pastel
EnvironmentFile=/opt/pastel/.env
Restart=on-failure
RestartSec=10
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/pastel
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View File

@@ -1,5 +0,0 @@
matrix-nio>=0.21.0
httpx>=0.25.0
aiosqlite>=0.19.0
apscheduler>=3.10.0
python-dotenv>=1.0.0