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