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

Commit e440930

Browse files
baboliviersquahtxanoadragon453
authored
Add a module callback to react to account data changes (#12327)
Co-authored-by: Sean Quah <[email protected]> Co-authored-by: Andrew Morgan <[email protected]>
1 parent 4e900ec commit e440930

File tree

7 files changed

+250
-2
lines changed

7 files changed

+250
-2
lines changed

changelog.d/12327.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a module callback to react to account data changes.

docs/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
- [Account validity callbacks](modules/account_validity_callbacks.md)
4646
- [Password auth provider callbacks](modules/password_auth_provider_callbacks.md)
4747
- [Background update controller callbacks](modules/background_update_controller_callbacks.md)
48+
- [Account data callbacks](modules/account_data_callbacks.md)
4849
- [Porting a legacy module to the new interface](modules/porting_legacy_module.md)
4950
- [Workers](workers.md)
5051
- [Using `synctl` with Workers](synctl_workers.md)
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Account data callbacks
2+
3+
Account data callbacks allow module developers to react to changes of the account data
4+
of local users. Account data callbacks can be registered using the module API's
5+
`register_account_data_callbacks` method.
6+
7+
## Callbacks
8+
9+
The available account data callbacks are:
10+
11+
### `on_account_data_updated`
12+
13+
_First introduced in Synapse v1.57.0_
14+
15+
```python
16+
async def on_account_data_updated(
17+
user_id: str,
18+
room_id: Optional[str],
19+
account_data_type: str,
20+
content: "synapse.module_api.JsonDict",
21+
) -> None:
22+
```
23+
24+
Called after user's account data has been updated. The module is given the
25+
Matrix ID of the user whose account data is changing, the room ID the data is associated
26+
with, the type associated with the change, as well as the new content. If the account
27+
data is not associated with a specific room, then the room ID is `None`.
28+
29+
This callback is triggered when new account data is added or when the data associated with
30+
a given type (and optionally room) changes. This includes deletion, since in Matrix,
31+
deleting account data consists of replacing the data associated with a given type
32+
(and optionally room) with an empty dictionary (`{}`).
33+
34+
Note that this doesn't trigger when changing the tags associated with a room, as these are
35+
processed separately by Synapse.
36+
37+
If multiple modules implement this callback, Synapse runs them all in order.
38+
39+
## Example
40+
41+
The example below is a module that implements the `on_account_data_updated` callback, and
42+
sends an event to an audit room when a user changes their account data.
43+
44+
```python
45+
import json
46+
import attr
47+
from typing import Any, Dict, Optional
48+
49+
from synapse.module_api import JsonDict, ModuleApi
50+
from synapse.module_api.errors import ConfigError
51+
52+
53+
@attr.s(auto_attribs=True)
54+
class CustomAccountDataConfig:
55+
audit_room: str
56+
sender: str
57+
58+
59+
class CustomAccountDataModule:
60+
def __init__(self, config: CustomAccountDataConfig, api: ModuleApi):
61+
self.api = api
62+
self.config = config
63+
64+
self.api.register_account_data_callbacks(
65+
on_account_data_updated=self.log_new_account_data,
66+
)
67+
68+
@staticmethod
69+
def parse_config(config: Dict[str, Any]) -> CustomAccountDataConfig:
70+
def check_in_config(param: str):
71+
if param not in config:
72+
raise ConfigError(f"'{param}' is required")
73+
74+
check_in_config("audit_room")
75+
check_in_config("sender")
76+
77+
return CustomAccountDataConfig(
78+
audit_room=config["audit_room"],
79+
sender=config["sender"],
80+
)
81+
82+
async def log_new_account_data(
83+
self,
84+
user_id: str,
85+
room_id: Optional[str],
86+
account_data_type: str,
87+
content: JsonDict,
88+
) -> None:
89+
content_raw = json.dumps(content)
90+
msg_content = f"{user_id} has changed their account data for type {account_data_type} to: {content_raw}"
91+
92+
if room_id is not None:
93+
msg_content += f" (in room {room_id})"
94+
95+
await self.api.create_and_send_event_into_room(
96+
{
97+
"room_id": self.config.audit_room,
98+
"sender": self.config.sender,
99+
"type": "m.room.message",
100+
"content": {
101+
"msgtype": "m.text",
102+
"body": msg_content
103+
}
104+
}
105+
)
106+
```

docs/modules/writing_a_module.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ A module can implement the following static method:
3333

3434
```python
3535
@staticmethod
36-
def parse_config(config: dict) -> dict
36+
def parse_config(config: dict) -> Any
3737
```
3838

3939
This method is given a dictionary resulting from parsing the YAML configuration for the

synapse/handlers/account_data.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
15+
import logging
1516
import random
16-
from typing import TYPE_CHECKING, Collection, List, Optional, Tuple
17+
from typing import TYPE_CHECKING, Awaitable, Callable, Collection, List, Optional, Tuple
1718

1819
from synapse.replication.http.account_data import (
1920
ReplicationAddTagRestServlet,
@@ -27,6 +28,12 @@
2728
if TYPE_CHECKING:
2829
from synapse.server import HomeServer
2930

31+
logger = logging.getLogger(__name__)
32+
33+
ON_ACCOUNT_DATA_UPDATED_CALLBACK = Callable[
34+
[str, Optional[str], str, JsonDict], Awaitable
35+
]
36+
3037

3138
class AccountDataHandler:
3239
def __init__(self, hs: "HomeServer"):
@@ -40,6 +47,44 @@ def __init__(self, hs: "HomeServer"):
4047
self._remove_tag_client = ReplicationRemoveTagRestServlet.make_client(hs)
4148
self._account_data_writers = hs.config.worker.writers.account_data
4249

50+
self._on_account_data_updated_callbacks: List[
51+
ON_ACCOUNT_DATA_UPDATED_CALLBACK
52+
] = []
53+
54+
def register_module_callbacks(
55+
self, on_account_data_updated: Optional[ON_ACCOUNT_DATA_UPDATED_CALLBACK] = None
56+
) -> None:
57+
"""Register callbacks from modules."""
58+
if on_account_data_updated is not None:
59+
self._on_account_data_updated_callbacks.append(on_account_data_updated)
60+
61+
async def _notify_modules(
62+
self,
63+
user_id: str,
64+
room_id: Optional[str],
65+
account_data_type: str,
66+
content: JsonDict,
67+
) -> None:
68+
"""Notifies modules about new account data changes.
69+
70+
A change can be either a new account data type being added, or the content
71+
associated with a type being changed. Account data for a given type is removed by
72+
changing the associated content to an empty dictionary.
73+
74+
Note that this is not called when the tags associated with a room change.
75+
76+
Args:
77+
user_id: The user whose account data is changing.
78+
room_id: The ID of the room the account data change concerns, if any.
79+
account_data_type: The type of the account data.
80+
content: The content that is now associated with this type.
81+
"""
82+
for callback in self._on_account_data_updated_callbacks:
83+
try:
84+
await callback(user_id, room_id, account_data_type, content)
85+
except Exception as e:
86+
logger.exception("Failed to run module callback %s: %s", callback, e)
87+
4388
async def add_account_data_to_room(
4489
self, user_id: str, room_id: str, account_data_type: str, content: JsonDict
4590
) -> int:
@@ -63,6 +108,8 @@ async def add_account_data_to_room(
63108
"account_data_key", max_stream_id, users=[user_id]
64109
)
65110

111+
await self._notify_modules(user_id, room_id, account_data_type, content)
112+
66113
return max_stream_id
67114
else:
68115
response = await self._room_data_client(
@@ -96,6 +143,9 @@ async def add_account_data_for_user(
96143
self._notifier.on_new_event(
97144
"account_data_key", max_stream_id, users=[user_id]
98145
)
146+
147+
await self._notify_modules(user_id, None, account_data_type, content)
148+
99149
return max_stream_id
100150
else:
101151
response = await self._user_data_client(

synapse/module_api/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
ON_THREEPID_BIND_CALLBACK,
6666
ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK,
6767
)
68+
from synapse.handlers.account_data import ON_ACCOUNT_DATA_UPDATED_CALLBACK
6869
from synapse.handlers.account_validity import (
6970
IS_USER_EXPIRED_CALLBACK,
7071
ON_LEGACY_ADMIN_REQUEST,
@@ -216,6 +217,7 @@ def __init__(self, hs: "HomeServer", auth_handler: AuthHandler) -> None:
216217
self._third_party_event_rules = hs.get_third_party_event_rules()
217218
self._password_auth_provider = hs.get_password_auth_provider()
218219
self._presence_router = hs.get_presence_router()
220+
self._account_data_handler = hs.get_account_data_handler()
219221

220222
#################################################################################
221223
# The following methods should only be called during the module's initialisation.
@@ -376,6 +378,19 @@ def register_background_update_controller_callbacks(
376378
min_batch_size=min_batch_size,
377379
)
378380

381+
def register_account_data_callbacks(
382+
self,
383+
*,
384+
on_account_data_updated: Optional[ON_ACCOUNT_DATA_UPDATED_CALLBACK] = None,
385+
) -> None:
386+
"""Registers account data callbacks.
387+
388+
Added in Synapse 1.57.0.
389+
"""
390+
return self._account_data_handler.register_module_callbacks(
391+
on_account_data_updated=on_account_data_updated,
392+
)
393+
379394
def register_web_resource(self, path: str, resource: Resource) -> None:
380395
"""Registers a web resource to be served at the given path.
381396
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Copyright 2022 The Matrix.org Foundation C.I.C
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
from unittest.mock import Mock
15+
16+
from synapse.rest import admin
17+
from synapse.rest.client import account_data, login, room
18+
19+
from tests import unittest
20+
from tests.test_utils import make_awaitable
21+
22+
23+
class AccountDataTestCase(unittest.HomeserverTestCase):
24+
servlets = [
25+
admin.register_servlets,
26+
login.register_servlets,
27+
room.register_servlets,
28+
account_data.register_servlets,
29+
]
30+
31+
def test_on_account_data_updated_callback(self) -> None:
32+
"""Tests that the on_account_data_updated module callback is called correctly when
33+
a user's account data changes.
34+
"""
35+
mocked_callback = Mock(return_value=make_awaitable(None))
36+
self.hs.get_account_data_handler()._on_account_data_updated_callbacks.append(
37+
mocked_callback
38+
)
39+
40+
user_id = self.register_user("user", "password")
41+
tok = self.login("user", "password")
42+
account_data_type = "org.matrix.foo"
43+
account_data_content = {"bar": "baz"}
44+
45+
# Change the user's global account data.
46+
channel = self.make_request(
47+
"PUT",
48+
f"/user/{user_id}/account_data/{account_data_type}",
49+
account_data_content,
50+
access_token=tok,
51+
)
52+
53+
# Test that the callback is called with the user ID, the new account data, and
54+
# None as the room ID.
55+
self.assertEqual(channel.code, 200, channel.result)
56+
mocked_callback.assert_called_once_with(
57+
user_id, None, account_data_type, account_data_content
58+
)
59+
60+
# Change the user's room-specific account data.
61+
room_id = self.helper.create_room_as(user_id, tok=tok)
62+
channel = self.make_request(
63+
"PUT",
64+
f"/user/{user_id}/rooms/{room_id}/account_data/{account_data_type}",
65+
account_data_content,
66+
access_token=tok,
67+
)
68+
69+
# Test that the callback is called with the user ID, the room ID and the new
70+
# account data.
71+
self.assertEqual(channel.code, 200, channel.result)
72+
self.assertEqual(mocked_callback.call_count, 2)
73+
mocked_callback.assert_called_with(
74+
user_id, room_id, account_data_type, account_data_content
75+
)

0 commit comments

Comments
 (0)