Repository: matrix-org/synapse

Issue:
Include bundled events when requesting relations
In order for [MSC3440](https://github.com/matrix-org/matrix-doc/pull/3440) to be successful it proposes being explicit about the behavior in [MSC2675](https://github.com/matrix-org/matrix-doc/pull/2675) that the `/relations` API should include the bundled relations of each returned event.

The behavior here was changed a few times:

* #5595 asked to include the original event (in the `original_event` field), which was done in #5626. Both of these fields included bundle aggregations (which is a bit odd since you're requesting the relations).
* #5654 stopped bundling aggregations in both the original event *and* the relations events.

The proposal here is to include them on the relations event. Note that #5654 includes the following:

> We also do this when pulling the relation events as well, because edits of edits are not something we'd like to support here.

The code in question lies at:

https://github.com/matrix-org/synapse/blob/1f9d0b8a7a6a777d59fe3217724f3e2ddb94a9b2/synapse/rest/client/relations.py#L232-L237

Which asserts that we don't allow relations to relations, but I don't think this is accurate. If this is a problem for edits we might need to solve this a different way.


Discussion:

Resolving patch:
diff --git a/synapse/events/utils.py b/synapse/events/utils.py
--- a/synapse/events/utils.py
+++ b/synapse/events/utils.py
@@ -435,6 +435,14 @@ async def _injected_bundled_relations(
             serialized_event: The serialized event which may be modified.
 
         """
+        # Do not bundle relations for an event which represents an edit or an
+        # annotation. It does not make sense for them to have related events.
+        relates_to = event.content.get("m.relates_to")
+        if isinstance(relates_to, (dict, frozendict)):
+            relation_type = relates_to.get("rel_type")
+            if relation_type in (RelationTypes.ANNOTATION, RelationTypes.REPLACE):
+                return
+
         event_id = event.event_id
 
         # The bundled relations to include.
diff --git a/synapse/rest/client/relations.py b/synapse/rest/client/relations.py
--- a/synapse/rest/client/relations.py
+++ b/synapse/rest/client/relations.py
@@ -230,12 +230,9 @@ async def on_GET(
         original_event = await self._event_serializer.serialize_event(
             event, now, bundle_relations=False
         )
-        # Similarly, we don't allow relations to be applied to relations, so we
-        # return the original relations without any aggregations on top of them
-        # here.
-        serialized_events = await self._event_serializer.serialize_events(
-            events, now, bundle_relations=False
-        )
+        # The relations returned for the requested event do include their
+        # bundled relations.
+        serialized_events = await self._event_serializer.serialize_events(events, now)
 
         return_value = pagination_chunk.to_dict()
         return_value["chunk"] = serialized_events


Test patch:
diff --git a/tests/rest/client/test_relations.py b/tests/rest/client/test_relations.py
--- a/tests/rest/client/test_relations.py
+++ b/tests/rest/client/test_relations.py
@@ -526,6 +526,74 @@ def test_aggregation_get_event(self):
             },
         )
 
+    def test_aggregation_get_event_for_annotation(self):
+        """Test that annotations do not get bundled relations included
+        when directly requested.
+        """
+        channel = self._send_relation(RelationTypes.ANNOTATION, "m.reaction", "a")
+        self.assertEquals(200, channel.code, channel.json_body)
+        annotation_id = channel.json_body["event_id"]
+
+        # Annotate the annotation.
+        channel = self._send_relation(
+            RelationTypes.ANNOTATION, "m.reaction", "a", parent_id=annotation_id
+        )
+        self.assertEquals(200, channel.code, channel.json_body)
+
+        channel = self.make_request(
+            "GET",
+            f"/rooms/{self.room}/event/{annotation_id}",
+            access_token=self.user_token,
+        )
+        self.assertEquals(200, channel.code, channel.json_body)
+        self.assertIsNone(channel.json_body["unsigned"].get("m.relations"))
+
+    def test_aggregation_get_event_for_thread(self):
+        """Test that threads get bundled relations included when directly requested."""
+        channel = self._send_relation(RelationTypes.THREAD, "m.room.test")
+        self.assertEquals(200, channel.code, channel.json_body)
+        thread_id = channel.json_body["event_id"]
+
+        # Annotate the annotation.
+        channel = self._send_relation(
+            RelationTypes.ANNOTATION, "m.reaction", "a", parent_id=thread_id
+        )
+        self.assertEquals(200, channel.code, channel.json_body)
+
+        channel = self.make_request(
+            "GET",
+            f"/rooms/{self.room}/event/{thread_id}",
+            access_token=self.user_token,
+        )
+        self.assertEquals(200, channel.code, channel.json_body)
+        self.assertEquals(
+            channel.json_body["unsigned"].get("m.relations"),
+            {
+                RelationTypes.ANNOTATION: {
+                    "chunk": [{"count": 1, "key": "a", "type": "m.reaction"}]
+                },
+            },
+        )
+
+        # It should also be included when the entire thread is requested.
+        channel = self.make_request(
+            "GET",
+            f"/_matrix/client/unstable/rooms/{self.room}/relations/{self.parent_id}?limit=1",
+            access_token=self.user_token,
+        )
+        self.assertEquals(200, channel.code, channel.json_body)
+        self.assertEqual(len(channel.json_body["chunk"]), 1)
+
+        thread_message = channel.json_body["chunk"][0]
+        self.assertEquals(
+            thread_message["unsigned"].get("m.relations"),
+            {
+                RelationTypes.ANNOTATION: {
+                    "chunk": [{"count": 1, "key": "a", "type": "m.reaction"}]
+                },
+            },
+        )
+
     def test_edit(self):
         """Test that a simple edit works."""
 
@@ -672,6 +740,56 @@ def test_edit_reply(self):
             {"event_id": edit_event_id, "sender": self.user_id}, m_replace_dict
         )
 
+    def test_edit_edit(self):
+        """Test that an edit cannot be edited."""
+        new_body = {"msgtype": "m.text", "body": "Initial edit"}
+        channel = self._send_relation(
+            RelationTypes.REPLACE,
+            "m.room.message",
+            content={
+                "msgtype": "m.text",
+                "body": "Wibble",
+                "m.new_content": new_body,
+            },
+        )
+        self.assertEquals(200, channel.code, channel.json_body)
+        edit_event_id = channel.json_body["event_id"]
+
+        # Edit the edit event.
+        channel = self._send_relation(
+            RelationTypes.REPLACE,
+            "m.room.message",
+            content={
+                "msgtype": "m.text",
+                "body": "foo",
+                "m.new_content": {"msgtype": "m.text", "body": "Ignored edit"},
+            },
+            parent_id=edit_event_id,
+        )
+        self.assertEquals(200, channel.code, channel.json_body)
+
+        # Request the original event.
+        channel = self.make_request(
+            "GET",
+            "/rooms/%s/event/%s" % (self.room, self.parent_id),
+            access_token=self.user_token,
+        )
+        self.assertEquals(200, channel.code, channel.json_body)
+        # The edit to the edit should be ignored.
+        self.assertEquals(channel.json_body["content"], new_body)
+
+        # The relations information should not include the edit to the edit.
+        relations_dict = channel.json_body["unsigned"].get("m.relations")
+        self.assertIn(RelationTypes.REPLACE, relations_dict)
+
+        m_replace_dict = relations_dict[RelationTypes.REPLACE]
+        for key in ["event_id", "sender", "origin_server_ts"]:
+            self.assertIn(key, m_replace_dict)
+
+        self.assert_dict(
+            {"event_id": edit_event_id, "sender": self.user_id}, m_replace_dict
+        )
+
     def test_relations_redaction_redacts_edits(self):
         """Test that edits of an event are redacted when the original event
         is redacted.