{"repo":"NousResearch/hermes-agent","public_id":"6893","display_label":"Feishu/Lark interactive approval card bugs and fixes","summary":"This cluster is focused on Feishu/Lark platform integration, specifically recurring issues with interactive approval card button clicks failing (error codes 200340, 200343), card update mechanisms, callback handling, and serialization. Members span bug reports, fix attempts, and feature additions all centered on making Feishu card interactions work correctly.","subsystem":"gateway","counts":{"members":30,"open_issues":5,"open_issues_distinct":5,"open_prs":6,"closed_issues":8,"merged_prs":2,"attempt_prs":15},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":9871},"resolution":{"state":"partially_resolved","by":"pr-9871"},"freshness":{"state":"verified","last_verified_at":"2026-09-12T15:48:10+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.07273563218390804},"fix_edges":[{"issue_number":8246,"pr_number":9871,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":8358,"pr_number":9871,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":8764,"pr_number":9871,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":10251,"pr_number":109174,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":6893,"title":"[Bug] Error code 200340 when approving dangerous commands via Feishu/Lark gateway","url":null,"state":"closed"},{"kind":"issue","number":8764,"title":"[Bug]: Feishu Card Interactive Button Error: Code 200340 - No card.action Callback Received & Severe Response Timeout","url":null,"state":"closed"},{"kind":"issue","number":8847,"title":"fix(feishu): approval card never updates — use im.v1.message.patch instead of update","url":null,"state":"closed"},{"kind":"issue","number":9533,"title":"fix(feishu): approval card not updating after button click — use PATCH instead of PUT","url":null,"state":"closed"},{"kind":"issue","number":9585,"title":"Feishu message button click error","url":null,"state":"closed"},{"kind":"issue","number":10073,"title":"[Bug]: [Feishu Mobile] Command approval card returns error code:200340 on any option click despite card.action.trigger being configured","url":null,"state":"open"},{"kind":"issue","number":10154,"title":"[Bug]: Feishu approval card fails to update after button click (error 200340)","url":null,"state":"closed"},{"kind":"issue","number":10251,"title":"Feishu command approval card buttons fail with error code 200340","url":null,"state":"closed"},{"kind":"issue","number":11600,"title":"[Bug]: [Feishu][v0.9.0] approval card clicks can fall through to unsupported /card path","url":null,"state":"closed"},{"kind":"issue","number":13924,"title":"Feishu: Command Approval buttons return \"出错了，请稍后再试 code: 220340\" when clicked","url":null,"state":"open"},{"kind":"issue","number":20596,"title":"Feishu approval card appears but click interaction fails, blocking exec approval flow","url":null,"state":"open"},{"kind":"issue","number":25886,"title":"[Bug]: Feishu/Lark card authorization buttons fail with error 200343","url":null,"state":"open"},{"kind":"issue","number":38305,"title":"Feishu error 200340 persists in v0.15.2 - PR #10256 needs merge","url":null,"state":"open"},{"kind":"pr","number":8787,"title":"fix: route Feishu websocket CARD callbacks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9871","number":9871}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":9848,"title":"fix(feishu): resolve approval cards inline from callback path","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9871","number":9871}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":9871,"title":"fix(feishu): resolve approval cards inline from callback path","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/feishu.py","scripts/release.py","tests/gateway/test_feishu_approval_buttons.py"],"fix_hunks":[{"file":"gateway/platforms/feishu.py","hunks":["@@ -72,7 +72,10 @@\n         UpdateMessageRequestBody,\n     )\n     from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN\n-    from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse\n+    from lark_oapi.event.callback.model.p2_card_action_trigger import (\n+        CallBackCard,\n+        P2CardActionTriggerResponse,\n+    )\n     from lark_oapi.event.dispatcher_handler import EventDispatcherHandler\n     from lark_oapi.ws import Client as FeishuWSClient\n ","@@ -80,6 +83,7 @@\n except ImportError:\n     FEISHU_AVAILABLE = False\n     lark = None  # type: ignore[assignment]\n+    CallBackCard = None  # type: ignore[assignment]\n     P2CardActionTriggerResponse = None  # type: ignore[assignment]\n     EventDispatcherHandler = None  # type: ignore[assignment]\n     FeishuWSClient = None  # type: ignore[assignment]","@@ -169,6 +173,19 @@\n _FEISHU_WEBHOOK_ANOMALY_THRESHOLD = 25             # consecutive error responses before WARNING log\n _FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60  # anomaly tracker TTL (6 hours) — matches openclaw\n _FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS = 15 * 60    # card action token dedup window (15 min)\n+\n+_APPROVAL_CHOICE_MAP: Dict[str, str] = {\n+    \"approve_once\": \"once\",\n+    \"approve_session\": \"session\",\n+    \"approve_always\": \"always\",\n+    \"deny\": \"deny\",\n+}\n+_APPROVAL_LABEL_MAP: Dict[str, str] = {\n+    \"once\": \"Approved once\",\n+    \"session\": \"Approved for session\",\n+    \"always\": \"Approved permanently\",\n+    \"deny\": \"Denied\",\n+}\n _FEISHU_BOT_MSG_TRACK_SIZE = 512                   # LRU size for tracking sent message IDs\n _FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003})  # reply target withdrawn/missing → create fallback\n _FEISHU_ACK_EMOJI = \"OK\"","@@ -1490,14 +1507,12 @@ def _btn(label: str, action_name: str, btn_type: str = \"default\") -> dict:\n             logger.warning(\"[Feishu] send_exec_approval failed: %s\", exc)\n             return SendResult(success=False, error=str(exc))\n \n-    async def _update_approval_card(\n-        self, message_id: str, label: str, user_name: str, choice: str,\n-    ) -> None:\n-        \"\"\"Replace the approval card with a resolved status card.\"\"\"\n-        if not self._client or not message_id:\n-            return\n+    @staticmethod\n+    def _build_resolved_approval_card(*, choice: str, user_name: str) -> Dict[str, Any]:\n+        \"\"\"Build raw card JSON for a resolved approval action.\"\"\"\n         icon = \"❌\" if choice == \"deny\" else \"✅\"\n-        card = {\n+        label = _APPROVAL_LABEL_MAP.get(choice, \"Resolved\")\n+        return {\n             \"config\": {\"wide_screen_mode\": True},\n             \"header\": {\n                 \"title\": {\"content\": f\"{icon} {label}\", \"tag\": \"plain_text\"},","@@ -1510,13 +1525,6 @@ async def _update_approval_card(\n                 },\n             ],\n         }\n-        try:\n-            payload = json.dumps(card, ensure_ascii=False)\n-            body = self._build_update_message_body(msg_type=\"interactive\", content=payload)\n-            request = self._build_update_message_request(message_id=message_id, request_body=body)\n-            await asyncio.to_thread(self._client.im.v1.message.update, request)\n-        except Exception as exc:\n-            logger.warning(\"[Feishu] Failed to update approval card %s: %s\", message_id, exc)\n \n     async def send_voice(\n         self,","@@ -1845,20 +1853,82 @@ def _on_reaction_event(self, event_type: str, data: Any) -> None:\n         future.add_done_callback(self._log_background_failure)\n \n     def _on_card_action_trigger(self, data: Any) -> Any:\n-        \"\"\"Schedule Feishu card actions on the adapter loop and acknowledge immediately.\"\"\"\n+        \"\"\"Handle card-action callback from the Feishu SDK (synchronous).\n+\n+        For approval actions: parses the event once, returns the resolved card\n+        inline (the only reliable way to sync all clients), and schedules a\n+        lightweight async method to actually unblock the agent.\n+\n+        For other card actions: delegates to ``_handle_card_action_event``.\n+        \"\"\"\n         loop = self._loop\n-        if loop is None or bool(getattr(loop, \"is_closed\", lambda: False)()):\n+        if not self._loop_accepts_callbacks(loop):\n             logger.warning(\"[Feishu] Dropping card action before adapter loop is ready\")\n-        else:\n-            future = asyncio.run_coroutine_threadsafe(\n-                self._handle_card_action_event(data),\n-                loop,\n-            )\n-            future.add_done_callback(self._log_background_failure)\n+            return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None\n+\n+        event = getattr(data, \"event\", None)\n+        action = getattr(event, \"action\", None)\n+        action_value = getattr(action, \"value\", {}) or {}\n+        hermes_action = action_value.get(\"hermes_action\") if isinstance(action_value, dict) else None\n+\n+        if hermes_action:\n+            return self._handle_approval_card_action(event=event, action_value=action_value, loop=loop)\n+\n+        self._submit_on_loop(loop, self._handle_card_action_event(data))\n         if P2CardActionTriggerResponse is None:\n             return None\n         return P2CardActionTriggerResponse()\n \n+    @staticmethod\n+    def _loop_accepts_callbacks(loop: Any) -> bool:\n+        \"\"\"Return True when the adapter loop can accept thread-safe submissions.\"\"\"\n+        return loop is not None and not bool(getattr(loop, \"is_closed\", lambda: False)())\n+\n+    def _submit_on_loop(self, loop: Any, coro: Any) -> None:\n+        \"\"\"Schedule background work on the adapter loop with shared failure logging.\"\"\"\n+        future = asyncio.run_coroutine_threadsafe(coro, loop)\n+        future.add_done_callback(self._log_background_failure)\n+\n+    def _handle_approval_card_action(self, *, event: Any, action_value: Dict[str, Any], loop: Any) -> Any:\n+        \"\"\"Schedule approval resolution and build the synchronous callback response.\"\"\"\n+        approval_id = action_value.get(\"approval_id\")\n+        if approval_id is None:\n+            logger.debug(\"[Feishu] Card action missing approval_id, ignoring\")\n+            return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None\n+        choice = _APPROVAL_CHOICE_MAP.get(action_value.get(\"hermes_action\"), \"deny\")\n+\n+        operator = getattr(event, \"operator\", None)\n+        open_id = str(getattr(operator, \"open_id\", \"\") or \"\")\n+        user_name = self._get_cached_sender_name(open_id) or open_id\n+\n+        self._submit_on_loop(loop, self._resolve_approval(approval_id, choice, user_name))\n+\n+        if P2CardActionTriggerResponse is None:\n+            return None\n+        response = P2CardActionTriggerResponse()\n+        if CallBackCard is not None:\n+            card = CallBackCard()\n+            card.type = \"raw\"\n+            card.data = self._build_resolved_approval_card(choice=choice, user_name=user_name)\n+            response.card = card\n+        return response\n+\n+    async def _resolve_approval(self, approval_id: Any, choice: str, user_name: str) -> None:\n+        \"\"\"Pop approval state and unblock the waiting agent thread.\"\"\"\n+        state = self._approval_state.pop(approval_id, None)\n+        if not state:\n+            logger.debug(\"[Feishu] Approval %s already resolved or unknown\", approval_id)\n+            return\n+        try:\n+            from tools.approval import resolve_gateway_approval\n+            count = resolve_gateway_approval(state[\"session_key\"], choice)\n+            logger.info(\n+                \"Feishu button resolved %d approval(s) for session %s (choice=%s, user=%s)\",\n+                count, state[\"session_key\"], choice, user_name,\n+            )\n+        except Exception as exc:\n+            logger.error(\"Failed to resolve gateway approval from Feishu button: %s\", exc)\n+\n     async def _handle_reaction_event(self, event_type: str, data: Any) -> None:\n         \"\"\"Fetch the reacted-to message; if it was sent by this bot, emit a synthetic text event.\"\"\"\n         if not self._client:","@@ -1950,51 +2020,6 @@ async def _handle_card_action_event(self, data: Any) -> None:\n         action_tag = str(getattr(action, \"tag\", \"\") or \"button\")\n         action_value = getattr(action, \"value\", {}) or {}\n \n-        # --- Exec approval button intercept ---\n-        hermes_action = action_value.get(\"hermes_action\") if isinstance(action_value, dict) else None\n-        if hermes_action:\n-            approval_id = action_value.get(\"approval_id\")\n-            state = self._approval_state.pop(approval_id, None)\n-            if not state:\n-                logger.debug(\"[Feishu] Approval %s already resolved or unknown\", approval_id)\n-                return\n-\n-            choice_map = {\n-                \"approve_once\": \"once\",\n-                \"approve_session\": \"session\",\n-                \"approve_always\": \"always\",\n-                \"deny\": \"deny\",\n-            }\n-            choice = choice_map.get(hermes_action, \"deny\")\n-\n-            label_map = {\n-                \"once\": \"Approved once\",\n-                \"session\": \"Approved for session\",\n-                \"always\": \"Approved permanently\",\n-                \"deny\": \"Denied\",\n-            }\n-            label = label_map.get(choice, \"Resolved\")\n-\n-            # Resolve sender name for the status card\n-            sender_id = SimpleNamespace(open_id=open_id, user_id=None, union_id=None)\n-            sender_profile = await self._resolve_sender_profile(sender_id)\n-            user_name = sender_profile.get(\"user_name\") or open_id\n-\n-            # Resolve the approval — unblocks the agent thread\n-            try:\n-                from tools.approval import resolve_gateway_approval\n-                count = resolve_gateway_approval(state[\"session_key\"], choice)\n-                logger.info(\n-                    \"Feishu button resolved %d approval(s) for session %s (choice=%s, user=%s)\",\n-                    count, state[\"session_key\"], choice, user_name,\n-                )\n-            except Exception as exc:\n-                logger.error(\"Failed to resolve gateway approval from Feishu button: %s\", exc)\n-\n-            # Update the card to show the decision\n-            await self._update_approval_card(state.get(\"message_id\", \"\"), label, user_name, choice)\n-            return\n-\n         synthetic_text = f\"/card {action_tag}\"\n         if action_value:\n             try:","@@ -2897,6 +2922,19 @@ async def _resolve_sender_profile(self, sender_id: Any) -> Dict[str, Optional[st\n             \"user_id_alt\": union_id,\n         }\n \n+    def _get_cached_sender_name(self, sender_id: Optional[str]) -> Optional[str]:\n+        \"\"\"Return a cached sender name only while its TTL is still valid.\"\"\"\n+        if not sender_id:\n+            return None\n+        cached = self._sender_name_cache.get(sender_id)\n+        if cached is None:\n+            return None\n+        name, expire_at = cached\n+        if time.time() < expire_at:\n+            return name\n+        self._sender_name_cache.pop(sender_id, None)\n+        return None\n+\n     async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optional[str]:\n         \"\"\"Fetch the sender's display name from the Feishu contact API with a 10-minute cache.\n ","@@ -2909,11 +2947,9 @@ async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optio\n         if not trimmed:\n             return None\n         now = time.time()\n-        cached = self._sender_name_cache.get(trimmed)\n-        if cached is not None:\n-            name, expire_at = cached\n-            if now < expire_at:\n-                return name\n+        cached_name = self._get_cached_sender_name(trimmed)\n+        if cached_name is not None:\n+            return cached_name\n         try:\n             from lark_oapi.api.contact.v3 import GetUserRequest  # lazy import\n             if trimmed.startswith(\"ou_\"):"]},{"file":"scripts/release.py","hunks":["@@ -62,6 +62,7 @@\n     \"[redacted:email]\": \"voidborne-d\",\n     \"[redacted:email]\": \"insecurejezza\",\n     \"[redacted:email]\": \"Bartok9\",\n+    \"[redacted:email]\": \"Roy-oss1\",\n     # contributors (manual mapping from git names)\n     \"[redacted:email]\": \"dmahan93\",\n     \"[redacted:email]\": \"samherring99\","]},{"file":"tests/gateway/test_feishu_approval_buttons.py","hunks":["@@ -1,12 +1,11 @@\n \"\"\"Tests for Feishu interactive card approval buttons.\"\"\"\n \n-import asyncio\n+import importlib.util\n import json\n-import os\n import sys\n from pathlib import Path\n from types import SimpleNamespace\n-from unittest.mock import AsyncMock, MagicMock, Mock, patch\n+from unittest.mock import AsyncMock, MagicMock, patch\n \n import pytest\n ","@@ -23,14 +22,14 @@\n # ---------------------------------------------------------------------------\n def _ensure_feishu_mocks():\n     \"\"\"Provide stubs for lark-oapi / aiohttp.web so the import succeeds.\"\"\"\n-    if \"lark_oapi\" not in sys.modules:\n+    if importlib.util.find_spec(\"lark_oapi\") is None and \"lark_oapi\" not in sys.modules:\n         mod = MagicMock()\n         for name in (\n             \"lark_oapi\", \"lark_oapi.api.im.v1\",\n             \"lark_oapi.event\", \"lark_oapi.event.callback_type\",\n         ):\n             sys.modules.setdefault(name, mod)\n-    if \"aiohttp\" not in sys.modules:\n+    if importlib.util.find_spec(\"aiohttp\") is None and \"aiohttp\" not in sys.modules:\n         aio = MagicMock()\n         sys.modules.setdefault(\"aiohttp\", aio)\n         sys.modules.setdefault(\"aiohttp.web\", aio.web)","@@ -39,6 +38,7 @@ def _ensure_feishu_mocks():\n _ensure_feishu_mocks()\n \n from gateway.config import PlatformConfig\n+import gateway.platforms.feishu as feishu_module\n from gateway.platforms.feishu import FeishuAdapter\n \n ","@@ -74,6 +74,12 @@ def _make_card_action_data(\n     )\n \n \n+def _close_submitted_coro(coro, _loop):\n+    \"\"\"Close scheduled coroutines in sync-handler tests to avoid unawaited warnings.\"\"\"\n+    coro.close()\n+    return SimpleNamespace(add_done_callback=lambda *_args, **_kwargs: None)\n+\n+\n # ===========================================================================\n # send_exec_approval — interactive card with buttons\n # ===========================================================================","@@ -203,14 +209,14 @@ async def test_multiple_approvals_get_unique_ids(self):\n \n \n # ===========================================================================\n-# _handle_card_action_event — approval button clicks\n+# _resolve_approval — approval state pop + gateway resolution\n # ===========================================================================\n \n-class TestFeishuApprovalCallback:\n-    \"\"\"Test the approval intercept in _handle_card_action_event.\"\"\"\n+class TestResolveApproval:\n+    \"\"\"Test _resolve_approval pops state and calls resolve_gateway_approval.\"\"\"\n \n     @pytest.mark.asyncio\n-    async def test_resolves_approval_on_click(self):\n+    async def test_resolves_once(self):\n         adapter = _make_adapter()\n         adapter._approval_state[1] = {\n             \"session_key\": \"agent:main:feishu:group:oc_12345\",","@@ -218,28 +224,14 @@ async def test_resolves_approval_on_click(self):\n             \"chat_id\": \"oc_12345\",\n         }\n \n-        data = _make_card_action_data(\n-            action_value={\"hermes_action\": \"approve_once\", \"approval_id\": 1},\n-        )\n-\n-        with (\n-            patch.object(\n-                adapter, \"_resolve_sender_profile\", new_callable=AsyncMock,\n-                return_value={\"user_id\": \"ou_user1\", \"user_name\": \"Norbert\", \"user_id_alt\": None},\n-            ),\n-            patch.object(adapter, \"_update_approval_card\", new_callable=AsyncMock) as mock_update,\n-            patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve,\n-        ):\n-            await adapter._handle_card_action_event(data)\n+        with patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve:\n+            await adapter._resolve_approval(1, \"once\", \"Norbert\")\n \n         mock_resolve.assert_called_once_with(\"agent:main:feishu:group:oc_12345\", \"once\")\n-        mock_update.assert_called_once_with(\"msg_001\", \"Approved once\", \"Norbert\", \"once\")\n-\n-        # State should be cleaned up\n         assert 1 not in adapter._approval_state\n \n     @pytest.mark.asyncio\n-    async def test_deny_button(self):\n+    async def test_resolves_deny(self):\n         adapter = _make_adapter()\n         adapter._approval_state[2] = {\n             \"session_key\": \"some-session\",","@@ -247,26 +239,13 @@ async def test_deny_button(self):\n             \"chat_id\": \"oc_12345\",\n         }\n \n-        data = _make_card_action_data(\n-            action_value={\"hermes_action\": \"deny\", \"approval_id\": 2},\n-            token=\"[redacted:secret-assignment]\",\n-        )\n-\n-        with (\n-            patch.object(\n-                adapter, \"_resolve_sender_profile\", new_callable=AsyncMock,\n-                return_value={\"user_id\": \"ou_alice\", \"user_name\": \"Alice\", \"user_id_alt\": None},\n-            ),\n-            patch.object(adapter, \"_update_approval_card\", new_callable=AsyncMock) as mock_update,\n-            patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve,\n-        ):\n-            await adapter._handle_card_action_event(data)\n+        with patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve:\n+            await adapter._resolve_approval(2, \"deny\", \"Alice\")\n \n         mock_resolve.assert_called_once_with(\"some-session\", \"deny\")\n-        mock_update.assert_called_once_with(\"msg_002\", \"Denied\", \"Alice\", \"deny\")\n \n     @pytest.mark.asyncio\n-    async def test_session_approval(self):\n+    async def test_resolves_session(self):\n         adapter = _make_adapter()\n         adapter._approval_state[3] = {\n             \"session_key\": \"sess-3\",","@@ -274,26 +253,13 @@ async def test_session_approval(self):\n             \"chat_id\": \"oc_99\",\n         }\n \n-        data = _make_card_action_data(\n-            action_value={\"hermes_action\": \"approve_session\", \"approval_id\": 3},\n-            token=\"[redacted:secret-assignment]\",\n-        )\n-\n-        with (\n-            patch.object(\n-                adapter, \"_resolve_sender_profile\", new_callable=AsyncMock,\n-                return_value={\"user_id\": \"ou_u\", \"user_name\": \"Bob\", \"user_id_alt\": None},\n-            ),\n-            patch.object(adapter, \"_update_approval_card\", new_callable=AsyncMock) as mock_update,\n-            patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve,\n-        ):\n-            await adapter._handle_card_action_event(data)\n+        with patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve:\n+            await adapter._resolve_approval(3, \"session\", \"Bob\")\n \n         mock_resolve.assert_called_once_with(\"sess-3\", \"session\")\n-        mock_update.assert_called_once_with(\"msg_003\", \"Approved for session\", \"Bob\", \"session\")\n \n     @pytest.mark.asyncio\n-    async def test_always_approval(self):\n+    async def test_resolves_always(self):\n         adapter = _make_adapter()\n         adapter._approval_state[4] = {\n             \"session_key\": \"sess-4\",","@@ -301,42 +267,29 @@ async def test_always_approval(self):\n             \"chat_id\": \"oc_55\",\n         }\n \n-        data = _make_card_action_data(\n-            action_value={\"hermes_action\": \"approve_always\", \"approval_id\": 4},\n-            token=\"[redacted:secret-assignment]\",\n-        )\n-\n-        with (\n-            patch.object(\n-                adapter, \"_resolve_sender_profile\", new_callable=AsyncMock,\n-                return_value={\"user_id\": \"ou_u\", \"user_name\": \"Carol\", \"user_id_alt\": None},\n-            ),\n-            patch.object(adapter, \"_update_approval_card\", new_callable=AsyncMock),\n-            patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve,\n-        ):\n-            await adapter._handle_card_action_event(data)\n+        with patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve:\n+            await adapter._resolve_approval(4, \"always\", \"Carol\")\n \n         mock_resolve.assert_called_once_with(\"sess-4\", \"always\")\n \n     @pytest.mark.asyncio\n     async def test_already_resolved_drops_silently(self):\n         adapter = _make_adapter()\n-        # No state for approval_id 99 — already resolved\n-\n-        data = _make_card_action_data(\n-            action_value={\"hermes_action\": \"approve_once\", \"approval_id\": 99},\n-            token=\"[redacted:secret-assignment]\",\n-        )\n \n         with patch(\"tools.approval.resolve_gateway_approval\") as mock_resolve:\n-            await adapter._handle_card_action_event(data)\n+            await adapter._resolve_approval(99, \"once\", \"Nobody\")\n \n-        # Should NOT resolve — already handled\n         mock_resolve.assert_not_called()\n \n+# ===========================================================================\n+# _handle_card_action_event — non-approval card actions\n+# ===========================================================================\n+\n+class TestNonApprovalCardAction:\n+    \"\"\"Non-approval card actions should still route as synthetic commands.\"\"\"\n+\n     @pytest.mark.asyncio\n-    async def test_non_approval_actions_route_normally(self):\n-        \"\"\"Non-approval card actions should still become synthetic commands.\"\"\"\n+    async def test_routes_as_synthetic_command(self):\n         adapter = _make_adapter()\n \n         data = _make_card_action_data(","@@ -351,82 +304,141 @@ async def test_non_approval_actions_route_normally(self):\n             ),\n             patch.object(adapter, \"get_chat_info\", new_callable=AsyncMock, return_value={\"name\": \"Test Chat\"}),\n             patch.object(adapter, \"_handle_message_with_guards\", new_callable=AsyncMock) as mock_handle,\n-            patch(\"tools.approval.resolve_gateway_approval\") as mock_resolve,\n         ):\n             await adapter._handle_card_action_event(data)\n \n-        # Should NOT resolve any approval\n-        mock_resolve.assert_not_called()\n-        # Should have routed as synthetic command\n         mock_handle.assert_called_once()\n         event = mock_handle.call_args[0][0]\n         assert \"/card button\" in event.text\n \n \n # ===========================================================================\n-# _update_approval_card — card replacement after resolution\n+# _on_card_action_trigger — inline card response for approval actions\n # ===========================================================================\n \n-class TestFeishuUpdateApprovalCard:\n-    \"\"\"Test the card update after approval resolution.\"\"\"\n+class _FakeCallBackCard:\n+    def __init__(self):\n+        self.type = None\n+        self.data = None\n \n-    @pytest.mark.asyncio\n-    async def test_updates_card_on_approve(self):\n+\n+class _FakeP2Response:\n+    def __init__(self):\n+        self.card = None\n+\n+\n[redacted:email](autouse=False)\n+def _patch_callback_card_types(monkeypatch):\n+    \"\"\"Provide real-ish P2CardActionTriggerResponse / CallBackCard for tests.\"\"\"\n+    monkeypatch.setattr(feishu_module, \"P2CardActionTriggerResponse\", _FakeP2Response)\n+    monkeypatch.setattr(feishu_module, \"CallBackCard\", _FakeCallBackCard)\n+\n+\n+class TestCardActionCallbackResponse:\n+    \"\"\"Test that _on_card_action_trigger returns updated card inline.\"\"\"\n+\n+    def test_drops_action_when_loop_not_ready(self, _patch_callback_card_types):\n         adapter = _make_adapter()\n+        adapter._loop = None\n+        data = _make_card_action_data({\"hermes_action\": \"approve_once\", \"approval_id\": 1})\n \n-        mock_update = AsyncMock()\n-        adapter._client.im.v1.message.update = MagicMock()\n+        with patch(\"asyncio.run_coroutine_threadsafe\") as mock_submit:\n+            response = adapter._on_card_action_trigger(data)\n \n-        with patch(\"asyncio.to_thread\", new_callable=AsyncMock) as mock_thread:\n-            await adapter._update_approval_card(\n-                \"msg_001\", \"Approved once\", \"Norbert\", \"once\"\n-            )\n+        assert response is not None\n+        assert response.card is None\n+        mock_submit.assert_not_called()\n \n-        mock_thread.assert_called_once()\n-        # Verify the update request was built\n-        call_args = mock_thread.call_args\n-        assert call_args[0][0] == adapter._client.im.v1.message.update\n+    def test_returns_card_for_approve_action(self, _patch_callback_card_types):\n+        adapter = _make_adapter()\n+        adapter._loop = MagicMock()\n+        adapter._loop.is_closed = MagicMock(return_value=False)\n+        data = _make_card_action_data(\n+            {\"hermes_action\": \"approve_once\", \"approval_id\": 1},\n+            open_id=\"ou_bob\",\n+        )\n+        adapter._sender_name_cache[\"ou_bob\"] = (\"Bob\", 9999999999)\n \n-    @pytest.mark.asyncio\n-    async def test_updates_card_on_deny(self):\n+        with patch(\"asyncio.run_coroutine_threadsafe\", side_effect=_close_submitted_coro):\n+            response = adapter._on_card_action_trigger(data)\n+\n+        assert response is not None\n+        assert response.card is not None\n+        assert response.card.type == \"raw\"\n+        card = response.card.data\n+        assert card[\"header\"][\"template\"] == \"green\"\n+        assert \"Approved once\" in card[\"header\"][\"title\"][\"content\"]\n+        assert \"Bob\" in card[\"elements\"][0][\"content\"]\n+\n+    def test_returns_card_for_deny_action(self, _patch_callback_card_types):\n         adapter = _make_adapter()\n+        adapter._loop = MagicMock()\n+        adapter._loop.is_closed = MagicMock(return_value=False)\n+        data = _make_card_action_data(\n+            {\"hermes_action\": \"deny\", \"approval_id\": 2},\n+        )\n \n-        with patch(\"asyncio.to_thread\", new_callable=AsyncMock) as mock_thread:\n-            await adapter._update_approval_card(\n-                \"msg_002\", \"Denied\", \"Alice\", \"deny\"\n-            )\n+        with patch(\"asyncio.run_coroutine_threadsafe\", side_effect=_close_submitted_coro):\n+            response = adapter._on_card_action_trigger(data)\n \n-        mock_thread.assert_called_once()\n+        assert response.card is not None\n+        card = response.card.data\n+        assert card[\"header\"][\"template\"] == \"red\"\n+        assert \"Denied\" in card[\"header\"][\"title\"][\"content\"]\n \n-    @pytest.mark.asyncio\n-    async def test_skips_update_when_not_connected(self):\n+    def test_ignores_missing_approval_id(self, _patch_callback_card_types):\n         adapter = _make_adapter()\n-        adapter._client = None\n+        adapter._loop = MagicMock()\n+        adapter._loop.is_closed = MagicMock(return_value=False)\n+        data = _make_card_action_data({\"hermes_action\": \"approve_once\"})\n \n-        with patch(\"asyncio.to_thread\", new_callable=AsyncMock) as mock_thread:\n-            await adapter._update_approval_card(\n-                \"msg_001\", \"Approved\", \"Bob\", \"once\"\n-            )\n+        with patch(\"asyncio.run_coroutine_threadsafe\") as mock_submit:\n+            response = adapter._on_card_action_trigger(data)\n \n-        mock_thread.assert_not_called()\n+        assert response is not None\n+        assert response.card is None\n+        mock_submit.assert_not_called()\n \n-    @pytest.mark.asyncio\n-    async def test_skips_update_when_no_message_id(self):\n+    def test_no_card_for_non_approval_action(self, _patch_callback_card_types):\n         adapter = _make_adapter()\n+        adapter._loop = MagicMock()\n+        adapter._loop.is_closed = MagicMock(return_value=False)\n+        data = _make_card_action_data({\"some_other\": \"value\"})\n \n-        with patch(\"asyncio.to_thread\", new_callable=AsyncMock) as mock_thread:\n-            await adapter._update_approval_card(\n-                \"\", \"Approved\", \"Bob\", \"once\"\n-            )\n+        with patch(\"asyncio.run_coroutine_threadsafe\", side_effect=_close_submitted_coro):\n+            response = adapter._on_card_action_trigger(data)\n \n-        mock_thread.assert_not_called()\n+        assert response is not None\n+        assert response.card is None\n \n-    @pytest.mark.asyncio\n-    async def test_swallows_update_errors(self):\n+    def test_falls_back_to_open_id_when_name_not_cached(self, _patch_callback_card_types):\n         adapter = _make_adapter()\n+        adapter._loop = MagicMock()\n+        adapter._loop.is_closed = MagicMock(return_value=False)\n+        data = _make_card_action_data(\n+            {\"hermes_action\": \"approve_session\", \"approval_id\": 3},\n+            open_id=\"ou_unknown\",\n+        )\n \n-        with patch(\"asyncio.to_thread\", new_callable=AsyncMock, side_effect=Exception(\"API error\")):\n-            # Should not raise\n-            await adapter._update_approval_card(\n-                \"msg_001\", \"Approved\", \"Bob\", \"once\"\n-            )\n+        with patch(\"asyncio.run_coroutine_threadsafe\", side_effect=_close_submitted_coro):\n+            response = adapter._on_card_action_trigger(data)\n+\n+        card = response.card.data\n+        assert \"ou_unknown\" in card[\"elements\"][0][\"content\"]\n+\n+    def test_ignores_expired_cached_name(self, _patch_callback_card_types):\n+        adapter = _make_adapter()\n+        adapter._loop = MagicMock()\n+        adapter._loop.is_closed = MagicMock(return_value=False)\n+        data = _make_card_action_data(\n+            {\"hermes_action\": \"approve_once\", \"approval_id\": 4},\n+            open_id=\"ou_expired\",\n+        )\n+        adapter._sender_name_cache[\"ou_expired\"] = (\"Old Name\", 1)\n+\n+        with patch(\"asyncio.run_coroutine_threadsafe\", side_effect=_close_submitted_coro):\n+            response = adapter._on_card_action_trigger(data)\n+\n+        card = response.card.data\n+        assert \"Old Name\" not in card[\"elements\"][0][\"content\"]\n+        assert \"ou_expired\" in card[\"elements\"][0][\"content\"]\n"]}]},{"kind":"pr","number":10095,"title":"fix(feishu): handle CARD-type WebSocket frames for interactive card actions","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":10161,"title":"fix(feishu): use PATCH API to update approval cards (error 200340)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":10256,"title":"fix(feishu): serialize approval callback card data to JSON string","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"6c1db05d4a","number":0},{"kind":"pr","value":"#109174","number":109174}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":10801,"title":"fix(feishu): WebSocket watchdog + approval card PATCH + markdown table rendering","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#10251","number":10251},{"kind":"pr","value":"#10251","number":10251},{"kind":"pr","value":"#77364","number":77364}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":11740,"title":"fix(feishu): drop non-approval card callbacks instead of replying \"Unknown command /card\" (#11600)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":13929,"title":"fix(feishu): proactively update approval card via API to avoid 220340 callback failure","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"5600105478ffde29d7566b45421b100eaa29c4ef","number":0},{"kind":"commit","value":"b296915c82c9da02bd6edacf52490e68f85e1f16","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":23645,"title":"fix(feishu): patch approval card updates","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1aa76620d464f5ec105e01a67d0e336b5612feaf","number":0},{"kind":"pr","value":"#9533","number":9533}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":26122,"title":"fix(feishu): monkey-patch WS client to handle CARD-type messages (error 200343)","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":34707,"title":"fix(feishu): approval card clicks rejected when FEISHU_ALLOWED_USERS not set","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#39048","number":39048}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":35042,"title":"fix(feishu): use consistent auth check for approval card actions","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#34707","number":34707},{"kind":"pr","value":"#35032","number":35032},{"kind":"pr","value":"#20596","number":20596},{"kind":"pr","value":"#34707","number":34707}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":42465,"title":"fix(feishu): serialize card.data to JSON string for approval/update-prompt responses","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"6c1db05d4a","number":0},{"kind":"pr","value":"#109174","number":109174}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":77364,"title":"fix(feishu): serialize card.data to JSON string in approval/update-prompt callback responses","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#77374","number":77374}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":77374,"title":"fix(feishu): serialize card.data to JSON string in approval/update-prompt callbacks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6c1db05d4a","number":0},{"kind":"pr","value":"#109174","number":109174}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":109174,"title":"docs(feishu): error 200340 on card buttons is a missing callback subscription, not a payload bug (#10251)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["website/docs/user-guide/messaging/feishu.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/feishu.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}