feat: Support ignoring reply trees via relations

This commit is contained in:
timedout
2026-07-01 19:06:22 +01:00
parent 5f914a158f
commit 687c03f27c
3 changed files with 179 additions and 4 deletions
+50 -3
View File
@@ -42,12 +42,13 @@ from synapse.events.utils import clone_event, prune_event
from synapse.logging.opentracing import trace
from synapse.storage.controllers import StorageControllers
from synapse.storage.databases.main import DataStore
from synapse.storage.databases.main.events_worker import EventRedactBehaviour
from synapse.synapse_rust.events import event_visible_to_server
from synapse.types import (
RetentionPolicy,
EventID, RetentionPolicy,
StateMap,
StrCollection,
get_domain_from_id,
UserID, get_domain_from_id,
)
from synapse.types.state import StateFilter
from synapse.util.clock import Clock
@@ -175,15 +176,21 @@ async def filter_and_transform_events_for_client(
retention_policies[
room_id
] = await storage.main.get_retention_policy_for_room(room_id)
sender_trees: dict[str, set[str]] = {}
def allowed(event: EventBase) -> EventBase | None:
state_after_event = event_id_to_state.get(event.event_id)
sender_ignored = event.sender in ignore_list
if not sender_ignored and (tree := sender_trees.get(event.event_id)):
logger.debug("%s is not in %r, checking if any senders in %r are", event.sender, ignore_list, tree)
sender_ignored = any(sender in ignore_list for sender in tree)
logger.debug("any sender in ignore list: %r", sender_ignored)
filtered = _check_client_allowed_to_see_event(
user_id=user_id,
event=event,
clock=storage.main.clock,
filter_send_to_client=filter_send_to_client,
sender_ignored=event.sender in ignore_list,
sender_ignored=sender_ignored,
always_include_ids=always_include_ids,
retention_policy=retention_policies[event.room_id],
state=state_after_event,
@@ -255,6 +262,46 @@ async def filter_and_transform_events_for_client(
return cloned
# Check each event: gives an iterable of None or (a modified) EventBase.
event_map = {ev.event_id: ev for ev in events}
async def walk_relations(root: EventBase, senders: set[str]) -> set[str]:
senders.add(root.sender)
logger.debug("Walking relations of %s (senders: %r)", root.event_id, senders)
relations = root.unsigned.get("m.relates_to")
if not isinstance(relations, dict):
logger.debug("No more relations to walk (no relates_to)")
return senders
reply_to = relations.get("m.in_reply_to")
if not isinstance(reply_to, dict):
logger.debug("No more relations to walk (no in_reply_to)")
return senders
reply_id = reply_to.get("event_id")
if not isinstance(reply_id, str):
logger.debug("No more relations to walk (no event_id)")
return senders
reply = event_map.get(
reply_id,
await storage.main.get_event(
reply_id,
redact_behaviour=EventRedactBehaviour.as_is,
allow_rejected=True,
allow_none=True
)
)
if reply is None:
logger.debug("No more relations to walk (reply to unknown event)")
return senders
# If this reply is already in sender_trees, we've already walked it, and can
# return now.
if reply in sender_trees:
logger.debug("Already seen reply, no more relations to walk.")
return senders
return await walk_relations(reply, senders)
for ev in events:
logger.debug("Preparing to walk relations of proposed event %s", ev.event_id)
sender_trees.setdefault(ev.event_id, set())
await walk_relations(ev, sender_trees[ev.event_id])
filtered_events = map(allowed, events)
# Turn it into a list and remove None entries before returning.
+106 -1
View File
@@ -32,7 +32,10 @@ from synapse.api.constants import (
ReceiptTypes,
RelationTypes,
)
from synapse.rest.client import devices, knock, login, read_marker, receipts, room, sync
from synapse.rest.client import (
account_data, devices, knock, login, read_marker,
receipts, room, sync,
)
from synapse.server import HomeServer
from synapse.types import JsonDict
from synapse.util.clock import Clock
@@ -1208,3 +1211,105 @@ class SyncCancellationTestCase(unittest.HomeserverTestCase):
)
self.assertEqual(200, channel.code, msg=channel.result["body"])
class StarnapseSyncIgnoreSenderTreeTestCase(unittest.HomeserverTestCase):
servlets = [
synapse.rest.admin.register_servlets,
login.register_servlets,
sync.register_servlets,
room.register_servlets,
account_data.register_servlets,
]
def test_initial_sync(self) -> None:
"""Tests that a reply to an ignored user is omitted from sync"""
alice = self.register_user("alice", "password")
bob = self.register_user("bob", "password")
charlie = self.register_user("charlie", "password")
alice_token = self.login("alice", "password")
bob_token = self.login("bob", "password")
charlie_token = self.login("charlie", "password")
# Create a room and join everyone to it
room_id = self.helper.create_room_as(alice, tok=alice_token)
self.helper.join(room_id, bob, tok=bob_token)
self.helper.join(room_id, charlie, tok=charlie_token)
# Alice ignores bob
self.helper.set_account_data(
alice,
"m.ignored_user_list",
{
bob: {}
},
tok=alice_token
)
# Alice sends a message
alice_msg = self.helper.send(room_id, alice, tok=alice_token)["event_id"]
# And Bob replies to it
bob_msg = self.helper.send_event(
room_id,
bob,
{
"msgtype": "m.text",
"body": f"Hello {alice} from Bob!",
"m.relates_to": {
"m.in_reply_to": {
"event_id": alice_msg,
}
}
},
tok=bob_token
)["event_id"]
# And then Charlie replies to Bob
charlie_msg = self.helper.send_event(
room_id,
bob,
{
"msgtype": "m.text",
"body": f"Hello {bob} from Charlie!",
"m.relates_to": {
"m.in_reply_to": {
"event_id": bob_msg,
}
}
},
tok=charlie_token
)["event_id"]
# Because Alice blocked Bob, she probably doesn't want to see Charlie's reply
# to his message either. Neither Bob nor Charlie's message should show up in
# the next sync.
# However, we want to make sure a reply from Charlie to Alice doesn't
# get dropped.
charlie_msg2 = self.helper.send_event(
room_id,
bob,
{
"msgtype": "m.text",
"body": f"Hello {alice} from Charlie!",
"m.relates_to": {
"m.in_reply_to": {
"event_id": alice_msg,
}
}
},
tok=charlie_token
)["event_id"]
charlie_msg3 = self.helper.send(room_id, charlie, tok=charlie_token)["event_id"]
# Request an initial sync
channel = self.make_request("GET", "/sync", access_token=alice_token)
self.assertEqual(channel.code, 200, channel.json_body)
# Check for those one time key counts
timeline = channel.json_body["rooms"]["join"][room_id]["timeline"]["events"]
timeline_map = {e["event_id"]: e for e in timeline if e.get("state_key") is None}
print("\nTimeline map: %s\nAlice: %s\nBob: %s\nCharlie: %s %s %s" % (json.dumps(timeline_map, indent=4), alice_msg, bob_msg, charlie_msg, charlie_msg2, charlie_msg3))
assert bob_msg not in timeline_map, "Bob bypassed ignore list"
assert charlie_msg not in timeline_map, "Charlie's reply to bob appeared"
assert alice_msg in timeline_map, "Alice's message should be in the timeline"
assert charlie_msg2 in timeline_map, "Charlie's reply to alice message should be in the timeline"
assert charlie_msg3 in timeline_map, "Charlie's unrelated message should be in the timeline"
+23
View File
@@ -1022,3 +1022,26 @@ class RestHelper:
access_token=tok,
)
assert channel.code == HTTPStatus.OK, channel.text_body
def set_account_data(
self,
user_id: str,
type: str,
content: dict,
room_id: str | None = None,
*,
tok: str
) -> None:
if room_id is None:
path = f"/user/{user_id}/account_data/{type}"
else:
path = f"/user/{user_id}/rooms/{room_id}/account_data/{type}"
channel = make_request(
self.reactor,
self.site,
method="PUT",
path=path,
content=content,
access_token=tok,
)
assert channel.code == HTTPStatus.OK, channel.text_body