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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user