diff --git a/bot/handler.py b/bot/handler.py index bc9badf..921edb0 100644 --- a/bot/handler.py +++ b/bot/handler.py @@ -215,15 +215,21 @@ def _handle_any(data: lark.CustomizedEvent) -> None: logger.info("RAW CustomizedEvent: %s", marshaled[:500]) -def _handle_card_action(data: lark.CustomizedEvent) -> None: - """Handle Feishu card button clicks (approval approve/deny).""" +def _handle_card_action(data: lark.CustomizedEvent) -> dict | None: + """Handle Feishu card button clicks (approval approve/deny). + + Per docs/feishu/card_callback_communication.md: + - Must respond within 3 seconds + - Return toast + updated card to give user visual feedback + """ try: marshaled = lark.JSON.marshal(data) if not marshaled: - return + return None payload = json.loads(marshaled) if isinstance(marshaled, str) else marshaled - action = payload.get("event", {}).get("action", {}) + event = payload.get("event", {}) + action = event.get("action", {}) value = action.get("value", {}) action_type = value.get("action") # "approve" or "deny" @@ -231,17 +237,55 @@ def _handle_card_action(data: lark.CustomizedEvent) -> None: if not action_type or not conv_id: logger.debug("Card action without action/conv_id: %s", value) - return + return None approved = action_type == "approve" - logger.info("Card action: %s for session %s", action_type, conv_id) + operator_open_id = event.get("operator", {}).get("open_id", "") + logger.info( + "Card action: %s for session %s by %s", + action_type, conv_id, operator_open_id[-8:], + ) + # Dispatch approval to SDKSession (async, fire-and-forget) if _main_loop: asyncio.run_coroutine_threadsafe( _handle_approval_async(conv_id, approved), _main_loop ) + + # Respond to callback within 3s: toast + updated card showing result + if approved: + toast_type, toast_text = "success", "✅ 已批准" + card_status = "✅ **已批准**" + template = "green" + else: + toast_type, toast_text = "warning", "❌ 已拒绝" + card_status = "❌ **已拒绝**" + template = "red" + + return { + "toast": { + "type": toast_type, + "content": toast_text, + }, + "card": { + "type": "raw", + "data": { + "schema": "2.0", + "header": { + "title": {"tag": "plain_text", "content": "🔐 权限审批"}, + "template": template, + }, + "body": { + "elements": [ + {"tag": "markdown", "content": card_status}, + ], + }, + }, + }, + } except Exception: logger.exception("Error handling card action") + return None async def _handle_approval_async(conv_id: str, approved: bool) -> None: diff --git a/tests/test_sdk_migration.py b/tests/test_sdk_migration.py index 487c71a..84d66a5 100644 --- a/tests/test_sdk_migration.py +++ b/tests/test_sdk_migration.py @@ -783,6 +783,91 @@ class TestBuildApprovalCard: assert action_el["actions"][1]["value"]["action"] == "deny" +# =========================================================================== +# 8b. bot/handler.py card callback response +# =========================================================================== + + +class TestCardCallbackResponse: + """Test _handle_card_action returns proper callback response per + docs/feishu/card_callback_communication.md.""" + + def _make_card_event(self, action: str, conv_id: str) -> object: + """Create a mock CustomizedEvent with card action payload.""" + import lark_oapi as lark + mock_event = MagicMock() + payload = json.dumps({ + "schema": "2.0", + "header": { + "event_id": "evt_123", + "event_type": "card.action.trigger", + }, + "event": { + "operator": { + "open_id": "ou_test_user_123", + }, + "action": { + "value": {"action": action, "conv_id": conv_id}, + "tag": "button", + }, + }, + }) + # lark.JSON.marshal returns the JSON string + with patch("lark_oapi.JSON.marshal", return_value=payload): + yield payload + + def test_approve_returns_toast_and_card(self): + from bot.handler import _handle_card_action + payload = json.dumps({ + "event": { + "operator": {"open_id": "ou_test"}, + "action": {"value": {"action": "approve", "conv_id": "c1"}, "tag": "button"}, + }, + }) + with patch("lark_oapi.JSON.marshal", return_value=payload), \ + patch("bot.handler._main_loop", new=MagicMock()): + result = _handle_card_action(MagicMock()) + + assert result is not None + # Toast + assert result["toast"]["type"] == "success" + assert "批准" in result["toast"]["content"] + # Updated card + assert result["card"]["type"] == "raw" + card_data = result["card"]["data"] + assert card_data["schema"] == "2.0" + assert card_data["header"]["template"] == "green" + assert "批准" in card_data["body"]["elements"][0]["content"] + + def test_deny_returns_warning_toast(self): + from bot.handler import _handle_card_action + payload = json.dumps({ + "event": { + "operator": {"open_id": "ou_test"}, + "action": {"value": {"action": "deny", "conv_id": "c1"}, "tag": "button"}, + }, + }) + with patch("lark_oapi.JSON.marshal", return_value=payload), \ + patch("bot.handler._main_loop", new=MagicMock()): + result = _handle_card_action(MagicMock()) + + assert result is not None + assert result["toast"]["type"] == "warning" + assert "拒绝" in result["toast"]["content"] + assert result["card"]["data"]["header"]["template"] == "red" + + def test_missing_value_returns_none(self): + from bot.handler import _handle_card_action + payload = json.dumps({ + "event": { + "action": {"value": {}, "tag": "button"}, + }, + }) + with patch("lark_oapi.JSON.marshal", return_value=payload): + result = _handle_card_action(MagicMock()) + assert result is None + + # =========================================================================== # 9. Permission mode constants # ===========================================================================