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

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"}
}