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"
|
||||
"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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user