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