From de3e9b49ecc09e9f31ea80cc3cd4292762838d33 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:26:32 +0100 Subject: [PATCH] Add msc4354_sticky_duration_ttl_ms support --- synapse/api/constants.py | 3 +++ synapse/events/__init__.py | 21 ++++++++++++++++++- .../storage/databases/main/sticky_events.py | 17 ++++----------- synapse/visibility.py | 9 ++++++++ 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/synapse/api/constants.py b/synapse/api/constants.py index 5f2b1a4b5a..105a3fe7c7 100644 --- a/synapse/api/constants.py +++ b/synapse/api/constants.py @@ -279,6 +279,8 @@ class EventUnsignedContentFields: # Requesting user's membership, per MSC4115 MEMBERSHIP: Final = "membership" + STICKY_TTL: Final = "msc4354_sticky_duration_ttl_ms" + class MTextFields: """Fields found inside m.text content blocks.""" @@ -369,3 +371,4 @@ class StickyEventField(TypedDict): class StickyEvent: QUERY_PARAM_NAME: Final = "msc4354_stick_duration_ms" FIELD_NAME: Final = "msc4354_sticky" + MAX_DURATION_MS: Final = 3600000 # 1 hour diff --git a/synapse/events/__init__.py b/synapse/events/__init__.py index db38754280..82f8940dc9 100644 --- a/synapse/events/__init__.py +++ b/synapse/events/__init__.py @@ -41,7 +41,12 @@ from typing import ( import attr from unpaddedbase64 import encode_base64 -from synapse.api.constants import EventContentFields, EventTypes, RelationTypes +from synapse.api.constants import ( + EventContentFields, + EventTypes, + RelationTypes, + StickyEvent, +) from synapse.api.room_versions import EventFormatVersions, RoomVersion, RoomVersions from synapse.synapse_rust.events import EventInternalMetadata from synapse.types import ( @@ -323,6 +328,20 @@ class EventBase(metaclass=abc.ABCMeta): # this will be a no-op if the event dict is already frozen. self._dict = freeze(self._dict) + def sticky_duration(self) -> Optional[int]: + sticky_obj = self.get_dict().get(StickyEvent.FIELD_NAME, None) + if type(sticky_obj) is not dict: + return None + sticky_duration_ms = sticky_obj.get("duration_ms", None) + # MSC: Valid values are the integer range 0-MAX_DURATION_MS + if ( + type(sticky_duration_ms) is int + and sticky_duration_ms >= 0 + and sticky_duration_ms <= StickyEvent.MAX_DURATION_MS + ): + return sticky_duration_ms + return None + def __str__(self) -> str: return self.__repr__() diff --git a/synapse/storage/databases/main/sticky_events.py b/synapse/storage/databases/main/sticky_events.py index db7b8c641e..5672a9b79f 100644 --- a/synapse/storage/databases/main/sticky_events.py +++ b/synapse/storage/databases/main/sticky_events.py @@ -57,7 +57,6 @@ logger = logging.getLogger(__name__) # Consumers call 'get_sticky_events_in_rooms' which has `WHERE expires_at > ?` # to filter out expired sticky events that have yet to be deleted. DELETE_EXPIRED_STICKY_EVENTS_MS = 60 * 1000 * 60 # 1 hour -MAX_STICKY_DURATION_MS = 3600000 # 1 hour class StickyEventsWorkerStore(StateGroupWorkerStore, CacheInvalidationWorkerStore): @@ -297,22 +296,14 @@ class StickyEventsWorkerStore(StateGroupWorkerStore, CacheInvalidationWorkerStor if ev.rejected_reason is not None: continue # MSC: The presence of sticky.duration_ms with a valid value makes the event “sticky” - sticky_obj = ev.get_dict().get(StickyEvent.FIELD_NAME, None) - if type(sticky_obj) is not dict: - continue - sticky_duration_ms = sticky_obj.get("duration_ms", None) - # MSC: Valid values are the integer range 0-MAX_STICKY_DURATION_MS - if ( - type(sticky_duration_ms) is int - and sticky_duration_ms >= 0 - and sticky_duration_ms <= MAX_STICKY_DURATION_MS - ): + sticky_duration = ev.sticky_duration() + if sticky_duration: # MSC: The start time is min(now, origin_server_ts). # This ensures that malicious origin timestamps cannot specify start times in the future. - # Calculate the end time as start_time + min(sticky.duration_ms, MAX_STICKY_DURATION_MS). + # Calculate the end time as start_time + min(sticky.duration_ms, MAX_DURATION_MS). expires_at = min(ev.origin_server_ts, now_ms) + min( ev.get_dict()[StickyEvent.FIELD_NAME]["duration_ms"], - MAX_STICKY_DURATION_MS, + StickyEvent.MAX_DURATION_MS, ) # filter out already expired sticky events if expires_at > now_ms: diff --git a/synapse/visibility.py b/synapse/visibility.py index d460d8f4c2..d0d23c54a4 100644 --- a/synapse/visibility.py +++ b/synapse/visibility.py @@ -209,6 +209,15 @@ async def filter_events_for_client( # to the cache! cloned = clone_event(filtered) cloned.unsigned[EventUnsignedContentFields.MEMBERSHIP] = user_membership + if storage.main.config.experimental.msc4354_enabled: + sticky_duration = cloned.sticky_duration() + if sticky_duration: + now = storage.main.clock.time_msec() + expires_at = min(cloned.origin_server_ts, now) + sticky_duration + if sticky_duration and expires_at > now: + cloned.unsigned[EventUnsignedContentFields.STICKY_TTL] = ( + expires_at - now + ) return cloned