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:
115
internal/config/config.go
Normal file
115
internal/config/config.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
MatrixHomeserverURL string
|
||||
MatrixBotUserID string
|
||||
MatrixBotAccessToken string
|
||||
MatrixDealsRoomID string
|
||||
ITADAPIKey string
|
||||
DealSources []string
|
||||
MinDealRating float64
|
||||
MinDiscountPercent int
|
||||
MaxPriceUSD float64
|
||||
SendIntroMessage bool
|
||||
DatabasePath string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
// Load .env file if present (ignore error if missing)
|
||||
_ = godotenv.Load()
|
||||
|
||||
c := &Config{}
|
||||
var err error
|
||||
|
||||
c.MatrixHomeserverURL, err = require("MATRIX_HOMESERVER_URL")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.MatrixBotUserID, err = require("MATRIX_BOT_USER_ID")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.MatrixBotAccessToken, err = require("MATRIX_BOT_ACCESS_TOKEN")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.MatrixDealsRoomID, err = require("MATRIX_DEALS_ROOM_ID")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.ITADAPIKey = os.Getenv("ITAD_API_KEY")
|
||||
|
||||
rawSources := os.Getenv("DEAL_SOURCES")
|
||||
if rawSources == "" {
|
||||
rawSources = "cheapshark"
|
||||
}
|
||||
for _, s := range strings.Split(rawSources, ",") {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
if s != "" {
|
||||
c.DealSources = append(c.DealSources, s)
|
||||
}
|
||||
}
|
||||
|
||||
c.MinDealRating = envFloat("MIN_DEAL_RATING", 8.0)
|
||||
c.MinDiscountPercent = envInt("MIN_DISCOUNT_PERCENT", 50)
|
||||
c.MaxPriceUSD = envFloat("MAX_PRICE_USD", 20)
|
||||
c.DatabasePath = envStr("DATABASE_PATH", "deals.db")
|
||||
|
||||
intro := strings.ToLower(os.Getenv("SEND_INTRO_MESSAGE"))
|
||||
c.SendIntroMessage = intro == "true" || intro == "1" || intro == "yes"
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// HasSource checks if a deal source is enabled.
|
||||
func (c *Config) HasSource(name string) bool {
|
||||
for _, s := range c.DealSources {
|
||||
if s == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func require(key string) (string, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("required environment variable %s is not set", key)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func envStr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envFloat(key string, def float64) float64 {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if i, err := strconv.Atoi(v); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
96
internal/currency/currency.go
Normal file
96
internal/currency/currency.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package currency
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
apiURL = "https://api.frankfurter.dev/v1/latest?base=USD&symbols=CAD,EUR,GBP"
|
||||
cacheTTL = 12 * time.Hour
|
||||
)
|
||||
|
||||
var symbols = map[string]string{
|
||||
"USD": "$",
|
||||
"CAD": "C$",
|
||||
"EUR": "€",
|
||||
"GBP": "£",
|
||||
}
|
||||
|
||||
var currencies = []string{"CAD", "EUR", "GBP"}
|
||||
|
||||
type Converter struct {
|
||||
mu sync.Mutex
|
||||
rates map[string]float64
|
||||
lastFetched time.Time
|
||||
}
|
||||
|
||||
func NewConverter() *Converter {
|
||||
return &Converter{}
|
||||
}
|
||||
|
||||
func (c *Converter) fetchRates() error {
|
||||
resp, err := http.Get(apiURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("frankfurter API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Rates map[string]float64 `json:"rates"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.rates = result.Rates
|
||||
c.lastFetched = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureRates fetches exchange rates if the cache is stale or empty.
|
||||
func (c *Converter) EnsureRates() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if time.Since(c.lastFetched) < cacheTTL && c.rates != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.fetchRates(); err != nil {
|
||||
slog.Warn("failed to fetch exchange rates, using USD only", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// FormatPrice converts a USD amount to multi-currency display string.
|
||||
func (c *Converter) FormatPrice(usd float64) string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
parts := []string{fmt.Sprintf("$%.2f", usd)}
|
||||
|
||||
for _, cur := range currencies {
|
||||
if rate, ok := c.rates[cur]; ok {
|
||||
sym := symbols[cur]
|
||||
parts = append(parts, fmt.Sprintf("%s%.2f", sym, usd*rate))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// HasRates returns true if exchange rates are available.
|
||||
func (c *Converter) HasRates() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.rates != nil && len(c.rates) > 0
|
||||
}
|
||||
105
internal/database/database.go
Normal file
105
internal/database/database.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func Open(path string) (*DB, error) {
|
||||
db, err := sqlx.Open("sqlite", path+"?_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &DB{db: db}
|
||||
if err := d.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *DB) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
// RawDB exposes the underlying sqlx.DB for packages that need direct access.
|
||||
func (d *DB) RawDB() *sqlx.DB {
|
||||
return d.db
|
||||
}
|
||||
|
||||
func (d *DB) migrate() error {
|
||||
_, err := d.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS posted_deals (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS watchlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
game_name TEXT NOT NULL,
|
||||
game_name_normalized TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
expiry_warned INTEGER DEFAULT 0,
|
||||
UNIQUE(user_id, game_name_normalized)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_watchlist_normalized ON watchlist(game_name_normalized);
|
||||
CREATE INDEX IF NOT EXISTS idx_watchlist_expires ON watchlist(expires_at);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsPosted checks if a deal has already been posted.
|
||||
func (d *DB) IsPosted(id string) (bool, error) {
|
||||
var count int
|
||||
err := d.db.Get(&count, "SELECT COUNT(1) FROM posted_deals WHERE id = ?", id)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// MarkPosted records a deal as posted.
|
||||
func (d *DB) MarkPosted(id, source, title string) error {
|
||||
_, err := d.db.Exec(
|
||||
"INSERT OR IGNORE INTO posted_deals (id, source, title) VALUES (?, ?, ?)",
|
||||
id, source, title,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsFirstRunDone checks if the initial population has been completed.
|
||||
func (d *DB) IsFirstRunDone() (bool, error) {
|
||||
var val string
|
||||
err := d.db.Get(&val, "SELECT value FROM config WHERE key = 'first_run_done'")
|
||||
if err != nil {
|
||||
return false, nil // not found = not done
|
||||
}
|
||||
return val == "true", nil
|
||||
}
|
||||
|
||||
// SetFirstRunDone marks the initial population as complete.
|
||||
func (d *DB) SetFirstRunDone() error {
|
||||
_, err := d.db.Exec(
|
||||
"INSERT OR REPLACE INTO config (key, value) VALUES ('first_run_done', 'true')",
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// PruneOldDeals removes deals older than the given number of days.
|
||||
func (d *DB) PruneOldDeals(days int) error {
|
||||
cutoff := time.Now().AddDate(0, 0, -days).UTC().Format(time.RFC3339)
|
||||
_, err := d.db.Exec("DELETE FROM posted_deals WHERE posted_at < ?", cutoff)
|
||||
return err
|
||||
}
|
||||
123
internal/deals/cheapshark.go
Normal file
123
internal/deals/cheapshark.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package deals
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var storeNames = map[string]string{
|
||||
"1": "Steam",
|
||||
"7": "GOG",
|
||||
"11": "Humble Store",
|
||||
"23": "GreenManGaming",
|
||||
}
|
||||
|
||||
type CheapSharkDeal struct {
|
||||
DealID string
|
||||
GameID string
|
||||
Title string
|
||||
SalePrice float64
|
||||
NormalPrice float64
|
||||
Savings float64
|
||||
DealRating float64
|
||||
StoreID string
|
||||
StoreName string
|
||||
LastChange int64
|
||||
SteamAppID string
|
||||
DealURL string
|
||||
IsHistLow bool
|
||||
DedupID string
|
||||
}
|
||||
|
||||
type cheapSharkRaw struct {
|
||||
DealID string `json:"dealID"`
|
||||
GameID string `json:"gameID"`
|
||||
Title string `json:"title"`
|
||||
SalePrice string `json:"salePrice"`
|
||||
NormalPrice string `json:"normalPrice"`
|
||||
Savings string `json:"savings"`
|
||||
DealRating string `json:"dealRating"`
|
||||
StoreID string `json:"storeID"`
|
||||
LastChange int64 `json:"lastChange"`
|
||||
SteamAppID string `json:"steamAppID"`
|
||||
}
|
||||
|
||||
// FetchCheapSharkDeals fetches deals from CheapShark API.
|
||||
func FetchCheapSharkDeals(maxPrice float64, pageSize int) ([]CheapSharkDeal, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://www.cheapshark.com/api/1.0/deals?storeID=1,7,11,23&upperPrice=%d&sortBy=recent&desc=1&pageSize=%d",
|
||||
int(maxPrice), pageSize,
|
||||
)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cheapshark request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("cheapshark returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw []cheapSharkRaw
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("cheapshark decode failed: %w", err)
|
||||
}
|
||||
|
||||
var deals []CheapSharkDeal
|
||||
for _, d := range raw {
|
||||
sale, _ := strconv.ParseFloat(d.SalePrice, 64)
|
||||
normal, _ := strconv.ParseFloat(d.NormalPrice, 64)
|
||||
savings, _ := strconv.ParseFloat(d.Savings, 64)
|
||||
rating, _ := strconv.ParseFloat(d.DealRating, 64)
|
||||
|
||||
storeName := storeNames[d.StoreID]
|
||||
if storeName == "" {
|
||||
storeName = "Unknown"
|
||||
}
|
||||
|
||||
deals = append(deals, CheapSharkDeal{
|
||||
DealID: d.DealID,
|
||||
GameID: d.GameID,
|
||||
Title: d.Title,
|
||||
SalePrice: sale,
|
||||
NormalPrice: normal,
|
||||
Savings: savings,
|
||||
DealRating: rating,
|
||||
StoreID: d.StoreID,
|
||||
StoreName: storeName,
|
||||
LastChange: d.LastChange,
|
||||
SteamAppID: d.SteamAppID,
|
||||
DealURL: fmt.Sprintf("https://www.cheapshark.com/redirect?dealID=%s", d.DealID),
|
||||
DedupID: fmt.Sprintf("cheapshark-%s-%d", d.GameID, d.LastChange),
|
||||
})
|
||||
}
|
||||
|
||||
return deals, nil
|
||||
}
|
||||
|
||||
// FilterCheapSharkDeals filters deals by rating, discount, and price thresholds.
|
||||
func FilterCheapSharkDeals(deals []CheapSharkDeal, minRating float64, minDiscount int, maxPrice float64) []CheapSharkDeal {
|
||||
var filtered []CheapSharkDeal
|
||||
for _, d := range deals {
|
||||
discount := int(math.Floor(d.Savings))
|
||||
if discount < minDiscount {
|
||||
slog.Debug("cheapshark: skipping (low discount)", "title", d.Title, "discount", discount)
|
||||
continue
|
||||
}
|
||||
if d.DealRating < minRating && d.DealRating != 0 {
|
||||
slog.Debug("cheapshark: skipping (low rating)", "title", d.Title, "rating", d.DealRating)
|
||||
continue
|
||||
}
|
||||
if d.SalePrice > maxPrice {
|
||||
slog.Debug("cheapshark: skipping (too expensive)", "title", d.Title, "price", d.SalePrice)
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
147
internal/deals/epic.go
Normal file
147
internal/deals/epic.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package deals
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EpicFreeGame struct {
|
||||
ID string
|
||||
Title string
|
||||
Desc string
|
||||
URL string
|
||||
EndDate *time.Time
|
||||
Upcoming bool
|
||||
DedupID string
|
||||
}
|
||||
|
||||
type epicResponse struct {
|
||||
Data struct {
|
||||
Catalog struct {
|
||||
SearchStore struct {
|
||||
Elements []epicElement `json:"elements"`
|
||||
} `json:"searchStore"`
|
||||
} `json:"Catalog"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type epicElement struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ProductSlug string `json:"productSlug"`
|
||||
URLSlug string `json:"urlSlug"`
|
||||
CatalogNs struct {
|
||||
Mappings []struct {
|
||||
PageSlug string `json:"pageSlug"`
|
||||
} `json:"mappings"`
|
||||
} `json:"catalogNs"`
|
||||
Promotions *struct {
|
||||
PromotionalOffers []epicOfferGroup `json:"promotionalOffers"`
|
||||
UpcomingPromotionalOffers []epicOfferGroup `json:"upcomingPromotionalOffers"`
|
||||
} `json:"promotions"`
|
||||
}
|
||||
|
||||
type epicOfferGroup struct {
|
||||
PromotionalOffers []epicOffer `json:"promotionalOffers"`
|
||||
}
|
||||
|
||||
type epicOffer struct {
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
DiscountSetting struct {
|
||||
DiscountPercentage int `json:"discountPercentage"`
|
||||
} `json:"discountSetting"`
|
||||
}
|
||||
|
||||
// FetchEpicFreeGames fetches current and upcoming free games from Epic Games Store.
|
||||
func FetchEpicFreeGames() ([]EpicFreeGame, error) {
|
||||
resp, err := http.Get("https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions?locale=en-US")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("epic request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("epic returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result epicResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("epic decode failed: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var games []EpicFreeGame
|
||||
|
||||
for _, elem := range result.Data.Catalog.SearchStore.Elements {
|
||||
if elem.Promotions == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
slug := elem.ProductSlug
|
||||
if slug == "" {
|
||||
slug = elem.URLSlug
|
||||
}
|
||||
if slug == "" && len(elem.CatalogNs.Mappings) > 0 {
|
||||
slug = elem.CatalogNs.Mappings[0].PageSlug
|
||||
}
|
||||
storeURL := fmt.Sprintf("https://store.epicgames.com/en-US/p/%s", slug)
|
||||
|
||||
// Check current free offers
|
||||
for _, group := range elem.Promotions.PromotionalOffers {
|
||||
for _, offer := range group.PromotionalOffers {
|
||||
if offer.DiscountSetting.DiscountPercentage != 0 {
|
||||
continue
|
||||
}
|
||||
start, _ := time.Parse(time.RFC3339, offer.StartDate)
|
||||
end, _ := time.Parse(time.RFC3339, offer.EndDate)
|
||||
|
||||
if start.After(now) || end.Before(now) {
|
||||
continue
|
||||
}
|
||||
|
||||
game := EpicFreeGame{
|
||||
ID: elem.ID,
|
||||
Title: elem.Title,
|
||||
Desc: elem.Description,
|
||||
URL: storeURL,
|
||||
Upcoming: false,
|
||||
DedupID: fmt.Sprintf("epic-%s", elem.ID),
|
||||
}
|
||||
if !end.IsZero() {
|
||||
game.EndDate = &end
|
||||
}
|
||||
games = append(games, game)
|
||||
}
|
||||
}
|
||||
|
||||
// Check upcoming free offers
|
||||
for _, group := range elem.Promotions.UpcomingPromotionalOffers {
|
||||
for _, offer := range group.PromotionalOffers {
|
||||
if offer.DiscountSetting.DiscountPercentage != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
game := EpicFreeGame{
|
||||
ID: elem.ID,
|
||||
Title: elem.Title,
|
||||
Desc: elem.Description,
|
||||
URL: storeURL,
|
||||
Upcoming: true,
|
||||
DedupID: fmt.Sprintf("epic-%s", elem.ID),
|
||||
}
|
||||
if offer.EndDate != "" {
|
||||
if end, err := time.Parse(time.RFC3339, offer.EndDate); err == nil {
|
||||
game.EndDate = &end
|
||||
}
|
||||
}
|
||||
games = append(games, game)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return games, nil
|
||||
}
|
||||
232
internal/deals/itad.go
Normal file
232
internal/deals/itad.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package deals
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ITADDeal struct {
|
||||
GameID string
|
||||
Slug string
|
||||
Title string
|
||||
Type string
|
||||
Discount int
|
||||
Price float64
|
||||
Regular float64
|
||||
Currency string
|
||||
ShopName string
|
||||
ShopID int
|
||||
URL string
|
||||
Flag string // "H" = historical low, "N" = new low
|
||||
Timestamp time.Time
|
||||
Expiry *time.Time
|
||||
DedupID string
|
||||
IsHistLow bool
|
||||
}
|
||||
|
||||
type itadResponse struct {
|
||||
List []itadEntry `json:"list"`
|
||||
}
|
||||
|
||||
type itadEntry struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Deal struct {
|
||||
Shop struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"shop"`
|
||||
Price struct {
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
} `json:"price"`
|
||||
Regular struct {
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
} `json:"regular"`
|
||||
Cut int `json:"cut"`
|
||||
URL string `json:"url"`
|
||||
Flag string `json:"flag"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Expiry *string `json:"expiry"`
|
||||
} `json:"deal"`
|
||||
}
|
||||
|
||||
// FetchITADDeals fetches deals from ITAD API v2.
|
||||
func FetchITADDeals(apiKey string, limit int) ([]ITADDeal, error) {
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"https://api.isthereanydeal.com/deals/v2?key=%s&sort=-cut&limit=%d&nondeals=false",
|
||||
apiKey, limit,
|
||||
)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("itad request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("itad returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result itadResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("itad decode failed: %w", err)
|
||||
}
|
||||
|
||||
var deals []ITADDeal
|
||||
for _, e := range result.List {
|
||||
// Only include games and DLC
|
||||
t := strings.ToLower(e.Type)
|
||||
if t != "game" && t != "dlc" {
|
||||
continue
|
||||
}
|
||||
|
||||
deal := ITADDeal{
|
||||
GameID: e.ID,
|
||||
Slug: e.Slug,
|
||||
Title: e.Title,
|
||||
Type: e.Type,
|
||||
Discount: e.Deal.Cut,
|
||||
Price: e.Deal.Price.Amount,
|
||||
Regular: e.Deal.Regular.Amount,
|
||||
Currency: e.Deal.Price.Currency,
|
||||
ShopName: e.Deal.Shop.Name,
|
||||
ShopID: e.Deal.Shop.ID,
|
||||
URL: e.Deal.URL,
|
||||
Flag: e.Deal.Flag,
|
||||
IsHistLow: e.Deal.Flag == "H" || e.Deal.Flag == "N",
|
||||
DedupID: fmt.Sprintf("itad-%s-%d-%d", e.ID, e.Deal.Shop.ID, e.Deal.Cut),
|
||||
}
|
||||
|
||||
if ts, err := time.Parse(time.RFC3339, e.Deal.Timestamp); err == nil {
|
||||
deal.Timestamp = ts
|
||||
}
|
||||
if e.Deal.Expiry != nil {
|
||||
if exp, err := time.Parse(time.RFC3339, *e.Deal.Expiry); err == nil {
|
||||
deal.Expiry = &exp
|
||||
}
|
||||
}
|
||||
|
||||
deals = append(deals, deal)
|
||||
}
|
||||
|
||||
return deals, nil
|
||||
}
|
||||
|
||||
// FilterITADDeals filters ITAD deals by discount and price thresholds.
|
||||
func FilterITADDeals(deals []ITADDeal, minDiscount int, maxPrice float64) []ITADDeal {
|
||||
var filtered []ITADDeal
|
||||
for _, d := range deals {
|
||||
if d.Discount < minDiscount {
|
||||
slog.Debug("itad: skipping (low discount)", "title", d.Title, "discount", d.Discount)
|
||||
continue
|
||||
}
|
||||
if d.Price > maxPrice {
|
||||
slog.Debug("itad: skipping (too expensive)", "title", d.Title, "price", d.Price)
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// LookupHistoricalLows checks if games are at their historical low price via ITAD.
|
||||
// Takes a map of steamAppID -> deal index, returns map of steamAppID -> isHistoricalLow.
|
||||
func LookupHistoricalLows(apiKey string, steamAppIDs []string) (map[string]bool, error) {
|
||||
result := make(map[string]bool)
|
||||
if apiKey == "" || len(steamAppIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Step 1: Look up ITAD game IDs from Steam app IDs
|
||||
itadIDs := make(map[string]string) // steamAppID -> itadID
|
||||
for _, appID := range steamAppIDs {
|
||||
if appID == "" || appID == "0" {
|
||||
continue
|
||||
}
|
||||
url := fmt.Sprintf(
|
||||
"https://api.isthereanydeal.com/games/lookup/v1?key=%s&appid=%s",
|
||||
apiKey, appID,
|
||||
)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
slog.Warn("itad lookup failed", "appid", appID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
var lookup struct {
|
||||
Found bool `json:"found"`
|
||||
Game struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"game"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&lookup); err != nil {
|
||||
resp.Body.Close()
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if lookup.Found {
|
||||
itadIDs[appID] = lookup.Game.ID
|
||||
}
|
||||
}
|
||||
|
||||
if len(itadIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Step 2: Get price overview for all looked-up games
|
||||
var gameIDs []string
|
||||
itadToSteam := make(map[string]string)
|
||||
for steamID, itadID := range itadIDs {
|
||||
gameIDs = append(gameIDs, itadID)
|
||||
itadToSteam[itadID] = steamID
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(gameIDs)
|
||||
url := fmt.Sprintf("https://api.isthereanydeal.com/games/overview/v2?key=%s", apiKey)
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("itad overview request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var overview struct {
|
||||
Prices []struct {
|
||||
ID string `json:"id"`
|
||||
Current struct {
|
||||
Price struct {
|
||||
Amount float64 `json:"amount"`
|
||||
} `json:"price"`
|
||||
} `json:"current"`
|
||||
Lowest struct {
|
||||
Price struct {
|
||||
Amount float64 `json:"amount"`
|
||||
} `json:"price"`
|
||||
} `json:"lowest"`
|
||||
} `json:"prices"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&overview); err != nil {
|
||||
return result, fmt.Errorf("itad overview decode failed: %w", err)
|
||||
}
|
||||
|
||||
for _, p := range overview.Prices {
|
||||
if steamID, ok := itadToSteam[p.ID]; ok {
|
||||
result[steamID] = p.Current.Price.Amount <= p.Lowest.Price.Amount
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
126
internal/formatter/formatter.go
Normal file
126
internal/formatter/formatter.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package formatter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/prosolis/Pastel/internal/currency"
|
||||
"github.com/prosolis/Pastel/internal/deals"
|
||||
)
|
||||
|
||||
// Message holds both plain-text and HTML versions of a formatted message.
|
||||
type Message struct {
|
||||
Plain string
|
||||
HTML string
|
||||
}
|
||||
|
||||
// FormatCheapSharkDeal formats a CheapShark deal for Matrix.
|
||||
func FormatCheapSharkDeal(d deals.CheapSharkDeal, conv *currency.Converter) Message {
|
||||
discount := int(math.Floor(d.Savings))
|
||||
saleMulti := conv.FormatPrice(d.SalePrice)
|
||||
normalMulti := conv.FormatPrice(d.NormalPrice)
|
||||
|
||||
var plain, htmlB strings.Builder
|
||||
|
||||
plain.WriteString(fmt.Sprintf("🎮 [DEAL] %s\n", d.Title))
|
||||
plain.WriteString(fmt.Sprintf(" %d%% off on %s (was %s)\n", discount, d.StoreName, normalMulti))
|
||||
plain.WriteString(fmt.Sprintf(" 💰 %s\n", saleMulti))
|
||||
|
||||
htmlB.WriteString(fmt.Sprintf("<strong>🎮 [DEAL] %s</strong><br>\n", html.EscapeString(d.Title)))
|
||||
htmlB.WriteString(fmt.Sprintf("%d%% off on %s <del>%s</del><br>\n",
|
||||
discount, html.EscapeString(d.StoreName), html.EscapeString(normalMulti)))
|
||||
htmlB.WriteString(fmt.Sprintf("💰 <strong>%s</strong><br>\n", html.EscapeString(saleMulti)))
|
||||
|
||||
if d.IsHistLow {
|
||||
plain.WriteString(" 🏆 All-time low!\n")
|
||||
htmlB.WriteString("🏆 <em>All-time low!</em><br>\n")
|
||||
}
|
||||
|
||||
plain.WriteString(fmt.Sprintf(" 🔗 %s", d.DealURL))
|
||||
htmlB.WriteString(fmt.Sprintf("🔗 <a href=\"%s\">View Deal</a>", html.EscapeString(d.DealURL)))
|
||||
|
||||
return Message{Plain: plain.String(), HTML: htmlB.String()}
|
||||
}
|
||||
|
||||
// FormatITADDeal formats an ITAD deal for Matrix.
|
||||
func FormatITADDeal(d deals.ITADDeal, conv *currency.Converter) Message {
|
||||
saleMulti := conv.FormatPrice(d.Price)
|
||||
normalMulti := conv.FormatPrice(d.Regular)
|
||||
|
||||
var plain, htmlB strings.Builder
|
||||
|
||||
plain.WriteString(fmt.Sprintf("🎮 [DEAL] %s\n", d.Title))
|
||||
plain.WriteString(fmt.Sprintf(" %d%% off on %s (was %s)\n", d.Discount, d.ShopName, normalMulti))
|
||||
plain.WriteString(fmt.Sprintf(" 💰 %s\n", saleMulti))
|
||||
|
||||
htmlB.WriteString(fmt.Sprintf("<strong>🎮 [DEAL] %s</strong><br>\n", html.EscapeString(d.Title)))
|
||||
htmlB.WriteString(fmt.Sprintf("%d%% off on %s <del>%s</del><br>\n",
|
||||
d.Discount, html.EscapeString(d.ShopName), html.EscapeString(normalMulti)))
|
||||
htmlB.WriteString(fmt.Sprintf("💰 <strong>%s</strong><br>\n", html.EscapeString(saleMulti)))
|
||||
|
||||
if d.IsHistLow {
|
||||
plain.WriteString(" 🏆 All-time low!\n")
|
||||
htmlB.WriteString("🏆 <em>All-time low!</em><br>\n")
|
||||
}
|
||||
|
||||
plain.WriteString(fmt.Sprintf(" 🔗 %s", d.URL))
|
||||
htmlB.WriteString(fmt.Sprintf("🔗 <a href=\"%s\">View Deal</a>", html.EscapeString(d.URL)))
|
||||
|
||||
return Message{Plain: plain.String(), HTML: htmlB.String()}
|
||||
}
|
||||
|
||||
// FormatEpicFreeGame formats an Epic free game for Matrix.
|
||||
func FormatEpicFreeGame(g deals.EpicFreeGame) Message {
|
||||
var plain, htmlB strings.Builder
|
||||
|
||||
if g.Upcoming {
|
||||
plain.WriteString(fmt.Sprintf("📢 [UPCOMING FREE] %s\n", g.Title))
|
||||
plain.WriteString(" Coming soon — Free on Epic Games Store\n")
|
||||
|
||||
htmlB.WriteString(fmt.Sprintf("<strong>📢 [UPCOMING FREE] %s</strong><br>\n", html.EscapeString(g.Title)))
|
||||
htmlB.WriteString("Coming soon — Free on Epic Games Store<br>\n")
|
||||
} else {
|
||||
plain.WriteString(fmt.Sprintf("🆓 [FREE] %s\n", g.Title))
|
||||
plain.WriteString(" Free on Epic Games Store\n")
|
||||
|
||||
htmlB.WriteString(fmt.Sprintf("<strong>🆓 [FREE] %s</strong><br>\n", html.EscapeString(g.Title)))
|
||||
htmlB.WriteString("Free on Epic Games Store<br>\n")
|
||||
}
|
||||
|
||||
if g.EndDate != nil {
|
||||
dateStr := g.EndDate.Format("January 2")
|
||||
plain.WriteString(fmt.Sprintf(" 📅 Free until %s\n", dateStr))
|
||||
htmlB.WriteString(fmt.Sprintf("📅 <em>Free until %s</em><br>\n", html.EscapeString(dateStr)))
|
||||
}
|
||||
|
||||
linkText := "Claim Now"
|
||||
if g.Upcoming {
|
||||
linkText = "Store Page"
|
||||
}
|
||||
|
||||
plain.WriteString(fmt.Sprintf(" 🔗 %s", g.URL))
|
||||
htmlB.WriteString(fmt.Sprintf("🔗 <a href=\"%s\">%s</a>", html.EscapeString(g.URL), linkText))
|
||||
|
||||
return Message{Plain: plain.String(), HTML: htmlB.String()}
|
||||
}
|
||||
|
||||
// FormatWatchlistNotification formats a DM notification for a watchlist match.
|
||||
func FormatWatchlistNotification(watchedName, dealTitle, dealURL string, price string, discount int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🔔 Deal alert for \"%s\"!\n", watchedName))
|
||||
sb.WriteString(fmt.Sprintf(" %s — %d%% off\n", dealTitle, discount))
|
||||
sb.WriteString(fmt.Sprintf(" 💰 %s\n", price))
|
||||
sb.WriteString(fmt.Sprintf(" 🔗 %s", dealURL))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatWatchlistFreeNotification formats a DM notification for a free game watchlist match.
|
||||
func FormatWatchlistFreeNotification(watchedName, dealTitle, dealURL string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🔔 Free game alert for \"%s\"!\n", watchedName))
|
||||
sb.WriteString(fmt.Sprintf(" %s — Free on Epic Games Store\n", dealTitle))
|
||||
sb.WriteString(fmt.Sprintf(" 🔗 %s", dealURL))
|
||||
return sb.String()
|
||||
}
|
||||
263
internal/matrix/client.go
Normal file
263
internal/matrix/client.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *mautrix.Client
|
||||
crypto *cryptohelper.CryptoHelper
|
||||
cancel context.CancelFunc
|
||||
syncCancel context.CancelFunc
|
||||
hasE2EE bool
|
||||
startTime time.Time
|
||||
|
||||
dmMu sync.Mutex
|
||||
dmCache map[id.UserID]id.RoomID // userID -> DM room
|
||||
}
|
||||
|
||||
// New creates a new Matrix client with E2EE support via cryptohelper.
|
||||
// The cryptoDBPath is the path to the SQLite database for the crypto store
|
||||
// (e.g. "crypto.db"). Call RegisterMessageHandler before StartSync to
|
||||
// receive DM events.
|
||||
func New(homeserverURL, userID, accessToken, cryptoDBPath string) (*Client, error) {
|
||||
uid := id.UserID(userID)
|
||||
client, err := mautrix.NewClient(homeserverURL, uid, accessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create matrix client: %w", err)
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
client: client,
|
||||
dmCache: make(map[id.UserID]id.RoomID),
|
||||
startTime: time.Now(),
|
||||
}
|
||||
|
||||
if cryptoDBPath != "" {
|
||||
ch, err := cryptohelper.NewCryptoHelper(client, []byte("pastel_pickle_key"), cryptoDBPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init crypto helper: %w", err)
|
||||
}
|
||||
|
||||
if err := ch.Init(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("crypto helper init: %w", err)
|
||||
}
|
||||
|
||||
client.Crypto = ch
|
||||
c.crypto = ch
|
||||
|
||||
slog.Info("E2EE initialized", "device_id", client.DeviceID)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// RegisterMessageHandler registers a callback for incoming messages.
|
||||
// Must be called before StartSync. Ignores the bot's own messages and
|
||||
// messages from before the bot started (avoids replaying history).
|
||||
func (c *Client) RegisterMessageHandler(fn func(senderID id.UserID, roomID id.RoomID, body string)) {
|
||||
syncer := c.client.Syncer.(*mautrix.DefaultSyncer)
|
||||
syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) {
|
||||
// Ignore own messages
|
||||
if evt.Sender == c.client.UserID {
|
||||
return
|
||||
}
|
||||
// Ignore messages from before bot startup (history replay)
|
||||
evtTime := time.UnixMilli(evt.Timestamp)
|
||||
if evtTime.Before(c.startTime) {
|
||||
return
|
||||
}
|
||||
msg := evt.Content.AsMessage()
|
||||
if msg == nil || msg.Body == "" {
|
||||
return
|
||||
}
|
||||
fn(evt.Sender, evt.RoomID, msg.Body)
|
||||
})
|
||||
}
|
||||
|
||||
// StartSync launches the background sync loop. Must be called after handler
|
||||
// registration so events are dispatched to registered callbacks.
|
||||
func (c *Client) StartSync() {
|
||||
syncCtx, syncCancel := context.WithCancel(context.Background())
|
||||
c.syncCancel = syncCancel
|
||||
go func() {
|
||||
for {
|
||||
if err := c.client.SyncWithContext(syncCtx); err != nil {
|
||||
if syncCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
slog.Warn("sync error, retrying in 10s", "error", err)
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Whoami validates the access token by calling /whoami.
|
||||
func (c *Client) Whoami() (string, error) {
|
||||
resp, err := c.client.Whoami(context.Background())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.UserID.String(), nil
|
||||
}
|
||||
|
||||
// JoinedRooms returns the list of rooms the bot has joined.
|
||||
func (c *Client) JoinedRooms() ([]string, error) {
|
||||
resp, err := c.client.JoinedRooms(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rooms []string
|
||||
for _, r := range resp.JoinedRooms {
|
||||
rooms = append(rooms, r.String())
|
||||
}
|
||||
return rooms, nil
|
||||
}
|
||||
|
||||
// SendDeal sends a formatted deal message to a room.
|
||||
func (c *Client) SendDeal(roomID, plainText, html string) error {
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: plainText,
|
||||
Format: event.FormatHTML,
|
||||
FormattedBody: html,
|
||||
}
|
||||
_, err := c.client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send deal message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendNotice sends a notice message to a room (non-highlighting).
|
||||
func (c *Client) SendNotice(roomID, text string) error {
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgNotice,
|
||||
Body: text,
|
||||
}
|
||||
_, err := c.client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send notice: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDMRoom returns the DM room for a user, reusing an existing one if available.
|
||||
// Checks the in-memory cache first, then m.direct account data, and only creates
|
||||
// a new encrypted room as a last resort. Follows gogobee's pattern to avoid
|
||||
// opening duplicate DM rooms against the same user.
|
||||
func (c *Client) GetDMRoom(userID id.UserID) (id.RoomID, error) {
|
||||
c.dmMu.Lock()
|
||||
if roomID, ok := c.dmCache[userID]; ok {
|
||||
c.dmMu.Unlock()
|
||||
return roomID, nil
|
||||
}
|
||||
c.dmMu.Unlock()
|
||||
|
||||
// Check m.direct account data for existing DM rooms
|
||||
var dmRooms map[id.UserID][]id.RoomID
|
||||
err := c.client.GetAccountData(context.Background(), "m.direct", &dmRooms)
|
||||
if err == nil {
|
||||
if rooms, ok := dmRooms[userID]; ok && len(rooms) > 0 {
|
||||
roomID := rooms[len(rooms)-1] // use most recent
|
||||
c.dmMu.Lock()
|
||||
c.dmCache[userID] = roomID
|
||||
c.dmMu.Unlock()
|
||||
return roomID, nil
|
||||
}
|
||||
}
|
||||
|
||||
// No existing DM room — create an encrypted one
|
||||
resp, err := c.client.CreateRoom(context.Background(), &mautrix.ReqCreateRoom{
|
||||
Preset: "trusted_private_chat",
|
||||
Invite: []id.UserID{userID},
|
||||
IsDirect: true,
|
||||
InitialState: []*event.Event{
|
||||
{
|
||||
Type: event.StateEncryption,
|
||||
Content: event.Content{
|
||||
Parsed: &event.EncryptionEventContent{
|
||||
Algorithm: id.AlgorithmMegolmV1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create DM room: %w", err)
|
||||
}
|
||||
|
||||
c.dmMu.Lock()
|
||||
c.dmCache[userID] = resp.RoomID
|
||||
c.dmMu.Unlock()
|
||||
|
||||
slog.Info("created DM room", "user", userID, "room", resp.RoomID)
|
||||
return resp.RoomID, nil
|
||||
}
|
||||
|
||||
// SendDM sends a direct message to a user. Reuses existing DM room if available.
|
||||
func (c *Client) SendDM(userID id.UserID, text string) error {
|
||||
roomID, err := c.GetDMRoom(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: text,
|
||||
}
|
||||
_, err = c.client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
return err
|
||||
}
|
||||
|
||||
// StartPresenceHeartbeat starts a goroutine that sends online presence every 60 seconds.
|
||||
func (c *Client) StartPresenceHeartbeat() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.cancel = cancel
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Send immediately
|
||||
c.sendPresence()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.sendPresence()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *Client) sendPresence() {
|
||||
err := c.client.SetPresence(context.Background(), mautrix.ReqPresence{Presence: event.PresenceOnline})
|
||||
if err != nil {
|
||||
slog.Warn("failed to set presence", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop cancels the presence heartbeat and sync loop.
|
||||
func (c *Client) Stop() {
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
if c.syncCancel != nil {
|
||||
c.syncCancel()
|
||||
}
|
||||
if c.crypto != nil {
|
||||
c.crypto.Close()
|
||||
}
|
||||
}
|
||||
160
internal/preflight/preflight.go
Normal file
160
internal/preflight/preflight.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/prosolis/Pastel/internal/config"
|
||||
"github.com/prosolis/Pastel/internal/matrix"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Name string
|
||||
Status string // "pass", "fail", "skip"
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Run executes all preflight checks and returns results.
|
||||
func Run(cfg *config.Config) []Result {
|
||||
var results []Result
|
||||
|
||||
results = append(results, checkMatrix(cfg))
|
||||
results = append(results, checkFrankfurter())
|
||||
|
||||
if cfg.HasSource("cheapshark") {
|
||||
results = append(results, checkCheapShark())
|
||||
} else {
|
||||
results = append(results, Result{"CheapShark", "skip", "not in DEAL_SOURCES"})
|
||||
}
|
||||
|
||||
results = append(results, checkEpic())
|
||||
|
||||
if cfg.ITADAPIKey != "" || cfg.HasSource("itad") {
|
||||
results = append(results, checkITAD(cfg))
|
||||
} else {
|
||||
results = append(results, Result{"IsThereAnyDeal", "skip", "no API key and not in DEAL_SOURCES"})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// PrintResults prints preflight results and returns true if all passed.
|
||||
func PrintResults(results []Result) bool {
|
||||
allPass := true
|
||||
for _, r := range results {
|
||||
var icon string
|
||||
switch r.Status {
|
||||
case "pass":
|
||||
icon = "✓"
|
||||
case "fail":
|
||||
icon = "✗"
|
||||
allPass = false
|
||||
case "skip":
|
||||
icon = "–"
|
||||
}
|
||||
fmt.Printf(" %s %s: %s\n", icon, r.Name, r.Detail)
|
||||
}
|
||||
return allPass
|
||||
}
|
||||
|
||||
func checkMatrix(cfg *config.Config) Result {
|
||||
client, err := matrix.New(cfg.MatrixHomeserverURL, cfg.MatrixBotUserID, cfg.MatrixBotAccessToken, "")
|
||||
if err != nil {
|
||||
return Result{"Matrix", "fail", fmt.Sprintf("client error: %v", err)}
|
||||
}
|
||||
|
||||
who, err := client.Whoami()
|
||||
if err != nil {
|
||||
return Result{"Matrix", "fail", fmt.Sprintf("whoami failed: %v", err)}
|
||||
}
|
||||
|
||||
rooms, err := client.JoinedRooms()
|
||||
if err != nil {
|
||||
return Result{"Matrix", "fail", fmt.Sprintf("joined_rooms failed: %v", err)}
|
||||
}
|
||||
|
||||
inRoom := false
|
||||
for _, r := range rooms {
|
||||
if r == cfg.MatrixDealsRoomID {
|
||||
inRoom = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !inRoom {
|
||||
return Result{"Matrix", "fail", fmt.Sprintf("bot %s is not in room %s", who, cfg.MatrixDealsRoomID)}
|
||||
}
|
||||
|
||||
return Result{"Matrix", "pass", fmt.Sprintf("authenticated as %s, in target room", who)}
|
||||
}
|
||||
|
||||
func checkCheapShark() Result {
|
||||
resp, err := http.Get("https://www.cheapshark.com/api/1.0/deals?pageSize=1")
|
||||
if err != nil {
|
||||
return Result{"CheapShark", "fail", fmt.Sprintf("request failed: %v", err)}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var deals []json.RawMessage
|
||||
if err := json.NewDecoder(resp.Body).Decode(&deals); err != nil || len(deals) == 0 {
|
||||
return Result{"CheapShark", "fail", "unexpected response format"}
|
||||
}
|
||||
|
||||
return Result{"CheapShark", "pass", "API reachable"}
|
||||
}
|
||||
|
||||
func checkEpic() Result {
|
||||
resp, err := http.Get("https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions?locale=en-US")
|
||||
if err != nil {
|
||||
return Result{"Epic Games", "fail", fmt.Sprintf("request failed: %v", err)}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Data struct {
|
||||
Catalog struct {
|
||||
SearchStore struct {
|
||||
Elements []json.RawMessage `json:"elements"`
|
||||
} `json:"searchStore"`
|
||||
} `json:"Catalog"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return Result{"Epic Games", "fail", "unexpected response format"}
|
||||
}
|
||||
|
||||
return Result{"Epic Games", "pass", fmt.Sprintf("API reachable, %d elements", len(result.Data.Catalog.SearchStore.Elements))}
|
||||
}
|
||||
|
||||
func checkFrankfurter() Result {
|
||||
resp, err := http.Get("https://api.frankfurter.dev/v1/latest?base=USD&symbols=CAD,EUR,GBP")
|
||||
if err != nil {
|
||||
return Result{"Frankfurter", "fail", fmt.Sprintf("request failed: %v", err)}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Rates map[string]float64 `json:"rates"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || len(result.Rates) == 0 {
|
||||
return Result{"Frankfurter", "fail", "no rates returned"}
|
||||
}
|
||||
|
||||
return Result{"Frankfurter", "pass", fmt.Sprintf("rates: %v", result.Rates)}
|
||||
}
|
||||
|
||||
func checkITAD(cfg *config.Config) Result {
|
||||
url := fmt.Sprintf("https://api.isthereanydeal.com/games/lookup/v1?key=%s&appid=220", cfg.ITADAPIKey)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return Result{"IsThereAnyDeal", "fail", fmt.Sprintf("request failed: %v", err)}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return Result{"IsThereAnyDeal", "fail", "invalid API key"}
|
||||
}
|
||||
|
||||
return Result{"IsThereAnyDeal", "pass", "API key valid"}
|
||||
}
|
||||
163
internal/watchlist/commands.go
Normal file
163
internal/watchlist/commands.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// DMSender is the interface for sending DMs to users.
|
||||
type DMSender interface {
|
||||
SendDM(userID id.UserID, text string) error
|
||||
}
|
||||
|
||||
// CommandHandler handles watchlist commands received via DM.
|
||||
type CommandHandler struct {
|
||||
store *Store
|
||||
sender DMSender
|
||||
}
|
||||
|
||||
// NewCommandHandler creates a new command handler.
|
||||
func NewCommandHandler(store *Store, sender DMSender) *CommandHandler {
|
||||
return &CommandHandler{store: store, sender: sender}
|
||||
}
|
||||
|
||||
// HandleMessage parses and dispatches a DM command.
|
||||
func (h *CommandHandler) HandleMessage(senderID, body string) {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var cmd, args string
|
||||
if idx := strings.IndexByte(body, ' '); idx > 0 {
|
||||
cmd = strings.ToLower(body[:idx])
|
||||
args = strings.TrimSpace(body[idx+1:])
|
||||
} else {
|
||||
cmd = strings.ToLower(body)
|
||||
}
|
||||
|
||||
uid := id.UserID(senderID)
|
||||
|
||||
switch cmd {
|
||||
case "!watch":
|
||||
h.handleWatch(uid, args)
|
||||
case "!unwatch":
|
||||
h.handleUnwatch(uid, args)
|
||||
case "!extend":
|
||||
h.handleExtend(uid, args)
|
||||
case "!watchlist":
|
||||
h.handleList(uid)
|
||||
case "!help":
|
||||
h.handleHelp(uid)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CommandHandler) handleWatch(userID id.UserID, gameName string) {
|
||||
if gameName == "" {
|
||||
h.reply(userID, "Usage: !watch <game name>")
|
||||
return
|
||||
}
|
||||
|
||||
added, err := h.store.AddWatch(string(userID), gameName)
|
||||
if err != nil {
|
||||
slog.Error("watchlist: add failed", "user", userID, "game", gameName, "error", err)
|
||||
h.reply(userID, "Something went wrong. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
if !added {
|
||||
h.reply(userID, fmt.Sprintf("You're already watching \"%s\".", gameName))
|
||||
return
|
||||
}
|
||||
|
||||
expires := time.Now().Add(watchDuration)
|
||||
h.reply(userID, fmt.Sprintf("Added \"%s\" to your watchlist. I'll DM you when a deal appears. Expires %s.",
|
||||
gameName, expires.Format("January 2, 2006")))
|
||||
}
|
||||
|
||||
func (h *CommandHandler) handleUnwatch(userID id.UserID, gameName string) {
|
||||
if gameName == "" {
|
||||
h.reply(userID, "Usage: !unwatch <game name>")
|
||||
return
|
||||
}
|
||||
|
||||
removed, err := h.store.RemoveWatch(string(userID), gameName)
|
||||
if err != nil {
|
||||
slog.Error("watchlist: remove failed", "user", userID, "game", gameName, "error", err)
|
||||
h.reply(userID, "Something went wrong. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
if !removed {
|
||||
h.reply(userID, fmt.Sprintf("No watch found for \"%s\".", gameName))
|
||||
return
|
||||
}
|
||||
|
||||
h.reply(userID, fmt.Sprintf("Removed \"%s\" from your watchlist.", gameName))
|
||||
}
|
||||
|
||||
func (h *CommandHandler) handleExtend(userID id.UserID, gameName string) {
|
||||
if gameName == "" {
|
||||
h.reply(userID, "Usage: !extend <game name>")
|
||||
return
|
||||
}
|
||||
|
||||
extended, err := h.store.ExtendWatch(string(userID), gameName)
|
||||
if err != nil {
|
||||
slog.Error("watchlist: extend failed", "user", userID, "game", gameName, "error", err)
|
||||
h.reply(userID, "Something went wrong. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
if !extended {
|
||||
h.reply(userID, fmt.Sprintf("No watch found for \"%s\".", gameName))
|
||||
return
|
||||
}
|
||||
|
||||
expires := time.Now().Add(watchDuration)
|
||||
h.reply(userID, fmt.Sprintf("Extended \"%s\" — now expires %s.",
|
||||
gameName, expires.Format("January 2, 2006")))
|
||||
}
|
||||
|
||||
func (h *CommandHandler) handleList(userID id.UserID) {
|
||||
entries, err := h.store.ListWatches(string(userID))
|
||||
if err != nil {
|
||||
slog.Error("watchlist: list failed", "user", userID, "error", err)
|
||||
h.reply(userID, "Something went wrong. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
h.reply(userID, "Your watchlist is empty. Use !watch <game name> to add one.")
|
||||
return
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Your watchlist (%d):\n", len(entries)))
|
||||
for _, e := range entries {
|
||||
sb.WriteString(fmt.Sprintf(" - %s (expires %s)\n", e.GameName, e.ExpiresAt.Format("January 2, 2006")))
|
||||
}
|
||||
sb.WriteString("\nCommands: !watch, !unwatch, !extend, !watchlist")
|
||||
|
||||
h.reply(userID, sb.String())
|
||||
}
|
||||
|
||||
func (h *CommandHandler) handleHelp(userID id.UserID) {
|
||||
h.reply(userID, "Watchlist commands:\n"+
|
||||
" !watch <game name> — Watch for deals on a game\n"+
|
||||
" !unwatch <game name> — Remove a game from your watchlist\n"+
|
||||
" !extend <game name> — Reset the 180-day expiry timer\n"+
|
||||
" !watchlist — Show your current watches\n"+
|
||||
" !help — Show this message\n\n"+
|
||||
"Watches expire after 180 days. You'll get a reminder 7 days before.")
|
||||
}
|
||||
|
||||
func (h *CommandHandler) reply(userID id.UserID, text string) {
|
||||
if err := h.sender.SendDM(userID, text); err != nil {
|
||||
slog.Error("watchlist: failed to send DM", "user", userID, "error", err)
|
||||
}
|
||||
}
|
||||
170
internal/watchlist/watchlist.go
Normal file
170
internal/watchlist/watchlist.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const watchDuration = 180 * 24 * time.Hour
|
||||
|
||||
// WatchEntry represents a single watchlist entry.
|
||||
type WatchEntry struct {
|
||||
ID int64 `db:"id"`
|
||||
UserID string `db:"user_id"`
|
||||
GameName string `db:"game_name"`
|
||||
GameNameNormalized string `db:"game_name_normalized"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
ExpiryWarned int `db:"expiry_warned"`
|
||||
}
|
||||
|
||||
// Match represents a user whose watchlist entry matched a deal.
|
||||
type Match struct {
|
||||
UserID string
|
||||
GameName string // original user-provided name for display
|
||||
}
|
||||
|
||||
// Store handles watchlist database operations.
|
||||
type Store struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewStore creates a new watchlist store.
|
||||
func NewStore(db *sqlx.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// Normalize lowercases, strips non-alphanumeric (keeping spaces), and collapses whitespace.
|
||||
func Normalize(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
// AddWatch adds a game to a user's watchlist. Returns false if already watched.
|
||||
func (s *Store) AddWatch(userID, gameName string) (bool, error) {
|
||||
normalized := Normalize(gameName)
|
||||
if normalized == "" {
|
||||
return false, nil
|
||||
}
|
||||
expiresAt := time.Now().Add(watchDuration).UTC().Format(time.RFC3339)
|
||||
result, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO watchlist (user_id, game_name, game_name_normalized, expires_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
userID, gameName, normalized, expiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
return rows > 0, nil
|
||||
}
|
||||
|
||||
// RemoveWatch removes a game from a user's watchlist. Returns false if not found.
|
||||
func (s *Store) RemoveWatch(userID, gameName string) (bool, error) {
|
||||
normalized := Normalize(gameName)
|
||||
result, err := s.db.Exec(
|
||||
"DELETE FROM watchlist WHERE user_id = ? AND game_name_normalized = ?",
|
||||
userID, normalized,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
return rows > 0, nil
|
||||
}
|
||||
|
||||
// ExtendWatch resets the expiry to 180 days from now. Returns false if not found.
|
||||
func (s *Store) ExtendWatch(userID, gameName string) (bool, error) {
|
||||
normalized := Normalize(gameName)
|
||||
expiresAt := time.Now().Add(watchDuration).UTC().Format(time.RFC3339)
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE watchlist SET expires_at = ?, expiry_warned = 0
|
||||
WHERE user_id = ? AND game_name_normalized = ?`,
|
||||
expiresAt, userID, normalized,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
return rows > 0, nil
|
||||
}
|
||||
|
||||
// ListWatches returns all active watches for a user.
|
||||
func (s *Store) ListWatches(userID string) ([]WatchEntry, error) {
|
||||
var entries []WatchEntry
|
||||
err := s.db.Select(&entries,
|
||||
`SELECT id, user_id, game_name, game_name_normalized, created_at, expires_at, expiry_warned
|
||||
FROM watchlist WHERE user_id = ? AND expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY created_at`,
|
||||
userID,
|
||||
)
|
||||
return entries, err
|
||||
}
|
||||
|
||||
// FindMatchingUsers finds all users whose watchlist entries match the given deal title.
|
||||
// Loads all active entries and checks normalized substring containment in Go.
|
||||
func (s *Store) FindMatchingUsers(dealTitle string) ([]Match, error) {
|
||||
normalizedTitle := Normalize(dealTitle)
|
||||
|
||||
var entries []WatchEntry
|
||||
err := s.db.Select(&entries,
|
||||
`SELECT user_id, game_name, game_name_normalized
|
||||
FROM watchlist WHERE expires_at > CURRENT_TIMESTAMP`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var matches []Match
|
||||
for _, e := range entries {
|
||||
if strings.Contains(normalizedTitle, e.GameNameNormalized) {
|
||||
matches = append(matches, Match{
|
||||
UserID: e.UserID,
|
||||
GameName: e.GameName,
|
||||
})
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// GetExpiringWatches returns entries expiring within the given number of days
|
||||
// that haven't been warned yet.
|
||||
func (s *Store) GetExpiringWatches(withinDays int) ([]WatchEntry, error) {
|
||||
deadline := time.Now().AddDate(0, 0, withinDays).UTC().Format(time.RFC3339)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
var entries []WatchEntry
|
||||
err := s.db.Select(&entries,
|
||||
`SELECT id, user_id, game_name, game_name_normalized, created_at, expires_at, expiry_warned
|
||||
FROM watchlist
|
||||
WHERE expires_at > ? AND expires_at <= ? AND expiry_warned = 0`,
|
||||
now, deadline,
|
||||
)
|
||||
return entries, err
|
||||
}
|
||||
|
||||
// MarkExpiryWarned sets the expiry_warned flag on an entry.
|
||||
func (s *Store) MarkExpiryWarned(id int64) error {
|
||||
_, err := s.db.Exec("UPDATE watchlist SET expiry_warned = 1 WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// PurgeExpired deletes entries past their expiry date. Returns count deleted.
|
||||
func (s *Store) PurgeExpired() (int, error) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
result, err := s.db.Exec("DELETE FROM watchlist WHERE expires_at <= ?", now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
return int(rows), nil
|
||||
}
|
||||
Reference in New Issue
Block a user