Skip to content

Commit 6a9a641

Browse files
authored
Bring auto-accept invite logic into Synapse (#17147)
This PR ports the logic from the [synapse_auto_accept_invite](https://github.com/matrix-org/synapse-auto-accept-invite) module into synapse. I went with the naive approach of injecting the "module" next to where third party modules are currently loaded. If there is a better/preferred way to handle this, I'm all ears. It wasn't obvious to me if there was a better location to add this logic that would cleanly apply to all incoming invite events. Relies on #17166 to fix linter errors.
1 parent b5facba commit 6a9a641

File tree

11 files changed

+945
-1
lines changed

11 files changed

+945
-1
lines changed

Diff for: changelog.d/17147.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add the ability to auto-accept invites on the behalf of users. See the [`auto_accept_invites`](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#auto-accept-invites) config option for details.

Diff for: docs/usage/configuration/config_documentation.md

+29
Original file line numberDiff line numberDiff line change
@@ -4595,3 +4595,32 @@ background_updates:
45954595
min_batch_size: 10
45964596
default_batch_size: 50
45974597
```
4598+
---
4599+
## Auto Accept Invites
4600+
Configuration settings related to automatically accepting invites.
4601+
4602+
---
4603+
### `auto_accept_invites`
4604+
4605+
Automatically accepting invites controls whether users are presented with an invite request or if they
4606+
are instead automatically joined to a room when receiving an invite. Set the `enabled` sub-option to true to
4607+
enable auto-accepting invites. Defaults to false.
4608+
This setting has the following sub-options:
4609+
* `enabled`: Whether to run the auto-accept invites logic. Defaults to false.
4610+
* `only_for_direct_messages`: Whether invites should be automatically accepted for all room types, or only
4611+
for direct messages. Defaults to false.
4612+
* `only_from_local_users`: Whether to only automatically accept invites from users on this homeserver. Defaults to false.
4613+
* `worker_to_run_on`: Which worker to run this module on. This must match the "worker_name".
4614+
4615+
NOTE: Care should be taken not to enable this setting if the `synapse_auto_accept_invite` module is enabled and installed.
4616+
The two modules will compete to perform the same task and may result in undesired behaviour. For example, multiple join
4617+
events could be generated from a single invite.
4618+
4619+
Example configuration:
4620+
```yaml
4621+
auto_accept_invites:
4622+
enabled: true
4623+
only_for_direct_messages: true
4624+
only_from_local_users: true
4625+
worker_to_run_on: "worker_1"
4626+
```

Diff for: synapse/app/_base.py

+6
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
from synapse.config.homeserver import HomeServerConfig
6969
from synapse.config.server import ListenerConfig, ManholeConfig, TCPListenerConfig
7070
from synapse.crypto import context_factory
71+
from synapse.events.auto_accept_invites import InviteAutoAccepter
7172
from synapse.events.presence_router import load_legacy_presence_router
7273
from synapse.handlers.auth import load_legacy_password_auth_providers
7374
from synapse.http.site import SynapseSite
@@ -582,6 +583,11 @@ def run_sighup(*args: Any, **kwargs: Any) -> None:
582583
m = module(config, module_api)
583584
logger.info("Loaded module %s", m)
584585

586+
if hs.config.auto_accept_invites.enabled:
587+
# Start the local auto_accept_invites module.
588+
m = InviteAutoAccepter(hs.config.auto_accept_invites, module_api)
589+
logger.info("Loaded local module %s", m)
590+
585591
load_legacy_spam_checkers(hs)
586592
load_legacy_third_party_event_rules(hs)
587593
load_legacy_presence_router(hs)

Diff for: synapse/config/_base.pyi

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ from synapse.config import ( # noqa: F401
2323
api,
2424
appservice,
2525
auth,
26+
auto_accept_invites,
2627
background_updates,
2728
cache,
2829
captcha,
@@ -120,6 +121,7 @@ class RootConfig:
120121
federation: federation.FederationConfig
121122
retention: retention.RetentionConfig
122123
background_updates: background_updates.BackgroundUpdateConfig
124+
auto_accept_invites: auto_accept_invites.AutoAcceptInvitesConfig
123125

124126
config_classes: List[Type["Config"]] = ...
125127
config_files: List[str]

Diff for: synapse/config/auto_accept_invites.py

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#
2+
# This file is licensed under the Affero General Public License (AGPL) version 3.
3+
#
4+
# Copyright (C) 2024 New Vector, Ltd
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as
8+
# published by the Free Software Foundation, either version 3 of the
9+
# License, or (at your option) any later version.
10+
#
11+
# See the GNU Affero General Public License for more details:
12+
# <https://www.gnu.org/licenses/agpl-3.0.html>.
13+
#
14+
# Originally licensed under the Apache License, Version 2.0:
15+
# <http://www.apache.org/licenses/LICENSE-2.0>.
16+
#
17+
# [This file includes modifications made by New Vector Limited]
18+
#
19+
#
20+
from typing import Any
21+
22+
from synapse.types import JsonDict
23+
24+
from ._base import Config
25+
26+
27+
class AutoAcceptInvitesConfig(Config):
28+
section = "auto_accept_invites"
29+
30+
def read_config(self, config: JsonDict, **kwargs: Any) -> None:
31+
auto_accept_invites_config = config.get("auto_accept_invites") or {}
32+
33+
self.enabled = auto_accept_invites_config.get("enabled", False)
34+
35+
self.accept_invites_only_for_direct_messages = auto_accept_invites_config.get(
36+
"only_for_direct_messages", False
37+
)
38+
39+
self.accept_invites_only_from_local_users = auto_accept_invites_config.get(
40+
"only_from_local_users", False
41+
)
42+
43+
self.worker_to_run_on = auto_accept_invites_config.get("worker_to_run_on")

Diff for: synapse/config/homeserver.py

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from .api import ApiConfig
2424
from .appservice import AppServiceConfig
2525
from .auth import AuthConfig
26+
from .auto_accept_invites import AutoAcceptInvitesConfig
2627
from .background_updates import BackgroundUpdateConfig
2728
from .cache import CacheConfig
2829
from .captcha import CaptchaConfig
@@ -105,4 +106,5 @@ class HomeServerConfig(RootConfig):
105106
RedisConfig,
106107
ExperimentalConfig,
107108
BackgroundUpdateConfig,
109+
AutoAcceptInvitesConfig,
108110
]

Diff for: synapse/events/auto_accept_invites.py

+196
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
#
2+
# This file is licensed under the Affero General Public License (AGPL) version 3.
3+
#
4+
# Copyright 2021 The Matrix.org Foundation C.I.C
5+
# Copyright (C) 2024 New Vector, Ltd
6+
#
7+
# This program is free software: you can redistribute it and/or modify
8+
# it under the terms of the GNU Affero General Public License as
9+
# published by the Free Software Foundation, either version 3 of the
10+
# License, or (at your option) any later version.
11+
#
12+
# See the GNU Affero General Public License for more details:
13+
# <https://www.gnu.org/licenses/agpl-3.0.html>.
14+
#
15+
# Originally licensed under the Apache License, Version 2.0:
16+
# <http://www.apache.org/licenses/LICENSE-2.0>.
17+
#
18+
# [This file includes modifications made by New Vector Limited]
19+
#
20+
#
21+
import logging
22+
from http import HTTPStatus
23+
from typing import Any, Dict, Tuple
24+
25+
from synapse.api.constants import AccountDataTypes, EventTypes, Membership
26+
from synapse.api.errors import SynapseError
27+
from synapse.config.auto_accept_invites import AutoAcceptInvitesConfig
28+
from synapse.module_api import EventBase, ModuleApi, run_as_background_process
29+
30+
logger = logging.getLogger(__name__)
31+
32+
33+
class InviteAutoAccepter:
34+
def __init__(self, config: AutoAcceptInvitesConfig, api: ModuleApi):
35+
# Keep a reference to the Module API.
36+
self._api = api
37+
self._config = config
38+
39+
if not self._config.enabled:
40+
return
41+
42+
should_run_on_this_worker = config.worker_to_run_on == self._api.worker_name
43+
44+
if not should_run_on_this_worker:
45+
logger.info(
46+
"Not accepting invites on this worker (configured: %r, here: %r)",
47+
config.worker_to_run_on,
48+
self._api.worker_name,
49+
)
50+
return
51+
52+
logger.info(
53+
"Accepting invites on this worker (here: %r)", self._api.worker_name
54+
)
55+
56+
# Register the callback.
57+
self._api.register_third_party_rules_callbacks(
58+
on_new_event=self.on_new_event,
59+
)
60+
61+
async def on_new_event(self, event: EventBase, *args: Any) -> None:
62+
"""Listens for new events, and if the event is an invite for a local user then
63+
automatically accepts it.
64+
65+
Args:
66+
event: The incoming event.
67+
"""
68+
# Check if the event is an invite for a local user.
69+
is_invite_for_local_user = (
70+
event.type == EventTypes.Member
71+
and event.is_state()
72+
and event.membership == Membership.INVITE
73+
and self._api.is_mine(event.state_key)
74+
)
75+
76+
# Only accept invites for direct messages if the configuration mandates it.
77+
is_direct_message = event.content.get("is_direct", False)
78+
is_allowed_by_direct_message_rules = (
79+
not self._config.accept_invites_only_for_direct_messages
80+
or is_direct_message is True
81+
)
82+
83+
# Only accept invites from remote users if the configuration mandates it.
84+
is_from_local_user = self._api.is_mine(event.sender)
85+
is_allowed_by_local_user_rules = (
86+
not self._config.accept_invites_only_from_local_users
87+
or is_from_local_user is True
88+
)
89+
90+
if (
91+
is_invite_for_local_user
92+
and is_allowed_by_direct_message_rules
93+
and is_allowed_by_local_user_rules
94+
):
95+
# Make the user join the room. We run this as a background process to circumvent a race condition
96+
# that occurs when responding to invites over federation (see https://github.com/matrix-org/synapse-auto-accept-invite/issues/12)
97+
run_as_background_process(
98+
"retry_make_join",
99+
self._retry_make_join,
100+
event.state_key,
101+
event.state_key,
102+
event.room_id,
103+
"join",
104+
bg_start_span=False,
105+
)
106+
107+
if is_direct_message:
108+
# Mark this room as a direct message!
109+
await self._mark_room_as_direct_message(
110+
event.state_key, event.sender, event.room_id
111+
)
112+
113+
async def _mark_room_as_direct_message(
114+
self, user_id: str, dm_user_id: str, room_id: str
115+
) -> None:
116+
"""
117+
Marks a room (`room_id`) as a direct message with the counterparty `dm_user_id`
118+
from the perspective of the user `user_id`.
119+
120+
Args:
121+
user_id: the user for whom the membership is changing
122+
dm_user_id: the user performing the membership change
123+
room_id: room id of the room the user is invited to
124+
"""
125+
126+
# This is a dict of User IDs to tuples of Room IDs
127+
# (get_global will return a frozendict of tuples as it freezes the data,
128+
# but we should accept either frozen or unfrozen variants.)
129+
# Be careful: we convert the outer frozendict into a dict here,
130+
# but the contents of the dict are still frozen (tuples in lieu of lists,
131+
# etc.)
132+
dm_map: Dict[str, Tuple[str, ...]] = dict(
133+
await self._api.account_data_manager.get_global(
134+
user_id, AccountDataTypes.DIRECT
135+
)
136+
or {}
137+
)
138+
139+
if dm_user_id not in dm_map:
140+
dm_map[dm_user_id] = (room_id,)
141+
else:
142+
dm_rooms_for_user = dm_map[dm_user_id]
143+
assert isinstance(dm_rooms_for_user, (tuple, list))
144+
145+
dm_map[dm_user_id] = tuple(dm_rooms_for_user) + (room_id,)
146+
147+
await self._api.account_data_manager.put_global(
148+
user_id, AccountDataTypes.DIRECT, dm_map
149+
)
150+
151+
async def _retry_make_join(
152+
self, sender: str, target: str, room_id: str, new_membership: str
153+
) -> None:
154+
"""
155+
A function to retry sending the `make_join` request with an increasing backoff. This is
156+
implemented to work around a race condition when receiving invites over federation.
157+
158+
Args:
159+
sender: the user performing the membership change
160+
target: the user for whom the membership is changing
161+
room_id: room id of the room to join to
162+
new_membership: the type of membership event (in this case will be "join")
163+
"""
164+
165+
sleep = 0
166+
retries = 0
167+
join_event = None
168+
169+
while retries < 5:
170+
try:
171+
await self._api.sleep(sleep)
172+
join_event = await self._api.update_room_membership(
173+
sender=sender,
174+
target=target,
175+
room_id=room_id,
176+
new_membership=new_membership,
177+
)
178+
except SynapseError as e:
179+
if e.code == HTTPStatus.FORBIDDEN:
180+
logger.debug(
181+
f"Update_room_membership was forbidden. This can sometimes be expected for remote invites. Exception: {e}"
182+
)
183+
else:
184+
logger.warn(
185+
f"Update_room_membership raised the following unexpected (SynapseError) exception: {e}"
186+
)
187+
except Exception as e:
188+
logger.warn(
189+
f"Update_room_membership raised the following unexpected exception: {e}"
190+
)
191+
192+
sleep = 2**retries
193+
retries += 1
194+
195+
if join_event is not None:
196+
break

Diff for: synapse/handlers/sso.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -817,7 +817,7 @@ def is_allowed_mime_type(content_type: str) -> bool:
817817
server_name = profile["avatar_url"].split("/")[-2]
818818
media_id = profile["avatar_url"].split("/")[-1]
819819
if self._is_mine_server_name(server_name):
820-
media = await self._media_repo.store.get_local_media(media_id)
820+
media = await self._media_repo.store.get_local_media(media_id) # type: ignore[has-type]
821821
if media is not None and upload_name == media.upload_name:
822822
logger.info("skipping saving the user avatar")
823823
return True

0 commit comments

Comments
 (0)