Port the PresenceRouter module interface to the new generic interface (#10524)

Port the PresenceRouter module interface to the new generic interface introduced in v1.37.0
This commit is contained in:
Azrenbeth 2021-08-17 14:22:45 +01:00 committed by GitHub
parent 272b89d547
commit 1a9f531c79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 326 additions and 69 deletions

View File

@ -0,0 +1 @@
Port the PresenceRouter module interface to the new generic interface.

View File

@ -282,6 +282,52 @@ the request is a server admin.
Modules can modify the `request_content` (by e.g. adding events to its `initial_state`), Modules can modify the `request_content` (by e.g. adding events to its `initial_state`),
or deny the room's creation by raising a `module_api.errors.SynapseError`. or deny the room's creation by raising a `module_api.errors.SynapseError`.
#### Presence router callbacks
Presence router callbacks allow module developers to specify additional users (local or remote)
to receive certain presence updates from local users. Presence router callbacks can be
registered using the module API's `register_presence_router_callbacks` method.
The available presence router callbacks are:
```python
async def get_users_for_states(
self,
state_updates: Iterable["synapse.api.UserPresenceState"],
) -> Dict[str, Set["synapse.api.UserPresenceState"]]:
```
**Requires** `get_interested_users` to also be registered
Called when processing updates to the presence state of one or more users. This callback can
be used to instruct the server to forward that presence state to specific users. The module
must return a dictionary that maps from Matrix user IDs (which can be local or remote) to the
`UserPresenceState` changes that they should be forwarded.
Synapse will then attempt to send the specified presence updates to each user when possible.
```python
async def get_interested_users(
self,
user_id: str
) -> Union[Set[str], "synapse.module_api.PRESENCE_ALL_USERS"]
```
**Requires** `get_users_for_states` to also be registered
Called when determining which users someone should be able to see the presence state of. This
callback should return complementary results to `get_users_for_state` or the presence information
may not be properly forwarded.
The callback is given the Matrix user ID for a local user that is requesting presence data and
should return the Matrix user IDs of the users whose presence state they are allowed to
query. The returned users can be local or remote.
Alternatively the callback can return `synapse.module_api.PRESENCE_ALL_USERS`
to indicate that the user should receive updates from all known users.
For example, if the user `@alice:example.org` is passed to this method, and the Set
`{"@bob:example.com", "@charlie:somewhere.org"}` is returned, this signifies that Alice
should receive presence updates sent by Bob and Charlie, regardless of whether these users
share a room.
### Porting an existing module that uses the old interface ### Porting an existing module that uses the old interface

View File

@ -1,3 +1,9 @@
<h2 style="color:red">
This page of the Synapse documentation is now deprecated. For up to date
documentation on setting up or writing a presence router module, please see
<a href="modules.md">this page</a>.
</h2>
# Presence Router Module # Presence Router Module
Synapse supports configuring a module that can specify additional users Synapse supports configuring a module that can specify additional users

View File

@ -108,20 +108,6 @@ presence:
# #
#enabled: false #enabled: false
# Presence routers are third-party modules that can specify additional logic
# to where presence updates from users are routed.
#
presence_router:
# The custom module's class. Uncomment to use a custom presence router module.
#
#module: "my_custom_router.PresenceRouter"
# Configuration options of the custom module. Refer to your module's
# documentation for available options.
#
#config:
# example_option: 'something'
# Whether to require authentication to retrieve profile data (avatars, # Whether to require authentication to retrieve profile data (avatars,
# display names) of other users through the client API. Defaults to # display names) of other users through the client API. Defaults to
# 'false'. Note that profile data is also available via the federation # 'false'. Note that profile data is also available via the federation

View File

@ -37,6 +37,7 @@ from synapse.app import check_bind_error
from synapse.app.phone_stats_home import start_phone_stats_home from synapse.app.phone_stats_home import start_phone_stats_home
from synapse.config.homeserver import HomeServerConfig from synapse.config.homeserver import HomeServerConfig
from synapse.crypto import context_factory from synapse.crypto import context_factory
from synapse.events.presence_router import load_legacy_presence_router
from synapse.events.spamcheck import load_legacy_spam_checkers from synapse.events.spamcheck import load_legacy_spam_checkers
from synapse.events.third_party_rules import load_legacy_third_party_event_rules from synapse.events.third_party_rules import load_legacy_third_party_event_rules
from synapse.logging.context import PreserveLoggingContext from synapse.logging.context import PreserveLoggingContext
@ -370,6 +371,7 @@ async def start(hs: "HomeServer"):
load_legacy_spam_checkers(hs) load_legacy_spam_checkers(hs)
load_legacy_third_party_event_rules(hs) load_legacy_third_party_event_rules(hs)
load_legacy_presence_router(hs)
# If we've configured an expiry time for caches, start the background job now. # If we've configured an expiry time for caches, start the background job now.
setup_expire_lru_cache_entries(hs) setup_expire_lru_cache_entries(hs)

View File

@ -248,6 +248,7 @@ class ServerConfig(Config):
self.use_presence = config.get("use_presence", True) self.use_presence = config.get("use_presence", True)
# Custom presence router module # 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 self.presence_router_module_class = None
self.presence_router_config = None self.presence_router_config = None
presence_router_config = presence_config.get("presence_router") presence_router_config = presence_config.get("presence_router")
@ -858,20 +859,6 @@ class ServerConfig(Config):
# #
#enabled: false #enabled: false
# Presence routers are third-party modules that can specify additional logic
# to where presence updates from users are routed.
#
presence_router:
# The custom module's class. Uncomment to use a custom presence router module.
#
#module: "my_custom_router.PresenceRouter"
# Configuration options of the custom module. Refer to your module's
# documentation for available options.
#
#config:
# example_option: 'something'
# Whether to require authentication to retrieve profile data (avatars, # Whether to require authentication to retrieve profile data (avatars,
# display names) of other users through the client API. Defaults to # display names) of other users through the client API. Defaults to
# 'false'. Note that profile data is also available via the federation # 'false'. Note that profile data is also available via the federation

View File

@ -11,45 +11,115 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import logging
from typing import TYPE_CHECKING, Dict, Iterable, Set, Union from typing import (
TYPE_CHECKING,
Awaitable,
Callable,
Dict,
Iterable,
List,
Optional,
Set,
Union,
)
from synapse.api.presence import UserPresenceState from synapse.api.presence import UserPresenceState
from synapse.util.async_helpers import maybe_awaitable
if TYPE_CHECKING: if TYPE_CHECKING:
from synapse.server import HomeServer from synapse.server import HomeServer
GET_USERS_FOR_STATES_CALLBACK = Callable[
[Iterable[UserPresenceState]], Awaitable[Dict[str, Set[UserPresenceState]]]
]
GET_INTERESTED_USERS_CALLBACK = Callable[
[str], Awaitable[Union[Set[str], "PresenceRouter.ALL_USERS"]]
]
logger = logging.getLogger(__name__)
def load_legacy_presence_router(hs: "HomeServer"):
"""Wrapper that loads a presence router module configured using the old
configuration, and registers the hooks they implement.
"""
if hs.config.presence_router_module_class is None:
return
module = hs.config.presence_router_module_class
config = hs.config.presence_router_config
api = hs.get_module_api()
presence_router = module(config=config, module_api=api)
# The known hooks. If a module implements a method which name appears in this set,
# we'll want to register it.
presence_router_methods = {
"get_users_for_states",
"get_interested_users",
}
# All methods that the module provides should be async, but this wasn't enforced
# in the old module system, so we wrap them if needed
def async_wrapper(f: Optional[Callable]) -> Optional[Callable[..., Awaitable]]:
# f might be None if the callback isn't implemented by the module. In this
# case we don't want to register a callback at all so we return None.
if f is None:
return None
def run(*args, **kwargs):
# mypy doesn't do well across function boundaries so we need to tell it
# f is definitely not None.
assert f is not None
return maybe_awaitable(f(*args, **kwargs))
return run
# Register the hooks through the module API.
hooks = {
hook: async_wrapper(getattr(presence_router, hook, None))
for hook in presence_router_methods
}
api.register_presence_router_callbacks(**hooks)
class PresenceRouter: class PresenceRouter:
""" """
A module that the homeserver will call upon to help route user presence updates to A module that the homeserver will call upon to help route user presence updates to
additional destinations. If a custom presence router is configured, calls will be additional destinations.
passed to that instead.
""" """
ALL_USERS = "ALL" ALL_USERS = "ALL"
def __init__(self, hs: "HomeServer"): def __init__(self, hs: "HomeServer"):
self.custom_presence_router = None # Initially there are no callbacks
self._get_users_for_states_callbacks: List[GET_USERS_FOR_STATES_CALLBACK] = []
self._get_interested_users_callbacks: List[GET_INTERESTED_USERS_CALLBACK] = []
# Check whether a custom presence router module has been configured def register_presence_router_callbacks(
if hs.config.presence_router_module_class: self,
# Initialise the module get_users_for_states: Optional[GET_USERS_FOR_STATES_CALLBACK] = None,
self.custom_presence_router = hs.config.presence_router_module_class( get_interested_users: Optional[GET_INTERESTED_USERS_CALLBACK] = None,
config=hs.config.presence_router_config, module_api=hs.get_module_api() ):
# PresenceRouter modules are required to implement both of these methods
# or neither of them as they are assumed to act in a complementary manner
paired_methods = [get_users_for_states, get_interested_users]
if paired_methods.count(None) == 1:
raise RuntimeError(
"PresenceRouter modules must register neither or both of the paired callbacks: "
"[get_users_for_states, get_interested_users]"
) )
# Ensure the module has implemented the required methods # Append the methods provided to the lists of callbacks
required_methods = ["get_users_for_states", "get_interested_users"] if get_users_for_states is not None:
for method_name in required_methods: self._get_users_for_states_callbacks.append(get_users_for_states)
if not hasattr(self.custom_presence_router, method_name):
raise Exception( if get_interested_users is not None:
"PresenceRouter module '%s' must implement all required methods: %s" self._get_interested_users_callbacks.append(get_interested_users)
% (
hs.config.presence_router_module_class.__name__,
", ".join(required_methods),
)
)
async def get_users_for_states( async def get_users_for_states(
self, self,
@ -66,15 +136,41 @@ class PresenceRouter:
A dictionary of user_id -> set of UserPresenceState, indicating which A dictionary of user_id -> set of UserPresenceState, indicating which
presence updates each user should receive. presence updates each user should receive.
""" """
if self.custom_presence_router is not None:
# Ask the custom module
return await self.custom_presence_router.get_users_for_states(
state_updates=state_updates
)
# Bail out early if we don't have any callbacks to run.
if len(self._get_users_for_states_callbacks) == 0:
# Don't include any extra destinations for presence updates # Don't include any extra destinations for presence updates
return {} return {}
users_for_states = {}
# run all the callbacks for get_users_for_states and combine the results
for callback in self._get_users_for_states_callbacks:
try:
result = await callback(state_updates)
except Exception as e:
logger.warning("Failed to run module API callback %s: %s", callback, e)
continue
if not isinstance(result, Dict):
logger.warning(
"Wrong type returned by module API callback %s: %s, expected Dict",
callback,
result,
)
continue
for key, new_entries in result.items():
if not isinstance(new_entries, Set):
logger.warning(
"Wrong type returned by module API callback %s: %s, expected Set",
callback,
new_entries,
)
break
users_for_states.setdefault(key, set()).update(new_entries)
return users_for_states
async def get_interested_users(self, user_id: str) -> Union[Set[str], ALL_USERS]: async def get_interested_users(self, user_id: str) -> Union[Set[str], ALL_USERS]:
""" """
Retrieve a list of users that `user_id` is interested in receiving the Retrieve a list of users that `user_id` is interested in receiving the
@ -92,12 +188,36 @@ class PresenceRouter:
A set of user IDs to return presence updates for, or ALL_USERS to return all A set of user IDs to return presence updates for, or ALL_USERS to return all
known updates. known updates.
""" """
if self.custom_presence_router is not None:
# Ask the custom module for interested users
return await self.custom_presence_router.get_interested_users(
user_id=user_id
)
# A custom presence router is not defined. # Bail out early if we don't have any callbacks to run.
if len(self._get_interested_users_callbacks) == 0:
# Don't report any additional interested users # Don't report any additional interested users
return set() return set()
interested_users = set()
# run all the callbacks for get_interested_users and combine the results
for callback in self._get_interested_users_callbacks:
try:
result = await callback(user_id)
except Exception as e:
logger.warning("Failed to run module API callback %s: %s", callback, e)
continue
# If one of the callbacks returns ALL_USERS then we can stop calling all
# of the other callbacks, since the set of interested_users is already as
# large as it can possibly be
if result == PresenceRouter.ALL_USERS:
return PresenceRouter.ALL_USERS
if not isinstance(result, Set):
logger.warning(
"Wrong type returned by module API callback %s: %s, expected set",
callback,
result,
)
continue
# Add the new interested users to the set
interested_users.update(result)
return interested_users

View File

@ -32,6 +32,7 @@ from twisted.internet import defer
from twisted.web.resource import IResource from twisted.web.resource import IResource
from synapse.events import EventBase from synapse.events import EventBase
from synapse.events.presence_router import PresenceRouter
from synapse.http.client import SimpleHttpClient from synapse.http.client import SimpleHttpClient
from synapse.http.server import ( from synapse.http.server import (
DirectServeHtmlResource, DirectServeHtmlResource,
@ -57,6 +58,8 @@ This package defines the 'stable' API which can be used by extension modules whi
are loaded into Synapse. are loaded into Synapse.
""" """
PRESENCE_ALL_USERS = PresenceRouter.ALL_USERS
__all__ = [ __all__ = [
"errors", "errors",
"make_deferred_yieldable", "make_deferred_yieldable",
@ -70,6 +73,7 @@ __all__ = [
"DirectServeHtmlResource", "DirectServeHtmlResource",
"DirectServeJsonResource", "DirectServeJsonResource",
"ModuleApi", "ModuleApi",
"PRESENCE_ALL_USERS",
] ]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -111,6 +115,7 @@ class ModuleApi:
self._spam_checker = hs.get_spam_checker() self._spam_checker = hs.get_spam_checker()
self._account_validity_handler = hs.get_account_validity_handler() self._account_validity_handler = hs.get_account_validity_handler()
self._third_party_event_rules = hs.get_third_party_event_rules() self._third_party_event_rules = hs.get_third_party_event_rules()
self._presence_router = hs.get_presence_router()
################################################################################# #################################################################################
# The following methods should only be called during the module's initialisation. # The following methods should only be called during the module's initialisation.
@ -130,6 +135,11 @@ class ModuleApi:
"""Registers callbacks for third party event rules capabilities.""" """Registers callbacks for third party event rules capabilities."""
return self._third_party_event_rules.register_third_party_rules_callbacks return self._third_party_event_rules.register_third_party_rules_callbacks
@property
def register_presence_router_callbacks(self):
"""Registers callbacks for presence router capabilities."""
return self._presence_router.register_presence_router_callbacks
def register_web_resource(self, path: str, resource: IResource): def register_web_resource(self, path: str, resource: IResource):
"""Registers a web resource to be served at the given path. """Registers a web resource to be served at the given path.

View File

@ -17,7 +17,7 @@ from unittest.mock import Mock
import attr import attr
from synapse.api.constants import EduTypes from synapse.api.constants import EduTypes
from synapse.events.presence_router import PresenceRouter from synapse.events.presence_router import PresenceRouter, load_legacy_presence_router
from synapse.federation.units import Transaction from synapse.federation.units import Transaction
from synapse.handlers.presence import UserPresenceState from synapse.handlers.presence import UserPresenceState
from synapse.module_api import ModuleApi from synapse.module_api import ModuleApi
@ -34,7 +34,7 @@ class PresenceRouterTestConfig:
users_who_should_receive_all_presence = attr.ib(type=List[str], default=[]) users_who_should_receive_all_presence = attr.ib(type=List[str], default=[])
class PresenceRouterTestModule: class LegacyPresenceRouterTestModule:
def __init__(self, config: PresenceRouterTestConfig, module_api: ModuleApi): def __init__(self, config: PresenceRouterTestConfig, module_api: ModuleApi):
self._config = config self._config = config
self._module_api = module_api self._module_api = module_api
@ -77,6 +77,53 @@ class PresenceRouterTestModule:
return config return config
class PresenceRouterTestModule:
def __init__(self, config: PresenceRouterTestConfig, api: ModuleApi):
self._config = config
self._module_api = api
api.register_presence_router_callbacks(
get_users_for_states=self.get_users_for_states,
get_interested_users=self.get_interested_users,
)
async def get_users_for_states(
self, state_updates: Iterable[UserPresenceState]
) -> Dict[str, Set[UserPresenceState]]:
users_to_state = {
user_id: set(state_updates)
for user_id in self._config.users_who_should_receive_all_presence
}
return users_to_state
async def get_interested_users(
self, user_id: str
) -> Union[Set[str], PresenceRouter.ALL_USERS]:
if user_id in self._config.users_who_should_receive_all_presence:
return PresenceRouter.ALL_USERS
return set()
@staticmethod
def parse_config(config_dict: dict) -> PresenceRouterTestConfig:
"""Parse a configuration dictionary from the homeserver config, do
some validation and return a typed PresenceRouterConfig.
Args:
config_dict: The configuration dictionary.
Returns:
A validated config object.
"""
# Initialise a typed config object
config = PresenceRouterTestConfig()
config.users_who_should_receive_all_presence = config_dict.get(
"users_who_should_receive_all_presence"
)
return config
class PresenceRouterTestCase(FederatingHomeserverTestCase): class PresenceRouterTestCase(FederatingHomeserverTestCase):
servlets = [ servlets = [
admin.register_servlets, admin.register_servlets,
@ -86,9 +133,17 @@ class PresenceRouterTestCase(FederatingHomeserverTestCase):
] ]
def make_homeserver(self, reactor, clock): def make_homeserver(self, reactor, clock):
return self.setup_test_homeserver( hs = self.setup_test_homeserver(
federation_transport_client=Mock(spec=["send_transaction"]), federation_transport_client=Mock(spec=["send_transaction"]),
) )
# Load the modules into the homeserver
module_api = hs.get_module_api()
for module, config in hs.config.modules.loaded_modules:
module(config=config, api=module_api)
load_legacy_presence_router(hs)
return hs
def prepare(self, reactor, clock, homeserver): def prepare(self, reactor, clock, homeserver):
self.sync_handler = self.hs.get_sync_handler() self.sync_handler = self.hs.get_sync_handler()
@ -98,7 +153,7 @@ class PresenceRouterTestCase(FederatingHomeserverTestCase):
{ {
"presence": { "presence": {
"presence_router": { "presence_router": {
"module": __name__ + ".PresenceRouterTestModule", "module": __name__ + ".LegacyPresenceRouterTestModule",
"config": { "config": {
"users_who_should_receive_all_presence": [ "users_who_should_receive_all_presence": [
"@presence_gobbler:test", "@presence_gobbler:test",
@ -109,7 +164,28 @@ class PresenceRouterTestCase(FederatingHomeserverTestCase):
"send_federation": True, "send_federation": True,
} }
) )
def test_receiving_all_presence_legacy(self):
self.receiving_all_presence_test_body()
@override_config(
{
"modules": [
{
"module": __name__ + ".PresenceRouterTestModule",
"config": {
"users_who_should_receive_all_presence": [
"@presence_gobbler:test",
]
},
},
],
"send_federation": True,
}
)
def test_receiving_all_presence(self): def test_receiving_all_presence(self):
self.receiving_all_presence_test_body()
def receiving_all_presence_test_body(self):
"""Test that a user that does not share a room with another other can receive """Test that a user that does not share a room with another other can receive
presence for them, due to presence routing. presence for them, due to presence routing.
""" """
@ -203,7 +279,7 @@ class PresenceRouterTestCase(FederatingHomeserverTestCase):
{ {
"presence": { "presence": {
"presence_router": { "presence_router": {
"module": __name__ + ".PresenceRouterTestModule", "module": __name__ + ".LegacyPresenceRouterTestModule",
"config": { "config": {
"users_who_should_receive_all_presence": [ "users_who_should_receive_all_presence": [
"@presence_gobbler1:test", "@presence_gobbler1:test",
@ -216,7 +292,30 @@ class PresenceRouterTestCase(FederatingHomeserverTestCase):
"send_federation": True, "send_federation": True,
} }
) )
def test_send_local_online_presence_to_with_module_legacy(self):
self.send_local_online_presence_to_with_module_test_body()
@override_config(
{
"modules": [
{
"module": __name__ + ".PresenceRouterTestModule",
"config": {
"users_who_should_receive_all_presence": [
"@presence_gobbler1:test",
"@presence_gobbler2:test",
"@far_away_person:island",
]
},
},
],
"send_federation": True,
}
)
def test_send_local_online_presence_to_with_module(self): def test_send_local_online_presence_to_with_module(self):
self.send_local_online_presence_to_with_module_test_body()
def send_local_online_presence_to_with_module_test_body(self):
"""Tests that send_local_presence_to_users sends local online presence to a set """Tests that send_local_presence_to_users sends local online presence to a set
of specified local and remote users, with a custom PresenceRouter module enabled. of specified local and remote users, with a custom PresenceRouter module enabled.
""" """