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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user