Files
Warp/server/internal/syncapi/sliding.go
T
tajniak81andClaude Opus 5.5 487e0f410b
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Phase 10: hardening and ops
Implements PLAN.md Phase 10.

Simplified Sliding Sync (MSC4186) is served at the unstable
org.matrix.simplified_msc3575 endpoint and advertised in /versions. Lists
are sorted by activity and filtered by DM, invite, encryption, room type,
space and tag; room subscriptions, required_state with wildcards, $ME and
$LAZY, and the to_device, e2ee, account_data, receipts and typing
extensions are supported. Per-connection state is kept in memory and an
unknown pos answers M_UNKNOWN_POS.

Threads and relations: m.relates_to is indexed on roomserver_events
(migration 0012), /relations (with recursion) and /threads are served, and
every event a timeline read returns carries its m.thread, m.replace and
m.reference summaries in unsigned.m.relations. Notifications carry a
thread ID (migration 0013): threaded receipts clear only their thread, and
sync reports unread_thread_notifications when the filter asks.

Spaces: /hierarchy walks a space breadth first, asking other servers about
rooms this one is not in through the new federation hierarchy endpoint;
room_summary describes one room. /search runs on FTS5 (sqlite, pocketbase)
or tsvector (postgres), bounded to what each user may see, with context,
profiles, state, grouping and paging.

The PocketBase driver is real: the sqlite schema and store on an embedded
PocketBase app's database, with media in its filesystem, local or S3
(database.pocketbase.s3). It runs the full storage test suite.

Ops: OpenTelemetry tracing over OTLP/HTTP (tracing.*), with server spans
per route, component spans, and trace propagation on federation, push and
application service calls. New metrics for persisted events, waiting
syncs, sliding sync connections and the database pool, with a Grafana
dashboard. deploy/ adds a Compose setup (PostgreSQL, Prometheus, Grafana,
Jaeger) and a Helm chart; cmd/warp-loadtest loads a server and reports
request and delivery latencies.

The load tester found a long-standing bug, fixed here: on a server where
no account data had been written, every incremental /sync re-sent the push
rules and returned at once, and an all-zero token was taken for an initial
sync, so long polls became busy loops.

The PostgreSQL migrations and search dialect compile but were not run
against a live PostgreSQL in this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:21:31 +02:00

1002 lines
31 KiB
Go

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
}