From e452e36a636737d0a8d918d755eaab3cc673171d Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:54:15 -0700 Subject: [PATCH] Fix deployment issues and add threads, numbered watchlist, search URL encoding - Fix SQLite connection string (drop query param, use explicit PRAGMA) - Register sqlite3-fk-wal driver for mautrix cryptohelper (pure Go, no CGO) - Fetch DeviceID via /whoami when using bare access token - Log cross-signing results at Warn/Info instead of Debug - Post deals into Matrix threads: Game Deals, DLC Deals, Epic Free Games - Add numbered watchlist; !extend and !unwatch accept list numbers - URL-encode search queries for multi-word !search commands - Add GetConfig/SetConfig helpers for persistent thread event IDs Co-Authored-By: Claude Opus 4.6 --- cmd/migrate/main.go | 174 ++++++++++++++++++++++++++++++++ cmd/pastel/main.go | 72 +++++++++++-- internal/database/database.go | 21 +++- internal/deals/cheapshark.go | 54 ++++++++++ internal/matrix/client.go | 60 ++++++++++- internal/matrix/sqlitedriver.go | 36 +++++++ internal/watchlist/commands.go | 150 ++++++++++++++++++++++++--- 7 files changed, 539 insertions(+), 28 deletions(-) create mode 100644 cmd/migrate/main.go create mode 100644 internal/matrix/sqlitedriver.go diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..c346d42 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,174 @@ +// Command migrate converts a Pastel Python database to the Go format. +// +// The Python bot writes posted_at timestamps using SQLite's CURRENT_TIMESTAMP +// ("YYYY-MM-DD HH:MM:SS") and prunes with Python's isoformat() +// ("YYYY-MM-DDTHH:MM:SS+00:00"). The Go bot uses RFC 3339 ("YYYY-MM-DDTHH:MM:SSZ"). +// +// This script normalizes all posted_at values to RFC 3339 so the Go bot can use +// its native format consistently. It also creates any new tables (watchlist) that +// the Python version didn't have. +// +// Usage: +// +// go run ./cmd/migrate [path-to-deals.db] +// +// The database is modified in place. A backup is created at .bak first. +package main + +import ( + "database/sql" + "fmt" + "io" + "os" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +// Formats the Python bot may have written. +var parseFormats = []string{ + "2006-01-02 15:04:05", // SQLite CURRENT_TIMESTAMP + "2006-01-02T15:04:05+00:00", // Python datetime.isoformat() + "2006-01-02T15:04:05Z", // RFC 3339 (already correct) + "2006-01-02T15:04:05-07:00", // RFC 3339 with offset +} + +func main() { + dbPath := "deals.db" + if len(os.Args) > 1 { + dbPath = os.Args[1] + } + + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Database not found: %s\n", dbPath) + os.Exit(1) + } + + // Create backup + backupPath := dbPath + ".bak" + if err := copyFile(dbPath, backupPath); err != nil { + fmt.Fprintf(os.Stderr, "Failed to create backup: %v\n", err) + os.Exit(1) + } + fmt.Printf("Backup created: %s\n", backupPath) + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to open database: %v\n", err) + os.Exit(1) + } + defer db.Close() + + // Ensure new tables exist (watchlist, etc.) + if _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS watchlist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + game_name TEXT NOT NULL, + game_name_normalized TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + expiry_warned INTEGER DEFAULT 0, + UNIQUE(user_id, game_name_normalized) + ); + CREATE INDEX IF NOT EXISTS idx_watchlist_normalized ON watchlist(game_name_normalized); + CREATE INDEX IF NOT EXISTS idx_watchlist_expires ON watchlist(expires_at); + `); err != nil { + fmt.Fprintf(os.Stderr, "Failed to create new tables: %v\n", err) + os.Exit(1) + } + + // Convert posted_at timestamps + rows, err := db.Query("SELECT id, posted_at FROM posted_deals") + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to query deals: %v\n", err) + os.Exit(1) + } + + type update struct { + id string + converted string + } + var updates []update + + for rows.Next() { + var id, raw string + if err := rows.Scan(&id, &raw); err != nil { + continue + } + + converted, changed := normalizeTimestamp(raw) + if changed { + updates = append(updates, update{id, converted}) + } + } + rows.Close() + + if len(updates) == 0 { + fmt.Println("No timestamps need conversion. Database is already in Go format.") + return + } + + tx, err := db.Begin() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to begin transaction: %v\n", err) + os.Exit(1) + } + + stmt, err := tx.Prepare("UPDATE posted_deals SET posted_at = ? WHERE id = ?") + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to prepare statement: %v\n", err) + os.Exit(1) + } + + for _, u := range updates { + if _, err := stmt.Exec(u.converted, u.id); err != nil { + tx.Rollback() + fmt.Fprintf(os.Stderr, "Failed to update %s: %v\n", u.id, err) + os.Exit(1) + } + } + stmt.Close() + + if err := tx.Commit(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to commit: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Converted %d timestamps to RFC 3339.\n", len(updates)) + fmt.Println("Migration complete.") +} + +func normalizeTimestamp(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + + for _, layout := range parseFormats { + if t, err := time.Parse(layout, raw); err == nil { + rfc := t.UTC().Format(time.RFC3339) + return rfc, rfc != raw + } + } + + // Unparseable — leave as-is + return raw, false +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Sync() +} diff --git a/cmd/pastel/main.go b/cmd/pastel/main.go index 9acd9e6..b97ba82 100644 --- a/cmd/pastel/main.go +++ b/cmd/pastel/main.go @@ -7,6 +7,7 @@ import ( "math" "os" "os/signal" + "strings" "syscall" "time" @@ -22,6 +23,33 @@ import ( "github.com/prosolis/Pastel/internal/watchlist" ) +const ( + threadKeyGameDeals = "thread_game_deals" + threadKeyDLCDeals = "thread_dlc_deals" + threadKeyEpicFree = "thread_epic_free" +) + +// getOrCreateThread retrieves an existing thread event ID from the DB, +// or creates a new thread root message and stores its ID. +func getOrCreateThread(db *database.DB, mx *matrix.Client, roomID, dbKey, title string) (string, error) { + eventID, err := db.GetConfig(dbKey) + if err == nil && eventID != "" { + return eventID, nil + } + + eventID, err = mx.CreateThread(roomID, title) + if err != nil { + return "", err + } + + if err := db.SetConfig(dbKey, eventID); err != nil { + slog.Warn("failed to persist thread event ID", "key", dbKey, "error", err) + } + + slog.Info("created thread", "title", title, "event_id", eventID) + return eventID, nil +} + func main() { checkFlag := flag.Bool("check", false, "Run preflight checks and exit") debugFlag := flag.Bool("debug", false, "Enable debug logging") @@ -75,8 +103,12 @@ func main() { } defer mx.Stop() + // Pre-fetch exchange rates + conv := currency.NewConverter() + conv.EnsureRates() + // Set up watchlist command handler and register DM event handler - cmdHandler := watchlist.NewCommandHandler(watchStore, mx) + cmdHandler := watchlist.NewCommandHandler(watchStore, mx, conv) dealsRoomID := id.RoomID(cfg.MatrixDealsRoomID) mx.RegisterMessageHandler(func(senderID id.UserID, roomID id.RoomID, body string) { // Only handle DMs, not messages in the deals room @@ -89,10 +121,6 @@ func main() { // Start sync loop after handlers are registered mx.StartSync() - // Pre-fetch exchange rates - conv := currency.NewConverter() - conv.EnsureRates() - // First-run: populate DB without posting firstRunDone, _ := db.IsFirstRunDone() if !firstRunDone { @@ -294,6 +322,12 @@ func checkCheapShark(cfg *config.Config, db *database.DB, mx *matrix.Client, con } } + threadID, err := getOrCreateThread(db, mx, cfg.MatrixDealsRoomID, threadKeyGameDeals, "Game Deals") + if err != nil { + slog.Error("failed to get/create game deals thread", "error", err) + return + } + posted := 0 for _, d := range filtered { already, err := db.IsPosted(d.DedupID) @@ -306,7 +340,7 @@ func checkCheapShark(cfg *config.Config, db *database.DB, mx *matrix.Client, con } msg := formatter.FormatCheapSharkDeal(d, conv) - if err := mx.SendDeal(cfg.MatrixDealsRoomID, msg.Plain, msg.HTML); err != nil { + if err := mx.SendDealInThread(cfg.MatrixDealsRoomID, threadID, msg.Plain, msg.HTML); err != nil { slog.Error("failed to send cheapshark deal", "title", d.Title, "error", err) continue } @@ -345,6 +379,17 @@ func checkITADDeals(cfg *config.Config, db *database.DB, mx *matrix.Client, conv filtered := deals.FilterITADDeals(itadDeals, cfg.MinDiscountPercent, cfg.MaxPriceUSD) + gameThreadID, err := getOrCreateThread(db, mx, cfg.MatrixDealsRoomID, threadKeyGameDeals, "Game Deals") + if err != nil { + slog.Error("failed to get/create game deals thread", "error", err) + return + } + dlcThreadID, err := getOrCreateThread(db, mx, cfg.MatrixDealsRoomID, threadKeyDLCDeals, "DLC Deals") + if err != nil { + slog.Error("failed to get/create dlc deals thread", "error", err) + return + } + posted := 0 for _, d := range filtered { already, err := db.IsPosted(d.DedupID) @@ -356,8 +401,13 @@ func checkITADDeals(cfg *config.Config, db *database.DB, mx *matrix.Client, conv continue } + threadID := gameThreadID + if strings.EqualFold(d.Type, "dlc") { + threadID = dlcThreadID + } + msg := formatter.FormatITADDeal(d, conv) - if err := mx.SendDeal(cfg.MatrixDealsRoomID, msg.Plain, msg.HTML); err != nil { + if err := mx.SendDealInThread(cfg.MatrixDealsRoomID, threadID, msg.Plain, msg.HTML); err != nil { slog.Error("failed to send itad deal", "title", d.Title, "error", err) continue } @@ -389,6 +439,12 @@ func checkEpicFreeGames(cfg *config.Config, db *database.DB, mx *matrix.Client, return } + threadID, err := getOrCreateThread(db, mx, cfg.MatrixDealsRoomID, threadKeyEpicFree, "Epic Free Games") + if err != nil { + slog.Error("failed to get/create epic free games thread", "error", err) + return + } + posted := 0 for _, g := range games { already, err := db.IsPosted(g.DedupID) @@ -401,7 +457,7 @@ func checkEpicFreeGames(cfg *config.Config, db *database.DB, mx *matrix.Client, } msg := formatter.FormatEpicFreeGame(g) - if err := mx.SendDeal(cfg.MatrixDealsRoomID, msg.Plain, msg.HTML); err != nil { + if err := mx.SendDealInThread(cfg.MatrixDealsRoomID, threadID, msg.Plain, msg.HTML); err != nil { slog.Error("failed to send epic game", "title", g.Title, "error", err) continue } diff --git a/internal/database/database.go b/internal/database/database.go index 037c14c..8ed84bc 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -12,13 +12,16 @@ type DB struct { } func Open(path string) (*DB, error) { - db, err := sqlx.Open("sqlite", path+"?_pragma=journal_mode(WAL)") + db, err := sqlx.Open("sqlite", path) if err != nil { return nil, err } if err := db.Ping(); err != nil { return nil, err } + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + return nil, err + } d := &DB{db: db} if err := d.migrate(); err != nil { return nil, err @@ -97,6 +100,22 @@ func (d *DB) SetFirstRunDone() error { return err } +// GetConfig retrieves a value from the config table. +func (d *DB) GetConfig(key string) (string, error) { + var val string + err := d.db.Get(&val, "SELECT value FROM config WHERE key = ?", key) + return val, err +} + +// SetConfig sets a value in the config table. +func (d *DB) SetConfig(key, value string) error { + _, err := d.db.Exec( + "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", + key, value, + ) + return err +} + // PruneOldDeals removes deals older than the given number of days. func (d *DB) PruneOldDeals(days int) error { cutoff := time.Now().AddDate(0, 0, -days).UTC().Format(time.RFC3339) diff --git a/internal/deals/cheapshark.go b/internal/deals/cheapshark.go index d236c3c..70290d9 100644 --- a/internal/deals/cheapshark.go +++ b/internal/deals/cheapshark.go @@ -6,6 +6,7 @@ import ( "log/slog" "math" "net/http" + "net/url" "strconv" ) @@ -100,6 +101,59 @@ func FetchCheapSharkDeals(maxPrice float64, pageSize int) ([]CheapSharkDeal, err return deals, nil } +// SearchCheapSharkDeals searches for current deals matching a title query. +func SearchCheapSharkDeals(query string, maxResults int) ([]CheapSharkDeal, error) { + url := fmt.Sprintf( + "https://www.cheapshark.com/api/1.0/deals?storeID=1,7,11,23&sortBy=savings&desc=1&pageSize=%d&title=%s", + maxResults, url.QueryEscape(query), + ) + + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("cheapshark search 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 results []CheapSharkDeal + for _, d := range raw { + sale, _ := strconv.ParseFloat(d.SalePrice, 64) + normal, _ := strconv.ParseFloat(d.NormalPrice, 64) + savings, _ := strconv.ParseFloat(d.Savings, 64) + + if savings <= 0 { + continue + } + + storeName := storeNames[d.StoreID] + if storeName == "" { + storeName = "Unknown" + } + + results = append(results, CheapSharkDeal{ + DealID: d.DealID, + GameID: d.GameID, + Title: d.Title, + SalePrice: sale, + NormalPrice: normal, + Savings: savings, + StoreID: d.StoreID, + StoreName: storeName, + DealURL: fmt.Sprintf("https://www.cheapshark.com/redirect?dealID=%s", d.DealID), + }) + } + + return results, nil +} + // FilterCheapSharkDeals filters deals by rating, discount, and price thresholds. func FilterCheapSharkDeals(deals []CheapSharkDeal, minRating float64, minDiscount int, maxPrice float64) []CheapSharkDeal { var filtered []CheapSharkDeal diff --git a/internal/matrix/client.go b/internal/matrix/client.go index bf7e9e5..06f8e96 100644 --- a/internal/matrix/client.go +++ b/internal/matrix/client.go @@ -124,6 +124,15 @@ func New(cfg ClientConfig) (*Client, error) { c.client = client } + // Ensure DeviceID is set (required by cryptohelper) + if c.client.DeviceID == "" { + resp, err := c.client.Whoami(context.Background()) + if err != nil { + return nil, fmt.Errorf("whoami failed (needed for device ID): %w", err) + } + c.client.DeviceID = resp.DeviceID + } + // Set up E2EE if cfg.CryptoDBPath != "" { ch, err := cryptohelper.NewCryptoHelper(c.client, []byte("pastel_pickle_key"), cfg.CryptoDBPath) @@ -176,15 +185,21 @@ func (c *Client) bootstrapCrossSigning() { } }, "") if err != nil { - slog.Debug("cross-signing: key upload skipped (may already exist)", "error", err) + slog.Warn("cross-signing: key upload failed (may already exist)", "error", err) + } else { + slog.Info("cross-signing: keys uploaded") } if err := mach.SignOwnDevice(context.Background(), mach.OwnIdentity()); err != nil { - slog.Debug("cross-signing: sign own device skipped", "error", err) + slog.Warn("cross-signing: sign own device failed", "error", err) + } else { + slog.Info("cross-signing: own device signed") } if err := mach.SignOwnMasterKey(context.Background()); err != nil { - slog.Debug("cross-signing: sign master key skipped", "error", err) + slog.Warn("cross-signing: sign master key failed", "error", err) + } else { + slog.Info("cross-signing: master key signed") } } @@ -249,7 +264,7 @@ func (c *Client) JoinedRooms() ([]string, error) { return rooms, nil } -// SendDeal sends a formatted deal message to a room. +// SendDeal sends a formatted deal message to a room (top-level, no thread). func (c *Client) SendDeal(roomID, plainText, html string) error { content := &event.MessageEventContent{ MsgType: event.MsgText, @@ -264,6 +279,43 @@ func (c *Client) SendDeal(roomID, plainText, html string) error { return nil } +// CreateThread sends a root message and returns its event ID for use as a thread root. +func (c *Client) CreateThread(roomID, text string) (string, error) { + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: text, + } + resp, err := c.client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, content) + if err != nil { + return "", fmt.Errorf("failed to create thread: %w", err) + } + return resp.EventID.String(), nil +} + +// SendDealInThread sends a formatted deal message as a reply in a thread. +func (c *Client) SendDealInThread(roomID, threadEventID, plainText, html string) error { + evtID := id.EventID(threadEventID) + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: plainText, + Format: event.FormatHTML, + FormattedBody: html, + RelatesTo: &event.RelatesTo{ + Type: event.RelThread, + EventID: evtID, + InReplyTo: &event.InReplyTo{ + EventID: evtID, + }, + IsFallingBack: true, + }, + } + _, err := c.client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, content) + if err != nil { + return fmt.Errorf("failed to send threaded deal: %w", err) + } + return nil +} + // SendNotice sends a notice message to a room (non-highlighting). func (c *Client) SendNotice(roomID, text string) error { content := &event.MessageEventContent{ diff --git a/internal/matrix/sqlitedriver.go b/internal/matrix/sqlitedriver.go new file mode 100644 index 0000000..27cbe3f --- /dev/null +++ b/internal/matrix/sqlitedriver.go @@ -0,0 +1,36 @@ +package matrix + +import ( + "database/sql" + "database/sql/driver" + + sqlite "modernc.org/sqlite" +) + +type fkWALDriver struct { + inner *sqlite.Driver +} + +func (d *fkWALDriver) Open(name string) (driver.Conn, error) { + conn, err := d.inner.Open(name) + if err != nil { + return nil, err + } + execer := conn.(driver.Execer) + for _, pragma := range []string{ + "PRAGMA foreign_keys = ON", + "PRAGMA journal_mode = WAL", + "PRAGMA synchronous = NORMAL", + "PRAGMA busy_timeout = 5000", + } { + if _, err := execer.Exec(pragma, nil); err != nil { + conn.Close() + return nil, err + } + } + return conn, nil +} + +func init() { + sql.Register("sqlite3-fk-wal", &fkWALDriver{inner: &sqlite.Driver{}}) +} diff --git a/internal/watchlist/commands.go b/internal/watchlist/commands.go index ad21936..3cd7bfc 100644 --- a/internal/watchlist/commands.go +++ b/internal/watchlist/commands.go @@ -3,10 +3,21 @@ package watchlist import ( "fmt" "log/slog" + "math" + "strconv" "strings" + "sync" "time" "maunium.net/go/mautrix/id" + + "github.com/prosolis/Pastel/internal/currency" + "github.com/prosolis/Pastel/internal/deals" +) + +const ( + searchRateLimit = 5 // max searches per window + searchRateWindow = 10 * time.Minute // sliding window ) // DMSender is the interface for sending DMs to users. @@ -18,11 +29,46 @@ type DMSender interface { type CommandHandler struct { store *Store sender DMSender + conv *currency.Converter + + rateMu sync.Mutex + rateLimit map[id.UserID][]time.Time // search timestamps per user } // NewCommandHandler creates a new command handler. -func NewCommandHandler(store *Store, sender DMSender) *CommandHandler { - return &CommandHandler{store: store, sender: sender} +func NewCommandHandler(store *Store, sender DMSender, conv *currency.Converter) *CommandHandler { + return &CommandHandler{ + store: store, + sender: sender, + conv: conv, + rateLimit: make(map[id.UserID][]time.Time), + } +} + +// checkSearchRate returns true if the user is within the rate limit. +func (h *CommandHandler) checkSearchRate(userID id.UserID) bool { + h.rateMu.Lock() + defer h.rateMu.Unlock() + + now := time.Now() + cutoff := now.Add(-searchRateWindow) + + // Prune old entries + recent := h.rateLimit[userID] + filtered := recent[:0] + for _, t := range recent { + if t.After(cutoff) { + filtered = append(filtered, t) + } + } + + if len(filtered) >= searchRateLimit { + h.rateLimit[userID] = filtered + return false + } + + h.rateLimit[userID] = append(filtered, now) + return true } // HandleMessage parses and dispatches a DM command. @@ -49,6 +95,8 @@ func (h *CommandHandler) HandleMessage(senderID, body string) { h.handleUnwatch(uid, args) case "!extend": h.handleExtend(uid, args) + case "!search": + h.handleSearch(uid, args) case "!watchlist": h.handleList(uid) case "!help": @@ -79,9 +127,15 @@ func (h *CommandHandler) handleWatch(userID id.UserID, gameName string) { gameName, expires.Format("January 2, 2006"))) } -func (h *CommandHandler) handleUnwatch(userID id.UserID, gameName string) { - if gameName == "" { - h.reply(userID, "Usage: !unwatch ") +func (h *CommandHandler) handleUnwatch(userID id.UserID, args string) { + if args == "" { + h.reply(userID, "Usage: !unwatch <# or game name>") + return + } + + gameName, err := h.resolveGameArg(userID, args) + if err != nil { + h.reply(userID, err.Error()) return } @@ -100,9 +154,15 @@ func (h *CommandHandler) handleUnwatch(userID id.UserID, gameName string) { 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 ") +func (h *CommandHandler) handleExtend(userID id.UserID, args string) { + if args == "" { + h.reply(userID, "Usage: !extend <# or game name>") + return + } + + gameName, err := h.resolveGameArg(userID, args) + if err != nil { + h.reply(userID, err.Error()) return } @@ -138,24 +198,84 @@ func (h *CommandHandler) handleList(userID id.UserID) { 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"))) + for i, e := range entries { + sb.WriteString(fmt.Sprintf(" %d. %s (expires %s)\n", i+1, e.GameName, e.ExpiresAt.Format("January 2, 2006"))) + } + sb.WriteString("\nUse !extend <#> or !unwatch <#> with the number above.") + + h.reply(userID, sb.String()) +} + +func (h *CommandHandler) handleSearch(userID id.UserID, query string) { + if query == "" { + h.reply(userID, "Usage: !search ") + return + } + + if !h.checkSearchRate(userID) { + h.reply(userID, fmt.Sprintf("Rate limited — max %d searches per %d minutes. Try again shortly.", + searchRateLimit, int(searchRateWindow.Minutes()))) + return + } + + h.conv.EnsureRates() + + results, err := deals.SearchCheapSharkDeals(query, 5) + if err != nil { + slog.Error("search: cheapshark failed", "query", query, "error", err) + h.reply(userID, "Search failed. Please try again later.") + return + } + + if len(results) == 0 { + h.reply(userID, fmt.Sprintf("No current deals found for \"%s\".", query)) + return + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Deals matching \"%s\":\n\n", query)) + for _, d := range results { + discount := int(math.Floor(d.Savings)) + price := h.conv.FormatPrice(d.SalePrice) + sb.WriteString(fmt.Sprintf(" %s\n", d.Title)) + sb.WriteString(fmt.Sprintf(" %d%% off on %s — %s\n", discount, d.StoreName, price)) + sb.WriteString(fmt.Sprintf(" %s\n\n", d.DealURL)) } - 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"+ + h.reply(userID, "Commands:\n"+ + " !search — Search for current deals\n"+ " !watch — Watch for deals on a game\n"+ - " !unwatch — Remove a game from your watchlist\n"+ - " !extend — Reset the 180-day expiry timer\n"+ - " !watchlist — Show your current watches\n"+ + " !unwatch <# or game name> — Remove a game from your watchlist\n"+ + " !extend <# or game name> — Reset the 180-day expiry timer\n"+ + " !watchlist — Show your numbered watchlist\n"+ " !help — Show this message\n\n"+ "Watches expire after 180 days. You'll get a reminder 7 days before.") } +// resolveGameArg resolves an argument that is either a list number (from !watchlist) +// or a game name. Returns the game name. +func (h *CommandHandler) resolveGameArg(userID id.UserID, arg string) (string, error) { + num, err := strconv.Atoi(strings.TrimSpace(arg)) + if err != nil { + return arg, nil // not a number, treat as game name + } + + entries, err := h.store.ListWatches(string(userID)) + if err != nil { + return "", fmt.Errorf("Something went wrong. Please try again.") + } + + if num < 1 || num > len(entries) { + return "", fmt.Errorf("Invalid number. Use !watchlist to see your list (1-%d).", len(entries)) + } + + return entries[num-1].GameName, nil +} + 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)