Compare commits

...

12 commits

Author SHA1 Message Date
Michael Hollister 63f6ba82c4
Merge 9716d53627 into f1c4dfb08b 2024-06-12 13:06:01 +01:00
Michael Hollister 9716d53627 Docs grammar fix 2024-05-30 22:59:32 -05:00
Michael Hollister 66d3244860 Updated changelog description 2024-05-30 22:50:59 -05:00
Michael Hollister 46ddc1d893 Reverted schema compat version 2024-05-30 22:46:18 -05:00
Michael Hollister 6b12d3ec6c Improved wording of documentation and config option naming 2024-05-30 22:45:38 -05:00
Michael Hollister 89d8b32af7
Improved wording of presence tracking documentation
Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
2024-05-30 15:24:30 -05:00
Michael Hollister 8386b98e11
Merge branch 'element-hq:develop' into michael/presence-enhancements 2024-05-02 15:40:44 -05:00
Michael Hollister cccb26f206 Removed duplicate license comment block 2024-05-01 14:22:28 -05:00
Michael Hollister fbbd8ed6be Added documentation for configuring new presence tracking options 2024-05-01 14:20:17 -05:00
Michael Hollister a38f805ff3 Updating DB schema to 86 due to rebase with develop 2024-05-01 13:44:12 -05:00
Michael Hollister c893e5a577 Added missing type hints to DB queries 2024-05-01 13:37:47 -05:00
Michael Hollister 16a21470dd Added presence update on change of profile information and config flags for selective presence tracking
Signed-off-by: Michael Hollister <michael@futo.org>
2024-05-01 12:14:56 -05:00
13 changed files with 198 additions and 10 deletions

View file

@ -0,0 +1 @@
Added presence tracking of user profile updates and config flags for disabling user activity tracking. Contributed by @Michael-Hollister.

View file

@ -246,6 +246,8 @@ Example configuration:
```yaml
presence:
enabled: false
local_activity_tracking: true
remote_activity_tracking: true
```
`enabled` can also be set to a special value of "untracked" which ignores updates
@ -254,6 +256,21 @@ received via clients and federation, while still accepting updates from the
*The "untracked" option was added in Synapse 1.96.0.*
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`

View file

@ -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 @@ class UserPresenceState:
last_user_sync_ts=0,
status_msg=None,
currently_active=False,
displayname=None,
avatar_url=None,
)

View file

@ -384,6 +384,16 @@ class ServerConfig(Config):
# Whether to internally track presence, requires that presence is enabled,
self.track_presence = self.presence_enabled and presence_enabled != "untracked"
# 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

View file

@ -1425,9 +1425,30 @@ class FederationHandlerRegistry:
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:

View file

@ -201,6 +201,9 @@ class BasePresenceHandler(abc.ABC):
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 @@ class BasePresenceHandler(abc.ABC):
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 @@ class WorkerPresenceHandler(BasePresenceHandler):
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 @@ class WorkerPresenceHandler(BasePresenceHandler):
row.last_user_sync_ts,
row.status_msg,
row.currently_active,
row.displayname,
row.avatar_url,
)
for row in rows
]
@ -1140,7 +1151,11 @@ class PresenceHandler(BasePresenceHandler):
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 @@ class PresenceHandler(BasePresenceHandler):
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 @@ class PresenceHandler(BasePresenceHandler):
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 @@ class PresenceHandler(BasePresenceHandler):
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 @@ class PresenceHandler(BasePresenceHandler):
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

View file

@ -202,6 +202,19 @@ class ProfileHandler:
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 @@ class ProfileHandler:
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

View file

@ -330,6 +330,8 @@ class PresenceStream(_StreamFromIdGen):
last_user_sync_ts: int
status_msg: str
currently_active: bool
displayname: str
avatar_url: str
NAME = "presence"
ROW_TYPE = PresenceStreamRow

View file

@ -174,6 +174,8 @@ class PresenceStore(PresenceBackgroundUpdateStore, CacheInvalidationWorkerStore)
"last_user_sync_ts",
"status_msg",
"currently_active",
"displayname",
"avatar_url",
"instance_name",
),
values=[
@ -186,6 +188,8 @@ class PresenceStore(PresenceBackgroundUpdateStore, CacheInvalidationWorkerStore)
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)
@ -225,7 +229,8 @@ class PresenceStore(PresenceBackgroundUpdateStore, CacheInvalidationWorkerStore)
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
@ -264,7 +269,19 @@ class PresenceStore(PresenceBackgroundUpdateStore, CacheInvalidationWorkerStore)
# 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",
@ -278,6 +295,8 @@ class PresenceStore(PresenceBackgroundUpdateStore, CacheInvalidationWorkerStore)
"last_user_sync_ts",
"status_msg",
"currently_active",
"displayname",
"avatar_url",
),
desc="get_presence_for_users",
),
@ -292,8 +311,10 @@ class PresenceStore(PresenceBackgroundUpdateStore, CacheInvalidationWorkerStore)
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(
@ -403,7 +424,19 @@ class PresenceStore(PresenceBackgroundUpdateStore, CacheInvalidationWorkerStore)
# 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,
@ -420,6 +453,8 @@ class PresenceStore(PresenceBackgroundUpdateStore, CacheInvalidationWorkerStore)
"last_user_sync_ts",
"status_msg",
"currently_active",
"displayname",
"avatar_url",
),
order_direction="ASC",
),
@ -433,6 +468,8 @@ class PresenceStore(PresenceBackgroundUpdateStore, CacheInvalidationWorkerStore)
last_user_sync_ts,
status_msg,
currently_active,
displayname,
avatar_url,
) in rows:
users_to_state[user_id] = UserPresenceState(
user_id=user_id,
@ -442,6 +479,8 @@ class PresenceStore(PresenceBackgroundUpdateStore, CacheInvalidationWorkerStore)
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 @@ class PresenceStore(PresenceBackgroundUpdateStore, CacheInvalidationWorkerStore)
# 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 @@ class PresenceStore(PresenceBackgroundUpdateStore, CacheInvalidationWorkerStore)
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]:

View file

@ -19,7 +19,7 @@
#
#
SCHEMA_VERSION = 85 # remember to update the list below when updating
SCHEMA_VERSION = 86 # 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
@ -139,6 +139,9 @@ Changes in SCHEMA_VERSION = 84
Changes in SCHEMA_VERSION = 85
- Add a column `suspended` to the `users` table
Changes in SCHEMA_VERSION = 86
- Added displayname and avatar_url columns to presence_stream
"""

View file

@ -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:
-- <https://www.gnu.org/licenses/agpl-3.0.html>.
ALTER TABLE presence_stream ADD COLUMN displayname TEXT;
ALTER TABLE presence_stream ADD COLUMN avatar_url TEXT;

View file

@ -450,6 +450,8 @@ class FilteringTestCase(unittest.HomeserverTestCase):
last_user_sync_ts=0,
status_msg=None,
currently_active=False,
displayname=None,
avatar_url=None,
),
]
@ -478,6 +480,8 @@ class FilteringTestCase(unittest.HomeserverTestCase):
last_user_sync_ts=0,
status_msg=None,
currently_active=False,
displayname=None,
avatar_url=None,
),
]

View file

@ -366,6 +366,8 @@ class PresenceUpdateTestCase(unittest.HomeserverTestCase):
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 @@ class PresenceHandlerInitTestCase(unittest.HomeserverTestCase):
last_user_sync_ts=now,
status_msg=None,
currently_active=True,
displayname=None,
avatar_url=None,
)
]
)