Files
fn_registry/functions/infra/matrix_room_list.go
T
egutierrez 621e8895c9 feat(infra): auto-commit con 86 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:38:15 +02:00

301 lines
9.7 KiB
Go

package infra
import (
"context"
"fmt"
"log"
"sort"
"strings"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// RoomSummary es el resumen de una room Matrix para renderizar en el sidebar de un cliente.
type RoomSummary struct {
RoomID string `json:"room_id"`
Name string `json:"name,omitempty"` // m.room.name o fallback
CanonicalAlias string `json:"canonical_alias,omitempty"` // #room:server
AvatarMxc string `json:"avatar_mxc,omitempty"` // mxc://...
Topic string `json:"topic,omitempty"`
IsDirect bool `json:"is_direct"` // m.direct account_data
IsSpace bool `json:"is_space"` // m.room.type == m.space
IsEncrypted bool `json:"is_encrypted"` // m.room.encryption state event presente
MemberCount int `json:"member_count"`
LastEventTs int64 `json:"last_event_ts"` // unix ms del ultimo evento conocido
UnreadCount int `json:"unread_count"` // notifications.unread + highlight
Tags []string `json:"tags,omitempty"` // m.tag account_data
}
// MatrixRoomListConfig agrupa los parametros de MatrixRoomList.
type MatrixRoomListConfig struct {
Client *mautrix.Client
}
// MatrixRoomList devuelve todos los rooms en los que el usuario esta unido,
// ordenados por LastEventTs DESC (recientes primero).
//
// Estrategia:
// 1. JoinedRooms() para la lista de room IDs.
// 2. m.direct account_data para detectar DMs.
// 3. Para cada room: State() -> nombre, alias, topic, avatar, encryption, space, members.
// 4. Messages(limit=1) -> LastEventTs (TODO: coste N*HTTP; cachear con TTL 30s).
// 5. GetRoomAccountData("m.tag") -> Tags.
//
// Sub-operaciones que fallan por room concreto no abortan el global.
// LastEventTs puede ser 0 si el store no lo cachea (ver ## Gotchas del .md).
func MatrixRoomList(ctx context.Context, cfg MatrixRoomListConfig) ([]RoomSummary, error) {
if cfg.Client == nil {
return nil, fmt.Errorf("matrix_room_list: client no puede ser nil")
}
client := cfg.Client
// 1. Rooms unidos
respJoined, err := client.JoinedRooms(ctx)
if err != nil {
return nil, fmt.Errorf("matrix_room_list: JoinedRooms: %w", err)
}
if len(respJoined.JoinedRooms) == 0 {
return []RoomSummary{}, nil
}
// 2. m.direct -> set roomID -> true
directSet := loadDirectRooms(ctx, client)
// 3. Construir summaries (secuencial para v0.1.0)
results := make([]RoomSummary, 0, len(respJoined.JoinedRooms))
for _, roomID := range respJoined.JoinedRooms {
s := buildRoomSummaryFromState(ctx, client, roomID, directSet)
results = append(results, s)
}
// 4. Ordenar DESC por LastEventTs; si empatan (ej. todo 0) -> alfabetico por Name
sort.Slice(results, func(i, j int) bool {
if results[i].LastEventTs != results[j].LastEventTs {
return results[i].LastEventTs > results[j].LastEventTs
}
return results[i].Name < results[j].Name
})
return results, nil
}
// loadDirectRooms carga m.direct account_data y devuelve un set roomID -> true.
// Falla silenciosamente: si hay error devuelve mapa vacio (IsDirect quedara false).
func loadDirectRooms(ctx context.Context, client *mautrix.Client) map[id.RoomID]bool {
result := make(map[id.RoomID]bool)
var directContent event.DirectChatsEventContent
if err := client.GetAccountData(ctx, "m.direct", &directContent); err != nil {
log.Printf("matrix_room_list: GetAccountData(m.direct) warning: %v", err)
return result
}
for _, rooms := range directContent {
for _, rid := range rooms {
result[rid] = true
}
}
return result
}
// buildRoomSummaryFromState construye el RoomSummary para un room concreto.
// Si State() falla usa el roomID como Name de emergencia.
func buildRoomSummaryFromState(ctx context.Context, client *mautrix.Client, roomID id.RoomID, directSet map[id.RoomID]bool) RoomSummary {
s := RoomSummary{
RoomID: string(roomID),
IsDirect: directSet[roomID],
}
// State del room
stateMap, err := client.State(ctx, roomID)
if err != nil {
log.Printf("matrix_room_list: State(%s) warning: %v", roomID, err)
s.Name = deriveRoomName(&s, nil)
return s
}
fillStateFields(&s, stateMap)
s.Name = deriveRoomName(&s, stateMap)
// Tags: m.tag room account_data
s.Tags = loadRoomTags(ctx, client, roomID)
// LastEventTs: Messages(limit=1, dir=backward)
// TODO(0148): caro N*HTTP -> cachear en backend con TTL 30s.
msgs, err := client.Messages(ctx, roomID, "", "", mautrix.DirectionBackward, nil, 1)
if err != nil {
log.Printf("matrix_room_list: Messages(%s) warning: %v", roomID, err)
// No fatal: LastEventTs queda 0 y el room cae al fondo del orden
} else if msgs != nil && len(msgs.Chunk) > 0 {
s.LastEventTs = msgs.Chunk[0].Timestamp
}
return s
}
// ensureParsed llama ParseRaw si el contenido no esta aun parseado.
// ParseRaw devuelve ErrContentAlreadyParsed cuando ya fue parseado (p.ej.
// por parseRoomStateArray al deserializar el state); en ese caso ignoramos
// el error y usamos el Parsed existente.
func ensureParsed(c *event.Content, evtType event.Type) {
if c.Parsed == nil {
_ = c.ParseRaw(evtType)
}
}
// fillStateFields rellena los campos del RoomSummary a partir del state map.
// parseRoomStateArray ya llama ParseRaw al deserializar, por lo que es posible
// que Content.Parsed este ya populado. ensureParsed maneja ambos casos.
func fillStateFields(s *RoomSummary, stateMap mautrix.RoomStateMap) {
// m.room.name
if nameEvts, ok := stateMap[event.StateRoomName]; ok {
if nameEvt, ok := nameEvts[""]; ok {
ensureParsed(&nameEvt.Content, event.StateRoomName)
if c := nameEvt.Content.AsRoomName(); c != nil {
s.Name = c.Name
}
}
}
// m.room.canonical_alias
if aliasEvts, ok := stateMap[event.StateCanonicalAlias]; ok {
if aliasEvt, ok := aliasEvts[""]; ok {
ensureParsed(&aliasEvt.Content, event.StateCanonicalAlias)
if c := aliasEvt.Content.AsCanonicalAlias(); c != nil {
s.CanonicalAlias = string(c.Alias)
}
}
}
// m.room.avatar
if avatarEvts, ok := stateMap[event.StateRoomAvatar]; ok {
if avatarEvt, ok := avatarEvts[""]; ok {
ensureParsed(&avatarEvt.Content, event.StateRoomAvatar)
if c := avatarEvt.Content.AsRoomAvatar(); c != nil {
s.AvatarMxc = string(c.URL)
}
}
}
// m.room.topic
if topicEvts, ok := stateMap[event.StateTopic]; ok {
if topicEvt, ok := topicEvts[""]; ok {
ensureParsed(&topicEvt.Content, event.StateTopic)
if c := topicEvt.Content.AsTopic(); c != nil {
s.Topic = c.Topic
}
}
}
// m.room.encryption (existence = encrypted)
if encEvts, ok := stateMap[event.StateEncryption]; ok {
if _, ok := encEvts[""]; ok {
s.IsEncrypted = true
}
}
// m.room.create -> IsSpace si type == "m.space"
if createEvts, ok := stateMap[event.StateCreate]; ok {
if createEvt, ok := createEvts[""]; ok {
ensureParsed(&createEvt.Content, event.StateCreate)
if c := createEvt.Content.AsCreate(); c != nil {
s.IsSpace = c.Type == event.RoomTypeSpace
}
}
}
// m.room.member: contar membership == join
if memberEvts, ok := stateMap[event.StateMember]; ok {
count := 0
for _, memberEvt := range memberEvts {
ensureParsed(&memberEvt.Content, event.StateMember)
if c := memberEvt.Content.AsMember(); c != nil && c.Membership == event.MembershipJoin {
count++
}
}
s.MemberCount = count
}
}
// deriveRoomName calcula el nombre display para el room siguiendo la jerarquia:
// 1. Name (ya seteado desde m.room.name).
// 2. CanonicalAlias.
// 3. "Direct Message" si IsDirect.
// 4. Lista de otros miembros si los hay (max 3).
// 5. "Empty room" si MemberCount <= 1.
func deriveRoomName(s *RoomSummary, stateMap mautrix.RoomStateMap) string {
if s.Name != "" {
return s.Name
}
if s.CanonicalAlias != "" {
return s.CanonicalAlias
}
if s.IsDirect {
// Intentar obtener displayname del otro miembro desde el state
if stateMap != nil {
if memberEvts, ok := stateMap[event.StateMember]; ok {
for userKey, memberEvt := range memberEvts {
ensureParsed(&memberEvt.Content, event.StateMember)
if c := memberEvt.Content.AsMember(); c != nil &&
c.Membership == event.MembershipJoin &&
userKey != "" {
if c.Displayname != "" {
return c.Displayname
}
return userKey // user ID como fallback
}
}
}
}
return "Direct Message"
}
if stateMap != nil && s.MemberCount > 1 {
// Lista de displaynames de otros miembros (max 3)
names := collectMemberNames(stateMap, 3)
if len(names) > 0 {
return strings.Join(names, ", ")
}
}
return "Empty room"
}
// collectMemberNames extrae hasta maxN displaynames de joined members del state.
func collectMemberNames(stateMap mautrix.RoomStateMap, maxN int) []string {
names := make([]string, 0, maxN)
if memberEvts, ok := stateMap[event.StateMember]; ok {
for userKey, memberEvt := range memberEvts {
if len(names) >= maxN {
break
}
ensureParsed(&memberEvt.Content, event.StateMember)
if c := memberEvt.Content.AsMember(); c != nil && c.Membership == event.MembershipJoin {
if c.Displayname != "" {
names = append(names, c.Displayname)
} else if userKey != "" {
names = append(names, userKey)
}
}
}
}
return names
}
// loadRoomTags carga m.tag room account_data y devuelve los tag names como []string.
// Falla silenciosamente devolviendo nil.
func loadRoomTags(ctx context.Context, client *mautrix.Client, roomID id.RoomID) []string {
var tagContent event.TagEventContent
if err := client.GetRoomAccountData(ctx, roomID, "m.tag", &tagContent); err != nil {
// No fatal: rooms sin tags dan 404, lo cual es normal
return nil
}
if len(tagContent.Tags) == 0 {
return nil
}
tags := make([]string, 0, len(tagContent.Tags))
for tag := range tagContent.Tags {
tags = append(tags, string(tag))
}
sort.Strings(tags) // orden determinista
return tags
}