Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions examples/client_info.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of the file is a bit inaccurate, I think it should be something like member_info.py or just member.py

Original file line number Diff line number Diff line change
@@ -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")
2 changes: 2 additions & 0 deletions matrix/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
126 changes: 126 additions & 0 deletions matrix/member.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While looking at this, I think one of the most interesting addition to member you could've made is mention method. Something like:

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})"

def __str__(self) -> str:
    return self.mention()

You can see that I also implemented __str__, I think it's a good idea to make it fallback to mention so e can do:

await ctx.reply(f"Welcome {member}!")

But this could be another PR.

Original file line number Diff line number Diff line change
@@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be cleaner to have a dataclass for the MemberProfile instead of returning a plain dictionary; it would give callers type safety and make the fields explicit.

"""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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should move this closer (just above them) to has_permission and has_event_permission since they rely on this

"""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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be consistent with the following method, I think we could rename this has_room_permission

"""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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback would cause the power level to always be true.

For example, if the response is an error, it will fallback to 0. This could give access to someone when it shouldn't and it's going to be hard to debug because it's doing it silently. In the worst cases, a permission check should never default to "allowed".

If you use matrix_call it will raise and at the same time default to a "not allowed" behaviour.

Also, there was no test covering that and same thing for has_event_permission


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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to be sure, does event really use room power level too?

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)
208 changes: 208 additions & 0 deletions tests/test_member.py
Original file line number Diff line number Diff line change
@@ -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
Loading