Add a module callback to react to account data changes (#12327)

Co-authored-by: Sean Quah <8349537+squahtx@users.noreply.github.com>
Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
pull/12348/head
Brendan Abolivier 2022-04-01 11:22:48 +02:00 committed by GitHub
parent 4e900ece42
commit e4409301ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 250 additions and 2 deletions

View File

@ -0,0 +1 @@
Add a module callback to react to account data changes.

View File

@ -45,6 +45,7 @@
- [Account validity callbacks](modules/account_validity_callbacks.md) - [Account validity callbacks](modules/account_validity_callbacks.md)
- [Password auth provider callbacks](modules/password_auth_provider_callbacks.md) - [Password auth provider callbacks](modules/password_auth_provider_callbacks.md)
- [Background update controller callbacks](modules/background_update_controller_callbacks.md) - [Background update controller callbacks](modules/background_update_controller_callbacks.md)
- [Account data callbacks](modules/account_data_callbacks.md)
- [Porting a legacy module to the new interface](modules/porting_legacy_module.md) - [Porting a legacy module to the new interface](modules/porting_legacy_module.md)
- [Workers](workers.md) - [Workers](workers.md)
- [Using `synctl` with Workers](synctl_workers.md) - [Using `synctl` with Workers](synctl_workers.md)

View File

@ -0,0 +1,106 @@
# Account data callbacks
Account data callbacks allow module developers to react to changes of the account data
of local users. Account data callbacks can be registered using the module API's
`register_account_data_callbacks` method.
## Callbacks
The available account data callbacks are:
### `on_account_data_updated`
_First introduced in Synapse v1.57.0_
```python
async def on_account_data_updated(
user_id: str,
room_id: Optional[str],
account_data_type: str,
content: "synapse.module_api.JsonDict",
) -> None:
```
Called after user's account data has been updated. The module is given the
Matrix ID of the user whose account data is changing, the room ID the data is associated
with, the type associated with the change, as well as the new content. If the account
data is not associated with a specific room, then the room ID is `None`.
This callback is triggered when new account data is added or when the data associated with
a given type (and optionally room) changes. This includes deletion, since in Matrix,
deleting account data consists of replacing the data associated with a given type
(and optionally room) with an empty dictionary (`{}`).
Note that this doesn't trigger when changing the tags associated with a room, as these are
processed separately by Synapse.
If multiple modules implement this callback, Synapse runs them all in order.
## Example
The example below is a module that implements the `on_account_data_updated` callback, and
sends an event to an audit room when a user changes their account data.
```python
import json
import attr
from typing import Any, Dict, Optional
from synapse.module_api import JsonDict, ModuleApi
from synapse.module_api.errors import ConfigError
@attr.s(auto_attribs=True)
class CustomAccountDataConfig:
audit_room: str
sender: str
class CustomAccountDataModule:
def __init__(self, config: CustomAccountDataConfig, api: ModuleApi):
self.api = api
self.config = config
self.api.register_account_data_callbacks(
on_account_data_updated=self.log_new_account_data,
)
@staticmethod
def parse_config(config: Dict[str, Any]) -> CustomAccountDataConfig:
def check_in_config(param: str):
if param not in config:
raise ConfigError(f"'{param}' is required")
check_in_config("audit_room")
check_in_config("sender")
return CustomAccountDataConfig(
audit_room=config["audit_room"],
sender=config["sender"],
)
async def log_new_account_data(
self,
user_id: str,
room_id: Optional[str],
account_data_type: str,
content: JsonDict,
) -> None:
content_raw = json.dumps(content)
msg_content = f"{user_id} has changed their account data for type {account_data_type} to: {content_raw}"
if room_id is not None:
msg_content += f" (in room {room_id})"
await self.api.create_and_send_event_into_room(
{
"room_id": self.config.audit_room,
"sender": self.config.sender,
"type": "m.room.message",
"content": {
"msgtype": "m.text",
"body": msg_content
}
}
)
```

View File

@ -33,7 +33,7 @@ A module can implement the following static method:
```python ```python
@staticmethod @staticmethod
def parse_config(config: dict) -> dict def parse_config(config: dict) -> Any
``` ```
This method is given a dictionary resulting from parsing the YAML configuration for the This method is given a dictionary resulting from parsing the YAML configuration for the

View File

@ -12,8 +12,9 @@
# 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
import random import random
from typing import TYPE_CHECKING, Collection, List, Optional, Tuple from typing import TYPE_CHECKING, Awaitable, Callable, Collection, List, Optional, Tuple
from synapse.replication.http.account_data import ( from synapse.replication.http.account_data import (
ReplicationAddTagRestServlet, ReplicationAddTagRestServlet,
@ -27,6 +28,12 @@ from synapse.types import JsonDict, UserID
if TYPE_CHECKING: if TYPE_CHECKING:
from synapse.server import HomeServer from synapse.server import HomeServer
logger = logging.getLogger(__name__)
ON_ACCOUNT_DATA_UPDATED_CALLBACK = Callable[
[str, Optional[str], str, JsonDict], Awaitable
]
class AccountDataHandler: class AccountDataHandler:
def __init__(self, hs: "HomeServer"): def __init__(self, hs: "HomeServer"):
@ -40,6 +47,44 @@ class AccountDataHandler:
self._remove_tag_client = ReplicationRemoveTagRestServlet.make_client(hs) self._remove_tag_client = ReplicationRemoveTagRestServlet.make_client(hs)
self._account_data_writers = hs.config.worker.writers.account_data self._account_data_writers = hs.config.worker.writers.account_data
self._on_account_data_updated_callbacks: List[
ON_ACCOUNT_DATA_UPDATED_CALLBACK
] = []
def register_module_callbacks(
self, on_account_data_updated: Optional[ON_ACCOUNT_DATA_UPDATED_CALLBACK] = None
) -> None:
"""Register callbacks from modules."""
if on_account_data_updated is not None:
self._on_account_data_updated_callbacks.append(on_account_data_updated)
async def _notify_modules(
self,
user_id: str,
room_id: Optional[str],
account_data_type: str,
content: JsonDict,
) -> None:
"""Notifies modules about new account data changes.
A change can be either a new account data type being added, or the content
associated with a type being changed. Account data for a given type is removed by
changing the associated content to an empty dictionary.
Note that this is not called when the tags associated with a room change.
Args:
user_id: The user whose account data is changing.
room_id: The ID of the room the account data change concerns, if any.
account_data_type: The type of the account data.
content: The content that is now associated with this type.
"""
for callback in self._on_account_data_updated_callbacks:
try:
await callback(user_id, room_id, account_data_type, content)
except Exception as e:
logger.exception("Failed to run module callback %s: %s", callback, e)
async def add_account_data_to_room( async def add_account_data_to_room(
self, user_id: str, room_id: str, account_data_type: str, content: JsonDict self, user_id: str, room_id: str, account_data_type: str, content: JsonDict
) -> int: ) -> int:
@ -63,6 +108,8 @@ class AccountDataHandler:
"account_data_key", max_stream_id, users=[user_id] "account_data_key", max_stream_id, users=[user_id]
) )
await self._notify_modules(user_id, room_id, account_data_type, content)
return max_stream_id return max_stream_id
else: else:
response = await self._room_data_client( response = await self._room_data_client(
@ -96,6 +143,9 @@ class AccountDataHandler:
self._notifier.on_new_event( self._notifier.on_new_event(
"account_data_key", max_stream_id, users=[user_id] "account_data_key", max_stream_id, users=[user_id]
) )
await self._notify_modules(user_id, None, account_data_type, content)
return max_stream_id return max_stream_id
else: else:
response = await self._user_data_client( response = await self._user_data_client(

View File

@ -65,6 +65,7 @@ from synapse.events.third_party_rules import (
ON_THREEPID_BIND_CALLBACK, ON_THREEPID_BIND_CALLBACK,
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK, ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK,
) )
from synapse.handlers.account_data import ON_ACCOUNT_DATA_UPDATED_CALLBACK
from synapse.handlers.account_validity import ( from synapse.handlers.account_validity import (
IS_USER_EXPIRED_CALLBACK, IS_USER_EXPIRED_CALLBACK,
ON_LEGACY_ADMIN_REQUEST, ON_LEGACY_ADMIN_REQUEST,
@ -216,6 +217,7 @@ class ModuleApi:
self._third_party_event_rules = hs.get_third_party_event_rules() self._third_party_event_rules = hs.get_third_party_event_rules()
self._password_auth_provider = hs.get_password_auth_provider() self._password_auth_provider = hs.get_password_auth_provider()
self._presence_router = hs.get_presence_router() self._presence_router = hs.get_presence_router()
self._account_data_handler = hs.get_account_data_handler()
################################################################################# #################################################################################
# The following methods should only be called during the module's initialisation. # The following methods should only be called during the module's initialisation.
@ -376,6 +378,19 @@ class ModuleApi:
min_batch_size=min_batch_size, min_batch_size=min_batch_size,
) )
def register_account_data_callbacks(
self,
*,
on_account_data_updated: Optional[ON_ACCOUNT_DATA_UPDATED_CALLBACK] = None,
) -> None:
"""Registers account data callbacks.
Added in Synapse 1.57.0.
"""
return self._account_data_handler.register_module_callbacks(
on_account_data_updated=on_account_data_updated,
)
def register_web_resource(self, path: str, resource: Resource) -> None: def register_web_resource(self, path: str, resource: Resource) -> None:
"""Registers a web resource to be served at the given path. """Registers a web resource to be served at the given path.

View File

@ -0,0 +1,75 @@
# Copyright 2022 The Matrix.org Foundation C.I.C
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# 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 unittest.mock import Mock
from synapse.rest import admin
from synapse.rest.client import account_data, login, room
from tests import unittest
from tests.test_utils import make_awaitable
class AccountDataTestCase(unittest.HomeserverTestCase):
servlets = [
admin.register_servlets,
login.register_servlets,
room.register_servlets,
account_data.register_servlets,
]
def test_on_account_data_updated_callback(self) -> None:
"""Tests that the on_account_data_updated module callback is called correctly when
a user's account data changes.
"""
mocked_callback = Mock(return_value=make_awaitable(None))
self.hs.get_account_data_handler()._on_account_data_updated_callbacks.append(
mocked_callback
)
user_id = self.register_user("user", "password")
tok = self.login("user", "password")
account_data_type = "org.matrix.foo"
account_data_content = {"bar": "baz"}
# Change the user's global account data.
channel = self.make_request(
"PUT",
f"/user/{user_id}/account_data/{account_data_type}",
account_data_content,
access_token=tok,
)
# Test that the callback is called with the user ID, the new account data, and
# None as the room ID.
self.assertEqual(channel.code, 200, channel.result)
mocked_callback.assert_called_once_with(
user_id, None, account_data_type, account_data_content
)
# Change the user's room-specific account data.
room_id = self.helper.create_room_as(user_id, tok=tok)
channel = self.make_request(
"PUT",
f"/user/{user_id}/rooms/{room_id}/account_data/{account_data_type}",
account_data_content,
access_token=tok,
)
# Test that the callback is called with the user ID, the room ID and the new
# account data.
self.assertEqual(channel.code, 200, channel.result)
self.assertEqual(mocked_callback.call_count, 2)
mocked_callback.assert_called_with(
user_id, room_id, account_data_type, account_data_content
)