refactor(infra): split de drivers pesados a subpaquetes + fix TestSSEHandler

Mueve duckdb_open, clickhouse_open, postgres_open, matrix_* y keyring_token_store
del paquete monolitico functions/infra a subpaquetes propios
(functions/infra/{duckdb,clickhouse,postgres,matrix,keyring}). El paquete infra ya
no importa los drivers (go-duckdb, clickhouse-go, pgx, mautrix, go-keyring), por lo
que las apps que solo usan funciones ligeras (process, cron, http, sqlite) dejan de
arrastrarlos. Reduccion de binarios: dag_engine 72->10MB, registry_api 70->8.7MB,
services_api 70->9MB, call_monitor 68->6.6MB, sqlite_api 70->8.9MB.

Los IDs del registry se mantienen estables (domain: infra en frontmatter). Se
preservan los build tags goolm/libolm de matrix_crypto_init.

Tambien corrige TestSSEHandler: el test leia el body con un unico Read() que con
HTTP chunked solo capturaba el primer evento; ahora usa io.ReadAll hasta EOF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 23:48:59 +02:00
parent 4ad4e7093a
commit 4bce095964
25 changed files with 55 additions and 51 deletions
@@ -0,0 +1,153 @@
package matrix
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,87 @@
---
name: matrix_client_init
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func MatrixClientInit(cfg MatrixClientInitConfig) (*MatrixClientInitResult, error)"
description: "Construye un *mautrix.Client listo para Sync a partir de un access_token ya obtenido (OIDC). Valida inputs, crea StoreDir con permisos 0700, descubre DeviceID via /whoami si no se proporciona. No maneja login — eso lo hace mas_oidc_loopback."
tags: [matrix, mautrix, sync, client, store, sqlite, infra, matrix-mas]
params:
- name: cfg.HomeserverURL
desc: "URL base del servidor Matrix (Synapse). Debe empezar con https://. Ejemplo: https://matrix-af2f3d.organic-machine.com"
- name: cfg.UserID
desc: "MXID del usuario en formato @local:servidor. Ejemplo: @egutierrez:matrix-af2f3d.organic-machine.com"
- name: cfg.AccessToken
desc: "Bearer token obtenido del flow OIDC (mas_oidc_loopback). No puede estar vacio."
- name: cfg.DeviceID
desc: "Device ID del cliente Matrix. Si vacio, se descubre via GET /whoami sumando ~100ms de latencia. Recomendado guardarlo en keyring tras el primer uso."
- name: cfg.StoreDir
desc: "Directorio donde se persiste el estado de sync (next_batch, filter_id). Se crea con permisos 0700. Puede ser relativo (se convierte a absoluto). Ejemplo: /home/lucas/.matrix_client_pc/egutierrez/"
- name: cfg.EnableCrypto
desc: "Si true, configura crypto store para E2EE (Olm/Megolm). En v0.1.0 devuelve error — implementacion completa en matrix_crypto_init_go_infra (issue 0150)."
output: "*MatrixClientInitResult con Client (*mautrix.Client listo para Sync), StorePath (ruta absoluta del directorio de estado) y CryptoPath (calculado pero vacio en v0.1.0)."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/id"
tested: true
tests:
- "HomeserverURL invalido"
- "UserID format invalido"
- "DeviceID vacio Whoami exitoso"
- "Whoami 401 token invalido"
- "EnableCrypto true devuelve error not implemented"
- "StoreDir se crea con permisos 0700"
test_file_path: "functions/infra/matrix/matrix_client_init_test.go"
file_path: "functions/infra/matrix/matrix_client_init.go"
---
## Ejemplo
```go
import (
"fmt"
infra "fn-registry/functions/infra"
)
cfg := infra.MatrixClientInitConfig{
HomeserverURL: "https://matrix-af2f3d.organic-machine.com",
UserID: "@egutierrez:matrix-af2f3d.organic-machine.com",
AccessToken: "mxat_xyz...", // de mas_oidc_loopback_go_infra
DeviceID: "", // se descubre via whoami
StoreDir: "/home/lucas/.matrix_client_pc/egutierrez/",
EnableCrypto: false, // v0.1.0
}
res, err := infra.MatrixClientInit(cfg)
if err != nil {
panic(err)
}
// res.Client listo para res.Client.Sync()
fmt.Println("DeviceID:", res.Client.DeviceID)
fmt.Println("StorePath:", res.StorePath)
```
## Cuando usarla
Llamar UNA vez por sesion, justo tras obtener el access_token via OIDC flow (`mas_oidc_loopback_go_infra`). El `*mautrix.Client` resultante se pasa al loop de Sync, al sender de mensajes y al handler de eventos. No volver a llamarla mientras el token siga valido.
## Gotchas
- **DeviceID vacio dispara GET /whoami**: suma ~100ms de latencia al arranque. Si guardas el DeviceID en keyring tras el primer uso (recomendado), pasalo directamente para evitarlo.
- **StoreDir permisos 0700**: la funcion los aplica en Linux/macOS. En Windows el `MkdirAll` no soporta permisos Unix — usar una ubicacion bajo `os.UserConfigDir()` que ya esta protegida por el SO.
- **mautrix-go v0.28+ cambio de tipos**: `id.UserID` y `id.DeviceID` ya no son alias de `string`. Importar `maunium.net/go/mautrix/id` para conversiones explicitas.
- **EnableCrypto=true devuelve error en v0.1.0**: la inicializacion correcta de Olm/Megolm con cross-signing requiere configurar `crypto.OlmMachine` con su propio SQLite — issue 0150 lo aborda completo. No hacerlo a medias aqui evita estados de crypto corrompidos.
- **M_UNKNOWN_TOKEN en Whoami**: si el AccessToken esta caducado y DeviceID es vacio, el error menciona explicitamente "UNKNOWN_TOKEN". El caller debe refrescar via OIDC (refresh_token de `MasOidcLoopbackResult`).
- **mautrix.NewClient normaliza HomeserverURL**: llama `ParseAndNormalizeBaseURL` internamente. Si la URL tiene trailing slash o path extra, se normaliza. Verificar en `res.Client.HomeserverURL.String()` si hay dudas.
## Notas
- El `*mautrix.Client` usa `NewMemorySyncStore()` por defecto (no persiste next_batch entre reinicios). Para persistencia real del sync state, el caller debe configurar un `SQLiteSyncStore` apuntando a `{StoreDir}/sync.db` — ver documentacion de mautrix-go SQLite stores.
- `CryptoPath` se calcula como `{StoreDir}/crypto.db` pero no se inicializa. Reservado para `matrix_crypto_init_go_infra` (issue 0150).
- La funcion no configura un `Syncer` ni `StateStore` custom — el caller lo hace segun sus necesidades (DefaultSyncer con handlers de eventos para matrix_client_pc).
@@ -0,0 +1,195 @@
package matrix
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// whoamiHandler devuelve un handler httptest que simula /_matrix/client/v3/account/whoami.
// Si statusCode != 200, devuelve un RespError de mautrix.
func whoamiHandler(t *testing.T, statusCode int, userID, deviceID string) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
if statusCode != http.StatusOK {
// mautrix espera JSON con errcode/error para errores Matrix
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(map[string]string{
"errcode": "M_UNKNOWN_TOKEN",
"error": "Invalid macaroon passed.",
})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"user_id": userID,
"device_id": deviceID,
})
}
}
func TestMatrixClientInit(t *testing.T) {
t.Run("HomeserverURL invalido", func(t *testing.T) {
tmpDir := t.TempDir()
cfg := MatrixClientInitConfig{
HomeserverURL: "not-a-url",
UserID: "@user:server",
AccessToken: "mxat_test",
StoreDir: tmpDir,
}
_, err := MatrixClientInit(cfg)
if err == nil {
t.Fatal("esperaba error con HomeserverURL invalido, got nil")
}
if !strings.Contains(err.Error(), "HomeserverURL") {
t.Errorf("error deberia mencionar HomeserverURL, got: %v", err)
}
})
t.Run("UserID format invalido", func(t *testing.T) {
tmpDir := t.TempDir()
cfg := MatrixClientInitConfig{
HomeserverURL: "https://matrix.example.com",
UserID: "egutierrez",
AccessToken: "mxat_test",
StoreDir: tmpDir,
}
_, err := MatrixClientInit(cfg)
if err == nil {
t.Fatal("esperaba error con UserID invalido, got nil")
}
if !strings.Contains(err.Error(), "UserID") {
t.Errorf("error deberia mencionar UserID, got: %v", err)
}
})
t.Run("DeviceID vacio Whoami exitoso", func(t *testing.T) {
const testUserID = "@egutierrez:test.matrix.org"
const testDeviceID = "ABCDEF1234"
srv := httptest.NewServer(whoamiHandler(t, http.StatusOK, testUserID, testDeviceID))
defer srv.Close()
tmpDir := t.TempDir()
cfg := MatrixClientInitConfig{
HomeserverURL: srv.URL,
UserID: testUserID,
AccessToken: "mxat_valid_token",
DeviceID: "", // fuerza Whoami
StoreDir: tmpDir,
}
res, err := MatrixClientInit(cfg)
if err != nil {
t.Fatalf("esperaba nil error, got: %v", err)
}
if res.Client == nil {
t.Fatal("Client es nil")
}
if string(res.Client.DeviceID) != testDeviceID {
t.Errorf("DeviceID: got %q, want %q", res.Client.DeviceID, testDeviceID)
}
if string(res.Client.UserID) != testUserID {
t.Errorf("UserID: got %q, want %q", res.Client.UserID, testUserID)
}
if res.Client.AccessToken != "mxat_valid_token" {
t.Errorf("AccessToken: got %q, want %q", res.Client.AccessToken, "mxat_valid_token")
}
if res.StorePath == "" {
t.Error("StorePath no puede estar vacio")
}
})
t.Run("Whoami 401 token invalido", func(t *testing.T) {
srv := httptest.NewServer(whoamiHandler(t, http.StatusUnauthorized, "", ""))
defer srv.Close()
tmpDir := t.TempDir()
cfg := MatrixClientInitConfig{
HomeserverURL: srv.URL,
UserID: "@egutierrez:test.matrix.org",
AccessToken: "mxat_expired",
DeviceID: "", // fuerza Whoami
StoreDir: tmpDir,
}
_, err := MatrixClientInit(cfg)
if err == nil {
t.Fatal("esperaba error con token invalido, got nil")
}
// Debe mencionar token invalido o M_UNKNOWN_TOKEN
errStr := err.Error()
if !strings.Contains(errStr, "UNKNOWN_TOKEN") && !strings.Contains(errStr, "token") && !strings.Contains(errStr, "Whoami") {
t.Errorf("error deberia mencionar token/Whoami, got: %v", err)
}
})
t.Run("EnableCrypto true devuelve error not implemented", func(t *testing.T) {
tmpDir := t.TempDir()
cfg := MatrixClientInitConfig{
HomeserverURL: "https://matrix.example.com",
UserID: "@user:matrix.example.com",
AccessToken: "mxat_test",
StoreDir: tmpDir,
EnableCrypto: true,
}
_, err := MatrixClientInit(cfg)
if err == nil {
t.Fatal("esperaba error con EnableCrypto=true, got nil")
}
if !strings.Contains(err.Error(), "not implemented") {
t.Errorf("error deberia mencionar 'not implemented', got: %v", err)
}
if !strings.Contains(err.Error(), "0150") {
t.Errorf("error deberia mencionar issue 0150, got: %v", err)
}
})
t.Run("StoreDir se crea con permisos 0700", func(t *testing.T) {
if os.Getenv("GOOS") == "windows" {
t.Skip("permisos Unix no aplican en Windows")
}
const testUserID = "@egutierrez:test.matrix.org"
const testDeviceID = "TESTDEVICE01"
srv := httptest.NewServer(whoamiHandler(t, http.StatusOK, testUserID, testDeviceID))
defer srv.Close()
base := t.TempDir()
// StoreDir que no existe aun — debe crearse
storeDir := filepath.Join(base, "matrix_state", "egutierrez")
cfg := MatrixClientInitConfig{
HomeserverURL: srv.URL,
UserID: testUserID,
AccessToken: "mxat_valid",
DeviceID: testDeviceID,
StoreDir: storeDir,
}
res, err := MatrixClientInit(cfg)
if err != nil {
t.Fatalf("esperaba nil error, got: %v", err)
}
if res.StorePath != storeDir {
t.Errorf("StorePath: got %q, want %q", res.StorePath, storeDir)
}
info, err := os.Stat(storeDir)
if err != nil {
t.Fatalf("StoreDir no fue creado: %v", err)
}
if !info.IsDir() {
t.Error("StoreDir no es un directorio")
}
// Verificar permisos 0700 (solo propietario)
mode := info.Mode().Perm()
if mode != 0700 {
t.Errorf("permisos StoreDir: got %04o, want 0700", mode)
}
})
}
@@ -0,0 +1,107 @@
//go:build goolm || libolm
package matrix
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,96 @@
---
name: matrix_crypto_init
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func MatrixCryptoInit(ctx context.Context, cfg MatrixCryptoInitConfig) (*MatrixCryptoInitResult, error)"
description: "Inicializa el crypto store Olm/Megolm para un *mautrix.Client usando cryptohelper v0.28+. Crea el SQLite store, carga la cuenta Olm, sube one-time keys al servidor y asigna client.Crypto para que SendMessageEvent cifre automaticamente en rooms E2EE."
tags: [matrix, mautrix, e2ee, olm, megolm, crypto, cryptohelper, infra, matrix-mas]
params:
- name: ctx
desc: "context.Context con deadline/cancel. Se propaga a helper.Init() que hace HTTP a Synapse. Usar timeout de al menos 5s (primera vez puede tardar ~500ms por /keys/upload)."
- name: cfg.Client
desc: "*mautrix.Client ya inicializado via MatrixClientInit. Debe tener AccessToken, UserID y DeviceID poblados. DeviceID es obligatorio — descubrirlo via Whoami antes si no lo tienes."
- name: cfg.StorePath
desc: "Ruta absoluta al archivo SQLite del crypto store. Separado del state store. Si el directorio padre no existe, se crea con permisos 0700. Ejemplo: /home/lucas/.config/matrix_client_pc/egutierrez/crypto.db"
- name: cfg.PickleKey
desc: "Exactamente 32 bytes usados para cifrar las sesiones Olm at-rest en el SQLite. Generar con crypto/rand.Read(). DEBE persistir entre arranques — guardar en keyring del sistema. Si se pierde, el store se vuelve inutilizable."
output: "*MatrixCryptoInitResult con Helper (*cryptohelper.CryptoHelper ya asignado a client.Crypto y listo para Sync/SendMessageEvent) y StorePath (ruta al SQLite). Llamar helper.Close() en shutdown."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/crypto/cryptohelper"
tested: true
tests:
- "Client nil devuelve error"
- "AccessToken vacio devuelve error"
- "UserID vacio devuelve error"
- "DeviceID vacio devuelve error"
- "StorePath vacio devuelve error"
- "StorePath relativo devuelve error"
- "PickleKey != 32 bytes devuelve error"
- "directorio del store se crea con permisos 0700"
- "input valido Init exito helper no nil"
- "Synapse 401 en keys upload devuelve error"
test_file_path: "functions/infra/matrix/matrix_crypto_init_test.go"
file_path: "functions/infra/matrix/matrix_crypto_init.go"
---
## Ejemplo
```go
import (
"context"
"crypto/rand"
infra "fn-registry/functions/infra"
)
// Paso 1: cliente ya inicializado (ver matrix_client_init_go_infra)
clientRes, err := infra.MatrixClientInit(infra.MatrixClientInitConfig{
HomeserverURL: "https://matrix-af2f3d.organic-machine.com",
UserID: "@egutierrez:matrix-af2f3d.organic-machine.com",
AccessToken: "mxat_xyz...",
DeviceID: "MYDEVICEID",
StoreDir: "/home/lucas/.config/matrix_client_pc/egutierrez/",
})
if err != nil { panic(err) }
// Paso 2: generar PickleKey (guardar en keyring, NO en codigo)
pickleKey := make([]byte, 32)
if _, err := rand.Read(pickleKey); err != nil { panic(err) }
// Persistir: secret-tool store --label="matrix pickle" service matrix account @user:server
// Paso 3: activar E2EE
ctx := context.Background()
cryptoRes, err := infra.MatrixCryptoInit(ctx, infra.MatrixCryptoInitConfig{
Client: clientRes.Client,
StorePath: "/home/lucas/.config/matrix_client_pc/egutierrez/crypto.db",
PickleKey: pickleKey,
})
if err != nil { panic(err) }
defer cryptoRes.Helper.Close()
// Ahora clientRes.Client.SendMessageEvent en rooms E2EE cifra automaticamente.
// El Syncer descifra mensajes recibidos tambien automaticamente.
```
## Cuando usarla
Llamar UNA vez por sesion, tras `MatrixClientInit` y ANTES de arrancar `client.Sync()`. El orden es critico: si Sync arranca antes, los primeros eventos cifrados llegan sin handler Olm y se pierden. Una vez asignado `client.Crypto`, el Sync loop gestiona cifrado y descifrado transparente sin codigo adicional.
## Gotchas
- **PickleKey DEBE sobrevivir entre arranques**: si pierdes los 32 bytes, el store SQLite no se puede abrir y debes hacer nuevo login con nuevo DeviceID. Guardar obligatoriamente en keyring: `secret-tool store --label="matrix pickle key" service matrix_client_pc account pickle_key_@egutierrez:servidor`.
- **DeviceID es obligatorio**: a diferencia de `MatrixClientInit` (que puede descubrirlo via Whoami), esta funcion falla si `Client.DeviceID` esta vacio para evitar crear un store huerfano vinculado a ningun dispositivo real.
- **StorePath debe ser persistente**: NO usar `/tmp/`. Si el store se pierde entre arranques, se pierden las sesiones Olm — los mensajes historicos en rooms E2EE NO se podran descifrar sin Key Backup (issue 0150 full).
- **Init() hace HTTP a Synapse**: primera vez ~500ms por `/keys/upload`. Usar context con timeout >= 5s. Si devuelve error con "M_UNKNOWN_TOKEN", el access token caducó — refrescar via OIDC.
- **Sin cross-signing/SAS**: otros dispositivos ven el tuyo como "unverified" (amber warning en Element). E2EE sigue funcionando — cifra y descifra OK via TOFU. Cross-signing e implementacion de verificacion quedan para issue 0150 completo.
- **Build tag obligatorio**: el archivo requiere `-tags goolm` (puro Go, sin CGO) o `-tags libolm` (CGO + libolm-dev instalado). Sin ninguno de los dos, el archivo no compila (build constraint).
- **client.Syncer debe ser ExtensibleSyncer**: `mautrix.DefaultSyncer` lo implementa. Si usas Syncer custom, verificar que implementa `mautrix.ExtensibleSyncer` o `NewCryptoHelper` fallara.
- **Cerrar el helper en shutdown**: `helper.Close()` cierra la conexion SQLite del store. Imprescindible para evitar WAL leak en el crypto.db.
@@ -0,0 +1,321 @@
//go:build goolm || libolm
package matrix
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// makeTestClient construye un *mautrix.Client apuntando al servidor dado con
// credenciales validas para los tests.
func makeTestClient(t *testing.T, serverURL string) *mautrix.Client {
t.Helper()
cli, err := mautrix.NewClient(serverURL, "@user:localhost", "test-token")
if err != nil {
t.Fatalf("mautrix.NewClient: %v", err)
}
cli.AccessToken = "test-token"
cli.UserID = id.UserID("@user:localhost")
cli.DeviceID = id.DeviceID("TESTDEVICE")
return cli
}
// validPickleKey genera una clave de 32 bytes para tests.
func validPickleKey() []byte {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
return key
}
// newSynapseMock crea un httptest.Server que responde a los endpoints
// necesarios para Init(): /keys/upload y /keys/query.
// Acepta un statusCode para /keys/upload (200 = exito, 401 = token invalido).
func newSynapseMock(t *testing.T, uploadStatus int) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
// POST /_matrix/client/v3/keys/upload -> one-time key counts
mux.HandleFunc("/_matrix/client/v3/keys/upload", func(w http.ResponseWriter, r *http.Request) {
if uploadStatus != http.StatusOK {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(uploadStatus)
resp := map[string]any{
"errcode": "M_UNKNOWN_TOKEN",
"error": "Invalid access token",
}
_ = json.NewEncoder(w).Encode(resp)
return
}
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"one_time_key_counts": map[string]int{
"signed_curve25519": 50,
},
}
_ = json.NewEncoder(w).Encode(resp)
})
// POST /_matrix/client/v3/keys/query -> empty device keys
mux.HandleFunc("/_matrix/client/v3/keys/query", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"device_keys": map[string]any{},
"failures": map[string]any{},
"master_keys": map[string]any{},
"user_signing_keys": map[string]any{},
"self_signing_keys": map[string]any{},
}
_ = json.NewEncoder(w).Encode(resp)
})
// GET /_matrix/client/v3/sync -> minimal empty sync
mux.HandleFunc("/_matrix/client/v3/sync", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"next_batch": "s0_1",
"rooms": map[string]any{},
"to_device": map[string]any{"events": []any{}},
"device_one_time_keys_count": map[string]any{},
}
_ = json.NewEncoder(w).Encode(resp)
})
// Catchall para no dejar requests colgados
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
})
return httptest.NewServer(mux)
}
func TestMatrixCryptoInit(t *testing.T) {
t.Run("Client nil devuelve error", func(t *testing.T) {
ctx := context.Background()
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: nil,
StorePath: "/tmp/crypto_test.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con Client nil, got nil")
}
if !strings.Contains(err.Error(), "Client no puede ser nil") {
t.Errorf("mensaje de error inesperado: %q", err.Error())
}
})
t.Run("AccessToken vacio devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "")
cli.UserID = "@user:localhost"
cli.DeviceID = "DEVID"
cli.AccessToken = ""
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "/tmp/crypto_test.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con AccessToken vacio, got nil")
}
})
t.Run("UserID vacio devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "", "token_abc")
cli.DeviceID = "DEVID"
cli.AccessToken = "token_abc"
cli.UserID = ""
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "/tmp/crypto_test.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con UserID vacio, got nil")
}
})
t.Run("DeviceID vacio devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "token_abc")
cli.AccessToken = "token_abc"
cli.UserID = "@user:localhost"
cli.DeviceID = ""
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "/tmp/crypto_test.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con DeviceID vacio, got nil")
}
})
t.Run("StorePath vacio devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "token")
cli.AccessToken = "token"
cli.UserID = "@user:localhost"
cli.DeviceID = id.DeviceID("DEVID")
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con StorePath vacio, got nil")
}
})
t.Run("StorePath relativo devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "token")
cli.AccessToken = "token"
cli.UserID = "@user:localhost"
cli.DeviceID = id.DeviceID("DEVID")
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "relative/path/crypto.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con StorePath relativo, got nil")
}
})
t.Run("PickleKey != 32 bytes devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "token")
cli.AccessToken = "token"
cli.UserID = "@user:localhost"
cli.DeviceID = id.DeviceID("DEVID")
// Clave de 16 bytes (demasiado corta)
shortKey := make([]byte, 16)
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "/tmp/crypto_test.db",
PickleKey: shortKey,
})
if err == nil {
t.Fatal("esperaba error con PickleKey de 16 bytes, got nil")
}
if !strings.Contains(err.Error(), "32 bytes") {
t.Errorf("mensaje de error debe mencionar '32 bytes', got %q", err.Error())
}
})
t.Run("directorio del store se crea con permisos 0700", func(t *testing.T) {
tmpDir := t.TempDir()
storeDir := filepath.Join(tmpDir, "sub", "crypto_store")
storePath := filepath.Join(storeDir, "crypto.db")
srv := newSynapseMock(t, http.StatusOK)
defer srv.Close()
cli := makeTestClient(t, srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// El Init puede fallar (e.g. sync loop), pero el directorio debe crearse.
_, _ = MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: storePath,
PickleKey: validPickleKey(),
})
if _, statErr := os.Stat(storeDir); os.IsNotExist(statErr) {
t.Fatalf("el directorio %q no fue creado", storeDir)
}
info, statErr := os.Stat(storeDir)
if statErr != nil {
t.Fatalf("no se pudo stat el directorio: %v", statErr)
}
perm := info.Mode().Perm()
if perm != 0700 {
t.Errorf("permisos del directorio: got %04o, want 0700", perm)
}
})
t.Run("input valido Init exito helper no nil", func(t *testing.T) {
tmpDir := t.TempDir()
storePath := filepath.Join(tmpDir, "crypto.db")
srv := newSynapseMock(t, http.StatusOK)
defer srv.Close()
cli := makeTestClient(t, srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
res, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: storePath,
PickleKey: validPickleKey(),
})
if err != nil {
t.Fatalf("MatrixCryptoInit failed: %v", err)
}
if res == nil {
t.Fatal("resultado es nil")
}
if res.Helper == nil {
t.Fatal("Helper es nil")
}
if res.StorePath != storePath {
t.Errorf("StorePath: got %q, want %q", res.StorePath, storePath)
}
if cli.Crypto == nil {
t.Error("client.Crypto no fue asignado")
}
// Verificar que el archivo SQLite fue creado
if _, err := os.Stat(storePath); os.IsNotExist(err) {
t.Error("archivo crypto.db no fue creado")
}
if err := res.Helper.Close(); err != nil {
t.Errorf("Helper.Close() error: %v", err)
}
})
t.Run("Synapse 401 en keys upload devuelve error", func(t *testing.T) {
tmpDir := t.TempDir()
storePath := filepath.Join(tmpDir, "crypto.db")
srv := newSynapseMock(t, http.StatusUnauthorized)
defer srv.Close()
cli := makeTestClient(t, srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: storePath,
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con Synapse 401, got nil")
}
if !strings.Contains(err.Error(), "helper.Init failed") {
t.Errorf("mensaje de error inesperado: %q", err.Error())
}
})
}
@@ -0,0 +1,121 @@
package matrix
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,99 @@
---
name: matrix_message_send
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: |
func MatrixSendText(ctx context.Context, client *mautrix.Client, roomID id.RoomID, body string) (id.EventID, error)
func MatrixSendMarkdown(ctx context.Context, client *mautrix.Client, roomID id.RoomID, markdown string) (id.EventID, error)
func MatrixSendReply(ctx context.Context, client *mautrix.Client, roomID id.RoomID, replyTo id.EventID, body string) (id.EventID, error)
func MatrixEditMessage(ctx context.Context, client *mautrix.Client, roomID id.RoomID, eventID id.EventID, newBody string) (id.EventID, error)
func MatrixSendReaction(ctx context.Context, client *mautrix.Client, roomID id.RoomID, targetEventID id.EventID, key string) (id.EventID, error)
description: "Envía mensajes Matrix con todas las variantes del compositor: texto plain, markdown con HTML sanitizado, reply con m.in_reply_to, edit (m.replace) y reaction (m.annotation). Si el room es E2EE y client.Crypto está configurado via matrix_crypto_init, mautrix cifra automáticamente."
tags: [matrix, mautrix, send, message, markdown, reply, edit, reaction, infra, matrix-mas]
params:
- name: ctx
desc: "Context para cancelación y timeout de la petición HTTP a Synapse."
- name: client
desc: "*mautrix.Client autenticado. Debe tener AccessToken, UserID y DeviceID. Si es nil, error inmediato."
- name: roomID
desc: "ID del room Matrix destino. Formato: !xxx:server."
- name: body / markdown / newBody
desc: "Contenido del mensaje. Para MatrixSendMarkdown se parsea con goldmark y se sanitiza con bluemonday UGCPolicy."
- name: replyTo / eventID / targetEventID
desc: "ID del evento referenciado (para reply, edit y reaction)."
- name: key
desc: "Emoji unicode raw para reaction (ej. '👍'). No shortcodes (:thumbsup:)."
output: "id.EventID del evento enviado por Synapse + error. El EventID permite referenciar el mensaje para edits, replies o reactions posteriores."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "context"
- "bytes"
- "fmt"
- "github.com/microcosm-cc/bluemonday"
- "github.com/yuin/goldmark"
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
tested: true
tests:
- "SendText body correcto y EventID parseado"
- "SendMarkdown bold convierte a HTML strong y sanitiza script"
- "SendReply m.relates_to m.in_reply_to presente"
- "EditMessage rel_type m.replace y m.new_content"
- "SendReaction tipo m.reaction con m.annotation y key"
- "SendText client nil devuelve error"
- "SendMarkdown client nil devuelve error"
- "SendReply client nil devuelve error"
- "EditMessage client nil devuelve error"
- "SendReaction client nil devuelve error"
test_file_path: "functions/infra/matrix/matrix_message_send_test.go"
file_path: "functions/infra/matrix/matrix_message_send.go"
---
## Ejemplo
```go
import (
"context"
infra "fn-registry/functions/infra"
"maunium.net/go/mautrix/id"
)
ctx := context.Background()
roomID := id.RoomID("!abc123:organic-machine.com")
// Texto plain
evID, err := infra.MatrixSendText(ctx, client, roomID, "Hola")
// Markdown: **bold**, `code`, > quote -> HTML sanitizado
evID, err = infra.MatrixSendMarkdown(ctx, client, roomID, "**bold** + `code`")
// Reply a un evento existente
evID, err = infra.MatrixSendReply(ctx, client, roomID, id.EventID("$orig:server"), "Si, totalmente")
// Edit de un mensaje ya enviado
evID, err = infra.MatrixEditMessage(ctx, client, roomID, id.EventID("$msg:server"), "texto corregido")
// Reaction emoji
evID, err = infra.MatrixSendReaction(ctx, client, roomID, id.EventID("$msg:server"), "👍")
```
## Cuando usarla
Llamar desde el compositor del cliente Matrix (`matrix_client_pc`) tras inicializar el cliente con `matrix_client_init`. Si el room es E2EE, llamar primero a `matrix_crypto_init` para que `client.Crypto` esté configurado — el cifrado es transparente, no requiere código extra en estas funciones.
## Gotchas
- **Markdown sanitization**: goldmark puede emitir tags HTML arbitrarios si el input los contiene. Esta función aplica `bluemonday.UGCPolicy()` + allowlist extra (`details`, `summary`, `code`, `pre`). Tags fuera de la allowlist como `<script>`, `<iframe>`, `<style>` son eliminados. El texto interno puede quedar como texto plano.
- **Edits sobre mensajes cifrados**: mautrix-go cifra el `m.new_content` también. Receivers que no tengan acceso a la session megolm no verán el edit — verán el mensaje original.
- **Reactions** son evento separado `m.reaction`, NO `m.room.message`. Algunos clientes Matrix viejos las ignoran. No se cifran aunque el room sea E2EE (limitación de mautrix-go).
- **Reply quote v0.1.0**: esta función NO inserta el texto del mensaje original en el body. Es responsabilidad del caller construir la cita si la necesita. v0.2.0 podría hacer fetch del original via state cache.
- **Edit racing**: si dos edits llegan al mismo tiempo al servidor, gana el de timestamp mayor (regla Matrix server-side). No hay protección contra races en esta función.
- **client nil**: todas las funciones validan `client != nil` y retornan error inmediato. No hacen validación del formato de `roomID` — Synapse responderá con error si es inválido.
@@ -0,0 +1,269 @@
package matrix
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// newMXTestClient construye un *mautrix.Client apuntando al servidor httptest dado.
func newMXTestClient(t *testing.T, serverURL string) *mautrix.Client {
t.Helper()
cli, err := mautrix.NewClient(serverURL, "@testuser:example.com", "mxat_test_token")
if err != nil {
t.Fatalf("newMXTestClient: %v", err)
}
cli.DeviceID = id.DeviceID("TESTDEVICE01")
return cli
}
// mxSendHandler devuelve un http.Handler que:
// - Acepta PUT /…/rooms/{roomID}/send/{eventType}/{txnID}
// - Devuelve {"event_id": "$fakeEvent123:example.com"} con 200
// - Guarda el body JSON decodificado en bodyOut y la path en pathOut para assertions
func mxSendHandler(t *testing.T, bodyOut *map[string]interface{}, pathOut *string) http.Handler {
t.Helper()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if pathOut != nil {
*pathOut = r.URL.Path
}
if r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if bodyOut != nil {
var parsed map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&parsed); err != nil {
t.Errorf("mxSendHandler: json decode: %v", err)
}
*bodyOut = parsed
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"event_id":"$fakeEvent123:example.com"}`))
})
}
func TestMatrixMessageSend(t *testing.T) {
ctx := context.Background()
const roomID = "!testroom:example.com"
const wantEventID = "$fakeEvent123:example.com"
t.Run("SendText body correcto y EventID parseado", func(t *testing.T) {
var body map[string]interface{}
srv := httptest.NewServer(mxSendHandler(t, &body, nil))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
evID, err := MatrixSendText(ctx, cli, id.RoomID(roomID), "Hola mundo")
if err != nil {
t.Fatalf("MatrixSendText error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
if got := body["msgtype"]; got != "m.text" {
t.Errorf("body['msgtype']: got %v, want 'm.text'", got)
}
if got := body["body"]; got != "Hola mundo" {
t.Errorf("body['body']: got %v, want 'Hola mundo'", got)
}
})
t.Run("SendMarkdown bold convierte a HTML strong y sanitiza script", func(t *testing.T) {
var body map[string]interface{}
srv := httptest.NewServer(mxSendHandler(t, &body, nil))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
evID, err := MatrixSendMarkdown(ctx, cli, id.RoomID(roomID), "**bold**")
if err != nil {
t.Fatalf("MatrixSendMarkdown error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
// Body debe ser el markdown original como fallback
if got := body["body"]; got != "**bold**" {
t.Errorf("body['body'] fallback: got %v, want '**bold**'", got)
}
// formatted_body debe contener <strong>bold</strong>
fmtBody, ok := body["formatted_body"].(string)
if !ok {
t.Fatalf("formatted_body no es string: %v", body["formatted_body"])
}
if !strings.Contains(fmtBody, "<strong>bold</strong>") {
t.Errorf("formatted_body no contiene <strong>bold</strong>, got: %q", fmtBody)
}
// format debe ser org.matrix.custom.html
if got := body["format"]; got != "org.matrix.custom.html" {
t.Errorf("format: got %v, want 'org.matrix.custom.html'", got)
}
// Sub-test: sanitizer elimina <script>
const xssPayload = `texto <script>alert(1)</script> seguro`
var body2 map[string]interface{}
srv2 := httptest.NewServer(mxSendHandler(t, &body2, nil))
defer srv2.Close()
cli2 := newMXTestClient(t, srv2.URL)
_, err = MatrixSendMarkdown(ctx, cli2, id.RoomID(roomID), xssPayload)
if err != nil {
t.Fatalf("MatrixSendMarkdown XSS error: %v", err)
}
fmtBody2, ok := body2["formatted_body"].(string)
if !ok {
t.Fatalf("formatted_body no es string (XSS test): %v", body2["formatted_body"])
}
// El sanitizer debe eliminar el tag <script>...</script> completo.
// goldmark convierte inline HTML a texto plano antes de sanitizar,
// por lo que el texto interior puede quedar como texto plano — eso es correcto.
if strings.Contains(fmtBody2, "<script>") {
t.Errorf("formatted_body contiene <script> — sanitizer no funciono: %q", fmtBody2)
}
if strings.Contains(fmtBody2, "</script>") {
t.Errorf("formatted_body contiene </script> — sanitizer no funciono: %q", fmtBody2)
}
})
t.Run("SendReply m.relates_to m.in_reply_to presente", func(t *testing.T) {
var body map[string]interface{}
srv := httptest.NewServer(mxSendHandler(t, &body, nil))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
const parentID = "$parentEvent:example.com"
evID, err := MatrixSendReply(ctx, cli, id.RoomID(roomID), id.EventID(parentID), "ack")
if err != nil {
t.Fatalf("MatrixSendReply error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
if got := body["body"]; got != "ack" {
t.Errorf("body['body']: got %v, want 'ack'", got)
}
relatesTo, ok := body["m.relates_to"].(map[string]interface{})
if !ok {
t.Fatalf("m.relates_to no es object, got: %v", body["m.relates_to"])
}
inReplyTo, ok := relatesTo["m.in_reply_to"].(map[string]interface{})
if !ok {
t.Fatalf("m.in_reply_to no es object, got: %v", relatesTo["m.in_reply_to"])
}
if got := inReplyTo["event_id"]; got != parentID {
t.Errorf("m.in_reply_to.event_id: got %v, want %q", got, parentID)
}
})
t.Run("EditMessage rel_type m.replace y m.new_content", func(t *testing.T) {
var body map[string]interface{}
srv := httptest.NewServer(mxSendHandler(t, &body, nil))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
const originalID = "$originalEvent:example.com"
evID, err := MatrixEditMessage(ctx, cli, id.RoomID(roomID), id.EventID(originalID), "texto editado")
if err != nil {
t.Fatalf("MatrixEditMessage error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
// fallback body
if got := body["body"]; got != "* texto editado" {
t.Errorf("body['body'] fallback: got %v, want '* texto editado'", got)
}
newContent, ok := body["m.new_content"].(map[string]interface{})
if !ok {
t.Fatalf("m.new_content no es object, got: %v", body["m.new_content"])
}
if got := newContent["body"]; got != "texto editado" {
t.Errorf("m.new_content.body: got %v, want 'texto editado'", got)
}
relatesTo, ok := body["m.relates_to"].(map[string]interface{})
if !ok {
t.Fatalf("m.relates_to no es object, got: %v", body["m.relates_to"])
}
if got := relatesTo["rel_type"]; got != "m.replace" {
t.Errorf("m.relates_to.rel_type: got %v, want 'm.replace'", got)
}
if got := relatesTo["event_id"]; got != originalID {
t.Errorf("m.relates_to.event_id: got %v, want %q", got, originalID)
}
})
t.Run("SendReaction tipo m.reaction con m.annotation y key", func(t *testing.T) {
var body map[string]interface{}
var capturedPath string
srv := httptest.NewServer(mxSendHandler(t, &body, &capturedPath))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
const targetID = "$targetEvent:example.com"
evID, err := MatrixSendReaction(ctx, cli, id.RoomID(roomID), id.EventID(targetID), "👍")
if err != nil {
t.Fatalf("MatrixSendReaction error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
// URL debe contener "m.reaction"
if !strings.Contains(capturedPath, "m.reaction") {
t.Errorf("URL path no contiene 'm.reaction': %q", capturedPath)
}
relatesTo, ok := body["m.relates_to"].(map[string]interface{})
if !ok {
t.Fatalf("m.relates_to no es object, got: %v", body["m.relates_to"])
}
if got := relatesTo["rel_type"]; got != "m.annotation" {
t.Errorf("m.relates_to.rel_type: got %v, want 'm.annotation'", got)
}
if got := relatesTo["key"]; got != "👍" {
t.Errorf("m.relates_to.key: got %v, want '👍'", got)
}
if got := relatesTo["event_id"]; got != targetID {
t.Errorf("m.relates_to.event_id: got %v, want %q", got, targetID)
}
})
t.Run("SendText client nil devuelve error", func(t *testing.T) {
_, err := MatrixSendText(ctx, nil, id.RoomID(roomID), "texto")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
t.Run("SendMarkdown client nil devuelve error", func(t *testing.T) {
_, err := MatrixSendMarkdown(ctx, nil, id.RoomID(roomID), "**md**")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
t.Run("SendReply client nil devuelve error", func(t *testing.T) {
_, err := MatrixSendReply(ctx, nil, id.RoomID(roomID), "$evID:x", "reply")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
t.Run("EditMessage client nil devuelve error", func(t *testing.T) {
_, err := MatrixEditMessage(ctx, nil, id.RoomID(roomID), "$evID:x", "new")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
t.Run("SendReaction client nil devuelve error", func(t *testing.T) {
_, err := MatrixSendReaction(ctx, nil, id.RoomID(roomID), "$evID:x", "👍")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
}
+300
View File
@@ -0,0 +1,300 @@
package matrix
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,65 @@
---
name: matrix_room_list
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func MatrixRoomList(ctx context.Context, cfg MatrixRoomListConfig) ([]RoomSummary, error)"
description: "Devuelve la lista de rooms Matrix en los que el usuario esta unido con metadata completa (nombre, alias, avatar, topic, encryption, space, DM, tags), ordenada por LastEventTs DESC."
tags: ["matrix", "mautrix", "rooms", "summary", "state", "infra", "matrix-mas"]
params:
- name: ctx
desc: "Context de la llamada. Cancela todas las HTTP requests en curso si se cancela."
- name: cfg.Client
desc: "Cliente mautrix autenticado. Debe haber completado al menos un Sync para que JoinedRooms devuelva datos frescos. No puede ser nil."
output: "[]RoomSummary ordenado por LastEventTs DESC (rooms mas recientes primero). Si LastEventTs es 0 para todos, ordena alfabeticamente por Name."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
tested: true
tests:
- "3 rooms devueltos con metadata correcta"
- "1 room sin m.room.name usa fallback name"
- "IsDirect set correctamente segun m.direct"
- "IsEncrypted set segun presencia de m.room.encryption"
- "client nil devuelve error"
test_file_path: "functions/infra/matrix/matrix_room_list_test.go"
file_path: "functions/infra/matrix/matrix_room_list.go"
---
## Ejemplo
```go
rooms, err := MatrixRoomList(ctx, MatrixRoomListConfig{Client: client})
if err != nil {
log.Fatal(err)
}
for _, r := range rooms {
fmt.Printf("%s [%s] enc=%v dm=%v members=%d\n",
r.Name, r.RoomID, r.IsEncrypted, r.IsDirect, r.MemberCount)
}
// Output ejemplo:
// General [!abc:server] enc=true dm=false members=12
// Alice [!xyz:server] enc=true dm=true members=2
```
## Cuando usarla
Usar tras al menos un Sync completado, para poblar el sidebar de rooms en la UI. Llamar periodicamente con un TTL de 30s o tras recibir eventos `m.room.*` / `m.direct` en el sync stream. Ideal para el panel lateral de `matrix_client_pc` y `admin_panel`.
## Gotchas
- **Costoso si muchos rooms**: cada room genera 3+ HTTP calls (State, Messages, m.tag). Para N=50 rooms son ~150 HTTP calls. Cachear en el backend con TTL 30s antes de exponer al frontend.
- **Sin sync previo**: si se llama antes del primer Sync completado, `JoinedRooms` puede devolver lista vacia o stale. Siempre hacer Sync primero.
- **LastEventTs puede ser 0**: mautrix Store en memoria no persiste el timestamp del ultimo evento. Si el store es en memoria (default), `Messages(limit=1)` hace una HTTP call extra por room. Si `LastEventTs == 0`, el room cae al fondo del orden (orden alfabetico por Name como desempate).
- **UnreadCount siempre 0 en v0.1.0**: los notification counters vienen del Sync response, no de la API de state. TODO: obtenerlos del Syncer internamente.
- **Spaces planos**: esta funcion devuelve joined rooms planos. No enumera recursivamente los children de un Space. Para arbol de Space, implementar funcion separada.
- **Content.ParseRaw idempotente**: mautrix `parseRoomStateArray` llama `ParseRaw` al deserializar el state. La funcion usa `ensureParsed` que es no-op si ya esta parseado.
- **IsDirect puede ser false si m.direct no esta sincronizado**: algunas implementaciones de Synapse no sincronizan `m.direct` inmediatamente. Si IsDirect es incorrecto, hacer un Sync completo primero.
@@ -0,0 +1,339 @@
package matrix
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// matrixTestServer simula las respuestas de Synapse para MatrixRoomList.
// Los room IDs contienen '!' que mautrix URL-codifica como %21 en el path;
// los handlers lo decodifican antes de hacer lookup.
type matrixTestServer struct {
*httptest.Server
joinedRooms []string // room IDs que devuelve /joined_rooms
roomNames map[string]string // roomID -> name (no seteado = sin m.room.name)
encryptedRooms map[string]bool // roomID -> tiene encryption event
directContent map[string][]string // userID -> []roomID
roomTags map[string][]string // roomID -> []tag names
}
func newMatrixTestServer(t *testing.T) *matrixTestServer {
t.Helper()
ts := &matrixTestServer{
joinedRooms: []string{},
roomNames: map[string]string{},
encryptedRooms: map[string]bool{},
directContent: map[string][]string{},
roomTags: map[string][]string{},
}
mux := http.NewServeMux()
// GET /_matrix/client/v3/joined_rooms
mux.HandleFunc("/_matrix/client/v3/joined_rooms", func(w http.ResponseWriter, r *http.Request) {
rooms := make([]string, len(ts.joinedRooms))
copy(rooms, ts.joinedRooms)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"joined_rooms": rooms})
})
// Prefix handler para /rooms/ y /user/
mux.HandleFunc("/_matrix/", func(w http.ResponseWriter, r *http.Request) {
// URL-decode el path completo para manejar %21 -> !
rawPath := r.URL.Path
decodedPath, err := url.PathUnescape(rawPath)
if err != nil {
decodedPath = rawPath
}
w.Header().Set("Content-Type", "application/json")
switch {
// /user/{uid}/account_data/m.direct
case strings.Contains(decodedPath, "/account_data/m.direct") && strings.Contains(decodedPath, "/user/"):
json.NewEncoder(w).Encode(ts.directContent)
// /rooms/{roomId}/state (full state array)
case strings.Contains(decodedPath, "/rooms/") && strings.HasSuffix(decodedPath, "/state"):
roomID := extractRoomIDFromPath(decodedPath, "/state")
ts.serveFullState(w, roomID)
// /rooms/{roomId}/messages
case strings.Contains(decodedPath, "/rooms/") && strings.Contains(decodedPath, "/messages"):
// Devolver chunk vacio para simplificar (LastEventTs = 0)
json.NewEncoder(w).Encode(map[string]any{
"chunk": []any{},
"start": "",
})
// /rooms/{roomId}/account_data/m.tag
case strings.Contains(decodedPath, "/rooms/") && strings.Contains(decodedPath, "/account_data/m.tag"):
roomID := extractRoomIDFromPath(decodedPath, "/account_data")
tags, ok := ts.roomTags[roomID]
if !ok || len(tags) == 0 {
http.NotFound(w, r)
return
}
tagMap := make(map[string]any)
for _, tag := range tags {
tagMap[tag] = map[string]any{}
}
json.NewEncoder(w).Encode(map[string]any{"tags": tagMap})
default:
http.NotFound(w, r)
}
})
srv := httptest.NewServer(mux)
ts.Server = srv
t.Cleanup(srv.Close)
return ts
}
// extractRoomIDFromPath extrae el roomID de /...rooms/{roomId}/{suffix}.
// suffix debe empezar con "/" (ej. "/state", "/account_data").
func extractRoomIDFromPath(path, suffix string) string {
// Encontrar el segmento entre /rooms/ y suffix
roomsIdx := strings.Index(path, "/rooms/")
if roomsIdx < 0 {
return ""
}
after := path[roomsIdx+len("/rooms/"):]
suffixIdx := strings.Index(after, suffix)
if suffixIdx < 0 {
// suffix no encontrado -> el roomID es lo que queda
return after
}
return after[:suffixIdx]
}
// serveFullState construye y escribe el array de state events para el room.
func (ts *matrixTestServer) serveFullState(w http.ResponseWriter, roomID string) {
events := []map[string]any{}
// m.room.name (si existe)
if name, ok := ts.roomNames[roomID]; ok && name != "" {
events = append(events, map[string]any{
"type": "m.room.name",
"state_key": "",
"content": map[string]any{"name": name},
"event_id": "$name",
"sender": "@bot:test",
"room_id": roomID,
})
}
// m.room.create (sin space)
events = append(events, map[string]any{
"type": "m.room.create",
"state_key": "",
"content": map[string]any{"room_version": "9"},
"event_id": "$create",
"sender": "@user:test",
"room_id": roomID,
})
// m.room.member: dos joined members
events = append(events, map[string]any{
"type": "m.room.member",
"state_key": "@alice:test",
"content": map[string]any{"membership": "join", "displayname": "Alice"},
"event_id": "$member1",
"sender": "@alice:test",
"room_id": roomID,
})
events = append(events, map[string]any{
"type": "m.room.member",
"state_key": "@bob:test",
"content": map[string]any{"membership": "join", "displayname": "Bob"},
"event_id": "$member2",
"sender": "@bob:test",
"room_id": roomID,
})
// m.room.encryption (si aplica)
if ts.encryptedRooms[roomID] {
events = append(events, map[string]any{
"type": "m.room.encryption",
"state_key": "",
"content": map[string]any{"algorithm": "m.megolm.v1.aes-sha2"},
"event_id": "$enc",
"sender": "@alice:test",
"room_id": roomID,
})
}
json.NewEncoder(w).Encode(events)
}
// newTestClient crea un cliente mautrix apuntando al servidor httptest.
func newTestClient(t *testing.T, srv *matrixTestServer) *mautrix.Client {
t.Helper()
cli, err := mautrix.NewClient(srv.URL, id.UserID("@user:test"), "test_token")
if err != nil {
t.Fatalf("NewClient: %v", err)
}
return cli
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
// Test 1: 3 rooms devueltos con metadata correcta.
func TestMatrixRoomList_ThreeRoomsMetadata(t *testing.T) {
t.Run("3 rooms devueltos con metadata correcta", func(t *testing.T) {
srv := newMatrixTestServer(t)
srv.joinedRooms = []string{"!room1:test", "!room2:test", "!room3:test"}
srv.roomNames = map[string]string{
"!room1:test": "General",
"!room2:test": "Engineering",
"!room3:test": "Random",
}
cli := newTestClient(t, srv)
rooms, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: cli})
if err != nil {
t.Fatalf("MatrixRoomList: %v", err)
}
if len(rooms) != 3 {
t.Fatalf("got %d rooms, want 3", len(rooms))
}
nameSet := map[string]bool{}
for _, r := range rooms {
nameSet[r.Name] = true
if r.RoomID == "" {
t.Error("RoomID vacio en algun room")
}
// State simulado tiene 2 joined members (alice + bob)
if r.MemberCount != 2 {
t.Errorf("room %s: got MemberCount=%d, want 2", r.RoomID, r.MemberCount)
}
}
for _, want := range []string{"General", "Engineering", "Random"} {
if !nameSet[want] {
t.Errorf("nombre %q no encontrado en rooms", want)
}
}
})
}
// Test 2: room sin m.room.name -> fallback name no vacio.
func TestMatrixRoomList_FallbackName(t *testing.T) {
t.Run("1 room sin m.room.name usa fallback name", func(t *testing.T) {
srv := newMatrixTestServer(t)
srv.joinedRooms = []string{"!noname:test"}
// No registramos nombre para !noname:test
cli := newTestClient(t, srv)
rooms, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: cli})
if err != nil {
t.Fatalf("MatrixRoomList: %v", err)
}
if len(rooms) != 1 {
t.Fatalf("got %d rooms, want 1", len(rooms))
}
r := rooms[0]
if r.Name == "" {
t.Error("Name no debe ser vacio tras fallback")
}
t.Logf("fallback name para room sin m.room.name: %q", r.Name)
})
}
// Test 3: IsDirect set correctamente segun m.direct.
func TestMatrixRoomList_IsDirect(t *testing.T) {
t.Run("IsDirect set correctamente segun m.direct", func(t *testing.T) {
srv := newMatrixTestServer(t)
srv.joinedRooms = []string{"!dm:test", "!group:test"}
srv.roomNames = map[string]string{
"!dm:test": "Alice DM",
"!group:test": "Team channel",
}
// m.direct: !dm:test es DM con @alice:test
srv.directContent = map[string][]string{
"@alice:test": {"!dm:test"},
}
cli := newTestClient(t, srv)
rooms, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: cli})
if err != nil {
t.Fatalf("MatrixRoomList: %v", err)
}
if len(rooms) != 2 {
t.Fatalf("got %d rooms, want 2", len(rooms))
}
for _, r := range rooms {
switch r.RoomID {
case "!dm:test":
if !r.IsDirect {
t.Errorf("!dm:test: IsDirect debe ser true")
}
case "!group:test":
if r.IsDirect {
t.Errorf("!group:test: IsDirect debe ser false")
}
}
}
})
}
// Test 4: IsEncrypted set segun presencia de m.room.encryption.
func TestMatrixRoomList_IsEncrypted(t *testing.T) {
t.Run("IsEncrypted set segun presencia de m.room.encryption", func(t *testing.T) {
srv := newMatrixTestServer(t)
srv.joinedRooms = []string{"!encrypted:test", "!plain:test"}
srv.roomNames = map[string]string{
"!encrypted:test": "Encrypted room",
"!plain:test": "Plain room",
}
srv.encryptedRooms = map[string]bool{
"!encrypted:test": true,
}
cli := newTestClient(t, srv)
rooms, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: cli})
if err != nil {
t.Fatalf("MatrixRoomList: %v", err)
}
if len(rooms) != 2 {
t.Fatalf("got %d rooms, want 2", len(rooms))
}
for _, r := range rooms {
switch r.RoomID {
case "!encrypted:test":
if !r.IsEncrypted {
t.Errorf("!encrypted:test: IsEncrypted debe ser true")
}
case "!plain:test":
if r.IsEncrypted {
t.Errorf("!plain:test: IsEncrypted debe ser false")
}
}
}
})
}
// Test 5: client nil -> error.
func TestMatrixRoomList_NilClient(t *testing.T) {
t.Run("client nil devuelve error", func(t *testing.T) {
_, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: nil})
if err == nil {
t.Fatal("se esperaba error para client nil, got nil")
}
if !strings.Contains(err.Error(), "nil") {
t.Errorf("el error deberia mencionar nil, got: %v", err)
}
})
}
@@ -0,0 +1,366 @@
package matrix
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")
}
@@ -0,0 +1,79 @@
---
name: matrix_sync_service
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func MatrixSyncService(ctx context.Context, cfg MatrixSyncServiceConfig) (*MatrixSyncServiceHandle, error)"
description: "Arranca el sync loop de mautrix contra Synapse en background con backoff exponencial, emite eventos Matrix normalizados via canal Go y expone funcion de stop idempotente."
tags: [matrix, mautrix, sync, longpoll, reconnect, goroutine, channels, infra, matrix-mas]
params:
- name: ctx
desc: "Context padre. Si se cancela, la goroutine sale limpiamente y cierra los channels."
- name: cfg.Client
desc: "*mautrix.Client ya inicializado con credenciales (HomeserverURL, AccessToken, UserID). Usar matrix_client_init para crearlo. Obligatorio."
- name: cfg.InitialBackoffMS
desc: "Milisegundos de espera inicial entre reintentos tras error de sync. Default: 1000 (1s)."
- name: cfg.MaxBackoffMS
desc: "Techo del backoff exponencial en ms. Default: 60000 (60s)."
- name: cfg.ChannelBuffer
desc: "Capacidad del buffer del canal Events. Si el consumer va lento y el buffer se llena, el sync se bloquea hasta que el consumer drene. Default: 256."
output: "*MatrixSyncServiceHandle con Events <-chan MatrixSyncEvent (canal de eventos normalizados), Errors <-chan error (errores transitorios no fatales), Stop func() (cancela y cierra todo, idempotente)."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
tested: true
tests:
- "RecibeMensajeYStop"
- "BackoffRecovery"
- "Error401NoExit"
- "StopIdempotente"
- "CtxCancelCierraChannels"
test_file_path: "functions/infra/matrix/matrix_sync_service_test.go"
file_path: "functions/infra/matrix/matrix_sync_service.go"
---
## Ejemplo
```go
ctx := context.Background()
h, err := MatrixSyncService(ctx, MatrixSyncServiceConfig{
Client: client, // *mautrix.Client de matrix_client_init
})
if err != nil {
panic(err)
}
defer h.Stop()
// Consumir errores transitorios en goroutine separada
go func() {
for e := range h.Errors {
log.Println("matrix sync error:", e)
}
}()
// Loop de eventos (bloquea hasta que h.Stop() se llame o ctx sea cancelado)
for ev := range h.Events {
fmt.Printf("[%s] %s: %s\n", ev.Type, ev.Sender, ev.Body)
}
```
## Cuando usarla
Usar despues de `MatrixClientInit` (y opcionalmente `MatrixCryptoInit`) para recibir el stream de eventos de Matrix en tiempo real. Es el servicio long-running central de cualquier cliente Matrix: matrix_client_pc, admin_panel, bots, monitores. Un solo `MatrixSyncService` por client, durante toda la vida de la aplicacion.
## Gotchas
- **Solo UN Sync por client**: dos goroutines llamando `SyncWithContext` simultaneamente sobre el mismo client rompe el `since` token y produce duplicados o perdidas. Esta funcion garantiza una sola goroutine de sync si es llamada una sola vez. NO llamar `MatrixSyncService` dos veces sobre el mismo `*mautrix.Client`.
- **Crypto antes del Sync**: mensajes `m.room.encrypted` que llegan antes de inicializar `MatrixCryptoInit` quedan sin descifrar (emitidos con `Type:"encrypted"`, `Body:""`, `Raw:*event.Event`). Inicializar crypto siempre ANTES de llamar a esta funcion.
- **Buffer de channel**: si el consumer no drena `Events` con suficiente rapidez, el sync se bloquea en el punto de emision. Synapse puede acumular deltas. Mantener el consumer rapido o aumentar `ChannelBuffer`.
- **Errores fatales (401/M_UNKNOWN_TOKEN)**: no cierran el servicio automaticamente — se emiten a `Errors` y el servicio espera con backoff maximo. El caller decide llamar `Stop()` y re-autenticar.
- **Stop idempotente**: llamar `Stop()` multiples veces es seguro; no causa panic.
- **Build tag**: el paquete `infra` requiere `-tags goolm` para compilar tests sin libolm (dependencia C de la crypto de mautrix). Los tests usan `//go:build goolm`.
@@ -0,0 +1,313 @@
//go:build goolm
package matrix
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// fakeSynapseServer crea un httptest.Server que simula Synapse para tests de sync.
// syncHandler recibe el numero de llamada /sync (1-indexed) y devuelve la respuesta.
// nil response significa bloquear hasta ctx cancelado.
func fakeSynapseServer(t *testing.T, syncFn func(call int, w http.ResponseWriter, r *http.Request)) *httptest.Server {
t.Helper()
var callCount int32
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/_matrix/client/v3/user/@alice:localhost/filter":
// mautrix necesita este endpoint para guardar el filtro; responder con un filter_id
_ = json.NewEncoder(w).Encode(map[string]interface{}{"filter_id": "f1"})
case r.URL.Path == "/_matrix/client/v3/sync" || r.URL.Path == "/_matrix/client/r0/sync":
n := int(atomic.AddInt32(&callCount, 1))
syncFn(n, w, r)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
// syncRespMessage construye una respuesta /sync con un m.room.message.
func syncRespMessage(nextBatch string) map[string]interface{} {
return map[string]interface{}{
"next_batch": nextBatch,
"rooms": map[string]interface{}{
"join": map[string]interface{}{
"!testroom:localhost": map[string]interface{}{
"timeline": map[string]interface{}{
"events": []interface{}{
map[string]interface{}{
"event_id": "$evt001:localhost",
"type": "m.room.message",
"sender": "@alice:localhost",
"origin_server_ts": int64(1700000000000),
"room_id": "!testroom:localhost",
"content": map[string]interface{}{
"msgtype": "m.text",
"body": "hola mundo",
},
},
},
"limited": false,
},
},
},
},
}
}
// newTestSyncClient crea un *mautrix.Client apuntando al servidor dado.
func newTestSyncClient(t *testing.T, serverURL string) *mautrix.Client {
t.Helper()
cli, err := mautrix.NewClient(serverURL, "@alice:localhost", "token-test")
if err != nil {
t.Fatalf("NewClient: %v", err)
}
cli.UserID = id.UserID("@alice:localhost")
return cli
}
// TestMatrixSyncService_RecibeMensajeYStop arranca el servicio, recibe 1 evento y hace Stop limpio.
func TestMatrixSyncService_RecibeMensajeYStop(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
if n == 1 {
_ = json.NewEncoder(w).Encode(syncRespMessage("nb_001"))
return
}
// Bloquear syncs subsiguientes hasta cancelacion
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_002"})
})
defer srv.Close()
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{
Client: cli,
ChannelBuffer: 16,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
// Esperar el primer evento
select {
case ev, ok := <-h.Events:
if !ok {
t.Fatal("canal cerrado antes de recibir evento")
}
if ev.Type != "message" {
t.Errorf("tipo esperado 'message', got %q", ev.Type)
}
if ev.Body != "hola mundo" {
t.Errorf("body esperado 'hola mundo', got %q", ev.Body)
}
if ev.Sender != "@alice:localhost" {
t.Errorf("sender esperado '@alice:localhost', got %q", ev.Sender)
}
if ev.RoomID != "!testroom:localhost" {
t.Errorf("roomID esperado '!testroom:localhost', got %q", ev.RoomID)
}
case <-time.After(5 * time.Second):
t.Fatal("timeout esperando evento")
}
// Stop limpio
h.Stop()
// Verificar que Events cierra tras Stop
timeout := time.After(3 * time.Second)
for {
select {
case _, ok := <-h.Events:
if !ok {
return // canal cerrado correctamente
}
case <-timeout:
t.Fatal("canal Events no cerro tras Stop")
}
}
}
// TestMatrixSyncService_BackoffRecovery verifica backoff con 2 errores 500 seguidos de exito.
func TestMatrixSyncService_BackoffRecovery(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
if n <= 2 {
// Primeras 2 llamadas: devolver 500
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"errcode": "M_UNKNOWN",
"error": "internal server error",
})
return
}
if n == 3 {
// Tercera llamada: respuesta correcta inmediata (no bloquear)
_ = json.NewEncoder(w).Encode(syncRespMessage("nb_recovery"))
return
}
// Cuarta en adelante: bloquear hasta cancelacion
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_x"})
})
defer srv.Close()
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{
Client: cli,
InitialBackoffMS: 50, // backoff corto para tests
MaxBackoffMS: 200,
ChannelBuffer: 16,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
defer h.Stop()
// Tras los fallos, debe llegar el evento de recovery
select {
case ev, ok := <-h.Events:
if !ok {
t.Fatal("canal cerrado antes de evento de recovery")
}
if ev.Type != "message" {
t.Errorf("tipo esperado 'message', got %q", ev.Type)
}
if ev.Body != "hola mundo" {
t.Errorf("body esperado 'hola mundo', got %q", ev.Body)
}
case <-time.After(8 * time.Second):
t.Fatal("timeout esperando evento de recovery tras backoff")
}
}
// TestMatrixSyncService_Error401NoExit verifica que 401 emite error pero no cierra el servicio.
func TestMatrixSyncService_Error401NoExit(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
if n == 1 {
// Primera llamada: 401 M_UNKNOWN_TOKEN
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"errcode": "M_UNKNOWN_TOKEN",
"error": "Invalid macaroon passed.",
})
return
}
// Bloquear: el servicio espera en backoff maximo
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_x"})
})
defer srv.Close()
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{
Client: cli,
InitialBackoffMS: 50,
MaxBackoffMS: 200,
ChannelBuffer: 8,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
// Debe recibir al menos un error (fatal 401)
select {
case syncErr := <-h.Errors:
if syncErr == nil {
t.Error("error esperado no nil")
}
case <-time.After(4 * time.Second):
t.Fatal("timeout esperando error 401 en canal Errors")
}
// El canal Events NO debe estar cerrado — el servicio sigue activo
select {
case _, ok := <-h.Events:
if !ok {
t.Fatal("canal Events no debia cerrarse con error 401 (dejar al caller decidir via Stop)")
}
case <-time.After(300 * time.Millisecond):
// Correcto: canal sigue abierto
}
h.Stop()
// Tras Stop, Events debe cerrarse
select {
case _, ok := <-h.Events:
if !ok {
return // OK
}
case <-time.After(3 * time.Second):
t.Fatal("canal Events no cerro tras Stop despues de error 401")
}
}
// TestMatrixSyncService_StopIdempotente verifica que Stop() dos veces no causa panic.
func TestMatrixSyncService_StopIdempotente(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_1"})
})
defer srv.Close()
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{
Client: cli,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
// Llamar Stop dos veces — no debe panic
defer func() {
if r := recover(); r != nil {
t.Errorf("Stop() dos veces causó panic: %v", r)
}
}()
h.Stop()
h.Stop()
}
// TestMatrixSyncService_CtxCancelCierraChannels verifica que cancelar ctx cierra Events < 1s.
func TestMatrixSyncService_CtxCancelCierraChannels(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_ctx"})
})
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(ctx, MatrixSyncServiceConfig{
Client: cli,
ChannelBuffer: 4,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
// Cancelar contexto padre
cancel()
// Events debe cerrarse en menos de 1 segundo
deadline := time.After(1 * time.Second)
for {
select {
case _, ok := <-h.Events:
if !ok {
return // canal cerrado correctamente
}
case <-deadline:
t.Fatal("canal Events no cerro en 1s tras cancelar ctx")
}
}
}