From 97b30faa54bcaa289d90df96eb932e61d3075325 Mon Sep 17 00:00:00 2001 From: Nikunj kumar Date: Wed, 1 Jul 2026 22:02:38 +0530 Subject: [PATCH 1/4] feat: Add pin and unpin methods to Message class --- matrix/message.py | 89 ++++++++++++++++++++++++- tests/test_message.py | 146 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 233 insertions(+), 2 deletions(-) diff --git a/matrix/message.py b/matrix/message.py index f221dea..72f316b 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -1,6 +1,13 @@ from typing import TYPE_CHECKING, Self -from nio import AsyncClient, Event +from nio import ( + AsyncClient, + Event, + RoomGetStateEventError, + RoomGetStateEventResponse, + RoomPutStateError, + RoomPutStateResponse, +) from matrix.types import Reaction from matrix.content import ReactionContent, EditContent @@ -168,3 +175,83 @@ async def oops(ctx: Context): ) except Exception as e: raise MatrixError(f"Failed to delete message: {e}") + + async def pin(self) -> None: + """Pin this message to the room. + + ## Example + ```python + @bot.command() + async def pin(ctx: Context): + await ctx.message.pin() + ``` + """ + try: + response = await self.client.room_get_state_event( + room_id=self.room.room_id, + event_type="m.room.pinned_events", + ) + + if isinstance(response, RoomGetStateEventResponse): + pinned = list(response.content.get("pinned", [])) + elif isinstance(response, RoomGetStateEventError): + if response.status_code == "404": + pinned = [] + else: + raise MatrixError(f"Failed to fetch pinned events: {response.message}") + else: + raise MatrixError("Unexpected response type when fetching pinned events") + + if self.event_id not in pinned: + pinned.append(self.event_id) + put_response = await self.client.room_put_state( + room_id=self.room.room_id, + event_type="m.room.pinned_events", + content={"pinned": pinned}, + ) + if isinstance(put_response, RoomPutStateError): + raise MatrixError(f"Failed to pin message: {put_response.message}") + except MatrixError: + raise + except Exception as e: + raise MatrixError(f"Failed to pin message: {e}") + + async def unpin(self) -> None: + """Unpin this message from the room. + + ## Example + ```python + @bot.command() + async def unpin(ctx: Context): + await ctx.message.unpin() + ``` + """ + try: + response = await self.client.room_get_state_event( + room_id=self.room.room_id, + event_type="m.room.pinned_events", + ) + + if isinstance(response, RoomGetStateEventResponse): + pinned = list(response.content.get("pinned", [])) + elif isinstance(response, RoomGetStateEventError): + if response.status_code == "404": + pinned = [] + else: + raise MatrixError(f"Failed to fetch pinned events: {response.message}") + else: + raise MatrixError("Unexpected response type when fetching pinned events") + + if self.event_id in pinned: + pinned.remove(self.event_id) + put_response = await self.client.room_put_state( + room_id=self.room.room_id, + event_type="m.room.pinned_events", + content={"pinned": pinned}, + ) + if isinstance(put_response, RoomPutStateError): + raise MatrixError(f"Failed to unpin message: {put_response.message}") + except MatrixError: + raise + except Exception as e: + raise MatrixError(f"Failed to unpin message: {e}") diff --git a/tests/test_message.py b/tests/test_message.py index b22baad..e8fbaa5 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -1,6 +1,14 @@ import pytest from unittest.mock import AsyncMock, MagicMock, Mock -from nio import MatrixRoom, AsyncClient, Event +from nio import ( + MatrixRoom, + AsyncClient, + Event, + RoomGetStateEventResponse, + RoomGetStateEventError, + RoomPutStateResponse, + RoomPutStateError, +) from matrix.errors import MatrixError from matrix.message import Message from matrix.room import Room @@ -206,3 +214,139 @@ def test_message_client__expect_correct_reference(message, client): def test_message_repr__expect_formatted_string(message): result = repr(message) assert result == "" + + +@pytest.mark.asyncio +async def test_pin_no_existing_pinned_events__expect_state_updated(message, client): + mock_get_error = MagicMock(spec=RoomGetStateEventError) + mock_get_error.status_code = "404" + client.room_get_state_event = AsyncMock(return_value=mock_get_error) + + mock_put_resp = MagicMock(spec=RoomPutStateResponse) + client.room_put_state = AsyncMock(return_value=mock_put_resp) + + await message.pin() + + client.room_get_state_event.assert_awaited_once_with( + room_id="!room:example.com", + event_type="m.room.pinned_events", + ) + client.room_put_state.assert_awaited_once_with( + room_id="!room:example.com", + event_type="m.room.pinned_events", + content={"pinned": ["$event123"]}, + ) + + +@pytest.mark.asyncio +async def test_pin_with_existing_pinned_events__expect_appended_state_updated(message, client): + mock_get_resp = MagicMock(spec=RoomGetStateEventResponse) + mock_get_resp.content = {"pinned": ["$other_event"]} + client.room_get_state_event = AsyncMock(return_value=mock_get_resp) + + mock_put_resp = MagicMock(spec=RoomPutStateResponse) + client.room_put_state = AsyncMock(return_value=mock_put_resp) + + await message.pin() + + client.room_put_state.assert_awaited_once_with( + room_id="!room:example.com", + event_type="m.room.pinned_events", + content={"pinned": ["$other_event", "$event123"]}, + ) + + +@pytest.mark.asyncio +async def test_pin_already_pinned__expect_no_state_update(message, client): + mock_get_resp = MagicMock(spec=RoomGetStateEventResponse) + mock_get_resp.content = {"pinned": ["$event123"]} + client.room_get_state_event = AsyncMock(return_value=mock_get_resp) + + client.room_put_state = AsyncMock() + + await message.pin() + + client.room_put_state.assert_not_called() + + +@pytest.mark.asyncio +async def test_pin_with_forbidden_error__expect_matrix_error(message, client): + mock_get_error = MagicMock(spec=RoomGetStateEventError) + mock_get_error.status_code = "403" + mock_get_error.message = "Forbidden" + client.room_get_state_event = AsyncMock(return_value=mock_get_error) + + with pytest.raises(MatrixError, match="Failed to fetch pinned events: Forbidden"): + await message.pin() + + +@pytest.mark.asyncio +async def test_pin_with_put_error__expect_matrix_error(message, client): + mock_get_error = MagicMock(spec=RoomGetStateEventError) + mock_get_error.status_code = "404" + client.room_get_state_event = AsyncMock(return_value=mock_get_error) + + mock_put_error = MagicMock(spec=RoomPutStateError) + mock_put_error.message = "Failed to update state" + client.room_put_state = AsyncMock(return_value=mock_put_error) + + with pytest.raises(MatrixError, match="Failed to pin message: Failed to update state"): + await message.pin() + + +@pytest.mark.asyncio +async def test_unpin_event_is_pinned__expect_state_updated(message, client): + mock_get_resp = MagicMock(spec=RoomGetStateEventResponse) + mock_get_resp.content = {"pinned": ["$other_event", "$event123"]} + client.room_get_state_event = AsyncMock(return_value=mock_get_resp) + + mock_put_resp = MagicMock(spec=RoomPutStateResponse) + client.room_put_state = AsyncMock(return_value=mock_put_resp) + + await message.unpin() + + client.room_put_state.assert_awaited_once_with( + room_id="!room:example.com", + event_type="m.room.pinned_events", + content={"pinned": ["$other_event"]}, + ) + + +@pytest.mark.asyncio +async def test_unpin_event_not_pinned__expect_no_state_update(message, client): + mock_get_resp = MagicMock(spec=RoomGetStateEventResponse) + mock_get_resp.content = {"pinned": ["$other_event"]} + client.room_get_state_event = AsyncMock(return_value=mock_get_resp) + + client.room_put_state = AsyncMock() + + await message.unpin() + + client.room_put_state.assert_not_called() + + +@pytest.mark.asyncio +async def test_unpin_no_pinned_events__expect_no_state_update(message, client): + mock_get_error = MagicMock(spec=RoomGetStateEventError) + mock_get_error.status_code = "404" + client.room_get_state_event = AsyncMock(return_value=mock_get_error) + + client.room_put_state = AsyncMock() + + await message.unpin() + + client.room_put_state.assert_not_called() + + +@pytest.mark.asyncio +async def test_unpin_with_put_error__expect_matrix_error(message, client): + mock_get_resp = MagicMock(spec=RoomGetStateEventResponse) + mock_get_resp.content = {"pinned": ["$event123"]} + client.room_get_state_event = AsyncMock(return_value=mock_get_resp) + + mock_put_error = MagicMock(spec=RoomPutStateError) + mock_put_error.message = "Failed to update state" + client.room_put_state = AsyncMock(return_value=mock_put_error) + + with pytest.raises(MatrixError, match="Failed to unpin message: Failed to update state"): + await message.unpin() From faf005c2ae7429760611794197f2dd537b74a496 Mon Sep 17 00:00:00 2001 From: Nikunj kumar Date: Wed, 1 Jul 2026 22:58:14 +0530 Subject: [PATCH 2/4] refactor: address review comments for pin/unpin methods --- matrix/message.py | 85 ++++++++++++++++++++++------------------------- 1 file changed, 39 insertions(+), 46 deletions(-) diff --git a/matrix/message.py b/matrix/message.py index 72f316b..9fb8cb0 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -176,6 +176,21 @@ async def oops(ctx: Context): except Exception as e: raise MatrixError(f"Failed to delete message: {e}") + async def _fetch_pinned(self) -> list[str]: + response = await self.client.room_get_state_event( + room_id=self.room.room_id, + event_type="m.room.pinned_events", + ) + + if isinstance(response, RoomGetStateEventResponse): + return list(response.content["pinned"]) + if isinstance(response, RoomGetStateEventError): + if response.status_code == "404": + return [] + raise MatrixError(f"Failed to fetch pinned events: {response.message}") + + raise MatrixError("Unexpected response type when fetching pinned events") + async def pin(self) -> None: """Pin this message to the room. @@ -186,36 +201,25 @@ async def pin(ctx: Context): await ctx.message.pin() ``` """ + pinned = await self._fetch_pinned() + + if self.event_id in pinned: + return + + pinned.append(self.event_id) + try: - response = await self.client.room_get_state_event( + put_response = await self.client.room_put_state( room_id=self.room.room_id, event_type="m.room.pinned_events", + content={"pinned": pinned}, ) - - if isinstance(response, RoomGetStateEventResponse): - pinned = list(response.content.get("pinned", [])) - elif isinstance(response, RoomGetStateEventError): - if response.status_code == "404": - pinned = [] - else: - raise MatrixError(f"Failed to fetch pinned events: {response.message}") - else: - raise MatrixError("Unexpected response type when fetching pinned events") - - if self.event_id not in pinned: - pinned.append(self.event_id) - put_response = await self.client.room_put_state( - room_id=self.room.room_id, - event_type="m.room.pinned_events", - content={"pinned": pinned}, - ) - if isinstance(put_response, RoomPutStateError): - raise MatrixError(f"Failed to pin message: {put_response.message}") - except MatrixError: - raise except Exception as e: raise MatrixError(f"Failed to pin message: {e}") + if isinstance(put_response, RoomPutStateError): + raise MatrixError(f"Failed to pin message: {put_response.message}") + async def unpin(self) -> None: """Unpin this message from the room. @@ -226,32 +230,21 @@ async def unpin(ctx: Context): await ctx.message.unpin() ``` """ + pinned = await self._fetch_pinned() + + if self.event_id not in pinned: + return + + pinned.remove(self.event_id) + try: - response = await self.client.room_get_state_event( + put_response = await self.client.room_put_state( room_id=self.room.room_id, event_type="m.room.pinned_events", + content={"pinned": pinned}, ) - - if isinstance(response, RoomGetStateEventResponse): - pinned = list(response.content.get("pinned", [])) - elif isinstance(response, RoomGetStateEventError): - if response.status_code == "404": - pinned = [] - else: - raise MatrixError(f"Failed to fetch pinned events: {response.message}") - else: - raise MatrixError("Unexpected response type when fetching pinned events") - - if self.event_id in pinned: - pinned.remove(self.event_id) - put_response = await self.client.room_put_state( - room_id=self.room.room_id, - event_type="m.room.pinned_events", - content={"pinned": pinned}, - ) - if isinstance(put_response, RoomPutStateError): - raise MatrixError(f"Failed to unpin message: {put_response.message}") - except MatrixError: - raise except Exception as e: raise MatrixError(f"Failed to unpin message: {e}") + + if isinstance(put_response, RoomPutStateError): + raise MatrixError(f"Failed to unpin message: {put_response.message}") From fe4f10803f213fb6010ef6a7f8b533f7dfc2b406 Mon Sep 17 00:00:00 2001 From: Nikunj kumar Date: Thu, 2 Jul 2026 22:41:59 +0530 Subject: [PATCH 3/4] nit: use .get() with default and fix formatting --- matrix/message.py | 2 +- tests/test_message.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/matrix/message.py b/matrix/message.py index 9fb8cb0..cadac87 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -183,7 +183,7 @@ async def _fetch_pinned(self) -> list[str]: ) if isinstance(response, RoomGetStateEventResponse): - return list(response.content["pinned"]) + return list(response.content.get("pinned", [])) if isinstance(response, RoomGetStateEventError): if response.status_code == "404": return [] diff --git a/tests/test_message.py b/tests/test_message.py index e8fbaa5..5207650 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -239,7 +239,9 @@ async def test_pin_no_existing_pinned_events__expect_state_updated(message, clie @pytest.mark.asyncio -async def test_pin_with_existing_pinned_events__expect_appended_state_updated(message, client): +async def test_pin_with_existing_pinned_events__expect_appended_state_updated( + message, client +): mock_get_resp = MagicMock(spec=RoomGetStateEventResponse) mock_get_resp.content = {"pinned": ["$other_event"]} client.room_get_state_event = AsyncMock(return_value=mock_get_resp) @@ -290,7 +292,9 @@ async def test_pin_with_put_error__expect_matrix_error(message, client): mock_put_error.message = "Failed to update state" client.room_put_state = AsyncMock(return_value=mock_put_error) - with pytest.raises(MatrixError, match="Failed to pin message: Failed to update state"): + with pytest.raises( + MatrixError, match="Failed to pin message: Failed to update state" + ): await message.pin() @@ -348,5 +352,7 @@ async def test_unpin_with_put_error__expect_matrix_error(message, client): mock_put_error.message = "Failed to update state" client.room_put_state = AsyncMock(return_value=mock_put_error) - with pytest.raises(MatrixError, match="Failed to unpin message: Failed to update state"): + with pytest.raises( + MatrixError, match="Failed to unpin message: Failed to update state" + ): await message.unpin() From d38b6e6c7bc229a64a054de7501d3ddb665bb693 Mon Sep 17 00:00:00 2001 From: Nikunj kumar Date: Sat, 4 Jul 2026 14:20:13 +0530 Subject: [PATCH 4/4] Refactor pin/unpin methods to use matrix_call for error handling --- matrix/message.py | 28 ++++++++++------------------ tests/test_message.py | 30 +++++++++++------------------- 2 files changed, 21 insertions(+), 37 deletions(-) diff --git a/matrix/message.py b/matrix/message.py index c74b4fe..ed1aaee 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -5,8 +5,6 @@ Event, RoomGetStateEventError, RoomGetStateEventResponse, - RoomPutStateError, - RoomPutStateResponse, ) from matrix.types import Reaction @@ -215,17 +213,14 @@ async def pin(ctx: Context): pinned.append(self.event_id) - try: - put_response = await self.client.room_put_state( + await matrix_call( + self.client.room_put_state( room_id=self.room.room_id, event_type="m.room.pinned_events", content={"pinned": pinned}, - ) - except Exception as e: - raise MatrixError(f"Failed to pin message: {e}") - - if isinstance(put_response, RoomPutStateError): - raise MatrixError(f"Failed to pin message: {put_response.message}") + ), + error_message="Failed to pin message", + ) async def unpin(self) -> None: """Unpin this message from the room. @@ -244,14 +239,11 @@ async def unpin(ctx: Context): pinned.remove(self.event_id) - try: - put_response = await self.client.room_put_state( + await matrix_call( + self.client.room_put_state( room_id=self.room.room_id, event_type="m.room.pinned_events", content={"pinned": pinned}, - ) - except Exception as e: - raise MatrixError(f"Failed to unpin message: {e}") - - if isinstance(put_response, RoomPutStateError): - raise MatrixError(f"Failed to unpin message: {put_response.message}") + ), + error_message="Failed to unpin message", + ) diff --git a/tests/test_message.py b/tests/test_message.py index 6503715..2bbcbdb 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -6,7 +6,6 @@ Event, RoomGetStateEventResponse, RoomGetStateEventError, - RoomPutStateResponse, RoomPutStateError, RoomSendError, RoomRedactError, @@ -269,8 +268,7 @@ async def test_pin_no_existing_pinned_events__expect_state_updated(message, clie mock_get_error.status_code = "404" client.room_get_state_event = AsyncMock(return_value=mock_get_error) - mock_put_resp = MagicMock(spec=RoomPutStateResponse) - client.room_put_state = AsyncMock(return_value=mock_put_resp) + client.room_put_state = AsyncMock(return_value=MagicMock()) await message.pin() @@ -293,8 +291,7 @@ async def test_pin_with_existing_pinned_events__expect_appended_state_updated( mock_get_resp.content = {"pinned": ["$other_event"]} client.room_get_state_event = AsyncMock(return_value=mock_get_resp) - mock_put_resp = MagicMock(spec=RoomPutStateResponse) - client.room_put_state = AsyncMock(return_value=mock_put_resp) + client.room_put_state = AsyncMock(return_value=MagicMock()) await message.pin() @@ -335,13 +332,11 @@ async def test_pin_with_put_error__expect_matrix_error(message, client): mock_get_error.status_code = "404" client.room_get_state_event = AsyncMock(return_value=mock_get_error) - mock_put_error = MagicMock(spec=RoomPutStateError) - mock_put_error.message = "Failed to update state" - client.room_put_state = AsyncMock(return_value=mock_put_error) + client.room_put_state = AsyncMock( + return_value=RoomPutStateError("not allowed", "M_FORBIDDEN") + ) - with pytest.raises( - MatrixError, match="Failed to pin message: Failed to update state" - ): + with pytest.raises(MatrixError, match="Failed to pin message: .*M_FORBIDDEN"): await message.pin() @@ -351,8 +346,7 @@ async def test_unpin_event_is_pinned__expect_state_updated(message, client): mock_get_resp.content = {"pinned": ["$other_event", "$event123"]} client.room_get_state_event = AsyncMock(return_value=mock_get_resp) - mock_put_resp = MagicMock(spec=RoomPutStateResponse) - client.room_put_state = AsyncMock(return_value=mock_put_resp) + client.room_put_state = AsyncMock(return_value=MagicMock()) await message.unpin() @@ -395,11 +389,9 @@ async def test_unpin_with_put_error__expect_matrix_error(message, client): mock_get_resp.content = {"pinned": ["$event123"]} client.room_get_state_event = AsyncMock(return_value=mock_get_resp) - mock_put_error = MagicMock(spec=RoomPutStateError) - mock_put_error.message = "Failed to update state" - client.room_put_state = AsyncMock(return_value=mock_put_error) + client.room_put_state = AsyncMock( + return_value=RoomPutStateError("not allowed", "M_FORBIDDEN") + ) - with pytest.raises( - MatrixError, match="Failed to unpin message: Failed to update state" - ): + with pytest.raises(MatrixError, match="Failed to unpin message: .*M_FORBIDDEN"): await message.unpin()