From c36a0db4a1ce4bbc5ea066a8475cef4a5c59238b Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 6 Jul 2026 19:49:55 -0700 Subject: [PATCH 1/8] add profile command example to retrieve member information --- examples/client_info.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 examples/client_info.py diff --git a/examples/client_info.py b/examples/client_info.py new file mode 100644 index 0000000..74c5060 --- /dev/null +++ b/examples/client_info.py @@ -0,0 +1,28 @@ +from matrix import Bot + +bot = Bot() + + +@bot.command() +async def profile(ctx): + member = ctx.member + + name = await member.get_display_name() + avatar = await member.get_avatar_url() + level = await member.get_room_power_level(ctx.room) + presence = await member.get_presence() + has_permission = await member.has_permission(ctx.room, "ban") + has_event_permission = await member.has_event_permission(ctx.room, "m.room.message") + + await ctx.reply( + f"User ID: {member.user_id}\n" + f"Display name: {name}\n" + f"Avatar URL: {avatar}\n" + f"Power level: {level}\n" + f"Presence: {presence}\n" + f"Has permission to ban users: {has_permission}\n" + f"Has permission to send messages: {has_event_permission}" + ) + + +bot.start(config="config.yaml") From ac232c89d15670f5bf5c179ff356b28012e17892 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 6 Jul 2026 19:50:34 -0700 Subject: [PATCH 2/8] Add Member class to manage member-related functionalities --- matrix/context.py | 2 + matrix/member.py | 126 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 matrix/member.py diff --git a/matrix/context.py b/matrix/context.py index 185b5f0..ab4d3cb 100644 --- a/matrix/context.py +++ b/matrix/context.py @@ -7,6 +7,7 @@ from .message import Message from .room import Room from .types import File, Image +from .member import Member if TYPE_CHECKING: from .bot import Bot # pragma: no cover @@ -23,6 +24,7 @@ def __init__(self, bot: "Bot", room: Room, event: Event): self.bot = bot self.room = room self.event = event + self.member = Member(event.sender, bot.client) self.body: str = getattr(event, "body", "") self.sender: str = event.sender diff --git a/matrix/member.py b/matrix/member.py new file mode 100644 index 0000000..6fc705e --- /dev/null +++ b/matrix/member.py @@ -0,0 +1,126 @@ +from matrix.room import Room +from nio import AsyncClient + + +class Member: + def __init__(self, user_id: str, client: AsyncClient) -> None: + self._user_id: str = user_id + self._client: AsyncClient = client + + @property + def user_id(self) -> str: + return self._user_id + + async def get_profile(self) -> dict | None: + """Get the profile information for this member. + + ## Example + + ```python + profile = await member.get_profile() + for key, value in profile.other_info.items(): + print(f"{key}: {value}") + ``` + """ + profile = await self._client.get_profile(self._user_id) + return profile if profile else None + + async def get_display_name(self) -> str | None: + """Get the display name for this member. + + ## Example + + ```python + display_name = await member.get_display_name() + print(f"Display name: {display_name}") + ``` + """ + display_name = await self._client.get_displayname(self._user_id) + return display_name.displayname if display_name else None + + async def get_avatar_url(self) -> str | None: + """Get the avatar URL for this member. + + ## Example + + ```python + avatar_url = await member.get_avatar_url() + print(f"Avatar URL: {avatar_url}") + ``` + """ + avatar = await self._client.get_avatar(self._user_id) + return await self._client.mxc_to_http(avatar.avatar_url) if avatar else None + + async def get_room_power_level(self, room: Room) -> int: + """Get the power level for this member in a specific room. + + ## Example + + ```python + level = await member.get_room_power_level(ctx.room) + print(f"Power level in room {room.room_id}: {level}") + ``` + """ + power_level = await self._client.room_get_state_event( + room.room_id, "m.room.power_levels", "" + ) + + content = getattr(power_level, "content", {}) or {} + users = content.get("users", {}) + default = content.get("users_default", 0) + + return int(users.get(self._user_id, default)) + + async def get_presence(self) -> str | None: + """Get the presence status for this member. + + ## Example + + ```python + presence = await member.get_presence() + print(f"Presence status: {presence}") + ``` + """ + presence = await self._client.get_presence(self._user_id) + return presence.presence if presence else None + + async def has_permission(self, room: Room, permission: str) -> bool: + """Check if this member has a specific permission in a room. + + ## Example + + ```python + has_permission = await member.has_permission(ctx.room, "ban") + print(f"Has ban permission: {has_permission}") + ``` + """ + power_level = await self.get_room_power_level(room) + power_levels_event = await self._client.room_get_state_event( + room.room_id, "m.room.power_levels", "" + ) + + content = getattr(power_levels_event, "content", {}) or {} + required_level = content.get(permission, 0) + + return bool(power_level >= required_level) + + async def has_event_permission(self, room: Room, event_type: str) -> bool: + """Check if this member has permission to send a specific event type in a room. + + ## Example + + ```python + can_send_message = await member.has_event_permission(ctx.room, "m.room.message") + print(f"Can send messages: {can_send_message}") + ``` + """ + power_level = await self.get_room_power_level(room) + power_levels_event = await self._client.room_get_state_event( + room.room_id, "m.room.power_levels", "" + ) + + content = getattr(power_levels_event, "content", {}) or {} + events = content.get("events", {}) + required_level = events.get(event_type, 0) + + return bool(power_level >= required_level) From 98e0133d99b96e0f45020f6ffa6e2d016a3eea60 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 7 Jul 2026 15:27:32 -0700 Subject: [PATCH 3/8] Add unit tests for Member class method --- tests/test_member.py | 208 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/test_member.py diff --git a/tests/test_member.py b/tests/test_member.py new file mode 100644 index 0000000..8a244bf --- /dev/null +++ b/tests/test_member.py @@ -0,0 +1,208 @@ +import pytest +from unittest.mock import AsyncMock, Mock + +from nio import AsyncClient +from matrix.member import Member +from matrix.room import Room + + +@pytest.fixture +def client(): + return AsyncMock(spec=AsyncClient) + + +@pytest.fixture +def room(): + room = Mock(spec=Room) + room.room_id = "!room:example.com" + return room + + +@pytest.fixture +def member(client): + return Member("@user:matrix.org", client) + + +def test_member_user_id__expect_correct_value(member): + assert member.user_id == "@user:matrix.org" + + +@pytest.mark.asyncio +async def test_get_profile__expect_profile_returned(member, client): + mock_profile = Mock() + mock_profile.displayname = "Alice" + mock_profile.avatar_url = "mxc://matrix.org/avatar" + mock_profile.other_info = {"chat.commet.profile_status": "Online"} + + client.get_profile = AsyncMock(return_value=mock_profile) + + result = await member.get_profile() + + client.get_profile.assert_awaited_once_with("@user:matrix.org") + assert result is mock_profile + + +@pytest.mark.asyncio +async def test_get_profile_empty__expect_none(member, client): + client.get_profile = AsyncMock(return_value=None) + + result = await member.get_profile() + + assert result is None + + +@pytest.mark.asyncio +async def test_get_display_name__expect_display_name(member, client): + response = Mock() + response.displayname = "Alice" + client.get_displayname = AsyncMock(return_value=response) + + result = await member.get_display_name() + + client.get_displayname.assert_awaited_once_with("@user:matrix.org") + assert result == "Alice" + + +@pytest.mark.asyncio +async def test_get_display_name_empty__expect_none(member, client): + client.get_displayname = AsyncMock(return_value=None) + + result = await member.get_display_name() + + assert result is None + + +@pytest.mark.asyncio +async def test_get_avatar_url__expect_http_avatar_url(member, client): + response = Mock() + response.avatar_url = "mxc://matrix.org/avatar" + + client.get_avatar = AsyncMock(return_value=response) + client.mxc_to_http = AsyncMock(return_value="https://matrix.org/avatar") + + result = await member.get_avatar_url() + + client.get_avatar.assert_awaited_once_with("@user:matrix.org") + client.mxc_to_http.assert_awaited_once_with("mxc://matrix.org/avatar") + assert result == "https://matrix.org/avatar" + + +@pytest.mark.asyncio +async def test_get_avatar_url_empty__expect_none(member, client): + client.get_avatar = AsyncMock(return_value=None) + + result = await member.get_avatar_url() + + assert result is None + + +@pytest.mark.asyncio +async def test_get_room_power_level__expect_user_power_level(member, client, room): + response = Mock() + response.content = { + "users": {"@user:matrix.org": 50}, + "users_default": 0, + } + client.room_get_state_event = AsyncMock(return_value=response) + + result = await member.get_room_power_level(room) + + client.room_get_state_event.assert_awaited_once_with( + "!room:example.com", "m.room.power_levels", "" + ) + assert result == 50 + + +@pytest.mark.asyncio +async def test_get_room_power_level_missing_user__expect_default(member, client, room): + response = Mock() + response.content = { + "users": {}, + "users_default": 10, + } + client.room_get_state_event = AsyncMock(return_value=response) + + result = await member.get_room_power_level(room) + + assert result == 10 + + +@pytest.mark.asyncio +async def test_get_presence__expect_presence(member, client): + response = Mock() + response.presence = "online" + client.get_presence = AsyncMock(return_value=response) + + result = await member.get_presence() + + client.get_presence.assert_awaited_once_with("@user:matrix.org") + assert result == "online" + + +@pytest.mark.asyncio +async def test_get_presence_empty__expect_none(member, client): + client.get_presence = AsyncMock(return_value=None) + + result = await member.get_presence() + + assert result is None + + +@pytest.mark.asyncio +async def test_has_permission__expect_true(member, client, room): + response = Mock() + response.content = { + "users": {"@user:matrix.org": 50}, + "users_default": 0, + "ban": 50, + } + client.room_get_state_event = AsyncMock(return_value=response) + + result = await member.has_permission(room, "ban") + + assert result is True + + +@pytest.mark.asyncio +async def test_has_permission__expect_false(member, client, room): + response = Mock() + response.content = { + "users": {"@user:matrix.org": 10}, + "users_default": 0, + "ban": 50, + } + client.room_get_state_event = AsyncMock(return_value=response) + + result = await member.has_permission(room, "ban") + + assert result is False + + +@pytest.mark.asyncio +async def test_has_event_permission__expect_true(member, client, room): + response = Mock() + response.content = { + "users": {"@user:matrix.org": 50}, + "users_default": 0, + "events": {"m.room.message": 0}, + } + client.room_get_state_event = AsyncMock(return_value=response) + + result = await member.has_event_permission(room, "m.room.message") + + assert result is True + + +@pytest.mark.asyncio +async def test_has_event_permission__expect_false(member, client, room): + response = Mock() + response.content = { + "users": {"@user:matrix.org": 10}, + "users_default": 0, + "events": {"m.room.power_levels": 100}, + } + client.room_get_state_event = AsyncMock(return_value=response) + + result = await member.has_event_permission(room, "m.room.power_levels") + + assert result is False From a7ef6459716ce630e75cac435893c66dd2414112 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 8 Jul 2026 17:39:16 -0700 Subject: [PATCH 4/8] renamed example file --- examples/{client_info.py => member.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename examples/{client_info.py => member.py} (86%) diff --git a/examples/client_info.py b/examples/member.py similarity index 86% rename from examples/client_info.py rename to examples/member.py index 74c5060..ba97c5c 100644 --- a/examples/client_info.py +++ b/examples/member.py @@ -11,11 +11,11 @@ async def profile(ctx): avatar = await member.get_avatar_url() level = await member.get_room_power_level(ctx.room) presence = await member.get_presence() - has_permission = await member.has_permission(ctx.room, "ban") + has_permission = await member.has_room_permission(ctx.room, "ban") has_event_permission = await member.has_event_permission(ctx.room, "m.room.message") await ctx.reply( - f"User ID: {member.user_id}\n" + f"Welcome {member.mention()}!\n" f"Display name: {name}\n" f"Avatar URL: {avatar}\n" f"Power level: {level}\n" From b54ba1b0e4b929bbfbf0ecb068fe18a644ccc14f Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 8 Jul 2026 18:16:43 -0700 Subject: [PATCH 5/8] Add get_state_event method to Room class for fetching room state events --- matrix/room.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/matrix/room.py b/matrix/room.py index 1d1fc0c..5587211 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -1,6 +1,12 @@ -from typing import Any - -from nio import AsyncClient, MatrixRoom, Event +from typing import Any, Union + +from nio import ( + AsyncClient, + MatrixRoom, + Event, + RoomGetStateEventResponse, + RoomGetStateEventError, +) from matrix.api import matrix_call from matrix.message import Message @@ -97,6 +103,14 @@ def encrypted(self) -> bool: """Whether the room is encrypted.""" return self._matrix_room.encrypted # type: ignore[no-any-return] + async def get_state_event( + self, event_type: str, state_key: str = "" + ) -> Union[RoomGetStateEventResponse, RoomGetStateEventError]: + return await matrix_call( + self._client.room_get_state_event(self.room_id, event_type, state_key), + error_message=f"Failed to get state event for room {self._matrix_room.room_id}", + ) + async def send( self, content: str | None = None, From b09df78205c70c3a2fe7bbd6d10928b6cac74ae9 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 8 Jul 2026 18:19:47 -0700 Subject: [PATCH 6/8] Refactor Member class to use matrix_call for API calls --- matrix/member.py | 74 +++++++++++++++++---------- tests/test_member.py | 119 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 151 insertions(+), 42 deletions(-) diff --git a/matrix/member.py b/matrix/member.py index 6fc705e..898f5a3 100644 --- a/matrix/member.py +++ b/matrix/member.py @@ -1,5 +1,6 @@ from matrix.room import Room from nio import AsyncClient +from matrix.api import matrix_call class Member: @@ -7,10 +8,24 @@ def __init__(self, user_id: str, client: AsyncClient) -> None: self._user_id: str = user_id self._client: AsyncClient = client + def __str__(self) -> str: + return self.mention() + @property def user_id(self) -> str: return self._user_id + def mention(self) -> str: + """Get a Markdown-formatted mention link (matrix.to pill) for this member. + + ## Example + + ```python + await ctx.reply(f"Welcome {member.mention()}!") + ``` + """ + return f"[{self._user_id}](https://matrix.to/#/{self._user_id})" + async def get_profile(self) -> dict | None: """Get the profile information for this member. @@ -22,7 +37,10 @@ async def get_profile(self) -> dict | None: print(f"{key}: {value}") ``` """ - profile = await self._client.get_profile(self._user_id) + profile = await matrix_call( + self._client.get_profile(self._user_id), + error_message=f"Failed to get profile for user {self._user_id}", + ) return profile if profile else None async def get_display_name(self) -> str | None: @@ -35,7 +53,10 @@ async def get_display_name(self) -> str | None: print(f"Display name: {display_name}") ``` """ - display_name = await self._client.get_displayname(self._user_id) + display_name = await matrix_call( + self._client.get_displayname(self._user_id), + error_message=f"Failed to get display name for user {self._user_id}", + ) return display_name.displayname if display_name else None async def get_avatar_url(self) -> str | None: @@ -51,6 +72,22 @@ async def get_avatar_url(self) -> str | None: avatar = await self._client.get_avatar(self._user_id) return await self._client.mxc_to_http(avatar.avatar_url) if avatar else None + async def get_presence(self) -> str | None: + """Get the presence status for this member. + + ## Example + + ```python + presence = await member.get_presence() + print(f"Presence status: {presence}") + ``` + """ + presence = await matrix_call( + self._client.get_presence(self._user_id), + error_message=f"Failed to get presence for user {self._user_id}", + ) + return presence.presence if presence else None + async def get_room_power_level(self, room: Room) -> int: """Get the power level for this member in a specific room. @@ -61,9 +98,7 @@ async def get_room_power_level(self, room: Room) -> int: print(f"Power level in room {room.room_id}: {level}") ``` """ - power_level = await self._client.room_get_state_event( - room.room_id, "m.room.power_levels", "" - ) + power_level = await room.get_state_event("m.room.power_levels", "") content = getattr(power_level, "content", {}) or {} users = content.get("users", {}) @@ -71,36 +106,23 @@ async def get_room_power_level(self, room: Room) -> int: return int(users.get(self._user_id, default)) - async def get_presence(self) -> str | None: - """Get the presence status for this member. - - ## Example - - ```python - presence = await member.get_presence() - print(f"Presence status: {presence}") - ``` - """ - presence = await self._client.get_presence(self._user_id) - return presence.presence if presence else None - - async def has_permission(self, room: Room, permission: str) -> bool: + async def has_room_permission(self, room: Room, permission: str) -> bool: """Check if this member has a specific permission in a room. ## Example ```python - has_permission = await member.has_permission(ctx.room, "ban") + has_permission = await member.has_room_permission(ctx.room, "ban") print(f"Has ban permission: {has_permission}") ``` """ power_level = await self.get_room_power_level(room) - power_levels_event = await self._client.room_get_state_event( - room.room_id, "m.room.power_levels", "" - ) + power_levels_event = await room.get_state_event("m.room.power_levels", "") content = getattr(power_levels_event, "content", {}) or {} - required_level = content.get(permission, 0) + required_level = content.get(permission) + if required_level is None: + return False # Permission not defined in the room's power levels return bool(power_level >= required_level) @@ -115,9 +137,7 @@ async def has_event_permission(self, room: Room, event_type: str) -> bool: ``` """ power_level = await self.get_room_power_level(room) - power_levels_event = await self._client.room_get_state_event( - room.room_id, "m.room.power_levels", "" - ) + power_levels_event = await room.get_state_event("m.room.power_levels", "") content = getattr(power_levels_event, "content", {}) or {} events = content.get("events", {}) diff --git a/tests/test_member.py b/tests/test_member.py index 8a244bf..fd300d4 100644 --- a/tests/test_member.py +++ b/tests/test_member.py @@ -27,6 +27,16 @@ def test_member_user_id__expect_correct_value(member): assert member.user_id == "@user:matrix.org" +def test_member_mention__expect_matrix_to_link(member): + assert ( + member.mention() == "[@user:matrix.org](https://matrix.to/#/@user:matrix.org)" + ) + + +def test_member_str__expect_mention(member): + assert str(member) == "[@user:matrix.org](https://matrix.to/#/@user:matrix.org)" + + @pytest.mark.asyncio async def test_get_profile__expect_profile_returned(member, client): mock_profile = Mock() @@ -93,40 +103,62 @@ async def test_get_avatar_url_empty__expect_none(member, client): result = await member.get_avatar_url() + client.mxc_to_http.assert_not_awaited() assert result is None @pytest.mark.asyncio -async def test_get_room_power_level__expect_user_power_level(member, client, room): +async def test_get_room_power_level__expect_user_power_level(member, room): response = Mock() response.content = { "users": {"@user:matrix.org": 50}, "users_default": 0, } - client.room_get_state_event = AsyncMock(return_value=response) + room.get_state_event = AsyncMock(return_value=response) result = await member.get_room_power_level(room) - client.room_get_state_event.assert_awaited_once_with( - "!room:example.com", "m.room.power_levels", "" - ) + room.get_state_event.assert_awaited_once_with("m.room.power_levels", "") assert result == 50 @pytest.mark.asyncio -async def test_get_room_power_level_missing_user__expect_default(member, client, room): +async def test_get_room_power_level_missing_user__expect_default(member, room): response = Mock() response.content = { "users": {}, "users_default": 10, } - client.room_get_state_event = AsyncMock(return_value=response) + room.get_state_event = AsyncMock(return_value=response) result = await member.get_room_power_level(room) assert result == 10 +@pytest.mark.asyncio +async def test_get_room_power_level_missing_content__expect_zero(member, room): + response = Mock() + response.content = None + room.get_state_event = AsyncMock(return_value=response) + + result = await member.get_room_power_level(room) + + assert result == 0 + + +@pytest.mark.asyncio +async def test_get_room_power_level_missing_event_content_attr__expect_zero( + member, room +): + response = object() + room.get_state_event = AsyncMock(return_value=response) + + result = await member.get_room_power_level(room) + + assert result == 0 + + @pytest.mark.asyncio async def test_get_presence__expect_presence(member, client): response = Mock() @@ -149,31 +181,59 @@ async def test_get_presence_empty__expect_none(member, client): @pytest.mark.asyncio -async def test_has_permission__expect_true(member, client, room): +async def test_has_room_permission__expect_true(member, client, room): response = Mock() response.content = { "users": {"@user:matrix.org": 50}, "users_default": 0, "ban": 50, } - client.room_get_state_event = AsyncMock(return_value=response) + room.get_state_event = AsyncMock(return_value=response) - result = await member.has_permission(room, "ban") + result = await member.has_room_permission(room, "ban") + assert room.get_state_event.await_count == 2 assert result is True @pytest.mark.asyncio -async def test_has_permission__expect_false(member, client, room): +async def test_has_room_permission__expect_false(member, client, room): response = Mock() response.content = { "users": {"@user:matrix.org": 10}, "users_default": 0, "ban": 50, } - client.room_get_state_event = AsyncMock(return_value=response) + room.get_state_event = AsyncMock(return_value=response) - result = await member.has_permission(room, "ban") + result = await member.has_room_permission(room, "ban") + + assert result is False + + +@pytest.mark.asyncio +async def test_has_room_permission_missing_permission__expect_false( + member, client, room +): + response = Mock() + response.content = { + "users": {"@user:matrix.org": 100}, + "users_default": 0, + } + room.get_state_event = AsyncMock(return_value=response) + + result = await member.has_room_permission(room, "ban") + + assert result is False + + +@pytest.mark.asyncio +async def test_has_room_permission_missing_content__expect_false(member, client, room): + response = Mock() + response.content = None + room.get_state_event = AsyncMock(return_value=response) + + result = await member.has_room_permission(room, "ban") assert result is False @@ -186,10 +246,11 @@ async def test_has_event_permission__expect_true(member, client, room): "users_default": 0, "events": {"m.room.message": 0}, } - client.room_get_state_event = AsyncMock(return_value=response) + room.get_state_event = AsyncMock(return_value=response) result = await member.has_event_permission(room, "m.room.message") + assert room.get_state_event.await_count == 2 assert result is True @@ -201,8 +262,36 @@ async def test_has_event_permission__expect_false(member, client, room): "users_default": 0, "events": {"m.room.power_levels": 100}, } - client.room_get_state_event = AsyncMock(return_value=response) + room.get_state_event = AsyncMock(return_value=response) result = await member.has_event_permission(room, "m.room.power_levels") assert result is False + + +@pytest.mark.asyncio +async def test_has_event_permission_missing_event_level__expect_true( + member, client, room +): + response = Mock() + response.content = { + "users": {"@user:matrix.org": 0}, + "users_default": 0, + "events": {}, + } + room.get_state_event = AsyncMock(return_value=response) + + result = await member.has_event_permission(room, "m.room.message") + + assert result is True + + +@pytest.mark.asyncio +async def test_has_event_permission_missing_content__expect_true(member, client, room): + response = Mock() + response.content = None + room.get_state_event = AsyncMock(return_value=response) + + result = await member.has_event_permission(room, "m.room.message") + + assert result is True From 36404bf2612a44d1bcc4aad0e6cc06561b499e04 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 9 Jul 2026 15:38:33 -0700 Subject: [PATCH 7/8] Refactor get_profile method to return MemberProfile and add MemberProfile dataclass --- matrix/member.py | 19 +++++++++++++++++-- tests/test_member.py | 8 ++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/matrix/member.py b/matrix/member.py index 898f5a3..a2d2639 100644 --- a/matrix/member.py +++ b/matrix/member.py @@ -1,6 +1,15 @@ from matrix.room import Room from nio import AsyncClient from matrix.api import matrix_call +from dataclasses import dataclass +from typing import Any + + +@dataclass +class MemberProfile: + displayname: str | None + avatar_url: str | None + other_info: dict[Any, Any] class Member: @@ -26,7 +35,7 @@ def mention(self) -> str: """ return f"[{self._user_id}](https://matrix.to/#/{self._user_id})" - async def get_profile(self) -> dict | None: + async def get_profile(self) -> MemberProfile | None: """Get the profile information for this member. ## Example @@ -41,7 +50,13 @@ async def get_profile(self) -> dict | None: self._client.get_profile(self._user_id), error_message=f"Failed to get profile for user {self._user_id}", ) - return profile if profile else None + if profile: + return MemberProfile( + displayname=profile.displayname, + avatar_url=profile.avatar_url, + other_info=profile.other_info, + ) + return None async def get_display_name(self) -> str | None: """Get the display name for this member. diff --git a/tests/test_member.py b/tests/test_member.py index fd300d4..9b9fa1b 100644 --- a/tests/test_member.py +++ b/tests/test_member.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, Mock from nio import AsyncClient -from matrix.member import Member +from matrix.member import Member, MemberProfile from matrix.room import Room @@ -49,7 +49,11 @@ async def test_get_profile__expect_profile_returned(member, client): result = await member.get_profile() client.get_profile.assert_awaited_once_with("@user:matrix.org") - assert result is mock_profile + assert result == MemberProfile( + displayname="Alice", + avatar_url="mxc://matrix.org/avatar", + other_info={"chat.commet.profile_status": "Online"}, + ) @pytest.mark.asyncio From 97897adc71dfcb22697897d9356b6ae70b4e5ca8 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 9 Jul 2026 17:59:03 -0700 Subject: [PATCH 8/8] Refactor Member class methods to use matrix_call for error handling and improve test cases for profile and permission checks --- matrix/member.py | 17 +++- tests/test_member.py | 228 ++++++++++++++++++++++--------------------- 2 files changed, 131 insertions(+), 114 deletions(-) diff --git a/matrix/member.py b/matrix/member.py index a2d2639..d698602 100644 --- a/matrix/member.py +++ b/matrix/member.py @@ -84,7 +84,10 @@ async def get_avatar_url(self) -> str | None: print(f"Avatar URL: {avatar_url}") ``` """ - avatar = await self._client.get_avatar(self._user_id) + avatar = await matrix_call( + self._client.get_avatar(self._user_id), + error_message=f"Failed to get avatar for user {self._user_id}", + ) return await self._client.mxc_to_http(avatar.avatar_url) if avatar else None async def get_presence(self) -> str | None: @@ -114,8 +117,10 @@ async def get_room_power_level(self, room: Room) -> int: ``` """ power_level = await room.get_state_event("m.room.power_levels", "") + if power_level is None: + return 0 - content = getattr(power_level, "content", {}) or {} + content = power_level.content users = content.get("users", {}) default = content.get("users_default", 0) @@ -133,8 +138,10 @@ async def has_room_permission(self, room: Room, permission: str) -> bool: """ power_level = await self.get_room_power_level(room) power_levels_event = await room.get_state_event("m.room.power_levels", "") + if power_levels_event is None: + return False - content = getattr(power_levels_event, "content", {}) or {} + content = power_levels_event.content required_level = content.get(permission) if required_level is None: return False # Permission not defined in the room's power levels @@ -153,8 +160,10 @@ async def has_event_permission(self, room: Room, event_type: str) -> bool: """ power_level = await self.get_room_power_level(room) power_levels_event = await room.get_state_event("m.room.power_levels", "") + if power_levels_event is None: + return False - content = getattr(power_levels_event, "content", {}) or {} + content = power_levels_event.content events = content.get("events", {}) required_level = events.get(event_type, 0) diff --git a/tests/test_member.py b/tests/test_member.py index 9b9fa1b..6927f76 100644 --- a/tests/test_member.py +++ b/tests/test_member.py @@ -1,7 +1,14 @@ import pytest from unittest.mock import AsyncMock, Mock -from nio import AsyncClient +from nio import ( + AsyncClient, + PresenceGetResponse, + ProfileGetAvatarResponse, + ProfileGetDisplayNameResponse, + ProfileGetResponse, + RoomGetStateEventResponse, +) from matrix.member import Member, MemberProfile from matrix.room import Room @@ -23,28 +30,39 @@ def member(client): return Member("@user:matrix.org", client) -def test_member_user_id__expect_correct_value(member): +def power_levels_response(content): + return RoomGetStateEventResponse( + content=content, + event_type="m.room.power_levels", + state_key="", + room_id="!room:example.com", + ) + + +def test_member_user_id__expect_configured_user_id(member): assert member.user_id == "@user:matrix.org" -def test_member_mention__expect_matrix_to_link(member): +def test_member_mention__expect_markdown_matrix_to_link(member): assert ( member.mention() == "[@user:matrix.org](https://matrix.to/#/@user:matrix.org)" ) -def test_member_str__expect_mention(member): - assert str(member) == "[@user:matrix.org](https://matrix.to/#/@user:matrix.org)" +def test_member_str__expect_same_value_as_mention(member): + assert str(member) == member.mention() @pytest.mark.asyncio -async def test_get_profile__expect_profile_returned(member, client): - mock_profile = Mock() - mock_profile.displayname = "Alice" - mock_profile.avatar_url = "mxc://matrix.org/avatar" - mock_profile.other_info = {"chat.commet.profile_status": "Online"} - - client.get_profile = AsyncMock(return_value=mock_profile) +async def test_get_profile__with_profile_response__expect_member_profile( + member, client +): + response = ProfileGetResponse( + displayname="Alice", + avatar_url="mxc://matrix.org/avatar", + other_info={"chat.commet.profile_status": "Online"}, + ) + client.get_profile = AsyncMock(return_value=response) result = await member.get_profile() @@ -57,7 +75,7 @@ async def test_get_profile__expect_profile_returned(member, client): @pytest.mark.asyncio -async def test_get_profile_empty__expect_none(member, client): +async def test_get_profile__when_client_returns_none__expect_none(member, client): client.get_profile = AsyncMock(return_value=None) result = await member.get_profile() @@ -66,9 +84,8 @@ async def test_get_profile_empty__expect_none(member, client): @pytest.mark.asyncio -async def test_get_display_name__expect_display_name(member, client): - response = Mock() - response.displayname = "Alice" +async def test_get_display_name__with_display_name__expect_value(member, client): + response = ProfileGetDisplayNameResponse(displayname="Alice") client.get_displayname = AsyncMock(return_value=response) result = await member.get_display_name() @@ -78,8 +95,8 @@ async def test_get_display_name__expect_display_name(member, client): @pytest.mark.asyncio -async def test_get_display_name_empty__expect_none(member, client): - client.get_displayname = AsyncMock(return_value=None) +async def test_get_display_name__without_display_name__expect_none(member, client): + client.get_displayname = AsyncMock(return_value=ProfileGetDisplayNameResponse()) result = await member.get_display_name() @@ -87,10 +104,17 @@ async def test_get_display_name_empty__expect_none(member, client): @pytest.mark.asyncio -async def test_get_avatar_url__expect_http_avatar_url(member, client): - response = Mock() - response.avatar_url = "mxc://matrix.org/avatar" +async def test_get_display_name__when_client_returns_none__expect_none(member, client): + client.get_displayname = AsyncMock(return_value=None) + result = await member.get_display_name() + + assert result is None + + +@pytest.mark.asyncio +async def test_get_avatar_url__with_mxc_avatar__expect_http_url(member, client): + response = ProfileGetAvatarResponse(avatar_url="mxc://matrix.org/avatar") client.get_avatar = AsyncMock(return_value=response) client.mxc_to_http = AsyncMock(return_value="https://matrix.org/avatar") @@ -102,7 +126,7 @@ async def test_get_avatar_url__expect_http_avatar_url(member, client): @pytest.mark.asyncio -async def test_get_avatar_url_empty__expect_none(member, client): +async def test_get_avatar_url__when_client_returns_none__expect_none(member, client): client.get_avatar = AsyncMock(return_value=None) result = await member.get_avatar_url() @@ -113,11 +137,9 @@ async def test_get_avatar_url_empty__expect_none(member, client): @pytest.mark.asyncio async def test_get_room_power_level__expect_user_power_level(member, room): - response = Mock() - response.content = { - "users": {"@user:matrix.org": 50}, - "users_default": 0, - } + response = power_levels_response( + {"users": {"@user:matrix.org": 50}, "users_default": 0} + ) room.get_state_event = AsyncMock(return_value=response) result = await member.get_room_power_level(room) @@ -127,12 +149,10 @@ async def test_get_room_power_level__expect_user_power_level(member, room): @pytest.mark.asyncio -async def test_get_room_power_level_missing_user__expect_default(member, room): - response = Mock() - response.content = { - "users": {}, - "users_default": 10, - } +async def test_get_room_power_level__when_user_not_listed__expect_users_default( + member, room +): + response = power_levels_response({"users": {}, "users_default": 10}) room.get_state_event = AsyncMock(return_value=response) result = await member.get_room_power_level(room) @@ -141,9 +161,8 @@ async def test_get_room_power_level_missing_user__expect_default(member, room): @pytest.mark.asyncio -async def test_get_room_power_level_missing_content__expect_zero(member, room): - response = Mock() - response.content = None +async def test_get_room_power_level__when_content_empty__expect_zero(member, room): + response = power_levels_response({}) room.get_state_event = AsyncMock(return_value=response) result = await member.get_room_power_level(room) @@ -152,11 +171,8 @@ async def test_get_room_power_level_missing_content__expect_zero(member, room): @pytest.mark.asyncio -async def test_get_room_power_level_missing_event_content_attr__expect_zero( - member, room -): - response = object() - room.get_state_event = AsyncMock(return_value=response) +async def test_get_room_power_level__when_event_is_none__expect_zero(member, room): + room.get_state_event = AsyncMock(return_value=None) result = await member.get_room_power_level(room) @@ -164,9 +180,14 @@ async def test_get_room_power_level_missing_event_content_attr__expect_zero( @pytest.mark.asyncio -async def test_get_presence__expect_presence(member, client): - response = Mock() - response.presence = "online" +async def test_get_presence__with_online_presence__expect_online(member, client): + response = PresenceGetResponse( + user_id="@user:matrix.org", + presence="online", + last_active_ago=None, + currently_active=True, + status_msg=None, + ) client.get_presence = AsyncMock(return_value=response) result = await member.get_presence() @@ -176,7 +197,7 @@ async def test_get_presence__expect_presence(member, client): @pytest.mark.asyncio -async def test_get_presence_empty__expect_none(member, client): +async def test_get_presence__when_client_returns_none__expect_none(member, client): client.get_presence = AsyncMock(return_value=None) result = await member.get_presence() @@ -185,13 +206,16 @@ async def test_get_presence_empty__expect_none(member, client): @pytest.mark.asyncio -async def test_has_room_permission__expect_true(member, client, room): - response = Mock() - response.content = { - "users": {"@user:matrix.org": 50}, - "users_default": 0, - "ban": 50, - } +async def test_has_room_permission__with_user_level_meeting_requirement__expect_true( + member, room +): + response = power_levels_response( + { + "users": {"@user:matrix.org": 50}, + "users_default": 0, + "ban": 50, + } + ) room.get_state_event = AsyncMock(return_value=response) result = await member.has_room_permission(room, "ban") @@ -201,13 +225,14 @@ async def test_has_room_permission__expect_true(member, client, room): @pytest.mark.asyncio -async def test_has_room_permission__expect_false(member, client, room): - response = Mock() - response.content = { - "users": {"@user:matrix.org": 10}, - "users_default": 0, - "ban": 50, - } +async def test_has_room_permission__with_user_level_too_low__expect_false(member, room): + response = power_levels_response( + { + "users": {"@user:matrix.org": 10}, + "users_default": 0, + "ban": 50, + } + ) room.get_state_event = AsyncMock(return_value=response) result = await member.has_room_permission(room, "ban") @@ -216,25 +241,12 @@ async def test_has_room_permission__expect_false(member, client, room): @pytest.mark.asyncio -async def test_has_room_permission_missing_permission__expect_false( - member, client, room +async def test_has_room_permission__when_permission_not_defined__expect_false( + member, room ): - response = Mock() - response.content = { - "users": {"@user:matrix.org": 100}, - "users_default": 0, - } - room.get_state_event = AsyncMock(return_value=response) - - result = await member.has_room_permission(room, "ban") - - assert result is False - - -@pytest.mark.asyncio -async def test_has_room_permission_missing_content__expect_false(member, client, room): - response = Mock() - response.content = None + response = power_levels_response( + {"users": {"@user:matrix.org": 100}, "users_default": 0} + ) room.get_state_event = AsyncMock(return_value=response) result = await member.has_room_permission(room, "ban") @@ -243,13 +255,16 @@ async def test_has_room_permission_missing_content__expect_false(member, client, @pytest.mark.asyncio -async def test_has_event_permission__expect_true(member, client, room): - response = Mock() - response.content = { - "users": {"@user:matrix.org": 50}, - "users_default": 0, - "events": {"m.room.message": 0}, - } +async def test_has_event_permission__with_user_level_meeting_requirement__expect_true( + member, room +): + response = power_levels_response( + { + "users": {"@user:matrix.org": 50}, + "users_default": 0, + "events": {"m.room.message": 0}, + } + ) room.get_state_event = AsyncMock(return_value=response) result = await member.has_event_permission(room, "m.room.message") @@ -259,13 +274,16 @@ async def test_has_event_permission__expect_true(member, client, room): @pytest.mark.asyncio -async def test_has_event_permission__expect_false(member, client, room): - response = Mock() - response.content = { - "users": {"@user:matrix.org": 10}, - "users_default": 0, - "events": {"m.room.power_levels": 100}, - } +async def test_has_event_permission__with_user_level_too_low__expect_false( + member, room +): + response = power_levels_response( + { + "users": {"@user:matrix.org": 10}, + "users_default": 0, + "events": {"m.room.power_levels": 100}, + } + ) room.get_state_event = AsyncMock(return_value=response) result = await member.has_event_permission(room, "m.room.power_levels") @@ -274,26 +292,16 @@ async def test_has_event_permission__expect_false(member, client, room): @pytest.mark.asyncio -async def test_has_event_permission_missing_event_level__expect_true( - member, client, room +async def test_has_event_permission__when_event_level_not_defined__expect_true( + member, room ): - response = Mock() - response.content = { - "users": {"@user:matrix.org": 0}, - "users_default": 0, - "events": {}, - } - room.get_state_event = AsyncMock(return_value=response) - - result = await member.has_event_permission(room, "m.room.message") - - assert result is True - - -@pytest.mark.asyncio -async def test_has_event_permission_missing_content__expect_true(member, client, room): - response = Mock() - response.content = None + response = power_levels_response( + { + "users": {"@user:matrix.org": 0}, + "users_default": 0, + "events": {}, + } + ) room.get_state_event = AsyncMock(return_value=response) result = await member.has_event_permission(room, "m.room.message")