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:
Egutierrez
2026-05-25 01:03:31 +02:00
parent f28c2b121e
commit 36a485ea26
21 changed files with 2529 additions and 93 deletions
+84
View File
@@ -0,0 +1,84 @@
package infra
import (
"encoding/json"
"errors"
"fmt"
"time"
keyring "github.com/zalando/go-keyring"
)
// ErrNotFound is returned by Load when no token exists for the given account.
var ErrNotFound = errors.New("token not found in keyring")
// Token holds OAuth/OIDC credentials that need to survive app restarts.
type Token struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never expires
UserID string `json:"user_id"`
DeviceID string `json:"device_id,omitempty"`
HomeserverURL string `json:"homeserver_url"`
Issuer string `json:"issuer,omitempty"` // MAS/OIDC issuer URL
ClientID string `json:"client_id,omitempty"` // MAS client_id used
// PickleKeyHex is the 32-byte hex-encoded key used by cryptohelper to
// pickle Olm/Megolm sessions at-rest in the SQLite crypto store.
// MUST persist across restarts. If lost, the crypto store is unusable
// and a fresh device login is required.
PickleKeyHex string `json:"pickle_key_hex,omitempty"`
}
// KeyringTokenStore persists tokens in the OS keyring (Secret Service on Linux,
// Keychain on macOS, Credential Manager on Windows).
type KeyringTokenStore struct {
// Service is the keyring namespace. Keep it stable across app versions.
// Example: "fn_registry.matrix_client_pc"
Service string
}
// NewKeyringTokenStore returns a store scoped to the given service name.
func NewKeyringTokenStore(service string) *KeyringTokenStore {
return &KeyringTokenStore{Service: service}
}
// Save serialises t to JSON and writes it to the keyring under (service, account).
// Overwrites silently if an entry already exists.
// account is typically the user ID, e.g. "@user:homeserver.example.com".
func (s *KeyringTokenStore) Save(account string, t Token) error {
b, err := json.Marshal(t)
if err != nil {
return fmt.Errorf("keyring save: marshal: %w", err)
}
if err := keyring.Set(s.Service, account, string(b)); err != nil {
return fmt.Errorf("keyring save: %w", err)
}
return nil
}
// Load retrieves and deserialises the token stored under (service, account).
// Returns ErrNotFound if no entry exists. Callers should check with errors.Is.
func (s *KeyringTokenStore) Load(account string) (*Token, error) {
raw, err := keyring.Get(s.Service, account)
if err != nil {
if errors.Is(err, keyring.ErrNotFound) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("keyring load: %w", err)
}
var t Token
if err := json.Unmarshal([]byte(raw), &t); err != nil {
return nil, fmt.Errorf("keyring load: unmarshal: %w", err)
}
return &t, nil
}
// Delete removes the token for account from the keyring.
// Idempotent: if no entry exists, returns nil.
func (s *KeyringTokenStore) Delete(account string) error {
err := keyring.Delete(s.Service, account)
if err != nil && !errors.Is(err, keyring.ErrNotFound) {
return fmt.Errorf("keyring delete: %w", err)
}
return nil
}
+382
View File
@@ -0,0 +1,382 @@
package infra
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os/exec"
"runtime"
"strings"
"time"
)
// MasOidcLoopbackConfig configura el flujo OAuth2 PKCE con loopback HTTP
// contra Matrix Authentication Service (MAS).
type MasOidcLoopbackConfig struct {
// Issuer es la URL base del MAS. Debe terminar en "/".
// La funcion hace GET a {Issuer}.well-known/openid-configuration para descubrir endpoints.
Issuer string
// ClientID es el ULID del client registrado en MAS.
// El client debe tener client_auth_method: none (public client PKCE).
ClientID string
// Scopes a solicitar. Si vacio usa ["openid", "urn:matrix:org.matrix.msc2967.client:api:*"].
Scopes []string
// LoopbackPort es el puerto local donde escucha el callback.
// Debe coincidir con el redirect_uri registrado en MAS (http://127.0.0.1:{port}/callback).
// Si 0, elige un puerto libre dinamicamente.
LoopbackPort int
// OpenBrowser abre el browser del SO automaticamente si es true.
// Si false, imprime la URL a stdout y espera que el caller la abra.
OpenBrowser bool
// TimeoutSeconds es el tiempo maximo esperando el callback. Default 300.
TimeoutSeconds int
}
// MasOidcLoopbackResult contiene los tokens devueltos por MAS tras el intercambio.
type MasOidcLoopbackResult struct {
// AccessToken es el Bearer token para usar contra Synapse.
AccessToken string `json:"access_token"`
// RefreshToken permite renovar el access token sin re-autenticar.
RefreshToken string `json:"refresh_token"`
// ExpiresIn es el tiempo de vida del access token en segundos.
ExpiresIn int `json:"expires_in"`
// TokenType es el tipo de token, normalmente "Bearer".
TokenType string `json:"token_type"`
// Scope es la lista de scopes concedidos (space-separated).
Scope string `json:"scope"`
// IDToken es el JWT de identidad OIDC (puede estar vacio si no se pidio openid).
IDToken string `json:"id_token,omitempty"`
}
// oidcDiscovery es la respuesta de .well-known/openid-configuration.
type oidcDiscovery struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
}
// MasOidcLoopback ejecuta el flujo OAuth2 Authorization Code + PKCE contra MAS
// usando un servidor HTTP loopback para recibir el callback.
//
// Flujo:
// 1. Discovery de endpoints via .well-known/openid-configuration.
// 2. Generacion de code_verifier/challenge PKCE y state anti-CSRF.
// 3. Arranque de servidor loopback en 127.0.0.1:{LoopbackPort}.
// 4. Apertura del browser (o impresion de URL si OpenBrowser=false).
// 5. Espera del callback con el authorization code.
// 6. Intercambio del code por tokens via POST al token_endpoint.
// 7. Devolucion de MasOidcLoopbackResult.
func MasOidcLoopback(cfg MasOidcLoopbackConfig) (*MasOidcLoopbackResult, error) {
// 1. Validar inputs
if cfg.Issuer == "" {
return nil, fmt.Errorf("mas_oidc_loopback: Issuer no puede estar vacio")
}
if !strings.HasSuffix(cfg.Issuer, "/") {
return nil, fmt.Errorf("mas_oidc_loopback: Issuer debe terminar en '/' (got %q)", cfg.Issuer)
}
if cfg.ClientID == "" {
return nil, fmt.Errorf("mas_oidc_loopback: ClientID no puede estar vacio")
}
if cfg.LoopbackPort < 0 {
return nil, fmt.Errorf("mas_oidc_loopback: LoopbackPort debe ser >= 0")
}
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if cfg.TimeoutSeconds <= 0 {
timeout = 300 * time.Second
}
scopes := cfg.Scopes
if len(scopes) == 0 {
scopes = []string{"openid", "urn:matrix:org.matrix.msc2967.client:api:*"}
}
// 2. Discovery OIDC
discovery, err := masOidcDiscover(cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("mas_oidc_loopback: discovery failed: %w", err)
}
// 3. PKCE: code_verifier + code_challenge
verifier, challenge, err := masOidcPKCE()
if err != nil {
return nil, fmt.Errorf("mas_oidc_loopback: pkce generation failed: %w", err)
}
// 4. State anti-CSRF
state, err := masOidcRandomBase64URL(32)
if err != nil {
return nil, fmt.Errorf("mas_oidc_loopback: state generation failed: %w", err)
}
// 5. Arrancar loopback server
listener, port, err := masOidcStartListener(cfg.LoopbackPort)
if err != nil {
return nil, fmt.Errorf("mas_oidc_loopback: no se pudo abrir puerto loopback: %w", err)
}
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", port)
// Canal para recibir el code o error desde el handler HTTP
codeCh := make(chan string, 1)
errCh := make(chan error, 1)
mux := http.NewServeMux()
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
// Validar state anti-CSRF
if q.Get("state") != state {
errCh <- fmt.Errorf("mas_oidc_loopback: state mismatch (posible CSRF) — esperado %q, recibido %q", state, q.Get("state"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("<html><body><h2>Error: state mismatch. Por favor cierra esta ventana.</h2></body></html>"))
return
}
// Verificar error del proveedor
if errParam := q.Get("error"); errParam != "" {
desc := q.Get("error_description")
errCh <- fmt.Errorf("mas_oidc_loopback: proveedor devolvio error %q: %s", errParam, desc)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(fmt.Sprintf("<html><body><h2>Error de autorizacion: %s</h2></body></html>", desc)))
return
}
code := q.Get("code")
if code == "" {
errCh <- fmt.Errorf("mas_oidc_loopback: callback sin 'code'")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("<html><body><h2>Error: no se recibio authorization code.</h2></body></html>"))
return
}
// Responder al browser con mensaje de exito
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<!DOCTYPE html>
<html lang="es">
<head><meta charset="utf-8"><title>Login completo</title></head>
<body style="font-family:sans-serif;text-align:center;padding:3em;">
<h2>Login completo</h2>
<p>Puedes cerrar esta ventana y volver a la aplicacion.</p>
</body>
</html>`))
codeCh <- code
})
srv := &http.Server{Handler: mux}
// Arrancar el servidor en goroutine
srvErrCh := make(chan error, 1)
go func() {
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
srvErrCh <- err
}
}()
// 6. Construir URL de autorización
authURL := masOidcBuildAuthURL(
discovery.AuthorizationEndpoint,
cfg.ClientID,
redirectURI,
strings.Join(scopes, " "),
state,
challenge,
)
// 7. Abrir browser o imprimir URL
if cfg.OpenBrowser {
if err := masOidcOpenBrowser(authURL); err != nil {
// No es fatal: continuamos y el usuario puede abrir manualmente
fmt.Printf("mas_oidc_loopback: no se pudo abrir el browser automaticamente.\nAbre esta URL manualmente:\n%s\n", authURL)
}
} else {
fmt.Printf("Abre esta URL en tu browser para autenticarte:\n%s\n", authURL)
}
// 8. Esperar callback con timeout
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var code string
select {
case code = <-codeCh:
// ok
case callbackErr := <-errCh:
_ = srv.Shutdown(context.Background())
return nil, callbackErr
case <-ctx.Done():
_ = srv.Shutdown(context.Background())
return nil, fmt.Errorf("mas_oidc_loopback: timeout esperando callback despues de %v", timeout)
case srvErr := <-srvErrCh:
return nil, fmt.Errorf("mas_oidc_loopback: servidor loopback fallo: %w", srvErr)
}
// 9. Shutdown graceful del servidor loopback
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer shutdownCancel()
_ = srv.Shutdown(shutdownCtx)
// 10. Intercambiar code por tokens
result, err := masOidcExchangeCode(
discovery.TokenEndpoint,
cfg.ClientID,
code,
redirectURI,
verifier,
)
if err != nil {
return nil, fmt.Errorf("mas_oidc_loopback: token exchange failed: %w", err)
}
return result, nil
}
// masOidcHTTPClient es el cliente HTTP usado por masOidcDiscover y masOidcExchangeCode.
// Tiene timeout de 15s. Puede ser reemplazado en tests.
var masOidcHTTPClient = &http.Client{Timeout: 15 * time.Second}
// masOidcDiscover obtiene los endpoints OIDC desde .well-known/openid-configuration.
func masOidcDiscover(issuer string) (*oidcDiscovery, error) {
discoveryURL := issuer + ".well-known/openid-configuration"
resp, err := masOidcHTTPClient.Get(discoveryURL) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("GET %s: %w", discoveryURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("discovery HTTP %d: %s", resp.StatusCode, string(body))
}
var d oidcDiscovery
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
return nil, fmt.Errorf("parsing discovery JSON: %w", err)
}
if d.AuthorizationEndpoint == "" {
return nil, fmt.Errorf("discovery: authorization_endpoint vacio")
}
if d.TokenEndpoint == "" {
return nil, fmt.Errorf("discovery: token_endpoint vacio")
}
return &d, nil
}
// masOidcPKCE genera un code_verifier aleatorio y su code_challenge SHA256/base64url.
func masOidcPKCE() (verifier, challenge string, err error) {
verifier, err = masOidcRandomBase64URL(32) // 32 bytes -> 43 chars base64url
if err != nil {
return "", "", err
}
h := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(h[:])
return verifier, challenge, nil
}
// masOidcRandomBase64URL genera n bytes aleatorios codificados en base64url sin padding.
func masOidcRandomBase64URL(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// masOidcStartListener abre un listener TCP en 127.0.0.1:{port}.
// Si port=0, elige un puerto libre y devuelve el puerto asignado.
func masOidcStartListener(port int) (net.Listener, int, error) {
addr := fmt.Sprintf("127.0.0.1:%d", port)
l, err := net.Listen("tcp", addr)
if err != nil {
return nil, 0, err
}
assignedPort := l.Addr().(*net.TCPAddr).Port
return l, assignedPort, nil
}
// masOidcBuildAuthURL construye la URL de autorización OAuth2 con PKCE.
func masOidcBuildAuthURL(authEndpoint, clientID, redirectURI, scope, state, challenge string) string {
u, _ := url.Parse(authEndpoint)
q := u.Query()
q.Set("response_type", "code")
q.Set("client_id", clientID)
q.Set("redirect_uri", redirectURI)
q.Set("scope", scope)
q.Set("state", state)
q.Set("code_challenge", challenge)
q.Set("code_challenge_method", "S256")
u.RawQuery = q.Encode()
return u.String()
}
// masOidcOpenBrowser abre la URL en el browser predeterminado del SO.
func masOidcOpenBrowser(rawURL string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "linux":
cmd = exec.Command("xdg-open", rawURL)
case "darwin":
cmd = exec.Command("open", rawURL)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
default:
return fmt.Errorf("plataforma no soportada para abrir browser: %s", runtime.GOOS)
}
return cmd.Start()
}
// masOidcExchangeCode intercambia el authorization code por tokens via POST al token_endpoint.
func masOidcExchangeCode(tokenEndpoint, clientID, code, redirectURI, verifier string) (*MasOidcLoopbackResult, error) {
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("code", code)
formData.Set("redirect_uri", redirectURI)
formData.Set("client_id", clientID)
formData.Set("code_verifier", verifier)
resp, err := masOidcHTTPClient.PostForm(tokenEndpoint, formData) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("POST %s: %w", tokenEndpoint, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("leyendo respuesta del token endpoint: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token endpoint HTTP %d: %s", resp.StatusCode, string(body))
}
var result MasOidcLoopbackResult
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parsing token response JSON: %w", err)
}
if result.AccessToken == "" {
return nil, fmt.Errorf("token response sin access_token: %s", string(body))
}
return &result, nil
}
+153
View File
@@ -0,0 +1,153 @@
package infra
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// MatrixClientInitConfig parametriza la inicializacion del cliente Matrix.
type MatrixClientInitConfig struct {
// HomeserverURL es la URL base del servidor Matrix (Synapse/Dendrite/etc.).
// Ejemplo: "https://matrix-af2f3d.organic-machine.com"
HomeserverURL string
// UserID es el MXID del usuario. Formato "@local:servidor".
// Ejemplo: "@egutierrez:matrix-af2f3d.organic-machine.com"
UserID string
// AccessToken es el Bearer token obtenido del flow OIDC (mas_oidc_loopback).
// No puede estar vacio.
AccessToken string
// DeviceID del cliente Matrix. Si vacio, se descubre via /whoami al inicializar.
// Recomendado guardarlo en keyring tras el primer uso para evitar la llamada extra.
DeviceID string
// StoreDir es el directorio donde se persiste el estado de sync (next_batch, filter_id).
// Se crea con permisos 0700 si no existe. Puede ser relativo (se convierte a absoluto).
// Ejemplo: "~/.matrix_client_pc/egutierrez/" (no expandido automaticamente — usar os.UserHomeDir).
StoreDir string
// EnableCrypto activa el crypto store SQLite para Olm/Megolm (E2EE).
// En v0.1.0 devuelve error — la implementacion completa esta en issue 0150.
EnableCrypto bool
}
// MatrixClientInitResult contiene el cliente listo y los paths de persistencia.
type MatrixClientInitResult struct {
// Client es el *mautrix.Client listo para Sync/SendMessage.
// UserID, AccessToken y DeviceID ya estan configurados.
Client *mautrix.Client
// StorePath es la ruta al directorio de persistencia de sync state.
StorePath string
// CryptoPath es la ruta calculada para el crypto store SQLite.
// Vacio si EnableCrypto=false. En v0.1.0 siempre vacio (no implementado).
CryptoPath string
}
// MatrixClientInit construye un *mautrix.Client listo para hacer Sync,
// sin manejar el login (que ya hizo el flow OIDC via mas_oidc_loopback).
//
// Pasos:
// 1. Valida inputs (HomeserverURL parseable, UserID formato "@x:server", AccessToken no vacio).
// 2. Crea StoreDir con permisos 0700.
// 3. Llama mautrix.NewClient con las credenciales.
// 4. Si DeviceID esta vacio, hace Whoami para descubrirlo (sum latency ~100ms).
// 5. Si EnableCrypto=true, devuelve error (issue 0150 lo implementa).
// 6. Devuelve MatrixClientInitResult con el cliente configurado.
func MatrixClientInit(cfg MatrixClientInitConfig) (*MatrixClientInitResult, error) {
// 1. Validar HomeserverURL
if cfg.HomeserverURL == "" {
return nil, fmt.Errorf("matrix_client_init: HomeserverURL no puede estar vacio")
}
if _, err := url.ParseRequestURI(cfg.HomeserverURL); err != nil {
return nil, fmt.Errorf("matrix_client_init: HomeserverURL invalido %q: %w", cfg.HomeserverURL, err)
}
if !strings.HasPrefix(cfg.HomeserverURL, "http://") && !strings.HasPrefix(cfg.HomeserverURL, "https://") {
return nil, fmt.Errorf("matrix_client_init: HomeserverURL debe empezar con http:// o https:// (got %q)", cfg.HomeserverURL)
}
// Validar UserID: debe ser "@local:servidor"
if cfg.UserID == "" {
return nil, fmt.Errorf("matrix_client_init: UserID no puede estar vacio")
}
if !strings.HasPrefix(cfg.UserID, "@") || !strings.Contains(cfg.UserID, ":") {
return nil, fmt.Errorf("matrix_client_init: UserID invalido %q — formato esperado @local:servidor", cfg.UserID)
}
// Validar AccessToken
if cfg.AccessToken == "" {
return nil, fmt.Errorf("matrix_client_init: AccessToken no puede estar vacio")
}
// Validar StoreDir
if cfg.StoreDir == "" {
return nil, fmt.Errorf("matrix_client_init: StoreDir no puede estar vacio")
}
// En v0.1.0 crypto no esta implementado
if cfg.EnableCrypto {
return nil, fmt.Errorf("matrix_client_init: crypto not implemented in v0.1.0, see issue 0150")
}
// Convertir StoreDir a absoluto si es relativo
storeDir := cfg.StoreDir
if !filepath.IsAbs(storeDir) {
abs, err := filepath.Abs(storeDir)
if err != nil {
return nil, fmt.Errorf("matrix_client_init: no se pudo resolver StoreDir %q: %w", storeDir, err)
}
storeDir = abs
}
// 2. Crear StoreDir con permisos 0700 (datos sensibles)
if err := os.MkdirAll(storeDir, 0700); err != nil {
return nil, fmt.Errorf("matrix_client_init: no se pudo crear StoreDir %q: %w", storeDir, err)
}
// 3. Construir cliente mautrix
client, err := mautrix.NewClient(cfg.HomeserverURL, id.UserID(cfg.UserID), cfg.AccessToken)
if err != nil {
return nil, fmt.Errorf("matrix_client_init: mautrix.NewClient failed: %w", err)
}
// 4. DeviceID: usar el proporcionado o descubrir via Whoami
if cfg.DeviceID != "" {
client.DeviceID = id.DeviceID(cfg.DeviceID)
} else {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
whoami, err := client.Whoami(ctx)
if err != nil {
// Distinguir token invalido (M_UNKNOWN_TOKEN) de error de red
if errors.Is(err, mautrix.MUnknownToken) {
return nil, fmt.Errorf("matrix_client_init: access token invalido o expirado (M_UNKNOWN_TOKEN) — refrescar via OIDC: %w", err)
}
return nil, fmt.Errorf("matrix_client_init: Whoami failed (servidor caido o token invalido): %w", err)
}
client.DeviceID = whoami.DeviceID
}
// Calcular CryptoPath (aunque no se use en v0.1.0)
cryptoPath := ""
// CryptoPath calculado pero no inicializado en v0.1.0
_ = filepath.Join(storeDir, "crypto.db") // reservado para matrix_crypto_init_go_infra (issue 0150)
return &MatrixClientInitResult{
Client: client,
StorePath: storeDir,
CryptoPath: cryptoPath,
}, nil
}
+107
View File
@@ -0,0 +1,107 @@
//go:build goolm || libolm
package infra
import (
"context"
"fmt"
"os"
"path/filepath"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper"
)
// MatrixCryptoInitConfig parametriza la inicializacion del crypto store Olm/Megolm.
type MatrixCryptoInitConfig struct {
// Client es el *mautrix.Client ya inicializado via MatrixClientInit.
// Debe tener AccessToken, UserID y DeviceID poblados.
Client *mautrix.Client
// StorePath es la ruta absoluta al archivo SQLite del crypto store.
// Debe ser separado del state store. El SDK gestiona el schema internamente.
// Si el directorio padre no existe, se crea con permisos 0700.
// Ejemplo: "/home/lucas/.config/matrix_client_pc/egutierrez/crypto.db"
StorePath string
// PickleKey son exactamente 32 bytes usados por cryptohelper para cifrar las
// sesiones Olm en disco at-rest. DEBE persistir entre arranques (guardar en keyring).
// Si se pierde, el store SQLite se vuelve inutilizable y hay que crear nuevo dispositivo.
PickleKey []byte
}
// MatrixCryptoInitResult contiene el helper listo para usar.
type MatrixCryptoInitResult struct {
// Helper es el *cryptohelper.CryptoHelper inicializado.
// Ya esta asignado a client.Crypto — el Sync loop cifra/descifra automaticamente.
Helper *cryptohelper.CryptoHelper
// StorePath es la ruta al archivo SQLite del crypto store (igual que cfg.StorePath).
StorePath string
}
// MatrixCryptoInit inicializa el crypto store Olm/Megolm para un cliente mautrix
// usando cryptohelper — el wrapper oficial que abstrae SQLite + Olm identity keys +
// one-time key upload + decrypt automatico via el Syncer.
//
// Pasos:
// 1. Valida inputs (Client no nil con AccessToken/UserID/DeviceID, StorePath
// absoluto, PickleKey exactamente 32 bytes).
// 2. Crea el directorio padre de StorePath con permisos 0700 si no existe.
// 3. Construye el helper via cryptohelper.NewCryptoHelper(client, pickleKey, storePath).
// 4. Llama helper.Init(ctx) — crea tablas SQLite, carga cuenta Olm, sube one-time keys.
// 5. Asigna client.Crypto = helper para que SendMessageEvent cifre automaticamente.
// 6. Devuelve MatrixCryptoInitResult con el helper listo.
func MatrixCryptoInit(ctx context.Context, cfg MatrixCryptoInitConfig) (*MatrixCryptoInitResult, error) {
// 1. Validar Client
if cfg.Client == nil {
return nil, fmt.Errorf("matrix_crypto_init: Client no puede ser nil")
}
if cfg.Client.AccessToken == "" {
return nil, fmt.Errorf("matrix_crypto_init: Client.AccessToken no puede estar vacio")
}
if cfg.Client.UserID == "" {
return nil, fmt.Errorf("matrix_crypto_init: Client.UserID no puede estar vacio")
}
if cfg.Client.DeviceID == "" {
return nil, fmt.Errorf("matrix_crypto_init: Client.DeviceID no puede estar vacio — descubrirlo via MatrixClientInit o Whoami antes de llamar MatrixCryptoInit")
}
// Validar StorePath
if cfg.StorePath == "" {
return nil, fmt.Errorf("matrix_crypto_init: StorePath no puede estar vacio")
}
if !filepath.IsAbs(cfg.StorePath) {
return nil, fmt.Errorf("matrix_crypto_init: StorePath debe ser una ruta absoluta (got %q)", cfg.StorePath)
}
// Validar PickleKey: exactamente 32 bytes
if len(cfg.PickleKey) != 32 {
return nil, fmt.Errorf("matrix_crypto_init: PickleKey debe tener exactamente 32 bytes (got %d)", len(cfg.PickleKey))
}
// 2. Crear directorio padre con permisos 0700 (datos sensibles)
storeDir := filepath.Dir(cfg.StorePath)
if err := os.MkdirAll(storeDir, 0700); err != nil {
return nil, fmt.Errorf("matrix_crypto_init: no se pudo crear directorio del store %q: %w", storeDir, err)
}
// 3. Construir CryptoHelper — acepta string como path SQLite directamente (v0.28 API)
helper, err := cryptohelper.NewCryptoHelper(cfg.Client, cfg.PickleKey, cfg.StorePath)
if err != nil {
return nil, fmt.Errorf("matrix_crypto_init: NewCryptoHelper failed: %w", err)
}
// 4. Init: crea tablas SQLite, carga cuenta Olm, sube one-time keys al servidor
if err := helper.Init(ctx); err != nil {
return nil, fmt.Errorf("matrix_crypto_init: helper.Init failed (comprueba conectividad con Synapse y validez del token): %w", err)
}
// 5. Asignar client.Crypto para que SendMessageEvent cifre automaticamente
cfg.Client.Crypto = helper
return &MatrixCryptoInitResult{
Helper: helper,
StorePath: cfg.StorePath,
}, nil
}
+121
View File
@@ -0,0 +1,121 @@
package infra
import (
"bytes"
"context"
"fmt"
"github.com/microcosm-cc/bluemonday"
"github.com/yuin/goldmark"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// matrixMarkdownToHTML convierte Markdown a HTML sanitizado con goldmark + bluemonday.
// El HTML resultante es seguro para incluir en formatted_body de un evento Matrix.
// Allowlist: bluemonday UGCPolicy + <details>, <summary>, <code>, <pre>.
func matrixMarkdownToHTML(markdown string) (string, error) {
var buf bytes.Buffer
if err := goldmark.Convert([]byte(markdown), &buf); err != nil {
return "", fmt.Errorf("matrix_message_send: goldmark convert: %w", err)
}
p := bluemonday.UGCPolicy()
p.AllowElements("details", "summary", "code", "pre")
sanitized := p.SanitizeBytes(buf.Bytes())
return string(sanitized), nil
}
// matrixSendEvent es el helper interno que llama a client.SendMessageEvent
// y devuelve el id.EventID asignado por Synapse.
func matrixSendEvent(ctx context.Context, client *mautrix.Client, roomID id.RoomID, eventType event.Type, content interface{}) (id.EventID, error) {
resp, err := client.SendMessageEvent(ctx, roomID, eventType, content)
if err != nil {
return "", err
}
return resp.EventID, nil
}
// MatrixSendText envía un mensaje de texto plano (m.text) al room indicado.
// Si el room tiene E2EE activo y client.Crypto != nil, mautrix cifra automáticamente.
func MatrixSendText(ctx context.Context, client *mautrix.Client, roomID id.RoomID, body string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: body,
}
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
}
// MatrixSendMarkdown convierte markdown a HTML con goldmark, lo sanitiza con bluemonday
// (UGCPolicy + <details>, <summary>, <code>, <pre>) y envía con format=org.matrix.custom.html.
// El campo Body contiene el markdown original como fallback para clientes sin HTML.
func MatrixSendMarkdown(ctx context.Context, client *mautrix.Client, roomID id.RoomID, markdown string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
htmlBody, err := matrixMarkdownToHTML(markdown)
if err != nil {
return "", fmt.Errorf("matrix_message_send.MatrixSendMarkdown: %w", err)
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: markdown,
Format: event.FormatHTML,
FormattedBody: htmlBody,
}
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
}
// MatrixSendReply envía un mensaje con m.relates_to.m.in_reply_to apuntando a replyTo.
// El body es el texto de la respuesta. En v0.1.0 el caller construye la cita si la necesita.
// El cifrado E2EE es automático si client.Crypto está configurado.
func MatrixSendReply(ctx context.Context, client *mautrix.Client, roomID id.RoomID, replyTo id.EventID, body string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: body,
RelatesTo: (&event.RelatesTo{}).SetReplyTo(replyTo),
}
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
}
// MatrixEditMessage envía un replacement event (m.replace) compatible con Element y la spec Matrix.
// NewContent contiene el texto nuevo; Body es el fallback "* newBody" para clientes sin soporte de edición.
// eventID es el evento original a reemplazar.
func MatrixEditMessage(ctx context.Context, client *mautrix.Client, roomID id.RoomID, eventID id.EventID, newBody string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: "* " + newBody,
NewContent: &event.MessageEventContent{
MsgType: event.MsgText,
Body: newBody,
},
RelatesTo: (&event.RelatesTo{}).SetReplace(eventID),
}
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
}
// MatrixSendReaction envía un evento m.reaction con m.relates_to.rel_type=m.annotation.
// key debe ser el emoji unicode raw (ej. "👍"), no shortcode (:thumbsup:).
// Las reactions no se cifran aunque el room sea E2EE (comportamiento de mautrix-go).
func MatrixSendReaction(ctx context.Context, client *mautrix.Client, roomID id.RoomID, targetEventID id.EventID, key string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
content := &event.ReactionEventContent{
RelatesTo: event.RelatesTo{
Type: event.RelAnnotation,
EventID: targetEventID,
Key: key,
},
}
return matrixSendEvent(ctx, client, roomID, event.EventReaction, content)
}
+300
View File
@@ -0,0 +1,300 @@
package infra
import (
"context"
"fmt"
"log"
"sort"
"strings"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// RoomSummary es el resumen de una room Matrix para renderizar en el sidebar de un cliente.
type RoomSummary struct {
RoomID string `json:"room_id"`
Name string `json:"name,omitempty"` // m.room.name o fallback
CanonicalAlias string `json:"canonical_alias,omitempty"` // #room:server
AvatarMxc string `json:"avatar_mxc,omitempty"` // mxc://...
Topic string `json:"topic,omitempty"`
IsDirect bool `json:"is_direct"` // m.direct account_data
IsSpace bool `json:"is_space"` // m.room.type == m.space
IsEncrypted bool `json:"is_encrypted"` // m.room.encryption state event presente
MemberCount int `json:"member_count"`
LastEventTs int64 `json:"last_event_ts"` // unix ms del ultimo evento conocido
UnreadCount int `json:"unread_count"` // notifications.unread + highlight
Tags []string `json:"tags,omitempty"` // m.tag account_data
}
// MatrixRoomListConfig agrupa los parametros de MatrixRoomList.
type MatrixRoomListConfig struct {
Client *mautrix.Client
}
// MatrixRoomList devuelve todos los rooms en los que el usuario esta unido,
// ordenados por LastEventTs DESC (recientes primero).
//
// Estrategia:
// 1. JoinedRooms() para la lista de room IDs.
// 2. m.direct account_data para detectar DMs.
// 3. Para cada room: State() -> nombre, alias, topic, avatar, encryption, space, members.
// 4. Messages(limit=1) -> LastEventTs (TODO: coste N*HTTP; cachear con TTL 30s).
// 5. GetRoomAccountData("m.tag") -> Tags.
//
// Sub-operaciones que fallan por room concreto no abortan el global.
// LastEventTs puede ser 0 si el store no lo cachea (ver ## Gotchas del .md).
func MatrixRoomList(ctx context.Context, cfg MatrixRoomListConfig) ([]RoomSummary, error) {
if cfg.Client == nil {
return nil, fmt.Errorf("matrix_room_list: client no puede ser nil")
}
client := cfg.Client
// 1. Rooms unidos
respJoined, err := client.JoinedRooms(ctx)
if err != nil {
return nil, fmt.Errorf("matrix_room_list: JoinedRooms: %w", err)
}
if len(respJoined.JoinedRooms) == 0 {
return []RoomSummary{}, nil
}
// 2. m.direct -> set roomID -> true
directSet := loadDirectRooms(ctx, client)
// 3. Construir summaries (secuencial para v0.1.0)
results := make([]RoomSummary, 0, len(respJoined.JoinedRooms))
for _, roomID := range respJoined.JoinedRooms {
s := buildRoomSummaryFromState(ctx, client, roomID, directSet)
results = append(results, s)
}
// 4. Ordenar DESC por LastEventTs; si empatan (ej. todo 0) -> alfabetico por Name
sort.Slice(results, func(i, j int) bool {
if results[i].LastEventTs != results[j].LastEventTs {
return results[i].LastEventTs > results[j].LastEventTs
}
return results[i].Name < results[j].Name
})
return results, nil
}
// loadDirectRooms carga m.direct account_data y devuelve un set roomID -> true.
// Falla silenciosamente: si hay error devuelve mapa vacio (IsDirect quedara false).
func loadDirectRooms(ctx context.Context, client *mautrix.Client) map[id.RoomID]bool {
result := make(map[id.RoomID]bool)
var directContent event.DirectChatsEventContent
if err := client.GetAccountData(ctx, "m.direct", &directContent); err != nil {
log.Printf("matrix_room_list: GetAccountData(m.direct) warning: %v", err)
return result
}
for _, rooms := range directContent {
for _, rid := range rooms {
result[rid] = true
}
}
return result
}
// buildRoomSummaryFromState construye el RoomSummary para un room concreto.
// Si State() falla usa el roomID como Name de emergencia.
func buildRoomSummaryFromState(ctx context.Context, client *mautrix.Client, roomID id.RoomID, directSet map[id.RoomID]bool) RoomSummary {
s := RoomSummary{
RoomID: string(roomID),
IsDirect: directSet[roomID],
}
// State del room
stateMap, err := client.State(ctx, roomID)
if err != nil {
log.Printf("matrix_room_list: State(%s) warning: %v", roomID, err)
s.Name = deriveRoomName(&s, nil)
return s
}
fillStateFields(&s, stateMap)
s.Name = deriveRoomName(&s, stateMap)
// Tags: m.tag room account_data
s.Tags = loadRoomTags(ctx, client, roomID)
// LastEventTs: Messages(limit=1, dir=backward)
// TODO(0148): caro N*HTTP -> cachear en backend con TTL 30s.
msgs, err := client.Messages(ctx, roomID, "", "", mautrix.DirectionBackward, nil, 1)
if err != nil {
log.Printf("matrix_room_list: Messages(%s) warning: %v", roomID, err)
// No fatal: LastEventTs queda 0 y el room cae al fondo del orden
} else if msgs != nil && len(msgs.Chunk) > 0 {
s.LastEventTs = msgs.Chunk[0].Timestamp
}
return s
}
// ensureParsed llama ParseRaw si el contenido no esta aun parseado.
// ParseRaw devuelve ErrContentAlreadyParsed cuando ya fue parseado (p.ej.
// por parseRoomStateArray al deserializar el state); en ese caso ignoramos
// el error y usamos el Parsed existente.
func ensureParsed(c *event.Content, evtType event.Type) {
if c.Parsed == nil {
_ = c.ParseRaw(evtType)
}
}
// fillStateFields rellena los campos del RoomSummary a partir del state map.
// parseRoomStateArray ya llama ParseRaw al deserializar, por lo que es posible
// que Content.Parsed este ya populado. ensureParsed maneja ambos casos.
func fillStateFields(s *RoomSummary, stateMap mautrix.RoomStateMap) {
// m.room.name
if nameEvts, ok := stateMap[event.StateRoomName]; ok {
if nameEvt, ok := nameEvts[""]; ok {
ensureParsed(&nameEvt.Content, event.StateRoomName)
if c := nameEvt.Content.AsRoomName(); c != nil {
s.Name = c.Name
}
}
}
// m.room.canonical_alias
if aliasEvts, ok := stateMap[event.StateCanonicalAlias]; ok {
if aliasEvt, ok := aliasEvts[""]; ok {
ensureParsed(&aliasEvt.Content, event.StateCanonicalAlias)
if c := aliasEvt.Content.AsCanonicalAlias(); c != nil {
s.CanonicalAlias = string(c.Alias)
}
}
}
// m.room.avatar
if avatarEvts, ok := stateMap[event.StateRoomAvatar]; ok {
if avatarEvt, ok := avatarEvts[""]; ok {
ensureParsed(&avatarEvt.Content, event.StateRoomAvatar)
if c := avatarEvt.Content.AsRoomAvatar(); c != nil {
s.AvatarMxc = string(c.URL)
}
}
}
// m.room.topic
if topicEvts, ok := stateMap[event.StateTopic]; ok {
if topicEvt, ok := topicEvts[""]; ok {
ensureParsed(&topicEvt.Content, event.StateTopic)
if c := topicEvt.Content.AsTopic(); c != nil {
s.Topic = c.Topic
}
}
}
// m.room.encryption (existence = encrypted)
if encEvts, ok := stateMap[event.StateEncryption]; ok {
if _, ok := encEvts[""]; ok {
s.IsEncrypted = true
}
}
// m.room.create -> IsSpace si type == "m.space"
if createEvts, ok := stateMap[event.StateCreate]; ok {
if createEvt, ok := createEvts[""]; ok {
ensureParsed(&createEvt.Content, event.StateCreate)
if c := createEvt.Content.AsCreate(); c != nil {
s.IsSpace = c.Type == event.RoomTypeSpace
}
}
}
// m.room.member: contar membership == join
if memberEvts, ok := stateMap[event.StateMember]; ok {
count := 0
for _, memberEvt := range memberEvts {
ensureParsed(&memberEvt.Content, event.StateMember)
if c := memberEvt.Content.AsMember(); c != nil && c.Membership == event.MembershipJoin {
count++
}
}
s.MemberCount = count
}
}
// deriveRoomName calcula el nombre display para el room siguiendo la jerarquia:
// 1. Name (ya seteado desde m.room.name).
// 2. CanonicalAlias.
// 3. "Direct Message" si IsDirect.
// 4. Lista de otros miembros si los hay (max 3).
// 5. "Empty room" si MemberCount <= 1.
func deriveRoomName(s *RoomSummary, stateMap mautrix.RoomStateMap) string {
if s.Name != "" {
return s.Name
}
if s.CanonicalAlias != "" {
return s.CanonicalAlias
}
if s.IsDirect {
// Intentar obtener displayname del otro miembro desde el state
if stateMap != nil {
if memberEvts, ok := stateMap[event.StateMember]; ok {
for userKey, memberEvt := range memberEvts {
ensureParsed(&memberEvt.Content, event.StateMember)
if c := memberEvt.Content.AsMember(); c != nil &&
c.Membership == event.MembershipJoin &&
userKey != "" {
if c.Displayname != "" {
return c.Displayname
}
return userKey // user ID como fallback
}
}
}
}
return "Direct Message"
}
if stateMap != nil && s.MemberCount > 1 {
// Lista de displaynames de otros miembros (max 3)
names := collectMemberNames(stateMap, 3)
if len(names) > 0 {
return strings.Join(names, ", ")
}
}
return "Empty room"
}
// collectMemberNames extrae hasta maxN displaynames de joined members del state.
func collectMemberNames(stateMap mautrix.RoomStateMap, maxN int) []string {
names := make([]string, 0, maxN)
if memberEvts, ok := stateMap[event.StateMember]; ok {
for userKey, memberEvt := range memberEvts {
if len(names) >= maxN {
break
}
ensureParsed(&memberEvt.Content, event.StateMember)
if c := memberEvt.Content.AsMember(); c != nil && c.Membership == event.MembershipJoin {
if c.Displayname != "" {
names = append(names, c.Displayname)
} else if userKey != "" {
names = append(names, userKey)
}
}
}
}
return names
}
// loadRoomTags carga m.tag room account_data y devuelve los tag names como []string.
// Falla silenciosamente devolviendo nil.
func loadRoomTags(ctx context.Context, client *mautrix.Client, roomID id.RoomID) []string {
var tagContent event.TagEventContent
if err := client.GetRoomAccountData(ctx, roomID, "m.tag", &tagContent); err != nil {
// No fatal: rooms sin tags dan 404, lo cual es normal
return nil
}
if len(tagContent.Tags) == 0 {
return nil
}
tags := make([]string, 0, len(tagContent.Tags))
for tag := range tagContent.Tags {
tags = append(tags, string(tag))
}
sort.Strings(tags) // orden determinista
return tags
}
+366
View File
@@ -0,0 +1,366 @@
package infra
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// MatrixSyncEvent es un evento normalizado emitido por MatrixSyncService.
// Cubre mensajes, pertenencia a sala, redacciones, reacciones, tipeo y estado.
type MatrixSyncEvent struct {
Type string `json:"type"` // "message" | "membership" | "redaction" | "reaction" | "edit" | "encrypted" | "presence" | "typing" | "read_receipt" | "room_state"
RoomID string `json:"room_id"` // ID de la sala (vacio para presencia global)
EventID string `json:"event_id"` // event_id unico Matrix (vacio para eventos efimeros)
Sender string `json:"sender"` // MXID del emisor (vacio para eventos efimeros)
Ts int64 `json:"ts"` // origin_server_ts en milisegundos
Body string `json:"body,omitempty"` // contenido de texto del evento (mensajes)
Raw interface{} `json:"raw,omitempty"` // *event.Event original para acceso completo
}
// MatrixSyncServiceConfig configura el servicio de sync loop de Matrix.
type MatrixSyncServiceConfig struct {
// Client es el *mautrix.Client ya inicializado con credenciales.
// Obligatorio.
Client *mautrix.Client
// InitialBackoffMS es el tiempo inicial de espera entre reintentos tras error (ms).
// Default: 1000 (1 segundo).
InitialBackoffMS int
// MaxBackoffMS es el techo del backoff exponencial (ms).
// Default: 60000 (60 segundos).
MaxBackoffMS int
// ChannelBuffer es la capacidad del canal Events.
// Si el consumer va lento y el buffer se llena, el sync se bloquea hasta
// que el consumer drene. Default: 256.
ChannelBuffer int
}
// MatrixSyncServiceHandle es el handle devuelto por MatrixSyncService.
type MatrixSyncServiceHandle struct {
// Events es el canal de eventos normalizados (cierra al Stop).
Events <-chan MatrixSyncEvent
// Errors recibe errores transitorios (red, 5xx, etc.).
// No fatal: el servicio reintenta con backoff. El caller decide si actuar.
// El canal cierra al Stop.
Errors <-chan error
// Stop cancela el sync loop de forma limpia e idempotente.
// Cierra Events y Errors. Seguro llamar varias veces.
Stop func()
}
// matrixSyncerWrapper envuelve DefaultSyncer para interceptar OnFailedSync
// e inyectar nuestro backoff exponencial y emision de errores al canal.
type matrixSyncerWrapper struct {
*mautrix.DefaultSyncer
errCh chan<- error
innerCtx context.Context
backoffMs *int
initialMS int
maxMS int
lastSyncOK *time.Time
}
// OnFailedSync implementa mautrix.Syncer. Emite el error al canal y devuelve
// el proximo backoff. Para errores fatales (401, M_FORBIDDEN) devuelve el
// backoff maximo y emite al canal — el caller decide via Stop().
func (w *matrixSyncerWrapper) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
if w.innerCtx.Err() != nil {
return 0, fmt.Errorf("matrix_sync_service: context cancelado")
}
// Emitir error al canal de forma no-bloqueante
select {
case w.errCh <- fmt.Errorf("matrix_sync_service: %w", err):
default:
}
// Reset backoff si el ultimo sync exitoso fue reciente
if time.Since(*w.lastSyncOK) < 30*time.Second {
*w.backoffMs = w.initialMS
}
// Calcular duracion de espera
wait := time.Duration(*w.backoffMs) * time.Millisecond
// Backoff exponencial con techo
*w.backoffMs *= 2
if *w.backoffMs > w.maxMS {
*w.backoffMs = w.maxMS
}
// Para errores fatales, esperar el maximo pero no retornar error
// (dejamos al caller decidir via Stop)
if isFatalMatrixError(err) {
return time.Duration(w.maxMS) * time.Millisecond, nil
}
return wait, nil
}
// GetFilterJSON delega al DefaultSyncer.
func (w *matrixSyncerWrapper) GetFilterJSON(userID id.UserID) *mautrix.Filter {
return w.DefaultSyncer.GetFilterJSON(userID)
}
// ProcessResponse delega al DefaultSyncer. Actualiza lastSyncOK en exito.
func (w *matrixSyncerWrapper) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
err := w.DefaultSyncer.ProcessResponse(ctx, resp, since)
if err == nil {
now := time.Now()
*w.lastSyncOK = now
}
return err
}
// MatrixSyncService arranca el sync loop de mautrix contra Synapse en background.
// Registra handlers para los tipos de evento mas comunes y los emite via canal.
// Implementa reconnect con backoff exponencial para errores transitorios.
//
// Requiere un *mautrix.Client ya inicializado (ver matrix_client_init).
// Opcionalmente combinar con matrix_crypto_init para descifrar m.room.encrypted.
//
// La goroutine interna vive hasta que ctx sea cancelado o se llame Stop.
// Ambas acciones cierran los canales Events y Errors.
func MatrixSyncService(ctx context.Context, cfg MatrixSyncServiceConfig) (*MatrixSyncServiceHandle, error) {
if cfg.Client == nil {
return nil, fmt.Errorf("matrix_sync_service: Client no puede ser nil")
}
// Aplicar defaults
initialBackoff := cfg.InitialBackoffMS
if initialBackoff <= 0 {
initialBackoff = 1000
}
maxBackoff := cfg.MaxBackoffMS
if maxBackoff <= 0 {
maxBackoff = 60000
}
bufSize := cfg.ChannelBuffer
if bufSize <= 0 {
bufSize = 256
}
// Context cancelable derivado del pasado
innerCtx, cancel := context.WithCancel(ctx)
// Channels
evtCh := make(chan MatrixSyncEvent, bufSize)
errCh := make(chan error, 8)
// Stop idempotente via sync.Once
var once sync.Once
stopFn := func() {
once.Do(func() {
cancel()
})
}
// Estado de backoff compartido con el wrapper
backoffMs := initialBackoff
lastSyncOK := time.Now()
// Configurar el Syncer: usar DefaultSyncer base (existente o nuevo)
var baseSyncer *mautrix.DefaultSyncer
if ds, ok := cfg.Client.Syncer.(*mautrix.DefaultSyncer); ok {
baseSyncer = ds
} else {
baseSyncer = mautrix.NewDefaultSyncer()
}
// Crear wrapper que intercepta OnFailedSync
wrapper := &matrixSyncerWrapper{
DefaultSyncer: baseSyncer,
errCh: errCh,
innerCtx: innerCtx,
backoffMs: &backoffMs,
initialMS: initialBackoff,
maxMS: maxBackoff,
lastSyncOK: &lastSyncOK,
}
cfg.Client.Syncer = wrapper
// Helper: emitir evento de forma no-bloqueante respetando ctx
emit := func(ev MatrixSyncEvent) {
select {
case evtCh <- ev:
case <-innerCtx.Done():
}
}
// Helper: extraer body de texto de Content.VeryRaw
extractBody := func(evt *event.Event) string {
raw := evt.Content.VeryRaw
if raw == nil {
return ""
}
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err != nil {
return ""
}
if b, ok := m["body"].(string); ok {
return b
}
return ""
}
// Registrar event handlers sobre el DefaultSyncer base
// m.room.message — mensajes de texto, imagen, archivo
baseSyncer.OnEventType(event.EventMessage, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "message",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Body: extractBody(evt),
Raw: evt,
})
})
// m.room.encrypted — mensajes cifrados (crypto helper los descifra si esta init)
baseSyncer.OnEventType(event.EventEncrypted, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "encrypted",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.room.redaction — redacciones de mensajes
baseSyncer.OnEventType(event.EventRedaction, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "redaction",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.reaction — reacciones emoji
baseSyncer.OnEventType(event.EventReaction, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "reaction",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.room.member — cambios de pertenencia a sala
baseSyncer.OnEventType(event.StateMember, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "membership",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.typing — efimero: quien esta escribiendo en una sala
baseSyncer.OnEventType(event.EphemeralEventTyping, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "typing",
RoomID: evt.RoomID.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.receipt — read receipts
baseSyncer.OnEventType(event.EphemeralEventReceipt, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "read_receipt",
RoomID: evt.RoomID.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.presence — presencia de usuarios
baseSyncer.OnEventType(event.EphemeralEventPresence, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "presence",
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// Goroutine principal
// SyncWithContext ya es un loop bloqueante que incluye retry via OnFailedSync.
// Esta goroutine solo reinicia si SyncWithContext retorna con error inesperado.
go func() {
defer func() {
cancel()
close(evtCh)
close(errCh)
}()
for {
select {
case <-innerCtx.Done():
return
default:
}
err := cfg.Client.SyncWithContext(innerCtx)
// ctx cancelado = salida limpia
if innerCtx.Err() != nil {
return
}
// SyncWithContext retorna nil si otro Sync() lo cancelo
if err == nil {
return
}
// Cualquier otro error: pequeno delay antes de reiniciar
select {
case <-innerCtx.Done():
return
case <-time.After(time.Duration(initialBackoff) * time.Millisecond):
}
}
}()
return &MatrixSyncServiceHandle{
Events: evtCh,
Errors: errCh,
Stop: stopFn,
}, nil
}
// isFatalMatrixError devuelve true si el error indica que no tiene sentido
// reintentar (token invalido, forbidden).
func isFatalMatrixError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "M_UNKNOWN_TOKEN") ||
strings.Contains(msg, "M_FORBIDDEN") ||
strings.Contains(msg, "401")
}