294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""Feishu interactive card action callback routes."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, Request, Response
|
||
from lark_oapi.card.action_handler import CardActionHandler
|
||
from lark_oapi.card.model import Card
|
||
from lark_oapi.core.model.raw_request import RawRequest
|
||
from lark_oapi.core.model.raw_response import RawResponse
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ...core.database import get_db
|
||
from ...core.logging import get_logger
|
||
from ...services.integrations.feishu_integration_service import get_feishu_service
|
||
|
||
router = APIRouter()
|
||
logger = get_logger(__name__)
|
||
_HEADER_ALIASES = {
|
||
"x-lark-request-timestamp": "X-Lark-Request-Timestamp",
|
||
"x-lark-request-nonce": "X-Lark-Request-Nonce",
|
||
"x-lark-signature": "X-Lark-Signature",
|
||
"x-request-id": "X-Request-Id",
|
||
"content-type": "Content-Type",
|
||
}
|
||
|
||
_SIGNATURE_FAILURE_MARKERS = (
|
||
"signature verification failed",
|
||
"invalid verification_token",
|
||
)
|
||
|
||
|
||
def _build_raw_request(request: Request, body: bytes) -> RawRequest:
|
||
raw_request = RawRequest()
|
||
raw_request.uri = str(request.url)
|
||
raw_request.body = body
|
||
|
||
headers: dict[str, str] = {}
|
||
for key, value in request.headers.items():
|
||
headers[str(key)] = str(value)
|
||
headers[str(key).lower()] = str(value)
|
||
for lower_key, canonical_key in _HEADER_ALIASES.items():
|
||
if lower_key in headers and canonical_key not in headers:
|
||
headers[canonical_key] = headers[lower_key]
|
||
raw_request.headers = headers
|
||
return raw_request
|
||
|
||
|
||
def _raw_response_to_response(raw_response: RawResponse) -> Response:
|
||
headers = dict(raw_response.headers or {})
|
||
media_type = headers.pop("content-type", headers.pop("Content-Type", None)) or "application/json"
|
||
return Response(
|
||
content=raw_response.content or b"",
|
||
status_code=int(raw_response.status_code or 200),
|
||
media_type=media_type,
|
||
headers=headers or None,
|
||
)
|
||
|
||
|
||
def _build_card_action_handler(encrypt_key: str, verification_token: str, capture: dict[str, Card]) -> CardActionHandler:
|
||
def _capture_card(card: Card) -> None:
|
||
capture["card"] = card
|
||
return None
|
||
|
||
return (
|
||
CardActionHandler.builder(encrypt_key, verification_token)
|
||
.register(_capture_card)
|
||
.build()
|
||
)
|
||
|
||
|
||
def _parse_action_value(action: Any) -> dict[str, Any]:
|
||
raw_value = getattr(action, "value", None)
|
||
if isinstance(raw_value, dict):
|
||
return raw_value
|
||
if isinstance(raw_value, str):
|
||
text = raw_value.strip()
|
||
if text:
|
||
try:
|
||
parsed = json.loads(text)
|
||
except Exception:
|
||
return {}
|
||
if isinstance(parsed, dict):
|
||
return parsed
|
||
return {}
|
||
|
||
|
||
def _card_to_event_payload(card: Card) -> dict[str, Any]:
|
||
action_value = _parse_action_value(card.action)
|
||
action_payload: dict[str, Any] = {"value": action_value}
|
||
if getattr(card.action, "name", None):
|
||
action_payload["name"] = card.action.name
|
||
if getattr(card.action, "tag", None):
|
||
action_payload["tag"] = card.action.tag
|
||
|
||
work_item_id = action_value.get("work_item_id") or action_value.get("target_work_item_id") or action_value.get("workItemId")
|
||
draft_version = action_value.get("draft_version") or action_value.get("version")
|
||
revision_note = action_value.get("revision_note") or action_value.get("note") or action_value.get("comment")
|
||
|
||
operator_payload: dict[str, Any] = {}
|
||
if getattr(card, "open_id", None):
|
||
operator_payload["open_id"] = card.open_id
|
||
if getattr(card, "user_id", None):
|
||
operator_payload["user_id"] = card.user_id
|
||
if getattr(card, "tenant_key", None):
|
||
operator_payload["tenant_key"] = card.tenant_key
|
||
|
||
payload: dict[str, Any] = {
|
||
"type": str(getattr(card, "type", "") or "").strip() or "card.action.trigger",
|
||
"open_message_id": getattr(card, "open_message_id", None),
|
||
"open_chat_id": getattr(card, "open_chat_id", None),
|
||
"message_id": getattr(card, "open_message_id", None),
|
||
"chat_id": getattr(card, "open_chat_id", None),
|
||
"operator": operator_payload,
|
||
"action": action_payload,
|
||
}
|
||
if work_item_id is not None:
|
||
payload["work_item_id"] = work_item_id
|
||
if draft_version is not None:
|
||
payload["draft_version"] = draft_version
|
||
if revision_note is not None:
|
||
payload["revision_note"] = revision_note
|
||
if getattr(card, "token", None):
|
||
payload["token"] = card.token
|
||
if getattr(card, "challenge", None):
|
||
payload["challenge"] = card.challenge
|
||
if getattr(card, "tenant_key", None):
|
||
payload["tenant_key"] = card.tenant_key
|
||
return payload
|
||
|
||
|
||
def _is_signature_failure(raw_response: RawResponse) -> bool:
|
||
status_code = int(raw_response.status_code or 0)
|
||
if status_code < 400:
|
||
return False
|
||
content = raw_response.content or b""
|
||
text = content.decode("utf-8", errors="ignore")
|
||
return any(marker in text for marker in _SIGNATURE_FAILURE_MARKERS)
|
||
|
||
|
||
def _safe_result_dict(value: Any) -> dict[str, Any]:
|
||
return value if isinstance(value, dict) else {}
|
||
|
||
|
||
def _build_action_result_payload(result: dict[str, Any]) -> dict[str, Any]:
|
||
data = _safe_result_dict(result.get("data"))
|
||
code = int(result.get("code") or 0)
|
||
msg = str(result.get("msg") or "").strip()
|
||
raw_status = str(data.get("status") or "").strip().lower()
|
||
followup_reply = str(data.get("followup_reply") or result.get("msg") or "").strip()
|
||
error_text = str(
|
||
data.get("error")
|
||
or result.get("error")
|
||
or result.get("msg")
|
||
or "处理失败"
|
||
).strip()
|
||
|
||
header_title = "AI PMO 已处理"
|
||
header_template = "grey"
|
||
lead_text = followup_reply or "已收到,正在处理。"
|
||
|
||
if code != 0:
|
||
header_title = "AI PMO 处理失败"
|
||
header_template = "red"
|
||
lead_text = error_text
|
||
elif msg == "card_action_ignored":
|
||
header_title = "AI PMO 卡片已失效"
|
||
header_template = "grey"
|
||
lead_text = "这张卡片已失效,请刷新后再试"
|
||
elif raw_status in {"succeeded", "confirmed", "success", "done"}:
|
||
header_title = "AI PMO 已确认并入库"
|
||
header_template = "green"
|
||
lead_text = followup_reply or "已确认并入库。"
|
||
elif raw_status in {"cancelled", "canceled"}:
|
||
header_title = "AI PMO 已取消"
|
||
header_template = "grey"
|
||
lead_text = followup_reply or "已取消。"
|
||
elif raw_status in {"pending_manual", "pending_confirmation", "pending_queue"}:
|
||
header_title = "AI PMO 待继续处理"
|
||
header_template = "yellow"
|
||
lead_text = followup_reply or "已收到,正在处理中。"
|
||
elif raw_status == "failed_safe":
|
||
header_title = "AI PMO 处理失败"
|
||
header_template = "red"
|
||
lead_text = followup_reply or error_text
|
||
|
||
detail_rows: list[str] = []
|
||
for label, key in (
|
||
("草稿ID", "target_work_item_id"),
|
||
("工作项ID", "work_item_id"),
|
||
("项目ID", "project_id"),
|
||
("任务ID", "task_id"),
|
||
("信号ID", "signal_id"),
|
||
("注册ID", "source_registry_id"),
|
||
("版本", "draft_version"),
|
||
):
|
||
value = data.get(key)
|
||
if value is not None and str(value).strip():
|
||
detail_rows.append(f"- **{label}**:{value}")
|
||
if raw_status:
|
||
detail_rows.append(f"- **状态**:{raw_status}")
|
||
if code != 0:
|
||
detail_rows.append(f"- **错误**:{error_text}")
|
||
|
||
body_lines = [f"### {lead_text}"]
|
||
if detail_rows:
|
||
body_lines.extend(["", *detail_rows])
|
||
|
||
toast_type = "error" if code != 0 or raw_status == "failed_safe" else "info"
|
||
return {
|
||
"toast": {
|
||
"type": toast_type,
|
||
"content": lead_text,
|
||
"i18n": {
|
||
"zh_cn": lead_text,
|
||
"en_us": lead_text,
|
||
},
|
||
}
|
||
}
|
||
|
||
|
||
@router.post("/card-action")
|
||
@router.post("/card-action/", include_in_schema=False)
|
||
async def handle_feishu_card_action(
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Handle Feishu interactive card button callbacks.
|
||
|
||
Long connection keeps the message pipeline alive; this HTTP callback is
|
||
still required for interactive card actions configured in Feishu.
|
||
"""
|
||
service = get_feishu_service(db)
|
||
await service.load_config(tenant_id=1)
|
||
|
||
raw_body = await request.body()
|
||
raw_request = _build_raw_request(request, raw_body)
|
||
|
||
async def _process_captured_card(capture: dict[str, Card]) -> dict[str, Any] | None:
|
||
card = capture.get("card")
|
||
if card is None:
|
||
return None
|
||
try:
|
||
return await service._handle_card_action_trigger(_card_to_event_payload(card))
|
||
except Exception as exc: # pragma: no cover - do not break Feishu ACK
|
||
logger.warning(
|
||
"feishu_card_action_post_process_failed",
|
||
tenant_id=service.tenant_id,
|
||
error=str(exc),
|
||
)
|
||
return {"code": 1, "msg": "card_action_failed", "data": {"error": str(exc)}}
|
||
|
||
strict_capture: dict[str, Card] = {}
|
||
strict_handler = _build_card_action_handler(
|
||
service.encrypt_key or "",
|
||
service.verification_token or "",
|
||
strict_capture,
|
||
)
|
||
strict_response = strict_handler.do(raw_request)
|
||
|
||
if b'"challenge"' in (strict_response.content or b""):
|
||
return _raw_response_to_response(strict_response)
|
||
|
||
if _is_signature_failure(strict_response):
|
||
logger.warning(
|
||
"feishu_card_action_signature_verification_failed_fallback",
|
||
tenant_id=service.tenant_id,
|
||
request_uri=str(raw_request.uri),
|
||
)
|
||
fallback_capture: dict[str, Card] = {}
|
||
fallback_handler = _build_card_action_handler(
|
||
service.encrypt_key or "",
|
||
"",
|
||
fallback_capture,
|
||
)
|
||
fallback_handler.do(raw_request)
|
||
result = await _process_captured_card(fallback_capture)
|
||
if isinstance(result, dict):
|
||
return Response(
|
||
content=json.dumps(_build_action_result_payload(result), ensure_ascii=False).encode("utf-8"),
|
||
media_type="application/json",
|
||
)
|
||
return _raw_response_to_response(strict_response)
|
||
|
||
result = await _process_captured_card(strict_capture)
|
||
if isinstance(result, dict):
|
||
return Response(
|
||
content=json.dumps(_build_action_result_payload(result), ensure_ascii=False).encode("utf-8"),
|
||
media_type="application/json",
|
||
)
|
||
return _raw_response_to_response(strict_response)
|