Add token auto-refresh, device persistence, and cross-signing
Following gogobee's auth pattern: if MATRIX_BOT_PASSWORD is set, the bot persists device credentials to device.json, validates tokens on startup, re-logs in automatically when tokens expire, and bootstraps cross-signing so the device shows as verified without manual emoji verification. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
MATRIX_HOMESERVER_URL=https://matrix.example.com
|
MATRIX_HOMESERVER_URL=https://matrix.example.com
|
||||||
MATRIX_BOT_USER_ID=@dealsbot:example.com
|
MATRIX_BOT_USER_ID=@dealsbot:example.com
|
||||||
MATRIX_BOT_ACCESS_TOKEN=syt_...
|
MATRIX_BOT_ACCESS_TOKEN=syt_...
|
||||||
|
# Optional: enables token auto-refresh, cross-signing, and device persistence
|
||||||
|
# MATRIX_BOT_PASSWORD=
|
||||||
MATRIX_DEALS_ROOM_ID=!roomid:example.com
|
MATRIX_DEALS_ROOM_ID=!roomid:example.com
|
||||||
|
|
||||||
# IsThereAnyDeal API key (optional — enables historical low detection and ITAD deals)
|
# IsThereAnyDeal API key (optional — enables historical low detection and ITAD deals)
|
||||||
@@ -46,5 +48,5 @@ MAX_PRICE=20
|
|||||||
# Send an intro message to the room on startup (true/false)
|
# Send an intro message to the room on startup (true/false)
|
||||||
SEND_INTRO_MESSAGE=false
|
SEND_INTRO_MESSAGE=false
|
||||||
|
|
||||||
# Database path (inside the container, mount a volume for persistence)
|
# Database path
|
||||||
DATABASE_PATH=/data/deals.db
|
DATABASE_PATH=deals.db
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
.env
|
.env
|
||||||
*.db
|
*.db
|
||||||
*.db.bak
|
*.db.bak
|
||||||
pastel
|
device.json
|
||||||
migrate
|
/pastel
|
||||||
|
/migrate
|
||||||
|
|||||||
450
cmd/pastel/main.go
Normal file
450
cmd/pastel/main.go
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"github.com/prosolis/Pastel/internal/config"
|
||||||
|
"github.com/prosolis/Pastel/internal/currency"
|
||||||
|
"github.com/prosolis/Pastel/internal/database"
|
||||||
|
"github.com/prosolis/Pastel/internal/deals"
|
||||||
|
"github.com/prosolis/Pastel/internal/formatter"
|
||||||
|
"github.com/prosolis/Pastel/internal/matrix"
|
||||||
|
"github.com/prosolis/Pastel/internal/preflight"
|
||||||
|
"github.com/prosolis/Pastel/internal/watchlist"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
checkFlag := flag.Bool("check", false, "Run preflight checks and exit")
|
||||||
|
debugFlag := flag.Bool("debug", false, "Enable debug logging")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if *debugFlag {
|
||||||
|
level = slog.LevelDebug
|
||||||
|
}
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to load config", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *checkFlag {
|
||||||
|
fmt.Println("Running preflight checks...")
|
||||||
|
results := preflight.Run(cfg)
|
||||||
|
if !preflight.PrintResults(results) {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("All checks passed.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open database
|
||||||
|
db, err := database.Open(cfg.DatabasePath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to open database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Initialize watchlist store
|
||||||
|
watchStore := watchlist.NewStore(db.RawDB())
|
||||||
|
|
||||||
|
// Initialize Matrix client with E2EE and auto-refresh
|
||||||
|
mx, err := matrix.New(matrix.ClientConfig{
|
||||||
|
HomeserverURL: cfg.MatrixHomeserverURL,
|
||||||
|
UserID: cfg.MatrixBotUserID,
|
||||||
|
AccessToken: cfg.MatrixBotAccessToken,
|
||||||
|
Password: cfg.MatrixBotPassword,
|
||||||
|
CryptoDBPath: "crypto.db",
|
||||||
|
DevicePath: "device.json",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to create matrix client", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer mx.Stop()
|
||||||
|
|
||||||
|
// Set up watchlist command handler and register DM event handler
|
||||||
|
cmdHandler := watchlist.NewCommandHandler(watchStore, mx)
|
||||||
|
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
|
||||||
|
if roomID == dealsRoomID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmdHandler.HandleMessage(string(senderID), body)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
slog.Info("first run detected — populating database without posting")
|
||||||
|
populateInitialState(cfg, db)
|
||||||
|
if err := db.SetFirstRunDone(); err != nil {
|
||||||
|
slog.Error("failed to set first run done", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send intro message
|
||||||
|
if cfg.SendIntroMessage {
|
||||||
|
if err := mx.SendNotice(cfg.MatrixDealsRoomID, "The deals must flow."); err != nil {
|
||||||
|
slog.Warn("failed to send intro message", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start presence heartbeat
|
||||||
|
mx.StartPresenceHeartbeat()
|
||||||
|
|
||||||
|
// Run initial checks
|
||||||
|
slog.Info("running initial deal checks")
|
||||||
|
if cfg.HasSource("cheapshark") {
|
||||||
|
checkCheapShark(cfg, db, mx, conv, watchStore)
|
||||||
|
}
|
||||||
|
if cfg.HasSource("itad") {
|
||||||
|
checkITADDeals(cfg, db, mx, conv, watchStore)
|
||||||
|
}
|
||||||
|
checkEpicFreeGames(cfg, db, mx, watchStore)
|
||||||
|
|
||||||
|
// Run initial expiry check
|
||||||
|
checkWatchlistExpiry(watchStore, mx)
|
||||||
|
|
||||||
|
// Start ticker goroutines
|
||||||
|
stop := make(chan struct{})
|
||||||
|
|
||||||
|
if cfg.HasSource("cheapshark") {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(2 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
checkCheapShark(cfg, db, mx, conv, watchStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.HasSource("itad") {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(2 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
checkITADDeals(cfg, db, mx, conv, watchStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
checkEpicFreeGames(cfg, db, mx, watchStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Watchlist expiry check — daily
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
checkWatchlistExpiry(watchStore, mx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
slog.Info("bot is running", "sources", cfg.DealSources)
|
||||||
|
|
||||||
|
// Wait for OS signal
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
|
||||||
|
slog.Info("shutting down")
|
||||||
|
close(stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func populateInitialState(cfg *config.Config, db *database.DB) {
|
||||||
|
// Record CheapShark deals without posting
|
||||||
|
if cfg.HasSource("cheapshark") {
|
||||||
|
rawDeals, err := deals.FetchCheapSharkDeals(cfg.MaxPriceUSD, 10)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("first run: cheapshark fetch failed", "error", err)
|
||||||
|
} else {
|
||||||
|
filtered := deals.FilterCheapSharkDeals(rawDeals, cfg.MinDealRating, cfg.MinDiscountPercent, cfg.MaxPriceUSD)
|
||||||
|
for _, d := range filtered {
|
||||||
|
_ = db.MarkPosted(d.DedupID, "cheapshark", d.Title)
|
||||||
|
}
|
||||||
|
slog.Info("first run: populated cheapshark deals", "count", len(filtered))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record ITAD deals without posting
|
||||||
|
if cfg.HasSource("itad") && cfg.ITADAPIKey != "" {
|
||||||
|
itadDeals, err := deals.FetchITADDeals(cfg.ITADAPIKey, 20)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("first run: itad fetch failed", "error", err)
|
||||||
|
} else {
|
||||||
|
filtered := deals.FilterITADDeals(itadDeals, cfg.MinDiscountPercent, cfg.MaxPriceUSD)
|
||||||
|
for _, d := range filtered {
|
||||||
|
_ = db.MarkPosted(d.DedupID, "itad", d.Title)
|
||||||
|
}
|
||||||
|
slog.Info("first run: populated itad deals", "count", len(filtered))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Epic games are intentionally NOT recorded on first run
|
||||||
|
// so they post immediately (few games, time-limited)
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyWatchlistMatches(ws *watchlist.Store, mx *matrix.Client, title, url, price string, discount int) {
|
||||||
|
matches, err := ws.FindMatchingUsers(title)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("watchlist match failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, m := range matches {
|
||||||
|
msg := formatter.FormatWatchlistNotification(m.GameName, title, url, price, discount)
|
||||||
|
if err := mx.SendDM(id.UserID(m.UserID), msg); err != nil {
|
||||||
|
slog.Error("failed to send watchlist DM", "user", m.UserID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyWatchlistFreeMatches(ws *watchlist.Store, mx *matrix.Client, title, url string) {
|
||||||
|
matches, err := ws.FindMatchingUsers(title)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("watchlist match failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, m := range matches {
|
||||||
|
msg := formatter.FormatWatchlistFreeNotification(m.GameName, title, url)
|
||||||
|
if err := mx.SendDM(id.UserID(m.UserID), msg); err != nil {
|
||||||
|
slog.Error("failed to send watchlist DM", "user", m.UserID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkCheapShark(cfg *config.Config, db *database.DB, mx *matrix.Client, conv *currency.Converter, ws *watchlist.Store) {
|
||||||
|
slog.Debug("checking cheapshark deals")
|
||||||
|
conv.EnsureRates()
|
||||||
|
|
||||||
|
rawDeals, err := deals.FetchCheapSharkDeals(cfg.MaxPriceUSD, 10)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("cheapshark fetch failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := deals.FilterCheapSharkDeals(rawDeals, cfg.MinDealRating, cfg.MinDiscountPercent, cfg.MaxPriceUSD)
|
||||||
|
|
||||||
|
// Look up historical lows if ITAD key is available
|
||||||
|
if cfg.ITADAPIKey != "" {
|
||||||
|
var steamIDs []string
|
||||||
|
steamIDSet := make(map[string]bool)
|
||||||
|
for _, d := range filtered {
|
||||||
|
if d.SteamAppID != "" && d.SteamAppID != "0" && !steamIDSet[d.SteamAppID] {
|
||||||
|
steamIDs = append(steamIDs, d.SteamAppID)
|
||||||
|
steamIDSet[d.SteamAppID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(steamIDs) > 0 {
|
||||||
|
lows, err := deals.LookupHistoricalLows(cfg.ITADAPIKey, steamIDs)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("historical low lookup failed", "error", err)
|
||||||
|
} else {
|
||||||
|
for i := range filtered {
|
||||||
|
if isLow, ok := lows[filtered[i].SteamAppID]; ok && isLow {
|
||||||
|
filtered[i].IsHistLow = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
posted := 0
|
||||||
|
for _, d := range filtered {
|
||||||
|
already, err := db.IsPosted(d.DedupID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("db check failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if already {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := formatter.FormatCheapSharkDeal(d, conv)
|
||||||
|
if err := mx.SendDeal(cfg.MatrixDealsRoomID, msg.Plain, msg.HTML); err != nil {
|
||||||
|
slog.Error("failed to send cheapshark deal", "title", d.Title, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.MarkPosted(d.DedupID, "cheapshark", d.Title); err != nil {
|
||||||
|
slog.Error("failed to mark deal posted", "error", err)
|
||||||
|
}
|
||||||
|
posted++
|
||||||
|
|
||||||
|
// Notify watchlist matches
|
||||||
|
notifyWatchlistMatches(ws, mx, d.Title, d.DealURL, conv.FormatPrice(d.SalePrice), int(math.Floor(d.Savings)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if posted > 0 {
|
||||||
|
slog.Info("posted cheapshark deals", "count", posted)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.PruneOldDeals(30); err != nil {
|
||||||
|
slog.Warn("failed to prune old deals", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkITADDeals(cfg *config.Config, db *database.DB, mx *matrix.Client, conv *currency.Converter, ws *watchlist.Store) {
|
||||||
|
if cfg.ITADAPIKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("checking itad deals")
|
||||||
|
conv.EnsureRates()
|
||||||
|
|
||||||
|
itadDeals, err := deals.FetchITADDeals(cfg.ITADAPIKey, 20)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("itad fetch failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := deals.FilterITADDeals(itadDeals, cfg.MinDiscountPercent, cfg.MaxPriceUSD)
|
||||||
|
|
||||||
|
posted := 0
|
||||||
|
for _, d := range filtered {
|
||||||
|
already, err := db.IsPosted(d.DedupID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("db check failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if already {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := formatter.FormatITADDeal(d, conv)
|
||||||
|
if err := mx.SendDeal(cfg.MatrixDealsRoomID, msg.Plain, msg.HTML); err != nil {
|
||||||
|
slog.Error("failed to send itad deal", "title", d.Title, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.MarkPosted(d.DedupID, "itad", d.Title); err != nil {
|
||||||
|
slog.Error("failed to mark deal posted", "error", err)
|
||||||
|
}
|
||||||
|
posted++
|
||||||
|
|
||||||
|
// Notify watchlist matches
|
||||||
|
notifyWatchlistMatches(ws, mx, d.Title, d.URL, conv.FormatPrice(d.Price), d.Discount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if posted > 0 {
|
||||||
|
slog.Info("posted itad deals", "count", posted)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.PruneOldDeals(30); err != nil {
|
||||||
|
slog.Warn("failed to prune old deals", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkEpicFreeGames(cfg *config.Config, db *database.DB, mx *matrix.Client, ws *watchlist.Store) {
|
||||||
|
slog.Debug("checking epic free games")
|
||||||
|
|
||||||
|
games, err := deals.FetchEpicFreeGames()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("epic fetch failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
posted := 0
|
||||||
|
for _, g := range games {
|
||||||
|
already, err := db.IsPosted(g.DedupID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("db check failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if already {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := formatter.FormatEpicFreeGame(g)
|
||||||
|
if err := mx.SendDeal(cfg.MatrixDealsRoomID, msg.Plain, msg.HTML); err != nil {
|
||||||
|
slog.Error("failed to send epic game", "title", g.Title, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.MarkPosted(g.DedupID, "epic", g.Title); err != nil {
|
||||||
|
slog.Error("failed to mark epic game posted", "error", err)
|
||||||
|
}
|
||||||
|
posted++
|
||||||
|
|
||||||
|
// Notify watchlist matches
|
||||||
|
notifyWatchlistFreeMatches(ws, mx, g.Title, g.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if posted > 0 {
|
||||||
|
slog.Info("posted epic games", "count", posted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkWatchlistExpiry(ws *watchlist.Store, mx *matrix.Client) {
|
||||||
|
// Warn users about entries expiring within 7 days
|
||||||
|
expiring, err := ws.GetExpiringWatches(7)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to check expiring watches", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range expiring {
|
||||||
|
msg := fmt.Sprintf("Your watch for \"%s\" expires on %s. Send !extend %s to keep it for another 180 days.",
|
||||||
|
e.GameName, e.ExpiresAt.Format("January 2, 2006"), e.GameName)
|
||||||
|
if err := mx.SendDM(id.UserID(e.UserID), msg); err != nil {
|
||||||
|
slog.Error("failed to send expiry warning", "user", e.UserID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := ws.MarkExpiryWarned(e.ID); err != nil {
|
||||||
|
slog.Error("failed to mark expiry warned", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge fully expired entries
|
||||||
|
purged, err := ws.PurgeExpired()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to purge expired watches", "error", err)
|
||||||
|
} else if purged > 0 {
|
||||||
|
slog.Info("purged expired watchlist entries", "count", purged)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ type Config struct {
|
|||||||
MatrixHomeserverURL string
|
MatrixHomeserverURL string
|
||||||
MatrixBotUserID string
|
MatrixBotUserID string
|
||||||
MatrixBotAccessToken string
|
MatrixBotAccessToken string
|
||||||
|
MatrixBotPassword string
|
||||||
MatrixDealsRoomID string
|
MatrixDealsRoomID string
|
||||||
ITADAPIKey string
|
ITADAPIKey string
|
||||||
DealSources []string
|
DealSources []string
|
||||||
@@ -42,6 +43,8 @@ func Load() (*Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
c.MatrixBotPassword = os.Getenv("MATRIX_BOT_PASSWORD")
|
||||||
|
|
||||||
c.MatrixDealsRoomID, err = require("MATRIX_DEALS_ROOM_ID")
|
c.MatrixDealsRoomID, err = require("MATRIX_DEALS_ROOM_ID")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package matrix
|
package matrix
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,65 +18,185 @@ import (
|
|||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DeviceInfo holds persisted device credentials for stable device identity
|
||||||
|
// across restarts.
|
||||||
|
type DeviceInfo struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientConfig holds the parameters needed to create a Matrix client.
|
||||||
|
type ClientConfig struct {
|
||||||
|
HomeserverURL string
|
||||||
|
UserID string
|
||||||
|
AccessToken string
|
||||||
|
Password string // optional: enables auto-refresh and cross-signing
|
||||||
|
CryptoDBPath string // e.g. "crypto.db", empty to skip E2EE
|
||||||
|
DevicePath string // e.g. "device.json", empty to skip persistence
|
||||||
|
}
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
client *mautrix.Client
|
client *mautrix.Client
|
||||||
crypto *cryptohelper.CryptoHelper
|
crypto *cryptohelper.CryptoHelper
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
syncCancel context.CancelFunc
|
syncCancel context.CancelFunc
|
||||||
hasE2EE bool
|
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
|
cfg ClientConfig
|
||||||
|
|
||||||
dmMu sync.Mutex
|
dmMu sync.Mutex
|
||||||
dmCache map[id.UserID]id.RoomID // userID -> DM room
|
dmCache map[id.UserID]id.RoomID
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Matrix client with E2EE support via cryptohelper.
|
// New creates a new Matrix client. If a password is provided, the client will:
|
||||||
// The cryptoDBPath is the path to the SQLite database for the crypto store
|
// - Persist device credentials to DevicePath for stable device identity
|
||||||
// (e.g. "crypto.db"). Call RegisterMessageHandler before StartSync to
|
// - Validate existing tokens on startup and re-login if expired
|
||||||
// receive DM events.
|
// - Set LoginAs on the cryptohelper so it can auto-refresh mid-operation
|
||||||
func New(homeserverURL, userID, accessToken, cryptoDBPath string) (*Client, error) {
|
// - Bootstrap cross-signing so the bot's device is automatically verified
|
||||||
uid := id.UserID(userID)
|
//
|
||||||
client, err := mautrix.NewClient(homeserverURL, uid, accessToken)
|
// If only an access token is provided (no password), the client works but
|
||||||
if err != nil {
|
// cannot auto-refresh — the token must be manually replaced if it expires.
|
||||||
return nil, fmt.Errorf("failed to create matrix client: %w", err)
|
func New(cfg ClientConfig) (*Client, error) {
|
||||||
}
|
uid := id.UserID(cfg.UserID)
|
||||||
|
accessToken := cfg.AccessToken
|
||||||
|
|
||||||
c := &Client{
|
c := &Client{
|
||||||
client: client,
|
cfg: cfg,
|
||||||
dmCache: make(map[id.UserID]id.RoomID),
|
dmCache: make(map[id.UserID]id.RoomID),
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cryptoDBPath != "" {
|
// If we have a device file and password, try to load persisted credentials
|
||||||
ch, err := cryptohelper.NewCryptoHelper(client, []byte("pastel_pickle_key"), cryptoDBPath)
|
if cfg.DevicePath != "" && cfg.Password != "" {
|
||||||
|
device, err := loadDevice(cfg.DevicePath)
|
||||||
|
if err == nil && device.AccessToken != "" {
|
||||||
|
if isTokenValid(cfg.HomeserverURL, device.AccessToken) {
|
||||||
|
slog.Info("existing device credentials valid", "device_id", device.DeviceID)
|
||||||
|
accessToken = device.AccessToken
|
||||||
|
|
||||||
|
client, err := mautrix.NewClient(cfg.HomeserverURL, id.UserID(device.UserID), device.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create client with existing token: %w", err)
|
||||||
|
}
|
||||||
|
client.DeviceID = id.DeviceID(device.DeviceID)
|
||||||
|
c.client = client
|
||||||
|
} else {
|
||||||
|
slog.Warn("existing device credentials expired, logging in again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we don't have a valid client yet, try password login or fall back to token
|
||||||
|
if c.client == nil && cfg.Password != "" {
|
||||||
|
loginResp, err := loginWithPassword(cfg.HomeserverURL, cfg.UserID, cfg.Password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("login: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := mautrix.NewClient(cfg.HomeserverURL, id.UserID(loginResp.UserID), loginResp.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create client after login: %w", err)
|
||||||
|
}
|
||||||
|
client.DeviceID = id.DeviceID(loginResp.DeviceID)
|
||||||
|
c.client = client
|
||||||
|
accessToken = loginResp.AccessToken
|
||||||
|
|
||||||
|
// Save device credentials for next restart
|
||||||
|
if cfg.DevicePath != "" {
|
||||||
|
if err := saveDevice(cfg.DevicePath, &DeviceInfo{
|
||||||
|
AccessToken: loginResp.AccessToken,
|
||||||
|
DeviceID: loginResp.DeviceID,
|
||||||
|
UserID: loginResp.UserID,
|
||||||
|
}); err != nil {
|
||||||
|
slog.Warn("failed to save device info", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("logged in with password", "user_id", loginResp.UserID, "device_id", loginResp.DeviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to bare access token (no password, no auto-refresh)
|
||||||
|
if c.client == nil {
|
||||||
|
client, err := mautrix.NewClient(cfg.HomeserverURL, uid, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create client: %w", err)
|
||||||
|
}
|
||||||
|
c.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up E2EE
|
||||||
|
if cfg.CryptoDBPath != "" {
|
||||||
|
ch, err := cryptohelper.NewCryptoHelper(c.client, []byte("pastel_pickle_key"), cfg.CryptoDBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("init crypto helper: %w", err)
|
return nil, fmt.Errorf("init crypto helper: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If password is available, enable auto-refresh on token expiry
|
||||||
|
if cfg.Password != "" {
|
||||||
|
ch.LoginAs = &mautrix.ReqLogin{
|
||||||
|
Type: mautrix.AuthTypePassword,
|
||||||
|
Identifier: mautrix.UserIdentifier{
|
||||||
|
Type: mautrix.IdentifierTypeUser,
|
||||||
|
User: cfg.UserID,
|
||||||
|
},
|
||||||
|
Password: cfg.Password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := ch.Init(context.Background()); err != nil {
|
if err := ch.Init(context.Background()); err != nil {
|
||||||
return nil, fmt.Errorf("crypto helper init: %w", err)
|
return nil, fmt.Errorf("crypto helper init: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
client.Crypto = ch
|
c.client.Crypto = ch
|
||||||
c.crypto = ch
|
c.crypto = ch
|
||||||
|
|
||||||
slog.Info("E2EE initialized", "device_id", client.DeviceID)
|
// Bootstrap cross-signing if password is available
|
||||||
|
if cfg.Password != "" {
|
||||||
|
c.bootstrapCrossSigning()
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("E2EE initialized", "device_id", c.client.DeviceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) bootstrapCrossSigning() {
|
||||||
|
mach := c.crypto.Machine()
|
||||||
|
|
||||||
|
_, _, err := mach.GenerateAndUploadCrossSigningKeys(context.Background(), func(ui *mautrix.RespUserInteractive) interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": mautrix.AuthTypePassword,
|
||||||
|
"identifier": map[string]interface{}{
|
||||||
|
"type": mautrix.IdentifierTypeUser,
|
||||||
|
"user": c.cfg.UserID,
|
||||||
|
},
|
||||||
|
"password": c.cfg.Password,
|
||||||
|
"session": ui.Session,
|
||||||
|
}
|
||||||
|
}, "")
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("cross-signing: key upload skipped (may already exist)", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mach.SignOwnDevice(context.Background(), mach.OwnIdentity()); err != nil {
|
||||||
|
slog.Debug("cross-signing: sign own device skipped", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mach.SignOwnMasterKey(context.Background()); err != nil {
|
||||||
|
slog.Debug("cross-signing: sign master key skipped", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterMessageHandler registers a callback for incoming messages.
|
// RegisterMessageHandler registers a callback for incoming messages.
|
||||||
// Must be called before StartSync. Ignores the bot's own messages and
|
// Must be called before StartSync. Ignores the bot's own messages and
|
||||||
// messages from before the bot started (avoids replaying history).
|
// messages from before the bot started (avoids replaying history).
|
||||||
func (c *Client) RegisterMessageHandler(fn func(senderID id.UserID, roomID id.RoomID, body string)) {
|
func (c *Client) RegisterMessageHandler(fn func(senderID id.UserID, roomID id.RoomID, body string)) {
|
||||||
syncer := c.client.Syncer.(*mautrix.DefaultSyncer)
|
syncer := c.client.Syncer.(*mautrix.DefaultSyncer)
|
||||||
syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) {
|
syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) {
|
||||||
// Ignore own messages
|
|
||||||
if evt.Sender == c.client.UserID {
|
if evt.Sender == c.client.UserID {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Ignore messages from before bot startup (history replay)
|
|
||||||
evtTime := time.UnixMilli(evt.Timestamp)
|
evtTime := time.UnixMilli(evt.Timestamp)
|
||||||
if evtTime.Before(c.startTime) {
|
if evtTime.Before(c.startTime) {
|
||||||
return
|
return
|
||||||
@@ -153,9 +278,6 @@ func (c *Client) SendNotice(roomID, text string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetDMRoom returns the DM room for a user, reusing an existing one if available.
|
// GetDMRoom returns the DM room for a user, reusing an existing one if available.
|
||||||
// Checks the in-memory cache first, then m.direct account data, and only creates
|
|
||||||
// a new encrypted room as a last resort. Follows gogobee's pattern to avoid
|
|
||||||
// opening duplicate DM rooms against the same user.
|
|
||||||
func (c *Client) GetDMRoom(userID id.UserID) (id.RoomID, error) {
|
func (c *Client) GetDMRoom(userID id.UserID) (id.RoomID, error) {
|
||||||
c.dmMu.Lock()
|
c.dmMu.Lock()
|
||||||
if roomID, ok := c.dmCache[userID]; ok {
|
if roomID, ok := c.dmCache[userID]; ok {
|
||||||
@@ -164,12 +286,11 @@ func (c *Client) GetDMRoom(userID id.UserID) (id.RoomID, error) {
|
|||||||
}
|
}
|
||||||
c.dmMu.Unlock()
|
c.dmMu.Unlock()
|
||||||
|
|
||||||
// Check m.direct account data for existing DM rooms
|
|
||||||
var dmRooms map[id.UserID][]id.RoomID
|
var dmRooms map[id.UserID][]id.RoomID
|
||||||
err := c.client.GetAccountData(context.Background(), "m.direct", &dmRooms)
|
err := c.client.GetAccountData(context.Background(), "m.direct", &dmRooms)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if rooms, ok := dmRooms[userID]; ok && len(rooms) > 0 {
|
if rooms, ok := dmRooms[userID]; ok && len(rooms) > 0 {
|
||||||
roomID := rooms[len(rooms)-1] // use most recent
|
roomID := rooms[len(rooms)-1]
|
||||||
c.dmMu.Lock()
|
c.dmMu.Lock()
|
||||||
c.dmCache[userID] = roomID
|
c.dmCache[userID] = roomID
|
||||||
c.dmMu.Unlock()
|
c.dmMu.Unlock()
|
||||||
@@ -177,7 +298,6 @@ func (c *Client) GetDMRoom(userID id.UserID) (id.RoomID, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No existing DM room — create an encrypted one
|
|
||||||
resp, err := c.client.CreateRoom(context.Background(), &mautrix.ReqCreateRoom{
|
resp, err := c.client.CreateRoom(context.Background(), &mautrix.ReqCreateRoom{
|
||||||
Preset: "trusted_private_chat",
|
Preset: "trusted_private_chat",
|
||||||
Invite: []id.UserID{userID},
|
Invite: []id.UserID{userID},
|
||||||
@@ -228,7 +348,6 @@ func (c *Client) StartPresenceHeartbeat() {
|
|||||||
ticker := time.NewTicker(60 * time.Second)
|
ticker := time.NewTicker(60 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
// Send immediately
|
|
||||||
c.sendPresence()
|
c.sendPresence()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -261,3 +380,75 @@ func (c *Client) Stop() {
|
|||||||
c.crypto.Close()
|
c.crypto.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- auth helpers ---
|
||||||
|
|
||||||
|
type loginResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginWithPassword(homeserver, user, password string) (*loginResponse, error) {
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"type": "m.login.password",
|
||||||
|
"identifier": map[string]string{
|
||||||
|
"type": "m.id.user",
|
||||||
|
"user": user,
|
||||||
|
},
|
||||||
|
"password": password,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Post(
|
||||||
|
homeserver+"/_matrix/client/v3/login", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("login request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("login failed (HTTP %d): %s", resp.StatusCode, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result loginResponse
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse login response: %w", err)
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTokenValid(homeserver, accessToken string) bool {
|
||||||
|
req, err := http.NewRequest("GET", homeserver+"/_matrix/client/v3/account/whoami", nil)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp.StatusCode == 200
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDevice(path string) (*DeviceInfo, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var info DeviceInfo
|
||||||
|
if err := json.Unmarshal(data, &info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveDevice(path string, info *DeviceInfo) error {
|
||||||
|
data, err := json.MarshalIndent(info, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ func PrintResults(results []Result) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkMatrix(cfg *config.Config) Result {
|
func checkMatrix(cfg *config.Config) Result {
|
||||||
client, err := matrix.New(cfg.MatrixHomeserverURL, cfg.MatrixBotUserID, cfg.MatrixBotAccessToken, "")
|
client, err := matrix.New(matrix.ClientConfig{
|
||||||
|
HomeserverURL: cfg.MatrixHomeserverURL,
|
||||||
|
UserID: cfg.MatrixBotUserID,
|
||||||
|
AccessToken: cfg.MatrixBotAccessToken,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{"Matrix", "fail", fmt.Sprintf("client error: %v", err)}
|
return Result{"Matrix", "fail", fmt.Sprintf("client error: %v", err)}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user