From dac9b59ac67ee310ce21cd0683e11029373566a8 Mon Sep 17 00:00:00 2001 From: wlknight Date: Wed, 24 Jun 2026 21:47:58 +0800 Subject: [PATCH] fix(feishu): use open_message_id for card action reply instead of token When a Feishu interactive card button is clicked, the agent fails to send a reply with: [99992354] Invalid ids: [c-...] Root cause: _handle_card_action_event() sets message_id to the card action token (format c-xxx), but the Feishu ReplyMessage API requires an open_message_id (format om_xxx). P2CardActionTriggerData.CallBackContext already provides the open_message_id field, but the adapter only extracted open_chat_id while ignoring open_message_id. Two-line fix in plugins/platforms/feishu/adapter.py: 1. Extract open_message_id from CallBackContext 2. Prefer open_message_id as message_id (fall back to token/uuid) Regression tests added in tests/gateway/test_feishu_approval_buttons.py: - test_uses_open_message_id_when_available: verifies om_xxx is used when present in the callback context - test_falls_back_to_token_when_open_message_id_absent: verifies token fallback when open_message_id is absent All 40 tests pass. --- plugins/platforms/feishu/adapter.py | 3 +- tests/gateway/test_feishu_approval_buttons.py | 56 ++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index bf3c49d3b8679..de1800b0fdbb1 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -2859,6 +2859,7 @@ async def _handle_card_action_event(self, data: Any) -> None: context = getattr(event, "context", None) chat_id = str(getattr(context, "open_chat_id", "") or "") + open_message_id = str(getattr(context, "open_message_id", "") or "") operator = getattr(event, "operator", None) open_id = str(getattr(operator, "open_id", "") or "") if not chat_id or not open_id: @@ -2893,7 +2894,7 @@ async def _handle_card_action_event(self, data: Any) -> None: message_type=MessageType.COMMAND, source=source, raw_message=data, - message_id=token or str(uuid.uuid4()), + message_id=open_message_id or token or str(uuid.uuid4()), timestamp=datetime.now(), ) logger.info("[Feishu] Routing card action %r from %s in %s as synthetic command", action_tag, open_id, chat_id) diff --git a/tests/gateway/test_feishu_approval_buttons.py b/tests/gateway/test_feishu_approval_buttons.py index f5b9a26c1e127..4b3503ef3bb4c 100644 --- a/tests/gateway/test_feishu_approval_buttons.py +++ b/tests/gateway/test_feishu_approval_buttons.py @@ -59,12 +59,13 @@ def _make_card_action_data( chat_id: str = "oc_12345", open_id: str = "ou_user1", token: str = "tok_abc", + open_message_id: str = "", ) -> SimpleNamespace: """Create a mock Feishu card action callback data object.""" return SimpleNamespace( event=SimpleNamespace( token=token, - context=SimpleNamespace(open_chat_id=chat_id), + context=SimpleNamespace(open_chat_id=chat_id, open_message_id=open_message_id), operator=SimpleNamespace(open_id=open_id), action=SimpleNamespace( tag="button", @@ -437,6 +438,59 @@ async def test_routes_as_synthetic_command(self): event = mock_handle.call_args[0][0] assert "/card button" in event.text + @pytest.mark.asyncio + async def test_uses_open_message_id_when_available(self): + """When open_message_id is present in the callback context, it should + be used as the synthetic event's message_id so downstream reply + anchors resolve to a valid Feishu message (om_xxx), not the card + action token (c-xxx).""" + adapter = _make_adapter() + + data = _make_card_action_data( + action_value={"custom_action": "recognize"}, + token="c-deadbeef", + open_message_id="om_card_message_123", + ) + + with ( + patch.object( + adapter, "_resolve_sender_profile", new_callable=AsyncMock, + return_value={"user_id": "ou_u", "user_name": "Dave", "user_id_alt": None}, + ), + 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, + ): + await adapter._handle_card_action_event(data) + + event = mock_handle.call_args[0][0] + assert event.message_id == "om_card_message_123" + + @pytest.mark.asyncio + async def test_falls_back_to_token_when_open_message_id_absent(self): + """When open_message_id is absent from the callback context (e.g. + older Feishu SDK or edge-case payload), message_id should fall back + to the card action token for deduplication purposes.""" + adapter = _make_adapter() + + data = _make_card_action_data( + action_value={"custom_action": "recognize"}, + token="c-fallback-token", + open_message_id="", # absent + ) + + with ( + patch.object( + adapter, "_resolve_sender_profile", new_callable=AsyncMock, + return_value={"user_id": "ou_u", "user_name": "Dave", "user_id_alt": None}, + ), + 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, + ): + await adapter._handle_card_action_event(data) + + event = mock_handle.call_args[0][0] + assert event.message_id == "c-fallback-token" + # =========================================================================== # _on_card_action_trigger — inline card response for approval actions