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 10 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.
41 changes: 41 additions & 0 deletions docs/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,47 @@ 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]]:
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved
```
**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.
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved

Synapse will then attempt to send the specified presence updates to each user when possible.
```python
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved
async def get_interested_users(self, user_id: str) -> Union[Set[str], str]
```
**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 info
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved
may not be properly forwarded.

The callback is given a single Matrix user ID and should return a set of additional Matrix user
babolivier marked this conversation as resolved.
Show resolved Hide resolved
ID's who's presence state should be queried. The returned users can be local or remote.
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved

Alternatively the callback can return `synapse.events.presence_router.PresenceRouter.ALL_USERS`
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved
to indicate that the user should receive updates from 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
6 changes: 6 additions & 0 deletions docs/presence_router_module.md
Original file line number Diff line number Diff line change
@@ -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
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
194 changes: 158 additions & 36 deletions synapse/events/presence_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,45 +11,113 @@
# 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], str]]]

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.
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_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]"
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 +134,43 @@ 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
)

# Don't include any extra destinations for presence updates
return {}
# 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 {}

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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't realise you could do this, but it seems to work. With that said:

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
if key not in users_for_states:
users_for_states[key] = new_entries
else:
users_for_states[key].update(new_entries)
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved

return users_for_states

async def get_interested_users(self, user_id: str) -> Union[Set[str], ALL_USERS]:
"""
Expand All @@ -92,12 +189,37 @@ 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:
# 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:
interested_users = PresenceRouter.ALL_USERS
break
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved

if not isinstance(result, set):
Azrenbeth marked this conversation as resolved.
Show resolved Hide resolved
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
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