diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index cd9504e1da2..0470aaa2665 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -1404,6 +1404,9 @@ class FeishuAdapter(BasePlatformAdapter): # Exec approval button state (approval_id → {session_key, message_id, chat_id}) self._approval_state: Dict[int, Dict[str, str]] = {} self._approval_counter = itertools.count(1) + # Update prompt button state (prompt_id → {session_key, message_id, chat_id}) + self._update_prompt_state: Dict[int, Dict[str, str]] = {} + self._update_prompt_counter = itertools.count(1) # Feishu reaction deletion requires the opaque reaction_id returned # by create, so we cache it per message_id. self._pending_processing_reactions: "OrderedDict[str, str]" = OrderedDict() @@ -1856,6 +1859,74 @@ class FeishuAdapter(BasePlatformAdapter): logger.warning("[Feishu] send_exec_approval failed: %s", exc) return SendResult(success=False, error=str(exc)) + @staticmethod + def _build_update_prompt_card(*, prompt: str, default: str, prompt_id: int) -> Dict[str, Any]: + default_hint = f"\n\nDefault: `{default}`" if default else "" + + def _btn(label: str, answer: str, btn_type: str) -> dict: + return { + "tag": "button", + "text": {"tag": "plain_text", "content": label}, + "type": btn_type, + "value": { + "hermes_update_prompt_action": answer, + "update_prompt_id": prompt_id, + }, + } + + return { + "config": {"wide_screen_mode": True}, + "header": { + "title": {"content": "⚕ Update Needs Your Input", "tag": "plain_text"}, + "template": "orange", + }, + "elements": [ + {"tag": "markdown", "content": f"{prompt}{default_hint}"}, + { + "tag": "action", + "actions": [ + _btn("✓ Yes", "y", "primary"), + _btn("✗ No", "n", "danger"), + ], + }, + ], + } + + async def send_update_prompt( + self, chat_id: str, prompt: str, default: str = "", + session_key: str = "", + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an interactive update prompt with Yes/No buttons.""" + if not self._client: + return SendResult(success=False, error="Not connected") + + try: + prompt_id = next(self._update_prompt_counter) + payload = json.dumps( + self._build_update_prompt_card(prompt=prompt, default=default, prompt_id=prompt_id), + ensure_ascii=False, + ) + response = await self._feishu_send_with_retry( + chat_id=chat_id, + msg_type="interactive", + payload=payload, + reply_to=None, + metadata=metadata, + ) + + result = self._finalize_send_result(response, "send_update_prompt failed") + if result.success: + self._update_prompt_state[prompt_id] = { + "session_key": session_key, + "message_id": result.message_id or "", + "chat_id": chat_id, + } + return result + except Exception as exc: + logger.warning("[Feishu] send_update_prompt failed: %s", exc) + return SendResult(success=False, error=str(exc)) + @staticmethod def _build_resolved_approval_card(*, choice: str, user_name: str) -> Dict[str, Any]: """Build raw card JSON for a resolved approval action.""" @@ -1875,6 +1946,28 @@ class FeishuAdapter(BasePlatformAdapter): ], } + @staticmethod + def _build_resolved_update_prompt_card(*, answer: str, user_name: str) -> Dict[str, Any]: + yes = answer == "y" + label = "Yes" if yes else "No" + return { + "config": {"wide_screen_mode": True}, + "header": { + "title": {"content": f"{'✅' if yes else '❌'} Update prompt answered: {label}", "tag": "plain_text"}, + "template": "green" if yes else "red", + }, + "elements": [ + {"tag": "markdown", "content": f"Answered by **{user_name}**"}, + ], + } + + @staticmethod + def _write_update_prompt_response(answer: str) -> None: + response_path = get_hermes_home() / ".update_response" + tmp_path = response_path.with_suffix(".tmp") + tmp_path.write_text(answer) + tmp_path.replace(response_path) + async def send_voice( self, chat_id: str, @@ -2372,9 +2465,19 @@ class FeishuAdapter(BasePlatformAdapter): action = getattr(event, "action", None) action_value = getattr(action, "value", {}) or {} hermes_action = action_value.get("hermes_action") if isinstance(action_value, dict) else None + update_prompt_action = ( + action_value.get("hermes_update_prompt_action") + if isinstance(action_value, dict) else None + ) if hermes_action: return self._handle_approval_card_action(event=event, action_value=action_value, loop=loop) + if update_prompt_action: + return self._handle_update_prompt_card_action( + event=event, + action_value=action_value, + loop=loop, + ) self._submit_on_loop(loop, self._handle_card_action_event(data)) if P2CardActionTriggerResponse is None: @@ -2386,10 +2489,26 @@ class FeishuAdapter(BasePlatformAdapter): """Return True when the adapter loop can accept thread-safe submissions.""" return loop is not None and not bool(getattr(loop, "is_closed", lambda: False)()) - def _submit_on_loop(self, loop: Any, coro: Any) -> None: + def _submit_on_loop(self, loop: Any, coro: Any) -> bool: """Schedule background work on the adapter loop with shared failure logging.""" - future = asyncio.run_coroutine_threadsafe(coro, loop) + try: + future = asyncio.run_coroutine_threadsafe(coro, loop) + except Exception: + coro.close() + logger.warning("[Feishu] Failed to schedule background callback work", exc_info=True) + return False future.add_done_callback(self._log_background_failure) + return True + + def _is_interactive_operator_authorized(self, open_id: str) -> bool: + """Return whether this card-action operator may answer gated prompts.""" + normalized = str(open_id or "").strip() + if not normalized: + return False + allowed_ids = set(self._admins) | set(self._allowed_group_users) + if not allowed_ids: + return True + return "*" in allowed_ids or normalized in allowed_ids def _handle_approval_card_action(self, *, event: Any, action_value: Dict[str, Any], loop: Any) -> Any: """Schedule approval resolution and build the synchronous callback response.""" @@ -2403,7 +2522,8 @@ class FeishuAdapter(BasePlatformAdapter): open_id = str(getattr(operator, "open_id", "") or "") user_name = self._get_cached_sender_name(open_id) or open_id - self._submit_on_loop(loop, self._resolve_approval(approval_id, choice, user_name)) + if not self._submit_on_loop(loop, self._resolve_approval(approval_id, choice, user_name)): + return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None if P2CardActionTriggerResponse is None: return None @@ -2415,6 +2535,41 @@ class FeishuAdapter(BasePlatformAdapter): response.card = card return response + def _handle_update_prompt_card_action(self, *, event: Any, action_value: Dict[str, Any], loop: Any) -> Any: + """Schedule update prompt resolution and build the synchronous callback response.""" + prompt_id = action_value.get("update_prompt_id") + if prompt_id is None: + logger.debug("[Feishu] Card action missing update_prompt_id, ignoring") + return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None + if prompt_id not in self._update_prompt_state: + logger.debug("[Feishu] Update prompt %s already resolved or unknown", prompt_id) + return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None + + answer = str(action_value.get("hermes_update_prompt_action", "") or "").strip().lower() + if answer not in {"y", "n"}: + logger.debug("[Feishu] Card action has invalid update prompt answer=%r", answer) + return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None + + operator = getattr(event, "operator", None) + open_id = str(getattr(operator, "open_id", "") or "") + if not self._is_interactive_operator_authorized(open_id): + logger.warning("[Feishu] Unauthorized update prompt click by %s", open_id or "") + return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None + + user_name = self._get_cached_sender_name(open_id) or open_id + if not self._submit_on_loop(loop, self._resolve_update_prompt(prompt_id, answer, user_name)): + return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None + + if P2CardActionTriggerResponse is None: + return None + response = P2CardActionTriggerResponse() + if CallBackCard is not None: + card = CallBackCard() + card.type = "raw" + card.data = self._build_resolved_update_prompt_card(answer=answer, user_name=user_name) + response.card = card + return response + async def _resolve_approval(self, approval_id: Any, choice: str, user_name: str) -> None: """Pop approval state and unblock the waiting agent thread.""" state = self._approval_state.pop(approval_id, None) @@ -2431,6 +2586,21 @@ class FeishuAdapter(BasePlatformAdapter): except Exception as exc: logger.error("Failed to resolve gateway approval from Feishu button: %s", exc) + async def _resolve_update_prompt(self, prompt_id: Any, answer: str, user_name: str) -> None: + """Persist an update prompt answer for the detached update process.""" + state = self._update_prompt_state.pop(prompt_id, None) + if not state: + logger.debug("[Feishu] Update prompt %s already resolved or unknown", prompt_id) + return + try: + self._write_update_prompt_response(answer) + logger.info( + "Feishu update prompt resolved for session %s (answer=%s, user=%s)", + state["session_key"], answer, user_name, + ) + except Exception as exc: + logger.error("Failed to resolve Feishu update prompt: %s", exc) + async def _handle_reaction_event(self, event_type: str, data: Any) -> None: """Fetch the reacted-to message; if it was sent by this bot, emit a synthetic text event.""" if not self._client: diff --git a/tests/gateway/test_feishu_approval_buttons.py b/tests/gateway/test_feishu_approval_buttons.py index 954e9c06104..8af56913c10 100644 --- a/tests/gateway/test_feishu_approval_buttons.py +++ b/tests/gateway/test_feishu_approval_buttons.py @@ -208,6 +208,101 @@ class TestFeishuExecApproval: assert ids[0] != ids[1] +# =========================================================================== +# send_update_prompt — interactive card with buttons +# =========================================================================== + +class TestFeishuUpdatePrompt: + """Test send_update_prompt sends an interactive card.""" + + @pytest.mark.asyncio + async def test_sends_interactive_card(self): + adapter = _make_adapter() + + mock_response = SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(message_id="msg_up_001"), + ) + with patch.object( + adapter, "_feishu_send_with_retry", new_callable=AsyncMock, + return_value=mock_response, + ) as mock_send: + result = await adapter.send_update_prompt( + chat_id="oc_12345", + prompt="Restore stashed changes after update?", + default="y", + session_key="agent:main:feishu:group:oc_12345", + metadata={"thread_id": "th_1"}, + ) + + assert result.success is True + assert result.message_id == "msg_up_001" + + kwargs = mock_send.call_args[1] + assert kwargs["chat_id"] == "oc_12345" + assert kwargs["msg_type"] == "interactive" + assert kwargs["metadata"] == {"thread_id": "th_1"} + + card = json.loads(kwargs["payload"]) + assert card["header"]["template"] == "orange" + assert "Restore stashed changes after update?" in card["elements"][0]["content"] + assert "Default: `y`" in card["elements"][0]["content"] + actions = card["elements"][1]["actions"] + assert [a["value"]["hermes_update_prompt_action"] for a in actions] == ["y", "n"] + + @pytest.mark.asyncio + async def test_stores_prompt_state(self): + adapter = _make_adapter() + + mock_response = SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(message_id="msg_up_002"), + ) + with patch.object( + adapter, "_feishu_send_with_retry", new_callable=AsyncMock, + return_value=mock_response, + ): + await adapter.send_update_prompt( + chat_id="oc_12345", + prompt="Continue update?", + session_key="my-session-key", + ) + + assert len(adapter._update_prompt_state) == 1 + prompt_id = list(adapter._update_prompt_state.keys())[0] + state = adapter._update_prompt_state[prompt_id] + assert state["session_key"] == "my-session-key" + assert state["message_id"] == "msg_up_002" + assert state["chat_id"] == "oc_12345" + + @pytest.mark.asyncio + async def test_not_connected(self): + adapter = _make_adapter() + adapter._client = None + result = await adapter.send_update_prompt( + chat_id="oc_12345", + prompt="Continue update?", + session_key="s", + ) + assert result.success is False + + @pytest.mark.asyncio + async def test_send_failure_returns_error(self): + adapter = _make_adapter() + with patch.object( + adapter, "_feishu_send_with_retry", new_callable=AsyncMock, + side_effect=TimeoutError("timed out"), + ): + result = await adapter.send_update_prompt( + chat_id="oc_12345", + prompt="Continue update?", + session_key="s", + ) + + assert result.success is False + assert "timed out" in (result.error or "") + + # =========================================================================== # _resolve_approval — approval state pop + gateway resolution # =========================================================================== @@ -442,3 +537,166 @@ class TestCardActionCallbackResponse: card = response.card.data assert "Old Name" not in card["elements"][0]["content"] assert "ou_expired" in card["elements"][0]["content"] + + def test_returns_card_for_update_prompt_yes(self, _patch_callback_card_types): + adapter = _make_adapter() + adapter._loop = MagicMock() + adapter._loop.is_closed = MagicMock(return_value=False) + adapter._update_prompt_state[1] = { + "session_key": "sess-up-1", + "message_id": "msg_up_003", + "chat_id": "oc_12345", + } + data = _make_card_action_data( + {"hermes_update_prompt_action": "y", "update_prompt_id": 1}, + open_id="ou_bob", + ) + adapter._sender_name_cache["ou_bob"] = ("Bob", 9999999999) + + with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro): + response = adapter._on_card_action_trigger(data) + + assert response is not None + assert response.card is not None + card = response.card.data + assert card["header"]["template"] == "green" + assert "answered: Yes" in card["header"]["title"]["content"] + assert "Bob" in card["elements"][0]["content"] + + def test_returns_card_for_update_prompt_no(self, _patch_callback_card_types): + adapter = _make_adapter() + adapter._loop = MagicMock() + adapter._loop.is_closed = MagicMock(return_value=False) + adapter._update_prompt_state[2] = { + "session_key": "sess-up-2", + "message_id": "msg_up_004", + "chat_id": "oc_12345", + } + data = _make_card_action_data( + {"hermes_update_prompt_action": "n", "update_prompt_id": 2}, + ) + + with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro): + response = adapter._on_card_action_trigger(data) + + assert response is not None + assert response.card is not None + card = response.card.data + assert card["header"]["template"] == "red" + assert "answered: No" in card["header"]["title"]["content"] + + def test_ignores_missing_update_prompt_id(self, _patch_callback_card_types): + adapter = _make_adapter() + adapter._loop = MagicMock() + adapter._loop.is_closed = MagicMock(return_value=False) + data = _make_card_action_data({"hermes_update_prompt_action": "y"}) + + with patch("asyncio.run_coroutine_threadsafe") as mock_submit: + response = adapter._on_card_action_trigger(data) + + assert response is not None + assert response.card is None + mock_submit.assert_not_called() + + def test_already_resolved_update_prompt_returns_no_card(self, _patch_callback_card_types): + adapter = _make_adapter() + adapter._loop = MagicMock() + adapter._loop.is_closed = MagicMock(return_value=False) + data = _make_card_action_data( + {"hermes_update_prompt_action": "y", "update_prompt_id": 99}, + ) + + with patch("asyncio.run_coroutine_threadsafe") as mock_submit: + response = adapter._on_card_action_trigger(data) + + assert response is not None + assert response.card is None + mock_submit.assert_not_called() + + def test_update_prompt_schedule_failure_returns_no_card(self, _patch_callback_card_types): + adapter = _make_adapter() + adapter._loop = MagicMock() + adapter._loop.is_closed = MagicMock(return_value=False) + adapter._update_prompt_state[1] = { + "session_key": "sess-up-1", + "message_id": "msg_up_005", + "chat_id": "oc_12345", + } + data = _make_card_action_data( + {"hermes_update_prompt_action": "y", "update_prompt_id": 1}, + ) + + with patch("asyncio.run_coroutine_threadsafe", side_effect=RuntimeError("loop closed")): + response = adapter._on_card_action_trigger(data) + + assert response is not None + assert response.card is None + + def test_update_prompt_unauthorized_operator_returns_no_card(self, _patch_callback_card_types): + adapter = _make_adapter() + adapter._loop = MagicMock() + adapter._loop.is_closed = MagicMock(return_value=False) + adapter._update_prompt_state[1] = { + "session_key": "sess-up-1", + "message_id": "msg_up_006", + "chat_id": "oc_12345", + } + adapter._allowed_group_users = {"ou_allowed"} + data = _make_card_action_data( + {"hermes_update_prompt_action": "y", "update_prompt_id": 1}, + open_id="ou_intruder", + ) + + with patch("asyncio.run_coroutine_threadsafe") as mock_submit: + response = adapter._on_card_action_trigger(data) + + assert response is not None + assert response.card is None + mock_submit.assert_not_called() + + +class TestResolveUpdatePrompt: + """Test update prompt resolution persists the response file.""" + + @pytest.mark.asyncio + async def test_writes_response_file(self, tmp_path, monkeypatch): + adapter = _make_adapter() + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir() + adapter._update_prompt_state[1] = { + "session_key": "sess-up-1", + "message_id": "msg_up_003", + "chat_id": "oc_12345", + } + + await adapter._resolve_update_prompt(1, "y", "Alice") + + assert (tmp_path / ".hermes" / ".update_response").read_text() == "y" + assert 1 not in adapter._update_prompt_state + + @pytest.mark.asyncio + async def test_overwrites_existing_response_file(self, tmp_path, monkeypatch): + adapter = _make_adapter() + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + home = tmp_path / ".hermes" + home.mkdir() + (home / ".update_response").write_text("n") + adapter._update_prompt_state[2] = { + "session_key": "sess-up-2", + "message_id": "msg_up_004", + "chat_id": "oc_12345", + } + + await adapter._resolve_update_prompt(2, "y", "Alice") + + assert (home / ".update_response").read_text() == "y" + + @pytest.mark.asyncio + async def test_unknown_prompt_id_drops_silently(self, tmp_path, monkeypatch): + adapter = _make_adapter() + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir() + + await adapter._resolve_update_prompt(99, "n", "Nobody") + + assert not (tmp_path / ".hermes" / ".update_response").exists() diff --git a/website/docs/user-guide/messaging/feishu.md b/website/docs/user-guide/messaging/feishu.md index 879964c80fc..d5a84afc0e6 100644 --- a/website/docs/user-guide/messaging/feishu.md +++ b/website/docs/user-guide/messaging/feishu.md @@ -249,6 +249,8 @@ When users click buttons or interact with interactive cards sent by the bot, the - The action's `value` payload from the card definition is included as JSON. - Card actions are deduplicated with a 15-minute window to prevent double processing. +Gateway-driven update prompts use a native Feishu `Yes` / `No` card instead of falling back to plain text replies. When `hermes update --gateway` needs confirmation, the adapter records the selected answer in Hermes's `.update_response` file and replaces the card inline with a resolved state. + Card action events are dispatched with `MessageType.COMMAND`, so they flow through the normal command processing pipeline. This is also how **command approval** works — when the agent needs to run a dangerous command, it sends an interactive card with Allow Once / Session / Always / Deny buttons. The user clicks a button, and the card action callback delivers the approval decision back to the agent.