Rewrite Pastel from Python to Go with watchlist feature
Full rewrite of the gaming deals Matrix bot in Go for better performance and a single static binary. Includes all original functionality (CheapShark, ITAD, Epic free games, multi-currency pricing, dedup, preflight checks, presence heartbeat) plus a new watchlist feature where users can DM the bot to watch for specific game deals with 180-day auto-expiry. New: systemd service file, DB migration script for Python-to-Go transition. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,2 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
*.db
|
||||
*.db.bak
|
||||
pastel
|
||||
migrate
|
||||
|
||||
119
README.md
119
README.md
@@ -1,6 +1,6 @@
|
||||
# Pastel
|
||||
|
||||
A Matrix bot that posts gaming deals and free game alerts to a specified Matrix room.
|
||||
A Matrix bot that posts gaming deals and free game alerts to a specified Matrix room, with a personal watchlist feature via DMs.
|
||||
|
||||
Deals are sourced from PC/digital storefronts only (Steam, GOG, Humble Store, GreenManGaming, Epic Games Store) — universally accessible regardless of region.
|
||||
|
||||
@@ -15,19 +15,39 @@ CheapShark and IsThereAnyDeal can be used individually or together — configure
|
||||
## Quick Start
|
||||
|
||||
1. Copy `.env.example` to `.env` and fill in your Matrix credentials and room ID
|
||||
2. Run with Docker:
|
||||
2. Build and run with Go 1.25+:
|
||||
|
||||
```bash
|
||||
go build -tags goolm -o pastel ./cmd/pastel
|
||||
./pastel
|
||||
```
|
||||
|
||||
Or run with Docker:
|
||||
|
||||
```bash
|
||||
docker build -t pastel .
|
||||
docker run --env-file .env -v pastel-data:/data pastel
|
||||
```
|
||||
|
||||
Or run directly with Python 3.12+:
|
||||
## Watchlist
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python -m gaming_deals_bot
|
||||
```
|
||||
Users can DM the bot to set up personal deal alerts. When a matching deal appears, the bot sends a DM notification.
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `!search <game name>` | Search for current deals on a game |
|
||||
| `!watch <game name>` | Watch for deals on a game |
|
||||
| `!unwatch <# or game name>` | Remove a game from your watchlist |
|
||||
| `!extend <# or game name>` | Reset the 180-day expiry timer |
|
||||
| `!watchlist` | Show your numbered watchlist |
|
||||
| `!help` | List available commands |
|
||||
|
||||
- Watches expire after **180 days** to prevent stale entries from accumulating
|
||||
- The bot sends a reminder **7 days before expiry** — reply with `!extend` to keep it
|
||||
- Matching uses normalized substring search, so watching "elden ring" will match "ELDEN RING: Shadow of the Erdtree"
|
||||
- Notifications are sent via encrypted DMs (E2EE)
|
||||
- `!unwatch` and `!extend` accept either the game name or a number from `!watchlist`
|
||||
- `!search` is rate-limited to 5 searches per 10 minutes per user
|
||||
|
||||
## Obtaining a Matrix Bot Access Token
|
||||
|
||||
@@ -62,37 +82,51 @@ All configuration is via environment variables (see `.env.example`):
|
||||
| `MATRIX_HOMESERVER_URL` | Yes | — | Matrix homeserver URL |
|
||||
| `MATRIX_BOT_USER_ID` | Yes | — | Bot's Matrix user ID |
|
||||
| `MATRIX_BOT_ACCESS_TOKEN` | Yes | — | Bot's access token |
|
||||
| `MATRIX_BOT_PASSWORD` | No | — | Bot's password (enables auto-refresh, cross-signing, and device persistence) |
|
||||
| `MATRIX_DEALS_ROOM_ID` | Yes | — | Room ID to post deals in |
|
||||
| `ITAD_API_KEY` | No | — | IsThereAnyDeal API key (required when `itad` is in `DEAL_SOURCES`, optional otherwise for historical low detection) |
|
||||
| `DEAL_SOURCES` | No | cheapshark | Comma-separated deal sources: `cheapshark`, `itad`, or `cheapshark,itad` |
|
||||
| `ITAD_COUNTRIES` | No | US | Comma-separated ISO 3166-1 alpha-2 country codes to fetch ITAD deals from (e.g. `US,CA,GB,DE`) |
|
||||
| `DEFAULT_CURRENCY` | No | USD | Primary display currency shown first in price strings |
|
||||
| `EXTRA_CURRENCIES` | No | CAD,EUR,GBP | Additional currencies shown after the default (comma-separated) |
|
||||
| `MATRIX_USE_THREADS` | No | false | Post deals into per-category threads (see Threads section below) |
|
||||
| `MIN_DEAL_RATING` | No | 8.0 | Minimum CheapShark deal rating (0-10) |
|
||||
| `MIN_DISCOUNT_PERCENT` | No | 50 | Minimum discount percentage |
|
||||
| `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD |
|
||||
| `SEND_INTRO_MESSAGE` | No | false | Send "The deals must flow." to the room on startup |
|
||||
| `DATABASE_PATH` | No | deals.db | Path to SQLite database file |
|
||||
|
||||
### Filtering
|
||||
## Deployment
|
||||
|
||||
Each deal source has its own filter settings. Source-specific values take priority; when not set they fall back to the shared defaults.
|
||||
### systemd
|
||||
|
||||
| Variable | Source | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `CHEAPSHARK_MIN_DISCOUNT` | CheapShark | 50 | Minimum discount percentage |
|
||||
| `CHEAPSHARK_MIN_RATING` | CheapShark | 8.0 | Minimum deal rating (0-10, 0 = unrated allowed) |
|
||||
| `CHEAPSHARK_MAX_PRICE` | CheapShark | 20 | Maximum sale price (USD) |
|
||||
| `ITAD_MIN_DISCOUNT` | ITAD | 50 | Minimum discount percentage |
|
||||
| `ITAD_MAX_PRICE` | ITAD | 20 | Maximum sale price (USD, prices from other regions are converted) |
|
||||
| `ITAD_DEALS_LIMIT` | ITAD | 200 | Number of deals to fetch per country (max 200) |
|
||||
| `MIN_DISCOUNT_PERCENT` | Shared | 50 | Fallback minimum discount when source-specific value is not set |
|
||||
| `MAX_PRICE` | Shared | 20 | Fallback maximum price when source-specific value is not set |
|
||||
A service file is included for systemd deployments:
|
||||
|
||||
```bash
|
||||
# Build and install
|
||||
go build -tags goolm -o pastel ./cmd/pastel
|
||||
sudo mkdir -p /opt/pastel
|
||||
sudo cp pastel /opt/pastel/
|
||||
sudo cp .env /opt/pastel/
|
||||
|
||||
# Install and start the service
|
||||
sudo cp pastel.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now pastel
|
||||
```
|
||||
|
||||
The service runs as a hardened unit with `ProtectSystem=strict`, restricting writes to `/opt/pastel` only. Adjust `WorkingDirectory` in the service file if deploying elsewhere.
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
sudo systemctl status pastel
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u pastel -f
|
||||
```
|
||||
|
||||
## Preflight Check
|
||||
|
||||
Run `--check` to validate your configuration and test connectivity to all services before starting the bot:
|
||||
|
||||
```bash
|
||||
python -m gaming_deals_bot --check
|
||||
./pastel --check
|
||||
```
|
||||
|
||||
This verifies:
|
||||
@@ -107,24 +141,43 @@ The command exits with code 0 on success and 1 on failure, so it works in CI and
|
||||
|
||||
## Threads
|
||||
|
||||
When `MATRIX_USE_THREADS=true`, deals are posted inside per-category threads instead of directly into the room timeline. This keeps the room organized and lets users follow only the categories they care about.
|
||||
Deals are posted inside per-category threads to keep the room organized:
|
||||
|
||||
| Thread | Content |
|
||||
|---|---|
|
||||
| 🎮 Game Deals | CheapShark deals + ITAD deals with type `game` |
|
||||
| 🧩 DLC Deals | ITAD deals with type `dlc` |
|
||||
| 🆓 Epic Free Games | Current and upcoming free games from the Epic Games Store |
|
||||
| 📦 Non-Game Deals | ITAD deals that aren't games or DLC (software, courses, etc.) |
|
||||
| Game Deals | CheapShark deals + ITAD deals with type `game` |
|
||||
| DLC Deals | ITAD deals with type `dlc` |
|
||||
| Epic Free Games | Current and upcoming free games from the Epic Games Store |
|
||||
|
||||
Thread root messages are created automatically the first time a deal in that category appears. The root event IDs are stored in the database so subsequent deals are posted into the same threads.
|
||||
|
||||
When threads are **disabled** (default), the bot behaves as before — all deals post directly to the room and non-game ITAD content is excluded.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **First run**: fetches current deals and records them in the database without posting (avoids spamming the room with existing deals)
|
||||
- **Deduplication**: deals are tracked by game ID + timestamp; duplicates are never reposted
|
||||
- **Pruning**: deals older than 30 days are pruned from the database so they can be reposted if they return
|
||||
- **One message per deal**: each deal is posted individually so messages are independently linkable and dismissible
|
||||
- **Multi-currency pricing**: deal prices are shown in your configured currencies (default: USD, CAD, EUR, GBP) using live exchange rates from the [Frankfurter API](https://api.frankfurter.dev) (ECB data, no API key required). Set `DEFAULT_CURRENCY` to change the primary display currency and `EXTRA_CURRENCIES` for additional ones. Rates are cached and refreshed twice daily.
|
||||
- **Multi-country ITAD deals**: when using IsThereAnyDeal, deals can be fetched from multiple countries simultaneously via `ITAD_COUNTRIES` (e.g. `US,CA,GB,DE`). Deals are merged and deduplicated, with the first country in the list taking priority for duplicate games.
|
||||
- **Multi-currency pricing**: deal prices are shown in USD, CAD, EUR, and GBP using live exchange rates from the [Frankfurter API](https://api.frankfurter.dev) (ECB data, no API key required). Rates are cached and refreshed twice daily.
|
||||
- **E2EE support**: persistent crypto store via mautrix CryptoHelper for encrypted room and DM support
|
||||
- **Auto-refresh**: when `MATRIX_BOT_PASSWORD` is set, the bot persists device credentials and automatically re-authenticates if the token expires
|
||||
- **Cross-signing**: the bot bootstraps cross-signing on startup so its device is automatically verified
|
||||
- **Presence heartbeat**: keeps the bot shown as online in Matrix clients
|
||||
|
||||
## Migrating from the Python Version
|
||||
|
||||
The Go version is compatible with the existing Python `deals.db`. A migration script is included to convert timestamp formats and create the new watchlist table:
|
||||
|
||||
```bash
|
||||
# Build the migration tool
|
||||
go build -o migrate ./cmd/migrate
|
||||
|
||||
# Migrate (creates a .bak backup automatically)
|
||||
./migrate deals.db
|
||||
```
|
||||
|
||||
The script:
|
||||
- Creates a backup at `deals.db.bak`
|
||||
- Converts Python's timestamp formats (`YYYY-MM-DD HH:MM:SS`, `YYYY-MM-DDTHH:MM:SS+00:00`) to RFC 3339
|
||||
- Creates the `watchlist` table
|
||||
|
||||
All posted deals and the first-run flag carry over — no duplicate posts on switchover.
|
||||
|
||||
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=
|
||||
115
internal/config/config.go
Normal file
115
internal/config/config.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
MatrixHomeserverURL string
|
||||
MatrixBotUserID string
|
||||
MatrixBotAccessToken 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.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
|
||||
}
|
||||
105
internal/database/database.go
Normal file
105
internal/database/database.go
Normal file
@@ -0,0 +1,105 @@
|
||||
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+"?_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
123
internal/deals/cheapshark.go
Normal file
123
internal/deals/cheapshark.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package deals
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"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) {
|
||||
url := 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(url)
|
||||
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, _ := strconv.ParseFloat(d.SalePrice, 64)
|
||||
normal, _ := strconv.ParseFloat(d.NormalPrice, 64)
|
||||
savings, _ := strconv.ParseFloat(d.Savings, 64)
|
||||
rating, _ := strconv.ParseFloat(d.DealRating, 64)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
147
internal/deals/epic.go
Normal file
147
internal/deals/epic.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package deals
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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
|
||||
}
|
||||
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, _ := time.Parse(time.RFC3339, offer.StartDate)
|
||||
end, _ := time.Parse(time.RFC3339, offer.EndDate)
|
||||
|
||||
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-%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-%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
|
||||
}
|
||||
232
internal/deals/itad.go
Normal file
232
internal/deals/itad.go
Normal file
@@ -0,0 +1,232 @@
|
||||
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
|
||||
}
|
||||
|
||||
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, _ := json.Marshal(gameIDs)
|
||||
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()
|
||||
|
||||
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()
|
||||
}
|
||||
263
internal/matrix/client.go
Normal file
263
internal/matrix/client.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *mautrix.Client
|
||||
crypto *cryptohelper.CryptoHelper
|
||||
cancel context.CancelFunc
|
||||
syncCancel context.CancelFunc
|
||||
hasE2EE bool
|
||||
startTime time.Time
|
||||
|
||||
dmMu sync.Mutex
|
||||
dmCache map[id.UserID]id.RoomID // userID -> DM room
|
||||
}
|
||||
|
||||
// New creates a new Matrix client with E2EE support via cryptohelper.
|
||||
// The cryptoDBPath is the path to the SQLite database for the crypto store
|
||||
// (e.g. "crypto.db"). Call RegisterMessageHandler before StartSync to
|
||||
// receive DM events.
|
||||
func New(homeserverURL, userID, accessToken, cryptoDBPath string) (*Client, error) {
|
||||
uid := id.UserID(userID)
|
||||
client, err := mautrix.NewClient(homeserverURL, uid, accessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create matrix client: %w", err)
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
client: client,
|
||||
dmCache: make(map[id.UserID]id.RoomID),
|
||||
startTime: time.Now(),
|
||||
}
|
||||
|
||||
if cryptoDBPath != "" {
|
||||
ch, err := cryptohelper.NewCryptoHelper(client, []byte("pastel_pickle_key"), cryptoDBPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init crypto helper: %w", err)
|
||||
}
|
||||
|
||||
if err := ch.Init(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("crypto helper init: %w", err)
|
||||
}
|
||||
|
||||
client.Crypto = ch
|
||||
c.crypto = ch
|
||||
|
||||
slog.Info("E2EE initialized", "device_id", client.DeviceID)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Ignore own messages
|
||||
if evt.Sender == c.client.UserID {
|
||||
return
|
||||
}
|
||||
// Ignore messages from before bot startup (history replay)
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Checks the in-memory cache first, then m.direct account data, and only creates
|
||||
// a new encrypted room as a last resort. Follows gogobee's pattern to avoid
|
||||
// opening duplicate DM rooms against the same user.
|
||||
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()
|
||||
|
||||
// Check m.direct account data for existing DM rooms
|
||||
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] // use most recent
|
||||
c.dmMu.Lock()
|
||||
c.dmCache[userID] = roomID
|
||||
c.dmMu.Unlock()
|
||||
return roomID, nil
|
||||
}
|
||||
}
|
||||
|
||||
// No existing DM room — create an encrypted one
|
||||
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()
|
||||
|
||||
// Send immediately
|
||||
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()
|
||||
}
|
||||
}
|
||||
160
internal/preflight/preflight.go
Normal file
160
internal/preflight/preflight.go
Normal file
@@ -0,0 +1,160 @@
|
||||
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(cfg.MatrixHomeserverURL, cfg.MatrixBotUserID, 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"}
|
||||
}
|
||||
163
internal/watchlist/commands.go
Normal file
163
internal/watchlist/commands.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// NewCommandHandler creates a new command handler.
|
||||
func NewCommandHandler(store *Store, sender DMSender) *CommandHandler {
|
||||
return &CommandHandler{store: store, sender: sender}
|
||||
}
|
||||
|
||||
// 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 "!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, gameName string) {
|
||||
if gameName == "" {
|
||||
h.reply(userID, "Usage: !unwatch <game name>")
|
||||
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, gameName string) {
|
||||
if gameName == "" {
|
||||
h.reply(userID, "Usage: !extend <game name>")
|
||||
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 _, e := range entries {
|
||||
sb.WriteString(fmt.Sprintf(" - %s (expires %s)\n", e.GameName, e.ExpiresAt.Format("January 2, 2006")))
|
||||
}
|
||||
sb.WriteString("\nCommands: !watch, !unwatch, !extend, !watchlist")
|
||||
|
||||
h.reply(userID, sb.String())
|
||||
}
|
||||
|
||||
func (h *CommandHandler) handleHelp(userID id.UserID) {
|
||||
h.reply(userID, "Watchlist commands:\n"+
|
||||
" !watch <game name> — Watch for deals on a game\n"+
|
||||
" !unwatch <game name> — Remove a game from your watchlist\n"+
|
||||
" !extend <game name> — Reset the 180-day expiry timer\n"+
|
||||
" !watchlist — Show your current watches\n"+
|
||||
" !help — Show this message\n\n"+
|
||||
"Watches expire after 180 days. You'll get a reminder 7 days before.")
|
||||
}
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user