From df447ea08172b318333d545271edccc2078fe3f2 Mon Sep 17 00:00:00 2001 From: anay Date: Wed, 22 Jul 2026 14:52:07 -0700 Subject: [PATCH 1/2] feat(message): add unreact method Signed-off-by: anay --- matrix/message.py | 42 +++++++++++++++++++++++++++++++ tests/test_message.py | 58 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/matrix/message.py b/matrix/message.py index 0188787..c6a9f29 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -131,6 +131,48 @@ async def thumbsup(ctx: Context): error_message="Failed to add reaction", ) + async def unreact(self, emoji: str) -> None: + """Remove this client's reaction emoji from the message. + + If the client has not reacted with the requested emoji, this method + does nothing. + + ## Example + ```python + @bot.command() + async def toggle(ctx: Context): + msg = await ctx.reply("React to this!") + await msg.react("👍") + await msg.unreact("👍") + ``` + """ + reaction_event_id = None + try: + async for event in self.client.room_get_event_relations( + room_id=self.room.room_id, + event_id=self.event_id, + ): + if ( + getattr(event, "key", None) == emoji + and getattr(event, "sender", None) == self.client.user_id + ): + reaction_event_id = getattr(event, "event_id", None) + if reaction_event_id: + break + except Exception as e: + raise MatrixError(f"Failed to remove reaction: {e}") from e + + if reaction_event_id is None: + return + + await matrix_call( + self.client.room_redact( + room_id=self.room.room_id, + event_id=reaction_event_id, + ), + error_message="Failed to remove reaction", + ) + async def edit(self, new_body: str) -> None: """Updates the message content to the new text. diff --git a/tests/test_message.py b/tests/test_message.py index 092b50d..1adb0e9 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -29,7 +29,9 @@ def matrix_room(): @pytest.fixture def client(): - return AsyncMock(spec=AsyncClient) + client = AsyncMock(spec=AsyncClient) + client.user_id = "@bot:matrix.org" + return client @pytest.fixture @@ -109,6 +111,60 @@ async def test_react_with_api_error__expect_matrix_error(message, client): await message.react("😀") +@pytest.mark.asyncio +async def test_unreact__expect_own_matching_reaction_redacted(message, client): + own_reaction = MagicMock(event_id="$reaction1", key="👍", sender="@bot:matrix.org") + other_reaction = MagicMock( + event_id="$reaction2", key="👍", sender="@alice:matrix.org" + ) + + async def mock_relations(*args, **kwargs): + for reaction in [other_reaction, own_reaction]: + yield reaction + + client.room_get_event_relations = mock_relations + client.room_redact = AsyncMock() + + await message.unreact("👍") + + client.room_redact.assert_awaited_once_with( + room_id="!room:example.com", event_id="$reaction1" + ) + + +@pytest.mark.asyncio +async def test_unreact_without_matching_reaction__expect_no_redaction(message, client): + other_reaction = MagicMock( + event_id="$reaction2", key="👍", sender="@alice:matrix.org" + ) + + async def mock_relations(*args, **kwargs): + yield other_reaction + + client.room_get_event_relations = mock_relations + client.room_redact = AsyncMock() + + await message.unreact("👍") + + client.room_redact.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_unreact_with_api_error__expect_matrix_error(message, client): + own_reaction = MagicMock(event_id="$reaction1", key="👍", sender="@bot:matrix.org") + + async def mock_relations(*args, **kwargs): + yield own_reaction + + client.room_get_event_relations = mock_relations + client.room_redact = AsyncMock( + return_value=RoomRedactError("not allowed", "M_FORBIDDEN") + ) + + with pytest.raises(MatrixError, match="Failed to remove reaction"): + await message.unreact("👍") + + @pytest.mark.asyncio async def test_edit__expect_message_updated(message, client): client.room_send = AsyncMock() From a2f9b50a3eebb729f0f616adaa25e35458a8c9e9 Mon Sep 17 00:00:00 2001 From: anay Date: Wed, 22 Jul 2026 16:43:05 -0700 Subject: [PATCH 2/2] refactor(message): share reaction iteration Signed-off-by: anay --- examples/config.yaml | 2 +- matrix/message.py | 44 +++++++++++++++++++++++-------------------- matrix/types.py | 9 +++++++++ tests/test_message.py | 12 ++++++++++++ 4 files changed, 46 insertions(+), 21 deletions(-) diff --git a/examples/config.yaml b/examples/config.yaml index 6edd561..79207e1 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -1,4 +1,4 @@ HOMESERVER: "your_matrix_server(default to matrix.org)" USERNAME: "your_bot_username" PASSWORD: "your_Password" -PREFIX: "your_custom_prefix(default to !)" \ No newline at end of file +PREFIX: "your_custom_prefix(default to !)" diff --git a/matrix/message.py b/matrix/message.py index c6a9f29..c5fcc90 100644 --- a/matrix/message.py +++ b/matrix/message.py @@ -1,3 +1,4 @@ +from collections.abc import AsyncIterator from typing import TYPE_CHECKING, Self from nio import ( @@ -7,7 +8,7 @@ RoomGetStateEventResponse, ) -from matrix.types import Reaction +from matrix.types import Reaction, ReactionEvent from matrix.content import ReactionContent, EditContent from matrix.errors import MatrixError from matrix.api import matrix_call @@ -76,6 +77,13 @@ async def reactions(ctx: Context): """ raw: dict[str, list[str]] = {} + async for reaction_event in self._iter_reaction_events(): + raw.setdefault(reaction_event.emoji, []).append(reaction_event.sender) + + return [Reaction(key=emoji, senders=senders) for emoji, senders in raw.items()] + + async def _iter_reaction_events(self) -> AsyncIterator[ReactionEvent]: + """Yield complete reaction relation events for this message.""" try: async for event in self.client.room_get_event_relations( room_id=self.room.room_id, @@ -83,13 +91,16 @@ async def reactions(ctx: Context): ): emoji = getattr(event, "key", None) sender = getattr(event, "sender", None) - - if emoji and sender: - raw.setdefault(emoji, []).append(sender) + event_id = getattr(event, "event_id", None) + + if emoji and sender and event_id: + yield ReactionEvent( + emoji=emoji, + sender=sender, + event_id=event_id, + ) except Exception as e: - raise MatrixError(f"Failed to fetch reactions: {e}") - - return [Reaction(key=emoji, senders=senders) for emoji, senders in raw.items()] + raise MatrixError(f"Failed to fetch reactions: {e}") from e async def reply(self, body: str) -> "Message": """Reply to this message. @@ -147,20 +158,13 @@ async def toggle(ctx: Context): ``` """ reaction_event_id = None - try: - async for event in self.client.room_get_event_relations( - room_id=self.room.room_id, - event_id=self.event_id, + async for reaction_event in self._iter_reaction_events(): + if ( + reaction_event.emoji == emoji + and reaction_event.sender == self.client.user_id ): - if ( - getattr(event, "key", None) == emoji - and getattr(event, "sender", None) == self.client.user_id - ): - reaction_event_id = getattr(event, "event_id", None) - if reaction_event_id: - break - except Exception as e: - raise MatrixError(f"Failed to remove reaction: {e}") from e + reaction_event_id = reaction_event.event_id + break if reaction_event_id is None: return diff --git a/matrix/types.py b/matrix/types.py index 8da5243..36ac3ac 100644 --- a/matrix/types.py +++ b/matrix/types.py @@ -30,3 +30,12 @@ class Video(File): class Reaction: key: str senders: list[str] + + +@dataclass +class ReactionEvent: + """Details for a single reaction relation event.""" + + emoji: str + sender: str + event_id: str diff --git a/tests/test_message.py b/tests/test_message.py index 1adb0e9..fc252b4 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -149,6 +149,18 @@ async def mock_relations(*args, **kwargs): client.room_redact.assert_not_awaited() +@pytest.mark.asyncio +async def test_unreact_with_relations_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: Network error"): + await message.unreact("👍") + + @pytest.mark.asyncio async def test_unreact_with_api_error__expect_matrix_error(message, client): own_reaction = MagicMock(event_id="$reaction1", key="👍", sender="@bot:matrix.org")