From d6c8d4f59923b8bcf220b0b2af98777685108f2f Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:35:23 -0700 Subject: [PATCH] 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 --- .env.example | 6 +- .gitignore | 5 +- cmd/pastel/main.go | 450 ++++++++++++++++++++++++++++++++ internal/config/config.go | 3 + internal/matrix/client.go | 243 +++++++++++++++-- internal/preflight/preflight.go | 6 +- 6 files changed, 682 insertions(+), 31 deletions(-) create mode 100644 cmd/pastel/main.go diff --git a/.env.example b/.env.example index 1e26def..cd77217 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ MATRIX_HOMESERVER_URL=https://matrix.example.com MATRIX_BOT_USER_ID=@dealsbot:example.com 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 # 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_INTRO_MESSAGE=false -# Database path (inside the container, mount a volume for persistence) -DATABASE_PATH=/data/deals.db +# Database path +DATABASE_PATH=deals.db diff --git a/.gitignore b/.gitignore index 87d9b6f..3597fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .env *.db *.db.bak -pastel -migrate +device.json +/pastel +/migrate diff --git a/cmd/pastel/main.go b/cmd/pastel/main.go new file mode 100644 index 0000000..9acd9e6 --- /dev/null +++ b/cmd/pastel/main.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 447d8a6..a76862d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,7 @@ type Config struct { MatrixHomeserverURL string MatrixBotUserID string MatrixBotAccessToken string + MatrixBotPassword string MatrixDealsRoomID string ITADAPIKey string DealSources []string @@ -42,6 +43,8 @@ func Load() (*Config, error) { if err != nil { return nil, err } + c.MatrixBotPassword = os.Getenv("MATRIX_BOT_PASSWORD") + c.MatrixDealsRoomID, err = require("MATRIX_DEALS_ROOM_ID") if err != nil { return nil, err diff --git a/internal/matrix/client.go b/internal/matrix/client.go index 5c5c146..bf7e9e5 100644 --- a/internal/matrix/client.go +++ b/internal/matrix/client.go @@ -1,9 +1,14 @@ package matrix import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "log/slog" + "net/http" + "os" "sync" "time" @@ -13,65 +18,185 @@ import ( "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 { client *mautrix.Client crypto *cryptohelper.CryptoHelper cancel context.CancelFunc syncCancel context.CancelFunc - hasE2EE bool startTime time.Time + cfg ClientConfig 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. -// The cryptoDBPath is the path to the SQLite database for the crypto store -// (e.g. "crypto.db"). Call RegisterMessageHandler before StartSync to -// receive DM events. -func New(homeserverURL, userID, accessToken, cryptoDBPath string) (*Client, error) { - uid := id.UserID(userID) - client, err := mautrix.NewClient(homeserverURL, uid, accessToken) - if err != nil { - return nil, fmt.Errorf("failed to create matrix client: %w", err) - } +// New creates a new Matrix client. If a password is provided, the client will: +// - Persist device credentials to DevicePath for stable device identity +// - Validate existing tokens on startup and re-login if expired +// - Set LoginAs on the cryptohelper so it can auto-refresh mid-operation +// - Bootstrap cross-signing so the bot's device is automatically verified +// +// If only an access token is provided (no password), the client works but +// cannot auto-refresh — the token must be manually replaced if it expires. +func New(cfg ClientConfig) (*Client, error) { + uid := id.UserID(cfg.UserID) + accessToken := cfg.AccessToken c := &Client{ - client: client, + cfg: cfg, dmCache: make(map[id.UserID]id.RoomID), startTime: time.Now(), } - if cryptoDBPath != "" { - ch, err := cryptohelper.NewCryptoHelper(client, []byte("pastel_pickle_key"), cryptoDBPath) + // If we have a device file and password, try to load persisted credentials + 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 { 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 { return nil, fmt.Errorf("crypto helper init: %w", err) } - client.Crypto = ch + c.client.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 } +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. // Must be called before StartSync. Ignores the bot's own messages and // messages from before the bot started (avoids replaying history). func (c *Client) RegisterMessageHandler(fn func(senderID id.UserID, roomID id.RoomID, body string)) { syncer := c.client.Syncer.(*mautrix.DefaultSyncer) syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) { - // Ignore own messages if evt.Sender == c.client.UserID { return } - // Ignore messages from before bot startup (history replay) evtTime := time.UnixMilli(evt.Timestamp) if evtTime.Before(c.startTime) { 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. -// 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) { c.dmMu.Lock() if roomID, ok := c.dmCache[userID]; ok { @@ -164,12 +286,11 @@ func (c *Client) GetDMRoom(userID id.UserID) (id.RoomID, error) { } c.dmMu.Unlock() - // Check m.direct account data for existing DM rooms var dmRooms map[id.UserID][]id.RoomID err := c.client.GetAccountData(context.Background(), "m.direct", &dmRooms) if err == nil { 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.dmCache[userID] = roomID 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{ Preset: "trusted_private_chat", Invite: []id.UserID{userID}, @@ -228,7 +348,6 @@ func (c *Client) StartPresenceHeartbeat() { ticker := time.NewTicker(60 * time.Second) defer ticker.Stop() - // Send immediately c.sendPresence() for { @@ -261,3 +380,75 @@ func (c *Client) Stop() { 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) +} diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index 8c3af03..262f13a 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -59,7 +59,11 @@ func PrintResults(results []Result) bool { } 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 { return Result{"Matrix", "fail", fmt.Sprintf("client error: %v", err)} }