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>
171 lines
4.9 KiB
Go
171 lines
4.9 KiB
Go
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
|
|
}
|