Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
```


Expand Down Expand Up @@ -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*
Expand Down
4 changes: 2 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
91 changes: 91 additions & 0 deletions synapse_patch_push_rules/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
)
Empty file.
49 changes: 49 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
86 changes: 86 additions & 0 deletions tests/test_push_rules_patcher.py
Original file line number Diff line number Diff line change
@@ -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,
)