Parcourir la source

Forward Chatto reactions to the gateway's reaction hook surface

reaction_added/reaction_removed were parsed by chattolib and then thrown
away in _handle_realtime_event. The gateway wires a reaction handler into
every adapter (gateway/run.py:12617) and fans it out as reaction:added /
reaction:removed hooks — Chatto simply never fed it.

The normalised dict follows the Slack adapter's shape, since hook consumers
are written against that contract rather than a per-platform one. Our own
lifecycle markers are filtered out: forwarding the eyes/check/cross we just
posted ourselves would feed the agent its own output.

Also moves message_edited/message_retracted into the known-kinds list; they
were logging as 'unknown event kind' on every edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul-Dieter Klumpp il y a 1 semaine
Parent
commit
aca30b1304
2 fichiers modifiés avec 114 ajouts et 2 suppressions
  1. 53 2
      adapter.py
  2. 61 0
      test_adapter.py

+ 53 - 2
adapter.py

@@ -72,7 +72,8 @@ try:
         stream_events
     )
     from chattolib.realtime_types import (
-        MessagePostedPayload
+        MessagePostedPayload,
+        ReactionPayload,
     )
     from chattolib.types import (
         PresenceStatus, RoomKind, User
@@ -614,6 +615,47 @@ class ChattoAdapter(BasePlatformAdapter):
         await self.handle_message(message_event)
         return
 
+    async def _forward_reaction(
+        self, event: RealtimeEvent, payload: ReactionPayload, *, removed: bool,
+    ) -> None:
+        """Forward a human reaction to the gateway's reaction hook surface.
+
+        The handler is registered by the gateway via ``set_reaction_handler``
+        and fans out as ``reaction:added`` / ``reaction:removed`` through the
+        HookRegistry.  The dict shape mirrors the Slack adapter's — hook
+        consumers are written against that contract, not against a per-platform
+        one.  Our own lifecycle reactions (👀/✅/❌) are dropped: forwarding them
+        would feed the agent its own markers.
+        """
+        actor_id = event.actor_id
+        if actor_id and self.me and actor_id == self.me.id:
+            return
+        if not payload.room_id or not payload.message_event_id or not actor_id:
+            return
+
+        handler = getattr(self, "_reaction_handler", None)
+        if handler is None:
+            return
+
+        action = "removed" if removed else "added"
+        try:
+            await handler(
+                {
+                    "platform": ChattoConstants.PLATFORM_NAME,
+                    "event_name": f"reaction:{action}",
+                    "reaction": payload.emoji,
+                    "user_id": actor_id,
+                    "item_user_id": None,
+                    "item_type": "message",
+                    "channel_id": payload.room_id,
+                    "message_ts": payload.message_event_id,
+                    "event_ts": event.id,
+                    "raw_event": event,
+                }
+            )
+        except Exception:  # pragma: no cover - the hook contract is non-blocking
+            logger.debug("Chatto: reaction hook forwarding failed", exc_info=True)
+
     async def _handle_realtime_event(self, event: RealtimeEvent) -> None:
         if self._is_seen(event.id):
             return
@@ -633,10 +675,19 @@ class ChattoAdapter(BasePlatformAdapter):
 
             await self._dispatch_message_posted(event_payload)
 
+        elif event.kind in ("reaction_added", "reaction_removed"):
+            reaction_payload = event.get(event.kind)
+            if reaction_payload is not None:
+                await self._forward_reaction(
+                    event,
+                    cast(ReactionPayload, reaction_payload),
+                    removed=event.kind == "reaction_removed",
+                )
+
         # confirmed:
         elif event.kind in ("mention_notification", "projection_event", "presence_changed", "notification_dismissed",
                             "room_marked_as_read", "user_typing", "notification_created", "new_direct_message_notification",
-                            'reaction_removed', 'reaction_added'):
+                            "message_edited", "message_retracted"):
             logger.debug("Chatto: '%s' event received. Not yet implemented or not needed.", event.kind)
         else:
             logger.error("Chatto: unknown event kind: '%s'", event.kind)

+ 61 - 0
test_adapter.py

@@ -394,6 +394,67 @@ class TestMessageEditing:
         adapter._chatto_client.delete_message.assert_called_once()
 
 
+# -- Reaction event forwarding --
+
+class TestReactionForwarding:
+    """Human reactions reach the gateway's reaction hook surface."""
+
+    @pytest_asyncio.fixture
+    def adapter(self):
+        adapter = _make_adapter()
+        adapter.me = _make_user("bot-user-id", "hermes_bot")
+        return adapter
+
+    def _event(self, kind, actor_id="human-1"):
+        event = MagicMock()
+        event.id = "evt-1"
+        event.kind = kind
+        event.actor_id = actor_id
+        payload = ReactionPayload(
+            room_id="room-1", message_event_id="msg-1", emoji="thumbsup",
+        )
+        # RealtimeEvent.get() only yields the payload for its own kind.
+        event.get = MagicMock(side_effect=lambda k: payload if k == kind else None)
+        return event
+
+    async def test_forwards_added_reaction(self, adapter):
+        handler = AsyncMock()
+        adapter.set_reaction_handler(handler)
+
+        await adapter._handle_realtime_event(self._event("reaction_added"))
+
+        handler.assert_called_once()
+        payload = handler.call_args.args[0]
+        assert payload["event_name"] == "reaction:added"
+        assert payload["reaction"] == "thumbsup"
+        assert payload["channel_id"] == "room-1"
+        assert payload["message_ts"] == "msg-1"
+        assert payload["user_id"] == "human-1"
+        assert payload["item_type"] == "message"
+
+    async def test_forwards_removed_reaction(self, adapter):
+        handler = AsyncMock()
+        adapter.set_reaction_handler(handler)
+        await adapter._handle_realtime_event(self._event("reaction_removed"))
+        assert handler.call_args.args[0]["event_name"] == "reaction:removed"
+
+    async def test_ignores_own_lifecycle_reactions(self, adapter):
+        """👀/✅/❌ are ours — forwarding them would feed the agent its own markers."""
+        handler = AsyncMock()
+        adapter.set_reaction_handler(handler)
+        await adapter._handle_realtime_event(
+            self._event("reaction_added", actor_id="bot-user-id"),
+        )
+        handler.assert_not_called()
+
+    async def test_no_handler_registered_is_harmless(self, adapter):
+        await adapter._handle_realtime_event(self._event("reaction_added"))
+
+    async def test_handler_exception_does_not_propagate(self, adapter):
+        adapter.set_reaction_handler(AsyncMock(side_effect=RuntimeError("hook boom")))
+        await adapter._handle_realtime_event(self._event("reaction_added"))
+
+
 # -- Inbound attachments --
 
 class TestInboundAttachments: