PMFlow/backend/app/api/feishu_api/card_action_routes.py

298 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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")
queue_item_id = action_value.get("queue_item_id") or action_value.get("item_id") or action_value.get("queueItemId")
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 queue_item_id is not None:
payload["queue_item_id"] = queue_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 _looks_like_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", "queue_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 _looks_like_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)