import json from typing import Any from fastapi import HTTPException, status from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.constants import ActorValue from app.modules.approvals.service import ApprovalService from app.modules.audit.constants import AuditAction, AuditSource from app.modules.audit.schemas import AuditLogCreate from app.modules.feishu.commands import FeishuCommandService from app.modules.feishu.constants import ( FeishuCommandKey, FeishuEventReceiptKey, FeishuEventSource, FeishuPayloadKey, FeishuResponseKey, ) from app.modules.feishu.models import FeishuEventReceipt from app.modules.feishu.service import FeishuService FEISHU_EVENT_ACTIONS = { FeishuEventSource.WEBHOOK: AuditAction.FEISHU_WEBHOOK_EVENT, FeishuEventSource.LONG_CONNECTION: AuditAction.FEISHU_LONG_CONNECTION_EVENT, } class FeishuEventService: """Handle Feishu message events from webhook or long connection.""" def __init__(self, db: Session): self.db = db self.feishu = FeishuService(db) self.commands = FeishuCommandService(db) def handle_event( self, payload: dict[str, Any], source: str | FeishuEventSource, auto_reply: bool = True, ) -> dict[str, Any]: self.feishu.verify_event(payload) challenge = payload.get(FeishuPayloadKey.CHALLENGE) if challenge: return {FeishuResponseKey.CHALLENGE: challenge} source_value = _normalize_source(source) event_identity = _event_identity(payload, source) if event_identity and not self._register_event(event_identity): return { FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: False, FeishuResponseKey.DUPLICATE: True, } self.feishu.audit.log( AuditLogCreate( actor=ActorValue.FEISHU, source=AuditSource.FEISHU, action=FEISHU_EVENT_ACTIONS[source_value], target_type=source_value, target_id=( event_identity.get(FeishuEventReceiptKey.EVENT_KEY) if event_identity else None ), request_payload=payload, response_payload={FeishuResponseKey.ACCEPTED: True}, ) ) command = self.commands.extract_event_command(payload) if not command: return {FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: False} result = self.commands.handle_text( command[FeishuCommandKey.TEXT], chat_id=command[FeishuCommandKey.CHAT_ID], actor=command[FeishuCommandKey.ACTOR], auto_reply=auto_reply, ) return { FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: True, FeishuResponseKey.RESULT: result, } def handle_approval_card_action(self, payload: dict[str, Any]) -> dict[str, Any]: """Handle Feishu interactive-card approval button callbacks.""" self.feishu.verify_event(payload) value = _approval_action_value(payload) ticket_id = str(value.get("ticket_id") or "").strip() decision = str(value.get("decision") or value.get("action") or "").lower() if not ticket_id or decision not in {"approve", "reject"}: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid Feishu approval action payload", ) comment = value.get("comment") actor = _approval_operator(payload) event_identity = _approval_event_identity(payload, ticket_id, decision, actor) if not self._register_event(event_identity): ticket = ApprovalService(self.db).get_by_ticket(ticket_id) return { FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: True, FeishuResponseKey.DUPLICATE: True, FeishuResponseKey.RESULT: { "ticket_id": ticket.ticket_id, "status": ticket.status, "approver": ticket.approver, }, } ticket = ApprovalService(self.db).decide( ticket_id, actor, approved=decision == "approve", comment=str(comment) if comment is not None else None, ) self.feishu.audit.log( AuditLogCreate( actor=actor, source=AuditSource.FEISHU, action=AuditAction.FEISHU_WEBHOOK_EVENT, target_type="approval_card_action", target_id=ticket_id, request_payload=payload, response_payload={"status": ticket.status, "decision": decision}, ) ) return { FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: True, FeishuResponseKey.RESULT: { "ticket_id": ticket.ticket_id, "status": ticket.status, "approver": ticket.approver, }, } def _register_event(self, event_identity: dict[str, str | None]) -> bool: receipt = FeishuEventReceipt( event_key=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]), source=str(event_identity[FeishuEventReceiptKey.SOURCE]), event_id=event_identity.get(FeishuEventReceiptKey.EVENT_ID), message_id=event_identity.get(FeishuEventReceiptKey.MESSAGE_ID), ) self.db.add(receipt) try: self.db.flush() except IntegrityError: self.db.rollback() return False return True def _normalize_source(source: str | FeishuEventSource) -> FeishuEventSource: return FeishuEventSource(source) def _event_identity( payload: dict[str, Any], source: str | FeishuEventSource, ) -> dict[str, str | None] | None: source_value = _normalize_source(source) header = payload.get(FeishuPayloadKey.HEADER) or {} event = payload.get(FeishuPayloadKey.EVENT) or {} message = event.get(FeishuPayloadKey.MESSAGE) or {} event_id = header.get(FeishuPayloadKey.EVENT_ID) message_id = message.get(FeishuPayloadKey.MESSAGE_ID) stable_id = event_id or message_id if not stable_id: return None event_type = header.get(FeishuPayloadKey.EVENT_TYPE) event_key = ":".join( str(part) for part in (source_value, event_type or FeishuPayloadKey.EVENT, stable_id) ) return { FeishuEventReceiptKey.EVENT_KEY: event_key, FeishuEventReceiptKey.SOURCE: source_value, FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None, FeishuEventReceiptKey.MESSAGE_ID: str(message_id) if message_id else None, } def _approval_action_value(payload: dict[str, Any]) -> dict[str, Any]: action = payload.get("action") or {} event = payload.get(FeishuPayloadKey.EVENT) or {} event_action = event.get("action") or {} value = action.get("value") or event_action.get("value") or payload.get("value") or {} if isinstance(value, str): try: parsed = json.loads(value) except json.JSONDecodeError: return {} return parsed if isinstance(parsed, dict) else {} return value if isinstance(value, dict) else {} def _approval_operator(payload: dict[str, Any]) -> str: operator = payload.get("operator") or (payload.get(FeishuPayloadKey.EVENT) or {}).get( "operator" ) or {} operator_id = operator.get("operator_id") or {} return ( operator_id.get(FeishuPayloadKey.OPEN_ID) or operator_id.get(FeishuPayloadKey.USER_ID) or operator.get(FeishuPayloadKey.OPEN_ID) or operator.get(FeishuPayloadKey.USER_ID) or ActorValue.FEISHU ) def _approval_event_identity( payload: dict[str, Any], ticket_id: str, decision: str, actor: str, ) -> dict[str, str | None]: header = payload.get(FeishuPayloadKey.HEADER) or {} event_id = header.get(FeishuPayloadKey.EVENT_ID) stable_id = event_id or f"{ticket_id}:{decision}:{actor}" return { FeishuEventReceiptKey.EVENT_KEY: f"{FeishuEventSource.WEBHOOK}:approval:{stable_id}", FeishuEventReceiptKey.SOURCE: FeishuEventSource.WEBHOOK, FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None, FeishuEventReceiptKey.MESSAGE_ID: None, }