diff --git a/matrix/api.py b/matrix/api.py new file mode 100644 index 0000000..34a5c57 --- /dev/null +++ b/matrix/api.py @@ -0,0 +1,35 @@ +from typing import Awaitable, TypeVar + +from nio import ErrorResponse, Response + +from matrix.errors import MatrixError + +T = TypeVar("T", bound=Response) + + +async def matrix_call(coro: Awaitable[T], /, *, error_message: str) -> T: + """Await `coro`, translating any failure into a `MatrixError`. + + matrix-nio's `AsyncClient` methods don't raise on API-level errors; they + return an `ErrorResponse` instead of raising. This wraps a single call so + both transport-level exceptions and nio `ErrorResponse` results become a + `MatrixError` carrying `error_message`. + + ## Example + + ```python + response = await matrix_call( + self.client.room_kick(room_id=self.room_id, user_id=user_id), + error_message="Failed to kick user", + ) + ``` + """ + try: + response = await coro + except Exception as e: + raise MatrixError(f"{error_message}: {e}") from e + + if isinstance(response, ErrorResponse): + raise MatrixError(f"{error_message}: {response}") + + return response diff --git a/matrix/bot.py b/matrix/bot.py index c529f79..4164f79 100644 --- a/matrix/bot.py +++ b/matrix/bot.py @@ -22,6 +22,7 @@ CheckError, RoomNotFoundError, ) +from .api import matrix_call class Bot(Registry): @@ -328,7 +329,10 @@ async def run(self) -> None: if self.config.token: self.client.access_token = self.config.token else: - login_resp = await self.client.login(self.config.password) + login_resp = await matrix_call( + self.client.login(self.config.password), + error_message="Failed to log in", + ) self.log.info("logged in: %s", login_resp) sync_task = asyncio.create_task(self.client.sync_forever(timeout=30_000)) diff --git a/matrix/message.py b/matrix/message.py index 186ab36..0e90d6e 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -5,6 +5,7 @@ from matrix.types import Reaction from matrix.content import ReactionContent, EditContent from matrix.errors import MatrixError +from matrix.api import matrix_call if TYPE_CHECKING: from .room import Room # pragma: no cover @@ -116,14 +117,14 @@ async def thumbsup(ctx: Context): """ content = ReactionContent(event_id=self.event_id, emoji=emoji) - try: - await self.client.room_send( + await matrix_call( + self.client.room_send( room_id=self.room.room_id, message_type="m.reaction", content=content.build(), - ) - except Exception as e: - raise MatrixError(f"Failed to add reaction: {e}") + ), + error_message="Failed to add reaction", + ) async def edit(self, new_body: str) -> None: """Updates the message content to the new text. @@ -139,15 +140,15 @@ async def typo(ctx: Context): """ content = EditContent(new_body, original_event_id=self.event_id) - try: - await self.client.room_send( + await matrix_call( + self.client.room_send( room_id=self.room.room_id, message_type="m.room.message", content=content.build(), - ) - self._body = new_body - except Exception as e: - raise MatrixError(f"Failed to edit message: {e}") + ), + error_message="Failed to edit message", + ) + self._body = new_body async def delete(self, reason: str | None = None) -> None: """Removes the message content from the room. This action cannot be undone. @@ -166,11 +167,11 @@ async def oops(ctx: Context): await message.delete(reason="Violated room rules") ``` """ - try: - await self.client.room_redact( + await matrix_call( + self.client.room_redact( room_id=self.room.room_id, event_id=self.event_id, reason=reason, - ) - except Exception as e: - raise MatrixError(f"Failed to delete message: {e}") + ), + error_message="Failed to delete message", + ) diff --git a/matrix/room.py b/matrix/room.py index 8aaa9d8..1d1fc0c 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -2,7 +2,7 @@ from nio import AsyncClient, MatrixRoom, Event -from matrix.errors import MatrixError +from matrix.api import matrix_call from matrix.message import Message from matrix.content import ( BaseMessageContent, @@ -329,21 +329,21 @@ async def send_file(self, file: File) -> Message: async def _send_payload(self, payload: BaseMessageContent) -> Message: """Send a BaseMessageContent payload and return a Message object.""" - try: - resp = await self.client.room_send( + resp = await matrix_call( + self.client.room_send( room_id=self.room_id, message_type="m.room.message", content=payload.build(), - ) - event = await self.fetch_event(resp.event_id) + ), + error_message="Failed to send message", + ) + event = await self.fetch_event(resp.event_id) - return Message( - room=self, - event=event, - client=self.client, - ) - except Exception as e: - raise MatrixError(f"Failed to send message: {e}") + return Message( + room=self, + event=event, + client=self.client, + ) async def fetch_event(self, event_id: str) -> Event: """Fetch a Matrix event by its ID. @@ -354,14 +354,11 @@ async def fetch_event(self, event_id: str) -> Event: 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}") + response = await matrix_call( + self.client.room_get_event(room_id=self.room_id, event_id=event_id), + error_message="Failed to get event", + ) + return response.event async def fetch_message(self, event_id: str) -> Message: """Fetch a Message by its event ID. @@ -393,14 +390,14 @@ async def on_message(room: Room, event: Event): await room.mark_as_read(event.event_id) ``` """ - try: - await self.client.room_read_markers( + await matrix_call( + self.client.room_read_markers( room_id=self.room_id, fully_read_event=event_id, read_event=event_id, - ) - except Exception as e: - raise MatrixError(f"Failed to mark as read: {e}") + ), + error_message="Failed to mark as read", + ) async def invite_user(self, user_id: str) -> None: """Invite a user to the room. @@ -415,10 +412,10 @@ async def invite_user(self, user_id: str) -> None: await room.invite_user("@alice:example.com") ``` """ - try: - await self.client.room_invite(room_id=self.room_id, user_id=user_id) - except Exception as e: - raise MatrixError(f"Failed to invite user: {e}") + await matrix_call( + self.client.room_invite(room_id=self.room_id, user_id=user_id), + error_message="Failed to invite user", + ) async def ban_user(self, user_id: str, reason: str | None = None) -> None: """Ban a user from the room. @@ -436,12 +433,10 @@ async def ban_user(self, user_id: str, reason: str | None = None) -> None: await room.ban_user("@spammer:example.com", reason="Spam and harassment") ``` """ - try: - await self.client.room_ban( - room_id=self.room_id, user_id=user_id, reason=reason - ) - except Exception as e: - raise MatrixError(f"Failed to ban user: {e}") + await matrix_call( + self.client.room_ban(room_id=self.room_id, user_id=user_id, reason=reason), + error_message="Failed to ban user", + ) async def unban_user(self, user_id: str) -> None: """Unban a user from the room. @@ -456,10 +451,10 @@ async def unban_user(self, user_id: str) -> None: await room.unban_user("@alice:example.com") ``` """ - try: - await self.client.room_unban(room_id=self.room_id, user_id=user_id) - except Exception as e: - raise MatrixError(f"Failed to unban user: {e}") + await matrix_call( + self.client.room_unban(room_id=self.room_id, user_id=user_id), + error_message="Failed to unban user", + ) async def kick_user(self, user_id: str, reason: str | None = None) -> None: """Kick a user from the room. @@ -478,12 +473,10 @@ async def kick_user(self, user_id: str, reason: str | None = None) -> None: await room.kick_user("@troublemaker:example.com", reason="Violating room rules") ``` """ - try: - await self.client.room_kick( - room_id=self.room_id, user_id=user_id, reason=reason - ) - except Exception as e: - raise MatrixError(f"Failed to kick user: {e}") + await matrix_call( + self.client.room_kick(room_id=self.room_id, user_id=user_id, reason=reason), + error_message="Failed to kick user", + ) async def get_members(self) -> list[str]: """Fetch the list of user IDs currently joined to the room. @@ -498,8 +491,8 @@ async def get_members(self) -> list[str]: print(f"{len(members)} members: {', '.join(members)}") ``` """ - try: - response = await self.client.joined_members(self.room_id) - return [member.user_id for member in response.members] - except Exception as e: - raise MatrixError(f"Failed to get members: {e}") + response = await matrix_call( + self.client.joined_members(self.room_id), + error_message="Failed to get members", + ) + return [member.user_id for member in response.members] diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..fa1ed7f --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,43 @@ +import pytest +from nio import RoomSendResponse, RoomSendError + +from matrix.errors import MatrixError +from matrix.api import matrix_call + + +@pytest.mark.asyncio +async def test_matrix_call_with_success__expect_response_returned(): + async def call(): + return RoomSendResponse(event_id="$event123", room_id="!room:example.com") + + response = await matrix_call(call(), error_message="Failed to send message") + + assert response.event_id == "$event123" + + +@pytest.mark.asyncio +async def test_matrix_call_with_transport_exception__expect_matrix_error(): + async def call(): + raise Exception("Network error") + + with pytest.raises(MatrixError, match="Failed to send message: Network error"): + await matrix_call(call(), error_message="Failed to send message") + + +@pytest.mark.asyncio +async def test_matrix_call_with_error_response__expect_matrix_error(): + async def call(): + return RoomSendError("not allowed", "M_FORBIDDEN") + + with pytest.raises(MatrixError, match="Failed to send message: .*M_FORBIDDEN"): + await matrix_call(call(), error_message="Failed to send message") + + +def test_matrix_call_requires_keyword_error_message__expect_type_error(): + with pytest.raises(TypeError): + matrix_call(None, "Failed to send message") + + +def test_matrix_call_requires_positional_coro__expect_type_error(): + with pytest.raises(TypeError): + matrix_call(coro=None, error_message="Failed to send message") diff --git a/tests/test_bot.py b/tests/test_bot.py index 2125ac0..18b7f16 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -1,13 +1,14 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch -from nio import MatrixRoom, RoomMessageText +from nio import MatrixRoom, RoomMessageText, LoginError from matrix import Bot, Config, Extension, Room, Space from matrix.errors import ( CheckError, CommandNotFoundError, AlreadyRegisteredError, + MatrixError, ) @@ -555,6 +556,21 @@ async def mock_login(password): bot._on_ready.assert_awaited_once() +@pytest.mark.asyncio +async def test_run_with_login_api_error__expect_matrix_error(bot): + bot._client.login = AsyncMock( + return_value=LoginError("bad credentials", "M_FORBIDDEN") + ) + bot._client.sync_forever = AsyncMock() + bot._on_ready = AsyncMock() + + with pytest.raises(MatrixError, match="Failed to log in"): + await bot.run() + + bot._client.sync_forever.assert_not_called() + bot._on_ready.assert_not_called() + + def test_start_handles_keyboard_interrupt(caplog): bot = Bot() bot._client = MagicMock() diff --git a/tests/test_message.py b/tests/test_message.py index 4014d9f..b7a4b13 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -1,6 +1,12 @@ import pytest from unittest.mock import AsyncMock, MagicMock, Mock -from nio import MatrixRoom, AsyncClient, Event +from nio import ( + MatrixRoom, + AsyncClient, + Event, + RoomSendError, + RoomRedactError, +) from matrix.errors import MatrixError from matrix.message import Message from matrix.room import Room @@ -90,6 +96,16 @@ async def test_react_with_error__expect_matrix_error(message, client): await message.react("😀") +@pytest.mark.asyncio +async def test_react_with_api_error__expect_matrix_error(message, client): + client.room_send = AsyncMock( + return_value=RoomSendError("not allowed", "M_FORBIDDEN") + ) + + with pytest.raises(MatrixError, match="Failed to add reaction"): + await message.react("😀") + + @pytest.mark.asyncio async def test_edit__expect_message_updated(message, client): client.room_send = AsyncMock() @@ -114,6 +130,18 @@ async def test_edit_with_error__expect_matrix_error(message, client): await message.edit("New content") +@pytest.mark.asyncio +async def test_edit_with_api_error__expect_local_body_unchanged(message, client): + client.room_send = AsyncMock( + return_value=RoomSendError("not allowed", "M_FORBIDDEN") + ) + + with pytest.raises(MatrixError, match="Failed to edit message"): + await message.edit("New content") + + assert message.body == "Hello world!" + + @pytest.mark.asyncio async def test_delete__expect_message_redacted(message, client): client.room_redact = AsyncMock() @@ -146,6 +174,16 @@ async def test_delete_with_error__expect_matrix_error(message, client): await message.delete() +@pytest.mark.asyncio +async def test_delete_with_api_error__expect_matrix_error(message, client): + client.room_redact = AsyncMock( + return_value=RoomRedactError("not allowed", "M_FORBIDDEN") + ) + + with pytest.raises(MatrixError, match="Failed to delete message"): + await message.delete() + + @pytest.mark.asyncio async def test_fetch_reactions__expect_grouped_by_key(message, client): reaction_a = MagicMock() diff --git a/tests/test_room.py b/tests/test_room.py index 6f61078..89d2c72 100644 --- a/tests/test_room.py +++ b/tests/test_room.py @@ -1,6 +1,17 @@ import pytest from unittest.mock import AsyncMock, Mock, MagicMock -from nio import MatrixRoom, Event +from nio import ( + MatrixRoom, + Event, + RoomSendError, + RoomGetEventError, + RoomReadMarkersError, + RoomInviteError, + RoomBanError, + RoomUnbanError, + RoomKickError, + JoinedMembersError, +) from matrix.errors import MatrixError from matrix.room import Room, make_room from matrix.space import Space @@ -114,6 +125,16 @@ async def test_send_message_with_network_error__expect_matrix_error(room, client await room.send("Hello, world!") +@pytest.mark.asyncio +async def test_send_message_with_api_error__expect_matrix_error(room, client): + client.room_send = AsyncMock( + return_value=RoomSendError("not allowed", "M_FORBIDDEN") + ) + + 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 @@ -234,6 +255,16 @@ async def test_fetch_event_with_error__expect_matrix_error(room, client): await room.fetch_event("$event123") +@pytest.mark.asyncio +async def test_fetch_event_with_api_error__expect_matrix_error(room, client): + client.room_get_event = AsyncMock( + return_value=RoomGetEventError("not found", "M_NOT_FOUND") + ) + + with pytest.raises(MatrixError, match="Failed to get event"): + await room.fetch_event("$event123") + + # FETCH MESSAGE @@ -284,6 +315,16 @@ async def test_mark_as_read_with_error__expect_matrix_error(room, client): await room.mark_as_read("$event123") +@pytest.mark.asyncio +async def test_mark_as_read_with_api_error__expect_matrix_error(room, client): + client.room_read_markers = AsyncMock( + return_value=RoomReadMarkersError("not allowed", "M_FORBIDDEN") + ) + + with pytest.raises(MatrixError, match="Failed to mark as read"): + await room.mark_as_read("$event123") + + # INVITE @@ -306,6 +347,16 @@ async def test_invite_user_with_error__expect_matrix_error(room, client): await room.invite_user("@alice:example.com") +@pytest.mark.asyncio +async def test_invite_user_with_api_error__expect_matrix_error(room, client): + client.room_invite = AsyncMock( + return_value=RoomInviteError("not allowed", "M_FORBIDDEN") + ) + + with pytest.raises(MatrixError, match="Failed to invite user"): + await room.invite_user("@alice:example.com") + + # BAN @@ -341,6 +392,14 @@ async def test_ban_user_with_error__expect_matrix_error(room, client): await room.ban_user("@spammer:example.com") +@pytest.mark.asyncio +async def test_ban_user_with_api_error__expect_matrix_error(room, client): + client.room_ban = AsyncMock(return_value=RoomBanError("not allowed", "M_FORBIDDEN")) + + with pytest.raises(MatrixError, match="Failed to ban user"): + await room.ban_user("@spammer:example.com") + + @pytest.mark.asyncio async def test_unban_user__expect_successful_unban(room, client): client.room_unban = AsyncMock() @@ -360,6 +419,16 @@ async def test_unban_user_with_error__expect_matrix_error(room, client): await room.unban_user("@alice:example.com") +@pytest.mark.asyncio +async def test_unban_user_with_api_error__expect_matrix_error(room, client): + client.room_unban = AsyncMock( + return_value=RoomUnbanError("not allowed", "M_FORBIDDEN") + ) + + with pytest.raises(MatrixError, match="Failed to unban user"): + await room.unban_user("@alice:example.com") + + @pytest.mark.asyncio async def test_kick_user_without_reason__expect_successful_kick(room, client): client.room_kick = AsyncMock() @@ -392,6 +461,16 @@ async def test_kick_user_with_error__expect_matrix_error(room, client): await room.kick_user("@troublemaker:example.com") +@pytest.mark.asyncio +async def test_kick_user_with_api_error__expect_matrix_error(room, client): + client.room_kick = AsyncMock( + return_value=RoomKickError("not allowed", "M_FORBIDDEN") + ) + + with pytest.raises(MatrixError, match="Failed to kick user"): + await room.kick_user("@troublemaker:example.com") + + # GET MEMBERS @@ -420,6 +499,16 @@ async def test_get_members_with_error__expect_matrix_error(room, client): await room.get_members() +@pytest.mark.asyncio +async def test_get_members_with_api_error__expect_matrix_error(room, client): + client.joined_members = AsyncMock( + return_value=JoinedMembersError("not allowed", "M_FORBIDDEN") + ) + + with pytest.raises(MatrixError, match="Failed to get members"): + await room.get_members() + + def test_room_properties__expect_correct_delegation_to_matrix_room(room, matrix_room): assert room.room_id == "!room:example.com" assert room.name == "Test Room"