feat: chat E2EE MVP - rooms list + timeline + composer + sync (issues 0148+0149+0150)
Backend extends MatrixService with Start()/Stop()/ListRooms()/LoadTimeline()/
SendText()/SendMarkdown(). On login the service initialises the crypto store
(cryptohelper, Olm/Megolm via goolm build tag) and a sync loop that fans
events out through Wails events ("matrix:event", "matrix:error"). Pickle
key is 32 random bytes hex-encoded in the OS keyring alongside the access
token, so the crypto SQLite store survives restarts.
Vendors 4 fresh helpers from fn_registry/functions/infra/:
matrix_crypto_init.go (//go:build goolm || libolm)
matrix_sync_service.go
matrix_message_send.go
matrix_room_list.go
Plus the existing 3 (mas_oidc_loopback, keyring_token_store, matrix_client_init).
go-sqlite3 driver pulled explicitly via sqlite_driver.go.
Frontend rewires HomeScreen as a 3-zone AppShell (sidebar / timeline /
composer). useMatrixRooms polls + reacts to the sync stream; useMatrixTimeline
loads the last 50 events of the selected room and appends live ones. New
components: RoomList, Timeline, EventBubble, Composer. Composer supports
plain text (default) and a markdown toggle; Enter sends, Shift+Enter newline.
wails.json now passes "build:tags": "goolm" by default. Tested with
wails build -tags goolm on linux/amd64 and windows/amd64.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+290
-30
@@ -2,22 +2,30 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
"fn-registry/projects/element_agents/apps/matrix_client_pc/internal/infra"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Constants are operator-configurable later via settings UI. Hardcoded for issue 0147 MVP.
|
||||
const (
|
||||
homeserverURL = "https://matrix-af2f3d.organic-machine.com"
|
||||
masIssuer = "https://auth-af2f3d.organic-machine.com/"
|
||||
masClientID = "VDC4XQ2ZKN2TJ0BYVJ54FK7M6Y"
|
||||
loopbackPort = 8765
|
||||
keyringServiceName = "fn_registry.matrix_client_pc"
|
||||
oidcTimeoutSeconds = 300
|
||||
homeserverURL = "https://matrix-af2f3d.organic-machine.com"
|
||||
masIssuer = "https://auth-af2f3d.organic-machine.com/"
|
||||
masClientID = "VDC4XQ2ZKN2TJ0BYVJ54FK7M6Y"
|
||||
loopbackPort = 8765
|
||||
keyringServiceName = "fn_registry.matrix_client_pc"
|
||||
oidcTimeoutSeconds = 300
|
||||
)
|
||||
|
||||
var defaultScopes = []string{
|
||||
@@ -27,9 +35,13 @@ var defaultScopes = []string{
|
||||
|
||||
// MatrixService is bound to the Wails frontend.
|
||||
type MatrixService struct {
|
||||
ctx context.Context
|
||||
mu sync.Mutex
|
||||
store *infra.KeyringTokenStore
|
||||
ctx context.Context
|
||||
mu sync.Mutex
|
||||
store *infra.KeyringTokenStore
|
||||
client *mautrix.Client
|
||||
sync *infra.MatrixSyncServiceHandle
|
||||
crypto *infra.MatrixCryptoInitResult
|
||||
userID string
|
||||
}
|
||||
|
||||
func NewMatrixService() *MatrixService {
|
||||
@@ -51,6 +63,28 @@ type SessionView struct {
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// MatrixEvent is the exportable, JSON-friendly shape of a Matrix event for the frontend.
|
||||
type MatrixEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
RoomID string `json:"room_id"`
|
||||
Sender string `json:"sender"`
|
||||
Type string `json:"type"`
|
||||
Ts int64 `json:"ts"`
|
||||
Body string `json:"body,omitempty"`
|
||||
EncryptedRaw bool `json:"encrypted_raw"`
|
||||
}
|
||||
|
||||
// SyncEventView is what we emit through Wails events. Mirrors MatrixSyncEvent but without
|
||||
// the raw event pointer that wouldn't survive JSON serialization.
|
||||
type SyncEventView struct {
|
||||
Type string `json:"type"`
|
||||
RoomID string `json:"room_id"`
|
||||
EventID string `json:"event_id"`
|
||||
Sender string `json:"sender"`
|
||||
Ts int64 `json:"ts"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// Login launches the OAuth2 PKCE flow against MAS. Blocks until completion or timeout.
|
||||
// Returns the user_id of the authenticated session.
|
||||
func (s *MatrixService) Login() (string, error) {
|
||||
@@ -70,29 +104,20 @@ func (s *MatrixService) Login() (string, error) {
|
||||
return "", fmt.Errorf("oidc: %w", err)
|
||||
}
|
||||
|
||||
// Initialize Matrix client to discover user_id + device_id via whoami.
|
||||
tmpStore := tempStoreDir()
|
||||
clientCfg := infra.MatrixClientInitConfig{
|
||||
HomeserverURL: homeserverURL,
|
||||
// UserID is unknown until whoami. mautrix-go requires it pre-set, but we'll
|
||||
// use Whoami via the Wails service directly. As shortcut: parse id_token if present.
|
||||
// For v0.1.0 use a placeholder + Whoami after; mautrix accepts empty UserID, then
|
||||
// updates after whoami call.
|
||||
UserID: "",
|
||||
AccessToken: res.AccessToken,
|
||||
StoreDir: tmpStore,
|
||||
EnableCrypto: false,
|
||||
}
|
||||
|
||||
// Pre-fetch user_id by hitting /whoami directly (mautrix requires UserID at NewClient).
|
||||
userID, deviceID, err := whoami(s.ctx, homeserverURL, res.AccessToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("whoami: %w", err)
|
||||
}
|
||||
clientCfg.UserID = userID
|
||||
clientCfg.DeviceID = deviceID
|
||||
clientCfg.StoreDir = userStoreDir(userID)
|
||||
|
||||
clientCfg := infra.MatrixClientInitConfig{
|
||||
HomeserverURL: homeserverURL,
|
||||
UserID: userID,
|
||||
DeviceID: deviceID,
|
||||
AccessToken: res.AccessToken,
|
||||
StoreDir: userStoreDir(userID),
|
||||
EnableCrypto: false, // crypto goes through MatrixCryptoInit in Start()
|
||||
}
|
||||
if _, err := infra.MatrixClientInit(clientCfg); err != nil {
|
||||
return "", fmt.Errorf("matrix init: %w", err)
|
||||
}
|
||||
@@ -115,10 +140,9 @@ func (s *MatrixService) Login() (string, error) {
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// GetSession returns the persisted session for the given user_id (or last-known if empty).
|
||||
// GetSession returns the persisted session for the given user_id.
|
||||
func (s *MatrixService) GetSession(userID string) (*SessionView, error) {
|
||||
if userID == "" {
|
||||
// v0.1.0: no multi-account index. Frontend must pass the user_id once known.
|
||||
return nil, errors.New("user_id required (v0.1.0 multi-account index TODO)")
|
||||
}
|
||||
tok, err := s.store.Load(userID)
|
||||
@@ -140,12 +164,248 @@ func (s *MatrixService) GetSession(userID string) (*SessionView, error) {
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// Logout deletes the persisted token for the given user_id.
|
||||
// Logout deletes the persisted token + stops sync.
|
||||
func (s *MatrixService) Logout(userID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if userID == "" {
|
||||
return errors.New("user_id required")
|
||||
}
|
||||
if s.sync != nil {
|
||||
s.sync.Stop()
|
||||
s.sync = nil
|
||||
}
|
||||
s.client = nil
|
||||
s.crypto = nil
|
||||
s.userID = ""
|
||||
return s.store.Delete(userID)
|
||||
}
|
||||
|
||||
// Stop shuts down the sync loop without deleting credentials. Safe to call multiple times.
|
||||
func (s *MatrixService) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.sync != nil {
|
||||
s.sync.Stop()
|
||||
s.sync = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Start initializes the Matrix client + crypto + sync loop for the given user.
|
||||
// Must be called after Login() or after a successful GetSession() for a returning user.
|
||||
// Idempotent: safe to call multiple times for the same user.
|
||||
func (s *MatrixService) Start(userID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if userID == "" {
|
||||
return errors.New("user_id required")
|
||||
}
|
||||
|
||||
// Idempotent for same user
|
||||
if s.sync != nil && s.userID == userID {
|
||||
return nil
|
||||
}
|
||||
// Different user or restart: stop previous
|
||||
if s.sync != nil {
|
||||
s.sync.Stop()
|
||||
s.sync = nil
|
||||
}
|
||||
|
||||
tok, err := s.store.Load(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keyring load: %w", err)
|
||||
}
|
||||
|
||||
storeDir := userStoreDir(userID)
|
||||
clientCfg := infra.MatrixClientInitConfig{
|
||||
HomeserverURL: tok.HomeserverURL,
|
||||
UserID: tok.UserID,
|
||||
DeviceID: tok.DeviceID,
|
||||
AccessToken: tok.AccessToken,
|
||||
StoreDir: storeDir,
|
||||
EnableCrypto: false,
|
||||
}
|
||||
clientRes, err := infra.MatrixClientInit(clientCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("matrix init: %w", err)
|
||||
}
|
||||
|
||||
// Pickle key: load from keyring (hex), or generate fresh and persist.
|
||||
pickleKey, err := s.loadOrCreatePickleKey(tok)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pickle key: %w", err)
|
||||
}
|
||||
|
||||
cryptoStorePath := filepath.Join(storeDir, "crypto.db")
|
||||
|
||||
// Wrap MatrixCryptoInit in 60s timeout — hang here is the canonical MAS-UIA-rejection signal.
|
||||
cryptoCtx, cancel := context.WithTimeout(s.ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cryptoRes, err := infra.MatrixCryptoInit(cryptoCtx, infra.MatrixCryptoInitConfig{
|
||||
Client: clientRes.Client,
|
||||
StorePath: cryptoStorePath,
|
||||
PickleKey: pickleKey,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("matrix crypto init (hang here = MAS rejected UIA, see memory feedback_agents_e2ee_unblock_pattern): %w", err)
|
||||
}
|
||||
|
||||
syncRes, err := infra.MatrixSyncService(s.ctx, infra.MatrixSyncServiceConfig{
|
||||
Client: clientRes.Client,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("matrix sync: %w", err)
|
||||
}
|
||||
|
||||
s.client = clientRes.Client
|
||||
s.crypto = cryptoRes
|
||||
s.sync = syncRes
|
||||
s.userID = userID
|
||||
|
||||
// Fan events out via Wails runtime.
|
||||
go s.fanout()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MatrixService) fanout() {
|
||||
if s.ctx == nil || s.sync == nil {
|
||||
return
|
||||
}
|
||||
events := s.sync.Events
|
||||
errs := s.sync.Errors
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case ev, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
view := SyncEventView{
|
||||
Type: ev.Type,
|
||||
RoomID: ev.RoomID,
|
||||
EventID: ev.EventID,
|
||||
Sender: ev.Sender,
|
||||
Ts: ev.Ts,
|
||||
Body: ev.Body,
|
||||
}
|
||||
runtime.EventsEmit(s.ctx, "matrix:event", view)
|
||||
case e, ok := <-errs:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e != nil {
|
||||
runtime.EventsEmit(s.ctx, "matrix:error", e.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ListRooms returns the joined rooms with summary metadata.
|
||||
func (s *MatrixService) ListRooms() ([]infra.RoomSummary, error) {
|
||||
s.mu.Lock()
|
||||
client := s.client
|
||||
s.mu.Unlock()
|
||||
if client == nil {
|
||||
return nil, errors.New("matrix service not started — call Start() first")
|
||||
}
|
||||
return infra.MatrixRoomList(s.ctx, infra.MatrixRoomListConfig{Client: client})
|
||||
}
|
||||
|
||||
// LoadTimeline fetches the last N messages of a room (most recent first).
|
||||
func (s *MatrixService) LoadTimeline(roomID string, limit int) ([]MatrixEvent, error) {
|
||||
s.mu.Lock()
|
||||
client := s.client
|
||||
s.mu.Unlock()
|
||||
if client == nil {
|
||||
return nil, errors.New("matrix service not started — call Start() first")
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
msgs, err := client.Messages(s.ctx, id.RoomID(roomID), "", "", mautrix.DirectionBackward, nil, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("messages: %w", err)
|
||||
}
|
||||
out := make([]MatrixEvent, 0, len(msgs.Chunk))
|
||||
for _, evt := range msgs.Chunk {
|
||||
out = append(out, eventToView(evt))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func eventToView(evt *event.Event) MatrixEvent {
|
||||
view := MatrixEvent{
|
||||
EventID: evt.ID.String(),
|
||||
RoomID: evt.RoomID.String(),
|
||||
Sender: evt.Sender.String(),
|
||||
Type: evt.Type.String(),
|
||||
Ts: evt.Timestamp,
|
||||
}
|
||||
if evt.Type == event.EventEncrypted {
|
||||
view.EncryptedRaw = true
|
||||
return view
|
||||
}
|
||||
// Try to parse and extract body for messages.
|
||||
if evt.Type == event.EventMessage {
|
||||
_ = evt.Content.ParseRaw(evt.Type)
|
||||
if mc, ok := evt.Content.Parsed.(*event.MessageEventContent); ok && mc != nil {
|
||||
view.Body = mc.Body
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// SendText sends a plain text message to the given room.
|
||||
func (s *MatrixService) SendText(roomID, body string) (string, error) {
|
||||
s.mu.Lock()
|
||||
client := s.client
|
||||
s.mu.Unlock()
|
||||
if client == nil {
|
||||
return "", errors.New("matrix service not started — call Start() first")
|
||||
}
|
||||
evID, err := infra.MatrixSendText(s.ctx, client, id.RoomID(roomID), body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(evID), nil
|
||||
}
|
||||
|
||||
// SendMarkdown sends a markdown-formatted message (rendered + sanitized HTML).
|
||||
func (s *MatrixService) SendMarkdown(roomID, md string) (string, error) {
|
||||
s.mu.Lock()
|
||||
client := s.client
|
||||
s.mu.Unlock()
|
||||
if client == nil {
|
||||
return "", errors.New("matrix service not started — call Start() first")
|
||||
}
|
||||
evID, err := infra.MatrixSendMarkdown(s.ctx, client, id.RoomID(roomID), md)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(evID), nil
|
||||
}
|
||||
|
||||
// loadOrCreatePickleKey returns the 32-byte pickle key for the user.
|
||||
// If absent in keyring, generates fresh random bytes, hex-encodes them, persists, and returns.
|
||||
func (s *MatrixService) loadOrCreatePickleKey(tok *infra.Token) ([]byte, error) {
|
||||
if tok.PickleKeyHex != "" {
|
||||
key, err := hex.DecodeString(tok.PickleKeyHex)
|
||||
if err == nil && len(key) == 32 {
|
||||
return key, nil
|
||||
}
|
||||
// Malformed key in keyring — fall through and regenerate.
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return nil, fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
tok.PickleKeyHex = hex.EncodeToString(buf)
|
||||
if err := s.store.Save(tok.UserID, *tok); err != nil {
|
||||
return nil, fmt.Errorf("save pickle key: %w", err)
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user