From 9a26683b323ef04087ba3fc4ed5fb3648fd2931e Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:56:45 +0200 Subject: [PATCH] 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 --- synapse/api/constants.py | 10 + synapse/handlers/member_info.py | 98 ++++++++ synapse/handlers/message.py | 22 +- synapse/handlers/sliding_sync/__init__.py | 10 + synapse/handlers/sync.py | 29 ++- synapse/rest/client/room.py | 15 +- synapse/rest/client/sync.py | 16 +- synapse/server.py | 5 + synapse/types/handlers/sliding_sync.py | 5 + tests/rest/client/test_msc4428.py | 284 +++++++++++++++++++++- 10 files changed, 482 insertions(+), 12 deletions(-) create mode 100644 synapse/handlers/member_info.py diff --git a/synapse/api/constants.py b/synapse/api/constants.py index c50120831d..b344bcc89a 100644 --- a/synapse/api/constants.py +++ b/synapse/api/constants.py @@ -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.""" diff --git a/synapse/handlers/member_info.py b/synapse/handlers/member_info.py new file mode 100644 index 0000000000..91443cecd8 --- /dev/null +++ b/synapse/handlers/member_info.py @@ -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: +# . +# +"""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 diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py index 7a8866c8ed..d3fc4e8fb3 100644 --- a/synapse/handlers/message.py +++ b/synapse/handlers/message.py @@ -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 diff --git a/synapse/handlers/sliding_sync/__init__.py b/synapse/handlers/sliding_sync/__init__.py index afb93e42b4..99c326e062 100644 --- a/synapse/handlers/sliding_sync/__init__.py +++ b/synapse/handlers/sliding_sync/__init__.py @@ -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 diff --git a/synapse/handlers/sync.py b/synapse/handlers/sync.py index 2eb5eb79c6..5be0f39f93 100644 --- a/synapse/handlers/sync.py +++ b/synapse/handlers/sync.py @@ -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) diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index 4ffb6a89b3..6da1746d1d 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -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? diff --git a/synapse/rest/client/sync.py b/synapse/rest/client/sync.py index 83aa00d0b5..c45904f606 100644 --- a/synapse/rest/client/sync.py +++ b/synapse/rest/client/sync.py @@ -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 diff --git a/synapse/server.py b/synapse/server.py index 266440534c..a5e11dff3b 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -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) diff --git a/synapse/types/handlers/sliding_sync.py b/synapse/types/handlers/sliding_sync.py index f6d30460b5..0033a5c2a1 100644 --- a/synapse/types/handlers/sliding_sync.py +++ b/synapse/types/handlers/sliding_sync.py @@ -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) diff --git a/tests/rest/client/test_msc4428.py b/tests/rest/client/test_msc4428.py index 79deb8aa9b..28a03eb494 100644 --- a/tests/rest/client/test_msc4428.py +++ b/tests/rest/client/test_msc4428.py @@ -12,12 +12,11 @@ # . # """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..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"}, + )