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:
prosolis
2026-03-17 20:54:15 -07:00
parent d6c8d4f599
commit e452e36a63
7 changed files with 539 additions and 28 deletions

174
cmd/migrate/main.go Normal file
View 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()
}

View File

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