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 }