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 <noreply@anthropic.com>
This commit is contained in:
174
cmd/migrate/main.go
Normal file
174
cmd/migrate/main.go
Normal file
@@ -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 <path>.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()
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -22,6 +23,33 @@ import (
|
|||||||
"github.com/prosolis/Pastel/internal/watchlist"
|
"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() {
|
func main() {
|
||||||
checkFlag := flag.Bool("check", false, "Run preflight checks and exit")
|
checkFlag := flag.Bool("check", false, "Run preflight checks and exit")
|
||||||
debugFlag := flag.Bool("debug", false, "Enable debug logging")
|
debugFlag := flag.Bool("debug", false, "Enable debug logging")
|
||||||
@@ -75,8 +103,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer mx.Stop()
|
defer mx.Stop()
|
||||||
|
|
||||||
|
// Pre-fetch exchange rates
|
||||||
|
conv := currency.NewConverter()
|
||||||
|
conv.EnsureRates()
|
||||||
|
|
||||||
// Set up watchlist command handler and register DM event handler
|
// 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)
|
dealsRoomID := id.RoomID(cfg.MatrixDealsRoomID)
|
||||||
mx.RegisterMessageHandler(func(senderID id.UserID, roomID id.RoomID, body string) {
|
mx.RegisterMessageHandler(func(senderID id.UserID, roomID id.RoomID, body string) {
|
||||||
// Only handle DMs, not messages in the deals room
|
// Only handle DMs, not messages in the deals room
|
||||||
@@ -89,10 +121,6 @@ func main() {
|
|||||||
// Start sync loop after handlers are registered
|
// Start sync loop after handlers are registered
|
||||||
mx.StartSync()
|
mx.StartSync()
|
||||||
|
|
||||||
// Pre-fetch exchange rates
|
|
||||||
conv := currency.NewConverter()
|
|
||||||
conv.EnsureRates()
|
|
||||||
|
|
||||||
// First-run: populate DB without posting
|
// First-run: populate DB without posting
|
||||||
firstRunDone, _ := db.IsFirstRunDone()
|
firstRunDone, _ := db.IsFirstRunDone()
|
||||||
if !firstRunDone {
|
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
|
posted := 0
|
||||||
for _, d := range filtered {
|
for _, d := range filtered {
|
||||||
already, err := db.IsPosted(d.DedupID)
|
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)
|
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)
|
slog.Error("failed to send cheapshark deal", "title", d.Title, "error", err)
|
||||||
continue
|
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)
|
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
|
posted := 0
|
||||||
for _, d := range filtered {
|
for _, d := range filtered {
|
||||||
already, err := db.IsPosted(d.DedupID)
|
already, err := db.IsPosted(d.DedupID)
|
||||||
@@ -356,8 +401,13 @@ func checkITADDeals(cfg *config.Config, db *database.DB, mx *matrix.Client, conv
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
threadID := gameThreadID
|
||||||
|
if strings.EqualFold(d.Type, "dlc") {
|
||||||
|
threadID = dlcThreadID
|
||||||
|
}
|
||||||
|
|
||||||
msg := formatter.FormatITADDeal(d, conv)
|
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)
|
slog.Error("failed to send itad deal", "title", d.Title, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -389,6 +439,12 @@ func checkEpicFreeGames(cfg *config.Config, db *database.DB, mx *matrix.Client,
|
|||||||
return
|
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
|
posted := 0
|
||||||
for _, g := range games {
|
for _, g := range games {
|
||||||
already, err := db.IsPosted(g.DedupID)
|
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)
|
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)
|
slog.Error("failed to send epic game", "title", g.Title, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ type DB struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Open(path string) (*DB, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := db.Ping(); err != nil {
|
if err := db.Ping(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
d := &DB{db: db}
|
d := &DB{db: db}
|
||||||
if err := d.migrate(); err != nil {
|
if err := d.migrate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -97,6 +100,22 @@ func (d *DB) SetFirstRunDone() error {
|
|||||||
return err
|
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.
|
// PruneOldDeals removes deals older than the given number of days.
|
||||||
func (d *DB) PruneOldDeals(days int) error {
|
func (d *DB) PruneOldDeals(days int) error {
|
||||||
cutoff := time.Now().AddDate(0, 0, -days).UTC().Format(time.RFC3339)
|
cutoff := time.Now().AddDate(0, 0, -days).UTC().Format(time.RFC3339)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,6 +101,59 @@ func FetchCheapSharkDeals(maxPrice float64, pageSize int) ([]CheapSharkDeal, err
|
|||||||
return deals, nil
|
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.
|
// FilterCheapSharkDeals filters deals by rating, discount, and price thresholds.
|
||||||
func FilterCheapSharkDeals(deals []CheapSharkDeal, minRating float64, minDiscount int, maxPrice float64) []CheapSharkDeal {
|
func FilterCheapSharkDeals(deals []CheapSharkDeal, minRating float64, minDiscount int, maxPrice float64) []CheapSharkDeal {
|
||||||
var filtered []CheapSharkDeal
|
var filtered []CheapSharkDeal
|
||||||
|
|||||||
@@ -124,6 +124,15 @@ func New(cfg ClientConfig) (*Client, error) {
|
|||||||
c.client = client
|
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
|
// Set up E2EE
|
||||||
if cfg.CryptoDBPath != "" {
|
if cfg.CryptoDBPath != "" {
|
||||||
ch, err := cryptohelper.NewCryptoHelper(c.client, []byte("pastel_pickle_key"), 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 {
|
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 {
|
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 {
|
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
|
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 {
|
func (c *Client) SendDeal(roomID, plainText, html string) error {
|
||||||
content := &event.MessageEventContent{
|
content := &event.MessageEventContent{
|
||||||
MsgType: event.MsgText,
|
MsgType: event.MsgText,
|
||||||
@@ -264,6 +279,43 @@ func (c *Client) SendDeal(roomID, plainText, html string) error {
|
|||||||
return nil
|
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).
|
// SendNotice sends a notice message to a room (non-highlighting).
|
||||||
func (c *Client) SendNotice(roomID, text string) error {
|
func (c *Client) SendNotice(roomID, text string) error {
|
||||||
content := &event.MessageEventContent{
|
content := &event.MessageEventContent{
|
||||||
|
|||||||
36
internal/matrix/sqlitedriver.go
Normal file
36
internal/matrix/sqlitedriver.go
Normal file
@@ -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{}})
|
||||||
|
}
|
||||||
@@ -3,10 +3,21 @@ package watchlist
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
"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.
|
// DMSender is the interface for sending DMs to users.
|
||||||
@@ -18,11 +29,46 @@ type DMSender interface {
|
|||||||
type CommandHandler struct {
|
type CommandHandler struct {
|
||||||
store *Store
|
store *Store
|
||||||
sender DMSender
|
sender DMSender
|
||||||
|
conv *currency.Converter
|
||||||
|
|
||||||
|
rateMu sync.Mutex
|
||||||
|
rateLimit map[id.UserID][]time.Time // search timestamps per user
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCommandHandler creates a new command handler.
|
// NewCommandHandler creates a new command handler.
|
||||||
func NewCommandHandler(store *Store, sender DMSender) *CommandHandler {
|
func NewCommandHandler(store *Store, sender DMSender, conv *currency.Converter) *CommandHandler {
|
||||||
return &CommandHandler{store: store, sender: sender}
|
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.
|
// HandleMessage parses and dispatches a DM command.
|
||||||
@@ -49,6 +95,8 @@ func (h *CommandHandler) HandleMessage(senderID, body string) {
|
|||||||
h.handleUnwatch(uid, args)
|
h.handleUnwatch(uid, args)
|
||||||
case "!extend":
|
case "!extend":
|
||||||
h.handleExtend(uid, args)
|
h.handleExtend(uid, args)
|
||||||
|
case "!search":
|
||||||
|
h.handleSearch(uid, args)
|
||||||
case "!watchlist":
|
case "!watchlist":
|
||||||
h.handleList(uid)
|
h.handleList(uid)
|
||||||
case "!help":
|
case "!help":
|
||||||
@@ -79,9 +127,15 @@ func (h *CommandHandler) handleWatch(userID id.UserID, gameName string) {
|
|||||||
gameName, expires.Format("January 2, 2006")))
|
gameName, expires.Format("January 2, 2006")))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *CommandHandler) handleUnwatch(userID id.UserID, gameName string) {
|
func (h *CommandHandler) handleUnwatch(userID id.UserID, args string) {
|
||||||
if gameName == "" {
|
if args == "" {
|
||||||
h.reply(userID, "Usage: !unwatch <game name>")
|
h.reply(userID, "Usage: !unwatch <# or game name>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gameName, err := h.resolveGameArg(userID, args)
|
||||||
|
if err != nil {
|
||||||
|
h.reply(userID, err.Error())
|
||||||
return
|
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))
|
h.reply(userID, fmt.Sprintf("Removed \"%s\" from your watchlist.", gameName))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *CommandHandler) handleExtend(userID id.UserID, gameName string) {
|
func (h *CommandHandler) handleExtend(userID id.UserID, args string) {
|
||||||
if gameName == "" {
|
if args == "" {
|
||||||
h.reply(userID, "Usage: !extend <game name>")
|
h.reply(userID, "Usage: !extend <# or game name>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gameName, err := h.resolveGameArg(userID, args)
|
||||||
|
if err != nil {
|
||||||
|
h.reply(userID, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,24 +198,84 @@ func (h *CommandHandler) handleList(userID id.UserID) {
|
|||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(fmt.Sprintf("Your watchlist (%d):\n", len(entries)))
|
sb.WriteString(fmt.Sprintf("Your watchlist (%d):\n", len(entries)))
|
||||||
for _, e := range entries {
|
for i, e := range entries {
|
||||||
sb.WriteString(fmt.Sprintf(" - %s (expires %s)\n", e.GameName, e.ExpiresAt.Format("January 2, 2006")))
|
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 <game name>")
|
||||||
|
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())
|
h.reply(userID, sb.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *CommandHandler) handleHelp(userID id.UserID) {
|
func (h *CommandHandler) handleHelp(userID id.UserID) {
|
||||||
h.reply(userID, "Watchlist commands:\n"+
|
h.reply(userID, "Commands:\n"+
|
||||||
|
" !search <game name> — Search for current deals\n"+
|
||||||
" !watch <game name> — Watch for deals on a game\n"+
|
" !watch <game name> — Watch for deals on a game\n"+
|
||||||
" !unwatch <game name> — Remove a game from your watchlist\n"+
|
" !unwatch <# or game name> — Remove a game from your watchlist\n"+
|
||||||
" !extend <game name> — Reset the 180-day expiry timer\n"+
|
" !extend <# or game name> — Reset the 180-day expiry timer\n"+
|
||||||
" !watchlist — Show your current watches\n"+
|
" !watchlist — Show your numbered watchlist\n"+
|
||||||
" !help — Show this message\n\n"+
|
" !help — Show this message\n\n"+
|
||||||
"Watches expire after 180 days. You'll get a reminder 7 days before.")
|
"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) {
|
func (h *CommandHandler) reply(userID id.UserID, text string) {
|
||||||
if err := h.sender.SendDM(userID, text); err != nil {
|
if err := h.sender.SendDM(userID, text); err != nil {
|
||||||
slog.Error("watchlist: failed to send DM", "user", userID, "error", err)
|
slog.Error("watchlist: failed to send DM", "user", userID, "error", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user