From 450f4fb35f9d5a60e9476d12066172c8121663d9 Mon Sep 17 00:00:00 2001 From: Paul Chobert Date: Thu, 29 Oct 2026 22:34:50 +0100 Subject: [PATCH] Serialize the synthetic join like other events --- rust/src/events/mod.rs | 1 + rust/src/events/serialize.rs | 29 +++++++++++++- synapse/events/utils.py | 17 +++++++++ synapse/handlers/server_announcements.py | 3 +- synapse/rest/client/sync.py | 7 +++- synapse/synapse_rust/events.pyi | 18 +++++++++ tests/rest/client/test_sync.py | 48 ++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 4 deletions(-) diff --git a/rust/src/events/mod.rs b/rust/src/events/mod.rs index ad45e29414..b81f6d3daf 100644 --- a/rust/src/events/mod.rs +++ b/rust/src/events/mod.rs @@ -118,6 +118,7 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> child_module.add_function(wrap_pyfunction!(redact_event_py, m)?)?; child_module.add_function(wrap_pyfunction!(redact_event_dict, m)?)?; child_module.add_function(wrap_pyfunction!(serialize::serialize_events, m)?)?; + child_module.add_function(wrap_pyfunction!(serialize::serialize_synthetic_event, m)?)?; child_module.add_function(wrap_pyfunction!(serialize::format_event_raw, m)?)?; child_module.add_function(wrap_pyfunction!(serialize::format_event_for_client_v1, m)?)?; child_module.add_function(wrap_pyfunction!(serialize::format_event_for_client_v2, m)?)?; diff --git a/rust/src/events/serialize.rs b/rust/src/events/serialize.rs index 77a42ce5d2..d4a59d1abf 100644 --- a/rust/src/events/serialize.rs +++ b/rust/src/events/serialize.rs @@ -36,7 +36,7 @@ use pyo3::{ types::{PyAnyMethods, PyDict, PyDictMethods}, Bound, PyAny, PyResult, Python, }; -use pythonize::pythonize; +use pythonize::{depythonize, pythonize}; use serde_json::{Map, Number, Value}; use crate::{ @@ -578,6 +578,33 @@ fn format_for_client_v2(d: &mut Map) { } } +/// Serialize an event that only exists as JSON, such as a synthetic event that +/// is never persisted. +/// +/// Only the event format and the field allowlist from `config` apply: there is +/// no stored event to take the age, transaction ID, redaction or bundled +/// aggregations from. +#[pyfunction] +pub fn serialize_synthetic_event<'py>( + py: Python<'py>, + event: Bound<'py, PyAny>, + config: &SerializeEventConfig, +) -> PyResult> { + let mut d: Map = depythonize(&event)?; + + if config.as_client_event { + apply_event_format(config.event_format, &mut d); + } + + if let Some(fields) = &config.event_field_allowlist { + if !fields.is_empty() { + d = only_fields(&d, fields)?; + } + } + + Ok(pythonize(py, &Value::Object(d))?) +} + // Standalone versions of the client format transforms, re-exported from // `synapse.events.utils` purely as a backwards compatibility hack: they have // never been part of the module API and modules shouldn't be pulling them in, diff --git a/synapse/events/utils.py b/synapse/events/utils.py index c3bc6fa9da..cba03521a7 100644 --- a/synapse/events/utils.py +++ b/synapse/events/utils.py @@ -50,6 +50,7 @@ from synapse.synapse_rust.events import ( format_event_raw, redact_event, serialize_events, + serialize_synthetic_event, ) from synapse.synapse_rust.types import Requester from synapse.types import JsonDict @@ -410,6 +411,22 @@ class EventClientSerializer: for event in events ] + def serialize_synthetic_event( + self, event: JsonDict, config: SerializeEventConfig + ) -> JsonDict: + """Serializes an event that only exists as JSON, such as a synthetic + event that is never persisted, applying the event format and field + allowlist from `config`. + + Args: + event: The event's JSON, in federation format. + config: Event serialization config + + Returns: + The serialized event + """ + return serialize_synthetic_event(event, config) + def register_add_extra_fields_to_unsigned_client_event_callback( self, callback: ADD_EXTRA_FIELDS_TO_UNSIGNED_CLIENT_EVENT_CALLBACK ) -> None: diff --git a/synapse/handlers/server_announcements.py b/synapse/handlers/server_announcements.py index 6ceb713dd7..6adfe49ee3 100644 --- a/synapse/handlers/server_announcements.py +++ b/synapse/handlers/server_announcements.py @@ -50,9 +50,10 @@ def make_synthetic_join_event( every time, e.g. that of the room's create event. Returns: - The event, serialized for the client. + The event's JSON in federation format. """ return { + "room_id": room_id, "type": EventTypes.Member, "event_id": "-".join( [ diff --git a/synapse/rest/client/sync.py b/synapse/rest/client/sync.py index 0554c44ffb..22ee32c2c3 100644 --- a/synapse/rest/client/sync.py +++ b/synapse/rest/client/sync.py @@ -655,8 +655,11 @@ class SyncRestServlet(RestServlet): if is_announcement: create_event = await self.store.get_create_event_for_room(room.room_id) serialized_state.append( - make_synthetic_join_event( - room.room_id, user_id, create_event.origin_server_ts + self._event_serializer.serialize_synthetic_event( + make_synthetic_join_event( + room.room_id, user_id, create_event.origin_server_ts + ), + serialize_options, ) ) serialized_timeline = await self._event_serializer.serialize_events( diff --git a/synapse/synapse_rust/events.pyi b/synapse/synapse_rust/events.pyi index 4b45bbdf20..5b9b537445 100644 --- a/synapse/synapse_rust/events.pyi +++ b/synapse/synapse_rust/events.pyi @@ -467,6 +467,24 @@ def serialize_events( The serialized events, in the same order as `events`. """ +def serialize_synthetic_event( + event: JsonDict, config: SerializeEventConfig +) -> JsonDict: + """Serialize an event that only exists as JSON, such as a synthetic event + that is never persisted. + + Only the event format and the field allowlist from `config` apply: there is + no stored event to take the age, transaction ID, redaction or bundled + aggregations from. + + Args: + event: The event's JSON, in federation format. + config: The serialization config. + + Returns: + The serialized event. + """ + # The standalone `format_event_*` transforms below are a backwards # compatibility hack: they have never been part of the module API and modules # shouldn't be pulling them in, but some in the wild import them (via diff --git a/tests/rest/client/test_sync.py b/tests/rest/client/test_sync.py index c073b9b865..6f29e5636a 100644 --- a/tests/rest/client/test_sync.py +++ b/tests/rest/client/test_sync.py @@ -2592,3 +2592,51 @@ class ServerAnnouncementsSyncTestCase(unittest.HomeserverTestCase): ), self.bob, ) + + def test_event_fields(self) -> None: + """With an `event_fields` filter, the synthetic join is trimmed to the + requested fields like every other event. + """ + self._announce("hello") + event_fields = ["type", "content", "state_key"] + + response = self._sync(self.bob_tok, filter={"event_fields": event_fields}) + room = response["rooms"]["join"][self.room_id] + + events = room["state"]["events"] + room["timeline"]["events"] + own_memberships = [ + event + for event in events + if event["type"] == EventTypes.Member and event["state_key"] == self.bob + ] + self.assertEqual( + own_memberships, + [ + { + "type": EventTypes.Member, + "content": {"membership": Membership.JOIN}, + "state_key": self.bob, + } + ], + ) + for event in events: + self.assertLessEqual(set(event), set(event_fields), event) + + def test_federation_event_format(self) -> None: + """With the `federation` event format, the synthetic join carries the + room ID like every other event. + """ + self._announce("hello") + + response = self._sync(self.bob_tok, filter={"event_format": "federation"}) + room = response["rooms"]["join"][self.room_id] + + events = room["state"]["events"] + room["timeline"]["events"] + own_memberships = [ + event + for event in events + if event["type"] == EventTypes.Member and event["state_key"] == self.bob + ] + self.assertEqual(len(own_memberships), 1, own_memberships) + for event in events: + self.assertEqual(event.get("room_id"), self.room_id, event)