diff --git a/.gitignore b/.gitignore index 7a60b85..3ca8039 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ __pycache__/ *.pyc +.env +*.db +*.db.bak +pastel +migrate diff --git a/README.md b/README.md index 060e1a1..1d5cb83 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pastel -A Matrix bot that posts gaming deals and free game alerts to a specified Matrix room. +A Matrix bot that posts gaming deals and free game alerts to a specified Matrix room, with a personal watchlist feature via DMs. Deals are sourced from PC/digital storefronts only (Steam, GOG, Humble Store, GreenManGaming, Epic Games Store) — universally accessible regardless of region. @@ -15,19 +15,39 @@ CheapShark and IsThereAnyDeal can be used individually or together — configure ## Quick Start 1. Copy `.env.example` to `.env` and fill in your Matrix credentials and room ID -2. Run with Docker: +2. Build and run with Go 1.25+: + +```bash +go build -tags goolm -o pastel ./cmd/pastel +./pastel +``` + +Or run with Docker: ```bash docker build -t pastel . docker run --env-file .env -v pastel-data:/data pastel ``` -Or run directly with Python 3.12+: +## Watchlist -```bash -pip install -r requirements.txt -python -m gaming_deals_bot -``` +Users can DM the bot to set up personal deal alerts. When a matching deal appears, the bot sends a DM notification. + +| Command | Description | +|---|---| +| `!search ` | Search for current deals on a game | +| `!watch ` | 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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26aaa23 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b713556 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..447d8a6 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/currency/currency.go b/internal/currency/currency.go new file mode 100644 index 0000000..56a0ca8 --- /dev/null +++ b/internal/currency/currency.go @@ -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 +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..037c14c --- /dev/null +++ b/internal/database/database.go @@ -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 +} diff --git a/internal/deals/cheapshark.go b/internal/deals/cheapshark.go new file mode 100644 index 0000000..d236c3c --- /dev/null +++ b/internal/deals/cheapshark.go @@ -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 +} diff --git a/internal/deals/epic.go b/internal/deals/epic.go new file mode 100644 index 0000000..f2b9e58 --- /dev/null +++ b/internal/deals/epic.go @@ -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 +} diff --git a/internal/deals/itad.go b/internal/deals/itad.go new file mode 100644 index 0000000..fdc199b --- /dev/null +++ b/internal/deals/itad.go @@ -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 +} diff --git a/internal/formatter/formatter.go b/internal/formatter/formatter.go new file mode 100644 index 0000000..d69c3dc --- /dev/null +++ b/internal/formatter/formatter.go @@ -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("🎮 [DEAL] %s
\n", html.EscapeString(d.Title))) + htmlB.WriteString(fmt.Sprintf("%d%% off on %s %s
\n", + discount, html.EscapeString(d.StoreName), html.EscapeString(normalMulti))) + htmlB.WriteString(fmt.Sprintf("💰 %s
\n", html.EscapeString(saleMulti))) + + if d.IsHistLow { + plain.WriteString(" 🏆 All-time low!\n") + htmlB.WriteString("🏆 All-time low!
\n") + } + + plain.WriteString(fmt.Sprintf(" 🔗 %s", d.DealURL)) + htmlB.WriteString(fmt.Sprintf("🔗 View Deal", 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("🎮 [DEAL] %s
\n", html.EscapeString(d.Title))) + htmlB.WriteString(fmt.Sprintf("%d%% off on %s %s
\n", + d.Discount, html.EscapeString(d.ShopName), html.EscapeString(normalMulti))) + htmlB.WriteString(fmt.Sprintf("💰 %s
\n", html.EscapeString(saleMulti))) + + if d.IsHistLow { + plain.WriteString(" 🏆 All-time low!\n") + htmlB.WriteString("🏆 All-time low!
\n") + } + + plain.WriteString(fmt.Sprintf(" 🔗 %s", d.URL)) + htmlB.WriteString(fmt.Sprintf("🔗 View Deal", 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("📢 [UPCOMING FREE] %s
\n", html.EscapeString(g.Title))) + htmlB.WriteString("Coming soon — Free on Epic Games Store
\n") + } else { + plain.WriteString(fmt.Sprintf("🆓 [FREE] %s\n", g.Title)) + plain.WriteString(" Free on Epic Games Store\n") + + htmlB.WriteString(fmt.Sprintf("🆓 [FREE] %s
\n", html.EscapeString(g.Title))) + htmlB.WriteString("Free on Epic Games Store
\n") + } + + if g.EndDate != nil { + dateStr := g.EndDate.Format("January 2") + plain.WriteString(fmt.Sprintf(" 📅 Free until %s\n", dateStr)) + htmlB.WriteString(fmt.Sprintf("📅 Free until %s
\n", html.EscapeString(dateStr))) + } + + linkText := "Claim Now" + if g.Upcoming { + linkText = "Store Page" + } + + plain.WriteString(fmt.Sprintf(" 🔗 %s", g.URL)) + htmlB.WriteString(fmt.Sprintf("🔗 %s", 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() +} diff --git a/internal/matrix/client.go b/internal/matrix/client.go new file mode 100644 index 0000000..5c5c146 --- /dev/null +++ b/internal/matrix/client.go @@ -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() + } +} diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go new file mode 100644 index 0000000..8c3af03 --- /dev/null +++ b/internal/preflight/preflight.go @@ -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"} +} diff --git a/internal/watchlist/commands.go b/internal/watchlist/commands.go new file mode 100644 index 0000000..ad21936 --- /dev/null +++ b/internal/watchlist/commands.go @@ -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 ") + 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 ") + 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 ") + 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 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 — Watch for deals on a game\n"+ + " !unwatch — Remove a game from your watchlist\n"+ + " !extend — 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) + } +} diff --git a/internal/watchlist/watchlist.go b/internal/watchlist/watchlist.go new file mode 100644 index 0000000..7408ee8 --- /dev/null +++ b/internal/watchlist/watchlist.go @@ -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 +} diff --git a/pastel.service b/pastel.service new file mode 100644 index 0000000..030ee74 --- /dev/null +++ b/pastel.service @@ -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