cluster

Infrastructure files for Nordgedanken and Midnightthoughts.
git clone git://archive.git.mtrnord.blog/MTRNord/cluster.git
Log | Files | Refs | README

rooms.go (9602B)


      1 // rooms.go — room list management: name calculation, DM detection, S3 persistence.
      2 package main
      3 
      4 import (
      5 	"context"
      6 	"encoding/json"
      7 	"fmt"
      8 	"log/slog"
      9 	"strings"
     10 
     11 	"maunium.net/go/mautrix"
     12 	"maunium.net/go/mautrix/event"
     13 	"maunium.net/go/mautrix/id"
     14 	"net/url"
     15 )
     16 
     17 type roomEntry struct {
     18 	RoomID         string   `json:"room_id"`
     19 	Name           string   `json:"name"`
     20 	Type           string   `json:"type"`
     21 	Aliases        []string `json:"aliases"`
     22 	CanonicalAlias string   `json:"canonical_alias,omitempty"`
     23 	MemberCount    int      `json:"member_count"`
     24 	Encrypted      bool     `json:"encrypted"`
     25 	ViaServers     []string `json:"via_servers,omitempty"`
     26 }
     27 
     28 // calcRoomName implements the Matrix room display-name algorithm:
     29 // explicit name → canonical alias → heroes → "Empty Room".
     30 func calcRoomName(
     31 	stateEvents []*event.Event,
     32 	heroes []id.UserID,
     33 	joinedCount, invitedCount int,
     34 	selfUserID id.UserID,
     35 ) string {
     36 	for _, ev := range stateEvents {
     37 		if ev.Type == event.StateRoomName {
     38 			if c := ev.Content.AsRoomName(); c != nil && c.Name != "" {
     39 				return c.Name
     40 			}
     41 		}
     42 	}
     43 	for _, ev := range stateEvents {
     44 		if ev.Type == event.StateCanonicalAlias {
     45 			if c := ev.Content.AsCanonicalAlias(); c != nil && c.Alias != "" {
     46 				return string(c.Alias)
     47 			}
     48 		}
     49 	}
     50 
     51 	memberNames := make(map[id.UserID]string)
     52 	for _, ev := range stateEvents {
     53 		if ev.Type == event.StateMember && ev.StateKey != nil {
     54 			uid := id.UserID(*ev.StateKey)
     55 			if c := ev.Content.AsMember(); c != nil && c.Displayname != "" {
     56 				memberNames[uid] = c.Displayname
     57 			} else {
     58 				local, _, _ := uid.Parse()
     59 				if local != "" {
     60 					memberNames[uid] = local
     61 				} else {
     62 					memberNames[uid] = string(uid)
     63 				}
     64 			}
     65 		}
     66 	}
     67 
     68 	var filtered []id.UserID
     69 	for _, h := range heroes {
     70 		if h != selfUserID {
     71 			filtered = append(filtered, h)
     72 		}
     73 	}
     74 
     75 	tentativeNames := make(map[id.UserID]string, len(filtered))
     76 	for _, h := range filtered {
     77 		if n, ok := memberNames[h]; ok {
     78 			tentativeNames[h] = n
     79 		} else {
     80 			local, _, _ := h.Parse()
     81 			if local != "" {
     82 				tentativeNames[h] = local
     83 			} else {
     84 				tentativeNames[h] = string(h)
     85 			}
     86 		}
     87 	}
     88 	nameCounts := make(map[string]int, len(filtered))
     89 	for _, n := range tentativeNames {
     90 		nameCounts[n]++
     91 	}
     92 	heroName := func(uid id.UserID) string {
     93 		n := tentativeNames[uid]
     94 		if nameCounts[n] > 1 {
     95 			_, server, err := uid.Parse()
     96 			if err == nil && server != "" {
     97 				return n + " (" + server + ")"
     98 			}
     99 			return string(uid)
    100 		}
    101 		return n
    102 	}
    103 
    104 	others := joinedCount + invitedCount - 1
    105 	if others < 0 {
    106 		others = 0
    107 	}
    108 	if len(filtered) == 0 {
    109 		if others == 0 {
    110 			return "Empty Room"
    111 		}
    112 		return ""
    113 	}
    114 
    115 	names := make([]string, len(filtered))
    116 	for i, h := range filtered {
    117 		names[i] = heroName(h)
    118 	}
    119 	unnamed := others - len(names)
    120 	if unnamed <= 0 {
    121 		switch len(names) {
    122 		case 1:
    123 			return names[0]
    124 		case 2:
    125 			return names[0] + " and " + names[1]
    126 		default:
    127 			return strings.Join(names[:len(names)-1], ", ") + ", and " + names[len(names)-1]
    128 		}
    129 	}
    130 	switch len(names) {
    131 	case 1:
    132 		return fmt.Sprintf("%s and %d others", names[0], unnamed)
    133 	case 2:
    134 		return fmt.Sprintf("%s, %s, and %d others", names[0], names[1], unnamed)
    135 	default:
    136 		return fmt.Sprintf("%s, and %d others", strings.Join(names, ", "), unnamed)
    137 	}
    138 }
    139 
    140 // collectViaServers returns up to 3 server names for matrix.to ?via= links.
    141 func collectViaServers(roomID id.RoomID, heroes []id.UserID, stateEvents []*event.Event) []string {
    142 	seen := make(map[string]bool)
    143 	var servers []string
    144 	add := func(s string) bool {
    145 		if s != "" && !seen[s] {
    146 			seen[s] = true
    147 			servers = append(servers, s)
    148 		}
    149 		return len(servers) >= 3
    150 	}
    151 	if idx := strings.LastIndex(string(roomID), ":"); idx >= 0 {
    152 		add(string(roomID)[idx+1:])
    153 	}
    154 	for _, h := range heroes {
    155 		_, server, err := h.Parse()
    156 		if err == nil && add(server) {
    157 			return servers
    158 		}
    159 	}
    160 	for _, ev := range stateEvents {
    161 		if len(servers) >= 3 {
    162 			break
    163 		}
    164 		if ev.Type != event.StateMember || ev.StateKey == nil {
    165 			continue
    166 		}
    167 		var mc struct {
    168 			Membership string `json:"membership"`
    169 		}
    170 		if ev.Content.VeryRaw != nil {
    171 			_ = json.Unmarshal(ev.Content.VeryRaw, &mc)
    172 		}
    173 		if mc.Membership != "join" {
    174 			continue
    175 		}
    176 		_, server, err := id.UserID(*ev.StateKey).Parse()
    177 		if err == nil {
    178 			add(server)
    179 		}
    180 	}
    181 	return servers
    182 }
    183 
    184 // fetchRoomNameFromState fetches the room name directly from the state API.
    185 // Used when the sync delta doesn't include the name event.
    186 func fetchRoomNameFromState(ctx context.Context, client *mautrix.Client, roomID id.RoomID) string {
    187 	base := url.PathEscape(string(roomID))
    188 	var nameContent struct {
    189 		Name string `json:"name"`
    190 	}
    191 	if err := matrixGetJSON(ctx, client,
    192 		"/_matrix/client/v3/rooms/"+base+"/state/m.room.name",
    193 		&nameContent,
    194 	); err == nil && nameContent.Name != "" {
    195 		return nameContent.Name
    196 	}
    197 	var aliasContent struct {
    198 		Alias string `json:"alias"`
    199 	}
    200 	if err := matrixGetJSON(ctx, client,
    201 		"/_matrix/client/v3/rooms/"+base+"/state/m.room.canonical_alias",
    202 		&aliasContent,
    203 	); err == nil && aliasContent.Alias != "" {
    204 		return aliasContent.Alias
    205 	}
    206 	return ""
    207 }
    208 
    209 // saveRoomList builds a JSON snapshot of all joined rooms and uploads it to S3.
    210 // Rooms absent from this sync delta are preserved from the prior list.
    211 func saveRoomList(ctx context.Context, client *mautrix.Client, syncResp *mautrix.RespSync, prefix string) error {
    212 	existing := make(map[string]roomEntry)
    213 	if raw, err := getDecryptedAgeFromS3(ctx, prefix+"/rooms-latest.json.age"); err != nil {
    214 		slog.Warn("Could not load previous room list", "error", err)
    215 	} else if raw != nil {
    216 		var prev []roomEntry
    217 		if err := json.Unmarshal(raw, &prev); err != nil {
    218 			slog.Warn("Previous room list is corrupt, starting fresh", "error", err)
    219 		} else {
    220 			for _, r := range prev {
    221 				// Retroactively reclassify 2-member non-space rooms stored as
    222 				// "normal" before the member-count heuristic was introduced.
    223 				if r.Type == "normal" && r.MemberCount > 0 && r.MemberCount <= 2 {
    224 					r.Type = "dm"
    225 				}
    226 				existing[r.RoomID] = r
    227 			}
    228 		}
    229 	}
    230 
    231 	dmRooms := getDMRooms(ctx, client, syncResp)
    232 
    233 	for roomID, joinedRoom := range syncResp.Rooms.Join {
    234 		var name, canonicalAlias string
    235 		var aliases []string
    236 		encrypted := false
    237 		rtype := "normal"
    238 		if dmRooms[roomID] {
    239 			rtype = "dm"
    240 		}
    241 
    242 		for _, ev := range joinedRoom.State.Events {
    243 			_ = ev.Content.ParseRaw(ev.Type)
    244 		}
    245 		for _, ev := range joinedRoom.State.Events {
    246 			switch ev.Type {
    247 			case event.StateRoomName:
    248 				if c := ev.Content.AsRoomName(); c.Name != "" {
    249 					name = c.Name
    250 				}
    251 			case event.StateCanonicalAlias:
    252 				if c := ev.Content.AsCanonicalAlias(); c.Alias != "" {
    253 					canonicalAlias = string(c.Alias)
    254 					for _, a := range c.AltAliases {
    255 						aliases = append(aliases, string(a))
    256 					}
    257 				}
    258 			case event.StateEncryption:
    259 				encrypted = true
    260 			case event.StateCreate:
    261 				if ev.Content.VeryRaw != nil {
    262 					var createContent struct {
    263 						Type string `json:"type"`
    264 					}
    265 					if json.Unmarshal(ev.Content.VeryRaw, &createContent) == nil && createContent.Type == "m.space" {
    266 						rtype = "space"
    267 					}
    268 				}
    269 			}
    270 		}
    271 
    272 		joinedCount, invitedCount := 0, 0
    273 		if joinedRoom.Summary.JoinedMemberCount != nil {
    274 			joinedCount = *joinedRoom.Summary.JoinedMemberCount
    275 		}
    276 		if joinedRoom.Summary.InvitedMemberCount != nil {
    277 			invitedCount = *joinedRoom.Summary.InvitedMemberCount
    278 		}
    279 		if rtype == "normal" && joinedCount > 0 && joinedCount <= 2 {
    280 			rtype = "dm"
    281 		}
    282 
    283 		if name == "" {
    284 			name = calcRoomName(joinedRoom.State.Events, joinedRoom.Summary.Heroes, joinedCount, invitedCount, client.UserID)
    285 		}
    286 		if name == "" {
    287 			name = fetchRoomNameFromState(ctx, client, roomID)
    288 		}
    289 		if name == "" {
    290 			if prev, ok := existing[string(roomID)]; ok {
    291 				name = prev.Name
    292 			}
    293 		}
    294 		if name == "" {
    295 			name = string(roomID)
    296 		}
    297 
    298 		existing[string(roomID)] = roomEntry{
    299 			RoomID:         string(roomID),
    300 			Name:           name,
    301 			Type:           rtype,
    302 			Aliases:        aliases,
    303 			CanonicalAlias: canonicalAlias,
    304 			MemberCount:    joinedCount,
    305 			Encrypted:      encrypted,
    306 			ViaServers:     collectViaServers(roomID, joinedRoom.Summary.Heroes, joinedRoom.State.Events),
    307 		}
    308 		slog.Info("Room", "type", rtype, "name", name)
    309 	}
    310 
    311 	rooms := make([]roomEntry, 0, len(existing))
    312 	for _, r := range existing {
    313 		rooms = append(rooms, r)
    314 	}
    315 	data, err := json.MarshalIndent(rooms, "", "  ")
    316 	if err != nil {
    317 		return err
    318 	}
    319 	if err := s3PutAge(ctx, prefix+"/rooms-"+dateStr+".json", data); err != nil {
    320 		return err
    321 	}
    322 	if err := s3PutAge(ctx, prefix+"/rooms-latest.json", data); err != nil {
    323 		return err
    324 	}
    325 	slog.Info("Uploaded room list", "rooms", len(rooms))
    326 	return nil
    327 }
    328 
    329 // getDMRooms returns the full set of room IDs marked as direct chats.
    330 // Always fetches from the account-data API so incremental syncs (which only
    331 // carry changed account-data events) don't see a stale map.
    332 func getDMRooms(ctx context.Context, client *mautrix.Client, syncResp *mautrix.RespSync) map[id.RoomID]bool {
    333 	dmRooms := make(map[id.RoomID]bool)
    334 	populate := func(direct event.DirectChatsEventContent) {
    335 		for _, roomIDs := range direct {
    336 			for _, rid := range roomIDs {
    337 				dmRooms[rid] = true
    338 			}
    339 		}
    340 	}
    341 	var direct event.DirectChatsEventContent
    342 	if err := getAccountData(ctx, client, "m.direct", &direct); err == nil {
    343 		populate(direct)
    344 		return dmRooms
    345 	}
    346 	// Fallback: use whatever the sync included.
    347 	for _, ev := range syncResp.AccountData.Events {
    348 		if ev.Type == event.AccountDataDirectChats {
    349 			if err := json.Unmarshal(ev.Content.VeryRaw, &direct); err == nil {
    350 				populate(direct)
    351 			}
    352 			break
    353 		}
    354 	}
    355 	return dmRooms
    356 }