mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-25 05:52:34 +00:00
feat(feishu): add native update prompt cards
This commit is contained in:
parent
e3ebaa19ba
commit
7e578f02c8
3 changed files with 433 additions and 3 deletions
|
|
@ -1404,6 +1404,9 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||||
# Exec approval button state (approval_id → {session_key, message_id, chat_id})
|
# Exec approval button state (approval_id → {session_key, message_id, chat_id})
|
||||||
self._approval_state: Dict[int, Dict[str, str]] = {}
|
self._approval_state: Dict[int, Dict[str, str]] = {}
|
||||||
self._approval_counter = itertools.count(1)
|
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
|
# Feishu reaction deletion requires the opaque reaction_id returned
|
||||||
# by create, so we cache it per message_id.
|
# by create, so we cache it per message_id.
|
||||||
self._pending_processing_reactions: "OrderedDict[str, str]" = OrderedDict()
|
self._pending_processing_reactions: "OrderedDict[str, str]" = OrderedDict()
|
||||||
|
|
@ -1856,6 +1859,74 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||||
logger.warning("[Feishu] send_exec_approval failed: %s", exc)
|
logger.warning("[Feishu] send_exec_approval failed: %s", exc)
|
||||||
return SendResult(success=False, error=str(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
|
@staticmethod
|
||||||
def _build_resolved_approval_card(*, choice: str, user_name: str) -> Dict[str, Any]:
|
def _build_resolved_approval_card(*, choice: str, user_name: str) -> Dict[str, Any]:
|
||||||
"""Build raw card JSON for a resolved approval action."""
|
"""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(
|
async def send_voice(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
|
|
@ -2372,9 +2465,19 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||||
action = getattr(event, "action", None)
|
action = getattr(event, "action", None)
|
||||||
action_value = getattr(action, "value", {}) or {}
|
action_value = getattr(action, "value", {}) or {}
|
||||||
hermes_action = action_value.get("hermes_action") if isinstance(action_value, dict) else None
|
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:
|
if hermes_action:
|
||||||
return self._handle_approval_card_action(event=event, action_value=action_value, loop=loop)
|
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))
|
self._submit_on_loop(loop, self._handle_card_action_event(data))
|
||||||
if P2CardActionTriggerResponse is None:
|
if P2CardActionTriggerResponse is None:
|
||||||
|
|
@ -2386,10 +2489,26 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||||
"""Return True when the adapter loop can accept thread-safe submissions."""
|
"""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)())
|
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."""
|
"""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)
|
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:
|
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."""
|
"""Schedule approval resolution and build the synchronous callback response."""
|
||||||
|
|
@ -2403,7 +2522,8 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||||
open_id = str(getattr(operator, "open_id", "") or "")
|
open_id = str(getattr(operator, "open_id", "") or "")
|
||||||
user_name = self._get_cached_sender_name(open_id) or open_id
|
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:
|
if P2CardActionTriggerResponse is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -2415,6 +2535,41 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||||
response.card = card
|
response.card = card
|
||||||
return response
|
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 "<unknown>")
|
||||||
|
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:
|
async def _resolve_approval(self, approval_id: Any, choice: str, user_name: str) -> None:
|
||||||
"""Pop approval state and unblock the waiting agent thread."""
|
"""Pop approval state and unblock the waiting agent thread."""
|
||||||
state = self._approval_state.pop(approval_id, None)
|
state = self._approval_state.pop(approval_id, None)
|
||||||
|
|
@ -2431,6 +2586,21 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to resolve gateway approval from Feishu button: %s", 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:
|
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."""
|
"""Fetch the reacted-to message; if it was sent by this bot, emit a synthetic text event."""
|
||||||
if not self._client:
|
if not self._client:
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,101 @@ class TestFeishuExecApproval:
|
||||||
assert ids[0] != ids[1]
|
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
|
# _resolve_approval — approval state pop + gateway resolution
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
@ -442,3 +537,166 @@ class TestCardActionCallbackResponse:
|
||||||
card = response.card.data
|
card = response.card.data
|
||||||
assert "Old Name" not in card["elements"][0]["content"]
|
assert "Old Name" not in card["elements"][0]["content"]
|
||||||
assert "ou_expired" 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()
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- 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.
|
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.
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue