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

Implement an on_new_event callback #11126

Merged
merged 12 commits into from
Oct 26, 2021
1 change: 1 addition & 0 deletions changelog.d/11126.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add an `on_new_event` third-party rules callback to allow modules to listen on new events.
babolivier marked this conversation as resolved.
Show resolved Hide resolved
19 changes: 19 additions & 0 deletions docs/modules/third_party_rules_callbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,25 @@ callback returns `True`, Synapse falls through to the next one. The value of the
callback that does not return `True` will be used. If this happens, Synapse will not call
any of the subsequent implementations of this callback.

### `on_new_event`

```python
async def on_new_event(
event: "synapse.events.EventBase",
state_events: "synapse.types.StateMap",
) -> None:
```

Called after sending an event into a room. The module is passed the invite event, as well
babolivier marked this conversation as resolved.
Show resolved Hide resolved
as the state of the room _after_ the event. This means that if the event is a state event,
it will be included in this state.

Note that this callback is called when the event has already been processed and stored
into the room, which means this callback cannot be used to deny it. To deny an incoming
babolivier marked this conversation as resolved.
Show resolved Hide resolved
event, see [`check_event_for_spam`](http://localhost:3000/modules/spam_checker_callbacks.html#check_event_for_spam).
babolivier marked this conversation as resolved.
Show resolved Hide resolved

If multiple modules implement this callback, Synapse runs them all in order.
babolivier marked this conversation as resolved.
Show resolved Hide resolved

## Example

The example below is a module that implements the third-party rules callback
Expand Down
7 changes: 7 additions & 0 deletions synapse/api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,3 +596,10 @@ class ShadowBanError(Exception):

This should be caught and a proper "fake" success response sent to the user.
"""


class ModuleFailureError(Exception):
"""
Raised when a module raises an exception. If this is raised in the context of an
HTTP(S) request, it will translate into a 500 response with a generic Matrix error.
"""
babolivier marked this conversation as resolved.
Show resolved Hide resolved
31 changes: 30 additions & 1 deletion synapse/events/third_party_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import logging
from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Optional, Tuple

from synapse.api.errors import SynapseError
from synapse.api.errors import ModuleFailureError, SynapseError
from synapse.events import EventBase
from synapse.events.snapshot import EventContext
from synapse.types import Requester, StateMap
Expand All @@ -36,6 +36,7 @@
CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK = Callable[
[str, StateMap[EventBase], str], Awaitable[bool]
]
ON_NEW_EVENT_CALLBACK = Callable[[EventBase, StateMap[EventBase]], Awaitable]
squahtx marked this conversation as resolved.
Show resolved Hide resolved


def load_legacy_third_party_event_rules(hs: "HomeServer") -> None:
Expand Down Expand Up @@ -152,6 +153,7 @@ def __init__(self, hs: "HomeServer"):
self._check_visibility_can_be_modified_callbacks: List[
CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK
] = []
self._on_new_event_callbacks: List[ON_NEW_EVENT_CALLBACK] = []

def register_third_party_rules_callbacks(
self,
Expand All @@ -163,6 +165,7 @@ def register_third_party_rules_callbacks(
check_visibility_can_be_modified: Optional[
CHECK_VISIBILITY_CAN_BE_MODIFIED_CALLBACK
] = None,
on_new_event: Optional[ON_NEW_EVENT_CALLBACK] = None,
) -> None:
"""Register callbacks from modules for each hook."""
if check_event_allowed is not None:
Expand All @@ -181,6 +184,9 @@ def register_third_party_rules_callbacks(
check_visibility_can_be_modified,
)

if on_new_event is not None:
self._on_new_event_callbacks.append(on_new_event)

async def check_event_allowed(
self, event: EventBase, context: EventContext
) -> Tuple[bool, Optional[dict]]:
Expand Down Expand Up @@ -321,6 +327,29 @@ async def check_visibility_can_be_modified(

return True

async def on_new_event(self, event: EventBase) -> None:
"""Let modules act on events after they've been sent (e.g. auto-accepting
invites, etc.)

Args:
event: The invite event.
babolivier marked this conversation as resolved.
Show resolved Hide resolved

Raises:
ModuleFailureError if a callback raised any exception.
"""
# Bail out early without hitting the store if we don't have any callback
babolivier marked this conversation as resolved.
Show resolved Hide resolved
if len(self._on_new_event_callbacks) == 0:
return

state_events = await self._get_state_map_for_room(event.room_id)

for callback in self._on_new_event_callbacks:
try:
await callback(event, state_events)
except Exception as e:
logger.error("Failed to run module API callback %s: %s", callback, e)
raise ModuleFailureError(e)
babolivier marked this conversation as resolved.
Show resolved Hide resolved

async def _get_state_map_for_room(self, room_id: str) -> StateMap[EventBase]:
"""Given a room ID, return the state events of that room.

Expand Down
2 changes: 1 addition & 1 deletion synapse/handlers/federation_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -2019,7 +2019,7 @@ async def _notify_persisted_event(
event_pos = PersistedEventPosition(
self._instance_name, event.internal_metadata.stream_ordering
)
self._notifier.on_new_room_event(
await self._notifier.on_new_room_event(
event, event_pos, max_stream_token, extra_users=extra_users
)

Expand Down
4 changes: 2 additions & 2 deletions synapse/handlers/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -1537,9 +1537,9 @@ async def persist_and_notify_client_event(
# If there's an expiry timestamp on the event, schedule its expiry.
self._message_handler.maybe_schedule_expiry(event)

def _notify() -> None:
async def _notify() -> None:
try:
self.notifier.on_new_room_event(
await self.notifier.on_new_room_event(
event, event_pos, max_stream_token, extra_users=extra_users
)
except Exception:
babolivier marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
10 changes: 8 additions & 2 deletions synapse/notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ def __init__(self, hs: "synapse.server.HomeServer"):
# down.
self.remote_server_up_callbacks: List[Callable[[str], None]] = []

self._third_party_rules = hs.get_third_party_event_rules()

self.clock = hs.get_clock()
self.appservice_handler = hs.get_application_service_handler()
self._pusher_pool = hs.get_pusherpool()
Expand Down Expand Up @@ -267,14 +269,16 @@ def add_replication_callback(self, cb: Callable[[], None]):
"""
self.replication_callbacks.append(cb)

def on_new_room_event(
async def on_new_room_event(
self,
event: EventBase,
event_pos: PersistedEventPosition,
max_room_stream_token: RoomStreamToken,
extra_users: Optional[Collection[UserID]] = None,
):
"""Unwraps event and calls `on_new_room_event_args`."""
"""Unwraps event and calls `on_new_room_event_args`.
Also notifies modules listening on new events via the `on_new_event` callback.
"""
self.on_new_room_event_args(
event_pos=event_pos,
room_id=event.room_id,
Expand All @@ -285,6 +289,8 @@ def on_new_room_event(
extra_users=extra_users or [],
)

await self._third_party_rules.on_new_event(event)
babolivier marked this conversation as resolved.
Show resolved Hide resolved

def on_new_room_event_args(
self,
room_id: str,
Expand Down
32 changes: 30 additions & 2 deletions tests/rest/client/test_third_party_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from typing import TYPE_CHECKING, Dict, Optional, Tuple
from unittest.mock import Mock

from synapse.api.constants import EventTypes
from synapse.api.constants import EventTypes, Membership
from synapse.api.errors import SynapseError
from synapse.events import EventBase
from synapse.events.third_party_rules import load_legacy_third_party_event_rules
Expand All @@ -25,6 +25,7 @@
from synapse.util.frozenutils import unfreeze

from tests import unittest
from tests.test_utils import make_awaitable

if TYPE_CHECKING:
from synapse.module_api import ModuleApi
Expand Down Expand Up @@ -89,8 +90,9 @@ def make_homeserver(self, reactor, clock):
return hs

def prepare(self, reactor, clock, homeserver):
# Create a user and room to play with during the tests
# Create some users and a room to play with during the tests
self.user_id = self.register_user("kermit", "monkey")
self.invitee = self.register_user("invitee", "hackme")
self.tok = self.login("kermit", "monkey")

# Some tests might prevent room creation on purpose.
Expand Down Expand Up @@ -424,6 +426,32 @@ async def test_fn(event: EventBase, state_events):
self.assertEqual(channel.code, 200)
self.assertEqual(channel.json_body["i"], i)

def test_on_new_event(self):
babolivier marked this conversation as resolved.
Show resolved Hide resolved
"""Test that the on_new_event callback is called on new events"""
on_new_event = Mock(make_awaitable(None))
self.hs.get_third_party_event_rules()._on_new_event_callbacks.append(
on_new_event
)

# Send a message event to the room and check that the callback is called.
self.helper.send(room_id=self.room_id, tok=self.tok)
self.assertEqual(on_new_event.call_count, 1)

# Check that the callback is also called on membership updates.
self.helper.invite(
room=self.room_id,
src=self.user_id,
targ=self.invitee,
tok=self.tok,
)

self.assertEqual(on_new_event.call_count, 2)

args, _ = on_new_event.call_args

self.assertEqual(args[0].membership, Membership.INVITE)
self.assertEqual(args[0].state_key, self.invitee)
babolivier marked this conversation as resolved.
Show resolved Hide resolved

def _update_power_levels(self, event_default: int = 0):
"""Updates the room's power levels.

Expand Down