23 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
Claude
2b1c606a80 Add optional Matrix thread support for per-category deal organization
When MATRIX_USE_THREADS=true, deals are posted into dedicated threads
instead of the room timeline:

  - 🎮 Game Deals — CheapShark + ITAD type=game
  - 🧩 DLC Deals — ITAD type=dlc
  - 🆓 Epic Free Games — current and upcoming Epic free games
  - 📦 Non-Game Deals — ITAD non-game content (software, courses, etc.)

Thread roots are created on first use and persisted in the database.
When threads are disabled (default), behaviour is unchanged.

Key changes:
- New threads.py module with ThreadCategory enum and routing logic
- ITADDeal now carries deal_type; type filter moved from fetcher to bot
- MatrixDealsClient gains send_deal_in_thread() and create_thread_root()
- Database gets thread_roots table for storing root event IDs
- Bot routes each deal to the appropriate thread via _send_to_thread_or_room()

https://claude.ai/code/session_01EfPjktyrF24DBNHWsY1KBP
2026-03-01 00:37:26 +00:00
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
Claude
f13bfd7b07 Default ITAD_DEALS_LIMIT to 200 (API max) to maximise deal coverage
Since the ITAD API only sorts by discount or price (no time-based sort),
fetching the full 200-deal window per country ensures our client-side
filtering sees every available deal rather than just the top N by discount.
The dedup database already prevents reposting, so the extra data is cheap.

https://claude.ai/code/session_01EfPjktyrF24DBNHWsY1KBP
2026-02-28 23:42:54 +00:00
Claude
e4d90d0331 Add multi-country ITAD deals, configurable currency, and per-source filtering
- ITAD deals can now be fetched from multiple countries simultaneously via
  ITAD_COUNTRIES (e.g. US,CA,GB,DE). Deals are merged across regions and
  deduplicated by game+shop, with the first country taking priority.

- Prices from non-USD regions are normalised to USD using exchange rates
  so filtering (max price) works consistently across countries.

- ITAD results are now sorted by newest deals first (client-side by
  timestamp) instead of only by highest discount, reducing missed deals.

- Default fetch limit raised from 20 to 100 to capture more deals.

- Display currency is now configurable: DEFAULT_CURRENCY sets the primary
  currency shown first, EXTRA_CURRENCIES sets additional ones. All
  Frankfurter-supported currencies are available with proper symbols.

- Filtering is now per-source with dedicated env vars:
  CHEAPSHARK_MIN_DISCOUNT, CHEAPSHARK_MIN_RATING, CHEAPSHARK_MAX_PRICE,
  ITAD_MIN_DISCOUNT, ITAD_MAX_PRICE, ITAD_DEALS_LIMIT. Shared fallbacks
  (MIN_DISCOUNT_PERCENT, MAX_PRICE) are still supported. Legacy
  MAX_PRICE_USD and MIN_DEAL_RATING continue to work as fallbacks.

https://claude.ai/code/session_01EfPjktyrF24DBNHWsY1KBP
2026-02-28 23:40:36 +00:00
prosolis
da957f82ba Merge pull request #13 from prosolis/claude/add-presence-heartbeat-WckxL
Add presence heartbeat to keep bot shown as online
2026-02-28 00:17:05 -08:00
Claude
30c9872d04 Add presence heartbeat to keep bot shown as online
Adds a background asyncio.Task that calls set_presence("online") every
60 seconds to keep the homeserver presence status fresh. On startup the
bot sets itself online and spawns the heartbeat; on shutdown the task is
cancelled and presence is set to offline before closing the client.

https://claude.ai/code/session_01Nu3N2okrfJV3meatEmYdrB
2026-02-28 08:14:46 +00:00
prosolis
123252e197 Merge pull request #12 from prosolis/claude/fix-isthereanydeal-api-W5umt
Fixed post formatting issues
2026-02-27 21:33:00 -08:00
Claude
c5ae8543e1 Fix Matrix formatting: build HTML directly instead of markdown conversion
The markdown library's conversion collapsed blockquote lines into a
single paragraph, causing all deal info to render on one line in Matrix
clients. Build HTML directly with <br> tags for reliable line breaks.
Also adds html.escape() for proper escaping of titles and prices, and
removes the unused markdown dependency.

https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 05:23:37 +00:00
Claude
949eceb983 Filter ITAD deals to only include games and DLC
Non-game content (courses, software bundles) was showing up in deal
posts. Filter on the API's type field to only include entries with
type "game" or "dlc".

https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 05:14:27 +00:00
prosolis
1dcb76a394 Merge pull request #11 from prosolis/claude/fix-isthereanydeal-api-W5umt
CheapShark kinda sucks.. let's move to ITAD instead
2026-02-27 21:06:12 -08:00
Claude
d1a33e97c5 Update README to document ITAD as a deal source and DEAL_SOURCES config
https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 05:04:24 +00:00
Claude
34da126507 Add IsThereAnyDeal as a deal source alongside CheapShark
Users can now choose between CheapShark, ITAD, or both via the
DEAL_SOURCES env var (comma-separated, default: "cheapshark"). When
"itad" is included, the bot polls GET /deals/v2 for current deals with
discount/price filtering and posts them with the same formatting style.
ITAD deals include built-in historical low detection via the API's
deal flags. Preflight checks enforce ITAD_API_KEY when ITAD is a
configured deal source.

https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 04:59:27 +00:00
Claude
72d86fd4e5 Fix IsThereAnyDeal API: use POST for overview endpoint and lookup game UUIDs
The ITAD v2 API requires POST (not GET) for /games/overview/v2, and
expects ITAD game UUIDs in the JSON body rather than Steam app IDs.
Added a lookup step via GET /games/lookup/v1 to convert Steam app IDs
to ITAD UUIDs before calling the overview endpoint. Updated preflight
check to use the lookup endpoint for API key validation.

https://claude.ai/code/session_01B7YPGrE3NatkwadCXVjv2j
2026-02-28 04:47:00 +00:00
prosolis
a83f230291 Merge pull request #10 from prosolis/claude/fix-cheapshark-rating-bug-pxVJv
Post Epic free games on first run instead of silently recording them
2026-02-27 19:38:10 -08:00
prosolis
a334cb7bbe Merge pull request #9 from prosolis/claude/fix-cheapshark-rating-bug-pxVJv
Add SEND_INTRO_MESSAGE to .env.example
2026-02-27 19:13:29 -08:00
prosolis
66407c5a43 Merge pull request #8 from prosolis/claude/fix-cheapshark-rating-bug-pxVJv
More bugfixes
2026-02-27 19:05:26 -08:00
prosolis
19c070405e Merge pull request #7 from prosolis/claude/fix-cheapshark-rating-bug-pxVJv
Fix cheapshark rating and sorting issue
2026-02-27 18:50:37 -08:00
35 changed files with 3268 additions and 1226 deletions

View File

@@ -2,18 +2,51 @@
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)
# IsThereAnyDeal API key (optional — enables historical low detection and ITAD deals)
ITAD_API_KEY=
# Deal filtering
MIN_DEAL_RATING=8.0
# Deal sources: comma-separated list (cheapshark, itad, or both)
# Requires ITAD_API_KEY when "itad" is included
DEAL_SOURCES=cheapshark
# ITAD countries: comma-separated ISO 3166-1 alpha-2 country codes
# Deals are fetched for each country and merged (duplicates removed).
# Examples: US, CA, GB, DE, FR, AU, BR, JP
ITAD_COUNTRIES=US
# Currency display
# DEFAULT_CURRENCY is shown first; EXTRA_CURRENCIES are shown after it.
DEFAULT_CURRENCY=USD
EXTRA_CURRENCIES=CAD,EUR,GBP
# CheapShark-specific filtering
# (Falls back to shared MIN_DISCOUNT_PERCENT / MAX_PRICE if not set)
#CHEAPSHARK_MIN_DISCOUNT=50
#CHEAPSHARK_MIN_RATING=8.0
#CHEAPSHARK_MAX_PRICE=20
# ITAD-specific filtering
# (Falls back to shared MIN_DISCOUNT_PERCENT / MAX_PRICE if not set)
#ITAD_MIN_DISCOUNT=50
#ITAD_MAX_PRICE=20
#ITAD_DEALS_LIMIT=200
# Shared filter defaults (used when source-specific values are not set)
MIN_DISCOUNT_PERCENT=50
MAX_PRICE_USD=20
MAX_PRICE=20
# Matrix threads — post deals into per-category threads instead of the room
# Categories: Game Deals, DLC Deals, Epic Free Games, Non-Game Deals
# When disabled (default), all deals post directly to the room timeline
# and non-game ITAD content is excluded (original behaviour).
#MATRIX_USE_THREADS=false
# Send an intro message to the room on startup (true/false)
SEND_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"]

114
README.md
View File

@@ -1,31 +1,53 @@
# 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.
## Data Sources
- **CheapShark** — polled every 2 hours for top deals across Steam, GOG, Humble Store, and GreenManGaming
- **IsThereAnyDeal** — polled every 2 hours for deals across all tracked stores, with built-in historical low detection (requires API key)
- **Epic Games Store** — polled daily for free game promotions
- **IsThereAnyDeal** (optional) — flags deals that are at an all-time historical low price
CheapShark and IsThereAnyDeal can be used individually or together — configure via the `DEAL_SOURCES` variable. When used as a deal source, IsThereAnyDeal provides historical low flags directly. When only CheapShark is active, IsThereAnyDeal can still optionally enrich deals with historical low info via the `ITAD_API_KEY`.
## 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
@@ -53,37 +75,82 @@ python -m gaming_deals_bot
All configuration is via environment variables (see `.env.example`):
### Core
| Variable | Required | Default | Description |
|---|---|---|---|
| `MATRIX_HOMESERVER_URL` | Yes | — | Matrix homeserver URL |
| `MATRIX_BOT_USER_ID` | Yes | — | Bot's Matrix user ID |
| `MATRIX_BOT_ACCESS_TOKEN` | Yes | — | Bot's access token |
| `MATRIX_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 for historical low detection |
| `ITAD_API_KEY` | No | — | IsThereAnyDeal API key (required when `itad` is in `DEAL_SOURCES`, optional otherwise for historical low detection) |
| `DEAL_SOURCES` | No | cheapshark | Comma-separated deal sources: `cheapshark`, `itad`, or `cheapshark,itad` |
| `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 |
## Deployment
### systemd
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:
- **Matrix** — authentication token is valid and bot has joined the target room
- **CheapShark** — API is reachable
- **CheapShark** — API is reachable (skipped if not in `DEAL_SOURCES`)
- **Epic Games Store** — API is reachable
- **Frankfurter** — exchange rate API is reachable
- **IsThereAnyDeal** — API key is valid (skipped if not configured)
- **IsThereAnyDeal** — API key is valid (required when `itad` is in `DEAL_SOURCES`, skipped otherwise if not configured)
The command exits with code 0 on success and 1 on failure, so it works in CI and Docker health-checks.
## Threads
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 |
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.
## Behavior
- **First run**: fetches current deals and records them in the database without posting (avoids spamming the room with existing deals)
@@ -91,3 +158,26 @@ The command exits with code 0 on success and 1 on failure, so it works in CI and
- **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 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,98 +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
scheduler.add_job(
bot.check_cheapshark,
"interval",
hours=2,
id="cheapshark",
name="CheapShark deals check",
)
# Epic free games: once daily
scheduler.add_job(
bot.check_epic_free_games,
"interval",
hours=24,
id="epic_free",
name="Epic free games check",
)
scheduler.start()
logger.info("Bot started — scheduler running")
await bot.send_intro()
# Run initial checks immediately (after first-run population is done)
await bot.check_cheapshark()
await bot.check_epic_free_games()
# Keep running until signaled to stop
stop_event = asyncio.Event()
def handle_signal():
logger.info("Shutdown signal received")
stop_event.set()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, handle_signal)
await stop_event.wait()
logger.info("Shutting down...")
scheduler.shutdown(wait=False)
await bot.stop()
logger.info("Bot stopped")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,154 +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
from .config import Config
from .currency import refresh_rates
from .database import Database
from .epic import EpicFreeGame, fetch_free_games
from .formatter import format_deal, format_epic_free, format_epic_upcoming
from .itad import check_single_historical_low
from .matrix_client import MatrixDealsClient
logger = logging.getLogger(__name__)
class DealsBot:
def __init__(self, config: Config):
self.config = config
self.db = Database(config.database_path)
self.matrix = MatrixDealsClient(
homeserver_url=config.matrix_homeserver_url,
user_id=config.matrix_bot_user_id,
access_token=config.matrix_bot_access_token,
room_id=config.matrix_deals_room_id,
)
self._http = httpx.AsyncClient(timeout=30)
self._first_run_done = False
async def start(self):
"""Initialize database, fetch exchange rates, and run first-run population."""
await self.db.connect()
# 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
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 CheapShark 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.
"""
deals = await fetch_deals(
self._http,
max_price=self.config.max_price_usd,
min_rating=self.config.min_deal_rating,
min_discount=self.config.min_discount_percent,
)
for deal in deals:
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
logger.info("First run: recorded %d existing CheapShark deals", len(deals))
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.")
async def check_cheapshark(self):
"""Poll CheapShark for deals and post new ones."""
if not self._first_run_done:
return
logger.info("Checking CheapShark for deals...")
deals = await fetch_deals(
self._http,
max_price=self.config.max_price_usd,
min_rating=self.config.min_deal_rating,
min_discount=self.config.min_discount_percent,
)
for deal in deals:
await self._process_deal(deal)
# Prune old records
await self.db.prune_old(days=30)
async def _process_deal(self, deal: CheapSharkDeal):
"""Check dedup, check historical low, format, and post a single deal."""
if await self.db.has_been_posted(deal.dedup_id):
return
# Check if this is a historical low via ITAD
is_historical_low = False
if self.config.itad_api_key and deal.steam_app_id:
is_historical_low = await check_single_historical_low(
self._http,
self.config.itad_api_key,
deal.steam_app_id,
)
plain_text, html = format_deal(deal, is_historical_low)
success = await self.matrix.send_deal(plain_text, html)
if success:
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
logger.info("Posted deal: %s", deal.title)
else:
logger.warning("Failed to post deal: %s — will retry next cycle", deal.title)
async def check_epic_free_games(self):
"""Poll Epic Games Store for free games and post new ones."""
if not self._first_run_done:
return
logger.info("Checking Epic Games Store for free games...")
current_free, upcoming = await fetch_free_games(self._http)
for game in current_free:
await self._process_epic_game(game, is_upcoming=False)
for game in upcoming:
await self._process_epic_game(game, is_upcoming=True)
async def _process_epic_game(self, game: EpicFreeGame, *, is_upcoming: bool):
"""Check dedup, format, and post a single Epic free game."""
if await self.db.has_been_posted(game.dedup_id):
return
if is_upcoming:
plain_text, html = format_epic_upcoming(game)
else:
plain_text, html = format_epic_free(game)
success = await self.matrix.send_deal(plain_text, html)
if success:
await self.db.mark_posted(game.dedup_id, "epic", game.title)
logger.info("Posted Epic game: %s (upcoming=%s)", game.title, is_upcoming)
else:
logger.warning(
"Failed to post Epic game: %s — will retry next cycle", game.title
)

View File

@@ -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,35 +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 filtering
self.min_deal_rating = float(os.environ.get("MIN_DEAL_RATING", "8.0"))
self.min_discount_percent = int(os.environ.get("MIN_DISCOUNT_PERCENT", "50"))
self.max_price_usd = float(os.environ.get("MAX_PRICE_USD", "20"))
# 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,96 +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.
"""
import logging
import time
import httpx
logger = logging.getLogger(__name__)
FRANKFURTER_URL = "https://api.frankfurter.dev/v1/latest"
TARGET_CURRENCIES = ("CAD", "EUR", "GBP")
CACHE_TTL_SECONDS = 43200 # 12 hours — be nice to the free service
# Module-level cache
_rates: dict[str, float] = {}
_last_fetched: float = 0.0
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
symbols = ",".join(TARGET_CURRENCIES)
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 not _rates or (time.monotonic() - _last_fetched) > CACHE_TTL_SECONDS:
await refresh_rates(client)
def _convert(usd_amount: float, currency: str) -> float | None:
"""Convert a USD amount to the target currency using cached rates."""
rate = _rates.get(currency)
if rate is None:
return None
return round(usd_amount * rate, 2)
# Currency display symbols
_SYMBOLS = {
"USD": "$",
"CAD": "C$",
"EUR": "",
"GBP": "£",
}
def format_price(usd_amount: float) -> str:
"""Return a formatted multi-currency price string.
Example: ``$14.99 · C$20.54 · €13.78 · £11.98``
Falls back to USD-only if rates aren't available.
"""
parts = [f"${usd_amount:.2f}"]
for cur in TARGET_CURRENCIES:
converted = _convert(usd_amount, cur)
if converted is not None:
symbol = _SYMBOLS.get(cur, cur)
parts.append(f"{symbol}{converted:.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,79 +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
);
"""
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()

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,138 +0,0 @@
"""Message formatting for Matrix deal posts.
Produces both a plain-text fallback and HTML formatted body for Matrix messages.
"""
from datetime import datetime, timezone
import markdown
from .cheapshark import CheapSharkDeal
from .currency import format_price
from .epic import EpicFreeGame
_md = markdown.Markdown()
def _render_html(md_text: str) -> str:
"""Convert Markdown to HTML, resetting the parser between calls."""
_md.reset()
return _md.convert(md_text)
def format_deal(deal: CheapSharkDeal, is_historical_low: bool = False) -> tuple[str, str]:
"""Format a CheapShark deal into (plain_text, html) for Matrix.
Returns (body, formatted_body).
"""
discount = int(deal.savings)
sale_price = float(deal.sale_price)
normal_price = float(deal.normal_price)
sale_multi = format_price(sale_price)
normal_display = format_price(normal_price)
lines = [
f"**🎮 [DEAL] {deal.title}**",
f"> {discount}% off on {deal.store_name} ~~{normal_display}~~",
f"> 💰 **{sale_multi}**",
]
if is_historical_low:
lines.append("> 🏆 _All-time low!_")
lines.append(f"> 🔗 [View Deal]({deal.deal_url})")
md_text = "\n".join(lines)
html = _render_html(md_text)
# Plain text fallback — strip markdown syntax
plain_lines = [
f"🎮 [DEAL] {deal.title}",
f" {discount}% off 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_epic_free(game: EpicFreeGame) -> tuple[str, str]:
"""Format an Epic free game into (plain_text, html) for Matrix.
Returns (body, formatted_body).
"""
lines = [
f"**🆓 [FREE] {game.title}**",
"> Free on Epic Games Store",
]
if game.end_date:
try:
end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00"))
date_str = end_dt.strftime("%B %-d")
lines.append(f"> 📅 _Free until {date_str}_")
except (ValueError, TypeError):
pass
if game.url:
lines.append(f"> 🔗 [Claim Now]({game.url})")
md_text = "\n".join(lines)
html = _render_html(md_text)
# Plain text fallback
plain_lines = [
f"🆓 [FREE] {game.title}",
" Free on Epic Games Store",
]
if game.end_date:
try:
end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00"))
date_str = end_dt.strftime("%B %-d")
plain_lines.append(f" 📅 Free until {date_str}")
except (ValueError, TypeError):
pass
if game.url:
plain_lines.append(f" 🔗 {game.url}")
plain_text = "\n".join(plain_lines)
return plain_text, html
def format_epic_upcoming(game: EpicFreeGame) -> tuple[str, str]:
"""Format an upcoming Epic free game into (plain_text, html) for Matrix."""
lines = [
f"**📢 [UPCOMING FREE] {game.title}**",
"> Coming soon — Free on Epic Games Store",
]
if game.end_date:
try:
end_dt = datetime.fromisoformat(game.end_date.replace("Z", "+00:00"))
date_str = end_dt.strftime("%B %-d")
lines.append(f"> 📅 _Free until {date_str}_")
except (ValueError, TypeError):
pass
if game.url:
lines.append(f"> 🔗 [Store Page]({game.url})")
md_text = "\n".join(lines)
html = _render_html(md_text)
plain_lines = [
f"📢 [UPCOMING FREE] {game.title}",
" Coming soon — Free on Epic Games Store",
]
if game.url:
plain_lines.append(f" 🔗 {game.url}")
plain_text = "\n".join(plain_lines)
return plain_text, html

View File

@@ -1,92 +0,0 @@
import logging
import httpx
logger = logging.getLogger(__name__)
BASE_URL = "https://api.isthereanydeal.com"
async def check_historical_lows(
client: httpx.AsyncClient,
api_key: str,
steam_app_ids: list[str],
) -> dict[str, bool]:
"""Check if current prices are historical lows for a batch of Steam app IDs.
Returns a mapping of steam_app_id -> is_historical_low.
"""
if not api_key or not steam_app_ids:
return {}
# Build the list of ITAD game IDs from Steam app IDs
# ITAD uses the format "app/{steam_app_id}" for Steam games
game_ids = [f"app/{sid}" for sid in steam_app_ids]
try:
resp = await client.get(
f"{BASE_URL}/games/overview/v2",
params={"key": api_key, "shops[]": "steam"},
headers={"Content-Type": "application/json"},
)
# The v2 endpoint may use POST with body — fall back to per-game lookup
# if batch doesn't work. For now, use the games/info endpoint pattern.
resp.raise_for_status()
data = resp.json()
except (httpx.HTTPError, ValueError) as exc:
logger.error("ITAD API error: %s", exc)
return {}
results: dict[str, bool] = {}
# Parse the response — structure depends on the ITAD API version
# The overview endpoint returns price overview with historical low data
if isinstance(data, dict):
for steam_id in steam_app_ids:
game_key = f"app/{steam_id}"
game_data = data.get(game_key) or data.get(steam_id, {})
if isinstance(game_data, dict):
lowest = game_data.get("lowest", {})
current = game_data.get("current", {})
if lowest and current:
lowest_price = lowest.get("price", float("inf"))
current_price = current.get("price", float("inf"))
results[steam_id] = current_price <= lowest_price
logger.info(
"ITAD: checked %d games, %d are at historical low",
len(steam_app_ids),
sum(results.values()),
)
return results
async def check_single_historical_low(
client: httpx.AsyncClient,
api_key: str,
steam_app_id: str,
) -> bool:
"""Check if a single game is at its historical low price."""
if not api_key or not steam_app_id:
return False
try:
resp = await client.get(
f"{BASE_URL}/games/overview/v2",
params={
"key": api_key,
"apps[]": f"app/{steam_app_id}",
},
)
resp.raise_for_status()
data = resp.json()
except (httpx.HTTPError, ValueError) as exc:
logger.error("ITAD API error for app %s: %s", steam_app_id, exc)
return False
# Try to extract historical low info
if isinstance(data, dict) and "prices" in data:
for price_info in data["prices"]:
if price_info.get("isLowest"):
return True
return False

View File

@@ -1,72 +0,0 @@
"""Matrix client wrapper for sending deal messages."""
import logging
from nio import AsyncClient, RoomSendError
logger = logging.getLogger(__name__)
class MatrixDealsClient:
def __init__(
self,
homeserver_url: str,
user_id: str,
access_token: str,
room_id: str,
):
self.room_id = room_id
self._client = AsyncClient(homeserver_url, user_id)
self._client.access_token = access_token
self._client.user_id = user_id
async def send_deal(self, plain_text: str, html: str) -> bool:
"""Send a deal message to the configured room.
Returns True on success, False on failure.
"""
content = {
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"body": plain_text,
"formatted_body": html,
}
try:
resp = await self._client.room_send(
room_id=self.room_id,
message_type="m.room.message",
content=content,
)
if isinstance(resp, RoomSendError):
logger.error("Failed to send message: %s", resp.message)
return False
logger.debug("Message sent: %s", resp.event_id)
return True
except Exception as exc:
logger.error("Matrix send error: %s", exc)
return False
async def 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 close(self):
await self._client.close()

View File

@@ -1,183 +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, 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
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}")
all_ok &= await _check_cheapshark(http)
# --- 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)
# --- IsThereAnyDeal (optional) ---
print(f"\n{_BOLD}IsThereAnyDeal{_RESET}")
all_ok &= await _check_itad(http, config.itad_api_key)
# --- 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) -> bool:
"""Fetch exchange rates to confirm Frankfurter is reachable."""
try:
symbols = ",".join(TARGET_CURRENCIES)
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) -> bool:
"""Verify the ITAD API key works (optional — skipped if no key is set)."""
if not api_key:
return _skip("Skipped", "no ITAD_API_KEY configured (optional)")
try:
# Use a lightweight endpoint to validate the key
resp = await http.get(
f"{ITAD_URL}/games/overview/v2",
params={"key": api_key, "apps[]": "app/220"}, # Half-Life 2
)
if resp.status_code == 401 or resp.status_code == 403:
return _fail("API key", "rejected by ITAD (401/403)")
resp.raise_for_status()
return _pass("API key valid", "ITAD responded successfully")
except Exception as exc:
return _fail("API reachable", str(exc))

View File

@@ -0,0 +1,59 @@
"""Thread category definitions for Matrix room threads.
When ``MATRIX_USE_THREADS=true``, deal messages are posted inside
per-category threads rather than directly into the room timeline.
"""
from enum import Enum
from html import escape
class ThreadCategory(str, Enum):
"""Categories that map to distinct Matrix threads."""
GAME_DEALS = "game_deals"
DLC_DEALS = "dlc_deals"
EPIC_FREE = "epic_free"
NON_GAME_DEALS = "non_game_deals"
# Display metadata for each thread category.
# ``label`` is the thread root title; ``description`` appears below it.
THREAD_META: dict[ThreadCategory, dict[str, str]] = {
ThreadCategory.GAME_DEALS: {
"label": "🎮 Game Deals",
"description": "PC game deals from CheapShark and IsThereAnyDeal",
},
ThreadCategory.DLC_DEALS: {
"label": "🧩 DLC Deals",
"description": "DLC and expansion deals from IsThereAnyDeal",
},
ThreadCategory.EPIC_FREE: {
"label": "🆓 Epic Free Games",
"description": "Weekly free games from the Epic Games Store",
},
ThreadCategory.NON_GAME_DEALS: {
"label": "📦 Non-Game Deals",
"description": "Software, courses, and other non-game deals",
},
}
def itad_type_to_category(deal_type: str) -> ThreadCategory:
"""Map an ITAD ``type`` value to a thread category."""
if deal_type == "game":
return ThreadCategory.GAME_DEALS
if deal_type == "dlc":
return ThreadCategory.DLC_DEALS
return ThreadCategory.NON_GAME_DEALS
def format_thread_root(category: ThreadCategory) -> tuple[str, str]:
"""Return ``(plain_text, html)`` for a thread root message."""
meta = THREAD_META[category]
label = meta["label"]
desc = meta["description"]
plain_text = f"{label}\n{desc}"
html = f"<strong>{escape(label)}</strong><br>\n<em>{escape(desc)}</em>"
return plain_text, html

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,6 +0,0 @@
matrix-nio>=0.21.0
httpx>=0.25.0
aiosqlite>=0.19.0
apscheduler>=3.10.0
markdown>=3.5.0
python-dotenv>=1.0.0