From e7195de31b414305d1541c7d06debf5994a819df Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Sat, 3 Oct 2026 21:00:03 +0100 Subject: [PATCH 01/12] Normalize request JSON and decode Matrix path parameters --- internal/routes/client/account.go | 41 ++---- internal/routes/client/accountdata.go | 32 ++--- internal/routes/client/devices.go | 4 +- internal/routes/client/keybackup.go | 12 +- internal/routes/client/presence.go | 6 +- internal/routes/client/pushers.go | 22 ++- internal/routes/client/roomcreate.go | 26 ++-- internal/routes/client/roomevent.go | 7 +- internal/routes/client/roommember.go | 32 ++--- internal/routes/client/roomsend.go | 28 ++-- internal/routes/client/todevice.go | 3 +- internal/routes/client/userprofiles.go | 7 +- internal/routes/debug/debugevents.go | 5 +- internal/routes/debug/debugroom.go | 7 +- internal/routes/debug/debugsync.go | 2 +- internal/util/request.go | 141 +++++++++++-------- internal/util/request_test.go | 184 +++++++++++++++++++++++++ 17 files changed, 363 insertions(+), 196 deletions(-) create mode 100644 internal/util/request_test.go diff --git a/internal/routes/client/account.go b/internal/routes/client/account.go index 8370bb3..5e1bb88 100644 --- a/internal/routes/client/account.go +++ b/internal/routes/client/account.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "errors" - "io" "net/http" "strings" @@ -44,9 +43,9 @@ type reqLogin struct { // https://spec.matrix.org/v1.11/client-server-api/#post_matrixclientv3login func (c *ClientRoutes) Login(w http.ResponseWriter, r *http.Request) { - var req reqLogin - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) + req, respErr := util.ParseRequestJSON[reqLogin](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) return } @@ -237,7 +236,7 @@ func (c *ClientRoutes) Register(w http.ResponseWriter, r *http.Request) { return } - params, rawAuth, respErr := parseUIARequestBody(r, false) + params, rawAuth, respErr := parseUIARequestBody(w, r, false) if respErr != nil { if respErr.ErrCode == mautrix.MTooLarge.ErrCode { util.ResponseJSON(w, r, http.StatusRequestEntityTooLarge, respErr) @@ -414,26 +413,16 @@ func respondPasswordUIA(w http.ResponseWriter, r *http.Request, session, errCode }) } -func parseUIARequestBody(r *http.Request, allowEmpty bool) (json.RawMessage, json.RawMessage, *mautrix.RespError) { - var encoded json.RawMessage - bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, (64<<10)+1)) - if err != nil { - return nil, nil, &mautrix.MNotJSON - } - if len(bodyBytes) > 64<<10 { - return nil, nil, &mautrix.MTooLarge +// parseUIARequestBody splits a request body into its parameters and its auth. With allowEmpty a body +// that is not JSON is taken as an empty object, as Synapse takes it for deleting a device. +func parseUIARequestBody(w http.ResponseWriter, r *http.Request, allowEmpty bool) (json.RawMessage, json.RawMessage, *mautrix.RespError) { + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + encoded, respErr := util.ParseRequestJSON[json.RawMessage](r) + if respErr != nil && allowEmpty && respErr.ErrCode == mautrix.MNotJSON.ErrCode { + encoded, respErr = json.RawMessage("{}"), nil } - decoder := json.NewDecoder(bytes.NewReader(bodyBytes)) - if err := decoder.Decode(&encoded); err != nil { - if allowEmpty && errors.Is(err, io.EOF) { - encoded = json.RawMessage("{}") - } else { - return nil, nil, &mautrix.MNotJSON - } - } - var trailing json.RawMessage - if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { - return nil, nil, &mautrix.MNotJSON + if respErr != nil { + return nil, nil, respErr } var body map[string]any objectDecoder := json.NewDecoder(bytes.NewReader(encoded)) @@ -580,7 +569,7 @@ func (c *ClientRoutes) logout(w http.ResponseWriter, r *http.Request, all bool) } func (c *ClientRoutes) Refresh(w http.ResponseWriter, r *http.Request) { - body, _, parseErr := parseUIARequestBody(r, false) + body, _, parseErr := parseUIARequestBody(w, r, false) if parseErr != nil { if parseErr.ErrCode == mautrix.MTooLarge.ErrCode { util.ResponseJSON(w, r, http.StatusRequestEntityTooLarge, parseErr) @@ -611,7 +600,7 @@ func (c *ClientRoutes) Refresh(w http.ResponseWriter, r *http.Request) { } func (c *ClientRoutes) ChangePassword(w http.ResponseWriter, r *http.Request) { - params, rawAuth, respErr := parseUIARequestBody(r, false) + params, rawAuth, respErr := parseUIARequestBody(w, r, false) if respErr != nil { if respErr.ErrCode == mautrix.MTooLarge.ErrCode { util.ResponseJSON(w, r, http.StatusRequestEntityTooLarge, respErr) diff --git a/internal/routes/client/accountdata.go b/internal/routes/client/accountdata.go index 6bf6d40..94d9063 100644 --- a/internal/routes/client/accountdata.go +++ b/internal/routes/client/accountdata.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "net/url" @@ -35,9 +34,9 @@ func (c *ClientRoutes) SetAccountData(w http.ResponseWriter, r *http.Request) { adType := event.NewEventType(chi.URLParam(r, "type")) // Decode + re-encode the content as JSON to ensure validity - var content map[string]any - if err := json.NewDecoder(r.Body).Decode(&content); err != nil { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) + content, respErr := util.ParseRequestJSON[map[string]any](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) return } contentBytes, _ := json.Marshal(content) @@ -141,25 +140,16 @@ func (c *ClientRoutes) updateRoomTag(w http.ResponseWriter, r *http.Request, rem } var encoded json.RawMessage if !remove { - body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, accounts.MaxRoomTagsBytes)) - if err != nil { - var sizeErr *http.MaxBytesError - if errors.As(err, &sizeErr) { - util.ResponseJSON(w, r, http.StatusRequestEntityTooLarge, &mautrix.MTooLarge) + r.Body = http.MaxBytesReader(w, r.Body, accounts.MaxRoomTagsBytes) + content, respErr := util.ParseRequestJSON[map[string]json.RawMessage](r) + if respErr != nil { + if respErr.ErrCode == mautrix.MTooLarge.ErrCode { + util.ResponseJSON(w, r, http.StatusRequestEntityTooLarge, respErr) } else { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) + util.ResponseErrorJSON(w, r, *respErr) } return } - if !json.Valid(body) { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) - return - } - var content map[string]json.RawMessage - if err := json.Unmarshal(body, &content); err != nil || content == nil { - util.ResponseErrorJSON(w, r, mautrix.MBadJSON) - return - } if raw, ok := content["order"]; ok { var order *float64 if err := json.Unmarshal(raw, &order); err != nil || order == nil || *order < 0 || *order > 1 { @@ -167,8 +157,8 @@ func (c *ClientRoutes) updateRoomTag(w http.ResponseWriter, r *http.Request, rem return } } - encoded, err = json.Marshal(content) - if err != nil { + var err error + if encoded, err = json.Marshal(content); err != nil { util.ResponseErrorUnknownJSON(w, r, err) return } diff --git a/internal/routes/client/devices.go b/internal/routes/client/devices.go index 8ddffde..1a007f3 100644 --- a/internal/routes/client/devices.go +++ b/internal/routes/client/devices.go @@ -71,7 +71,7 @@ func (c *ClientRoutes) PutDevice(w http.ResponseWriter, r *http.Request) { // https://spec.matrix.org/v1.16/client-server-api/#delete_matrixclientv3devicesdeviceid func (c *ClientRoutes) DeleteDevice(w http.ResponseWriter, r *http.Request) { deviceID := decodedURLParam(r, "deviceID") - params, rawAuth, respErr := parseUIARequestBody(r, true) + params, rawAuth, respErr := parseUIARequestBody(w, r, true) if respErr != nil { if respErr.ErrCode == mautrix.MTooLarge.ErrCode { util.ResponseJSON(w, r, http.StatusRequestEntityTooLarge, respErr) @@ -100,7 +100,7 @@ func (c *ClientRoutes) DeleteDevice(w http.ResponseWriter, r *http.Request) { // https://spec.matrix.org/v1.16/client-server-api/#post_matrixclientv3delete_devices func (c *ClientRoutes) DeleteDevices(w http.ResponseWriter, r *http.Request) { - params, rawAuth, respErr := parseUIARequestBody(r, false) + params, rawAuth, respErr := parseUIARequestBody(w, r, false) if respErr != nil { if respErr.ErrCode == mautrix.MTooLarge.ErrCode { util.ResponseJSON(w, r, http.StatusRequestEntityTooLarge, respErr) diff --git a/internal/routes/client/keybackup.go b/internal/routes/client/keybackup.go index a81f1df..4102914 100644 --- a/internal/routes/client/keybackup.go +++ b/internal/routes/client/keybackup.go @@ -210,7 +210,7 @@ func (c *ClientRoutes) DeleteRoomKeys(w http.ResponseWriter, r *http.Request) { // https://spec.matrix.org/v1.11/client-server-api/#put_matrixclientv3room_keyskeysroomid func (c *ClientRoutes) PutRoomKeysByRoomID(w http.ResponseWriter, r *http.Request) { userID := middleware.GetRequestUserID(r) - roomID := chi.URLParam(r, "roomId") + roomID := string(util.RoomIDFromRequestURLParam(r, "roomId")) versionString := r.URL.Query().Get("version") if versionString == "" { util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Missing version query parameter") @@ -248,7 +248,7 @@ func (c *ClientRoutes) PutRoomKeysByRoomID(w http.ResponseWriter, r *http.Reques // https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv3room_keyskeysroomid func (c *ClientRoutes) GetRoomKeysByRoomID(w http.ResponseWriter, r *http.Request) { userID := middleware.GetRequestUserID(r) - roomID := chi.URLParam(r, "roomId") + roomID := string(util.RoomIDFromRequestURLParam(r, "roomId")) versionString := r.URL.Query().Get("version") if versionString == "" { util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Missing version query parameter") @@ -269,7 +269,7 @@ func (c *ClientRoutes) GetRoomKeysByRoomID(w http.ResponseWriter, r *http.Reques // https://spec.matrix.org/v1.11/client-server-api/#delete_matrixclientv3room_keyskeysroomid func (c *ClientRoutes) DeleteRoomKeysByRoomID(w http.ResponseWriter, r *http.Request) { userID := middleware.GetRequestUserID(r) - roomID := chi.URLParam(r, "roomId") + roomID := string(util.RoomIDFromRequestURLParam(r, "roomId")) versionString := r.URL.Query().Get("version") if versionString == "" { util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Missing version query parameter") @@ -288,7 +288,7 @@ func (c *ClientRoutes) DeleteRoomKeysByRoomID(w http.ResponseWriter, r *http.Req // https://spec.matrix.org/v1.11/client-server-api/#put_matrixclientv3room_keyskeysroomidsessionid func (c *ClientRoutes) PutRoomKeyBySessionID(w http.ResponseWriter, r *http.Request) { userID := middleware.GetRequestUserID(r) - roomID := chi.URLParam(r, "roomId") + roomID := string(util.RoomIDFromRequestURLParam(r, "roomId")) sessionID := chi.URLParam(r, "sessionId") versionString := r.URL.Query().Get("version") if versionString == "" { @@ -325,7 +325,7 @@ func (c *ClientRoutes) PutRoomKeyBySessionID(w http.ResponseWriter, r *http.Requ // https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv3room_keyskeysroomidsessionid func (c *ClientRoutes) GetRoomKeyBySessionID(w http.ResponseWriter, r *http.Request) { userID := middleware.GetRequestUserID(r) - roomID := chi.URLParam(r, "roomId") + roomID := string(util.RoomIDFromRequestURLParam(r, "roomId")) sessionID := chi.URLParam(r, "sessionId") versionString := r.URL.Query().Get("version") if versionString == "" { @@ -350,7 +350,7 @@ func (c *ClientRoutes) GetRoomKeyBySessionID(w http.ResponseWriter, r *http.Requ // https://spec.matrix.org/v1.11/client-server-api/#delete_matrixclientv3room_keyskeysroomidsessionid func (c *ClientRoutes) DeleteRoomKeyBySessionID(w http.ResponseWriter, r *http.Request) { userID := middleware.GetRequestUserID(r) - roomID := chi.URLParam(r, "roomId") + roomID := string(util.RoomIDFromRequestURLParam(r, "roomId")) sessionID := chi.URLParam(r, "sessionId") versionString := r.URL.Query().Get("version") if versionString == "" { diff --git a/internal/routes/client/presence.go b/internal/routes/client/presence.go index 5bd35f2..6eb7551 100644 --- a/internal/routes/client/presence.go +++ b/internal/routes/client/presence.go @@ -4,10 +4,8 @@ import ( "net/http" "time" - "github.com/go-chi/chi/v5" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" "github.com/beeper/babbleserv/internal/middleware" "github.com/beeper/babbleserv/internal/types" @@ -16,7 +14,7 @@ import ( // https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv3presenceuseridstatus func (c *ClientRoutes) GetPresence(w http.ResponseWriter, r *http.Request) { - userID := id.UserID(chi.URLParam(r, "userID")) + userID := util.UserIDFromRequestURLParam(r, "userID") presence, err := c.db.Transient.GetUserPresence(r.Context(), userID) if err != nil { @@ -56,7 +54,7 @@ func (c *ClientRoutes) PutPresence(w http.ResponseWriter, r *http.Request) { } userID := middleware.GetRequestUserID(r) - userIDParam := id.UserID(chi.URLParam(r, "userID")) + userIDParam := util.UserIDFromRequestURLParam(r, "userID") if userIDParam != userID { util.ResponseErrorJSON(w, r, mautrix.MForbidden) return diff --git a/internal/routes/client/pushers.go b/internal/routes/client/pushers.go index 463fcb7..b863931 100644 --- a/internal/routes/client/pushers.go +++ b/internal/routes/client/pushers.go @@ -2,8 +2,6 @@ package client import ( "encoding/json" - "errors" - "io" "net/http" "net/url" "unicode/utf8" @@ -36,22 +34,18 @@ func (c *ClientRoutes) GetPushers(w http.ResponseWriter, r *http.Request) { // https://spec.matrix.org/v1.11/client-server-api/#post_matrixclientv3pushersset func (c *ClientRoutes) SetPusher(w http.ResponseWriter, r *http.Request) { const maxPusherBytes = 64 * 1024 - body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxPusherBytes)) - if err != nil { - var sizeErr *http.MaxBytesError - if errors.As(err, &sizeErr) { - util.ResponseJSON(w, r, http.StatusRequestEntityTooLarge, &mautrix.MTooLarge) + r.Body = http.MaxBytesReader(w, r.Body, maxPusherBytes) + body, respErr := util.ParseRequestJSON[json.RawMessage](r) + if respErr != nil { + if respErr.ErrCode == mautrix.MTooLarge.ErrCode { + util.ResponseJSON(w, r, http.StatusRequestEntityTooLarge, respErr) } else { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) + util.ResponseErrorJSON(w, r, *respErr) } return } - if !json.Valid(body) { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) - return - } - var req *reqSetPusher - if err := json.Unmarshal(body, &req); err != nil || req == nil { + var req reqSetPusher + if err := json.Unmarshal(body, &req); err != nil { util.ResponseErrorJSON(w, r, mautrix.MBadJSON) return } diff --git a/internal/routes/client/roomcreate.go b/internal/routes/client/roomcreate.go index dda551a..71bd909 100644 --- a/internal/routes/client/roomcreate.go +++ b/internal/routes/client/roomcreate.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "strconv" "strings" @@ -45,25 +44,16 @@ var presets = map[string][]struct { }, } +type reqCreateRoom struct { + mautrix.ReqCreateRoom + PowerLevelOverride map[string]json.RawMessage `json:"power_level_content_override"` +} + // https://spec.matrix.org/v1.16/client-server-api/#post_matrixclientv3createroom func (c *ClientRoutes) CreateRoom(w http.ResponseWriter, r *http.Request) { - var req *struct { - mautrix.ReqCreateRoom - PowerLevelOverride map[string]json.RawMessage `json:"power_level_content_override"` - } - decoder := json.NewDecoder(r.Body) - var raw json.RawMessage - if err := decoder.Decode(&raw); err != nil { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) - return - } - var trailing json.RawMessage - if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) - return - } - if err := json.Unmarshal(raw, &req); err != nil || req == nil { - util.ResponseErrorJSON(w, r, mautrix.MBadJSON) + req, respErr := util.ParseRequestJSON[reqCreateRoom](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) return } diff --git a/internal/routes/client/roomevent.go b/internal/routes/client/roomevent.go index abf43d3..c9e7ab5 100644 --- a/internal/routes/client/roomevent.go +++ b/internal/routes/client/roomevent.go @@ -6,7 +6,6 @@ import ( "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" - "github.com/go-chi/chi/v5" "github.com/tidwall/gjson" "maunium.net/go/mautrix/id" @@ -60,8 +59,8 @@ func (c *ClientRoutes) GetRoomStateEvent(w http.ResponseWriter, r *http.Request) roomID := util.RoomIDFromRequestURLParam(r, "roomID") userID := middleware.GetRequestUserID(r) - evType := chi.URLParam(r, "eventType") - stateKey := chi.URLParam(r, "stateKey") + evType := util.EventTypeFromRequestURLParam(r, "eventType") + stateKey := util.StateKeyFromRequestURLParam(r, "stateKey") // Check the user is currently in the room, state is always available irrespective of send time if inRoom, err := c.db.Rooms.IsUserJoinedRoom(r.Context(), userID, roomID); err != nil { @@ -73,7 +72,7 @@ func (c *ClientRoutes) GetRoomStateEvent(w http.ResponseWriter, r *http.Request) } stateEv, err := c.db.Rooms.GetCurrentRoomStateEvent(r.Context(), roomID, types.StateTup{ - Type: event.NewEventType(evType), + Type: evType, StateKey: stateKey, }) if err != nil { diff --git a/internal/routes/client/roommember.go b/internal/routes/client/roommember.go index 639f8c3..3745ebc 100644 --- a/internal/routes/client/roommember.go +++ b/internal/routes/client/roommember.go @@ -73,7 +73,7 @@ func (c *ClientRoutes) ForgetRoom(w http.ResponseWriter, r *http.Request) { func (c *ClientRoutes) SendRoomInvite(w http.ResponseWriter, r *http.Request) { roomID := util.RoomIDFromRequestURLParam(r, "roomID") - req, respErr := util.ParseRequestJSON[reqMemberOther](r) + req, respErr := util.ParseOptionalRequestJSON[reqMemberOther](r) if respErr != nil { util.ResponseErrorJSON(w, r, *respErr) return @@ -144,9 +144,9 @@ func (c *ClientRoutes) sendRoomJoin(w http.ResponseWriter, r *http.Request) { return } - var req reqMemberSelf - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) + req, respErr := util.ParseOptionalRequestJSON[reqMemberSelf](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) return } @@ -276,9 +276,9 @@ func (c *ClientRoutes) SendRoomKnockAlias(w http.ResponseWriter, r *http.Request return } - var req reqMemberSelf - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) + req, respErr := util.ParseRequestJSON[reqMemberSelf](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) return } @@ -350,9 +350,9 @@ func (c *ClientRoutes) SendRoomKnockAlias(w http.ResponseWriter, r *http.Request // https://spec.matrix.org/v1.11/client-server-api/#post_matrixclientv3roomsroomidleave func (c *ClientRoutes) SendRoomLeave(w http.ResponseWriter, r *http.Request) { - var req reqMemberSelf - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) + req, respErr := util.ParseOptionalRequestJSON[reqMemberSelf](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) return } userID := middleware.GetRequestUserID(r) @@ -361,9 +361,9 @@ func (c *ClientRoutes) SendRoomLeave(w http.ResponseWriter, r *http.Request) { // https://spec.matrix.org/v1.11/client-server-api/#post_matrixclientv3roomsroomidkick func (c *ClientRoutes) SendRoomKick(w http.ResponseWriter, r *http.Request) { - var req reqMemberOther - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) + req, respErr := util.ParseOptionalRequestJSON[reqMemberOther](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) return } c.sendRoomLeaveOrKick(w, r, req.UserID, req.Reason) @@ -523,9 +523,9 @@ func (c *ClientRoutes) SendRoomUnban(w http.ResponseWriter, r *http.Request) { func (c *ClientRoutes) sendBanOrUnban(w http.ResponseWriter, r *http.Request, membership event.Membership) { roomID := util.RoomIDFromRequestURLParam(r, "roomID") - var req reqMemberOther - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) + req, respErr := util.ParseOptionalRequestJSON[reqMemberOther](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) return } diff --git a/internal/routes/client/roomsend.go b/internal/routes/client/roomsend.go index 52b3a6c..5abbfa8 100644 --- a/internal/routes/client/roomsend.go +++ b/internal/routes/client/roomsend.go @@ -1,7 +1,6 @@ package client import ( - "encoding/json" "net/http" "maunium.net/go/mautrix" @@ -17,13 +16,13 @@ import ( ) func (c *ClientRoutes) SendRoomStateEvent(w http.ResponseWriter, r *http.Request) { - roomID := id.RoomID(chi.URLParam(r, "roomID")) - evType := event.NewEventType(chi.URLParam(r, "eventType")) - stateKey := chi.URLParam(r, "stateKey") + roomID := util.RoomIDFromRequestURLParam(r, "roomID") + evType := util.EventTypeFromRequestURLParam(r, "eventType") + stateKey := util.StateKeyFromRequestURLParam(r, "stateKey") - var content map[string]any - if err := json.NewDecoder(r.Body).Decode(&content); err != nil { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) + content, respErr := util.ParseRequestJSON[map[string]any](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) return } @@ -37,18 +36,13 @@ func (c *ClientRoutes) SendRoomStateEvent(w http.ResponseWriter, r *http.Request } func (c *ClientRoutes) SendRoomEvent(w http.ResponseWriter, r *http.Request) { - roomID := id.RoomID(chi.URLParam(r, "roomID")) - evType := event.NewEventType(chi.URLParam(r, "eventType")) + roomID := util.RoomIDFromRequestURLParam(r, "roomID") + evType := util.EventTypeFromRequestURLParam(r, "eventType") txnID := chi.URLParam(r, "txnID") - var content map[string]any - if err := json.NewDecoder(r.Body).Decode(&content); err != nil { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) - return - } - - if content == nil { - util.ResponseErrorMessageJSON(w, r, mautrix.MBadJSON, "Event content must be an object") + content, respErr := util.ParseRequestJSON[map[string]any](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) return } diff --git a/internal/routes/client/todevice.go b/internal/routes/client/todevice.go index 9bc5d3b..465cfb8 100644 --- a/internal/routes/client/todevice.go +++ b/internal/routes/client/todevice.go @@ -6,7 +6,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/rs/zerolog/hlog" "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" "github.com/beeper/babbleserv/internal/databases/transient" "github.com/beeper/babbleserv/internal/middleware" @@ -16,7 +15,7 @@ import ( func (c *ClientRoutes) SendToDevice(w http.ResponseWriter, r *http.Request) { txnID := chi.URLParam(r, "txnID") - eventType := event.NewEventType(chi.URLParam(r, "eventType")) + eventType := util.EventTypeFromRequestURLParam(r, "eventType") req, respErr := util.ParseRequestJSON[mautrix.ReqSendToDevice](r) if respErr != nil { diff --git a/internal/routes/client/userprofiles.go b/internal/routes/client/userprofiles.go index 785d5de..ff92c67 100644 --- a/internal/routes/client/userprofiles.go +++ b/internal/routes/client/userprofiles.go @@ -1,7 +1,6 @@ package client import ( - "encoding/json" "errors" "net/http" @@ -51,9 +50,9 @@ func (c *ClientRoutes) GetProfile(w http.ResponseWriter, r *http.Request) { // https://spec.matrix.org/v1.10/client-server-api/#put_matrixclientv3profileuseridavatar_url // https://spec.matrix.org/v1.10/client-server-api/#put_matrixclientv3profileuseriddisplayname func (c *ClientRoutes) PutProfile(w http.ResponseWriter, r *http.Request) { - var req map[string]any - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - util.ResponseErrorJSON(w, r, mautrix.MNotJSON) + req, respErr := util.ParseRequestJSON[map[string]any](r) + if respErr != nil { + util.ResponseErrorJSON(w, r, *respErr) return } diff --git a/internal/routes/debug/debugevents.go b/internal/routes/debug/debugevents.go index 22d6b57..865d75f 100644 --- a/internal/routes/debug/debugevents.go +++ b/internal/routes/debug/debugevents.go @@ -5,7 +5,6 @@ import ( "net/http" "strconv" - "github.com/go-chi/chi/v5" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -15,7 +14,7 @@ import ( ) func (d *DebugRoutes) DebugGetEvent(w http.ResponseWriter, r *http.Request) { - eventID := id.EventID(chi.URLParam(r, "eventID")) + eventID := util.EventIDFromRequestURLParam(r, "eventID") ev, err := d.db.Rooms.GetEvent(r.Context(), eventID) if err != nil { @@ -72,7 +71,7 @@ func (d *DebugRoutes) DebugMakeEvents(w http.ResponseWriter, r *http.Request) { return } - roomID := id.RoomID(chi.URLParam(r, "roomID")) + roomID := util.RoomIDFromRequestURLParam(r, "roomID") partialEvs := make([]*types.PartialEvent, 0, count) for i := range count { diff --git a/internal/routes/debug/debugroom.go b/internal/routes/debug/debugroom.go index 4ed7f68..64e655e 100644 --- a/internal/routes/debug/debugroom.go +++ b/internal/routes/debug/debugroom.go @@ -3,7 +3,6 @@ package debug import ( "net/http" - "github.com/go-chi/chi/v5" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -12,7 +11,7 @@ import ( ) func (d *DebugRoutes) DebugGetRoom(w http.ResponseWriter, r *http.Request) { - roomID := id.RoomID(chi.URLParam(r, "roomID")) + roomID := util.RoomIDFromRequestURLParam(r, "roomID") room, err := d.db.Rooms.GetRoom(r.Context(), roomID) if err != nil { @@ -54,8 +53,8 @@ func (d *DebugRoutes) DebugGetRoom(w http.ResponseWriter, r *http.Request) { } func (d *DebugRoutes) DebugGetRoomStateAt(w http.ResponseWriter, r *http.Request) { - roomID := id.RoomID(chi.URLParam(r, "roomID")) - eventID := id.EventID(chi.URLParam(r, "eventID")) + roomID := util.RoomIDFromRequestURLParam(r, "roomID") + eventID := util.EventIDFromRequestURLParam(r, "eventID") allStateMap, err := d.db.Rooms.GetRoomStateMapAtEvent(r.Context(), roomID, eventID) if err != nil { diff --git a/internal/routes/debug/debugsync.go b/internal/routes/debug/debugsync.go index 32a98f4..de95275 100644 --- a/internal/routes/debug/debugsync.go +++ b/internal/routes/debug/debugsync.go @@ -12,7 +12,7 @@ import ( ) func (d *DebugRoutes) DebugSyncUser(w http.ResponseWriter, r *http.Request) { - userID := id.UserID(chi.URLParam(r, "userID")) + userID := util.UserIDFromRequestURLParam(r, "userID") deviceID := id.DeviceID(chi.URLParam(r, "deviceID")) versions, err := util.VersionMapFromRequestQuery(r, "since") diff --git a/internal/util/request.go b/internal/util/request.go index baea40c..d13f03f 100644 --- a/internal/util/request.go +++ b/internal/util/request.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "encoding/json" + "encoding/json/jsontext" + jsonv2 "encoding/json/v2" "errors" "fmt" "io" @@ -18,6 +20,7 @@ import ( "github.com/matrix-org/gomatrixserverlib/fclient" "github.com/matrix-org/gomatrixserverlib/spec" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "github.com/beeper/babbleserv/internal/types" @@ -25,56 +28,38 @@ import ( // Chi URL params -func RoomIDFromRequestURLParam(r *http.Request, field string) id.RoomID { +func urlParam(r *http.Request, field string) string { p := chi.URLParam(r, field) - - if strings.HasSuffix(p, "!") { - return id.RoomID(p) - } - if parsed, err := url.PathUnescape(p); err != nil { - return id.RoomID("") - } else { - return id.RoomID(parsed) + if r.URL.RawPath == "" { + return p + } else if parsed, err := url.PathUnescape(p); err == nil { + return parsed } + return p } -func RoomAliasFromRequestURLParam(r *http.Request, field string) id.RoomAlias { - p := chi.URLParam(r, field) +func RoomIDFromRequestURLParam(r *http.Request, field string) id.RoomID { + return id.RoomID(urlParam(r, field)) +} - if strings.HasSuffix(p, "#") { - return id.RoomAlias(p) - } - if parsed, err := url.PathUnescape(p); err != nil { - return id.RoomAlias("") - } else { - return id.RoomAlias(parsed) - } +func RoomAliasFromRequestURLParam(r *http.Request, field string) id.RoomAlias { + return id.RoomAlias(urlParam(r, field)) } func EventIDFromRequestURLParam(r *http.Request, field string) id.EventID { - p := chi.URLParam(r, field) - - if strings.HasSuffix(p, "$") { - return id.EventID(p) - } - if parsed, err := url.PathUnescape(p); err != nil { - return id.EventID("") - } else { - return id.EventID(parsed) - } + return id.EventID(urlParam(r, field)) } func UserIDFromRequestURLParam(r *http.Request, field string) id.UserID { - p := chi.URLParam(r, field) + return id.UserID(urlParam(r, field)) +} - if strings.HasSuffix(p, "@") { - return id.UserID(p) - } - if parsed, err := url.PathUnescape(p); err != nil { - return id.UserID("") - } else { - return id.UserID(parsed) - } +func StateKeyFromRequestURLParam(r *http.Request, field string) string { + return urlParam(r, field) +} + +func EventTypeFromRequestURLParam(r *http.Request, field string) event.Type { + return event.NewEventType(urlParam(r, field)) } func HomeserverForRoomID(roomID id.RoomID) string { @@ -170,31 +155,79 @@ func VersionFromRequestQuery(r *http.Request, field, versionKey types.VersionKey // Request body func ParseRequestJSON[T any](r *http.Request) (T, *mautrix.RespError) { + return parseRequestJSON[T](r, false) +} + +func ParseOptionalRequestJSON[T any](r *http.Request) (T, *mautrix.RespError) { + return parseRequestJSON[T](r, true) +} + +func parseRequestJSON[T any](r *http.Request, optional bool) (T, *mautrix.RespError) { var req T - decoder := json.NewDecoder(r.Body) - var raw json.RawMessage - if err := decoder.Decode(&raw); err != nil { - var sErr *json.SyntaxError - if errors.As(err, &sErr) { - return req, &mautrix.MNotJSON + body, err := io.ReadAll(r.Body) + if err != nil { + return req, requestReadError(err) + } + if optional && len(body) == 0 { + return req, nil + } + if bytes.Equal(bytes.TrimSpace(body), []byte("null")) { + if optional { + return req, nil } return req, &mautrix.MBadJSON } - if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return req, &mautrix.MBadJSON + // We want to match how Python handles duplicate keys in JSON - go's jsonv2 merges nested values + // when allowing duplicates whereas Python overwrites. Catch the duplicate error from default + // decode and then use our own function that behaves like Python. + err = jsonv2.Unmarshal(body, &req, jsontext.AllowInvalidUTF8(true)) + if errors.Is(err, jsontext.ErrDuplicateName) { + body, err = collapseDuplicateJSONKeys(body) + if err == nil { + var zero T + req = zero + err = jsonv2.Unmarshal(body, &req, jsontext.AllowInvalidUTF8(true)) + } } - var trailing json.RawMessage - if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + var semanticErr *jsonv2.SemanticError + if errors.As(err, &semanticErr) { + return req, &mautrix.MBadJSON + } else if err != nil { return req, &mautrix.MNotJSON } - if err := json.Unmarshal(raw, &req); err != nil { - var sErr *json.SyntaxError - if errors.As(err, &sErr) { - return req, &mautrix.MNotJSON + return req, nil +} + +// collapseDuplicateKeys reads JSON as Python does, the last value of a duplicate key winning +func collapseDuplicateJSONKeys(b []byte) ([]byte, error) { + // Decoding into a struct would merge the duplicates of a map field + decoder := json.NewDecoder(bytes.NewReader(b)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, err + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + if err == nil { + err = errors.New("multiple JSON values") } - return req, &mautrix.MBadJSON + return nil, err } - return req, nil + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} + +func requestReadError(err error) *mautrix.RespError { + var sizeErr *http.MaxBytesError + if errors.As(err, &sizeErr) { + return &mautrix.MTooLarge + } + return &mautrix.MNotJSON } // Federation request authentication diff --git a/internal/util/request_test.go b/internal/util/request_test.go new file mode 100644 index 0000000..4d2e098 --- /dev/null +++ b/internal/util/request_test.go @@ -0,0 +1,184 @@ +package util_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" +) + +func TestParseRequestJSON(t *testing.T) { + type request struct { + Content map[string]any `json:"content"` + } + for body, expected := range map[string]*mautrix.RespError{ + `{"content":{"a":1}}`: nil, + ` {"content":{}} `: nil, + `{"content":{"a":1,"a":2}}`: nil, + `{"content":{},"content":{}}`: nil, + `{"content":{"a":[{"b":1,"b":1}]}}`: nil, + `null`: &mautrix.MBadJSON, + `{"content":[]}`: &mautrix.MBadJSON, + ``: &mautrix.MNotJSON, + `{"content":`: &mautrix.MNotJSON, + `{"content":{}}{}`: &mautrix.MNotJSON, + `{"content":{}} x`: &mautrix.MNotJSON, + `{"content":{},"content":{}}{}`: &mautrix.MNotJSON, + `{"content":{},"content":{}} x`: &mautrix.MNotJSON, + `{"content":{},"content":`: &mautrix.MNotJSON, + `{"content":{},"content":[]}`: &mautrix.MBadJSON, + } { + _, respErr := util.ParseRequestJSON[request](httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))) + if expected == nil { + assert.Nil(t, respErr, body) + } else { + require.NotNil(t, respErr, body) + assert.Equal(t, expected.ErrCode, respErr.ErrCode, body) + } + } + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"content":{"a":"`+strings.Repeat("x", 100)+`"}}`)) + req.Body = http.MaxBytesReader(httptest.NewRecorder(), req.Body, 64) + _, respErr := util.ParseRequestJSON[request](req) + require.NotNil(t, respErr) + assert.Equal(t, mautrix.MTooLarge.ErrCode, respErr.ErrCode) +} + +func TestParseRequestJSONDuplicateValuesReplace(t *testing.T) { + type request struct { + Content map[string]any `json:"content"` + Signatures map[string]map[string]string `json:"signatures"` + } + body := `{"content":{"old":1},"content":{"new":2},"signatures":{"a":{"key":"old"}},"signatures":{"b":{"key":"new"}}}` + req, respErr := util.ParseRequestJSON[request](httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))) + require.Nil(t, respErr) + assert.Equal(t, map[string]any{"new": float64(2)}, req.Content) + assert.Equal(t, map[string]map[string]string{"b": {"key": "new"}}, req.Signatures) +} + +func TestParseRequestJSONRawContent(t *testing.T) { + type request struct { + Content json.RawMessage `json:"content"` + } + for body, expected := range map[string]string{ + `{"content":{ "a": 1 }}`: `{ "a": 1 }`, + `{"content":{"a":1,"\u0061":2,"nested":[{"x":1,"x":2}],"n":9007199254740993}}`: `{"a":2,"n":9007199254740993,"nested":[{"x":2}]}`, + } { + req, respErr := util.ParseRequestJSON[request](httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))) + require.Nil(t, respErr, body) + assert.Equal(t, expected, string(req.Content), body) + } +} + +func TestParseRequestJSONEvent(t *testing.T) { + body := `{ + "type":"m.room.message", + "type":"m.room.member", + "state_key":"@bob:example.com", + "content":{"membership":"join","displayname":"Bob","membership":"ban"}, + "signatures":{"a.example.com":{"ed25519:1":"a"}}, + "signatures":{"b.example.com":{"ed25519:1":"b"}} + }` + ev, respErr := util.ParseRequestJSON[types.Event](httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))) + require.Nil(t, respErr) + assert.Equal(t, event.StateMember, ev.Type) + assert.Equal(t, `{"displayname":"Bob","membership":"ban"}`, string(ev.Content)) + assert.Equal(t, event.MembershipBan, ev.Membership()) + assert.Equal(t, map[string]map[string]string{"b.example.com": {"ed25519:1": "b"}}, ev.Signatures) +} + +func TestParseOptionalRequestJSON(t *testing.T) { + type request struct { + Reason string `json:"reason"` + } + newRequest := func(body string) *http.Request { + return httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + } + + for _, body := range []string{``, `null`} { + req, respErr := util.ParseOptionalRequestJSON[request](newRequest(body)) + assert.Nil(t, respErr, body) + assert.Equal(t, request{}, req, body) + } + + req, respErr := util.ParseOptionalRequestJSON[request](newRequest(`{"reason":"bye"}`)) + assert.Nil(t, respErr) + assert.Equal(t, request{Reason: "bye"}, req) + + req, respErr = util.ParseOptionalRequestJSON[request](newRequest(`{"reason":"a","reason":"b"}`)) + require.Nil(t, respErr) + assert.Equal(t, request{Reason: "b"}, req) + + for body, expected := range map[string]*mautrix.RespError{ + ` `: &mautrix.MNotJSON, + `{"reason":`: &mautrix.MNotJSON, + `[]`: &mautrix.MBadJSON, + } { + _, respErr := util.ParseOptionalRequestJSON[request](newRequest(body)) + require.NotNil(t, respErr, body) + assert.Equal(t, expected.ErrCode, respErr.ErrCode, body) + } +} + +func TestURLParamsArePercentDecoded(t *testing.T) { + type ids struct { + roomID id.RoomID + eventID id.EventID + userID id.UserID + } + var got ids + router := chi.NewRouter() + router.Get("/state_ids/{roomID}/{eventID}/{userID}", func(w http.ResponseWriter, r *http.Request) { + got = ids{util.RoomIDFromRequestURLParam(r, "roomID"), util.EventIDFromRequestURLParam(r, "eventID"), util.UserIDFromRequestURLParam(r, "userID")} + }) + want := ids{"!abc:example.org", "$ev/ent+id", "@alice:example.org"} + for _, path := range []string{ + // As Synapse quotes path arguments + "/state_ids/%21abc%3Aexample.org/%24ev%2Fent%2Bid/%40alice%3Aexample.org", + "/state_ids/!abc:example.org/$ev%2Fent+id/@alice:example.org", + } { + got = ids{} + router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, path, nil)) + assert.Equal(t, want, got, path) + } + + var stateKey string + router.Get("/state/{stateKey}", func(w http.ResponseWriter, r *http.Request) { + stateKey = util.StateKeyFromRequestURLParam(r, "stateKey") + }) + for path, want := range map[string]string{ + "/state/%40alice%3Aexample.org": "@alice:example.org", + "/state/@alice:example.org": "@alice:example.org", + "/state/100%25": "100%", + "/state/a%2541": "a%41", + "/state/a%2541%2Fb": "a%41/b", + } { + stateKey = "" + router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, path, nil)) + assert.Equal(t, want, stateKey, path) + } + + var userID id.UserID + router.Get("/presence/{userID}", func(w http.ResponseWriter, r *http.Request) { + userID = util.UserIDFromRequestURLParam(r, "userID") + }) + for path, want := range map[string]id.UserID{ + "/presence/@100%25:example.org": "@100%:example.org", + "/presence/%40100%25%3Aexample.org": "@100%:example.org", + } { + userID = "" + router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, path, nil)) + assert.Equal(t, want, userID, path) + } +} From c0596a6ed1fba70eef5d7414c37ab04d495fa276 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Sat, 3 Oct 2026 21:28:14 +0100 Subject: [PATCH 02/12] Update event types and authorization for state contexts and room v12 --- go.mod | 10 +- go.sum | 13 +++ internal/types/error.go | 8 +- internal/types/event.go | 162 ++++++++++++++++++++++++++------ internal/types/event_test.go | 60 ++++++++++++ internal/types/eventpdu.go | 20 +++- internal/types/eventtups.go | 70 +++++++++++--- internal/types/room.go | 9 +- internal/types/statecontext.go | 48 ++++++++++ internal/util/eventauth.go | 70 ++++++++++++++ internal/util/events.go | 101 +++++++++++++++----- internal/util/events_test.go | 145 ++++++++++++++++++++++++++++ internal/util/eventsize.go | 56 +++++++++++ internal/util/eventsize_test.go | 57 +++++++++++ internal/util/response.go | 30 ++++++ internal/util/roomversion.go | 8 ++ 16 files changed, 785 insertions(+), 82 deletions(-) create mode 100644 internal/types/statecontext.go create mode 100644 internal/util/eventauth.go create mode 100644 internal/util/eventsize.go create mode 100644 internal/util/eventsize_test.go create mode 100644 internal/util/roomversion.go diff --git a/go.mod b/go.mod index 004b4dd..50ccd7e 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,8 @@ require ( github.com/beeper/libserv v0.0.0-20231231202820-c7303abfc32c github.com/go-chi/chi/v5 v5.0.12 github.com/jedib0t/go-pretty/v6 v6.7.5 - github.com/matrix-org/gomatrix v0.0.0-20210324163249-be2af5ef2e16 - github.com/matrix-org/gomatrixserverlib v0.0.0-20240328203753-c2391f7113a5 + github.com/matrix-org/gomatrix v0.0.0-20220926102614-ceba4d9f7530 + github.com/matrix-org/gomatrixserverlib v0.0.0-20260716140101-4fe595dc7f58 github.com/minio/minio-go/v7 v7.0.74 github.com/redis/go-redis/v9 v9.5.4 github.com/rs/xid v1.6.0 @@ -35,18 +35,20 @@ require ( github.com/goccy/go-json v0.10.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-set/v3 v3.0.0 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/matrix-org/util v0.0.0-20200807132607-55161520e1d4 // indirect + github.com/matrix-org/util v0.0.0-20221111132719-399730281e66 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/minio/md5-simd v1.1.2 // indirect + github.com/oleiade/lane/v2 v2.0.0 // indirect github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sirupsen/logrus v1.9.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect diff --git a/go.sum b/go.sum index 0dcf61a..0b65821 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/hashicorp/go-set/v3 v3.0.0 h1:CaJBQvQCOWoftrBcDt7Nwgo0kdpmrKxar/x2o6pV9JA= +github.com/hashicorp/go-set/v3 v3.0.0/go.mod h1:IEghM2MpE5IaNvL+D7X480dfNtxjRXZ6VMpK3C8s2ok= github.com/jedib0t/go-pretty/v6 v6.7.5 h1:9dJSWTJnsXJVVAbvxIFxeHf/JxoJd7GUl5o3UzhtuiM= github.com/jedib0t/go-pretty/v6 v6.7.5/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= @@ -48,10 +50,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/matrix-org/gomatrix v0.0.0-20210324163249-be2af5ef2e16 h1:ZtO5uywdd5dLDCud4r0r55eP4j9FuUNpl60Gmntcop4= github.com/matrix-org/gomatrix v0.0.0-20210324163249-be2af5ef2e16/go.mod h1:/gBX06Kw0exX1HrwmoBibFA98yBk/jxKpGVeyQbff+s= +github.com/matrix-org/gomatrix v0.0.0-20220926102614-ceba4d9f7530 h1:kHKxCOLcHH8r4Fzarl4+Y3K5hjothkVW5z7T1dUM11U= +github.com/matrix-org/gomatrix v0.0.0-20220926102614-ceba4d9f7530/go.mod h1:/gBX06Kw0exX1HrwmoBibFA98yBk/jxKpGVeyQbff+s= github.com/matrix-org/gomatrixserverlib v0.0.0-20240328203753-c2391f7113a5 h1:GuxmpyjZQoqb6UFQgKq8Td3wIITlXln/sItqp1jbTTA= github.com/matrix-org/gomatrixserverlib v0.0.0-20240328203753-c2391f7113a5/go.mod h1:HZGsVJ3bUE+DkZtufkH9H0mlsvbhEGK5CpX0Zlavylg= +github.com/matrix-org/gomatrixserverlib v0.0.0-20260716140101-4fe595dc7f58 h1:S42otjye0YIfq58z0nnKmiOuZDXiy6XlbMpLUmTZAqE= +github.com/matrix-org/gomatrixserverlib v0.0.0-20260716140101-4fe595dc7f58/go.mod h1:b6KVfDjXjA5Q7vhpOaMqIhFYvu5BuFVZixlNeTV/CLc= github.com/matrix-org/util v0.0.0-20200807132607-55161520e1d4 h1:eCEHXWDv9Rm335MSuB49mFUK44bwZPFSDde3ORE3syk= github.com/matrix-org/util v0.0.0-20200807132607-55161520e1d4/go.mod h1:vVQlW/emklohkZnOPwD3LrZUBqdfsbiyO3p1lNV8F6U= +github.com/matrix-org/util v0.0.0-20221111132719-399730281e66 h1:6z4KxomXSIGWqhHcfzExgkH3Z3UkIXry4ibJS4Aqz2Y= +github.com/matrix-org/util v0.0.0-20221111132719-399730281e66/go.mod h1:iBI1foelCqA09JJgPV0FYz4qA5dUXYOxMi57FxKBdd4= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -65,10 +73,13 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= +github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.74 h1:fTo/XlPBTSpo3BAMshlwKL5RspXRv9us5UeHEGYCFe0= github.com/minio/minio-go/v7 v7.0.74/go.mod h1:qydcVzV8Hqtj1VtEocfxbmVFa2siu6HGa+LDEPogjD8= +github.com/oleiade/lane/v2 v2.0.0 h1:XW/ex/Inr+bPkLd3O240xrFOhUkTd4Wy176+Gv0E3Qw= +github.com/oleiade/lane/v2 v2.0.0/go.mod h1:i5FBPFAYSWCgLh58UkUGCChjcCzef/MI7PlQm2TKCeg= github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a h1:VweslR2akb/ARhXfqSfRbj1vpWwYXf3eeAUyw/ndms0= github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -91,6 +102,8 @@ github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRo github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= diff --git a/internal/types/error.go b/internal/types/error.go index a3ae20a..b70180b 100644 --- a/internal/types/error.go +++ b/internal/types/error.go @@ -3,9 +3,10 @@ package types import "errors" var ( - ErrEventNotFound = errors.New("event not found") - ErrAlreadyExists = errors.New("event already exists") - ErrEventRedacted = errors.New("event has been redacted") + ErrEventNotFound = errors.New("event not found") + ErrAlreadyExists = errors.New("event already exists") + ErrEventRedacted = errors.New("event has been redacted") + ErrStateUnavailable = errors.New("state not known at event") ErrUserNotInRoom = errors.New("user is not in this room") ErrUserNotFound = errors.New("user not found") @@ -20,6 +21,7 @@ var ( ErrInvalidPassword = errors.New("invalid password") ErrRoomNotFound = errors.New("room not found") + ErrRoomTooLarge = errors.New("the mandatory rows of this change exceed the transaction limit") ErrRoomAliasNotFound = errors.New("room alias not found") ErrRoomAliasTaken = errors.New("room alias taken") ErrRoomPublicationForbidden = errors.New("room publication forbidden") diff --git a/internal/types/event.go b/internal/types/event.go index 63fa21a..b99737a 100644 --- a/internal/types/event.go +++ b/internal/types/event.go @@ -2,8 +2,8 @@ package types import ( "encoding/json" + "fmt" - "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" "github.com/matrix-org/gomatrixserverlib" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -60,6 +60,10 @@ type Event struct { Depth int64 `msgpack:"dpt" json:"depth"` + // Internal state contexts before and after the event, zero when unknown + BeforeState StateHash `msgpack:"bst" json:"-"` + AfterState StateHash `msgpack:"ast" json:"-"` + // Only here for backwards compat ??? PrevState []id.EventID `msgpack:"pst" json:"prev_state,omitzero"` @@ -70,11 +74,9 @@ type Event struct { Signatures map[string]map[string]string `msgpack:"sig" json:"signatures"` // Internal, in-memory only flags used for the lifetime of a request/background job - ClientTransactionID string `msgpack:"-" json:"-"` - IsForClientAPI bool `msgpack:"-" json:"-"` - IsDuplicate bool `msgpack:"-" json:"-"` - IncompleteVersion tuple.Versionstamp `msgpack:"-" json:"-"` - PrevStateEvent *Event `msgpack:"-" json:"-"` + ClientTransactionID string `msgpack:"-" json:"-"` + IsForClientAPI bool `msgpack:"-" json:"-"` + IsDuplicate bool `msgpack:"-" json:"-"` } func NewEventFromBytes(b []byte, id id.EventID) (*Event, error) { @@ -128,16 +130,37 @@ func (ev *PartialEvent) SetUnsigned(key string, value any) { ev.Unsigned[key] = value } -func eventIDsFromProtoEvent(input any) []id.EventID { - ids := input.([]any) - evIDs := make([]id.EventID, 0, len(ids)) - for _, ev := range ids { - evIDs = append(evIDs, id.EventID(ev.(string))) +func eventIDsFromProtoEvent(input any) ([]id.EventID, error) { + var out []id.EventID + switch ids := input.(type) { + case nil: + case []string: + for _, value := range ids { + out = append(out, id.EventID(value)) + } + case []any: + for _, value := range ids { + eventID, ok := value.(string) + if !ok { + return nil, fmt.Errorf("unsupported proto-event reference %T", value) + } + out = append(out, id.EventID(eventID)) + } + default: + return nil, fmt.Errorf("unsupported proto-event references %T", input) } - return evIDs + return out, nil } -func EventFromProtoEvent(protoEv gomatrixserverlib.ProtoEvent) *Event { +func EventFromProtoEvent(protoEv gomatrixserverlib.ProtoEvent) (*Event, error) { + authEventIDs, err := eventIDsFromProtoEvent(protoEv.AuthEvents) + if err != nil { + return nil, err + } + prevEventIDs, err := eventIDsFromProtoEvent(protoEv.PrevEvents) + if err != nil { + return nil, err + } return &Event{ PartialEvent: PartialEvent{ RoomID: id.RoomID(protoEv.RoomID), @@ -149,9 +172,9 @@ func EventFromProtoEvent(protoEv gomatrixserverlib.ProtoEvent) *Event { Redacts: id.EventID(protoEv.Redacts), }, Depth: protoEv.Depth, - AuthEventIDs: eventIDsFromProtoEvent(protoEv.AuthEvents), - PrevEventIDs: eventIDsFromProtoEvent(protoEv.PrevEvents), - } + AuthEventIDs: authEventIDs, + PrevEventIDs: prevEventIDs, + }, nil } type marshalEvent Event @@ -194,10 +217,16 @@ func (ev *Event) ToMsgpack() []byte { } func (ev Event) MarshalJSON() ([]byte, error) { + roomSpec, err := gomatrixserverlib.GetRoomVersion(ev.GetRoomVersion()) + domainlessCreate := ev.Type == event.StateCreate && err == nil && roomSpec.DomainlessRoomIDs() if ev.IsForClientAPI { // Client API removes room_id, adds event_id b := exerrors.Must(json.Marshal(ev.PartialEvent)) b = exerrors.Must(sjson.DeleteBytes(b, "room_id")) + // Clients need the derived room ID even though v12 create PDUs omit it. + if domainlessCreate { + b = exerrors.Must(sjson.SetBytes(b, "room_id", ev.RoomID)) + } b = exerrors.Must(sjson.SetBytes(b, "event_id", ev.ID)) // Only locally looked-up metadata may expose a client transaction ID. b = exerrors.Must(sjson.DeleteBytes(b, "unsigned.transaction_id")) @@ -214,13 +243,42 @@ func (ev Event) MarshalJSON() ([]byte, error) { ev.PrevEventIDs = make([]id.EventID, 0) } b := exerrors.Must(json.Marshal((marshalEvent)(ev))) + if domainlessCreate { + b = exerrors.Must(sjson.DeleteBytes(b, "room_id")) + } // Strip unsigned from any S2S API calls b = exerrors.Must(sjson.DeleteBytes(b, "unsigned")) return b, nil } func (ev *Event) UnmarshalJSON(b []byte) error { - return json.Unmarshal(b, (*marshalEvent)(ev)) + if err := json.Unmarshal(b, (*marshalEvent)(ev)); err != nil { + return err + } + if ev.Type != event.StateCreate { + return nil + } + roomVersion := ev.RoomVersion + if roomVersion == "" { + roomVersion = gjson.GetBytes(ev.Content, "room_version").String() + } + roomSpec, err := gomatrixserverlib.GetRoomVersion(gomatrixserverlib.RoomVersion(roomVersion)) + if err != nil || !roomSpec.DomainlessRoomIDs() { + return nil + } + ev.RoomVersion = roomVersion + if gjson.GetBytes(b, "room_id").Exists() { + return fmt.Errorf("v12 create event must omit room_id") + } + if ev.StateKey == nil || *ev.StateKey != "" { + return fmt.Errorf("create event must have an empty state key") + } + pdu, err := roomSpec.NewEventFromTrustedJSON(b, false) + if err != nil { + return err + } + ev.RoomID = id.RoomID(pdu.RoomID().String()) + return nil } func (ev *Event) EventTup() EventTup { @@ -232,19 +290,6 @@ func (ev *Event) EventTup() EventTup { } } -func (ev *Event) EventStateTup() EventStateTup { - if ev.StateKey == nil { - panic("not a state event") - } - return EventStateTup{ - EventID: ev.ID, - StateTup: StateTup{ - Type: ev.Type, - StateKey: *ev.StateKey, - }, - } -} - func (ev *Event) StateTup() StateTup { if ev.StateKey == nil { panic("not a state event") @@ -255,6 +300,15 @@ func (ev *Event) StateTup() StateTup { } } +// StateEntry is what a state context holds for the state event +func (ev *Event) StateEntry() StateEntry { + entry := StateEntry{EventID: ev.ID} + if ev.Type == event.StateMember { + entry.Membership = ev.Membership() + } + return entry +} + func (ev *Event) MembershipTup() MembershipTup { if ev.Type != event.StateMember { panic("not a member event") @@ -284,7 +338,7 @@ func (ev *Event) MustGetRoomSpec() gomatrixserverlib.IRoomVersion { } func (ev *Event) IsProfileUpdate() bool { - return gjson.GetBytes(ev.Content, "babbleserv.is_profile_update").Bool() + return gjson.GetBytes(ev.Content, `babbleserv\.is_profile_update`).Bool() } func (ev *Event) Membership() event.Membership { @@ -333,7 +387,53 @@ func (ev *Event) GetRedactedEvent() (*Event, error) { if err := json.Unmarshal(b, &redacted); err != nil { return nil, err } + redacted.RoomVersion, redacted.RoomID, redacted.ID = ev.RoomVersion, ev.RoomID, ev.ID redacted.Redacted = true return &redacted, err } + +// ImplicitCreateEventID is the create event committed to by a domainless room ID. +func (ev *Event) ImplicitCreateEventID() id.EventID { + roomSpec, err := gomatrixserverlib.GetRoomVersion(ev.GetRoomVersion()) + if err != nil || !roomSpec.DomainlessRoomIDs() || len(ev.RoomID) < 2 || ev.RoomID[0] != '!' { + return "" + } + return id.EventID("$" + ev.RoomID[1:]) +} + +// DomainlessRoomID is the room ID a create event commits to, the inverse of ImplicitCreateEventID. +func (ev *Event) DomainlessRoomID() id.RoomID { + roomSpec, err := gomatrixserverlib.GetRoomVersion(ev.GetRoomVersion()) + if err != nil || !roomSpec.DomainlessRoomIDs() || ev.Type != event.StateCreate || len(ev.ID) < 2 || ev.ID[0] != '$' { + return "" + } + return id.RoomID("!" + ev.ID[1:]) +} + +// AuthDependencyIDs are the event's auth events, preceded by the create event that it cites +// implicitly in a room with a domainless room ID. +func (ev *Event) AuthDependencyIDs() []id.EventID { + if createID := ev.ImplicitCreateEventID(); createID != "" && ev.Type != event.StateCreate { + return append([]id.EventID{createID}, ev.AuthEventIDs...) + } + return ev.AuthEventIDs +} + +// HasStateIn reports whether the event, which may be nil, is in the room with known state contexts. +// BeforeState and AfterState are always set together, and an outlier never has them. +func (ev *Event) HasStateIn(roomID id.RoomID) bool { + return ev != nil && !ev.Outlier && ev.RoomID == roomID && !ev.AfterState.IsZero() +} + +// ResetOutcome clears what authorizing the event decided: its rejection, soft failure and state. +func (ev *Event) ResetOutcome() { + ev.Rejected, ev.SoftFailed = false, false + ev.BeforeState, ev.AfterState = StateHash{}, StateHash{} +} + +// CopyOutcome gives the event what authorizing another copy of it decided. +func (ev *Event) CopyOutcome(from *Event) { + ev.Rejected, ev.SoftFailed = from.Rejected, from.SoftFailed + ev.BeforeState, ev.AfterState = from.BeforeState, from.AfterState +} diff --git a/internal/types/event_test.go b/internal/types/event_test.go index 27e54d0..814a3bb 100644 --- a/internal/types/event_test.go +++ b/internal/types/event_test.go @@ -4,13 +4,16 @@ import ( "encoding/json" "testing" + "github.com/matrix-org/gomatrixserverlib" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" "github.com/vmihailenco/msgpack/v5" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" ) func TestEventSerializationNoPrevState(t *testing.T) { @@ -60,3 +63,60 @@ func TestEventSerializationWithPrevState(t *testing.T) { require.NoError(t, err) assert.NotNil(t, ev.PrevState) } + +func TestDomainlessRoomIDInvertsImplicitCreateEventID(t *testing.T) { + create := &types.Event{ID: "$abc", PartialEvent: types.PartialEvent{Type: event.StateCreate}, RoomVersion: "12"} + create.RoomID = create.DomainlessRoomID() + assert.Equal(t, id.RoomID("!abc"), create.RoomID) + assert.Equal(t, create.ID, create.ImplicitCreateEventID()) + + create.RoomVersion = "11" + assert.Empty(t, create.DomainlessRoomID()) +} + +func TestStateTupCompareOrdersTypeFirst(t *testing.T) { + topic := types.StateTup{Type: event.StateTopic} + alice := types.MemberStateTup("@alice:example.com") + bob := types.MemberStateTup("@bob:example.com") + assert.Negative(t, alice.Compare(bob)) + assert.Negative(t, bob.Compare(topic)) + assert.Positive(t, topic.Compare(alice)) + assert.Zero(t, alice.Compare(alice)) +} + +func TestAuthDependencyIDsFollowRoomVersionCapabilities(t *testing.T) { + for _, test := range []struct { + roomVersion string + evType event.Type + expected []id.EventID + }{ + {roomVersion: "11", evType: event.EventMessage, expected: []id.EventID{"$power"}}, + {roomVersion: "12", evType: event.EventMessage, expected: []id.EventID{"$abc", "$power"}}, + {roomVersion: "org.matrix.hydra.11", evType: event.EventMessage, expected: []id.EventID{"$abc", "$power"}}, + {roomVersion: "12", evType: event.StateCreate, expected: []id.EventID{"$power"}}, + {roomVersion: "unknown", evType: event.EventMessage, expected: []id.EventID{"$power"}}, + } { + ev := &types.Event{ + PartialEvent: types.PartialEvent{RoomID: "!abc", Type: test.evType}, + RoomVersion: test.roomVersion, + AuthEventIDs: []id.EventID{"$power"}, + } + assert.Equal(t, test.expected, ev.AuthDependencyIDs(), test.roomVersion) + } + assert.True(t, util.RoomVersionHas("org.matrix.hydra.11", func(impl gomatrixserverlib.IRoomVersion) bool { + return impl.StateResAlgorithm() == gomatrixserverlib.StateResV2_1 + })) + assert.False(t, util.RoomVersionHas("11", func(impl gomatrixserverlib.IRoomVersion) bool { + return impl.StateResAlgorithm() == gomatrixserverlib.StateResV2_1 + })) +} + +func TestMembershipRowRoundTrips(t *testing.T) { + for _, row := range []types.MembershipRow{ + {MembershipTup: types.MembershipTup{EventID: "$join", RoomID: "!room", Membership: event.MembershipJoin}}, + {MembershipTup: types.MembershipTup{EventID: "$invite", RoomID: "!room", Membership: event.MembershipInvite}, Outlier: true}, + } { + assert.Equal(t, row, types.BytesToMembershipRow(types.MembershipRowToBytes(row))) + assert.Equal(t, row.MembershipTup, types.BytesToMembershipTup(types.MembershipRowToBytes(row))) + } +} diff --git a/internal/types/eventpdu.go b/internal/types/eventpdu.go index f06e585..fbfb15c 100644 --- a/internal/types/eventpdu.go +++ b/internal/types/eventpdu.go @@ -3,6 +3,7 @@ package types import ( "crypto/ed25519" "encoding/json" + "time" "github.com/matrix-org/gomatrixserverlib" "github.com/matrix-org/gomatrixserverlib/spec" @@ -154,9 +155,10 @@ func (pdu EventPDU) JSON() []byte { } func (pdu EventPDU) AuthEventIDs() []string { - eventIDs := make([]string, 0, len(pdu.ev.AuthEventIDs)) - for _, eventID := range pdu.ev.AuthEventIDs { - eventIDs = append(eventIDs, eventID.String()) + authIDs := pdu.ev.AuthDependencyIDs() + eventIDs := make([]string, len(authIDs)) + for i, eventID := range authIDs { + eventIDs[i] = eventID.String() } return eventIDs } @@ -164,3 +166,15 @@ func (pdu EventPDU) AuthEventIDs() []string { func (pdu EventPDU) ToHeaderedJSON() ([]byte, error) { return nil, nil } + +func (pdu EventPDU) PrevStateEventIDs() []string { + out := make([]string, len(pdu.ev.PrevState)) + for i, eventID := range pdu.ev.PrevState { + out[i] = eventID.String() + } + return out +} + +// Sticky events are not supported by the versions this server advertises. +func (pdu EventPDU) IsSticky(now, received time.Time) bool { return false } +func (pdu EventPDU) StickyEndTime(received time.Time) time.Time { return time.Time{} } diff --git a/internal/types/eventtups.go b/internal/types/eventtups.go index 301c858..28f158f 100644 --- a/internal/types/eventtups.go +++ b/internal/types/eventtups.go @@ -2,8 +2,10 @@ package types import ( "bytes" + "cmp" "context" "slices" + "strings" "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" "github.com/rs/zerolog" @@ -74,6 +76,15 @@ type StateTup struct { StateKey string `json:"state_key"` } +func MemberStateTup(userID id.UserID) StateTup { + return StateTup{Type: event.StateMember, StateKey: userID.String()} +} + +// Compare orders state tuples by type, then state key +func (tup StateTup) Compare(other StateTup) int { + return cmp.Or(strings.Compare(tup.Type.Type, other.Type.Type), strings.Compare(tup.StateKey, other.StateKey)) +} + func (tup StateTup) MarshalText() ([]byte, error) { if tup.StateKey == "" { return []byte(tup.Type.String()), nil @@ -81,6 +92,24 @@ func (tup StateTup) MarshalText() ([]byte, error) { return []byte(tup.Type.String() + "/" + tup.StateKey), nil } +// TupleElements are the type and state key as FoundationDB tuple elements, read back with +// StateTupFromElements. +func (tup StateTup) TupleElements() tuple.Tuple { + return tuple.Tuple{tup.Type.String(), tup.StateKey} +} + +func StateTupFromElements(elements tuple.Tuple) (StateTup, bool) { + if len(elements) != 2 { + return StateTup{}, false + } + evType, typeOK := elements[0].(string) + stateKey, stateKeyOK := elements[1].(string) + if !typeOK || !stateKeyOK { + return StateTup{}, false + } + return StateTup{Type: event.NewEventType(evType), StateKey: stateKey}, true +} + type EventStateTup struct { StateTup `json:",inline"` EventID id.EventID @@ -110,21 +139,6 @@ func (s StateMap) ToTups() []EventStateTup { return tups } -func EventStateTupToBytes(tup EventStateTup) []byte { - return tuple.Tuple{tup.EventID.String(), tup.Type.String(), tup.StateKey}.Pack() -} - -func BytesToEventStateTup(b []byte) EventStateTup { - tup, _ := tuple.Unpack(b) - return EventStateTup{ - EventID: id.EventID(tup[0].(string)), - StateTup: StateTup{ - Type: event.NewEventType(tup[1].(string)), - StateKey: tup[2].(string), - }, - } -} - // Membership tuples defined as (eventID, roomID, membership) type MembershipTup struct { EventID id.EventID `json:"event_id"` @@ -150,9 +164,35 @@ func MembershipTupToBytes(tup MembershipTup) []byte { func BytesToMembershipTup(b []byte) MembershipTup { tup, _ := tuple.Unpack(b) + return membershipTupOf(tup) +} + +func membershipTupOf(tup tuple.Tuple) MembershipTup { return MembershipTup{ EventID: id.EventID(tup[0].(string)), RoomID: id.RoomID(tup[1].(string)), Membership: event.Membership(tup[2].(string)), } } + +// MembershipRow is a local user's membership row of a room, marking an outlier membership received +// while this server is out of the room. +type MembershipRow struct { + MembershipTup + Outlier bool +} + +// MembershipRowToBytes packs (event_id, room_id, membership), and true after them for an outlier +// membership +func MembershipRowToBytes(row MembershipRow) []byte { + tup := tuple.Tuple{row.EventID.String(), row.RoomID.String(), string(row.Membership)} + if row.Outlier { + tup = append(tup, true) + } + return tup.Pack() +} + +func BytesToMembershipRow(b []byte) MembershipRow { + tup, _ := tuple.Unpack(b) + return MembershipRow{MembershipTup: membershipTupOf(tup), Outlier: len(tup) > 3 && tup[3] == true} +} diff --git a/internal/types/room.go b/internal/types/room.go index cfd5892..5ff25e8 100644 --- a/internal/types/room.go +++ b/internal/types/room.go @@ -20,13 +20,20 @@ type Room struct { CanonicalAlias string `json:"canonical_alias" msgpack:"cas"` - MemberCount int `json:"members" msgpack:"mem"` + MemberCount int `json:"members" msgpack:"mem"` + // Joined members of this server + LocalMembers int `json:"local_members" msgpack:"lmc"` + Encrypted bool `json:"encrypted" msgpack:"enc"` JoinRule string `json:"join_rule" msgpack:"jrl"` HistoryVisibility string `json:"history_visibility" msgpack:"hvs"` GuestAccess string `json:"guest_access" msgpack:"gac"` Public bool `json:"is_public" msgpack:"pub"` Federated bool `json:"is_federated" msgpack:"fed"` + + // Bumped by every publish, whose guard requires it unchanged since the send prepared + StateRevision uint64 `json:"state_revision" msgpack:"srv"` + CurrentState StateHash `json:"current_state" msgpack:"cst"` } func NewRoomFromBytes(b []byte, id id.RoomID) (*Room, error) { diff --git a/internal/types/statecontext.go b/internal/types/statecontext.go new file mode 100644 index 0000000..a6a6321 --- /dev/null +++ b/internal/types/statecontext.go @@ -0,0 +1,48 @@ +package types + +import ( + "encoding/hex" + "encoding/json" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// StateHash identifies an immutable state context or state map page: the first 16 bytes of the +// SHA-256 of its encoding. The zero hash is the root of an empty map, and as a context ID means the +// context is unknown. +type StateHash [16]byte + +func (h StateHash) IsZero() bool { + return h == StateHash{} +} + +// MarshalJSON renders the hash as hex. There is deliberately no MarshalText: msgpack prefers it and +// would store the hash as a hex string instead of 16 bytes. +func (h StateHash) MarshalJSON() ([]byte, error) { + return json.Marshal(hex.EncodeToString(h[:])) +} + +// StateChange describes a change bwtween two contexts for a given state tup (type, state_key) pair +type StateChange struct { + StateTup + OldEventID id.EventID + NewEventID id.EventID + OldMembership event.Membership + NewMembership event.Membership +} + +type StateEntry struct { + EventID id.EventID + Membership event.Membership +} + +type StateEntries map[StateTup]StateEntry + +func (s StateEntries) EventIDs() StateMap { + stateMap := make(StateMap, len(s)) + for tup, entry := range s { + stateMap[tup] = entry.EventID + } + return stateMap +} diff --git a/internal/util/eventauth.go b/internal/util/eventauth.go new file mode 100644 index 0000000..ee44c3f --- /dev/null +++ b/internal/util/eventauth.go @@ -0,0 +1,70 @@ +package util + +import ( + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/gomatrixserverlib/spec" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +// AuthEventLookup returns the event for a tuple of the state an event is authorized against, or +// nil if that state has none. +type AuthEventLookup func(types.StateTup) (*types.Event, error) + +var _ gomatrixserverlib.AuthEventProvider = AuthEventLookup(nil) + +func (lookup AuthEventLookup) pdu(tup types.StateTup) (gomatrixserverlib.PDU, error) { + ev, err := lookup(tup) + if ev == nil || err != nil { + return nil, err + } + return ev.PDU(), nil +} + +func (lookup AuthEventLookup) Create() (gomatrixserverlib.PDU, error) { + return lookup.pdu(types.StateTup{Type: event.StateCreate}) +} + +func (lookup AuthEventLookup) JoinRules() (gomatrixserverlib.PDU, error) { + return lookup.pdu(types.StateTup{Type: event.StateJoinRules}) +} + +func (lookup AuthEventLookup) PowerLevels() (gomatrixserverlib.PDU, error) { + return lookup.pdu(types.StateTup{Type: event.StatePowerLevels}) +} + +func (lookup AuthEventLookup) Member(stateKey spec.SenderID) (gomatrixserverlib.PDU, error) { + return lookup.pdu(types.MemberStateTup(id.UserID(stateKey))) +} + +func (lookup AuthEventLookup) ThirdPartyInvite(stateKey string) (gomatrixserverlib.PDU, error) { + return lookup.pdu(types.StateTup{Type: event.StateThirdPartyInvite, StateKey: stateKey}) +} + +func (lookup AuthEventLookup) Valid() bool { + return true +} + +func userIDForSender(_ spec.RoomID, senderID spec.SenderID) (*spec.UserID, error) { + return senderID.ToUserID(), nil +} + +// Authorize keeps lookup errors separate from rejections, including provider errors that +// gomatrixserverlib.Allowed does not propagate. +func Authorize(ev *types.Event, lookup AuthEventLookup) (authErr, err error) { + var lookupErr error + provider := AuthEventLookup(func(tup types.StateTup) (*types.Event, error) { + authEv, err := lookup(tup) + if lookupErr == nil { + lookupErr = err + } + return authEv, err + }) + authErr = gomatrixserverlib.Allowed(ev.PDU(), provider, userIDForSender) + if lookupErr != nil { + return nil, lookupErr + } + return authErr, nil +} diff --git a/internal/util/events.go b/internal/util/events.go index cebdf25..172eb79 100644 --- a/internal/util/events.go +++ b/internal/util/events.go @@ -1,6 +1,7 @@ package util import ( + "cmp" "context" "crypto/ed25519" "crypto/sha256" @@ -10,6 +11,7 @@ import ( "github.com/matrix-org/gomatrixserverlib" "github.com/matrix-org/gomatrixserverlib/spec" + "github.com/rs/zerolog" "github.com/tidwall/sjson" "maunium.net/go/mautrix/federation" "maunium.net/go/mautrix/id" @@ -17,6 +19,14 @@ import ( "github.com/beeper/babbleserv/internal/types" ) +func EventsToIDs(evs []*types.Event) []id.EventID { + ids := make([]id.EventID, len(evs)) + for i, ev := range evs { + ids[i] = ev.ID + } + return ids +} + func EventForClientAPI(ev *types.Event) *types.Event { ev.IsForClientAPI = true return ev @@ -52,24 +62,10 @@ func EventsToMauPDUs(evs []*types.Event) []federation.PDU { func SortEventList(evs []*types.Event) { slices.SortFunc(evs, func(a, b *types.Event) int { // TODO: this is probably not enough - if a.Depth > b.Depth { - return 1 - } - if a.Depth == b.Depth && len(a.AuthEventIDs) > len(b.AuthEventIDs) { - return 1 - } - return -1 + return cmp.Or(cmp.Compare(a.Depth, b.Depth), cmp.Compare(len(a.AuthEventIDs), len(b.AuthEventIDs))) }) } -func EventsToPDUs(evs []*types.Event) []gomatrixserverlib.PDU { - pdus := make([]gomatrixserverlib.PDU, len(evs)) - for i, ev := range evs { - pdus[i] = ev.PDU() - } - return pdus -} - func EventsToJSONs(evs []*types.Event) []json.RawMessage { jsons := make([]json.RawMessage, len(evs)) for i, ev := range evs { @@ -82,16 +78,6 @@ func EventsToJSONs(evs []*types.Event) []json.RawMessage { return jsons } -func MergeEventsMap(evSlices ...[]*types.Event) map[id.EventID]*types.Event { - evMap := make(map[id.EventID]*types.Event, len(evSlices)*len(evSlices[0])) - for _, evs := range evSlices { - for _, ev := range evs { - evMap[ev.ID] = ev - } - } - return evMap -} - func getEventRedactedJSON(ev *types.Event) ([]byte, error) { b, err := json.Marshal(ev) if err != nil { @@ -161,9 +147,69 @@ func VerifyEventFromServer(ctx context.Context, ev *types.Event, serverName stri return verifyEvent(ctx, ev, serverName, keyStore) } +// Verifies an event from another server, returning the event to keep: the redacted form of one that +// has been redacted, as Synapse does. +func VerifyRemoteEvent(ctx context.Context, ev *types.Event, keyStore *KeyStore) (*types.Event, error, error) { + // The event must be signed by the senders server, which may not be the one it came from + verifyErr, err := VerifyEvent(ctx, ev, keyStore) + if err != nil { + return nil, nil, err + } else if errors.Is(verifyErr, types.ErrEventRedacted) { + redactedEv, err := ev.GetRedactedEvent() + if err != nil { + return nil, nil, err + } + return redactedEv, nil, nil + } else if verifyErr != nil { + return nil, verifyErr, nil + } + return ev, nil, nil +} + +// Parses and verifies events fetched from another server, skipping those that cannot be parsed or +// fail verification and any already seen. Redacted events are kept, redacted. +func VerifyRemoteEvents[S ~[]E, E ~[]byte]( + ctx context.Context, + raws S, + roomVersion string, + keyStore *KeyStore, + seenIDs map[id.EventID]struct{}, +) ([]*types.Event, error) { + log := zerolog.Ctx(ctx) + evs := make([]*types.Event, 0, len(raws)) + for _, b := range raws { + remoteEv := &types.Event{RoomVersion: roomVersion} + if err := json.Unmarshal(b, remoteEv); err != nil { + log.Warn().Err(err).Msg("Skipping remote event that cannot be parsed") + continue + } + verifiedEv, verifyErr, err := VerifyRemoteEvent(ctx, remoteEv, keyStore) + if err != nil { + return nil, err + } else if verifyErr != nil { + log.Err(verifyErr). + Stringer("event_id", remoteEv.ID). + Stringer("type", remoteEv.Type). + Any("ev", remoteEv). + Msg("Skipping remote event that failed verification") + continue + } else if _, found := seenIDs[verifiedEv.ID]; found { + log.Warn(). + Stringer("event_id", verifiedEv.ID). + Msg("Skipping duplicate remote event") + continue + } + evs = append(evs, verifiedEv) + seenIDs[verifiedEv.ID] = struct{}{} + } + return evs, nil +} + func verifyEvent(ctx context.Context, ev *types.Event, serverName string, keyStore *KeyStore) (error, error) { if _, err := spec.NewRoomID(ev.RoomID.String()); err != nil { return err, nil + } else if err := CheckEventSize(ev); err != nil { + return err, nil } if ev.Type.Type == spec.MRoomMember && ev.StateKey == nil { return errors.New("membership event has no state key"), nil @@ -264,6 +310,11 @@ func GetRefHashForRedactedBytes(b []byte, roomVersion gomatrixserverlib.RoomVers } func HashAndSignEvent(ev *types.Event, serverName, keyID string, key ed25519.PrivateKey) error { + if err := CheckEventSize(ev); err != nil { + return err + } else if err := ev.MustGetRoomSpec().CheckCanonicalJSON(ev.PDU().JSON()); err != nil { + return &gomatrixserverlib.EventValidationError{Code: 400, Message: err.Error()} + } // Calculate the content hash before the ID/reference hash hash, err := GetEventContentHash(ev) if err != nil { diff --git a/internal/util/events_test.go b/internal/util/events_test.go index 3bcb2f9..643727e 100644 --- a/internal/util/events_test.go +++ b/internal/util/events_test.go @@ -1,18 +1,26 @@ package util_test import ( + "bytes" + "context" "crypto/ed25519" "encoding/base64" "encoding/json" + "net/http" + "net/http/httptest" "testing" + "time" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/gomatrixserverlib/fclient" + "github.com/matrix-org/gomatrixserverlib/spec" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" "github.com/beeper/babbleserv/internal/types" "github.com/beeper/babbleserv/internal/util" @@ -144,3 +152,140 @@ func TestVerifyEventJSON(t *testing.T) { err = util.VerifyJSON(b, "matrix.org", "ed25519:a_RXGa", pubKey) require.NoError(t, err) } + +func TestV12CreateHashAndRoomID(t *testing.T) { + empty := "" + create := &types.Event{RoomVersion: "12", PartialEvent: types.PartialEvent{Type: event.StateCreate, StateKey: &empty, Sender: "@alice:example.com", Timestamp: 1234, Content: json.RawMessage(`{"room_version":"12"}`)}} + key := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) + require.NoError(t, util.HashAndSignEvent(create, "example.com", "ed25519:test", key)) + create.RoomID = id.RoomID("!" + create.ID[1:]) + raw, err := json.Marshal(create) + require.NoError(t, err) + require.False(t, gjson.GetBytes(raw, "room_id").Exists()) + native, err := create.MustGetRoomSpec().NewEventFromTrustedJSON(raw, false) + require.NoError(t, err) + require.Equal(t, create.ID.String(), native.EventID()) + require.Equal(t, create.RoomID.String(), native.RoomID().String()) + decoded := &types.Event{RoomVersion: "12"} + require.NoError(t, json.Unmarshal(raw, decoded)) + require.Equal(t, create.RoomID, decoded.RoomID) + // Re-signing in the room transaction does not change the precomputed ID. + before := create.ID + require.NoError(t, util.HashAndSignEvent(create, "example.com", "ed25519:test", key)) + require.Equal(t, before, create.ID) + malformed, err := sjson.SetBytes(raw, "room_id", create.RoomID) + require.NoError(t, err) + require.Error(t, json.Unmarshal(malformed, &types.Event{RoomVersion: "12"})) + malformed, err = sjson.DeleteBytes(raw, "state_key") + require.NoError(t, err) + require.Error(t, json.Unmarshal(malformed, &types.Event{RoomVersion: "12"})) + redacted, err := create.GetRedactedEvent() + require.NoError(t, err) + require.Equal(t, create.RoomID, redacted.RoomID) + require.Equal(t, create.RoomVersion, redacted.RoomVersion) +} + +type keysClient struct { + fclient.FederationClient + key ed25519.PublicKey +} + +func (c keysClient) GetServerKeys(context.Context, spec.ServerName) (gomatrixserverlib.ServerKeys, error) { + var keys gomatrixserverlib.ServerKeys + keys.VerifyKeys = map[gomatrixserverlib.KeyID]gomatrixserverlib.VerifyKey{"ed25519:test": {Key: spec.Base64Bytes(c.key)}} + keys.ValidUntilTS = spec.AsTimestamp(time.Now().Add(time.Hour)) + return keys, nil +} + +func TestVerifyRemoteEvents(t *testing.T) { + key := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) + signed := func(body string) *types.Event { + ev := &types.Event{ + PartialEvent: types.PartialEvent{ + RoomID: "!room:example.com", + Sender: "@alice:example.com", + Type: event.EventMessage, + Timestamp: time.Now().UnixMilli(), + Content: json.RawMessage(`{"msgtype":"m.text","body":"` + body + `"}`), + }, + RoomVersion: "11", + PrevEventIDs: []id.EventID{}, + AuthEventIDs: []id.EventID{}, + } + require.NoError(t, util.HashAndSignEvent(ev, "example.com", "ed25519:test", key)) + return ev + } + pdu := func(ev *types.Event) json.RawMessage { + b, err := json.Marshal(ev) + require.NoError(t, err) + return b + } + + valid := signed("valid") + redacted := signed("redacted") + redactedPDU, err := sjson.SetBytes(pdu(redacted), "content.body", "altered") + require.NoError(t, err) + forged, err := sjson.SetBytes(pdu(signed("forged")), "sender", "@mallory:example.com") + require.NoError(t, err) + + seenIDs := map[id.EventID]struct{}{} + evs, err := util.VerifyRemoteEvents( + context.Background(), + []json.RawMessage{json.RawMessage(`{"content":[`), forged, redactedPDU, pdu(valid), pdu(valid)}, + "11", util.NewKeyStore(keysClient{key: key.Public().(ed25519.PublicKey)}), seenIDs, + ) + require.NoError(t, err) + + // An event that cannot be parsed or fails verification is skipped, and a redacted one is kept + // redacted + require.Len(t, evs, 2) + assert.Equal(t, redacted.ID, evs[0].ID) + assert.True(t, evs[0].Redacted) + assert.JSONEq(t, `{}`, string(evs[0].Content)) + assert.Equal(t, valid.ID, evs[1].ID) + assert.False(t, evs[1].Redacted) + assert.Equal(t, map[id.EventID]struct{}{redacted.ID: {}, valid.ID: {}}, seenIDs) +} + +// Signs a member event as its sender would, over its content exactly as given and without the +// checks this server makes of the events it signs +func signedRemoteMember(t *testing.T, key ed25519.PrivateKey, stateKey, content string) (id.EventID, json.RawMessage) { + ev := &types.Event{ + PartialEvent: types.PartialEvent{ + RoomID: "!room:example.com", Sender: "@alice:example.com", Type: event.StateMember, StateKey: &stateKey, + Timestamp: time.Now().UnixMilli(), Content: json.RawMessage(content), + }, + RoomVersion: "11", + PrevEventIDs: []id.EventID{}, + AuthEventIDs: []id.EventID{}, + } + hash, err := util.GetEventContentHash(ev) + require.NoError(t, err) + ev.Hashes = map[string]string{"sha256": hash} + signature, eventID, err := util.GetEventSignatureAndRefrerenceHash(ev, key) + require.NoError(t, err) + ev.Signatures, ev.ID = map[string]map[string]string{"example.com": {"ed25519:test": signature}}, eventID + b, err := json.Marshal(ev) + require.NoError(t, err) + return eventID, b +} + +func TestVerifyRequestEventWithDuplicateKeys(t *testing.T) { + key := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) + // The sender hashed the content with both members, redaction keeps the last membership + eventID, pdu := signedRemoteMember(t, key, "@bob:example.com", `{"membership":"join","displayname":"Bob","membership":"ban"}`) + ev, respErr := util.ParseRequestJSON[types.Event](httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(pdu))) + require.Nil(t, respErr) + ev.RoomVersion = "11" + verified, verifyErr, err := util.VerifyRemoteEvent( + context.Background(), &ev, + util.NewKeyStore(keysClient{key: key.Public().(ed25519.PublicKey)}), + ) + require.NoError(t, err) + require.NoError(t, verifyErr) + require.NotNil(t, verified) + assert.Equal(t, eventID, verified.ID) + assert.True(t, verified.Redacted) + assert.JSONEq(t, `{"membership":"ban"}`, string(verified.Content)) + assert.Equal(t, event.MembershipBan, verified.Membership()) +} diff --git a/internal/util/eventsize.go b/internal/util/eventsize.go new file mode 100644 index 0000000..771e88a --- /dev/null +++ b/internal/util/eventsize.go @@ -0,0 +1,56 @@ +package util + +import ( + "encoding/json" + "fmt" + "net/http" + "unicode/utf8" + + "github.com/matrix-org/gomatrixserverlib" + + "github.com/beeper/babbleserv/internal/types" +) + +const ( + MaxPDUBytes = 65536 + MaxPDUFieldSize = 255 +) + +// CheckEventSize fails with a 413 EventValidationError for an event over Synapse's PDU limits: its +// canonical JSON over MaxPDUBytes, or its sender, room ID, event ID, type or state key over +// MaxPDUFieldSize, counted in codepoints before room version 11 and in bytes from it. +func CheckEventSize(ev *types.Event) error { + b, err := json.Marshal(ev) + if err == nil { + b, err = gomatrixserverlib.CanonicalJSON(b) + } + if err != nil { + return err + } else if len(b) > MaxPDUBytes { + return eventTooLarge("event is %d bytes, over %d", len(b), MaxPDUBytes) + } + + fieldSize := utf8.RuneCountInString + if roomVersion, err := gomatrixserverlib.GetRoomVersion(ev.GetRoomVersion()); err == nil && roomVersion.StrictEventByteLimits() { + fieldSize = func(s string) int { return len(s) } + } + fields := map[string]string{ + "sender": ev.Sender.String(), + "room_id": ev.RoomID.String(), + "event_id": ev.ID.String(), + "type": ev.Type.Type, + } + if ev.StateKey != nil { + fields["state_key"] = *ev.StateKey + } + for name, value := range fields { + if size := fieldSize(value); size > MaxPDUFieldSize { + return eventTooLarge("%s is %d long, over %d", name, size, MaxPDUFieldSize) + } + } + return nil +} + +func eventTooLarge(format string, args ...any) error { + return &gomatrixserverlib.EventValidationError{Code: http.StatusRequestEntityTooLarge, Message: fmt.Sprintf(format, args...)} +} diff --git a/internal/util/eventsize_test.go b/internal/util/eventsize_test.go new file mode 100644 index 0000000..162cd30 --- /dev/null +++ b/internal/util/eventsize_test.go @@ -0,0 +1,57 @@ +package util_test + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/matrix-org/gomatrixserverlib" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" +) + +func TestCheckEventSize(t *testing.T) { + newEvent := func(roomVersion, stateKey, body string) *types.Event { + content, err := json.Marshal(map[string]string{"body": body}) + require.NoError(t, err) + return &types.Event{ + PartialEvent: types.PartialEvent{ + RoomID: "!room:example.com", + Sender: "@alice:example.com", + Type: event.NewEventType("m.test"), + StateKey: &stateKey, + Content: content, + }, + RoomVersion: roomVersion, + } + } + multiByteKey := strings.Repeat("é", util.MaxPDUFieldSize) + + for name, tc := range map[string]struct { + ev *types.Event + tooLarge bool + }{ + "small event": {newEvent("11", "", "hello"), false}, + "content at the limit's edge": {newEvent("11", "", strings.Repeat("x", util.MaxPDUBytes-400)), false}, + "content over the limit": {newEvent("11", "", strings.Repeat("x", util.MaxPDUBytes)), true}, + "long state key in codepoints before v11": {newEvent("10", multiByteKey, ""), false}, + "long state key in bytes from v11": {newEvent("11", multiByteKey, ""), true}, + "state key over the limit in any version": {newEvent("10", strings.Repeat("k", util.MaxPDUFieldSize+1), ""), true}, + } { + err := util.CheckEventSize(tc.ev) + if !tc.tooLarge { + assert.NoError(t, err, name) + continue + } + var invalid *gomatrixserverlib.EventValidationError + require.ErrorAs(t, err, &invalid, name) + assert.Equal(t, http.StatusRequestEntityTooLarge, invalid.Code, name) + assert.Equal(t, mautrix.MTooLarge.ErrCode, util.RejectedEventError(err).ErrCode, name) + } +} diff --git a/internal/util/response.go b/internal/util/response.go index e7e8591..270df46 100644 --- a/internal/util/response.go +++ b/internal/util/response.go @@ -8,8 +8,11 @@ import ( "runtime/debug" "github.com/matrix-org/gomatrix" + "github.com/matrix-org/gomatrixserverlib" "github.com/rs/zerolog/hlog" "maunium.net/go/mautrix" + + "github.com/beeper/babbleserv/internal/types" ) var ( @@ -79,6 +82,11 @@ func MakeMatrixError(error mautrix.RespError, message string) mautrix.RespError } func ResponseErrorUnknownJSON(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, types.ErrRoomTooLarge) { + hlog.FromRequest(r).Warn().Err(err).Msg("Refusing a change too large to publish") + ResponseJSON(w, r, http.StatusInternalServerError, errorData{MUnknown.ErrCode, err.Error()}) + return + } var httpErr gomatrix.HTTPError if errors.As(err, &httpErr) { hlog.FromRequest(r).Error(). @@ -97,6 +105,28 @@ func ResponseErrorUnknownJSON(w http.ResponseWriter, r *http.Request, err error) ResponseErrorJSON(w, r, MUnknown) } +// IsInvalidEvent reports whether an event was refused for its JSON, its sender's fault +func IsInvalidEvent(err error) bool { + var invalid *gomatrixserverlib.EventValidationError + return errors.As(err, &invalid) && invalid.Code == http.StatusBadRequest +} + +// RejectedEventError is the error refusing an event: invalid event JSON is a bad request, an event +// over the size limits too large, any other reason forbidden, as Synapse answers +func RejectedEventError(err error) mautrix.RespError { + var invalid *gomatrixserverlib.EventValidationError + if IsInvalidEvent(err) { + return mautrix.MBadJSON + } else if errors.As(err, &invalid) && invalid.Code == http.StatusRequestEntityTooLarge { + return mautrix.MTooLarge + } + return mautrix.MForbidden +} + +func ResponseRejectedEventJSON(w http.ResponseWriter, r *http.Request, err error) { + ResponseErrorMessageJSON(w, r, RejectedEventError(err), err.Error()) +} + func ResponseErrorJSON(w http.ResponseWriter, r *http.Request, error mautrix.RespError) { ResponseErrorMessageJSON(w, r, error, "") } diff --git a/internal/util/roomversion.go b/internal/util/roomversion.go new file mode 100644 index 0000000..efbfc87 --- /dev/null +++ b/internal/util/roomversion.go @@ -0,0 +1,8 @@ +package util + +import "github.com/matrix-org/gomatrixserverlib" + +func RoomVersionHas(roomVersion string, capability func(gomatrixserverlib.IRoomVersion) bool) bool { + impl, err := gomatrixserverlib.GetRoomVersion(gomatrixserverlib.RoomVersion(roomVersion)) + return err == nil && capability(impl) +} From 65a782c1b2b7c6c1207bd95109557647833cce22 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Sat, 3 Oct 2026 21:45:59 +0100 Subject: [PATCH 03/12] Implement sparse state resolution v2 and v2.1 --- internal/stateres/sparse.go | 146 +++ internal/stateres/sparse_test.go | 285 +++++ internal/stateres/stateres.go | 62 + internal/stateres/stateres_test.go | 863 ++++++++++++++ internal/stateres/synapse_fixture_test.go | 113 ++ internal/stateres/testdata/generate_v12.py | 83 ++ .../stateres/testdata/materialized_forks.json | 1010 +++++++++++++++++ internal/stateres/testdata/synapse_forks.json | 124 ++ internal/stateres/testdata/synapse_v12.json | 124 ++ internal/stateres/v2.go | 388 +++++++ 10 files changed, 3198 insertions(+) create mode 100644 internal/stateres/sparse.go create mode 100644 internal/stateres/sparse_test.go create mode 100644 internal/stateres/stateres.go create mode 100644 internal/stateres/stateres_test.go create mode 100644 internal/stateres/synapse_fixture_test.go create mode 100644 internal/stateres/testdata/generate_v12.py create mode 100644 internal/stateres/testdata/materialized_forks.json create mode 100644 internal/stateres/testdata/synapse_forks.json create mode 100644 internal/stateres/testdata/synapse_v12.json create mode 100644 internal/stateres/v2.go diff --git a/internal/stateres/sparse.go b/internal/stateres/sparse.go new file mode 100644 index 0000000..194b9b5 --- /dev/null +++ b/internal/stateres/sparse.go @@ -0,0 +1,146 @@ +package stateres + +import ( + "fmt" + "maps" + "slices" + + "github.com/matrix-org/gomatrixserverlib" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" +) + +// SparseInput holds only the differing tuples and graph candidates. Lookup +// reads tuples from the first input, including an empty ID for an absent tuple. +// Base holds tuples of the first input already read, the same way, so they are +// not looked up again. Conflicts retains absences, so a tuple present in only +// some inputs conflicts. +type SparseInput struct { + Conflicts map[types.StateTup][]id.EventID + AuthDifference []id.EventID + ConflictedSubgraph []id.EventID + Lookup func([]types.StateTup) (types.StateMap, error) + Base types.StateMap +} + +// ResolveSparse returns a delta against the first input. Common state is read +// on demand and is never enumerated or copied into the result. Room versions +// before state resolution v2 return ErrUnsupportedAlgorithm. Inconsistent inputs +// are errors: a conflicted event that getEvent cannot find or that is not the +// event for its tuple, and any event of the full conflicted set from another +// room version. An auth difference or subgraph event that getEvent cannot find +// is skipped. ResolveSparse never changes the events supplied by getEvent. +func ResolveSparse(roomVersion string, input SparseInput, getEvent GetEventFunc) (types.StateMap, error) { + if err := CheckAlgorithm(roomVersion); err != nil { + return nil, err + } + if len(input.Conflicts) == 0 { + return types.StateMap{}, nil + } + base := maps.Clone(input.Base) + if base == nil { + base = make(types.StateMap) + } + lookup := func(tup types.StateTup) (id.EventID, error) { + if eventID, ok := base[tup]; ok { + return eventID, nil + } + values, err := input.Lookup([]types.StateTup{tup}) + if err != nil { + return "", err + } + base[tup] = values[tup] // Cache absence too. + return base[tup], nil + } + common := func(tup types.StateTup) (id.EventID, error) { + if _, conflicted := input.Conflicts[tup]; conflicted { + return "", nil + } + return lookup(tup) + } + r := newResolver(roomVersion, getEvent) + r.partial = make(types.StateMap) + // v2.1 starts iterative authorization from an empty state. + if !util.RoomVersionHas(roomVersion, func(impl gomatrixserverlib.IRoomVersion) bool { + return impl.StateResAlgorithm() == gomatrixserverlib.StateResV2_1 + }) { + r.common = common + } + full := make(map[id.EventID]*types.Event) + for tup, ids := range input.Conflicts { + for _, eventID := range ids { + if eventID == "" { + continue + } + ev, err := r.events.get(eventID) + if err != nil { + return nil, err + } + if ev == nil || ev.StateKey == nil || ev.StateTup() != tup { + return nil, fmt.Errorf("missing or mismatched conflicted event %s for %v", eventID, tup) + } + if err := r.checkRoomVersion(ev); err != nil { + return nil, err + } + full[eventID] = ev + } + } + inSubgraph := make(map[id.EventID]bool, len(input.ConflictedSubgraph)) + for _, eventID := range input.ConflictedSubgraph { + inSubgraph[eventID] = true + } + for _, eventID := range slices.Concat(input.AuthDifference, input.ConflictedSubgraph) { + if _, loaded := full[eventID]; loaded { + continue + } + ev, err := r.events.get(eventID) + if err != nil { + return nil, err + } + if ev == nil || ev.StateKey == nil { + continue + } + if err := r.checkRoomVersion(ev); err != nil { + return nil, err + } + // The strict auth difference excludes exact common state events, + // matching the inclusive-root convention used by Synapse. + // v2.1 must include common events lying in the conflicted subgraph. + if !inSubgraph[eventID] { + commonID, err := common(ev.StateTup()) + if err != nil { + return nil, err + } + if commonID == eventID { + continue + } + } + full[eventID] = ev + } + if err := r.run(full); err != nil { + return nil, err + } + // Only conflicts and tuples touched by candidates can differ. Common state + // wins at the end, even if a candidate temporarily replaced it for auth. + for tup := range input.Conflicts { + if _, touched := r.partial[tup]; !touched { + r.partial[tup] = "" + } + } + delta := make(types.StateMap) + for tup, resolvedID := range r.partial { + baseID, err := lookup(tup) + if err != nil { + return nil, err + } + if _, conflicted := input.Conflicts[tup]; !conflicted && baseID != "" { + resolvedID = baseID + } + if baseID != resolvedID { + delta[tup] = resolvedID + } + } + return delta, nil +} diff --git a/internal/stateres/sparse_test.go b/internal/stateres/sparse_test.go new file mode 100644 index 0000000..cdc65c3 --- /dev/null +++ b/internal/stateres/sparse_test.go @@ -0,0 +1,285 @@ +package stateres + +import ( + "errors" + "fmt" + "maps" + "math/rand/v2" + "slices" + "testing" + + "github.com/matrix-org/gomatrixserverlib" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" +) + +// sparseInput derives what production reads from stored state and the auth graph, with the first +// state as the base. +func (r *testRoom) sparseInput(states []types.StateMap) SparseInput { + _, conflicts := separate(states) + chains := make([]map[id.EventID]struct{}, len(states)) + for i, state := range states { + chains[i] = make(map[id.EventID]struct{}) + for _, eventID := range state { + maps.Copy(chains[i], r.authChain(eventID)) + } + } + input := SparseInput{ + Conflicts: conflicts, + AuthDifference: authDifference(chains), + Lookup: func(tups []types.StateTup) (types.StateMap, error) { + values := make(types.StateMap, len(tups)) + for _, tup := range tups { + values[tup] = states[0][tup] + } + return values, nil + }, + } + if util.RoomVersionHas(r.roomVersion, func(impl gomatrixserverlib.IRoomVersion) bool { + return impl.StateResAlgorithm() == gomatrixserverlib.StateResV2_1 + }) { + input.ConflictedSubgraph = r.conflictedSubgraph(conflicts) + } + return input +} + +// conflictedSubgraph is every event that is both an auth ancestor and an auth descendant of +// conflicted events, counting the conflicted events themselves. +func (r *testRoom) conflictedSubgraph(conflicts map[types.StateTup][]id.EventID) []id.EventID { + var conflicted []id.EventID + for _, eventIDs := range conflicts { + conflicted = append(conflicted, eventIDs...) + } + ancestors := make(map[id.EventID]struct{}) + for _, eventID := range conflicted { + ancestors[eventID] = struct{}{} + maps.Copy(ancestors, r.authChain(eventID)) + } + var subgraph []id.EventID + for eventID := range ancestors { + chain := r.authChain(eventID) + if slices.ContainsFunc(conflicted, func(conflictedID id.EventID) bool { + _, descends := chain[conflictedID] + return descends || conflictedID == eventID + }) { + subgraph = append(subgraph, eventID) + } + } + return subgraph +} + +// resolveWith resolves the states with ResolveSparse and returns the whole resolved state rather +// than the delta against the first. +func (r *testRoom) resolveWith(getEvent GetEventFunc, states ...types.StateMap) (types.StateMap, error) { + delta, err := ResolveSparse(r.roomVersion, r.sparseInput(states), getEvent) + if err != nil { + return nil, err + } + return applyDelta(states[0], delta), nil +} + +func (r *testRoom) resolve(states ...types.StateMap) types.StateMap { + resolved, err := r.resolveWith(r.getEvent, states...) + require.NoError(r.t, err) + return resolved +} + +func applyDelta(base, delta types.StateMap) types.StateMap { + resolved := maps.Clone(base) + for tup, eventID := range delta { + if eventID == "" { + delete(resolved, tup) + } else { + resolved[tup] = eventID + } + } + return resolved +} + +// separate returns the tuples that have the same event in every state, and for every other tuple +// the distinct events found for it. A tuple missing from any state is conflicted, even when every +// state that has it agrees. +func separate(states []types.StateMap) (types.StateMap, map[types.StateTup][]id.EventID) { + unconflicted := make(types.StateMap) + conflicted := make(map[types.StateTup][]id.EventID) + for _, state := range states { + for tup := range state { + if _, done := unconflicted[tup]; done { + continue + } + if _, done := conflicted[tup]; done { + continue + } + var eventIDs []id.EventID + missing := false + for _, other := range states { + eventID, ok := other[tup] + if !ok { + missing = true + } else if !slices.Contains(eventIDs, eventID) { + eventIDs = append(eventIDs, eventID) + } + } + if !missing && len(eventIDs) == 1 { + unconflicted[tup] = eventIDs[0] + } else { + conflicted[tup] = eventIDs + } + } + } + return unconflicted, conflicted +} + +// authDifference is the union of the auth chains minus their intersection. +func authDifference(chains []map[id.EventID]struct{}) []id.EventID { + counts := make(map[id.EventID]int) + for _, chain := range chains { + for eventID := range chain { + counts[eventID]++ + } + } + var difference []id.EventID + for eventID, count := range counts { + if count < len(chains) { + difference = append(difference, eventID) + } + } + return difference +} + +func TestSeparate(t *testing.T) { + a := types.StateMap{createTup: "$create", memberTup(bob): "$bob", topicTup: "$t1", nameTup: "$name"} + b := types.StateMap{createTup: "$create", memberTup(bob): "$bob", topicTup: "$t2"} + c := types.StateMap{createTup: "$create", memberTup(bob): "$bob", topicTup: "$t1", memberTup(evelyn): "$evelyn"} + + unconflicted, conflicted := separate([]types.StateMap{a, b, c}) + assert.Equal(t, types.StateMap{createTup: "$create", memberTup(bob): "$bob"}, unconflicted) + assert.Equal(t, map[types.StateTup][]id.EventID{ + topicTup: {"$t1", "$t2"}, + nameTup: {"$name"}, + memberTup(evelyn): {"$evelyn"}, + }, conflicted) +} + +// v2.1 authorizes from an empty state, so the common $PL1 only builds the mainline if it is +// authorized again, as part of the conflicted subgraph between $MB2 and $MB3. That mainline puts +// $T1, sent under $PL0, before $T2, sent under $PL1, despite $T1's later timestamp. +func TestResolveV12ConflictedSubgraph(t *testing.T) { + r := newTestRoom(t) + r.roomVersion = "12" + createID := id.EventID("$" + testRoomID[1:]) + r.add(createID, alice, event.StateCreate, new(""), `{"room_version":"12"}`) + r.add("$MA", alice, event.StateMember, new(alice.String()), join, createID) + r.add("$PL0", alice, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{bob: 100}), "$MA") + r.add("$JR", alice, event.StateJoinRules, new(""), joinRule("public"), "$PL0") + r.add("$MB1", bob, event.StateMember, new(bob.String()), join, "$JR") + r.add("$MB2", bob, event.StateMember, new(bob.String()), `{"membership":"join","displayname":"2"}`, "$MB1") + r.add("$T1", alice, event.StateTopic, new(""), topic("T1"), "$MB2") + r.add("$PL1", bob, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{bob: 100, charlie: 50}), "$T1") + r.add("$MB3", bob, event.StateMember, new(bob.String()), `{"membership":"join","displayname":"3"}`, "$PL1") + r.add("$T2", alice, event.StateTopic, new(""), topic("T2"), "$MB3") + r.events["$T1"].Timestamp = r.events["$T2"].Timestamp + 1 + + states := []types.StateMap{r.stateAfter["$PL1"], r.stateAfter["$T2"]} + input := r.sparseInput(states) + assert.Equal(t, []id.EventID{"$PL1"}, input.AuthDifference) + assert.ElementsMatch(t, []id.EventID{"$MB2", "$T1", "$PL1", "$MB3", "$T2"}, input.ConflictedSubgraph) + + resolved := r.resolve(states...) + assert.Equal(t, id.EventID("$T2"), resolved[topicTup]) + assert.Equal(t, id.EventID("$MB3"), resolved[memberTup(bob)]) +} + +// MSC4297: the original join rule is in the auth chain of every input, so outside the auth +// difference, and lies between the conflicting power levels $PL0 and $PL2, through Bob's join. +// Alice's demotion of Dave sorts before Dave's join rule changes by her creator power and rejects +// both, so only authorizing the original join rule again, as part of the conflicted subgraph, leaves +// the room a join rule. +func TestResolveV12SubgraphBeyondTheAuthDifference(t *testing.T) { + r := newTestRoom(t) + r.roomVersion = "12" + createID := id.EventID("$" + testRoomID[1:]) + r.add(createID, alice, event.StateCreate, new(""), `{"room_version":"12"}`) + r.add("$MA", alice, event.StateMember, new(alice.String()), join, createID) + r.add("$PL0", alice, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{bob: 100, dave: 100}), "$MA") + r.add("$JR", alice, event.StateJoinRules, new(""), joinRule("public"), "$PL0") + r.add("$MB", bob, event.StateMember, new(bob.String()), join, "$JR") + r.add("$MD", dave, event.StateMember, new(dave.String()), join, "$MB") + r.add("$JR1", dave, event.StateJoinRules, new(""), joinRule("public"), "$MD") + r.add("$JR2", dave, event.StateJoinRules, new(""), joinRule("invite"), "$MD") + r.add("$PL1", alice, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{bob: 100, dave: 0}), "$JR2") + r.add("$PL2", bob, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{bob: 100, charlie: 50, dave: 0}), "$PL1") + + states := []types.StateMap{r.stateAfter["$JR1"], r.stateAfter["$PL2"]} + input := r.sparseInput(states) + assert.Equal(t, map[types.StateTup][]id.EventID{joinRulesTup: {"$JR1", "$JR2"}, powerLevelsTup: {"$PL0", "$PL2"}}, input.Conflicts) + assert.NotContains(t, input.AuthDifference, id.EventID("$JR")) + assert.Contains(t, input.ConflictedSubgraph, id.EventID("$JR")) + + resolved := r.resolve(states...) + assert.Equal(t, id.EventID("$JR"), resolved[joinRulesTup]) + assert.Equal(t, id.EventID("$PL2"), resolved[powerLevelsTup]) +} + +// Base entries, an absence included, are read instead of looking the tuples up again. +func TestResolveSparseReadsBaseBeforeLookup(t *testing.T) { + r := newSynapseRoom(t) + r.add("$T", alice, event.StateTopic, new(""), topic("T"), "$START") + input := r.sparseInput([]types.StateMap{r.stateAfter["$START"], r.stateAfter["$T"]}) + lookup := input.Lookup + lookedUp := make(types.StateMap) + input.Lookup = func(tups []types.StateTup) (types.StateMap, error) { + values, err := lookup(tups) + maps.Copy(lookedUp, values) + return values, err + } + delta, err := ResolveSparse(testRoomVersion, input, r.getEvent) + require.NoError(t, err) + require.Equal(t, types.StateMap{topicTup: "$T"}, delta) + require.Contains(t, lookedUp, topicTup) + require.Empty(t, lookedUp[topicTup]) + + input.Base = maps.Clone(lookedUp) + input.Lookup = func([]types.StateTup) (types.StateMap, error) { + return nil, errors.New("unexpected lookup") + } + delta, err = ResolveSparse(testRoomVersion, input, r.getEvent) + require.NoError(t, err) + assert.Equal(t, types.StateMap{topicTup: "$T"}, delta) + assert.Equal(t, lookedUp, input.Base) +} + +func TestSparseResolutionDoesNotLoadUnrelated100kMembers(t *testing.T) { + r := newTestRoom(t) + g := &forkGenerator{room: r, rng: rand.New(rand.NewPCG(1, 2))} + baseID := g.base() + left := r.add("$left", alice, event.StateTopic, new(""), topic("left"), baseID) + right := r.add("$right", alice, event.StateTopic, new(""), topic("right"), baseID) + base := maps.Clone(r.stateAfter[left.ID]) + for i := range 100000 { + base[memberTup(id.UserID(fmt.Sprintf("@unrelated%d:example.com", i)))] = id.EventID(fmt.Sprintf("$unrelated%d", i)) + } + loaded, lookups := 0, 0 + delta, err := ResolveSparse("11", SparseInput{Conflicts: map[types.StateTup][]id.EventID{topicTup: {left.ID, right.ID}}, Lookup: func(keys []types.StateTup) (types.StateMap, error) { + out := make(types.StateMap) + for _, tup := range keys { + lookups++ + require.NotContains(t, tup.StateKey, "unrelated") + out[tup] = base[tup] + } + return out, nil + }}, func(eventID id.EventID) (*types.Event, error) { + loaded++ + require.NotContains(t, eventID.String(), "unrelated") + return r.getEvent(eventID) + }) + require.NoError(t, err) + require.Equal(t, types.StateMap{topicTup: right.ID}, delta) + require.Less(t, loaded, 10) + require.Less(t, lookups, 10) +} diff --git a/internal/stateres/stateres.go b/internal/stateres/stateres.go new file mode 100644 index 0000000..e82b7d7 --- /dev/null +++ b/internal/stateres/stateres.go @@ -0,0 +1,62 @@ +// Package stateres resolves room state across several inputs with the Matrix state resolution +// algorithm, version 2 or 2.1 as the room version gives it: +// https://spec.matrix.org/v1.14/rooms/v2/#state-resolution +package stateres + +import ( + "errors" + "fmt" + + "github.com/matrix-org/gomatrixserverlib" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +var ErrUnsupportedAlgorithm = errors.New("unsupported state resolution algorithm") + +// Version identifies the results of this implementation, which are stored. Bump it with any change +// that can change a result, so results stored before it are not used. +const Version = 2 + +// CheckAlgorithm fails with ErrUnsupportedAlgorithm for a room version resolving state with an +// algorithm before state resolution v2. +func CheckAlgorithm(roomVersion string) error { + ver, err := gomatrixserverlib.GetRoomVersion(gomatrixserverlib.RoomVersion(roomVersion)) + if err != nil { + return err + } + if ver.StateResAlgorithm() != gomatrixserverlib.StateResV2 && ver.StateResAlgorithm() != gomatrixserverlib.StateResV2_1 { + return fmt.Errorf("%w: room version %s", ErrUnsupportedAlgorithm, roomVersion) + } + return nil +} + +// GetEventFunc must return nil, nil for unknown events. +type GetEventFunc func(id.EventID) (*types.Event, error) + +// checkRoomVersion keeps an event authorized under another room version from being counted as +// rejected, which is how gomatrixserverlib.Allowed would report it. +func (r *resolver) checkRoomVersion(ev *types.Event) error { + if ev.RoomVersion != r.roomVersion { + return fmt.Errorf("event %s has room version %q, resolving for %q", ev.ID, ev.RoomVersion, r.roomVersion) + } + return nil +} + +type eventCache struct { + getEvent GetEventFunc + events map[id.EventID]*types.Event +} + +func (c *eventCache) get(eventID id.EventID) (*types.Event, error) { + if ev, ok := c.events[eventID]; ok { + return ev, nil + } + ev, err := c.getEvent(eventID) + if err != nil { + return nil, fmt.Errorf("failed to get event %s: %w", eventID, err) + } + c.events[eventID] = ev + return ev, nil +} diff --git a/internal/stateres/stateres_test.go b/internal/stateres/stateres_test.go new file mode 100644 index 0000000..7d57405 --- /dev/null +++ b/internal/stateres/stateres_test.go @@ -0,0 +1,863 @@ +package stateres + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "math/rand/v2" + "os" + "slices" + "testing" + + "github.com/matrix-org/gomatrixserverlib" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" +) + +const testRoomID id.RoomID = "!room:example.com" + +const testRoomVersion = "11" + +const ( + alice id.UserID = "@alice:example.com" + bob id.UserID = "@bob:example.com" + charlie id.UserID = "@charlie:example.com" + dave id.UserID = "@dave:example.com" + evelyn id.UserID = "@evelyn:example.com" + zara id.UserID = "@zara:example.com" +) + +const ( + join = `{"membership":"join"}` + leave = `{"membership":"leave"}` + ban = `{"membership":"ban"}` + message = `{"msgtype":"m.text","body":"hi"}` +) + +var ( + topicTup = types.StateTup{Type: event.StateTopic} + nameTup = types.StateTup{Type: event.StateRoomName} + joinRulesTup = types.StateTup{Type: event.StateJoinRules} +) + +var memberTup = types.MemberStateTup + +func mustJSON(v any) string { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return string(b) +} + +func powerLevels(users map[id.UserID]int, extra ...map[string]any) string { + content := map[string]any{"users": users} + for _, fields := range extra { + maps.Copy(content, fields) + } + return mustJSON(content) +} + +func joinRule(rule string) string { + return mustJSON(map[string]string{"join_rule": rule}) +} + +func topic(text string) string { + return mustJSON(map[string]string{"topic": text}) +} + +// testRoom builds a room DAG in memory. Each event's state before is its prev event's state after, +// resolved when there are several, and its auth events are picked from that state the way the +// auth events selection algorithm does. +type testRoom struct { + t *testing.T + roomVersion string + events map[id.EventID]*types.Event + stateAfter map[id.EventID]types.StateMap + authChains map[id.EventID]map[id.EventID]struct{} + timestamp int64 +} + +func newTestRoom(t *testing.T) *testRoom { + return &testRoom{ + t: t, + roomVersion: testRoomVersion, + events: make(map[id.EventID]*types.Event), + stateAfter: make(map[id.EventID]types.StateMap), + authChains: make(map[id.EventID]map[id.EventID]struct{}), + } +} + +func (r *testRoom) getEvent(eventID id.EventID) (*types.Event, error) { + return r.events[eventID], nil +} + +// authChain is the event's auth chain, without the event itself. An unknown event has none. +func (r *testRoom) authChain(eventID id.EventID) map[id.EventID]struct{} { + if chain, ok := r.authChains[eventID]; ok { + return chain + } + chain := make(map[id.EventID]struct{}) + if ev := r.events[eventID]; ev != nil { + for _, authEventID := range ev.AuthEventIDs { + chain[authEventID] = struct{}{} + maps.Copy(chain, r.authChain(authEventID)) + } + } + r.authChains[eventID] = chain + return chain +} + +func (r *testRoom) stateMap(eventIDs []id.EventID) types.StateMap { + state := make(types.StateMap, len(eventIDs)) + for _, eventID := range eventIDs { + state[r.events[eventID].StateTup()] = eventID + } + return state +} + +func (r *testRoom) stateBefore(prevs []id.EventID) types.StateMap { + switch len(prevs) { + case 0: + return types.StateMap{} + case 1: + return maps.Clone(r.stateAfter[prevs[0]]) + } + states := make([]types.StateMap, len(prevs)) + for i, prev := range prevs { + states[i] = r.stateAfter[prev] + } + return r.resolve(states...) +} + +func (r *testRoom) newEvent( + eventID id.EventID, + sender id.UserID, + evType event.Type, + stateKey *string, + content string, + prevs ...id.EventID, +) (*types.Event, types.StateMap) { + var depth int64 + for _, prev := range prevs { + depth = max(depth, r.events[prev].Depth) + } + r.timestamp++ + ev := &types.Event{ + PartialEvent: types.PartialEvent{ + RoomID: testRoomID, + Sender: sender, + Type: evType, + StateKey: stateKey, + Content: json.RawMessage(content), + Timestamp: r.timestamp, + }, + ID: eventID, + RoomVersion: r.roomVersion, + Depth: depth + 1, + PrevEventIDs: prevs, + } + before := r.stateBefore(prevs) + for _, tup := range gomatrixserverlib.StateNeededForAuth([]gomatrixserverlib.PDU{ev.PDU()}).Tuples() { + stateTup := types.StateTup{Type: event.NewEventType(tup.EventType), StateKey: tup.StateKey} + if stateTup == createTup && util.RoomVersionHas(ev.RoomVersion, gomatrixserverlib.IRoomVersion.DomainlessRoomIDs) { + continue + } + if authEventID, ok := before[stateTup]; ok { + ev.AuthEventIDs = append(ev.AuthEventIDs, authEventID) + } + } + return ev, before +} + +func (r *testRoom) store(ev *types.Event, before types.StateMap) { + require.NotContains(r.t, r.events, ev.ID) + after := before + if ev.StateKey != nil { + after[ev.StateTup()] = ev.ID + } + r.events[ev.ID] = ev + r.stateAfter[ev.ID] = after +} + +func (r *testRoom) add( + eventID id.EventID, + sender id.UserID, + evType event.Type, + stateKey *string, + content string, + prevs ...id.EventID, +) *types.Event { + ev, before := r.newEvent(eventID, sender, evType, stateKey, content, prevs...) + r.store(ev, before) + return ev +} + +func (r *testRoom) allowed(ev *types.Event, before types.StateMap) bool { + resolver := newResolver(r.roomVersion, r.getEvent) + resolver.partial = before + authErr, err := resolver.authorize(ev) + require.NoError(r.t, err) + return authErr == nil +} + +// requireChangedState asserts that the state at eventID, ignoring tuples that still have their +// value from $START, is exactly the expected events. +func (r *testRoom) requireChangedState(eventID id.EventID, expected ...id.EventID) { + r.t.Helper() + expectedState := make(types.StateMap, len(expected)) + for _, expectedID := range expected { + expectedState[r.events[expectedID].StateTup()] = expectedID + } + start := r.stateAfter["$START"] + changed := make(types.StateMap) + for tup, value := range r.stateAfter[eventID] { + if _, ok := expectedState[tup]; ok || start[tup] != value { + changed[tup] = value + } + } + require.Equal(r.t, expectedState, changed) +} + +// newSynapseRoom builds the starting graph of Synapse's state resolution v2 tests +// (tests/state/test_v2.py), which are the examples the algorithm was designed against. Events +// in cases built on it are added in the order Synapse's harness creates them, so the +// origin_server_ts tiebreaks match. +func newSynapseRoom(t *testing.T) *testRoom { + r := newTestRoom(t) + r.add("$CREATE", alice, event.StateCreate, new(""), `{"room_version":"11"}`) + r.add("$IMA", alice, event.StateMember, new(alice.String()), join, "$CREATE") + r.add("$IPOWER", alice, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100}), "$IMA") + r.add("$IJR", alice, event.StateJoinRules, new(""), joinRule("public"), "$IPOWER") + r.add("$IMB", bob, event.StateMember, new(bob.String()), join, "$IJR") + r.add("$IMC", charlie, event.StateMember, new(charlie.String()), join, "$IMB") + r.add("$IMZ", zara, event.StateMember, new(zara.String()), join, "$IMC") + r.add("$START", zara, event.EventMessage, nil, message, "$IMZ") + return r +} + +func TestResolveTrivialInputs(t *testing.T) { + delta, err := ResolveSparse(testRoomVersion, SparseInput{}, func(id.EventID) (*types.Event, error) { + return nil, errors.New("unexpected event lookup") + }) + require.NoError(t, err) + assert.NotNil(t, delta) + assert.Empty(t, delta) + + r := newSynapseRoom(t) + state := r.stateAfter["$START"] + assert.Equal(t, state, r.resolve(state)) +} + +func TestResolveUnsupportedRoomVersions(t *testing.T) { + r := newSynapseRoom(t) + state := r.stateAfter["$START"] + input := r.sparseInput([]types.StateMap{state, state}) + + _, err := ResolveSparse("1", input, r.getEvent) + require.ErrorIs(t, err, ErrUnsupportedAlgorithm) + + _, err = ResolveSparse("not-a-version", input, r.getEvent) + var unsupported gomatrixserverlib.UnsupportedRoomVersionError + require.ErrorAs(t, err, &unsupported) +} + +func TestResolveSynapseVectors(t *testing.T) { + // Power events are ordered by sender power level first: the ban is authorized before Bob's + // power levels event, which then fails because Bob is banned. + t.Run("ban_vs_pl", func(t *testing.T) { + r := newSynapseRoom(t) + r.add("$PA", alice, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 50}), "$START") + r.add("$MA", alice, event.StateMember, new(alice.String()), join, "$PA") + r.add("$MB", alice, event.StateMember, new(bob.String()), ban, "$MA") + r.add("$PB", bob, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 50}), "$PA") + r.add("$END", zara, event.EventMessage, nil, message, "$MB", "$PB") + r.requireChangedState("$END", "$PA", "$MA", "$MB") + }) + + // $PB is in neither state, only in the auth difference, and must be authorized before $PC + // for Charlie to hold the power to send it. + t.Run("offtopic_pl", func(t *testing.T) { + r := newSynapseRoom(t) + r.add("$PA", alice, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 50}), "$START") + r.add("$PB", bob, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 50, charlie: 50}), "$PA") + r.add("$PC", charlie, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 50, charlie: 0}), "$PB") + r.add("$END", zara, event.EventMessage, nil, message, "$PC", "$PA") + r.requireChangedState("$END", "$PC") + }) + + // Two branches set different power levels. Alice's $PA2 outranks Bob's $PB and demotes Bob, + // so Bob's topic from the losing branch is authorized against $PA2 and rejected. + t.Run("topic_basic", func(t *testing.T) { + r := newSynapseRoom(t) + r.add("$T1", alice, event.StateTopic, new(""), topic("T1"), "$START") + r.add("$PA1", alice, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 50}), "$T1") + r.add("$PB", bob, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 50}), "$PA1") + r.add("$T2", alice, event.StateTopic, new(""), topic("T2"), "$PA1") + r.add("$PA2", alice, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 0}), "$T2") + r.add("$T3", bob, event.StateTopic, new(""), topic("T3"), "$PB") + r.add("$END", zara, event.EventMessage, nil, message, "$PA2", "$T3") + r.requireChangedState("$END", "$PA2", "$T2") + }) + + // The ban is a power event applied before the topics, so Bob's $T2 fails and the topic + // resets to $T1. + t.Run("topic_reset", func(t *testing.T) { + r := newSynapseRoom(t) + r.add("$T1", alice, event.StateTopic, new(""), topic("T1"), "$START") + r.add("$PA", alice, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 50}), "$T1") + r.add("$T2", bob, event.StateTopic, new(""), topic("T2"), "$PA") + r.add("$MB", alice, event.StateMember, new(bob.String()), ban, "$T2") + r.add("$END", zara, event.EventMessage, nil, message, "$MB", "$T1") + r.requireChangedState("$END", "$T1", "$MB", "$PA") + }) + + t.Run("topic", func(t *testing.T) { + r := newSynapseRoom(t) + r.add("$T1", alice, event.StateTopic, new(""), topic("T1"), "$START") + r.add("$PA1", alice, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 50}), "$T1") + r.add("$PB", bob, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 50}), "$PA1") + r.add("$T2", alice, event.StateTopic, new(""), topic("T2"), "$PA1") + r.add("$PA2", alice, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 0}), "$T2") + r.add("$T3", bob, event.StateTopic, new(""), topic("T3"), "$PB") + r.add("$MZ1", zara, event.EventMessage, nil, message, "$PA2", "$T3") + r.add("$T4", alice, event.StateTopic, new(""), topic("T4"), "$MZ1") + r.add("$END", zara, event.EventMessage, nil, message, "$T4", "$MZ1") + r.requireChangedState("$END", "$T4", "$PA2") + }) + + // $T3 is based on a later mainline power levels event than $T4, so it is applied last and + // wins despite its earlier origin_server_ts. + t.Run("mainline_sort", func(t *testing.T) { + r := newSynapseRoom(t) + r.add("$T1", alice, event.StateTopic, new(""), topic("T1"), "$START") + r.add("$PA1", alice, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 50}), "$T1") + r.add("$PB", bob, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 50}), "$PA1") + r.add("$T2", alice, event.StateTopic, new(""), topic("T2"), "$PA1") + r.add("$PA2", alice, event.StatePowerLevels, new(""), powerLevels( + map[id.UserID]int{alice: 100, bob: 50}, + map[string]any{"events": map[string]int{event.StatePowerLevels.Type: 100}}, + ), "$T2") + r.add("$T3", bob, event.StateTopic, new(""), topic("T3"), "$PA2") + r.add("$T4", alice, event.StateTopic, new(""), topic("T4"), "$PB") + r.add("$END", zara, event.EventMessage, nil, message, "$T3", "$T4") + r.requireChangedState("$END", "$T3", "$PA2") + }) +} + +// A tuple present in one input and absent from another is conflicted, which gomatrixserverlib's +// flattened ResolveConflicts misses: it keeps Evelyn's join in both cases. +func TestResolveAbsenceConflict(t *testing.T) { + // Synapse's test_join_rule_evasion. + t.Run("join rule change rejects a join from the other branch", func(t *testing.T) { + r := newSynapseRoom(t) + r.add("$JR", alice, event.StateJoinRules, new(""), joinRule("private"), "$START") + r.add("$ME", evelyn, event.StateMember, new(evelyn.String()), join, "$START") + r.add("$END", zara, event.EventMessage, nil, message, "$JR", "$ME") + r.requireChangedState("$END", "$JR") + }) + + t.Run("join from one branch is kept when nothing forbids it", func(t *testing.T) { + r := newSynapseRoom(t) + r.add("$T", alice, event.StateTopic, new(""), topic("T"), "$START") + r.add("$ME", evelyn, event.StateMember, new(evelyn.String()), join, "$START") + r.add("$END", zara, event.EventMessage, nil, message, "$T", "$ME") + r.requireChangedState("$END", "$T", "$ME") + }) +} + +func TestResolveMembershipConflict(t *testing.T) { + t.Run("ban beats a later join", func(t *testing.T) { + r := newSynapseRoom(t) + r.add("$BAN", alice, event.StateMember, new(bob.String()), ban, "$START") + r.add("$RENAME", bob, event.StateMember, new(bob.String()), `{"membership":"join","displayname":"Bob"}`, "$START") + r.add("$END", zara, event.EventMessage, nil, message, "$BAN", "$RENAME") + r.requireChangedState("$END", "$BAN") + }) + + // The kick is a power event and is applied first, then Bob's join from the other branch is + // authorized as a rejoin, which only the public room allows. + for _, tc := range []struct { + rule string + expected id.EventID + }{ + {"public", "$RENAME"}, + {"invite", "$KICK"}, + } { + t.Run("kick against a later join in a "+tc.rule+" room", func(t *testing.T) { + r := newSynapseRoom(t) + r.add("$JR", alice, event.StateJoinRules, new(""), joinRule(tc.rule), "$START") + r.add("$KICK", alice, event.StateMember, new(bob.String()), leave, "$JR") + r.add("$RENAME", bob, event.StateMember, new(bob.String()), `{"membership":"join","displayname":"Bob"}`, "$JR") + r.add("$END", zara, event.EventMessage, nil, message, "$KICK", "$RENAME") + r.requireChangedState("$END", "$JR", tc.expected) + }) + } + + for _, later := range []id.EventID{"$LEAVE", "$RENAME"} { + t.Run("later of a leave and a join wins: "+later.String(), func(t *testing.T) { + r := newSynapseRoom(t) + addLeave := func() { + r.add("$LEAVE", bob, event.StateMember, new(bob.String()), leave, "$START") + } + addRename := func() { + r.add("$RENAME", bob, event.StateMember, new(bob.String()), `{"membership":"join","displayname":"Bob"}`, "$START") + } + if later == "$LEAVE" { + addRename() + addLeave() + } else { + addLeave() + addRename() + } + r.add("$END", zara, event.EventMessage, nil, message, "$LEAVE", "$RENAME") + r.requireChangedState("$END", later) + }) + } +} + +func TestResolveAuthDifferenceControlEvent(t *testing.T) { + r := newSynapseRoom(t) + r.add("$PA1", alice, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 50}), "$START") + r.add("$PA2", bob, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100, bob: 50, charlie: 50}), "$PA1") + r.add("$MSG", zara, event.EventMessage, nil, message, "$START") + input := r.sparseInput([]types.StateMap{r.stateAfter["$MSG"], r.stateAfter["$PA2"]}) + assert.Equal(t, map[types.StateTup][]id.EventID{powerLevelsTup: {"$IPOWER", "$PA2"}}, input.Conflicts) + + // $PA1 is in neither state, only in $PA2's auth chain. Bob's join is there too, but it is + // common state and stays the base of the resolution. + assert.ElementsMatch(t, []id.EventID{"$PA1", "$IMB"}, input.AuthDifference) + + // Bob can only send $PA2 under $PA1, so $PA2 wins only if $PA1 was authorized before it. + delta, err := ResolveSparse(testRoomVersion, input, r.getEvent) + require.NoError(t, err) + assert.Equal(t, types.StateMap{powerLevelsTup: "$PA2"}, delta) + + input.AuthDifference = []id.EventID{"$IMB"} + delta, err = ResolveSparse(testRoomVersion, input, r.getEvent) + require.NoError(t, err) + assert.Empty(t, delta) +} + +// $T1 was sent before the room had power levels, so it never reaches the mainline of the resolved +// $PL and sorts before $T2, though its timestamp is later: $T2 is applied last. +func TestResolveMainlineEventWithoutPowerLevelsAncestor(t *testing.T) { + r := newTestRoom(t) + r.add("$CREATE", alice, event.StateCreate, new(""), `{"room_version":"11"}`) + r.add("$IMA", alice, event.StateMember, new(alice.String()), join, "$CREATE") + r.add("$T1", alice, event.StateTopic, new(""), topic("T1"), "$IMA") + r.add("$PL", alice, event.StatePowerLevels, new(""), powerLevels(map[id.UserID]int{alice: 100}), "$IMA") + r.add("$T2", alice, event.StateTopic, new(""), topic("T2"), "$PL") + r.events["$T1"].Timestamp = r.events["$T2"].Timestamp + 1 + assert.NotContains(t, r.authChain("$T1"), id.EventID("$PL")) + + resolved := r.resolve(r.stateAfter["$T1"], r.stateAfter["$T2"]) + assert.Equal(t, id.EventID("$T2"), resolved[topicTup]) + assert.Equal(t, id.EventID("$PL"), resolved[powerLevelsTup]) +} + +// Evelyn's join is only known through her topic's auth events. The join rule change rejects the +// join itself, so the topic is authorized with the declared join as the fallback for Evelyn's +// membership, which is allowed for an outlier and not for a rejected event. +func TestResolveDeclaredAuthFallback(t *testing.T) { + for _, tc := range []struct { + name string + markJoin func(*types.Event) + expectedTopic id.EventID + }{ + {"outlier auth event is used", func(ev *types.Event) { ev.Outlier = true }, "$TE"}, + {"rejected auth event is not used", func(ev *types.Event) { ev.Rejected = true }, "$T0"}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newSynapseRoom(t) + r.add("$PTOPIC", alice, event.StatePowerLevels, new(""), powerLevels( + map[id.UserID]int{alice: 100}, + map[string]any{"events": map[string]int{event.StateTopic.Type: 0}}, + ), "$START") + r.add("$T0", alice, event.StateTopic, new(""), topic("T0"), "$PTOPIC") + r.add("$JE", evelyn, event.StateMember, new(evelyn.String()), join, "$T0") + r.add("$TE", evelyn, event.StateTopic, new(""), topic("TE"), "$JE") + r.add("$JR", alice, event.StateJoinRules, new(""), joinRule("invite"), "$T0") + tc.markJoin(r.events["$JE"]) + + withoutJoin := maps.Clone(r.stateAfter["$TE"]) + delete(withoutJoin, memberTup(evelyn)) + resolved := r.resolve(withoutJoin, r.stateAfter["$JR"]) + + assert.Equal(t, tc.expectedTopic, resolved[topicTup]) + assert.Equal(t, id.EventID("$JR"), resolved[joinRulesTup]) + assert.NotContains(t, resolved, memberTup(evelyn)) + }) + } +} + +// Bob's leave is rejected, so authorizing his topic falls back to the join the topic cites. +func TestResolveRejectedStateFallsBackToDeclaredAuthEvent(t *testing.T) { + r := newSynapseRoom(t) + r.add("$PTOPIC", alice, event.StatePowerLevels, new(""), powerLevels( + map[id.UserID]int{alice: 100}, + map[string]any{"events": map[string]int{event.StateTopic.Type: 0}}, + ), "$START") + r.add("$LEAVE", bob, event.StateMember, new(bob.String()), leave, "$PTOPIC") + r.add("$T0", alice, event.StateTopic, new(""), topic("T0"), "$LEAVE") + r.add("$TB", bob, event.StateTopic, new(""), topic("TB"), "$PTOPIC") + r.events["$LEAVE"].Rejected = true + + withLeave := maps.Clone(r.stateAfter["$TB"]) + withLeave[memberTup(bob)] = "$LEAVE" + resolved := r.resolve(withLeave, r.stateAfter["$T0"]) + assert.Equal(t, id.EventID("$TB"), resolved[topicTup]) +} + +func TestSenderPowerLevelCreator(t *testing.T) { + for _, tc := range []struct { + roomVersion string + creator id.UserID + }{ + {"10", bob}, + {"11", alice}, + } { + t.Run("room version "+tc.roomVersion, func(t *testing.T) { + create := &types.Event{ + PartialEvent: types.PartialEvent{ + RoomID: testRoomID, Sender: alice, Type: event.StateCreate, StateKey: new(""), + Content: json.RawMessage(mustJSON(map[string]string{"creator": bob.String(), "room_version": tc.roomVersion})), + }, + ID: "$create", + RoomVersion: tc.roomVersion, + } + events := map[id.EventID]*types.Event{create.ID: create} + resolver := newResolver(tc.roomVersion, func(eventID id.EventID) (*types.Event, error) { + return events[eventID], nil + }) + for _, sender := range []id.UserID{alice, bob} { + member := &types.Event{ + PartialEvent: types.PartialEvent{RoomID: testRoomID, Sender: sender, Type: event.StateMember, StateKey: new(sender.String()), Content: json.RawMessage(join)}, + ID: id.EventID("$join-" + sender.Localpart()), + RoomVersion: tc.roomVersion, + AuthEventIDs: []id.EventID{create.ID}, + } + level, err := resolver.senderPowerLevel(member) + require.NoError(t, err) + if sender == tc.creator { + assert.EqualValues(t, creatorPowerLevel, level, sender) + } else { + assert.Zero(t, level, sender) + } + } + }) + } +} + +func TestResolveInconsistentInputs(t *testing.T) { + r := newSynapseRoom(t) + r.add("$T", alice, event.StateTopic, new(""), topic("T"), "$START") + r.add("$MSG", zara, event.EventMessage, nil, message, "$START") + withTopic, withoutTopic := r.stateAfter["$T"], r.stateAfter["$MSG"] + + t.Run("missing auth difference event is skipped", func(t *testing.T) { + input := r.sparseInput([]types.StateMap{withoutTopic, withTopic}) + input.AuthDifference = append(input.AuthDifference, "$unknown") + delta, err := ResolveSparse(testRoomVersion, input, r.getEvent) + require.NoError(t, err) + assert.Equal(t, types.StateMap{topicTup: "$T"}, delta) + }) + + t.Run("missing conflicted state event is an error", func(t *testing.T) { + unknownTopic := maps.Clone(withoutTopic) + unknownTopic[topicTup] = "$unknown" + _, err := r.resolveWith(r.getEvent, withTopic, unknownTopic) + require.ErrorContains(t, err, "$unknown") + }) + + t.Run("conflicted state event listed under another tuple is an error", func(t *testing.T) { + misfiled := maps.Clone(withoutTopic) + misfiled[nameTup] = "$T" + _, err := r.resolveWith(r.getEvent, withTopic, misfiled) + require.ErrorContains(t, err, "$T") + }) + + t.Run("event from another room version is an error", func(t *testing.T) { + r := newSynapseRoom(t) + r.add("$T", alice, event.StateTopic, new(""), topic("T"), "$START") + r.events["$T"].RoomVersion = "10" + _, err := r.resolveWith(r.getEvent, r.stateAfter["$T"], r.stateAfter["$START"]) + require.ErrorContains(t, err, "room version") + }) + + t.Run("event lookup errors are returned", func(t *testing.T) { + failure := errors.New("database unavailable") + getEvent := func(eventID id.EventID) (*types.Event, error) { + if eventID == "$IPOWER" { + return nil, failure + } + return r.getEvent(eventID) + } + _, err := r.resolveWith(getEvent, withTopic, withoutTopic) + require.ErrorIs(t, err, failure) + }) +} + +var generatedUsers = []id.UserID{bob, charlie, dave} + +type eventKind int + +const ( + powerLevelsChange eventKind = iota + joinRulesChange + moderation + topicChange + nameChange + ownMembership +) + +type proposedEvent struct { + sender id.UserID + evType event.Type + stateKey *string + content string +} + +// forkGenerator builds random forks to compare against gomatrixserverlib. The pinned +// gomatrixserverlib departs from the spec in ways the Synapse vectors expose: it orders power +// events with lower sender power levels first, tiebreaks the mainline order on the number of +// steps taken to reach the mainline, and leaves everything but conflicted events out of the auth +// difference. The forks stay clear of those cases: only the room creator sends power events, only +// the first branch changes power levels, each branch changes a tuple at most once, and the shared +// history never changes the join rules, whose events do not cite their predecessors. Every tuple +// exists before the fork, so no input lacks a tuple another has. +type forkGenerator struct { + rng *rand.Rand + room *testRoom + nextID int +} + +func (g *forkGenerator) pick(values ...int) int { + return values[g.rng.IntN(len(values))] +} + +func (g *forkGenerator) user() id.UserID { + return generatedUsers[g.rng.IntN(len(generatedUsers))] +} + +func (g *forkGenerator) anyone() id.UserID { + if g.rng.IntN(len(generatedUsers)+1) == 0 { + return alice + } + return g.user() +} + +func (g *forkGenerator) powerLevels() string { + users := map[id.UserID]int{alice: 100} + for _, userID := range generatedUsers { + users[userID] = g.pick(0, 25, 50, 75) + } + return powerLevels(users, map[string]any{ + "state_default": g.pick(0, 50), + "kick": g.pick(25, 50, 75), + "ban": g.pick(25, 50, 75), + "events": map[string]int{ + event.StateTopic.Type: g.pick(0, 25, 50, 75), + event.StateRoomName.Type: g.pick(0, 25, 50, 75), + event.StatePowerLevels.Type: g.pick(50, 75, 100), + event.StateJoinRules.Type: g.pick(50, 75, 100), + }, + }) +} + +func (g *forkGenerator) base() id.EventID { + r := g.room + r.add("$create", alice, event.StateCreate, new(""), `{"room_version":"11"}`) + r.add("$alice", alice, event.StateMember, new(alice.String()), join, "$create") + r.add("$pl", alice, event.StatePowerLevels, new(""), g.powerLevels(), "$alice") + extremityID := r.add("$jr", alice, event.StateJoinRules, new(""), joinRule("public"), "$pl").ID + for _, userID := range generatedUsers { + extremityID = r.add(id.EventID("$join-"+userID.Localpart()), userID, event.StateMember, new(userID.String()), join, extremityID).ID + } + extremityID = r.add("$topic", alice, event.StateTopic, new(""), topic("base"), extremityID).ID + return r.add("$name", alice, event.StateRoomName, new(""), `{"name":"base"}`, extremityID).ID +} + +func (g *forkGenerator) propose(state types.StateMap, kinds []eventKind) proposedEvent { + switch kinds[g.rng.IntN(len(kinds))] { + case powerLevelsChange: + return proposedEvent{alice, event.StatePowerLevels, new(""), g.powerLevels()} + case joinRulesChange: + rule := []string{"public", "invite"}[g.rng.IntN(2)] + return proposedEvent{alice, event.StateJoinRules, new(""), joinRule(rule)} + case moderation: + membership := []string{leave, ban}[g.rng.IntN(2)] + return proposedEvent{alice, event.StateMember, new(g.user().String()), membership} + case topicChange: + return proposedEvent{g.anyone(), event.StateTopic, new(""), topic(fmt.Sprint(g.rng.Int()))} + case nameChange: + return proposedEvent{g.anyone(), event.StateRoomName, new(""), mustJSON(map[string]int{"name": g.rng.Int()})} + default: + userID := g.user() + if g.room.events[state[memberTup(userID)]].Membership() == event.MembershipJoin && g.rng.IntN(2) == 0 { + return proposedEvent{userID, event.StateMember, new(userID.String()), leave} + } + content := mustJSON(map[string]any{"membership": "join", "displayname": fmt.Sprint(g.rng.Int())}) + return proposedEvent{userID, event.StateMember, new(userID.String()), content} + } +} + +func (g *forkGenerator) tryAdd(extremityID id.EventID, proposed proposedEvent) (*types.Event, bool) { + g.nextID++ + ev, before := g.room.newEvent( + id.EventID(fmt.Sprintf("$e%d", g.nextID)), + proposed.sender, proposed.evType, proposed.stateKey, proposed.content, extremityID, + ) + if !g.room.allowed(ev, before) { + return nil, false + } + g.room.store(ev, before) + return ev, true +} + +func (g *forkGenerator) history(extremityID id.EventID, steps int) id.EventID { + kinds := []eventKind{powerLevelsChange, moderation, topicChange, nameChange, ownMembership} + for range steps { + if ev, ok := g.tryAdd(extremityID, g.propose(g.room.stateAfter[extremityID], kinds)); ok { + extremityID = ev.ID + } + } + return extremityID +} + +// fork grows the branches in turn, so their origin_server_ts values interleave. +func (g *forkGenerator) fork(from id.EventID, branches, steps int) []id.EventID { + extremities := make([]id.EventID, branches) + changed := make([]map[types.StateTup]struct{}, branches) + for i := range extremities { + extremities[i] = from + changed[i] = make(map[types.StateTup]struct{}) + } + for range steps { + for i, extremityID := range extremities { + kinds := []eventKind{joinRulesChange, moderation, topicChange, nameChange, ownMembership} + if i == 0 { + kinds = append(kinds, powerLevelsChange) + } + proposed := g.propose(g.room.stateAfter[extremityID], kinds) + tup := types.StateTup{Type: proposed.evType, StateKey: *proposed.stateKey} + if _, ok := changed[i][tup]; ok { + continue + } + if ev, ok := g.tryAdd(extremityID, proposed); ok { + changed[i][tup] = struct{}{} + extremities[i] = ev.ID + } + } + } + return extremities +} + +// testdata/materialized_forks.json holds what Synapse 1.161.0 resolves for each of the 1,000 +// generated forks. Their origin_server_ts values are distinct, so Synapse's own event IDs break no +// ties. +func TestResolveFrozenForks(t *testing.T) { + t.Skip("generated inputs depend on Synapse auth rules deferred until after state storage") + + raw, err := os.ReadFile("testdata/materialized_forks.json") + require.NoError(t, err) + var frozen map[string][][]id.EventID + require.NoError(t, json.Unmarshal(raw, &frozen)) + for _, variant := range []struct { + name string + branches int + historySteps int + }{ + {"two branches", 2, 0}, + {"three branches", 3, 0}, + {"two branches after shared history", 2, 8}, + {"three branches after shared history", 3, 8}, + } { + t.Run(variant.name, func(t *testing.T) { + rng := rand.New(rand.NewPCG(42, uint64(variant.branches*100+variant.historySteps))) + for iteration := range 250 { + g := &forkGenerator{rng: rng, room: newTestRoom(t)} + extremities := g.fork(g.history(g.base(), variant.historySteps), variant.branches, 2+rng.IntN(5)) + states := make([]types.StateMap, len(extremities)) + for i, extremityID := range extremities { + states[i] = g.room.stateAfter[extremityID] + } + _, conflicted := separate(states) + for tup := range conflicted { + for _, state := range states { + require.Contains(t, state, tup) + } + } + + resolved := g.room.resolve(states...) + require.Equal(t, frozen[variant.name][iteration], slices.Sorted(maps.Values(resolved))) + } + }) + } +} + +func TestResolveStoredRejection(t *testing.T) { + r := newSynapseRoom(t) + topicEv := r.add("$T", alice, event.StateTopic, new(""), topic("T"), "$START") + r.add("$MSG", zara, event.EventMessage, nil, message, "$START") + states := []types.StateMap{r.stateAfter["$T"], r.stateAfter["$MSG"]} + + // The topic passes auth, but its stored rejection must prevent selection. + require.Equal(t, topicEv.ID, r.resolve(states...)[topicTup]) + topicEv.Rejected = true + + for range 2 { + assert.NotContains(t, r.resolve(states...), topicTup) + assert.True(t, topicEv.Rejected) + } +} + +func TestResolveRejectedPowerCannotAuthorizeLaterCandidate(t *testing.T) { + for _, source := range []string{"conflicted state", "auth difference"} { + t.Run(source, func(t *testing.T) { + r := newSynapseRoom(t) + power := r.add("$P", alice, event.StatePowerLevels, new(""), + powerLevels(map[id.UserID]int{alice: 100, bob: 50}), "$START") + var candidate *types.Event + if source == "conflicted state" { + candidate = r.add("$T", bob, event.StateTopic, new(""), topic("T"), "$P") + } else { + candidate = r.add("$P2", bob, event.StatePowerLevels, new(""), powerLevels( + map[id.UserID]int{alice: 100, bob: 50, charlie: 25}, + ), "$P") + } + r.add("$MSG", zara, event.EventMessage, nil, message, "$START") + states := []types.StateMap{r.stateAfter[candidate.ID], r.stateAfter["$MSG"]} + + require.Equal(t, candidate.ID, r.resolve(states...)[candidate.StateTup()]) + power.Rejected = true + + resolved := r.resolve(states...) + assert.Equal(t, id.EventID("$IPOWER"), resolved[powerLevelsTup]) + // Bob has no power under $IPOWER, so neither his topic nor $P2 can pass auth. + assert.NotContains(t, resolved, topicTup) + assert.True(t, power.Rejected) + }) + } +} + +func TestResolveSoftFailedPowerStillParticipates(t *testing.T) { + r := newSynapseRoom(t) + power := r.add("$P", alice, event.StatePowerLevels, new(""), + powerLevels(map[id.UserID]int{alice: 100, bob: 50}), "$START") + topicEv := r.add("$T", bob, event.StateTopic, new(""), topic("T"), "$P") + r.add("$MSG", zara, event.EventMessage, nil, message, "$START") + power.SoftFailed = true + + resolved := r.resolve(r.stateAfter[topicEv.ID], r.stateAfter["$MSG"]) + assert.Equal(t, power.ID, resolved[powerLevelsTup]) + assert.Equal(t, topicEv.ID, resolved[topicTup]) + assert.True(t, power.SoftFailed) + assert.False(t, power.Rejected) +} diff --git a/internal/stateres/synapse_fixture_test.go b/internal/stateres/synapse_fixture_test.go new file mode 100644 index 0000000..3ad4163 --- /dev/null +++ b/internal/stateres/synapse_fixture_test.go @@ -0,0 +1,113 @@ +package stateres + +import ( + "cmp" + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +// fixtureEvent decodes an event of a Synapse fixture, written as +// [event_id, type, state_key, sender, content, auth_events, origin_server_ts, rejected?]. +type fixtureEvent struct { + *types.Event +} + +func (e *fixtureEvent) UnmarshalJSON(b []byte) error { + var fields []json.RawMessage + if err := json.Unmarshal(b, &fields); err != nil { + return err + } + ev := &types.Event{PartialEvent: types.PartialEvent{StateKey: new(string)}} + var evType string + targets := []any{&ev.ID, &evType, ev.StateKey, &ev.Sender, &ev.Content, &ev.AuthEventIDs, &ev.Timestamp, &ev.Rejected} + if len(fields) < len(targets)-1 || len(fields) > len(targets) { + return fmt.Errorf("fixture event has %d fields", len(fields)) + } + for i, field := range fields { + if err := json.Unmarshal(field, targets[i]); err != nil { + return fmt.Errorf("fixture event field %d: %w", i, err) + } + } + ev.Type = event.NewEventType(evType) + e.Event = ev + return nil +} + +type synapseFork struct { + Name string `json:"name"` + RoomID id.RoomID `json:"room_id"` + Tags []string `json:"tags"` + Events []fixtureEvent `json:"events"` + States [][]id.EventID `json:"states"` + Resolved []id.EventID `json:"resolved"` + roomVersion string +} + +// loadForks reads the forks of a Synapse fixture as events of the room version. A fork without a +// room ID is in testRoomID. +func loadForks(t *testing.T, path, roomVersion string) []synapseFork { + b, err := os.ReadFile(path) + require.NoError(t, err) + var fixture struct { + Forks []synapseFork `json:"forks"` + } + require.NoError(t, json.Unmarshal(b, &fixture)) + for i := range fixture.Forks { + fork := &fixture.Forks[i] + fork.roomVersion = roomVersion + for _, ev := range fork.Events { + ev.RoomID, ev.RoomVersion = cmp.Or(fork.RoomID, testRoomID), roomVersion + } + } + return fixture.Forks +} + +func (f synapseFork) requireResolved(t *testing.T) { + r := newTestRoom(t) + r.roomVersion = f.roomVersion + for _, ev := range f.Events { + r.events[ev.ID] = ev.Event + } + states := make([]types.StateMap, len(f.States)) + for i, eventIDs := range f.States { + states[i] = r.stateMap(eventIDs) + } + assert.Equal(t, r.stateMap(f.Resolved), r.resolve(states...)) +} + +// testdata/synapse_forks.json holds random forks with origin_server_ts values between 0 and 5 and +// power events from any member, some with an uncited conflicted power event marked rejected in +// one input. Each expected result is what Synapse 1.161.0 resolves, run as room version 2 so it +// keeps the fixture's event IDs and their tiebreaks. Forks where a leave after a leave changes the +// result were left out when the fixture was generated. A tiebreak tag means inverting that +// comparison changes the fork's result. +func TestResolveSynapseFixture(t *testing.T) { + tagged := make(map[string]int) + for _, fork := range loadForks(t, "testdata/synapse_forks.json", testRoomVersion) { + for _, tag := range fork.Tags { + tagged[tag]++ + } + t.Run(fork.Name, fork.requireResolved) + } + + for _, tag := range []string{"power_tiebreak", "timestamp_tiebreak", "event_id_tiebreak", "absence_conflict", "rejected_power_event"} { + assert.Positive(t, tagged[tag], tag) + } +} + +// testdata/synapse_v12.json holds the same forks converted to room version 12 by generate_v12.py, +// with what Synapse 1.161.0 resolves for them. +func TestResolveV12SynapseFixture(t *testing.T) { + for _, fork := range loadForks(t, "testdata/synapse_v12.json", "12") { + t.Run(fork.Name, fork.requireResolved) + } +} diff --git a/internal/stateres/testdata/generate_v12.py b/internal/stateres/testdata/generate_v12.py new file mode 100644 index 0000000..4b248d5 --- /dev/null +++ b/internal/stateres/testdata/generate_v12.py @@ -0,0 +1,83 @@ +"""Regenerate v12 cases with Synapse 1.161.0 (run in its Python environment). + +Converts the existing small fork corpus to v12: new event IDs, implicit create, +creator-free power levels, and exact auth difference plus conflicted subgraph +from independent graph walks. No server or database is required. Events are +written as rows in synapse_forks.json's format, under a fork-level room ID. +""" +import asyncio +import copy +import json +from pathlib import Path + +import synapse.server # noqa: F401 (initialize imports) +from synapse.api.room_versions import RoomVersions +from synapse.events import make_event_from_dict +from synapse.state import v2 +from synapse.storage.databases.main.event_federation import StateDifference + + +class Clock: + async def sleep(self, *args): + pass + + +class Store: + def __init__(self, events): + self.events = events + + async def get_events(self, ids, allow_rejected=False): + return {i: self.events[i] for i in ids if i in self.events and + (allow_rejected or self.events[i].rejected_reason is None)} + + def chain(self, roots): + seen, stack = set(), list(roots) + while stack: + at = stack.pop() + if at in seen: + continue + seen.add(at) + stack.extend(self.events[at].auth_event_ids()) + return seen + + async def get_auth_chain_difference(self, room_id, states, conflicts, additional): + chains = [self.chain(s) for s in states] + difference = set.union(*chains) - set.intersection(*chains) + backward = self.chain(conflicts) + subgraph = {at for at in backward if self.chain([at]).intersection(conflicts)} + return StateDifference(auth_difference=difference, conflicted_subgraph=subgraph) + + +root = Path(__file__).parent +out = [] +for case in json.loads((root / 'synapse_forks.json').read_text())['forks']: + events, mapped, room_id = {}, {}, None + rows = [] + for original in case['events']: + alias, typ, state_key, sender, content, auth, ts, *rejected = copy.deepcopy(original) + if typ == 'm.room.create': + content['room_version'] = '12' + if typ == 'm.room.power_levels': + content.get('users', {}).pop('@a:x', None) + data = dict(type=typ, state_key=state_key, sender=sender, content=content, + auth_events=[mapped[a] for a in auth if a != '$create'], + prev_events=[mapped['$create']] if alias == '$alice' else [], + origin_server_ts=ts, depth=len(events), hashes={'sha256': alias}) + if room_id is not None: + data['room_id'] = room_id + ev = make_event_from_dict(data, RoomVersions.V12, + rejected_reason='rejected' if rejected and rejected[0] else None) + if room_id is None: + room_id = ev.room_id + mapped[alias] = ev.event_id + events[ev.event_id] = ev + rows.append([ev.event_id, typ, state_key, sender, content, data['auth_events'], ts] + + ([True] if rejected and rejected[0] else [])) + states = [{(events[mapped[a]].type, events[mapped[a]].state_key): mapped[a] for a in s} + for s in case['states']] + resolved = asyncio.run(v2.resolve_events_with_store( + Clock(), room_id, RoomVersions.V12, states, None, Store(events))) + out.append(dict(name=case['name'], room_id=room_id, events=rows, + states=[sorted(s.values()) for s in states], resolved=sorted(resolved.values()))) +(root / 'synapse_v12.json').write_text( + '{"forks":[\n' + ',\n'.join(json.dumps(fork, separators=(',', ':')) for fork in out) + '\n]}\n') diff --git a/internal/stateres/testdata/materialized_forks.json b/internal/stateres/testdata/materialized_forks.json new file mode 100644 index 0000000..98a95c7 --- /dev/null +++ b/internal/stateres/testdata/materialized_forks.json @@ -0,0 +1,1010 @@ +{ + "three branches": [ + ["$alice","$create","$e11","$e12","$e13","$e15","$e5","$topic"], + ["$alice","$create","$e5","$e9","$join-bob","$join-charlie","$join-dave","$name","$pl"], + ["$alice","$create","$e10","$e11","$e12","$e6","$e8","$e9","$name"], + ["$alice","$create","$e1","$e6","$e8","$e9","$join-dave","$name","$pl"], + ["$alice","$create","$e2","$e3","$e4","$e5","$name","$pl","$topic"], + ["$alice","$create","$e1","$e6","$e7","$e8","$join-bob","$pl","$topic"], + ["$alice","$create","$e10","$e2","$e4","$e5","$e7","$e8","$name"], + ["$alice","$create","$e11","$e12","$e13","$e3","$e5","$e8","$pl"], + ["$alice","$create","$e10","$e12","$e3","$e6","$e7","$name","$topic"], + ["$alice","$create","$e10","$e4","$e5","$e8","$e9","$join-dave","$topic"], + ["$alice","$create","$e1","$e10","$e11","$e13","$e4","$e5","$e8"], + ["$alice","$create","$e2","$e6","$join-bob","$jr","$name","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e5","$e6","$jr","$name","$topic"], + ["$alice","$create","$e1","$e4","$e5","$e6","$jr","$name","$pl"], + ["$alice","$create","$e2","$e4","$e8","$e9","$name","$pl","$topic"], + ["$alice","$create","$e1","$e10","$e12","$e3","$e6","$e8","$e9"], + ["$alice","$create","$e3","$e4","$e5","$e6","$e7","$join-charlie","$pl"], + ["$alice","$create","$e5","$e6","$join-bob","$join-charlie","$join-dave","$pl","$topic"], + ["$alice","$create","$e1","$e10","$e5","$e6","$e7","$pl","$topic"], + ["$alice","$create","$e2","$e4","$e5","$join-bob","$join-dave","$name","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e5","$e7","$join-charlie","$pl"], + ["$alice","$create","$e10","$e2","$e4","$e6","$e7","$name","$pl"], + ["$alice","$create","$e11","$e12","$e13","$e15","$e6","$e8","$e9"], + ["$alice","$create","$e10","$e11","$e3","$e9","$name","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e5","$e7","$join-charlie","$jr","$pl"], + ["$alice","$create","$e1","$e11","$e3","$e4","$e6","$e8","$name"], + ["$alice","$create","$e10","$e12","$e2","$e7","$e8","$name","$topic"], + ["$alice","$create","$e1","$e11","$e7","$e8","$jr","$pl","$topic"], + ["$alice","$create","$e1","$e4","$e6","$e7","$e8","$join-charlie","$topic"], + ["$alice","$create","$e1","$e10","$e12","$e7","$name","$pl","$topic"], + ["$alice","$create","$e1","$e4","$e5","$join-charlie","$name","$pl","$topic"], + ["$alice","$create","$e12","$e3","$e5","$e6","$e8","$name","$pl"], + ["$alice","$create","$e1","$e11","$e3","$e6","$e7","$e9","$pl"], + ["$alice","$create","$e2","$e3","$e4","$e6","$e7","$join-dave","$topic"], + ["$alice","$create","$e1","$e11","$e12","$e4","$e6","$e9","$topic"], + ["$alice","$create","$e1","$e11","$e3","$e4","$e8","$e9","$join-dave"], + ["$alice","$create","$e1","$e3","$e4","$e5","$join-charlie","$join-dave","$pl"], + ["$alice","$create","$e10","$e12","$e3","$e7","$e8","$e9","$topic"], + ["$alice","$create","$e12","$e14","$e4","$e5","$e7","$e8","$join-charlie"], + ["$alice","$create","$e2","$e3","$e4","$e5","$join-bob","$pl","$topic"], + ["$alice","$create","$e10","$e11","$e4","$e5","$e8","$e9","$join-charlie"], + ["$alice","$create","$e11","$e12","$e13","$e6","$e8","$name","$topic"], + ["$alice","$create","$e11","$e12","$e14","$e15","$e2","$e5","$e9"], + ["$alice","$create","$e1","$e10","$e11","$e2","$e4","$e9","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-bob","$jr","$pl"], + ["$alice","$create","$e3","$e4","$e6","$join-charlie","$join-dave","$pl","$topic"], + ["$alice","$create","$e1","$e4","$e5","$e7","$join-bob","$join-dave","$topic"], + ["$alice","$create","$e1","$e4","$e6","$e7","$e8","$join-bob","$jr"], + ["$alice","$create","$e1","$e4","$e5","$e7","$e8","$join-charlie","$topic"], + ["$alice","$create","$e10","$e11","$e4","$e5","$e7","$e8","$join-bob"], + ["$alice","$create","$e10","$e11","$e2","$e3","$e4","$e7","$e8"], + ["$alice","$create","$e1","$e12","$e5","$e9","$join-bob","$name","$pl"], + ["$alice","$create","$e1","$e4","$e5","$join-bob","$join-dave","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e6","$e8","$e9","$join-charlie","$topic"], + ["$alice","$create","$e4","$e5","$e6","$join-bob","$join-dave","$jr","$topic"], + ["$alice","$create","$e1","$e3","$e5","$e7","$e8","$pl","$topic"], + ["$alice","$create","$e11","$e2","$e4","$e7","$e8","$e9","$join-bob"], + ["$alice","$create","$e1","$e10","$e11","$e12","$e4","$e9","$pl"], + ["$alice","$create","$e2","$e4","$e6","$e8","$join-charlie","$name","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e5","$jr","$topic"], + ["$alice","$create","$e10","$e11","$e3","$e5","$e7","$e9","$topic"], + ["$alice","$create","$e10","$e11","$e3","$e6","$e7","$join-dave","$pl"], + ["$alice","$create","$e10","$e11","$e5","$e6","$e8","$join-bob","$join-charlie"], + ["$alice","$create","$e1","$e10","$e4","$e6","$e7","$e8","$e9"], + ["$alice","$create","$e3","$e4","$e5","$e6","$e8","$name","$pl"], + ["$alice","$create","$e11","$e12","$e8","$e9","$join-dave","$name","$topic"], + ["$alice","$create","$e10","$e11","$e12","$e3","$e7","$e8","$name"], + ["$alice","$create","$e1","$e7","$e8","$e9","$join-charlie","$name","$topic"], + ["$alice","$create","$e1","$e10","$e3","$e6","$e9","$join-bob","$join-dave"], + ["$alice","$create","$e2","$e4","$e5","$join-charlie","$join-dave","$name","$topic"], + ["$alice","$create","$e11","$e14","$e3","$e4","$e5","$e8","$name"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e5","$join-dave","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e5","$join-dave","$jr","$pl"], + ["$alice","$create","$e1","$e3","$e4","$e5","$e6","$e7","$pl"], + ["$alice","$create","$e2","$e6","$join-charlie","$jr","$name","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e5","$join-charlie","$name","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e5","$e7","$join-dave","$name"], + ["$alice","$create","$e1","$e3","$e4","$e5","$e6","$pl","$topic"], + ["$alice","$create","$e12","$e13","$e4","$e6","$e7","$e9","$pl"], + ["$alice","$create","$e1","$e11","$e12","$e3","$e7","$name","$topic"], + ["$alice","$create","$e1","$e10","$e11","$e7","$e9","$join-charlie","$topic"], + ["$alice","$create","$e10","$e12","$e2","$e5","$e8","$e9","$topic"], + ["$alice","$create","$e10","$e11","$e2","$e7","$e8","$name","$topic"], + ["$alice","$create","$e12","$e3","$e5","$e7","$e8","$name","$pl"], + ["$alice","$create","$e1","$e2","$join-bob","$join-dave","$name","$pl","$topic"], + ["$alice","$create","$e11","$e12","$e3","$e4","$e7","$e8","$topic"], + ["$alice","$create","$e3","$e5","$e6","$e7","$e8","$e9","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-bob","$name","$pl"], + ["$alice","$create","$e1","$e12","$e13","$e14","$e7","$e9","$name"], + ["$alice","$create","$e10","$e3","$e4","$e6","$e9","$join-bob","$join-dave"], + ["$alice","$create","$e1","$e4","$e5","$e7","$join-bob","$name","$topic"], + ["$alice","$create","$e10","$e11","$e4","$e6","$e7","$e8","$e9"], + ["$alice","$create","$e1","$e3","$e6","$join-bob","$jr","$name","$pl"], + ["$alice","$create","$e10","$e11","$e2","$e3","$e6","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e7","$e8","$name","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e5","$e8","$join-charlie","$name","$pl"], + ["$alice","$create","$e1","$e3","$e5","$join-bob","$join-charlie","$pl","$topic"], + ["$alice","$create","$e1","$e11","$e14","$e5","$e6","$e7","$e8"], + ["$alice","$create","$e1","$e2","$e4","$e5","$e6","$join-dave","$jr"], + ["$alice","$create","$e10","$e3","$e4","$e7","$e8","$e9","$join-dave"], + ["$alice","$create","$e1","$e10","$e12","$e2","$e3","$e6","$name"], + ["$alice","$create","$e6","$e7","$e8","$join-charlie","$name","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-bob","$jr","$topic"], + ["$alice","$create","$e1","$e10","$e11","$e13","$e4","$e9","$join-dave"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-bob","$name","$topic"], + ["$alice","$create","$e10","$e13","$e3","$e4","$e7","$e9","$name"], + ["$alice","$create","$e1","$e2","$e3","$e5","$join-charlie","$pl","$topic"], + ["$alice","$create","$e3","$e4","$e5","$e6","$join-bob","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e6","$join-charlie","$join-dave","$name","$pl"], + ["$alice","$create","$e3","$e4","$e6","$join-bob","$join-charlie","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e5","$join-charlie","$jr","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e5","$join-bob","$join-dave","$pl"], + ["$alice","$create","$e1","$e10","$e5","$e8","$e9","$pl","$topic"], + ["$alice","$create","$e3","$e4","$e5","$e7","$e8","$e9","$name"], + ["$alice","$create","$e2","$e3","$e4","$e6","$e9","$pl","$topic"], + ["$alice","$create","$e1","$e10","$e13","$e8","$e9","$name","$pl"], + ["$alice","$create","$e10","$e11","$e12","$e14","$e2","$e5","$name"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-charlie","$join-dave","$topic"], + ["$alice","$create","$e11","$e7","$e8","$e9","$join-bob","$name","$pl"], + ["$alice","$create","$e11","$e5","$e6","$join-charlie","$name","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e5","$e6","$name","$pl"], + ["$alice","$create","$e2","$e4","$e5","$e6","$e7","$e8","$pl"], + ["$alice","$create","$e11","$e4","$e5","$e6","$e8","$name","$pl"], + ["$alice","$create","$e11","$e2","$e5","$e7","$e8","$pl","$topic"], + ["$alice","$create","$e11","$e14","$e3","$e7","$e8","$join-dave","$pl"], + ["$alice","$create","$e1","$e12","$e3","$e5","$e6","$e8","$pl"], + ["$alice","$create","$e3","$e4","$e6","$e9","$join-charlie","$name","$topic"], + ["$alice","$create","$e1","$e3","$e6","$e8","$jr","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e6","$join-dave","$name","$pl","$topic"], + ["$alice","$create","$e4","$e6","$e8","$join-bob","$join-charlie","$name","$pl"], + ["$alice","$create","$e12","$e14","$e2","$e5","$e8","$name","$pl"], + ["$alice","$create","$e1","$e3","$e4","$join-dave","$name","$pl","$topic"], + ["$alice","$create","$e1","$e10","$e3","$e5","$e6","$e8","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e4","$jr","$name","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e5","$e6","$pl","$topic"], + ["$alice","$create","$e10","$e4","$e5","$e6","$e8","$e9","$topic"], + ["$alice","$create","$e1","$e2","$e5","$jr","$name","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e5","$e6","$join-charlie","$name","$pl"], + ["$alice","$create","$e10","$e4","$e6","$e8","$name","$pl","$topic"], + ["$alice","$create","$e11","$e13","$e4","$e6","$e7","$join-dave","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e6","$e7","$e8"], + ["$alice","$create","$e1","$e11","$e13","$e14","$e4","$e9","$topic"], + ["$alice","$create","$e2","$e3","$e4","$e5","$jr","$name","$topic"], + ["$alice","$create","$e10","$e11","$e8","$join-bob","$pl","$topic"], + ["$alice","$create","$e4","$e6","$e7","$e8","$e9","$name","$pl"], + ["$alice","$create","$e2","$e5","$e6","$e7","$e8","$join-charlie","$pl"], + ["$alice","$create","$e1","$e4","$e5","$e7","$e8","$join-charlie","$name"], + ["$alice","$create","$e10","$e11","$e14","$e3","$e7","$join-charlie","$pl"], + ["$alice","$create","$e1","$e6","$e7","$e8","$e9","$name","$topic"], + ["$alice","$create","$e3","$e5","$e6","$e7","$e8","$name","$topic"], + ["$alice","$create","$e11","$e12","$e3","$e4","$e6","$e8","$join-charlie"], + ["$alice","$create","$e2","$e3","$e4","$e5","$name","$pl","$topic"], + ["$alice","$create","$e11","$e14","$e4","$e6","$e7","$e9","$name"], + ["$alice","$create","$e1","$e2","$e4","$e5","$join-bob","$join-dave","$pl"], + ["$alice","$create","$e1","$e2","$e4","$e5","$e7","$name","$pl"], + ["$alice","$create","$e10","$e11","$e4","$e7","$join-bob","$name","$topic"], + ["$alice","$create","$e3","$e4","$e6","$e7","$e8","$e9","$join-charlie"], + ["$alice","$create","$e4","$e5","$e6","$e8","$e9","$pl","$topic"], + ["$alice","$create","$e10","$e11","$e12","$e13","$e2","$e6","$pl"], + ["$alice","$create","$e1","$e3","$e5","$e7","$e9","$join-bob","$pl"], + ["$alice","$create","$e10","$e2","$e3","$e5","$e6","$e8","$pl"], + ["$alice","$create","$e1","$e10","$e2","$e3","$e4","$e8","$e9"], + ["$alice","$create","$e11","$e5","$e7","$e8","$e9","$name","$pl"], + ["$alice","$create","$e3","$e4","$e5","$e6","$e7","$e9","$join-bob"], + ["$alice","$create","$e10","$e4","$e6","$e8","$join-charlie","$name","$topic"], + ["$alice","$create","$e3","$e6","$e7","$e8","$e9","$join-charlie","$name"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e7","$e9","$name"], + ["$alice","$create","$e1","$e2","$e3","$e5","$e6","$e7","$e8"], + ["$alice","$create","$e10","$e13","$e3","$e6","$e7","$e8","$e9"], + ["$alice","$create","$e1","$e4","$e6","$e7","$e8","$e9","$pl"], + ["$alice","$create","$e10","$e4","$e8","$e9","$join-charlie","$name","$topic"], + ["$alice","$create","$e1","$e10","$e3","$e5","$e6","$e9","$name"], + ["$alice","$create","$e2","$e6","$e7","$e8","$e9","$join-dave","$name"], + ["$alice","$create","$e3","$e5","$e6","$e8","$e9","$join-dave","$pl"], + ["$alice","$create","$e3","$e5","$e6","$join-bob","$join-charlie","$name","$topic"], + ["$alice","$create","$e1","$e2","$e5","$e7","$e8","$e9","$topic"], + ["$alice","$create","$e2","$e3","$e4","$e7","$e9","$join-bob","$join-dave"], + ["$alice","$create","$e2","$e4","$e5","$e7","$join-dave","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e5","$e6","$e7","$join-bob","$name"], + ["$alice","$create","$e10","$e11","$e12","$e14","$e9","$join-dave","$pl"], + ["$alice","$create","$e1","$e3","$e5","$e6","$e8","$e9","$pl"], + ["$alice","$create","$e1","$e10","$e4","$e9","$join-bob","$name","$topic"], + ["$alice","$create","$e1","$e10","$e11","$e6","$e7","$e8","$pl"], + ["$alice","$create","$e4","$e5","$e6","$join-charlie","$join-dave","$pl","$topic"], + ["$alice","$create","$e1","$e10","$e11","$e2","$e6","$e9","$topic"], + ["$alice","$create","$e1","$e4","$e5","$e8","$e9","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e7","$name","$pl","$topic"], + ["$alice","$create","$e3","$e4","$e5","$e6","$join-bob","$pl","$topic"], + ["$alice","$create","$e1","$e10","$e12","$e13","$e9","$join-charlie","$name"], + ["$alice","$create","$e1","$e11","$e6","$e7","$e9","$name","$topic"], + ["$alice","$create","$e1","$e11","$e2","$e3","$e9","$name","$pl"], + ["$alice","$create","$e1","$e10","$e3","$e6","$e7","$e8","$name"], + ["$alice","$create","$e1","$e10","$e12","$e4","$e6","$e7","$jr"], + ["$alice","$create","$e1","$e3","$e4","$e6","$join-bob","$join-dave","$pl"], + ["$alice","$create","$e2","$e3","$e5","$join-charlie","$name","$pl","$topic"], + ["$alice","$create","$e11","$e4","$e5","$e6","$name","$pl","$topic"], + ["$alice","$create","$e1","$e11","$e12","$e13","$e3","$e8"], + ["$alice","$create","$e1","$e3","$e4","$e7","$name","$pl","$topic"], + ["$alice","$create","$e10","$e12","$e13","$e3","$e4","$e9","$pl"], + ["$alice","$create","$e2","$e3","$e5","$e6","$join-charlie","$join-dave","$pl"], + ["$alice","$create","$e3","$e4","$e5","$e6","$join-charlie","$jr","$topic"], + ["$alice","$create","$e3","$e4","$e5","$e8","$e9","$name","$pl"], + ["$alice","$create","$e2","$e3","$e4","$e5","$join-charlie","$join-dave","$pl"], + ["$alice","$create","$e1","$e2","$e4","$e5","$e6","$join-dave","$topic"], + ["$alice","$create","$e10","$e3","$e6","$e7","$e9","$join-bob","$topic"], + ["$alice","$create","$e10","$e12","$e13","$e4","$e8","$e9","$topic"], + ["$alice","$create","$e1","$e5","$e7","$e8","$join-dave","$jr","$topic"], + ["$alice","$create","$e1","$e11","$e4","$e8","$e9","$name","$topic"], + ["$alice","$create","$e1","$e3","$join-bob","$join-charlie","$join-dave","$name","$pl"], + ["$alice","$create","$e10","$e11","$e12","$e5","$e8","$e9","$name"], + ["$alice","$create","$e3","$e4","$e5","$e6","$e7","$join-bob","$pl"], + ["$alice","$create","$e1","$e2","$e4","$e5","$join-bob","$join-charlie","$join-dave"], + ["$alice","$create","$e1","$e10","$e11","$e12","$e3","$e7","$e8"], + ["$alice","$create","$e1","$e2","$e4","$join-charlie","$join-dave","$jr","$name"], + ["$alice","$create","$e10","$e11","$e13","$e15","$e7","$name","$topic"], + ["$alice","$create","$e1","$e13","$e4","$e6","$join-charlie","$join-dave","$topic"], + ["$alice","$create","$e1","$e3","$e4","$join-charlie","$jr","$name","$pl"], + ["$alice","$create","$e1","$e11","$e13","$e3","$e8","$join-charlie","$pl"], + ["$alice","$create","$e10","$e13","$e15","$e3","$e4","$e5","$e9"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e5","$join-charlie","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e5","$e6","$name"], + ["$alice","$create","$e10","$e4","$e5","$e6","$e8","$e9","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e5","$join-bob","$jr","$pl"], + ["$alice","$create","$e10","$e11","$e2","$e5","$e8","$e9","$topic"], + ["$alice","$create","$e1","$e11","$e12","$e13","$e2","$e6","$e9"], + ["$alice","$create","$e1","$e10","$e11","$e3","$e4","$e7","$join-charlie"], + ["$alice","$create","$e11","$e4","$e6","$e7","$e8","$pl","$topic"], + ["$alice","$create","$e1","$e10","$e2","$e3","$e5","$e8","$topic"], + ["$alice","$create","$e3","$e6","$join-bob","$join-charlie","$name","$pl","$topic"], + ["$alice","$create","$e2","$e4","$e5","$e6","$join-bob","$join-charlie","$pl"], + ["$alice","$create","$e10","$e11","$e12","$e2","$e6","$e9","$pl"], + ["$alice","$create","$e2","$e3","$e4","$e5","$e6","$join-charlie","$topic"], + ["$alice","$create","$e10","$e11","$e4","$e7","$e9","$name","$pl"], + ["$alice","$create","$e1","$e2","$e4","$e5","$e6","$join-charlie","$pl"], + ["$alice","$create","$e1","$e10","$e3","$e4","$e5","$join-charlie","$topic"], + ["$alice","$create","$e2","$e3","$join-charlie","$jr","$name","$pl","$topic"], + ["$alice","$create","$e4","$e5","$e6","$e7","$e8","$join-bob","$pl"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e3","$e4","$e5"], + ["$alice","$create","$e1","$e11","$e2","$e5","$e9","$name","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e5","$e6","$pl","$topic"], + ["$alice","$create","$e11","$e3","$e5","$e8","$e9","$join-charlie","$pl"], + ["$alice","$create","$e11","$e3","$e8","$e9","$join-charlie","$join-dave","$pl"], + ["$alice","$create","$e1","$e12","$e2","$e3","$e4","$e7","$e9"], + ["$alice","$create","$e10","$e12","$e14","$e15","$e2","$e7","$e8"], + ["$alice","$create","$e10","$e11","$e3","$e6","$jr","$pl","$topic"], + ["$alice","$create","$e3","$e6","$e8","$e9","$join-bob","$name","$topic"], + ["$alice","$create","$e1","$e10","$e11","$e12","$e5","$e7","$pl"], + ["$alice","$create","$e10","$e11","$e6","$e7","$e8","$e9","$pl"], + ["$alice","$create","$e10","$e4","$e5","$e6","$e8","$join-bob","$pl"], + ["$alice","$create","$e2","$e4","$e5","$e7","$e8","$join-bob","$topic"] + ], + "three branches after shared history": [ + ["$alice","$create","$e12","$e13","$e14","$e15","$e16","$e18","$e8"], + ["$alice","$create","$e1","$e12","$e13","$e14","$e4","$jr","$name"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e15","$e16","$e8"], + ["$alice","$create","$e12","$e13","$e14","$e18","$e20","$e21","$jr"], + ["$alice","$create","$e1","$e13","$e14","$e19","$e6","$join-bob","$topic"], + ["$alice","$create","$e11","$e12","$e14","$e3","$e4","$e8","$jr"], + ["$alice","$create","$e10","$e12","$e13","$e2","$e5","$e6","$e7"], + ["$alice","$create","$e16","$e17","$e18","$e19","$e7","$e8","$topic"], + ["$alice","$create","$e10","$e11","$e15","$e4","$e6","$e7","$topic"], + ["$alice","$create","$e10","$e13","$e14","$e18","$e19","$e4","$e8"], + ["$alice","$create","$e10","$e11","$e13","$e15","$e6","$e8","$pl"], + ["$alice","$create","$e11","$e13","$e14","$e15","$e16","$e5","$e7"], + ["$alice","$create","$e11","$e12","$e16","$e5","$e9","$jr","$name"], + ["$alice","$create","$e12","$e15","$e16","$e17","$e19","$e3","$e9"], + ["$alice","$create","$e1","$e10","$e12","$e13","$e14","$e4","$jr"], + ["$alice","$create","$e10","$e11","$e16","$e6","$e9","$name","$topic"], + ["$alice","$create","$e1","$e11","$e12","$e3","$e6","$e8","$e9"], + ["$alice","$create","$e12","$e14","$e18","$e20","$e3","$e5","$e9"], + ["$alice","$create","$e10","$e11","$e12","$e17","$e2","$e5","$e9"], + ["$alice","$create","$e11","$e14","$e16","$e18","$e20","$e5","$e9"], + ["$alice","$create","$e11","$e13","$e18","$e2","$e3","$e4","$name"], + ["$alice","$create","$e10","$e11","$e14","$e2","$e7","$e9","$topic"], + ["$alice","$create","$e11","$e14","$e15","$e17","$e18","$e3","$topic"], + ["$alice","$create","$e10","$e13","$e15","$e3","$e4","$name"], + ["$alice","$create","$e10","$e11","$e13","$e14","$e17","$e18","$e3"], + ["$alice","$create","$e11","$e12","$e14","$e5","$join-dave","$jr","$name"], + ["$alice","$create","$e11","$e12","$e15","$e16","$e18","$e4","$name"], + ["$alice","$create","$e10","$e12","$e13","$e14","$e8","$e9","$topic"], + ["$alice","$create","$e13","$e16","$e17","$e18","$e19","$e7","$topic"], + ["$alice","$create","$e1","$e13","$e16","$e18","$e20","$e4","$e6"], + ["$alice","$create","$e12","$e13","$e15","$e3","$e7","$e9","$name"], + ["$alice","$create","$e12","$e14","$e15","$e17","$e19","$e2","$e22"], + ["$alice","$create","$e14","$e15","$e18","$e21","$e4","$e6","$join-charlie"], + ["$alice","$create","$e11","$e12","$e16","$e18","$e19","$e20","$e4"], + ["$alice","$create","$e10","$e12","$e13","$e14","$e15","$e8","$topic"], + ["$alice","$create","$e13","$e14","$e16","$e20","$e21","$e5","$pl"], + ["$alice","$create","$e13","$e14","$e3","$e4","$e5","$e6","$topic"], + ["$alice","$create","$e11","$e13","$e16","$e17","$e19","$e4","$e9"], + ["$alice","$create","$e11","$e12","$e13","$e15","$e2","$e7","$name"], + ["$alice","$create","$e12","$e17","$e19","$e8","$e9","$name","$topic"], + ["$alice","$create","$e10","$e11","$e13","$e2","$e9","$join-bob","$name"], + ["$alice","$create","$e12","$e15","$e17","$e6","$e7","$e9","$join-charlie"], + ["$alice","$create","$e14","$e15","$e19","$e20","$e21","$e7","$name"], + ["$alice","$create","$e1","$e10","$e12","$e13","$e4","$e5","$e6"], + ["$alice","$create","$e11","$e12","$e4","$e5","$e9","$jr","$topic"], + ["$alice","$create","$e11","$e12","$e14","$e7","$join-charlie","$join-dave","$name"], + ["$alice","$create","$e14","$e15","$e17","$e18","$e20","$e9","$join-dave"], + ["$alice","$create","$e11","$e14","$e15","$e6","$e7","$e8","$jr"], + ["$alice","$create","$e10","$e11","$e12","$e13","$e2","$join-dave","$jr"], + ["$alice","$create","$e11","$e12","$e14","$e16","$e17","$e18","$e5"], + ["$alice","$create","$e11","$e13","$e14","$e16","$e19","$e8","$name"], + ["$alice","$create","$e1","$e10","$e13","$e14","$e6","$e8","$jr"], + ["$alice","$create","$e11","$e12","$e13","$e6","$e7","$e9","$name"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e5","$e6","$e7"], + ["$alice","$create","$e17","$e18","$e19","$e21","$e23","$e4","$e9"], + ["$alice","$create","$e10","$e12","$e5","$e9","$jr","$name","$pl"], + ["$alice","$create","$e1","$e12","$e13","$e15","$e3","$e4","$e8"], + ["$alice","$create","$e14","$e17","$e19","$e20","$e4","$e5","$e8"], + ["$alice","$create","$e11","$e13","$e15","$e16","$e18","$e9","$name"], + ["$alice","$create","$e10","$e13","$e14","$e17","$e18","$e3","$e9"], + ["$alice","$create","$e12","$e13","$e14","$e2","$join-dave","$jr","$pl"], + ["$alice","$create","$e11","$e13","$e15","$e16","$e3","$e5"], + ["$alice","$create","$e15","$e18","$e2","$e21","$e4","$e5","$e9"], + ["$alice","$create","$e1","$e11","$e15","$e18","$e3","$e7","$e9"], + ["$alice","$create","$e11","$e16","$e17","$e18","$e19","$e5","$name"], + ["$alice","$create","$e10","$e15","$e16","$e4","$e5","$e9","$name"], + ["$alice","$create","$e13","$e14","$e4","$e6","$e7","$name","$topic"], + ["$alice","$create","$e13","$e14","$e8","$e9","$join-charlie","$pl","$topic"], + ["$alice","$create","$e11","$e12","$e14","$e17","$e19","$name","$topic"], + ["$alice","$create","$e10","$e13","$e14","$e15","$e16","$e17","$e9"], + ["$alice","$create","$e10","$e11","$e13","$e15","$e2","$e3","$e9"], + ["$alice","$create","$e10","$e13","$e15","$e18","$e3","$e4","$topic"], + ["$alice","$create","$e10","$e11","$e12","$e14","$e3","$jr","$name"], + ["$alice","$create","$e1","$e14","$e15","$e16","$e2","$e3","$e6"], + ["$alice","$create","$e10","$e13","$e17","$e18","$e2","$e5","$e9"], + ["$alice","$create","$e18","$e19","$e2","$e20","$e21","$e8","$e9"], + ["$alice","$create","$e11","$e13","$e14","$e2","$e3","$e8","$join-dave"], + ["$alice","$create","$e10","$e13","$e14","$e15","$e7","$e9","$join-dave"], + ["$alice","$create","$e1","$e13","$e14","$e5","$e6","$e8","$e9"], + ["$alice","$create","$e12","$e5","$e7","$e8","$e9","$jr","$name"], + ["$alice","$create","$e11","$e12","$e16","$e17","$e19","$e9","$name"], + ["$alice","$create","$e10","$e11","$e12","$e13","$e16","$e5","$e6"], + ["$alice","$create","$e1","$e15","$e17","$e19","$e22","$name","$pl"], + ["$alice","$create","$e1","$e10","$e13","$e14","$e17","$e19","$jr"], + ["$alice","$create","$e10","$e12","$e13","$e14","$e17","$e7","$e8"], + ["$alice","$create","$e11","$e12","$e13","$e2","$e8","$e9","$name"], + ["$alice","$create","$e1","$e16","$e18","$e19","$e20","$e6","$e9"], + ["$alice","$create","$e1","$e11","$e13","$e14","$e16","$e9","$name"], + ["$alice","$create","$e1","$e10","$e11","$e12","$e8","$e9","$join-dave"], + ["$alice","$create","$e10","$e11","$e12","$e13","$e14","$e7","$e8"], + ["$alice","$create","$e12","$e13","$e14","$e17","$e8","$e9","$name"], + ["$alice","$create","$e13","$e5","$e7","$e8","$e9","$join-charlie","$name"], + ["$alice","$create","$e11","$e16","$e3","$e4","$e6","$e9","$name"], + ["$alice","$create","$e10","$e11","$e12","$e14","$e15","$e20","$e8"], + ["$alice","$create","$e10","$e12","$e13","$e6","$e8","$e9","$topic"], + ["$alice","$create","$e12","$e13","$e14","$e16","$e17","$e6","$topic"], + ["$alice","$create","$e11","$e12","$e14","$e15","$e3","$e8","$topic"], + ["$alice","$create","$e1","$e14","$e15","$e5","$e7","$e9","$jr"], + ["$alice","$create","$e10","$e12","$e16","$e18","$e19","$e5","$e7"], + ["$alice","$create","$e11","$e15","$e20","$e21","$e22","$e5","$e9"], + ["$alice","$create","$e13","$e19","$e21","$e23","$e24","$e6","$e7"], + ["$alice","$create","$e10","$e11","$e4","$e5","$e8","$e9","$name"], + ["$alice","$create","$e10","$e11","$e13","$e8","$join-charlie","$jr","$name"], + ["$alice","$create","$e10","$e14","$e15","$e17","$e3","$e8","$topic"], + ["$alice","$create","$e12","$e15","$e16","$e17","$e18","$e8","$name"], + ["$alice","$create","$e12","$e14","$e15","$e16","$e17","$e18","$e3"], + ["$alice","$create","$e13","$e14","$e17","$e18","$e5","$e8"], + ["$alice","$create","$e10","$e11","$e5","$e6","$e9","$jr"], + ["$alice","$create","$e10","$e13","$e17","$e18","$e19","$e5"], + ["$alice","$create","$e11","$e12","$e13","$e17","$e5","$topic"], + ["$alice","$create","$e10","$e12","$e14","$e15","$e7","$e9","$jr"], + ["$alice","$create","$e13","$e3","$e4","$e8","$e9","$join-charlie","$jr"], + ["$alice","$create","$e10","$e11","$e14","$e6","$e9","$jr","$name"], + ["$alice","$create","$e16","$e17","$e18","$e20","$e21","$e7","$name"], + ["$alice","$create","$e11","$e13","$e15","$e17","$e19","$e20","$e8"], + ["$alice","$create","$e11","$e12","$e14","$e5","$e8","$e9","$name"], + ["$alice","$create","$e14","$e17","$e18","$e3","$e4","$e6","$jr"], + ["$alice","$create","$e10","$e11","$e13","$e4","$e5","$e6","$join-charlie"], + ["$alice","$create","$e11","$e16","$e17","$e19","$e20","$e9"], + ["$alice","$create","$e1","$e12","$e15","$e20","$e3","$e8","$topic"], + ["$alice","$create","$e11","$e13","$e14","$e16","$e18","$e19","$e20"], + ["$alice","$create","$e12","$e14","$e17","$e23","$e9"], + ["$alice","$create","$e12","$e14","$e15","$e16","$e5","$e9","$name"], + ["$alice","$create","$e13","$e14","$e17","$e18","$e7","$e8","$name"], + ["$alice","$create","$e10","$e13","$e15","$e2","$e6","$e8"], + ["$alice","$create","$e15","$e16","$e17","$e19","$e21","$name","$topic"], + ["$alice","$create","$e11","$e12","$e14","$e5","$e6","$e9","$name"], + ["$alice","$create","$e12","$e13","$e15","$e19","$e3","$e6","$e9"], + ["$alice","$create","$e14","$e15","$e16","$e18","$e19","$e4","$e9"], + ["$alice","$create","$e10","$e12","$e3","$e5","$e6","$e7","$e8"], + ["$alice","$create","$e10","$e13","$e14","$e15","$e2","$e9","$join-bob"], + ["$alice","$create","$e10","$e15","$e17","$e18","$e20","$e7","$e8"], + ["$alice","$create","$e12","$e13","$e16","$e18","$e2","$e20","$e3"], + ["$alice","$create","$e11","$e12","$e13","$e15","$e4","$e5","$e9"], + ["$alice","$create","$e11","$e15","$e16","$e17","$e18","$e21","$e6"], + ["$alice","$create","$e1","$e14","$e16","$e18","$e4","$e7","$e9"], + ["$alice","$create","$e11","$e12","$e14","$e15","$e16","$e6","$topic"], + ["$alice","$create","$e10","$e15","$e16","$e17","$e4","$e5","$topic"], + ["$alice","$create","$e10","$e15","$e17","$e4","$e5","$e6","$name"], + ["$alice","$create","$e10","$e11","$e12","$e14","$e18","$e7","$e8"], + ["$alice","$create","$e10","$e12","$e4","$e6","$e9","$name"], + ["$alice","$create","$e10","$e12","$e14","$e3","$e9","$join-bob","$pl"], + ["$alice","$create","$e1","$e14","$e16","$e19","$e4","$e7","$e8"], + ["$alice","$create","$e12","$e13","$e14","$e16","$e18","$e20","$e9"], + ["$alice","$create","$e11","$e12","$e3","$e7","$e8","$e9","$pl"], + ["$alice","$create","$e1","$e10","$e11","$e12","$e17","$e7","$topic"], + ["$alice","$create","$e1","$e10","$e13","$e6","$e8","$join-dave","$jr"], + ["$alice","$create","$e11","$e14","$e5","$e7","$e8","$e9","$join-dave"], + ["$alice","$create","$e11","$e13","$e2","$e3","$e7","$e8","$jr"], + ["$alice","$create","$e1","$e10","$e13","$e16","$e4","$e5","$e7"], + ["$alice","$create","$e11","$e12","$e16","$e2","$e3","$e7","$name"], + ["$alice","$create","$e10","$e11","$e13","$e15","$e17","$e5","$e6"], + ["$alice","$create","$e1","$e10","$e12","$e2","$e5","$e7","$pl"], + ["$alice","$create","$e12","$e13","$e16","$e18","$e2","$e5","$e6"], + ["$alice","$create","$e10","$e15","$e17","$e18","$e5","$e8","$e9"], + ["$alice","$create","$e11","$e18","$e19","$e20","$e21","$e4","$e5"], + ["$alice","$create","$e10","$e12","$e14","$e9","$name","$pl"], + ["$alice","$create","$e10","$e12","$e15","$e16","$e17","$e18","$e20"], + ["$alice","$create","$e10","$e12","$e13","$e17","$e18","$jr"], + ["$alice","$create","$e13","$e14","$e17","$e4","$e6","$e8","$e9"], + ["$alice","$create","$e10","$e13","$e18","$e20","$e21","$e3","$e7"], + ["$alice","$create","$e11","$e12","$e13","$e15","$e16","$e7","$e8"], + ["$alice","$create","$e12","$e13","$e14","$e15","$e7","$e9","$join-charlie"], + ["$alice","$create","$e10","$e13","$e16","$e17","$e18","$e19","$topic"], + ["$alice","$create","$e1","$e10","$e13","$e14","$e21","$e22","$name"], + ["$alice","$create","$e11","$e16","$e17","$e5","$e6","$e8","$name"], + ["$alice","$create","$e10","$e12","$e13","$e15","$e17","$e4","$e7"], + ["$alice","$create","$e17","$e18","$e19","$e21","$e3","$e5","$e8"], + ["$alice","$create","$e1","$e10","$e11","$e2","$e8","$e9","$pl"], + ["$alice","$create","$e1","$e11","$e13","$e5","$e6","$e8","$e9"], + ["$alice","$create","$e11","$e14","$e17","$e18","$e4","$pl","$topic"], + ["$alice","$create","$e10","$e20","$e21","$e5","$e8","$e9","$name"], + ["$alice","$create","$e10","$e11","$e12","$e4","$e5","$e6","$name"], + ["$alice","$create","$e12","$e13","$e14","$e15","$e9","$join-charlie","$name"], + ["$alice","$create","$e10","$e13","$e15","$e17","$e2","$e5","$e9"], + ["$alice","$create","$e15","$e16","$e17","$e18","$e21","$e22","$e9"], + ["$alice","$create","$e12","$e13","$e14","$e15","$e19","$e2","$e3"], + ["$alice","$create","$e13","$e15","$e16","$e18","$e19","$e6","$topic"], + ["$alice","$create","$e12","$e13","$e2","$e4","$e8","$join-bob","$join-dave"], + ["$alice","$create","$e12","$e14","$e15","$e2","$e8","$name","$topic"], + ["$alice","$create","$e11","$e16","$e17","$e19","$e7","$e8","$e9"], + ["$alice","$create","$e17","$e18","$e19","$e21","$e22","$pl"], + ["$alice","$create","$e13","$e14","$e17","$e19","$e20","$e8","$e9"], + ["$alice","$create","$e13","$e14","$e18","$e6","$e9","$name","$topic"], + ["$alice","$create","$e1","$e10","$e12","$e3","$e8","$e9","$jr"], + ["$alice","$create","$e13","$e14","$e15","$e18","$e19","$e4","$join-bob"], + ["$alice","$create","$e10","$e12","$e13","$e15","$e18","$e20","$e9"], + ["$alice","$create","$e11","$e14","$e19","$e20","$e4","$e6","$e9"], + ["$alice","$create","$e11","$e15","$e3","$e7","$e8","$e9","$jr"], + ["$alice","$create","$e13","$e15","$e16","$e4","$e8","$e9","$topic"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e15","$e16","$e4"], + ["$alice","$create","$e11","$e12","$e18","$e19","$e5","$e8","$e9"], + ["$alice","$create","$e12","$e13","$e15","$e16","$e2","$e5","$e8"], + ["$alice","$create","$e11","$e12","$e15","$e8","$e9","$join-charlie","$pl"], + ["$alice","$create","$e11","$e13","$e15","$e17","$e18","$e19","$pl"], + ["$alice","$create","$e13","$e14","$e15","$e16","$e17","$e3","$e6"], + ["$alice","$create","$e1","$e12","$e14","$e18","$e19","$e6","$name"], + ["$alice","$create","$e13","$e14","$e16","$e17","$e3","$e8","$pl"], + ["$alice","$create","$e11","$e15","$e16","$e17","$e4","$e5","$e6"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e15","$e9","$join-charlie"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e3","$e7","$join-dave"], + ["$alice","$create","$e10","$e12","$e13","$e14","$e17","$e4","$e7"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e4","$e9","$join-dave"], + ["$alice","$create","$e12","$e13","$e15","$e16","$e17","$e8","$e9"], + ["$alice","$create","$e10","$e12","$e13","$e15","$e16","$e8","$join-bob"], + ["$alice","$create","$e12","$e14","$e15","$e18","$e19","$e9","$join-dave"], + ["$alice","$create","$e10","$e12","$e14","$e17","$e18","$e2","$name"], + ["$alice","$create","$e10","$e12","$e13","$e14","$e17","$e7","$e8"], + ["$alice","$create","$e11","$e13","$e14","$e16","$e17","$e8","$topic"], + ["$alice","$create","$e10","$e12","$e13","$e14","$e3","$e5","$e9"], + ["$alice","$create","$e11","$e14","$e18","$e20","$e21","$e24","$topic"], + ["$alice","$create","$e11","$e12","$e14","$e15","$e7","$e8","$join-bob"], + ["$alice","$create","$e13","$e15","$e16","$e18","$e19","$join-bob","$pl"], + ["$alice","$create","$e10","$e12","$e13","$e15","$e16","$e2","$e6"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e19","$e7","$topic"], + ["$alice","$create","$e11","$e15","$e16","$e18","$join-dave"], + ["$alice","$create","$e13","$e15","$e16","$e17","$e19","$e9","$jr"], + ["$alice","$create","$e12","$e14","$e15","$e16","$e20","$e5"], + ["$alice","$create","$e11","$e12","$e15","$e16","$e4","$e7","$e8"], + ["$alice","$create","$e11","$e12","$e14","$e17","$e19","$e2","$name"], + ["$alice","$create","$e11","$e14","$e15","$e19","$e4","$e6","$join-bob"], + ["$alice","$create","$e11","$e12","$e13","$e16","$e8","$e9","$name"], + ["$alice","$create","$e1","$e11","$e4","$e7","$e8","$join-bob","$name"], + ["$alice","$create","$e1","$e10","$e15","$e17","$e21","$e22","$e7"], + ["$alice","$create","$e11","$e13","$e17","$e18","$e7","$e8","$e9"], + ["$alice","$create","$e10","$e11","$e12","$e14","$e15","$e16","$e8"], + ["$alice","$create","$e10","$e12","$e13","$e5","$e7","$e9","$join-dave"], + ["$alice","$create","$e11","$e15","$e17","$e9","$join-bob","$jr","$pl"], + ["$alice","$create","$e11","$e12","$e13","$e2","$e5","$e6","$join-dave"], + ["$alice","$create","$e10","$e11","$e12","$e13","$e4","$e6","$name"], + ["$alice","$create","$e10","$e12","$e13","$e15","$e16","$e6","$e7"], + ["$alice","$create","$e13","$e16","$e18","$e19","$e20","$e5","$e7"], + ["$alice","$create","$e14","$e24","$e25","$e7","$e9","$name","$topic"], + ["$alice","$create","$e1","$e12","$e15","$e17","$e19","$e20","$e9"], + ["$alice","$create","$e12","$e15","$e16","$e18","$e20","$e4","$topic"], + ["$alice","$create","$e10","$e13","$e14","$e5","$e9","$join-charlie","$jr"], + ["$alice","$create","$e12","$e16","$e21","$e23","$e8","$join-bob"], + ["$alice","$create","$e15","$e17","$e19","$e21","$e23","$e8"], + ["$alice","$create","$e12","$e13","$e15","$e17","$e20","$e21","$e5"], + ["$alice","$create","$e1","$e11","$e16","$e17","$e20","$e7","$e9"], + ["$alice","$create","$e12","$e14","$e15","$e19","$e2","$name"], + ["$alice","$create","$e11","$e16","$e17","$e18","$e2","$e4","$e7"], + ["$alice","$create","$e12","$e15","$e17","$e19","$e20","$e21","$e3"], + ["$alice","$create","$e12","$e20","$e21","$e6","$e9","$jr","$topic"], + ["$alice","$create","$e10","$e13","$e15","$e16","$e18","$e6","$join-dave"], + ["$alice","$create","$e13","$e15","$e16","$e18","$e19","$e9","$name"], + ["$alice","$create","$e11","$e12","$e13","$e4","$e7","$name"], + ["$alice","$create","$e10","$e11","$e14","$e15","$e5","$e7","$join-charlie"], + ["$alice","$create","$e1","$e10","$e11","$e13","$e4","$e7","$e8"], + ["$alice","$create","$e11","$e13","$e4","$e9","$join-dave","$jr"] + ], + "two branches": [ + ["$alice","$create","$e3","$e4","$e5","$e7","$join-bob","$join-charlie","$name"], + ["$alice","$create","$e2","$e5","$join-bob","$join-charlie","$name","$pl","$topic"], + ["$alice","$create","$e1","$e10","$e3","$e5","$e7","$e9","$name"], + ["$alice","$create","$e1","$e2","$e3","$join-bob","$name","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e5","$e6","$e7","$join-dave","$topic"], + ["$alice","$create","$e3","$e5","$e6","$e7","$e8","$join-bob","$name"], + ["$alice","$create","$e2","$e4","$e5","$e6","$join-charlie","$name","$pl"], + ["$alice","$create","$e1","$e2","$e4","$join-dave","$name","$pl","$topic"], + ["$alice","$create","$e1","$e4","$e5","$e6","$name","$pl","$topic"], + ["$alice","$create","$e3","$e4","$join-bob","$join-dave","$name","$pl","$topic"], + ["$alice","$create","$e2","$e3","$e5","$e6","$e7","$e8","$join-charlie"], + ["$alice","$create","$e1","$e3","$e4","$e5","$e7","$join-charlie","$pl"], + ["$alice","$create","$e1","$e3","$join-bob","$join-dave","$jr","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e5","$e6","$e7","$join-bob","$topic"], + ["$alice","$create","$e2","$e3","$e7","$e8","$join-bob","$name","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e5","$e6","$e7","$e9"], + ["$alice","$create","$e1","$e3","$e5","$e6","$e7","$join-bob","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e6","$e7","$name","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e5","$join-bob","$pl"], + ["$alice","$create","$e4","$e6","$e7","$e8","$join-dave","$pl","$topic"], + ["$alice","$create","$e1","$e3","$join-bob","$join-charlie","$join-dave","$jr","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e5","$e7","$join-dave","$topic"], + ["$alice","$create","$e2","$e4","$e5","$e6","$e7","$join-charlie","$pl"], + ["$alice","$create","$e1","$e5","$e7","$join-charlie","$jr","$name","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e6","$join-bob","$join-charlie","$pl"], + ["$alice","$create","$e1","$e2","$e4","$e5","$join-bob","$name","$pl"], + ["$alice","$create","$e4","$e5","$e6","$e8","$name","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e5","$e8","$e9","$name"], + ["$alice","$create","$e4","$e6","$e7","$e8","$e9","$join-bob","$name"], + ["$alice","$create","$e3","$e4","$e5","$e6","$e8","$e9","$topic"], + ["$alice","$create","$e1","$e3","$e6","$e7","$jr","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e3","$join-dave","$jr","$name","$pl"], + ["$alice","$create","$e4","$e6","$e7","$e8","$e9","$pl","$topic"], + ["$alice","$create","$e2","$e3","$e4","$e5","$name","$pl"], + ["$alice","$create","$e2","$e4","$e5","$e6","$join-charlie","$join-dave","$name"], + ["$alice","$create","$e1","$e3","$e4","$e6","$e8","$jr","$name"], + ["$alice","$create","$e2","$e4","$e6","$e7","$e8","$e9","$pl"], + ["$alice","$create","$e1","$e2","$e3","$join-charlie","$join-dave","$name","$pl"], + ["$alice","$create","$e1","$e3","$e4","$e5","$e7","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e5","$e8","$e9","$name","$topic"], + ["$alice","$create","$e1","$e4","$e5","$e6","$e7","$join-dave","$pl"], + ["$alice","$create","$e1","$e3","$e5","$e6","$e7","$e9","$join-bob"], + ["$alice","$create","$e1","$e2","$e3","$join-charlie","$name","$pl","$topic"], + ["$alice","$create","$e2","$e3","$e4","$join-bob","$name","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e5","$e6","$name","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e5","$e7","$name"], + ["$alice","$create","$e2","$e4","$e5","$join-bob","$join-dave","$name","$topic"], + ["$alice","$create","$e3","$e4","$e5","$e6","$join-bob","$join-charlie","$pl"], + ["$alice","$create","$e4","$e5","$e6","$e7","$e8","$join-charlie","$pl"], + ["$alice","$create","$e1","$e2","$e3","$join-bob","$join-dave","$name","$pl"], + ["$alice","$create","$e3","$e4","$e5","$e7","$e8","$name","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e7","$join-dave","$topic"], + ["$alice","$create","$e1","$e5","$e7","$join-bob","$name","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-bob","$join-charlie","$name"], + ["$alice","$create","$e1","$e2","$e3","$e6","$join-charlie","$join-dave","$topic"], + ["$alice","$create","$e1","$e3","$join-bob","$join-charlie","$join-dave","$jr","$topic"], + ["$alice","$create","$e1","$e3","$e5","$e6","$e7","$join-bob","$topic"], + ["$alice","$create","$e1","$e10","$e3","$e4","$e6","$e8","$e9"], + ["$alice","$create","$e2","$e6","$e7","$e8","$e9","$join-dave","$pl"], + ["$alice","$create","$e3","$e4","$e5","$e6","$e8","$name","$topic"], + ["$alice","$create","$e1","$e2","$join-bob","$join-charlie","$jr","$name","$pl"], + ["$alice","$create","$e2","$e3","$e4","$e5","$e6","$e9","$join-dave"], + ["$alice","$create","$e11","$e3","$e4","$e8","$name","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e4","$jr","$name","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e5","$join-bob","$name"], + ["$alice","$create","$e1","$e4","$e6","$join-dave","$jr","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e5","$e6","$e7"], + ["$alice","$create","$e1","$e3","$e4","$e5","$name","$pl","$topic"], + ["$alice","$create","$e2","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"], + ["$alice","$create","$e3","$e4","$e5","$e6","$e7","$join-bob","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e4","$jr","$name","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-charlie","$jr","$name"], + ["$alice","$create","$e2","$e3","$e4","$join-bob","$jr","$name","$pl"], + ["$alice","$create","$e1","$e2","$e3","$join-charlie","$name","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e5","$e6","$e7","$e9","$topic"], + ["$alice","$create","$e2","$e3","$e4","$e5","$e7","$jr","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e7","$join-bob","$join-charlie"], + ["$alice","$create","$e2","$e3","$e4","$join-bob","$join-charlie","$jr","$name"], + ["$alice","$create","$e2","$e3","$e5","$e6","$join-bob","$name"], + ["$alice","$create","$e4","$e5","$e7","$join-bob","$join-dave","$name","$pl"], + ["$alice","$create","$e2","$e3","$e4","$e6","$e7","$e8","$e9"], + ["$alice","$create","$e4","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-bob","$name","$pl"], + ["$alice","$create","$e3","$e4","$e5","$join-dave","$jr","$name","$topic"], + ["$alice","$create","$e1","$e3","$e7","$e8","$name","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e5","$e6","$join-bob","$name"], + ["$alice","$create","$e1","$e2","$e3","$e7","$join-bob","$name","$topic"], + ["$alice","$create","$e1","$e2","$e4","$e5","$e6","$pl","$topic"], + ["$alice","$create","$e3","$e4","$e5","$e6","$join-charlie","$jr","$topic"], + ["$alice","$create","$e1","$e4","$e5","$e6","$join-charlie","$join-dave","$name"], + ["$alice","$create","$e1","$e2","$e4","$jr","$name","$pl","$topic"], + ["$alice","$create","$e3","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"], + ["$alice","$create","$e1","$e3","$join-charlie","$join-dave","$name","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e5","$name","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e4","$e5","$e6","$join-bob","$topic"], + ["$alice","$create","$e2","$e3","$e4","$join-bob","$join-charlie","$jr","$pl"], + ["$alice","$create","$e3","$e4","$e5","$join-bob","$jr","$name","$topic"], + ["$alice","$create","$e1","$e4","$join-bob","$join-charlie","$join-dave","$name","$pl"], + ["$alice","$create","$e3","$e5","$e6","$join-bob","$join-dave","$jr","$pl"], + ["$alice","$create","$e2","$e3","$e4","$e5","$e7","$join-charlie","$name"], + ["$alice","$create","$e2","$e3","$e4","$e5","$join-charlie","$join-dave","$pl"], + ["$alice","$create","$e1","$e3","$join-bob","$join-charlie","$name","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e5","$e6","$pl"], + ["$alice","$create","$e2","$e3","$e5","$e6","$jr","$name","$topic"], + ["$alice","$create","$e1","$e6","$e7","$e8","$e9","$pl","$topic"], + ["$alice","$create","$e2","$e3","$e4","$e5","$join-charlie","$pl","$topic"], + ["$alice","$create","$e2","$e4","$e5","$e6","$e7","$join-dave"], + ["$alice","$create","$e2","$e3","$e4","$e6","$e7","$join-charlie","$name"], + ["$alice","$create","$e1","$e4","$join-bob","$join-charlie","$join-dave","$name","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e6","$e7","$e8","$join-dave"], + ["$alice","$create","$e1","$e2","$e4","$join-bob","$join-dave","$pl","$topic"], + ["$alice","$create","$e1","$e2","$join-bob","$join-charlie","$join-dave","$jr","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-bob","$jr","$name"], + ["$alice","$create","$e1","$e4","$e5","$join-bob","$name","$pl","$topic"], + ["$alice","$create","$e2","$e3","$e4","$join-bob","$join-dave","$jr","$pl"], + ["$alice","$create","$e1","$e3","$e4","$e5","$e6","$name","$pl"], + ["$alice","$create","$e1","$e2","$e3","$join-bob","$join-dave","$jr","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-charlie","$jr","$name"], + ["$alice","$create","$e11","$e2","$e4","$e5","$e7","$e8","$e9"], + ["$alice","$create","$e1","$e2","$join-bob","$join-charlie","$join-dave","$name","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-charlie","$name","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e5","$e6","$join-charlie","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e5","$join-dave","$name","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-bob","$join-charlie","$name"], + ["$alice","$create","$e1","$e3","$e4","$join-charlie","$join-dave","$name","$topic"], + ["$alice","$create","$e1","$e2","$e4","$e5","$e6","$name","$topic"], + ["$alice","$create","$e1","$e2","$e4","$join-bob","$join-dave","$pl","$topic"], + ["$alice","$create","$e2","$e5","$e6","$e7","$join-bob","$join-charlie","$pl"], + ["$alice","$create","$e1","$e2","$e3","$join-charlie","$join-dave","$jr","$name"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e5","$name","$pl"], + ["$alice","$create","$e2","$e3","$e4","$e5","$join-bob","$name","$topic"], + ["$alice","$create","$e2","$e4","$e5","$e7","$join-bob","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e4","$e5","$e7","$join-bob","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-charlie","$join-dave","$topic"], + ["$alice","$create","$e1","$e4","$e5","$e6","$join-bob","$name","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e5","$join-dave","$name","$topic"], + ["$alice","$create","$e1","$e3","$e5","$e7","$join-charlie","$join-dave","$jr"], + ["$alice","$create","$e2","$e3","$e4","$e6","$join-dave","$name","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-bob","$pl","$topic"], + ["$alice","$create","$e1","$e5","$e6","$e7","$name","$pl","$topic"], + ["$alice","$create","$e2","$e3","$join-bob","$join-charlie","$jr","$name","$topic"], + ["$alice","$create","$e1","$e2","$e3","$join-bob","$join-dave","$jr","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e6","$e7","$join-charlie","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-bob","$name","$topic"], + ["$alice","$create","$e2","$e4","$join-bob","$join-dave","$name","$pl","$topic"], + ["$alice","$create","$e1","$e3","$join-bob","$jr","$name","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e4","$join-charlie","$join-dave","$name","$pl"], + ["$alice","$create","$e1","$e5","$e6","$join-bob","$join-charlie","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e5","$e6","$e7","$name","$topic"], + ["$alice","$create","$e1","$e7","$e8","$join-bob","$jr","$name","$topic"], + ["$alice","$create","$e1","$e3","$e5","$join-bob","$join-charlie","$name","$topic"], + ["$alice","$create","$e1","$e2","$join-bob","$join-charlie","$join-dave","$name","$topic"], + ["$alice","$create","$e3","$e4","$e5","$e6","$e8","$join-charlie","$pl"], + ["$alice","$create","$e1","$e2","$e3","$join-bob","$join-charlie","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e4","$join-dave","$jr","$name","$topic"], + ["$alice","$create","$e1","$e2","$e3","$join-charlie","$join-dave","$name","$topic"], + ["$alice","$create","$e3","$e4","$e5","$join-bob","$join-dave","$name","$topic"], + ["$alice","$create","$e1","$e2","$e4","$join-charlie","$join-dave","$name","$pl"], + ["$alice","$create","$e3","$e4","$e6","$e7","$join-charlie","$join-dave","$jr"], + ["$alice","$create","$e2","$e3","$e4","$e6","$join-bob","$pl","$topic"], + ["$alice","$create","$e2","$e4","$e5","$e7","$e8","$join-dave","$pl"], + ["$alice","$create","$e2","$e3","$e5","$e6","$e7","$jr","$name"], + ["$alice","$create","$e2","$e3","$e4","$e6","$e7","$name","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e5","$e6","$join-bob","$pl"], + ["$alice","$create","$e4","$e5","$e6","$e7","$e8","$e9","$name"], + ["$alice","$create","$e1","$e2","$e4","$e5","$e6","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$name","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e4","$join-bob","$jr","$name","$pl"], + ["$alice","$create","$e2","$e4","$e5","$e6","$join-bob","$name","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-bob","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e5","$e6","$join-dave","$name"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-charlie","$join-dave","$pl"], + ["$alice","$create","$e3","$e4","$join-charlie","$jr","$name","$pl","$topic"], + ["$alice","$create","$e3","$e4","$e5","$e8","$e9","$join-dave","$name"], + ["$alice","$create","$e2","$e4","$e5","$e7","$e8","$join-dave","$topic"], + ["$alice","$create","$e1","$e2","$e3","$join-charlie","$join-dave","$pl","$topic"], + ["$alice","$create","$e2","$e4","$e5","$e7","$e8","$join-dave","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e5","$name","$topic"], + ["$alice","$create","$e1","$e2","$e6","$e7","$e8","$join-charlie","$join-dave"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e5","$join-charlie","$pl"], + ["$alice","$create","$e3","$e5","$join-bob","$join-charlie","$join-dave","$jr","$topic"], + ["$alice","$create","$e1","$e2","$e3","$join-charlie","$jr","$name","$topic"], + ["$alice","$create","$e1","$e4","$join-bob","$jr","$name","$pl","$topic"], + ["$alice","$create","$e2","$e4","$e5","$join-bob","$join-dave","$name","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e5","$e8","$name","$pl"], + ["$alice","$create","$e2","$e3","$e4","$join-charlie","$jr","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e5","$join-bob","$join-dave","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e8","$e9","$topic"], + ["$alice","$create","$e2","$e3","$e5","$e6","$e9","$join-charlie","$name"], + ["$alice","$create","$e1","$e2","$e4","$e6","$e7","$join-bob","$join-dave"], + ["$alice","$create","$e1","$e3","$e4","$e6","$e8","$join-bob","$join-dave"], + ["$alice","$create","$e1","$e2","$e4","$e6","$e8","$name","$topic"], + ["$alice","$create","$e1","$e2","$e4","$e5","$join-dave","$pl","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e6","$e9","$join-charlie","$name"], + ["$alice","$create","$e1","$e2","$e3","$e5","$join-dave","$name","$pl"], + ["$alice","$create","$e1","$e2","$e4","$e5","$e6","$name","$topic"], + ["$alice","$create","$e1","$e5","$e6","$e7","$join-dave","$name","$topic"], + ["$alice","$create","$e1","$e4","$e6","$e8","$name","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e3","$join-charlie","$jr","$pl","$topic"], + ["$alice","$create","$e2","$e3","$e5","$e6","$e8","$name","$topic"], + ["$alice","$create","$e2","$e4","$e5","$e7","$join-dave","$name","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e6","$join-bob","$name","$pl"], + ["$alice","$create","$e3","$e5","$join-bob","$join-charlie","$jr","$name","$topic"], + ["$alice","$create","$e1","$e3","$e5","$e6","$join-bob","$name","$pl"], + ["$alice","$create","$e1","$e4","$e6","$e7","$e9","$join-bob","$topic"], + ["$alice","$create","$e5","$e7","$join-bob","$jr","$name","$pl","$topic"], + ["$alice","$create","$e3","$e4","$e6","$e8","$join-charlie","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e5","$join-bob","$name","$topic"], + ["$alice","$create","$e3","$e4","$e6","$join-bob","$name","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-dave","$pl","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e5","$join-dave","$topic"], + ["$alice","$create","$e1","$e5","$e6","$e8","$e9","$join-dave","$topic"], + ["$alice","$create","$e2","$e5","$e6","$e7","$e8","$join-dave","$name"], + ["$alice","$create","$e1","$e4","$e5","$e6","$e8","$name","$topic"], + ["$alice","$create","$e1","$e2","$e3","$join-bob","$join-dave","$jr","$pl"], + ["$alice","$create","$e1","$e3","$e4","$join-bob","$join-dave","$jr","$name"], + ["$alice","$create","$e1","$e3","$e5","$e7","$e8","$join-charlie","$jr"], + ["$alice","$create","$e4","$e5","$e6","$e7","$e8","$name","$pl"], + ["$alice","$create","$e1","$e4","$join-charlie","$join-dave","$jr","$name","$topic"], + ["$alice","$create","$e2","$e3","$e5","$e7","$join-bob","$join-dave","$name"], + ["$alice","$create","$e4","$e5","$e8","$join-charlie","$name","$pl","$topic"], + ["$alice","$create","$e1","$e4","$e5","$e6","$e7","$pl","$topic"], + ["$alice","$create","$e2","$e3","$e5","$e6","$e7","$name","$topic"], + ["$alice","$create","$e1","$e2","$e5","$e6","$e7","$join-dave","$pl"], + ["$alice","$create","$e2","$e3","$e4","$e5","$e7","$e8","$join-dave"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-dave","$name","$pl"], + ["$alice","$create","$e10","$e3","$e4","$e6","$e9","$name","$topic"], + ["$alice","$create","$e2","$e3","$e5","$e6","$e7","$join-bob","$pl"], + ["$alice","$create","$e3","$e5","$e6","$e7","$join-dave","$pl"], + ["$alice","$create","$e1","$e2","$e3","$join-charlie","$join-dave","$jr","$pl"], + ["$alice","$create","$e3","$e4","$e6","$e7","$e8","$join-dave","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e4","$join-bob","$join-dave","$pl"], + ["$alice","$create","$e2","$e3","$e4","$join-charlie","$join-dave","$jr","$topic"], + ["$alice","$create","$e2","$e3","$e4","$e5","$e6","$e7","$join-charlie"], + ["$alice","$create","$e1","$e10","$e3","$e6","$e7","$join-dave","$topic"], + ["$alice","$create","$e1","$e2","$e5","$join-bob","$join-dave","$name","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e6","$e7","$join-charlie","$join-dave"], + ["$alice","$create","$e1","$e2","$e3","$e5","$e7","$name","$topic"], + ["$alice","$create","$e3","$e8","$e9","$join-dave","$jr","$name","$topic"], + ["$alice","$create","$e3","$e7","$e8","$e9","$join-bob","$topic"], + ["$alice","$create","$e1","$e2","$e3","$e4","$e6","$name","$pl"], + ["$alice","$create","$e1","$e3","$e6","$e8","$e9","$join-dave","$name"], + ["$alice","$create","$e4","$e7","$e8","$join-dave","$name","$pl"], + ["$alice","$create","$e1","$e2","$e3","$e7","$join-bob","$name","$topic"], + ["$alice","$create","$e3","$e4","$e5","$e6","$join-charlie","$join-dave","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e6","$e8","$name","$topic"], + ["$alice","$create","$e1","$e3","$e4","$e6","$e9","$join-charlie","$name"], + ["$alice","$create","$e2","$e4","$e6","$e7","$join-charlie","$name","$pl"], + ["$alice","$create","$e10","$e4","$e5","$e6","$e9","$join-bob","$topic"], + ["$alice","$create","$e1","$e3","$e5","$join-charlie","$join-dave","$jr","$name"] + ], + "two branches after shared history": [ + ["$alice","$create","$e11","$e12","$e15","$e3","$e5","$e8","$e9"], + ["$alice","$create","$e1","$e11","$e12","$e13","$e14","$e9","$name"], + ["$alice","$create","$e13","$e14","$e15","$e2","$e5","$e8","$e9"], + ["$alice","$create","$e11","$e13","$e16","$e17","$e3","$e6","$e9"], + ["$alice","$create","$e11","$e13","$e14","$e15","$e3","$e5","$e9"], + ["$alice","$create","$e10","$e13","$e14","$e15","$e5","$e6","$e9"], + ["$alice","$create","$e11","$e13","$e14","$e15","$e18","$join-charlie","$jr"], + ["$alice","$create","$e10","$e13","$e15","$e4","$e9","$jr"], + ["$alice","$create","$e1","$e11","$e12","$e13","$e15","$e5","$e9"], + ["$alice","$create","$e11","$e13","$e14","$e16","$e17","$e8","$e9"], + ["$alice","$create","$e10","$e12","$e13","$e3","$e8","$jr","$name"], + ["$alice","$create","$e1","$e11","$e12","$e14","$e15","$e8","$e9"], + ["$alice","$create","$e10","$e11","$e12","$e13","$e5","$e8","$pl"], + ["$alice","$create","$e13","$e14","$e15","$e5","$e8","$e9","$topic"], + ["$alice","$create","$e11","$e14","$e15","$e17","$e4","$e6","$e7"], + ["$alice","$create","$e10","$e11","$e6","$e7","$e8","$e9","$join-bob"], + ["$alice","$create","$e12","$e13","$e14","$e17","$e19","$e7","$topic"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e7","$e8","$e9"], + ["$alice","$create","$e11","$e13","$e14","$e4","$e8","$e9"], + ["$alice","$create","$e11","$e14","$e15","$e4","$e7","$e9","$jr"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e2","$e9","$name"], + ["$alice","$create","$e12","$e2","$e7","$e9","$jr","$pl","$topic"], + ["$alice","$create","$e11","$e13","$e14","$e15","$e4","$e9","$join-charlie"], + ["$alice","$create","$e12","$e13","$e14","$e15","$e6","$e9","$topic"], + ["$alice","$create","$e1","$e16","$e18","$e4","$e8","$e9","$topic"], + ["$alice","$create","$e11","$e12","$e2","$e9","$join-bob","$join-dave","$name"], + ["$alice","$create","$e1","$e11","$e14","$e17","$e3","$e4","$e5"], + ["$alice","$create","$e10","$e11","$e12","$e4","$join-dave","$jr","$name"], + ["$alice","$create","$e11","$e13","$e4","$e6","$e8","$e9","$topic"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e15","$e4"], + ["$alice","$create","$e12","$e13","$e15","$e16","$e5","$e8","$e9"], + ["$alice","$create","$e11","$e12","$e16","$e17","$e5","$e6","$e9"], + ["$alice","$create","$e1","$e10","$e11","$e13","$e7","$e9","$jr"], + ["$alice","$create","$e12","$e14","$e4","$e5","$e9","$name","$topic"], + ["$alice","$create","$e12","$e13","$e15","$e8","$e9"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e7","$e9","$pl"], + ["$alice","$create","$e11","$e12","$e2","$e6","$e7","$e8","$join-bob"], + ["$alice","$create","$e1","$e10","$e11","$e14","$e16","$e9","$name"], + ["$alice","$create","$e10","$e12","$e15","$e18","$e4","$e5","$name"], + ["$alice","$create","$e10","$e12","$e4","$e6","$e7","$e8","$jr"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e15","$e8","$join-charlie"], + ["$alice","$create","$e1","$e10","$e12","$e3","$e6","$e8","$e9"], + ["$alice","$create","$e11","$e13","$e14","$e15","$e16","$e5","$topic"], + ["$alice","$create","$e12","$e14","$e4","$e7","$join-charlie","$jr","$topic"], + ["$alice","$create","$e10","$e11","$e12","$e13","$e14","$e9","$topic"], + ["$alice","$create","$e10","$e11","$e13","$e2","$e4","$e9","$name"], + ["$alice","$create","$e10","$e12","$e13","$e4","$e6","$e7","$e8"], + ["$alice","$create","$e10","$e12","$e14","$e7","$name","$pl","$topic"], + ["$alice","$create","$e1","$e10","$e11","$e6","$e7","$name"], + ["$alice","$create","$e1","$e11","$e12","$e13","$e15","$e9","$pl"], + ["$alice","$create","$e1","$e10","$e11","$e12","$e5","$e6","$e9"], + ["$alice","$create","$e10","$e12","$e13","$e14","$e2","$e9","$pl"], + ["$alice","$create","$e11","$e12","$e2","$e5","$e8","$e9","$topic"], + ["$alice","$create","$e11","$e8","$e9","$join-dave","$jr","$name","$topic"], + ["$alice","$create","$e10","$e4","$e6","$e7","$e9","$join-dave","$topic"], + ["$alice","$create","$e1","$e10","$e11","$e15","$e8","$e9","$join-dave"], + ["$alice","$create","$e10","$e11","$e3","$e5","$e6","$join-charlie","$pl"], + ["$alice","$create","$e10","$e11","$e2","$e8","$jr","$name","$topic"], + ["$alice","$create","$e1","$e11","$e13","$e15","$e7","$e9","$name"], + ["$alice","$create","$e1","$e11","$e12","$e2","$e4","$e7","$name"], + ["$alice","$create","$e1","$e10","$e12","$e4","$e7","$jr","$topic"], + ["$alice","$create","$e10","$e11","$e12","$e2","$e4","$e8","$e9"], + ["$alice","$create","$e11","$e6","$e8","$e9","$join-bob","$jr","$topic"], + ["$alice","$create","$e1","$e10","$e11","$e5","$e7","$e9","$join-charlie"], + ["$alice","$create","$e11","$e13","$e14","$e6","$e8","$e9","$join-charlie"], + ["$alice","$create","$e10","$e11","$e2","$e8","$e9","$jr","$pl"], + ["$alice","$create","$e12","$e16","$e5","$e6","$e7","$e9"], + ["$alice","$create","$e12","$e13","$e14","$e15","$e16","$e9","$name"], + ["$alice","$create","$e10","$e12","$e5","$e6","$e8","$e9","$jr"], + ["$alice","$create","$e10","$e11","$e12","$e5","$e6","$e7","$e9"], + ["$alice","$create","$e10","$e12","$e14","$e15","$e7","$e9","$name"], + ["$alice","$create","$e10","$e11","$e13","$e14","$e4","$e8","$join-dave"], + ["$alice","$create","$e1","$e11","$e3","$e6","$e9","$join-bob","$jr"], + ["$alice","$create","$e10","$e11","$e3","$e5","$e6","$e7","$jr"], + ["$alice","$create","$e10","$e11","$e13","$e14","$e6","$e7","$e9"], + ["$alice","$create","$e10","$e4","$e6","$e7","$e8","$e9","$join-dave"], + ["$alice","$create","$e10","$e12","$e2","$e6","$e8","$pl","$topic"], + ["$alice","$create","$e10","$e11","$e16","$e5","$e7","$e8","$e9"], + ["$alice","$create","$e1","$e11","$e2","$e6","$e7","$e8","$e9"], + ["$alice","$create","$e10","$e11","$e12","$e13","$e2","$e5","$e7"], + ["$alice","$create","$e12","$e14","$e15","$e5","$e7","$e8","$name"], + ["$alice","$create","$e10","$e11","$e13","$e14","$e16","$e3","$e9"], + ["$alice","$create","$e11","$e3","$e5","$join-dave","$jr","$name","$topic"], + ["$alice","$create","$e11","$e13","$e15","$e16","$e6","$e8","$topic"], + ["$alice","$create","$e14","$e16","$e18","$e2","$e5","$e6","$e7"], + ["$alice","$create","$e10","$e12","$e3","$e7","$jr","$name","$pl"], + ["$alice","$create","$e10","$e11","$e13","$e14","$e5","$e9","$join-dave"], + ["$alice","$create","$e10","$e13","$e14","$e17","$e19","$e4","$join-charlie"], + ["$alice","$create","$e10","$e13","$e3","$e7","$e8","$e9"], + ["$alice","$create","$e10","$e11","$e13","$e9","$join-charlie","$join-dave","$jr"], + ["$alice","$create","$e10","$e11","$e12","$e6","$e7","$join-charlie","$jr"], + ["$alice","$create","$e1","$e11","$e14","$e2","$e8","$e9","$name"], + ["$alice","$create","$e10","$e11","$e13","$e9","$jr","$name","$pl"], + ["$alice","$create","$e10","$e11","$e6","$e8","$e9","$join-bob","$join-dave"], + ["$alice","$create","$e13","$e15","$e4","$e5","$e8","$name","$pl"], + ["$alice","$create","$e13","$e14","$e4","$e5","$e6","$e9","$topic"], + ["$alice","$create","$e10","$e11","$e3","$e4","$e7","$e8","$e9"], + ["$alice","$create","$e11","$e12","$e13","$e8","$e9","$join-bob","$join-dave"], + ["$alice","$create","$e13","$e14","$e5","$e7","$e9","$join-dave","$pl"], + ["$alice","$create","$e11","$e13","$e14","$e16","$e17","$e5"], + ["$alice","$create","$e1","$e12","$e3","$e5","$e6","$e8","$e9"], + ["$alice","$create","$e11","$e17","$e18","$e19","$e4","$e8","$pl"], + ["$alice","$create","$e10","$e12","$e16","$e3","$e8","$join-bob","$name"], + ["$alice","$create","$e11","$e13","$e15","$e16","$e17","$e3","$e4"], + ["$alice","$create","$e10","$e11","$e4","$e7","$e9","$pl","$topic"], + ["$alice","$create","$e11","$e13","$e15","$e3","$e8","$e9","$jr"], + ["$alice","$create","$e11","$e12","$e14","$e18","$e5","$e9","$join-charlie"], + ["$alice","$create","$e10","$e3","$e5","$e6","$e8","$e9","$jr"], + ["$alice","$create","$e10","$e11","$e12","$e3","$e4","$e8","$join-dave"], + ["$alice","$create","$e11","$e13","$e14","$e3","$e4","$e5","$join-dave"], + ["$alice","$create","$e11","$e13","$e14","$e15","$e7","$e8"], + ["$alice","$create","$e10","$e11","$e12","$e4","$e9","$jr","$topic"], + ["$alice","$create","$e13","$e14","$e15","$e2","$e5","$e9","$topic"], + ["$alice","$create","$e10","$e11","$e4","$e5","$e8","$e9","$join-charlie"], + ["$alice","$create","$e10","$e13","$e14","$e4","$e5","$join-bob","$topic"], + ["$alice","$create","$e10","$e12","$e13","$e15","$e4","$e6","$topic"], + ["$alice","$create","$e10","$e11","$e13","$e14","$e15","$e9","$jr"], + ["$alice","$create","$e10","$e12","$e13","$e14","$e16","$e2","$e8"], + ["$alice","$create","$e10","$e11","$e5","$e8","$e9","$jr","$topic"], + ["$alice","$create","$e1","$e11","$e12","$e13","$e3","$e7","$name"], + ["$alice","$create","$e13","$e14","$e16","$e2","$e7","$e8","$e9"], + ["$alice","$create","$e13","$e15","$e17","$e19","$e4","$e8","$name"], + ["$alice","$create","$e10","$e11","$e13","$e14","$e16","$e17","$e2"], + ["$alice","$create","$e1","$e11","$e12","$e5","$e8","$e9","$join-bob"], + ["$alice","$create","$e10","$e11","$e5","$e6","$e7","$e9","$jr"], + ["$alice","$create","$e11","$e12","$e14","$e15","$e8","$pl","$topic"], + ["$alice","$create","$e10","$e11","$e13","$e14","$join-charlie","$join-dave"], + ["$alice","$create","$e1","$e10","$e12","$e16","$e6","$e8","$e9"], + ["$alice","$create","$e11","$e13","$e17","$e2","$e8","$e9","$join-dave"], + ["$alice","$create","$e10","$e11","$e12","$e6","$e7","$e8","$topic"], + ["$alice","$create","$e12","$e14","$e15","$e16","$e6","$e8","$name"], + ["$alice","$create","$e10","$e12","$e13","$e15","$e16","$e5","$e7"], + ["$alice","$create","$e10","$e11","$e13","$e15","$e16","$e17","$jr"], + ["$alice","$create","$e10","$e13","$e14","$e15","$e16","$e17","$e9"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e2","$e4","$e5"], + ["$alice","$create","$e10","$e11","$e9","$join-charlie","$join-dave","$jr","$topic"], + ["$alice","$create","$e10","$e14","$e16","$e17","$e4","$e5","$e9"], + ["$alice","$create","$e10","$e12","$e13","$e2","$e4","$e8","$pl"], + ["$alice","$create","$e10","$e11","$e4","$e6","$e8","$e9","$name"], + ["$alice","$create","$e11","$e12","$e13","$e7","$e8","$e9","$join-charlie"], + ["$alice","$create","$e11","$e12","$e14","$e15","$e17","$e8","$topic"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e15","$e3","$join-bob"], + ["$alice","$create","$e12","$e13","$e15","$e6","$e8","$e9","$topic"], + ["$alice","$create","$e10","$e11","$e12","$e6","$e7","$join-bob","$topic"], + ["$alice","$create","$e1","$e11","$e12","$e13","$e5","$jr"], + ["$alice","$create","$e13","$e14","$e15","$e16","$e17","$e8","$join-charlie"], + ["$alice","$create","$e1","$e10","$e13","$e15","$e7","$e9","$topic"], + ["$alice","$create","$e11","$e14","$e2","$e7","$e9","$join-bob","$topic"], + ["$alice","$create","$e1","$e11","$e12","$e2","$e9","$join-bob","$topic"], + ["$alice","$create","$e11","$e13","$e14","$e4","$e6","$e7","$e9"], + ["$alice","$create","$e10","$e11","$e12","$e13","$e14","$e6","$join-dave"], + ["$alice","$create","$e11","$e12","$e13","$e3","$e5","$e6","$e7"], + ["$alice","$create","$e10","$e12","$e15","$e7","$e8","$e9","$name"], + ["$alice","$create","$e11","$e13","$e6","$e8","$e9","$join-dave","$jr"], + ["$alice","$create","$e11","$e12","$e15","$e16","$e8","$name","$topic"], + ["$alice","$create","$e10","$e12","$e13","$e15","$e5","$e7","$jr"], + ["$alice","$create","$e10","$e11","$e12","$e14","$e4","$e8","$jr"], + ["$alice","$create","$e10","$e11","$e13","$e4","$e6","$join-dave","$name"], + ["$alice","$create","$e10","$e5","$e6","$e7","$e8","$e9","$name"], + ["$alice","$create","$e10","$e12","$e15","$e2","$e3","$e5","$name"], + ["$alice","$create","$e10","$e11","$e3","$e5","$e8","$jr","$topic"], + ["$alice","$create","$e10","$e11","$e12","$e4","$e6","$e7","$e8"], + ["$alice","$create","$e10","$e11","$e13","$e14","$e4","$e5","$e6"], + ["$alice","$create","$e11","$e13","$e7","$e9","$jr","$name","$topic"], + ["$alice","$create","$e12","$e13","$e14","$e15","$join-dave"], + ["$alice","$create","$e11","$e12","$e16","$e2","$e4","$name","$topic"], + ["$alice","$create","$e10","$e11","$e12","$e2","$e7","$e8","$topic"], + ["$alice","$create","$e12","$e13","$e14","$e7","$e9","$join-bob","$join-dave"], + ["$alice","$create","$e1","$e11","$e12","$e13","$e2","$e3","$e8"], + ["$alice","$create","$e1","$e12","$e13","$e15","$e16","$e6","$e9"], + ["$alice","$create","$e1","$e11","$e13","$e4","$e7","$e8","$join-bob"], + ["$alice","$create","$e10","$e12","$e6","$e7","$e8","$e9","$join-bob"], + ["$alice","$create","$e10","$e13","$e2","$e3","$e6","$e8","$join-dave"], + ["$alice","$create","$e12","$e13","$e14","$e8","$e9","$join-charlie","$pl"], + ["$alice","$create","$e10","$e12","$e13","$e15","$e3","$e9","$topic"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e6","$join-bob","$jr"], + ["$alice","$create","$e10","$e11","$e12","$e13","$e4","$e8","$join-charlie"], + ["$alice","$create","$e10","$e11","$e12","$e15","$e8","$pl","$topic"], + ["$alice","$create","$e11","$e16","$e17","$e18","$e5","$e9","$join-charlie"], + ["$alice","$create","$e10","$e15","$e2","$e7","$e9","$join-bob","$jr"], + ["$alice","$create","$e12","$e5","$e7","$e8","$e9","$join-dave","$topic"], + ["$alice","$create","$e10","$e11","$e7","$e8","$e9","$join-dave","$jr"], + ["$alice","$create","$e10","$e11","$e12","$e8","$e9","$join-dave","$pl"], + ["$alice","$create","$e12","$e13","$e4","$e5","$e7","$e9","$topic"], + ["$alice","$create","$e11","$e12","$e14","$e15","$e16","$e7","$e8"], + ["$alice","$create","$e10","$e12","$e13","$e14","$e4","$e7","$e9"], + ["$alice","$create","$e10","$e11","$e14","$e15","$e16","$e6","$topic"], + ["$alice","$create","$e1","$e11","$e12","$e3","$e4","$e7","$topic"], + ["$alice","$create","$e1","$e10","$e12","$e7","$join-dave","$jr","$name"], + ["$alice","$create","$e1","$e10","$e11","$e12","$e13","$e16","$e5"], + ["$alice","$create","$e11","$e12","$e13","$e14","$e15","$e17","$e2"], + ["$alice","$create","$e10","$e2","$e3","$e5","$e7","$e8","$join-dave"], + ["$alice","$create","$e1","$e11","$e14","$e2","$e8","$e9","$topic"], + ["$alice","$create","$e10","$e11","$e15","$e17","$e18","$e2","$e6"], + ["$alice","$create","$e1","$e10","$e11","$e15","$e2","$e4","$e6"], + ["$alice","$create","$e10","$e11","$e13","$e8","$e9","$jr"], + ["$alice","$create","$e10","$e11","$e12","$e14","$e15","$e7","$join-charlie"], + ["$alice","$create","$e11","$e12","$e14","$e15","$e16","$e8","$e9"], + ["$alice","$create","$e10","$e15","$e17","$e7","$e9","$name"], + ["$alice","$create","$e10","$e11","$e12","$e13","$e3","$e5","$e9"], + ["$alice","$create","$e10","$e11","$e15","$e16","$e17","$e7","$e9"], + ["$alice","$create","$e12","$e13","$e14","$e15","$e16","$e3","$e4"], + ["$alice","$create","$e12","$e4","$e7","$e8","$join-charlie","$name","$topic"], + ["$alice","$create","$e13","$e14","$e15","$e16","$e4","$e8","$e9"], + ["$alice","$create","$e10","$e11","$e13","$e14","$e15","$e16","$e8"], + ["$alice","$create","$e10","$e11","$e13","$e5","$e7","$join-bob","$pl"], + ["$alice","$create","$e10","$e11","$e3","$join-bob","$jr","$pl","$topic"], + ["$alice","$create","$e10","$e11","$e3","$e6","$e8","$join-dave","$name"], + ["$alice","$create","$e10","$e12","$e2","$e3","$e8","$e9","$join-bob"], + ["$alice","$create","$e10","$e6","$e7","$e8","$join-dave","$name","$topic"], + ["$alice","$create","$e10","$e11","$e2","$e8","$e9","$join-dave","$jr"], + ["$alice","$create","$e10","$e12","$e13","$e14","$e8","$e9","$topic"], + ["$alice","$create","$e10","$e12","$e9","$join-dave","$name","$pl","$topic"], + ["$alice","$create","$e13","$e15","$e16","$e18","$e19","$join-dave"], + ["$alice","$create","$e12","$e3","$e4","$e6","$e7","$e9","$jr"], + ["$alice","$create","$e11","$e14","$e6","$e9","$name","$pl","$topic"], + ["$alice","$create","$e10","$e12","$e13","$e4","$e5","$e8","$e9"], + ["$alice","$create","$e10","$e11","$e12","$e13","$e4","$e5","$e6"], + ["$alice","$create","$e12","$e14","$e2","$e3","$e4","$name","$pl"], + ["$alice","$create","$e10","$e11","$e13","$e14","$e5","$e8","$e9"], + ["$alice","$create","$e10","$e12","$e13","$e3","$e7","$e9","$join-charlie"], + ["$alice","$create","$e11","$e12","$e13","$e3","$e5","$e7","$e9"], + ["$alice","$create","$e1","$e11","$e12","$e9","$join-charlie","$join-dave","$jr"], + ["$alice","$create","$e11","$e12","$e4","$e7","$e8","$e9","$jr"], + ["$alice","$create","$e10","$e12","$e14","$e15","$e6","$e7","$e8"], + ["$alice","$create","$e10","$e11","$e12","$e14","$e6","$e9","$jr"], + ["$alice","$create","$e12","$e14","$e15","$e2","$e4","$e7","$e9"], + ["$alice","$create","$e10","$e11","$e4","$e5","$e9","$jr","$topic"], + ["$alice","$create","$e11","$e13","$e3","$e5","$e9","$jr","$topic"], + ["$alice","$create","$e1","$e11","$e6","$e7","$e9","$pl","$topic"], + ["$alice","$create","$e1","$e11","$e12","$e13","$e15","$e16","$join-bob"], + ["$alice","$create","$e11","$e13","$e2","$e5","$e6","$e8","$e9"], + ["$alice","$create","$e10","$e14","$e16","$e6","$e9","$pl","$topic"], + ["$alice","$create","$e14","$e15","$e16","$e6","$e8","$e9","$pl"], + ["$alice","$create","$e10","$e12","$e14","$e15","$e16","$e7","$join-dave"], + ["$alice","$create","$e10","$e12","$e13","$e8","$e9","$jr","$topic"], + ["$alice","$create","$e1","$e10","$e11","$e12","$e13","$e15"], + ["$alice","$create","$e13","$e15","$e16","$e17","$e18","$e9","$topic"], + ["$alice","$create","$e10","$e11","$e12","$e13","$e6","$jr","$topic"], + ["$alice","$create","$e13","$e14","$e17","$e4","$e7","$e9","$name"], + ["$alice","$create","$e10","$e12","$e4","$e5","$e6","$e8","$jr"], + ["$alice","$create","$e10","$e11","$e4","$e6","$join-bob","$join-dave","$name"], + ["$alice","$create","$e10","$e11","$e12","$e3","$e7","$name","$topic"], + ["$alice","$create","$e10","$e11","$e3","$e6","$e9","$join-charlie","$jr"], + ["$alice","$create","$e10","$e11","$e13","$e18","$e5","$e9","$topic"], + ["$alice","$create","$e12","$e13","$e14","$e15","$e4","$join-bob","$topic"], + ["$alice","$create","$e10","$e12","$e6","$e7","$e9","$join-charlie","$join-dave"], + ["$alice","$create","$e16","$e17","$e4","$e6","$e8","$e9","$name"], + ["$alice","$create","$e12","$e7","$e8","$e9","$join-charlie","$jr","$topic"], + ["$alice","$create","$e10","$e11","$e12","$e13","$e14","$e5","$e9"] + ] +} diff --git a/internal/stateres/testdata/synapse_forks.json b/internal/stateres/testdata/synapse_forks.json new file mode 100644 index 0000000..a3b07b6 --- /dev/null +++ b/internal/stateres/testdata/synapse_forks.json @@ -0,0 +1,124 @@ +{"forks":[ +{"name":"rej-32","tags":["power_tiebreak","timestamp_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":0,"@d:x":0}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e3","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],2],["$e4","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],3],["$e9","m.room.member","@e:x","@a:x",{"membership":"leave"},["$create","$pl","$alice"],2],["$e11","m.room.member","@b:x","@b:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl","$join-bob"],3],["$e6","m.room.member","@e:x","@e:x",{"displayname":"3","membership":"join"},["$create","$jr","$pl"],4],["$e10","m.room.member","@e:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob","$e6"],5,true]],"states":[["$alice","$create","$e10","$e4","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e11","$e3","$e9","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e11","$e4","$e6","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"rej-202","tags":["rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":75},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":75,"@c:x":0,"@d:x":25}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$e6","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-bob"],2],["$e7","m.room.member","@e:x","@a:x",{"membership":"ban"},["$create","$pl","$alice"],0],["$e8","m.room.name","","@a:x",{"name":"1"},["$create","$pl","$alice"],0],["$e10","m.room.member","@e:x","@a:x",{"membership":"ban"},["$create","$pl","$alice","$e7"],1,true]],"states":[["$alice","$create","$e6","$e7","$e8","$join-charlie","$join-dave","$jr","$pl","$topic"],["$alice","$create","$e10","$e6","$e8","$join-charlie","$join-dave","$jr","$pl","$topic"]],"resolved":["$alice","$create","$e6","$e7","$e8","$join-charlie","$join-dave","$jr","$pl","$topic"]}, +{"name":"rej-235","tags":["timestamp_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":0},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":0,"@d:x":25}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],0],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],0],["$e2","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-bob"],2],["$e3","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],3,true],["$e5","m.room.topic","","@d:x",{"topic":"1"},["$create","$pl","$join-dave"],5]],"states":[["$alice","$create","$e2","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e3","$e5","$join-bob","$join-charlie","$join-dave","$name","$pl"]],"resolved":["$alice","$create","$e5","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"rej-248","tags":["power_tiebreak","timestamp_tiebreak","absence_conflict","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":75,"@d:x":50}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],5],["$e1","m.room.member","@e:x","@d:x",{"membership":"ban"},["$create","$pl","$join-dave"],1,true],["$e2","m.room.join_rules","","@c:x",{"join_rule":"public"},["$create","$pl","$join-charlie"],5],["$e4","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],2],["$e3","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-charlie"],4],["$e7","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$e3"],5]],"states":[["$alice","$create","$e1","$e7","$join-bob","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e2","$e4","$join-bob","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e2","$e7","$join-bob","$join-dave","$name","$pl","$topic"]}, +{"name":"rej-250","tags":["rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":75},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":50,"@c:x":50,"@d:x":75}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],5],["$e1","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0,true],["$e4","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$create","$pl","$join-dave"],5],["$e6","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],0]],"states":[["$alice","$create","$e4","$e6","$join-bob","$join-charlie","$name","$pl","$topic"],["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e4","$e6","$join-bob","$join-charlie","$name","$pl","$topic"]}, +{"name":"rej-566","tags":["rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":25},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":0,"@d:x":75}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$e1","m.room.topic","","@d:x",{"topic":"0"},["$create","$pl","$join-dave"],0],["$e4","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],5],["$e5","m.room.name","","@a:x",{"name":"1"},["$create","$pl","$alice"],3],["$e9","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3,true]],"states":[["$alice","$create","$e1","$e4","$e5","$e9","$join-bob","$join-charlie","$pl"],["$alice","$create","$e1","$e4","$e5","$join-bob","$join-charlie","$jr","$pl"],["$alice","$create","$e1","$e4","$e5","$join-bob","$join-charlie","$jr","$pl"]],"resolved":["$alice","$create","$e1","$e4","$e5","$join-bob","$join-charlie","$jr","$pl"]}, +{"name":"rej-938","tags":["timestamp_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":50,"@c:x":0,"@d:x":50}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e1","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],0],["$e4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5,true]],"states":[["$alice","$create","$e1","$join-bob","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e4","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"rej-1022","tags":["timestamp_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":0},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":75,"@d:x":0}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e1","m.room.member","@e:x","@a:x",{"membership":"invite"},["$create","$jr","$pl","$alice"],3],["$e2","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],2],["$e4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0,true],["$e5","m.room.topic","","@b:x",{"topic":"1"},["$create","$pl","$join-bob"],3]],"states":[["$alice","$create","$e1","$e5","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e1","$e2","$e4","$join-bob","$join-charlie","$join-dave","$name","$pl"]],"resolved":["$alice","$create","$e1","$e5","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"rej-1122","tags":["absence_conflict","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":25},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":50,"@c:x":0,"@d:x":50}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],5],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e1","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl"],0],["$e4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0,true]],"states":[["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e4","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"rej-1226","tags":["rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":25,"@d:x":0}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e1","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],0],["$e5","m.room.member","@e:x","@e:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl"],0],["$e7","m.room.member","@d:x","@a:x",{"membership":"leave"},["$create","$pl","$alice","$join-dave"],3,true]],"states":[["$alice","$create","$e1","$e5","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e1","$e5","$e7","$join-bob","$join-charlie","$jr","$name","$pl"]],"resolved":["$alice","$create","$e1","$e5","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"rej-1276","tags":["timestamp_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":50},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":50,"@d:x":75}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e2","m.room.topic","","@c:x",{"topic":"0"},["$create","$pl","$join-charlie"],1],["$e3","m.room.join_rules","","@d:x",{"join_rule":"public"},["$create","$pl","$join-dave"],1,true],["$e5","m.room.name","","@d:x",{"name":"1"},["$create","$pl","$join-dave"],2]],"states":[["$alice","$create","$e2","$e3","$e5","$join-bob","$join-charlie","$join-dave","$pl"],["$alice","$create","$e2","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e2","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"rej-1403","tags":["timestamp_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":25},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":0,"@d:x":50}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],1],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],0],["$e10","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2,true],["$e6","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-dave"],2]],"states":[["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e6","$join-bob","$join-charlie","$jr","$name","$pl","$topic"],["$alice","$create","$e10","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"rej-1465","tags":["timestamp_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":75},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":50,"@d:x":75}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],5],["$e2","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2,true],["$e1","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],1],["$e3","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$e1"],4]],"states":[["$alice","$create","$e3","$join-bob","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e2","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e3","$join-bob","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"rej-1485","tags":["event_id_tiebreak","absence_conflict","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":25},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":25,"@c:x":75,"@d:x":75}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],0],["$e6","m.room.topic","","@d:x",{"topic":"0"},["$create","$pl","$join-dave"],1],["$e9","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],1],["$e10","m.room.member","@e:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],4,true]],"states":[["$alice","$create","$e9","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e10","$e6","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e9","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"rej-1657","tags":["timestamp_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":25,"@c:x":50,"@d:x":75}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],1],["$e2","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$create","$pl","$join-dave"],5,true],["$e10","m.room.name","","@a:x",{"name":"0"},["$create","$pl","$alice"],3],["$e5","m.room.name","","@a:x",{"name":"1"},["$create","$pl","$alice"],5]],"states":[["$alice","$create","$e10","$e2","$join-bob","$join-charlie","$join-dave","$pl","$topic"],["$alice","$create","$e5","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"]],"resolved":["$alice","$create","$e5","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"]}, +{"name":"rej-1677","tags":["timestamp_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":50,"@d:x":75}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e3","m.room.member","@c:x","@a:x",{"membership":"leave"},["$create","$pl","$alice","$join-charlie"],5,true],["$e2","m.room.topic","","@d:x",{"topic":"0"},["$create","$pl","$join-dave"],2],["$e5","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],3]],"states":[["$alice","$create","$e2","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e3","$e5","$join-bob","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e5","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"rej-1916","tags":["timestamp_tiebreak","absence_conflict","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":50},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":50,"@d:x":0}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e3","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],2],["$e4","m.room.member","@c:x","@c:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl","$join-charlie"],4],["$e6","m.room.member","@e:x","@c:x",{"membership":"leave"},["$create","$pl","$e4"],1,true],["$e7","m.room.member","@b:x","@b:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl","$join-bob"],5]],"states":[["$alice","$create","$e3","$e4","$e6","$join-bob","$join-dave","$jr","$name","$pl"],["$alice","$create","$e3","$e4","$e7","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e3","$e4","$e7","$join-dave","$jr","$name","$pl"]}, +{"name":"rej-2017","tags":["timestamp_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":25,"@c:x":50,"@d:x":0}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],0],["$e4","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],0,true],["$e1","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-bob"],2],["$e3","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl","$join-dave"],4]],"states":[["$alice","$create","$e1","$e3","$join-charlie","$jr","$name","$pl","$topic"],["$alice","$create","$e4","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e3","$join-charlie","$jr","$name","$pl","$topic"]}, +{"name":"rej-2066","tags":["timestamp_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":0,"@d:x":25}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e1","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl"],1],["$e5","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],0],["$e2","m.room.member","@e:x","@a:x",{"membership":"ban"},["$create","$pl","$alice","$e1"],4,true]],"states":[["$alice","$create","$e2","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e1","$e5","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"rej-2347","tags":["timestamp_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":0,"@d:x":50}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e3","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],1,true],["$e4","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-charlie"],4]],"states":[["$alice","$create","$e3","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"],["$alice","$create","$e4","$join-bob","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e4","$join-bob","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"rej-2392","tags":["timestamp_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":25},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":75,"@d:x":75}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e1","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],1],["$e2","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],1],["$e5","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],0,true],["$e6","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],4]],"states":[["$alice","$create","$e1","$e2","$e5","$join-bob","$join-dave","$name","$pl"],["$alice","$create","$e1","$e2","$e6","$join-bob","$jr","$name","$pl"]],"resolved":["$alice","$create","$e1","$e2","$e6","$join-bob","$jr","$name","$pl"]}, +{"name":"rej-2403","tags":["timestamp_tiebreak","absence_conflict","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":0},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":25,"@d:x":25}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3,true],["$e5","m.room.topic","","@d:x",{"topic":"0"},["$create","$pl","$join-dave"],4],["$e6","m.room.topic","","@c:x",{"topic":"1"},["$create","$pl","$join-charlie"],3],["$e7","m.room.member","@e:x","@e:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl"],4]],"states":[["$alice","$create","$e5","$e7","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e4","$e6","$join-bob","$join-charlie","$join-dave","$name","$pl"]],"resolved":["$alice","$create","$e5","$e7","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"rej-2739","tags":["rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":0},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":75,"@c:x":0,"@d:x":50}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],1],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],5],["$e1","m.room.member","@e:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],4],["$e7","m.room.join_rules","","@b:x",{"join_rule":"public"},["$create","$pl","$join-bob"],2],["$e8","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4,true]],"states":[["$alice","$create","$e1","$e7","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"],["$alice","$create","$e1","$e8","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e7","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]}, +{"name":"rej-2804","tags":["timestamp_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":0,"@d:x":25}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e2","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-bob"],4],["$e7","m.room.topic","","@b:x",{"topic":"1"},["$create","$pl","$join-bob"],2],["$e4","m.room.topic","","@a:x",{"topic":"2"},["$create","$pl","$alice"],4],["$e8","m.room.join_rules","","@b:x",{"join_rule":"public"},["$create","$pl","$e2"],3,true]],"states":[["$alice","$create","$e2","$e4","$e8","$join-charlie","$join-dave","$name","$pl"],["$alice","$create","$e7","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e2","$e4","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"rej-2836","tags":["event_id_tiebreak","rejected_power_event"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":50},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":75,"@c:x":25,"@d:x":0}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e2","m.room.join_rules","","@b:x",{"join_rule":"invite"},["$create","$pl","$join-bob"],4],["$e8","m.room.member","@c:x","@b:x",{"membership":"ban"},["$create","$pl","$join-bob","$join-charlie"],2,true],["$e7","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$create","$e2","$pl","$join-dave"],1]],"states":[["$alice","$create","$e2","$e7","$join-bob","$join-charlie","$name","$pl","$topic"],["$alice","$create","$e2","$e8","$join-bob","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e2","$join-bob","$join-dave","$name","$pl","$topic"]}, +{"name":"ts-32","tags":["power_tiebreak","timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":0,"@d:x":0}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],0],["$e3","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],5],["$e9","m.room.member","@e:x","@a:x",{"membership":"leave"},["$create","$pl","$alice"],3],["$e4","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],3],["$e6","m.room.member","@e:x","@e:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl"],3],["$e11","m.room.member","@b:x","@b:x",{"displayname":"3","membership":"join"},["$create","$jr","$pl","$join-bob"],2],["$e10","m.room.member","@e:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob","$e6"],2]],"states":[["$alice","$create","$e10","$e4","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e11","$e3","$e9","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e10","$e11","$e3","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"ts-52","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":0},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":75,"@c:x":50,"@d:x":0}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e1","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-dave"],2],["$e3","m.room.name","","@b:x",{"name":"1"},["$create","$pl","$join-bob"],1],["$e5","m.room.member","@c:x","@c:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl","$join-charlie"],2]],"states":[["$alice","$create","$e1","$join-bob","$join-charlie","$jr","$name","$pl","$topic"],["$alice","$create","$e1","$e3","$e5","$join-bob","$jr","$pl","$topic"]],"resolved":["$alice","$create","$e1","$join-bob","$join-charlie","$jr","$name","$pl","$topic"]}, +{"name":"ts-81","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":75},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":50,"@c:x":50,"@d:x":0}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e6","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],3],["$e8","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-dave"],1],["$e3","m.room.name","","@a:x",{"name":"1"},["$create","$pl","$alice"],5]],"states":[["$alice","$create","$e3","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"],["$alice","$create","$e6","$e8","$join-bob","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e3","$e8","$join-bob","$join-charlie","$jr","$pl","$topic"]}, +{"name":"ts-98","tags":["timestamp_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":100,"m.room.name":0,"m.room.power_levels":50,"m.room.topic":25},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":50,"@d:x":50}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],5],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e1","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],3],["$e3","m.room.name","","@d:x",{"name":"0"},["$create","$pl","$join-dave"],0],["$e5","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl","$join-dave"],3]],"states":[["$alice","$create","$e1","$e3","$e5","$join-bob","$jr","$pl","$topic"],["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e5","$join-bob","$join-charlie","$jr","$name","$pl","$topic"]}, +{"name":"ts-135","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":0},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":75,"@d:x":25}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],3],["$e4","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-dave"],5],["$e7","m.room.topic","","@d:x",{"topic":"1"},["$create","$pl","$e4"],1],["$e8","m.room.name","","@d:x",{"name":"2"},["$create","$pl","$e4"],0],["$e9","m.room.name","","@a:x",{"name":"3"},["$create","$pl","$alice"],5]],"states":[["$alice","$create","$e4","$e8","$join-bob","$join-charlie","$jr","$pl","$topic"],["$alice","$create","$e4","$e7","$e9","$join-bob","$join-charlie","$jr","$pl"]],"resolved":["$alice","$create","$e4","$e9","$join-bob","$join-charlie","$jr","$pl","$topic"]}, +{"name":"ts-202","tags":[],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":75},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":75,"@c:x":0,"@d:x":25}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],0],["$e6","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-bob"],0],["$e7","m.room.member","@e:x","@a:x",{"membership":"ban"},["$create","$pl","$alice"],0],["$e8","m.room.name","","@a:x",{"name":"1"},["$create","$pl","$alice"],4],["$e10","m.room.member","@e:x","@a:x",{"membership":"ban"},["$create","$pl","$alice","$e7"],2]],"states":[["$alice","$create","$e6","$e7","$e8","$join-charlie","$join-dave","$jr","$pl","$topic"],["$alice","$create","$e10","$e6","$e8","$join-charlie","$join-dave","$jr","$pl","$topic"]],"resolved":["$alice","$create","$e10","$e6","$e8","$join-charlie","$join-dave","$jr","$pl","$topic"]}, +{"name":"ts-228","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":100,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":50,"@c:x":0,"@d:x":75}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e2","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],4]],"states":[["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e2","$join-bob","$join-charlie","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e2","$join-bob","$join-charlie","$jr","$name","$pl","$topic"]}, +{"name":"ts-235","tags":["timestamp_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":0},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":0,"@d:x":25}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],5],["$e2","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-bob"],5],["$e3","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],0],["$e5","m.room.topic","","@d:x",{"topic":"1"},["$create","$pl","$join-dave"],1]],"states":[["$alice","$create","$e2","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e3","$e5","$join-bob","$join-charlie","$join-dave","$name","$pl"]],"resolved":["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-241","tags":["power_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":50},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":25,"@d:x":75}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e4","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-bob"],5],["$e3","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],3],["$e6","m.room.member","@b:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],0],["$e7","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$create","$pl","$join-dave"],0]],"states":[["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e3","$e6","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e4","$e7","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e6","$e7","$join-charlie","$join-dave","$name","$pl","$topic"]}, +{"name":"ts-248","tags":["power_tiebreak","timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":75,"@d:x":50}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e1","m.room.member","@e:x","@d:x",{"membership":"ban"},["$create","$pl","$join-dave"],4],["$e2","m.room.join_rules","","@c:x",{"join_rule":"public"},["$create","$pl","$join-charlie"],5],["$e4","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],0],["$e3","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-charlie"],4],["$e7","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$e3"],1]],"states":[["$alice","$create","$e1","$e7","$join-bob","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e2","$e4","$join-bob","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e2","$e3","$join-bob","$join-dave","$name","$pl","$topic"]}, +{"name":"ts-249","tags":[],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":50,"@c:x":25,"@d:x":0}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e1","m.room.member","@e:x","@d:x",{"membership":"invite"},["$create","$jr","$pl","$join-dave"],0]],"states":[["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-250","tags":["power_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":75},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":50,"@c:x":50,"@d:x":75}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],5],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e1","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$e4","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$create","$pl","$join-dave"],1],["$e6","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],5]],"states":[["$alice","$create","$e4","$e6","$join-bob","$join-charlie","$name","$pl","$topic"],["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e4","$e6","$join-bob","$join-charlie","$name","$pl","$topic"]}, +{"name":"ts-332","tags":["event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":25,"@c:x":25,"@d:x":25}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e1","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl"],2],["$e5","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],4]],"states":[["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e1","$e5","$join-bob","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-401","tags":["timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":50},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":25,"@d:x":25}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],1],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e1","m.room.member","@b:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],0],["$e3","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl"],4],["$e5","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],5]],"states":[["$alice","$create","$e1","$e3","$e5","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e3","$e5","$join-bob","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-457","tags":["power_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":25,"@d:x":75}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e1","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl"],5],["$e2","m.room.member","@e:x","@a:x",{"membership":"ban"},["$create","$pl","$alice","$e1"],2],["$e3","m.room.topic","","@b:x",{"topic":"1"},["$create","$pl","$join-bob"],3],["$e6","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$create","$pl","$join-dave"],0],["$e5","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],3]],"states":[["$alice","$create","$e2","$e3","$e6","$join-bob","$join-charlie","$join-dave","$name","$pl"],["$alice","$create","$e2","$e3","$e5","$join-bob","$join-charlie","$join-dave","$name","$pl"]],"resolved":["$alice","$create","$e2","$e3","$e6","$join-bob","$join-charlie","$join-dave","$name","$pl"]}, +{"name":"ts-472","tags":["event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":50,"m.room.topic":50},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":0,"@d:x":0}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],1],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e5","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],1],["$e10","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-charlie"],3]],"states":[["$alice","$create","$e5","$join-bob","$join-charlie","$jr","$name","$pl","$topic"],["$alice","$create","$e10","$join-bob","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-479","tags":["timestamp_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":50},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":75,"@c:x":50,"@d:x":75}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],5],["$e2","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],0],["$e10","m.room.name","","@a:x",{"name":"0"},["$create","$pl","$alice"],4]],"states":[["$alice","$create","$e2","$join-bob","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e10","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"]],"resolved":["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-542","tags":["timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":25},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":75,"@d:x":25}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],0],["$e2","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],5],["$e5","m.room.topic","","@d:x",{"topic":"1"},["$create","$pl","$join-dave"],0],["$e6","m.room.member","@e:x","@b:x",{"membership":"invite"},["$create","$jr","$pl","$join-bob"],5]],"states":[["$alice","$create","$e5","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e2","$e6","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e2","$e6","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"ts-556","tags":["timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":25,"m.room.power_levels":50,"m.room.topic":25},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":25,"@c:x":50,"@d:x":0}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],5],["$e1","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$e5","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$create","$e1","$pl"],5]],"states":[["$alice","$create","$e1","$e5","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"],["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e5","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-566","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":25},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":0,"@d:x":75}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$e1","m.room.topic","","@d:x",{"topic":"0"},["$create","$pl","$join-dave"],1],["$e4","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],4],["$e5","m.room.name","","@a:x",{"name":"1"},["$create","$pl","$alice"],5],["$e9","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5]],"states":[["$alice","$create","$e1","$e4","$e5","$e9","$join-bob","$join-charlie","$pl"],["$alice","$create","$e1","$e4","$e5","$join-bob","$join-charlie","$jr","$pl"],["$alice","$create","$e1","$e4","$e5","$join-bob","$join-charlie","$jr","$pl"]],"resolved":["$alice","$create","$e1","$e4","$e5","$e9","$join-bob","$join-charlie","$pl"]}, +{"name":"ts-581","tags":["power_tiebreak","timestamp_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":25},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":75,"@c:x":25,"@d:x":75}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e1","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl"],1],["$e2","m.room.topic","","@b:x",{"topic":"1"},["$create","$pl","$join-bob"],5],["$e4","m.room.member","@b:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],5],["$e9","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$create","$pl","$join-dave"],5]],"states":[["$alice","$create","$e1","$e2","$e4","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e1","$e9","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e2","$e4","$e9","$join-charlie","$join-dave","$name","$pl"]}, +{"name":"ts-639","tags":["power_tiebreak","timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":0},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":0,"@d:x":75}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e1","m.room.topic","","@b:x",{"topic":"0"},["$create","$pl","$join-bob"],0],["$e3","m.room.join_rules","","@d:x",{"join_rule":"public"},["$create","$pl","$join-dave"],0],["$e9","m.room.name","","@a:x",{"name":"1"},["$create","$pl","$alice"],4],["$e5","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],0],["$e7","m.room.member","@d:x","@d:x",{"displayname":"2","membership":"join"},["$create","$e3","$pl","$join-dave"],3],["$e8","m.room.name","","@a:x",{"name":"3"},["$create","$pl","$alice"],5]],"states":[["$alice","$create","$e1","$e5","$e8","$join-bob","$join-charlie","$join-dave","$pl"],["$alice","$create","$e1","$e3","$e9","$join-bob","$join-charlie","$join-dave","$pl"],["$alice","$create","$e1","$e3","$e7","$join-bob","$join-charlie","$name","$pl"]],"resolved":["$alice","$create","$e1","$e3","$e7","$e8","$join-bob","$join-charlie","$pl"]}, +{"name":"ts-650","tags":["timestamp_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":25,"@c:x":50,"@d:x":0}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$e3","m.room.name","","@c:x",{"name":"0"},["$create","$pl","$join-charlie"],5],["$e4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$e5","m.room.member","@b:x","@b:x",{"displayname":"1","membership":"join"},["$create","$e4","$pl","$join-bob"],1],["$e8","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],4]],"states":[["$alice","$create","$e3","$e4","$e5","$join-charlie","$join-dave","$pl","$topic"],["$alice","$create","$e3","$e4","$e8","$join-bob","$join-dave","$pl","$topic"]],"resolved":["$alice","$create","$e3","$e4","$e5","$join-charlie","$join-dave","$pl","$topic"]}, +{"name":"ts-656","tags":["timestamp_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":50},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":50,"@d:x":50}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],5],["$e1","m.room.name","","@c:x",{"name":"0"},["$create","$pl","$join-charlie"],2],["$e4","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl","$join-dave"],4]],"states":[["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"],["$alice","$create","$e4","$join-bob","$join-charlie","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-696","tags":["power_tiebreak","timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":25},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":25,"@d:x":50}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e4","m.room.join_rules","","@d:x",{"join_rule":"public"},["$create","$pl","$join-dave"],2],["$e3","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl"],4],["$e8","m.room.member","@c:x","@c:x",{"displayname":"1","membership":"join"},["$create","$e4","$pl","$join-charlie"],0],["$e5","m.room.member","@e:x","@e:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl","$e3"],1]],"states":[["$alice","$create","$e5","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e4","$e8","$join-bob","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e3","$e4","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]}, +{"name":"ts-733","tags":["event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":75},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":0,"@d:x":75}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],1],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e3","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],3],["$e4","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-dave"],3]],"states":[["$alice","$create","$e3","$join-bob","$join-charlie","$jr","$name","$pl","$topic"],["$alice","$create","$e4","$join-bob","$join-charlie","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e4","$join-bob","$join-charlie","$jr","$name","$pl","$topic"]}, +{"name":"ts-762","tags":["timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":25,"m.room.power_levels":50,"m.room.topic":25},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":75,"@d:x":75}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],5],["$e2","m.room.name","","@a:x",{"name":"0"},["$create","$pl","$alice"],2],["$e3","m.room.topic","","@d:x",{"topic":"1"},["$create","$pl","$join-dave"],1],["$e6","m.room.member","@e:x","@a:x",{"membership":"invite"},["$create","$jr","$pl","$alice"],2]],"states":[["$alice","$create","$e2","$e6","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"],["$alice","$create","$e3","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e6","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-781","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":25},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":25,"@d:x":0}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],0],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e1","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],2],["$e3","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],2]],"states":[["$alice","$create","$e1","$e3","$join-bob","$join-dave","$jr","$name","$pl"],["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e3","$join-bob","$join-dave","$jr","$name","$pl"]}, +{"name":"ts-782","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":75},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":75,"@c:x":50,"@d:x":0}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],0],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e1","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],1],["$e4","m.room.member","@b:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],2],["$e3","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],4]],"states":[["$alice","$create","$e1","$e3","$join-bob","$jr","$name","$pl","$topic"],["$alice","$create","$e4","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e3","$join-bob","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-920","tags":[],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":50},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":25,"@c:x":0,"@d:x":0}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e1","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],5],["$e4","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],4]],"states":[["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"],["$alice","$create","$e1","$e4","$join-bob","$join-dave","$name","$pl","$topic"],["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e4","$join-bob","$join-dave","$name","$pl","$topic"]}, +{"name":"ts-928","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":50},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":25,"@c:x":75,"@d:x":75}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$e2","m.room.member","@e:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],1],["$e4","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],5],["$e5","m.room.member","@b:x","@c:x",{"membership":"leave"},["$create","$pl","$join-bob","$join-charlie"],4],["$e7","m.room.name","","@c:x",{"name":"1"},["$create","$pl","$join-charlie"],2],["$e11","m.room.name","","@a:x",{"name":"2"},["$create","$pl","$alice"],3]],"states":[["$alice","$create","$e11","$e2","$e4","$e5","$join-charlie","$join-dave","$jr","$pl"],["$alice","$create","$e2","$e4","$e5","$e7","$join-charlie","$join-dave","$jr","$pl"]],"resolved":["$alice","$create","$e11","$e2","$e4","$e5","$join-charlie","$join-dave","$jr","$pl"]}, +{"name":"ts-938","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":50,"@c:x":0,"@d:x":50}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],0],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e1","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],1],["$e4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2]],"states":[["$alice","$create","$e1","$join-bob","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e4","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-948","tags":["timestamp_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":50,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":0,"@d:x":25}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],5],["$e2","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl"],1],["$e3","m.room.name","","@d:x",{"name":"1"},["$create","$pl","$join-dave"],0],["$e4","m.room.name","","@b:x",{"name":"2"},["$create","$pl","$join-bob"],0],["$e8","m.room.topic","","@a:x",{"topic":"3"},["$create","$pl","$alice"],1]],"states":[["$alice","$create","$e2","$e3","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"],["$alice","$create","$e2","$e4","$e8","$join-bob","$join-charlie","$join-dave","$jr","$pl"]],"resolved":["$alice","$create","$e2","$e4","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"]}, +{"name":"ts-960","tags":["power_tiebreak","timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":25,"m.room.power_levels":50,"m.room.topic":25},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":75,"@c:x":75,"@d:x":25}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e2","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],1],["$e1","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl"],2],["$e6","m.room.member","@e:x","@e:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl"],1],["$e5","m.room.join_rules","","@c:x",{"join_rule":"public"},["$create","$pl","$join-charlie"],2]],"states":[["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e2","$e5","$join-bob","$join-charlie","$name","$pl","$topic"],["$alice","$create","$e6","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e2","$e5","$join-bob","$join-charlie","$name","$pl","$topic"]}, +{"name":"ts-963","tags":["power_tiebreak","timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":0},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":25,"@c:x":75,"@d:x":75}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$e2","m.room.name","","@c:x",{"name":"0"},["$create","$pl","$join-charlie"],1],["$e3","m.room.join_rules","","@d:x",{"join_rule":"public"},["$create","$pl","$join-dave"],1],["$e4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$e8","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],0],["$e7","m.room.topic","","@b:x",{"topic":"2"},["$create","$pl","$join-bob"],5],["$e10","m.room.member","@e:x","@e:x",{"displayname":"3","membership":"join"},["$create","$e4","$pl"],3],["$e9","m.room.member","@e:x","@e:x",{"displayname":"4","membership":"join"},["$create","$e3","$pl"],5],["$e12","m.room.name","","@c:x",{"name":"5"},["$create","$pl","$join-charlie"],3]],"states":[["$alice","$create","$e2","$e3","$e7","$e9","$join-bob","$join-charlie","$join-dave","$pl"],["$alice","$create","$e10","$e12","$e4","$e8","$join-bob","$join-charlie","$join-dave","$pl"]],"resolved":["$alice","$create","$e12","$e3","$e7","$e9","$join-bob","$join-charlie","$join-dave","$pl"]}, +{"name":"ts-965","tags":["power_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":25},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":75,"@d:x":75}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e1","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$e2","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],4],["$e3","m.room.join_rules","","@c:x",{"join_rule":"invite"},["$create","$pl","$join-charlie"],3],["$e4","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$e1","$pl","$join-charlie"],2],["$e5","m.room.member","@b:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],2]],"states":[["$alice","$create","$e1","$e2","$e4","$join-bob","$name","$pl","$topic"],["$alice","$create","$e3","$e5","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e2","$e3","$e4","$e5","$name","$pl","$topic"]}, +{"name":"ts-980","tags":["event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":0},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":0,"@d:x":25}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],1],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],0],["$e2","m.room.member","@b:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],3],["$e7","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$e2"],3]],"states":[["$alice","$create","$e7","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e2","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e7","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-987","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":50},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":50,"@c:x":50,"@d:x":75}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e1","m.room.name","","@a:x",{"name":"0"},["$create","$pl","$alice"],2],["$e3","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],2]],"states":[["$alice","$create","$e1","$e3","$join-bob","$join-dave","$jr","$pl","$topic"],["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e3","$join-bob","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-1022","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":0},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":75,"@d:x":0}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e1","m.room.member","@e:x","@a:x",{"membership":"invite"},["$create","$jr","$pl","$alice"],2],["$e2","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],3],["$e4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$e5","m.room.topic","","@b:x",{"topic":"1"},["$create","$pl","$join-bob"],2]],"states":[["$alice","$create","$e1","$e5","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e1","$e2","$e4","$join-bob","$join-charlie","$join-dave","$name","$pl"]],"resolved":["$alice","$create","$e1","$e2","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"ts-1085","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":75},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":50,"@c:x":50,"@d:x":0}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],1],["$e2","m.room.name","","@c:x",{"name":"0"},["$create","$pl","$join-charlie"],3],["$e3","m.room.name","","@c:x",{"name":"1"},["$create","$pl","$join-charlie"],4],["$e4","m.room.member","@d:x","@d:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl","$join-dave"],5]],"states":[["$alice","$create","$e3","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"],["$alice","$create","$e2","$e4","$join-bob","$join-charlie","$jr","$pl","$topic"]],"resolved":["$alice","$create","$e3","$e4","$join-bob","$join-charlie","$jr","$pl","$topic"]}, +{"name":"ts-1108","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":0},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":50,"@c:x":75,"@d:x":0}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$e1","m.room.name","","@c:x",{"name":"0"},["$create","$pl","$join-charlie"],5],["$e8","m.room.name","","@a:x",{"name":"1"},["$create","$pl","$alice"],1],["$e7","m.room.member","@d:x","@d:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl","$join-dave"],4]],"states":[["$alice","$create","$e1","$e7","$join-bob","$join-charlie","$jr","$pl","$topic"],["$alice","$create","$e8","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"]],"resolved":["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"]}, +{"name":"ts-1122","tags":["timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":25},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":50,"@c:x":0,"@d:x":50}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],5],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e1","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl"],0],["$e4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1]],"states":[["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e4","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e4","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]}, +{"name":"ts-1187","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":75},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":50,"@c:x":0,"@d:x":75}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e4","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],4]],"states":[["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e4","$join-bob","$join-charlie","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e4","$join-bob","$join-charlie","$jr","$name","$pl","$topic"]}, +{"name":"ts-1226","tags":[],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":25,"@d:x":0}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e1","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],2],["$e5","m.room.member","@e:x","@e:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl"],3],["$e7","m.room.member","@d:x","@a:x",{"membership":"leave"},["$create","$pl","$alice","$join-dave"],0]],"states":[["$alice","$create","$e1","$e5","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e1","$e5","$e7","$join-bob","$join-charlie","$jr","$name","$pl"]],"resolved":["$alice","$create","$e1","$e5","$e7","$join-bob","$join-charlie","$jr","$name","$pl"]}, +{"name":"ts-1233","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":25,"m.room.power_levels":50,"m.room.topic":50},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":75,"@d:x":50}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],5],["$e2","m.room.name","","@c:x",{"name":"0"},["$create","$pl","$join-charlie"],3],["$e5","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl","$join-dave"],3]],"states":[["$alice","$create","$e2","$e5","$join-bob","$join-charlie","$jr","$pl","$topic"],["$alice","$create","$e2","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"]],"resolved":["$alice","$create","$e2","$e5","$join-bob","$join-charlie","$jr","$pl","$topic"]}, +{"name":"ts-1244","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":50,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":50,"@c:x":75,"@d:x":75}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],0],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e5","m.room.topic","","@d:x",{"topic":"0"},["$create","$pl","$join-dave"],3],["$e3","m.room.member","@b:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],5]],"states":[["$alice","$create","$e3","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e5","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e3","$e5","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"ts-1276","tags":["power_tiebreak","timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":50},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":50,"@d:x":75}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e2","m.room.topic","","@c:x",{"topic":"0"},["$create","$pl","$join-charlie"],0],["$e3","m.room.join_rules","","@d:x",{"join_rule":"public"},["$create","$pl","$join-dave"],3],["$e5","m.room.name","","@d:x",{"name":"1"},["$create","$pl","$join-dave"],1]],"states":[["$alice","$create","$e2","$e3","$e5","$join-bob","$join-charlie","$join-dave","$pl"],["$alice","$create","$e2","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e2","$e3","$join-bob","$join-charlie","$join-dave","$name","$pl"]}, +{"name":"ts-1316","tags":["timestamp_tiebreak","event_id_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":50},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":50,"@c:x":50,"@d:x":50}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e4","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],2],["$e5","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],2],["$e6","m.room.member","@e:x","@e:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl"],4]],"states":[["$alice","$create","$e5","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e4","$e6","$join-bob","$join-charlie","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e4","$e6","$join-bob","$join-charlie","$jr","$name","$pl","$topic"]}, +{"name":"ts-1319","tags":["timestamp_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":50,"@c:x":75,"@d:x":75}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$e1","m.room.name","","@d:x",{"name":"0"},["$create","$pl","$join-dave"],4],["$e3","m.room.member","@b:x","@b:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl","$join-bob"],2],["$e5","m.room.member","@b:x","@b:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl","$e3"],2]],"states":[["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"],["$alice","$create","$e1","$e5","$join-charlie","$join-dave","$jr","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e5","$join-charlie","$join-dave","$jr","$pl","$topic"]}, +{"name":"ts-1324","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":25},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":50,"@c:x":25,"@d:x":25}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e2","m.room.topic","","@d:x",{"topic":"0"},["$create","$pl","$join-dave"],0],["$e4","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl","$join-dave"],1],["$e3","m.room.member","@d:x","@d:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl","$join-dave"],3],["$e5","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],4]],"states":[["$alice","$create","$e2","$e3","$e5","$join-bob","$jr","$name","$pl"],["$alice","$create","$e2","$e4","$join-bob","$join-charlie","$jr","$name","$pl"]],"resolved":["$alice","$create","$e2","$e3","$e5","$join-bob","$jr","$name","$pl"]}, +{"name":"ts-1337","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":100,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":25},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":0,"@d:x":0}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e1","m.room.topic","","@b:x",{"topic":"0"},["$create","$pl","$join-bob"],5],["$e2","m.room.member","@b:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],1],["$e4","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl","$join-dave"],5],["$e3","m.room.name","","@d:x",{"name":"2"},["$create","$pl","$join-dave"],0]],"states":[["$alice","$create","$e1","$e2","$e3","$join-charlie","$join-dave","$jr","$pl"],["$alice","$create","$e1","$e2","$e4","$join-charlie","$jr","$name","$pl"]],"resolved":["$alice","$create","$e1","$e2","$e4","$join-charlie","$jr","$name","$pl"]}, +{"name":"ts-1422","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":0},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":25,"@c:x":75,"@d:x":50}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],0],["$e4","m.room.name","","@c:x",{"name":"0"},["$create","$pl","$join-charlie"],5]],"states":[["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e4","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"]],"resolved":["$alice","$create","$e4","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"]}, +{"name":"ts-1467","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":25},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":50,"@c:x":50,"@d:x":75}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e3","m.room.member","@b:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],2],["$e7","m.room.topic","","@c:x",{"topic":"0"},["$create","$pl","$join-charlie"],5],["$e10","m.room.member","@b:x","@a:x",{"membership":"invite"},["$create","$jr","$pl","$alice","$e3"],3]],"states":[["$alice","$create","$e10","$e7","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e3","$e7","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e10","$e7","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"ts-1485","tags":["timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":25},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":25,"@c:x":75,"@d:x":75}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e6","m.room.topic","","@d:x",{"topic":"0"},["$create","$pl","$join-dave"],2],["$e10","m.room.member","@e:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],5],["$e9","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],3]],"states":[["$alice","$create","$e9","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e10","$e6","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e10","$e9","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"ts-1577","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":0,"m.room.power_levels":50,"m.room.topic":75},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":50,"@c:x":50,"@d:x":50}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e1","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-bob"],2],["$e4","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],0],["$e6","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],5]],"states":[["$alice","$create","$e1","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e4","$e6","$join-bob","$join-charlie","$jr","$name","$pl"]],"resolved":["$alice","$create","$e1","$e6","$join-charlie","$jr","$name","$pl","$topic"]}, +{"name":"ts-1598","tags":["power_tiebreak","timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":0},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":50,"@c:x":75,"@d:x":75}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$e1","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-dave"],3],["$e3","m.room.join_rules","","@c:x",{"join_rule":"invite"},["$create","$pl","$join-charlie"],4],["$e4","m.room.name","","@a:x",{"name":"1"},["$create","$pl","$alice"],2],["$e6","m.room.member","@b:x","@b:x",{"displayname":"2","membership":"join"},["$create","$e3","$pl","$join-bob"],3],["$e7","m.room.member","@c:x","@c:x",{"displayname":"3","membership":"join"},["$create","$e3","$pl","$join-charlie"],2],["$e8","m.room.topic","","@b:x",{"topic":"4"},["$create","$pl","$e6"],3],["$e10","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],1]],"states":[["$alice","$create","$e1","$e10","$e4","$e6","$e7","$e8","$pl"],["$alice","$create","$e1","$e3","$e4","$e6","$e7","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e3","$e4","$e6","$e7","$e8","$pl"]}, +{"name":"ts-1657","tags":["power_tiebreak","timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":25,"@c:x":50,"@d:x":75}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$e2","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$create","$pl","$join-dave"],4],["$e10","m.room.name","","@a:x",{"name":"0"},["$create","$pl","$alice"],3],["$e5","m.room.name","","@a:x",{"name":"1"},["$create","$pl","$alice"],1]],"states":[["$alice","$create","$e10","$e2","$join-bob","$join-charlie","$join-dave","$pl","$topic"],["$alice","$create","$e5","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"]],"resolved":["$alice","$create","$e10","$e2","$join-bob","$join-charlie","$join-dave","$pl","$topic"]}, +{"name":"ts-1677","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":50,"@d:x":75}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],5],["$e2","m.room.topic","","@d:x",{"topic":"0"},["$create","$pl","$join-dave"],4],["$e3","m.room.member","@c:x","@a:x",{"membership":"leave"},["$create","$pl","$alice","$join-charlie"],0],["$e5","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],2]],"states":[["$alice","$create","$e2","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e3","$e5","$join-bob","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e2","$e3","$join-bob","$join-dave","$jr","$name","$pl"]}, +{"name":"ts-1683","tags":["power_tiebreak","timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":75},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":75,"@d:x":25}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],0],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e4","m.room.join_rules","","@c:x",{"join_rule":"invite"},["$create","$pl","$join-charlie"],3],["$e12","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl"],5],["$e7","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],4],["$e14","m.room.name","","@a:x",{"name":"2"},["$create","$pl","$alice"],5]],"states":[["$alice","$create","$e4","$e7","$join-bob","$join-charlie","$join-dave","$name","$pl"],["$alice","$create","$e12","$e14","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"],["$alice","$create","$e12","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e14","$e4","$e7","$join-bob","$join-charlie","$join-dave","$pl"]}, +{"name":"ts-1765","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":50,"@d:x":0}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$e1","m.room.member","@b:x","@a:x",{"membership":"ban"},["$create","$pl","$alice","$join-bob"],4],["$e2","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl"],4],["$e3","m.room.name","","@a:x",{"name":"1"},["$create","$pl","$alice"],4],["$e9","m.room.topic","","@c:x",{"topic":"2"},["$create","$pl","$join-charlie"],4],["$e12","m.room.topic","","@a:x",{"topic":"3"},["$create","$pl","$alice"],3]],"states":[["$alice","$create","$e1","$e12","$e2","$e3","$join-charlie","$join-dave","$jr","$pl"],["$alice","$create","$e1","$e2","$e3","$e9","$join-charlie","$join-dave","$jr","$pl"]],"resolved":["$alice","$create","$e1","$e2","$e3","$e9","$join-charlie","$join-dave","$jr","$pl"]}, +{"name":"ts-1786","tags":["timestamp_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":50,"@d:x":75}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e1","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-bob"],0],["$e2","m.room.name","","@a:x",{"name":"1"},["$create","$pl","$alice"],3]],"states":[["$alice","$create","$e1","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e2","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"],["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-1901","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":50},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":25,"@d:x":25}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e2","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-bob"],1],["$e4","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],5],["$e10","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],3]],"states":[["$alice","$create","$e2","$e4","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e10","$e2","$e4","$join-charlie","$jr","$name","$pl"]],"resolved":["$alice","$create","$e2","$e4","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"ts-2003","tags":["power_tiebreak","timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":25},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":75,"@d:x":0}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],0],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e5","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-dave"],2],["$e3","m.room.member","@e:x","@d:x",{"membership":"invite"},["$create","$jr","$pl","$join-dave"],3],["$e1","m.room.join_rules","","@c:x",{"join_rule":"public"},["$create","$pl","$join-charlie"],5],["$e4","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],4]],"states":[["$alice","$create","$e1","$e4","$join-bob","$join-charlie","$name","$pl","$topic"],["$alice","$create","$e5","$join-bob","$join-charlie","$jr","$name","$pl","$topic"],["$alice","$create","$e3","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e3","$e4","$join-bob","$join-charlie","$name","$pl","$topic"]}, +{"name":"ts-2008","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":50},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":50,"@c:x":25,"@d:x":25}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e3","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-charlie"],1]],"states":[["$alice","$create","$e3","$join-bob","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-2025","tags":["power_tiebreak","timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":50},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":50,"@d:x":50}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$e2","m.room.name","","@d:x",{"name":"0"},["$create","$pl","$join-dave"],4],["$e3","m.room.member","@e:x","@e:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl"],1],["$e4","m.room.member","@b:x","@b:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl","$join-bob"],5],["$e8","m.room.topic","","@d:x",{"topic":"3"},["$create","$pl","$join-dave"],4],["$e5","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$create","$pl","$join-dave"],5]],"states":[["$alice","$create","$e2","$e3","$e5","$join-bob","$join-charlie","$join-dave","$pl","$topic"],["$alice","$create","$e2","$e4","$e8","$join-charlie","$join-dave","$jr","$pl"]],"resolved":["$alice","$create","$e2","$e4","$e5","$e8","$join-charlie","$join-dave","$pl"]}, +{"name":"ts-2052","tags":["absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":25,"m.room.power_levels":50,"m.room.topic":0},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":75,"@c:x":75,"@d:x":0}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],1],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e1","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],1],["$e6","m.room.member","@e:x","@b:x",{"membership":"invite"},["$create","$e1","$pl","$join-bob"],4]],"states":[["$alice","$create","$e1","$e6","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"],["$alice","$create","$e1","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e6","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]}, +{"name":"ts-2088","tags":["power_tiebreak","timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":0},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":25,"@d:x":75}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],0],["$e3","m.room.topic","","@d:x",{"topic":"0"},["$create","$pl","$join-dave"],5],["$e9","m.room.topic","","@c:x",{"topic":"1"},["$create","$pl","$join-charlie"],1],["$e4","m.room.topic","","@b:x",{"topic":"2"},["$create","$pl","$join-bob"],0],["$e10","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$create","$pl","$join-dave"],4]],"states":[["$alice","$create","$e10","$e4","$join-bob","$join-charlie","$join-dave","$name","$pl"],["$alice","$create","$e3","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e9","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e10","$e3","$join-bob","$join-charlie","$join-dave","$name","$pl"]}, +{"name":"ts-2104","tags":[],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":25},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":0,"@d:x":25}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],3],["$e1","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],4],["$e3","m.room.name","","@a:x",{"name":"0"},["$create","$pl","$alice"],4],["$e4","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],1],["$e7","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],2]],"states":[["$alice","$create","$e1","$e3","$e4","$join-bob","$join-dave","$pl","$topic"],["$alice","$create","$e1","$e3","$e4","$e7","$join-bob","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e3","$e4","$e7","$join-bob","$pl","$topic"]}, +{"name":"ts-2188","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":25,"@c:x":50,"@d:x":75}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],5],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],5],["$e1","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-charlie"],2],["$e5","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl","$join-dave"],5],["$e4","m.room.topic","","@a:x",{"topic":"2"},["$create","$pl","$alice"],2]],"states":[["$alice","$create","$e1","$e4","$join-bob","$join-dave","$jr","$name","$pl"],["$alice","$create","$e1","$e5","$join-bob","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e5","$join-bob","$jr","$name","$pl","$topic"]}, +{"name":"ts-2228","tags":["power_tiebreak","timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":75},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":25,"@c:x":50,"@d:x":25}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$e1","m.room.name","","@a:x",{"name":"0"},["$create","$pl","$alice"],5],["$e4","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],2],["$e3","m.room.join_rules","","@c:x",{"join_rule":"invite"},["$create","$pl","$join-charlie"],4],["$e7","m.room.member","@b:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],2],["$e6","m.room.member","@e:x","@e:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl"],2]],"states":[["$alice","$create","$e1","$e4","$e6","$join-bob","$join-charlie","$join-dave","$jr","$pl"],["$alice","$create","$e1","$e3","$e7","$join-charlie","$join-dave","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e3","$e7","$join-charlie","$join-dave","$pl","$topic"]}, +{"name":"ts-2282","tags":["timestamp_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":50},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":50,"@d:x":75}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$e2","m.room.name","","@d:x",{"name":"0"},["$create","$pl","$join-dave"],0],["$e4","m.room.topic","","@d:x",{"topic":"1"},["$create","$pl","$join-dave"],1],["$e3","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],0],["$e6","m.room.name","","@a:x",{"name":"2"},["$create","$pl","$alice"],1]],"states":[["$alice","$create","$e2","$e3","$join-bob","$join-charlie","$jr","$pl","$topic"],["$alice","$create","$e4","$e6","$join-bob","$join-charlie","$join-dave","$jr","$pl"]],"resolved":["$alice","$create","$e6","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"]}, +{"name":"ts-2340","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":0},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":25,"@d:x":75}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],0],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e5","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-charlie"],5]],"states":[["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e5","$join-bob","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e5","$join-bob","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-2347","tags":["timestamp_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":0,"@d:x":50}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e3","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],4],["$e4","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-charlie"],3]],"states":[["$alice","$create","$e3","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"],["$alice","$create","$e4","$join-bob","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e3","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]}, +{"name":"ts-2354","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":25},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":75,"@d:x":0}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],5],["$e1","m.room.member","@b:x","@a:x",{"membership":"ban"},["$create","$pl","$alice","$join-bob"],3],["$e3","m.room.name","","@a:x",{"name":"0"},["$create","$pl","$alice"],4],["$e6","m.room.member","@c:x","@c:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl","$join-charlie"],0],["$e8","m.room.topic","","@c:x",{"topic":"2"},["$create","$pl","$e6"],2]],"states":[["$alice","$create","$e1","$e3","$join-charlie","$join-dave","$jr","$pl","$topic"],["$alice","$create","$e1","$e3","$e6","$e8","$join-dave","$jr","$pl"]],"resolved":["$alice","$create","$e1","$e3","$join-charlie","$join-dave","$jr","$pl","$topic"]}, +{"name":"ts-2379","tags":["timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":50,"m.room.topic":25},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":75,"@d:x":50}},["$create","$alice"],0],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e2","m.room.name","","@c:x",{"name":"0"},["$create","$pl","$join-charlie"],4],["$e3","m.room.topic","","@c:x",{"topic":"1"},["$create","$pl","$join-charlie"],2],["$e4","m.room.member","@e:x","@e:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl"],0]],"states":[["$alice","$create","$e2","$e4","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"],["$alice","$create","$e3","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e2","$e4","$join-bob","$join-charlie","$join-dave","$jr","$pl","$topic"]}, +{"name":"ts-2392","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":25},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":75,"@d:x":75}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e1","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],2],["$e2","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],4],["$e6","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],0],["$e5","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],1]],"states":[["$alice","$create","$e1","$e2","$e5","$join-bob","$join-dave","$name","$pl"],["$alice","$create","$e1","$e2","$e6","$join-bob","$jr","$name","$pl"]],"resolved":["$alice","$create","$e1","$e2","$join-bob","$join-dave","$jr","$name","$pl"]}, +{"name":"ts-2403","tags":["timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":0},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":25,"@d:x":25}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$e6","m.room.topic","","@c:x",{"topic":"0"},["$create","$pl","$join-charlie"],5],["$e5","m.room.topic","","@d:x",{"topic":"1"},["$create","$pl","$join-dave"],3],["$e7","m.room.member","@e:x","@e:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl"],2]],"states":[["$alice","$create","$e5","$e7","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e4","$e6","$join-bob","$join-charlie","$join-dave","$name","$pl"]],"resolved":["$alice","$create","$e4","$e6","$e7","$join-bob","$join-charlie","$join-dave","$name","$pl"]}, +{"name":"ts-2417","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":50,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":25,"@d:x":50}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e1","m.room.topic","","@b:x",{"topic":"0"},["$create","$pl","$join-bob"],0],["$e2","m.room.member","@e:x","@e:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl"],1],["$e7","m.room.member","@e:x","@e:x",{"membership":"leave"},["$create","$pl","$e2"],5]],"states":[["$alice","$create","$e1","$e2","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e1","$e7","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e1","$e7","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"ts-2440","tags":["timestamp_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":50,"@d:x":0}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],5],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e1","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],1],["$e6","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],2],["$e3","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$e1"],2]],"states":[["$alice","$create","$e3","$join-bob","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e6","$join-bob","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e6","$join-bob","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-2542","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":50},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":50,"@d:x":0}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e1","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-bob"],1],["$e2","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],3]],"states":[["$alice","$create","$e1","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e2","$join-bob","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e2","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-2557","tags":["timestamp_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],1],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":0},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":50,"@c:x":25,"@d:x":75}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],1],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e3","m.room.topic","","@b:x",{"topic":"0"},["$create","$pl","$join-bob"],1],["$e4","m.room.member","@c:x","@c:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl","$join-charlie"],2]],"states":[["$alice","$create","$e4","$join-bob","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e3","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e4","$join-bob","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-2674","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],0],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":0},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":75,"@d:x":75}},["$create","$alice"],5],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],5],["$e4","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],0],["$e1","m.room.name","","@a:x",{"name":"1"},["$create","$pl","$alice"],3],["$e3","m.room.member","@b:x","@b:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl","$join-bob"],3]],"states":[["$alice","$create","$e1","$e3","$join-charlie","$join-dave","$jr","$pl","$topic"],["$alice","$create","$e4","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e3","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-2677","tags":["power_tiebreak","timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":75,"@d:x":0}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],4],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],0],["$e1","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],3],["$e10","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],3],["$e5","m.room.join_rules","","@c:x",{"join_rule":"invite"},["$create","$pl","$join-charlie"],2],["$e12","m.room.name","","@a:x",{"name":"0"},["$create","$pl","$alice"],4],["$e9","m.room.name","","@a:x",{"name":"1"},["$create","$pl","$alice"],3],["$e15","m.room.member","@c:x","@c:x",{"displayname":"2","membership":"join"},["$create","$e5","$pl","$join-charlie"],2],["$e16","m.room.topic","","@a:x",{"topic":"3"},["$create","$pl","$alice"],4]],"states":[["$alice","$create","$e1","$e15","$e5","$e9","$join-bob","$pl","$topic"],["$alice","$create","$e10","$e12","$e16","$join-bob","$join-charlie","$join-dave","$pl"]],"resolved":["$alice","$create","$e1","$e12","$e15","$e16","$e5","$join-bob","$pl"]}, +{"name":"ts-2711","tags":[],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":25},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":25,"@d:x":0}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],0],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e2","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],2]],"states":[["$alice","$create","$e2","$join-bob","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e2","$join-bob","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e2","$join-bob","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-2739","tags":["power_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":0},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":75,"@c:x":0,"@d:x":50}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],0],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e1","m.room.member","@e:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],1],["$e7","m.room.join_rules","","@b:x",{"join_rule":"public"},["$create","$pl","$join-bob"],0],["$e8","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2]],"states":[["$alice","$create","$e1","$e7","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"],["$alice","$create","$e1","$e8","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e7","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"]}, +{"name":"ts-2760","tags":["power_tiebreak","event_id_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":25},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":50,"@d:x":25}},["$create","$alice"],2],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],0],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],1],["$e1","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-charlie"],4],["$e2","m.room.member","@e:x","@e:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl"],5],["$e5","m.room.topic","","@c:x",{"topic":"2"},["$create","$pl","$e1"],2],["$e4","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],1],["$e7","m.room.member","@d:x","@a:x",{"membership":"ban"},["$create","$pl","$alice","$join-dave"],3],["$e6","m.room.join_rules","","@c:x",{"join_rule":"public"},["$create","$pl","$e1"],5]],"states":[["$alice","$create","$e1","$e2","$e4","$e6","$join-bob","$name","$pl","$topic"],["$alice","$create","$e1","$e5","$e7","$join-bob","$jr","$name","$pl"]],"resolved":["$alice","$create","$e1","$e2","$e6","$e7","$join-bob","$name","$pl","$topic"]}, +{"name":"ts-2801","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":75},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":50,"@d:x":0}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],0],["$e3","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],4],["$e9","m.room.member","@b:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],0]],"states":[["$alice","$create","$e3","$e9","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e3","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e3","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]}, +{"name":"ts-2803","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":25},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":50,"@d:x":75}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],5],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],5],["$e1","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],2],["$e3","m.room.member","@d:x","@b:x",{"membership":"invite"},["$create","$jr","$pl","$join-bob","$e1"],0]],"states":[["$alice","$create","$e3","$join-bob","$join-charlie","$jr","$name","$pl","$topic"],["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$join-bob","$join-charlie","$jr","$name","$pl","$topic"]}, +{"name":"ts-2804","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],3],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@a:x":100,"@b:x":75,"@c:x":0,"@d:x":25}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],5],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],5],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e7","m.room.topic","","@b:x",{"topic":"0"},["$create","$pl","$join-bob"],4],["$e2","m.room.member","@b:x","@b:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl","$join-bob"],4],["$e4","m.room.topic","","@a:x",{"topic":"2"},["$create","$pl","$alice"],2],["$e8","m.room.join_rules","","@b:x",{"join_rule":"public"},["$create","$pl","$e2"],4]],"states":[["$alice","$create","$e2","$e4","$e8","$join-charlie","$join-dave","$name","$pl"],["$alice","$create","$e7","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e2","$e7","$e8","$join-charlie","$join-dave","$name","$pl"]}, +{"name":"ts-2836","tags":["event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":50},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":75,"@c:x":25,"@d:x":0}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],2],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],3],["$e2","m.room.join_rules","","@b:x",{"join_rule":"invite"},["$create","$pl","$join-bob"],2],["$e7","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$create","$e2","$pl","$join-dave"],4],["$e8","m.room.member","@c:x","@b:x",{"membership":"ban"},["$create","$pl","$join-bob","$join-charlie"],1]],"states":[["$alice","$create","$e2","$e7","$join-bob","$join-charlie","$name","$pl","$topic"],["$alice","$create","$e2","$e8","$join-bob","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e2","$e8","$join-bob","$join-dave","$name","$pl","$topic"]}, +{"name":"ts-2851","tags":["timestamp_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],3],["$pl","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":100,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":75},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":25,"@c:x":50,"@d:x":0}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],0],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],4],["$e3","m.room.topic","","@a:x",{"topic":"0"},["$create","$pl","$alice"],2],["$e5","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],4],["$e8","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],3],["$e9","m.room.member","@b:x","@b:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl","$join-bob"],5]],"states":[["$alice","$create","$e8","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl"],["$alice","$create","$e3","$e5","$e9","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e5","$e8","$e9","$join-dave","$jr","$name","$pl"]}, +{"name":"ts-2861","tags":[],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":0,"@d:x":0}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],2],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],3],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],5],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e1","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-charlie"],0],["$e2","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$create","$pl","$alice"],2],["$e5","m.room.member","@b:x","@b:x",{"displayname":"1","membership":"join"},["$create","$e2","$pl","$join-bob"],3]],"states":[["$alice","$create","$e1","$e2","$e5","$join-dave","$name","$pl","$topic"],["$alice","$create","$e1","$e2","$e5","$join-dave","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e2","$e5","$join-dave","$name","$pl","$topic"]}, +{"name":"ts-2878","tags":["timestamp_tiebreak","event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":0},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":0,"@c:x":0,"@d:x":0}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],1],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],0],["$e1","m.room.member","@b:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],0],["$e2","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-charlie"],5],["$e4","m.room.member","@d:x","@d:x",{"membership":"leave"},["$create","$pl","$join-dave"],2]],"states":[["$alice","$create","$e1","$e2","$e4","$jr","$name","$pl","$topic"],["$alice","$create","$e1","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e4","$join-charlie","$jr","$name","$pl","$topic"]}, +{"name":"ts-2941","tags":[],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],0],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],4],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":0,"@d:x":25}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],1],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],1],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],0]],"states":[["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$join-bob","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-2950","tags":["event_id_tiebreak"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],5],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],1],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":50,"m.room.topic":50},"kick":50,"state_default":50,"users":{"@a:x":100,"@b:x":0,"@c:x":75,"@d:x":0}},["$create","$alice"],4],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],3],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],1],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],2],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],0],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],0],["$e1","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl","$join-charlie"],5],["$e5","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$create","$jr","$pl","$join-dave"],4],["$e2","m.room.member","@d:x","@d:x",{"displayname":"2","membership":"join"},["$create","$jr","$pl","$join-dave"],4]],"states":[["$alice","$create","$e1","$e2","$join-bob","$jr","$name","$pl","$topic"],["$alice","$create","$e1","$e5","$join-bob","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e1","$e5","$join-bob","$jr","$name","$pl","$topic"]}, +{"name":"ts-2963","tags":["timestamp_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],2],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],5],["$pl","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":50},"kick":25,"state_default":0,"users":{"@a:x":100,"@b:x":50,"@c:x":0,"@d:x":0}},["$create","$alice"],3],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],1],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],2],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],5],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],4],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e1","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],0],["$e4","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$create","$jr","$pl"],5],["$e6","m.room.topic","","@a:x",{"topic":"1"},["$create","$pl","$alice"],3]],"states":[["$alice","$create","$e1","$join-bob","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e1","$e4","$e6","$join-bob","$join-dave","$jr","$name","$pl"]],"resolved":["$alice","$create","$e1","$e4","$join-bob","$join-dave","$jr","$name","$pl","$topic"]}, +{"name":"ts-2990","tags":["power_tiebreak","absence_conflict"],"events":[["$create","m.room.create","","@a:x",{"room_version":"11"},[],4],["$alice","m.room.member","@a:x","@a:x",{"membership":"join"},["$create"],2],["$pl","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":25},"kick":75,"state_default":50,"users":{"@a:x":100,"@b:x":50,"@c:x":50,"@d:x":50}},["$create","$alice"],1],["$jr","m.room.join_rules","","@a:x",{"join_rule":"public"},["$create","$pl","$alice"],3],["$join-bob","m.room.member","@b:x","@b:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-charlie","m.room.member","@c:x","@c:x",{"membership":"join"},["$create","$jr","$pl"],4],["$join-dave","m.room.member","@d:x","@d:x",{"membership":"join"},["$create","$jr","$pl"],4],["$topic","m.room.topic","","@a:x",{"topic":"base"},["$create","$pl","$alice"],3],["$name","m.room.name","","@a:x",{"name":"base"},["$create","$pl","$alice"],2],["$e2","m.room.member","@c:x","@c:x",{"membership":"leave"},["$create","$pl","$join-charlie"],0],["$e4","m.room.member","@b:x","@b:x",{"membership":"leave"},["$create","$pl","$join-bob"],5],["$e3","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$create","$pl","$join-dave"],0],["$e7","m.room.member","@e:x","@a:x",{"membership":"leave"},["$create","$pl","$alice"],3]],"states":[["$alice","$create","$e2","$join-bob","$join-dave","$jr","$name","$pl","$topic"],["$alice","$create","$e3","$join-bob","$join-charlie","$join-dave","$name","$pl","$topic"],["$alice","$create","$e4","$e7","$join-charlie","$join-dave","$jr","$name","$pl","$topic"]],"resolved":["$alice","$create","$e2","$e3","$e4","$e7","$join-dave","$name","$pl","$topic"]} +]} diff --git a/internal/stateres/testdata/synapse_v12.json b/internal/stateres/testdata/synapse_v12.json new file mode 100644 index 0000000..88bb4ba --- /dev/null +++ b/internal/stateres/testdata/synapse_v12.json @@ -0,0 +1,124 @@ +{"forks":[ +{"name":"rej-32","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@b:x":75,"@c:x":0,"@d:x":0}},["$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78"],5],["$3PK7dyotiLL6YeJslUUK-yNXmhqm4ZUFn2tNa8QCJlg","m.room.join_rules","","@a:x",{"join_rule":"public"},["$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78"],3],["$EAJ3Js6avbUk5j7lr8nmtAehzaSVlXHvkm_6UoEAxm0","m.room.member","@b:x","@b:x",{"membership":"join"},["$3PK7dyotiLL6YeJslUUK-yNXmhqm4ZUFn2tNa8QCJlg","$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8"],1],["$ltqJYN8LkHigzq0suGjlywUwXC7DLTTb4yIygXRnRy0","m.room.member","@c:x","@c:x",{"membership":"join"},["$3PK7dyotiLL6YeJslUUK-yNXmhqm4ZUFn2tNa8QCJlg","$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8"],3],["$vJzz0oYdX4UsZgKmDM0dn-ArgmdYjzS-VOK8iFjYmDA","m.room.member","@d:x","@d:x",{"membership":"join"},["$3PK7dyotiLL6YeJslUUK-yNXmhqm4ZUFn2tNa8QCJlg","$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8"],1],["$9ehcZGkfoTAttM_jGIVhvEfVD083uFs8R217bTgBk3A","m.room.name","","@a:x",{"name":"base"},["$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78"],3],["$glY1MOeF7umA1IPXSm1T0nK1MZQo4cO-5YCvYRwim7o","m.room.topic","","@a:x",{"topic":"0"},["$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78"],2],["$JbYDDwQTDlljblG_ec38-oM0WexZq9hOX1ateZ2BOJo","m.room.topic","","@a:x",{"topic":"1"},["$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78"],3],["$3_7z5VIHrcqU4DgieWm189exnd1a2bPBsWGQdFx_wLo","m.room.member","@e:x","@a:x",{"membership":"leave"},["$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78"],2],["$AslblKm4iazbV3ZborACVe1OpaCzfhABvyz_EE1Gxb8","m.room.member","@b:x","@b:x",{"displayname":"2","membership":"join"},["$3PK7dyotiLL6YeJslUUK-yNXmhqm4ZUFn2tNa8QCJlg","$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8","$EAJ3Js6avbUk5j7lr8nmtAehzaSVlXHvkm_6UoEAxm0"],3],["$sAjIRhH_VAkMUiBYouspK5hpegAb7kaBlZjBMjQfIi0","m.room.member","@e:x","@e:x",{"displayname":"3","membership":"join"},["$3PK7dyotiLL6YeJslUUK-yNXmhqm4ZUFn2tNa8QCJlg","$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8"],4],["$8eyULIr2s2jCKoIC5s2rIGf5Hs3h5RMorymGrmTNo_M","m.room.member","@e:x","@b:x",{"membership":"leave"},["$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8","$EAJ3Js6avbUk5j7lr8nmtAehzaSVlXHvkm_6UoEAxm0","$sAjIRhH_VAkMUiBYouspK5hpegAb7kaBlZjBMjQfIi0"],5,true]],"states":[["$3PK7dyotiLL6YeJslUUK-yNXmhqm4ZUFn2tNa8QCJlg","$8eyULIr2s2jCKoIC5s2rIGf5Hs3h5RMorymGrmTNo_M","$9ehcZGkfoTAttM_jGIVhvEfVD083uFs8R217bTgBk3A","$EAJ3Js6avbUk5j7lr8nmtAehzaSVlXHvkm_6UoEAxm0","$JbYDDwQTDlljblG_ec38-oM0WexZq9hOX1ateZ2BOJo","$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78","$ltqJYN8LkHigzq0suGjlywUwXC7DLTTb4yIygXRnRy0","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$vJzz0oYdX4UsZgKmDM0dn-ArgmdYjzS-VOK8iFjYmDA"],["$3PK7dyotiLL6YeJslUUK-yNXmhqm4ZUFn2tNa8QCJlg","$3_7z5VIHrcqU4DgieWm189exnd1a2bPBsWGQdFx_wLo","$9ehcZGkfoTAttM_jGIVhvEfVD083uFs8R217bTgBk3A","$AslblKm4iazbV3ZborACVe1OpaCzfhABvyz_EE1Gxb8","$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78","$glY1MOeF7umA1IPXSm1T0nK1MZQo4cO-5YCvYRwim7o","$ltqJYN8LkHigzq0suGjlywUwXC7DLTTb4yIygXRnRy0","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$vJzz0oYdX4UsZgKmDM0dn-ArgmdYjzS-VOK8iFjYmDA"]],"resolved":["$3PK7dyotiLL6YeJslUUK-yNXmhqm4ZUFn2tNa8QCJlg","$9ehcZGkfoTAttM_jGIVhvEfVD083uFs8R217bTgBk3A","$AslblKm4iazbV3ZborACVe1OpaCzfhABvyz_EE1Gxb8","$JbYDDwQTDlljblG_ec38-oM0WexZq9hOX1ateZ2BOJo","$W0YKAC3ZjzWzWdwWbYW9DC7L3woHowr99TpJQ2yWBW8","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78","$ltqJYN8LkHigzq0suGjlywUwXC7DLTTb4yIygXRnRy0","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$sAjIRhH_VAkMUiBYouspK5hpegAb7kaBlZjBMjQfIi0","$vJzz0oYdX4UsZgKmDM0dn-ArgmdYjzS-VOK8iFjYmDA"]}, +{"name":"rej-202","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$TpQESxOqkiL0fWjFIaCRRS8HT5RwONYxSeqTRbnsRu8","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":75},"kick":50,"state_default":50,"users":{"@b:x":75,"@c:x":0,"@d:x":25}},["$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],0],["$dNOqjRIuigpVA4b0_ZTGxQxcDpQK9or6dWb72XYvSgs","m.room.join_rules","","@a:x",{"join_rule":"public"},["$TpQESxOqkiL0fWjFIaCRRS8HT5RwONYxSeqTRbnsRu8","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],2],["$aBIAL7bD8VgNkEAEnhoLpl3kVyeHn6ssl1FFqFCzgdU","m.room.member","@b:x","@b:x",{"membership":"join"},["$dNOqjRIuigpVA4b0_ZTGxQxcDpQK9or6dWb72XYvSgs","$TpQESxOqkiL0fWjFIaCRRS8HT5RwONYxSeqTRbnsRu8"],5],["$djw5PUgNsAi4cZOHyXS-zGXjqmDytNzsDtYeqAFceSk","m.room.member","@c:x","@c:x",{"membership":"join"},["$dNOqjRIuigpVA4b0_ZTGxQxcDpQK9or6dWb72XYvSgs","$TpQESxOqkiL0fWjFIaCRRS8HT5RwONYxSeqTRbnsRu8"],1],["$Ahik9ULgIlnAm4xeur0XhVpcz_r3L_z932-HcaLm7KE","m.room.member","@d:x","@d:x",{"membership":"join"},["$dNOqjRIuigpVA4b0_ZTGxQxcDpQK9or6dWb72XYvSgs","$TpQESxOqkiL0fWjFIaCRRS8HT5RwONYxSeqTRbnsRu8"],0],["$CaxJLb1gCYwBWng-q4EH24v7CqXJ-tHtx2BYkhYrKO8","m.room.topic","","@a:x",{"topic":"base"},["$TpQESxOqkiL0fWjFIaCRRS8HT5RwONYxSeqTRbnsRu8","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],4],["$ypNZ8NWLy1qqNGJhZVYCiZwJF-sZf8ZIi8z3xXp8faE","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$dNOqjRIuigpVA4b0_ZTGxQxcDpQK9or6dWb72XYvSgs","$TpQESxOqkiL0fWjFIaCRRS8HT5RwONYxSeqTRbnsRu8","$aBIAL7bD8VgNkEAEnhoLpl3kVyeHn6ssl1FFqFCzgdU"],2],["$MVmxD1UoD7_CLumb6eU-H7Q3if0ijc1r47FusJpKqzM","m.room.member","@e:x","@a:x",{"membership":"ban"},["$TpQESxOqkiL0fWjFIaCRRS8HT5RwONYxSeqTRbnsRu8","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],0],["$9At--pKI2BAz514GbkkMmbT551wPxaXoGFDw662dCsg","m.room.name","","@a:x",{"name":"1"},["$TpQESxOqkiL0fWjFIaCRRS8HT5RwONYxSeqTRbnsRu8","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],0],["$9UY_IQPW5bhQKNbwj8s_PF-gi0304DVYVv2TZlXcjOo","m.room.member","@e:x","@a:x",{"membership":"ban"},["$TpQESxOqkiL0fWjFIaCRRS8HT5RwONYxSeqTRbnsRu8","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI","$MVmxD1UoD7_CLumb6eU-H7Q3if0ijc1r47FusJpKqzM"],1,true]],"states":[["$9At--pKI2BAz514GbkkMmbT551wPxaXoGFDw662dCsg","$Ahik9ULgIlnAm4xeur0XhVpcz_r3L_z932-HcaLm7KE","$CaxJLb1gCYwBWng-q4EH24v7CqXJ-tHtx2BYkhYrKO8","$MVmxD1UoD7_CLumb6eU-H7Q3if0ijc1r47FusJpKqzM","$TpQESxOqkiL0fWjFIaCRRS8HT5RwONYxSeqTRbnsRu8","$dNOqjRIuigpVA4b0_ZTGxQxcDpQK9or6dWb72XYvSgs","$djw5PUgNsAi4cZOHyXS-zGXjqmDytNzsDtYeqAFceSk","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI","$ypNZ8NWLy1qqNGJhZVYCiZwJF-sZf8ZIi8z3xXp8faE"],["$9At--pKI2BAz514GbkkMmbT551wPxaXoGFDw662dCsg","$9UY_IQPW5bhQKNbwj8s_PF-gi0304DVYVv2TZlXcjOo","$Ahik9ULgIlnAm4xeur0XhVpcz_r3L_z932-HcaLm7KE","$CaxJLb1gCYwBWng-q4EH24v7CqXJ-tHtx2BYkhYrKO8","$TpQESxOqkiL0fWjFIaCRRS8HT5RwONYxSeqTRbnsRu8","$dNOqjRIuigpVA4b0_ZTGxQxcDpQK9or6dWb72XYvSgs","$djw5PUgNsAi4cZOHyXS-zGXjqmDytNzsDtYeqAFceSk","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI","$ypNZ8NWLy1qqNGJhZVYCiZwJF-sZf8ZIi8z3xXp8faE"]],"resolved":["$9At--pKI2BAz514GbkkMmbT551wPxaXoGFDw662dCsg","$Ahik9ULgIlnAm4xeur0XhVpcz_r3L_z932-HcaLm7KE","$CaxJLb1gCYwBWng-q4EH24v7CqXJ-tHtx2BYkhYrKO8","$MVmxD1UoD7_CLumb6eU-H7Q3if0ijc1r47FusJpKqzM","$TpQESxOqkiL0fWjFIaCRRS8HT5RwONYxSeqTRbnsRu8","$dNOqjRIuigpVA4b0_ZTGxQxcDpQK9or6dWb72XYvSgs","$djw5PUgNsAi4cZOHyXS-zGXjqmDytNzsDtYeqAFceSk","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI","$ypNZ8NWLy1qqNGJhZVYCiZwJF-sZf8ZIi8z3xXp8faE"]}, +{"name":"rej-235","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$tMrlR8qcfQwa7gpVGytTq1aCy0R9LfmbHmDf0p5tG0c","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":0},"kick":75,"state_default":50,"users":{"@b:x":25,"@c:x":0,"@d:x":25}},["$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],5],["$9-I1VX3h2ElmJcQwRIasOZBLOK4s1MiStYoeOZDM1JA","m.room.join_rules","","@a:x",{"join_rule":"public"},["$tMrlR8qcfQwa7gpVGytTq1aCy0R9LfmbHmDf0p5tG0c","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],1],["$v_4MVce6ro3-oc-7LT2Gs2HBA-I8WQnioMp0WhGShBk","m.room.member","@b:x","@b:x",{"membership":"join"},["$9-I1VX3h2ElmJcQwRIasOZBLOK4s1MiStYoeOZDM1JA","$tMrlR8qcfQwa7gpVGytTq1aCy0R9LfmbHmDf0p5tG0c"],5],["$l5mz0oeS6RIaa8MHMocJwI6QOnGm9BpDtSl-GaELz6I","m.room.member","@c:x","@c:x",{"membership":"join"},["$9-I1VX3h2ElmJcQwRIasOZBLOK4s1MiStYoeOZDM1JA","$tMrlR8qcfQwa7gpVGytTq1aCy0R9LfmbHmDf0p5tG0c"],5],["$pt6MTYbTI1K10QzS581CAsYMSzFOgF-Aa0q4zIYNY-A","m.room.member","@d:x","@d:x",{"membership":"join"},["$9-I1VX3h2ElmJcQwRIasOZBLOK4s1MiStYoeOZDM1JA","$tMrlR8qcfQwa7gpVGytTq1aCy0R9LfmbHmDf0p5tG0c"],3],["$aAfxSk_l9Rb90t2wN3snkcXloWNuImgY4ZwUF2XAR3E","m.room.topic","","@a:x",{"topic":"base"},["$tMrlR8qcfQwa7gpVGytTq1aCy0R9LfmbHmDf0p5tG0c","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],0],["$RE_WYEv-1LlPT406sEnNI0ANBE_WCAjeDxEb13aE0Qg","m.room.name","","@a:x",{"name":"base"},["$tMrlR8qcfQwa7gpVGytTq1aCy0R9LfmbHmDf0p5tG0c","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],0],["$HuJBmYAOvaU2gdr2i2reIurc2sLIhlJLh_7Ljrl07uI","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$9-I1VX3h2ElmJcQwRIasOZBLOK4s1MiStYoeOZDM1JA","$tMrlR8qcfQwa7gpVGytTq1aCy0R9LfmbHmDf0p5tG0c","$v_4MVce6ro3-oc-7LT2Gs2HBA-I8WQnioMp0WhGShBk"],2],["$BkuhXDNRUWdOJho9YK4Wd2m3olIRWU_0t8G-MdENx90","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$tMrlR8qcfQwa7gpVGytTq1aCy0R9LfmbHmDf0p5tG0c","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],3,true],["$fI8-DCgUqz4i2uKQOZFb48izBcf9PJqzuxs7wOHzzks","m.room.topic","","@d:x",{"topic":"1"},["$tMrlR8qcfQwa7gpVGytTq1aCy0R9LfmbHmDf0p5tG0c","$pt6MTYbTI1K10QzS581CAsYMSzFOgF-Aa0q4zIYNY-A"],5]],"states":[["$9-I1VX3h2ElmJcQwRIasOZBLOK4s1MiStYoeOZDM1JA","$HuJBmYAOvaU2gdr2i2reIurc2sLIhlJLh_7Ljrl07uI","$RE_WYEv-1LlPT406sEnNI0ANBE_WCAjeDxEb13aE0Qg","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$aAfxSk_l9Rb90t2wN3snkcXloWNuImgY4ZwUF2XAR3E","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$l5mz0oeS6RIaa8MHMocJwI6QOnGm9BpDtSl-GaELz6I","$pt6MTYbTI1K10QzS581CAsYMSzFOgF-Aa0q4zIYNY-A","$tMrlR8qcfQwa7gpVGytTq1aCy0R9LfmbHmDf0p5tG0c"],["$BkuhXDNRUWdOJho9YK4Wd2m3olIRWU_0t8G-MdENx90","$RE_WYEv-1LlPT406sEnNI0ANBE_WCAjeDxEb13aE0Qg","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$fI8-DCgUqz4i2uKQOZFb48izBcf9PJqzuxs7wOHzzks","$l5mz0oeS6RIaa8MHMocJwI6QOnGm9BpDtSl-GaELz6I","$pt6MTYbTI1K10QzS581CAsYMSzFOgF-Aa0q4zIYNY-A","$tMrlR8qcfQwa7gpVGytTq1aCy0R9LfmbHmDf0p5tG0c","$v_4MVce6ro3-oc-7LT2Gs2HBA-I8WQnioMp0WhGShBk"]],"resolved":["$9-I1VX3h2ElmJcQwRIasOZBLOK4s1MiStYoeOZDM1JA","$RE_WYEv-1LlPT406sEnNI0ANBE_WCAjeDxEb13aE0Qg","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$fI8-DCgUqz4i2uKQOZFb48izBcf9PJqzuxs7wOHzzks","$l5mz0oeS6RIaa8MHMocJwI6QOnGm9BpDtSl-GaELz6I","$pt6MTYbTI1K10QzS581CAsYMSzFOgF-Aa0q4zIYNY-A","$tMrlR8qcfQwa7gpVGytTq1aCy0R9LfmbHmDf0p5tG0c","$v_4MVce6ro3-oc-7LT2Gs2HBA-I8WQnioMp0WhGShBk"]}, +{"name":"rej-248","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":50,"users":{"@b:x":25,"@c:x":75,"@d:x":50}},["$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],4],["$JZz0yYx3AgaYA2Nsx7IQ2jZED2eONj5ZRTd6wgZB_Ls","m.room.join_rules","","@a:x",{"join_rule":"public"},["$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],4],["$9sFvAwl7CUCf7RGSZbKKIrWRiGdbPiqAHSM3bWjrSmA","m.room.member","@b:x","@b:x",{"membership":"join"},["$JZz0yYx3AgaYA2Nsx7IQ2jZED2eONj5ZRTd6wgZB_Ls","$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM"],5],["$jbRSQHnFKdeYcypaoQAfHfz2HtV7gE2HUAouyUyx_A4","m.room.member","@c:x","@c:x",{"membership":"join"},["$JZz0yYx3AgaYA2Nsx7IQ2jZED2eONj5ZRTd6wgZB_Ls","$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM"],0],["$cheBaM8bciszrdUXPZRcLvbVj70aBRhLptEImum70JU","m.room.member","@d:x","@d:x",{"membership":"join"},["$JZz0yYx3AgaYA2Nsx7IQ2jZED2eONj5ZRTd6wgZB_Ls","$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM"],1],["$6Im4LZBTzoPgKiQAetdafApo8dnzUXbT7NIYWoH_Ui8","m.room.topic","","@a:x",{"topic":"base"},["$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],4],["$BEHrTxeAiSFiGKIc2ZZ0uUOGDhRl9nYNP0kOxNiS-rA","m.room.name","","@a:x",{"name":"base"},["$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],5],["$Mo_Nd75iIoiErx5oBonn_tte_t7mzLbpsXPb0Uy7pa0","m.room.member","@e:x","@d:x",{"membership":"ban"},["$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM","$cheBaM8bciszrdUXPZRcLvbVj70aBRhLptEImum70JU"],1,true],["$UaCScBW1aDqinrnHFsyYYd8KIXBpH8di3_FpvPhDjLw","m.room.join_rules","","@c:x",{"join_rule":"public"},["$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM","$jbRSQHnFKdeYcypaoQAfHfz2HtV7gE2HUAouyUyx_A4"],5],["$v4jDlIg9t0t_aA2Gk9lcV7tBSE9Rlhy81quQdd83DHQ","m.room.member","@c:x","@c:x",{"membership":"leave"},["$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM","$jbRSQHnFKdeYcypaoQAfHfz2HtV7gE2HUAouyUyx_A4"],2],["$CH_nehrBL62_gM4oQbGRGZpjzKCU7mmwm099eR_j1cw","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$JZz0yYx3AgaYA2Nsx7IQ2jZED2eONj5ZRTd6wgZB_Ls","$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM","$jbRSQHnFKdeYcypaoQAfHfz2HtV7gE2HUAouyUyx_A4"],4],["$lDB-zIHu9lap3RJi4u9aJtoZ5nnj4igXDGVydf9IDMo","m.room.member","@c:x","@c:x",{"membership":"leave"},["$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM","$CH_nehrBL62_gM4oQbGRGZpjzKCU7mmwm099eR_j1cw"],5]],"states":[["$6Im4LZBTzoPgKiQAetdafApo8dnzUXbT7NIYWoH_Ui8","$9sFvAwl7CUCf7RGSZbKKIrWRiGdbPiqAHSM3bWjrSmA","$BEHrTxeAiSFiGKIc2ZZ0uUOGDhRl9nYNP0kOxNiS-rA","$JZz0yYx3AgaYA2Nsx7IQ2jZED2eONj5ZRTd6wgZB_Ls","$Mo_Nd75iIoiErx5oBonn_tte_t7mzLbpsXPb0Uy7pa0","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$cheBaM8bciszrdUXPZRcLvbVj70aBRhLptEImum70JU","$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM","$lDB-zIHu9lap3RJi4u9aJtoZ5nnj4igXDGVydf9IDMo","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],["$6Im4LZBTzoPgKiQAetdafApo8dnzUXbT7NIYWoH_Ui8","$9sFvAwl7CUCf7RGSZbKKIrWRiGdbPiqAHSM3bWjrSmA","$BEHrTxeAiSFiGKIc2ZZ0uUOGDhRl9nYNP0kOxNiS-rA","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$UaCScBW1aDqinrnHFsyYYd8KIXBpH8di3_FpvPhDjLw","$cheBaM8bciszrdUXPZRcLvbVj70aBRhLptEImum70JU","$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM","$v4jDlIg9t0t_aA2Gk9lcV7tBSE9Rlhy81quQdd83DHQ","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"]],"resolved":["$6Im4LZBTzoPgKiQAetdafApo8dnzUXbT7NIYWoH_Ui8","$9sFvAwl7CUCf7RGSZbKKIrWRiGdbPiqAHSM3bWjrSmA","$BEHrTxeAiSFiGKIc2ZZ0uUOGDhRl9nYNP0kOxNiS-rA","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$UaCScBW1aDqinrnHFsyYYd8KIXBpH8di3_FpvPhDjLw","$cheBaM8bciszrdUXPZRcLvbVj70aBRhLptEImum70JU","$iVknb_toT4Bh83bLXStDJMZ34i528OgWK2W8CRtEpGM","$lDB-zIHu9lap3RJi4u9aJtoZ5nnj4igXDGVydf9IDMo","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"]}, +{"name":"rej-250","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$k51wgbc0zFASMRuhlRQdQmp1R8OCJa4AEnFrZdQZdUs","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":75},"kick":75,"state_default":50,"users":{"@b:x":50,"@c:x":50,"@d:x":75}},["$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],5],["$Dzsf56x6R1HeZQIZn_9jWYKbvr8_6GG946BuE5maI24","m.room.join_rules","","@a:x",{"join_rule":"public"},["$k51wgbc0zFASMRuhlRQdQmp1R8OCJa4AEnFrZdQZdUs","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],5],["$6MBs3q2vgvXKDEseyWva5XOydkNNMkyy2PMI-PaAJBw","m.room.member","@b:x","@b:x",{"membership":"join"},["$Dzsf56x6R1HeZQIZn_9jWYKbvr8_6GG946BuE5maI24","$k51wgbc0zFASMRuhlRQdQmp1R8OCJa4AEnFrZdQZdUs"],5],["$6NYeSS-agPTQpHxFYq6nZzX0fAmWENXhAtOx1Pptp2k","m.room.member","@c:x","@c:x",{"membership":"join"},["$Dzsf56x6R1HeZQIZn_9jWYKbvr8_6GG946BuE5maI24","$k51wgbc0zFASMRuhlRQdQmp1R8OCJa4AEnFrZdQZdUs"],1],["$evs6D8ZI4tHcMjnApGjSqtureEaCtR8bYf9fHCLJ6VQ","m.room.member","@d:x","@d:x",{"membership":"join"},["$Dzsf56x6R1HeZQIZn_9jWYKbvr8_6GG946BuE5maI24","$k51wgbc0zFASMRuhlRQdQmp1R8OCJa4AEnFrZdQZdUs"],2],["$1VTAQlbBJDhpUQNrEzAVPRG_X_us6pLz3xdomJRWjSQ","m.room.topic","","@a:x",{"topic":"base"},["$k51wgbc0zFASMRuhlRQdQmp1R8OCJa4AEnFrZdQZdUs","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],2],["$KEkJFG7_aazVuc2V7wKP9eleILSCjhz4HTG9DPm9NSo","m.room.name","","@a:x",{"name":"base"},["$k51wgbc0zFASMRuhlRQdQmp1R8OCJa4AEnFrZdQZdUs","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],5],["$4CWz8vCzNXLMP5XC3BfJfYk_Ijrh3eb95brs2cHxTtY","m.room.join_rules","","@a:x",{"join_rule":"public"},["$k51wgbc0zFASMRuhlRQdQmp1R8OCJa4AEnFrZdQZdUs","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],0,true],["$keuyEQ-eCEd8L-1Fa-E9fsnHgLlDMZspQuKAXkb4TBE","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$k51wgbc0zFASMRuhlRQdQmp1R8OCJa4AEnFrZdQZdUs","$evs6D8ZI4tHcMjnApGjSqtureEaCtR8bYf9fHCLJ6VQ"],5],["$xiHjVL6MJO0t69MB0KmaEPPu2OszOEcxWWHBcx-LAK4","m.room.member","@d:x","@d:x",{"membership":"leave"},["$k51wgbc0zFASMRuhlRQdQmp1R8OCJa4AEnFrZdQZdUs","$evs6D8ZI4tHcMjnApGjSqtureEaCtR8bYf9fHCLJ6VQ"],0]],"states":[["$1VTAQlbBJDhpUQNrEzAVPRG_X_us6pLz3xdomJRWjSQ","$6MBs3q2vgvXKDEseyWva5XOydkNNMkyy2PMI-PaAJBw","$6NYeSS-agPTQpHxFYq6nZzX0fAmWENXhAtOx1Pptp2k","$KEkJFG7_aazVuc2V7wKP9eleILSCjhz4HTG9DPm9NSo","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","$k51wgbc0zFASMRuhlRQdQmp1R8OCJa4AEnFrZdQZdUs","$keuyEQ-eCEd8L-1Fa-E9fsnHgLlDMZspQuKAXkb4TBE","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$xiHjVL6MJO0t69MB0KmaEPPu2OszOEcxWWHBcx-LAK4"],["$1VTAQlbBJDhpUQNrEzAVPRG_X_us6pLz3xdomJRWjSQ","$4CWz8vCzNXLMP5XC3BfJfYk_Ijrh3eb95brs2cHxTtY","$6MBs3q2vgvXKDEseyWva5XOydkNNMkyy2PMI-PaAJBw","$6NYeSS-agPTQpHxFYq6nZzX0fAmWENXhAtOx1Pptp2k","$KEkJFG7_aazVuc2V7wKP9eleILSCjhz4HTG9DPm9NSo","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","$evs6D8ZI4tHcMjnApGjSqtureEaCtR8bYf9fHCLJ6VQ","$k51wgbc0zFASMRuhlRQdQmp1R8OCJa4AEnFrZdQZdUs","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"]],"resolved":["$1VTAQlbBJDhpUQNrEzAVPRG_X_us6pLz3xdomJRWjSQ","$6MBs3q2vgvXKDEseyWva5XOydkNNMkyy2PMI-PaAJBw","$6NYeSS-agPTQpHxFYq6nZzX0fAmWENXhAtOx1Pptp2k","$KEkJFG7_aazVuc2V7wKP9eleILSCjhz4HTG9DPm9NSo","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","$k51wgbc0zFASMRuhlRQdQmp1R8OCJa4AEnFrZdQZdUs","$keuyEQ-eCEd8L-1Fa-E9fsnHgLlDMZspQuKAXkb4TBE","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$xiHjVL6MJO0t69MB0KmaEPPu2OszOEcxWWHBcx-LAK4"]}, +{"name":"rej-566","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$nDwdAUCN-6CGhvSTFbk8Q5VqENVI_u5hw2F5Ze13yu8","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":25},"kick":50,"state_default":50,"users":{"@b:x":25,"@c:x":0,"@d:x":75}},["$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw"],1],["$t1oCLCkE4-OlQ4hS_B_WSX7dgaTIGmdktu9ibvsLefc","m.room.join_rules","","@a:x",{"join_rule":"public"},["$nDwdAUCN-6CGhvSTFbk8Q5VqENVI_u5hw2F5Ze13yu8","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw"],3],["$fNnxqr1OuhRLbCa3bIcn7JQObDDaZZrygZjur1Ph2t4","m.room.member","@b:x","@b:x",{"membership":"join"},["$t1oCLCkE4-OlQ4hS_B_WSX7dgaTIGmdktu9ibvsLefc","$nDwdAUCN-6CGhvSTFbk8Q5VqENVI_u5hw2F5Ze13yu8"],4],["$n0462qkH_5I28rrru0swAXwOkUBv7Hf-tk5fGp3Ri5Q","m.room.member","@c:x","@c:x",{"membership":"join"},["$t1oCLCkE4-OlQ4hS_B_WSX7dgaTIGmdktu9ibvsLefc","$nDwdAUCN-6CGhvSTFbk8Q5VqENVI_u5hw2F5Ze13yu8"],3],["$4TVQyux-EadFTK48TiHBmRimS4LLvbnNc7xJ3AHUjvQ","m.room.member","@d:x","@d:x",{"membership":"join"},["$t1oCLCkE4-OlQ4hS_B_WSX7dgaTIGmdktu9ibvsLefc","$nDwdAUCN-6CGhvSTFbk8Q5VqENVI_u5hw2F5Ze13yu8"],4],["$pmB0f7KmeVkwIuHtEl8ePamCc1RN7gQ-vfMm5ZWAsKo","m.room.topic","","@d:x",{"topic":"0"},["$nDwdAUCN-6CGhvSTFbk8Q5VqENVI_u5hw2F5Ze13yu8","$4TVQyux-EadFTK48TiHBmRimS4LLvbnNc7xJ3AHUjvQ"],0],["$N7-vQVjHLI0XcQj9_6tPDfTW8EKct_6iMbPJTaBqYeU","m.room.member","@d:x","@d:x",{"membership":"leave"},["$nDwdAUCN-6CGhvSTFbk8Q5VqENVI_u5hw2F5Ze13yu8","$4TVQyux-EadFTK48TiHBmRimS4LLvbnNc7xJ3AHUjvQ"],5],["$2-Olnfmgp7uF69xxUd_gPWVmQp7dTdCV11QSsnVjgBY","m.room.name","","@a:x",{"name":"1"},["$nDwdAUCN-6CGhvSTFbk8Q5VqENVI_u5hw2F5Ze13yu8","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw"],3],["$9QBdPiEvRJ00pORpGAA6n5SLcWSFPwaACBESANXxTEc","m.room.join_rules","","@a:x",{"join_rule":"public"},["$nDwdAUCN-6CGhvSTFbk8Q5VqENVI_u5hw2F5Ze13yu8","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw"],3,true]],"states":[["$2-Olnfmgp7uF69xxUd_gPWVmQp7dTdCV11QSsnVjgBY","$9QBdPiEvRJ00pORpGAA6n5SLcWSFPwaACBESANXxTEc","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw","$N7-vQVjHLI0XcQj9_6tPDfTW8EKct_6iMbPJTaBqYeU","$fNnxqr1OuhRLbCa3bIcn7JQObDDaZZrygZjur1Ph2t4","$n0462qkH_5I28rrru0swAXwOkUBv7Hf-tk5fGp3Ri5Q","$nDwdAUCN-6CGhvSTFbk8Q5VqENVI_u5hw2F5Ze13yu8","$pmB0f7KmeVkwIuHtEl8ePamCc1RN7gQ-vfMm5ZWAsKo","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"],["$2-Olnfmgp7uF69xxUd_gPWVmQp7dTdCV11QSsnVjgBY","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw","$N7-vQVjHLI0XcQj9_6tPDfTW8EKct_6iMbPJTaBqYeU","$fNnxqr1OuhRLbCa3bIcn7JQObDDaZZrygZjur1Ph2t4","$n0462qkH_5I28rrru0swAXwOkUBv7Hf-tk5fGp3Ri5Q","$nDwdAUCN-6CGhvSTFbk8Q5VqENVI_u5hw2F5Ze13yu8","$pmB0f7KmeVkwIuHtEl8ePamCc1RN7gQ-vfMm5ZWAsKo","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$t1oCLCkE4-OlQ4hS_B_WSX7dgaTIGmdktu9ibvsLefc"],["$2-Olnfmgp7uF69xxUd_gPWVmQp7dTdCV11QSsnVjgBY","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw","$N7-vQVjHLI0XcQj9_6tPDfTW8EKct_6iMbPJTaBqYeU","$fNnxqr1OuhRLbCa3bIcn7JQObDDaZZrygZjur1Ph2t4","$n0462qkH_5I28rrru0swAXwOkUBv7Hf-tk5fGp3Ri5Q","$nDwdAUCN-6CGhvSTFbk8Q5VqENVI_u5hw2F5Ze13yu8","$pmB0f7KmeVkwIuHtEl8ePamCc1RN7gQ-vfMm5ZWAsKo","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$t1oCLCkE4-OlQ4hS_B_WSX7dgaTIGmdktu9ibvsLefc"]],"resolved":["$2-Olnfmgp7uF69xxUd_gPWVmQp7dTdCV11QSsnVjgBY","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw","$N7-vQVjHLI0XcQj9_6tPDfTW8EKct_6iMbPJTaBqYeU","$fNnxqr1OuhRLbCa3bIcn7JQObDDaZZrygZjur1Ph2t4","$n0462qkH_5I28rrru0swAXwOkUBv7Hf-tk5fGp3Ri5Q","$nDwdAUCN-6CGhvSTFbk8Q5VqENVI_u5hw2F5Ze13yu8","$pmB0f7KmeVkwIuHtEl8ePamCc1RN7gQ-vfMm5ZWAsKo","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$t1oCLCkE4-OlQ4hS_B_WSX7dgaTIGmdktu9ibvsLefc"]}, +{"name":"rej-938","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$l2iDZ4MTQhPjx6BDCZsruqU56mLrjtcwaIH1Z3kvsaU","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@b:x":50,"@c:x":0,"@d:x":50}},["$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],0],["$po_d5IDI03WmJCakMKu9req4n46gT93KdoryXytwf58","m.room.join_rules","","@a:x",{"join_rule":"public"},["$l2iDZ4MTQhPjx6BDCZsruqU56mLrjtcwaIH1Z3kvsaU","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],1],["$HwoTJcl8XQPh4O9FWAPH36ToSm3Cy4j1PzyD0UmT8jg","m.room.member","@b:x","@b:x",{"membership":"join"},["$po_d5IDI03WmJCakMKu9req4n46gT93KdoryXytwf58","$l2iDZ4MTQhPjx6BDCZsruqU56mLrjtcwaIH1Z3kvsaU"],2],["$GADM1je-1NxjIMcWDOOGxMedjVO2-WvnKuN0mm0wWHg","m.room.member","@c:x","@c:x",{"membership":"join"},["$po_d5IDI03WmJCakMKu9req4n46gT93KdoryXytwf58","$l2iDZ4MTQhPjx6BDCZsruqU56mLrjtcwaIH1Z3kvsaU"],5],["$0J8EnCewcwfoMGZ0sLY4CgW_b1jhoOnG3afWqkpKDXQ","m.room.member","@d:x","@d:x",{"membership":"join"},["$po_d5IDI03WmJCakMKu9req4n46gT93KdoryXytwf58","$l2iDZ4MTQhPjx6BDCZsruqU56mLrjtcwaIH1Z3kvsaU"],3],["$d670g_hZ_QZZOIZ1zJxaT0kMLHhwkGvERRlIk8gbYKw","m.room.topic","","@a:x",{"topic":"base"},["$l2iDZ4MTQhPjx6BDCZsruqU56mLrjtcwaIH1Z3kvsaU","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],3],["$_cSUsOKX-4zJ4t_BmyAc-z_kNaX-AZSVE5qBNYyJuLw","m.room.name","","@a:x",{"name":"base"},["$l2iDZ4MTQhPjx6BDCZsruqU56mLrjtcwaIH1Z3kvsaU","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],4],["$NGPBes0aFXVFX9ILx5G0msLfs39tF7mInfFohM6Ba_s","m.room.member","@c:x","@c:x",{"membership":"leave"},["$l2iDZ4MTQhPjx6BDCZsruqU56mLrjtcwaIH1Z3kvsaU","$GADM1je-1NxjIMcWDOOGxMedjVO2-WvnKuN0mm0wWHg"],0],["$-ocHE1L_fTJikkY6NMh5iteOlMoK6tWGU382C8HTZRc","m.room.join_rules","","@a:x",{"join_rule":"public"},["$l2iDZ4MTQhPjx6BDCZsruqU56mLrjtcwaIH1Z3kvsaU","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],5,true]],"states":[["$0J8EnCewcwfoMGZ0sLY4CgW_b1jhoOnG3afWqkpKDXQ","$HwoTJcl8XQPh4O9FWAPH36ToSm3Cy4j1PzyD0UmT8jg","$NGPBes0aFXVFX9ILx5G0msLfs39tF7mInfFohM6Ba_s","$_cSUsOKX-4zJ4t_BmyAc-z_kNaX-AZSVE5qBNYyJuLw","$d670g_hZ_QZZOIZ1zJxaT0kMLHhwkGvERRlIk8gbYKw","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","$l2iDZ4MTQhPjx6BDCZsruqU56mLrjtcwaIH1Z3kvsaU","$po_d5IDI03WmJCakMKu9req4n46gT93KdoryXytwf58","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"],["$-ocHE1L_fTJikkY6NMh5iteOlMoK6tWGU382C8HTZRc","$0J8EnCewcwfoMGZ0sLY4CgW_b1jhoOnG3afWqkpKDXQ","$GADM1je-1NxjIMcWDOOGxMedjVO2-WvnKuN0mm0wWHg","$HwoTJcl8XQPh4O9FWAPH36ToSm3Cy4j1PzyD0UmT8jg","$_cSUsOKX-4zJ4t_BmyAc-z_kNaX-AZSVE5qBNYyJuLw","$d670g_hZ_QZZOIZ1zJxaT0kMLHhwkGvERRlIk8gbYKw","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","$l2iDZ4MTQhPjx6BDCZsruqU56mLrjtcwaIH1Z3kvsaU","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"]],"resolved":["$0J8EnCewcwfoMGZ0sLY4CgW_b1jhoOnG3afWqkpKDXQ","$GADM1je-1NxjIMcWDOOGxMedjVO2-WvnKuN0mm0wWHg","$HwoTJcl8XQPh4O9FWAPH36ToSm3Cy4j1PzyD0UmT8jg","$_cSUsOKX-4zJ4t_BmyAc-z_kNaX-AZSVE5qBNYyJuLw","$d670g_hZ_QZZOIZ1zJxaT0kMLHhwkGvERRlIk8gbYKw","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","$l2iDZ4MTQhPjx6BDCZsruqU56mLrjtcwaIH1Z3kvsaU","$po_d5IDI03WmJCakMKu9req4n46gT93KdoryXytwf58","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"]}, +{"name":"rej-1022","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$46IkHnpfEYyHpfHBhMi5oFwMcmOTAPoSKk99UnaunSU","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":0},"kick":25,"state_default":0,"users":{"@b:x":75,"@c:x":75,"@d:x":0}},["$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],2],["$6zHqjnkzpQtMzYFuYCdvTXYa4Wk-drVZ7iFmwoqXUpE","m.room.join_rules","","@a:x",{"join_rule":"public"},["$46IkHnpfEYyHpfHBhMi5oFwMcmOTAPoSKk99UnaunSU","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],1],["$DrW8KOc8pKIYAMwlJnvqiXbbk6vanAegcIcg12pGdaM","m.room.member","@b:x","@b:x",{"membership":"join"},["$6zHqjnkzpQtMzYFuYCdvTXYa4Wk-drVZ7iFmwoqXUpE","$46IkHnpfEYyHpfHBhMi5oFwMcmOTAPoSKk99UnaunSU"],0],["$3d4Mm0q9N_xSR13bDwDGNPJcfG1MWTE10eWHbWoxvvI","m.room.member","@c:x","@c:x",{"membership":"join"},["$6zHqjnkzpQtMzYFuYCdvTXYa4Wk-drVZ7iFmwoqXUpE","$46IkHnpfEYyHpfHBhMi5oFwMcmOTAPoSKk99UnaunSU"],3],["$ahRbGB1aCAqCIPxUPb12n9ne9w8HQIkrJHU7XZeAsv8","m.room.member","@d:x","@d:x",{"membership":"join"},["$6zHqjnkzpQtMzYFuYCdvTXYa4Wk-drVZ7iFmwoqXUpE","$46IkHnpfEYyHpfHBhMi5oFwMcmOTAPoSKk99UnaunSU"],1],["$gmoxndsHeqneJJJrF6YeA64Azol5FJnqqdvrXXXYL4I","m.room.name","","@a:x",{"name":"base"},["$46IkHnpfEYyHpfHBhMi5oFwMcmOTAPoSKk99UnaunSU","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],2],["$Y8TljKNdCVog3L4VKtbE332BGND0uO5cALU0hT5LqW0","m.room.member","@e:x","@a:x",{"membership":"invite"},["$6zHqjnkzpQtMzYFuYCdvTXYa4Wk-drVZ7iFmwoqXUpE","$46IkHnpfEYyHpfHBhMi5oFwMcmOTAPoSKk99UnaunSU","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],3],["$NhcMO6ZPDsWaKZDNbMOUeavGuZeeV1D0TNpbQ2NMckA","m.room.topic","","@a:x",{"topic":"0"},["$46IkHnpfEYyHpfHBhMi5oFwMcmOTAPoSKk99UnaunSU","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],2],["$q0yJp39mBGVmwF0xxSwvuSCdEEYSDwtXZZL6okV_LWw","m.room.join_rules","","@a:x",{"join_rule":"public"},["$46IkHnpfEYyHpfHBhMi5oFwMcmOTAPoSKk99UnaunSU","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],0,true],["$yWl3CXmxVA5SYE5BOdsmz7iXkmZDNIhdWUiaiNljseI","m.room.topic","","@b:x",{"topic":"1"},["$46IkHnpfEYyHpfHBhMi5oFwMcmOTAPoSKk99UnaunSU","$DrW8KOc8pKIYAMwlJnvqiXbbk6vanAegcIcg12pGdaM"],3]],"states":[["$3d4Mm0q9N_xSR13bDwDGNPJcfG1MWTE10eWHbWoxvvI","$46IkHnpfEYyHpfHBhMi5oFwMcmOTAPoSKk99UnaunSU","$6zHqjnkzpQtMzYFuYCdvTXYa4Wk-drVZ7iFmwoqXUpE","$DrW8KOc8pKIYAMwlJnvqiXbbk6vanAegcIcg12pGdaM","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$Y8TljKNdCVog3L4VKtbE332BGND0uO5cALU0hT5LqW0","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$ahRbGB1aCAqCIPxUPb12n9ne9w8HQIkrJHU7XZeAsv8","$gmoxndsHeqneJJJrF6YeA64Azol5FJnqqdvrXXXYL4I","$yWl3CXmxVA5SYE5BOdsmz7iXkmZDNIhdWUiaiNljseI"],["$3d4Mm0q9N_xSR13bDwDGNPJcfG1MWTE10eWHbWoxvvI","$46IkHnpfEYyHpfHBhMi5oFwMcmOTAPoSKk99UnaunSU","$DrW8KOc8pKIYAMwlJnvqiXbbk6vanAegcIcg12pGdaM","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$NhcMO6ZPDsWaKZDNbMOUeavGuZeeV1D0TNpbQ2NMckA","$Y8TljKNdCVog3L4VKtbE332BGND0uO5cALU0hT5LqW0","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$ahRbGB1aCAqCIPxUPb12n9ne9w8HQIkrJHU7XZeAsv8","$gmoxndsHeqneJJJrF6YeA64Azol5FJnqqdvrXXXYL4I","$q0yJp39mBGVmwF0xxSwvuSCdEEYSDwtXZZL6okV_LWw"]],"resolved":["$3d4Mm0q9N_xSR13bDwDGNPJcfG1MWTE10eWHbWoxvvI","$46IkHnpfEYyHpfHBhMi5oFwMcmOTAPoSKk99UnaunSU","$6zHqjnkzpQtMzYFuYCdvTXYa4Wk-drVZ7iFmwoqXUpE","$DrW8KOc8pKIYAMwlJnvqiXbbk6vanAegcIcg12pGdaM","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$Y8TljKNdCVog3L4VKtbE332BGND0uO5cALU0hT5LqW0","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$ahRbGB1aCAqCIPxUPb12n9ne9w8HQIkrJHU7XZeAsv8","$gmoxndsHeqneJJJrF6YeA64Azol5FJnqqdvrXXXYL4I","$yWl3CXmxVA5SYE5BOdsmz7iXkmZDNIhdWUiaiNljseI"]}, +{"name":"rej-1122","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$_YrS4iRhHuywAi_dOQKg2geV09N4Gb6pmUokJOPOIsY","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":25},"kick":50,"state_default":50,"users":{"@b:x":50,"@c:x":0,"@d:x":50}},["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w"],1],["$qg6murAuMLC4j1mtoypyKKDto0zBSkZGsl81EawPZDo","m.room.join_rules","","@a:x",{"join_rule":"public"},["$_YrS4iRhHuywAi_dOQKg2geV09N4Gb6pmUokJOPOIsY","$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w"],0],["$RQlCG3TFy-WHL-_Fa6chsfo1VkvcyZ0yohRWS9J3kx8","m.room.member","@b:x","@b:x",{"membership":"join"},["$qg6murAuMLC4j1mtoypyKKDto0zBSkZGsl81EawPZDo","$_YrS4iRhHuywAi_dOQKg2geV09N4Gb6pmUokJOPOIsY"],1],["$dw-_TeKNZesnZx7od4SWh1XQtvDhX3lbmvcjHNQQGdw","m.room.member","@c:x","@c:x",{"membership":"join"},["$qg6murAuMLC4j1mtoypyKKDto0zBSkZGsl81EawPZDo","$_YrS4iRhHuywAi_dOQKg2geV09N4Gb6pmUokJOPOIsY"],1],["$iFZXrOIMEYzuWKF1xvZ07oqLtcVK8KzDOFdEPhJzBMU","m.room.member","@d:x","@d:x",{"membership":"join"},["$qg6murAuMLC4j1mtoypyKKDto0zBSkZGsl81EawPZDo","$_YrS4iRhHuywAi_dOQKg2geV09N4Gb6pmUokJOPOIsY"],0],["$_AHva_-u-SiDtS0ezrHihP07yd1Gjyyd-zxZTTH8SVU","m.room.topic","","@a:x",{"topic":"base"},["$_YrS4iRhHuywAi_dOQKg2geV09N4Gb6pmUokJOPOIsY","$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w"],5],["$L9ZTTuBJc2Y7Fpc52RKZlZ-Cc4mAZe2lxJXLyRwLoYo","m.room.name","","@a:x",{"name":"base"},["$_YrS4iRhHuywAi_dOQKg2geV09N4Gb6pmUokJOPOIsY","$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w"],2],["$aJsIqokJrbhL0tC-zZnHk5uIrmdv5rkgM3fx9Ogapa8","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$qg6murAuMLC4j1mtoypyKKDto0zBSkZGsl81EawPZDo","$_YrS4iRhHuywAi_dOQKg2geV09N4Gb6pmUokJOPOIsY"],0],["$MaG5LePAUyidffWOaaSoDy6YY3thJ1u4Ofzc1ss0H-w","m.room.join_rules","","@a:x",{"join_rule":"public"},["$_YrS4iRhHuywAi_dOQKg2geV09N4Gb6pmUokJOPOIsY","$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w"],0,true]],"states":[["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$L9ZTTuBJc2Y7Fpc52RKZlZ-Cc4mAZe2lxJXLyRwLoYo","$RQlCG3TFy-WHL-_Fa6chsfo1VkvcyZ0yohRWS9J3kx8","$_AHva_-u-SiDtS0ezrHihP07yd1Gjyyd-zxZTTH8SVU","$_YrS4iRhHuywAi_dOQKg2geV09N4Gb6pmUokJOPOIsY","$aJsIqokJrbhL0tC-zZnHk5uIrmdv5rkgM3fx9Ogapa8","$dw-_TeKNZesnZx7od4SWh1XQtvDhX3lbmvcjHNQQGdw","$iFZXrOIMEYzuWKF1xvZ07oqLtcVK8KzDOFdEPhJzBMU","$qg6murAuMLC4j1mtoypyKKDto0zBSkZGsl81EawPZDo"],["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$L9ZTTuBJc2Y7Fpc52RKZlZ-Cc4mAZe2lxJXLyRwLoYo","$MaG5LePAUyidffWOaaSoDy6YY3thJ1u4Ofzc1ss0H-w","$RQlCG3TFy-WHL-_Fa6chsfo1VkvcyZ0yohRWS9J3kx8","$_AHva_-u-SiDtS0ezrHihP07yd1Gjyyd-zxZTTH8SVU","$_YrS4iRhHuywAi_dOQKg2geV09N4Gb6pmUokJOPOIsY","$dw-_TeKNZesnZx7od4SWh1XQtvDhX3lbmvcjHNQQGdw","$iFZXrOIMEYzuWKF1xvZ07oqLtcVK8KzDOFdEPhJzBMU"]],"resolved":["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$L9ZTTuBJc2Y7Fpc52RKZlZ-Cc4mAZe2lxJXLyRwLoYo","$RQlCG3TFy-WHL-_Fa6chsfo1VkvcyZ0yohRWS9J3kx8","$_AHva_-u-SiDtS0ezrHihP07yd1Gjyyd-zxZTTH8SVU","$_YrS4iRhHuywAi_dOQKg2geV09N4Gb6pmUokJOPOIsY","$aJsIqokJrbhL0tC-zZnHk5uIrmdv5rkgM3fx9Ogapa8","$dw-_TeKNZesnZx7od4SWh1XQtvDhX3lbmvcjHNQQGdw","$iFZXrOIMEYzuWKF1xvZ07oqLtcVK8KzDOFdEPhJzBMU","$qg6murAuMLC4j1mtoypyKKDto0zBSkZGsl81EawPZDo"]}, +{"name":"rej-1226","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$H4o2MGXdvDqqFIsOnIoKrSqUT7VVgF8lIjB_TiivhqA","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@b:x":0,"@c:x":25,"@d:x":0}},["$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],0],["$6OYY-YnsS7FPhmbDd-GwMnKi8UDHV2Oy90HB4DVvNIw","m.room.join_rules","","@a:x",{"join_rule":"public"},["$H4o2MGXdvDqqFIsOnIoKrSqUT7VVgF8lIjB_TiivhqA","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],5],["$qn1i0jj2EofewKp8amwGOEqXfMiyQWFFi0_kzuMHBIU","m.room.member","@b:x","@b:x",{"membership":"join"},["$6OYY-YnsS7FPhmbDd-GwMnKi8UDHV2Oy90HB4DVvNIw","$H4o2MGXdvDqqFIsOnIoKrSqUT7VVgF8lIjB_TiivhqA"],5],["$kBvMdgMv3qdlDPiXQTe7yXH9jTSTezet9Elb13IL_bY","m.room.member","@c:x","@c:x",{"membership":"join"},["$6OYY-YnsS7FPhmbDd-GwMnKi8UDHV2Oy90HB4DVvNIw","$H4o2MGXdvDqqFIsOnIoKrSqUT7VVgF8lIjB_TiivhqA"],3],["$_gRLOnCTSvId9no8Wjbyryw1j4fHGRKrcJSuK1FUi8M","m.room.member","@d:x","@d:x",{"membership":"join"},["$6OYY-YnsS7FPhmbDd-GwMnKi8UDHV2Oy90HB4DVvNIw","$H4o2MGXdvDqqFIsOnIoKrSqUT7VVgF8lIjB_TiivhqA"],2],["$oBJK-kRW4IfWU8AC67Akd6tZ23gwncvCZBoTwMqVslg","m.room.name","","@a:x",{"name":"base"},["$H4o2MGXdvDqqFIsOnIoKrSqUT7VVgF8lIjB_TiivhqA","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],2],["$fvSDv6Jup8z4iJvJM0tWMpsECb1O6h3zOOGETpu4lbI","m.room.topic","","@a:x",{"topic":"0"},["$H4o2MGXdvDqqFIsOnIoKrSqUT7VVgF8lIjB_TiivhqA","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],0],["$6zZTQB1f_zS_aRPtidHkyuzTxrEFQninHYrfS44JtOw","m.room.member","@e:x","@e:x",{"displayname":"1","membership":"join"},["$6OYY-YnsS7FPhmbDd-GwMnKi8UDHV2Oy90HB4DVvNIw","$H4o2MGXdvDqqFIsOnIoKrSqUT7VVgF8lIjB_TiivhqA"],0],["$ARVUHA87pM1IB2Qg25SPtq53tbXpWOZvkcbHdw0rHpI","m.room.member","@d:x","@a:x",{"membership":"leave"},["$H4o2MGXdvDqqFIsOnIoKrSqUT7VVgF8lIjB_TiivhqA","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U","$_gRLOnCTSvId9no8Wjbyryw1j4fHGRKrcJSuK1FUi8M"],3,true]],"states":[["$6OYY-YnsS7FPhmbDd-GwMnKi8UDHV2Oy90HB4DVvNIw","$6zZTQB1f_zS_aRPtidHkyuzTxrEFQninHYrfS44JtOw","$H4o2MGXdvDqqFIsOnIoKrSqUT7VVgF8lIjB_TiivhqA","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$_gRLOnCTSvId9no8Wjbyryw1j4fHGRKrcJSuK1FUi8M","$fvSDv6Jup8z4iJvJM0tWMpsECb1O6h3zOOGETpu4lbI","$kBvMdgMv3qdlDPiXQTe7yXH9jTSTezet9Elb13IL_bY","$oBJK-kRW4IfWU8AC67Akd6tZ23gwncvCZBoTwMqVslg","$qn1i0jj2EofewKp8amwGOEqXfMiyQWFFi0_kzuMHBIU","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],["$6OYY-YnsS7FPhmbDd-GwMnKi8UDHV2Oy90HB4DVvNIw","$6zZTQB1f_zS_aRPtidHkyuzTxrEFQninHYrfS44JtOw","$ARVUHA87pM1IB2Qg25SPtq53tbXpWOZvkcbHdw0rHpI","$H4o2MGXdvDqqFIsOnIoKrSqUT7VVgF8lIjB_TiivhqA","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$fvSDv6Jup8z4iJvJM0tWMpsECb1O6h3zOOGETpu4lbI","$kBvMdgMv3qdlDPiXQTe7yXH9jTSTezet9Elb13IL_bY","$oBJK-kRW4IfWU8AC67Akd6tZ23gwncvCZBoTwMqVslg","$qn1i0jj2EofewKp8amwGOEqXfMiyQWFFi0_kzuMHBIU","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"]],"resolved":["$6OYY-YnsS7FPhmbDd-GwMnKi8UDHV2Oy90HB4DVvNIw","$6zZTQB1f_zS_aRPtidHkyuzTxrEFQninHYrfS44JtOw","$H4o2MGXdvDqqFIsOnIoKrSqUT7VVgF8lIjB_TiivhqA","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$_gRLOnCTSvId9no8Wjbyryw1j4fHGRKrcJSuK1FUi8M","$fvSDv6Jup8z4iJvJM0tWMpsECb1O6h3zOOGETpu4lbI","$kBvMdgMv3qdlDPiXQTe7yXH9jTSTezet9Elb13IL_bY","$oBJK-kRW4IfWU8AC67Akd6tZ23gwncvCZBoTwMqVslg","$qn1i0jj2EofewKp8amwGOEqXfMiyQWFFi0_kzuMHBIU","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"]}, +{"name":"rej-1276","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":50},"kick":50,"state_default":50,"users":{"@b:x":0,"@c:x":50,"@d:x":75}},["$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],2],["$532fCZDFVCs-4F7K9yHtV-9mfKbhxwthCie7lhsp9mk","m.room.join_rules","","@a:x",{"join_rule":"public"},["$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],3],["$RTjEYN3OBxK1BOMnzCs5b88o2vLP3vGayvnRg_dEZ-4","m.room.member","@b:x","@b:x",{"membership":"join"},["$532fCZDFVCs-4F7K9yHtV-9mfKbhxwthCie7lhsp9mk","$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0"],3],["$odUANtLFMTBK6BJtkMgjjSOS5Od28WBAJ7mE5n2V1hU","m.room.member","@c:x","@c:x",{"membership":"join"},["$532fCZDFVCs-4F7K9yHtV-9mfKbhxwthCie7lhsp9mk","$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0"],3],["$SFoKNOOOXOeAh6Iwdx4O_d8juy30cIOYggh-9c_q6qg","m.room.member","@d:x","@d:x",{"membership":"join"},["$532fCZDFVCs-4F7K9yHtV-9mfKbhxwthCie7lhsp9mk","$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0"],3],["$5wSMFgJWoXOi_Zg25T2Pr11RPUbycnzys1GNZMZPxwc","m.room.name","","@a:x",{"name":"base"},["$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],3],["$k0twC8o90gP8pptEq5cm0o-d5LQ-e1xe_oZRQWGrIVI","m.room.topic","","@c:x",{"topic":"0"},["$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$odUANtLFMTBK6BJtkMgjjSOS5Od28WBAJ7mE5n2V1hU"],1],["$a46IY4wEYIe4SDS2K92VBpZ1JhVbWneatduHMh1G0nE","m.room.join_rules","","@d:x",{"join_rule":"public"},["$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$SFoKNOOOXOeAh6Iwdx4O_d8juy30cIOYggh-9c_q6qg"],1,true],["$c1zBo2QTD3MQp32hOgNDaKEQV7FTV54uQ6-VJ7cLAWg","m.room.name","","@d:x",{"name":"1"},["$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$SFoKNOOOXOeAh6Iwdx4O_d8juy30cIOYggh-9c_q6qg"],2]],"states":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","$RTjEYN3OBxK1BOMnzCs5b88o2vLP3vGayvnRg_dEZ-4","$SFoKNOOOXOeAh6Iwdx4O_d8juy30cIOYggh-9c_q6qg","$a46IY4wEYIe4SDS2K92VBpZ1JhVbWneatduHMh1G0nE","$c1zBo2QTD3MQp32hOgNDaKEQV7FTV54uQ6-VJ7cLAWg","$k0twC8o90gP8pptEq5cm0o-d5LQ-e1xe_oZRQWGrIVI","$odUANtLFMTBK6BJtkMgjjSOS5Od28WBAJ7mE5n2V1hU"],["$532fCZDFVCs-4F7K9yHtV-9mfKbhxwthCie7lhsp9mk","$5wSMFgJWoXOi_Zg25T2Pr11RPUbycnzys1GNZMZPxwc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","$RTjEYN3OBxK1BOMnzCs5b88o2vLP3vGayvnRg_dEZ-4","$SFoKNOOOXOeAh6Iwdx4O_d8juy30cIOYggh-9c_q6qg","$k0twC8o90gP8pptEq5cm0o-d5LQ-e1xe_oZRQWGrIVI","$odUANtLFMTBK6BJtkMgjjSOS5Od28WBAJ7mE5n2V1hU"]],"resolved":["$532fCZDFVCs-4F7K9yHtV-9mfKbhxwthCie7lhsp9mk","$5wSMFgJWoXOi_Zg25T2Pr11RPUbycnzys1GNZMZPxwc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","$RTjEYN3OBxK1BOMnzCs5b88o2vLP3vGayvnRg_dEZ-4","$SFoKNOOOXOeAh6Iwdx4O_d8juy30cIOYggh-9c_q6qg","$k0twC8o90gP8pptEq5cm0o-d5LQ-e1xe_oZRQWGrIVI","$odUANtLFMTBK6BJtkMgjjSOS5Od28WBAJ7mE5n2V1hU"]}, +{"name":"rej-1403","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$NAjXpkffrtCAi1OknQt-LmBUdb4ULed_D2gSdJatGPs","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":25},"kick":75,"state_default":50,"users":{"@b:x":0,"@c:x":0,"@d:x":50}},["$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],5],["$0-WXTvafO6isy962-VMyXmQtzPsughlHGSLnUyRYvcw","m.room.join_rules","","@a:x",{"join_rule":"public"},["$NAjXpkffrtCAi1OknQt-LmBUdb4ULed_D2gSdJatGPs","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],3],["$8HDgnE9eYMdgIc_myYfP0ppMv73hPgtcqtf2KqZml5s","m.room.member","@b:x","@b:x",{"membership":"join"},["$0-WXTvafO6isy962-VMyXmQtzPsughlHGSLnUyRYvcw","$NAjXpkffrtCAi1OknQt-LmBUdb4ULed_D2gSdJatGPs"],2],["$UuVEFKKPaO87L5V9iivy9R1MrovH87BBGVexoHXoenI","m.room.member","@c:x","@c:x",{"membership":"join"},["$0-WXTvafO6isy962-VMyXmQtzPsughlHGSLnUyRYvcw","$NAjXpkffrtCAi1OknQt-LmBUdb4ULed_D2gSdJatGPs"],3],["$Q-1BMFOmz2GDgf-xYg1025U5j-hVNJA3HFZh40-ZKEY","m.room.member","@d:x","@d:x",{"membership":"join"},["$0-WXTvafO6isy962-VMyXmQtzPsughlHGSLnUyRYvcw","$NAjXpkffrtCAi1OknQt-LmBUdb4ULed_D2gSdJatGPs"],5],["$qX8qHWPQrkg7BarQqNdZd6WFYPWpVy10KEZvBPxcvQc","m.room.topic","","@a:x",{"topic":"base"},["$NAjXpkffrtCAi1OknQt-LmBUdb4ULed_D2gSdJatGPs","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],1],["$KZj1oIbWK2C3eCxi9wPPOIuBV8A2Fgl3Pf1k5_V9X4s","m.room.name","","@a:x",{"name":"base"},["$NAjXpkffrtCAi1OknQt-LmBUdb4ULed_D2gSdJatGPs","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],0],["$MkcBNdhv0XC-2Z2uTxQ_s1YRyYGiqplU3dF2nLRVqWI","m.room.join_rules","","@a:x",{"join_rule":"public"},["$NAjXpkffrtCAi1OknQt-LmBUdb4ULed_D2gSdJatGPs","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],2,true],["$foyc56K4fEylzYEZ7tHOiIoumME1k8QMlimR6YofXsI","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$0-WXTvafO6isy962-VMyXmQtzPsughlHGSLnUyRYvcw","$NAjXpkffrtCAi1OknQt-LmBUdb4ULed_D2gSdJatGPs","$Q-1BMFOmz2GDgf-xYg1025U5j-hVNJA3HFZh40-ZKEY"],2]],"states":[["$0-WXTvafO6isy962-VMyXmQtzPsughlHGSLnUyRYvcw","$8HDgnE9eYMdgIc_myYfP0ppMv73hPgtcqtf2KqZml5s","$KZj1oIbWK2C3eCxi9wPPOIuBV8A2Fgl3Pf1k5_V9X4s","$NAjXpkffrtCAi1OknQt-LmBUdb4ULed_D2gSdJatGPs","$Q-1BMFOmz2GDgf-xYg1025U5j-hVNJA3HFZh40-ZKEY","$UuVEFKKPaO87L5V9iivy9R1MrovH87BBGVexoHXoenI","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$qX8qHWPQrkg7BarQqNdZd6WFYPWpVy10KEZvBPxcvQc","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"],["$0-WXTvafO6isy962-VMyXmQtzPsughlHGSLnUyRYvcw","$8HDgnE9eYMdgIc_myYfP0ppMv73hPgtcqtf2KqZml5s","$KZj1oIbWK2C3eCxi9wPPOIuBV8A2Fgl3Pf1k5_V9X4s","$NAjXpkffrtCAi1OknQt-LmBUdb4ULed_D2gSdJatGPs","$UuVEFKKPaO87L5V9iivy9R1MrovH87BBGVexoHXoenI","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$foyc56K4fEylzYEZ7tHOiIoumME1k8QMlimR6YofXsI","$qX8qHWPQrkg7BarQqNdZd6WFYPWpVy10KEZvBPxcvQc","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"],["$8HDgnE9eYMdgIc_myYfP0ppMv73hPgtcqtf2KqZml5s","$KZj1oIbWK2C3eCxi9wPPOIuBV8A2Fgl3Pf1k5_V9X4s","$MkcBNdhv0XC-2Z2uTxQ_s1YRyYGiqplU3dF2nLRVqWI","$NAjXpkffrtCAi1OknQt-LmBUdb4ULed_D2gSdJatGPs","$Q-1BMFOmz2GDgf-xYg1025U5j-hVNJA3HFZh40-ZKEY","$UuVEFKKPaO87L5V9iivy9R1MrovH87BBGVexoHXoenI","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$qX8qHWPQrkg7BarQqNdZd6WFYPWpVy10KEZvBPxcvQc","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"]],"resolved":["$0-WXTvafO6isy962-VMyXmQtzPsughlHGSLnUyRYvcw","$8HDgnE9eYMdgIc_myYfP0ppMv73hPgtcqtf2KqZml5s","$KZj1oIbWK2C3eCxi9wPPOIuBV8A2Fgl3Pf1k5_V9X4s","$NAjXpkffrtCAi1OknQt-LmBUdb4ULed_D2gSdJatGPs","$Q-1BMFOmz2GDgf-xYg1025U5j-hVNJA3HFZh40-ZKEY","$UuVEFKKPaO87L5V9iivy9R1MrovH87BBGVexoHXoenI","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$qX8qHWPQrkg7BarQqNdZd6WFYPWpVy10KEZvBPxcvQc","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"]}, +{"name":"rej-1465","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$S_EB9cfLnO1m4ZlmcqfUndqSnDOD2XrYyBqUk0_9evw","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":75},"kick":75,"state_default":0,"users":{"@b:x":0,"@c:x":50,"@d:x":75}},["$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],4],["$NJqlGJ25cXRI5vzFHjZ2NwgbsU7Rabram59oH8qAPp0","m.room.join_rules","","@a:x",{"join_rule":"public"},["$S_EB9cfLnO1m4ZlmcqfUndqSnDOD2XrYyBqUk0_9evw","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],5],["$q5u6P8KO6wc9rwK1i1EWsCHULiSR5jiNH_b8bBV8AU0","m.room.member","@b:x","@b:x",{"membership":"join"},["$NJqlGJ25cXRI5vzFHjZ2NwgbsU7Rabram59oH8qAPp0","$S_EB9cfLnO1m4ZlmcqfUndqSnDOD2XrYyBqUk0_9evw"],3],["$7fGkfuElTQxP1M6qz1psdJcT7Yd40Y7Ds5QKWgUCuEc","m.room.member","@c:x","@c:x",{"membership":"join"},["$NJqlGJ25cXRI5vzFHjZ2NwgbsU7Rabram59oH8qAPp0","$S_EB9cfLnO1m4ZlmcqfUndqSnDOD2XrYyBqUk0_9evw"],3],["$ZRiBVJm9ckMZB6d8O9zy8MZM9RIVK7wfYf_LWmUKoGQ","m.room.member","@d:x","@d:x",{"membership":"join"},["$NJqlGJ25cXRI5vzFHjZ2NwgbsU7Rabram59oH8qAPp0","$S_EB9cfLnO1m4ZlmcqfUndqSnDOD2XrYyBqUk0_9evw"],5],["$yEvuP8M7QAku3dNzWUgpahwrzZuyGOCszdbx0yHlZPo","m.room.topic","","@a:x",{"topic":"base"},["$S_EB9cfLnO1m4ZlmcqfUndqSnDOD2XrYyBqUk0_9evw","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],4],["$Xhj2EyoHRAJVdw5viQ1IC-UvXV4qqu4j2wf9wdgC-Us","m.room.name","","@a:x",{"name":"base"},["$S_EB9cfLnO1m4ZlmcqfUndqSnDOD2XrYyBqUk0_9evw","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],5],["$B4Rr9K04V-aFIjsknYyCUr-mxfKw5EVCrsqfjY19pWw","m.room.join_rules","","@a:x",{"join_rule":"public"},["$S_EB9cfLnO1m4ZlmcqfUndqSnDOD2XrYyBqUk0_9evw","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],2,true],["$oyTTF1-9oT9QT9Y7wPx_Rw-HUlXZjzILhEGlIaakstw","m.room.member","@c:x","@c:x",{"membership":"leave"},["$S_EB9cfLnO1m4ZlmcqfUndqSnDOD2XrYyBqUk0_9evw","$7fGkfuElTQxP1M6qz1psdJcT7Yd40Y7Ds5QKWgUCuEc"],1],["$N14EhJ0F1E23aHkvo7qKIyjRyOm6ACIJalOznBPcF3o","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$NJqlGJ25cXRI5vzFHjZ2NwgbsU7Rabram59oH8qAPp0","$S_EB9cfLnO1m4ZlmcqfUndqSnDOD2XrYyBqUk0_9evw","$oyTTF1-9oT9QT9Y7wPx_Rw-HUlXZjzILhEGlIaakstw"],4]],"states":[["$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw","$N14EhJ0F1E23aHkvo7qKIyjRyOm6ACIJalOznBPcF3o","$NJqlGJ25cXRI5vzFHjZ2NwgbsU7Rabram59oH8qAPp0","$S_EB9cfLnO1m4ZlmcqfUndqSnDOD2XrYyBqUk0_9evw","$Xhj2EyoHRAJVdw5viQ1IC-UvXV4qqu4j2wf9wdgC-Us","$ZRiBVJm9ckMZB6d8O9zy8MZM9RIVK7wfYf_LWmUKoGQ","$q5u6P8KO6wc9rwK1i1EWsCHULiSR5jiNH_b8bBV8AU0","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$yEvuP8M7QAku3dNzWUgpahwrzZuyGOCszdbx0yHlZPo"],["$7fGkfuElTQxP1M6qz1psdJcT7Yd40Y7Ds5QKWgUCuEc","$B4Rr9K04V-aFIjsknYyCUr-mxfKw5EVCrsqfjY19pWw","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw","$S_EB9cfLnO1m4ZlmcqfUndqSnDOD2XrYyBqUk0_9evw","$Xhj2EyoHRAJVdw5viQ1IC-UvXV4qqu4j2wf9wdgC-Us","$ZRiBVJm9ckMZB6d8O9zy8MZM9RIVK7wfYf_LWmUKoGQ","$q5u6P8KO6wc9rwK1i1EWsCHULiSR5jiNH_b8bBV8AU0","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$yEvuP8M7QAku3dNzWUgpahwrzZuyGOCszdbx0yHlZPo"]],"resolved":["$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw","$N14EhJ0F1E23aHkvo7qKIyjRyOm6ACIJalOznBPcF3o","$NJqlGJ25cXRI5vzFHjZ2NwgbsU7Rabram59oH8qAPp0","$S_EB9cfLnO1m4ZlmcqfUndqSnDOD2XrYyBqUk0_9evw","$Xhj2EyoHRAJVdw5viQ1IC-UvXV4qqu4j2wf9wdgC-Us","$ZRiBVJm9ckMZB6d8O9zy8MZM9RIVK7wfYf_LWmUKoGQ","$q5u6P8KO6wc9rwK1i1EWsCHULiSR5jiNH_b8bBV8AU0","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$yEvuP8M7QAku3dNzWUgpahwrzZuyGOCszdbx0yHlZPo"]}, +{"name":"rej-1485","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$BqIXygAt8rwUyz0gGTxSZ7ldlM-PzLPd_clKgOuDA3E","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":25},"kick":25,"state_default":0,"users":{"@b:x":25,"@c:x":75,"@d:x":75}},["$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],4],["$kJP38M9hFjrHBw5Z_UaeyTsjGbOFfUFE3j5pD7C19tY","m.room.join_rules","","@a:x",{"join_rule":"public"},["$BqIXygAt8rwUyz0gGTxSZ7ldlM-PzLPd_clKgOuDA3E","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],2],["$rclEECtdQFP0Z4RlOansaxDOb2dOoT_KMWahd2VkEXs","m.room.member","@b:x","@b:x",{"membership":"join"},["$kJP38M9hFjrHBw5Z_UaeyTsjGbOFfUFE3j5pD7C19tY","$BqIXygAt8rwUyz0gGTxSZ7ldlM-PzLPd_clKgOuDA3E"],0],["$OSYdOk6-FcBTi35sgPZyzGVMltDNOdtV1DvMtXz1tZo","m.room.member","@c:x","@c:x",{"membership":"join"},["$kJP38M9hFjrHBw5Z_UaeyTsjGbOFfUFE3j5pD7C19tY","$BqIXygAt8rwUyz0gGTxSZ7ldlM-PzLPd_clKgOuDA3E"],2],["$DxwAL5Px4ogNz8AQwZK2cAqpArz3sXTHovWb2X0YvNk","m.room.member","@d:x","@d:x",{"membership":"join"},["$kJP38M9hFjrHBw5Z_UaeyTsjGbOFfUFE3j5pD7C19tY","$BqIXygAt8rwUyz0gGTxSZ7ldlM-PzLPd_clKgOuDA3E"],3],["$s_H2z09p4Y-C5haD5RSvM81WsOaNubnw2dW5zniPff4","m.room.name","","@a:x",{"name":"base"},["$BqIXygAt8rwUyz0gGTxSZ7ldlM-PzLPd_clKgOuDA3E","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],0],["$Xh9kjo2boog_aDhudqczpEJghW4LXsMbKFcQc_thN44","m.room.topic","","@d:x",{"topic":"0"},["$BqIXygAt8rwUyz0gGTxSZ7ldlM-PzLPd_clKgOuDA3E","$DxwAL5Px4ogNz8AQwZK2cAqpArz3sXTHovWb2X0YvNk"],1],["$5wadZa9sI99ynur4zUCWP5_BI8pgsVX4guffoC7lPAI","m.room.topic","","@a:x",{"topic":"1"},["$BqIXygAt8rwUyz0gGTxSZ7ldlM-PzLPd_clKgOuDA3E","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],1],["$5NBhMHGfD8mdkSmra3RgSNn9U5SVNvBFysukV74jK4s","m.room.member","@e:x","@c:x",{"membership":"leave"},["$BqIXygAt8rwUyz0gGTxSZ7ldlM-PzLPd_clKgOuDA3E","$OSYdOk6-FcBTi35sgPZyzGVMltDNOdtV1DvMtXz1tZo"],4,true]],"states":[["$5wadZa9sI99ynur4zUCWP5_BI8pgsVX4guffoC7lPAI","$BqIXygAt8rwUyz0gGTxSZ7ldlM-PzLPd_clKgOuDA3E","$DxwAL5Px4ogNz8AQwZK2cAqpArz3sXTHovWb2X0YvNk","$OSYdOk6-FcBTi35sgPZyzGVMltDNOdtV1DvMtXz1tZo","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$kJP38M9hFjrHBw5Z_UaeyTsjGbOFfUFE3j5pD7C19tY","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$rclEECtdQFP0Z4RlOansaxDOb2dOoT_KMWahd2VkEXs","$s_H2z09p4Y-C5haD5RSvM81WsOaNubnw2dW5zniPff4"],["$5NBhMHGfD8mdkSmra3RgSNn9U5SVNvBFysukV74jK4s","$BqIXygAt8rwUyz0gGTxSZ7ldlM-PzLPd_clKgOuDA3E","$DxwAL5Px4ogNz8AQwZK2cAqpArz3sXTHovWb2X0YvNk","$OSYdOk6-FcBTi35sgPZyzGVMltDNOdtV1DvMtXz1tZo","$Xh9kjo2boog_aDhudqczpEJghW4LXsMbKFcQc_thN44","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$kJP38M9hFjrHBw5Z_UaeyTsjGbOFfUFE3j5pD7C19tY","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$rclEECtdQFP0Z4RlOansaxDOb2dOoT_KMWahd2VkEXs","$s_H2z09p4Y-C5haD5RSvM81WsOaNubnw2dW5zniPff4"]],"resolved":["$BqIXygAt8rwUyz0gGTxSZ7ldlM-PzLPd_clKgOuDA3E","$DxwAL5Px4ogNz8AQwZK2cAqpArz3sXTHovWb2X0YvNk","$OSYdOk6-FcBTi35sgPZyzGVMltDNOdtV1DvMtXz1tZo","$Xh9kjo2boog_aDhudqczpEJghW4LXsMbKFcQc_thN44","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$kJP38M9hFjrHBw5Z_UaeyTsjGbOFfUFE3j5pD7C19tY","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$rclEECtdQFP0Z4RlOansaxDOb2dOoT_KMWahd2VkEXs","$s_H2z09p4Y-C5haD5RSvM81WsOaNubnw2dW5zniPff4"]}, +{"name":"rej-1657","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$W0IoZHuMLxZiEavUzIK9PCXQY_aj-hyiLx9zWeZcmEk","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@b:x":25,"@c:x":50,"@d:x":75}},["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],5],["$Pn6-bmY5zBo2pl4gCwUwKOzIybFXxfSvc5lotUIYQgM","m.room.join_rules","","@a:x",{"join_rule":"public"},["$W0IoZHuMLxZiEavUzIK9PCXQY_aj-hyiLx9zWeZcmEk","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],2],["$fOmPytIK6DYLPpQ17V7ZIhWBhYhd5jlIcy9Ri_4wUnQ","m.room.member","@b:x","@b:x",{"membership":"join"},["$Pn6-bmY5zBo2pl4gCwUwKOzIybFXxfSvc5lotUIYQgM","$W0IoZHuMLxZiEavUzIK9PCXQY_aj-hyiLx9zWeZcmEk"],3],["$TqPW8aQfix2Kw9Pxv-7tqNoB3bOv2ypumfKa2g7TKr0","m.room.member","@c:x","@c:x",{"membership":"join"},["$Pn6-bmY5zBo2pl4gCwUwKOzIybFXxfSvc5lotUIYQgM","$W0IoZHuMLxZiEavUzIK9PCXQY_aj-hyiLx9zWeZcmEk"],5],["$63MxgDYFEvaAg93ts9tIMc9ts8UdOl1hqF4a6QId3aI","m.room.member","@d:x","@d:x",{"membership":"join"},["$Pn6-bmY5zBo2pl4gCwUwKOzIybFXxfSvc5lotUIYQgM","$W0IoZHuMLxZiEavUzIK9PCXQY_aj-hyiLx9zWeZcmEk"],4],["$LsZGFTdIZ5fGILsKe8Hg8jAClekHwuL1KWnU6gTZOpo","m.room.topic","","@a:x",{"topic":"base"},["$W0IoZHuMLxZiEavUzIK9PCXQY_aj-hyiLx9zWeZcmEk","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],1],["$_rSw1ghMnaU1P_H-Q10k5u5qrKUtsCWHGmobQUm_bno","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$W0IoZHuMLxZiEavUzIK9PCXQY_aj-hyiLx9zWeZcmEk","$63MxgDYFEvaAg93ts9tIMc9ts8UdOl1hqF4a6QId3aI"],5,true],["$Zvfn5Qz41UlpJv4pjRP8w2mzd6H2pkmzvIm0PFDIonU","m.room.name","","@a:x",{"name":"0"},["$W0IoZHuMLxZiEavUzIK9PCXQY_aj-hyiLx9zWeZcmEk","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],3],["$ZWZekWV7IZrjEWbFrTgFadT9-841l4tDI8Ou50-dtf0","m.room.name","","@a:x",{"name":"1"},["$W0IoZHuMLxZiEavUzIK9PCXQY_aj-hyiLx9zWeZcmEk","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],5]],"states":[["$63MxgDYFEvaAg93ts9tIMc9ts8UdOl1hqF4a6QId3aI","$LsZGFTdIZ5fGILsKe8Hg8jAClekHwuL1KWnU6gTZOpo","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$TqPW8aQfix2Kw9Pxv-7tqNoB3bOv2ypumfKa2g7TKr0","$W0IoZHuMLxZiEavUzIK9PCXQY_aj-hyiLx9zWeZcmEk","$Zvfn5Qz41UlpJv4pjRP8w2mzd6H2pkmzvIm0PFDIonU","$_rSw1ghMnaU1P_H-Q10k5u5qrKUtsCWHGmobQUm_bno","$fOmPytIK6DYLPpQ17V7ZIhWBhYhd5jlIcy9Ri_4wUnQ","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],["$63MxgDYFEvaAg93ts9tIMc9ts8UdOl1hqF4a6QId3aI","$LsZGFTdIZ5fGILsKe8Hg8jAClekHwuL1KWnU6gTZOpo","$Pn6-bmY5zBo2pl4gCwUwKOzIybFXxfSvc5lotUIYQgM","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$TqPW8aQfix2Kw9Pxv-7tqNoB3bOv2ypumfKa2g7TKr0","$W0IoZHuMLxZiEavUzIK9PCXQY_aj-hyiLx9zWeZcmEk","$ZWZekWV7IZrjEWbFrTgFadT9-841l4tDI8Ou50-dtf0","$fOmPytIK6DYLPpQ17V7ZIhWBhYhd5jlIcy9Ri_4wUnQ","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"]],"resolved":["$63MxgDYFEvaAg93ts9tIMc9ts8UdOl1hqF4a6QId3aI","$LsZGFTdIZ5fGILsKe8Hg8jAClekHwuL1KWnU6gTZOpo","$Pn6-bmY5zBo2pl4gCwUwKOzIybFXxfSvc5lotUIYQgM","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$TqPW8aQfix2Kw9Pxv-7tqNoB3bOv2ypumfKa2g7TKr0","$W0IoZHuMLxZiEavUzIK9PCXQY_aj-hyiLx9zWeZcmEk","$ZWZekWV7IZrjEWbFrTgFadT9-841l4tDI8Ou50-dtf0","$fOmPytIK6DYLPpQ17V7ZIhWBhYhd5jlIcy9Ri_4wUnQ","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"]}, +{"name":"rej-1677","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$mw8-u5cOuN2yN0gB9pSGBLJn-3YkCF7zoygmvouTWG8","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@b:x":75,"@c:x":50,"@d:x":75}},["$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],3],["$YC4eYt1vIqSlxCcSWh_0pbo06yiSdrbHzOMJCYz0Ua4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$mw8-u5cOuN2yN0gB9pSGBLJn-3YkCF7zoygmvouTWG8","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],4],["$w6IBEioaVYeIbkOuoEjzhRevuucgNip0UkYSORwqwHo","m.room.member","@b:x","@b:x",{"membership":"join"},["$YC4eYt1vIqSlxCcSWh_0pbo06yiSdrbHzOMJCYz0Ua4","$mw8-u5cOuN2yN0gB9pSGBLJn-3YkCF7zoygmvouTWG8"],2],["$MrlqXiqlY56jFA1lLEn79KvmsO0QYEqEO1jNmrSWUDw","m.room.member","@c:x","@c:x",{"membership":"join"},["$YC4eYt1vIqSlxCcSWh_0pbo06yiSdrbHzOMJCYz0Ua4","$mw8-u5cOuN2yN0gB9pSGBLJn-3YkCF7zoygmvouTWG8"],3],["$eonPuncn_Br_O4VuUjcV1ZggIn5YNg0Db5cyaIGLRsQ","m.room.member","@d:x","@d:x",{"membership":"join"},["$YC4eYt1vIqSlxCcSWh_0pbo06yiSdrbHzOMJCYz0Ua4","$mw8-u5cOuN2yN0gB9pSGBLJn-3YkCF7zoygmvouTWG8"],4],["$wR0YqVJQI3HmIwLNa3K97vEJdt3KTVyG3hHvK4kL_xU","m.room.name","","@a:x",{"name":"base"},["$mw8-u5cOuN2yN0gB9pSGBLJn-3YkCF7zoygmvouTWG8","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],4],["$ppzCN4Y6ys0Oj7NQg05BBCplG0PXAvRwko9XbXvAq-I","m.room.member","@c:x","@a:x",{"membership":"leave"},["$mw8-u5cOuN2yN0gB9pSGBLJn-3YkCF7zoygmvouTWG8","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","$MrlqXiqlY56jFA1lLEn79KvmsO0QYEqEO1jNmrSWUDw"],5,true],["$3KhJ16c3mv99BIT6NAeeEGemHpOkiWYElvaUTvw2-Gw","m.room.topic","","@d:x",{"topic":"0"},["$mw8-u5cOuN2yN0gB9pSGBLJn-3YkCF7zoygmvouTWG8","$eonPuncn_Br_O4VuUjcV1ZggIn5YNg0Db5cyaIGLRsQ"],2],["$WNkaG3oWJcWoZGqcX2TV9gksj5P6dtVo1f3BWpHknY8","m.room.topic","","@a:x",{"topic":"1"},["$mw8-u5cOuN2yN0gB9pSGBLJn-3YkCF7zoygmvouTWG8","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],3]],"states":[["$3KhJ16c3mv99BIT6NAeeEGemHpOkiWYElvaUTvw2-Gw","$MrlqXiqlY56jFA1lLEn79KvmsO0QYEqEO1jNmrSWUDw","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","$YC4eYt1vIqSlxCcSWh_0pbo06yiSdrbHzOMJCYz0Ua4","$eonPuncn_Br_O4VuUjcV1ZggIn5YNg0Db5cyaIGLRsQ","$mw8-u5cOuN2yN0gB9pSGBLJn-3YkCF7zoygmvouTWG8","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$w6IBEioaVYeIbkOuoEjzhRevuucgNip0UkYSORwqwHo","$wR0YqVJQI3HmIwLNa3K97vEJdt3KTVyG3hHvK4kL_xU"],["$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","$WNkaG3oWJcWoZGqcX2TV9gksj5P6dtVo1f3BWpHknY8","$YC4eYt1vIqSlxCcSWh_0pbo06yiSdrbHzOMJCYz0Ua4","$eonPuncn_Br_O4VuUjcV1ZggIn5YNg0Db5cyaIGLRsQ","$mw8-u5cOuN2yN0gB9pSGBLJn-3YkCF7zoygmvouTWG8","$ppzCN4Y6ys0Oj7NQg05BBCplG0PXAvRwko9XbXvAq-I","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$w6IBEioaVYeIbkOuoEjzhRevuucgNip0UkYSORwqwHo","$wR0YqVJQI3HmIwLNa3K97vEJdt3KTVyG3hHvK4kL_xU"]],"resolved":["$MrlqXiqlY56jFA1lLEn79KvmsO0QYEqEO1jNmrSWUDw","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","$WNkaG3oWJcWoZGqcX2TV9gksj5P6dtVo1f3BWpHknY8","$YC4eYt1vIqSlxCcSWh_0pbo06yiSdrbHzOMJCYz0Ua4","$eonPuncn_Br_O4VuUjcV1ZggIn5YNg0Db5cyaIGLRsQ","$mw8-u5cOuN2yN0gB9pSGBLJn-3YkCF7zoygmvouTWG8","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$w6IBEioaVYeIbkOuoEjzhRevuucgNip0UkYSORwqwHo","$wR0YqVJQI3HmIwLNa3K97vEJdt3KTVyG3hHvK4kL_xU"]}, +{"name":"rej-1916","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$Jqp1JXcvbO491ECdTmAYHj25wWR6ANOXW9F8Vw7ff9w","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":50},"kick":25,"state_default":50,"users":{"@b:x":25,"@c:x":50,"@d:x":0}},["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],4],["$G2Xff414VN--w68QEruKcjKAGyYh9zGtMskIJ5XKdDM","m.room.join_rules","","@a:x",{"join_rule":"public"},["$Jqp1JXcvbO491ECdTmAYHj25wWR6ANOXW9F8Vw7ff9w","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],5],["$9OAQeQ5FFAJm_KbjJSXKEAoZwxJb7jxFAOgkikdIN8M","m.room.member","@b:x","@b:x",{"membership":"join"},["$G2Xff414VN--w68QEruKcjKAGyYh9zGtMskIJ5XKdDM","$Jqp1JXcvbO491ECdTmAYHj25wWR6ANOXW9F8Vw7ff9w"],0],["$HkkZZvl_t_aysowa9LLnLa0-6claWgX4_MrG4AtBiWM","m.room.member","@c:x","@c:x",{"membership":"join"},["$G2Xff414VN--w68QEruKcjKAGyYh9zGtMskIJ5XKdDM","$Jqp1JXcvbO491ECdTmAYHj25wWR6ANOXW9F8Vw7ff9w"],5],["$_2-jlP11xV8p0vzaDu3e_YUycbbSIr9lAvu6XbQMd3c","m.room.member","@d:x","@d:x",{"membership":"join"},["$G2Xff414VN--w68QEruKcjKAGyYh9zGtMskIJ5XKdDM","$Jqp1JXcvbO491ECdTmAYHj25wWR6ANOXW9F8Vw7ff9w"],4],["$UZFNcKI6y4-syjjS5KDlbLrIr-Hc069BxE8dWrFdaeU","m.room.name","","@a:x",{"name":"base"},["$Jqp1JXcvbO491ECdTmAYHj25wWR6ANOXW9F8Vw7ff9w","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],1],["$MKP0SUm2P3SuN_3CmTSoRuKb0zGQ9DKi5FqZXKSC_LE","m.room.topic","","@a:x",{"topic":"0"},["$Jqp1JXcvbO491ECdTmAYHj25wWR6ANOXW9F8Vw7ff9w","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],2],["$xzG0jhFAiK3M15hd3AB-2tSshWFbpshpW7B-EzsXlLw","m.room.member","@c:x","@c:x",{"displayname":"1","membership":"join"},["$G2Xff414VN--w68QEruKcjKAGyYh9zGtMskIJ5XKdDM","$Jqp1JXcvbO491ECdTmAYHj25wWR6ANOXW9F8Vw7ff9w","$HkkZZvl_t_aysowa9LLnLa0-6claWgX4_MrG4AtBiWM"],4],["$MhAqpSuo-3D7aJUYSLu34BLhpSiQ8ddxe9mGpNEUez8","m.room.member","@e:x","@c:x",{"membership":"leave"},["$Jqp1JXcvbO491ECdTmAYHj25wWR6ANOXW9F8Vw7ff9w","$xzG0jhFAiK3M15hd3AB-2tSshWFbpshpW7B-EzsXlLw"],1,true],["$KFMRqLLOk9foqYbisT61przTSLmJS2HQpFVtq5Ki16Y","m.room.member","@b:x","@b:x",{"displayname":"2","membership":"join"},["$G2Xff414VN--w68QEruKcjKAGyYh9zGtMskIJ5XKdDM","$Jqp1JXcvbO491ECdTmAYHj25wWR6ANOXW9F8Vw7ff9w","$9OAQeQ5FFAJm_KbjJSXKEAoZwxJb7jxFAOgkikdIN8M"],5]],"states":[["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$9OAQeQ5FFAJm_KbjJSXKEAoZwxJb7jxFAOgkikdIN8M","$G2Xff414VN--w68QEruKcjKAGyYh9zGtMskIJ5XKdDM","$Jqp1JXcvbO491ECdTmAYHj25wWR6ANOXW9F8Vw7ff9w","$MKP0SUm2P3SuN_3CmTSoRuKb0zGQ9DKi5FqZXKSC_LE","$MhAqpSuo-3D7aJUYSLu34BLhpSiQ8ddxe9mGpNEUez8","$UZFNcKI6y4-syjjS5KDlbLrIr-Hc069BxE8dWrFdaeU","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$_2-jlP11xV8p0vzaDu3e_YUycbbSIr9lAvu6XbQMd3c","$xzG0jhFAiK3M15hd3AB-2tSshWFbpshpW7B-EzsXlLw"],["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$G2Xff414VN--w68QEruKcjKAGyYh9zGtMskIJ5XKdDM","$Jqp1JXcvbO491ECdTmAYHj25wWR6ANOXW9F8Vw7ff9w","$KFMRqLLOk9foqYbisT61przTSLmJS2HQpFVtq5Ki16Y","$MKP0SUm2P3SuN_3CmTSoRuKb0zGQ9DKi5FqZXKSC_LE","$UZFNcKI6y4-syjjS5KDlbLrIr-Hc069BxE8dWrFdaeU","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$_2-jlP11xV8p0vzaDu3e_YUycbbSIr9lAvu6XbQMd3c","$xzG0jhFAiK3M15hd3AB-2tSshWFbpshpW7B-EzsXlLw"]],"resolved":["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$G2Xff414VN--w68QEruKcjKAGyYh9zGtMskIJ5XKdDM","$Jqp1JXcvbO491ECdTmAYHj25wWR6ANOXW9F8Vw7ff9w","$KFMRqLLOk9foqYbisT61przTSLmJS2HQpFVtq5Ki16Y","$MKP0SUm2P3SuN_3CmTSoRuKb0zGQ9DKi5FqZXKSC_LE","$UZFNcKI6y4-syjjS5KDlbLrIr-Hc069BxE8dWrFdaeU","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$_2-jlP11xV8p0vzaDu3e_YUycbbSIr9lAvu6XbQMd3c","$xzG0jhFAiK3M15hd3AB-2tSshWFbpshpW7B-EzsXlLw"]}, +{"name":"rej-2017","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$GtytgCEfudYLro5fr_8IXzs6dhXUTPA0DeiK7M8GZbs","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@b:x":25,"@c:x":50,"@d:x":0}},["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],5],["$J4oXBAFCBZ-Es32smBF_fh9VeMVG8Xr_LkcYaR-PD34","m.room.join_rules","","@a:x",{"join_rule":"public"},["$GtytgCEfudYLro5fr_8IXzs6dhXUTPA0DeiK7M8GZbs","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],1],["$z93z9IcEQPyb5fRyNa_VIPmXpWfBKD9pbcCuwU9UEHI","m.room.member","@b:x","@b:x",{"membership":"join"},["$J4oXBAFCBZ-Es32smBF_fh9VeMVG8Xr_LkcYaR-PD34","$GtytgCEfudYLro5fr_8IXzs6dhXUTPA0DeiK7M8GZbs"],0],["$ZXmR13XToKhWk8rS3N51S9VBIuOMwvoNBV9IOx4r0O8","m.room.member","@c:x","@c:x",{"membership":"join"},["$J4oXBAFCBZ-Es32smBF_fh9VeMVG8Xr_LkcYaR-PD34","$GtytgCEfudYLro5fr_8IXzs6dhXUTPA0DeiK7M8GZbs"],1],["$6pnHKb4xTF4rnqd0Ljs3br2xrMBP6obx6gvNHfF_9Fc","m.room.member","@d:x","@d:x",{"membership":"join"},["$J4oXBAFCBZ-Es32smBF_fh9VeMVG8Xr_LkcYaR-PD34","$GtytgCEfudYLro5fr_8IXzs6dhXUTPA0DeiK7M8GZbs"],1],["$uHxkcrdXGxmVgWBiVpQImDPt69eLUy9clJOaJt7k9Yk","m.room.topic","","@a:x",{"topic":"base"},["$GtytgCEfudYLro5fr_8IXzs6dhXUTPA0DeiK7M8GZbs","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],4],["$FqgPAQkOqGcql2rvlgCkTrNYsbaGrIlnQWkHTgfvWGI","m.room.name","","@a:x",{"name":"base"},["$GtytgCEfudYLro5fr_8IXzs6dhXUTPA0DeiK7M8GZbs","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],0],["$NC_LUNBlJDp-WydUxhBnvWAVT6MDjGHrZo9o8MBllog","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$GtytgCEfudYLro5fr_8IXzs6dhXUTPA0DeiK7M8GZbs","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],0,true],["$Dun5zpvJ1WIpNxnUcFLxvNPw_SPNaZXeHxvqIm2Ipu4","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$J4oXBAFCBZ-Es32smBF_fh9VeMVG8Xr_LkcYaR-PD34","$GtytgCEfudYLro5fr_8IXzs6dhXUTPA0DeiK7M8GZbs","$z93z9IcEQPyb5fRyNa_VIPmXpWfBKD9pbcCuwU9UEHI"],2],["$esCm1YzSHH7xDKm-N3T-Fx5Dxu555ZnCYTAzijJakeA","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$J4oXBAFCBZ-Es32smBF_fh9VeMVG8Xr_LkcYaR-PD34","$GtytgCEfudYLro5fr_8IXzs6dhXUTPA0DeiK7M8GZbs","$6pnHKb4xTF4rnqd0Ljs3br2xrMBP6obx6gvNHfF_9Fc"],4]],"states":[["$Dun5zpvJ1WIpNxnUcFLxvNPw_SPNaZXeHxvqIm2Ipu4","$FqgPAQkOqGcql2rvlgCkTrNYsbaGrIlnQWkHTgfvWGI","$GtytgCEfudYLro5fr_8IXzs6dhXUTPA0DeiK7M8GZbs","$J4oXBAFCBZ-Es32smBF_fh9VeMVG8Xr_LkcYaR-PD34","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$ZXmR13XToKhWk8rS3N51S9VBIuOMwvoNBV9IOx4r0O8","$esCm1YzSHH7xDKm-N3T-Fx5Dxu555ZnCYTAzijJakeA","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$uHxkcrdXGxmVgWBiVpQImDPt69eLUy9clJOaJt7k9Yk"],["$6pnHKb4xTF4rnqd0Ljs3br2xrMBP6obx6gvNHfF_9Fc","$FqgPAQkOqGcql2rvlgCkTrNYsbaGrIlnQWkHTgfvWGI","$GtytgCEfudYLro5fr_8IXzs6dhXUTPA0DeiK7M8GZbs","$NC_LUNBlJDp-WydUxhBnvWAVT6MDjGHrZo9o8MBllog","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$ZXmR13XToKhWk8rS3N51S9VBIuOMwvoNBV9IOx4r0O8","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$uHxkcrdXGxmVgWBiVpQImDPt69eLUy9clJOaJt7k9Yk","$z93z9IcEQPyb5fRyNa_VIPmXpWfBKD9pbcCuwU9UEHI"]],"resolved":["$Dun5zpvJ1WIpNxnUcFLxvNPw_SPNaZXeHxvqIm2Ipu4","$FqgPAQkOqGcql2rvlgCkTrNYsbaGrIlnQWkHTgfvWGI","$GtytgCEfudYLro5fr_8IXzs6dhXUTPA0DeiK7M8GZbs","$J4oXBAFCBZ-Es32smBF_fh9VeMVG8Xr_LkcYaR-PD34","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$ZXmR13XToKhWk8rS3N51S9VBIuOMwvoNBV9IOx4r0O8","$esCm1YzSHH7xDKm-N3T-Fx5Dxu555ZnCYTAzijJakeA","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$uHxkcrdXGxmVgWBiVpQImDPt69eLUy9clJOaJt7k9Yk"]}, +{"name":"rej-2066","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$oXVbdud6Rxm4CWxQPzkzZaOxMDQXztPk_8Gzi22e0XQ","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":75,"state_default":50,"users":{"@b:x":0,"@c:x":0,"@d:x":25}},["$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],0],["$ltrTKQ_G9DnF5frQGYTROPKIeQ6DKq_Wuho0YrIGKGg","m.room.join_rules","","@a:x",{"join_rule":"public"},["$oXVbdud6Rxm4CWxQPzkzZaOxMDQXztPk_8Gzi22e0XQ","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],3],["$E263quMK05X3wqLm-peX4vx4foJFoOAxXu38NOjtp_g","m.room.member","@b:x","@b:x",{"membership":"join"},["$ltrTKQ_G9DnF5frQGYTROPKIeQ6DKq_Wuho0YrIGKGg","$oXVbdud6Rxm4CWxQPzkzZaOxMDQXztPk_8Gzi22e0XQ"],0],["$VSWLKOlvItiMTCK5_fWYZCtJw3vH7rhK4DODzBPME4Y","m.room.member","@c:x","@c:x",{"membership":"join"},["$ltrTKQ_G9DnF5frQGYTROPKIeQ6DKq_Wuho0YrIGKGg","$oXVbdud6Rxm4CWxQPzkzZaOxMDQXztPk_8Gzi22e0XQ"],3],["$0k6Q2xp7BcNtNaoZ7VQefNe9XiaYCyerWN_aSdbNHFc","m.room.member","@d:x","@d:x",{"membership":"join"},["$ltrTKQ_G9DnF5frQGYTROPKIeQ6DKq_Wuho0YrIGKGg","$oXVbdud6Rxm4CWxQPzkzZaOxMDQXztPk_8Gzi22e0XQ"],5],["$fbCWlQC-v2UpmWqRWdE_gUmnZwEt31kJl1fMrvgqWoo","m.room.topic","","@a:x",{"topic":"base"},["$oXVbdud6Rxm4CWxQPzkzZaOxMDQXztPk_8Gzi22e0XQ","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],4],["$x6_hRFsivPYdXu8cJ2e-2JYLwVy_g_Ses1ZOkHUCE0k","m.room.name","","@a:x",{"name":"base"},["$oXVbdud6Rxm4CWxQPzkzZaOxMDQXztPk_8Gzi22e0XQ","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],4],["$79yewuTNkOmrnbNZLlkyyEUDPCth13gGHTo-5Tq6yBY","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$ltrTKQ_G9DnF5frQGYTROPKIeQ6DKq_Wuho0YrIGKGg","$oXVbdud6Rxm4CWxQPzkzZaOxMDQXztPk_8Gzi22e0XQ"],1],["$l-jkhr1UnAWLoSGt6Dehz6ss_STERA9El47XxLk9Qoc","m.room.topic","","@a:x",{"topic":"1"},["$oXVbdud6Rxm4CWxQPzkzZaOxMDQXztPk_8Gzi22e0XQ","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],0],["$Mqu2-p7sheDjosufcAQS_J5UtUVHGzGC-cV8j6WGxoA","m.room.member","@e:x","@a:x",{"membership":"ban"},["$oXVbdud6Rxm4CWxQPzkzZaOxMDQXztPk_8Gzi22e0XQ","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8","$79yewuTNkOmrnbNZLlkyyEUDPCth13gGHTo-5Tq6yBY"],4,true]],"states":[["$0k6Q2xp7BcNtNaoZ7VQefNe9XiaYCyerWN_aSdbNHFc","$E263quMK05X3wqLm-peX4vx4foJFoOAxXu38NOjtp_g","$Mqu2-p7sheDjosufcAQS_J5UtUVHGzGC-cV8j6WGxoA","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8","$VSWLKOlvItiMTCK5_fWYZCtJw3vH7rhK4DODzBPME4Y","$fbCWlQC-v2UpmWqRWdE_gUmnZwEt31kJl1fMrvgqWoo","$ltrTKQ_G9DnF5frQGYTROPKIeQ6DKq_Wuho0YrIGKGg","$oXVbdud6Rxm4CWxQPzkzZaOxMDQXztPk_8Gzi22e0XQ","$x6_hRFsivPYdXu8cJ2e-2JYLwVy_g_Ses1ZOkHUCE0k"],["$0k6Q2xp7BcNtNaoZ7VQefNe9XiaYCyerWN_aSdbNHFc","$79yewuTNkOmrnbNZLlkyyEUDPCth13gGHTo-5Tq6yBY","$E263quMK05X3wqLm-peX4vx4foJFoOAxXu38NOjtp_g","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8","$VSWLKOlvItiMTCK5_fWYZCtJw3vH7rhK4DODzBPME4Y","$l-jkhr1UnAWLoSGt6Dehz6ss_STERA9El47XxLk9Qoc","$ltrTKQ_G9DnF5frQGYTROPKIeQ6DKq_Wuho0YrIGKGg","$oXVbdud6Rxm4CWxQPzkzZaOxMDQXztPk_8Gzi22e0XQ","$x6_hRFsivPYdXu8cJ2e-2JYLwVy_g_Ses1ZOkHUCE0k"]],"resolved":["$0k6Q2xp7BcNtNaoZ7VQefNe9XiaYCyerWN_aSdbNHFc","$79yewuTNkOmrnbNZLlkyyEUDPCth13gGHTo-5Tq6yBY","$E263quMK05X3wqLm-peX4vx4foJFoOAxXu38NOjtp_g","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8","$VSWLKOlvItiMTCK5_fWYZCtJw3vH7rhK4DODzBPME4Y","$fbCWlQC-v2UpmWqRWdE_gUmnZwEt31kJl1fMrvgqWoo","$ltrTKQ_G9DnF5frQGYTROPKIeQ6DKq_Wuho0YrIGKGg","$oXVbdud6Rxm4CWxQPzkzZaOxMDQXztPk_8Gzi22e0XQ","$x6_hRFsivPYdXu8cJ2e-2JYLwVy_g_Ses1ZOkHUCE0k"]}, +{"name":"rej-2347","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$9yuhIDqQIxHbtybpDaPoUvyijezrO4nw_VPWdm7pdVw","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":50,"users":{"@b:x":0,"@c:x":0,"@d:x":50}},["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],0],["$pEHoBvCxPuHLNgt9rBDDqPNytJXo9VbofnE-u1OEZpg","m.room.join_rules","","@a:x",{"join_rule":"public"},["$9yuhIDqQIxHbtybpDaPoUvyijezrO4nw_VPWdm7pdVw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],3],["$vIQXyn1AXsCtSI-ZhISqdKD4pd57SwW6C3d2utgC3wo","m.room.member","@b:x","@b:x",{"membership":"join"},["$pEHoBvCxPuHLNgt9rBDDqPNytJXo9VbofnE-u1OEZpg","$9yuhIDqQIxHbtybpDaPoUvyijezrO4nw_VPWdm7pdVw"],2],["$biUDrxCBU1eb7rvbJVV63_Z8vs4ZMpArrckm_Wo4-i8","m.room.member","@c:x","@c:x",{"membership":"join"},["$pEHoBvCxPuHLNgt9rBDDqPNytJXo9VbofnE-u1OEZpg","$9yuhIDqQIxHbtybpDaPoUvyijezrO4nw_VPWdm7pdVw"],3],["$5X6d-elpuEmGHln_xK6M_zQ6Goii7n3s-wcMqU3FRwY","m.room.member","@d:x","@d:x",{"membership":"join"},["$pEHoBvCxPuHLNgt9rBDDqPNytJXo9VbofnE-u1OEZpg","$9yuhIDqQIxHbtybpDaPoUvyijezrO4nw_VPWdm7pdVw"],2],["$cxQrj80vvhqdrgW0-FT_cCiL6HkSrcOKMp5ZyMK_Mm8","m.room.topic","","@a:x",{"topic":"base"},["$9yuhIDqQIxHbtybpDaPoUvyijezrO4nw_VPWdm7pdVw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],2],["$EVYA80TPLKBOz46GnlPa23qXm9KfWOMURtXsUr1gcoY","m.room.name","","@a:x",{"name":"base"},["$9yuhIDqQIxHbtybpDaPoUvyijezrO4nw_VPWdm7pdVw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],3],["$9SBDPV9OCqTU-PvgAGM5ErC7hQvgSQfVLOipNMiHFYw","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$9yuhIDqQIxHbtybpDaPoUvyijezrO4nw_VPWdm7pdVw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],1,true],["$3JvSQ9c3D5Zpfg47TT9AwPF2AGlA6XIshuVrU23C5fg","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$pEHoBvCxPuHLNgt9rBDDqPNytJXo9VbofnE-u1OEZpg","$9yuhIDqQIxHbtybpDaPoUvyijezrO4nw_VPWdm7pdVw","$biUDrxCBU1eb7rvbJVV63_Z8vs4ZMpArrckm_Wo4-i8"],4]],"states":[["$5X6d-elpuEmGHln_xK6M_zQ6Goii7n3s-wcMqU3FRwY","$9SBDPV9OCqTU-PvgAGM5ErC7hQvgSQfVLOipNMiHFYw","$9yuhIDqQIxHbtybpDaPoUvyijezrO4nw_VPWdm7pdVw","$EVYA80TPLKBOz46GnlPa23qXm9KfWOMURtXsUr1gcoY","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$biUDrxCBU1eb7rvbJVV63_Z8vs4ZMpArrckm_Wo4-i8","$cxQrj80vvhqdrgW0-FT_cCiL6HkSrcOKMp5ZyMK_Mm8","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$vIQXyn1AXsCtSI-ZhISqdKD4pd57SwW6C3d2utgC3wo"],["$3JvSQ9c3D5Zpfg47TT9AwPF2AGlA6XIshuVrU23C5fg","$5X6d-elpuEmGHln_xK6M_zQ6Goii7n3s-wcMqU3FRwY","$9yuhIDqQIxHbtybpDaPoUvyijezrO4nw_VPWdm7pdVw","$EVYA80TPLKBOz46GnlPa23qXm9KfWOMURtXsUr1gcoY","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$cxQrj80vvhqdrgW0-FT_cCiL6HkSrcOKMp5ZyMK_Mm8","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$pEHoBvCxPuHLNgt9rBDDqPNytJXo9VbofnE-u1OEZpg","$vIQXyn1AXsCtSI-ZhISqdKD4pd57SwW6C3d2utgC3wo"]],"resolved":["$3JvSQ9c3D5Zpfg47TT9AwPF2AGlA6XIshuVrU23C5fg","$5X6d-elpuEmGHln_xK6M_zQ6Goii7n3s-wcMqU3FRwY","$9yuhIDqQIxHbtybpDaPoUvyijezrO4nw_VPWdm7pdVw","$EVYA80TPLKBOz46GnlPa23qXm9KfWOMURtXsUr1gcoY","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$cxQrj80vvhqdrgW0-FT_cCiL6HkSrcOKMp5ZyMK_Mm8","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$pEHoBvCxPuHLNgt9rBDDqPNytJXo9VbofnE-u1OEZpg","$vIQXyn1AXsCtSI-ZhISqdKD4pd57SwW6C3d2utgC3wo"]}, +{"name":"rej-2392","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$aJwNnugY-MbXZuqmen5IPlkvVZhOmVfwLe4TJtrCsTk","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":25},"kick":75,"state_default":0,"users":{"@b:x":75,"@c:x":75,"@d:x":75}},["$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],4],["$6hdgnkGw--nXb3NIbHzW1ENuqGtacTQHxSJMw9V2jQI","m.room.join_rules","","@a:x",{"join_rule":"public"},["$aJwNnugY-MbXZuqmen5IPlkvVZhOmVfwLe4TJtrCsTk","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],2],["$XyC2muvsAVLD1oj68-IDJW14RC4fZfnq8Xkl5Yg8vkQ","m.room.member","@b:x","@b:x",{"membership":"join"},["$6hdgnkGw--nXb3NIbHzW1ENuqGtacTQHxSJMw9V2jQI","$aJwNnugY-MbXZuqmen5IPlkvVZhOmVfwLe4TJtrCsTk"],3],["$U55FflTqghW4xmJoguCnp7J6r_7jMjkxlvQQMYikaRY","m.room.member","@c:x","@c:x",{"membership":"join"},["$6hdgnkGw--nXb3NIbHzW1ENuqGtacTQHxSJMw9V2jQI","$aJwNnugY-MbXZuqmen5IPlkvVZhOmVfwLe4TJtrCsTk"],0],["$CdYfbT4cRA7or9N-H3dwGbra8GzB2XzWggCxBfJV8I0","m.room.member","@d:x","@d:x",{"membership":"join"},["$6hdgnkGw--nXb3NIbHzW1ENuqGtacTQHxSJMw9V2jQI","$aJwNnugY-MbXZuqmen5IPlkvVZhOmVfwLe4TJtrCsTk"],3],["$dmvwLzrxHPTHNXypBmkpzzwSIK_mbpwYZ7RFlG_YdfM","m.room.name","","@a:x",{"name":"base"},["$aJwNnugY-MbXZuqmen5IPlkvVZhOmVfwLe4TJtrCsTk","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],2],["$-8YkxbjE_imIz2HyWoHhGEUC3IlQiIjOfUTgA4iFh5E","m.room.member","@c:x","@c:x",{"membership":"leave"},["$aJwNnugY-MbXZuqmen5IPlkvVZhOmVfwLe4TJtrCsTk","$U55FflTqghW4xmJoguCnp7J6r_7jMjkxlvQQMYikaRY"],1],["$jukMmRbu_3FLu6WF7Qfcm4k7ClYfznlxKjENETsrDrM","m.room.topic","","@a:x",{"topic":"0"},["$aJwNnugY-MbXZuqmen5IPlkvVZhOmVfwLe4TJtrCsTk","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],1],["$BexZ-cnGuRuu79MWir7fWyeYl_CVnnYe1K2aRDEfu8g","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$aJwNnugY-MbXZuqmen5IPlkvVZhOmVfwLe4TJtrCsTk","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],0,true],["$OL9OZSct6WID-KPa3yRNStvGoEqy4hv_QtiapRvEmXs","m.room.member","@d:x","@d:x",{"membership":"leave"},["$aJwNnugY-MbXZuqmen5IPlkvVZhOmVfwLe4TJtrCsTk","$CdYfbT4cRA7or9N-H3dwGbra8GzB2XzWggCxBfJV8I0"],4]],"states":[["$-8YkxbjE_imIz2HyWoHhGEUC3IlQiIjOfUTgA4iFh5E","$BexZ-cnGuRuu79MWir7fWyeYl_CVnnYe1K2aRDEfu8g","$CdYfbT4cRA7or9N-H3dwGbra8GzB2XzWggCxBfJV8I0","$XyC2muvsAVLD1oj68-IDJW14RC4fZfnq8Xkl5Yg8vkQ","$aJwNnugY-MbXZuqmen5IPlkvVZhOmVfwLe4TJtrCsTk","$dmvwLzrxHPTHNXypBmkpzzwSIK_mbpwYZ7RFlG_YdfM","$jukMmRbu_3FLu6WF7Qfcm4k7ClYfznlxKjENETsrDrM","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],["$-8YkxbjE_imIz2HyWoHhGEUC3IlQiIjOfUTgA4iFh5E","$6hdgnkGw--nXb3NIbHzW1ENuqGtacTQHxSJMw9V2jQI","$OL9OZSct6WID-KPa3yRNStvGoEqy4hv_QtiapRvEmXs","$XyC2muvsAVLD1oj68-IDJW14RC4fZfnq8Xkl5Yg8vkQ","$aJwNnugY-MbXZuqmen5IPlkvVZhOmVfwLe4TJtrCsTk","$dmvwLzrxHPTHNXypBmkpzzwSIK_mbpwYZ7RFlG_YdfM","$jukMmRbu_3FLu6WF7Qfcm4k7ClYfznlxKjENETsrDrM","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"]],"resolved":["$-8YkxbjE_imIz2HyWoHhGEUC3IlQiIjOfUTgA4iFh5E","$6hdgnkGw--nXb3NIbHzW1ENuqGtacTQHxSJMw9V2jQI","$OL9OZSct6WID-KPa3yRNStvGoEqy4hv_QtiapRvEmXs","$XyC2muvsAVLD1oj68-IDJW14RC4fZfnq8Xkl5Yg8vkQ","$aJwNnugY-MbXZuqmen5IPlkvVZhOmVfwLe4TJtrCsTk","$dmvwLzrxHPTHNXypBmkpzzwSIK_mbpwYZ7RFlG_YdfM","$jukMmRbu_3FLu6WF7Qfcm4k7ClYfznlxKjENETsrDrM","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"]}, +{"name":"rej-2403","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$2ppHrQM0Wl2kO9y7ocb7nVgkugEZennWDacIQPukesc","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":0},"kick":25,"state_default":0,"users":{"@b:x":0,"@c:x":25,"@d:x":25}},["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],1],["$xFlCDjFL_RNQXToFg02RVjkFfdF2ATyxeMpFOI3PcU4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$2ppHrQM0Wl2kO9y7ocb7nVgkugEZennWDacIQPukesc","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],3],["$531qL-LtCno9ww9kVWuQ9wT9g-32l-jj2BE0skXKnNA","m.room.member","@b:x","@b:x",{"membership":"join"},["$xFlCDjFL_RNQXToFg02RVjkFfdF2ATyxeMpFOI3PcU4","$2ppHrQM0Wl2kO9y7ocb7nVgkugEZennWDacIQPukesc"],1],["$Tpn48clytrv31ll6tgghSVK1kQPgFnxmHmz1GvZNLCE","m.room.member","@c:x","@c:x",{"membership":"join"},["$xFlCDjFL_RNQXToFg02RVjkFfdF2ATyxeMpFOI3PcU4","$2ppHrQM0Wl2kO9y7ocb7nVgkugEZennWDacIQPukesc"],3],["$3d3uDCxdg_Nv5KgZDUeL8qKsjMTR4kQvPjyqmq1Ua5U","m.room.member","@d:x","@d:x",{"membership":"join"},["$xFlCDjFL_RNQXToFg02RVjkFfdF2ATyxeMpFOI3PcU4","$2ppHrQM0Wl2kO9y7ocb7nVgkugEZennWDacIQPukesc"],4],["$g9CJmRu-JREwm4emtCi2mY1guo_UyqFZMaCrQqRns-o","m.room.name","","@a:x",{"name":"base"},["$2ppHrQM0Wl2kO9y7ocb7nVgkugEZennWDacIQPukesc","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],2],["$AnSGMQiJFAZ6tHaGHa4Zictzn-95c4xcSTyBY7X9wmk","m.room.join_rules","","@a:x",{"join_rule":"public"},["$2ppHrQM0Wl2kO9y7ocb7nVgkugEZennWDacIQPukesc","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],3,true],["$JEZ3LyNp0xEoxUjVKihIwy58Vs7EYhSWm8IxNosCKUk","m.room.topic","","@d:x",{"topic":"0"},["$2ppHrQM0Wl2kO9y7ocb7nVgkugEZennWDacIQPukesc","$3d3uDCxdg_Nv5KgZDUeL8qKsjMTR4kQvPjyqmq1Ua5U"],4],["$BIK7kZM8E0y8_nRgDSXQGrB6pOnwn-_cG4vw1RCEBCU","m.room.topic","","@c:x",{"topic":"1"},["$2ppHrQM0Wl2kO9y7ocb7nVgkugEZennWDacIQPukesc","$Tpn48clytrv31ll6tgghSVK1kQPgFnxmHmz1GvZNLCE"],3],["$H9taIjFU0a35ZaR3oiC1u67EsSqajJwmZe6iozh3oyw","m.room.member","@e:x","@e:x",{"displayname":"2","membership":"join"},["$xFlCDjFL_RNQXToFg02RVjkFfdF2ATyxeMpFOI3PcU4","$2ppHrQM0Wl2kO9y7ocb7nVgkugEZennWDacIQPukesc"],4]],"states":[["$2ppHrQM0Wl2kO9y7ocb7nVgkugEZennWDacIQPukesc","$3d3uDCxdg_Nv5KgZDUeL8qKsjMTR4kQvPjyqmq1Ua5U","$531qL-LtCno9ww9kVWuQ9wT9g-32l-jj2BE0skXKnNA","$H9taIjFU0a35ZaR3oiC1u67EsSqajJwmZe6iozh3oyw","$JEZ3LyNp0xEoxUjVKihIwy58Vs7EYhSWm8IxNosCKUk","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$Tpn48clytrv31ll6tgghSVK1kQPgFnxmHmz1GvZNLCE","$g9CJmRu-JREwm4emtCi2mY1guo_UyqFZMaCrQqRns-o","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$xFlCDjFL_RNQXToFg02RVjkFfdF2ATyxeMpFOI3PcU4"],["$2ppHrQM0Wl2kO9y7ocb7nVgkugEZennWDacIQPukesc","$3d3uDCxdg_Nv5KgZDUeL8qKsjMTR4kQvPjyqmq1Ua5U","$531qL-LtCno9ww9kVWuQ9wT9g-32l-jj2BE0skXKnNA","$AnSGMQiJFAZ6tHaGHa4Zictzn-95c4xcSTyBY7X9wmk","$BIK7kZM8E0y8_nRgDSXQGrB6pOnwn-_cG4vw1RCEBCU","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$Tpn48clytrv31ll6tgghSVK1kQPgFnxmHmz1GvZNLCE","$g9CJmRu-JREwm4emtCi2mY1guo_UyqFZMaCrQqRns-o","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"]],"resolved":["$2ppHrQM0Wl2kO9y7ocb7nVgkugEZennWDacIQPukesc","$3d3uDCxdg_Nv5KgZDUeL8qKsjMTR4kQvPjyqmq1Ua5U","$531qL-LtCno9ww9kVWuQ9wT9g-32l-jj2BE0skXKnNA","$H9taIjFU0a35ZaR3oiC1u67EsSqajJwmZe6iozh3oyw","$JEZ3LyNp0xEoxUjVKihIwy58Vs7EYhSWm8IxNosCKUk","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$Tpn48clytrv31ll6tgghSVK1kQPgFnxmHmz1GvZNLCE","$g9CJmRu-JREwm4emtCi2mY1guo_UyqFZMaCrQqRns-o","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$xFlCDjFL_RNQXToFg02RVjkFfdF2ATyxeMpFOI3PcU4"]}, +{"name":"rej-2739","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$j5F7cH3O39lJ09jXA9vmhSoBcjtXt9LgmJ5YJ0e3HC0","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":0},"kick":50,"state_default":50,"users":{"@b:x":75,"@c:x":0,"@d:x":50}},["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],0],["$a76U7hNQj38MIG6NhjSzp4Mvtj1JEMzD8VWHB27xWUs","m.room.join_rules","","@a:x",{"join_rule":"public"},["$j5F7cH3O39lJ09jXA9vmhSoBcjtXt9LgmJ5YJ0e3HC0","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],3],["$yDeEcGn0Iruwjmm3X7ZR1fTwl-zT6CjExgYcVp2Z4iY","m.room.member","@b:x","@b:x",{"membership":"join"},["$a76U7hNQj38MIG6NhjSzp4Mvtj1JEMzD8VWHB27xWUs","$j5F7cH3O39lJ09jXA9vmhSoBcjtXt9LgmJ5YJ0e3HC0"],1],["$aWiN2aoQSi_RbyUVNRRf6DaEGisr89YzbRxySTqJ9nY","m.room.member","@c:x","@c:x",{"membership":"join"},["$a76U7hNQj38MIG6NhjSzp4Mvtj1JEMzD8VWHB27xWUs","$j5F7cH3O39lJ09jXA9vmhSoBcjtXt9LgmJ5YJ0e3HC0"],4],["$5cDfqxqDWuWkRqmEXBUxtKvtHKwrLNeYwXrG_EaqM5U","m.room.member","@d:x","@d:x",{"membership":"join"},["$a76U7hNQj38MIG6NhjSzp4Mvtj1JEMzD8VWHB27xWUs","$j5F7cH3O39lJ09jXA9vmhSoBcjtXt9LgmJ5YJ0e3HC0"],0],["$6yHEqQ4oEzQRm4LF5iY6k5Y1bqccxeHVft_A35rekLA","m.room.topic","","@a:x",{"topic":"base"},["$j5F7cH3O39lJ09jXA9vmhSoBcjtXt9LgmJ5YJ0e3HC0","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],1],["$o5I42y6qGK15Cxu7qIrTDcJtL3sEB3ejsnnXTnS8A8I","m.room.name","","@a:x",{"name":"base"},["$j5F7cH3O39lJ09jXA9vmhSoBcjtXt9LgmJ5YJ0e3HC0","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],5],["$kmWp7HzqfeuO-xdoGcTIF_Psz0KJvbrnADS8Lk-qnVQ","m.room.member","@e:x","@b:x",{"membership":"leave"},["$j5F7cH3O39lJ09jXA9vmhSoBcjtXt9LgmJ5YJ0e3HC0","$yDeEcGn0Iruwjmm3X7ZR1fTwl-zT6CjExgYcVp2Z4iY"],4],["$TttNzG1eUHY9ErP2E_ALKsn7kEXgrSHD0kGSI89FUJk","m.room.join_rules","","@b:x",{"join_rule":"public"},["$j5F7cH3O39lJ09jXA9vmhSoBcjtXt9LgmJ5YJ0e3HC0","$yDeEcGn0Iruwjmm3X7ZR1fTwl-zT6CjExgYcVp2Z4iY"],2],["$gK3ZBbueuL_NTQ5FmDWI_xOKgbrXlr6ca683QKZYV90","m.room.join_rules","","@a:x",{"join_rule":"public"},["$j5F7cH3O39lJ09jXA9vmhSoBcjtXt9LgmJ5YJ0e3HC0","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],4,true]],"states":[["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$5cDfqxqDWuWkRqmEXBUxtKvtHKwrLNeYwXrG_EaqM5U","$6yHEqQ4oEzQRm4LF5iY6k5Y1bqccxeHVft_A35rekLA","$TttNzG1eUHY9ErP2E_ALKsn7kEXgrSHD0kGSI89FUJk","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$aWiN2aoQSi_RbyUVNRRf6DaEGisr89YzbRxySTqJ9nY","$j5F7cH3O39lJ09jXA9vmhSoBcjtXt9LgmJ5YJ0e3HC0","$kmWp7HzqfeuO-xdoGcTIF_Psz0KJvbrnADS8Lk-qnVQ","$o5I42y6qGK15Cxu7qIrTDcJtL3sEB3ejsnnXTnS8A8I","$yDeEcGn0Iruwjmm3X7ZR1fTwl-zT6CjExgYcVp2Z4iY"],["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$5cDfqxqDWuWkRqmEXBUxtKvtHKwrLNeYwXrG_EaqM5U","$6yHEqQ4oEzQRm4LF5iY6k5Y1bqccxeHVft_A35rekLA","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$aWiN2aoQSi_RbyUVNRRf6DaEGisr89YzbRxySTqJ9nY","$gK3ZBbueuL_NTQ5FmDWI_xOKgbrXlr6ca683QKZYV90","$j5F7cH3O39lJ09jXA9vmhSoBcjtXt9LgmJ5YJ0e3HC0","$kmWp7HzqfeuO-xdoGcTIF_Psz0KJvbrnADS8Lk-qnVQ","$o5I42y6qGK15Cxu7qIrTDcJtL3sEB3ejsnnXTnS8A8I","$yDeEcGn0Iruwjmm3X7ZR1fTwl-zT6CjExgYcVp2Z4iY"]],"resolved":["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$5cDfqxqDWuWkRqmEXBUxtKvtHKwrLNeYwXrG_EaqM5U","$6yHEqQ4oEzQRm4LF5iY6k5Y1bqccxeHVft_A35rekLA","$TttNzG1eUHY9ErP2E_ALKsn7kEXgrSHD0kGSI89FUJk","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$aWiN2aoQSi_RbyUVNRRf6DaEGisr89YzbRxySTqJ9nY","$j5F7cH3O39lJ09jXA9vmhSoBcjtXt9LgmJ5YJ0e3HC0","$kmWp7HzqfeuO-xdoGcTIF_Psz0KJvbrnADS8Lk-qnVQ","$o5I42y6qGK15Cxu7qIrTDcJtL3sEB3ejsnnXTnS8A8I","$yDeEcGn0Iruwjmm3X7ZR1fTwl-zT6CjExgYcVp2Z4iY"]}, +{"name":"rej-2804","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$D34Rglz--kLrWDGnMFhEhuEKqbaTAhHRIYATh1Mr8Os","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@b:x":75,"@c:x":0,"@d:x":25}},["$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],1],["$wyC7DYEHQ52CUGgQWtn5zVb7ggxBXNwYMchRLEQokiY","m.room.join_rules","","@a:x",{"join_rule":"public"},["$D34Rglz--kLrWDGnMFhEhuEKqbaTAhHRIYATh1Mr8Os","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],3],["$ohDGrTg03_DU5PVIe58QSjWat-iKy2CP738XT8LFUjw","m.room.member","@b:x","@b:x",{"membership":"join"},["$wyC7DYEHQ52CUGgQWtn5zVb7ggxBXNwYMchRLEQokiY","$D34Rglz--kLrWDGnMFhEhuEKqbaTAhHRIYATh1Mr8Os"],5],["$cHPSXqfg2AY8juZr36M6lTqwwgvmcu6hmbx5trCQK5Q","m.room.member","@c:x","@c:x",{"membership":"join"},["$wyC7DYEHQ52CUGgQWtn5zVb7ggxBXNwYMchRLEQokiY","$D34Rglz--kLrWDGnMFhEhuEKqbaTAhHRIYATh1Mr8Os"],5],["$0H6ZgwzmoS18lhkiG8PAkRYCX6e_2ja3oB16JdTiuII","m.room.member","@d:x","@d:x",{"membership":"join"},["$wyC7DYEHQ52CUGgQWtn5zVb7ggxBXNwYMchRLEQokiY","$D34Rglz--kLrWDGnMFhEhuEKqbaTAhHRIYATh1Mr8Os"],4],["$Y2LGTlgDrhvC7TpJt23_A79_-t8hFv_Vp3jBqvpE9sE","m.room.name","","@a:x",{"name":"base"},["$D34Rglz--kLrWDGnMFhEhuEKqbaTAhHRIYATh1Mr8Os","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],4],["$nZyyp6sZnQBALj0rcA9nCPtw0LFGabVqq1KcQRrq-WY","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$wyC7DYEHQ52CUGgQWtn5zVb7ggxBXNwYMchRLEQokiY","$D34Rglz--kLrWDGnMFhEhuEKqbaTAhHRIYATh1Mr8Os","$ohDGrTg03_DU5PVIe58QSjWat-iKy2CP738XT8LFUjw"],4],["$2CjtLIHtjvta1G3ngvc-n9S2tcY_MdcaH5sD7rc60Rs","m.room.topic","","@b:x",{"topic":"1"},["$D34Rglz--kLrWDGnMFhEhuEKqbaTAhHRIYATh1Mr8Os","$ohDGrTg03_DU5PVIe58QSjWat-iKy2CP738XT8LFUjw"],2],["$IbmOtX5Vnt864AxrtQBgM1-Df7BSVjzP0DUbxOwdcvU","m.room.topic","","@a:x",{"topic":"2"},["$D34Rglz--kLrWDGnMFhEhuEKqbaTAhHRIYATh1Mr8Os","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],4],["$VzVo65rI0TNl4PAgupF_HP2UkEvaS5PYD0pY3FAiDhE","m.room.join_rules","","@b:x",{"join_rule":"public"},["$D34Rglz--kLrWDGnMFhEhuEKqbaTAhHRIYATh1Mr8Os","$nZyyp6sZnQBALj0rcA9nCPtw0LFGabVqq1KcQRrq-WY"],3,true]],"states":[["$0H6ZgwzmoS18lhkiG8PAkRYCX6e_2ja3oB16JdTiuII","$D34Rglz--kLrWDGnMFhEhuEKqbaTAhHRIYATh1Mr8Os","$IbmOtX5Vnt864AxrtQBgM1-Df7BSVjzP0DUbxOwdcvU","$VzVo65rI0TNl4PAgupF_HP2UkEvaS5PYD0pY3FAiDhE","$Y2LGTlgDrhvC7TpJt23_A79_-t8hFv_Vp3jBqvpE9sE","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$cHPSXqfg2AY8juZr36M6lTqwwgvmcu6hmbx5trCQK5Q","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$nZyyp6sZnQBALj0rcA9nCPtw0LFGabVqq1KcQRrq-WY"],["$0H6ZgwzmoS18lhkiG8PAkRYCX6e_2ja3oB16JdTiuII","$2CjtLIHtjvta1G3ngvc-n9S2tcY_MdcaH5sD7rc60Rs","$D34Rglz--kLrWDGnMFhEhuEKqbaTAhHRIYATh1Mr8Os","$Y2LGTlgDrhvC7TpJt23_A79_-t8hFv_Vp3jBqvpE9sE","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$cHPSXqfg2AY8juZr36M6lTqwwgvmcu6hmbx5trCQK5Q","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$ohDGrTg03_DU5PVIe58QSjWat-iKy2CP738XT8LFUjw","$wyC7DYEHQ52CUGgQWtn5zVb7ggxBXNwYMchRLEQokiY"]],"resolved":["$0H6ZgwzmoS18lhkiG8PAkRYCX6e_2ja3oB16JdTiuII","$D34Rglz--kLrWDGnMFhEhuEKqbaTAhHRIYATh1Mr8Os","$IbmOtX5Vnt864AxrtQBgM1-Df7BSVjzP0DUbxOwdcvU","$Y2LGTlgDrhvC7TpJt23_A79_-t8hFv_Vp3jBqvpE9sE","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$cHPSXqfg2AY8juZr36M6lTqwwgvmcu6hmbx5trCQK5Q","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$nZyyp6sZnQBALj0rcA9nCPtw0LFGabVqq1KcQRrq-WY","$wyC7DYEHQ52CUGgQWtn5zVb7ggxBXNwYMchRLEQokiY"]}, +{"name":"rej-2836","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$oGIE2-zEvjFuGSz-DXpZTJc9llzD9_dJmIFNqPwXyJQ","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":50},"kick":25,"state_default":50,"users":{"@b:x":75,"@c:x":25,"@d:x":0}},["$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ"],1],["$4Y29rAGs_qCOojPzJHSJSRVTtXnr-Ic2vWXs0qPbygs","m.room.join_rules","","@a:x",{"join_rule":"public"},["$oGIE2-zEvjFuGSz-DXpZTJc9llzD9_dJmIFNqPwXyJQ","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ"],2],["$fenIO8W3eMM46rRAwj2Xl9FOqibpT6AbCN278dl23lo","m.room.member","@b:x","@b:x",{"membership":"join"},["$4Y29rAGs_qCOojPzJHSJSRVTtXnr-Ic2vWXs0qPbygs","$oGIE2-zEvjFuGSz-DXpZTJc9llzD9_dJmIFNqPwXyJQ"],4],["$USDbatO6pejCVSB8YMiRqbrf6RoBYgk0LPYllZSErfA","m.room.member","@c:x","@c:x",{"membership":"join"},["$4Y29rAGs_qCOojPzJHSJSRVTtXnr-Ic2vWXs0qPbygs","$oGIE2-zEvjFuGSz-DXpZTJc9llzD9_dJmIFNqPwXyJQ"],1],["$QGOXnLWKlHZhEGOPooiAamU2FUdCVYxpiBcsiYD8pFU","m.room.member","@d:x","@d:x",{"membership":"join"},["$4Y29rAGs_qCOojPzJHSJSRVTtXnr-Ic2vWXs0qPbygs","$oGIE2-zEvjFuGSz-DXpZTJc9llzD9_dJmIFNqPwXyJQ"],1],["$7DGuJAT4Egifq8ivxT1v_Rb69vJcGazsJONvG-tNzng","m.room.topic","","@a:x",{"topic":"base"},["$oGIE2-zEvjFuGSz-DXpZTJc9llzD9_dJmIFNqPwXyJQ","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ"],4],["$qt732v1ftnckv9j1A6sOhEnH5OYC-6_QBptpbLEx5fM","m.room.name","","@a:x",{"name":"base"},["$oGIE2-zEvjFuGSz-DXpZTJc9llzD9_dJmIFNqPwXyJQ","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ"],2],["$NH4wtRUmpYvcRBomcof1hS_y5ckUUNcVqvVUgpLyWEQ","m.room.join_rules","","@b:x",{"join_rule":"invite"},["$oGIE2-zEvjFuGSz-DXpZTJc9llzD9_dJmIFNqPwXyJQ","$fenIO8W3eMM46rRAwj2Xl9FOqibpT6AbCN278dl23lo"],4],["$HiJjMJPhri4-Yoqf8Z3qFAgaWzilcOJ1lk5pY46xNnY","m.room.member","@c:x","@b:x",{"membership":"ban"},["$oGIE2-zEvjFuGSz-DXpZTJc9llzD9_dJmIFNqPwXyJQ","$fenIO8W3eMM46rRAwj2Xl9FOqibpT6AbCN278dl23lo","$USDbatO6pejCVSB8YMiRqbrf6RoBYgk0LPYllZSErfA"],2,true],["$MmkpoCLy6csfFz1dcTFbgACDYUTMOxFB2VP4oOjK4fw","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$NH4wtRUmpYvcRBomcof1hS_y5ckUUNcVqvVUgpLyWEQ","$oGIE2-zEvjFuGSz-DXpZTJc9llzD9_dJmIFNqPwXyJQ","$QGOXnLWKlHZhEGOPooiAamU2FUdCVYxpiBcsiYD8pFU"],1]],"states":[["$7DGuJAT4Egifq8ivxT1v_Rb69vJcGazsJONvG-tNzng","$MmkpoCLy6csfFz1dcTFbgACDYUTMOxFB2VP4oOjK4fw","$NH4wtRUmpYvcRBomcof1hS_y5ckUUNcVqvVUgpLyWEQ","$USDbatO6pejCVSB8YMiRqbrf6RoBYgk0LPYllZSErfA","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$fenIO8W3eMM46rRAwj2Xl9FOqibpT6AbCN278dl23lo","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ","$oGIE2-zEvjFuGSz-DXpZTJc9llzD9_dJmIFNqPwXyJQ","$qt732v1ftnckv9j1A6sOhEnH5OYC-6_QBptpbLEx5fM"],["$7DGuJAT4Egifq8ivxT1v_Rb69vJcGazsJONvG-tNzng","$HiJjMJPhri4-Yoqf8Z3qFAgaWzilcOJ1lk5pY46xNnY","$NH4wtRUmpYvcRBomcof1hS_y5ckUUNcVqvVUgpLyWEQ","$QGOXnLWKlHZhEGOPooiAamU2FUdCVYxpiBcsiYD8pFU","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$fenIO8W3eMM46rRAwj2Xl9FOqibpT6AbCN278dl23lo","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ","$oGIE2-zEvjFuGSz-DXpZTJc9llzD9_dJmIFNqPwXyJQ","$qt732v1ftnckv9j1A6sOhEnH5OYC-6_QBptpbLEx5fM"]],"resolved":["$7DGuJAT4Egifq8ivxT1v_Rb69vJcGazsJONvG-tNzng","$NH4wtRUmpYvcRBomcof1hS_y5ckUUNcVqvVUgpLyWEQ","$QGOXnLWKlHZhEGOPooiAamU2FUdCVYxpiBcsiYD8pFU","$USDbatO6pejCVSB8YMiRqbrf6RoBYgk0LPYllZSErfA","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$fenIO8W3eMM46rRAwj2Xl9FOqibpT6AbCN278dl23lo","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ","$oGIE2-zEvjFuGSz-DXpZTJc9llzD9_dJmIFNqPwXyJQ","$qt732v1ftnckv9j1A6sOhEnH5OYC-6_QBptpbLEx5fM"]}, +{"name":"ts-32","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@b:x":75,"@c:x":0,"@d:x":0}},["$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],4],["$0qnnX-3iQR-YyhAZWigyutMdNFMUhGfkZAyPOlVugP8","m.room.join_rules","","@a:x",{"join_rule":"public"},["$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],2],["$2fyqnOuMN2Qi06_Ep2ZlPk2ONOesqnFEw4ERoTdpgg8","m.room.member","@b:x","@b:x",{"membership":"join"},["$0qnnX-3iQR-YyhAZWigyutMdNFMUhGfkZAyPOlVugP8","$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk"],5],["$eTKDMBwmh8oW14UNAvrOL8umww26Ovvwn8grSL1HAqM","m.room.member","@c:x","@c:x",{"membership":"join"},["$0qnnX-3iQR-YyhAZWigyutMdNFMUhGfkZAyPOlVugP8","$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk"],5],["$kYhEhhKKdk0h5QkxuJsI2M2MzCeuWKkLTH8oG0Zvx8o","m.room.member","@d:x","@d:x",{"membership":"join"},["$0qnnX-3iQR-YyhAZWigyutMdNFMUhGfkZAyPOlVugP8","$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk"],3],["$0dsSFetkGhGMA6bwNbhYuJJHJVnwIxlk-en9NaTxr5E","m.room.name","","@a:x",{"name":"base"},["$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],0],["$8_lqqlWXptxyt5ch6h9c0pundg3D2Hc17uUeCzCAeqY","m.room.topic","","@a:x",{"topic":"0"},["$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],5],["$K5LwjwcWDg3TDleso2zCN5BTLTDWX49UpgTOi4jAN88","m.room.member","@e:x","@a:x",{"membership":"leave"},["$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],3],["$EcpuAtfGcXnB2gZjWlVP3XYyx7ma4DucObm7-8T1rs8","m.room.topic","","@a:x",{"topic":"1"},["$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],3],["$TksRCW613w2JxR1jetCL5uOyPsCVgWjF1uu9KmCRXto","m.room.member","@e:x","@e:x",{"displayname":"2","membership":"join"},["$0qnnX-3iQR-YyhAZWigyutMdNFMUhGfkZAyPOlVugP8","$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk"],3],["$m8P_Yymq7XTF5aJb9Ea97Wy8kidTT_DgAWeNnMApq98","m.room.member","@b:x","@b:x",{"displayname":"3","membership":"join"},["$0qnnX-3iQR-YyhAZWigyutMdNFMUhGfkZAyPOlVugP8","$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk","$2fyqnOuMN2Qi06_Ep2ZlPk2ONOesqnFEw4ERoTdpgg8"],2],["$meSVaxHsBgfefvmvNs4AUNhVpOgIvEFAXCijr1h6t8g","m.room.member","@e:x","@b:x",{"membership":"leave"},["$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk","$2fyqnOuMN2Qi06_Ep2ZlPk2ONOesqnFEw4ERoTdpgg8","$TksRCW613w2JxR1jetCL5uOyPsCVgWjF1uu9KmCRXto"],2]],"states":[["$0dsSFetkGhGMA6bwNbhYuJJHJVnwIxlk-en9NaTxr5E","$0qnnX-3iQR-YyhAZWigyutMdNFMUhGfkZAyPOlVugP8","$2fyqnOuMN2Qi06_Ep2ZlPk2ONOesqnFEw4ERoTdpgg8","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$EcpuAtfGcXnB2gZjWlVP3XYyx7ma4DucObm7-8T1rs8","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","$eTKDMBwmh8oW14UNAvrOL8umww26Ovvwn8grSL1HAqM","$kYhEhhKKdk0h5QkxuJsI2M2MzCeuWKkLTH8oG0Zvx8o","$meSVaxHsBgfefvmvNs4AUNhVpOgIvEFAXCijr1h6t8g","$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk"],["$0dsSFetkGhGMA6bwNbhYuJJHJVnwIxlk-en9NaTxr5E","$0qnnX-3iQR-YyhAZWigyutMdNFMUhGfkZAyPOlVugP8","$8_lqqlWXptxyt5ch6h9c0pundg3D2Hc17uUeCzCAeqY","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$K5LwjwcWDg3TDleso2zCN5BTLTDWX49UpgTOi4jAN88","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","$eTKDMBwmh8oW14UNAvrOL8umww26Ovvwn8grSL1HAqM","$kYhEhhKKdk0h5QkxuJsI2M2MzCeuWKkLTH8oG0Zvx8o","$m8P_Yymq7XTF5aJb9Ea97Wy8kidTT_DgAWeNnMApq98","$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk"]],"resolved":["$0dsSFetkGhGMA6bwNbhYuJJHJVnwIxlk-en9NaTxr5E","$0qnnX-3iQR-YyhAZWigyutMdNFMUhGfkZAyPOlVugP8","$8_lqqlWXptxyt5ch6h9c0pundg3D2Hc17uUeCzCAeqY","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","$eTKDMBwmh8oW14UNAvrOL8umww26Ovvwn8grSL1HAqM","$kYhEhhKKdk0h5QkxuJsI2M2MzCeuWKkLTH8oG0Zvx8o","$m8P_Yymq7XTF5aJb9Ea97Wy8kidTT_DgAWeNnMApq98","$meSVaxHsBgfefvmvNs4AUNhVpOgIvEFAXCijr1h6t8g","$sgNo5TtZ4v9nL5mJ15S_TqLZcjq0Knwp76hjl4HtMfk"]}, +{"name":"ts-52","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$7adSfodLXmaZRdhzsi4aepzn_7s_0F9TOhVb-qQP3EE","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":0},"kick":25,"state_default":50,"users":{"@b:x":75,"@c:x":50,"@d:x":0}},["$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],2],["$JSV9WMeQL3RdqEfXlsopCEsZOklwBSC7Be2AyJL2cdo","m.room.join_rules","","@a:x",{"join_rule":"public"},["$7adSfodLXmaZRdhzsi4aepzn_7s_0F9TOhVb-qQP3EE","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],0],["$vuBFLaey5W93dNXNEgL6MSTBmowDZIHl__FgPKk-j_o","m.room.member","@b:x","@b:x",{"membership":"join"},["$JSV9WMeQL3RdqEfXlsopCEsZOklwBSC7Be2AyJL2cdo","$7adSfodLXmaZRdhzsi4aepzn_7s_0F9TOhVb-qQP3EE"],1],["$XLYg_jjlaAAP1QsEzDZgdLk_VIDqbpvlzvBcLHVMjgU","m.room.member","@c:x","@c:x",{"membership":"join"},["$JSV9WMeQL3RdqEfXlsopCEsZOklwBSC7Be2AyJL2cdo","$7adSfodLXmaZRdhzsi4aepzn_7s_0F9TOhVb-qQP3EE"],4],["$2fASt80FyTqVCjzpWo0YjHBg2SUpUwjABIR6hwax0yM","m.room.member","@d:x","@d:x",{"membership":"join"},["$JSV9WMeQL3RdqEfXlsopCEsZOklwBSC7Be2AyJL2cdo","$7adSfodLXmaZRdhzsi4aepzn_7s_0F9TOhVb-qQP3EE"],0],["$MC1yotmkI8H-B_wWMLPaekCwpeiDz0iNauFtkEBBbkk","m.room.topic","","@a:x",{"topic":"base"},["$7adSfodLXmaZRdhzsi4aepzn_7s_0F9TOhVb-qQP3EE","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],2],["$NsCCG_jXBuaje1SE90MerwB60B5yWmLUC22wZhgsFAQ","m.room.name","","@a:x",{"name":"base"},["$7adSfodLXmaZRdhzsi4aepzn_7s_0F9TOhVb-qQP3EE","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],3],["$gUPzkPmsSed55M7pn1zg2DfX90RVLk3rHsB4l-bCHio","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$JSV9WMeQL3RdqEfXlsopCEsZOklwBSC7Be2AyJL2cdo","$7adSfodLXmaZRdhzsi4aepzn_7s_0F9TOhVb-qQP3EE","$2fASt80FyTqVCjzpWo0YjHBg2SUpUwjABIR6hwax0yM"],2],["$Fnjr3nsbEEWBKC8PnhHA6jd05bkt1_cFvWA448CBpNU","m.room.name","","@b:x",{"name":"1"},["$7adSfodLXmaZRdhzsi4aepzn_7s_0F9TOhVb-qQP3EE","$vuBFLaey5W93dNXNEgL6MSTBmowDZIHl__FgPKk-j_o"],1],["$-_15IU-pqx2N7sflqYZS-4eDj-yHkLPH0_a8ABMntGM","m.room.member","@c:x","@c:x",{"displayname":"2","membership":"join"},["$JSV9WMeQL3RdqEfXlsopCEsZOklwBSC7Be2AyJL2cdo","$7adSfodLXmaZRdhzsi4aepzn_7s_0F9TOhVb-qQP3EE","$XLYg_jjlaAAP1QsEzDZgdLk_VIDqbpvlzvBcLHVMjgU"],2]],"states":[["$7adSfodLXmaZRdhzsi4aepzn_7s_0F9TOhVb-qQP3EE","$JSV9WMeQL3RdqEfXlsopCEsZOklwBSC7Be2AyJL2cdo","$MC1yotmkI8H-B_wWMLPaekCwpeiDz0iNauFtkEBBbkk","$NsCCG_jXBuaje1SE90MerwB60B5yWmLUC22wZhgsFAQ","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","$XLYg_jjlaAAP1QsEzDZgdLk_VIDqbpvlzvBcLHVMjgU","$gUPzkPmsSed55M7pn1zg2DfX90RVLk3rHsB4l-bCHio","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$vuBFLaey5W93dNXNEgL6MSTBmowDZIHl__FgPKk-j_o"],["$-_15IU-pqx2N7sflqYZS-4eDj-yHkLPH0_a8ABMntGM","$7adSfodLXmaZRdhzsi4aepzn_7s_0F9TOhVb-qQP3EE","$Fnjr3nsbEEWBKC8PnhHA6jd05bkt1_cFvWA448CBpNU","$JSV9WMeQL3RdqEfXlsopCEsZOklwBSC7Be2AyJL2cdo","$MC1yotmkI8H-B_wWMLPaekCwpeiDz0iNauFtkEBBbkk","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","$gUPzkPmsSed55M7pn1zg2DfX90RVLk3rHsB4l-bCHio","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$vuBFLaey5W93dNXNEgL6MSTBmowDZIHl__FgPKk-j_o"]],"resolved":["$7adSfodLXmaZRdhzsi4aepzn_7s_0F9TOhVb-qQP3EE","$JSV9WMeQL3RdqEfXlsopCEsZOklwBSC7Be2AyJL2cdo","$MC1yotmkI8H-B_wWMLPaekCwpeiDz0iNauFtkEBBbkk","$NsCCG_jXBuaje1SE90MerwB60B5yWmLUC22wZhgsFAQ","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","$XLYg_jjlaAAP1QsEzDZgdLk_VIDqbpvlzvBcLHVMjgU","$gUPzkPmsSed55M7pn1zg2DfX90RVLk3rHsB4l-bCHio","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$vuBFLaey5W93dNXNEgL6MSTBmowDZIHl__FgPKk-j_o"]}, +{"name":"ts-81","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$b_YMXWK9CKOG88VUz9PsymiECHh-UK85LYbuq5F4-F4","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":75},"kick":75,"state_default":50,"users":{"@b:x":50,"@c:x":50,"@d:x":0}},["$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],0],["$TmJTz25L-8_pg3HbHvHS-LF8NNr6EfFjQnT0_058-RQ","m.room.join_rules","","@a:x",{"join_rule":"public"},["$b_YMXWK9CKOG88VUz9PsymiECHh-UK85LYbuq5F4-F4","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],3],["$Jftdxl5S6CQz_aqEsT4lqOw2QYcbQqCXNTdUolpFqlc","m.room.member","@b:x","@b:x",{"membership":"join"},["$TmJTz25L-8_pg3HbHvHS-LF8NNr6EfFjQnT0_058-RQ","$b_YMXWK9CKOG88VUz9PsymiECHh-UK85LYbuq5F4-F4"],2],["$7V_tdWgVAYuLGfDzgo7trttAJjCf0w1S-3QP_bGfn9A","m.room.member","@c:x","@c:x",{"membership":"join"},["$TmJTz25L-8_pg3HbHvHS-LF8NNr6EfFjQnT0_058-RQ","$b_YMXWK9CKOG88VUz9PsymiECHh-UK85LYbuq5F4-F4"],4],["$YewJ7FxdnbCPooZg-TrPEGRDsEVkg8geoqN_f0IPyk8","m.room.member","@d:x","@d:x",{"membership":"join"},["$TmJTz25L-8_pg3HbHvHS-LF8NNr6EfFjQnT0_058-RQ","$b_YMXWK9CKOG88VUz9PsymiECHh-UK85LYbuq5F4-F4"],0],["$Kn5xSm-lH9JZYC3oH43oELTuXOUskGiZzD_voOet3NA","m.room.topic","","@a:x",{"topic":"base"},["$b_YMXWK9CKOG88VUz9PsymiECHh-UK85LYbuq5F4-F4","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],4],["$JysMJ1NSRSL2s-6M4SfwnOTdzSJVBGai1bpWSCriRDg","m.room.name","","@a:x",{"name":"base"},["$b_YMXWK9CKOG88VUz9PsymiECHh-UK85LYbuq5F4-F4","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],1],["$Z28nHQu8Y9xizNLxES1_f9yAXl_XllhsSwqfjp9ilOE","m.room.member","@c:x","@c:x",{"membership":"leave"},["$b_YMXWK9CKOG88VUz9PsymiECHh-UK85LYbuq5F4-F4","$7V_tdWgVAYuLGfDzgo7trttAJjCf0w1S-3QP_bGfn9A"],3],["$PIjqs62x8wflkaqgTPyypMBNNx8aGr2-jE_10YQMIDA","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$TmJTz25L-8_pg3HbHvHS-LF8NNr6EfFjQnT0_058-RQ","$b_YMXWK9CKOG88VUz9PsymiECHh-UK85LYbuq5F4-F4","$YewJ7FxdnbCPooZg-TrPEGRDsEVkg8geoqN_f0IPyk8"],1],["$t4j3D2rmsi9YzOSXS42N6bFrVgafR_fpgzC7UiLdKmc","m.room.name","","@a:x",{"name":"1"},["$b_YMXWK9CKOG88VUz9PsymiECHh-UK85LYbuq5F4-F4","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],5]],"states":[["$7V_tdWgVAYuLGfDzgo7trttAJjCf0w1S-3QP_bGfn9A","$Jftdxl5S6CQz_aqEsT4lqOw2QYcbQqCXNTdUolpFqlc","$Kn5xSm-lH9JZYC3oH43oELTuXOUskGiZzD_voOet3NA","$TmJTz25L-8_pg3HbHvHS-LF8NNr6EfFjQnT0_058-RQ","$YewJ7FxdnbCPooZg-TrPEGRDsEVkg8geoqN_f0IPyk8","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$b_YMXWK9CKOG88VUz9PsymiECHh-UK85LYbuq5F4-F4","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$t4j3D2rmsi9YzOSXS42N6bFrVgafR_fpgzC7UiLdKmc"],["$Jftdxl5S6CQz_aqEsT4lqOw2QYcbQqCXNTdUolpFqlc","$JysMJ1NSRSL2s-6M4SfwnOTdzSJVBGai1bpWSCriRDg","$Kn5xSm-lH9JZYC3oH43oELTuXOUskGiZzD_voOet3NA","$PIjqs62x8wflkaqgTPyypMBNNx8aGr2-jE_10YQMIDA","$TmJTz25L-8_pg3HbHvHS-LF8NNr6EfFjQnT0_058-RQ","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$Z28nHQu8Y9xizNLxES1_f9yAXl_XllhsSwqfjp9ilOE","$b_YMXWK9CKOG88VUz9PsymiECHh-UK85LYbuq5F4-F4","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"]],"resolved":["$7V_tdWgVAYuLGfDzgo7trttAJjCf0w1S-3QP_bGfn9A","$Jftdxl5S6CQz_aqEsT4lqOw2QYcbQqCXNTdUolpFqlc","$Kn5xSm-lH9JZYC3oH43oELTuXOUskGiZzD_voOet3NA","$PIjqs62x8wflkaqgTPyypMBNNx8aGr2-jE_10YQMIDA","$TmJTz25L-8_pg3HbHvHS-LF8NNr6EfFjQnT0_058-RQ","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$b_YMXWK9CKOG88VUz9PsymiECHh-UK85LYbuq5F4-F4","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$t4j3D2rmsi9YzOSXS42N6bFrVgafR_fpgzC7UiLdKmc"]}, +{"name":"ts-98","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$zJjfgVBYBgOJmQIj_fm2PK94m1Ot71ejICr9-PD8ma8","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":100,"m.room.name":0,"m.room.power_levels":50,"m.room.topic":25},"kick":25,"state_default":0,"users":{"@b:x":0,"@c:x":50,"@d:x":50}},["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],5],["$CmV3c-QffQacaB3iiELxgUJVF6X43VOnzj9w5fyx0wk","m.room.join_rules","","@a:x",{"join_rule":"public"},["$zJjfgVBYBgOJmQIj_fm2PK94m1Ot71ejICr9-PD8ma8","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],0],["$dFwDEOaMO4jNT6AlOO4eusY0q_XA1VB0DF3HEJLsrVk","m.room.member","@b:x","@b:x",{"membership":"join"},["$CmV3c-QffQacaB3iiELxgUJVF6X43VOnzj9w5fyx0wk","$zJjfgVBYBgOJmQIj_fm2PK94m1Ot71ejICr9-PD8ma8"],2],["$bsbkT8enSZRwvHI0TAo8Y98HvNCwRFIm8m721-Jt3ko","m.room.member","@c:x","@c:x",{"membership":"join"},["$CmV3c-QffQacaB3iiELxgUJVF6X43VOnzj9w5fyx0wk","$zJjfgVBYBgOJmQIj_fm2PK94m1Ot71ejICr9-PD8ma8"],3],["$3C51OA5xlExkJEyzYMQdLiC97ae949N2MXiSF1_hBY0","m.room.member","@d:x","@d:x",{"membership":"join"},["$CmV3c-QffQacaB3iiELxgUJVF6X43VOnzj9w5fyx0wk","$zJjfgVBYBgOJmQIj_fm2PK94m1Ot71ejICr9-PD8ma8"],0],["$P7FrV-gqj1h2lOzFB8ZKpu5vow-kWn6z-JKD_thnq5g","m.room.topic","","@a:x",{"topic":"base"},["$zJjfgVBYBgOJmQIj_fm2PK94m1Ot71ejICr9-PD8ma8","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],5],["$G4W3j4rEd5tdqKJtFPRTrbdNS3gpBciUyfSD1iryM_Y","m.room.name","","@a:x",{"name":"base"},["$zJjfgVBYBgOJmQIj_fm2PK94m1Ot71ejICr9-PD8ma8","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],4],["$R4-z4-a8UGvY-BCbwaiN482AuQHQYUgFOtu2a395kn0","m.room.member","@c:x","@c:x",{"membership":"leave"},["$zJjfgVBYBgOJmQIj_fm2PK94m1Ot71ejICr9-PD8ma8","$bsbkT8enSZRwvHI0TAo8Y98HvNCwRFIm8m721-Jt3ko"],3],["$wSVH8Pzrpshqi-4op_zncdedbgV0J8_jifYvvC4InE4","m.room.name","","@d:x",{"name":"0"},["$zJjfgVBYBgOJmQIj_fm2PK94m1Ot71ejICr9-PD8ma8","$3C51OA5xlExkJEyzYMQdLiC97ae949N2MXiSF1_hBY0"],0],["$xukQUZ2OFjjoy8SCdN-uY3aKbhev3CiaghywFj3YXHg","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$CmV3c-QffQacaB3iiELxgUJVF6X43VOnzj9w5fyx0wk","$zJjfgVBYBgOJmQIj_fm2PK94m1Ot71ejICr9-PD8ma8","$3C51OA5xlExkJEyzYMQdLiC97ae949N2MXiSF1_hBY0"],3]],"states":[["$CmV3c-QffQacaB3iiELxgUJVF6X43VOnzj9w5fyx0wk","$P7FrV-gqj1h2lOzFB8ZKpu5vow-kWn6z-JKD_thnq5g","$R4-z4-a8UGvY-BCbwaiN482AuQHQYUgFOtu2a395kn0","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$dFwDEOaMO4jNT6AlOO4eusY0q_XA1VB0DF3HEJLsrVk","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$wSVH8Pzrpshqi-4op_zncdedbgV0J8_jifYvvC4InE4","$xukQUZ2OFjjoy8SCdN-uY3aKbhev3CiaghywFj3YXHg","$zJjfgVBYBgOJmQIj_fm2PK94m1Ot71ejICr9-PD8ma8"],["$3C51OA5xlExkJEyzYMQdLiC97ae949N2MXiSF1_hBY0","$CmV3c-QffQacaB3iiELxgUJVF6X43VOnzj9w5fyx0wk","$G4W3j4rEd5tdqKJtFPRTrbdNS3gpBciUyfSD1iryM_Y","$P7FrV-gqj1h2lOzFB8ZKpu5vow-kWn6z-JKD_thnq5g","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$bsbkT8enSZRwvHI0TAo8Y98HvNCwRFIm8m721-Jt3ko","$dFwDEOaMO4jNT6AlOO4eusY0q_XA1VB0DF3HEJLsrVk","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$zJjfgVBYBgOJmQIj_fm2PK94m1Ot71ejICr9-PD8ma8"]],"resolved":["$CmV3c-QffQacaB3iiELxgUJVF6X43VOnzj9w5fyx0wk","$G4W3j4rEd5tdqKJtFPRTrbdNS3gpBciUyfSD1iryM_Y","$P7FrV-gqj1h2lOzFB8ZKpu5vow-kWn6z-JKD_thnq5g","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$bsbkT8enSZRwvHI0TAo8Y98HvNCwRFIm8m721-Jt3ko","$dFwDEOaMO4jNT6AlOO4eusY0q_XA1VB0DF3HEJLsrVk","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$xukQUZ2OFjjoy8SCdN-uY3aKbhev3CiaghywFj3YXHg","$zJjfgVBYBgOJmQIj_fm2PK94m1Ot71ejICr9-PD8ma8"]}, +{"name":"ts-135","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$KpG2SWsdcYa3YhKEpwG0MpB5Wx9ClfZNDpnv9fSmlgI","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":0},"kick":50,"state_default":0,"users":{"@b:x":0,"@c:x":75,"@d:x":25}},["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w"],4],["$omhhlpJ0Byk1iteLyHt0qxrnS0ZFs01jzHUY3hHYW98","m.room.join_rules","","@a:x",{"join_rule":"public"},["$KpG2SWsdcYa3YhKEpwG0MpB5Wx9ClfZNDpnv9fSmlgI","$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w"],3],["$w2uE7qm8IukqJtPaT6Z5ZTI03VaRKjX7r2H6qfZam-w","m.room.member","@b:x","@b:x",{"membership":"join"},["$omhhlpJ0Byk1iteLyHt0qxrnS0ZFs01jzHUY3hHYW98","$KpG2SWsdcYa3YhKEpwG0MpB5Wx9ClfZNDpnv9fSmlgI"],3],["$2CvCcPdKlRMyOoT33eDGLrveJG2KepcI_laVL_UTHkA","m.room.member","@c:x","@c:x",{"membership":"join"},["$omhhlpJ0Byk1iteLyHt0qxrnS0ZFs01jzHUY3hHYW98","$KpG2SWsdcYa3YhKEpwG0MpB5Wx9ClfZNDpnv9fSmlgI"],0],["$WZeif4ABZ47XH2xJkTf9pDFTmYF60n8GKV_aos-Vsp0","m.room.member","@d:x","@d:x",{"membership":"join"},["$omhhlpJ0Byk1iteLyHt0qxrnS0ZFs01jzHUY3hHYW98","$KpG2SWsdcYa3YhKEpwG0MpB5Wx9ClfZNDpnv9fSmlgI"],5],["$WBrukMgWn7QNG4QxBnsi_o43Xo1so5bBWmVvJ5BN6sg","m.room.topic","","@a:x",{"topic":"base"},["$KpG2SWsdcYa3YhKEpwG0MpB5Wx9ClfZNDpnv9fSmlgI","$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w"],3],["$GIE_TJ5Ap-XRyatwdkZIeZ5xg04hAaU8Log4pEnFsa0","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$omhhlpJ0Byk1iteLyHt0qxrnS0ZFs01jzHUY3hHYW98","$KpG2SWsdcYa3YhKEpwG0MpB5Wx9ClfZNDpnv9fSmlgI","$WZeif4ABZ47XH2xJkTf9pDFTmYF60n8GKV_aos-Vsp0"],5],["$g2CnBWRe1svaXUe9i6NtDftD0EZqBVEcyXJwWC-Dtl8","m.room.topic","","@d:x",{"topic":"1"},["$KpG2SWsdcYa3YhKEpwG0MpB5Wx9ClfZNDpnv9fSmlgI","$GIE_TJ5Ap-XRyatwdkZIeZ5xg04hAaU8Log4pEnFsa0"],1],["$fpjhf35rwPdeUXRK9d0XLa8ujgahMxcjHaUZc2fwrz8","m.room.name","","@d:x",{"name":"2"},["$KpG2SWsdcYa3YhKEpwG0MpB5Wx9ClfZNDpnv9fSmlgI","$GIE_TJ5Ap-XRyatwdkZIeZ5xg04hAaU8Log4pEnFsa0"],0],["$AD14pWai0TY8e1fskUiUPtgrMfQbe4NOkVNpZ9DlBBU","m.room.name","","@a:x",{"name":"3"},["$KpG2SWsdcYa3YhKEpwG0MpB5Wx9ClfZNDpnv9fSmlgI","$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w"],5]],"states":[["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w","$2CvCcPdKlRMyOoT33eDGLrveJG2KepcI_laVL_UTHkA","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$GIE_TJ5Ap-XRyatwdkZIeZ5xg04hAaU8Log4pEnFsa0","$KpG2SWsdcYa3YhKEpwG0MpB5Wx9ClfZNDpnv9fSmlgI","$WBrukMgWn7QNG4QxBnsi_o43Xo1so5bBWmVvJ5BN6sg","$fpjhf35rwPdeUXRK9d0XLa8ujgahMxcjHaUZc2fwrz8","$omhhlpJ0Byk1iteLyHt0qxrnS0ZFs01jzHUY3hHYW98","$w2uE7qm8IukqJtPaT6Z5ZTI03VaRKjX7r2H6qfZam-w"],["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w","$2CvCcPdKlRMyOoT33eDGLrveJG2KepcI_laVL_UTHkA","$AD14pWai0TY8e1fskUiUPtgrMfQbe4NOkVNpZ9DlBBU","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$GIE_TJ5Ap-XRyatwdkZIeZ5xg04hAaU8Log4pEnFsa0","$KpG2SWsdcYa3YhKEpwG0MpB5Wx9ClfZNDpnv9fSmlgI","$g2CnBWRe1svaXUe9i6NtDftD0EZqBVEcyXJwWC-Dtl8","$omhhlpJ0Byk1iteLyHt0qxrnS0ZFs01jzHUY3hHYW98","$w2uE7qm8IukqJtPaT6Z5ZTI03VaRKjX7r2H6qfZam-w"]],"resolved":["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w","$2CvCcPdKlRMyOoT33eDGLrveJG2KepcI_laVL_UTHkA","$AD14pWai0TY8e1fskUiUPtgrMfQbe4NOkVNpZ9DlBBU","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$GIE_TJ5Ap-XRyatwdkZIeZ5xg04hAaU8Log4pEnFsa0","$KpG2SWsdcYa3YhKEpwG0MpB5Wx9ClfZNDpnv9fSmlgI","$WBrukMgWn7QNG4QxBnsi_o43Xo1so5bBWmVvJ5BN6sg","$omhhlpJ0Byk1iteLyHt0qxrnS0ZFs01jzHUY3hHYW98","$w2uE7qm8IukqJtPaT6Z5ZTI03VaRKjX7r2H6qfZam-w"]}, +{"name":"ts-202","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$APKF_v28FvVrjx8zJ6o5IzAXnP6lnA5Alvsaj2Crvxw","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":75},"kick":50,"state_default":50,"users":{"@b:x":75,"@c:x":0,"@d:x":25}},["$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],4],["$fy76zia0fD___SMGj9l7Mja3JplMLSRexaUfVBDIo1I","m.room.join_rules","","@a:x",{"join_rule":"public"},["$APKF_v28FvVrjx8zJ6o5IzAXnP6lnA5Alvsaj2Crvxw","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],1],["$phiJiBJ2Y52t017XP_XpOTMjowEhv4LYO4s_Srz8Zck","m.room.member","@b:x","@b:x",{"membership":"join"},["$fy76zia0fD___SMGj9l7Mja3JplMLSRexaUfVBDIo1I","$APKF_v28FvVrjx8zJ6o5IzAXnP6lnA5Alvsaj2Crvxw"],2],["$wP4m1UWYEW1RUI9bBjB8jtQO0i-1xaD1GtDr2BOW_yU","m.room.member","@c:x","@c:x",{"membership":"join"},["$fy76zia0fD___SMGj9l7Mja3JplMLSRexaUfVBDIo1I","$APKF_v28FvVrjx8zJ6o5IzAXnP6lnA5Alvsaj2Crvxw"],2],["$C1maTX2jwkS21FuZeCi7mBQAGnhLQP3sWzqk7nzr9Fo","m.room.member","@d:x","@d:x",{"membership":"join"},["$fy76zia0fD___SMGj9l7Mja3JplMLSRexaUfVBDIo1I","$APKF_v28FvVrjx8zJ6o5IzAXnP6lnA5Alvsaj2Crvxw"],0],["$xG7s3fpTefC72ZEVIABPmqP9-PLN_cxQR_AfbNlGx9w","m.room.topic","","@a:x",{"topic":"base"},["$APKF_v28FvVrjx8zJ6o5IzAXnP6lnA5Alvsaj2Crvxw","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],0],["$X59mjWdlHOb3OW78ia_v0_Q8h-RINq4NFNckRpc2P1Y","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$fy76zia0fD___SMGj9l7Mja3JplMLSRexaUfVBDIo1I","$APKF_v28FvVrjx8zJ6o5IzAXnP6lnA5Alvsaj2Crvxw","$phiJiBJ2Y52t017XP_XpOTMjowEhv4LYO4s_Srz8Zck"],0],["$bheJ6JRlZDbYlb11zYDGPvkKXc2bSoRkXLkCYnGNvx0","m.room.member","@e:x","@a:x",{"membership":"ban"},["$APKF_v28FvVrjx8zJ6o5IzAXnP6lnA5Alvsaj2Crvxw","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],0],["$qZutbaJ3UlZgSqn5fqJggvpFLNe3NUrUoLZWQxyYvYU","m.room.name","","@a:x",{"name":"1"},["$APKF_v28FvVrjx8zJ6o5IzAXnP6lnA5Alvsaj2Crvxw","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],4],["$OIGegLE4nuK6JWOpG-4C0C3oTFkLn9IbkyXKzTkNq5U","m.room.member","@e:x","@a:x",{"membership":"ban"},["$APKF_v28FvVrjx8zJ6o5IzAXnP6lnA5Alvsaj2Crvxw","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","$bheJ6JRlZDbYlb11zYDGPvkKXc2bSoRkXLkCYnGNvx0"],2]],"states":[["$APKF_v28FvVrjx8zJ6o5IzAXnP6lnA5Alvsaj2Crvxw","$C1maTX2jwkS21FuZeCi7mBQAGnhLQP3sWzqk7nzr9Fo","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","$X59mjWdlHOb3OW78ia_v0_Q8h-RINq4NFNckRpc2P1Y","$bheJ6JRlZDbYlb11zYDGPvkKXc2bSoRkXLkCYnGNvx0","$fy76zia0fD___SMGj9l7Mja3JplMLSRexaUfVBDIo1I","$qZutbaJ3UlZgSqn5fqJggvpFLNe3NUrUoLZWQxyYvYU","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$wP4m1UWYEW1RUI9bBjB8jtQO0i-1xaD1GtDr2BOW_yU","$xG7s3fpTefC72ZEVIABPmqP9-PLN_cxQR_AfbNlGx9w"],["$APKF_v28FvVrjx8zJ6o5IzAXnP6lnA5Alvsaj2Crvxw","$C1maTX2jwkS21FuZeCi7mBQAGnhLQP3sWzqk7nzr9Fo","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","$OIGegLE4nuK6JWOpG-4C0C3oTFkLn9IbkyXKzTkNq5U","$X59mjWdlHOb3OW78ia_v0_Q8h-RINq4NFNckRpc2P1Y","$fy76zia0fD___SMGj9l7Mja3JplMLSRexaUfVBDIo1I","$qZutbaJ3UlZgSqn5fqJggvpFLNe3NUrUoLZWQxyYvYU","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$wP4m1UWYEW1RUI9bBjB8jtQO0i-1xaD1GtDr2BOW_yU","$xG7s3fpTefC72ZEVIABPmqP9-PLN_cxQR_AfbNlGx9w"]],"resolved":["$APKF_v28FvVrjx8zJ6o5IzAXnP6lnA5Alvsaj2Crvxw","$C1maTX2jwkS21FuZeCi7mBQAGnhLQP3sWzqk7nzr9Fo","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","$OIGegLE4nuK6JWOpG-4C0C3oTFkLn9IbkyXKzTkNq5U","$X59mjWdlHOb3OW78ia_v0_Q8h-RINq4NFNckRpc2P1Y","$fy76zia0fD___SMGj9l7Mja3JplMLSRexaUfVBDIo1I","$qZutbaJ3UlZgSqn5fqJggvpFLNe3NUrUoLZWQxyYvYU","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$wP4m1UWYEW1RUI9bBjB8jtQO0i-1xaD1GtDr2BOW_yU","$xG7s3fpTefC72ZEVIABPmqP9-PLN_cxQR_AfbNlGx9w"]}, +{"name":"ts-228","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$5I3DZ65_Docul8Jv3zaUp82W4wt-0MsqxSJ06P8cc9Y","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$B3VeeYH71TD-DenKYnqmkg6ps6mRnButAJCpWeyYCu4","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":100,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@b:x":50,"@c:x":0,"@d:x":75}},["$5I3DZ65_Docul8Jv3zaUp82W4wt-0MsqxSJ06P8cc9Y"],5],["$-AlLT1BnQV3rYIxUsMTIPU8GkC2Ilv-rFANeGw1PW2E","m.room.join_rules","","@a:x",{"join_rule":"public"},["$B3VeeYH71TD-DenKYnqmkg6ps6mRnButAJCpWeyYCu4","$5I3DZ65_Docul8Jv3zaUp82W4wt-0MsqxSJ06P8cc9Y"],2],["$ivqD_E9EDvnrkwYnL-flC-pRljRtFx1W8uzcYSovb6U","m.room.member","@b:x","@b:x",{"membership":"join"},["$-AlLT1BnQV3rYIxUsMTIPU8GkC2Ilv-rFANeGw1PW2E","$B3VeeYH71TD-DenKYnqmkg6ps6mRnButAJCpWeyYCu4"],4],["$6Npz7f82zDMjArbuG4D5-lxjA9T0Y1PJk3CK_A7epok","m.room.member","@c:x","@c:x",{"membership":"join"},["$-AlLT1BnQV3rYIxUsMTIPU8GkC2Ilv-rFANeGw1PW2E","$B3VeeYH71TD-DenKYnqmkg6ps6mRnButAJCpWeyYCu4"],1],["$1SahdQUiA5sdoMLLY8nWbR3sAqnQNZD4l3LXoDh27G8","m.room.member","@d:x","@d:x",{"membership":"join"},["$-AlLT1BnQV3rYIxUsMTIPU8GkC2Ilv-rFANeGw1PW2E","$B3VeeYH71TD-DenKYnqmkg6ps6mRnButAJCpWeyYCu4"],3],["$WPgzDYcG3PX0WHcxplbjqXHyIAbfLcAIZZnxqM1yrQU","m.room.topic","","@a:x",{"topic":"base"},["$B3VeeYH71TD-DenKYnqmkg6ps6mRnButAJCpWeyYCu4","$5I3DZ65_Docul8Jv3zaUp82W4wt-0MsqxSJ06P8cc9Y"],3],["$WEqNwzJA0n-4Vnh7iUT-nisCNgG71Kzpzsp_kQkyGhE","m.room.name","","@a:x",{"name":"base"},["$B3VeeYH71TD-DenKYnqmkg6ps6mRnButAJCpWeyYCu4","$5I3DZ65_Docul8Jv3zaUp82W4wt-0MsqxSJ06P8cc9Y"],3],["$fN7HwP8_zCuqnL7EI3TWjeed78v0iAlGH4Jk-XLRjqk","m.room.member","@d:x","@d:x",{"membership":"leave"},["$B3VeeYH71TD-DenKYnqmkg6ps6mRnButAJCpWeyYCu4","$1SahdQUiA5sdoMLLY8nWbR3sAqnQNZD4l3LXoDh27G8"],4]],"states":[["$-AlLT1BnQV3rYIxUsMTIPU8GkC2Ilv-rFANeGw1PW2E","$1SahdQUiA5sdoMLLY8nWbR3sAqnQNZD4l3LXoDh27G8","$5I3DZ65_Docul8Jv3zaUp82W4wt-0MsqxSJ06P8cc9Y","$6Npz7f82zDMjArbuG4D5-lxjA9T0Y1PJk3CK_A7epok","$B3VeeYH71TD-DenKYnqmkg6ps6mRnButAJCpWeyYCu4","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$WEqNwzJA0n-4Vnh7iUT-nisCNgG71Kzpzsp_kQkyGhE","$WPgzDYcG3PX0WHcxplbjqXHyIAbfLcAIZZnxqM1yrQU","$ivqD_E9EDvnrkwYnL-flC-pRljRtFx1W8uzcYSovb6U"],["$-AlLT1BnQV3rYIxUsMTIPU8GkC2Ilv-rFANeGw1PW2E","$5I3DZ65_Docul8Jv3zaUp82W4wt-0MsqxSJ06P8cc9Y","$6Npz7f82zDMjArbuG4D5-lxjA9T0Y1PJk3CK_A7epok","$B3VeeYH71TD-DenKYnqmkg6ps6mRnButAJCpWeyYCu4","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$WEqNwzJA0n-4Vnh7iUT-nisCNgG71Kzpzsp_kQkyGhE","$WPgzDYcG3PX0WHcxplbjqXHyIAbfLcAIZZnxqM1yrQU","$fN7HwP8_zCuqnL7EI3TWjeed78v0iAlGH4Jk-XLRjqk","$ivqD_E9EDvnrkwYnL-flC-pRljRtFx1W8uzcYSovb6U"]],"resolved":["$-AlLT1BnQV3rYIxUsMTIPU8GkC2Ilv-rFANeGw1PW2E","$5I3DZ65_Docul8Jv3zaUp82W4wt-0MsqxSJ06P8cc9Y","$6Npz7f82zDMjArbuG4D5-lxjA9T0Y1PJk3CK_A7epok","$B3VeeYH71TD-DenKYnqmkg6ps6mRnButAJCpWeyYCu4","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$WEqNwzJA0n-4Vnh7iUT-nisCNgG71Kzpzsp_kQkyGhE","$WPgzDYcG3PX0WHcxplbjqXHyIAbfLcAIZZnxqM1yrQU","$fN7HwP8_zCuqnL7EI3TWjeed78v0iAlGH4Jk-XLRjqk","$ivqD_E9EDvnrkwYnL-flC-pRljRtFx1W8uzcYSovb6U"]}, +{"name":"ts-235","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$dDtlM0EL06DBlql3M9N-kcItE-5SnDFLRjdU768_vKs","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":0},"kick":75,"state_default":50,"users":{"@b:x":25,"@c:x":0,"@d:x":25}},["$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],0],["$LKYgz4EcHl-bhGOhN5KBqnzxXNAlxmT2QK3_jJidEKU","m.room.join_rules","","@a:x",{"join_rule":"public"},["$dDtlM0EL06DBlql3M9N-kcItE-5SnDFLRjdU768_vKs","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],3],["$5N76M1_dUTb01Duykb-ud61KZwBqnkSHuvExQN5v8YE","m.room.member","@b:x","@b:x",{"membership":"join"},["$LKYgz4EcHl-bhGOhN5KBqnzxXNAlxmT2QK3_jJidEKU","$dDtlM0EL06DBlql3M9N-kcItE-5SnDFLRjdU768_vKs"],5],["$QtCkc9-fI2Vle3exoSHDToM4eSmvsc-T3cWIAu62tk0","m.room.member","@c:x","@c:x",{"membership":"join"},["$LKYgz4EcHl-bhGOhN5KBqnzxXNAlxmT2QK3_jJidEKU","$dDtlM0EL06DBlql3M9N-kcItE-5SnDFLRjdU768_vKs"],3],["$o3hjUgZ6coJ6N0A2MAWKq8LZq-RY7kKCFVbjdsBoxac","m.room.member","@d:x","@d:x",{"membership":"join"},["$LKYgz4EcHl-bhGOhN5KBqnzxXNAlxmT2QK3_jJidEKU","$dDtlM0EL06DBlql3M9N-kcItE-5SnDFLRjdU768_vKs"],5],["$Nu3LnZ1vIeaqbBdlsy5G9j0BtwwNMp3HblwpYrnJ-ug","m.room.topic","","@a:x",{"topic":"base"},["$dDtlM0EL06DBlql3M9N-kcItE-5SnDFLRjdU768_vKs","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],4],["$zTpdsoxyFxiwfl5eBieJVfYRZJLnjtEcwt9ZRi4SNOs","m.room.name","","@a:x",{"name":"base"},["$dDtlM0EL06DBlql3M9N-kcItE-5SnDFLRjdU768_vKs","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],5],["$G3oY9HHzg6USJhHEUAzECyLu7nUIBdz7R49Pjg1tXgY","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$LKYgz4EcHl-bhGOhN5KBqnzxXNAlxmT2QK3_jJidEKU","$dDtlM0EL06DBlql3M9N-kcItE-5SnDFLRjdU768_vKs","$5N76M1_dUTb01Duykb-ud61KZwBqnkSHuvExQN5v8YE"],5],["$LR18Oxb98ebPl4Yz8W468PRdDqHMRvNP2HO0Rcjzytw","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$dDtlM0EL06DBlql3M9N-kcItE-5SnDFLRjdU768_vKs","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],0],["$f3qVDkmTlX3B4F2zIs5pe9a2d44PSr6yXyoxsE9vuZw","m.room.topic","","@d:x",{"topic":"1"},["$dDtlM0EL06DBlql3M9N-kcItE-5SnDFLRjdU768_vKs","$o3hjUgZ6coJ6N0A2MAWKq8LZq-RY7kKCFVbjdsBoxac"],1]],"states":[["$G3oY9HHzg6USJhHEUAzECyLu7nUIBdz7R49Pjg1tXgY","$LKYgz4EcHl-bhGOhN5KBqnzxXNAlxmT2QK3_jJidEKU","$Nu3LnZ1vIeaqbBdlsy5G9j0BtwwNMp3HblwpYrnJ-ug","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$QtCkc9-fI2Vle3exoSHDToM4eSmvsc-T3cWIAu62tk0","$dDtlM0EL06DBlql3M9N-kcItE-5SnDFLRjdU768_vKs","$o3hjUgZ6coJ6N0A2MAWKq8LZq-RY7kKCFVbjdsBoxac","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA","$zTpdsoxyFxiwfl5eBieJVfYRZJLnjtEcwt9ZRi4SNOs"],["$5N76M1_dUTb01Duykb-ud61KZwBqnkSHuvExQN5v8YE","$LR18Oxb98ebPl4Yz8W468PRdDqHMRvNP2HO0Rcjzytw","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$QtCkc9-fI2Vle3exoSHDToM4eSmvsc-T3cWIAu62tk0","$dDtlM0EL06DBlql3M9N-kcItE-5SnDFLRjdU768_vKs","$f3qVDkmTlX3B4F2zIs5pe9a2d44PSr6yXyoxsE9vuZw","$o3hjUgZ6coJ6N0A2MAWKq8LZq-RY7kKCFVbjdsBoxac","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA","$zTpdsoxyFxiwfl5eBieJVfYRZJLnjtEcwt9ZRi4SNOs"]],"resolved":["$G3oY9HHzg6USJhHEUAzECyLu7nUIBdz7R49Pjg1tXgY","$LKYgz4EcHl-bhGOhN5KBqnzxXNAlxmT2QK3_jJidEKU","$Nu3LnZ1vIeaqbBdlsy5G9j0BtwwNMp3HblwpYrnJ-ug","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$QtCkc9-fI2Vle3exoSHDToM4eSmvsc-T3cWIAu62tk0","$dDtlM0EL06DBlql3M9N-kcItE-5SnDFLRjdU768_vKs","$o3hjUgZ6coJ6N0A2MAWKq8LZq-RY7kKCFVbjdsBoxac","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA","$zTpdsoxyFxiwfl5eBieJVfYRZJLnjtEcwt9ZRi4SNOs"]}, +{"name":"ts-241","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":50},"kick":50,"state_default":50,"users":{"@b:x":25,"@c:x":25,"@d:x":75}},["$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],2],["$1D-aOpjXM3xUu1xj06swWWwZ6sbrKKtwx0XRYNRYcKM","m.room.join_rules","","@a:x",{"join_rule":"public"},["$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],2],["$yP_YX9MCrL0UW-AmSBZmONAYJqGCFu8QxF80_VqJEes","m.room.member","@b:x","@b:x",{"membership":"join"},["$1D-aOpjXM3xUu1xj06swWWwZ6sbrKKtwx0XRYNRYcKM","$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI"],4],["$ywyPA4uyoPPncgFc7dPI9hYQck70vR3PobMb4HkURY8","m.room.member","@c:x","@c:x",{"membership":"join"},["$1D-aOpjXM3xUu1xj06swWWwZ6sbrKKtwx0XRYNRYcKM","$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI"],0],["$hwWtf8if9AsTM3-p9AeAzRmdih9_Zl-9lgVuhCE7RAk","m.room.member","@d:x","@d:x",{"membership":"join"},["$1D-aOpjXM3xUu1xj06swWWwZ6sbrKKtwx0XRYNRYcKM","$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI"],1],["$oLA0Mf_GwCR7Y6JAINzvr_1G-URepQJ35BdjIW5CObg","m.room.topic","","@a:x",{"topic":"base"},["$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],3],["$y5ZQJtpjSoP_KTkFo0S_ONFMnfV2DW3_REFqHRE_tJ8","m.room.name","","@a:x",{"name":"base"},["$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],2],["$gxmdx-4CDDLYBABnGSkO__wwVcMs25X0OqL7bAzJQ4U","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$1D-aOpjXM3xUu1xj06swWWwZ6sbrKKtwx0XRYNRYcKM","$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI","$yP_YX9MCrL0UW-AmSBZmONAYJqGCFu8QxF80_VqJEes"],5],["$yWfq0jL7r2WyVLhrmiB8P_evwaLmD_GQ9PKadMfqZLo","m.room.topic","","@a:x",{"topic":"1"},["$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],3],["$KljAcXLZLDIGaGsr8zmMepK9VzU3oXb4x6HmdfdceWs","m.room.member","@b:x","@b:x",{"membership":"leave"},["$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI","$yP_YX9MCrL0UW-AmSBZmONAYJqGCFu8QxF80_VqJEes"],0],["$IyorzM2YS8mJ4chVwxcsfIS9BoBxZuso5kjLSfMQg3M","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI","$hwWtf8if9AsTM3-p9AeAzRmdih9_Zl-9lgVuhCE7RAk"],0]],"states":[["$1D-aOpjXM3xUu1xj06swWWwZ6sbrKKtwx0XRYNRYcKM","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$hwWtf8if9AsTM3-p9AeAzRmdih9_Zl-9lgVuhCE7RAk","$oLA0Mf_GwCR7Y6JAINzvr_1G-URepQJ35BdjIW5CObg","$y5ZQJtpjSoP_KTkFo0S_ONFMnfV2DW3_REFqHRE_tJ8","$yP_YX9MCrL0UW-AmSBZmONAYJqGCFu8QxF80_VqJEes","$ywyPA4uyoPPncgFc7dPI9hYQck70vR3PobMb4HkURY8"],["$1D-aOpjXM3xUu1xj06swWWwZ6sbrKKtwx0XRYNRYcKM","$KljAcXLZLDIGaGsr8zmMepK9VzU3oXb4x6HmdfdceWs","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$hwWtf8if9AsTM3-p9AeAzRmdih9_Zl-9lgVuhCE7RAk","$y5ZQJtpjSoP_KTkFo0S_ONFMnfV2DW3_REFqHRE_tJ8","$yWfq0jL7r2WyVLhrmiB8P_evwaLmD_GQ9PKadMfqZLo","$ywyPA4uyoPPncgFc7dPI9hYQck70vR3PobMb4HkURY8"],["$IyorzM2YS8mJ4chVwxcsfIS9BoBxZuso5kjLSfMQg3M","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$gxmdx-4CDDLYBABnGSkO__wwVcMs25X0OqL7bAzJQ4U","$hwWtf8if9AsTM3-p9AeAzRmdih9_Zl-9lgVuhCE7RAk","$oLA0Mf_GwCR7Y6JAINzvr_1G-URepQJ35BdjIW5CObg","$y5ZQJtpjSoP_KTkFo0S_ONFMnfV2DW3_REFqHRE_tJ8","$ywyPA4uyoPPncgFc7dPI9hYQck70vR3PobMb4HkURY8"]],"resolved":["$IyorzM2YS8mJ4chVwxcsfIS9BoBxZuso5kjLSfMQg3M","$KljAcXLZLDIGaGsr8zmMepK9VzU3oXb4x6HmdfdceWs","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$UIOyCY_hHRi_WQpTUQBxw69WSn0pjgh56IkXx9J3qdI","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$hwWtf8if9AsTM3-p9AeAzRmdih9_Zl-9lgVuhCE7RAk","$y5ZQJtpjSoP_KTkFo0S_ONFMnfV2DW3_REFqHRE_tJ8","$yWfq0jL7r2WyVLhrmiB8P_evwaLmD_GQ9PKadMfqZLo","$ywyPA4uyoPPncgFc7dPI9hYQck70vR3PobMb4HkURY8"]}, +{"name":"ts-248","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":50,"users":{"@b:x":25,"@c:x":75,"@d:x":50}},["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],5],["$cjO0BLBIsCJn-DhlGxEuMOylbDLIHIJS6aTJTCiFn5c","m.room.join_rules","","@a:x",{"join_rule":"public"},["$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],1],["$5YVyp69CxFQDF-tCC7p0XW2GZKlnpoF06dvU3NsXsyw","m.room.member","@b:x","@b:x",{"membership":"join"},["$cjO0BLBIsCJn-DhlGxEuMOylbDLIHIJS6aTJTCiFn5c","$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4"],2],["$Du3KFCe8sRGSDOCFYDAkuhjwkNHZy6YcxyplKD352JI","m.room.member","@c:x","@c:x",{"membership":"join"},["$cjO0BLBIsCJn-DhlGxEuMOylbDLIHIJS6aTJTCiFn5c","$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4"],5],["$S9jDmGWDLDZnBbjijvwGXX3IZLOtKtLN4EdWLA4fxiM","m.room.member","@d:x","@d:x",{"membership":"join"},["$cjO0BLBIsCJn-DhlGxEuMOylbDLIHIJS6aTJTCiFn5c","$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4"],5],["$sTq3Bfih3fWmdPEMixIj1WomsmegpFfJ-smDwTFVRAw","m.room.topic","","@a:x",{"topic":"base"},["$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],2],["$ANrSDy_Cb_kT6FlYNRVfN7tJKz7mjcQaZ0rgSg7aHok","m.room.name","","@a:x",{"name":"base"},["$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],3],["$iFv0gIm21lOuWW8gWO5419V44yW7PbCBrHP-QHZxR8M","m.room.member","@e:x","@d:x",{"membership":"ban"},["$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4","$S9jDmGWDLDZnBbjijvwGXX3IZLOtKtLN4EdWLA4fxiM"],4],["$GabvXvn4X5J859oH0YIvPs6OLR4IYSVOwjGCKh_B94M","m.room.join_rules","","@c:x",{"join_rule":"public"},["$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4","$Du3KFCe8sRGSDOCFYDAkuhjwkNHZy6YcxyplKD352JI"],5],["$p4SFMB08aJy6u-RH1K8uSJ5YY_HB_iSMWZyoN7_-n88","m.room.member","@c:x","@c:x",{"membership":"leave"},["$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4","$Du3KFCe8sRGSDOCFYDAkuhjwkNHZy6YcxyplKD352JI"],0],["$jREVPf5l40sU5MuVAyQOLM88AS5A-rYGTw2lQI41-MA","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$cjO0BLBIsCJn-DhlGxEuMOylbDLIHIJS6aTJTCiFn5c","$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4","$Du3KFCe8sRGSDOCFYDAkuhjwkNHZy6YcxyplKD352JI"],4],["$gn0pyw5yy2YiLbxT0jOqC0n4hr8C0VlDkSTgKSx8WAU","m.room.member","@c:x","@c:x",{"membership":"leave"},["$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4","$jREVPf5l40sU5MuVAyQOLM88AS5A-rYGTw2lQI41-MA"],1]],"states":[["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$5YVyp69CxFQDF-tCC7p0XW2GZKlnpoF06dvU3NsXsyw","$ANrSDy_Cb_kT6FlYNRVfN7tJKz7mjcQaZ0rgSg7aHok","$S9jDmGWDLDZnBbjijvwGXX3IZLOtKtLN4EdWLA4fxiM","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$cjO0BLBIsCJn-DhlGxEuMOylbDLIHIJS6aTJTCiFn5c","$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4","$gn0pyw5yy2YiLbxT0jOqC0n4hr8C0VlDkSTgKSx8WAU","$iFv0gIm21lOuWW8gWO5419V44yW7PbCBrHP-QHZxR8M","$sTq3Bfih3fWmdPEMixIj1WomsmegpFfJ-smDwTFVRAw"],["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$5YVyp69CxFQDF-tCC7p0XW2GZKlnpoF06dvU3NsXsyw","$ANrSDy_Cb_kT6FlYNRVfN7tJKz7mjcQaZ0rgSg7aHok","$GabvXvn4X5J859oH0YIvPs6OLR4IYSVOwjGCKh_B94M","$S9jDmGWDLDZnBbjijvwGXX3IZLOtKtLN4EdWLA4fxiM","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4","$p4SFMB08aJy6u-RH1K8uSJ5YY_HB_iSMWZyoN7_-n88","$sTq3Bfih3fWmdPEMixIj1WomsmegpFfJ-smDwTFVRAw"]],"resolved":["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$5YVyp69CxFQDF-tCC7p0XW2GZKlnpoF06dvU3NsXsyw","$ANrSDy_Cb_kT6FlYNRVfN7tJKz7mjcQaZ0rgSg7aHok","$GabvXvn4X5J859oH0YIvPs6OLR4IYSVOwjGCKh_B94M","$S9jDmGWDLDZnBbjijvwGXX3IZLOtKtLN4EdWLA4fxiM","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$dnjS5SBo-ptiymLVV94fwuq7W3a5VdhmXuVxQf2QrA4","$iFv0gIm21lOuWW8gWO5419V44yW7PbCBrHP-QHZxR8M","$jREVPf5l40sU5MuVAyQOLM88AS5A-rYGTw2lQI41-MA","$sTq3Bfih3fWmdPEMixIj1WomsmegpFfJ-smDwTFVRAw"]}, +{"name":"ts-249","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$pk8W824xchfZo3K1-kW_vEYKU7EoBoXnr4T6o1ZlD7Y","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@b:x":50,"@c:x":25,"@d:x":0}},["$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],3],["$6vzLalFfEEo4-8KwwrN4eQi0jzeGWYq5ELmyDoEJMnE","m.room.join_rules","","@a:x",{"join_rule":"public"},["$pk8W824xchfZo3K1-kW_vEYKU7EoBoXnr4T6o1ZlD7Y","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],0],["$XdjTMTV20m_HFBcTxd59H1zvXCnAA2GGmP8yeYJT1sU","m.room.member","@b:x","@b:x",{"membership":"join"},["$6vzLalFfEEo4-8KwwrN4eQi0jzeGWYq5ELmyDoEJMnE","$pk8W824xchfZo3K1-kW_vEYKU7EoBoXnr4T6o1ZlD7Y"],0],["$m9Zb_wq2DjeE5cYbcaAPtzuhVwJ4VckR3oZ6P7KznbA","m.room.member","@c:x","@c:x",{"membership":"join"},["$6vzLalFfEEo4-8KwwrN4eQi0jzeGWYq5ELmyDoEJMnE","$pk8W824xchfZo3K1-kW_vEYKU7EoBoXnr4T6o1ZlD7Y"],2],["$SOgHUHzfj4aRXZ_yMDJxBWk_lFpUIh_hG6EwkVV7tL4","m.room.member","@d:x","@d:x",{"membership":"join"},["$6vzLalFfEEo4-8KwwrN4eQi0jzeGWYq5ELmyDoEJMnE","$pk8W824xchfZo3K1-kW_vEYKU7EoBoXnr4T6o1ZlD7Y"],1],["$j-QPjBOWcBGOsjW7fp5fM16gIf2HeuCsFRwTZYnNtdA","m.room.topic","","@a:x",{"topic":"base"},["$pk8W824xchfZo3K1-kW_vEYKU7EoBoXnr4T6o1ZlD7Y","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],4],["$mlaLdmI_W361tHYCrfgKVdEVtdxAJCINjJ5TFeUTOlk","m.room.name","","@a:x",{"name":"base"},["$pk8W824xchfZo3K1-kW_vEYKU7EoBoXnr4T6o1ZlD7Y","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],1],["$WW4fr-5CjcbgCAc5wn2-jRhV0rdskksE-t2KFV1URzc","m.room.member","@e:x","@d:x",{"membership":"invite"},["$6vzLalFfEEo4-8KwwrN4eQi0jzeGWYq5ELmyDoEJMnE","$pk8W824xchfZo3K1-kW_vEYKU7EoBoXnr4T6o1ZlD7Y","$SOgHUHzfj4aRXZ_yMDJxBWk_lFpUIh_hG6EwkVV7tL4"],0]],"states":[["$6vzLalFfEEo4-8KwwrN4eQi0jzeGWYq5ELmyDoEJMnE","$SOgHUHzfj4aRXZ_yMDJxBWk_lFpUIh_hG6EwkVV7tL4","$WW4fr-5CjcbgCAc5wn2-jRhV0rdskksE-t2KFV1URzc","$XdjTMTV20m_HFBcTxd59H1zvXCnAA2GGmP8yeYJT1sU","$j-QPjBOWcBGOsjW7fp5fM16gIf2HeuCsFRwTZYnNtdA","$m9Zb_wq2DjeE5cYbcaAPtzuhVwJ4VckR3oZ6P7KznbA","$mlaLdmI_W361tHYCrfgKVdEVtdxAJCINjJ5TFeUTOlk","$pk8W824xchfZo3K1-kW_vEYKU7EoBoXnr4T6o1ZlD7Y","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],["$6vzLalFfEEo4-8KwwrN4eQi0jzeGWYq5ELmyDoEJMnE","$SOgHUHzfj4aRXZ_yMDJxBWk_lFpUIh_hG6EwkVV7tL4","$WW4fr-5CjcbgCAc5wn2-jRhV0rdskksE-t2KFV1URzc","$XdjTMTV20m_HFBcTxd59H1zvXCnAA2GGmP8yeYJT1sU","$j-QPjBOWcBGOsjW7fp5fM16gIf2HeuCsFRwTZYnNtdA","$m9Zb_wq2DjeE5cYbcaAPtzuhVwJ4VckR3oZ6P7KznbA","$mlaLdmI_W361tHYCrfgKVdEVtdxAJCINjJ5TFeUTOlk","$pk8W824xchfZo3K1-kW_vEYKU7EoBoXnr4T6o1ZlD7Y","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"]],"resolved":["$6vzLalFfEEo4-8KwwrN4eQi0jzeGWYq5ELmyDoEJMnE","$SOgHUHzfj4aRXZ_yMDJxBWk_lFpUIh_hG6EwkVV7tL4","$WW4fr-5CjcbgCAc5wn2-jRhV0rdskksE-t2KFV1URzc","$XdjTMTV20m_HFBcTxd59H1zvXCnAA2GGmP8yeYJT1sU","$j-QPjBOWcBGOsjW7fp5fM16gIf2HeuCsFRwTZYnNtdA","$m9Zb_wq2DjeE5cYbcaAPtzuhVwJ4VckR3oZ6P7KznbA","$mlaLdmI_W361tHYCrfgKVdEVtdxAJCINjJ5TFeUTOlk","$pk8W824xchfZo3K1-kW_vEYKU7EoBoXnr4T6o1ZlD7Y","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"]}, +{"name":"ts-250","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$LAqO9UEIq3iw6Sy-vUuVrgTljflP-29TPvYMU0jAXhw","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":75},"kick":75,"state_default":50,"users":{"@b:x":50,"@c:x":50,"@d:x":75}},["$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg"],5],["$h2jfmmDoI1pS5vaYstx-zAWreO6bPsTKnXLWc9g5Xao","m.room.join_rules","","@a:x",{"join_rule":"public"},["$LAqO9UEIq3iw6Sy-vUuVrgTljflP-29TPvYMU0jAXhw","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg"],0],["$Z4OgakmTaafIqlBWEb396m2uXjatDqXeJKsX316jsW0","m.room.member","@b:x","@b:x",{"membership":"join"},["$h2jfmmDoI1pS5vaYstx-zAWreO6bPsTKnXLWc9g5Xao","$LAqO9UEIq3iw6Sy-vUuVrgTljflP-29TPvYMU0jAXhw"],5],["$Bi-BqJNh3rL7LPqhqoAY4tRIeCDV-bbPoilEh1bbQ5o","m.room.member","@c:x","@c:x",{"membership":"join"},["$h2jfmmDoI1pS5vaYstx-zAWreO6bPsTKnXLWc9g5Xao","$LAqO9UEIq3iw6Sy-vUuVrgTljflP-29TPvYMU0jAXhw"],5],["$v863bJiD1pceZbGpB6-Qll91RjHjVJW29-QISfk4eus","m.room.member","@d:x","@d:x",{"membership":"join"},["$h2jfmmDoI1pS5vaYstx-zAWreO6bPsTKnXLWc9g5Xao","$LAqO9UEIq3iw6Sy-vUuVrgTljflP-29TPvYMU0jAXhw"],0],["$zg1K9zK3WPho_vrq8DlybTZafwgoDovnC7ebYSNex-A","m.room.topic","","@a:x",{"topic":"base"},["$LAqO9UEIq3iw6Sy-vUuVrgTljflP-29TPvYMU0jAXhw","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg"],5],["$jnetcP4GhnVAT45Glem7475f90fRFj058cVJwS1R1h8","m.room.name","","@a:x",{"name":"base"},["$LAqO9UEIq3iw6Sy-vUuVrgTljflP-29TPvYMU0jAXhw","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg"],1],["$bXRh9eWZ4UVcRO6RINDfhSmTofZQXqUC7STUwch7W-k","m.room.join_rules","","@a:x",{"join_rule":"public"},["$LAqO9UEIq3iw6Sy-vUuVrgTljflP-29TPvYMU0jAXhw","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg"],5],["$CFEeFY8aPTBGsDaCaI5cCEyg2AxwTV92d-S1JisQ2NA","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$LAqO9UEIq3iw6Sy-vUuVrgTljflP-29TPvYMU0jAXhw","$v863bJiD1pceZbGpB6-Qll91RjHjVJW29-QISfk4eus"],1],["$LvQQpt4FrFT1ZGgiOhlgreLC1Sff574IYQ-3nLXPWf4","m.room.member","@d:x","@d:x",{"membership":"leave"},["$LAqO9UEIq3iw6Sy-vUuVrgTljflP-29TPvYMU0jAXhw","$v863bJiD1pceZbGpB6-Qll91RjHjVJW29-QISfk4eus"],5]],"states":[["$Bi-BqJNh3rL7LPqhqoAY4tRIeCDV-bbPoilEh1bbQ5o","$CFEeFY8aPTBGsDaCaI5cCEyg2AxwTV92d-S1JisQ2NA","$LAqO9UEIq3iw6Sy-vUuVrgTljflP-29TPvYMU0jAXhw","$LvQQpt4FrFT1ZGgiOhlgreLC1Sff574IYQ-3nLXPWf4","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Z4OgakmTaafIqlBWEb396m2uXjatDqXeJKsX316jsW0","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg","$jnetcP4GhnVAT45Glem7475f90fRFj058cVJwS1R1h8","$zg1K9zK3WPho_vrq8DlybTZafwgoDovnC7ebYSNex-A"],["$Bi-BqJNh3rL7LPqhqoAY4tRIeCDV-bbPoilEh1bbQ5o","$LAqO9UEIq3iw6Sy-vUuVrgTljflP-29TPvYMU0jAXhw","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Z4OgakmTaafIqlBWEb396m2uXjatDqXeJKsX316jsW0","$bXRh9eWZ4UVcRO6RINDfhSmTofZQXqUC7STUwch7W-k","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg","$jnetcP4GhnVAT45Glem7475f90fRFj058cVJwS1R1h8","$v863bJiD1pceZbGpB6-Qll91RjHjVJW29-QISfk4eus","$zg1K9zK3WPho_vrq8DlybTZafwgoDovnC7ebYSNex-A"]],"resolved":["$Bi-BqJNh3rL7LPqhqoAY4tRIeCDV-bbPoilEh1bbQ5o","$CFEeFY8aPTBGsDaCaI5cCEyg2AxwTV92d-S1JisQ2NA","$LAqO9UEIq3iw6Sy-vUuVrgTljflP-29TPvYMU0jAXhw","$LvQQpt4FrFT1ZGgiOhlgreLC1Sff574IYQ-3nLXPWf4","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Z4OgakmTaafIqlBWEb396m2uXjatDqXeJKsX316jsW0","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg","$jnetcP4GhnVAT45Glem7475f90fRFj058cVJwS1R1h8","$zg1K9zK3WPho_vrq8DlybTZafwgoDovnC7ebYSNex-A"]}, +{"name":"ts-332","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$Mcxhoua1Gh84cFjM8TObj_cUy6VzcUXEb-IhrZdTyeI","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@b:x":25,"@c:x":25,"@d:x":25}},["$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],0],["$Tp7QN1jJ79teOEcmv1dWoOnY7gNw3lkWwq-j0ORJl7w","m.room.join_rules","","@a:x",{"join_rule":"public"},["$Mcxhoua1Gh84cFjM8TObj_cUy6VzcUXEb-IhrZdTyeI","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],4],["$YG088-00zSMgQQm7_D7lRS4fMVAegC-_uW9PNLGIsns","m.room.member","@b:x","@b:x",{"membership":"join"},["$Tp7QN1jJ79teOEcmv1dWoOnY7gNw3lkWwq-j0ORJl7w","$Mcxhoua1Gh84cFjM8TObj_cUy6VzcUXEb-IhrZdTyeI"],0],["$HDgAIdkP3XlmKQB19o2mN6kNVRNIIkYcpvB4pB-QZso","m.room.member","@c:x","@c:x",{"membership":"join"},["$Tp7QN1jJ79teOEcmv1dWoOnY7gNw3lkWwq-j0ORJl7w","$Mcxhoua1Gh84cFjM8TObj_cUy6VzcUXEb-IhrZdTyeI"],4],["$B2ZxGV7E0VD5lZJ0AiIh2IodCRQOAfTRlj8Qz_KPaUc","m.room.member","@d:x","@d:x",{"membership":"join"},["$Tp7QN1jJ79teOEcmv1dWoOnY7gNw3lkWwq-j0ORJl7w","$Mcxhoua1Gh84cFjM8TObj_cUy6VzcUXEb-IhrZdTyeI"],0],["$DqSemJLxIHZyrtdqBN8VT1j-QCT9kPmBA8DEQ2iZJ3Q","m.room.topic","","@a:x",{"topic":"base"},["$Mcxhoua1Gh84cFjM8TObj_cUy6VzcUXEb-IhrZdTyeI","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],4],["$iBrpMQ4OirO4yGYNpWk6Put9yP9YMIxocK8r8wqShHY","m.room.name","","@a:x",{"name":"base"},["$Mcxhoua1Gh84cFjM8TObj_cUy6VzcUXEb-IhrZdTyeI","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],1],["$mnFODMf9P4v63QaDTyyCbEkgOGjC0TSimgKTwDCmtrM","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$Tp7QN1jJ79teOEcmv1dWoOnY7gNw3lkWwq-j0ORJl7w","$Mcxhoua1Gh84cFjM8TObj_cUy6VzcUXEb-IhrZdTyeI"],2],["$uwx7z3yxD-6SQIJvsXroE2b9fNnw8sv1VjB1YjMVpaQ","m.room.member","@c:x","@c:x",{"membership":"leave"},["$Mcxhoua1Gh84cFjM8TObj_cUy6VzcUXEb-IhrZdTyeI","$HDgAIdkP3XlmKQB19o2mN6kNVRNIIkYcpvB4pB-QZso"],4]],"states":[["$B2ZxGV7E0VD5lZJ0AiIh2IodCRQOAfTRlj8Qz_KPaUc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$DqSemJLxIHZyrtdqBN8VT1j-QCT9kPmBA8DEQ2iZJ3Q","$HDgAIdkP3XlmKQB19o2mN6kNVRNIIkYcpvB4pB-QZso","$Mcxhoua1Gh84cFjM8TObj_cUy6VzcUXEb-IhrZdTyeI","$Tp7QN1jJ79teOEcmv1dWoOnY7gNw3lkWwq-j0ORJl7w","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","$YG088-00zSMgQQm7_D7lRS4fMVAegC-_uW9PNLGIsns","$iBrpMQ4OirO4yGYNpWk6Put9yP9YMIxocK8r8wqShHY","$mnFODMf9P4v63QaDTyyCbEkgOGjC0TSimgKTwDCmtrM"],["$B2ZxGV7E0VD5lZJ0AiIh2IodCRQOAfTRlj8Qz_KPaUc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$DqSemJLxIHZyrtdqBN8VT1j-QCT9kPmBA8DEQ2iZJ3Q","$Mcxhoua1Gh84cFjM8TObj_cUy6VzcUXEb-IhrZdTyeI","$Tp7QN1jJ79teOEcmv1dWoOnY7gNw3lkWwq-j0ORJl7w","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","$YG088-00zSMgQQm7_D7lRS4fMVAegC-_uW9PNLGIsns","$iBrpMQ4OirO4yGYNpWk6Put9yP9YMIxocK8r8wqShHY","$mnFODMf9P4v63QaDTyyCbEkgOGjC0TSimgKTwDCmtrM","$uwx7z3yxD-6SQIJvsXroE2b9fNnw8sv1VjB1YjMVpaQ"]],"resolved":["$B2ZxGV7E0VD5lZJ0AiIh2IodCRQOAfTRlj8Qz_KPaUc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$DqSemJLxIHZyrtdqBN8VT1j-QCT9kPmBA8DEQ2iZJ3Q","$Mcxhoua1Gh84cFjM8TObj_cUy6VzcUXEb-IhrZdTyeI","$Tp7QN1jJ79teOEcmv1dWoOnY7gNw3lkWwq-j0ORJl7w","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","$YG088-00zSMgQQm7_D7lRS4fMVAegC-_uW9PNLGIsns","$iBrpMQ4OirO4yGYNpWk6Put9yP9YMIxocK8r8wqShHY","$mnFODMf9P4v63QaDTyyCbEkgOGjC0TSimgKTwDCmtrM","$uwx7z3yxD-6SQIJvsXroE2b9fNnw8sv1VjB1YjMVpaQ"]}, +{"name":"ts-401","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$wp3azTVVP7JuWGZW4N1Qib2TqtQMveGm_3oPzxkkDY0","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":50},"kick":50,"state_default":0,"users":{"@b:x":0,"@c:x":25,"@d:x":25}},["$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],0],["$_-nS38gE97rAhA5-2yZvLXMHkiUKlQb2upXRfvryWmg","m.room.join_rules","","@a:x",{"join_rule":"public"},["$wp3azTVVP7JuWGZW4N1Qib2TqtQMveGm_3oPzxkkDY0","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],4],["$vrjWOnyWYZXkZjzl9nKETiN4ZS32tx-HHUwXjj09J08","m.room.member","@b:x","@b:x",{"membership":"join"},["$_-nS38gE97rAhA5-2yZvLXMHkiUKlQb2upXRfvryWmg","$wp3azTVVP7JuWGZW4N1Qib2TqtQMveGm_3oPzxkkDY0"],5],["$j1rA1S4I2uvQuMsIHkUx1RlbqcJIK1kCCCJHdr25EvA","m.room.member","@c:x","@c:x",{"membership":"join"},["$_-nS38gE97rAhA5-2yZvLXMHkiUKlQb2upXRfvryWmg","$wp3azTVVP7JuWGZW4N1Qib2TqtQMveGm_3oPzxkkDY0"],4],["$Ppx7rO0OQm3LibehhreJPiwyrPvft5Rq5kNP-cDkHIU","m.room.member","@d:x","@d:x",{"membership":"join"},["$_-nS38gE97rAhA5-2yZvLXMHkiUKlQb2upXRfvryWmg","$wp3azTVVP7JuWGZW4N1Qib2TqtQMveGm_3oPzxkkDY0"],2],["$CGsfloLi5ztd_HS3zv8t3H3bEq1tWjGuteeZLVZ3ZT8","m.room.topic","","@a:x",{"topic":"base"},["$wp3azTVVP7JuWGZW4N1Qib2TqtQMveGm_3oPzxkkDY0","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],1],["$-0tzOs3t27E6qIoP0-rtCjHi1g2cbokuxU1EGgXC41s","m.room.name","","@a:x",{"name":"base"},["$wp3azTVVP7JuWGZW4N1Qib2TqtQMveGm_3oPzxkkDY0","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],4],["$PHCbqvDEFSi9TwnuCKU14dv6WT_iUyBUHvACzjLhy-E","m.room.member","@b:x","@b:x",{"membership":"leave"},["$wp3azTVVP7JuWGZW4N1Qib2TqtQMveGm_3oPzxkkDY0","$vrjWOnyWYZXkZjzl9nKETiN4ZS32tx-HHUwXjj09J08"],0],["$FryGG95RQWvz1aBijri_BklmPaD5W3AMD7i4j-Iqvj0","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$_-nS38gE97rAhA5-2yZvLXMHkiUKlQb2upXRfvryWmg","$wp3azTVVP7JuWGZW4N1Qib2TqtQMveGm_3oPzxkkDY0"],4],["$e-DFgxc2IxLXrxD_MjVifH7B4XM_OUaJfCeNkBWCZjc","m.room.member","@c:x","@c:x",{"membership":"leave"},["$wp3azTVVP7JuWGZW4N1Qib2TqtQMveGm_3oPzxkkDY0","$j1rA1S4I2uvQuMsIHkUx1RlbqcJIK1kCCCJHdr25EvA"],5]],"states":[["$-0tzOs3t27E6qIoP0-rtCjHi1g2cbokuxU1EGgXC41s","$CGsfloLi5ztd_HS3zv8t3H3bEq1tWjGuteeZLVZ3ZT8","$FryGG95RQWvz1aBijri_BklmPaD5W3AMD7i4j-Iqvj0","$PHCbqvDEFSi9TwnuCKU14dv6WT_iUyBUHvACzjLhy-E","$Ppx7rO0OQm3LibehhreJPiwyrPvft5Rq5kNP-cDkHIU","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$_-nS38gE97rAhA5-2yZvLXMHkiUKlQb2upXRfvryWmg","$e-DFgxc2IxLXrxD_MjVifH7B4XM_OUaJfCeNkBWCZjc","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo","$wp3azTVVP7JuWGZW4N1Qib2TqtQMveGm_3oPzxkkDY0"],["$-0tzOs3t27E6qIoP0-rtCjHi1g2cbokuxU1EGgXC41s","$CGsfloLi5ztd_HS3zv8t3H3bEq1tWjGuteeZLVZ3ZT8","$Ppx7rO0OQm3LibehhreJPiwyrPvft5Rq5kNP-cDkHIU","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$_-nS38gE97rAhA5-2yZvLXMHkiUKlQb2upXRfvryWmg","$j1rA1S4I2uvQuMsIHkUx1RlbqcJIK1kCCCJHdr25EvA","$vrjWOnyWYZXkZjzl9nKETiN4ZS32tx-HHUwXjj09J08","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo","$wp3azTVVP7JuWGZW4N1Qib2TqtQMveGm_3oPzxkkDY0"]],"resolved":["$-0tzOs3t27E6qIoP0-rtCjHi1g2cbokuxU1EGgXC41s","$CGsfloLi5ztd_HS3zv8t3H3bEq1tWjGuteeZLVZ3ZT8","$FryGG95RQWvz1aBijri_BklmPaD5W3AMD7i4j-Iqvj0","$Ppx7rO0OQm3LibehhreJPiwyrPvft5Rq5kNP-cDkHIU","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$_-nS38gE97rAhA5-2yZvLXMHkiUKlQb2upXRfvryWmg","$e-DFgxc2IxLXrxD_MjVifH7B4XM_OUaJfCeNkBWCZjc","$vrjWOnyWYZXkZjzl9nKETiN4ZS32tx-HHUwXjj09J08","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo","$wp3azTVVP7JuWGZW4N1Qib2TqtQMveGm_3oPzxkkDY0"]}, +{"name":"ts-457","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$bbJwlqNZElf1RhzuS0WdhI6C6dYN4HrA3ECbCWqTsRg","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@b:x":75,"@c:x":25,"@d:x":75}},["$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg"],0],["$dnMv8oxhEqvcLszQM386L-QirSetHNm1runOrUzedqI","m.room.join_rules","","@a:x",{"join_rule":"public"},["$bbJwlqNZElf1RhzuS0WdhI6C6dYN4HrA3ECbCWqTsRg","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg"],3],["$J6g0vo3rq19JWdrBByspE9v0H8GT3O4EQZKP9D1CTq8","m.room.member","@b:x","@b:x",{"membership":"join"},["$dnMv8oxhEqvcLszQM386L-QirSetHNm1runOrUzedqI","$bbJwlqNZElf1RhzuS0WdhI6C6dYN4HrA3ECbCWqTsRg"],5],["$kHj6heGJglI-0Fyi6nHXLih1YHZPN5_EIv5dvl2y4pY","m.room.member","@c:x","@c:x",{"membership":"join"},["$dnMv8oxhEqvcLszQM386L-QirSetHNm1runOrUzedqI","$bbJwlqNZElf1RhzuS0WdhI6C6dYN4HrA3ECbCWqTsRg"],5],["$KdIXNl2r1-0Wzfo-HvbD5X-divNsGMeOF0ypS-A07x8","m.room.member","@d:x","@d:x",{"membership":"join"},["$dnMv8oxhEqvcLszQM386L-QirSetHNm1runOrUzedqI","$bbJwlqNZElf1RhzuS0WdhI6C6dYN4HrA3ECbCWqTsRg"],5],["$zCumNDiZSIVzKXJ7O5lysbvuJ8x5OCqFyd-RAH3rVNM","m.room.name","","@a:x",{"name":"base"},["$bbJwlqNZElf1RhzuS0WdhI6C6dYN4HrA3ECbCWqTsRg","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg"],4],["$jfRpblts3qqy8SPTX6lO4loXL6CR-Oc_1_vojh4Dv2I","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$dnMv8oxhEqvcLszQM386L-QirSetHNm1runOrUzedqI","$bbJwlqNZElf1RhzuS0WdhI6C6dYN4HrA3ECbCWqTsRg"],5],["$2rFL4yavat-b0kpno0bN2_5-w-1KGP-73Eb1GTAwtks","m.room.member","@e:x","@a:x",{"membership":"ban"},["$bbJwlqNZElf1RhzuS0WdhI6C6dYN4HrA3ECbCWqTsRg","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg","$jfRpblts3qqy8SPTX6lO4loXL6CR-Oc_1_vojh4Dv2I"],2],["$3GpH7QqR_pW0ceC-e51ejLTOP5q2XQYDn5zs6BKRMqY","m.room.topic","","@b:x",{"topic":"1"},["$bbJwlqNZElf1RhzuS0WdhI6C6dYN4HrA3ECbCWqTsRg","$J6g0vo3rq19JWdrBByspE9v0H8GT3O4EQZKP9D1CTq8"],3],["$3niQ9Eg5YtbngtdvTPtZYE__FnAzIUVNmoPZG-rnUo0","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$bbJwlqNZElf1RhzuS0WdhI6C6dYN4HrA3ECbCWqTsRg","$KdIXNl2r1-0Wzfo-HvbD5X-divNsGMeOF0ypS-A07x8"],0],["$j9sNXMpVdlRON35sUucZfh_prkDg340p4zTUZsZFdYM","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$bbJwlqNZElf1RhzuS0WdhI6C6dYN4HrA3ECbCWqTsRg","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg"],3]],"states":[["$2rFL4yavat-b0kpno0bN2_5-w-1KGP-73Eb1GTAwtks","$3GpH7QqR_pW0ceC-e51ejLTOP5q2XQYDn5zs6BKRMqY","$3niQ9Eg5YtbngtdvTPtZYE__FnAzIUVNmoPZG-rnUo0","$J6g0vo3rq19JWdrBByspE9v0H8GT3O4EQZKP9D1CTq8","$KdIXNl2r1-0Wzfo-HvbD5X-divNsGMeOF0ypS-A07x8","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$bbJwlqNZElf1RhzuS0WdhI6C6dYN4HrA3ECbCWqTsRg","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg","$kHj6heGJglI-0Fyi6nHXLih1YHZPN5_EIv5dvl2y4pY","$zCumNDiZSIVzKXJ7O5lysbvuJ8x5OCqFyd-RAH3rVNM"],["$2rFL4yavat-b0kpno0bN2_5-w-1KGP-73Eb1GTAwtks","$3GpH7QqR_pW0ceC-e51ejLTOP5q2XQYDn5zs6BKRMqY","$J6g0vo3rq19JWdrBByspE9v0H8GT3O4EQZKP9D1CTq8","$KdIXNl2r1-0Wzfo-HvbD5X-divNsGMeOF0ypS-A07x8","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$bbJwlqNZElf1RhzuS0WdhI6C6dYN4HrA3ECbCWqTsRg","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg","$j9sNXMpVdlRON35sUucZfh_prkDg340p4zTUZsZFdYM","$kHj6heGJglI-0Fyi6nHXLih1YHZPN5_EIv5dvl2y4pY","$zCumNDiZSIVzKXJ7O5lysbvuJ8x5OCqFyd-RAH3rVNM"]],"resolved":["$2rFL4yavat-b0kpno0bN2_5-w-1KGP-73Eb1GTAwtks","$3GpH7QqR_pW0ceC-e51ejLTOP5q2XQYDn5zs6BKRMqY","$3niQ9Eg5YtbngtdvTPtZYE__FnAzIUVNmoPZG-rnUo0","$J6g0vo3rq19JWdrBByspE9v0H8GT3O4EQZKP9D1CTq8","$KdIXNl2r1-0Wzfo-HvbD5X-divNsGMeOF0ypS-A07x8","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$bbJwlqNZElf1RhzuS0WdhI6C6dYN4HrA3ECbCWqTsRg","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg","$kHj6heGJglI-0Fyi6nHXLih1YHZPN5_EIv5dvl2y4pY","$zCumNDiZSIVzKXJ7O5lysbvuJ8x5OCqFyd-RAH3rVNM"]}, +{"name":"ts-472","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$uF5QltbsJTjsGuEQP3dVhzcWSS1SLmEAZBs-1b1V7-M","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":50,"m.room.topic":50},"kick":25,"state_default":50,"users":{"@b:x":25,"@c:x":0,"@d:x":0}},["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],4],["$lL_ptF_T4mgg7BZCsYvGZYf_NAwym-mRCteU6p8-LPg","m.room.join_rules","","@a:x",{"join_rule":"public"},["$uF5QltbsJTjsGuEQP3dVhzcWSS1SLmEAZBs-1b1V7-M","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],0],["$8PUghtFmIOcvLF4Mh4mHiTe7s_aczdmbwZ_Mk-DJdlg","m.room.member","@b:x","@b:x",{"membership":"join"},["$lL_ptF_T4mgg7BZCsYvGZYf_NAwym-mRCteU6p8-LPg","$uF5QltbsJTjsGuEQP3dVhzcWSS1SLmEAZBs-1b1V7-M"],4],["$QCH51eREpDCy8NQW9n0SKhAh1MipDxbourmm6HoLO7o","m.room.member","@c:x","@c:x",{"membership":"join"},["$lL_ptF_T4mgg7BZCsYvGZYf_NAwym-mRCteU6p8-LPg","$uF5QltbsJTjsGuEQP3dVhzcWSS1SLmEAZBs-1b1V7-M"],3],["$MTlLl2UK_BuYVJQktUQgdm4VgASDFC74p6IVuvoSjhQ","m.room.member","@d:x","@d:x",{"membership":"join"},["$lL_ptF_T4mgg7BZCsYvGZYf_NAwym-mRCteU6p8-LPg","$uF5QltbsJTjsGuEQP3dVhzcWSS1SLmEAZBs-1b1V7-M"],1],["$8deWzZwAWE2nyO0IsKlmlpZaWct-Ydan4_hRhWhYKzA","m.room.topic","","@a:x",{"topic":"base"},["$uF5QltbsJTjsGuEQP3dVhzcWSS1SLmEAZBs-1b1V7-M","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],1],["$e_XMd0n3LMaRcfESEKEi2uT4EZvY3XsqSipkoppvXGo","m.room.name","","@a:x",{"name":"base"},["$uF5QltbsJTjsGuEQP3dVhzcWSS1SLmEAZBs-1b1V7-M","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],3],["$LQ32NNclaq16LZEUCrJBNLQKZmHsKvV3D4YILVOYH40","m.room.member","@d:x","@d:x",{"membership":"leave"},["$uF5QltbsJTjsGuEQP3dVhzcWSS1SLmEAZBs-1b1V7-M","$MTlLl2UK_BuYVJQktUQgdm4VgASDFC74p6IVuvoSjhQ"],1],["$UyyaDJaMWbf5C-ieZ1c5jRz2jfy0Y6s4iO9QgzLs244","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$lL_ptF_T4mgg7BZCsYvGZYf_NAwym-mRCteU6p8-LPg","$uF5QltbsJTjsGuEQP3dVhzcWSS1SLmEAZBs-1b1V7-M","$QCH51eREpDCy8NQW9n0SKhAh1MipDxbourmm6HoLO7o"],3]],"states":[["$8PUghtFmIOcvLF4Mh4mHiTe7s_aczdmbwZ_Mk-DJdlg","$8deWzZwAWE2nyO0IsKlmlpZaWct-Ydan4_hRhWhYKzA","$LQ32NNclaq16LZEUCrJBNLQKZmHsKvV3D4YILVOYH40","$QCH51eREpDCy8NQW9n0SKhAh1MipDxbourmm6HoLO7o","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$e_XMd0n3LMaRcfESEKEi2uT4EZvY3XsqSipkoppvXGo","$lL_ptF_T4mgg7BZCsYvGZYf_NAwym-mRCteU6p8-LPg","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$uF5QltbsJTjsGuEQP3dVhzcWSS1SLmEAZBs-1b1V7-M"],["$8PUghtFmIOcvLF4Mh4mHiTe7s_aczdmbwZ_Mk-DJdlg","$8deWzZwAWE2nyO0IsKlmlpZaWct-Ydan4_hRhWhYKzA","$MTlLl2UK_BuYVJQktUQgdm4VgASDFC74p6IVuvoSjhQ","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$UyyaDJaMWbf5C-ieZ1c5jRz2jfy0Y6s4iO9QgzLs244","$e_XMd0n3LMaRcfESEKEi2uT4EZvY3XsqSipkoppvXGo","$lL_ptF_T4mgg7BZCsYvGZYf_NAwym-mRCteU6p8-LPg","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$uF5QltbsJTjsGuEQP3dVhzcWSS1SLmEAZBs-1b1V7-M"]],"resolved":["$8PUghtFmIOcvLF4Mh4mHiTe7s_aczdmbwZ_Mk-DJdlg","$8deWzZwAWE2nyO0IsKlmlpZaWct-Ydan4_hRhWhYKzA","$MTlLl2UK_BuYVJQktUQgdm4VgASDFC74p6IVuvoSjhQ","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$UyyaDJaMWbf5C-ieZ1c5jRz2jfy0Y6s4iO9QgzLs244","$e_XMd0n3LMaRcfESEKEi2uT4EZvY3XsqSipkoppvXGo","$lL_ptF_T4mgg7BZCsYvGZYf_NAwym-mRCteU6p8-LPg","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$uF5QltbsJTjsGuEQP3dVhzcWSS1SLmEAZBs-1b1V7-M"]}, +{"name":"ts-479","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$7I80AL4HQZbd9qLDVrWu7UN9HquHLRykzQ3_300Rj98","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":50},"kick":50,"state_default":50,"users":{"@b:x":75,"@c:x":50,"@d:x":75}},["$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],3],["$5r6_Gvt1OpUBcAnVD5wMw8Tc_KbcVnmdncmlZtrKJrI","m.room.join_rules","","@a:x",{"join_rule":"public"},["$7I80AL4HQZbd9qLDVrWu7UN9HquHLRykzQ3_300Rj98","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],4],["$iS6XrV1_yHmeRIxa3ZHEicuYlg5AwqDmSLYUw_jX80M","m.room.member","@b:x","@b:x",{"membership":"join"},["$5r6_Gvt1OpUBcAnVD5wMw8Tc_KbcVnmdncmlZtrKJrI","$7I80AL4HQZbd9qLDVrWu7UN9HquHLRykzQ3_300Rj98"],5],["$4-KRwStjt4F3Y1mlGKgMpoiulL4JU8iIf94JvfXILNo","m.room.member","@c:x","@c:x",{"membership":"join"},["$5r6_Gvt1OpUBcAnVD5wMw8Tc_KbcVnmdncmlZtrKJrI","$7I80AL4HQZbd9qLDVrWu7UN9HquHLRykzQ3_300Rj98"],0],["$qmWfw5elcU_3zE59SynEpSR_JPQuWJ1FzywodCd1eZw","m.room.member","@d:x","@d:x",{"membership":"join"},["$5r6_Gvt1OpUBcAnVD5wMw8Tc_KbcVnmdncmlZtrKJrI","$7I80AL4HQZbd9qLDVrWu7UN9HquHLRykzQ3_300Rj98"],0],["$1HAm6Fohc9novgJ0i_HGz7VtWnsLwPMCqycsJCJWABE","m.room.topic","","@a:x",{"topic":"base"},["$7I80AL4HQZbd9qLDVrWu7UN9HquHLRykzQ3_300Rj98","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],4],["$vt4o_mpVFFGekCwAlKx_xSDR0OhJMc0Xsd8_suu39YE","m.room.name","","@a:x",{"name":"base"},["$7I80AL4HQZbd9qLDVrWu7UN9HquHLRykzQ3_300Rj98","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],5],["$XJFEaozy0l8QFcmfnCysQ41J9RDtuUQCTJs4DSSeloc","m.room.member","@c:x","@c:x",{"membership":"leave"},["$7I80AL4HQZbd9qLDVrWu7UN9HquHLRykzQ3_300Rj98","$4-KRwStjt4F3Y1mlGKgMpoiulL4JU8iIf94JvfXILNo"],0],["$2obl14uZ9SNlL77LTjtvkFXj9ekYz3uR6RZIyCWHnrs","m.room.name","","@a:x",{"name":"0"},["$7I80AL4HQZbd9qLDVrWu7UN9HquHLRykzQ3_300Rj98","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],4]],"states":[["$1HAm6Fohc9novgJ0i_HGz7VtWnsLwPMCqycsJCJWABE","$5r6_Gvt1OpUBcAnVD5wMw8Tc_KbcVnmdncmlZtrKJrI","$7I80AL4HQZbd9qLDVrWu7UN9HquHLRykzQ3_300Rj98","$XJFEaozy0l8QFcmfnCysQ41J9RDtuUQCTJs4DSSeloc","$iS6XrV1_yHmeRIxa3ZHEicuYlg5AwqDmSLYUw_jX80M","$qmWfw5elcU_3zE59SynEpSR_JPQuWJ1FzywodCd1eZw","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$vt4o_mpVFFGekCwAlKx_xSDR0OhJMc0Xsd8_suu39YE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],["$1HAm6Fohc9novgJ0i_HGz7VtWnsLwPMCqycsJCJWABE","$4-KRwStjt4F3Y1mlGKgMpoiulL4JU8iIf94JvfXILNo","$5r6_Gvt1OpUBcAnVD5wMw8Tc_KbcVnmdncmlZtrKJrI","$7I80AL4HQZbd9qLDVrWu7UN9HquHLRykzQ3_300Rj98","$iS6XrV1_yHmeRIxa3ZHEicuYlg5AwqDmSLYUw_jX80M","$qmWfw5elcU_3zE59SynEpSR_JPQuWJ1FzywodCd1eZw","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$vt4o_mpVFFGekCwAlKx_xSDR0OhJMc0Xsd8_suu39YE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],["$1HAm6Fohc9novgJ0i_HGz7VtWnsLwPMCqycsJCJWABE","$2obl14uZ9SNlL77LTjtvkFXj9ekYz3uR6RZIyCWHnrs","$4-KRwStjt4F3Y1mlGKgMpoiulL4JU8iIf94JvfXILNo","$5r6_Gvt1OpUBcAnVD5wMw8Tc_KbcVnmdncmlZtrKJrI","$7I80AL4HQZbd9qLDVrWu7UN9HquHLRykzQ3_300Rj98","$iS6XrV1_yHmeRIxa3ZHEicuYlg5AwqDmSLYUw_jX80M","$qmWfw5elcU_3zE59SynEpSR_JPQuWJ1FzywodCd1eZw","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"]],"resolved":["$1HAm6Fohc9novgJ0i_HGz7VtWnsLwPMCqycsJCJWABE","$5r6_Gvt1OpUBcAnVD5wMw8Tc_KbcVnmdncmlZtrKJrI","$7I80AL4HQZbd9qLDVrWu7UN9HquHLRykzQ3_300Rj98","$XJFEaozy0l8QFcmfnCysQ41J9RDtuUQCTJs4DSSeloc","$iS6XrV1_yHmeRIxa3ZHEicuYlg5AwqDmSLYUw_jX80M","$qmWfw5elcU_3zE59SynEpSR_JPQuWJ1FzywodCd1eZw","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$vt4o_mpVFFGekCwAlKx_xSDR0OhJMc0Xsd8_suu39YE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"]}, +{"name":"ts-542","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$L_hmiTzZY3Zmb6qg2oohcuGcexE_RllvfRzqDue0ICY","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":25},"kick":25,"state_default":0,"users":{"@b:x":75,"@c:x":75,"@d:x":25}},["$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY"],3],["$hKsipmgTVfVp-X15kBBDNCM6H7enx39N2ySDZxUZ2jo","m.room.join_rules","","@a:x",{"join_rule":"public"},["$L_hmiTzZY3Zmb6qg2oohcuGcexE_RllvfRzqDue0ICY","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY"],1],["$DMTcV1D1wfyUXnF0Rw9WnEarhRsBAHzcwUEHmFJy5pA","m.room.member","@b:x","@b:x",{"membership":"join"},["$hKsipmgTVfVp-X15kBBDNCM6H7enx39N2ySDZxUZ2jo","$L_hmiTzZY3Zmb6qg2oohcuGcexE_RllvfRzqDue0ICY"],2],["$LTiXQ6IR42icyVkxyCOjaKDPFAHALTvOao4_idX5PM0","m.room.member","@c:x","@c:x",{"membership":"join"},["$hKsipmgTVfVp-X15kBBDNCM6H7enx39N2ySDZxUZ2jo","$L_hmiTzZY3Zmb6qg2oohcuGcexE_RllvfRzqDue0ICY"],2],["$YdNDXtQjKj1Ia_BVXp96PfB5EHp6EzedhAK1IZp4jU4","m.room.member","@d:x","@d:x",{"membership":"join"},["$hKsipmgTVfVp-X15kBBDNCM6H7enx39N2ySDZxUZ2jo","$L_hmiTzZY3Zmb6qg2oohcuGcexE_RllvfRzqDue0ICY"],0],["$wxtayN3-HcDEA_1AzvC5cULWFI3s7ilgOQPGhxX0JkM","m.room.name","","@a:x",{"name":"base"},["$L_hmiTzZY3Zmb6qg2oohcuGcexE_RllvfRzqDue0ICY","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY"],0],["$7sf-X-3nX2Outj-2LlSTXUBY31M6QLie5FeeF4NO5-s","m.room.topic","","@a:x",{"topic":"0"},["$L_hmiTzZY3Zmb6qg2oohcuGcexE_RllvfRzqDue0ICY","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY"],5],["$hzYawarpIGauqVL45pg9lLMY9eaba8O89WuaqqDFqU0","m.room.topic","","@d:x",{"topic":"1"},["$L_hmiTzZY3Zmb6qg2oohcuGcexE_RllvfRzqDue0ICY","$YdNDXtQjKj1Ia_BVXp96PfB5EHp6EzedhAK1IZp4jU4"],0],["$bB-dIFIu8rH06Z7GCGSxjDr5TPDv1CCXse7nC4Gz_Zg","m.room.member","@e:x","@b:x",{"membership":"invite"},["$hKsipmgTVfVp-X15kBBDNCM6H7enx39N2ySDZxUZ2jo","$L_hmiTzZY3Zmb6qg2oohcuGcexE_RllvfRzqDue0ICY","$DMTcV1D1wfyUXnF0Rw9WnEarhRsBAHzcwUEHmFJy5pA"],5]],"states":[["$DMTcV1D1wfyUXnF0Rw9WnEarhRsBAHzcwUEHmFJy5pA","$LTiXQ6IR42icyVkxyCOjaKDPFAHALTvOao4_idX5PM0","$L_hmiTzZY3Zmb6qg2oohcuGcexE_RllvfRzqDue0ICY","$YdNDXtQjKj1Ia_BVXp96PfB5EHp6EzedhAK1IZp4jU4","$hKsipmgTVfVp-X15kBBDNCM6H7enx39N2ySDZxUZ2jo","$hzYawarpIGauqVL45pg9lLMY9eaba8O89WuaqqDFqU0","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$wxtayN3-HcDEA_1AzvC5cULWFI3s7ilgOQPGhxX0JkM"],["$7sf-X-3nX2Outj-2LlSTXUBY31M6QLie5FeeF4NO5-s","$DMTcV1D1wfyUXnF0Rw9WnEarhRsBAHzcwUEHmFJy5pA","$LTiXQ6IR42icyVkxyCOjaKDPFAHALTvOao4_idX5PM0","$L_hmiTzZY3Zmb6qg2oohcuGcexE_RllvfRzqDue0ICY","$YdNDXtQjKj1Ia_BVXp96PfB5EHp6EzedhAK1IZp4jU4","$bB-dIFIu8rH06Z7GCGSxjDr5TPDv1CCXse7nC4Gz_Zg","$hKsipmgTVfVp-X15kBBDNCM6H7enx39N2ySDZxUZ2jo","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$wxtayN3-HcDEA_1AzvC5cULWFI3s7ilgOQPGhxX0JkM"]],"resolved":["$7sf-X-3nX2Outj-2LlSTXUBY31M6QLie5FeeF4NO5-s","$DMTcV1D1wfyUXnF0Rw9WnEarhRsBAHzcwUEHmFJy5pA","$LTiXQ6IR42icyVkxyCOjaKDPFAHALTvOao4_idX5PM0","$L_hmiTzZY3Zmb6qg2oohcuGcexE_RllvfRzqDue0ICY","$YdNDXtQjKj1Ia_BVXp96PfB5EHp6EzedhAK1IZp4jU4","$bB-dIFIu8rH06Z7GCGSxjDr5TPDv1CCXse7nC4Gz_Zg","$hKsipmgTVfVp-X15kBBDNCM6H7enx39N2ySDZxUZ2jo","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$wxtayN3-HcDEA_1AzvC5cULWFI3s7ilgOQPGhxX0JkM"]}, +{"name":"ts-556","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$-_baxwjOfKmeo3Ga5rdn9r6X7GPoBMbr9AAg_eITjVo","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":25,"m.room.power_levels":50,"m.room.topic":25},"kick":75,"state_default":0,"users":{"@b:x":25,"@c:x":50,"@d:x":0}},["$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],0],["$DF1MHwLLEJs7KVPFSIE6DF9T2JrVRvgzsd7jT9DI8uk","m.room.join_rules","","@a:x",{"join_rule":"public"},["$-_baxwjOfKmeo3Ga5rdn9r6X7GPoBMbr9AAg_eITjVo","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],3],["$YWjVnK8WJxCeLaWD6GT9VurUFjYn14D47WrWuV3BAVQ","m.room.member","@b:x","@b:x",{"membership":"join"},["$DF1MHwLLEJs7KVPFSIE6DF9T2JrVRvgzsd7jT9DI8uk","$-_baxwjOfKmeo3Ga5rdn9r6X7GPoBMbr9AAg_eITjVo"],5],["$o3ncdTKyVXVfqUcR8_k5qFxtp4yV8tye3rV3ZqFe5SU","m.room.member","@c:x","@c:x",{"membership":"join"},["$DF1MHwLLEJs7KVPFSIE6DF9T2JrVRvgzsd7jT9DI8uk","$-_baxwjOfKmeo3Ga5rdn9r6X7GPoBMbr9AAg_eITjVo"],5],["$oNRo-YoQS1QHfGuvdM2D8pXQ5eV6oe9qlIZaAORgMY8","m.room.member","@d:x","@d:x",{"membership":"join"},["$DF1MHwLLEJs7KVPFSIE6DF9T2JrVRvgzsd7jT9DI8uk","$-_baxwjOfKmeo3Ga5rdn9r6X7GPoBMbr9AAg_eITjVo"],1],["$HzpD5OAT_4xnuUKQxvyLUM0apwhyFqxEVEa07cjGyF4","m.room.topic","","@a:x",{"topic":"base"},["$-_baxwjOfKmeo3Ga5rdn9r6X7GPoBMbr9AAg_eITjVo","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],3],["$VHknM9QH9aj5lL5ve8x5CmJB-rBN7BVqghrH1Z-GBzo","m.room.name","","@a:x",{"name":"base"},["$-_baxwjOfKmeo3Ga5rdn9r6X7GPoBMbr9AAg_eITjVo","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],5],["$fRXfAP1mR8dn3kthuxrcBllxXPVqorFq8-DFhvAc7F8","m.room.join_rules","","@a:x",{"join_rule":"public"},["$-_baxwjOfKmeo3Ga5rdn9r6X7GPoBMbr9AAg_eITjVo","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],0],["$WkyX1l-SLw_axKoxCqRPexWVkJNnwiD04kBH3GLnTk4","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$fRXfAP1mR8dn3kthuxrcBllxXPVqorFq8-DFhvAc7F8","$-_baxwjOfKmeo3Ga5rdn9r6X7GPoBMbr9AAg_eITjVo"],5]],"states":[["$-_baxwjOfKmeo3Ga5rdn9r6X7GPoBMbr9AAg_eITjVo","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","$HzpD5OAT_4xnuUKQxvyLUM0apwhyFqxEVEa07cjGyF4","$VHknM9QH9aj5lL5ve8x5CmJB-rBN7BVqghrH1Z-GBzo","$WkyX1l-SLw_axKoxCqRPexWVkJNnwiD04kBH3GLnTk4","$YWjVnK8WJxCeLaWD6GT9VurUFjYn14D47WrWuV3BAVQ","$fRXfAP1mR8dn3kthuxrcBllxXPVqorFq8-DFhvAc7F8","$o3ncdTKyVXVfqUcR8_k5qFxtp4yV8tye3rV3ZqFe5SU","$oNRo-YoQS1QHfGuvdM2D8pXQ5eV6oe9qlIZaAORgMY8","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"],["$-_baxwjOfKmeo3Ga5rdn9r6X7GPoBMbr9AAg_eITjVo","$DF1MHwLLEJs7KVPFSIE6DF9T2JrVRvgzsd7jT9DI8uk","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","$HzpD5OAT_4xnuUKQxvyLUM0apwhyFqxEVEa07cjGyF4","$VHknM9QH9aj5lL5ve8x5CmJB-rBN7BVqghrH1Z-GBzo","$YWjVnK8WJxCeLaWD6GT9VurUFjYn14D47WrWuV3BAVQ","$o3ncdTKyVXVfqUcR8_k5qFxtp4yV8tye3rV3ZqFe5SU","$oNRo-YoQS1QHfGuvdM2D8pXQ5eV6oe9qlIZaAORgMY8","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"]],"resolved":["$-_baxwjOfKmeo3Ga5rdn9r6X7GPoBMbr9AAg_eITjVo","$DF1MHwLLEJs7KVPFSIE6DF9T2JrVRvgzsd7jT9DI8uk","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","$HzpD5OAT_4xnuUKQxvyLUM0apwhyFqxEVEa07cjGyF4","$VHknM9QH9aj5lL5ve8x5CmJB-rBN7BVqghrH1Z-GBzo","$WkyX1l-SLw_axKoxCqRPexWVkJNnwiD04kBH3GLnTk4","$YWjVnK8WJxCeLaWD6GT9VurUFjYn14D47WrWuV3BAVQ","$o3ncdTKyVXVfqUcR8_k5qFxtp4yV8tye3rV3ZqFe5SU","$oNRo-YoQS1QHfGuvdM2D8pXQ5eV6oe9qlIZaAORgMY8","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"]}, +{"name":"ts-566","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$3OR_P8oWuTssOqOlENCo3__ohlyRTh20YNpptvijvBg","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":25},"kick":50,"state_default":50,"users":{"@b:x":25,"@c:x":0,"@d:x":75}},["$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY"],3],["$-1j2tUj8lL_pGAWPSbOZqC_xQo_k1nWrR9HEEJ2LoBc","m.room.join_rules","","@a:x",{"join_rule":"public"},["$3OR_P8oWuTssOqOlENCo3__ohlyRTh20YNpptvijvBg","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY"],3],["$vpgpWCKH2GggWnh201_luO50YIJELFoJZgqlLnoGG8s","m.room.member","@b:x","@b:x",{"membership":"join"},["$-1j2tUj8lL_pGAWPSbOZqC_xQo_k1nWrR9HEEJ2LoBc","$3OR_P8oWuTssOqOlENCo3__ohlyRTh20YNpptvijvBg"],3],["$RR4B-exSp4LHBlPV0_STDIMmP8dWkDAWf3_tD6VXsZU","m.room.member","@c:x","@c:x",{"membership":"join"},["$-1j2tUj8lL_pGAWPSbOZqC_xQo_k1nWrR9HEEJ2LoBc","$3OR_P8oWuTssOqOlENCo3__ohlyRTh20YNpptvijvBg"],4],["$Fh1jP02kGbcum0IlIrSjodgiTBtMbJb4pE1QpXU5yN8","m.room.member","@d:x","@d:x",{"membership":"join"},["$-1j2tUj8lL_pGAWPSbOZqC_xQo_k1nWrR9HEEJ2LoBc","$3OR_P8oWuTssOqOlENCo3__ohlyRTh20YNpptvijvBg"],3],["$syACUv8eK3s52omoagV-ztCz0249jcnd0qC8X93TvVU","m.room.topic","","@d:x",{"topic":"0"},["$3OR_P8oWuTssOqOlENCo3__ohlyRTh20YNpptvijvBg","$Fh1jP02kGbcum0IlIrSjodgiTBtMbJb4pE1QpXU5yN8"],1],["$FCw2AGz4_uN6LeF179svcOGZN1sSM-IJIoF-1P1EP8A","m.room.member","@d:x","@d:x",{"membership":"leave"},["$3OR_P8oWuTssOqOlENCo3__ohlyRTh20YNpptvijvBg","$Fh1jP02kGbcum0IlIrSjodgiTBtMbJb4pE1QpXU5yN8"],4],["$CdS_ksqYHNKCucUC0Po1fKLknk3kR4PcPe0xNK7ECzA","m.room.name","","@a:x",{"name":"1"},["$3OR_P8oWuTssOqOlENCo3__ohlyRTh20YNpptvijvBg","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY"],5],["$r8BUYTEc2pT_Ykwvm3IF-acdieeJNR6hp28dZyQp_ac","m.room.join_rules","","@a:x",{"join_rule":"public"},["$3OR_P8oWuTssOqOlENCo3__ohlyRTh20YNpptvijvBg","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY"],5]],"states":[["$3OR_P8oWuTssOqOlENCo3__ohlyRTh20YNpptvijvBg","$CdS_ksqYHNKCucUC0Po1fKLknk3kR4PcPe0xNK7ECzA","$FCw2AGz4_uN6LeF179svcOGZN1sSM-IJIoF-1P1EP8A","$RR4B-exSp4LHBlPV0_STDIMmP8dWkDAWf3_tD6VXsZU","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY","$r8BUYTEc2pT_Ykwvm3IF-acdieeJNR6hp28dZyQp_ac","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$syACUv8eK3s52omoagV-ztCz0249jcnd0qC8X93TvVU","$vpgpWCKH2GggWnh201_luO50YIJELFoJZgqlLnoGG8s"],["$-1j2tUj8lL_pGAWPSbOZqC_xQo_k1nWrR9HEEJ2LoBc","$3OR_P8oWuTssOqOlENCo3__ohlyRTh20YNpptvijvBg","$CdS_ksqYHNKCucUC0Po1fKLknk3kR4PcPe0xNK7ECzA","$FCw2AGz4_uN6LeF179svcOGZN1sSM-IJIoF-1P1EP8A","$RR4B-exSp4LHBlPV0_STDIMmP8dWkDAWf3_tD6VXsZU","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$syACUv8eK3s52omoagV-ztCz0249jcnd0qC8X93TvVU","$vpgpWCKH2GggWnh201_luO50YIJELFoJZgqlLnoGG8s"],["$-1j2tUj8lL_pGAWPSbOZqC_xQo_k1nWrR9HEEJ2LoBc","$3OR_P8oWuTssOqOlENCo3__ohlyRTh20YNpptvijvBg","$CdS_ksqYHNKCucUC0Po1fKLknk3kR4PcPe0xNK7ECzA","$FCw2AGz4_uN6LeF179svcOGZN1sSM-IJIoF-1P1EP8A","$RR4B-exSp4LHBlPV0_STDIMmP8dWkDAWf3_tD6VXsZU","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$syACUv8eK3s52omoagV-ztCz0249jcnd0qC8X93TvVU","$vpgpWCKH2GggWnh201_luO50YIJELFoJZgqlLnoGG8s"]],"resolved":["$3OR_P8oWuTssOqOlENCo3__ohlyRTh20YNpptvijvBg","$CdS_ksqYHNKCucUC0Po1fKLknk3kR4PcPe0xNK7ECzA","$FCw2AGz4_uN6LeF179svcOGZN1sSM-IJIoF-1P1EP8A","$RR4B-exSp4LHBlPV0_STDIMmP8dWkDAWf3_tD6VXsZU","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY","$r8BUYTEc2pT_Ykwvm3IF-acdieeJNR6hp28dZyQp_ac","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$syACUv8eK3s52omoagV-ztCz0249jcnd0qC8X93TvVU","$vpgpWCKH2GggWnh201_luO50YIJELFoJZgqlLnoGG8s"]}, +{"name":"ts-581","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$FMzaIc925JFJ2QC3bg0wnr1zNQ8W6-m4oV_gnlT4y7M","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":25},"kick":75,"state_default":50,"users":{"@b:x":75,"@c:x":25,"@d:x":75}},["$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],5],["$pN1m0QB24Cv-yIh9PUns-taN39X_5qKDVrnx9PLe6GE","m.room.join_rules","","@a:x",{"join_rule":"public"},["$FMzaIc925JFJ2QC3bg0wnr1zNQ8W6-m4oV_gnlT4y7M","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],0],["$PH4om8JNUn5kNAWkTXYT7PrDs_-l7AC_rWeG0Xd8ddg","m.room.member","@b:x","@b:x",{"membership":"join"},["$pN1m0QB24Cv-yIh9PUns-taN39X_5qKDVrnx9PLe6GE","$FMzaIc925JFJ2QC3bg0wnr1zNQ8W6-m4oV_gnlT4y7M"],1],["$TlpdpmY5j76w4VtkQjrn3W6mklUXwlMXSnMO2wQ3S1U","m.room.member","@c:x","@c:x",{"membership":"join"},["$pN1m0QB24Cv-yIh9PUns-taN39X_5qKDVrnx9PLe6GE","$FMzaIc925JFJ2QC3bg0wnr1zNQ8W6-m4oV_gnlT4y7M"],5],["$qWO-_b92SkB332dbQSvk2vb16m-ArUlfJI5YXJ9mj-w","m.room.member","@d:x","@d:x",{"membership":"join"},["$pN1m0QB24Cv-yIh9PUns-taN39X_5qKDVrnx9PLe6GE","$FMzaIc925JFJ2QC3bg0wnr1zNQ8W6-m4oV_gnlT4y7M"],4],["$Qsys5vjJ-lntaS6JoNd6CpSmav16dnns7isVlFHdlwk","m.room.topic","","@a:x",{"topic":"base"},["$FMzaIc925JFJ2QC3bg0wnr1zNQ8W6-m4oV_gnlT4y7M","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],4],["$jepUa_aafWPHt0NC7nY1VwAOjrdRGksnYqRo7-c9bro","m.room.name","","@a:x",{"name":"base"},["$FMzaIc925JFJ2QC3bg0wnr1zNQ8W6-m4oV_gnlT4y7M","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],1],["$p8JvNKqcMJyBvWNU1FDkiLDTRlXy5rsdbuFHnb2B8C8","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$pN1m0QB24Cv-yIh9PUns-taN39X_5qKDVrnx9PLe6GE","$FMzaIc925JFJ2QC3bg0wnr1zNQ8W6-m4oV_gnlT4y7M"],1],["$Uf41ulDQI4W52CEC-Cwr8ekBlp1s7saukcwiPaUF81w","m.room.topic","","@b:x",{"topic":"1"},["$FMzaIc925JFJ2QC3bg0wnr1zNQ8W6-m4oV_gnlT4y7M","$PH4om8JNUn5kNAWkTXYT7PrDs_-l7AC_rWeG0Xd8ddg"],5],["$hvf0zh2ooSlcvkuhABJjG2Q0iTQfWj5TdgYsKIoVkj8","m.room.member","@b:x","@b:x",{"membership":"leave"},["$FMzaIc925JFJ2QC3bg0wnr1zNQ8W6-m4oV_gnlT4y7M","$PH4om8JNUn5kNAWkTXYT7PrDs_-l7AC_rWeG0Xd8ddg"],5],["$3z27KuWw_w6Ta2iyBt4A5GbR45vGMsrv_eUpEDI19to","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$FMzaIc925JFJ2QC3bg0wnr1zNQ8W6-m4oV_gnlT4y7M","$qWO-_b92SkB332dbQSvk2vb16m-ArUlfJI5YXJ9mj-w"],5]],"states":[["$FMzaIc925JFJ2QC3bg0wnr1zNQ8W6-m4oV_gnlT4y7M","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","$TlpdpmY5j76w4VtkQjrn3W6mklUXwlMXSnMO2wQ3S1U","$Uf41ulDQI4W52CEC-Cwr8ekBlp1s7saukcwiPaUF81w","$hvf0zh2ooSlcvkuhABJjG2Q0iTQfWj5TdgYsKIoVkj8","$jepUa_aafWPHt0NC7nY1VwAOjrdRGksnYqRo7-c9bro","$p8JvNKqcMJyBvWNU1FDkiLDTRlXy5rsdbuFHnb2B8C8","$pN1m0QB24Cv-yIh9PUns-taN39X_5qKDVrnx9PLe6GE","$qWO-_b92SkB332dbQSvk2vb16m-ArUlfJI5YXJ9mj-w","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"],["$3z27KuWw_w6Ta2iyBt4A5GbR45vGMsrv_eUpEDI19to","$FMzaIc925JFJ2QC3bg0wnr1zNQ8W6-m4oV_gnlT4y7M","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","$PH4om8JNUn5kNAWkTXYT7PrDs_-l7AC_rWeG0Xd8ddg","$Qsys5vjJ-lntaS6JoNd6CpSmav16dnns7isVlFHdlwk","$TlpdpmY5j76w4VtkQjrn3W6mklUXwlMXSnMO2wQ3S1U","$jepUa_aafWPHt0NC7nY1VwAOjrdRGksnYqRo7-c9bro","$p8JvNKqcMJyBvWNU1FDkiLDTRlXy5rsdbuFHnb2B8C8","$qWO-_b92SkB332dbQSvk2vb16m-ArUlfJI5YXJ9mj-w","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"]],"resolved":["$3z27KuWw_w6Ta2iyBt4A5GbR45vGMsrv_eUpEDI19to","$FMzaIc925JFJ2QC3bg0wnr1zNQ8W6-m4oV_gnlT4y7M","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","$TlpdpmY5j76w4VtkQjrn3W6mklUXwlMXSnMO2wQ3S1U","$Uf41ulDQI4W52CEC-Cwr8ekBlp1s7saukcwiPaUF81w","$hvf0zh2ooSlcvkuhABJjG2Q0iTQfWj5TdgYsKIoVkj8","$jepUa_aafWPHt0NC7nY1VwAOjrdRGksnYqRo7-c9bro","$p8JvNKqcMJyBvWNU1FDkiLDTRlXy5rsdbuFHnb2B8C8","$qWO-_b92SkB332dbQSvk2vb16m-ArUlfJI5YXJ9mj-w","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"]}, +{"name":"ts-639","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":0},"kick":75,"state_default":50,"users":{"@b:x":0,"@c:x":0,"@d:x":75}},["$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],4],["$PY2lseZEfHheVbS2leOjm6MmRe183tjQTTBweMAIltI","m.room.join_rules","","@a:x",{"join_rule":"public"},["$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],4],["$H7BnT8PQ_q8dj6nkK7Nl5c54MkqdhsXNXyInw5k2YTY","m.room.member","@b:x","@b:x",{"membership":"join"},["$PY2lseZEfHheVbS2leOjm6MmRe183tjQTTBweMAIltI","$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw"],0],["$rvbsXV_IrS00d0BbX3ZDGY_wSRrX4uQfTpLS0lX0MWE","m.room.member","@c:x","@c:x",{"membership":"join"},["$PY2lseZEfHheVbS2leOjm6MmRe183tjQTTBweMAIltI","$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw"],1],["$aftYrlCpGasK9PUO-kiTq3DMX4WYIyJPax9bvhC60sk","m.room.member","@d:x","@d:x",{"membership":"join"},["$PY2lseZEfHheVbS2leOjm6MmRe183tjQTTBweMAIltI","$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw"],0],["$gWB12pspv3z6ZRo7Zh9hHJ4OVhRKzJVG4a0LXE9b4kc","m.room.name","","@a:x",{"name":"base"},["$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],2],["$morHW1qBFR9U-p8mrdMIvfstjL-8o2OZITtwzuzPuuU","m.room.topic","","@b:x",{"topic":"0"},["$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw","$H7BnT8PQ_q8dj6nkK7Nl5c54MkqdhsXNXyInw5k2YTY"],0],["$rD6xvONB2TaYHRsw3NfgCBRpYqjc8Ui68b6SVLzlRFs","m.room.join_rules","","@d:x",{"join_rule":"public"},["$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw","$aftYrlCpGasK9PUO-kiTq3DMX4WYIyJPax9bvhC60sk"],0],["$f_WEoMucwAQHgTvyOexiUF_qREePeIiZ1D-uwV2fixw","m.room.name","","@a:x",{"name":"1"},["$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],4],["$ZOHdou7idfPZvdYwpBGgmc5jBh0Ez7EFTmEnjooV06E","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],0],["$El8tokhZ3l-Ce-I1jcUMo7ziTl_egQ7dgxrhVa-8fdA","m.room.member","@d:x","@d:x",{"displayname":"2","membership":"join"},["$rD6xvONB2TaYHRsw3NfgCBRpYqjc8Ui68b6SVLzlRFs","$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw","$aftYrlCpGasK9PUO-kiTq3DMX4WYIyJPax9bvhC60sk"],3],["$kjhUDWGoVKvp30aAVd4uxFT6BpCjqmgemui0Siq9PCc","m.room.name","","@a:x",{"name":"3"},["$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],5]],"states":[["$H7BnT8PQ_q8dj6nkK7Nl5c54MkqdhsXNXyInw5k2YTY","$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$ZOHdou7idfPZvdYwpBGgmc5jBh0Ez7EFTmEnjooV06E","$aftYrlCpGasK9PUO-kiTq3DMX4WYIyJPax9bvhC60sk","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$kjhUDWGoVKvp30aAVd4uxFT6BpCjqmgemui0Siq9PCc","$morHW1qBFR9U-p8mrdMIvfstjL-8o2OZITtwzuzPuuU","$rvbsXV_IrS00d0BbX3ZDGY_wSRrX4uQfTpLS0lX0MWE"],["$H7BnT8PQ_q8dj6nkK7Nl5c54MkqdhsXNXyInw5k2YTY","$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$aftYrlCpGasK9PUO-kiTq3DMX4WYIyJPax9bvhC60sk","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$f_WEoMucwAQHgTvyOexiUF_qREePeIiZ1D-uwV2fixw","$morHW1qBFR9U-p8mrdMIvfstjL-8o2OZITtwzuzPuuU","$rD6xvONB2TaYHRsw3NfgCBRpYqjc8Ui68b6SVLzlRFs","$rvbsXV_IrS00d0BbX3ZDGY_wSRrX4uQfTpLS0lX0MWE"],["$El8tokhZ3l-Ce-I1jcUMo7ziTl_egQ7dgxrhVa-8fdA","$H7BnT8PQ_q8dj6nkK7Nl5c54MkqdhsXNXyInw5k2YTY","$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$gWB12pspv3z6ZRo7Zh9hHJ4OVhRKzJVG4a0LXE9b4kc","$morHW1qBFR9U-p8mrdMIvfstjL-8o2OZITtwzuzPuuU","$rD6xvONB2TaYHRsw3NfgCBRpYqjc8Ui68b6SVLzlRFs","$rvbsXV_IrS00d0BbX3ZDGY_wSRrX4uQfTpLS0lX0MWE"]],"resolved":["$El8tokhZ3l-Ce-I1jcUMo7ziTl_egQ7dgxrhVa-8fdA","$H7BnT8PQ_q8dj6nkK7Nl5c54MkqdhsXNXyInw5k2YTY","$IQVqZVgnXQHt9zVOA25Ny4Hp0_Rf6iVM9IgPrhJmixw","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$kjhUDWGoVKvp30aAVd4uxFT6BpCjqmgemui0Siq9PCc","$morHW1qBFR9U-p8mrdMIvfstjL-8o2OZITtwzuzPuuU","$rD6xvONB2TaYHRsw3NfgCBRpYqjc8Ui68b6SVLzlRFs","$rvbsXV_IrS00d0BbX3ZDGY_wSRrX4uQfTpLS0lX0MWE"]}, +{"name":"ts-650","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$rWHKonXz6qQWcZXvVNlCFETdNLz3u2g7aTWc90eoaNs","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@b:x":25,"@c:x":50,"@d:x":0}},["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],3],["$mQ1ueaYnfx41mEU1rMCGNkoRNyeJufn2j_2p-5SZVAs","m.room.join_rules","","@a:x",{"join_rule":"public"},["$rWHKonXz6qQWcZXvVNlCFETdNLz3u2g7aTWc90eoaNs","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],0],["$clkH6XdKFv5Hbv54N-whBtCCDoe7WDapCRJBbM0HzFc","m.room.member","@b:x","@b:x",{"membership":"join"},["$mQ1ueaYnfx41mEU1rMCGNkoRNyeJufn2j_2p-5SZVAs","$rWHKonXz6qQWcZXvVNlCFETdNLz3u2g7aTWc90eoaNs"],0],["$_663cB6Fx2DEWW7T8HM60ieOOWhmk9p7BSXe52Nev_E","m.room.member","@c:x","@c:x",{"membership":"join"},["$mQ1ueaYnfx41mEU1rMCGNkoRNyeJufn2j_2p-5SZVAs","$rWHKonXz6qQWcZXvVNlCFETdNLz3u2g7aTWc90eoaNs"],4],["$xKCYinmkED0o1zZiPzHN7wG8VseoRqUf9lQkmENezWQ","m.room.member","@d:x","@d:x",{"membership":"join"},["$mQ1ueaYnfx41mEU1rMCGNkoRNyeJufn2j_2p-5SZVAs","$rWHKonXz6qQWcZXvVNlCFETdNLz3u2g7aTWc90eoaNs"],1],["$DtITwSUcOzQmfCiGsy0e078IyNIR651W1FU4ks9v9sA","m.room.topic","","@a:x",{"topic":"base"},["$rWHKonXz6qQWcZXvVNlCFETdNLz3u2g7aTWc90eoaNs","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],4],["$A21sH5RIe_QuI8SMhk2oZjZJs21nclJNKMYE0K8vdGs","m.room.name","","@c:x",{"name":"0"},["$rWHKonXz6qQWcZXvVNlCFETdNLz3u2g7aTWc90eoaNs","$_663cB6Fx2DEWW7T8HM60ieOOWhmk9p7BSXe52Nev_E"],5],["$Qgul5l5564QFdxqj3abALeEd6c4s-L9d3LU8oxtzMYA","m.room.join_rules","","@a:x",{"join_rule":"public"},["$rWHKonXz6qQWcZXvVNlCFETdNLz3u2g7aTWc90eoaNs","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],0],["$RpCuf6o5MMLDJ5rJCEFJulJQV-9MMNTFH_-H6o4GqR8","m.room.member","@b:x","@b:x",{"displayname":"1","membership":"join"},["$Qgul5l5564QFdxqj3abALeEd6c4s-L9d3LU8oxtzMYA","$rWHKonXz6qQWcZXvVNlCFETdNLz3u2g7aTWc90eoaNs","$clkH6XdKFv5Hbv54N-whBtCCDoe7WDapCRJBbM0HzFc"],1],["$m0_K-BCfvt8d7VaCeb54CvEFh5dBoQVXrUfP8T6SJbw","m.room.member","@c:x","@c:x",{"membership":"leave"},["$rWHKonXz6qQWcZXvVNlCFETdNLz3u2g7aTWc90eoaNs","$_663cB6Fx2DEWW7T8HM60ieOOWhmk9p7BSXe52Nev_E"],4]],"states":[["$A21sH5RIe_QuI8SMhk2oZjZJs21nclJNKMYE0K8vdGs","$DtITwSUcOzQmfCiGsy0e078IyNIR651W1FU4ks9v9sA","$Qgul5l5564QFdxqj3abALeEd6c4s-L9d3LU8oxtzMYA","$RpCuf6o5MMLDJ5rJCEFJulJQV-9MMNTFH_-H6o4GqR8","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$_663cB6Fx2DEWW7T8HM60ieOOWhmk9p7BSXe52Nev_E","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$rWHKonXz6qQWcZXvVNlCFETdNLz3u2g7aTWc90eoaNs","$xKCYinmkED0o1zZiPzHN7wG8VseoRqUf9lQkmENezWQ"],["$A21sH5RIe_QuI8SMhk2oZjZJs21nclJNKMYE0K8vdGs","$DtITwSUcOzQmfCiGsy0e078IyNIR651W1FU4ks9v9sA","$Qgul5l5564QFdxqj3abALeEd6c4s-L9d3LU8oxtzMYA","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$clkH6XdKFv5Hbv54N-whBtCCDoe7WDapCRJBbM0HzFc","$m0_K-BCfvt8d7VaCeb54CvEFh5dBoQVXrUfP8T6SJbw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$rWHKonXz6qQWcZXvVNlCFETdNLz3u2g7aTWc90eoaNs","$xKCYinmkED0o1zZiPzHN7wG8VseoRqUf9lQkmENezWQ"]],"resolved":["$A21sH5RIe_QuI8SMhk2oZjZJs21nclJNKMYE0K8vdGs","$DtITwSUcOzQmfCiGsy0e078IyNIR651W1FU4ks9v9sA","$Qgul5l5564QFdxqj3abALeEd6c4s-L9d3LU8oxtzMYA","$RpCuf6o5MMLDJ5rJCEFJulJQV-9MMNTFH_-H6o4GqR8","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$m0_K-BCfvt8d7VaCeb54CvEFh5dBoQVXrUfP8T6SJbw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$rWHKonXz6qQWcZXvVNlCFETdNLz3u2g7aTWc90eoaNs","$xKCYinmkED0o1zZiPzHN7wG8VseoRqUf9lQkmENezWQ"]}, +{"name":"ts-656","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$gnVE5tNBhPrXASJxHnINM3WH18jvcmVy1YU8OLKgbjg","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":50},"kick":50,"state_default":0,"users":{"@b:x":75,"@c:x":50,"@d:x":50}},["$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],1],["$x5c-Rk0UAfesBo-Hzc80HqlEglKj5NnONagVTJzcBug","m.room.join_rules","","@a:x",{"join_rule":"public"},["$gnVE5tNBhPrXASJxHnINM3WH18jvcmVy1YU8OLKgbjg","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],0],["$KAZyiJ8fMh9SC7OmXxYAgw1SVmt00XuuCjueoq6aMMk","m.room.member","@b:x","@b:x",{"membership":"join"},["$x5c-Rk0UAfesBo-Hzc80HqlEglKj5NnONagVTJzcBug","$gnVE5tNBhPrXASJxHnINM3WH18jvcmVy1YU8OLKgbjg"],2],["$EIM3TIg_XhgJR6snBM_iSt7Zu6zLhv9wbvrvKCZmuDs","m.room.member","@c:x","@c:x",{"membership":"join"},["$x5c-Rk0UAfesBo-Hzc80HqlEglKj5NnONagVTJzcBug","$gnVE5tNBhPrXASJxHnINM3WH18jvcmVy1YU8OLKgbjg"],3],["$HLhgOuoOyM_WUvJda_Hm13CfYwNf_Wb46aWaLg3d9jE","m.room.member","@d:x","@d:x",{"membership":"join"},["$x5c-Rk0UAfesBo-Hzc80HqlEglKj5NnONagVTJzcBug","$gnVE5tNBhPrXASJxHnINM3WH18jvcmVy1YU8OLKgbjg"],4],["$4LnNTo1LxtT113_-5uRLJmY3sTl8wiJ4vseTx13QPW8","m.room.topic","","@a:x",{"topic":"base"},["$gnVE5tNBhPrXASJxHnINM3WH18jvcmVy1YU8OLKgbjg","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],2],["$GVXb8gjMavb1ay1zl24-eiBBj0ntWjRt_kNQp7Tqb2c","m.room.name","","@a:x",{"name":"base"},["$gnVE5tNBhPrXASJxHnINM3WH18jvcmVy1YU8OLKgbjg","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],5],["$7Zx6xJuO9Em-s1KGPjjRP6CzcUo5KC15b_ST-Ga7Oq0","m.room.name","","@c:x",{"name":"0"},["$gnVE5tNBhPrXASJxHnINM3WH18jvcmVy1YU8OLKgbjg","$EIM3TIg_XhgJR6snBM_iSt7Zu6zLhv9wbvrvKCZmuDs"],2],["$MbpXggqbAdE3h0FH3kzoGDgQk0_3MF41poq85vmNQ8E","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$x5c-Rk0UAfesBo-Hzc80HqlEglKj5NnONagVTJzcBug","$gnVE5tNBhPrXASJxHnINM3WH18jvcmVy1YU8OLKgbjg","$HLhgOuoOyM_WUvJda_Hm13CfYwNf_Wb46aWaLg3d9jE"],4]],"states":[["$4LnNTo1LxtT113_-5uRLJmY3sTl8wiJ4vseTx13QPW8","$7Zx6xJuO9Em-s1KGPjjRP6CzcUo5KC15b_ST-Ga7Oq0","$EIM3TIg_XhgJR6snBM_iSt7Zu6zLhv9wbvrvKCZmuDs","$HLhgOuoOyM_WUvJda_Hm13CfYwNf_Wb46aWaLg3d9jE","$KAZyiJ8fMh9SC7OmXxYAgw1SVmt00XuuCjueoq6aMMk","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$gnVE5tNBhPrXASJxHnINM3WH18jvcmVy1YU8OLKgbjg","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo","$x5c-Rk0UAfesBo-Hzc80HqlEglKj5NnONagVTJzcBug"],["$4LnNTo1LxtT113_-5uRLJmY3sTl8wiJ4vseTx13QPW8","$EIM3TIg_XhgJR6snBM_iSt7Zu6zLhv9wbvrvKCZmuDs","$GVXb8gjMavb1ay1zl24-eiBBj0ntWjRt_kNQp7Tqb2c","$KAZyiJ8fMh9SC7OmXxYAgw1SVmt00XuuCjueoq6aMMk","$MbpXggqbAdE3h0FH3kzoGDgQk0_3MF41poq85vmNQ8E","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$gnVE5tNBhPrXASJxHnINM3WH18jvcmVy1YU8OLKgbjg","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo","$x5c-Rk0UAfesBo-Hzc80HqlEglKj5NnONagVTJzcBug"]],"resolved":["$4LnNTo1LxtT113_-5uRLJmY3sTl8wiJ4vseTx13QPW8","$EIM3TIg_XhgJR6snBM_iSt7Zu6zLhv9wbvrvKCZmuDs","$GVXb8gjMavb1ay1zl24-eiBBj0ntWjRt_kNQp7Tqb2c","$KAZyiJ8fMh9SC7OmXxYAgw1SVmt00XuuCjueoq6aMMk","$MbpXggqbAdE3h0FH3kzoGDgQk0_3MF41poq85vmNQ8E","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$gnVE5tNBhPrXASJxHnINM3WH18jvcmVy1YU8OLKgbjg","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo","$x5c-Rk0UAfesBo-Hzc80HqlEglKj5NnONagVTJzcBug"]}, +{"name":"ts-696","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$jchwVFaCsaOYRBuUwWrpA2ND_z4hn2ySnee5jhMRoF4","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":25},"kick":25,"state_default":0,"users":{"@b:x":0,"@c:x":25,"@d:x":50}},["$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],5],["$dZPk2g9cahnmkmn2Ey1ie3d4BsfYdFYvuyd4VzQb5-k","m.room.join_rules","","@a:x",{"join_rule":"public"},["$jchwVFaCsaOYRBuUwWrpA2ND_z4hn2ySnee5jhMRoF4","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],5],["$A2lno9VX8W9lYx5zEqYFGukQh4Ml0RUD-jB3RhbIDuc","m.room.member","@b:x","@b:x",{"membership":"join"},["$dZPk2g9cahnmkmn2Ey1ie3d4BsfYdFYvuyd4VzQb5-k","$jchwVFaCsaOYRBuUwWrpA2ND_z4hn2ySnee5jhMRoF4"],0],["$_LH_QNi6zHL_ofEYOS1pshhLT20ddrubSld9kY6-lPY","m.room.member","@c:x","@c:x",{"membership":"join"},["$dZPk2g9cahnmkmn2Ey1ie3d4BsfYdFYvuyd4VzQb5-k","$jchwVFaCsaOYRBuUwWrpA2ND_z4hn2ySnee5jhMRoF4"],2],["$3Wxii0trmk2_av11nvPk7GAwVxEq1_8hNRuPr0eG_uM","m.room.member","@d:x","@d:x",{"membership":"join"},["$dZPk2g9cahnmkmn2Ey1ie3d4BsfYdFYvuyd4VzQb5-k","$jchwVFaCsaOYRBuUwWrpA2ND_z4hn2ySnee5jhMRoF4"],1],["$KAGLiH_vaxg0S0aj36UWqViZ_CCZQ03wq08BBcetw-8","m.room.topic","","@a:x",{"topic":"base"},["$jchwVFaCsaOYRBuUwWrpA2ND_z4hn2ySnee5jhMRoF4","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],4],["$ynj53EwS0mHJdQm-4nLn2Nyij8w8BQL6qkUaZdUt8IA","m.room.name","","@a:x",{"name":"base"},["$jchwVFaCsaOYRBuUwWrpA2ND_z4hn2ySnee5jhMRoF4","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],1],["$RAf0lfrPyUJCp63Y81tRKhRRdv-GA3euqyvvJMZMvZg","m.room.join_rules","","@d:x",{"join_rule":"public"},["$jchwVFaCsaOYRBuUwWrpA2ND_z4hn2ySnee5jhMRoF4","$3Wxii0trmk2_av11nvPk7GAwVxEq1_8hNRuPr0eG_uM"],2],["$Hl0c3peEd_G4_GZBYDwHJP8uifgYMi9IUKwmCqb7M2M","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$dZPk2g9cahnmkmn2Ey1ie3d4BsfYdFYvuyd4VzQb5-k","$jchwVFaCsaOYRBuUwWrpA2ND_z4hn2ySnee5jhMRoF4"],4],["$zsV12FE1FF5pvguit9UZ5g2J26NtWH34kFY1MwVFot0","m.room.member","@c:x","@c:x",{"displayname":"1","membership":"join"},["$RAf0lfrPyUJCp63Y81tRKhRRdv-GA3euqyvvJMZMvZg","$jchwVFaCsaOYRBuUwWrpA2ND_z4hn2ySnee5jhMRoF4","$_LH_QNi6zHL_ofEYOS1pshhLT20ddrubSld9kY6-lPY"],0],["$JWYsQKBneTWF3aTyqvh3gQ5rmwTKDiQTEaOa4V7KEuc","m.room.member","@e:x","@e:x",{"displayname":"2","membership":"join"},["$dZPk2g9cahnmkmn2Ey1ie3d4BsfYdFYvuyd4VzQb5-k","$jchwVFaCsaOYRBuUwWrpA2ND_z4hn2ySnee5jhMRoF4","$Hl0c3peEd_G4_GZBYDwHJP8uifgYMi9IUKwmCqb7M2M"],1]],"states":[["$3Wxii0trmk2_av11nvPk7GAwVxEq1_8hNRuPr0eG_uM","$A2lno9VX8W9lYx5zEqYFGukQh4Ml0RUD-jB3RhbIDuc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$JWYsQKBneTWF3aTyqvh3gQ5rmwTKDiQTEaOa4V7KEuc","$KAGLiH_vaxg0S0aj36UWqViZ_CCZQ03wq08BBcetw-8","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","$_LH_QNi6zHL_ofEYOS1pshhLT20ddrubSld9kY6-lPY","$dZPk2g9cahnmkmn2Ey1ie3d4BsfYdFYvuyd4VzQb5-k","$jchwVFaCsaOYRBuUwWrpA2ND_z4hn2ySnee5jhMRoF4","$ynj53EwS0mHJdQm-4nLn2Nyij8w8BQL6qkUaZdUt8IA"],["$3Wxii0trmk2_av11nvPk7GAwVxEq1_8hNRuPr0eG_uM","$A2lno9VX8W9lYx5zEqYFGukQh4Ml0RUD-jB3RhbIDuc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$KAGLiH_vaxg0S0aj36UWqViZ_CCZQ03wq08BBcetw-8","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","$RAf0lfrPyUJCp63Y81tRKhRRdv-GA3euqyvvJMZMvZg","$jchwVFaCsaOYRBuUwWrpA2ND_z4hn2ySnee5jhMRoF4","$ynj53EwS0mHJdQm-4nLn2Nyij8w8BQL6qkUaZdUt8IA","$zsV12FE1FF5pvguit9UZ5g2J26NtWH34kFY1MwVFot0"]],"resolved":["$3Wxii0trmk2_av11nvPk7GAwVxEq1_8hNRuPr0eG_uM","$A2lno9VX8W9lYx5zEqYFGukQh4Ml0RUD-jB3RhbIDuc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$Hl0c3peEd_G4_GZBYDwHJP8uifgYMi9IUKwmCqb7M2M","$KAGLiH_vaxg0S0aj36UWqViZ_CCZQ03wq08BBcetw-8","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","$RAf0lfrPyUJCp63Y81tRKhRRdv-GA3euqyvvJMZMvZg","$_LH_QNi6zHL_ofEYOS1pshhLT20ddrubSld9kY6-lPY","$jchwVFaCsaOYRBuUwWrpA2ND_z4hn2ySnee5jhMRoF4","$ynj53EwS0mHJdQm-4nLn2Nyij8w8BQL6qkUaZdUt8IA"]}, +{"name":"ts-733","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$g4eXRDcpPXkwmlZia88QRCkNL-0FAqEi43HrOHEWJh8","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":75},"kick":75,"state_default":50,"users":{"@b:x":0,"@c:x":0,"@d:x":75}},["$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],3],["$vtlS-AFOWd3RV-c-4kQ72r1j27obJsykwk85zNaaHSw","m.room.join_rules","","@a:x",{"join_rule":"public"},["$g4eXRDcpPXkwmlZia88QRCkNL-0FAqEi43HrOHEWJh8","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],1],["$XerbJWZvYk2zuqR6DobjYK4oZ2iYDtM6lpRSkGvHkyI","m.room.member","@b:x","@b:x",{"membership":"join"},["$vtlS-AFOWd3RV-c-4kQ72r1j27obJsykwk85zNaaHSw","$g4eXRDcpPXkwmlZia88QRCkNL-0FAqEi43HrOHEWJh8"],2],["$hHOkFy7_uKpnQcVxFOg4EReJbtC9jWwd8clqBZU_D7M","m.room.member","@c:x","@c:x",{"membership":"join"},["$vtlS-AFOWd3RV-c-4kQ72r1j27obJsykwk85zNaaHSw","$g4eXRDcpPXkwmlZia88QRCkNL-0FAqEi43HrOHEWJh8"],3],["$OWjWjB-g8zw5TWgUME7p-X-9T305rd2cOAozsbHaPtc","m.room.member","@d:x","@d:x",{"membership":"join"},["$vtlS-AFOWd3RV-c-4kQ72r1j27obJsykwk85zNaaHSw","$g4eXRDcpPXkwmlZia88QRCkNL-0FAqEi43HrOHEWJh8"],5],["$GBJBHfU7u2Lfl3JWoO0Oq1BdQgJ5ekZzMrTiKkaQG-o","m.room.topic","","@a:x",{"topic":"base"},["$g4eXRDcpPXkwmlZia88QRCkNL-0FAqEi43HrOHEWJh8","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],1],["$zWgdi-Q70aREIDtBQYCGveq9j_D4Ni9qUMO_zFMLZNg","m.room.name","","@a:x",{"name":"base"},["$g4eXRDcpPXkwmlZia88QRCkNL-0FAqEi43HrOHEWJh8","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],2],["$sJyj_k31mc27EZOTvWVd-SpAtxHyM7SImwEmiXOBfwQ","m.room.member","@d:x","@d:x",{"membership":"leave"},["$g4eXRDcpPXkwmlZia88QRCkNL-0FAqEi43HrOHEWJh8","$OWjWjB-g8zw5TWgUME7p-X-9T305rd2cOAozsbHaPtc"],3],["$Z2IfOJJVIYO8CRXGFMDq2cWPuJyEHE8-uIHMrlhNeqE","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$vtlS-AFOWd3RV-c-4kQ72r1j27obJsykwk85zNaaHSw","$g4eXRDcpPXkwmlZia88QRCkNL-0FAqEi43HrOHEWJh8","$OWjWjB-g8zw5TWgUME7p-X-9T305rd2cOAozsbHaPtc"],3]],"states":[["$GBJBHfU7u2Lfl3JWoO0Oq1BdQgJ5ekZzMrTiKkaQG-o","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","$XerbJWZvYk2zuqR6DobjYK4oZ2iYDtM6lpRSkGvHkyI","$g4eXRDcpPXkwmlZia88QRCkNL-0FAqEi43HrOHEWJh8","$hHOkFy7_uKpnQcVxFOg4EReJbtC9jWwd8clqBZU_D7M","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$sJyj_k31mc27EZOTvWVd-SpAtxHyM7SImwEmiXOBfwQ","$vtlS-AFOWd3RV-c-4kQ72r1j27obJsykwk85zNaaHSw","$zWgdi-Q70aREIDtBQYCGveq9j_D4Ni9qUMO_zFMLZNg"],["$GBJBHfU7u2Lfl3JWoO0Oq1BdQgJ5ekZzMrTiKkaQG-o","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","$XerbJWZvYk2zuqR6DobjYK4oZ2iYDtM6lpRSkGvHkyI","$Z2IfOJJVIYO8CRXGFMDq2cWPuJyEHE8-uIHMrlhNeqE","$g4eXRDcpPXkwmlZia88QRCkNL-0FAqEi43HrOHEWJh8","$hHOkFy7_uKpnQcVxFOg4EReJbtC9jWwd8clqBZU_D7M","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$vtlS-AFOWd3RV-c-4kQ72r1j27obJsykwk85zNaaHSw","$zWgdi-Q70aREIDtBQYCGveq9j_D4Ni9qUMO_zFMLZNg"]],"resolved":["$GBJBHfU7u2Lfl3JWoO0Oq1BdQgJ5ekZzMrTiKkaQG-o","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","$XerbJWZvYk2zuqR6DobjYK4oZ2iYDtM6lpRSkGvHkyI","$g4eXRDcpPXkwmlZia88QRCkNL-0FAqEi43HrOHEWJh8","$hHOkFy7_uKpnQcVxFOg4EReJbtC9jWwd8clqBZU_D7M","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$sJyj_k31mc27EZOTvWVd-SpAtxHyM7SImwEmiXOBfwQ","$vtlS-AFOWd3RV-c-4kQ72r1j27obJsykwk85zNaaHSw","$zWgdi-Q70aREIDtBQYCGveq9j_D4Ni9qUMO_zFMLZNg"]}, +{"name":"ts-762","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$V6wMjAUt168Oc9Vpf5Fi4ogMeruCAfuND4seGpx9HY8","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":25,"m.room.power_levels":50,"m.room.topic":25},"kick":75,"state_default":0,"users":{"@b:x":75,"@c:x":75,"@d:x":75}},["$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],1],["$0y8siuctw5BMgRu51I99IciRTlhorKISX9lHHQPSEmk","m.room.join_rules","","@a:x",{"join_rule":"public"},["$V6wMjAUt168Oc9Vpf5Fi4ogMeruCAfuND4seGpx9HY8","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],4],["$V_n7_YCNhmN4y6raEj9SzVAH-K1UgMFEOiYC7E_N_hU","m.room.member","@b:x","@b:x",{"membership":"join"},["$0y8siuctw5BMgRu51I99IciRTlhorKISX9lHHQPSEmk","$V6wMjAUt168Oc9Vpf5Fi4ogMeruCAfuND4seGpx9HY8"],1],["$Da496v5O1AMSsP0NqsNlNoekYclbPQqnei2hnSzuhHU","m.room.member","@c:x","@c:x",{"membership":"join"},["$0y8siuctw5BMgRu51I99IciRTlhorKISX9lHHQPSEmk","$V6wMjAUt168Oc9Vpf5Fi4ogMeruCAfuND4seGpx9HY8"],2],["$yHXeRhzx4lMz2wRs8ApllUh8K9RULLqsvuHXfJRzQqE","m.room.member","@d:x","@d:x",{"membership":"join"},["$0y8siuctw5BMgRu51I99IciRTlhorKISX9lHHQPSEmk","$V6wMjAUt168Oc9Vpf5Fi4ogMeruCAfuND4seGpx9HY8"],0],["$LfYz26yLjySBg5JHxLUFdiayHoMYuua6GklTPvo2ynA","m.room.topic","","@a:x",{"topic":"base"},["$V6wMjAUt168Oc9Vpf5Fi4ogMeruCAfuND4seGpx9HY8","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],4],["$EMY-jfj6Fzjk-PjNRVJ5n9NF0FjNMibO2vJlY_mUGcs","m.room.name","","@a:x",{"name":"base"},["$V6wMjAUt168Oc9Vpf5Fi4ogMeruCAfuND4seGpx9HY8","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],5],["$0SYIxPLUCmAtjV3GSga9oUK2pJn5mFoJEJwKD7tkfdk","m.room.name","","@a:x",{"name":"0"},["$V6wMjAUt168Oc9Vpf5Fi4ogMeruCAfuND4seGpx9HY8","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],2],["$v6Ypx3HMSUG_TIjbSEwtnjOxG7dSg1Kav_SMuMutX_s","m.room.topic","","@d:x",{"topic":"1"},["$V6wMjAUt168Oc9Vpf5Fi4ogMeruCAfuND4seGpx9HY8","$yHXeRhzx4lMz2wRs8ApllUh8K9RULLqsvuHXfJRzQqE"],1],["$O1WpS7ImbwtVSlcVlCO7QWJLCwV5Ti4wwGBK6jnGlXA","m.room.member","@e:x","@a:x",{"membership":"invite"},["$0y8siuctw5BMgRu51I99IciRTlhorKISX9lHHQPSEmk","$V6wMjAUt168Oc9Vpf5Fi4ogMeruCAfuND4seGpx9HY8","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],2]],"states":[["$0SYIxPLUCmAtjV3GSga9oUK2pJn5mFoJEJwKD7tkfdk","$0y8siuctw5BMgRu51I99IciRTlhorKISX9lHHQPSEmk","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$Da496v5O1AMSsP0NqsNlNoekYclbPQqnei2hnSzuhHU","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$LfYz26yLjySBg5JHxLUFdiayHoMYuua6GklTPvo2ynA","$O1WpS7ImbwtVSlcVlCO7QWJLCwV5Ti4wwGBK6jnGlXA","$V6wMjAUt168Oc9Vpf5Fi4ogMeruCAfuND4seGpx9HY8","$V_n7_YCNhmN4y6raEj9SzVAH-K1UgMFEOiYC7E_N_hU","$yHXeRhzx4lMz2wRs8ApllUh8K9RULLqsvuHXfJRzQqE"],["$0y8siuctw5BMgRu51I99IciRTlhorKISX9lHHQPSEmk","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$Da496v5O1AMSsP0NqsNlNoekYclbPQqnei2hnSzuhHU","$EMY-jfj6Fzjk-PjNRVJ5n9NF0FjNMibO2vJlY_mUGcs","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$V6wMjAUt168Oc9Vpf5Fi4ogMeruCAfuND4seGpx9HY8","$V_n7_YCNhmN4y6raEj9SzVAH-K1UgMFEOiYC7E_N_hU","$v6Ypx3HMSUG_TIjbSEwtnjOxG7dSg1Kav_SMuMutX_s","$yHXeRhzx4lMz2wRs8ApllUh8K9RULLqsvuHXfJRzQqE"]],"resolved":["$0y8siuctw5BMgRu51I99IciRTlhorKISX9lHHQPSEmk","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$Da496v5O1AMSsP0NqsNlNoekYclbPQqnei2hnSzuhHU","$EMY-jfj6Fzjk-PjNRVJ5n9NF0FjNMibO2vJlY_mUGcs","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$LfYz26yLjySBg5JHxLUFdiayHoMYuua6GklTPvo2ynA","$O1WpS7ImbwtVSlcVlCO7QWJLCwV5Ti4wwGBK6jnGlXA","$V6wMjAUt168Oc9Vpf5Fi4ogMeruCAfuND4seGpx9HY8","$V_n7_YCNhmN4y6raEj9SzVAH-K1UgMFEOiYC7E_N_hU","$yHXeRhzx4lMz2wRs8ApllUh8K9RULLqsvuHXfJRzQqE"]}, +{"name":"ts-781","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$iVM9n3PFF3sHfGDnYy6Q6GXYPsgHFnG2077PJZasPE0","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":25},"kick":25,"state_default":50,"users":{"@b:x":25,"@c:x":25,"@d:x":0}},["$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],1],["$jRe4bnH5UtcRxl1c_Jiu7KkqKyR0LZ_GXuQda5dxu68","m.room.join_rules","","@a:x",{"join_rule":"public"},["$iVM9n3PFF3sHfGDnYy6Q6GXYPsgHFnG2077PJZasPE0","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],1],["$P-RDx9_f1irHyuyUwspQC4upz2F2o3qzXE37RbLAhwU","m.room.member","@b:x","@b:x",{"membership":"join"},["$jRe4bnH5UtcRxl1c_Jiu7KkqKyR0LZ_GXuQda5dxu68","$iVM9n3PFF3sHfGDnYy6Q6GXYPsgHFnG2077PJZasPE0"],2],["$4iyd-1w7pVDpj1jvDCzgE_5bsBcPEygT1v_nYQKIAok","m.room.member","@c:x","@c:x",{"membership":"join"},["$jRe4bnH5UtcRxl1c_Jiu7KkqKyR0LZ_GXuQda5dxu68","$iVM9n3PFF3sHfGDnYy6Q6GXYPsgHFnG2077PJZasPE0"],1],["$xBcFAF5jntgTTyQVcWnd81iIbY99NeAeK4naMYtf18w","m.room.member","@d:x","@d:x",{"membership":"join"},["$jRe4bnH5UtcRxl1c_Jiu7KkqKyR0LZ_GXuQda5dxu68","$iVM9n3PFF3sHfGDnYy6Q6GXYPsgHFnG2077PJZasPE0"],5],["$5ezGA8viYxW_c5Qm5-2x_53objngDyaDBB34V3sOLNs","m.room.topic","","@a:x",{"topic":"base"},["$iVM9n3PFF3sHfGDnYy6Q6GXYPsgHFnG2077PJZasPE0","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],0],["$1LMbRJfwECumqeyYqFlxOj18FGDsg0Hoc4X3JLtuMKo","m.room.name","","@a:x",{"name":"base"},["$iVM9n3PFF3sHfGDnYy6Q6GXYPsgHFnG2077PJZasPE0","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],4],["$8SZ2gCdHEh8uYCGV1WY-y0hLJRr92sRu3hLsJOj7nwQ","m.room.member","@c:x","@c:x",{"membership":"leave"},["$iVM9n3PFF3sHfGDnYy6Q6GXYPsgHFnG2077PJZasPE0","$4iyd-1w7pVDpj1jvDCzgE_5bsBcPEygT1v_nYQKIAok"],2],["$QP1OBn_AviDsJTn0GZDDM0cuTyKdKxf1W-N6nOZtQC4","m.room.topic","","@a:x",{"topic":"0"},["$iVM9n3PFF3sHfGDnYy6Q6GXYPsgHFnG2077PJZasPE0","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg"],2]],"states":[["$1LMbRJfwECumqeyYqFlxOj18FGDsg0Hoc4X3JLtuMKo","$8SZ2gCdHEh8uYCGV1WY-y0hLJRr92sRu3hLsJOj7nwQ","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","$P-RDx9_f1irHyuyUwspQC4upz2F2o3qzXE37RbLAhwU","$QP1OBn_AviDsJTn0GZDDM0cuTyKdKxf1W-N6nOZtQC4","$iVM9n3PFF3sHfGDnYy6Q6GXYPsgHFnG2077PJZasPE0","$jRe4bnH5UtcRxl1c_Jiu7KkqKyR0LZ_GXuQda5dxu68","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$xBcFAF5jntgTTyQVcWnd81iIbY99NeAeK4naMYtf18w"],["$1LMbRJfwECumqeyYqFlxOj18FGDsg0Hoc4X3JLtuMKo","$4iyd-1w7pVDpj1jvDCzgE_5bsBcPEygT1v_nYQKIAok","$5ezGA8viYxW_c5Qm5-2x_53objngDyaDBB34V3sOLNs","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","$P-RDx9_f1irHyuyUwspQC4upz2F2o3qzXE37RbLAhwU","$iVM9n3PFF3sHfGDnYy6Q6GXYPsgHFnG2077PJZasPE0","$jRe4bnH5UtcRxl1c_Jiu7KkqKyR0LZ_GXuQda5dxu68","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$xBcFAF5jntgTTyQVcWnd81iIbY99NeAeK4naMYtf18w"]],"resolved":["$1LMbRJfwECumqeyYqFlxOj18FGDsg0Hoc4X3JLtuMKo","$8SZ2gCdHEh8uYCGV1WY-y0hLJRr92sRu3hLsJOj7nwQ","$HpjswoVWlCUEneXztRVgOcs_3K4p0J-P4juthYfkvCg","$P-RDx9_f1irHyuyUwspQC4upz2F2o3qzXE37RbLAhwU","$QP1OBn_AviDsJTn0GZDDM0cuTyKdKxf1W-N6nOZtQC4","$iVM9n3PFF3sHfGDnYy6Q6GXYPsgHFnG2077PJZasPE0","$jRe4bnH5UtcRxl1c_Jiu7KkqKyR0LZ_GXuQda5dxu68","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$xBcFAF5jntgTTyQVcWnd81iIbY99NeAeK4naMYtf18w"]}, +{"name":"ts-782","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$HYP-ZRH84ZCKmTu3dgm9CUHYieDBwy4evbj-B3KScQM","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":75},"kick":25,"state_default":50,"users":{"@b:x":75,"@c:x":50,"@d:x":0}},["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w"],3],["$TLGiogt40kbC61GkyRErX3OUXxiCshHGQGj9ZDj3pVU","m.room.join_rules","","@a:x",{"join_rule":"public"},["$HYP-ZRH84ZCKmTu3dgm9CUHYieDBwy4evbj-B3KScQM","$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w"],3],["$ZZztutzKbRHqabYREtRw3PoAb0r4mmEr5fgQO9ZJT_o","m.room.member","@b:x","@b:x",{"membership":"join"},["$TLGiogt40kbC61GkyRErX3OUXxiCshHGQGj9ZDj3pVU","$HYP-ZRH84ZCKmTu3dgm9CUHYieDBwy4evbj-B3KScQM"],4],["$vFA-j9d6YSyt_NkvapT9f6yp6AJwVGxE8hMxGz5ugnY","m.room.member","@c:x","@c:x",{"membership":"join"},["$TLGiogt40kbC61GkyRErX3OUXxiCshHGQGj9ZDj3pVU","$HYP-ZRH84ZCKmTu3dgm9CUHYieDBwy4evbj-B3KScQM"],2],["$jpfWq6V-N2Iy9DidSP15Cq2UVBa_kET5iYwOFuiRaOg","m.room.member","@d:x","@d:x",{"membership":"join"},["$TLGiogt40kbC61GkyRErX3OUXxiCshHGQGj9ZDj3pVU","$HYP-ZRH84ZCKmTu3dgm9CUHYieDBwy4evbj-B3KScQM"],2],["$NIdpMDdyUpDquNmHTv42G6pTiwfd5tXYvDy9wgEWjyE","m.room.topic","","@a:x",{"topic":"base"},["$HYP-ZRH84ZCKmTu3dgm9CUHYieDBwy4evbj-B3KScQM","$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w"],0],["$zgaM_P5hc8Bcg5b9VSKkfjnHcrXjwxxachz5OKzoRS4","m.room.name","","@a:x",{"name":"base"},["$HYP-ZRH84ZCKmTu3dgm9CUHYieDBwy4evbj-B3KScQM","$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w"],1],["$uYhY4cctsQSsIdKmugg8UCbDo6SO5Y-jvxOi8X7MEME","m.room.member","@d:x","@d:x",{"membership":"leave"},["$HYP-ZRH84ZCKmTu3dgm9CUHYieDBwy4evbj-B3KScQM","$jpfWq6V-N2Iy9DidSP15Cq2UVBa_kET5iYwOFuiRaOg"],1],["$SC5DkzeDARNKoQK_ustTnCju6lQS1yMybdlR2JMgX8c","m.room.member","@b:x","@b:x",{"membership":"leave"},["$HYP-ZRH84ZCKmTu3dgm9CUHYieDBwy4evbj-B3KScQM","$ZZztutzKbRHqabYREtRw3PoAb0r4mmEr5fgQO9ZJT_o"],2],["$M2T1wXLjxCAgakAb0ktMqzPVU8twMHNCXVILBtoiERs","m.room.member","@c:x","@c:x",{"membership":"leave"},["$HYP-ZRH84ZCKmTu3dgm9CUHYieDBwy4evbj-B3KScQM","$vFA-j9d6YSyt_NkvapT9f6yp6AJwVGxE8hMxGz5ugnY"],4]],"states":[["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$HYP-ZRH84ZCKmTu3dgm9CUHYieDBwy4evbj-B3KScQM","$M2T1wXLjxCAgakAb0ktMqzPVU8twMHNCXVILBtoiERs","$NIdpMDdyUpDquNmHTv42G6pTiwfd5tXYvDy9wgEWjyE","$TLGiogt40kbC61GkyRErX3OUXxiCshHGQGj9ZDj3pVU","$ZZztutzKbRHqabYREtRw3PoAb0r4mmEr5fgQO9ZJT_o","$uYhY4cctsQSsIdKmugg8UCbDo6SO5Y-jvxOi8X7MEME","$zgaM_P5hc8Bcg5b9VSKkfjnHcrXjwxxachz5OKzoRS4"],["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$HYP-ZRH84ZCKmTu3dgm9CUHYieDBwy4evbj-B3KScQM","$NIdpMDdyUpDquNmHTv42G6pTiwfd5tXYvDy9wgEWjyE","$SC5DkzeDARNKoQK_ustTnCju6lQS1yMybdlR2JMgX8c","$TLGiogt40kbC61GkyRErX3OUXxiCshHGQGj9ZDj3pVU","$jpfWq6V-N2Iy9DidSP15Cq2UVBa_kET5iYwOFuiRaOg","$vFA-j9d6YSyt_NkvapT9f6yp6AJwVGxE8hMxGz5ugnY","$zgaM_P5hc8Bcg5b9VSKkfjnHcrXjwxxachz5OKzoRS4"]],"resolved":["$15KlHgezSEb8UzOOay4YO2g5GJ1rHwRv0xOUAhdTx9w","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$HYP-ZRH84ZCKmTu3dgm9CUHYieDBwy4evbj-B3KScQM","$M2T1wXLjxCAgakAb0ktMqzPVU8twMHNCXVILBtoiERs","$NIdpMDdyUpDquNmHTv42G6pTiwfd5tXYvDy9wgEWjyE","$TLGiogt40kbC61GkyRErX3OUXxiCshHGQGj9ZDj3pVU","$ZZztutzKbRHqabYREtRw3PoAb0r4mmEr5fgQO9ZJT_o","$jpfWq6V-N2Iy9DidSP15Cq2UVBa_kET5iYwOFuiRaOg","$zgaM_P5hc8Bcg5b9VSKkfjnHcrXjwxxachz5OKzoRS4"]}, +{"name":"ts-920","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$CXuArXMBAkE79ioz8ozROMIgIJHM3Qj3SENGcwyc1H4","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":50},"kick":25,"state_default":0,"users":{"@b:x":25,"@c:x":0,"@d:x":0}},["$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],5],["$wp07q4Ow--PCXqBaDKniiG9jDYjKfpDJi6ZVNs6sNT0","m.room.join_rules","","@a:x",{"join_rule":"public"},["$CXuArXMBAkE79ioz8ozROMIgIJHM3Qj3SENGcwyc1H4","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],4],["$6WixVSCqdZAbEJKpzQrkD59oKObKXlFo3nP67Ax3ERw","m.room.member","@b:x","@b:x",{"membership":"join"},["$wp07q4Ow--PCXqBaDKniiG9jDYjKfpDJi6ZVNs6sNT0","$CXuArXMBAkE79ioz8ozROMIgIJHM3Qj3SENGcwyc1H4"],4],["$08L9bijhsDpfkdzJNtRJgc1L0MBfm1zdZuJRJXQC7AU","m.room.member","@c:x","@c:x",{"membership":"join"},["$wp07q4Ow--PCXqBaDKniiG9jDYjKfpDJi6ZVNs6sNT0","$CXuArXMBAkE79ioz8ozROMIgIJHM3Qj3SENGcwyc1H4"],0],["$zFSsvLHESvhPYnSBGa2npyqYZyRN0q9cYRDYWDd7wMU","m.room.member","@d:x","@d:x",{"membership":"join"},["$wp07q4Ow--PCXqBaDKniiG9jDYjKfpDJi6ZVNs6sNT0","$CXuArXMBAkE79ioz8ozROMIgIJHM3Qj3SENGcwyc1H4"],3],["$zi-ph3QNp0q9xwCkUyJZKP6RZAJwCgaqdokDpshsI2E","m.room.topic","","@a:x",{"topic":"base"},["$CXuArXMBAkE79ioz8ozROMIgIJHM3Qj3SENGcwyc1H4","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],2],["$03YaqDwaM5ZbMmYW06Jzf77b02wOWbRr7NZuiKhczvc","m.room.name","","@a:x",{"name":"base"},["$CXuArXMBAkE79ioz8ozROMIgIJHM3Qj3SENGcwyc1H4","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],4],["$6pa8YshEwgEXKN6Iq1JonD43dYNXLoKvjJOppTra0gc","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$CXuArXMBAkE79ioz8ozROMIgIJHM3Qj3SENGcwyc1H4","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],5],["$ErK91ekaMhc45KENnnB8cjbRFI4bZ1pPaf0pnmrgNYk","m.room.member","@c:x","@c:x",{"membership":"leave"},["$CXuArXMBAkE79ioz8ozROMIgIJHM3Qj3SENGcwyc1H4","$08L9bijhsDpfkdzJNtRJgc1L0MBfm1zdZuJRJXQC7AU"],4]],"states":[["$03YaqDwaM5ZbMmYW06Jzf77b02wOWbRr7NZuiKhczvc","$08L9bijhsDpfkdzJNtRJgc1L0MBfm1zdZuJRJXQC7AU","$6WixVSCqdZAbEJKpzQrkD59oKObKXlFo3nP67Ax3ERw","$6pa8YshEwgEXKN6Iq1JonD43dYNXLoKvjJOppTra0gc","$CXuArXMBAkE79ioz8ozROMIgIJHM3Qj3SENGcwyc1H4","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$zFSsvLHESvhPYnSBGa2npyqYZyRN0q9cYRDYWDd7wMU","$zi-ph3QNp0q9xwCkUyJZKP6RZAJwCgaqdokDpshsI2E"],["$03YaqDwaM5ZbMmYW06Jzf77b02wOWbRr7NZuiKhczvc","$6WixVSCqdZAbEJKpzQrkD59oKObKXlFo3nP67Ax3ERw","$6pa8YshEwgEXKN6Iq1JonD43dYNXLoKvjJOppTra0gc","$CXuArXMBAkE79ioz8ozROMIgIJHM3Qj3SENGcwyc1H4","$ErK91ekaMhc45KENnnB8cjbRFI4bZ1pPaf0pnmrgNYk","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$zFSsvLHESvhPYnSBGa2npyqYZyRN0q9cYRDYWDd7wMU","$zi-ph3QNp0q9xwCkUyJZKP6RZAJwCgaqdokDpshsI2E"],["$03YaqDwaM5ZbMmYW06Jzf77b02wOWbRr7NZuiKhczvc","$08L9bijhsDpfkdzJNtRJgc1L0MBfm1zdZuJRJXQC7AU","$6WixVSCqdZAbEJKpzQrkD59oKObKXlFo3nP67Ax3ERw","$6pa8YshEwgEXKN6Iq1JonD43dYNXLoKvjJOppTra0gc","$CXuArXMBAkE79ioz8ozROMIgIJHM3Qj3SENGcwyc1H4","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$zFSsvLHESvhPYnSBGa2npyqYZyRN0q9cYRDYWDd7wMU","$zi-ph3QNp0q9xwCkUyJZKP6RZAJwCgaqdokDpshsI2E"]],"resolved":["$03YaqDwaM5ZbMmYW06Jzf77b02wOWbRr7NZuiKhczvc","$6WixVSCqdZAbEJKpzQrkD59oKObKXlFo3nP67Ax3ERw","$6pa8YshEwgEXKN6Iq1JonD43dYNXLoKvjJOppTra0gc","$CXuArXMBAkE79ioz8ozROMIgIJHM3Qj3SENGcwyc1H4","$ErK91ekaMhc45KENnnB8cjbRFI4bZ1pPaf0pnmrgNYk","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$zFSsvLHESvhPYnSBGa2npyqYZyRN0q9cYRDYWDd7wMU","$zi-ph3QNp0q9xwCkUyJZKP6RZAJwCgaqdokDpshsI2E"]}, +{"name":"ts-928","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$65tKzdNMXcA9NbAK3Xxbh33DpT6_vuO0iF9HUNlv8uY","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":50},"kick":50,"state_default":0,"users":{"@b:x":25,"@c:x":75,"@d:x":75}},["$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],5],["$5-NJmcdFRgAr7WxaRsrfIZu9b6nZxooBKxqJGH3S1Y4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$65tKzdNMXcA9NbAK3Xxbh33DpT6_vuO0iF9HUNlv8uY","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],0],["$IJYHl_HNTDjX7wMgllBMnZu2ruLYA333r9YewtYvAH8","m.room.member","@b:x","@b:x",{"membership":"join"},["$5-NJmcdFRgAr7WxaRsrfIZu9b6nZxooBKxqJGH3S1Y4","$65tKzdNMXcA9NbAK3Xxbh33DpT6_vuO0iF9HUNlv8uY"],4],["$4TozkxQjWGyrHM3Yzt1i6rb_DGF4TK2RnXRSMbYx4YE","m.room.member","@c:x","@c:x",{"membership":"join"},["$5-NJmcdFRgAr7WxaRsrfIZu9b6nZxooBKxqJGH3S1Y4","$65tKzdNMXcA9NbAK3Xxbh33DpT6_vuO0iF9HUNlv8uY"],5],["$fp0FeP-WaB-LmaxAnhaZySp0_niv_hRxr7SWyo6rpts","m.room.member","@d:x","@d:x",{"membership":"join"},["$5-NJmcdFRgAr7WxaRsrfIZu9b6nZxooBKxqJGH3S1Y4","$65tKzdNMXcA9NbAK3Xxbh33DpT6_vuO0iF9HUNlv8uY"],1],["$xWtoMTFi9qNAjDGuZzma0F5Tc-rAiSJUyeHs1XfEf-4","m.room.member","@e:x","@d:x",{"membership":"leave"},["$65tKzdNMXcA9NbAK3Xxbh33DpT6_vuO0iF9HUNlv8uY","$fp0FeP-WaB-LmaxAnhaZySp0_niv_hRxr7SWyo6rpts"],1],["$dfTmmIcC19P0Hj1WaaRF82Vxsb0bX3aAQxbfCl1gr_g","m.room.topic","","@a:x",{"topic":"0"},["$65tKzdNMXcA9NbAK3Xxbh33DpT6_vuO0iF9HUNlv8uY","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],5],["$ldhRxFHs375xEeO2-ay7-avOBmMSMt2WlBVonfrp1oY","m.room.member","@b:x","@c:x",{"membership":"leave"},["$65tKzdNMXcA9NbAK3Xxbh33DpT6_vuO0iF9HUNlv8uY","$IJYHl_HNTDjX7wMgllBMnZu2ruLYA333r9YewtYvAH8","$4TozkxQjWGyrHM3Yzt1i6rb_DGF4TK2RnXRSMbYx4YE"],4],["$DVvD1MlXod5_5qzoLvCXUTNdQ-INYfVgJrXwoa37wyE","m.room.name","","@c:x",{"name":"1"},["$65tKzdNMXcA9NbAK3Xxbh33DpT6_vuO0iF9HUNlv8uY","$4TozkxQjWGyrHM3Yzt1i6rb_DGF4TK2RnXRSMbYx4YE"],2],["$HqGHSm4AQbnuqhvn0FS2-FDuFxL1DOs0lXQcT9Mf8RE","m.room.name","","@a:x",{"name":"2"},["$65tKzdNMXcA9NbAK3Xxbh33DpT6_vuO0iF9HUNlv8uY","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],3]],"states":[["$4TozkxQjWGyrHM3Yzt1i6rb_DGF4TK2RnXRSMbYx4YE","$5-NJmcdFRgAr7WxaRsrfIZu9b6nZxooBKxqJGH3S1Y4","$65tKzdNMXcA9NbAK3Xxbh33DpT6_vuO0iF9HUNlv8uY","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","$HqGHSm4AQbnuqhvn0FS2-FDuFxL1DOs0lXQcT9Mf8RE","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$dfTmmIcC19P0Hj1WaaRF82Vxsb0bX3aAQxbfCl1gr_g","$fp0FeP-WaB-LmaxAnhaZySp0_niv_hRxr7SWyo6rpts","$ldhRxFHs375xEeO2-ay7-avOBmMSMt2WlBVonfrp1oY","$xWtoMTFi9qNAjDGuZzma0F5Tc-rAiSJUyeHs1XfEf-4"],["$4TozkxQjWGyrHM3Yzt1i6rb_DGF4TK2RnXRSMbYx4YE","$5-NJmcdFRgAr7WxaRsrfIZu9b6nZxooBKxqJGH3S1Y4","$65tKzdNMXcA9NbAK3Xxbh33DpT6_vuO0iF9HUNlv8uY","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","$DVvD1MlXod5_5qzoLvCXUTNdQ-INYfVgJrXwoa37wyE","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$dfTmmIcC19P0Hj1WaaRF82Vxsb0bX3aAQxbfCl1gr_g","$fp0FeP-WaB-LmaxAnhaZySp0_niv_hRxr7SWyo6rpts","$ldhRxFHs375xEeO2-ay7-avOBmMSMt2WlBVonfrp1oY","$xWtoMTFi9qNAjDGuZzma0F5Tc-rAiSJUyeHs1XfEf-4"]],"resolved":["$4TozkxQjWGyrHM3Yzt1i6rb_DGF4TK2RnXRSMbYx4YE","$5-NJmcdFRgAr7WxaRsrfIZu9b6nZxooBKxqJGH3S1Y4","$65tKzdNMXcA9NbAK3Xxbh33DpT6_vuO0iF9HUNlv8uY","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","$HqGHSm4AQbnuqhvn0FS2-FDuFxL1DOs0lXQcT9Mf8RE","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$dfTmmIcC19P0Hj1WaaRF82Vxsb0bX3aAQxbfCl1gr_g","$fp0FeP-WaB-LmaxAnhaZySp0_niv_hRxr7SWyo6rpts","$ldhRxFHs375xEeO2-ay7-avOBmMSMt2WlBVonfrp1oY","$xWtoMTFi9qNAjDGuZzma0F5Tc-rAiSJUyeHs1XfEf-4"]}, +{"name":"ts-938","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$4ytwOQEC05TREGvO_r1iXi3-RDmPoSDG5zX0cbafX-o","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@b:x":50,"@c:x":0,"@d:x":50}},["$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],4],["$rm35MVFNQwj0izG3_Qy32xckQ6uR08bDK8YVN_a6Lp0","m.room.join_rules","","@a:x",{"join_rule":"public"},["$4ytwOQEC05TREGvO_r1iXi3-RDmPoSDG5zX0cbafX-o","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],3],["$2slDbRJY6zxJyBQvJJ-xah_imuXbhrj0tOVDxEZWRHk","m.room.member","@b:x","@b:x",{"membership":"join"},["$rm35MVFNQwj0izG3_Qy32xckQ6uR08bDK8YVN_a6Lp0","$4ytwOQEC05TREGvO_r1iXi3-RDmPoSDG5zX0cbafX-o"],0],["$MdIDOJS_RKQ3DoRwaQLKJPNUPE9ix7h2yWbNBEP127k","m.room.member","@c:x","@c:x",{"membership":"join"},["$rm35MVFNQwj0izG3_Qy32xckQ6uR08bDK8YVN_a6Lp0","$4ytwOQEC05TREGvO_r1iXi3-RDmPoSDG5zX0cbafX-o"],4],["$SwujsL2jgaYJ5Qr4NP-DlLBqPt06C9iS9QKQPnk1IC4","m.room.member","@d:x","@d:x",{"membership":"join"},["$rm35MVFNQwj0izG3_Qy32xckQ6uR08bDK8YVN_a6Lp0","$4ytwOQEC05TREGvO_r1iXi3-RDmPoSDG5zX0cbafX-o"],5],["$w6ES3WifShh1lGNodjX92gB7eDw6NKlU4d4x97A6w-s","m.room.topic","","@a:x",{"topic":"base"},["$4ytwOQEC05TREGvO_r1iXi3-RDmPoSDG5zX0cbafX-o","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],0],["$f1TnmOzbhFdwQyWX6XNBbdPJerA5zVjZJLW_SSgl5jY","m.room.name","","@a:x",{"name":"base"},["$4ytwOQEC05TREGvO_r1iXi3-RDmPoSDG5zX0cbafX-o","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],3],["$7r9zEKZJaJPmAPyyjCOU825NnSk49p5mjPXrJcX37_c","m.room.member","@c:x","@c:x",{"membership":"leave"},["$4ytwOQEC05TREGvO_r1iXi3-RDmPoSDG5zX0cbafX-o","$MdIDOJS_RKQ3DoRwaQLKJPNUPE9ix7h2yWbNBEP127k"],1],["$ikrQtYTBBDKcxhMfMkB3u2SO6Z-RqvPvVJ02rcEe34Q","m.room.join_rules","","@a:x",{"join_rule":"public"},["$4ytwOQEC05TREGvO_r1iXi3-RDmPoSDG5zX0cbafX-o","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],2]],"states":[["$2slDbRJY6zxJyBQvJJ-xah_imuXbhrj0tOVDxEZWRHk","$4ytwOQEC05TREGvO_r1iXi3-RDmPoSDG5zX0cbafX-o","$7r9zEKZJaJPmAPyyjCOU825NnSk49p5mjPXrJcX37_c","$SwujsL2jgaYJ5Qr4NP-DlLBqPt06C9iS9QKQPnk1IC4","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$f1TnmOzbhFdwQyWX6XNBbdPJerA5zVjZJLW_SSgl5jY","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$rm35MVFNQwj0izG3_Qy32xckQ6uR08bDK8YVN_a6Lp0","$w6ES3WifShh1lGNodjX92gB7eDw6NKlU4d4x97A6w-s"],["$2slDbRJY6zxJyBQvJJ-xah_imuXbhrj0tOVDxEZWRHk","$4ytwOQEC05TREGvO_r1iXi3-RDmPoSDG5zX0cbafX-o","$MdIDOJS_RKQ3DoRwaQLKJPNUPE9ix7h2yWbNBEP127k","$SwujsL2jgaYJ5Qr4NP-DlLBqPt06C9iS9QKQPnk1IC4","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$f1TnmOzbhFdwQyWX6XNBbdPJerA5zVjZJLW_SSgl5jY","$ikrQtYTBBDKcxhMfMkB3u2SO6Z-RqvPvVJ02rcEe34Q","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$w6ES3WifShh1lGNodjX92gB7eDw6NKlU4d4x97A6w-s"]],"resolved":["$2slDbRJY6zxJyBQvJJ-xah_imuXbhrj0tOVDxEZWRHk","$4ytwOQEC05TREGvO_r1iXi3-RDmPoSDG5zX0cbafX-o","$MdIDOJS_RKQ3DoRwaQLKJPNUPE9ix7h2yWbNBEP127k","$SwujsL2jgaYJ5Qr4NP-DlLBqPt06C9iS9QKQPnk1IC4","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$f1TnmOzbhFdwQyWX6XNBbdPJerA5zVjZJLW_SSgl5jY","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$rm35MVFNQwj0izG3_Qy32xckQ6uR08bDK8YVN_a6Lp0","$w6ES3WifShh1lGNodjX92gB7eDw6NKlU4d4x97A6w-s"]}, +{"name":"ts-948","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$YekW7GhUs2Ozd_BWhCVJ4bqnYoIf_pVXlF10y5WJwcw","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":50,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@b:x":0,"@c:x":0,"@d:x":25}},["$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],2],["$ioYibNRCm3cqVYf5EZBuc_o2HskKgh3hSnQ4pcucHI0","m.room.join_rules","","@a:x",{"join_rule":"public"},["$YekW7GhUs2Ozd_BWhCVJ4bqnYoIf_pVXlF10y5WJwcw","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],1],["$Jd6N6FVmg7PYbzMqswviOHySJIDKOsg0FfpnJTwWSmE","m.room.member","@b:x","@b:x",{"membership":"join"},["$ioYibNRCm3cqVYf5EZBuc_o2HskKgh3hSnQ4pcucHI0","$YekW7GhUs2Ozd_BWhCVJ4bqnYoIf_pVXlF10y5WJwcw"],4],["$baDy19kBV0OqID6GMEOqqMXfux6CqxbN1SpDgeLkdPY","m.room.member","@c:x","@c:x",{"membership":"join"},["$ioYibNRCm3cqVYf5EZBuc_o2HskKgh3hSnQ4pcucHI0","$YekW7GhUs2Ozd_BWhCVJ4bqnYoIf_pVXlF10y5WJwcw"],2],["$Tq3Evf38QZm3OKr61oFDsvm8GbuNAosAEdAQ5YMCA08","m.room.member","@d:x","@d:x",{"membership":"join"},["$ioYibNRCm3cqVYf5EZBuc_o2HskKgh3hSnQ4pcucHI0","$YekW7GhUs2Ozd_BWhCVJ4bqnYoIf_pVXlF10y5WJwcw"],1],["$WlEbe2y_nepO9OUObTRUtvNr62arJHK8VuiHVZP0Mms","m.room.topic","","@a:x",{"topic":"base"},["$YekW7GhUs2Ozd_BWhCVJ4bqnYoIf_pVXlF10y5WJwcw","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],5],["$ictvj3oI5TxeY9yOxOk2uIx38tLULEdl7YAabR8JxTQ","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$ioYibNRCm3cqVYf5EZBuc_o2HskKgh3hSnQ4pcucHI0","$YekW7GhUs2Ozd_BWhCVJ4bqnYoIf_pVXlF10y5WJwcw"],1],["$tJuaPCINLeznakbgfSpiEtE7hmBBSgA-uyfPoe6Eyj4","m.room.name","","@d:x",{"name":"1"},["$YekW7GhUs2Ozd_BWhCVJ4bqnYoIf_pVXlF10y5WJwcw","$Tq3Evf38QZm3OKr61oFDsvm8GbuNAosAEdAQ5YMCA08"],0],["$QnSn0cpaSTikmW8pX3sAiHispwj8qAojN7lVdVDUhvI","m.room.name","","@b:x",{"name":"2"},["$YekW7GhUs2Ozd_BWhCVJ4bqnYoIf_pVXlF10y5WJwcw","$Jd6N6FVmg7PYbzMqswviOHySJIDKOsg0FfpnJTwWSmE"],0],["$kuQIdl_N8PREjMQ3_7F68ZockU7YqpLDWvruZBAXLEw","m.room.topic","","@a:x",{"topic":"3"},["$YekW7GhUs2Ozd_BWhCVJ4bqnYoIf_pVXlF10y5WJwcw","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],1]],"states":[["$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","$Jd6N6FVmg7PYbzMqswviOHySJIDKOsg0FfpnJTwWSmE","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Tq3Evf38QZm3OKr61oFDsvm8GbuNAosAEdAQ5YMCA08","$WlEbe2y_nepO9OUObTRUtvNr62arJHK8VuiHVZP0Mms","$YekW7GhUs2Ozd_BWhCVJ4bqnYoIf_pVXlF10y5WJwcw","$baDy19kBV0OqID6GMEOqqMXfux6CqxbN1SpDgeLkdPY","$ictvj3oI5TxeY9yOxOk2uIx38tLULEdl7YAabR8JxTQ","$ioYibNRCm3cqVYf5EZBuc_o2HskKgh3hSnQ4pcucHI0","$tJuaPCINLeznakbgfSpiEtE7hmBBSgA-uyfPoe6Eyj4"],["$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","$Jd6N6FVmg7PYbzMqswviOHySJIDKOsg0FfpnJTwWSmE","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$QnSn0cpaSTikmW8pX3sAiHispwj8qAojN7lVdVDUhvI","$Tq3Evf38QZm3OKr61oFDsvm8GbuNAosAEdAQ5YMCA08","$YekW7GhUs2Ozd_BWhCVJ4bqnYoIf_pVXlF10y5WJwcw","$baDy19kBV0OqID6GMEOqqMXfux6CqxbN1SpDgeLkdPY","$ictvj3oI5TxeY9yOxOk2uIx38tLULEdl7YAabR8JxTQ","$ioYibNRCm3cqVYf5EZBuc_o2HskKgh3hSnQ4pcucHI0","$kuQIdl_N8PREjMQ3_7F68ZockU7YqpLDWvruZBAXLEw"]],"resolved":["$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","$Jd6N6FVmg7PYbzMqswviOHySJIDKOsg0FfpnJTwWSmE","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Tq3Evf38QZm3OKr61oFDsvm8GbuNAosAEdAQ5YMCA08","$WlEbe2y_nepO9OUObTRUtvNr62arJHK8VuiHVZP0Mms","$YekW7GhUs2Ozd_BWhCVJ4bqnYoIf_pVXlF10y5WJwcw","$baDy19kBV0OqID6GMEOqqMXfux6CqxbN1SpDgeLkdPY","$ictvj3oI5TxeY9yOxOk2uIx38tLULEdl7YAabR8JxTQ","$ioYibNRCm3cqVYf5EZBuc_o2HskKgh3hSnQ4pcucHI0","$tJuaPCINLeznakbgfSpiEtE7hmBBSgA-uyfPoe6Eyj4"]}, +{"name":"ts-960","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":25,"m.room.power_levels":50,"m.room.topic":25},"kick":75,"state_default":50,"users":{"@b:x":75,"@c:x":75,"@d:x":25}},["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],5],["$-OjSRyWW1K4RK5vsZYOiA0nZbpCxGVNzy9_f7ZtVXf4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],3],["$AJF08sFnaCEiuaMJ0UpbmmO4QzHjHUeEya-IbXNmLC0","m.room.member","@b:x","@b:x",{"membership":"join"},["$-OjSRyWW1K4RK5vsZYOiA0nZbpCxGVNzy9_f7ZtVXf4","$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw"],1],["$Fuz8OgWF7C7xYwcNRi3m0isAqI-SsnxrTAWEM4guLg4","m.room.member","@c:x","@c:x",{"membership":"join"},["$-OjSRyWW1K4RK5vsZYOiA0nZbpCxGVNzy9_f7ZtVXf4","$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw"],5],["$iuvNjvzt5ERbdAg9ZMkMaxtDZt5LqZJmo6V7pBvtyMI","m.room.member","@d:x","@d:x",{"membership":"join"},["$-OjSRyWW1K4RK5vsZYOiA0nZbpCxGVNzy9_f7ZtVXf4","$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw"],0],["$fOYbVb_H3m_hETFkYqqsHRUy0OMQMBFOb5S9cmYbpqI","m.room.topic","","@a:x",{"topic":"base"},["$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],4],["$lPCGIGslriCTiFeb-8T0GKxifLYZexs1XyPnWC8H0gY","m.room.name","","@a:x",{"name":"base"},["$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],4],["$Lv7tL8r0HA4H_-oRz02kIftpT1JkdwB69pS-KYIPkL4","m.room.member","@d:x","@d:x",{"membership":"leave"},["$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw","$iuvNjvzt5ERbdAg9ZMkMaxtDZt5LqZJmo6V7pBvtyMI"],1],["$12X8wOBVjTFXvQy1oBOTtZlyncWBwlPHCqOyDJMFxaM","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$-OjSRyWW1K4RK5vsZYOiA0nZbpCxGVNzy9_f7ZtVXf4","$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw"],2],["$bc-SJEJ-NkgCvo880ZAnqWISBX1PHQBYWoLwHGHvs8k","m.room.member","@e:x","@e:x",{"displayname":"1","membership":"join"},["$-OjSRyWW1K4RK5vsZYOiA0nZbpCxGVNzy9_f7ZtVXf4","$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw"],1],["$ORC0iyx_McMKnBQpJzQW41aqe8GIHx8YnYqoWB461EY","m.room.join_rules","","@c:x",{"join_rule":"public"},["$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw","$Fuz8OgWF7C7xYwcNRi3m0isAqI-SsnxrTAWEM4guLg4"],2]],"states":[["$-OjSRyWW1K4RK5vsZYOiA0nZbpCxGVNzy9_f7ZtVXf4","$12X8wOBVjTFXvQy1oBOTtZlyncWBwlPHCqOyDJMFxaM","$AJF08sFnaCEiuaMJ0UpbmmO4QzHjHUeEya-IbXNmLC0","$Fuz8OgWF7C7xYwcNRi3m0isAqI-SsnxrTAWEM4guLg4","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$fOYbVb_H3m_hETFkYqqsHRUy0OMQMBFOb5S9cmYbpqI","$iuvNjvzt5ERbdAg9ZMkMaxtDZt5LqZJmo6V7pBvtyMI","$lPCGIGslriCTiFeb-8T0GKxifLYZexs1XyPnWC8H0gY","$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],["$AJF08sFnaCEiuaMJ0UpbmmO4QzHjHUeEya-IbXNmLC0","$Fuz8OgWF7C7xYwcNRi3m0isAqI-SsnxrTAWEM4guLg4","$Lv7tL8r0HA4H_-oRz02kIftpT1JkdwB69pS-KYIPkL4","$ORC0iyx_McMKnBQpJzQW41aqe8GIHx8YnYqoWB461EY","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$fOYbVb_H3m_hETFkYqqsHRUy0OMQMBFOb5S9cmYbpqI","$lPCGIGslriCTiFeb-8T0GKxifLYZexs1XyPnWC8H0gY","$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],["$-OjSRyWW1K4RK5vsZYOiA0nZbpCxGVNzy9_f7ZtVXf4","$AJF08sFnaCEiuaMJ0UpbmmO4QzHjHUeEya-IbXNmLC0","$Fuz8OgWF7C7xYwcNRi3m0isAqI-SsnxrTAWEM4guLg4","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$bc-SJEJ-NkgCvo880ZAnqWISBX1PHQBYWoLwHGHvs8k","$fOYbVb_H3m_hETFkYqqsHRUy0OMQMBFOb5S9cmYbpqI","$iuvNjvzt5ERbdAg9ZMkMaxtDZt5LqZJmo6V7pBvtyMI","$lPCGIGslriCTiFeb-8T0GKxifLYZexs1XyPnWC8H0gY","$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"]],"resolved":["$12X8wOBVjTFXvQy1oBOTtZlyncWBwlPHCqOyDJMFxaM","$AJF08sFnaCEiuaMJ0UpbmmO4QzHjHUeEya-IbXNmLC0","$Fuz8OgWF7C7xYwcNRi3m0isAqI-SsnxrTAWEM4guLg4","$Lv7tL8r0HA4H_-oRz02kIftpT1JkdwB69pS-KYIPkL4","$ORC0iyx_McMKnBQpJzQW41aqe8GIHx8YnYqoWB461EY","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$fOYbVb_H3m_hETFkYqqsHRUy0OMQMBFOb5S9cmYbpqI","$lPCGIGslriCTiFeb-8T0GKxifLYZexs1XyPnWC8H0gY","$nRefaLs-AuuD4zkw31Zr56jOKFht_R0Y6Wm7s_KUtpw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"]}, +{"name":"ts-963","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":0},"kick":75,"state_default":0,"users":{"@b:x":25,"@c:x":75,"@d:x":75}},["$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],1],["$oqnd8UnAwOTjY_HoZ-BQ1WxiFQpTRmm1wxqxuEbmD8g","m.room.join_rules","","@a:x",{"join_rule":"public"},["$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],2],["$Ozzfmjq1r9beLGWHjqCUFUTJZfiXXhcIZ-SAnXWaAXQ","m.room.member","@b:x","@b:x",{"membership":"join"},["$oqnd8UnAwOTjY_HoZ-BQ1WxiFQpTRmm1wxqxuEbmD8g","$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8"],2],["$0AKJU8vBsFfTpX8vBgdSxTQQo1tv2r8zajpxr2ba1VE","m.room.member","@c:x","@c:x",{"membership":"join"},["$oqnd8UnAwOTjY_HoZ-BQ1WxiFQpTRmm1wxqxuEbmD8g","$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8"],4],["$FlbnYnbyJmbiuSc83BypI3vCSi7vz1KeOUeVKQh-4mo","m.room.member","@d:x","@d:x",{"membership":"join"},["$oqnd8UnAwOTjY_HoZ-BQ1WxiFQpTRmm1wxqxuEbmD8g","$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8"],1],["$1d4sH-El6SrEtfdXR6UxgAmrkUihtuwqgMwmyp81nYc","m.room.name","","@c:x",{"name":"0"},["$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8","$0AKJU8vBsFfTpX8vBgdSxTQQo1tv2r8zajpxr2ba1VE"],1],["$SiJ9aZz9K-eGcumD5wz8kD5j9U3WlDlVSPWpf28jWvI","m.room.join_rules","","@d:x",{"join_rule":"public"},["$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8","$FlbnYnbyJmbiuSc83BypI3vCSi7vz1KeOUeVKQh-4mo"],1],["$EExBN7NmhqF92tZv0zoUZqmN_8H1aEypApeYPwTjkbo","m.room.join_rules","","@a:x",{"join_rule":"public"},["$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],1],["$ttUpXWwC7NOX38lbXV2MAq53z0bcp4UX7UzbndOKHLQ","m.room.topic","","@a:x",{"topic":"1"},["$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],0],["$kF_N_3dG_MY5y3CrWTD-NscCoRKuEzIdpK5LRyDgQew","m.room.topic","","@b:x",{"topic":"2"},["$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8","$Ozzfmjq1r9beLGWHjqCUFUTJZfiXXhcIZ-SAnXWaAXQ"],5],["$rxkhIxRZuRW_0yUHld1uhrQoF8jli6seaGu5EVR_ncU","m.room.member","@e:x","@e:x",{"displayname":"3","membership":"join"},["$EExBN7NmhqF92tZv0zoUZqmN_8H1aEypApeYPwTjkbo","$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8"],3],["$WIQkaMxkJ_Kdv4EK_wzIC1MIWzqqJNEBZZm57HQ19g4","m.room.member","@e:x","@e:x",{"displayname":"4","membership":"join"},["$SiJ9aZz9K-eGcumD5wz8kD5j9U3WlDlVSPWpf28jWvI","$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8"],5],["$wb5WeqpXeRC-Wckglm2ljF5AGwQSiBDu70Kq9k9tMQY","m.room.name","","@c:x",{"name":"5"},["$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8","$0AKJU8vBsFfTpX8vBgdSxTQQo1tv2r8zajpxr2ba1VE"],3]],"states":[["$0AKJU8vBsFfTpX8vBgdSxTQQo1tv2r8zajpxr2ba1VE","$1d4sH-El6SrEtfdXR6UxgAmrkUihtuwqgMwmyp81nYc","$FlbnYnbyJmbiuSc83BypI3vCSi7vz1KeOUeVKQh-4mo","$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8","$Ozzfmjq1r9beLGWHjqCUFUTJZfiXXhcIZ-SAnXWaAXQ","$SiJ9aZz9K-eGcumD5wz8kD5j9U3WlDlVSPWpf28jWvI","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","$WIQkaMxkJ_Kdv4EK_wzIC1MIWzqqJNEBZZm57HQ19g4","$kF_N_3dG_MY5y3CrWTD-NscCoRKuEzIdpK5LRyDgQew","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"],["$0AKJU8vBsFfTpX8vBgdSxTQQo1tv2r8zajpxr2ba1VE","$EExBN7NmhqF92tZv0zoUZqmN_8H1aEypApeYPwTjkbo","$FlbnYnbyJmbiuSc83BypI3vCSi7vz1KeOUeVKQh-4mo","$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8","$Ozzfmjq1r9beLGWHjqCUFUTJZfiXXhcIZ-SAnXWaAXQ","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$rxkhIxRZuRW_0yUHld1uhrQoF8jli6seaGu5EVR_ncU","$ttUpXWwC7NOX38lbXV2MAq53z0bcp4UX7UzbndOKHLQ","$wb5WeqpXeRC-Wckglm2ljF5AGwQSiBDu70Kq9k9tMQY"]],"resolved":["$0AKJU8vBsFfTpX8vBgdSxTQQo1tv2r8zajpxr2ba1VE","$FlbnYnbyJmbiuSc83BypI3vCSi7vz1KeOUeVKQh-4mo","$ItD91b3h8wanozUCMkONmqLoPoc1PFZSLdyvLYhdCh8","$Ozzfmjq1r9beLGWHjqCUFUTJZfiXXhcIZ-SAnXWaAXQ","$SiJ9aZz9K-eGcumD5wz8kD5j9U3WlDlVSPWpf28jWvI","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","$WIQkaMxkJ_Kdv4EK_wzIC1MIWzqqJNEBZZm57HQ19g4","$kF_N_3dG_MY5y3CrWTD-NscCoRKuEzIdpK5LRyDgQew","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$wb5WeqpXeRC-Wckglm2ljF5AGwQSiBDu70Kq9k9tMQY"]}, +{"name":"ts-965","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":25},"kick":25,"state_default":50,"users":{"@b:x":0,"@c:x":75,"@d:x":75}},["$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"],1],["$ga_FQ5nMKKlH5q3HYPLma720gmoGOQLeThTDHBIeP_k","m.room.join_rules","","@a:x",{"join_rule":"public"},["$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI","$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"],5],["$prYz-hps63Fqldrs5Tzly7nYfyZfzqlFUthS8uj6gtA","m.room.member","@b:x","@b:x",{"membership":"join"},["$ga_FQ5nMKKlH5q3HYPLma720gmoGOQLeThTDHBIeP_k","$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI"],3],["$M3pURUYFziTVN1YFQSQ4FFs3Ze-hm-GzyVQEDXg-6O0","m.room.member","@c:x","@c:x",{"membership":"join"},["$ga_FQ5nMKKlH5q3HYPLma720gmoGOQLeThTDHBIeP_k","$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI"],2],["$K-DrwsbYyU3LMhYsoQ9eOMGGsUqqhPQYkrkI7byvuQQ","m.room.member","@d:x","@d:x",{"membership":"join"},["$ga_FQ5nMKKlH5q3HYPLma720gmoGOQLeThTDHBIeP_k","$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI"],1],["$MtoIyF_yMi63BfXF5ARKkL57a3Gzk9otU045vegLrvk","m.room.topic","","@a:x",{"topic":"base"},["$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI","$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"],3],["$PWISVcaCogLH8GBOFv62n9Z-tJVOlxTfaSr6J69iAPA","m.room.name","","@a:x",{"name":"base"},["$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI","$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"],3],["$CerTCXDdNaH_vlVP4-sVOA9EIunelsUtI4Jx-ymXjPQ","m.room.join_rules","","@a:x",{"join_rule":"public"},["$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI","$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"],0],["$MEgwP3RH4k3SrRbaBxtWJVgFwrKSpU3MqQxTW-Xm7vc","m.room.member","@d:x","@d:x",{"membership":"leave"},["$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI","$K-DrwsbYyU3LMhYsoQ9eOMGGsUqqhPQYkrkI7byvuQQ"],4],["$PrdTXBqtKp0oKOd-68ZpFJPBmFi1kX_93m3hte8XHos","m.room.join_rules","","@c:x",{"join_rule":"invite"},["$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI","$M3pURUYFziTVN1YFQSQ4FFs3Ze-hm-GzyVQEDXg-6O0"],3],["$ZDQT3yH4TfxtvpUCQqi1gdqVHE4213PIGD3d_xOs6Rg","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$CerTCXDdNaH_vlVP4-sVOA9EIunelsUtI4Jx-ymXjPQ","$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI","$M3pURUYFziTVN1YFQSQ4FFs3Ze-hm-GzyVQEDXg-6O0"],2],["$fNVKGnpw_83lyMVEz5FeYr7ZMBntlGhX6QTrlLRPy-M","m.room.member","@b:x","@b:x",{"membership":"leave"},["$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI","$prYz-hps63Fqldrs5Tzly7nYfyZfzqlFUthS8uj6gtA"],2]],"states":[["$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI","$CerTCXDdNaH_vlVP4-sVOA9EIunelsUtI4Jx-ymXjPQ","$MEgwP3RH4k3SrRbaBxtWJVgFwrKSpU3MqQxTW-Xm7vc","$MtoIyF_yMi63BfXF5ARKkL57a3Gzk9otU045vegLrvk","$PWISVcaCogLH8GBOFv62n9Z-tJVOlxTfaSr6J69iAPA","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$ZDQT3yH4TfxtvpUCQqi1gdqVHE4213PIGD3d_xOs6Rg","$prYz-hps63Fqldrs5Tzly7nYfyZfzqlFUthS8uj6gtA","$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"],["$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI","$K-DrwsbYyU3LMhYsoQ9eOMGGsUqqhPQYkrkI7byvuQQ","$M3pURUYFziTVN1YFQSQ4FFs3Ze-hm-GzyVQEDXg-6O0","$MtoIyF_yMi63BfXF5ARKkL57a3Gzk9otU045vegLrvk","$PWISVcaCogLH8GBOFv62n9Z-tJVOlxTfaSr6J69iAPA","$PrdTXBqtKp0oKOd-68ZpFJPBmFi1kX_93m3hte8XHos","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$fNVKGnpw_83lyMVEz5FeYr7ZMBntlGhX6QTrlLRPy-M","$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"]],"resolved":["$2qZzh720OP300ya9BQCJ73kw7rKMuRAqoT3FbAIj2HI","$MEgwP3RH4k3SrRbaBxtWJVgFwrKSpU3MqQxTW-Xm7vc","$MtoIyF_yMi63BfXF5ARKkL57a3Gzk9otU045vegLrvk","$PWISVcaCogLH8GBOFv62n9Z-tJVOlxTfaSr6J69iAPA","$PrdTXBqtKp0oKOd-68ZpFJPBmFi1kX_93m3hte8XHos","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$ZDQT3yH4TfxtvpUCQqi1gdqVHE4213PIGD3d_xOs6Rg","$fNVKGnpw_83lyMVEz5FeYr7ZMBntlGhX6QTrlLRPy-M","$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"]}, +{"name":"ts-980","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$MLDIpgPXtqHojOz7KbmBqYJHYKW7XmS8Gr-RHJ2geCk","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":0},"kick":75,"state_default":50,"users":{"@b:x":0,"@c:x":0,"@d:x":25}},["$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],5],["$qGVbAIOxDDZTJ3tm-2vu9vKoLPYkyMKXgzut9u8Mjlw","m.room.join_rules","","@a:x",{"join_rule":"public"},["$MLDIpgPXtqHojOz7KbmBqYJHYKW7XmS8Gr-RHJ2geCk","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],2],["$MMVZ1Wvinc5IiKT-EUZ28fYhbTlZ1dKiui1fPwj_Jyk","m.room.member","@b:x","@b:x",{"membership":"join"},["$qGVbAIOxDDZTJ3tm-2vu9vKoLPYkyMKXgzut9u8Mjlw","$MLDIpgPXtqHojOz7KbmBqYJHYKW7XmS8Gr-RHJ2geCk"],0],["$T2_E4q_rflRKb17sXEmpMBYwjjpjxnE9vCJWS8BU5dw","m.room.member","@c:x","@c:x",{"membership":"join"},["$qGVbAIOxDDZTJ3tm-2vu9vKoLPYkyMKXgzut9u8Mjlw","$MLDIpgPXtqHojOz7KbmBqYJHYKW7XmS8Gr-RHJ2geCk"],2],["$9pWtXji07Z1MIsjX4I9a2ybwYbdSQFL4FIliX7CEW94","m.room.member","@d:x","@d:x",{"membership":"join"},["$qGVbAIOxDDZTJ3tm-2vu9vKoLPYkyMKXgzut9u8Mjlw","$MLDIpgPXtqHojOz7KbmBqYJHYKW7XmS8Gr-RHJ2geCk"],0],["$1B-zHNJRwynkGWV-tQVe-mtj3qDk9nLuGbIcI3wx8yM","m.room.topic","","@a:x",{"topic":"base"},["$MLDIpgPXtqHojOz7KbmBqYJHYKW7XmS8Gr-RHJ2geCk","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],1],["$NlbBjmM27BT8Mw3NvUyJBgf8zrlK3XIjjEm3z_PPeR0","m.room.name","","@a:x",{"name":"base"},["$MLDIpgPXtqHojOz7KbmBqYJHYKW7XmS8Gr-RHJ2geCk","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],0],["$ka3Ixg5I6d929ednbfc-pB3FLgTyug7Hh1C0QjP5c4Y","m.room.member","@b:x","@b:x",{"membership":"leave"},["$MLDIpgPXtqHojOz7KbmBqYJHYKW7XmS8Gr-RHJ2geCk","$MMVZ1Wvinc5IiKT-EUZ28fYhbTlZ1dKiui1fPwj_Jyk"],3],["$hoOIzRXMPc0TwvcFKAgOmu9WXiOKH9YaxUc0_tYtnGs","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$qGVbAIOxDDZTJ3tm-2vu9vKoLPYkyMKXgzut9u8Mjlw","$MLDIpgPXtqHojOz7KbmBqYJHYKW7XmS8Gr-RHJ2geCk","$ka3Ixg5I6d929ednbfc-pB3FLgTyug7Hh1C0QjP5c4Y"],3]],"states":[["$1B-zHNJRwynkGWV-tQVe-mtj3qDk9nLuGbIcI3wx8yM","$9pWtXji07Z1MIsjX4I9a2ybwYbdSQFL4FIliX7CEW94","$MLDIpgPXtqHojOz7KbmBqYJHYKW7XmS8Gr-RHJ2geCk","$NlbBjmM27BT8Mw3NvUyJBgf8zrlK3XIjjEm3z_PPeR0","$T2_E4q_rflRKb17sXEmpMBYwjjpjxnE9vCJWS8BU5dw","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","$hoOIzRXMPc0TwvcFKAgOmu9WXiOKH9YaxUc0_tYtnGs","$qGVbAIOxDDZTJ3tm-2vu9vKoLPYkyMKXgzut9u8Mjlw","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"],["$1B-zHNJRwynkGWV-tQVe-mtj3qDk9nLuGbIcI3wx8yM","$9pWtXji07Z1MIsjX4I9a2ybwYbdSQFL4FIliX7CEW94","$MLDIpgPXtqHojOz7KbmBqYJHYKW7XmS8Gr-RHJ2geCk","$NlbBjmM27BT8Mw3NvUyJBgf8zrlK3XIjjEm3z_PPeR0","$T2_E4q_rflRKb17sXEmpMBYwjjpjxnE9vCJWS8BU5dw","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","$ka3Ixg5I6d929ednbfc-pB3FLgTyug7Hh1C0QjP5c4Y","$qGVbAIOxDDZTJ3tm-2vu9vKoLPYkyMKXgzut9u8Mjlw","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"]],"resolved":["$1B-zHNJRwynkGWV-tQVe-mtj3qDk9nLuGbIcI3wx8yM","$9pWtXji07Z1MIsjX4I9a2ybwYbdSQFL4FIliX7CEW94","$MLDIpgPXtqHojOz7KbmBqYJHYKW7XmS8Gr-RHJ2geCk","$NlbBjmM27BT8Mw3NvUyJBgf8zrlK3XIjjEm3z_PPeR0","$T2_E4q_rflRKb17sXEmpMBYwjjpjxnE9vCJWS8BU5dw","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","$ka3Ixg5I6d929ednbfc-pB3FLgTyug7Hh1C0QjP5c4Y","$qGVbAIOxDDZTJ3tm-2vu9vKoLPYkyMKXgzut9u8Mjlw","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"]}, +{"name":"ts-987","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$4Fh871IUci1Z89CFxWxH-KYgaAd-6VIX3L3p5nb7364","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":50},"kick":50,"state_default":50,"users":{"@b:x":50,"@c:x":50,"@d:x":75}},["$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],4],["$pzK4Ppeo67NCJY__-T88GLTpoip4tMpneBMyq7FzoEE","m.room.join_rules","","@a:x",{"join_rule":"public"},["$4Fh871IUci1Z89CFxWxH-KYgaAd-6VIX3L3p5nb7364","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],4],["$m6rlj_ywTzZCqUNPFxLxBUP1CL113LdsVI6PTeLMDAA","m.room.member","@b:x","@b:x",{"membership":"join"},["$pzK4Ppeo67NCJY__-T88GLTpoip4tMpneBMyq7FzoEE","$4Fh871IUci1Z89CFxWxH-KYgaAd-6VIX3L3p5nb7364"],3],["$0TFgRrwJmeoPEZBmBrDS3bFZDOGYbWdwVqVYi20M6w4","m.room.member","@c:x","@c:x",{"membership":"join"},["$pzK4Ppeo67NCJY__-T88GLTpoip4tMpneBMyq7FzoEE","$4Fh871IUci1Z89CFxWxH-KYgaAd-6VIX3L3p5nb7364"],0],["$c4e0EpvenUxm_SADSaEeOv9kvlDEdjvRoVSXFJ-ed5A","m.room.member","@d:x","@d:x",{"membership":"join"},["$pzK4Ppeo67NCJY__-T88GLTpoip4tMpneBMyq7FzoEE","$4Fh871IUci1Z89CFxWxH-KYgaAd-6VIX3L3p5nb7364"],2],["$PffevUi7IZBd6FtgZF4cc1-qgULlqvo8PUg-4EoCwME","m.room.topic","","@a:x",{"topic":"base"},["$4Fh871IUci1Z89CFxWxH-KYgaAd-6VIX3L3p5nb7364","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],2],["$8xWtbUFyUGwAoO8RSZYmt3Fwiaf-xOyo01MWwGogY_8","m.room.name","","@a:x",{"name":"base"},["$4Fh871IUci1Z89CFxWxH-KYgaAd-6VIX3L3p5nb7364","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],3],["$KI1ejPbN3Gf6gjrojFRhYesb77PSrEHtVAnehtvArCc","m.room.name","","@a:x",{"name":"0"},["$4Fh871IUci1Z89CFxWxH-KYgaAd-6VIX3L3p5nb7364","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],2],["$hcauOhfOt3IJbfIOm_Gb6eGOfVBJRhRa1BQfrBjKSG4","m.room.member","@c:x","@c:x",{"membership":"leave"},["$4Fh871IUci1Z89CFxWxH-KYgaAd-6VIX3L3p5nb7364","$0TFgRrwJmeoPEZBmBrDS3bFZDOGYbWdwVqVYi20M6w4"],2]],"states":[["$4Fh871IUci1Z89CFxWxH-KYgaAd-6VIX3L3p5nb7364","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw","$KI1ejPbN3Gf6gjrojFRhYesb77PSrEHtVAnehtvArCc","$PffevUi7IZBd6FtgZF4cc1-qgULlqvo8PUg-4EoCwME","$c4e0EpvenUxm_SADSaEeOv9kvlDEdjvRoVSXFJ-ed5A","$hcauOhfOt3IJbfIOm_Gb6eGOfVBJRhRa1BQfrBjKSG4","$m6rlj_ywTzZCqUNPFxLxBUP1CL113LdsVI6PTeLMDAA","$pzK4Ppeo67NCJY__-T88GLTpoip4tMpneBMyq7FzoEE","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"],["$0TFgRrwJmeoPEZBmBrDS3bFZDOGYbWdwVqVYi20M6w4","$4Fh871IUci1Z89CFxWxH-KYgaAd-6VIX3L3p5nb7364","$8xWtbUFyUGwAoO8RSZYmt3Fwiaf-xOyo01MWwGogY_8","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw","$PffevUi7IZBd6FtgZF4cc1-qgULlqvo8PUg-4EoCwME","$c4e0EpvenUxm_SADSaEeOv9kvlDEdjvRoVSXFJ-ed5A","$m6rlj_ywTzZCqUNPFxLxBUP1CL113LdsVI6PTeLMDAA","$pzK4Ppeo67NCJY__-T88GLTpoip4tMpneBMyq7FzoEE","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"]],"resolved":["$4Fh871IUci1Z89CFxWxH-KYgaAd-6VIX3L3p5nb7364","$8xWtbUFyUGwAoO8RSZYmt3Fwiaf-xOyo01MWwGogY_8","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw","$PffevUi7IZBd6FtgZF4cc1-qgULlqvo8PUg-4EoCwME","$c4e0EpvenUxm_SADSaEeOv9kvlDEdjvRoVSXFJ-ed5A","$hcauOhfOt3IJbfIOm_Gb6eGOfVBJRhRa1BQfrBjKSG4","$m6rlj_ywTzZCqUNPFxLxBUP1CL113LdsVI6PTeLMDAA","$pzK4Ppeo67NCJY__-T88GLTpoip4tMpneBMyq7FzoEE","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"]}, +{"name":"ts-1022","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$QpOOtuf1eJjk-uimbbhREysd_pkvo-iNLldtRSQ8mO4","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":0},"kick":25,"state_default":0,"users":{"@b:x":75,"@c:x":75,"@d:x":0}},["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],2],["$J85oHI5fPhbHaqUow2s2BcwRneaaUdTTCY-mraPZmMs","m.room.join_rules","","@a:x",{"join_rule":"public"},["$QpOOtuf1eJjk-uimbbhREysd_pkvo-iNLldtRSQ8mO4","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],5],["$ckifM4-EWi53MOObUT3r1CosAid7NgWIC4WQMRSE3PI","m.room.member","@b:x","@b:x",{"membership":"join"},["$J85oHI5fPhbHaqUow2s2BcwRneaaUdTTCY-mraPZmMs","$QpOOtuf1eJjk-uimbbhREysd_pkvo-iNLldtRSQ8mO4"],0],["$Fs7sjri3ODu8BXbFLQkutpN3zD6XBwZLpwwRs4gMqjY","m.room.member","@c:x","@c:x",{"membership":"join"},["$J85oHI5fPhbHaqUow2s2BcwRneaaUdTTCY-mraPZmMs","$QpOOtuf1eJjk-uimbbhREysd_pkvo-iNLldtRSQ8mO4"],3],["$aCtwH6xGn0BfQKLVNbOIB4hToRhMyzxsSUU-NTfv1SQ","m.room.member","@d:x","@d:x",{"membership":"join"},["$J85oHI5fPhbHaqUow2s2BcwRneaaUdTTCY-mraPZmMs","$QpOOtuf1eJjk-uimbbhREysd_pkvo-iNLldtRSQ8mO4"],1],["$TWLC_TXZjdNohX1gbznl4E0BdbY_DksqUrfLvbGvky8","m.room.name","","@a:x",{"name":"base"},["$QpOOtuf1eJjk-uimbbhREysd_pkvo-iNLldtRSQ8mO4","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],2],["$olgwYiVqcziri4a0U_wt4Uv6WZ2GoiEYatgmmMVsM3A","m.room.member","@e:x","@a:x",{"membership":"invite"},["$J85oHI5fPhbHaqUow2s2BcwRneaaUdTTCY-mraPZmMs","$QpOOtuf1eJjk-uimbbhREysd_pkvo-iNLldtRSQ8mO4","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],2],["$FuZsbZMkxZ97Q0o5YPd84uMR1qSkPihBSqvwUPGD-mk","m.room.topic","","@a:x",{"topic":"0"},["$QpOOtuf1eJjk-uimbbhREysd_pkvo-iNLldtRSQ8mO4","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],3],["$EylI0rWgMBWcNumFFWqMml8u5tlfGlEPqoR1MwwMtjk","m.room.join_rules","","@a:x",{"join_rule":"public"},["$QpOOtuf1eJjk-uimbbhREysd_pkvo-iNLldtRSQ8mO4","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],0],["$1mOesBSR5pDd2UuWd1ma57SbIDFSGDSoS5SR2JPQ430","m.room.topic","","@b:x",{"topic":"1"},["$QpOOtuf1eJjk-uimbbhREysd_pkvo-iNLldtRSQ8mO4","$ckifM4-EWi53MOObUT3r1CosAid7NgWIC4WQMRSE3PI"],2]],"states":[["$1mOesBSR5pDd2UuWd1ma57SbIDFSGDSoS5SR2JPQ430","$Fs7sjri3ODu8BXbFLQkutpN3zD6XBwZLpwwRs4gMqjY","$J85oHI5fPhbHaqUow2s2BcwRneaaUdTTCY-mraPZmMs","$QpOOtuf1eJjk-uimbbhREysd_pkvo-iNLldtRSQ8mO4","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$TWLC_TXZjdNohX1gbznl4E0BdbY_DksqUrfLvbGvky8","$aCtwH6xGn0BfQKLVNbOIB4hToRhMyzxsSUU-NTfv1SQ","$ckifM4-EWi53MOObUT3r1CosAid7NgWIC4WQMRSE3PI","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$olgwYiVqcziri4a0U_wt4Uv6WZ2GoiEYatgmmMVsM3A"],["$EylI0rWgMBWcNumFFWqMml8u5tlfGlEPqoR1MwwMtjk","$Fs7sjri3ODu8BXbFLQkutpN3zD6XBwZLpwwRs4gMqjY","$FuZsbZMkxZ97Q0o5YPd84uMR1qSkPihBSqvwUPGD-mk","$QpOOtuf1eJjk-uimbbhREysd_pkvo-iNLldtRSQ8mO4","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$TWLC_TXZjdNohX1gbznl4E0BdbY_DksqUrfLvbGvky8","$aCtwH6xGn0BfQKLVNbOIB4hToRhMyzxsSUU-NTfv1SQ","$ckifM4-EWi53MOObUT3r1CosAid7NgWIC4WQMRSE3PI","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$olgwYiVqcziri4a0U_wt4Uv6WZ2GoiEYatgmmMVsM3A"]],"resolved":["$Fs7sjri3ODu8BXbFLQkutpN3zD6XBwZLpwwRs4gMqjY","$FuZsbZMkxZ97Q0o5YPd84uMR1qSkPihBSqvwUPGD-mk","$J85oHI5fPhbHaqUow2s2BcwRneaaUdTTCY-mraPZmMs","$QpOOtuf1eJjk-uimbbhREysd_pkvo-iNLldtRSQ8mO4","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$TWLC_TXZjdNohX1gbznl4E0BdbY_DksqUrfLvbGvky8","$aCtwH6xGn0BfQKLVNbOIB4hToRhMyzxsSUU-NTfv1SQ","$ckifM4-EWi53MOObUT3r1CosAid7NgWIC4WQMRSE3PI","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$olgwYiVqcziri4a0U_wt4Uv6WZ2GoiEYatgmmMVsM3A"]}, +{"name":"ts-1085","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$vIgJuNKhgr5uKtl40tf4ZDtPIitKPMNJiKPo96NxFEc","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":75},"kick":75,"state_default":0,"users":{"@b:x":50,"@c:x":50,"@d:x":0}},["$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],0],["$YVvuloAYaAjXme0NQbbLUAcUxOMp_t7J0Vg96jLlgvc","m.room.join_rules","","@a:x",{"join_rule":"public"},["$vIgJuNKhgr5uKtl40tf4ZDtPIitKPMNJiKPo96NxFEc","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],1],["$pdRra2DNy5m5uSG6CxIlDkmqjzw-FEvNIIDY4JxcuXk","m.room.member","@b:x","@b:x",{"membership":"join"},["$YVvuloAYaAjXme0NQbbLUAcUxOMp_t7J0Vg96jLlgvc","$vIgJuNKhgr5uKtl40tf4ZDtPIitKPMNJiKPo96NxFEc"],5],["$8bD4s1XQTHeo45s6q5s5khxbqBU7ayn5iiLSDXqxCyw","m.room.member","@c:x","@c:x",{"membership":"join"},["$YVvuloAYaAjXme0NQbbLUAcUxOMp_t7J0Vg96jLlgvc","$vIgJuNKhgr5uKtl40tf4ZDtPIitKPMNJiKPo96NxFEc"],1],["$kUrtNcR6Zy85D9A6tQfjvhMwA18ehTYdgcUsGtUPEeE","m.room.member","@d:x","@d:x",{"membership":"join"},["$YVvuloAYaAjXme0NQbbLUAcUxOMp_t7J0Vg96jLlgvc","$vIgJuNKhgr5uKtl40tf4ZDtPIitKPMNJiKPo96NxFEc"],1],["$M4bn4nvJ5VLLY85zyYOlwPkRz5w49C2KYuU7RcOF4Kk","m.room.topic","","@a:x",{"topic":"base"},["$vIgJuNKhgr5uKtl40tf4ZDtPIitKPMNJiKPo96NxFEc","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],1],["$Kmkifp56QNVWsyoQ1VafWpXU4J1sOVobnjhB-22hZ0o","m.room.name","","@c:x",{"name":"0"},["$vIgJuNKhgr5uKtl40tf4ZDtPIitKPMNJiKPo96NxFEc","$8bD4s1XQTHeo45s6q5s5khxbqBU7ayn5iiLSDXqxCyw"],3],["$SwyoaAYBXWtPzm5QqiyZAVyrIWDvDJGToXx61ub78bA","m.room.name","","@c:x",{"name":"1"},["$vIgJuNKhgr5uKtl40tf4ZDtPIitKPMNJiKPo96NxFEc","$8bD4s1XQTHeo45s6q5s5khxbqBU7ayn5iiLSDXqxCyw"],4],["$rlecgWSR425CAWp7ZevfA-JWrLLqH05puDxKGbBrCe8","m.room.member","@d:x","@d:x",{"displayname":"2","membership":"join"},["$YVvuloAYaAjXme0NQbbLUAcUxOMp_t7J0Vg96jLlgvc","$vIgJuNKhgr5uKtl40tf4ZDtPIitKPMNJiKPo96NxFEc","$kUrtNcR6Zy85D9A6tQfjvhMwA18ehTYdgcUsGtUPEeE"],5]],"states":[["$8bD4s1XQTHeo45s6q5s5khxbqBU7ayn5iiLSDXqxCyw","$M4bn4nvJ5VLLY85zyYOlwPkRz5w49C2KYuU7RcOF4Kk","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$SwyoaAYBXWtPzm5QqiyZAVyrIWDvDJGToXx61ub78bA","$YVvuloAYaAjXme0NQbbLUAcUxOMp_t7J0Vg96jLlgvc","$kUrtNcR6Zy85D9A6tQfjvhMwA18ehTYdgcUsGtUPEeE","$pdRra2DNy5m5uSG6CxIlDkmqjzw-FEvNIIDY4JxcuXk","$vIgJuNKhgr5uKtl40tf4ZDtPIitKPMNJiKPo96NxFEc","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],["$8bD4s1XQTHeo45s6q5s5khxbqBU7ayn5iiLSDXqxCyw","$Kmkifp56QNVWsyoQ1VafWpXU4J1sOVobnjhB-22hZ0o","$M4bn4nvJ5VLLY85zyYOlwPkRz5w49C2KYuU7RcOF4Kk","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$YVvuloAYaAjXme0NQbbLUAcUxOMp_t7J0Vg96jLlgvc","$pdRra2DNy5m5uSG6CxIlDkmqjzw-FEvNIIDY4JxcuXk","$rlecgWSR425CAWp7ZevfA-JWrLLqH05puDxKGbBrCe8","$vIgJuNKhgr5uKtl40tf4ZDtPIitKPMNJiKPo96NxFEc","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"]],"resolved":["$8bD4s1XQTHeo45s6q5s5khxbqBU7ayn5iiLSDXqxCyw","$M4bn4nvJ5VLLY85zyYOlwPkRz5w49C2KYuU7RcOF4Kk","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$SwyoaAYBXWtPzm5QqiyZAVyrIWDvDJGToXx61ub78bA","$YVvuloAYaAjXme0NQbbLUAcUxOMp_t7J0Vg96jLlgvc","$pdRra2DNy5m5uSG6CxIlDkmqjzw-FEvNIIDY4JxcuXk","$rlecgWSR425CAWp7ZevfA-JWrLLqH05puDxKGbBrCe8","$vIgJuNKhgr5uKtl40tf4ZDtPIitKPMNJiKPo96NxFEc","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"]}, +{"name":"ts-1108","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$7T5krS9EgrseP7xc0zxK0kI3QN2B2Mv5sTOb9Y4tRQY","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":0},"kick":50,"state_default":50,"users":{"@b:x":50,"@c:x":75,"@d:x":0}},["$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],0],["$LTbnqBCMEhl0TgT9CpKs2SWXcC8B6Q47VnSYbPBoHE0","m.room.join_rules","","@a:x",{"join_rule":"public"},["$7T5krS9EgrseP7xc0zxK0kI3QN2B2Mv5sTOb9Y4tRQY","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],1],["$7LnOhUgqRFmJRC0KoT_ip4S8EnMCh9m8k8tyn_arB4o","m.room.member","@b:x","@b:x",{"membership":"join"},["$LTbnqBCMEhl0TgT9CpKs2SWXcC8B6Q47VnSYbPBoHE0","$7T5krS9EgrseP7xc0zxK0kI3QN2B2Mv5sTOb9Y4tRQY"],1],["$gOF5z0ci23H2ukikYwTlhvLmqduWHcn2YZ08XUj880g","m.room.member","@c:x","@c:x",{"membership":"join"},["$LTbnqBCMEhl0TgT9CpKs2SWXcC8B6Q47VnSYbPBoHE0","$7T5krS9EgrseP7xc0zxK0kI3QN2B2Mv5sTOb9Y4tRQY"],3],["$HOEDGWwnoglPtkpOZIPVOwmuv9PvXyVvTvFS4gPtk7Y","m.room.member","@d:x","@d:x",{"membership":"join"},["$LTbnqBCMEhl0TgT9CpKs2SWXcC8B6Q47VnSYbPBoHE0","$7T5krS9EgrseP7xc0zxK0kI3QN2B2Mv5sTOb9Y4tRQY"],5],["$3Dmr1H_o1AECVtWFZggCQRKtgP7QaxlsgfZv2_YbSxs","m.room.topic","","@a:x",{"topic":"base"},["$7T5krS9EgrseP7xc0zxK0kI3QN2B2Mv5sTOb9Y4tRQY","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],4],["$i_ASWYEQkDQtAGm0bUqViRy-FNzoKZjFgnPx_6NGqrM","m.room.name","","@c:x",{"name":"0"},["$7T5krS9EgrseP7xc0zxK0kI3QN2B2Mv5sTOb9Y4tRQY","$gOF5z0ci23H2ukikYwTlhvLmqduWHcn2YZ08XUj880g"],5],["$xTDg8dq9biG16fI1xaOIOiIYH9F68xqKKbZ0jTAUZ2M","m.room.name","","@a:x",{"name":"1"},["$7T5krS9EgrseP7xc0zxK0kI3QN2B2Mv5sTOb9Y4tRQY","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],1],["$4BdlhreAZpz7vw0vLZpu6eZxYaQf6uXOYokz77VRzGw","m.room.member","@d:x","@d:x",{"displayname":"2","membership":"join"},["$LTbnqBCMEhl0TgT9CpKs2SWXcC8B6Q47VnSYbPBoHE0","$7T5krS9EgrseP7xc0zxK0kI3QN2B2Mv5sTOb9Y4tRQY","$HOEDGWwnoglPtkpOZIPVOwmuv9PvXyVvTvFS4gPtk7Y"],4]],"states":[["$3Dmr1H_o1AECVtWFZggCQRKtgP7QaxlsgfZv2_YbSxs","$4BdlhreAZpz7vw0vLZpu6eZxYaQf6uXOYokz77VRzGw","$7LnOhUgqRFmJRC0KoT_ip4S8EnMCh9m8k8tyn_arB4o","$7T5krS9EgrseP7xc0zxK0kI3QN2B2Mv5sTOb9Y4tRQY","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$LTbnqBCMEhl0TgT9CpKs2SWXcC8B6Q47VnSYbPBoHE0","$gOF5z0ci23H2ukikYwTlhvLmqduWHcn2YZ08XUj880g","$i_ASWYEQkDQtAGm0bUqViRy-FNzoKZjFgnPx_6NGqrM"],["$3Dmr1H_o1AECVtWFZggCQRKtgP7QaxlsgfZv2_YbSxs","$7LnOhUgqRFmJRC0KoT_ip4S8EnMCh9m8k8tyn_arB4o","$7T5krS9EgrseP7xc0zxK0kI3QN2B2Mv5sTOb9Y4tRQY","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$HOEDGWwnoglPtkpOZIPVOwmuv9PvXyVvTvFS4gPtk7Y","$LTbnqBCMEhl0TgT9CpKs2SWXcC8B6Q47VnSYbPBoHE0","$gOF5z0ci23H2ukikYwTlhvLmqduWHcn2YZ08XUj880g","$xTDg8dq9biG16fI1xaOIOiIYH9F68xqKKbZ0jTAUZ2M"]],"resolved":["$3Dmr1H_o1AECVtWFZggCQRKtgP7QaxlsgfZv2_YbSxs","$7LnOhUgqRFmJRC0KoT_ip4S8EnMCh9m8k8tyn_arB4o","$7T5krS9EgrseP7xc0zxK0kI3QN2B2Mv5sTOb9Y4tRQY","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$HOEDGWwnoglPtkpOZIPVOwmuv9PvXyVvTvFS4gPtk7Y","$LTbnqBCMEhl0TgT9CpKs2SWXcC8B6Q47VnSYbPBoHE0","$gOF5z0ci23H2ukikYwTlhvLmqduWHcn2YZ08XUj880g","$i_ASWYEQkDQtAGm0bUqViRy-FNzoKZjFgnPx_6NGqrM"]}, +{"name":"ts-1122","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$cmCNOQBBCG-JltHRAMujvSrsKUzUXSfEWKxYzXF79fM","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":25},"kick":50,"state_default":50,"users":{"@b:x":50,"@c:x":0,"@d:x":50}},["$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],1],["$ukXLuVjY5SsC9ft0OQSjrFnU4km1GvELFtcswdOkXQs","m.room.join_rules","","@a:x",{"join_rule":"public"},["$cmCNOQBBCG-JltHRAMujvSrsKUzUXSfEWKxYzXF79fM","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],0],["$cyQwIGC-oz0-bz59UrvftIfKQ5Sw-sw1JWV7VGrZFiE","m.room.member","@b:x","@b:x",{"membership":"join"},["$ukXLuVjY5SsC9ft0OQSjrFnU4km1GvELFtcswdOkXQs","$cmCNOQBBCG-JltHRAMujvSrsKUzUXSfEWKxYzXF79fM"],0],["$hLbi1GkuvN-WBzSutjEsEJ-aEOu8glkE2M80lqzHfzA","m.room.member","@c:x","@c:x",{"membership":"join"},["$ukXLuVjY5SsC9ft0OQSjrFnU4km1GvELFtcswdOkXQs","$cmCNOQBBCG-JltHRAMujvSrsKUzUXSfEWKxYzXF79fM"],0],["$ziOS4fkTQxW4OcyjfY13Y64GpznjbDX1B90I2-UfYD8","m.room.member","@d:x","@d:x",{"membership":"join"},["$ukXLuVjY5SsC9ft0OQSjrFnU4km1GvELFtcswdOkXQs","$cmCNOQBBCG-JltHRAMujvSrsKUzUXSfEWKxYzXF79fM"],1],["$CIjH50j6-jvONxHGIFzQK2Jed6SSVNmMEpkrIQPiyso","m.room.topic","","@a:x",{"topic":"base"},["$cmCNOQBBCG-JltHRAMujvSrsKUzUXSfEWKxYzXF79fM","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],5],["$mel1x_q8XZy5-v0aF3OjbRgWRXGQPpdqiwznNdAA7Vw","m.room.name","","@a:x",{"name":"base"},["$cmCNOQBBCG-JltHRAMujvSrsKUzUXSfEWKxYzXF79fM","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],2],["$YF9VD8UhVe5GSDY3r9UUqk-HBL67GvQkiKuGRcKpPMo","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$ukXLuVjY5SsC9ft0OQSjrFnU4km1GvELFtcswdOkXQs","$cmCNOQBBCG-JltHRAMujvSrsKUzUXSfEWKxYzXF79fM"],0],["$EilSHsyoZ8ZSlBnwpNHWuiXtkItTnA7at9Djb9ovwfk","m.room.join_rules","","@a:x",{"join_rule":"public"},["$cmCNOQBBCG-JltHRAMujvSrsKUzUXSfEWKxYzXF79fM","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],1]],"states":[["$CIjH50j6-jvONxHGIFzQK2Jed6SSVNmMEpkrIQPiyso","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8","$YF9VD8UhVe5GSDY3r9UUqk-HBL67GvQkiKuGRcKpPMo","$cmCNOQBBCG-JltHRAMujvSrsKUzUXSfEWKxYzXF79fM","$cyQwIGC-oz0-bz59UrvftIfKQ5Sw-sw1JWV7VGrZFiE","$hLbi1GkuvN-WBzSutjEsEJ-aEOu8glkE2M80lqzHfzA","$mel1x_q8XZy5-v0aF3OjbRgWRXGQPpdqiwznNdAA7Vw","$ukXLuVjY5SsC9ft0OQSjrFnU4km1GvELFtcswdOkXQs","$ziOS4fkTQxW4OcyjfY13Y64GpznjbDX1B90I2-UfYD8"],["$CIjH50j6-jvONxHGIFzQK2Jed6SSVNmMEpkrIQPiyso","$EilSHsyoZ8ZSlBnwpNHWuiXtkItTnA7at9Djb9ovwfk","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8","$cmCNOQBBCG-JltHRAMujvSrsKUzUXSfEWKxYzXF79fM","$cyQwIGC-oz0-bz59UrvftIfKQ5Sw-sw1JWV7VGrZFiE","$hLbi1GkuvN-WBzSutjEsEJ-aEOu8glkE2M80lqzHfzA","$mel1x_q8XZy5-v0aF3OjbRgWRXGQPpdqiwznNdAA7Vw","$ziOS4fkTQxW4OcyjfY13Y64GpznjbDX1B90I2-UfYD8"]],"resolved":["$CIjH50j6-jvONxHGIFzQK2Jed6SSVNmMEpkrIQPiyso","$EilSHsyoZ8ZSlBnwpNHWuiXtkItTnA7at9Djb9ovwfk","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8","$YF9VD8UhVe5GSDY3r9UUqk-HBL67GvQkiKuGRcKpPMo","$cmCNOQBBCG-JltHRAMujvSrsKUzUXSfEWKxYzXF79fM","$cyQwIGC-oz0-bz59UrvftIfKQ5Sw-sw1JWV7VGrZFiE","$hLbi1GkuvN-WBzSutjEsEJ-aEOu8glkE2M80lqzHfzA","$mel1x_q8XZy5-v0aF3OjbRgWRXGQPpdqiwznNdAA7Vw","$ziOS4fkTQxW4OcyjfY13Y64GpznjbDX1B90I2-UfYD8"]}, +{"name":"ts-1187","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$mk21VNmiSdccRZwGYm-jC1B_-1y-dqIcgpHBar2Q2wY","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":75},"kick":75,"state_default":0,"users":{"@b:x":50,"@c:x":0,"@d:x":75}},["$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0"],2],["$rFpFviOrH3NNPoHX_mlfDUzbhW9AE7jLAgWlZ4o3-KQ","m.room.join_rules","","@a:x",{"join_rule":"public"},["$mk21VNmiSdccRZwGYm-jC1B_-1y-dqIcgpHBar2Q2wY","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0"],3],["$gDahkk2qK3vFr47TKFmQheD6M3bjmr9apsxikURtxjg","m.room.member","@b:x","@b:x",{"membership":"join"},["$rFpFviOrH3NNPoHX_mlfDUzbhW9AE7jLAgWlZ4o3-KQ","$mk21VNmiSdccRZwGYm-jC1B_-1y-dqIcgpHBar2Q2wY"],3],["$HEHAJLg2jCP67_XmIXZSUK3WdWQ1BKzEnC2Nl56SfKs","m.room.member","@c:x","@c:x",{"membership":"join"},["$rFpFviOrH3NNPoHX_mlfDUzbhW9AE7jLAgWlZ4o3-KQ","$mk21VNmiSdccRZwGYm-jC1B_-1y-dqIcgpHBar2Q2wY"],3],["$opLGoeU2wHL0ZxFs8hWAbxxTT71FqgVkvuY_oQGHqUY","m.room.member","@d:x","@d:x",{"membership":"join"},["$rFpFviOrH3NNPoHX_mlfDUzbhW9AE7jLAgWlZ4o3-KQ","$mk21VNmiSdccRZwGYm-jC1B_-1y-dqIcgpHBar2Q2wY"],0],["$5MoXD6H_q6_tO0ff2upxD50PAonGWuPucUIh6r8--8U","m.room.topic","","@a:x",{"topic":"base"},["$mk21VNmiSdccRZwGYm-jC1B_-1y-dqIcgpHBar2Q2wY","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0"],2],["$sYSCqrJNNe2VJjbVjTxe7kSM7b13Z3KpIuWl0_cUElM","m.room.name","","@a:x",{"name":"base"},["$mk21VNmiSdccRZwGYm-jC1B_-1y-dqIcgpHBar2Q2wY","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0"],4],["$yIBzm2LCkFuYbDtFg76LJp9bS2hiDtoqwJwvlaRMRhw","m.room.member","@d:x","@d:x",{"membership":"leave"},["$mk21VNmiSdccRZwGYm-jC1B_-1y-dqIcgpHBar2Q2wY","$opLGoeU2wHL0ZxFs8hWAbxxTT71FqgVkvuY_oQGHqUY"],4]],"states":[["$5MoXD6H_q6_tO0ff2upxD50PAonGWuPucUIh6r8--8U","$HEHAJLg2jCP67_XmIXZSUK3WdWQ1BKzEnC2Nl56SfKs","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0","$gDahkk2qK3vFr47TKFmQheD6M3bjmr9apsxikURtxjg","$mk21VNmiSdccRZwGYm-jC1B_-1y-dqIcgpHBar2Q2wY","$opLGoeU2wHL0ZxFs8hWAbxxTT71FqgVkvuY_oQGHqUY","$rFpFviOrH3NNPoHX_mlfDUzbhW9AE7jLAgWlZ4o3-KQ","$sYSCqrJNNe2VJjbVjTxe7kSM7b13Z3KpIuWl0_cUElM"],["$5MoXD6H_q6_tO0ff2upxD50PAonGWuPucUIh6r8--8U","$HEHAJLg2jCP67_XmIXZSUK3WdWQ1BKzEnC2Nl56SfKs","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0","$gDahkk2qK3vFr47TKFmQheD6M3bjmr9apsxikURtxjg","$mk21VNmiSdccRZwGYm-jC1B_-1y-dqIcgpHBar2Q2wY","$rFpFviOrH3NNPoHX_mlfDUzbhW9AE7jLAgWlZ4o3-KQ","$sYSCqrJNNe2VJjbVjTxe7kSM7b13Z3KpIuWl0_cUElM","$yIBzm2LCkFuYbDtFg76LJp9bS2hiDtoqwJwvlaRMRhw"]],"resolved":["$5MoXD6H_q6_tO0ff2upxD50PAonGWuPucUIh6r8--8U","$HEHAJLg2jCP67_XmIXZSUK3WdWQ1BKzEnC2Nl56SfKs","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0","$gDahkk2qK3vFr47TKFmQheD6M3bjmr9apsxikURtxjg","$mk21VNmiSdccRZwGYm-jC1B_-1y-dqIcgpHBar2Q2wY","$rFpFviOrH3NNPoHX_mlfDUzbhW9AE7jLAgWlZ4o3-KQ","$sYSCqrJNNe2VJjbVjTxe7kSM7b13Z3KpIuWl0_cUElM","$yIBzm2LCkFuYbDtFg76LJp9bS2hiDtoqwJwvlaRMRhw"]}, +{"name":"ts-1226","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$CGX14u5mzHksjOWF2fIIOSjFdsR2QeWKsdH1oKZAG6c","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@b:x":0,"@c:x":25,"@d:x":0}},["$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],5],["$8KKkOXym4P2x7p1-7iEELSDZpmU36H_i8ak8N9TzFhY","m.room.join_rules","","@a:x",{"join_rule":"public"},["$CGX14u5mzHksjOWF2fIIOSjFdsR2QeWKsdH1oKZAG6c","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],3],["$ieRSHlUscIRgIdK8mey3f6ChG0V8a8a4XhQCEQTrJP0","m.room.member","@b:x","@b:x",{"membership":"join"},["$8KKkOXym4P2x7p1-7iEELSDZpmU36H_i8ak8N9TzFhY","$CGX14u5mzHksjOWF2fIIOSjFdsR2QeWKsdH1oKZAG6c"],5],["$9El4SWoKe09uk0hJ3s6bBf0AvlS8W5rAGCI9_tPtD6s","m.room.member","@c:x","@c:x",{"membership":"join"},["$8KKkOXym4P2x7p1-7iEELSDZpmU36H_i8ak8N9TzFhY","$CGX14u5mzHksjOWF2fIIOSjFdsR2QeWKsdH1oKZAG6c"],5],["$8ZiKsIRsJuL8k5hgVotvQB55Gao-HW8fUrM1HQUd6zQ","m.room.member","@d:x","@d:x",{"membership":"join"},["$8KKkOXym4P2x7p1-7iEELSDZpmU36H_i8ak8N9TzFhY","$CGX14u5mzHksjOWF2fIIOSjFdsR2QeWKsdH1oKZAG6c"],2],["$GAn1wa5vt63feAJanzuBXfQrrx6qsUxGf8_2cwAEvLA","m.room.name","","@a:x",{"name":"base"},["$CGX14u5mzHksjOWF2fIIOSjFdsR2QeWKsdH1oKZAG6c","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],2],["$cAaBaoM_sXs-BePSKk8tMrdKppncBIUsn4Tj714pT3U","m.room.topic","","@a:x",{"topic":"0"},["$CGX14u5mzHksjOWF2fIIOSjFdsR2QeWKsdH1oKZAG6c","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],2],["$UJjZ-iNpP8yiM7msL0-zp0DnxkoXWIkgwdXocxqs0D0","m.room.member","@e:x","@e:x",{"displayname":"1","membership":"join"},["$8KKkOXym4P2x7p1-7iEELSDZpmU36H_i8ak8N9TzFhY","$CGX14u5mzHksjOWF2fIIOSjFdsR2QeWKsdH1oKZAG6c"],3],["$lJ8zYSf3aqOQfdQLPStOg-U6te02pApkWglTidpj6dY","m.room.member","@d:x","@a:x",{"membership":"leave"},["$CGX14u5mzHksjOWF2fIIOSjFdsR2QeWKsdH1oKZAG6c","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo","$8ZiKsIRsJuL8k5hgVotvQB55Gao-HW8fUrM1HQUd6zQ"],0]],"states":[["$8KKkOXym4P2x7p1-7iEELSDZpmU36H_i8ak8N9TzFhY","$8ZiKsIRsJuL8k5hgVotvQB55Gao-HW8fUrM1HQUd6zQ","$9El4SWoKe09uk0hJ3s6bBf0AvlS8W5rAGCI9_tPtD6s","$CGX14u5mzHksjOWF2fIIOSjFdsR2QeWKsdH1oKZAG6c","$GAn1wa5vt63feAJanzuBXfQrrx6qsUxGf8_2cwAEvLA","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$UJjZ-iNpP8yiM7msL0-zp0DnxkoXWIkgwdXocxqs0D0","$cAaBaoM_sXs-BePSKk8tMrdKppncBIUsn4Tj714pT3U","$ieRSHlUscIRgIdK8mey3f6ChG0V8a8a4XhQCEQTrJP0","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"],["$8KKkOXym4P2x7p1-7iEELSDZpmU36H_i8ak8N9TzFhY","$9El4SWoKe09uk0hJ3s6bBf0AvlS8W5rAGCI9_tPtD6s","$CGX14u5mzHksjOWF2fIIOSjFdsR2QeWKsdH1oKZAG6c","$GAn1wa5vt63feAJanzuBXfQrrx6qsUxGf8_2cwAEvLA","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$UJjZ-iNpP8yiM7msL0-zp0DnxkoXWIkgwdXocxqs0D0","$cAaBaoM_sXs-BePSKk8tMrdKppncBIUsn4Tj714pT3U","$ieRSHlUscIRgIdK8mey3f6ChG0V8a8a4XhQCEQTrJP0","$lJ8zYSf3aqOQfdQLPStOg-U6te02pApkWglTidpj6dY","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"]],"resolved":["$8KKkOXym4P2x7p1-7iEELSDZpmU36H_i8ak8N9TzFhY","$9El4SWoKe09uk0hJ3s6bBf0AvlS8W5rAGCI9_tPtD6s","$CGX14u5mzHksjOWF2fIIOSjFdsR2QeWKsdH1oKZAG6c","$GAn1wa5vt63feAJanzuBXfQrrx6qsUxGf8_2cwAEvLA","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$UJjZ-iNpP8yiM7msL0-zp0DnxkoXWIkgwdXocxqs0D0","$cAaBaoM_sXs-BePSKk8tMrdKppncBIUsn4Tj714pT3U","$ieRSHlUscIRgIdK8mey3f6ChG0V8a8a4XhQCEQTrJP0","$lJ8zYSf3aqOQfdQLPStOg-U6te02pApkWglTidpj6dY","$wi-i2olHFRMg4wkm68m3sRdHf59cz-igEmRELbThxMo"]}, +{"name":"ts-1233","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$le4T3Z1AhH0Afipz_lc_i3pEdZt8lPmOUkXFwX9ZrD8","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":25,"m.room.power_levels":50,"m.room.topic":50},"kick":75,"state_default":50,"users":{"@b:x":25,"@c:x":75,"@d:x":50}},["$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE"],5],["$MoLT4WReHoSI5_XbqO_dCiWCFPMyAiVFvE7gWEvI64Y","m.room.join_rules","","@a:x",{"join_rule":"public"},["$le4T3Z1AhH0Afipz_lc_i3pEdZt8lPmOUkXFwX9ZrD8","$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE"],3],["$EydgfY-_yjYVc83nmX4kGOnNqBgcEGwGhPh4fVHadxo","m.room.member","@b:x","@b:x",{"membership":"join"},["$MoLT4WReHoSI5_XbqO_dCiWCFPMyAiVFvE7gWEvI64Y","$le4T3Z1AhH0Afipz_lc_i3pEdZt8lPmOUkXFwX9ZrD8"],4],["$m8o_s7F6Grx47LhCKB4FEjwimN2K6m0iCH5O_GyzA-k","m.room.member","@c:x","@c:x",{"membership":"join"},["$MoLT4WReHoSI5_XbqO_dCiWCFPMyAiVFvE7gWEvI64Y","$le4T3Z1AhH0Afipz_lc_i3pEdZt8lPmOUkXFwX9ZrD8"],1],["$A0FpBEAVcsyWUCUWGJ52VvuGVbARJSqROq5-JzyH7HE","m.room.member","@d:x","@d:x",{"membership":"join"},["$MoLT4WReHoSI5_XbqO_dCiWCFPMyAiVFvE7gWEvI64Y","$le4T3Z1AhH0Afipz_lc_i3pEdZt8lPmOUkXFwX9ZrD8"],2],["$-tHr1lhPGSJZfOUCn0fx0m8pfkbDjxDw89IHf58N2ZE","m.room.topic","","@a:x",{"topic":"base"},["$le4T3Z1AhH0Afipz_lc_i3pEdZt8lPmOUkXFwX9ZrD8","$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE"],5],["$6ahaJmWQLNMaQSec6LDNDiIyVYN7O_3QI8zGT9dkqwk","m.room.name","","@c:x",{"name":"0"},["$le4T3Z1AhH0Afipz_lc_i3pEdZt8lPmOUkXFwX9ZrD8","$m8o_s7F6Grx47LhCKB4FEjwimN2K6m0iCH5O_GyzA-k"],3],["$ZqXlOyCAJ1EFBd5gEFbAAJSDUvJIC7kw-RPLvV_fDD4","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$MoLT4WReHoSI5_XbqO_dCiWCFPMyAiVFvE7gWEvI64Y","$le4T3Z1AhH0Afipz_lc_i3pEdZt8lPmOUkXFwX9ZrD8","$A0FpBEAVcsyWUCUWGJ52VvuGVbARJSqROq5-JzyH7HE"],3]],"states":[["$-tHr1lhPGSJZfOUCn0fx0m8pfkbDjxDw89IHf58N2ZE","$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE","$6ahaJmWQLNMaQSec6LDNDiIyVYN7O_3QI8zGT9dkqwk","$EydgfY-_yjYVc83nmX4kGOnNqBgcEGwGhPh4fVHadxo","$MoLT4WReHoSI5_XbqO_dCiWCFPMyAiVFvE7gWEvI64Y","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$ZqXlOyCAJ1EFBd5gEFbAAJSDUvJIC7kw-RPLvV_fDD4","$le4T3Z1AhH0Afipz_lc_i3pEdZt8lPmOUkXFwX9ZrD8","$m8o_s7F6Grx47LhCKB4FEjwimN2K6m0iCH5O_GyzA-k"],["$-tHr1lhPGSJZfOUCn0fx0m8pfkbDjxDw89IHf58N2ZE","$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE","$6ahaJmWQLNMaQSec6LDNDiIyVYN7O_3QI8zGT9dkqwk","$A0FpBEAVcsyWUCUWGJ52VvuGVbARJSqROq5-JzyH7HE","$EydgfY-_yjYVc83nmX4kGOnNqBgcEGwGhPh4fVHadxo","$MoLT4WReHoSI5_XbqO_dCiWCFPMyAiVFvE7gWEvI64Y","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$le4T3Z1AhH0Afipz_lc_i3pEdZt8lPmOUkXFwX9ZrD8","$m8o_s7F6Grx47LhCKB4FEjwimN2K6m0iCH5O_GyzA-k"]],"resolved":["$-tHr1lhPGSJZfOUCn0fx0m8pfkbDjxDw89IHf58N2ZE","$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE","$6ahaJmWQLNMaQSec6LDNDiIyVYN7O_3QI8zGT9dkqwk","$EydgfY-_yjYVc83nmX4kGOnNqBgcEGwGhPh4fVHadxo","$MoLT4WReHoSI5_XbqO_dCiWCFPMyAiVFvE7gWEvI64Y","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$ZqXlOyCAJ1EFBd5gEFbAAJSDUvJIC7kw-RPLvV_fDD4","$le4T3Z1AhH0Afipz_lc_i3pEdZt8lPmOUkXFwX9ZrD8","$m8o_s7F6Grx47LhCKB4FEjwimN2K6m0iCH5O_GyzA-k"]}, +{"name":"ts-1244","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$7nJnfz4VE-0PnjfzvP8D4JP3lEcSQKnvH9P-xglzM4s","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":50,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@b:x":50,"@c:x":75,"@d:x":75}},["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],1],["$gj6Zv_mNaQJXC8B4pqdRoHXUDZFzS-l9EOj3pCcieoU","m.room.join_rules","","@a:x",{"join_rule":"public"},["$7nJnfz4VE-0PnjfzvP8D4JP3lEcSQKnvH9P-xglzM4s","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],4],["$2EbJPzHDplewSnhnVI_W7t0Li1uK5zQNGLnab13azjA","m.room.member","@b:x","@b:x",{"membership":"join"},["$gj6Zv_mNaQJXC8B4pqdRoHXUDZFzS-l9EOj3pCcieoU","$7nJnfz4VE-0PnjfzvP8D4JP3lEcSQKnvH9P-xglzM4s"],4],["$vSx8xKtE7RpaFkHXSy2LsC1WOjtq5fAworJU2J9PaDA","m.room.member","@c:x","@c:x",{"membership":"join"},["$gj6Zv_mNaQJXC8B4pqdRoHXUDZFzS-l9EOj3pCcieoU","$7nJnfz4VE-0PnjfzvP8D4JP3lEcSQKnvH9P-xglzM4s"],2],["$H374VXaXiylcWgVnKM8xJjKccGnIEZSAwKw1S-kuaVk","m.room.member","@d:x","@d:x",{"membership":"join"},["$gj6Zv_mNaQJXC8B4pqdRoHXUDZFzS-l9EOj3pCcieoU","$7nJnfz4VE-0PnjfzvP8D4JP3lEcSQKnvH9P-xglzM4s"],4],["$bcQn2UdWZV-hOCgHaxz4IfSuBSUdmFipwMfzQsL-AKI","m.room.topic","","@a:x",{"topic":"base"},["$7nJnfz4VE-0PnjfzvP8D4JP3lEcSQKnvH9P-xglzM4s","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],0],["$5exjFx-FcCRoalFVS5mBBiA4yIdc0Yb00Qgb9DVb7CU","m.room.name","","@a:x",{"name":"base"},["$7nJnfz4VE-0PnjfzvP8D4JP3lEcSQKnvH9P-xglzM4s","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],2],["$ikZgBRe8LGPornN1ch03rxN7j-25h3lCKaXzsMVPG5w","m.room.topic","","@d:x",{"topic":"0"},["$7nJnfz4VE-0PnjfzvP8D4JP3lEcSQKnvH9P-xglzM4s","$H374VXaXiylcWgVnKM8xJjKccGnIEZSAwKw1S-kuaVk"],3],["$tsx0gHsjxyEnFEc9hBMCI42UzNci7pJxqzLi8Ywj8d0","m.room.member","@b:x","@b:x",{"membership":"leave"},["$7nJnfz4VE-0PnjfzvP8D4JP3lEcSQKnvH9P-xglzM4s","$2EbJPzHDplewSnhnVI_W7t0Li1uK5zQNGLnab13azjA"],5]],"states":[["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$5exjFx-FcCRoalFVS5mBBiA4yIdc0Yb00Qgb9DVb7CU","$7nJnfz4VE-0PnjfzvP8D4JP3lEcSQKnvH9P-xglzM4s","$H374VXaXiylcWgVnKM8xJjKccGnIEZSAwKw1S-kuaVk","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$bcQn2UdWZV-hOCgHaxz4IfSuBSUdmFipwMfzQsL-AKI","$gj6Zv_mNaQJXC8B4pqdRoHXUDZFzS-l9EOj3pCcieoU","$tsx0gHsjxyEnFEc9hBMCI42UzNci7pJxqzLi8Ywj8d0","$vSx8xKtE7RpaFkHXSy2LsC1WOjtq5fAworJU2J9PaDA"],["$2EbJPzHDplewSnhnVI_W7t0Li1uK5zQNGLnab13azjA","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$5exjFx-FcCRoalFVS5mBBiA4yIdc0Yb00Qgb9DVb7CU","$7nJnfz4VE-0PnjfzvP8D4JP3lEcSQKnvH9P-xglzM4s","$H374VXaXiylcWgVnKM8xJjKccGnIEZSAwKw1S-kuaVk","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$bcQn2UdWZV-hOCgHaxz4IfSuBSUdmFipwMfzQsL-AKI","$gj6Zv_mNaQJXC8B4pqdRoHXUDZFzS-l9EOj3pCcieoU","$vSx8xKtE7RpaFkHXSy2LsC1WOjtq5fAworJU2J9PaDA"],["$2EbJPzHDplewSnhnVI_W7t0Li1uK5zQNGLnab13azjA","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$5exjFx-FcCRoalFVS5mBBiA4yIdc0Yb00Qgb9DVb7CU","$7nJnfz4VE-0PnjfzvP8D4JP3lEcSQKnvH9P-xglzM4s","$H374VXaXiylcWgVnKM8xJjKccGnIEZSAwKw1S-kuaVk","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$gj6Zv_mNaQJXC8B4pqdRoHXUDZFzS-l9EOj3pCcieoU","$ikZgBRe8LGPornN1ch03rxN7j-25h3lCKaXzsMVPG5w","$vSx8xKtE7RpaFkHXSy2LsC1WOjtq5fAworJU2J9PaDA"]],"resolved":["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$5exjFx-FcCRoalFVS5mBBiA4yIdc0Yb00Qgb9DVb7CU","$7nJnfz4VE-0PnjfzvP8D4JP3lEcSQKnvH9P-xglzM4s","$H374VXaXiylcWgVnKM8xJjKccGnIEZSAwKw1S-kuaVk","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$gj6Zv_mNaQJXC8B4pqdRoHXUDZFzS-l9EOj3pCcieoU","$ikZgBRe8LGPornN1ch03rxN7j-25h3lCKaXzsMVPG5w","$tsx0gHsjxyEnFEc9hBMCI42UzNci7pJxqzLi8Ywj8d0","$vSx8xKtE7RpaFkHXSy2LsC1WOjtq5fAworJU2J9PaDA"]}, +{"name":"ts-1276","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":50},"kick":50,"state_default":50,"users":{"@b:x":0,"@c:x":50,"@d:x":75}},["$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],2],["$JsH2-hRx-LgKU8MJSyhOhxfKoSHmCqhMSniLhwpH80E","m.room.join_rules","","@a:x",{"join_rule":"public"},["$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],1],["$WiBKP-F2hGQg_TlhteO4TtTwpqpNlShAonGHlF9MX68","m.room.member","@b:x","@b:x",{"membership":"join"},["$JsH2-hRx-LgKU8MJSyhOhxfKoSHmCqhMSniLhwpH80E","$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0"],3],["$G5ymfVMvRCcV55OakHy7toqRNObg4GxY8MZlWnCy4sM","m.room.member","@c:x","@c:x",{"membership":"join"},["$JsH2-hRx-LgKU8MJSyhOhxfKoSHmCqhMSniLhwpH80E","$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0"],3],["$QMHUt91ElfEAK77hPFJs0CtG0hOPrZBLnHL3ATwc0oo","m.room.member","@d:x","@d:x",{"membership":"join"},["$JsH2-hRx-LgKU8MJSyhOhxfKoSHmCqhMSniLhwpH80E","$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0"],3],["$5wSMFgJWoXOi_Zg25T2Pr11RPUbycnzys1GNZMZPxwc","m.room.name","","@a:x",{"name":"base"},["$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE"],3],["$GJCv-es_g2B4v6yMCq1fwqwAtBB81U97JExiJ5ow9pQ","m.room.topic","","@c:x",{"topic":"0"},["$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$G5ymfVMvRCcV55OakHy7toqRNObg4GxY8MZlWnCy4sM"],0],["$pFcRC_sAq9jPjbb8iIh8LUuxk6AwNiJKqTbBdINI3B8","m.room.join_rules","","@d:x",{"join_rule":"public"},["$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$QMHUt91ElfEAK77hPFJs0CtG0hOPrZBLnHL3ATwc0oo"],3],["$riER_S7KJ8EHEOTtHVusN5lhXwCSBhwBTx5GOoLHFwU","m.room.name","","@d:x",{"name":"1"},["$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$QMHUt91ElfEAK77hPFJs0CtG0hOPrZBLnHL3ATwc0oo"],1]],"states":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$G5ymfVMvRCcV55OakHy7toqRNObg4GxY8MZlWnCy4sM","$GJCv-es_g2B4v6yMCq1fwqwAtBB81U97JExiJ5ow9pQ","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","$QMHUt91ElfEAK77hPFJs0CtG0hOPrZBLnHL3ATwc0oo","$WiBKP-F2hGQg_TlhteO4TtTwpqpNlShAonGHlF9MX68","$pFcRC_sAq9jPjbb8iIh8LUuxk6AwNiJKqTbBdINI3B8","$riER_S7KJ8EHEOTtHVusN5lhXwCSBhwBTx5GOoLHFwU"],["$5wSMFgJWoXOi_Zg25T2Pr11RPUbycnzys1GNZMZPxwc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$G5ymfVMvRCcV55OakHy7toqRNObg4GxY8MZlWnCy4sM","$GJCv-es_g2B4v6yMCq1fwqwAtBB81U97JExiJ5ow9pQ","$JsH2-hRx-LgKU8MJSyhOhxfKoSHmCqhMSniLhwpH80E","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","$QMHUt91ElfEAK77hPFJs0CtG0hOPrZBLnHL3ATwc0oo","$WiBKP-F2hGQg_TlhteO4TtTwpqpNlShAonGHlF9MX68"]],"resolved":["$5wSMFgJWoXOi_Zg25T2Pr11RPUbycnzys1GNZMZPxwc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$DhN0kSl9rQe8buFvi7UX_JxwT-df1pndbNVWGLUiiH0","$G5ymfVMvRCcV55OakHy7toqRNObg4GxY8MZlWnCy4sM","$GJCv-es_g2B4v6yMCq1fwqwAtBB81U97JExiJ5ow9pQ","$LmIKKxaI3Equ7Vu-3yuyxMIstDKcMwpmzyR_1tn1uJE","$QMHUt91ElfEAK77hPFJs0CtG0hOPrZBLnHL3ATwc0oo","$WiBKP-F2hGQg_TlhteO4TtTwpqpNlShAonGHlF9MX68","$pFcRC_sAq9jPjbb8iIh8LUuxk6AwNiJKqTbBdINI3B8"]}, +{"name":"ts-1316","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$TcKBvK_q0hpSczG-DSre3TG3W2rlxovD_oT3KmZ1Ols","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":50},"kick":25,"state_default":50,"users":{"@b:x":50,"@c:x":50,"@d:x":50}},["$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY"],2],["$bCT4DjhLbOIFeQQuTyn7h3e6UyVrQCGk7TEu8HgQE5g","m.room.join_rules","","@a:x",{"join_rule":"public"},["$TcKBvK_q0hpSczG-DSre3TG3W2rlxovD_oT3KmZ1Ols","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY"],2],["$teZpEdlTW4ReQvhQshLqte5nKOEYtU-J5EQ0bY5sKo8","m.room.member","@b:x","@b:x",{"membership":"join"},["$bCT4DjhLbOIFeQQuTyn7h3e6UyVrQCGk7TEu8HgQE5g","$TcKBvK_q0hpSczG-DSre3TG3W2rlxovD_oT3KmZ1Ols"],3],["$NlMSLswC6Tk7Nrw-Y2CJOaJ9ueMxIbu6qwLcmM3LtT4","m.room.member","@c:x","@c:x",{"membership":"join"},["$bCT4DjhLbOIFeQQuTyn7h3e6UyVrQCGk7TEu8HgQE5g","$TcKBvK_q0hpSczG-DSre3TG3W2rlxovD_oT3KmZ1Ols"],2],["$xQT-Kknuzmdez-HOoETd8YX29kjjLZB2dvnv3IGADEc","m.room.member","@d:x","@d:x",{"membership":"join"},["$bCT4DjhLbOIFeQQuTyn7h3e6UyVrQCGk7TEu8HgQE5g","$TcKBvK_q0hpSczG-DSre3TG3W2rlxovD_oT3KmZ1Ols"],1],["$FqGBJzt8nFhzuzK-cjMoErHxsYryKNO25kJrzLqV2Ec","m.room.topic","","@a:x",{"topic":"base"},["$TcKBvK_q0hpSczG-DSre3TG3W2rlxovD_oT3KmZ1Ols","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY"],2],["$Wy3rtrC4ytLA95biJ8MiMRkLtsfLwjcTm3MVs7R9e6c","m.room.name","","@a:x",{"name":"base"},["$TcKBvK_q0hpSczG-DSre3TG3W2rlxovD_oT3KmZ1Ols","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY"],1],["$rqpSkhcPbANFf3dSVI54EjbTxbnlXQ3DcqzWM2ZD41A","m.room.member","@d:x","@d:x",{"membership":"leave"},["$TcKBvK_q0hpSczG-DSre3TG3W2rlxovD_oT3KmZ1Ols","$xQT-Kknuzmdez-HOoETd8YX29kjjLZB2dvnv3IGADEc"],2],["$RHwdncn0CJ_22gofE7bfJkCTSQHf3SstwO0uTMJkZ9o","m.room.topic","","@a:x",{"topic":"0"},["$TcKBvK_q0hpSczG-DSre3TG3W2rlxovD_oT3KmZ1Ols","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY"],2],["$lwQTAqqJicZ6lMdzUPi7AkgQtQ-MjP6ooFuwgQLn8Jg","m.room.member","@e:x","@e:x",{"displayname":"1","membership":"join"},["$bCT4DjhLbOIFeQQuTyn7h3e6UyVrQCGk7TEu8HgQE5g","$TcKBvK_q0hpSczG-DSre3TG3W2rlxovD_oT3KmZ1Ols"],4]],"states":[["$NlMSLswC6Tk7Nrw-Y2CJOaJ9ueMxIbu6qwLcmM3LtT4","$RHwdncn0CJ_22gofE7bfJkCTSQHf3SstwO0uTMJkZ9o","$TcKBvK_q0hpSczG-DSre3TG3W2rlxovD_oT3KmZ1Ols","$Wy3rtrC4ytLA95biJ8MiMRkLtsfLwjcTm3MVs7R9e6c","$bCT4DjhLbOIFeQQuTyn7h3e6UyVrQCGk7TEu8HgQE5g","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$teZpEdlTW4ReQvhQshLqte5nKOEYtU-J5EQ0bY5sKo8","$xQT-Kknuzmdez-HOoETd8YX29kjjLZB2dvnv3IGADEc"],["$FqGBJzt8nFhzuzK-cjMoErHxsYryKNO25kJrzLqV2Ec","$NlMSLswC6Tk7Nrw-Y2CJOaJ9ueMxIbu6qwLcmM3LtT4","$TcKBvK_q0hpSczG-DSre3TG3W2rlxovD_oT3KmZ1Ols","$Wy3rtrC4ytLA95biJ8MiMRkLtsfLwjcTm3MVs7R9e6c","$bCT4DjhLbOIFeQQuTyn7h3e6UyVrQCGk7TEu8HgQE5g","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY","$lwQTAqqJicZ6lMdzUPi7AkgQtQ-MjP6ooFuwgQLn8Jg","$rqpSkhcPbANFf3dSVI54EjbTxbnlXQ3DcqzWM2ZD41A","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$teZpEdlTW4ReQvhQshLqte5nKOEYtU-J5EQ0bY5sKo8"]],"resolved":["$NlMSLswC6Tk7Nrw-Y2CJOaJ9ueMxIbu6qwLcmM3LtT4","$RHwdncn0CJ_22gofE7bfJkCTSQHf3SstwO0uTMJkZ9o","$TcKBvK_q0hpSczG-DSre3TG3W2rlxovD_oT3KmZ1Ols","$Wy3rtrC4ytLA95biJ8MiMRkLtsfLwjcTm3MVs7R9e6c","$bCT4DjhLbOIFeQQuTyn7h3e6UyVrQCGk7TEu8HgQE5g","$ksNln4i-pQx9_oWd97S4LEenI44cvHRrXRSp7NwgdVY","$lwQTAqqJicZ6lMdzUPi7AkgQtQ-MjP6ooFuwgQLn8Jg","$rqpSkhcPbANFf3dSVI54EjbTxbnlXQ3DcqzWM2ZD41A","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$teZpEdlTW4ReQvhQshLqte5nKOEYtU-J5EQ0bY5sKo8"]}, +{"name":"ts-1319","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$7nWfqP38K-NYV-A4JcYOJUoBiW9pvPRx0pau7Ox7szc","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":75,"state_default":0,"users":{"@b:x":50,"@c:x":75,"@d:x":75}},["$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],4],["$vp9d96EFtj4V5ZeBodtAvD_xzzuk6T2xG9Y1dSY8gAc","m.room.join_rules","","@a:x",{"join_rule":"public"},["$7nWfqP38K-NYV-A4JcYOJUoBiW9pvPRx0pau7Ox7szc","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],5],["$RFpUICW-E2KPkU95G5yZI-Ht7Ao2x7Z0blG2iws2UFM","m.room.member","@b:x","@b:x",{"membership":"join"},["$vp9d96EFtj4V5ZeBodtAvD_xzzuk6T2xG9Y1dSY8gAc","$7nWfqP38K-NYV-A4JcYOJUoBiW9pvPRx0pau7Ox7szc"],0],["$LrmUgWpWLlaxDlirea3sDfT-TbO9Vgp5OgXRWWEjdvA","m.room.member","@c:x","@c:x",{"membership":"join"},["$vp9d96EFtj4V5ZeBodtAvD_xzzuk6T2xG9Y1dSY8gAc","$7nWfqP38K-NYV-A4JcYOJUoBiW9pvPRx0pau7Ox7szc"],4],["$YbE8fm-JC6v3GI4FsU06H4bdup5Q1c0xmtQUc0mWrSU","m.room.member","@d:x","@d:x",{"membership":"join"},["$vp9d96EFtj4V5ZeBodtAvD_xzzuk6T2xG9Y1dSY8gAc","$7nWfqP38K-NYV-A4JcYOJUoBiW9pvPRx0pau7Ox7szc"],2],["$V2M6GEDy-bTiQk0NpC53JN95vsiol-oHvbiUGyVVOFQ","m.room.topic","","@a:x",{"topic":"base"},["$7nWfqP38K-NYV-A4JcYOJUoBiW9pvPRx0pau7Ox7szc","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],4],["$Eui1QM0yA6SDtbOXQcrNJT7wguawxxCMl2NWDYzZIHY","m.room.name","","@d:x",{"name":"0"},["$7nWfqP38K-NYV-A4JcYOJUoBiW9pvPRx0pau7Ox7szc","$YbE8fm-JC6v3GI4FsU06H4bdup5Q1c0xmtQUc0mWrSU"],4],["$8V_rG7_Fs9Rxgo2hfoUQ4PlUF3c8oJCS88iHdz6L5zA","m.room.member","@b:x","@b:x",{"displayname":"1","membership":"join"},["$vp9d96EFtj4V5ZeBodtAvD_xzzuk6T2xG9Y1dSY8gAc","$7nWfqP38K-NYV-A4JcYOJUoBiW9pvPRx0pau7Ox7szc","$RFpUICW-E2KPkU95G5yZI-Ht7Ao2x7Z0blG2iws2UFM"],2],["$VOeGIglrx9fAXur-KvwvyfN633RlpqFKAjS9PO6-3-o","m.room.member","@b:x","@b:x",{"displayname":"2","membership":"join"},["$vp9d96EFtj4V5ZeBodtAvD_xzzuk6T2xG9Y1dSY8gAc","$7nWfqP38K-NYV-A4JcYOJUoBiW9pvPRx0pau7Ox7szc","$8V_rG7_Fs9Rxgo2hfoUQ4PlUF3c8oJCS88iHdz6L5zA"],2]],"states":[["$7nWfqP38K-NYV-A4JcYOJUoBiW9pvPRx0pau7Ox7szc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$Eui1QM0yA6SDtbOXQcrNJT7wguawxxCMl2NWDYzZIHY","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$LrmUgWpWLlaxDlirea3sDfT-TbO9Vgp5OgXRWWEjdvA","$RFpUICW-E2KPkU95G5yZI-Ht7Ao2x7Z0blG2iws2UFM","$V2M6GEDy-bTiQk0NpC53JN95vsiol-oHvbiUGyVVOFQ","$YbE8fm-JC6v3GI4FsU06H4bdup5Q1c0xmtQUc0mWrSU","$vp9d96EFtj4V5ZeBodtAvD_xzzuk6T2xG9Y1dSY8gAc"],["$7nWfqP38K-NYV-A4JcYOJUoBiW9pvPRx0pau7Ox7szc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$Eui1QM0yA6SDtbOXQcrNJT7wguawxxCMl2NWDYzZIHY","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$LrmUgWpWLlaxDlirea3sDfT-TbO9Vgp5OgXRWWEjdvA","$V2M6GEDy-bTiQk0NpC53JN95vsiol-oHvbiUGyVVOFQ","$VOeGIglrx9fAXur-KvwvyfN633RlpqFKAjS9PO6-3-o","$YbE8fm-JC6v3GI4FsU06H4bdup5Q1c0xmtQUc0mWrSU","$vp9d96EFtj4V5ZeBodtAvD_xzzuk6T2xG9Y1dSY8gAc"]],"resolved":["$7nWfqP38K-NYV-A4JcYOJUoBiW9pvPRx0pau7Ox7szc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$Eui1QM0yA6SDtbOXQcrNJT7wguawxxCMl2NWDYzZIHY","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$LrmUgWpWLlaxDlirea3sDfT-TbO9Vgp5OgXRWWEjdvA","$V2M6GEDy-bTiQk0NpC53JN95vsiol-oHvbiUGyVVOFQ","$VOeGIglrx9fAXur-KvwvyfN633RlpqFKAjS9PO6-3-o","$YbE8fm-JC6v3GI4FsU06H4bdup5Q1c0xmtQUc0mWrSU","$vp9d96EFtj4V5ZeBodtAvD_xzzuk6T2xG9Y1dSY8gAc"]}, +{"name":"ts-1324","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$QwGCoSFJNeHSIjtGHmZOp7BDT4MUdf0aNZYAKAZdTVM","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":25},"kick":50,"state_default":50,"users":{"@b:x":50,"@c:x":25,"@d:x":25}},["$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],0],["$L0Ovt7e3L5JTULXWkusf93SbEMNFjqf_bTIog5uB7mU","m.room.join_rules","","@a:x",{"join_rule":"public"},["$QwGCoSFJNeHSIjtGHmZOp7BDT4MUdf0aNZYAKAZdTVM","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],1],["$GyARfjHkZ3z9ulUGGbDTXm1CrLkfSN1Sl5Sy-_4qiXk","m.room.member","@b:x","@b:x",{"membership":"join"},["$L0Ovt7e3L5JTULXWkusf93SbEMNFjqf_bTIog5uB7mU","$QwGCoSFJNeHSIjtGHmZOp7BDT4MUdf0aNZYAKAZdTVM"],5],["$sK8AXRs4uz51LVvv8_-fbuypDiQ9rAX75S-9a24KERA","m.room.member","@c:x","@c:x",{"membership":"join"},["$L0Ovt7e3L5JTULXWkusf93SbEMNFjqf_bTIog5uB7mU","$QwGCoSFJNeHSIjtGHmZOp7BDT4MUdf0aNZYAKAZdTVM"],2],["$i7pKhxEG9sdhdGHEROTGfg9JCGnCb58FWVS6a1kxnkU","m.room.member","@d:x","@d:x",{"membership":"join"},["$L0Ovt7e3L5JTULXWkusf93SbEMNFjqf_bTIog5uB7mU","$QwGCoSFJNeHSIjtGHmZOp7BDT4MUdf0aNZYAKAZdTVM"],2],["$ZDFSCy3eSWEFjU5NqMaG7Xq6nW50t8cv84uqGK4f_xw","m.room.name","","@a:x",{"name":"base"},["$QwGCoSFJNeHSIjtGHmZOp7BDT4MUdf0aNZYAKAZdTVM","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],2],["$Gxr2YO9_pzfJkzAQijf9IFdSzwcqnv40uRwKaBiWjnw","m.room.topic","","@d:x",{"topic":"0"},["$QwGCoSFJNeHSIjtGHmZOp7BDT4MUdf0aNZYAKAZdTVM","$i7pKhxEG9sdhdGHEROTGfg9JCGnCb58FWVS6a1kxnkU"],0],["$JmXWwFHJ20nDCVfRySQc5AL4eW2zoiUv4tjJtgEbzNM","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$L0Ovt7e3L5JTULXWkusf93SbEMNFjqf_bTIog5uB7mU","$QwGCoSFJNeHSIjtGHmZOp7BDT4MUdf0aNZYAKAZdTVM","$i7pKhxEG9sdhdGHEROTGfg9JCGnCb58FWVS6a1kxnkU"],1],["$zFSgj2dCebcSwzre47NoUVMYyYL38o5iN_wsKj2DJys","m.room.member","@d:x","@d:x",{"displayname":"2","membership":"join"},["$L0Ovt7e3L5JTULXWkusf93SbEMNFjqf_bTIog5uB7mU","$QwGCoSFJNeHSIjtGHmZOp7BDT4MUdf0aNZYAKAZdTVM","$i7pKhxEG9sdhdGHEROTGfg9JCGnCb58FWVS6a1kxnkU"],3],["$i42Ed59Hywu0C-CXYslNqV0MVeumZ1xANZ1BCWjPCHw","m.room.member","@c:x","@c:x",{"membership":"leave"},["$QwGCoSFJNeHSIjtGHmZOp7BDT4MUdf0aNZYAKAZdTVM","$sK8AXRs4uz51LVvv8_-fbuypDiQ9rAX75S-9a24KERA"],4]],"states":[["$Gxr2YO9_pzfJkzAQijf9IFdSzwcqnv40uRwKaBiWjnw","$GyARfjHkZ3z9ulUGGbDTXm1CrLkfSN1Sl5Sy-_4qiXk","$L0Ovt7e3L5JTULXWkusf93SbEMNFjqf_bTIog5uB7mU","$QwGCoSFJNeHSIjtGHmZOp7BDT4MUdf0aNZYAKAZdTVM","$ZDFSCy3eSWEFjU5NqMaG7Xq6nW50t8cv84uqGK4f_xw","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$i42Ed59Hywu0C-CXYslNqV0MVeumZ1xANZ1BCWjPCHw","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$zFSgj2dCebcSwzre47NoUVMYyYL38o5iN_wsKj2DJys"],["$Gxr2YO9_pzfJkzAQijf9IFdSzwcqnv40uRwKaBiWjnw","$GyARfjHkZ3z9ulUGGbDTXm1CrLkfSN1Sl5Sy-_4qiXk","$JmXWwFHJ20nDCVfRySQc5AL4eW2zoiUv4tjJtgEbzNM","$L0Ovt7e3L5JTULXWkusf93SbEMNFjqf_bTIog5uB7mU","$QwGCoSFJNeHSIjtGHmZOp7BDT4MUdf0aNZYAKAZdTVM","$ZDFSCy3eSWEFjU5NqMaG7Xq6nW50t8cv84uqGK4f_xw","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$sK8AXRs4uz51LVvv8_-fbuypDiQ9rAX75S-9a24KERA"]],"resolved":["$Gxr2YO9_pzfJkzAQijf9IFdSzwcqnv40uRwKaBiWjnw","$GyARfjHkZ3z9ulUGGbDTXm1CrLkfSN1Sl5Sy-_4qiXk","$L0Ovt7e3L5JTULXWkusf93SbEMNFjqf_bTIog5uB7mU","$QwGCoSFJNeHSIjtGHmZOp7BDT4MUdf0aNZYAKAZdTVM","$ZDFSCy3eSWEFjU5NqMaG7Xq6nW50t8cv84uqGK4f_xw","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$i42Ed59Hywu0C-CXYslNqV0MVeumZ1xANZ1BCWjPCHw","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$zFSgj2dCebcSwzre47NoUVMYyYL38o5iN_wsKj2DJys"]}, +{"name":"ts-1337","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$rqKMltdFa7OmUn9gOyevN8MVc9-KM_23aB8N5N1xGl8","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":100,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":25},"kick":50,"state_default":0,"users":{"@b:x":75,"@c:x":0,"@d:x":0}},["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],4],["$xhwA8ujb9mGWolzZKXhTKfTFl-av5gW12idDzDRAaiw","m.room.join_rules","","@a:x",{"join_rule":"public"},["$rqKMltdFa7OmUn9gOyevN8MVc9-KM_23aB8N5N1xGl8","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],2],["$oFxyeqHVcX2uJIrAHz9XVRxUu8zl1R73MP0T-62QdJw","m.room.member","@b:x","@b:x",{"membership":"join"},["$xhwA8ujb9mGWolzZKXhTKfTFl-av5gW12idDzDRAaiw","$rqKMltdFa7OmUn9gOyevN8MVc9-KM_23aB8N5N1xGl8"],3],["$zwDvRSCXd0TGID42gLM7eeRQsb1Nvkz5QXZqvg9h-4I","m.room.member","@c:x","@c:x",{"membership":"join"},["$xhwA8ujb9mGWolzZKXhTKfTFl-av5gW12idDzDRAaiw","$rqKMltdFa7OmUn9gOyevN8MVc9-KM_23aB8N5N1xGl8"],3],["$1G2hqfmtq5DCgz_5fVeVb-bm7A5gRb0pA_ez6B9aeIE","m.room.member","@d:x","@d:x",{"membership":"join"},["$xhwA8ujb9mGWolzZKXhTKfTFl-av5gW12idDzDRAaiw","$rqKMltdFa7OmUn9gOyevN8MVc9-KM_23aB8N5N1xGl8"],0],["$reQOeuQp6BIWEKmwvenL9HFK81_OhdmnHAFuO9Z1wCU","m.room.name","","@a:x",{"name":"base"},["$rqKMltdFa7OmUn9gOyevN8MVc9-KM_23aB8N5N1xGl8","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],3],["$E6-0FiInvQSrU-4M1GDzpPPi1RMau2NAzyr74QY5N64","m.room.topic","","@b:x",{"topic":"0"},["$rqKMltdFa7OmUn9gOyevN8MVc9-KM_23aB8N5N1xGl8","$oFxyeqHVcX2uJIrAHz9XVRxUu8zl1R73MP0T-62QdJw"],5],["$hgeWeTIGXCF14ftlRPdYlwQd5y6Dnf9QFxejU0tfnzA","m.room.member","@b:x","@b:x",{"membership":"leave"},["$rqKMltdFa7OmUn9gOyevN8MVc9-KM_23aB8N5N1xGl8","$oFxyeqHVcX2uJIrAHz9XVRxUu8zl1R73MP0T-62QdJw"],1],["$Pg14_GQOXA4gHxy6nTjJF7Sp_15QTjWs9GoXG_BR0-U","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$xhwA8ujb9mGWolzZKXhTKfTFl-av5gW12idDzDRAaiw","$rqKMltdFa7OmUn9gOyevN8MVc9-KM_23aB8N5N1xGl8","$1G2hqfmtq5DCgz_5fVeVb-bm7A5gRb0pA_ez6B9aeIE"],5],["$1F1XbanXxU3ZYSuOMJJl0qijfMNZSFi2hNT-zqiisfs","m.room.name","","@d:x",{"name":"2"},["$rqKMltdFa7OmUn9gOyevN8MVc9-KM_23aB8N5N1xGl8","$1G2hqfmtq5DCgz_5fVeVb-bm7A5gRb0pA_ez6B9aeIE"],0]],"states":[["$1F1XbanXxU3ZYSuOMJJl0qijfMNZSFi2hNT-zqiisfs","$1G2hqfmtq5DCgz_5fVeVb-bm7A5gRb0pA_ez6B9aeIE","$E6-0FiInvQSrU-4M1GDzpPPi1RMau2NAzyr74QY5N64","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$hgeWeTIGXCF14ftlRPdYlwQd5y6Dnf9QFxejU0tfnzA","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$rqKMltdFa7OmUn9gOyevN8MVc9-KM_23aB8N5N1xGl8","$xhwA8ujb9mGWolzZKXhTKfTFl-av5gW12idDzDRAaiw","$zwDvRSCXd0TGID42gLM7eeRQsb1Nvkz5QXZqvg9h-4I"],["$E6-0FiInvQSrU-4M1GDzpPPi1RMau2NAzyr74QY5N64","$Pg14_GQOXA4gHxy6nTjJF7Sp_15QTjWs9GoXG_BR0-U","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$hgeWeTIGXCF14ftlRPdYlwQd5y6Dnf9QFxejU0tfnzA","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$reQOeuQp6BIWEKmwvenL9HFK81_OhdmnHAFuO9Z1wCU","$rqKMltdFa7OmUn9gOyevN8MVc9-KM_23aB8N5N1xGl8","$xhwA8ujb9mGWolzZKXhTKfTFl-av5gW12idDzDRAaiw","$zwDvRSCXd0TGID42gLM7eeRQsb1Nvkz5QXZqvg9h-4I"]],"resolved":["$E6-0FiInvQSrU-4M1GDzpPPi1RMau2NAzyr74QY5N64","$Pg14_GQOXA4gHxy6nTjJF7Sp_15QTjWs9GoXG_BR0-U","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$hgeWeTIGXCF14ftlRPdYlwQd5y6Dnf9QFxejU0tfnzA","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$reQOeuQp6BIWEKmwvenL9HFK81_OhdmnHAFuO9Z1wCU","$rqKMltdFa7OmUn9gOyevN8MVc9-KM_23aB8N5N1xGl8","$xhwA8ujb9mGWolzZKXhTKfTFl-av5gW12idDzDRAaiw","$zwDvRSCXd0TGID42gLM7eeRQsb1Nvkz5QXZqvg9h-4I"]}, +{"name":"ts-1422","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$ZRd5TYzrTyGXrSlW7JWsSbRIe224QgobQil0MNaqv24","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":0},"kick":50,"state_default":0,"users":{"@b:x":25,"@c:x":75,"@d:x":50}},["$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],5],["$pUpRya1tv7wNWJzpAGx23M46bcDjV2XuRj7TqUDEug8","m.room.join_rules","","@a:x",{"join_rule":"public"},["$ZRd5TYzrTyGXrSlW7JWsSbRIe224QgobQil0MNaqv24","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],2],["$t_-vOcWyWPBkV1Vy_6IUxxoTjxpVNRq-1kYI3aQK-7s","m.room.member","@b:x","@b:x",{"membership":"join"},["$pUpRya1tv7wNWJzpAGx23M46bcDjV2XuRj7TqUDEug8","$ZRd5TYzrTyGXrSlW7JWsSbRIe224QgobQil0MNaqv24"],0],["$yOaArFJaK6J82qJYmJ_4J5otugqok6lYWRD3cCthpw8","m.room.member","@c:x","@c:x",{"membership":"join"},["$pUpRya1tv7wNWJzpAGx23M46bcDjV2XuRj7TqUDEug8","$ZRd5TYzrTyGXrSlW7JWsSbRIe224QgobQil0MNaqv24"],4],["$UPckalVXkEghjPW--ecc3vngCjCtGtVhkuchBufCk0c","m.room.member","@d:x","@d:x",{"membership":"join"},["$pUpRya1tv7wNWJzpAGx23M46bcDjV2XuRj7TqUDEug8","$ZRd5TYzrTyGXrSlW7JWsSbRIe224QgobQil0MNaqv24"],2],["$8NU8090iZ9nWnfSzi-xM_xLi8p67k4XaGv1Sdxy2dPo","m.room.topic","","@a:x",{"topic":"base"},["$ZRd5TYzrTyGXrSlW7JWsSbRIe224QgobQil0MNaqv24","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],4],["$CTLzFmHlFZ8-NfdV0ibNqYlKqZzzzk7DYFrjZC_B_iU","m.room.name","","@a:x",{"name":"base"},["$ZRd5TYzrTyGXrSlW7JWsSbRIe224QgobQil0MNaqv24","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho"],0],["$KcJgGLyxvNHjk1VcRy3l9NJwB_oH1ZFzCYoVOGU7S9U","m.room.name","","@c:x",{"name":"0"},["$ZRd5TYzrTyGXrSlW7JWsSbRIe224QgobQil0MNaqv24","$yOaArFJaK6J82qJYmJ_4J5otugqok6lYWRD3cCthpw8"],5]],"states":[["$8NU8090iZ9nWnfSzi-xM_xLi8p67k4XaGv1Sdxy2dPo","$CTLzFmHlFZ8-NfdV0ibNqYlKqZzzzk7DYFrjZC_B_iU","$UPckalVXkEghjPW--ecc3vngCjCtGtVhkuchBufCk0c","$ZRd5TYzrTyGXrSlW7JWsSbRIe224QgobQil0MNaqv24","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$pUpRya1tv7wNWJzpAGx23M46bcDjV2XuRj7TqUDEug8","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$t_-vOcWyWPBkV1Vy_6IUxxoTjxpVNRq-1kYI3aQK-7s","$yOaArFJaK6J82qJYmJ_4J5otugqok6lYWRD3cCthpw8"],["$8NU8090iZ9nWnfSzi-xM_xLi8p67k4XaGv1Sdxy2dPo","$KcJgGLyxvNHjk1VcRy3l9NJwB_oH1ZFzCYoVOGU7S9U","$UPckalVXkEghjPW--ecc3vngCjCtGtVhkuchBufCk0c","$ZRd5TYzrTyGXrSlW7JWsSbRIe224QgobQil0MNaqv24","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$pUpRya1tv7wNWJzpAGx23M46bcDjV2XuRj7TqUDEug8","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$t_-vOcWyWPBkV1Vy_6IUxxoTjxpVNRq-1kYI3aQK-7s","$yOaArFJaK6J82qJYmJ_4J5otugqok6lYWRD3cCthpw8"]],"resolved":["$8NU8090iZ9nWnfSzi-xM_xLi8p67k4XaGv1Sdxy2dPo","$KcJgGLyxvNHjk1VcRy3l9NJwB_oH1ZFzCYoVOGU7S9U","$UPckalVXkEghjPW--ecc3vngCjCtGtVhkuchBufCk0c","$ZRd5TYzrTyGXrSlW7JWsSbRIe224QgobQil0MNaqv24","$aK-ixEOFpdSPLCosW_TDrK_DIG-yzFolpWPUg6MRVho","$pUpRya1tv7wNWJzpAGx23M46bcDjV2XuRj7TqUDEug8","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$t_-vOcWyWPBkV1Vy_6IUxxoTjxpVNRq-1kYI3aQK-7s","$yOaArFJaK6J82qJYmJ_4J5otugqok6lYWRD3cCthpw8"]}, +{"name":"ts-1467","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$C4gHrRCH3HoHFqIObB8s8udko-RF2LG5nr3k-ftJfWI","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":25},"kick":75,"state_default":0,"users":{"@b:x":50,"@c:x":50,"@d:x":75}},["$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],1],["$fQvpP4r2Si50K4HQmQ0UeLWybd5CFHhYrG9z6lefmRQ","m.room.join_rules","","@a:x",{"join_rule":"public"},["$C4gHrRCH3HoHFqIObB8s8udko-RF2LG5nr3k-ftJfWI","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],5],["$ZsJJaBH4eGTAx12ckjMHMYuWknZrpmvRjO9_hgz897o","m.room.member","@b:x","@b:x",{"membership":"join"},["$fQvpP4r2Si50K4HQmQ0UeLWybd5CFHhYrG9z6lefmRQ","$C4gHrRCH3HoHFqIObB8s8udko-RF2LG5nr3k-ftJfWI"],4],["$KD5qyl4YQtRr7YxgQlVJLM6TZsJCQHvAUOkEB_3hTaE","m.room.member","@c:x","@c:x",{"membership":"join"},["$fQvpP4r2Si50K4HQmQ0UeLWybd5CFHhYrG9z6lefmRQ","$C4gHrRCH3HoHFqIObB8s8udko-RF2LG5nr3k-ftJfWI"],2],["$1QfcHUpy4x7QQXIAEe5urhzr4EnKdPUKBfSBdf7s_jM","m.room.member","@d:x","@d:x",{"membership":"join"},["$fQvpP4r2Si50K4HQmQ0UeLWybd5CFHhYrG9z6lefmRQ","$C4gHrRCH3HoHFqIObB8s8udko-RF2LG5nr3k-ftJfWI"],1],["$z89EGWdlxkwaE56CQE5evc6P1VO4_f_kgJvBlgl58J4","m.room.name","","@a:x",{"name":"base"},["$C4gHrRCH3HoHFqIObB8s8udko-RF2LG5nr3k-ftJfWI","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],2],["$JAlagZCpB5CdCWM_he5RXjzJshZ0eqbH46HMB7cnErk","m.room.member","@b:x","@b:x",{"membership":"leave"},["$C4gHrRCH3HoHFqIObB8s8udko-RF2LG5nr3k-ftJfWI","$ZsJJaBH4eGTAx12ckjMHMYuWknZrpmvRjO9_hgz897o"],2],["$58ssfwXyo118XeSa8emJ-PYdp6w8Jx1uRZ-AqHafdVo","m.room.topic","","@c:x",{"topic":"0"},["$C4gHrRCH3HoHFqIObB8s8udko-RF2LG5nr3k-ftJfWI","$KD5qyl4YQtRr7YxgQlVJLM6TZsJCQHvAUOkEB_3hTaE"],5],["$POPBIJw7UV8TGg98y389j4TcSqOw8rxIE_36471bcTw","m.room.member","@b:x","@a:x",{"membership":"invite"},["$fQvpP4r2Si50K4HQmQ0UeLWybd5CFHhYrG9z6lefmRQ","$C4gHrRCH3HoHFqIObB8s8udko-RF2LG5nr3k-ftJfWI","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$JAlagZCpB5CdCWM_he5RXjzJshZ0eqbH46HMB7cnErk"],3]],"states":[["$1QfcHUpy4x7QQXIAEe5urhzr4EnKdPUKBfSBdf7s_jM","$58ssfwXyo118XeSa8emJ-PYdp6w8Jx1uRZ-AqHafdVo","$C4gHrRCH3HoHFqIObB8s8udko-RF2LG5nr3k-ftJfWI","$KD5qyl4YQtRr7YxgQlVJLM6TZsJCQHvAUOkEB_3hTaE","$POPBIJw7UV8TGg98y389j4TcSqOw8rxIE_36471bcTw","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$fQvpP4r2Si50K4HQmQ0UeLWybd5CFHhYrG9z6lefmRQ","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$z89EGWdlxkwaE56CQE5evc6P1VO4_f_kgJvBlgl58J4"],["$1QfcHUpy4x7QQXIAEe5urhzr4EnKdPUKBfSBdf7s_jM","$58ssfwXyo118XeSa8emJ-PYdp6w8Jx1uRZ-AqHafdVo","$C4gHrRCH3HoHFqIObB8s8udko-RF2LG5nr3k-ftJfWI","$JAlagZCpB5CdCWM_he5RXjzJshZ0eqbH46HMB7cnErk","$KD5qyl4YQtRr7YxgQlVJLM6TZsJCQHvAUOkEB_3hTaE","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$fQvpP4r2Si50K4HQmQ0UeLWybd5CFHhYrG9z6lefmRQ","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$z89EGWdlxkwaE56CQE5evc6P1VO4_f_kgJvBlgl58J4"]],"resolved":["$1QfcHUpy4x7QQXIAEe5urhzr4EnKdPUKBfSBdf7s_jM","$58ssfwXyo118XeSa8emJ-PYdp6w8Jx1uRZ-AqHafdVo","$C4gHrRCH3HoHFqIObB8s8udko-RF2LG5nr3k-ftJfWI","$KD5qyl4YQtRr7YxgQlVJLM6TZsJCQHvAUOkEB_3hTaE","$POPBIJw7UV8TGg98y389j4TcSqOw8rxIE_36471bcTw","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$fQvpP4r2Si50K4HQmQ0UeLWybd5CFHhYrG9z6lefmRQ","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$z89EGWdlxkwaE56CQE5evc6P1VO4_f_kgJvBlgl58J4"]}, +{"name":"ts-1485","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$YBCCVCl4Ml_vt6PwOxxdyaIz3qqoJd_tgz5kJ8LyPiQ","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":25},"kick":25,"state_default":0,"users":{"@b:x":25,"@c:x":75,"@d:x":75}},["$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],5],["$Yuh0_oUt7gfPe_5lxZ5nsXdN7hzrHAx7Q-yiM58uhm8","m.room.join_rules","","@a:x",{"join_rule":"public"},["$YBCCVCl4Ml_vt6PwOxxdyaIz3qqoJd_tgz5kJ8LyPiQ","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],0],["$FVD3iaMjpSJj9eOIJl8GP71Aw4uc-0N_-RIwKVf17OI","m.room.member","@b:x","@b:x",{"membership":"join"},["$Yuh0_oUt7gfPe_5lxZ5nsXdN7hzrHAx7Q-yiM58uhm8","$YBCCVCl4Ml_vt6PwOxxdyaIz3qqoJd_tgz5kJ8LyPiQ"],0],["$BJkWr87UjVDNPulgpxmmTsq1WN2Wp00Sl51FmHny6p0","m.room.member","@c:x","@c:x",{"membership":"join"},["$Yuh0_oUt7gfPe_5lxZ5nsXdN7hzrHAx7Q-yiM58uhm8","$YBCCVCl4Ml_vt6PwOxxdyaIz3qqoJd_tgz5kJ8LyPiQ"],2],["$MtGVet7DMuR0CPYi4vc8_sux-v9iAqr3YZUMNr8-dZU","m.room.member","@d:x","@d:x",{"membership":"join"},["$Yuh0_oUt7gfPe_5lxZ5nsXdN7hzrHAx7Q-yiM58uhm8","$YBCCVCl4Ml_vt6PwOxxdyaIz3qqoJd_tgz5kJ8LyPiQ"],4],["$2YHTrQRJ5Cmq-R6tKyjulad4iYbL83eBxg_dew8AnTQ","m.room.name","","@a:x",{"name":"base"},["$YBCCVCl4Ml_vt6PwOxxdyaIz3qqoJd_tgz5kJ8LyPiQ","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],4],["$lrwckw0Gs6LVtp01OIN3ozQHz5w9OWE5we_uUHL5KC0","m.room.topic","","@d:x",{"topic":"0"},["$YBCCVCl4Ml_vt6PwOxxdyaIz3qqoJd_tgz5kJ8LyPiQ","$MtGVet7DMuR0CPYi4vc8_sux-v9iAqr3YZUMNr8-dZU"],2],["$8T1Oj2oaF_GsTi2pfG7el70Q0yI-AxcvYWNYEjyYmPo","m.room.member","@e:x","@c:x",{"membership":"leave"},["$YBCCVCl4Ml_vt6PwOxxdyaIz3qqoJd_tgz5kJ8LyPiQ","$BJkWr87UjVDNPulgpxmmTsq1WN2Wp00Sl51FmHny6p0"],5],["$YhTQCA_HDF6abJgBwB_x-NNiebVsimgNCSaLM63O8sE","m.room.topic","","@a:x",{"topic":"1"},["$YBCCVCl4Ml_vt6PwOxxdyaIz3qqoJd_tgz5kJ8LyPiQ","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],3]],"states":[["$2YHTrQRJ5Cmq-R6tKyjulad4iYbL83eBxg_dew8AnTQ","$BJkWr87UjVDNPulgpxmmTsq1WN2Wp00Sl51FmHny6p0","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$FVD3iaMjpSJj9eOIJl8GP71Aw4uc-0N_-RIwKVf17OI","$MtGVet7DMuR0CPYi4vc8_sux-v9iAqr3YZUMNr8-dZU","$YBCCVCl4Ml_vt6PwOxxdyaIz3qqoJd_tgz5kJ8LyPiQ","$YhTQCA_HDF6abJgBwB_x-NNiebVsimgNCSaLM63O8sE","$Yuh0_oUt7gfPe_5lxZ5nsXdN7hzrHAx7Q-yiM58uhm8"],["$2YHTrQRJ5Cmq-R6tKyjulad4iYbL83eBxg_dew8AnTQ","$8T1Oj2oaF_GsTi2pfG7el70Q0yI-AxcvYWNYEjyYmPo","$BJkWr87UjVDNPulgpxmmTsq1WN2Wp00Sl51FmHny6p0","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$FVD3iaMjpSJj9eOIJl8GP71Aw4uc-0N_-RIwKVf17OI","$MtGVet7DMuR0CPYi4vc8_sux-v9iAqr3YZUMNr8-dZU","$YBCCVCl4Ml_vt6PwOxxdyaIz3qqoJd_tgz5kJ8LyPiQ","$Yuh0_oUt7gfPe_5lxZ5nsXdN7hzrHAx7Q-yiM58uhm8","$lrwckw0Gs6LVtp01OIN3ozQHz5w9OWE5we_uUHL5KC0"]],"resolved":["$2YHTrQRJ5Cmq-R6tKyjulad4iYbL83eBxg_dew8AnTQ","$8T1Oj2oaF_GsTi2pfG7el70Q0yI-AxcvYWNYEjyYmPo","$BJkWr87UjVDNPulgpxmmTsq1WN2Wp00Sl51FmHny6p0","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$FVD3iaMjpSJj9eOIJl8GP71Aw4uc-0N_-RIwKVf17OI","$MtGVet7DMuR0CPYi4vc8_sux-v9iAqr3YZUMNr8-dZU","$YBCCVCl4Ml_vt6PwOxxdyaIz3qqoJd_tgz5kJ8LyPiQ","$YhTQCA_HDF6abJgBwB_x-NNiebVsimgNCSaLM63O8sE","$Yuh0_oUt7gfPe_5lxZ5nsXdN7hzrHAx7Q-yiM58uhm8"]}, +{"name":"ts-1577","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$yiUoK2QIWaRWfX_tuJ2AEf8-sHep_gTAcIV5OQE9YNc","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":0,"m.room.power_levels":50,"m.room.topic":75},"kick":50,"state_default":50,"users":{"@b:x":50,"@c:x":50,"@d:x":50}},["$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],1],["$CTpMB86kOfpH0SNDQz943k_3jaqiTdVx3xTiBK8VwIk","m.room.join_rules","","@a:x",{"join_rule":"public"},["$yiUoK2QIWaRWfX_tuJ2AEf8-sHep_gTAcIV5OQE9YNc","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],5],["$0X11MXsoK2IDuDW33CWJ1GIgLl1ILLkQJamDmOOvgac","m.room.member","@b:x","@b:x",{"membership":"join"},["$CTpMB86kOfpH0SNDQz943k_3jaqiTdVx3xTiBK8VwIk","$yiUoK2QIWaRWfX_tuJ2AEf8-sHep_gTAcIV5OQE9YNc"],0],["$gchceGPAW-O4eJhlVmWHsl15K8kncq41pWu-dHF48NI","m.room.member","@c:x","@c:x",{"membership":"join"},["$CTpMB86kOfpH0SNDQz943k_3jaqiTdVx3xTiBK8VwIk","$yiUoK2QIWaRWfX_tuJ2AEf8-sHep_gTAcIV5OQE9YNc"],5],["$moiFZ0hiXiGjKubUP3BW2mUCzW7h6Kzpj8fMI7OS5vI","m.room.member","@d:x","@d:x",{"membership":"join"},["$CTpMB86kOfpH0SNDQz943k_3jaqiTdVx3xTiBK8VwIk","$yiUoK2QIWaRWfX_tuJ2AEf8-sHep_gTAcIV5OQE9YNc"],0],["$q4dG3F4Ogofw0dg5_Q0tFEvJg0eW7_EzJdLXj6Q4R9M","m.room.topic","","@a:x",{"topic":"base"},["$yiUoK2QIWaRWfX_tuJ2AEf8-sHep_gTAcIV5OQE9YNc","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],2],["$kIsCU4O0U9ShZfuQR-BxKggumWRkqCRJ3yiuM7eHDS4","m.room.name","","@a:x",{"name":"base"},["$yiUoK2QIWaRWfX_tuJ2AEf8-sHep_gTAcIV5OQE9YNc","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],3],["$MgDdRMaROQXFsfmc7U24kEvbNr9eBSmHRSsySJmTCLo","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$CTpMB86kOfpH0SNDQz943k_3jaqiTdVx3xTiBK8VwIk","$yiUoK2QIWaRWfX_tuJ2AEf8-sHep_gTAcIV5OQE9YNc","$0X11MXsoK2IDuDW33CWJ1GIgLl1ILLkQJamDmOOvgac"],2],["$u69DbWBjG-NaQe-bQfmcggNilJf5uWU0LVvQAR5BdZI","m.room.topic","","@a:x",{"topic":"1"},["$yiUoK2QIWaRWfX_tuJ2AEf8-sHep_gTAcIV5OQE9YNc","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],0],["$pmA-J2hBiVYjVbAMHIJ5xs0kBlecbYGHn-9lewlaZJs","m.room.member","@d:x","@d:x",{"membership":"leave"},["$yiUoK2QIWaRWfX_tuJ2AEf8-sHep_gTAcIV5OQE9YNc","$moiFZ0hiXiGjKubUP3BW2mUCzW7h6Kzpj8fMI7OS5vI"],5]],"states":[["$CTpMB86kOfpH0SNDQz943k_3jaqiTdVx3xTiBK8VwIk","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$MgDdRMaROQXFsfmc7U24kEvbNr9eBSmHRSsySJmTCLo","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$gchceGPAW-O4eJhlVmWHsl15K8kncq41pWu-dHF48NI","$kIsCU4O0U9ShZfuQR-BxKggumWRkqCRJ3yiuM7eHDS4","$moiFZ0hiXiGjKubUP3BW2mUCzW7h6Kzpj8fMI7OS5vI","$q4dG3F4Ogofw0dg5_Q0tFEvJg0eW7_EzJdLXj6Q4R9M","$yiUoK2QIWaRWfX_tuJ2AEf8-sHep_gTAcIV5OQE9YNc"],["$0X11MXsoK2IDuDW33CWJ1GIgLl1ILLkQJamDmOOvgac","$CTpMB86kOfpH0SNDQz943k_3jaqiTdVx3xTiBK8VwIk","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$gchceGPAW-O4eJhlVmWHsl15K8kncq41pWu-dHF48NI","$kIsCU4O0U9ShZfuQR-BxKggumWRkqCRJ3yiuM7eHDS4","$pmA-J2hBiVYjVbAMHIJ5xs0kBlecbYGHn-9lewlaZJs","$u69DbWBjG-NaQe-bQfmcggNilJf5uWU0LVvQAR5BdZI","$yiUoK2QIWaRWfX_tuJ2AEf8-sHep_gTAcIV5OQE9YNc"]],"resolved":["$CTpMB86kOfpH0SNDQz943k_3jaqiTdVx3xTiBK8VwIk","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$MgDdRMaROQXFsfmc7U24kEvbNr9eBSmHRSsySJmTCLo","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$gchceGPAW-O4eJhlVmWHsl15K8kncq41pWu-dHF48NI","$kIsCU4O0U9ShZfuQR-BxKggumWRkqCRJ3yiuM7eHDS4","$pmA-J2hBiVYjVbAMHIJ5xs0kBlecbYGHn-9lewlaZJs","$q4dG3F4Ogofw0dg5_Q0tFEvJg0eW7_EzJdLXj6Q4R9M","$yiUoK2QIWaRWfX_tuJ2AEf8-sHep_gTAcIV5OQE9YNc"]}, +{"name":"ts-1598","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$HsZ3cNpnS1GC8RCX0jxZ6rq3CIcUteGPXql_FAwucpc","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":0},"kick":25,"state_default":0,"users":{"@b:x":50,"@c:x":75,"@d:x":75}},["$HsZ3cNpnS1GC8RCX0jxZ6rq3CIcUteGPXql_FAwucpc"],4],["$1esVYR1eCteYSIrNNS-0J_E9wz01kstwsAzIxDlGnt4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ","$HsZ3cNpnS1GC8RCX0jxZ6rq3CIcUteGPXql_FAwucpc"],2],["$rC-HpHeqEW8K5HoBLoVQ94Wc5sRxOZZczw3WYUZSzVs","m.room.member","@b:x","@b:x",{"membership":"join"},["$1esVYR1eCteYSIrNNS-0J_E9wz01kstwsAzIxDlGnt4","$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ"],0],["$4T5Dxm3EwcMZKN6URjiON0fR7R0EOlOrWyQBGtgkS5g","m.room.member","@c:x","@c:x",{"membership":"join"},["$1esVYR1eCteYSIrNNS-0J_E9wz01kstwsAzIxDlGnt4","$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ"],3],["$7EIfkoidInvXWLKhgvPVYbS525irA1bJBI8MH_lqHMM","m.room.member","@d:x","@d:x",{"membership":"join"},["$1esVYR1eCteYSIrNNS-0J_E9wz01kstwsAzIxDlGnt4","$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ"],2],["$40pDSPAxHVyM5OVGZNdSqEs6zXjK4U82W0_rYRqdkL4","m.room.topic","","@a:x",{"topic":"base"},["$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ","$HsZ3cNpnS1GC8RCX0jxZ6rq3CIcUteGPXql_FAwucpc"],2],["$1LSbaFyag6FlwqZyOgpP1csUgPg_dLa5SH5XknF5sfI","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$1esVYR1eCteYSIrNNS-0J_E9wz01kstwsAzIxDlGnt4","$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ","$7EIfkoidInvXWLKhgvPVYbS525irA1bJBI8MH_lqHMM"],3],["$Ed8dPvWZyseKNqr59txkxmw0LNPe6jXhHAPwmELtl9M","m.room.join_rules","","@c:x",{"join_rule":"invite"},["$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ","$4T5Dxm3EwcMZKN6URjiON0fR7R0EOlOrWyQBGtgkS5g"],4],["$fE8uKARbTE1wCPEiiCP_mDuMFwyF-AQPv2fn3HeO7a4","m.room.name","","@a:x",{"name":"1"},["$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ","$HsZ3cNpnS1GC8RCX0jxZ6rq3CIcUteGPXql_FAwucpc"],2],["$MzWU-YFcF8gCUSX4h9drcyEwJ-viVLFLuj8mjlad30Y","m.room.member","@b:x","@b:x",{"displayname":"2","membership":"join"},["$Ed8dPvWZyseKNqr59txkxmw0LNPe6jXhHAPwmELtl9M","$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ","$rC-HpHeqEW8K5HoBLoVQ94Wc5sRxOZZczw3WYUZSzVs"],3],["$ShCMtq_hX-KflGkRo8WO7rBNoUjPqGvVTxeZ8seuoaY","m.room.member","@c:x","@c:x",{"displayname":"3","membership":"join"},["$Ed8dPvWZyseKNqr59txkxmw0LNPe6jXhHAPwmELtl9M","$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ","$4T5Dxm3EwcMZKN6URjiON0fR7R0EOlOrWyQBGtgkS5g"],2],["$_DhCRKMmNjYs-cFP0LFqgKykPCzqmhpC-saTFYHHyts","m.room.topic","","@b:x",{"topic":"4"},["$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ","$MzWU-YFcF8gCUSX4h9drcyEwJ-viVLFLuj8mjlad30Y"],3],["$xjisSlgUiVvPifuShwt3YMbfTJXgEqtIWKvIJLxKU5Y","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ","$HsZ3cNpnS1GC8RCX0jxZ6rq3CIcUteGPXql_FAwucpc"],1]],"states":[["$1LSbaFyag6FlwqZyOgpP1csUgPg_dLa5SH5XknF5sfI","$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$HsZ3cNpnS1GC8RCX0jxZ6rq3CIcUteGPXql_FAwucpc","$MzWU-YFcF8gCUSX4h9drcyEwJ-viVLFLuj8mjlad30Y","$ShCMtq_hX-KflGkRo8WO7rBNoUjPqGvVTxeZ8seuoaY","$_DhCRKMmNjYs-cFP0LFqgKykPCzqmhpC-saTFYHHyts","$fE8uKARbTE1wCPEiiCP_mDuMFwyF-AQPv2fn3HeO7a4","$xjisSlgUiVvPifuShwt3YMbfTJXgEqtIWKvIJLxKU5Y"],["$1LSbaFyag6FlwqZyOgpP1csUgPg_dLa5SH5XknF5sfI","$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ","$40pDSPAxHVyM5OVGZNdSqEs6zXjK4U82W0_rYRqdkL4","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$Ed8dPvWZyseKNqr59txkxmw0LNPe6jXhHAPwmELtl9M","$HsZ3cNpnS1GC8RCX0jxZ6rq3CIcUteGPXql_FAwucpc","$MzWU-YFcF8gCUSX4h9drcyEwJ-viVLFLuj8mjlad30Y","$ShCMtq_hX-KflGkRo8WO7rBNoUjPqGvVTxeZ8seuoaY","$fE8uKARbTE1wCPEiiCP_mDuMFwyF-AQPv2fn3HeO7a4"]],"resolved":["$1LSbaFyag6FlwqZyOgpP1csUgPg_dLa5SH5XknF5sfI","$3jIkbjDKi_5XCLRQgE3qRJckKTk6ZlINA3LKy6icmCQ","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$Ed8dPvWZyseKNqr59txkxmw0LNPe6jXhHAPwmELtl9M","$HsZ3cNpnS1GC8RCX0jxZ6rq3CIcUteGPXql_FAwucpc","$MzWU-YFcF8gCUSX4h9drcyEwJ-viVLFLuj8mjlad30Y","$ShCMtq_hX-KflGkRo8WO7rBNoUjPqGvVTxeZ8seuoaY","$_DhCRKMmNjYs-cFP0LFqgKykPCzqmhpC-saTFYHHyts","$fE8uKARbTE1wCPEiiCP_mDuMFwyF-AQPv2fn3HeO7a4"]}, +{"name":"ts-1657","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$nUI8z_6d2R7-fsc5hyKhauHMrX2Dj-VvYPWp1GyHwlc","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@b:x":25,"@c:x":50,"@d:x":75}},["$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],3],["$M6Kio0qGjBpHqhLcWbmcIjduI8U3PHhOyICUVv3o12g","m.room.join_rules","","@a:x",{"join_rule":"public"},["$nUI8z_6d2R7-fsc5hyKhauHMrX2Dj-VvYPWp1GyHwlc","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],1],["$VJuR_UJnJc56Ofv2t9RzIH2zUBSCbDSXurW9LSiO5F0","m.room.member","@b:x","@b:x",{"membership":"join"},["$M6Kio0qGjBpHqhLcWbmcIjduI8U3PHhOyICUVv3o12g","$nUI8z_6d2R7-fsc5hyKhauHMrX2Dj-VvYPWp1GyHwlc"],5],["$X0mA8YIidNfeLS9agY5dDavBl17S35DvNL_nt7Wj9aw","m.room.member","@c:x","@c:x",{"membership":"join"},["$M6Kio0qGjBpHqhLcWbmcIjduI8U3PHhOyICUVv3o12g","$nUI8z_6d2R7-fsc5hyKhauHMrX2Dj-VvYPWp1GyHwlc"],3],["$undj93HS6T5qo-Q1HaueEodDZRdl-HIAnTKPeKXu3WA","m.room.member","@d:x","@d:x",{"membership":"join"},["$M6Kio0qGjBpHqhLcWbmcIjduI8U3PHhOyICUVv3o12g","$nUI8z_6d2R7-fsc5hyKhauHMrX2Dj-VvYPWp1GyHwlc"],3],["$_aU9YlFCAo9i_Tcqq6KgeR33vjMbDHAAdZPOLOQbKSk","m.room.topic","","@a:x",{"topic":"base"},["$nUI8z_6d2R7-fsc5hyKhauHMrX2Dj-VvYPWp1GyHwlc","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],2],["$yXubpeVRXP-OvFeVwhmGxmZio3WJu5-R1_6IlbQSZyE","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$nUI8z_6d2R7-fsc5hyKhauHMrX2Dj-VvYPWp1GyHwlc","$undj93HS6T5qo-Q1HaueEodDZRdl-HIAnTKPeKXu3WA"],4],["$1nG5TPga8Fdylmal5YOdNp81twua-XjfVvjpqawBbOc","m.room.name","","@a:x",{"name":"0"},["$nUI8z_6d2R7-fsc5hyKhauHMrX2Dj-VvYPWp1GyHwlc","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],3],["$Sn9r5QYvogIQ4t2exEckZMaGfkGpgI4avjwZric2d6o","m.room.name","","@a:x",{"name":"1"},["$nUI8z_6d2R7-fsc5hyKhauHMrX2Dj-VvYPWp1GyHwlc","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],1]],"states":[["$1nG5TPga8Fdylmal5YOdNp81twua-XjfVvjpqawBbOc","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$VJuR_UJnJc56Ofv2t9RzIH2zUBSCbDSXurW9LSiO5F0","$X0mA8YIidNfeLS9agY5dDavBl17S35DvNL_nt7Wj9aw","$_aU9YlFCAo9i_Tcqq6KgeR33vjMbDHAAdZPOLOQbKSk","$nUI8z_6d2R7-fsc5hyKhauHMrX2Dj-VvYPWp1GyHwlc","$undj93HS6T5qo-Q1HaueEodDZRdl-HIAnTKPeKXu3WA","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U","$yXubpeVRXP-OvFeVwhmGxmZio3WJu5-R1_6IlbQSZyE"],["$M6Kio0qGjBpHqhLcWbmcIjduI8U3PHhOyICUVv3o12g","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$Sn9r5QYvogIQ4t2exEckZMaGfkGpgI4avjwZric2d6o","$VJuR_UJnJc56Ofv2t9RzIH2zUBSCbDSXurW9LSiO5F0","$X0mA8YIidNfeLS9agY5dDavBl17S35DvNL_nt7Wj9aw","$_aU9YlFCAo9i_Tcqq6KgeR33vjMbDHAAdZPOLOQbKSk","$nUI8z_6d2R7-fsc5hyKhauHMrX2Dj-VvYPWp1GyHwlc","$undj93HS6T5qo-Q1HaueEodDZRdl-HIAnTKPeKXu3WA","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"]],"resolved":["$1nG5TPga8Fdylmal5YOdNp81twua-XjfVvjpqawBbOc","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$VJuR_UJnJc56Ofv2t9RzIH2zUBSCbDSXurW9LSiO5F0","$X0mA8YIidNfeLS9agY5dDavBl17S35DvNL_nt7Wj9aw","$_aU9YlFCAo9i_Tcqq6KgeR33vjMbDHAAdZPOLOQbKSk","$nUI8z_6d2R7-fsc5hyKhauHMrX2Dj-VvYPWp1GyHwlc","$undj93HS6T5qo-Q1HaueEodDZRdl-HIAnTKPeKXu3WA","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U","$yXubpeVRXP-OvFeVwhmGxmZio3WJu5-R1_6IlbQSZyE"]}, +{"name":"ts-1677","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$pqiIGfIWu4sRzKHJCP-MRHx2kdkN7KupChUrYgevZhw","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@b:x":75,"@c:x":50,"@d:x":75}},["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],4],["$XNNJT6QKHt6iavxufFOZbZ6JAAz4uJJdg6_8ZrefWts","m.room.join_rules","","@a:x",{"join_rule":"public"},["$pqiIGfIWu4sRzKHJCP-MRHx2kdkN7KupChUrYgevZhw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],4],["$HuqyFQCsc6wPic3OlBIpafoVGcwTB5OMc7RUnz6cIOg","m.room.member","@b:x","@b:x",{"membership":"join"},["$XNNJT6QKHt6iavxufFOZbZ6JAAz4uJJdg6_8ZrefWts","$pqiIGfIWu4sRzKHJCP-MRHx2kdkN7KupChUrYgevZhw"],2],["$MoFDLbDEFxXTvClTOIP-6NDjd33vEoS4jCXj2zl8CCc","m.room.member","@c:x","@c:x",{"membership":"join"},["$XNNJT6QKHt6iavxufFOZbZ6JAAz4uJJdg6_8ZrefWts","$pqiIGfIWu4sRzKHJCP-MRHx2kdkN7KupChUrYgevZhw"],4],["$N0Nt5rwrizuEpCa1eajkW9BqqfRhjqsgO2LiGlcF-4s","m.room.member","@d:x","@d:x",{"membership":"join"},["$XNNJT6QKHt6iavxufFOZbZ6JAAz4uJJdg6_8ZrefWts","$pqiIGfIWu4sRzKHJCP-MRHx2kdkN7KupChUrYgevZhw"],3],["$w7ShVyzEHr6BUseWEX27NDmk0MyMVr95ebJlVJNRarw","m.room.name","","@a:x",{"name":"base"},["$pqiIGfIWu4sRzKHJCP-MRHx2kdkN7KupChUrYgevZhw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],5],["$WjF_IgH12_nl_1rYbRREzDvv9sn_BrtWl-s-0-jvCAM","m.room.topic","","@d:x",{"topic":"0"},["$pqiIGfIWu4sRzKHJCP-MRHx2kdkN7KupChUrYgevZhw","$N0Nt5rwrizuEpCa1eajkW9BqqfRhjqsgO2LiGlcF-4s"],4],["$qDYEdUOS58kzjRQ2XVeYAU4h6e_j_w1NErhpZGIv4W4","m.room.member","@c:x","@a:x",{"membership":"leave"},["$pqiIGfIWu4sRzKHJCP-MRHx2kdkN7KupChUrYgevZhw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$MoFDLbDEFxXTvClTOIP-6NDjd33vEoS4jCXj2zl8CCc"],0],["$c3AsBljnSnVeqht22cEdX3W8tBkfBMMj_L6nPnqh3S8","m.room.topic","","@a:x",{"topic":"1"},["$pqiIGfIWu4sRzKHJCP-MRHx2kdkN7KupChUrYgevZhw","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],2]],"states":[["$HuqyFQCsc6wPic3OlBIpafoVGcwTB5OMc7RUnz6cIOg","$MoFDLbDEFxXTvClTOIP-6NDjd33vEoS4jCXj2zl8CCc","$N0Nt5rwrizuEpCa1eajkW9BqqfRhjqsgO2LiGlcF-4s","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$WjF_IgH12_nl_1rYbRREzDvv9sn_BrtWl-s-0-jvCAM","$XNNJT6QKHt6iavxufFOZbZ6JAAz4uJJdg6_8ZrefWts","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$pqiIGfIWu4sRzKHJCP-MRHx2kdkN7KupChUrYgevZhw","$w7ShVyzEHr6BUseWEX27NDmk0MyMVr95ebJlVJNRarw"],["$HuqyFQCsc6wPic3OlBIpafoVGcwTB5OMc7RUnz6cIOg","$N0Nt5rwrizuEpCa1eajkW9BqqfRhjqsgO2LiGlcF-4s","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$XNNJT6QKHt6iavxufFOZbZ6JAAz4uJJdg6_8ZrefWts","$c3AsBljnSnVeqht22cEdX3W8tBkfBMMj_L6nPnqh3S8","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$pqiIGfIWu4sRzKHJCP-MRHx2kdkN7KupChUrYgevZhw","$qDYEdUOS58kzjRQ2XVeYAU4h6e_j_w1NErhpZGIv4W4","$w7ShVyzEHr6BUseWEX27NDmk0MyMVr95ebJlVJNRarw"]],"resolved":["$HuqyFQCsc6wPic3OlBIpafoVGcwTB5OMc7RUnz6cIOg","$N0Nt5rwrizuEpCa1eajkW9BqqfRhjqsgO2LiGlcF-4s","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$WjF_IgH12_nl_1rYbRREzDvv9sn_BrtWl-s-0-jvCAM","$XNNJT6QKHt6iavxufFOZbZ6JAAz4uJJdg6_8ZrefWts","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$pqiIGfIWu4sRzKHJCP-MRHx2kdkN7KupChUrYgevZhw","$qDYEdUOS58kzjRQ2XVeYAU4h6e_j_w1NErhpZGIv4W4","$w7ShVyzEHr6BUseWEX27NDmk0MyMVr95ebJlVJNRarw"]}, +{"name":"ts-1683","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":75},"kick":75,"state_default":0,"users":{"@b:x":0,"@c:x":75,"@d:x":25}},["$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],2],["$nDfRPozTDflOItVFL-SBMYe7vl4_l7xgisdw5d79fYA","m.room.join_rules","","@a:x",{"join_rule":"public"},["$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],3],["$S3cX7rXswArmI8WGRjxFHkYZEKTbsCL_0UtDNe3f04k","m.room.member","@b:x","@b:x",{"membership":"join"},["$nDfRPozTDflOItVFL-SBMYe7vl4_l7xgisdw5d79fYA","$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g"],2],["$Xudy1j-kr5YFWluiFjql0MvjiN6fod_1VB8PApzJ_Jc","m.room.member","@c:x","@c:x",{"membership":"join"},["$nDfRPozTDflOItVFL-SBMYe7vl4_l7xgisdw5d79fYA","$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g"],1],["$zY36SN2nYlQvYvf_z78ORxXS7oEgy17Th78my77m__U","m.room.member","@d:x","@d:x",{"membership":"join"},["$nDfRPozTDflOItVFL-SBMYe7vl4_l7xgisdw5d79fYA","$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g"],2],["$5yIX6YE60i8ZGnUUciolZWhtfzRO5qpzoqtodTpDIx8","m.room.topic","","@a:x",{"topic":"base"},["$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],0],["$xYenmXlWU06zH4QfycBrmvYQCkgnGLiPvtzTHVYpWHE","m.room.name","","@a:x",{"name":"base"},["$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],4],["$_lSc1FYNnup0q0NzN7VMlk7S07ul_hVko4lBXgwmaD8","m.room.join_rules","","@c:x",{"join_rule":"invite"},["$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g","$Xudy1j-kr5YFWluiFjql0MvjiN6fod_1VB8PApzJ_Jc"],3],["$PJ9s2MUS3onOVk3u-M86TIdATOirMHwOxvOczUV5_DM","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$nDfRPozTDflOItVFL-SBMYe7vl4_l7xgisdw5d79fYA","$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g"],5],["$bKYAxWCEdIk79_J4HtOR4sDuQeFRt7t8pS557gYKnpA","m.room.topic","","@a:x",{"topic":"1"},["$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],4],["$yPLsjEpOl4gi-rCSOxriDZ9sX0_9Gm1drZJr-rvdsBo","m.room.name","","@a:x",{"name":"2"},["$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],5]],"states":[["$S3cX7rXswArmI8WGRjxFHkYZEKTbsCL_0UtDNe3f04k","$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$Xudy1j-kr5YFWluiFjql0MvjiN6fod_1VB8PApzJ_Jc","$_lSc1FYNnup0q0NzN7VMlk7S07ul_hVko4lBXgwmaD8","$bKYAxWCEdIk79_J4HtOR4sDuQeFRt7t8pS557gYKnpA","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$xYenmXlWU06zH4QfycBrmvYQCkgnGLiPvtzTHVYpWHE","$zY36SN2nYlQvYvf_z78ORxXS7oEgy17Th78my77m__U"],["$5yIX6YE60i8ZGnUUciolZWhtfzRO5qpzoqtodTpDIx8","$PJ9s2MUS3onOVk3u-M86TIdATOirMHwOxvOczUV5_DM","$S3cX7rXswArmI8WGRjxFHkYZEKTbsCL_0UtDNe3f04k","$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$Xudy1j-kr5YFWluiFjql0MvjiN6fod_1VB8PApzJ_Jc","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$nDfRPozTDflOItVFL-SBMYe7vl4_l7xgisdw5d79fYA","$yPLsjEpOl4gi-rCSOxriDZ9sX0_9Gm1drZJr-rvdsBo","$zY36SN2nYlQvYvf_z78ORxXS7oEgy17Th78my77m__U"],["$5yIX6YE60i8ZGnUUciolZWhtfzRO5qpzoqtodTpDIx8","$PJ9s2MUS3onOVk3u-M86TIdATOirMHwOxvOczUV5_DM","$S3cX7rXswArmI8WGRjxFHkYZEKTbsCL_0UtDNe3f04k","$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$Xudy1j-kr5YFWluiFjql0MvjiN6fod_1VB8PApzJ_Jc","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$nDfRPozTDflOItVFL-SBMYe7vl4_l7xgisdw5d79fYA","$xYenmXlWU06zH4QfycBrmvYQCkgnGLiPvtzTHVYpWHE","$zY36SN2nYlQvYvf_z78ORxXS7oEgy17Th78my77m__U"]],"resolved":["$S3cX7rXswArmI8WGRjxFHkYZEKTbsCL_0UtDNe3f04k","$SPpnz20-gDbnxIaFLaVT-MGx4RuvLcu-b--9yqz7a_g","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$Xudy1j-kr5YFWluiFjql0MvjiN6fod_1VB8PApzJ_Jc","$_lSc1FYNnup0q0NzN7VMlk7S07ul_hVko4lBXgwmaD8","$bKYAxWCEdIk79_J4HtOR4sDuQeFRt7t8pS557gYKnpA","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$yPLsjEpOl4gi-rCSOxriDZ9sX0_9Gm1drZJr-rvdsBo","$zY36SN2nYlQvYvf_z78ORxXS7oEgy17Th78my77m__U"]}, +{"name":"ts-1765","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$A4gcwW5qLXELevCri_mZJIe8IAZ6g1LXHwV13OtQHo0","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@b:x":75,"@c:x":50,"@d:x":0}},["$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78"],0],["$ykhtg1G3BglmnFy2wQ-0MbH4F4pneAlvBk1NQyQrQ6E","m.room.join_rules","","@a:x",{"join_rule":"public"},["$A4gcwW5qLXELevCri_mZJIe8IAZ6g1LXHwV13OtQHo0","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78"],4],["$I8IQsnUvdkn-pukRLbG-JQc4ABym8Z3sSIALg9AD_aY","m.room.member","@b:x","@b:x",{"membership":"join"},["$ykhtg1G3BglmnFy2wQ-0MbH4F4pneAlvBk1NQyQrQ6E","$A4gcwW5qLXELevCri_mZJIe8IAZ6g1LXHwV13OtQHo0"],5],["$HMksIPeweyxDQwVmhByYtSC9zn3nfDc1hOJO4YRM5VQ","m.room.member","@c:x","@c:x",{"membership":"join"},["$ykhtg1G3BglmnFy2wQ-0MbH4F4pneAlvBk1NQyQrQ6E","$A4gcwW5qLXELevCri_mZJIe8IAZ6g1LXHwV13OtQHo0"],5],["$LhEdKfgTVkqINFdfjVqKKYUnptHEZCPxrlwYkHIFuX0","m.room.member","@d:x","@d:x",{"membership":"join"},["$ykhtg1G3BglmnFy2wQ-0MbH4F4pneAlvBk1NQyQrQ6E","$A4gcwW5qLXELevCri_mZJIe8IAZ6g1LXHwV13OtQHo0"],4],["$v8X1yvsuC8lsPsdj6iBdPNqy19ohDG9I--bv8scEo40","m.room.member","@b:x","@a:x",{"membership":"ban"},["$A4gcwW5qLXELevCri_mZJIe8IAZ6g1LXHwV13OtQHo0","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78","$I8IQsnUvdkn-pukRLbG-JQc4ABym8Z3sSIALg9AD_aY"],4],["$xqdkcK0R_elrzcbZ8sVpf5-w4yDFIvtTL0hqs2jEJ2M","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$ykhtg1G3BglmnFy2wQ-0MbH4F4pneAlvBk1NQyQrQ6E","$A4gcwW5qLXELevCri_mZJIe8IAZ6g1LXHwV13OtQHo0"],4],["$U1PVcVoLL8sA9hUm7WYc3xEwVc6eX3ZjqMl_4lEbzag","m.room.name","","@a:x",{"name":"1"},["$A4gcwW5qLXELevCri_mZJIe8IAZ6g1LXHwV13OtQHo0","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78"],4],["$q-CUpjvxiD2BVO0jrYSY0ZStEy5f7heyFaUa_cC7BK4","m.room.topic","","@c:x",{"topic":"2"},["$A4gcwW5qLXELevCri_mZJIe8IAZ6g1LXHwV13OtQHo0","$HMksIPeweyxDQwVmhByYtSC9zn3nfDc1hOJO4YRM5VQ"],4],["$-9Vf99DTCKKy7fdRtOQdHNjiuJSmHiK7AubizEeYGrU","m.room.topic","","@a:x",{"topic":"3"},["$A4gcwW5qLXELevCri_mZJIe8IAZ6g1LXHwV13OtQHo0","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78"],3]],"states":[["$-9Vf99DTCKKy7fdRtOQdHNjiuJSmHiK7AubizEeYGrU","$A4gcwW5qLXELevCri_mZJIe8IAZ6g1LXHwV13OtQHo0","$HMksIPeweyxDQwVmhByYtSC9zn3nfDc1hOJO4YRM5VQ","$LhEdKfgTVkqINFdfjVqKKYUnptHEZCPxrlwYkHIFuX0","$U1PVcVoLL8sA9hUm7WYc3xEwVc6eX3ZjqMl_4lEbzag","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$v8X1yvsuC8lsPsdj6iBdPNqy19ohDG9I--bv8scEo40","$xqdkcK0R_elrzcbZ8sVpf5-w4yDFIvtTL0hqs2jEJ2M","$ykhtg1G3BglmnFy2wQ-0MbH4F4pneAlvBk1NQyQrQ6E"],["$A4gcwW5qLXELevCri_mZJIe8IAZ6g1LXHwV13OtQHo0","$HMksIPeweyxDQwVmhByYtSC9zn3nfDc1hOJO4YRM5VQ","$LhEdKfgTVkqINFdfjVqKKYUnptHEZCPxrlwYkHIFuX0","$U1PVcVoLL8sA9hUm7WYc3xEwVc6eX3ZjqMl_4lEbzag","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78","$q-CUpjvxiD2BVO0jrYSY0ZStEy5f7heyFaUa_cC7BK4","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$v8X1yvsuC8lsPsdj6iBdPNqy19ohDG9I--bv8scEo40","$xqdkcK0R_elrzcbZ8sVpf5-w4yDFIvtTL0hqs2jEJ2M","$ykhtg1G3BglmnFy2wQ-0MbH4F4pneAlvBk1NQyQrQ6E"]],"resolved":["$A4gcwW5qLXELevCri_mZJIe8IAZ6g1LXHwV13OtQHo0","$HMksIPeweyxDQwVmhByYtSC9zn3nfDc1hOJO4YRM5VQ","$LhEdKfgTVkqINFdfjVqKKYUnptHEZCPxrlwYkHIFuX0","$U1PVcVoLL8sA9hUm7WYc3xEwVc6eX3ZjqMl_4lEbzag","$_lP45O-5OM39z5rLP62aR4TNQXm1IQ-Cb9vnMXImY78","$q-CUpjvxiD2BVO0jrYSY0ZStEy5f7heyFaUa_cC7BK4","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$v8X1yvsuC8lsPsdj6iBdPNqy19ohDG9I--bv8scEo40","$xqdkcK0R_elrzcbZ8sVpf5-w4yDFIvtTL0hqs2jEJ2M","$ykhtg1G3BglmnFy2wQ-0MbH4F4pneAlvBk1NQyQrQ6E"]}, +{"name":"ts-1786","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$Arhbn2MYF8PTUIF6jy6xYrITq2MQeD8dm80EoIKVcAc","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@b:x":0,"@c:x":50,"@d:x":75}},["$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],0],["$qxNczYSFpZIsE3ntOd9ouL0ZPPzkgIXwLd-okqDjWKQ","m.room.join_rules","","@a:x",{"join_rule":"public"},["$Arhbn2MYF8PTUIF6jy6xYrITq2MQeD8dm80EoIKVcAc","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],2],["$Zs76Mv3KoncavajHk9oPjGCkmWfVHojv_WxBLm6SSsg","m.room.member","@b:x","@b:x",{"membership":"join"},["$qxNczYSFpZIsE3ntOd9ouL0ZPPzkgIXwLd-okqDjWKQ","$Arhbn2MYF8PTUIF6jy6xYrITq2MQeD8dm80EoIKVcAc"],3],["$eUTNbt-22HMRyTWTfuoJelgLYUSJkR-8OvRCwDWO2Eo","m.room.member","@c:x","@c:x",{"membership":"join"},["$qxNczYSFpZIsE3ntOd9ouL0ZPPzkgIXwLd-okqDjWKQ","$Arhbn2MYF8PTUIF6jy6xYrITq2MQeD8dm80EoIKVcAc"],1],["$p8_kTfCqDXbawAodNNbewy69SM4TYPTCQbUdPi1X3m4","m.room.member","@d:x","@d:x",{"membership":"join"},["$qxNczYSFpZIsE3ntOd9ouL0ZPPzkgIXwLd-okqDjWKQ","$Arhbn2MYF8PTUIF6jy6xYrITq2MQeD8dm80EoIKVcAc"],5],["$Vt7yDc38snKf-D1x8nsu8NsCCslEImgYDtn32QSx4Js","m.room.topic","","@a:x",{"topic":"base"},["$Arhbn2MYF8PTUIF6jy6xYrITq2MQeD8dm80EoIKVcAc","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],2],["$-Csbf6lFWh0LKwIEx6KaqZLlIgqoRhrGHkWA6k8L_Lg","m.room.name","","@a:x",{"name":"base"},["$Arhbn2MYF8PTUIF6jy6xYrITq2MQeD8dm80EoIKVcAc","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],3],["$GelEKoeJqeLkPA1xCpx0VQA90e-ZGg3cspoBcup4SJ8","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$qxNczYSFpZIsE3ntOd9ouL0ZPPzkgIXwLd-okqDjWKQ","$Arhbn2MYF8PTUIF6jy6xYrITq2MQeD8dm80EoIKVcAc","$Zs76Mv3KoncavajHk9oPjGCkmWfVHojv_WxBLm6SSsg"],0],["$wBZ-UBn8ZjApz6nD4MxiYEQ2Y2h5ZVe12D3Kl2oVCvk","m.room.name","","@a:x",{"name":"1"},["$Arhbn2MYF8PTUIF6jy6xYrITq2MQeD8dm80EoIKVcAc","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],3]],"states":[["$-Csbf6lFWh0LKwIEx6KaqZLlIgqoRhrGHkWA6k8L_Lg","$Arhbn2MYF8PTUIF6jy6xYrITq2MQeD8dm80EoIKVcAc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$GelEKoeJqeLkPA1xCpx0VQA90e-ZGg3cspoBcup4SJ8","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","$Vt7yDc38snKf-D1x8nsu8NsCCslEImgYDtn32QSx4Js","$eUTNbt-22HMRyTWTfuoJelgLYUSJkR-8OvRCwDWO2Eo","$p8_kTfCqDXbawAodNNbewy69SM4TYPTCQbUdPi1X3m4","$qxNczYSFpZIsE3ntOd9ouL0ZPPzkgIXwLd-okqDjWKQ"],["$Arhbn2MYF8PTUIF6jy6xYrITq2MQeD8dm80EoIKVcAc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","$Vt7yDc38snKf-D1x8nsu8NsCCslEImgYDtn32QSx4Js","$Zs76Mv3KoncavajHk9oPjGCkmWfVHojv_WxBLm6SSsg","$eUTNbt-22HMRyTWTfuoJelgLYUSJkR-8OvRCwDWO2Eo","$p8_kTfCqDXbawAodNNbewy69SM4TYPTCQbUdPi1X3m4","$qxNczYSFpZIsE3ntOd9ouL0ZPPzkgIXwLd-okqDjWKQ","$wBZ-UBn8ZjApz6nD4MxiYEQ2Y2h5ZVe12D3Kl2oVCvk"],["$-Csbf6lFWh0LKwIEx6KaqZLlIgqoRhrGHkWA6k8L_Lg","$Arhbn2MYF8PTUIF6jy6xYrITq2MQeD8dm80EoIKVcAc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","$Vt7yDc38snKf-D1x8nsu8NsCCslEImgYDtn32QSx4Js","$Zs76Mv3KoncavajHk9oPjGCkmWfVHojv_WxBLm6SSsg","$eUTNbt-22HMRyTWTfuoJelgLYUSJkR-8OvRCwDWO2Eo","$p8_kTfCqDXbawAodNNbewy69SM4TYPTCQbUdPi1X3m4","$qxNczYSFpZIsE3ntOd9ouL0ZPPzkgIXwLd-okqDjWKQ"]],"resolved":["$Arhbn2MYF8PTUIF6jy6xYrITq2MQeD8dm80EoIKVcAc","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","$Vt7yDc38snKf-D1x8nsu8NsCCslEImgYDtn32QSx4Js","$Zs76Mv3KoncavajHk9oPjGCkmWfVHojv_WxBLm6SSsg","$eUTNbt-22HMRyTWTfuoJelgLYUSJkR-8OvRCwDWO2Eo","$p8_kTfCqDXbawAodNNbewy69SM4TYPTCQbUdPi1X3m4","$qxNczYSFpZIsE3ntOd9ouL0ZPPzkgIXwLd-okqDjWKQ","$wBZ-UBn8ZjApz6nD4MxiYEQ2Y2h5ZVe12D3Kl2oVCvk"]}, +{"name":"ts-1901","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$GRBGjJ5pwQVx6oIHB5uKXmdlWNGZnMmvf-GaopLJQTo","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":50},"kick":50,"state_default":0,"users":{"@b:x":0,"@c:x":25,"@d:x":25}},["$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],3],["$pStD-EZU0SYk0pZR47axHtg9cW5ikZKVPjw5WIvupI0","m.room.join_rules","","@a:x",{"join_rule":"public"},["$GRBGjJ5pwQVx6oIHB5uKXmdlWNGZnMmvf-GaopLJQTo","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],3],["$psqHnelIzLVYF1HVwrRQ4HVIBOFn0EEHmyVnaFNcB6Y","m.room.member","@b:x","@b:x",{"membership":"join"},["$pStD-EZU0SYk0pZR47axHtg9cW5ikZKVPjw5WIvupI0","$GRBGjJ5pwQVx6oIHB5uKXmdlWNGZnMmvf-GaopLJQTo"],0],["$8xdyR9m7XokXTef8elzHdQOxlqGU2R2xhWboQ9sgpYw","m.room.member","@c:x","@c:x",{"membership":"join"},["$pStD-EZU0SYk0pZR47axHtg9cW5ikZKVPjw5WIvupI0","$GRBGjJ5pwQVx6oIHB5uKXmdlWNGZnMmvf-GaopLJQTo"],2],["$lHGkIFP5fyjFK2b45yf6s2y6e6shuO108W2pvNYSSfs","m.room.member","@d:x","@d:x",{"membership":"join"},["$pStD-EZU0SYk0pZR47axHtg9cW5ikZKVPjw5WIvupI0","$GRBGjJ5pwQVx6oIHB5uKXmdlWNGZnMmvf-GaopLJQTo"],4],["$aQEp2sBshAdUW-KOSGFHvRe5UMeP7jNNkwiP1PZf3dI","m.room.name","","@a:x",{"name":"base"},["$GRBGjJ5pwQVx6oIHB5uKXmdlWNGZnMmvf-GaopLJQTo","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],2],["$OQao1qmWfyoIlBsEJxOR98mkUq09O2AkdPKrxXMUBo8","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$pStD-EZU0SYk0pZR47axHtg9cW5ikZKVPjw5WIvupI0","$GRBGjJ5pwQVx6oIHB5uKXmdlWNGZnMmvf-GaopLJQTo","$psqHnelIzLVYF1HVwrRQ4HVIBOFn0EEHmyVnaFNcB6Y"],1],["$6vTwV6PpkTQJXDpYRvMZrdXdD0gAdcxMpafNBEDcJh8","m.room.topic","","@a:x",{"topic":"1"},["$GRBGjJ5pwQVx6oIHB5uKXmdlWNGZnMmvf-GaopLJQTo","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],5],["$IbfxVJ2J9KnX93LJixz1GZEzfT3EWRVbdi5uVhI4et0","m.room.member","@d:x","@d:x",{"membership":"leave"},["$GRBGjJ5pwQVx6oIHB5uKXmdlWNGZnMmvf-GaopLJQTo","$lHGkIFP5fyjFK2b45yf6s2y6e6shuO108W2pvNYSSfs"],3]],"states":[["$6vTwV6PpkTQJXDpYRvMZrdXdD0gAdcxMpafNBEDcJh8","$8xdyR9m7XokXTef8elzHdQOxlqGU2R2xhWboQ9sgpYw","$GRBGjJ5pwQVx6oIHB5uKXmdlWNGZnMmvf-GaopLJQTo","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$OQao1qmWfyoIlBsEJxOR98mkUq09O2AkdPKrxXMUBo8","$aQEp2sBshAdUW-KOSGFHvRe5UMeP7jNNkwiP1PZf3dI","$lHGkIFP5fyjFK2b45yf6s2y6e6shuO108W2pvNYSSfs","$pStD-EZU0SYk0pZR47axHtg9cW5ikZKVPjw5WIvupI0","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"],["$6vTwV6PpkTQJXDpYRvMZrdXdD0gAdcxMpafNBEDcJh8","$8xdyR9m7XokXTef8elzHdQOxlqGU2R2xhWboQ9sgpYw","$GRBGjJ5pwQVx6oIHB5uKXmdlWNGZnMmvf-GaopLJQTo","$IbfxVJ2J9KnX93LJixz1GZEzfT3EWRVbdi5uVhI4et0","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$OQao1qmWfyoIlBsEJxOR98mkUq09O2AkdPKrxXMUBo8","$aQEp2sBshAdUW-KOSGFHvRe5UMeP7jNNkwiP1PZf3dI","$pStD-EZU0SYk0pZR47axHtg9cW5ikZKVPjw5WIvupI0","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"]],"resolved":["$6vTwV6PpkTQJXDpYRvMZrdXdD0gAdcxMpafNBEDcJh8","$8xdyR9m7XokXTef8elzHdQOxlqGU2R2xhWboQ9sgpYw","$GRBGjJ5pwQVx6oIHB5uKXmdlWNGZnMmvf-GaopLJQTo","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$OQao1qmWfyoIlBsEJxOR98mkUq09O2AkdPKrxXMUBo8","$aQEp2sBshAdUW-KOSGFHvRe5UMeP7jNNkwiP1PZf3dI","$lHGkIFP5fyjFK2b45yf6s2y6e6shuO108W2pvNYSSfs","$pStD-EZU0SYk0pZR47axHtg9cW5ikZKVPjw5WIvupI0","$xxgjoXQJl7PpTk9eXqwJq4KQjJByGbtLr1pmDkb4cIA"]}, +{"name":"ts-2003","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$Ikm6k9xW2_XHmsBBDVn6uiK85tu3Fsmi5EcCnfsvPRM","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":25},"kick":75,"state_default":0,"users":{"@b:x":75,"@c:x":75,"@d:x":0}},["$Ikm6k9xW2_XHmsBBDVn6uiK85tu3Fsmi5EcCnfsvPRM"],3],["$F8Dh55f1NboD4or4CHvMf6z2z_l0AeFf_fBdTPvz6dw","m.room.join_rules","","@a:x",{"join_rule":"public"},["$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc","$Ikm6k9xW2_XHmsBBDVn6uiK85tu3Fsmi5EcCnfsvPRM"],2],["$ETLNPTK8rp_N5gP6pURg4edCZ82iScdgJ8mD1zR3-AE","m.room.member","@b:x","@b:x",{"membership":"join"},["$F8Dh55f1NboD4or4CHvMf6z2z_l0AeFf_fBdTPvz6dw","$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc"],5],["$QNSKagRrq39KEsJ3xLk1oZweVPfE0nniL0iUpG0tkBg","m.room.member","@c:x","@c:x",{"membership":"join"},["$F8Dh55f1NboD4or4CHvMf6z2z_l0AeFf_fBdTPvz6dw","$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc"],5],["$5IR0D4AizX24XyBCxdbx50dMiYqXsVF2sQwJz9hV32w","m.room.member","@d:x","@d:x",{"membership":"join"},["$F8Dh55f1NboD4or4CHvMf6z2z_l0AeFf_fBdTPvz6dw","$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc"],2],["$1R3Ixdar1uz4L4jekbzjJ2iddWs0K9Es8xtYH3hDspY","m.room.topic","","@a:x",{"topic":"base"},["$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc","$Ikm6k9xW2_XHmsBBDVn6uiK85tu3Fsmi5EcCnfsvPRM"],0],["$JWoReAQ2gMDIIIMkHGs8q7xvL9A-7fADrazMDJwFKIY","m.room.name","","@a:x",{"name":"base"},["$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc","$Ikm6k9xW2_XHmsBBDVn6uiK85tu3Fsmi5EcCnfsvPRM"],2],["$LtXqhWkH6ZEekyevT30psDDmj05hxWVQrICmSnaL7RY","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$F8Dh55f1NboD4or4CHvMf6z2z_l0AeFf_fBdTPvz6dw","$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc","$5IR0D4AizX24XyBCxdbx50dMiYqXsVF2sQwJz9hV32w"],2],["$k0yFd1HyLMvrVkF7u13ONPepz1rnwUqj3EOk5xmqIM8","m.room.member","@e:x","@d:x",{"membership":"invite"},["$F8Dh55f1NboD4or4CHvMf6z2z_l0AeFf_fBdTPvz6dw","$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc","$5IR0D4AizX24XyBCxdbx50dMiYqXsVF2sQwJz9hV32w"],3],["$4kq_Ct-8t0hg7wDJbqJzBe8mBFuJOn6XDbC0mywuDbI","m.room.join_rules","","@c:x",{"join_rule":"public"},["$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc","$QNSKagRrq39KEsJ3xLk1oZweVPfE0nniL0iUpG0tkBg"],5],["$NNOmZBYzhXkMzp3xSQSMqgXfBKca0aAhz1TNXhu_bmY","m.room.member","@d:x","@d:x",{"membership":"leave"},["$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc","$5IR0D4AizX24XyBCxdbx50dMiYqXsVF2sQwJz9hV32w"],4]],"states":[["$1R3Ixdar1uz4L4jekbzjJ2iddWs0K9Es8xtYH3hDspY","$4kq_Ct-8t0hg7wDJbqJzBe8mBFuJOn6XDbC0mywuDbI","$ETLNPTK8rp_N5gP6pURg4edCZ82iScdgJ8mD1zR3-AE","$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc","$Ikm6k9xW2_XHmsBBDVn6uiK85tu3Fsmi5EcCnfsvPRM","$JWoReAQ2gMDIIIMkHGs8q7xvL9A-7fADrazMDJwFKIY","$NNOmZBYzhXkMzp3xSQSMqgXfBKca0aAhz1TNXhu_bmY","$QNSKagRrq39KEsJ3xLk1oZweVPfE0nniL0iUpG0tkBg","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"],["$1R3Ixdar1uz4L4jekbzjJ2iddWs0K9Es8xtYH3hDspY","$ETLNPTK8rp_N5gP6pURg4edCZ82iScdgJ8mD1zR3-AE","$F8Dh55f1NboD4or4CHvMf6z2z_l0AeFf_fBdTPvz6dw","$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc","$Ikm6k9xW2_XHmsBBDVn6uiK85tu3Fsmi5EcCnfsvPRM","$JWoReAQ2gMDIIIMkHGs8q7xvL9A-7fADrazMDJwFKIY","$LtXqhWkH6ZEekyevT30psDDmj05hxWVQrICmSnaL7RY","$QNSKagRrq39KEsJ3xLk1oZweVPfE0nniL0iUpG0tkBg","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"],["$1R3Ixdar1uz4L4jekbzjJ2iddWs0K9Es8xtYH3hDspY","$5IR0D4AizX24XyBCxdbx50dMiYqXsVF2sQwJz9hV32w","$ETLNPTK8rp_N5gP6pURg4edCZ82iScdgJ8mD1zR3-AE","$F8Dh55f1NboD4or4CHvMf6z2z_l0AeFf_fBdTPvz6dw","$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc","$Ikm6k9xW2_XHmsBBDVn6uiK85tu3Fsmi5EcCnfsvPRM","$JWoReAQ2gMDIIIMkHGs8q7xvL9A-7fADrazMDJwFKIY","$QNSKagRrq39KEsJ3xLk1oZweVPfE0nniL0iUpG0tkBg","$k0yFd1HyLMvrVkF7u13ONPepz1rnwUqj3EOk5xmqIM8","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"]],"resolved":["$1R3Ixdar1uz4L4jekbzjJ2iddWs0K9Es8xtYH3hDspY","$4kq_Ct-8t0hg7wDJbqJzBe8mBFuJOn6XDbC0mywuDbI","$ETLNPTK8rp_N5gP6pURg4edCZ82iScdgJ8mD1zR3-AE","$FmlenDXe_atNKDYF37BUjXjS5TizuRWAkm4zndSKhMc","$Ikm6k9xW2_XHmsBBDVn6uiK85tu3Fsmi5EcCnfsvPRM","$JWoReAQ2gMDIIIMkHGs8q7xvL9A-7fADrazMDJwFKIY","$NNOmZBYzhXkMzp3xSQSMqgXfBKca0aAhz1TNXhu_bmY","$QNSKagRrq39KEsJ3xLk1oZweVPfE0nniL0iUpG0tkBg","$k0yFd1HyLMvrVkF7u13ONPepz1rnwUqj3EOk5xmqIM8","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"]}, +{"name":"ts-2008","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$TnF_YG18xhw7yMqhx5f9mW8yj2ELV5LuGGsvgk_5eKA","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":0,"m.room.power_levels":75,"m.room.topic":50},"kick":25,"state_default":50,"users":{"@b:x":50,"@c:x":25,"@d:x":25}},["$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],0],["$bQT2CoFmRRq19OvmQzkrP4uAa6-NDAmjMypE2Wq0Wqo","m.room.join_rules","","@a:x",{"join_rule":"public"},["$TnF_YG18xhw7yMqhx5f9mW8yj2ELV5LuGGsvgk_5eKA","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],5],["$6BFfsvFD75ODLP5AoEefo7gHRMiE5vvH2Sd80Rf65Zk","m.room.member","@b:x","@b:x",{"membership":"join"},["$bQT2CoFmRRq19OvmQzkrP4uAa6-NDAmjMypE2Wq0Wqo","$TnF_YG18xhw7yMqhx5f9mW8yj2ELV5LuGGsvgk_5eKA"],4],["$bInzaJoNWDhREkogYF5_sJw_qU5pt53qgNN3_hsFE0s","m.room.member","@c:x","@c:x",{"membership":"join"},["$bQT2CoFmRRq19OvmQzkrP4uAa6-NDAmjMypE2Wq0Wqo","$TnF_YG18xhw7yMqhx5f9mW8yj2ELV5LuGGsvgk_5eKA"],2],["$73ELFxKyXLBKHFtSciESgHLGyWil3EvqZ9TvxWo7qPg","m.room.member","@d:x","@d:x",{"membership":"join"},["$bQT2CoFmRRq19OvmQzkrP4uAa6-NDAmjMypE2Wq0Wqo","$TnF_YG18xhw7yMqhx5f9mW8yj2ELV5LuGGsvgk_5eKA"],1],["$6OzvPAWqWN97739Jqh7zVTPzIIQmhfV2zy3UHDjmsBc","m.room.topic","","@a:x",{"topic":"base"},["$TnF_YG18xhw7yMqhx5f9mW8yj2ELV5LuGGsvgk_5eKA","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],2],["$1Vf9Q0BDP4KIeTGEMLG4uH2IG4kMBFxbSV1VU7OdX8U","m.room.name","","@a:x",{"name":"base"},["$TnF_YG18xhw7yMqhx5f9mW8yj2ELV5LuGGsvgk_5eKA","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],2],["$5BF-VvqcsOIvy21wKlRnV0NrkBV-RQ5s5r2c-3VqEZ0","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$bQT2CoFmRRq19OvmQzkrP4uAa6-NDAmjMypE2Wq0Wqo","$TnF_YG18xhw7yMqhx5f9mW8yj2ELV5LuGGsvgk_5eKA","$bInzaJoNWDhREkogYF5_sJw_qU5pt53qgNN3_hsFE0s"],1]],"states":[["$1Vf9Q0BDP4KIeTGEMLG4uH2IG4kMBFxbSV1VU7OdX8U","$5BF-VvqcsOIvy21wKlRnV0NrkBV-RQ5s5r2c-3VqEZ0","$6BFfsvFD75ODLP5AoEefo7gHRMiE5vvH2Sd80Rf65Zk","$6OzvPAWqWN97739Jqh7zVTPzIIQmhfV2zy3UHDjmsBc","$73ELFxKyXLBKHFtSciESgHLGyWil3EvqZ9TvxWo7qPg","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$TnF_YG18xhw7yMqhx5f9mW8yj2ELV5LuGGsvgk_5eKA","$bQT2CoFmRRq19OvmQzkrP4uAa6-NDAmjMypE2Wq0Wqo","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"],["$1Vf9Q0BDP4KIeTGEMLG4uH2IG4kMBFxbSV1VU7OdX8U","$6BFfsvFD75ODLP5AoEefo7gHRMiE5vvH2Sd80Rf65Zk","$6OzvPAWqWN97739Jqh7zVTPzIIQmhfV2zy3UHDjmsBc","$73ELFxKyXLBKHFtSciESgHLGyWil3EvqZ9TvxWo7qPg","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$TnF_YG18xhw7yMqhx5f9mW8yj2ELV5LuGGsvgk_5eKA","$bInzaJoNWDhREkogYF5_sJw_qU5pt53qgNN3_hsFE0s","$bQT2CoFmRRq19OvmQzkrP4uAa6-NDAmjMypE2Wq0Wqo","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"]],"resolved":["$1Vf9Q0BDP4KIeTGEMLG4uH2IG4kMBFxbSV1VU7OdX8U","$6BFfsvFD75ODLP5AoEefo7gHRMiE5vvH2Sd80Rf65Zk","$6OzvPAWqWN97739Jqh7zVTPzIIQmhfV2zy3UHDjmsBc","$73ELFxKyXLBKHFtSciESgHLGyWil3EvqZ9TvxWo7qPg","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$TnF_YG18xhw7yMqhx5f9mW8yj2ELV5LuGGsvgk_5eKA","$bInzaJoNWDhREkogYF5_sJw_qU5pt53qgNN3_hsFE0s","$bQT2CoFmRRq19OvmQzkrP4uAa6-NDAmjMypE2Wq0Wqo","$yRzc_TjtZbhKbQYzVXnGl9pmxL2bB8-TWXQ9rVRdF4U"]}, +{"name":"ts-2025","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$AjT_oEsm9Osg470rOoFoJ-61Kxrqr-3NpzQfX8Yxfy8","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":50},"kick":75,"state_default":50,"users":{"@b:x":25,"@c:x":50,"@d:x":50}},["$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],2],["$rn16lU0tlWTpfegd106VZvZHaMMvkyDMkcbNsU6J0Xg","m.room.join_rules","","@a:x",{"join_rule":"public"},["$AjT_oEsm9Osg470rOoFoJ-61Kxrqr-3NpzQfX8Yxfy8","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],3],["$UOWxwWnRt2ytyBFdp4pagj0q8hl5J4xp1Dgm0iVBSOQ","m.room.member","@b:x","@b:x",{"membership":"join"},["$rn16lU0tlWTpfegd106VZvZHaMMvkyDMkcbNsU6J0Xg","$AjT_oEsm9Osg470rOoFoJ-61Kxrqr-3NpzQfX8Yxfy8"],3],["$lCioCzWfjGM2f5c3zXG65FHem-KhASthSwP8VoPr3GA","m.room.member","@c:x","@c:x",{"membership":"join"},["$rn16lU0tlWTpfegd106VZvZHaMMvkyDMkcbNsU6J0Xg","$AjT_oEsm9Osg470rOoFoJ-61Kxrqr-3NpzQfX8Yxfy8"],0],["$rgBUpH9g6Qow2n4kBNEBNpEvpxtemVYkL4iT4tEfAbI","m.room.member","@d:x","@d:x",{"membership":"join"},["$rn16lU0tlWTpfegd106VZvZHaMMvkyDMkcbNsU6J0Xg","$AjT_oEsm9Osg470rOoFoJ-61Kxrqr-3NpzQfX8Yxfy8"],5],["$Pkjcpzq5mrXWEHjnHRAf8ak4ujoGG2rnxS2RgUz_F1M","m.room.topic","","@a:x",{"topic":"base"},["$AjT_oEsm9Osg470rOoFoJ-61Kxrqr-3NpzQfX8Yxfy8","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],2],["$GkUCa3TZRoa7U6cNl6Yh8TXXfebZz0MqDhcRV8EEJE8","m.room.name","","@d:x",{"name":"0"},["$AjT_oEsm9Osg470rOoFoJ-61Kxrqr-3NpzQfX8Yxfy8","$rgBUpH9g6Qow2n4kBNEBNpEvpxtemVYkL4iT4tEfAbI"],4],["$l8n7EuGZcmeGs9etGc0oaRyXfzK0SNGzjqc1PXjqswo","m.room.member","@e:x","@e:x",{"displayname":"1","membership":"join"},["$rn16lU0tlWTpfegd106VZvZHaMMvkyDMkcbNsU6J0Xg","$AjT_oEsm9Osg470rOoFoJ-61Kxrqr-3NpzQfX8Yxfy8"],1],["$AwPIb4NOASiwOQUGAJFGImnvmLfzapRW_02P6hjw4Fg","m.room.member","@b:x","@b:x",{"displayname":"2","membership":"join"},["$rn16lU0tlWTpfegd106VZvZHaMMvkyDMkcbNsU6J0Xg","$AjT_oEsm9Osg470rOoFoJ-61Kxrqr-3NpzQfX8Yxfy8","$UOWxwWnRt2ytyBFdp4pagj0q8hl5J4xp1Dgm0iVBSOQ"],5],["$Z6YL6fkKWtcxA_wJQ8exWY5-Oat4NSRgPSWCo7t_Vtk","m.room.topic","","@d:x",{"topic":"3"},["$AjT_oEsm9Osg470rOoFoJ-61Kxrqr-3NpzQfX8Yxfy8","$rgBUpH9g6Qow2n4kBNEBNpEvpxtemVYkL4iT4tEfAbI"],4],["$corZ57mvF6bZFahwYeWXKQ_jpRFfzgpPpHZpZuUvryE","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$AjT_oEsm9Osg470rOoFoJ-61Kxrqr-3NpzQfX8Yxfy8","$rgBUpH9g6Qow2n4kBNEBNpEvpxtemVYkL4iT4tEfAbI"],5]],"states":[["$AjT_oEsm9Osg470rOoFoJ-61Kxrqr-3NpzQfX8Yxfy8","$GkUCa3TZRoa7U6cNl6Yh8TXXfebZz0MqDhcRV8EEJE8","$Pkjcpzq5mrXWEHjnHRAf8ak4ujoGG2rnxS2RgUz_F1M","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$UOWxwWnRt2ytyBFdp4pagj0q8hl5J4xp1Dgm0iVBSOQ","$corZ57mvF6bZFahwYeWXKQ_jpRFfzgpPpHZpZuUvryE","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$l8n7EuGZcmeGs9etGc0oaRyXfzK0SNGzjqc1PXjqswo","$lCioCzWfjGM2f5c3zXG65FHem-KhASthSwP8VoPr3GA","$rgBUpH9g6Qow2n4kBNEBNpEvpxtemVYkL4iT4tEfAbI"],["$AjT_oEsm9Osg470rOoFoJ-61Kxrqr-3NpzQfX8Yxfy8","$AwPIb4NOASiwOQUGAJFGImnvmLfzapRW_02P6hjw4Fg","$GkUCa3TZRoa7U6cNl6Yh8TXXfebZz0MqDhcRV8EEJE8","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$Z6YL6fkKWtcxA_wJQ8exWY5-Oat4NSRgPSWCo7t_Vtk","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$lCioCzWfjGM2f5c3zXG65FHem-KhASthSwP8VoPr3GA","$rgBUpH9g6Qow2n4kBNEBNpEvpxtemVYkL4iT4tEfAbI","$rn16lU0tlWTpfegd106VZvZHaMMvkyDMkcbNsU6J0Xg"]],"resolved":["$AjT_oEsm9Osg470rOoFoJ-61Kxrqr-3NpzQfX8Yxfy8","$AwPIb4NOASiwOQUGAJFGImnvmLfzapRW_02P6hjw4Fg","$GkUCa3TZRoa7U6cNl6Yh8TXXfebZz0MqDhcRV8EEJE8","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$Z6YL6fkKWtcxA_wJQ8exWY5-Oat4NSRgPSWCo7t_Vtk","$corZ57mvF6bZFahwYeWXKQ_jpRFfzgpPpHZpZuUvryE","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$lCioCzWfjGM2f5c3zXG65FHem-KhASthSwP8VoPr3GA","$rgBUpH9g6Qow2n4kBNEBNpEvpxtemVYkL4iT4tEfAbI"]}, +{"name":"ts-2052","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$Z6mX8YxsaU1iap_8b-2fxgMdQavz22wR-drvSsCTCqo","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":25,"m.room.power_levels":50,"m.room.topic":0},"kick":50,"state_default":50,"users":{"@b:x":75,"@c:x":75,"@d:x":0}},["$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],5],["$aTI8LaZfmhlXkXKaSL74uqpUSNtiA0oDotMEEkqSFKs","m.room.join_rules","","@a:x",{"join_rule":"public"},["$Z6mX8YxsaU1iap_8b-2fxgMdQavz22wR-drvSsCTCqo","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],1],["$Vusmt2Y8y057R409QBGoZcm9vupMf3Yo8OL8KwxMdSA","m.room.member","@b:x","@b:x",{"membership":"join"},["$aTI8LaZfmhlXkXKaSL74uqpUSNtiA0oDotMEEkqSFKs","$Z6mX8YxsaU1iap_8b-2fxgMdQavz22wR-drvSsCTCqo"],0],["$OpHgP7kkIsy2wUMFIqAifklrGWsM5EBHcQZ4wpjgezM","m.room.member","@c:x","@c:x",{"membership":"join"},["$aTI8LaZfmhlXkXKaSL74uqpUSNtiA0oDotMEEkqSFKs","$Z6mX8YxsaU1iap_8b-2fxgMdQavz22wR-drvSsCTCqo"],5],["$0iEz4i4hA81VkYPTKZ_kqihB0qraIh12u6Ju8B0eymM","m.room.member","@d:x","@d:x",{"membership":"join"},["$aTI8LaZfmhlXkXKaSL74uqpUSNtiA0oDotMEEkqSFKs","$Z6mX8YxsaU1iap_8b-2fxgMdQavz22wR-drvSsCTCqo"],1],["$fOLVlHfJFnOf5touMto_X43xSRD32hfk_cvY5BRSS_k","m.room.topic","","@a:x",{"topic":"base"},["$Z6mX8YxsaU1iap_8b-2fxgMdQavz22wR-drvSsCTCqo","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],1],["$A5PDQoa9Pn0T-0Mbxk2LXRJ_AUH4Oj_FaFuUkhXGc0k","m.room.name","","@a:x",{"name":"base"},["$Z6mX8YxsaU1iap_8b-2fxgMdQavz22wR-drvSsCTCqo","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],1],["$03lMbZ-E8kwNQMaTlKUBeMSbAUZ7ttvwhQB-oUbMrnU","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$Z6mX8YxsaU1iap_8b-2fxgMdQavz22wR-drvSsCTCqo","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],1],["$TYov_FmgQRA5QvgyiGbE0l3x1aoisLc1Z_qrFi9kQ_k","m.room.member","@e:x","@b:x",{"membership":"invite"},["$03lMbZ-E8kwNQMaTlKUBeMSbAUZ7ttvwhQB-oUbMrnU","$Z6mX8YxsaU1iap_8b-2fxgMdQavz22wR-drvSsCTCqo","$Vusmt2Y8y057R409QBGoZcm9vupMf3Yo8OL8KwxMdSA"],4]],"states":[["$03lMbZ-E8kwNQMaTlKUBeMSbAUZ7ttvwhQB-oUbMrnU","$0iEz4i4hA81VkYPTKZ_kqihB0qraIh12u6Ju8B0eymM","$A5PDQoa9Pn0T-0Mbxk2LXRJ_AUH4Oj_FaFuUkhXGc0k","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$OpHgP7kkIsy2wUMFIqAifklrGWsM5EBHcQZ4wpjgezM","$TYov_FmgQRA5QvgyiGbE0l3x1aoisLc1Z_qrFi9kQ_k","$Vusmt2Y8y057R409QBGoZcm9vupMf3Yo8OL8KwxMdSA","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$Z6mX8YxsaU1iap_8b-2fxgMdQavz22wR-drvSsCTCqo","$fOLVlHfJFnOf5touMto_X43xSRD32hfk_cvY5BRSS_k"],["$03lMbZ-E8kwNQMaTlKUBeMSbAUZ7ttvwhQB-oUbMrnU","$0iEz4i4hA81VkYPTKZ_kqihB0qraIh12u6Ju8B0eymM","$A5PDQoa9Pn0T-0Mbxk2LXRJ_AUH4Oj_FaFuUkhXGc0k","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$OpHgP7kkIsy2wUMFIqAifklrGWsM5EBHcQZ4wpjgezM","$Vusmt2Y8y057R409QBGoZcm9vupMf3Yo8OL8KwxMdSA","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$Z6mX8YxsaU1iap_8b-2fxgMdQavz22wR-drvSsCTCqo","$fOLVlHfJFnOf5touMto_X43xSRD32hfk_cvY5BRSS_k"]],"resolved":["$03lMbZ-E8kwNQMaTlKUBeMSbAUZ7ttvwhQB-oUbMrnU","$0iEz4i4hA81VkYPTKZ_kqihB0qraIh12u6Ju8B0eymM","$A5PDQoa9Pn0T-0Mbxk2LXRJ_AUH4Oj_FaFuUkhXGc0k","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$OpHgP7kkIsy2wUMFIqAifklrGWsM5EBHcQZ4wpjgezM","$TYov_FmgQRA5QvgyiGbE0l3x1aoisLc1Z_qrFi9kQ_k","$Vusmt2Y8y057R409QBGoZcm9vupMf3Yo8OL8KwxMdSA","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$Z6mX8YxsaU1iap_8b-2fxgMdQavz22wR-drvSsCTCqo","$fOLVlHfJFnOf5touMto_X43xSRD32hfk_cvY5BRSS_k"]}, +{"name":"ts-2088","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$v0uFPqaaOe6SWXK1b2FzaeRHXle0wGsKtfnts7tIhe0","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":0},"kick":25,"state_default":50,"users":{"@b:x":0,"@c:x":25,"@d:x":75}},["$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],2],["$9aVL4WZ6DtD5dU0CHqmKiHZSZYgiyMW8hAkrQfoxNpM","m.room.join_rules","","@a:x",{"join_rule":"public"},["$v0uFPqaaOe6SWXK1b2FzaeRHXle0wGsKtfnts7tIhe0","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],4],["$OBZ8jkhuNVUxFMxVn3gNCdBF4cNWB8Jnva5UDe2OHnw","m.room.member","@b:x","@b:x",{"membership":"join"},["$9aVL4WZ6DtD5dU0CHqmKiHZSZYgiyMW8hAkrQfoxNpM","$v0uFPqaaOe6SWXK1b2FzaeRHXle0wGsKtfnts7tIhe0"],1],["$3OXSiUYPlrkYbRWw8zW02NFP9jht1gMlY9oR0lG68Qk","m.room.member","@c:x","@c:x",{"membership":"join"},["$9aVL4WZ6DtD5dU0CHqmKiHZSZYgiyMW8hAkrQfoxNpM","$v0uFPqaaOe6SWXK1b2FzaeRHXle0wGsKtfnts7tIhe0"],0],["$sxpEqI0gl7qQGxDY9rLTSUICiz_XCgEdRSADOaSc_R0","m.room.member","@d:x","@d:x",{"membership":"join"},["$9aVL4WZ6DtD5dU0CHqmKiHZSZYgiyMW8hAkrQfoxNpM","$v0uFPqaaOe6SWXK1b2FzaeRHXle0wGsKtfnts7tIhe0"],3],["$KhQsFRDBkafw57bt3BxgLks7gPYFKFyrLrp1JlnovLc","m.room.name","","@a:x",{"name":"base"},["$v0uFPqaaOe6SWXK1b2FzaeRHXle0wGsKtfnts7tIhe0","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM"],0],["$Id3VS2PeRM18FuBwuuUYMBQ0Wd8Z5CZJa3Obzw4BoWk","m.room.topic","","@d:x",{"topic":"0"},["$v0uFPqaaOe6SWXK1b2FzaeRHXle0wGsKtfnts7tIhe0","$sxpEqI0gl7qQGxDY9rLTSUICiz_XCgEdRSADOaSc_R0"],5],["$D9q5O_ZRb84OGyadzERUF70HxVQZArwwjmFll-1e2s4","m.room.topic","","@c:x",{"topic":"1"},["$v0uFPqaaOe6SWXK1b2FzaeRHXle0wGsKtfnts7tIhe0","$3OXSiUYPlrkYbRWw8zW02NFP9jht1gMlY9oR0lG68Qk"],1],["$TV92LZoLPtH8xRxg-xMil5c6hLCzgM14MvIaHkAzDI8","m.room.topic","","@b:x",{"topic":"2"},["$v0uFPqaaOe6SWXK1b2FzaeRHXle0wGsKtfnts7tIhe0","$OBZ8jkhuNVUxFMxVn3gNCdBF4cNWB8Jnva5UDe2OHnw"],0],["$5MjgU7uGwWWOQ2FxkAAl-2aAx_71WYRaTdeF-SLkhlU","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$v0uFPqaaOe6SWXK1b2FzaeRHXle0wGsKtfnts7tIhe0","$sxpEqI0gl7qQGxDY9rLTSUICiz_XCgEdRSADOaSc_R0"],4]],"states":[["$3OXSiUYPlrkYbRWw8zW02NFP9jht1gMlY9oR0lG68Qk","$5MjgU7uGwWWOQ2FxkAAl-2aAx_71WYRaTdeF-SLkhlU","$KhQsFRDBkafw57bt3BxgLks7gPYFKFyrLrp1JlnovLc","$OBZ8jkhuNVUxFMxVn3gNCdBF4cNWB8Jnva5UDe2OHnw","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","$TV92LZoLPtH8xRxg-xMil5c6hLCzgM14MvIaHkAzDI8","$sxpEqI0gl7qQGxDY9rLTSUICiz_XCgEdRSADOaSc_R0","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$v0uFPqaaOe6SWXK1b2FzaeRHXle0wGsKtfnts7tIhe0"],["$3OXSiUYPlrkYbRWw8zW02NFP9jht1gMlY9oR0lG68Qk","$9aVL4WZ6DtD5dU0CHqmKiHZSZYgiyMW8hAkrQfoxNpM","$Id3VS2PeRM18FuBwuuUYMBQ0Wd8Z5CZJa3Obzw4BoWk","$KhQsFRDBkafw57bt3BxgLks7gPYFKFyrLrp1JlnovLc","$OBZ8jkhuNVUxFMxVn3gNCdBF4cNWB8Jnva5UDe2OHnw","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","$sxpEqI0gl7qQGxDY9rLTSUICiz_XCgEdRSADOaSc_R0","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$v0uFPqaaOe6SWXK1b2FzaeRHXle0wGsKtfnts7tIhe0"],["$3OXSiUYPlrkYbRWw8zW02NFP9jht1gMlY9oR0lG68Qk","$9aVL4WZ6DtD5dU0CHqmKiHZSZYgiyMW8hAkrQfoxNpM","$D9q5O_ZRb84OGyadzERUF70HxVQZArwwjmFll-1e2s4","$KhQsFRDBkafw57bt3BxgLks7gPYFKFyrLrp1JlnovLc","$OBZ8jkhuNVUxFMxVn3gNCdBF4cNWB8Jnva5UDe2OHnw","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","$sxpEqI0gl7qQGxDY9rLTSUICiz_XCgEdRSADOaSc_R0","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$v0uFPqaaOe6SWXK1b2FzaeRHXle0wGsKtfnts7tIhe0"]],"resolved":["$3OXSiUYPlrkYbRWw8zW02NFP9jht1gMlY9oR0lG68Qk","$5MjgU7uGwWWOQ2FxkAAl-2aAx_71WYRaTdeF-SLkhlU","$Id3VS2PeRM18FuBwuuUYMBQ0Wd8Z5CZJa3Obzw4BoWk","$KhQsFRDBkafw57bt3BxgLks7gPYFKFyrLrp1JlnovLc","$OBZ8jkhuNVUxFMxVn3gNCdBF4cNWB8Jnva5UDe2OHnw","$OibzDitMSRWGY7M23FKgwQE7t-VurR8CvSlQ87HZcNM","$sxpEqI0gl7qQGxDY9rLTSUICiz_XCgEdRSADOaSc_R0","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$v0uFPqaaOe6SWXK1b2FzaeRHXle0wGsKtfnts7tIhe0"]}, +{"name":"ts-2104","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$mwLR9rbbkV3PwPwWO7VE6Bfo23g896goz-B7NxtIoFg","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":25},"kick":25,"state_default":0,"users":{"@b:x":0,"@c:x":0,"@d:x":25}},["$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ"],0],["$14RgqxUH5EBKYTBK3CSpksqb81m4hAZUQr8ib9QKBZ4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$mwLR9rbbkV3PwPwWO7VE6Bfo23g896goz-B7NxtIoFg","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ"],5],["$XJtwzLxDFdL_KML4FX32wuFToWdHRY0R1bNhbARWlt4","m.room.member","@b:x","@b:x",{"membership":"join"},["$14RgqxUH5EBKYTBK3CSpksqb81m4hAZUQr8ib9QKBZ4","$mwLR9rbbkV3PwPwWO7VE6Bfo23g896goz-B7NxtIoFg"],2],["$VrPxv1yz92RL_dUuVTbUBSIYD3AGb2m6f2iO4x9Aw0w","m.room.member","@c:x","@c:x",{"membership":"join"},["$14RgqxUH5EBKYTBK3CSpksqb81m4hAZUQr8ib9QKBZ4","$mwLR9rbbkV3PwPwWO7VE6Bfo23g896goz-B7NxtIoFg"],4],["$9bcWFqbeEPgFZBcAZzeKnFqvFgrkWAe9WWdvf9L4sJU","m.room.member","@d:x","@d:x",{"membership":"join"},["$14RgqxUH5EBKYTBK3CSpksqb81m4hAZUQr8ib9QKBZ4","$mwLR9rbbkV3PwPwWO7VE6Bfo23g896goz-B7NxtIoFg"],2],["$5L0EiAKM_LUzNvBmvKY4XjUBLA7XXQCKjqTEvqkQcUc","m.room.topic","","@a:x",{"topic":"base"},["$mwLR9rbbkV3PwPwWO7VE6Bfo23g896goz-B7NxtIoFg","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ"],3],["$9L1-xo4yAn_82Y9PdwFbGYhSD1uYvmfX5oVxXja-bcI","m.room.member","@c:x","@c:x",{"membership":"leave"},["$mwLR9rbbkV3PwPwWO7VE6Bfo23g896goz-B7NxtIoFg","$VrPxv1yz92RL_dUuVTbUBSIYD3AGb2m6f2iO4x9Aw0w"],4],["$1YYiDEm898mW3_XDYQ_gBmRaojB6jXHiP282xfbvIks","m.room.name","","@a:x",{"name":"0"},["$mwLR9rbbkV3PwPwWO7VE6Bfo23g896goz-B7NxtIoFg","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ"],4],["$ntLDYkSWxaJ13jZUKl0YX5lL9-ZSeqpLiJkszuRuOtA","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$mwLR9rbbkV3PwPwWO7VE6Bfo23g896goz-B7NxtIoFg","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ"],1],["$UjMQqBk9auaKr3WzcG5Td-RULFxp01TJ6uetOThBMyk","m.room.member","@d:x","@d:x",{"membership":"leave"},["$mwLR9rbbkV3PwPwWO7VE6Bfo23g896goz-B7NxtIoFg","$9bcWFqbeEPgFZBcAZzeKnFqvFgrkWAe9WWdvf9L4sJU"],2]],"states":[["$1YYiDEm898mW3_XDYQ_gBmRaojB6jXHiP282xfbvIks","$5L0EiAKM_LUzNvBmvKY4XjUBLA7XXQCKjqTEvqkQcUc","$9L1-xo4yAn_82Y9PdwFbGYhSD1uYvmfX5oVxXja-bcI","$9bcWFqbeEPgFZBcAZzeKnFqvFgrkWAe9WWdvf9L4sJU","$XJtwzLxDFdL_KML4FX32wuFToWdHRY0R1bNhbARWlt4","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ","$mwLR9rbbkV3PwPwWO7VE6Bfo23g896goz-B7NxtIoFg","$ntLDYkSWxaJ13jZUKl0YX5lL9-ZSeqpLiJkszuRuOtA"],["$1YYiDEm898mW3_XDYQ_gBmRaojB6jXHiP282xfbvIks","$5L0EiAKM_LUzNvBmvKY4XjUBLA7XXQCKjqTEvqkQcUc","$9L1-xo4yAn_82Y9PdwFbGYhSD1uYvmfX5oVxXja-bcI","$UjMQqBk9auaKr3WzcG5Td-RULFxp01TJ6uetOThBMyk","$XJtwzLxDFdL_KML4FX32wuFToWdHRY0R1bNhbARWlt4","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ","$mwLR9rbbkV3PwPwWO7VE6Bfo23g896goz-B7NxtIoFg","$ntLDYkSWxaJ13jZUKl0YX5lL9-ZSeqpLiJkszuRuOtA"]],"resolved":["$1YYiDEm898mW3_XDYQ_gBmRaojB6jXHiP282xfbvIks","$5L0EiAKM_LUzNvBmvKY4XjUBLA7XXQCKjqTEvqkQcUc","$9L1-xo4yAn_82Y9PdwFbGYhSD1uYvmfX5oVxXja-bcI","$UjMQqBk9auaKr3WzcG5Td-RULFxp01TJ6uetOThBMyk","$XJtwzLxDFdL_KML4FX32wuFToWdHRY0R1bNhbARWlt4","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ","$mwLR9rbbkV3PwPwWO7VE6Bfo23g896goz-B7NxtIoFg","$ntLDYkSWxaJ13jZUKl0YX5lL9-ZSeqpLiJkszuRuOtA"]}, +{"name":"ts-2188","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$F-RmDRYxEinBotazMTb1on6DXPs1xUrSGvqyh33k-C4","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":0,"users":{"@b:x":25,"@c:x":50,"@d:x":75}},["$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],5],["$9We_5ssLTIhdXFLMf9AsE5A9ZPva1JQOtUH1aeNTLrw","m.room.join_rules","","@a:x",{"join_rule":"public"},["$F-RmDRYxEinBotazMTb1on6DXPs1xUrSGvqyh33k-C4","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],3],["$lLCHT58L-sA8l8zJbJZOKGMKLwHgN9NNY9o5y-DkeFQ","m.room.member","@b:x","@b:x",{"membership":"join"},["$9We_5ssLTIhdXFLMf9AsE5A9ZPva1JQOtUH1aeNTLrw","$F-RmDRYxEinBotazMTb1on6DXPs1xUrSGvqyh33k-C4"],5],["$K2bjAFejT0nTGEZm-rchqJkl9stJuj2XFTj33jOP_SE","m.room.member","@c:x","@c:x",{"membership":"join"},["$9We_5ssLTIhdXFLMf9AsE5A9ZPva1JQOtUH1aeNTLrw","$F-RmDRYxEinBotazMTb1on6DXPs1xUrSGvqyh33k-C4"],4],["$loqeXXLQjtWz08fujbzXf8OKqsvKUB9tlH83YqM_T6g","m.room.member","@d:x","@d:x",{"membership":"join"},["$9We_5ssLTIhdXFLMf9AsE5A9ZPva1JQOtUH1aeNTLrw","$F-RmDRYxEinBotazMTb1on6DXPs1xUrSGvqyh33k-C4"],3],["$3c6zkwHsuFsNYk81xpMjQhPfp0nehCoUk3y4LEyCwgY","m.room.topic","","@a:x",{"topic":"base"},["$F-RmDRYxEinBotazMTb1on6DXPs1xUrSGvqyh33k-C4","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],5],["$IWRkKbk5PUi_MAl1LmJvB_Vug-TBqxn1ad8OjkpJY8E","m.room.name","","@a:x",{"name":"base"},["$F-RmDRYxEinBotazMTb1on6DXPs1xUrSGvqyh33k-C4","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],5],["$WRu8qk4laOiu64Mo94ubJsYqfIudeS8tOkJASTUiQl8","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$9We_5ssLTIhdXFLMf9AsE5A9ZPva1JQOtUH1aeNTLrw","$F-RmDRYxEinBotazMTb1on6DXPs1xUrSGvqyh33k-C4","$K2bjAFejT0nTGEZm-rchqJkl9stJuj2XFTj33jOP_SE"],2],["$UC10muX_-2HmD4tooRPTgDhFOMaLRJy6lCzE17SvDx8","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$9We_5ssLTIhdXFLMf9AsE5A9ZPva1JQOtUH1aeNTLrw","$F-RmDRYxEinBotazMTb1on6DXPs1xUrSGvqyh33k-C4","$loqeXXLQjtWz08fujbzXf8OKqsvKUB9tlH83YqM_T6g"],5],["$GPEEx283ZKahsEn0vIU1IUcPMA4nFuISuq8K3pvkgPo","m.room.topic","","@a:x",{"topic":"2"},["$F-RmDRYxEinBotazMTb1on6DXPs1xUrSGvqyh33k-C4","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8"],2]],"states":[["$9We_5ssLTIhdXFLMf9AsE5A9ZPva1JQOtUH1aeNTLrw","$F-RmDRYxEinBotazMTb1on6DXPs1xUrSGvqyh33k-C4","$GPEEx283ZKahsEn0vIU1IUcPMA4nFuISuq8K3pvkgPo","$IWRkKbk5PUi_MAl1LmJvB_Vug-TBqxn1ad8OjkpJY8E","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8","$WRu8qk4laOiu64Mo94ubJsYqfIudeS8tOkJASTUiQl8","$lLCHT58L-sA8l8zJbJZOKGMKLwHgN9NNY9o5y-DkeFQ","$loqeXXLQjtWz08fujbzXf8OKqsvKUB9tlH83YqM_T6g"],["$3c6zkwHsuFsNYk81xpMjQhPfp0nehCoUk3y4LEyCwgY","$9We_5ssLTIhdXFLMf9AsE5A9ZPva1JQOtUH1aeNTLrw","$F-RmDRYxEinBotazMTb1on6DXPs1xUrSGvqyh33k-C4","$IWRkKbk5PUi_MAl1LmJvB_Vug-TBqxn1ad8OjkpJY8E","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8","$UC10muX_-2HmD4tooRPTgDhFOMaLRJy6lCzE17SvDx8","$WRu8qk4laOiu64Mo94ubJsYqfIudeS8tOkJASTUiQl8","$lLCHT58L-sA8l8zJbJZOKGMKLwHgN9NNY9o5y-DkeFQ"]],"resolved":["$3c6zkwHsuFsNYk81xpMjQhPfp0nehCoUk3y4LEyCwgY","$9We_5ssLTIhdXFLMf9AsE5A9ZPva1JQOtUH1aeNTLrw","$F-RmDRYxEinBotazMTb1on6DXPs1xUrSGvqyh33k-C4","$IWRkKbk5PUi_MAl1LmJvB_Vug-TBqxn1ad8OjkpJY8E","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Pp_BkqHXdhwe5v7Q_bXDyrJ48PP4jWNdFTtSuIZm6f8","$UC10muX_-2HmD4tooRPTgDhFOMaLRJy6lCzE17SvDx8","$WRu8qk4laOiu64Mo94ubJsYqfIudeS8tOkJASTUiQl8","$lLCHT58L-sA8l8zJbJZOKGMKLwHgN9NNY9o5y-DkeFQ"]}, +{"name":"ts-2228","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$_dVAiX76D5yY3NzFaGZAyk-Z13j1Yz5bsIBqhhJF2F4","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":75},"kick":75,"state_default":0,"users":{"@b:x":25,"@c:x":50,"@d:x":25}},["$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],5],["$-V_kF68f3gxQI5eyldbRzoFq9MgSMbMvw3JEW0sghMw","m.room.join_rules","","@a:x",{"join_rule":"public"},["$_dVAiX76D5yY3NzFaGZAyk-Z13j1Yz5bsIBqhhJF2F4","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],2],["$5FfqQRcy6l4ZfAki1jzi1CWX-Sl_GXuDfAWVV4-Jpw4","m.room.member","@b:x","@b:x",{"membership":"join"},["$-V_kF68f3gxQI5eyldbRzoFq9MgSMbMvw3JEW0sghMw","$_dVAiX76D5yY3NzFaGZAyk-Z13j1Yz5bsIBqhhJF2F4"],3],["$oiEXp5ZfSfdV7Ta99ZOqnhIrceme_WXyN0WJ0p05DYU","m.room.member","@c:x","@c:x",{"membership":"join"},["$-V_kF68f3gxQI5eyldbRzoFq9MgSMbMvw3JEW0sghMw","$_dVAiX76D5yY3NzFaGZAyk-Z13j1Yz5bsIBqhhJF2F4"],3],["$L53nwlEDdfmxLtHkrsiROR91ChGwzlKn8yqk33r2Ijc","m.room.member","@d:x","@d:x",{"membership":"join"},["$-V_kF68f3gxQI5eyldbRzoFq9MgSMbMvw3JEW0sghMw","$_dVAiX76D5yY3NzFaGZAyk-Z13j1Yz5bsIBqhhJF2F4"],0],["$m03LCLesVlo0a9YGqbl6jjtF7VCh2uyM46dIuTX0wQ0","m.room.topic","","@a:x",{"topic":"base"},["$_dVAiX76D5yY3NzFaGZAyk-Z13j1Yz5bsIBqhhJF2F4","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],4],["$Q2bnBr4DKK6RQ63TbMDoJqDprdWh6T4jLHXvkI16Ry8","m.room.name","","@a:x",{"name":"0"},["$_dVAiX76D5yY3NzFaGZAyk-Z13j1Yz5bsIBqhhJF2F4","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],5],["$x_PAJ1Ep8xqsxRHVbUS2-QFO5WUKxpDgaKzeL9zZwmE","m.room.topic","","@a:x",{"topic":"1"},["$_dVAiX76D5yY3NzFaGZAyk-Z13j1Yz5bsIBqhhJF2F4","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],2],["$KibI_biHsYP64WuLzl5WYWUP_9qCFjGDvvJmA1s_LeY","m.room.join_rules","","@c:x",{"join_rule":"invite"},["$_dVAiX76D5yY3NzFaGZAyk-Z13j1Yz5bsIBqhhJF2F4","$oiEXp5ZfSfdV7Ta99ZOqnhIrceme_WXyN0WJ0p05DYU"],4],["$aWaXCDz64WpsB_vkXL4CCKJMtvo1bE1ELhs-_5OFTUM","m.room.member","@b:x","@b:x",{"membership":"leave"},["$_dVAiX76D5yY3NzFaGZAyk-Z13j1Yz5bsIBqhhJF2F4","$5FfqQRcy6l4ZfAki1jzi1CWX-Sl_GXuDfAWVV4-Jpw4"],2],["$7xj4EktlFQ9D7_7auxqmjUzY_bi-mBrq9A9n19RXyNs","m.room.member","@e:x","@e:x",{"displayname":"2","membership":"join"},["$-V_kF68f3gxQI5eyldbRzoFq9MgSMbMvw3JEW0sghMw","$_dVAiX76D5yY3NzFaGZAyk-Z13j1Yz5bsIBqhhJF2F4"],2]],"states":[["$-V_kF68f3gxQI5eyldbRzoFq9MgSMbMvw3JEW0sghMw","$5FfqQRcy6l4ZfAki1jzi1CWX-Sl_GXuDfAWVV4-Jpw4","$7xj4EktlFQ9D7_7auxqmjUzY_bi-mBrq9A9n19RXyNs","$L53nwlEDdfmxLtHkrsiROR91ChGwzlKn8yqk33r2Ijc","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$Q2bnBr4DKK6RQ63TbMDoJqDprdWh6T4jLHXvkI16Ry8","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$_dVAiX76D5yY3NzFaGZAyk-Z13j1Yz5bsIBqhhJF2F4","$oiEXp5ZfSfdV7Ta99ZOqnhIrceme_WXyN0WJ0p05DYU","$x_PAJ1Ep8xqsxRHVbUS2-QFO5WUKxpDgaKzeL9zZwmE"],["$KibI_biHsYP64WuLzl5WYWUP_9qCFjGDvvJmA1s_LeY","$L53nwlEDdfmxLtHkrsiROR91ChGwzlKn8yqk33r2Ijc","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$Q2bnBr4DKK6RQ63TbMDoJqDprdWh6T4jLHXvkI16Ry8","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$_dVAiX76D5yY3NzFaGZAyk-Z13j1Yz5bsIBqhhJF2F4","$aWaXCDz64WpsB_vkXL4CCKJMtvo1bE1ELhs-_5OFTUM","$m03LCLesVlo0a9YGqbl6jjtF7VCh2uyM46dIuTX0wQ0","$oiEXp5ZfSfdV7Ta99ZOqnhIrceme_WXyN0WJ0p05DYU"]],"resolved":["$KibI_biHsYP64WuLzl5WYWUP_9qCFjGDvvJmA1s_LeY","$L53nwlEDdfmxLtHkrsiROR91ChGwzlKn8yqk33r2Ijc","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$Q2bnBr4DKK6RQ63TbMDoJqDprdWh6T4jLHXvkI16Ry8","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$_dVAiX76D5yY3NzFaGZAyk-Z13j1Yz5bsIBqhhJF2F4","$aWaXCDz64WpsB_vkXL4CCKJMtvo1bE1ELhs-_5OFTUM","$m03LCLesVlo0a9YGqbl6jjtF7VCh2uyM46dIuTX0wQ0","$oiEXp5ZfSfdV7Ta99ZOqnhIrceme_WXyN0WJ0p05DYU"]}, +{"name":"ts-2282","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$68EgoggmnEVSFCZ5fNXJBJwZ0OzMn0Wf8nblK-aVZJo","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":50},"kick":25,"state_default":0,"users":{"@b:x":75,"@c:x":50,"@d:x":75}},["$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],0],["$_yqpwg6EETJ_nx6VdRNvkTlThcNa6dy4FpY7aRWruow","m.room.join_rules","","@a:x",{"join_rule":"public"},["$68EgoggmnEVSFCZ5fNXJBJwZ0OzMn0Wf8nblK-aVZJo","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],0],["$CsDZb5UJj0jgZpIjbDf871pU5_8HNpmsApiMboA76Jk","m.room.member","@b:x","@b:x",{"membership":"join"},["$_yqpwg6EETJ_nx6VdRNvkTlThcNa6dy4FpY7aRWruow","$68EgoggmnEVSFCZ5fNXJBJwZ0OzMn0Wf8nblK-aVZJo"],5],["$0oNba3MvstXPqmBrXToRy5I_uj-smI06u4S1Ry4v3vA","m.room.member","@c:x","@c:x",{"membership":"join"},["$_yqpwg6EETJ_nx6VdRNvkTlThcNa6dy4FpY7aRWruow","$68EgoggmnEVSFCZ5fNXJBJwZ0OzMn0Wf8nblK-aVZJo"],3],["$KU5RBfr4QAE4CdoudhUxk9GtMTAHrATdfQgiaTRfyMo","m.room.member","@d:x","@d:x",{"membership":"join"},["$_yqpwg6EETJ_nx6VdRNvkTlThcNa6dy4FpY7aRWruow","$68EgoggmnEVSFCZ5fNXJBJwZ0OzMn0Wf8nblK-aVZJo"],0],["$jNdTaAhISEg1gvZN7dnHEK3Irto8tYZivLmA1iVX04Y","m.room.topic","","@a:x",{"topic":"base"},["$68EgoggmnEVSFCZ5fNXJBJwZ0OzMn0Wf8nblK-aVZJo","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],4],["$XdJS-7RslonRGpivL19SykHfdKGUTMPEAQvREgaY1YM","m.room.name","","@d:x",{"name":"0"},["$68EgoggmnEVSFCZ5fNXJBJwZ0OzMn0Wf8nblK-aVZJo","$KU5RBfr4QAE4CdoudhUxk9GtMTAHrATdfQgiaTRfyMo"],0],["$sGlRjFfCTFD5V3o6LCtBFLm27R_BCc-l_JkYvbyv8_s","m.room.topic","","@d:x",{"topic":"1"},["$68EgoggmnEVSFCZ5fNXJBJwZ0OzMn0Wf8nblK-aVZJo","$KU5RBfr4QAE4CdoudhUxk9GtMTAHrATdfQgiaTRfyMo"],1],["$BzHX1REw82QXpDI1jJkFVvV-ok4rD2RoRJTS2_XMXCM","m.room.member","@d:x","@d:x",{"membership":"leave"},["$68EgoggmnEVSFCZ5fNXJBJwZ0OzMn0Wf8nblK-aVZJo","$KU5RBfr4QAE4CdoudhUxk9GtMTAHrATdfQgiaTRfyMo"],0],["$LlpLEDwu-bpGlwKQlm26GwreSVNp5Y724U-IedBDvw0","m.room.name","","@a:x",{"name":"2"},["$68EgoggmnEVSFCZ5fNXJBJwZ0OzMn0Wf8nblK-aVZJo","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],1]],"states":[["$0oNba3MvstXPqmBrXToRy5I_uj-smI06u4S1Ry4v3vA","$68EgoggmnEVSFCZ5fNXJBJwZ0OzMn0Wf8nblK-aVZJo","$BzHX1REw82QXpDI1jJkFVvV-ok4rD2RoRJTS2_XMXCM","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$CsDZb5UJj0jgZpIjbDf871pU5_8HNpmsApiMboA76Jk","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","$XdJS-7RslonRGpivL19SykHfdKGUTMPEAQvREgaY1YM","$_yqpwg6EETJ_nx6VdRNvkTlThcNa6dy4FpY7aRWruow","$jNdTaAhISEg1gvZN7dnHEK3Irto8tYZivLmA1iVX04Y"],["$0oNba3MvstXPqmBrXToRy5I_uj-smI06u4S1Ry4v3vA","$68EgoggmnEVSFCZ5fNXJBJwZ0OzMn0Wf8nblK-aVZJo","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$CsDZb5UJj0jgZpIjbDf871pU5_8HNpmsApiMboA76Jk","$KU5RBfr4QAE4CdoudhUxk9GtMTAHrATdfQgiaTRfyMo","$LlpLEDwu-bpGlwKQlm26GwreSVNp5Y724U-IedBDvw0","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","$_yqpwg6EETJ_nx6VdRNvkTlThcNa6dy4FpY7aRWruow","$sGlRjFfCTFD5V3o6LCtBFLm27R_BCc-l_JkYvbyv8_s"]],"resolved":["$0oNba3MvstXPqmBrXToRy5I_uj-smI06u4S1Ry4v3vA","$68EgoggmnEVSFCZ5fNXJBJwZ0OzMn0Wf8nblK-aVZJo","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$CsDZb5UJj0jgZpIjbDf871pU5_8HNpmsApiMboA76Jk","$KU5RBfr4QAE4CdoudhUxk9GtMTAHrATdfQgiaTRfyMo","$LlpLEDwu-bpGlwKQlm26GwreSVNp5Y724U-IedBDvw0","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","$_yqpwg6EETJ_nx6VdRNvkTlThcNa6dy4FpY7aRWruow","$jNdTaAhISEg1gvZN7dnHEK3Irto8tYZivLmA1iVX04Y"]}, +{"name":"ts-2340","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$AfSowKpWccQoLIethMRaCJ7y0qHSnSG_A2SOMb0HJAs","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$TcrPfkPPw-NdyA1j7RkD9yrbKN8fTZv_S57pHgDuCAA","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":0},"kick":25,"state_default":0,"users":{"@b:x":0,"@c:x":25,"@d:x":75}},["$AfSowKpWccQoLIethMRaCJ7y0qHSnSG_A2SOMb0HJAs"],2],["$e4LmefPFdcYrmpbrArQ4-jq3-AYI8PdGmMfutEPClok","m.room.join_rules","","@a:x",{"join_rule":"public"},["$TcrPfkPPw-NdyA1j7RkD9yrbKN8fTZv_S57pHgDuCAA","$AfSowKpWccQoLIethMRaCJ7y0qHSnSG_A2SOMb0HJAs"],5],["$D1a2Hqys-vggU7VqOz3mzH-D98Q8_wzzsswcdni9q7c","m.room.member","@b:x","@b:x",{"membership":"join"},["$e4LmefPFdcYrmpbrArQ4-jq3-AYI8PdGmMfutEPClok","$TcrPfkPPw-NdyA1j7RkD9yrbKN8fTZv_S57pHgDuCAA"],0],["$waD6oY4KQNlU-kcjdeDQKiZNFuC_I_IIBVRWNjmShxU","m.room.member","@c:x","@c:x",{"membership":"join"},["$e4LmefPFdcYrmpbrArQ4-jq3-AYI8PdGmMfutEPClok","$TcrPfkPPw-NdyA1j7RkD9yrbKN8fTZv_S57pHgDuCAA"],0],["$__3ul6vLuQiCJ_0lekEUlrQsH8I2_iPTWbJXq30Jp7g","m.room.member","@d:x","@d:x",{"membership":"join"},["$e4LmefPFdcYrmpbrArQ4-jq3-AYI8PdGmMfutEPClok","$TcrPfkPPw-NdyA1j7RkD9yrbKN8fTZv_S57pHgDuCAA"],1],["$wnaYAu_Rvhde-zVtFnWJiVfV0oaO-hjjX4RyWTrdNkQ","m.room.topic","","@a:x",{"topic":"base"},["$TcrPfkPPw-NdyA1j7RkD9yrbKN8fTZv_S57pHgDuCAA","$AfSowKpWccQoLIethMRaCJ7y0qHSnSG_A2SOMb0HJAs"],0],["$Q5VVCBXbJmES9jUODQeh7lU5gruER82Oi5gzBxGRe7g","m.room.name","","@a:x",{"name":"base"},["$TcrPfkPPw-NdyA1j7RkD9yrbKN8fTZv_S57pHgDuCAA","$AfSowKpWccQoLIethMRaCJ7y0qHSnSG_A2SOMb0HJAs"],1],["$JJlXDiF8yFilxUszXp7lj4yJoVyhv2gzG9LJfNhqEvk","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$e4LmefPFdcYrmpbrArQ4-jq3-AYI8PdGmMfutEPClok","$TcrPfkPPw-NdyA1j7RkD9yrbKN8fTZv_S57pHgDuCAA","$waD6oY4KQNlU-kcjdeDQKiZNFuC_I_IIBVRWNjmShxU"],5]],"states":[["$AfSowKpWccQoLIethMRaCJ7y0qHSnSG_A2SOMb0HJAs","$D1a2Hqys-vggU7VqOz3mzH-D98Q8_wzzsswcdni9q7c","$Q5VVCBXbJmES9jUODQeh7lU5gruER82Oi5gzBxGRe7g","$TcrPfkPPw-NdyA1j7RkD9yrbKN8fTZv_S57pHgDuCAA","$__3ul6vLuQiCJ_0lekEUlrQsH8I2_iPTWbJXq30Jp7g","$e4LmefPFdcYrmpbrArQ4-jq3-AYI8PdGmMfutEPClok","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$waD6oY4KQNlU-kcjdeDQKiZNFuC_I_IIBVRWNjmShxU","$wnaYAu_Rvhde-zVtFnWJiVfV0oaO-hjjX4RyWTrdNkQ"],["$AfSowKpWccQoLIethMRaCJ7y0qHSnSG_A2SOMb0HJAs","$D1a2Hqys-vggU7VqOz3mzH-D98Q8_wzzsswcdni9q7c","$JJlXDiF8yFilxUszXp7lj4yJoVyhv2gzG9LJfNhqEvk","$Q5VVCBXbJmES9jUODQeh7lU5gruER82Oi5gzBxGRe7g","$TcrPfkPPw-NdyA1j7RkD9yrbKN8fTZv_S57pHgDuCAA","$__3ul6vLuQiCJ_0lekEUlrQsH8I2_iPTWbJXq30Jp7g","$e4LmefPFdcYrmpbrArQ4-jq3-AYI8PdGmMfutEPClok","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$wnaYAu_Rvhde-zVtFnWJiVfV0oaO-hjjX4RyWTrdNkQ"],["$AfSowKpWccQoLIethMRaCJ7y0qHSnSG_A2SOMb0HJAs","$D1a2Hqys-vggU7VqOz3mzH-D98Q8_wzzsswcdni9q7c","$Q5VVCBXbJmES9jUODQeh7lU5gruER82Oi5gzBxGRe7g","$TcrPfkPPw-NdyA1j7RkD9yrbKN8fTZv_S57pHgDuCAA","$__3ul6vLuQiCJ_0lekEUlrQsH8I2_iPTWbJXq30Jp7g","$e4LmefPFdcYrmpbrArQ4-jq3-AYI8PdGmMfutEPClok","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$waD6oY4KQNlU-kcjdeDQKiZNFuC_I_IIBVRWNjmShxU","$wnaYAu_Rvhde-zVtFnWJiVfV0oaO-hjjX4RyWTrdNkQ"]],"resolved":["$AfSowKpWccQoLIethMRaCJ7y0qHSnSG_A2SOMb0HJAs","$D1a2Hqys-vggU7VqOz3mzH-D98Q8_wzzsswcdni9q7c","$JJlXDiF8yFilxUszXp7lj4yJoVyhv2gzG9LJfNhqEvk","$Q5VVCBXbJmES9jUODQeh7lU5gruER82Oi5gzBxGRe7g","$TcrPfkPPw-NdyA1j7RkD9yrbKN8fTZv_S57pHgDuCAA","$__3ul6vLuQiCJ_0lekEUlrQsH8I2_iPTWbJXq30Jp7g","$e4LmefPFdcYrmpbrArQ4-jq3-AYI8PdGmMfutEPClok","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$wnaYAu_Rvhde-zVtFnWJiVfV0oaO-hjjX4RyWTrdNkQ"]}, +{"name":"ts-2347","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$SvNmB6tBdqQbGMKReEUerc980zXPfLaBRugDnyC4jqk","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":50,"users":{"@b:x":0,"@c:x":0,"@d:x":50}},["$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],2],["$XfkFlzG7ND8quRaf0hNoTjeBteI1uLF6oZGW3qqag20","m.room.join_rules","","@a:x",{"join_rule":"public"},["$SvNmB6tBdqQbGMKReEUerc980zXPfLaBRugDnyC4jqk","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],3],["$ogTKbFhXWLYxRUYBQXi-RnS2Iv120H2mknO-o13z1Ew","m.room.member","@b:x","@b:x",{"membership":"join"},["$XfkFlzG7ND8quRaf0hNoTjeBteI1uLF6oZGW3qqag20","$SvNmB6tBdqQbGMKReEUerc980zXPfLaBRugDnyC4jqk"],0],["$Dl66DiM3Y-rO-RchafOfKonkc3Cjw5y6Od7VVUAPi38","m.room.member","@c:x","@c:x",{"membership":"join"},["$XfkFlzG7ND8quRaf0hNoTjeBteI1uLF6oZGW3qqag20","$SvNmB6tBdqQbGMKReEUerc980zXPfLaBRugDnyC4jqk"],3],["$2fHurw28oASKNyAxihe4asGjBEXBtON3NnSdmPGVFUM","m.room.member","@d:x","@d:x",{"membership":"join"},["$XfkFlzG7ND8quRaf0hNoTjeBteI1uLF6oZGW3qqag20","$SvNmB6tBdqQbGMKReEUerc980zXPfLaBRugDnyC4jqk"],2],["$cROrMf7LRsFXfDgr-PT2MJvundI1hj4WElBL2R0cALk","m.room.topic","","@a:x",{"topic":"base"},["$SvNmB6tBdqQbGMKReEUerc980zXPfLaBRugDnyC4jqk","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],2],["$SPDuI_SiCmX6tF9Z300kVOA2mFylPnIRDOPozMF5NPQ","m.room.name","","@a:x",{"name":"base"},["$SvNmB6tBdqQbGMKReEUerc980zXPfLaBRugDnyC4jqk","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],1],["$pi0jmPDa-LwzAPfudZ9K15ObTJvOCY6cwKcHlFu_Ch8","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$SvNmB6tBdqQbGMKReEUerc980zXPfLaBRugDnyC4jqk","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg"],4],["$rI6-HcOpFuSeU8P9tsAN2qJcaLmhJ3_sMlIezXKTQjM","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$XfkFlzG7ND8quRaf0hNoTjeBteI1uLF6oZGW3qqag20","$SvNmB6tBdqQbGMKReEUerc980zXPfLaBRugDnyC4jqk","$Dl66DiM3Y-rO-RchafOfKonkc3Cjw5y6Od7VVUAPi38"],3]],"states":[["$2fHurw28oASKNyAxihe4asGjBEXBtON3NnSdmPGVFUM","$Dl66DiM3Y-rO-RchafOfKonkc3Cjw5y6Od7VVUAPi38","$SPDuI_SiCmX6tF9Z300kVOA2mFylPnIRDOPozMF5NPQ","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$SvNmB6tBdqQbGMKReEUerc980zXPfLaBRugDnyC4jqk","$cROrMf7LRsFXfDgr-PT2MJvundI1hj4WElBL2R0cALk","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$ogTKbFhXWLYxRUYBQXi-RnS2Iv120H2mknO-o13z1Ew","$pi0jmPDa-LwzAPfudZ9K15ObTJvOCY6cwKcHlFu_Ch8"],["$2fHurw28oASKNyAxihe4asGjBEXBtON3NnSdmPGVFUM","$SPDuI_SiCmX6tF9Z300kVOA2mFylPnIRDOPozMF5NPQ","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$SvNmB6tBdqQbGMKReEUerc980zXPfLaBRugDnyC4jqk","$XfkFlzG7ND8quRaf0hNoTjeBteI1uLF6oZGW3qqag20","$cROrMf7LRsFXfDgr-PT2MJvundI1hj4WElBL2R0cALk","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$ogTKbFhXWLYxRUYBQXi-RnS2Iv120H2mknO-o13z1Ew","$rI6-HcOpFuSeU8P9tsAN2qJcaLmhJ3_sMlIezXKTQjM"]],"resolved":["$2fHurw28oASKNyAxihe4asGjBEXBtON3NnSdmPGVFUM","$SPDuI_SiCmX6tF9Z300kVOA2mFylPnIRDOPozMF5NPQ","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$SvNmB6tBdqQbGMKReEUerc980zXPfLaBRugDnyC4jqk","$cROrMf7LRsFXfDgr-PT2MJvundI1hj4WElBL2R0cALk","$n_u6u-DvCUyMo0kAFGLcOe3YVVILaDGgtcmyU-AoUgg","$ogTKbFhXWLYxRUYBQXi-RnS2Iv120H2mknO-o13z1Ew","$pi0jmPDa-LwzAPfudZ9K15ObTJvOCY6cwKcHlFu_Ch8","$rI6-HcOpFuSeU8P9tsAN2qJcaLmhJ3_sMlIezXKTQjM"]}, +{"name":"ts-2354","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$2O2AxTwjTjob9K-HkJv_gOZ9M_08WBsz4ZszAsPSg38","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":25},"kick":50,"state_default":50,"users":{"@b:x":0,"@c:x":75,"@d:x":0}},["$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0"],0],["$LWmnJPwCSM0_51EY5FfvZXLKCDWK39cGAaVjVhcbpcc","m.room.join_rules","","@a:x",{"join_rule":"public"},["$2O2AxTwjTjob9K-HkJv_gOZ9M_08WBsz4ZszAsPSg38","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0"],3],["$oFDDGEcu8r0kXJyAD-PC1cI2ba5JUhVbGs2uBTjrcZI","m.room.member","@b:x","@b:x",{"membership":"join"},["$LWmnJPwCSM0_51EY5FfvZXLKCDWK39cGAaVjVhcbpcc","$2O2AxTwjTjob9K-HkJv_gOZ9M_08WBsz4ZszAsPSg38"],3],["$gh0NwHuVMzx0E4puK3F_BD9djYtuLvFSHeD5KeiMMz4","m.room.member","@c:x","@c:x",{"membership":"join"},["$LWmnJPwCSM0_51EY5FfvZXLKCDWK39cGAaVjVhcbpcc","$2O2AxTwjTjob9K-HkJv_gOZ9M_08WBsz4ZszAsPSg38"],2],["$cv6D2w3IE14j90ea9ZK-8SVQUM3EZYBZQl0t8G1SNis","m.room.member","@d:x","@d:x",{"membership":"join"},["$LWmnJPwCSM0_51EY5FfvZXLKCDWK39cGAaVjVhcbpcc","$2O2AxTwjTjob9K-HkJv_gOZ9M_08WBsz4ZszAsPSg38"],2],["$U-up8zN8o_ldjA4pNoCbTD0iyhzsBKtWJTyZucdUNbM","m.room.topic","","@a:x",{"topic":"base"},["$2O2AxTwjTjob9K-HkJv_gOZ9M_08WBsz4ZszAsPSg38","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0"],5],["$Ujrl_eRTVGB1qz6Q0BwEZ80aaU4ZZ78Gk43IRHab_Y8","m.room.member","@b:x","@a:x",{"membership":"ban"},["$2O2AxTwjTjob9K-HkJv_gOZ9M_08WBsz4ZszAsPSg38","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0","$oFDDGEcu8r0kXJyAD-PC1cI2ba5JUhVbGs2uBTjrcZI"],3],["$BE5ji20RFv46-_LXycWz6IWXbXfp1uhlfzADMPdAJRw","m.room.name","","@a:x",{"name":"0"},["$2O2AxTwjTjob9K-HkJv_gOZ9M_08WBsz4ZszAsPSg38","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0"],4],["$lriY40WndgTZCRh08ftBrY_GwJjIky6Xrwn_u2BRbTg","m.room.member","@c:x","@c:x",{"displayname":"1","membership":"join"},["$LWmnJPwCSM0_51EY5FfvZXLKCDWK39cGAaVjVhcbpcc","$2O2AxTwjTjob9K-HkJv_gOZ9M_08WBsz4ZszAsPSg38","$gh0NwHuVMzx0E4puK3F_BD9djYtuLvFSHeD5KeiMMz4"],0],["$C386LWp3EsoNRqD3VWWWJi79Lij3Dys7Pi2gSxk-Hjw","m.room.topic","","@c:x",{"topic":"2"},["$2O2AxTwjTjob9K-HkJv_gOZ9M_08WBsz4ZszAsPSg38","$lriY40WndgTZCRh08ftBrY_GwJjIky6Xrwn_u2BRbTg"],2]],"states":[["$2O2AxTwjTjob9K-HkJv_gOZ9M_08WBsz4ZszAsPSg38","$BE5ji20RFv46-_LXycWz6IWXbXfp1uhlfzADMPdAJRw","$LWmnJPwCSM0_51EY5FfvZXLKCDWK39cGAaVjVhcbpcc","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0","$U-up8zN8o_ldjA4pNoCbTD0iyhzsBKtWJTyZucdUNbM","$Ujrl_eRTVGB1qz6Q0BwEZ80aaU4ZZ78Gk43IRHab_Y8","$cv6D2w3IE14j90ea9ZK-8SVQUM3EZYBZQl0t8G1SNis","$gh0NwHuVMzx0E4puK3F_BD9djYtuLvFSHeD5KeiMMz4"],["$2O2AxTwjTjob9K-HkJv_gOZ9M_08WBsz4ZszAsPSg38","$BE5ji20RFv46-_LXycWz6IWXbXfp1uhlfzADMPdAJRw","$C386LWp3EsoNRqD3VWWWJi79Lij3Dys7Pi2gSxk-Hjw","$LWmnJPwCSM0_51EY5FfvZXLKCDWK39cGAaVjVhcbpcc","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0","$Ujrl_eRTVGB1qz6Q0BwEZ80aaU4ZZ78Gk43IRHab_Y8","$cv6D2w3IE14j90ea9ZK-8SVQUM3EZYBZQl0t8G1SNis","$lriY40WndgTZCRh08ftBrY_GwJjIky6Xrwn_u2BRbTg"]],"resolved":["$2O2AxTwjTjob9K-HkJv_gOZ9M_08WBsz4ZszAsPSg38","$BE5ji20RFv46-_LXycWz6IWXbXfp1uhlfzADMPdAJRw","$LWmnJPwCSM0_51EY5FfvZXLKCDWK39cGAaVjVhcbpcc","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0","$U-up8zN8o_ldjA4pNoCbTD0iyhzsBKtWJTyZucdUNbM","$Ujrl_eRTVGB1qz6Q0BwEZ80aaU4ZZ78Gk43IRHab_Y8","$cv6D2w3IE14j90ea9ZK-8SVQUM3EZYBZQl0t8G1SNis","$gh0NwHuVMzx0E4puK3F_BD9djYtuLvFSHeD5KeiMMz4"]}, +{"name":"ts-2379","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$V3ZyLu_CFxy1Zp52VNufk-LcgsDAebEzQ0gXV5dohHg","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":50,"m.room.topic":25},"kick":75,"state_default":0,"users":{"@b:x":75,"@c:x":75,"@d:x":50}},["$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],0],["$n2RdAJwG6fNh9gc0bXsxDli0rwUfD9ibTSdA5zN0epE","m.room.join_rules","","@a:x",{"join_rule":"public"},["$V3ZyLu_CFxy1Zp52VNufk-LcgsDAebEzQ0gXV5dohHg","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],4],["$Oyt7g20bShBz8AmsHLfwydqmaFpBaaJcNLrfjayv5hE","m.room.member","@b:x","@b:x",{"membership":"join"},["$n2RdAJwG6fNh9gc0bXsxDli0rwUfD9ibTSdA5zN0epE","$V3ZyLu_CFxy1Zp52VNufk-LcgsDAebEzQ0gXV5dohHg"],3],["$kw2jI1aQ79wYGpgNw8pf9oBSjXcXaspR0_J7o8QA_SQ","m.room.member","@c:x","@c:x",{"membership":"join"},["$n2RdAJwG6fNh9gc0bXsxDli0rwUfD9ibTSdA5zN0epE","$V3ZyLu_CFxy1Zp52VNufk-LcgsDAebEzQ0gXV5dohHg"],3],["$C5ZFKFU_bFU4DCvwlAMlnCG3QEgqQVVU62sIEJdXffY","m.room.member","@d:x","@d:x",{"membership":"join"},["$n2RdAJwG6fNh9gc0bXsxDli0rwUfD9ibTSdA5zN0epE","$V3ZyLu_CFxy1Zp52VNufk-LcgsDAebEzQ0gXV5dohHg"],4],["$iFkfiYLOrydoJJF7TkCrzIhq8Nx8dKQZkGzv8EZ072E","m.room.topic","","@a:x",{"topic":"base"},["$V3ZyLu_CFxy1Zp52VNufk-LcgsDAebEzQ0gXV5dohHg","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],4],["$gP-8jFDw9wPvnEjm6ycUwHez8-NtR40EQ7mn4R3KNeI","m.room.name","","@a:x",{"name":"base"},["$V3ZyLu_CFxy1Zp52VNufk-LcgsDAebEzQ0gXV5dohHg","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M"],3],["$yr3hSUiygy4rU7ZsNkgAK86MeZcGCQ6IU1feSl96Le8","m.room.name","","@c:x",{"name":"0"},["$V3ZyLu_CFxy1Zp52VNufk-LcgsDAebEzQ0gXV5dohHg","$kw2jI1aQ79wYGpgNw8pf9oBSjXcXaspR0_J7o8QA_SQ"],4],["$mPxzJos4BvIvRY76-YGTsMd3lV0tiOfN-n5TrquK9q0","m.room.topic","","@c:x",{"topic":"1"},["$V3ZyLu_CFxy1Zp52VNufk-LcgsDAebEzQ0gXV5dohHg","$kw2jI1aQ79wYGpgNw8pf9oBSjXcXaspR0_J7o8QA_SQ"],2],["$8yoSdeB16x6oAPlfW3PkH0i5i87xYXuPVUxM94s0ITY","m.room.member","@e:x","@e:x",{"displayname":"2","membership":"join"},["$n2RdAJwG6fNh9gc0bXsxDli0rwUfD9ibTSdA5zN0epE","$V3ZyLu_CFxy1Zp52VNufk-LcgsDAebEzQ0gXV5dohHg"],0]],"states":[["$8yoSdeB16x6oAPlfW3PkH0i5i87xYXuPVUxM94s0ITY","$C5ZFKFU_bFU4DCvwlAMlnCG3QEgqQVVU62sIEJdXffY","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$Oyt7g20bShBz8AmsHLfwydqmaFpBaaJcNLrfjayv5hE","$V3ZyLu_CFxy1Zp52VNufk-LcgsDAebEzQ0gXV5dohHg","$iFkfiYLOrydoJJF7TkCrzIhq8Nx8dKQZkGzv8EZ072E","$kw2jI1aQ79wYGpgNw8pf9oBSjXcXaspR0_J7o8QA_SQ","$n2RdAJwG6fNh9gc0bXsxDli0rwUfD9ibTSdA5zN0epE","$yr3hSUiygy4rU7ZsNkgAK86MeZcGCQ6IU1feSl96Le8"],["$C5ZFKFU_bFU4DCvwlAMlnCG3QEgqQVVU62sIEJdXffY","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$Oyt7g20bShBz8AmsHLfwydqmaFpBaaJcNLrfjayv5hE","$V3ZyLu_CFxy1Zp52VNufk-LcgsDAebEzQ0gXV5dohHg","$gP-8jFDw9wPvnEjm6ycUwHez8-NtR40EQ7mn4R3KNeI","$kw2jI1aQ79wYGpgNw8pf9oBSjXcXaspR0_J7o8QA_SQ","$mPxzJos4BvIvRY76-YGTsMd3lV0tiOfN-n5TrquK9q0","$n2RdAJwG6fNh9gc0bXsxDli0rwUfD9ibTSdA5zN0epE"]],"resolved":["$8yoSdeB16x6oAPlfW3PkH0i5i87xYXuPVUxM94s0ITY","$C5ZFKFU_bFU4DCvwlAMlnCG3QEgqQVVU62sIEJdXffY","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$FH-zIduVFfqQOzHBLcL3ZmFvDEnz4BDjjwWZZYXSy5M","$Oyt7g20bShBz8AmsHLfwydqmaFpBaaJcNLrfjayv5hE","$V3ZyLu_CFxy1Zp52VNufk-LcgsDAebEzQ0gXV5dohHg","$iFkfiYLOrydoJJF7TkCrzIhq8Nx8dKQZkGzv8EZ072E","$kw2jI1aQ79wYGpgNw8pf9oBSjXcXaspR0_J7o8QA_SQ","$n2RdAJwG6fNh9gc0bXsxDli0rwUfD9ibTSdA5zN0epE","$yr3hSUiygy4rU7ZsNkgAK86MeZcGCQ6IU1feSl96Le8"]}, +{"name":"ts-2392","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$V7OVpwPzzNGthQiLHOefR4_dhBGJ-fxbZTnCLTXVlnw","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":25},"kick":75,"state_default":0,"users":{"@b:x":75,"@c:x":75,"@d:x":75}},["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],1],["$zCIeprvIRQCJnpW2IbZeGLcnb4qmMAye9tXtlSRQnXE","m.room.join_rules","","@a:x",{"join_rule":"public"},["$V7OVpwPzzNGthQiLHOefR4_dhBGJ-fxbZTnCLTXVlnw","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],3],["$SRe9wii9UekYNcLbobrNEqUZBx3YX9HZPOfLxEWsttg","m.room.member","@b:x","@b:x",{"membership":"join"},["$zCIeprvIRQCJnpW2IbZeGLcnb4qmMAye9tXtlSRQnXE","$V7OVpwPzzNGthQiLHOefR4_dhBGJ-fxbZTnCLTXVlnw"],0],["$jw8uDv4HTHriNEP7-_yM9AdbwyggmtfKwehiLEl_NiY","m.room.member","@c:x","@c:x",{"membership":"join"},["$zCIeprvIRQCJnpW2IbZeGLcnb4qmMAye9tXtlSRQnXE","$V7OVpwPzzNGthQiLHOefR4_dhBGJ-fxbZTnCLTXVlnw"],4],["$pqe48kKQuVfujl_1bpCNmnrsKcbXs2nqgG2S7A9l-kQ","m.room.member","@d:x","@d:x",{"membership":"join"},["$zCIeprvIRQCJnpW2IbZeGLcnb4qmMAye9tXtlSRQnXE","$V7OVpwPzzNGthQiLHOefR4_dhBGJ-fxbZTnCLTXVlnw"],2],["$qHfS1Enqi2269d7C5RI4NxI_nylvMPm_ySxNkwWp6tM","m.room.name","","@a:x",{"name":"base"},["$V7OVpwPzzNGthQiLHOefR4_dhBGJ-fxbZTnCLTXVlnw","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],3],["$5PDN7ikNPgmMb_GaXgiVBvgPj3mR_y4eUawpNZvGw3k","m.room.member","@c:x","@c:x",{"membership":"leave"},["$V7OVpwPzzNGthQiLHOefR4_dhBGJ-fxbZTnCLTXVlnw","$jw8uDv4HTHriNEP7-_yM9AdbwyggmtfKwehiLEl_NiY"],2],["$RjWWR6_zoRA8qkqWk2k5Ya-M1tovpDQXbm6mmtZIF7A","m.room.topic","","@a:x",{"topic":"0"},["$V7OVpwPzzNGthQiLHOefR4_dhBGJ-fxbZTnCLTXVlnw","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],4],["$_utFz55fbciWjcSSBci7oj8DqwGiTKxu2JY6XHZYYRM","m.room.member","@d:x","@d:x",{"membership":"leave"},["$V7OVpwPzzNGthQiLHOefR4_dhBGJ-fxbZTnCLTXVlnw","$pqe48kKQuVfujl_1bpCNmnrsKcbXs2nqgG2S7A9l-kQ"],0],["$hTEXN4cv8bWjp_Kn3Kwac7XfD_71SBb1LQTl-6eFPKM","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$V7OVpwPzzNGthQiLHOefR4_dhBGJ-fxbZTnCLTXVlnw","$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4"],1]],"states":[["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$5PDN7ikNPgmMb_GaXgiVBvgPj3mR_y4eUawpNZvGw3k","$RjWWR6_zoRA8qkqWk2k5Ya-M1tovpDQXbm6mmtZIF7A","$SRe9wii9UekYNcLbobrNEqUZBx3YX9HZPOfLxEWsttg","$V7OVpwPzzNGthQiLHOefR4_dhBGJ-fxbZTnCLTXVlnw","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$hTEXN4cv8bWjp_Kn3Kwac7XfD_71SBb1LQTl-6eFPKM","$pqe48kKQuVfujl_1bpCNmnrsKcbXs2nqgG2S7A9l-kQ","$qHfS1Enqi2269d7C5RI4NxI_nylvMPm_ySxNkwWp6tM"],["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$5PDN7ikNPgmMb_GaXgiVBvgPj3mR_y4eUawpNZvGw3k","$RjWWR6_zoRA8qkqWk2k5Ya-M1tovpDQXbm6mmtZIF7A","$SRe9wii9UekYNcLbobrNEqUZBx3YX9HZPOfLxEWsttg","$V7OVpwPzzNGthQiLHOefR4_dhBGJ-fxbZTnCLTXVlnw","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$_utFz55fbciWjcSSBci7oj8DqwGiTKxu2JY6XHZYYRM","$qHfS1Enqi2269d7C5RI4NxI_nylvMPm_ySxNkwWp6tM","$zCIeprvIRQCJnpW2IbZeGLcnb4qmMAye9tXtlSRQnXE"]],"resolved":["$3kh4a7TsA_YP74pv84r0mwBs8u2ufIAv6BRHUX8qiu4","$5PDN7ikNPgmMb_GaXgiVBvgPj3mR_y4eUawpNZvGw3k","$RjWWR6_zoRA8qkqWk2k5Ya-M1tovpDQXbm6mmtZIF7A","$SRe9wii9UekYNcLbobrNEqUZBx3YX9HZPOfLxEWsttg","$V7OVpwPzzNGthQiLHOefR4_dhBGJ-fxbZTnCLTXVlnw","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$pqe48kKQuVfujl_1bpCNmnrsKcbXs2nqgG2S7A9l-kQ","$qHfS1Enqi2269d7C5RI4NxI_nylvMPm_ySxNkwWp6tM","$zCIeprvIRQCJnpW2IbZeGLcnb4qmMAye9tXtlSRQnXE"]}, +{"name":"ts-2403","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$Xp8j6MpzjSJpZaz2btTBJjoGTxPyifvrMCoqIxpGl_0","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":0},"kick":25,"state_default":0,"users":{"@b:x":0,"@c:x":25,"@d:x":25}},["$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],4],["$UTE97xzh1Z0QlsoaR6OrDngvmBqYzqCwKAYpQOnonro","m.room.join_rules","","@a:x",{"join_rule":"public"},["$Xp8j6MpzjSJpZaz2btTBJjoGTxPyifvrMCoqIxpGl_0","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],0],["$hLQ6V44YRhqsmfQItITHxTWtc9PPU-RYUmNTqJkmc5Y","m.room.member","@b:x","@b:x",{"membership":"join"},["$UTE97xzh1Z0QlsoaR6OrDngvmBqYzqCwKAYpQOnonro","$Xp8j6MpzjSJpZaz2btTBJjoGTxPyifvrMCoqIxpGl_0"],4],["$gplxGS70wa2guXC1Dm4fDl9OXIij02BiQLSk1KUSD9o","m.room.member","@c:x","@c:x",{"membership":"join"},["$UTE97xzh1Z0QlsoaR6OrDngvmBqYzqCwKAYpQOnonro","$Xp8j6MpzjSJpZaz2btTBJjoGTxPyifvrMCoqIxpGl_0"],3],["$vozgvMcf94jCZDCdfhgMb6WR7OzU5WAqxCGQK0KLqbU","m.room.member","@d:x","@d:x",{"membership":"join"},["$UTE97xzh1Z0QlsoaR6OrDngvmBqYzqCwKAYpQOnonro","$Xp8j6MpzjSJpZaz2btTBJjoGTxPyifvrMCoqIxpGl_0"],3],["$0hMQe7ma_NnM5Xt8LXtzGbJQ9Xsjrqyg4Vj_uCfsOko","m.room.name","","@a:x",{"name":"base"},["$Xp8j6MpzjSJpZaz2btTBJjoGTxPyifvrMCoqIxpGl_0","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],1],["$pcruu37XWoIZ4F5oBj3kd49Svl3ZBk1IOVx96b7At4Y","m.room.join_rules","","@a:x",{"join_rule":"public"},["$Xp8j6MpzjSJpZaz2btTBJjoGTxPyifvrMCoqIxpGl_0","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk"],1],["$8WDA5UY5jAUnr0EBjFI9fgVOMfbz6sh9-7puayoap1s","m.room.topic","","@c:x",{"topic":"0"},["$Xp8j6MpzjSJpZaz2btTBJjoGTxPyifvrMCoqIxpGl_0","$gplxGS70wa2guXC1Dm4fDl9OXIij02BiQLSk1KUSD9o"],5],["$bFH38wrdF6Iz72AvyBMAs6aZs4o55FCUAGr4oOG8XOk","m.room.topic","","@d:x",{"topic":"1"},["$Xp8j6MpzjSJpZaz2btTBJjoGTxPyifvrMCoqIxpGl_0","$vozgvMcf94jCZDCdfhgMb6WR7OzU5WAqxCGQK0KLqbU"],3],["$260diDXzyqD9i0xbp4aH6zCov7Jn7U_PfJYHXpVP9K0","m.room.member","@e:x","@e:x",{"displayname":"2","membership":"join"},["$UTE97xzh1Z0QlsoaR6OrDngvmBqYzqCwKAYpQOnonro","$Xp8j6MpzjSJpZaz2btTBJjoGTxPyifvrMCoqIxpGl_0"],2]],"states":[["$0hMQe7ma_NnM5Xt8LXtzGbJQ9Xsjrqyg4Vj_uCfsOko","$260diDXzyqD9i0xbp4aH6zCov7Jn7U_PfJYHXpVP9K0","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$UTE97xzh1Z0QlsoaR6OrDngvmBqYzqCwKAYpQOnonro","$Xp8j6MpzjSJpZaz2btTBJjoGTxPyifvrMCoqIxpGl_0","$bFH38wrdF6Iz72AvyBMAs6aZs4o55FCUAGr4oOG8XOk","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$gplxGS70wa2guXC1Dm4fDl9OXIij02BiQLSk1KUSD9o","$hLQ6V44YRhqsmfQItITHxTWtc9PPU-RYUmNTqJkmc5Y","$vozgvMcf94jCZDCdfhgMb6WR7OzU5WAqxCGQK0KLqbU"],["$0hMQe7ma_NnM5Xt8LXtzGbJQ9Xsjrqyg4Vj_uCfsOko","$8WDA5UY5jAUnr0EBjFI9fgVOMfbz6sh9-7puayoap1s","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$Xp8j6MpzjSJpZaz2btTBJjoGTxPyifvrMCoqIxpGl_0","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$gplxGS70wa2guXC1Dm4fDl9OXIij02BiQLSk1KUSD9o","$hLQ6V44YRhqsmfQItITHxTWtc9PPU-RYUmNTqJkmc5Y","$pcruu37XWoIZ4F5oBj3kd49Svl3ZBk1IOVx96b7At4Y","$vozgvMcf94jCZDCdfhgMb6WR7OzU5WAqxCGQK0KLqbU"]],"resolved":["$0hMQe7ma_NnM5Xt8LXtzGbJQ9Xsjrqyg4Vj_uCfsOko","$260diDXzyqD9i0xbp4aH6zCov7Jn7U_PfJYHXpVP9K0","$8WDA5UY5jAUnr0EBjFI9fgVOMfbz6sh9-7puayoap1s","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$Xp8j6MpzjSJpZaz2btTBJjoGTxPyifvrMCoqIxpGl_0","$earEQfwPdICJ_uITL4mHY4HTBWtCo2QuLqxyHOahCpk","$gplxGS70wa2guXC1Dm4fDl9OXIij02BiQLSk1KUSD9o","$hLQ6V44YRhqsmfQItITHxTWtc9PPU-RYUmNTqJkmc5Y","$pcruu37XWoIZ4F5oBj3kd49Svl3ZBk1IOVx96b7At4Y","$vozgvMcf94jCZDCdfhgMb6WR7OzU5WAqxCGQK0KLqbU"]}, +{"name":"ts-2417","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$8dhvTQo8Y9upjgcswo8uYNohKUxUpANGqwTk9zjwh_I","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":50,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@b:x":75,"@c:x":25,"@d:x":50}},["$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ"],1],["$dpX0bBi9AFKvJPKvoySxcpC5gS-BXfNCuM1Qt12obF8","m.room.join_rules","","@a:x",{"join_rule":"public"},["$8dhvTQo8Y9upjgcswo8uYNohKUxUpANGqwTk9zjwh_I","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ"],2],["$XTVXOyr0fVkSvsvVdYtZnoh89-carr613wqYYod8y2E","m.room.member","@b:x","@b:x",{"membership":"join"},["$dpX0bBi9AFKvJPKvoySxcpC5gS-BXfNCuM1Qt12obF8","$8dhvTQo8Y9upjgcswo8uYNohKUxUpANGqwTk9zjwh_I"],1],["$6O6mJcQKlPSueicLz-IxBPNP2BX6f2CmlMUDd49YyY0","m.room.member","@c:x","@c:x",{"membership":"join"},["$dpX0bBi9AFKvJPKvoySxcpC5gS-BXfNCuM1Qt12obF8","$8dhvTQo8Y9upjgcswo8uYNohKUxUpANGqwTk9zjwh_I"],5],["$JNTmNyIAjgeFTnYZ3AXtU9Rp1LHywzupd80k6KKnPAk","m.room.member","@d:x","@d:x",{"membership":"join"},["$dpX0bBi9AFKvJPKvoySxcpC5gS-BXfNCuM1Qt12obF8","$8dhvTQo8Y9upjgcswo8uYNohKUxUpANGqwTk9zjwh_I"],0],["$LIt-diSt8bK6m4Tbmou6S_Aey0Dp7Q3N4dE75vJkThc","m.room.name","","@a:x",{"name":"base"},["$8dhvTQo8Y9upjgcswo8uYNohKUxUpANGqwTk9zjwh_I","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ"],3],["$RgbfYt2U7e7iBkSs5PYX1HsF7YIdAROXjw7H06jfGSM","m.room.topic","","@b:x",{"topic":"0"},["$8dhvTQo8Y9upjgcswo8uYNohKUxUpANGqwTk9zjwh_I","$XTVXOyr0fVkSvsvVdYtZnoh89-carr613wqYYod8y2E"],0],["$i8wAYgfU2JaPVeSYtECOEzcfm0iojzIv6BGT50Tlsns","m.room.member","@e:x","@e:x",{"displayname":"1","membership":"join"},["$dpX0bBi9AFKvJPKvoySxcpC5gS-BXfNCuM1Qt12obF8","$8dhvTQo8Y9upjgcswo8uYNohKUxUpANGqwTk9zjwh_I"],1],["$xyWWJcO6gvaAX7HP_00DgcXZZtz3d-uNwdbctslpaiM","m.room.member","@e:x","@e:x",{"membership":"leave"},["$8dhvTQo8Y9upjgcswo8uYNohKUxUpANGqwTk9zjwh_I","$i8wAYgfU2JaPVeSYtECOEzcfm0iojzIv6BGT50Tlsns"],5]],"states":[["$6O6mJcQKlPSueicLz-IxBPNP2BX6f2CmlMUDd49YyY0","$8dhvTQo8Y9upjgcswo8uYNohKUxUpANGqwTk9zjwh_I","$JNTmNyIAjgeFTnYZ3AXtU9Rp1LHywzupd80k6KKnPAk","$LIt-diSt8bK6m4Tbmou6S_Aey0Dp7Q3N4dE75vJkThc","$RgbfYt2U7e7iBkSs5PYX1HsF7YIdAROXjw7H06jfGSM","$XTVXOyr0fVkSvsvVdYtZnoh89-carr613wqYYod8y2E","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$dpX0bBi9AFKvJPKvoySxcpC5gS-BXfNCuM1Qt12obF8","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ","$i8wAYgfU2JaPVeSYtECOEzcfm0iojzIv6BGT50Tlsns"],["$6O6mJcQKlPSueicLz-IxBPNP2BX6f2CmlMUDd49YyY0","$8dhvTQo8Y9upjgcswo8uYNohKUxUpANGqwTk9zjwh_I","$JNTmNyIAjgeFTnYZ3AXtU9Rp1LHywzupd80k6KKnPAk","$LIt-diSt8bK6m4Tbmou6S_Aey0Dp7Q3N4dE75vJkThc","$RgbfYt2U7e7iBkSs5PYX1HsF7YIdAROXjw7H06jfGSM","$XTVXOyr0fVkSvsvVdYtZnoh89-carr613wqYYod8y2E","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$dpX0bBi9AFKvJPKvoySxcpC5gS-BXfNCuM1Qt12obF8","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ","$xyWWJcO6gvaAX7HP_00DgcXZZtz3d-uNwdbctslpaiM"]],"resolved":["$6O6mJcQKlPSueicLz-IxBPNP2BX6f2CmlMUDd49YyY0","$8dhvTQo8Y9upjgcswo8uYNohKUxUpANGqwTk9zjwh_I","$JNTmNyIAjgeFTnYZ3AXtU9Rp1LHywzupd80k6KKnPAk","$LIt-diSt8bK6m4Tbmou6S_Aey0Dp7Q3N4dE75vJkThc","$RgbfYt2U7e7iBkSs5PYX1HsF7YIdAROXjw7H06jfGSM","$XTVXOyr0fVkSvsvVdYtZnoh89-carr613wqYYod8y2E","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$dpX0bBi9AFKvJPKvoySxcpC5gS-BXfNCuM1Qt12obF8","$h5lWLYBMTRh9Xb6RJDcDZddEXs13Shi7RpZmDT0XivQ","$xyWWJcO6gvaAX7HP_00DgcXZZtz3d-uNwdbctslpaiM"]}, +{"name":"ts-2440","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$WsUTmHX21qUlVnXiFDJ-eMc6axbneiuw-erKVS3BvnI","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@b:x":0,"@c:x":50,"@d:x":0}},["$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE"],4],["$pw7u5fU5aAZRX-16VhLg-cVYB2rUzE-ZSG9fSpL1JeU","m.room.join_rules","","@a:x",{"join_rule":"public"},["$WsUTmHX21qUlVnXiFDJ-eMc6axbneiuw-erKVS3BvnI","$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE"],2],["$W3AJUUF8DcpwRF8n7zTT-dkip9xl1UMsW6V0eCVis6Q","m.room.member","@b:x","@b:x",{"membership":"join"},["$pw7u5fU5aAZRX-16VhLg-cVYB2rUzE-ZSG9fSpL1JeU","$WsUTmHX21qUlVnXiFDJ-eMc6axbneiuw-erKVS3BvnI"],3],["$SEkHu89FoQX3BJGXqA9-WaRLl5REoYqZdHE3h-rJUsQ","m.room.member","@c:x","@c:x",{"membership":"join"},["$pw7u5fU5aAZRX-16VhLg-cVYB2rUzE-ZSG9fSpL1JeU","$WsUTmHX21qUlVnXiFDJ-eMc6axbneiuw-erKVS3BvnI"],4],["$Zej8k6Em8Y2y9NqlqebfASXUiI7skYYWkUxXyXZD9cs","m.room.member","@d:x","@d:x",{"membership":"join"},["$pw7u5fU5aAZRX-16VhLg-cVYB2rUzE-ZSG9fSpL1JeU","$WsUTmHX21qUlVnXiFDJ-eMc6axbneiuw-erKVS3BvnI"],3],["$C-MG0PNVqUhC_rCKzWznpLlIfs8_Md6y7Ux69t5y6uI","m.room.topic","","@a:x",{"topic":"base"},["$WsUTmHX21qUlVnXiFDJ-eMc6axbneiuw-erKVS3BvnI","$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE"],5],["$LyMhjmwaPiU2eZE-0IEb96RB63V9KPA2Y3htkY31hNk","m.room.name","","@a:x",{"name":"base"},["$WsUTmHX21qUlVnXiFDJ-eMc6axbneiuw-erKVS3BvnI","$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE"],1],["$itSKMF2I5g3S9XXkfEYOfwwW2LX3Qe6ssNue7APnD6A","m.room.member","@c:x","@c:x",{"membership":"leave"},["$WsUTmHX21qUlVnXiFDJ-eMc6axbneiuw-erKVS3BvnI","$SEkHu89FoQX3BJGXqA9-WaRLl5REoYqZdHE3h-rJUsQ"],1],["$f5T3BFRRqicTpK1MukcMj_oD2SqFtVZA6qxkXHvVZNU","m.room.member","@c:x","@c:x",{"membership":"leave"},["$WsUTmHX21qUlVnXiFDJ-eMc6axbneiuw-erKVS3BvnI","$SEkHu89FoQX3BJGXqA9-WaRLl5REoYqZdHE3h-rJUsQ"],2],["$JhDT4-V-Lj1MLqyM99hk5KPh1hHUZN9GUK5n-DBjEuE","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$pw7u5fU5aAZRX-16VhLg-cVYB2rUzE-ZSG9fSpL1JeU","$WsUTmHX21qUlVnXiFDJ-eMc6axbneiuw-erKVS3BvnI","$itSKMF2I5g3S9XXkfEYOfwwW2LX3Qe6ssNue7APnD6A"],2]],"states":[["$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE","$C-MG0PNVqUhC_rCKzWznpLlIfs8_Md6y7Ux69t5y6uI","$JhDT4-V-Lj1MLqyM99hk5KPh1hHUZN9GUK5n-DBjEuE","$LyMhjmwaPiU2eZE-0IEb96RB63V9KPA2Y3htkY31hNk","$W3AJUUF8DcpwRF8n7zTT-dkip9xl1UMsW6V0eCVis6Q","$WsUTmHX21qUlVnXiFDJ-eMc6axbneiuw-erKVS3BvnI","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$Zej8k6Em8Y2y9NqlqebfASXUiI7skYYWkUxXyXZD9cs","$pw7u5fU5aAZRX-16VhLg-cVYB2rUzE-ZSG9fSpL1JeU"],["$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE","$C-MG0PNVqUhC_rCKzWznpLlIfs8_Md6y7Ux69t5y6uI","$LyMhjmwaPiU2eZE-0IEb96RB63V9KPA2Y3htkY31hNk","$W3AJUUF8DcpwRF8n7zTT-dkip9xl1UMsW6V0eCVis6Q","$WsUTmHX21qUlVnXiFDJ-eMc6axbneiuw-erKVS3BvnI","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$Zej8k6Em8Y2y9NqlqebfASXUiI7skYYWkUxXyXZD9cs","$f5T3BFRRqicTpK1MukcMj_oD2SqFtVZA6qxkXHvVZNU","$pw7u5fU5aAZRX-16VhLg-cVYB2rUzE-ZSG9fSpL1JeU"]],"resolved":["$0zEAOvEwoshkst4L2bgFoSvvUGmLV0GCFn5VsC2HxpE","$C-MG0PNVqUhC_rCKzWznpLlIfs8_Md6y7Ux69t5y6uI","$LyMhjmwaPiU2eZE-0IEb96RB63V9KPA2Y3htkY31hNk","$W3AJUUF8DcpwRF8n7zTT-dkip9xl1UMsW6V0eCVis6Q","$WsUTmHX21qUlVnXiFDJ-eMc6axbneiuw-erKVS3BvnI","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$Zej8k6Em8Y2y9NqlqebfASXUiI7skYYWkUxXyXZD9cs","$f5T3BFRRqicTpK1MukcMj_oD2SqFtVZA6qxkXHvVZNU","$pw7u5fU5aAZRX-16VhLg-cVYB2rUzE-ZSG9fSpL1JeU"]}, +{"name":"ts-2542","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$Mtmigh94k4-Hfu9vqbPnPfu5_nPnT4obo0OBH4xUnU0","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":50},"kick":25,"state_default":50,"users":{"@b:x":0,"@c:x":50,"@d:x":0}},["$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg"],1],["$sZ4c8EbjjawGrYbXUCdlZEr3eVCFM10xiMgFc5MYN_Q","m.room.join_rules","","@a:x",{"join_rule":"public"},["$Mtmigh94k4-Hfu9vqbPnPfu5_nPnT4obo0OBH4xUnU0","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg"],0],["$Olc96glEqPuxuk9INiD2rEWuN4K0Y7Q63Fzr9CtkNFg","m.room.member","@b:x","@b:x",{"membership":"join"},["$sZ4c8EbjjawGrYbXUCdlZEr3eVCFM10xiMgFc5MYN_Q","$Mtmigh94k4-Hfu9vqbPnPfu5_nPnT4obo0OBH4xUnU0"],0],["$QLPSmOhirgnIZpmEmh4TnU5p24O_fRWAaXuVJXVrIBk","m.room.member","@c:x","@c:x",{"membership":"join"},["$sZ4c8EbjjawGrYbXUCdlZEr3eVCFM10xiMgFc5MYN_Q","$Mtmigh94k4-Hfu9vqbPnPfu5_nPnT4obo0OBH4xUnU0"],0],["$aixbNuNWKYBypVBwqVPvp535ybIK7qWX9ec2LCpmSgo","m.room.member","@d:x","@d:x",{"membership":"join"},["$sZ4c8EbjjawGrYbXUCdlZEr3eVCFM10xiMgFc5MYN_Q","$Mtmigh94k4-Hfu9vqbPnPfu5_nPnT4obo0OBH4xUnU0"],1],["$5wLAvZiiB217uaCLRC43KXbJlf48_KC_a0V3z9L9r8A","m.room.topic","","@a:x",{"topic":"base"},["$Mtmigh94k4-Hfu9vqbPnPfu5_nPnT4obo0OBH4xUnU0","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg"],2],["$vnICUITx3ZLOfDVnMycTHNUz1Q-AZ7rZWvqhpXw18qI","m.room.name","","@a:x",{"name":"base"},["$Mtmigh94k4-Hfu9vqbPnPfu5_nPnT4obo0OBH4xUnU0","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg"],2],["$9TM-ORm4v0JabF6-mJBJK6JPGaW1SwWViUXvF2TZjvk","m.room.member","@b:x","@b:x",{"displayname":"0","membership":"join"},["$sZ4c8EbjjawGrYbXUCdlZEr3eVCFM10xiMgFc5MYN_Q","$Mtmigh94k4-Hfu9vqbPnPfu5_nPnT4obo0OBH4xUnU0","$Olc96glEqPuxuk9INiD2rEWuN4K0Y7Q63Fzr9CtkNFg"],1],["$DliSg0PeGp6dlz_VwhV9TlJdPECA8ES6Kl9WZYCQyHQ","m.room.member","@c:x","@c:x",{"membership":"leave"},["$Mtmigh94k4-Hfu9vqbPnPfu5_nPnT4obo0OBH4xUnU0","$QLPSmOhirgnIZpmEmh4TnU5p24O_fRWAaXuVJXVrIBk"],3]],"states":[["$5wLAvZiiB217uaCLRC43KXbJlf48_KC_a0V3z9L9r8A","$9TM-ORm4v0JabF6-mJBJK6JPGaW1SwWViUXvF2TZjvk","$Mtmigh94k4-Hfu9vqbPnPfu5_nPnT4obo0OBH4xUnU0","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$QLPSmOhirgnIZpmEmh4TnU5p24O_fRWAaXuVJXVrIBk","$aixbNuNWKYBypVBwqVPvp535ybIK7qWX9ec2LCpmSgo","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg","$sZ4c8EbjjawGrYbXUCdlZEr3eVCFM10xiMgFc5MYN_Q","$vnICUITx3ZLOfDVnMycTHNUz1Q-AZ7rZWvqhpXw18qI"],["$5wLAvZiiB217uaCLRC43KXbJlf48_KC_a0V3z9L9r8A","$DliSg0PeGp6dlz_VwhV9TlJdPECA8ES6Kl9WZYCQyHQ","$Mtmigh94k4-Hfu9vqbPnPfu5_nPnT4obo0OBH4xUnU0","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Olc96glEqPuxuk9INiD2rEWuN4K0Y7Q63Fzr9CtkNFg","$aixbNuNWKYBypVBwqVPvp535ybIK7qWX9ec2LCpmSgo","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg","$sZ4c8EbjjawGrYbXUCdlZEr3eVCFM10xiMgFc5MYN_Q","$vnICUITx3ZLOfDVnMycTHNUz1Q-AZ7rZWvqhpXw18qI"]],"resolved":["$5wLAvZiiB217uaCLRC43KXbJlf48_KC_a0V3z9L9r8A","$9TM-ORm4v0JabF6-mJBJK6JPGaW1SwWViUXvF2TZjvk","$DliSg0PeGp6dlz_VwhV9TlJdPECA8ES6Kl9WZYCQyHQ","$Mtmigh94k4-Hfu9vqbPnPfu5_nPnT4obo0OBH4xUnU0","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$aixbNuNWKYBypVBwqVPvp535ybIK7qWX9ec2LCpmSgo","$iaYoxquBxPyc6Fe_sELGsEI5TMJ24PE3sQvfd4dkEPg","$sZ4c8EbjjawGrYbXUCdlZEr3eVCFM10xiMgFc5MYN_Q","$vnICUITx3ZLOfDVnMycTHNUz1Q-AZ7rZWvqhpXw18qI"]}, +{"name":"ts-2557","room_id":"!CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","events":[["$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","m.room.create","","@a:x",{"room_version":"12"},[],1],["$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$xzskmVfH4wcB083BhLZp6OKzoR2dDIERr00LtHXKjFY","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":0},"kick":75,"state_default":50,"users":{"@b:x":50,"@c:x":25,"@d:x":75}},["$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],3],["$wnB777TsWfgwHlo_ye0r1oIOM6Iy05c9uJeKmcNbUAc","m.room.join_rules","","@a:x",{"join_rule":"public"},["$xzskmVfH4wcB083BhLZp6OKzoR2dDIERr00LtHXKjFY","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],4],["$4UViArBjl_uw6FiHN-EDIuwhjV0J617N9xYDF5IN9nU","m.room.member","@b:x","@b:x",{"membership":"join"},["$wnB777TsWfgwHlo_ye0r1oIOM6Iy05c9uJeKmcNbUAc","$xzskmVfH4wcB083BhLZp6OKzoR2dDIERr00LtHXKjFY"],0],["$kNCT_Bgt43XvOJ9NfwWXdX5XwEuSU8wTP0lqiaztilE","m.room.member","@c:x","@c:x",{"membership":"join"},["$wnB777TsWfgwHlo_ye0r1oIOM6Iy05c9uJeKmcNbUAc","$xzskmVfH4wcB083BhLZp6OKzoR2dDIERr00LtHXKjFY"],0],["$P0-ojjF1WIzpTFlOkJJ_dBvHJzCsQYhXCuXvCBuNmo4","m.room.member","@d:x","@d:x",{"membership":"join"},["$wnB777TsWfgwHlo_ye0r1oIOM6Iy05c9uJeKmcNbUAc","$xzskmVfH4wcB083BhLZp6OKzoR2dDIERr00LtHXKjFY"],2],["$6GhbddtfizzNVDO8m5X7q9oX2gLzNuI7pJ1BfJpqMT4","m.room.topic","","@a:x",{"topic":"base"},["$xzskmVfH4wcB083BhLZp6OKzoR2dDIERr00LtHXKjFY","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],1],["$fuo2rwnOOn2kYQyhABc-l9oOJr95B5LEwaw6uCm_lDM","m.room.name","","@a:x",{"name":"base"},["$xzskmVfH4wcB083BhLZp6OKzoR2dDIERr00LtHXKjFY","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q"],1],["$Jk5G5mHBynlUNQMSwFtpMRWfuJz6LyqT_cwjyyrm2eA","m.room.topic","","@b:x",{"topic":"0"},["$xzskmVfH4wcB083BhLZp6OKzoR2dDIERr00LtHXKjFY","$4UViArBjl_uw6FiHN-EDIuwhjV0J617N9xYDF5IN9nU"],1],["$hE_XVFwuU7Jg8VKGTLpW-l4Y4rAt9swAPqcRG77JM7E","m.room.member","@c:x","@c:x",{"displayname":"1","membership":"join"},["$wnB777TsWfgwHlo_ye0r1oIOM6Iy05c9uJeKmcNbUAc","$xzskmVfH4wcB083BhLZp6OKzoR2dDIERr00LtHXKjFY","$kNCT_Bgt43XvOJ9NfwWXdX5XwEuSU8wTP0lqiaztilE"],2]],"states":[["$4UViArBjl_uw6FiHN-EDIuwhjV0J617N9xYDF5IN9nU","$6GhbddtfizzNVDO8m5X7q9oX2gLzNuI7pJ1BfJpqMT4","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$P0-ojjF1WIzpTFlOkJJ_dBvHJzCsQYhXCuXvCBuNmo4","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","$fuo2rwnOOn2kYQyhABc-l9oOJr95B5LEwaw6uCm_lDM","$hE_XVFwuU7Jg8VKGTLpW-l4Y4rAt9swAPqcRG77JM7E","$wnB777TsWfgwHlo_ye0r1oIOM6Iy05c9uJeKmcNbUAc","$xzskmVfH4wcB083BhLZp6OKzoR2dDIERr00LtHXKjFY"],["$4UViArBjl_uw6FiHN-EDIuwhjV0J617N9xYDF5IN9nU","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$Jk5G5mHBynlUNQMSwFtpMRWfuJz6LyqT_cwjyyrm2eA","$P0-ojjF1WIzpTFlOkJJ_dBvHJzCsQYhXCuXvCBuNmo4","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","$fuo2rwnOOn2kYQyhABc-l9oOJr95B5LEwaw6uCm_lDM","$kNCT_Bgt43XvOJ9NfwWXdX5XwEuSU8wTP0lqiaztilE","$wnB777TsWfgwHlo_ye0r1oIOM6Iy05c9uJeKmcNbUAc","$xzskmVfH4wcB083BhLZp6OKzoR2dDIERr00LtHXKjFY"]],"resolved":["$4UViArBjl_uw6FiHN-EDIuwhjV0J617N9xYDF5IN9nU","$CqrnInhgxPCabZCNImqDT3yCsAibFyBxaw6fUfJjDa0","$Jk5G5mHBynlUNQMSwFtpMRWfuJz6LyqT_cwjyyrm2eA","$P0-ojjF1WIzpTFlOkJJ_dBvHJzCsQYhXCuXvCBuNmo4","$VKAuK0-qv4iIoXVv1Jbic8Odj54TkVpCZbTGXvy-x9Q","$fuo2rwnOOn2kYQyhABc-l9oOJr95B5LEwaw6uCm_lDM","$hE_XVFwuU7Jg8VKGTLpW-l4Y4rAt9swAPqcRG77JM7E","$wnB777TsWfgwHlo_ye0r1oIOM6Iy05c9uJeKmcNbUAc","$xzskmVfH4wcB083BhLZp6OKzoR2dDIERr00LtHXKjFY"]}, +{"name":"ts-2674","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0","m.room.member","@a:x","@a:x",{"membership":"join"},[],0],["$KABHTC0yi9p3J92v7grvOhou4HdClrgTr8nlRxlx7GM","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":0},"kick":50,"state_default":50,"users":{"@b:x":25,"@c:x":75,"@d:x":75}},["$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0"],5],["$LJ5otawbfdcDNyndFAvo1riw2qHgMdKFfpF6biHjrFM","m.room.join_rules","","@a:x",{"join_rule":"public"},["$KABHTC0yi9p3J92v7grvOhou4HdClrgTr8nlRxlx7GM","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0"],3],["$IWW7b2989XH9CE7mk7FXzpDVFGlBC3Ei-pCPoM9HHeM","m.room.member","@b:x","@b:x",{"membership":"join"},["$LJ5otawbfdcDNyndFAvo1riw2qHgMdKFfpF6biHjrFM","$KABHTC0yi9p3J92v7grvOhou4HdClrgTr8nlRxlx7GM"],2],["$Kc85gt3Qi_f55N1ZrNYsprqXnlkOTpVPxSpVtfQpqVA","m.room.member","@c:x","@c:x",{"membership":"join"},["$LJ5otawbfdcDNyndFAvo1riw2qHgMdKFfpF6biHjrFM","$KABHTC0yi9p3J92v7grvOhou4HdClrgTr8nlRxlx7GM"],5],["$XYzWBlRexV0KrJH0hnopQXmhXfoc9Un34MgRQNORFeY","m.room.member","@d:x","@d:x",{"membership":"join"},["$LJ5otawbfdcDNyndFAvo1riw2qHgMdKFfpF6biHjrFM","$KABHTC0yi9p3J92v7grvOhou4HdClrgTr8nlRxlx7GM"],3],["$o5BU4ai8w8evSWrtpUpJ7uRpfjZq5k2457CRi9sDsgc","m.room.topic","","@a:x",{"topic":"base"},["$KABHTC0yi9p3J92v7grvOhou4HdClrgTr8nlRxlx7GM","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0"],3],["$41PXfOyC5Y_jH1ZpEp3FQiho0Gitcd5fGoOGEa5cL8k","m.room.name","","@a:x",{"name":"base"},["$KABHTC0yi9p3J92v7grvOhou4HdClrgTr8nlRxlx7GM","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0"],5],["$Scpekjv1j1oUgy76-kKogoCGhnS78nyXXqhxFgl0tOg","m.room.topic","","@a:x",{"topic":"0"},["$KABHTC0yi9p3J92v7grvOhou4HdClrgTr8nlRxlx7GM","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0"],0],["$Vvq2V691u4boWczatElCIQ3E39Urq2rtn-J2TOQOMj4","m.room.name","","@a:x",{"name":"1"},["$KABHTC0yi9p3J92v7grvOhou4HdClrgTr8nlRxlx7GM","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0"],3],["$qTnkkMVM6uBbwksESynsKtprESE60I8WudkS13DuDRY","m.room.member","@b:x","@b:x",{"displayname":"2","membership":"join"},["$LJ5otawbfdcDNyndFAvo1riw2qHgMdKFfpF6biHjrFM","$KABHTC0yi9p3J92v7grvOhou4HdClrgTr8nlRxlx7GM","$IWW7b2989XH9CE7mk7FXzpDVFGlBC3Ei-pCPoM9HHeM"],3]],"states":[["$KABHTC0yi9p3J92v7grvOhou4HdClrgTr8nlRxlx7GM","$Kc85gt3Qi_f55N1ZrNYsprqXnlkOTpVPxSpVtfQpqVA","$LJ5otawbfdcDNyndFAvo1riw2qHgMdKFfpF6biHjrFM","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0","$Vvq2V691u4boWczatElCIQ3E39Urq2rtn-J2TOQOMj4","$XYzWBlRexV0KrJH0hnopQXmhXfoc9Un34MgRQNORFeY","$o5BU4ai8w8evSWrtpUpJ7uRpfjZq5k2457CRi9sDsgc","$qTnkkMVM6uBbwksESynsKtprESE60I8WudkS13DuDRY"],["$41PXfOyC5Y_jH1ZpEp3FQiho0Gitcd5fGoOGEa5cL8k","$IWW7b2989XH9CE7mk7FXzpDVFGlBC3Ei-pCPoM9HHeM","$KABHTC0yi9p3J92v7grvOhou4HdClrgTr8nlRxlx7GM","$Kc85gt3Qi_f55N1ZrNYsprqXnlkOTpVPxSpVtfQpqVA","$LJ5otawbfdcDNyndFAvo1riw2qHgMdKFfpF6biHjrFM","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Scpekjv1j1oUgy76-kKogoCGhnS78nyXXqhxFgl0tOg","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0","$XYzWBlRexV0KrJH0hnopQXmhXfoc9Un34MgRQNORFeY"]],"resolved":["$41PXfOyC5Y_jH1ZpEp3FQiho0Gitcd5fGoOGEa5cL8k","$KABHTC0yi9p3J92v7grvOhou4HdClrgTr8nlRxlx7GM","$Kc85gt3Qi_f55N1ZrNYsprqXnlkOTpVPxSpVtfQpqVA","$LJ5otawbfdcDNyndFAvo1riw2qHgMdKFfpF6biHjrFM","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$Sh1Wdr-ejbQ6rp1-Nxo0bQERm7bX6gI1jQ4LoKmCLa0","$XYzWBlRexV0KrJH0hnopQXmhXfoc9Un34MgRQNORFeY","$o5BU4ai8w8evSWrtpUpJ7uRpfjZq5k2457CRi9sDsgc","$qTnkkMVM6uBbwksESynsKtprESE60I8WudkS13DuDRY"]}, +{"name":"ts-2677","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":50,"users":{"@b:x":25,"@c:x":75,"@d:x":0}},["$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw"],1],["$KDHsoPMgzXFZdOOInSl8TtC7zKfFkRcQcz7hXeYvhMo","m.room.join_rules","","@a:x",{"join_rule":"public"},["$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw"],4],["$htuYAnnv0FX9NbscppRtXgPRnrBBKiWfePBNvdw8eVI","m.room.member","@b:x","@b:x",{"membership":"join"},["$KDHsoPMgzXFZdOOInSl8TtC7zKfFkRcQcz7hXeYvhMo","$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM"],2],["$kf6ltzei2jlxqs5Etf9z0rCCz-SKvuzkxe8ZhH3756A","m.room.member","@c:x","@c:x",{"membership":"join"},["$KDHsoPMgzXFZdOOInSl8TtC7zKfFkRcQcz7hXeYvhMo","$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM"],3],["$du0Ur6c-jqB4KIDCCE5nFIV6ZPafmlxhS-wHQ5PDKiE","m.room.member","@d:x","@d:x",{"membership":"join"},["$KDHsoPMgzXFZdOOInSl8TtC7zKfFkRcQcz7hXeYvhMo","$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM"],4],["$9ByLIaIs-vaxFWd4W4TNuMGcVsPfo0_XsGBOcsKO750","m.room.topic","","@a:x",{"topic":"base"},["$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw"],0],["$EOBL0HiQQHuthbRNq6yMyKc3i-xAu7WvFGW3GGNo3eE","m.room.member","@d:x","@d:x",{"membership":"leave"},["$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM","$du0Ur6c-jqB4KIDCCE5nFIV6ZPafmlxhS-wHQ5PDKiE"],3],["$0pEdwqxomZaUr0nUtGerf3Wu4OdptK6iiuo8uwKe3ew","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw"],3],["$mu0fMXmEldnLHHrfSkNfi6Z8dGr82aP9C768RysoYKI","m.room.join_rules","","@c:x",{"join_rule":"invite"},["$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM","$kf6ltzei2jlxqs5Etf9z0rCCz-SKvuzkxe8ZhH3756A"],2],["$IoaRTPzEJfY_uo38dXUVVgUA_HZWHMnsPJOPlLgO54k","m.room.name","","@a:x",{"name":"0"},["$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw"],4],["$7iCr_06GTSavbAmjW3tA1pNYxcH8UPwmfo1gaqREv3I","m.room.name","","@a:x",{"name":"1"},["$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw"],3],["$cKIQfrnHuTkdYUPZ18nH1ofxCceOY_uRweiHj7CYnAM","m.room.member","@c:x","@c:x",{"displayname":"2","membership":"join"},["$mu0fMXmEldnLHHrfSkNfi6Z8dGr82aP9C768RysoYKI","$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM","$kf6ltzei2jlxqs5Etf9z0rCCz-SKvuzkxe8ZhH3756A"],2],["$XvE6RU4C_XTxFJkfNgle7WR1XtKT2IAj7FGemp3rcuQ","m.room.topic","","@a:x",{"topic":"3"},["$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw"],4]],"states":[["$7iCr_06GTSavbAmjW3tA1pNYxcH8UPwmfo1gaqREv3I","$9ByLIaIs-vaxFWd4W4TNuMGcVsPfo0_XsGBOcsKO750","$EOBL0HiQQHuthbRNq6yMyKc3i-xAu7WvFGW3GGNo3eE","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw","$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM","$cKIQfrnHuTkdYUPZ18nH1ofxCceOY_uRweiHj7CYnAM","$htuYAnnv0FX9NbscppRtXgPRnrBBKiWfePBNvdw8eVI","$mu0fMXmEldnLHHrfSkNfi6Z8dGr82aP9C768RysoYKI","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"],["$0pEdwqxomZaUr0nUtGerf3Wu4OdptK6iiuo8uwKe3ew","$IoaRTPzEJfY_uo38dXUVVgUA_HZWHMnsPJOPlLgO54k","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw","$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM","$XvE6RU4C_XTxFJkfNgle7WR1XtKT2IAj7FGemp3rcuQ","$du0Ur6c-jqB4KIDCCE5nFIV6ZPafmlxhS-wHQ5PDKiE","$htuYAnnv0FX9NbscppRtXgPRnrBBKiWfePBNvdw8eVI","$kf6ltzei2jlxqs5Etf9z0rCCz-SKvuzkxe8ZhH3756A","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"]],"resolved":["$EOBL0HiQQHuthbRNq6yMyKc3i-xAu7WvFGW3GGNo3eE","$IoaRTPzEJfY_uo38dXUVVgUA_HZWHMnsPJOPlLgO54k","$LMXG8CT1a4_RELEp2797QDr7P7kGS8fvMW3we1d93bw","$NIwFNw6MAz38IZPV2mSiC2XwHjNDwNFB7dMPQMQlJjM","$XvE6RU4C_XTxFJkfNgle7WR1XtKT2IAj7FGemp3rcuQ","$cKIQfrnHuTkdYUPZ18nH1ofxCceOY_uRweiHj7CYnAM","$htuYAnnv0FX9NbscppRtXgPRnrBBKiWfePBNvdw8eVI","$mu0fMXmEldnLHHrfSkNfi6Z8dGr82aP9C768RysoYKI","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE"]}, +{"name":"ts-2711","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$etLAeYb0A36jFJgFmb4xvutmlP0O0wRBPtSb4g5HL0g","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":75,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":25},"kick":75,"state_default":50,"users":{"@b:x":25,"@c:x":25,"@d:x":0}},["$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],1],["$546Hg2yjmFUSb7dkrMy1laebKbVU2KGow7ROMgf7kcA","m.room.join_rules","","@a:x",{"join_rule":"public"},["$etLAeYb0A36jFJgFmb4xvutmlP0O0wRBPtSb4g5HL0g","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],1],["$dCg36G7EgYmldIyhoEh2-4BFUHJsV70hYwcYMbi9DpU","m.room.member","@b:x","@b:x",{"membership":"join"},["$546Hg2yjmFUSb7dkrMy1laebKbVU2KGow7ROMgf7kcA","$etLAeYb0A36jFJgFmb4xvutmlP0O0wRBPtSb4g5HL0g"],0],["$epRIH-VDMfAD_tck_kS8RXnsitCrJDLCFalFXmgCiko","m.room.member","@c:x","@c:x",{"membership":"join"},["$546Hg2yjmFUSb7dkrMy1laebKbVU2KGow7ROMgf7kcA","$etLAeYb0A36jFJgFmb4xvutmlP0O0wRBPtSb4g5HL0g"],2],["$thvVPwHgzcZGXJHcnaNgclvB4W60JBRfgrEQuZCdFH0","m.room.member","@d:x","@d:x",{"membership":"join"},["$546Hg2yjmFUSb7dkrMy1laebKbVU2KGow7ROMgf7kcA","$etLAeYb0A36jFJgFmb4xvutmlP0O0wRBPtSb4g5HL0g"],1],["$SiJbqQhqSQXX-w7EtANmADxPkDYhkeG2jRZ2sgrTXz0","m.room.topic","","@a:x",{"topic":"base"},["$etLAeYb0A36jFJgFmb4xvutmlP0O0wRBPtSb4g5HL0g","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],0],["$xlaVBOXv41LchJsuJVtz1FQDhke_pilMccq6rM5HhGk","m.room.name","","@a:x",{"name":"base"},["$etLAeYb0A36jFJgFmb4xvutmlP0O0wRBPtSb4g5HL0g","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],2],["$hGeY1KKO-AXErcSNwGSwpmgE5-3YG_2hI96_N0lE42w","m.room.member","@c:x","@c:x",{"membership":"leave"},["$etLAeYb0A36jFJgFmb4xvutmlP0O0wRBPtSb4g5HL0g","$epRIH-VDMfAD_tck_kS8RXnsitCrJDLCFalFXmgCiko"],2]],"states":[["$546Hg2yjmFUSb7dkrMy1laebKbVU2KGow7ROMgf7kcA","$SiJbqQhqSQXX-w7EtANmADxPkDYhkeG2jRZ2sgrTXz0","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$dCg36G7EgYmldIyhoEh2-4BFUHJsV70hYwcYMbi9DpU","$etLAeYb0A36jFJgFmb4xvutmlP0O0wRBPtSb4g5HL0g","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$hGeY1KKO-AXErcSNwGSwpmgE5-3YG_2hI96_N0lE42w","$thvVPwHgzcZGXJHcnaNgclvB4W60JBRfgrEQuZCdFH0","$xlaVBOXv41LchJsuJVtz1FQDhke_pilMccq6rM5HhGk"],["$546Hg2yjmFUSb7dkrMy1laebKbVU2KGow7ROMgf7kcA","$SiJbqQhqSQXX-w7EtANmADxPkDYhkeG2jRZ2sgrTXz0","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$dCg36G7EgYmldIyhoEh2-4BFUHJsV70hYwcYMbi9DpU","$etLAeYb0A36jFJgFmb4xvutmlP0O0wRBPtSb4g5HL0g","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$hGeY1KKO-AXErcSNwGSwpmgE5-3YG_2hI96_N0lE42w","$thvVPwHgzcZGXJHcnaNgclvB4W60JBRfgrEQuZCdFH0","$xlaVBOXv41LchJsuJVtz1FQDhke_pilMccq6rM5HhGk"]],"resolved":["$546Hg2yjmFUSb7dkrMy1laebKbVU2KGow7ROMgf7kcA","$SiJbqQhqSQXX-w7EtANmADxPkDYhkeG2jRZ2sgrTXz0","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$dCg36G7EgYmldIyhoEh2-4BFUHJsV70hYwcYMbi9DpU","$etLAeYb0A36jFJgFmb4xvutmlP0O0wRBPtSb4g5HL0g","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$hGeY1KKO-AXErcSNwGSwpmgE5-3YG_2hI96_N0lE42w","$thvVPwHgzcZGXJHcnaNgclvB4W60JBRfgrEQuZCdFH0","$xlaVBOXv41LchJsuJVtz1FQDhke_pilMccq6rM5HhGk"]}, +{"name":"ts-2739","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$S3k6WHC9-kSvDWww_6UnH80b6Tnn1n161o5-9tM2nqw","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":75,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":0},"kick":50,"state_default":50,"users":{"@b:x":75,"@c:x":0,"@d:x":50}},["$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],4],["$oPL7SA9R8YMGmaOG08UR_osqeoYiNvOTc_cIY6OOtSg","m.room.join_rules","","@a:x",{"join_rule":"public"},["$S3k6WHC9-kSvDWww_6UnH80b6Tnn1n161o5-9tM2nqw","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],0],["$oQ4wlzea51OvIUt2HmX8nV3biA6Xbl31x47aayy9rqU","m.room.member","@b:x","@b:x",{"membership":"join"},["$oPL7SA9R8YMGmaOG08UR_osqeoYiNvOTc_cIY6OOtSg","$S3k6WHC9-kSvDWww_6UnH80b6Tnn1n161o5-9tM2nqw"],4],["$8XRRWfYtqPkvdwpbIIak2aNIiYHyAo3IAiOPGgCBOrE","m.room.member","@c:x","@c:x",{"membership":"join"},["$oPL7SA9R8YMGmaOG08UR_osqeoYiNvOTc_cIY6OOtSg","$S3k6WHC9-kSvDWww_6UnH80b6Tnn1n161o5-9tM2nqw"],5],["$WefQWchiYQ6ZfAvUBToQPPJXAI6FukaEc2Sc25-Wnew","m.room.member","@d:x","@d:x",{"membership":"join"},["$oPL7SA9R8YMGmaOG08UR_osqeoYiNvOTc_cIY6OOtSg","$S3k6WHC9-kSvDWww_6UnH80b6Tnn1n161o5-9tM2nqw"],4],["$jF9fLFXH6nINalrcR30hrL3cFeQlq02kKvo1tAsiApw","m.room.topic","","@a:x",{"topic":"base"},["$S3k6WHC9-kSvDWww_6UnH80b6Tnn1n161o5-9tM2nqw","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],4],["$FbYPDOviHh4XYD2lMgrNt3lV5XbBhZenpYjmiLgHhvs","m.room.name","","@a:x",{"name":"base"},["$S3k6WHC9-kSvDWww_6UnH80b6Tnn1n161o5-9tM2nqw","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],3],["$f4mROs3jecZ5gZvgUDuBr5vSuWiIGAjN1AGmyy-cdSk","m.room.member","@e:x","@b:x",{"membership":"leave"},["$S3k6WHC9-kSvDWww_6UnH80b6Tnn1n161o5-9tM2nqw","$oQ4wlzea51OvIUt2HmX8nV3biA6Xbl31x47aayy9rqU"],1],["$rr6MJW_CkDHAQdqI-oI4f55dDfQpfilNZ9cOhWFxyb8","m.room.join_rules","","@b:x",{"join_rule":"public"},["$S3k6WHC9-kSvDWww_6UnH80b6Tnn1n161o5-9tM2nqw","$oQ4wlzea51OvIUt2HmX8nV3biA6Xbl31x47aayy9rqU"],0],["$NO4rXw18J0SbfX17QnZfDVwxZsWLKmQ0Npos--Z72ZI","m.room.join_rules","","@a:x",{"join_rule":"public"},["$S3k6WHC9-kSvDWww_6UnH80b6Tnn1n161o5-9tM2nqw","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],2]],"states":[["$8XRRWfYtqPkvdwpbIIak2aNIiYHyAo3IAiOPGgCBOrE","$FbYPDOviHh4XYD2lMgrNt3lV5XbBhZenpYjmiLgHhvs","$S3k6WHC9-kSvDWww_6UnH80b6Tnn1n161o5-9tM2nqw","$WefQWchiYQ6ZfAvUBToQPPJXAI6FukaEc2Sc25-Wnew","$f4mROs3jecZ5gZvgUDuBr5vSuWiIGAjN1AGmyy-cdSk","$jF9fLFXH6nINalrcR30hrL3cFeQlq02kKvo1tAsiApw","$oQ4wlzea51OvIUt2HmX8nV3biA6Xbl31x47aayy9rqU","$rr6MJW_CkDHAQdqI-oI4f55dDfQpfilNZ9cOhWFxyb8","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],["$8XRRWfYtqPkvdwpbIIak2aNIiYHyAo3IAiOPGgCBOrE","$FbYPDOviHh4XYD2lMgrNt3lV5XbBhZenpYjmiLgHhvs","$NO4rXw18J0SbfX17QnZfDVwxZsWLKmQ0Npos--Z72ZI","$S3k6WHC9-kSvDWww_6UnH80b6Tnn1n161o5-9tM2nqw","$WefQWchiYQ6ZfAvUBToQPPJXAI6FukaEc2Sc25-Wnew","$f4mROs3jecZ5gZvgUDuBr5vSuWiIGAjN1AGmyy-cdSk","$jF9fLFXH6nINalrcR30hrL3cFeQlq02kKvo1tAsiApw","$oQ4wlzea51OvIUt2HmX8nV3biA6Xbl31x47aayy9rqU","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"]],"resolved":["$8XRRWfYtqPkvdwpbIIak2aNIiYHyAo3IAiOPGgCBOrE","$FbYPDOviHh4XYD2lMgrNt3lV5XbBhZenpYjmiLgHhvs","$S3k6WHC9-kSvDWww_6UnH80b6Tnn1n161o5-9tM2nqw","$WefQWchiYQ6ZfAvUBToQPPJXAI6FukaEc2Sc25-Wnew","$f4mROs3jecZ5gZvgUDuBr5vSuWiIGAjN1AGmyy-cdSk","$jF9fLFXH6nINalrcR30hrL3cFeQlq02kKvo1tAsiApw","$oQ4wlzea51OvIUt2HmX8nV3biA6Xbl31x47aayy9rqU","$rr6MJW_CkDHAQdqI-oI4f55dDfQpfilNZ9cOhWFxyb8","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"]}, +{"name":"ts-2760","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":100,"m.room.topic":25},"kick":25,"state_default":50,"users":{"@b:x":0,"@c:x":50,"@d:x":25}},["$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],2],["$GD8qVNaB-3VniuR3fqovXik1e4YNEGpowb8IOXKRzvM","m.room.join_rules","","@a:x",{"join_rule":"public"},["$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],2],["$SzbNRQIU1WnigA8dKl_YZT7MzYItD3HA3qCiB2Y1l3Q","m.room.member","@b:x","@b:x",{"membership":"join"},["$GD8qVNaB-3VniuR3fqovXik1e4YNEGpowb8IOXKRzvM","$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo"],4],["$2sSG75PNPIX2dY8aKqosE8huENevTJp8Ny2OSsD7v0Y","m.room.member","@c:x","@c:x",{"membership":"join"},["$GD8qVNaB-3VniuR3fqovXik1e4YNEGpowb8IOXKRzvM","$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo"],5],["$oBgxSjTKZL2n0ZwvsTAc9xYhmGUNp8Xv5tFUelCFXUI","m.room.member","@d:x","@d:x",{"membership":"join"},["$GD8qVNaB-3VniuR3fqovXik1e4YNEGpowb8IOXKRzvM","$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo"],0],["$VuBsdEvqD50K4nSUkGL61Lhypxm2bHeNvoae6JNL938","m.room.topic","","@a:x",{"topic":"base"},["$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],2],["$Ejg8BZ5_XEVFLEk7hMFSLMz3kVzGEgRufObc6GXpFb4","m.room.name","","@a:x",{"name":"base"},["$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],1],["$C9z56veOVnk06pHHnEttbQP0GVkX5UFNr5YB6ITKXe4","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$GD8qVNaB-3VniuR3fqovXik1e4YNEGpowb8IOXKRzvM","$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo","$2sSG75PNPIX2dY8aKqosE8huENevTJp8Ny2OSsD7v0Y"],4],["$5zJfRrrXwOKY81N1SVXotd6kANAtumwAEegwwyFLc7I","m.room.member","@e:x","@e:x",{"displayname":"1","membership":"join"},["$GD8qVNaB-3VniuR3fqovXik1e4YNEGpowb8IOXKRzvM","$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo"],5],["$fwzwSWvb8YhJeDzQYC2CBUfZ55m18oeGQ-Vtp0AGqoo","m.room.topic","","@c:x",{"topic":"2"},["$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo","$C9z56veOVnk06pHHnEttbQP0GVkX5UFNr5YB6ITKXe4"],2],["$iNAKrRvT6ZbSr77xKwocYwteWy3dI-JCBeyjCL-4BUA","m.room.member","@d:x","@d:x",{"membership":"leave"},["$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo","$oBgxSjTKZL2n0ZwvsTAc9xYhmGUNp8Xv5tFUelCFXUI"],1],["$8bkZ2rN38BQZioBEm-htExN0phAfHuUVpx-XSLLPsiE","m.room.member","@d:x","@a:x",{"membership":"ban"},["$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","$oBgxSjTKZL2n0ZwvsTAc9xYhmGUNp8Xv5tFUelCFXUI"],3],["$om17lGOdR4OnTnimDhRvbV9C8s133ur01czSzux2wBI","m.room.join_rules","","@c:x",{"join_rule":"public"},["$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo","$C9z56veOVnk06pHHnEttbQP0GVkX5UFNr5YB6ITKXe4"],5]],"states":[["$5zJfRrrXwOKY81N1SVXotd6kANAtumwAEegwwyFLc7I","$C9z56veOVnk06pHHnEttbQP0GVkX5UFNr5YB6ITKXe4","$Ejg8BZ5_XEVFLEk7hMFSLMz3kVzGEgRufObc6GXpFb4","$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo","$SzbNRQIU1WnigA8dKl_YZT7MzYItD3HA3qCiB2Y1l3Q","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","$VuBsdEvqD50K4nSUkGL61Lhypxm2bHeNvoae6JNL938","$iNAKrRvT6ZbSr77xKwocYwteWy3dI-JCBeyjCL-4BUA","$om17lGOdR4OnTnimDhRvbV9C8s133ur01czSzux2wBI","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"],["$8bkZ2rN38BQZioBEm-htExN0phAfHuUVpx-XSLLPsiE","$C9z56veOVnk06pHHnEttbQP0GVkX5UFNr5YB6ITKXe4","$Ejg8BZ5_XEVFLEk7hMFSLMz3kVzGEgRufObc6GXpFb4","$GD8qVNaB-3VniuR3fqovXik1e4YNEGpowb8IOXKRzvM","$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo","$SzbNRQIU1WnigA8dKl_YZT7MzYItD3HA3qCiB2Y1l3Q","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","$fwzwSWvb8YhJeDzQYC2CBUfZ55m18oeGQ-Vtp0AGqoo","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"]],"resolved":["$5zJfRrrXwOKY81N1SVXotd6kANAtumwAEegwwyFLc7I","$8bkZ2rN38BQZioBEm-htExN0phAfHuUVpx-XSLLPsiE","$C9z56veOVnk06pHHnEttbQP0GVkX5UFNr5YB6ITKXe4","$Ejg8BZ5_XEVFLEk7hMFSLMz3kVzGEgRufObc6GXpFb4","$MNgs_FHoEk1S3KE1BAEh1YFH24LhVRE0kPRBD9a4Fwo","$SzbNRQIU1WnigA8dKl_YZT7MzYItD3HA3qCiB2Y1l3Q","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","$fwzwSWvb8YhJeDzQYC2CBUfZ55m18oeGQ-Vtp0AGqoo","$om17lGOdR4OnTnimDhRvbV9C8s133ur01czSzux2wBI","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"]}, +{"name":"ts-2801","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$zWUNCHt-L-H-aNZvxCMRbuND8bI7Vsl6UE-GdCXCu-8","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":100,"m.room.topic":75},"kick":75,"state_default":0,"users":{"@b:x":0,"@c:x":50,"@d:x":0}},["$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],1],["$o8_z8R0B19WxDt90YDBH8VKT0EFywwqelzYOwDB0l4Y","m.room.join_rules","","@a:x",{"join_rule":"public"},["$zWUNCHt-L-H-aNZvxCMRbuND8bI7Vsl6UE-GdCXCu-8","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],1],["$EH5sugPM4LnjqENylV4roMqr8ZMyhLkKc_PWOSdD-KY","m.room.member","@b:x","@b:x",{"membership":"join"},["$o8_z8R0B19WxDt90YDBH8VKT0EFywwqelzYOwDB0l4Y","$zWUNCHt-L-H-aNZvxCMRbuND8bI7Vsl6UE-GdCXCu-8"],5],["$4n9IMbr5KXFbTnrxbvDZ8D2pPzYVGm6-dGxEW8P76fQ","m.room.member","@c:x","@c:x",{"membership":"join"},["$o8_z8R0B19WxDt90YDBH8VKT0EFywwqelzYOwDB0l4Y","$zWUNCHt-L-H-aNZvxCMRbuND8bI7Vsl6UE-GdCXCu-8"],5],["$RpLYAix_Fs5_LnWVERJYtbO87ykWyWQRM6N6upJjeUw","m.room.member","@d:x","@d:x",{"membership":"join"},["$o8_z8R0B19WxDt90YDBH8VKT0EFywwqelzYOwDB0l4Y","$zWUNCHt-L-H-aNZvxCMRbuND8bI7Vsl6UE-GdCXCu-8"],5],["$Ljn1Pr2YXLHq7Sg5tsHLcfhVsDnLsyKyB1pdq2JUvU0","m.room.name","","@a:x",{"name":"base"},["$zWUNCHt-L-H-aNZvxCMRbuND8bI7Vsl6UE-GdCXCu-8","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],0],["$qHmcP8z6LuW-XXTGrgFWp9FB_RHNmkI4EXLooSWzfnc","m.room.topic","","@a:x",{"topic":"0"},["$zWUNCHt-L-H-aNZvxCMRbuND8bI7Vsl6UE-GdCXCu-8","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],4],["$WYewMvcM8vz-18EG_-QUE4U0KYAvaUBmkgno1qN_ccQ","m.room.member","@b:x","@b:x",{"membership":"leave"},["$zWUNCHt-L-H-aNZvxCMRbuND8bI7Vsl6UE-GdCXCu-8","$EH5sugPM4LnjqENylV4roMqr8ZMyhLkKc_PWOSdD-KY"],0]],"states":[["$4n9IMbr5KXFbTnrxbvDZ8D2pPzYVGm6-dGxEW8P76fQ","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","$Ljn1Pr2YXLHq7Sg5tsHLcfhVsDnLsyKyB1pdq2JUvU0","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$RpLYAix_Fs5_LnWVERJYtbO87ykWyWQRM6N6upJjeUw","$WYewMvcM8vz-18EG_-QUE4U0KYAvaUBmkgno1qN_ccQ","$o8_z8R0B19WxDt90YDBH8VKT0EFywwqelzYOwDB0l4Y","$qHmcP8z6LuW-XXTGrgFWp9FB_RHNmkI4EXLooSWzfnc","$zWUNCHt-L-H-aNZvxCMRbuND8bI7Vsl6UE-GdCXCu-8"],["$4n9IMbr5KXFbTnrxbvDZ8D2pPzYVGm6-dGxEW8P76fQ","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","$EH5sugPM4LnjqENylV4roMqr8ZMyhLkKc_PWOSdD-KY","$Ljn1Pr2YXLHq7Sg5tsHLcfhVsDnLsyKyB1pdq2JUvU0","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$RpLYAix_Fs5_LnWVERJYtbO87ykWyWQRM6N6upJjeUw","$o8_z8R0B19WxDt90YDBH8VKT0EFywwqelzYOwDB0l4Y","$qHmcP8z6LuW-XXTGrgFWp9FB_RHNmkI4EXLooSWzfnc","$zWUNCHt-L-H-aNZvxCMRbuND8bI7Vsl6UE-GdCXCu-8"]],"resolved":["$4n9IMbr5KXFbTnrxbvDZ8D2pPzYVGm6-dGxEW8P76fQ","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","$EH5sugPM4LnjqENylV4roMqr8ZMyhLkKc_PWOSdD-KY","$Ljn1Pr2YXLHq7Sg5tsHLcfhVsDnLsyKyB1pdq2JUvU0","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$RpLYAix_Fs5_LnWVERJYtbO87ykWyWQRM6N6upJjeUw","$o8_z8R0B19WxDt90YDBH8VKT0EFywwqelzYOwDB0l4Y","$qHmcP8z6LuW-XXTGrgFWp9FB_RHNmkI4EXLooSWzfnc","$zWUNCHt-L-H-aNZvxCMRbuND8bI7Vsl6UE-GdCXCu-8"]}, +{"name":"ts-2803","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$-Wj4kO9l3cWUD-OiTnpNb5RgYHbZm4DarGBpnWTLing","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":25},"kick":25,"state_default":0,"users":{"@b:x":0,"@c:x":50,"@d:x":75}},["$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0"],1],["$jzLyS8yMpVYLzvTF9VPIuMt4hykRkS_d4NMKqZALHeY","m.room.join_rules","","@a:x",{"join_rule":"public"},["$-Wj4kO9l3cWUD-OiTnpNb5RgYHbZm4DarGBpnWTLing","$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0"],3],["$tSbGMeba4FG82v-JM2vLYO99f1nSc7wf-tEwMNkQ-oU","m.room.member","@b:x","@b:x",{"membership":"join"},["$jzLyS8yMpVYLzvTF9VPIuMt4hykRkS_d4NMKqZALHeY","$-Wj4kO9l3cWUD-OiTnpNb5RgYHbZm4DarGBpnWTLing"],3],["$_0P2SfvGoiIhdnVONf87tJgNBavwTAhcB27D4NkqHHA","m.room.member","@c:x","@c:x",{"membership":"join"},["$jzLyS8yMpVYLzvTF9VPIuMt4hykRkS_d4NMKqZALHeY","$-Wj4kO9l3cWUD-OiTnpNb5RgYHbZm4DarGBpnWTLing"],3],["$qHoS2l3Oz6xX0HhqWtpgT00ybBFPqiov3Aj3VEY5aS4","m.room.member","@d:x","@d:x",{"membership":"join"},["$jzLyS8yMpVYLzvTF9VPIuMt4hykRkS_d4NMKqZALHeY","$-Wj4kO9l3cWUD-OiTnpNb5RgYHbZm4DarGBpnWTLing"],1],["$Clf3ldyF8wKNzsmW0IpwSsqhpABp122FwIBbNFPNoQ0","m.room.topic","","@a:x",{"topic":"base"},["$-Wj4kO9l3cWUD-OiTnpNb5RgYHbZm4DarGBpnWTLing","$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0"],5],["$i5B4HeQOepekwPfesafiykjSMuifhEylF8_HQA6H98s","m.room.name","","@a:x",{"name":"base"},["$-Wj4kO9l3cWUD-OiTnpNb5RgYHbZm4DarGBpnWTLing","$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0"],5],["$wO-EUhmxyyOsB3JZGLhd3cP4XGBpeh-D_YeXvme12DE","m.room.member","@d:x","@d:x",{"membership":"leave"},["$-Wj4kO9l3cWUD-OiTnpNb5RgYHbZm4DarGBpnWTLing","$qHoS2l3Oz6xX0HhqWtpgT00ybBFPqiov3Aj3VEY5aS4"],2],["$S_iolwBJrbx5e7VA1wiajx4f4YV8dDu6xe0_1zScgLk","m.room.member","@d:x","@b:x",{"membership":"invite"},["$jzLyS8yMpVYLzvTF9VPIuMt4hykRkS_d4NMKqZALHeY","$-Wj4kO9l3cWUD-OiTnpNb5RgYHbZm4DarGBpnWTLing","$tSbGMeba4FG82v-JM2vLYO99f1nSc7wf-tEwMNkQ-oU","$wO-EUhmxyyOsB3JZGLhd3cP4XGBpeh-D_YeXvme12DE"],0]],"states":[["$-Wj4kO9l3cWUD-OiTnpNb5RgYHbZm4DarGBpnWTLing","$Clf3ldyF8wKNzsmW0IpwSsqhpABp122FwIBbNFPNoQ0","$S_iolwBJrbx5e7VA1wiajx4f4YV8dDu6xe0_1zScgLk","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$_0P2SfvGoiIhdnVONf87tJgNBavwTAhcB27D4NkqHHA","$i5B4HeQOepekwPfesafiykjSMuifhEylF8_HQA6H98s","$jzLyS8yMpVYLzvTF9VPIuMt4hykRkS_d4NMKqZALHeY","$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0","$tSbGMeba4FG82v-JM2vLYO99f1nSc7wf-tEwMNkQ-oU"],["$-Wj4kO9l3cWUD-OiTnpNb5RgYHbZm4DarGBpnWTLing","$Clf3ldyF8wKNzsmW0IpwSsqhpABp122FwIBbNFPNoQ0","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$_0P2SfvGoiIhdnVONf87tJgNBavwTAhcB27D4NkqHHA","$i5B4HeQOepekwPfesafiykjSMuifhEylF8_HQA6H98s","$jzLyS8yMpVYLzvTF9VPIuMt4hykRkS_d4NMKqZALHeY","$qHoS2l3Oz6xX0HhqWtpgT00ybBFPqiov3Aj3VEY5aS4","$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0","$tSbGMeba4FG82v-JM2vLYO99f1nSc7wf-tEwMNkQ-oU"]],"resolved":["$-Wj4kO9l3cWUD-OiTnpNb5RgYHbZm4DarGBpnWTLing","$Clf3ldyF8wKNzsmW0IpwSsqhpABp122FwIBbNFPNoQ0","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$_0P2SfvGoiIhdnVONf87tJgNBavwTAhcB27D4NkqHHA","$i5B4HeQOepekwPfesafiykjSMuifhEylF8_HQA6H98s","$jzLyS8yMpVYLzvTF9VPIuMt4hykRkS_d4NMKqZALHeY","$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0","$tSbGMeba4FG82v-JM2vLYO99f1nSc7wf-tEwMNkQ-oU","$wO-EUhmxyyOsB3JZGLhd3cP4XGBpeh-D_YeXvme12DE"]}, +{"name":"ts-2804","room_id":"!Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","events":[["$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","m.room.create","","@a:x",{"room_version":"12"},[],3],["$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$CpBaM2dG34h2waJ_0bPdCXHQ4C4XunhaNcGOHiysXxU","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":75,"m.room.power_levels":50,"m.room.topic":50},"kick":75,"state_default":0,"users":{"@b:x":75,"@c:x":0,"@d:x":25}},["$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0"],3],["$sUrU2kPwkzyfu1LC02iE7Ymoj1xpS5L20jVWHNR3Bb0","m.room.join_rules","","@a:x",{"join_rule":"public"},["$CpBaM2dG34h2waJ_0bPdCXHQ4C4XunhaNcGOHiysXxU","$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0"],5],["$R8saErKMqshmJXNSoVYqU9J_AvFXZxGhfyW7-rGkNMM","m.room.member","@b:x","@b:x",{"membership":"join"},["$sUrU2kPwkzyfu1LC02iE7Ymoj1xpS5L20jVWHNR3Bb0","$CpBaM2dG34h2waJ_0bPdCXHQ4C4XunhaNcGOHiysXxU"],4],["$N0W8qEY5Xq1uzGczWeImaXcUfLacAUF9MRu-EBT85-E","m.room.member","@c:x","@c:x",{"membership":"join"},["$sUrU2kPwkzyfu1LC02iE7Ymoj1xpS5L20jVWHNR3Bb0","$CpBaM2dG34h2waJ_0bPdCXHQ4C4XunhaNcGOHiysXxU"],1],["$REDorli1T4NkrLwOt__l6pzpeGZuv3GP1N1jBU-3lQs","m.room.member","@d:x","@d:x",{"membership":"join"},["$sUrU2kPwkzyfu1LC02iE7Ymoj1xpS5L20jVWHNR3Bb0","$CpBaM2dG34h2waJ_0bPdCXHQ4C4XunhaNcGOHiysXxU"],5],["$gtpQidUJTSwxNaFOc1xS8F5Q7DkTDBYtFo3-Bk6VHWY","m.room.name","","@a:x",{"name":"base"},["$CpBaM2dG34h2waJ_0bPdCXHQ4C4XunhaNcGOHiysXxU","$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0"],4],["$tBQBtWiO5QmNYsc5e81_fiaAkYVwhvEiLnhwRn26Vjo","m.room.topic","","@b:x",{"topic":"0"},["$CpBaM2dG34h2waJ_0bPdCXHQ4C4XunhaNcGOHiysXxU","$R8saErKMqshmJXNSoVYqU9J_AvFXZxGhfyW7-rGkNMM"],4],["$9s0zoBEnjOTi3XdM-gLHmWfdyAgkFbo6_lEGXk1ixqo","m.room.member","@b:x","@b:x",{"displayname":"1","membership":"join"},["$sUrU2kPwkzyfu1LC02iE7Ymoj1xpS5L20jVWHNR3Bb0","$CpBaM2dG34h2waJ_0bPdCXHQ4C4XunhaNcGOHiysXxU","$R8saErKMqshmJXNSoVYqU9J_AvFXZxGhfyW7-rGkNMM"],4],["$9GVxkpgpfze87BPcAZoL6Gu5BZ9BOhziggsM1gfnt1E","m.room.topic","","@a:x",{"topic":"2"},["$CpBaM2dG34h2waJ_0bPdCXHQ4C4XunhaNcGOHiysXxU","$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0"],2],["$MLtjye1DdFJTje-IKfn-b-S5Z9cjXodURx9BlJ5QkKM","m.room.join_rules","","@b:x",{"join_rule":"public"},["$CpBaM2dG34h2waJ_0bPdCXHQ4C4XunhaNcGOHiysXxU","$9s0zoBEnjOTi3XdM-gLHmWfdyAgkFbo6_lEGXk1ixqo"],4]],"states":[["$9GVxkpgpfze87BPcAZoL6Gu5BZ9BOhziggsM1gfnt1E","$9s0zoBEnjOTi3XdM-gLHmWfdyAgkFbo6_lEGXk1ixqo","$CpBaM2dG34h2waJ_0bPdCXHQ4C4XunhaNcGOHiysXxU","$MLtjye1DdFJTje-IKfn-b-S5Z9cjXodURx9BlJ5QkKM","$N0W8qEY5Xq1uzGczWeImaXcUfLacAUF9MRu-EBT85-E","$REDorli1T4NkrLwOt__l6pzpeGZuv3GP1N1jBU-3lQs","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$gtpQidUJTSwxNaFOc1xS8F5Q7DkTDBYtFo3-Bk6VHWY","$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0"],["$CpBaM2dG34h2waJ_0bPdCXHQ4C4XunhaNcGOHiysXxU","$N0W8qEY5Xq1uzGczWeImaXcUfLacAUF9MRu-EBT85-E","$R8saErKMqshmJXNSoVYqU9J_AvFXZxGhfyW7-rGkNMM","$REDorli1T4NkrLwOt__l6pzpeGZuv3GP1N1jBU-3lQs","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$gtpQidUJTSwxNaFOc1xS8F5Q7DkTDBYtFo3-Bk6VHWY","$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0","$sUrU2kPwkzyfu1LC02iE7Ymoj1xpS5L20jVWHNR3Bb0","$tBQBtWiO5QmNYsc5e81_fiaAkYVwhvEiLnhwRn26Vjo"]],"resolved":["$9s0zoBEnjOTi3XdM-gLHmWfdyAgkFbo6_lEGXk1ixqo","$CpBaM2dG34h2waJ_0bPdCXHQ4C4XunhaNcGOHiysXxU","$MLtjye1DdFJTje-IKfn-b-S5Z9cjXodURx9BlJ5QkKM","$N0W8qEY5Xq1uzGczWeImaXcUfLacAUF9MRu-EBT85-E","$REDorli1T4NkrLwOt__l6pzpeGZuv3GP1N1jBU-3lQs","$Sb6agZaJwUJorTWHFUrRzRztWO1umg2-dZMUWMs5YSI","$gtpQidUJTSwxNaFOc1xS8F5Q7DkTDBYtFo3-Bk6VHWY","$s6eq_wyAMqZR2Ne4Yr1qTWwJ8FR71tEH-rdlM8bUlp0","$tBQBtWiO5QmNYsc5e81_fiaAkYVwhvEiLnhwRn26Vjo"]}, +{"name":"ts-2836","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$kOWmpMNLmCi86pYbjyIujRwMcmjgAT4_U-Wocc_D1VM","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":75,"m.room.name":50,"m.room.power_levels":50,"m.room.topic":50},"kick":25,"state_default":50,"users":{"@b:x":75,"@c:x":25,"@d:x":0}},["$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],1],["$TqqMeHdFFCc8TCdlPw4FBO6AI2tIZoWh2KLH3WJhJT4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$kOWmpMNLmCi86pYbjyIujRwMcmjgAT4_U-Wocc_D1VM","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],2],["$0HKhK10_VVCpNBdmBbR4foZ-bHp4SXLjuZa1BiB6ZNQ","m.room.member","@b:x","@b:x",{"membership":"join"},["$TqqMeHdFFCc8TCdlPw4FBO6AI2tIZoWh2KLH3WJhJT4","$kOWmpMNLmCi86pYbjyIujRwMcmjgAT4_U-Wocc_D1VM"],1],["$4SBV3a60Ty5RLiu4GqcEZ_WrKBsPHoWHwb-WkqUgoqE","m.room.member","@c:x","@c:x",{"membership":"join"},["$TqqMeHdFFCc8TCdlPw4FBO6AI2tIZoWh2KLH3WJhJT4","$kOWmpMNLmCi86pYbjyIujRwMcmjgAT4_U-Wocc_D1VM"],4],["$IIpKHESQMvTEEns4YTUOezqyLpA-auOnhiumaPWIzbo","m.room.member","@d:x","@d:x",{"membership":"join"},["$TqqMeHdFFCc8TCdlPw4FBO6AI2tIZoWh2KLH3WJhJT4","$kOWmpMNLmCi86pYbjyIujRwMcmjgAT4_U-Wocc_D1VM"],4],["$6o25BLo5iiuTDk9_I0-ZGXnL4liGYoMPdmYdugvR0O4","m.room.topic","","@a:x",{"topic":"base"},["$kOWmpMNLmCi86pYbjyIujRwMcmjgAT4_U-Wocc_D1VM","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],2],["$VZOZ2YwpJffULQCpUiv8_ocLelJ2QiCmjtIbU47XNjw","m.room.name","","@a:x",{"name":"base"},["$kOWmpMNLmCi86pYbjyIujRwMcmjgAT4_U-Wocc_D1VM","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g"],3],["$pHTQj-D3TydaaXAVd3t62Ytr9sB_8gjVp_xnCJ6KcoI","m.room.join_rules","","@b:x",{"join_rule":"invite"},["$kOWmpMNLmCi86pYbjyIujRwMcmjgAT4_U-Wocc_D1VM","$0HKhK10_VVCpNBdmBbR4foZ-bHp4SXLjuZa1BiB6ZNQ"],2],["$0buhR4UdoIR0W9ZgT5H3umz7g8VJy17Gheq6cbdgBVI","m.room.member","@d:x","@d:x",{"displayname":"0","membership":"join"},["$pHTQj-D3TydaaXAVd3t62Ytr9sB_8gjVp_xnCJ6KcoI","$kOWmpMNLmCi86pYbjyIujRwMcmjgAT4_U-Wocc_D1VM","$IIpKHESQMvTEEns4YTUOezqyLpA-auOnhiumaPWIzbo"],4],["$b-3KOY12aIi4I1YdHKrlX9bkUwkCI-TxOs9M2jCu6L0","m.room.member","@c:x","@b:x",{"membership":"ban"},["$kOWmpMNLmCi86pYbjyIujRwMcmjgAT4_U-Wocc_D1VM","$0HKhK10_VVCpNBdmBbR4foZ-bHp4SXLjuZa1BiB6ZNQ","$4SBV3a60Ty5RLiu4GqcEZ_WrKBsPHoWHwb-WkqUgoqE"],1]],"states":[["$0HKhK10_VVCpNBdmBbR4foZ-bHp4SXLjuZa1BiB6ZNQ","$0buhR4UdoIR0W9ZgT5H3umz7g8VJy17Gheq6cbdgBVI","$4SBV3a60Ty5RLiu4GqcEZ_WrKBsPHoWHwb-WkqUgoqE","$6o25BLo5iiuTDk9_I0-ZGXnL4liGYoMPdmYdugvR0O4","$VZOZ2YwpJffULQCpUiv8_ocLelJ2QiCmjtIbU47XNjw","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$kOWmpMNLmCi86pYbjyIujRwMcmjgAT4_U-Wocc_D1VM","$pHTQj-D3TydaaXAVd3t62Ytr9sB_8gjVp_xnCJ6KcoI"],["$0HKhK10_VVCpNBdmBbR4foZ-bHp4SXLjuZa1BiB6ZNQ","$6o25BLo5iiuTDk9_I0-ZGXnL4liGYoMPdmYdugvR0O4","$IIpKHESQMvTEEns4YTUOezqyLpA-auOnhiumaPWIzbo","$VZOZ2YwpJffULQCpUiv8_ocLelJ2QiCmjtIbU47XNjw","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$b-3KOY12aIi4I1YdHKrlX9bkUwkCI-TxOs9M2jCu6L0","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$kOWmpMNLmCi86pYbjyIujRwMcmjgAT4_U-Wocc_D1VM","$pHTQj-D3TydaaXAVd3t62Ytr9sB_8gjVp_xnCJ6KcoI"]],"resolved":["$0HKhK10_VVCpNBdmBbR4foZ-bHp4SXLjuZa1BiB6ZNQ","$6o25BLo5iiuTDk9_I0-ZGXnL4liGYoMPdmYdugvR0O4","$IIpKHESQMvTEEns4YTUOezqyLpA-auOnhiumaPWIzbo","$VZOZ2YwpJffULQCpUiv8_ocLelJ2QiCmjtIbU47XNjw","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$b-3KOY12aIi4I1YdHKrlX9bkUwkCI-TxOs9M2jCu6L0","$gH40YlqLJcpQhL3GuIVN23ykVADIHppP1Tlbvt_Bb1g","$kOWmpMNLmCi86pYbjyIujRwMcmjgAT4_U-Wocc_D1VM","$pHTQj-D3TydaaXAVd3t62Ytr9sB_8gjVp_xnCJ6KcoI"]}, +{"name":"ts-2851","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw","m.room.member","@a:x","@a:x",{"membership":"join"},[],3],["$hzwnNH1sRFwiTUlrCVvLo3_HRr-jv_NvErRDtzELE9k","m.room.power_levels","","@a:x",{"ban":75,"events":{"m.room.join_rules":100,"m.room.name":25,"m.room.power_levels":100,"m.room.topic":75},"kick":25,"state_default":50,"users":{"@b:x":25,"@c:x":50,"@d:x":0}},["$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],4],["$IVI49mw0L4n4sO4p4DC9_ouiv0klA0VmuhOP4p5ZpSw","m.room.join_rules","","@a:x",{"join_rule":"public"},["$hzwnNH1sRFwiTUlrCVvLo3_HRr-jv_NvErRDtzELE9k","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],2],["$Gu-0qeS3FpHq_1t6iu8AR-5x1wlbrF9JtMVcXY5vSOE","m.room.member","@b:x","@b:x",{"membership":"join"},["$IVI49mw0L4n4sO4p4DC9_ouiv0klA0VmuhOP4p5ZpSw","$hzwnNH1sRFwiTUlrCVvLo3_HRr-jv_NvErRDtzELE9k"],0],["$M2RhAUiz2u_u6e4mOt3Ldrw1MF4f-RlFQiILN5ey6yY","m.room.member","@c:x","@c:x",{"membership":"join"},["$IVI49mw0L4n4sO4p4DC9_ouiv0klA0VmuhOP4p5ZpSw","$hzwnNH1sRFwiTUlrCVvLo3_HRr-jv_NvErRDtzELE9k"],0],["$Sxzl_mM4ZQZB7fsRRn3954spQdS1vFYkhZpmSn7cvbk","m.room.member","@d:x","@d:x",{"membership":"join"},["$IVI49mw0L4n4sO4p4DC9_ouiv0klA0VmuhOP4p5ZpSw","$hzwnNH1sRFwiTUlrCVvLo3_HRr-jv_NvErRDtzELE9k"],4],["$1uudkHaGrc2rkFwNcZAUiM_xSB5bECl4xMdCJoFSEcs","m.room.name","","@a:x",{"name":"base"},["$hzwnNH1sRFwiTUlrCVvLo3_HRr-jv_NvErRDtzELE9k","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],4],["$xYI92Hnn_ru6bxU6f9nlbUypmkYF8cvXPn18DuO_Wv8","m.room.topic","","@a:x",{"topic":"0"},["$hzwnNH1sRFwiTUlrCVvLo3_HRr-jv_NvErRDtzELE9k","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],2],["$-tMdJ-J8Szw2ibvJDiUqpY1N8zpIBW-OgZyqLuA1B98","m.room.member","@c:x","@c:x",{"membership":"leave"},["$hzwnNH1sRFwiTUlrCVvLo3_HRr-jv_NvErRDtzELE9k","$M2RhAUiz2u_u6e4mOt3Ldrw1MF4f-RlFQiILN5ey6yY"],4],["$SAyRmyTBG23cda1WJQh09zMHHkc3GU2CxOtaBFthXYU","m.room.topic","","@a:x",{"topic":"1"},["$hzwnNH1sRFwiTUlrCVvLo3_HRr-jv_NvErRDtzELE9k","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw"],3],["$sfR0bf-B-3nF84qeAv6IM6L91y2pJjLRffKnVjknByE","m.room.member","@b:x","@b:x",{"displayname":"2","membership":"join"},["$IVI49mw0L4n4sO4p4DC9_ouiv0klA0VmuhOP4p5ZpSw","$hzwnNH1sRFwiTUlrCVvLo3_HRr-jv_NvErRDtzELE9k","$Gu-0qeS3FpHq_1t6iu8AR-5x1wlbrF9JtMVcXY5vSOE"],5]],"states":[["$1uudkHaGrc2rkFwNcZAUiM_xSB5bECl4xMdCJoFSEcs","$Gu-0qeS3FpHq_1t6iu8AR-5x1wlbrF9JtMVcXY5vSOE","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw","$IVI49mw0L4n4sO4p4DC9_ouiv0klA0VmuhOP4p5ZpSw","$M2RhAUiz2u_u6e4mOt3Ldrw1MF4f-RlFQiILN5ey6yY","$SAyRmyTBG23cda1WJQh09zMHHkc3GU2CxOtaBFthXYU","$Sxzl_mM4ZQZB7fsRRn3954spQdS1vFYkhZpmSn7cvbk","$hzwnNH1sRFwiTUlrCVvLo3_HRr-jv_NvErRDtzELE9k","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc"],["$-tMdJ-J8Szw2ibvJDiUqpY1N8zpIBW-OgZyqLuA1B98","$1uudkHaGrc2rkFwNcZAUiM_xSB5bECl4xMdCJoFSEcs","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw","$IVI49mw0L4n4sO4p4DC9_ouiv0klA0VmuhOP4p5ZpSw","$Sxzl_mM4ZQZB7fsRRn3954spQdS1vFYkhZpmSn7cvbk","$hzwnNH1sRFwiTUlrCVvLo3_HRr-jv_NvErRDtzELE9k","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$sfR0bf-B-3nF84qeAv6IM6L91y2pJjLRffKnVjknByE","$xYI92Hnn_ru6bxU6f9nlbUypmkYF8cvXPn18DuO_Wv8"]],"resolved":["$-tMdJ-J8Szw2ibvJDiUqpY1N8zpIBW-OgZyqLuA1B98","$1uudkHaGrc2rkFwNcZAUiM_xSB5bECl4xMdCJoFSEcs","$IJ5auV2cVhI-hUiHjf87qhwbzB9FuwXKe0ULwGWvarw","$IVI49mw0L4n4sO4p4DC9_ouiv0klA0VmuhOP4p5ZpSw","$SAyRmyTBG23cda1WJQh09zMHHkc3GU2CxOtaBFthXYU","$Sxzl_mM4ZQZB7fsRRn3954spQdS1vFYkhZpmSn7cvbk","$hzwnNH1sRFwiTUlrCVvLo3_HRr-jv_NvErRDtzELE9k","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$sfR0bf-B-3nF84qeAv6IM6L91y2pJjLRffKnVjknByE"]}, +{"name":"ts-2861","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$-VqvHLi0Qkj9-NIaCV4mTdFPPMqtUCKWcEXB0hcqZGc","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":25,"m.room.power_levels":75,"m.room.topic":75},"kick":50,"state_default":0,"users":{"@b:x":0,"@c:x":0,"@d:x":0}},["$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],1],["$Gn_KJo42D_fB6qkM7hdA0hkUKuMXywgKCze7aQ7kVfc","m.room.join_rules","","@a:x",{"join_rule":"public"},["$-VqvHLi0Qkj9-NIaCV4mTdFPPMqtUCKWcEXB0hcqZGc","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],2],["$mo6DvHtUu8slhkQb-UtbLxtjFu9RSt4trVAnJJno3pQ","m.room.member","@b:x","@b:x",{"membership":"join"},["$Gn_KJo42D_fB6qkM7hdA0hkUKuMXywgKCze7aQ7kVfc","$-VqvHLi0Qkj9-NIaCV4mTdFPPMqtUCKWcEXB0hcqZGc"],1],["$zR3TNVZI5wo-f9pjrc3-Z6KN2PJcO3jv8s_LwneHRf0","m.room.member","@c:x","@c:x",{"membership":"join"},["$Gn_KJo42D_fB6qkM7hdA0hkUKuMXywgKCze7aQ7kVfc","$-VqvHLi0Qkj9-NIaCV4mTdFPPMqtUCKWcEXB0hcqZGc"],1],["$8VI8-47M736WsLmaZp30_Ysj5bnsKNukeEUTrZj5W1I","m.room.member","@d:x","@d:x",{"membership":"join"},["$Gn_KJo42D_fB6qkM7hdA0hkUKuMXywgKCze7aQ7kVfc","$-VqvHLi0Qkj9-NIaCV4mTdFPPMqtUCKWcEXB0hcqZGc"],3],["$se3aOMnQoM564xZP8TEywoC0mAp83xjYK_bm4jt2ofo","m.room.topic","","@a:x",{"topic":"base"},["$-VqvHLi0Qkj9-NIaCV4mTdFPPMqtUCKWcEXB0hcqZGc","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],5],["$Pd2ahqhn0SwYZsng6lysDxnEIMlhpcBVT2zA0WehQ9c","m.room.name","","@a:x",{"name":"base"},["$-VqvHLi0Qkj9-NIaCV4mTdFPPMqtUCKWcEXB0hcqZGc","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],2],["$xRpYbgGN6QnpJqqnQ8JBxk3yDjQERZqx-j1EpGcwbPo","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$Gn_KJo42D_fB6qkM7hdA0hkUKuMXywgKCze7aQ7kVfc","$-VqvHLi0Qkj9-NIaCV4mTdFPPMqtUCKWcEXB0hcqZGc","$zR3TNVZI5wo-f9pjrc3-Z6KN2PJcO3jv8s_LwneHRf0"],0],["$hpmbmmlcq2q9yIlXSTxcFoPndC4ARG8c_UdtYvhF0Ps","m.room.join_rules","","@a:x",{"join_rule":"invite"},["$-VqvHLi0Qkj9-NIaCV4mTdFPPMqtUCKWcEXB0hcqZGc","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0"],2],["$GI3UetB2LwJgutqS9OrAfHJmHrTZlCJKMPGstUq6rLE","m.room.member","@b:x","@b:x",{"displayname":"1","membership":"join"},["$hpmbmmlcq2q9yIlXSTxcFoPndC4ARG8c_UdtYvhF0Ps","$-VqvHLi0Qkj9-NIaCV4mTdFPPMqtUCKWcEXB0hcqZGc","$mo6DvHtUu8slhkQb-UtbLxtjFu9RSt4trVAnJJno3pQ"],3]],"states":[["$-VqvHLi0Qkj9-NIaCV4mTdFPPMqtUCKWcEXB0hcqZGc","$8VI8-47M736WsLmaZp30_Ysj5bnsKNukeEUTrZj5W1I","$GI3UetB2LwJgutqS9OrAfHJmHrTZlCJKMPGstUq6rLE","$Pd2ahqhn0SwYZsng6lysDxnEIMlhpcBVT2zA0WehQ9c","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","$hpmbmmlcq2q9yIlXSTxcFoPndC4ARG8c_UdtYvhF0Ps","$se3aOMnQoM564xZP8TEywoC0mAp83xjYK_bm4jt2ofo","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$xRpYbgGN6QnpJqqnQ8JBxk3yDjQERZqx-j1EpGcwbPo"],["$-VqvHLi0Qkj9-NIaCV4mTdFPPMqtUCKWcEXB0hcqZGc","$8VI8-47M736WsLmaZp30_Ysj5bnsKNukeEUTrZj5W1I","$GI3UetB2LwJgutqS9OrAfHJmHrTZlCJKMPGstUq6rLE","$Pd2ahqhn0SwYZsng6lysDxnEIMlhpcBVT2zA0WehQ9c","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","$hpmbmmlcq2q9yIlXSTxcFoPndC4ARG8c_UdtYvhF0Ps","$se3aOMnQoM564xZP8TEywoC0mAp83xjYK_bm4jt2ofo","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$xRpYbgGN6QnpJqqnQ8JBxk3yDjQERZqx-j1EpGcwbPo"]],"resolved":["$-VqvHLi0Qkj9-NIaCV4mTdFPPMqtUCKWcEXB0hcqZGc","$8VI8-47M736WsLmaZp30_Ysj5bnsKNukeEUTrZj5W1I","$GI3UetB2LwJgutqS9OrAfHJmHrTZlCJKMPGstUq6rLE","$Pd2ahqhn0SwYZsng6lysDxnEIMlhpcBVT2zA0WehQ9c","$eoTI6rMnAA1iNS99g-9qjVbWtton9IPzMrMLluGFqs0","$hpmbmmlcq2q9yIlXSTxcFoPndC4ARG8c_UdtYvhF0Ps","$se3aOMnQoM564xZP8TEywoC0mAp83xjYK_bm4jt2ofo","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$xRpYbgGN6QnpJqqnQ8JBxk3yDjQERZqx-j1EpGcwbPo"]}, +{"name":"ts-2878","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$9tEKSIvPqiN_DkcJbDvl-WyVKamcGIzad8z7PJuiWe0","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":100,"m.room.topic":0},"kick":25,"state_default":0,"users":{"@b:x":0,"@c:x":0,"@d:x":0}},["$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"],1],["$DT2ef-Dd3pXpWqO0MfWk2jE38beYNLrkOYiOXDth6M8","m.room.join_rules","","@a:x",{"join_rule":"public"},["$9tEKSIvPqiN_DkcJbDvl-WyVKamcGIzad8z7PJuiWe0","$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"],1],["$3SIuCeBOc1YZbVsOeb8BAsP95i3htWs9agGiyS21T44","m.room.member","@b:x","@b:x",{"membership":"join"},["$DT2ef-Dd3pXpWqO0MfWk2jE38beYNLrkOYiOXDth6M8","$9tEKSIvPqiN_DkcJbDvl-WyVKamcGIzad8z7PJuiWe0"],4],["$0HQ5BgfAUV5nJQpwZ8fvbEl7kspxCDj1H1wob9hyTMA","m.room.member","@c:x","@c:x",{"membership":"join"},["$DT2ef-Dd3pXpWqO0MfWk2jE38beYNLrkOYiOXDth6M8","$9tEKSIvPqiN_DkcJbDvl-WyVKamcGIzad8z7PJuiWe0"],5],["$ndRox10DLEdfwjtsOgFJze7yec8gutGSBk3SHsVQZ8g","m.room.member","@d:x","@d:x",{"membership":"join"},["$DT2ef-Dd3pXpWqO0MfWk2jE38beYNLrkOYiOXDth6M8","$9tEKSIvPqiN_DkcJbDvl-WyVKamcGIzad8z7PJuiWe0"],1],["$AEiQ935dPrfZSkU6oq03zDWdu94DWmunTL0UC-1hePM","m.room.topic","","@a:x",{"topic":"base"},["$9tEKSIvPqiN_DkcJbDvl-WyVKamcGIzad8z7PJuiWe0","$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"],1],["$dJP0CWXI25d2Dqkly399Xcfq4kIU07OOuxdpjVtab-Q","m.room.name","","@a:x",{"name":"base"},["$9tEKSIvPqiN_DkcJbDvl-WyVKamcGIzad8z7PJuiWe0","$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"],0],["$MhBP8GyMpf9fK55u6qLfZdBGyRFJtGwWTggqriBnMEw","m.room.member","@b:x","@b:x",{"membership":"leave"},["$9tEKSIvPqiN_DkcJbDvl-WyVKamcGIzad8z7PJuiWe0","$3SIuCeBOc1YZbVsOeb8BAsP95i3htWs9agGiyS21T44"],0],["$LrBDZ0__U6DFuaeAFGmQdooV6nhDgLHatD4slGiFLrg","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$DT2ef-Dd3pXpWqO0MfWk2jE38beYNLrkOYiOXDth6M8","$9tEKSIvPqiN_DkcJbDvl-WyVKamcGIzad8z7PJuiWe0","$0HQ5BgfAUV5nJQpwZ8fvbEl7kspxCDj1H1wob9hyTMA"],5],["$d-MRY2W9uxEJytaOJaDfC_-aZZpDLTZN6TERNGqGScA","m.room.member","@d:x","@d:x",{"membership":"leave"},["$9tEKSIvPqiN_DkcJbDvl-WyVKamcGIzad8z7PJuiWe0","$ndRox10DLEdfwjtsOgFJze7yec8gutGSBk3SHsVQZ8g"],2]],"states":[["$9tEKSIvPqiN_DkcJbDvl-WyVKamcGIzad8z7PJuiWe0","$AEiQ935dPrfZSkU6oq03zDWdu94DWmunTL0UC-1hePM","$DT2ef-Dd3pXpWqO0MfWk2jE38beYNLrkOYiOXDth6M8","$LrBDZ0__U6DFuaeAFGmQdooV6nhDgLHatD4slGiFLrg","$MhBP8GyMpf9fK55u6qLfZdBGyRFJtGwWTggqriBnMEw","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$d-MRY2W9uxEJytaOJaDfC_-aZZpDLTZN6TERNGqGScA","$dJP0CWXI25d2Dqkly399Xcfq4kIU07OOuxdpjVtab-Q","$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"],["$0HQ5BgfAUV5nJQpwZ8fvbEl7kspxCDj1H1wob9hyTMA","$9tEKSIvPqiN_DkcJbDvl-WyVKamcGIzad8z7PJuiWe0","$AEiQ935dPrfZSkU6oq03zDWdu94DWmunTL0UC-1hePM","$DT2ef-Dd3pXpWqO0MfWk2jE38beYNLrkOYiOXDth6M8","$MhBP8GyMpf9fK55u6qLfZdBGyRFJtGwWTggqriBnMEw","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$dJP0CWXI25d2Dqkly399Xcfq4kIU07OOuxdpjVtab-Q","$ndRox10DLEdfwjtsOgFJze7yec8gutGSBk3SHsVQZ8g","$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"]],"resolved":["$9tEKSIvPqiN_DkcJbDvl-WyVKamcGIzad8z7PJuiWe0","$AEiQ935dPrfZSkU6oq03zDWdu94DWmunTL0UC-1hePM","$DT2ef-Dd3pXpWqO0MfWk2jE38beYNLrkOYiOXDth6M8","$LrBDZ0__U6DFuaeAFGmQdooV6nhDgLHatD4slGiFLrg","$MhBP8GyMpf9fK55u6qLfZdBGyRFJtGwWTggqriBnMEw","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$d-MRY2W9uxEJytaOJaDfC_-aZZpDLTZN6TERNGqGScA","$dJP0CWXI25d2Dqkly399Xcfq4kIU07OOuxdpjVtab-Q","$ta8Z8LbWY56Nvp0qjb6PxY_tBBpl4e3BU6vWpPqoCxo"]}, +{"name":"ts-2941","room_id":"!sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","events":[["$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","m.room.create","","@a:x",{"room_version":"12"},[],0],["$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI","m.room.member","@a:x","@a:x",{"membership":"join"},[],4],["$kBHXkhR7I3p0y1CgfGhFxf4pCUYD13hTio-nhWxfZK8","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":75},"kick":25,"state_default":50,"users":{"@b:x":0,"@c:x":0,"@d:x":25}},["$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],3],["$RKMQwcILi-3fmYqp7QC6IW0Aelthre366hAa_ayH_Qk","m.room.join_rules","","@a:x",{"join_rule":"public"},["$kBHXkhR7I3p0y1CgfGhFxf4pCUYD13hTio-nhWxfZK8","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],3],["$c09_kh_uf0GdRVHljXkzV8LQ2Fj8bxPH9G7d_PCgAwk","m.room.member","@b:x","@b:x",{"membership":"join"},["$RKMQwcILi-3fmYqp7QC6IW0Aelthre366hAa_ayH_Qk","$kBHXkhR7I3p0y1CgfGhFxf4pCUYD13hTio-nhWxfZK8"],4],["$TczbseT2A41FrSzb5A_jC03HYqnIpHOzwh2upf2AxEY","m.room.member","@c:x","@c:x",{"membership":"join"},["$RKMQwcILi-3fmYqp7QC6IW0Aelthre366hAa_ayH_Qk","$kBHXkhR7I3p0y1CgfGhFxf4pCUYD13hTio-nhWxfZK8"],3],["$XUxKpBz1dtyclQYJMdeThGUqMxKZivEYW2Z4BS3SRI4","m.room.member","@d:x","@d:x",{"membership":"join"},["$RKMQwcILi-3fmYqp7QC6IW0Aelthre366hAa_ayH_Qk","$kBHXkhR7I3p0y1CgfGhFxf4pCUYD13hTio-nhWxfZK8"],1],["$MF2C4i40eIsYn_RlGr4eLOWA70HKLOzmQAWfGEXm6Ds","m.room.topic","","@a:x",{"topic":"base"},["$kBHXkhR7I3p0y1CgfGhFxf4pCUYD13hTio-nhWxfZK8","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],1],["$Z_lEtvIJbC7QRTYtz7Lr8pm8Wd6S6y95Dj6FsAI2cB4","m.room.name","","@a:x",{"name":"base"},["$kBHXkhR7I3p0y1CgfGhFxf4pCUYD13hTio-nhWxfZK8","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],0]],"states":[["$MF2C4i40eIsYn_RlGr4eLOWA70HKLOzmQAWfGEXm6Ds","$RKMQwcILi-3fmYqp7QC6IW0Aelthre366hAa_ayH_Qk","$TczbseT2A41FrSzb5A_jC03HYqnIpHOzwh2upf2AxEY","$XUxKpBz1dtyclQYJMdeThGUqMxKZivEYW2Z4BS3SRI4","$Z_lEtvIJbC7QRTYtz7Lr8pm8Wd6S6y95Dj6FsAI2cB4","$c09_kh_uf0GdRVHljXkzV8LQ2Fj8bxPH9G7d_PCgAwk","$kBHXkhR7I3p0y1CgfGhFxf4pCUYD13hTio-nhWxfZK8","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],["$MF2C4i40eIsYn_RlGr4eLOWA70HKLOzmQAWfGEXm6Ds","$RKMQwcILi-3fmYqp7QC6IW0Aelthre366hAa_ayH_Qk","$TczbseT2A41FrSzb5A_jC03HYqnIpHOzwh2upf2AxEY","$XUxKpBz1dtyclQYJMdeThGUqMxKZivEYW2Z4BS3SRI4","$Z_lEtvIJbC7QRTYtz7Lr8pm8Wd6S6y95Dj6FsAI2cB4","$c09_kh_uf0GdRVHljXkzV8LQ2Fj8bxPH9G7d_PCgAwk","$kBHXkhR7I3p0y1CgfGhFxf4pCUYD13hTio-nhWxfZK8","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"],["$MF2C4i40eIsYn_RlGr4eLOWA70HKLOzmQAWfGEXm6Ds","$RKMQwcILi-3fmYqp7QC6IW0Aelthre366hAa_ayH_Qk","$TczbseT2A41FrSzb5A_jC03HYqnIpHOzwh2upf2AxEY","$XUxKpBz1dtyclQYJMdeThGUqMxKZivEYW2Z4BS3SRI4","$Z_lEtvIJbC7QRTYtz7Lr8pm8Wd6S6y95Dj6FsAI2cB4","$c09_kh_uf0GdRVHljXkzV8LQ2Fj8bxPH9G7d_PCgAwk","$kBHXkhR7I3p0y1CgfGhFxf4pCUYD13hTio-nhWxfZK8","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"]],"resolved":["$MF2C4i40eIsYn_RlGr4eLOWA70HKLOzmQAWfGEXm6Ds","$RKMQwcILi-3fmYqp7QC6IW0Aelthre366hAa_ayH_Qk","$TczbseT2A41FrSzb5A_jC03HYqnIpHOzwh2upf2AxEY","$XUxKpBz1dtyclQYJMdeThGUqMxKZivEYW2Z4BS3SRI4","$Z_lEtvIJbC7QRTYtz7Lr8pm8Wd6S6y95Dj6FsAI2cB4","$c09_kh_uf0GdRVHljXkzV8LQ2Fj8bxPH9G7d_PCgAwk","$kBHXkhR7I3p0y1CgfGhFxf4pCUYD13hTio-nhWxfZK8","$sxunuhZtIIux2j4nzcLEwIEQJzCX59Qh9I64qGhaLjE","$yjgAGus80oZPvXEFREHkfhBV5h0rhFaa50HyQ3ss8qI"]}, +{"name":"ts-2950","room_id":"!qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","events":[["$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","m.room.create","","@a:x",{"room_version":"12"},[],5],["$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","m.room.member","@a:x","@a:x",{"membership":"join"},[],1],["$Awul5iL5aWgMLq0K7vr94kndiQMoCvE4zSeYPIfYmKY","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":50,"m.room.name":0,"m.room.power_levels":50,"m.room.topic":50},"kick":50,"state_default":50,"users":{"@b:x":0,"@c:x":75,"@d:x":0}},["$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],4],["$kgCoc3rSK5XyGH82LIc-f5krZKAVK0Pfpwji-ApmTqg","m.room.join_rules","","@a:x",{"join_rule":"public"},["$Awul5iL5aWgMLq0K7vr94kndiQMoCvE4zSeYPIfYmKY","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],1],["$t2-e5Q8XjjPZrqClwwlf45IbVXSO7m-5Vq2KhUOtwIo","m.room.member","@b:x","@b:x",{"membership":"join"},["$kgCoc3rSK5XyGH82LIc-f5krZKAVK0Pfpwji-ApmTqg","$Awul5iL5aWgMLq0K7vr94kndiQMoCvE4zSeYPIfYmKY"],3],["$oymbw0nCI7a08OeF5abN4RKEhM5Y6V7heg9aK3MKQoY","m.room.member","@c:x","@c:x",{"membership":"join"},["$kgCoc3rSK5XyGH82LIc-f5krZKAVK0Pfpwji-ApmTqg","$Awul5iL5aWgMLq0K7vr94kndiQMoCvE4zSeYPIfYmKY"],1],["$jj-GKG7QWiGTqt1_nniVdqIJxz8JxRcjGe715SyBiqk","m.room.member","@d:x","@d:x",{"membership":"join"},["$kgCoc3rSK5XyGH82LIc-f5krZKAVK0Pfpwji-ApmTqg","$Awul5iL5aWgMLq0K7vr94kndiQMoCvE4zSeYPIfYmKY"],2],["$HOBjNUFdEH6KKq7DH7H1hSDxk218UE0FlCONSwV0U3A","m.room.topic","","@a:x",{"topic":"base"},["$Awul5iL5aWgMLq0K7vr94kndiQMoCvE4zSeYPIfYmKY","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],0],["$N4o1SlCoH1sX9kDIfA0OhMFY7TYM4IMyduw-swY2JFQ","m.room.name","","@a:x",{"name":"base"},["$Awul5iL5aWgMLq0K7vr94kndiQMoCvE4zSeYPIfYmKY","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI"],0],["$sy9yyUUXy37evA1ZtPyxa-5PgKFeuWO3taYvkpkHldQ","m.room.member","@c:x","@c:x",{"displayname":"0","membership":"join"},["$kgCoc3rSK5XyGH82LIc-f5krZKAVK0Pfpwji-ApmTqg","$Awul5iL5aWgMLq0K7vr94kndiQMoCvE4zSeYPIfYmKY","$oymbw0nCI7a08OeF5abN4RKEhM5Y6V7heg9aK3MKQoY"],5],["$FaEDprJyjcoNjc9FkpxTQaCs1ddVPwSoDSd67hJHiN4","m.room.member","@d:x","@d:x",{"displayname":"1","membership":"join"},["$kgCoc3rSK5XyGH82LIc-f5krZKAVK0Pfpwji-ApmTqg","$Awul5iL5aWgMLq0K7vr94kndiQMoCvE4zSeYPIfYmKY","$jj-GKG7QWiGTqt1_nniVdqIJxz8JxRcjGe715SyBiqk"],4],["$oWeog4ryNyfvJQFOQkqxKUvSyk1KsNoDeFe0FcOvA9o","m.room.member","@d:x","@d:x",{"displayname":"2","membership":"join"},["$kgCoc3rSK5XyGH82LIc-f5krZKAVK0Pfpwji-ApmTqg","$Awul5iL5aWgMLq0K7vr94kndiQMoCvE4zSeYPIfYmKY","$jj-GKG7QWiGTqt1_nniVdqIJxz8JxRcjGe715SyBiqk"],4]],"states":[["$Awul5iL5aWgMLq0K7vr94kndiQMoCvE4zSeYPIfYmKY","$HOBjNUFdEH6KKq7DH7H1hSDxk218UE0FlCONSwV0U3A","$N4o1SlCoH1sX9kDIfA0OhMFY7TYM4IMyduw-swY2JFQ","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","$kgCoc3rSK5XyGH82LIc-f5krZKAVK0Pfpwji-ApmTqg","$oWeog4ryNyfvJQFOQkqxKUvSyk1KsNoDeFe0FcOvA9o","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$sy9yyUUXy37evA1ZtPyxa-5PgKFeuWO3taYvkpkHldQ","$t2-e5Q8XjjPZrqClwwlf45IbVXSO7m-5Vq2KhUOtwIo"],["$Awul5iL5aWgMLq0K7vr94kndiQMoCvE4zSeYPIfYmKY","$FaEDprJyjcoNjc9FkpxTQaCs1ddVPwSoDSd67hJHiN4","$HOBjNUFdEH6KKq7DH7H1hSDxk218UE0FlCONSwV0U3A","$N4o1SlCoH1sX9kDIfA0OhMFY7TYM4IMyduw-swY2JFQ","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","$kgCoc3rSK5XyGH82LIc-f5krZKAVK0Pfpwji-ApmTqg","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$sy9yyUUXy37evA1ZtPyxa-5PgKFeuWO3taYvkpkHldQ","$t2-e5Q8XjjPZrqClwwlf45IbVXSO7m-5Vq2KhUOtwIo"]],"resolved":["$Awul5iL5aWgMLq0K7vr94kndiQMoCvE4zSeYPIfYmKY","$HOBjNUFdEH6KKq7DH7H1hSDxk218UE0FlCONSwV0U3A","$N4o1SlCoH1sX9kDIfA0OhMFY7TYM4IMyduw-swY2JFQ","$UEZG71iRLDTlQSmczWmLtWOAiYJVfIOREAIRfvjjzhI","$kgCoc3rSK5XyGH82LIc-f5krZKAVK0Pfpwji-ApmTqg","$oWeog4ryNyfvJQFOQkqxKUvSyk1KsNoDeFe0FcOvA9o","$qcQq24y87dqoHuTtH5Lwx94TNrcv-PUbJIRDB37_uOc","$sy9yyUUXy37evA1ZtPyxa-5PgKFeuWO3taYvkpkHldQ","$t2-e5Q8XjjPZrqClwwlf45IbVXSO7m-5Vq2KhUOtwIo"]}, +{"name":"ts-2963","room_id":"!O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","events":[["$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","m.room.create","","@a:x",{"room_version":"12"},[],2],["$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","m.room.member","@a:x","@a:x",{"membership":"join"},[],5],["$peUf4CbE4qgHgBCgyIRu_UwHTptE_IeAXu3V1QymKuM","m.room.power_levels","","@a:x",{"ban":50,"events":{"m.room.join_rules":100,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":50},"kick":25,"state_default":0,"users":{"@b:x":50,"@c:x":0,"@d:x":0}},["$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],3],["$MdC3T8M9WhdcMs3APZXGhubirXkPGuZIoBKSu-PAfq4","m.room.join_rules","","@a:x",{"join_rule":"public"},["$peUf4CbE4qgHgBCgyIRu_UwHTptE_IeAXu3V1QymKuM","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],1],["$UVslAnTRzB3BaNQdxzuu1Bigu5IXQ-ifh3ZEv84ey_o","m.room.member","@b:x","@b:x",{"membership":"join"},["$MdC3T8M9WhdcMs3APZXGhubirXkPGuZIoBKSu-PAfq4","$peUf4CbE4qgHgBCgyIRu_UwHTptE_IeAXu3V1QymKuM"],2],["$6A5Q5J5hCZF9ahc7GUX8WxsZYogCAIrVsng0cvuJ1Ow","m.room.member","@c:x","@c:x",{"membership":"join"},["$MdC3T8M9WhdcMs3APZXGhubirXkPGuZIoBKSu-PAfq4","$peUf4CbE4qgHgBCgyIRu_UwHTptE_IeAXu3V1QymKuM"],5],["$OwrM_XLz9u9CxUtoMQTf4UIc_lPTnc4pnPCqsQd2eDg","m.room.member","@d:x","@d:x",{"membership":"join"},["$MdC3T8M9WhdcMs3APZXGhubirXkPGuZIoBKSu-PAfq4","$peUf4CbE4qgHgBCgyIRu_UwHTptE_IeAXu3V1QymKuM"],4],["$J4IvwEaPNeOUa65MjdQpS95FlAd_gmbUKlOn-6NOF-A","m.room.topic","","@a:x",{"topic":"base"},["$peUf4CbE4qgHgBCgyIRu_UwHTptE_IeAXu3V1QymKuM","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],4],["$1dk9yhHjOVi7eNAoMHi538_4oBsMAVogJTUMDke6vu4","m.room.name","","@a:x",{"name":"base"},["$peUf4CbE4qgHgBCgyIRu_UwHTptE_IeAXu3V1QymKuM","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],2],["$k_ObNO9pqoRVyEwVWhvWgJw_ctxfK4RyUhFMb2cB2Bk","m.room.member","@c:x","@c:x",{"membership":"leave"},["$peUf4CbE4qgHgBCgyIRu_UwHTptE_IeAXu3V1QymKuM","$6A5Q5J5hCZF9ahc7GUX8WxsZYogCAIrVsng0cvuJ1Ow"],0],["$QidW0nQuzHZAxafJ7AcIzbih1BreP_2hof0J_kLJCjM","m.room.member","@e:x","@e:x",{"displayname":"0","membership":"join"},["$MdC3T8M9WhdcMs3APZXGhubirXkPGuZIoBKSu-PAfq4","$peUf4CbE4qgHgBCgyIRu_UwHTptE_IeAXu3V1QymKuM"],5],["$xfHm-6N1k8vPOUw_nmUPfWI9EUtMokgExgldzcJofKI","m.room.topic","","@a:x",{"topic":"1"},["$peUf4CbE4qgHgBCgyIRu_UwHTptE_IeAXu3V1QymKuM","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw"],3]],"states":[["$1dk9yhHjOVi7eNAoMHi538_4oBsMAVogJTUMDke6vu4","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","$J4IvwEaPNeOUa65MjdQpS95FlAd_gmbUKlOn-6NOF-A","$MdC3T8M9WhdcMs3APZXGhubirXkPGuZIoBKSu-PAfq4","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$OwrM_XLz9u9CxUtoMQTf4UIc_lPTnc4pnPCqsQd2eDg","$UVslAnTRzB3BaNQdxzuu1Bigu5IXQ-ifh3ZEv84ey_o","$k_ObNO9pqoRVyEwVWhvWgJw_ctxfK4RyUhFMb2cB2Bk","$peUf4CbE4qgHgBCgyIRu_UwHTptE_IeAXu3V1QymKuM"],["$1dk9yhHjOVi7eNAoMHi538_4oBsMAVogJTUMDke6vu4","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","$MdC3T8M9WhdcMs3APZXGhubirXkPGuZIoBKSu-PAfq4","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$OwrM_XLz9u9CxUtoMQTf4UIc_lPTnc4pnPCqsQd2eDg","$QidW0nQuzHZAxafJ7AcIzbih1BreP_2hof0J_kLJCjM","$UVslAnTRzB3BaNQdxzuu1Bigu5IXQ-ifh3ZEv84ey_o","$k_ObNO9pqoRVyEwVWhvWgJw_ctxfK4RyUhFMb2cB2Bk","$peUf4CbE4qgHgBCgyIRu_UwHTptE_IeAXu3V1QymKuM","$xfHm-6N1k8vPOUw_nmUPfWI9EUtMokgExgldzcJofKI"]],"resolved":["$1dk9yhHjOVi7eNAoMHi538_4oBsMAVogJTUMDke6vu4","$BTnbOBqTP4Rn0ci_R2rkJn1gl5SJaFVRDmcSszUX4lw","$J4IvwEaPNeOUa65MjdQpS95FlAd_gmbUKlOn-6NOF-A","$MdC3T8M9WhdcMs3APZXGhubirXkPGuZIoBKSu-PAfq4","$O2lDVnhKdUeBmIdof0wk2dAdbId1pwunpq5jIRBksdk","$OwrM_XLz9u9CxUtoMQTf4UIc_lPTnc4pnPCqsQd2eDg","$QidW0nQuzHZAxafJ7AcIzbih1BreP_2hof0J_kLJCjM","$UVslAnTRzB3BaNQdxzuu1Bigu5IXQ-ifh3ZEv84ey_o","$k_ObNO9pqoRVyEwVWhvWgJw_ctxfK4RyUhFMb2cB2Bk","$peUf4CbE4qgHgBCgyIRu_UwHTptE_IeAXu3V1QymKuM"]}, +{"name":"ts-2990","room_id":"!YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","events":[["$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","m.room.create","","@a:x",{"room_version":"12"},[],4],["$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","m.room.member","@a:x","@a:x",{"membership":"join"},[],2],["$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU","m.room.power_levels","","@a:x",{"ban":25,"events":{"m.room.join_rules":50,"m.room.name":50,"m.room.power_levels":75,"m.room.topic":25},"kick":75,"state_default":50,"users":{"@b:x":50,"@c:x":50,"@d:x":50}},["$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],1],["$0I5REygnpatBIR1V2-BdrXSPBHTa7XG4NcIJN4BDd3c","m.room.join_rules","","@a:x",{"join_rule":"public"},["$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],3],["$SCX55GcM6H9ZKq77sOEhGRa4cvq6eKqwZXtjrbDh4u8","m.room.member","@b:x","@b:x",{"membership":"join"},["$0I5REygnpatBIR1V2-BdrXSPBHTa7XG4NcIJN4BDd3c","$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU"],4],["$YQq3dNKwgZOfKK50rYitBpocy1hz5_hA3OCjEgrBSPQ","m.room.member","@c:x","@c:x",{"membership":"join"},["$0I5REygnpatBIR1V2-BdrXSPBHTa7XG4NcIJN4BDd3c","$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU"],4],["$J9sn8j9htPat_jIHl3dqJZmbAde_XQSuJ-MYNu5UcrI","m.room.member","@d:x","@d:x",{"membership":"join"},["$0I5REygnpatBIR1V2-BdrXSPBHTa7XG4NcIJN4BDd3c","$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU"],4],["$wg_jf8-_ToiySleaK-yPbTGuyr2vaOruQUXNWj7r5zQ","m.room.topic","","@a:x",{"topic":"base"},["$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],3],["$gXwPxFbX3WLGbEN4OYClTIZ3limaoDvDGcZkPb0y6DE","m.room.name","","@a:x",{"name":"base"},["$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],2],["$NNURdS0dW5-4XXn3CxPHVPuqAFwGJhMTlmYd9TzMZpE","m.room.member","@c:x","@c:x",{"membership":"leave"},["$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU","$YQq3dNKwgZOfKK50rYitBpocy1hz5_hA3OCjEgrBSPQ"],0],["$zCV9jTL9a_Dv6e9fSWPf_lPHP84o-CCLnz3GKis3ey4","m.room.member","@b:x","@b:x",{"membership":"leave"},["$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU","$SCX55GcM6H9ZKq77sOEhGRa4cvq6eKqwZXtjrbDh4u8"],5],["$lt357IQpf0IS0ArUggg0Xy6ZJQAXFX26ehE9z8_M8kw","m.room.join_rules","","@d:x",{"join_rule":"invite"},["$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU","$J9sn8j9htPat_jIHl3dqJZmbAde_XQSuJ-MYNu5UcrI"],0],["$fnD9PXJAmGFkF7_hbzfuqMGOMR5pPoJOdGwkdnMOjWE","m.room.member","@e:x","@a:x",{"membership":"leave"},["$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc"],3]],"states":[["$0I5REygnpatBIR1V2-BdrXSPBHTa7XG4NcIJN4BDd3c","$J9sn8j9htPat_jIHl3dqJZmbAde_XQSuJ-MYNu5UcrI","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$NNURdS0dW5-4XXn3CxPHVPuqAFwGJhMTlmYd9TzMZpE","$SCX55GcM6H9ZKq77sOEhGRa4cvq6eKqwZXtjrbDh4u8","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$gXwPxFbX3WLGbEN4OYClTIZ3limaoDvDGcZkPb0y6DE","$wg_jf8-_ToiySleaK-yPbTGuyr2vaOruQUXNWj7r5zQ","$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU"],["$J9sn8j9htPat_jIHl3dqJZmbAde_XQSuJ-MYNu5UcrI","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$SCX55GcM6H9ZKq77sOEhGRa4cvq6eKqwZXtjrbDh4u8","$YQq3dNKwgZOfKK50rYitBpocy1hz5_hA3OCjEgrBSPQ","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$gXwPxFbX3WLGbEN4OYClTIZ3limaoDvDGcZkPb0y6DE","$lt357IQpf0IS0ArUggg0Xy6ZJQAXFX26ehE9z8_M8kw","$wg_jf8-_ToiySleaK-yPbTGuyr2vaOruQUXNWj7r5zQ","$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU"],["$0I5REygnpatBIR1V2-BdrXSPBHTa7XG4NcIJN4BDd3c","$J9sn8j9htPat_jIHl3dqJZmbAde_XQSuJ-MYNu5UcrI","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$YQq3dNKwgZOfKK50rYitBpocy1hz5_hA3OCjEgrBSPQ","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$fnD9PXJAmGFkF7_hbzfuqMGOMR5pPoJOdGwkdnMOjWE","$gXwPxFbX3WLGbEN4OYClTIZ3limaoDvDGcZkPb0y6DE","$wg_jf8-_ToiySleaK-yPbTGuyr2vaOruQUXNWj7r5zQ","$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU","$zCV9jTL9a_Dv6e9fSWPf_lPHP84o-CCLnz3GKis3ey4"]],"resolved":["$J9sn8j9htPat_jIHl3dqJZmbAde_XQSuJ-MYNu5UcrI","$MRXkrY-rMPI6mA1m81T90gbtLDNhcWjolztiLJAunwc","$NNURdS0dW5-4XXn3CxPHVPuqAFwGJhMTlmYd9TzMZpE","$YkPkNVhPVWrm8XE64Iz_sjRM_g1RBhYf9z92TJOLFSA","$fnD9PXJAmGFkF7_hbzfuqMGOMR5pPoJOdGwkdnMOjWE","$gXwPxFbX3WLGbEN4OYClTIZ3limaoDvDGcZkPb0y6DE","$lt357IQpf0IS0ArUggg0Xy6ZJQAXFX26ehE9z8_M8kw","$wg_jf8-_ToiySleaK-yPbTGuyr2vaOruQUXNWj7r5zQ","$xQl-YXlsyP5cOIBqGiFPgMKBGQDtyF3geZ97TKR0PsU","$zCV9jTL9a_Dv6e9fSWPf_lPHP84o-CCLnz3GKis3ey4"]} +]} diff --git a/internal/stateres/v2.go b/internal/stateres/v2.go new file mode 100644 index 0000000..bfb1956 --- /dev/null +++ b/internal/stateres/v2.go @@ -0,0 +1,388 @@ +// Adapted from gomatrixserverlib's state resolution v2 implementation. +// TODO: Reconsider events rejected against their state, as the spec requires. +// Like Synapse, we currently exclude them; accepting them also requires repairing +// their stored rejection status, state contexts and dependent events. + +package stateres + +import ( + "cmp" + "container/heap" + "fmt" + "slices" + + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/gomatrixserverlib/spec" + "github.com/tidwall/gjson" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" +) + +const creatorPowerLevel = 100 + +var ( + createTup = types.StateTup{Type: event.StateCreate} + powerLevelsTup = types.StateTup{Type: event.StatePowerLevels} +) + +type resolver struct { + roomVersion string + events *eventCache + partial types.StateMap + common func(types.StateTup) (id.EventID, error) + powerLevelContents map[id.EventID]*gomatrixserverlib.PowerLevelContent +} + +func newResolver(roomVersion string, getEvent GetEventFunc) *resolver { + return &resolver{ + roomVersion: roomVersion, + events: &eventCache{ + getEvent: getEvent, + events: make(map[id.EventID]*types.Event), + }, + powerLevelContents: make(map[id.EventID]*gomatrixserverlib.PowerLevelContent), + } +} + +func (r *resolver) lookupPartial(tup types.StateTup) (id.EventID, error) { + if eventID, ok := r.partial[tup]; ok { + return eventID, nil + } + if r.common != nil { + return r.common(tup) + } + return "", nil +} + +func (r *resolver) run(fullConflictedSet map[id.EventID]*types.Event) error { + + // Spec steps 1 and 2: power events and their auth ancestors, authorized on the unconflicted + // state. + powerEvents, err := r.reverseTopologicalPowerOrder(fullConflictedSet) + if err != nil { + return err + } + if err := r.iterativeAuthChecks(powerEvents); err != nil { + return err + } + + ordered := make(map[id.EventID]struct{}, len(powerEvents)) + for _, ev := range powerEvents { + ordered[ev.ID] = struct{}{} + } + // Spec steps 3 and 4: everything else, in mainline order of the resolved power levels. + others := make([]*types.Event, 0, len(fullConflictedSet)-len(powerEvents)) + for eventID, ev := range fullConflictedSet { + if _, ok := ordered[eventID]; !ok { + others = append(others, ev) + } + } + if err := r.mainlineOrder(others); err != nil { + return err + } + if err := r.iterativeAuthChecks(others); err != nil { + return err + } + + return nil +} + +// isPowerEvent follows the spec's definition of a power event, one that might remove someone's +// ability to do something in the room, and adds the create event as Synapse does. +func isPowerEvent(ev *types.Event) bool { + switch ev.Type { + case event.StateCreate, event.StatePowerLevels, event.StateJoinRules: + return *ev.StateKey == "" + case event.StateMember: + if *ev.StateKey == "" || *ev.StateKey == ev.Sender.String() { + return false + } + membership := ev.Membership() + return membership == event.MembershipLeave || membership == event.MembershipBan + } + return false +} + +type powerSortKey struct { + powerLevel int64 + timestamp int64 + eventID id.EventID +} + +func (k powerSortKey) less(other powerSortKey) bool { + if k.powerLevel != other.powerLevel { + return k.powerLevel > other.powerLevel + } + if k.timestamp != other.timestamp { + return k.timestamp < other.timestamp + } + return k.eventID < other.eventID +} + +type powerSortHeap []powerSortKey + +func (h powerSortHeap) Len() int { return len(h) } +func (h powerSortHeap) Less(i, j int) bool { return h[i].less(h[j]) } +func (h powerSortHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h *powerSortHeap) Push(x any) { *h = append(*h, x.(powerSortKey)) } +func (h *powerSortHeap) Pop() any { + old := *h + last := old[len(old)-1] + *h = old[:len(old)-1] + return last +} + +// reverseTopologicalPowerOrder takes the power events of the full conflicted set together with +// their auth ancestors inside the full conflicted set, and returns them in the reverse topological +// power ordering: auth events before the events citing them, ties broken by greater sender power +// level, then earlier origin_server_ts, then smaller event ID. +func (r *resolver) reverseTopologicalPowerOrder(fullConflictedSet map[id.EventID]*types.Event) ([]*types.Event, error) { + authEdges := make(map[id.EventID][]id.EventID) + var pending []*types.Event + for _, ev := range fullConflictedSet { + if isPowerEvent(ev) { + pending = append(pending, ev) + } + } + for len(pending) > 0 { + ev := pending[len(pending)-1] + pending = pending[:len(pending)-1] + if _, seen := authEdges[ev.ID]; seen { + continue + } + edges := make([]id.EventID, 0, len(ev.AuthEventIDs)) + for _, authEventID := range ev.AuthEventIDs { + authEv, ok := fullConflictedSet[authEventID] + if !ok || slices.Contains(edges, authEventID) { + continue + } + edges = append(edges, authEventID) + pending = append(pending, authEv) + } + authEdges[ev.ID] = edges + } + + keys := make(map[id.EventID]powerSortKey, len(authEdges)) + remainingAuth := make(map[id.EventID]int, len(authEdges)) + citedBy := make(map[id.EventID][]id.EventID, len(authEdges)) + ready := make(powerSortHeap, 0, len(authEdges)) + for eventID, edges := range authEdges { + ev := fullConflictedSet[eventID] + powerLevel, err := r.senderPowerLevel(ev) + if err != nil { + return nil, err + } + keys[eventID] = powerSortKey{powerLevel: powerLevel, timestamp: ev.Timestamp, eventID: eventID} + remainingAuth[eventID] = len(edges) + for _, authEventID := range edges { + citedBy[authEventID] = append(citedBy[authEventID], eventID) + } + if len(edges) == 0 { + ready = append(ready, keys[eventID]) + } + } + heap.Init(&ready) + + ordered := make([]*types.Event, 0, len(authEdges)) + for ready.Len() > 0 { + next := heap.Pop(&ready).(powerSortKey) + ordered = append(ordered, fullConflictedSet[next.eventID]) + for _, eventID := range citedBy[next.eventID] { + remainingAuth[eventID]-- + if remainingAuth[eventID] == 0 { + heap.Push(&ready, keys[eventID]) + } + } + } + return ordered, nil +} + +// senderPowerLevel is the sender's power level according to the power levels event among the +// event's auth events, or the creator level if there is none and the sender created the room. +func (r *resolver) senderPowerLevel(ev *types.Event) (int64, error) { + var createEv *types.Event + if util.RoomVersionHas(ev.RoomVersion, gomatrixserverlib.IRoomVersion.PrivilegedCreators) { + var err error + createEv, err = r.events.get(ev.ImplicitCreateEventID()) + if err != nil { + return 0, err + } + if createEv != nil && slices.Contains(gomatrixserverlib.CreatorsFromCreateEvent(createEv.PDU()), ev.Sender.String()) { + return gomatrixserverlib.CreatorPowerLevel, nil + } + } + powerLevelsEv, err := r.citedAuthEvent(ev, powerLevelsTup, false) + if err != nil { + return 0, err + } else if powerLevelsEv != nil { + if content := r.powerLevelContent(powerLevelsEv); content != nil { + return content.UserLevel(spec.SenderID(ev.Sender)), nil + } + return 0, nil + } + if citedCreateEv, err := r.citedAuthEvent(ev, createTup, false); err != nil { + return 0, err + } else if citedCreateEv != nil { + createEv = citedCreateEv + } + if createEv != nil && r.isCreator(createEv, ev.Sender) { + return creatorPowerLevel, nil + } + return 0, nil +} + +func (r *resolver) isCreator(createEv *types.Event, userID id.UserID) bool { + if util.RoomVersionHas(r.roomVersion, gomatrixserverlib.IRoomVersion.CreatorInCreateEvent) { + return gjson.GetBytes(createEv.Content, "creator").String() == userID.String() + } + return createEv.Sender == userID +} + +// powerLevelContent returns nil for unparseable content. +func (r *resolver) powerLevelContent(ev *types.Event) *gomatrixserverlib.PowerLevelContent { + if content, ok := r.powerLevelContents[ev.ID]; ok { + return content + } + var content *gomatrixserverlib.PowerLevelContent + if parsed, err := gomatrixserverlib.NewPowerLevelContentFromEvent(ev.PDU()); err == nil { + content = &parsed + } + r.powerLevelContents[ev.ID] = content + return content +} + +type mainlineSortKey struct { + position int + timestamp int64 + eventID id.EventID +} + +func (k mainlineSortKey) compare(other mainlineSortKey) int { + return cmp.Or( + cmp.Compare(k.position, other.position), + cmp.Compare(k.timestamp, other.timestamp), + cmp.Compare(k.eventID, other.eventID), + ) +} + +// mainlineOrder sorts events by the mainline of the resolved power levels event: the chain of +// power levels events reached by following each one's auth events back to the start of the room. +// An event's position is that of the first mainline event on its own power levels chain, with the +// oldest mainline event first and events that never reach the mainline before all of them, then +// ties broken by earlier origin_server_ts and smaller event ID. +func (r *resolver) mainlineOrder(evs []*types.Event) error { + if len(evs) == 0 { + return nil + } + + var mainline []id.EventID + eventID, err := r.lookupPartial(powerLevelsTup) + if err != nil { + return err + } + if eventID != "" { + current, err := r.events.get(eventID) + if err != nil { + return err + } + for current != nil { + mainline = append(mainline, current.ID) + if current, err = r.citedAuthEvent(current, powerLevelsTup, false); err != nil { + return err + } + } + } + positions := make(map[id.EventID]int, len(mainline)) + for i, eventID := range mainline { + positions[eventID] = len(mainline) - i + } + + keys := make(map[id.EventID]mainlineSortKey, len(evs)) + for _, ev := range evs { + position, err := r.mainlinePosition(ev, positions) + if err != nil { + return err + } + keys[ev.ID] = mainlineSortKey{position: position, timestamp: ev.Timestamp, eventID: ev.ID} + } + slices.SortFunc(evs, func(a, b *types.Event) int { + return keys[a.ID].compare(keys[b.ID]) + }) + return nil +} + +func (r *resolver) mainlinePosition(ev *types.Event, positions map[id.EventID]int) (int, error) { + for current := ev; current != nil; { + if position, ok := positions[current.ID]; ok { + return position, nil + } + var err error + if current, err = r.citedAuthEvent(current, powerLevelsTup, false); err != nil { + return 0, err + } + } + return 0, nil +} + +func (r *resolver) iterativeAuthChecks(evs []*types.Event) error { + for _, ev := range evs { + if ev.Rejected { + continue + } + authErr, err := r.authorize(ev) + if err != nil { + return err + } + if authErr == nil { + r.partial[ev.StateTup()] = ev.ID + } + } + return nil +} + +func (r *resolver) authorize(ev *types.Event) (authErr, err error) { + return util.Authorize(ev, func(tup types.StateTup) (*types.Event, error) { + return r.authEvent(ev, tup) + }) +} + +// authEvent serves the auth events for one event from the partial state. Per the spec's iterative +// auth checks, a tuple the partial state lacks, or holds a rejected event for, falls back to the +// event's own auth event for it, unless that auth event was rejected too. +func (r *resolver) authEvent(ev *types.Event, tup types.StateTup) (*types.Event, error) { + eventID, err := r.lookupPartial(tup) + if err != nil { + return nil, err + } + if tup == createTup && util.RoomVersionHas(ev.RoomVersion, gomatrixserverlib.IRoomVersion.DomainlessRoomIDs) { + eventID = ev.ImplicitCreateEventID() + } + if eventID != "" { + stateEv, err := r.events.get(eventID) + if err != nil { + return nil, err + } else if stateEv == nil { + return nil, fmt.Errorf("state event %s for (%s, %q) not found", eventID, tup.Type.Type, tup.StateKey) + } else if !stateEv.Rejected { + return stateEv, nil + } + } + return r.citedAuthEvent(ev, tup, true) +} + +// citedAuthEvent returns the first of the event's auth events that is the state event for tup. +func (r *resolver) citedAuthEvent(ev *types.Event, tup types.StateTup, skipRejected bool) (*types.Event, error) { + for _, authEventID := range ev.AuthEventIDs { + authEv, err := r.events.get(authEventID) + if err != nil { + return nil, err + } + if authEv != nil && !(skipRejected && authEv.Rejected) && authEv.StateKey != nil && authEv.StateTup() == tup { + return authEv, nil + } + } + return nil, nil +} From 6dc6cb8a5788e2052e78d5670936301d8899db14 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Sat, 3 Oct 2026 22:10:47 +0100 Subject: [PATCH 04/12] Store immutable room state in content-addressed maps --- internal/databases/rooms/state/cache.go | 90 ++ internal/databases/rooms/state/conflicts.go | 109 +++ internal/databases/rooms/state/context.go | 611 +++++++++++++ .../databases/rooms/state/context_test.go | 825 ++++++++++++++++++ .../databases/rooms/state/memstore_test.go | 227 +++++ internal/databases/rooms/state/page.go | 157 ++++ internal/databases/rooms/state/page_test.go | 134 +++ internal/databases/rooms/state/resolution.go | 57 ++ .../databases/rooms/state/resolution_test.go | 53 ++ internal/databases/rooms/state/stage.go | 77 ++ internal/databases/rooms/state/stage_test.go | 257 ++++++ internal/databases/rooms/state/state.go | 284 ++++++ internal/databases/rooms/state/tree.go | 691 +++++++++++++++ internal/databases/rooms/state/tree_test.go | 450 ++++++++++ 14 files changed, 4022 insertions(+) create mode 100644 internal/databases/rooms/state/cache.go create mode 100644 internal/databases/rooms/state/conflicts.go create mode 100644 internal/databases/rooms/state/context.go create mode 100644 internal/databases/rooms/state/context_test.go create mode 100644 internal/databases/rooms/state/memstore_test.go create mode 100644 internal/databases/rooms/state/page.go create mode 100644 internal/databases/rooms/state/page_test.go create mode 100644 internal/databases/rooms/state/resolution.go create mode 100644 internal/databases/rooms/state/resolution_test.go create mode 100644 internal/databases/rooms/state/stage.go create mode 100644 internal/databases/rooms/state/stage_test.go create mode 100644 internal/databases/rooms/state/state.go create mode 100644 internal/databases/rooms/state/tree.go create mode 100644 internal/databases/rooms/state/tree_test.go diff --git a/internal/databases/rooms/state/cache.go b/internal/databases/rooms/state/cache.go new file mode 100644 index 0000000..8e42ccd --- /dev/null +++ b/internal/databases/rooms/state/cache.go @@ -0,0 +1,90 @@ +package state + +import ( + "container/list" + "sync" + + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +const defaultCacheBytes = 64 << 20 + +// Rough per item bookkeeping cost on top of the value: map slot, list element, key and item. +const cacheItemOverhead = 256 + +// A decoded leaf holds a 48 byte entry per item plus separate key and value copies. +const decodedEntryCost = 64 + +type cacheKey struct { + roomID id.RoomID + hash types.StateHash +} + +type cacheItem struct { + key cacheKey + value any + cost int +} + +// cache is an LRU of decoded pages and contexts that are committed, bounded by bytes. Both are +// immutable. Each room stores its own records, so entries are keyed by room: a hash read in one room +// says nothing about another room's storage. +type cache struct { + mu sync.Mutex + maxBytes int + bytes int + items map[cacheKey]*list.Element + order *list.List +} + +func newCache(maxBytes int) *cache { + return &cache{ + maxBytes: maxBytes, + items: make(map[cacheKey]*list.Element), + order: list.New(), + } +} + +func (c *cache) get(key cacheKey) any { + c.mu.Lock() + defer c.mu.Unlock() + el, ok := c.items[key] + if !ok { + return nil + } + c.order.MoveToFront(el) + return el.Value.(*cacheItem).value +} + +func (c *cache) getPage(roomID id.RoomID, h types.StateHash) *page { + p, _ := c.get(cacheKey{roomID, h}).(*page) + return p +} + +func (c *cache) getContext(roomID id.RoomID, h types.StateHash) (stateContext, bool) { + ctx, ok := c.get(cacheKey{roomID, h}).(stateContext) + return ctx, ok +} + +func (c *cache) add(key cacheKey, value any, size int) { + cost := size + cacheItemOverhead + if cost > c.maxBytes { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.items[key]; ok { + c.order.MoveToFront(el) + } else { + c.items[key] = c.order.PushFront(&cacheItem{key: key, value: value, cost: cost}) + c.bytes += cost + } + for c.bytes > c.maxBytes { + evicted := c.order.Remove(c.order.Back()).(*cacheItem) + delete(c.items, evicted.key) + c.bytes -= evicted.cost + } +} diff --git a/internal/databases/rooms/state/conflicts.go b/internal/databases/rooms/state/conflicts.go new file mode 100644 index 0000000..1c3085d --- /dev/null +++ b/internal/databases/rooms/state/conflicts.go @@ -0,0 +1,109 @@ +package state + +import ( + "github.com/apple/foundationdb/bindings/go/src/fdb" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +type multiChange struct { + key []byte + values [][]byte +} + +// diffAll compares every root of each group to the group's first root, all groups sharing one +// batched read per level. Each key differing within a group has a value, nil when absent, for every +// root of the group, in no particular key order. +func (t *tree) diffAll(r pageReader, groups ...[]types.StateHash) ([][]multiChange, error) { + var pairs []rootPair + for _, roots := range groups { + for i := 1; i < len(roots); i++ { + pairs = append(pairs, rootPair{roots[0], roots[i]}) + } + } + diffs, err := t.diff(r, pairs...) + if err != nil { + return nil, err + } + + out := make([][]multiChange, len(groups)) + pair := 0 + for g, roots := range groups { + byKey := make(map[string]int) + for i := 1; i < len(roots); i++ { + for _, change := range diffs[pair] { + j, ok := byKey[string(change.key)] + if !ok { + j = len(out[g]) + byKey[string(change.key)] = j + values := make([][]byte, len(roots)) + for k := range values { + values[k] = change.old + } + out[g] = append(out[g], multiChange{key: change.key, values: values}) + } + out[g][j].values[i] = change.new + } + pair++ + } + } + return out, nil +} + +// diffContexts returns each tuple whose event differs between the contexts, with its entry in every +// context in the order of ctxs and the empty entry where it is absent. All the comparisons share one +// batched read per level and identical subtrees are never enumerated. A member's value is a function +// of its event ID, so values differ exactly where event IDs do. +func (b *Batch) diffContexts(r storeReader, ctxs []types.StateHash) (map[types.StateTup][]types.StateEntry, error) { + resolved, err := b.resolve(r, ctxs...) + if err != nil { + return nil, err + } + groups := make([][]types.StateHash, contextMaps) + for _, c := range resolved { + for m, root := range c.roots() { + groups[m] = append(groups[m], root) + } + } + diffs, err := b.tree.diffAll(r, groups...) + if err != nil { + return nil, err + } + + out := make(map[types.StateTup][]types.StateEntry) + for m, changes := range diffs { + for _, change := range changes { + tup, err := keyTuple(m, change.key) + if err != nil { + return nil, err + } + entries := make([]types.StateEntry, len(change.values)) + for i, value := range change.values { + if entries[i], err = decodeValue(m, value); err != nil { + return nil, err + } + } + out[tup] = entries + } + } + return out, nil +} + +// TxnConflicts returns the tuples whose event differs between the contexts, each with one event ID +// per context in the order of ctxs and "" where the tuple is absent from that context. +func (b *Batch) TxnConflicts(txn fdb.ReadTransaction, ctxs []types.StateHash) (map[types.StateTup][]id.EventID, error) { + diff, err := b.diffContexts(b.store.reader(txn, b.roomID), ctxs) + if err != nil { + return nil, err + } + conflicts := make(map[types.StateTup][]id.EventID, len(diff)) + for tup, entries := range diff { + eventIDs := make([]id.EventID, len(entries)) + for i, entry := range entries { + eventIDs[i] = entry.EventID + } + conflicts[tup] = eventIDs + } + return conflicts, nil +} diff --git a/internal/databases/rooms/state/context.go b/internal/databases/rooms/state/context.go new file mode 100644 index 0000000..134fd34 --- /dev/null +++ b/internal/databases/rooms/state/context.go @@ -0,0 +1,611 @@ +package state + +import ( + "bytes" + "context" + "errors" + "fmt" + "slices" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +var ( + ErrUnknownContext = errors.New("unknown state context") + ErrContextNotFound = errors.New("state context not found") + ErrInvalidContext = errors.New("invalid state context") + ErrInvalidEntry = errors.New("invalid state entry") +) + +// stateContext is the room state at a point in the DAG as the roots of two immutable maps: +// non-member state keyed by packed (type, stateKey) to event IDs, and members keyed by user ID to +// their membership and event ID, see encodeValue. +type stateContext struct { + stateRoot, memberRoot types.StateHash +} + +// EmptyContext is the ID of the context holding no state, it is never stored. The zero hash is not +// a context and means the state is unknown. +var EmptyContext = stateContext{}.ID() + +func (c stateContext) ID() types.StateHash { + return hashOf(c.encode()) +} + +func (c stateContext) encode() []byte { + return tuple.Tuple{hashToBytes(c.stateRoot), hashToBytes(c.memberRoot)}.Pack() +} + +func decodeContext(raw []byte) (stateContext, error) { + tup, err := tuple.Unpack(raw) + if err != nil { + return stateContext{}, fmt.Errorf("%w: %w", ErrInvalidContext, err) + } else if len(tup) != 2 { + return stateContext{}, fmt.Errorf("%w: %d elements", ErrInvalidContext, len(tup)) + } + var c stateContext + if c.stateRoot, err = hashFromElement(tup[0]); err != nil { + return stateContext{}, fmt.Errorf("%w: state root: %w", ErrInvalidContext, err) + } else if c.memberRoot, err = hashFromElement(tup[1]); err != nil { + return stateContext{}, fmt.Errorf("%w: member root: %w", ErrInvalidContext, err) + } + return c, nil +} + +// A context has a map of non-member state and a map of members, indexed in that order by roots. +const ( + stateIndex = iota + memberIndex + contextMaps +) + +func (c stateContext) roots() [contextMaps]types.StateHash { + return [contextMaps]types.StateHash{stateIndex: c.stateRoot, memberIndex: c.memberRoot} +} + +// tupleKey returns the index in roots of the map holding the tuple, and its key in that map. +func tupleKey(tup types.StateTup) (int, []byte) { + if tup.Type.Type == event.StateMember.Type { + return memberIndex, []byte(tup.StateKey) + } + return stateIndex, packStateKey(tup) +} + +func keyTuple(m int, key []byte) (types.StateTup, error) { + if m == memberIndex { + return types.MemberStateTup(id.UserID(key)), nil + } + return unpackStateKey(key) +} + +// The codes of the memberships member map values start with, part of the format. Zero is invalid. +var membershipCodes = [...]event.Membership{ + 1: event.MembershipJoin, + 2: event.MembershipInvite, + 3: event.MembershipLeave, + 4: event.MembershipBan, + 5: event.MembershipKnock, +} + +// encodeValue returns the value the map holds for an entry, nil for a removal. A member's value is +// its membership code then the event ID. Membership is a function of the event, so the value, and +// with it every root and context ID, still depends only on the event IDs. +func encodeValue(m int, entry types.StateEntry) ([]byte, error) { + switch { + case entry.EventID == "" && entry.Membership != "": + return nil, fmt.Errorf("%w: removal with membership %q", ErrInvalidEntry, entry.Membership) + case entry.EventID == "": + return nil, nil + case m != memberIndex && entry.Membership != "": + return nil, fmt.Errorf("%w: %s is not a member event but has membership %q", ErrInvalidEntry, entry.EventID, entry.Membership) + case m != memberIndex: + return []byte(entry.EventID), nil + } + code := slices.Index(membershipCodes[:], entry.Membership) + if code <= 0 { + return nil, fmt.Errorf("%w: member event %s has invalid membership %q", ErrInvalidEntry, entry.EventID, entry.Membership) + } + return append([]byte{byte(code)}, entry.EventID...), nil +} + +// decodeValue returns the entry of a value the map holds, the empty entry for nil. +func decodeValue(m int, value []byte) (types.StateEntry, error) { + if value == nil { + return types.StateEntry{}, nil + } else if m != memberIndex { + return types.StateEntry{EventID: id.EventID(value)}, nil + } else if len(value) < 2 || int(value[0]) >= len(membershipCodes) || membershipCodes[value[0]] == "" { + return types.StateEntry{}, fmt.Errorf("%w: member value %q", ErrInvalidPage, value) + } + return types.StateEntry{EventID: id.EventID(value[1:]), Membership: membershipCodes[value[0]]}, nil +} + +// encodeChanges returns the changes of each map that apply the delta. +func encodeChanges(delta types.StateEntries) ([contextMaps]map[string][]byte, error) { + var changes [contextMaps]map[string][]byte + for m := range changes { + changes[m] = make(map[string][]byte) + } + for tup, entry := range delta { + m, key := tupleKey(tup) + value, err := encodeValue(m, entry) + if err != nil { + return changes, fmt.Errorf("%v: %w", tup, err) + } + changes[m][string(key)] = value + } + return changes, nil +} + +func packStateKey(tup types.StateTup) []byte { + return tup.TupleElements().Pack() +} + +func unpackStateKey(key []byte) (types.StateTup, error) { + tup, err := tuple.Unpack(key) + if err != nil { + return types.StateTup{}, fmt.Errorf("%w: state key: %w", ErrInvalidPage, err) + } + if stateTup, ok := types.StateTupFromElements(tup); ok { + return stateTup, nil + } + return types.StateTup{}, fmt.Errorf("%w: state key %v is not (type, stateKey)", ErrInvalidPage, tup) +} + +type storeReader interface { + pageReader + // readContexts returns the stored bytes of each context in one round trip, a job batch's in one + // per bounded batch, nil for a missing one. + readContexts(ctxs []types.StateHash) ([][]byte, error) + // readResolution starts reading the stored result of a resolution and returns a function waiting + // for it, which gives nil when there is none. + readResolution(key types.StateHash) func() ([]byte, error) +} + +type storeWriter interface { + pageWriter + writeContext(ctx types.StateHash, raw []byte) + writeResolution(key types.StateHash, raw []byte) +} + +type storage interface { + reader(txn fdb.ReadTransaction, roomID id.RoomID) storeReader + writer(txn fdb.Transaction, roomID id.RoomID) storeWriter + stage(ctx context.Context, roomID id.RoomID, fn func(storeWriter) error) error + // Page and context keys of a room have the same length + keySize(roomID id.RoomID) int +} + +// Batch collects the contexts and pages produced for one room until they are written. Every read +// consults the batch first, so a context is usable before it is stored. A batch is not safe for +// concurrent use and may span transactions, reading in one and writing in another. +type Batch struct { + store storage + roomID id.RoomID + tree tree + contexts map[types.StateHash]stateContext +} + +func newBatch(store storage, c *cache, roomID id.RoomID, limits leafLimits) *Batch { + return &Batch{ + store: store, + roomID: roomID, + tree: newTree(c, roomID, limits), + contexts: make(map[types.StateHash]stateContext), + } +} + +// resolve returns the roots of each context, reading the ones neither pending nor cached together. +func (b *Batch) resolve(r storeReader, ctxs ...types.StateHash) ([]stateContext, error) { + resolved := make([]stateContext, len(ctxs)) + var missing []types.StateHash + for i, ctx := range ctxs { + if ctx == EmptyContext { + continue + } else if ctx.IsZero() { + return nil, ErrUnknownContext + } else if c, ok := b.contexts[ctx]; ok { + resolved[i] = c + } else if c, ok := b.tree.cache.getContext(b.roomID, ctx); ok { + resolved[i] = c + } else { + missing = append(missing, ctx) + } + } + + read, err := b.readContexts(r, missing) + if err != nil { + return nil, err + } + for i, ctx := range ctxs { + if c, ok := read[ctx]; ok { + resolved[i] = c + } + } + return resolved, nil +} + +// readContexts reads each distinct context from storage in one round trip and caches it. +func (b *Batch) readContexts(r storeReader, ctxs []types.StateHash) (map[types.StateHash]stateContext, error) { + unique := slices.Compact(slices.SortedFunc(slices.Values(ctxs), compareHashes)) + read := make(map[types.StateHash]stateContext, len(unique)) + if len(unique) == 0 { + return read, nil + } + + raws, err := r.readContexts(unique) + if err != nil { + return nil, err + } + for i, ctx := range unique { + if raws[i] == nil { + return nil, fmt.Errorf("%w: %x", ErrContextNotFound, ctx) + } + c, err := decodeContext(raws[i]) + if err != nil { + return nil, fmt.Errorf("context %x: %w", ctx, err) + } else if c.ID() != ctx { + return nil, fmt.Errorf("%w: %x does not match its hash", ErrInvalidContext, ctx) + } + if r.cacheable() { + b.tree.cache.add(cacheKey{b.roomID, ctx}, c, len(raws[i])) + } + read[ctx] = c + } + return read, nil +} + +func compareHashes(a, b types.StateHash) int { + return bytes.Compare(a[:], b[:]) +} + +func (b *Batch) context(r storeReader, ctx types.StateHash) (stateContext, error) { + resolved, err := b.resolve(r, ctx) + if err != nil { + return stateContext{}, err + } + return resolved[0], nil +} + +// TxnLookupEntries returns the entry of each tuple present in the context, with the membership of +// each member, reading the member and state maps together with one batched read per level. +func (b *Batch) TxnLookupEntries( + txn fdb.ReadTransaction, + ctx types.StateHash, + tups []types.StateTup, +) (types.StateEntries, error) { + return b.lookupEntries(b.store.reader(txn, b.roomID), ctx, tups) +} + +func (b *Batch) lookupEntries(r storeReader, ctx types.StateHash, tups []types.StateTup) (types.StateEntries, error) { + c, err := b.context(r, ctx) + if err != nil { + return nil, err + } + + roots := c.roots() + inMap := make([]int, len(tups)) + probes := make([]probe, len(tups)) + for i, tup := range tups { + m, key := tupleKey(tup) + inMap[i], probes[i] = m, probe{roots[m], key} + } + values, err := b.tree.lookup(r, probes) + if err != nil { + return nil, err + } + + entries := make(types.StateEntries, len(tups)) + for i, value := range values { + if value == nil { + continue + } + if entries[tups[i]], err = decodeValue(inMap[i], value); err != nil { + return nil, err + } + } + return entries, nil +} + +// TxnIterateEntries returns every entry of the context, with the membership of each member. +func (b *Batch) TxnIterateEntries(txn fdb.ReadTransaction, ctx types.StateHash) (types.StateEntries, error) { + states, err := b.TxnIterateAll(txn, ctx) + if err != nil { + return nil, err + } + return states[0], nil +} + +// TxnIterateAll returns every entry of each context, reading all of them together with one batched +// read per level. +func (b *Batch) TxnIterateAll(txn fdb.ReadTransaction, ctxs ...types.StateHash) ([]types.StateEntries, error) { + return b.iterateAll(b.store.reader(txn, b.roomID), ctxs...) +} + +// TxnIterateState returns the context's non-member state, reading none of its member map. +func (b *Batch) TxnIterateState(txn fdb.ReadTransaction, ctx types.StateHash) (types.StateMap, error) { + r := b.store.reader(txn, b.roomID) + c, err := b.context(r, ctx) + if err != nil { + return nil, err + } + stateMap := make(types.StateMap) + err = b.tree.iterate(r, []types.StateHash{c.stateRoot}, func(_ int, e entry) error { + tup, err := keyTuple(stateIndex, e.key) + if err != nil { + return err + } + stateMap[tup] = id.EventID(e.value) + return nil + }) + return stateMap, err +} + +// TxnIterateMembers returns the context's members, reading none of its non-member state. +func (b *Batch) TxnIterateMembers(txn fdb.ReadTransaction, ctx types.StateHash) (map[id.UserID]types.StateEntry, error) { + r := b.store.reader(txn, b.roomID) + c, err := b.context(r, ctx) + if err != nil { + return nil, err + } + members := make(map[id.UserID]types.StateEntry) + err = b.tree.iterate(r, []types.StateHash{c.memberRoot}, func(_ int, e entry) error { + member, err := decodeValue(memberIndex, e.value) + members[id.UserID(e.key)] = member + return err + }) + return members, err +} + +// TxnMembersPage returns a page of the context's members: those of the leaves of its member map from +// the one starting at bucket from, until they hold at least limit members. It also returns the bucket +// the next page starts at, and false once the map is done. A page's bucket is only meaningful for the +// context it came from. +func (b *Batch) TxnMembersPage( + txn fdb.ReadTransaction, + ctx types.StateHash, + from uint64, + limit int, +) (map[id.UserID]types.StateEntry, uint64, bool, error) { + r := b.store.reader(txn, b.roomID) + c, err := b.context(r, ctx) + if err != nil { + return nil, 0, false, err + } + members := make(map[id.UserID]types.StateEntry) + next, more, err := b.tree.iterateFrom(r, c.memberRoot, from, limit, func(e entry) error { + member, err := decodeValue(memberIndex, e.value) + members[id.UserID(e.key)] = member + return err + }) + return members, next, more, err +} + +// TxnCount returns the number of tuples in the context, reading only the root page of each map. +func (b *Batch) TxnCount(txn fdb.ReadTransaction, ctx types.StateHash) (int, error) { + r := b.store.reader(txn, b.roomID) + c, err := b.context(r, ctx) + if err != nil { + return 0, err + } + counts, err := b.countAll(r, []stateContext{c}) + if err != nil { + return 0, err + } + return counts[0], nil +} + +// countAll returns the number of tuples in each context, reading the root pages of all their maps +// together. +func (b *Batch) countAll(r storeReader, contexts []stateContext) ([]int, error) { + roots := make([]types.StateHash, 0, contextMaps*len(contexts)) + for _, c := range contexts { + contextRoots := c.roots() + roots = append(roots, contextRoots[:]...) + } + pages := make(pageSet, len(roots)) + if err := b.tree.fetch(r, pages, roots); err != nil { + return nil, err + } + counts := make([]int, len(contexts)) + for i, c := range contexts { + for _, root := range c.roots() { + if p := pages[root]; p != nil { + counts[i] += p.count + } + } + } + return counts, nil +} + +// iterateAll returns the state of each context, reading all of them together with one batched read +// per level. +func (b *Batch) iterateAll(r storeReader, ctxs ...types.StateHash) ([]types.StateEntries, error) { + resolved, err := b.resolve(r, ctxs...) + if err != nil { + return nil, err + } + + roots := make([]types.StateHash, 0, contextMaps*len(resolved)) + states := make([]types.StateEntries, len(resolved)) + for i, c := range resolved { + contextRoots := c.roots() + roots = append(roots, contextRoots[:]...) + states[i] = make(types.StateEntries) + } + err = b.tree.iterate(r, roots, func(root int, e entry) error { + m := root % contextMaps + tup, err := keyTuple(m, e.key) + if err != nil { + return err + } + states[root/contextMaps][tup], err = decodeValue(m, e.value) + return err + }) + return states, err +} + +// TxnApply returns the context with delta applied, pending in the batch until written, an entry +// with an empty event ID removing the tuple. It fails with ErrInvalidEntry for a member set without +// its event's membership or another entry with one, and with ErrEntryTooLarge for an entry too large +// for a page. +func (b *Batch) TxnApply( + txn fdb.ReadTransaction, + ctx types.StateHash, + delta types.StateEntries, +) (types.StateHash, error) { + changes, err := encodeChanges(delta) + if err != nil { + return types.StateHash{}, err + } + r := b.store.reader(txn, b.roomID) + c, err := b.context(r, ctx) + if err != nil { + return types.StateHash{}, err + } + + var deltas [contextMaps]mapDelta + for m, root := range c.roots() { + deltas[m] = mapDelta{root: root, changes: changes[m]} + } + roots, err := b.tree.apply(r, deltas[:]...) + if err != nil { + return types.StateHash{}, err + } + + next := stateContext{stateRoot: roots[stateIndex], memberRoot: roots[memberIndex]} + if next == c { + return ctx, nil + } + nextID := next.ID() + if nextID != EmptyContext { + b.contexts[nextID] = next + } + return nextID, nil +} + +// Delta returns the changes TxnApply takes from a context holding from to one holding to. +func Delta(from, to types.StateEntries) types.StateEntries { + delta := make(types.StateEntries) + for tup, entry := range to { + if from[tup] != entry { + delta[tup] = entry + } + } + for tup := range from { + if _, found := to[tup]; !found { + delta[tup] = types.StateEntry{} + } + } + return delta +} + +// TxnDiff returns the tuples whose event differs between two contexts, sorted by type and state key, +// with the memberships of member tuples. +func (b *Batch) TxnDiff(txn fdb.ReadTransaction, from, to types.StateHash) ([]types.StateChange, error) { + diff, err := b.diffContexts(b.store.reader(txn, b.roomID), []types.StateHash{from, to}) + if err != nil { + return nil, err + } + changes := make([]types.StateChange, 0, len(diff)) + for tup, entries := range diff { + changes = append(changes, types.StateChange{ + StateTup: tup, + OldEventID: entries[0].EventID, + NewEventID: entries[1].EventID, + OldMembership: entries[0].Membership, + NewMembership: entries[1].Membership, + }) + } + slices.SortFunc(changes, func(a, b types.StateChange) int { + return a.Compare(b.StateTup) + }) + return changes, nil +} + +// TxnStateSince returns, sorted by type and state key, each tuple that one of tos sets to an event +// other than from's and outside skip, with the event of the last of tos to do so. Removed tuples are +// left out, and a context equal to from or to the one before it is not diffed. +func (b *Batch) TxnStateSince( + txn fdb.ReadTransaction, + from types.StateHash, + tos []types.StateHash, + skip map[id.EventID]struct{}, +) ([]types.EventStateTup, error) { + events := make(types.StateMap) + for i, to := range tos { + if to == from || i > 0 && to == tos[i-1] { + continue + } + changes, err := b.TxnDiff(txn, from, to) + if err != nil { + return nil, err + } + for _, change := range changes { + if _, skipped := skip[change.NewEventID]; change.NewEventID != "" && !skipped { + events[change.StateTup] = change.NewEventID + } + } + } + since := make([]types.EventStateTup, 0, len(events)) + for tup, eventID := range events { + since = append(since, types.EventStateTup{StateTup: tup, EventID: eventID}) + } + slices.SortFunc(since, func(a, b types.EventStateTup) int { + return a.Compare(b.StateTup) + }) + return since, nil +} + +// TxnWrite stores those of the given contexts that are pending in this batch and every pending page +// they reach. Each context passed must be pending or stored. The batch keeps its pending records so +// a retried transaction can write them again. +func (b *Batch) TxnWrite(txn fdb.Transaction, ctxs ...types.StateHash) { + w := b.store.writer(txn, b.roomID) + records := b.unwritten(ctxs) + for _, h := range records.pages { + w.writePage(h, b.tree.pending[h].raw) + } + for _, ctx := range records.contexts { + w.writeContext(ctx, b.contexts[ctx].encode()) + } +} + +// unwrittenRecords are what writing contexts stores: those pending in a batch and every pending page +// they reach, each after its children. +type unwrittenRecords struct { + pages, contexts []types.StateHash +} + +func (b *Batch) unwritten(ctxs []types.StateHash) unwrittenRecords { + var records unwrittenRecords + var roots []types.StateHash + seen := make(map[types.StateHash]struct{}, len(ctxs)) + for _, ctx := range ctxs { + if _, done := seen[ctx]; done { + continue + } + seen[ctx] = struct{}{} + if c, pending := b.contexts[ctx]; pending { + records.contexts = append(records.contexts, ctx) + roots = append(roots, c.stateRoot, c.memberRoot) + } + } + records.pages = b.tree.unwritten(roots...) + return records +} + +// committed moves records a transaction committed out of the pending sets, into the cache. +func (b *Batch) committed(records unwrittenRecords) { + b.tree.committed(records.pages) + for _, ctx := range records.contexts { + c := b.contexts[ctx] + b.tree.cache.add(cacheKey{b.roomID, ctx}, c, len(c.encode())) + delete(b.contexts, ctx) + } +} diff --git a/internal/databases/rooms/state/context_test.go b/internal/databases/rooms/state/context_test.go new file mode 100644 index 0000000..e54b98a --- /dev/null +++ b/internal/databases/rooms/state/context_test.go @@ -0,0 +1,825 @@ +package state + +import ( + "fmt" + "maps" + "math/rand/v2" + "slices" + "strings" + "testing" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +const testRoomID = id.RoomID("!room:example.com") + +var ( + createTup = types.StateTup{Type: event.StateCreate} + rulesTup = types.StateTup{Type: event.StateJoinRules} + powerTup = types.StateTup{Type: event.StatePowerLevels} + topicTup = types.StateTup{Type: event.StateTopic} +) + +func userID(i int) id.UserID { + return id.UserID(fmt.Sprintf("@user%d:example.com", i)) +} + +func initialState() types.StateMap { + state := types.StateMap{ + createTup: "$create", + rulesTup: "$rules", + powerTup: "$power", + } + for i := range 12 { + state[types.MemberStateTup(userID(i))] = id.EventID(fmt.Sprintf("$join%d", i)) + } + return state +} + +var testMemberships = []event.Membership{ + event.MembershipJoin, event.MembershipInvite, event.MembershipLeave, event.MembershipBan, event.MembershipKnock, +} + +// testMembership is the membership of a test member event, a function of its ID as a real event's +// is: the one its ID starts with after the $, otherwise one picked by the ID's hash. +func testMembership(eventID id.EventID) event.Membership { + for _, membership := range testMemberships { + if strings.HasPrefix(string(eventID), "$"+string(membership)) { + return membership + } + } + return testMemberships[bucketOf([]byte(eventID))%uint64(len(testMemberships))] +} + +// withMemberships gives each member set in a test state or delta its event's membership +func withMemberships(stateMap types.StateMap) types.StateEntries { + out := make(types.StateEntries, len(stateMap)) + for tup, eventID := range stateMap { + entry := types.StateEntry{EventID: eventID} + if tup.Type == event.StateMember && eventID != "" { + entry.Membership = testMembership(eventID) + } + out[tup] = entry + } + return out +} + +func getContext(b *Batch, ctx types.StateHash) (stateContext, error) { + return b.context(b.store.reader(nil, b.roomID), ctx) +} + +func iterateEventIDs(b *Batch, ctx types.StateHash) (types.StateMap, error) { + entries, err := b.TxnIterateEntries(nil, ctx) + return entries.EventIDs(), err +} + +func lookupEventIDs(b *Batch, ctx types.StateHash, tups []types.StateTup) (types.StateMap, error) { + entries, err := b.TxnLookupEntries(nil, ctx, tups) + return entries.EventIDs(), err +} + +func TestContextRoutesStateAndMembers(t *testing.T) { + store := newMemStore() + b := newBatch(store, newCache(0), testRoomID, smallLimits) + state := initialState() + + ctx, err := b.TxnApply(nil, EmptyContext, withMemberships(state)) + require.NoError(t, err) + c, err := getContext(b, ctx) + require.NoError(t, err) + assert.Equal(t, c.ID(), ctx) + + h := &treeHarness{t: t, store: store.room(testRoomID), cache: b.tree.cache, limits: smallLimits} + b.TxnWrite(fdb.Transaction{}, ctx) + assert.Equal(t, map[string]string{ + string(packStateKey(createTup)): "$create", + string(packStateKey(rulesTup)): "$rules", + string(packStateKey(powerTup)): "$power", + }, h.contents(c.stateRoot)) + assert.Len(t, h.contents(c.memberRoot), 12) + assert.Equal(t, "\x01$join3", h.contents(c.memberRoot)[userID(3).String()], "a member value is its membership code then its event ID") + + all, err := iterateEventIDs(b, ctx) + require.NoError(t, err) + assert.Equal(t, state, all) + allEntries, err := b.TxnIterateEntries(nil, ctx) + require.NoError(t, err) + assert.Equal(t, withMemberships(state), allEntries) + + found, err := lookupEventIDs(b, ctx, []types.StateTup{ + createTup, + topicTup, + types.MemberStateTup(userID(4)), + types.MemberStateTup("@absent:example.com"), + {Type: event.StateMember}, + }) + require.NoError(t, err) + assert.Equal(t, types.StateMap{createTup: "$create", types.MemberStateTup(userID(4)): "$join4"}, found) + + foundEntries, err := b.TxnLookupEntries(nil, ctx, []types.StateTup{createTup, types.MemberStateTup(userID(4)), topicTup}) + require.NoError(t, err) + assert.Equal(t, types.StateEntries{ + createTup: {EventID: "$create"}, + types.MemberStateTup(userID(4)): {EventID: "$join4", Membership: event.MembershipJoin}, + }, foundEntries) +} + +func TestMemberValueEncoding(t *testing.T) { + for code, membership := range map[byte]event.Membership{ + 1: event.MembershipJoin, 2: event.MembershipInvite, 3: event.MembershipLeave, 4: event.MembershipBan, 5: event.MembershipKnock, + } { + entry := types.StateEntry{EventID: "$event", Membership: membership} + value, err := encodeValue(memberIndex, entry) + require.NoError(t, err) + assert.Equal(t, append([]byte{code}, "$event"...), value, "the codes are part of the format") + decoded, err := decodeValue(memberIndex, value) + require.NoError(t, err) + assert.Equal(t, entry, decoded) + } + + for _, value := range [][]byte{{}, {1}, append([]byte{0}, "$event"...), append([]byte{6}, "$event"...), []byte("$event")} { + _, err := decodeValue(memberIndex, value) + assert.ErrorIs(t, err, ErrInvalidPage, "member value %q", value) + } + decoded, err := decodeValue(stateIndex, []byte("$event")) + require.NoError(t, err) + assert.Equal(t, types.StateEntry{EventID: "$event"}, decoded) + + for m, entry := range map[int]types.StateEntry{ + memberIndex: {EventID: "$event"}, + stateIndex: {EventID: "$event", Membership: event.MembershipJoin}, + } { + _, err := encodeValue(m, entry) + assert.ErrorIs(t, err, ErrInvalidEntry, "map %d entry %v", m, entry) + } + for _, entry := range []types.StateEntry{ + {EventID: "$event", Membership: "custom"}, + {Membership: event.MembershipLeave}, + } { + _, err := encodeValue(memberIndex, entry) + assert.ErrorIs(t, err, ErrInvalidEntry, "member entry %v", entry) + } + removal, err := encodeValue(memberIndex, types.StateEntry{}) + require.NoError(t, err) + assert.Nil(t, removal) +} + +func TestContextApplyRejectsEntriesWithoutValidMembership(t *testing.T) { + b := newBatch(newMemStore(), newCache(0), testRoomID, smallLimits) + ctx, err := b.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + pending := len(b.tree.pending) + + member := types.MemberStateTup(userID(50)) + for _, delta := range []types.StateEntries{ + {member: {EventID: "$join50"}}, + {member: {EventID: "$join50", Membership: "custom"}}, + {topicTup: {EventID: "$topic", Membership: event.MembershipJoin}}, + {member: {Membership: event.MembershipLeave}}, + {topicTup: {EventID: "$topic"}, member: {EventID: "$join50"}}, + } { + _, err := b.TxnApply(nil, ctx, delta) + assert.ErrorIs(t, err, ErrInvalidEntry, "delta %v", delta) + } + assert.Len(t, b.tree.pending, pending, "a rejected delta builds nothing") +} + +// Memberships are part of the values, so the roots still depend only on the contents: applying a +// state's entries one at a time in any order gives the context building it at once gives. +func TestContextWithMembershipsIndependentOfBuildOrder(t *testing.T) { + rng := rand.New(rand.NewPCG(3, 7)) + state := initialState() + for i := range 150 { + state[types.MemberStateTup(userID(100+i))] = id.EventID(fmt.Sprintf("$%d", i)) + } + want, err := newBatch(newMemStore(), newCache(0), testRoomID, smallLimits).TxnApply(nil, EmptyContext, withMemberships(state)) + require.NoError(t, err) + + tups := slices.Collect(maps.Keys(state)) + for trial := range 5 { + rng.Shuffle(len(tups), func(i, j int) { tups[i], tups[j] = tups[j], tups[i] }) + b := newBatch(newMemStore(), newCache(0), testRoomID, smallLimits) + ctx := EmptyContext + for _, tup := range tups { + ctx, err = b.TxnApply(nil, ctx, withMemberships(types.StateMap{tup: state[tup]})) + require.NoError(t, err) + } + require.Equal(t, want, ctx, "trial %d", trial) + } +} + +func TestContextApplyAndDiff(t *testing.T) { + b := newBatch(newMemStore(), newCache(0), testRoomID, smallLimits) + before, err := b.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + + after, err := b.TxnApply(nil, before, withMemberships(types.StateMap{ + powerTup: "$power2", + topicTup: "$topic", + types.MemberStateTup(userID(2)): "", + types.MemberStateTup(userID(5)): "$leave5", + types.MemberStateTup(userID(9)): "$join9", + types.MemberStateTup(userID(99)): "", + })) + require.NoError(t, err) + + expected := initialState() + expected[powerTup] = "$power2" + expected[topicTup] = "$topic" + delete(expected, types.MemberStateTup(userID(2))) + expected[types.MemberStateTup(userID(5))] = "$leave5" + state, err := iterateEventIDs(b, after) + require.NoError(t, err) + assert.Equal(t, expected, state) + + unchanged, err := iterateEventIDs(b, before) + require.NoError(t, err) + assert.Equal(t, initialState(), unchanged) + + changes, err := b.TxnDiff(nil, before, after) + require.NoError(t, err) + assert.Equal(t, []types.StateChange{ + {StateTup: types.MemberStateTup(userID(2)), OldEventID: "$join2", OldMembership: event.MembershipJoin}, + { + StateTup: types.MemberStateTup(userID(5)), + OldEventID: "$join5", NewEventID: "$leave5", + OldMembership: event.MembershipJoin, NewMembership: event.MembershipLeave, + }, + {StateTup: powerTup, OldEventID: "$power", NewEventID: "$power2"}, + {StateTup: topicTup, OldEventID: "", NewEventID: "$topic"}, + }, changes) + + reverse, err := b.TxnDiff(nil, after, before) + require.NoError(t, err) + require.Len(t, reverse, len(changes)) + for i, change := range reverse { + assert.Equal(t, changes[i].StateTup, change.StateTup) + assert.Equal(t, changes[i].NewEventID, change.OldEventID) + assert.Equal(t, changes[i].OldEventID, change.NewEventID) + assert.Equal(t, changes[i].NewMembership, change.OldMembership) + assert.Equal(t, changes[i].OldMembership, change.NewMembership) + } + + fromEmpty, err := b.TxnDiff(nil, EmptyContext, before) + require.NoError(t, err) + assert.Len(t, fromEmpty, len(initialState())) + + same, err := b.TxnDiff(nil, after, after) + require.NoError(t, err) + assert.Empty(t, same) +} + +func TestStateSinceIsTheDiffLessTheTimeline(t *testing.T) { + store := newMemStore() + b := newBatch(store, newCache(0), testRoomID, smallLimits) + from, err := b.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + to, err := b.TxnApply(nil, from, withMemberships(types.StateMap{ + topicTup: "$topic", + powerTup: "$power2", + types.MemberStateTup(userID(3)): "$leave3", + types.MemberStateTup(userID(4)): "", + types.MemberStateTup(userID(12)): "$join12", + })) + require.NoError(t, err) + b.TxnWrite(fdb.Transaction{}, from, to) + timeline := map[id.EventID]struct{}{"$message": {}, "$topic": {}, "$join12": {}} + + since, err := newBatch(store, newCache(0), testRoomID, smallLimits).TxnStateSince(nil, from, []types.StateHash{to}, timeline) + require.NoError(t, err) + assert.Equal(t, []types.EventStateTup{ + {StateTup: types.MemberStateTup(userID(3)), EventID: "$leave3"}, + {StateTup: powerTup, EventID: "$power2"}, + }, since, "changes the timeline holds are left out, and so is a removed member") + + store.resetCounts() + same, err := newBatch(store, newCache(0), testRoomID, smallLimits).TxnStateSince(nil, to, []types.StateHash{to}, timeline) + require.NoError(t, err) + assert.Empty(t, same) + assert.Zero(t, store.contextReads+store.pageReads, "equal contexts are not read") + + all, err := newBatch(store, newCache(0), testRoomID, smallLimits).TxnStateSince(nil, EmptyContext, []types.StateHash{from}, timeline) + require.NoError(t, err) + assert.Len(t, all, len(initialState()), "before the room's first state everything is new") +} + +func TestStateSinceSendsAChangeAfterTheTimelineStart(t *testing.T) { + store := newMemStore() + b := newBatch(store, newCache(0), testRoomID, smallLimits) + atFrom, err := b.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + // The timeline starts at a message, then its second event's step resolves power levels to a fork's + // event the timeline does not hold + atStart := atFrom + atEnd, err := b.TxnApply(nil, atStart, withMemberships(types.StateMap{powerTup: "$forkpower"})) + require.NoError(t, err) + b.TxnWrite(fdb.Transaction{}, atFrom, atEnd) + timeline := map[id.EventID]struct{}{"$message1": {}, "$message2": {}} + + toStart, err := newBatch(store, newCache(0), testRoomID, smallLimits).TxnStateSince(nil, atFrom, []types.StateHash{atStart}, timeline) + require.NoError(t, err) + assert.Empty(t, toStart) + + since, err := newBatch(store, newCache(0), testRoomID, smallLimits).TxnStateSince(nil, atFrom, []types.StateHash{atStart, atEnd}, timeline) + require.NoError(t, err) + assert.Equal(t, []types.EventStateTup{{StateTup: powerTup, EventID: "$forkpower"}}, since) +} + +func TestStateSinceTakesTheLastContextsEvent(t *testing.T) { + store := newMemStore() + b := newBatch(store, newCache(0), testRoomID, smallLimits) + atFrom, err := b.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + atStart, err := b.TxnApply(nil, atFrom, withMemberships(types.StateMap{topicTup: "$topic1", rulesTup: "$rules1"})) + require.NoError(t, err) + atEnd, err := b.TxnApply(nil, atStart, withMemberships(types.StateMap{topicTup: "$topic2", rulesTup: "$rules2"})) + require.NoError(t, err) + b.TxnWrite(fdb.Transaction{}, atFrom, atStart, atEnd) + timeline := map[id.EventID]struct{}{"$rules2": {}} + + since, err := newBatch(store, newCache(0), testRoomID, smallLimits).TxnStateSince(nil, atFrom, []types.StateHash{atStart, atEnd}, timeline) + require.NoError(t, err) + assert.Equal(t, []types.EventStateTup{ + {StateTup: rulesTup, EventID: "$rules1"}, + {StateTup: topicTup, EventID: "$topic2"}, + }, since, "the end's event replaces the start's unless the timeline holds it") + + store.resetCounts() + _, err = newBatch(store, newCache(0), testRoomID, smallLimits).TxnStateSince(nil, atFrom, []types.StateHash{atEnd}, timeline) + require.NoError(t, err) + once := store.pageReads + store.contextReads + store.resetCounts() + _, err = newBatch(store, newCache(0), testRoomID, smallLimits).TxnStateSince(nil, atFrom, []types.StateHash{atFrom, atEnd, atEnd}, timeline) + require.NoError(t, err) + assert.Equal(t, once, store.pageReads+store.contextReads, "contexts equal to from or to the one before are not diffed") +} + +func TestIterateStateReadsOnlyNonMemberState(t *testing.T) { + store := newMemStore() + b := newBatch(store, newCache(0), testRoomID, smallLimits) + ctx, err := b.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + b.TxnWrite(fdb.Transaction{}, ctx) + + store.resetCounts() + _, err = iterateEventIDs(newBatch(store, newCache(0), testRoomID, smallLimits), ctx) + require.NoError(t, err) + allPages := store.pageReads + + store.resetCounts() + stateMap, err := newBatch(store, newCache(0), testRoomID, smallLimits).TxnIterateState(nil, ctx) + require.NoError(t, err) + assert.Equal(t, types.StateMap{createTup: "$create", rulesTup: "$rules", powerTup: "$power"}, stateMap) + assert.Less(t, store.pageReads, allPages) + + empty, err := b.TxnIterateState(nil, EmptyContext) + require.NoError(t, err) + assert.Empty(t, empty) +} + +func TestIterateMembersReadsOnlyTheMemberMap(t *testing.T) { + store := newMemStore() + b := newBatch(store, newCache(0), testRoomID, smallLimits) + ctx, err := b.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + b.TxnWrite(fdb.Transaction{}, ctx) + + store.resetCounts() + all, err := newBatch(store, newCache(0), testRoomID, smallLimits).TxnIterateEntries(nil, ctx) + require.NoError(t, err) + allPages := store.pageReads + + store.resetCounts() + members, err := newBatch(store, newCache(0), testRoomID, smallLimits).TxnIterateMembers(nil, ctx) + require.NoError(t, err) + assert.Less(t, store.pageReads, allPages) + require.Len(t, members, 12) + for userID, member := range members { + assert.Equal(t, all[types.MemberStateTup(userID)], member) + } + + empty, err := b.TxnIterateMembers(nil, EmptyContext) + require.NoError(t, err) + assert.Empty(t, empty) +} + +func TestMembersPagesCoverEveryMemberOnce(t *testing.T) { + stateMap := types.StateMap{createTup: "$create"} + for i := range 300 { + stateMap[types.MemberStateTup(userID(i))] = id.EventID(fmt.Sprintf("$join%d", i)) + } + store := newMemStore() + b := newBatch(store, newCache(0), testRoomID, smallLimits) + ctx, err := b.TxnApply(nil, EmptyContext, withMemberships(stateMap)) + require.NoError(t, err) + b.TxnWrite(fdb.Transaction{}, ctx) + all, err := b.TxnIterateMembers(nil, ctx) + require.NoError(t, err) + + for _, limit := range []int{1, 7, 64, 1000} { + seen := make(map[id.UserID]types.StateEntry) + pages := 0 + for from, more := uint64(0), true; more; pages++ { + var page map[id.UserID]types.StateEntry + page, from, more, err = newBatch(store, newCache(0), testRoomID, smallLimits).TxnMembersPage(nil, ctx, from, limit) + require.NoError(t, err) + if more { + assert.GreaterOrEqual(t, len(page), limit, "only the last page holds fewer than the limit") + } + for userID, member := range page { + _, again := seen[userID] + assert.False(t, again, "%s in two pages", userID) + seen[userID] = member + } + } + assert.Equal(t, all, seen, "limit %d", limit) + if limit < len(all) { + assert.Greater(t, pages, 1, "limit %d", limit) + } + } + + page, _, more, err := b.TxnMembersPage(nil, EmptyContext, 0, 10) + require.NoError(t, err) + assert.Empty(t, page) + assert.False(t, more) +} + +func TestContextApplyWithoutChangesKeepsContext(t *testing.T) { + b := newBatch(newMemStore(), newCache(0), testRoomID, smallLimits) + ctx, err := b.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + pending := len(b.tree.pending) + + same, err := b.TxnApply(nil, ctx, withMemberships(types.StateMap{createTup: "$create", topicTup: ""})) + require.NoError(t, err) + assert.Equal(t, ctx, same) + assert.Len(t, b.tree.pending, pending) + + empty, err := b.TxnApply(nil, EmptyContext, withMemberships(types.StateMap{createTup: ""})) + require.NoError(t, err) + assert.Equal(t, EmptyContext, empty) +} + +func TestContextWriteAndReload(t *testing.T) { + store := newMemStore() + cache := newCache(defaultCacheBytes) + b := newBatch(store, cache, testRoomID, smallLimits) + + first, err := b.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + intermediate, err := b.TxnApply(nil, first, withMemberships(types.StateMap{topicTup: "$topic1"})) + require.NoError(t, err) + last, err := b.TxnApply(nil, intermediate, withMemberships(types.StateMap{topicTup: "$topic2", types.MemberStateTup(userID(50)): "$join50"})) + require.NoError(t, err) + + found, err := lookupEventIDs(b, last, []types.StateTup{topicTup, types.MemberStateTup(userID(50))}) + require.NoError(t, err) + assert.Equal(t, types.StateMap{topicTup: "$topic2", types.MemberStateTup(userID(50)): "$join50"}, found) + assert.Empty(t, store.pages) + assert.Zero(t, store.pageReads) + + b.TxnWrite(fdb.Transaction{}, first, last, EmptyContext) + assert.Len(t, store.contexts, 2) + written := maps.Clone(store.pages) + b.TxnWrite(fdb.Transaction{}, first, last) + assert.Equal(t, written, store.pages, "a retried write stores the same records") + + fresh := newBatch(store, cache, testRoomID, smallLimits) + expected := initialState() + expected[topicTup] = "$topic2" + expected[types.MemberStateTup(userID(50))] = "$join50" + state, err := iterateEventIDs(fresh, last) + require.NoError(t, err) + assert.Equal(t, expected, state) + state, err = iterateEventIDs(fresh, first) + require.NoError(t, err) + assert.Equal(t, initialState(), state) + + _, err = getContext(fresh, intermediate) + assert.ErrorIs(t, err, ErrContextNotFound) + + otherRoom := newBatch(store, cache, "!other:example.com", smallLimits) + _, err = iterateEventIDs(otherRoom, last) + assert.ErrorIs(t, err, ErrContextNotFound) + store.contexts[store.room(otherRoom.roomID).key(last)] = store.contexts[store.room(testRoomID).key(last)] + _, err = iterateEventIDs(otherRoom, last) + assert.ErrorIs(t, err, ErrPageNotFound, "pages cached for one room are not used by another") +} + +func TestContextIterateAllSharesRoundTrips(t *testing.T) { + store := newMemStore() + cache := newCache(0) + b := newBatch(store, cache, testRoomID, smallLimits) + first, err := b.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + second, err := b.TxnApply(nil, first, withMemberships(types.StateMap{topicTup: "$topic", types.MemberStateTup(userID(3)): ""})) + require.NoError(t, err) + b.TxnWrite(fdb.Transaction{}, first, second) + + fresh := func() *Batch { return newBatch(store, cache, testRoomID, smallLimits) } + store.resetCounts() + firstState, err := iterateEventIDs(fresh(), first) + require.NoError(t, err) + singleReadCalls := store.pageReadCalls + secondState, err := iterateEventIDs(fresh(), second) + require.NoError(t, err) + + store.resetCounts() + states, err := fresh().iterateAll(store.room(testRoomID), first, EmptyContext, second) + require.NoError(t, err) + assert.Equal(t, []types.StateEntries{withMemberships(firstState), {}, withMemberships(secondState)}, states) + assert.Equal(t, singleReadCalls, store.pageReadCalls, "contexts are read in the same round trips") + assert.Equal(t, 1, store.contextReadCalls) +} + +func TestContextWriteStoresEachRecordOnce(t *testing.T) { + store := newMemStore() + b := newBatch(store, newCache(defaultCacheBytes), testRoomID, smallLimits) + first, err := b.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + second, err := b.TxnApply(nil, first, withMemberships(types.StateMap{topicTup: "$topic"})) + require.NoError(t, err) + + b.TxnWrite(fdb.Transaction{}, first, second, first, second) + + assert.Len(t, store.contexts, 2) + assert.Equal(t, len(store.pages), store.pageWrites) + assert.Equal(t, len(store.contexts), store.contextWrites) + + state, err := iterateEventIDs(newBatch(store, newCache(defaultCacheBytes), testRoomID, smallLimits), second) + require.NoError(t, err) + expected := initialState() + expected[topicTup] = "$topic" + assert.Equal(t, expected, state) +} + +func TestContextCacheAvoidsReads(t *testing.T) { + store := newMemStore() + cache := newCache(defaultCacheBytes) + b := newBatch(store, cache, testRoomID, smallLimits) + ctx, err := b.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + b.TxnWrite(fdb.Transaction{}, ctx) + + state, err := iterateEventIDs(newBatch(store, cache, testRoomID, smallLimits), ctx) + require.NoError(t, err) + assert.Equal(t, initialState(), state) + assert.NotZero(t, store.pageReads, "writes are not cached before commit") + + store.resetCounts() + store.pages, store.contexts = nil, nil + state, err = iterateEventIDs(newBatch(store, cache, testRoomID, smallLimits), ctx) + require.NoError(t, err) + assert.Equal(t, initialState(), state) + assert.Zero(t, store.pageReads) + assert.Zero(t, store.contextReadCalls) +} + +func TestContextUncommittedWritesAreNotCached(t *testing.T) { + store := newMemStore() + cache := newCache(defaultCacheBytes) + failed := newBatch(uncommitted{store}, cache, testRoomID, smallLimits) + ctx, err := failed.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + failed.TxnWrite(fdb.Transaction{}, ctx) + + next := newBatch(store, cache, testRoomID, smallLimits) + _, err = next.TxnApply(nil, ctx, withMemberships(types.StateMap{topicTup: "$topic"})) + assert.ErrorIs(t, err, ErrContextNotFound) +} + +// A transaction reads back what it wrote before it commits, through any batch, so nothing it reads +// once it wrote is cached, and an abort leaves the cache holding only committed records. +func TestReadsOfAnOpenTransactionsOwnWritesAreNotCached(t *testing.T) { + store := newMemStore() + cache := newCache(defaultCacheBytes) + b := newBatch(store, cache, testRoomID, smallLimits) + committed, err := b.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + b.TxnWrite(fdb.Transaction{}, committed) + + txn := newOpenTxn(store) + _, err = iterateEventIDs(newBatch(txn, cache, testRoomID, smallLimits), committed) + require.NoError(t, err) + _, cached := cache.getContext(testRoomID, committed) + assert.True(t, cached, "what a transaction reads before it writes is committed") + + writer := newBatch(txn, cache, testRoomID, smallLimits) + written, err := writer.TxnApply(nil, committed, withMemberships(types.StateMap{topicTup: "$topic"})) + require.NoError(t, err) + pages := writer.unwritten([]types.StateHash{written}).pages + require.NotEmpty(t, pages) + writer.TxnWrite(fdb.Transaction{}, written) + state, err := iterateEventIDs(newBatch(txn, cache, testRoomID, smallLimits), written) + require.NoError(t, err) + assert.Equal(t, id.EventID("$topic"), state[topicTup], "another batch of the transaction reads its writes") + _, cached = cache.getContext(testRoomID, written) + assert.False(t, cached) + for _, h := range pages { + assert.Nil(t, cache.getPage(testRoomID, h)) + } + + _, err = iterateEventIDs(newBatch(store, cache, testRoomID, smallLimits), written) + assert.ErrorIs(t, err, ErrContextNotFound, "the transaction never committed") +} + +func TestWrittenTransactionsAreForgottenAtTheSecondRotation(t *testing.T) { + var txn fdb.Transaction + assert.Equal(t, txnID(txn), txnID(txn.Snapshot()), "every view of a transaction is the same one") + + var w writtenTxns + w.add(1) + assert.True(t, w.has(1)) + w.rotated = w.rotated.Add(-writtenTxnsRotation) + w.add(2) + assert.True(t, w.has(1), "a rotation keeps the generation before") + w.rotated = w.rotated.Add(-writtenTxnsRotation) + w.add(3) + assert.False(t, w.has(1)) + assert.True(t, w.has(2)) +} + +func TestContextReadsShareRoundTrips(t *testing.T) { + store := newMemStore() + cache := newCache(0) + b := newBatch(store, cache, testRoomID, smallLimits) + members := types.StateMap{createTup: "$create", powerTup: "$power"} + for i := range 200 { + members[types.MemberStateTup(userID(i))] = id.EventID(fmt.Sprintf("$join%d", i)) + } + before, err := b.TxnApply(nil, EmptyContext, withMemberships(members)) + require.NoError(t, err) + after, err := b.TxnApply(nil, before, withMemberships(types.StateMap{topicTup: "$topic"})) + require.NoError(t, err) + left, err := b.TxnApply(nil, before, withMemberships(types.StateMap{types.MemberStateTup(userID(3)): "$leave3"})) + require.NoError(t, err) + both, err := b.TxnApply(nil, left, withMemberships(types.StateMap{topicTup: "$topic"})) + require.NoError(t, err) + b.TxnWrite(fdb.Transaction{}, before, after, left, both) + + depthOf := func(tup types.StateTup) int { + store.resetCounts() + _, err := lookupEventIDs(newBatch(store, cache, testRoomID, smallLimits), before, []types.StateTup{tup}) + require.NoError(t, err) + return store.pageReadCalls + } + memberDepth, stateDepth := depthOf(types.MemberStateTup(userID(3))), depthOf(createTup) + require.Greater(t, memberDepth, stateDepth) + + store.resetCounts() + _, err = newBatch(store, cache, testRoomID, smallLimits).TxnApply(nil, before, withMemberships(types.StateMap{ + topicTup: "$topic", + types.MemberStateTup(userID(3)): "$leave3", + })) + require.NoError(t, err) + assert.Equal(t, memberDepth, store.pageReadCalls, "both maps are read in the same round trips") + + conflictReadCalls := func(to types.StateHash) int { + store.resetCounts() + _, err := newBatch(store, cache, testRoomID, smallLimits).diffContexts(store.room(testRoomID), []types.StateHash{before, to}) + require.NoError(t, err) + return store.pageReadCalls + } + assert.Equal(t, conflictReadCalls(left), conflictReadCalls(both), "both maps are diffed in the same round trips") + + store.resetCounts() + _, err = newBatch(store, cache, testRoomID, smallLimits).TxnDiff(nil, before, after) + require.NoError(t, err) + assert.Equal(t, 1, store.contextReadCalls) + + store.resetCounts() + _, err = newBatch(store, cache, testRoomID, smallLimits).TxnDiff(nil, before, before) + require.NoError(t, err) + assert.Equal(t, 1, store.contextReads, "a repeated context is read once") +} + +func TestContextUnknownAndEmpty(t *testing.T) { + b := newBatch(newMemStore(), newCache(0), testRoomID, smallLimits) + + _, err := lookupEventIDs(b, types.StateHash{}, []types.StateTup{createTup}) + assert.ErrorIs(t, err, ErrUnknownContext) + _, err = b.TxnApply(nil, types.StateHash{}, withMemberships(types.StateMap{createTup: "$create"})) + assert.ErrorIs(t, err, ErrUnknownContext) + + state, err := iterateEventIDs(b, EmptyContext) + require.NoError(t, err) + assert.Empty(t, state) + found, err := lookupEventIDs(b, EmptyContext, []types.StateTup{createTup, types.MemberStateTup(userID(1))}) + require.NoError(t, err) + assert.Empty(t, found) + + ctx, err := b.TxnApply(nil, EmptyContext, withMemberships(types.StateMap{createTup: "$create"})) + require.NoError(t, err) + emptied, err := b.TxnApply(nil, ctx, withMemberships(types.StateMap{createTup: ""})) + require.NoError(t, err) + assert.Equal(t, EmptyContext, emptied) + assert.NotContains(t, b.contexts, EmptyContext) + + _, err = b.TxnDiff(nil, types.StateHash{}, types.StateHash{}) + assert.ErrorIs(t, err, ErrUnknownContext) + missing := stateContext{stateRoot: hashOf([]byte("missing"))}.ID() + _, err = b.TxnDiff(nil, missing, missing) + assert.ErrorIs(t, err, ErrContextNotFound) +} + +const ( + deltaTestTuples = 40 + deltaTestVersions = 3 +) + +func deltaTestTup(i int) types.StateTup { + if i < 4 { + return []types.StateTup{createTup, rulesTup, powerTup, topicTup}[i] + } + return types.MemberStateTup(userID(i)) +} + +// Each event belongs to one tuple, as in a room. +func deltaTestEvent(i, version int) id.EventID { + return id.EventID(fmt.Sprintf("$%d.%d", i, version)) +} + +func randomState(rng *rand.Rand) types.StateMap { + stateMap := make(types.StateMap) + for i := range deltaTestTuples { + if rng.IntN(3) > 0 { + stateMap[deltaTestTup(i)] = deltaTestEvent(i, rng.IntN(deltaTestVersions)) + } + } + return stateMap +} + +// randomStatePair returns, in turn, a state from empty, a state to empty, two unrelated states, and +// a state with a few tuples changed or removed. +func randomStatePair(rng *rand.Rand, trial int) (from, to types.StateMap) { + switch trial % 4 { + case 0: + return types.StateMap{}, randomState(rng) + case 1: + return randomState(rng), types.StateMap{} + case 2: + return randomState(rng), randomState(rng) + } + from = randomState(rng) + to = maps.Clone(from) + for range 1 + rng.IntN(6) { + i := rng.IntN(deltaTestTuples) + if rng.IntN(3) == 0 { + delete(to, deltaTestTup(i)) + } else { + to[deltaTestTup(i)] = deltaTestEvent(i, rng.IntN(deltaTestVersions)) + } + } + return from, to +} + +func TestDeltaTurnsFromIntoTo(t *testing.T) { + rng := rand.New(rand.NewPCG(5, 8)) + for trial := range 400 { + from, to := randomStatePair(rng, trial) + delta := Delta(withMemberships(from), withMemberships(to)) + for tup, entry := range delta { + require.NotEqual(t, from[tup], entry.EventID, "trial %d: %v is unchanged", trial, tup) + } + applied := withMemberships(from) + applyToModel(applied, delta) + require.Equal(t, withMemberships(to), applied, "trial %d", trial) + } +} + +func TestContextApplyDeltaMatchesBuildingFromEmpty(t *testing.T) { + rng := rand.New(rand.NewPCG(13, 21)) + for trial := range 200 { + from, to := randomStatePair(rng, trial) + store := newMemStore() + b := newBatch(store, newCache(0), testRoomID, smallLimits) + base, err := b.TxnApply(nil, EmptyContext, withMemberships(from)) + require.NoError(t, err) + b.TxnWrite(fdb.Transaction{}, base) + + b = newBatch(store, newCache(0), testRoomID, smallLimits) + count, err := b.TxnCount(nil, base) + require.NoError(t, err) + require.Equal(t, len(from), count) + got, err := b.TxnApply(nil, base, Delta(withMemberships(from), withMemberships(to))) + require.NoError(t, err) + count, err = b.TxnCount(nil, got) + require.NoError(t, err) + require.Equal(t, len(to), count) + + fresh := newBatch(newMemStore(), newCache(0), testRoomID, smallLimits) + want, err := fresh.TxnApply(nil, EmptyContext, withMemberships(to)) + require.NoError(t, err) + require.Equal(t, want, got, "trial %d", trial) + + state, err := b.TxnIterateEntries(nil, got) + require.NoError(t, err) + require.Equal(t, withMemberships(to), state, "trial %d", trial) + } +} diff --git a/internal/databases/rooms/state/memstore_test.go b/internal/databases/rooms/state/memstore_test.go new file mode 100644 index 0000000..767cb88 --- /dev/null +++ b/internal/databases/rooms/state/memstore_test.go @@ -0,0 +1,227 @@ +package state + +import ( + "bytes" + "context" + "errors" + "fmt" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +type memStore struct { + pages map[string][]byte + contexts map[string][]byte + resolutions map[string][]byte + + pageReads, pageReadCalls, contextReads, contextReadCalls int + pageWrites, contextWrites int + + // What each committed staging transaction wrote, in order + stages [][]stagedRecord + // The staging transaction that fails, counting from 1, or 0 for none + failingStage, stageCalls int +} + +type recordKind int + +const ( + pageRecord recordKind = iota + contextRecord +) + +type stagedRecord struct { + hash types.StateHash + kind recordKind + raw []byte +} + +func newMemStore() *memStore { + return &memStore{ + pages: make(map[string][]byte), + contexts: make(map[string][]byte), + resolutions: make(map[string][]byte), + } +} + +func (m *memStore) room(roomID id.RoomID) memRoom { + return memRoom{m, roomID} +} + +func (m *memStore) reader(_ fdb.ReadTransaction, roomID id.RoomID) storeReader { + return m.room(roomID) +} + +func (m *memStore) writer(_ fdb.Transaction, roomID id.RoomID) storeWriter { + return m.room(roomID) +} + +func (m *memStore) stage(_ context.Context, roomID id.RoomID, fn func(storeWriter) error) error { + m.stageCalls++ + if m.stageCalls == m.failingStage { + return errStageFailed + } + w := &stagingWriter{memRoom: m.room(roomID)} + if err := fn(w); err != nil { + return err + } + m.stages = append(m.stages, w.records) + return nil +} + +func (m *memStore) keySize(roomID id.RoomID) int { + return len(m.room(roomID).key(types.StateHash{})) +} + +func (m *memStore) resetCounts() { + m.pageReads, m.pageReadCalls, m.contextReads, m.contextReadCalls = 0, 0, 0, 0 +} + +type memRoom struct { + *memStore + roomID id.RoomID +} + +func (m memRoom) key(h types.StateHash) string { + return m.roomID.String() + "/" + string(h[:]) +} + +func (m memRoom) cacheable() bool { + return true +} + +func (m memRoom) readPages(hashes []types.StateHash) ([][]byte, error) { + m.pageReads += len(hashes) + m.pageReadCalls++ + return m.getAll(m.pages, hashes), nil +} + +func (m memRoom) readContexts(ctxs []types.StateHash) ([][]byte, error) { + m.contextReads += len(ctxs) + m.contextReadCalls++ + return m.getAll(m.contexts, ctxs), nil +} + +func (m memRoom) getAll(records map[string][]byte, hashes []types.StateHash) [][]byte { + raws := make([][]byte, len(hashes)) + for i, h := range hashes { + raws[i] = records[m.key(h)] + } + return raws +} + +func (m memRoom) readResolution(key types.StateHash) func() ([]byte, error) { + raw := m.resolutions[m.key(key)] + return func() ([]byte, error) { return raw, nil } +} + +func (m memRoom) writeResolution(key types.StateHash, raw []byte) { + put(m.resolutions, m.key(key), raw) +} + +func (m memRoom) writePage(h types.StateHash, raw []byte) { + m.pageWrites++ + put(m.pages, m.key(h), raw) +} + +func (m memRoom) writeContext(ctx types.StateHash, raw []byte) { + m.contextWrites++ + put(m.contexts, m.key(ctx), raw) +} + +var errStageFailed = errors.New("staging transaction failed") + +type stagingWriter struct { + memRoom + records []stagedRecord +} + +func (w *stagingWriter) writePage(h types.StateHash, raw []byte) { + w.records = append(w.records, stagedRecord{hash: h, kind: pageRecord, raw: raw}) + w.memRoom.writePage(h, raw) +} + +func (w *stagingWriter) writeContext(ctx types.StateHash, raw []byte) { + w.records = append(w.records, stagedRecord{hash: ctx, kind: contextRecord, raw: raw}) + w.memRoom.writeContext(ctx, raw) +} + +func put(records map[string][]byte, key string, raw []byte) { + if existing, ok := records[key]; ok && !bytes.Equal(existing, raw) { + panic(fmt.Sprintf("immutable record %x rewritten with different bytes", key)) + } + records[key] = bytes.Clone(raw) +} + +// uncommitted reads from the store but drops every write, like a transaction that never commits. +type uncommitted struct { + *memStore +} + +func (uncommitted) writer(fdb.Transaction, id.RoomID) storeWriter { + return droppedWrites{} +} + +type droppedWrites struct{} + +func (droppedWrites) writePage(types.StateHash, []byte) {} + +func (droppedWrites) writeContext(types.StateHash, []byte) {} + +func (droppedWrites) writeResolution(types.StateHash, []byte) {} + +// openTxn is a transaction that has not committed over the store: it reads back its own writes, as +// a FoundationDB transaction does, and is not cacheable once it wrote. +type openTxn struct { + *memStore + writes *memStore + wrote bool +} + +func newOpenTxn(store *memStore) *openTxn { + return &openTxn{memStore: store, writes: newMemStore()} +} + +func (t *openTxn) reader(_ fdb.ReadTransaction, roomID id.RoomID) storeReader { + return openTxnReader{memRoom: t.room(roomID), writes: t.writes.room(roomID), committed: !t.wrote} +} + +func (t *openTxn) writer(_ fdb.Transaction, roomID id.RoomID) storeWriter { + t.wrote = true + return t.writes.room(roomID) +} + +type openTxnReader struct { + memRoom + writes memRoom + committed bool +} + +func (r openTxnReader) cacheable() bool { + return r.committed +} + +func (r openTxnReader) readPages(hashes []types.StateHash) ([][]byte, error) { + return r.overlay(r.memRoom.readPages, r.writes.readPages, hashes) +} + +func (r openTxnReader) readContexts(ctxs []types.StateHash) ([][]byte, error) { + return r.overlay(r.memRoom.readContexts, r.writes.readContexts, ctxs) +} + +func (r openTxnReader) overlay(stored, written func([]types.StateHash) ([][]byte, error), hashes []types.StateHash) ([][]byte, error) { + raws, err := stored(hashes) + if err != nil { + return nil, err + } + own, err := written(hashes) + for i, raw := range own { + if raw != nil { + raws[i] = raw + } + } + return raws, err +} diff --git a/internal/databases/rooms/state/page.go b/internal/databases/rooms/state/page.go new file mode 100644 index 0000000..5b4d13f --- /dev/null +++ b/internal/databases/rooms/state/page.go @@ -0,0 +1,157 @@ +package state + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "slices" + + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + + "github.com/beeper/babbleserv/internal/types" +) + +// A subtree is a single leaf page when it has at most leafCapacity entries and encodes to at most +// leafMaxBytes, otherwise it is a branch. The byte bound keeps pages under the FoundationDB value +// limit when keys approach the 255 byte type and state key limits. +const ( + leafCapacity = 64 + leafMaxBytes = 64 << 10 +) + +const ( + fanout = 16 + // One nibble of the 8 byte bucket hash per level, pages this deep are always leaves. + maxDepth = 16 + + leafTag = 0 + branchTag = 1 +) + +var ErrInvalidPage = errors.New("invalid state page") + +type entry struct { + key, value []byte +} + +// encodedSize is the entry's share of a leaf encoding: both byte strings with their type code, +// terminator and escaped zero bytes. +func (e entry) encodedSize() int { + return 4 + len(e.key) + len(e.value) + bytes.Count(e.key, []byte{0}) + bytes.Count(e.value, []byte{0}) +} + +// page is a decoded leaf or branch. count and bytes total the entries of the whole subtree, bytes +// being their encoded size, so a leaf encodes to bytes plus its one byte tag. +type page struct { + leaf bool + entries []entry + children [fanout]types.StateHash + count int + bytes int +} + +func (p *page) find(key []byte) []byte { + i, found := slices.BinarySearchFunc(p.entries, key, func(e entry, key []byte) int { + return bytes.Compare(e.key, key) + }) + if !found { + return nil + } + return p.entries[i].value +} + +func hashOf(raw []byte) types.StateHash { + sum := sha256.Sum256(raw) + return types.StateHash(sum[:16]) +} + +func bucketOf(key []byte) uint64 { + sum := sha256.Sum256(key) + return binary.BigEndian.Uint64(sum[:8]) +} + +func slot(bucket uint64, depth int) int { + return int(bucket>>(60-4*depth)) & (fanout - 1) +} + +func hashToBytes(h types.StateHash) []byte { + if h.IsZero() { + return []byte{} + } + return h[:] +} + +func hashFromElement(el tuple.TupleElement) (types.StateHash, error) { + b, ok := el.([]byte) + switch { + case !ok: + return types.StateHash{}, fmt.Errorf("state hash is %T, not bytes", el) + case len(b) == 0: + return types.StateHash{}, nil + case len(b) != len(types.StateHash{}): + return types.StateHash{}, fmt.Errorf("state hash has %d bytes", len(b)) + } + return types.StateHash(b), nil +} + +func encodeLeaf(entries []entry) []byte { + tup := make(tuple.Tuple, 0, 1+2*len(entries)) + tup = append(tup, leafTag) + for _, e := range entries { + tup = append(tup, e.key, e.value) + } + return tup.Pack() +} + +func encodeBranch(children *[fanout]types.StateHash, count, size int) []byte { + tup := make(tuple.Tuple, 0, 3+fanout) + tup = append(tup, branchTag, count, size) + for _, child := range children { + tup = append(tup, hashToBytes(child)) + } + return tup.Pack() +} + +func decodePage(raw []byte) (*page, error) { + tup, err := tuple.Unpack(raw) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidPage, err) + } else if len(tup) == 0 { + return nil, fmt.Errorf("%w: empty", ErrInvalidPage) + } + tag, ok := tup[0].(int64) + if !ok { + return nil, fmt.Errorf("%w: tag is %T", ErrInvalidPage, tup[0]) + } + + switch { + case tag == leafTag && len(tup) >= 3 && len(tup)%2 == 1: + p := &page{leaf: true, entries: make([]entry, 0, len(tup)/2), count: len(tup) / 2, bytes: len(raw) - 1} + for i := 1; i < len(tup); i += 2 { + key, keyOK := tup[i].([]byte) + value, valueOK := tup[i+1].([]byte) + if !keyOK || !valueOK { + return nil, fmt.Errorf("%w: leaf entry %d is not bytes", ErrInvalidPage, i/2) + } + p.entries = append(p.entries, entry{key, value}) + } + return p, nil + case tag == branchTag && len(tup) == 3+fanout: + count, countOK := tup[1].(int64) + size, sizeOK := tup[2].(int64) + if !countOK || !sizeOK || count <= 0 || size <= 0 { + return nil, fmt.Errorf("%w: branch totals %v and %v", ErrInvalidPage, tup[1], tup[2]) + } + p := &page{count: int(count), bytes: int(size)} + for i := range fanout { + if p.children[i], err = hashFromElement(tup[3+i]); err != nil { + return nil, fmt.Errorf("%w: branch slot %d: %w", ErrInvalidPage, i, err) + } + } + return p, nil + default: + return nil, fmt.Errorf("%w: tag %d with %d elements", ErrInvalidPage, tag, len(tup)) + } +} diff --git a/internal/databases/rooms/state/page_test.go b/internal/databases/rooms/state/page_test.go new file mode 100644 index 0000000..44b339d --- /dev/null +++ b/internal/databases/rooms/state/page_test.go @@ -0,0 +1,134 @@ +package state + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "testing" + + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +func TestLeafEncodingRoundTrip(t *testing.T) { + entries := []entry{ + {[]byte("a"), []byte("$one")}, + {[]byte("b\x00c"), []byte("$two")}, + } + raw := encodeLeaf(entries) + assert.Equal(t, tuple.Tuple{0, []byte("a"), []byte("$one"), []byte("b\x00c"), []byte("$two")}.Pack(), raw) + assert.Equal(t, len(raw), 1+entries[0].encodedSize()+entries[1].encodedSize()) + + p, err := decodePage(raw) + require.NoError(t, err) + assert.True(t, p.leaf) + assert.Equal(t, entries, p.entries) + assert.Equal(t, 2, p.count) + assert.Equal(t, len(raw)-1, p.bytes) + assert.Equal(t, []byte("$two"), p.find([]byte("b\x00c"))) + assert.Nil(t, p.find([]byte("b"))) +} + +func TestBranchEncodingRoundTrip(t *testing.T) { + var children [fanout]types.StateHash + children[0] = hashOf([]byte("zero")) + children[5] = hashOf([]byte("five")) + raw := encodeBranch(&children, 300, 24000) + + expected := tuple.Tuple{1, 300, 24000} + for i := range fanout { + switch i { + case 0, 5: + expected = append(expected, children[i][:]) + default: + expected = append(expected, []byte{}) + } + } + assert.Equal(t, expected.Pack(), raw) + + p, err := decodePage(raw) + require.NoError(t, err) + assert.False(t, p.leaf) + assert.Equal(t, children, p.children) + assert.Equal(t, 300, p.count) + assert.Equal(t, 24000, p.bytes) +} + +func TestHashesAreDeterministic(t *testing.T) { + raw := encodeLeaf([]entry{{[]byte("key"), []byte("value")}}) + sum := sha256.Sum256(raw) + assert.Equal(t, types.StateHash(sum[:16]), hashOf(raw)) + assert.Equal(t, hashOf(raw), hashOf(encodeLeaf([]entry{{[]byte("key"), []byte("value")}}))) + assert.NotEqual(t, hashOf(raw), hashOf(encodeLeaf([]entry{{[]byte("key"), []byte("other")}}))) + + bucketSum := sha256.Sum256([]byte("key")) + assert.Equal(t, int(bucketSum[0]>>4), slot(bucketOf([]byte("key")), 0)) + assert.Equal(t, int(bucketSum[0]&0xf), slot(bucketOf([]byte("key")), 1)) + assert.Equal(t, int(bucketSum[7]&0xf), slot(bucketOf([]byte("key")), maxDepth-1)) +} + +func TestContextEncoding(t *testing.T) { + c := stateContext{stateRoot: hashOf([]byte("state"))} + raw := c.encode() + assert.Equal(t, tuple.Tuple{c.stateRoot[:], []byte{}}.Pack(), raw) + + decoded, err := decodeContext(raw) + require.NoError(t, err) + assert.Equal(t, c, decoded) + assert.Equal(t, hashOf(raw), c.ID()) + + assert.False(t, EmptyContext.IsZero()) + assert.Equal(t, hashOf(tuple.Tuple{[]byte{}, []byte{}}.Pack()), EmptyContext) + + _, err = decodeContext(tuple.Tuple{c.stateRoot[:], []byte{}, []byte{}}.Pack()) + assert.ErrorIs(t, err, ErrInvalidContext, "a context row holds two roots") +} + +// The leaf limits, hash functions, bucket order, value and page encodings are the format: every +// stored root and context ID depends on them, so these must never change. +func TestGoldenRoots(t *testing.T) { + stateMap := types.StateMap{createTup: "$create", powerTup: "$power", rulesTup: "$rules", topicTup: "$topic"} + for i := range 300 { + stateMap[types.MemberStateTup(userID(i))] = id.EventID(fmt.Sprintf("$%s%d", testMemberships[i%len(testMemberships)], i)) + } + b := newBatch(newMemStore(), newCache(0), testRoomID, defaultLeafLimits) + ctx, err := b.TxnApply(nil, EmptyContext, withMemberships(stateMap)) + require.NoError(t, err) + c := b.contexts[ctx] + assert.Equal(t, "2ede2f124a889bb3779e090af0c7a0de", hex.EncodeToString(c.stateRoot[:])) + assert.Equal(t, "75bc96d192f2aa4d3aacba70da057f7d", hex.EncodeToString(c.memberRoot[:])) + assert.Equal(t, "9830cba165ecd52e7378e2d0aa6f4537", hex.EncodeToString(ctx[:])) + assert.False(t, b.tree.pending[c.memberRoot].page.leaf, "300 members make a branch") +} + +func TestDecodeRejectsMalformedPages(t *testing.T) { + emptySlots := make(tuple.Tuple, fanout) + for i := range emptySlots { + emptySlots[i] = []byte{} + } + for name, raw := range map[string][]byte{ + "not a tuple": {0xff}, + "empty": tuple.Tuple{}.Pack(), + "unknown tag": tuple.Tuple{2}.Pack(), + "string tag": tuple.Tuple{"x", []byte("key"), []byte("value")}.Pack(), + "bytes tag": tuple.Tuple{[]byte{}, []byte("key"), []byte("value")}.Pack(), + "nil tag": tuple.Tuple{nil, []byte("key"), []byte("value")}.Pack(), + "empty leaf": tuple.Tuple{0}.Pack(), + "odd leaf": tuple.Tuple{0, []byte("key")}.Pack(), + "string leaf key": tuple.Tuple{0, "key", []byte("value")}.Pack(), + "short branch": tuple.Tuple{1, 300, 24000, []byte{}}.Pack(), + "branch without totals": append(tuple.Tuple{1}, emptySlots...).Pack(), + "zero branch count": append(tuple.Tuple{1, 0, 24000}, emptySlots...).Pack(), + "bad child length": append(tuple.Tuple{1, 300, 24000, []byte("short")}, emptySlots[1:]...).Pack(), + } { + _, err := decodePage(raw) + assert.ErrorIs(t, err, ErrInvalidPage, name) + } + + _, err := decodeContext(tuple.Tuple{[]byte("short"), []byte{}}.Pack()) + assert.ErrorIs(t, err, ErrInvalidContext) +} diff --git a/internal/databases/rooms/state/resolution.go b/internal/databases/rooms/state/resolution.go new file mode 100644 index 0000000..7038bc9 --- /dev/null +++ b/internal/databases/rooms/state/resolution.go @@ -0,0 +1,57 @@ +package state + +import ( + "context" + "fmt" + "slices" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + + "github.com/beeper/babbleserv/internal/types" +) + +// resolutionKey identifies the resolution of contexts whatever their order and repeats, as the +// result depends on neither. +func resolutionKey(ctxs []types.StateHash) types.StateHash { + sorted := slices.Compact(slices.SortedFunc(slices.Values(ctxs), compareHashes)) + raw := make([]byte, 0, len(sorted)*len(types.StateHash{})) + for _, ctx := range sorted { + raw = append(raw, ctx[:]...) + } + return hashOf(raw) +} + +// TxnResolved starts reading the result an artifact job stored for resolving the contexts, and +// returns a function waiting for it. A result is stored only once its context is, so it is readable +// wherever it is found. Only resolutions with conflicts are stored. +func (b *Batch) TxnResolved(txn fdb.ReadTransaction, ctxs []types.StateHash) func() (types.StateHash, bool, error) { + wait := b.store.reader(txn, b.roomID).readResolution(resolutionKey(ctxs)) + return func() (types.StateHash, bool, error) { + raw, err := wait() + if err != nil || raw == nil { + return types.StateHash{}, false, err + } + tup, err := tuple.Unpack(raw) + if err != nil || len(tup) != 1 { + return types.StateHash{}, false, fmt.Errorf("%w: invalid resolution result", ErrInvalidContext) + } + result, err := hashFromElement(tup[0]) + if err != nil || result.IsZero() { + return types.StateHash{}, false, fmt.Errorf("%w: invalid resolution result", ErrInvalidContext) + } + return result, true, nil + } +} + +// StoreResolved stores the result of resolving contexts with conflicts in a write transaction of its +// own. A result pending in this batch must be staged first. +func (b *Batch) StoreResolved(ctx context.Context, ctxs []types.StateHash, result types.StateHash) error { + if records := b.unwritten([]types.StateHash{result}); len(records.contexts) > 0 { + return fmt.Errorf("resolution result %x is not staged", result) + } + return b.store.stage(ctx, b.roomID, func(w storeWriter) error { + w.writeResolution(resolutionKey(ctxs), tuple.Tuple{result[:]}.Pack()) + return nil + }) +} diff --git a/internal/databases/rooms/state/resolution_test.go b/internal/databases/rooms/state/resolution_test.go new file mode 100644 index 0000000..968aba4 --- /dev/null +++ b/internal/databases/rooms/state/resolution_test.go @@ -0,0 +1,53 @@ +package state + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/beeper/babbleserv/internal/types" +) + +func TestResolutionResultIsStoredOnceStaged(t *testing.T) { + store := newMemStore() + b := newBatch(store, newCache(0), testRoomID, smallLimits) + first, err := b.TxnApply(nil, EmptyContext, withMemberships(initialState())) + require.NoError(t, err) + second, err := b.TxnApply(nil, first, withMemberships(types.StateMap{topicTup: "$topic"})) + require.NoError(t, err) + result, err := b.TxnApply(nil, first, withMemberships(types.StateMap{topicTup: "$topic2"})) + require.NoError(t, err) + require.NoError(t, b.Stage(context.Background(), 4096, first, second)) + + inputs := []types.StateHash{first, second} + _, found, err := b.TxnResolved(nil, inputs)() + require.NoError(t, err) + assert.False(t, found) + + require.Error(t, b.StoreResolved(context.Background(), inputs, result)) + _, found, err = b.TxnResolved(nil, inputs)() + require.NoError(t, err) + assert.False(t, found) + + require.NoError(t, b.Stage(context.Background(), 4096, result)) + require.NoError(t, b.StoreResolved(context.Background(), inputs, result)) + + fresh := newBatch(store, newCache(0), testRoomID, smallLimits) + _, found, err = fresh.TxnResolved(nil, []types.StateHash{first, result})() + require.NoError(t, err) + assert.False(t, found) + stored, found, err := fresh.TxnResolved(nil, []types.StateHash{second, first, second})() + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, result, stored) + _, err = iterateEventIDs(fresh, stored) + require.NoError(t, err) + + require.NoError(t, fresh.StoreResolved(context.Background(), []types.StateHash{result, first}, first)) + stored, found, err = fresh.TxnResolved(nil, []types.StateHash{first, result})() + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, first, stored) +} diff --git a/internal/databases/rooms/state/stage.go b/internal/databases/rooms/state/stage.go new file mode 100644 index 0000000..35db484 --- /dev/null +++ b/internal/databases/rooms/state/stage.go @@ -0,0 +1,77 @@ +package state + +import ( + "context" + "fmt" + "math" + + "github.com/beeper/babbleserv/internal/types" +) + +// Stage writes the given contexts that are pending in this batch and every pending page they reach, +// ahead of the transaction using them, counting key and value bytes against the budget, a record +// larger than the budget alone. Pages go first, each after its children, then context rows, so a +// staged record only references stored ones. A committed transaction's records move from the +// pending sets into the cache. +func (b *Batch) Stage(ctx context.Context, budget int, ctxs ...types.StateHash) error { + for _, chunk := range b.planStaging(budget, ctxs).chunks { + if err := b.store.stage(ctx, b.roomID, func(w storeWriter) error { + for _, h := range chunk.pages { + w.writePage(h, b.tree.pending[h].raw) + } + for _, h := range chunk.contexts { + w.writeContext(h, b.contexts[h].encode()) + } + return nil + }); err != nil { + return fmt.Errorf("failed to stage room state: %w", err) + } + b.committed(chunk.unwrittenRecords) + } + return nil +} + +// PendingBytes counts key and value bytes for pending contexts and every pending page they reach. +func (b *Batch) PendingBytes(ctxs ...types.StateHash) int { + total := 0 + for _, chunk := range b.planStaging(math.MaxInt, ctxs).chunks { + total += chunk.bytes + } + return total +} + +func (b *Batch) planStaging(budget int, ctxs []types.StateHash) *stagingPlan { + records := b.unwritten(ctxs) + keySize := b.store.keySize(b.roomID) + plan := &stagingPlan{budget: budget} + for _, h := range records.pages { + chunk := plan.add(keySize + len(b.tree.pending[h].raw)) + chunk.pages = append(chunk.pages, h) + } + for _, h := range records.contexts { + chunk := plan.add(keySize + len(b.contexts[h].encode())) + chunk.contexts = append(chunk.contexts, h) + } + return plan +} + +type stagingChunk struct { + unwrittenRecords + bytes int +} + +type stagingPlan struct { + budget int + chunks []*stagingChunk +} + +// add returns the chunk a record of this many bytes goes in: the last one while it stays within the +// budget, otherwise a new one. +func (p *stagingPlan) add(bytes int) *stagingChunk { + if n := len(p.chunks); n == 0 || p.chunks[n-1].bytes+bytes > p.budget { + p.chunks = append(p.chunks, &stagingChunk{}) + } + chunk := p.chunks[len(p.chunks)-1] + chunk.bytes += bytes + return chunk +} diff --git a/internal/databases/rooms/state/stage_test.go b/internal/databases/rooms/state/stage_test.go new file mode 100644 index 0000000..7fe78ea --- /dev/null +++ b/internal/databases/rooms/state/stage_test.go @@ -0,0 +1,257 @@ +package state + +import ( + "context" + "fmt" + "maps" + "slices" + "testing" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +type stageTest struct { + store *memStore + batch *Batch + contexts []types.StateHash + states []types.StateMap + // Applied on the way to the contexts but never staged + intermediate types.StateHash +} + +func newStageTest(t *testing.T) *stageTest { + st := &stageTest{store: newMemStore()} + st.batch = newBatch(st.store, newCache(0), testRoomID, smallLimits) + + stateMap := initialState() + for i := range 150 { + stateMap[types.MemberStateTup(userID(i))] = id.EventID(fmt.Sprintf("$join%d", i)) + } + ctx := st.apply(t, EmptyContext, stateMap, stateMap) + st.intermediate = st.apply(t, ctx, stateMap, types.StateMap{topicTup: "$topic1"}) + ctx = st.apply(t, st.intermediate, stateMap, types.StateMap{topicTup: "$topic2", types.MemberStateTup(userID(3)): "$leave3"}) + st.apply(t, ctx, stateMap, types.StateMap{types.MemberStateTup(userID(200)): "$join200"}) + return st +} + +func (st *stageTest) apply(t *testing.T, from types.StateHash, stateMap, delta types.StateMap) types.StateHash { + ctx, err := st.batch.TxnApply(nil, from, withMemberships(delta)) + require.NoError(t, err) + applyToModel(stateMap, delta) + st.contexts = append(st.contexts, ctx) + st.states = append(st.states, maps.Clone(stateMap)) + return ctx +} + +func (st *stageTest) staged() []types.StateHash { + staged := make([]types.StateHash, 0, len(st.contexts)) + for _, ctx := range st.contexts { + if ctx != st.intermediate { + staged = append(staged, ctx) + } + } + return staged +} + +func (st *stageTest) recordBytes(r stagedRecord) int { + return st.store.keySize(testRoomID) + len(r.raw) +} + +func TestStageWritesContextsReadableInAFreshBatch(t *testing.T) { + st := newStageTest(t) + require.NoError(t, st.batch.Stage(context.Background(), 2048, st.staged()...)) + require.Greater(t, len(st.store.stages), 1) + + fresh := newBatch(st.store, newCache(0), testRoomID, smallLimits) + for i, ctx := range st.contexts { + state, err := iterateEventIDs(fresh, ctx) + if ctx == st.intermediate { + assert.ErrorIs(t, err, ErrContextNotFound) + continue + } + require.NoError(t, err) + assert.Equal(t, st.states[i], state) + } +} + +func TestStageWritesChildrenBeforeParents(t *testing.T) { + st := newStageTest(t) + pending := maps.Clone(st.batch.tree.pending) + require.NoError(t, st.batch.Stage(context.Background(), 1024, st.staged()...)) + + type recordKey struct { + kind recordKind + hash types.StateHash + } + written := make(map[recordKey]int) + chunkOf := make(map[recordKey]int) + var records []stagedRecord + for i, chunk := range st.store.stages { + for _, r := range chunk { + key := recordKey{r.kind, r.hash} + _, again := written[key] + require.False(t, again, "%x is staged twice", r.hash) + written[key], chunkOf[key] = len(records), i + records = append(records, r) + } + } + + var withinChunk, acrossChunks int + requireWrittenBefore := func(parent, child recordKey) { + at, found := written[child] + require.True(t, found, "%x is not staged", child.hash) + require.Less(t, at, written[parent]) + if chunkOf[child] == chunkOf[parent] { + withinChunk++ + } else { + acrossChunks++ + } + } + requirePagesBefore := func(parent recordKey, pages ...types.StateHash) { + for _, h := range pages { + if _, pending := pending[h]; pending { + requireWrittenBefore(parent, recordKey{pageRecord, h}) + } + } + } + kind := pageRecord + for _, r := range records { + require.GreaterOrEqual(t, r.kind, kind, "pages go first, then context rows") + kind = r.kind + key := recordKey{r.kind, r.hash} + switch r.kind { + case pageRecord: + p, err := decodePage(r.raw) + require.NoError(t, err) + requirePagesBefore(key, p.children[:]...) + case contextRecord: + c, err := decodeContext(r.raw) + require.NoError(t, err) + requirePagesBefore(key, c.stateRoot, c.memberRoot) + } + } + assert.Equal(t, contextRecord, kind) + assert.NotZero(t, withinChunk) + assert.NotZero(t, acrossChunks) +} + +func TestStageChunksRespectTheBudget(t *testing.T) { + for _, budget := range []int{1, 600, 4096, 5_000_000} { + st := newStageTest(t) + require.NoError(t, st.batch.Stage(context.Background(), budget, st.staged()...)) + if budget == 5_000_000 { + assert.Len(t, st.store.stages, 1) + } + for i, chunk := range st.store.stages { + require.NotEmpty(t, chunk) + total := 0 + for _, r := range chunk { + total += st.recordBytes(r) + } + if len(chunk) > 1 { + require.LessOrEqual(t, total, budget, "budget %d, chunk %d", budget, i) + } + if i+1 < len(st.store.stages) { + require.Greater(t, total+st.recordBytes(st.store.stages[i+1][0]), budget, "budget %d, chunk %d is not full", budget, i) + } + } + } +} + +func TestTxnWriteSkipsStagedRecords(t *testing.T) { + st := newStageTest(t) + staged := st.staged() + require.NoError(t, st.batch.Stage(context.Background(), 4096, staged...)) + for _, ctx := range staged { + assert.NotContains(t, st.batch.contexts, ctx, "a committed staging transaction leaves nothing pending") + } + + st.store.pageWrites, st.store.contextWrites = 0, 0 + st.batch.TxnWrite(fdb.Transaction{}, staged...) + assert.Zero(t, st.store.pageWrites) + assert.Zero(t, st.store.contextWrites) + + last := staged[len(staged)-1] + next, err := st.batch.TxnApply(nil, last, withMemberships(types.StateMap{topicTup: "$topic3"})) + require.NoError(t, err) + newPages := len(st.batch.unwritten([]types.StateHash{next}).pages) + require.NotZero(t, newPages) + st.batch.TxnWrite(fdb.Transaction{}, append(staged, next)...) + assert.Equal(t, newPages, st.store.pageWrites) + assert.Equal(t, 1, st.store.contextWrites) +} + +func TestStagedRecordsMoveIntoTheCache(t *testing.T) { + st := newStageTest(t) + cache := newCache(defaultCacheBytes) + st.batch.tree.cache = cache + staged := st.staged() + require.NoError(t, st.batch.Stage(context.Background(), 4096, staged...)) + assert.Equal(t, []types.StateHash{st.intermediate}, slices.Collect(maps.Keys(st.batch.contexts)), "only what was not staged stays pending") + + st.store.resetCounts() + for i, ctx := range st.contexts { + if ctx == st.intermediate { + continue + } + state, err := iterateEventIDs(newBatch(st.store, cache, testRoomID, smallLimits), ctx) + require.NoError(t, err) + assert.Equal(t, st.states[i], state) + } + assert.Zero(t, st.store.pageReads+st.store.contextReads) +} + +func TestStageAgainIsANoop(t *testing.T) { + st := newStageTest(t) + require.NoError(t, st.batch.Stage(context.Background(), 4096, st.staged()...)) + stages := st.store.stageCalls + + require.NoError(t, st.batch.Stage(context.Background(), 4096, st.staged()...)) + assert.Equal(t, stages, st.store.stageCalls) +} + +func TestStageCarriesOnAfterAFailedChunk(t *testing.T) { + st := newStageTest(t) + st.store.failingStage = 3 + require.ErrorIs(t, st.batch.Stage(context.Background(), 1024, st.staged()...), errStageFailed) + require.Len(t, st.store.stages, 2) + + require.NoError(t, st.batch.Stage(context.Background(), 1024, st.staged()...)) + written := make(map[types.StateHash]struct{}) + for _, chunk := range st.store.stages { + for _, r := range chunk { + _, again := written[r.hash] + require.False(t, again, "%x is staged twice", r.hash) + written[r.hash] = struct{}{} + } + } + + fresh := newBatch(st.store, newCache(0), testRoomID, smallLimits) + for i, ctx := range st.contexts { + if ctx != st.intermediate { + state, err := iterateEventIDs(fresh, ctx) + require.NoError(t, err) + assert.Equal(t, st.states[i], state) + } + } +} + +func TestPendingBytesIsWhatStageWrites(t *testing.T) { + st := newStageTest(t) + pending := st.batch.PendingBytes(st.staged()...) + require.NoError(t, st.batch.Stage(context.Background(), 1<<20, st.staged()...)) + + written := 0 + for _, chunk := range st.store.stages { + for _, r := range chunk { + written += st.recordBytes(r) + } + } + assert.Equal(t, written, pending) + assert.Zero(t, st.batch.PendingBytes(st.staged()...)) +} diff --git a/internal/databases/rooms/state/state.go b/internal/databases/rooms/state/state.go new file mode 100644 index 0000000..726e48c --- /dev/null +++ b/internal/databases/rooms/state/state.go @@ -0,0 +1,284 @@ +package state + +import ( + "context" + "reflect" + "slices" + "sync" + "time" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/apple/foundationdb/bindings/go/src/fdb/directory" + "github.com/apple/foundationdb/bindings/go/src/fdb/subspace" + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/stateres" + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" +) + +// StateDirectory stores immutable map pages and contexts identified by their state roots. +type StateDirectory struct { + log zerolog.Logger + db fdb.Database + + // Context records, the state at a point in the room DAG as the roots of its state and member + // maps. The empty context is never stored. + // + // key: (RoomID, ContextID) + // value: (StateRoot, MemberRoot) + idToContext subspace.Subspace + + // Map pages. A leaf holds entries sorted by key. A branch holds its subtree's entry count and + // encoded entry bytes, then the child page for each nibble of the key bucket hash with "" for + // an empty slot. + // + // key: (RoomID, PageID) + // value: (0, key, value, key, value, ...) or (1, count, bytes, child0, ..., child15) + idToPage subspace.Subspace + + // Results of resolutions too large to run within a send's transactions, stored by the artifact + // job that ran them once the result context is stored. A resolution key is the hash of the + // sorted distinct input context IDs, under the version of state resolution that resolved them. + // + // key: (RoomID, stateres.Version, ResolutionKey) + // value: (ContextID) + idToResolution subspace.Subspace + + cache *cache + written writtenTxns +} + +func NewStateDirectory(logger zerolog.Logger, db fdb.Database, parentDir directory.Directory) *StateDirectory { + stateDir, err := parentDir.CreateOrOpen(db, []string{"state"}, nil) + if err != nil { + panic(err) + } + + log := logger.With().Str("directory", "state").Logger() + log.Debug(). + Bytes("prefix", stateDir.Bytes()). + Msg("Init rooms/state directory") + + return &StateDirectory{ + log: log, + db: db, + + idToContext: stateDir.Sub("cx"), + idToPage: stateDir.Sub("ob"), + idToResolution: stateDir.Sub("rs"), + + cache: newCache(defaultCacheBytes), + } +} + +func (s *StateDirectory) NewBatch(roomID id.RoomID) *Batch { + return newBatch(s, s.cache, roomID, defaultLeafLimits) +} + +// NewJobBatch returns a batch for an artifact job, running each batch of reads through read, +// whatever transaction its methods are given, so its reads may span transactions. +func (s *StateDirectory) NewJobBatch(roomID id.RoomID, read func(func(fdb.ReadTransaction) error) error) *Batch { + return newBatch(jobStorage{storage: s, read: read}, s.cache, roomID, defaultLeafLimits) +} + +func (s *StateDirectory) keyForContext(roomID id.RoomID, ctx types.StateHash) fdb.Key { + return s.idToContext.Pack(tuple.Tuple{roomID.String(), ctx[:]}) +} + +func (s *StateDirectory) keyForPage(roomID id.RoomID, h types.StateHash) fdb.Key { + return s.idToPage.Pack(tuple.Tuple{roomID.String(), h[:]}) +} + +func (s *StateDirectory) keyForResolution(roomID id.RoomID, key types.StateHash) fdb.Key { + return s.idToResolution.Pack(tuple.Tuple{roomID.String(), int64(stateres.Version), key[:]}) +} + +func (s *StateDirectory) reader(txn fdb.ReadTransaction, roomID id.RoomID) storeReader { + return txnReader{dir: s, txn: txn.Snapshot(), roomID: roomID, committed: !s.written.has(txnID(txn))} +} + +func (s *StateDirectory) writer(txn fdb.Transaction, roomID id.RoomID) storeWriter { + return &txnWriter{dir: s, txn: txn, roomID: roomID} +} + +func (s *StateDirectory) stage(ctx context.Context, roomID id.RoomID, fn func(storeWriter) error) error { + _, err := util.DoWriteTransaction(ctx, s.db, func(txn fdb.Transaction) (types.Nil, error) { + return nil, fn(s.writer(txn, roomID)) + }) + return err +} + +func (s *StateDirectory) keySize(roomID id.RoomID) int { + return len(s.keyForPage(roomID, hashOf(nil))) +} + +// txnID identifies a transaction, from any view of it, by the address of its handle, without +// keeping it alive. +func txnID(txn fdb.ReadTransaction) uintptr { + return reflect.ValueOf(txn.Snapshot()).Field(0).Pointer() +} + +// writtenTxns are the transactions state rows were written in, whose reads are never cached as they +// read back writes not yet committed. One is forgotten at the second rotation after its write, a +// rotation period being longer than a transaction can read for, and until then a later transaction +// reusing its address does not cache either. +type writtenTxns struct { + mu sync.RWMutex + rotated time.Time + current, previous map[uintptr]struct{} +} + +const writtenTxnsRotation = 10 * time.Second + +func (w *writtenTxns) add(txn uintptr) { + w.mu.Lock() + defer w.mu.Unlock() + if now := time.Now(); w.current == nil || now.Sub(w.rotated) >= writtenTxnsRotation { + w.previous, w.current, w.rotated = w.current, make(map[uintptr]struct{}), now + } + w.current[txn] = struct{}{} +} + +func (w *writtenTxns) has(txn uintptr) bool { + w.mu.RLock() + defer w.mu.RUnlock() + _, inCurrent := w.current[txn] + _, inPrevious := w.previous[txn] + return inCurrent || inPrevious +} + +type txnReader struct { + dir *StateDirectory + txn fdb.ReadTransaction + roomID id.RoomID + // No state rows were written in the transaction, so everything it reads is committed + committed bool +} + +func (r txnReader) cacheable() bool { + return r.committed +} + +func (r txnReader) readPages(hashes []types.StateHash) ([][]byte, error) { + return r.getAll(hashes, r.dir.keyForPage) +} + +func (r txnReader) readContexts(ctxs []types.StateHash) ([][]byte, error) { + return r.getAll(ctxs, r.dir.keyForContext) +} + +func (r txnReader) readResolution(key types.StateHash) func() ([]byte, error) { + return r.txn.Get(r.dir.keyForResolution(r.roomID, key)).Get +} + +func (r txnReader) getAll(hashes []types.StateHash, keyFor func(id.RoomID, types.StateHash) fdb.Key) ([][]byte, error) { + futures := make([]fdb.FutureByteSlice, len(hashes)) + for i, h := range hashes { + futures[i] = r.txn.Get(keyFor(r.roomID, h)) + } + values := make([][]byte, len(hashes)) + for i, future := range futures { + value, err := future.Get() + if err != nil { + return nil, err + } + values[i] = value + } + return values, nil +} + +type txnWriter struct { + dir *StateDirectory + txn fdb.Transaction + roomID id.RoomID + wrote bool +} + +// set records the transaction as written in before its first write. +func (w *txnWriter) set(key fdb.Key, value []byte) { + if !w.wrote { + w.dir.written.add(txnID(w.txn)) + w.wrote = true + } + w.txn.Set(key, value) +} + +func (w *txnWriter) writePage(h types.StateHash, raw []byte) { + w.set(w.dir.keyForPage(w.roomID, h), raw) +} + +func (w *txnWriter) writeContext(ctx types.StateHash, raw []byte) { + w.set(w.dir.keyForContext(w.roomID, ctx), raw) +} + +func (w *txnWriter) writeResolution(key types.StateHash, raw []byte) { + w.set(w.dir.keyForResolution(w.roomID, key), raw) +} + +// Pages and contexts a job batch reads per batch of reads, so a batch finishes well within +// FoundationDB's five seconds: pages are at most 64 KiB, 8 MiB a batch, and a context row is under +// 100 bytes. +const ( + jobPageBatch = 128 + jobContextBatch = 1000 +) + +type jobStorage struct { + storage + read func(func(fdb.ReadTransaction) error) error +} + +func (s jobStorage) reader(_ fdb.ReadTransaction, roomID id.RoomID) storeReader { + return jobReader{store: s.storage, roomID: roomID, read: s.read} +} + +type jobReader struct { + store storage + roomID id.RoomID + read func(func(fdb.ReadTransaction) error) error +} + +// A job's transactions only read +func (r jobReader) cacheable() bool { + return true +} + +func (r jobReader) readPages(hashes []types.StateHash) ([][]byte, error) { + return r.readInBatches(hashes, jobPageBatch, storeReader.readPages) +} + +func (r jobReader) readContexts(ctxs []types.StateHash) ([][]byte, error) { + return r.readInBatches(ctxs, jobContextBatch, storeReader.readContexts) +} + +func (r jobReader) readInBatches( + hashes []types.StateHash, + size int, + readBatch func(storeReader, []types.StateHash) ([][]byte, error), +) ([][]byte, error) { + raws := make([][]byte, 0, len(hashes)) + for part := range slices.Chunk(hashes, size) { + if err := r.read(func(txn fdb.ReadTransaction) error { + partRaws, err := readBatch(r.store.reader(txn, r.roomID), part) + if err == nil { + raws = append(raws, partRaws...) + } + return err + }); err != nil { + return nil, err + } + } + return raws, nil +} + +func (r jobReader) readResolution(key types.StateHash) func() ([]byte, error) { + var raw []byte + err := r.read(func(txn fdb.ReadTransaction) (err error) { + raw, err = r.store.reader(txn, r.roomID).readResolution(key)() + return err + }) + return func() ([]byte, error) { return raw, err } +} diff --git a/internal/databases/rooms/state/tree.go b/internal/databases/rooms/state/tree.go new file mode 100644 index 0000000..1cad891 --- /dev/null +++ b/internal/databases/rooms/state/tree.go @@ -0,0 +1,691 @@ +package state + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "math/bits" + "slices" + + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +var ( + ErrPageNotFound = errors.New("state page not found") + ErrEntryTooLarge = errors.New("state entry does not fit in a page") +) + +type pageReader interface { + // readPages returns the stored bytes of each page in one round trip, a job batch's in one per + // bounded batch, nil for a missing page. + readPages(hashes []types.StateHash) ([][]byte, error) + // cacheable reports whether everything read is committed, so may be cached: false for a + // transaction that wrote state rows, which it would read back before they commit. + cacheable() bool +} + +type pageWriter interface { + writePage(h types.StateHash, raw []byte) +} + +type pendingPage struct { + raw []byte + page *page +} + +type pageSet map[types.StateHash]*page + +// tree implements immutable maps from byte keys to byte values stored in one room. The form is +// canonical: a subtree that fits the leaf limits is one leaf and a larger one is a branch on the +// next nibble of each key's bucket hash, so a root depends only on the map contents. Pages +// produced by apply stay pending until committed, and reads consult pending, then the cache, then +// the reader. +type tree struct { + roomID id.RoomID + limits leafLimits + cache *cache + pending map[types.StateHash]pendingPage +} + +type leafLimits struct { + entries, bytes int +} + +var defaultLeafLimits = leafLimits{entries: leafCapacity, bytes: leafMaxBytes} + +// fits reports whether entries with this total encoded size make a single leaf, whose encoding +// adds a one byte tag. Fitting is monotone, so a branch never fits and neither does any subtree +// containing one. +func (l leafLimits) fits(entries, bytes int) bool { + return entries <= l.entries && bytes+1 <= l.bytes +} + +func newTree(c *cache, roomID id.RoomID, limits leafLimits) tree { + return tree{ + roomID: roomID, + limits: limits, + cache: c, + pending: make(map[types.StateHash]pendingPage), + } +} + +func (t *tree) fetch(r pageReader, pages pageSet, hashes []types.StateHash) error { + var missing []types.StateHash + for _, h := range hashes { + if h.IsZero() { + continue + } else if _, ok := pages[h]; ok { + continue + } else if p, ok := t.pending[h]; ok { + pages[h] = p.page + } else if p := t.cache.getPage(t.roomID, h); p != nil { + pages[h] = p + } else { + // Placeholder so repeated hashes are read once, filled below or the fetch fails. + pages[h] = nil + missing = append(missing, h) + } + } + if len(missing) == 0 { + return nil + } + + raws, err := r.readPages(missing) + if err != nil { + return err + } + for i, h := range missing { + raw := raws[i] + if raw == nil { + return fmt.Errorf("%w: %x", ErrPageNotFound, h) + } else if hashOf(raw) != h { + return fmt.Errorf("%w: %x does not match its hash", ErrInvalidPage, h) + } + p, err := decodePage(raw) + if err != nil { + return fmt.Errorf("page %x: %w", h, err) + } + if r.cacheable() { + t.cache.add(cacheKey{t.roomID, h}, p, cacheCost(raw, p)) + } + pages[h] = p + } + return nil +} + +type cursor struct { + at types.StateHash + bucket uint64 +} + +// loadPaths reads every page from each cursor down to the leaf covering its bucket, one batched +// read per level. Cursors must not start at the zero hash. +func (t *tree) loadPaths(r pageReader, pages pageSet, cursors []cursor) error { + for depth := 0; len(cursors) > 0; depth++ { + hashes := make([]types.StateHash, len(cursors)) + for i, c := range cursors { + hashes[i] = c.at + } + if err := t.fetch(r, pages, hashes); err != nil { + return err + } + + next := cursors[:0] + for _, c := range cursors { + if p := pages[c.at]; !p.leaf { + if child := p.children[slot(c.bucket, depth)]; !child.IsZero() { + next = append(next, cursor{child, c.bucket}) + } + } + } + cursors = next + } + return nil +} + +func (pages pageSet) find(root types.StateHash, key []byte, bucket uint64) []byte { + h := root + for depth := 0; !h.IsZero(); depth++ { + p := pages[h] + if p.leaf { + return p.find(key) + } + h = p.children[slot(bucket, depth)] + } + return nil +} + +type probe struct { + root types.StateHash + key []byte +} + +// lookup returns the value for each probe's key in its root's map, nil when absent. Probes may +// target different roots and share one batched read per level. +func (t *tree) lookup(r pageReader, probes []probe) ([][]byte, error) { + buckets := make([]uint64, len(probes)) + cursors := make([]cursor, 0, len(probes)) + for i, p := range probes { + buckets[i] = bucketOf(p.key) + if !p.root.IsZero() { + cursors = append(cursors, cursor{p.root, buckets[i]}) + } + } + + pages := make(pageSet) + if err := t.loadPaths(r, pages, cursors); err != nil { + return nil, err + } + + values := make([][]byte, len(probes)) + for i, p := range probes { + values[i] = pages.find(p.root, p.key, buckets[i]) + } + return values, nil +} + +// iterate calls fn with every entry of each root's map, reading one level of all the maps per +// batch. The order is deterministic but is not key order. +func (t *tree) iterate(r pageReader, roots []types.StateHash, fn func(root int, e entry) error) error { + type node struct { + root int + hash types.StateHash + } + + var frontier []node + for i, h := range roots { + if !h.IsZero() { + frontier = append(frontier, node{i, h}) + } + } + + for len(frontier) > 0 { + hashes := make([]types.StateHash, len(frontier)) + for i, n := range frontier { + hashes[i] = n.hash + } + pages := make(pageSet, len(hashes)) + if err := t.fetch(r, pages, hashes); err != nil { + return err + } + + var next []node + for _, n := range frontier { + p := pages[n.hash] + if !p.leaf { + for _, child := range p.children { + if !child.IsZero() { + next = append(next, node{n.root, child}) + } + } + continue + } + for _, e := range p.entries { + if err := fn(n.root, e); err != nil { + return err + } + } + } + frontier = next + } + return nil +} + +// iterateFrom calls fn with the entries of the leaves of root's map, a leaf at a time in bucket order, +// from the leaf starting at bucket from until the leaves seen hold at least limit entries. Each +// branch's children are read together. It returns the bucket the next leaf starts at, and false +// once no leaf is left. +func (t *tree) iterateFrom(r pageReader, root types.StateHash, from uint64, limit int, fn func(e entry) error) (uint64, bool, error) { + type node struct { + hash types.StateHash + depth int + first uint64 + } + + pages := make(pageSet) + if err := t.fetch(r, pages, []types.StateHash{root}); err != nil { + return 0, false, err + } + stack := []node{{hash: root}} + seen := 0 + for len(stack) > 0 { + n := stack[len(stack)-1] + stack = stack[:len(stack)-1] + end, last := subtreeEnd(n.first, n.depth) + if n.hash.IsZero() || (!last && end <= from) { + continue + } + + p := pages[n.hash] + if !p.leaf { + children := make([]node, 0, fanout) + for s, child := range p.children { + first := n.first | uint64(s)<<(60-4*n.depth) + if childEnd, childLast := subtreeEnd(first, n.depth+1); !child.IsZero() && (childLast || childEnd > from) { + children = append(children, node{child, n.depth + 1, first}) + } + } + hashes := make([]types.StateHash, len(children)) + for i, child := range children { + hashes[i] = child.hash + } + if err := t.fetch(r, pages, hashes); err != nil { + return 0, false, err + } + for i := len(children) - 1; i >= 0; i-- { + stack = append(stack, children[i]) + } + continue + } + + for _, e := range p.entries { + if err := fn(e); err != nil { + return 0, false, err + } + } + if seen += len(p.entries); last { + return 0, false, nil + } else if seen >= limit && len(stack) > 0 { + return end, true, nil + } + } + return 0, false, nil +} + +// subtreeEnd returns the first bucket past those of the subtree at depth starting at bucket first, +// and true for a subtree ending the bucket space. +func subtreeEnd(first uint64, depth int) (uint64, bool) { + if depth == 0 { + return 0, true + } + end, carry := bits.Add64(first, 1<<(64-4*depth), 0) + return end, carry != 0 +} + +type keyChange struct { + key, old, new []byte +} + +type rootPair struct { + from, to types.StateHash +} + +// diff returns for each pair the keys whose values differ between its two maps, in no particular +// order, with nil for a side where the key is absent. All pairs share one batched read per level. +// Subtrees with equal hashes are skipped; where a leaf or empty slot meets a branch both sides of +// that range are enumerated. +func (t *tree) diff(r pageReader, pairs ...rootPair) ([][]keyChange, error) { + type node struct { + pair int + rootPair + } + + var frontier, boundaries []node + for i, p := range pairs { + if p.from != p.to { + frontier = append(frontier, node{i, p}) + } + } + + for len(frontier) > 0 { + hashes := make([]types.StateHash, 0, 2*len(frontier)) + for _, n := range frontier { + hashes = append(hashes, n.from, n.to) + } + pages := make(pageSet, len(hashes)) + if err := t.fetch(r, pages, hashes); err != nil { + return nil, err + } + + var next []node + for _, n := range frontier { + a, b := pages[n.from], pages[n.to] + if a == nil || b == nil || a.leaf || b.leaf { + boundaries = append(boundaries, n) + continue + } + for i := range fanout { + if a.children[i] != b.children[i] { + next = append(next, node{n.pair, rootPair{a.children[i], b.children[i]}}) + } + } + } + frontier = next + } + + roots := make([]types.StateHash, 0, 2*len(boundaries)) + for _, n := range boundaries { + roots = append(roots, n.from, n.to) + } + sides := make([][]entry, len(roots)) + if err := t.iterate(r, roots, func(root int, e entry) error { + sides[root] = append(sides[root], e) + return nil + }); err != nil { + return nil, err + } + + changes := make([][]keyChange, len(pairs)) + for i, n := range boundaries { + changes[n.pair] = appendChanges(changes[n.pair], sides[2*i], sides[2*i+1]) + } + return changes, nil +} + +func appendChanges(changes []keyChange, from, to []entry) []keyChange { + before := make(map[string][]byte, len(from)) + for _, e := range from { + before[string(e.key)] = e.value + } + for _, e := range to { + old, ok := before[string(e.key)] + delete(before, string(e.key)) + if !ok || !bytes.Equal(old, e.value) { + changes = append(changes, keyChange{e.key, old, e.value}) + } + } + for key, old := range before { + changes = append(changes, keyChange{[]byte(key), old, nil}) + } + return changes +} + +type change struct { + key []byte + value []byte + bucket uint64 +} + +func (c change) isDelete() bool { + return c.value == nil +} + +// growth is the change in a subtree's entry count and encoded entry bytes. +type growth struct { + entries, bytes int +} + +type mapDelta struct { + root types.StateHash + changes map[string][]byte +} + +// apply returns the new root of each map with its changes applied, an empty value deleting the +// key. Pages on changed paths are copied into new pending pages and everything else is shared. +// The paths of all maps are read together, one batched read per level. +func (t *tree) apply(r pageReader, deltas ...mapDelta) ([]types.StateHash, error) { + sorted := make([][]change, len(deltas)) + var cursors []cursor + for i, d := range deltas { + changes := make([]change, 0, len(d.changes)) + for k, v := range d.changes { + c := change{key: []byte(k), bucket: bucketOf([]byte(k))} + if len(v) > 0 { + c.value = bytes.Clone(v) + if !t.limits.fits(1, entry{c.key, c.value}.encodedSize()) { + return nil, fmt.Errorf("%w: %d byte key, %d byte value", ErrEntryTooLarge, len(c.key), len(c.value)) + } + } + changes = append(changes, c) + if !d.root.IsZero() { + cursors = append(cursors, cursor{d.root, c.bucket}) + } + } + slices.SortFunc(changes, func(a, b change) int { + return cmp.Or(cmp.Compare(a.bucket, b.bucket), bytes.Compare(a.key, b.key)) + }) + sorted[i] = changes + } + + pages := make(pageSet) + if err := t.loadPaths(r, pages, cursors); err != nil { + return nil, err + } + + roots := make([]types.StateHash, len(deltas)) + for i, d := range deltas { + if len(sorted[i]) == 0 { + roots[i] = d.root + continue + } + root, _, err := t.update(r, pages, d.root, sorted[i], 0) + if err != nil { + return nil, err + } + roots[i] = root + } + return roots, nil +} + +// update rewrites the subtree at h, whose pages on the paths of changes are already in pages. +// Changes are sorted by bucket so each branch slot's changes are contiguous. +func (t *tree) update( + r pageReader, + pages pageSet, + h types.StateHash, + changes []change, + depth int, +) (types.StateHash, growth, error) { + p := pages[h] + if p == nil || p.leaf { + var entries []entry + if p != nil { + entries = p.entries + } + merged, g, changed := mergeEntries(entries, changes) + if !changed { + return h, g, nil + } + return t.build(merged, depth), g, nil + } + + children := p.children + var g growth + for lo := 0; lo < len(changes); { + i := slot(changes[lo].bucket, depth) + hi := lo + 1 + for hi < len(changes) && slot(changes[hi].bucket, depth) == i { + hi++ + } + child, childGrowth, err := t.update(r, pages, children[i], changes[lo:hi], depth+1) + if err != nil { + return types.StateHash{}, growth{}, err + } + children[i] = child + g.entries, g.bytes = g.entries+childGrowth.entries, g.bytes+childGrowth.bytes + lo = hi + } + + if children == p.children { + return h, g, nil + } + count, size := p.count+g.entries, p.bytes+g.bytes + if !t.limits.fits(count, size) { + return t.saveBranch(&children, count, size), g, nil + } + entries, err := t.gather(r, pages, &children) + if err != nil { + return types.StateHash{}, growth{}, err + } + return t.build(entries, depth), g, nil +} + +// gather returns every entry under children sorted by key, for a subtree that now fits in one leaf. +// Every child of such a subtree is a leaf, and the ones not yet read cost one batched read. +func (t *tree) gather(r pageReader, pages pageSet, children *[fanout]types.StateHash) ([]entry, error) { + if err := t.fetch(r, pages, children[:]); err != nil { + return nil, err + } + + var entries []entry + for _, child := range children { + if child.IsZero() { + continue + } + p := pages[child] + if !p.leaf { + return nil, fmt.Errorf("%w: branch %x inside a subtree that fits in a leaf", ErrInvalidPage, child) + } + entries = append(entries, p.entries...) + } + slices.SortFunc(entries, compareEntries) + return entries, nil +} + +func compareEntries(a, b entry) int { + return bytes.Compare(a.key, b.key) +} + +func mergeEntries(entries []entry, changes []change) ([]entry, growth, bool) { + changes = slices.Clone(changes) + slices.SortFunc(changes, func(a, b change) int { + return bytes.Compare(a.key, b.key) + }) + + merged := make([]entry, 0, len(entries)+len(changes)) + var g growth + changed := false + i := 0 + for _, c := range changes { + for i < len(entries) && bytes.Compare(entries[i].key, c.key) < 0 { + merged = append(merged, entries[i]) + i++ + } + if i < len(entries) && bytes.Equal(entries[i].key, c.key) { + if bytes.Equal(entries[i].value, c.value) { + merged = append(merged, entries[i]) + i++ + continue + } + g.entries, g.bytes = g.entries-1, g.bytes-entries[i].encodedSize() + changed = true + i++ + } + if !c.isDelete() { + e := entry{c.key, c.value} + g.entries, g.bytes = g.entries+1, g.bytes+e.encodedSize() + changed = true + merged = append(merged, e) + } + } + return append(merged, entries[i:]...), g, changed +} + +type bucketed struct { + entry + bucket uint64 +} + +// build returns the canonical subtree at depth holding entries, which are sorted by key. +func (t *tree) build(entries []entry, depth int) types.StateHash { + if h, ok := t.saveLeaf(entries, depth); ok { + return h + } + items := make([]bucketed, len(entries)) + for i, e := range entries { + items[i] = bucketed{e, bucketOf(e.key)} + } + return t.buildBranch(items, depth) +} + +func (t *tree) buildBranch(items []bucketed, depth int) types.StateHash { + var groups [fanout][]bucketed + for _, item := range items { + i := slot(item.bucket, depth) + groups[i] = append(groups[i], item) + } + + var children [fanout]types.StateHash + var count, size int + for i, group := range groups { + entries := make([]entry, len(group)) + for j, item := range group { + entries[j] = item.entry + } + if h, ok := t.saveLeaf(entries, depth+1); ok { + children[i] = h + } else { + children[i] = t.buildBranch(group, depth+1) + } + if child, ok := t.pending[children[i]]; ok { + count, size = count+child.page.count, size+child.page.bytes + } + } + return t.saveBranch(&children, count, size) +} + +// saveLeaf saves entries as one leaf if they fit, or unconditionally at the maximum depth. +func (t *tree) saveLeaf(entries []entry, depth int) (types.StateHash, bool) { + if len(entries) == 0 { + return types.StateHash{}, true + } else if len(entries) > t.limits.entries && depth < maxDepth { + return types.StateHash{}, false + } + raw := encodeLeaf(entries) + if !t.limits.fits(len(entries), len(raw)-1) && depth < maxDepth { + return types.StateHash{}, false + } + return t.save(raw, &page{leaf: true, entries: entries, count: len(entries), bytes: len(raw) - 1}), true +} + +func (t *tree) saveBranch(children *[fanout]types.StateHash, count, size int) types.StateHash { + return t.save(encodeBranch(children, count, size), &page{children: *children, count: count, bytes: size}) +} + +func (t *tree) save(raw []byte, p *page) types.StateHash { + h := hashOf(raw) + t.pending[h] = pendingPage{raw, p} + return h +} + +// unwritten returns the pending pages reachable from roots, each after its children, skipping +// intermediates that no root uses. Every child of a pending page is pending or stored, so the walk +// stops at pages that are not pending. +func (t *tree) unwritten(roots ...types.StateHash) []types.StateHash { + visited := make(map[types.StateHash]struct{}) + var order []types.StateHash + var visit func(h types.StateHash) + visit = func(h types.StateHash) { + p, ok := t.pending[h] + if !ok { + return + } else if _, done := visited[h]; done { + return + } + visited[h] = struct{}{} + if !p.page.leaf { + for _, child := range p.page.children { + visit(child) + } + } + order = append(order, h) + } + for _, root := range roots { + visit(root) + } + return order +} + +// write stores the pending pages reachable from roots. Pending pages are kept so a retried +// transaction can write them again. +func (t *tree) write(w pageWriter, roots ...types.StateHash) { + for _, h := range t.unwritten(roots...) { + w.writePage(h, t.pending[h].raw) + } +} + +// committed moves pending pages a transaction committed into the cache. +func (t *tree) committed(hashes []types.StateHash) { + for _, h := range hashes { + p := t.pending[h] + t.cache.add(cacheKey{t.roomID, h}, p.page, cacheCost(p.raw, p.page)) + delete(t.pending, h) + } +} + +func cacheCost(raw []byte, p *page) int { + return len(raw) + decodedEntryCost*len(p.entries) +} diff --git a/internal/databases/rooms/state/tree_test.go b/internal/databases/rooms/state/tree_test.go new file mode 100644 index 0000000..9eac433 --- /dev/null +++ b/internal/databases/rooms/state/tree_test.go @@ -0,0 +1,450 @@ +package state + +import ( + "bytes" + "fmt" + "maps" + "math/rand/v2" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix/event" + + "github.com/beeper/babbleserv/internal/types" +) + +var smallLimits = leafLimits{entries: 4, bytes: 1 << 20} + +type treeHarness struct { + t *testing.T + store memRoom + cache *cache + limits leafLimits +} + +func newTreeHarness(t *testing.T, limits leafLimits) *treeHarness { + return &treeHarness{ + t: t, + store: newMemStore().room(testRoomID), + cache: newCache(0), + limits: limits, + } +} + +func (h *treeHarness) tree() *tree { + tr := newTree(h.cache, h.store.roomID, h.limits) + return &tr +} + +func (h *treeHarness) applyWith(tr *tree, deltas ...mapDelta) []types.StateHash { + roots, err := tr.apply(h.store, deltas...) + require.NoError(h.t, err) + return roots +} + +func (h *treeHarness) apply(root types.StateHash, delta map[string]string) types.StateHash { + tr := h.tree() + next := h.applyWith(tr, mapDelta{root, toDelta(delta)})[0] + tr.write(h.store, next) + return next +} + +func (h *treeHarness) build(contents map[string]string) types.StateHash { + return h.applyWith(h.tree(), mapDelta{types.StateHash{}, toDelta(contents)})[0] +} + +func (h *treeHarness) contents(root types.StateHash) map[string]string { + return h.contentsWith(h.tree(), root) +} + +func (h *treeHarness) contentsWith(tr *tree, root types.StateHash) map[string]string { + out := make(map[string]string) + require.NoError(h.t, tr.iterate(h.store, []types.StateHash{root}, func(_ int, e entry) error { + if _, dup := out[string(e.key)]; dup { + h.t.Fatalf("iterate returned %q twice", e.key) + } + out[string(e.key)] = string(e.value) + return nil + })) + return out +} + +func (h *treeHarness) lookup(root types.StateHash, keys []string) map[string]string { + return h.lookupWith(h.tree(), root, keys) +} + +func (h *treeHarness) lookupWith(tr *tree, root types.StateHash, keys []string) map[string]string { + probes := make([]probe, len(keys)) + for i, key := range keys { + probes[i] = probe{root, []byte(key)} + } + values, err := tr.lookup(h.store, probes) + require.NoError(h.t, err) + out := make(map[string]string) + for i, value := range values { + if value != nil { + out[keys[i]] = string(value) + } + } + return out +} + +func (h *treeHarness) diff(from, to types.StateHash) []string { + changes, err := h.tree().diff(h.store, rootPair{from, to}) + require.NoError(h.t, err) + slices.SortFunc(changes[0], func(a, b keyChange) int { + return bytes.Compare(a.key, b.key) + }) + out := make([]string, len(changes[0])) + for i, c := range changes[0] { + out[i] = fmt.Sprintf("%s:%s>%s", c.key, c.old, c.new) + } + return out +} + +func (h *treeHarness) page(hash types.StateHash) *page { + pages := make(pageSet) + require.NoError(h.t, h.tree().fetch(h.store, pages, []types.StateHash{hash})) + return pages[hash] +} + +// requireCanonical walks a stored tree checking that leaves fit and are sorted, every key sits +// under the branch slots of its bucket hash, branch totals are exact and no branch would fit in a +// single leaf. +func (h *treeHarness) requireCanonical(root types.StateHash) { + var walk func(hash types.StateHash, path []int) (int, int) + walk = func(hash types.StateHash, path []int) (int, int) { + p := h.page(hash) + if p.leaf { + if len(p.entries) == 0 || p.count != len(p.entries) || !h.limits.fits(p.count, p.bytes) { + h.t.Fatalf("leaf %x has %d entries in %d bytes", hash, len(p.entries), p.bytes) + } + for i, e := range p.entries { + if i > 0 && bytes.Compare(p.entries[i-1].key, e.key) >= 0 { + h.t.Fatalf("leaf %x is not strictly sorted at %q", hash, e.key) + } + bucket := bucketOf(e.key) + for d, s := range path { + if slot(bucket, d) != s { + h.t.Fatalf("key %q is under slot %d at depth %d", e.key, s, d) + } + } + } + return p.count, p.bytes + } + + var count, size int + for i, child := range p.children { + if !child.IsZero() { + c, s := walk(child, append(slices.Clone(path), i)) + count, size = count+c, size+s + } + } + if count != p.count || size != p.bytes { + h.t.Fatalf("branch %x records %d entries in %d bytes, holds %d in %d", hash, p.count, p.bytes, count, size) + } else if h.limits.fits(count, size) { + h.t.Fatalf("branch %x with %d entries fits in a leaf", hash, count) + } + return count, size + } + if !root.IsZero() { + walk(root, nil) + } +} + +func toDelta(contents map[string]string) map[string][]byte { + delta := make(map[string][]byte, len(contents)) + for k, v := range contents { + delta[k] = []byte(v) + } + return delta +} + +func expectedDiff(from, to map[string]string) []string { + out := []string{} + for _, key := range slices.Sorted(maps.Keys(keysOf(from, to))) { + if from[key] != to[key] { + out = append(out, fmt.Sprintf("%s:%s>%s", key, from[key], to[key])) + } + } + return out +} + +func keysOf(ms ...map[string]string) map[string]struct{} { + keys := make(map[string]struct{}) + for _, m := range ms { + for k := range m { + keys[k] = struct{}{} + } + } + return keys +} + +func randomValue(rng *rand.Rand) string { + return "$" + strings.Repeat(string(rune('a'+rng.IntN(26))), 1+rng.IntN(24)) +} + +func modelKey(i int) string { + if i%3 == 0 { + return fmt.Sprintf("k\x00%d", i) + } + return fmt.Sprintf("k%d", i) +} + +type version struct { + root types.StateHash + contents map[string]string +} + +func randomDelta(rng *rand.Rand, step, keySpace int, model map[string]string) map[string]string { + delta := make(map[string]string) + if step%100 == 99 { + for key := range model { + delta[key] = "" + } + return delta + } + deleteChance := []float64{0.1, 0.95}[(step/40)%2] + for range 1 + rng.IntN(12) { + key := modelKey(rng.IntN(keySpace)) + if rng.Float64() < deleteChance { + delta[key] = "" + } else { + delta[key] = randomValue(rng) + } + } + return delta +} + +func applyToModel[K comparable, V comparable](model, delta map[K]V) { + var absent V + for key, value := range delta { + if value == absent { + delete(model, key) + } else { + model[key] = value + } + } +} + +func modelKeys(keySpace int) []string { + keys := make([]string, 0, keySpace+10) + for i := range keySpace { + keys = append(keys, modelKey(i)) + } + for i := range 10 { + keys = append(keys, fmt.Sprintf("absent\x00%d", i)) + } + return keys +} + +func TestTreeMatchesModel(t *testing.T) { + const keySpace, steps = 250, 250 + + for _, limits := range []leafLimits{smallLimits, {entries: 4, bytes: 48}, {entries: 1, bytes: 1 << 20}} { + for seed := range uint64(3) { + t.Run(fmt.Sprintf("entries=%d,bytes=%d,seed=%d", limits.entries, limits.bytes, seed), func(t *testing.T) { + h := newTreeHarness(t, limits) + rng := rand.New(rand.NewPCG(seed, seed)) + allKeys := modelKeys(keySpace) + + history := []version{{contents: map[string]string{}}} + model := make(map[string]string) + var root types.StateHash + + for step := range steps { + delta := randomDelta(rng, step, keySpace, model) + applyToModel(model, delta) + root = h.apply(root, delta) + history = append(history, version{root, maps.Clone(model)}) + + require.Equal(t, model, h.lookup(root, allKeys)) + require.Equal(t, model, h.contents(root)) + require.Equal(t, h.build(model), root, "root depends on history, not only contents") + h.requireCanonical(root) + if len(model) == 0 { + require.True(t, root.IsZero()) + } + + from, to := history[rng.IntN(len(history))], history[rng.IntN(len(history))] + require.Equal(t, expectedDiff(from.contents, to.contents), h.diff(from.root, to.root)) + } + + for _, v := range history { + require.Equal(t, v.contents, h.lookup(v.root, allKeys)) + require.Equal(t, v.contents, h.contents(v.root)) + } + }) + } + } +} + +func TestTreeLongLivedBatchWithPartialWrites(t *testing.T) { + const keySpace, steps = 200, 200 + h := newTreeHarness(t, leafLimits{entries: 4, bytes: 64}) + rng := rand.New(rand.NewPCG(11, 11)) + allKeys := modelKeys(keySpace) + tr := h.tree() + + models := [2]map[string]string{{}, {}} + var roots [2]types.StateHash + var written []version + for step := range steps { + var deltas [2]mapDelta + for i := range models { + delta := randomDelta(rng, step+i*20, keySpace, models[i]) + applyToModel(models[i], delta) + deltas[i] = mapDelta{roots[i], toDelta(delta)} + } + copy(roots[:], h.applyWith(tr, deltas[:]...)) + + for i := range models { + require.Equal(t, models[i], h.lookupWith(tr, roots[i], allKeys)) + require.Equal(t, models[i], h.contentsWith(tr, roots[i])) + } + if step%4 == 0 { + tr.write(h.store, roots[:]...) + written = append(written, version{roots[0], maps.Clone(models[0])}, version{roots[1], maps.Clone(models[1])}) + } + } + + for _, v := range written { + require.Equal(t, v.contents, h.contents(v.root)) + require.Equal(t, v.contents, h.lookup(v.root, allKeys)) + h.requireCanonical(v.root) + } +} + +func TestTreeShrinkingReadsOnlyChangedPaths(t *testing.T) { + h := newTreeHarness(t, defaultLeafLimits) + contents := make(map[string]string) + for i := range 5000 { + contents[fmt.Sprintf("@user%d:example.com", i)] = fmt.Sprintf("$join%d", i) + } + root := h.apply(types.StateHash{}, contents) + + h.store.resetCounts() + h.lookup(root, []string{"@user7:example.com"}) + pathLength := h.store.pageReads + require.Greater(t, pathLength, 1) + + h.store.resetCounts() + h.apply(root, map[string]string{"@user7:example.com": ""}) + assert.Equal(t, pathLength, h.store.pageReads) + assert.Equal(t, pathLength, h.store.pageReadCalls) + + h.store.resetCounts() + h.apply(root, map[string]string{"@user8:example.com": "$x"}) + assert.Equal(t, pathLength, h.store.pageReads) + + delta := make(map[string]string) + for i := range 50 { + delta[fmt.Sprintf("@user%d:example.com", i*97)] = "" + } + h.store.resetCounts() + h.apply(root, delta) + assert.Equal(t, pathLength, h.store.pageReadCalls, "one batched read per level") +} + +func TestTreePagesStayUnderValueLimit(t *testing.T) { + h := newTreeHarness(t, defaultLeafLimits) + rng := rand.New(rand.NewPCG(3, 3)) + contents := make(map[string]string) + for i := range 600 { + key := string(packStateKey(types.StateTup{ + Type: event.NewEventType(fmt.Sprintf("%03d", i) + strings.Repeat("t", 252)), + StateKey: strings.Repeat("\x00", 1+rng.IntN(254)), + })) + contents[key] = "$" + strings.Repeat("e", 254) + } + root := h.apply(types.StateHash{}, contents) + h.requireCanonical(root) + assert.Equal(t, contents, h.contents(root)) + + for _, raw := range h.store.pages { + assert.LessOrEqual(t, len(raw), 100_000) + if p, err := decodePage(raw); assert.NoError(t, err) && p.leaf { + assert.LessOrEqual(t, len(raw), leafMaxBytes) + } + } +} + +func TestTreeRejectsEntriesLargerThanAPage(t *testing.T) { + h := newTreeHarness(t, defaultLeafLimits) + key := strings.Repeat("k", 1000) + largest := strings.Repeat("v", leafMaxBytes-1-4-len(key)) + root := h.apply(types.StateHash{}, map[string]string{key: largest}) + assert.Equal(t, map[string]string{key: largest}, h.contents(root)) + + _, err := h.tree().apply(h.store, mapDelta{root, map[string][]byte{key: []byte(largest + "v")}}) + assert.ErrorIs(t, err, ErrEntryTooLarge) + _, err = h.tree().apply(h.store, mapDelta{changes: map[string][]byte{strings.Repeat("x", 70_000): []byte("$v")}}) + assert.ErrorIs(t, err, ErrEntryTooLarge) +} + +func TestTreeDiffAcrossBranchBoundaries(t *testing.T) { + h := newTreeHarness(t, smallLimits) + small := map[string]string{"a": "$1", "b": "$2", "c": "$3"} + large := map[string]string{"a": "$1", "b": "$changed"} + for i := range 30 { + large[fmt.Sprintf("n%02d", i)] = "$new" + } + + smallRoot := h.apply(types.StateHash{}, small) + largeRoot := h.apply(types.StateHash{}, large) + largerRoot := h.apply(largeRoot, map[string]string{"n07": "$again"}) + + h.store.resetCounts() + assert.Equal(t, []string{"n07:$new>$again"}, h.diff(largeRoot, largerRoot)) + assert.Less(t, h.store.pageReads, 12, "diff should only read the changed path") + + changes, err := h.tree().diff(h.store, rootPair{smallRoot, largeRoot}, rootPair{largeRoot, largerRoot}) + require.NoError(t, err) + assert.Len(t, changes[0], 32) + assert.Len(t, changes[1], 1) +} + +func TestTreeWriteSkipsIntermediatePages(t *testing.T) { + h := newTreeHarness(t, smallLimits) + tr := h.tree() + + var root types.StateHash + for i := range 30 { + root = h.applyWith(tr, mapDelta{root, map[string][]byte{fmt.Sprintf("k%d", i): []byte("$v")}})[0] + } + assert.Empty(t, h.store.pages) + + tr.write(h.store, root) + reachable := make(map[types.StateHash]struct{}) + var walk func(types.StateHash) + walk = func(hash types.StateHash) { + if hash.IsZero() { + return + } + reachable[hash] = struct{}{} + if p := tr.pending[hash].page; !p.leaf { + for _, child := range p.children { + walk(child) + } + } + } + walk(root) + assert.Len(t, h.store.pages, len(reachable)) + assert.Greater(t, len(tr.pending), len(reachable)) +} + +func TestTreeMissingPageIsAnError(t *testing.T) { + h := newTreeHarness(t, smallLimits) + _, err := h.tree().lookup(h.store, []probe{{hashOf([]byte("nowhere")), []byte("key")}}) + assert.ErrorIs(t, err, ErrPageNotFound) + + root := h.apply(types.StateHash{}, map[string]string{"key": "$v"}) + for key := range h.store.pages { + h.store.pages[key] = encodeLeaf([]entry{{[]byte("key"), []byte("$tampered")}}) + } + _, err = h.tree().lookup(h.store, []probe{{root, []byte("key")}}) + assert.ErrorIs(t, err, ErrInvalidPage) +} From b6176bf19e1dffa1b1ebb8780b6b6058129dd23e Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Sat, 3 Oct 2026 22:42:27 +0100 Subject: [PATCH 05/12] Index event state history and auth chains --- internal/databases/rooms/events/auth.go | 114 ++- internal/databases/rooms/events/authchains.go | 596 ++++++++++++ internal/databases/rooms/events/authgraph.go | 397 ++++++++ .../databases/rooms/events/authgraph_test.go | 901 ++++++++++++++++++ internal/databases/rooms/events/events.go | 310 ++---- .../databases/rooms/events/extremities.go | 42 + internal/databases/rooms/events/pagination.go | 63 +- internal/databases/rooms/events/providers.go | 272 +++--- internal/databases/rooms/events/state.go | 96 +- .../databases/rooms/events/statecurrent.go | 306 ------ .../databases/rooms/events/statehistory.go | 50 + .../databases/rooms/events/stateversion.go | 192 ---- internal/types/authheader.go | 15 + 13 files changed, 2396 insertions(+), 958 deletions(-) create mode 100644 internal/databases/rooms/events/authchains.go create mode 100644 internal/databases/rooms/events/authgraph.go create mode 100644 internal/databases/rooms/events/authgraph_test.go create mode 100644 internal/databases/rooms/events/extremities.go delete mode 100644 internal/databases/rooms/events/statecurrent.go create mode 100644 internal/databases/rooms/events/statehistory.go delete mode 100644 internal/databases/rooms/events/stateversion.go create mode 100644 internal/types/authheader.go diff --git a/internal/databases/rooms/events/auth.go b/internal/databases/rooms/events/auth.go index 31431c3..889012a 100644 --- a/internal/databases/rooms/events/auth.go +++ b/internal/databases/rooms/events/auth.go @@ -2,6 +2,8 @@ package events import ( "fmt" + "maps" + "slices" "github.com/apple/foundationdb/bindings/go/src/fdb" "maunium.net/go/mautrix/id" @@ -9,62 +11,84 @@ import ( "github.com/beeper/babbleserv/internal/types" ) -// Get the auth chain for one or more events, that is all the auth events for each input event and each of their auth events, and so on, recursively. Introduced in Room V2: -// https://spec.matrix.org/v1.10/rooms/v2/#definitions -func (e *EventsDirectory) TxnGetAuthChainForEvents( - txn fdb.ReadTransaction, - evs []*types.Event, - eventsProvider *TxnEventsProvider, -) ([]*types.Event, error) { - eventIDsToFetch := make(map[id.EventID]struct{}, len(evs)) - authEvs := make(map[id.EventID]*types.Event, len(evs)) +// AuthEventIDs maps event IDs to their auth event IDs. Chain walks sharing one map read each +// event's auth event IDs once, and events not yet stored can be added to it up front. +type AuthEventIDs map[id.EventID][]id.EventID - // Start fetching each direct auth event from each input event - for _, reqEv := range evs { - for _, evID := range reqEv.AuthEventIDs { - if _, found := eventIDsToFetch[evID]; !found { - eventsProvider.WillGet(evID) - eventIDsToFetch[evID] = struct{}{} - } - } +// TxnGetAuthChainIDs returns the seeds and every event reachable from them over auth events. The +// walk is breadth first with one batch of reads per level for events missing from known, which it +// fills in. +func (e *EventsDirectory) TxnGetAuthChainIDs( + txn fdb.ReadTransaction, + seeds []id.EventID, + known AuthEventIDs, +) (map[id.EventID]struct{}, error) { + if known == nil { + known = make(AuthEventIDs) } - for { - // Exit once we've no more futures to process - if len(eventIDsToFetch) == 0 { - break + chain := make(map[id.EventID]struct{}, len(seeds)) + frontier := make([]id.EventID, 0, len(seeds)) + for _, eventID := range seeds { + if _, found := chain[eventID]; !found { + chain[eventID] = struct{}{} + frontier = append(frontier, eventID) } + } - for evID := range eventIDsToFetch { - ev, err := eventsProvider.Get(evID) - if err != nil { - return nil, err - } else if ev == nil { - return nil, fmt.Errorf("failed to get auth chain: %w: %s", types.ErrEventNotFound, evID) + _, load := e.authHeaderReader(txn, nil) + for len(frontier) > 0 { + var missing []id.EventID + for _, eventID := range frontier { + if _, found := known[eventID]; !found { + missing = append(missing, eventID) } - - // Add to fetched, remove from futures - authEvs[evID] = ev - delete(eventIDsToFetch, evID) - - // Now loop through the auth events auth events, if we're not fetching - // or have fetched it, start fetching it in another future and continue. - for _, evID := range ev.AuthEventIDs { - if _, found := eventIDsToFetch[evID]; found { - continue - } - if _, found := authEvs[evID]; found { - continue + } + headers, err := load(missing) + if err != nil { + return nil, fmt.Errorf("failed to get auth chain: %w", err) + } + for i, h := range headers { + known[missing[i]] = h.AuthEventIDs + } + var next []id.EventID + for _, eventID := range frontier { + for _, authEventID := range known[eventID] { + if _, found := chain[authEventID]; !found { + chain[authEventID] = struct{}{} + next = append(next, authEventID) } - eventsProvider.WillGet(evID) - eventIDsToFetch[evID] = struct{}{} } } + frontier = next + } + + return chain, nil +} + +// Get the auth chain for one or more events, that is all the auth events for each input event and each of their auth events, and so on, recursively. Introduced in Room V2: +// https://spec.matrix.org/v1.10/rooms/v2/#definitions +func (e *EventsDirectory) TxnGetAuthChainForEvents( + txn fdb.ReadTransaction, + evs []*types.Event, + eventsProvider *TxnEventsProvider, +) ([]*types.Event, error) { + // Events held by the provider may not be stored yet, such as a federated batch being authorized + known := eventsProvider.authEventIDs() + var seeds []id.EventID + for _, ev := range evs { + known[ev.ID] = ev.AuthEventIDs + seeds = append(seeds, ev.AuthDependencyIDs()...) + } + + chainIDs, err := e.TxnGetAuthChainIDs(txn, seeds, known) + if err != nil { + return nil, err } - authChain := make([]*types.Event, 0, len(authEvs)) - for _, ev := range authEvs { - authChain = append(authChain, ev) + authChain, err := eventsProvider.GetAll(slices.Collect(maps.Keys(chainIDs))) + if err != nil { + return nil, fmt.Errorf("failed to get auth chain: %w", err) } return authChain, nil } diff --git a/internal/databases/rooms/events/authchains.go b/internal/databases/rooms/events/authchains.go new file mode 100644 index 0000000..bb7d8e0 --- /dev/null +++ b/internal/databases/rooms/events/authchains.go @@ -0,0 +1,596 @@ +package events + +import ( + "fmt" + "maps" + "math" + "slices" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +type chainPosition struct{ chain, seq uint32 } +type chainLink struct{ seq, target, targetSeq uint32 } +type chainInterval struct{ chain, first, last uint32 } + +// linkRead reads at most limit of a chain's links, from its first or, when after is set, from the +// one following after. +type linkRead struct { + chain uint32 + after chainLink + limit int +} + +// chainCover holds the chain links a graph read and the positions it assigned, reading position rows +// only for tips, subgraph intervals and auth differences. +// +// Staging outside the room's mutex may extend a chain or allocate a chain ID after a graph read it, +// so a graph never reads a chain's rows above what it holds: of a chain it extended it holds the +// links and every position above the stored tip, and a chain it allocated it never reads. A graph +// labels only within one transaction; one spanning transactions, an artifact job's, labels nothing. +type chainCover struct { + // Returns the links of the reads completed before a failure + loadLinks func([]linkRead) ([][]chainLink, error) + loadTip func(uint32) (uint32, error) + allocate func() (uint32, error) + // Returns the events at every position of the intervals, interval by interval + loadPositions func([]chainInterval) ([]id.EventID, error) + links map[uint32][]chainLink + // Links of chains read in part, to continue after the last + partialLinks map[uint32][]chainLink + tips map[uint32]uint32 + positions map[chainPosition]id.EventID + // The lowest position assigned on each chain, from which every position of the chain is held + pendingFrom map[uint32]uint32 + addedLinks map[uint32][]chainLink + next uint32 + allocated bool + // The allocator as read before the first allocation + allocatedFrom uint32 +} + +func (g *AuthGraph) newChainCover() *chainCover { + c := &chainCover{links: make(map[uint32][]chainLink), partialLinks: make(map[uint32][]chainLink), tips: make(map[uint32]uint32), positions: make(map[chainPosition]id.EventID), pendingFrom: make(map[uint32]uint32), addedLinks: make(map[uint32][]chainLink)} + c.loadLinks = func(reads []linkRead) ([][]chainLink, error) { + futures := make([]fdb.RangeResult, len(reads)) + for i, read := range reads { + begin, end := g.dir.authChainLinks.Sub(g.roomID.String(), int64(read.chain)).FDBRangeKeys() + if read.after.seq != 0 { + after := g.dir.authChainLinks.Pack(tuple.Tuple{g.roomID.String(), int64(read.chain), int64(read.after.seq), int64(read.after.target), int64(read.after.targetSeq)}) + begin = append(after, 0x00) + } + futures[i] = g.txn.GetRange(fdb.KeyRange{Begin: begin, End: end}, fdb.RangeOptions{Limit: read.limit}) + } + out := make([][]chainLink, 0, len(reads)) + for _, future := range futures { + rows, err := future.GetSliceWithError() + if err != nil { + return out, err + } + links := make([]chainLink, 0, len(rows)) + for _, row := range rows { + t, err := g.dir.authChainLinks.Unpack(row.Key) + if err != nil || len(t) != 5 { + return out, fmt.Errorf("invalid auth chain link") + } + vals := [3]uint32{} + for j := range vals { + n, ok := t[j+2].(int64) + if !ok || n <= 0 || n > math.MaxUint32 { + return out, fmt.Errorf("invalid auth chain coordinate") + } + vals[j] = uint32(n) + } + links = append(links, chainLink{vals[0], vals[1], vals[2]}) + } + out = append(out, links) + } + return out, nil + } + c.loadTip = func(chain uint32) (uint32, error) { + return g.readTip(g.txn, chain) + } + c.allocate = func() (uint32, error) { + if !c.allocated { + next, err := g.readNextChain(g.txn) + if err != nil { + return 0, err + } + c.next, c.allocatedFrom, c.allocated = next, next, true + } + if c.next == math.MaxUint32 { + return 0, fmt.Errorf("auth chain ID overflow") + } + c.next++ + return c.next, nil + } + c.loadPositions = func(intervals []chainInterval) ([]id.EventID, error) { + futures := make([]fdb.RangeResult, len(intervals)) + for i, in := range intervals { + futures[i] = g.txn.GetRange(fdb.KeyRange{ + Begin: g.dir.authChainPositions.Pack(tuple.Tuple{g.roomID.String(), int64(in.chain), int64(in.first)}), + End: g.dir.authChainPositions.Pack(tuple.Tuple{g.roomID.String(), int64(in.chain), int64(in.last) + 1}), + }, fdb.RangeOptions{}) + } + var out []id.EventID + for i, future := range futures { + rows, err := future.GetSliceWithError() + if err != nil { + return nil, err + } + in := intervals[i] + if uint64(len(rows)) != uint64(in.last)-uint64(in.first)+1 { + return nil, fmt.Errorf("missing auth chain positions in chain %d from %d to %d", in.chain, in.first, in.last) + } + for j, row := range rows { + seq := in.first + uint32(j) + t, err := g.dir.authChainPositions.Unpack(row.Key) + if err != nil || len(t) != 3 || t[1] != int64(in.chain) || t[2] != int64(seq) { + return nil, fmt.Errorf("invalid auth chain position") + } + eventID, err := unpackChainEntry(row.Value) + if err != nil { + return nil, fmt.Errorf("auth chain position %d of chain %d: %w", seq, in.chain, err) + } + out = append(out, eventID) + } + } + return out, nil + } + return c +} + +func (g *AuthGraph) readTip(txn fdb.ReadTransaction, chain uint32) (uint32, error) { + rows, err := txn.GetRange(g.dir.authChainPositions.Sub(g.roomID.String(), int64(chain)), fdb.RangeOptions{Limit: 1, Reverse: true}).GetSliceWithError() + if err != nil { + return 0, err + } + if len(rows) == 0 { + return 0, nil + } + t, err := g.dir.authChainPositions.Unpack(rows[0].Key) + if err != nil || len(t) != 3 { + return 0, fmt.Errorf("invalid auth chain position") + } + n, ok := t[2].(int64) + if !ok || n <= 0 || n > math.MaxUint32 { + return 0, fmt.Errorf("invalid auth chain sequence") + } + return uint32(n), nil +} + +func (g *AuthGraph) readNextChain(txn fdb.ReadTransaction) (uint32, error) { + raw, err := txn.Get(g.dir.authChainNext.Pack(tuple.Tuple{g.roomID.String()})).Get() + if err != nil || raw == nil { + return 0, err + } + t, err := tuple.Unpack(raw) + if err != nil || len(t) != 1 { + return 0, fmt.Errorf("invalid chain allocator") + } + n, ok := t[0].(int64) + if !ok || n < 0 || n > math.MaxUint32 { + return 0, fmt.Errorf("invalid chain allocator") + } + return uint32(n), nil +} + +// TxnLabelsUnchanged reports whether the headers the graph finalized are still unfinalized, and the +// tips of the chains it extended and the chain allocator it allocated from are as it read them. On a +// transaction that writes, the reads conflict with a writer committing after them. +func (g *AuthGraph) TxnLabelsUnchanged(txn fdb.ReadTransaction) (bool, error) { + headers := make([]fdb.FutureByteSlice, 0, len(g.dirty)) + for eventID := range g.dirty { + headers = append(headers, txn.Get(g.dir.keyForAuthEventIDs(eventID))) + } + c := g.cover + if c.allocated { + if next, err := g.readNextChain(txn); err != nil || next != c.allocatedFrom { + return false, err + } + } + for chain, from := range c.pendingFrom { + if c.allocated && chain > c.allocatedFrom { + continue + } else if tip, err := g.readTip(txn, chain); err != nil || tip != from-1 { + return false, err + } + } + for _, header := range headers { + if raw, err := header.Get(); err != nil { + return false, err + } else if raw == nil { + continue + } else if h, err := unpackAuthHeader(raw); err != nil || h.Finalized() { + return false, err + } + } + return true, nil +} + +// fetch reads the links of the chains and of every chain they link to, in batches of at most +// linkChainBatch chains and linkRowBatch links, shared evenly between the chains. +// A chain with more links continues in a later batch. Links are kept in source sequence order. +func (g *AuthGraph) fetch(chains []uint32) error { + c := g.cover + var queue []uint32 + queued := make(map[uint32]bool) + enqueue := func(chain uint32) { + if _, done := c.links[chain]; !done && !queued[chain] { + queued[chain] = true + queue = append(queue, chain) + } + } + for _, chain := range chains { + enqueue(chain) + } + for len(queue) > 0 { + part := queue[:min(len(queue), linkChainBatch)] + queue = queue[len(part):] + limit := max(linkRowBatch/len(part), 1) + if err := g.do(func() error { + var reads []linkRead + for _, chain := range part { + if _, done := c.links[chain]; !done { + read := linkRead{chain: chain, limit: limit} + if partial := c.partialLinks[chain]; len(partial) > 0 { + read.after = partial[len(partial)-1] + } + reads = append(reads, read) + } + } + results, err := c.loadLinks(reads) + for i, links := range results { + chain := reads[i].chain + c.partialLinks[chain] = append(c.partialLinks[chain], links...) + if len(links) < reads[i].limit { + c.links[chain] = c.partialLinks[chain] + delete(c.partialLinks, chain) + } + for _, l := range links { + enqueue(l.target) + } + } + return err + }); err != nil { + return err + } + for _, chain := range part { + if _, done := c.links[chain]; !done { + queue = append(queue, chain) + } + } + } + return nil +} + +// readPositions reads the events at the intervals' positions, at most positionBatch per batch. +func (g *AuthGraph) readPositions(intervals []chainInterval) ([]id.EventID, error) { + var out []id.EventID + for _, part := range positionBatches(intervals, positionBatch) { + if err := g.do(func() error { + eventIDs, err := g.cover.loadPositions(part) + if err == nil { + out = append(out, eventIDs...) + } + return err + }); err != nil { + return nil, err + } + } + return out, nil +} + +// stored returns the part of an interval below the positions the graph assigned on its chain, the +// only part it reads from storage. +func (c *chainCover) stored(in chainInterval) (chainInterval, bool) { + if from, ok := c.pendingFrom[in.chain]; ok && in.last >= from { + in.last = from - 1 + } + return in, in.first <= in.last +} + +// positionBatches splits intervals into batches of at most size positions, cutting an interval +// where a batch fills. +func positionBatches(intervals []chainInterval, size uint64) [][]chainInterval { + var batches [][]chainInterval + var current []chainInterval + room := size + for _, in := range intervals { + for first := uint64(in.first); first <= uint64(in.last); { + n := min(uint64(in.last)-first+1, room) + current = append(current, chainInterval{in.chain, uint32(first), uint32(first + n - 1)}) + first += n + if room -= n; room == 0 { + batches = append(batches, current) + current, room = nil, size + } + } + } + if len(current) > 0 { + batches = append(batches, current) + } + return batches +} + +func (c *chainCover) backward(seeds []chainPosition) map[uint32]uint32 { + reach := make(map[uint32]uint32) + stack := slices.Clone(seeds) + for len(stack) > 0 { + at := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if reach[at.chain] >= at.seq { + continue + } + reach[at.chain] = at.seq + for _, l := range c.links[at.chain] { + if l.seq > at.seq { + break + } + stack = append(stack, chainPosition{l.target, l.targetSeq}) + } + } + return reach +} + +func (g *AuthGraph) assign(ev *types.Event, parents []*types.Event, headers []types.AuthHeader) (chainPosition, error) { + c := g.cover + var own chainPosition + var seeds []chainPosition + var chains []uint32 + for i, p := range parents { + h := headers[i] + if h.Chain == 0 { + return own, fmt.Errorf("unlabelled auth parent %s", p.ID) + } + seeds = append(seeds, chainPosition{h.Chain, h.Sequence}) + chains = append(chains, h.Chain) + } + for i, p := range parents { + if p.StateKey == nil || p.StateTup() != ev.StateTup() { + continue + } + pos := seeds[i] + tip, ok := c.tips[pos.chain] + if !ok { + var err error + tip, err = c.loadTip(pos.chain) + if err != nil { + return own, err + } + c.tips[pos.chain] = tip + } + if tip == pos.seq && tip < math.MaxUint32 { + own = chainPosition{pos.chain, tip + 1} + } + break + } + if own.chain == 0 { + chain, err := c.allocate() + if err != nil { + return own, err + } + own = chainPosition{chain, 1} + c.links[chain] = nil + } + if err := g.fetch(chains); err != nil { + return own, err + } + closures := make([]map[uint32]uint32, len(seeds)) + for i, seed := range seeds { + closures[i] = c.backward([]chainPosition{seed}) + } + for i, target := range seeds { + if target.chain == own.chain { + continue + } + redundant := false + for j, reach := range closures { + if i != j && (seeds[i] != seeds[j] || j < i) && reach[target.chain] >= target.seq { + redundant = true + break + } + } + if redundant { + continue + } + for _, l := range c.links[own.chain] { + if l.target == target.chain && l.targetSeq >= target.seq { + redundant = true + break + } + } + if !redundant { + l := chainLink{own.seq, target.chain, target.seq} + c.links[own.chain] = append(c.links[own.chain], l) + c.addedLinks[own.chain] = append(c.addedLinks[own.chain], l) + } + } + c.tips[own.chain] = own.seq + c.positions[own] = ev.ID + if _, ok := c.pendingFrom[own.chain]; !ok { + c.pendingFrom[own.chain] = own.seq + } + return own, nil +} + +// ConflictedSubgraph returns the events on paths between conflicted events, +// including endpoints. Intersect backward maxima with forward minima on each +// chain, then load the overlapping intervals in bounded batches. +func (g *AuthGraph) ConflictedSubgraph(ids []id.EventID) ([]id.EventID, error) { + headers, err := g.HeadersInBatches(ids) + if err != nil { + return nil, err + } + c := g.cover + seeds := make([]chainPosition, len(headers)) + chains := make([]uint32, len(headers)) + for i, h := range headers { + seeds[i] = chainPosition{h.Chain, h.Sequence} + chains[i] = h.Chain + } + if err := g.fetch(chains); err != nil { + return nil, err + } + back := c.backward(seeds) + type inverse struct { + targetSeq uint32 + source chainPosition + } + inverted := make(map[uint32][]inverse) + for chain := range back { + for _, l := range c.links[chain] { + if l.seq <= back[chain] { + inverted[l.target] = append(inverted[l.target], inverse{l.targetSeq, chainPosition{chain, l.seq}}) + } + } + } + forward := make(map[uint32]uint32) + stack := slices.Clone(seeds) + for len(stack) > 0 { + at := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if first := forward[at.chain]; first != 0 && first <= at.seq { + continue + } + forward[at.chain] = at.seq + for _, l := range inverted[at.chain] { + if l.targetSeq >= at.seq { + stack = append(stack, l.source) + } + } + } + var intervals []chainInterval + for chain, last := range back { + if first := forward[chain]; first != 0 && first <= last { + intervals = append(intervals, chainInterval{chain, first, last}) + } + } + between, err := g.chainEvents(intervals) + if err != nil { + return nil, err + } + out := slices.Concat(ids, between) + slices.Sort(out) + return slices.Compact(out), nil +} + +// AuthChainDifference returns, sorted, the events in the auth chains of some of the state sets but +// not all, each state event counted in its own set's, as Synapse's +// _get_auth_chain_difference_using_cover_index computes it. A set reaches, on each chain, the +// highest position its state events' own positions and the links below them reach. Every position +// of a chain above the lowest reach of any set, a set not reaching the chain having none, and at or +// below the highest is in the difference. It reads the state events' headers, the links of every +// chain they reach and the positions of the difference in bounded batches, so it +// costs O(state events + chains touched). +func (g *AuthGraph) AuthChainDifference(stateSets [][]id.EventID) ([]id.EventID, error) { + var ids []id.EventID + for _, set := range stateSets { + ids = append(ids, set...) + } + slices.Sort(ids) + ids = slices.Compact(ids) + headers, err := g.HeadersInBatches(ids) + if err != nil { + return nil, err + } + positions := make(map[id.EventID]chainPosition, len(ids)) + chains := make([]uint32, 0, len(ids)) + for i, h := range headers { + if h.Chain == 0 { + return nil, fmt.Errorf("%w: state event %s has no chain position", ErrAuthEventNotFinalized, ids[i]) + } + positions[ids[i]] = chainPosition{h.Chain, h.Sequence} + chains = append(chains, h.Chain) + } + c := g.cover + if err := g.fetch(chains); err != nil { + return nil, err + } + reaches := make([]map[uint32]uint32, len(stateSets)) + reached := make(map[uint32]struct{}) + for i, set := range stateSets { + seeds := make([]chainPosition, len(set)) + for j, eventID := range set { + seeds[j] = positions[eventID] + } + reaches[i] = c.backward(seeds) + for chain := range reaches[i] { + reached[chain] = struct{}{} + } + } + var intervals []chainInterval + for _, chain := range slices.Sorted(maps.Keys(reached)) { + lowest, highest := uint32(math.MaxUint32), uint32(0) + for _, reach := range reaches { + lowest, highest = min(lowest, reach[chain]), max(highest, reach[chain]) + } + if lowest < highest { + intervals = append(intervals, chainInterval{chain, lowest + 1, highest}) + } + } + difference, err := g.chainEvents(intervals) + if err != nil { + return nil, err + } + slices.Sort(difference) + return difference, nil +} + +// chainEvents returns the events at every position of the intervals, interval by interval in +// sequence order, reading at most positionBatch stored positions per batch. +// Positions at or above the lowest the graph assigned on a chain come from memory and the rest +// from storage, so a chain staging appended to since is read only below them. +func (g *AuthGraph) chainEvents(intervals []chainInterval) ([]id.EventID, error) { + c := g.cover + var storedIntervals []chainInterval + for _, in := range intervals { + if in.first == 0 || in.first > in.last { + return nil, fmt.Errorf("invalid chain interval %d from %d to %d", in.chain, in.first, in.last) + } + if part, ok := c.stored(in); ok { + storedIntervals = append(storedIntervals, part) + } + } + stored, err := g.readPositions(storedIntervals) + if err != nil { + return nil, err + } + var out []id.EventID + for _, in := range intervals { + for seq := in.first; ; seq++ { + if from, ok := c.pendingFrom[in.chain]; ok && seq >= from { + eventID, ok := c.positions[chainPosition{in.chain, seq}] + if !ok { + return nil, fmt.Errorf("auth chain position %d of chain %d is above those the graph holds", seq, in.chain) + } + out = append(out, eventID) + } else { + out, stored = append(out, stored[0]), stored[1:] + } + if seq == in.last { + break + } + } + } + return out, nil +} + +func (g *AuthGraph) writeChains(txn fdb.Transaction) { + c := g.cover + for pos, eventID := range c.positions { + txn.Set(g.dir.authChainPositions.Pack(tuple.Tuple{g.roomID.String(), int64(pos.chain), int64(pos.seq)}), packChainEntry(eventID)) + } + for chain, links := range c.addedLinks { + for _, l := range links { + txn.Set(g.dir.authChainLinks.Pack(tuple.Tuple{g.roomID.String(), int64(chain), int64(l.seq), int64(l.target), int64(l.targetSeq)}), nil) + } + } + if c.allocated { + txn.Set(g.dir.authChainNext.Pack(tuple.Tuple{g.roomID.String()}), tuple.Tuple{int64(c.next)}.Pack()) + } +} diff --git a/internal/databases/rooms/events/authgraph.go b/internal/databases/rooms/events/authgraph.go new file mode 100644 index 0000000..8acba54 --- /dev/null +++ b/internal/databases/rooms/events/authgraph.go @@ -0,0 +1,397 @@ +package events + +import ( + "cmp" + "errors" + "fmt" + "math" + "slices" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +var ( + // ErrAuthEventNotFinalized fails finalizing an event whose auth chain reaches a header neither + // finalized nor finalizable, such as a rejected or unknown event's, and reading the header of an + // event neither finalized nor pending. + ErrAuthEventNotFinalized = errors.New("auth event header not finalized") + // ErrAuthEventPending fails finalizing an event whose auth chain reaches, through stored events + // not finalized, a pending event later in the batch, which may be finalized once that one is. + ErrAuthEventPending = errors.New("auth event pending in the batch") + + errNoAuthHeader = errors.New("no auth header") +) + +// maxAuthEdges is the most auth events a finalized event may cite. The auth events selection that +// step 4 enforces allows at most seven, so only a rejected event can cite more. +const maxAuthEdges = 64 + +// Rows per batch of an artifact job's reads, so a batch finishes well within FoundationDB's five +// seconds and loses little to a retry: auth headers of about 400 bytes, chains whose links are read, +// links of about 60 bytes and chain positions of about 100 bytes, about 1 MB a batch at most. +const ( + headerBatch = 1000 + linkChainBatch = 1000 + linkRowBatch = 10000 + positionBatch = 5000 +) + +func packAuthHeader(h types.AuthHeader) []byte { + parents := make(tuple.Tuple, len(h.AuthEventIDs)) + for i, p := range h.AuthEventIDs { + parents[i] = p.String() + } + return tuple.Tuple{int64(1), int64(h.Chain), int64(h.Sequence), parents}.Pack() +} + +func unpackAuthHeader(raw []byte) (types.AuthHeader, error) { + t, err := tuple.Unpack(raw) + if err != nil { + return types.AuthHeader{}, err + } + var h types.AuthHeader + if len(t) != 4 { + return h, fmt.Errorf("invalid auth header length") + } else if version, ok := t[0].(int64); !ok || version != 1 { + return h, fmt.Errorf("invalid auth header version") + } + chain, ok1 := t[1].(int64) + seq, ok2 := t[2].(int64) + parents, ok3 := t[3].(tuple.Tuple) + if !ok1 || chain < 0 || chain > math.MaxUint32 || !ok2 || seq < 0 || seq > math.MaxUint32 || !ok3 || (chain == 0) != (seq == 0) { + return h, fmt.Errorf("invalid auth header fields") + } + h.Chain, h.Sequence = uint32(chain), uint32(seq) + for _, v := range parents { + p, ok := v.(string) + if !ok || p == "" { + return h, fmt.Errorf("invalid auth parent") + } + h.AuthEventIDs = append(h.AuthEventIDs, id.EventID(p)) + } + // Events rejected at step 4 for citing too many auth events keep an unfinalized header. + if h.Finalized() && len(h.AuthEventIDs) > maxAuthEdges { + return h, fmt.Errorf("too many auth events for a finalized header") + } + return h, nil +} + +// packChainEntry is the value of an event's chain position +func packChainEntry(eventID id.EventID) []byte { + return tuple.Tuple{eventID.String()}.Pack() +} + +func unpackChainEntry(raw []byte) (id.EventID, error) { + t, err := tuple.Unpack(raw) + if err != nil || len(t) != 1 { + return "", fmt.Errorf("invalid auth chain entry") + } + eventID, ok := t[0].(string) + if !ok || eventID == "" { + return "", fmt.Errorf("invalid auth chain entry") + } + return id.EventID(eventID), nil +} + +type eventReader interface { + WillGet(...id.EventID) + Get(id.EventID) (*types.Event, error) +} + +// AuthGraph holds the auth headers one transaction read and finalized, pending events' included: a +// send's preparation, whose publish writes them under its guard, or a staging transaction. An +// artifact job's graph reads finalized headers across transactions instead, see Renew. +type AuthGraph struct { + cover *chainCover + do func(func() error) error + prefetch func([]id.EventID) + load func([]id.EventID) ([]types.AuthHeader, error) + headers map[id.EventID]types.AuthHeader + dirty map[id.EventID]types.AuthHeader + pending map[id.EventID]*types.Event + // The position of each pending event in the batch, the order they are finalized in + order map[id.EventID]int + dir *EventsDirectory + txn fdb.ReadTransaction + roomID id.RoomID + // Only a graph finalizing headers within one transaction has an event reader. + events eventReader +} + +// authHeaderReader batches reads of stored auth headers. Every stored event has one, so only +// the pending events, which are not stored yet, may lack one. A failed load returns the headers +// read before the failure. +func (e *EventsDirectory) authHeaderReader( + txn fdb.ReadTransaction, + pending map[id.EventID]*types.Event, +) (prefetch func([]id.EventID), load func([]id.EventID) ([]types.AuthHeader, error)) { + futures := make(map[id.EventID]fdb.FutureByteSlice) + prefetch = func(ids []id.EventID) { + for _, eventID := range ids { + if _, ok := futures[eventID]; !ok { + futures[eventID] = txn.Get(e.keyForAuthEventIDs(eventID)) + } + } + } + load = func(ids []id.EventID) ([]types.AuthHeader, error) { + prefetch(ids) + out := make([]types.AuthHeader, 0, len(ids)) + for _, eventID := range ids { + raw, err := futures[eventID].Get() + delete(futures, eventID) + if err != nil { + return out, err + } + var h types.AuthHeader + if raw != nil { + if h, err = unpackAuthHeader(raw); err != nil { + return out, fmt.Errorf("auth header %s: %w", eventID, err) + } + } else if ev := pending[eventID]; ev != nil { + h.AuthEventIDs = slices.Clone(ev.AuthEventIDs) + } else { + return out, fmt.Errorf("%w for %s", errNoAuthHeader, eventID) + } + out = append(out, h) + } + return out, nil + } + return prefetch, load +} + +// NewAuthGraph returns a graph reading in one transaction, which finalizes the headers it needs. +func (e *EventsDirectory) NewAuthGraph(txn fdb.ReadTransaction, roomID id.RoomID, provider *TxnEventsProvider) *AuthGraph { + g := e.newAuthGraph(roomID, func(reads func() error) error { return reads() }) + g.txn, g.events = txn, provider + g.prefetch, g.load = e.authHeaderReader(txn, g.pending) + return g +} + +// NewJobAuthGraph returns a graph for an artifact job, reading in the transactions Renew gives it. +func (e *EventsDirectory) NewJobAuthGraph(roomID id.RoomID, do func(func() error) error) *AuthGraph { + return e.newAuthGraph(roomID, do) +} + +func (e *EventsDirectory) newAuthGraph(roomID id.RoomID, do func(func() error) error) *AuthGraph { + g := &AuthGraph{headers: make(map[id.EventID]types.AuthHeader), dirty: make(map[id.EventID]types.AuthHeader), pending: make(map[id.EventID]*types.Event), order: make(map[id.EventID]int), dir: e, roomID: roomID} + g.do = do + g.cover = g.newChainCover() + return g +} + +// Renew moves the graph's reads to another transaction, for an artifact job whose reads span +// transactions. What the graph holds stays: headers, which never change once finalized, pending +// events with their chain positions, and the chains read so far. From then on it finalizes nothing. +func (g *AuthGraph) Renew(txn fdb.ReadTransaction, do func(func() error) error) { + g.txn, g.do, g.events = txn, do, nil + g.prefetch, g.load = g.dir.authHeaderReader(txn, g.pending) +} + +// Add makes pending events available to the graph, in batch order, and starts reading any header +// already stored for their state events, which alone can be finalized. +func (g *AuthGraph) Add(evs ...*types.Event) { + var stateIDs []id.EventID + for _, ev := range evs { + g.pending[ev.ID] = ev + if _, added := g.order[ev.ID]; !added { + g.order[ev.ID] = len(g.order) + } + if _, loaded := g.headers[ev.ID]; !loaded && ev.StateKey != nil { + stateIDs = append(stateIDs, ev.ID) + } + } + if g.prefetch != nil { + g.prefetch(stateIDs) + } +} + +// read keeps the headers loaded before a failure, so reading again loads only the rest. +func (g *AuthGraph) read(ids []id.EventID) error { + var missing []id.EventID + seen := make(map[id.EventID]bool, len(ids)) + for _, eventID := range ids { + if _, ok := g.headers[eventID]; !ok && !seen[eventID] { + seen[eventID] = true + missing = append(missing, eventID) + } + } + if len(missing) == 0 { + return nil + } + hs, err := g.load(missing) + for i, h := range hs { + g.headers[missing[i]] = h + } + return err +} + +func (g *AuthGraph) event(eventID id.EventID) (*types.Event, error) { + if ev := g.pending[eventID]; ev != nil { + return ev, nil + } + return g.events.Get(eventID) +} + +func (g *AuthGraph) willGet(ids []id.EventID) { + for _, eventID := range ids { + if _, ok := g.pending[eventID]; !ok { + g.events.WillGet(eventID) + } + } +} + +// Headers returns the events' headers, finalizing in batch order those of pending events that are +// not, labelling them on the chain cover. An event is finalized after what it cites, which must be +// finalized, pending earlier in the batch, or a stored event finalizeCited finalizes ahead of it; +// otherwise Headers fails with ErrAuthEventPending for a pending event and ErrAuthEventNotFinalized +// for anything else. +func (g *AuthGraph) Headers(ids []id.EventID) ([]types.AuthHeader, error) { + if err := g.read(ids); err != nil { + return nil, err + } + var unfinalized []id.EventID + seen := make(map[id.EventID]bool, len(ids)) + for _, eventID := range ids { + if g.headers[eventID].Finalized() || seen[eventID] { + continue + } else if g.events == nil { + return nil, fmt.Errorf("%w: %s, which a graph spanning transactions does not finalize", ErrAuthEventNotFinalized, eventID) + } else if _, pending := g.pending[eventID]; !pending { + return nil, fmt.Errorf("%w: %s", ErrAuthEventNotFinalized, eventID) + } + seen[eventID] = true + unfinalized = append(unfinalized, eventID) + } + if err := g.finalize(unfinalized); err != nil { + return nil, err + } + out := make([]types.AuthHeader, len(ids)) + for i, eventID := range ids { + out[i] = g.headers[eventID] + } + return out, nil +} + +// HeadersInBatches is Headers reading at most headerBatch events per batch of reads. +func (g *AuthGraph) HeadersInBatches(ids []id.EventID) ([]types.AuthHeader, error) { + for part := range slices.Chunk(ids, headerBatch) { + if err := g.do(func() error { + _, err := g.Headers(part) + return err + }); err != nil { + return nil, err + } + } + out := make([]types.AuthHeader, len(ids)) + for i, eventID := range ids { + out[i] = g.headers[eventID] + } + return out, nil +} + +// finalize finalizes pending events in batch order, reading the headers and events of the auth +// events they cite together first. +func (g *AuthGraph) finalize(ids []id.EventID) error { + if len(ids) == 0 { + return nil + } + slices.SortFunc(ids, func(a, b id.EventID) int { return cmp.Compare(g.order[a], g.order[b]) }) + var parents []id.EventID + for _, eventID := range ids { + authIDs := g.headers[eventID].AuthEventIDs + if len(authIDs) > maxAuthEdges { + return fmt.Errorf("too many auth edges for %s", eventID) + } + parents = append(parents, authIDs...) + } + g.willGet(parents) + if err := g.read(parents); err != nil { + return err + } + for _, eventID := range ids { + if err := g.finalizeEvent(eventID, make(map[id.EventID]bool)); err != nil { + return err + } + } + return nil +} + +// finalizeCited finalizes, ahead of the event citing it, a stored event the graph does not hold as +// pending whose header is not finalized but could be, as an outlier's: a state event of the room +// that is not rejected, citing only stored or pending events. Its auth events are read first. A +// pending event is finalized in batch order instead, and an event on a cycle of citations, among +// those finalizing, never. +func (g *AuthGraph) finalizeCited(eventID id.EventID, finalizing map[id.EventID]bool) error { + if _, pending := g.pending[eventID]; pending || g.headers[eventID].Finalized() || finalizing[eventID] { + return nil + } + ev, err := g.events.Get(eventID) + if err != nil { + return err + } + authIDs := g.headers[eventID].AuthEventIDs + if ev == nil || ev.Rejected || ev.StateKey == nil || ev.RoomID != g.roomID || len(authIDs) > maxAuthEdges { + return nil + } + g.willGet(authIDs) + if err := g.read(authIDs); errors.Is(err, errNoAuthHeader) { + return nil + } else if err != nil { + return err + } + return g.finalizeEvent(eventID, finalizing) +} + +func (g *AuthGraph) finalizeEvent(eventID id.EventID, finalizing map[id.EventID]bool) error { + finalizing[eventID] = true + for _, p := range g.headers[eventID].AuthEventIDs { + if err := g.finalizeCited(p, finalizing); err != nil { + return err + } + } + h := g.headers[eventID] + for _, p := range h.AuthEventIDs { + if pending := g.pending[p]; !g.headers[p].Finalized() && pending != nil && !pending.Rejected && !finalizing[p] { + return fmt.Errorf("%w: %s cites %s", ErrAuthEventPending, eventID, p) + } else if !g.headers[p].Finalized() { + return fmt.Errorf("%w: %s cites %s", ErrAuthEventNotFinalized, eventID, p) + } + } + + ev, err := g.event(eventID) + if err != nil { + return err + } + if ev == nil || ev.StateKey == nil || ev.RoomID != g.roomID { + return fmt.Errorf("invalid chain event %s", eventID) + } + parents := make([]*types.Event, len(h.AuthEventIDs)) + headers := make([]types.AuthHeader, len(parents)) + for i, p := range h.AuthEventIDs { + if parents[i], err = g.event(p); err != nil { + return err + } + if parents[i] == nil || parents[i].StateKey == nil || parents[i].RoomID != g.roomID { + return fmt.Errorf("invalid chain parent %s", p) + } + headers[i] = g.headers[p] + } + pos, err := g.assign(ev, parents, headers) + if err != nil { + return err + } + h.Chain, h.Sequence = pos.chain, pos.seq + g.headers[eventID], g.dirty[eventID] = h, h + return nil +} + +func (g *AuthGraph) Write(txn fdb.Transaction) { + g.writeChains(txn) + for eventID, h := range g.dirty { + txn.Set(g.dir.keyForAuthEventIDs(eventID), packAuthHeader(h)) + } +} diff --git a/internal/databases/rooms/events/authgraph_test.go b/internal/databases/rooms/events/authgraph_test.go new file mode 100644 index 0000000..210822c --- /dev/null +++ b/internal/databases/rooms/events/authgraph_test.go @@ -0,0 +1,901 @@ +package events + +import ( + "cmp" + "errors" + "fmt" + "maps" + "math" + "math/rand/v2" + "slices" + "testing" + + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +var errLoadFailed = errors.New("load failed") + +type graphStore struct { + events map[id.EventID]*types.Event + headers map[id.EventID]types.AuthHeader + links map[uint32][]chainLink + positions map[chainPosition]id.EventID + tips map[uint32]uint32 + next uint32 + headerReads, linkReads, positionReads int + headerLoads, positionLoads int + // The most links one load returned + linkLoadRows int + // The next header load fails once it has read this many, unless zero + failAfter int + prefetched map[id.EventID]bool + unprefetchedGets int +} + +func newGraphStore() *graphStore { + return &graphStore{events: make(map[id.EventID]*types.Event), headers: make(map[id.EventID]types.AuthHeader), links: make(map[uint32][]chainLink), positions: make(map[chainPosition]id.EventID), tips: make(map[uint32]uint32), prefetched: make(map[id.EventID]bool)} +} + +func (s *graphStore) WillGet(ids ...id.EventID) { + for _, eventID := range ids { + s.prefetched[eventID] = true + } +} + +func (s *graphStore) Get(eventID id.EventID) (*types.Event, error) { + if !s.prefetched[eventID] { + s.unprefetchedGets++ + } + return s.events[eventID], nil +} + +func (s *graphStore) graph() *AuthGraph { + g := &AuthGraph{headers: make(map[id.EventID]types.AuthHeader), dirty: make(map[id.EventID]types.AuthHeader), pending: make(map[id.EventID]*types.Event), order: make(map[id.EventID]int), roomID: "!room", events: s} + g.do = func(reads func() error) error { return reads() } + g.load = func(ids []id.EventID) ([]types.AuthHeader, error) { + s.headerLoads++ + out := make([]types.AuthHeader, 0, len(ids)) + for _, eventID := range ids { + if s.failAfter > 0 && len(out) == s.failAfter { + s.failAfter = 0 + return out, errLoadFailed + } + s.headerReads++ + h, ok := s.headers[eventID] + if !ok { + ev := s.events[eventID] + if ev == nil { + return out, fmt.Errorf("%w: missing %s", errNoAuthHeader, eventID) + } + h.AuthEventIDs = ev.AuthEventIDs + } + out = append(out, h) + } + return out, nil + } + c := &chainCover{links: make(map[uint32][]chainLink), partialLinks: make(map[uint32][]chainLink), tips: make(map[uint32]uint32), positions: make(map[chainPosition]id.EventID), pendingFrom: make(map[uint32]uint32), addedLinks: make(map[uint32][]chainLink)} + c.loadLinks = func(reads []linkRead) ([][]chainLink, error) { + out := make([][]chainLink, len(reads)) + s.linkReads += len(reads) + rows := 0 + for i, read := range reads { + links := slices.SortedFunc(slices.Values(s.links[read.chain]), compareLinks) + start := 0 + if read.after.seq != 0 { + if start = slices.IndexFunc(links, func(l chainLink) bool { return compareLinks(l, read.after) > 0 }); start < 0 { + start = len(links) + } + } + out[i] = links[start:min(len(links), start+read.limit)] + rows += len(out[i]) + } + s.linkLoadRows = max(s.linkLoadRows, rows) + return out, nil + } + c.loadTip = func(chain uint32) (uint32, error) { return s.tips[chain], nil } + c.allocate = func() (uint32, error) { s.next++; return s.next, nil } + c.loadPositions = func(intervals []chainInterval) ([]id.EventID, error) { + var out []id.EventID + s.positionLoads++ + s.positionReads += len(intervals) + for _, in := range intervals { + for seq := in.first; seq <= in.last; seq++ { + eventID, ok := s.positions[chainPosition{in.chain, seq}] + if !ok { + return nil, fmt.Errorf("missing position") + } + stored, err := unpackChainEntry(packChainEntry(eventID)) + if err != nil { + return nil, err + } + out = append(out, stored) + } + } + return out, nil + } + g.cover = c + return g +} + +func compareLinks(a, b chainLink) int { + return cmp.Or(cmp.Compare(a.seq, b.seq), cmp.Compare(a.target, b.target), cmp.Compare(a.targetSeq, b.targetSeq)) +} + +func (s *graphStore) commit(g *AuthGraph) { + maps.Copy(s.headers, g.dirty) + c := g.cover + maps.Copy(s.positions, c.positions) + maps.Copy(s.tips, c.tips) + for chain, links := range c.addedLinks { + s.links[chain] = append(s.links[chain], links...) + } +} + +func (s *graphStore) add(n, slot int, parents ...id.EventID) id.EventID { + eventID := id.EventID(fmt.Sprintf("$%d", n)) + s.events[eventID] = &types.Event{ID: eventID, RoomVersion: "12", PartialEvent: types.PartialEvent{RoomID: "!room", Type: event.StateMember, StateKey: new(fmt.Sprintf("@user%d:test", slot))}, AuthEventIDs: parents} + return eventID +} + +func (s *graphStore) addCreate() id.EventID { + eventID := id.EventID("$create") + s.events[eventID] = &types.Event{ID: eventID, RoomVersion: "11", PartialEvent: types.PartialEvent{RoomID: "!room", Type: event.StateCreate, StateKey: new("")}} + return eventID +} + +// finalize stores the events, in order, as a staging transaction does. +func (s *graphStore) finalize(t *testing.T, ids ...id.EventID) { + t.Helper() + g := s.graph() + for _, eventID := range ids { + g.Add(s.events[eventID]) + } + _, err := g.Headers(ids) + require.NoError(t, err) + s.commit(g) +} + +func (s *graphStore) ancestors(root id.EventID) map[id.EventID]bool { + seen := make(map[id.EventID]bool) + stack := []id.EventID{root} + for len(stack) > 0 { + at := stack[len(stack)-1] + stack = stack[:len(stack)-1] + for _, p := range s.events[at].AuthEventIDs { + if !seen[p] { + seen[p] = true + stack = append(stack, p) + } + } + } + return seen +} + +func (s *graphStore) subgraph(conflicts []id.EventID) []id.EventID { + out := make(map[id.EventID]bool) + for _, from := range conflicts { + out[from] = true + anc := s.ancestors(from) + for _, to := range conflicts { + if !anc[to] { + continue + } + for at := range anc { + if at == to || s.ancestors(at)[to] { + out[at] = true + } + } + } + } + return slices.Sorted(maps.Keys(out)) +} + +func (s *graphStore) requireSubgraphs(t *testing.T, rng *rand.Rand, ids []id.EventID) { + t.Helper() + for range 100 { + conflicts := []id.EventID{ids[rng.IntN(len(ids))], ids[rng.IntN(len(ids))], ids[rng.IntN(len(ids))]} + got, err := s.graph().ConflictedSubgraph(conflicts) + require.NoError(t, err) + require.Equal(t, s.subgraph(conflicts), got) + } +} + +func TestChainCoverOfRandomGraphs(t *testing.T) { + s := newGraphStore() + rng := rand.New(rand.NewPCG(129, 33)) + var ids []id.EventID + for n := range 180 { + var parents []id.EventID + if n > 0 { + for range 1 + rng.IntN(4) { + p := ids[rng.IntN(n)] + if !slices.Contains(parents, p) { + parents = append(parents, p) + } + } + } + eventID := s.add(n, rng.IntN(12), parents...) + ids = append(ids, eventID) + s.finalize(t, eventID) + require.True(t, s.headers[eventID].Finalized()) + } + s.requireSubgraphs(t, rng, ids) + s.requireAuthDifferences(t, rng, ids, s.graph()) +} + +// Before room version 12 every event but the create event cites it. Any other auth event reaches +// it, so only an event citing the create event alone links to its chain. +func TestChainCoverWhereEveryEventCitesTheCreateEvent(t *testing.T) { + s := newGraphStore() + rng := rand.New(rand.NewPCG(7, 91)) + create := s.addCreate() + s.finalize(t, create) + ids := []id.EventID{create} + alone := 0 + for n := 1; n < 180; n++ { + parents := []id.EventID{create} + for range min(rng.IntN(4), n-1) { + if p := ids[1+rng.IntN(n-1)]; !slices.Contains(parents, p) { + parents = append(parents, p) + } + } + if len(parents) == 1 { + alone++ + } + eventID := s.add(n, rng.IntN(12), parents...) + ids = append(ids, eventID) + s.finalize(t, eventID) + } + createChain := s.headers[create].Chain + linked := 0 + for chain, links := range s.links { + for _, l := range links { + if l.target == createChain { + linked++ + require.Equal(t, []id.EventID{create}, s.events[s.positions[chainPosition{chain, l.seq}]].AuthEventIDs) + } + } + } + require.Equal(t, alone, linked) + s.requireSubgraphs(t, rng, ids) + s.requireAuthDifferences(t, rng, ids, s.graph()) +} + +func TestChainTipForkAndLongHistory(t *testing.T) { + s := newGraphStore() + root := s.add(0, 0) + s.finalize(t, root) + prev := root + for n := 1; n <= 5000; n++ { + next := s.add(n, 0, prev) + s.finalize(t, next) + prev = next + } + fork := s.add(5001, 0, root) + s.finalize(t, fork) + require.NotEqual(t, s.headers[fork].Chain, s.headers[prev].Chain) + require.Equal(t, uint32(5001), s.headers[prev].Sequence) + s.headerReads, s.linkReads, s.positionReads = 0, 0, 0 + conflicts := []id.EventID{prev, fork} + got, err := s.graph().ConflictedSubgraph(conflicts) + require.NoError(t, err) + require.ElementsMatch(t, conflicts, got) + require.Equal(t, 2, s.headerReads) + require.LessOrEqual(t, s.linkReads, 2) + require.LessOrEqual(t, s.positionReads, 2) +} + +func TestConflictedSubgraphReadsPositionsInBatches(t *testing.T) { + s := newGraphStore() + var chain []id.EventID + for seq := uint32(1); seq <= 2*positionBatch+1; seq++ { + eventID := s.add(int(seq), 0) + s.headers[eventID] = types.AuthHeader{Chain: 1, Sequence: seq} + s.positions[chainPosition{1, seq}] = eventID + chain = append(chain, eventID) + } + batches := 0 + g := s.graph() + g.do = func(reads func() error) error { + batches++ + return reads() + } + got, err := g.ConflictedSubgraph([]id.EventID{chain[0], chain[len(chain)-1]}) + require.NoError(t, err) + require.Equal(t, slices.Sorted(slices.Values(chain)), got) + require.Equal(t, 3, s.positionLoads) + require.Equal(t, 5, batches, "one of headers, one of links and three of positions") +} + +func TestChainLinksReadInBatchesOfRows(t *testing.T) { + s := newGraphStore() + n := uint32(2*linkRowBatch + 5) + var first, second []id.EventID + for seq := uint32(1); seq <= n; seq++ { + e, f := s.add(int(seq), 0), s.add(int(n+seq), 1) + s.headers[e] = types.AuthHeader{Chain: 1, Sequence: seq} + s.headers[f] = types.AuthHeader{Chain: 2, Sequence: seq} + s.positions[chainPosition{1, seq}], s.positions[chainPosition{2, seq}] = e, f + s.links[2] = append(s.links[2], chainLink{seq, 1, seq}) + first, second = append(first, e), append(second, f) + } + g := s.graph() + got, err := g.ConflictedSubgraph([]id.EventID{first[0], second[n-1]}) + require.NoError(t, err) + require.Equal(t, slices.Sorted(slices.Values(slices.Concat(first, second))), got) + require.Equal(t, s.links[2], g.cover.links[2]) + require.Empty(t, g.cover.partialLinks) + require.Equal(t, linkRowBatch, s.linkLoadRows) +} + +func TestPositionBatches(t *testing.T) { + require.Empty(t, positionBatches(nil, 4)) + require.Equal(t, [][]chainInterval{ + {{1, 1, 3}, {2, 5, 5}}, + {{2, 6, 9}}, + {{2, 10, 10}, {3, math.MaxUint32 - 4, math.MaxUint32 - 2}}, + {{3, math.MaxUint32 - 1, math.MaxUint32}}, + }, positionBatches([]chainInterval{{1, 1, 3}, {2, 5, 10}, {3, math.MaxUint32 - 4, math.MaxUint32}}, 4)) +} + +// A graph finalizes pending events in batch order, whatever order they are asked for in, reading the +// headers of their auth events in one batch, and fails for an event citing one not finalized. +func TestHeadersFinalizeInBatchOrder(t *testing.T) { + s := newGraphStore() + rng := rand.New(rand.NewPCG(3, 4)) + create := s.addCreate() + s.finalize(t, create) + // Stored without a finalized header, as an outlier is + outlier := s.add(1, 0, create) + s.headers[outlier] = types.AuthHeader{AuthEventIDs: []id.EventID{create}} + var first, second []*types.Event + for n := range 20 { + first = append(first, s.events[s.add(100+n, n, create)]) + } + for n := range 50 { + parents := []id.EventID{create} + for range 3 { + if p := first[rng.IntN(len(first))].ID; !slices.Contains(parents, p) { + parents = append(parents, p) + } + } + second = append(second, s.events[s.add(200+n, 20+n, parents...)]) + } + + g := s.graph() + g.Add(slices.Concat(first, second)...) + _, err := g.Headers(eventIDs(second)) + require.ErrorIs(t, err, ErrAuthEventPending, "the first events are pending but not finalized") + require.Empty(t, g.dirty) + + g = s.graph() + g.Add(slices.Concat(first, second)...) + ids := eventIDs(slices.Concat(first, second)) + rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] }) + s.headerLoads, s.unprefetchedGets = 0, 0 + _, err = g.Headers(ids) + require.NoError(t, err) + require.Equal(t, 2, s.headerLoads, "the events, then the auth events they cite") + require.Zero(t, s.unprefetchedGets) + require.Len(t, g.dirty, len(first)+len(second)) + s.commit(g) + for _, ev := range slices.Concat(first, second) { + require.True(t, s.headers[ev.ID].Finalized()) + } + s.requireSubgraphs(t, rng, slices.Concat([]id.EventID{create}, ids)) + + _, err = s.graph().Headers([]id.EventID{outlier}) + require.ErrorIs(t, err, ErrAuthEventNotFinalized, "an event asked for is finalized only when pending") +} + +// A stored event whose header is not finalized, as an outlier's, is finalized ahead of the batch +// event citing it, with the outliers it cites in turn. One that is rejected, on a cycle or citing an +// event not stored makes the event citing it no accepted state. One citing a pending event not +// finalized yet leaves the event citing it to when that one is. A batch event citing an event not +// stored fails the batch. +func TestHeadersFinalizeCitedOutliersFirst(t *testing.T) { + s := newGraphStore() + create := s.addCreate() + s.finalize(t, create) + outlier := func(n int, parents ...id.EventID) id.EventID { + eventID := s.add(n, n, parents...) + s.headers[eventID] = types.AuthHeader{AuthEventIDs: parents} + return eventID + } + invite := outlier(1, create) + rescind := outlier(2, create, invite) + citing := s.events[s.add(3, 0, create, rescind)] + + g := s.graph() + g.Add(citing) + hs, err := g.Headers([]id.EventID{citing.ID}) + require.NoError(t, err) + require.Len(t, g.dirty, 3, "the event and both outliers") + require.True(t, g.dirty[invite].Finalized() && g.dirty[rescind].Finalized() && hs[0].Finalized()) + s.commit(g) + + g = s.graph() + again := s.events[s.add(4, 0, create, invite)] + g.Add(again) + _, err = g.Headers([]id.EventID{again.ID}) + require.NoError(t, err) + require.Len(t, g.dirty, 1, "an outlier finalized once is stored finalized") + + rejected := outlier(5, create) + s.events[rejected].Rejected = true + cyclic, other := outlier(6, create), outlier(7, create) + s.headers[cyclic] = types.AuthHeader{AuthEventIDs: []id.EventID{create, other}} + s.headers[other] = types.AuthHeader{AuthEventIDs: []id.EventID{create, cyclic}} + unknownAuth := outlier(8, create, "$gone") + for n, cited := range []id.EventID{rejected, cyclic, unknownAuth} { + ev := s.events[s.add(20+n, 0, create, cited)] + g := s.graph() + g.Add(ev) + _, err := g.Headers([]id.EventID{ev.ID}) + require.ErrorIs(t, err, ErrAuthEventNotFinalized, "citing %s", cited) + } + + later := s.events[s.add(30, 30, create)] + citesLater := outlier(31, create, later.ID) + ev := s.events[s.add(32, 0, create, citesLater)] + g = s.graph() + g.Add(ev, later) + _, err = g.Headers([]id.EventID{ev.ID, later.ID}) + require.ErrorIs(t, err, ErrAuthEventPending, "an outlier citing a pending event later in the batch") + g = s.graph() + g.Add(ev, later) + _, err = g.Headers([]id.EventID{ev.ID, later.ID}) + require.ErrorIs(t, err, ErrAuthEventPending, "an event citing a pending event later in the batch") + g = s.graph() + g.Add(later, ev) + _, err = g.Headers([]id.EventID{ev.ID, later.ID}) + require.NoError(t, err, "with the pending event first") + later.Rejected = true + g = s.graph() + g.Add(later, ev) + _, err = g.Headers([]id.EventID{ev.ID}) + require.ErrorIs(t, err, ErrAuthEventNotFinalized, "an outlier citing a rejected pending event") + + missing := s.events[s.add(11, 0, create, "$missing")] + g = s.graph() + g.Add(missing) + _, err = g.Headers([]id.EventID{missing.ID}) + require.ErrorContains(t, err, "missing $missing") +} + +func eventIDs(evs []*types.Event) []id.EventID { + ids := make([]id.EventID, len(evs)) + for i, ev := range evs { + ids[i] = ev.ID + } + return ids +} + +func TestReadKeepsHeadersLoadedBeforeAFailure(t *testing.T) { + s := newGraphStore() + var ids []id.EventID + for n := range 5 { + ids = append(ids, s.add(n, n)) + } + s.finalize(t, ids...) + g := s.graph() + s.headerReads, s.failAfter = 0, 2 + g.do = func(reads func() error) error { + err := reads() + require.ErrorIs(t, err, errLoadFailed) + return reads() + } + hs, err := g.HeadersInBatches(ids) + require.NoError(t, err) + require.Equal(t, 5, s.headerReads, "the second read loads only the three headers left") + for i, h := range hs { + require.Equal(t, s.headers[ids[i]], h) + } +} + +func TestGraphSpanningTransactionsFinalizesNothing(t *testing.T) { + s := newGraphStore() + stored := s.add(0, 0) + s.finalize(t, stored) + pending := s.events[s.add(1, 0, stored)] + g := s.graph() + g.events = nil + g.Add(pending) + _, err := g.Headers([]id.EventID{stored}) + require.NoError(t, err) + _, err = g.Headers([]id.EventID{pending.ID}) + require.ErrorContains(t, err, "not finalized") + require.Empty(t, g.dirty) +} + +// addRandom adds an event of one of four users citing the create event, usually the user's latest +// event too, and up to extra events of other users. +func (s *graphStore) addRandom(rng *rand.Rand, create id.EventID, ids []id.EventID, extra int) *types.Event { + slot := rng.IntN(4) + stateKey := fmt.Sprintf("@user%d:test", slot) + parents := []id.EventID{create} + for i := len(ids) - 1; i > 0; i-- { + if *s.events[ids[i]].StateKey == stateKey { + if rng.IntN(3) > 0 { + parents = append(parents, ids[i]) + } + break + } + } + for range extra { + if len(ids) > 1 { + if p := ids[1+rng.IntN(len(ids)-1)]; !slices.Contains(parents, p) && *s.events[p].StateKey != stateKey { + parents = append(parents, p) + } + } + } + return s.events[s.add(len(s.events), slot, parents...)] +} + +func TestLabellingPendingEventsTogether(t *testing.T) { + for seed := range uint64(40) { + s := newGraphStore() + rng := rand.New(rand.NewPCG(seed, 77)) + create := s.addCreate() + s.finalize(t, create) + ids := []id.EventID{create} + for range 8 { + var pending []*types.Event + for range 1 + rng.IntN(25) { + ev := s.addRandom(rng, create, ids, rng.IntN(3)) + ids = append(ids, ev.ID) + pending = append(pending, ev) + } + g := s.graph() + g.Add(pending...) + pendingIDs := eventIDs(pending) + rng.Shuffle(len(pendingIDs), func(i, j int) { pendingIDs[i], pendingIDs[j] = pendingIDs[j], pendingIDs[i] }) + _, err := g.Headers(pendingIDs) + require.NoError(t, err) + for range 30 { + conflicts := []id.EventID{ids[rng.IntN(len(ids))], ids[rng.IntN(len(ids))], ids[rng.IntN(len(ids))]} + got, err := g.ConflictedSubgraph(conflicts) + require.NoError(t, err) + require.Equal(t, s.subgraph(conflicts), got, "seed %d", seed) + } + s.requireAuthDifferences(t, rng, ids, g) + s.commit(g) + } + s.requireSubgraphs(t, rng, ids) + positions := make(map[chainPosition]bool) + for _, eventID := range ids { + h := s.headers[eventID] + require.False(t, positions[chainPosition{h.Chain, h.Sequence}]) + positions[chainPosition{h.Chain, h.Sequence}] = true + } + } +} + +// A preparation labels its pending events, then staging stores others, extending the same chains and +// allocating the same chain IDs, before a job reads the conflicted subgraph from the preparation's +// graph. +func TestJobAfterConcurrentStaging(t *testing.T) { + collisions := 0 + for seed := range uint64(40) { + s := newGraphStore() + rng := rand.New(rand.NewPCG(seed, 99)) + create := s.addCreate() + s.finalize(t, create) + stored := []id.EventID{create} + addBatch := func(ids []id.EventID) []*types.Event { + var evs []*types.Event + for range 10 { + ev := s.addRandom(rng, create, ids, rng.IntN(2)) + ids = append(ids, ev.ID) + evs = append(evs, ev) + } + return evs + } + for range 3 { + evs := addBatch(stored) + stored = append(stored, eventIDs(evs)...) + s.finalize(t, eventIDs(evs)...) + } + + prepared := addBatch(stored) + allocated := s.next + prep := s.graph() + prep.Add(prepared...) + _, err := prep.Headers(eventIDs(prepared)) + require.NoError(t, err) + + s.next = allocated + staged := addBatch(stored) + staging := s.graph() + staging.Add(staged...) + _, err = staging.Headers(eventIDs(staged)) + require.NoError(t, err) + for pos := range staging.cover.positions { + if _, found := prep.cover.positions[pos]; found { + collisions++ + } + } + s.commit(staging) + + prep.events = nil + ids := slices.Concat(stored, eventIDs(prepared)) + for range 30 { + conflicts := []id.EventID{ids[rng.IntN(len(ids))], ids[rng.IntN(len(ids))], ids[rng.IntN(len(ids))]} + got, err := prep.ConflictedSubgraph(conflicts) + require.NoError(t, err) + require.Equal(t, s.subgraph(conflicts), got, "seed %d", seed) + } + } + require.Positive(t, collisions, "staging took positions the preparation holds") +} + +// authDifference is the model auth difference of state sets: the events in the auth chain of some +// sets but not all, a set's state events counted in its own +func (s *graphStore) authDifference(sets [][]id.EventID) []id.EventID { + counts := make(map[id.EventID]int) + for _, set := range sets { + chain := make(map[id.EventID]bool) + for _, eventID := range set { + chain[eventID] = true + maps.Copy(chain, s.ancestors(eventID)) + } + for eventID := range chain { + counts[eventID]++ + } + } + var difference []id.EventID + for eventID, n := range counts { + if n < len(sets) { + difference = append(difference, eventID) + } + } + slices.Sort(difference) + return difference +} + +func (s *graphStore) requireAuthDifferences(t *testing.T, rng *rand.Rand, ids []id.EventID, g *AuthGraph) { + t.Helper() + for range 30 { + sets := make([][]id.EventID, 2+rng.IntN(2)) + for i := range sets { + for range rng.IntN(6) { + sets[i] = append(sets[i], ids[rng.IntN(len(ids))]) + } + } + got, err := g.AuthChainDifference(sets) + require.NoError(t, err) + require.Equal(t, s.authDifference(sets), got, "sets %v", sets) + } +} + +// diffTestGraph is a small room: the create event, two power levels events, two members' joins and +// profile updates, and a third member's join. +type diffTestGraph struct { + s *graphStore + create, p1, p2, a1, a2, b1, b2, c1 id.EventID +} + +func newDiffTestGraph(t *testing.T) *diffTestGraph { + s := newGraphStore() + d := &diffTestGraph{s: s, create: s.addCreate()} + d.p1 = s.add(1, 1, d.create) + d.a1 = s.add(2, 2, d.create, d.p1) + d.a2 = s.add(3, 2, d.create, d.p1, d.a1) + d.b1 = s.add(4, 3, d.create, d.p1) + d.b2 = s.add(5, 3, d.create, d.p1, d.b1) + d.p2 = s.add(6, 1, d.create, d.p1, d.a2) + d.c1 = s.add(7, 4, d.create, d.p1) + s.finalize(t, d.create, d.p1, d.a1, d.a2, d.b1, d.b2, d.p2, d.c1) + return d +} + +func (d *diffTestGraph) difference(t *testing.T, sets ...[]id.EventID) []id.EventID { + t.Helper() + got, err := d.s.graph().AuthChainDifference(sets) + require.NoError(t, err) + require.Equal(t, d.s.authDifference(sets), got) + return got +} + +func TestAuthChainDifferenceOfIdenticalSetsIsEmpty(t *testing.T) { + d := newDiffTestGraph(t) + state := []id.EventID{d.create, d.p2, d.a2, d.b2} + require.Empty(t, d.difference(t, state, slices.Clone(state))) + require.Empty(t, d.difference(t, state, []id.EventID{d.b2, d.a2, d.p2, d.create}, state), "whatever the order") +} + +func TestAuthChainDifferenceOfAChangedMemberIsItsChainAboveTheCommonReach(t *testing.T) { + d := newDiffTestGraph(t) + require.Equal(t, d.s.headers[d.b1].Chain, d.s.headers[d.b2].Chain, "Bob's join and profile update are one chain") + require.Equal(t, []id.EventID{d.b2}, d.difference(t, + []id.EventID{d.create, d.p1, d.a1, d.b1}, + []id.EventID{d.create, d.p1, d.a1, d.b2}, + ), "Bob's join is in both, below his profile update") +} + +func TestAuthChainDifferenceFollowsLinksToOtherChains(t *testing.T) { + d := newDiffTestGraph(t) + require.NotEqual(t, d.s.headers[d.p2].Chain, d.s.headers[d.a2].Chain) + require.ElementsMatch(t, []id.EventID{d.p2, d.a2}, d.difference(t, + []id.EventID{d.create, d.p1, d.a1}, + []id.EventID{d.create, d.p2, d.a1}, + ), "the second power levels cite Alice's profile update, which neither state holds") +} + +func TestAuthChainDifferenceHoldsAChainOnlyOneSetReaches(t *testing.T) { + d := newDiffTestGraph(t) + require.ElementsMatch(t, []id.EventID{d.b1, d.b2}, d.difference(t, + []id.EventID{d.create, d.p1}, + []id.EventID{d.create, d.p1, d.b2}, + ), "the whole prefix of Bob's chain") + require.ElementsMatch(t, []id.EventID{d.b1, d.b2, d.c1}, d.difference(t, + []id.EventID{d.create, d.p1, d.c1}, + []id.EventID{d.create, d.p1, d.b2}, + []id.EventID{d.create, d.p1, d.b2, d.c1}, + )) + require.ElementsMatch(t, []id.EventID{d.create, d.p1, d.a1}, d.difference(t, nil, []id.EventID{d.a1}), "an empty state") +} + +func TestAuthChainDifferenceReadsInBatches(t *testing.T) { + d := newDiffTestGraph(t) + d.s.headerLoads, d.s.positionLoads = 0, 0 + batches := 0 + g := d.s.graph() + g.do = func(reads func() error) error { + batches++ + return reads() + } + got, err := g.AuthChainDifference([][]id.EventID{{d.create, d.p1, d.a1}, {d.create, d.p2, d.a1}}) + require.NoError(t, err) + require.ElementsMatch(t, []id.EventID{d.p2, d.a2}, got) + require.Equal(t, 1, d.s.headerLoads) + require.Equal(t, 1, d.s.positionLoads) + require.Equal(t, 3, batches, "one of headers, one of links and one of positions") + + unfinalized := d.s.add(8, 5, d.create) + _, err = d.s.graph().AuthChainDifference([][]id.EventID{{d.create}, {unfinalized}}) + require.ErrorIs(t, err, ErrAuthEventNotFinalized) +} + +// A preparation's graph holds the positions it assigned above the stored tips, where staging may store +// other events before a job reads them. The auth difference of states holding the preparation's +// events is exact, and holds no event staging stored since. +func TestAuthChainDifferenceOfPendingEventsAfterConcurrentStaging(t *testing.T) { + collisions := 0 + for seed := range uint64(30) { + s := newGraphStore() + rng := rand.New(rand.NewPCG(seed, 31)) + create := s.addCreate() + s.finalize(t, create) + stored := []id.EventID{create} + addBatch := func(ids []id.EventID) []*types.Event { + var evs []*types.Event + for range 15 { + ev := s.addRandom(rng, create, ids, rng.IntN(3)) + ids = append(ids, ev.ID) + evs = append(evs, ev) + } + return evs + } + for range 4 { + evs := addBatch(stored) + stored = append(stored, eventIDs(evs)...) + s.finalize(t, eventIDs(evs)...) + } + + prepared := addBatch(stored) + allocated := s.next + prep := s.graph() + prep.Add(prepared...) + _, err := prep.Headers(eventIDs(prepared)) + require.NoError(t, err) + s.next = allocated + staged := addBatch(stored) + staging := s.graph() + staging.Add(staged...) + _, err = staging.Headers(eventIDs(staged)) + require.NoError(t, err) + for pos := range staging.cover.positions { + if _, found := prep.cover.positions[pos]; found { + collisions++ + } + } + s.commit(staging) + + prep.events = nil + s.requireAuthDifferences(t, rng, slices.Concat(stored, eventIDs(prepared)), prep) + for chain, from := range prep.cover.pendingFrom { + loads := s.positionLoads + _, err := prep.chainEvents([]chainInterval{{chain, from, prep.cover.tips[chain]}}) + require.NoError(t, err) + require.Equal(t, loads, s.positionLoads, "positions the graph holds are not read") + } + } + require.Positive(t, collisions, "staging took positions the preparation holds") +} + +func TestAuthHeaderCodecAndIncompleteGraph(t *testing.T) { + finalized := types.AuthHeader{AuthEventIDs: []id.EventID{"$a", "$b"}, Chain: 7, Sequence: 22} + for _, h := range []types.AuthHeader{{}, {AuthEventIDs: []id.EventID{"$a"}}, finalized} { + got, err := unpackAuthHeader(packAuthHeader(h)) + require.NoError(t, err) + require.Equal(t, h, got) + } + for _, raw := range [][]byte{ + tuple.Tuple{"$a", "$b"}.Pack(), + tuple.Tuple{int64(8)}.Pack(), + tuple.Tuple{}.Pack(), + tuple.Tuple{int64(2), int64(0), int64(0), tuple.Tuple{}}.Pack(), + tuple.Tuple{int64(1), int64(0), int64(0), tuple.Tuple{}, tuple.Tuple{}}.Pack(), + tuple.Tuple{int64(1), int64(1), int64(0), tuple.Tuple{}}.Pack(), + tuple.Tuple{int64(1), int64(0), int64(1), tuple.Tuple{}}.Pack(), + tuple.Tuple{int64(1), int64(-1), int64(1), tuple.Tuple{}}.Pack(), + tuple.Tuple{int64(1), int64(math.MaxUint32 + 1), int64(1), tuple.Tuple{}}.Pack(), + tuple.Tuple{int64(1), int64(0), int64(0), tuple.Tuple{""}}.Pack(), + tuple.Tuple{int64(1), int64(0), int64(0), tuple.Tuple{int64(1)}}.Pack(), + } { + _, err := unpackAuthHeader(raw) + require.Error(t, err) + } + + got, err := unpackChainEntry(packChainEntry("$c")) + require.NoError(t, err) + require.Equal(t, id.EventID("$c"), got) + for _, raw := range [][]byte{ + []byte("$c"), + tuple.Tuple{}.Pack(), + tuple.Tuple{""}.Pack(), + tuple.Tuple{int64(19)}.Pack(), + tuple.Tuple{"$c", int64(19)}.Pack(), + } { + _, err := unpackChainEntry(raw) + require.Error(t, err) + } + // A header rejected at step 4 for too many auth events stays readable, but cannot be finalized. + tooMany := make([]id.EventID, maxAuthEdges+1) + for i := range tooMany { + tooMany[i] = id.EventID(fmt.Sprintf("$auth%d", i)) + } + rejected, err := unpackAuthHeader(packAuthHeader(types.AuthHeader{AuthEventIDs: tooMany})) + require.NoError(t, err) + require.Equal(t, tooMany, rejected.AuthEventIDs) + _, err = unpackAuthHeader(packAuthHeader(types.AuthHeader{AuthEventIDs: tooMany, Chain: 1, Sequence: 1})) + require.Error(t, err) + + s := newGraphStore() + pending := func(ids ...id.EventID) *AuthGraph { + g := s.graph() + for _, eventID := range ids { + g.Add(s.events[eventID]) + } + return g + } + a := s.add(0, 0, "$missing") + _, err = pending(a).Headers([]id.EventID{a}) + require.ErrorContains(t, err, "missing $missing") + s.events[a].AuthEventIDs = []id.EventID{a} + _, err = pending(a).Headers([]id.EventID{a}) + require.ErrorIs(t, err, ErrAuthEventNotFinalized, "an event citing itself") + b, c := s.add(1, 1, a), s.add(2, 2) + s.events[a].AuthEventIDs = []id.EventID{b} + s.events[c].AuthEventIDs = []id.EventID{a} + _, err = pending(a, b, c).Headers([]id.EventID{a, b, c}) + require.ErrorIs(t, err, ErrAuthEventPending, "a cycle cites an event later in the batch") + + s.events[a].AuthEventIDs = nil + g := pending(a) + hs, err := g.Headers([]id.EventID{a}) + require.NoError(t, err) + require.True(t, hs[0].Finalized()) + s.commit(g) + g = s.graph() + again, err := g.Headers([]id.EventID{a}) + require.NoError(t, err) + require.Equal(t, hs, again) + require.Empty(t, g.dirty) +} diff --git a/internal/databases/rooms/events/events.go b/internal/databases/rooms/events/events.go index 52a3758..ca386fd 100644 --- a/internal/databases/rooms/events/events.go +++ b/internal/databases/rooms/events/events.go @@ -25,6 +25,29 @@ type EventsDirectory struct { // value: types.Event idToEvent subspace.Subspace + // Auth event IDs by event ID, written with every stored event so auth chains are walked + // without loading full events, with the event's chain position once finalized, 0 until then + // + // key: EventID + // value: (1, Chain, Sequence, (AuthEventID, ...)) + idToAuthEventIDs subspace.Subspace + + // Chain cover positions, in every room version + // + // key: (RoomID, Chain, Sequence) + // value: (EventID) + authChainPositions subspace.Subspace + // Chain cover links from a position to the position of an auth event of its event on another chain + // + // key: (RoomID, Chain, Sequence, TargetChain, TargetSequence) + // value: empty + authChainLinks subspace.Subspace + // The room's chain ID allocator + // + // key: RoomID + // value: last allocated chain ID + authChainNext subspace.Subspace + // Ordered EventTups by version, for paginating all events over all rooms. Used by internal // services for things like profile update propagation. // @@ -32,8 +55,9 @@ type EventsDirectory struct { // value: types.EventTup versionToTup subspace.Subspace - // Event ID to `tuple.Versionstamp`. Allows us to get the version of an event so we can lookup state - // at that point in time. + // Event ID to `tuple.Versionstamp`, the version the event was stored at. Tells whether an event + // is stored and places receipts and thread roots in the timeline. Every stored room event has + // its own version, rejected and soft failed events included, outliers have none. // // key: EventID // value: Versionstamp @@ -52,17 +76,13 @@ type EventsDirectory struct { // value: types.EventTup localRoomVersionToTup subspace.Subspace - // Ordered `EventStateTup`s by room/version, for pagination of room state changes. - // - // - pull everything <= by versionstamp, latest (type, state_key) pairs win - // - means iterating over duplicates, but alternative is to snapshot state at each change which - // is extremely space wasteful. - // - "get state changes between X and Y events in this room" - // (S2S API needs MSC, see [this synapse issue](https://github.com/matrix-org/synapse/issues/13618)) + // The room's current state context by room/version, one row per step moving it, at the version + // of the event making the step. The latest row at or before a version is the room's current + // state as of that version, for syncing state changes. // // key: (RoomID, tuple.Versionstamp) - // value: types.EventStateTup - roomStateVersionToTup subspace.Subspace + // value: (ContextID) + roomVersionToState subspace.Subspace // Current room extremeties, keys only roomID/eventID. We range over them to pull the current // DAG extremeties before clearing when writing events (and setting the written event). @@ -74,52 +94,6 @@ type EventsDirectory struct { // value: empty roomExtremIDs subspace.Subspace - // Room ID to tuple of information from the last time state resolution was performed on the room - // ie (versionstamp, eventID1, eventID2, ...). We combine this with the list of current extrem - // IDs to identify whether we need to re-resolve the room again (if multiple forks have written - // state changes since last resolve). - // - // key: RoomID - // value: []id.EventID - idToLastResolvedData subspace.Subspace - - // State events by room/type/version, for paginating the history of a single state event. This - // allows us to fetch state for a given room/type at a point in time, for authorizing events. - // - // - "get m.room.power_levels at version X" - // - // key: (RoomID, EventType, StateKey, Versionstamp) - // value: EventID - roomVersionToIDStateTup subspace.Subspace - - // Current state event by room/type for quick lookups and event auth. Also allows fetching the - // entire room state quickly (without members) as all the keys are stored together. - // - // key: (RoomID, EventType, StateKey) - // value: EventID - currentRoomStateToID subspace.Subspace - - // Current memberships by room/user, quick lookups of specific user memberships in a room and - // quick pagination of all members in a room by keeping the keys together. - // - // key: (RoomID, EventType, StateKey) - // value: types.MembershipTup - currentRoomMemberships subspace.Subspace - - // Current servers by room, used to ensure we're running federation senders for each one when - // the room receives updates. - // - // key: (RoomID, EventType, StateKey) - // value: types.MembershipTup - currentRoomServers subspace.Subspace - - // Per server history of membership changes for the room. Allows us to determine a servers - // membership at a given version. - // - // key: (id.RoomID, ServerName, tuple.Versionstamp) - // value: types.MembershipTup - serverMembershipChanges subspace.Subspace - // RoomID/related-event-ID/version to (eventID, relType) // // - paginate events relating to this event @@ -162,35 +136,70 @@ func NewEventsDirectory(logger zerolog.Logger, db fdb.Database, parentDir direct // Init data model subspaces, subspace prefixes are intentionally short // "When using the tuple layer to encode keys (as is recommended), select short strings or small integers for tuple elements." // https://apple.github.io/foundationdb/data-modeling.html#key-and-value-sizes - idToEvent: eventsDir.Sub("eid"), - versionToTup: eventsDir.Sub("evr"), - localRoomVersionToTup: eventsDir.Sub("elv"), - idToVersion: eventsDir.Sub("etv"), - roomVersionToTup: eventsDir.Sub("rmv"), - roomStateVersionToTup: eventsDir.Sub("rsv"), - roomExtremIDs: eventsDir.Sub("rex"), - idToLastResolvedData: eventsDir.Sub("lre"), - roomVersionToIDStateTup: eventsDir.Sub("rvs"), - currentRoomStateToID: eventsDir.Sub("rcs"), - currentRoomMemberships: eventsDir.Sub("rmb"), - currentRoomServers: eventsDir.Sub("rsr"), - serverMembershipChanges: eventsDir.Sub("smc"), - byRoomRelation: eventsDir.Sub("rel"), - byRoomReaction: eventsDir.Sub("rea"), - roomThreadVersionToID: eventsDir.Sub("rth"), + idToEvent: eventsDir.Sub("eid"), + idToAuthEventIDs: eventsDir.Sub("eah"), + authChainPositions: eventsDir.Sub("acp"), + authChainLinks: eventsDir.Sub("acl"), + authChainNext: eventsDir.Sub("acn"), + versionToTup: eventsDir.Sub("evr"), + localRoomVersionToTup: eventsDir.Sub("elv"), + idToVersion: eventsDir.Sub("etv"), + roomVersionToTup: eventsDir.Sub("rmv"), + roomVersionToState: eventsDir.Sub("rch"), + roomExtremIDs: eventsDir.Sub("rex"), + byRoomRelation: eventsDir.Sub("rel"), + byRoomReaction: eventsDir.Sub("rea"), + roomThreadVersionToID: eventsDir.Sub("rth"), } } -func (e *EventsDirectory) keyForEventID(eventID id.EventID) fdb.Key { +func (e *EventsDirectory) KeyForEventID(eventID id.EventID) fdb.Key { return e.idToEvent.Pack(tuple.Tuple{eventID.String()}) } -func (e *EventsDirectory) TxnStoreEvent(txn fdb.Transaction, ev *types.Event) { - txn.Set(e.keyForEventID(ev.ID), ev.ToMsgpack()) +func (e *EventsDirectory) keyForAuthEventIDs(eventID id.EventID) fdb.Key { + return e.idToAuthEventIDs.Pack(tuple.Tuple{eventID.String()}) +} + +// TxnStoreEventRows writes events stored for the first time and returns the version this transaction +// gives each: its body, an unfinalized auth header, its version unless it is an outlier, and with +// indexed its entry in the index of all events. A member event stored before as an outlier keeps its +// auth header, which an auth graph may have finalized as another event's auth event. +func (e *EventsDirectory) TxnStoreEventRows(txn fdb.Transaction, indexed bool, evs ...*types.Event) map[id.EventID]tuple.Versionstamp { + storedHeaders := make(map[id.EventID]fdb.FutureByteSlice) + for _, ev := range evs { + if ev.Type == event.StateMember { + storedHeaders[ev.ID] = txn.Get(e.keyForAuthEventIDs(ev.ID)) + } + } + versions := make(map[id.EventID]tuple.Versionstamp, len(evs)) + for i, ev := range evs { + version := tuple.IncompleteVersionstamp(uint16(i)) + versions[ev.ID] = version + txn.Set(e.KeyForEventID(ev.ID), ev.ToMsgpack()) + if header, found := storedHeaders[ev.ID]; !found || len(header.MustGet()) == 0 { + txn.Set(e.keyForAuthEventIDs(ev.ID), packAuthHeader(types.AuthHeader{AuthEventIDs: ev.AuthEventIDs})) + } + if indexed { + txn.SetVersionstampedKey(e.KeyForVersion(version), types.EventTupToBytes(ev.EventTup())) + } + if !ev.Outlier { + txn.SetVersionstampedValue(e.KeyForIDToVersion(ev.ID), types.MustVersionstampToBytes(version)) + } + } + return versions +} + +// TxnAdoptEvents rewrites stored events with the outcome a remote join gives them, keeping the auth +// header stored for each. +func (e *EventsDirectory) TxnAdoptEvents(txn fdb.Transaction, evs ...*types.Event) { + for _, ev := range evs { + txn.Set(e.KeyForEventID(ev.ID), ev.ToMsgpack()) + } } func (e *EventsDirectory) TxnGetEvent(txn fdb.ReadTransaction, id id.EventID) *types.Event { - b := txn.Get(e.keyForEventID(id)).MustGet() + b := txn.Get(e.KeyForEventID(id)).MustGet() if b == nil { return nil } @@ -201,6 +210,17 @@ func (e *EventsDirectory) KeyForIDToVersion(eventID id.EventID) fdb.Key { return e.idToVersion.Pack(tuple.Tuple{eventID.String()}) } +func (e *EventsDirectory) TxnLookupVersionForEventID( + txn fdb.ReadTransaction, + eventID id.EventID, +) tuple.Versionstamp { + b := txn.Get(e.KeyForIDToVersion(eventID)).MustGet() + if b == nil { + return types.ZeroVersionstamp + } + return types.MustBytesToVersionstamp(b) +} + // Room version indices // @@ -271,88 +291,6 @@ func (e *EventsDirectory) rangeForLocalRoomVersion( return types.GetVersionRange(e.localRoomVersionToTup, fromVersion, toVersion, roomID.String()) } -// Room state versions (room_id, versionstamp) -> (event_id, type, state_key) -// - -func (e *EventsDirectory) KeyToRoomStateVersion(key fdb.Key) tuple.Versionstamp { - tup, _ := e.roomStateVersionToTup.Unpack(key) - return tup[1].(tuple.Versionstamp) -} - -func (e *EventsDirectory) KeyForRoomStateVersion(roomID id.RoomID, version tuple.Versionstamp) fdb.Key { - return types.MustPackVersionKey(e.roomStateVersionToTup, tuple.Tuple{roomID.String(), version}) -} - -func (e *EventsDirectory) RangeForRoomStateVersion( - roomID id.RoomID, - fromVersion, toVersion tuple.Versionstamp, -) fdb.Range { - return types.GetVersionRange(e.roomStateVersionToTup, fromVersion, toVersion, roomID.String()) -} - -// Room member (room_id, user_id) -> (event_id, membership) -// - -func (e *EventsDirectory) KeyForCurrentRoomMember(roomID id.RoomID, userID id.UserID) fdb.Key { - return e.currentRoomMemberships.Pack(tuple.Tuple{roomID.String(), userID.String()}) -} - -func (e *EventsDirectory) CurrentRoomMemberKeyValueToTups(kv fdb.KeyValue) (types.EventStateTup, types.MembershipTup) { - tup, _ := e.currentRoomMemberships.Unpack(kv.Key) - membershipTup := types.BytesToMembershipTup(kv.Value) - - return types.EventStateTup{ - EventID: membershipTup.EventID, - StateTup: types.StateTup{ - Type: event.StateMember, - StateKey: tup[1].(string), - }, - }, membershipTup -} - -func (e *EventsDirectory) RangeForCurrentRoomMembers(roomID id.RoomID) fdb.Range { - return e.currentRoomMemberships.Sub(roomID.String()) -} - -// Room server (room_id, server_name) -> MembershipTup -// - -func (e *EventsDirectory) KeyForCurrentRoomServer(roomID id.RoomID, serverName string) fdb.Key { - return e.currentRoomServers.Pack(tuple.Tuple{roomID.String(), serverName}) -} - -func (e *EventsDirectory) currentRoomServerKeyToServer(key fdb.Key) string { - tup, _ := e.currentRoomServers.Unpack(key) - return tup[1].(string) -} - -func (e *EventsDirectory) rangeForCurrentRoomServers(roomID id.RoomID) fdb.Range { - return e.currentRoomServers.Sub(roomID.String()) -} - -func (e *EventsDirectory) TxnStoreServerMembership( - txn fdb.Transaction, - roomID id.RoomID, - serverName string, - mtup types.MembershipTup, - version tuple.Versionstamp, -) { - mtupBytes := types.MembershipTupToBytes(mtup) - - key := e.KeyForCurrentRoomServer(roomID, serverName) - if mtup.Membership == event.MembershipJoin { - txn.Set(key, mtupBytes) - } else { - txn.Clear(key) - } - - key, err := e.serverMembershipChanges.PackWithVersionstamp(tuple.Tuple{roomID.String(), serverName, version}) - if err != nil { - panic(err) - } - txn.Set(key, mtupBytes) -} - // Room extremeties (room_id, event_id) -> '' // @@ -370,58 +308,6 @@ func (e *EventsDirectory) rangeForRoomExtrems(roomID id.RoomID) fdb.ExactRange { // return e.roomExtremIDs } -// Room last resolution event IDs -func (e *EventsDirectory) KeyForRoomLastResolvedExtrems(roomID id.RoomID) fdb.Key { - return e.idToLastResolvedData.Pack(tuple.Tuple{roomID.String(), "ids"}) -} - -// Room last resolution version -func (e *EventsDirectory) KeyForRoomLastResolvedVersion(roomID id.RoomID) fdb.Key { - return e.idToLastResolvedData.Pack(tuple.Tuple{roomID.String(), "version"}) -} - -// Room current state tups (room_id, type, state_key) -> event_id -// - -func (e *EventsDirectory) KeyForRoomCurrentStateTup(roomID id.RoomID, evType event.Type, stateKey string) fdb.Key { - return e.currentRoomStateToID.Pack(tuple.Tuple{ - roomID.String(), evType.String(), stateKey, - }) -} - -func (e *EventsDirectory) CurrentRoomStateKeyValueToStateTup(kv fdb.KeyValue) types.EventStateTup { - tup, _ := e.currentRoomStateToID.Unpack(kv.Key) - return types.EventStateTup{ - EventID: id.EventID(kv.Value), - StateTup: types.StateTup{ - Type: event.NewEventType(tup[1].(string)), - StateKey: tup[2].(string), - }, - } -} - -func (e *EventsDirectory) RangeForRoomCurrentState(roomID id.RoomID) fdb.Range { - return e.currentRoomStateToID.Sub(roomID.String()) -} - -// Room version state tups -// - -func (e *EventsDirectory) rangeForRoomVersionStateTup(roomID id.RoomID, evType event.Type, stateKey string, version tuple.Versionstamp) fdb.Range { - return fdb.KeyRange{ - Begin: e.roomVersionToIDStateTup.Pack(tuple.Tuple{ - roomID.String(), evType.String(), stateKey, - }), - End: e.roomVersionToIDStateTup.Pack(tuple.Tuple{ - roomID.String(), evType.String(), stateKey, version, - }), - } -} - -func (e *EventsDirectory) KeyForRoomStateTupVersion(roomID id.RoomID, evType event.Type, stateKey string, version tuple.Versionstamp) fdb.Key { - return types.MustPackVersionKey(e.roomVersionToIDStateTup, tuple.Tuple{roomID.String(), evType.String(), stateKey, version}) -} - // Room relations/reactions/threads // @@ -440,5 +326,5 @@ func (e *EventsDirectory) KeyForRoomReaction(roomID id.RoomID, relEvID id.EventI } func (e *EventsDirectory) KeyForRoomThread(roomID id.RoomID, version tuple.Versionstamp) fdb.Key { - return e.roomThreadVersionToID.Pack(tuple.Tuple{roomID.String(), version}) + return types.MustPackVersionKey(e.roomThreadVersionToID, tuple.Tuple{roomID.String(), version}) } diff --git a/internal/databases/rooms/events/extremities.go b/internal/databases/rooms/events/extremities.go new file mode 100644 index 0000000..b07ca9a --- /dev/null +++ b/internal/databases/rooms/events/extremities.go @@ -0,0 +1,42 @@ +package events + +import ( + "github.com/apple/foundationdb/bindings/go/src/fdb" + "maunium.net/go/mautrix/id" +) + +// Room extremeties +// + +func (e *EventsDirectory) TxnDeleteRoomExtremEventID(txn fdb.Transaction, roomID id.RoomID, eventID id.EventID) { + txn.Clear(e.keyForRoomExtrem(roomID, eventID)) +} + +func (e *EventsDirectory) TxnSetRoomExtremEventID(txn fdb.Transaction, roomID id.RoomID, eventID id.EventID) { + txn.Set(e.keyForRoomExtrem(roomID, eventID), []byte{}) +} + +func (e *EventsDirectory) TxnResetRoomExtremEventIDs(txn fdb.Transaction, roomID id.RoomID, eventID id.EventID) { + txn.ClearRange(e.rangeForRoomExtrems(roomID)) + e.TxnSetRoomExtremEventID(txn, roomID, eventID) +} + +// Lookup current last event IDs for a room - note we do not start fetching the +// events as we only need the IDs. +func (e *EventsDirectory) TxnLookupCurrentRoomExtremEventIDs( + txn fdb.ReadTransaction, + roomID id.RoomID, +) []id.EventID { + iter := txn.GetRange( + e.rangeForRoomExtrems(roomID), + fdb.RangeOptions{ + Mode: fdb.StreamingModeWantAll, + }, + ).Iterator() + ids := make([]id.EventID, 0, 1) + for iter.Advance() { + kv := iter.MustGet() + ids = append(ids, e.roomExtremKeyToEventID(kv.Key)) + } + return ids +} diff --git a/internal/databases/rooms/events/pagination.go b/internal/databases/rooms/events/pagination.go index 9678466..5d68fd9 100644 --- a/internal/databases/rooms/events/pagination.go +++ b/internal/databases/rooms/events/pagination.go @@ -1,13 +1,8 @@ package events import ( - "context" - "slices" - "github.com/apple/foundationdb/bindings/go/src/fdb" "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" - "github.com/rs/zerolog" - "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" "github.com/beeper/babbleserv/internal/types" @@ -41,42 +36,11 @@ func (e *EventsDirectory) TxnPaginateAllEventTups( return evIDs } -func (e *EventsDirectory) TxnPaginateRoomStateEventTups( - txn fdb.ReadTransaction, - roomID id.RoomID, - options types.PaginationOptions, - eventsProvider *TxnEventsProvider, -) []types.EventStateTupWithVersion { - iter := txn.GetRange( - e.RangeForRoomStateVersion(roomID, options.From, options.To), - options.RangeOptions(), - ).Iterator() - - evIDs := make([]types.EventStateTupWithVersion, 0, options.Limit) - - for iter.Advance() { - kv := iter.MustGet() - version := e.KeyToRoomStateVersion(kv.Key) - stateTup := types.BytesToEventStateTup(kv.Value) - tup := types.EventStateTupWithVersion{ - Version: version, - EventStateTup: stateTup, - } - evIDs = append(evIDs, tup) - if eventsProvider != nil { - eventsProvider.WillGet(tup.EventID) - } - } - - return evIDs -} - func (e *EventsDirectory) TxnPaginateRoomEventTups( txn fdb.ReadTransaction, roomID id.RoomID, options types.PaginationOptions, eventsProvider *TxnEventsProvider, - filter *mautrix.FilterPart, ) []types.EventTupWithVersion { return e.txnPaginateRoomEventIDs( txn, @@ -84,7 +48,6 @@ func (e *EventsDirectory) TxnPaginateRoomEventTups( e.rangeForRoomVersion(roomID, options.From, options.To), e.KeyToRoomVersion, eventsProvider, - filter, ) } @@ -93,7 +56,6 @@ func (e *EventsDirectory) TxnPaginateLocalRoomEventTups( roomID id.RoomID, options types.PaginationOptions, eventsProvider *TxnEventsProvider, - filter *mautrix.FilterPart, ) []types.EventTupWithVersion { return e.txnPaginateRoomEventIDs( txn, @@ -101,7 +63,6 @@ func (e *EventsDirectory) TxnPaginateLocalRoomEventTups( e.rangeForLocalRoomVersion(roomID, options.From, options.To), e.KeyToLocalRoomVersion, eventsProvider, - filter, ) } @@ -111,33 +72,17 @@ func (e *EventsDirectory) txnPaginateRoomEventIDs( keyRange fdb.Range, keyToVersion func(fdb.Key) tuple.Versionstamp, eventsProvider *TxnEventsProvider, - filter *mautrix.FilterPart, ) []types.EventTupWithVersion { iter := txn.GetRange(keyRange, options.RangeOptions()).Iterator() evIDs := make([]types.EventTupWithVersion, 0, options.Limit) for iter.Advance() { kv := iter.MustGet() - version := keyToVersion(kv.Key) - tup := types.BytesToEventTup(kv.Value) - - if filter != nil && - (len(filter.Types) > 0 && !slices.Contains(filter.Types, tup.Type) || - len(filter.Senders) > 0 && !slices.Contains(filter.Senders, tup.Sender) || - len(filter.NotTypes) > 0 && slices.Contains(filter.NotTypes, tup.Type) || - len(filter.NotSenders) > 0 && slices.Contains(filter.NotSenders, tup.Sender)) { - zerolog.Ctx(context.TODO()).Trace(). - Any("tup", tup). - Any("filter", filter). - Msg("Filtered out pagination tup") - continue + tup := types.EventTupWithVersion{ + EventTup: types.BytesToEventTup(kv.Value), + Version: keyToVersion(kv.Key), } - - evIDs = append(evIDs, types.EventTupWithVersion{ - EventTup: tup, - Version: version, - }) - + evIDs = append(evIDs, tup) if eventsProvider != nil { eventsProvider.WillGet(tup.EventID) } diff --git a/internal/databases/rooms/events/providers.go b/internal/databases/rooms/events/providers.go index d2fca6f..9f1069c 100644 --- a/internal/databases/rooms/events/providers.go +++ b/internal/databases/rooms/events/providers.go @@ -2,20 +2,21 @@ package events import ( "context" + "errors" + "fmt" "runtime" "slices" - "strings" "github.com/apple/foundationdb/bindings/go/src/fdb" "github.com/apple/foundationdb/bindings/go/src/fdb/subspace" "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" "github.com/matrix-org/gomatrixserverlib" - "github.com/matrix-org/gomatrixserverlib/spec" "github.com/rs/zerolog" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" ) // TxnEventsProvider is a per-txn singleton that handles pulling event objects from FoundationDB. @@ -31,6 +32,8 @@ type TxnEventsProvider struct { futures map[id.EventID]fdb.FutureByteSlice events map[id.EventID]*types.Event + // Events whose read found nothing in this transaction + missing map[id.EventID]struct{} } func (e *EventsDirectory) NewTxnEventsProvider(ctx context.Context, txn fdb.ReadTransaction) *TxnEventsProvider { @@ -45,6 +48,7 @@ func (e *EventsDirectory) NewTxnEventsProvider(ctx context.Context, txn fdb.Read byID: e.idToEvent, futures: make(map[id.EventID]fdb.FutureByteSlice, 10), events: make(map[id.EventID]*types.Event, 10), + missing: make(map[id.EventID]struct{}), } runtime.SetFinalizer(provider, func(ep *TxnEventsProvider) { @@ -89,20 +93,36 @@ func (ep *TxnEventsProvider) WithProviderEvents(providers ...*TxnEventsProvider) return ep } +// Settle waits for every read started, so a provider for a later transaction takes the events read +// with WithProviderEvents once this provider's transaction is too old to read with. A read that +// failed is dropped, to start again there. +func (ep *TxnEventsProvider) Settle() { + for eventID, fut := range ep.futures { + if _, err := fut.Get(); err != nil { + delete(ep.futures, eventID) + } + } +} + func (ep *TxnEventsProvider) keyForEventID(eventID id.EventID) fdb.Key { return ep.byID.Pack(tuple.Tuple{eventID.String()}) } -func (ep *TxnEventsProvider) WillGet(eventID id.EventID) { - if _, found := ep.events[eventID]; found { - return - } - if _, found := ep.futures[eventID]; found { - return +func (ep *TxnEventsProvider) WillGet(eventIDs ...id.EventID) { + for _, eventID := range eventIDs { + if _, found := ep.events[eventID]; found { + continue + } + if _, found := ep.futures[eventID]; found { + continue + } + if _, found := ep.missing[eventID]; found { + continue + } + fut := ep.txn.Get(ep.keyForEventID(eventID)) + ep.futures[eventID] = fut + ep.log.Trace().Str("event_id", eventID.String()).Msg("Will get event") } - fut := ep.txn.Get(ep.keyForEventID(eventID)) - ep.futures[eventID] = fut - ep.log.Trace().Str("event_id", eventID.String()).Msg("Will get event") } func (ep *TxnEventsProvider) Add(ev *types.Event) { @@ -116,6 +136,8 @@ func (ep *TxnEventsProvider) Add(ev *types.Event) { func (ep *TxnEventsProvider) Get(eventID id.EventID) (*types.Event, error) { if ev, found := ep.events[eventID]; found { return ev, nil + } else if _, found := ep.missing[eventID]; found { + return nil, nil } // Fallback to any future we have pending @@ -137,6 +159,10 @@ func (ep *TxnEventsProvider) Get(eventID id.EventID) (*types.Event, error) { return nil, err } else if b == nil { ep.log.Warn().Str("event_id", eventID.String()).Msg("Event does not exist") + if found { + ep.missing[eventID] = struct{}{} + delete(ep.futures, eventID) + } return nil, nil } @@ -162,10 +188,89 @@ func (ep *TxnEventsProvider) MustGet(eventID id.EventID) *types.Event { return ev } -// TxnAuthEventsProvider implements the gomatrixserverlib.AuthEventProvider -// interface in a way that allows us to overwrite event IDs with ones in the -// current persist batch, so batches are authed against themselves if they -// contain auth state changes. +// GetRequired is Get for an event that must exist, failing with types.ErrEventNotFound otherwise. +func (ep *TxnEventsProvider) GetRequired(eventID id.EventID) (*types.Event, error) { + ev, err := ep.Get(eventID) + if err == nil && ev == nil { + err = fmt.Errorf("%w: %s", types.ErrEventNotFound, eventID) + } + return ev, err +} + +// GetAll is GetRequired for each event, fetching them all at once. +func (ep *TxnEventsProvider) GetAll(eventIDs []id.EventID) ([]*types.Event, error) { + ep.WillGet(eventIDs...) + evs := make([]*types.Event, len(eventIDs)) + for i, eventID := range eventIDs { + ev, err := ep.GetRequired(eventID) + if err != nil { + return nil, err + } + evs[i] = ev + } + return evs, nil +} + +// An event citing an auth event that is not known was not evaluated, unlike one that fails auth +var ErrAuthEventMissing = errors.New("auth event missing") + +// CheckEventAuthEvents authorizes an event using only its declared auth events. The event cache +// is shared, but the auth state is fresh for each event so unrelated branches cannot affect it. +// Auth failures and lookup errors are returned separately so database errors can be retried. +func (ep *TxnEventsProvider) CheckEventAuthEvents(ctx context.Context, ev *types.Event) (authErr, err error) { + // Auth rule 2 in every room version: no duplicate tuples, and none outside the selection. An + // event citing more auth events than the selection holds breaks it without loading any. + selected := authEventSelection(ev) + if len(ev.AuthEventIDs) > len(selected) { + return fmt.Errorf("event cites %d auth events, its selection allows %d", len(ev.AuthEventIDs), len(selected)), nil + } + authState := make(types.StateMap, len(ev.AuthEventIDs)) + for _, authID := range ev.AuthEventIDs { + authEv, err := ep.Get(authID) + if err != nil { + return nil, fmt.Errorf("failed to fetch auth event: %w", err) + } else if authEv == nil { + return fmt.Errorf("%w: %s", ErrAuthEventMissing, authID), nil + } else if authEv.Rejected { + return fmt.Errorf("auth event rejected: %s", authID), nil + } else if authEv.StateKey == nil { + return fmt.Errorf("auth event %s is not state", authID), nil + } else if authEv.RoomID != ev.RoomID { + return fmt.Errorf("auth event %s is for another room", authID), nil + } else if _, duplicate := authState[authEv.StateTup()]; duplicate { + return fmt.Errorf("duplicate auth tuple %v", authEv.StateTup()), nil + } else if !slices.Contains(selected, authEv.StateTup()) { + return fmt.Errorf("unexpected auth tuple %v", authEv.StateTup()), nil + } + authState[authEv.StateTup()] = authEv.ID + } + if ev.Type != event.StateCreate && util.RoomVersionHas(ev.RoomVersion, gomatrixserverlib.IRoomVersion.DomainlessRoomIDs) { + createID := ev.ImplicitCreateEventID() + create, err := ep.Get(createID) + if err != nil { + return nil, err + } + if create == nil { + return fmt.Errorf("%w: implicit create %s", ErrAuthEventMissing, createID), nil + } + if create.Rejected || create.Type != event.StateCreate || create.StateKey == nil || *create.StateKey != "" || create.RoomID != ev.RoomID { + return fmt.Errorf("invalid implicit create %s", createID), nil + } + authState[create.StateTup()] = createID + } + return NewTxnAuthEventsProvider(ctx, ep, authState).IsEventAllowed(ev) +} + +func (ep *TxnEventsProvider) authEventIDs() AuthEventIDs { + known := make(AuthEventIDs, len(ep.events)) + for eventID, ev := range ep.events { + known[eventID] = ev.AuthEventIDs + } + return known +} + +// TxnAuthEventsProvider authorizes events against a supplied state map. Authorization never +// changes that state. type TxnAuthEventsProvider struct { log zerolog.Logger eventsProvider *TxnEventsProvider @@ -189,140 +294,37 @@ func NewTxnAuthEventsProvider( } } -func (ap *TxnAuthEventsProvider) get(eventID id.EventID) (gomatrixserverlib.PDU, error) { - ev, err := ap.eventsProvider.Get(eventID) - if err != nil { - return nil, err - } else if ev == nil { - return nil, types.ErrEventNotFound - } - return ev.PDU(), nil -} - -func (ap *TxnAuthEventsProvider) getByType(evType event.Type) (gomatrixserverlib.PDU, error) { - eventID, found := ap.stateMap[types.StateTup{Type: evType}] +func (ap *TxnAuthEventsProvider) lookup(tup types.StateTup) (*types.Event, error) { + eventID, found := ap.stateMap[tup] if !found { - ap.log.Trace().Str("type", evType.String()).Msg("Missed auth state event") + ap.log.Trace().Stringer("type", tup.Type).Str("state_key", tup.StateKey).Msg("Missed auth state event") return nil, nil } - return ap.get(eventID) + return ap.eventsProvider.GetRequired(eventID) } -func (ap *TxnAuthEventsProvider) IsEventAllowed(ev *types.Event) error { - if err := gomatrixserverlib.Allowed( - ev.PDU(), - ap, - func(_ spec.RoomID, senderID spec.SenderID) (*spec.UserID, error) { - return senderID.ToUserID(), nil - }, - ); err != nil { - return err - } - - // TODO: there's a TODO in GMSL eventauth ~L1300 that states these are not checked - but they - // are tested by complement. - // LOL: complement tests only check the API level, if we do this check in the DB write other - // federated leave tests rely on this! - // if ev.Type == event.StateMember && ev.Membership() == event.MembershipLeave { - // oldEvID, found := ap.stateMap[types.StateTup{ - // Type: event.StateMember, - // StateKey: *ev.StateKey, - // }] - // if !found { - // return fmt.Errorf("user does not have room membership") - // } - // oldEv, err := ap.eventsProvider.Get(oldEvID) - // if err != nil { - // return err - // } - // if oldEv.Membership() == event.MembershipLeave { - // return fmt.Errorf("user is already left") - // } - - // If we authorized this event and it's a state event type, overwrite any - // in our stateEventIDs/memberEventIDs. This means subsequent auth checks - // will use this event if it has altered the auth state. - switch ev.Type { - case event.StateCreate: - ap.stateMap[types.StateTup{Type: event.StateCreate}] = ev.ID - case event.StateJoinRules: - ap.stateMap[types.StateTup{Type: event.StateJoinRules}] = ev.ID - case event.StatePowerLevels: - ap.stateMap[types.StateTup{Type: event.StatePowerLevels}] = ev.ID - case event.StateMember: - ap.stateMap[types.StateTup{ - Type: event.StateMember, - StateKey: *ev.StateKey, - }] = ev.ID +// IsEventAllowed authorizes an event without changing the provider's state. Auth failures and +// failures to load that state are returned separately, so only the former reject the event. +func (ap *TxnAuthEventsProvider) IsEventAllowed(ev *types.Event) (authErr, err error) { + authErr, err = util.Authorize(ev, ap.lookup) + if err != nil { + return nil, fmt.Errorf("failed to load auth state for %s: %w", ev.ID, err) } - return nil + return authErr, nil } +// AuthEventIDsFor selects an event's auth events from the state it is authorized against. // https://spec.matrix.org/v1.11/server-server-api/#auth-events-selection -func (ap *TxnAuthEventsProvider) GetAuthEventIDsForEvent(ev *types.Event) []id.EventID { +func AuthEventIDsFor(ev *types.Event, authState types.StateMap) []id.EventID { if ev.Type == event.StateCreate { return nil } - authEventIDs := make([]id.EventID, 0, len(ap.stateMap)) - for stateTup, eventID := range ap.stateMap { - var include bool - switch stateTup.Type { - case event.StateCreate, event.StatePowerLevels: - // TODO: room version 12 excludes create event - include = true - case event.StateJoinRules: - if ev.Type == event.StateMember { - include = true - } - case event.StateMember: - if stateTup.StateKey == ev.Sender.String() || stateTup.StateKey == *ev.StateKey { - include = true - } - // TODO: If membership is invite and content contains a third_party_invite property, the current m.room.third_party_invite event with state_key matching content.third_party_invite.signed.token, if any. - // TODO: If content.join_authorised_via_users_server is present, and the room version supports restricted rooms, then the m.room.member event with state_key matching content.join_authorised_via_users_server. - } - if include { + authEventIDs := make([]id.EventID, 0, len(authState)) + for _, stateTup := range authEventSelection(ev) { + if eventID, found := authState[stateTup]; found { authEventIDs = append(authEventIDs, eventID) } } - slices.SortFunc(authEventIDs, func(a, b id.EventID) int { - return strings.Compare(a.String(), b.String()) - }) + slices.Sort(authEventIDs) return authEventIDs } - -// Interface methods for gomatrixserverlib.AuthEventProvider - -func (ap *TxnAuthEventsProvider) Member(senderID spec.SenderID) (gomatrixserverlib.PDU, error) { - eventID, found := ap.stateMap[types.StateTup{ - Type: event.StateMember, - StateKey: string(senderID), - }] - if !found { - ap.log.Trace().Str("sender", string(senderID)).Msg("Missed member state event") - return nil, nil - } - return ap.get(eventID) -} - -func (ap *TxnAuthEventsProvider) Create() (gomatrixserverlib.PDU, error) { - return ap.getByType(event.StateCreate) -} - -func (ap *TxnAuthEventsProvider) JoinRules() (gomatrixserverlib.PDU, error) { - return ap.getByType(event.StateJoinRules) -} - -func (ap *TxnAuthEventsProvider) PowerLevels() (gomatrixserverlib.PDU, error) { - return ap.getByType(event.StatePowerLevels) -} - -func (ap *TxnAuthEventsProvider) ThirdPartyInvite(s string) (gomatrixserverlib.PDU, error) { - // TODO: implement - return nil, nil -} - -func (ap *TxnAuthEventsProvider) Valid() bool { - // TODO: implement - return true -} diff --git a/internal/databases/rooms/events/state.go b/internal/databases/rooms/events/state.go index 05a927f..ac98f46 100644 --- a/internal/databases/rooms/events/state.go +++ b/internal/databases/rooms/events/state.go @@ -1,29 +1,70 @@ package events import ( + "slices" + + "github.com/matrix-org/gomatrixserverlib" "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" ) -// Types of state event used for authorization - excluding members +// State used for authorization, excluding members // https://spec.matrix.org/v1.11/server-server-api/#auth-events-selection -var authStateTypes = []event.Type{ - event.StateCreate, - event.StateJoinRules, - event.StatePowerLevels, - // Third party? -} - var authStateTups = []types.StateTup{ {Type: event.StateCreate, StateKey: ""}, {Type: event.StateJoinRules, StateKey: ""}, {Type: event.StatePowerLevels, StateKey: ""}, } +func AuthStateTupsWithMembers(userIDs []id.UserID) []types.StateTup { + return slices.Concat(authStateTups, MemberStateTups(userIDs)) +} + +// AuthStateTupsForEvent includes the state an auth check may read, including the +// additional membership and invite state named in member content. +func AuthStateTupsForEvent(ev *types.Event) []types.StateTup { + userIDs := []id.UserID{ev.Sender} + var thirdPartyInvite *gomatrixserverlib.MemberThirdPartyInvite + if ev.Type == event.StateMember && ev.StateKey != nil { + userIDs = append(userIDs, id.UserID(*ev.StateKey)) + if member, err := gomatrixserverlib.NewMemberContentFromEvent(ev.PDU()); err == nil { + if member.AuthorisedVia != "" { + userIDs = append(userIDs, id.UserID(member.AuthorisedVia)) + } + thirdPartyInvite = member.ThirdPartyInvite + } + } + keys := AuthStateTupsWithMembers(userIDs) + if thirdPartyInvite != nil { + keys = append(keys, types.StateTup{Type: event.StateThirdPartyInvite, StateKey: thirdPartyInvite.Signed.Token}) + } + return uniqueStateTups(keys) +} + +func uniqueStateTups(tups []types.StateTup) []types.StateTup { + unique := tups[:0] + for _, tup := range tups { + if !slices.Contains(unique, tup) { + unique = append(unique, tup) + } + } + return unique +} + +func MemberStateTups(userIDs []id.UserID) []types.StateTup { + tups := make([]types.StateTup, len(userIDs)) + for i, userID := range userIDs { + tups[i] = types.MemberStateTup(userID) + } + return tups +} + // Types of state event used for stripped state on invites // https://spec.matrix.org/v1.11/client-server-api/#stripped-state -var strippedStateTups = []types.StateTup{ +var StrippedStateTups = []types.StateTup{ {Type: event.StateCreate, StateKey: ""}, {Type: event.StateRoomName, StateKey: ""}, {Type: event.StateRoomAvatar, StateKey: ""}, @@ -32,3 +73,40 @@ var strippedStateTups = []types.StateTup{ {Type: event.StateCanonicalAlias, StateKey: ""}, {Type: event.StateEncryption, StateKey: ""}, } + +// authEventSelection is the state an event's auth_events may cite, as Synapse's +// auth_types_for_event selects it for every room version, and so narrower than +// AuthStateTupsForEvent. A domainless room ID implies the create event. +// https://spec.matrix.org/v1.16/server-server-api/#auth-events-selection +func authEventSelection(ev *types.Event) []types.StateTup { + if ev.Type == event.StateCreate { + return nil + } + selected := []types.StateTup{ + {Type: event.StatePowerLevels}, + types.MemberStateTup(ev.Sender), + } + if !util.RoomVersionHas(ev.RoomVersion, gomatrixserverlib.IRoomVersion.DomainlessRoomIDs) { + selected = append(selected, types.StateTup{Type: event.StateCreate}) + } + if ev.Type != event.StateMember || ev.StateKey == nil { + return selected + } + selected = append(selected, types.MemberStateTup(id.UserID(*ev.StateKey))) + if member, err := gomatrixserverlib.NewMemberContentFromEvent(ev.PDU()); err == nil { + membership := event.Membership(member.Membership) + switch membership { + case event.MembershipJoin, event.MembershipInvite, event.MembershipKnock: + selected = append(selected, types.StateTup{Type: event.StateJoinRules}) + } + if membership == event.MembershipInvite && member.ThirdPartyInvite != nil { + selected = append(selected, types.StateTup{Type: event.StateThirdPartyInvite, StateKey: member.ThirdPartyInvite.Signed.Token}) + } + if membership == event.MembershipJoin && member.AuthorisedVia != "" { + if roomSpec, err := gomatrixserverlib.GetRoomVersion(ev.GetRoomVersion()); err == nil && roomSpec.CheckRestrictedJoinsAllowed() == nil { + selected = append(selected, types.MemberStateTup(id.UserID(member.AuthorisedVia))) + } + } + } + return uniqueStateTups(selected) +} diff --git a/internal/databases/rooms/events/statecurrent.go b/internal/databases/rooms/events/statecurrent.go deleted file mode 100644 index 4b0dfe7..0000000 --- a/internal/databases/rooms/events/statecurrent.go +++ /dev/null @@ -1,306 +0,0 @@ -package events - -import ( - "context" - "maps" - - "github.com/apple/foundationdb/bindings/go/src/fdb" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "github.com/beeper/babbleserv/internal/types" -) - -// Room extremeties -// - -func (e *EventsDirectory) TxnDeleteRoomExtremEventID(txn fdb.Transaction, roomID id.RoomID, eventID id.EventID) { - txn.Clear(e.keyForRoomExtrem(roomID, eventID)) -} - -func (e *EventsDirectory) TxnSetRoomExtremEventID(txn fdb.Transaction, roomID id.RoomID, eventID id.EventID) { - txn.Set(e.keyForRoomExtrem(roomID, eventID), []byte{}) -} - -func (e *EventsDirectory) TxnResetRoomExtremEventIDs(txn fdb.Transaction, roomID id.RoomID, eventID id.EventID) { - txn.ClearRange(e.rangeForRoomExtrems(roomID)) - e.TxnSetRoomExtremEventID(txn, roomID, eventID) -} - -// Lookup current last event IDs for a room - note we do not start fetching the -// events as we only need the IDs. -func (e *EventsDirectory) TxnLookupCurrentRoomExtremEventIDs( - txn fdb.ReadTransaction, - roomID id.RoomID, -) []id.EventID { - iter := txn.GetRange( - e.rangeForRoomExtrems(roomID), - fdb.RangeOptions{ - Mode: fdb.StreamingModeWantAll, - }, - ).Iterator() - ids := make([]id.EventID, 0, 1) - for iter.Advance() { - kv := iter.MustGet() - ids = append(ids, e.roomExtremKeyToEventID(kv.Key)) - } - return ids -} - -// Current room state (non-member) events -// - -func (e *EventsDirectory) TxnIsRoomEncrypted(txn fdb.ReadTransaction, roomID id.RoomID) bool { - // Note we're simply checking for the presence of an encryption event here - b := txn.Get(e.KeyForRoomCurrentStateTup(roomID, event.StateEncryption, "")).MustGet() - return b != nil -} - -func (e *EventsDirectory) TxnGetCurrentRoomStateEvent( - txn fdb.ReadTransaction, - roomID id.RoomID, - stateTup types.StateTup, - eventsProvider *TxnEventsProvider, -) *types.Event { - var sMap types.StateMap - - // Member state events are special and are stored separately to other state events - if stateTup.Type == event.StateMember { - sMap = e.TxnLookupCurrentSpecificRoomMemberStateMap( - txn, - roomID, - []id.UserID{id.UserID(stateTup.StateKey)}, - eventsProvider, - ) - } else { - sMap = e.TxnLookupCurrentSpecificRoomStateTupMap( - txn, - roomID, - []types.StateTup{stateTup}, - eventsProvider, - ) - } - - if len(sMap) == 0 { - return nil - } - - eventID := sMap[stateTup] - return eventsProvider.MustGet(eventID) -} - -func (e *EventsDirectory) TxnFilterMembershipsWithEncryption( - txn fdb.ReadTransaction, - memberships types.Memberships, -) (types.Memberships, error) { - // Find joins and kick off fetches for the room encryption event state tup - futs := make(map[id.RoomID]fdb.FutureByteSlice, len(memberships)) - for roomID := range memberships { - futs[roomID] = txn.Get(e.KeyForRoomCurrentStateTup(roomID, event.StateEncryption, "")) - } - - // Now make new memberships for only rooms with an encryption event - newMemberships := make(types.Memberships, len(futs)) - for roomID, fut := range futs { - b, err := fut.Get() - if err != nil { - return nil, err - } else if b != nil { - newMemberships[roomID] = memberships[roomID] - } - } - - return newMemberships, nil -} - -// Lookup specific state events -func (e *EventsDirectory) TxnLookupCurrentSpecificRoomStateTupMap( - txn fdb.ReadTransaction, - roomID id.RoomID, - tups []types.StateTup, - eventsProvider *TxnEventsProvider, -) types.StateMap { - tupToFut := make(map[types.StateTup]fdb.FutureByteSlice, len(tups)) - for _, tup := range tups { - tupToFut[tup] = txn.Get(e.KeyForRoomCurrentStateTup(roomID, tup.Type, tup.StateKey)) - } - - stateMap := make(types.StateMap, len(tups)) - for tup, fut := range tupToFut { - if eid := fut.MustGet(); eid != nil { - stateMap[tup] = id.EventID(eid) - if eventsProvider != nil { - eventsProvider.WillGet(id.EventID(eid)) - } - } - } - - return stateMap -} - -func (e *EventsDirectory) TxnLookupCurrentRoomAuthStateMap( - txn fdb.ReadTransaction, - roomID id.RoomID, - eventsProvider *TxnEventsProvider, -) types.StateMap { - return e.TxnLookupCurrentSpecificRoomStateTupMap(txn, roomID, authStateTups, eventsProvider) -} - -func (e *EventsDirectory) TxnLookupCurrentRoomStrippedStateStateMap( - txn fdb.ReadTransaction, - roomID id.RoomID, - eventsProvider *TxnEventsProvider, -) types.StateMap { - return e.TxnLookupCurrentSpecificRoomStateTupMap(txn, roomID, strippedStateTups, eventsProvider) -} - -// Lookup current state (non member) event IDs and start fetching events -func (e *EventsDirectory) TxnLookupCurrentRoomStateMap( - txn fdb.ReadTransaction, - roomID id.RoomID, - eventsProvider *TxnEventsProvider, -) types.StateMap { - iter := txn.GetRange( - e.RangeForRoomCurrentState(roomID), - fdb.RangeOptions{ - Mode: fdb.StreamingModeWantAll, - }, - ).Iterator() - - ids := make(types.StateMap) - for iter.Advance() { - kv := iter.MustGet() - stateTup := e.CurrentRoomStateKeyValueToStateTup(kv) - ids[stateTup.StateTup] = id.EventID(kv.Value) - if eventsProvider != nil { - eventsProvider.WillGet(stateTup.EventID) - } - } - return ids -} - -// Current room memberships (users & servers) -// - -func (e *EventsDirectory) TxnLookupCurrentRoomMemberships( - txn fdb.ReadTransaction, - roomID id.RoomID, - eventsProvider *TxnEventsProvider, -) types.RoomMemberships { - kvs := txn.GetRange( - e.RangeForCurrentRoomMembers(roomID), - fdb.RangeOptions{ - Mode: fdb.StreamingModeWantAll, - }, - ).GetSliceOrPanic() - - ids := make(types.RoomMemberships, len(kvs)) - for _, kv := range kvs { - stateTup, membershipTup := e.CurrentRoomMemberKeyValueToTups(kv) - if eventsProvider != nil { - eventsProvider.WillGet(membershipTup.EventID) - } - ids[id.UserID(stateTup.StateKey)] = membershipTup - } - - return ids -} - -func (e *EventsDirectory) TxnLookupCurrentRoomMemberStateMap( - txn fdb.ReadTransaction, - roomID id.RoomID, - eventsProvider *TxnEventsProvider, -) types.StateMap { - iter := txn.GetRange( - e.RangeForCurrentRoomMembers(roomID), - fdb.RangeOptions{ - Mode: fdb.StreamingModeWantAll, - }, - ).Iterator() - - ids := make(types.StateMap) - for iter.Advance() { - kv := iter.MustGet() - stateTup, membershipTup := e.CurrentRoomMemberKeyValueToTups(kv) - if eventsProvider != nil { - eventsProvider.WillGet(membershipTup.EventID) - } - ids[stateTup.StateTup] = membershipTup.EventID - } - - return ids -} - -func (e *EventsDirectory) TxnLookupCurrentRoomStateAndMemberMap( - txn fdb.ReadTransaction, - roomID id.RoomID, - eventsProvider *TxnEventsProvider, -) types.StateMap { - stateMap := e.TxnLookupCurrentRoomStateMap(txn, roomID, eventsProvider) - memberMap := e.TxnLookupCurrentRoomMemberStateMap(txn, roomID, eventsProvider) - maps.Copy(stateMap, memberMap) - return stateMap -} - -func (e *EventsDirectory) TxnLookupCurrentRoomServers( - txn fdb.ReadTransaction, - roomID id.RoomID, -) ([]string, error) { - iter := txn.GetRange( - e.rangeForCurrentRoomServers(roomID), - fdb.RangeOptions{ - Mode: fdb.StreamingModeWantAll, - }, - ).Iterator() - - serverNames := make([]string, 0) - for iter.Advance() { - kv := iter.MustGet() - serverNames = append(serverNames, e.currentRoomServerKeyToServer(kv.Key)) - } - - return serverNames, nil -} - -// Lookup current room member event IDs and start fetching events -func (e *EventsDirectory) TxnLookupCurrentSpecificRoomMemberStateMap( - txn fdb.ReadTransaction, - roomID id.RoomID, - userIDs []id.UserID, - eventsProvider *TxnEventsProvider, -) types.StateMap { - idToFut := make(map[id.UserID]fdb.FutureByteSlice, len(userIDs)) - for _, uid := range userIDs { - idToFut[uid] = txn.Get(e.KeyForCurrentRoomMember(roomID, uid)) - } - idToEventID := make(types.StateMap, len(userIDs)) - - for uid, fut := range idToFut { - b := fut.MustGet() - if b == nil { - continue - } - membershipTup := types.BytesToMembershipTup(b) - if eventsProvider != nil { - eventsProvider.WillGet(membershipTup.EventID) - } - idToEventID[types.StateTup{ - Type: event.StateMember, - StateKey: uid.String(), - }] = membershipTup.EventID - } - return idToEventID -} - -func (e *EventsDirectory) TxnLookupCurrentRoomAuthAndSpecificMemberStateMap( - ctx context.Context, - txn fdb.ReadTransaction, - roomID id.RoomID, - userIDs []id.UserID, - eventsProvider *TxnEventsProvider, -) types.StateMap { - stateMap := e.TxnLookupCurrentRoomAuthStateMap(txn, roomID, eventsProvider) - memberMap := e.TxnLookupCurrentSpecificRoomMemberStateMap(txn, roomID, userIDs, eventsProvider) - maps.Copy(stateMap, memberMap) - return stateMap -} diff --git a/internal/databases/rooms/events/statehistory.go b/internal/databases/rooms/events/statehistory.go new file mode 100644 index 0000000..9f33783 --- /dev/null +++ b/internal/databases/rooms/events/statehistory.go @@ -0,0 +1,50 @@ +package events + +import ( + "fmt" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +// TxnStoreRoomStateStep records the room's current state context after the event stored at version +// moved it there. +func (e *EventsDirectory) TxnStoreRoomStateStep(txn fdb.Transaction, roomID id.RoomID, version tuple.Versionstamp, stateCtx types.StateHash) { + txn.SetVersionstampedKey(e.keyForRoomStateStep(roomID, version), tuple.Tuple{stateCtx[:]}.Pack()) +} + +// TxnLookupRoomStateAt returns the room's current state context as of version, false before the +// room's first step. +func (e *EventsDirectory) TxnLookupRoomStateAt(txn fdb.ReadTransaction, roomID id.RoomID, version tuple.Versionstamp) (types.StateHash, bool, error) { + if version == types.ZeroVersionstamp { + return types.StateHash{}, false, nil + } + kvs, err := txn.GetRange(e.rangeForRoomStateAt(roomID, version), fdb.RangeOptions{Reverse: true, Limit: 1}).GetSliceWithError() + if err != nil || len(kvs) == 0 { + return types.StateHash{}, false, err + } + tup, err := tuple.Unpack(kvs[0].Value) + if err != nil { + return types.StateHash{}, false, fmt.Errorf("invalid room state step in %s: %w", roomID, err) + } + var stateCtx []byte + if len(tup) == 1 { + stateCtx, _ = tup[0].([]byte) + } + if len(stateCtx) != len(types.StateHash{}) { + return types.StateHash{}, false, fmt.Errorf("invalid room state step in %s: %v", roomID, tup) + } + return types.StateHash(stateCtx), true, nil +} + +func (e *EventsDirectory) keyForRoomStateStep(roomID id.RoomID, version tuple.Versionstamp) fdb.Key { + return types.MustPackVersionKey(e.roomVersionToState, tuple.Tuple{roomID.String(), version}) +} + +// rangeForRoomStateAt covers the room's steps up to and including version +func (e *EventsDirectory) rangeForRoomStateAt(roomID id.RoomID, version tuple.Versionstamp) fdb.ExactRange { + return types.GetVersionRange(e.roomVersionToState, types.ZeroVersionstamp, version, roomID.String()) +} diff --git a/internal/databases/rooms/events/stateversion.go b/internal/databases/rooms/events/stateversion.go deleted file mode 100644 index cc86396..0000000 --- a/internal/databases/rooms/events/stateversion.go +++ /dev/null @@ -1,192 +0,0 @@ -package events - -import ( - "context" - "maps" - - "github.com/apple/foundationdb/bindings/go/src/fdb" - "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" - "github.com/rs/zerolog" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "github.com/beeper/babbleserv/internal/types" -) - -// Lookup versionstamp for a given event -func (e *EventsDirectory) TxnLookupVersionForEventID( - txn fdb.ReadTransaction, - eventID id.EventID, -) tuple.Versionstamp { - key := e.KeyForIDToVersion(eventID) - b := txn.Get(key).MustGet() - if b == nil { - return types.ZeroVersionstamp - } - return types.MustBytesToVersionstamp(b) -} - -func (e *EventsDirectory) TxnLookupRoomStateAndMemberMapAtVersion( - txn fdb.ReadTransaction, - roomID id.RoomID, - version tuple.Versionstamp, - eventsProvider *TxnEventsProvider, -) types.StateMap { - // Fetch all state event tups up to now - stateTups := e.TxnPaginateRoomStateEventTups(txn, roomID, types.PaginationOptions{ - From: types.ZeroVersionstamp, - To: version, - Mode: fdb.StreamingModeWantAll, - }, eventsProvider) - - // Apply each in order, last state wins - stateMap := make(types.StateMap, 10) - for _, tup := range stateTups { - stateMap[tup.StateTup] = tup.EventID - } - return stateMap -} - -// Lookup room state event IDs before a given event, optionally passing an events provider to start -// fetching the events. This is expensive since we have to fetch *all* room state events up until -// the event in question. Only exists to satisfy "get state at event" S2S API. -func (e *EventsDirectory) TxnLookupRoomStateAndMemberMapAtEvent( - txn fdb.ReadTransaction, - roomID id.RoomID, - eventID id.EventID, - eventsProvider *TxnEventsProvider, -) types.StateMap { - version := e.TxnLookupVersionForEventID(txn, eventID) - return e.TxnLookupRoomStateAndMemberMapAtVersion(txn, roomID, version, eventsProvider) -} - -// Lookup room auth state at or before an event, that is the most recent power_levels, join_rules, -// etc events that occurred before the version of the input event. -func (e *EventsDirectory) TxnLookupRoomAuthStateMapAtEvent( - ctx context.Context, - txn fdb.ReadTransaction, - roomID id.RoomID, - eventID id.EventID, - eventsProvider *TxnEventsProvider, -) types.StateMap { - version := e.TxnLookupVersionForEventID(txn, eventID) - version.UserVersion += 1 // FDB range ends are exclusive, the event should be included - - log := zerolog.Ctx(ctx).With().Str("at_or_before_event_id", eventID.String()).Logger() - ctx = log.WithContext(ctx) - - return e.TxnLookupRoomAuthStateMapAtVersion(ctx, txn, roomID, version, eventsProvider) -} - -func (e *EventsDirectory) TxnLookupRoomAuthStateMapAtVersion( - ctx context.Context, - txn fdb.ReadTransaction, - roomID id.RoomID, - version tuple.Versionstamp, - eventsProvider *TxnEventsProvider, -) types.StateMap { - futs := make(map[event.Type]fdb.RangeResult, len(authStateTypes)) - for _, stateType := range authStateTypes { - futs[stateType] = txn.GetRange( - e.rangeForRoomVersionStateTup(roomID, stateType, "", version), - fdb.RangeOptions{ - Reverse: true, - Limit: 1, - }, - ) - } - - stateMap := make(types.StateMap, len(authStateTypes)) - for _, stateType := range authStateTypes { - results := futs[stateType].GetSliceOrPanic() - if len(results) > 1 { - panic("more than one key returned for versioned state request") - } else if results == nil { - zerolog.Ctx(ctx).Warn(). - Stringer("room_id", roomID). - Stringer("state_type", stateType). - Any("versionstamp", version). - Msg("No historical state event found in room") - continue - } - stateMap[types.StateTup{ - Type: stateType, - }] = id.EventID(results[0].Value) - } - - if eventsProvider != nil { - for _, evID := range stateMap { - eventsProvider.WillGet(evID) - } - } - - return stateMap - -} - -// Lookup specific member state at or before an event, that is the most recent room_member event -// that occurred before the version of the input event. -func (e *EventsDirectory) TxnLookupSpecificRoomMemberStateMapAtEvent( - ctx context.Context, - txn fdb.ReadTransaction, - roomID id.RoomID, - userIDs []id.UserID, - eventID id.EventID, - eventsProvider *TxnEventsProvider, -) types.StateMap { - version := e.TxnLookupVersionForEventID(txn, eventID) - version.UserVersion += 1 // FDB range ends are exclusive, the event should be included - - futs := make(map[id.UserID]fdb.RangeResult, len(userIDs)) - for _, userID := range userIDs { - futs[userID] = txn.GetRange( - e.rangeForRoomVersionStateTup(roomID, event.StateMember, userID.String(), version), - fdb.RangeOptions{ - Reverse: true, - Limit: 1, - }, - ) - } - - stateMap := make(types.StateMap, len(userIDs)) - for _, userID := range userIDs { - results := futs[userID].GetSliceOrPanic() - if len(results) > 1 { - panic("more than one key returned for versioned member state request") - } else if results == nil { - zerolog.Ctx(ctx).Warn(). - Str("room_id", roomID.String()). - Str("user_id", userID.String()). - Str("versionstamp", version.String()). - Str("at_or_before_event_id", eventID.String()). - Msg("No historical member state event found in room") - continue - } - stateMap[types.StateTup{ - Type: event.StateMember, - StateKey: userID.String(), - }] = id.EventID(results[0].Value) - } - - if eventsProvider != nil { - for _, evID := range stateMap { - eventsProvider.WillGet(evID) - } - } - - return stateMap -} - -func (e *EventsDirectory) TxnLookupRoomAuthAndSpecificMemberStateMapAtEvent( - ctx context.Context, - txn fdb.ReadTransaction, - roomID id.RoomID, - userIDs []id.UserID, - eventID id.EventID, - eventsProvider *TxnEventsProvider, -) types.StateMap { - stateMap := e.TxnLookupRoomAuthStateMapAtEvent(ctx, txn, roomID, eventID, eventsProvider) - memberMap := e.TxnLookupSpecificRoomMemberStateMapAtEvent(ctx, txn, roomID, userIDs, eventID, eventsProvider) - maps.Copy(stateMap, memberMap) - return stateMap -} diff --git a/internal/types/authheader.go b/internal/types/authheader.go new file mode 100644 index 0000000..d53c4ba --- /dev/null +++ b/internal/types/authheader.go @@ -0,0 +1,15 @@ +package types + +import "maunium.net/go/mautrix/id" + +// AuthHeader is graph metadata independent of an event's authorization outcome: the event's auth +// events and, once finalized, its chain position, zero while it is not. +type AuthHeader struct { + AuthEventIDs []id.EventID + Chain uint32 + Sequence uint32 +} + +func (h AuthHeader) Finalized() bool { + return h.Chain != 0 +} From 5f7cbe8af586a9cfbe4ba171be0aafde19c80555 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Sun, 4 Oct 2026 22:08:32 +0100 Subject: [PATCH 06/12] Publish room events through guarded send plans Move event sending onto immutable state contexts, separating preparation, supporting work and atomic publication so large sends can span multiple FoundationDB transactions. Organize the send paths around concrete senders: - eventSender owns the shared attempt loop, publication, retries and notifications. - localEventSender builds and authorizes local events, retaining the single-transaction fast path and a stable timestamp across retries. - federatedEventSender handles received events, fetched dependencies, authorization, state resolution and staging. - remoteJoinSender embeds federatedEventSender and supplies join-specific preparation, response validation and boundary-state construction. Each preparation returns an explicit next action: prepare | +-- readyToPublish(publishPlan) | -> stage pending state if needed | -> check guards and publish atomically | -> retry preparation if guards changed | +-- needsWork(preparationWork) | -> release room mutex | -> stage supporting events, resolve state or build join boundary | -> retry preparation using the stored results | +-- alreadySent(SendEventsResult) -> return the existing result preparedEvents carries the attempt's event copies, state batch, auth graph, event provider and state transitions. publishPlan adds publication guards and writes specific to the send. Publication checks the room revision, server membership, previously unstored event IDs and auth-graph labels. It commits event history, extremities, current state, membership changes, server counts and room metadata together. Notifications run after commit and outside the mutex. Extract context resolution and renewable read transactions into rooms/eventsendutil. Bound inline resolution and staging with configurable budgets; larger jobs carry their inputs across transactions and store reusable results before preparation resumes. Keep workflow methods on their owning senders, with RoomsDatabase providing public entry points and shared storage operations. --- docker/complement-config.yaml | 7 + internal/config/config.go | 29 + internal/databases/eventsend.go | 52 +- internal/databases/rooms/eventsend.go | 1635 ++++++----------- internal/databases/rooms/eventsender.go | 683 +++++++ internal/databases/rooms/eventsender_test.go | 199 ++ .../databases/rooms/eventsendfederated.go | 1457 +++++++++++++++ internal/databases/rooms/eventsendjoin.go | 283 +++ internal/databases/rooms/eventsendlocal.go | 394 ++++ internal/databases/rooms/eventsendutil.go | 297 --- .../databases/rooms/eventsendutil/reads.go | 129 ++ .../databases/rooms/eventsendutil/resolver.go | 448 +++++ .../rooms/eventsendutil/resolver_test.go | 86 + internal/databases/rooms/rooms.go | 78 +- .../databases/rooms/servers/memberships.go | 96 +- internal/databases/rooms/servers/servers.go | 31 +- internal/databases/rooms/users/memberships.go | 57 +- internal/databases/rooms/users/users.go | 9 +- internal/types/event.go | 2 +- internal/util/database.go | 2 +- internal/workers/devicejoineventiterator.go | 2 +- 21 files changed, 4508 insertions(+), 1468 deletions(-) create mode 100644 internal/databases/rooms/eventsender.go create mode 100644 internal/databases/rooms/eventsender_test.go create mode 100644 internal/databases/rooms/eventsendfederated.go create mode 100644 internal/databases/rooms/eventsendjoin.go create mode 100644 internal/databases/rooms/eventsendlocal.go delete mode 100644 internal/databases/rooms/eventsendutil.go create mode 100644 internal/databases/rooms/eventsendutil/reads.go create mode 100644 internal/databases/rooms/eventsendutil/resolver.go create mode 100644 internal/databases/rooms/eventsendutil/resolver_test.go diff --git a/docker/complement-config.yaml b/docker/complement-config.yaml index 202c96c..2336d35 100644 --- a/docker/complement-config.yaml +++ b/docker/complement-config.yaml @@ -34,6 +34,13 @@ notifier: rooms: enabled: true defaultVersion: 11 + # Small, so that Complement stages over several transactions and runs an artifact job for every + # resolution with conflicts. + stateBudget: + stagingBytes: 8192 + inlineResolutionCandidates: 1 + # Every resolution with conflicts reads its auth difference in a job + inlineResolutionStateTuples: 1 accounts: enabled: true transient: diff --git a/internal/config/config.go b/internal/config/config.go index 631f64f..c4dcb74 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,24 @@ type keyConfig struct { ExpiredTimestamp int64 `yaml:"expiredTimestamp"` } +// What a publish's transaction may hold before the room record, leaving room under FoundationDB's +// 10 MB for the record and a send's extra writes +const PublishMandatoryMaxBytes = 9_000_000 + +// Bounds on the work of one transaction when sending events to a room +type stateBudgetConfig struct { + // Approximate bytes staged per transaction, leaving headroom for FoundationDB overhead + // Default: 5 MB + StagingBytes int `yaml:"stagingBytes"` + // Candidate events of a state resolution run inline, past which an artifact job runs it + // Default: 2000 + InlineResolutionCandidates int `yaml:"inlineResolutionCandidates"` + // State tuples of the largest input of a state resolution run inline, past which an artifact job + // runs it, as its auth difference reads the auth header of every state event of its inputs + // Default: 10000 + InlineResolutionStateTuples int `yaml:"inlineResolutionStateTuples"` +} + type NotifierConfig struct { RedisAddr string `yaml:"redisAddr"` RedisChannel string `yaml:"redisChannel"` @@ -53,6 +71,8 @@ type BabbleConfig struct { // events sent per room in memory only. // Default: 3h CompactRoomNotificationsTimeout time.Duration `yaml:"compactRoomNotificationsTimeout"` + + StateBudget stateBudgetConfig `yaml:"stateBudget"` } `yaml:"rooms"` Accounts struct { @@ -182,6 +202,15 @@ func NewBabbleConfig(filename string, commitHash string) BabbleConfig { if cfg.Rooms.CompactRoomNotificationsTimeout == 0 { cfg.Rooms.CompactRoomNotificationsTimeout = 3 * time.Hour } + if cfg.Rooms.StateBudget.StagingBytes <= 0 { + cfg.Rooms.StateBudget.StagingBytes = 5_000_000 + } + if cfg.Rooms.StateBudget.InlineResolutionCandidates <= 0 { + cfg.Rooms.StateBudget.InlineResolutionCandidates = 2000 + } + if cfg.Rooms.StateBudget.InlineResolutionStateTuples <= 0 { + cfg.Rooms.StateBudget.InlineResolutionStateTuples = 10_000 + } return cfg } diff --git a/internal/databases/eventsend.go b/internal/databases/eventsend.go index 5330631..a266d20 100644 --- a/internal/databases/eventsend.go +++ b/internal/databases/eventsend.go @@ -2,7 +2,12 @@ package databases import ( "context" + "encoding/json" + "errors" + "fmt" + "slices" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "github.com/rs/zerolog" @@ -28,13 +33,54 @@ func (d *Databases) SendFederatedEvents( ctx context.Context, roomID id.RoomID, evs []*types.Event, - options rooms.SendFederatedEventsOptions, + given *rooms.GivenStates, ) (*rooms.SendEventsResult, error) { return d.sendEventsFunc(ctx, roomID, func(userPushRules types.UserPushRulesMap, userRoomContext types.UserRoomContextMap) (*rooms.SendEventsResult, error) { - return d.Rooms.SendFederatedEvents(ctx, roomID, evs, userPushRules, userRoomContext, options) + return d.Rooms.SendFederatedEvents(ctx, roomID, evs, given, userPushRules, userRoomContext) }) } +// Sends a local user's join to a room through another server, with the state before the join and +// its auth chain from the resident server's send_join response. While this server is not in the +// room the join takes the response state as the room's state, otherwise it is sent like any +// federated event, against that state and the room's current state. +func (d *Databases) SendRemoteJoin( + ctx context.Context, + roomID id.RoomID, + joinEv *types.Event, + stateEvs, authEvs []*types.Event, +) (*rooms.SendEventsResult, error) { + stateIDs := make([]id.EventID, len(stateEvs)) + for i, ev := range stateEvs { + stateIDs[i] = ev.ID + } + res, err := d.SendFederatedEvents(ctx, roomID, []*types.Event{joinEv}, &rooms.GivenStates{ + BeforeEvents: map[id.EventID][]id.EventID{joinEv.ID: stateIDs}, + Events: slices.Concat(stateEvs, authEvs), + }) + if err != nil { + return nil, err + } + dropped := len(res.Rejected) > 0 && errors.Is(res.Rejected[0].Error, rooms.ErrEventDropped) + softFailed := slices.ContainsFunc(res.Allowed, func(ev *types.Event) bool { return ev.SoftFailed }) || + (len(res.Rejected) > 0 && res.Rejected[0].Event.SoftFailed) + if dropped || softFailed { + // Another join got this server into the room meanwhile. A dropped join is not stored and a + // soft failed one never becomes the user's membership, so the user joins with a new local + // event instead. Otherwise the send_join event is kept, as the other server has it and may + // have federated it already. + var content map[string]any + if err := json.Unmarshal(joinEv.Content, &content); err != nil { + return nil, fmt.Errorf("failed to parse join content: %w", err) + } + delete(content, "join_authorised_via_users_server") + return d.SendLocalEvents(ctx, roomID, []*types.PartialEvent{ + types.NewPartialEvent(roomID, event.StateMember, joinEv.StateKey, joinEv.Sender, content), + }, rooms.SendLocalEventsOptions{}) + } + return res, nil +} + func (d *Databases) sendEventsFunc( ctx context.Context, roomID id.RoomID, @@ -53,7 +99,7 @@ func (d *Databases) sendEventsFunc( } // Get local users in room - memberships, err := d.Rooms.GetCurrentRoomLocalJoinedMemberships(ctx, roomID) + memberships, err := d.Rooms.LocalJoinedMembers(ctx, roomID) if err != nil { return nil, err } diff --git a/internal/databases/rooms/eventsend.go b/internal/databases/rooms/eventsend.go index 297bdd5..828b3e0 100644 --- a/internal/databases/rooms/eventsend.go +++ b/internal/databases/rooms/eventsend.go @@ -9,1213 +9,724 @@ import ( "errors" "fmt" "maps" - "sync" - "time" + "slices" "github.com/apple/foundationdb/bindings/go/src/fdb" "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "github.com/matrix-org/gomatrixserverlib" "github.com/rs/zerolog" + "github.com/tidwall/gjson" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + "github.com/beeper/babbleserv/internal/config" "github.com/beeper/babbleserv/internal/databases/rooms/events" + "github.com/beeper/babbleserv/internal/databases/rooms/servers" + "github.com/beeper/babbleserv/internal/databases/rooms/state" + "github.com/beeper/babbleserv/internal/databases/rooms/users" "github.com/beeper/babbleserv/internal/notifier" "github.com/beeper/babbleserv/internal/types" "github.com/beeper/babbleserv/internal/util" - "github.com/beeper/babbleserv/internal/util/lock" ) -var ErrRequiredEventRejected = errors.New("required event rejected") +type SendEventsResult struct { + transactionDuplicate bool + versionFut fdb.FutureKey + change notifier.Change -type SendLocalEventsOptions struct { - // Ensure (+refresh) a lock is held at commit time - LockTxnRefresh lock.LockTxnRefreshFunc - - TransactionDevice *types.UserDevice - TransactionEndpoint string - TransactionID string + Allowed []*types.Event + Rejected []RejectedEvent +} - PublishRoom bool - RoomAlias id.RoomAlias - RoomAliasOwner id.UserID - RequireAllEvents bool +type RejectedEvent struct { + Event *types.Event + Error error } -// Send local events to a room, populating prev/auth events as well as authorizing -// the new events against the current room state. -func (r *RoomsDatabase) SendLocalEvents( +// Store events handles writing out all the relevant event data into FoundationDB, assuming that all +// events passed in are already authenticated and carry their state contexts. Each accepted event +// clears the extremities it replaces. The caller then stores the room's current state with +// txnStoreRoomState. +func (r *RoomsDatabase) txnStoreEvents( ctx context.Context, - roomID id.RoomID, - partialEvs []*types.PartialEvent, - userPushRules types.UserPushRulesMap, - userRoomContext types.UserRoomContextMap, - options SendLocalEventsOptions, -) (*SendEventsResult, error) { - lock, _ := r.roomLocks.GetOrSet(roomID, &sync.Mutex{}) - lock.Lock() - defer lock.Unlock() - - log := r.getTxnLogContext(ctx, "SendLocalEvents"). - Str("room_id", roomID.String()). - Int("events", len(partialEvs)). - Logger() - - ctx = log.WithContext(ctx) - - if res, err := util.DoWriteTransactionWithVersion(ctx, r.db, func(txn fdb.Transaction) (*SendEventsResult, error) { - if options.TransactionDevice != nil && options.TransactionID != "" { - if existing := r.txnGetEventForTransaction(txn, *options.TransactionDevice, roomID, options.TransactionEndpoint, options.TransactionID); existing != nil { - if options.LockTxnRefresh != nil { - options.LockTxnRefresh(txn) - } - return &SendEventsResult{Allowed: []*types.Event{existing}, transactionDuplicate: true}, nil - } - } - - room, err := r.txnGetOrCreateRoomForEvents(txn, roomID, partialEvs) - if err != nil { - return nil, err - } + txn fdb.Transaction, + room *types.Room, + evs []*types.Event, + replacedExtremities map[id.EventID][]id.EventID, + eventNotifications map[id.EventID]map[id.UserID]types.Notifications, +) (map[id.EventID]tuple.Versionstamp, error) { + if len(evs) > types.MaxVersionstampUserVersion { + return nil, fmt.Errorf("cannot store %d events in one transaction", len(evs)) + } else if len(evs) == 0 { + return nil, nil + } - eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) + notifs := 0 + for _, u := range eventNotifications { + notifs += len(u) + } + zerolog.Ctx(ctx).Debug(). + Int("events", len(evs)). + Int("event_notifications", notifs). + Msg("Storing batch of events") - allowedEvs, rejectedEvs, err := r.txnPrepareLocalEvents(ctx, txn, room, partialEvs, eventsProvider, options) - if err != nil { - return nil, err - } + var version tuple.Versionstamp + depthKey := r.KeyForRoomDepth(room.ID) + depth := types.BytesToRoomDepth(txn.Get(depthKey).MustGet()) - if options.RequireAllEvents && len(rejectedEvs) > 0 { - return nil, fmt.Errorf("%w: %w", ErrRequiredEventRejected, rejectedEvs[0].Error) + newEvs := make([]*types.Event, 0, len(evs)) + for _, ev := range evs { + if ev.Outlier { + panic("cannot pass outliers to txnStoreEvents") + } else if !ev.IsDuplicate { + newEvs = append(newEvs, ev) } + } + // The versions this transaction gives its events. They cannot be read back before it commits, + // FDB fails the read of a versionstamped value with error 1036, so an index keyed by the + // version of an event in the batch takes it from here. + batchVersions := r.events.TxnStoreEventRows(txn, true, newEvs...) + batchThreadRoots := make(map[id.EventID]struct{}) - // Get local users in the room and evaluate notifications for each event - eventNotifications := txnEvaluateNotificationsForEvents( - txn, eventsProvider, allowedEvs, userPushRules, userRoomContext, - ) - - changedUsers := make(map[id.UserID]struct{}, 1) - changedServers := make(map[string]struct{}, 1) + for _, ev := range newEvs { + zerolog.Ctx(ctx).Trace().Any("event", ev).Msg("Storing event") - if !r.txnStoreEvents(ctx, txn, room, allowedEvs, changedUsers, changedServers, eventNotifications) { - log.Warn().Msg("No events stored in send transaction") - } + eventTupBytes := types.EventTupToBytes(ev.EventTup()) - if options.RoomAlias != "" { - if err = r.txnSetRoomAlias(txn, options.RoomAlias, room.ID, options.RoomAliasOwner); err != nil { - return nil, err - } - } - if options.PublishRoom { - r.txnSetRoomPublished(txn, room, true) - } + // This is the magic FDB version which is globally ordered, we index events by this + version = batchVersions[ev.ID] - if options.TransactionDevice != nil && options.TransactionID != "" && len(allowedEvs) > 0 { - r.txnStoreEventTransaction(txn, *options.TransactionDevice, roomID, options.TransactionEndpoint, options.TransactionID, allowedEvs[0].ID) + // Rejected and soft failed events never appear to clients, so neither enters the room indices + if ev.Rejected || ev.SoftFailed { + continue } - if options.LockTxnRefresh != nil { - options.LockTxnRefresh(txn) + if ev.Depth > depth { + // Update room depth + txn.Set(depthKey, types.RoomDepthToBytes(ev.Depth)) + depth = ev.Depth } - return newSendEventsResults( - txn.GetVersionstamp(), - room, - allowedEvs, - rejectedEvs, - changedUsers, - changedServers, - ), nil - }); err != nil { - return nil, fmt.Errorf("failed to send local events: %w", err) - } else { - return r.handleSendEventsResults(res, log) - } -} - -// Prepare, but don't send, a local event - this populates the event ID, prev/auth -// events and authenticates it against the *current* state. The resulting event -// must be sent in a separate transaction using the *SendFederatedEvents* method -// which will re-evaluate the auth rules. -// Returns (evErr, err) where evErr is a rejection error (can be returned to users) -func (r *RoomsDatabase) PrepareLocalEvents(ctx context.Context, roomID id.RoomID, partialEvs []*types.PartialEvent) ([]*types.Event, []RejectedEvent, error) { - log := r.getTxnLogContext(ctx, "PrepareLocalEvents"). - Str("room_id", roomID.String()). - Logger() - - ctx = log.WithContext(ctx) - var allowed []*types.Event - var rejected []RejectedEvent - - _, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.Nil, error) { - eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) - - room, err := r.txnGetOrCreateRoomForEvents(txn, roomID, partialEvs) - if err != nil { - return nil, err + // Room indices + // room/version -> EventTup, used to sync room events to clients + txn.SetVersionstampedKey(r.events.KeyForRoomVersion(room.ID, version), eventTupBytes) + if ev.Local { + // local events room/version -> EventTup + txn.SetVersionstampedKey(r.events.KeyForLocalRoomVersion(room.ID, version), eventTupBytes) } - allowed, rejected, err = r.txnPrepareLocalEvents( - ctx, txn, room, partialEvs, eventsProvider, SendLocalEventsOptions{}, - ) - return nil, err - }) - - return allowed, rejected, err -} -func (r *RoomsDatabase) txnPrepareLocalEvents( - ctx context.Context, - txn fdb.ReadTransaction, - room *types.Room, - partialEvs []*types.PartialEvent, - eventsProvider *events.TxnEventsProvider, - options SendLocalEventsOptions, -) ([]*types.Event, []RejectedEvent, error) { - // Get the current room state which we'll use to authenticate the events - currentStateMap := r.events.TxnLookupCurrentRoomAuthAndSpecificMemberStateMap( - ctx, - txn, - room.ID, - getUserIDList(partialEvs), - eventsProvider, - ) - - var depth int64 - depthBytes := txn.Get(r.KeyForRoomDepth(room.ID)).MustGet() - if depthBytes != nil { - depth = types.BytesToRoomDepth(depthBytes) + 1 - } + // Relation events indices + relEvID, relType := ev.RelatesTo() + if relEvID != "" { + txn.SetVersionstampedKey( + r.events.KeyForRoomRelation(room.ID, relEvID, version), + tuple.Tuple{ev.ID.String(), []byte(relType)}.Pack(), + ) - // TODO: prev_events cannot be unlimited size, need to cap at the most - // recent(?) X event IDs - - // Get the current last (dangling) room events for prev_events. This may - // be multiple events if federation is involved, due to forks in the DAG. - // State forks cannot occur for local events thanks to FoundationDB, so - // we do not need to resolve the state here. State resolution is only - // needed when receiving federated events with multiple prev_events and - // thus different states on the DAG. - - // Is this correct? - // Current room state events cannot be updated out-of-order w/FoundationDB - // The auth check using current state happens within the transaction - - prevEventIDs := r.events.TxnLookupCurrentRoomExtremEventIDs(txn, room.ID) - - authProvider := events.NewTxnAuthEventsProvider(ctx, eventsProvider, currentStateMap) - - allowedEvs := make([]*types.Event, 0, len(partialEvs)) - rejectedEvs := make([]RejectedEvent, 0) - - // Send all events in the batch with the same UTC timestamp - originTimestamp := time.Now().UTC() - - keyID, key := r.config.MustGetActiveSigningKey() - - for _, partialEv := range partialEvs { - if partialEv.StateKey != nil { - // If we're a state event check for any current state that is identical (by type, key - // and content), and dedupe. - stateTup := types.StateTup{Type: partialEv.Type, StateKey: *partialEv.StateKey} - currentStateMap := r.events.TxnLookupCurrentSpecificRoomStateTupMap(txn, room.ID, []types.StateTup{stateTup}, eventsProvider) - if evID, ok := currentStateMap[stateTup]; ok { - currentEv := eventsProvider.MustGet(evID) - if currentEv != nil && util.CompareSignedJSON(partialEv.Content, currentEv.Content) { - zerolog.Ctx(ctx).Warn(). - Stringer("event_id", currentEv.ID). - Stringer("type", currentEv.Type). - Str("state_key", stateTup.StateKey). - Msg("Received duplicate state event, returning current") - // Flag as dupe (so we don't store) and return as the event - currentEv.IsDuplicate = true - allowedEvs = append(allowedEvs, currentEv) - continue + if relType == event.RelThread { + // room-threads/root-ev-version -> root event ID - only if this + // doesn't already exist (so the first reply in a thread creates). + if rootVersion, found := batchVersions[relEvID]; found { + if _, written := batchThreadRoots[relEvID]; !written { + txn.SetVersionstampedKey(r.events.KeyForRoomThread(room.ID, rootVersion), []byte(relEvID)) + batchThreadRoots[relEvID] = struct{}{} + } + } else { + relEvVersion := r.events.TxnLookupVersionForEventID(txn, relEvID) + threadKey := r.events.KeyForRoomThread(room.ID, relEvVersion) + if txn.Get(threadKey).MustGet() == nil { + txn.Set(threadKey, []byte(relEvID)) + } } } - } - partial := *partialEv - partial.Timestamp = originTimestamp.UnixMilli() - partial.Unsigned = maps.Clone(partialEv.Unsigned) - ev := &types.Event{ - PartialEvent: partial, - Local: true, - Depth: depth, - RoomVersion: room.Version, - PrevEventIDs: prevEventIDs, + if relType == event.RelAnnotation { + // room-ev-reactions/rel-ev/uid/key + // Note: dupe check is handled before we call storeEvents + txn.Set(r.events.KeyForRoomReaction(room.ID, relEvID, ev.Sender, ev.ReactionKey()), []byte(ev.ID)) + } } - ev.AuthEventIDs = authProvider.GetAuthEventIDsForEvent(ev) - - if err := util.HashAndSignEvent(ev, r.config.ServerName, keyID, key); err != nil { - rejectedEvs = append(rejectedEvs, RejectedEvent{ev, err}) - continue - } else if err := r.txnCheckEventBeforeStore(txn, room.ID, ev); err != nil { - rejectedEvs = append(rejectedEvs, RejectedEvent{ev, err}) - continue + // Store notification counts for local users + if userNotifs, ok := eventNotifications[ev.ID]; ok { + for userID, notif := range userNotifs { + r.users.TxnStoreNotification(txn, userID, room.ID, version, notif) + } } - if err := authProvider.IsEventAllowed(ev); err != nil { - zerolog.Ctx(ctx).Err(err). - Stringer("event_id", ev.ID). - Any("event", ev). - Msg("Failed to auth event against current state") - rejectedEvs = append(rejectedEvs, RejectedEvent{ev, err}) - continue + // Update room extremeties + // This is where we handle the partial DAG ordering via prev_events + // For each new event: + // store room/last/event_id empty key + // for each extremity it replaces, clear room/last/extremity_id + // The contents of room/last/ are used at event creation time to populate + // prev_events, thus any DAG split can be corrected by sending an event. + for _, extremityID := range replacedExtremities[ev.ID] { + r.events.TxnDeleteRoomExtremEventID(txn, room.ID, extremityID) } + // Set this last, so rooms always have a last event + r.events.TxnSetRoomExtremEventID(txn, room.ID, ev.ID) + } - // Event is allowed, use as next prev and bump depths - prevEventIDs = []id.EventID{ev.ID} - depth += 1 - - r.txnPreProcessEventUnsigned(txn, eventsProvider, ev) - - allowedEvs = append(allowedEvs, ev) - eventsProvider.Add(ev) - - zerolog.Ctx(ctx).Debug(). - Stringer("event_id", ev.ID). - Stringer("type", ev.Type). - Msg("Event authorized for storage") + if len(batchVersions) > 0 { + // Bump the room version to the max + txn.SetVersionstampedValue(r.KeyForRoomVersion(room.ID), types.MustVersionstampToBytes(version)) } - return allowedEvs, rejectedEvs, nil + return batchVersions, nil } -// Sends an event we don't actually want stored in a room, but only on the relevant users membership -// stream so they can see them, plus the raw event bytes. Simply sets a small subset of keys we -// normally set during event store. Future non-outlier memberships will overwrite. -func (r *RoomsDatabase) SendFederatedOutlierMembershipEvent(ctx context.Context, ev *types.Event) error { - if ev.Type != event.StateMember { - panic("outlier event is not a member event") +// txnStoreRoomState moves the room's current state through the prepared steps once the transaction +// stored their events: the contexts, a history row per step, the room record's fields, the server +// rows and local users' rows. The publish then holding more than publishMandatoryMaxBytes fails with +// types.ErrRoomTooLarge, otherwise the room record is written. +func (r *RoomsDatabase) txnStoreRoomState( + ctx context.Context, + txn fdb.Transaction, + room *types.Room, + prepared *preparedEvents, + versions map[id.EventID]tuple.Versionstamp, + changed *membershipChanges, +) error { + stateBatch, steps := prepared.stateBatch, prepared.stateSteps + previous := *room + if len(steps) > 0 { + room.CurrentState = steps[len(steps)-1].state } - // Flag the event as an outlier so we only store it without adding to the room/state - ev.Outlier = true - - userID := id.UserID(*ev.StateKey) - - versionFut, err := util.DoWriteTransactionWithVersion(ctx, r.db, func(txn fdb.Transaction) (fdb.FutureKey, error) { - // Important to check that we're not in the room inside the write txn - if r.servers.TxnIsServerJoinedRoom(txn, r.config.ServerName, ev.RoomID) { - return nil, fmt.Errorf("cannot send outlier events to rooms this server is participating in") - } - - eventTupBytes := types.EventTupToBytes(ev.EventTup()) + stateBatch.TxnWrite(txn, prepared.stateContexts()...) + prepared.authGraph.Write(txn) + diffedSteps, err := prepared.txnDiffStateSteps(txn, previous.CurrentState) + if err != nil { + return err + } + published := make([]publishStep, len(diffedSteps)) + for i, step := range diffedSteps { + version, found := versions[step.eventID] + if !found { + return fmt.Errorf("current state moved by %s, which is not stored with it", step.eventID) + } + r.events.TxnStoreRoomStateStep(txn, room.ID, version, step.state) + published[i] = publishStep{version: version, eventID: step.eventID, changes: step.changes} + } - // Firstly, store the event itself - r.events.TxnStoreEvent(txn, ev) - - // Store global version -> EventTup, this is our index of all events - version := tuple.IncompleteVersionstamp(0) - txn.SetVersionstampedKey(r.events.KeyForVersion(version), eventTupBytes) - - // Store the membership and membership change for the user - mtup := ev.MembershipTup() - r.users.TxnStoreMembership(txn, userID, ev.RoomID, mtup) - r.users.TxnStoreMembershipChange(txn, userID, version, mtup) - - return txn.GetVersionstamp(), nil - }) - - if err == nil { - r.notifier.SendChange(notifier.Change{ - UserIDs: []id.UserID{userID}, - }) - zerolog.Ctx(ctx).Debug(). - Stringer("event_id", ev.ID). - Stringer("target_user_id", userID). - Stringer("sender", ev.Sender). - Str("membership", string(ev.Membership())). - Bool("is_outlier", ev.Outlier). - Any("versionstamp", types.DecodeRawVersionstamp(versionFut.MustGet())). - Msg("Stored outlier membership event") + room.StateRevision++ + if err := r.txnStoreRoomFields(prepared.eventsProvider, room, published); err != nil { + return err + } + madeJoined := previous.LocalMembers == 0 && room.LocalMembers > 0 + if err := r.txnStoreServerRows(ctx, txn, room.ID, madeJoined, published, changed); err != nil { + return err } - return err + if err := r.txnStoreLocalMemberRows(txn, room.ID, published, changed); err != nil { + return err + } + if size, err := txn.GetApproximateSize().Get(); err != nil { + return err + } else if err := r.checkPublishSize(size); err != nil { + return err + } + r.txnStoreRoom(txn, room, previous.MemberCount) + return nil } -type SendFederatedEventsOptions struct { - // Skip stage 5 auth of the state at each events prev_events. This is required when doing remote - // joins where we don't have the history of the room prior to the join. - RemoteJoinEventID id.EventID +// checkPublishSize fails a publish holding more than config.PublishMandatoryMaxBytes before the room +// record with types.ErrRoomTooLarge, leaving room under FoundationDB's 10 MB for the record and a +// send's extra writes. +func (r *RoomsDatabase) checkPublishSize(size int64) error { + if size > config.PublishMandatoryMaxBytes { + return fmt.Errorf("%w: %d bytes before the room record", types.ErrRoomTooLarge, size) + } + return nil } -var ( - ErrAuthStage4 = errors.New("failed to auth event (step 4)") - ErrAuthStage5 = errors.New("failed to auth event (step 5)") -) - -// Send federated events to a room after passing through all the required -// authorization checks. (steps 4-6: https://spec.matrix.org/v1.10/server-server-api/#checks-performed-on-receipt-of-a-pdu) -// -// This method assumes all remote fetching of events has been completed and are -// included in evs, any that are not will be rejected. Since this entire batch -// must be executed in a single FDB txn we only have 5s, so can't waste time -// fetching events. -func (r *RoomsDatabase) SendFederatedEvents( - ctx context.Context, - roomID id.RoomID, - evs []*types.Event, - userPushRules types.UserPushRulesMap, - userRoomContext types.UserRoomContextMap, - options SendFederatedEventsOptions, -) (*SendEventsResult, error) { - lock, _ := r.roomLocks.GetOrSet(roomID, &sync.Mutex{}) - lock.Lock() - defer lock.Unlock() - - log := r.getTxnLogContext(ctx, "SendFederatedEvents"). - Str("room_id", roomID.String()). - Int("events", len(evs)). - Logger() - ctx = log.WithContext(ctx) +// publishStep is what one step of a publish changed in current state, written at its event's version +type publishStep struct { + version tuple.Versionstamp + eventID id.EventID + changes []types.StateChange +} - userIDs := getUserIDList(util.EventsToPartialEvents(evs)) - rejectedEvs := make([]RejectedEvent, 0) +func isJoinEvent(ev *types.Event) bool { + return ev.Type == event.StateMember && ev.Membership() == event.MembershipJoin +} - var eventsProvider *events.TxnEventsProvider - var err error - var room *types.Room +// joinedDelta is what a member change adds to the room's joined members +func (r *RoomsDatabase) joinedDelta(change types.StateChange) int { + switch was, is := change.OldMembership == event.MembershipJoin, change.NewMembership == event.MembershipJoin; { + case is && !was: + return 1 + case was && !is: + return -1 + } + return 0 +} - // First read only transaction - pre-check room can federated, reject dupes - // and apply the first authorization check: - // Step 4: Passes authorization rules based on the event’s auth events, otherwise it is rejected. - if _, err = util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.Nil, error) { - room, err = r.txnGetOrCreateRoomForEvents(txn, roomID, util.EventsToPartialEvents(evs)) - if err != nil { - return nil, err - } - if !room.Federated { - return nil, errors.New("this room is not federated") - } +var encryptionTup = types.StateTup{Type: event.StateEncryption} - // Setup our initial events provider prefilled with the input events - // this is safe because the provider merely provides key value access - // without any authorization. - eventsProvider = r.events.NewTxnEventsProvider(ctx, txn).WithEvents(evs...) +// roomSummaryField is a room summary field, held by the state event of its type with an empty state +// key, under contentKey of its content +type roomSummaryField struct { + contentKey string + field func(room *types.Room) *string +} - // Quickly initiate fetches of all the events direct auth+prev events - for _, ev := range evs { - // For each event we need it's auth events - for _, evID := range ev.AuthEventIDs { - eventsProvider.WillGet(evID) - } - // And for each event's prev events we need their auth events, first - // grab the prev events themselves. - for _, evID := range ev.PrevEventIDs { - eventsProvider.WillGet(evID) - } - } +var roomSummaryFields = map[string]roomSummaryField{ + event.StateRoomName.Type: {"name", func(room *types.Room) *string { return &room.Name }}, + event.StateTopic.Type: {"topic", func(room *types.Room) *string { return &room.Topic }}, + event.StateRoomAvatar.Type: {"url", func(room *types.Room) *string { return &room.AvatarURL }}, + event.StateCanonicalAlias.Type: {"alias", func(room *types.Room) *string { return &room.CanonicalAlias }}, + event.StateJoinRules.Type: {"join_rule", func(room *types.Room) *string { return &room.JoinRule }}, + event.StateHistoryVisibility.Type: {"history_visibility", func(room *types.Room) *string { return &room.HistoryVisibility }}, + event.StateGuestAccess.Type: {"guest_access", func(room *types.Room) *string { return &room.GuestAccess }}, +} - // Also check for any events we already have so we can reject the dupe - eventIDToVersionFut := make(map[id.EventID]fdb.FutureByteSlice) - for _, ev := range evs { - eventIDToVersionFut[ev.ID] = txn.Get(r.events.KeyForIDToVersion(ev.ID)) - } +func (r *RoomsDatabase) roomSummaryFieldOf(tup types.StateTup) (roomSummaryField, bool) { + if tup.StateKey != "" { + return roomSummaryField{}, false + } + summary, found := roomSummaryFields[tup.Type.Type] + return summary, found +} - for _, ev := range evs { - evLog := log.With(). - Str("event_id", ev.ID.String()). - Str("type", ev.Type.String()). - Logger() - - // Check if we already have this event - can happen if another server - // gets confused and sends a duplicate. - evExists, err := eventIDToVersionFut[ev.ID].Get() - var isDuplicate bool - if err != nil { - return nil, err - } else if evExists != nil { - if ev.Type == event.StateMember { - // We might be un-outlier-ing a federated member event - currentEv := r.events.TxnGetEvent(txn, ev.ID) - if !currentEv.Outlier { - isDuplicate = true - } - } else { - isDuplicate = true - } - } - if isDuplicate { - // Flag as a dupe - but we'll still continue to process/auth it below as events - // later in the batch may rely on it if is state. - evLog.Warn().Msg("Received duplicate event we already know about") - ev.IsDuplicate = true - currentEv := eventsProvider.MustGet(ev.ID) - if currentEv.Rejected { - // Make sure we match the dupe - ev.Rejected = true - rejectedEvs = append(rejectedEvs, RejectedEvent{ev, types.ErrAlreadyExists}) +// txnStoreRoomFields moves the room record's member counts and summary fields by the steps' +// changes, reading only the events that set a summary field. +func (r *RoomsDatabase) txnStoreRoomFields( + eventsProvider *events.TxnEventsProvider, + room *types.Room, + steps []publishStep, +) error { + for _, step := range steps { + step.willGetChangedEvents(eventsProvider) + } + for _, step := range steps { + for _, change := range step.changes { + if change.Type == event.StateMember { + delta := r.joinedDelta(change) + room.MemberCount += delta + if r.isLocalUser(id.UserID(change.StateKey)) { + room.LocalMembers += delta } continue - } else if err := r.txnCheckEventBeforeStore(txn, roomID, ev); err != nil { - ev.Rejected = true - rejectedEvs = append(rejectedEvs, RejectedEvent{ev, err}) + } + if change.StateTup == encryptionTup { + room.Encrypted = change.NewEventID != "" + } + summary, found := r.roomSummaryFieldOf(change.StateTup) + if !found { continue } - - ev.RoomVersion = room.Version - - var authEventsMissing bool - var authErr error - authEvStateMap := make(types.StateMap) - - for _, authID := range ev.AuthEventIDs { - authEv, err := eventsProvider.Get(authID) + value := "" + if change.NewEventID != "" { + ev, err := eventsProvider.GetRequired(change.NewEventID) if err != nil { - return nil, fmt.Errorf("failed to fetch auth event: %w", err) - } else if authEv == nil || authEv.Rejected { - // If auth event missing or rejected: go straight to fail - authEventsMissing = true - authErr = fmt.Errorf("auth event missing or rejected: %s", authID) - break + return err } - if authEv.StateKey == nil { - // Should we reject this event if the auth events contain non-state events? - // The spec language is "should be the following subset of the room state" - // https://spec.matrix.org/v1.11/server-server-api/#auth-events-selection - evLog.Warn().Msg("Ignoring non-state auth event") - continue - } - authEvStateMap[types.StateTup{ - Type: authEv.Type, - StateKey: *authEv.StateKey, - }] = authEv.ID - } - - if !authEventsMissing { - authEvAuthProvider := events.NewTxnAuthEventsProvider(ctx, eventsProvider, authEvStateMap) - authErr = authEvAuthProvider.IsEventAllowed(ev) - } - if authEventsMissing || authErr != nil { - log.Err(authErr). - Any("event", ev). - Str("event_id", ev.ID.String()). - Msg("Failed to auth event (step 4)") - // Flag event as rejected - we still store it - ev.Rejected = true - rejectedEvs = append(rejectedEvs, RejectedEvent{ - ev, - fmt.Errorf("%w: %w", ErrAuthStage4, authErr), - }) - } else { - evLog.Trace().Msg("Event passed authorization step 4") + value = gjson.GetBytes(ev.Content, summary.contentKey).String() } + *summary.field(room) = value } - return nil, nil - }); err != nil { - return nil, err } + return nil +} - // Second read only transaction, second authorization check: - // Step 5: Passes authorization rules based on the state before the event, otherwise it is rejected. - if _, err = util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.Nil, error) { - if options.RemoteJoinEventID != "" { - return nil, nil +// willGetChangedEvents starts reading the events the changes set room summary fields to +func (step publishStep) willGetChangedEvents(eventsProvider *events.TxnEventsProvider) { + for _, change := range step.changes { + if _, found := roomSummaryFields[change.Type.Type]; found && change.StateKey == "" && change.NewEventID != "" { + eventsProvider.WillGet(change.NewEventID) } + } +} - // New provider for this txn copying any events we pulled in the last - eventsProvider = r.events.NewTxnEventsProvider(ctx, txn).WithProviderEvents(eventsProvider) +// serverCrossing is a server's joined member count of a room crossing zero in a step, with the +// membership its server rows record +type serverCrossing struct { + server string + membership types.MembershipTup +} - // Now for each event fetch any prev event's auth events *if we already have the prev - // event* (we often will not). - for _, ev := range evs { - if ev.Rejected { +// countJoinedMembers moves each server's joined member count of the room by each step's member +// changes, and returns the servers whose count crosses zero in each step: joined with the step's +// first join of their members, left with its last loss, or with the step's event when that loss +// removed the member key. +func (r *RoomsDatabase) countJoinedMembers(roomID id.RoomID, counts map[string]int, steps []publishStep) [][]serverCrossing { + crossings := make([][]serverCrossing, len(steps)) + for i, step := range steps { + before := make(map[string]int) + joins := make(map[string]types.StateChange) + losses := make(map[string]types.StateChange) + var order []string + for _, change := range step.changes { + delta := 0 + if change.Type == event.StateMember { + delta = r.joinedDelta(change) + } + if delta == 0 { continue } - for _, prevID := range ev.PrevEventIDs { - // Grab the prev event, will block if not loaded yet - prevEv, err := eventsProvider.Get(prevID) - if err != nil { - return nil, fmt.Errorf("failed to fetch prev event") - } else if prevEv == nil { - // We may not have this event as it could be in the ev list, - // so we'll do the state fetch on-demand later. - continue - } - // And the prev events auth events - for _, pEvID := range prevEv.AuthEventIDs { - eventsProvider.WillGet(pEvID) - } - // TODO: potentially kick off range fetches for the state at - // time of prev (don't need futures, FDB client will cache)? + server := id.UserID(change.StateKey).Homeserver() + if _, found := before[server]; !found { + before[server] = counts[server] + order = append(order, server) } - } - - // Keep track of state at each event as we authenticate them (including the event itself) - // so we can use it in the prev state checks. Often we'll be persisting a batch of events - // that refer to one another as prev_events and this enables doing that. - evIDToStateMap := make(map[id.EventID]types.StateMap, len(evs)) - getStateAtEvent := func(evID id.EventID) types.StateMap { - if sMap, found := evIDToStateMap[evID]; found { - return sMap + counts[server] = max(counts[server]+delta, 0) + if _, found := joins[server]; !found && delta > 0 { + joins[server] = change + } else if delta < 0 { + losses[server] = change } - return r.events.TxnLookupRoomAuthAndSpecificMemberStateMapAtEvent( - ctx, - txn, - roomID, - userIDs, - evID, - eventsProvider, - ) } - - for _, ev := range evs { - evLog := log.With(). - Stringer("event_id", ev.ID). - Stringer("type", ev.Type). - Logger() - - var prevEvStateMap types.StateMap - if len(ev.PrevEventIDs) == 1 { - if sMap, found := evIDToStateMap[ev.PrevEventIDs[0]]; found { - prevEvStateMap = sMap - } else { - prevEvStateMap = getStateAtEvent(ev.PrevEventIDs[0]) + for _, server := range order { + switch { + case before[server] == 0 && counts[server] > 0: + join := joins[server] + crossings[i] = append(crossings[i], serverCrossing{server, types.MembershipTup{ + EventID: join.NewEventID, RoomID: roomID, Membership: event.MembershipJoin, + }}) + case before[server] > 0 && counts[server] == 0: + eventID := losses[server].NewEventID + if eventID == "" { + eventID = step.eventID } - } else { - // We have multiple prev events, so we need to get the state at each, combine them - // together and perform state resolution to get the state we need. - stateEvs := make(map[id.EventID]struct{}) - for _, evID := range ev.PrevEventIDs { - sMap, found := evIDToStateMap[evID] - if !found { - sMap = getStateAtEvent(evID) - } - for _, evID := range sMap { - stateEvs[evID] = struct{}{} - } - } - prevEvStateMap, err = r.txnResolveStateForEvents(txn, stateEvs, room.Version, eventsProvider) - if err != nil { - return nil, fmt.Errorf("failed to get state at prev event(s): %w", err) - } - } - // If we've no previous state and we're not creating the room - fail - if len(prevEvStateMap) == 0 && ev.Type != event.StateCreate { - evLog.Error(). - Any("event", ev). - Stringer("event_id", ev.ID). - Any("prev_event_ids", ev.PrevEventIDs). - Msg("Failed to auth event (step 5): prev event(s) not found") - ev.Rejected = true - rejectedEvs = append(rejectedEvs, RejectedEvent{ - ev, - fmt.Errorf("failed to auth event (step 5): prev event(s) not found: %s", ev.PrevEventIDs), - }) - continue + crossings[i] = append(crossings[i], serverCrossing{server, types.MembershipTup{ + EventID: eventID, RoomID: roomID, Membership: event.MembershipLeave, + }}) } + } + } + return crossings +} - // Store the state map for the event now, before we finish authorizing it, we'll update - // it with the event iself if it is allowed. - evIDToStateMap[ev.ID] = prevEvStateMap - - // Important that we skip rejected *after* fetching any prev state, if a later event - // refers to the rejected event as it's prev we must use the prior state. - if ev.Rejected { - continue - } +// recordsServerChange reports whether a server crossing zero in a publish gets a membership change +// row: every crossing but other servers' joins in a publish making this server joined, as the +// federation sender starts every server's stream of the room at this server's join. +func (crossing serverCrossing) recordsServerChange(localServer string, madeJoined bool) bool { + return !madeJoined || crossing.server == localServer || crossing.membership.Membership != event.MembershipJoin +} - prevStateAuthProvider := events.NewTxnAuthEventsProvider(ctx, eventsProvider, prevEvStateMap) - if err := prevStateAuthProvider.IsEventAllowed(ev); err != nil { - evLog.Err(err). - Any("event", ev). - Stringer("event_id", ev.ID). - Msg("Failed to auth event (step 5)") - // Flag event as rejected - we still store it - ev.Rejected = true - rejectedEvs = append(rejectedEvs, RejectedEvent{ - ev, - fmt.Errorf("%w: %w", ErrAuthStage5, err), - }) - continue - } else { - evLog.Trace().Msg("Event passed authorization step 5") - if ev.StateKey != nil { - // Update the state at this event to include it - evIDToStateMap[ev.ID][ev.StateTup()] = ev.ID - } +// txnStoreServerRows writes each changed server's joined member count, and for a server whose count +// crosses zero its membership and membership change, see recordsServerChange. +func (r *RoomsDatabase) txnStoreServerRows( + ctx context.Context, + txn fdb.Transaction, + roomID id.RoomID, + madeJoined bool, + steps []publishStep, + changed *membershipChanges, +) error { + var serverNames []string + for _, step := range steps { + for _, change := range step.changes { + if change.Type == event.StateMember && r.joinedDelta(change) != 0 { + serverNames = append(serverNames, id.UserID(change.StateKey).Homeserver()) } } - return nil, nil - }); err != nil { - return nil, err } - - // The actual write transaction and final authorization step: - // Step 6: Passes authorization rules based on the current state of the room, otherwise it is “soft failed”. - // Bonus: we must also handle state resolution here if the room now has multiple extremeties, as - // this affects the current room state. - if res, err := util.DoWriteTransactionWithVersion(ctx, r.db, func(txn fdb.Transaction) (*SendEventsResult, error) { - thisServerInRoom := r.servers.TxnIsServerJoinedRoom(txn, r.config.ServerName, roomID) - // Important to check that we're in the room inside the write txn - unless remote join - if options.RemoteJoinEventID == "" && !thisServerInRoom { - return nil, fmt.Errorf("cannot send federated events to rooms this server is not participating in") + slices.Sort(serverNames) + serverNames = slices.Compact(serverNames) + futures := r.servers.TxnReadJoinedCounts(txn, roomID, serverNames) + counts := make(map[string]int, len(futures)) + for serverName, future := range futures { + b, err := future.Get() + if err != nil { + return err } + counts[serverName] = servers.JoinedCountOf(b) + } + before := maps.Clone(counts) - // New provider for this txn copying any events we pulled in the last two - eventsProvider = r.events.NewTxnEventsProvider(ctx, txn).WithProviderEvents(eventsProvider) - - // Get the current room auth state + members - // Get the current room state which we'll use to authenticate the events - currentStateMap := r.events.TxnLookupCurrentRoomAuthAndSpecificMemberStateMap( - ctx, - txn, - roomID, - userIDs, - eventsProvider, - ) - - // Use this auth provider throughout as we accept events after step 6 - currentStateAuthProvider := events.NewTxnAuthEventsProvider(ctx, eventsProvider, currentStateMap) - - for _, ev := range evs { - if ev.Rejected { - continue - } - evLog := log.With(). - Str("event_id", ev.ID.String()). - Str("type", ev.Type.String()). - Logger() - - if err := currentStateAuthProvider.IsEventAllowed(ev); err != nil { - evLog.Err(err). - Any("event", ev). - Str("event_id", ev.ID.String()). - Msg("Soft failed to auth event (step 6)") - // Flag the event as soft failed to exclude it from room indices and current state - ev.SoftFailed = true - } else { - evLog.Trace().Msg("Event passed authorization step 6") - } - - r.txnPreProcessEventUnsigned(txn, eventsProvider, ev) - - eventsProvider.Add(ev) - evLog.Debug().Msg("Event authorized for storage") + crossings := r.countJoinedMembers(roomID, counts, steps) + for _, serverName := range serverNames { + if counts[serverName] != before[serverName] { + r.servers.TxnSetJoinedCount(txn, roomID, serverName, counts[serverName]) } - - // Note: federated events use fallback notification evaluation (no push rules) - eventNotifications := txnEvaluateNotificationsForEvents( - txn, eventsProvider, evs, userPushRules, userRoomContext, - ) - - changedUsers := make(map[id.UserID]struct{}, 1) - changedServers := make(map[string]struct{}, 1) - - if !r.txnStoreEvents(ctx, txn, room, evs, changedUsers, changedServers, eventNotifications) { - log.Warn().Msg("No events stored in send transaction") - } else { - if options.RemoteJoinEventID != "" && !thisServerInRoom { - // We're joining the room for the first time, so overwrite the room extremeties to - // the join event just created via the make/send federation handshake. - r.events.TxnResetRoomExtremEventIDs(txn, roomID, options.RemoteJoinEventID) - } else { - // We're not joining (or already were) - check if we need to perform state res - if err := r.txnResolveRoomState(ctx, txn, room, evs, changedUsers, changedServers, eventsProvider); err != nil { - return nil, fmt.Errorf("failed to resolve room state: %w", err) - } + } + for i, step := range steps { + for _, crossing := range crossings[i] { + zerolog.Ctx(ctx).Debug(). + Str("server", crossing.server). + Str("membership", string(crossing.membership.Membership)). + Msg("Server membership of room changed") + changed.servers[crossing.server] = struct{}{} + r.servers.TxnStoreServerMembership(txn, roomID, crossing.server, crossing.membership) + if crossing.recordsServerChange(r.config.ServerName, madeJoined) { + r.servers.TxnStoreServerMembershipChange(txn, crossing.server, step.version, crossing.membership) } } - - return newSendEventsResults( - txn.GetVersionstamp(), - room, - evs, - rejectedEvs, - changedUsers, - changedServers, - ), nil - }); err != nil { - return nil, fmt.Errorf("failed to send federated events: %w", err) - } else { - return r.handleSendEventsResults(res, log) } + return nil } -// Perform state resolution on a room if needed, called whenever persisting federated events. -// Resolution is needed whenever: -// - find extremities with different IDs vs. the last time we did state res -// - if >1 of those, multiple forks have changed and we need to resolve the state -// To resolve the state, take: -// - current state -// - state *at* the time of last resolution -// - state *changes* from the time of last resolution to now -// This captures all the state events that could potentially alter the state of the room. We pass -// them all into the state resolution algorithm (from gomatrixserverlib) to get the resolved state. -// Finally we save the new resolved state position with the new extremity list. -func (r *RoomsDatabase) txnResolveRoomState( - ctx context.Context, +// txnStoreLocalMemberRows writes local users' rows of the steps' member changes, see +// localMemberWrites, reading the rows they replace first. +func (r *RoomsDatabase) txnStoreLocalMemberRows( txn fdb.Transaction, - room *types.Room, - evs []*types.Event, - changedUsers map[id.UserID]struct{}, - changedServers map[string]struct{}, - eventsProvider *events.TxnEventsProvider, + roomID id.RoomID, + steps []publishStep, + changed *membershipChanges, ) error { - log := zerolog.Ctx(ctx) - - // Now we check the room extremeties to see if we need to resolve forked states - extremEventIDs := r.events.TxnLookupCurrentRoomExtremEventIDs(txn, room.ID) - if len(extremEventIDs) == 1 { - // Room has one extremety in the DAG (allowedEvs[-1]) so current state is correct - return nil - } - - lastResolvedIDsKey := r.events.KeyForRoomLastResolvedExtrems(room.ID) - lastResolvedVersionKey := r.events.KeyForRoomLastResolvedVersion(room.ID) - - var prevResVersion tuple.Versionstamp - - // Now check if we've resolved state before, get the IDs and version of that - prevResolvedExtremBytes := txn.Get(lastResolvedIDsKey).MustGet() - if prevResolvedExtremBytes == nil { - // Initial case: we've never done state res on this room, meaning this batch of events is - // responsible for forking the state. So grab the state *before* this batch by using the - // first prev event of the first event. This might over-fetch by being too old but that's - // absolutely fine, the resolution algorithm will handle it. - oldestEventID := evs[0].PrevEventIDs[0] // this should, by definition, be outside the batch - prevResVersion = r.events.TxnLookupVersionForEventID(txn, oldestEventID) - } else { - // We have resolved state in the room before - we now check which extremity event IDs we did - // that for. If there's only one different one in the current set we can skip state res as - // only one fork has any changes, thus there's nothing to resolve. - prevResolvedExtremIDs, _ := tuple.Unpack(prevResolvedExtremBytes) - - var changed int - for _, evIDAny := range prevResolvedExtremIDs { - evID := id.EventID(evIDAny.(string)) - for _, currentID := range extremEventIDs { - if evID != currentID { - changed += 1 + futures := make(map[id.UserID]fdb.FutureByteSlice) + for _, step := range steps { + for _, change := range step.changes { + if userID := id.UserID(change.StateKey); change.Type == event.StateMember && r.isLocalUser(userID) { + if _, found := futures[userID]; !found { + futures[userID] = txn.Get(r.users.KeyForMembership(userID, roomID)) } } } - if changed <= 1 { - zerolog.Ctx(ctx).Debug().Msg("Room extremities resolved, skipping state resolution") - return nil - } - - prevResVersion = types.MustBytesToVersionstamp(txn.Get(lastResolvedVersionKey).MustGet()) - } - - log.Warn(). - Strs("extreme_event_ids", util.StringersToStrs(extremEventIDs)). - Msg("Room has unresolved extremeties, performing state resolution") - - // Collect events: current - currentStateMap := r.events.TxnLookupCurrentRoomStateAndMemberMap(txn, room.ID, eventsProvider) - - // State at point of last res - stateAtLastResMap := r.events.TxnLookupRoomStateAndMemberMapAtVersion(txn, room.ID, prevResVersion, eventsProvider) - // All state changes from last res -> now, this will include events from all forks (that passed - // authorization). - stateChangesSinceLastRes := r.events.TxnPaginateRoomStateEventTups(txn, room.ID, types.PaginationOptions{ - From: prevResVersion, - To: util.TxnGetLatestWriteVersion(txn), - Mode: fdb.StreamingModeWantAll, - }, eventsProvider) - - // Combine, resolve - eventMap := make(map[id.EventID]struct{}, len(currentStateMap)+len(stateAtLastResMap)+len(stateChangesSinceLastRes)) - for _, evID := range currentStateMap { - eventMap[evID] = struct{}{} - } - for _, evID := range stateAtLastResMap { - eventMap[evID] = struct{}{} } - for _, tup := range stateChangesSinceLastRes { - eventMap[tup.EventID] = struct{}{} - } - resolvedStateMap, err := r.txnResolveStateForEvents(txn, eventMap, room.Version, eventsProvider) + stored, err := users.MembershipRowsOf(futures) if err != nil { - return fmt.Errorf("failed to resolve state for events: %w", err) + return err } - - // Apply changes - // Now find differences between the current state and the resolved current state, and update - // the relevant current state keys. Note we're not sending any new events here (so clients - // will not get these changes: https://github.com/matrix-org/matrix-spec/issues/1209). This - // is only to ensure that incoming new events are authenticated against this resolved state. - toClear := make([]types.EventStateTup, 0) - toSet := make([]types.EventStateTup, 0) - for tup, evID := range currentStateMap { - if _, found := resolvedStateMap[tup]; !found { - toClear = append(toClear, types.EventStateTup{ - StateTup: tup, - EventID: evID, - }) + for _, write := range r.localMemberWrites(roomID, steps, stored) { + changed.users[write.userID] = struct{}{} + localKey := r.keyForLocalMember(roomID, write.userID) + if write.row == nil { + r.users.TxnDeleteMembership(txn, write.userID, roomID) + txn.Clear(localKey) + continue } - } - for tup, evID := range resolvedStateMap { - if id, found := currentStateMap[tup]; !found || id != evID { - toSet = append(toSet, types.EventStateTup{ - StateTup: tup, - EventID: evID, - }) + r.users.TxnStoreMembership(txn, write.userID, roomID, *write.row) + if write.row.Membership == event.MembershipJoin { + txn.Set(localKey, types.MembershipTupToBytes(write.row.MembershipTup)) + } else { + txn.Clear(localKey) } - } - - log.Warn(). - Int("to_set", len(toSet)). - Int("to_clear", len(toClear)). - Int("existing", len(currentStateMap)). - Int("resolved", len(resolvedStateMap)). - Msg("Applying resolved room state") - - previousMemberCount := room.MemberCount - var roomChanged bool - for _, tup := range toClear { - log.Trace().Any("state", tup).Msg("Delete state event references") - if r.txnDeleteStateEvent(txn, room, eventsProvider.MustGet(tup.EventID)) { - roomChanged = true + if write.changed { + r.users.TxnStoreMembershipChange(txn, write.userID, write.version, write.row.MembershipTup) } } + return nil +} - latestVersion := evs[len(evs)-1].IncompleteVersion - - for _, tup := range toSet { - log.Trace().Any("state", tup).Msg("Store state event references") - // Increase the version because we want the event in question (restored state) to appear - // after previous state. This means the historical states of the events persisted in - // this batch may differ between servers, but should converge on the resolved state. - // TODO: this is very dangerous if the state reset has >65k changes - latestVersion.UserVersion += 1 - ev := eventsProvider.MustGet(tup.EventID) - if r.txnStoreStateEvent(ctx, txn, room, ev, latestVersion, changedUsers, changedServers) { - roomChanged = true - } - } +// localMemberWrite is a local user's member change as a publish writes it: the user's membership row +// and the room's local joined member, both cleared for a member key that left the room's state when +// row is nil, and when changed a membership change at version. +type localMemberWrite struct { + userID id.UserID + row *types.MembershipRow + changed bool + version tuple.Versionstamp +} - if roomChanged { - r.txnStoreRoom(txn, room, previousMemberCount) +// localMemberWrites returns the writes of local users' member changes in step order, each at its +// step's version, given the rows stored before the publish, see writesMembershipChange. Remote users +// have no rows. +func (r *RoomsDatabase) localMemberWrites( + roomID id.RoomID, + steps []publishStep, + stored map[id.UserID]*types.MembershipRow, +) []localMemberWrite { + stored = maps.Clone(stored) + var writes []localMemberWrite + for _, step := range steps { + for _, change := range step.changes { + userID := id.UserID(change.StateKey) + if change.Type != event.StateMember || !r.isLocalUser(userID) { + continue + } + write := localMemberWrite{userID: userID, version: step.version} + if change.NewEventID != "" { + write.row = &types.MembershipRow{ + MembershipTup: types.MembershipTup{EventID: change.NewEventID, RoomID: roomID, Membership: change.NewMembership}, + } + } + write.changed = write.writesMembershipChange(stored[userID]) + stored[userID] = write.row + writes = append(writes, write) + } } + return writes +} - // Re-pack the last resolved tup and save - evIDs := make([]tuple.TupleElement, len(extremEventIDs)) - for i, id := range extremEventIDs { - evIDs[i] = id.String() - } - txn.Set(lastResolvedIDsKey, append(tuple.Tuple{}, evIDs...).Pack()) - txn.SetVersionstampedValue(lastResolvedVersionKey, types.MustVersionstampToBytes(latestVersion)) - return nil +// writesMembershipChange reports whether a local user's change gets a membership change row for +// sync, given the row it replaces: only when that held another event, so each change is delivered +// once, and never for a member key that left the room's state, which sync cannot express. +func (w localMemberWrite) writesMembershipChange(stored *types.MembershipRow) bool { + return w.row != nil && (stored == nil || stored.EventID != w.row.EventID) } -// Store events handles writing out all the relevant event data into FoundationDB, assuming that all -// events passed in are already authenticated. -func (r *RoomsDatabase) txnStoreEvents( - ctx context.Context, - txn fdb.Transaction, - room *types.Room, - evs []*types.Event, - changedUsers map[id.UserID]struct{}, - changedServers map[string]struct{}, - eventNotifications map[id.EventID]map[id.UserID]types.Notifications, -) bool { - if len(evs) > types.MaxVersionstampUserVersion { - panic("not safe to write this many events in one transaction") - } else if len(evs) == 0 { - return false +// txnGetOrCreateRoomForEvents returns the room events are sent to: the stored room, or the one the +// batch's create event creates, which must come first. +func (r *RoomsDatabase) txnGetOrCreateRoomForEvents( + txn fdb.ReadTransaction, + roomID id.RoomID, + evs []*types.PartialEvent, +) (*types.Room, error) { + room, err := r.txnGetRoom(txn, roomID) + if errors.Is(err, types.ErrRoomNotFound) { + return r.newRoomFromEvents(roomID, evs) } + return room, err +} - notifs := 0 - for _, u := range eventNotifications { - notifs += len(u) +func (r *RoomsDatabase) newRoomFromEvents(roomID id.RoomID, evs []*types.PartialEvent) (*types.Room, error) { + if len(evs) == 0 || evs[0].Type != event.StateCreate { + return nil, fmt.Errorf("%w: %s", types.ErrRoomNotFound, roomID) } - zerolog.Ctx(ctx).Debug(). - Int("events", len(evs)). - Int("event_notifications", notifs). - Msg("Storing batch of events") - var version tuple.Versionstamp - depthKey := r.KeyForRoomDepth(room.ID) - depth := types.BytesToRoomDepth(txn.Get(depthKey).MustGet()) + room := types.Room{ID: roomID, CurrentState: state.EmptyContext} - previousMemberCount := room.MemberCount - var eventsStored bool - var roomChanged bool - - for i, ev := range evs { - if ev.Outlier { - panic("cannot pass outliers to txnStoreEvents") - } else if ev.IsDuplicate { - continue - } - - zerolog.Ctx(ctx).Trace().Any("event", ev).Msg("Storing event") - - eventsStored = true - if ev.Type == event.StateCreate { - roomChanged = true - } + room.Version = createRoomVersion(evs[0]) + room.Type = gjson.GetBytes(evs[0].Content, "type").String() - eventTupBytes := types.EventTupToBytes(ev.EventTup()) - - // Firstly, store the event itself - r.events.TxnStoreEvent(txn, ev) - - // This is the magic FDB version which is globally ordered, we index events by this - version = tuple.IncompleteVersionstamp(uint16(i)) - ev.IncompleteVersion = version // store for later - - // Store global version -> EventTup, this is our index of all events - txn.SetVersionstampedKey(r.events.KeyForVersion(version), eventTupBytes) + // https://spec.matrix.org/v1.14/client-server-api/#mroomcreate + // Whether users on other servers can join this room. Defaults to true if key does not exist. + res := gjson.GetBytes(evs[0].Content, "m\\.federate") + var canFederate bool + if res.Exists() { + canFederate = res.Bool() + } else { + canFederate = true + } + room.Federated = canFederate - if ev.Rejected || ev.SoftFailed { - // We can't just point soft failed events at the new version we're - // about to create because they aren't valid at that point. They are - // valid at their prev events, however, so we point their version to - // the first one of those. This means we resolve the correct historical - // state at a soft failed event. - firstPrevVersion := r.events.TxnLookupVersionForEventID(txn, ev.PrevEventIDs[0]) - if firstPrevVersion == types.ZeroVersionstamp { - // If we don't have a prev event, this event is an outlier, update it - ev.Outlier = true - r.events.TxnStoreEvent(txn, ev) - } else { - txn.Set(r.events.KeyForIDToVersion(ev.ID), types.MustVersionstampToBytes(firstPrevVersion)) - } - // Now we've stored the event, global version index and it's own version - // we're done here, since soft failed events don't appear to clients. - continue - } + return &room, nil +} - if ev.Depth > depth { - // Update room depth - txn.Set(depthKey, types.RoomDepthToBytes(ev.Depth)) - depth = ev.Depth - } +func createRoomVersion(createEv *types.PartialEvent) string { + return gjson.GetBytes(createEv.Content, "room_version").String() +} - // Store global event_id -> version - txn.SetVersionstampedValue(r.events.KeyForIDToVersion(ev.ID), types.MustVersionstampToBytes(version)) +var errRoomNotFederated = errors.New("this room is not federated") - // Room indices - // room/version -> EventTup, used to sync room events to clients - txn.SetVersionstampedKey(r.events.KeyForRoomVersion(room.ID, version), eventTupBytes) - if ev.Local { - // local events room/version -> EventTup - txn.SetVersionstampedKey(r.events.KeyForLocalRoomVersion(room.ID, version), eventTupBytes) - } +// Run final internal checks on an event before we accept it for storage, this +// is where we prevent duplicate annotations currently. +func (r *RoomsDatabase) txnCheckEventBeforeStore( + txn fdb.ReadTransaction, + roomID id.RoomID, + ev *types.Event, +) error { + // Quick sanity checks - should never happen! + if ev.Type == event.StateCreate && util.RoomVersionHas(ev.RoomVersion, gomatrixserverlib.IRoomVersion.DomainlessRoomIDs) && ev.ID != ev.ImplicitCreateEventID() { + return fmt.Errorf("v12 room ID does not match its create event") + } + if ev.RoomID != roomID { + panic("event room ID does not match input room ID") + } else if ev.RoomVersion == "" { + panic("event room version is missing") + } - // State events indices - if ev.StateKey != nil { - if r.txnStoreStateEvent(ctx, txn, room, ev, version, changedUsers, changedServers) { - roomChanged = true - } + relEvID, relType := ev.RelatesTo() + if relType == event.RelAnnotation { + existng := txn.Get(r.events.KeyForRoomReaction(ev.RoomID, relEvID, ev.Sender, ev.ReactionKey())).MustGet() + if existng != nil { + return errors.New("duplicate reaction for this event/user") } + } - // Relation events indices - relEvID, relType := ev.RelatesTo() - if relEvID != "" { - txn.Set( - r.events.KeyForRoomRelation(room.ID, relEvID, version), - tuple.Tuple{ev.ID.String(), []byte(relType)}.Pack(), - ) - - if relType == event.RelThread { - // room-threads/root-ev-version -> root event ID - only if this - // doesn't already exist (so the first reply in a thread creates). - relEvVersion := r.events.TxnLookupVersionForEventID(txn, relEvID) - threadKey := r.events.KeyForRoomThread(room.ID, relEvVersion) - if txn.Get(threadKey).MustGet() == nil { - txn.Set(threadKey, []byte(relEvID)) - } - } - - if relType == event.RelAnnotation { - // room-ev-reactions/rel-ev/uid/key - // Note: dupe check is handled before we call storeEvents - txn.Set(r.events.KeyForRoomReaction(room.ID, relEvID, ev.Sender, ev.ReactionKey()), []byte(ev.ID)) - } - } + return nil +} - // Store notification counts for local users - if userNotifs, ok := eventNotifications[ev.ID]; ok { - for userID, notif := range userNotifs { - r.users.TxnStoreNotification(txn, userID, room.ID, version, notif) - } +// Strip everything unexpected from an event's unsigned map before it is stored. +func (r *RoomsDatabase) stripEventUnsigned(ev *types.Event) { + for k := range ev.Unsigned { + switch k { + case "invite_room_state", "knock_room_state": + // OK! + default: + delete(ev.Unsigned, k) } + } +} - // Update room extremeties - // This is where we handle the partial DAG ordering via prev_events - // For each new event: - // store room/last/event_id empty key - // for each ev.prev_events, clear room/last/prev_ev_id - // The contents of room/last/ are used at event creation time to populate - // prev_events, thus any DAG split can be corrected by sending an event. - for _, prevEventID := range ev.PrevEventIDs { - r.events.TxnDeleteRoomExtremEventID(txn, room.ID, prevEventID) +func (r *RoomsDatabase) txnPreProcessEventUnsigned( + txn fdb.ReadTransaction, + eventsProvider *events.TxnEventsProvider, + ev *types.Event, + prevStateEventID id.EventID, +) error { + r.stripEventUnsigned(ev) + if ev.StateKey == nil { + return nil + } + var prevStateEv *types.Event + if prevStateEventID != "" { + var err error + if prevStateEv, err = eventsProvider.Get(prevStateEventID); err != nil { + return fmt.Errorf("failed to get previous state event of %s: %w", ev.ID, err) } - // Set this last, so rooms always have a last event - r.events.TxnSetRoomExtremEventID(txn, room.ID, ev.ID) } - - if roomChanged { - r.txnStoreRoom(txn, room, previousMemberCount) + if prevStateEv == nil { + var err error + if prevStateEv, err = r.txnGetLocalJoinerInvite(txn, ev); err != nil { + return err + } } - - if eventsStored { - // Bump the room version to the max - txn.SetVersionstampedValue(r.KeyForRoomVersion(room.ID), types.MustVersionstampToBytes(version)) + if prevStateEv != nil { + ev.SetUnsigned("prev_content", prevStateEv.Content) + // Note: this is not referenced anywhere in the spec but synapse does it and complement tests it + ev.SetUnsigned("prev_sender", prevStateEv.Sender) } - - return eventsStored + return nil } -// Store references to a state event that will be used to fetch a) current state and b) versioned -// state of room *at* the provided event (includes the event). Note that this method must not store -// anything that cannot later be cleared (ie by a state reset), see `txnDeleteStateEvent`. Returns -// a bool indicating whether the input room object has been changed. -func (r *RoomsDatabase) txnStoreStateEvent( - ctx context.Context, - txn fdb.Transaction, - room *types.Room, - ev *types.Event, - version tuple.Versionstamp, - changedUsers map[id.UserID]struct{}, - changedServers map[string]struct{}, -) bool { - zerolog.Ctx(ctx).Debug().Any("state_tup", ev.StateTup()).Msg("Storing state event") - - // room/version -> EventStateTup - txn.SetVersionstampedKey( - r.events.KeyForRoomStateVersion(room.ID, version), - types.EventStateTupToBytes(ev.EventStateTup()), - ) - - // room/type/state_key/version -> event_id - txn.SetVersionstampedKey( - r.events.KeyForRoomStateTupVersion(room.ID, ev.Type, *ev.StateKey, version), - []byte(ev.ID), - ) - - var membershipChanged bool - if ev.Type == event.StateMember { - // Process user and server membership changes - membershipChanged = r.txnStoreMembershipEvent(ctx, txn, room, ev, version, changedUsers, changedServers) +// txnGetLocalJoinerInvite returns the invite a local user's join follows when the state before the +// join holds no member event of theirs: an outlier invite received while this server was not joined. +func (r *RoomsDatabase) txnGetLocalJoinerInvite(txn fdb.ReadTransaction, ev *types.Event) (*types.Event, error) { + if !isJoinEvent(ev) { + return nil, nil + } + userID := id.UserID(*ev.StateKey) + if !r.isLocalUser(userID) { + return nil, nil + } + memberships, err := r.txnMemberships(txn, ev.RoomID, []id.UserID{userID}) + if err != nil { + return nil, err + } else if membership, found := memberships[userID]; !found || membership.Membership != event.MembershipInvite { + return nil, nil } else { - // Current (non member) room/type/state_key -> event_id - txn.Set(r.events.KeyForRoomCurrentStateTup(room.ID, ev.Type, *ev.StateKey), []byte(ev.ID)) + return r.events.TxnGetEvent(txn, membership.EventID), nil } +} - return r.updateRoomForStateEvent(room, ev) || membershipChanged +// stateStep is the room's current state after an event stored in the same transaction moved it. +type stateStep struct { + eventID id.EventID + state types.StateHash } -// Member event handling involves a bunch more steps, we need to keep track of which users, and -// servers, are in the room. We also track membership changes by user/server. -func (r *RoomsDatabase) txnStoreMembershipEvent( - ctx context.Context, - txn fdb.Transaction, - room *types.Room, - ev *types.Event, - version tuple.Versionstamp, - changedUsers map[id.UserID]struct{}, - changedServers map[string]struct{}, -) bool { - memberID := id.UserID(*ev.StateKey) - membershipTup := ev.MembershipTup() +// stateSteps records each move of a room's current state during one send. +type stateSteps struct { + current types.StateHash + steps []stateStep +} - zerolog.Ctx(ctx).Debug(). - Any("membership_tup", membershipTup). - Str("member_id", memberID.String()). - Msg("Storing membership event") - - changedUsers[memberID] = struct{}{} - membershipTupBytes := types.MembershipTupToBytes(membershipTup) - - currentMemberKey := r.events.KeyForCurrentRoomMember(ev.RoomID, memberID) - previous := txn.Get(currentMemberKey).MustGet() - wasJoined := previous != nil && types.BytesToMembershipTup(previous).Membership == event.MembershipJoin - isJoined := ev.Membership() == event.MembershipJoin - if !wasJoined && isJoined { - room.MemberCount++ - } else if wasJoined && !isJoined { - room.MemberCount-- +func (s *stateSteps) advance(eventID id.EventID, next types.StateHash) { + if next == s.current { + return } + s.steps = append(s.steps, stateStep{eventID: eventID, state: next}) + s.current = next +} - // Current room/member -> MembershipTup - txn.Set(currentMemberKey, membershipTupBytes) - - // Current user/room_id -> MembershipTup - r.users.TxnStoreMembership(txn, memberID, ev.RoomID, membershipTup) - - // User user/member_changes/version -> MembershipTup - r.users.TxnStoreMembershipChange(txn, memberID, version, membershipTup) - - // Handle server room memberships (including local!) - username, serverName, _ := memberID.Parse() - serverJoinedMemberKey := r.servers.KeyForRoomJoinedMember(ev.RoomID, serverName, username) - - // Check if the server is currently joined (servers are only joined or nothing) - wasServerJoined := r.servers.TxnIsServerJoinedRoom(txn, serverName, ev.RoomID) - if ev.Membership() == event.MembershipJoin { - // Set the joined member key and the server membership - txn.Set(serverJoinedMemberKey, []byte{}) - if !wasServerJoined { - zerolog.Ctx(ctx).Debug().Str("server", serverName).Msg("Server has joined room") - changedServers[serverName] = struct{}{} - r.events.TxnStoreServerMembership(txn, ev.RoomID, serverName, membershipTup, version) - r.servers.TxnStoreServerMembership(txn, ev.RoomID, serverName, membershipTup, version) - } - } else if wasServerJoined { - txn.Clear(serverJoinedMemberKey) - // If leaving, now we've cleared the specific member key - // we check if the server has any other joined members. - haveOtherJoinedMembers := len(txn.GetRange( - r.servers.RangeForRoomJoinedMembers(ev.RoomID, serverName), - fdb.RangeOptions{ - Limit: 1, - }, - ).GetSliceOrPanic()) > 0 - if !haveOtherJoinedMembers { - zerolog.Ctx(ctx).Debug().Str("server", serverName).Msg("Server has left room") - changedServers[serverName] = struct{}{} - // Note any non-join membership is handled here so we create a new leave MembershipTup - leaveMtup := types.MembershipTup{ - EventID: ev.ID, - RoomID: ev.RoomID, - Membership: event.MembershipLeave, - } - r.events.TxnStoreServerMembership(txn, ev.RoomID, serverName, leaveMtup, version) - r.servers.TxnStoreServerMembership(txn, ev.RoomID, serverName, leaveMtup, version) - } - } - return wasJoined != isJoined +// diffedStep is a state step with the changes it made to current state +type diffedStep struct { + stateStep + changes []types.StateChange } -// Removes references to a state event such that it never appears to have existed as part of the -// room state. Removes both room state versions and all user/server memberships and changes. -func (r *RoomsDatabase) txnDeleteStateEvent(txn fdb.Transaction, room *types.Room, ev *types.Event) bool { - // First get the version *at which the event was written* - if the event is within this batch - // then we must use the IncompleteVersion field. - version := ev.IncompleteVersion - if version == types.ZeroVersionstamp { - version = r.events.TxnLookupVersionForEventID(txn, ev.ID) - } +// membershipChanges are the users and servers whose rows of the room a send wrote +type membershipChanges struct { + users map[id.UserID]struct{} + servers map[string]struct{} +} - // Remove room version/local version - txn.Clear(r.events.KeyForRoomStateVersion(ev.RoomID, version)) - txn.Clear(r.events.KeyForRoomStateTupVersion(ev.RoomID, ev.Type, *ev.StateKey, version)) - - if ev.Type == event.StateMember { - // Clear out user/server memberships and room member state - memberID := id.UserID(*ev.StateKey) - _, serverName, _ := memberID.Parse() - currentMemberKey := r.events.KeyForCurrentRoomMember(ev.RoomID, memberID) - previous := txn.Get(currentMemberKey).MustGet() - if previous != nil && types.BytesToMembershipTup(previous).Membership == event.MembershipJoin { - room.MemberCount-- - } - txn.Clear(currentMemberKey) - txn.Clear(r.events.KeyForCurrentRoomServer(ev.RoomID, serverName)) - r.users.TxnDeleteUserMembership(txn, memberID, ev.RoomID, version) - r.servers.TxnDeleteServerMembership(txn, ev.RoomID, serverName, version) - } else { - // Clear our room/type/state version - txn.Clear(r.events.KeyForRoomCurrentStateTup(ev.RoomID, ev.Type, *ev.StateKey)) +func newMembershipChanges() *membershipChanges { + return &membershipChanges{ + users: make(map[id.UserID]struct{}, 1), + servers: make(map[string]struct{}, 1), } - // Clearing a state tuple also removes its materialized summary value. - cleared := *ev - cleared.Content = nil - return r.updateRoomForStateEvent(room, &cleared) || ev.Type == event.StateMember +} + +func (c *membershipChanges) userIDs() []id.UserID { + return slices.Collect(maps.Keys(c.users)) +} + +func (c *membershipChanges) serverNames() []string { + return slices.Collect(maps.Keys(c.servers)) } diff --git a/internal/databases/rooms/eventsender.go b/internal/databases/rooms/eventsender.go new file mode 100644 index 0000000..5800e7b --- /dev/null +++ b/internal/databases/rooms/eventsender.go @@ -0,0 +1,683 @@ +package rooms + +import ( + "cmp" + "context" + "errors" + "fmt" + "maps" + "math/rand/v2" + "slices" + "time" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/databases/rooms/events" + "github.com/beeper/babbleserv/internal/databases/rooms/eventsendutil" + "github.com/beeper/babbleserv/internal/databases/rooms/state" + "github.com/beeper/babbleserv/internal/notifier" + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" +) + +var ( + errNeedsStaging = errors.New("the send needs staging") + errGuardFailed = errors.New("the room changed since the send was prepared") + errEventsStoredConcurrently = fmt.Errorf("%w: events were stored concurrently", errGuardFailed) +) + +// Event sending timeouts & retries +const ( + firstSendRetryPause = 10 * time.Millisecond + longestSendRetryPause = 250 * time.Millisecond + sendTimeout = 60 * time.Second + remoteJoinTimeout = 10 * time.Minute +) + +// sendGuard is room data checked at publish time to ensure no other process has mutated the room +type sendGuard struct { + revision uint64 + joined bool + unstored []id.EventID +} + +// publishPlan contains everything needed to complete (publish) an event send +type publishPlan struct { + room *types.Room + guard sendGuard + preparedEvents + // Stored rejected events a remote join accepts, which its publish then un-rejects + readopted []*types.Event + // The remote join whose publish replaces every extremity + resetExtremities id.EventID + // What else the publish writes: an alias, publication, a transaction ID or a lock refresh + extra func(fdb.Transaction, *types.Room) error +} + +// preparationOutcome is exactly one next action of a send attempt +type preparationOutcome interface{ preparationOutcome() } + +type readyToPublish struct{ plan *publishPlan } +type needsWork struct{ work preparationWork } +type alreadySent struct{ result *SendEventsResult } + +func (readyToPublish) preparationOutcome() {} +func (needsWork) preparationOutcome() {} +func (alreadySent) preparationOutcome() {} + +type prepareEvents func(context.Context) (preparationOutcome, error) + +// pushContext is what evaluating push rules for a send's events needs of the room's local members. +type pushContext struct { + rules types.UserPushRulesMap + rooms types.UserRoomContextMap +} + +// eventSender owns a send's shared context and attempt loop. Concrete senders embed it and +// pass their prepare method explicitly to run. Operations are bound once, and replaceable in tests. +type eventSender struct { + r *RoomsDatabase + roomID id.RoomID + push pushContext + lock func() (unlock func()) + publish func(context.Context, *publishPlan) (*SendEventsResult, error) + build func(context.Context, preparationWork) error + after func(context.Context, *SendEventsResult) + pause func(context.Context, int) error +} + +func (r *RoomsDatabase) newEventSender(roomID id.RoomID, push pushContext) eventSender { + s := eventSender{ + r: r, + roomID: roomID, + push: push, + lock: func() func() { return r.lockRoom(roomID) }, + } + s.pause = s.pauseBeforeRetry + s.publish = s.stageAndPublish + s.build = s.executePreparationWork + s.after = s.afterSend + return s +} + +// run prepares and publishes under the room mutex. Preparatory work, notifications and retry +// pauses run after releasing it. The concrete sender survives every attempt; each plan does not. +func (s *eventSender) run(ctx context.Context, prepare prepareEvents) (*SendEventsResult, error) { + built := make(builtArtifacts) + for failures := 0; ; { + if err := ctx.Err(); err != nil { + return nil, err + } + res, work, err := func() (*SendEventsResult, preparationWork, error) { + defer s.lock()() + return s.attempt(ctx, prepare) + }() + if err == nil && work != nil { + if err = built.check(work); err == nil { + err = s.build(ctx, work) + } + if err == nil { + built.add(work) + continue + } + } + if !errors.Is(err, errGuardFailed) && !errors.Is(err, errJoinUnpublished) { + if err != nil { + return nil, err + } + s.after(ctx, res) + return res, nil + } + failures++ + level := zerolog.WarnLevel + if errors.Is(err, errJoinUnpublished) { + level = zerolog.DebugLevel + } + zerolog.Ctx(ctx).WithLevel(level).Err(err).Int("attempt", failures).Msg("Room changed while sending events, starting again") + if pauseErr := s.pause(ctx, failures); pauseErr != nil { + return nil, fmt.Errorf("gave up after %d attempts: %w: %w", failures, err, pauseErr) + } + } +} + +func (s *eventSender) attempt(ctx context.Context, prepare prepareEvents) (*SendEventsResult, preparationWork, error) { + outcome, err := prepare(ctx) + if err != nil { + return nil, nil, err + } + switch outcome := outcome.(type) { + case readyToPublish: + if outcome.plan != nil { + res, err := s.publish(ctx, outcome.plan) + return res, nil, err + } + case needsWork: + if outcome.work != nil { + return nil, outcome.work, nil + } + case alreadySent: + if outcome.result != nil { + return outcome.result, nil, nil + } + } + return nil, nil, fmt.Errorf("invalid preparation outcome: %T", outcome) +} + +type builtArtifacts map[string]struct{} + +func (b builtArtifacts) check(work preparationWork) error { + key := work.artifactKey() + if _, again := b[key]; key != "" && again { + return fmt.Errorf("artifact still needed after its job ran: %s", work) + } + return nil +} + +func (b builtArtifacts) add(work preparationWork) { + if key := work.artifactKey(); key != "" { + b[key] = struct{}{} + } +} + +func (s *eventSender) pauseBeforeRetry(ctx context.Context, failures int) error { + pause := min(firstSendRetryPause< 0 { + if from != p.boundary.from { + return nil, fmt.Errorf("room state %x is not the state %x the join's state before it was diffed from", from, p.boundary.from) + } + from, ahead = p.boundary.state, p.boundary.changes + } + diffedSteps := make([]diffedStep, len(p.stateSteps)) + for i, step := range p.stateSteps { + changes, err := p.stateBatch.TxnDiff(txn, from, step.state) + if err != nil { + return nil, fmt.Errorf("failed to diff room state: %w", err) + } + diffedSteps[i] = diffedStep{stateStep: step, changes: changes} + from = step.state + } + if ahead != nil { + diffedSteps[0].changes = p.foldStateChanges([]diffedStep{{changes: ahead}, diffedSteps[0]}) + } + return diffedSteps, nil +} + +// The walk through rejected and soft failed events reads one level of prev events at a time, and +// stops expanding past this many events, the rest counting as reached. +const maxReplacedExtremitiesWalk = 1000 + +// findReplacedExtremities returns the pending extremities an accepted event with prevs replaces: +// its prevs and those reached through rejected or soft failed events. Those events never became +// extremities, so the extremities behind them are still ancestors of the accepted event. +func (p *preparedEvents) findReplacedExtremities( + log zerolog.Logger, + pendingExtremities, prevs []id.EventID, +) ([]id.EventID, error) { + reached := make(map[id.EventID]struct{}, len(prevs)) + for level := prevs; len(level) > 0; { + var fetch []id.EventID + for _, eventID := range level { + if _, found := reached[eventID]; !found { + reached[eventID] = struct{}{} + fetch = append(fetch, eventID) + } + } + if len(reached) > maxReplacedExtremitiesWalk { + log.Warn(). + Int("events", len(reached)). + Msg("Stopped walking rejected and soft failed prev events for the extremities they hide") + break + } + + p.eventsProvider.WillGet(fetch...) + level = nil + for _, eventID := range fetch { + ev, err := p.eventsProvider.Get(eventID) + if err != nil { + return nil, err + } else if ev != nil && (ev.Rejected || ev.SoftFailed) { + level = append(level, ev.PrevEventIDs...) + } + } + } + + var replaced []id.EventID + for _, extremityID := range pendingExtremities { + if _, found := reached[extremityID]; found { + replaced = append(replaced, extremityID) + } + } + return replaced, nil +} + +// foldStateChanges is the overall change made by consecutive steps, sorted as state.Batch.TxnDiff +// sorts changes. +func (p *preparedEvents) foldStateChanges(steps []diffedStep) []types.StateChange { + folded := make(map[types.StateTup]types.StateChange) + for _, step := range steps { + for _, change := range step.changes { + if earlier, found := folded[change.StateTup]; found { + change.OldEventID, change.OldMembership = earlier.OldEventID, earlier.OldMembership + } + folded[change.StateTup] = change + } + } + overall := make([]types.StateChange, 0, len(folded)) + for _, change := range folded { + if change.OldEventID != change.NewEventID { + overall = append(overall, change) + } + } + slices.SortFunc(overall, func(a, b types.StateChange) int { + return a.Compare(b.StateTup) + }) + return overall +} + +// missingEvents returns those of the events the provider did not find +func (p *preparedEvents) missingEvents(eventIDs []id.EventID) []id.EventID { + var missing []id.EventID + for _, eventID := range eventIDs { + if ev, err := p.eventsProvider.Get(eventID); err == nil && ev == nil { + missing = append(missing, eventID) + } + } + return missing +} + +// Publishing + +func (p *preparedEvents) sendResult( + versionFut fdb.FutureKey, + room *types.Room, + changed *membershipChanges, +) *SendEventsResult { + var change notifier.Change + + if len(p.evs) > 0 { + change = notifier.Change{ + RoomIDs: []id.RoomID{room.ID}, + UserIDs: changed.userIDs(), + Servers: changed.serverNames(), + // Note: only pass the last event ID here to minimize notifier pubsub traffic + EventIDs: []id.EventID{p.evs[len(p.evs)-1].ID}, + } + } + + return &SendEventsResult{ + versionFut: versionFut, + change: change, + + Allowed: p.evs, + Rejected: p.rejected, + } +} + +// fitsPublish reports whether a plan's pending contexts fit the publish writing its events +func (s *eventSender) fitsPublish(plan *publishPlan) bool { + return plan.stateBatch.PendingBytes(plan.stateContexts()...) <= s.r.config.Rooms.StateBudget.StagingBytes +} + +// stageAndPublish stages the plan's contexts too large to write with its events, then publishes it. +func (s *eventSender) stageAndPublish(ctx context.Context, plan *publishPlan) (*SendEventsResult, error) { + if !s.fitsPublish(plan) { + if err := plan.stateBatch.Stage(ctx, s.r.config.Rooms.StateBudget.StagingBytes, plan.stateContexts()...); err != nil { + return nil, err + } + } + res, err := util.DoWriteTransactionWithVersion(ctx, s.r.db, func(txn fdb.Transaction) (*SendEventsResult, error) { + return s.txnPublish(ctx, txn, plan) + }) + if err != nil { + return nil, fmt.Errorf("failed to publish events: %w", err) + } + return res, nil +} + +// txnPublish writes a plan in one transaction once its guard holds: the stored rejected events a +// remote join accepts, the events with the rows indexing them, the room's current state with every +// row it moves, see txnStoreRoomState, and the plan's other writes. +func (s *eventSender) txnPublish(ctx context.Context, txn fdb.Transaction, plan *publishPlan) (*SendEventsResult, error) { + room, err := s.txnCheckGuard(txn, plan) + if err != nil { + return nil, err + } + // Ahead of the state, so the publish's size bound counts them + s.r.events.TxnAdoptEvents(txn, plan.readopted...) + + prepared := plan.preparedEvents + prepared.eventsProvider = s.r.events.NewTxnEventsProvider(ctx, txn).WithProviderEvents(plan.eventsProvider) + notifications := txnEvaluateNotificationsForEvents(txn, prepared.eventsProvider, prepared.evs, s.push.rules, s.push.rooms) + changed := newMembershipChanges() + versions, err := s.r.txnStoreEvents(ctx, txn, room, prepared.evs, prepared.replacedExtremities, notifications) + if err != nil { + return nil, err + } + if len(versions) == 0 { + zerolog.Ctx(ctx).Warn().Msg("No events stored in send transaction") + } else if err := s.r.txnStoreRoomState(ctx, txn, room, &prepared, versions, changed); err != nil { + return nil, err + } + if plan.resetExtremities != "" { + s.r.events.TxnResetRoomExtremEventIDs(txn, room.ID, plan.resetExtremities) + } + if plan.extra != nil { + if err := plan.extra(txn, room); err != nil { + return nil, err + } + } + return prepared.sendResult(txn.GetVersionstamp(), room, changed), nil +} + +// txnCheckGuard returns the room a plan publishes to, failing with errGuardFailed unless the room is +// as the plan was prepared for and none of the auth chains and headers its graph labelled changed +// since. +func (s *eventSender) txnCheckGuard(txn fdb.Transaction, plan *publishPlan) (*types.Room, error) { + stored, err := roomOrNil(s.r.txnGetRoom(txn, plan.room.ID)) + if err != nil { + return nil, err + } + var revision uint64 + if stored != nil { + revision = stored.StateRevision + } + if revision != plan.guard.revision { + return nil, fmt.Errorf("%w: state revision %d, prepared at %d", errGuardFailed, revision, plan.guard.revision) + } else if joined := isServerJoined(stored); joined != plan.guard.joined { + return nil, fmt.Errorf("%w: this server joined %t, prepared %t", errGuardFailed, joined, plan.guard.joined) + } + if storedNow, err := s.r.txnStoredEventIDs(txn, plan.guard.unstored); err != nil { + return nil, err + } else if len(storedNow) > 0 { + return nil, errEventsStoredConcurrently + } + if unchanged, err := plan.authGraph.TxnLabelsUnchanged(txn); err != nil { + return nil, err + } else if !unchanged { + return nil, fmt.Errorf("%w: auth chains or headers", errEventsStoredConcurrently) + } + room := stored + if room == nil { + newRoom := *plan.room + room = &newRoom + } + // A create event redacted before room version 11 lost its room version, which a remote join gives + room.Version = cmp.Or(room.Version, plan.room.Version) + return room, nil +} + +// afterSend finishes a send that published, once the room's mutex is released, notifying of what the +// send changed. +func (s *eventSender) afterSend(ctx context.Context, res *SendEventsResult) { + if res.transactionDuplicate { + return + } + s.r.notifier.SendChange(res.change) + + rlog := zerolog.Ctx(ctx).Info(). + Object("change", res.change). + Int("events_allowed", len(res.Allowed)). + Int("events_rejected", len(res.Rejected)) + if len(res.Allowed) > 0 { + rlog = rlog.Any("versionstamp", types.DecodeRawVersionstamp(res.versionFut.MustGet())) + } + rlog.Msg("Sent events") +} + +// Preparation work +// + +// preparationWork is supporting data to build before preparing again. Only artifacts have a +// stable key: staging may legitimately be requested again after a concurrent writer changed events. +type preparationWork interface { + artifactKey() string + fmt.Stringer +} + +type artifactInputs struct { + stateBatch *state.Batch + eventsProvider *events.TxnEventsProvider + authGraph *events.AuthGraph +} + +type resolveStateWork struct { + request *eventsendutil.ResolutionRequest + inputs artifactInputs + roomVersion string +} + +func (w resolveStateWork) artifactKey() string { + return "resolution " + w.request.Key() +} + +func (w resolveStateWork) String() string { + return w.request.String() +} + +func (s *eventSender) executePreparationWork(ctx context.Context, work preparationWork) error { + switch work := work.(type) { + case resolveStateWork: + zerolog.Ctx(ctx).Info().Stringer("work", work).Msg("Running artifact job, then starting again") + return s.buildStateResolution(ctx, work) + default: + return fmt.Errorf("invalid preparation work: %T", work) + } +} + +// Artifact jobs + +// buildStateResolution stages pending inputs, resolves using renewable read transactions, +// then stages and stores the result for the next attempt. +func (s *eventSender) buildStateResolution(ctx context.Context, work resolveStateWork) error { + prepared := work.inputs + if err := prepared.stateBatch.Stage(ctx, s.r.config.Rooms.StateBudget.StagingBytes, work.request.Contexts...); err != nil { + return err + } + + reads, err := eventsendutil.NewArtifactReads(ctx, s.r.db, s.r.events, s.roomID, prepared.eventsProvider, prepared.authGraph) + if err != nil { + return err + } + start := time.Now() + stateBatch := s.r.state.NewJobBatch(s.roomID, reads.Read) + resolver := reads.NewResolver(eventsendutil.ResolverOptions{ + StateBatch: stateBatch, + RoomID: s.roomID, + RoomVersion: work.roomVersion, + }) + if err := s.buildAndStoreResolution(ctx, resolver, stateBatch, work.request.Contexts); err != nil { + return fmt.Errorf("failed to resolve contexts: %w", err) + } + stateEvents, candidates := resolver.Stats() + zerolog.Ctx(ctx).Info(). + Str("job", "resolution"). + Any("contexts", work.request.Contexts). + Int("state_events", stateEvents). + Int("candidates", candidates). + Int("transactions", reads.Transactions()). + Dur("duration", time.Since(start)). + Msg("Resolved state") + return nil +} + +func (s *eventSender) buildAndStoreResolution(ctx context.Context, resolver *eventsendutil.Resolver, stateBatch *state.Batch, contexts []types.StateHash) error { + result, request, err := resolver.ResolveContexts(contexts) + if err != nil { + return err + } + if request != nil { + return errors.New("artifact job requested another resolution") + } + if err := stateBatch.Stage(ctx, s.r.config.Rooms.StateBudget.StagingBytes, slices.Concat(contexts, []types.StateHash{result})...); err != nil { + return err + } + return stateBatch.StoreResolved(ctx, contexts, result) +} diff --git a/internal/databases/rooms/eventsender_test.go b/internal/databases/rooms/eventsender_test.go new file mode 100644 index 0000000..e21534a --- /dev/null +++ b/internal/databases/rooms/eventsender_test.go @@ -0,0 +1,199 @@ +package rooms + +import ( + "context" + "fmt" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/databases/rooms/eventsendutil" + "github.com/beeper/babbleserv/internal/types" +) + +const sendTestRoomID = id.RoomID("!room:example.com") + +func sendTestMember(eventID id.EventID, userID id.UserID) *types.Event { + ev := &types.Event{ID: eventID} + ev.RoomID, ev.Type, ev.StateKey = sendTestRoomID, event.StateMember, new(userID.String()) + ev.Content = []byte(`{"membership":"join"}`) + return ev +} + +// Send attempts and publishing + +// sendRecorder fakes the steps of a send, failing a step that runs with the room's mutex held when +// it must not be, or released when it must be held +type sendRecorder struct { + t *testing.T + locked bool + prepares []func() (preparationOutcome, error) + publishes []func() (*SendEventsResult, error) + built []preparationWork + pauses []int + after []*SendEventsResult +} + +func (s *sendRecorder) sender() *eventSender { + return &eventSender{ + lock: func() func() { + require.False(s.t, s.locked, "the mutex is taken twice") + s.locked = true + return func() { s.locked = false } + }, + publish: func(context.Context, *publishPlan) (*SendEventsResult, error) { + assert.True(s.t, s.locked, "publish holds the mutex") + next := s.publishes[0] + s.publishes = s.publishes[1:] + return next() + }, + build: func(_ context.Context, work preparationWork) error { + assert.False(s.t, s.locked, "staging and artifact jobs run without the mutex") + s.built = append(s.built, work) + return nil + }, + after: func(_ context.Context, res *SendEventsResult) { + assert.False(s.t, s.locked, "after runs without the mutex") + s.after = append(s.after, res) + }, + pause: func(ctx context.Context, failures int) error { + assert.False(s.t, s.locked, "the pause runs without the mutex") + s.pauses = append(s.pauses, failures) + return ctx.Err() + }, + } +} + +func (s *sendRecorder) prepare(context.Context) (preparationOutcome, error) { + assert.True(s.t, s.locked, "prepare holds the mutex") + next := s.prepares[0] + s.prepares = s.prepares[1:] + return next() +} + +func preparedAs(outcome preparationOutcome) func() (preparationOutcome, error) { + return func() (preparationOutcome, error) { return outcome, nil } +} + +func plannedTo(plan *publishPlan) func() (preparationOutcome, error) { + return preparedAs(readyToPublish{plan: plan}) +} + +func publishedAs(res *SendEventsResult, err error) func() (*SendEventsResult, error) { + return func() (*SendEventsResult, error) { return res, err } +} + +func TestSendStartsAgainAfterAGuardFailure(t *testing.T) { + published := &SendEventsResult{} + s := &sendRecorder{ + t: t, + prepares: []func() (preparationOutcome, error){plannedTo(&publishPlan{}), plannedTo(&publishPlan{}), plannedTo(&publishPlan{})}, + publishes: []func() (*SendEventsResult, error){ + publishedAs(nil, fmt.Errorf("failed to publish events: %w", errEventsStoredConcurrently)), + publishedAs(nil, fmt.Errorf("%w: state revision 3, prepared at 2", errGuardFailed)), + publishedAs(published, nil), + }, + } + res, err := s.sender().run(context.Background(), s.prepare) + require.NoError(t, err) + assert.Same(t, published, res) + assert.Equal(t, []int{1, 2}, s.pauses, "each guard failure pauses longer") + assert.Equal(t, []*SendEventsResult{published}, s.after) + assert.Empty(t, s.prepares) + assert.False(t, s.locked) +} + +func TestAttemptEventsLeaveTheEventsGivenAsTheyAre(t *testing.T) { + s := &eventSender{r: &RoomsDatabase{}} + given := sendTestMember("$join", "@alice:example.com") + given.Unsigned = map[string]any{"invite_room_state": []any{}} + copies := s.attemptEvents([]*types.Event{given}) + copies[0].Rejected, copies[0].SoftFailed, copies[0].IsDuplicate = true, true, true + copies[0].BeforeState, copies[0].AfterState = types.StateHash{1}, types.StateHash{2} + copies[0].SetUnsigned("prev_content", map[string]any{}) + s.r.stripEventUnsigned(copies[0]) + + assert.False(t, given.Rejected || given.SoftFailed || given.IsDuplicate) + assert.True(t, given.BeforeState.IsZero() && given.AfterState.IsZero()) + assert.Equal(t, map[string]any{"invite_room_state": []any{}}, given.Unsigned) + + attempted := *given + attempted.Rejected, attempted.AfterState = true, types.StateHash{2} + again := s.attemptEvents([]*types.Event{&attempted})[0] + assert.False(t, again.Rejected, "an attempt starts without another's outcome") + assert.True(t, again.AfterState.IsZero()) +} + +// Staging + +func TestChunkStatelessEvents(t *testing.T) { + s := &federatedEventSender{eventSender: eventSender{r: &RoomsDatabase{}}} + var evs []*types.Event + for i := range 40 { + ev := sendTestMember(id.EventID(fmt.Sprintf("$join%d", i)), id.UserID(fmt.Sprintf("@user%d:example.com", i))) + ev.Content = []byte(`{"membership":"join"}`) + if i%7 == 3 { + ev.Content = []byte(`{"membership":"join","displayname":"` + strings.Repeat("x", 3000) + `"}`) + } + evs = append(evs, ev) + } + budget := 3 * s.stagedEventBytes(evs[0]) + require.Greater(t, s.stagedEventBytes(evs[3]), budget) + + s.r.config.Rooms.StateBudget.StagingBytes = budget + chunks := s.chunkStatelessEvents(evs) + assert.Equal(t, evs, slices.Concat(chunks...), "events keep their order") + for i, chunk := range chunks { + require.NotEmpty(t, chunk) + size := 0 + for _, ev := range chunk { + size += s.stagedEventBytes(ev) + } + if len(chunk) > 1 { + assert.LessOrEqual(t, size, budget, "chunk %d", i) + } + if i+1 < len(chunks) { + assert.Greater(t, size+s.stagedEventBytes(chunks[i+1][0]), budget, "chunk %d is not full", i) + } + } + + assert.Empty(t, s.chunkStatelessEvents(nil)) + s.r.config.Rooms.StateBudget.StagingBytes = 1 << 30 + assert.Len(t, s.chunkStatelessEvents(evs), 1) +} + +// Artifact jobs + +func TestSendRetriesAnArtifactWhoseBuildConflicted(t *testing.T) { + work := resolveStateWork{request: &eventsendutil.ResolutionRequest{Contexts: []types.StateHash{{1}, {2}}}} + published := &SendEventsResult{} + s := &sendRecorder{ + t: t, + prepares: []func() (preparationOutcome, error){ + preparedAs(needsWork{work: work}), preparedAs(needsWork{work: work}), plannedTo(&publishPlan{}), + }, + publishes: []func() (*SendEventsResult, error){publishedAs(published, nil)}, + } + sender := s.sender() + build := sender.build + attempts := 0 + sender.build = func(ctx context.Context, work preparationWork) error { + assert.False(t, s.locked) + attempts++ + if attempts == 1 { + return errEventsStoredConcurrently + } + return build(ctx, work) + } + res, err := sender.run(context.Background(), s.prepare) + require.NoError(t, err) + assert.Same(t, published, res) + assert.Equal(t, 2, attempts, "a failed job is not marked complete") + assert.Equal(t, []int{1}, s.pauses) + assert.Len(t, s.built, 1) +} diff --git a/internal/databases/rooms/eventsendfederated.go b/internal/databases/rooms/eventsendfederated.go new file mode 100644 index 0000000..7d20954 --- /dev/null +++ b/internal/databases/rooms/eventsendfederated.go @@ -0,0 +1,1457 @@ +package rooms + +import ( + "cmp" + "container/heap" + "context" + "errors" + "fmt" + "maps" + "slices" + "time" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/databases/rooms/events" + "github.com/beeper/babbleserv/internal/databases/rooms/eventsendutil" + "github.com/beeper/babbleserv/internal/databases/rooms/state" + "github.com/beeper/babbleserv/internal/notifier" + "github.com/beeper/babbleserv/internal/stateres" + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" +) + +// Sends an event we don't actually want stored in a room, but only on the relevant users membership +// stream so they can see them, plus the raw event bytes. Simply sets a small subset of keys we +// normally set during event store. Only a local target gets a membership row, marked outlier, which +// future non-outlier memberships overwrite. An event already stored as a room event is left as it +// is, an outlier copy would take it out of room state. +func (r *RoomsDatabase) SendFederatedOutlierMembershipEvent(ctx context.Context, ev *types.Event) error { + if ev.Type != event.StateMember { + panic("outlier event is not a member event") + } + + // Flag the event as an outlier so we only store it without adding to the room/state + ev.Outlier = true + + userID := id.UserID(*ev.StateKey) + isLocal := r.isLocalUser(userID) + + versionFut, err := util.DoWriteTransactionWithVersion(ctx, r.db, func(txn fdb.Transaction) (fdb.FutureKey, error) { + roomEventVersion := txn.Get(r.events.KeyForIDToVersion(ev.ID)) + var storedFut fdb.FutureByteSlice + if isLocal { + storedFut = txn.Get(r.users.KeyForMembership(userID, ev.RoomID)) + } + // A normal read, so a publish to the room meanwhile conflicts with this write, which is only + // for a room this server is not in + room, err := roomOrNil(r.txnGetRoom(txn, ev.RoomID)) + if err != nil { + return nil, err + } else if isServerJoined(room) { + return nil, fmt.Errorf("cannot send outlier events to rooms this server is participating in") + } else if roomEventVersion.MustGet() != nil { + return nil, nil + } + + version := r.events.TxnStoreEventRows(txn, true, ev)[ev.ID] + if isLocal { + // This server is out of the room, so the user is not among its local joined members + mtup := ev.MembershipTup() + stored := storedFut.MustGet() + r.users.TxnStoreMembership(txn, userID, ev.RoomID, types.MembershipRow{MembershipTup: mtup, Outlier: true}) + txn.Clear(r.keyForLocalMember(ev.RoomID, userID)) + if stored == nil || types.BytesToMembershipRow(stored).EventID != ev.ID { + r.users.TxnStoreMembershipChange(txn, userID, version, mtup) + } + } + + return txn.GetVersionstamp(), nil + }) + + if err != nil { + return err + } else if versionFut == nil { + zerolog.Ctx(ctx).Debug().Stringer("event_id", ev.ID).Msg("Outlier membership event is stored as a room event") + return nil + } + if isLocal { + r.notifier.SendChange(notifier.Change{ + UserIDs: []id.UserID{userID}, + }) + } + zerolog.Ctx(ctx).Debug(). + Stringer("event_id", ev.ID). + Stringer("target_user_id", userID). + Stringer("sender", ev.Sender). + Str("membership", string(ev.Membership())). + Bool("is_outlier", ev.Outlier). + Any("versionstamp", types.DecodeRawVersionstamp(versionFut.MustGet())). + Msg("Stored outlier membership event") + return nil +} + +var ( + ErrAuthStage4 = errors.New("failed to auth event (step 4)") + ErrAuthStage5 = errors.New("failed to auth event (step 5)") + ErrEventDropped = errors.New("event dropped") + ErrPrevEventsUnavailable = errors.New("state at prev events unavailable") +) + +// State another server gave where our state doesn't exist or is stale (we're (re)joining a room) +type GivenStates struct { + // The state event IDs before prev events without state here, from /state_ids + BeforePrevs map[id.EventID][]id.EventID + // The state event IDs before batch events, as send_join gives the state before a join + BeforeEvents map[id.EventID][]id.EventID + // Events not stored here: the events the states name with their auth chains, and the prev + // events. Every event a state before a batch event names must be here or stored. + Events []*types.Event +} + +// Send federated events to a room after passing through all the required +// authorization checks. (steps 4-6: https://spec.matrix.org/v1.10/server-server-api/#checks-performed-on-receipt-of-a-pdu) +// +// Every event fetched from other servers must be in evs or given: an event that cannot be evaluated +// is dropped with an error wrapping ErrEventDropped. An event with a given before-state takes it +// instead of the state after its prev events. Remote joins are passed to remoteJoinIn. +func (r *RoomsDatabase) SendFederatedEvents( + ctx context.Context, + roomID id.RoomID, + evs []*types.Event, + given *GivenStates, + userPushRules types.UserPushRulesMap, + userRoomContext types.UserRoomContextMap, +) (*SendEventsResult, error) { + push := pushContext{rules: userPushRules, rooms: userRoomContext} + p := &federatedEventSender{eventSender: r.newEventSender(roomID, push)} + evs = p.sortEventsTopologically(evs) + p.evs = evs + log := r.getTxnLogContext(ctx, "SendFederatedEvents"). + Str("room_id", roomID.String()). + Int("events", len(evs)). + Logger() + ctx = log.WithContext(ctx) + if given != nil { + p.given = givenStates{beforePrevs: given.BeforePrevs, beforeEvents: given.BeforeEvents} + p.fetched = given.Events + } + p.build = p.executePreparationWork + + if join := r.remoteJoinIn(evs, given); join != nil { + ctx, cancel := p.withSendTimeout(ctx, remoteJoinTimeout) + defer cancel() + p.join = join + p.given = givenStates{beforeEvents: map[id.EventID][]id.EventID{join.ev.ID: join.stateIDs}} + s := &remoteJoinSender{federatedEventSender: p} + return s.send(ctx) + } + if joined, err := r.IsServerJoined(ctx, roomID, r.config.ServerName); err != nil { + return nil, err + } else if !joined { + return r.sendOutlierMemberships(ctx, roomID, evs) + } + + ctx, cancel := p.withSendTimeout(ctx, sendTimeout) + defer cancel() + res, err := p.send(ctx) + if errors.Is(err, ErrServerNotInRoom) { + log.Info().Err(err).Msg("This server left the room while sending events") + return r.sendOutlierMemberships(ctx, roomID, evs) + } + return res, err +} + +// sendOutlierMemberships stores those of a batch's events for a room this server is not in that are +// outlier memberships, see isOutlierMembership, and drops the rest with ErrServerNotInRoom. +func (r *RoomsDatabase) sendOutlierMemberships(ctx context.Context, roomID id.RoomID, evs []*types.Event) (*SendEventsResult, error) { + res := &SendEventsResult{} + for _, ev := range evs { + if outlier, err := r.isOutlierMembership(ctx, roomID, ev); err != nil { + return nil, err + } else if !outlier { + res.Rejected = append(res.Rejected, RejectedEvent{ev, fmt.Errorf("%w: %w: %s", ErrEventDropped, ErrServerNotInRoom, roomID)}) + continue + } + outlier := *ev + if err := r.SendFederatedOutlierMembershipEvent(ctx, &outlier); err != nil { + return nil, err + } + res.Allowed = append(res.Allowed, &outlier) + } + return res, nil +} + +// isOutlierMembership reports whether a member event of a room this server is not in reaches a local +// user who sees it outside the room: an invite of a local user, or a leave answering a local user's +// invite, sent by the inviter rescinding it or by the invitee rejecting a local user's invite. A +// remote user's membership is never an outlier, as only local users have membership rows. +func (r *RoomsDatabase) isOutlierMembership(ctx context.Context, roomID id.RoomID, ev *types.Event) (bool, error) { + if ev.Type != event.StateMember || ev.StateKey == nil || ev.RoomID != roomID { + return false, nil + } + target := id.UserID(*ev.StateKey) + if !r.isLocalUser(target) { + return false, nil + } + switch ev.Membership() { + case event.MembershipInvite: + return true, nil + case event.MembershipLeave: + return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (bool, error) { + memberships, err := r.txnMemberships(txn, roomID, []id.UserID{target}) + if err != nil { + return false, err + } else if membership, found := memberships[target]; !found || membership.Membership != event.MembershipInvite { + return false, nil + } else if invite := r.events.TxnGetEvent(txn, membership.EventID); invite == nil { + return false, nil + } else { + rejects := ev.Sender == target && r.isLocalUser(invite.Sender) + return ev.Sender == invite.Sender || rejects, nil + } + }) + } + return false, nil +} + +// givenStates is what a send keeps of the states the sending server gave +type givenStates struct { + beforePrevs map[id.EventID][]id.EventID + beforeEvents map[id.EventID][]id.EventID + // Before-contexts of batch events built already, by a remote join that found this server joined + // once it had + boundaries map[id.EventID]types.StateHash +} + +// federatedEventSender prepares federated events to a room this server is joined to, once the events +// fetched for the given states are stored without state. +type federatedEventSender struct { + eventSender + evs []*types.Event + given givenStates + fetched []*types.Event + // Set for a remote join, including after it switches to member mode + join *remoteJoin +} + +func (p *federatedEventSender) send(ctx context.Context) (*SendEventsResult, error) { + return p.run(ctx, p.prepare) +} + +func (p *federatedEventSender) prepare(ctx context.Context) (preparationOutcome, error) { + evs := p.attemptEvents(p.evs) + var dropped map[id.EventID]struct{} + if len(p.fetched) > 0 { + checked, err := p.checkFetchedEvents(ctx) + if err != nil { + return nil, fmt.Errorf("failed to check fetched events: %w", err) + } else if len(checked.kept) > 0 { + return needsWork{work: stageEventsWork{staging: &stagingEvents{ + evs: checked.kept, rejectedAuth: checked.rejectedAuth, joined: true, + }}}, nil + } + dropped = checked.dropped + } + outcome, err := util.DoReadTransaction(ctx, p.r.db, func(txn fdb.ReadTransaction) (preparationOutcome, error) { + return p.txnPrepareFederated(ctx, txn, evs, dropped) + }) + if err != nil { + return nil, fmt.Errorf("failed to prepare federated events: %w", err) + } + return outcome, nil +} + +func (s *federatedEventSender) executePreparationWork(ctx context.Context, work preparationWork) error { + switch work := work.(type) { + case stageEventsWork: + if err := s.stageStatelessEvents(ctx, work.staging); err != nil { + return fmt.Errorf("failed to stage events without state: %w", err) + } + return nil + case joinStateWork: + zerolog.Ctx(ctx).Info().Stringer("work", work).Msg("Running artifact job, then starting again") + return s.buildJoinBoundary(ctx) + default: + return s.eventSender.executePreparationWork(ctx, work) + } +} + +type checkedFetchedEvents struct { + // To store without state, in topological order + kept []*types.Event + dropped map[id.EventID]struct{} + // Stored events the check found rejected + rejectedAuth map[id.EventID]struct{} +} + +// checkFetchedEvents checks events fetched over federation (ie unknown to us, or previously rejected) +// against their own auth events. +func (s *federatedEventSender) checkFetchedEvents(ctx context.Context) (*checkedFetchedEvents, error) { + room, err := util.DoReadTransaction(ctx, s.r.db, func(txn fdb.ReadTransaction) (*types.Room, error) { + return s.txnGetFederatedRoom(txn, nil) + }) + if err != nil { + return nil, err + } else if !isServerJoined(room) { + return nil, fmt.Errorf("%w: %s", ErrServerNotInRoom, s.roomID) + } + batchIDs := make(map[id.EventID]struct{}, len(s.evs)) + for _, ev := range s.evs { + batchIDs[ev.ID] = struct{}{} + } + var candidates []*types.Event + for _, ev := range s.attemptEvents(s.fetched) { + if _, found := batchIDs[ev.ID]; !found && ev.RoomID == room.ID { + ev.RoomVersion = room.Version + candidates = append(candidates, ev) + } + } + unstored, _, err := s.eventsToStage(ctx, candidates, false) + if err != nil { + return nil, err + } + + unstored = s.sortEventsTopologically(unstored) + reads, err := eventsendutil.NewArtifactReads(ctx, s.r.db, s.r.events, s.roomID, nil, nil) + if err != nil { + return nil, err + } + dropped, rejectedAuth, err := s.checkOwnAuthEvents(ctx, reads, unstored, nil) + if err != nil { + return nil, err + } + kept := slices.DeleteFunc(unstored, func(ev *types.Event) bool { + _, found := dropped[ev.ID] + return found + }) + return &checkedFetchedEvents{kept: kept, dropped: dropped, rejectedAuth: rejectedAuth}, nil +} + +// federatedPreparation is one attempt of preparing a federated send +type federatedPreparation struct { + *federatedEventSender + ctx context.Context + txn fdb.ReadTransaction + plan *publishPlan + resolver *eventsendutil.Resolver + // The state before batch events given one, taken instead of the state after their prev events + givenBefore map[id.EventID]types.StateHash + dropped map[id.EventID]struct{} + rejected map[id.EventID]struct{} +} + +func (p *federatedPreparation) newContextResolver() *eventsendutil.Resolver { + return eventsendutil.NewResolver(p.txn, p.plan.eventsProvider, p.plan.authGraph, eventsendutil.ResolverOptions{ + StateBatch: p.plan.stateBatch, + RoomID: p.roomID, + RoomVersion: p.plan.room.Version, + MaxCandidates: p.r.config.Rooms.StateBudget.InlineResolutionCandidates, + MaxStateTuples: p.r.config.Rooms.StateBudget.InlineResolutionStateTuples, + }) +} + +// txnPrepareFederated prepares an attempt's copies of federated events. Events are authorized +// against their own auth events, step 4, the state before them, step 5, and the room's current +// state, step 6. +func (s *federatedEventSender) txnPrepareFederated( + ctx context.Context, + txn fdb.ReadTransaction, + evs []*types.Event, + fetchedDropped map[id.EventID]struct{}, +) (preparationOutcome, error) { + join := s.join + log := zerolog.Ctx(ctx) + + room, err := s.txnGetFederatedRoom(txn, join) + if err != nil { + return nil, err + } + if joined := isServerJoined(room); joined && join.inJoinMode() { + return nil, errServerJoined + } else if !joined && !join.inJoinMode() { + return nil, fmt.Errorf("%w: %s", ErrServerNotInRoom, s.roomID) + } + var extremities []id.EventID + if !join.inJoinMode() { + extremities = s.r.events.TxnLookupCurrentRoomExtremEventIDs(txn, s.roomID) + } else if join.diff == nil || room.CurrentState != join.diff.from { + return needsWork{work: joinStateWork{from: room.CurrentState}}, nil + } + + readopted, err := s.txnReadoptedEvents(ctx, txn) + if err != nil { + return nil, err + } + // Prefilled with the attempt's events, as the provider merely provides key value access without + // any authorization + eventsProvider := s.r.events.NewTxnEventsProvider(ctx, txn).WithEvents(readopted...).WithEvents(evs...) + for _, ev := range evs { + eventsProvider.WillGet(ev.AuthDependencyIDs()...) + eventsProvider.WillGet(ev.PrevEventIDs...) + } + eventsProvider.WillGet(extremities...) + + stored, err := s.r.txnStoredEventIDs(txn, util.EventsToIDs(evs)) + if err != nil { + return nil, err + } + // The events provider holds the incoming copies, so stored copies come from a provider of their own + storedEvents := s.r.events.NewTxnEventsProvider(ctx, txn) + for eventID := range stored { + storedEvents.WillGet(eventID) + } + + stateBatch, authGraph := s.txnNewStateBatch(txn, eventsProvider, evs...) + plan := &publishPlan{ + room: room, + guard: sendGuard{revision: room.StateRevision, joined: !join.inJoinMode()}, + preparedEvents: preparedEvents{ + stateBatch: stateBatch, + authGraph: authGraph, + eventsProvider: eventsProvider, + evs: make([]*types.Event, 0, len(evs)), + replacedExtremities: make(map[id.EventID][]id.EventID, len(evs)), + }, + } + p := &federatedPreparation{ + federatedEventSender: s, + ctx: ctx, + txn: txn, + plan: plan, + // Batch events citing a fetched event that could not be evaluated are dropped with it + dropped: maps.Clone(fetchedDropped), + rejected: make(map[id.EventID]struct{}), + } + p.resolver = p.newContextResolver() + if p.dropped == nil { + p.dropped = make(map[id.EventID]struct{}) + } + + // Step 4: Passes authorization rules based on the event’s auth events, otherwise it is rejected. + for _, ev := range evs { + if _, found := stored[ev.ID]; found { + storedEv, err := storedEvents.Get(ev.ID) + if err != nil { + return nil, err + } + // Still evaluated in turn, as events later in the batch may cite it, and they see the + // outcome and state stored for it + log.Warn().Stringer("event_id", ev.ID).Msg("Received duplicate event we already know about") + ev.IsDuplicate = true + if storedEv != nil { + ev.CopyOutcome(storedEv) + } + if ev.Rejected { + plan.rejected = append(plan.rejected, RejectedEvent{ev, types.ErrAlreadyExists}) + } + continue + } + plan.guard.unstored = append(plan.guard.unstored, ev.ID) + if err := s.r.txnCheckEventBeforeStore(txn, s.roomID, ev); err != nil { + p.reject(ev, err) + continue + } + ev.RoomVersion = room.Version + drop, authErr, err := s.checkAuthEvents(ctx, eventsProvider, ev, p.dropped, false) + if err != nil { + return nil, err + } else if drop { + p.drop(ev, fmt.Errorf("%w: %w", ErrEventDropped, authErr)) + } else if authErr != nil { + log.Err(authErr).Any("event", ev).Stringer("event_id", ev.ID).Msg("Failed to auth event (step 4)") + p.reject(ev, fmt.Errorf("%w: %w", ErrAuthStage4, authErr)) + // Stored as rejected for good, unless an auth event it misses is stored meanwhile + if errors.Is(authErr, events.ErrAuthEventMissing) { + plan.guard.unstored = append(plan.guard.unstored, plan.missingEvents(ev.AuthDependencyIDs())...) + } + } + } + + if join != nil { + if i := slices.IndexFunc(evs, func(ev *types.Event) bool { return ev.ID == join.ev.ID }); i >= 0 && evs[i].IsDuplicate { + if join.inJoinMode() { + // Answered outside the room's join lock, see remoteJoinSender.send + return nil, errJoinStored + } + join.stored = true + res, err := s.txnStoredJoinResult(txn) + return alreadySent{result: res}, err + } + } + + if join.inJoinMode() { + plan.boundary = join.diff + plan.resetExtremities = join.ev.ID + p.givenBefore = map[id.EventID]types.StateHash{join.ev.ID: join.boundary} + } else { + boundaries, givenBefore, err := p.txnBoundaryContexts(evs, &s.given) + if err != nil { + return nil, err + } + p.resolver.SetBoundaries(boundaries) + p.givenBefore = givenBefore + } + + // Step 5: Passes authorization rules based on the state before the event, otherwise it is rejected. + // Each event's state is the state given before it, or the state after its prev events, earlier + // events in the batch having been assigned theirs by the time a later one cites them. + for _, ev := range evs { + if _, dropped := p.dropped[ev.ID]; dropped { + continue + } else if ev.IsDuplicate { + plan.evs = append(plan.evs, ev) + } else if keep, request, err := p.evaluate(ev); err != nil { + return nil, err + } else if request != nil { + return p.resolutionWork(request), nil + } else if keep { + plan.evs = append(plan.evs, ev) + } + } + if join.inJoinMode() { + if i := slices.IndexFunc(plan.rejected, func(rejected RejectedEvent) bool { + return rejected.Event.ID == join.ev.ID + }); i >= 0 { + return nil, plan.rejected[i].Error + } + } + if join != nil && slices.ContainsFunc(plan.evs, func(ev *types.Event) bool { return ev.ID == join.ev.ID }) { + plan.readopted = readopted + } + + // Step 6: Passes authorization rules based on the current state of the room, otherwise it is + // “soft failed”. Pending extremities start from the room's stored extremities, none for a join in + // join mode, and advance as events are accepted. Current state is the state after them, resolved + // when there are several. + pendingExtremities := extremities + current := stateSteps{current: room.CurrentState} + for _, ev := range plan.evs { + if ev.IsDuplicate || ev.Rejected { + continue + } + evLog := log.With(). + Stringer("event_id", ev.ID). + Stringer("type", ev.Type). + Logger() + + var replaced []id.EventID + if !join.inJoinMode() { + _, authErr, err := plan.txnAuthorizeAt(ctx, txn, current.current, ev) + if err != nil { + return nil, err + } else if authErr != nil { + evLog.Err(authErr). + Any("event", ev). + Msg("Soft failed to auth event (step 6)") + // Soft failed events keep their state, but stay out of room indices, extremities + // and current state + ev.SoftFailed = true + continue + } + evLog.Trace().Msg("Event passed authorization step 6") + + if replaced, err = plan.findReplacedExtremities(evLog, pendingExtremities, ev.PrevEventIDs); err != nil { + return nil, err + } + } + pendingExtremities = s.advanceExtremities(pendingExtremities, replaced, ev.ID) + plan.replacedExtremities[ev.ID] = replaced + + next := ev.AfterState + if len(pendingExtremities) > 1 { + var request *eventsendutil.ResolutionRequest + if next, request, err = p.resolver.Resolve(pendingExtremities); err != nil { + return nil, err + } else if request != nil { + return p.resolutionWork(request), nil + } + } + current.advance(ev.ID, next) + evLog.Debug().Msg("Event authorized for storage") + } + + if len(pendingExtremities) > 1 { + log.Warn(). + Strs("extremity_event_ids", util.StringersToStrs(pendingExtremities)). + Msg("Room has several extremities, current state is resolved") + } + plan.stateSteps = current.steps + return readyToPublish{plan: plan}, nil +} + +// evaluate assigns an event its state and authorizes it against the state before it, step 5. +// Returns false for an event dropped from the batch. +func (p *federatedPreparation) evaluate(ev *types.Event) (bool, *eventsendutil.ResolutionRequest, error) { + log := zerolog.Ctx(p.ctx).With(). + Stringer("event_id", ev.ID). + Stringer("type", ev.Type). + Logger() + + before, given := p.givenBefore[ev.ID] + var knownPrevs []id.EventID + if !given { + // Resolving only a subset of the prev events would persist incomplete state. Leave the + // event unstored so it can be evaluated again when state at every prev event is available, + // from the sending server for prev events without state here. + var unknownPrevs []id.EventID + var err error + if knownPrevs, unknownPrevs, err = p.resolver.PartitionPrevs(ev.PrevEventIDs); err != nil { + return false, nil, err + } else if len(unknownPrevs) > 0 { + p.drop(ev, fmt.Errorf("%w: %w: %s", ErrEventDropped, ErrPrevEventsUnavailable, unknownPrevs)) + return false, nil, nil + } + } + + // An event is dropped with any auth event it cites, so no stored auth chain reaches an event + // that is not stored. + if authEventID, found := p.citedAuthEvent(ev, p.dropped); found { + p.drop(ev, fmt.Errorf("%w: auth event %s was dropped", ErrEventDropped, authEventID)) + return false, nil, nil + } + + if !given { + var err error + var request *eventsendutil.ResolutionRequest + before, request, err = p.resolver.StateBefore(knownPrevs) + if errors.Is(err, stateres.ErrUnsupportedAlgorithm) { + // The only state that cannot be worked out from sound inputs, anything else is a + // storage or consistency failure and fails the batch + p.drop(ev, fmt.Errorf("%w: %w", ErrEventDropped, err)) + return false, nil, nil + } else if err != nil { + return false, nil, err + } else if request != nil { + return false, request, nil + } + } + + // Rejected events contribute nothing, so later events citing one fall through to the state + // before it + ev.BeforeState, ev.AfterState = before, before + if ev.Rejected { + return true, nil, nil + } + + if authEventID, found := p.citedAuthEvent(ev, p.rejected); found { + p.reject(ev, fmt.Errorf("%w: auth event %s was rejected", ErrAuthStage4, authEventID)) + return true, nil, nil + } + + authState, authErr, err := p.plan.txnAuthorizeAt(p.ctx, p.txn, before, ev) + if err != nil { + return false, nil, err + } else if authErr != nil { + log.Err(authErr). + Any("event", ev). + Msg("Failed to auth event (step 5)") + // Flag event as rejected - we still store it + p.reject(ev, fmt.Errorf("%w: %w", ErrAuthStage5, authErr)) + return true, nil, nil + } + log.Trace().Msg("Event passed authorization step 5") + + var prevStateEventID id.EventID + if ev.StateKey != nil { + prevStateEventID = authState[ev.StateTup()] + } + if err := p.plan.txnApplyEvent(p.txn, before, ev); errors.Is(err, state.ErrEntryTooLarge) { + log.Err(err).Msg("Rejecting state event too large for room state") + p.reject(ev, fmt.Errorf("state event does not fit in room state: %w", err)) + return true, nil, nil + } else if errors.Is(err, events.ErrAuthEventPending) { + // Its auth chain reaches a batch event through stored events the batch order cannot see, so it + // is left for the sending server to send again + p.drop(ev, fmt.Errorf("%w: %w", ErrEventDropped, err)) + return false, nil, nil + } else if errors.Is(err, events.ErrAuthEventNotFinalized) { + log.Err(err).Msg("Rejecting state event citing an auth event that is not accepted state") + p.reject(ev, fmt.Errorf("%w: %w", ErrAuthStage4, err)) + return true, nil, nil + } else if err != nil { + return false, nil, err + } else if err := p.r.txnPreProcessEventUnsigned(p.txn, p.plan.eventsProvider, ev, prevStateEventID); err != nil { + return false, nil, err + } + return true, nil, nil +} + +// resolutionWork captures only the preparation inputs the job needs after this transaction ends. +func (p *federatedPreparation) resolutionWork(request *eventsendutil.ResolutionRequest) needsWork { + return needsWork{work: resolveStateWork{ + request: request, + inputs: artifactInputs{ + stateBatch: p.plan.stateBatch, + eventsProvider: p.plan.eventsProvider, + authGraph: p.plan.authGraph, + }, + roomVersion: p.plan.room.Version, + }} +} + +// reject flags an event rejected, it is still stored, and records why +func (p *federatedPreparation) reject(ev *types.Event, err error) { + ev.Rejected = true + p.rejected[ev.ID] = struct{}{} + p.plan.rejected = append(p.plan.rejected, RejectedEvent{ev, err}) +} + +func (s *federatedEventSender) citedAuthEvent(ev *types.Event, eventIDs map[id.EventID]struct{}) (id.EventID, bool) { + for _, authID := range ev.AuthEventIDs { + if _, found := eventIDs[authID]; found { + return authID, true + } + } + return "", false +} + +func (p *federatedPreparation) drop(ev *types.Event, err error) { + zerolog.Ctx(p.ctx).Warn(). + Err(err). + Stringer("event_id", ev.ID). + Msg("Dropping event from batch") + // Events citing it as a prev event find no state after it + ev.BeforeState, ev.AfterState = types.StateHash{}, types.StateHash{} + p.dropped[ev.ID] = struct{}{} + // Replaces any step 4 rejection, which would imply the event is stored + if i := slices.IndexFunc(p.plan.rejected, func(rejected RejectedEvent) bool { + return rejected.Event == ev + }); i >= 0 { + p.plan.rejected[i].Error = err + } else { + p.plan.rejected = append(p.plan.rejected, RejectedEvent{ev, err}) + } +} + +func (s *federatedEventSender) txnGetFederatedRoom(txn fdb.ReadTransaction, join *remoteJoin) (*types.Room, error) { + room, err := s.r.txnGetRoom(txn, s.roomID) + if errors.Is(err, types.ErrRoomNotFound) && join.inJoinMode() { + newRoom := *join.room + room, err = &newRoom, nil + } else if errors.Is(err, types.ErrRoomNotFound) { + room, err = s.r.newRoomFromEvents(s.roomID, util.EventsToPartialEvents(s.evs)) + } + if err != nil { + return nil, err + } else if !room.Federated { + return nil, errRoomNotFederated + } + if join != nil { + room.Version = cmp.Or(room.Version, join.ev.RoomVersion) + } + return room, nil +} + +// Events checked against their own auth events per batch of reads +const ownAuthCheckChunk = 1000 + +func (s *federatedEventSender) checkOwnAuthEvents( + ctx context.Context, + reads *eventsendutil.ArtifactReads, + evs []*types.Event, + required *types.Event, +) (dropped, rejectedAuth map[id.EventID]struct{}, err error) { + checking := make(map[id.EventID]struct{}, len(evs)) + for _, ev := range evs { + checking[ev.ID] = struct{}{} + } + if err := reads.Do(func() error { + reads.Events().WithEvents(evs...) + return nil + }); err != nil { + return nil, nil, err + } + + dropped = make(map[id.EventID]struct{}) + rejectedAuth = make(map[id.EventID]struct{}) + for chunk := range slices.Chunk(evs, ownAuthCheckChunk) { + if err := reads.Do(func() error { + eventsProvider := reads.Events() + for _, ev := range chunk { + eventsProvider.WillGet(ev.AuthEventIDs...) + } + for _, ev := range chunk { + // A batch of reads failing runs again + ev.Rejected = false + delete(dropped, ev.ID) + drop, authErr, err := s.checkAuthEvents(ctx, eventsProvider, ev, dropped, true) + if err != nil { + return fmt.Errorf("failed to check event %s: %w", ev.ID, err) + } else if authErr == nil { + continue + } else if ev == required { + return fmt.Errorf("%w: %s event %s: %w", ErrAuthStage4, ev.Type, ev.ID, authErr) + } + evLog := zerolog.Ctx(ctx).Warn(). + Err(authErr). + Stringer("event_id", ev.ID). + Stringer("type", ev.Type) + if drop { + evLog.Msg("Dropping event that could not be authorized (step 4)") + dropped[ev.ID] = struct{}{} + continue + } + evLog.Msg("Rejecting event that failed auth (step 4)") + ev.Rejected = true + for _, authID := range ev.AuthEventIDs { + if _, found := checking[authID]; found { + continue + } else if authEv, err := eventsProvider.Get(authID); err != nil { + return err + } else if authEv != nil && authEv.Rejected { + rejectedAuth[authID] = struct{}{} + } + } + } + return nil + }); err != nil { + return nil, nil, err + } + } + return dropped, rejectedAuth, nil +} + +func (s *federatedEventSender) checkAuthEvents( + ctx context.Context, + eventsProvider *events.TxnEventsProvider, + ev *types.Event, + dropped map[id.EventID]struct{}, + dropMissing bool, +) (drop bool, authErr, err error) { + if authID, found := s.citedAuthEvent(ev, dropped); found { + return true, fmt.Errorf("auth event %s was dropped", authID), nil + } + if authErr, err = eventsProvider.CheckEventAuthEvents(ctx, ev); err != nil { + return false, nil, err + } + return dropMissing && errors.Is(authErr, events.ErrAuthEventMissing), authErr, nil +} + +// Staging + +type stageEventsWork struct{ staging *stagingEvents } + +func (stageEventsWork) artifactKey() string { return "" } +func (stageEventsWork) String() string { return "stage supporting events" } + +// eventsToStage returns those of the events to stage, once each and keeping their order: those not +// stored as room events yet, and with readopt the stored ones the check did not reject that are +// stored rejected, which it also returns. +func (s *federatedEventSender) eventsToStage( + ctx context.Context, + evs []*types.Event, + readopt bool, +) ([]*types.Event, map[id.EventID]struct{}, error) { + seen := make(map[id.EventID]struct{}, len(evs)) + unique := make([]*types.Event, 0, len(evs)) + for _, ev := range evs { + if _, found := seen[ev.ID]; !found { + seen[ev.ID] = struct{}{} + unique = append(unique, ev) + } + } + stored, err := s.r.storedEventIDs(ctx, util.EventsToIDs(unique)) + if err != nil { + return nil, nil, err + } + var candidates []*types.Event + for _, ev := range unique { + if _, found := stored[ev.ID]; found && readopt && !ev.Rejected { + candidates = append(candidates, ev) + } + } + rejected, err := s.storedRejected(ctx, candidates) + if err != nil { + return nil, nil, err + } + staged := slices.DeleteFunc(unique, func(ev *types.Event) bool { + _, found := stored[ev.ID] + _, readopted := rejected[ev.ID] + return found && !readopted + }) + return staged, rejected, nil +} + +// storedRejected returns those of the stored events whose stored copy is rejected, reading the copies +// of a chunk of them per read transaction, see storedCheckChunks. +func (s *federatedEventSender) storedRejected(ctx context.Context, evs []*types.Event) (map[id.EventID]struct{}, error) { + rejected := make(map[id.EventID]struct{}) + for _, chunk := range s.storedCheckChunks(evs) { + if _, err := util.DoReadTransaction(ctx, s.r.db, func(txn fdb.ReadTransaction) (types.Nil, error) { + storedEvents := s.r.events.NewTxnEventsProvider(ctx, txn) + storedEvents.WillGet(util.EventsToIDs(chunk)...) + for _, ev := range chunk { + if storedEv, err := storedEvents.Get(ev.ID); err != nil { + return nil, err + } else if storedEv != nil && storedEv.Rejected { + rejected[ev.ID] = struct{}{} + } + } + return nil, nil + }); err != nil { + return nil, err + } + } + return rejected, nil +} + +// Read batch size limits for previously stored events (to check current rejected state) +const ( + storedCheckChunk = 5000 + readoptCheckBytes = 4 << 20 +) + +func (s *federatedEventSender) storedCheckChunks(evs []*types.Event) [][]*types.Event { + var chunks [][]*types.Event + size := 0 + for _, ev := range evs { + evSize := len(ev.ToMsgpack()) + if n := len(chunks); n == 0 || len(chunks[n-1]) == storedCheckChunk || size+evSize > readoptCheckBytes { + chunks = append(chunks, nil) + size = 0 + } + chunks[len(chunks)-1] = append(chunks[len(chunks)-1], ev) + size += evSize + } + return chunks +} + +// Estimated bytes an event stored without state writes besides its eid value and the auth event IDs +// of its eah row: three keys, the etv value, chain rows and a conflict range for each row read or +// written. +const stagedEventOverhead = 1000 + +// stagingEvents are checked events of a room to store without state, in topological order, citing +// only stored events or earlier ones. +type stagingEvents struct { + evs []*types.Event + // Stored already as rejected, which a remote join's publish accepts again, see txnReadoptedEvents: + // staging only finalizes their auth headers, as the events citing them need + readopt map[id.EventID]struct{} + // Stored events the check found rejected, so the events citing them were rejected + rejectedAuth map[id.EventID]struct{} + // Whether the events were checked for a room this server is joined to, or a remote join + joined bool + staged bool +} + +// stageStatelessEvents stores checked events of a room without state ahead of a send +func (s *federatedEventSender) stageStatelessEvents(ctx context.Context, staging *stagingEvents) error { + evs := staging.evs + for _, ev := range evs { + if _, found := staging.readopt[ev.ID]; !found { + s.r.stripEventUnsigned(ev) + } + } + staged := 0 + for _, chunk := range s.chunkStatelessEvents(evs) { + staged += len(chunk) + if _, err := util.DoWriteTransactionWithVersion(ctx, s.r.db, func(txn fdb.Transaction) (types.Nil, error) { + return nil, s.txnStageStatelessEvents(ctx, txn, evs[:staged], chunk, staging) + }); err != nil { + return err + } + } + staging.staged = true + return nil +} + +func (s *federatedEventSender) chunkStatelessEvents(evs []*types.Event) [][]*types.Event { + budget := s.r.config.Rooms.StateBudget.StagingBytes + var chunks [][]*types.Event + size := 0 + for _, ev := range evs { + evSize := s.stagedEventBytes(ev) + if n := len(chunks); n == 0 || size+evSize > budget || len(chunks[n-1]) == types.MaxVersionstampUserVersion { + chunks = append(chunks, nil) + size = 0 + } + chunks[len(chunks)-1] = append(chunks[len(chunks)-1], ev) + size += evSize + } + return chunks +} + +func (s *federatedEventSender) stagedEventBytes(ev *types.Event) int { + size := len(ev.ToMsgpack()) + stagedEventOverhead + for _, authID := range ev.AuthEventIDs { + size += len(authID) + } + return size +} + +func (s *federatedEventSender) txnStageStatelessEvents( + ctx context.Context, + txn fdb.Transaction, + staged, chunk []*types.Event, + staging *stagingEvents, +) error { + // A snapshot read, as a publish to the room changes nothing staging relies on. A remote join + // finding this server joined starts again as a member's join. + room, err := roomOrNil(s.r.txnGetRoom(txn.Snapshot(), s.roomID)) + if err != nil { + return err + } else if joined := isServerJoined(room); joined && !staging.joined { + return fmt.Errorf("%w: %w", errGuardFailed, errServerJoined) + } else if !joined && staging.joined { + return fmt.Errorf("%w: %s", ErrServerNotInRoom, s.roomID) + } + var unstored []*types.Event + var rejectedAuth []id.EventID + for _, ev := range chunk { + if _, found := staging.readopt[ev.ID]; !found { + unstored = append(unstored, ev) + } + for _, authID := range ev.AuthEventIDs { + if _, found := staging.rejectedAuth[authID]; found && !slices.Contains(rejectedAuth, authID) { + rejectedAuth = append(rejectedAuth, authID) + } + } + } + if storedNow, err := s.r.txnStoredEventIDs(txn, util.EventsToIDs(unstored)); err != nil { + return err + } else if len(storedNow) > 0 { + return errEventsStoredConcurrently + } + storedEvents := s.r.events.NewTxnEventsProvider(ctx, txn) + storedEvents.WillGet(rejectedAuth...) + for _, authID := range rejectedAuth { + if authEv, err := storedEvents.Get(authID); err != nil { + return err + } else if authEv != nil && !authEv.Rejected { + return fmt.Errorf("%w: auth event %s accepted since", errEventsStoredConcurrently, authID) + } + } + + // The graph reads the chain allocator and tips it extends with normal reads, so another writer + // extending them conflicts with this one + eventsProvider := s.r.events.NewTxnEventsProvider(ctx, txn).WithEvents(staged...) + authGraph := s.r.events.NewAuthGraph(txn, s.roomID, eventsProvider) + authGraph.Add(chunk...) + if err := finalizeAcceptedState(authGraph, chunk...); err != nil { + return err + } + s.r.events.TxnStoreEventRows(txn, false, unstored...) + authGraph.Write(txn) + return nil +} + +// Join state + +type joinStateWork struct{ from types.StateHash } + +func (w joinStateWork) artifactKey() string { return "join " + string(w.from[:]) } +func (w joinStateWork) String() string { + return fmt.Sprintf("the state before the join diffed from context %x", w.from) +} + +func (s *federatedEventSender) txnStoredJoinResult( + txn fdb.ReadTransaction, +) (*SendEventsResult, error) { + joinEv := s.join.ev + stored := s.r.events.TxnGetEvent(txn, joinEv.ID) + if stored == nil { + return nil, fmt.Errorf("stored remote join: %w: %s", types.ErrEventNotFound, joinEv.ID) + } + + var rejection error + switch { + case stored.Rejected: + rejection = fmt.Errorf("%w: remote join %s was rejected", types.ErrAlreadyExists, joinEv.ID) + case stored.SoftFailed: + rejection = fmt.Errorf("%w: remote join %s was soft failed", types.ErrAlreadyExists, joinEv.ID) + case !stored.HasStateIn(s.roomID): + room, err := roomOrNil(s.r.txnGetRoom(txn, s.roomID)) + if err != nil { + return nil, err + } else if !isServerJoined(room) { + return nil, errJoinUnpublished + } + joiner := id.UserID(*joinEv.StateKey) + current, err := s.r.txnRoomMembers(txn, room, []id.UserID{joiner}) + if err != nil { + return nil, err + } else if current[types.MemberStateTup(joiner)].EventID != joinEv.ID { + rejection = fmt.Errorf("remote join %s: %w", joinEv.ID, types.ErrStateUnavailable) + } + } + res := &SendEventsResult{transactionDuplicate: true} + if rejection != nil { + res.Rejected = []RejectedEvent{{Event: stored, Error: rejection}} + } else { + res.Allowed = []*types.Event{stored} + } + return res, nil +} + +// txnReadoptedEvents returns accepted copies, without state, of events currently stored rejected +func (s *federatedEventSender) txnReadoptedEvents( + ctx context.Context, + txn fdb.ReadTransaction, +) ([]*types.Event, error) { + candidates := s.join.readopted() + if len(candidates) == 0 { + return nil, nil + } + storedEvents := s.r.events.NewTxnEventsProvider(ctx, txn) + storedEvents.WillGet(candidates...) + var readopted []*types.Event + for _, eventID := range candidates { + storedEv, err := storedEvents.Get(eventID) + if err != nil { + return nil, err + } else if storedEv == nil || !storedEv.Rejected { + continue + } + zerolog.Ctx(ctx).Warn(). + Stringer("event_id", storedEv.ID). + Stringer("type", storedEv.Type). + Msg("Accepting stored rejected event from the join response") + // A rejected event was never soft failed + readoptedEv := *storedEv + readoptedEv.ResetOutcome() + readopted = append(readopted, &readoptedEv) + } + return readopted, nil +} + +// buildJoinBoundary is the artifact job building the state before a remote join as a boundary over +// the room's current state, and diffing the current state against it for the join's publish. It +// stages what it builds. +func (s *federatedEventSender) buildJoinBoundary(ctx context.Context) error { + join := s.join + room, err := s.r.GetRoom(ctx, s.roomID) + if err != nil { + return err + } else if room == nil { + room = join.room + } + + start := time.Now() + reads, err := eventsendutil.NewArtifactReads(ctx, s.r.db, s.r.events, s.roomID, nil, nil) + if err != nil { + return err + } + stateBatch := s.r.state.NewJobBatch(s.roomID, reads.Read) + // A job batch reads in the job's transactions whatever transaction it is given + base, baseState, err := s.txnBoundaryBase(nil, stateBatch, room.CurrentState, len(join.state)) + if err != nil { + return err + } + boundary, err := s.txnApplyBoundary(nil, stateBatch, base, baseState, join.state) + if err != nil { + return err + } + // Ahead of staging, which leaves the boundary's pages to storage the job's older transactions + // may not see yet + changes, err := stateBatch.TxnDiff(nil, room.CurrentState, boundary) + if err != nil { + return fmt.Errorf("failed to diff the room's state against the state before the join: %w", err) + } else if err := stateBatch.Stage(ctx, s.r.config.Rooms.StateBudget.StagingBytes, boundary, base); err != nil { + return err + } + zerolog.Ctx(ctx).Info(). + Str("job", "join state"). + Int("state", len(join.state)). + Int("current_state", len(baseState)). + Int("changes", len(changes)). + Int("transactions", reads.Transactions()). + Dur("duration", time.Since(start)). + Msg("Built the state before the join") + join.boundary = boundary + join.diff = &boundaryChanges{from: room.CurrentState, state: boundary, changes: changes} + return nil +} + +// Event ordering + +// sortEventsTopologically orders a batch so every event follows the batch events it cites as prev +// or auth events, otherwise keeping the input order, and without later copies of an event. Events +// on a cycle, which no valid batch has, follow the rest in input order. +func (s *federatedEventSender) sortEventsTopologically(batch []*types.Event) []*types.Event { + evs := make([]*types.Event, 0, len(batch)) + indexes := make(map[id.EventID]int, len(batch)) + for _, ev := range batch { + if _, found := indexes[ev.ID]; !found { + indexes[ev.ID] = len(evs) + evs = append(evs, ev) + } + } + + citedBy := make([][]int, len(evs)) + remaining := make([]int, len(evs)) + for i, ev := range evs { + cited := make(map[int]struct{}) + for _, citedID := range slices.Concat(ev.PrevEventIDs, ev.AuthDependencyIDs()) { + if j, found := indexes[citedID]; found && j != i { + cited[j] = struct{}{} + } + } + for j := range cited { + citedBy[j] = append(citedBy[j], i) + } + remaining[i] = len(cited) + } + + ready := &indexHeap{} + for i := range evs { + if remaining[i] == 0 { + *ready = append(*ready, i) + } + } + heap.Init(ready) + sorted := make([]*types.Event, 0, len(evs)) + placed := make([]bool, len(evs)) + for ready.Len() > 0 { + i := heap.Pop(ready).(int) + sorted = append(sorted, evs[i]) + placed[i] = true + for _, j := range citedBy[i] { + remaining[j]-- + if remaining[j] == 0 { + heap.Push(ready, j) + } + } + } + for i, ev := range evs { + if !placed[i] { + sorted = append(sorted, ev) + } + } + return sorted +} + +// indexHeap gives the lowest index first +type indexHeap []int + +func (h indexHeap) Len() int { return len(h) } +func (h indexHeap) Less(i, j int) bool { return h[i] < h[j] } +func (h indexHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h *indexHeap) Push(x any) { *h = append(*h, x.(int)) } +func (h *indexHeap) Pop() any { + old := *h + x := old[len(old)-1] + *h = old[:len(old)-1] + return x +} + +// advanceExtremities returns the pending extremities once an event is accepted: those it does not +// replace, then the event. +func (s *federatedEventSender) advanceExtremities(pendingExtremities, replaced []id.EventID, eventID id.EventID) []id.EventID { + next := make([]id.EventID, 0, len(pendingExtremities)+1) + for _, extremityID := range pendingExtremities { + if extremityID != eventID && !slices.Contains(replaced, extremityID) { + next = append(next, extremityID) + } + } + return append(next, eventID) +} + +// Given state + +var errUnusableBoundary = errors.New("unusable state from the sending server") + +// givenState is a state the sending server gave: the state before a prev event without state here, +// which with the prev event gives the state after it, or the state before a batch event +type givenState struct { + eventID id.EventID + // Nil for the state before a batch event + prev *types.Event + stateIDs []id.EventID +} + +// txnBoundaryContexts builds the contexts of the states the sending server gave. The state after +// each prev event without state here is built from the state before it, as Synapse does for prev +// events still missing after get_missing_events: that state, without events unknown, rejected or not +// state here, and the prev event itself when it is an accepted state event. Step 5 is not run on the +// prev event. The state before a batch event is built the same way without adding the event, unless +// built already. A state that is unusable gives no context. +func (p *federatedPreparation) txnBoundaryContexts( + batch []*types.Event, + given *givenStates, +) (afterPrevs, beforeEvents map[id.EventID]types.StateHash, err error) { + // A batch event is only known to be stored once evaluated, after the boundaries are needed + unsettled := make(map[id.EventID]struct{}, len(batch)) + for _, ev := range batch { + if !ev.IsDuplicate { + unsettled[ev.ID] = struct{}{} + } + } + prevIDs := slices.Sorted(maps.Keys(given.beforePrevs)) + p.plan.eventsProvider.WillGet(prevIDs...) + var states []givenState + size := 0 + for _, prevID := range prevIDs { + prev, err := p.plan.eventsProvider.Get(prevID) + if err != nil { + return nil, nil, err + } else if _, found := unsettled[prevID]; found || prev.HasStateIn(p.plan.room.ID) { + continue + } else if prev == nil || prev.Outlier || prev.RoomID != p.plan.room.ID { + zerolog.Ctx(p.ctx).Warn().Stringer("prev_event_id", prevID).Msg("No state after prev event, it is unknown") + continue + } + states = append(states, givenState{eventID: prevID, prev: prev, stateIDs: given.beforePrevs[prevID]}) + // With the prev event's own tuple + size = max(size, len(given.beforePrevs[prevID])+1) + } + beforeEvents = make(map[id.EventID]types.StateHash) + for _, ev := range batch { + stateIDs, found := given.beforeEvents[ev.ID] + if !found || ev.IsDuplicate { + continue + } else if boundary, built := given.boundaries[ev.ID]; built { + beforeEvents[ev.ID] = boundary + continue + } + // As a send_join response may repeat the join + stateIDs = slices.DeleteFunc(slices.Clone(stateIDs), func(eventID id.EventID) bool { return eventID == ev.ID }) + states = append(states, givenState{eventID: ev.ID, stateIDs: stateIDs}) + size = max(size, len(stateIDs)) + } + if len(states) == 0 { + return nil, beforeEvents, nil + } + + base, baseState, err := p.txnBoundaryBase(p.txn, p.plan.stateBatch, p.plan.room.CurrentState, size) + if err != nil { + return nil, nil, err + } + baseTups := make(map[id.EventID]types.StateTup, len(baseState)) + for tup, entry := range baseState { + baseTups[entry.EventID] = tup + } + for _, g := range states { + for _, eventID := range g.stateIDs { + if _, found := baseTups[eventID]; !found { + p.plan.eventsProvider.WillGet(eventID) + } + } + } + + afterPrevs = make(map[id.EventID]types.StateHash, len(states)) + for _, g := range states { + log := zerolog.Ctx(p.ctx).With().Stringer("event_id", g.eventID).Bool("prev_event", g.prev != nil).Logger() + stateMap, err := p.boundaryState(baseState, baseTups, g.prev, g.stateIDs, unsettled) + if errors.Is(err, errUnusableBoundary) { + log.Warn().Err(err).Msg("Unusable state given by the sending server") + continue + } else if err != nil { + return nil, nil, err + } + boundary, err := p.txnApplyBoundary(p.txn, p.plan.stateBatch, base, baseState, stateMap) + if errors.Is(err, state.ErrEntryTooLarge) { + log.Warn().Err(err).Msg("Unusable state given by the sending server, a state event does not fit in room state") + continue + } else if err != nil { + return nil, nil, fmt.Errorf("failed to build the state given at %s: %w", g.eventID, err) + } + if g.prev != nil { + afterPrevs[g.eventID] = boundary + } else { + beforeEvents[g.eventID] = boundary + } + } + return afterPrevs, beforeEvents, nil +} + +// The state a sending server gave, with the prev event's own tuple for the state after a prev event, +// nil for the state before a batch event. Unusable when it names two events for one tuple, a batch +// event, or no create event. Every event in a context is accepted state of the room, so one in the +// base, at baseTups, is that tuple's event without loading it, with the membership the base holds. +func (p *federatedPreparation) boundaryState( + base types.StateEntries, + baseTups map[id.EventID]types.StateTup, + prev *types.Event, + stateIDs []id.EventID, + unsettled map[id.EventID]struct{}, +) (types.StateEntries, error) { + for _, eventID := range stateIDs { + if _, found := unsettled[eventID]; found { + return nil, fmt.Errorf("%w: names batch event %s", errUnusableBoundary, eventID) + } + } + stateMap := make(types.StateEntries, len(stateIDs)) + for _, eventID := range stateIDs { + tup, inBase := baseTups[eventID] + entry := base[tup] + if !inBase { + ev, err := p.plan.eventsProvider.Get(eventID) + if err != nil { + return nil, err + } else if !p.isRoomState(ev) { + continue + } + tup, entry = ev.StateTup(), ev.StateEntry() + } + if err := p.addGivenState(stateMap, tup, entry); err != nil { + return nil, fmt.Errorf("%w: %w", errUnusableBoundary, err) + } + } + if prev != nil && isAcceptedState(prev) { + stateMap[prev.StateTup()] = prev.StateEntry() + } + // Events judged on state without one are rejected, which is stored for good + if _, found := stateMap[types.StateTup{Type: event.StateCreate}]; !found { + return nil, fmt.Errorf("%w: names no create event", errUnusableBoundary) + } + return stateMap, nil +} + +// txnBoundaryBase returns the context boundaries of at most size tuples are built over, and its +// state: the room's current state, which a boundary usually shares most of its state with. Current +// state of over twice that size differs from each boundary by more tuples than the boundary holds, +// so it is not read and the base is empty. +func (s *federatedEventSender) txnBoundaryBase( + txn fdb.ReadTransaction, + stateBatch *state.Batch, + currentState types.StateHash, + size int, +) (types.StateHash, types.StateEntries, error) { + if currentState.IsZero() { + return state.EmptyContext, types.StateEntries{}, nil + } + if count, err := stateBatch.TxnCount(txn, currentState); err != nil { + return types.StateHash{}, nil, fmt.Errorf("failed to count the room's current state: %w", err) + } else if count > 2*size { + return state.EmptyContext, types.StateEntries{}, nil + } + baseState, err := stateBatch.TxnIterateEntries(txn, currentState) + if err != nil { + return types.StateHash{}, nil, fmt.Errorf("failed to read the room's current state: %w", err) + } + return currentState, baseState, nil +} + +// txnApplyBoundary builds the context holding a boundary's state from the base or from empty, +// whichever needs fewer changes. Both give the same context. +func (s *federatedEventSender) txnApplyBoundary( + txn fdb.ReadTransaction, + stateBatch *state.Batch, + base types.StateHash, + baseState, target types.StateEntries, +) (types.StateHash, error) { + from, delta := base, state.Delta(baseState, target) + if len(delta) > len(target) { + from, delta = state.EmptyContext, target + } + return stateBatch.TxnApply(txn, from, delta) +} + +// The event may be nil +func (s *federatedEventSender) isRoomState(ev *types.Event) bool { + return ev != nil && !ev.Outlier && ev.RoomID == s.roomID && isAcceptedState(ev) +} + +// A second event for one tuple is an error +func (s *federatedEventSender) addGivenState(stateMap types.StateEntries, tup types.StateTup, entry types.StateEntry) error { + if existing, found := stateMap[tup]; found && existing.EventID != entry.EventID { + return fmt.Errorf("names %s and %s for %v", existing.EventID, entry.EventID, tup) + } + stateMap[tup] = entry + return nil +} diff --git a/internal/databases/rooms/eventsendjoin.go b/internal/databases/rooms/eventsendjoin.go new file mode 100644 index 0000000..87cd19d --- /dev/null +++ b/internal/databases/rooms/eventsendjoin.go @@ -0,0 +1,283 @@ +package rooms + +import ( + "cmp" + "context" + "errors" + "fmt" + "maps" + "slices" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/databases/rooms/events" + "github.com/beeper/babbleserv/internal/databases/rooms/eventsendutil" + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" +) + +var ( + errServerJoined = errors.New("this server joined the room meanwhile") + errJoinStored = errors.New("the join is stored already") + errJoinUnpublished = errors.New("the join is stored without state and this server is not joined yet") +) + +// remoteJoin is a local user's join to a room with the state before it given, as the resident +// server's send_join response gives it +type remoteJoin struct { + ev *types.Event + stateIDs []id.EventID + // The room the response gives, which the join's publish stores when it has no record + room *types.Room + // The state before the join, the response state less the events failing their own auth events + state types.StateEntries + // Response events stored rejected that the join accepts, which its publish stores accepted + readopt []id.EventID + // The state before the join once staged, and the changes to it from the room's current state + boundary types.StateHash + diff *boundaryChanges + // Another join got this server into the room, so the join is sent as any federated event + asMember bool + // Stored already, so the send reports the stored outcome, see txnStoredJoinResult + stored bool +} + +// boundaryChanges are the changes from the room's current state, from, to the state before a remote +// join, diffed ahead of the join's publish, which then diffs only the join's own step +type boundaryChanges struct { + from, state types.StateHash + changes []types.StateChange +} + +// remoteJoinIn returns the join of a batch that is a local user's join with a given before-state, +// nil for any other batch +func (r *RoomsDatabase) remoteJoinIn(evs []*types.Event, given *GivenStates) *remoteJoin { + if given == nil || len(evs) != 1 || evs[0].StateKey == nil || !isJoinEvent(evs[0]) { + return nil + } else if stateIDs, found := given.BeforeEvents[evs[0].ID]; found && r.isLocalUser(id.UserID(*evs[0].StateKey)) { + return &remoteJoin{ev: evs[0], stateIDs: stateIDs} + } + return nil +} + +// inJoinMode reports whether a send publishes a remote join that replaces the room's state +func (join *remoteJoin) inJoinMode() bool { + return join != nil && !join.asMember +} + +// readopted returns the response events the join's publish stores accepted: none once the join is +// sent as a member before its state was built, as the state built then leaves them out. +func (join *remoteJoin) readopted() []id.EventID { + if join == nil || (join.asMember && join.diff == nil) { + return nil + } + return join.readopt +} + +// remoteJoinSender prepares a local user's join to a room this server is not in: it checks the +// join and stages the response events, a job builds the state before the join, then the join +// publishes. +type remoteJoinSender struct { + *federatedEventSender + // The events the last check kept to stage, checked again unless staged + staging *stagingEvents +} + +func (s *remoteJoinSender) send(ctx context.Context) (*SendEventsResult, error) { + res, err := s.sendInJoinTurn(ctx) + if errors.Is(err, errJoinStored) { + s.join.stored = true + return s.run(ctx, s.prepare) + } + return res, err +} + +func (s *remoteJoinSender) sendInJoinTurn(ctx context.Context) (*SendEventsResult, error) { + unlock, err := s.r.lockJoin(ctx, s.roomID) + if err != nil { + return nil, err + } + defer unlock() + return s.run(ctx, s.prepare) +} + +func (s *remoteJoinSender) prepare(ctx context.Context) (preparationOutcome, error) { + if s.join.stored { + return s.storedJoinResult(ctx) + } else if !s.join.asMember { + outcome, err := s.prepareJoin(ctx) + if !errors.Is(err, errServerJoined) { + return outcome, err + } + zerolog.Ctx(ctx).Info().Stringer("event_id", s.join.ev.ID).Msg("Another join got this server into the room, sending the join as a member") + s.join.asMember = true + if !s.join.boundary.IsZero() { + s.given.boundaries = map[id.EventID]types.StateHash{s.join.ev.ID: s.join.boundary} + } + } + return s.federatedEventSender.prepare(ctx) +} + +func (p *remoteJoinSender) prepareJoin(ctx context.Context) (preparationOutcome, error) { + if p.staging == nil || !p.staging.staged { + staging, err := p.checkRemoteJoin(ctx) + if err != nil { + return nil, err + } + p.staging = staging + return needsWork{work: stageEventsWork{staging: staging}}, nil + } + outcome, err := util.DoReadTransaction(ctx, p.r.db, func(txn fdb.ReadTransaction) (preparationOutcome, error) { + return p.txnPrepareFederated(ctx, txn, p.attemptEvents(p.evs), nil) + }) + if err != nil { + return nil, fmt.Errorf("failed to prepare the join: %w", err) + } + return outcome, nil +} + +// checkRemoteJoin checks the given events (from the other server), and the join event against their +// own auth events. Returns the given events to stage and updates the join event. +func (s *remoteJoinSender) checkRemoteJoin(ctx context.Context) (*stagingEvents, error) { + join := s.join + inState := make(map[id.EventID]struct{}, len(join.stateIDs)) + for _, eventID := range join.stateIDs { + inState[eventID] = struct{}{} + } + room, err := util.DoReadTransaction(ctx, s.r.db, func(txn fdb.ReadTransaction) (*types.Room, error) { + return s.txnCheckRemoteJoinRoom(txn, inState) + }) + if err != nil { + return nil, err + } + + reads, err := eventsendutil.NewArtifactReads(ctx, s.r.db, s.r.events, s.roomID, nil, nil) + if err != nil { + return nil, err + } + joinEv := s.attemptEvents([]*types.Event{join.ev})[0] + kept, rejectedAuth, err := s.checkRemoteJoinAuth(ctx, reads, joinEv, s.attemptEvents(s.fetched)) + if err != nil { + return nil, err + } + + // A state response names one event for each tuple, the join fails on any other + stateEvs := slices.DeleteFunc(slices.Clone(kept), func(ev *types.Event) bool { + _, found := inState[ev.ID] + return !found + }) + joinState, err := s.stateFromEvents(stateEvs) + if err != nil { + return nil, fmt.Errorf("invalid state before the join: %w", err) + } + if err := reads.Do(func() error { + authErr, err := events.NewTxnAuthEventsProvider(ctx, reads.Events(), joinState.EventIDs()).IsEventAllowed(joinEv) + if err != nil { + return err + } else if authErr != nil { + return fmt.Errorf("%w: %w", ErrAuthStage5, authErr) + } + return nil + }); err != nil { + return nil, err + } + + staged, readopt, err := s.eventsToStage(ctx, kept, true) + if err != nil { + return nil, err + } + join.room, join.state, join.readopt = room, joinState, slices.Sorted(maps.Keys(readopt)) + join.boundary, join.diff = types.StateHash{}, nil + return &stagingEvents{evs: staged, readopt: readopt, rejectedAuth: rejectedAuth}, nil +} + +// txnCheckRemoteJoinRoom checks the room a join is to, and that the join is not stored, and gives +// the room the join's publish stores when it has no record: from the create event of the state +// before the join. +func (s *remoteJoinSender) txnCheckRemoteJoinRoom( + txn fdb.ReadTransaction, + inState map[id.EventID]struct{}, +) (*types.Room, error) { + joinEv := s.join.ev + // Each event is staged under the room it names + for _, ev := range slices.Concat(s.fetched, []*types.Event{joinEv}) { + if ev.RoomID != s.roomID { + return nil, fmt.Errorf("event %s in the join to %s is for room %s", ev.ID, s.roomID, ev.RoomID) + } + } + + joinVersionFut := txn.Get(s.r.events.KeyForIDToVersion(joinEv.ID)) + room, err := s.r.txnGetRoom(txn, s.roomID) + if errors.Is(err, types.ErrRoomNotFound) { + roomCreate := []*types.PartialEvent{&joinEv.PartialEvent} + for _, ev := range s.fetched { + if _, found := inState[ev.ID]; found && ev.Type == event.StateCreate && ev.StateKey != nil && *ev.StateKey == "" { + roomCreate = []*types.PartialEvent{&ev.PartialEvent} + break + } + } + room, err = s.r.newRoomFromEvents(s.roomID, roomCreate) + } else if err == nil && isServerJoined(room) { + return nil, errServerJoined + } + if err != nil { + return nil, err + } else if joinVersionFut.MustGet() != nil { + return nil, errJoinStored + } else if !room.Federated { + return nil, errRoomNotFederated + } + // A create event redacted before room version 11 has lost its room version, the join carries the + // version the resident server gave for the room + room.Version = cmp.Or(room.Version, joinEv.RoomVersion) + return room, nil +} + +// storedJoinResult answers a remote join stored already, see txnStoredJoinResult +func (s *remoteJoinSender) storedJoinResult(ctx context.Context) (preparationOutcome, error) { + res, err := util.DoReadTransaction(ctx, s.r.db, func(txn fdb.ReadTransaction) (*SendEventsResult, error) { + return s.txnStoredJoinResult(txn) + }) + if err != nil { + return nil, err + } + return alreadySent{result: res}, nil +} + +// checkRemoteJoinAuth checks the join and the response events against their own auth events, and +// returns the response events to keep, in topological order, and the stored events cited that it +// found rejected. +func (s *remoteJoinSender) checkRemoteJoinAuth( + ctx context.Context, + reads *eventsendutil.ArtifactReads, + joinEv *types.Event, + responseEvs []*types.Event, +) ([]*types.Event, map[id.EventID]struct{}, error) { + // The join goes first so it is the copy checked when the response repeats it + evs := s.sortEventsTopologically(slices.Concat([]*types.Event{joinEv}, responseEvs)) + dropped, rejectedAuth, err := s.checkOwnAuthEvents(ctx, reads, evs, joinEv) + if err != nil { + return nil, nil, err + } + return slices.DeleteFunc(evs, func(ev *types.Event) bool { + _, found := dropped[ev.ID] + return found || ev.ID == joinEv.ID + }), rejectedAuth, nil +} + +// stateFromEvents is the state another server gave as events, without those that are unknown or not +// accepted state of the room. Two events for one tuple are inconsistent. +func (s *remoteJoinSender) stateFromEvents(evs []*types.Event) (types.StateEntries, error) { + stateMap := make(types.StateEntries, len(evs)) + for _, ev := range evs { + if !s.isRoomState(ev) { + continue + } else if err := s.addGivenState(stateMap, ev.StateTup(), ev.StateEntry()); err != nil { + return nil, err + } + } + return stateMap, nil +} diff --git a/internal/databases/rooms/eventsendlocal.go b/internal/databases/rooms/eventsendlocal.go new file mode 100644 index 0000000..15ae504 --- /dev/null +++ b/internal/databases/rooms/eventsendlocal.go @@ -0,0 +1,394 @@ +package rooms + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" + "time" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/matrix-org/gomatrixserverlib" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/databases/rooms/events" + "github.com/beeper/babbleserv/internal/databases/rooms/state" + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" + "github.com/beeper/babbleserv/internal/util/lock" +) + +var ( + ErrServerNotInRoom = errors.New("this server is not in the room") + ErrProfileUpdateNotJoined = errors.New("profile update from a user who is not joined") +) + +type SendLocalEventsOptions struct { + // Ensure (+refresh) a lock is held at commit time + LockTxnRefresh lock.LockTxnRefreshFunc + + TransactionDevice *types.UserDevice + TransactionEndpoint string + TransactionID string + + PublishRoom bool + RoomAlias id.RoomAlias + RoomAliasOwner id.UserID + RequireAllEvents bool +} + +// RequiredEventRejectedError fails a send requiring all its events once one is rejected, storing +// none of them +type RequiredEventRejectedError struct { + Event *types.Event + Err error +} + +func (e *RequiredEventRejectedError) Error() string { + return fmt.Sprintf("required %s event rejected: %s", e.Event.Type, e.Err) +} + +func (e *RequiredEventRejectedError) Unwrap() error { return e.Err } + +// Send local events to a room, populating prev/auth events as well as authorizing the new events +// against the current room state. A room that exists takes local events only while this server is +// joined to it, failing with ErrServerNotInRoom otherwise. The events are prepared and published in +// one write transaction, unless their contexts need staging, when they are sent in attempts. +func (r *RoomsDatabase) SendLocalEvents( + ctx context.Context, + roomID id.RoomID, + partialEvs []*types.PartialEvent, + userPushRules types.UserPushRulesMap, + userRoomContext types.UserRoomContextMap, + options SendLocalEventsOptions, +) (*SendEventsResult, error) { + log := r.getTxnLogContext(ctx, "SendLocalEvents"). + Str("room_id", roomID.String()). + Int("events", len(partialEvs)). + Logger() + ctx = log.WithContext(ctx) + + s := &localEventSender{ + eventSender: r.newEventSender(roomID, pushContext{rules: userPushRules, rooms: userRoomContext}), + partialEvs: partialEvs, + options: options, + now: time.Now().UTC(), + } + return s.send(ctx) +} + +// localEventSender keeps the send's timestamp across the fast path and every staged attempt. +type localEventSender struct { + eventSender + partialEvs []*types.PartialEvent + options SendLocalEventsOptions + now time.Time +} + +func (s *localEventSender) send(ctx context.Context) (*SendEventsResult, error) { + res, err := s.sendInOneTransaction(ctx) + if errors.Is(err, errNeedsStaging) { + zerolog.Ctx(ctx).Debug().Msg("Local events need staging, sending them in attempts") + ctx, cancel := s.withSendTimeout(ctx, sendTimeout) + defer cancel() + return s.run(ctx, s.prepare) + } else if err != nil { + return nil, fmt.Errorf("failed to send local events: %w", err) + } + s.after(ctx, res) + return res, nil +} + +func (s *localEventSender) sendInOneTransaction(ctx context.Context) (*SendEventsResult, error) { + defer s.lock()() + return util.DoWriteTransactionWithVersion(ctx, s.r.db, func(txn fdb.Transaction) (*SendEventsResult, error) { + sender := s.eventSender + sender.publish = func(ctx context.Context, plan *publishPlan) (*SendEventsResult, error) { + if !s.fitsPublish(plan) { + return nil, errNeedsStaging + } + return s.txnPublish(ctx, txn, plan) + } + res, work, err := sender.attempt(ctx, func(ctx context.Context) (preparationOutcome, error) { + return s.txnPrepareLocal(ctx, txn) + }) + if err == nil && work != nil { + return nil, errNeedsStaging + } + return res, err + }) +} + +func (s *localEventSender) prepare(ctx context.Context) (preparationOutcome, error) { + return util.DoReadTransaction(ctx, s.r.db, func(txn fdb.ReadTransaction) (preparationOutcome, error) { + return s.txnPrepareLocal(ctx, txn) + }) +} + +func (s *localEventSender) txnPrepareLocal(ctx context.Context, txn fdb.ReadTransaction) (preparationOutcome, error) { + options := s.options + withTransactionID := options.TransactionDevice != nil && options.TransactionID != "" + if withTransactionID { + if existing := s.r.txnGetEventForTransaction(txn, *options.TransactionDevice, s.roomID, options.TransactionEndpoint, options.TransactionID); existing != nil { + return alreadySent{result: &SendEventsResult{Allowed: []*types.Event{existing}, transactionDuplicate: true}}, nil + } + } + + room, err := s.r.txnGetOrCreateRoomForEvents(txn, s.roomID, s.partialEvs) + if err != nil { + return nil, err + } + created := room.StateRevision == 0 + if !created && !isServerJoined(room) { + return nil, fmt.Errorf("%w: %s", ErrServerNotInRoom, s.roomID) + } + prepared, err := s.txnPrepareLocalEvents(ctx, txn, room) + if err != nil { + return nil, err + } else if options.RequireAllEvents && len(prepared.rejected) > 0 { + return nil, &RequiredEventRejectedError{Event: prepared.rejected[0].Event, Err: prepared.rejected[0].Error} + } + plan := &publishPlan{ + room: room, + guard: sendGuard{revision: room.StateRevision, joined: !created}, + preparedEvents: *prepared, + } + plan.extra = func(txn fdb.Transaction, room *types.Room) error { + if options.RoomAlias != "" { + if err := s.r.txnSetRoomAlias(txn, options.RoomAlias, room.ID, options.RoomAliasOwner); err != nil { + return err + } + } + if options.PublishRoom { + s.r.txnSetRoomPublished(txn, room, true) + } + if withTransactionID && len(plan.evs) > 0 { + s.r.txnStoreEventTransaction(txn, *options.TransactionDevice, room.ID, options.TransactionEndpoint, options.TransactionID, plan.evs[0].ID) + } + if options.LockTxnRefresh != nil { + options.LockTxnRefresh(txn) + } + return nil + } + return readyToPublish{plan: plan}, nil +} + +// Prepare, but don't send, a local event - this populates the event ID, prev/auth events and +// authenticates it against the *current* state. The resulting event must be sent in a separate +// transaction using the *SendFederatedEvents* method which will re-evaluate the auth rules. +// Returns the events and those rejected, whose errors can be returned to users. +func (r *RoomsDatabase) PrepareLocalEvents(ctx context.Context, roomID id.RoomID, partialEvs []*types.PartialEvent) ([]*types.Event, []RejectedEvent, error) { + log := r.getTxnLogContext(ctx, "PrepareLocalEvents"). + Str("room_id", roomID.String()). + Logger() + + ctx = log.WithContext(ctx) + s := &localEventSender{ + eventSender: r.newEventSender(roomID, pushContext{}), + partialEvs: partialEvs, + } + prepared, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (*preparedEvents, error) { + room, err := r.txnGetOrCreateRoomForEvents(txn, roomID, partialEvs) + if err != nil { + return nil, err + } + s.now = time.Now().UTC() + return s.txnPrepareLocalEvents(ctx, txn, room) + }) + if err != nil { + return nil, nil, err + } + return prepared.evs, prepared.rejected, nil +} + +// txnPrepareLocalEvents builds local events sent at now in a line, each authorized against the state +// after the one before, from the room's current state. +func (s *localEventSender) txnPrepareLocalEvents( + ctx context.Context, + txn fdb.ReadTransaction, + room *types.Room, +) (*preparedEvents, error) { + eventsProvider := s.r.events.NewTxnEventsProvider(ctx, txn) + stateBatch, authGraph := s.txnNewStateBatch(txn, eventsProvider) + prepared := &preparedEvents{ + stateBatch: stateBatch, + authGraph: authGraph, + eventsProvider: eventsProvider, + } + // Prefetch the batch's auth and previous state together. Each event below reads + // its own before-state, including changes accepted earlier in this batch. + lookupKeys := make(map[types.StateTup]struct{}) + for _, partialEv := range s.partialEvs { + ev := &types.Event{PartialEvent: *partialEv, RoomVersion: room.Version} + for _, key := range stateLookupKeys(ev) { + lookupKeys[key] = struct{}{} + } + } + initialState, err := stateBatch.TxnLookupEntries(txn, room.CurrentState, slices.Collect(maps.Keys(lookupKeys))) + if err != nil { + return nil, fmt.Errorf("failed to lookup current auth state: %w", err) + } + for _, entry := range initialState { + eventsProvider.WillGet(entry.EventID) + } + + var depth int64 + depthBytes := txn.Get(s.r.KeyForRoomDepth(room.ID)).MustGet() + if depthBytes != nil { + depth = types.BytesToRoomDepth(depthBytes) + 1 + } + + // TODO: prev_events cannot be unlimited size, need to cap at the most + // recent(?) X event IDs + + // Get the current last (dangling) room events for prev_events. This may + // be multiple events if federation is involved, due to forks in the DAG. + // State forks cannot occur for local events thanks to FoundationDB, so + // we do not need to resolve the state here. State resolution is only + // needed when receiving federated events with multiple prev_events and + // thus different states on the DAG. + + prevEventIDs := s.r.events.TxnLookupCurrentRoomExtremEventIDs(txn, room.ID) + + allowedEvs := make([]*types.Event, 0, len(s.partialEvs)) + rejectedEvs := make([]RejectedEvent, 0) + + keyID, key := s.r.config.MustGetActiveSigningKey() + + // State after the events accepted so far, which is the state before the next + runningState := room.CurrentState + + for _, partialEv := range s.partialEvs { + ev := newLocalEvent(partialEv, room.Version, depth, prevEventIDs, s.now) + + beforeState, err := prepared.txnLookupAuthState(txn, runningState, ev) + if err != nil { + return nil, err + } + if ev.Type == event.StateMember { + var senderMember *types.Event + if senderMemberID, found := beforeState[types.MemberStateTup(ev.Sender)]; found { + if senderMember, err = eventsProvider.GetRequired(senderMemberID); err != nil { + return nil, err + } + } + if err := s.checkBabbleProfileUpdateValid(ev, senderMember); err != nil { + rejectedEvs = append(rejectedEvs, RejectedEvent{ev, err}) + continue + } + } + var prevStateEventID id.EventID + if ev.StateKey != nil { + stateTup := ev.StateTup() + prevStateEventID = beforeState[stateTup] + // Membership events are not deduped: make_join and friends build templates from + // them, and repeated leaves or kicks are authorized afresh. + if prevStateEventID != "" && partialEv.Type != event.StateMember { + currentEv := eventsProvider.MustGet(prevStateEventID) + if currentEv != nil && util.CompareSignedJSON(partialEv.Content, currentEv.Content) { + zerolog.Ctx(ctx).Warn(). + Stringer("event_id", currentEv.ID). + Stringer("type", currentEv.Type). + Str("state_key", stateTup.StateKey). + Msg("Received duplicate state event, returning current") + // Flag a copy as dupe (so we don't store) and return as the event, the current + // event may be earlier in this batch and still needs storing. + duplicateEv := *currentEv + duplicateEv.IsDuplicate = true + allowedEvs = append(allowedEvs, &duplicateEv) + continue + } + } + } + + ev.AuthEventIDs = events.AuthEventIDsFor(ev, beforeState) + if err := util.HashAndSignEvent(ev, s.r.config.ServerName, keyID, key); err != nil { + rejectedEvs = append(rejectedEvs, RejectedEvent{ev, err}) + continue + } else if err := s.r.txnCheckEventBeforeStore(txn, room.ID, ev); err != nil { + rejectedEvs = append(rejectedEvs, RejectedEvent{ev, err}) + continue + } + + if authErr, err := events.NewTxnAuthEventsProvider(ctx, eventsProvider, beforeState).IsEventAllowed(ev); err != nil { + return nil, err + } else if authErr != nil { + zerolog.Ctx(ctx).Err(authErr). + Stringer("event_id", ev.ID). + Any("event", ev). + Msg("Failed to auth event against current state") + rejectedEvs = append(rejectedEvs, RejectedEvent{ev, authErr}) + continue + } + + // The graph finalizes its auth header, and later events of the batch read it as auth state + authGraph.Add(ev) + eventsProvider.Add(ev) + if err := prepared.txnApplyEvent(txn, runningState, ev); errors.Is(err, state.ErrEntryTooLarge) { + rejectedEvs = append(rejectedEvs, RejectedEvent{ev, err}) + continue + } else if err != nil { + return nil, err + } else if err := s.r.txnPreProcessEventUnsigned(txn, eventsProvider, ev, prevStateEventID); err != nil { + return nil, err + } + + // Event is allowed, use as next prev and bump depths + prevEventIDs = []id.EventID{ev.ID} + depth += 1 + runningState = ev.AfterState + allowedEvs = append(allowedEvs, ev) + + zerolog.Ctx(ctx).Debug(). + Stringer("event_id", ev.ID). + Stringer("type", ev.Type). + Msg("Event authorized for storage") + } + + prepared.evs, prepared.rejected = allowedEvs, rejectedEvs + prepared.setLocalState(room.CurrentState) + return prepared, nil +} + +func (s *localEventSender) checkBabbleProfileUpdateValid(ev, senderMember *types.Event) error { + if !ev.IsBabbleProfileUpdate() || senderMember != nil && senderMember.Membership() == event.MembershipJoin { + return nil + } + return ErrProfileUpdateNotJoined +} + +func newLocalEvent( + partialEv *types.PartialEvent, + roomVersion string, + depth int64, + prevEventIDs []id.EventID, + now time.Time, +) *types.Event { + partial := *partialEv + if !util.RoomVersionHas(roomVersion, gomatrixserverlib.IRoomVersion.DomainlessRoomIDs) || partial.Type != event.StateCreate || partial.Timestamp == 0 { + partial.Timestamp = now.UnixMilli() + } + partial.Unsigned = maps.Clone(partialEv.Unsigned) + return &types.Event{ + PartialEvent: partial, + Local: true, + Depth: depth, + RoomVersion: roomVersion, + PrevEventIDs: prevEventIDs, + } +} + +func (p *preparedEvents) setLocalState(current types.StateHash) { + steps := stateSteps{current: current} + p.replacedExtremities = make(map[id.EventID][]id.EventID, len(p.evs)) + for _, ev := range p.evs { + if !ev.IsDuplicate { + steps.advance(ev.ID, ev.AfterState) + p.replacedExtremities[ev.ID] = ev.PrevEventIDs + } + } + p.stateSteps = steps.steps +} diff --git a/internal/databases/rooms/eventsendutil.go b/internal/databases/rooms/eventsendutil.go deleted file mode 100644 index 820d2c7..0000000 --- a/internal/databases/rooms/eventsendutil.go +++ /dev/null @@ -1,297 +0,0 @@ -// The send events transactions are the engine of Babbleserv, this contains the -// logic to authorize and ingest events from local homeserver users and events -// coming from other homeservers via federation. - -package rooms - -import ( - "errors" - "fmt" - - "github.com/apple/foundationdb/bindings/go/src/fdb" - "github.com/matrix-org/gomatrixserverlib" - "github.com/matrix-org/gomatrixserverlib/spec" - "github.com/rs/zerolog" - "github.com/tidwall/gjson" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "github.com/beeper/babbleserv/internal/databases/rooms/events" - "github.com/beeper/babbleserv/internal/notifier" - "github.com/beeper/babbleserv/internal/types" - "github.com/beeper/babbleserv/internal/util" -) - -type SendEventsResult struct { - transactionDuplicate bool - versionFut fdb.FutureKey - change notifier.Change - - Allowed []*types.Event - Rejected []RejectedEvent -} - -type RejectedEvent struct { - Event *types.Event - Error error -} - -func newSendEventsResults( - versionFut fdb.FutureKey, - room *types.Room, - allowedEvs []*types.Event, - rejectedEvs []RejectedEvent, - changedUsers map[id.UserID]struct{}, - changedServers map[string]struct{}, -) *SendEventsResult { - changedUserIDs := make([]id.UserID, 0, len(changedUsers)) - for uid := range changedUsers { - changedUserIDs = append(changedUserIDs, uid) - } - changedServerNames := make([]string, 0, len(changedServers)) - for serverName := range changedServers { - changedServerNames = append(changedServerNames, serverName) - } - - var change notifier.Change - - if len(allowedEvs) > 0 { - change = notifier.Change{ - RoomIDs: []id.RoomID{room.ID}, - UserIDs: changedUserIDs, - Servers: changedServerNames, - // Note: only pass the last event ID here to minimize notifier pubsub traffic - EventIDs: []id.EventID{allowedEvs[len(allowedEvs)-1].ID}, - } - } - - return &SendEventsResult{ - versionFut: versionFut, - change: change, - - Allowed: allowedEvs, - Rejected: rejectedEvs, - } -} - -func getUserIDList(evs []*types.PartialEvent) []id.UserID { - userIDMap := make(map[id.UserID]struct{}, len(evs)) - for _, ev := range evs { - userIDMap[ev.Sender] = struct{}{} - if ev.Type == event.StateMember { - userIDMap[id.UserID(*ev.StateKey)] = struct{}{} - } - } - userIDs := make([]id.UserID, 0, len(userIDMap)) - for userID := range userIDMap { - userIDs = append(userIDs, userID) - } - return userIDs -} - -func (r *RoomsDatabase) handleSendEventsResults(res *SendEventsResult, log zerolog.Logger) (*SendEventsResult, error) { - if res.transactionDuplicate { - return res, nil - } - r.notifier.SendChange(res.change) - - rlog := log.Info(). - Object("change", res.change). - Int("events_allowed", len(res.Allowed)). - Int("events_rejected", len(res.Rejected)) - if len(res.Allowed) > 0 { - rlog = rlog.Any("versionstamp", types.DecodeRawVersionstamp(res.versionFut.MustGet())) - } - rlog.Msg("Sent events") - - return res, nil -} - -func (r *RoomsDatabase) txnGetOrCreateRoomForEvents( - txn fdb.ReadTransaction, - roomID id.RoomID, - evs []*types.PartialEvent, -) (*types.Room, error) { - if roomBytes := txn.Get(r.KeyForRoom(roomID)).MustGet(); roomBytes != nil { - return types.MustNewRoomFromBytes(roomBytes, roomID), nil - } - - // If roomBytes is nil we must be creating the room, which means the first input event - // *must* be the create event. - if evs[0].Type != event.StateCreate { - return nil, fmt.Errorf("%w: %s", types.ErrRoomNotFound, roomID) - } - - room := types.Room{ID: roomID} - - room.Version = gjson.GetBytes(evs[0].Content, "room_version").String() - room.Type = gjson.GetBytes(evs[0].Content, "type").String() - - // https://spec.matrix.org/v1.14/client-server-api/#mroomcreate - // Whether users on other servers can join this room. Defaults to true if key does not exist. - res := gjson.GetBytes(evs[0].Content, "m\\.federate") - var canFederate bool - if res.Exists() { - canFederate = res.Bool() - } else { - canFederate = true - } - room.Federated = canFederate - - return &room, nil -} - -// Run final internal checks on an event before we accept it for storage, this -// is where we prevent duplicate annotations currently. -func (r *RoomsDatabase) txnCheckEventBeforeStore( - txn fdb.ReadTransaction, - roomID id.RoomID, - ev *types.Event, -) error { - // Quick sanity checks - should never happen! - if ev.RoomID != roomID { - panic("event room ID does not match input room ID") - } else if ev.RoomVersion == "" { - panic("event room version is missing") - } - - relEvID, relType := ev.RelatesTo() - if relType == event.RelAnnotation { - existng := txn.Get(r.events.KeyForRoomReaction(ev.RoomID, relEvID, ev.Sender, ev.ReactionKey())).MustGet() - if existng != nil { - return errors.New("duplicate reaction for this event/user") - } - } - - return nil -} - -// Gets the room state at one or more event IDs. If multiple event IDs are provided then state -// resolution is applied to merge the combined states. -func (r *RoomsDatabase) txnResolveStateForEvents( - txn fdb.ReadTransaction, - eventMap map[id.EventID]struct{}, - roomVersion string, - eventsProvider *events.TxnEventsProvider, -) (types.StateMap, error) { - // Gather all the state events we have - allStateEvents := make([]*types.Event, 0, len(eventMap)) - for evID := range eventMap { - allStateEvents = append(allStateEvents, eventsProvider.MustGet(evID)) - } - - // Now get the auth chain for those events - allAuthEvents, err := r.events.TxnGetAuthChainForEvents(txn, allStateEvents, eventsProvider) - if err != nil { - return nil, err - } - - evMap := util.MergeEventsMap(allStateEvents, allAuthEvents) - resolvedPDUs, err := gomatrixserverlib.ResolveConflicts( - gomatrixserverlib.RoomVersion(roomVersion), - util.EventsToPDUs(allStateEvents), - util.EventsToPDUs(allAuthEvents), - func(_ spec.RoomID, senderID spec.SenderID) (*spec.UserID, error) { - return senderID.ToUserID(), nil - }, - func(eventID string) bool { - ev := evMap[id.EventID(eventID)] - return ev.Outlier || ev.Rejected - }, - ) - if err != nil { - return nil, err - } - - // Turn the resolved state back into our stateMap/memberMap - resolvedStateMap := make(types.StateMap, len(resolvedPDUs)) - for _, pdu := range resolvedPDUs { - pduEv := pdu.(types.EventPDU).Event() - resolvedStateMap[types.StateTup{ - Type: pduEv.Type, - StateKey: *pduEv.StateKey, - }] = pduEv.ID - } - - return resolvedStateMap, nil -} - -// Pre-process the unsigned map for a given event before it gets stored, notably this just removes -// everything unexpected and injects prev_content for state events. -func (r *RoomsDatabase) txnPreProcessEventUnsigned( - txn fdb.ReadTransaction, - eventsProvider *events.TxnEventsProvider, - ev *types.Event, -) { - for k := range ev.Unsigned { - switch k { - case "invite_room_state", "knock_room_state": - // OK! - default: - delete(ev.Unsigned, k) - } - } - - if ev.StateKey == nil { - return - } - - stateTup := types.StateTup{ - Type: ev.Type, - StateKey: *ev.StateKey, - } - currentEv := r.events.TxnGetCurrentRoomStateEvent(txn, ev.RoomID, stateTup, eventsProvider) - if currentEv == nil { - // If we're joining now and the joiner is local - lookup any current membership for the - // room which may point to an outlier event to pull prev_content from. - userID := id.UserID(*ev.StateKey) - if ev.Membership() == event.MembershipJoin && userID.Homeserver() == r.config.ServerName { - currentMembership := r.users.TxnGetMembership(txn, userID, ev.RoomID) - if currentMembership != nil && currentMembership.Membership == event.MembershipInvite { - currentEv = r.events.TxnGetEvent(txn, currentMembership.EventID) - } - } - } - if currentEv == nil { - return - } - - ev.SetUnsigned("prev_content", currentEv.Content) - // Note: this is not referenced anywhere in the spec but synapse does it and complement tests it - ev.SetUnsigned("prev_sender", currentEv.Sender) - - // Internal cache of the prev state event object, used when updating room below - ev.PrevStateEvent = currentEv -} - -func (r *RoomsDatabase) updateRoomForStateEvent(room *types.Room, ev *types.Event) bool { - if ev.StateKey == nil || *ev.StateKey != "" { - return false - } - var changed bool - switch ev.Type.Type { - case event.StateRoomName.Type: - room.Name = gjson.GetBytes(ev.Content, "name").String() - changed = true - case event.StateTopic.Type: - room.Topic = gjson.GetBytes(ev.Content, "topic").String() - changed = true - case event.StateRoomAvatar.Type: - room.AvatarURL = gjson.GetBytes(ev.Content, "url").String() - changed = true - case event.StateCanonicalAlias.Type: - room.CanonicalAlias = gjson.GetBytes(ev.Content, "alias").String() - changed = true - case event.StateJoinRules.Type: - room.JoinRule = gjson.GetBytes(ev.Content, "join_rule").String() - changed = true - case event.StateHistoryVisibility.Type: - room.HistoryVisibility = gjson.GetBytes(ev.Content, "history_visibility").String() - changed = true - case event.StateGuestAccess.Type: - room.GuestAccess = gjson.GetBytes(ev.Content, "guest_access").String() - changed = true - } - - return changed -} diff --git a/internal/databases/rooms/eventsendutil/reads.go b/internal/databases/rooms/eventsendutil/reads.go new file mode 100644 index 0000000..52101f4 --- /dev/null +++ b/internal/databases/rooms/eventsendutil/reads.go @@ -0,0 +1,129 @@ +package eventsendutil + +import ( + "context" + "errors" + "time" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/databases/rooms/events" +) + +// An artifact job reads in a new transaction once its current one is this old (ahead of fdb's 5s limit) +const artifactJobTxnAge = 3 * time.Second + +// Candidate events an artifact job resolving contexts reads per transaction +const artifactJobEventChunk = 1000 + +// resolutionReads are where resolving contexts reads: one transaction of a preparation, or the +// transactions of an artifact job. Reads of events and of the auth graph run in Do, which a job runs +// again in a new transaction when they fail with a retryable error. A job's state batch runs its +// own reads that way, whatever transaction it is given. +type resolutionReads interface { + Do(reads func() error) error + txn() fdb.ReadTransaction + Events() *events.TxnEventsProvider + graph() *events.AuthGraph +} + +type preparationReads struct { + readTxn fdb.ReadTransaction + eventsProvider *events.TxnEventsProvider + authGraph *events.AuthGraph +} + +func (p preparationReads) Do(reads func() error) error { return reads() } +func (p preparationReads) txn() fdb.ReadTransaction { return p.readTxn } +func (p preparationReads) Events() *events.TxnEventsProvider { return p.eventsProvider } +func (p preparationReads) graph() *events.AuthGraph { return p.authGraph } + +// ArtifactReads gives an artifact job's reads a transaction younger than artifactJobTxnAge, moving +// the events read so far and the preparation's auth graph to a new one as the current one ages. +type ArtifactReads struct { + ctx context.Context + db fdb.Database + eventsDir *events.EventsDirectory + tr fdb.Transaction + readTxn fdb.ReadTransaction + started time.Time + provider *events.TxnEventsProvider + authGraph *events.AuthGraph + transactions int +} + +// NewArtifactReads carries successfully read events and finalized auth headers into renewable +// transactions. A nil graph starts a new job graph; a nil provider starts without carried events. +func NewArtifactReads( + ctx context.Context, + db fdb.Database, + eventsDir *events.EventsDirectory, + roomID id.RoomID, + provider *events.TxnEventsProvider, + graph *events.AuthGraph, +) (*ArtifactReads, error) { + a := &ArtifactReads{ctx: ctx, db: db, eventsDir: eventsDir, authGraph: graph} + if a.authGraph == nil { + a.authGraph = eventsDir.NewJobAuthGraph(roomID, a.Do) + } + return a, a.start(provider) +} + +// start begins a new transaction with the events carried read successfully. +func (a *ArtifactReads) start(carried *events.TxnEventsProvider) error { + if err := a.ctx.Err(); err != nil { + return err + } + tr, err := a.db.CreateTransaction() + if err != nil { + return err + } + a.tr, a.readTxn, a.started = tr, tr.Snapshot(), time.Now() + a.transactions++ + a.provider = a.eventsDir.NewTxnEventsProvider(a.ctx, a.readTxn) + if carried != nil { + a.provider.WithProviderEvents(carried) + } + a.authGraph.Renew(a.readTxn, a.Do) + return nil +} + +// Do runs a batch of reads in renewable transactions until complete or the context is canceled. +func (a *ArtifactReads) Do(reads func() error) error { + if time.Since(a.started) >= artifactJobTxnAge { + // Event reads started in the current transaction finish there, while it is young enough + a.provider.Settle() + if err := a.start(a.provider); err != nil { + return err + } + } + for { + err := reads() + var fdbErr fdb.Error + if err == nil || !errors.As(err, &fdbErr) || a.tr.OnError(fdbErr).Get() != nil { + return err + } + zerolog.Ctx(a.ctx).Warn().Err(err).Msg("Artifact job read failed, reading again in a new transaction") + if err := a.start(a.provider); err != nil { + return err + } + } +} + +func (a *ArtifactReads) Read(batch func(fdb.ReadTransaction) error) error { + return a.Do(func() error { return batch(a.readTxn) }) +} + +func (a *ArtifactReads) txn() fdb.ReadTransaction { return a.readTxn } +func (a *ArtifactReads) Events() *events.TxnEventsProvider { return a.provider } +func (a *ArtifactReads) graph() *events.AuthGraph { return a.authGraph } + +func (a *ArtifactReads) Transactions() int { return a.transactions } + +func (a *ArtifactReads) NewResolver(options ResolverOptions) *Resolver { + resolver := newResolver(a, options) + resolver.eventChunk = artifactJobEventChunk + return resolver +} diff --git a/internal/databases/rooms/eventsendutil/resolver.go b/internal/databases/rooms/eventsendutil/resolver.go new file mode 100644 index 0000000..7dd5908 --- /dev/null +++ b/internal/databases/rooms/eventsendutil/resolver.go @@ -0,0 +1,448 @@ +// Package eventsendutil resolves room state using fixed or renewable transactions. +package eventsendutil + +import ( + "bytes" + "fmt" + "maps" + "slices" + "strings" + + "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/matrix-org/gomatrixserverlib" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/databases/rooms/events" + "github.com/beeper/babbleserv/internal/databases/rooms/state" + "github.com/beeper/babbleserv/internal/stateres" + "github.com/beeper/babbleserv/internal/types" + "github.com/beeper/babbleserv/internal/util" +) + +// contextSetKey identifies a resolution, which depends only on the distinct contexts it resolves. +func contextSetKey(contexts []types.StateHash) string { + sorted := slices.Clone(contexts) + slices.SortFunc(sorted, func(a, b types.StateHash) int { + return bytes.Compare(a[:], b[:]) + }) + sorted = slices.Compact(sorted) + var key strings.Builder + for _, ctx := range sorted { + key.Write(ctx[:]) + } + return key.String() +} + +type resolution struct { + state types.StateHash + err error +} + +// Resolver resolves the state after several events, either prev events or pending +// extremities, for one attempt of preparing a federated send. Batch events carry the contexts +// assigned so far in the attempt, stored events their own, so both are read from the event. An +// artifact job resolves with one of its own. +type Resolver struct { + reads resolutionReads + stateBatch *state.Batch + roomID id.RoomID + roomVersion string + // A resolution with more candidate events, or an input of more state tuples, is left to an + // artifact job, unless zero + maxCandidates, maxStateTuples int + // Candidate events read per transaction, all of them in one when zero + eventChunk int + // State events of the inputs and candidate events of the last resolution run + stateEvents, candidates int + resolved map[string]resolution + // The state after prev events without state here, as the sending server gave it + boundaries map[id.EventID]types.StateHash +} + +// ResolverOptions sets the state batch and limits for a resolution. Zero limits are unbounded. +type ResolverOptions struct { + StateBatch *state.Batch + RoomID id.RoomID + RoomVersion string + MaxCandidates, MaxStateTuples int +} + +func NewResolver(txn fdb.ReadTransaction, provider *events.TxnEventsProvider, graph *events.AuthGraph, options ResolverOptions) *Resolver { + return newResolver(preparationReads{txn, provider, graph}, options) +} + +func newResolver(reads resolutionReads, options ResolverOptions) *Resolver { + return &Resolver{ + reads: reads, + stateBatch: options.StateBatch, + roomID: options.RoomID, + roomVersion: options.RoomVersion, + maxCandidates: options.MaxCandidates, + maxStateTuples: options.MaxStateTuples, + resolved: make(map[string]resolution), + } +} + +func (cr *Resolver) SetBoundaries(boundaries map[id.EventID]types.StateHash) { + cr.boundaries = boundaries +} + +func (cr *Resolver) Stats() (stateEvents, candidates int) { + return cr.stateEvents, cr.candidates +} + +// ResolutionRequest describes a resolution that exceeded an inline budget. +type ResolutionRequest struct { + Contexts []types.StateHash + Candidates, StateTuples int +} + +func (r *ResolutionRequest) Key() string { return contextSetKey(r.Contexts) } + +func (r *ResolutionRequest) String() string { + if r.StateTuples > 0 { + return fmt.Sprintf("resolution of contexts %x of up to %d state tuples", r.Contexts, r.StateTuples) + } + return fmt.Sprintf("resolution of contexts %x with at least %d candidate events", r.Contexts, r.Candidates) +} + +func (cr *Resolver) getEvent(eventID id.EventID) (ev *types.Event, err error) { + err = cr.reads.Do(func() error { + ev, err = cr.reads.Events().Get(eventID) + return err + }) + return ev, err +} + +// afterState is zero for an event whose state is unknown here and was not given by the sending +// server. An event's own state is preferred over what the sending server gave. +func (cr *Resolver) afterState(eventID id.EventID) (types.StateHash, error) { + ev, err := cr.getEvent(eventID) + if err != nil { + return types.StateHash{}, err + } else if ev.HasStateIn(cr.roomID) { + return ev.AfterState, nil + } + return cr.boundaries[eventID], nil +} + +// PartitionPrevs splits prev events into those with a known state after them and the others. +func (cr *Resolver) PartitionPrevs(prevs []id.EventID) (known, unknown []id.EventID, err error) { + for _, prevID := range prevs { + if afterState, err := cr.afterState(prevID); err != nil { + return nil, nil, err + } else if afterState.IsZero() { + unknown = append(unknown, prevID) + } else { + known = append(known, prevID) + } + } + return known, unknown, nil +} + +// StateBefore is the state before an event with the given prev events, each of which must have a +// known state after it. +func (cr *Resolver) StateBefore(prevs []id.EventID) (types.StateHash, *ResolutionRequest, error) { + switch len(prevs) { + case 0: + return state.EmptyContext, nil, nil + case 1: + after, err := cr.afterState(prevs[0]) + return after, nil, err + default: + return cr.Resolve(prevs) + } +} + +func (cr *Resolver) Resolve(eventIDs []id.EventID) (types.StateHash, *ResolutionRequest, error) { + contexts, err := cr.afterStates(eventIDs) + if err != nil { + return types.StateHash{}, nil, err + } + if len(contexts) == 1 { + return contexts[0], nil, nil + } + + key := contextSetKey(contexts) + res, found := cr.resolved[key] + if !found { + var request *ResolutionRequest + res.state, request, res.err = cr.resolveContexts(contexts) + if request != nil { + return types.StateHash{}, request, nil + } + cr.resolved[key] = res + } + if res.err != nil { + return types.StateHash{}, nil, fmt.Errorf("failed to resolve state after %v: %w", eventIDs, res.err) + } + return res.state, nil, nil +} + +// afterStates returns the distinct after-contexts of the events. +func (cr *Resolver) afterStates(eventIDs []id.EventID) ([]types.StateHash, error) { + cr.reads.Events().WillGet(eventIDs...) + contexts := make([]types.StateHash, 0, len(eventIDs)) + for _, eventID := range eventIDs { + afterState, err := cr.afterState(eventID) + if err != nil { + return nil, err + } else if afterState.IsZero() { + return nil, fmt.Errorf("no state after %s", eventID) + } + if !slices.Contains(contexts, afterState) { + contexts = append(contexts, afterState) + } + } + return contexts, nil +} + +// resolveContexts takes the result an artifact job stored for resolving the contexts, or resolves +// them here, see ResolveContexts. Distinct contexts always conflict, so a room version before state +// resolution v2 fails before reading anything. +func (cr *Resolver) resolveContexts(contexts []types.StateHash) (types.StateHash, *ResolutionRequest, error) { + if err := stateres.CheckAlgorithm(cr.roomVersion); err != nil { + return types.StateHash{}, nil, err + } + if result, found, err := cr.stateBatch.TxnResolved(cr.reads.txn(), contexts)(); err != nil { + return types.StateHash{}, nil, err + } else if found { + return result, nil, nil + } + return cr.ResolveContexts(contexts) +} + +// ResolveContexts resolves the contexts from their conflicts and, when they have any, the auth +// difference of their states, see authDifference. +// It computes directly; the caller stages and stores the result. +func (cr *Resolver) ResolveContexts(contexts []types.StateHash) (types.StateHash, *ResolutionRequest, error) { + conflicts, err := cr.stateBatch.TxnConflicts(cr.reads.txn(), contexts) + if err != nil || len(conflicts) == 0 { + return contexts[0], nil, err + } + difference, request, err := cr.authDifference(contexts) + if err != nil || request != nil { + return types.StateHash{}, request, err + } + return cr.resolveConflicts(contexts, conflicts, difference) +} + +// authDifference returns the events in the auth chains of some of the contexts' states but not all, +// computed from the chain cover over every state event of the contexts, see +// events.AuthGraph.AuthChainDifference. A resolution whose largest input holds more state tuples than +// the resolver reads inline requests a resolution job, before any is read but the root pages of +// the inputs' maps. +func (cr *Resolver) authDifference(contexts []types.StateHash) ([]id.EventID, *ResolutionRequest, error) { + if request, err := cr.checkStateTuples(contexts, func(ctx types.StateHash) (int, error) { + return cr.stateBatch.TxnCount(cr.reads.txn(), ctx) + }); err != nil || request != nil { + return nil, request, err + } + states, err := cr.stateBatch.TxnIterateAll(cr.reads.txn(), contexts...) + if err != nil { + return nil, nil, err + } + stateSets := make([][]id.EventID, len(states)) + cr.stateEvents = 0 + for i, entries := range states { + stateSets[i] = make([]id.EventID, 0, len(entries)) + for _, entry := range entries { + stateSets[i] = append(stateSets[i], entry.EventID) + } + cr.stateEvents += len(entries) + } + difference, err := cr.reads.graph().AuthChainDifference(stateSets) + return difference, nil, err +} + +// checkStateTuples requests a job when an input holds more tuples than the resolver reads inline. +func (cr *Resolver) checkStateTuples(contexts []types.StateHash, count func(types.StateHash) (int, error)) (*ResolutionRequest, error) { + if cr.maxStateTuples == 0 { + return nil, nil + } + for _, ctx := range contexts { + if tuples, err := count(ctx); err != nil { + return nil, err + } else if tuples > cr.maxStateTuples { + return &ResolutionRequest{Contexts: contexts, StateTuples: tuples}, nil + } + } + return nil, nil +} + +// resolveConflicts resolves the contexts from what differs between them: their conflicts, their auth +// difference and in room version 12 the conflicted subgraph, reading the state they share from the +// first context on demand. A resolution exceeding the candidate budget requests a job as soon as +// the candidates are known, before reading any. +func (cr *Resolver) resolveConflicts( + contexts []types.StateHash, + conflicts map[types.StateTup][]id.EventID, + difference []id.EventID, +) (types.StateHash, *ResolutionRequest, error) { + if len(conflicts) == 0 { + return contexts[0], nil, nil + } + var conflictedIDs []id.EventID + for _, ids := range conflicts { + for _, eventID := range ids { + if eventID != "" { + conflictedIDs = append(conflictedIDs, eventID) + } + } + } + candidates, request := cr.limitCandidates(contexts, conflictedIDs, difference) + if request != nil { + return types.StateHash{}, request, nil + } + var err error + stateResV2_1 := util.RoomVersionHas(cr.roomVersion, func(impl gomatrixserverlib.IRoomVersion) bool { + return impl.StateResAlgorithm() == gomatrixserverlib.StateResV2_1 + }) + var subgraph []id.EventID + if stateResV2_1 { + if subgraph, err = cr.reads.graph().ConflictedSubgraph(conflictedIDs); err != nil { + return types.StateHash{}, nil, err + } + if candidates, request = cr.limitCandidates(contexts, candidates, subgraph); request != nil { + return types.StateHash{}, request, nil + } + } + cr.candidates = len(candidates) + keys, authKeys, err := cr.readCandidates(candidates) + if err != nil { + return types.StateHash{}, nil, err + } + // Prefetched together, so the resolution rarely looks up a tuple of its own + baseEntries, err := cr.stateBatch.TxnLookupEntries(cr.reads.txn(), contexts[0], slices.Collect(maps.Keys(keys))) + if err != nil { + return types.StateHash{}, nil, err + } + base := baseEntries.EventIDs() + var authBase []id.EventID + for tup := range keys { + // Cache negative lookups too. + if _, ok := base[tup]; !ok { + base[tup] = "" + } + if _, conflicted := conflicts[tup]; !conflicted && authKeys[tup] && base[tup] != "" && !stateResV2_1 { + authBase = append(authBase, base[tup]) + } + } + if err := cr.prefetchEvents(authBase); err != nil { + return types.StateHash{}, nil, err + } + delta, err := stateres.ResolveSparse(cr.roomVersion, stateres.SparseInput{ + Conflicts: conflicts, AuthDifference: difference, ConflictedSubgraph: subgraph, Base: base, + Lookup: func(keys []types.StateTup) (types.StateMap, error) { + entries, err := cr.stateBatch.TxnLookupEntries(cr.reads.txn(), contexts[0], keys) + return entries.EventIDs(), err + }, + }, cr.getEvent) + if err != nil { + return types.StateHash{}, nil, err + } + entries, err := cr.withMemberships(delta) + if err != nil { + return types.StateHash{}, nil, err + } + result, err := cr.stateBatch.TxnApply(cr.reads.txn(), contexts[0], entries) + return result, nil, err +} + +// withMemberships gives each member a resolution sets the membership of its event, which the +// resolution has read already. +func (cr *Resolver) withMemberships(delta types.StateMap) (types.StateEntries, error) { + entries := make(types.StateEntries, len(delta)) + for tup, eventID := range delta { + if eventID == "" || tup.Type != event.StateMember { + entries[tup] = types.StateEntry{EventID: eventID} + continue + } + ev, err := cr.getEvent(eventID) + if err != nil { + return nil, err + } else if ev == nil { + return nil, fmt.Errorf("%w: resolved member event %s", types.ErrEventNotFound, eventID) + } + entries[tup] = ev.StateEntry() + } + return entries, nil +} + +// limitCandidates returns the distinct candidates, or requests a job when they exceed the inline limit. +func (cr *Resolver) limitCandidates(contexts []types.StateHash, lists ...[]id.EventID) ([]id.EventID, *ResolutionRequest) { + candidates := slices.Concat(lists...) + slices.Sort(candidates) + candidates = slices.Compact(candidates) + if cr.maxCandidates > 0 && len(candidates) > cr.maxCandidates { + return nil, &ResolutionRequest{Contexts: contexts, Candidates: len(candidates)} + } + return candidates, nil +} + +// prefetchEvents starts reading events a resolution reads later. A job reads them now, eventChunk at +// a time, so that no transaction holds more reads than that and the events carry over. +func (cr *Resolver) prefetchEvents(eventIDs []id.EventID) error { + if cr.eventChunk == 0 { + cr.reads.Events().WillGet(eventIDs...) + return nil + } + for part := range slices.Chunk(eventIDs, cr.eventChunk) { + if err := cr.reads.Do(func() error { + eventsProvider := cr.reads.Events() + eventsProvider.WillGet(part...) + for _, eventID := range part { + if _, err := eventsProvider.Get(eventID); err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + } + return nil +} + +// readCandidates reads the candidate events of a resolution, eventChunk at a time, then prefetches +// their auth events. Returns the tuples the candidates are authorized against or replace, and those +// they are authorized against. +func (cr *Resolver) readCandidates(candidates []id.EventID) (keys map[types.StateTup]struct{}, authKeys map[types.StateTup]bool, err error) { + keys = make(map[types.StateTup]struct{}) + authKeys = make(map[types.StateTup]bool) + authEvents := make(map[id.EventID]struct{}) + chunk := cr.eventChunk + if chunk == 0 { + chunk = max(len(candidates), 1) + } + for part := range slices.Chunk(candidates, chunk) { + if err := cr.reads.Do(func() error { + eventsProvider := cr.reads.Events() + eventsProvider.WillGet(part...) + for _, eventID := range part { + ev, err := eventsProvider.Get(eventID) + if err != nil { + return err + } + if ev == nil { + continue + } + for _, authID := range ev.AuthEventIDs { + authEvents[authID] = struct{}{} + } + for _, tup := range events.AuthStateTupsForEvent(ev) { + authKeys[tup] = true + keys[tup] = struct{}{} + } + if ev.StateKey != nil { + keys[ev.StateTup()] = struct{}{} + } + } + return nil + }); err != nil { + return nil, nil, err + } + } + return keys, authKeys, cr.prefetchEvents(slices.Collect(maps.Keys(authEvents))) +} diff --git a/internal/databases/rooms/eventsendutil/resolver_test.go b/internal/databases/rooms/eventsendutil/resolver_test.go new file mode 100644 index 0000000..5d3165e --- /dev/null +++ b/internal/databases/rooms/eventsendutil/resolver_test.go @@ -0,0 +1,86 @@ +package eventsendutil + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/stateres" + "github.com/beeper/babbleserv/internal/types" +) + +func TestResolutionCandidatesAreLimitedOnceEach(t *testing.T) { + contexts := []types.StateHash{{1}, {2}} + cr := NewResolver(nil, nil, nil, ResolverOptions{MaxCandidates: 3}) + conflicted := []id.EventID{"$a", "$b", "$a", "$b"} + candidates, request := cr.limitCandidates(contexts, conflicted, []id.EventID{"$c"}) + require.Nil(t, request) + assert.Equal(t, []id.EventID{"$a", "$b", "$c"}, candidates) + candidates, request = cr.limitCandidates(contexts, candidates, conflicted) + require.Nil(t, request) + assert.Len(t, candidates, 3, "a v12 subgraph holds the conflicted events again") + candidates, request = cr.limitCandidates(contexts, conflicted, []id.EventID{"$c", "$d"}) + require.NotNil(t, request) + assert.Nil(t, candidates) + assert.Equal(t, contexts, request.Contexts) + assert.Equal(t, 4, request.Candidates) + cr.maxCandidates = 0 + candidates, request = cr.limitCandidates(contexts, conflicted, []id.EventID{"$c", "$d"}) + require.Nil(t, request) + assert.Len(t, candidates, 4) +} + +func TestResolutionOfLargeStatesIsLeftToAJob(t *testing.T) { + contexts := []types.StateHash{{1}, {2}} + counts := map[types.StateHash]int{{1}: 10, {2}: 11} + count := func(ctx types.StateHash) (int, error) { return counts[ctx], nil } + cr := NewResolver(nil, nil, nil, ResolverOptions{MaxStateTuples: 11}) + request, err := cr.checkStateTuples(contexts, count) + require.NoError(t, err) + assert.Nil(t, request) + cr.maxStateTuples = 10 + request, err = cr.checkStateTuples(contexts, count) + require.NoError(t, err) + require.NotNil(t, request) + assert.Equal(t, contexts, request.Contexts) + assert.Equal(t, 11, request.StateTuples) + assert.Equal(t, (&ResolutionRequest{Contexts: contexts, Candidates: 3}).Key(), request.Key(), "the job's stored result serves either limit") + cr.maxStateTuples = 0 + request, err = cr.checkStateTuples(contexts, count) + require.NoError(t, err) + assert.Nil(t, request, "a job reads states of any size") + failed := errors.New("count failed") + cr.maxStateTuples = 10 + request, err = cr.checkStateTuples(contexts, func(types.StateHash) (int, error) { return 0, failed }) + assert.ErrorIs(t, err, failed) + assert.Nil(t, request) +} + +func TestResolvingWithoutStateResV2FailsBeforeReading(t *testing.T) { + contexts := []types.StateHash{{1}, {2}} + cr := NewResolver(nil, nil, nil, ResolverOptions{RoomVersion: "1", MaxCandidates: 1}) + result, request, err := cr.resolveConflicts(contexts, nil, nil) + require.NoError(t, err) + assert.Nil(t, request) + assert.Equal(t, contexts[0], result) + _, request, err = cr.resolveContexts(contexts) + assert.ErrorIs(t, err, stateres.ErrUnsupportedAlgorithm) + assert.Nil(t, request) +} + +func TestResolutionRequestsWorkBeforeReadingCandidateEvents(t *testing.T) { + contexts := []types.StateHash{{1}, {2}} + cr := NewResolver(nil, nil, nil, ResolverOptions{RoomVersion: "11", MaxCandidates: 1}) + result, request, err := cr.resolveConflicts(contexts, map[types.StateTup][]id.EventID{ + {Type: event.StateTopic}: {"$a", "$b"}, + }, nil) + require.NoError(t, err) + require.NotNil(t, request, "resolution can defer without any transaction or event provider") + assert.True(t, result.IsZero(), "an unfinished resolution has no state result") + assert.Equal(t, contexts, request.Contexts) + assert.Equal(t, 2, request.Candidates) +} diff --git a/internal/databases/rooms/rooms.go b/internal/databases/rooms/rooms.go index 6dddd54..877d9f0 100644 --- a/internal/databases/rooms/rooms.go +++ b/internal/databases/rooms/rooms.go @@ -12,6 +12,7 @@ import ( "github.com/apple/foundationdb/bindings/go/src/fdb/directory" "github.com/apple/foundationdb/bindings/go/src/fdb/subspace" "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "github.com/matrix-org/gomatrixserverlib" "github.com/rs/zerolog" "go.mau.fi/util/exsync" "maunium.net/go/mautrix/id" @@ -20,6 +21,7 @@ import ( "github.com/beeper/babbleserv/internal/databases/rooms/events" "github.com/beeper/babbleserv/internal/databases/rooms/receipts" "github.com/beeper/babbleserv/internal/databases/rooms/servers" + "github.com/beeper/babbleserv/internal/databases/rooms/state" "github.com/beeper/babbleserv/internal/databases/rooms/users" "github.com/beeper/babbleserv/internal/notifier" "github.com/beeper/babbleserv/internal/types" @@ -38,6 +40,7 @@ type RoomsDatabase struct { users *users.UsersDirectory servers *servers.ServersDirectory receipts *receipts.ReceiptsDirectory + state *state.StateDirectory // Room ID to bytes we decode to `*types.Room` items idToRoom subspace.Subspace @@ -45,9 +48,15 @@ type RoomsDatabase struct { // Room ID to depth int64 idToDepth subspace.Subspace - // Room ID to tuple.Versionstamp of the most recent receipt or event in the room + // Room ID to tuple.Versionstamp of the most recent event or receipt in the room idToVersion subspace.Subspace + // Joined members of this server by room + // + // key: (RoomID, UserID) + // value: types.MembershipTup + localMembers subspace.Subspace + // Aliases to room IDs (and owner to authz delete) // // key: RoomAlias @@ -72,6 +81,8 @@ type RoomsDatabase struct { // Per-look lock used to serialize per-room DB writes, this is an optional optimization since // FDB will enforce serialization at the DB level. roomLocks *exsync.Map[id.RoomID, *sync.Mutex] + // Serializes this process's remote joins to a room, see lockJoin + joinLocks *exsync.Map[id.RoomID, chan struct{}] } func NewRoomsDatabase( @@ -111,11 +122,14 @@ func NewRoomsDatabase( users: users.NewUsersDirectory(log, db, roomsDir), servers: servers.NewServersDirectory(log, db, roomsDir), receipts: receipts.NewReceiptsDirectory(log, db, roomsDir), + state: state.NewStateDirectory(log, db, roomsDir), idToRoom: roomsDir.Sub("id"), idToDepth: roomsDir.Sub("idd"), idToVersion: roomsDir.Sub("iev"), + localMembers: roomsDir.Sub("rlm"), + aliasToID: roomsDir.Sub("aid"), idAliases: roomsDir.Sub("ida"), publishedRooms: roomsDir.Sub("pub"), @@ -124,6 +138,7 @@ func NewRoomsDatabase( eventTransactionIDs: roomsDir.Sub("eti"), roomLocks: exsync.NewMap[id.RoomID, *sync.Mutex](), + joinLocks: exsync.NewMap[id.RoomID, chan struct{}](), } } @@ -137,6 +152,25 @@ func (r *RoomsDatabase) getTxnLogContext(ctx context.Context, name string) zerol Str("transaction", name) } +// lockRoom serializes this process's writes to a room until the returned func is called +func (r *RoomsDatabase) lockRoom(roomID id.RoomID) (unlock func()) { + lock, _ := r.roomLocks.GetOrSet(roomID, &sync.Mutex{}) + lock.Lock() + return lock.Unlock +} + +// lockJoin serializes this process's remote joins to a room until the returned func is called, or +// fails once ctx ends while it waits. +func (r *RoomsDatabase) lockJoin(ctx context.Context, roomID id.RoomID) (unlock func(), err error) { + lock, _ := r.joinLocks.GetOrSet(roomID, make(chan struct{}, 1)) + select { + case lock <- struct{}{}: + return func() { <-lock }, nil + case <-ctx.Done(): + return nil, fmt.Errorf("waiting for another join to the room: %w", ctx.Err()) + } +} + func (r *RoomsDatabase) GetTimeForVersion(ctx context.Context, version tuple.Versionstamp) (*time.Time, error) { return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (*time.Time, error) { time := util.TxnGetTimeForVersion(txn, version) @@ -144,7 +178,13 @@ func (r *RoomsDatabase) GetTimeForVersion(ctx context.Context, version tuple.Ver }) } -func (r *RoomsDatabase) GenerateRoomID(ctx context.Context) id.RoomID { +// GenerateRoomID returns the ID of the room a local create event creates. A domainless room ID is +// derived from the create event as SendLocalEvents stores it, so this fixes the create event's +// timestamp, including across retries of the send. +func (r *RoomsDatabase) GenerateRoomID(ctx context.Context, createEv *types.PartialEvent) (id.RoomID, error) { + if roomVersion := createRoomVersion(createEv); util.RoomVersionHas(roomVersion, gomatrixserverlib.IRoomVersion.DomainlessRoomIDs) { + return r.domainlessRoomID(createEv, roomVersion) + } for { rid := util.GenerateRandomStringBase32Hex(16) roomID := id.RoomID("!" + rid + ":" + r.config.ServerName) @@ -152,25 +192,33 @@ func (r *RoomsDatabase) GenerateRoomID(ctx context.Context) id.RoomID { // Extremely unlikely but check the room ID isn't taken, the chance of this is incredibly // small but nice to be sure. if existing, err := r.GetRoom(ctx, roomID); err != nil { - panic(fmt.Errorf("failed to check for existing room: %w", err)) + return "", fmt.Errorf("failed to check for existing room: %w", err) } else if existing != nil { zerolog.Ctx(ctx).Warn().Stringer("room_id", roomID).Msg("Generated duplicate roomID!") continue } - return roomID + return roomID, nil } } +func (r *RoomsDatabase) domainlessRoomID(createEv *types.PartialEvent, roomVersion string) (id.RoomID, error) { + now := time.Now() + if createEv.Timestamp == 0 { + createEv.Timestamp = now.UnixMilli() + } + // The first event of a room has no depth or prev events + create := newLocalEvent(createEv, roomVersion, 0, nil, now) + keyID, key := r.config.MustGetActiveSigningKey() + if err := util.HashAndSignEvent(create, r.config.ServerName, keyID, key); err != nil { + return "", err + } + return create.DomainlessRoomID(), nil +} + func (r *RoomsDatabase) GetRoom(ctx context.Context, roomID id.RoomID) (*types.Room, error) { return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (*types.Room, error) { - key := r.KeyForRoom(roomID) - b := txn.Get(key).MustGet() - if b == nil { - return nil, nil - } - ev := types.MustNewRoomFromBytes(b, roomID) - return ev, nil + return roomOrNil(r.txnGetRoom(txn, roomID)) }) } @@ -192,6 +240,14 @@ func (r *RoomsDatabase) KeyForRoomDepth(roomID id.RoomID) fdb.Key { return r.idToDepth.Pack(tuple.Tuple{roomID.String()}) } +func (r *RoomsDatabase) keyForLocalMember(roomID id.RoomID, userID id.UserID) fdb.Key { + return r.localMembers.Pack(tuple.Tuple{roomID.String(), userID.String()}) +} + +func (r *RoomsDatabase) isLocalUser(userID id.UserID) bool { + return userID.Homeserver() == r.config.ServerName +} + func (r *RoomsDatabase) KeyForRoomAlias(roomAlias id.RoomAlias) fdb.Key { return r.aliasToID.Pack(tuple.Tuple{roomAlias.String()}) } diff --git a/internal/databases/rooms/servers/memberships.go b/internal/databases/rooms/servers/memberships.go index 0f2f0a3..bafd15d 100644 --- a/internal/databases/rooms/servers/memberships.go +++ b/internal/databases/rooms/servers/memberships.go @@ -9,31 +9,6 @@ import ( "github.com/beeper/babbleserv/internal/types" ) -func (s *ServersDirectory) TxnGetMembership( - txn fdb.ReadTransaction, - serverName string, - roomID id.RoomID, -) *types.MembershipTup { - if b := txn.Get(s.keyForMembership(serverName, roomID)).MustGet(); b == nil { - return nil - } else { - tup := types.BytesToMembershipTup(b) - return &tup - } -} - -func (s *ServersDirectory) TxnIsServerJoinedRoom( - txn fdb.ReadTransaction, - serverName string, - roomID id.RoomID, -) bool { - mtup := s.TxnGetMembership(txn, serverName, roomID) - if mtup == nil { - return false - } - return mtup.Membership == event.MembershipJoin -} - func (s *ServersDirectory) TxnLookupServerMemberships( txn fdb.ReadTransaction, serverName string, @@ -85,31 +60,68 @@ func (s *ServersDirectory) TxnLookupServerMembershipChanges( return changes, nil } -func (s *ServersDirectory) TxnStoreServerMembership( - txn fdb.Transaction, - roomID id.RoomID, - serverName string, - mtup types.MembershipTup, - version tuple.Versionstamp, -) { - mtupBytes := types.MembershipTupToBytes(mtup) - - txn.SetVersionstampedKey(s.keyForMembershipChange(serverName, version), mtupBytes) - - membershipKey := s.keyForMembership(serverName, roomID) +// TxnStoreServerMembership sets a server's membership of the room on a join, clears it otherwise +func (s *ServersDirectory) TxnStoreServerMembership(txn fdb.Transaction, roomID id.RoomID, serverName string, mtup types.MembershipTup) { + membershipKey := s.KeyForMembership(serverName, roomID) if mtup.Membership == event.MembershipJoin { - txn.Set(membershipKey, mtupBytes) + txn.Set(membershipKey, types.MembershipTupToBytes(mtup)) } else { txn.Clear(membershipKey) } } -func (s *ServersDirectory) TxnDeleteServerMembership( +func (s *ServersDirectory) TxnStoreServerMembershipChange( txn fdb.Transaction, - roomID id.RoomID, serverName string, version tuple.Versionstamp, + mtup types.MembershipTup, ) { - txn.Clear(s.keyForMembership(serverName, roomID)) - txn.Clear(s.keyForMembershipChange(serverName, version)) + txn.SetVersionstampedKey(s.KeyForMembershipChange(serverName, version), types.MembershipTupToBytes(mtup)) +} + +// TxnReadJoinedCounts starts reading each server's joined member count of the room +func (s *ServersDirectory) TxnReadJoinedCounts(txn fdb.ReadTransaction, roomID id.RoomID, serverNames []string) map[string]fdb.FutureByteSlice { + futures := make(map[string]fdb.FutureByteSlice, len(serverNames)) + for _, serverName := range serverNames { + futures[serverName] = txn.Get(s.KeyForJoinedCount(roomID, serverName)) + } + return futures +} + +func JoinedCountOf(b []byte) int { + if b == nil { + return 0 + } + tup, err := tuple.Unpack(b) + if err != nil { + panic(err) + } + return int(tup[0].(int64)) +} + +// TxnSetJoinedCount writes a server's joined member count of the room, clearing it at zero +func (s *ServersDirectory) TxnSetJoinedCount(txn fdb.Transaction, roomID id.RoomID, serverName string, count int) { + key := s.KeyForJoinedCount(roomID, serverName) + if count > 0 { + txn.Set(key, tuple.Tuple{int64(count)}.Pack()) + } else { + txn.Clear(key) + } +} + +// TxnLookupRoomServers returns the servers with joined members in the room +func (s *ServersDirectory) TxnLookupRoomServers(txn fdb.ReadTransaction, roomID id.RoomID) ([]string, error) { + kvs, err := txn.GetRange(s.rangeForJoinedCounts(roomID), fdb.RangeOptions{Mode: fdb.StreamingModeWantAll}).GetSliceWithError() + if err != nil { + return nil, err + } + serverNames := make([]string, len(kvs)) + for i, kv := range kvs { + tup, err := s.joinedCounts.Unpack(kv.Key) + if err != nil { + return nil, err + } + serverNames[i] = tup[1].(string) + } + return serverNames, nil } diff --git a/internal/databases/rooms/servers/servers.go b/internal/databases/rooms/servers/servers.go index bd1fae4..15f82d1 100644 --- a/internal/databases/rooms/servers/servers.go +++ b/internal/databases/rooms/servers/servers.go @@ -14,11 +14,12 @@ import ( type ServersDirectory struct { log zerolog.Logger - // UserIDs by room/server so we can easily detect when a server goes in/out of a room + // Joined member counts by room/server, absent at zero: whether a server is in a room, and the + // room's servers. // - // key: (RoomID, ServerName, UserID) - // value: []byte (always empty) - joinedRoomMembers subspace.Subspace + // key: (RoomID, ServerName) + // value: (count) + joinedCounts subspace.Subspace // Room memberships by server so we list (joined) rooms for a given server // @@ -29,7 +30,7 @@ type ServersDirectory struct { // Room membership changes by server so we can handle changes during sync (for federation) // // key: (ServerName, Versionstamp) - // value: types.MembershipTupWithVersion + // value: types.MembershipTup membershipChanges subspace.Subspace } @@ -47,27 +48,27 @@ func NewServersDirectory(logger zerolog.Logger, db fdb.Database, parentDir direc return &ServersDirectory{ log: log, - joinedRoomMembers: serversDir.Sub("jme"), + joinedCounts: serversDir.Sub("jcn"), memberships: serversDir.Sub("mem"), membershipChanges: serversDir.Sub("mch"), } } -// Server joined members (room_id, server_name, username) -> '' +// Server joined member counts (room_id, server_name) -> (count) // -func (s *ServersDirectory) KeyForRoomJoinedMember(roomID id.RoomID, serverName string, username string) fdb.Key { - return s.joinedRoomMembers.Pack(tuple.Tuple{roomID.String(), serverName, username}) +func (s *ServersDirectory) KeyForJoinedCount(roomID id.RoomID, serverName string) fdb.Key { + return s.joinedCounts.Pack(tuple.Tuple{roomID.String(), serverName}) } -func (s *ServersDirectory) RangeForRoomJoinedMembers(roomID id.RoomID, serverName string) fdb.Range { - return s.joinedRoomMembers.Sub(roomID.String(), serverName) +func (s *ServersDirectory) rangeForJoinedCounts(roomID id.RoomID) fdb.Range { + return s.joinedCounts.Sub(roomID.String()) } -// Server memberships (server_name, room_id) -> '' (we only care about join) +// Server memberships (server_name, room_id) -> MembershipTup, set only while joined // -func (s *ServersDirectory) keyForMembership(serverName string, roomID id.RoomID) fdb.Key { +func (s *ServersDirectory) KeyForMembership(serverName string, roomID id.RoomID) fdb.Key { return s.memberships.Pack(tuple.Tuple{serverName, roomID.String()}) } @@ -75,7 +76,7 @@ func (s *ServersDirectory) rangeForMemberships(serverName string) fdb.Range { return s.memberships.Sub(serverName) } -// Server membership changes (server_name, version) -> (room_id, membership) +// Server membership changes (server_name, version) -> MembershipTup // func (s *ServersDirectory) keyToMembershipChangeVersion(key fdb.Key) tuple.Versionstamp { @@ -86,7 +87,7 @@ func (s *ServersDirectory) keyToMembershipChangeVersion(key fdb.Key) tuple.Versi return tup[1].(tuple.Versionstamp) } -func (s *ServersDirectory) keyForMembershipChange(serverName string, version tuple.Versionstamp) fdb.Key { +func (s *ServersDirectory) KeyForMembershipChange(serverName string, version tuple.Versionstamp) fdb.Key { return types.MustPackVersionKey(s.membershipChanges, tuple.Tuple{serverName, version}) } diff --git a/internal/databases/rooms/users/memberships.go b/internal/databases/rooms/users/memberships.go index 6395995..77f5abe 100644 --- a/internal/databases/rooms/users/memberships.go +++ b/internal/databases/rooms/users/memberships.go @@ -3,21 +3,16 @@ package users import ( "github.com/apple/foundationdb/bindings/go/src/fdb" "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" - "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "github.com/beeper/babbleserv/internal/types" ) -func (u *UsersDirectory) TxnDeleteUserMembership(txn fdb.Transaction, userID id.UserID, roomID id.RoomID, version tuple.Versionstamp) { - txn.Clear(u.keyForMembership(userID, roomID)) - txn.Clear(u.keyForMembershipChange(userID, version)) -} - -// Memberships (id.UserID, id.RoomID) -> types.MembershipTup +// Memberships of local users (id.UserID, id.RoomID) -> types.MembershipRow, cleared when the user's +// member key leaves the room's state // -func (u *UsersDirectory) keyForMembership(userID id.UserID, roomID id.RoomID) fdb.Key { +func (u *UsersDirectory) KeyForMembership(userID id.UserID, roomID id.RoomID) fdb.Key { return u.memberships.Pack(tuple.Tuple{userID.String(), roomID.String()}) } @@ -25,33 +20,33 @@ func (u *UsersDirectory) rangeForMemberships(userID id.UserID) fdb.Range { return u.memberships.Sub(userID.String()) } -func (u *UsersDirectory) TxnStoreMembership(txn fdb.Transaction, userID id.UserID, roomID id.RoomID, tup types.MembershipTup) { - txn.Set(u.keyForMembership(userID, roomID), types.MembershipTupToBytes(tup)) +func (u *UsersDirectory) TxnStoreMembership(txn fdb.Transaction, userID id.UserID, roomID id.RoomID, row types.MembershipRow) { + txn.Set(u.KeyForMembership(userID, roomID), types.MembershipRowToBytes(row)) } -func (u *UsersDirectory) TxnGetMembership( - txn fdb.ReadTransaction, - userID id.UserID, - roomID id.RoomID, -) *types.MembershipTup { - if b := txn.Get(u.keyForMembership(userID, roomID)).MustGet(); b == nil { +func (u *UsersDirectory) TxnDeleteMembership(txn fdb.Transaction, userID id.UserID, roomID id.RoomID) { + txn.Clear(u.KeyForMembership(userID, roomID)) +} + +func membershipRowOf(b []byte) *types.MembershipRow { + if b == nil { return nil - } else { - tup := types.BytesToMembershipTup(b) - return &tup } + row := types.BytesToMembershipRow(b) + return &row } -func (u *UsersDirectory) TxnIsUserJoinedRoom( - txn fdb.ReadTransaction, - userID id.UserID, - roomID id.RoomID, -) bool { - mtup := u.TxnGetMembership(txn, userID, roomID) - if mtup == nil { - return false +// MembershipRowsOf waits for reads of users' rows, nil for none +func MembershipRowsOf[K comparable](futures map[K]fdb.FutureByteSlice) (map[K]*types.MembershipRow, error) { + rows := make(map[K]*types.MembershipRow, len(futures)) + for key, future := range futures { + b, err := future.Get() + if err != nil { + return nil, err + } + rows[key] = membershipRowOf(b) } - return mtup.Membership == event.MembershipJoin + return rows, nil } func (u *UsersDirectory) TxnLookupUserMemberships( @@ -86,7 +81,7 @@ func (u *UsersDirectory) keyToMembershipChangeVersion(key fdb.Key) tuple.Version return tup[1].(tuple.Versionstamp) } -func (u *UsersDirectory) keyForMembershipChange(userID id.UserID, version tuple.Versionstamp) fdb.Key { +func (u *UsersDirectory) KeyForMembershipChange(userID id.UserID, version tuple.Versionstamp) fdb.Key { return types.MustPackVersionKey(u.membershipChanges, tuple.Tuple{userID.String(), version}) } @@ -103,7 +98,7 @@ func (u *UsersDirectory) TxnStoreMembershipChange( version tuple.Versionstamp, tup types.MembershipTup, ) { - txn.SetVersionstampedKey(u.keyForMembershipChange(userID, version), types.MembershipTupToBytes(tup)) + txn.SetVersionstampedKey(u.KeyForMembershipChange(userID, version), types.MembershipTupToBytes(tup)) } func (u *UsersDirectory) TxnLookupUserMembershipChanges( @@ -130,7 +125,7 @@ func (u *UsersDirectory) TxnLookupUserMembershipChanges( return changes } -// TxnLookupUserMembershipRows returns at most limit current-membership rows, +// TxnLookupUserMembershipRows returns the memberships of at most limit current-membership rows, // including terminal memberships, and reports whether the range had more rows. // It is used by privacy-sensitive user-directory filtering, where silently // skipping leave rows would make the transaction's scan bound ineffective. diff --git a/internal/databases/rooms/users/users.go b/internal/databases/rooms/users/users.go index 14281ba..59fe2ee 100644 --- a/internal/databases/rooms/users/users.go +++ b/internal/databases/rooms/users/users.go @@ -11,16 +11,17 @@ type UsersDirectory struct { log zerolog.Logger db fdb.Database - // Current user memberships + // Current memberships of local users, written with the room's current state, and outlier + // memberships received while this server is out of a room // // key: (id.UserID, id.RoomID) - // value: types.MembershipTup + // value: (EventID, RoomID, Membership[, Outlier]), see types.MembershipRow memberships subspace.Subspace - // Membership changes + // Membership changes of local users, for sync // // key: (id.UserID, tuple.Versionstamp) - // value: types.MembershipTupWithVersion + // value: types.MembershipTup membershipChanges subspace.Subspace // Notification counts per event version diff --git a/internal/types/event.go b/internal/types/event.go index b99737a..2eb04bf 100644 --- a/internal/types/event.go +++ b/internal/types/event.go @@ -337,7 +337,7 @@ func (ev *Event) MustGetRoomSpec() gomatrixserverlib.IRoomVersion { return roomSpec } -func (ev *Event) IsProfileUpdate() bool { +func (ev *Event) IsBabbleProfileUpdate() bool { return gjson.GetBytes(ev.Content, `babbleserv\.is_profile_update`).Bool() } diff --git a/internal/util/database.go b/internal/util/database.go index 92943b6..2bdb26d 100644 --- a/internal/util/database.go +++ b/internal/util/database.go @@ -249,7 +249,7 @@ func doWriteTransactionWithRetries( case <-time.After(retryIn): continue // retry case <-ctx.Done(): - return ctx.Err(), nil + return nil, ctx.Err() } } log.Err(err).Msg("Transaction error") diff --git a/internal/workers/devicejoineventiterator.go b/internal/workers/devicejoineventiterator.go index 4ef50e3..17c7e47 100644 --- a/internal/workers/devicejoineventiterator.go +++ b/internal/workers/devicejoineventiterator.go @@ -145,7 +145,7 @@ func (e *DeviceJoinEventIterator) sendLocalDeviceChanges(lock lock.Lock, tups [] ev, err := e.db.Rooms.GetEvent(e.ctx, tup.EventID) if err != nil { return err - } else if ev.IsProfileUpdate() { + } else if ev.IsBabbleProfileUpdate() { // Ignore internal profile updates as these don't actually change membership - note this // doesn't cover profile updates over federation. continue From 8a4d71ba0dd6dca3ac75ce69233d9041c0483941 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Sun, 4 Oct 2026 22:55:31 +0100 Subject: [PATCH 07/12] Read room state and memberships from exact contexts Replace current and historical state-index readers with immutable room and event contexts. Read local membership rows directly and derive remote user memberships from current state in rooms shared with their server. Batch room membership reads and return membership and encrypted-room sharing results directly. Update receipt and room-publication checks, client/debug routes, and notification and presence workers to use the new readers. Add the shared stored-event lookups used by event senders and align their call sites with the renamed membership and event lookup methods. --- internal/databases/rooms/events.go | 33 ++ internal/databases/rooms/eventsend.go | 2 +- internal/databases/rooms/eventsender.go | 2 +- .../databases/rooms/eventsendfederated.go | 6 +- internal/databases/rooms/publicrooms.go | 13 +- internal/databases/rooms/receiptsend.go | 17 +- internal/databases/rooms/roomstate.go | 421 ++++++++++++------ internal/databases/rooms/servers.go | 30 +- internal/databases/rooms/users.go | 313 ++++++++++++- internal/routes/client/keys.go | 8 +- internal/routes/client/roomevent.go | 8 +- internal/routes/debug/debugroom.go | 4 +- .../workers/compactnotificationiterator.go | 2 +- internal/workers/eventsiterator.go | 2 +- internal/workers/presencechangeiterator.go | 14 +- internal/workers/pushnotificationiterator.go | 4 +- 16 files changed, 690 insertions(+), 189 deletions(-) diff --git a/internal/databases/rooms/events.go b/internal/databases/rooms/events.go index 63b5a1a..c94044f 100644 --- a/internal/databases/rooms/events.go +++ b/internal/databases/rooms/events.go @@ -2,6 +2,8 @@ package rooms import ( "context" + "maps" + "slices" "maunium.net/go/mautrix/id" @@ -33,6 +35,37 @@ func (r *RoomsDatabase) MustDoesEventExist(ctx context.Context, eventID id.Event } } +// storedEventIDs returns a map of event IDs already stored +func (r *RoomsDatabase) storedEventIDs(ctx context.Context, eventIDs []id.EventID) (map[id.EventID]struct{}, error) { + stored := make(map[id.EventID]struct{}) + for chunk := range slices.Chunk(eventIDs, storedCheckChunk) { + chunkStored, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (map[id.EventID]struct{}, error) { + return r.txnGetStoredEventIDs(txn, chunk) + }) + if err != nil { + return nil, err + } + maps.Copy(stored, chunkStored) + } + return stored, nil +} + +func (r *RoomsDatabase) txnGetStoredEventIDs(txn fdb.ReadTransaction, eventIDs []id.EventID) (map[id.EventID]struct{}, error) { + futures := make([]fdb.FutureByteSlice, len(eventIDs)) + for i, eventID := range eventIDs { + futures[i] = txn.Get(r.events.KeyForIDToVersion(eventID)) + } + stored := make(map[id.EventID]struct{}, len(eventIDs)) + for i, future := range futures { + if b, err := future.Get(); err != nil { + return nil, err + } else if b != nil { + stored[eventIDs[i]] = struct{}{} + } + } + return stored, nil +} + func (r *RoomsDatabase) GetEvent(ctx context.Context, eventID id.EventID) (*types.Event, error) { return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (*types.Event, error) { return r.events.TxnGetEvent(txn, eventID), nil diff --git a/internal/databases/rooms/eventsend.go b/internal/databases/rooms/eventsend.go index 828b3e0..d461871 100644 --- a/internal/databases/rooms/eventsend.go +++ b/internal/databases/rooms/eventsend.go @@ -674,7 +674,7 @@ func (r *RoomsDatabase) txnGetLocalJoinerInvite(txn fdb.ReadTransaction, ev *typ if !r.isLocalUser(userID) { return nil, nil } - memberships, err := r.txnMemberships(txn, ev.RoomID, []id.UserID{userID}) + memberships, err := r.txnGetRoomUserMemberships(txn, ev.RoomID, []id.UserID{userID}) if err != nil { return nil, err } else if membership, found := memberships[userID]; !found || membership.Membership != event.MembershipInvite { diff --git a/internal/databases/rooms/eventsender.go b/internal/databases/rooms/eventsender.go index 5800e7b..f9ecda9 100644 --- a/internal/databases/rooms/eventsender.go +++ b/internal/databases/rooms/eventsender.go @@ -554,7 +554,7 @@ func (s *eventSender) txnCheckGuard(txn fdb.Transaction, plan *publishPlan) (*ty } else if joined := isServerJoined(stored); joined != plan.guard.joined { return nil, fmt.Errorf("%w: this server joined %t, prepared %t", errGuardFailed, joined, plan.guard.joined) } - if storedNow, err := s.r.txnStoredEventIDs(txn, plan.guard.unstored); err != nil { + if storedNow, err := s.r.txnGetStoredEventIDs(txn, plan.guard.unstored); err != nil { return nil, err } else if len(storedNow) > 0 { return nil, errEventsStoredConcurrently diff --git a/internal/databases/rooms/eventsendfederated.go b/internal/databases/rooms/eventsendfederated.go index 7d20954..68cb558 100644 --- a/internal/databases/rooms/eventsendfederated.go +++ b/internal/databases/rooms/eventsendfederated.go @@ -202,7 +202,7 @@ func (r *RoomsDatabase) isOutlierMembership(ctx context.Context, roomID id.RoomI return true, nil case event.MembershipLeave: return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (bool, error) { - memberships, err := r.txnMemberships(txn, roomID, []id.UserID{target}) + memberships, err := r.txnGetRoomUserMemberships(txn, roomID, []id.UserID{target}) if err != nil { return false, err } else if membership, found := memberships[target]; !found || membership.Membership != event.MembershipInvite { @@ -395,7 +395,7 @@ func (s *federatedEventSender) txnPrepareFederated( } eventsProvider.WillGet(extremities...) - stored, err := s.r.txnStoredEventIDs(txn, util.EventsToIDs(evs)) + stored, err := s.r.txnGetStoredEventIDs(txn, util.EventsToIDs(evs)) if err != nil { return nil, err } @@ -1012,7 +1012,7 @@ func (s *federatedEventSender) txnStageStatelessEvents( } } } - if storedNow, err := s.r.txnStoredEventIDs(txn, util.EventsToIDs(unstored)); err != nil { + if storedNow, err := s.r.txnGetStoredEventIDs(txn, util.EventsToIDs(unstored)); err != nil { return err } else if len(storedNow) > 0 { return errEventsStoredConcurrently diff --git a/internal/databases/rooms/publicrooms.go b/internal/databases/rooms/publicrooms.go index 20ee72f..1d176bb 100644 --- a/internal/databases/rooms/publicrooms.go +++ b/internal/databases/rooms/publicrooms.go @@ -43,14 +43,15 @@ func (r *RoomsDatabase) SetRoomPublished( return nil, types.ErrRoomNotFound } room := types.MustNewRoomFromBytes(roomBytes, roomID) - if !r.users.TxnIsUserJoinedRoom(txn, userID, roomID) { + if joined, err := r.txnUsersJoined(txn, roomID, []id.UserID{userID}); err != nil { + return nil, err + } else if !joined[userID] { return nil, types.ErrRoomPublicationForbidden } - eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) - createEvent := r.events.TxnGetCurrentRoomStateEvent(txn, roomID, types.StateTup{ - Type: event.StateCreate, - }, eventsProvider) - if createEvent == nil || createEvent.Sender != userID { + createEvents, err := r.txnRoomStateEvents(ctx, txn, roomID, []types.StateTup{{Type: event.StateCreate}}) + if err != nil { + return nil, err + } else if len(createEvents) == 0 || createEvents[0].Sender != userID { return nil, types.ErrRoomPublicationForbidden } diff --git a/internal/databases/rooms/receiptsend.go b/internal/databases/rooms/receiptsend.go index acc272e..71942bb 100644 --- a/internal/databases/rooms/receiptsend.go +++ b/internal/databases/rooms/receiptsend.go @@ -3,7 +3,6 @@ package rooms import ( "context" "fmt" - "sync" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -34,9 +33,7 @@ func (r *RoomsDatabase) SendReceipts( roomID id.RoomID, rcs []*types.Receipt, ) (*SendReceiptsResults, error) { - lock, _ := r.roomLocks.GetOrSet(roomID, &sync.Mutex{}) - lock.Lock() - defer lock.Unlock() + defer r.lockRoom(roomID)() if len(rcs) >= types.MaxVersionstampUserVersion { // Very unlikely! But safety first @@ -54,12 +51,20 @@ func (r *RoomsDatabase) SendReceipts( allowedReceipts := make([]*types.Receipt, 0, len(rcs)) rejectedReceipts := make([]RejectedReceipt, 0) - for i, rc := range rcs { + userIDs := make([]id.UserID, 0, len(rcs)) + for _, rc := range rcs { if rc.RoomID != roomID { panic("wrong room id provided") } + userIDs = append(userIDs, rc.UserID) + } + joined, err := r.txnUsersJoined(txn, roomID, userIDs) + if err != nil { + return nil, err + } - if !r.users.TxnIsUserJoinedRoom(txn, rc.UserID, rc.RoomID) { + for i, rc := range rcs { + if !joined[rc.UserID] { // Change from spec: silently ignore receipts for rooms the user is not a member of log.Warn(). Stringer("user_id", rc.UserID). diff --git a/internal/databases/rooms/roomstate.go b/internal/databases/rooms/roomstate.go index b74cbad..77dc441 100644 --- a/internal/databases/rooms/roomstate.go +++ b/internal/databases/rooms/roomstate.go @@ -2,113 +2,280 @@ package rooms import ( "context" + "errors" + "fmt" + "maps" + "slices" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "github.com/beeper/babbleserv/internal/databases/rooms/events" + "github.com/beeper/babbleserv/internal/databases/rooms/state" "github.com/beeper/babbleserv/internal/types" "github.com/beeper/babbleserv/internal/util" ) +func (r *RoomsDatabase) txnGetRoom(txn fdb.ReadTransaction, roomID id.RoomID) (*types.Room, error) { + roomBytes, err := txn.Get(r.KeyForRoom(roomID)).Get() + if err != nil { + return nil, err + } else if roomBytes == nil { + return nil, fmt.Errorf("%w: %s", types.ErrRoomNotFound, roomID) + } + return types.NewRoomFromBytes(roomBytes, roomID) +} + +func roomOrNil(room *types.Room, err error) (*types.Room, error) { + if errors.Is(err, types.ErrRoomNotFound) { + return nil, nil + } + return room, err +} + +func roomCurrentState(room *types.Room) types.StateHash { + if room == nil || room.CurrentState.IsZero() { + return state.EmptyContext + } + return room.CurrentState +} + +func (r *RoomsDatabase) txnCurrentState(txn fdb.ReadTransaction, roomID id.RoomID) (types.StateHash, error) { + room, err := roomOrNil(r.txnGetRoom(txn, roomID)) + return roomCurrentState(room), err +} + func (r *RoomsDatabase) IsRoomEncrypted(ctx context.Context, roomID id.RoomID) (bool, error) { - return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (bool, error) { - isEncrypted := r.events.TxnIsRoomEncrypted(txn, roomID) - return isEncrypted, nil + room, err := r.GetRoom(ctx, roomID) + return room != nil && room.Encrypted, err +} + +// RoomState returns the events of the room's current state, sorted +func (r *RoomsDatabase) RoomState(ctx context.Context, roomID id.RoomID) ([]*types.Event, error) { + return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) ([]*types.Event, error) { + current, err := r.txnCurrentState(txn, roomID) + if err != nil { + return nil, err + } + state, err := r.state.NewBatch(roomID).TxnIterateEntries(txn, current) + if err != nil { + return nil, err + } + return txnGetStateEvents(r.events.NewTxnEventsProvider(ctx, txn), state.EventIDs()) }) } -func (r *RoomsDatabase) GetCurrentRoomStateEvent(ctx context.Context, roomID id.RoomID, stateTup types.StateTup) (*types.Event, error) { +// RoomStateEvent returns the event of a tuple of the room's current state, nil for none +func (r *RoomsDatabase) RoomStateEvent(ctx context.Context, roomID id.RoomID, tup types.StateTup) (*types.Event, error) { return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (*types.Event, error) { - eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) - return r.events.TxnGetCurrentRoomStateEvent(txn, roomID, stateTup, eventsProvider), nil + evs, err := r.txnRoomStateEvents(ctx, txn, roomID, []types.StateTup{tup}) + if err != nil || len(evs) == 0 { + return nil, err + } + return evs[0], nil }) } -func (r *RoomsDatabase) GetCurrentRoomMemberships(ctx context.Context, roomID id.RoomID) (types.RoomMemberships, error) { - return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.RoomMemberships, error) { - return r.events.TxnLookupCurrentRoomMemberships(txn, roomID, nil), nil +// RoomStrippedState returns the state of the room's current state that invites and knocks carry +func (r *RoomsDatabase) RoomStrippedState(ctx context.Context, roomID id.RoomID) ([]*types.PartialEvent, error) { + return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) ([]*types.PartialEvent, error) { + evs, err := r.txnRoomStateEvents(ctx, txn, roomID, events.StrippedStateTups) + return util.EventsToPartialEvents(evs), err }) } -func (r *RoomsDatabase) GetCurrentRoomLocalJoinedMemberships(ctx context.Context, roomID id.RoomID) (types.RoomMemberships, error) { +// txnRoomStateEvents returns the events of those of the tuples in the room's current state, sorted +func (r *RoomsDatabase) txnRoomStateEvents(ctx context.Context, txn fdb.ReadTransaction, roomID id.RoomID, tups []types.StateTup) ([]*types.Event, error) { + current, err := r.txnCurrentState(txn, roomID) + if err != nil { + return nil, err + } + state, err := r.state.NewBatch(roomID).TxnLookupEntries(txn, current, tups) + if err != nil { + return nil, err + } + return txnGetStateEvents(r.events.NewTxnEventsProvider(ctx, txn), state.EventIDs()) +} + +// RoomMembers returns the members of the room's current state with any of the memberships, every +// member when none is given +func (r *RoomsDatabase) RoomMembers(ctx context.Context, roomID id.RoomID, memberships ...event.Membership) (types.RoomMemberships, error) { return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.RoomMemberships, error) { - memberships := r.events.TxnLookupCurrentRoomMemberships(txn, roomID, nil) - localMemberships := make(types.RoomMemberships, len(memberships)) - for userID, mTup := range memberships { - if userID.Homeserver() == r.config.ServerName && mTup.Membership == event.MembershipJoin { - localMemberships[userID] = mTup + current, err := r.txnCurrentState(txn, roomID) + if err != nil { + return nil, err + } + members, err := r.state.NewBatch(roomID).TxnIterateMembers(txn, current) + if err != nil { + return nil, err + } + roomMembers := make(types.RoomMemberships, len(members)) + for userID, member := range members { + if len(memberships) == 0 || slices.Contains(memberships, member.Membership) { + roomMembers[userID] = types.MembershipTup{EventID: member.EventID, RoomID: roomID, Membership: member.Membership} } } - return localMemberships, nil + return roomMembers, nil }) } -func (r *RoomsDatabase) GetRoomStateMapAtEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (types.StateMap, error) { - return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.StateMap, error) { - return r.events.TxnLookupRoomStateAndMemberMapAtEvent(txn, roomID, eventID, nil), nil +// RoomMemberEvents returns the member events of the room's current state, sorted +func (r *RoomsDatabase) RoomMemberEvents(ctx context.Context, roomID id.RoomID) ([]*types.Event, error) { + return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) ([]*types.Event, error) { + current, err := r.txnCurrentState(txn, roomID) + if err != nil { + return nil, err + } + members, err := r.state.NewBatch(roomID).TxnIterateMembers(txn, current) + if err != nil { + return nil, err + } + stateMap := make(types.StateMap, len(members)) + for userID, member := range members { + stateMap[types.MemberStateTup(userID)] = member.EventID + } + return txnGetStateEvents(r.events.NewTxnEventsProvider(ctx, txn), stateMap) }) } -func (r *RoomsDatabase) GetRoomAuthStateMapAtEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (types.StateMap, error) { - return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.StateMap, error) { - return r.events.TxnLookupRoomAuthStateMapAtEvent(ctx, txn, roomID, eventID, nil), nil +// LocalJoinedMembers returns this server's joined members of the room +func (r *RoomsDatabase) LocalJoinedMembers(ctx context.Context, roomID id.RoomID) (types.RoomMemberships, error) { + return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.RoomMemberships, error) { + rows, err := txn.GetRange(r.localMembers.Sub(roomID.String()), fdb.RangeOptions{Mode: fdb.StreamingModeWantAll}).GetSliceWithError() + if err != nil { + return nil, err + } + members := make(types.RoomMemberships, len(rows)) + for _, row := range rows { + userID, member, err := r.localMemberOf(row) + if err != nil { + return nil, err + } + members[userID] = member + } + return members, nil }) } -func (r *RoomsDatabase) GetRoomSpecificRoomMemberStateMapAtEvent(ctx context.Context, roomID id.RoomID, userIDs []id.UserID, eventID id.EventID) (types.StateMap, error) { - return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.StateMap, error) { - return r.events.TxnLookupSpecificRoomMemberStateMapAtEvent(ctx, txn, roomID, userIDs, eventID, nil), nil - }) +var errInvalidLocalMember = errors.New("invalid local joined member row") + +// localMemberOf decodes a row of the room's local joined members +func (r *RoomsDatabase) localMemberOf(kv fdb.KeyValue) (id.UserID, types.MembershipTup, error) { + keyTup, err := r.localMembers.Unpack(kv.Key) + userID, userOK := "", false + if err == nil && len(keyTup) == 2 { + userID, userOK = keyTup[1].(string) + } + tup, err := tuple.Unpack(kv.Value) + if err != nil || len(tup) != 3 || !userOK { + return "", types.MembershipTup{}, fmt.Errorf("%w: %x", errInvalidLocalMember, []byte(kv.Key)) + } + eventID, eventOK := tup[0].(string) + roomID, roomOK := tup[1].(string) + membership, membershipOK := tup[2].(string) + if !eventOK || !roomOK || !membershipOK { + return "", types.MembershipTup{}, fmt.Errorf("%w: %x", errInvalidLocalMember, []byte(kv.Key)) + } + return id.UserID(userID), types.MembershipTup{EventID: id.EventID(eventID), RoomID: id.RoomID(roomID), Membership: event.Membership(membership)}, nil } -func (r *RoomsDatabase) GetCurrentRoomStrippedStateEvents(ctx context.Context, roomID id.RoomID) ([]*types.PartialEvent, error) { - return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) ([]*types.PartialEvent, error) { - eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) - stateMap := r.events.TxnLookupCurrentRoomStrippedStateStateMap(txn, roomID, eventsProvider) - evs := make([]*types.Event, 0, len(stateMap)) - for _, evID := range stateMap { - evs = append(evs, eventsProvider.MustGet(evID)) - } - util.SortEventList(evs) - return util.EventsToPartialEvents(evs), nil +// LocalMembersJoinedWith returns the room's local members joined in the state after a join, the +// joiner included, when the join's step, stored at version, made this server joined, otherwise none. +func (r *RoomsDatabase) LocalMembersJoinedWith(ctx context.Context, join *types.Event, version tuple.Versionstamp) (types.RoomMemberships, error) { + return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.RoomMemberships, error) { + change, err := txn.Get(r.servers.KeyForMembershipChange(r.config.ServerName, version)).Get() + if err != nil || change == nil { + return nil, err + } else if mtup := types.BytesToMembershipTup(change); mtup.RoomID != join.RoomID || mtup.Membership != event.MembershipJoin { + return nil, nil + } + rows, err := txn.GetRange(r.localMembers.Sub(join.RoomID.String()), fdb.RangeOptions{Mode: fdb.StreamingModeWantAll}).GetSliceWithError() + if err != nil { + return nil, err + } + userIDs := make([]id.UserID, 0, len(rows)) + for _, row := range rows { + userID, _, err := r.localMemberOf(row) + if err != nil { + return nil, err + } + userIDs = append(userIDs, userID) + } + entries, err := r.state.NewBatch(join.RoomID).TxnLookupEntries(txn, join.AfterState, events.MemberStateTups(userIDs)) + if err != nil { + return nil, err + } + members := make(types.RoomMemberships, len(entries)) + for tup, entry := range entries { + if entry.Membership == event.MembershipJoin { + members[id.UserID(tup.StateKey)] = types.MembershipTup{EventID: entry.EventID, RoomID: join.RoomID, Membership: entry.Membership} + } + } + return members, nil }) } -func (r *RoomsDatabase) GetCurrentRoomMemberEvents(ctx context.Context, roomID id.RoomID) ([]*types.Event, error) { - if memberEvs, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) ([]*types.Event, error) { - eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) - memberMap := r.events.TxnLookupCurrentRoomMemberStateMap(txn, roomID, eventsProvider) - evs := make([]*types.Event, 0, len(memberMap)) - for _, evID := range memberMap { - evs = append(evs, eventsProvider.MustGet(evID)) - } - return evs, nil - }); err != nil { - return nil, err - } else { - util.SortEventList(memberEvs) - return memberEvs, nil +func (r *RoomsDatabase) txnGetEventWithState(txn fdb.ReadTransaction, roomID id.RoomID, eventID id.EventID) (*types.Event, error) { + ev := r.events.TxnGetEvent(txn, eventID) + if ev == nil || ev.RoomID != roomID { + return nil, types.ErrEventNotFound + } else if !ev.HasStateIn(roomID) { + return nil, types.ErrStateUnavailable } + return ev, nil } -func (r *RoomsDatabase) GetCurrentRoomStateEvents(ctx context.Context, roomID id.RoomID) ([]*types.Event, error) { - stateEvs, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) ([]*types.Event, error) { - eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) - stateMap := r.events.TxnLookupCurrentRoomStateAndMemberMap(txn, roomID, eventsProvider) - evs := make([]*types.Event, 0, len(stateMap)) - for _, evID := range stateMap { - evs = append(evs, eventsProvider.MustGet(evID)) +func (r *RoomsDatabase) txnGetStateAtEvent(txn fdb.ReadTransaction, roomID id.RoomID, eventID id.EventID) (*types.Event, types.StateMap, error) { + ev, err := r.txnGetEventWithState(txn, roomID, eventID) + if err != nil { + return nil, nil, err + } + state, err := r.state.NewBatch(roomID).TxnIterateEntries(txn, ev.BeforeState) + return ev, state.EventIDs(), err +} + +func (r *RoomsDatabase) lookupStateAtEvent( + ctx context.Context, + roomID id.RoomID, + eventID id.EventID, + tups []types.StateTup, +) (types.StateMap, error) { + return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.StateMap, error) { + ev, err := r.txnGetEventWithState(txn, roomID, eventID) + if err != nil { + return nil, err } - return evs, nil + state, err := r.state.NewBatch(roomID).TxnLookupEntries(txn, ev.BeforeState, tups) + return state.EventIDs(), err + }) +} + +func (r *RoomsDatabase) GetRoomStateMapAtEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (types.StateMap, error) { + return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.StateMap, error) { + _, stateMap, err := r.txnGetStateAtEvent(txn, roomID, eventID) + return stateMap, err }) +} + +func (r *RoomsDatabase) GetRoomAuthStateMapAtEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (types.StateMap, error) { + return r.lookupStateAtEvent(ctx, roomID, eventID, events.AuthStateTupsWithMembers(nil)) +} + +func (r *RoomsDatabase) GetRoomSpecificRoomMemberStateMapAtEvent(ctx context.Context, roomID id.RoomID, userIDs []id.UserID, eventID id.EventID) (types.StateMap, error) { + return r.lookupStateAtEvent(ctx, roomID, eventID, events.MemberStateTups(userIDs)) +} + +// Reads the events of a state map, sorted +func txnGetStateEvents(eventsProvider *events.TxnEventsProvider, stateMap types.StateMap) ([]*types.Event, error) { + evs, err := eventsProvider.GetAll(slices.Collect(maps.Values(stateMap))) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get state: %w", err) } - - util.SortEventList(stateEvs) - return stateEvs, nil + util.SortEventList(evs) + return evs, nil } type stateWithAuthChain struct { @@ -116,69 +283,56 @@ type stateWithAuthChain struct { AuthChain []*types.Event } -func (r *RoomsDatabase) GetCurrentRoomStateEventsWithAuthChain(ctx context.Context, roomID id.RoomID) (stateWithAuthChain, error) { - var res stateWithAuthChain - var err error - - res.StateEvents, err = r.GetCurrentRoomStateEvents(ctx, roomID) +// Adds the auth chain of the sorted state events, read in a transaction of its own +func (r *RoomsDatabase) withAuthChain(ctx context.Context, stateEvs []*types.Event) (stateWithAuthChain, error) { + authChain, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) ([]*types.Event, error) { + eventsProvider := r.events.NewTxnEventsProvider(ctx, txn).WithEvents(stateEvs...) + return r.events.TxnGetAuthChainForEvents(txn, stateEvs, eventsProvider) + }) if err != nil { - return res, err + return stateWithAuthChain{}, err } + util.SortEventList(authChain) + return stateWithAuthChain{StateEvents: stateEvs, AuthChain: authChain}, nil +} - if _, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (*struct{}, error) { - eventsProvider := r.events.NewTxnEventsProvider(ctx, txn).WithEvents(res.StateEvents...) - if authChain, err := r.events.TxnGetAuthChainForEvents(txn, res.StateEvents, eventsProvider); err != nil { +// GetCurrentRoomStateWithAuthChain returns the room's current state with its auth chain +func (r *RoomsDatabase) GetCurrentRoomStateWithAuthChain(ctx context.Context, roomID id.RoomID) (stateWithAuthChain, error) { + stateEvs, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) ([]*types.Event, error) { + room, err := r.txnGetRoom(txn, roomID) + if err != nil { return nil, err - } else { - res.AuthChain = authChain + } else if room.CurrentState.IsZero() { + return nil, types.ErrStateUnavailable } - return nil, nil - }); err != nil { - return res, err + state, err := r.state.NewBatch(roomID).TxnIterateEntries(txn, room.CurrentState) + if err != nil { + return nil, err + } + return txnGetStateEvents(r.events.NewTxnEventsProvider(ctx, txn), state.EventIDs()) + }) + if err != nil { + return stateWithAuthChain{}, err } - - util.SortEventList(res.StateEvents) - util.SortEventList(res.AuthChain) - return res, nil + return r.withAuthChain(ctx, stateEvs) } -// Get room state (state events + their auth chain) at a given event. This is broken down into -// multuple read transactions so we don't hit the 5s limit. Since events are immutableish we don't -// need the consistency guarantees of a single transaction. func (r *RoomsDatabase) GetRoomStateWithAuthChainAtEvent( ctx context.Context, roomID id.RoomID, eventID id.EventID, ) (stateWithAuthChain, error) { - var res stateWithAuthChain - - if _, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (*struct{}, error) { - eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) - stateMap := r.events.TxnLookupRoomStateAndMemberMapAtEvent(txn, roomID, eventID, eventsProvider) - res.StateEvents = make([]*types.Event, 0, len(stateMap)) - for _, eventID := range stateMap { - res.StateEvents = append(res.StateEvents, eventsProvider.MustGet(eventID)) - } - return nil, nil - }); err != nil { - return res, err - } - - if _, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (*struct{}, error) { - eventsProvider := r.events.NewTxnEventsProvider(ctx, txn).WithEvents(res.StateEvents...) - if authChain, err := r.events.TxnGetAuthChainForEvents(txn, res.StateEvents, eventsProvider); err != nil { + stateEvs, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) ([]*types.Event, error) { + _, stateMap, err := r.txnGetStateAtEvent(txn, roomID, eventID) + if err != nil { return nil, err - } else { - res.AuthChain = authChain } - return nil, nil - }); err != nil { - return res, err + return txnGetStateEvents(r.events.NewTxnEventsProvider(ctx, txn), stateMap) + }) + if err != nil { + return stateWithAuthChain{}, err } - - util.SortEventList(res.StateEvents) - util.SortEventList(res.AuthChain) - return res, nil + return r.withAuthChain(ctx, stateEvs) } type stateWithAuthChainIDs struct { @@ -186,26 +340,45 @@ type stateWithAuthChainIDs struct { AuthChainIDs []id.EventID } +// Walks the auth event IDs stored for each event rather than reading the events func (r *RoomsDatabase) GetRoomStateWithAuthChainIDsAtEvent( ctx context.Context, roomID id.RoomID, eventID id.EventID, ) (stateWithAuthChainIDs, error) { - var res stateWithAuthChainIDs - - roomStateAtEvent, err := r.GetRoomStateWithAuthChainAtEvent(ctx, roomID, eventID) - if err != nil { - return res, err - } + return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (stateWithAuthChainIDs, error) { + ev, stateMap, err := r.txnGetStateAtEvent(txn, roomID, eventID) + if err != nil { + return stateWithAuthChainIDs{}, err + } + stateIDs := slices.AppendSeq(make([]id.EventID, 0, len(stateMap)), maps.Values(stateMap)) + slices.Sort(stateIDs) - res.StateEventIDs = make([]id.EventID, 0, len(roomStateAtEvent.StateEvents)) - for _, ev := range roomStateAtEvent.StateEvents { - res.StateEventIDs = append(res.StateEventIDs, ev.ID) - } - res.AuthChainIDs = make([]id.EventID, 0, len(roomStateAtEvent.AuthChain)) - for _, ev := range roomStateAtEvent.AuthChain { - res.AuthChainIDs = append(res.AuthChainIDs, ev.ID) - } + // The auth chain of the state is what a walk from it reaches over at least one auth event: + // the auth events of every event the walk reaches + authEventIDs := make(events.AuthEventIDs, len(stateIDs)) + reached, err := r.events.TxnGetAuthChainIDs(txn, stateIDs, authEventIDs) + if err != nil { + return stateWithAuthChainIDs{}, err + } + authChain := make(map[id.EventID]struct{}, len(reached)) + for eventID := range reached { + for _, authEventID := range authEventIDs[eventID] { + authChain[authEventID] = struct{}{} + } + } + // In a room whose ID derives from the create event, every other event cites it implicitly + if createID := ev.ImplicitCreateEventID(); createID != "" { + for eventID := range reached { + if eventID != createID { + authChain[createID] = struct{}{} + break + } + } + } - return res, nil + authChainIDs := slices.AppendSeq(make([]id.EventID, 0, len(authChain)), maps.Keys(authChain)) + slices.Sort(authChainIDs) + return stateWithAuthChainIDs{StateEventIDs: stateIDs, AuthChainIDs: authChainIDs}, nil + }) } diff --git a/internal/databases/rooms/servers.go b/internal/databases/rooms/servers.go index 14fd701..740da18 100644 --- a/internal/databases/rooms/servers.go +++ b/internal/databases/rooms/servers.go @@ -6,24 +6,46 @@ import ( "github.com/apple/foundationdb/bindings/go/src/fdb" "maunium.net/go/mautrix/id" + "github.com/beeper/babbleserv/internal/databases/rooms/servers" "github.com/beeper/babbleserv/internal/types" "github.com/beeper/babbleserv/internal/util" ) -func (r *RoomsDatabase) IsServerJoinedRoom(ctx context.Context, serverName string, roomID id.RoomID) (bool, error) { +// IsServerJoined reports whether the server has joined members in the room: this server when the room +// record counts any, another server when its joined member count is above zero too, as nothing is +// served for a room this server is not in. +func (r *RoomsDatabase) IsServerJoined(ctx context.Context, roomID id.RoomID, serverName string) (bool, error) { return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (bool, error) { - return r.servers.TxnIsServerJoinedRoom(txn, serverName, roomID), nil + var count fdb.FutureByteSlice + if serverName != r.config.ServerName { + count = txn.Get(r.servers.KeyForJoinedCount(roomID, serverName)) + } + if room, err := roomOrNil(r.txnGetRoom(txn, roomID)); err != nil || !isServerJoined(room) { + return false, err + } else if count == nil { + return true, nil + } + b, err := count.Get() + return servers.JoinedCountOf(b) > 0, err }) } +// isServerJoined reports whether this server has joined members in the room, nil for a room without +// a record +func isServerJoined(room *types.Room) bool { + return room != nil && room.LocalMembers > 0 +} + func (r *RoomsDatabase) GetServerMemberships(ctx context.Context, serverName string) (types.Memberships, error) { return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.Memberships, error) { return r.servers.TxnLookupServerMemberships(txn, serverName) }) } -func (r *RoomsDatabase) GetCurrentRoomServers(ctx context.Context, roomID id.RoomID) ([]string, error) { +// RoomServers returns the servers with joined members in the room, this one included. Once this +// server leaves, the others are those of the room's current state when it left. +func (r *RoomsDatabase) RoomServers(ctx context.Context, roomID id.RoomID) ([]string, error) { return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) ([]string, error) { - return r.events.TxnLookupCurrentRoomServers(txn, roomID) + return r.servers.TxnLookupRoomServers(txn, roomID) }) } diff --git a/internal/databases/rooms/users.go b/internal/databases/rooms/users.go index e18ee87..cdb416c 100644 --- a/internal/databases/rooms/users.go +++ b/internal/databases/rooms/users.go @@ -3,39 +3,125 @@ package rooms import ( "context" "fmt" + "maps" + "slices" + "sync" "github.com/apple/foundationdb/bindings/go/src/fdb" "github.com/samber/lo" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + "github.com/beeper/babbleserv/internal/databases/rooms/events" + "github.com/beeper/babbleserv/internal/databases/rooms/users" "github.com/beeper/babbleserv/internal/types" "github.com/beeper/babbleserv/internal/util" ) func (r *RoomsDatabase) IsUserJoinedRoom(ctx context.Context, userID id.UserID, roomID id.RoomID) (bool, error) { - return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (bool, error) { - return r.users.TxnIsUserJoinedRoom(txn, userID, roomID), nil + memberships, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (map[id.UserID]types.MembershipTup, error) { + return r.txnGetRoomUserMemberships(txn, roomID, []id.UserID{userID}) }) + return memberships[userID].Membership == event.MembershipJoin, err +} + +// txnRoomMembers returns the event and membership each user's member tuple holds in the room's +// current state +func (r *RoomsDatabase) txnRoomMembers(txn fdb.ReadTransaction, room *types.Room, userIDs []id.UserID) (types.StateEntries, error) { + if room == nil || room.CurrentState.IsZero() || len(userIDs) == 0 { + return types.StateEntries{}, nil + } + return r.state.NewBatch(room.ID).TxnLookupEntries(txn, room.CurrentState, events.MemberStateTups(userIDs)) +} + +// txnGetRoomUserMemberships returns each user's membership of the room, leaving out users without +// one. While this server has no joined members, outlier membership rows take precedence over the +// room's current state. +func (r *RoomsDatabase) txnGetRoomUserMemberships(txn fdb.ReadTransaction, roomID id.RoomID, userIDs []id.UserID) (map[id.UserID]types.MembershipTup, error) { + room, err := roomOrNil(r.txnGetRoom(txn.Snapshot(), roomID)) + if err != nil { + return nil, err + } + localMembers := 0 + if room != nil { + localMembers = room.LocalMembers + } + rows := make(map[id.UserID]*types.MembershipRow) + if localMembers == 0 { + futures := make(map[id.UserID]fdb.FutureByteSlice, len(userIDs)) + for _, userID := range userIDs { + futures[userID] = txn.Get(r.users.KeyForMembership(userID, roomID)) + } + if rows, err = users.MembershipRowsOf(futures); err != nil { + return nil, err + } + } + var fromState []id.UserID + for _, userID := range userIDs { + if row := rows[userID]; localMembers > 0 || row == nil || !row.Outlier { + fromState = append(fromState, userID) + } + } + members, err := r.txnRoomMembers(txn, room, fromState) + if err != nil { + return nil, err + } + + memberships := make(map[id.UserID]types.MembershipTup, len(userIDs)) + for _, userID := range userIDs { + if row := rows[userID]; localMembers == 0 && row != nil && row.Outlier { + memberships[userID] = row.MembershipTup + } else if member := members[types.MemberStateTup(userID)]; member.EventID != "" { + memberships[userID] = types.MembershipTup{EventID: member.EventID, RoomID: roomID, Membership: member.Membership} + } + } + return memberships, nil } -func (r *RoomsDatabase) GetUserMembershipAndEvent(ctx context.Context, userID id.UserID, roomID id.RoomID) (*types.MembershipTup, *types.Event, error) { +// txnUsersJoined reports whether each user is joined to the room, see txnGetRoomUserMemberships +func (r *RoomsDatabase) txnUsersJoined( + txn fdb.ReadTransaction, + roomID id.RoomID, + userIDs []id.UserID, +) (map[id.UserID]bool, error) { + memberships, err := r.txnGetRoomUserMemberships(txn, roomID, userIDs) + if err != nil { + return nil, err + } + joined := make(map[id.UserID]bool, len(userIDs)) + for userID, membership := range memberships { + joined[userID] = membership.Membership == event.MembershipJoin + } + return joined, nil +} + +// GetCurrentMembershipAndEvent returns the user's membership of the room, see txnGetRoomUserMemberships, +// and its event, nil for none. +func (r *RoomsDatabase) GetCurrentMembershipAndEvent(ctx context.Context, userID id.UserID, roomID id.RoomID) (*types.MembershipTup, *types.Event, error) { var ev *types.Event mtup, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (*types.MembershipTup, error) { - mtup := r.users.TxnGetMembership(txn, userID, roomID) - if mtup == nil { + memberships, err := r.txnGetRoomUserMemberships(txn, roomID, []id.UserID{userID}) + if err != nil { + return nil, err + } + mtup, found := memberships[userID] + if !found { return nil, nil } - ev = r.events.TxnGetEvent(txn, mtup.EventID) - if ev == nil { - panic(fmt.Errorf("missing membership event: %s", mtup.EventID)) + if ev = r.events.TxnGetEvent(txn, mtup.EventID); ev == nil { + return nil, fmt.Errorf("missing membership event: %s", mtup.EventID) } - return mtup, nil + return &mtup, nil }) return mtup, ev, err } +// GetUserMemberships returns the user's memberships: a local user's rows, or for a remote user, who +// has none, their members in the rooms this server shares with their server, see remoteUserMemberships. func (r *RoomsDatabase) GetUserMemberships(ctx context.Context, userID id.UserID) (types.Memberships, error) { + if !r.isLocalUser(userID) { + return r.remoteUserMemberships(ctx, userID, false) + } return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.Memberships, error) { return r.users.TxnLookupUserMemberships(txn, userID), nil }) @@ -51,27 +137,214 @@ func (r *RoomsDatabase) GetUserJoinedMemberships(ctx context.Context, userID id. }), nil } +// JoinedRooms returns the rooms the user is joined to, see GetUserMemberships +func (r *RoomsDatabase) JoinedRooms(ctx context.Context, userID id.UserID) ([]id.RoomID, error) { + memberships, err := r.GetUserJoinedMemberships(ctx, userID) + if err != nil { + return nil, err + } + return slices.Sorted(maps.Keys(memberships)), nil +} + +// GetUserJoinedMembershipsWithEncryption returns the user's joined memberships, see +// GetUserMemberships, of rooms whose current state holds an encryption event func (r *RoomsDatabase) GetUserJoinedMembershipsWithEncryption(ctx context.Context, userID id.UserID) (types.Memberships, error) { + if !r.isLocalUser(userID) { + return r.remoteUserMemberships(ctx, userID, true) + } return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.Memberships, error) { - memberships := r.users.TxnLookupUserMemberships(txn, userID) - memberships = lo.PickBy(memberships, func(uid id.RoomID, mtup types.MembershipTup) bool { + memberships := lo.PickBy(r.users.TxnLookupUserMemberships(txn, userID), func(_ id.RoomID, mtup types.MembershipTup) bool { return mtup.Membership == event.MembershipJoin }) - return r.events.TxnFilterMembershipsWithEncryption(txn, memberships) + futures := make(map[id.RoomID]fdb.FutureByteSlice, len(memberships)) + for roomID := range memberships { + futures[roomID] = txn.Get(r.KeyForRoom(roomID)) + } + for roomID, future := range futures { + if roomBytes, err := future.Get(); err != nil { + return nil, err + } else if roomBytes == nil || !types.MustNewRoomFromBytes(roomBytes, roomID).Encrypted { + delete(memberships, roomID) + } + } + return memberships, nil + }) +} + +// UsersSharingEncryptedRoom reports which of the users share an encrypted room with a local user: +// those joined in the current state of an encrypted room the local user's rows hold them joined to. +// It looks every user up in each such room, see lookupRoomMembers, and never reads another user's +// rooms. +func (r *RoomsDatabase) UsersSharingEncryptedRoom(ctx context.Context, localUserID id.UserID, userIDs []id.UserID) (map[id.UserID]bool, error) { + if !r.isLocalUser(localUserID) { + return nil, fmt.Errorf("%s is not a local user", localUserID) + } + encrypted, err := r.GetUserJoinedMembershipsWithEncryption(ctx, localUserID) + if err != nil { + return nil, err + } + rooms, err := r.lookupRoomMembers(ctx, slices.Collect(maps.Keys(encrypted)), userIDs) + if err != nil { + return nil, err + } + joined := make(map[id.UserID]bool) + for _, lookup := range rooms { + if !lookup.room.Encrypted { + continue + } + for tup, member := range lookup.members { + if member.Membership == event.MembershipJoin { + joined[id.UserID(tup.StateKey)] = true + } + } + } + return joined, nil +} + +// Rooms whose current state one read transaction looks users up in, and such transactions run at once +const ( + roomLookupChunk = 50 + roomLookupConcurrency = 8 +) + +// roomMembersLookup is a room's record and the entries of the users looked up in its current state +type roomMembersLookup struct { + room *types.Room + members types.StateEntries +} + +// remoteUserMemberships looks a remote user up in the current state of each room this server shares +// with the user's server, since remote users have no membership rows. It reads a room record and +// member entry per shared room, optionally keeping only joins in encrypted rooms. +func (r *RoomsDatabase) remoteUserMemberships(ctx context.Context, userID id.UserID, encryptedJoinsOnly bool) (types.Memberships, error) { + shared, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.Memberships, error) { + return r.servers.TxnLookupServerMemberships(txn, userID.Homeserver()) + }) + if err != nil { + return nil, err + } + rooms, err := r.lookupRoomMembers(ctx, slices.Collect(maps.Keys(shared)), []id.UserID{userID}) + if err != nil { + return nil, err + } + memberships := make(types.Memberships, len(rooms)) + memberTup := types.MemberStateTup(userID) + for roomID, lookup := range rooms { + member := lookup.members[memberTup] + if member.EventID == "" || encryptedJoinsOnly && (!lookup.room.Encrypted || member.Membership != event.MembershipJoin) { + continue + } + memberships[roomID] = types.MembershipTup{EventID: member.EventID, RoomID: roomID, Membership: member.Membership} + } + return memberships, nil +} + +// lookupRoomMembers looks users up in the current state of each room this server is joined to, see +// inRoomChunks. Rooms without a record or this server's joined members are left out. +func (r *RoomsDatabase) lookupRoomMembers(ctx context.Context, roomIDs []id.RoomID, userIDs []id.UserID) (map[id.RoomID]roomMembersLookup, error) { + return inRoomChunks(ctx, roomIDs, func(ctx context.Context, chunk []id.RoomID) (map[id.RoomID]roomMembersLookup, error) { + return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (map[id.RoomID]roomMembersLookup, error) { + return r.txnLookupRoomMembers(txn, chunk, userIDs) + }) }) } +// inRoomChunks reads the rooms roomLookupChunk at a time, each chunk in a read transaction of its +// own, roomLookupConcurrency at once, and merges what they return. The first error stops it. +func inRoomChunks[T any]( + ctx context.Context, + roomIDs []id.RoomID, + read func(ctx context.Context, chunk []id.RoomID) (map[id.RoomID]T, error), +) (map[id.RoomID]T, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + found := make(map[id.RoomID]T, len(roomIDs)) + var ( + wg sync.WaitGroup + mu sync.Mutex + firstErr error + ) + running := make(chan struct{}, roomLookupConcurrency) + for chunk := range slices.Chunk(slices.Sorted(slices.Values(roomIDs)), roomLookupChunk) { + running <- struct{}{} + if ctx.Err() != nil { + break + } + wg.Go(func() { + defer func() { <-running }() + chunkFound, err := read(ctx, chunk) + mu.Lock() + defer mu.Unlock() + if err != nil && firstErr == nil { + firstErr = err + cancel() + } + maps.Copy(found, chunkFound) + }) + } + wg.Wait() + if firstErr == nil { + firstErr = ctx.Err() + } + return found, firstErr +} + +func (r *RoomsDatabase) txnLookupRoomMembers( + txn fdb.ReadTransaction, + roomIDs []id.RoomID, + userIDs []id.UserID, +) (map[id.RoomID]roomMembersLookup, error) { + futures := make([]fdb.FutureByteSlice, len(roomIDs)) + for i, roomID := range roomIDs { + futures[i] = txn.Get(r.KeyForRoom(roomID)) + } + found := make(map[id.RoomID]roomMembersLookup, len(roomIDs)) + for i, roomID := range roomIDs { + roomBytes, err := futures[i].Get() + if err != nil { + return nil, err + } else if roomBytes == nil { + continue + } + room, err := types.NewRoomFromBytes(roomBytes, roomID) + if err != nil { + return nil, err + } else if !isServerJoined(room) { + continue + } + members, err := r.txnRoomMembers(txn, room, userIDs) + if err != nil { + return nil, err + } + found[roomID] = roomMembersLookup{room: room, members: members} + } + return found, nil +} + func (r *RoomsDatabase) WasUserJoinedRoomAtEvent(ctx context.Context, userID id.UserID, roomID id.RoomID, evID id.EventID) (bool, error) { return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (bool, error) { - eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) - stateMap := r.events.TxnLookupSpecificRoomMemberStateMapAtEvent(ctx, txn, roomID, []id.UserID{userID}, evID, eventsProvider) - ev := eventsProvider.MustGet(stateMap[types.StateTup{ - Type: event.StateMember, - StateKey: userID.String(), - }]) - if ev == nil { + ev := r.events.TxnGetEvent(txn, evID) + if !ev.HasStateIn(roomID) { return false, nil } - return ev.Membership() == event.MembershipJoin, nil + // Check the state after the event, so the user's own join is included, and also check the + // state before if we're looking at our own membership, so we can see our own leave/ban. + stateCtxs := []types.StateHash{ev.AfterState} + isOwnMembership := ev.Type == event.StateMember && ev.StateKey != nil && *ev.StateKey == userID.String() + if isOwnMembership && !ev.BeforeState.IsZero() { + stateCtxs = append(stateCtxs, ev.BeforeState) + } + + stateBatch := r.state.NewBatch(roomID) + memberTups := events.MemberStateTups([]id.UserID{userID}) + for _, stateCtx := range stateCtxs { + members, err := stateBatch.TxnLookupEntries(txn, stateCtx, memberTups) + if err != nil { + return false, err + } else if members[memberTups[0]].Membership == event.MembershipJoin { + return true, nil + } + } + return false, nil }) } diff --git a/internal/routes/client/keys.go b/internal/routes/client/keys.go index 384568a..f281aee 100644 --- a/internal/routes/client/keys.go +++ b/internal/routes/client/keys.go @@ -549,15 +549,13 @@ func (c *ClientRoutes) GetKeyChanges(w http.ResponseWriter, r *http.Request) { userIDsInterestedIn[userID] = struct{}{} for roomID := range memberships { - memberIDs, err := c.db.Rooms.GetCurrentRoomMemberships(r.Context(), roomID) + members, err := c.db.Rooms.RoomMembers(r.Context(), roomID, event.MembershipJoin) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return } - for memberID, membershipTup := range memberIDs { - if membershipTup.Membership == event.MembershipJoin { - userIDsInterestedIn[memberID] = struct{}{} - } + for memberID := range members { + userIDsInterestedIn[memberID] = struct{}{} } } diff --git a/internal/routes/client/roomevent.go b/internal/routes/client/roomevent.go index c9e7ab5..b0252b2 100644 --- a/internal/routes/client/roomevent.go +++ b/internal/routes/client/roomevent.go @@ -71,7 +71,7 @@ func (c *ClientRoutes) GetRoomStateEvent(w http.ResponseWriter, r *http.Request) return } - stateEv, err := c.db.Rooms.GetCurrentRoomStateEvent(r.Context(), roomID, types.StateTup{ + stateEv, err := c.db.Rooms.RoomStateEvent(r.Context(), roomID, types.StateTup{ Type: evType, StateKey: stateKey, }) @@ -100,7 +100,7 @@ func (c *ClientRoutes) GetRoomState(w http.ResponseWriter, r *http.Request) { return } - stateEvs, err := c.db.Rooms.GetCurrentRoomStateEvents(r.Context(), roomID) + stateEvs, err := c.db.Rooms.RoomState(r.Context(), roomID) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return @@ -123,7 +123,7 @@ func (c *ClientRoutes) GetRoomMembers(w http.ResponseWriter, r *http.Request) { return } - memberEvs, err := c.db.Rooms.GetCurrentRoomMemberEvents(r.Context(), roomID) + memberEvs, err := c.db.Rooms.RoomMemberEvents(r.Context(), roomID) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return @@ -144,7 +144,7 @@ func (c *ClientRoutes) GetJoinedMembers(w http.ResponseWriter, r *http.Request) return } - memberEvs, err := c.db.Rooms.GetCurrentRoomMemberEvents(r.Context(), roomID) + memberEvs, err := c.db.Rooms.RoomMemberEvents(r.Context(), roomID) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return diff --git a/internal/routes/debug/debugroom.go b/internal/routes/debug/debugroom.go index 64e655e..f31c87a 100644 --- a/internal/routes/debug/debugroom.go +++ b/internal/routes/debug/debugroom.go @@ -19,13 +19,13 @@ func (d *DebugRoutes) DebugGetRoom(w http.ResponseWriter, r *http.Request) { return } - servers, err := d.db.Rooms.GetCurrentRoomServers(r.Context(), roomID) + servers, err := d.db.Rooms.RoomServers(r.Context(), roomID) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return } - stateEvs, err := d.db.Rooms.GetCurrentRoomStateEvents(r.Context(), roomID) + stateEvs, err := d.db.Rooms.RoomState(r.Context(), roomID) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return diff --git a/internal/workers/compactnotificationiterator.go b/internal/workers/compactnotificationiterator.go index 2704971..9345230 100644 --- a/internal/workers/compactnotificationiterator.go +++ b/internal/workers/compactnotificationiterator.go @@ -119,7 +119,7 @@ func (n *CompactNotificationIterator) compactNotificationsForRoom(roomID id.Room } func (n *CompactNotificationIterator) compactNotificationsForRoomMembers(roomID id.RoomID) error { - memberships, err := n.db.Rooms.GetCurrentRoomLocalJoinedMemberships(n.ctx, roomID) + memberships, err := n.db.Rooms.LocalJoinedMembers(n.ctx, roomID) if err != nil { return err } diff --git a/internal/workers/eventsiterator.go b/internal/workers/eventsiterator.go index 418b0a0..4267ec6 100644 --- a/internal/workers/eventsiterator.go +++ b/internal/workers/eventsiterator.go @@ -127,7 +127,7 @@ func (e *EventsIterator) notifyFederationSenders(tups []types.EventTupWithVersio // Now get unique server names from those rooms serverNames := make(map[string]struct{}) for roomID := range roomIDs { - servers, err := e.db.Rooms.GetCurrentRoomServers(e.ctx, roomID) + servers, err := e.db.Rooms.RoomServers(e.ctx, roomID) if err != nil { return err } diff --git a/internal/workers/presencechangeiterator.go b/internal/workers/presencechangeiterator.go index 66865b7..fc5525f 100644 --- a/internal/workers/presencechangeiterator.go +++ b/internal/workers/presencechangeiterator.go @@ -163,12 +163,12 @@ func (p *PresenceChangeIterator) processRemotePresenceChange(lock lock.Lock, cha localUserIDs := make(map[id.UserID]struct{}, len(memberships)) for roomID := range memberships { - roomMemberships, err := p.db.Rooms.GetCurrentRoomMemberships(p.ctx, roomID) + roomMembers, err := p.db.Rooms.RoomMembers(p.ctx, roomID, event.MembershipJoin) if err != nil { return fmt.Errorf("failed to get room member IDs: %s: %w", roomID, err) } - for memberID, membershipTup := range roomMemberships { - if membershipTup.Membership == event.MembershipJoin && memberID.Homeserver() == p.config.ServerName { + for memberID := range roomMembers { + if memberID.Homeserver() == p.config.ServerName { localUserIDs[memberID] = struct{}{} } } @@ -214,15 +214,11 @@ func (p *PresenceChangeIterator) processLocalPresenceChange(lock lock.Lock, chan localUserIDs[change.Presence.UserID] = struct{}{} for roomID := range memberships { - roomMemberships, err := p.db.Rooms.GetCurrentRoomMemberships(p.ctx, roomID) + roomMembers, err := p.db.Rooms.RoomMembers(p.ctx, roomID, event.MembershipJoin) if err != nil { return fmt.Errorf("failed to get room member IDs: %s: %w", roomID, err) } - for memberID, membershipTup := range roomMemberships { - if membershipTup.Membership != event.MembershipJoin { - // We only care about joined members - continue - } + for memberID := range roomMembers { if memberID.Homeserver() == p.config.ServerName { localUserIDs[memberID] = struct{}{} } else { diff --git a/internal/workers/pushnotificationiterator.go b/internal/workers/pushnotificationiterator.go index ab831cb..a5143b4 100644 --- a/internal/workers/pushnotificationiterator.go +++ b/internal/workers/pushnotificationiterator.go @@ -143,7 +143,7 @@ func (n *PushNotificationIterator) sendPushNotificationsForEvents(tups []types.E for roomID, eventTups := range eventsByRoom { // Get local joined users in room - memberships, err := n.db.Rooms.GetCurrentRoomLocalJoinedMemberships(n.ctx, roomID) + memberships, err := n.db.Rooms.LocalJoinedMembers(n.ctx, roomID) if err != nil { return err } @@ -289,7 +289,7 @@ func (n *PushNotificationIterator) getSenderDisplayName(ev *types.Event) string func (n *PushNotificationIterator) getRoomName(roomID id.RoomID) string { // Try to get room name from state - roomNameEvent, err := n.db.Rooms.GetCurrentRoomStateEvent(n.ctx, roomID, types.StateTup{ + roomNameEvent, err := n.db.Rooms.RoomStateEvent(n.ctx, roomID, types.StateTup{ Type: event.StateRoomName, StateKey: "", }) From c3737ac23b2e15bc86ee9741e4ea071d013d0e55 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 5 Oct 2026 12:49:43 +0100 Subject: [PATCH 08/12] Derive sync state and federation ranges from published history Build client state sections from immutable room-state context diffs at the response bounds. Keep legacy timeline-start state and resolved changes missing from the timeline, and select one client membership per room. Capture destination and local server memberships with the sync version, then read both change histories through that version. Federate each period when both servers were joined, draining pending events and receipts before advancing past a leave to a later rejoin. Keep streaming cursors within the events, receipts and membership intervals actually read. Handle versionstamp predecessors across transaction boundaries and tolerate partial sync filters. Validation: 12 focused room sync tests passed; versionstamp boundary tests passed during review. --- internal/databases/rooms/sync.go | 521 +++++++++++++++-------- internal/databases/rooms/sync_test.go | 573 ++++++++++++++++++++++++++ internal/databases/rooms/synctypes.go | 23 ++ internal/types/sync.go | 7 +- internal/types/version.go | 17 +- internal/types/version_test.go | 61 ++- 6 files changed, 1014 insertions(+), 188 deletions(-) create mode 100644 internal/databases/rooms/sync_test.go diff --git a/internal/databases/rooms/sync.go b/internal/databases/rooms/sync.go index 4ca9287..d9d5ba9 100644 --- a/internal/databases/rooms/sync.go +++ b/internal/databases/rooms/sync.go @@ -2,6 +2,7 @@ package rooms import ( "context" + "errors" "fmt" "slices" "sync" @@ -13,6 +14,7 @@ import ( "maunium.net/go/mautrix/id" "github.com/beeper/babbleserv/internal/databases/rooms/events" + "github.com/beeper/babbleserv/internal/databases/rooms/state" "github.com/beeper/babbleserv/internal/types" "github.com/beeper/babbleserv/internal/util" ) @@ -38,10 +40,7 @@ func (r *RoomsDatabase) SyncRoomsForUser( return r.users.TxnLookupUserMembershipChanges(txn, userID, options), nil }, func(txn fdb.ReadTransaction, roomID id.RoomID, options types.PaginationOptions, eventsProvider *events.TxnEventsProvider) []types.EventTupWithVersion { - return r.events.TxnPaginateRoomEventTups(txn, roomID, options, eventsProvider, syncOpts.GetTimelineFilter()) - }, - func(txn fdb.ReadTransaction, roomID id.RoomID, options types.PaginationOptions, eventsProvider *events.TxnEventsProvider) []types.EventStateTupWithVersion { - return r.events.TxnPaginateRoomStateEventTups(txn, roomID, options, eventsProvider) + return r.events.TxnPaginateRoomEventTups(txn, roomID, options, eventsProvider) }, func(txn fdb.ReadTransaction, roomID id.RoomID, options types.PaginationOptions) ([]*types.ReceiptWithVersion, error) { rcs, err := r.receipts.TxnPaginateRoomReceipts(txn, roomID, options) @@ -78,10 +77,7 @@ func (r *RoomsDatabase) SyncRoomsForServer( return r.servers.TxnLookupServerMembershipChanges(txn, serverName, options) }, func(txn fdb.ReadTransaction, roomID id.RoomID, options types.PaginationOptions, eventsProvider *events.TxnEventsProvider) []types.EventTupWithVersion { - return r.events.TxnPaginateLocalRoomEventTups(txn, roomID, options, eventsProvider, syncOpts.GetTimelineFilter()) - }, - func(txn fdb.ReadTransaction, roomID id.RoomID, options types.PaginationOptions, eventsProvider *events.TxnEventsProvider) []types.EventStateTupWithVersion { - panic("server sync should never have gaps in state") + return r.events.TxnPaginateLocalRoomEventTups(txn, roomID, options, eventsProvider) }, func(txn fdb.ReadTransaction, roomID id.RoomID, options types.PaginationOptions) ([]*types.ReceiptWithVersion, error) { return r.receipts.TxnPaginateLocalRoomReceipts(txn, roomID, options) @@ -98,7 +94,6 @@ func (r *RoomsDatabase) syncRoomEvents( getCurrentMembershipsFunc func(fdb.ReadTransaction) (types.Memberships, error), getMembershipChanges func(fdb.ReadTransaction, types.PaginationOptions) (types.MembershipChanges, error), paginateRoomEvents func(fdb.ReadTransaction, id.RoomID, types.PaginationOptions, *events.TxnEventsProvider) []types.EventTupWithVersion, - paginateRoomStateEvents func(fdb.ReadTransaction, id.RoomID, types.PaginationOptions, *events.TxnEventsProvider) []types.EventStateTupWithVersion, paginateRoomReceipts func(fdb.ReadTransaction, id.RoomID, types.PaginationOptions) ([]*types.ReceiptWithVersion, error), ) (tuple.Versionstamp, map[types.MembershipTup]*types.SyncRoom, error) { // First stage - select the rooms, data and version range of each, we're going to pull for this @@ -109,9 +104,12 @@ func (r *RoomsDatabase) syncRoomEvents( // for membership changes between since -> latest and appropriately trim the version ranges of // those rooms as we join/unjoin the room. + isInitSync := fromVersion == types.ZeroVersionstamp + isServerSync := options.IsServerToServer + // Get the latest rooms database version, the current memberships and the latest version of each // room in a single txn so they are all consistent with each other. - var memberships types.Memberships + var memberships, localMemberships types.Memberships var roomVersions map[id.RoomID]tuple.Versionstamp latestVersion, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (tuple.Versionstamp, error) { latestVersion := util.TxnGetLatestWriteVersion(txn) @@ -120,18 +118,19 @@ func (r *RoomsDatabase) syncRoomEvents( if err != nil { return latestVersion, err } - roomVersions, err = r.txnGetVersionsForMemberships(txn, memberships) - if err != nil { - return latestVersion, err + if isServerSync { + localMemberships, err = r.servers.TxnLookupServerMemberships(txn, r.config.ServerName) + if err != nil { + return latestVersion, err + } } - return latestVersion, nil + roomVersions, err = r.txnGetVersionsForMemberships(txn, memberships) + return latestVersion, err }) if err != nil { return types.ZeroVersionstamp, nil, err } - isInitSync := fromVersion == types.ZeroVersionstamp - // Calculate map of room membership -> sync config roomsToSync := make(map[types.MembershipTup]*roomSyncConfig, len(memberships)) if options.Mode == types.SyncModeSliding { @@ -176,48 +175,74 @@ func (r *RoomsDatabase) syncRoomEvents( // If not init: Get membership changes options.From -> toVersion if !isInitSync { + var localChanges types.MembershipChanges membershipChanges, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.MembershipChanges, error) { - return getMembershipChanges(txn, types.PaginationOptions{ - From: fromVersion, - To: latestVersion, - }) + options := types.PaginationOptions{From: fromVersion, To: latestVersion} + changes, err := getMembershipChanges(txn, options) + if err != nil { + return nil, err + } + if isServerSync { + localChanges, err = r.servers.TxnLookupServerMembershipChanges(txn, r.config.ServerName, options) + if err != nil { + return nil, err + } + } + return changes, nil }) if err != nil { return types.ZeroVersionstamp, nil, err } - for _, membershipChange := range membershipChanges { + joinEnds := joinEntryEnds(membershipChanges, latestVersion) + for i, membershipChange := range membershipChanges { if _, found := roomsToSync[membershipChange.MembershipTup]; !found { // This means we're not joined to the room currently, so place an empty sync config // as we have to at least pass the membership event down. roomsToSync[membershipChange.MembershipTup] = &roomSyncConfig{} } roomConfig := roomsToSync[membershipChange.MembershipTup] + roomConfig.changedAt = membershipChange.Version switch membershipChange.Membership { case event.MembershipJoin: - // This means we joined at this point, after the since token, so fetch events from, - // and including, the membership join and flag the room as initial. - roomConfig.from = types.VersionstampBefore(membershipChange.Version) // include the membership event itself - roomConfig.to = latestVersion - if !options.IsServerToServer { + // Joined after since. A client gets the room as initial from the join on. A server gets + // the events after the join it was recorded with, which it has: its own, or our remote + // join, which the resident server sends to the room. + roomConfig.to = joinEnds[i] + if isServerSync { + roomConfig.from, roomConfig.server.serverJoined = fromVersion, membershipChange.Version + } else { + // Only the zero versionstamp has no predecessor, and a zero from is the room's start + roomConfig.from, _ = types.VersionstampBefore(membershipChange.Version) roomConfig.isInitial = true } case event.MembershipLeave, event.MembershipBan: // This means we left at this point, after the since token, so fetch events until // the leave event. - // TODO: unused currently, since non-joins -> syncRoomNotJoinedTups + // TODO: unused currently; non-joined rooms only include the membership event. roomConfig.from = fromVersion roomConfig.to = membershipChange.Version } } + + if isServerSync { + serverChangesByRoom, localChangesByRoom := changesByRoom(membershipChanges), changesByRoom(localChanges) + for membershipTup, roomConfig := range roomsToSync { + roomID := membershipTup.RoomID + roomConfig.server.serverChanges = serverChangesByRoom[roomID] + roomConfig.server.serverJoinedNow = memberships[roomID].Membership == event.MembershipJoin + roomConfig.server.localChanges = localChangesByRoom[roomID] + roomConfig.server.localJoinedNow = localMemberships[roomID].Membership == event.MembershipJoin + } + } } // Second stage - find the event IDs and receipts to send down for each room. Depends on the // input room range and sync mode. // - if streaming: fetch oldest -> newest per room, up to - // - if sliding or legacy: fetch most recent +1 per our version range, if we have - // the +1, we also need to fetch room state events sent from since -> oldest timeline event. + // - if sliding or legacy: fetch most recent +1 per our version range, the +1 + // telling a limited timeline // For receipts in either case we just get everything in the range. roomResults := make(map[types.MembershipTup]*roomSyncResult, len(roomsToSync)) @@ -230,47 +255,52 @@ func (r *RoomsDatabase) syncRoomEvents( roomResults[membershipTup] = res if membershipTup.Membership != event.MembershipJoin { - // For non-joined rooms we just fetch the membership event - wg.Add(1) - go func(membershipTup types.MembershipTup, res *roomSyncResult) { - defer wg.Done() - if err := r.syncRoomNotJoinedTups(ctx, options, membershipTup, res); err != nil { - panic(err) - } - }(membershipTup, res) + res.eventStateTups = []types.EventStateTupWithVersion{{ + EventStateTup: types.EventStateTup{ + EventID: membershipTup.EventID, + StateTup: types.MemberStateTup(options.UserID), + }, + }} continue } - wg.Add(1) - go func(membershipTup types.MembershipTup, res *roomSyncResult) { - defer wg.Done() - // Mutates: res.eventTups + res.eventStateTups + res.limited, fetch event ID tups in range - if err := r.syncRoomEventTups(ctx, options, membershipTup, res, paginateRoomEvents, paginateRoomStateEvents); err != nil { + wg.Go(func() { + // Mutates: res.eventTups + res.limited, fetch event ID tups in range, after a server's + // range of the room is applied, so receipts follow + if err := r.syncRoomEventTups(ctx, options, membershipTup, res, paginateRoomEvents); err != nil { panic(fmt.Errorf("failed to sync room events: %s: %w", membershipTup.RoomID, err)) + } else if !roomConfig.includeReceipts || res.denied { + return } - }(membershipTup, res) - - if roomConfig.includeReceipts { - wg.Add(1) - go func(membershipTup types.MembershipTup, res *roomSyncResult) { - defer wg.Done() - // Mutates: res.receipts - if err := r.syncRoomReceipts(ctx, options, membershipTup, res, paginateRoomReceipts); err != nil { - panic(fmt.Errorf("failed to sync room receipts: %s: %w", membershipTup.RoomID, err)) - } - }(membershipTup, res) - } + // Mutates: res.receipts + if err := r.syncRoomReceipts(ctx, options, membershipTup, res, paginateRoomReceipts); err != nil { + panic(fmt.Errorf("failed to sync room receipts: %s: %w", membershipTup.RoomID, err)) + } + }) } // Wait for all the EventTups+Receipts to be fetched wg.Wait() + for membershipTup, res := range roomResults { + if res.denied { + delete(roomResults, membershipTup) + } + } // If streaming: combine them all together, grab the first and drop the rest. Then we // update the latest position to that of the latest event in the selected list. This means we're // over-paginating event ID tups when the since token is far behind now. Edge-case-y enough not // to be a big concern. if options.Mode == types.SyncModeStreaming && !isInitSync { - latestVersion = r.filterStreamingSyncTups(options, roomResults, latestVersion) + latestVersion = r.filterStreamingSyncTups(options, roomResults, fromVersion, latestVersion) + } + + if !options.IsServerToServer { + filterClientSyncRooms(roomResults) + // Mutates: res.eventStateTups + if err := r.syncRoomsStateTups(ctx, options, roomResults); err != nil { + return types.ZeroVersionstamp, nil, err + } } // Now that we have our final set of rooms, fetch the events! @@ -278,32 +308,20 @@ func (r *RoomsDatabase) syncRoomEvents( _, err = util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (*struct{}, error) { eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) - idToVersion := make(map[id.EventID]tuple.Versionstamp, 10) transactionIDs := make(map[id.EventID]fdb.FutureByteSlice) device := types.UserDevice{UserID: options.UserID, DeviceID: options.DeviceID} - // First pass: start fetching all the timeline events + // First pass: start fetching all the timeline and state events for _, room := range roomResults { - roomEvIDs := make(map[id.EventID]struct{}, len(room.eventTups)) for _, tup := range room.eventTups { eventsProvider.WillGet(tup.EventID) - idToVersion[tup.EventID] = tup.Version - roomEvIDs[tup.EventID] = struct{}{} if !options.IsServerToServer && options.DeviceID != "" && tup.Sender == options.UserID { transactionIDs[tup.EventID] = txn.Get(r.keyForEventTransactionID(tup.EventID, device)) } } - - // Now get any state events not in timeline, update the state event IDs to only those - newEventStateTups := make([]types.EventStateTupWithVersion, 0, len(room.eventStateTups)) for _, tup := range room.eventStateTups { - if _, found := roomEvIDs[tup.EventID]; !found { - eventsProvider.WillGet(tup.EventID) - idToVersion[tup.EventID] = tup.Version - newEventStateTups = append(newEventStateTups, tup) - } + eventsProvider.WillGet(tup.EventID) } - room.eventStateTups = newEventStateTups } // Second pass: apply the changes @@ -314,12 +332,12 @@ func (r *RoomsDatabase) syncRoomEvents( if future, ok := transactionIDs[tup.EventID]; ok { timeline[i].ClientTransactionID = string(future.MustGet()) } - timeline[i].SetUnsigned("hs.order", types.MustVersionstampToString(idToVersion[tup.EventID])) + timeline[i].SetUnsigned("hs.order", types.MustVersionstampToString(tup.Version)) } state := make([]*types.Event, len(result.eventStateTups)) for i, tup := range result.eventStateTups { state[i] = eventsProvider.MustGet(tup.EventID) - state[i].SetUnsigned("hs.order", types.MustVersionstampToString(idToVersion[tup.EventID])) + state[i].SetUnsigned("hs.order", types.MustVersionstampToString(tup.Version)) } syncRoom := &types.SyncRoom{ @@ -365,32 +383,12 @@ func (r *RoomsDatabase) syncRoomEvents( return latestVersion, rooms, err } -func (r *RoomsDatabase) syncRoomNotJoinedTups( - ctx context.Context, - options types.SyncOptions, - membershipTup types.MembershipTup, - res *roomSyncResult, -) error { - // Super simple: just include the membership event itself as a state event - res.eventStateTups = []types.EventStateTupWithVersion{{ - EventStateTup: types.EventStateTup{ - EventID: membershipTup.EventID, - StateTup: types.StateTup{ - Type: event.StateMember, - StateKey: options.UserID.String(), - }, - }, - }} - return nil -} - func (r *RoomsDatabase) syncRoomEventTups( ctx context.Context, options types.SyncOptions, membershipTup types.MembershipTup, res *roomSyncResult, paginateRoomEvents func(fdb.ReadTransaction, id.RoomID, types.PaginationOptions, *events.TxnEventsProvider) []types.EventTupWithVersion, - paginateRoomStateEvents func(fdb.ReadTransaction, id.RoomID, types.PaginationOptions, *events.TxnEventsProvider) []types.EventStateTupWithVersion, ) error { reverse := true limit := options.GetTimelineLimit() @@ -414,9 +412,15 @@ func (r *RoomsDatabase) syncRoomEventTups( Int("limit", limit). Msg("Paginating room events for sync") - // First grab the event IDs, most recent first unless streaming incremental + // Grab the event IDs, most recent first unless streaming incremental + var page []types.EventTupWithVersion + if options.IsServerToServer { + if res.denied = !applyServerSyncRange(res); res.denied { + return nil + } + } _, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.Nil, error) { - tups := paginateRoomEvents(txn, membershipTup.RoomID, types.PaginationOptions{ + page = paginateRoomEvents(txn, membershipTup.RoomID, types.PaginationOptions{ From: res.from, To: res.to, Reverse: reverse, @@ -424,93 +428,257 @@ func (r *RoomsDatabase) syncRoomEventTups( }, nil) if reverse { // Switch the timeline back to old -> new order - slices.Reverse(tups) + slices.Reverse(page) } - res.eventTups = tups return nil, nil }) - if err != nil { - return err + if err == nil { + takeTimelinePage(options, res, page, limit) } + return err +} - if res.isInitial { - // Handle newly seen rooms (either init sync or newly joined room): fetch all current state - // event IDs. Technically incorrect since this will fetch current, at time of transaction, - // state, rather than at the time of latestVersion. In reality this means there's a small - // chance state events might be delivered twice (once in this sync, once in the next) for - // this room. Since clients need to handle double-delivery of events anyway, who cares. - var stateEventID id.EventID - _, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.Nil, error) { - stateMap := r.events.TxnLookupCurrentRoomStateMap(txn, membershipTup.RoomID, nil) - // TODO: lazy loading members - memberMap := r.events.TxnLookupCurrentRoomMemberStateMap(txn, membershipTup.RoomID, nil) - stateTups := make([]types.EventStateTupWithVersion, 0, len(stateMap)+len(memberMap)) - for stateTup, evID := range stateMap { - if stateTup.Type == event.StateCreate { - stateEventID = evID +// applyServerSyncRange replaces a room's range in a server's sync by the one serverSyncRange gives, +// reporting false when it gives none and the room is left out. +func applyServerSyncRange(res *roomSyncResult) bool { + room := res.server + room.since, room.to = res.from, res.to + from, to, ok := serverSyncRange(room) + if ok { + res.from, res.to = from, to + if types.VersionIsBefore(to, room.to) { + // Stop here if a later joined interval still needs reading, even with a short page. + for _, change := range room.localChanges { + if !types.VersionIsBefore(change.Version, room.to) { + break } - stateTups = append(stateTups, types.EventStateTupWithVersion{ - Version: res.to, - EventStateTup: types.EventStateTup{ - EventID: evID, - StateTup: stateTup, - }, - }) - } - res.eventStateTups = stateTups - - // Initial syncs are always limited unless we have the create event in the timeline - if stateEventID != "" { - var hasCreateEvent bool - for _, tup := range res.eventTups { - if tup.EventID == stateEventID { - hasCreateEvent = true - break - } - } - if !hasCreateEvent { - res.limited = true + if change.Membership == event.MembershipJoin && types.VersionIsAfter(change.Version, to) { + res.intervalEnd = to + break } } - return nil, nil - }) - return err + } } + return ok +} - if options.Mode == types.SyncModeStreaming { - // Streaming mode is simple - there's no state gap ever - return nil +func changesByRoom(changes types.MembershipChanges) map[id.RoomID]types.MembershipChanges { + byRoom := make(map[id.RoomID]types.MembershipChanges) + for _, change := range changes { + byRoom[change.RoomID] = append(byRoom[change.RoomID], change) } + return byRoom +} - if len(res.eventTups) == 0 { - // If we have no timeline we don't need to fetch state either, this might happen if a rooms - // version increases due to a receipt and thus there's no new events. - return nil - } else if len(res.eventTups) == limit { - // We overfetched the timeline to indicate a gappy/limited sync - res.limited = true - res.eventTups = res.eventTups[1:] +// joinEntryEnds returns where the range of each of a sync's membership changes ends if it is a join: +// before the room's next change in the sync that is not a join, or at the sync's end, so no events +// after a leave are sent. +func joinEntryEnds(changes types.MembershipChanges, end tuple.Versionstamp) []tuple.Versionstamp { + ends := make([]tuple.Versionstamp, len(changes)) + next := make(map[id.RoomID]tuple.Versionstamp) + for i := len(changes) - 1; i >= 0; i-- { + ends[i] = end + if leave, found := next[changes[i].RoomID]; found { + ends[i], _ = types.VersionstampBefore(leave) + } + if changes[i].Membership != event.MembershipJoin { + next[changes[i].RoomID] = changes[i].Version + } } + return ends +} - // Fetch state tups: - // - if sliding sync OR MSC4222 enabled all state changes from -> end of timeline - // - if legacy sync without MSC4222 all state changes from -> start of timeline - _, err = util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (*struct{}, error) { - to := res.eventTups[len(res.eventTups)-1].Version - if options.Mode == types.SyncModeLegacy && !options.EnableLegacyStateAfter { - to = res.eventTups[0].Version +// joinedAt reports whether a server was joined to a room at a version, from its first membership +// change of the room after it, or whether it is joined now without one +func joinedAt(version tuple.Versionstamp, changes types.MembershipChanges, joinedNow bool) bool { + for _, change := range changes { + if types.VersionIsAfter(change.Version, version) { + return change.Membership != event.MembershipJoin } - stateEvTups := paginateRoomStateEvents(txn, membershipTup.RoomID, types.PaginationOptions{ - From: res.from, - To: to, - Mode: fdb.StreamingModeWantAll, - }, nil) - res.eventStateTups = stateEvTups - return nil, nil + } + return joinedNow +} + +// Return the first (from, to] interval where both servers were joined, including our leave. +// Later rejoins are handled by subsequent syncs so we don't skip pending events before a leave. +func serverSyncRange(s serverRoomSync) (tuple.Versionstamp, tuple.Versionstamp, bool) { + if !joinedAt(s.to, s.serverChanges, s.serverJoinedNow) { + return s.since, s.to, false + } + from := s.since + if types.VersionIsAfter(s.serverJoined, from) { + from = s.serverJoined + } + localJoined := joinedAt(from, s.localChanges, s.localJoinedNow) + for _, change := range s.localChanges { + if types.VersionIsAfter(change.Version, s.to) { + break + } else if types.VersionIsAtOrBefore(change.Version, from) { + continue + } + if change.Membership == event.MembershipJoin { + if !localJoined { + from = change.Version + } + localJoined = true + } else if localJoined { + return from, change.Version, true + } + } + if localJoined && types.VersionIsBefore(from, s.to) { + return from, s.to, true + } + return s.since, s.to, false +} + +// takeTimelinePage makes a page of a room's events read up to limit, oldest first before applying +// any timeline filtering (so limited is accurate before filtering). +func takeTimelinePage(options types.SyncOptions, res *roomSyncResult, page []types.EventTupWithVersion, limit int) { + if len(page) == limit && !res.isInitial { + if options.Mode == types.SyncModeStreaming { + res.pageEnd = page[len(page)-1].Version + } else { + res.limited = true + page = page[1:] + } + } + filter := options.GetTimelineFilter() + res.eventTups = slices.DeleteFunc(page, func(tup types.EventTupWithVersion) bool { + return filter != nil && (len(filter.Types) > 0 && !slices.Contains(filter.Types, tup.Type) || + len(filter.Senders) > 0 && !slices.Contains(filter.Senders, tup.Sender) || + slices.Contains(filter.NotTypes, tup.Type) || + slices.Contains(filter.NotSenders, tup.Sender)) + }) +} + +// Keep one result per room using the latest membership entry - ie if the user leaves/rejoins inside +// the gap we just keep the join onwards. +func filterClientSyncRooms(roomResults map[types.MembershipTup]*roomSyncResult) { + latest := make(map[id.RoomID]types.MembershipTup, len(roomResults)) + for membershipTup, res := range roomResults { + previous, found := latest[membershipTup.RoomID] + if !found || types.VersionIsAfter(res.changedAt, roomResults[previous].changedAt) { + latest[membershipTup.RoomID] = membershipTup + } + } + for membershipTup := range roomResults { + if membershipTup != latest[membershipTup.RoomID] { + delete(roomResults, membershipTup) + } + } +} + +func (r *RoomsDatabase) syncRoomsStateTups( + ctx context.Context, + options types.SyncOptions, + roomResults map[types.MembershipTup]*roomSyncResult, +) error { + var wg sync.WaitGroup + var mu sync.Mutex + var errs []error + for membershipTup, res := range roomResults { + if membershipTup.Membership != event.MembershipJoin { + continue + } + wg.Go(func() { + if err := r.syncRoomState(ctx, options, membershipTup.RoomID, res); err != nil { + mu.Lock() + errs = append(errs, fmt.Errorf("failed to sync room state: %s: %w", membershipTup.RoomID, err)) + mu.Unlock() + } + }) + } + wg.Wait() + return errors.Join(errs...) +} + +func (r *RoomsDatabase) syncRoomState( + ctx context.Context, + options types.SyncOptions, + roomID id.RoomID, + res *roomSyncResult, +) error { + timeline := make(map[id.EventID]struct{}, len(res.eventTups)) + for _, tup := range res.eventTups { + timeline[tup.EventID] = struct{}{} + } + _, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (types.Nil, error) { + stateBatch := r.state.NewBatch(roomID) + var stateTups []types.EventStateTup + if res.isInitial { + // TODO: lazy loading members + atEnd, err := r.txnRoomStateAt(txn, roomID, res.to) + if err != nil { + return nil, err + } + stateMap, err := stateBatch.TxnIterateState(txn, atEnd) + if err != nil { + return nil, err + } + for stateTup, eventID := range stateMap { + if _, found := timeline[eventID]; !found { + stateTups = append(stateTups, types.EventStateTup{StateTup: stateTup, EventID: eventID}) + } + } + if createID := stateMap[types.StateTup{Type: event.StateCreate}]; createID != "" { + _, inTimeline := timeline[createID] + res.limited = !inTimeline + } + } else { + atFrom, err := r.txnRoomStateAt(txn, roomID, res.from) + if err != nil { + return nil, err + } + versions := []tuple.Versionstamp{res.to} + if len(res.eventTups) > 0 && options.Mode == types.SyncModeLegacy { + // Legacy needs state at the timeline's start, plus changes from resolving forks + // that aren't in the timeline. The end context supplies those missing changes. + versions = []tuple.Versionstamp{res.eventTups[0].Version, res.to} + } + tos := make([]types.StateHash, len(versions)) + for i, version := range versions { + if tos[i], err = r.txnRoomStateAt(txn, roomID, version); err != nil { + return nil, err + } + } + if stateTups, err = stateBatch.TxnStateSince(txn, atFrom, tos, timeline); err != nil { + return nil, err + } + } + var err error + res.eventStateTups, err = r.txnWithEventVersions(txn, stateTups) + return nil, err }) return err } +func (r *RoomsDatabase) txnRoomStateAt(txn fdb.ReadTransaction, roomID id.RoomID, version tuple.Versionstamp) (types.StateHash, error) { + stateCtx, found, err := r.events.TxnLookupRoomStateAt(txn, roomID, version) + if err != nil || found { + return stateCtx, err + } + return state.EmptyContext, nil +} + +func (r *RoomsDatabase) txnWithEventVersions(txn fdb.ReadTransaction, stateTups []types.EventStateTup) ([]types.EventStateTupWithVersion, error) { + futures := make([]fdb.FutureByteSlice, len(stateTups)) + for i, tup := range stateTups { + futures[i] = txn.Get(r.events.KeyForIDToVersion(tup.EventID)) + } + withVersions := make([]types.EventStateTupWithVersion, len(stateTups)) + for i, tup := range stateTups { + withVersions[i].EventStateTup = tup + if b, err := futures[i].Get(); err != nil { + return nil, err + } else if b != nil { + if withVersions[i].Version, err = types.BytesToVersionstamp(b); err != nil { + return nil, err + } + } + } + return withVersions, nil +} + func (r *RoomsDatabase) syncRoomReceipts( ctx context.Context, _ types.SyncOptions, @@ -563,18 +731,24 @@ func (r *RoomsDatabase) syncRoomReceipts( func (r *RoomsDatabase) filterStreamingSyncTups( options types.SyncOptions, roomResults map[types.MembershipTup]*roomSyncResult, - latestVersion tuple.Versionstamp, + from, latestVersion tuple.Versionstamp, ) tuple.Versionstamp { + endAt := func(version tuple.Versionstamp) { + if types.VersionIsAfter(version, from) && types.VersionIsBefore(version, latestVersion) { + latestVersion = version + } + } + timelineLimit := options.GetTimelineLimit() allEventTups := make([]types.EventTupWithVersion, 0, len(roomResults)*timelineLimit) for _, res := range roomResults { allEventTups = append(allEventTups, res.eventTups...) + endAt(res.pageEnd) + endAt(res.intervalEnd) } if len(allEventTups) > timelineLimit { - // Now, overwrite the latest version with the latest selected event tup types.SortVersioners(allEventTups) - allEventTups = allEventTups[:min(len(allEventTups), timelineLimit)] - latestVersion = allEventTups[len(allEventTups)-1].Version + endAt(allEventTups[timelineLimit-1].Version) } receiptsLimit := options.GetReceiptsLimit() @@ -583,18 +757,19 @@ func (r *RoomsDatabase) filterStreamingSyncTups( allReceipts = append(allReceipts, res.receipts...) } if len(allReceipts) > receiptsLimit { - // Limit also applies to receipts, overwrite latest version *if* older than the one - // already set. If newer we'll be trimming anything ahead already. types.SortVersioners(allReceipts) - allReceipts = allReceipts[:min(len(allReceipts), receiptsLimit)] - latestReceiptVersion := allReceipts[len(allReceipts)-1].Version - if types.VersionIsBefore(latestReceiptVersion, latestVersion) { - latestVersion = latestReceiptVersion - } + endAt(allReceipts[receiptsLimit-1].Version) } // Now go through each room result and remove events + receipts ahead of the version - for _, res := range roomResults { + for membershipTup, res := range roomResults { + if types.VersionIsAfter(res.changedAt, latestVersion) { + delete(roomResults, membershipTup) + continue + } + if types.VersionIsAfter(res.to, latestVersion) { + res.to = latestVersion + } filteredTups := make([]types.EventTupWithVersion, 0, len(res.eventTups)) for _, tup := range res.eventTups { if types.VersionIsAtOrBefore(tup.Version, latestVersion) { diff --git a/internal/databases/rooms/sync_test.go b/internal/databases/rooms/sync_test.go new file mode 100644 index 0000000..958083e --- /dev/null +++ b/internal/databases/rooms/sync_test.go @@ -0,0 +1,573 @@ +package rooms + +import ( + "maps" + "slices" + "testing" + + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +func syncTestVersion(transaction byte, user uint16) tuple.Versionstamp { + return tuple.Versionstamp{TransactionVersion: [10]byte{9: transaction}, UserVersion: user} +} + +func syncTestEvent(eventID id.EventID, evType event.Type, version tuple.Versionstamp) types.EventTupWithVersion { + return types.EventTupWithVersion{EventTup: types.EventTup{EventID: eventID, Type: evType}, Version: version} +} + +func streamingTestOptions(limit int) types.SyncOptions { + return types.SyncOptions{ + Mode: types.SyncModeStreaming, + Filter: &mautrix.Filter{Room: &mautrix.RoomFilter{ + Timeline: &mautrix.FilterPart{Limit: limit}, + Ephemeral: &mautrix.FilterPart{}, + }}, + } +} + +func TestTakeTimelinePageTellsAFullPageBeforeFiltering(t *testing.T) { + page := func() []types.EventTupWithVersion { + return []types.EventTupWithVersion{ + syncTestEvent("$one", event.EventMessage, syncTestVersion(1, 0)), + syncTestEvent("$filtered", event.NewEventType("please_filter_me"), syncTestVersion(2, 0)), + syncTestEvent("$three", event.EventMessage, syncTestVersion(3, 0)), + } + } + filter := &mautrix.Filter{Room: &mautrix.RoomFilter{Timeline: &mautrix.FilterPart{ + NotTypes: []event.Type{event.NewEventType("please_filter_me")}, + }}} + + streaming := &roomSyncResult{roomSyncConfig: &roomSyncConfig{}} + takeTimelinePage(types.SyncOptions{Mode: types.SyncModeStreaming, Filter: filter}, streaming, page(), 3) + assert.Equal(t, syncTestVersion(3, 0), streaming.pageEnd) + assert.Equal(t, []types.EventTupWithVersion{page()[0], page()[2]}, streaming.eventTups) + + legacy := &roomSyncResult{roomSyncConfig: &roomSyncConfig{}} + takeTimelinePage(types.SyncOptions{Mode: types.SyncModeLegacy, Filter: filter}, legacy, page(), 3) + assert.True(t, legacy.limited) + assert.Equal(t, []types.EventTupWithVersion{page()[2]}, legacy.eventTups, "the event read to tell a limited timeline is dropped") + + short := &roomSyncResult{roomSyncConfig: &roomSyncConfig{}} + takeTimelinePage(types.SyncOptions{Mode: types.SyncModeStreaming}, short, page(), 4) + assert.Equal(t, types.ZeroVersionstamp, short.pageEnd) + assert.Equal(t, page(), short.eventTups) + + initial := &roomSyncResult{roomSyncConfig: &roomSyncConfig{isInitial: true}} + takeTimelinePage(types.SyncOptions{Mode: types.SyncModeLegacy}, initial, page(), 3) + assert.False(t, initial.limited) + assert.Equal(t, types.ZeroVersionstamp, initial.pageEnd) + assert.Equal(t, page(), initial.eventTups) +} + +func TestFilterStreamingSyncTupsEndsRoomsAtTheTrimmedVersion(t *testing.T) { + from := syncTestVersion(0, types.MaxVersionstampUserVersion) + latest := syncTestVersion(9, types.MaxVersionstampUserVersion) + joined := &roomSyncResult{ + roomSyncConfig: &roomSyncConfig{from: from, to: latest}, + eventTups: []types.EventTupWithVersion{ + {Version: syncTestVersion(1, 0)}, {Version: syncTestVersion(3, 0)}, {Version: syncTestVersion(5, 0)}, + }, + } + newlyJoined := &roomSyncResult{ + roomSyncConfig: &roomSyncConfig{to: latest, isInitial: true}, + eventTups: []types.EventTupWithVersion{{Version: syncTestVersion(2, 1)}}, + } + left := &roomSyncResult{roomSyncConfig: &roomSyncConfig{to: syncTestVersion(1, 2)}} + options := streamingTestOptions(2) + + trimmed := (&RoomsDatabase{}).filterStreamingSyncTups(options, map[types.MembershipTup]*roomSyncResult{ + {RoomID: "!joined:example.com"}: joined, + {RoomID: "!newlyjoined:example.com"}: newlyJoined, + {RoomID: "!left:example.com"}: left, + }, from, latest) + + assert.Equal(t, syncTestVersion(2, 1), trimmed) + assert.Equal(t, []types.EventTupWithVersion{{Version: syncTestVersion(1, 0)}}, joined.eventTups) + assert.Equal(t, trimmed, joined.to) + assert.Equal(t, trimmed, newlyJoined.to) + assert.Equal(t, syncTestVersion(1, 2), left.to, "a room ending before the trimmed version keeps its end") +} + +func TestFilterStreamingSyncTupsEndsAtAFullPage(t *testing.T) { + from := syncTestVersion(0, types.MaxVersionstampUserVersion) + latest := syncTestVersion(9, types.MaxVersionstampUserVersion) + full := &roomSyncResult{ + roomSyncConfig: &roomSyncConfig{from: from, to: latest}, + eventTups: []types.EventTupWithVersion{{Version: syncTestVersion(1, 0)}, {Version: syncTestVersion(3, 0)}}, + pageEnd: syncTestVersion(4, 0), + } + + trimmed := (&RoomsDatabase{}).filterStreamingSyncTups(streamingTestOptions(3), map[types.MembershipTup]*roomSyncResult{ + {RoomID: "!full:example.com"}: full, + }, from, latest) + + assert.Equal(t, syncTestVersion(4, 0), trimmed, "a full page within the limit still ends the sync") + assert.Equal(t, trimmed, full.to) + assert.Len(t, full.eventTups, 2) +} + +func TestFilterStreamingSyncTupsNeverEndsAtOrBeforeSince(t *testing.T) { + from := syncTestVersion(5, types.MaxVersionstampUserVersion) + latest := syncTestVersion(9, types.MaxVersionstampUserVersion) + receipts := make([]*types.ReceiptWithVersion, 3) + for i := range receipts { + receipts[i] = &types.ReceiptWithVersion{Version: syncTestVersion(byte(1+i), 0)} + } + newlyJoined := &roomSyncResult{ + roomSyncConfig: &roomSyncConfig{to: latest, isInitial: true}, + eventTups: []types.EventTupWithVersion{{Version: syncTestVersion(6, 0)}}, + receipts: receipts, + } + options := streamingTestOptions(5) + options.Filter.Room.Ephemeral.Limit = 2 + + trimmed := (&RoomsDatabase{}).filterStreamingSyncTups(options, map[types.MembershipTup]*roomSyncResult{ + {RoomID: "!newlyjoined:example.com"}: newlyJoined, + }, from, latest) + + assert.Equal(t, latest, trimmed, "a newly joined room's receipts from before since do not end the sync") + assert.Len(t, newlyJoined.receipts, 3) +} + +func TestFilterStreamingSyncTupsLeavesLaterMembershipChangesToTheNextSync(t *testing.T) { + from := syncTestVersion(0, types.MaxVersionstampUserVersion) + latest := syncTestVersion(9, types.MaxVersionstampUserVersion) + joined := &roomSyncResult{ + roomSyncConfig: &roomSyncConfig{from: from, to: latest}, + eventTups: []types.EventTupWithVersion{{Version: syncTestVersion(1, 0)}, {Version: syncTestVersion(3, 0)}, {Version: syncTestVersion(5, 0)}}, + } + invitedBefore := &roomSyncResult{roomSyncConfig: &roomSyncConfig{changedAt: syncTestVersion(2, 0)}} + invitedAfter := &roomSyncResult{roomSyncConfig: &roomSyncConfig{changedAt: syncTestVersion(4, 0)}} + leftAfter := &roomSyncResult{roomSyncConfig: &roomSyncConfig{from: from, to: syncTestVersion(6, 0), changedAt: syncTestVersion(6, 0)}} + joinedAfter := &roomSyncResult{ + roomSyncConfig: &roomSyncConfig{to: latest, isInitial: true, changedAt: syncTestVersion(7, 0)}, + eventTups: []types.EventTupWithVersion{{Version: syncTestVersion(7, 0)}}, + } + results := map[types.MembershipTup]*roomSyncResult{ + {RoomID: "!joined:example.com", Membership: event.MembershipJoin}: joined, + {RoomID: "!invitedbefore:example.com", Membership: event.MembershipInvite}: invitedBefore, + {RoomID: "!invitedafter:example.com", Membership: event.MembershipInvite}: invitedAfter, + {RoomID: "!leftafter:example.com", Membership: event.MembershipLeave}: leftAfter, + {RoomID: "!joinedafter:example.com", Membership: event.MembershipJoin}: joinedAfter, + } + + trimmed := (&RoomsDatabase{}).filterStreamingSyncTups(streamingTestOptions(2), results, from, latest) + + assert.Equal(t, syncTestVersion(3, 0), trimmed) + assert.ElementsMatch(t, []*roomSyncResult{joined, invitedBefore}, slices.Collect(maps.Values(results)), + "a room changed after the trimmed end, its join included, is left to the next sync") +} + +func TestFilterClientSyncRoomsSelectsMembershipAtResponseEnd(t *testing.T) { + from, latest := syncTestVersion(1, 0), syncTestVersion(9, 0) + changes := types.MembershipChanges{ + syncTestChange(syncTestVersion(2, 0), event.MembershipJoin), + syncTestChange(syncTestVersion(4, 0), event.MembershipLeave), + syncTestChange(syncTestVersion(6, 0), event.MembershipJoin), + } + changes[0].EventID, changes[1].EventID, changes[2].EventID = "$join1", "$leave", "$join2" + ends := joinEntryEnds(changes, latest) + for _, tc := range []struct { + name string + mode types.SyncMode + end byte + want int + }{ + {"legacy rejoin", types.SyncModeLegacy, 9, 2}, + {"streaming rejoin", types.SyncModeStreaming, 9, 2}, + {"before leave", types.SyncModeStreaming, 3, 0}, + {"at leave", types.SyncModeStreaming, 4, 1}, + {"before rejoin", types.SyncModeStreaming, 5, 1}, + {"at rejoin", types.SyncModeStreaming, 6, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + results := make(map[types.MembershipTup]*roomSyncResult) + for i, change := range changes { + res := &roomSyncResult{roomSyncConfig: &roomSyncConfig{ + from: from, to: change.Version, changedAt: change.Version, + }} + if change.Membership == event.MembershipJoin { + res.from, _ = types.VersionstampBefore(change.Version) + res.to, res.isInitial = ends[i], true + res.eventTups = []types.EventTupWithVersion{syncTestEvent(change.EventID, event.StateMember, change.Version)} + } + results[change.MembershipTup] = res + } + wantMembership := changes[tc.want].MembershipTup + wantResult := results[wantMembership] + otherMembership := types.MembershipTup{RoomID: "!other:example.com", Membership: event.MembershipJoin} + other := &roomSyncResult{ + roomSyncConfig: &roomSyncConfig{from: from, to: latest}, + pageEnd: syncTestVersion(tc.end, 0), + } + results[otherMembership] = other + + end := latest + if tc.mode == types.SyncModeStreaming { + end = (&RoomsDatabase{}).filterStreamingSyncTups(streamingTestOptions(10), results, from, latest) + } + filterClientSyncRooms(results) + + assert.Equal(t, syncTestVersion(tc.end, 0), end) + require.Len(t, results, 2, "only the selected membership and the unchanged room remain") + require.Same(t, wantResult, results[wantMembership]) + assert.Same(t, other, results[otherMembership]) + if wantMembership.Membership == event.MembershipJoin { + assert.Equal(t, end, wantResult.to, "state is read at the response end") + assert.Equal(t, changes[tc.want].EventID, wantResult.eventTups[0].EventID, "the selected join keeps its timeline") + } + + // Exercise response assembly too: the room must appear in exactly one membership section. + rooms := make(map[types.MembershipTup]*types.SyncRoom) + for membership := range results { + rooms[membership] = &types.SyncRoom{} + } + response := types.NewSync(rooms, nil, nil, nil) + if wantMembership.Membership == event.MembershipJoin { + assert.Same(t, rooms[wantMembership], response.Rooms.Join[wantMembership.RoomID]) + assert.NotContains(t, response.Rooms.Leave, wantMembership.RoomID) + } else { + assert.Same(t, rooms[wantMembership], response.Rooms.Leave[wantMembership.RoomID]) + assert.NotContains(t, response.Rooms.Join, wantMembership.RoomID) + } + }) + } +} + +func syncTestChange(version tuple.Versionstamp, membership event.Membership) types.MembershipTupWithVersion { + return types.MembershipTupWithVersion{MembershipTup: types.MembershipTup{RoomID: "!room:example.com", Membership: membership}, Version: version} +} + +func TestServerSyncRangeIsWhileBothServersWereInTheRoom(t *testing.T) { + since, to := syncTestVersion(5, 0), syncTestVersion(9, 0) + v := func(transaction byte) tuple.Versionstamp { return syncTestVersion(transaction, 0) } + for _, tc := range []struct { + name string + sync serverRoomSync + wantFrom, wantTo tuple.Versionstamp + wantSynced bool + }{ + { + name: "both joined since before since", + sync: serverRoomSync{since: since, to: to, serverJoinedNow: true, localJoinedNow: true}, + wantFrom: since, wantTo: to, wantSynced: true, + }, + { + name: "the server joined within the sync", + sync: serverRoomSync{since: since, to: to, serverJoined: v(6), serverJoinedNow: true, localJoinedNow: true}, + wantFrom: v(6), wantTo: to, wantSynced: true, + }, + { + name: "this server joined within the sync, the server's join unrecorded", + sync: serverRoomSync{ + since: since, to: to, serverJoinedNow: true, localJoinedNow: true, + localChanges: types.MembershipChanges{syncTestChange(v(7), event.MembershipJoin)}, + }, + wantFrom: v(7), wantTo: to, wantSynced: true, + }, + { + name: "this server left within the sync", + sync: serverRoomSync{ + since: since, to: to, serverJoinedNow: true, + localChanges: types.MembershipChanges{syncTestChange(v(6), event.MembershipJoin), syncTestChange(v(7), event.MembershipLeave)}, + }, + wantFrom: v(6), wantTo: v(7), wantSynced: true, + }, + { + name: "this server left and rejoined with events still pending before the leave", + sync: serverRoomSync{ + since: since, to: to, serverJoinedNow: true, localJoinedNow: true, + localChanges: types.MembershipChanges{syncTestChange(v(7), event.MembershipLeave), syncTestChange(v(8), event.MembershipJoin)}, + }, + wantFrom: since, wantTo: v(7), wantSynced: true, + }, + { + name: "resume at the leave and read the next joined interval", + sync: serverRoomSync{ + since: v(7), to: to, serverJoinedNow: true, localJoinedNow: true, + localChanges: types.MembershipChanges{syncTestChange(v(8), event.MembershipJoin)}, + }, + wantFrom: v(8), wantTo: to, wantSynced: true, + }, + { + name: "first join and leave win over a later rejoin", + sync: serverRoomSync{ + since: since, to: to, serverJoinedNow: true, localJoinedNow: true, + localChanges: types.MembershipChanges{ + syncTestChange(v(6), event.MembershipJoin), syncTestChange(v(7), event.MembershipLeave), syncTestChange(v(8), event.MembershipJoin), + }, + }, + wantFrom: v(6), wantTo: v(7), wantSynced: true, + }, + { + name: "this server left, rejoined while the server joined and left again", + sync: serverRoomSync{ + since: since, to: to, serverJoined: v(7), serverJoinedNow: true, + localChanges: types.MembershipChanges{syncTestChange(v(6), event.MembershipLeave), syncTestChange(v(7), event.MembershipJoin), syncTestChange(v(8), event.MembershipLeave)}, + }, + wantFrom: v(7), wantTo: v(8), wantSynced: true, + }, + { + name: "this server left after the sync's end", + sync: serverRoomSync{ + since: since, to: to, serverJoinedNow: true, + localChanges: types.MembershipChanges{syncTestChange(syncTestVersion(10, 0), event.MembershipLeave)}, + }, + wantFrom: since, wantTo: to, wantSynced: true, + }, + { + name: "out of the room for the whole sync", + sync: serverRoomSync{since: since, to: to, serverJoinedNow: true}, + }, + { + name: "this server left before since", + sync: serverRoomSync{since: since, to: to, serverJoinedNow: true, localChanges: types.MembershipChanges{}}, + }, + { + name: "the server left after the range's end", + sync: serverRoomSync{ + since: since, to: to, localJoinedNow: true, + serverChanges: types.MembershipChanges{syncTestChange(syncTestVersion(10, 0), event.MembershipLeave)}, + }, + wantFrom: since, wantTo: to, wantSynced: true, + }, + { + name: "the server is not in the room", + sync: serverRoomSync{since: since, to: to, localJoinedNow: true}, + }, + { + name: "the server joined after the range's end", + sync: serverRoomSync{ + since: since, to: to, serverJoinedNow: true, localJoinedNow: true, + serverChanges: types.MembershipChanges{syncTestChange(syncTestVersion(10, 0), event.MembershipJoin)}, + }, + }, + { + name: "this server joins exactly at the end with nothing after it", + sync: serverRoomSync{ + since: since, to: to, serverJoinedNow: true, localJoinedNow: true, + localChanges: types.MembershipChanges{syncTestChange(to, event.MembershipJoin)}, + }, + }, + } { + from, to, synced := serverSyncRange(tc.sync) + assert.Equal(t, tc.wantSynced, synced, tc.name) + if synced { + assert.Equal(t, tc.wantFrom, from, tc.name) + assert.Equal(t, tc.wantTo, to, tc.name) + assert.False(t, types.VersionIsBefore(from, tc.sync.since), "%s: never before since", tc.name) + } + } +} + +func TestServerSyncResumesJoinedIntervalsWithoutSkippingEventsOrReceipts(t *testing.T) { + v := func(transaction byte) tuple.Versionstamp { return syncTestVersion(transaction, 0) } + latest := v(10) + localChanges := types.MembershipChanges{ + syncTestChange(v(7), event.MembershipLeave), syncTestChange(v(8), event.MembershipJoin), + } + allEvents := []types.EventTupWithVersion{ + syncTestEvent("$pending", event.EventMessage, v(6)), + syncTestEvent("$leave", event.StateMember, v(7)), + syncTestEvent("$gap", event.EventMessage, syncTestVersion(7, 1)), + syncTestEvent("$rejoin", event.StateMember, v(8)), + syncTestEvent("$after", event.EventMessage, v(9)), + } + allReceipts := []*types.ReceiptWithVersion{ + {Version: syncTestVersion(6, 1)}, + {Version: syncTestVersion(6, 2)}, + {Version: syncTestVersion(7, 2)}, + {Version: syncTestVersion(8, 1)}, + } + for _, tc := range []struct { + name string + eventLimit int + receiptLimit int + firstEnd tuple.Versionstamp + }{ + {"short page", 10, 10, v(7)}, + {"full page", 2, 10, v(7)}, + {"page stops before leave", 1, 10, v(6)}, + {"receipt limit stops before leave", 10, 1, syncTestVersion(6, 1)}, + } { + t.Run(tc.name, func(t *testing.T) { + options := streamingTestOptions(tc.eventLimit) + options.IsServerToServer = true + options.Filter.Room.Ephemeral.Limit = tc.receiptLimit + room := types.MembershipTup{RoomID: "!room:example.com", Membership: event.MembershipJoin} + var events []id.EventID + var receipts []tuple.Versionstamp + cursor := v(5) + for attempt := 0; cursor != latest; attempt++ { + require.Less(t, attempt, 10, "catch-up must finish") + res := &roomSyncResult{roomSyncConfig: &roomSyncConfig{ + from: cursor, to: latest, includeReceipts: true, + server: serverRoomSync{ + serverJoinedNow: true, localJoinedNow: true, + localChanges: slices.DeleteFunc(slices.Clone(localChanges), func(change types.MembershipTupWithVersion) bool { + return types.VersionIsAtOrBefore(change.Version, cursor) + }), + }, + }} + results := map[types.MembershipTup]*roomSyncResult{room: res} + if applyServerSyncRange(res) { + var page []types.EventTupWithVersion + for _, ev := range allEvents { + if types.VersionIsAfter(ev.Version, res.from) && types.VersionIsAtOrBefore(ev.Version, res.to) { + page = append(page, ev) + if len(page) == tc.eventLimit { + break + } + } + } + takeTimelinePage(options, res, page, tc.eventLimit) + for _, receipt := range allReceipts { + if types.VersionIsAfter(receipt.Version, res.from) && types.VersionIsAtOrBefore(receipt.Version, res.to) { + res.receipts = append(res.receipts, receipt) + } + } + } else { + delete(results, room) + } + next := (&RoomsDatabase{}).filterStreamingSyncTups(options, results, cursor, latest) + if attempt == 0 { + assert.Equal(t, tc.firstEnd, next) + } + require.True(t, types.VersionIsAfter(next, cursor), "the delivery cursor must advance") + for _, ev := range res.eventTups { + events = append(events, ev.EventID) + } + for _, receipt := range res.receipts { + receipts = append(receipts, receipt.Version) + } + cursor = next + } + assert.Equal(t, []id.EventID{"$pending", "$leave", "$after"}, events, + "deliver both joined intervals once, excluding the gap and the join already exchanged") + assert.Equal(t, []tuple.Versionstamp{syncTestVersion(6, 1), syncTestVersion(6, 2), syncTestVersion(8, 1)}, receipts) + }) + } +} + +func TestServerSyncEmptyIntervalStillBoundsOtherRooms(t *testing.T) { + from, latest := syncTestVersion(5, 0), syncTestVersion(10, 0) + leave := syncTestVersion(7, 0) + res := &roomSyncResult{roomSyncConfig: &roomSyncConfig{ + from: from, to: latest, + server: serverRoomSync{ + serverJoinedNow: true, localJoinedNow: true, + localChanges: types.MembershipChanges{ + syncTestChange(leave, event.MembershipLeave), syncTestChange(syncTestVersion(8, 0), event.MembershipJoin), + }, + }, + }} + require.True(t, applyServerSyncRange(res)) + options := streamingTestOptions(10) + options.IsServerToServer = true + takeTimelinePage(options, res, nil, 10) + other := &roomSyncResult{ + roomSyncConfig: &roomSyncConfig{from: from, to: latest}, + eventTups: []types.EventTupWithVersion{ + syncTestEvent("$before", event.EventMessage, syncTestVersion(6, 0)), + syncTestEvent("$later", event.EventMessage, syncTestVersion(9, 0)), + }, + receipts: []*types.ReceiptWithVersion{{Version: syncTestVersion(9, 1)}}, + } + results := map[types.MembershipTup]*roomSyncResult{ + {RoomID: "!room:example.com", Membership: event.MembershipJoin}: res, + {RoomID: "!other:example.com", Membership: event.MembershipJoin}: other, + } + next := (&RoomsDatabase{}).filterStreamingSyncTups(options, results, from, latest) + assert.Equal(t, leave, next, "an empty local-event page must not skip the next joined interval") + assert.Equal(t, leave, other.to) + require.Len(t, other.eventTups, 1) + assert.Equal(t, id.EventID("$before"), other.eventTups[0].EventID) + assert.Empty(t, other.receipts) +} + +func TestServerSyncLastIntervalDoesNotHoldBackTheCursor(t *testing.T) { + from, latest := syncTestVersion(5, 0), syncTestVersion(10, 0) + leave := syncTestVersion(7, 0) + res := &roomSyncResult{roomSyncConfig: &roomSyncConfig{ + from: from, to: latest, + server: serverRoomSync{ + serverJoinedNow: true, + localChanges: types.MembershipChanges{syncTestChange(leave, event.MembershipLeave)}, + }, + }} + require.True(t, applyServerSyncRange(res)) + assert.Equal(t, leave, res.to) + assert.Equal(t, types.ZeroVersionstamp, res.intervalEnd) + options := streamingTestOptions(10) + options.IsServerToServer = true + takeTimelinePage(options, res, []types.EventTupWithVersion{syncTestEvent("$leave", event.StateMember, leave)}, 10) + next := (&RoomsDatabase{}).filterStreamingSyncTups(options, map[types.MembershipTup]*roomSyncResult{ + {RoomID: "!room:example.com", Membership: event.MembershipJoin}: res, + }, from, latest) + assert.Equal(t, latest, next, "no later joined interval remains to be read") +} + +func TestServerSyncRangeOfAServerTheRejoinRemoved(t *testing.T) { + since, latest := syncTestVersion(1, 0), syncTestVersion(9, 0) + vJ, vL1, vJ2, vJ3, vE := syncTestVersion(2, 0), syncTestVersion(3, 0), syncTestVersion(4, 0), syncTestVersion(5, 0), syncTestVersion(6, 0) + // The server joins, this server's last user leaves, the server's users leave meanwhile, this + // server rejoins and its join's publish removes the server, which rejoins through it and leaves + serverChanges := types.MembershipChanges{ + syncTestChange(vJ, event.MembershipJoin), syncTestChange(vJ2, event.MembershipLeave), syncTestChange(vJ3, event.MembershipJoin), syncTestChange(vE, event.MembershipLeave), + } + localChanges := types.MembershipChanges{syncTestChange(vL1, event.MembershipLeave), syncTestChange(vJ2, event.MembershipJoin)} + ends := joinEntryEnds(serverChanges, latest) + + var ranges [][2]tuple.Versionstamp + for i, change := range serverChanges { + if change.Membership != event.MembershipJoin { + continue + } + from, to, synced := serverSyncRange(serverRoomSync{ + since: since, to: ends[i], serverJoined: change.Version, + serverChanges: serverChanges, localChanges: localChanges, localJoinedNow: true, + }) + require.True(t, synced, change.Version) + ranges = append(ranges, [2]tuple.Versionstamp{from, to}) + } + beforeLeave, _ := types.VersionstampBefore(vE) + assert.Equal(t, [][2]tuple.Versionstamp{{vJ, vL1}, {vJ3, beforeLeave}}, ranges, "never this server's events from when the server was out of the room") +} + +func TestJoinEntriesEndBeforeTheRoomsNextOtherChange(t *testing.T) { + end := syncTestVersion(9, types.MaxVersionstampUserVersion) + change := func(roomID id.RoomID, transaction byte, membership event.Membership) types.MembershipTupWithVersion { + return types.MembershipTupWithVersion{ + MembershipTup: types.MembershipTup{RoomID: roomID, Membership: membership}, + Version: syncTestVersion(transaction, 0), + } + } + changes := types.MembershipChanges{ + change("!a:example.com", 1, event.MembershipJoin), + change("!b:example.com", 2, event.MembershipJoin), + change("!a:example.com", 3, event.MembershipJoin), + change("!a:example.com", 4, event.MembershipLeave), + change("!a:example.com", 5, event.MembershipJoin), + change("!b:example.com", 6, event.MembershipBan), + } + before := func(version tuple.Versionstamp) tuple.Versionstamp { + previous, _ := types.VersionstampBefore(version) + return previous + } + assert.Equal(t, []tuple.Versionstamp{ + before(syncTestVersion(4, 0)), + before(syncTestVersion(6, 0)), + before(syncTestVersion(4, 0)), + end, + end, + end, + }, joinEntryEnds(changes, end), "a join's entry ends before the leave, another join of the room not ending it") +} diff --git a/internal/databases/rooms/synctypes.go b/internal/databases/rooms/synctypes.go index 47bf718..53ee9ba 100644 --- a/internal/databases/rooms/synctypes.go +++ b/internal/databases/rooms/synctypes.go @@ -10,6 +10,23 @@ type roomSyncConfig struct { from, to tuple.Versionstamp isInitial bool includeReceipts bool + // The version of the membership change the room is synced for, zero for its current membership + changedAt tuple.Versionstamp + // Of a server's sync, what decides the room's range but the range itself + server serverRoomSync +} + +// serverRoomSync is what a server's sync of a room is decided by +type serverRoomSync struct { + since, to tuple.Versionstamp + // The server's join the room is synced for within the sync, zero for none + serverJoined tuple.Versionstamp + // Each server's membership changes of the room from since through latestVersion, and whether + // it was joined at latestVersion, all based on the initial membership snapshot. + serverChanges types.MembershipChanges + serverJoinedNow bool + localChanges types.MembershipChanges + localJoinedNow bool } type roomSyncResult struct { @@ -18,6 +35,12 @@ type roomSyncResult struct { limited bool eventTups []types.EventTupWithVersion eventStateTups []types.EventStateTupWithVersion + // The last event a streaming page read when it may have stopped short of to + pageEnd tuple.Versionstamp + // The end of a server's joined interval when later intervals have not been read + intervalEnd tuple.Versionstamp receipts []*types.ReceiptWithVersion + // Left out of a server's sync, see applyServerSyncRange + denied bool } diff --git a/internal/types/sync.go b/internal/types/sync.go index ae0680b..b678583 100644 --- a/internal/types/sync.go +++ b/internal/types/sync.go @@ -31,9 +31,6 @@ type SyncOptions struct { UserID id.UserID DeviceID id.DeviceID - // Enables MSC4222: state_after - EnableLegacyStateAfter bool - // Flag indicating whether this sync is for S2S federation IsServerToServer bool } @@ -49,7 +46,7 @@ func (o *SyncOptions) GetTimelineLimit() int { } func (o *SyncOptions) GetReceiptsLimit() int { - if o == nil || o.Filter == nil { + if o == nil || o.Filter == nil || o.Filter.Room == nil || o.Filter.Room.Ephemeral == nil { return DefaultReceiptsLimit } if o.Filter.Room.Ephemeral.Limit > 0 { @@ -73,7 +70,7 @@ func (o *SyncOptions) GetRoomFilter() *mautrix.RoomFilter { } func (o *SyncOptions) GetTimelineFilter() *mautrix.FilterPart { - if o == nil || o.Filter == nil { + if o == nil || o.Filter == nil || o.Filter.Room == nil { return nil } return o.Filter.Room.Timeline diff --git a/internal/types/version.go b/internal/types/version.go index 4379e88..1447d85 100644 --- a/internal/types/version.go +++ b/internal/types/version.go @@ -245,21 +245,24 @@ func VersionstampAfter(version tuple.Versionstamp) (tuple.Versionstamp, bool) { return ZeroVersionstamp, false } -// VersionstampBefore returns the largest versionstamp less than version, borrowing from the -// transaction version so that VersionstampAfter maps the result back to version. Returns -// ZeroVersionstamp, an open range start, when version has no predecessor. -func VersionstampBefore(version tuple.Versionstamp) tuple.Versionstamp { +// VersionstampBefore returns the greatest versionstamp less than version, borrowing from the +// transaction version when the user version is 0, as the first event of a transaction has. Neither +// the zero versionstamp nor an incomplete one has a predecessor. +func VersionstampBefore(version tuple.Versionstamp) (tuple.Versionstamp, bool) { + if version == ZeroVersionstamp || IsIncompleteVersionstamp(version) { + return ZeroVersionstamp, false + } if version.UserVersion > 0 { version.UserVersion-- - return version + return version, true } version.UserVersion = math.MaxUint16 for i := len(version.TransactionVersion) - 1; i >= 0; i-- { if version.TransactionVersion[i] != 0 { version.TransactionVersion[i]-- - return version + return version, true } version.TransactionVersion[i] = math.MaxUint8 } - return ZeroVersionstamp + return ZeroVersionstamp, false } diff --git a/internal/types/version_test.go b/internal/types/version_test.go index 8605f1e..26665b4 100644 --- a/internal/types/version_test.go +++ b/internal/types/version_test.go @@ -1,9 +1,11 @@ package types_test import ( + "bytes" "testing" "github.com/apple/foundationdb/bindings/go/src/fdb" + "github.com/apple/foundationdb/bindings/go/src/fdb/subspace" "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -39,7 +41,8 @@ func TestVersionstampBeforeAfter(t *testing.T) { TransactionVersion: [10]uint8{0, 0, 0, 0, 0x08, 0x09, 0x39, 0x55, 0x01, 0x00}, UserVersion: 0, } - before := types.VersionstampBefore(batchStart) + before, ok := types.VersionstampBefore(batchStart) + require.True(t, ok) assert.Equal(t, tuple.Versionstamp{ TransactionVersion: [10]uint8{0, 0, 0, 0, 0x08, 0x09, 0x39, 0x55, 0x00, 0xff}, UserVersion: 0xffff, @@ -49,9 +52,13 @@ func TestVersionstampBeforeAfter(t *testing.T) { assert.Equal(t, batchStart, after) mid := tuple.Versionstamp{TransactionVersion: batchStart.TransactionVersion, UserVersion: 7} - assert.Equal(t, uint16(6), types.VersionstampBefore(mid).UserVersion) + beforeMid, ok := types.VersionstampBefore(mid) + require.True(t, ok) + assert.Equal(t, uint16(6), beforeMid.UserVersion) - assert.Equal(t, types.ZeroVersionstamp, types.VersionstampBefore(types.ZeroVersionstamp)) + beforeZero, ok := types.VersionstampBefore(types.ZeroVersionstamp) + assert.False(t, ok) + assert.Equal(t, types.ZeroVersionstamp, beforeZero) } func TestVersionMap(t *testing.T) { @@ -98,3 +105,51 @@ func TestVersionMap(t *testing.T) { assert.Equal(t, otherVersionstamp, partialVersions[types.AccountsVersionKey]) assert.Equal(t, incompleteVersionstamp, partialVersions["someOtherKey"]) } + +func TestVersionstampBefore(t *testing.T) { + version := func(userVersion uint16, transactionVersion ...byte) tuple.Versionstamp { + v := tuple.Versionstamp{UserVersion: userVersion} + copy(v.TransactionVersion[len(v.TransactionVersion)-len(transactionVersion):], transactionVersion) + return v + } + + for _, tc := range []struct { + name string + version tuple.Versionstamp + before tuple.Versionstamp + }{ + {"user version", version(5, 0x07, 0x4c), version(4, 0x07, 0x4c)}, + {"first of a transaction", version(0, 0x07, 0x4c), version(0xffff, 0x07, 0x4b)}, + {"borrow across bytes", version(0, 0x01, 0x00, 0x00), version(0xffff, 0x00, 0xff, 0xff)}, + {"first transaction", version(1), version(0)}, + } { + t.Run(tc.name, func(t *testing.T) { + before, ok := types.VersionstampBefore(tc.version) + require.True(t, ok) + assert.Equal(t, tc.before, before) + assert.True(t, types.VersionIsBefore(before, tc.version)) + + after, ok := types.VersionstampAfter(before) + require.True(t, ok) + assert.Equal(t, tc.version, after) + + sub := subspace.Sub("versions") + begin, _ := types.GetVersionRange(sub, before, types.ZeroVersionstamp).FDBRangeKeys() + assert.LessOrEqual(t, bytes.Compare(begin.FDBKey(), sub.Pack(tuple.Tuple{tc.version})), 0) + }) + } + + for _, tc := range []struct { + name string + version tuple.Versionstamp + }{ + {"zero", types.ZeroVersionstamp}, + {"incomplete", tuple.IncompleteVersionstamp(1)}, + } { + t.Run(tc.name, func(t *testing.T) { + before, ok := types.VersionstampBefore(tc.version) + assert.False(t, ok) + assert.Equal(t, types.ZeroVersionstamp, before) + }) + } +} From 624bc7c983b9ffc93de6445db24dc1606c2ef9d4 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 5 Oct 2026 13:52:24 +0100 Subject: [PATCH 09/12] Integrate room creation and federation with exact state Adapt room creation, membership operations and federation state responses to the exact-state readers and structured event senders. Bound room creation inputs and use the configured room version for membership templates. Recover missing federation predecessors with get_missing_events and fetch state for unresolved predecessors of pulled events. Verify fetched events, keep them scoped to the room, and pass their state into event preparation. Handle transaction PDUs individually, validate EDU user origins and local to-device recipients, and recover panics in concurrent route work. Validation: federation route package builds after review. Client and federation route tests passed before their removal during review. --- internal/config/config.go | 16 + internal/databases/rooms/events.go | 155 ++++- internal/routes/client/client.go | 17 +- internal/routes/client/roomcreate.go | 97 ++- internal/routes/client/roommember.go | 307 ++++------ internal/routes/client/roommemberutil.go | 102 +++- internal/routes/client/roomsend.go | 4 + internal/routes/client/roomutil.go | 16 +- internal/routes/federation/roomevent.go | 23 +- internal/routes/federation/roommember.go | 60 +- internal/routes/federation/roommemberutil.go | 93 +-- internal/routes/federation/roomstate.go | 74 ++- internal/routes/federation/transaction.go | 606 ++++++++++++------- internal/util/panic.go | 24 +- 14 files changed, 978 insertions(+), 616 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index c4dcb74..c519e24 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -72,6 +72,13 @@ type BabbleConfig struct { // Default: 3h CompactRoomNotificationsTimeout time.Duration `yaml:"compactRoomNotificationsTimeout"` + // Most initial_state events a create room request may carry, all published with the create + // event + // Default: 100 + MaxCreateRoomInitialState int `yaml:"maxCreateRoomInitialState"` + // Default: 1000 + MaxCreateRoomInvites int `yaml:"maxCreateRoomInvites"` + StateBudget stateBudgetConfig `yaml:"stateBudget"` } `yaml:"rooms"` @@ -196,12 +203,21 @@ func NewBabbleConfig(filename string, commitHash string) BabbleConfig { cfg.Transient.PresenceTimeoutCheckInterval = time.Minute } + if cfg.Rooms.DefaultVersion == "" { + cfg.Rooms.DefaultVersion = "11" + } if cfg.Rooms.MaxNotificationsPerUserRoom == 0 { cfg.Rooms.MaxNotificationsPerUserRoom = 100 } if cfg.Rooms.CompactRoomNotificationsTimeout == 0 { cfg.Rooms.CompactRoomNotificationsTimeout = 3 * time.Hour } + if cfg.Rooms.MaxCreateRoomInitialState <= 0 { + cfg.Rooms.MaxCreateRoomInitialState = 100 + } + if cfg.Rooms.MaxCreateRoomInvites <= 0 { + cfg.Rooms.MaxCreateRoomInvites = 1000 + } if cfg.Rooms.StateBudget.StagingBytes <= 0 { cfg.Rooms.StateBudget.StagingBytes = 5_000_000 } diff --git a/internal/databases/rooms/events.go b/internal/databases/rooms/events.go index c94044f..3180d7f 100644 --- a/internal/databases/rooms/events.go +++ b/internal/databases/rooms/events.go @@ -13,26 +13,16 @@ import ( "github.com/beeper/babbleserv/internal/util" ) -// Check if we have an event (that we accepted) -func (r *RoomsDatabase) DoesEventExist(ctx context.Context, eventID id.EventID) (bool, error) { - return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (bool, error) { - key := r.events.KeyForIDToVersion(eventID) - if b, err := txn.Get(key).Get(); err != nil { - return false, err - } else if b == nil { - return false, nil - } else { - return true, nil - } - }) -} - -func (r *RoomsDatabase) MustDoesEventExist(ctx context.Context, eventID id.EventID) bool { - if ret, err := r.DoesEventExist(ctx, eventID); err != nil { - panic(err) - } else { - return ret +// Returns those of the events not stored as room events, which includes outliers +func (r *RoomsDatabase) GetUnstoredEventIDs(ctx context.Context, eventIDs []id.EventID) ([]id.EventID, error) { + stored, err := r.storedEventIDs(ctx, eventIDs) + if err != nil { + return nil, err } + return slices.DeleteFunc(slices.Clone(eventIDs), func(eventID id.EventID) bool { + _, found := stored[eventID] + return found + }), nil } // storedEventIDs returns a map of event IDs already stored @@ -50,6 +40,127 @@ func (r *RoomsDatabase) storedEventIDs(ctx context.Context, eventIDs []id.EventI return stored, nil } +// MissingEventsRequest is what a get_missing_events request for a federated batch names +type MissingEventsRequest struct { + // The prev events neither in the batch nor stored as room events that the batch events not + // stored yet cite, sorted + Missing []id.EventID + // The room's extremities, where the other server stops looking + Extremities []id.EventID + // The depth of the room's first timeline event, which after a remote join is the join, zero for + // a room without one + MinDepth int64 +} + +// GetMissingEventsRequest returns what a get_missing_events request for a federated batch names +func (r *RoomsDatabase) GetMissingEventsRequest(ctx context.Context, roomID id.RoomID, batch []*types.Event) (*MissingEventsRequest, error) { + return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (*MissingEventsRequest, error) { + prevIDs, storedPrevs, err := r.txnOutsidePrevEvents(txn, batch, batch) + if err != nil { + return nil, err + } + req := &MissingEventsRequest{ + Missing: slices.DeleteFunc(prevIDs, func(prevID id.EventID) bool { + _, found := storedPrevs[prevID] + return found + }), + } + if len(req.Missing) == 0 { + return req, nil + } + req.Extremities = r.events.TxnLookupCurrentRoomExtremEventIDs(txn, roomID) + eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) + if tups := r.events.TxnPaginateRoomEventTups(txn, roomID, types.PaginationOptions{Limit: 1}, eventsProvider); len(tups) > 0 { + first, err := eventsProvider.Get(tups[0].EventID) + if err != nil { + return nil, err + } else if first != nil { + req.MinDepth = first.Depth + } + } + return req, nil + }) +} + +// Returns the prev events of the pulled events of a federated batch whose state is not known here, +// sorted: those neither in the batch nor stored as room events, and those stored without state. +// Events already stored are not evaluated again, so their prev events are skipped. +func (r *RoomsDatabase) GetPrevEventsWithoutState( + ctx context.Context, + roomID id.RoomID, + batch, pulled []*types.Event, +) ([]id.EventID, error) { + return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) ([]id.EventID, error) { + return r.txnPrevEventsWithoutState(ctx, txn, roomID, batch, pulled) + }) +} + +func (r *RoomsDatabase) txnPrevEventsWithoutState( + ctx context.Context, + txn fdb.ReadTransaction, + roomID id.RoomID, + batch, pulled []*types.Event, +) ([]id.EventID, error) { + prevIDs, storedPrevs, err := r.txnOutsidePrevEvents(txn, batch, pulled) + if err != nil { + return nil, err + } + eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) + for prevID := range storedPrevs { + eventsProvider.WillGet(prevID) + } + withoutState := make([]id.EventID, 0, len(prevIDs)) + for _, prevID := range prevIDs { + if _, found := storedPrevs[prevID]; !found { + withoutState = append(withoutState, prevID) + } else if prev, err := eventsProvider.Get(prevID); err != nil { + return nil, err + } else if !prev.HasStateIn(roomID) { + withoutState = append(withoutState, prevID) + } + } + return withoutState, nil +} + +// Returns the prev events cited from outside a federated batch by those of the events not stored +// yet, sorted, and which of them are stored as room events. Events already stored are not evaluated +// again, so their prev events are skipped. +func (r *RoomsDatabase) txnOutsidePrevEvents( + txn fdb.ReadTransaction, + batch, evs []*types.Event, +) ([]id.EventID, map[id.EventID]struct{}, error) { + storedBatch, err := r.txnGetStoredEventIDs(txn, util.EventsToIDs(batch)) + if err != nil { + return nil, nil, err + } + unstoredBatch := make(map[id.EventID]struct{}, len(batch)) + for _, ev := range batch { + if _, found := storedBatch[ev.ID]; !found { + unstoredBatch[ev.ID] = struct{}{} + } + } + + var prevIDs []id.EventID + for _, ev := range evs { + if _, found := unstoredBatch[ev.ID]; !found { + continue + } + for _, prevID := range ev.PrevEventIDs { + if _, found := unstoredBatch[prevID]; !found { + prevIDs = append(prevIDs, prevID) + } + } + } + slices.Sort(prevIDs) + prevIDs = slices.Compact(prevIDs) + + storedPrevs, err := r.txnGetStoredEventIDs(txn, prevIDs) + if err != nil { + return nil, nil, err + } + return prevIDs, storedPrevs, nil +} + func (r *RoomsDatabase) txnGetStoredEventIDs(txn fdb.ReadTransaction, eventIDs []id.EventID) (map[id.EventID]struct{}, error) { futures := make([]fdb.FutureByteSlice, len(eventIDs)) for i, eventID := range eventIDs { @@ -72,14 +183,14 @@ func (r *RoomsDatabase) GetEvent(ctx context.Context, eventID id.EventID) (*type }) } -// Get the auth chain for a given event by fetching it's auth events and their auth events, etc recursively -func (r *RoomsDatabase) GetEventAuthChain(ctx context.Context, eventID id.EventID) ([]*types.Event, error) { +// Get the auth chain for a given event of the room by fetching it's auth events and their auth events, etc recursively +func (r *RoomsDatabase) GetEventAuthChain(ctx context.Context, roomID id.RoomID, eventID id.EventID) ([]*types.Event, error) { return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) ([]*types.Event, error) { eventsProvider := r.events.NewTxnEventsProvider(ctx, txn) reqEv, err := eventsProvider.Get(eventID) if err != nil { return nil, err - } else if reqEv == nil { + } else if reqEv == nil || reqEv.RoomID != roomID { return nil, types.ErrEventNotFound } evs, err := r.events.TxnGetAuthChainForEvents(txn, []*types.Event{reqEv}, eventsProvider) diff --git a/internal/routes/client/client.go b/internal/routes/client/client.go index 95bd5f7..b9789c9 100644 --- a/internal/routes/client/client.go +++ b/internal/routes/client/client.go @@ -2,6 +2,7 @@ package client import ( "net/http" + "slices" "sync" "github.com/go-chi/chi/v5" @@ -46,6 +47,10 @@ func NewClientRoutes( Str("routes", "client"). Logger() + if !slices.Contains(supportedRoomVersions, cfg.Rooms.DefaultVersion) { + panic("unsupported default room version: " + cfg.Rooms.DefaultVersion) + } + mediaClient := federation.NewClient(fedClient.ServerName, fedClient.Key, federation.NewInMemoryCache()) mediaClient.UserAgent = fedClient.UserAgent mediaClient.HTTP.Timeout = 0 // The media acquisition context covers network and storage work. @@ -249,16 +254,20 @@ func (f *ClientRoutes) GetVersions(w http.ResponseWriter, r *http.Request) { }) } +var supportedRoomVersions = []string{"3", "4", "5", "6", "7", "8", "9", "10", "11", "12"} + // https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv3capabilities func (f *ClientRoutes) GetCapabilities(w http.ResponseWriter, r *http.Request) { + available := make(map[string]mautrix.CapRoomVersionStability, len(supportedRoomVersions)) + for _, roomVersion := range supportedRoomVersions { + available[roomVersion] = mautrix.CapRoomVersionStable + } util.ResponseJSON(w, r, http.StatusOK, map[string]any{ "capabilities": mautrix.RespCapabilities{ ChangePassword: &mautrix.CapBooleanTrue{}, RoomVersions: &mautrix.CapRoomVersions{ - Default: "11", - Available: map[string]mautrix.CapRoomVersionStability{ - "11": mautrix.CapRoomVersionStable, - }, + Default: f.config.Rooms.DefaultVersion, + Available: available, }, }, }) diff --git a/internal/routes/client/roomcreate.go b/internal/routes/client/roomcreate.go index 71bd909..d738c42 100644 --- a/internal/routes/client/roomcreate.go +++ b/internal/routes/client/roomcreate.go @@ -6,14 +6,14 @@ import ( "errors" "fmt" "net/http" - "strconv" + "slices" "strings" + "github.com/matrix-org/gomatrixserverlib" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" - "github.com/matrix-org/gomatrixserverlib" "github.com/rs/zerolog" "github.com/rs/zerolog/hlog" @@ -58,7 +58,6 @@ func (c *ClientRoutes) CreateRoom(w http.ResponseWriter, r *http.Request) { } userID := middleware.GetRequestUserID(r) - roomID := c.db.Rooms.GenerateRoomID(r.Context()) visibility := req.Visibility if visibility == "" { visibility = "private" @@ -74,6 +73,18 @@ func (c *ClientRoutes) CreateRoom(w http.ResponseWriter, r *http.Request) { req.Preset = "private_chat" } } + if maxInitialState := c.config.Rooms.MaxCreateRoomInitialState; len(req.InitialState) > maxInitialState { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, fmt.Sprintf( + "initial_state may contain at most %d events", maxInitialState, + )) + return + } + if maxInvites := c.config.Rooms.MaxCreateRoomInvites; len(req.Invite) > maxInvites { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, fmt.Sprintf( + "invite may contain at most %d users", maxInvites, + )) + return + } for _, invitedUserID := range req.Invite { if _, _, err := invitedUserID.ParseAndValidateRelaxed(); err != nil { util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Invalid invite user ID") @@ -100,19 +111,42 @@ func (c *ClientRoutes) CreateRoom(w http.ResponseWriter, r *http.Request) { if roomVersion == "" { roomVersion = c.config.Rooms.DefaultVersion } - numericVersion, versionErr := strconv.Atoi(roomVersion) - if versionErr != nil || numericVersion < 3 || numericVersion > 11 || - !gomatrixserverlib.KnownRoomVersion(gomatrixserverlib.RoomVersion(roomVersion)) { + if !slices.Contains(supportedRoomVersions, roomVersion) { util.ResponseErrorJSON(w, r, mautrix.MUnsupportedRoomVersion) return } createContent["room_version"] = roomVersion - if numericVersion < 11 { + if util.RoomVersionHas(roomVersion, gomatrixserverlib.IRoomVersion.CreatorInCreateEvent) { createContent["creator"] = userID } else { delete(createContent, "creator") } - createEv := types.NewPartialEvent(roomID, event.StateCreate, &sKey, userID, createContent) + if util.RoomVersionHas(roomVersion, gomatrixserverlib.IRoomVersion.PrivilegedCreators) && req.Preset == "trusted_private_chat" { + additional, valid := createContent["additional_creators"].([]any) + if createContent["additional_creators"] == nil { + valid = true + } + if valid { + for _, invited := range req.Invite { + if !slices.Contains(additional, any(invited.String())) { + additional = append(additional, invited.String()) + } + } + if len(additional) > 0 { + createContent["additional_creators"] = additional + } + } + } + createEv := types.NewPartialEvent("", event.StateCreate, &sKey, userID, createContent) + roomID, err := c.db.Rooms.GenerateRoomID(r.Context(), createEv) + if util.IsInvalidEvent(err) { + util.ResponseErrorMessageJSON(w, r, mautrix.MBadJSON, err.Error()) + return + } else if err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } + createEv.RoomID = roomID evs = append(evs, createEv) // 2: An m.room.member event for the creator to join the room. This is needed so the remaining events can be sent. @@ -132,6 +166,16 @@ func (c *ClientRoutes) CreateRoom(w http.ResponseWriter, r *http.Request) { userPowerLevels[uid] = 100 } } + if util.RoomVersionHas(roomVersion, gomatrixserverlib.IRoomVersion.PrivilegedCreators) { + delete(userPowerLevels, userID) + if additional, ok := createContent["additional_creators"].([]any); ok { + for _, value := range additional { + if creator, ok := value.(string); ok { + delete(userPowerLevels, id.UserID(creator)) + } + } + } + } powerContent := map[string]any{ "ban": 50, "events_default": 0, @@ -148,6 +192,9 @@ func (c *ClientRoutes) CreateRoom(w http.ResponseWriter, r *http.Request) { event.StateServerACL: 100, }, } + if util.RoomVersionHas(roomVersion, gomatrixserverlib.IRoomVersion.PrivilegedCreators) { + powerContent["events"].(map[event.Type]int)[event.StateTombstone] = 150 + } if req.PowerLevelOverride != nil { encoded, err := json.Marshal(req.PowerLevelOverride) var parsed event.PowerLevelsEventContent @@ -238,8 +285,8 @@ func (c *ClientRoutes) CreateRoom(w http.ResponseWriter, r *http.Request) { } // 8: Invite events implied by invite and invite_3pid (m.room.member with membership: invite and m.room.third_party_invite). - // Note we'll keep external (federated) invites separate and send them after we create the room - // as we need the room persisted first. + // Local invites are sent with the room, which is created with every one of them or not at all, + // external (federated) ones once it exists, as they need the room persisted. externalInvites := make(map[id.UserID]*types.PartialEvent, 0) for _, uid := range req.Invite { uidStr := string(uid) @@ -256,24 +303,28 @@ func (c *ClientRoutes) CreateRoom(w http.ResponseWriter, r *http.Request) { } } - _, err := c.db.SendLocalEvents(r.Context(), roomID, evs, rooms.SendLocalEventsOptions{ + _, err = c.db.SendLocalEvents(r.Context(), roomID, evs, rooms.SendLocalEventsOptions{ PublishRoom: visibility == "public", RoomAlias: roomAlias, RoomAliasOwner: userID, RequireAllEvents: true, }) - if err != nil { - if errors.Is(err, types.ErrRoomAliasTaken) { - util.ResponseJSON(w, r, http.StatusBadRequest, map[string]string{ - "errcode": mautrix.MRoomInUse.ErrCode, "error": "Room alias taken", - }) - return - } else if errors.Is(err, rooms.ErrRequiredEventRejected) { - util.ResponseJSON(w, r, http.StatusBadRequest, map[string]string{ - "errcode": "M_INVALID_ROOM_STATE", "error": "Initial room state is not allowed", - }) - return - } + var rejected *rooms.RequiredEventRejectedError + if errors.Is(err, types.ErrRoomAliasTaken) { + util.ResponseJSON(w, r, http.StatusBadRequest, map[string]string{ + "errcode": mautrix.MRoomInUse.ErrCode, "error": "Room alias taken", + }) + return + } else if errors.As(err, &rejected) && rejected.Event.Type == event.StateMember && rejected.Event.Membership() == event.MembershipInvite { + // As Synapse fails the request with the invite's error + util.ResponseRejectedEventJSON(w, r, err) + return + } else if rejected != nil { + util.ResponseJSON(w, r, http.StatusBadRequest, map[string]string{ + "errcode": "M_INVALID_ROOM_STATE", "error": "Initial room state is not allowed", + }) + return + } else if err != nil { util.ResponseErrorUnknownJSON(w, r, fmt.Errorf("error sending local events: %w", err)) return } diff --git a/internal/routes/client/roommember.go b/internal/routes/client/roommember.go index 3745ebc..1a9b540 100644 --- a/internal/routes/client/roommember.go +++ b/internal/routes/client/roommember.go @@ -23,8 +23,8 @@ import ( "maunium.net/go/mautrix/id" "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/gomatrixserverlib/fclient" "github.com/matrix-org/gomatrixserverlib/spec" - "github.com/rs/zerolog" "github.com/rs/zerolog/hlog" "github.com/beeper/babbleserv/internal/databases/rooms" @@ -45,20 +45,11 @@ type reqMemberOther struct { // https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv3joined_rooms func (c *ClientRoutes) GetJoinedRooms(w http.ResponseWriter, r *http.Request) { - userID := middleware.GetRequestUserID(r) - memberships, err := c.db.Rooms.GetUserMemberships(r.Context(), userID) + roomIDs, err := c.db.Rooms.JoinedRooms(r.Context(), middleware.GetRequestUserID(r)) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return } - - roomIDs := make([]id.RoomID, 0) - for _, membership := range memberships { - if membership.Membership == event.MembershipJoin { - roomIDs = append(roomIDs, membership.RoomID) - } - } - util.ResponseJSON(w, r, http.StatusOK, mautrix.RespJoinedRooms{JoinedRooms: roomIDs}) } @@ -91,7 +82,7 @@ func (c *ClientRoutes) SendRoomInvite(w http.ResponseWriter, r *http.Request) { content := makeMembershipContent(event.MembershipInvite, req.Reason) partialEv := types.NewPartialEvent(roomID, event.StateMember, &sKey, userID, content) - inviteStateEvs, err := c.db.Rooms.GetCurrentRoomStrippedStateEvents(r.Context(), roomID) + inviteStateEvs, err := c.db.Rooms.RoomStrippedState(r.Context(), roomID) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return @@ -150,7 +141,7 @@ func (c *ClientRoutes) sendRoomJoin(w http.ResponseWriter, r *http.Request) { return } - serverInRoom, err := c.db.Rooms.IsServerJoinedRoom(r.Context(), c.config.ServerName, roomID) + serverInRoom, err := c.db.Rooms.IsServerJoined(r.Context(), roomID, c.config.ServerName) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return @@ -159,109 +150,17 @@ func (c *ClientRoutes) sendRoomJoin(w http.ResponseWriter, r *http.Request) { userID := middleware.GetRequestUserID(r) if serverInRoom { - // The easy path - we're already in the room, so just send the join. We - // re-check the server in room within the send local transaction. + // The easy path - we're already in the room, so just send the join. The send local + // transaction fails with ErrServerNotInRoom should this server have left meanwhile. sKey := userID.String() content := makeMembershipContent(event.MembershipJoin, req.Reason) ev := types.NewPartialEvent(roomID, event.StateMember, &sKey, userID, content) c.sendLocalEventHandleResults(w, r, roomID, ev, func(ev *types.Event) any { return util.EmptyJSON }) + } else if err := c.remoteMembership(r, roomID, event.MembershipJoin, userID, getOtherServers(r, roomID)); err != nil { + responseRemoteMembershipError(w, r, err) } else { - // TODO: lookup any invite - otherServers := getOtherServers(r, roomID) - ev, otherServer, err := c.makeFederatedEvent(r, roomID, otherServers, func(otherServer string) (federatedMakeResp, error) { - makeJoinResp, err := c.fclient.MakeJoin( - r.Context(), - spec.ServerName(c.config.ServerName), - spec.ServerName(otherServer), - roomID.String(), - userID.String(), - ) - return federatedMakeResp{ - makeJoinResp.JoinEvent, - makeJoinResp.RoomVersion, - }, err - }) - if err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } - - // Switch to a background context here - if the client drops the request - // we should still send/receive the join so the state on the remote HS - // and local don't end up diverged. - backgroundCtx := hlog.FromRequest(r).With(). - Str("background_task", "SendFederatedJoin"). - Str("server", otherServer). - Logger(). - WithContext(context.Background()) - - sendJoinResp, err := c.fclient.SendJoin( - backgroundCtx, - spec.ServerName(c.config.ServerName), - spec.ServerName(otherServer), - ev.PDU(), - ) - if err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } - - // Merge the state + auth events, verifying each - eventCount := len(sendJoinResp.StateEvents) + len(sendJoinResp.AuthEvents) - allEvs := make([]*types.Event, 0, eventCount) - seenIDs := make(map[id.EventID]struct{}, eventCount) - for _, b := range append(sendJoinResp.StateEvents, sendJoinResp.AuthEvents...) { - remoteEv := &types.Event{RoomVersion: ev.RoomVersion} - if err := json.Unmarshal(b, remoteEv); err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } - // Verify the event is signed by the senders server (which may not be the one we are - // joining the room via). - verifyErr, err := util.VerifyEvent(backgroundCtx, remoteEv, c.keyStore) - if err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } else if verifyErr != nil { - zerolog.Ctx(backgroundCtx). - Err(verifyErr). - Stringer("event_id", remoteEv.ID). - Stringer("type", remoteEv.Type). - Any("ev", remoteEv). - Msg("Skipping event that failed verification during join") - continue - } - if _, found := seenIDs[remoteEv.ID]; found { - zerolog.Ctx(backgroundCtx).Warn(). - Stringer("event_id", remoteEv.ID). - Msg("Skipping duplicate event in join response") - continue - } - allEvs = append(allEvs, remoteEv) - seenIDs[remoteEv.ID] = struct{}{} - } - // Finally, add our join event we just sent to the server - allEvs = append(allEvs, ev) - - // TODO: check for and fill any missing auth events here - // THIS SHOULD NEVER HAPPEN? Synapse on beeper-dev misses an event in the auth chain - - util.SortEventList(allEvs) - - if _, err = c.db.SendFederatedEvents( - backgroundCtx, roomID, allEvs, - rooms.SendFederatedEventsOptions{ - // We're joining *now* and won't have all prev event history, ultimately we have - // to trust the other HS is giving us the correct state. - RemoteJoinEventID: ev.ID, - }, - ); err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } - util.ResponseJSON(w, r, http.StatusOK, struct { RoomID id.RoomID `json:"room_id"` }{roomID}) @@ -282,7 +181,7 @@ func (c *ClientRoutes) SendRoomKnockAlias(w http.ResponseWriter, r *http.Request return } - serverInRoom, err := c.db.Rooms.IsServerJoinedRoom(r.Context(), c.config.ServerName, roomID) + serverInRoom, err := c.db.Rooms.IsServerJoined(r.Context(), roomID, c.config.ServerName) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return @@ -299,51 +198,9 @@ func (c *ClientRoutes) SendRoomKnockAlias(w http.ResponseWriter, r *http.Request c.sendLocalEventHandleResults(w, r, roomID, ev, func(ev *types.Event) any { return util.EmptyJSON }) + } else if err := c.remoteMembership(r, roomID, event.MembershipKnock, userID, getOtherServers(r, roomID)); err != nil { + responseRemoteMembershipError(w, r, err) } else { - otherServers := getOtherServers(r, roomID) - ev, otherServer, err := c.makeFederatedEvent(r, roomID, otherServers, func(otherServer string) (federatedMakeResp, error) { - makeJoinResp, err := c.fclient.MakeKnock( - r.Context(), - spec.ServerName(c.config.ServerName), - spec.ServerName(otherServer), - roomID.String(), - userID.String(), - []gomatrixserverlib.RoomVersion{}, - ) - return federatedMakeResp{ - makeJoinResp.KnockEvent, - makeJoinResp.RoomVersion, - }, err - }) - if err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } - - // Switch to a background context here - if the client drops the request we should still - // send/receive the knock so the state on the remote HS and local don't end up diverged. - backgroundCtx := hlog.FromRequest(r).With(). - Str("background_task", "SendFederatedKnock"). - Logger(). - WithContext(context.Background()) - - sendKnockResp, err := c.fclient.SendKnock( - backgroundCtx, - spec.ServerName(c.config.ServerName), - spec.ServerName(otherServer), - ev.PDU(), - ) - if err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } - ev.SetUnsigned("knock_room_state", sendKnockResp.KnockRoomState) - - if err := c.db.Rooms.SendFederatedOutlierMembershipEvent(r.Context(), ev); err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } - util.ResponseJSON(w, r, http.StatusOK, util.EmptyJSON) } } @@ -375,7 +232,7 @@ func (c *ClientRoutes) sendRoomLeaveOrKick(w http.ResponseWriter, r *http.Reques isLeave := middleware.GetRequestUserID(r) == leavingUserID // This is the CS API, we're the sender - serverInRoom, err := c.db.Rooms.IsServerJoinedRoom(r.Context(), c.config.ServerName, roomID) + serverInRoom, err := c.db.Rooms.IsServerJoined(r.Context(), roomID, c.config.ServerName) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return @@ -383,7 +240,7 @@ func (c *ClientRoutes) sendRoomLeaveOrKick(w http.ResponseWriter, r *http.Reques // Find membership of the leaving user var membership event.Membership - mtup, currentMemberEv, err := c.db.Rooms.GetUserMembershipAndEvent(r.Context(), leavingUserID, roomID) + mtup, currentMemberEv, err := c.db.Rooms.GetCurrentMembershipAndEvent(r.Context(), leavingUserID, roomID) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return @@ -419,21 +276,26 @@ func (c *ClientRoutes) sendRoomLeaveOrKick(w http.ResponseWriter, r *http.Reques content := makeMembershipContent(event.MembershipLeave, reason) partialEv := types.NewPartialEvent(roomID, event.StateMember, &sKey, sendingUserID, content) - // We're in the room - send the event locally and then, if the other HS isn't in the room, send - // it over to them. + // We're in the room - send the event locally and then, if it replaces an invite or knock whose + // other HS isn't in the room, send it over to them. if serverInRoom { - otherServerInRoom, err := c.db.Rooms.IsServerJoinedRoom(r.Context(), otherHomeserver, roomID) - if err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return + var outlierServer string + if membership != event.MembershipJoin && otherHomeserver != "" { + otherServerInRoom, err := c.db.Rooms.IsServerJoined(r.Context(), roomID, otherHomeserver) + if err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return + } else if !otherServerInRoom { + outlierServer = otherHomeserver + } } c.sendLocalEventHandleResults(w, r, roomID, partialEv, func(ev *types.Event) any { - if otherServerInRoom { + if outlierServer == "" { return util.EmptyJSON } td := &types.ToDevice{ - UserID: id.UserID("@:" + otherHomeserver), + UserID: id.UserID("@:" + outlierServer), Type: types.BabbleservRemoteOutlierEvent, Content: exerrors.Must(json.Marshal(ev)), } @@ -451,35 +313,10 @@ func (c *ClientRoutes) sendRoomLeaveOrKick(w http.ResponseWriter, r *http.Reques // We're not in the room but do know the other HS, whom we assume are in the room, so use them // via the make_leave, send_leave if otherHomeserver != "" { - leaveEv, otherServer, err := c.makeFederatedEvent(r, roomID, []string{otherHomeserver}, func(otherServer string) (federatedMakeResp, error) { - makeJoinResp, err := c.fclient.MakeLeave( - r.Context(), - spec.ServerName(c.config.ServerName), - spec.ServerName(otherServer), - roomID.String(), - leavingUserID.String(), - ) - return federatedMakeResp{ - makeJoinResp.LeaveEvent, - makeJoinResp.RoomVersion, - }, err - }) - if err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } else if err := c.fclient.SendLeave( - r.Context(), - spec.ServerName(c.config.ServerName), - spec.ServerName(otherServer), - leaveEv.PDU(), - ); err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } else if err := c.db.Rooms.SendFederatedOutlierMembershipEvent(r.Context(), leaveEv); err != nil { - util.ResponseErrorUnknownJSON(w, r, err) + if err := c.remoteMembership(r, roomID, event.MembershipLeave, leavingUserID, []string{otherHomeserver}); err != nil { + responseRemoteMembershipError(w, r, err) return } - util.ResponseJSON(w, r, http.StatusOK, util.EmptyJSON) return } @@ -538,3 +375,91 @@ func (c *ClientRoutes) sendBanOrUnban(w http.ResponseWriter, r *http.Request, me return util.EmptyJSON }) } + +// Wraps why another server or this one refused a membership sent through the other +var errMembershipRejected = errors.New("membership rejected") + +// remoteMembership sends target's membership of a room this server is not in via make_/send_ endpoints +func (c *ClientRoutes) remoteMembership( + r *http.Request, + roomID id.RoomID, + membership event.Membership, + target id.UserID, + servers []string, +) error { + origin := spec.ServerName(c.config.ServerName) + ev, server, err := c.makeFederatedEvent(r, roomID, membership, servers, func(server string) (federatedMakeResp, error) { + destination := spec.ServerName(server) + switch membership { + case event.MembershipJoin: + resp, err := c.fclient.MakeJoin(r.Context(), origin, destination, roomID.String(), target.String()) + return federatedMakeResp{resp.JoinEvent, resp.RoomVersion}, err + case event.MembershipKnock: + resp, err := c.fclient.MakeKnock(r.Context(), origin, destination, roomID.String(), target.String(), []gomatrixserverlib.RoomVersion{}) + return federatedMakeResp{resp.KnockEvent, resp.RoomVersion}, err + default: + resp, err := c.fclient.MakeLeave(r.Context(), origin, destination, roomID.String(), target.String()) + return federatedMakeResp{resp.LeaveEvent, resp.RoomVersion}, err + } + }) + if err != nil { + return err + } + + ctx := hlog.FromRequest(r).With(). + Str("background_task", "SendFederatedMembership"). + Str("membership", string(membership)). + Str("server", server). + Logger(). + WithContext(context.Background()) + destination := spec.ServerName(server) + switch membership { + case event.MembershipJoin: + resp, err := c.fclient.SendJoin(ctx, origin, destination, ev.PDU()) + if err != nil { + return err + } + return c.verifyAndSendRemoteJoin(ctx, roomID, ev, resp) + case event.MembershipKnock: + resp, err := c.fclient.SendKnock(ctx, origin, destination, ev.PDU()) + if err != nil { + return err + } + ev.SetUnsigned("knock_room_state", resp.KnockRoomState) + default: + if err := c.fclient.SendLeave(ctx, origin, destination, ev.PDU()); err != nil { + return err + } + } + return c.db.Rooms.SendFederatedOutlierMembershipEvent(ctx, ev) +} + +func (c *ClientRoutes) verifyAndSendRemoteJoin(ctx context.Context, roomID id.RoomID, ev *types.Event, resp fclient.RespSendJoin) error { + seenIDs := make(map[id.EventID]struct{}, len(resp.StateEvents)+len(resp.AuthEvents)) + stateEvs, err := util.VerifyRemoteEvents(ctx, resp.StateEvents, ev.RoomVersion, c.keyStore, seenIDs) + if err != nil { + return err + } + authEvs, err := util.VerifyRemoteEvents(ctx, resp.AuthEvents, ev.RoomVersion, c.keyStore, seenIDs) + if err != nil { + return err + } + res, err := c.db.SendRemoteJoin(ctx, roomID, ev, stateEvs, authEvs) + if errors.Is(err, rooms.ErrAuthStage4) || errors.Is(err, rooms.ErrAuthStage5) { + return fmt.Errorf("%w: %w", errMembershipRejected, err) + } else if err != nil { + return err + } else if len(res.Rejected) > 0 { + // A stored duplicate, or the fallback once another join got this server into the room + return fmt.Errorf("%w: %w", errMembershipRejected, res.Rejected[0].Error) + } + return nil +} + +func responseRemoteMembershipError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, errMembershipRejected) { + util.ResponseRejectedEventJSON(w, r, err) + return + } + responseMakeFederatedEventError(w, r, err) +} diff --git a/internal/routes/client/roommemberutil.go b/internal/routes/client/roommemberutil.go index eca2f51..bcf763e 100644 --- a/internal/routes/client/roommemberutil.go +++ b/internal/routes/client/roommemberutil.go @@ -1,6 +1,9 @@ package client import ( + "cmp" + "errors" + "fmt" "net/http" "strings" "time" @@ -8,9 +11,12 @@ import ( "github.com/matrix-org/gomatrixserverlib" "github.com/matrix-org/gomatrixserverlib/spec" "github.com/rs/zerolog/hlog" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + "github.com/beeper/babbleserv/internal/middleware" "github.com/beeper/babbleserv/internal/types" "github.com/beeper/babbleserv/internal/util" ) @@ -64,55 +70,87 @@ type federatedMakeResp struct { RoomVersion gomatrixserverlib.RoomVersion } +// A server answering make_* with a template this server cannot sign +var errInvalidMembershipTemplate = errors.New("invalid membership event template") + +// makeFederatedEvent signs the template of the first of the other servers giving one it can. When +// none does, the error of the last server whose request failed is returned, or else an +// errInvalidMembershipTemplate. func (c *ClientRoutes) makeFederatedEvent( r *http.Request, roomID id.RoomID, + membership event.Membership, otherServers []string, getMakeResp func(string) (federatedMakeResp, error), ) (*types.Event, string, error) { - var err error - var otherServer string - var resp federatedMakeResp - log := hlog.FromRequest(r) - // We're not in the room - we need to do the join dance to get the room - // current state from one of the remote servers. - for _, otherServer = range otherServers { - log.Debug(). - Str("room_id", roomID.String()). + var requestErr, templateErr error + for _, otherServer := range otherServers { + serverLog := log.With(). + Stringer("room_id", roomID). Str("server", otherServer). - Msg("Attempting to make federated event via server") - - resp, err = getMakeResp(otherServer) + Logger() + serverLog.Debug().Msg("Attempting to make federated event via server") + resp, err := getMakeResp(otherServer) if err != nil { - log.Warn().Err(err). - Str("server", otherServer). - Msg("Failed to make federated event via server") + serverLog.Warn().Err(err).Msg("Failed to make federated event via server") + requestErr = err continue } - break + ev, err := membershipTemplateEvent(middleware.GetRequestUserID(r), roomID, membership, resp) + if err == nil { + keyID, key := c.config.MustGetActiveSigningKey() + err = util.HashAndSignEvent(ev, c.config.ServerName, keyID, key) + } + if err != nil { + serverLog.Warn().Err(err).Msg("Server gave an invalid membership event template") + templateErr = fmt.Errorf("%w from %s: %w", errInvalidMembershipTemplate, otherServer, err) + continue + } + serverLog.Debug().Msg("Making federated event via server") + return ev, otherServer, nil } + return nil, "", cmp.Or(requestErr, templateErr) +} + +// membershipTemplateEvent holds a make_* template to what was asked for and sets the membership +// asked for, as Synapse does +func membershipTemplateEvent( + userID id.UserID, + roomID id.RoomID, + membership event.Membership, + resp federatedMakeResp, +) (*types.Event, error) { + if _, err := gomatrixserverlib.GetRoomVersion(resp.RoomVersion); err != nil { + return nil, err + } + ev, err := types.EventFromProtoEvent(resp.Event) if err != nil { - return nil, "", err + return nil, err + } else if ev.Type != event.StateMember || ev.StateKey == nil || *ev.StateKey != userID.String() || + ev.Sender != userID || ev.RoomID != roomID { + return nil, fmt.Errorf("not a member event of %s in %s", userID, roomID) + } + if !gjson.ParseBytes(ev.Content).IsObject() { + return nil, errors.New("content is not an object") + } else if ev.Content, err = sjson.SetBytes(ev.Content, "membership", membership); err != nil { + return nil, err } - - log.Debug(). - Str("room_id", roomID.String()). - Str("server", otherServer). - Msg("Making federated event via server") - - roomVersion := string(resp.RoomVersion) - - ev := types.EventFromProtoEvent(resp.Event) ev.Timestamp = time.Now().UTC().UnixMilli() - ev.RoomVersion = roomVersion + ev.RoomVersion = string(resp.RoomVersion) + return ev, nil +} - keyID, key := c.config.MustGetActiveSigningKey() - if err := util.HashAndSignEvent(ev, c.config.ServerName, keyID, key); err != nil { - return nil, "", err +// As Synapse does, a server failing to give a usable template is a bad gateway +func responseMakeFederatedEventError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, errInvalidMembershipTemplate) { + util.ResponseJSON(w, r, http.StatusBadGateway, map[string]string{ + "errcode": util.MUnknown.ErrCode, + "error": err.Error(), + }) + return } - - return ev, otherServer, nil + util.ResponseErrorUnknownJSON(w, r, err) } diff --git a/internal/routes/client/roomsend.go b/internal/routes/client/roomsend.go index 5abbfa8..cf6526c 100644 --- a/internal/routes/client/roomsend.go +++ b/internal/routes/client/roomsend.go @@ -19,6 +19,10 @@ func (c *ClientRoutes) SendRoomStateEvent(w http.ResponseWriter, r *http.Request roomID := util.RoomIDFromRequestURLParam(r, "roomID") evType := util.EventTypeFromRequestURLParam(r, "eventType") stateKey := util.StateKeyFromRequestURLParam(r, "stateKey") + if evType == event.StateCreate { + util.ResponseErrorMessageJSON(w, r, mautrix.MBadJSON, "Create events can only be sent by createRoom") + return + } content, respErr := util.ParseRequestJSON[map[string]any](r) if respErr != nil { diff --git a/internal/routes/client/roomutil.go b/internal/routes/client/roomutil.go index 64e9b99..e104e6a 100644 --- a/internal/routes/client/roomutil.go +++ b/internal/routes/client/roomutil.go @@ -42,12 +42,13 @@ func (c *ClientRoutes) sendLocalEventHandleResultsWithOptions( res, err := c.db.SendLocalEvents(r.Context(), roomID, []*types.PartialEvent{partialEv}, options) if errors.Is(err, types.ErrRoomNotFound) { util.ResponseErrorMessageJSON(w, r, mautrix.MNotFound, err.Error()) + } else if errors.Is(err, rooms.ErrServerNotInRoom) { + util.ResponseErrorMessageJSON(w, r, mautrix.MForbidden, err.Error()) } else if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return } else if len(res.Rejected) > 0 { - err := res.Rejected[0].Error - util.ResponseErrorMessageJSON(w, r, mautrix.MForbidden, err.Error()) + util.ResponseRejectedEventJSON(w, r, res.Rejected[0].Error) return } else { ev := res.Allowed[0] @@ -68,11 +69,12 @@ func (c *ClientRoutes) prepareAndSendInviteForRemoteUser( if err != nil { return nil, nil, err } else if len(rejected) > 0 { - return nil, &mautrix.MForbidden, rejected[0].Error + respErr := util.RejectedEventError(rejected[0].Error) + return nil, &respErr, rejected[0].Error } ev := evs[0] - inviteStateEvs, err := c.db.Rooms.GetCurrentRoomStrippedStateEvents(ctx, roomID) + inviteStateEvs, err := c.db.Rooms.RoomStrippedState(ctx, roomID) if err != nil { return nil, nil, err } @@ -124,14 +126,14 @@ func (c *ClientRoutes) prepareAndSendInviteForRemoteUser( // Now that we've prepared, other HS signed and we verified the event we can send it. We send // it as if it's a federated event which triggers all the authorization checks, accounting for // any state changes in the room during the signing process above. - results, err := c.db.SendFederatedEvents(backgroundCtx, roomID, []*types.Event{ev}, rooms.SendFederatedEventsOptions{}) + results, err := c.db.SendFederatedEvents(backgroundCtx, roomID, []*types.Event{ev}, nil) if err != nil { return nil, nil, err } if len(results.Rejected) > 0 { - err := results.Rejected[0].Error - return nil, &mautrix.MForbidden, err + respErr := util.RejectedEventError(results.Rejected[0].Error) + return nil, &respErr, results.Rejected[0].Error } return results, nil, nil diff --git a/internal/routes/federation/roomevent.go b/internal/routes/federation/roomevent.go index e74e4fc..8a93fa0 100644 --- a/internal/routes/federation/roomevent.go +++ b/internal/routes/federation/roomevent.go @@ -3,7 +3,6 @@ package federation import ( "net/http" - "github.com/go-chi/chi/v5" "github.com/rs/zerolog/hlog" "maunium.net/go/mautrix" "maunium.net/go/mautrix/federation" @@ -15,25 +14,28 @@ import ( // https://spec.matrix.org/v1.10/server-server-api/#get_matrixfederationv1eventeventid func (f *FederationRoutes) GetEvent(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "eventID") - // TODO: check server is in room? - ev, err := f.db.Rooms.GetEvent(r.Context(), id.EventID(eventID)) + eventID := util.EventIDFromRequestURLParam(r, "eventID") + ev, err := f.db.Rooms.GetEvent(r.Context(), eventID) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return } else if ev == nil { util.ResponseErrorJSON(w, r, mautrix.MNotFound) return + } else if !f.requireServerInRoom(w, r, ev.RoomID) { + return } util.ResponseJSON(w, r, http.StatusOK, ev) } // https://spec.matrix.org/v1.10/server-server-api/#get_matrixfederationv1event_authroomideventid func (f *FederationRoutes) GetEventAuth(w http.ResponseWriter, r *http.Request) { - // roomID := chi.URLParam(r, "roomID") // don't actually need it! - // TODO: check server is in room? - eventID := chi.URLParam(r, "eventID") - authChain, err := f.db.Rooms.GetEventAuthChain(r.Context(), id.EventID(eventID)) + roomID := util.RoomIDFromRequestURLParam(r, "roomID") + eventID := util.EventIDFromRequestURLParam(r, "eventID") + if !f.requireServerInRoom(w, r, roomID) { + return + } + authChain, err := f.db.Rooms.GetEventAuthChain(r.Context(), roomID, eventID) if err == types.ErrEventNotFound { util.ResponseErrorMessageJSON(w, r, mautrix.MNotFound, "Event not found") return @@ -48,10 +50,13 @@ func (f *FederationRoutes) GetEventAuth(w http.ResponseWriter, r *http.Request) // https://spec.matrix.org/v1.10/server-server-api/#post_matrixfederationv1get_missing_eventsroomid func (f *FederationRoutes) GetMissingEvents(w http.ResponseWriter, r *http.Request) { + roomID := util.RoomIDFromRequestURLParam(r, "roomID") req, respErr := util.ParseRequestJSON[federation.ReqGetMissingEvents](r) if respErr != nil { util.ResponseErrorJSON(w, r, *respErr) return + } else if !f.requireServerInRoom(w, r, roomID) { + return } if req.Limit == 0 { @@ -80,7 +85,7 @@ func (f *FederationRoutes) GetMissingEvents(w http.ResponseWriter, r *http.Reque ev, err := f.db.Rooms.GetEvent(r.Context(), evID) if err != nil { return err - } else if ev != nil { + } else if ev != nil && ev.RoomID == roomID { evs = append(evs, ev) seenEvents[ev.ID] = struct{}{} // Include if *not* one of the latestEventIDs provided diff --git a/internal/routes/federation/roommember.go b/internal/routes/federation/roommember.go index 50167dc..235af27 100644 --- a/internal/routes/federation/roommember.go +++ b/internal/routes/federation/roommember.go @@ -2,13 +2,13 @@ package federation import ( "encoding/json" + "errors" "net/http" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" - "github.com/beeper/babbleserv/internal/databases/rooms" "github.com/beeper/babbleserv/internal/middleware" "github.com/beeper/babbleserv/internal/types" "github.com/beeper/babbleserv/internal/util" @@ -32,6 +32,19 @@ func (f *FederationRoutes) SignInvite(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { util.ResponseErrorJSON(w, r, mautrix.MNotJSON) return + } else if req.Event == nil || req.Event.Type != event.StateMember || req.Event.StateKey == nil { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Event is not a member event") + return + } else if req.Event.Membership() != event.MembershipInvite { + // Stored as an outlier, unauthorized, when this server is not in the room + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Membership is not invite") + return + } else if id.UserID(*req.Event.StateKey).Homeserver() != f.config.ServerName { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "The invite event must be for this server") + return + } else if req.Event.RoomID != roomID { + util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Event room ID does not match the request") + return } req.Event.RoomVersion = req.RoomVersion @@ -57,31 +70,15 @@ func (f *FederationRoutes) SignInvite(w http.ResponseWriter, r *http.Request) { keyID: signature, } - serverInRoom, err := f.db.Rooms.IsServerJoinedRoom(r.Context(), f.config.ServerName, roomID) - if err != nil { + // For a room this server is not in, the invite is stored as an outlier, so the target local user + // sees it in sync without it forming part of the room. Otherwise it is sent as any federated event, + // and received a second time over federation as a duplicate. + if res, err := f.db.SendFederatedEvents(r.Context(), roomID, []*types.Event{req.Event}, nil); err != nil { util.ResponseErrorUnknownJSON(w, r, err) return - } - - if serverInRoom { - // We're in the room - so we can just send it directly as a federated event, we'll receive - // it a second time over federation txn (will be ignored as dupe). - res, err := f.db.SendFederatedEvents(r.Context(), roomID, []*types.Event{req.Event}, rooms.SendFederatedEventsOptions{}) - if err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } else if len(res.Rejected) > 0 { - err := res.Rejected[0].Error - util.ResponseErrorMessageJSON(w, r, mautrix.MForbidden, err.Error()) - return - } - } else { - // We're not in the room - store as an outlier so the target local user sees the invite in - // sync, but does not form part of the room. - if err := f.db.Rooms.SendFederatedOutlierMembershipEvent(r.Context(), req.Event); err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } + } else if len(res.Rejected) > 0 { + util.ResponseRejectedEventJSON(w, r, res.Rejected[0].Error) + return } util.ResponseJSON(w, r, http.StatusOK, struct { @@ -106,10 +103,13 @@ func (f *FederationRoutes) MakeJoin(w http.ResponseWriter, r *http.Request) { // https://spec.matrix.org/v1.11/server-server-api/#put_matrixfederationv2send_joinroomideventid func (f *FederationRoutes) SendJoin(w http.ResponseWriter, r *http.Request) { - f.sendMembershipEventFromOtherServer(w, r, event.MembershipJoin, func(roomID id.RoomID) (any, error) { - // We need to return the state *before* the new join event, so get that now - // before we sent the join. Wasteful if the join fails, possible DDOS risk. - stateWithAuthChain, err := f.db.Rooms.GetCurrentRoomStateEventsWithAuthChain(r.Context(), roomID) + f.sendMembershipEventFromOtherServer(w, r, event.MembershipJoin, func(roomID id.RoomID, eventID id.EventID) (any, error) { + // The state before the join. A join stored before without state, as a prev event's, takes the + // room's current state. + stateWithAuthChain, err := f.db.Rooms.GetRoomStateWithAuthChainAtEvent(r.Context(), roomID, eventID) + if errors.Is(err, types.ErrStateUnavailable) { + stateWithAuthChain, err = f.db.Rooms.GetCurrentRoomStateWithAuthChain(r.Context(), roomID) + } if err != nil { return nil, err } @@ -127,8 +127,8 @@ func (f *FederationRoutes) MakeKnock(w http.ResponseWriter, r *http.Request) { // https://spec.matrix.org/v1.14/server-server-api/#put_matrixfederationv1send_knockroomideventid func (f *FederationRoutes) SendKnock(w http.ResponseWriter, r *http.Request) { - f.sendMembershipEventFromOtherServer(w, r, event.MembershipKnock, func(roomID id.RoomID) (any, error) { - strippedState, err := f.db.Rooms.GetCurrentRoomStrippedStateEvents(r.Context(), roomID) + f.sendMembershipEventFromOtherServer(w, r, event.MembershipKnock, func(roomID id.RoomID, _ id.EventID) (any, error) { + strippedState, err := f.db.Rooms.RoomStrippedState(r.Context(), roomID) if err != nil { return nil, err } diff --git a/internal/routes/federation/roommemberutil.go b/internal/routes/federation/roommemberutil.go index f42bee5..1ed5d5e 100644 --- a/internal/routes/federation/roommemberutil.go +++ b/internal/routes/federation/roommemberutil.go @@ -18,18 +18,6 @@ import ( "github.com/beeper/babbleserv/internal/util" ) -func sendEventErrorResponse(w http.ResponseWriter, r *http.Request, err error) { - if errors.Is(err, rooms.ErrAuthStage4) { - // Stage4 errors happen when the events own auth events don't allow it, so this is an - // invalid input param. - util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, err.Error()) - return - } - // Stage5 errors happen based on the events prev_events (after passing stage4), so should - // be treated as an authorization error. - util.ResponseErrorMessageJSON(w, r, mautrix.MForbidden, err.Error()) -} - func (f *FederationRoutes) makeMembershipEventForOtherServer( w http.ResponseWriter, r *http.Request, @@ -45,11 +33,13 @@ func (f *FederationRoutes) makeMembershipEventForOtherServer( w, r, mautrix.MForbidden, "UserID is from this server, cannot make federated event", ) + return } else if otherServer != middleware.GetRequestServer(r) { util.ResponseErrorMessageJSON( w, r, mautrix.MForbidden, "UserID does not match requesting server origin", ) + return } room, err := f.db.Rooms.GetRoom(r.Context(), roomID) @@ -59,6 +49,10 @@ func (f *FederationRoutes) makeMembershipEventForOtherServer( } else if room == nil { util.ResponseErrorMessageJSON(w, r, mautrix.MNotFound, "Room not found") return + } else if membership == event.MembershipJoin && room.LocalMembers == 0 { + // As Synapse, a join to the stale state of a room this server left could not be sent + util.ResponseErrorMessageJSON(w, r, mautrix.MNotFound, "Not an active room on this server") + return } else if checkRequestVersions { if !slices.Contains(r.URL.Query()["ver"], room.Version) { util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Room version not supported") @@ -102,7 +96,7 @@ func (f *FederationRoutes) makeMembershipEventForOtherServer( util.ResponseErrorUnknownJSON(w, r, err) return } else if len(rejected) > 0 { - sendEventErrorResponse(w, r, rejected[0].Error) + util.ResponseRejectedEventJSON(w, r, rejected[0].Error) return } ev := evs[0] @@ -123,7 +117,7 @@ func (f *FederationRoutes) sendMembershipEventFromOtherServer( w http.ResponseWriter, r *http.Request, membership event.Membership, - getResponseBeforeSend func(id.RoomID) (any, error), + responseAfterSend func(roomID id.RoomID, eventID id.EventID) (any, error), ) { var ev types.Event if err := json.NewDecoder(r.Body).Decode(&ev); err != nil { @@ -140,6 +134,13 @@ func (f *FederationRoutes) sendMembershipEventFromOtherServer( } else if ev.Membership() != membership { util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Membership is incorrect") return + } else if ev.Sender.Homeserver() != middleware.GetRequestServer(r) { + util.ResponseErrorMessageJSON(w, r, mautrix.MForbidden, "User not from origin") + return + } else if *ev.StateKey != ev.Sender.String() { + // As Synapse, before authorizing it: no kicks or bans + util.ResponseErrorMessageJSON(w, r, mautrix.MBadJSON, "state_key and sender must match") + return } roomID := util.RoomIDFromRequestURLParam(r, "roomID") @@ -169,61 +170,31 @@ func (f *FederationRoutes) sendMembershipEventFromOtherServer( return } - var response any = util.EmptyJSON - if getResponseBeforeSend != nil { - response, err = getResponseBeforeSend(roomID) - if err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } + // For a room this server is not in, none is taken, as a remote user's membership is never an + // outlier + res, err := f.db.SendFederatedEvents(r.Context(), roomID, []*types.Event{&ev}, nil) + var rejection error + if err == nil && len(res.Rejected) > 0 { + rejection = res.Rejected[0].Error } - - isInRoom, err := f.db.Rooms.IsServerJoinedRoom(r.Context(), f.config.ServerName, roomID) - if err != nil { + if errors.Is(err, rooms.ErrServerNotInRoom) || errors.Is(rejection, rooms.ErrServerNotInRoom) { + util.ResponseErrorMessageJSON(w, r, mautrix.MNotFound, "Room not found") + return + } else if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return - } else if !isInRoom { - // We're not in the room - there's two cases where this might happen: - // - remote user is rejecting an invite sent from a local user who has now left - // - remote user is rescinding their own invite and we've never been in the room - if membership != event.MembershipLeave { - // Only leave memberships are valid here - util.ResponseErrorMessageJSON(w, r, mautrix.MNotFound, "Room not found, sent membership is not leave") - return - } - targetUserMembership, targetUserMembershipEv, err := f.db.Rooms.GetUserMembershipAndEvent(r.Context(), id.UserID(*ev.StateKey), roomID) - if err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } else if targetUserMembership == nil || targetUserMembership.Membership != event.MembershipInvite { - // Unknown room leaves are only valid if the target user is currently invited - util.ResponseErrorMessageJSON(w, r, mautrix.MNotFound, "Room not found, current membership is not invite") - return - } - if targetUserMembershipEv.Sender.Homeserver() == f.config.ServerName { - // Invite is from a local user (reject) - } else if targetUserMembershipEv.Sender == ev.Sender { - // Invite was sent by the remote user (rescind) - } else { - util.ResponseErrorMessageJSON(w, r, mautrix.MNotFound, "Room not found, not an invite reject or rescind") - return - } + } else if rejection != nil { + util.ResponseRejectedEventJSON(w, r, rejection) + return } - if !isInRoom { - if err := f.db.Rooms.SendFederatedOutlierMembershipEvent(r.Context(), &ev); err != nil { - util.ResponseErrorUnknownJSON(w, r, err) - return - } - } else { - if res, err := f.db.SendFederatedEvents(r.Context(), roomID, []*types.Event{&ev}, rooms.SendFederatedEventsOptions{}); err != nil { + var response any = util.EmptyJSON + if responseAfterSend != nil { + response, err = responseAfterSend(roomID, ev.ID) + if err != nil { util.ResponseErrorUnknownJSON(w, r, err) return - } else if len(res.Rejected) > 0 { - sendEventErrorResponse(w, r, res.Rejected[0].Error) - return } } - util.ResponseJSON(w, r, http.StatusOK, response) } diff --git a/internal/routes/federation/roomstate.go b/internal/routes/federation/roomstate.go index 730dc20..ecc5250 100644 --- a/internal/routes/federation/roomstate.go +++ b/internal/routes/federation/roomstate.go @@ -3,54 +3,84 @@ package federation import ( "net/http" - "github.com/go-chi/chi/v5" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/federation" "maunium.net/go/mautrix/id" + "github.com/beeper/babbleserv/internal/middleware" "github.com/beeper/babbleserv/internal/types" "github.com/beeper/babbleserv/internal/util" ) +// federation.RespGetState, with each event encoded straight into the response +type respGetState struct { + AuthChain []*types.Event `json:"auth_chain"` + PDUs []*types.Event `json:"pdus"` +} + +// requireServerInRoom responds and returns false unless the requesting server is in the room, as +// Synapse refuses a server not joined to the room's current state +func (f *FederationRoutes) requireServerInRoom(w http.ResponseWriter, r *http.Request, roomID id.RoomID) bool { + if joined, err := f.db.Rooms.IsServerJoined(r.Context(), roomID, middleware.GetRequestServer(r)); err != nil { + util.ResponseErrorUnknownJSON(w, r, err) + return false + } else if !joined { + util.ResponseErrorMessageJSON(w, r, mautrix.MForbidden, "Host not in room") + return false + } + return true +} + // https://spec.matrix.org/v1.10/server-server-api/#get_matrixfederationv1stateroomid func (f *FederationRoutes) GetState(w http.ResponseWriter, r *http.Request) { - roomID := chi.URLParam(r, "roomID") + roomID := util.RoomIDFromRequestURLParam(r, "roomID") eventID := r.URL.Query().Get("event_id") if eventID == "" { util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Missing event ID") return - } - state, err := f.db.Rooms.GetRoomStateWithAuthChainAtEvent(r.Context(), id.RoomID(roomID), id.EventID(eventID)) - if err == types.ErrEventNotFound { - util.ResponseErrorMessageJSON(w, r, mautrix.MNotFound, "Event not found") + } else if !f.requireServerInRoom(w, r, roomID) { return - } else if err != nil { - util.ResponseErrorUnknownJSON(w, r, err) + } + state, err := f.db.Rooms.GetRoomStateWithAuthChainAtEvent(r.Context(), roomID, id.EventID(eventID)) + if err != nil { + responseStateAtEventError(w, r, eventID, err) return } - util.ResponseJSON(w, r, http.StatusOK, struct { - State []*types.Event `json:"pdus"` - AuthChain []*types.Event `json:"auth_chain"` - }{state.StateEvents, state.AuthChain}) + util.ResponseJSON(w, r, http.StatusOK, respGetState{ + AuthChain: state.AuthChain, + PDUs: state.StateEvents, + }) } // https://spec.matrix.org/v1.10/server-server-api/#get_matrixfederationv1state_idsroomid func (f *FederationRoutes) GetStateIDs(w http.ResponseWriter, r *http.Request) { - roomID := chi.URLParam(r, "roomID") + roomID := util.RoomIDFromRequestURLParam(r, "roomID") eventID := r.URL.Query().Get("event_id") if eventID == "" { util.ResponseErrorMessageJSON(w, r, mautrix.MInvalidParam, "Missing event ID") return + } else if !f.requireServerInRoom(w, r, roomID) { + return } - stateIDs, err := f.db.Rooms.GetRoomStateWithAuthChainIDsAtEvent(r.Context(), id.RoomID(roomID), id.EventID(eventID)) - if err == types.ErrEventNotFound { - util.ResponseErrorMessageJSON(w, r, mautrix.MNotFound, "Event not found") + stateIDs, err := f.db.Rooms.GetRoomStateWithAuthChainIDsAtEvent(r.Context(), roomID, id.EventID(eventID)) + if err != nil { + responseStateAtEventError(w, r, eventID, err) return - } else if err != nil { + } + util.ResponseJSON(w, r, http.StatusOK, federation.RespGetStateIDs{ + PDUs: stateIDs.StateEventIDs, + AuthChain: stateIDs.AuthChainIDs, + }) +} + +func responseStateAtEventError(w http.ResponseWriter, r *http.Request, eventID string, err error) { + // Exact on purpose: a wrapped ErrEventNotFound for a missing state or auth chain PDU stays a 500 + switch err { + case types.ErrEventNotFound: + util.ResponseErrorMessageJSON(w, r, mautrix.MNotFound, "Event not found") + case types.ErrStateUnavailable: + util.ResponseErrorMessageJSON(w, r, mautrix.MNotFound, "State not known at event "+eventID) + default: util.ResponseErrorUnknownJSON(w, r, err) - return } - util.ResponseJSON(w, r, http.StatusOK, struct { - State []id.EventID `json:"pdu_ids"` - AuthChain []id.EventID `json:"auth_chain_ids"` - }{stateIDs.StateEventIDs, stateIDs.AuthChainIDs}) } diff --git a/internal/routes/federation/transaction.go b/internal/routes/federation/transaction.go index aa0f180..bc2a9d3 100644 --- a/internal/routes/federation/transaction.go +++ b/internal/routes/federation/transaction.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "errors" - "maps" + "fmt" "math" "net/http" "slices" @@ -33,8 +33,8 @@ type reqTransaction struct { Origin string `json:"origin"` OriginTimestamp int64 `json:"origin_server_ts"` - PDUs []*types.Event `json:"pdus"` - EDUs []*types.EDU `json:"edus"` + PDUs []json.RawMessage `json:"pdus"` + EDUs []*types.EDU `json:"edus"` } type respTransactionResult struct { @@ -78,18 +78,14 @@ func (f *FederationRoutes) SendTransaction(w http.ResponseWriter, r *http.Reques var resp *respTransaction var pduErr, eduErr error - wg.Add(1) - go func() { - resp, pduErr = f.processTransactionPDUs(r, req.Origin, req.PDUs) - wg.Done() - }() - - wg.Add(1) - go func() { - eduErr = f.processTransactionEDUs(r, req.EDUs) - wg.Done() - }() - + wg.Go(func() { + defer util.RecoverPanic(&log, &pduErr) + resp, pduErr = f.processTransactionPDUs(r, req.Origin, parseTransactionPDUs(&log, req.PDUs)) + }) + wg.Go(func() { + defer util.RecoverPanic(&log, &eduErr) + eduErr = f.processTransactionEDUs(r, req.Origin, req.EDUs) + }) wg.Wait() if pduErr != nil { @@ -105,7 +101,7 @@ func (f *FederationRoutes) SendTransaction(w http.ResponseWriter, r *http.Reques util.ResponseJSON(w, r, http.StatusOK, resp) } -func (f *FederationRoutes) processTransactionEDUs(r *http.Request, edus []*types.EDU) error { +func (f *FederationRoutes) processTransactionEDUs(r *http.Request, origin string, edus []*types.EDU) error { log := hlog.FromRequest(r) // Group edus by type @@ -116,10 +112,9 @@ func (f *FederationRoutes) processTransactionEDUs(r *http.Request, edus []*types var wg sync.WaitGroup - for eduType, eus := range edusByType { - wg.Add(1) - go func(eduType types.EDUType, edus []*types.EDU) { - defer wg.Done() + for eduType, edus := range edusByType { + wg.Go(func() { + defer util.RecoverPanic(log, nil) switch eduType { case types.EDUTypeDeviceListUpdate: @@ -127,11 +122,13 @@ func (f *FederationRoutes) processTransactionEDUs(r *http.Request, edus []*types var content types.DeviceListUpdateEDUContent if err := json.Unmarshal(edu.Content, &content); err != nil { log.Err(err).Msg("Failed to unmarshal m.device_list_update content") - return + continue + } else if !checkEDUOrigin(log, origin, eduType, content.UserID) { + continue } if err := f.db.Accounts.StoreDeviceChange(r.Context(), content.UserID, content.DeviceID); err != nil { log.Err(err).Msg("Failed to store remote device change") - return + continue } f.notifiers.Accounts.SendChange(notifier.Change{ UserIDs: []id.UserID{content.UserID}, @@ -149,11 +146,13 @@ func (f *FederationRoutes) processTransactionEDUs(r *http.Request, edus []*types var content types.SigningKeyUpdateEDUContent if err := json.Unmarshal(edu.Content, &content); err != nil { log.Err(err).Msg("Failed to unmarshal m.signing_key_update content") - return + continue + } else if !checkEDUOrigin(log, origin, eduType, content.UserID) { + continue } if err := f.db.Accounts.StoreDeviceChange(r.Context(), content.UserID, "*"); err != nil { log.Err(err).Msg("Failed to store remote device change") - return + continue } f.notifiers.Accounts.SendChange(notifier.Change{ UserIDs: []id.UserID{content.UserID}, @@ -162,14 +161,12 @@ func (f *FederationRoutes) processTransactionEDUs(r *http.Request, edus []*types Str("user_id", content.UserID.String()). Msg("Processed remote signing key update") - // TODO: store the keys! } case types.EDUTypeReceipt: // TODO case types.EDUTypePresence: - origin := middleware.GetRequestServer(r) for _, edu := range edus { var content types.PresenceEDUContent if err := json.Unmarshal(edu.Content, &content); err != nil { @@ -177,9 +174,7 @@ func (f *FederationRoutes) processTransactionEDUs(r *http.Request, edus []*types continue } for _, presenceItem := range content.Push { - if presenceItem.UserID.Homeserver() != origin { - log.Warn().Str("origin", origin).Stringer("user_id", presenceItem.UserID). - Msg("Ignoring unauthorized m.presence EDU item") + if !checkEDUOrigin(log, origin, eduType, presenceItem.UserID) { continue } if localpart, _, err := presenceItem.UserID.ParseAndValidateRelaxed(); err != nil || localpart == "" || @@ -209,7 +204,7 @@ func (f *FederationRoutes) processTransactionEDUs(r *http.Request, edus []*types log.Err(err). Str("user_id", presenceItem.UserID.String()). Msg("Failed to store remote presence") - return + continue } log.Debug(). Str("user_id", presenceItem.UserID.String()). @@ -223,7 +218,10 @@ func (f *FederationRoutes) processTransactionEDUs(r *http.Request, edus []*types for _, edu := range edus { var content types.ToDeviceEDUContent if err := json.Unmarshal(edu.Content, &content); err != nil { - panic(err) + log.Err(err).Msg("Failed to unmarshal m.direct_to_device content") + continue + } else if !checkEDUOrigin(log, origin, eduType, content.Sender) { + continue } for userID, deviceIDToContent := range content.Messages { @@ -231,6 +229,7 @@ func (f *FederationRoutes) processTransactionEDUs(r *http.Request, edus []*types log.Error(). Str("user_id", userID.String()). Msg("Ignoring to-device for nonlocal user") + continue } for deviceID, contentB := range deviceIDToContent { cnt, err := json.Marshal(contentB) @@ -250,77 +249,128 @@ func (f *FederationRoutes) processTransactionEDUs(r *http.Request, edus []*types } } } - - _, err := f.db.SendToDeviceEvents(r.Context(), tds, transient.SendToDeviceOptions{}) - if err != nil { - panic(err) + if _, err := f.db.SendToDeviceEvents(r.Context(), tds, transient.SendToDeviceOptions{}); err != nil { + log.Err(err).Msg("Failed to store remote to-device messages") } default: log.Error().Str("type", string(eduType)).Msg("Ignoring unknown EDU type") } - }(eduType, eus) + }) } wg.Wait() return nil } -func (f *FederationRoutes) processTransactionPDUs(r *http.Request, origin string, pdus []*types.Event) (*respTransaction, error) { - type results struct { - rooms.SendEventsResult - Outliers []*types.Event - } - verifyResults := results{ - Outliers: make([]*types.Event, 0), - SendEventsResult: rooms.SendEventsResult{ - Allowed: make([]*types.Event, 0, len(pdus)), - Rejected: make([]rooms.RejectedEvent, 0), - }, +func checkEDUOrigin(log *zerolog.Logger, origin string, eduType types.EDUType, userID id.UserID) bool { + if userID.Homeserver() == origin { + return true } + log.Warn(). + Str("type", string(eduType)). + Stringer("user_id", userID). + Str("origin", origin). + Msg("Dropping EDU about a user of another server") + return false +} - roomVersions := make(map[id.RoomID]string, 1) - - // Run some pre-checks before we send the events to the database layer - for _, ev := range pdus { - if roomVersions[ev.RoomID] == "" { - room, err := f.db.Rooms.GetRoom(r.Context(), ev.RoomID) - if err != nil { - return nil, err - } else if room == nil { - if ev.Type == event.StateCreate && f.config.SecretSwitches.EnableFederatedSendRoomCreate { - // If no room, and this is a create event, and we're allowed to receive create - // events over federation - pull the room version from the event content. - rmver := gjson.GetBytes(ev.Content, "room_version") - if rmver.Exists() { - roomVersions[ev.RoomID] = rmver.String() - } - } - } else { - roomVersions[ev.RoomID] = room.Version - } +// As Synapse does, a PDU that cannot be parsed is dropped without a result +func parseTransactionPDUs(log *zerolog.Logger, raws []json.RawMessage) []*types.Event { + pdus := make([]*types.Event, 0, len(raws)) + for _, raw := range raws { + var ev *types.Event + if err := json.Unmarshal(raw, &ev); err != nil || ev == nil { + log.Warn().Err(err).Msg("Dropping PDU that cannot be parsed") + continue } + pdus = append(pdus, ev) + } + return pdus +} - if roomVersions[ev.RoomID] == "" { - // Edge case: this is a leave event rescinding a previously sent remote invite for a - // local user, so we don't know the room but we still need to handle the leave as an - // outlier so the target local user sees it. - if ev.Type == event.StateMember { - targetUserID := id.UserID(*ev.StateKey) - if targetUserID.Homeserver() == f.config.ServerName { - membershipTup, membershipEv, err := f.db.Rooms.GetUserMembershipAndEvent(r.Context(), targetUserID, ev.RoomID) - if err != nil { - return nil, err - } else if membershipTup != nil && membershipTup.Membership == event.MembershipInvite && membershipEv.Sender == ev.Sender { - // Set the romm version and flag the event as an outlier - roomVersions[ev.RoomID] = membershipEv.RoomVersion - ev.Outlier = true - } - } - } +type pduRoomLookups interface { + GetRoom(ctx context.Context, roomID id.RoomID) (*types.Room, error) + GetCurrentMembershipAndEvent(ctx context.Context, userID id.UserID, roomID id.RoomID) (*types.MembershipTup, *types.Event, error) +} + +// pduRoom is the room of a transaction's PDUs: the version they are verified with, empty for a room +// unknown here, and whether this server is joined to it +type pduRoom struct { + version string + joined bool +} + +// pduRooms looks up the room of each PDU of a transaction once +type pduRooms struct { + ctx context.Context + db pduRoomLookups + serverName string + createRooms bool + rooms map[id.RoomID]pduRoom +} + +func newPDURooms(ctx context.Context, db pduRoomLookups, serverName string, createRooms bool) *pduRooms { + return &pduRooms{ + ctx: ctx, + db: db, + serverName: serverName, + createRooms: createRooms, + rooms: make(map[id.RoomID]pduRoom, 1), + } +} + +// roomVersion returns the version of the PDU's room. A room without a record takes the version of +// its create event, or for a leave of a local user, the version of the invite the leave answers. +func (p *pduRooms) roomVersion(ev *types.Event) (string, error) { + room, found := p.rooms[ev.RoomID] + if !found { + stored, err := p.db.GetRoom(p.ctx, ev.RoomID) + if err != nil { + return "", err + } else if stored != nil { + room = pduRoom{version: stored.Version, joined: stored.LocalMembers > 0} } + p.rooms[ev.RoomID] = room + } + if room.version != "" { + return room.version, nil + } else if ev.Type == event.StateCreate && p.createRooms { + // If no room, and this is a create event, and we're allowed to receive create + // events over federation - pull the room version from the event content. + room.version = gjson.GetBytes(ev.Content, "room_version").String() + p.rooms[ev.RoomID] = room + return room.version, nil + } + return p.invitedVersion(ev) +} - if roomVersions[ev.RoomID] == "" { +func (p *pduRooms) invitedVersion(ev *types.Event) (string, error) { + if ev.Type != event.StateMember || ev.StateKey == nil || ev.Membership() != event.MembershipLeave { + return "", nil + } + targetUserID := id.UserID(*ev.StateKey) + if targetUserID.Homeserver() != p.serverName { + return "", nil + } + membershipTup, membershipEv, err := p.db.GetCurrentMembershipAndEvent(p.ctx, targetUserID, ev.RoomID) + if err != nil || membershipTup == nil || membershipTup.Membership != event.MembershipInvite { + return "", err + } + return membershipEv.RoomVersion, nil +} + +func (f *FederationRoutes) processTransactionPDUs(r *http.Request, origin string, pdus []*types.Event) (*respTransaction, error) { + resp := respTransaction{make(map[id.EventID]respTransactionResult, len(pdus))} + txnRooms := newPDURooms(r.Context(), f.db.Rooms, f.config.ServerName, f.config.SecretSwitches.EnableFederatedSendRoomCreate) + + // Run some pre-checks before we send the events to the database layer + roomToEvs := make(map[id.RoomID][]*types.Event, 5) + for _, ev := range pdus { + roomVersion, err := txnRooms.roomVersion(ev) + if err != nil { + return nil, err + } else if roomVersion == "" { // If we have no room version we can't calculate the reference hash, // so we *silently* drop it (synapse + dendrite do this, spec unclear). hlog.FromRequest(r).Warn(). @@ -329,129 +379,120 @@ func (f *FederationRoutes) processTransactionPDUs(r *http.Request, origin string Msg("Silently dropping event from unknown room") continue } - ev.RoomVersion = roomVersions[ev.RoomID] + ev.RoomVersion = roomVersion - verifyErr, err := util.VerifyEvent(r.Context(), ev, f.keyStore) + verifiedEv, verifyErr, err := util.VerifyRemoteEvent(r.Context(), ev, f.keyStore) if err != nil { return nil, err - } else if errors.Is(verifyErr, types.ErrEventRedacted) { - redactedEv, err := ev.GetRedactedEvent() - if err != nil { - return nil, err - } - redactedEv.RoomVersion = roomVersions[ev.RoomID] - redactedEv.ID = ev.ID - verifyResults.Allowed = append(verifyResults.Allowed, redactedEv) - hlog.FromRequest(r).Warn(). - Stringer("room_id", ev.RoomID). - Stringer("event_id", ev.ID). - Msg("Processing redacted event over federation") } else if verifyErr != nil { - verifyResults.Rejected = append(verifyResults.Rejected, rooms.RejectedEvent{ - Event: ev, - Error: verifyErr, - }) + // https://spec.matrix.org/v1.14/server-server-api/#rejection + // "If an event in an incoming transaction is rejected, this should not cause the transaction request to be responded to with an error response." + resp.PDUs[ev.ID] = respTransactionResult{} hlog.FromRequest(r).Err(verifyErr). Stringer("room_id", ev.RoomID). Stringer("event_id", ev.ID). Msg("Federated event failed verification") - } else if ev.Outlier { - verifyResults.Outliers = append(verifyResults.Outliers, ev) + continue + } + + if verifiedEv.Redacted { hlog.FromRequest(r).Warn(). Stringer("room_id", ev.RoomID). Stringer("event_id", ev.ID). - Msg("Processing outlier event over federation") - } else { - verifyResults.Allowed = append(verifyResults.Allowed, ev) - } - } - - // Split up the PDUs by room - roomToEvs := make(map[id.RoomID][]*types.Event, 5) - for _, pdu := range verifyResults.Allowed { - if _, found := roomToEvs[pdu.RoomID]; !found { - roomToEvs[pdu.RoomID] = make([]*types.Event, 0) + Msg("Processing redacted event over federation") } - roomToEvs[pdu.RoomID] = append(roomToEvs[pdu.RoomID], pdu) + roomToEvs[verifiedEv.RoomID] = append(roomToEvs[verifiedEv.RoomID], verifiedEv) } var wg sync.WaitGroup - resultsCh := make(chan *rooms.SendEventsResult, len(roomToEvs)+1) // +1 for any outliers result + resultsCh := make(chan *rooms.SendEventsResult, len(roomToEvs)) for roomID, evs := range roomToEvs { - wg.Add(1) - go func() { - defer wg.Done() - // Now we have all the events we're going to try to send, fetch any missing - // prev or auth events so we can send those as well (or we'll reject). - var err error - evs, err = f.getMissingEventsForSendBatch(r.Context(), origin, roomID, roomVersions[roomID], evs) - if err != nil { - hlog.FromRequest(r).Err(err).Msg("Get missing events failed") - return + room := txnRooms.rooms[roomID] + wg.Go(func() { + log := hlog.FromRequest(r).With().Stringer("room_id", roomID).Logger() + // A panic leaves the room's events out of the response, as an error does + defer util.RecoverPanic(&log, nil) + var prevStates *rooms.GivenStates + // Events of a room this server is not in are only kept as outlier memberships, which + // need no missing events + if room.joined { + // Now we have all the events we're going to try to send, fetch any missing + // prev or auth events so we can send those as well (or we'll reject). + pulled, err := f.getMissingEventsForSendBatch(r.Context(), origin, roomID, room.version, evs) + if err != nil { + log.Err(err).Msg("Get missing events failed") + return + } + // Pulled events are older so go first, the send keeps the order of unrelated events + evs = slices.Concat(pulled, evs) + prevStates = f.getPrevEventStatesForSendBatch(r.Context(), origin, roomID, room.version, evs, pulled) } - options := rooms.SendFederatedEventsOptions{} - results, err := f.db.SendFederatedEvents(r.Context(), roomID, evs, options) + results, err := f.db.SendFederatedEvents(r.Context(), roomID, evs, prevStates) if err != nil { // This is *BAD*, an unexpected error handling results for a room, we can't bail the // request here as we'll poison other parallel room sends. So we just log and none // of the events in question will be in the response. // Does this make other servers angry? Will they retry those events? - hlog.FromRequest(r).Err(err).Msg("Sending federated events failed") + log.Err(err).Msg("Sending federated events failed") return } resultsCh <- results - }() - } - - if len(verifyResults.Outliers) > 0 { - // Spin up another goroutine to send any outliers - wg.Add(1) - go func() { - defer wg.Done() - - results := &rooms.SendEventsResult{ - Allowed: make([]*types.Event, 0, len(verifyResults.Outliers)), - } - for _, ev := range verifyResults.Outliers { - if err := f.db.Rooms.SendFederatedOutlierMembershipEvent(r.Context(), ev); err != nil { - hlog.FromRequest(r).Err(err).Msg("Sending federated outlier event failed") - continue - } - results.Allowed = append(results.Allowed, ev) - } - resultsCh <- results - }() + }) } wg.Wait() close(resultsCh) - resp := respTransaction{make(map[id.EventID]respTransactionResult, len(pdus))} - - for _, rejected := range verifyResults.Rejected { - resp.PDUs[rejected.Event.ID] = respTransactionResult{ - // https://spec.matrix.org/v1.14/server-server-api/#rejection - // "If an event in an incoming transaction is rejected, this should not cause the transaction request to be responded to with an error response." - // Error: rejected.Error.Error(), - } - } - for results := range resultsCh { for _, allowed := range results.Allowed { resp.PDUs[allowed.ID] = respTransactionResult{} } for _, rejected := range results.Rejected { - resp.PDUs[rejected.Event.ID] = respTransactionResult{ - // As above - // Error: rejected.Error.Error(), + result := respTransactionResult{} + if errors.Is(rejected.Error, rooms.ErrEventDropped) { + // Not stored at all, unlike rejected events + result.Error = rejected.Error.Error() + hlog.FromRequest(r).Warn(). + Err(rejected.Error). + Stringer("room_id", rejected.Event.RoomID). + Stringer("event_id", rejected.Event.ID). + Msg("Dropped federated event from batch") } + resp.PDUs[rejected.Event.ID] = result } } return &resp, nil } +const ( + // Prev events of a room's batch whose state is fetched from the sending server. Events citing + // the others are dropped, and the state fetched again with the next event citing them. + maxPrevEventStatesFetched = 10 + // Events of a prev event's state fetched concurrently when too few are missing to fetch it whole + prevEventStatesFetchConcurrency = 5 + // Both fetches fit in the minute a sending server typically waits for its transaction + getMissingEventsTimeout = 20 * time.Second + prevEventStatesFetchTimeout = 30 * time.Second +) + +// Keeps the events of the room, which another server may not stick to +func eventsInRoom(ctx context.Context, evs []*types.Event, roomID id.RoomID) []*types.Event { + return slices.DeleteFunc(evs, func(ev *types.Event) bool { + if ev.RoomID == roomID { + return false + } + zerolog.Ctx(ctx).Warn(). + Stringer("event_id", ev.ID). + Stringer("event_room_id", ev.RoomID). + Msg("Remote event is for another room, ignoring") + return true + }) +} + +// Pulls the events missing between the room's extremities and the batch events citing prev events +// that are missing here, returning those pulled func (f *FederationRoutes) getMissingEventsForSendBatch( ctx context.Context, origin string, @@ -459,70 +500,213 @@ func (f *FederationRoutes) getMissingEventsForSendBatch( roomVersion string, evs []*types.Event, ) ([]*types.Event, error) { - eventsWithMissingPrevs := make(map[id.EventID]struct{}, len(evs)) - eventsWeHave := make(map[id.EventID]struct{}, len(evs)) - // Add these events incase they reference each other - for _, ev := range evs { - eventsWeHave[ev.ID] = struct{}{} + missing, err := f.db.Rooms.GetMissingEventsRequest(ctx, roomID, evs) + if err != nil { + return nil, err + } else if len(missing.Missing) == 0 { + return nil, nil } - - // Find events that have one or more missing events + var latestIDs []id.EventID for _, ev := range evs { - for _, prevID := range ev.PrevEventIDs { - if _, ok := eventsWeHave[prevID]; ok { - continue - } else if exists, err := f.db.Rooms.DoesEventExist(ctx, prevID); err != nil { - return nil, err - } else if exists { - eventsWeHave[prevID] = struct{}{} - continue - } else { - eventsWithMissingPrevs[ev.ID] = struct{}{} - break - } + if slices.ContainsFunc(ev.PrevEventIDs, func(prevID id.EventID) bool { + _, found := slices.BinarySearch(missing.Missing, prevID) + return found + }) { + latestIDs = append(latestIDs, ev.ID) } } - if len(eventsWithMissingPrevs) == 0 { - return evs, nil - } - - // Now get the current room extremeties we know of, because it's possible we have none of the - // prev events and we need to tell the other HS where to stop searching. - roomExtremIDs, err := f.db.Rooms.GetRoomCurrentExtremEventIDs(ctx, roomID) - if err != nil { - return nil, err - } - + ctx, cancel := context.WithTimeout(ctx, getMissingEventsTimeout) + defer cancel() // TODO: paginate if this doesn't get everything - remoteEvs, err := f.fedclient.GetMissingEvents(ctx, &federation.ReqGetMissingEvents{ + // Events from before this server's earliest timeline event, such as its join, are kept out of the + // timeline as Synapse does, and the state at them fetched instead + resp, err := f.fedclient.GetMissingEvents(ctx, &federation.ReqGetMissingEvents{ ServerName: origin, RoomID: roomID, - EarliestEvents: roomExtremIDs, - LatestEvents: slices.Collect(maps.Keys(eventsWithMissingPrevs)), + EarliestEvents: missing.Extremities, + LatestEvents: latestIDs, Limit: f.config.Federation.MaxFetchMissingEvents, + MinDepth: int(missing.MinDepth), }) if err != nil { return nil, err } - for _, b := range remoteEvs.Events { - var ev types.Event - if err := json.Unmarshal(b, &ev); err != nil { - return nil, err - } - ev.RoomVersion = roomVersion + seenIDs := make(map[id.EventID]struct{}, len(evs)+len(resp.Events)) + for _, ev := range evs { + seenIDs[ev.ID] = struct{}{} + } + remoteEvs, err := util.VerifyRemoteEvents(ctx, resp.Events, roomVersion, f.keyStore, seenIDs) + if err != nil { + return nil, err + } + return slices.DeleteFunc(eventsInRoom(ctx, remoteEvs, roomID), func(ev *types.Event) bool { + // Not every server honours min_depth + return ev.Depth < missing.MinDepth + }), nil +} - if verifyErr, err := util.VerifyEvent(ctx, &ev, f.keyStore); err != nil { - return nil, err - } else if verifyErr != nil { - zerolog.Ctx(ctx).Warn().Err(verifyErr).Msg("Missing event failed verification, ignoring") +func (f *FederationRoutes) getPrevEventStatesForSendBatch( + ctx context.Context, + origin string, + roomID id.RoomID, + roomVersion string, + evs, pulled []*types.Event, +) *rooms.GivenStates { + log := zerolog.Ctx(ctx).With(). + Stringer("room_id", roomID). + Str("origin", origin). + Logger() + + if len(pulled) == 0 { + return nil + } + prevIDs, err := f.db.Rooms.GetPrevEventsWithoutState(ctx, roomID, evs, pulled) + if err != nil { + log.Err(err).Msg("Failed to get prev events without state") + return nil + } else if len(prevIDs) == 0 { + return nil + } else if len(prevIDs) > maxPrevEventStatesFetched { + log.Warn(). + Int("prev_events", len(prevIDs)). + Msg("Too many prev events without state, fetching the state at some of them") + prevIDs = prevIDs[:maxPrevEventStatesFetched] + } + + ctx, cancel := context.WithTimeout(ctx, prevEventStatesFetchTimeout) + defer cancel() + + known := make(map[id.EventID]struct{}, len(evs)) + for _, ev := range evs { + known[ev.ID] = struct{}{} + } + prevStates := &rooms.GivenStates{BeforePrevs: make(map[id.EventID][]id.EventID, len(prevIDs))} + for _, prevID := range prevIDs { + stateIDs, fetched, err := f.fetchPrevEventState(ctx, origin, roomID, roomVersion, prevID, known) + if err != nil { + log.Warn().Err(err).Stringer("prev_event_id", prevID).Msg("Failed to fetch the state at prev event") continue } + prevStates.BeforePrevs[prevID] = stateIDs + prevStates.Events = append(prevStates.Events, fetched...) + for _, ev := range fetched { + known[ev.ID] = struct{}{} + } + } + return prevStates +} + +func (f *FederationRoutes) fetchPrevEventState( + ctx context.Context, + origin string, + roomID id.RoomID, + roomVersion string, + prevID id.EventID, + known map[id.EventID]struct{}, +) ([]id.EventID, []*types.Event, error) { + stateIDs, err := f.fedclient.GetStateIDs(ctx, origin, roomID, prevID) + if err != nil { + return nil, nil, err + } else if stateIDs == nil { + return nil, nil, fmt.Errorf("null state_ids response for %s", prevID) + } + wanted := slices.DeleteFunc( + slices.Concat([]id.EventID{prevID}, stateIDs.PDUs, stateIDs.AuthChain), + func(eventID id.EventID) bool { + _, found := known[eventID] + return found + }, + ) + slices.Sort(wanted) + missing, err := f.db.Rooms.GetUnstoredEventIDs(ctx, slices.Compact(wanted)) + if err != nil { + return nil, nil, err + } + unfetched := make(map[id.EventID]struct{}, len(missing)) + for _, eventID := range missing { + unfetched[eventID] = struct{}{} + } + + var pdus []federation.PDU + // Fetching each event has a lot of overhead, so as Synapse does the whole state is fetched when + // many are missing + if len(missing)*10 >= len(stateIDs.PDUs)+len(stateIDs.AuthChain) { + resp, err := f.fedclient.GetState(ctx, origin, roomID, prevID) + if err != nil { + return nil, nil, err + } else if resp == nil { + return nil, nil, fmt.Errorf("null state response for %s", prevID) + } + pdus = slices.Concat(resp.PDUs, resp.AuthChain) + if _, found := unfetched[prevID]; found { + pdus = append(pdus, f.fetchEvents(ctx, origin, []id.EventID{prevID})...) + } + } else { + pdus = f.fetchEvents(ctx, origin, missing) + } - evs = append(evs, &ev) + pdus = wantedPDUs(ctx, pdus, roomVersion, unfetched) + remoteEvs, err := util.VerifyRemoteEvents(ctx, pdus, roomVersion, f.keyStore, make(map[id.EventID]struct{})) + if err != nil { + return nil, nil, err } + fetched := make([]*types.Event, 0, len(missing)) + for _, ev := range eventsInRoom(ctx, remoteEvs, roomID) { + if _, found := unfetched[ev.ID]; found { + delete(unfetched, ev.ID) + fetched = append(fetched, ev) + } + } + if _, found := unfetched[prevID]; found { + return nil, nil, fmt.Errorf("failed to fetch prev event %s", prevID) + } else if len(unfetched) > 0 { + zerolog.Ctx(ctx).Warn(). + Stringer("prev_event_id", prevID). + Int("events", len(unfetched)). + Msg("Failed to fetch some events of the state at prev event") + } + return stateIDs.PDUs, fetched, nil +} - util.SortEventList(evs) - return evs, nil +// Keeps the PDUs of the wanted events, told apart by reference hash before any is verified +func wantedPDUs(ctx context.Context, pdus []federation.PDU, roomVersion string, wanted map[id.EventID]struct{}) []federation.PDU { + kept := make([]federation.PDU, 0, len(wanted)) + for _, pdu := range pdus { + ev := &types.Event{RoomVersion: roomVersion} + var eventID id.EventID + err := json.Unmarshal(pdu, ev) + if err == nil { + eventID, err = util.GetEventReferenceHash(ev) + } + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Skipping fetched event that cannot be parsed") + } else if _, found := wanted[eventID]; found { + kept = append(kept, pdu) + } + } + return kept +} + +// Fetches events from a server one request each, skipping any that fail +func (f *FederationRoutes) fetchEvents(ctx context.Context, origin string, eventIDs []id.EventID) []federation.PDU { + pdus := make([][]federation.PDU, len(eventIDs)) + limit := make(chan struct{}, prevEventStatesFetchConcurrency) + var wg sync.WaitGroup + for i, eventID := range eventIDs { + limit <- struct{}{} + wg.Go(func() { + defer func() { <-limit }() + defer util.RecoverPanic(zerolog.Ctx(ctx), nil) + resp, err := f.fedclient.GetEvent(ctx, origin, eventID) + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Stringer("event_id", eventID).Msg("Failed to fetch event") + return + } + pdus[i] = resp.PDUs + }) + } + wg.Wait() + return slices.Concat(pdus...) } diff --git a/internal/util/panic.go b/internal/util/panic.go index a7a4450..f422be4 100644 --- a/internal/util/panic.go +++ b/internal/util/panic.go @@ -2,6 +2,7 @@ package util import ( "context" + "fmt" "runtime/debug" "github.com/rs/zerolog" @@ -9,6 +10,13 @@ import ( const panicRetries = 3 +func logPanic(log *zerolog.Logger, r any) { + log.Error(). + Any("recover", r). + Type("recover_type", r). + Msgf("PANIC! %s", debug.Stack()) +} + func PanicRetryLoop(ctx context.Context, log zerolog.Logger, f func()) { var lastPanic any for i := 0; i <= panicRetries; i++ { @@ -19,10 +27,7 @@ func PanicRetryLoop(ctx context.Context, log zerolog.Logger, f func()) { defer func() { if r := recover(); r != nil { lastPanic = r - log.Error(). - Any("recover", r). - Type("recover_type", r). - Msgf("PANIC! %s", debug.Stack()) + logPanic(&log, r) } }() f() @@ -33,3 +38,14 @@ func PanicRetryLoop(ctx context.Context, log zerolog.Logger, f func()) { } panic(lastPanic) } + +// Deferred by goroutines outside a request's recovery, so a panic is logged instead of taking the +// process down. The panic is returned through err unless it is nil. +func RecoverPanic(log *zerolog.Logger, err *error) { + if r := recover(); r != nil { + logPanic(log, r) + if err != nil { + *err = fmt.Errorf("panic: %v", r) + } + } +} From 5b9c465fd020bdd7f1c88c3d0fa15402bea405de Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 5 Oct 2026 14:27:21 +0100 Subject: [PATCH 10/12] Adapt device notifications to exact room memberships Use the room member and server readers for device-change fan-out. Notify local members introduced by remote join state, whose membership events do not pass through the event iterator. Check the joining user's encrypted rooms before sending initial device updates to another server. Compute device_lists.left notifications from local users' shared encrypted rooms. Validation: affected packages build. The two worker helper tests passed before their removal during review. --- internal/workers/devicechangeiterator.go | 14 +- internal/workers/devicejoineventiterator.go | 286 ++++++++++---------- 2 files changed, 153 insertions(+), 147 deletions(-) diff --git a/internal/workers/devicechangeiterator.go b/internal/workers/devicechangeiterator.go index 7557cd6..6554812 100644 --- a/internal/workers/devicechangeiterator.go +++ b/internal/workers/devicechangeiterator.go @@ -166,12 +166,12 @@ func (d *DeviceChangeIterator) processRemoteDeviceChange(lock lock.Lock, change localUserIDs := make(map[id.UserID]struct{}, len(memberships)) for roomID := range memberships { - memberships, err := d.db.Rooms.GetCurrentRoomMemberships(d.ctx, roomID) + roomMembers, err := d.db.Rooms.RoomMembers(d.ctx, roomID, event.MembershipJoin) if err != nil { return fmt.Errorf("failed to get room member IDs: %s: %w", roomID, err) } - for memberID, membershipTup := range memberships { - if membershipTup.Membership == event.MembershipJoin && memberID.Homeserver() == d.config.ServerName { + for memberID := range roomMembers { + if memberID.Homeserver() == d.config.ServerName { localUserIDs[memberID] = struct{}{} } } @@ -213,15 +213,11 @@ func (d *DeviceChangeIterator) processLocalDeviceChange(lock lock.Lock, change t localUserIDs[change.UserID] = struct{}{} for roomID := range memberships { - roomMemberships, err := d.db.Rooms.GetCurrentRoomMemberships(d.ctx, roomID) + roomMembers, err := d.db.Rooms.RoomMembers(d.ctx, roomID, event.MembershipJoin) if err != nil { return fmt.Errorf("failed to get room member IDs: %s: %w", roomID, err) } - for memberID, membershipTup := range roomMemberships { - if membershipTup.Membership != event.MembershipJoin { - // We only care about joined members - continue - } + for memberID := range roomMembers { if memberID.Homeserver() == d.config.ServerName { localUserIDs[memberID] = struct{}{} } else { diff --git a/internal/workers/devicejoineventiterator.go b/internal/workers/devicejoineventiterator.go index 17c7e47..4067466 100644 --- a/internal/workers/devicejoineventiterator.go +++ b/internal/workers/devicejoineventiterator.go @@ -2,10 +2,11 @@ package workers import ( "encoding/json" + "slices" "time" + "github.com/apple/foundationdb/bindings/go/src/fdb/tuple" "github.com/rs/zerolog" - "github.com/samber/lo" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -145,7 +146,7 @@ func (e *DeviceJoinEventIterator) sendLocalDeviceChanges(lock lock.Lock, tups [] ev, err := e.db.Rooms.GetEvent(e.ctx, tup.EventID) if err != nil { return err - } else if ev.IsBabbleProfileUpdate() { + } else if ev.IsBabbleProfileUpdate() && ev.Sender.Homeserver() == e.config.ServerName { // Ignore internal profile updates as these don't actually change membership - note this // doesn't cover profile updates over federation. continue @@ -153,7 +154,7 @@ func (e *DeviceJoinEventIterator) sendLocalDeviceChanges(lock lock.Lock, tups [] var tds []*types.ToDevice switch ev.Membership() { case event.MembershipJoin: - tds, err = e.deviceChangesForJoinEvent(ev) + tds, err = e.deviceChangesForJoin(ev, tup.Version) case event.MembershipLeave: tds, err = e.localDeviceChangesForLeaveEvent(ev) default: @@ -174,32 +175,47 @@ func (e *DeviceJoinEventIterator) sendLocalDeviceChanges(lock lock.Lock, tups [] return nil } -func (e *DeviceJoinEventIterator) deviceChangesForJoinEvent(ev *types.Event) ([]*types.ToDevice, error) { - tds, err := e.localDeviceChangesForJoinEvent(ev) +// deviceChangesForJoin returns the device list changes of a join. A local user's remote join that made +// this server joined also handles the other local members joined with it, whose joins its response +// may have named: those were staged without state and never reach the iterator. +func (e *DeviceJoinEventIterator) deviceChangesForJoin(ev *types.Event, version tuple.Versionstamp) ([]*types.ToDevice, error) { + isLocal := ev.Sender.Homeserver() == e.config.ServerName + if isLocal && !ev.HasStateIn(ev.RoomID) { + return nil, nil + } + roomMembers, err := e.db.Rooms.RoomMembers(e.ctx, ev.RoomID, event.MembershipJoin) if err != nil { return nil, err } - // If this is our user we might need to send m.device_list_updates out - if ev.Sender.Homeserver() == e.config.ServerName { - if rtds, err := e.remoteDeviceChangesForJoinEvent(ev); err != nil { + joiners := []id.UserID{ev.Sender} + if isLocal && !ev.Local { + localMembers, err := e.db.Rooms.LocalMembersJoinedWith(e.ctx, ev, version) + if err != nil { return nil, err - } else { + } + for memberID := range localMembers { + if memberID != ev.Sender { + joiners = append(joiners, memberID) + } + } + } + + var tds []*types.ToDevice + for _, joiner := range joiners { + tds = append(tds, e.localDeviceChangesForJoin(joiner, roomMembers)...) + // If this is our user we might need to send m.device_list_updates out + if joiner.Homeserver() == e.config.ServerName { + rtds, err := e.remoteDeviceChangesForJoin(ev.RoomID, joiner) + if err != nil { + return nil, err + } tds = append(tds, rtds...) } } return tds, nil } -func (e *DeviceJoinEventIterator) localDeviceChangesForJoinEvent(ev *types.Event) ([]*types.ToDevice, error) { - roomMembers, err := e.db.Rooms.GetCurrentRoomMemberships(e.ctx, ev.RoomID) - if err != nil { - return nil, err - } - // We only care about members currently joined in the room - roomMembers = lo.PickBy(roomMembers, func(uid id.UserID, mtup types.MembershipTup) bool { - return mtup.Membership == event.MembershipJoin - }) - +func (e *DeviceJoinEventIterator) localDeviceChangesForJoin(joiner id.UserID, roomMembers types.RoomMemberships) []*types.ToDevice { tds := make([]*types.ToDevice, 0, len(roomMembers)) for memberID := range roomMembers { @@ -210,178 +226,172 @@ func (e *DeviceJoinEventIterator) localDeviceChangesForJoinEvent(ev *types.Event Type: types.BabbleservLocalDeviceChange, UserID: memberID, DeviceID: id.DeviceID("*"), - Sender: ev.Sender, + Sender: joiner, }) } - // If the joining user is local, also notify them about changes to all other members - if ev.Sender.Homeserver() == e.config.ServerName { + // A local joiner tracks the devices of every other member, as Synapse lists them all in + // device_lists.changed for a newly joined room, a remote join's response members included, whose + // events the iterator never sees. + if joiner.Homeserver() == e.config.ServerName { for memberID := range roomMembers { - if memberID == ev.Sender { + if memberID == joiner { continue } tds = append(tds, &types.ToDevice{ Type: types.BabbleservLocalDeviceChange, - UserID: ev.Sender, + UserID: joiner, DeviceID: id.DeviceID("*"), Sender: memberID, }) } } - return tds, nil + return tds } // https://github.com/element-hq/synapse/issues/11374, m.device_list_edus are sent per spec: // ... when that user joins a room which contains servers which are not already receiving updates for that user’s device list -func (e *DeviceJoinEventIterator) remoteDeviceChangesForJoinEvent(ev *types.Event) ([]*types.ToDevice, error) { - // TODO: this is inefficient and inconsistent - we should pull memberships shared between us - // and each other server inside a single txn alongside the encryption filtering. - - // Note: servers are always joined - localServerMemberships, err := e.db.Rooms.GetServerMemberships(e.ctx, e.config.ServerName) +func (e *DeviceJoinEventIterator) remoteDeviceChangesForJoin(roomID id.RoomID, joiner id.UserID) ([]*types.ToDevice, error) { + roomServers, err := e.db.Rooms.RoomServers(e.ctx, roomID) if err != nil { return nil, err } - - roomServers, err := e.db.Rooms.GetCurrentRoomServers(e.ctx, ev.RoomID) + userRooms, err := e.db.Rooms.GetUserJoinedMembershipsWithEncryption(e.ctx, joiner) if err != nil { return nil, err } + serverRooms := make(map[string]types.Memberships, len(roomServers)) + for _, server := range roomServers { + if server == e.config.ServerName { + continue + } else if serverRooms[server], err = e.db.Rooms.GetServerMemberships(e.ctx, server); err != nil { + return nil, err + } + } + servers := serversNewToUser(roomID, userRooms, serverRooms) + if len(servers) == 0 { + return nil, nil + } - devices, err := e.db.Accounts.GetUserDevices(e.ctx, ev.Sender) + devices, err := e.db.Accounts.GetUserDevices(e.ctx, joiner) if err != nil { return nil, err } - getDeviceKeys := util.MemoizeMap(func(k id.DeviceID) (*mautrix.DeviceKeys, error) { // As above, requestUserID="" - return e.db.Accounts.GetDeviceKeys(e.ctx, ev.Sender, k, "") + return e.db.Accounts.GetDeviceKeys(e.ctx, joiner, k, "") }, 10) - tds := make([]*types.ToDevice, 0, len(roomServers)) - - for _, server := range roomServers { - // Find any other rooms shared with this server that also contain the joining user, if none - // we need to initialize the device updates we send to the server for this user. We do this - // by generating a m.device_list_update for each device of the joining user. - memberships, err := e.db.Rooms.GetServerMemberships(e.ctx, server) - if err != nil { - return nil, err - } - var match bool - for roomID := range memberships { - if roomID == ev.RoomID { - continue - } else if _, ok := localServerMemberships[roomID]; !ok { - continue - } else if isEncrypted, err := e.db.Rooms.IsRoomEncrypted(e.ctx, ev.RoomID); err != nil { - return nil, err - } else if !isEncrypted { - continue - } - // Both us and the other server share this other room, check if our joining user is in it - rmMemberships, err := e.db.Rooms.GetCurrentRoomMemberships(e.ctx, ev.RoomID) + tds := make([]*types.ToDevice, 0, len(servers)*len(devices)) + for _, server := range servers { + // Target is the server, not user, but we smuggle such updates through to-device + // internally (see the DeviceChangeIterator). + serverUserID := id.UserID("@:" + server) + for _, d := range devices { + keys, err := getDeviceKeys(d.ID) if err != nil { return nil, err } - if mtup, ok := rmMemberships[ev.Sender]; ok && mtup.Membership == event.MembershipJoin { - match = true - break - } - } - if !match { - // Target is the server, not user, but we smuggle such updates through to-device - // internally (see the DeviceChangeIterator). - serverUserID := id.UserID("@:" + server) - - // We have no matching rooms, generate an update for each joining users device - for _, d := range devices { - keys, err := getDeviceKeys(d.ID) - if err != nil { - return nil, err - } - content := types.DeviceListUpdateEDUContent{ - UserID: ev.Sender, - DeviceID: d.ID, - DeviceKeys: keys, - } - b, _ := json.Marshal(content) - tds = append(tds, &types.ToDevice{ - Type: types.BabbleservRemoteDeviceListUpdate, - UserID: serverUserID, - Content: b, - }) + content := types.DeviceListUpdateEDUContent{ + UserID: joiner, + DeviceID: d.ID, + DeviceKeys: keys, } + b, _ := json.Marshal(content) + tds = append(tds, &types.ToDevice{ + Type: types.BabbleservRemoteDeviceListUpdate, + UserID: serverUserID, + Content: b, + }) } } return tds, nil } -// Leave events are complicated - for every user in the room we need to determine if they still -// share any encrypted memberships with the leaving user. If not we tell both them and the leaving -// user they no longer share any encrypted rooms (device_lists.left). -func (e *DeviceJoinEventIterator) localDeviceChangesForLeaveEvent(ev *types.Event) ([]*types.ToDevice, error) { - leaverMemberships, err := e.db.Rooms.GetUserMemberships(e.ctx, ev.Sender) - if err != nil { - return nil, err +// serversNewToUser returns the servers that share none of the user's encrypted rooms but the one +// they joined, so receive no updates of the user's device list yet +func serversNewToUser(joinedRoomID id.RoomID, userEncryptedRooms types.Memberships, serverRooms map[string]types.Memberships) []string { + var servers []string + for server, rooms := range serverRooms { + shared := false + for roomID := range rooms { + if _, found := userEncryptedRooms[roomID]; found && roomID != joinedRoomID { + shared = true + break + } + } + if !shared { + servers = append(servers, server) + } } - // We only care about rooms the leaving user is joined in - leaverMemberships = lo.PickBy(leaverMemberships, func(rid id.RoomID, mtup types.MembershipTup) bool { - return mtup.Membership == event.MembershipJoin - }) + slices.Sort(servers) + return servers +} - roomMembers, err := e.db.Rooms.GetCurrentRoomMemberships(e.ctx, ev.RoomID) +// Leave events tell each local user, the leaver or another member of the room, of the other side +// once they no longer share an encrypted room (device_lists.left). Only local users are told, so +// every check is whether a local user still shares an encrypted room with another user, read from +// the local user's rows: a local leaver's for every member, otherwise each local member's for the +// leaver. A remote user's rooms are never read. +func (e *DeviceJoinEventIterator) localDeviceChangesForLeaveEvent(ev *types.Event) ([]*types.ToDevice, error) { + roomMembers, err := e.db.Rooms.RoomMembers(e.ctx, ev.RoomID) if err != nil { return nil, err } - - tds := make([]*types.ToDevice, 0, len(roomMembers)) - + leaver := ev.Sender + var others []id.UserID for memberID := range roomMembers { - if memberID == ev.Sender { - // Ignore our own leaves - continue - } else if ev.Sender.Homeserver() != e.config.ServerName && memberID.Homeserver() != e.config.ServerName { - // If neither the leaver or other member are local, nothing to do here - continue + if memberID != leaver && (e.isLocal(leaver) || e.isLocal(memberID)) { + others = append(others, memberID) } - memberships, err := e.db.Rooms.GetUserMemberships(e.ctx, memberID) - if err != nil { + } + shared := make(map[id.UserID]bool, len(others)) + if e.isLocal(leaver) { + if shared, err = e.db.Rooms.UsersSharingEncryptedRoom(e.ctx, leaver, others); err != nil { return nil, err } - var match bool - for roomID, membershipTup := range memberships { - if membershipTup.Membership != event.MembershipJoin { - continue - } - if _, ok := leaverMemberships[roomID]; ok { - match = true - break - } - } - // The leaving user and the still joined user no longer share any room memberships, notify - // them both. - if !match { - if ev.Sender.Homeserver() == e.config.ServerName { - tds = append(tds, &types.ToDevice{ - Type: types.BabbleservLocalDeviceLeft, - Sender: memberID, - UserID: ev.Sender, - DeviceID: id.DeviceID("*"), - }) - } - if memberID.Homeserver() == e.config.ServerName { - tds = append(tds, &types.ToDevice{ - Type: types.BabbleservLocalDeviceLeft, - Sender: ev.Sender, - UserID: memberID, - DeviceID: id.DeviceID("*"), - }) + } else { + for _, memberID := range others { + sharing, err := e.db.Rooms.UsersSharingEncryptedRoom(e.ctx, memberID, []id.UserID{leaver}) + if err != nil { + return nil, err } + shared[memberID] = sharing[leaver] } } + return leftDeviceChanges(e.isLocal, leaver, others, shared), nil +} - return tds, nil +func (e *DeviceJoinEventIterator) isLocal(userID id.UserID) bool { + return userID.Homeserver() == e.config.ServerName +} + +// leftDeviceChanges tells the leaver and each other member, those of them that are local, of the other +// side when they no longer share an encrypted room, as shared reports for each member +func leftDeviceChanges(isLocal func(id.UserID) bool, leaver id.UserID, others []id.UserID, shared map[id.UserID]bool) []*types.ToDevice { + var tds []*types.ToDevice + for _, memberID := range others { + if shared[memberID] { + continue + } + if isLocal(leaver) { + tds = append(tds, &types.ToDevice{ + Type: types.BabbleservLocalDeviceLeft, + Sender: memberID, + UserID: leaver, + DeviceID: id.DeviceID("*"), + }) + } + if isLocal(memberID) { + tds = append(tds, &types.ToDevice{ + Type: types.BabbleservLocalDeviceLeft, + Sender: leaver, + UserID: memberID, + DeviceID: id.DeviceID("*"), + }) + } + } + return tds } From c895911d648fb2bfaca35a121055b804c48533d3 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 5 Oct 2026 15:36:23 +0100 Subject: [PATCH 11/12] Adapt user directory discovery and visibility to current state Use local membership rows and batched current-state lookups to decide which directory candidates are visible, with bounded work per search. Validate discovered remote joins against the room's current membership. Scan remote members after a local user's remote join because the response state does not pass through the event iterator. Add directory users and queue profile fetches only when neither a fetch nor a profile exists. Leave profile-fetch timing and retries unchanged; membership leaves no longer cancel fetches, and current membership controls search visibility. Validation: three directory tests and affected package builds pass. --- .../databases/accounts/remotedirectory.go | 13 + .../accounts/users/remotedirectory.go | 28 +- internal/databases/rooms/userdirectory.go | 419 +++++++++++++----- .../databases/rooms/userdirectory_test.go | 143 ++++++ internal/routes/client/userdirectory.go | 14 +- internal/types/discovery.go | 3 +- .../workers/remoteuserdirectoryiterator.go | 36 +- 7 files changed, 527 insertions(+), 129 deletions(-) create mode 100644 internal/databases/rooms/userdirectory_test.go diff --git a/internal/databases/accounts/remotedirectory.go b/internal/databases/accounts/remotedirectory.go index 2666e5f..8beb144 100644 --- a/internal/databases/accounts/remotedirectory.go +++ b/internal/databases/accounts/remotedirectory.go @@ -21,6 +21,19 @@ func (a *AccountsDatabase) EnsureRemoteDirectoryUsers( return err } +// EnsureUnindexedRemoteDirectoryUsers is EnsureRemoteDirectoryUsers for those of the users with +// neither a profile fetch queued nor a profile fetched, as when indexing every member of a room +func (a *AccountsDatabase) EnsureUnindexedRemoteDirectoryUsers( + ctx context.Context, + sources []types.RemoteUserDirectorySource, + now, lookupAt time.Time, +) error { + _, err := util.DoWriteTransaction(ctx, a.db, func(txn fdb.Transaction) (types.Nil, error) { + return nil, a.users.TxnEnsureUnindexedRemoteDirectoryUsers(txn, sources, now, lookupAt) + }) + return err +} + func (a *AccountsDatabase) NextRemoteDirectoryProfileJob( ctx context.Context, now time.Time, diff --git a/internal/databases/accounts/users/remotedirectory.go b/internal/databases/accounts/users/remotedirectory.go index d0976eb..5592cce 100644 --- a/internal/databases/accounts/users/remotedirectory.go +++ b/internal/databases/accounts/users/remotedirectory.go @@ -56,13 +56,6 @@ func (u *UsersDirectory) TxnEnsureRemoteDirectoryUsers( return err } current := u.txnGetRemoteProfileJob(txn, source.UserID) - if !source.Joined { - if current != nil { - txn.Clear(u.remoteProfileDueKey(*current)) - txn.Clear(u.remoteProfileJobKey(source.UserID)) - } - continue - } user, err := u.txnGetUser(txn, source.UserID) if err != nil { return err @@ -104,6 +97,27 @@ func (u *UsersDirectory) TxnEnsureRemoteDirectoryUsers( return nil } +// Call TxnEnsureRemoteDirectoryUsers for users with neither a profile fetch queued nor a profile stored +func (u *UsersDirectory) TxnEnsureUnindexedRemoteDirectoryUsers( + txn fdb.Transaction, + sources []types.RemoteUserDirectorySource, + now, lookupAt time.Time, +) error { + jobs := make([]fdb.FutureByteSlice, len(sources)) + profiles := make([]fdb.FutureByteSlice, len(sources)) + for i, source := range sources { + jobs[i] = txn.Get(u.remoteProfileJobKey(source.UserID)) + profiles[i] = txn.Get(u.keyForProfile(source.UserID)) + } + unindexed := make([]types.RemoteUserDirectorySource, 0, len(sources)) + for i, source := range sources { + if jobs[i].MustGet() == nil && profiles[i].MustGet() == nil { + unindexed = append(unindexed, source) + } + } + return u.TxnEnsureRemoteDirectoryUsers(txn, unindexed, now, lookupAt) +} + func (u *UsersDirectory) TxnNextRemoteDirectoryProfileJob( txn fdb.ReadTransaction, now time.Time, diff --git a/internal/databases/rooms/userdirectory.go b/internal/databases/rooms/userdirectory.go index 4a5d9e2..99dc040 100644 --- a/internal/databases/rooms/userdirectory.go +++ b/internal/databases/rooms/userdirectory.go @@ -2,6 +2,8 @@ package rooms import ( "context" + "maps" + "slices" "unicode/utf8" "github.com/apple/foundationdb/bindings/go/src/fdb" @@ -13,179 +15,372 @@ import ( "github.com/beeper/babbleserv/internal/util" ) -const maxRemoteMembershipGenerationScan = 256 +// Candidates of a directory search decided together, their lookups in a room made in one batch +const directoryCandidateChunk = 50 +// FilterUserDirectoryCandidates returns, in order, up to maxResults of the candidates the requester +// may see and whether the search was limited. func (r *RoomsDatabase) FilterUserDirectoryCandidates( ctx context.Context, requester id.UserID, candidates []*types.UserDirectoryCandidate, maxResults int, - maxMembershipRows int, - maxMembershipRowsPerUser int, + maxLookups int, + maxLookupsPerUser int, ) ([]*types.UserDirectoryCandidate, bool, error) { type result struct { candidates []*types.UserDirectoryCandidate limited bool } res, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (result, error) { - visible := make([]*types.UserDirectoryCandidate, 0, min(maxResults+1, len(candidates))) - remaining := maxMembershipRows - limited := false - for _, candidate := range candidates { - if candidate.UserID == requester { - continue - } - if remaining <= 1 { - limited = true - break + requesterRows, limited := r.users.TxnLookupUserMembershipRows(txn, requester, maxLookups) + var joinedIDs []id.RoomID + for _, membership := range requesterRows { + if membership.Membership == event.MembershipJoin { + joinedIDs = append(joinedIDs, membership.RoomID) } - // Reserve one row for the membership scan's lookahead. - memberships, more := r.users.TxnLookupUserMembershipRows(txn, candidate.UserID, min(remaining-1, maxMembershipRowsPerUser)) - remaining -= len(memberships) - if more { - remaining-- - } - rooms := make([]fdb.FutureByteSlice, len(memberships)) - for i, membership := range memberships { - if membership.Membership == event.MembershipJoin { - rooms[i] = txn.Get(r.KeyForRoom(membership.RoomID)) + } + joined, err := r.txnGetRooms(txn, joinedIDs) + if err != nil { + return result{}, err + } + f := &directoryFilter{ + isLocal: r.isLocalUser, + shared: slices.DeleteFunc(joined, func(room *types.Room) bool { return room == nil }), + budget: directoryBudget{left: maxLookups, perUser: maxLookupsPerUser, used: make(map[id.UserID]int)}, + rows: func(userID id.UserID, limit int) ([]types.MembershipTup, bool) { + return r.users.TxnLookupUserMembershipRows(txn, userID, limit) + }, + rooms: func(roomIDs []id.RoomID) ([]*types.Room, error) { + return r.txnGetRooms(txn, roomIDs) + }, + members: func(room *types.Room, userIDs []id.UserID) (types.StateEntries, error) { + return r.txnRoomMembers(txn, room, userIDs) + }, + } + + visible := make([]*types.UserDirectoryCandidate, 0, min(maxResults+1, len(candidates))) + for chunk := range slices.Chunk(candidates, directoryCandidateChunk) { + userIDs := make([]id.UserID, 0, len(chunk)) + for _, candidate := range chunk { + if candidate.UserID != requester { + userIDs = append(userIDs, candidate.UserID) } } - found := false - var visibleMembership types.MembershipTup - for i, membership := range memberships { - if membership.Membership != event.MembershipJoin { - continue - } - b := rooms[i].MustGet() - if b == nil { - continue - } - room := types.MustNewRoomFromBytes(b, membership.RoomID) - if room.JoinRule == string(event.JoinRulePublic) || room.HistoryVisibility == string(event.HistoryVisibilityWorldReadable) || - r.users.TxnIsUserJoinedRoom(txn, requester, membership.RoomID) { - found = true - visibleMembership = membership - break - } + found, undecided, err := f.visible(userIDs) + if err != nil { + return result{}, err } - if found { - // Keep room-specific fallbacks out of the global index and of inputs - // reused if this read transaction retries. - result := *candidate - if candidate.UserID.Homeserver() != r.config.ServerName && - (candidate.DisplayName == "" || candidate.AvatarURL == "") { - if ev := r.events.TxnGetEvent(txn, visibleMembership.EventID); ev != nil { - name := gjson.GetBytes(ev.Content, "displayname") - if result.DisplayName == "" && name.Type == gjson.String && utf8.ValidString(name.Str) && - utf8.RuneCountInString(name.Str) <= 255 && len(name.Str) <= 1024 { - result.DisplayName = name.Str - } - avatar := gjson.GetBytes(ev.Content, "avatar_url") - if result.AvatarURL == "" && avatar.Type == gjson.String && len(avatar.Str) <= 4096 { - result.AvatarURL = avatar.Str - } - } + for _, candidate := range chunk { + eventID, ok := found[candidate.UserID] + if !ok { + limited = limited || undecided[candidate.UserID] + continue } - visible = append(visible, &result) + visible = append(visible, r.txnWithMemberProfile(txn, candidate, eventID)) if len(visible) > maxResults { - limited = true - break + return result{visible[:maxResults], true}, nil } - } else if more { - limited = true } } - if len(visible) > maxResults { - visible = visible[:maxResults] - } return result{visible, limited}, nil }) return res.candidates, res.limited, err } -// DiscoverRemoteDirectoryUsersForEvents returns remote users whose event in -// this batch is still the accepted current membership event. This -// validation prevents stale iterator work from scheduling profiles for users -// who have since left or whose membership generation has been replaced. +// txnGetRooms reads the records of the rooms together, nil for a room without one +func (r *RoomsDatabase) txnGetRooms(txn fdb.ReadTransaction, roomIDs []id.RoomID) ([]*types.Room, error) { + futures := make([]fdb.FutureByteSlice, len(roomIDs)) + for i, roomID := range roomIDs { + futures[i] = txn.Get(r.KeyForRoom(roomID)) + } + rooms := make([]*types.Room, len(roomIDs)) + for i, future := range futures { + roomBytes, err := future.Get() + if err != nil { + return nil, err + } else if roomBytes == nil { + continue + } else if rooms[i], err = types.NewRoomFromBytes(roomBytes, roomIDs[i]); err != nil { + return nil, err + } + } + return rooms, nil +} + +// txnWithMemberProfile returns a copy of a remote candidate missing a display name or avatar with +// them taken from the member event that made them visible, keeping room-specific fallbacks out of the +// global index and of inputs reused if the read transaction retries. +func (r *RoomsDatabase) txnWithMemberProfile(txn fdb.ReadTransaction, candidate *types.UserDirectoryCandidate, eventID id.EventID) *types.UserDirectoryCandidate { + result := *candidate + if r.isLocalUser(candidate.UserID) || (candidate.DisplayName != "" && candidate.AvatarURL != "") { + return &result + } + if ev := r.events.TxnGetEvent(txn, eventID); ev != nil { + name := gjson.GetBytes(ev.Content, "displayname") + if result.DisplayName == "" && name.Type == gjson.String && utf8.ValidString(name.Str) && + utf8.RuneCountInString(name.Str) <= 255 && len(name.Str) <= 1024 { + result.DisplayName = name.Str + } + avatar := gjson.GetBytes(ev.Content, "avatar_url") + if result.AvatarURL == "" && avatar.Type == gjson.String && len(avatar.Str) <= 4096 { + result.AvatarURL = avatar.Str + } + } + return &result +} + +// directoryBudget bounds the membership lookups of one directory search: left in all, and perUser +// for one candidate +type directoryBudget struct { + left, perUser int + used map[id.UserID]int +} + +// allows returns how many more lookups the candidate may make +func (b *directoryBudget) allows(userID id.UserID) int { + return max(min(b.left, b.perUser-b.used[userID]), 0) +} + +func (b *directoryBudget) take(userID id.UserID, n int) { + b.left -= n + b.used[userID] += n +} + +// directoryFilter decides which candidates of a directory search the requester sees: those joined +// in a room the requester is joined to, and local users whose rows hold them joined to a room whose +// join rule is public or history visibility world-readable. Local users' rows are exact, so they +// decide both for a local candidate. A remote candidate has no rows, so is looked up in the current +// state of each of the requester's rooms, and one in public rooms the requester shares none of is +// not found. +type directoryFilter struct { + isLocal func(id.UserID) bool + // The rooms the requester is joined to + shared []*types.Room + budget directoryBudget + rows func(userID id.UserID, limit int) ([]types.MembershipTup, bool) + rooms func(roomIDs []id.RoomID) ([]*types.Room, error) + members func(room *types.Room, userIDs []id.UserID) (types.StateEntries, error) +} + +// visible returns the member event making each visible candidate so, and the candidates the budget +// left undecided. +func (f *directoryFilter) visible(userIDs []id.UserID) (map[id.UserID]id.EventID, map[id.UserID]bool, error) { + found := make(map[id.UserID]id.EventID) + undecided := make(map[id.UserID]bool) + sharedIDs := make(map[id.RoomID]bool, len(f.shared)) + for _, room := range f.shared { + sharedIDs[room.ID] = true + } + var remote []id.UserID + for _, userID := range userIDs { + if !f.isLocal(userID) { + remote = append(remote, userID) + continue + } + eventID, decided, err := f.localVisible(userID, sharedIDs) + if err != nil { + return nil, nil, err + } else if eventID != "" { + found[userID] = eventID + } else if !decided { + undecided[userID] = true + } + } + + looked := make(map[id.UserID]int, len(remote)) + for _, room := range f.shared { + var lookup []id.UserID + for _, userID := range remote { + if _, ok := found[userID]; !ok && f.budget.allows(userID) > 0 { + f.budget.take(userID, 1) + lookup = append(lookup, userID) + looked[userID]++ + } + } + if len(lookup) == 0 { + break + } + members, err := f.members(room, lookup) + if err != nil { + return nil, nil, err + } + for _, userID := range lookup { + if member := members[types.MemberStateTup(userID)]; member.Membership == event.MembershipJoin { + found[userID] = member.EventID + } + } + } + for _, userID := range remote { + if _, ok := found[userID]; !ok && looked[userID] < len(f.shared) { + undecided[userID] = true + } + } + return found, undecided, nil +} + +// localVisible returns the event of a local candidate's join row making them visible, and whether +// their rows decided it, as they did unless the budget cut them short. One row of the budget is kept +// for the read telling whether there are more. +func (f *directoryFilter) localVisible(userID id.UserID, sharedIDs map[id.RoomID]bool) (id.EventID, bool, error) { + limit := f.budget.allows(userID) - 1 + if limit <= 0 { + return "", false, nil + } + memberships, more := f.rows(userID, limit) + f.budget.take(userID, len(memberships)) + if more { + f.budget.take(userID, 1) + } + var joined []types.MembershipTup + for _, membership := range memberships { + if membership.Membership != event.MembershipJoin { + continue + } else if sharedIDs[membership.RoomID] { + return membership.EventID, true, nil + } + joined = append(joined, membership) + } + roomIDs := make([]id.RoomID, len(joined)) + for i, membership := range joined { + roomIDs[i] = membership.RoomID + } + rooms, err := f.rooms(roomIDs) + if err != nil { + return "", false, err + } + for i, room := range rooms { + if room != nil && (room.JoinRule == string(event.JoinRulePublic) || room.HistoryVisibility == string(event.HistoryVisibilityWorldReadable)) { + return joined[i].EventID, true, nil + } + } + return "", !more, nil +} + +// DiscoverRemoteDirectoryUsersForEvents returns the remote users whose join in this batch is still +// their accepted member event in current state, so stale iterator work schedules no profile fetch. +// +// It also returns the rooms of the batch's remote joins of local users that +// this server is joined to, once each. A remote join's response events are +// staged without entering the index of all events, so the iterator never sees +// them: every remote member of those rooms is indexed instead, see +// RoomRemoteDirectorySources, whatever the joiner's membership is by now. func (r *RoomsDatabase) DiscoverRemoteDirectoryUsersForEvents( ctx context.Context, eventTups []types.EventTupWithVersion, -) ([]types.RemoteUserDirectorySource, error) { - return util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) ([]types.RemoteUserDirectorySource, error) { - sources := make([]types.RemoteUserDirectorySource, 0, len(eventTups)) +) ([]types.RemoteUserDirectorySource, []id.RoomID, error) { + type discovered struct { + sources []types.RemoteUserDirectorySource + joinedRooms []id.RoomID + } + res, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (discovered, error) { + var joinEvs []*types.Event + remoteJoinRooms := make(map[id.RoomID]struct{}) + roomUsers := make(map[id.RoomID][]id.UserID) for _, eventTup := range eventTups { if eventTup.Type.String() != event.StateMember.Type { continue } ev := r.events.TxnGetEvent(txn, eventTup.EventID) if ev == nil || ev.Rejected || ev.SoftFailed || ev.Outlier || ev.StateKey == nil || - ev.RoomID != eventTup.RoomID || ev.Type.String() != event.StateMember.Type { + ev.RoomID != eventTup.RoomID || !isJoinEvent(ev) { continue } userID := id.UserID(*ev.StateKey) if userID.Homeserver() == r.config.ServerName { + // The resident server sends a remote join, so it is not local + if !ev.Local { + remoteJoinRooms[ev.RoomID] = struct{}{} + } continue } if _, _, err := userID.ParseAndValidateRelaxed(); err != nil { continue } - membershipBytes, err := txn.Get(r.events.KeyForCurrentRoomMember(ev.RoomID, userID)).Get() + joinEvs = append(joinEvs, ev) + roomUsers[ev.RoomID] = append(roomUsers[ev.RoomID], userID) + } + currentMembers := make(map[id.RoomID]types.StateEntries, len(roomUsers)) + rooms := make(map[id.RoomID]*types.Room, len(roomUsers)) + for roomID, userIDs := range roomUsers { + room, err := roomOrNil(r.txnGetRoom(txn, roomID)) if err != nil { - return nil, err - } else if membershipBytes == nil { - continue + return discovered{}, err + } else if currentMembers[roomID], err = r.txnRoomMembers(txn, room, userIDs); err != nil { + return discovered{}, err } - membership := types.BytesToMembershipTup(membershipBytes) - if membership.EventID != ev.ID { - continue - } - sourceEventID, sourceRoomID := ev.ID, ev.RoomID - joined := membership.Membership == event.MembershipJoin - if !joined { - // A user may leave one room while remaining joined elsewhere. - // In that case retain a live join as the account-side profile - // generation. If the bounded scan is inconclusive, preserve the - // existing generation; the search visibility scan is independently - // conservative under the same condition. - currentMemberships, more := r.users.TxnLookupUserMembershipRows( - txn, userID, maxRemoteMembershipGenerationScan, - ) - for _, current := range currentMemberships { - if current.Membership == event.MembershipJoin { - joined = true - sourceEventID, sourceRoomID = current.EventID, current.RoomID - break - } - } - if !joined && more { - continue - } + rooms[roomID] = room + } + + var res discovered + for _, roomID := range slices.Sorted(maps.Keys(remoteJoinRooms)) { + if room, err := roomOrNil(r.txnGetRoom(txn, roomID)); err != nil { + return discovered{}, err + } else if isServerJoined(room) { + res.joinedRooms = append(res.joinedRooms, roomID) } - source := types.RemoteUserDirectorySource{ - UserID: userID, SourceEventID: sourceEventID, Joined: joined, + } + for _, ev := range joinEvs { + userID := id.UserID(*ev.StateKey) + if currentMembers[ev.RoomID][types.MemberStateTup(userID)].EventID == ev.ID { + res.sources = append(res.sources, types.RemoteUserDirectorySource{ + UserID: userID, SourceEventID: ev.ID, + Profile: r.txnGetPublicRoomMemberProfile(txn, rooms[ev.RoomID], ev.ID), + }) } - if joined { - source.Profile = r.txnGetPublicRoomMemberProfile(txn, sourceRoomID, sourceEventID) + } + return res, nil + }) + return res.sources, res.joinedRooms, err +} + +// RoomRemoteDirectorySources returns the remote users joined in the room's current state as the +// record gives it, among a page of its members from bucket from, see state.Batch.TxnMembersPage, and +// the bucket the next page starts at, false once there is none. +func (r *RoomsDatabase) RoomRemoteDirectorySources( + ctx context.Context, + room *types.Room, + from uint64, + limit int, +) ([]types.RemoteUserDirectorySource, uint64, bool, error) { + type page struct { + sources []types.RemoteUserDirectorySource + next uint64 + more bool + } + res, err := util.DoReadTransaction(ctx, r.db, func(txn fdb.ReadTransaction) (page, error) { + members, next, more, err := r.state.NewBatch(room.ID).TxnMembersPage(txn, roomCurrentState(room), from, limit) + if err != nil { + return page{}, err + } + res := page{next: next, more: more} + for _, userID := range slices.Sorted(maps.Keys(members)) { + member := members[userID] + if member.Membership != event.MembershipJoin || r.isLocalUser(userID) { + continue + } else if _, _, err := userID.ParseAndValidateRelaxed(); err != nil { + continue } - sources = append(sources, source) + res.sources = append(res.sources, types.RemoteUserDirectorySource{ + UserID: userID, SourceEventID: member.EventID, + Profile: r.txnGetPublicRoomMemberProfile(txn, room, member.EventID), + }) } - return sources, nil + return res, nil }) + return res.sources, res.next, res.more, err } // As in Synapse, a member event in a public or world-readable room is taken as // the user's global profile rather than making a federation profile request. func (r *RoomsDatabase) txnGetPublicRoomMemberProfile( txn fdb.ReadTransaction, - roomID id.RoomID, + room *types.Room, eventID id.EventID, ) *types.UserProfile { - b := txn.Get(r.KeyForRoom(roomID)).MustGet() - if b == nil { + if room == nil { return nil } - room := types.MustNewRoomFromBytes(b, roomID) if room.JoinRule != string(event.JoinRulePublic) && room.HistoryVisibility != string(event.HistoryVisibilityWorldReadable) { return nil } diff --git a/internal/databases/rooms/userdirectory_test.go b/internal/databases/rooms/userdirectory_test.go new file mode 100644 index 0000000..6186928 --- /dev/null +++ b/internal/databases/rooms/userdirectory_test.go @@ -0,0 +1,143 @@ +package rooms + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/babbleserv/internal/types" +) + +const ( + localTestServer = "local.example" + localBob id.UserID = "@bob:local.example" + remoteCarol id.UserID = "@carol:remote.example" + remoteDave id.UserID = "@dave:remote.example" +) + +// directoryTestRooms are the rooms of a directory filter test: their records, each user's rows, and +// each room's current state members +type directoryTestRooms struct { + records map[id.RoomID]*types.Room + rows map[id.UserID][]types.MembershipTup + members map[id.RoomID]types.StateEntries + lookups int +} + +func (d *directoryTestRooms) filter(shared []id.RoomID, lookups, perUser int) *directoryFilter { + f := &directoryFilter{ + isLocal: func(userID id.UserID) bool { return userID.Homeserver() == localTestServer }, + budget: directoryBudget{left: lookups, perUser: perUser, used: make(map[id.UserID]int)}, + rows: func(userID id.UserID, limit int) ([]types.MembershipTup, bool) { + rows := d.rows[userID] + if len(rows) > limit { + return rows[:limit], true + } + return rows, false + }, + rooms: func(roomIDs []id.RoomID) ([]*types.Room, error) { + rooms := make([]*types.Room, len(roomIDs)) + for i, roomID := range roomIDs { + rooms[i] = d.records[roomID] + } + return rooms, nil + }, + members: func(room *types.Room, userIDs []id.UserID) (types.StateEntries, error) { + d.lookups += len(userIDs) + found := make(types.StateEntries) + for _, userID := range userIDs { + if entry, ok := d.members[room.ID][types.MemberStateTup(userID)]; ok { + found[types.MemberStateTup(userID)] = entry + } + } + return found, nil + }, + } + for _, roomID := range shared { + f.shared = append(f.shared, d.records[roomID]) + } + return f +} + +func newDirectoryTestRooms() *directoryTestRooms { + const private, other, public, readable = "!private:local.example", "!other:local.example", "!public:local.example", "!readable:local.example" + row := func(eventID id.EventID, roomID id.RoomID, membership event.Membership) types.MembershipTup { + return types.MembershipTup{EventID: eventID, RoomID: roomID, Membership: membership} + } + member := func(eventID id.EventID, membership event.Membership) types.StateEntry { + return types.StateEntry{EventID: eventID, Membership: membership} + } + return &directoryTestRooms{ + records: map[id.RoomID]*types.Room{ + private: {ID: private}, + other: {ID: other}, + public: {ID: public, JoinRule: string(event.JoinRulePublic)}, + readable: {ID: readable, HistoryVisibility: string(event.HistoryVisibilityWorldReadable)}, + }, + rows: map[id.UserID][]types.MembershipTup{ + localBob: {row("$bobPublic", public, event.MembershipJoin)}, + "@erin:" + localTestServer: { + row("$erinLeft", private, event.MembershipLeave), row("$erinOther", other, event.MembershipJoin), row("$erinReadable", readable, event.MembershipJoin), + }, + "@finn:" + localTestServer: {row("$finnOther", other, event.MembershipJoin), row("$finnPrivate", private, event.MembershipJoin)}, + }, + members: map[id.RoomID]types.StateEntries{ + private: { + types.MemberStateTup(remoteCarol): member("$carolPrivate", event.MembershipJoin), + types.MemberStateTup(remoteDave): member("$daveLeft", event.MembershipLeave), + }, + other: { + types.MemberStateTup(remoteDave): member("$daveOther", event.MembershipJoin), + }, + public: { + types.MemberStateTup("@gus:remote.example"): member("$gusPublic", event.MembershipJoin), + }, + }, + } +} + +func TestDirectoryShowsUsersSharingARoomAndLocalUsersOfPublicRooms(t *testing.T) { + d := newDirectoryTestRooms() + f := d.filter([]id.RoomID{"!private:local.example"}, 1000, 100) + found, undecided, err := f.visible([]id.UserID{ + localBob, "@erin:" + localTestServer, "@finn:" + localTestServer, remoteCarol, remoteDave, "@gus:remote.example", + }) + require.NoError(t, err) + assert.Equal(t, map[id.UserID]id.EventID{ + localBob: "$bobPublic", + "@erin:" + localTestServer: "$erinReadable", + "@finn:" + localTestServer: "$finnPrivate", + remoteCarol: "$carolPrivate", + }, found, "a remote user who left the shared room, or is only in a public room, is not found") + assert.Empty(t, undecided) +} + +func TestDirectoryLooksRemoteUsersUpInEachSharedRoomOnceFound(t *testing.T) { + d := newDirectoryTestRooms() + f := d.filter([]id.RoomID{"!private:local.example", "!other:local.example"}, 1000, 100) + found, undecided, err := f.visible([]id.UserID{remoteCarol, remoteDave}) + require.NoError(t, err) + assert.Equal(t, map[id.UserID]id.EventID{remoteCarol: "$carolPrivate", remoteDave: "$daveOther"}, found) + assert.Empty(t, undecided) + assert.Equal(t, 3, d.lookups, "Carol is not looked up again once found") +} + +func TestDirectoryBudgetLeavesCandidatesUndecided(t *testing.T) { + d := newDirectoryTestRooms() + f := d.filter([]id.RoomID{"!private:local.example", "!other:local.example"}, 1000, 1) + found, undecided, err := f.visible([]id.UserID{remoteDave, "@erin:" + localTestServer}) + require.NoError(t, err) + assert.Empty(t, found, "one lookup per user reaches neither Dave's second room nor any of Erin's rows") + assert.Equal(t, map[id.UserID]bool{remoteDave: true, "@erin:" + localTestServer: true}, undecided) + + d = newDirectoryTestRooms() + f = d.filter([]id.RoomID{"!private:local.example"}, 3, 100) + found, undecided, err = f.visible([]id.UserID{"@erin:" + localTestServer, remoteDave}) + require.NoError(t, err) + assert.Empty(t, found, "Erin's rows are cut short and take every lookup with the one telling there are more, leaving Dave none") + assert.Equal(t, map[id.UserID]bool{remoteDave: true, "@erin:" + localTestServer: true}, undecided) + assert.Equal(t, 0, f.budget.left) +} diff --git a/internal/routes/client/userdirectory.go b/internal/routes/client/userdirectory.go index 3e74d3c..babdb55 100644 --- a/internal/routes/client/userdirectory.go +++ b/internal/routes/client/userdirectory.go @@ -11,12 +11,12 @@ import ( ) const ( - defaultUserDirectoryLimit = 10 - maxUserDirectoryLimit = 100 - maxUserDirectoryIndexCandidates = 500 - maxUserDirectoryIndexRows = 2000 - maxUserDirectoryMembershipRows = 4096 - maxUserDirectoryMembershipRowsUser = 256 + defaultUserDirectoryLimit = 10 + maxUserDirectoryLimit = 100 + maxUserDirectoryIndexCandidates = 500 + maxUserDirectoryIndexRows = 2000 + maxUserDirectoryLookups = 4096 + maxUserDirectoryLookupsPerUser = 256 ) type userDirectorySearchRequest struct { @@ -61,7 +61,7 @@ func (c *ClientRoutes) SearchUserDirectory(w http.ResponseWriter, r *http.Reques } visible, visibilityLimited, err := c.db.Rooms.FilterUserDirectoryCandidates( r.Context(), middleware.GetRequestUserID(r), candidates, limit, - maxUserDirectoryMembershipRows, maxUserDirectoryMembershipRowsUser, + maxUserDirectoryLookups, maxUserDirectoryLookupsPerUser, ) if err != nil { util.ResponseErrorUnknownJSON(w, r, err) diff --git a/internal/types/discovery.go b/internal/types/discovery.go index f21ca24..c40e4d8 100644 --- a/internal/types/discovery.go +++ b/internal/types/discovery.go @@ -43,7 +43,7 @@ type UserDirectoryResponse struct { Results []*UserDirectoryCandidate `json:"results"` } -// RemoteUserDirectorySource identifies the current remote membership event +// RemoteUserDirectorySource identifies the current remote join event // which made a user known to this homeserver. SourceEventID acts as a stable // generation for pending profile refresh jobs. Profile is set when the source // membership is in a public or world-readable room, whose member event profile @@ -51,7 +51,6 @@ type UserDirectoryResponse struct { type RemoteUserDirectorySource struct { UserID id.UserID SourceEventID id.EventID - Joined bool Profile *UserProfile } diff --git a/internal/workers/remoteuserdirectoryiterator.go b/internal/workers/remoteuserdirectoryiterator.go index d4a0b2b..de59dd8 100644 --- a/internal/workers/remoteuserdirectoryiterator.go +++ b/internal/workers/remoteuserdirectoryiterator.go @@ -7,6 +7,7 @@ import ( "github.com/matrix-org/gomatrixserverlib/fclient" "github.com/matrix-org/gomatrixserverlib/spec" "github.com/rs/zerolog" + "maunium.net/go/mautrix/id" "github.com/beeper/babbleserv/internal/config" "github.com/beeper/babbleserv/internal/databases" @@ -22,6 +23,7 @@ const ( remoteUserDirectoryLockTimeout = 30 * time.Second remoteUserDirectoryPollInterval = 5 * time.Second remoteUserDirectoryBatchSize = 10 + remoteUserDirectoryRoomPage = 100 remoteUserDirectoryLookupTimeout = 10 * time.Second remoteUserDirectoryRetryDelay = time.Minute remoteUserDirectoryMaxRetries = 8 @@ -106,7 +108,7 @@ func (w *RemoteUserDirectoryIterator) processEventBatch(workerLock lock.Lock) bo } else if len(eventTups) == 0 { return false } - sources, err := w.db.Rooms.DiscoverRemoteDirectoryUsersForEvents(w.ctx, eventTups) + sources, joinedRooms, err := w.db.Rooms.DiscoverRemoteDirectoryUsersForEvents(w.ctx, eventTups) if err != nil { w.log.Err(err).Msg("Failed to validate remote directory memberships") return false @@ -120,6 +122,12 @@ func (w *RemoteUserDirectoryIterator) processEventBatch(workerLock lock.Lock) bo return false } } + for _, roomID := range joinedRooms { + if err := w.indexRoomMembers(workerLock, roomID); err != nil { + w.log.Err(err).Stringer("room_id", roomID).Msg("Failed to index the remote members of a joined room") + return false + } + } position = eventTups[len(eventTups)-1].Version if err = w.db.System.UpdateIteratorPositions( w.ctx, remoteUserDirectoryPositionsKey, position, workerLock.TxnRefresh, @@ -130,6 +138,32 @@ func (w *RemoteUserDirectoryIterator) processEventBatch(workerLock lock.Lock) bo return true } +// indexRoomMembers indexes the remote members of a room this server joined that are not indexed +// already, from its current state a page of members at a time, as the join's response events are +// staged without entering the index of all events the iterator walks. +func (w *RemoteUserDirectoryIterator) indexRoomMembers(workerLock lock.Lock, roomID id.RoomID) error { + room, err := w.db.Rooms.GetRoom(w.ctx, roomID) + if err != nil || room == nil { + return err + } + for from, more := uint64(0), true; more; { + workerLock.Refresh() + var sources []types.RemoteUserDirectorySource + if sources, from, more, err = w.db.Rooms.RoomRemoteDirectorySources(w.ctx, room, from, remoteUserDirectoryRoomPage); err != nil { + return err + } else if len(sources) == 0 { + continue + } + now := time.Now().UTC() + if err := w.db.Accounts.EnsureUnindexedRemoteDirectoryUsers( + w.ctx, sources, now, now.Add(remoteUserDirectoryLookupDelay), + ); err != nil { + return err + } + } + return nil +} + func (w *RemoteUserDirectoryIterator) processProfileJob(workerLock lock.Lock) bool { now := time.Now().UTC() job, err := w.db.Accounts.NextRemoteDirectoryProfileJob(w.ctx, now) From e789f13df1e05e5a7284e1fe98ed2de9d90328a4 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Mon, 5 Oct 2026 17:40:37 +0100 Subject: [PATCH 12/12] Document state storage and event sending Describe the FoundationDB records, immutable state and membership trees, context history, caching, and auth-chain cover. Document sender ownership, transaction boundaries, guarded publication, staging, artifact jobs, retries, and sparse state resolution in a separate event-sending guide. Link both guides from the project structure documentation, refresh the schema reference, and exclude test files from schema generation. --- cmd/gendocs/main.go | 15 +- docs/data-model-generated.md | 125 +++++++++++----- docs/event-sending.md | 282 +++++++++++++++++++++++++++++++++++ docs/project-structure.md | 13 +- docs/state-storage.md | 243 ++++++++++++++++++++++++++++++ 5 files changed, 635 insertions(+), 43 deletions(-) create mode 100644 docs/event-sending.md create mode 100644 docs/state-storage.md diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 9e59484..857b89c 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -81,6 +81,11 @@ func genFileDocs(filename string) { return true }) } + +func isSourceFile(name string) bool { + return strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") +} + func main() { fmt.Println(`AUTOGENERATED FILE DO NOT EDIT @@ -91,7 +96,7 @@ This file contains autogenerated "schema" for Babbleserv's FoundationDB keyvalue fmt.Println("# Accounts Database") fmt.Println("") filepath.Walk("internal/databases/accounts", func(path string, info fs.FileInfo, err error) error { - if strings.HasSuffix(info.Name(), ".go") { + if isSourceFile(info.Name()) { genFileDocs(path) } return nil @@ -101,7 +106,7 @@ This file contains autogenerated "schema" for Babbleserv's FoundationDB keyvalue fmt.Println("# Media Database") fmt.Println("") filepath.Walk("internal/databases/media", func(path string, info fs.FileInfo, err error) error { - if strings.HasSuffix(info.Name(), ".go") { + if isSourceFile(info.Name()) { genFileDocs(path) } return nil @@ -111,7 +116,7 @@ This file contains autogenerated "schema" for Babbleserv's FoundationDB keyvalue fmt.Println("# Rooms Database") fmt.Println("") filepath.Walk("internal/databases/rooms", func(path string, info fs.FileInfo, err error) error { - if strings.HasSuffix(info.Name(), ".go") { + if isSourceFile(info.Name()) { genFileDocs(path) } return nil @@ -121,7 +126,7 @@ This file contains autogenerated "schema" for Babbleserv's FoundationDB keyvalue fmt.Println("# System Database") fmt.Println("") filepath.Walk("internal/databases/system", func(path string, info fs.FileInfo, err error) error { - if strings.HasSuffix(info.Name(), ".go") { + if isSourceFile(info.Name()) { genFileDocs(path) } return nil @@ -131,7 +136,7 @@ This file contains autogenerated "schema" for Babbleserv's FoundationDB keyvalue fmt.Println("# Transient Database") fmt.Println("") filepath.Walk("internal/databases/transient", func(path string, info fs.FileInfo, err error) error { - if strings.HasSuffix(info.Name(), ".go") { + if isSourceFile(info.Name()) { genFileDocs(path) } return nil diff --git a/docs/data-model-generated.md b/docs/data-model-generated.md index 1b4456b..321c0d8 100644 --- a/docs/data-model-generated.md +++ b/docs/data-model-generated.md @@ -9,16 +9,37 @@ This file contains autogenerated "schema" for Babbleserv's FoundationDB keyvalue | Field | Key | Value | Notes | | --- | --- | --- | --- | -| `accountDataTupToVersion` | `types.AccountDataTup` | `Versionstamp` | AccountDataTup to version, used to clear previous keys when new ones appended
| +| `accountDataTupToVersion` | `(UserID, RoomID, Type)` | `Versionstamp` | AccountDataTup to version, used to clear previous keys when new ones appended
| | `userVersionToAccountData` | `(UserID, Versionstamp)` | `types.AccountData` | Per user sparse stream of account data, only contains the latest version of each tup
- get whole range for init sync (all account data)
- paginate range for inc sync
| ### DevicesDirectory | Field | Key | Value | Notes | | --- | --- | --- | --- | -| `byUserDeviceID` | | | UserID/DeviceID to types.Device msgpack bytes
| -| `userDeviceLastSeen` | | | UserID/DeviceID to last seen time, separate to device bytes since updated on any req
| -| `syncConns` | | | UserID/DeviceID/ConnID to types.UserSyncConn msgpack bytes, stores basic information about
(native/sliding) sync connections which v2/streaming also use. Also acts as a lock preventing
parallel sync calls with the same deviceid/connid. Updated every sync req.
| +| `userDevices` | `(id.userID, id.DeviceID)` | `types.Device` | UserID/DeviceID to types.Device msgpack bytes
| +| `deviceLastSeen` | | | UserID/DeviceID to last seen time, separate to device bytes since updated on any req
| +| `deviceKeys` | | | | +| `deviceOneTimeKeys` | `(id.UserID, id.DeviceID, id.KeyAlgorithm, id.KeyID)` | `mautrix.OneTimeKey` | | +| `deviceOneTimeKeysByVersion` | `(id.UserID, id.DeviceID, id.KeyAlgorithm, tuple.Versionstamp)` | `id.KeyID` | Index keys by algo/version to claim in uploaded order (MSC4225), can also be used to clean
old keys in the future.
| +| `deviceFallbackKeys` | `(id.UserID, id.DeviceID, id.KeyAlgorithm)` | `mautrix.OneTimeKey` | | +| `deviceChanges` | `tuple.Versionstamp` | `types.UserDevice` | version -> device change, paginated and cleared by the DeviceChangeIterator worker
| +| `deviceSyncConns` | | | UserID/DeviceID/ConnID to types.UserSyncConn msgpack bytes, stores basic information about
(native/sliding) sync connections which v2/streaming also use. Also acts as a lock preventing
parallel sync calls with the same deviceid/connid. Updated every sync req.
| + +### KeyBackupDirectory + +| Field | Key | Value | Notes | +| --- | --- | --- | --- | +| `userVersions` | `(id.UserID, tuple.Versionstamp)` | `types.KeyBackupVersion msgpack bytes` | Backup version storage
| +| `userVersionsMeta` | `(id.UserID, backup-versionstamp)` | `tuple{count, updatedVersionstamp}` | Backup version metadata (count and etag tracking)
| +| `keys` | `(id.UserID, backup-versionstamp, roomID, sessionID)` | `types.KeyBackupData msgpack bytes` | Key storage
| + +### PushRulesDirectory + +| Field | Key | Value | Notes | +| --- | --- | --- | --- | +| `userPushRules` | `(id.UserID, Scope, Kind, RuleID)` | `types.StoredPushRule` | Push rules storage
Scope is "global" in Matrix spec (reserved for future scopes)
Kind is one of: override, content, room, sender, underride
| +| `userPushRuleOrders` | `(id.UserID, Scope, Kind)` | `[]string of RuleIDs` | Priority order of user-defined (non server-default) rules, most important first
| +| `userPushVersions` | `(id.UserID)` | `tuple.Versionstamp` | Version tracking for sync
| ### TokensDirectory @@ -30,13 +51,21 @@ This file contains autogenerated "schema" for Babbleserv's FoundationDB keyvalue | Field | Key | Value | Notes | | --- | --- | --- | --- | -| `byVersion` | | | / version -> username
| -| `byUsername` | `(Username)` | `types.User (as msgpack []byte)` | / username -> *types.User
| -| `userPasswordHashes` | | | username -> password hash
| -| `userMasterKeys` | | | Device signing keys
https://spec.matrix.org/v1.11/client-server-api/#post_matrixclientv3keysdevice_signingupload
username -> CrossSigningKey JSON (CSAPI)
| -| `userSelfSigningKeys` | | | username -> CrossSigningKey JSON (CSAPI)
| -| `userUserSigningKeys` | | | username -> CrossSigningKey JSON (CSAPI)
| -| `userFilters` | | | Username/version -> filter bytes, version returned to the user as the filter ID. PUT requests
will then update the filter in-place. Meaning filters aren't stored as an immutableish stream.
| +| `byVersion` | `tuple.Versionstamp` | `id.UserID` | / version -> user index
| +| `localUsers` | `id.UserID` | `types.User` | / userID -> types.User for local users (this homeserver)
| +| `remoteUsers` | `id.UserID` | `types.User` | / userID -> types.User for remote users (other homeservers)
| +| `userProfiles` | `id.UserID` | `types.UserProfile` | user -> types.UserProfile
| +| `profileChanges` | `tuple.Versionstamp` | `(id.UserID, types.UserProfile)` | version -> profile, paginated and cleared by the ProfileChangeIterator worker
| +| `userCrossSigningKeys` | `id.UserID` | `types.userCrossSigningKeys` | Cross signing signing keys
https://spec.matrix.org/v1.11/client-server-api/#post_matrixclientv3keysdevice_signingupload
user -> CrossSigningKey JSON (CSAPI)
| +| `userKeySignatures` | `(id.UserID (mine), id.UserID (other), KeyID (other), KeyID (mine))` | `[]byte` | Key signatures
Stored under signing user prefix, with target user/key next so we can fetch signatures
from user X for user Ys xs/device keys. Last part of the key is the signing users keyid
such that we have a unique user/key combo signing another user/key.
| +| `userPasswordHashes` | `username` | `[]byte` | username -> password hash
| +| `userFilters` | `(Username, Versionstamp)` | `matruix.Filter` | Username/version -> filter bytes, version returned to the user as the filter ID. PUT requests
will then update the filter in-place. Meaning filters aren't stored as an immutableish stream.
| +| `userPushers` | `(id.UserID, appID, pushKey)` | `pushgateway.Pusher (JSON)` | User push notification endpoints (pushers)
| +| `userPusherDevices` | `(id.UserID, appID, pushKey)` | `id.DeviceID which last set the pusher` | | +| `pusherUsersByIdentity` | `(appID, pushKey, id.UserID)` | `empty; finds owners of exactly one pusher identity` | | +| `searchGrams` | | | Lowercase one-to-three-rune substring -> user ID; value: empty.
| +| `remoteProfileJobs` | | | user ID -> (source membership event ID, due milliseconds)
| +| `remoteProfileJobsByDue` | | | (due milliseconds, user ID, source membership event ID) -> empty
| # Media Database @@ -56,18 +85,17 @@ indices to enable fast access to various bits of (room) event data. | Field | Key | Value | Notes | | --- | --- | --- | --- | -| `idToEvent` | `EventID` | `types.Event (as msgpack []byte)` | Full event bytes by ID (turned into `types.Event`), contians the full event content and extra
metadata like `auth_events` & `prev_events`.
| -| `versionToTup` | `tuple.Versionstamp` | `types.EventTup (as tuple.Tuple []byte)` | Ordered EventTups by version, for paginating all events over all rooms. Used by internal
services for things like profile update propagation.
| -| `idToVersion` | `EventID` | `Versionstamp` | Event ID to `tuple.Versionstamp`. Allows us to get the version of an event so we can lookup state
at that point in time.
| -| `roomVersionToTup` | `(RoomID, tuple.Versionstamp)` | `types.EventTup (as tuple.Tuple []byte)` | Ordered `EventTup`s by room/version, for paginating all events in a room
| -| `localRoomVersionToTup` | `(RoomID, tuple.Versionstamp)` | `types.EventTup (as tuple.Tuple []byte)` | Ordered `EventTup`s originating from this homeserver, for paginating events to send over
federation.
| -| `roomStateVersionToTup` | `(RoomID, tuple.Versionstamp)` | `types.EventStateTup` | Ordered `EventStateTup`s by room/version, for pagination of room state changes.
- pull everything <= by versionstamp, latest (type, state_key) pairs win
- means iterating over duplicates, but alternative is to snapshot state at each change which
is extremely space wasteful.
- "get state changes between X and Y events in this room"
(S2S API needs MSC, see [this synapse issue](https://github.com/matrix-org/synapse/issues/13618))
| -| `roomExtremIDs` | `(RoomID, EventID)` | `empty []byte` | Current room extremeties, keys only roomID/eventID. We range over them to pull the current
DAG extremeties before clearing when writing events (and setting the written event).
- on local send take all event IDs as `prev_events`
- on store event clear any found in `prev_events`
| -| `idToLastResolvedData` | | | Room ID to tuple of information from the last time state resolution was performed on the room
ie (versionstamp, eventID1, eventID2, ...). We combine this with the list of current extrem
IDs to identify whether we need to re-resolve the room again (if multiple forks have written
state changes since last resolve).
| -| `roomVersionToIDStateTup` | `(RoomID, EventType, StateKey, Versionstamp)` | `EventID` | State events by room/type/version, for paginating the history of a single state event. This
allows us to fetch state for a given room/type at a point in time, for authorizing events.
- "get m.room.power_levels at version X"
| -| `currentRoomStateToID` | `(RoomID, EventType, StateKey)` | `EventID` | Current state event by room/type for quick lookups and event auth. Also allows fetching the
entire room state quickly (without members) as all the keys are stored together.
| -| `currentRoomMemberships` | `(RoomID, EventType, StateKey)` | `types.MembershipTup` | Current memberships by room/user, quick lookups of specific user memberships in a room and
quick pagination of all members in a room by keeping the keys together.
| -| `currentRoomServers` | `(RoomID, EventType, StateKey)` | `types.MembershipTup` | Current servers by room, used to ensure we're running federation senders for each one when
the room receives updates.
| +| `idToEvent` | `EventID` | `types.Event` | Full event bytes by ID (turned into `types.Event`), contians the full event content and extra
metadata like `auth_events` & `prev_events`.
| +| `idToAuthEventIDs` | `EventID` | `(1, Chain, Sequence, (AuthEventID, ...))` | Auth event IDs by event ID, written with every stored event so auth chains are walked
without loading full events, with the event's chain position once finalized, 0 until then
| +| `authChainPositions` | `(RoomID, Chain, Sequence)` | `(EventID)` | Chain cover positions, in every room version
| +| `authChainLinks` | `(RoomID, Chain, Sequence, TargetChain, TargetSequence)` | `empty` | Chain cover links from a position to the position of an auth event of its event on another chain
| +| `authChainNext` | `RoomID` | `last allocated chain ID` | The room's chain ID allocator
| +| `versionToTup` | `tuple.Versionstamp` | `types.EventTup` | Ordered EventTups by version, for paginating all events over all rooms. Used by internal
services for things like profile update propagation.
| +| `idToVersion` | `EventID` | `Versionstamp` | Event ID to `tuple.Versionstamp`, the version the event was stored at. Tells whether an event
is stored and places receipts and thread roots in the timeline. Every stored room event has
its own version, rejected and soft failed events included, outliers have none.
| +| `roomVersionToTup` | `(RoomID, tuple.Versionstamp)` | `types.EventTup` | Ordered `EventTup`s by room/version, for paginating all events in a room
| +| `localRoomVersionToTup` | `(RoomID, tuple.Versionstamp)` | `types.EventTup` | Ordered `EventTup`s originating from this homeserver, for paginating events to send over
federation.
| +| `roomVersionToState` | `(RoomID, tuple.Versionstamp)` | `(ContextID)` | The room's current state context by room/version, one row per step moving it, at the version
of the event making the step. The latest row at or before a version is the room's current
state as of that version, for syncing state changes.
| +| `roomExtremIDs` | `(RoomID, EventID)` | `empty` | Current room extremeties, keys only roomID/eventID. We range over them to pull the current
DAG extremeties before clearing when writing events (and setting the written event).
- on local send take all event IDs as `prev_events`
- on store event clear any found in `prev_events`
| | `byRoomRelation` | | | RoomID/related-event-ID/version to (eventID, relType)
- paginate events relating to this event
- paginate events of a certain rel_type relating to this event
- have to paginate through types that don't match
- probably sufficient performance (rare)
| | `byRoomReaction` | | | Room event reactions
- de-dupe reactions to an event by user/key
- paginate reactions for a given event (`/relations/rel_to_ev_id/m.annotation`)
| | `roomThreadVersionToID` | | | root event ID by room/root-ev-version
Note: only set for new thread roots, on the first replying (in-thread) event.
- paginate thread roots in a room
| @@ -80,32 +108,49 @@ indices to enable fast access to various bits of (room) event data. | Field | Key | Value | Notes | | --- | --- | --- | --- | -| `receiptTupToVersion` | `(UserID, RoomID, Type, ThreadID)` | `version` | ReceiptTup (user, room, thread, type) to version, used to clear previous keys when new ones are appended.
| -| `roomVersionToReceipt` | `(RoomID, version)` | `types.Receipt (as tuple.Tuple []byte)` | Sparse stream of public receipts stored by room/version for pagination
| -| `localRoomVersionToReceipt` | `(RoomID, version)` | `types.Receipt (as tuple.Tuple []byte)` | Sparse stream of public receipts sent by local users, stored by room/version
| -| `userVersionToReceipt` | `(UserID, version)` | `types.Receipt (as tuple.Tuple []byte)` | Sparse stream of private receipts send by local users, stored by user/version
| +| `receiptTupToVersion` | `(UserID, RoomID, Type, ThreadID)` | `tuple.Versionstamp` | ReceiptTup (user, room, thread, type) to version, used to clear previous keys when new ones are appended.
| +| `roomVersionToReceipt` | `(RoomID, Versionstamp)` | `types.Receipt` | Sparse stream of public receipts stored by room/version for pagination
| +| `localRoomVersionToReceipt` | `(RoomID, Versionstamp)` | `types.Receipt` | Sparse stream of public receipts sent by local users, stored by room/version
| +| `userVersionToReceipt` | `(UserID, Versionstamp)` | `types.Receipt` | Sparse stream of private receipts send by local users, stored by user/version
| | Field | Key | Value | Notes | | --- | --- | --- | --- | | `idToRoom` | | | Room ID to bytes we decode to `*types.Room` items
| | `idToDepth` | | | Room ID to depth int64
| -| `idToVersion` | | | Room ID to tuple.Versionstamp of the most recent receipt or event in the room
| -| `aliasToID` | | | Aliases to room IDs
| +| `idToVersion` | | | Room ID to tuple.Versionstamp of the most recent event or receipt in the room
| +| `localMembers` | `(RoomID, UserID)` | `types.MembershipTup` | Joined members of this server by room
| +| `aliasToID` | `RoomAlias` | `(RoomID, UserID)` | Aliases to room IDs (and owner to authz delete)
| +| `idAliases` | `(RoomID, RoomAlias)` | `empty` | Aliases by roomID
| +| `publishedRooms` | `(-MemberCount, RoomID); value: empty` | | Published rooms ordered by descending joined-member count, then room ID.
| +| `eventTransactions` | | | (UserID, DeviceID, RoomID, Endpoint, TransactionID) -> EventID
| +| `eventTransactionIDs` | | | (EventID, UserID, DeviceID) -> TransactionID
| ### ServersDirectory | Field | Key | Value | Notes | | --- | --- | --- | --- | -| `joinedRoomMembers` | `(RoomID, ServerName, UserID)` | `[]byte (always empty)` | UserIDs by room/server so we can easily detect when a server goes in/out of a room
| -| `memberships` | `(ServerName, RoomID)` | `[]byte (always empty)` | Room memberships by server so we list (joined) rooms for a given server
| +| `joinedCounts` | `(RoomID, ServerName)` | `(count)` | Joined member counts by room/server, absent at zero: whether a server is in a room, and the
room's servers.
| +| `memberships` | `(ServerName, RoomID)` | `types.MembershipTup` | Room memberships by server so we list (joined) rooms for a given server
| | `membershipChanges` | `(ServerName, Versionstamp)` | `types.MembershipTup` | Room membership changes by server so we can handle changes during sync (for federation)
| +### StateDirectory + +StateDirectory stores immutable map pages and contexts identified by their state roots. + + +| Field | Key | Value | Notes | +| --- | --- | --- | --- | +| `idToContext` | `(RoomID, ContextID)` | `(StateRoot, MemberRoot)` | Context records, the state at a point in the room DAG as the roots of its state and member
maps. The empty context is never stored.
| +| `idToPage` | `(RoomID, PageID)` | `(0, key, value, key, value, ...) or (1, count, bytes, child0, ..., child15)` | Map pages. A leaf holds entries sorted by key. A branch holds its subtree's entry count and
encoded entry bytes, then the child page for each nibble of the key bucket hash with "" for
an empty slot.
| +| `idToResolution` | `(RoomID, stateres.Version, ResolutionKey)` | `(ContextID)` | Results of resolutions too large to run within a send's transactions, stored by the artifact
job that ran them once the result context is stored. A resolution key is the hash of the
sorted distinct input context IDs, under the version of state resolution that resolved them.
| + ### UsersDirectory | Field | Key | Value | Notes | | --- | --- | --- | --- | -| `profiles` | | | | -| `deviceConnRoomPositions` | | | UserID/DeviceID/ConnID/RoomID to rooms versions (events-vstamp, receipts-vstamp)
| +| `memberships` | `(id.UserID, id.RoomID)` | `(EventID, RoomID, Membership[, Outlier]), see types.MembershipRow` | Current memberships of local users, written with the room's current state, and outlier
memberships received while this server is out of a room
| +| `membershipChanges` | `(id.UserID, tuple.Versionstamp)` | `types.MembershipTup` | Membership changes of local users, for sync
| +| `notificationVersions` | `(id.UserID, id.RoomID, tuple.Versionstamp)` | `types.Notifications (msgpack)` | Notification counts per event version
Stored as deltas, summed for total counts, cleared on receipt
| # System Database @@ -124,15 +169,25 @@ indices to enable fast access to various bits of (room) event data. # Transient Database +### PresenceDirectory + +| Field | Key | Value | Notes | +| --- | --- | --- | --- | +| `userToPresence` | `(id.UserID)` | `types.Presence` | | +| `userToLastActive` | `(id.UserID)` | `time.Time` | | +| `presenceChanges` | `tuple.Versionstamp` | `(id.UserID, types.Presence)` | | +| `presenceTimeouts` | `(timeoutMS, id.UseriD)` | `[]byte{}` | | + ### ToDeviceDirectory The to-device directory holds versioned to-device events stored by user/device or server for -pagination during sync or federation sending. We also use these to store and sync/federate device -list changes (m.device_list_update EDU) and signing key updates (m.signing_key_update EDU). +pagination during sync or federation sending. Babbleserv also uses to-device events for some +internal changes ie generating m.device_list_update EDUs for federation. | Field | Key | Value | Notes | | --- | --- | --- | --- | +| `versionToMessage` | `Versionstamp` | `("server", ServerName) OR ("user", UserID, DeviceID, TransactionID)` | Version to to-device details, acts as a global index so we can drop stale records, which
build up as devices go missing/etc. These are not cleared when removing user/server messages
during sync, but when dropping stale records.
| | `localUserMessages` | `(UserID, DeviceID, Version)` | `types.ToDevice` | To-device messages for local user devices
| | `localUserMessageTxns` | `(UserID, DeviceID, TransactionID)` | `[]byte (always empty)` | Used to-device transaction IDs
| | `remoteServerMessages` | `(ServerName, version)` | `types.ToDevice (as msgpack []byte)` | To-device messages for other user devices on other homeservers
| diff --git a/docs/event-sending.md b/docs/event-sending.md new file mode 100644 index 0000000..2b7db25 --- /dev/null +++ b/docs/event-sending.md @@ -0,0 +1,282 @@ +# Event sending + +A send computes the state its events would produce, then publishes the events and the room's new state together. Small local sends do both in one FoundationDB transaction. Federated sends, remote joins and larger local sends prepare first and publish through a guarded write transaction. Work that is too large for those transactions is staged or computed between attempts. + +[Room state storage](state-storage.md) describes the contexts, tree and FDB records used here. This document follows the transaction flow and explains which objects survive a retry. + +## Entry points and ownership + +Routes perform federation requests and signature checks before calling the databases. The rooms database receives local event content, verified federated events, and any fetched events or state supplied by another server. It does not perform network requests during a transaction. + +| Entry point or type | Responsibility | +| --- | --- | +| `SendLocalEvents` / `localEventSender` | Build and authorize local events from current state; try a single transaction first | +| `SendFederatedEvents` / `federatedEventSender` | Check received events against their auth events, their before-state and current state | +| `remoteJoinSender` | Handle one local user's join with a supplied before-state while this server is outside the room | +| `eventSender` | Shared attempt loop, publication, artifact dispatch, retries and post-commit notification | +| `eventsendutil.Resolver` | Resolve contexts using either one preparation transaction or renewable artifact reads | + +Local and federated senders embed `eventSender`. `remoteJoinSender` embeds `*federatedEventSender` and overrides preparation. If another join gets this server into the room first, it can use normal federated preparation. + +The caller constructs one concrete sender for the whole send. Preparation produces fresh objects for each attempt: + +| Object | Contents and lifetime | +| --- | --- | +| `preparedEvents` | Attempt-local event copies, pending `state.Batch`, auth graph, event provider, extremity replacements and current-state steps | +| `publishPlan` | Those prepared events, the room snapshot and guard, plus optional readoption, extremity reset and extra writes | +| `readyToPublish` | A plan for the current attempt | +| `needsWork` | Supporting data to build before preparing again | +| `alreadySent` | A stored result, such as a client transaction-ID duplicate or completed join | + +`state.Batch` holds new state pages in memory until they are written. `AuthGraph` holds headers and chain labels assigned during preparation. `TxnEventsProvider` supplies both stored events and the attempt's pending events. A plan is valid only for the inputs recorded by its guard. + +## Transaction flow + +The common driver is `eventSender.run`, which calls `attempt` under the process's room mutex: + +```text +lock room + prepare (read transactions) + | + +-- readyToPublish + | stage oversized pending contexts if needed + | publish in one guarded write transaction + | + +-- needsWork ------------------------+ + | | + +-- alreadySent | +unlock room | + | | + +-- success: notify and return | + +-- guard failed: pause, retry | + +-- build requested work <-------------+ + success: prepare again + other error: return +``` + +`roomLocks` serialize attempts for the same room within this process. They do not exclude writers in another process. Cross-process correctness comes from the publication guard and FDB conflicts. + +Remote joins also take a separate per-room `joinLocks` turn across their attempts and preparatory work. This prevents local remote-join workflows from duplicating expensive work. Other senders do not take that lock. A join waiting for a result already stored by another operation releases its join turn first. + +Notifications and retry pauses happen after releasing the room mutex. Requested event staging and artifact jobs also run outside it. Staging the pending contexts of an already prepared publication plan happens inside the attempt, before opening its publication transaction. + +### Local sends + +`localEventSender.sendInOneTransaction` holds the room mutex through a write transaction that prepares and publishes: + +1. Check the client transaction ID, returning its existing event when present. +2. Read or create the room. An existing room must have local joined members. +3. Build the events in a line from current state. Set their prev/auth references, depth, timestamp and signatures, authorize them, and apply accepted state changes to the pending state batch. +4. Publish using the same transaction. Aliases, room publication, the client transaction ID and any requested lock refresh are included. + +`RequireAllEvents` aborts the whole send if any event is rejected. Otherwise accepted events proceed and rejected local events are returned to the caller without being stored. A babble profile update requires its sender to be joined. + +If the pending contexts exceed the staging target, the callback returns `errNeedsStaging` without committing. The same sender switches to the common attempt loop: read-only preparation, state staging, then guarded publication. It keeps the original timestamp, but rebuilds events against the state seen by the new attempt. + +### Federated sends + +The batch is deduplicated and ordered so an event follows the batch events it cites as predecessors or auth events. Preparation copies the input events so one attempt's outcomes do not alter the originals. + +Fetched events needed for supplied state are checked against their own auth events and staged first. Once available, a read transaction prepares the submitted batch: + +1. **Auth events (step 4).** Check each new event against its declared auth events. Already stored events keep their stored outcome. Events depending on fetched events that could not be evaluated are dropped. +2. **Before-state (step 5).** Obtain the state after every predecessor, resolving their contexts when needed, or use an explicitly supplied before-state. Authorize the event against that context. An accepted state event replaces its tuple to produce its after-context; a non-state event keeps the same context. +3. **Current state (step 6).** Check accepted events against the running current state. Failure here soft-fails the event. Otherwise update the pending extremities and set current state to their sole after-context or the resolution of all their after-contexts. +4. Return a publication plan, or `needsWork` if a resolution exceeds an inline budget. + +Step 5 and step 6 can resolve different sets of contexts. An event's before-state describes its predecessors. Current state merges the room's accepted forward extremities, including branches the event did not cite. + +| Outcome | Stored? | Effect | +| --- | --- | --- | +| Accepted | Yes | Can advance current state, extremities and the timeline | +| Rejected | Yes, once its before-state is available | Before and after contexts are equal; excluded from current state and the timeline | +| Soft failed | Yes | Retains its DAG contexts, including its own state change; excluded from the current extremities and timeline at publication | +| Dropped | No new stored event | Can be evaluated again when missing dependencies become available | +| Duplicate | Existing event reused | Does not get published again | + +Unknown predecessor state is a reason to drop an event, not to resolve only the known subset. When finalizing a state event's auth header in step 5, `ErrAuthEventPending` drops the event because its auth chain reaches an unsettled batch event; `ErrAuthEventNotFinalized` rejects it because an auth event cannot be finalized. Errors from resolving predecessor or extremity contexts generally fail the batch; an unsupported predecessor-resolution algorithm instead drops the affected event. + +When this server is outside the room, ordinary federation sends accept only local-user invites and specific leaves responding to an existing invite through the outlier-membership path. Other events are dropped. Client-initiated remote knocks and leaves call `SendFederatedOutlierMembershipEvent` directly after federation succeeds. These paths do not construct or publish full room state. + +### Remote joins + +A single local user's join with `GivenStates.BeforeEvents` is treated as a remote join. The resident server's response supplies the state before it. + +While this server is outside the room: + +1. Check the room and response events. Authorize the join against its own auth events and the response state. A failed join aborts the send. +2. Stage the response events without contexts or worker/timeline rows. +3. Build a boundary context from the response state in an artifact job. Compute its diff from current state and stage the boundary. +4. Prepare and publish the join with before = boundary and after = boundary plus join. There is no current-state soft-fail check in this mode. Current state becomes the join's after-context and the join replaces the room's extremities. + +The response may contain an event stored here as rejected that the join accepts. Staging finalizes its auth header; publication rewrites its stored outcome to accepted. This is readoption, and it is why event bodies cannot all be treated as immutable. + +If another operation gets the server joined first, the sender switches to normal federated preparation. It reuses a boundary already built, where available, and now performs the current-state check. If the join is already stored without state, the sender waits while this server remains outside the room; once joined, it checks whether that event is the joiner's current membership. + +### Supplied-state boundaries + +A boundary describes state where the preceding history is unknown locally. `GivenStates` can supply state before a predecessor or before a submitted event, together with the event bodies needed to interpret it. + +For a predecessor, its accepted state tuple is added to the supplied before-state to obtain an after-context. The ordinary federation boundary builder omits unknown, rejected, outlier, non-state and other-room events. Different events for the same state tuple, references to unsettled batch events, a missing create event or an entry too large for the tree make the boundary unusable. Building a predecessor boundary does not run step 5 on that predecessor. + +A boundary can be built by changing current state or by building from empty; both produce the same context ID. The builder counts current state first and avoids enumerating it when it is more than twice the estimated size of the largest boundary being built. Otherwise it reads current state, then chooses whichever base needs fewer changes for each boundary. Ordinary federation boundaries are built during preparation. Remote-join boundaries use an artifact job. + +## Publication and concurrency + +`txnPublish` re-reads the mutable inputs with normal transaction reads before writing anything: + +| Guard check | Why it is needed | +| --- | --- | +| Room `StateRevision` | A publication since preparation can invalidate the plan | +| Whether this server is joined | Distinguishes normal sends from remote joins | +| Event IDs expected to be unstored | Staging can store dependencies without changing the room revision | +| Auth-chain tips, allocator and headers used for labels | Staging can allocate or finalize labels without publishing room state | + +A mismatch returns `errGuardFailed`, including `errEventsStoredConcurrently`. A concurrent write after these reads conflicts with the publication transaction. The room mutex alone cannot provide either check across processes. + +After the guard, a publication with new events: + +1. Rewrites any rejected response events accepted by the remote join. +2. Evaluates notifications and stores event bodies, versions and worker rows. Accepted events also update timeline, relation, notification, depth and extremity records; the room's latest-version key advances. +3. Writes unstaged contexts and pages, finalized auth headers and chain records. +4. Diffs each current-state step against the previous one and writes an `events/rch` row at the event's version. The diffs update room summary fields and counts, server memberships and local-user membership rows and histories. +5. Checks the transaction's approximate size. It then stores the room record with its incremented revision, moving the published-room index if the joined-member count changed. +6. Applies a join's extremity reset and any extra writes: alias, room publication, transaction ID or lock refresh. + +These writes commit together. Staged pages can exist earlier, but current state and its membership indexes move only in this publication. A batch can contain several state steps, so its history preserves intermediate transitions even though the final room record holds only the last context. + +With no new events to store, the state writes and revision increment are skipped; any extra writes can still run. + +After a successful commit, `afterSend` sends the notifier change and logs the result. Client transaction-ID duplicates suppress that notification. + +## Staging and artifacts + +Staging writes supporting data ahead of publication. Artifact jobs build remote-join boundaries and run resolutions that exceed the inline preparation budgets. Both can use several transactions, but they leave the room's current-state pointer and publication revision alone. + +| Work | Output | Where the next attempt finds it | +| --- | --- | --- | +| `stageEventsWork` | Checked fetched or join-response events; finalized auth headers for accepted state events | Event and auth indexes | +| `joinStateWork` | Staged before-context and its diff from the room snapshot | `remoteJoin.boundary` and `remoteJoin.diff` on the surviving sender | +| `resolveStateWork` | Staged resolved context, then a stored result lookup | `state/rs`, keyed by input contexts and implementation version | + +Join and resolution work have stable artifact keys. Requesting the same key again after it was successfully built is an error, preventing an endless build loop. Event staging has no such key: concurrent storage can legitimately require another check of the remaining events. + +### Event staging transactions + +`stageStatelessEvents` writes checked events in dependency order, chunked by the staging target and versionstamp capacity. Each write transaction: + +- Checks joined mode from a snapshot of the room record. +- Checks that newly staged events are still unstored and that auth events previously read as rejected have not been accepted meanwhile. +- Assigns auth-chain labels to accepted state events using normal reads of the allocator and chain tips. New rejected and non-state events get unfinalized headers. +- Writes event bodies, `etv` and auth records, without `evr` or timeline rows. A readoption candidate gets its header finalized here, with the body rewrite left to publication. + +This path does not increment the room revision. A staged event already counts as stored for duplicate detection, even though its before/after contexts remain unknown. + +### State staging transactions + +A batch stages only the requested contexts and the pending pages they reach. Children are written before parents, and all pages precede the context records. Transactions target 5 MB of key and value bytes; the budget intentionally leaves headroom instead of estimating protocol overhead precisely. + +A ready publication plan stages its contexts when their pending bytes exceed that target. A resolution job stages any pending input contexts before it starts and stages its result before writing `state/rs`. A join-state job computes its diff while the new pages are still available in its batch, then stages the boundary. + +An abandoned send can therefore leave stored supporting data. Staging itself neither makes the room joined nor sends worker notifications. + +### Reads across transactions + +`eventsendutil.Resolver` uses `preparationReads` for an inline resolution. `ArtifactReads` runs the same resolution logic through renewable snapshot transactions. A `state.NewJobBatch` routes its page and context reads through the same renewal callback. + +Before another read batch begins, a transaction at least 3 seconds old is replaced. Successfully read events and the auth graph are carried over. A retryable FDB read error retries that batch in a new transaction. Batches are bounded: 128 pages, 1,000 contexts, headers or candidate events, up to 10,000 link rows, and 5,000 chain positions. + +The job is tied to specific input contexts, not to a moving room snapshot. Pages and contexts are immutable, and finalized auth positions are stable. The graph retains positions assigned during preparation and does not read later chain extensions into those positions. Jobs do not assign new labels. Stored event outcomes can change through readoption, so the stored resolution key should not be mistaken for a version of every event body it read. + +After the job, preparation starts again against the room's current record. A join boundary diff is reusable only while its starting context still matches. A resolution result is reusable for the same input context set. + +## State resolution + +Resolution is needed both to find the state before an event with several predecessors and to find current state after several accepted extremities. Equal context IDs need no resolution. A single known predecessor supplies its after-context directly. With no predecessors, the before-state is known empty state. + +`eventsendutil.Resolver` connects the stored contexts and auth graph to the algorithm in `internal/stateres`: + +```text +input context IDs + -> reuse a stored resolution result, if present + -> compare the state/member trees to find conflicting tuples + -> compute the auth-chain difference of the input states + -> add the conflicted subgraph for state resolution v2.1 + -> read candidate events and authorize them in resolution order + -> obtain a delta against the first input + -> apply the delta to produce the result context +``` + +### Conflicts and auth difference + +Tree comparison skips equal subtrees. A tuple whose event differs between inputs conflicts; absence in one input also counts. An input state's [auth chain](state-storage.md#auth-graph) is the union of the auth ancestry of its state events. Candidate events include those conflicting events and events reachable through some input states' auth chains but not all. + +To compute the auth difference, the graph reads every input state event's finalized chain position. For each input, it follows chain links to find the highest reachable sequence on every chain, including the starting state events themselves. On a chain, positions above the minimum reach and at or below the maximum reach form the difference. A chain not reached by an input has reach zero. + +```text + chain 1 chain 2 +input A reaches 5 2 +input B reaches 3 2 + +auth difference: chain 1 positions 4 and 5 +``` + +This can include an auth ancestor that appears in neither input state. Exact common-state events are removed from the strict auth difference by the sparse resolver. + +For v2.1, the graph also finds events that are both ancestors and descendants of conflicted events. It intersects backward and forward reach on each chain and reads the resulting intervals. Common events in this conflicted subgraph are retained as candidates. + +### Resolving candidates + +`ResolveSparse` receives the conflicts, auth difference, optional conflicted subgraph and a lookup into the first context. It returns only changes relative to that context. + +Power events and their auth ancestors within the candidate set are processed in reverse topological power order, followed by other candidates in mainline order. Each stage runs iterative authorization. V2 can read common state during these checks; v2.1 begins from empty partial state. Common state is restored in the final result. The resolver supplies membership values for changed member tuples before applying the delta. + +The implementation supports state resolution v2 and v2.1. Distinct contexts requiring an older algorithm fail with `ErrUnsupportedAlgorithm`. Rejected candidates are skipped during iterative authorization; accepting them would also require changing their stored rejection status and dependent state. Authorization is delegated through `util.Authorize` to `gomatrixserverlib.Allowed`, with lookup errors kept separate from authorization failures. + +Sparse resolution avoids copying unchanged state into the result, but computing the auth difference still enumerates the input state events and their headers. Its cost is not limited to the visibly conflicting tuples. + +### Inline budgets + +After finding conflicts, preparation checks input tuple counts before enumerating their states. It requests a resolution artifact when either budget is exceeded: + +| Setting | Default | Meaning | +| --- | --- | --- | +| `rooms.stateBudget.inlineResolutionStateTuples` | 10,000 | Largest input context's tuple count | +| `rooms.stateBudget.inlineResolutionCandidates` | 2,000 | Distinct candidate events | + +The candidate budget is checked after the auth difference and again after adding the v2.1 subgraph. Jobs run without those inline limits and read candidates in chunks. The completed result is staged and stored for the next attempt. + +## Retries and bounds + +There are three retry levels: + +| Level | What repeats | +| --- | --- | +| FDB transaction retry | The read or write callback, through the transaction helpers | +| Send attempt retry | Preparation and publication after a failed guard, or while a stored join remains unpublished | +| Artifact read retry | A failed bounded batch of reads, keeping successfully read data | + +Successful preparatory work immediately starts a new attempt. Guard failures and `errJoinUnpublished` pause with jitter: the base delay starts at 10 ms and doubles to a 250 ms cap, with each actual pause between half and the full base delay. Other errors return to the caller. + +When the caller has no deadline, ordinary federated sends and local sends using the attempt loop get 60 seconds; remote joins get 10 minutes. Existing deadlines are preserved. The local single-transaction path uses the caller's context and configured FDB transaction limits. + +| Bound | Behaviour | +| --- | --- | +| `rooms.stateBudget.stagingBytes`: 5,000,000 bytes by default | Target for state and event staging transactions | +| `config.PublishMandatoryMaxBytes`: 9,000,000 bytes | Publication fails with `ErrRoomTooLarge` when its approximate size exceeds this before the room record and extra writes | +| Artifact transaction age: 3 seconds | Renew before the next read batch | +| Inline resolution budgets | Return a work request instead of reading larger inputs inline | + +Staging does not split the mandatory publication writes. A state reset with many local-member changes or server rows must still fit one publication transaction. An inline transaction timeout also does not automatically become an artifact job: jobs are requested through the explicit size and candidate budgets. + +## Code map + +| Code | Responsibility | +| --- | --- | +| [eventsender.go](../internal/databases/rooms/eventsender.go) | Attempt driver, plans, guard, publication and resolution jobs | +| [eventsendlocal.go](../internal/databases/rooms/eventsendlocal.go) | Local event construction and single-transaction path | +| [eventsendfederated.go](../internal/databases/rooms/eventsendfederated.go) | Federated preparation, event staging and boundaries | +| [eventsendjoin.go](../internal/databases/rooms/eventsendjoin.go) | Remote-join checks and mode changes | +| [eventsend.go](../internal/databases/rooms/eventsend.go) | Event and current-state publication writes | +| [eventsendutil/resolver.go](../internal/databases/rooms/eventsendutil/resolver.go), [reads.go](../internal/databases/rooms/eventsendutil/reads.go) | Context resolution and renewable transactions | +| [stateres/sparse.go](../internal/stateres/sparse.go), [v2.go](../internal/stateres/v2.go) | Sparse state resolution and iterative authorization | diff --git a/docs/project-structure.md b/docs/project-structure.md index 757ef24..b31c088 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -4,7 +4,10 @@ Babbleserv is designed to lean on FoundationDB as much as possible. Think of it ## Transactions -At the core of this lies transactions which FDB provides strong consistency guarantees for. By **evaluating event authorzation and state resolution within transactions** we simplify handling of both local and federated events significantly. See the `SendLocalEvents` and `SendFederatedEvents` in `internal/database/rooms/eventsend.go` which implement auth + store in single transactions. +At the core of this lies transactions which FDB provides strong consistency guarantees for. Large changes (state resets, big joins) must perform work across multiple transactions to avoid limits. + +- [Room state storage](state-storage.md): FoundationDB records, immutable state trees, caching and membership indexes. +- [Event sending](event-sending.md): transaction flow, publication guards, staging, artifact jobs, retries and state resolution. ### Transaction Rules @@ -45,6 +48,10 @@ Babbleserv is roughly divided in three: - implement Matrix endpoints - call through to database transactions -### `internal/federator/` +### `internal/workers/` + +- asynchronous work after database changes, most as iterators over a database range, see [databases.md](databases.md#iterators-for-cross-database-transactions) + +### `internal/stateres/` -- federator? +- state resolution v2 and v2.1 over the conflicting part of the states, see [event-sending.md](event-sending.md#state-resolution) diff --git a/docs/state-storage.md b/docs/state-storage.md new file mode 100644 index 0000000..4051d95 --- /dev/null +++ b/docs/state-storage.md @@ -0,0 +1,243 @@ +# Room state storage + +Room state is stored in FoundationDB as immutable maps. An event records the contexts before and after it; the room record points to its current context. Updating state creates new pages and reuses unchanged parts of the old maps. + +This document describes the persisted data and the operations on it. [Event sending](event-sending.md) describes how preparation, state resolution, staging and publication use those operations. [Generated schema](data-model-generated.md) lists the database's other subspaces. + +## Contexts + +A state tuple is `(event_type, state_key)`. A context maps each tuple to the event occupying it at one point in the room DAG. It has two roots: + +| Map | Key | Value | +| --- | --- | --- | +| State | Tuple-encoded `(event_type, state_key)`, excluding `m.room.member` | Event ID bytes | +| Members | User ID bytes | One membership-code byte followed by event ID bytes | + +The separate member map lets callers read non-member state without traversing all members. Membership is stored alongside its event ID so authorization lookups, state diffs and joined-member counts can inspect it without loading the event body. The codes are join `1`, invite `2`, leave `3`, ban `4` and knock `5`. + +Page IDs and context IDs are `types.StateHash`: the first 16 bytes of SHA-256 over the encoded record. A page hashes its stored bytes; a context hashes its two encoded roots. Membership is determined by its event, so the context ID depends on the state event IDs, not the order in which the context was built. + +There are two different empty values: + +- An all-zero **page ID** means an empty map. Encodings represent it as an empty byte string. +- An all-zero **context ID** means unknown state. Known empty state has its own nonzero ID, `state.EmptyContext`, calculated from two empty roots. It needs no stored context row. + +## FoundationDB records + +Paths below are logical prefixes relative to the `rooms` FDB directory. The directory layer supplies the actual byte prefixes; keys use FDB tuple encoding. Values are tuples unless another encoding is stated. Hash fields are byte strings, not their hexadecimal display form. + +### Contexts, pages and resolution results + +```text +state/cx / (room_id, context_id) -> (state_root, member_root) +state/ob / (room_id, page_id) -> encoded leaf or branch +state/rs / (room_id, implementation_version, + resolution_key) -> (result_context_id) +``` + +Contexts and pages are immutable. Writing the same ID again writes the same bytes. They are scoped by room: two rooms with the same map contents still have separate FDB records. + +A resolution key hashes the sorted, distinct input context IDs. `implementation_version` is `stateres.Version`, which must change when an algorithm change can change a result. Artifact jobs write `rs` only after staging the result context and its pages. Small resolutions run in memory without adding an `rs` row. + +### Events and state history + +```text +events/eid / event_id -> Event (msgpack) +events/etv / event_id -> versionstamp +events/evr / versionstamp -> EventTup +events/rmv / (room_id, versionstamp) -> EventTup +events/elv / (room_id, versionstamp) -> EventTup +events/rch / (room_id, versionstamp) -> (context_id) +events/rex / (room_id, event_id) -> empty +``` + +`EventTup` is the tuple `(event_id, room_id, sender, event_type)`. Versionstamp values are also tuple-encoded. + +The event body holds `BeforeState` (`bst`) and `AfterState` (`ast`). They describe the event's place in the DAG, which can differ from the room's current state after merging its branches. + +| Stored event | Before context | After context | +| --- | --- | --- | +| Accepted non-state event | B | B | +| Accepted state event | B | B with its tuple replaced | +| Rejected event evaluated with known state | B | B | +| Soft-failed event | B | B with its tuple replaced, if it is a state event | +| Outlier or event staged without state | Unknown | Unknown | + +The indexes have different purposes: + +- `etv` records storage of a non-outlier event. Staged, rejected and soft-failed events have it too; it does not imply that an event has contexts or appears in a timeline. +- `evr` is the stream workers walk. Publication and outlier membership storage add rows; staging fetched events does not. +- `rmv` is the room timeline; `elv` is its locally sent subset used for federation. Rejected and soft-failed events enter neither. +- `rex` lists forward extremities. Accepted events replace the extremities they descend from. +- `rch` records each change to current state at the version of the event causing it. A batch can write several rows. A historical lookup takes the last row at or before its version; the room has empty state before its first row. + +Versionstamps order publication and membership changes. They are distinct from context IDs: two versions can refer to the same state, and an event's after-context need not be the current context recorded at its publication version. + +Event bodies are not content-addressed immutable records. They can be rewritten when an outlier is published or a remote join accepts an event previously stored as rejected. + +### Room record and membership indexes + +```text +id / room_id -> Room (msgpack) +idd / room_id -> (depth) +iev / room_id -> latest event or receipt versionstamp +rlm / (room_id, user_id) -> (event_id, room_id, "join") +pub / (-joined_member_count, room_id) -> empty + +users/mem / (user_id, room_id) -> (event_id, room_id, membership[, true]) +users/mch / (user_id, versionstamp) -> (event_id, room_id, membership) + +servers/jcn / (room_id, server_name) -> (joined_member_count) +servers/mem / (server_name, room_id) -> (event_id, room_id, "join") +servers/mch / (server_name, versionstamp) -> (event_id, room_id, membership) +``` + +The room record stores: + +| Field | Meaning | +| --- | --- | +| `CurrentState` (`cst`) | Current context ID | +| `StateRevision` (`srv`) | Revision checked by a send's publication guard; advances once per publication that stores new events, even if state is unchanged | +| `MemberCount` (`mem`) | Joined-member count | +| `LocalMembers` (`lmc`) | Joined members hosted here; greater than zero means this server is joined | +| `Encrypted` (`enc`) | Whether current state contains an encryption event | +| Summary fields | Name, topic, avatar, alias, join rule, history visibility and guest access from current state | +| Room metadata | Room version, type, federation setting and directory publication status | + +Publication updates current state and the affected membership indexes in the same transaction. These indexes are not asynchronously rebuilt views. + +**Local users.** Only local users have `users/mem` and `users/mch` rows. `rlm` lists the local users currently joined in each room. A membership change updates `mem`, sets or clears `rlm`, and writes `mch` when its event ID differs from the previous row. Removing a member tuple from state clears the rows without writing a membership-change row. An optional trailing `true` in `mem` marks an outlier invite, knock or other supported membership received while this server is outside the room. + +**Servers.** `jcn` counts joined users of each server. Zero clears the count and current server membership. Crossing from zero to positive writes a join; crossing back writes a leave at the state step's version. When a publish brings this server into the room, other servers' initial joins get no `mch` row: federation starts their room stream from this server's join. This server's own join and other servers' leaves are still recorded. + +**Room indexes.** `iev` lets sync skip unchanged rooms. `pub` orders published rooms by member count; storing a changed count moves the published-room key in the same transaction. + +### Auth graph + +An event's **auth chain** is the set of state events reached by following its `auth_events` references recursively: its direct auth events, their auth events, and so on. These are the events needed to check its authorization, including the authorization of those dependencies. For example, a message can reference the sender's joined membership and the room's power levels; the auth chain also includes the earlier events that authorized that membership and those power levels. + +In room v12, authorization also depends on the create event identified implicitly by the room ID. `AuthDependencyIDs` includes it for fetching and event ordering, but the auth-chain index follows only the explicit `auth_events` references. + +Despite the name, an auth chain can branch and share ancestors: it is a graph, not necessarily a linear sequence. `auth_events` links describe authorization dependencies; `prev_events` links describe predecessors in the room's event DAG. Being an earlier event in the room does not by itself make an event part of the auth chain. + +The **chain cover** is an index over this graph. It groups events into numbered linear chains and records links between them, so reachability can be computed using chain positions instead of traversing every auth edge. These numbered chains are an indexing structure; one event's auth chain can span many of them. + +```text +events/eah / event_id -> (1, chain, sequence, (auth_event_id, ...)) +events/acp / (room_id, chain, sequence) -> (event_id) +events/acl / (room_id, chain, sequence, target_chain, target_sequence) -> empty +events/acn / room_id -> (last_allocated_chain_id) +``` + +Every stored event has an auth header. Its chain and sequence are zero until finalized; only state events get chain positions. Accepted state events are finalized during preparation or staging, and a stored outlier's header can be finalized later when another event needs it. Preparation holds new labels in memory until publication writes them. The leading `1` is the header format version. A finalized header supports at most 64 auth-event edges. + +The cover is built as follows: + +- An event extends an auth event's chain when that auth event has the same state tuple and is still the chain tip. Otherwise it starts a new chain. +- `(chain, sequence)` reaches every earlier position in its chain. +- Links record dependencies on other chains. Redundant links are omitted when another dependency or an earlier position already reaches the target. + +A chain commonly follows one user's membership history or the room's power levels. Concurrent branches can create several chains for the same tuple. The tip is found through `acp`; `acn` allocates chain IDs. + +Finalized positions and links are stable, but headers are rewritten on finalization and the allocator advances. Staging allocates and extends chains using normal transaction reads. Labels assigned during read-only preparation are checked again with normal reads in the publication transaction. [State resolution](event-sending.md#state-resolution) uses this cover to find auth differences and conflicted subgraphs without walking every auth edge individually. + +## The immutable tree + +Both maps use the same canonical tree, implemented in [state/page.go](../internal/databases/rooms/state/page.go) and [state/tree.go](../internal/databases/rooms/state/tree.go). + +An entry's bucket is the first eight bytes of SHA-256 over its map key, interpreted as an unsigned big-endian integer. Each branch consumes one nibble of that bucket, giving 16 child slots. A path can be at most 16 branches deep. + +```text +leaf = (0, key, value, key, value, ...) +branch = (1, subtree_entry_count, subtree_entry_bytes, + child_0, child_1, ..., child_15) +``` + +Leaf entries are sorted by key. Keys, values and child IDs are byte strings; an empty child ID means no subtree. Branch totals describe entries below the branch, not the size of the branch record itself. + +A subtree becomes a leaf when it contains at most 64 entries and its encoded leaf fits within 64 KiB. Larger subtrees split by the next bucket nibble. Depth 16 always produces a leaf. A removal collapses a branch back to a leaf when the remaining contents fit; the stored totals let the tree make that decision before loading unchanged siblings. + +Because the split and collapse rules depend on contents, the same map built through different sequences of updates has the same root. + +### Updating a context + +`Batch.TxnApply` reads the paths affected by a delta and creates replacement pages in memory. It leaves the old pages untouched. An empty event ID in a delta removes the tuple. Applying no effective change returns the original context ID. + +For a member update, the non-member state root and unaffected member pages are shared: + +```text +Before: C1 = (S1, M1) After: C2 = (S1, M2) + +M1 --+-- leaf A M2 --+-- leaf A shared + +-- leaf B +-- leaf B shared + +-- leaf C +-- leaf C' changed member + +New records: leaf C', branch M2, context C2 +Existing C1, M1, S1 and their pages remain usable. +``` + +A diff compares page IDs first and skips equal subtrees. Unequal subtrees are traversed to produce changed tuples, including the old and new membership values. Multi-context conflict discovery also retains absence: a tuple present in only some inputs conflicts. + +### Reading, caching and writing + +A `state.Batch` belongs to one room and holds the pages and contexts built during an operation. Reads follow this order: + +```text +pending records in the Batch -> decoded process cache -> FDB snapshot reads +``` + +Missing pages are fetched together at each tree level. The process cache is a 64 MiB LRU keyed by room and hash. It contains committed pages and contexts only. Reads after state writes in the same transaction are excluded from the cache, because they may see uncommitted data. Records explicitly staged by a batch enter the cache after their staging transaction commits. + +Snapshot reads are appropriate for immutable pages and contexts. Mutable room records, membership writes and auth-chain allocation have separate concurrency checks; they do not inherit that rule. + +| Batch operation | Behaviour | +| --- | --- | +| `TxnLookupEntries` | Look up several tuples across both maps, batching reads by level | +| `TxnIterateState`, `TxnIterateMembers`, `TxnIterateAll` | Traverse selected maps or several contexts | +| `TxnMembersPage` | Resume a member scan by bucket, reading whole leaves until at least the requested limit; it can return more entries than the limit | +| `TxnCount` | Read the roots' entry totals | +| `TxnDiff`, `TxnConflicts` | Compare contexts while skipping shared pages | +| `TxnApply` | Build a new context from a delta, leaving its records pending | +| `TxnWrite` | Write requested pending contexts and the pending pages reachable from them into the caller's transaction | +| `Stage` | Write those records in separate bounded transactions before publication | + +A member-scan continuation bucket belongs to the context it came from. Keep that context fixed for the whole scan. + +Writing starts with children, then their parent pages, then context records. Unreachable intermediate results are omitted. Staging counts key and value bytes against `rooms.stateBudget.stagingBytes`, default 5,000,000 bytes; a record larger than the target is written alone. Staging does not move the room's current-state pointer. [Artifacts and staging](event-sending.md#staging-and-artifacts) explains how sends make staged data visible. + +## Reading room state + +Readers choose the context appropriate to the question: + +| Question | Starting point | +| --- | --- | +| State now | `Room.CurrentState` | +| State before or after a particular event | Its `BeforeState` or `AfterState` | +| Current state at a sync version | Last `events/rch` row at or before that version | +| One member's membership in a room | Member lookup in the selected context | +| Rooms a local user belongs to | Their exact `users/mem` rows | +| Rooms a remote user belongs to | Rooms shared with their server, followed by a member lookup in each room's current context | +| Joined local users or servers in a room | `rlm` or `servers/jcn` | + +Outlier local membership rows are the exception to current-state membership lookup while this server is outside the room. They preserve invites and knocks without inventing a full room context. + +Sync derives state sections from context differences and uses the timeline and membership histories to choose their endpoints. It does not reconstruct state by replaying every state event. Directory search similarly checks remote candidates against current state in the requester's rooms, because remote users have no per-user membership rows. + +## Storage constraints + +The hash function, encodings, membership codes and tree-shape limits are part of the storage format. Changing them can change context IDs. A single entry must fit the leaf byte limit, fixed at 64 KiB in production; otherwise applying it returns `ErrEntryTooLarge`. + +Pages, contexts, history rows and stored resolution results currently have no garbage collection. A send can leave staged data behind without publishing it. That data does not change current room state, but still consumes storage. Hashes are truncated to 128 bits; the format assumes distinct records do not collide. + +Immutable state permits reads across multiple transactions for artifact jobs. Reads of mutable room state still need a consistent transaction and the publication checks described in [event sending](event-sending.md#publication-and-concurrency). + +## Code map + +| Code | Responsibility | +| --- | --- | +| [state/context.go](../internal/databases/rooms/state/context.go) | Context encoding, map operations and pending records | +| [state/page.go](../internal/databases/rooms/state/page.go), [state/tree.go](../internal/databases/rooms/state/tree.go) | Page format, hashing, traversal and updates | +| [state/state.go](../internal/databases/rooms/state/state.go), [state/stage.go](../internal/databases/rooms/state/stage.go) | FDB storage, cache eligibility and staged writes | +| [events/authgraph.go](../internal/databases/rooms/events/authgraph.go), [events/authchains.go](../internal/databases/rooms/events/authchains.go) | Auth headers and chain cover | +| [events/statehistory.go](../internal/databases/rooms/events/statehistory.go) | Current-state history by version | +| [eventsend.go](../internal/databases/rooms/eventsend.go) | Atomic publication of room fields, history and membership indexes |