diff --git a/README.md b/README.md index 57086bc..81c7651 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Push rules patcher -Synapse module to create specific push rules when a new user registers +Synapse module to change the actions of specific push rules when a new user registers. ## Installation @@ -17,7 +17,22 @@ Then alter your homeserver configuration, adding to your `modules` configuration modules: - module: synapse_patch_push_rules.PushRulesPatcher config: - # TODO: Complete this section with an example for your module + # Rules to change with new actions when new users register. + # Required. + rules: + # The rule ID. Must be one of the predefined rules defined in the Matrix + # specification. See https://spec.matrix.org/latest/client-server-api/#predefined-rules + # for a complete list. + ".m.rule.message": + # The kind (override, underride or content) of the rule being modified. + # Required. + kind: "underride" + # The new actions for this rule. + # See https://spec.matrix.org/latest/client-server-api/#push-rules for a + # reference on the allowed values and format. + # Required. + actions: + - "dont_notify" ``` @@ -75,11 +90,7 @@ Synapse developers (assuming a Unix-like shell): git push origin tag v$version ``` - 7. If applicable: - Create a *release*, based on the tag you just pushed, on GitHub or GitLab. - - 8. If applicable: - Create a source distribution and upload it to PyPI: + 7. Create a source distribution and upload it to PyPI: ```shell python -m build twine upload dist/synapse_patch_push_rules-$version* diff --git a/setup.cfg b/setup.cfg index 17ea243..d810bd8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,9 +29,9 @@ dev = twisted aiounittest # for type checking - mypy == 0.910 + mypy == 0.931 # for linting - black == 21.9b0 + black == 22.3.0 flake8 == 4.0.1 isort == 5.9.3 diff --git a/synapse_patch_push_rules/__init__.py b/synapse_patch_push_rules/__init__.py new file mode 100644 index 0000000..7469fa9 --- /dev/null +++ b/synapse_patch_push_rules/__init__.py @@ -0,0 +1,91 @@ +# 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. +import logging +from typing import Any, Dict, List, Union + +import attr +from synapse.module_api import ModuleApi +from synapse.module_api.errors import ConfigError, InvalidRuleException + +logger = logging.getLogger(__name__) + + +@attr.s(auto_attribs=True, frozen=True) +class PushRule: + kind: str + actions: List[Union[str, Dict[str, str]]] + + +class PushRulesPatcherConfig: + def __init__(self, config: Dict[str, Any]) -> None: + self.rules: Dict[str, PushRule] = {} + for rule_id, rule in config["rules"].items(): + self.rules[rule_id] = PushRule(**rule) + + +class PushRulesPatcher: + def __init__(self, config: PushRulesPatcherConfig, api: ModuleApi): + # Keep a reference to the config and Module API + self._api = api + self._config = config + + # Check that the actions are valid. + for rule_id, rule in self._config.rules.items(): + if not rule_id.startswith("."): + raise ConfigError( + "Only the rules predefined in the Matrix specification are supported" + " by this module. See https://spec.matrix.org/latest/client-server-api/#predefined-rules" + " for a complete list." + ) + + try: + self._api.check_push_rule_actions(rule.actions) + except InvalidRuleException as e: + raise ConfigError("Invalid actions for rule %s: %s" % (rule_id, e)) + + self._api.register_account_validity_callbacks( + on_user_registration=self.set_push_rules_for_user, + ) + + @staticmethod + def parse_config(config: Dict[str, Any]) -> PushRulesPatcherConfig: + if "rules" not in config: + raise ConfigError("Missing 'rules' in module configuration") + + if not isinstance(config["rules"], dict): + raise ConfigError("'rules' must be a dictionary") + + return PushRulesPatcherConfig(config) + + async def set_push_rules_for_user(self, user_id: str) -> None: + """Create new push rules for the given user. + Implements the on_user_registration account validity callback. + """ + # If we're running on a worker, make this a noop. on_user_registration callbacks + # should only be called on the main process, so this is mostly a stopgap in case + # something changes in the future. + if self._api.worker_app: + logger.warning( + "Attempted to run callback 'set_push_rules_for_user' on a worker, aborting" + ) + return + + for rule_id, rule in self._config.rules.items(): + await self._api.set_push_rule_action( + user_id=user_id, + scope="global", + kind=rule.kind, + rule_id=rule_id, + actions=rule.actions, + ) diff --git a/synapse_patch_push_rules/py.typed b/synapse_patch_push_rules/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..f32da25 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,49 @@ +# 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 asyncio import Future +from typing import Any, Awaitable, Dict, Optional, TypeVar +from unittest.mock import Mock + +from synapse.module_api import ModuleApi + +from synapse_patch_push_rules import PushRulesPatcher + +TV = TypeVar("TV") + + +def make_awaitable(result: TV) -> Awaitable[TV]: + """ + Makes an awaitable, suitable for mocking an `async` function. + This uses Futures as they can be awaited multiple times so can be returned + to multiple callers. + """ + future = Future() # type: ignore + future.set_result(result) + return future + + +def create_module(config: Optional[Dict[str, Any]] = None) -> PushRulesPatcher: + # Create a mock based on the ModuleApi spec, but override some mocked functions + # because some capabilities are needed for running the tests. + module_api = Mock(spec=ModuleApi) + module_api.set_push_rule_action = Mock(return_value=make_awaitable(None)) + module_api.worker_app = None + module_api.check_push_rule_actions = Mock(return_value=True) + + # If necessary, give parse_config some configuration to parse. + if config is None: + config = {} + parsed_config = PushRulesPatcher.parse_config(config) + + return PushRulesPatcher(parsed_config, module_api) diff --git a/tests/test_push_rules_patcher.py b/tests/test_push_rules_patcher.py new file mode 100644 index 0000000..8a89ab4 --- /dev/null +++ b/tests/test_push_rules_patcher.py @@ -0,0 +1,86 @@ +# 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 + +import aiounittest +from synapse.module_api.errors import ConfigError + +from tests import create_module + + +class PushRulesPatcherTestCase(aiounittest.AsyncTestCase): + async def test_no_rules(self) -> None: + """Tests that the module can't be created if 'rules' is missing from the + configuration. + """ + with self.assertRaises(ConfigError): + create_module() + + async def test_rules_no_dict(self) -> None: + """Tests that the module can't be created if the configured set of rules isn't a + dictionary. + """ + with self.assertRaises(ConfigError): + create_module({"rules": "hello"}) + + async def test_set_rule_actions(self) -> None: + """Tests that, when configuring the module with multiple rules and calling the + callback, each rule is changed for the newly registered user. + """ + rule_id1 = ".m.rule.message" + rule_id2 = ".m.rule.encrypted" + actions1 = ["dont_notify"] + actions2 = ["notify"] + kind = "underride" + user_id = "@user:example.com" + + # Configure the module with 2 different rules. + module = create_module( + { + "rules": { + rule_id1: { + "kind": kind, + "actions": actions1, + }, + rule_id2: { + "kind": kind, + "actions": actions2, + }, + } + } + ) + + # Run the callback. + await module.set_push_rules_for_user(user_id) + + # Check the module API got called twice, and with the right arguments. + set_push_rule_action_mock: Mock = module._api.set_push_rule_action # type: ignore[assignment] + + self.assertEqual(set_push_rule_action_mock.call_count, 2) + + set_push_rule_action_mock.assert_any_call( + user_id=user_id, + scope="global", + kind=kind, + rule_id=rule_id1, + actions=actions1, + ) + + set_push_rule_action_mock.assert_any_call( + user_id=user_id, + scope="global", + kind=kind, + rule_id=rule_id2, + actions=actions2, + )