Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions gateway/platforms/matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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",
Expand All @@ -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 = "",
Expand All @@ -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,
Expand All @@ -1402,9 +1409,13 @@ 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)
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",
)
Expand Down
57 changes: 53 additions & 4 deletions tests/gateway/test_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -1956,15 +1956,15 @@ 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):
"""on_processing_start should send πŸ‘€ reaction."""
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"
Expand All @@ -1977,13 +1977,61 @@ 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"
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_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"
Expand All @@ -1995,6 +2043,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_not_called()
self.adapter._send_reaction.assert_called_once_with("!room:ex", "$msg1", "βœ…")

@pytest.mark.asyncio
Expand Down
Loading