Add heroes and room summary fields to Sliding Sync /sync (#17419)

Additional room summary fields: `joined_count`, `invited_count`

Based on
[MSC3575](https://github.com/matrix-org/matrix-spec-proposals/pull/3575):
Sliding Sync
This commit is contained in:
Eric Eastwood 2024-07-11 14:05:38 -05:00 committed by GitHub
parent 606da398fc
commit 5a97bbd895
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 528 additions and 109 deletions

View File

@ -0,0 +1 @@
Populate `heroes` and room summary fields (`joined_count`, `invited_count`) in experimental [MSC3575](https://github.com/matrix-org/matrix-spec-proposals/pull/3575) Sliding Sync `/sync` endpoint.

View File

@ -19,7 +19,7 @@
# #
import logging import logging
from itertools import chain from itertools import chain
from typing import TYPE_CHECKING, Any, Dict, Final, List, Optional, Set, Tuple from typing import TYPE_CHECKING, Any, Dict, Final, List, Mapping, Optional, Set, Tuple
import attr import attr
from immutabledict import immutabledict from immutabledict import immutabledict
@ -28,7 +28,9 @@ from synapse.api.constants import AccountDataTypes, Direction, EventTypes, Membe
from synapse.events import EventBase from synapse.events import EventBase
from synapse.events.utils import strip_event from synapse.events.utils import strip_event
from synapse.handlers.relations import BundledAggregations from synapse.handlers.relations import BundledAggregations
from synapse.storage.databases.main.roommember import extract_heroes_from_room_summary
from synapse.storage.databases.main.stream import CurrentStateDeltaMembership from synapse.storage.databases.main.stream import CurrentStateDeltaMembership
from synapse.storage.roommember import MemberSummary
from synapse.types import ( from synapse.types import (
JsonDict, JsonDict,
PersistedEventPosition, PersistedEventPosition,
@ -1043,6 +1045,103 @@ class SlidingSyncHandler:
reverse=True, reverse=True,
) )
async def get_current_state_ids_at(
self,
room_id: str,
room_membership_for_user_at_to_token: _RoomMembershipForUser,
state_filter: StateFilter,
to_token: StreamToken,
) -> StateMap[str]:
"""
Get current state IDs for the user in the room according to their membership. This
will be the current state at the time of their LEAVE/BAN, otherwise will be the
current state <= to_token.
Args:
room_id: The room ID to fetch data for
room_membership_for_user_at_token: Membership information for the user
in the room at the time of `to_token`.
to_token: The point in the stream to sync up to.
"""
room_state_ids: StateMap[str]
# People shouldn't see past their leave/ban event
if room_membership_for_user_at_to_token.membership in (
Membership.LEAVE,
Membership.BAN,
):
# TODO: `get_state_ids_at(...)` doesn't take into account the "current state"
room_state_ids = await self.storage_controllers.state.get_state_ids_at(
room_id,
stream_position=to_token.copy_and_replace(
StreamKeyType.ROOM,
room_membership_for_user_at_to_token.event_pos.to_room_stream_token(),
),
state_filter=state_filter,
# Partially-stated rooms should have all state events except for
# remote membership events. Since we've already excluded
# partially-stated rooms unless `required_state` only has
# `["m.room.member", "$LAZY"]` for membership, we should be able to
# retrieve everything requested. When we're lazy-loading, if there
# are some remote senders in the timeline, we should also have their
# membership event because we had to auth that timeline event. Plus
# we don't want to block the whole sync waiting for this one room.
await_full_state=False,
)
# Otherwise, we can get the latest current state in the room
else:
room_state_ids = await self.storage_controllers.state.get_current_state_ids(
room_id,
state_filter,
# Partially-stated rooms should have all state events except for
# remote membership events. Since we've already excluded
# partially-stated rooms unless `required_state` only has
# `["m.room.member", "$LAZY"]` for membership, we should be able to
# retrieve everything requested. When we're lazy-loading, if there
# are some remote senders in the timeline, we should also have their
# membership event because we had to auth that timeline event. Plus
# we don't want to block the whole sync waiting for this one room.
await_full_state=False,
)
# TODO: Query `current_state_delta_stream` and reverse/rewind back to the `to_token`
return room_state_ids
async def get_current_state_at(
self,
room_id: str,
room_membership_for_user_at_to_token: _RoomMembershipForUser,
state_filter: StateFilter,
to_token: StreamToken,
) -> StateMap[EventBase]:
"""
Get current state for the user in the room according to their membership. This
will be the current state at the time of their LEAVE/BAN, otherwise will be the
current state <= to_token.
Args:
room_id: The room ID to fetch data for
room_membership_for_user_at_token: Membership information for the user
in the room at the time of `to_token`.
to_token: The point in the stream to sync up to.
"""
room_state_ids = await self.get_current_state_ids_at(
room_id=room_id,
room_membership_for_user_at_to_token=room_membership_for_user_at_to_token,
state_filter=state_filter,
to_token=to_token,
)
event_map = await self.store.get_events(list(room_state_ids.values()))
state_map = {}
for key, event_id in room_state_ids.items():
event = event_map.get(event_id)
if event:
state_map[key] = event
return state_map
async def get_room_sync_data( async def get_room_sync_data(
self, self,
user: UserID, user: UserID,
@ -1074,7 +1173,7 @@ class SlidingSyncHandler:
# membership. Currently, we have to make all of these optional because # membership. Currently, we have to make all of these optional because
# `invite`/`knock` rooms only have `stripped_state`. See # `invite`/`knock` rooms only have `stripped_state`. See
# https://github.com/matrix-org/matrix-spec-proposals/pull/3575#discussion_r1653045932 # https://github.com/matrix-org/matrix-spec-proposals/pull/3575#discussion_r1653045932
timeline_events: Optional[List[EventBase]] = None timeline_events: List[EventBase] = []
bundled_aggregations: Optional[Dict[str, BundledAggregations]] = None bundled_aggregations: Optional[Dict[str, BundledAggregations]] = None
limited: Optional[bool] = None limited: Optional[bool] = None
prev_batch_token: Optional[StreamToken] = None prev_batch_token: Optional[StreamToken] = None
@ -1206,7 +1305,7 @@ class SlidingSyncHandler:
# Figure out any stripped state events for invite/knocks. This allows the # Figure out any stripped state events for invite/knocks. This allows the
# potential joiner to identify the room. # potential joiner to identify the room.
stripped_state: Optional[List[JsonDict]] = None stripped_state: List[JsonDict] = []
if room_membership_for_user_at_to_token.membership in ( if room_membership_for_user_at_to_token.membership in (
Membership.INVITE, Membership.INVITE,
Membership.KNOCK, Membership.KNOCK,
@ -1243,6 +1342,44 @@ class SlidingSyncHandler:
# updates. # updates.
initial = True initial = True
# Check whether the room has a name set
name_state_ids = await self.get_current_state_ids_at(
room_id=room_id,
room_membership_for_user_at_to_token=room_membership_for_user_at_to_token,
state_filter=StateFilter.from_types([(EventTypes.Name, "")]),
to_token=to_token,
)
name_event_id = name_state_ids.get((EventTypes.Name, ""))
room_membership_summary: Mapping[str, MemberSummary]
empty_membership_summary = MemberSummary([], 0)
if room_membership_for_user_at_to_token.membership in (
Membership.LEAVE,
Membership.BAN,
):
# TODO: Figure out how to get the membership summary for left/banned rooms
room_membership_summary = {}
else:
room_membership_summary = await self.store.get_room_summary(room_id)
# TODO: Reverse/rewind back to the `to_token`
# `heroes` are required if the room name is not set.
#
# Note: When you're the first one on your server to be invited to a new room
# over federation, we only have access to some stripped state in
# `event.unsigned.invite_room_state` which currently doesn't include `heroes`,
# see https://github.com/matrix-org/matrix-spec/issues/380. This means that
# clients won't be able to calculate the room name when necessary and just a
# pitfall we have to deal with until that spec issue is resolved.
hero_user_ids: List[str] = []
# TODO: Should we also check for `EventTypes.CanonicalAlias`
# (`m.room.canonical_alias`) as a fallback for the room name? see
# https://github.com/matrix-org/matrix-spec-proposals/pull/3575#discussion_r1671260153
if name_event_id is None:
hero_user_ids = extract_heroes_from_room_summary(
room_membership_summary, me=user.to_string()
)
# Fetch the `required_state` for the room # Fetch the `required_state` for the room
# #
# No `required_state` for invite/knock rooms (just `stripped_state`) # No `required_state` for invite/knock rooms (just `stripped_state`)
@ -1253,13 +1390,11 @@ class SlidingSyncHandler:
# https://github.com/matrix-org/matrix-spec-proposals/pull/3575#discussion_r1653045932 # https://github.com/matrix-org/matrix-spec-proposals/pull/3575#discussion_r1653045932
# #
# Calculate the `StateFilter` based on the `required_state` for the room # Calculate the `StateFilter` based on the `required_state` for the room
room_state: Optional[StateMap[EventBase]] = None required_state_filter = StateFilter.none()
required_room_state: Optional[StateMap[EventBase]] = None
if room_membership_for_user_at_to_token.membership not in ( if room_membership_for_user_at_to_token.membership not in (
Membership.INVITE, Membership.INVITE,
Membership.KNOCK, Membership.KNOCK,
): ):
required_state_filter = StateFilter.none()
# If we have a double wildcard ("*", "*") in the `required_state`, we need # If we have a double wildcard ("*", "*") in the `required_state`, we need
# to fetch all state for the room # to fetch all state for the room
# #
@ -1327,84 +1462,63 @@ class SlidingSyncHandler:
# We need this base set of info for the response so let's just fetch it along # We need this base set of info for the response so let's just fetch it along
# with the `required_state` for the room # with the `required_state` for the room
META_ROOM_STATE = [(EventTypes.Name, ""), (EventTypes.RoomAvatar, "")] meta_room_state = [(EventTypes.Name, ""), (EventTypes.RoomAvatar, "")] + [
(EventTypes.Member, hero_user_id) for hero_user_id in hero_user_ids
]
state_filter = StateFilter.all()
if required_state_filter != StateFilter.all():
state_filter = StateFilter( state_filter = StateFilter(
types=StateFilter.from_types( types=StateFilter.from_types(
chain(META_ROOM_STATE, required_state_filter.to_types()) chain(meta_room_state, required_state_filter.to_types())
).types, ).types,
include_others=required_state_filter.include_others, include_others=required_state_filter.include_others,
) )
# We can return all of the state that was requested if this was the first # We can return all of the state that was requested if this was the first
# time we've sent the room down this connection. # time we've sent the room down this connection.
room_state: StateMap[EventBase] = {}
if initial: if initial:
# People shouldn't see past their leave/ban event room_state = await self.get_current_state_at(
if room_membership_for_user_at_to_token.membership in ( room_id=room_id,
Membership.LEAVE, room_membership_for_user_at_to_token=room_membership_for_user_at_to_token,
Membership.BAN,
):
room_state = await self.storage_controllers.state.get_state_at(
room_id,
stream_position=to_token.copy_and_replace(
StreamKeyType.ROOM,
room_membership_for_user_at_to_token.event_pos.to_room_stream_token(),
),
state_filter=state_filter, state_filter=state_filter,
# Partially-stated rooms should have all state events except for to_token=to_token,
# remote membership events. Since we've already excluded
# partially-stated rooms unless `required_state` only has
# `["m.room.member", "$LAZY"]` for membership, we should be able to
# retrieve everything requested. When we're lazy-loading, if there
# are some remote senders in the timeline, we should also have their
# membership event because we had to auth that timeline event. Plus
# we don't want to block the whole sync waiting for this one room.
await_full_state=False,
) )
# Otherwise, we can get the latest current state in the room
else:
room_state = await self.storage_controllers.state.get_current_state(
room_id,
state_filter,
# Partially-stated rooms should have all state events except for
# remote membership events. Since we've already excluded
# partially-stated rooms unless `required_state` only has
# `["m.room.member", "$LAZY"]` for membership, we should be able to
# retrieve everything requested. When we're lazy-loading, if there
# are some remote senders in the timeline, we should also have their
# membership event because we had to auth that timeline event. Plus
# we don't want to block the whole sync waiting for this one room.
await_full_state=False,
)
# TODO: Query `current_state_delta_stream` and reverse/rewind back to the `to_token`
else: else:
# TODO: Once we can figure out if we've sent a room down this connection before, # TODO: Once we can figure out if we've sent a room down this connection before,
# we can return updates instead of the full required state. # we can return updates instead of the full required state.
raise NotImplementedError() raise NotImplementedError()
required_room_state: StateMap[EventBase] = {}
if required_state_filter != StateFilter.none(): if required_state_filter != StateFilter.none():
required_room_state = required_state_filter.filter_state(room_state) required_room_state = required_state_filter.filter_state(room_state)
# Find the room name and avatar from the state # Find the room name and avatar from the state
room_name: Optional[str] = None room_name: Optional[str] = None
room_avatar: Optional[str] = None # TODO: Should we also check for `EventTypes.CanonicalAlias`
if room_state is not None: # (`m.room.canonical_alias`) as a fallback for the room name? see
# https://github.com/matrix-org/matrix-spec-proposals/pull/3575#discussion_r1671260153
name_event = room_state.get((EventTypes.Name, "")) name_event = room_state.get((EventTypes.Name, ""))
if name_event is not None: if name_event is not None:
room_name = name_event.content.get("name") room_name = name_event.content.get("name")
room_avatar: Optional[str] = None
avatar_event = room_state.get((EventTypes.RoomAvatar, "")) avatar_event = room_state.get((EventTypes.RoomAvatar, ""))
if avatar_event is not None: if avatar_event is not None:
room_avatar = avatar_event.content.get("url") room_avatar = avatar_event.content.get("url")
elif stripped_state is not None:
for event in stripped_state:
if event["type"] == EventTypes.Name:
room_name = event.get("content", {}).get("name")
elif event["type"] == EventTypes.RoomAvatar:
room_avatar = event.get("content", {}).get("url")
# Found everything so we can stop looking # Assemble heroes: extract the info from the state we just fetched
if room_name is not None and room_avatar is not None: heroes: List[SlidingSyncResult.RoomResult.StrippedHero] = []
break for hero_user_id in hero_user_ids:
member_event = room_state.get((EventTypes.Member, hero_user_id))
if member_event is not None:
heroes.append(
SlidingSyncResult.RoomResult.StrippedHero(
user_id=hero_user_id,
display_name=member_event.content.get("displayname"),
avatar_url=member_event.content.get("avatar_url"),
)
)
# Figure out the last bump event in the room # Figure out the last bump event in the room
last_bump_event_result = ( last_bump_event_result = (
@ -1423,14 +1537,11 @@ class SlidingSyncHandler:
return SlidingSyncResult.RoomResult( return SlidingSyncResult.RoomResult(
name=room_name, name=room_name,
avatar=room_avatar, avatar=room_avatar,
# TODO: Dummy value heroes=heroes,
heroes=None,
# TODO: Dummy value # TODO: Dummy value
is_dm=False, is_dm=False,
initial=initial, initial=initial,
required_state=( required_state=list(required_room_state.values()),
list(required_room_state.values()) if required_room_state else None
),
timeline_events=timeline_events, timeline_events=timeline_events,
bundled_aggregations=bundled_aggregations, bundled_aggregations=bundled_aggregations,
stripped_state=stripped_state, stripped_state=stripped_state,
@ -1438,9 +1549,12 @@ class SlidingSyncHandler:
limited=limited, limited=limited,
num_live=num_live, num_live=num_live,
bump_stamp=bump_stamp, bump_stamp=bump_stamp,
# TODO: Dummy values joined_count=room_membership_summary.get(
joined_count=0, Membership.JOIN, empty_membership_summary
invited_count=0, ).count,
invited_count=room_membership_summary.get(
Membership.INVITE, empty_membership_summary
).count,
# TODO: These are just dummy values. We could potentially just remove these # TODO: These are just dummy values. We could potentially just remove these
# since notifications can only really be done correctly on the client anyway # since notifications can only really be done correctly on the client anyway
# (encrypted rooms). # (encrypted rooms).

View File

@ -997,8 +997,21 @@ class SlidingSyncRestServlet(RestServlet):
if room_result.avatar: if room_result.avatar:
serialized_rooms[room_id]["avatar"] = room_result.avatar serialized_rooms[room_id]["avatar"] = room_result.avatar
if room_result.heroes: if room_result.heroes is not None and len(room_result.heroes) > 0:
serialized_rooms[room_id]["heroes"] = room_result.heroes serialized_heroes = []
for hero in room_result.heroes:
serialized_hero = {
"user_id": hero.user_id,
}
if hero.display_name is not None:
# Not a typo, just how "displayname" is spelled in the spec
serialized_hero["displayname"] = hero.display_name
if hero.avatar_url is not None:
serialized_hero["avatar_url"] = hero.avatar_url
serialized_heroes.append(serialized_hero)
serialized_rooms[room_id]["heroes"] = serialized_heroes
# We should only include the `initial` key if it's `True` to save bandwidth. # We should only include the `initial` key if it's `True` to save bandwidth.
# The absense of this flag means `False`. # The absense of this flag means `False`.
@ -1006,7 +1019,10 @@ class SlidingSyncRestServlet(RestServlet):
serialized_rooms[room_id]["initial"] = room_result.initial serialized_rooms[room_id]["initial"] = room_result.initial
# This will be omitted for invite/knock rooms with `stripped_state` # This will be omitted for invite/knock rooms with `stripped_state`
if room_result.required_state is not None: if (
room_result.required_state is not None
and len(room_result.required_state) > 0
):
serialized_required_state = ( serialized_required_state = (
await self.event_serializer.serialize_events( await self.event_serializer.serialize_events(
room_result.required_state, room_result.required_state,
@ -1017,7 +1033,10 @@ class SlidingSyncRestServlet(RestServlet):
serialized_rooms[room_id]["required_state"] = serialized_required_state serialized_rooms[room_id]["required_state"] = serialized_required_state
# This will be omitted for invite/knock rooms with `stripped_state` # This will be omitted for invite/knock rooms with `stripped_state`
if room_result.timeline_events is not None: if (
room_result.timeline_events is not None
and len(room_result.timeline_events) > 0
):
serialized_timeline = await self.event_serializer.serialize_events( serialized_timeline = await self.event_serializer.serialize_events(
room_result.timeline_events, room_result.timeline_events,
time_now, time_now,
@ -1045,7 +1064,10 @@ class SlidingSyncRestServlet(RestServlet):
serialized_rooms[room_id]["is_dm"] = room_result.is_dm serialized_rooms[room_id]["is_dm"] = room_result.is_dm
# Stripped state only applies to invite/knock rooms # Stripped state only applies to invite/knock rooms
if room_result.stripped_state is not None: if (
room_result.stripped_state is not None
and len(room_result.stripped_state) > 0
):
# TODO: `knocked_state` but that isn't specced yet. # TODO: `knocked_state` but that isn't specced yet.
# #
# TODO: Instead of adding `knocked_state`, it would be good to rename # TODO: Instead of adding `knocked_state`, it would be good to rename

View File

@ -200,18 +200,24 @@ class SlidingSyncResult:
flag set. (same as sync v2) flag set. (same as sync v2)
""" """
@attr.s(slots=True, frozen=True, auto_attribs=True)
class StrippedHero:
user_id: str
display_name: Optional[str]
avatar_url: Optional[str]
name: Optional[str] name: Optional[str]
avatar: Optional[str] avatar: Optional[str]
heroes: Optional[List[EventBase]] heroes: Optional[List[StrippedHero]]
is_dm: bool is_dm: bool
initial: bool initial: bool
# Only optional because it won't be included for invite/knock rooms with `stripped_state` # Should be empty for invite/knock rooms with `stripped_state`
required_state: Optional[List[EventBase]] required_state: List[EventBase]
# Only optional because it won't be included for invite/knock rooms with `stripped_state` # Should be empty for invite/knock rooms with `stripped_state`
timeline_events: Optional[List[EventBase]] timeline_events: List[EventBase]
bundled_aggregations: Optional[Dict[str, "BundledAggregations"]] bundled_aggregations: Optional[Dict[str, "BundledAggregations"]]
# Optional because it's only relevant to invite/knock rooms # Optional because it's only relevant to invite/knock rooms
stripped_state: Optional[List[JsonDict]] stripped_state: List[JsonDict]
# Only optional because it won't be included for invite/knock rooms with `stripped_state` # Only optional because it won't be included for invite/knock rooms with `stripped_state`
prev_batch: Optional[StreamToken] prev_batch: Optional[StreamToken]
# Only optional because it won't be included for invite/knock rooms with `stripped_state` # Only optional because it won't be included for invite/knock rooms with `stripped_state`

View File

@ -200,9 +200,6 @@ class SlidingSyncBody(RequestBodyModel):
} }
timeline_limit: The maximum number of timeline events to return per response. timeline_limit: The maximum number of timeline events to return per response.
include_heroes: Return a stripped variant of membership events (containing
`user_id` and optionally `avatar_url` and `displayname`) for the users used
to calculate the room name.
filters: Filters to apply to the list before sorting. filters: Filters to apply to the list before sorting.
""" """
@ -270,7 +267,6 @@ class SlidingSyncBody(RequestBodyModel):
else: else:
ranges: Optional[List[Tuple[conint(ge=0, strict=True), conint(ge=0, strict=True)]]] = None # type: ignore[valid-type] ranges: Optional[List[Tuple[conint(ge=0, strict=True), conint(ge=0, strict=True)]]] = None # type: ignore[valid-type]
slow_get_all_rooms: Optional[StrictBool] = False slow_get_all_rooms: Optional[StrictBool] = False
include_heroes: Optional[StrictBool] = False
filters: Optional[Filters] = None filters: Optional[Filters] = None
class RoomSubscription(CommonRoomParameters): class RoomSubscription(CommonRoomParameters):

View File

@ -1813,8 +1813,8 @@ class SlidingSyncTestCase(unittest.HomeserverTestCase):
def test_rooms_meta_when_joined(self) -> None: def test_rooms_meta_when_joined(self) -> None:
""" """
Test that the `rooms` `name` and `avatar` (soon to test `heroes`) are included Test that the `rooms` `name` and `avatar` are included in the response and
in the response when the user is joined to the room. reflect the current state of the room when the user is joined to the room.
""" """
user1_id = self.register_user("user1", "pass") user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass") user1_tok = self.login(user1_id, "pass")
@ -1866,11 +1866,19 @@ class SlidingSyncTestCase(unittest.HomeserverTestCase):
"mxc://DUMMY_MEDIA_ID", "mxc://DUMMY_MEDIA_ID",
channel.json_body["rooms"][room_id1], channel.json_body["rooms"][room_id1],
) )
self.assertEqual(
channel.json_body["rooms"][room_id1]["joined_count"],
2,
)
self.assertEqual(
channel.json_body["rooms"][room_id1]["invited_count"],
0,
)
def test_rooms_meta_when_invited(self) -> None: def test_rooms_meta_when_invited(self) -> None:
""" """
Test that the `rooms` `name` and `avatar` (soon to test `heroes`) are included Test that the `rooms` `name` and `avatar` are included in the response and
in the response when the user is invited to the room. reflect the current state of the room when the user is invited to the room.
""" """
user1_id = self.register_user("user1", "pass") user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass") user1_tok = self.login(user1_id, "pass")
@ -1892,7 +1900,8 @@ class SlidingSyncTestCase(unittest.HomeserverTestCase):
tok=user2_tok, tok=user2_tok,
) )
self.helper.join(room_id1, user1_id, tok=user1_tok) # User1 is invited to the room
self.helper.invite(room_id1, src=user2_id, targ=user1_id, tok=user2_tok)
# Update the room name after user1 has left # Update the room name after user1 has left
self.helper.send_state( self.helper.send_state(
@ -1938,11 +1947,19 @@ class SlidingSyncTestCase(unittest.HomeserverTestCase):
"mxc://UPDATED_DUMMY_MEDIA_ID", "mxc://UPDATED_DUMMY_MEDIA_ID",
channel.json_body["rooms"][room_id1], channel.json_body["rooms"][room_id1],
) )
self.assertEqual(
channel.json_body["rooms"][room_id1]["joined_count"],
1,
)
self.assertEqual(
channel.json_body["rooms"][room_id1]["invited_count"],
1,
)
def test_rooms_meta_when_banned(self) -> None: def test_rooms_meta_when_banned(self) -> None:
""" """
Test that the `rooms` `name` and `avatar` (soon to test `heroes`) reflect the Test that the `rooms` `name` and `avatar` reflect the state of the room when the
state of the room when the user was banned (do not leak current state). user was banned (do not leak current state).
""" """
user1_id = self.register_user("user1", "pass") user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass") user1_tok = self.login(user1_id, "pass")
@ -2010,6 +2027,273 @@ class SlidingSyncTestCase(unittest.HomeserverTestCase):
"mxc://DUMMY_MEDIA_ID", "mxc://DUMMY_MEDIA_ID",
channel.json_body["rooms"][room_id1], channel.json_body["rooms"][room_id1],
) )
self.assertEqual(
channel.json_body["rooms"][room_id1]["joined_count"],
# FIXME: The actual number should be "1" (user2) but we currently don't
# support this for rooms where the user has left/been banned.
0,
)
self.assertEqual(
channel.json_body["rooms"][room_id1]["invited_count"],
0,
)
def test_rooms_meta_heroes(self) -> None:
"""
Test that the `rooms` `heroes` are included in the response when the room
doesn't have a room name set.
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
user2_id = self.register_user("user2", "pass")
user2_tok = self.login(user2_id, "pass")
user3_id = self.register_user("user3", "pass")
_user3_tok = self.login(user3_id, "pass")
room_id1 = self.helper.create_room_as(
user2_id,
tok=user2_tok,
extra_content={
"name": "my super room",
},
)
self.helper.join(room_id1, user1_id, tok=user1_tok)
# User3 is invited
self.helper.invite(room_id1, src=user2_id, targ=user3_id, tok=user2_tok)
room_id2 = self.helper.create_room_as(
user2_id,
tok=user2_tok,
extra_content={
# No room name set so that `heroes` is populated
#
# "name": "my super room2",
},
)
self.helper.join(room_id2, user1_id, tok=user1_tok)
# User3 is invited
self.helper.invite(room_id2, src=user2_id, targ=user3_id, tok=user2_tok)
# Make the Sliding Sync request
channel = self.make_request(
"POST",
self.sync_endpoint,
{
"lists": {
"foo-list": {
"ranges": [[0, 1]],
"required_state": [],
"timeline_limit": 0,
}
}
},
access_token=user1_tok,
)
self.assertEqual(channel.code, 200, channel.json_body)
# Room1 has a name so we shouldn't see any `heroes` which the client would use
# the calculate the room name themselves.
self.assertEqual(
channel.json_body["rooms"][room_id1]["name"],
"my super room",
channel.json_body["rooms"][room_id1],
)
self.assertIsNone(channel.json_body["rooms"][room_id1].get("heroes"))
self.assertEqual(
channel.json_body["rooms"][room_id1]["joined_count"],
2,
)
self.assertEqual(
channel.json_body["rooms"][room_id1]["invited_count"],
1,
)
# Room2 doesn't have a name so we should see `heroes` populated
self.assertIsNone(channel.json_body["rooms"][room_id2].get("name"))
self.assertCountEqual(
[
hero["user_id"]
for hero in channel.json_body["rooms"][room_id2].get("heroes", [])
],
# Heroes shouldn't include the user themselves (we shouldn't see user1)
[user2_id, user3_id],
)
self.assertEqual(
channel.json_body["rooms"][room_id2]["joined_count"],
2,
)
self.assertEqual(
channel.json_body["rooms"][room_id2]["invited_count"],
1,
)
# We didn't request any state so we shouldn't see any `required_state`
self.assertIsNone(channel.json_body["rooms"][room_id1].get("required_state"))
self.assertIsNone(channel.json_body["rooms"][room_id2].get("required_state"))
def test_rooms_meta_heroes_max(self) -> None:
"""
Test that the `rooms` `heroes` only includes the first 5 users (not including
yourself).
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
user2_id = self.register_user("user2", "pass")
user2_tok = self.login(user2_id, "pass")
user3_id = self.register_user("user3", "pass")
user3_tok = self.login(user3_id, "pass")
user4_id = self.register_user("user4", "pass")
user4_tok = self.login(user4_id, "pass")
user5_id = self.register_user("user5", "pass")
user5_tok = self.login(user5_id, "pass")
user6_id = self.register_user("user6", "pass")
user6_tok = self.login(user6_id, "pass")
user7_id = self.register_user("user7", "pass")
user7_tok = self.login(user7_id, "pass")
room_id1 = self.helper.create_room_as(
user2_id,
tok=user2_tok,
extra_content={
# No room name set so that `heroes` is populated
#
# "name": "my super room",
},
)
self.helper.join(room_id1, user1_id, tok=user1_tok)
self.helper.join(room_id1, user3_id, tok=user3_tok)
self.helper.join(room_id1, user4_id, tok=user4_tok)
self.helper.join(room_id1, user5_id, tok=user5_tok)
self.helper.join(room_id1, user6_id, tok=user6_tok)
self.helper.join(room_id1, user7_id, tok=user7_tok)
# Make the Sliding Sync request
channel = self.make_request(
"POST",
self.sync_endpoint,
{
"lists": {
"foo-list": {
"ranges": [[0, 1]],
"required_state": [],
"timeline_limit": 0,
}
}
},
access_token=user1_tok,
)
self.assertEqual(channel.code, 200, channel.json_body)
# Room2 doesn't have a name so we should see `heroes` populated
self.assertIsNone(channel.json_body["rooms"][room_id1].get("name"))
# FIXME: Remove this basic assertion and uncomment the better assertion below
# after https://github.com/element-hq/synapse/pull/17435 merges
self.assertEqual(len(channel.json_body["rooms"][room_id1].get("heroes", [])), 5)
# self.assertCountEqual(
# [
# hero["user_id"]
# for hero in channel.json_body["rooms"][room_id1].get("heroes", [])
# ],
# # Heroes should be the first 5 users in the room (excluding the user
# # themselves, we shouldn't see `user1`)
# [user2_id, user3_id, user4_id, user5_id, user6_id],
# )
self.assertEqual(
channel.json_body["rooms"][room_id1]["joined_count"],
7,
)
self.assertEqual(
channel.json_body["rooms"][room_id1]["invited_count"],
0,
)
# We didn't request any state so we shouldn't see any `required_state`
self.assertIsNone(channel.json_body["rooms"][room_id1].get("required_state"))
def test_rooms_meta_heroes_when_banned(self) -> None:
"""
Test that the `rooms` `heroes` are included in the response when the room
doesn't have a room name set but doesn't leak information past their ban.
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
user2_id = self.register_user("user2", "pass")
user2_tok = self.login(user2_id, "pass")
user3_id = self.register_user("user3", "pass")
_user3_tok = self.login(user3_id, "pass")
user4_id = self.register_user("user4", "pass")
user4_tok = self.login(user4_id, "pass")
user5_id = self.register_user("user5", "pass")
_user5_tok = self.login(user5_id, "pass")
room_id1 = self.helper.create_room_as(
user2_id,
tok=user2_tok,
extra_content={
# No room name set so that `heroes` is populated
#
# "name": "my super room",
},
)
# User1 joins the room
self.helper.join(room_id1, user1_id, tok=user1_tok)
# User3 is invited
self.helper.invite(room_id1, src=user2_id, targ=user3_id, tok=user2_tok)
# User1 is banned from the room
self.helper.ban(room_id1, src=user2_id, targ=user1_id, tok=user2_tok)
# User4 joins the room after user1 is banned
self.helper.join(room_id1, user4_id, tok=user4_tok)
# User5 is invited after user1 is banned
self.helper.invite(room_id1, src=user2_id, targ=user5_id, tok=user2_tok)
# Make the Sliding Sync request
channel = self.make_request(
"POST",
self.sync_endpoint,
{
"lists": {
"foo-list": {
"ranges": [[0, 1]],
"required_state": [],
"timeline_limit": 0,
}
}
},
access_token=user1_tok,
)
self.assertEqual(channel.code, 200, channel.json_body)
# Room2 doesn't have a name so we should see `heroes` populated
self.assertIsNone(channel.json_body["rooms"][room_id1].get("name"))
self.assertCountEqual(
[
hero["user_id"]
for hero in channel.json_body["rooms"][room_id1].get("heroes", [])
],
# Heroes shouldn't include the user themselves (we shouldn't see user1). We
# also shouldn't see user4 since they joined after user1 was banned.
#
# FIXME: The actual result should be `[user2_id, user3_id]` but we currently
# don't support this for rooms where the user has left/been banned.
[],
)
self.assertEqual(
channel.json_body["rooms"][room_id1]["joined_count"],
# FIXME: The actual number should be "1" (user2) but we currently don't
# support this for rooms where the user has left/been banned.
0,
)
self.assertEqual(
channel.json_body["rooms"][room_id1]["invited_count"],
# We shouldn't see user5 since they were invited after user1 was banned.
#
# FIXME: The actual number should be "1" (user3) but we currently don't
# support this for rooms where the user has left/been banned.
0,
)
def test_rooms_limited_initial_sync(self) -> None: def test_rooms_limited_initial_sync(self) -> None:
""" """
@ -3081,11 +3365,7 @@ class SlidingSyncTestCase(unittest.HomeserverTestCase):
self.assertEqual(channel.code, 200, channel.json_body) self.assertEqual(channel.code, 200, channel.json_body)
# Nothing to see for this banned user in the room in the token range # Nothing to see for this banned user in the room in the token range
self.assertEqual( self.assertIsNone(channel.json_body["rooms"][room_id1].get("timeline"))
channel.json_body["rooms"][room_id1]["timeline"],
[],
channel.json_body["rooms"][room_id1]["timeline"],
)
# No events returned in the timeline so nothing is "live" # No events returned in the timeline so nothing is "live"
self.assertEqual( self.assertEqual(
channel.json_body["rooms"][room_id1]["num_live"], channel.json_body["rooms"][room_id1]["num_live"],