Files
Warp/server/internal/syncapi/sliding.go
T
tajniak81andClaude Opus 5.5 b6f3be6ac4
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
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
Web client (warp-web) with E2EE, and two sync fixes
apps/web: a Matrix client for the browser on Vue 3, Tailwind and
matrix-js-sdk, built on @warp/ui. Like the admin panel it is an optional,
separate service: warp-web embeds the built app, serves /config.json and
proxies only /_matrix/client and /_matrix/media to the homeserver (or,
with -public-homeserver, lets the browser call it directly).
Sign-in and sign-up with registration tokens, room list, timeline with
back-pagination, text, images, video and files over authenticated media,
local echo with sent/read/failed states, receipts, typing, invites,
creating, joining and leaving rooms, room info, settings, phone layout.
End-to-end encryption through the Rust crypto: encrypted messages and
files, emoji verification, key backup setup and restore.

apps/ui: WRoomItem, WMessageBubble and WComposer from the brand package,
and the icons the client needs.

syncapi fixes found with the client:
- A room joined inside the sync window was sent as the join alone with
  limited: false, so clients thought they had the whole room. It is now
  read like an initial sync; a profile change is not mistaken for a join.
- A live typing notification was reported on every sync, so every long
  poll returned at once while anybody typed. Typing now has a position
  in the sync token and is reported when it changes, including the empty
  list when the last person stops.

Also: Dockerfile, a `web` compose profile, Makefile targets, a CI job
with a proxy smoke test, docs. PLAN.md moves out of the repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:22:11 +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, false)
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
}