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

View File

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

View File

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

View File

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

View 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{}})
}

View File

@@ -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 <game name>")
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 <game name>")
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 <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())
}
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"+
" !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"+
" !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)