Repository: opsdroid/opsdroid

Issue:
Improve performance of room_specific_nicks in matrix connector
# Description

At the moment we iterate over all the members in a room to calculate the room specific nick names:

https://github.com/opsdroid/opsdroid/blob/623a70bbc002db570915fb8336ddd5ed4abb8638/opsdroid/connector/matrix/connector.py#L325-L333

This is very inefficient when we can use the state endpoint to query it for the specific user: https://matrix.org/docs/spec/client_server/r0.5.0#get-matrix-client-r0-rooms-roomid-state-eventtype-statekey

Using the https://matrix-nio.readthedocs.io/en/latest/nio.html#nio.AsyncClient.room_get_state_event method from nio


Discussion:

Resolving patch:
diff --git a/opsdroid/connector/matrix/connector.py b/opsdroid/connector/matrix/connector.py
--- a/opsdroid/connector/matrix/connector.py
+++ b/opsdroid/connector/matrix/connector.py
@@ -96,7 +96,11 @@ def __init__(self, config, opsdroid=None):  # noqa: D107
         self.access_token = config.get("access_token")
         self.nick = config.get("nick")
         self.homeserver = config.get("homeserver", "https://matrix.org")
-        self.room_specific_nicks = config.get("room_specific_nicks", False)
+        # deprecated in 0.21
+        if config.get("room_specific_nicks", None):
+            _LOGGER.warning(
+                "The `room_specific_nicks` config option is deprecated as it is now always True."
+            )  # pragma: no cover
         self.send_m_notice = config.get("send_m_notice", False)
         self.session = None
         self.filter_id = None
@@ -296,12 +300,11 @@ async def _parse_sync_response(self, response):
                 if isinstance(e, nio.InviteMemberEvent)
                 if e.membership == "invite"
             ][0]
-            sender = await self.get_nick(None, invite_event.sender)
 
             yield events.UserInvite(
                 target=roomid,
                 user_id=invite_event.sender,
-                user=sender,
+                user=invite_event.sender,
                 connector=self,
                 raw_event=invite_event,
             )
@@ -354,25 +357,16 @@ async def get_nick(self, roomid, mxid):
         Get the nickname of a sender depending on the room specific config
         setting.
         """
-        if self.room_specific_nicks and roomid is not None:
-
-            res = await self.connection.joined_members(roomid)
-            if isinstance(res, nio.JoinedMembersError):
-                logging.exception("Failed to lookup room members for %s.", roomid)
-                # fallback to global profile
-            else:
-                for member in res.members:
-                    if member.user_id == mxid:
-                        return member.display_name
-                return mxid
-
-        res = await self.connection.get_displayname(mxid)
-        if isinstance(res, nio.ProfileGetDisplayNameError):
-            _LOGGER.error("Failed to lookup nick for %s.", mxid)
-            return mxid
-        if res.displayname is None:
+        room_state = await self.connection.room_get_state_event(
+            roomid, "m.room.member", mxid
+        )
+        if isinstance(room_state, nio.RoomGetStateEventError):
+            _LOGGER.error(
+                f"Error during getting display name from room state: {room_state.message} (status code {room_state.status_code})"
+            )
             return mxid
-        return res.displayname
+
+        return room_state.content.get("displayname", mxid) or mxid
 
     def get_roomname(self, room):
         """Get the name of a room from alias or room ID."""


Test patch:
diff --git a/opsdroid/connector/matrix/tests/test_event_parsing.py b/opsdroid/connector/matrix/tests/test_event_parsing.py
--- a/opsdroid/connector/matrix/tests/test_event_parsing.py
+++ b/opsdroid/connector/matrix/tests/test_event_parsing.py
@@ -1,8 +1,11 @@
-from opsdroid.connector.matrix.tests.conftest import message_factory, sync_response
+import logging
+
 import pytest
 
 from nio.responses import SyncResponse
 
+from opsdroid.connector.matrix.tests.conftest import message_factory, sync_response
+
 
 async def events_from_sync(events, connector):
     response = SyncResponse.from_dict(sync_response(events))
@@ -15,12 +18,12 @@ def assert_event_properties(event, **kwargs):
 
 
 @pytest.mark.add_response(
-    "/_matrix/client/r0/profile/@test:localhost/displayname",
+    "/_matrix/client/r0/rooms/!12345:localhost/state/m.room.member/@test:localhost",
     "GET",
     {"displayname": "test"},
 )
 @pytest.mark.add_response(
-    "/_matrix/client/r0/profile/@test:localhost/displayname",
+    "/_matrix/client/r0/rooms/!12345:localhost/state/m.room.member/@test:localhost",
     "GET",
     {"displayname": "test"},
 )
@@ -53,4 +56,89 @@ async def test_receive_message(opsdroid, connector_connected, mock_api, caplog):
         target="!12345:localhost",
     )
 
-    assert len(caplog.records) == 0, caplog.records
+    assert len(caplog.record_tuples) == 0, caplog.records
+
+
+@pytest.mark.matrix_connector_config(
+    {"access_token": "hello", "rooms": {"main": "#test:localhost"}}
+)
+@pytest.mark.asyncio
+async def test_get_nick_error(opsdroid, connector_connected, mock_api, caplog):
+    events = await events_from_sync(
+        [
+            message_factory("Hello", "m.text", "@test:localhost"),
+        ],
+        connector_connected,
+    )
+
+    assert_event_properties(
+        events[0],
+        text="Hello",
+        user_id="@test:localhost",
+        user="@test:localhost",
+        target="!12345:localhost",
+    )
+
+    assert len(caplog.record_tuples) == 1
+
+    assert caplog.record_tuples == [
+        (
+            "opsdroid.connector.matrix.connector",
+            logging.ERROR,
+            "Error during getting display name from room state: unknown error (status code None)",
+        )
+    ]
+
+
+@pytest.mark.add_response(
+    "/_matrix/client/r0/rooms/!12345:localhost/state/m.room.member/@test:localhost",
+    "GET",
+    {"displayname": "test"},
+)
+@pytest.mark.matrix_connector_config(
+    {"access_token": "hello", "rooms": {"main": "#test:localhost"}}
+)
+@pytest.mark.asyncio
+async def test_invite_with_message(opsdroid, connector_connected, mock_api, caplog):
+    events = [
+        message_factory("Hello", "m.text", "@test:localhost"),
+    ]
+
+    sync_dict = sync_response(events)
+    sync_dict["rooms"]["invite"] = {
+        "!12345:localhost": {
+            "invite_state": {
+                "events": [
+                    {
+                        "content": {"displayname": "Test", "membership": "invite"},
+                        "origin_server_ts": 1612718865588,
+                        "sender": "@other:localhost",
+                        "state_key": "@test:localhost",
+                        "type": "m.room.member",
+                        "event_id": "$NGAUv_hybVRv5jop5SdWyMW-fCJ66D5d4FMNtydCzw",
+                    }
+                ]
+            }
+        }
+    }
+
+    response = SyncResponse.from_dict(sync_dict)
+
+    events = [m async for m in connector_connected._parse_sync_response(response)]
+
+    assert_event_properties(
+        events[0],
+        user_id="@other:localhost",
+        user="@other:localhost",
+        target="!12345:localhost",
+    )
+
+    assert_event_properties(
+        events[1],
+        text="Hello",
+        user_id="@test:localhost",
+        user="test",
+        target="!12345:localhost",
+    )
+
+    assert len(caplog.record_tuples) == 0, caplog.records
diff --git a/tests/test_connector_matrix.py b/tests/test_connector_matrix.py
--- a/tests/test_connector_matrix.py
+++ b/tests/test_connector_matrix.py
@@ -284,181 +284,6 @@ async def test_exchange_keys(self, mocker, connector):
         assert patched_keys_query.called
         patched_keys_claim.assert_called_with(patched_get_users())
 
-    async def test_parse_sync_response(self, connector):
-        connector.room_ids = {"main": "!aroomid:localhost"}
-        connector.filter_id = "arbitrary string"
-
-        with amock.patch(api_string.format("get_displayname")) as patched_name:
-
-            patched_name.return_value = asyncio.Future()
-            patched_name.return_value.set_result(
-                nio.ProfileGetDisplayNameResponse("SomeUsersName")
-            )
-
-            returned_message = [
-                m async for m in connector._parse_sync_response(self.sync_return)
-            ]
-            assert len(returned_message) == 1
-
-            returned_message = returned_message[0]
-
-            assert isinstance(returned_message, events.Message)
-            assert returned_message.text == "LOUD NOISES"
-            assert returned_message.user == "SomeUsersName"
-            assert returned_message.target == "!aroomid:localhost"
-            assert returned_message.connector == connector
-            raw_message = (
-                self.sync_return.rooms.join["!aroomid:localhost"]
-                .timeline.events[0]
-                .source
-            )
-            assert returned_message.raw_event == raw_message
-
-            returned_message = [
-                m async for m in connector._parse_sync_response(self.sync_return_join)
-            ][0]
-
-            assert isinstance(returned_message, events.JoinRoom)
-            assert returned_message.user == "SomeUsersName"
-            assert returned_message.target == "!aroomid:localhost"
-            assert returned_message.connector == connector
-            raw_message = (
-                self.sync_return_join.rooms.join["!aroomid:localhost"]
-                .timeline.events[0]
-                .source
-            )
-            raw_message["content"] = {"membership": "join"}
-            assert returned_message.raw_event == raw_message
-
-    async def test_sync_parse_invites(self, connector):
-        with amock.patch(api_string.format("get_displayname")) as patched_name:
-            patched_name.return_value = asyncio.Future()
-            patched_name.return_value.set_result(
-                nio.ProfileGetDisplayNameResponse("SomeUsersName")
-            )
-
-            (invite,) = [
-                m async for m in connector._parse_sync_response(self.sync_invite)
-            ]
-
-            assert invite.target == "!AWtmOvkBPTCSPbdaHn:localhost"
-            assert invite.user == "SomeUsersName"
-            assert invite.user_id == "@neo:matrix.org"
-            assert invite.connector is connector
-
-    async def test_get_nick(self, connector):
-        connector.room_specific_nicks = False
-
-        with amock.patch(api_string.format("get_displayname")) as patched_globname:
-
-            mxid = "@notaperson:matrix.org"
-
-            patched_globname.return_value = asyncio.Future()
-            patched_globname.return_value.set_result(
-                nio.ProfileGetDisplayNameResponse(displayname="notaperson")
-            )
-            assert await connector.get_nick("#notaroom:localhost", mxid) == "notaperson"
-
-    async def test_get_room_specific_nick(self, caplog, connector):
-        connector.room_specific_nicks = True
-
-        with amock.patch(
-            api_string.format("get_displayname")
-        ) as patched_globname, amock.patch(
-            api_string.format("joined_members")
-        ) as patched_joined:
-
-            mxid = "@notaperson:matrix.org"
-
-            patched_globname.return_value = asyncio.Future()
-            patched_globname.return_value.set_result(
-                nio.ProfileGetDisplayNameResponse(displayname="notaperson")
-            )
-
-            patched_joined.return_value = asyncio.Future()
-            patched_joined.return_value.set_result(
-                nio.JoinedMembersResponse(
-                    members=[
-                        nio.RoomMember(
-                            user_id="@notaperson:matrix.org",
-                            display_name="notaperson",
-                            avatar_url="",
-                        )
-                    ],
-                    room_id="notanid",
-                )
-            )
-
-            assert await connector.get_nick("#notaroom:localhost", mxid) == "notaperson"
-
-            assert await connector.get_nick(None, mxid) == "notaperson"
-
-            # test member not in list
-            patched_joined.return_value = asyncio.Future()
-            patched_joined.return_value.set_result(
-                nio.JoinedMembersResponse(members=[], room_id="notanid")
-            )
-            assert await connector.get_nick("#notaroom:localhost", mxid) == mxid
-
-            # test JoinedMembersError
-            patched_joined.return_value = asyncio.Future()
-            patched_joined.return_value.set_result(
-                nio.JoinedMembersError(message="Some error", status_code=400)
-            )
-            caplog.clear()
-            assert await connector.get_nick("#notaroom:localhost", mxid) == "notaperson"
-            assert ["Failed to lookup room members for #notaroom:localhost."] == [
-                rec.message for rec in caplog.records
-            ]
-
-            # test displayname is not set
-            patched_globname.return_value = asyncio.Future()
-            patched_globname.return_value.set_result(
-                nio.ProfileGetDisplayNameResponse(displayname=None)
-            )
-            caplog.clear()
-            assert await connector.get_nick("#notaroom:localhost", mxid) == mxid
-            assert ["Failed to lookup room members for #notaroom:localhost."] == [
-                rec.message for rec in caplog.records
-            ]
-
-            # test ProfileGetDisplayNameError
-            patched_globname.return_value = asyncio.Future()
-            patched_globname.return_value.set_result(
-                nio.ProfileGetDisplayNameError(message="Some error", status_code=400)
-            )
-            caplog.clear()
-            assert await connector.get_nick("#notaroom:localhost", mxid) == mxid
-            assert f"Failed to lookup nick for {mxid}." == caplog.records[1].message
-
-    async def test_get_nick_not_set(self, connector):
-        connector.room_specific_nicks = False
-
-        with amock.patch(api_string.format("get_displayname")) as patched_globname:
-
-            mxid = "@notaperson:matrix.org"
-
-            # Test that failed nickname lookup returns the mxid
-            patched_globname.return_value = asyncio.Future()
-            patched_globname.return_value.set_result(
-                nio.ProfileGetDisplayNameResponse(displayname=None)
-            )
-            assert await connector.get_nick("#notaroom:localhost", mxid) == mxid
-
-    async def test_get_nick_error(self, connector):
-        connector.room_specific_nicks = False
-
-        with amock.patch(api_string.format("get_displayname")) as patched_globname:
-
-            mxid = "@notaperson:matrix.org"
-
-            # Test if that leads to a global displayname being returned
-            patched_globname.return_value = asyncio.Future()
-            patched_globname.return_value.set_result(
-                nio.ProfileGetDisplayNameError(message="Error")
-            )
-            assert await connector.get_nick("#notaroom:localhost", mxid) == mxid
-
     async def test_get_formatted_message_body(self, connector):
         original_html = "<p><h3><no>Hello World</no></h3></p>"
         original_body = "### Hello World"