mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-04 13:28:04 +00:00
A2: Add the MSC4428 member_info map, anchored on events
Add `org.matrix.msc4428.member_info` to `/sync` (joined and left rooms), sliding sync, `/members`, and `user_id` to `/joined_members` entries. A new `MemberInfoHandler` computes the map; entries are only produced where the mapping differs from the stable ID itself, so it only names erased users (`user_id: null`, via `are_users_erased`). A room's map covers the stable IDs the response mentions: senders of its timeline, state and sticky events, targets of its membership events, and the room's heroes. On an initial sync without lazy-loaded members that is every member; a lazy-loading client gets the entries for members it loads later from `/members`, which always carries the map. On sync the map is omitted when empty, and `member_info` counts towards whether a room is included in the response. Changes with no event anchor (the erasure itself) are delivered by the next event from or about that member, or on the next full sync; a stream for them is A3. With `msc4428_enabled` off the responses are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
45cfb3bc93
commit
9a26683b32
10 files changed
+482
-12
No files matched your search
@@ -332,6 +332,16 @@ class EventUnsignedContentFields:
|
||||
MSC4428_STABLE_ID: Final = "org.matrix.msc4428.stable_id"
|
||||
|
||||
|
||||
class MemberInfo:
|
||||
"""MSC4428: the per-room `member_info` map of stable ID -> user ID."""
|
||||
|
||||
FIELD: Final = "org.matrix.msc4428.member_info"
|
||||
"""The map's field name on `/sync`, sliding sync and `/members` responses."""
|
||||
|
||||
USER_ID: Final = "user_id"
|
||||
"""The user ID an entry maps to; `null` once the user is erased."""
|
||||
|
||||
|
||||
class MTextFields:
|
||||
"""Fields found inside m.text content blocks."""
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#
|
||||
# This file is licensed under the Affero General Public License (AGPL) version 3.
|
||||
#
|
||||
# Copyright (C) 2026 Element Creations Ltd
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# See the GNU Affero General Public License for more details:
|
||||
# <https://www.gnu.org/licenses/agpl-3.0.html>.
|
||||
#
|
||||
"""MSC4428: the per-room `member_info` map.
|
||||
|
||||
`member_info` maps a room member's stable ID (today: their user ID, carried on
|
||||
events as `unsigned.org.matrix.msc4428.stable_id`) to the user ID it currently
|
||||
stands for, which may be `None` once the user is erased. Entries whose user ID
|
||||
equals the stable ID are omitted, as the MSC allows, so in today's rooms the map
|
||||
only ever names erased users.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Collection, Iterable
|
||||
|
||||
from synapse.api.constants import EventTypes, MemberInfo
|
||||
from synapse.events import EventBase
|
||||
from synapse.types import JsonDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from synapse.server import HomeServer
|
||||
|
||||
|
||||
class MemberInfoHandler:
|
||||
def __init__(self, hs: "HomeServer"):
|
||||
self._store = hs.get_datastores().main
|
||||
self.enabled = hs.config.experimental.msc4428_enabled
|
||||
|
||||
@staticmethod
|
||||
def stable_ids_from_events(events: Iterable[EventBase]) -> set[str]:
|
||||
"""The stable IDs a client learns about from a set of events: the
|
||||
senders, and the targets of membership events."""
|
||||
stable_ids: set[str] = set()
|
||||
for event in events:
|
||||
stable_ids.add(event.sender)
|
||||
if event.type == EventTypes.Member and event.is_state():
|
||||
stable_ids.add(event.state_key)
|
||||
return stable_ids
|
||||
|
||||
async def get_member_info(
|
||||
self, room_id: str, stable_ids: Collection[str]
|
||||
) -> JsonDict:
|
||||
"""The `member_info` entries for the given stable IDs.
|
||||
|
||||
Only stable IDs whose mapping differs from the stable ID itself get an
|
||||
entry; today that is erased users, whose user ID is `None`.
|
||||
|
||||
Args:
|
||||
room_id: The room the stable IDs belong to.
|
||||
stable_ids: The stable IDs to look up.
|
||||
|
||||
Returns:
|
||||
The map, possibly empty.
|
||||
"""
|
||||
if not stable_ids:
|
||||
return {}
|
||||
|
||||
erased = await self._store.are_users_erased(stable_ids)
|
||||
return {
|
||||
stable_id: {MemberInfo.USER_ID: None}
|
||||
for stable_id, is_erased in erased.items()
|
||||
if is_erased
|
||||
}
|
||||
|
||||
async def member_info_for_response(
|
||||
self,
|
||||
room_id: str,
|
||||
events: Iterable[EventBase],
|
||||
heroes: Iterable[str] = (),
|
||||
) -> JsonDict | None:
|
||||
"""The `member_info` map for one room of a sync response.
|
||||
|
||||
The map covers the stable IDs the response mentions: the senders of the
|
||||
events in it, the targets of its membership events, and the room's
|
||||
heroes. On an initial sync without lazy-loaded members that is every
|
||||
member of the room; a lazy-loading client gets the entries for the
|
||||
members it loads later from `/members`.
|
||||
|
||||
Returns:
|
||||
The map, or `None` if MSC4428 is disabled or there is nothing to say
|
||||
(so the field can be omitted from the response).
|
||||
"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
stable_ids = self.stable_ids_from_events(events)
|
||||
stable_ids.update(heroes)
|
||||
|
||||
return await self.get_member_info(room_id, stable_ids) or None
|
||||
@@ -35,6 +35,7 @@ from synapse.api.constants import (
|
||||
GuestAccess,
|
||||
HistoryVisibility,
|
||||
JoinRules,
|
||||
MemberInfo,
|
||||
Membership,
|
||||
RelationTypes,
|
||||
UserTypes,
|
||||
@@ -112,6 +113,7 @@ class MessageHandler:
|
||||
self._storage_controllers = hs.get_storage_controllers()
|
||||
self._state_storage_controller = self._storage_controllers.state
|
||||
self._event_serializer = hs.get_event_client_serializer()
|
||||
self._member_info_handler = hs.get_member_info_handler()
|
||||
self._ephemeral_events_enabled = hs.config.server.enable_ephemeral_messages
|
||||
|
||||
# The scheduled call to self._expire_event. None if no call is currently
|
||||
@@ -340,7 +342,9 @@ class MessageHandler:
|
||||
requester: The user requesting state events.
|
||||
room_id: The room ID to get all state events from.
|
||||
Returns:
|
||||
A dict of user_id to profile info
|
||||
A dict of stable ID (today: user ID) to profile info. With MSC4428
|
||||
enabled, each entry also carries the `user_id` the stable ID maps
|
||||
to, `None` once that user is erased.
|
||||
"""
|
||||
if not requester.app_service_id:
|
||||
# We check AS auth after fetching the room membership, as it
|
||||
@@ -381,7 +385,7 @@ class MessageHandler:
|
||||
errcode=Codes.NOT_JOINED,
|
||||
)
|
||||
|
||||
return {
|
||||
result = {
|
||||
user_id: {
|
||||
"avatar_url": profile.avatar_url,
|
||||
"display_name": profile.display_name,
|
||||
@@ -389,6 +393,20 @@ class MessageHandler:
|
||||
for user_id, profile in users_with_profile.items()
|
||||
}
|
||||
|
||||
if self._member_info_handler.enabled:
|
||||
# MSC4428: the map is keyed by stable ID; tell the client which user
|
||||
# ID each one maps to (the stable ID itself unless erased).
|
||||
member_info = await self._member_info_handler.get_member_info(
|
||||
room_id, list(result.keys())
|
||||
)
|
||||
for stable_id, entry in result.items():
|
||||
info = member_info.get(stable_id)
|
||||
entry[MemberInfo.USER_ID] = (
|
||||
info[MemberInfo.USER_ID] if info is not None else stable_id
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def maybe_schedule_expiry(self, event: EventBase) -> None:
|
||||
"""Schedule the expiry of an event if there's not already one scheduled,
|
||||
or if the one running is for an event that will expire after the provided
|
||||
|
||||
@@ -106,6 +106,7 @@ class SlidingSyncHandler:
|
||||
self.notifier = hs.get_notifier()
|
||||
self.event_sources = hs.get_event_sources()
|
||||
self.relations_handler = hs.get_relations_handler()
|
||||
self.member_info_handler = hs.get_member_info_handler()
|
||||
self.rooms_to_exclude_globally = hs.config.server.rooms_to_exclude_from_sync
|
||||
self.is_mine_id = hs.is_mine_id
|
||||
|
||||
@@ -1499,6 +1500,14 @@ class SlidingSyncHandler:
|
||||
|
||||
set_tag(SynapseTags.RESULT_PREFIX + "initial", initial)
|
||||
|
||||
member_info = await self.member_info_handler.member_info_for_response(
|
||||
room_id,
|
||||
itertools.chain(
|
||||
(ev.event for ev in timeline_events), required_room_state.values()
|
||||
),
|
||||
heroes=(hero.user_id for hero in heroes),
|
||||
)
|
||||
|
||||
return SlidingSyncResult.RoomResult(
|
||||
name=room_name,
|
||||
avatar=room_avatar,
|
||||
@@ -1521,6 +1530,7 @@ class SlidingSyncHandler:
|
||||
# (encrypted rooms).
|
||||
notification_count=0,
|
||||
highlight_count=0,
|
||||
member_info=member_info,
|
||||
)
|
||||
|
||||
@trace
|
||||
|
||||
@@ -267,6 +267,9 @@ class JoinedSyncResult:
|
||||
unread_thread_notifications: JsonDict
|
||||
summary: JsonDict | None
|
||||
unread_count: int
|
||||
member_info: JsonDict | None
|
||||
"""MSC4428: the stable ID -> user ID entries for this response, or None if
|
||||
there are none (or the feature is disabled)."""
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Make the result appear empty if there are no updates. This is used
|
||||
@@ -278,6 +281,7 @@ class JoinedSyncResult:
|
||||
or self.ephemeral
|
||||
or self.account_data
|
||||
or self.sticky
|
||||
or self.member_info
|
||||
# nb the notification count does not, er, count: if there's nothing
|
||||
# else in the result, we don't need to send it.
|
||||
)
|
||||
@@ -289,12 +293,16 @@ class ArchivedSyncResult:
|
||||
timeline: TimelineBatch
|
||||
state: StateMap[EventBase]
|
||||
account_data: list[JsonDict]
|
||||
member_info: JsonDict | None
|
||||
"""MSC4428: as for `JoinedSyncResult.member_info`."""
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Make the result appear empty if there are no updates. This is used
|
||||
to tell if room needs to be part of the sync result.
|
||||
"""
|
||||
return bool(self.timeline or self.state or self.account_data)
|
||||
return bool(
|
||||
self.timeline or self.state or self.account_data or self.member_info
|
||||
)
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True, auto_attribs=True)
|
||||
@@ -420,6 +428,7 @@ class SyncHandler:
|
||||
self.auth_blocking = hs.get_auth_blocking()
|
||||
self._storage_controllers = hs.get_storage_controllers()
|
||||
self._state_storage_controller = self._storage_controllers.state
|
||||
self._member_info_handler = hs.get_member_info_handler()
|
||||
self._device_handler = hs.get_device_handler()
|
||||
self._task_scheduler = hs.get_task_scheduler()
|
||||
|
||||
@@ -3500,6 +3509,17 @@ class SyncHandler:
|
||||
# > Any joined user is authorised to see sticky events for the duration they remain sticky.
|
||||
always_include_ids=frozenset(sticky_event_ids),
|
||||
)
|
||||
|
||||
member_info = await self._member_info_handler.member_info_for_response(
|
||||
room_id,
|
||||
itertools.chain(
|
||||
(ev.event for ev in batch.events),
|
||||
state.values(),
|
||||
(ev.event for ev in sticky_events),
|
||||
),
|
||||
heroes=summary.get("m.heroes", ()) if summary else (),
|
||||
)
|
||||
|
||||
room_sync = JoinedSyncResult(
|
||||
room_id=room_id,
|
||||
timeline=batch,
|
||||
@@ -3511,6 +3531,7 @@ class SyncHandler:
|
||||
summary=summary,
|
||||
unread_count=0,
|
||||
sticky=sticky_events,
|
||||
member_info=member_info,
|
||||
)
|
||||
|
||||
if room_sync or always_include:
|
||||
@@ -3555,11 +3576,17 @@ class SyncHandler:
|
||||
len(state),
|
||||
)
|
||||
elif room_builder.rtype == "archived":
|
||||
member_info = await self._member_info_handler.member_info_for_response(
|
||||
room_id,
|
||||
itertools.chain((ev.event for ev in batch.events), state.values()),
|
||||
heroes=summary.get("m.heroes", ()) if summary else (),
|
||||
)
|
||||
archived_room_sync = ArchivedSyncResult(
|
||||
room_id=room_id,
|
||||
timeline=batch,
|
||||
state=state,
|
||||
account_data=account_data_events,
|
||||
member_info=member_info,
|
||||
)
|
||||
if archived_room_sync or always_include:
|
||||
sync_result_builder.archived.append(archived_room_sync)
|
||||
|
||||
@@ -37,6 +37,7 @@ from synapse import event_auth
|
||||
from synapse.api.constants import (
|
||||
Direction,
|
||||
EventTypes,
|
||||
MemberInfo,
|
||||
Membership,
|
||||
StickyEvent,
|
||||
StickyEventField,
|
||||
@@ -716,6 +717,7 @@ class RoomMemberListRestServlet(RestServlet):
|
||||
def __init__(self, hs: "HomeServer"):
|
||||
super().__init__()
|
||||
self.message_handler = hs.get_message_handler()
|
||||
self.member_info_handler = hs.get_member_info_handler()
|
||||
self.auth = hs.get_auth()
|
||||
self.store = hs.get_datastores().main
|
||||
|
||||
@@ -760,7 +762,18 @@ class RoomMemberListRestServlet(RestServlet):
|
||||
continue
|
||||
chunk.append(event)
|
||||
|
||||
return 200, {"chunk": chunk}
|
||||
response: JsonDict = {"chunk": chunk}
|
||||
|
||||
if self.member_info_handler.enabled:
|
||||
# MSC4428: the map is always present here, unlike on `/sync`, since
|
||||
# this is the endpoint a lazy-loading client learns members from.
|
||||
stable_ids = {event["state_key"] for event in chunk}
|
||||
stable_ids.update(event["sender"] for event in chunk)
|
||||
response[MemberInfo.FIELD] = await self.member_info_handler.get_member_info(
|
||||
room_id, stable_ids
|
||||
)
|
||||
|
||||
return 200, response
|
||||
|
||||
|
||||
# deprecated in favour of /members?membership=join?
|
||||
|
||||
@@ -24,7 +24,13 @@ from typing import TYPE_CHECKING, Any, Literal, Mapping
|
||||
|
||||
import attr
|
||||
|
||||
from synapse.api.constants import AccountDataTypes, EduTypes, Membership, PresenceState
|
||||
from synapse.api.constants import (
|
||||
AccountDataTypes,
|
||||
EduTypes,
|
||||
MemberInfo,
|
||||
Membership,
|
||||
PresenceState,
|
||||
)
|
||||
from synapse.api.errors import Codes, StoreError, SynapseError
|
||||
from synapse.api.filtering import FilterCollection
|
||||
from synapse.api.presence import UserPresenceState
|
||||
@@ -648,6 +654,10 @@ class SyncRestServlet(RestServlet):
|
||||
"account_data": {"events": account_data},
|
||||
}
|
||||
|
||||
if room.member_info is not None:
|
||||
# MSC4428
|
||||
result[MemberInfo.FIELD] = room.member_info
|
||||
|
||||
if joined:
|
||||
assert isinstance(room, JoinedSyncResult)
|
||||
ephemeral_events = room.ephemeral
|
||||
@@ -1047,6 +1057,10 @@ class SlidingSyncRestServlet(RestServlet):
|
||||
if room_result.is_dm:
|
||||
serialized_rooms[room_id]["is_dm"] = room_result.is_dm
|
||||
|
||||
# MSC4428: omitted when there is nothing to say.
|
||||
if room_result.member_info is not None:
|
||||
serialized_rooms[room_id][MemberInfo.FIELD] = room_result.member_info
|
||||
|
||||
# Stripped state only applies to invite/knock rooms
|
||||
if (
|
||||
room_result.stripped_state is not None
|
||||
|
||||
@@ -97,6 +97,7 @@ from synapse.handlers.federation import FederationHandler
|
||||
from synapse.handlers.federation_event import FederationEventHandler
|
||||
from synapse.handlers.identity import IdentityHandler
|
||||
from synapse.handlers.initial_sync import InitialSyncHandler
|
||||
from synapse.handlers.member_info import MemberInfoHandler
|
||||
from synapse.handlers.message import EventCreationHandler, MessageHandler
|
||||
from synapse.handlers.pagination import PaginationHandler
|
||||
from synapse.handlers.password_policy import PasswordPolicyHandler
|
||||
@@ -868,6 +869,10 @@ class HomeServer(metaclass=abc.ABCMeta):
|
||||
def get_sliding_sync_handler(self) -> SlidingSyncHandler:
|
||||
return SlidingSyncHandler(self)
|
||||
|
||||
@cache_in_self
|
||||
def get_member_info_handler(self) -> MemberInfoHandler:
|
||||
return MemberInfoHandler(self)
|
||||
|
||||
@cache_in_self
|
||||
def get_room_list_handler(self) -> RoomListHandler:
|
||||
return RoomListHandler(self)
|
||||
|
||||
@@ -169,6 +169,9 @@ class SlidingSyncResult:
|
||||
as sync v2)
|
||||
highlight_count: The number of unread notifications for this room with the highlight
|
||||
flag set. (same as sync v2)
|
||||
member_info: MSC4428: the stable ID -> user ID entries for the members
|
||||
this response mentions, or None if there are none (or the feature
|
||||
is disabled).
|
||||
"""
|
||||
|
||||
@attr.s(slots=True, frozen=True, auto_attribs=True)
|
||||
@@ -201,6 +204,7 @@ class SlidingSyncResult:
|
||||
invited_count: int | None
|
||||
notification_count: int
|
||||
highlight_count: int
|
||||
member_info: JsonDict | None
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Are there any updates that should be returned immediately to
|
||||
@@ -220,6 +224,7 @@ class SlidingSyncResult:
|
||||
or bool(self.required_state)
|
||||
or bool(self.timeline_events)
|
||||
or bool(self.stripped_state)
|
||||
or bool(self.member_info)
|
||||
)
|
||||
|
||||
@attr.s(slots=True, frozen=True, auto_attribs=True)
|
||||
|
||||
@@ -12,12 +12,11 @@
|
||||
# <https://www.gnu.org/licenses/agpl-3.0.html>.
|
||||
#
|
||||
"""Tests for MSC4428 (stable identifiers for room members): the
|
||||
`unsigned.org.matrix.msc4428.stable_id` field on events served to clients.
|
||||
`unsigned.org.matrix.msc4428.stable_id` field on events served to clients, and
|
||||
the per-room `member_info` map.
|
||||
|
||||
This stands in for the Complement test "stable_id on every event" (COMPLEMENT.md
|
||||
§2.2) until that suite exists: every event-returning client endpoint, including
|
||||
the events embedded in `unsigned` (`redacted_because`, bundled edits and thread
|
||||
summaries), carries the field.
|
||||
These stand in for the Complement tests of COMPLEMENT.md §2 (C#2, C#3, C#4 and
|
||||
C#14) until that suite exists.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -25,14 +24,20 @@ from typing import Iterable
|
||||
|
||||
from twisted.internet.testing import MemoryReactor
|
||||
|
||||
from synapse.api.constants import EventTypes, EventUnsignedContentFields, RelationTypes
|
||||
from synapse.api.constants import (
|
||||
EventTypes,
|
||||
EventUnsignedContentFields,
|
||||
MemberInfo,
|
||||
RelationTypes,
|
||||
)
|
||||
from synapse.rest import admin
|
||||
from synapse.rest.client import login, relations, room, sync
|
||||
from synapse.server import HomeServer
|
||||
from synapse.types import JsonDict
|
||||
from synapse.types import JsonDict, create_requester
|
||||
from synapse.util.clock import Clock
|
||||
|
||||
from tests import unittest
|
||||
from tests.rest.client.sliding_sync.test_sliding_sync import SlidingSyncBase
|
||||
|
||||
STABLE_ID = EventUnsignedContentFields.MSC4428_STABLE_ID
|
||||
|
||||
@@ -268,3 +273,268 @@ class StableIdDisabledTestCase(unittest.HomeserverTestCase):
|
||||
self.assertEqual(channel.code, 200, channel.json_body)
|
||||
for event in channel.json_body["rooms"]["join"][room_id]["timeline"]["events"]:
|
||||
self.assertNotIn(STABLE_ID, event.get("unsigned", {}))
|
||||
|
||||
|
||||
class MemberInfoTestCase(unittest.HomeserverTestCase):
|
||||
"""The `member_info` map: stands in for Complement tests C#3, C#4 and C#14
|
||||
(COMPLEMENT.md §2) until that suite exists. In today's rooms stable IDs equal
|
||||
user IDs, so the map only ever names erased users."""
|
||||
|
||||
servlets = [
|
||||
admin.register_servlets,
|
||||
login.register_servlets,
|
||||
room.register_servlets,
|
||||
sync.register_servlets,
|
||||
]
|
||||
|
||||
def default_config(self) -> JsonDict:
|
||||
config = super().default_config()
|
||||
config["experimental_features"] = {"msc4428_enabled": True}
|
||||
return config
|
||||
|
||||
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||
self.store = hs.get_datastores().main
|
||||
self.alice = self.register_user("alice", "pass")
|
||||
self.alice_tok = self.login("alice", "pass")
|
||||
self.bob = self.register_user("bob", "pass")
|
||||
self.bob_tok = self.login("bob", "pass")
|
||||
|
||||
self.room_id = self.helper.create_room_as(self.alice, tok=self.alice_tok)
|
||||
self.helper.join(self.room_id, self.bob, tok=self.bob_tok)
|
||||
self.helper.send(self.room_id, body="hi", tok=self.bob_tok)
|
||||
|
||||
def _erase(self, user_id: str) -> None:
|
||||
"""Mark a user erased without deactivating them, so they stay joined."""
|
||||
self.get_success(self.store.mark_user_erased(user_id))
|
||||
|
||||
def _sync(self, since: str | None = None, **params: str) -> JsonDict:
|
||||
query = {**params}
|
||||
if since is not None:
|
||||
query["since"] = since
|
||||
qs = "&".join(f"{k}={v}" for k, v in query.items())
|
||||
channel = self.make_request(
|
||||
"GET", f"/sync?{qs}" if qs else "/sync", access_token=self.alice_tok
|
||||
)
|
||||
self.assertEqual(channel.code, 200, channel.json_body)
|
||||
return channel.json_body
|
||||
|
||||
def test_sync_omits_map_when_nothing_to_say(self) -> None:
|
||||
room = self._sync()["rooms"]["join"][self.room_id]
|
||||
self.assertNotIn(MemberInfo.FIELD, room)
|
||||
|
||||
def test_sync_full_map_names_erased_member(self) -> None:
|
||||
"""C#14: erasure is visible on the next full sync."""
|
||||
self._erase(self.bob)
|
||||
|
||||
room = self._sync()["rooms"]["join"][self.room_id]
|
||||
self.assertEqual(room[MemberInfo.FIELD], {self.bob: {MemberInfo.USER_ID: None}})
|
||||
|
||||
# Likewise on a `full_state` incremental sync.
|
||||
since = self._sync()["next_batch"]
|
||||
room = self._sync(since, full_state="true")["rooms"]["join"][self.room_id]
|
||||
self.assertEqual(room[MemberInfo.FIELD], {self.bob: {MemberInfo.USER_ID: None}})
|
||||
|
||||
def test_sync_incremental_only_for_mentioned_members(self) -> None:
|
||||
"""An incremental sync carries entries only for the stable IDs the
|
||||
response mentions. A change with no event to anchor it (the erasure
|
||||
itself) waits for the next event from, or about, that member."""
|
||||
since = self._sync()["next_batch"]
|
||||
self._erase(self.bob)
|
||||
|
||||
# Alice's own message mentions nobody but Alice.
|
||||
self.helper.send(self.room_id, body="alice", tok=self.alice_tok)
|
||||
body = self._sync(since)
|
||||
room = body["rooms"]["join"][self.room_id]
|
||||
self.assertNotIn(MemberInfo.FIELD, room)
|
||||
since = body["next_batch"]
|
||||
|
||||
# Bob's next message is the anchor.
|
||||
self.helper.send(self.room_id, body="bob", tok=self.bob_tok)
|
||||
room = self._sync(since)["rooms"]["join"][self.room_id]
|
||||
self.assertEqual(room[MemberInfo.FIELD], {self.bob: {MemberInfo.USER_ID: None}})
|
||||
|
||||
def test_sync_deactivation_leave_anchors_erasure(self) -> None:
|
||||
"""Deactivating with `erase: true` parts the user from their rooms;
|
||||
the parting leave anchors the `null` entry for other members."""
|
||||
since = self._sync()["next_batch"]
|
||||
|
||||
self.get_success(
|
||||
self.hs.get_deactivate_account_handler().deactivate_account(
|
||||
self.bob, erase_data=True, requester=create_requester(self.bob)
|
||||
)
|
||||
)
|
||||
# Parting rooms happens in the background.
|
||||
self.pump()
|
||||
self.reactor.advance(1)
|
||||
self.pump()
|
||||
|
||||
room = self._sync(since)["rooms"]["join"][self.room_id]
|
||||
leave = [
|
||||
e
|
||||
for e in room["timeline"]["events"]
|
||||
if e["type"] == EventTypes.Member and e["state_key"] == self.bob
|
||||
]
|
||||
self.assertEqual(len(leave), 1, room["timeline"])
|
||||
self.assertEqual(leave[0]["content"]["membership"], "leave")
|
||||
self.assertEqual(room[MemberInfo.FIELD], {self.bob: {MemberInfo.USER_ID: None}})
|
||||
|
||||
def test_sync_left_room(self) -> None:
|
||||
"""The map is also present on rooms under `rooms.leave`."""
|
||||
since = self._sync()["next_batch"]
|
||||
self._erase(self.bob)
|
||||
self.helper.send(self.room_id, body="bob", tok=self.bob_tok)
|
||||
self.helper.leave(self.room_id, self.alice, tok=self.alice_tok)
|
||||
|
||||
room = self._sync(since)["rooms"]["leave"][self.room_id]
|
||||
self.assertEqual(room[MemberInfo.FIELD], {self.bob: {MemberInfo.USER_ID: None}})
|
||||
|
||||
def test_members(self) -> None:
|
||||
"""C#4: `/members` always carries the map."""
|
||||
channel = self.make_request(
|
||||
"GET", f"/rooms/{self.room_id}/members", access_token=self.alice_tok
|
||||
)
|
||||
self.assertEqual(channel.code, 200, channel.json_body)
|
||||
self.assertEqual(channel.json_body[MemberInfo.FIELD], {})
|
||||
|
||||
self._erase(self.bob)
|
||||
channel = self.make_request(
|
||||
"GET", f"/rooms/{self.room_id}/members", access_token=self.alice_tok
|
||||
)
|
||||
self.assertEqual(channel.code, 200, channel.json_body)
|
||||
self.assertEqual(
|
||||
channel.json_body[MemberInfo.FIELD], {self.bob: {MemberInfo.USER_ID: None}}
|
||||
)
|
||||
|
||||
# The map only covers the members in the (filtered) chunk.
|
||||
channel = self.make_request(
|
||||
"GET",
|
||||
f"/rooms/{self.room_id}/members?not_membership=join",
|
||||
access_token=self.alice_tok,
|
||||
)
|
||||
self.assertEqual(channel.code, 200, channel.json_body)
|
||||
self.assertEqual(channel.json_body["chunk"], [])
|
||||
self.assertEqual(channel.json_body[MemberInfo.FIELD], {})
|
||||
|
||||
def test_joined_members(self) -> None:
|
||||
"""C#4: `/joined_members` is keyed by stable ID and names the user ID."""
|
||||
channel = self.make_request(
|
||||
"GET", f"/rooms/{self.room_id}/joined_members", access_token=self.alice_tok
|
||||
)
|
||||
self.assertEqual(channel.code, 200, channel.json_body)
|
||||
joined = channel.json_body["joined"]
|
||||
self.assertEqual(joined[self.alice][MemberInfo.USER_ID], self.alice)
|
||||
self.assertEqual(joined[self.bob][MemberInfo.USER_ID], self.bob)
|
||||
self.assertEqual(joined[self.bob]["display_name"], "bob")
|
||||
|
||||
self._erase(self.bob)
|
||||
channel = self.make_request(
|
||||
"GET", f"/rooms/{self.room_id}/joined_members", access_token=self.alice_tok
|
||||
)
|
||||
self.assertEqual(channel.code, 200, channel.json_body)
|
||||
joined = channel.json_body["joined"]
|
||||
self.assertEqual(joined[self.alice][MemberInfo.USER_ID], self.alice)
|
||||
self.assertIsNone(joined[self.bob][MemberInfo.USER_ID])
|
||||
|
||||
|
||||
class MemberInfoSlidingSyncTestCase(SlidingSyncBase):
|
||||
"""C#3 for sliding sync: `rooms.<id>.member_info`."""
|
||||
|
||||
servlets = [
|
||||
admin.register_servlets,
|
||||
login.register_servlets,
|
||||
room.register_servlets,
|
||||
sync.register_servlets,
|
||||
]
|
||||
|
||||
def default_config(self) -> JsonDict:
|
||||
config = super().default_config()
|
||||
config["experimental_features"]["msc4428_enabled"] = True
|
||||
return config
|
||||
|
||||
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||
super().prepare(reactor, clock, hs)
|
||||
self.store = hs.get_datastores().main
|
||||
self.alice = self.register_user("alice", "pass")
|
||||
self.alice_tok = self.login("alice", "pass")
|
||||
self.bob = self.register_user("bob", "pass")
|
||||
self.bob_tok = self.login("bob", "pass")
|
||||
|
||||
self.room_id = self.helper.create_room_as(self.alice, tok=self.alice_tok)
|
||||
self.helper.join(self.room_id, self.bob, tok=self.bob_tok)
|
||||
self.helper.send(self.room_id, body="hi", tok=self.bob_tok)
|
||||
|
||||
self.sync_body = {
|
||||
"lists": {
|
||||
"foo-list": {
|
||||
"ranges": [[0, 1]],
|
||||
"required_state": [[EventTypes.Member, "*"]],
|
||||
"timeline_limit": 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def test_member_info(self) -> None:
|
||||
body, pos = self.do_sync(self.sync_body, tok=self.alice_tok)
|
||||
self.assertTrue(body["rooms"][self.room_id]["initial"])
|
||||
self.assertNotIn(MemberInfo.FIELD, body["rooms"][self.room_id])
|
||||
|
||||
self.get_success(self.store.mark_user_erased(self.bob))
|
||||
|
||||
# Incrementally: only once Bob is mentioned again.
|
||||
self.helper.send(self.room_id, body="alice", tok=self.alice_tok)
|
||||
body, pos = self.do_sync(self.sync_body, since=pos, tok=self.alice_tok)
|
||||
self.assertNotIn(MemberInfo.FIELD, body["rooms"][self.room_id])
|
||||
|
||||
self.helper.send(self.room_id, body="bob", tok=self.bob_tok)
|
||||
body, pos = self.do_sync(self.sync_body, since=pos, tok=self.alice_tok)
|
||||
self.assertEqual(
|
||||
body["rooms"][self.room_id][MemberInfo.FIELD],
|
||||
{self.bob: {MemberInfo.USER_ID: None}},
|
||||
)
|
||||
|
||||
# A fresh connection: the initial response mentions every member.
|
||||
body, _ = self.do_sync(self.sync_body, tok=self.alice_tok)
|
||||
self.assertTrue(body["rooms"][self.room_id]["initial"])
|
||||
self.assertEqual(
|
||||
body["rooms"][self.room_id][MemberInfo.FIELD],
|
||||
{self.bob: {MemberInfo.USER_ID: None}},
|
||||
)
|
||||
|
||||
|
||||
class MemberInfoDisabledTestCase(unittest.HomeserverTestCase):
|
||||
"""Without the flag nothing changes on the wire, even with an erased member."""
|
||||
|
||||
servlets = [
|
||||
admin.register_servlets,
|
||||
login.register_servlets,
|
||||
room.register_servlets,
|
||||
sync.register_servlets,
|
||||
]
|
||||
|
||||
def test_no_member_info(self) -> None:
|
||||
alice = self.register_user("alice", "pass")
|
||||
alice_tok = self.login("alice", "pass")
|
||||
bob = self.register_user("bob", "pass")
|
||||
bob_tok = self.login("bob", "pass")
|
||||
room_id = self.helper.create_room_as(alice, tok=alice_tok)
|
||||
self.helper.join(room_id, bob, tok=bob_tok)
|
||||
self.get_success(self.hs.get_datastores().main.mark_user_erased(bob))
|
||||
|
||||
channel = self.make_request("GET", "/sync", access_token=alice_tok)
|
||||
self.assertEqual(channel.code, 200, channel.json_body)
|
||||
self.assertNotIn(MemberInfo.FIELD, channel.json_body["rooms"]["join"][room_id])
|
||||
|
||||
channel = self.make_request(
|
||||
"GET", f"/rooms/{room_id}/members", access_token=alice_tok
|
||||
)
|
||||
self.assertEqual(channel.code, 200, channel.json_body)
|
||||
self.assertEqual(set(channel.json_body.keys()), {"chunk"})
|
||||
|
||||
channel = self.make_request(
|
||||
"GET", f"/rooms/{room_id}/joined_members", access_token=alice_tok
|
||||
)
|
||||
self.assertEqual(channel.code, 200, channel.json_body)
|
||||
self.assertEqual(
|
||||
set(channel.json_body["joined"][bob].keys()),
|
||||
{"avatar_url", "display_name"},
|
||||
)
|
||||
Reference in new issue
Block a user