Feishu/Lark interactive approval card bugs and fixes
NousResearch/hermes-agent · cluster 6893 · gateway
Data as of
11 open items. Fix merged in #9871. Check it covers your case.
The short answer
- Known?
- Yes. 30 related items grouped here, 8 issues already closed.
- Fix event
- A fix was merged to main in PR #9871. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #9871. That is separate from the fix event. - Still open
- 5 issues and 6 PRs.
Next step: read #9871, then check whether your report is one of the 5 still open.
Read #9871Pick up a Lab taskThis 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.
Open issues5
- Issue#10073[Bug]: [Feishu Mobile] Command approval card returns error code:200340 on any option click despite card.action.trigger being configured
- Issue#13924Feishu: Command Approval buttons return "出错了,请稍后再试 code: 220340" when clicked
- Issue#20596Feishu approval card appears but click interaction fails, blocking exec approval flow
- Issue#25886[Bug]: Feishu/Lark card authorization buttons fail with error 200343
- Issue#38305Feishu error 200340 persists in v0.15.2 - PR #10256 needs merge
Open PRs6
- PR#10256fix(feishu): serialize approval callback card data to JSON string
- PR#10801fix(feishu): WebSocket watchdog + approval card PATCH + markdown table rendering
- PR#13929fix(feishu): proactively update approval card via API to avoid 220340 callback failure
- PR#26122fix(feishu): monkey-patch WS client to handle CARD-type messages (error 200343)
- PR#35042fix(feishu): use consistent auth check for approval card actions
- PR#42465fix(feishu): serialize card.data to JSON string for approval/update-prompt responses
Fixes on record
-
#9871 fix(feishu): resolve approval cards inline from callback path
files:
gateway/platforms/feishu.py,scripts/release.py,tests/gateway/test_feishu_approval_buttons.pyShow the diff
-
gateway/platforms/feishu.py@@ -72,7 +72,10 @@ UpdateMessageRequestBody, ) from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN - from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse + from lark_oapi.event.callback.model.p2_card_action_trigger import ( + CallBackCard, + P2CardActionTriggerResponse, + ) from lark_oapi.event.dispatcher_handler import EventDispatcherHandler from lark_oapi.ws import Client as FeishuWSClient @@ -80,6 +83,7 @@ except ImportError: FEISHU_AVAILABLE = False lark = None # type: ignore[assignment] + CallBackCard = None # type: ignore[assignment] P2CardActionTriggerResponse = None # type: ignore[assignment] EventDispatcherHandler = None # type: ignore[assignment] FeishuWSClient = None # type: ignore[assignment] @@ -169,6 +173,19 @@ _FEISHU_WEBHOOK_ANOMALY_THRESHOLD = 25 # consecutive error responses before WARNING log _FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60 # anomaly tracker TTL (6 hours) — matches openclaw _FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS = 15 * 60 # card action token dedup window (15 min) + +_APPROVAL_CHOICE_MAP: Dict[str, str] = { + "approve_once": "once", + "approve_session": "session", + "approve_always": "always", + "deny": "deny", +} +_APPROVAL_LABEL_MAP: Dict[str, str] = { + "once": "Approved once", + "session": "Approved for session", + "always": "Approved permanently", + "deny": "Denied", +} _FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs _FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback _FEISHU_ACK_EMOJI = "OK" @@ -1490,14 +1507,12 @@ def _btn(label: str, action_name: str, btn_type: str = "default") -> dict: logger.warning("[Feishu] send_exec_approval failed: %s", exc) return SendResult(success=False, error=str(exc)) - async def _update_approval_card( - self, message_id: str, label: str, user_name: str, choice: str, - ) -> None: - """Replace the approval card with a resolved status card.""" - if not self._client or not message_id: - return + @staticmethod + def _build_resolved_approval_card(*, choice: str, user_name: str) -> Dict[str, Any]: + """Build raw card JSON for a resolved approval action.""" icon = "❌" if choice == "deny" else "✅" - card = { + label = _APPROVAL_LABEL_MAP.get(choice, "Resolved") + return { "config": {"wide_screen_mode": True}, "header": { "title": {"content": f"{icon} {label}", "tag": "plain_text"}, @@ -1510,13 +1525,6 @@ async def _update_approval_card( }, ], } - try: - payload = json.dumps(card, ensure_ascii=False) - body = self._build_update_message_body(msg_type="interactive", content=payload) - request = self._build_update_message_request(message_id=message_id, request_body=body) - await asyncio.to_thread(self._client.im.v1.message.update, request) - except Exception as exc: - logger.warning("[Feishu] Failed to update approval card %s: %s", message_id, exc) async def send_voice( self, @@ -1845,20 +1853,82 @@ def _on_reaction_event(self, event_type: str, data: Any) -> None: future.add_done_callback(self._log_background_failure) def _on_card_action_trigger(self, data: Any) -> Any: - """Schedule Feishu card actions on the adapter loop and acknowledge immediately.""" + """Handle card-action callback from the Feishu SDK (synchronous). + + For approval actions: parses the event once, returns the resolved card + inline (the only reliable way to sync all clients), and schedules a + lightweight async method to actually unblock the agent. + + For other card actions: delegates to ``_handle_card_action_event``. + """ loop = self._loop - if loop is None or bool(getattr(loop, "is_closed", lambda: False)()): + if not self._loop_accepts_callbacks(loop): logger.warning("[Feishu] Dropping card action before adapter loop is ready") - else: - future = asyncio.run_coroutine_threadsafe( - self._handle_card_action_event(data), - loop, - ) - future.add_done_callback(self._log_background_failure) + return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None + + event = getattr(data, "event", None) + 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 + + if hermes_action: + return self._handle_approval_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: return None return P2CardActionTriggerResponse() + @staticmethod + def _loop_accepts_callbacks(loop: Any) -> bool: + """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: + """Schedule background work on the adapter loop with shared failure logging.""" + future = asyncio.run_coroutine_threadsafe(coro, loop) + future.add_done_callback(self._log_background_failure) + + 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.""" + approval_id = action_value.get("approval_id") + if approval_id is None: + logger.debug("[Feishu] Card action missing approval_id, ignoring") + return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None + choice = _APPROVAL_CHOICE_MAP.get(action_value.get("hermes_action"), "deny") + + operator = getattr(event, "operator", None) + 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 P2CardActionTriggerResponse is None: + return None + response = P2CardActionTriggerResponse() + if CallBackCard is not None: + card = CallBackCard() + card.type = "raw" + card.data = self._build_resolved_approval_card(choice=choice, 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) + if not state: + logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id) + return + try: + from tools.approval import resolve_gateway_approval + count = resolve_gateway_approval(state["session_key"], choice) + logger.info( + "Feishu button resolved %d approval(s) for session %s (choice=%s, user=%s)", + count, state["session_key"], choice, user_name, + ) + except Exception as exc: + logger.error("Failed to resolve gateway approval from Feishu button: %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: @@ -1950,51 +2020,6 @@ async def _handle_card_action_event(self, data: Any) -> None: action_tag = str(getattr(action, "tag", "") or "button") action_value = getattr(action, "value", {}) or {} - # --- Exec approval button intercept --- - hermes_action = action_value.get("hermes_action") if isinstance(action_value, dict) else None - if hermes_action: - approval_id = action_value.get("approval_id") - state = self._approval_state.pop(approval_id, None) - if not state: - logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id) - return - - choice_map = { - "approve_once": "once", - "approve_session": "session", - "approve_always": "always", - "deny": "deny", - } - choice = choice_map.get(hermes_action, "deny") - - label_map = { - "once": "Approved once", - "session": "Approved for session", - "always": "Approved permanently", - "deny": "Denied", - } - label = label_map.get(choice, "Resolved") - - # Resolve sender name for the status card - sender_id = SimpleNamespace(open_id=open_id, user_id=None, union_id=None) - sender_profile = await self._resolve_sender_profile(sender_id) - user_name = sender_profile.get("user_name") or open_id - - # Resolve the approval — unblocks the agent thread - try: - from tools.approval import resolve_gateway_approval - count = resolve_gateway_approval(state["session_key"], choice) - logger.info( - "Feishu button resolved %d approval(s) for session %s (choice=%s, user=%s)", - count, state["session_key"], choice, user_name, - ) - except Exception as exc: - logger.error("Failed to resolve gateway approval from Feishu button: %s", exc) - - # Update the card to show the decision - await self._update_approval_card(state.get("message_id", ""), label, user_name, choice) - return - synthetic_text = f"/card {action_tag}" if action_value: try: @@ -2897,6 +2922,19 @@ async def _resolve_sender_profile(self, sender_id: Any) -> Dict[str, Optional[st "user_id_alt": union_id, } + def _get_cached_sender_name(self, sender_id: Optional[str]) -> Optional[str]: + """Return a cached sender name only while its TTL is still valid.""" + if not sender_id: + return None + cached = self._sender_name_cache.get(sender_id) + if cached is None: + return None + name, expire_at = cached + if time.time() < expire_at: + return name + self._sender_name_cache.pop(sender_id, None) + return None + async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optional[str]: """Fetch the sender's display name from the Feishu contact API with a 10-minute cache. @@ -2909,11 +2947,9 @@ async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optio if not trimmed: return None now = time.time() - cached = self._sender_name_cache.get(trimmed) - if cached is not None: - name, expire_at = cached - if now < expire_at: - return name + cached_name = self._get_cached_sender_name(trimmed) + if cached_name is not None: + return cached_name try: from lark_oapi.api.contact.v3 import GetUserRequest # lazy import if trimmed.startswith("ou_"): -
scripts/release.py@@ -62,6 +62,7 @@ "[redacted:email]": "voidborne-d", "[redacted:email]": "insecurejezza", "[redacted:email]": "Bartok9", + "[redacted:email]": "Roy-oss1", # contributors (manual mapping from git names) "[redacted:email]": "dmahan93", "[redacted:email]": "samherring99", -
tests/gateway/test_feishu_approval_buttons.py@@ -1,12 +1,11 @@ """Tests for Feishu interactive card approval buttons.""" -import asyncio +import importlib.util import json -import os import sys from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -23,14 +22,14 @@ # --------------------------------------------------------------------------- def _ensure_feishu_mocks(): """Provide stubs for lark-oapi / aiohttp.web so the import succeeds.""" - if "lark_oapi" not in sys.modules: + if importlib.util.find_spec("lark_oapi") is None and "lark_oapi" not in sys.modules: mod = MagicMock() for name in ( "lark_oapi", "lark_oapi.api.im.v1", "lark_oapi.event", "lark_oapi.event.callback_type", ): sys.modules.setdefault(name, mod) - if "aiohttp" not in sys.modules: + if importlib.util.find_spec("aiohttp") is None and "aiohttp" not in sys.modules: aio = MagicMock() sys.modules.setdefault("aiohttp", aio) sys.modules.setdefault("aiohttp.web", aio.web) @@ -39,6 +38,7 @@ def _ensure_feishu_mocks(): _ensure_feishu_mocks() from gateway.config import PlatformConfig +import gateway.platforms.feishu as feishu_module from gateway.platforms.feishu import FeishuAdapter @@ -74,6 +74,12 @@ def _make_card_action_data( ) +def _close_submitted_coro(coro, _loop): + """Close scheduled coroutines in sync-handler tests to avoid unawaited warnings.""" + coro.close() + return SimpleNamespace(add_done_callback=lambda *_args, **_kwargs: None) + + # =========================================================================== # send_exec_approval — interactive card with buttons # =========================================================================== @@ -203,14 +209,14 @@ async def test_multiple_approvals_get_unique_ids(self): # =========================================================================== -# _handle_card_action_event — approval button clicks +# _resolve_approval — approval state pop + gateway resolution # =========================================================================== -class TestFeishuApprovalCallback: - """Test the approval intercept in _handle_card_action_event.""" +class TestResolveApproval: + """Test _resolve_approval pops state and calls resolve_gateway_approval.""" @pytest.mark.asyncio - async def test_resolves_approval_on_click(self): + async def test_resolves_once(self): adapter = _make_adapter() adapter._approval_state[1] = { "session_key": "agent:main:feishu:group:oc_12345", @@ -218,28 +224,14 @@ async def test_resolves_approval_on_click(self): "chat_id": "oc_12345", } - data = _make_card_action_data( - action_value={"hermes_action": "approve_once", "approval_id": 1}, - ) - - with ( - patch.object( - adapter, "_resolve_sender_profile", new_callable=AsyncMock, - return_value={"user_id": "ou_user1", "user_name": "Norbert", "user_id_alt": None}, - ), - patch.object(adapter, "_update_approval_card", new_callable=AsyncMock) as mock_update, - patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve, - ): - await adapter._handle_card_action_event(data) + with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve: + await adapter._resolve_approval(1, "once", "Norbert") mock_resolve.assert_called_once_with("agent:main:feishu:group:oc_12345", "once") - mock_update.assert_called_once_with("msg_001", "Approved once", "Norbert", "once") - - # State should be cleaned up assert 1 not in adapter._approval_state @pytest.mark.asyncio - async def test_deny_button(self): + async def test_resolves_deny(self): adapter = _make_adapter() adapter._approval_state[2] = { "session_key": "some-session", @@ -247,26 +239,13 @@ async def test_deny_button(self): "chat_id": "oc_12345", } - data = _make_card_action_data( - action_value={"hermes_action": "deny", "approval_id": 2}, - token="[redacted:secret-assignment]", - ) - - with ( - patch.object( - adapter, "_resolve_sender_profile", new_callable=AsyncMock, - return_value={"user_id": "ou_alice", "user_name": "Alice", "user_id_alt": None}, - ), - patch.object(adapter, "_update_approval_card", new_callable=AsyncMock) as mock_update, - patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve, - ): - await adapter._handle_card_action_event(data) + with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve: + await adapter._resolve_approval(2, "deny", "Alice") mock_resolve.assert_called_once_with("some-session", "deny") - mock_update.assert_called_once_with("msg_002", "Denied", "Alice", "deny") @pytest.mark.asyncio - async def test_session_approval(self): + async def test_resolves_session(self): adapter = _make_adapter() adapter._approval_state[3] = { "session_key": "sess-3", @@ -274,26 +253,13 @@ async def test_session_approval(self): "chat_id": "oc_99", } - data = _make_card_action_data( - action_value={"hermes_action": "approve_session", "approval_id": 3}, - token="[redacted:secret-assignment]", - ) - - with ( - patch.object( - adapter, "_resolve_sender_profile", new_callable=AsyncMock, - return_value={"user_id": "ou_u", "user_name": "Bob", "user_id_alt": None}, - ), - patch.object(adapter, "_update_approval_card", new_callable=AsyncMock) as mock_update, - patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve, - ): - await adapter._handle_card_action_event(data) + with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve: + await adapter._resolve_approval(3, "session", "Bob") mock_resolve.assert_called_once_with("sess-3", "session") - mock_update.assert_called_once_with("msg_003", "Approved for session", "Bob", "session") @pytest.mark.asyncio - async def test_always_approval(self): + async def test_resolves_always(self): adapter = _make_adapter() adapter._approval_state[4] = { "session_key": "sess-4", @@ -301,42 +267,29 @@ async def test_always_approval(self): "chat_id": "oc_55", } - data = _make_card_action_data( - action_value={"hermes_action": "approve_always", "approval_id": 4}, - token="[redacted:secret-assignment]", - ) - - with ( - patch.object( - adapter, "_resolve_sender_profile", new_callable=AsyncMock, - return_value={"user_id": "ou_u", "user_name": "Carol", "user_id_alt": None}, - ), - patch.object(adapter, "_update_approval_card", new_callable=AsyncMock), - patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve, - ): - await adapter._handle_card_action_event(data) + with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve: + await adapter._resolve_approval(4, "always", "Carol") mock_resolve.assert_called_once_with("sess-4", "always") @pytest.mark.asyncio async def test_already_resolved_drops_silently(self): adapter = _make_adapter() - # No state for approval_id 99 — already resolved - - data = _make_card_action_data( - action_value={"hermes_action": "approve_once", "approval_id": 99}, - token="[redacted:secret-assignment]", - ) with patch("tools.approval.resolve_gateway_approval") as mock_resolve: - await adapter._handle_card_action_event(data) + await adapter._resolve_approval(99, "once", "Nobody") - # Should NOT resolve — already handled mock_resolve.assert_not_called() +# =========================================================================== +# _handle_card_action_event — non-approval card actions +# =========================================================================== + +class TestNonApprovalCardAction: + """Non-approval card actions should still route as synthetic commands.""" + @pytest.mark.asyncio - async def test_non_approval_actions_route_normally(self): - """Non-approval card actions should still become synthetic commands.""" + async def test_routes_as_synthetic_command(self): adapter = _make_adapter() data = _make_card_action_data( @@ -351,82 +304,141 @@ async def test_non_approval_actions_route_normally(self): ), patch.object(adapter, "get_chat_info", new_callable=AsyncMock, return_value={"name": "Test Chat"}), patch.object(adapter, "_handle_message_with_guards", new_callable=AsyncMock) as mock_handle, - patch("tools.approval.resolve_gateway_approval") as mock_resolve, ): await adapter._handle_card_action_event(data) - # Should NOT resolve any approval - mock_resolve.assert_not_called() - # Should have routed as synthetic command mock_handle.assert_called_once() event = mock_handle.call_args[0][0] assert "/card button" in event.text # =========================================================================== -# _update_approval_card — card replacement after resolution +# _on_card_action_trigger — inline card response for approval actions # =========================================================================== -class TestFeishuUpdateApprovalCard: - """Test the card update after approval resolution.""" +class _FakeCallBackCard: + def __init__(self): + self.type = None + self.data = None - @pytest.mark.asyncio - async def test_updates_card_on_approve(self): + +class _FakeP2Response: + def __init__(self): + self.card = None + + [redacted:email](autouse=False) +def _patch_callback_card_types(monkeypatch): + """Provide real-ish P2CardActionTriggerResponse / CallBackCard for tests.""" + monkeypatch.setattr(feishu_module, "P2CardActionTriggerResponse", _FakeP2Response) + monkeypatch.setattr(feishu_module, "CallBackCard", _FakeCallBackCard) + + +class TestCardActionCallbackResponse: + """Test that _on_card_action_trigger returns updated card inline.""" + + def test_drops_action_when_loop_not_ready(self, _patch_callback_card_types): adapter = _make_adapter() + adapter._loop = None + data = _make_card_action_data({"hermes_action": "approve_once", "approval_id": 1}) - mock_update = AsyncMock() - adapter._client.im.v1.message.update = MagicMock() + with patch("asyncio.run_coroutine_threadsafe") as mock_submit: + response = adapter._on_card_action_trigger(data) - with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread: - await adapter._update_approval_card( - "msg_001", "Approved once", "Norbert", "once" - ) + assert response is not None + assert response.card is None + mock_submit.assert_not_called() - mock_thread.assert_called_once() - # Verify the update request was built - call_args = mock_thread.call_args - assert call_args[0][0] == adapter._client.im.v1.message.update + def test_returns_card_for_approve_action(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_action": "approve_once", "approval_id": 1}, + open_id="ou_bob", + ) + adapter._sender_name_cache["ou_bob"] = ("Bob", 9999999999) - @pytest.mark.asyncio - async def test_updates_card_on_deny(self): + 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 + assert response.card.type == "raw" + card = response.card.data + assert card["header"]["template"] == "green" + assert "Approved once" in card["header"]["title"]["content"] + assert "Bob" in card["elements"][0]["content"] + + def test_returns_card_for_deny_action(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_action": "deny", "approval_id": 2}, + ) - with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread: - await adapter._update_approval_card( - "msg_002", "Denied", "Alice", "deny" - ) + with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro): + response = adapter._on_card_action_trigger(data) - mock_thread.assert_called_once() + assert response.card is not None + card = response.card.data + assert card["header"]["template"] == "red" + assert "Denied" in card["header"]["title"]["content"] - @pytest.mark.asyncio - async def test_skips_update_when_not_connected(self): + def test_ignores_missing_approval_id(self, _patch_callback_card_types): adapter = _make_adapter() - adapter._client = None + adapter._loop = MagicMock() + adapter._loop.is_closed = MagicMock(return_value=False) + data = _make_card_action_data({"hermes_action": "approve_once"}) - with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread: - await adapter._update_approval_card( - "msg_001", "Approved", "Bob", "once" - ) + with patch("asyncio.run_coroutine_threadsafe") as mock_submit: + response = adapter._on_card_action_trigger(data) - mock_thread.assert_not_called() + assert response is not None + assert response.card is None + mock_submit.assert_not_called() - @pytest.mark.asyncio - async def test_skips_update_when_no_message_id(self): + def test_no_card_for_non_approval_action(self, _patch_callback_card_types): adapter = _make_adapter() + adapter._loop = MagicMock() + adapter._loop.is_closed = MagicMock(return_value=False) + data = _make_card_action_data({"some_other": "value"}) - with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread: - await adapter._update_approval_card( - "", "Approved", "Bob", "once" - ) + with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro): + response = adapter._on_card_action_trigger(data) - mock_thread.assert_not_called() + assert response is not None + assert response.card is None - @pytest.mark.asyncio - async def test_swallows_update_errors(self): + def test_falls_back_to_open_id_when_name_not_cached(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_action": "approve_session", "approval_id": 3}, + open_id="ou_unknown", + ) - with patch("asyncio.to_thread", new_callable=AsyncMock, side_effect=Exception("API error")): - # Should not raise - await adapter._update_approval_card( - "msg_001", "Approved", "Bob", "once" - ) + with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro): + response = adapter._on_card_action_trigger(data) + + card = response.card.data + assert "ou_unknown" in card["elements"][0]["content"] + + def test_ignores_expired_cached_name(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_action": "approve_once", "approval_id": 4}, + open_id="ou_expired", + ) + adapter._sender_name_cache["ou_expired"] = ("Old Name", 1) + + with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro): + response = adapter._on_card_action_trigger(data) + + card = response.card.data + assert "Old Name" not in card["elements"][0]["content"] + assert "ou_expired" in card["elements"][0]["content"]
-
-
#109174 docs(feishu): error 200340 on card buttons is a missing callback subscription, not a payload bug (#10251)
files:
website/docs/user-guide/messaging/feishu.md,website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/feishu.md
9 PRs closed without merging
From the close comments: done elsewhere 3 · stale or abandoned 2 · superseded by another PR 2 · merged via another change 1 · other 1
-
#8787 fix: route Feishu websocket CARD callbacks · done elsewhere →
#9871 -
#9848 fix(feishu): resolve approval cards inline from callback path · merged via another change →
#9871 - #10095 fix(feishu): handle CARD-type WebSocket frames for interactive card actions · stale or abandoned
- #10161 fix(feishu): use PATCH API to update approval cards (error 200340) · other
- #11740 fix(feishu): drop non-approval card callbacks instead of replying "Unknown command /card" (#11600) · stale or abandoned
Show 4 more
-
#23645 fix(feishu): patch approval card updates · done elsewhere →
1aa7662,#9533 -
#34707 fix(feishu): approval card clicks rejected when FEISHU_ALLOWED_USERS not set · superseded by another PR →
#39048 -
#77364 fix(feishu): serialize card.data to JSON string in approval/update-prompt callback responses · superseded by another PR →
#77374 -
#77374 fix(feishu): serialize card.data to JSON string in approval/update-prompt callbacks · done elsewhere →
6c1db05,#109174
Closed issues · 8
- Closed#6893[Bug] Error code 200340 when approving dangerous commands via Feishu/Lark gateway
- Closed#8764[Bug]: Feishu Card Interactive Button Error: Code 200340 - No card.action Callback Received & Severe Response Timeout
- Closed#8847fix(feishu): approval card never updates — use im.v1.message.patch instead of update
- Closed#9533fix(feishu): approval card not updating after button click — use PATCH instead of PUT
- Closed#9585Feishu message button click error
- Closed#10154[Bug]: Feishu approval card fails to update after button click (error 200340)
- Closed#10251Feishu command approval card buttons fail with error code 200340
- Closed#11600[Bug]: [Feishu][v0.9.0] approval card clicks can fall through to unsupported /card path
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.07: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab