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 }