Rewrite Pastel from Python to Go with watchlist feature

Full rewrite of the gaming deals Matrix bot in Go for better performance
and a single static binary. Includes all original functionality (CheapShark,
ITAD, Epic free games, multi-currency pricing, dedup, preflight checks,
presence heartbeat) plus a new watchlist feature where users can DM the bot
to watch for specific game deals with 180-day auto-expiry.

New: systemd service file, DB migration script for Python-to-Go transition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-17 18:26:58 -07:00
parent 24fc9240fe
commit b8a0fd5f35
16 changed files with 1961 additions and 33 deletions

115
internal/config/config.go Normal file
View File

@@ -0,0 +1,115 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
type Config struct {
MatrixHomeserverURL string
MatrixBotUserID string
MatrixBotAccessToken string
MatrixDealsRoomID string
ITADAPIKey string
DealSources []string
MinDealRating float64
MinDiscountPercent int
MaxPriceUSD float64
SendIntroMessage bool
DatabasePath string
}
func Load() (*Config, error) {
// Load .env file if present (ignore error if missing)
_ = godotenv.Load()
c := &Config{}
var err error
c.MatrixHomeserverURL, err = require("MATRIX_HOMESERVER_URL")
if err != nil {
return nil, err
}
c.MatrixBotUserID, err = require("MATRIX_BOT_USER_ID")
if err != nil {
return nil, err
}
c.MatrixBotAccessToken, err = require("MATRIX_BOT_ACCESS_TOKEN")
if err != nil {
return nil, err
}
c.MatrixDealsRoomID, err = require("MATRIX_DEALS_ROOM_ID")
if err != nil {
return nil, err
}
c.ITADAPIKey = os.Getenv("ITAD_API_KEY")
rawSources := os.Getenv("DEAL_SOURCES")
if rawSources == "" {
rawSources = "cheapshark"
}
for _, s := range strings.Split(rawSources, ",") {
s = strings.TrimSpace(strings.ToLower(s))
if s != "" {
c.DealSources = append(c.DealSources, s)
}
}
c.MinDealRating = envFloat("MIN_DEAL_RATING", 8.0)
c.MinDiscountPercent = envInt("MIN_DISCOUNT_PERCENT", 50)
c.MaxPriceUSD = envFloat("MAX_PRICE_USD", 20)
c.DatabasePath = envStr("DATABASE_PATH", "deals.db")
intro := strings.ToLower(os.Getenv("SEND_INTRO_MESSAGE"))
c.SendIntroMessage = intro == "true" || intro == "1" || intro == "yes"
return c, nil
}
// HasSource checks if a deal source is enabled.
func (c *Config) HasSource(name string) bool {
for _, s := range c.DealSources {
if s == name {
return true
}
}
return false
}
func require(key string) (string, error) {
v := os.Getenv(key)
if v == "" {
return "", fmt.Errorf("required environment variable %s is not set", key)
}
return v, nil
}
func envStr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envFloat(key string, def float64) float64 {
if v := os.Getenv(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return def
}
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return def
}

View File

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

View File

@@ -0,0 +1,105 @@
package database
import (
"time"
"github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
)
type DB struct {
db *sqlx.DB
}
func Open(path string) (*DB, error) {
db, err := sqlx.Open("sqlite", path+"?_pragma=journal_mode(WAL)")
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
d := &DB{db: db}
if err := d.migrate(); err != nil {
return nil, err
}
return d, nil
}
func (d *DB) Close() error {
return d.db.Close()
}
// RawDB exposes the underlying sqlx.DB for packages that need direct access.
func (d *DB) RawDB() *sqlx.DB {
return d.db
}
func (d *DB) migrate() error {
_, err := d.db.Exec(`
CREATE TABLE IF NOT EXISTS posted_deals (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
title TEXT NOT NULL,
posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS watchlist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
game_name TEXT NOT NULL,
game_name_normalized TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
expiry_warned INTEGER DEFAULT 0,
UNIQUE(user_id, game_name_normalized)
);
CREATE INDEX IF NOT EXISTS idx_watchlist_normalized ON watchlist(game_name_normalized);
CREATE INDEX IF NOT EXISTS idx_watchlist_expires ON watchlist(expires_at);
`)
return err
}
// IsPosted checks if a deal has already been posted.
func (d *DB) IsPosted(id string) (bool, error) {
var count int
err := d.db.Get(&count, "SELECT COUNT(1) FROM posted_deals WHERE id = ?", id)
return count > 0, err
}
// MarkPosted records a deal as posted.
func (d *DB) MarkPosted(id, source, title string) error {
_, err := d.db.Exec(
"INSERT OR IGNORE INTO posted_deals (id, source, title) VALUES (?, ?, ?)",
id, source, title,
)
return err
}
// IsFirstRunDone checks if the initial population has been completed.
func (d *DB) IsFirstRunDone() (bool, error) {
var val string
err := d.db.Get(&val, "SELECT value FROM config WHERE key = 'first_run_done'")
if err != nil {
return false, nil // not found = not done
}
return val == "true", nil
}
// SetFirstRunDone marks the initial population as complete.
func (d *DB) SetFirstRunDone() error {
_, err := d.db.Exec(
"INSERT OR REPLACE INTO config (key, value) VALUES ('first_run_done', 'true')",
)
return err
}
// PruneOldDeals removes deals older than the given number of days.
func (d *DB) PruneOldDeals(days int) error {
cutoff := time.Now().AddDate(0, 0, -days).UTC().Format(time.RFC3339)
_, err := d.db.Exec("DELETE FROM posted_deals WHERE posted_at < ?", cutoff)
return err
}

View File

@@ -0,0 +1,123 @@
package deals
import (
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"strconv"
)
var storeNames = map[string]string{
"1": "Steam",
"7": "GOG",
"11": "Humble Store",
"23": "GreenManGaming",
}
type CheapSharkDeal struct {
DealID string
GameID string
Title string
SalePrice float64
NormalPrice float64
Savings float64
DealRating float64
StoreID string
StoreName string
LastChange int64
SteamAppID string
DealURL string
IsHistLow bool
DedupID string
}
type cheapSharkRaw struct {
DealID string `json:"dealID"`
GameID string `json:"gameID"`
Title string `json:"title"`
SalePrice string `json:"salePrice"`
NormalPrice string `json:"normalPrice"`
Savings string `json:"savings"`
DealRating string `json:"dealRating"`
StoreID string `json:"storeID"`
LastChange int64 `json:"lastChange"`
SteamAppID string `json:"steamAppID"`
}
// FetchCheapSharkDeals fetches deals from CheapShark API.
func FetchCheapSharkDeals(maxPrice float64, pageSize int) ([]CheapSharkDeal, error) {
url := fmt.Sprintf(
"https://www.cheapshark.com/api/1.0/deals?storeID=1,7,11,23&upperPrice=%d&sortBy=recent&desc=1&pageSize=%d",
int(maxPrice), pageSize,
)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("cheapshark request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cheapshark returned status %d", resp.StatusCode)
}
var raw []cheapSharkRaw
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("cheapshark decode failed: %w", err)
}
var deals []CheapSharkDeal
for _, d := range raw {
sale, _ := strconv.ParseFloat(d.SalePrice, 64)
normal, _ := strconv.ParseFloat(d.NormalPrice, 64)
savings, _ := strconv.ParseFloat(d.Savings, 64)
rating, _ := strconv.ParseFloat(d.DealRating, 64)
storeName := storeNames[d.StoreID]
if storeName == "" {
storeName = "Unknown"
}
deals = append(deals, CheapSharkDeal{
DealID: d.DealID,
GameID: d.GameID,
Title: d.Title,
SalePrice: sale,
NormalPrice: normal,
Savings: savings,
DealRating: rating,
StoreID: d.StoreID,
StoreName: storeName,
LastChange: d.LastChange,
SteamAppID: d.SteamAppID,
DealURL: fmt.Sprintf("https://www.cheapshark.com/redirect?dealID=%s", d.DealID),
DedupID: fmt.Sprintf("cheapshark-%s-%d", d.GameID, d.LastChange),
})
}
return deals, nil
}
// FilterCheapSharkDeals filters deals by rating, discount, and price thresholds.
func FilterCheapSharkDeals(deals []CheapSharkDeal, minRating float64, minDiscount int, maxPrice float64) []CheapSharkDeal {
var filtered []CheapSharkDeal
for _, d := range deals {
discount := int(math.Floor(d.Savings))
if discount < minDiscount {
slog.Debug("cheapshark: skipping (low discount)", "title", d.Title, "discount", discount)
continue
}
if d.DealRating < minRating && d.DealRating != 0 {
slog.Debug("cheapshark: skipping (low rating)", "title", d.Title, "rating", d.DealRating)
continue
}
if d.SalePrice > maxPrice {
slog.Debug("cheapshark: skipping (too expensive)", "title", d.Title, "price", d.SalePrice)
continue
}
filtered = append(filtered, d)
}
return filtered
}

147
internal/deals/epic.go Normal file
View File

@@ -0,0 +1,147 @@
package deals
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
type EpicFreeGame struct {
ID string
Title string
Desc string
URL string
EndDate *time.Time
Upcoming bool
DedupID string
}
type epicResponse struct {
Data struct {
Catalog struct {
SearchStore struct {
Elements []epicElement `json:"elements"`
} `json:"searchStore"`
} `json:"Catalog"`
} `json:"data"`
}
type epicElement struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
ProductSlug string `json:"productSlug"`
URLSlug string `json:"urlSlug"`
CatalogNs struct {
Mappings []struct {
PageSlug string `json:"pageSlug"`
} `json:"mappings"`
} `json:"catalogNs"`
Promotions *struct {
PromotionalOffers []epicOfferGroup `json:"promotionalOffers"`
UpcomingPromotionalOffers []epicOfferGroup `json:"upcomingPromotionalOffers"`
} `json:"promotions"`
}
type epicOfferGroup struct {
PromotionalOffers []epicOffer `json:"promotionalOffers"`
}
type epicOffer struct {
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
DiscountSetting struct {
DiscountPercentage int `json:"discountPercentage"`
} `json:"discountSetting"`
}
// FetchEpicFreeGames fetches current and upcoming free games from Epic Games Store.
func FetchEpicFreeGames() ([]EpicFreeGame, error) {
resp, err := http.Get("https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions?locale=en-US")
if err != nil {
return nil, fmt.Errorf("epic request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("epic returned status %d", resp.StatusCode)
}
var result epicResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("epic decode failed: %w", err)
}
now := time.Now()
var games []EpicFreeGame
for _, elem := range result.Data.Catalog.SearchStore.Elements {
if elem.Promotions == nil {
continue
}
slug := elem.ProductSlug
if slug == "" {
slug = elem.URLSlug
}
if slug == "" && len(elem.CatalogNs.Mappings) > 0 {
slug = elem.CatalogNs.Mappings[0].PageSlug
}
storeURL := fmt.Sprintf("https://store.epicgames.com/en-US/p/%s", slug)
// Check current free offers
for _, group := range elem.Promotions.PromotionalOffers {
for _, offer := range group.PromotionalOffers {
if offer.DiscountSetting.DiscountPercentage != 0 {
continue
}
start, _ := time.Parse(time.RFC3339, offer.StartDate)
end, _ := time.Parse(time.RFC3339, offer.EndDate)
if start.After(now) || end.Before(now) {
continue
}
game := EpicFreeGame{
ID: elem.ID,
Title: elem.Title,
Desc: elem.Description,
URL: storeURL,
Upcoming: false,
DedupID: fmt.Sprintf("epic-%s", elem.ID),
}
if !end.IsZero() {
game.EndDate = &end
}
games = append(games, game)
}
}
// Check upcoming free offers
for _, group := range elem.Promotions.UpcomingPromotionalOffers {
for _, offer := range group.PromotionalOffers {
if offer.DiscountSetting.DiscountPercentage != 0 {
continue
}
game := EpicFreeGame{
ID: elem.ID,
Title: elem.Title,
Desc: elem.Description,
URL: storeURL,
Upcoming: true,
DedupID: fmt.Sprintf("epic-%s", elem.ID),
}
if offer.EndDate != "" {
if end, err := time.Parse(time.RFC3339, offer.EndDate); err == nil {
game.EndDate = &end
}
}
games = append(games, game)
}
}
}
return games, nil
}

232
internal/deals/itad.go Normal file
View File

@@ -0,0 +1,232 @@
package deals
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
)
type ITADDeal struct {
GameID string
Slug string
Title string
Type string
Discount int
Price float64
Regular float64
Currency string
ShopName string
ShopID int
URL string
Flag string // "H" = historical low, "N" = new low
Timestamp time.Time
Expiry *time.Time
DedupID string
IsHistLow bool
}
type itadResponse struct {
List []itadEntry `json:"list"`
}
type itadEntry struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Type string `json:"type"`
Deal struct {
Shop struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"shop"`
Price struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
} `json:"price"`
Regular struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
} `json:"regular"`
Cut int `json:"cut"`
URL string `json:"url"`
Flag string `json:"flag"`
Timestamp string `json:"timestamp"`
Expiry *string `json:"expiry"`
} `json:"deal"`
}
// FetchITADDeals fetches deals from ITAD API v2.
func FetchITADDeals(apiKey string, limit int) ([]ITADDeal, error) {
if limit > 200 {
limit = 200
}
url := fmt.Sprintf(
"https://api.isthereanydeal.com/deals/v2?key=%s&sort=-cut&limit=%d&nondeals=false",
apiKey, limit,
)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("itad request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("itad returned status %d", resp.StatusCode)
}
var result itadResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("itad decode failed: %w", err)
}
var deals []ITADDeal
for _, e := range result.List {
// Only include games and DLC
t := strings.ToLower(e.Type)
if t != "game" && t != "dlc" {
continue
}
deal := ITADDeal{
GameID: e.ID,
Slug: e.Slug,
Title: e.Title,
Type: e.Type,
Discount: e.Deal.Cut,
Price: e.Deal.Price.Amount,
Regular: e.Deal.Regular.Amount,
Currency: e.Deal.Price.Currency,
ShopName: e.Deal.Shop.Name,
ShopID: e.Deal.Shop.ID,
URL: e.Deal.URL,
Flag: e.Deal.Flag,
IsHistLow: e.Deal.Flag == "H" || e.Deal.Flag == "N",
DedupID: fmt.Sprintf("itad-%s-%d-%d", e.ID, e.Deal.Shop.ID, e.Deal.Cut),
}
if ts, err := time.Parse(time.RFC3339, e.Deal.Timestamp); err == nil {
deal.Timestamp = ts
}
if e.Deal.Expiry != nil {
if exp, err := time.Parse(time.RFC3339, *e.Deal.Expiry); err == nil {
deal.Expiry = &exp
}
}
deals = append(deals, deal)
}
return deals, nil
}
// FilterITADDeals filters ITAD deals by discount and price thresholds.
func FilterITADDeals(deals []ITADDeal, minDiscount int, maxPrice float64) []ITADDeal {
var filtered []ITADDeal
for _, d := range deals {
if d.Discount < minDiscount {
slog.Debug("itad: skipping (low discount)", "title", d.Title, "discount", d.Discount)
continue
}
if d.Price > maxPrice {
slog.Debug("itad: skipping (too expensive)", "title", d.Title, "price", d.Price)
continue
}
filtered = append(filtered, d)
}
return filtered
}
// LookupHistoricalLows checks if games are at their historical low price via ITAD.
// Takes a map of steamAppID -> deal index, returns map of steamAppID -> isHistoricalLow.
func LookupHistoricalLows(apiKey string, steamAppIDs []string) (map[string]bool, error) {
result := make(map[string]bool)
if apiKey == "" || len(steamAppIDs) == 0 {
return result, nil
}
// Step 1: Look up ITAD game IDs from Steam app IDs
itadIDs := make(map[string]string) // steamAppID -> itadID
for _, appID := range steamAppIDs {
if appID == "" || appID == "0" {
continue
}
url := fmt.Sprintf(
"https://api.isthereanydeal.com/games/lookup/v1?key=%s&appid=%s",
apiKey, appID,
)
resp, err := http.Get(url)
if err != nil {
slog.Warn("itad lookup failed", "appid", appID, "error", err)
continue
}
var lookup struct {
Found bool `json:"found"`
Game struct {
ID string `json:"id"`
} `json:"game"`
}
if err := json.NewDecoder(resp.Body).Decode(&lookup); err != nil {
resp.Body.Close()
continue
}
resp.Body.Close()
if lookup.Found {
itadIDs[appID] = lookup.Game.ID
}
}
if len(itadIDs) == 0 {
return result, nil
}
// Step 2: Get price overview for all looked-up games
var gameIDs []string
itadToSteam := make(map[string]string)
for steamID, itadID := range itadIDs {
gameIDs = append(gameIDs, itadID)
itadToSteam[itadID] = steamID
}
body, _ := json.Marshal(gameIDs)
url := fmt.Sprintf("https://api.isthereanydeal.com/games/overview/v2?key=%s", apiKey)
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return result, fmt.Errorf("itad overview request failed: %w", err)
}
defer resp.Body.Close()
var overview struct {
Prices []struct {
ID string `json:"id"`
Current struct {
Price struct {
Amount float64 `json:"amount"`
} `json:"price"`
} `json:"current"`
Lowest struct {
Price struct {
Amount float64 `json:"amount"`
} `json:"price"`
} `json:"lowest"`
} `json:"prices"`
}
if err := json.NewDecoder(resp.Body).Decode(&overview); err != nil {
return result, fmt.Errorf("itad overview decode failed: %w", err)
}
for _, p := range overview.Prices {
if steamID, ok := itadToSteam[p.ID]; ok {
result[steamID] = p.Current.Price.Amount <= p.Lowest.Price.Amount
}
}
return result, nil
}

View File

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

263
internal/matrix/client.go Normal file
View File

@@ -0,0 +1,263 @@
package matrix
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type Client struct {
client *mautrix.Client
crypto *cryptohelper.CryptoHelper
cancel context.CancelFunc
syncCancel context.CancelFunc
hasE2EE bool
startTime time.Time
dmMu sync.Mutex
dmCache map[id.UserID]id.RoomID // userID -> DM room
}
// New creates a new Matrix client with E2EE support via cryptohelper.
// The cryptoDBPath is the path to the SQLite database for the crypto store
// (e.g. "crypto.db"). Call RegisterMessageHandler before StartSync to
// receive DM events.
func New(homeserverURL, userID, accessToken, cryptoDBPath string) (*Client, error) {
uid := id.UserID(userID)
client, err := mautrix.NewClient(homeserverURL, uid, accessToken)
if err != nil {
return nil, fmt.Errorf("failed to create matrix client: %w", err)
}
c := &Client{
client: client,
dmCache: make(map[id.UserID]id.RoomID),
startTime: time.Now(),
}
if cryptoDBPath != "" {
ch, err := cryptohelper.NewCryptoHelper(client, []byte("pastel_pickle_key"), cryptoDBPath)
if err != nil {
return nil, fmt.Errorf("init crypto helper: %w", err)
}
if err := ch.Init(context.Background()); err != nil {
return nil, fmt.Errorf("crypto helper init: %w", err)
}
client.Crypto = ch
c.crypto = ch
slog.Info("E2EE initialized", "device_id", client.DeviceID)
}
return c, nil
}
// RegisterMessageHandler registers a callback for incoming messages.
// Must be called before StartSync. Ignores the bot's own messages and
// messages from before the bot started (avoids replaying history).
func (c *Client) RegisterMessageHandler(fn func(senderID id.UserID, roomID id.RoomID, body string)) {
syncer := c.client.Syncer.(*mautrix.DefaultSyncer)
syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) {
// Ignore own messages
if evt.Sender == c.client.UserID {
return
}
// Ignore messages from before bot startup (history replay)
evtTime := time.UnixMilli(evt.Timestamp)
if evtTime.Before(c.startTime) {
return
}
msg := evt.Content.AsMessage()
if msg == nil || msg.Body == "" {
return
}
fn(evt.Sender, evt.RoomID, msg.Body)
})
}
// StartSync launches the background sync loop. Must be called after handler
// registration so events are dispatched to registered callbacks.
func (c *Client) StartSync() {
syncCtx, syncCancel := context.WithCancel(context.Background())
c.syncCancel = syncCancel
go func() {
for {
if err := c.client.SyncWithContext(syncCtx); err != nil {
if syncCtx.Err() != nil {
return
}
slog.Warn("sync error, retrying in 10s", "error", err)
time.Sleep(10 * time.Second)
}
}
}()
}
// Whoami validates the access token by calling /whoami.
func (c *Client) Whoami() (string, error) {
resp, err := c.client.Whoami(context.Background())
if err != nil {
return "", err
}
return resp.UserID.String(), nil
}
// JoinedRooms returns the list of rooms the bot has joined.
func (c *Client) JoinedRooms() ([]string, error) {
resp, err := c.client.JoinedRooms(context.Background())
if err != nil {
return nil, err
}
var rooms []string
for _, r := range resp.JoinedRooms {
rooms = append(rooms, r.String())
}
return rooms, nil
}
// SendDeal sends a formatted deal message to a room.
func (c *Client) SendDeal(roomID, plainText, html string) error {
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: plainText,
Format: event.FormatHTML,
FormattedBody: html,
}
_, err := c.client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return fmt.Errorf("failed to send deal message: %w", err)
}
return nil
}
// SendNotice sends a notice message to a room (non-highlighting).
func (c *Client) SendNotice(roomID, text string) error {
content := &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: text,
}
_, err := c.client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return fmt.Errorf("failed to send notice: %w", err)
}
return nil
}
// GetDMRoom returns the DM room for a user, reusing an existing one if available.
// Checks the in-memory cache first, then m.direct account data, and only creates
// a new encrypted room as a last resort. Follows gogobee's pattern to avoid
// opening duplicate DM rooms against the same user.
func (c *Client) GetDMRoom(userID id.UserID) (id.RoomID, error) {
c.dmMu.Lock()
if roomID, ok := c.dmCache[userID]; ok {
c.dmMu.Unlock()
return roomID, nil
}
c.dmMu.Unlock()
// Check m.direct account data for existing DM rooms
var dmRooms map[id.UserID][]id.RoomID
err := c.client.GetAccountData(context.Background(), "m.direct", &dmRooms)
if err == nil {
if rooms, ok := dmRooms[userID]; ok && len(rooms) > 0 {
roomID := rooms[len(rooms)-1] // use most recent
c.dmMu.Lock()
c.dmCache[userID] = roomID
c.dmMu.Unlock()
return roomID, nil
}
}
// No existing DM room — create an encrypted one
resp, err := c.client.CreateRoom(context.Background(), &mautrix.ReqCreateRoom{
Preset: "trusted_private_chat",
Invite: []id.UserID{userID},
IsDirect: true,
InitialState: []*event.Event{
{
Type: event.StateEncryption,
Content: event.Content{
Parsed: &event.EncryptionEventContent{
Algorithm: id.AlgorithmMegolmV1,
},
},
},
},
})
if err != nil {
return "", fmt.Errorf("create DM room: %w", err)
}
c.dmMu.Lock()
c.dmCache[userID] = resp.RoomID
c.dmMu.Unlock()
slog.Info("created DM room", "user", userID, "room", resp.RoomID)
return resp.RoomID, nil
}
// SendDM sends a direct message to a user. Reuses existing DM room if available.
func (c *Client) SendDM(userID id.UserID, text string) error {
roomID, err := c.GetDMRoom(userID)
if err != nil {
return err
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: text,
}
_, err = c.client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
return err
}
// StartPresenceHeartbeat starts a goroutine that sends online presence every 60 seconds.
func (c *Client) StartPresenceHeartbeat() {
ctx, cancel := context.WithCancel(context.Background())
c.cancel = cancel
go func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
// Send immediately
c.sendPresence()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.sendPresence()
}
}
}()
}
func (c *Client) sendPresence() {
err := c.client.SetPresence(context.Background(), mautrix.ReqPresence{Presence: event.PresenceOnline})
if err != nil {
slog.Warn("failed to set presence", "error", err)
}
}
// Stop cancels the presence heartbeat and sync loop.
func (c *Client) Stop() {
if c.cancel != nil {
c.cancel()
}
if c.syncCancel != nil {
c.syncCancel()
}
if c.crypto != nil {
c.crypto.Close()
}
}

View File

@@ -0,0 +1,160 @@
package preflight
import (
"encoding/json"
"fmt"
"net/http"
"github.com/prosolis/Pastel/internal/config"
"github.com/prosolis/Pastel/internal/matrix"
)
type Result struct {
Name string
Status string // "pass", "fail", "skip"
Detail string
}
// Run executes all preflight checks and returns results.
func Run(cfg *config.Config) []Result {
var results []Result
results = append(results, checkMatrix(cfg))
results = append(results, checkFrankfurter())
if cfg.HasSource("cheapshark") {
results = append(results, checkCheapShark())
} else {
results = append(results, Result{"CheapShark", "skip", "not in DEAL_SOURCES"})
}
results = append(results, checkEpic())
if cfg.ITADAPIKey != "" || cfg.HasSource("itad") {
results = append(results, checkITAD(cfg))
} else {
results = append(results, Result{"IsThereAnyDeal", "skip", "no API key and not in DEAL_SOURCES"})
}
return results
}
// PrintResults prints preflight results and returns true if all passed.
func PrintResults(results []Result) bool {
allPass := true
for _, r := range results {
var icon string
switch r.Status {
case "pass":
icon = "✓"
case "fail":
icon = "✗"
allPass = false
case "skip":
icon = ""
}
fmt.Printf(" %s %s: %s\n", icon, r.Name, r.Detail)
}
return allPass
}
func checkMatrix(cfg *config.Config) Result {
client, err := matrix.New(cfg.MatrixHomeserverURL, cfg.MatrixBotUserID, cfg.MatrixBotAccessToken, "")
if err != nil {
return Result{"Matrix", "fail", fmt.Sprintf("client error: %v", err)}
}
who, err := client.Whoami()
if err != nil {
return Result{"Matrix", "fail", fmt.Sprintf("whoami failed: %v", err)}
}
rooms, err := client.JoinedRooms()
if err != nil {
return Result{"Matrix", "fail", fmt.Sprintf("joined_rooms failed: %v", err)}
}
inRoom := false
for _, r := range rooms {
if r == cfg.MatrixDealsRoomID {
inRoom = true
break
}
}
if !inRoom {
return Result{"Matrix", "fail", fmt.Sprintf("bot %s is not in room %s", who, cfg.MatrixDealsRoomID)}
}
return Result{"Matrix", "pass", fmt.Sprintf("authenticated as %s, in target room", who)}
}
func checkCheapShark() Result {
resp, err := http.Get("https://www.cheapshark.com/api/1.0/deals?pageSize=1")
if err != nil {
return Result{"CheapShark", "fail", fmt.Sprintf("request failed: %v", err)}
}
defer resp.Body.Close()
var deals []json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&deals); err != nil || len(deals) == 0 {
return Result{"CheapShark", "fail", "unexpected response format"}
}
return Result{"CheapShark", "pass", "API reachable"}
}
func checkEpic() Result {
resp, err := http.Get("https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions?locale=en-US")
if err != nil {
return Result{"Epic Games", "fail", fmt.Sprintf("request failed: %v", err)}
}
defer resp.Body.Close()
var result struct {
Data struct {
Catalog struct {
SearchStore struct {
Elements []json.RawMessage `json:"elements"`
} `json:"searchStore"`
} `json:"Catalog"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return Result{"Epic Games", "fail", "unexpected response format"}
}
return Result{"Epic Games", "pass", fmt.Sprintf("API reachable, %d elements", len(result.Data.Catalog.SearchStore.Elements))}
}
func checkFrankfurter() Result {
resp, err := http.Get("https://api.frankfurter.dev/v1/latest?base=USD&symbols=CAD,EUR,GBP")
if err != nil {
return Result{"Frankfurter", "fail", fmt.Sprintf("request failed: %v", err)}
}
defer resp.Body.Close()
var result struct {
Rates map[string]float64 `json:"rates"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || len(result.Rates) == 0 {
return Result{"Frankfurter", "fail", "no rates returned"}
}
return Result{"Frankfurter", "pass", fmt.Sprintf("rates: %v", result.Rates)}
}
func checkITAD(cfg *config.Config) Result {
url := fmt.Sprintf("https://api.isthereanydeal.com/games/lookup/v1?key=%s&appid=220", cfg.ITADAPIKey)
resp, err := http.Get(url)
if err != nil {
return Result{"IsThereAnyDeal", "fail", fmt.Sprintf("request failed: %v", err)}
}
defer resp.Body.Close()
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return Result{"IsThereAnyDeal", "fail", "invalid API key"}
}
return Result{"IsThereAnyDeal", "pass", "API key valid"}
}

View File

@@ -0,0 +1,163 @@
package watchlist
import (
"fmt"
"log/slog"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
// DMSender is the interface for sending DMs to users.
type DMSender interface {
SendDM(userID id.UserID, text string) error
}
// CommandHandler handles watchlist commands received via DM.
type CommandHandler struct {
store *Store
sender DMSender
}
// NewCommandHandler creates a new command handler.
func NewCommandHandler(store *Store, sender DMSender) *CommandHandler {
return &CommandHandler{store: store, sender: sender}
}
// HandleMessage parses and dispatches a DM command.
func (h *CommandHandler) HandleMessage(senderID, body string) {
body = strings.TrimSpace(body)
if body == "" {
return
}
var cmd, args string
if idx := strings.IndexByte(body, ' '); idx > 0 {
cmd = strings.ToLower(body[:idx])
args = strings.TrimSpace(body[idx+1:])
} else {
cmd = strings.ToLower(body)
}
uid := id.UserID(senderID)
switch cmd {
case "!watch":
h.handleWatch(uid, args)
case "!unwatch":
h.handleUnwatch(uid, args)
case "!extend":
h.handleExtend(uid, args)
case "!watchlist":
h.handleList(uid)
case "!help":
h.handleHelp(uid)
}
}
func (h *CommandHandler) handleWatch(userID id.UserID, gameName string) {
if gameName == "" {
h.reply(userID, "Usage: !watch <game name>")
return
}
added, err := h.store.AddWatch(string(userID), gameName)
if err != nil {
slog.Error("watchlist: add failed", "user", userID, "game", gameName, "error", err)
h.reply(userID, "Something went wrong. Please try again.")
return
}
if !added {
h.reply(userID, fmt.Sprintf("You're already watching \"%s\".", gameName))
return
}
expires := time.Now().Add(watchDuration)
h.reply(userID, fmt.Sprintf("Added \"%s\" to your watchlist. I'll DM you when a deal appears. Expires %s.",
gameName, expires.Format("January 2, 2006")))
}
func (h *CommandHandler) handleUnwatch(userID id.UserID, gameName string) {
if gameName == "" {
h.reply(userID, "Usage: !unwatch <game name>")
return
}
removed, err := h.store.RemoveWatch(string(userID), gameName)
if err != nil {
slog.Error("watchlist: remove failed", "user", userID, "game", gameName, "error", err)
h.reply(userID, "Something went wrong. Please try again.")
return
}
if !removed {
h.reply(userID, fmt.Sprintf("No watch found for \"%s\".", gameName))
return
}
h.reply(userID, fmt.Sprintf("Removed \"%s\" from your watchlist.", gameName))
}
func (h *CommandHandler) handleExtend(userID id.UserID, gameName string) {
if gameName == "" {
h.reply(userID, "Usage: !extend <game name>")
return
}
extended, err := h.store.ExtendWatch(string(userID), gameName)
if err != nil {
slog.Error("watchlist: extend failed", "user", userID, "game", gameName, "error", err)
h.reply(userID, "Something went wrong. Please try again.")
return
}
if !extended {
h.reply(userID, fmt.Sprintf("No watch found for \"%s\".", gameName))
return
}
expires := time.Now().Add(watchDuration)
h.reply(userID, fmt.Sprintf("Extended \"%s\" — now expires %s.",
gameName, expires.Format("January 2, 2006")))
}
func (h *CommandHandler) handleList(userID id.UserID) {
entries, err := h.store.ListWatches(string(userID))
if err != nil {
slog.Error("watchlist: list failed", "user", userID, "error", err)
h.reply(userID, "Something went wrong. Please try again.")
return
}
if len(entries) == 0 {
h.reply(userID, "Your watchlist is empty. Use !watch <game name> to add one.")
return
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Your watchlist (%d):\n", len(entries)))
for _, e := range entries {
sb.WriteString(fmt.Sprintf(" - %s (expires %s)\n", e.GameName, e.ExpiresAt.Format("January 2, 2006")))
}
sb.WriteString("\nCommands: !watch, !unwatch, !extend, !watchlist")
h.reply(userID, sb.String())
}
func (h *CommandHandler) handleHelp(userID id.UserID) {
h.reply(userID, "Watchlist commands:\n"+
" !watch <game name> — Watch for deals on a game\n"+
" !unwatch <game name> — Remove a game from your watchlist\n"+
" !extend <game name> — Reset the 180-day expiry timer\n"+
" !watchlist — Show your current watches\n"+
" !help — Show this message\n\n"+
"Watches expire after 180 days. You'll get a reminder 7 days before.")
}
func (h *CommandHandler) reply(userID id.UserID, text string) {
if err := h.sender.SendDM(userID, text); err != nil {
slog.Error("watchlist: failed to send DM", "user", userID, "error", err)
}
}

View File

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