Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

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

Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/10524.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Port the PresenceRouter module interface to the new generic interface.
43 changes: 43 additions & 0 deletions docs/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,49 @@ 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[UserPresenceState],
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved
) -> Dict[str, Set[UserPresenceState]]:
```
**Requires** `get_interested_users` to also be registered

An asynchronous method that is passed an iterable of user presence state. This method can
determine whether a given presence update should be sent to certain users. It does this by
returning a dictionary with keys representing local or remote Matrix User IDs, and values
being a python set of synapse.handlers.presence.UserPresenceState instances.

Synapse will then attempt to send the specified presence updates to each user when possible.
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved

```python
async def get_interested_users(self, user_id: str) -> Union[Set[str], str]
```
**Requires** `get_users_for_states` to also be registered

An asynchronous method that is passed a single Matrix User ID. This method is expected to
return the users that the passed in user may be interested in the presence of. Returned
users may be local or remote. The presence routed as a result of what this method returns
is sent in addition to the updates already sent between users that share a room together.
Presence updates are deduplicated.

This method should return a python set of Matrix User IDs, or the object
`synapse.events.presence_router.PresenceRouter.ALL_USERS` to indicate that the passed user
should receive presence information for all known users.

For clarity, if the user `@alice:example.org` is passed to this method, and the Set
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved
`{"@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

Expand Down
17 changes: 9 additions & 8 deletions docs/presence_router_module.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,15 @@ class ExamplePresenceRouter:

Args:
config: A configuration object.
module_api: An instance of Synapse's ModuleApi.
api: An instance of Synapse's ModuleApi.
"""
def __init__(self, config: PresenceRouterConfig, module_api: ModuleApi):
def __init__(self, config: PresenceRouterConfig, api: ModuleApi):
self._config = config
self._module_api = module_api
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,
)

@staticmethod
def parse_config(config_dict: dict) -> PresenceRouterConfig:
Expand Down Expand Up @@ -221,11 +225,8 @@ Once you've crafted your module and installed it into the same Python environmen
Synapse, amend your homeserver config file with the following.

```yaml
presence:
enabled: true

presence_router:
module: my_module.ExamplePresenceRouter
modules:
- module: my_module.ExamplePresenceRouter
config:
# Any configuration options for your module. The below is an example.
# of setting options for ExamplePresenceRouter.
Expand Down
14 changes: 0 additions & 14 deletions docs/sample_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions synapse/app/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
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
Expand Down Expand Up @@ -370,6 +371,7 @@ def run_sighup(*args, **kwargs):

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)
Expand Down
15 changes: 1 addition & 14 deletions synapse/config/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ def read_config(self, config, **kwargs):
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")
Expand Down Expand Up @@ -858,20 +859,6 @@ def generate_config_section(
#
#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
Expand Down
168 changes: 135 additions & 33 deletions synapse/events/presence_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,45 +11,110 @@
# 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
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 = Callable[
[Iterable[UserPresenceState]], Awaitable[Dict[str, Set[UserPresenceState]]]
]
GET_INTERESTED_USERS = Callable[[str], Awaitable[Union[Set[str], str]]]
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved


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.
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved
"""

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] = []
self._get_interested_users_callbacks: List[GET_INTERESTED_USERS] = []

# 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] = None,
get_interested_users: Optional[GET_INTERESTED_USERS] = 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 Exception(
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved
"PresenceRouter modules must register neither or both of the paired callbacks: "
"[get_users_for_states, get_interested_users]"
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved
)

# 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,
Expand All @@ -66,14 +131,33 @@ async def get_users_for_states(
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

# Bail out early without if we don't have any callbacks to run.
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved
if len(self._get_users_for_states_callbacks) == 0:
# Don't include any extra destinations for presence updates
return {}

# If there are multiple callbacks for get_users_for_state then we want to
# return ALL of the extra destinations, this method joins two sets of extra
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved
# destinations into one
def combine(
dict1: Dict[str, Set[UserPresenceState]],
dict2: Dict[str, Set[UserPresenceState]],
) -> Dict[str, Set[UserPresenceState]]:
for key, new_entries in dict2.items():
old_entries = dict1.get(key, set())
dict1[key] = old_entries.union(new_entries)

return dict1

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:
users_for_states = combine(
users_for_states, await callback(state_updates=state_updates)
babolivier marked this conversation as resolved.
Show resolved Hide resolved
)

# Don't include any extra destinations for presence updates
return {}
return users_for_states

async def get_interested_users(self, user_id: str) -> Union[Set[str], ALL_USERS]:
"""
Expand All @@ -92,12 +176,30 @@ async def get_interested_users(self, user_id: str) -> Union[Set[str], ALL_USERS]
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:

# 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()

# If there are multiple callbacks for get_interested_users then we want to
# return ALL of the users, this method joins two sets of users into one
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved
def combine(
set1: Union[Set[str], str],
set2: Union[Set[str], str],
) -> Union[Set[str], str]:
# if one of the two sets is ALL_USERS then the union is also ALL_USERS
if set1 == PresenceRouter.ALL_USERS or set2 == PresenceRouter.ALL_USERS:
return PresenceRouter.ALL_USERS
else:
return set1.union(set2)

interested_users = set()
# run all the callbacks for get_interested_users and combine the results
for callback in self._get_interested_users_callbacks:
# Ask the custom module for interested users
return await self.custom_presence_router.get_interested_users(
user_id=user_id
interested_users = combine(
interested_users, await callback(user_id=user_id)
)
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved

# A custom presence router is not defined.
# Don't report any additional interested users
return set()
return interested_users
6 changes: 6 additions & 0 deletions synapse/module_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def __init__(self, hs: "HomeServer", auth_handler):
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.
Expand All @@ -130,6 +131,11 @@ def register_third_party_rules_callbacks(self):
"""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.

Expand Down
Loading