Browse Source

feat(chatto): route reactions to interactive prompts

Paul Klumpp 1 week ago
parent
commit
aab5be3015
1 changed files with 178 additions and 0 deletions
  1. 178 0
      adapter.py

+ 178 - 0
adapter.py

@@ -1781,6 +1781,168 @@ class ChattoAdapter(BasePlatformAdapter):
         """
         return self._is_authorized(user_id)
 
+    def _interactive_prompt_expired(self, prompt: Any) -> bool:
+        """Whether an interactive prompt has passed its expiry deadline."""
+        try:
+            return time.monotonic() > float(prompt.expires_at)
+        except (TypeError, ValueError, AttributeError):
+            return False
+
+    async def _try_handle_interactive_reaction(
+        self,
+        room_id: Any,
+        message_id: Any,
+        emoji: Any,
+        actor_id: Any,
+    ) -> bool:
+        """Route a reaction on a tracked interactive prompt.
+
+        Returns True when the message is a tracked prompt (consumed), False
+        otherwise so the caller forwards normally.
+        """
+        if not room_id or not message_id or not emoji or not actor_id:
+            return False
+        room_id = str(room_id)
+        message_id = str(message_id)
+        emoji = str(emoji)
+        actor_id = str(actor_id)
+
+        is_clarify = message_id in self._clarify_prompts
+        is_approval = message_id in self._approval_prompts
+        if not is_clarify and not is_approval:
+            return False
+
+        if is_clarify:
+            prompt = self._clarify_prompts.get(message_id)
+        else:
+            prompt = self._approval_prompts.get(message_id)
+        if prompt is None:
+            return False
+
+        # Room must match.
+        if str(getattr(prompt, "chat_id", "")) != room_id:
+            return True
+
+        # Expiry handling.
+        if self._interactive_prompt_expired(prompt):
+            try:
+                await self.edit_message(
+                    room_id, message_id, "⏰ This prompt has expired."
+                )
+            except Exception:
+                logger.debug("Chatto: expired prompt edit failed", exc_info=True)
+            if is_clarify:
+                self._clarify_prompts.pop(message_id, None)
+                cid = getattr(prompt, "clarify_id", None)
+                if cid is not None:
+                    self._clarify_id_to_message.pop(cid, None)
+            else:
+                self._approval_prompts.pop(message_id, None)
+            return True
+
+        # Authorization (fail-closed).
+        if not self._is_allowed_user_id(actor_id):
+            return True
+
+        # Double-click guard.
+        if getattr(prompt, "resolved", False):
+            return True
+        if is_clarify and getattr(prompt, "awaiting_text", False):
+            return True
+
+        user = self._user_cache.get(actor_id)
+        user_label = (
+            getattr(user, "name", None) or getattr(user, "login", None) or actor_id
+        )
+
+        if is_clarify:
+            number_emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"]
+            other_emoji = "✏️"
+            choices = list(getattr(prompt, "choices", []) or [])
+
+            if emoji == other_emoji:
+                setattr(prompt, "awaiting_text", True)
+                try:
+                    import tools.clarify_gateway as clarify_gateway
+
+                    clarify_gateway.mark_awaiting_text(getattr(prompt, "clarify_id"))
+                except Exception:
+                    logger.debug("Chatto: mark_awaiting_text failed", exc_info=True)
+                try:
+                    await self.edit_message(
+                        room_id, message_id, f"awaiting text from {user_label}"
+                    )
+                except Exception:
+                    logger.debug("Chatto: prompt edit failed", exc_info=True)
+                return True
+
+            if emoji in number_emojis:
+                idx = number_emojis.index(emoji)
+                if 0 <= idx < len(choices):
+                    choice = choices[idx]
+                    prompt.resolved = True
+                    self._clarify_prompts.pop(message_id, None)
+                    cid = getattr(prompt, "clarify_id", None)
+                    if cid is not None:
+                        self._clarify_id_to_message.pop(cid, None)
+                    try:
+                        import tools.clarify_gateway as clarify_gateway
+
+                        clarify_gateway.resolve_gateway_clarify(
+                            getattr(prompt, "clarify_id"), choice
+                        )
+                    except Exception:
+                        logger.exception("Chatto: resolve_gateway_clarify failed")
+                    try:
+                        await self.edit_message(
+                            room_id,
+                            message_id,
+                            f"✅ {user_label} chose: {choice}",
+                        )
+                    except Exception:
+                        logger.debug("Chatto: prompt edit failed", exc_info=True)
+                    return True
+
+            # Invalid emoji feedback.
+            try:
+                await self.send(
+                    room_id, "That reaction isn't a valid option for this prompt."
+                )
+            except Exception:
+                logger.debug("Chatto: invalid reaction feedback failed", exc_info=True)
+            return True
+
+        # Approval prompt.
+        choices_map = getattr(prompt, "choices_map", {}) or {}
+        if emoji in choices_map:
+            choice = choices_map[emoji]
+            prompt.resolved = True
+            self._approval_prompts.pop(message_id, None)
+            try:
+                import tools.approval as approval_mod
+
+                approval_mod.resolve_gateway_approval(
+                    str(getattr(prompt, "session_key", "")), choice
+                )
+            except Exception:
+                logger.exception("Chatto: resolve_gateway_approval failed")
+            try:
+                await self.edit_message(
+                    room_id, message_id, f"✅ {user_label}: {choice}"
+                )
+            except Exception:
+                logger.debug("Chatto: prompt edit failed", exc_info=True)
+            return True
+
+        # Invalid emoji feedback.
+        try:
+            await self.send(
+                room_id, "That reaction isn't a valid option for this prompt."
+            )
+        except Exception:
+            logger.debug("Chatto: invalid reaction feedback failed", exc_info=True)
+        return True
+
     def _evict_roster(self, room_id: str) -> None:
         """Drop all roster state for a room.
 
@@ -2283,6 +2445,22 @@ class ChattoAdapter(BasePlatformAdapter):
             await self._dispatch_message_edited(event.payload)
 
         elif event.kind in ("reaction_added", "reaction_removed"):
+            # Interactive prompts (clarify / exec-approval) are driven by
+            # reactions. Route a reaction on a tracked prompt before
+            # forwarding — the prompt is consumed either way, but the reaction
+            # still reaches the gateway hook surface afterward.
+            if event.kind == "reaction_added":
+                try:
+                    await self._try_handle_interactive_reaction(
+                        getattr(event.payload, "room_id", None),
+                        getattr(event.payload, "message_event_id", None),
+                        getattr(event.payload, "emoji", None),
+                        actor_id,
+                    )
+                except Exception:
+                    logger.debug(
+                        "Chatto: interactive reaction routing failed", exc_info=True
+                    )
             await self._forward_reaction(
                 event,
                 event.payload,