From 98f6753ecf1c9edde2e6e9a282dc8b9b175c2ad0 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Mon, 6 Apr 2026 18:33:45 -0400 Subject: [PATCH 1/4] WIP better creation of message and reactions # Conflicts: # examples/reaction.py --- examples/reaction.py | 31 ++++++----------- matrix/__init__.py | 2 ++ matrix/bot.py | 11 +++--- matrix/message.py | 82 +++++++++++++++++++++++++++++++++----------- matrix/room.py | 41 ++++++++++++---------- 5 files changed, 103 insertions(+), 64 deletions(-) diff --git a/examples/reaction.py b/examples/reaction.py index 5f5c1ac..472c41f 100644 --- a/examples/reaction.py +++ b/examples/reaction.py @@ -1,48 +1,39 @@ +from asyncio import Event from nio import MatrixRoom, RoomMessageText, ReactionEvent -from matrix import Bot -from matrix.message import Message +from matrix import Bot, Room, Message bot = Bot() @bot.event -async def on_message(room: MatrixRoom, event: RoomMessageText) -> None: +async def on_message(room: Room, event: RoomMessageText) -> None: """ This function listens for new messages in a room and reacts based on the message content. """ room = bot.get_room(room.room_id) - message = Message( - room=room, - event_id=event.event_id, - body=event.body, - client=bot.client, - ) - - if event.body.lower().startswith("thanks"): + message = Message.from_event(room, event) + + if message.body.lower().startswith("thanks"): await message.react("🙏") - if event.body.lower().startswith("hello"): + if message.body.lower().startswith("hello"): # Can also react with a text message instead of emoji await message.react("hi") - if event.body.lower().startswith("❤️"): + if message.body.lower().startswith("❤️"): await message.react("❤️") @bot.event -async def on_react(room: MatrixRoom, event: ReactionEvent) -> None: +async def on_react(room: Room, event: ReactionEvent) -> None: """ This function listens for new member reaction to messages in a room, and reacts based on the reaction emoji. """ room = bot.get_room(room.room_id) - message = Message( - room=room, - event_id=event.event_id, - body=None, - client=bot.client, - ) + message = Message.from_event(room, event) + emoji = event.key if emoji == "🙏": diff --git a/matrix/__init__.py b/matrix/__init__.py index ef00fe2..07bee19 100644 --- a/matrix/__init__.py +++ b/matrix/__init__.py @@ -15,6 +15,7 @@ from .help import HelpCommand from .checks import cooldown from .room import Room +from .message import Message from .extension import Extension __all__ = [ @@ -27,5 +28,6 @@ "HelpCommand", "cooldown", "Room", + "Message", "Extension", ] diff --git a/matrix/bot.py b/matrix/bot.py index ed09437..26bd1c5 100644 --- a/matrix/bot.py +++ b/matrix/bot.py @@ -262,10 +262,10 @@ async def run(self) -> None: # MATRIX EVENTS - async def on_message(self, room: MatrixRoom, event: Event) -> None: + async def on_message(self, room: Room, event: Event) -> None: await self._process_commands(room, event) - async def _on_matrix_event(self, room: MatrixRoom, event: Event) -> None: + async def _on_matrix_event(self, matrix_room: MatrixRoom, event: Event) -> None: # ignore bot events if event.sender == self.client.user: return @@ -275,6 +275,7 @@ async def _on_matrix_event(self, room: MatrixRoom, event: Event) -> None: return try: + room = self.get_room(matrix_room.room_id) await self._dispatch_matrix_event(room, event) except Exception as error: await self._on_error(error) @@ -284,14 +285,14 @@ async def _dispatch(self, event_name: str, *args: Any, **kwargs: Any) -> None: for handler in self._hook_handlers.get(event_name, []): await handler(*args, **kwargs) - async def _dispatch_matrix_event(self, room: MatrixRoom, event: Event) -> None: + async def _dispatch_matrix_event(self, room: Room, event: Event) -> None: """Fire all listeners registered for a named matrix event.""" for event_type, funcs in self._event_handlers.items(): if isinstance(event, event_type): for func in funcs: await func(room, event) - async def _process_commands(self, room: MatrixRoom, event: Event) -> None: + async def _process_commands(self, room: Room, event: Event) -> None: """Parse and execute commands""" ctx = await self._build_context(room, event) @@ -303,7 +304,7 @@ async def _process_commands(self, room: MatrixRoom, event: Event) -> None: await self._on_command(ctx) await ctx.command(ctx) - async def _build_context(self, matrix_room: MatrixRoom, event: Event) -> Context: + async def _build_context(self, matrix_room: Room, event: Event) -> Context: room = self.get_room(matrix_room.room_id) ctx = Context(bot=self, room=room, event=event) prefix = self.prefix or self.config.prefix diff --git a/matrix/message.py b/matrix/message.py index f52cc8f..8403fa1 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -1,5 +1,7 @@ -from typing import TYPE_CHECKING -from nio import AsyncClient +from typing import TYPE_CHECKING, Self + +from nio import AsyncClient, Event + from matrix.content import ReactionContent, EditContent from matrix.errors import MatrixError @@ -10,38 +12,81 @@ class Message: """Represents a Matrix message with methods to interact with it.""" - def __init__( - self, *, room: "Room", event_id: str, body: str | None, client: AsyncClient - ) -> None: + def __init__(self, *, room: "Room", event: Event, client: AsyncClient) -> None: self._room = room - self._event_id = event_id - self._body = body + self._matrix_event: Event = event self._client = client + def __repr__(self) -> str: + return f"" + + @classmethod + def from_event(cls, room: "Room", event: Event) -> Self: + return Message( + room=room, + event=event, + client=room.client, + ) + @property def room(self) -> "Room": """The room this message was sent in.""" return self._room @property - def id(self) -> str: - """The event ID of this message.""" - return self._event_id + def event(self) -> Event: + """The matrix event of this message""" + return self._matrix_event + + @property + def client(self) -> AsyncClient: + """The Matrix client.""" + return self._client @property def event_id(self) -> str: - """The event ID of this message (alias for id).""" - return self._event_id + """The event ID of this message.""" + return self._matrix_event.event_id @property def body(self) -> str | None: """The text content of this message.""" - return self._body + return getattr(self._matrix_event, "body", None) - @property - def client(self) -> AsyncClient: - """The Matrix client.""" - return self._client + async def fetch_reactions(self) -> dict[str, list[str]]: + """Fetch all reactions for this message. + + Returns a dict mapping emoji to a list of sender IDs who reacted with it. + + + ## Example + ```python + @bot.command() + async def reactions(ctx: Context): + reactions = await ctx.message.fetch_reactions() + + for emoji, senders in reactions.items(): + await ctx.reply(f"{emoji}: {len(senders)} reaction(s)") + ``` + """ + reactions: dict[str, list[str]] = {} + + try: + async for response in self.client.room_get_event_relations( + room_id=self.room.room_id, + event_id=self.event_id, + relation_type="m.annotation", + ): + for event in response.chunk: + emoji = event.get("content", {}).get("m.relates_to", {}).get("key") + sender = event.get("sender") + + if emoji and sender: + reactions.setdefault(emoji, []).append(sender) + except Exception as e: + raise MatrixError(f"Failed to fetch reactions: {e}") + + return reactions async def reply(self, body: str) -> "Message": """Reply to this message. @@ -126,6 +171,3 @@ async def oops(ctx: Context): ) except Exception as e: raise MatrixError(f"Failed to delete message: {e}") - - def __repr__(self) -> str: - return f"" diff --git a/matrix/room.py b/matrix/room.py index 4780d7a..e521873 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -1,6 +1,6 @@ from typing import Any -from nio import AsyncClient, MatrixRoom +from nio import AsyncClient, MatrixRoom, Event from matrix.errors import MatrixError from matrix.message import Message @@ -25,6 +25,22 @@ def __init__(self, matrix_room: MatrixRoom, client: AsyncClient) -> None: self._matrix_room: MatrixRoom = matrix_room self._client: AsyncClient = client + def __getattr__(self, name: str) -> Any: + """ + Fallback to MatrixRoom for attributes not explicitly defined. + + This allows access to any MatrixRoom attribute not wrapped by this class. + See matrix-nio's MatrixRoom documentation for available attributes. + + https://matrix-nio.readthedocs.io/en/latest/nio.html#nio.rooms.MatrixRoom + """ + try: + return getattr(self._matrix_room, name) + except AttributeError: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) from None + @property def matrix_room(self) -> MatrixRoom: """Access to underlying MatrixRoom object.""" @@ -65,22 +81,6 @@ def encrypted(self) -> bool: """Whether the room is encrypted.""" return self._matrix_room.encrypted # type: ignore[no-any-return] - def __getattr__(self, name: str) -> Any: - """ - Fallback to MatrixRoom for attributes not explicitly defined. - - This allows access to any MatrixRoom attribute not wrapped by this class. - See matrix-nio's MatrixRoom documentation for available attributes. - - https://matrix-nio.readthedocs.io/en/latest/nio.html#nio.rooms.MatrixRoom - """ - try: - return getattr(self._matrix_room, name) - except AttributeError: - raise AttributeError( - f"'{type(self).__name__}' object has no attribute '{name}'" - ) from None - async def send( self, content: str | None = None, @@ -319,16 +319,19 @@ async def _send_payload(self, payload: BaseMessageContent) -> Message: message_type="m.room.message", content=payload.build(), ) + event = self.get_event(resp.event_id) return Message( room=self, - event_id=resp.event_id, - body=getattr(payload, "body", None), + event=event, client=self.client, ) except Exception as e: raise MatrixError(f"Failed to send message: {e}") + async def get_event(self, event_id: str) -> Event: + return + async def invite_user(self, user_id: str) -> None: """Invite a user to the room. From 2659cc0c750029d43782300441124ec0fc1d147b Mon Sep 17 00:00:00 2001 From: penguinboi Date: Mon, 6 Apr 2026 20:11:18 -0400 Subject: [PATCH 2/4] cleaned up code, added fetch_event, fetch_message and added Reaction type --- matrix/message.py | 51 ++++++++++++++++++++++------------------------- matrix/room.py | 37 +++++++++++++++++++++++++++++++--- matrix/types.py | 6 ++++++ 3 files changed, 64 insertions(+), 30 deletions(-) diff --git a/matrix/message.py b/matrix/message.py index 8403fa1..f221dea 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -2,6 +2,7 @@ from nio import AsyncClient, Event +from matrix.types import Reaction from matrix.content import ReactionContent, EditContent from matrix.errors import MatrixError @@ -17,16 +18,10 @@ def __init__(self, *, room: "Room", event: Event, client: AsyncClient) -> None: self._matrix_event: Event = event self._client = client - def __repr__(self) -> str: - return f"" + self._body = getattr(self._matrix_event, "body", None) - @classmethod - def from_event(cls, room: "Room", event: Event) -> Self: - return Message( - room=room, - event=event, - client=room.client, - ) + def __repr__(self) -> str: + return f"" @property def room(self) -> "Room": @@ -46,18 +41,22 @@ def client(self) -> AsyncClient: @property def event_id(self) -> str: """The event ID of this message.""" - return self._matrix_event.event_id + return str(self._matrix_event.event_id) @property def body(self) -> str | None: """The text content of this message.""" - return getattr(self._matrix_event, "body", None) + return self._body - async def fetch_reactions(self) -> dict[str, list[str]]: - """Fetch all reactions for this message. + @property + def key(self) -> str | None: + """The key of this message.""" + return getattr(self._matrix_event, "key", None) - Returns a dict mapping emoji to a list of sender IDs who reacted with it. + async def fetch_reactions(self) -> list[Reaction]: + """Fetch all reactions for this message. + Returns a dict mapping emoji to a list of sender IDs who reacted with it. ## Example ```python @@ -69,24 +68,22 @@ async def reactions(ctx: Context): await ctx.reply(f"{emoji}: {len(senders)} reaction(s)") ``` """ - reactions: dict[str, list[str]] = {} + raw: dict[str, list[str]] = {} try: - async for response in self.client.room_get_event_relations( + async for event in self.client.room_get_event_relations( room_id=self.room.room_id, event_id=self.event_id, - relation_type="m.annotation", ): - for event in response.chunk: - emoji = event.get("content", {}).get("m.relates_to", {}).get("key") - sender = event.get("sender") + emoji = getattr(event, "key", None) + sender = getattr(event, "sender", None) - if emoji and sender: - reactions.setdefault(emoji, []).append(sender) + if emoji and sender: + raw.setdefault(emoji, []).append(sender) except Exception as e: raise MatrixError(f"Failed to fetch reactions: {e}") - return reactions + return [Reaction(key=emoji, senders=senders) for emoji, senders in raw.items()] async def reply(self, body: str) -> "Message": """Reply to this message. @@ -102,7 +99,7 @@ async def echo(ctx: Context): ``` """ try: - return await self.room.send_text(content=body, reply_to=self.id) + return await self.room.send_text(content=body, reply_to=self.event_id) except Exception as e: raise MatrixError(f"Failed to send reply: {e}") @@ -117,7 +114,7 @@ async def thumbsup(ctx: Context): await msg.react("👍") ``` """ - content = ReactionContent(event_id=self.id, emoji=emoji) + content = ReactionContent(event_id=self.event_id, emoji=emoji) try: await self.client.room_send( @@ -140,7 +137,7 @@ async def typo(ctx: Context): await msg.edit("Hello world!") ``` """ - content = EditContent(new_body, original_event_id=self.id) + content = EditContent(new_body, original_event_id=self.event_id) try: await self.client.room_send( @@ -167,7 +164,7 @@ async def oops(ctx: Context): try: await self.client.room_redact( room_id=self.room.room_id, - event_id=self.id, + event_id=self.event_id, ) except Exception as e: raise MatrixError(f"Failed to delete message: {e}") diff --git a/matrix/room.py b/matrix/room.py index e521873..c737070 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -319,7 +319,7 @@ async def _send_payload(self, payload: BaseMessageContent) -> Message: message_type="m.room.message", content=payload.build(), ) - event = self.get_event(resp.event_id) + event = await self.fetch_event(resp.event_id) return Message( room=self, @@ -329,8 +329,39 @@ async def _send_payload(self, payload: BaseMessageContent) -> Message: except Exception as e: raise MatrixError(f"Failed to send message: {e}") - async def get_event(self, event_id: str) -> Event: - return + async def fetch_event(self, event_id: str) -> Event: + """Fetch a Matrix event by its ID. + + ## Example + ```python + event = await room.fetch_event("$event_id:matrix.org") + print(event.sender) + ``` + """ + try: + response = await self.client.room_get_event( + room_id=self.room_id, + event_id=event_id, + ) + return response.event + except Exception as e: + raise MatrixError(f"Failed to get event: {e}") + + async def fetch_message(self, event_id: str) -> Message: + """Fetch a Message by its event ID. + + ## Example + ```python + message = await room.fetch_message("$event_id:matrix.org") + message.reply("hello world") + ``` + """ + event = await self.fetch_event(event_id) + return Message( + room=self, + event=event, + client=self.client, + ) async def invite_user(self, user_id: str) -> None: """Invite a user to the room. diff --git a/matrix/types.py b/matrix/types.py index 5c3257d..8da5243 100644 --- a/matrix/types.py +++ b/matrix/types.py @@ -24,3 +24,9 @@ class Video(File): width: int = 0 height: int = 0 duration: int = 0 + + +@dataclass +class Reaction: + key: str + senders: list[str] From e441f21a451be1f948a5215c33d1f5f56af93e84 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Mon, 6 Apr 2026 20:11:44 -0400 Subject: [PATCH 3/4] Test and adjusted example for reaction --- examples/reaction.py | 10 +-- tests/test_message.py | 87 +++++++++++++++++-- tests/test_room.py | 197 ++++++++++++++++++++++++------------------ 3 files changed, 196 insertions(+), 98 deletions(-) diff --git a/examples/reaction.py b/examples/reaction.py index 472c41f..a8a4ff3 100644 --- a/examples/reaction.py +++ b/examples/reaction.py @@ -11,8 +11,7 @@ async def on_message(room: Room, event: RoomMessageText) -> None: This function listens for new messages in a room and reacts based on the message content. """ - room = bot.get_room(room.room_id) - message = Message.from_event(room, event) + message = await room.fetch_message(event.event_id) if message.body.lower().startswith("thanks"): await message.react("🙏") @@ -31,12 +30,9 @@ async def on_react(room: Room, event: ReactionEvent) -> None: This function listens for new member reaction to messages in a room, and reacts based on the reaction emoji. """ - room = bot.get_room(room.room_id) - message = Message.from_event(room, event) + message = await room.fetch_message(event.reacts_to) - emoji = event.key - - if emoji == "🙏": + if message.key == "🙏": await message.react("❤️") diff --git a/tests/test_message.py b/tests/test_message.py index 3b70583..b22baad 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -1,6 +1,6 @@ import pytest -from unittest.mock import AsyncMock, Mock -from nio import MatrixRoom, AsyncClient +from unittest.mock import AsyncMock, MagicMock, Mock +from nio import MatrixRoom, AsyncClient, Event from matrix.errors import MatrixError from matrix.message import Message from matrix.room import Room @@ -20,8 +20,7 @@ def matrix_room(): @pytest.fixture def client(): - client = AsyncMock(spec=AsyncClient) - return client + return AsyncMock(spec=AsyncClient) @pytest.fixture @@ -30,8 +29,17 @@ def room(matrix_room, client): @pytest.fixture -def message(room, client): - return Message(room=room, event_id="$event123", body="Hello world!", client=client) +def mock_event(): + event = MagicMock(spec=Event) + event.event_id = "$event123" + event.body = "Hello world!" + event.sender = "@user:matrix.org" + return event + + +@pytest.fixture +def message(room, client, mock_event): + return Message(room=room, event=mock_event, client=client) @pytest.mark.asyncio @@ -49,7 +57,6 @@ async def test_reply__expect_threaded_reply_message(message, client): assert "m.relates_to" in content assert content["m.relates_to"]["m.in_reply_to"]["event_id"] == "$event123" assert isinstance(result, Message) - assert result.id == "$reply456" @pytest.mark.asyncio @@ -126,11 +133,73 @@ async def test_delete_with_error__expect_matrix_error(message, client): await message.delete() -def test_message_properties__expect_correct_values(message, room, client): - assert message.id == "$event123" +@pytest.mark.asyncio +async def test_fetch_reactions__expect_grouped_by_key(message, client): + reaction_a = MagicMock() + reaction_a.key = "👍" + reaction_a.sender = "@alice:matrix.org" + + reaction_b = MagicMock() + reaction_b.key = "👍" + reaction_b.sender = "@bob:matrix.org" + + reaction_c = MagicMock() + reaction_c.key = "👎" + reaction_c.sender = "@carol:matrix.org" + + async def mock_relations(*args, **kwargs): + for r in [reaction_a, reaction_b, reaction_c]: + yield r + + client.room_get_event_relations = mock_relations + + result = await message.fetch_reactions() + + assert len(result) == 2 + thumbs_up = next(r for r in result if r.key == "👍") + thumbs_down = next(r for r in result if r.key == "👎") + assert sorted(thumbs_up.senders) == sorted(["@alice:matrix.org", "@bob:matrix.org"]) + assert thumbs_down.senders == ["@carol:matrix.org"] + + +@pytest.mark.asyncio +async def test_fetch_reactions_empty__expect_empty_list(message, client): + async def mock_relations(*args, **kwargs): + return + yield # make it an async generator + + client.room_get_event_relations = mock_relations + + result = await message.fetch_reactions() + + assert result == [] + + +@pytest.mark.asyncio +async def test_fetch_reactions_with_error__expect_matrix_error(message, client): + async def mock_relations(*args, **kwargs): + raise Exception("Network error") + yield + + client.room_get_event_relations = mock_relations + + with pytest.raises(MatrixError, match="Failed to fetch reactions"): + await message.fetch_reactions() + + +def test_message_event_id__expect_correct_value(message): assert message.event_id == "$event123" + + +def test_message_body__expect_correct_value(message): assert message.body == "Hello world!" + + +def test_message_room__expect_correct_reference(message, room): assert message.room is room + + +def test_message_client__expect_correct_reference(message, client): assert message.client is client diff --git a/tests/test_room.py b/tests/test_room.py index 3ad2514..eafe95b 100644 --- a/tests/test_room.py +++ b/tests/test_room.py @@ -1,6 +1,6 @@ import pytest -from unittest.mock import AsyncMock, Mock -from nio import MatrixRoom +from unittest.mock import AsyncMock, Mock, MagicMock +from nio import MatrixRoom, Event from matrix.errors import MatrixError from matrix.room import Room from matrix.message import Message @@ -20,8 +20,7 @@ def matrix_room(): @pytest.fixture def client(): - client = AsyncMock() - return client + return AsyncMock() @pytest.fixture @@ -29,13 +28,27 @@ def room(matrix_room, client): return Room(matrix_room, client) -@pytest.mark.asyncio -async def test_send_markdown__expect_formatted_message(room, client): - client.room_send = AsyncMock() - mock_response = Mock() - mock_response.event_id = "$event123" - client.room_send.return_value = mock_response +@pytest.fixture +def mock_send_response(client): + """Set up client to return a mock event after room_send and fetch_event.""" + send_response = Mock() + send_response.event_id = "$event123" + client.room_send = AsyncMock(return_value=send_response) + + mock_event = MagicMock(spec=Event) + mock_event.event_id = "$event123" + + get_event_response = Mock() + get_event_response.event = mock_event + client.room_get_event = AsyncMock(return_value=get_event_response) + + return send_response + +@pytest.mark.asyncio +async def test_send_markdown__expect_formatted_message( + room, client, mock_send_response +): msg = await room.send("Hello **world**!") client.room_send.assert_awaited_once() @@ -46,19 +59,13 @@ async def test_send_markdown__expect_formatted_message(room, client): assert call_args.kwargs["content"]["body"] == "Hello **world**!" assert "formatted_body" in call_args.kwargs["content"] assert isinstance(msg, Message) - assert msg.id == "$event123" + assert msg.event_id == "$event123" @pytest.mark.asyncio -async def test_send_raw__expect_unformatted_message(room, client): - client.room_send = AsyncMock() - mock_response = Mock() - mock_response.event_id = "$event123" - client.room_send.return_value = mock_response - +async def test_send_raw__expect_unformatted_message(room, client, mock_send_response): await room.send("Hello world!", raw=True) - client.room_send.assert_awaited_once() call_args = client.room_send.call_args content = call_args.kwargs["content"] assert content["msgtype"] == "m.text" @@ -67,15 +74,11 @@ async def test_send_raw__expect_unformatted_message(room, client): @pytest.mark.asyncio -async def test_send_notice__expect_notice_message_type(room, client): - client.room_send = AsyncMock() - mock_response = Mock() - mock_response.event_id = "$event123" - client.room_send.return_value = mock_response - +async def test_send_notice__expect_notice_message_type( + room, client, mock_send_response +): await room.send("Bot starting up...", notice=True) - client.room_send.assert_awaited_once() call_args = client.room_send.call_args content = call_args.kwargs["content"] assert content["msgtype"] == "m.notice" @@ -83,15 +86,11 @@ async def test_send_notice__expect_notice_message_type(room, client): @pytest.mark.asyncio -async def test_send_text_with_reply_to__expect_threaded_reply(room, client): - client.room_send = AsyncMock() - mock_response = Mock() - mock_response.event_id = "$event123" - client.room_send.return_value = mock_response - +async def test_send_text_with_reply_to__expect_threaded_reply( + room, client, mock_send_response +): await room.send_text("Replying!", reply_to="$original_event") - client.room_send.assert_awaited_once() call_args = client.room_send.call_args content = call_args.kwargs["content"] assert content["msgtype"] == "m.text" @@ -101,13 +100,22 @@ async def test_send_text_with_reply_to__expect_threaded_reply(room, client): @pytest.mark.asyncio -async def test_send_file__expect_file_message(room, client): - from matrix.types import File +async def test_send_no_content__expect_value_error(room): + with pytest.raises(ValueError, match="You must provide content or file."): + await room.send() - client.room_send = AsyncMock() - mock_response = Mock() - mock_response.event_id = "$event123" - client.room_send.return_value = mock_response + +@pytest.mark.asyncio +async def test_send_message_with_network_error__expect_matrix_error(room, client): + client.room_send = AsyncMock(side_effect=Exception("Network error")) + + with pytest.raises(MatrixError, match="Failed to send message"): + await room.send("Hello, world!") + + +@pytest.mark.asyncio +async def test_send_file__expect_file_message(room, client, mock_send_response): + from matrix.types import File file = File( path="mxc://example.com/abc123", @@ -117,7 +125,6 @@ async def test_send_file__expect_file_message(room, client): await room.send(file=file) - client.room_send.assert_awaited_once() call_args = client.room_send.call_args content = call_args.kwargs["content"] assert content["msgtype"] == "m.file" @@ -126,14 +133,11 @@ async def test_send_file__expect_file_message(room, client): @pytest.mark.asyncio -async def test_send_image__expect_image_message_with_dimensions(room, client): +async def test_send_image__expect_image_message_with_dimensions( + room, client, mock_send_response +): from matrix.types import Image - client.room_send = AsyncMock() - mock_response = Mock() - mock_response.event_id = "$event123" - client.room_send.return_value = mock_response - image = Image( path="mxc://example.com/xyz789", filename="photo.jpg", @@ -144,7 +148,6 @@ async def test_send_image__expect_image_message_with_dimensions(room, client): await room.send(file=image) - client.room_send.assert_awaited_once() call_args = client.room_send.call_args content = call_args.kwargs["content"] assert content["msgtype"] == "m.image" @@ -155,14 +158,11 @@ async def test_send_image__expect_image_message_with_dimensions(room, client): @pytest.mark.asyncio -async def test_send_video__expect_video_message_with_metadata(room, client): +async def test_send_video__expect_video_message_with_metadata( + room, client, mock_send_response +): from matrix.types import Video - client.room_send = AsyncMock() - mock_response = Mock() - mock_response.event_id = "$event123" - client.room_send.return_value = mock_response - video = Video( path="mxc://example.com/video123", filename="clip.mp4", @@ -174,7 +174,6 @@ async def test_send_video__expect_video_message_with_metadata(room, client): await room.send(file=video) - client.room_send.assert_awaited_once() call_args = client.room_send.call_args content = call_args.kwargs["content"] assert content["msgtype"] == "m.video" @@ -186,14 +185,11 @@ async def test_send_video__expect_video_message_with_metadata(room, client): @pytest.mark.asyncio -async def test_send_audio__expect_audio_message_with_duration(room, client): +async def test_send_audio__expect_audio_message_with_duration( + room, client, mock_send_response +): from matrix.types import Audio - client.room_send = AsyncMock() - mock_response = Mock() - mock_response.event_id = "$event123" - client.room_send.return_value = mock_response - audio = Audio( path="mxc://example.com/audio123", filename="song.mp3", @@ -203,7 +199,6 @@ async def test_send_audio__expect_audio_message_with_duration(room, client): await room.send(file=audio) - client.room_send.assert_awaited_once() call_args = client.room_send.call_args content = call_args.kwargs["content"] assert content["msgtype"] == "m.audio" @@ -213,24 +208,63 @@ async def test_send_audio__expect_audio_message_with_duration(room, client): @pytest.mark.asyncio -async def test_send_no_content__expect_value_error(room): - with pytest.raises(ValueError, match="You must provide content or file."): - await room.send() +async def test_fetch_event__expect_event_returned(room, client): + mock_event = MagicMock(spec=Event) + mock_event.event_id = "$event123" + + response = Mock() + response.event = mock_event + client.room_get_event = AsyncMock(return_value=response) + + result = await room.fetch_event("$event123") + + client.room_get_event.assert_awaited_once_with( + room_id="!room:example.com", + event_id="$event123", + ) + assert result is mock_event @pytest.mark.asyncio -async def test_send_message_with_network_error__expect_matrix_error(room, client): - client.room_send = AsyncMock() - client.room_send.side_effect = Exception("Network error") +async def test_fetch_event_with_error__expect_matrix_error(room, client): + client.room_get_event = AsyncMock(side_effect=Exception("Not found")) - with pytest.raises(MatrixError, match="Failed to send message"): - await room.send("Hello, world!") + with pytest.raises(MatrixError, match="Failed to get event"): + await room.fetch_event("$event123") + + +# FETCH MESSAGE + + +@pytest.mark.asyncio +async def test_fetch_message__expect_message_returned(room, client): + mock_event = MagicMock(spec=Event) + mock_event.event_id = "$event123" + + response = Mock() + response.event = mock_event + client.room_get_event = AsyncMock(return_value=response) + + result = await room.fetch_message("$event123") + + assert isinstance(result, Message) + assert result.event_id == "$event123" + + +@pytest.mark.asyncio +async def test_fetch_message_with_error__expect_matrix_error(room, client): + client.room_get_event = AsyncMock(side_effect=Exception("Not found")) + + with pytest.raises(MatrixError, match="Failed to get event"): + await room.fetch_message("$event123") + + +# INVITE @pytest.mark.asyncio async def test_invite_user__expect_successful_invitation(room, client): client.room_invite = AsyncMock() - client.room_invite.return_value = None await room.invite_user("@alice:example.com") @@ -241,17 +275,18 @@ async def test_invite_user__expect_successful_invitation(room, client): @pytest.mark.asyncio async def test_invite_user_with_error__expect_matrix_error(room, client): - client.room_invite = AsyncMock() - client.room_invite.side_effect = Exception("User not found") + client.room_invite = AsyncMock(side_effect=Exception("User not found")) with pytest.raises(MatrixError, match="Failed to invite user"): await room.invite_user("@alice:example.com") +# BAN + + @pytest.mark.asyncio async def test_ban_user_without_reason__expect_successful_ban(room, client): client.room_ban = AsyncMock() - client.room_ban.return_value = None await room.ban_user("@spammer:example.com") @@ -263,7 +298,6 @@ async def test_ban_user_without_reason__expect_successful_ban(room, client): @pytest.mark.asyncio async def test_ban_user_with_reason__expect_successful_ban_with_reason(room, client): client.room_ban = AsyncMock() - client.room_ban.return_value = None await room.ban_user("@spammer:example.com", "Spam and harassment") @@ -276,8 +310,7 @@ async def test_ban_user_with_reason__expect_successful_ban_with_reason(room, cli @pytest.mark.asyncio async def test_ban_user_with_error__expect_matrix_error(room, client): - client.room_ban = AsyncMock() - client.room_ban.side_effect = Exception("Insufficient permissions") + client.room_ban = AsyncMock(side_effect=Exception("Insufficient permissions")) with pytest.raises(MatrixError, match="Failed to ban user"): await room.ban_user("@spammer:example.com") @@ -286,7 +319,6 @@ async def test_ban_user_with_error__expect_matrix_error(room, client): @pytest.mark.asyncio async def test_unban_user__expect_successful_unban(room, client): client.room_unban = AsyncMock() - client.room_unban.return_value = None await room.unban_user("@alice:example.com") @@ -297,8 +329,7 @@ async def test_unban_user__expect_successful_unban(room, client): @pytest.mark.asyncio async def test_unban_user_with_error__expect_matrix_error(room, client): - client.room_unban = AsyncMock() - client.room_unban.side_effect = Exception("User not banned") + client.room_unban = AsyncMock(side_effect=Exception("User not banned")) with pytest.raises(MatrixError, match="Failed to unban user"): await room.unban_user("@alice:example.com") @@ -307,7 +338,6 @@ async def test_unban_user_with_error__expect_matrix_error(room, client): @pytest.mark.asyncio async def test_kick_user_without_reason__expect_successful_kick(room, client): client.room_kick = AsyncMock() - client.room_kick.return_value = None await room.kick_user("@troublemaker:example.com") @@ -319,7 +349,6 @@ async def test_kick_user_without_reason__expect_successful_kick(room, client): @pytest.mark.asyncio async def test_kick_user_with_reason__expect_successful_kick_with_reason(room, client): client.room_kick = AsyncMock() - client.room_kick.return_value = None await room.kick_user("@troublemaker:example.com", "Violating rules") @@ -332,8 +361,7 @@ async def test_kick_user_with_reason__expect_successful_kick_with_reason(room, c @pytest.mark.asyncio async def test_kick_user_with_error__expect_matrix_error(room, client): - client.room_kick = AsyncMock() - client.room_kick.side_effect = Exception("User not in room") + client.room_kick = AsyncMock(side_effect=Exception("User not in room")) with pytest.raises(MatrixError, match="Failed to kick user"): await room.kick_user("@troublemaker:example.com") @@ -354,3 +382,8 @@ def test_room_matrix_room_property__expect_underlying_matrix_room(room, matrix_r def test_room_client_property__expect_async_client(room, client): assert room.client is client + + +def test_room_unknown_attribute__expect_attribute_error(room): + with pytest.raises(AttributeError): + _ = room.nonexistent_attribute From 93897dabb6095739dd46438dfef201b468dee6d3 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Mon, 6 Apr 2026 20:26:28 -0400 Subject: [PATCH 4/4] bump python version in workflow --- .github/workflows/tests.yml | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e8412bb..62babea 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,21 +15,21 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install .[dev] - - name: Lint with Black - run: | - black --check matrix/ tests/ examples/ - - name: Check typing with mypy - run: | - mypy matrix - - name: Test with pytest - run: | - pytest -v + - uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v3 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[dev] + - name: Lint with Black + run: | + black --check matrix/ tests/ examples/ + - name: Check typing with mypy + run: | + mypy matrix + - name: Test with pytest + run: | + pytest -v