"""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)