from dataclasses import replace from typing import Any from fastapi import HTTPException, status from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.application.feishu.commands import FeishuCommandService from app.core.config import get_settings from app.core.constants import ActorValue from app.modules.audit.constants import AuditAction, AuditSource from app.modules.audit.schemas import AuditLogCreate from app.modules.feishu.app_tickets import ( APP_TICKET_EVENT_TYPE, APP_TICKET_PAYLOAD_KEY, FeishuAppTicketService, ) 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 from app.modules.feishu_users.principal import FeishuMention, FeishuPrincipal from app.modules.feishu_users.services import FeishuIdentityService 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) return self._handle_verified_event(payload, source, auto_reply) def _handle_verified_event( self, payload: dict[str, Any], source: str | FeishuEventSource, auto_reply: bool = True, ) -> dict[str, Any]: """Handle an event after an HTTP verifier or the Feishu SDK accepted it.""" challenge = payload.get(FeishuPayloadKey.CHALLENGE) if challenge: return {FeishuResponseKey.CHALLENGE: challenge} source_value = _normalize_source(source) if _event_type(payload) == APP_TICKET_EVENT_TYPE: return self._handle_app_ticket_event(payload, source_value) user_features_enabled = get_settings().feishu_user_features_enabled command = ( self.commands.extract_event_command(payload) if user_features_enabled else None ) principal = ( self._resolve_principal(payload, command) if user_features_enabled and command else None ) 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=principal.user_code if principal else 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=_audit_event_metadata( payload, include_open_id=not user_features_enabled, include_identity_context=user_features_enabled, ), response_payload={FeishuResponseKey.ACCEPTED: True}, ) ) if command is None: 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=( principal.user_code if principal else ( ActorValue.FEISHU if user_features_enabled else command[FeishuCommandKey.ACTOR] ) ), auto_reply=auto_reply, principal=principal, ) return { FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: True, FeishuResponseKey.RESULT: result, } def _handle_app_ticket_event( self, payload: dict[str, Any], source: FeishuEventSource, ) -> dict[str, Any]: settings = get_settings() configured_app_id = str(settings.feishu_app_id or "").strip() if not configured_app_id: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="FEISHU_APP_ID is required for app ticket events", ) app_id, ticket = _app_ticket_fields(payload) if not app_id or not ticket: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid Feishu app ticket event", ) if app_id != configured_app_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Feishu app ticket app_id does not match configured application", ) event_identity = _event_identity(payload, source) if event_identity is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Feishu app ticket event identity is required", ) if not self._register_event(event_identity): return { FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: False, FeishuResponseKey.DUPLICATE: True, } FeishuAppTicketService(self.db).store_verified(app_id, ticket) self.feishu.audit.log( AuditLogCreate( actor=ActorValue.FEISHU, source=AuditSource.FEISHU, action=FEISHU_EVENT_ACTIONS[source], target_type=source, target_id=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]), request_payload=_audit_event_metadata(payload), response_payload={FeishuResponseKey.ACCEPTED: True}, ) ) return { FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: True, } def _resolve_principal( self, payload: dict[str, Any], command: dict[str, Any], ) -> FeishuPrincipal | None: header = payload.get(FeishuPayloadKey.HEADER) or {} event = payload.get(FeishuPayloadKey.EVENT) or {} sender = event.get(FeishuPayloadKey.SENDER) or {} sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {} tenant_key = str(header.get(FeishuPayloadKey.TENANT_KEY) or "").strip() open_id = str(sender_id.get(FeishuPayloadKey.OPEN_ID) or "").strip() if not tenant_key or not open_id: return None principal = FeishuIdentityService(self.db).resolve_or_register( tenant_key=tenant_key, open_id=open_id, union_id=sender_id.get(FeishuPayloadKey.UNION_ID), user_id=sender_id.get(FeishuPayloadKey.USER_ID), ) mentions_value = command.get(FeishuCommandKey.MENTIONS) mentions = ( tuple( mention for mention in mentions_value if isinstance(mention, FeishuMention) ) if isinstance(mentions_value, (list, tuple)) else () ) return replace( principal, chat_id=command.get(FeishuCommandKey.CHAT_ID), chat_type=command.get(FeishuCommandKey.CHAT_TYPE), mentions=mentions, ) 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), ) try: with self.db.begin_nested(): self.db.add(receipt) self.db.flush() except IntegrityError: return False return True def _audit_event_metadata( payload: dict[str, Any], *, include_open_id: bool = True, include_identity_context: bool = False, ) -> dict[str, Any]: """Keep webhook audit evidence without storing message content or tokens.""" header = payload.get(FeishuPayloadKey.HEADER) or {} event = payload.get(FeishuPayloadKey.EVENT) or {} message = event.get(FeishuPayloadKey.MESSAGE) or {} sender = event.get(FeishuPayloadKey.SENDER) or {} sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {} metadata = { "schema": payload.get("schema"), FeishuPayloadKey.EVENT_ID: header.get(FeishuPayloadKey.EVENT_ID), FeishuPayloadKey.EVENT_TYPE: _event_type(payload), FeishuPayloadKey.MESSAGE_ID: message.get(FeishuPayloadKey.MESSAGE_ID), FeishuCommandKey.CHAT_ID: message.get(FeishuCommandKey.CHAT_ID), FeishuPayloadKey.MESSAGE_TYPE: message.get(FeishuPayloadKey.MESSAGE_TYPE), } app_id, _ = _app_ticket_fields(payload) if app_id: metadata[FeishuPayloadKey.APP_ID] = app_id if include_identity_context: metadata[FeishuPayloadKey.TENANT_KEY] = header.get(FeishuPayloadKey.TENANT_KEY) metadata[FeishuCommandKey.CHAT_TYPE] = message.get(FeishuPayloadKey.CHAT_TYPE) if include_open_id: metadata[FeishuPayloadKey.OPEN_ID] = sender_id.get(FeishuPayloadKey.OPEN_ID) return metadata 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) or payload.get(FeishuPayloadKey.EVENT_ID) or payload.get(FeishuPayloadKey.UUID) or event.get(FeishuPayloadKey.UUID) ) message_id = message.get(FeishuPayloadKey.MESSAGE_ID) stable_id = event_id or message_id if not stable_id: return None event_type = _event_type(payload) app_id, _ = _app_ticket_fields(payload) tenant_key = header.get(FeishuPayloadKey.TENANT_KEY) or app_id or "unknown-tenant" event_key = ":".join( str(part) for part in (tenant_key, 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 _event_type(payload: dict[str, Any]) -> str: header = payload.get(FeishuPayloadKey.HEADER) or {} return str( header.get(FeishuPayloadKey.EVENT_TYPE) or payload.get(FeishuPayloadKey.EVENT_TYPE) or payload.get("type") or "" ).strip() def _app_ticket_fields(payload: dict[str, Any]) -> tuple[str, str]: header = payload.get(FeishuPayloadKey.HEADER) event = payload.get(FeishuPayloadKey.EVENT) data = payload.get(FeishuPayloadKey.DATA) candidates = [ value for value in (event, data, header, payload) if isinstance(value, dict) ] app_id = next( ( str(candidate.get(FeishuPayloadKey.APP_ID) or "").strip() for candidate in candidates if candidate.get(FeishuPayloadKey.APP_ID) ), "", ) ticket = next( ( str(candidate.get(APP_TICKET_PAYLOAD_KEY) or "").strip() for candidate in candidates if candidate.get(APP_TICKET_PAYLOAD_KEY) ), "", ) return app_id, ticket