From c323dc1b1e2641fd9c50eb4b081dfcf2d751b295 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 28 Feb 2025 10:36:22 +0100 Subject: [PATCH 1/3] Add plain-text handling for rich-text topics as per MSC3765 Signed-off-by: Johannes Marbach --- synapse/handlers/room.py | 2 +- synapse/handlers/stats.py | 5 +- synapse/storage/databases/main/events.py | 6 +- synapse/storage/databases/main/stats.py | 5 +- synapse/util/events.py | 29 +++++++ tests/rest/client/test_rooms.py | 53 +++++++++++++ tests/util/test_events.py | 97 ++++++++++++++++++++++++ 7 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 tests/util/test_events.py diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index 386375d64b..d24b094d45 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -1296,7 +1296,7 @@ class RoomCreationHandler: topic = room_config["topic"] topic_event, topic_context = await create_event( EventTypes.Topic, - {"topic": topic}, + {"topic": topic, "m.topic": {"m.text": [{"body": topic}]}}, True, ) events_to_send.append((topic_event, topic_context)) diff --git a/synapse/handlers/stats.py b/synapse/handlers/stats.py index 8f90c17060..aa33260809 100644 --- a/synapse/handlers/stats.py +++ b/synapse/handlers/stats.py @@ -36,6 +36,7 @@ from synapse.metrics import event_processing_positions from synapse.metrics.background_process_metrics import run_as_background_process from synapse.storage.databases.main.state_deltas import StateDelta from synapse.types import JsonDict +from synapse.util.events import get_plain_text_topic_from_event_content if TYPE_CHECKING: from synapse.server import HomeServer @@ -299,7 +300,9 @@ class StatsHandler: elif delta.event_type == EventTypes.Name: room_state["name"] = event_content.get("name") elif delta.event_type == EventTypes.Topic: - room_state["topic"] = event_content.get("topic") + room_state["topic"] = get_plain_text_topic_from_event_content( + event_content + ) elif delta.event_type == EventTypes.RoomAvatar: room_state["avatar"] = event_content.get("url") elif delta.event_type == EventTypes.CanonicalAlias: diff --git a/synapse/storage/databases/main/events.py b/synapse/storage/databases/main/events.py index 26fbc1a483..586f488f25 100644 --- a/synapse/storage/databases/main/events.py +++ b/synapse/storage/databases/main/events.py @@ -78,6 +78,7 @@ from synapse.types import ( from synapse.types.handlers import SLIDING_SYNC_DEFAULT_BUMP_EVENT_TYPES from synapse.types.state import StateFilter from synapse.util import json_encoder +from synapse.util.events import get_plain_text_topic_from_event_content from synapse.util.iterutils import batch_iter, sorted_topologically from synapse.util.stringutils import non_null_str_or_none @@ -3102,7 +3103,10 @@ class PersistEventsStore: def _store_room_topic_txn(self, txn: LoggingTransaction, event: EventBase) -> None: if isinstance(event.content.get("topic"), str): self.store_event_search_txn( - txn, event, "content.topic", event.content["topic"] + txn, + event, + "content.topic", + get_plain_text_topic_from_event_content(event.content), ) def _store_room_name_txn(self, txn: LoggingTransaction, event: EventBase) -> None: diff --git a/synapse/storage/databases/main/stats.py b/synapse/storage/databases/main/stats.py index 79c49e7fd9..74830b7129 100644 --- a/synapse/storage/databases/main/stats.py +++ b/synapse/storage/databases/main/stats.py @@ -48,6 +48,7 @@ from synapse.storage.databases.main.events_worker import InvalidEventError from synapse.storage.databases.main.state_deltas import StateDeltasStore from synapse.types import JsonDict from synapse.util.caches.descriptors import cached +from synapse.util.events import get_plain_text_topic_from_event_content if TYPE_CHECKING: from synapse.server import HomeServer @@ -611,7 +612,9 @@ class StatsStore(StateDeltasStore): elif event.type == EventTypes.Name: room_state["name"] = event.content.get("name") elif event.type == EventTypes.Topic: - room_state["topic"] = event.content.get("topic") + room_state["topic"] = get_plain_text_topic_from_event_content( + event.content + ) elif event.type == EventTypes.RoomAvatar: room_state["avatar"] = event.content.get("url") elif event.type == EventTypes.CanonicalAlias: diff --git a/synapse/util/events.py b/synapse/util/events.py index ad9b946578..4c609ad882 100644 --- a/synapse/util/events.py +++ b/synapse/util/events.py @@ -13,6 +13,7 @@ # # +from synapse.types import JsonDict from synapse.util.stringutils import random_string @@ -27,3 +28,31 @@ def generate_fake_event_id() -> str: A string intended to look like an event ID, but with no actual meaning. """ return "$" + random_string(43) + + +def get_plain_text_topic_from_event_content(content: JsonDict): + """ + Given the content of an m.room.topic event returns the plain text topic + representation if any exists. + + Returns: + A string representing the plain text topic. + """ + topic = content.get("topic") + + m_topic = content.get("m.topic") + if not m_topic: + return topic + + m_text = m_topic.get("m.text") + if not m_text: + return topic + + representation = next( + (r for r in m_text if "mimetype" not in r or r["mimetype"] == "text/plain"), + None, + ) + if not representation or "body" not in representation: + return topic + + return representation["body"] diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index dd8350ddd1..47c280502f 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -757,6 +757,59 @@ class RoomsCreateTestCase(RoomBase): assert channel.resource_usage is not None self.assertEqual(37, channel.resource_usage.db_txn_count) + def test_post_room_topic(self) -> None: + # POST with topic key, expect new room id + channel = self.make_request("POST", "/createRoom", b'{"topic":"shenanigans"}') + self.assertEqual(HTTPStatus.OK, channel.code) + self.assertTrue("room_id" in channel.json_body) + room_id = channel.json_body["room_id"] + + # GET topic event, expect content from topic key + channel = self.make_request("GET", "/rooms/%s/state/m.room.topic" % (room_id,)) + self.assertEqual(HTTPStatus.OK, channel.code) + self.assertEqual( + {"topic": "shenanigans", "m.topic": {"m.text": [{"body": "shenanigans"}]}}, + channel.json_body, + ) + + def test_post_room_topic_initial_state(self) -> None: + # POST with m.room.topic in initial state, expect new room id + channel = self.make_request( + "POST", + "/createRoom", + b'{"initial_state":[{"type": "m.room.topic", "content": {"topic": "foobar"}}]}', + ) + self.assertEqual(HTTPStatus.OK, channel.code) + self.assertTrue("room_id" in channel.json_body) + room_id = channel.json_body["room_id"] + + # GET topic event, expect content from initial state + channel = self.make_request("GET", "/rooms/%s/state/m.room.topic" % (room_id,)) + self.assertEqual(HTTPStatus.OK, channel.code) + self.assertEqual( + {"topic": "foobar"}, + channel.json_body, + ) + + def test_post_room_topic_overriding_initial_state(self) -> None: + # POST with m.room.topic in initial state and topic key, expect new room id + channel = self.make_request( + "POST", + "/createRoom", + b'{"initial_state":[{"type": "m.room.topic", "content": {"topic": "foobar"}}], "topic":"shenanigans"}', + ) + self.assertEqual(HTTPStatus.OK, channel.code) + self.assertTrue("room_id" in channel.json_body) + room_id = channel.json_body["room_id"] + + # GET topic event, expect content from topic key + channel = self.make_request("GET", "/rooms/%s/state/m.room.topic" % (room_id,)) + self.assertEqual(HTTPStatus.OK, channel.code) + self.assertEqual( + {"topic": "shenanigans", "m.topic": {"m.text": [{"body": "shenanigans"}]}}, + channel.json_body, + ) + def test_post_room_visibility_key(self) -> None: # POST with visibility config key, expect new room id channel = self.make_request("POST", "/createRoom", b'{"visibility":"private"}') diff --git a/tests/util/test_events.py b/tests/util/test_events.py new file mode 100644 index 0000000000..f6c73261d4 --- /dev/null +++ b/tests/util/test_events.py @@ -0,0 +1,97 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2025 New Vector, Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . +# +# Originally licensed under the Apache License, Version 2.0: +# . +# +# [This file includes modifications made by New Vector Limited] +# +# + + +from synapse.util.events import get_plain_text_topic_from_event_content + +from tests import unittest + + +class EventsTestCase(unittest.TestCase): + def test_get_plain_text_topic_no_topic(self) -> None: + # No legacy or rich topic, expect None + topic = get_plain_text_topic_from_event_content({}) + self.assertEqual(None, topic) + + def test_get_plain_text_topic_no_rich_topic(self) -> None: + # Only legacy topic, expect legacy topic + topic = get_plain_text_topic_from_event_content({"topic": "shenanigans"}) + self.assertEqual("shenanigans", topic) + + def test_get_plain_text_topic_rich_topic_without_representations(self) -> None: + # Legacy topic and rich topic without representations, expect legacy topic + topic = get_plain_text_topic_from_event_content( + {"topic": "shenanigans", "m.topic": {"m.text": []}} + ) + self.assertEqual("shenanigans", topic) + + def test_get_plain_text_topic_rich_topic_without_plain_text_representation( + self, + ) -> None: + # Legacy topic and rich topic without plain text representation, expect legacy topic + topic = get_plain_text_topic_from_event_content( + { + "topic": "shenanigans", + "m.topic": { + "m.text": [ + {"mimetype": "text/html", "body": "foobar"} + ] + }, + } + ) + self.assertEqual("shenanigans", topic) + + def test_get_plain_text_topic_rich_topic_with_plain_text_representation( + self, + ) -> None: + # Legacy topic and rich topic with plain text representation, expect plain text representation + topic = get_plain_text_topic_from_event_content( + { + "topic": "shenanigans", + "m.topic": {"m.text": [{"mimetype": "text/plain", "body": "foobar"}]}, + } + ) + self.assertEqual("foobar", topic) + + def test_get_plain_text_topic_rich_topic_with_implicit_plain_text_representation( + self, + ) -> None: + # Legacy topic and rich topic with implicit plain text representation, expect plain text representation + topic = get_plain_text_topic_from_event_content( + {"topic": "shenanigans", "m.topic": {"m.text": [{"body": "foobar"}]}} + ) + self.assertEqual("foobar", topic) + + def test_get_plain_text_topic_rich_topic_with_plain_text_and_other_representation( + self, + ) -> None: + # Legacy topic and rich topic with plain text representation, expect plain text representation + topic = get_plain_text_topic_from_event_content( + { + "topic": "shenanigans", + "m.topic": { + "m.text": [ + {"mimetype": "text/html", "body": "foobar"}, + {"mimetype": "text/plain", "body": "foobar"}, + ] + }, + } + ) + self.assertEqual("foobar", topic) From 42c518225ba31a0a2abbb349615f405f7d275dda Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 28 Feb 2025 10:41:39 +0100 Subject: [PATCH 2/3] Add changelog --- changelog.d/18195.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/18195.feature diff --git a/changelog.d/18195.feature b/changelog.d/18195.feature new file mode 100644 index 0000000000..7f7903bd71 --- /dev/null +++ b/changelog.d/18195.feature @@ -0,0 +1 @@ +Add plain-text handling for rich-text topics as per [MSC3765](https://github.com/matrix-org/matrix-spec-proposals/pull/3765). From 4dbca25a40918810c70e7405ad16f2f835d7708c Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 28 Feb 2025 10:58:53 +0100 Subject: [PATCH 3/3] Appease the linter --- synapse/storage/databases/main/events.py | 2 +- synapse/util/events.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/synapse/storage/databases/main/events.py b/synapse/storage/databases/main/events.py index 586f488f25..b043df68f5 100644 --- a/synapse/storage/databases/main/events.py +++ b/synapse/storage/databases/main/events.py @@ -3106,7 +3106,7 @@ class PersistEventsStore: txn, event, "content.topic", - get_plain_text_topic_from_event_content(event.content), + get_plain_text_topic_from_event_content(event.content) or "", ) def _store_room_name_txn(self, txn: LoggingTransaction, event: EventBase) -> None: diff --git a/synapse/util/events.py b/synapse/util/events.py index 4c609ad882..1e8f53c4e9 100644 --- a/synapse/util/events.py +++ b/synapse/util/events.py @@ -13,6 +13,8 @@ # # +from typing import Optional + from synapse.types import JsonDict from synapse.util.stringutils import random_string @@ -30,7 +32,7 @@ def generate_fake_event_id() -> str: return "$" + random_string(43) -def get_plain_text_topic_from_event_content(content: JsonDict): +def get_plain_text_topic_from_event_content(content: JsonDict) -> Optional[str]: """ Given the content of an m.room.topic event returns the plain text topic representation if any exists.