Port the PresenceRouter module interface to the new generic interface introduced in v1.37.0tags/v1.42.0rc1
@@ -0,0 +1 @@ | |||
Port the PresenceRouter module interface to the new generic interface. |
@@ -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`), | |||
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 | |||
@@ -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 | |||
Synapse supports configuring a module that can specify additional users | |||
@@ -108,20 +108,6 @@ presence: | |||
# | |||
#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, | |||
# display names) of other users through the client API. Defaults to | |||
# 'false'. Note that profile data is also available via the federation | |||
@@ -37,6 +37,7 @@ from synapse.app import check_bind_error | |||
from synapse.app.phone_stats_home import start_phone_stats_home | |||
from synapse.config.homeserver import HomeServerConfig | |||
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.third_party_rules import load_legacy_third_party_event_rules | |||
from synapse.logging.context import PreserveLoggingContext | |||
@@ -370,6 +371,7 @@ async def start(hs: "HomeServer"): | |||
load_legacy_spam_checkers(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. | |||
setup_expire_lru_cache_entries(hs) | |||
@@ -248,6 +248,7 @@ class ServerConfig(Config): | |||
self.use_presence = config.get("use_presence", 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 | |||
self.presence_router_config = None | |||
presence_router_config = presence_config.get("presence_router") | |||
@@ -858,20 +859,6 @@ class ServerConfig(Config): | |||
# | |||
#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, | |||
# display names) of other users through the client API. Defaults to | |||
# 'false'. Note that profile data is also available via the federation | |||
@@ -11,45 +11,115 @@ | |||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
# See the License for the specific language governing permissions and | |||
# limitations under the License. | |||
from typing import TYPE_CHECKING, Dict, Iterable, Set, Union | |||
import logging | |||
from typing import ( | |||
TYPE_CHECKING, | |||
Awaitable, | |||
Callable, | |||
Dict, | |||
Iterable, | |||
List, | |||
Optional, | |||
Set, | |||
Union, | |||
) | |||
from synapse.api.presence import UserPresenceState | |||
from synapse.util.async_helpers import maybe_awaitable | |||
if TYPE_CHECKING: | |||
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: | |||
""" | |||
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 | |||
passed to that instead. | |||
additional destinations. | |||
""" | |||
ALL_USERS = "ALL" | |||
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 | |||
if hs.config.presence_router_module_class: | |||
# Initialise the module | |||
self.custom_presence_router = hs.config.presence_router_module_class( | |||
config=hs.config.presence_router_config, module_api=hs.get_module_api() | |||
def register_presence_router_callbacks( | |||
self, | |||
get_users_for_states: Optional[GET_USERS_FOR_STATES_CALLBACK] = None, | |||
get_interested_users: Optional[GET_INTERESTED_USERS_CALLBACK] = None, | |||
): | |||
# 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 | |||
required_methods = ["get_users_for_states", "get_interested_users"] | |||
for method_name in required_methods: | |||
if not hasattr(self.custom_presence_router, method_name): | |||
raise Exception( | |||
"PresenceRouter module '%s' must implement all required methods: %s" | |||
% ( | |||
hs.config.presence_router_module_class.__name__, | |||
", ".join(required_methods), | |||
) | |||
) | |||
# Append the methods provided to the lists of callbacks | |||
if get_users_for_states is not None: | |||
self._get_users_for_states_callbacks.append(get_users_for_states) | |||
if get_interested_users is not None: | |||
self._get_interested_users_callbacks.append(get_interested_users) | |||
async def get_users_for_states( | |||
self, | |||
@@ -66,14 +136,40 @@ class PresenceRouter: | |||
A dictionary of user_id -> set of UserPresenceState, indicating which | |||
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 | |||
) | |||
# Don't include any extra destinations for presence updates | |||
return {} | |||
# 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 | |||
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]: | |||
""" | |||
@@ -92,12 +188,36 @@ class PresenceRouter: | |||
A set of user IDs to return presence updates for, or ALL_USERS to return all | |||
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. | |||
# Don't report any additional interested users | |||
return set() | |||
# 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 | |||
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 |
@@ -32,6 +32,7 @@ from twisted.internet import defer | |||
from twisted.web.resource import IResource | |||
from synapse.events import EventBase | |||
from synapse.events.presence_router import PresenceRouter | |||
from synapse.http.client import SimpleHttpClient | |||
from synapse.http.server import ( | |||
DirectServeHtmlResource, | |||
@@ -57,6 +58,8 @@ This package defines the 'stable' API which can be used by extension modules whi | |||
are loaded into Synapse. | |||
""" | |||
PRESENCE_ALL_USERS = PresenceRouter.ALL_USERS | |||
__all__ = [ | |||
"errors", | |||
"make_deferred_yieldable", | |||
@@ -70,6 +73,7 @@ __all__ = [ | |||
"DirectServeHtmlResource", | |||
"DirectServeJsonResource", | |||
"ModuleApi", | |||
"PRESENCE_ALL_USERS", | |||
] | |||
logger = logging.getLogger(__name__) | |||
@@ -111,6 +115,7 @@ class ModuleApi: | |||
self._spam_checker = hs.get_spam_checker() | |||
self._account_validity_handler = hs.get_account_validity_handler() | |||
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. | |||
@@ -130,6 +135,11 @@ class ModuleApi: | |||
"""Registers callbacks for third party event rules capabilities.""" | |||
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): | |||
"""Registers a web resource to be served at the given path. | |||
@@ -17,7 +17,7 @@ from unittest.mock import Mock | |||
import attr | |||
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.handlers.presence import UserPresenceState | |||
from synapse.module_api import ModuleApi | |||
@@ -34,7 +34,7 @@ class PresenceRouterTestConfig: | |||
users_who_should_receive_all_presence = attr.ib(type=List[str], default=[]) | |||
class PresenceRouterTestModule: | |||
class LegacyPresenceRouterTestModule: | |||
def __init__(self, config: PresenceRouterTestConfig, module_api: ModuleApi): | |||
self._config = config | |||
self._module_api = module_api | |||
@@ -77,6 +77,53 @@ class PresenceRouterTestModule: | |||
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): | |||
servlets = [ | |||
admin.register_servlets, | |||
@@ -86,9 +133,17 @@ class PresenceRouterTestCase(FederatingHomeserverTestCase): | |||
] | |||
def make_homeserver(self, reactor, clock): | |||
return self.setup_test_homeserver( | |||
hs = self.setup_test_homeserver( | |||
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): | |||
self.sync_handler = self.hs.get_sync_handler() | |||
@@ -98,7 +153,7 @@ class PresenceRouterTestCase(FederatingHomeserverTestCase): | |||
{ | |||
"presence": { | |||
"presence_router": { | |||
"module": __name__ + ".PresenceRouterTestModule", | |||
"module": __name__ + ".LegacyPresenceRouterTestModule", | |||
"config": { | |||
"users_who_should_receive_all_presence": [ | |||
"@presence_gobbler:test", | |||
@@ -109,7 +164,28 @@ class PresenceRouterTestCase(FederatingHomeserverTestCase): | |||
"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): | |||
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 | |||
presence for them, due to presence routing. | |||
""" | |||
@@ -203,7 +279,7 @@ class PresenceRouterTestCase(FederatingHomeserverTestCase): | |||
{ | |||
"presence": { | |||
"presence_router": { | |||
"module": __name__ + ".PresenceRouterTestModule", | |||
"module": __name__ + ".LegacyPresenceRouterTestModule", | |||
"config": { | |||
"users_who_should_receive_all_presence": [ | |||
"@presence_gobbler1:test", | |||
@@ -216,7 +292,30 @@ class PresenceRouterTestCase(FederatingHomeserverTestCase): | |||
"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): | |||
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 | |||
of specified local and remote users, with a custom PresenceRouter module enabled. | |||
""" | |||