diff --git a/changelog.d/16992.feature b/changelog.d/16992.feature new file mode 100644 index 00000000000..6b2cc834846 --- /dev/null +++ b/changelog.d/16992.feature @@ -0,0 +1 @@ +Added presence tracking of user profile updates and config flags for disabling user activity tracking. Contributed by @Michael-Hollister. diff --git a/docs/usage/configuration/config_documentation.md b/docs/usage/configuration/config_documentation.md index 40f64be8561..3cc5f0bf8f3 100644 --- a/docs/usage/configuration/config_documentation.md +++ b/docs/usage/configuration/config_documentation.md @@ -247,6 +247,8 @@ Example configuration: presence: enabled: false include_offline_users_on_sync: false + local_activity_tracking: true + remote_activity_tracking: true ``` `enabled` can also be set to a special value of "untracked" which ignores updates @@ -259,6 +261,21 @@ When clients perform an initial or `full_state` sync, presence results for offli not included by default. Setting `include_offline_users_on_sync` to `true` will always include offline users in the results. Defaults to false. +Enabling presence tracking can be resource intensive for the presence handler when server-side +tracking of user activity is enabled. Below are some additional configuration options which may +help improve the performance of the presence feature without outright disabling it: +* `local_activity_tracking` (Default enabled): Determines if the server tracks a user's activity +when syncing or fetching events. If disabled, the server will not automatically update the +user's presence activity when the /sync or /events endpoints are called. Note that client +applications can still update their presence by calling the presence /status endpoint. +* `remote_activity_tracking` (Default enabled): Determines if the server will accept presence +EDUs from remote servers that are exclusively user activity updates. If disabled, the server +will reject processing these EDUs. However if a presence EDU contains profile updates to any of +the `status_msg`, `displayname`, or `avatar_url` fields, then the server will accept the EDU. + +If the presence `enabled` field is set to "untracked", then these options will both act as if +set to false. + --- ### `require_auth_for_profile_requests` @@ -1765,7 +1782,7 @@ rc_3pid_validation: This option sets ratelimiting how often invites can be sent in a room or to a specific user. `per_room` defaults to `per_second: 0.3`, `burst_count: 10`, -`per_user` defaults to `per_second: 0.003`, `burst_count: 5`, and `per_issuer` +`per_user` defaults to `per_second: 0.003`, `burst_count: 5`, and `per_issuer` defaults to `per_second: 0.3`, `burst_count: 10`. Client requests that invite user(s) when [creating a @@ -1966,7 +1983,7 @@ max_image_pixels: 35M --- ### `remote_media_download_burst_count` -Remote media downloads are ratelimited using a [leaky bucket algorithm](https://en.wikipedia.org/wiki/Leaky_bucket), where a given "bucket" is keyed to the IP address of the requester when requesting remote media downloads. This configuration option sets the size of the bucket against which the size in bytes of downloads are penalized - if the bucket is full, ie a given number of bytes have already been downloaded, further downloads will be denied until the bucket drains. Defaults to 500MiB. See also `remote_media_download_per_second` which determines the rate at which the "bucket" is emptied and thus has available space to authorize new requests. +Remote media downloads are ratelimited using a [leaky bucket algorithm](https://en.wikipedia.org/wiki/Leaky_bucket), where a given "bucket" is keyed to the IP address of the requester when requesting remote media downloads. This configuration option sets the size of the bucket against which the size in bytes of downloads are penalized - if the bucket is full, ie a given number of bytes have already been downloaded, further downloads will be denied until the bucket drains. Defaults to 500MiB. See also `remote_media_download_per_second` which determines the rate at which the "bucket" is emptied and thus has available space to authorize new requests. Example configuration: ```yaml diff --git a/synapse/api/presence.py b/synapse/api/presence.py index 28c10403cef..891746dcd14 100644 --- a/synapse/api/presence.py +++ b/synapse/api/presence.py @@ -83,6 +83,8 @@ class UserPresenceState: last_user_sync_ts: int status_msg: Optional[str] currently_active: bool + displayname: Optional[str] + avatar_url: Optional[str] def as_dict(self) -> JsonDict: return attr.asdict(self) @@ -101,4 +103,6 @@ def default(cls, user_id: str) -> "UserPresenceState": last_user_sync_ts=0, status_msg=None, currently_active=False, + displayname=None, + avatar_url=None, ) diff --git a/synapse/config/server.py b/synapse/config/server.py index fd52c0475cf..b565951ddb1 100644 --- a/synapse/config/server.py +++ b/synapse/config/server.py @@ -389,6 +389,16 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None: "include_offline_users_on_sync", False ) + # Disabling server-side presence tracking + self.presence_local_activity_tracking = presence_config.get( + "local_activity_tracking", True + ) + + # Disabling federation presence tracking + self.presence_remote_activity_tracking = presence_config.get( + "remote_activity_tracking", True + ) + # Custom presence router module # This is the legacy way of configuring it (the config should now be put in the modules section) self.presence_router_module_class = None diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index 1932fa82a4a..674a4a94326 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -1425,9 +1425,30 @@ def register_instances_for_edu( self._edu_type_to_instance[edu_type] = instance_names async def on_edu(self, edu_type: str, origin: str, content: dict) -> None: + """Passes an EDU to a registered handler if one exists + + This potentially modifies the `content` dict for `m.presence` EDUs when + presence `remote_activity_tracking` is disabled. + + Args: + edu_type: The type of the incoming EDU to process + origin: The server we received the event from + content: The content of the EDU + """ if not self.config.server.track_presence and edu_type == EduTypes.PRESENCE: return + if ( + not self.config.server.presence_remote_activity_tracking + and edu_type == EduTypes.PRESENCE + ): + filtered_edus = [] + for e in content["push"]: + # Process only profile presence updates to reduce resource impact + if "status_msg" in e or "displayname" in e or "avatar_url" in e: + filtered_edus.append(e) + content["push"] = filtered_edus + # Check if we have a handler on this instance handler = self.edu_handlers.get(edu_type) if handler: diff --git a/synapse/handlers/presence.py b/synapse/handlers/presence.py index 37ee625f717..00ce79ed604 100644 --- a/synapse/handlers/presence.py +++ b/synapse/handlers/presence.py @@ -201,6 +201,9 @@ def __init__(self, hs: "HomeServer"): self._presence_enabled = hs.config.server.presence_enabled self._track_presence = hs.config.server.track_presence + self._presence_local_activity_tracking = ( + hs.config.server.presence_local_activity_tracking + ) self._federation = None if hs.should_send_federation(): @@ -451,6 +454,8 @@ async def send_full_presence_to_users(self, user_ids: StrCollection) -> None: state = { "presence": current_presence_state.state, "status_message": current_presence_state.status_msg, + "displayname": current_presence_state.displayname, + "avatar_url": current_presence_state.avatar_url, } # Copy the presence state to the tip of the presence stream. @@ -579,7 +584,11 @@ async def user_syncing( Called by the sync and events servlets to record that a user has connected to this worker and is waiting for some events. """ - if not affect_presence or not self._track_presence: + if ( + not affect_presence + or not self._track_presence + or not self._presence_local_activity_tracking + ): return _NullContextManager() # Note that this causes last_active_ts to be incremented which is not @@ -648,6 +657,8 @@ async def process_replication_rows( row.last_user_sync_ts, row.status_msg, row.currently_active, + row.displayname, + row.avatar_url, ) for row in rows ] @@ -1140,7 +1151,11 @@ async def user_syncing( client that is being used by a user. presence_state: The presence state indicated in the sync request """ - if not affect_presence or not self._track_presence: + if ( + not affect_presence + or not self._track_presence + or not self._presence_local_activity_tracking + ): return _NullContextManager() curr_sync = self._user_device_to_num_current_syncs.get((user_id, device_id), 0) @@ -1340,6 +1355,8 @@ async def incoming_presence(self, origin: str, content: JsonDict) -> None: new_fields["status_msg"] = push.get("status_msg", None) new_fields["currently_active"] = push.get("currently_active", False) + new_fields["displayname"] = push.get("displayname", None) + new_fields["avatar_url"] = push.get("avatar_url", None) prev_state = await self.current_state_for_user(user_id) updates.append(prev_state.copy_and_replace(**new_fields)) @@ -1369,6 +1386,8 @@ async def set_state( the `state` dict. """ status_msg = state.get("status_msg", None) + displayname = state.get("displayname", None) + avatar_url = state.get("avatar_url", None) presence = state["presence"] if presence not in self.VALID_PRESENCE: @@ -1414,6 +1433,8 @@ async def set_state( else: # Syncs do not override the status message. new_fields["status_msg"] = status_msg + new_fields["displayname"] = displayname + new_fields["avatar_url"] = avatar_url await self._update_states( [prev_state.copy_and_replace(**new_fields)], force_notify=force_notify @@ -1634,6 +1655,8 @@ async def _handle_state_delta(self, room_id: str, deltas: List[StateDelta]) -> N if state.state != PresenceState.OFFLINE or now - state.last_active_ts < 7 * 24 * 60 * 60 * 1000 or state.status_msg is not None + or state.displayname is not None + or state.avatar_url is not None ] await self._federation_queue.send_presence_to_destinations( @@ -1668,6 +1691,14 @@ def should_notify( notify_reason_counter.labels(user_location, "status_msg_change").inc() return True + if old_state.displayname != new_state.displayname: + notify_reason_counter.labels(user_location, "displayname_change").inc() + return True + + if old_state.avatar_url != new_state.avatar_url: + notify_reason_counter.labels(user_location, "avatar_url_change").inc() + return True + if old_state.state != new_state.state: notify_reason_counter.labels(user_location, "state_change").inc() state_transition_counter.labels( @@ -1725,6 +1756,8 @@ def format_user_presence_state( * status_msg: Optional. Included if `status_msg` is set on `state`. The user's status. * currently_active: Optional. Included only if `state.state` is "online". + * displayname: Optional. The current display name for this user, if any. + * avatar_url: Optional. The current avatar URL for this user, if any. Example: @@ -1733,7 +1766,9 @@ def format_user_presence_state( "user_id": "@alice:example.com", "last_active_ago": 16783813918, "status_msg": "Hello world!", - "currently_active": True + "currently_active": True, + "displayname": "Alice", + "avatar_url": "mxc://localhost/wefuiwegh8742w" } """ content: JsonDict = {"presence": state.state} @@ -1745,6 +1780,10 @@ def format_user_presence_state( content["status_msg"] = state.status_msg if state.state == PresenceState.ONLINE: content["currently_active"] = state.currently_active + if state.displayname: + content["displayname"] = state.displayname + if state.avatar_url: + content["avatar_url"] = state.avatar_url return content diff --git a/synapse/handlers/profile.py b/synapse/handlers/profile.py index 6663d4b271b..13ca64400ea 100644 --- a/synapse/handlers/profile.py +++ b/synapse/handlers/profile.py @@ -202,6 +202,19 @@ async def set_displayname( if propagate: await self._update_join_states(requester, target_user) + if self.hs.config.server.track_presence: + presence_handler = self.hs.get_presence_handler() + current_presence_state = await presence_handler.get_state(target_user) + + state = { + "presence": current_presence_state.state, + "status_message": current_presence_state.status_msg, + "displayname": new_displayname, + "avatar_url": current_presence_state.avatar_url, + } + + await presence_handler.set_state(target_user, requester.device_id, state) + async def get_avatar_url(self, target_user: UserID) -> Optional[str]: if self.hs.is_mine(target_user): try: @@ -295,6 +308,19 @@ async def set_avatar_url( if propagate: await self._update_join_states(requester, target_user) + if self.hs.config.server.track_presence: + presence_handler = self.hs.get_presence_handler() + current_presence_state = await presence_handler.get_state(target_user) + + state = { + "presence": current_presence_state.state, + "status_message": current_presence_state.status_msg, + "displayname": current_presence_state.displayname, + "avatar_url": new_avatar_url, + } + + await presence_handler.set_state(target_user, requester.device_id, state) + @cached() async def check_avatar_size_and_mime_type(self, mxc: str) -> bool: """Check that the size and content type of the avatar at the given MXC URI are diff --git a/synapse/replication/tcp/streams/_base.py b/synapse/replication/tcp/streams/_base.py index d021904de72..0c0e3689fa7 100644 --- a/synapse/replication/tcp/streams/_base.py +++ b/synapse/replication/tcp/streams/_base.py @@ -330,6 +330,8 @@ class PresenceStreamRow: last_user_sync_ts: int status_msg: str currently_active: bool + displayname: str + avatar_url: str NAME = "presence" ROW_TYPE = PresenceStreamRow diff --git a/synapse/storage/databases/main/presence.py b/synapse/storage/databases/main/presence.py index 065c8856036..3a3aa5d891c 100644 --- a/synapse/storage/databases/main/presence.py +++ b/synapse/storage/databases/main/presence.py @@ -171,6 +171,8 @@ def _update_presence_txn( "last_user_sync_ts", "status_msg", "currently_active", + "displayname", + "avatar_url", "instance_name", ), values=[ @@ -183,6 +185,8 @@ def _update_presence_txn( state.last_user_sync_ts, state.status_msg, state.currently_active, + state.displayname, + state.avatar_url, self._instance_name, ) for stream_id, state in zip(stream_orderings, presence_states) @@ -222,7 +226,8 @@ def get_all_presence_updates_txn( sql = """ SELECT stream_id, user_id, state, last_active_ts, last_federation_update_ts, last_user_sync_ts, - status_msg, currently_active + status_msg, currently_active, displayname, + avatar_url FROM presence_stream WHERE ? < stream_id AND stream_id <= ? ORDER BY stream_id ASC @@ -261,7 +266,19 @@ async def get_presence_for_users( # TODO All these columns are nullable, but we don't expect that: # https://github.com/matrix-org/synapse/issues/16467 rows = cast( - List[Tuple[str, str, int, int, int, Optional[str], Union[int, bool]]], + List[ + Tuple[ + str, + str, + int, + int, + int, + Optional[str], + Union[int, bool], + Optional[str], + Optional[str], + ] + ], await self.db_pool.simple_select_many_batch( table="presence_stream", column="user_id", @@ -275,6 +292,8 @@ async def get_presence_for_users( "last_user_sync_ts", "status_msg", "currently_active", + "displayname", + "avatar_url", ), desc="get_presence_for_users", ), @@ -289,8 +308,10 @@ async def get_presence_for_users( last_user_sync_ts=last_user_sync_ts, status_msg=status_msg, currently_active=bool(currently_active), + displayname=displayname, + avatar_url=avatar_url, ) - for user_id, state, last_active_ts, last_federation_update_ts, last_user_sync_ts, status_msg, currently_active in rows + for user_id, state, last_active_ts, last_federation_update_ts, last_user_sync_ts, status_msg, currently_active, displayname, avatar_url, in rows } async def should_user_receive_full_presence_with_token( @@ -400,7 +421,19 @@ async def get_presence_for_all_users( # TODO All these columns are nullable, but we don't expect that: # https://github.com/matrix-org/synapse/issues/16467 rows = cast( - List[Tuple[str, str, int, int, int, Optional[str], Union[int, bool]]], + List[ + Tuple[ + str, + str, + int, + int, + int, + Optional[str], + Union[int, bool], + Optional[str], + Optional[str], + ] + ], await self.db_pool.runInteraction( "get_presence_for_all_users", self.db_pool.simple_select_list_paginate_txn, @@ -417,6 +450,8 @@ async def get_presence_for_all_users( "last_user_sync_ts", "status_msg", "currently_active", + "displayname", + "avatar_url", ), order_direction="ASC", ), @@ -430,6 +465,8 @@ async def get_presence_for_all_users( last_user_sync_ts, status_msg, currently_active, + displayname, + avatar_url, ) in rows: users_to_state[user_id] = UserPresenceState( user_id=user_id, @@ -439,6 +476,8 @@ async def get_presence_for_all_users( last_user_sync_ts=last_user_sync_ts, status_msg=status_msg, currently_active=bool(currently_active), + displayname=displayname, + avatar_url=avatar_url, ) # We've run out of updates to query @@ -464,7 +503,8 @@ def _get_active_presence(self, db_conn: Connection) -> List[UserPresenceState]: # query. sql = ( "SELECT user_id, state, last_active_ts, last_federation_update_ts," - " last_user_sync_ts, status_msg, currently_active FROM presence_stream" + " last_user_sync_ts, status_msg, currently_active, displayname, avatar_url " + " FROM presence_stream" " WHERE state != ?" ) @@ -482,8 +522,10 @@ def _get_active_presence(self, db_conn: Connection) -> List[UserPresenceState]: last_user_sync_ts=last_user_sync_ts, status_msg=status_msg, currently_active=bool(currently_active), + displayname=displayname, + avatar_url=avatar_url, ) - for user_id, state, last_active_ts, last_federation_update_ts, last_user_sync_ts, status_msg, currently_active in rows + for user_id, state, last_active_ts, last_federation_update_ts, last_user_sync_ts, status_msg, currently_active, displayname, avatar_url, in rows ] def take_presence_startup_info(self) -> List[UserPresenceState]: diff --git a/synapse/storage/schema/__init__.py b/synapse/storage/schema/__init__.py index 581d00346bf..8b3bb04665b 100644 --- a/synapse/storage/schema/__init__.py +++ b/synapse/storage/schema/__init__.py @@ -19,7 +19,7 @@ # # -SCHEMA_VERSION = 86 # remember to update the list below when updating +SCHEMA_VERSION = 87 # remember to update the list below when updating """Represents the expectations made by the codebase about the database schema This should be incremented whenever the codebase changes its requirements on the @@ -142,6 +142,9 @@ Changes in SCHEMA_VERSION = 86 - Add a column `authenticated` to the tables `local_media_repository` and `remote_media_cache` + +Changes in SCHEMA_VERSION = 87 + - Added displayname and avatar_url columns to presence_stream """ diff --git a/synapse/storage/schema/main/delta/87/01_presence_stream_updates.sql b/synapse/storage/schema/main/delta/87/01_presence_stream_updates.sql new file mode 100644 index 00000000000..046fef3193d --- /dev/null +++ b/synapse/storage/schema/main/delta/87/01_presence_stream_updates.sql @@ -0,0 +1,15 @@ +-- +-- This file is licensed under the Affero General Public License (AGPL) version 3. +-- +-- Copyright (C) 2024 New Vector, 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: +-- . + +ALTER TABLE presence_stream ADD COLUMN displayname TEXT; +ALTER TABLE presence_stream ADD COLUMN avatar_url TEXT; diff --git a/tests/api/test_filtering.py b/tests/api/test_filtering.py index 743c52d969c..00516530269 100644 --- a/tests/api/test_filtering.py +++ b/tests/api/test_filtering.py @@ -450,6 +450,8 @@ def test_filter_presence_match(self) -> None: last_user_sync_ts=0, status_msg=None, currently_active=False, + displayname=None, + avatar_url=None, ), ] @@ -478,6 +480,8 @@ def test_filter_presence_no_match(self) -> None: last_user_sync_ts=0, status_msg=None, currently_active=False, + displayname=None, + avatar_url=None, ), ] diff --git a/tests/handlers/test_presence.py b/tests/handlers/test_presence.py index cc630d606ca..ae99bda15e2 100644 --- a/tests/handlers/test_presence.py +++ b/tests/handlers/test_presence.py @@ -366,6 +366,8 @@ def test_persisting_presence_updates(self) -> None: last_user_sync_ts=1, status_msg="I'm online!", currently_active=True, + displayname=None, + avatar_url=None, ) presence_states.append(presence_state) @@ -718,6 +720,8 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: last_user_sync_ts=now, status_msg=None, currently_active=True, + displayname=None, + avatar_url=None, ) ] )