Compare commits
36 Commits
claude/gam
...
fix/deal-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
373939fedb | ||
|
|
e452e36a63 | ||
|
|
d6c8d4f599 | ||
|
|
0cfed504a1 | ||
|
|
b8a0fd5f35 | ||
|
|
24fc9240fe | ||
|
|
2b1c606a80 | ||
|
|
d55f33af25 | ||
|
|
f13bfd7b07 | ||
|
|
e4d90d0331 | ||
|
|
da957f82ba | ||
|
|
30c9872d04 | ||
|
|
123252e197 | ||
|
|
c5ae8543e1 | ||
|
|
949eceb983 | ||
|
|
1dcb76a394 | ||
|
|
d1a33e97c5 | ||
|
|
34da126507 | ||
|
|
72d86fd4e5 | ||
|
|
a83f230291 | ||
|
|
0302cea523 | ||
|
|
a334cb7bbe | ||
|
|
c486b1f5c6 | ||
|
|
66407c5a43 | ||
|
|
13cdd6d8b5 | ||
|
|
703f92d46c | ||
|
|
0fd58c32b4 | ||
|
|
19c070405e | ||
|
|
3f7e8196e3 | ||
|
|
c33e46c3cf | ||
|
|
c145d83b0b | ||
|
|
7b05d86dc5 | ||
|
|
b6b674ca0e | ||
|
|
c09ecdbd21 | ||
|
|
aed260f345 | ||
|
|
ba1a3811d6 |
50
.env.example
50
.env.example
@@ -2,15 +2,51 @@
|
|||||||
MATRIX_HOMESERVER_URL=https://matrix.example.com
|
MATRIX_HOMESERVER_URL=https://matrix.example.com
|
||||||
MATRIX_BOT_USER_ID=@dealsbot:example.com
|
MATRIX_BOT_USER_ID=@dealsbot:example.com
|
||||||
MATRIX_BOT_ACCESS_TOKEN=syt_...
|
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
|
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=
|
ITAD_API_KEY=
|
||||||
|
|
||||||
# Deal filtering
|
# Deal sources: comma-separated list (cheapshark, itad, or both)
|
||||||
MIN_DEAL_RATING=8.0
|
# Requires ITAD_API_KEY when "itad" is included
|
||||||
MIN_DISCOUNT_PERCENT=50
|
DEAL_SOURCES=cheapshark
|
||||||
MAX_PRICE_USD=20
|
|
||||||
|
|
||||||
# Database path (inside the container, mount a volume for persistence)
|
# ITAD countries: comma-separated ISO 3166-1 alpha-2 country codes
|
||||||
DATABASE_PATH=/data/deals.db
|
# 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=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
|
||||||
|
DATABASE_PATH=deals.db
|
||||||
|
|||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.db.bak
|
||||||
|
device.json
|
||||||
|
/pastel
|
||||||
|
/migrate
|
||||||
10
Dockerfile
10
Dockerfile
@@ -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"]
|
|
||||||
115
README.md
115
README.md
@@ -1,31 +1,53 @@
|
|||||||
# Pastel
|
# 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.
|
Deals are sourced from PC/digital storefronts only (Steam, GOG, Humble Store, GreenManGaming, Epic Games Store) — universally accessible regardless of region.
|
||||||
|
|
||||||
## Data Sources
|
## Data Sources
|
||||||
|
|
||||||
- **CheapShark** — polled every 2 hours for top deals across Steam, GOG, Humble Store, and GreenManGaming
|
- **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
|
- **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
|
## Quick Start
|
||||||
|
|
||||||
1. Copy `.env.example` to `.env` and fill in your Matrix credentials and room ID
|
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
|
```bash
|
||||||
docker build -t pastel .
|
docker build -t pastel .
|
||||||
docker run --env-file .env -v pastel-data:/data pastel
|
docker run --env-file .env -v pastel-data:/data pastel
|
||||||
```
|
```
|
||||||
|
|
||||||
Or run directly with Python 3.12+:
|
## Watchlist
|
||||||
|
|
||||||
```bash
|
Users can DM the bot to set up personal deal alerts. When a matching deal appears, the bot sends a DM notification.
|
||||||
pip install -r requirements.txt
|
|
||||||
python -m gaming_deals_bot
|
| 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
|
## Obtaining a Matrix Bot Access Token
|
||||||
|
|
||||||
@@ -53,36 +75,82 @@ python -m gaming_deals_bot
|
|||||||
|
|
||||||
All configuration is via environment variables (see `.env.example`):
|
All configuration is via environment variables (see `.env.example`):
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `MATRIX_HOMESERVER_URL` | Yes | — | Matrix homeserver URL |
|
| `MATRIX_HOMESERVER_URL` | Yes | — | Matrix homeserver URL |
|
||||||
| `MATRIX_BOT_USER_ID` | Yes | — | Bot's Matrix user ID |
|
| `MATRIX_BOT_USER_ID` | Yes | — | Bot's Matrix user ID |
|
||||||
| `MATRIX_BOT_ACCESS_TOKEN` | Yes | — | Bot's access token |
|
| `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 |
|
| `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_DEAL_RATING` | No | 8.0 | Minimum CheapShark deal rating (0-10) |
|
||||||
| `MIN_DISCOUNT_PERCENT` | No | 50 | Minimum discount percentage |
|
| `MIN_DISCOUNT_PERCENT` | No | 50 | Minimum discount percentage |
|
||||||
| `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD |
|
| `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 |
|
| `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
|
## Preflight Check
|
||||||
|
|
||||||
Run `--check` to validate your configuration and test connectivity to all services before starting the bot:
|
Run `--check` to validate your configuration and test connectivity to all services before starting the bot:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m gaming_deals_bot --check
|
./pastel --check
|
||||||
```
|
```
|
||||||
|
|
||||||
This verifies:
|
This verifies:
|
||||||
|
|
||||||
- **Matrix** — authentication token is valid and bot has joined the target room
|
- **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
|
- **Epic Games Store** — API is reachable
|
||||||
- **Frankfurter** — exchange rate 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.
|
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
|
## Behavior
|
||||||
|
|
||||||
- **First run**: fetches current deals and records them in the database without posting (avoids spamming the room with existing deals)
|
- **First run**: fetches current deals and records them in the database without posting (avoids spamming the room with existing deals)
|
||||||
@@ -90,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
|
- **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
|
- **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.
|
- **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
174
cmd/migrate/main.go
Normal 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
506
cmd/pastel/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,96 +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")
|
|
||||||
|
|
||||||
# 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())
|
|
||||||
@@ -1,149 +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 deals and record them without posting (avoids spam on first run)."""
|
|
||||||
deals = await fetch_deals(
|
|
||||||
self._http,
|
|
||||||
max_price=self.config.max_price_usd,
|
|
||||||
min_rating=self.config.min_deal_rating,
|
|
||||||
min_discount=self.config.min_discount_percent,
|
|
||||||
)
|
|
||||||
for deal in deals:
|
|
||||||
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
|
|
||||||
|
|
||||||
current_free, upcoming = await fetch_free_games(self._http)
|
|
||||||
for game in current_free:
|
|
||||||
await self.db.mark_posted(game.dedup_id, "epic", game.title)
|
|
||||||
for game in upcoming:
|
|
||||||
await self.db.mark_posted(game.dedup_id, "epic", game.title)
|
|
||||||
|
|
||||||
total = len(deals) + len(current_free) + len(upcoming)
|
|
||||||
logger.info("First run: recorded %d existing deals/games", total)
|
|
||||||
|
|
||||||
async def check_cheapshark(self):
|
|
||||||
"""Poll CheapShark for deals and post new ones."""
|
|
||||||
if not self._first_run_done:
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Checking CheapShark for deals...")
|
|
||||||
deals = await fetch_deals(
|
|
||||||
self._http,
|
|
||||||
max_price=self.config.max_price_usd,
|
|
||||||
min_rating=self.config.min_deal_rating,
|
|
||||||
min_discount=self.config.min_discount_percent,
|
|
||||||
)
|
|
||||||
|
|
||||||
for deal in deals:
|
|
||||||
await self._process_deal(deal)
|
|
||||||
|
|
||||||
# Prune old records
|
|
||||||
await self.db.prune_old(days=30)
|
|
||||||
|
|
||||||
async def _process_deal(self, deal: CheapSharkDeal):
|
|
||||||
"""Check dedup, check historical low, format, and post a single deal."""
|
|
||||||
if await self.db.has_been_posted(deal.dedup_id):
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if this is a historical low via ITAD
|
|
||||||
is_historical_low = False
|
|
||||||
if self.config.itad_api_key and deal.steam_app_id:
|
|
||||||
is_historical_low = await check_single_historical_low(
|
|
||||||
self._http,
|
|
||||||
self.config.itad_api_key,
|
|
||||||
deal.steam_app_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
plain_text, html = format_deal(deal, is_historical_low)
|
|
||||||
success = await self.matrix.send_deal(plain_text, html)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
await self.db.mark_posted(deal.dedup_id, "cheapshark", deal.title)
|
|
||||||
logger.info("Posted deal: %s", deal.title)
|
|
||||||
else:
|
|
||||||
logger.warning("Failed to post deal: %s — will retry next cycle", deal.title)
|
|
||||||
|
|
||||||
async def check_epic_free_games(self):
|
|
||||||
"""Poll Epic Games Store for free games and post new ones."""
|
|
||||||
if not self._first_run_done:
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Checking Epic Games Store for free games...")
|
|
||||||
current_free, upcoming = await fetch_free_games(self._http)
|
|
||||||
|
|
||||||
for game in current_free:
|
|
||||||
await self._process_epic_game(game, is_upcoming=False)
|
|
||||||
|
|
||||||
for game in upcoming:
|
|
||||||
await self._process_epic_game(game, is_upcoming=True)
|
|
||||||
|
|
||||||
async def _process_epic_game(self, game: EpicFreeGame, *, is_upcoming: bool):
|
|
||||||
"""Check dedup, format, and post a single Epic free game."""
|
|
||||||
if await self.db.has_been_posted(game.dedup_id):
|
|
||||||
return
|
|
||||||
|
|
||||||
if is_upcoming:
|
|
||||||
plain_text, html = format_epic_upcoming(game)
|
|
||||||
else:
|
|
||||||
plain_text, html = format_epic_free(game)
|
|
||||||
|
|
||||||
success = await self.matrix.send_deal(plain_text, html)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
await self.db.mark_posted(game.dedup_id, "epic", game.title)
|
|
||||||
logger.info("Posted Epic game: %s (upcoming=%s)", game.title, is_upcoming)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to post Epic game: %s — will retry next cycle", game.title
|
|
||||||
)
|
|
||||||
@@ -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: int = 80,
|
|
||||||
min_discount: int = 50,
|
|
||||||
page_size: int = 10,
|
|
||||||
) -> list[CheapSharkDeal]:
|
|
||||||
"""Fetch top deals from CheapShark across configured stores."""
|
|
||||||
store_ids = ",".join(STORE_IDS.keys())
|
|
||||||
params = {
|
|
||||||
"storeID": store_ids,
|
|
||||||
"upperPrice": str(int(max_price)),
|
|
||||||
"sortBy": "Deal Rating",
|
|
||||||
"desc": "1",
|
|
||||||
"pageSize": str(page_size),
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = await client.get(f"{BASE_URL}/deals", params=params)
|
|
||||||
resp.raise_for_status()
|
|
||||||
raw_deals = resp.json()
|
|
||||||
except (httpx.HTTPError, ValueError) as exc:
|
|
||||||
logger.error("CheapShark API error: %s", exc)
|
|
||||||
return []
|
|
||||||
|
|
||||||
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 < 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
|
|
||||||
@@ -1,30 +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"))
|
|
||||||
|
|
||||||
# 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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -1,51 +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 close(self):
|
|
||||||
await self._client.close()
|
|
||||||
@@ -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))
|
|
||||||
59
gaming_deals_bot/threads.py
Normal file
59
gaming_deals_bot/threads.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Thread category definitions for Matrix room threads.
|
||||||
|
|
||||||
|
When ``MATRIX_USE_THREADS=true``, deal messages are posted inside
|
||||||
|
per-category threads rather than directly into the room timeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadCategory(str, Enum):
|
||||||
|
"""Categories that map to distinct Matrix threads."""
|
||||||
|
|
||||||
|
GAME_DEALS = "game_deals"
|
||||||
|
DLC_DEALS = "dlc_deals"
|
||||||
|
EPIC_FREE = "epic_free"
|
||||||
|
NON_GAME_DEALS = "non_game_deals"
|
||||||
|
|
||||||
|
|
||||||
|
# Display metadata for each thread category.
|
||||||
|
# ``label`` is the thread root title; ``description`` appears below it.
|
||||||
|
THREAD_META: dict[ThreadCategory, dict[str, str]] = {
|
||||||
|
ThreadCategory.GAME_DEALS: {
|
||||||
|
"label": "🎮 Game Deals",
|
||||||
|
"description": "PC game deals from CheapShark and IsThereAnyDeal",
|
||||||
|
},
|
||||||
|
ThreadCategory.DLC_DEALS: {
|
||||||
|
"label": "🧩 DLC Deals",
|
||||||
|
"description": "DLC and expansion deals from IsThereAnyDeal",
|
||||||
|
},
|
||||||
|
ThreadCategory.EPIC_FREE: {
|
||||||
|
"label": "🆓 Epic Free Games",
|
||||||
|
"description": "Weekly free games from the Epic Games Store",
|
||||||
|
},
|
||||||
|
ThreadCategory.NON_GAME_DEALS: {
|
||||||
|
"label": "📦 Non-Game Deals",
|
||||||
|
"description": "Software, courses, and other non-game deals",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def itad_type_to_category(deal_type: str) -> ThreadCategory:
|
||||||
|
"""Map an ITAD ``type`` value to a thread category."""
|
||||||
|
if deal_type == "game":
|
||||||
|
return ThreadCategory.GAME_DEALS
|
||||||
|
if deal_type == "dlc":
|
||||||
|
return ThreadCategory.DLC_DEALS
|
||||||
|
return ThreadCategory.NON_GAME_DEALS
|
||||||
|
|
||||||
|
|
||||||
|
def format_thread_root(category: ThreadCategory) -> tuple[str, str]:
|
||||||
|
"""Return ``(plain_text, html)`` for a thread root message."""
|
||||||
|
meta = THREAD_META[category]
|
||||||
|
label = meta["label"]
|
||||||
|
desc = meta["description"]
|
||||||
|
|
||||||
|
plain_text = f"{label}\n{desc}"
|
||||||
|
html = f"<strong>{escape(label)}</strong><br>\n<em>{escape(desc)}</em>"
|
||||||
|
return plain_text, html
|
||||||
36
go.mod
Normal file
36
go.mod
Normal 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
112
go.sum
Normal 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
118
internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
96
internal/currency/currency.go
Normal file
96
internal/currency/currency.go
Normal 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
|
||||||
|
}
|
||||||
124
internal/database/database.go
Normal file
124
internal/database/database.go
Normal 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
|
||||||
|
}
|
||||||
188
internal/deals/cheapshark.go
Normal file
188
internal/deals/cheapshark.go
Normal 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
156
internal/deals/epic.go
Normal 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
245
internal/deals/itad.go
Normal 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
|
||||||
|
}
|
||||||
126
internal/formatter/formatter.go
Normal file
126
internal/formatter/formatter.go
Normal 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
506
internal/matrix/client.go
Normal 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)
|
||||||
|
}
|
||||||
36
internal/matrix/sqlitedriver.go
Normal file
36
internal/matrix/sqlitedriver.go
Normal 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{}})
|
||||||
|
}
|
||||||
164
internal/preflight/preflight.go
Normal file
164
internal/preflight/preflight.go
Normal 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"}
|
||||||
|
}
|
||||||
283
internal/watchlist/commands.go
Normal file
283
internal/watchlist/commands.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
170
internal/watchlist/watchlist.go
Normal file
170
internal/watchlist/watchlist.go
Normal 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
22
pastel.service
Normal 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
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user