mirror of
https://github.com/element-hq/synapse.git
synced 2026-10-23 10:53:31 +00:00
Auto-join rooms when a knock is accepted
When an invite arrives for a user who has a pending knock in that room (i.e. the knock was accepted), automatically join the user rather than making them accept the resulting invite by hand (#16307). Detected by the invited user's own knock membership event appearing in the invite's auth events, which holds for local and federated invites alike. On by default (the user already asked to join by knocking); can be disabled with auto_accept_invites.enabled_for_accepted_knocks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uk8aPxHn3BHCe52L226jdG
This commit is contained in:
1 parent
ec6c53e7df
commit
556f01e583
8 files changed
+202
-18
No files matched your search
@@ -0,0 +1 @@
|
||||
Automatically join a user to a room when an invite arrives for a knock they have pending in that room (i.e. their knock was accepted), instead of requiring the user to accept the resulting invite by hand. Can be disabled with the new `auto_accept_invites.enabled_for_accepted_knocks` option.
|
||||
@@ -4773,6 +4773,8 @@ This setting has the following sub-options:
|
||||
|
||||
* `enabled` (boolean): Whether to run the auto-accept invites logic. Defaults to `false`.
|
||||
|
||||
* `enabled_for_accepted_knocks` (boolean): Whether to automatically join a user to a room when an invite arrives for a knock they have pending in that room (i.e. their knock was accepted). The user already asked to join by knocking, so this applies independently of `enabled` and of the restrictions below. Defaults to `true`.
|
||||
|
||||
* `only_for_direct_messages` (boolean): Whether invites should be automatically accepted for all room types, or only for direct messages. Defaults to `false`.
|
||||
|
||||
* `only_from_local_users` (boolean): Whether to only automatically accept invites from users on this homeserver. Defaults to `false`.
|
||||
|
||||
@@ -5865,6 +5865,15 @@ properties:
|
||||
type: boolean
|
||||
description: Whether to run the auto-accept invites logic.
|
||||
default: false
|
||||
enabled_for_accepted_knocks:
|
||||
type: boolean
|
||||
description: >-
|
||||
Whether to automatically join a user to a room when an invite
|
||||
arrives for a knock they have pending in that room (i.e. their
|
||||
knock was accepted). The user already asked to join by knocking,
|
||||
so this applies independently of `enabled` and of the restrictions
|
||||
below.
|
||||
default: true
|
||||
only_for_direct_messages:
|
||||
type: boolean
|
||||
description: >-
|
||||
|
||||
@@ -716,7 +716,10 @@ async def start(hs: "HomeServer", *, freeze: bool = True) -> None:
|
||||
m = module(config, module_api)
|
||||
logger.info("Loaded module %s", m)
|
||||
|
||||
if hs.config.auto_accept_invites.enabled:
|
||||
if (
|
||||
hs.config.auto_accept_invites.enabled
|
||||
or hs.config.auto_accept_invites.enabled_for_accepted_knocks
|
||||
):
|
||||
# Start the local auto_accept_invites module.
|
||||
m = InviteAutoAccepter(hs.config.auto_accept_invites, module_api)
|
||||
logger.info("Loaded local module %s", m)
|
||||
|
||||
@@ -32,6 +32,15 @@ class AutoAcceptInvitesConfig(Config):
|
||||
|
||||
self.enabled = auto_accept_invites_config.get("enabled", False)
|
||||
|
||||
# Whether to automatically join a room on behalf of a user when an
|
||||
# invite arrives for a knock the user has pending in that room (i.e.
|
||||
# the knock was accepted). The user already asked to join by knocking,
|
||||
# so this is on by default and independent of `enabled` above.
|
||||
# See https://github.com/element-hq/synapse/issues/16307.
|
||||
self.enabled_for_accepted_knocks = auto_accept_invites_config.get(
|
||||
"enabled_for_accepted_knocks", True
|
||||
)
|
||||
|
||||
self.accept_invites_only_for_direct_messages = auto_accept_invites_config.get(
|
||||
"only_for_direct_messages", False
|
||||
)
|
||||
|
||||
@@ -36,8 +36,12 @@ class InviteAutoAccepter:
|
||||
self._api = api
|
||||
self.server_name = api.server_name
|
||||
self._config = config
|
||||
# This is bundled with Synapse (rather than a true module), so reach
|
||||
# for the datastore directly: the module API doesn't expose event
|
||||
# fetching.
|
||||
self._store = api._store
|
||||
|
||||
if not self._config.enabled:
|
||||
if not self._config.enabled and not self._config.enabled_for_accepted_knocks:
|
||||
return
|
||||
|
||||
should_run_on_this_worker = config.worker_to_run_on == self._api.worker_name
|
||||
@@ -75,21 +79,34 @@ class InviteAutoAccepter:
|
||||
):
|
||||
return
|
||||
|
||||
# Only accept invites for direct messages if the configuration mandates it.
|
||||
is_direct_message = event.content.get("is_direct", False)
|
||||
if (
|
||||
self._config.accept_invites_only_for_direct_messages
|
||||
and is_direct_message is False
|
||||
):
|
||||
# An invite following a knock from the user (the knock being accepted)
|
||||
# is auto-joined regardless of the general auto-accept settings: the
|
||||
# user already asked to join the room by knocking.
|
||||
# See https://github.com/element-hq/synapse/issues/16307.
|
||||
is_accepted_knock = await self._is_accepted_knock(event)
|
||||
if is_accepted_knock:
|
||||
if not self._config.enabled_for_accepted_knocks:
|
||||
return
|
||||
elif not self._config.enabled:
|
||||
return
|
||||
|
||||
# Only accept invites from remote users if the configuration mandates it.
|
||||
is_from_local_user = self._api.is_mine(event.sender)
|
||||
if (
|
||||
self._config.accept_invites_only_from_local_users
|
||||
and is_from_local_user is False
|
||||
):
|
||||
return
|
||||
is_direct_message = event.content.get("is_direct", False)
|
||||
|
||||
if not is_accepted_knock:
|
||||
# Only accept invites for direct messages if the configuration mandates it.
|
||||
if (
|
||||
self._config.accept_invites_only_for_direct_messages
|
||||
and is_direct_message is False
|
||||
):
|
||||
return
|
||||
|
||||
# Only accept invites from remote users if the configuration mandates it.
|
||||
is_from_local_user = self._api.is_mine(event.sender)
|
||||
if (
|
||||
self._config.accept_invites_only_from_local_users
|
||||
and is_from_local_user is False
|
||||
):
|
||||
return
|
||||
|
||||
# Check the user is activated.
|
||||
recipient = await self._api.get_userinfo_by_id(event.state_key)
|
||||
@@ -127,6 +144,26 @@ class InviteAutoAccepter:
|
||||
event.state_key, event.sender, event.room_id
|
||||
)
|
||||
|
||||
async def _is_accepted_knock(self, invite_event: EventBase) -> bool:
|
||||
"""Whether the invite is the acceptance of a knock by the invited
|
||||
user: i.e. the invited user's own knock membership event is among the
|
||||
invite's auth events.
|
||||
|
||||
This works for over-federation invites too: the knocking server holds
|
||||
the knock event (it created it), and the resident server necessarily
|
||||
cited it as the invitee's prior membership when authing the invite.
|
||||
"""
|
||||
for auth_event_id in invite_event.auth_event_ids():
|
||||
auth_event = await self._store.get_event(auth_event_id, allow_none=True)
|
||||
if (
|
||||
auth_event is not None
|
||||
and auth_event.type == EventTypes.Member
|
||||
and auth_event.state_key == invite_event.state_key
|
||||
and auth_event.membership == Membership.KNOCK
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _mark_room_as_direct_message(
|
||||
self, user_id: str, dm_user_id: str, room_id: str
|
||||
) -> None:
|
||||
|
||||
@@ -36,7 +36,7 @@ from synapse.events.auto_accept_invites import InviteAutoAccepter
|
||||
from synapse.handlers.sync import JoinedSyncResult, SyncRequestKey
|
||||
from synapse.module_api import ModuleApi
|
||||
from synapse.rest import admin
|
||||
from synapse.rest.client import login, room
|
||||
from synapse.rest.client import knock, login, room
|
||||
from synapse.server import HomeServer
|
||||
from synapse.types import StreamToken, UserID, UserInfo, create_requester
|
||||
from synapse.util.clock import Clock
|
||||
@@ -58,10 +58,114 @@ class AutoAcceptInvitesTestCase(FederatingHomeserverTestCase):
|
||||
|
||||
servlets = [
|
||||
admin.register_servlets,
|
||||
knock.register_servlets,
|
||||
login.register_servlets,
|
||||
room.register_servlets,
|
||||
]
|
||||
|
||||
def _create_knockable_room_with_pending_knock(
|
||||
self,
|
||||
) -> tuple[str, str, str, str]:
|
||||
"""Create a knockable room and have a second local user knock on it.
|
||||
|
||||
Returns a tuple of (room_id, creator_id, creator_tok, knocker_id).
|
||||
"""
|
||||
creator_id = self.register_user("creator", "pass")
|
||||
creator_tok = self.login("creator", "pass")
|
||||
|
||||
knocker_id = self.register_user("knocker", "pass")
|
||||
knocker_tok = self.login("knocker", "pass")
|
||||
|
||||
room_id = self.helper.create_room_as(
|
||||
creator_id,
|
||||
is_public=False,
|
||||
tok=creator_tok,
|
||||
)
|
||||
self.helper.send_state(
|
||||
room_id,
|
||||
EventTypes.JoinRules,
|
||||
{"join_rule": "knock"},
|
||||
tok=creator_tok,
|
||||
)
|
||||
|
||||
self.helper.knock(room=room_id, user=knocker_id, tok=knocker_tok)
|
||||
|
||||
return room_id, creator_id, creator_tok, knocker_id
|
||||
|
||||
def test_auto_join_on_accepted_knock(self) -> None:
|
||||
"""A user whose knock is accepted (invited by a room member) is
|
||||
automatically joined to the room, even with no `auto_accept_invites`
|
||||
configuration at all."""
|
||||
(
|
||||
room_id,
|
||||
creator_id,
|
||||
creator_tok,
|
||||
knocker_id,
|
||||
) = self._create_knockable_room_with_pending_knock()
|
||||
|
||||
# The creator accepts the knock by inviting the knocker.
|
||||
self.helper.invite(
|
||||
room_id,
|
||||
creator_id,
|
||||
knocker_id,
|
||||
tok=creator_tok,
|
||||
)
|
||||
|
||||
# The knocker is automatically joined to the room.
|
||||
join_updates, _ = sync_join(self, knocker_id)
|
||||
self.assertEqual(len(join_updates), 1)
|
||||
self.assertEqual(join_updates[0].room_id, room_id)
|
||||
|
||||
@override_config(
|
||||
{
|
||||
"auto_accept_invites": {
|
||||
"enabled_for_accepted_knocks": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_no_auto_join_on_accepted_knock_when_disabled(self) -> None:
|
||||
"""With `enabled_for_accepted_knocks` off, an accepted knock stays an
|
||||
ordinary invite."""
|
||||
(
|
||||
room_id,
|
||||
creator_id,
|
||||
creator_tok,
|
||||
knocker_id,
|
||||
) = self._create_knockable_room_with_pending_knock()
|
||||
|
||||
self.helper.invite(
|
||||
room_id,
|
||||
creator_id,
|
||||
knocker_id,
|
||||
tok=creator_tok,
|
||||
)
|
||||
|
||||
join_updates, _ = sync_join(self, knocker_id)
|
||||
self.assertEqual(len(join_updates), 0)
|
||||
|
||||
def test_plain_invite_not_auto_accepted_by_default(self) -> None:
|
||||
"""A plain invite (no prior knock) is not auto-accepted just because
|
||||
the accepted-knocks logic is enabled by default."""
|
||||
inviting_user_id = self.register_user("inviter2", "pass")
|
||||
inviting_user_tok = self.login("inviter2", "pass")
|
||||
|
||||
invited_user_id = self.register_user("invitee2", "pass")
|
||||
self.login("invitee2", "pass")
|
||||
|
||||
room_id = self.helper.create_room_as(
|
||||
inviting_user_id, is_public=False, tok=inviting_user_tok
|
||||
)
|
||||
|
||||
self.helper.invite(
|
||||
room_id,
|
||||
inviting_user_id,
|
||||
invited_user_id,
|
||||
tok=inviting_user_tok,
|
||||
)
|
||||
|
||||
join_updates, _ = sync_join(self, invited_user_id)
|
||||
self.assertEqual(len(join_updates), 0)
|
||||
|
||||
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
|
||||
hs = self.setup_test_homeserver()
|
||||
self.handler = hs.get_federation_handler()
|
||||
@@ -561,7 +665,12 @@ class InviteAutoAccepterInternalTestCase(TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.module = create_module()
|
||||
# These tests exercise the plain-invite acceptance path, which is
|
||||
# gated on `enabled` in `on_new_event` (the accepted-knock path is
|
||||
# what's on by default).
|
||||
self.module = create_module(
|
||||
config_override={"auto_accept_invites": {"enabled": True}}
|
||||
)
|
||||
self.user_id = "@peter:test"
|
||||
self.invitee = "@lesley:test"
|
||||
self.remote_invitee = "@thomas:remote"
|
||||
@@ -772,6 +881,11 @@ class MockEvent:
|
||||
"""Checks if the event is a state event by checking if it has a state key."""
|
||||
return self.state_key is not None
|
||||
|
||||
def auth_event_ids(self) -> list[str]:
|
||||
"""The module walks the auth events when checking whether an invite
|
||||
accepts a knock; a mocked event has none."""
|
||||
return []
|
||||
|
||||
@property
|
||||
def membership(self) -> str:
|
||||
"""Extracts the membership from the event. Should only be called on an event
|
||||
@@ -800,6 +914,12 @@ def create_module(
|
||||
module_api = Mock(spec=ModuleApi)
|
||||
module_api.is_mine.side_effect = lambda a: a.split(":")[1] == "test"
|
||||
module_api.worker_name = worker_name
|
||||
# The module reaches into the datastore to walk an invite's auth events;
|
||||
# none of the mocked events have any.
|
||||
module_api._store = Mock()
|
||||
module_api._store.get_event.side_effect = lambda *_args, **_kwargs: (
|
||||
make_awaitable(None)
|
||||
)
|
||||
module_api.sleep.return_value = lambda *_args, **_kwargs: make_awaitable(None)
|
||||
module_api.get_userinfo_by_id.return_value = UserInfo(
|
||||
user_id=UserID.from_string("@user:test"),
|
||||
|
||||
+4
-1
@@ -1465,7 +1465,10 @@ def start_test_homeserver(
|
||||
for module, module_config in hs.config.modules.loaded_modules:
|
||||
module(config=module_config, api=module_api)
|
||||
|
||||
if hs.config.auto_accept_invites.enabled:
|
||||
if (
|
||||
hs.config.auto_accept_invites.enabled
|
||||
or hs.config.auto_accept_invites.enabled_for_accepted_knocks
|
||||
):
|
||||
# Start the local auto_accept_invites module.
|
||||
m = InviteAutoAccepter(hs.config.auto_accept_invites, module_api)
|
||||
logger.info("Loaded local module %s", m)
|
||||
|
||||
Reference in new issue
Block a user