feat(infra): auto-commit con 86 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user