Files
Pastel/cmd/pastel/main.go
prosolis 373939fedb Improve error handling across deal sources and increase CheapShark page size
Add proper error handling for price parsing, date parsing, and HTTP
status codes in CheapShark, Epic, and ITAD integrations. Fix Epic dedup
IDs to distinguish current vs upcoming offers. Bump CheapShark fetch
page size from 10 to 60.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 01:49:21 -07:00

507 lines
14 KiB
Go

package main
import (
"flag"
"fmt"
"log/slog"
"math"
"os"
"os/signal"
"strings"
"syscall"
"time"
"maunium.net/go/mautrix/id"
"github.com/prosolis/Pastel/internal/config"
"github.com/prosolis/Pastel/internal/currency"
"github.com/prosolis/Pastel/internal/database"
"github.com/prosolis/Pastel/internal/deals"
"github.com/prosolis/Pastel/internal/formatter"
"github.com/prosolis/Pastel/internal/matrix"
"github.com/prosolis/Pastel/internal/preflight"
"github.com/prosolis/Pastel/internal/watchlist"
)
const (
threadKeyGameDeals = "thread_game_deals"
threadKeyDLCDeals = "thread_dlc_deals"
threadKeyEpicFree = "thread_epic_free"
)
// getOrCreateThread retrieves an existing thread event ID from the DB,
// or creates a new thread root message and stores its ID.
func getOrCreateThread(db *database.DB, mx *matrix.Client, roomID, dbKey, title string) (string, error) {
eventID, err := db.GetConfig(dbKey)
if err == nil && eventID != "" {
return eventID, nil
}
eventID, err = mx.CreateThread(roomID, title)
if err != nil {
return "", err
}
if err := db.SetConfig(dbKey, eventID); err != nil {
slog.Warn("failed to persist thread event ID", "key", dbKey, "error", err)
}
slog.Info("created thread", "title", title, "event_id", eventID)
return eventID, nil
}
func main() {
checkFlag := flag.Bool("check", false, "Run preflight checks and exit")
debugFlag := flag.Bool("debug", false, "Enable debug logging")
flag.Parse()
level := slog.LevelInfo
if *debugFlag {
level = slog.LevelDebug
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
cfg, err := config.Load()
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
if *checkFlag {
fmt.Println("Running preflight checks...")
results := preflight.Run(cfg)
if !preflight.PrintResults(results) {
os.Exit(1)
}
fmt.Println("All checks passed.")
return
}
// Open database
db, err := database.Open(cfg.DatabasePath)
if err != nil {
slog.Error("failed to open database", "error", err)
os.Exit(1)
}
defer db.Close()
// Initialize watchlist store
watchStore := watchlist.NewStore(db.RawDB())
// Initialize Matrix client with E2EE and auto-refresh
mx, err := matrix.New(matrix.ClientConfig{
HomeserverURL: cfg.MatrixHomeserverURL,
UserID: cfg.MatrixBotUserID,
AccessToken: cfg.MatrixBotAccessToken,
Password: cfg.MatrixBotPassword,
CryptoDBPath: "crypto.db",
DevicePath: "device.json",
})
if err != nil {
slog.Error("failed to create matrix client", "error", err)
os.Exit(1)
}
defer mx.Stop()
// Pre-fetch exchange rates
conv := currency.NewConverter()
conv.EnsureRates()
// Set up watchlist command handler and register DM event handler
cmdHandler := watchlist.NewCommandHandler(watchStore, mx, conv)
dealsRoomID := id.RoomID(cfg.MatrixDealsRoomID)
mx.RegisterMessageHandler(func(senderID id.UserID, roomID id.RoomID, body string) {
// Only handle DMs, not messages in the deals room
if roomID == dealsRoomID {
return
}
cmdHandler.HandleMessage(string(senderID), body)
})
// Start sync loop after handlers are registered
mx.StartSync()
// First-run: populate DB without posting
firstRunDone, _ := db.IsFirstRunDone()
if !firstRunDone {
slog.Info("first run detected — populating database without posting")
populateInitialState(cfg, db)
if err := db.SetFirstRunDone(); err != nil {
slog.Error("failed to set first run done", "error", err)
}
}
// Send intro message
if cfg.SendIntroMessage {
if err := mx.SendNotice(cfg.MatrixDealsRoomID, "The deals must flow."); err != nil {
slog.Warn("failed to send intro message", "error", err)
}
}
// Start presence heartbeat
mx.StartPresenceHeartbeat()
// Run initial checks
slog.Info("running initial deal checks")
if cfg.HasSource("cheapshark") {
checkCheapShark(cfg, db, mx, conv, watchStore)
}
if cfg.HasSource("itad") {
checkITADDeals(cfg, db, mx, conv, watchStore)
}
checkEpicFreeGames(cfg, db, mx, watchStore)
// Run initial expiry check
checkWatchlistExpiry(watchStore, mx)
// Start ticker goroutines
stop := make(chan struct{})
if cfg.HasSource("cheapshark") {
go func() {
ticker := time.NewTicker(2 * time.Hour)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
checkCheapShark(cfg, db, mx, conv, watchStore)
}
}
}()
}
if cfg.HasSource("itad") {
go func() {
ticker := time.NewTicker(2 * time.Hour)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
checkITADDeals(cfg, db, mx, conv, watchStore)
}
}
}()
}
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
checkEpicFreeGames(cfg, db, mx, watchStore)
}
}
}()
// Watchlist expiry check — daily
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
checkWatchlistExpiry(watchStore, mx)
}
}
}()
slog.Info("bot is running", "sources", cfg.DealSources)
// Wait for OS signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
slog.Info("shutting down")
close(stop)
}
func populateInitialState(cfg *config.Config, db *database.DB) {
// Record CheapShark deals without posting
if cfg.HasSource("cheapshark") {
rawDeals, err := deals.FetchCheapSharkDeals(cfg.MaxPriceUSD, 60)
if err != nil {
slog.Warn("first run: cheapshark fetch failed", "error", err)
} else {
filtered := deals.FilterCheapSharkDeals(rawDeals, cfg.MinDealRating, cfg.MinDiscountPercent, cfg.MaxPriceUSD)
for _, d := range filtered {
_ = db.MarkPosted(d.DedupID, "cheapshark", d.Title)
}
slog.Info("first run: populated cheapshark deals", "count", len(filtered))
}
}
// Record ITAD deals without posting
if cfg.HasSource("itad") && cfg.ITADAPIKey != "" {
itadDeals, err := deals.FetchITADDeals(cfg.ITADAPIKey, 20)
if err != nil {
slog.Warn("first run: itad fetch failed", "error", err)
} else {
filtered := deals.FilterITADDeals(itadDeals, cfg.MinDiscountPercent, cfg.MaxPriceUSD)
for _, d := range filtered {
_ = db.MarkPosted(d.DedupID, "itad", d.Title)
}
slog.Info("first run: populated itad deals", "count", len(filtered))
}
}
// Epic games are intentionally NOT recorded on first run
// so they post immediately (few games, time-limited)
}
func notifyWatchlistMatches(ws *watchlist.Store, mx *matrix.Client, title, url, price string, discount int) {
matches, err := ws.FindMatchingUsers(title)
if err != nil {
slog.Error("watchlist match failed", "error", err)
return
}
for _, m := range matches {
msg := formatter.FormatWatchlistNotification(m.GameName, title, url, price, discount)
if err := mx.SendDM(id.UserID(m.UserID), msg); err != nil {
slog.Error("failed to send watchlist DM", "user", m.UserID, "error", err)
}
}
}
func notifyWatchlistFreeMatches(ws *watchlist.Store, mx *matrix.Client, title, url string) {
matches, err := ws.FindMatchingUsers(title)
if err != nil {
slog.Error("watchlist match failed", "error", err)
return
}
for _, m := range matches {
msg := formatter.FormatWatchlistFreeNotification(m.GameName, title, url)
if err := mx.SendDM(id.UserID(m.UserID), msg); err != nil {
slog.Error("failed to send watchlist DM", "user", m.UserID, "error", err)
}
}
}
func checkCheapShark(cfg *config.Config, db *database.DB, mx *matrix.Client, conv *currency.Converter, ws *watchlist.Store) {
slog.Debug("checking cheapshark deals")
conv.EnsureRates()
rawDeals, err := deals.FetchCheapSharkDeals(cfg.MaxPriceUSD, 60)
if err != nil {
slog.Error("cheapshark fetch failed", "error", err)
return
}
filtered := deals.FilterCheapSharkDeals(rawDeals, cfg.MinDealRating, cfg.MinDiscountPercent, cfg.MaxPriceUSD)
// Look up historical lows if ITAD key is available
if cfg.ITADAPIKey != "" {
var steamIDs []string
steamIDSet := make(map[string]bool)
for _, d := range filtered {
if d.SteamAppID != "" && d.SteamAppID != "0" && !steamIDSet[d.SteamAppID] {
steamIDs = append(steamIDs, d.SteamAppID)
steamIDSet[d.SteamAppID] = true
}
}
if len(steamIDs) > 0 {
lows, err := deals.LookupHistoricalLows(cfg.ITADAPIKey, steamIDs)
if err != nil {
slog.Warn("historical low lookup failed", "error", err)
} else {
for i := range filtered {
if isLow, ok := lows[filtered[i].SteamAppID]; ok && isLow {
filtered[i].IsHistLow = true
}
}
}
}
}
threadID, err := getOrCreateThread(db, mx, cfg.MatrixDealsRoomID, threadKeyGameDeals, "Game Deals")
if err != nil {
slog.Error("failed to get/create game deals thread", "error", err)
return
}
posted := 0
for _, d := range filtered {
already, err := db.IsPosted(d.DedupID)
if err != nil {
slog.Error("db check failed", "error", err)
continue
}
if already {
continue
}
msg := formatter.FormatCheapSharkDeal(d, conv)
if err := mx.SendDealInThread(cfg.MatrixDealsRoomID, threadID, msg.Plain, msg.HTML); err != nil {
slog.Error("failed to send cheapshark deal", "title", d.Title, "error", err)
continue
}
if err := db.MarkPosted(d.DedupID, "cheapshark", d.Title); err != nil {
slog.Error("failed to mark deal posted", "error", err)
}
posted++
// Notify watchlist matches
notifyWatchlistMatches(ws, mx, d.Title, d.DealURL, conv.FormatPrice(d.SalePrice), int(math.Floor(d.Savings)))
}
if posted > 0 {
slog.Info("posted cheapshark deals", "count", posted)
}
if err := db.PruneOldDeals(30); err != nil {
slog.Warn("failed to prune old deals", "error", err)
}
}
func checkITADDeals(cfg *config.Config, db *database.DB, mx *matrix.Client, conv *currency.Converter, ws *watchlist.Store) {
if cfg.ITADAPIKey == "" {
return
}
slog.Debug("checking itad deals")
conv.EnsureRates()
itadDeals, err := deals.FetchITADDeals(cfg.ITADAPIKey, 20)
if err != nil {
slog.Error("itad fetch failed", "error", err)
return
}
filtered := deals.FilterITADDeals(itadDeals, cfg.MinDiscountPercent, cfg.MaxPriceUSD)
gameThreadID, err := getOrCreateThread(db, mx, cfg.MatrixDealsRoomID, threadKeyGameDeals, "Game Deals")
if err != nil {
slog.Error("failed to get/create game deals thread", "error", err)
return
}
dlcThreadID, err := getOrCreateThread(db, mx, cfg.MatrixDealsRoomID, threadKeyDLCDeals, "DLC Deals")
if err != nil {
slog.Error("failed to get/create dlc deals thread", "error", err)
return
}
posted := 0
for _, d := range filtered {
already, err := db.IsPosted(d.DedupID)
if err != nil {
slog.Error("db check failed", "error", err)
continue
}
if already {
continue
}
threadID := gameThreadID
if strings.EqualFold(d.Type, "dlc") {
threadID = dlcThreadID
}
msg := formatter.FormatITADDeal(d, conv)
if err := mx.SendDealInThread(cfg.MatrixDealsRoomID, threadID, msg.Plain, msg.HTML); err != nil {
slog.Error("failed to send itad deal", "title", d.Title, "error", err)
continue
}
if err := db.MarkPosted(d.DedupID, "itad", d.Title); err != nil {
slog.Error("failed to mark deal posted", "error", err)
}
posted++
// Notify watchlist matches
notifyWatchlistMatches(ws, mx, d.Title, d.URL, conv.FormatPrice(d.Price), d.Discount)
}
if posted > 0 {
slog.Info("posted itad deals", "count", posted)
}
if err := db.PruneOldDeals(30); err != nil {
slog.Warn("failed to prune old deals", "error", err)
}
}
func checkEpicFreeGames(cfg *config.Config, db *database.DB, mx *matrix.Client, ws *watchlist.Store) {
slog.Debug("checking epic free games")
games, err := deals.FetchEpicFreeGames()
if err != nil {
slog.Error("epic fetch failed", "error", err)
return
}
threadID, err := getOrCreateThread(db, mx, cfg.MatrixDealsRoomID, threadKeyEpicFree, "Epic Free Games")
if err != nil {
slog.Error("failed to get/create epic free games thread", "error", err)
return
}
posted := 0
for _, g := range games {
already, err := db.IsPosted(g.DedupID)
if err != nil {
slog.Error("db check failed", "error", err)
continue
}
if already {
continue
}
msg := formatter.FormatEpicFreeGame(g)
if err := mx.SendDealInThread(cfg.MatrixDealsRoomID, threadID, msg.Plain, msg.HTML); err != nil {
slog.Error("failed to send epic game", "title", g.Title, "error", err)
continue
}
if err := db.MarkPosted(g.DedupID, "epic", g.Title); err != nil {
slog.Error("failed to mark epic game posted", "error", err)
}
posted++
// Notify watchlist matches
notifyWatchlistFreeMatches(ws, mx, g.Title, g.URL)
}
if posted > 0 {
slog.Info("posted epic games", "count", posted)
}
}
func checkWatchlistExpiry(ws *watchlist.Store, mx *matrix.Client) {
// Warn users about entries expiring within 7 days
expiring, err := ws.GetExpiringWatches(7)
if err != nil {
slog.Error("failed to check expiring watches", "error", err)
return
}
for _, e := range expiring {
msg := fmt.Sprintf("Your watch for \"%s\" expires on %s. Send !extend %s to keep it for another 180 days.",
e.GameName, e.ExpiresAt.Format("January 2, 2006"), e.GameName)
if err := mx.SendDM(id.UserID(e.UserID), msg); err != nil {
slog.Error("failed to send expiry warning", "user", e.UserID, "error", err)
continue
}
if err := ws.MarkExpiryWarned(e.ID); err != nil {
slog.Error("failed to mark expiry warned", "error", err)
}
}
// Purge fully expired entries
purged, err := ws.PurgeExpired()
if err != nil {
slog.Error("failed to purge expired watches", "error", err)
} else if purged > 0 {
slog.Info("purged expired watchlist entries", "count", purged)
}
}