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:
prosolis
2026-03-17 18:26:58 -07:00
parent 24fc9240fe
commit b8a0fd5f35
16 changed files with 1961 additions and 33 deletions

5
.gitignore vendored
View File

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

119
README.md
View File

@@ -1,6 +1,6 @@
# 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.
@@ -15,19 +15,39 @@ CheapShark and IsThereAnyDeal can be used individually or together — configure
## 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
@@ -62,37 +82,51 @@ All configuration is via environment variables (see `.env.example`):
| `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 (required when `itad` is in `DEAL_SOURCES`, optional otherwise 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` | | `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`) | | `MIN_DEAL_RATING` | No | 8.0 | Minimum CheapShark deal rating (0-10) |
| `DEFAULT_CURRENCY` | No | USD | Primary display currency shown first in price strings | | `MIN_DISCOUNT_PERCENT` | No | 50 | Minimum discount percentage |
| `EXTRA_CURRENCIES` | No | CAD,EUR,GBP | Additional currencies shown after the default (comma-separated) | | `MAX_PRICE_USD` | No | 20 | Maximum sale price in USD |
| `MATRIX_USE_THREADS` | No | false | Post deals into per-category threads (see Threads section below) |
| `SEND_INTRO_MESSAGE` | No | false | Send "The deals must flow." to the room on startup | | `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 |
### 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 | A service file is included for systemd deployments:
|---|---|---|---|
| `CHEAPSHARK_MIN_DISCOUNT` | CheapShark | 50 | Minimum discount percentage | ```bash
| `CHEAPSHARK_MIN_RATING` | CheapShark | 8.0 | Minimum deal rating (0-10, 0 = unrated allowed) | # Build and install
| `CHEAPSHARK_MAX_PRICE` | CheapShark | 20 | Maximum sale price (USD) | go build -tags goolm -o pastel ./cmd/pastel
| `ITAD_MIN_DISCOUNT` | ITAD | 50 | Minimum discount percentage | sudo mkdir -p /opt/pastel
| `ITAD_MAX_PRICE` | ITAD | 20 | Maximum sale price (USD, prices from other regions are converted) | sudo cp pastel /opt/pastel/
| `ITAD_DEALS_LIMIT` | ITAD | 200 | Number of deals to fetch per country (max 200) | sudo cp .env /opt/pastel/
| `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 | # 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:
@@ -107,24 +141,43 @@ The command exits with code 0 on success and 1 on failure, so it works in CI and
## Threads ## 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 | | Thread | Content |
|---|---| |---|---|
| 🎮 Game Deals | CheapShark deals + ITAD deals with type `game` | | Game Deals | CheapShark deals + ITAD deals with type `game` |
| 🧩 DLC Deals | ITAD deals with type `dlc` | | DLC Deals | ITAD deals with type `dlc` |
| 🆓 Epic Free Games | Current and upcoming free games from the Epic Games Store | | 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.) |
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. 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 ## 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)
- **Deduplication**: deals are tracked by game ID + timestamp; duplicates are never reposted - **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 - **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 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-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-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. - **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
View File

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

112
go.sum Normal file
View File

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

115
internal/config/config.go Normal file
View 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
}

View File

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

View File

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

View 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
View 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
View 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
}

View File

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

263
internal/matrix/client.go Normal file
View 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()
}
}

View 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"}
}

View 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)
}
}

View File

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

22
pastel.service Normal file
View File

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