feat(handler): implement card callback response with toast and updated card
Add visual feedback for card actions by returning toast messages and updated cards per Feishu requirements. The handler now responds within 3 seconds with appropriate success/warning toasts and card updates showing approval/denial status. Also added operator tracking in logs and comprehensive tests.
This commit is contained in:
parent
eac90941ef
commit
44bd1a4300
@ -215,15 +215,21 @@ def _handle_any(data: lark.CustomizedEvent) -> None:
|
|||||||
logger.info("RAW CustomizedEvent: %s", marshaled[:500])
|
logger.info("RAW CustomizedEvent: %s", marshaled[:500])
|
||||||
|
|
||||||
|
|
||||||
def _handle_card_action(data: lark.CustomizedEvent) -> None:
|
def _handle_card_action(data: lark.CustomizedEvent) -> dict | None:
|
||||||
"""Handle Feishu card button clicks (approval approve/deny)."""
|
"""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:
|
try:
|
||||||
marshaled = lark.JSON.marshal(data)
|
marshaled = lark.JSON.marshal(data)
|
||||||
if not marshaled:
|
if not marshaled:
|
||||||
return
|
return None
|
||||||
|
|
||||||
payload = json.loads(marshaled) if isinstance(marshaled, str) else marshaled
|
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", {})
|
value = action.get("value", {})
|
||||||
|
|
||||||
action_type = value.get("action") # "approve" or "deny"
|
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:
|
if not action_type or not conv_id:
|
||||||
logger.debug("Card action without action/conv_id: %s", value)
|
logger.debug("Card action without action/conv_id: %s", value)
|
||||||
return
|
return None
|
||||||
|
|
||||||
approved = action_type == "approve"
|
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:
|
if _main_loop:
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
_handle_approval_async(conv_id, approved), _main_loop
|
_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:
|
except Exception:
|
||||||
logger.exception("Error handling card action")
|
logger.exception("Error handling card action")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _handle_approval_async(conv_id: str, approved: bool) -> None:
|
async def _handle_approval_async(conv_id: str, approved: bool) -> None:
|
||||||
|
|||||||
@ -783,6 +783,91 @@ class TestBuildApprovalCard:
|
|||||||
assert action_el["actions"][1]["value"]["action"] == "deny"
|
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
|
# 9. Permission mode constants
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user