Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Implement MSC3051 to support multiple relations per event #16111

Draft
wants to merge 2 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/16111.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement MSC3051 - A scalable relation format. Contributed by @chayleaf.
11 changes: 5 additions & 6 deletions synapse/api/filtering.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from synapse.api.constants import EduTypes, EventContentFields
from synapse.api.errors import SynapseError
from synapse.api.presence import UserPresenceState
from synapse.events import EventBase, relation_from_event
from synapse.events import EventBase, relations_from_event
from synapse.types import JsonDict, RoomID, UserID

if TYPE_CHECKING:
Expand Down Expand Up @@ -408,18 +408,17 @@ def _check(self, event: FilterEvent) -> bool:
labels = content.get(EventContentFields.LABELS, [])

# Check if the event has a relation.
rel_type = None
rel_types: List[str] = []
if isinstance(event, EventBase):
relation = relation_from_event(event)
if relation:
rel_type = relation.rel_type
for relation in relations_from_event(event):
rel_types.append(relation.rel_type)

field_matchers = {
"rooms": lambda v: room_id == v,
"senders": lambda v: sender == v,
"types": lambda v: _matches_wildcard(ev_type, v),
"labels": lambda v: v in labels,
"rel_types": lambda v: rel_type == v,
"rel_types": lambda v: v in rel_types,
}

result = self._check_fields(field_matchers)
Expand Down
53 changes: 29 additions & 24 deletions synapse/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,32 +638,37 @@ class _EventRelation:
aggregation_key: Optional[str]


def relation_from_event(event: EventBase) -> Optional[_EventRelation]:
def relations_from_event(event: EventBase) -> List[_EventRelation]:
"""
Attempt to parse relation information an event.

Returns:
The event relation information, if it is valid. None, otherwise.
All valid event relation information.
"""
relation = event.content.get("m.relates_to")
if not relation or not isinstance(relation, collections.abc.Mapping):
# No relation information.
return None

# Relations must have a type and parent event ID.
rel_type = relation.get("rel_type")
if not isinstance(rel_type, str):
return None

parent_id = relation.get("event_id")
if not isinstance(parent_id, str):
return None

# Annotations have a key field.
aggregation_key = None
if rel_type == RelationTypes.ANNOTATION:
aggregation_key = relation.get("key")
if not isinstance(aggregation_key, str):
aggregation_key = None

return _EventRelation(parent_id, rel_type, aggregation_key)

relations = event.content.get("m.relations")
if not relations or not isinstance(relations, list):
relations = [event.content.get("m.relates_to")]

ret: List[_EventRelation] = []
for relation in relations:
if not relation or not isinstance(relation, collections.abc.Mapping):
continue
# Relations must have a type and parent event ID.
rel_type = relation.get("rel_type")
if not isinstance(rel_type, str):
continue

parent_id = relation.get("event_id")
if not isinstance(parent_id, str):
continue

# Annotations have a key field.
aggregation_key = None
if rel_type == RelationTypes.ANNOTATION:
aggregation_key = relation.get("key")
if not isinstance(aggregation_key, str):
aggregation_key = None

ret.append(_EventRelation(parent_id, rel_type, aggregation_key))
return ret
88 changes: 46 additions & 42 deletions synapse/handlers/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
from synapse.api.room_versions import KNOWN_ROOM_VERSIONS
from synapse.api.urls import ConsentURIBuilder
from synapse.event_auth import validate_event_for_room_version
from synapse.events import EventBase, relation_from_event
from synapse.events import EventBase, relations_from_event
from synapse.events.builder import EventBuilder
from synapse.events.snapshot import EventContext, UnpersistedEventContextBase
from synapse.events.utils import SerializeEventConfig, maybe_upsert_event_field
Expand Down Expand Up @@ -1334,51 +1334,55 @@ async def _validate_event_relation(self, event: EventBase) -> None:
SynapseError if the event is invalid.
"""

relation = relation_from_event(event)
if not relation:
return

parent_event = await self.store.get_event(relation.parent_id, allow_none=True)
if parent_event:
# And in the same room.
if parent_event.room_id != event.room_id:
raise SynapseError(400, "Relations must be in the same room")
for relation in relations_from_event(event):
if not relation:
continue

else:
# There must be some reason that the client knows the event exists,
# see if there are existing relations. If so, assume everything is fine.
if not await self.store.event_is_target_of_relation(relation.parent_id):
# Otherwise, the client can't know about the parent event!
raise SynapseError(400, "Can't send relation to unknown event")

# If this event is an annotation then we check that that the sender
# can't annotate the same way twice (e.g. stops users from liking an
# event multiple times).
if relation.rel_type == RelationTypes.ANNOTATION:
aggregation_key = relation.aggregation_key

if aggregation_key is None:
raise SynapseError(400, "Missing aggregation key")

if len(aggregation_key) > 500:
raise SynapseError(400, "Aggregation key is too long")

already_exists = await self.store.has_user_annotated_event(
relation.parent_id, event.type, aggregation_key, event.sender
parent_event = await self.store.get_event(
relation.parent_id, allow_none=True
)
if already_exists:
raise SynapseError(
400,
"Can't send same reaction twice",
errcode=Codes.DUPLICATE_ANNOTATION,
)
if parent_event:
# And in the same room.
if parent_event.room_id != event.room_id:
raise SynapseError(400, "Relations must be in the same room")

# Don't attempt to start a thread if the parent event is a relation.
elif relation.rel_type == RelationTypes.THREAD:
if await self.store.event_includes_relation(relation.parent_id):
raise SynapseError(
400, "Cannot start threads from an event with a relation"
else:
# There must be some reason that the client knows the event exists,
# see if there are existing relations. If so, assume everything is fine.
if not await self.store.event_is_target_of_relation(relation.parent_id):
# Otherwise, the client can't know about the parent event!
raise SynapseError(400, "Can't send relation to unknown event")

# If this event is an annotation then we check that that the sender
# can't annotate the same way twice (e.g. stops users from liking an
# event multiple times).
if relation.rel_type == RelationTypes.ANNOTATION:
aggregation_key = relation.aggregation_key

if aggregation_key is None:
raise SynapseError(400, "Missing aggregation key")

if len(aggregation_key) > 500:
raise SynapseError(400, "Aggregation key is too long")

already_exists = await self.store.has_user_annotated_event(
relation.parent_id, event.type, aggregation_key, event.sender
)
if already_exists:
raise SynapseError(
400,
"Can't send same reaction twice",
errcode=Codes.DUPLICATE_ANNOTATION,
)

# Don't attempt to start a thread if the parent event is a relation.
# XXX: should this be commented out alongside multiple relations being
# introduced?
elif relation.rel_type == RelationTypes.THREAD:
if await self.store.event_includes_relation(relation.parent_id):
raise SynapseError(
400, "Cannot start threads from an event with a relation"
)

@measure_func("handle_new_client_event")
async def handle_new_client_event(
Expand Down
34 changes: 22 additions & 12 deletions synapse/handlers/relations.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

from synapse.api.constants import Direction, EventTypes, RelationTypes
from synapse.api.errors import SynapseError
from synapse.events import EventBase, relation_from_event
from synapse.events import EventBase, relations_from_event
from synapse.events.utils import SerializeEventConfig
from synapse.logging.context import make_deferred_yieldable, run_in_background
from synapse.logging.opentracing import trace
Expand Down Expand Up @@ -286,15 +286,15 @@ async def get_references_for_events(
async def _get_threads_for_events(
self,
events_by_id: Dict[str, EventBase],
relations_by_id: Dict[str, str],
relations_by_id: Dict[str, List[str]],
user_id: str,
ignored_users: FrozenSet[str],
) -> Dict[str, _ThreadAggregation]:
"""Get the bundled aggregations for threads for the requested events.

Args:
events_by_id: A map of event_id to events to get aggregations for threads.
relations_by_id: A map of event_id to the relation type, if one exists
relations_by_id: A map of event_id to the relation types, if any exist
for that event.
user_id: The user requesting the bundled aggregations.
ignored_users: The users ignored by the requesting user.
Expand Down Expand Up @@ -432,28 +432,34 @@ async def get_bundled_aggregations(
"""
# De-duplicated events by ID to handle the same event requested multiple times.
events_by_id = {}
# A map of event ID to the relation in that event, if there is one.
relations_by_id: Dict[str, str] = {}
# A map of event ID to the relations in that event, if there are any.
relations_by_id: Dict[str, List[str]] = {}
for event in events:
# State events do not get bundled aggregations.
if event.is_state():
continue

relates_to = relation_from_event(event)
if relates_to:
valid = True

for relates_to in relations_from_event(event):
# An event which is a replacement (ie edit) or annotation (ie,
# reaction) may not have any other event related to it.
# XXX: should this be removed alongside multiple relations being introduced?
if relates_to.rel_type in (
RelationTypes.ANNOTATION,
RelationTypes.REPLACE,
):
continue
valid = False
break

# Track the event's relation information for later.
relations_by_id[event.event_id] = relates_to.rel_type
if event.event_id not in relations_by_id.keys():
relations_by_id[event.event_id] = []
relations_by_id[event.event_id].append(relates_to.rel_type)

# The event should get bundled aggregations.
events_by_id[event.event_id] = event
if valid:
# The event should get bundled aggregations.
events_by_id[event.event_id] = event

# event ID -> bundled aggregation in non-serialized form.
results: Dict[str, BundledAggregations] = {}
Expand Down Expand Up @@ -484,7 +490,11 @@ async def get_bundled_aggregations(
#
# We know that the latest event in a thread has a thread relation
# (as that is what makes it part of the thread).
relations_by_id[latest_thread_event.event_id] = RelationTypes.THREAD
if latest_thread_event.event_id not in relations_by_id.keys():
relations_by_id[latest_thread_event.event_id] = []
relations_by_id[latest_thread_event.event_id].append(
RelationTypes.THREAD
)

async def _fetch_references() -> None:
"""Fetch any references to bundle with this event."""
Expand Down
Loading