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:
163
internal/watchlist/commands.go
Normal file
163
internal/watchlist/commands.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// DMSender is the interface for sending DMs to users.
|
||||
type DMSender interface {
|
||||
SendDM(userID id.UserID, text string) error
|
||||
}
|
||||
|
||||
// CommandHandler handles watchlist commands received via DM.
|
||||
type CommandHandler struct {
|
||||
store *Store
|
||||
sender DMSender
|
||||
}
|
||||
|
||||
// NewCommandHandler creates a new command handler.
|
||||
func NewCommandHandler(store *Store, sender DMSender) *CommandHandler {
|
||||
return &CommandHandler{store: store, sender: sender}
|
||||
}
|
||||
|
||||
// HandleMessage parses and dispatches a DM command.
|
||||
func (h *CommandHandler) HandleMessage(senderID, body string) {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var cmd, args string
|
||||
if idx := strings.IndexByte(body, ' '); idx > 0 {
|
||||
cmd = strings.ToLower(body[:idx])
|
||||
args = strings.TrimSpace(body[idx+1:])
|
||||
} else {
|
||||
cmd = strings.ToLower(body)
|
||||
}
|
||||
|
||||
uid := id.UserID(senderID)
|
||||
|
||||
switch cmd {
|
||||
case "!watch":
|
||||
h.handleWatch(uid, args)
|
||||
case "!unwatch":
|
||||
h.handleUnwatch(uid, args)
|
||||
case "!extend":
|
||||
h.handleExtend(uid, args)
|
||||
case "!watchlist":
|
||||
h.handleList(uid)
|
||||
case "!help":
|
||||
h.handleHelp(uid)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CommandHandler) handleWatch(userID id.UserID, gameName string) {
|
||||
if gameName == "" {
|
||||
h.reply(userID, "Usage: !watch <game name>")
|
||||
return
|
||||
}
|
||||
|
||||
added, err := h.store.AddWatch(string(userID), gameName)
|
||||
if err != nil {
|
||||
slog.Error("watchlist: add failed", "user", userID, "game", gameName, "error", err)
|
||||
h.reply(userID, "Something went wrong. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
if !added {
|
||||
h.reply(userID, fmt.Sprintf("You're already watching \"%s\".", gameName))
|
||||
return
|
||||
}
|
||||
|
||||
expires := time.Now().Add(watchDuration)
|
||||
h.reply(userID, fmt.Sprintf("Added \"%s\" to your watchlist. I'll DM you when a deal appears. Expires %s.",
|
||||
gameName, expires.Format("January 2, 2006")))
|
||||
}
|
||||
|
||||
func (h *CommandHandler) handleUnwatch(userID id.UserID, gameName string) {
|
||||
if gameName == "" {
|
||||
h.reply(userID, "Usage: !unwatch <game name>")
|
||||
return
|
||||
}
|
||||
|
||||
removed, err := h.store.RemoveWatch(string(userID), gameName)
|
||||
if err != nil {
|
||||
slog.Error("watchlist: remove failed", "user", userID, "game", gameName, "error", err)
|
||||
h.reply(userID, "Something went wrong. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
if !removed {
|
||||
h.reply(userID, fmt.Sprintf("No watch found for \"%s\".", gameName))
|
||||
return
|
||||
}
|
||||
|
||||
h.reply(userID, fmt.Sprintf("Removed \"%s\" from your watchlist.", gameName))
|
||||
}
|
||||
|
||||
func (h *CommandHandler) handleExtend(userID id.UserID, gameName string) {
|
||||
if gameName == "" {
|
||||
h.reply(userID, "Usage: !extend <game name>")
|
||||
return
|
||||
}
|
||||
|
||||
extended, err := h.store.ExtendWatch(string(userID), gameName)
|
||||
if err != nil {
|
||||
slog.Error("watchlist: extend failed", "user", userID, "game", gameName, "error", err)
|
||||
h.reply(userID, "Something went wrong. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
if !extended {
|
||||
h.reply(userID, fmt.Sprintf("No watch found for \"%s\".", gameName))
|
||||
return
|
||||
}
|
||||
|
||||
expires := time.Now().Add(watchDuration)
|
||||
h.reply(userID, fmt.Sprintf("Extended \"%s\" — now expires %s.",
|
||||
gameName, expires.Format("January 2, 2006")))
|
||||
}
|
||||
|
||||
func (h *CommandHandler) handleList(userID id.UserID) {
|
||||
entries, err := h.store.ListWatches(string(userID))
|
||||
if err != nil {
|
||||
slog.Error("watchlist: list failed", "user", userID, "error", err)
|
||||
h.reply(userID, "Something went wrong. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
h.reply(userID, "Your watchlist is empty. Use !watch <game name> to add one.")
|
||||
return
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Your watchlist (%d):\n", len(entries)))
|
||||
for _, e := range entries {
|
||||
sb.WriteString(fmt.Sprintf(" - %s (expires %s)\n", e.GameName, e.ExpiresAt.Format("January 2, 2006")))
|
||||
}
|
||||
sb.WriteString("\nCommands: !watch, !unwatch, !extend, !watchlist")
|
||||
|
||||
h.reply(userID, sb.String())
|
||||
}
|
||||
|
||||
func (h *CommandHandler) handleHelp(userID id.UserID) {
|
||||
h.reply(userID, "Watchlist commands:\n"+
|
||||
" !watch <game name> — Watch for deals on a game\n"+
|
||||
" !unwatch <game name> — Remove a game from your watchlist\n"+
|
||||
" !extend <game name> — Reset the 180-day expiry timer\n"+
|
||||
" !watchlist — Show your current watches\n"+
|
||||
" !help — Show this message\n\n"+
|
||||
"Watches expire after 180 days. You'll get a reminder 7 days before.")
|
||||
}
|
||||
|
||||
func (h *CommandHandler) reply(userID id.UserID, text string) {
|
||||
if err := h.sender.SendDM(userID, text); err != nil {
|
||||
slog.Error("watchlist: failed to send DM", "user", userID, "error", err)
|
||||
}
|
||||
}
|
||||
170
internal/watchlist/watchlist.go
Normal file
170
internal/watchlist/watchlist.go
Normal file
@@ -0,0 +1,170 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user