From 8cdda296f1f7dd9d658906ac45c2af70a68772c1 Mon Sep 17 00:00:00 2001 From: Fran Fitzpatrick Date: Thu, 9 Apr 2026 18:28:53 -0500 Subject: [PATCH 1/3] fix(matrix): remove eyes reaction on processing complete The on_processing_complete handler was never removing the eyes reaction because _send_reaction didn't return the reaction event_id. Fix: - _send_reaction returns Optional[str] event_id - on_processing_start stores it in _pending_reactions dict - on_processing_complete redacts the eyes reaction before adding completion emoji --- gateway/platforms/matrix.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index e29ae379b31dd..9bdf692f3938f 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -171,6 +171,9 @@ def __init__(self, config: PlatformConfig): self._reactions_enabled: bool = os.getenv( "MATRIX_REACTIONS", "true" ).lower() not in ("false", "0", "no") + # Tracks the reaction event_id for in-progress (eyes) reactions. + # Key: (room_id, message_event_id) → reaction_event_id (for the eyes reaction). + self._pending_reactions: dict[tuple[str, str], str] = {} def _is_duplicate_event(self, event_id) -> bool: """Return True if this event was already processed. Tracks the ID otherwise.""" @@ -1350,12 +1353,14 @@ async def _on_invite(self, room: Any, event: Any) -> None: async def _send_reaction( self, room_id: str, event_id: str, emoji: str, - ) -> bool: - """Send an emoji reaction to a message in a room.""" + ) -> Optional[str]: + """Send an emoji reaction to a message in a room. + Returns the reaction event_id on success, None on failure. + """ import nio if not self._client: - return False + return None content = { "m.relates_to": { "rel_type": "m.annotation", @@ -1370,12 +1375,12 @@ async def _send_reaction( ) if isinstance(resp, nio.RoomSendResponse): logger.debug("Matrix: sent reaction %s to %s", emoji, event_id) - return True + return resp.event_id logger.debug("Matrix: reaction send failed: %s", resp) - return False + return None except Exception as exc: logger.debug("Matrix: reaction send error: %s", exc) - return False + return None async def _redact_reaction( self, room_id: str, reaction_event_id: str, reason: str = "", @@ -1390,7 +1395,9 @@ async def on_processing_start(self, event: MessageEvent) -> None: msg_id = event.message_id room_id = event.source.chat_id if msg_id and room_id: - await self._send_reaction(room_id, msg_id, "\U0001f440") + reaction_event_id = await self._send_reaction(room_id, msg_id, "\U0001f440") + if reaction_event_id: + self._pending_reactions[(room_id, msg_id)] = reaction_event_id async def on_processing_complete( self, event: MessageEvent, success: bool, @@ -1402,9 +1409,12 @@ async def on_processing_complete( room_id = event.source.chat_id if not msg_id or not room_id: return - # Note: Matrix doesn't support removing a specific reaction easily - # without tracking the reaction event_id. We send the new reaction; - # the eyes stays (acceptable UX — both are visible). + # Remove the eyes reaction first, if we tracked its event_id. + reaction_key = (room_id, msg_id) + if reaction_key in self._pending_reactions: + eyes_event_id = self._pending_reactions.pop(reaction_key) + await self._redact_reaction(room_id, eyes_event_id) + # Now send the completion reaction. await self._send_reaction( room_id, msg_id, "\u2705" if success else "\u274c", ) From 50e86a802167612b4809d89037436bebe56c3280 Mon Sep 17 00:00:00 2001 From: Fran Fitzpatrick Date: Thu, 9 Apr 2026 19:17:43 -0500 Subject: [PATCH 2/3] test: update Matrix reaction tests for new _send_reaction return type _send_reaction now returns Optional[str] (event_id) instead of bool. Tests updated: - test_send_reaction: assert result == event_id string - test_send_reaction_no_client: assert result is None - test_on_processing_start_sends_eyes: _send_reaction returns event_id, now also asserts _pending_reactions is populated - test_on_processing_complete_sends_check: set up _pending_reactions and mock _redact_reaction, assert eyes reaction is redacted before sending check --- tests/gateway/test_matrix.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index 0de00b736f4b5..f9cf280c86760 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -1943,7 +1943,7 @@ async def test_send_reaction(self): with patch.dict("sys.modules", {"nio": fake_nio}): result = await self.adapter._send_reaction("!room:ex", "$event1", "👍") - assert result is True + assert result == "$reaction1" mock_client.room_send.assert_called_once() args = mock_client.room_send.call_args assert args[0][1] == "m.reaction" @@ -1956,7 +1956,7 @@ async def test_send_reaction_no_client(self): self.adapter._client = None with patch.dict("sys.modules", {"nio": _make_fake_nio()}): result = await self.adapter._send_reaction("!room:ex", "$ev", "👍") - assert result is False + assert result is None @pytest.mark.asyncio async def test_on_processing_start_sends_eyes(self): @@ -1964,7 +1964,7 @@ async def test_on_processing_start_sends_eyes(self): from gateway.platforms.base import MessageEvent, MessageType self.adapter._reactions_enabled = True - self.adapter._send_reaction = AsyncMock(return_value=True) + self.adapter._send_reaction = AsyncMock(return_value="$reaction_event_123") source = MagicMock() source.chat_id = "!room:ex" @@ -1977,13 +1977,16 @@ async def test_on_processing_start_sends_eyes(self): ) await self.adapter.on_processing_start(event) self.adapter._send_reaction.assert_called_once_with("!room:ex", "$msg1", "👀") + assert self.adapter._pending_reactions == {("!room:ex", "$msg1"): "$reaction_event_123"} @pytest.mark.asyncio async def test_on_processing_complete_sends_check(self): from gateway.platforms.base import MessageEvent, MessageType self.adapter._reactions_enabled = True - self.adapter._send_reaction = AsyncMock(return_value=True) + self.adapter._pending_reactions = {("!room:ex", "$msg1"): "$eyes_reaction_123"} + self.adapter._redact_reaction = AsyncMock(return_value=True) + self.adapter._send_reaction = AsyncMock(return_value="$check_reaction_456") source = MagicMock() source.chat_id = "!room:ex" @@ -1995,6 +1998,7 @@ async def test_on_processing_complete_sends_check(self): message_id="$msg1", ) await self.adapter.on_processing_complete(event, success=True) + self.adapter._redact_reaction.assert_called_once_with("!room:ex", "$eyes_reaction_123") self.adapter._send_reaction.assert_called_once_with("!room:ex", "$msg1", "✅") @pytest.mark.asyncio From a64e6aefbea617c95f574005c724484d92ae7b15 Mon Sep 17 00:00:00 2001 From: Fran Fitzpatrick Date: Thu, 9 Apr 2026 23:34:09 -0500 Subject: [PATCH 3/3] fix(matrix): log redact failures and add missing reaction test cases Add debug logging when eyes reaction redaction fails, and add tests for the success=False path and the no-pending-reaction edge case. Co-Authored-By: Claude Opus 4.6 (1M context) --- gateway/platforms/matrix.py | 3 ++- tests/gateway/test_matrix.py | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 9bdf692f3938f..0d62b0c010b9d 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -1413,7 +1413,8 @@ async def on_processing_complete( reaction_key = (room_id, msg_id) if reaction_key in self._pending_reactions: eyes_event_id = self._pending_reactions.pop(reaction_key) - await self._redact_reaction(room_id, eyes_event_id) + if not await self._redact_reaction(room_id, eyes_event_id): + logger.debug("Matrix: failed to redact eyes reaction %s", eyes_event_id) # Now send the completion reaction. await self._send_reaction( room_id, msg_id, "\u2705" if success else "\u274c", diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index f9cf280c86760..ac371bd4fe660 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -2001,6 +2001,51 @@ async def test_on_processing_complete_sends_check(self): self.adapter._redact_reaction.assert_called_once_with("!room:ex", "$eyes_reaction_123") self.adapter._send_reaction.assert_called_once_with("!room:ex", "$msg1", "✅") + @pytest.mark.asyncio + async def test_on_processing_complete_sends_cross_on_failure(self): + from gateway.platforms.base import MessageEvent, MessageType + + self.adapter._reactions_enabled = True + self.adapter._pending_reactions = {("!room:ex", "$msg1"): "$eyes_reaction_123"} + self.adapter._redact_reaction = AsyncMock(return_value=True) + self.adapter._send_reaction = AsyncMock(return_value="$cross_reaction_456") + + source = MagicMock() + source.chat_id = "!room:ex" + event = MessageEvent( + text="hello", + message_type=MessageType.TEXT, + source=source, + raw_message={}, + message_id="$msg1", + ) + await self.adapter.on_processing_complete(event, success=False) + self.adapter._redact_reaction.assert_called_once_with("!room:ex", "$eyes_reaction_123") + self.adapter._send_reaction.assert_called_once_with("!room:ex", "$msg1", "❌") + + @pytest.mark.asyncio + async def test_on_processing_complete_no_pending_reaction(self): + """on_processing_complete should skip redaction if no eyes reaction was tracked.""" + from gateway.platforms.base import MessageEvent, MessageType + + self.adapter._reactions_enabled = True + self.adapter._pending_reactions = {} + self.adapter._redact_reaction = AsyncMock() + self.adapter._send_reaction = AsyncMock(return_value="$check_reaction_789") + + source = MagicMock() + source.chat_id = "!room:ex" + event = MessageEvent( + text="hello", + message_type=MessageType.TEXT, + source=source, + raw_message={}, + message_id="$msg1", + ) + await self.adapter.on_processing_complete(event, success=True) + self.adapter._redact_reaction.assert_not_called() + self.adapter._send_reaction.assert_called_once_with("!room:ex", "$msg1", "✅") + @pytest.mark.asyncio async def test_reactions_disabled(self): from gateway.platforms.base import MessageEvent, MessageType