diff --git a/SESSION_PLAN.md b/SESSION_PLAN.md index 15f6528..b69806c 100644 --- a/SESSION_PLAN.md +++ b/SESSION_PLAN.md @@ -19,7 +19,7 @@ Rewriting Bellhop from a Python FastAPI web portal into a Go-based Matrix comman - `go.mod` (mautrix + yaml) - `internal/config/config.go` — YAML loader: matrix creds, `allowed_rooms`, `services.{radarr,sonarr,lidarr}` -- [ ] **Session 2 — Matrix client** (`internal/matrix/`) +- [x] **Session 2 — Matrix client** (`internal/matrix/`) - Adapt Pete's `client.go`: login + device persistence + cryptohelper - Sync loop, auto-join on invite - Message handler scoped to `allowed_rooms` (not channel-name map like Pete) diff --git a/go.mod b/go.mod index 7cebd18..d53ef71 100644 --- a/go.mod +++ b/go.mod @@ -6,3 +6,22 @@ require ( gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mautrix v0.28.0 ) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.44 // indirect + github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.mau.fi/util v0.9.9 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4edd1ee --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE= +go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ= +maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0= diff --git a/internal/matrix/client.go b/internal/matrix/client.go new file mode 100644 index 0000000..5e384a5 --- /dev/null +++ b/internal/matrix/client.go @@ -0,0 +1,316 @@ +package matrix + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "sync" + "time" + + "bellhop/internal/config" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/cryptohelper" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// MessageHandler is called when a text message is received in an allowlisted room. +type MessageHandler func(roomID id.RoomID, eventID id.EventID, sender id.UserID, body string) + +type deviceInfo struct { + AccessToken string `json:"access_token"` + DeviceID string `json:"device_id"` + UserID string `json:"user_id"` +} + +type Client struct { + mx *mautrix.Client + allowedRooms map[id.RoomID]struct{} + userID id.UserID + cfg config.MatrixConfig + crypto *cryptohelper.CryptoHelper + + mu sync.RWMutex + onMessage MessageHandler + cancelSync context.CancelFunc +} + +// New creates a Matrix client with password login, device persistence, and E2EE. +func New(cfg config.MatrixConfig) (*Client, error) { + if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + + devicePath := filepath.Join(cfg.DataDir, "device.json") + + device, err := loadDevice(devicePath) + if err != nil { + slog.Info("no existing device found, will login fresh") + } + + var mx *mautrix.Client + + if device != nil { + if isTokenValid(cfg.Homeserver, device.AccessToken) { + slog.Info("existing device credentials valid", "device_id", device.DeviceID) + mx, err = mautrix.NewClient(cfg.Homeserver, id.UserID(device.UserID), device.AccessToken) + if err != nil { + return nil, fmt.Errorf("create client with existing token: %w", err) + } + mx.DeviceID = id.DeviceID(device.DeviceID) + } else { + slog.Warn("existing device credentials invalid, logging in again") + device = nil + } + } + + if device == nil { + mx, err = mautrix.NewClient(cfg.Homeserver, "", "") + if err != nil { + return nil, fmt.Errorf("create client: %w", err) + } + + resp, err := mx.Login(context.Background(), &mautrix.ReqLogin{ + Type: mautrix.AuthTypePassword, + Identifier: mautrix.UserIdentifier{ + Type: mautrix.IdentifierTypeUser, + User: cfg.UserID, + }, + Password: cfg.Password, + InitialDeviceDisplayName: cfg.DisplayName, + }) + if err != nil { + return nil, fmt.Errorf("login: %w", err) + } + + mx.AccessToken = resp.AccessToken + mx.UserID = resp.UserID + mx.DeviceID = resp.DeviceID + + device = &deviceInfo{ + AccessToken: resp.AccessToken, + DeviceID: string(resp.DeviceID), + UserID: string(resp.UserID), + } + if err := saveDevice(devicePath, device); err != nil { + slog.Warn("failed to save device info", "err", err) + } + + slog.Info("logged in successfully", "user_id", resp.UserID, "device_id", resp.DeviceID) + } + + cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db") + ch, err := cryptohelper.NewCryptoHelper(mx, []byte(cfg.PickleKey), cryptoDBPath) + if err != nil { + return nil, fmt.Errorf("init crypto helper: %w", err) + } + + // Surface decrypt failures — the default silent no-op hides missing-megolm-session + // errors, which look like the bot ignoring commands. + ch.DecryptErrorCallback = func(evt *event.Event, err error) { + slog.Warn("matrix: failed to decrypt incoming event", + "room", evt.RoomID, "event_id", evt.ID, "sender", evt.Sender, "err", err) + } + + ch.LoginAs = &mautrix.ReqLogin{ + Type: mautrix.AuthTypePassword, + Identifier: mautrix.UserIdentifier{ + Type: mautrix.IdentifierTypeUser, + User: cfg.UserID, + }, + Password: cfg.Password, + InitialDeviceDisplayName: cfg.DisplayName, + } + + if err := ch.Init(context.Background()); err != nil { + return nil, fmt.Errorf("crypto helper init: %w", err) + } + + mx.Crypto = ch + + mach := ch.Machine() + existingKeys, err := mach.GetOwnCrossSigningPublicKeys(context.Background()) + if err != nil { + slog.Warn("cross-signing: failed to fetch existing keys", "err", err) + } + if existingKeys == nil || existingKeys.MasterKey == "" { + _, _, 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": cfg.UserID, + }, + "password": cfg.Password, + "session": ui.Session, + } + }, "") + if err != nil { + slog.Warn("cross-signing: key upload failed", "err", err) + } else { + slog.Info("cross-signing: keys uploaded") + } + if err := mach.SignOwnDevice(context.Background(), mach.OwnIdentity()); err != nil { + slog.Warn("cross-signing: sign own device failed", "err", err) + } + if err := mach.SignOwnMasterKey(context.Background()); err != nil { + slog.Warn("cross-signing: sign master key failed", "err", err) + } + } else { + slog.Info("cross-signing: already configured, skipping bootstrap") + } + + slog.Info("E2EE initialized", "device_id", mx.DeviceID) + + allowed := make(map[id.RoomID]struct{}, len(cfg.AllowedRooms)) + for _, r := range cfg.AllowedRooms { + allowed[id.RoomID(r)] = struct{}{} + } + + return &Client{ + mx: mx, + allowedRooms: allowed, + userID: mx.UserID, + cfg: cfg, + crypto: ch, + }, nil +} + +// SetMessageHandler registers a callback for text messages in allowlisted rooms. +func (c *Client) SetMessageHandler(fn MessageHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.onMessage = fn +} + +// Start begins the Matrix sync loop in the background. +func (c *Client) Start(ctx context.Context) { + syncer := c.mx.Syncer.(*mautrix.DefaultSyncer) + + syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) { + if evt.Sender == c.userID { + return + } + if _, ok := c.allowedRooms[evt.RoomID]; !ok { + return + } + msg := evt.Content.AsMessage() + if msg == nil || msg.MsgType != event.MsgText { + return + } + c.mu.RLock() + handler := c.onMessage + c.mu.RUnlock() + if handler != nil { + handler(evt.RoomID, evt.ID, evt.Sender, msg.Body) + } + }) + + // Auto-join on invite to any room — allowlist still gates message handling. + syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) { + if evt.GetStateKey() == string(c.userID) && evt.Content.AsMember().Membership == event.MembershipInvite { + if _, err := c.mx.JoinRoomByID(ctx, evt.RoomID); err != nil { + slog.Error("failed to auto-join room", "room", evt.RoomID, "err", err) + } else { + slog.Info("auto-joined room", "room", evt.RoomID) + } + } + }) + + syncCtx, cancel := context.WithCancel(ctx) + c.cancelSync = cancel + + go func() { + for { + err := c.mx.SyncWithContext(syncCtx) + if syncCtx.Err() != nil { + return + } + if err != nil { + slog.Error("matrix sync stopped, restarting in 5s", "err", err) + } else { + slog.Warn("matrix sync returned without error, restarting in 5s") + } + select { + case <-time.After(5 * time.Second): + case <-syncCtx.Done(): + return + } + } + }() +} + +// Stop halts the sync loop and closes the crypto store. +func (c *Client) Stop() { + if c.cancelSync != nil { + c.cancelSync() + } + if c.crypto != nil { + if err := c.crypto.Close(); err != nil { + slog.Error("failed to close crypto helper", "err", err) + } + } +} + +// PostThreadedReply sends a plain/HTML message into the thread rooted at rootEventID. +// IsFallingBack + m.in_reply_to gives non-thread-aware clients a sensible quoted reply. +func (c *Client) PostThreadedReply(roomID id.RoomID, rootEventID id.EventID, plain, htmlBody string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: plain, + Format: event.FormatHTML, + FormattedBody: htmlBody, + RelatesTo: &event.RelatesTo{ + Type: event.RelThread, + EventID: rootEventID, + InReplyTo: &event.InReplyTo{EventID: rootEventID}, + IsFallingBack: true, + }, + } + _, err := c.mx.SendMessageEvent(ctx, roomID, event.EventMessage, content) + return err +} + +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) +} + +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) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +}