package syncapi import ( "cmp" "context" "encoding/json" "errors" "slices" "strconv" "strings" "time" "go.opentelemetry.io/otel/attribute" "github.com/dariuszjaniak/warp/server/internal/matrix/event" "github.com/dariuszjaniak/warp/server/internal/roomserver" "github.com/dariuszjaniak/warp/server/internal/storage" "github.com/dariuszjaniak/warp/server/internal/tracing" ) // Simplified Sliding Sync (MSC4186): /sync for clients with a thousand // rooms that want to show the top twenty. // // A classic initial sync sends every room a user is in, with its state and // a timeline, before the client can draw anything; a client with a large // account waits minutes. Sliding sync turns that round. The client asks for // lists — "my rooms, most recently active first, positions 0 to 19" — and // for the few pieces of state it needs to draw each one, and the server // sends those rooms and a count of the rest. As the user scrolls, the // client widens the window; as rooms become active, they move into it and // are sent. // // What the server keeps for this is per connection (slidingconn.go): which // rooms it has sent and as of which position, so that a room is sent once // in full and then only as it changes, and a room that leaves the window // and comes back is not sent again unless something happened to it. The // streams that are not about one room — to-device messages, device lists, // account data, receipts, typing — are extensions a client turns on, and // are read with the same helpers classic /sync uses. // SlidingRequest is one sliding sync request. type SlidingRequest struct { User string Localpart string Device string // Pos is the ?pos= of the previous response, empty for a new // connection. Pos string // Timeout is how long to wait for something to happen. Timeout time.Duration // SetPresence is ?set_presence=, as for /sync. SetPresence string Body SlidingBody } // SlidingBody is the request body. type SlidingBody struct { // ConnID tells apart the connections one device keeps: a client with a // room list and a notification process runs two. ConnID string `json:"conn_id,omitempty"` Lists map[string]SlidingList `json:"lists,omitempty"` RoomSubscriptions map[string]RoomSubscription `json:"room_subscriptions,omitempty"` Extensions SlidingExtensionsRequest `json:"extensions,omitempty"` } // RoomSubscription is what a client wants of each room it is sent. type RoomSubscription struct { // RequiredState lists [type, state_key] pairs. "*" is a wildcard in // either place, "$ME" is the user, and ["m.room.member", "$LAZY"] is the // members who sent something in the timeline. RequiredState [][]string `json:"required_state,omitempty"` TimelineLimit int `json:"timeline_limit,omitempty"` } // SlidingList is one room list. type SlidingList struct { RoomSubscription // Ranges are inclusive windows into the sorted list. Ranges [][2]int `json:"ranges,omitempty"` Filters *SlidingFilters `json:"filters,omitempty"` } // SlidingFilters narrows which rooms a list holds. type SlidingFilters struct { IsDM *bool `json:"is_dm,omitempty"` Spaces []string `json:"spaces,omitempty"` IsEncrypted *bool `json:"is_encrypted,omitempty"` IsInvite *bool `json:"is_invite,omitempty"` // RoomTypes and NotRoomTypes match m.room.create's type; a null entry // is a room with no type, which is to say an ordinary room. RoomTypes []*string `json:"room_types,omitempty"` NotRoomTypes []*string `json:"not_room_types,omitempty"` Tags []string `json:"tags,omitempty"` NotTags []string `json:"not_tags,omitempty"` } // extensionToggle is the shape every extension's request shares. type extensionToggle struct { Enabled *bool `json:"enabled,omitempty"` // Lists and Rooms narrow which rooms a per-room extension reports on; // absent means every room in the response's lists and subscriptions. Lists []string `json:"lists,omitempty"` Rooms []string `json:"rooms,omitempty"` } func (t *extensionToggle) on() bool { return t != nil && t.Enabled != nil && *t.Enabled } // SlidingExtensionsRequest is which extensions the client wants. type SlidingExtensionsRequest struct { ToDevice *struct { extensionToggle Since string `json:"since,omitempty"` Limit int `json:"limit,omitempty"` } `json:"to_device,omitempty"` E2EE *extensionToggle `json:"e2ee,omitempty"` AccountData *extensionToggle `json:"account_data,omitempty"` Receipts *extensionToggle `json:"receipts,omitempty"` Typing *extensionToggle `json:"typing,omitempty"` } // SlidingResponse is one response. type SlidingResponse struct { Pos string `json:"pos"` Lists map[string]SlidingListResult `json:"lists"` Rooms map[string]*SlidingRoom `json:"rooms"` Extensions SlidingExtensions `json:"extensions"` } // SlidingListResult is how many rooms a list holds. type SlidingListResult struct { Count int `json:"count"` } // SlidingHero is a member a room without a name is named after. type SlidingHero struct { UserID string `json:"user_id"` DisplayName string `json:"displayname,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` } // SlidingRoom is one room of a response. type SlidingRoom struct { Name string `json:"name,omitempty"` Avatar string `json:"avatar,omitempty"` Heroes []SlidingHero `json:"heroes,omitempty"` IsDM bool `json:"is_dm,omitempty"` // Initial says this is the room in full: the client replaces what it // had rather than adding to it. Initial bool `json:"initial,omitempty"` RequiredState []roomserver.ClientEvent `json:"required_state,omitempty"` Timeline []roomserver.ClientEvent `json:"timeline,omitempty"` PrevBatch string `json:"prev_batch,omitempty"` Limited bool `json:"limited,omitempty"` // NumLive is how many of the timeline's events are new since the // previous response, rather than history sent to fill the timeline. NumLive *int `json:"num_live,omitempty"` // BumpStamp orders rooms by activity: the position of the last event // that moved the room up a list. BumpStamp int64 `json:"bump_stamp,omitempty"` JoinedCount *int `json:"joined_count,omitempty"` InvitedCount *int `json:"invited_count,omitempty"` NotificationCount int `json:"notification_count"` HighlightCount int `json:"highlight_count"` InviteState []roomserver.StrippedEvent `json:"invite_state,omitempty"` KnockState []roomserver.StrippedEvent `json:"knock_state,omitempty"` } // SlidingExtensions is the extensions' half of a response. type SlidingExtensions struct { ToDevice *ToDeviceExtension `json:"to_device,omitempty"` E2EE *E2EEExtension `json:"e2ee,omitempty"` AccountData *AccountDataExtension `json:"account_data,omitempty"` Receipts *EphemeralExtension `json:"receipts,omitempty"` Typing *EphemeralExtension `json:"typing,omitempty"` } // ToDeviceExtension is the to-device messages for the syncing device. type ToDeviceExtension struct { NextBatch string `json:"next_batch"` Events []toDeviceEvent `json:"events"` } // E2EEExtension is the end-to-end encryption bookkeeping a classic sync // carries at its top level. type E2EEExtension struct { DeviceLists DeviceLists `json:"device_lists"` DeviceOneTimeKeysCount map[string]int `json:"device_one_time_keys_count"` DeviceUnusedFallbackKeyTypes []string `json:"device_unused_fallback_key_types"` } // AccountDataExtension is global and per-room account data. type AccountDataExtension struct { Global []AccountDataEvent `json:"global"` Rooms map[string][]AccountDataEvent `json:"rooms"` } // EphemeralExtension is receipts or typing, by room. type EphemeralExtension struct { Rooms map[string]EphemeralEvent `json:"rooms"` } // defaultToDeviceLimit is the to_device extension's batch size when the // client names none. A device that has been away drains its queue over // several requests rather than in one response of unbounded size. const defaultToDeviceLimit = 100 // directType is the account data a client records its direct chats in. const directType = "m.direct" // tagType is the room account data a client records a room's tags in. const tagType = "m.tag" // SlidingSync answers one sliding sync request, blocking for up to // req.Timeout when there is nothing to report. func (s *API) SlidingSync(ctx context.Context, req SlidingRequest) (SlidingResponse, error) { key := connKey{user: req.User, device: req.Device, connID: req.Body.ConnID} snap, err := s.conns.load(key, req.Pos) if err != nil { return SlidingResponse{}, err } if err := s.applySetPresence(ctx, SyncRequest{User: req.User, SetPresence: req.SetPresence}); err != nil { return SlidingResponse{}, err } timeout := min(req.Timeout, s.longPollTimeout) deadline := s.now().Add(timeout) for { rooms, err := s.rooms.UserMemberships(ctx, req.User, nil) if err != nil { return SlidingResponse{}, err } listener := s.notifier.Listen(req.User, roomIDsOf(rooms)) resp, next, interesting, err := s.slidingAttempt(ctx, req, snap, rooms) if err != nil { listener.Close() return SlidingResponse{}, err } wait := time.Until(deadline) if interesting || timeout <= 0 || wait <= 0 { listener.Close() resp.Pos = s.conns.save(key, next) return resp, nil } woken, err := waitFor(ctx, listener, wait) listener.Close() if err != nil { return SlidingResponse{}, err } if !woken { resp.Pos = s.conns.save(key, next) return resp, nil } } } // slidingRoomConfig is what a room is to be sent with, merged over every // list and subscription that selects it. type slidingRoomConfig struct { timelineLimit int requiredState [][]string } func (c slidingRoomConfig) merge(sub RoomSubscription) slidingRoomConfig { c.timelineLimit = max(c.timelineLimit, min(sub.TimelineLimit, MaxTimelineLimit)) for _, pair := range sub.RequiredState { if len(pair) == 2 && !slices.ContainsFunc(c.requiredState, func(p []string) bool { return slices.Equal(p, pair) }) { c.requiredState = append(c.requiredState, pair) } } return c } // key renders a config for comparison with the one a room was last sent // with. func (c slidingRoomConfig) key() string { pairs := make([]string, 0, len(c.requiredState)) for _, p := range c.requiredState { pairs = append(pairs, p[0]+"\x00"+p[1]) } slices.Sort(pairs) return strconv.Itoa(c.timelineLimit) + "\x01" + strings.Join(pairs, "\x01") } // slidingAttempt builds one candidate response and the connection state it // leaves behind. func (s *API) slidingAttempt(ctx context.Context, req SlidingRequest, prev *connSnapshot, memberships []roomserver.MembershipAt) (_ SlidingResponse, _ *connSnapshot, _ bool, err error) { ctx, span := tracing.Start(ctx, "syncapi.sliding_sync", attribute.Bool("matrix.initial", prev == nil)) defer func() { tracing.End(span, err) }() positions, err := s.store.StreamPositions(ctx) if err != nil { return SlidingResponse{}, nil, false, err } to := tokenAt(positions) var since Token next := &connSnapshot{token: to, startPDU: to.PDU, rooms: map[string]sentRoom{}, lists: map[string]int{}} if prev != nil { since = prev.token next = prev.clone() next.token = to } resp := SlidingResponse{ Lists: map[string]SlidingListResult{}, Rooms: map[string]*SlidingRoom{}, } byRoom := make(map[string]roomserver.MembershipAt, len(memberships)) for _, m := range memberships { byRoom[m.RoomID] = m } // The rooms a list may hold: those the user is in, invited to or // knocking on, and those they left while this connection was open — // which is how a client hears that a room it is showing is gone. var candidates []roomserver.MembershipAt for _, m := range memberships { switch m.Membership { case event.MembershipJoin, event.MembershipInvite, event.MembershipKnock: candidates = append(candidates, m) case event.MembershipLeave, event.MembershipBan: if prev != nil && m.Position > prev.startPDU { candidates = append(candidates, m) } } } facts, err := s.roomFacts(ctx, req, candidates, to, req.Body.Lists) if err != nil { return SlidingResponse{}, nil, false, err } selected := map[string]slidingRoomConfig{} listsChanged := prev == nil for name, list := range req.Body.Lists { var rooms []roomserver.MembershipAt for _, m := range candidates { ok, err := s.listAdmits(ctx, req, list.Filters, m, facts) if err != nil { return SlidingResponse{}, nil, false, err } if ok { rooms = append(rooms, m) } } slices.SortFunc(rooms, func(a, b roomserver.MembershipAt) int { return cmp.Or(cmp.Compare(facts.bump[b.RoomID], facts.bump[a.RoomID]), strings.Compare(a.RoomID, b.RoomID)) }) resp.Lists[name] = SlidingListResult{Count: len(rooms)} if count, ok := next.lists[name]; !ok || count != len(rooms) { listsChanged = true } next.lists[name] = len(rooms) for _, r := range list.Ranges { lo, hi := max(r[0], 0), min(r[1], len(rooms)-1) for i := lo; i <= hi; i++ { selected[rooms[i].RoomID] = selected[rooms[i].RoomID].merge(list.RoomSubscription) } } } // Subscriptions name a room directly: a room the client has open, // whatever list it is or is not in. The user has to be able to read // it, which for a room they are not in means a world-readable one. for roomID, sub := range req.Body.RoomSubscriptions { if _, ok := byRoom[roomID]; !ok { visible, err := s.rooms.VisibleRange(ctx, roomID, req.User) if err != nil { if errors.Is(err, ErrNoSuchRoom) { continue } return SlidingResponse{}, nil, false, err } if !visible.Allowed { continue } byRoom[roomID] = roomserver.MembershipAt{RoomID: roomID} } selected[roomID] = selected[roomID].merge(sub) } changed, err := s.slidingChangedRooms(ctx, req, prev, selected, byRoom, since, to) if err != nil { return SlidingResponse{}, nil, false, err } selectedIDs := make([]string, 0, len(selected)) for roomID := range selected { selectedIDs = append(selectedIDs, roomID) } slices.Sort(selectedIDs) var counts map[string]storage.NotificationCounts if s.push != nil && len(changed) > 0 { if counts, err = s.push.NotificationCounts(ctx, req.User, selectedIDs); err != nil { return SlidingResponse{}, nil, false, err } } initialRooms := map[string]bool{} for _, roomID := range selectedIDs { initial, ok := changed[roomID] if !ok { continue } config := selected[roomID] m := byRoom[roomID] sent := next.rooms[roomID] from := sent.pos if initial { from = 0 } room, err := s.slidingRoom(ctx, req, m, config, from, to, facts) if err != nil { if errors.Is(err, ErrForbidden) { // Lost access between the membership read and this one. continue } return SlidingResponse{}, nil, false, err } room.Initial = initial room.BumpStamp = facts.bump[roomID] c := counts[roomID] room.NotificationCount, room.HighlightCount = c.Notifications, c.Highlights resp.Rooms[roomID] = room if initial { initialRooms[roomID] = true } next.rooms[roomID] = sentRoom{pos: to.PDU, config: config.key(), membership: m.Membership, typing: sent.typing} } extensionsInteresting, err := s.slidingExtensions(ctx, req, prev, next, since, to, memberships, selectedIDs, byRoom, initialRooms, &resp) if err != nil { return SlidingResponse{}, nil, false, err } interesting := prev == nil || listsChanged || len(resp.Rooms) > 0 || extensionsInteresting return resp, next, interesting, nil } // slidingFacts are what a room list is filtered and sorted by. type slidingFacts struct { bump map[string]int64 dms map[string]bool entry map[string]roomserver.RoomListEntry } // roomFacts reads what the lists need to know about the candidate rooms. // // The bump stamp of a joined room is its last activity; a room the user is // invited to, knocking on or has left is bumped by their own membership, // because they cannot see what happens in it. func (s *API) roomFacts(ctx context.Context, req SlidingRequest, candidates []roomserver.MembershipAt, to Token, lists map[string]SlidingList) (slidingFacts, error) { facts := slidingFacts{bump: map[string]int64{}, dms: map[string]bool{}, entry: map[string]roomserver.RoomListEntry{}} var joined []string for _, m := range candidates { if m.Membership == event.MembershipJoin { joined = append(joined, m.RoomID) } else { facts.bump[m.RoomID] = m.Position } } bumps, err := s.rooms.BumpStamps(ctx, joined, to.PDU) if err != nil { return slidingFacts{}, err } for _, roomID := range joined { facts.bump[roomID] = bumps[roomID] } raw, err := s.accounts.AccountData(ctx, req.Localpart, "", directType) switch { case errors.Is(err, storage.ErrNoSuchAccountData): case err != nil: return slidingFacts{}, err default: var direct map[string][]string if json.Unmarshal(raw, &direct) == nil { for _, rooms := range direct { for _, roomID := range rooms { facts.dms[roomID] = true } } } } return facts, nil } // entryOf reads a room's list entry once per attempt. func (s *API) entryOf(ctx context.Context, roomID string, facts slidingFacts) (roomserver.RoomListEntry, error) { if e, ok := facts.entry[roomID]; ok { return e, nil } e, err := s.rooms.RoomListEntry(ctx, roomID) if err != nil { return roomserver.RoomListEntry{}, err } facts.entry[roomID] = e return e, nil } // listAdmits applies a list's filters to one room. func (s *API) listAdmits(ctx context.Context, req SlidingRequest, f *SlidingFilters, m roomserver.MembershipAt, facts slidingFacts) (bool, error) { if f == nil { return true, nil } if f.IsDM != nil && facts.dms[m.RoomID] != *f.IsDM { return false, nil } if f.IsInvite != nil && (m.Membership == event.MembershipInvite) != *f.IsInvite { return false, nil } if f.IsEncrypted != nil || len(f.RoomTypes) > 0 || len(f.NotRoomTypes) > 0 { entry, err := s.entryOf(ctx, m.RoomID, facts) if err != nil { return false, err } if f.IsEncrypted != nil && entry.Encrypted != *f.IsEncrypted { return false, nil } typeMatches := func(types []*string) bool { return slices.ContainsFunc(types, func(t *string) bool { if t == nil { return entry.RoomType == "" } return *t == entry.RoomType }) } if len(f.RoomTypes) > 0 && !typeMatches(f.RoomTypes) { return false, nil } if typeMatches(f.NotRoomTypes) { return false, nil } } if len(f.Spaces) > 0 { in := false for _, space := range f.Spaces { children, err := s.rooms.SpaceChildRooms(ctx, space) if err != nil { return false, err } if slices.Contains(children, m.RoomID) { in = true break } } if !in { return false, nil } } if len(f.Tags) > 0 || len(f.NotTags) > 0 { tags, err := s.roomTags(ctx, req.Localpart, m.RoomID) if err != nil { return false, err } if len(f.Tags) > 0 && !slices.ContainsFunc(f.Tags, func(t string) bool { return tags[t] }) { return false, nil } if slices.ContainsFunc(f.NotTags, func(t string) bool { return tags[t] }) { return false, nil } } return true, nil } // roomTags reads the tags a user has put on a room. func (s *API) roomTags(ctx context.Context, localpart, roomID string) (map[string]bool, error) { raw, err := s.accounts.AccountData(ctx, localpart, roomID, tagType) if errors.Is(err, storage.ErrNoSuchAccountData) { return nil, nil } if err != nil { return nil, err } var content struct { Tags map[string]json.RawMessage `json:"tags"` } // Tags a client wrote in a shape that is not the spec's are no tags: // a list filter is not the place to refuse somebody's account data. if json.Unmarshal(raw, &content) != nil { content.Tags = nil } out := make(map[string]bool, len(content.Tags)) for t := range content.Tags { out[t] = true } return out, nil } // slidingChangedRooms decides which selected rooms go into the response, // and whether each goes in full. The value is true for a room sent in full. func (s *API) slidingChangedRooms(ctx context.Context, req SlidingRequest, prev *connSnapshot, selected map[string]slidingRoomConfig, byRoom map[string]roomserver.MembershipAt, since, to Token) (map[string]bool, error) { out := map[string]bool{} // The rooms already sent, grouped by the position they were sent up // to, so that "has anything happened since" is one query per group. byPos := map[int64][]string{} for roomID, config := range selected { var sent sentRoom var ok bool if prev != nil { sent, ok = prev.rooms[roomID] } m := byRoom[roomID] switch { case !ok, sent.config != config.key(): out[roomID] = true case m.Membership != sent.membership: // A membership the room was last sent under no longer // applies: an invite accepted is a room the client has only // seen stripped state of. out[roomID] = sent.membership == event.MembershipInvite || sent.membership == event.MembershipKnock || sent.membership == "" case m.Position > sent.pos: out[roomID] = false case m.Membership == event.MembershipJoin || m.Membership == "": byPos[sent.pos] = append(byPos[sent.pos], roomID) } } for pos, roomIDs := range byPos { slices.Sort(roomIDs) withEvents, err := s.rooms.RoomsWithEvents(ctx, roomIDs, pos, to.PDU) if err != nil { return nil, err } for _, roomID := range withEvents { out[roomID] = false } } // Rooms whose unread counts moved without an event: read on another // device. if s.push != nil && prev != nil && to.Notification > since.Notification { moved, err := s.push.NotificationCountChanges(ctx, req.User, since.Notification, to.Notification) if err != nil { return nil, err } for _, roomID := range moved { if _, ok := selected[roomID]; ok { if _, already := out[roomID]; !already { out[roomID] = false } } } } return out, nil } // slidingRoom reads one room for a response. from is where the client's // copy of the room ends, zero for a room sent in full. func (s *API) slidingRoom(ctx context.Context, req SlidingRequest, m roomserver.MembershipAt, config slidingRoomConfig, from int64, to Token, facts slidingFacts) (*SlidingRoom, error) { room := &SlidingRoom{IsDM: facts.dms[m.RoomID]} entry, err := s.entryOf(ctx, m.RoomID, facts) if err != nil { return nil, err } room.Name, room.Avatar = entry.Name, entry.AvatarURL switch m.Membership { case event.MembershipInvite, event.MembershipKnock: state, err := s.rooms.StrippedState(ctx, m.RoomID, []string{req.User}) if err != nil { return nil, err } if m.Membership == event.MembershipInvite { room.InviteState = state } else { room.KnockState = state } return room, nil } if config.timelineLimit > 0 { timeline, err := s.rooms.Timeline(ctx, roomserver.TimelineRequest{ RoomID: m.RoomID, User: req.User, Low: from, High: to.PDU, Direction: storage.DirectionBackward, Limit: config.timelineLimit, }) if err != nil { return nil, err } room.Timeline = withoutRoomID(timeline.Events) room.Limited = timeline.Limited if len(timeline.Events) > 0 { room.PrevBatch = pduToken(timeline.Start).String() } live := 0 if from != 0 { live = len(timeline.Events) } room.NumLive = &live } match := newStateMatcher(config.requiredState, req.User, room.Timeline) if !match.empty() { state, err := s.rooms.RequiredState(ctx, roomserver.RequiredStateRequest{ RoomID: m.RoomID, User: req.User, Since: from, To: to.PDU, Match: match.matches, }) if err != nil { return nil, err } room.RequiredState = withoutRoomID(state) } summary, err := s.rooms.RoomSummaryFor(ctx, m.RoomID, req.User) if err != nil { return nil, err } joined, invited := summary.JoinedMemberCount, summary.InvitedMemberCount room.JoinedCount, room.InvitedCount = &joined, &invited if room.Name == "" && len(summary.Heroes) > 0 { members, err := s.rooms.MemberEvents(ctx, m.RoomID, summary.Heroes) if err != nil { return nil, err } profiles := map[string]SlidingHero{} for _, e := range members { if e.StateKey == nil { continue } var content struct { DisplayName string `json:"displayname"` AvatarURL string `json:"avatar_url"` } _ = json.Unmarshal(e.Content, &content) profiles[*e.StateKey] = SlidingHero{UserID: *e.StateKey, DisplayName: content.DisplayName, AvatarURL: content.AvatarURL} } for _, userID := range summary.Heroes { hero, ok := profiles[userID] if !ok { hero = SlidingHero{UserID: userID} } room.Heroes = append(room.Heroes, hero) } } return room, nil } // stateMatcher is a room's required_state, compiled. type stateMatcher struct { all bool wholeType map[string]bool anyType map[string]bool tuples map[[2]string]bool } func newStateMatcher(required [][]string, userID string, timeline []roomserver.ClientEvent) stateMatcher { m := stateMatcher{wholeType: map[string]bool{}, anyType: map[string]bool{}, tuples: map[[2]string]bool{}} for _, pair := range required { eventType, stateKey := pair[0], pair[1] switch stateKey { case "$ME": stateKey = userID case "$LAZY": if eventType == event.TypeMember { // The members whose names the timeline needs, and the user // themselves, whose own membership a client always shows. m.tuples[[2]string{event.TypeMember, userID}] = true for _, e := range timeline { m.tuples[[2]string{event.TypeMember, e.Sender}] = true } } continue } switch { case eventType == "*" && stateKey == "*": m.all = true case stateKey == "*": m.wholeType[eventType] = true case eventType == "*": m.anyType[stateKey] = true default: m.tuples[[2]string{eventType, stateKey}] = true } } return m } func (m stateMatcher) empty() bool { return !m.all && len(m.wholeType) == 0 && len(m.anyType) == 0 && len(m.tuples) == 0 } func (m stateMatcher) matches(eventType, stateKey string) bool { return m.all || m.wholeType[eventType] || m.anyType[stateKey] || m.tuples[[2]string{eventType, stateKey}] } // extensionRooms narrows the rooms a per-room extension reports on to the // ones its request names, by list or by room. // // Naming any list the request has, or "*", is every selected room: the // windows of the lists are merged before the extensions run, and a client // naming its main list means the rooms it is showing, which is a superset // at worst. Naming rooms narrows to those of them that are selected. func extensionRooms(t *extensionToggle, body SlidingBody, selected []string) []string { if len(t.Lists) == 0 && len(t.Rooms) == 0 { return selected } for _, name := range t.Lists { if _, ok := body.Lists[name]; ok || name == "*" { return selected } } if slices.Contains(t.Rooms, "*") { return selected } var out []string for _, roomID := range t.Rooms { if slices.Contains(selected, roomID) { out = append(out, roomID) } } return out } // slidingExtensions fills in the extensions a request turned on, and // reports whether any of them has something to say. func (s *API) slidingExtensions( ctx context.Context, req SlidingRequest, prev, next *connSnapshot, since, to Token, memberships []roomserver.MembershipAt, selected []string, byRoom map[string]roomserver.MembershipAt, initialRooms map[string]bool, resp *SlidingResponse, ) (bool, error) { ext := req.Body.Extensions interesting := false if ext.ToDevice != nil && ext.ToDevice.on() { from := next.toDevice if ext.ToDevice.Since != "" { parsed, err := strconv.ParseInt(ext.ToDevice.Since, 10, 64) if err != nil || parsed < 0 { return false, errInvalidTokenf("%q is not a to_device token", ext.ToDevice.Since) } from = parsed // Coming back with a since acknowledges everything up to it. s.ackToDevice(ctx, req.Localpart, req.Device, from) } limit := ext.ToDevice.Limit if limit <= 0 { limit = defaultToDeviceLimit } msgs, pos, err := s.toDeviceMessages(ctx, req.Localpart, req.Device, from, to.ToDevice, limit) if err != nil { return false, err } if msgs == nil { msgs = []toDeviceEvent{} } next.toDevice = pos resp.Extensions.ToDevice = &ToDeviceExtension{NextBatch: strconv.FormatInt(pos, 10), Events: msgs} interesting = interesting || len(msgs) > 0 } if ext.E2EE.on() { e2ee := &E2EEExtension{DeviceLists: DeviceLists{Changed: []string{}, Left: []string{}}} if prev != nil { lists, err := s.deviceLists(ctx, req.User, since, to, memberships) if err != nil { return false, err } e2ee.DeviceLists = lists } var keys Response if err := s.deviceKeyCounts(ctx, SyncRequest{User: req.User, Device: req.Device}, &keys); err != nil { return false, err } e2ee.DeviceOneTimeKeysCount = keys.DeviceOneTimeKeysCount if e2ee.DeviceOneTimeKeysCount == nil { e2ee.DeviceOneTimeKeysCount = map[string]int{} } e2ee.DeviceUnusedFallbackKeyTypes = []string{} if keys.DeviceUnusedFallbackKeyTypes != nil { e2ee.DeviceUnusedFallbackKeyTypes = *keys.DeviceUnusedFallbackKeyTypes } resp.Extensions.E2EE = e2ee interesting = interesting || len(e2ee.DeviceLists.Changed) > 0 || len(e2ee.DeviceLists.Left) > 0 } if ext.AccountData.on() { from := since.AccountData global, err := s.globalAccountData(ctx, SyncRequest{User: req.User, Localpart: req.Localpart}, from, to.AccountData, prev == nil, Filter{}) if err != nil { return false, err } out := &AccountDataExtension{Global: global, Rooms: map[string][]AccountDataEvent{}} for _, roomID := range extensionRooms(ext.AccountData, req.Body, selected) { window := roomWindow{since: since, to: to, full: initialRooms[roomID] || prev == nil} events, err := s.roomAccountData(ctx, req.Localpart, roomID, window, Filter{}) if err != nil { return false, err } if len(events) > 0 { out.Rooms[roomID] = events } } resp.Extensions.AccountData = out interesting = interesting || len(global) > 0 || len(out.Rooms) > 0 } joinedOnly := func(roomIDs []string) []string { var out []string for _, roomID := range roomIDs { if byRoom[roomID].Membership == event.MembershipJoin { out = append(out, roomID) } } return out } if ext.Receipts.on() { out := &EphemeralExtension{Rooms: map[string]EphemeralEvent{}} rooms := joinedOnly(extensionRooms(ext.Receipts, req.Body, selected)) var fresh, ongoing []string for _, roomID := range rooms { if initialRooms[roomID] || prev == nil { fresh = append(fresh, roomID) } else { ongoing = append(ongoing, roomID) } } for _, set := range []struct { rooms []string from int64 }{{fresh, 0}, {ongoing, since.Receipt}} { if len(set.rooms) == 0 || (set.from != 0 && to.Receipt <= set.from) { continue } byRoomReceipts, err := s.roomReceipts(ctx, req.User, set.rooms, set.from, to.Receipt) if err != nil { return false, err } for roomID, receipts := range byRoomReceipts { if e := receiptsEvent(receipts); e != nil { out.Rooms[roomID] = *e } } } resp.Extensions.Receipts = out interesting = interesting || len(out.Rooms) > 0 } if ext.Typing.on() { out := &EphemeralExtension{Rooms: map[string]EphemeralEvent{}} for _, roomID := range joinedOnly(extensionRooms(ext.Typing, req.Body, selected)) { e := s.typingEvent(roomID) current := "" if e != nil { current = string(e.Content) } sent := next.rooms[roomID] if current == sent.typing { continue } if e == nil { // Somebody stopped: the client has to be told the list is // now empty, or it would show them typing until it // restarted. e = &EphemeralEvent{Type: "m.typing", Content: json.RawMessage(`{"user_ids":[]}`)} } out.Rooms[roomID] = *e sent.typing = current next.rooms[roomID] = sent } resp.Extensions.Typing = out interesting = interesting || len(out.Rooms) > 0 } return interesting, nil }