import json import time from threading import RLock from typing import Any import httpx from fastapi import HTTPException, status from sqlalchemy.orm import Session from app.core.config import get_settings from app.core.constants import BEARER_TOKEN_TEMPLATE, HttpHeader from app.modules.feishu.constants import ( FEISHU_APP_TICKET_MISSING, FEISHU_APP_TOKEN_PATH, FEISHU_AUTH_MISSING, FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS, FEISHU_IMAGE_PATH, FEISHU_MESSAGE_PATH, FEISHU_RECEIVE_ID_MISSING, FEISHU_STORE_TENANT_TOKEN_PATH, FEISHU_SUCCESS_CODE, FEISHU_TENANT_KEY_MISSING, FEISHU_TENANT_TOKEN_PATH, FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS, FeishuAppType, FeishuMessageType, FeishuPayloadKey, FeishuReceiveIdType, ) from app.modules.feishu.errors import FeishuAPIError _RETRYABLE_PROVIDER_CODES = frozenset( { 99991400, 99991401, 99991402, 99991403, } ) _RETRYABLE_PROVIDER_TERMS = ( "rate limit", "too many request", "temporar", "timeout", "busy", "限流", "频率", "超时", "繁忙", ) class FeishuClient: """Small Feishu Open Platform client with tenant-isolated token caches.""" def __init__(self, db: Session | None = None) -> None: self.settings = get_settings() self.db = db self._tenant_access_token: str | None = None self._token_expires_at: float = 0 self._app_access_tokens: dict[str, tuple[str, float]] = {} self._store_tenant_access_tokens: dict[tuple[str, str], tuple[str, float]] = {} self._token_lock = RLock() def _is_configured(self) -> bool: return bool(self.settings.feishu_app_id and self.settings.feishu_app_secret) def _get_tenant_access_token(self, tenant_key: str | None = None) -> str: if not self._is_configured(): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=FEISHU_AUTH_MISSING, ) if self.settings.feishu_app_type == FeishuAppType.STORE: return self._get_store_tenant_access_token(tenant_key) return self._get_self_tenant_access_token() def _get_self_tenant_access_token(self) -> str: with self._token_lock: if ( self._tenant_access_token and time.time() < self._token_expires_at ): return self._tenant_access_token data = self._post( f"{self.settings.feishu_base_url}{FEISHU_TENANT_TOKEN_PATH}", operation="Feishu self tenant token request", json={ FeishuPayloadKey.APP_ID: self.settings.feishu_app_id, FeishuPayloadKey.APP_SECRET: self.settings.feishu_app_secret, }, ) token = self._required_token( data, FeishuPayloadKey.TENANT_ACCESS_TOKEN, "Feishu self tenant token response", ) self._tenant_access_token = token self._token_expires_at = self._expires_at(data) return token def _get_store_app_access_token(self) -> str: app_id = str(self.settings.feishu_app_id) with self._token_lock: cached = self._get_cached(self._app_access_tokens, app_id) if cached is not None: return cached app_ticket = self._get_app_ticket(app_id) data = self._post( f"{self.settings.feishu_base_url}{FEISHU_APP_TOKEN_PATH}", operation="Feishu store app token request", json={ FeishuPayloadKey.APP_ID: app_id, FeishuPayloadKey.APP_SECRET: self.settings.feishu_app_secret, FeishuPayloadKey.APP_TICKET: app_ticket, }, ) token = self._required_token( data, FeishuPayloadKey.APP_ACCESS_TOKEN, "Feishu store app token response", ) self._app_access_tokens[app_id] = (token, self._expires_at(data)) return token def _get_store_tenant_access_token(self, tenant_key: str | None) -> str: normalized_tenant_key = str(tenant_key or "").strip() if not normalized_tenant_key: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=FEISHU_TENANT_KEY_MISSING, ) app_id = str(self.settings.feishu_app_id) cache_key = (app_id, normalized_tenant_key) with self._token_lock: cached = self._get_cached( self._store_tenant_access_tokens, cache_key, ) if cached is not None: return cached app_access_token = self._get_store_app_access_token() data = self._post( f"{self.settings.feishu_base_url}{FEISHU_STORE_TENANT_TOKEN_PATH}", operation="Feishu store tenant token request", json={ FeishuPayloadKey.APP_ACCESS_TOKEN: app_access_token, FeishuPayloadKey.TENANT_KEY: normalized_tenant_key, }, ) token = self._required_token( data, FeishuPayloadKey.TENANT_ACCESS_TOKEN, "Feishu store tenant token response", ) self._store_tenant_access_tokens[cache_key] = ( token, self._expires_at(data), ) return token def _get_app_ticket(self, app_id: str) -> str: ticket: Any = None if self.db is not None: from app.modules.feishu.app_tickets import FeishuAppTicketService ticket = FeishuAppTicketService(self.db).get_ticket(app_id) if ticket is not None and not isinstance(ticket, str): ticket = getattr(ticket, "app_ticket", None) or getattr( ticket, "ticket", None, ) normalized = str(ticket or "").strip() if not normalized: normalized = str(self.settings.feishu_app_ticket or "").strip() if not normalized: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=FEISHU_APP_TICKET_MISSING, ) return normalized def send_message( self, receive_id: str, receive_id_type: str, msg_type: str, content: dict[str, Any], uuid: str | None = None, tenant_key: str | None = None, ) -> dict[str, Any]: token = self._get_tenant_access_token(tenant_key) headers = {HttpHeader.AUTHORIZATION: BEARER_TOKEN_TEMPLATE.format(token=token)} payload = { FeishuPayloadKey.RECEIVE_ID: receive_id, FeishuPayloadKey.MESSAGE_TYPE: msg_type, FeishuPayloadKey.CONTENT: json.dumps(content, ensure_ascii=False), } if uuid: payload[FeishuPayloadKey.UUID] = uuid return self._post( f"{self.settings.feishu_base_url}{FEISHU_MESSAGE_PATH}", operation="Feishu message request", headers=headers, params={FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type}, json=payload, ) def send_text( self, text: str, receive_id: str | None = None, receive_id_type: str = FeishuReceiveIdType.CHAT_ID, uuid: str | None = None, tenant_key: str | None = None, ) -> dict[str, Any]: target_id = receive_id or self.settings.feishu_default_chat_id if not target_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=FEISHU_RECEIVE_ID_MISSING, ) resolved_tenant_key = tenant_key if receive_id is None and not resolved_tenant_key: resolved_tenant_key = self.settings.feishu_default_tenant_key return self.send_message( target_id, receive_id_type, FeishuMessageType.TEXT, {FeishuPayloadKey.TEXT: text}, uuid, resolved_tenant_key, ) def upload_image( self, image: bytes, filename: str = "lifecycle-report.png", tenant_key: str | None = None, ) -> dict[str, Any]: resolved_tenant_key = tenant_key or self.settings.feishu_default_tenant_key token = self._get_tenant_access_token(resolved_tenant_key) headers = {HttpHeader.AUTHORIZATION: BEARER_TOKEN_TEMPLATE.format(token=token)} return self._post( f"{self.settings.feishu_base_url}{FEISHU_IMAGE_PATH}", operation="Feishu image upload request", timeout=30, headers=headers, data={FeishuPayloadKey.IMAGE_TYPE: FeishuPayloadKey.MESSAGE}, files={ FeishuPayloadKey.IMAGE: (filename, image, "image/png"), }, ) def send_card( self, card: dict[str, Any], receive_id: str | None = None, receive_id_type: str = FeishuReceiveIdType.CHAT_ID, uuid: str | None = None, tenant_key: str | None = None, ) -> dict[str, Any]: target_id = receive_id or self.settings.feishu_default_chat_id if not target_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=FEISHU_RECEIVE_ID_MISSING, ) resolved_tenant_key = tenant_key if receive_id is None and not resolved_tenant_key: resolved_tenant_key = self.settings.feishu_default_tenant_key return self.send_message( target_id, receive_id_type, FeishuMessageType.INTERACTIVE, card, uuid, resolved_tenant_key, ) def _post( self, url: str, *, operation: str, timeout: int = 20, **kwargs: Any, ) -> dict[str, Any]: try: with httpx.Client(timeout=timeout) as client: response = client.post(url, **kwargs) except httpx.HTTPError: raise FeishuAPIError( f"{operation} failed", retryable=True, ) from None if not status.HTTP_200_OK <= response.status_code < status.HTTP_300_MULTIPLE_CHOICES: raise FeishuAPIError( f"{operation} returned an HTTP error", retryable=_is_retryable_http_status(response.status_code), http_status=response.status_code, ) try: data = response.json() except ValueError: raise FeishuAPIError( f"{operation} response was not valid JSON", retryable=True, http_status=response.status_code, ) from None if not isinstance(data, dict): raise FeishuAPIError( f"{operation} response was not a JSON object", retryable=True, http_status=response.status_code, ) provider_code = data.get(FeishuPayloadKey.CODE) if provider_code != FEISHU_SUCCESS_CODE: raise FeishuAPIError( f"{operation} returned a non-zero business code", retryable=_is_retryable_business_error(data), http_status=response.status_code, provider_code=provider_code, provider_response={ FeishuPayloadKey.CODE: provider_code, }, ) return data @staticmethod def _required_token( data: dict[str, Any], key: FeishuPayloadKey, operation: str, ) -> str: token = data.get(key) if not isinstance(token, str) or not token.strip(): raise FeishuAPIError( f"{operation} did not include the required credential", retryable=True, provider_code=data.get(FeishuPayloadKey.CODE), provider_response={ FeishuPayloadKey.CODE: data.get(FeishuPayloadKey.CODE), }, ) return token @staticmethod def _expires_at(data: dict[str, Any]) -> float: try: expire_seconds = int( data.get( FeishuPayloadKey.EXPIRE, FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS, ) ) except (TypeError, ValueError): expire_seconds = FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS usable_seconds = max( 1, expire_seconds - FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS, ) return time.time() + usable_seconds @staticmethod def _get_cached( cache: dict[Any, tuple[str, float]], key: Any, ) -> str | None: entry = cache.get(key) if entry is None: return None token, expires_at = entry if time.time() < expires_at: return token cache.pop(key, None) return None def _is_retryable_http_status(status_code: int) -> bool: return ( status_code == status.HTTP_429_TOO_MANY_REQUESTS or status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR ) def _is_retryable_business_error(data: dict[str, Any]) -> bool: code = data.get(FeishuPayloadKey.CODE) if code in _RETRYABLE_PROVIDER_CODES: return True message = str(data.get("msg") or "").casefold() return any(term in message for term in _RETRYABLE_PROVIDER_TERMS)