mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-20 05:01:30 +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})
|
||||
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 "<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:
|
||||
"""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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue