621e8895c9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
301 lines
9.7 KiB
Go
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
|
|
}
|