from hashlib import sha256 from secrets import compare_digest from typing import Any from fastapi import HTTPException, status from sqlalchemy.orm import Session from app.core.constants import ActorValue from app.core.config import get_settings from app.modules.audit.constants import AuditAction, AuditSource from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService from app.modules.feishu.client import FeishuClient from app.modules.feishu.constants import ( FEISHU_EMPTY_CARD_TEXT, FEISHU_INVALID_TOKEN, FEISHU_VERIFICATION_TOKEN_REQUIRED, FeishuPayloadKey, FeishuReceiveIdType, ) class FeishuService: """Send Feishu messages and record audit entries for outbound actions.""" def __init__(self, db: Session, tenant_key: str | None = None): self.db = db self.audit = AuditService(db) self.client = FeishuClient(db) self.tenant_key = _optional_text(tenant_key) or _optional_text( get_settings().feishu_default_tenant_key ) def set_tenant_key(self, tenant_key: str | None) -> None: """Set the default tenant used by subsequent outbound operations.""" self.tenant_key = _optional_text(tenant_key) def verify_event(self, payload: dict[str, Any]) -> None: settings = get_settings() expected = settings.feishu_verification_token header = payload.get(FeishuPayloadKey.HEADER) or {} token = payload.get(FeishuPayloadKey.TOKEN) or header.get(FeishuPayloadKey.TOKEN) if not expected: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=FEISHU_VERIFICATION_TOKEN_REQUIRED, ) if not token or not compare_digest(str(token), expected): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=FEISHU_INVALID_TOKEN, ) def send_text( self, text: str, receive_id: str | None = None, receive_id_type: str = FeishuReceiveIdType.CHAT_ID, actor: str = ActorValue.SYSTEM, uuid: str | None = None, tenant_key: str | None = None, record_audit: bool = True, ) -> dict[str, Any]: result = self.client.send_text( text, receive_id, receive_id_type, uuid, tenant_key=self._resolve_tenant_key(tenant_key), ) if record_audit: self.audit.log( AuditLogCreate( actor=actor, source=AuditSource.FEISHU, action=AuditAction.FEISHU_SEND_TEXT, request_payload={ "receive_target_hash": _target_fingerprint(receive_id), FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type, "content_length": len(text), FeishuPayloadKey.UUID: uuid, }, response_payload=result, ) ) return result def send_card( self, card: dict[str, Any], receive_id: str | None = None, receive_id_type: str = FeishuReceiveIdType.CHAT_ID, actor: str = ActorValue.SYSTEM, uuid: str | None = None, tenant_key: str | None = None, ) -> dict[str, Any]: result = self.client.send_card( card, receive_id, receive_id_type, uuid, tenant_key=self._resolve_tenant_key(tenant_key), ) self.audit.log( AuditLogCreate( actor=actor, source=AuditSource.FEISHU, action=AuditAction.FEISHU_SEND_CARD, request_payload={ "receive_target_hash": _target_fingerprint(receive_id), FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type, "card_element_count": len(card.get(FeishuPayloadKey.ELEMENTS) or []), FeishuPayloadKey.UUID: uuid, }, response_payload=result, ) ) return result def upload_image( self, image: bytes, actor: str = ActorValue.SYSTEM, tenant_key: str | None = None, ) -> dict[str, Any]: result = self.client.upload_image( image, tenant_key=self._resolve_tenant_key(tenant_key), ) self.audit.log( AuditLogCreate( actor=actor, source=AuditSource.FEISHU, action=AuditAction.FEISHU_UPLOAD_IMAGE, request_payload={"content_type": "image/png", "size": len(image)}, response_payload=result, ) ) return result def _resolve_tenant_key(self, tenant_key: str | None) -> str | None: return _optional_text(tenant_key) or self.tenant_key @staticmethod def build_basic_card( title: str, lines: list[str], image_key: str | None = None, image_alt: str | None = None, ) -> dict[str, Any]: elements: list[dict[str, Any]] = [] if image_key: elements.append( { FeishuPayloadKey.TAG: FeishuPayloadKey.IMG, FeishuPayloadKey.IMG_KEY: image_key, FeishuPayloadKey.ALT: { FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT, FeishuPayloadKey.CONTENT: image_alt or "生命周期数据图", }, } ) elements.append( { FeishuPayloadKey.TAG: FeishuPayloadKey.DIV, FeishuPayloadKey.TEXT: { FeishuPayloadKey.TAG: FeishuPayloadKey.LARK_MARKDOWN, FeishuPayloadKey.CONTENT: "\n".join(lines) or FEISHU_EMPTY_CARD_TEXT, }, } ) return { FeishuPayloadKey.CONFIG: {FeishuPayloadKey.WIDE_SCREEN_MODE: True}, FeishuPayloadKey.HEADER: { FeishuPayloadKey.TITLE: { FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT, FeishuPayloadKey.CONTENT: title, } }, FeishuPayloadKey.ELEMENTS: elements, } def _target_fingerprint(receive_id: str | None) -> str | None: if not receive_id: return None return sha256(receive_id.encode("utf-8")).hexdigest()[:16] def _optional_text(value: str | None) -> str | None: text = str(value or "").strip() return text or None