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__/
*.pyc
.env
*.db
*.db.bak
pastel
migrate

119
README.md
View File

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

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