Prevent a sync request from removing a user's busy presence status (#12213)

In trying to use the MSC3026 busy presence status, the user's status
would be set back to 'online' next time they synced. This change makes
it so that syncing does not affect a user's presence status if it
is currently set to 'busy': it must be removed through the presence
API.

The MSC defers to implementations on the behaviour of busy presence,
so this ought to remain compatible with the MSC.
This commit is contained in:
David Baker 2022-04-13 16:21:07 +01:00 committed by GitHub
parent e3a49f4784
commit 73d8ded0b0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 133 additions and 18 deletions

1
changelog.d/12213.bugfix Normal file
View file

@ -0,0 +1 @@
Prevent a sync request from removing a user's busy presence status.

View file

@ -16,7 +16,7 @@ import logging
import random import random
from typing import TYPE_CHECKING, Iterable, List, Optional from typing import TYPE_CHECKING, Iterable, List, Optional
from synapse.api.constants import EduTypes, EventTypes, Membership from synapse.api.constants import EduTypes, EventTypes, Membership, PresenceState
from synapse.api.errors import AuthError, SynapseError from synapse.api.errors import AuthError, SynapseError
from synapse.events import EventBase from synapse.events import EventBase
from synapse.events.utils import SerializeEventConfig from synapse.events.utils import SerializeEventConfig
@ -67,7 +67,9 @@ class EventStreamHandler:
presence_handler = self.hs.get_presence_handler() presence_handler = self.hs.get_presence_handler()
context = await presence_handler.user_syncing( context = await presence_handler.user_syncing(
auth_user_id, affect_presence=affect_presence auth_user_id,
affect_presence=affect_presence,
presence_state=PresenceState.ONLINE,
) )
with context: with context:
if timeout: if timeout:

View file

@ -151,7 +151,7 @@ class BasePresenceHandler(abc.ABC):
@abc.abstractmethod @abc.abstractmethod
async def user_syncing( async def user_syncing(
self, user_id: str, affect_presence: bool self, user_id: str, affect_presence: bool, presence_state: str
) -> ContextManager[None]: ) -> ContextManager[None]:
"""Returns a context manager that should surround any stream requests """Returns a context manager that should surround any stream requests
from the user. from the user.
@ -165,6 +165,7 @@ class BasePresenceHandler(abc.ABC):
affect_presence: If false this function will be a no-op. affect_presence: If false this function will be a no-op.
Useful for streams that are not associated with an actual Useful for streams that are not associated with an actual
client that is being used by a user. client that is being used by a user.
presence_state: The presence state indicated in the sync request
""" """
@abc.abstractmethod @abc.abstractmethod
@ -228,6 +229,11 @@ class BasePresenceHandler(abc.ABC):
return states return states
async def current_state_for_user(self, user_id: str) -> UserPresenceState:
"""Get the current presence state for a user."""
res = await self.current_state_for_users([user_id])
return res[user_id]
@abc.abstractmethod @abc.abstractmethod
async def set_state( async def set_state(
self, self,
@ -461,7 +467,7 @@ class WorkerPresenceHandler(BasePresenceHandler):
self.send_user_sync(user_id, False, last_sync_ms) self.send_user_sync(user_id, False, last_sync_ms)
async def user_syncing( async def user_syncing(
self, user_id: str, affect_presence: bool self, user_id: str, affect_presence: bool, presence_state: str
) -> ContextManager[None]: ) -> ContextManager[None]:
"""Record that a user is syncing. """Record that a user is syncing.
@ -471,6 +477,17 @@ class WorkerPresenceHandler(BasePresenceHandler):
if not affect_presence or not self._presence_enabled: if not affect_presence or not self._presence_enabled:
return _NullContextManager() return _NullContextManager()
prev_state = await self.current_state_for_user(user_id)
if prev_state != PresenceState.BUSY:
# We set state here but pass ignore_status_msg = True as we don't want to
# cause the status message to be cleared.
# Note that this causes last_active_ts to be incremented which is not
# what the spec wants: see comment in the BasePresenceHandler version
# of this function.
await self.set_state(
UserID.from_string(user_id), {"presence": presence_state}, True
)
curr_sync = self._user_to_num_current_syncs.get(user_id, 0) curr_sync = self._user_to_num_current_syncs.get(user_id, 0)
self._user_to_num_current_syncs[user_id] = curr_sync + 1 self._user_to_num_current_syncs[user_id] = curr_sync + 1
@ -942,7 +959,10 @@ class PresenceHandler(BasePresenceHandler):
await self._update_states([prev_state.copy_and_replace(**new_fields)]) await self._update_states([prev_state.copy_and_replace(**new_fields)])
async def user_syncing( async def user_syncing(
self, user_id: str, affect_presence: bool = True self,
user_id: str,
affect_presence: bool = True,
presence_state: str = PresenceState.ONLINE,
) -> ContextManager[None]: ) -> ContextManager[None]:
"""Returns a context manager that should surround any stream requests """Returns a context manager that should surround any stream requests
from the user. from the user.
@ -956,6 +976,7 @@ class PresenceHandler(BasePresenceHandler):
affect_presence: If false this function will be a no-op. affect_presence: If false this function will be a no-op.
Useful for streams that are not associated with an actual Useful for streams that are not associated with an actual
client that is being used by a user. client that is being used by a user.
presence_state: The presence state indicated in the sync request
""" """
# Override if it should affect the user's presence, if presence is # Override if it should affect the user's presence, if presence is
# disabled. # disabled.
@ -967,9 +988,25 @@ class PresenceHandler(BasePresenceHandler):
self.user_to_num_current_syncs[user_id] = curr_sync + 1 self.user_to_num_current_syncs[user_id] = curr_sync + 1
prev_state = await self.current_state_for_user(user_id) prev_state = await self.current_state_for_user(user_id)
# If they're busy then they don't stop being busy just by syncing,
# so just update the last sync time.
if prev_state.state != PresenceState.BUSY:
# XXX: We set_state separately here and just update the last_active_ts above
# This keeps the logic as similar as possible between the worker and single
# process modes. Using set_state will actually cause last_active_ts to be
# updated always, which is not what the spec calls for, but synapse has done
# this for... forever, I think.
await self.set_state(
UserID.from_string(user_id), {"presence": presence_state}, True
)
# Retrieve the new state for the logic below. This should come from the
# in-memory cache.
prev_state = await self.current_state_for_user(user_id)
# To keep the single process behaviour consistent with worker mode, run the
# same logic as `update_external_syncs_row`, even though it looks weird.
if prev_state.state == PresenceState.OFFLINE: if prev_state.state == PresenceState.OFFLINE:
# If they're currently offline then bring them online, otherwise
# just update the last sync times.
await self._update_states( await self._update_states(
[ [
prev_state.copy_and_replace( prev_state.copy_and_replace(
@ -979,6 +1016,10 @@ class PresenceHandler(BasePresenceHandler):
) )
] ]
) )
# otherwise, set the new presence state & update the last sync time,
# but don't update last_active_ts as this isn't an indication that
# they've been active (even though it's probably been updated by
# set_state above)
else: else:
await self._update_states( await self._update_states(
[ [
@ -1086,11 +1127,6 @@ class PresenceHandler(BasePresenceHandler):
) )
self.external_process_last_updated_ms.pop(process_id, None) self.external_process_last_updated_ms.pop(process_id, None)
async def current_state_for_user(self, user_id: str) -> UserPresenceState:
"""Get the current presence state for a user."""
res = await self.current_state_for_users([user_id])
return res[user_id]
async def _persist_and_notify(self, states: List[UserPresenceState]) -> None: async def _persist_and_notify(self, states: List[UserPresenceState]) -> None:
"""Persist states in the database, poke the notifier and send to """Persist states in the database, poke the notifier and send to
interested remote servers interested remote servers

View file

@ -180,13 +180,10 @@ class SyncRestServlet(RestServlet):
affect_presence = set_presence != PresenceState.OFFLINE affect_presence = set_presence != PresenceState.OFFLINE
if affect_presence:
await self.presence_handler.set_state(
user, {"presence": set_presence}, True
)
context = await self.presence_handler.user_syncing( context = await self.presence_handler.user_syncing(
user.to_string(), affect_presence=affect_presence user.to_string(),
affect_presence=affect_presence,
presence_state=set_presence,
) )
with context: with context:
sync_result = await self.sync_handler.wait_for_sync_for_user( sync_result = await self.sync_handler.wait_for_sync_for_user(

View file

@ -657,6 +657,85 @@ class PresenceHandlerTestCase(unittest.HomeserverTestCase):
# Mark user as online and `status_msg = None` # Mark user as online and `status_msg = None`
self._set_presencestate_with_status_msg(user_id, PresenceState.ONLINE, None) self._set_presencestate_with_status_msg(user_id, PresenceState.ONLINE, None)
def test_set_presence_from_syncing_not_set(self):
"""Test that presence is not set by syncing if affect_presence is false"""
user_id = "@test:server"
status_msg = "I'm here!"
self._set_presencestate_with_status_msg(
user_id, PresenceState.UNAVAILABLE, status_msg
)
self.get_success(
self.presence_handler.user_syncing(user_id, False, PresenceState.ONLINE)
)
state = self.get_success(
self.presence_handler.get_state(UserID.from_string(user_id))
)
# we should still be unavailable
self.assertEqual(state.state, PresenceState.UNAVAILABLE)
# and status message should still be the same
self.assertEqual(state.status_msg, status_msg)
def test_set_presence_from_syncing_is_set(self):
"""Test that presence is set by syncing if affect_presence is true"""
user_id = "@test:server"
status_msg = "I'm here!"
self._set_presencestate_with_status_msg(
user_id, PresenceState.UNAVAILABLE, status_msg
)
self.get_success(
self.presence_handler.user_syncing(user_id, True, PresenceState.ONLINE)
)
state = self.get_success(
self.presence_handler.get_state(UserID.from_string(user_id))
)
# we should now be online
self.assertEqual(state.state, PresenceState.ONLINE)
def test_set_presence_from_syncing_keeps_status(self):
"""Test that presence set by syncing retains status message"""
user_id = "@test:server"
status_msg = "I'm here!"
self._set_presencestate_with_status_msg(
user_id, PresenceState.UNAVAILABLE, status_msg
)
self.get_success(
self.presence_handler.user_syncing(user_id, True, PresenceState.ONLINE)
)
state = self.get_success(
self.presence_handler.get_state(UserID.from_string(user_id))
)
# our status message should be the same as it was before
self.assertEqual(state.status_msg, status_msg)
def test_set_presence_from_syncing_keeps_busy(self):
"""Test that presence set by syncing doesn't affect busy status"""
# while this isn't the default
self.presence_handler._busy_presence_enabled = True
user_id = "@test:server"
status_msg = "I'm busy!"
self._set_presencestate_with_status_msg(user_id, PresenceState.BUSY, status_msg)
self.get_success(
self.presence_handler.user_syncing(user_id, True, PresenceState.ONLINE)
)
state = self.get_success(
self.presence_handler.get_state(UserID.from_string(user_id))
)
# we should still be busy
self.assertEqual(state.state, PresenceState.BUSY)
def _set_presencestate_with_status_msg( def _set_presencestate_with_status_msg(
self, user_id: str, state: str, status_msg: Optional[str] self, user_id: str, state: str, status_msg: Optional[str]
): ):